New to Kestra?
Use blueprints to kickstart your first workflows.
Detect an empty Citus worker or skewed shard bytes, print the rebalance plan, run citus_rebalance_start behind an approval and verify the skew improved.
Add a worker to a Citus cluster and it starts empty. Citus does not move shards on its own, so the new node idles while the old ones carry every query and every byte. The same thing builds up without a new node, when a few large tenants land on the same worker. Queries then wait on the hottest worker, that worker fills its disk first while the cluster looks like it has room because the total is averaged over nodes that are not being used.
This blueprint reads the shard placement from the coordinator, works out the per-worker shard count and bytes. It reports a worker holding nothing or a byte skew over a threshold. It prints the exact moves Citus would make, which is a real dry run rather than a guess, then runs the rebalance behind an approval, polls it to completion and reads the placement again. A rebalance that did not improve the skew fails the run. So does one that leaves a worker empty.
This blueprint was created by zkasuran.
Four things the obvious version of this check gets wrong:
citus_shards joins pg_dist_node on groupid, so on a cluster with a read replica every shard on the primary's group is counted again for the replica. Verified: a coordinator with three primary workers and one streaming replica behind the first. citus_shards returned 5 rows and reported w19-citus-r1 as a fourth worker holding 7 shards at 20 MB, which reads as a four-way cluster that is out of balance. This flow reads pg_dist_placement and filters pg_dist_node to noderole = 'primary', so the same cluster comes back as 3 workers, 12 shards, 63 MB, skew 1.18x and OK.shouldhaveshards = false marks a node that is being drained on purpose. It is meant to be empty, so it is excluded from the emptiness check and reported as draining.by_disk_size adds citus.rebalancer_by_disk_size_base_cost, 100MB by default, to every shard group before it compares nodes. On a cluster whose shards are smaller than that constant, the constant dominates, the strategy degenerates into a shard count balance and the moves it picks can make the byte skew worse. Reproduced on a two-worker cluster: 7 shards at 33 MB against 5 at 31 MB, a byte skew of 1.07x, came back as 6 shards at 20 MB against 6 at 44 MB, a skew of 2.21x, with the job reporting finished. Citus's own get_rebalance_table_shards_plan returned zero moves at that same placement. This is why the flow re-reads the placement after the move and fails the run when the skew did not drop. Lower the setting on the coordinator (ALTER DATABASE ... SET citus.rebalancer_by_disk_size_base_cost = '1MB') or pass it in the JDBC URL as ?options=-c%20citus.rebalancer_by_disk_size_base_cost%3D1MB, to make by_disk_size compare real bytes.get_rebalance_table_shards_plan changes nothing. The whole detection path, including the plan, is read-only.auto_remediate is true, dry_run is false and the plan has at least one move. Even then it runs only after a Pause is resumed.shard_transfer_mode is explicit. block_writes works on any wal_level, auto needs wal_level=logical on the workers.distribution (io.kestra.plugin.jdbc.postgresql.Query, read-only) returns one row per active primary worker from pg_dist_placement, pg_dist_shard, pg_dist_partition and citus_shard_sizes(), with its movable shard count, pinned shard count, bytes and share of the total. A worker holding nothing still gets a row, which is the whole point.
assess classifies the cluster from the same data plus citus_rebalance_status():
| State | Meaning |
|---|---|
REBALANCE_IN_PROGRESS |
A rebalance job is scheduled, running, cancelling or failing |
EMPTY_WORKER |
A worker that accepts shards holds no movable shard while the cluster holds data |
IMBALANCED |
Largest worker bytes over smallest is above skew_threshold and the cluster is over the min_total_mb floor |
OK |
Everything else, including a single-worker cluster and a cluster under the floor |
report logs the verdict, the hottest and coldest worker, the skew, the fair share and the full per-worker table.
route (io.kestra.plugin.core.flow.If):
balanced logs it.alert posts to Slack.already_running stops the run when a rebalance is in flight.plan calls get_rebalance_table_shards_plan for the exact moves, with each shard's real size taken from citus_shard_sizes(). log_plan prints them.remediate_gate continues only with auto_remediate true, dry_run false and a non-empty plan. Otherwise reported_only says what would have moved.approve (io.kestra.plugin.core.flow.Pause) waits for a human.start_rebalance calls citus_rebalance_start, which queues the moves as a background job and returns its id.wait_rebalance (io.kestra.plugin.core.flow.LoopUntil) polls citus_rebalance_status() every 10 seconds up to rebalance_timeout. job_result fails the run on any end state other than finished.redistribution and reassess read the placement again. reassess wraps the assessment query with the before numbers, so improved is decided in SQL rather than by comparing rendered strings.verify fails the run unless every worker that accepts shards now holds a shard and the skew either dropped or reached OK.errors alerts Slack when the coordinator cannot be read.
citus_add_node is the one people forget.Shard placement drifts every time a node joins, a tenant grows or a table is distributed. Kestra reads the placement on a schedule, sends the skew and the empty workers to Slack, holds the data move behind an approval and keeps the plan, the job id and the before and after distribution on the execution. The read-only path is safe to run every night, which is how imbalance gets found before a worker's disk does.
On Kestra 2.0.5 OSS against Citus 12.1 on PostgreSQL 16.6, one coordinator and three workers, with a local HTTP sink standing in for Slack. Two distributed tables, events (8 shards, 200000 rows) and orders (4 shards, 50000 rows), plus one reference table, were created while only the first worker was registered, then the second and third workers were added so they held nothing. That is the real trigger for this check.
| Run | Inputs | Result |
|---|---|---|
| 1 | defaults | EMPTY_WORKER. 12 movable shards, 63 MB, all on w1, w2 holding nothing. Hottest share 100.0% against a fair share of 50.0%. Alert sent, 6 moves planned at 43.6 MB, nothing applied |
| 2 | auto_remediate: true, dry_run: true |
Same plan, nothing applied. psql confirms 12 shards still on w1 and zero rebalance jobs in pg_dist_background_job |
| 3 | auto_remediate: true, dry_run: false |
Paused, resumed, job 1 started, polled to finished. 6 shards moved, but w1 ended at 20 MB against w2 at 44 MB, a 2.22x byte skew. verify passed only because the emptiness condition had cleared |
| 4 | skew_threshold: 1.01, auto_remediate: true, dry_run: false |
The failure path. Plan had 1 move, job 3 reported finished and the cluster went from 1.07x to 2.21x. remediation_failed fired with the before and after numbers and the errors block alerted Slack |
| 5 | jdbc_url with citus.rebalancer_by_disk_size_base_cost=1MB |
Job 4 moved 1 shard: skew 2.21x to 1.07x, w1 7 shards at 33 MB against w2 5 at 31 MB. verify passed with the cluster OK |
| 6 | defaults | OK at 1.07x. psql agrees |
| 7 | defaults, after a third worker joined with no shards | EMPTY_WORKER caught: w3 at 0 bytes while w1 held 33 MB and w2 31 MB. 3 moves planned, nothing applied |
| 8 | same base_cost option, auto_remediate: true, dry_run: false |
Job 7 moved 3 shards. w3 23 MB, w2 20 MB, w1 20 MB, skew 1.18x, verify passed |
| 9 | defaults | OK across 3 workers |
| 10 | min_total_mb: 4096, skew_threshold: 1.01 |
OK with the floor note at a 2.22x byte skew, which proves the floor suppresses skew and not emptiness |
| 11 | defaults, cluster with w2 drained via citus_drain_node |
OK, w2 reported as draining and excluded, w1 shown at 100.0% of a fair share of 100.0% |
| 12 | defaults, w2 marked ready again while still empty |
EMPTY_WORKER caught for the second time |
| 13 | auto_remediate: true, dry_run: false, default base_cost, plan empty |
reported_only refused to start a rebalance Citus itself would not plan and named the base cost as the reason |
| 14 | defaults, a rebalance already running | REBALANCE_IN_PROGRESS with the job id and state. The run stopped without queuing a second one |
| 15 | defaults, a read replica registered behind w1 |
OK, 3 workers, 12 shards, 63 MB. citus_shards on the same cluster reports 5 rows and a phantom fourth worker |
| 16 | unreachable coordinator | errors alert sent |
Every placement number was checked against citus_shards, pg_dist_node and pg_dist_background_job from psql on the coordinator, not only from the Kestra logs.
citus_rebalance_start and citus_rebalance_status need Citus 11 or later.pg_dist_* and the Citus views for the detection path. Rebalancing needs a superuser or a role with rebalance_table_shards rights.shard_transfer_mode: auto or force_logical needs wal_level = logical on the workers and every shard needs a replica identity, which a primary key provides. Use block_writes when either is missing.citus.max_background_task_executors caps how many moves run at once.citus.rebalancer_by_disk_size_base_cost defaults to 100MB. Lower it before trusting by_disk_size on a cluster of small shards. Alternatively use by_shard_count and judge the result from the byte report this flow prints.POSTGRES_USERNAME / POSTGRES_PASSWORD: credentials for the coordinator.SLACK_WEBHOOK_URL: used by alert and the errors block.SECRET_.| Input | Default | Purpose |
|---|---|---|
jdbc_url |
jdbc:postgresql://localhost:5432/citus |
Coordinator |
skew_threshold |
1.25 |
Largest worker bytes over smallest that counts as imbalanced |
min_total_mb |
10 |
Below this total, skew is ignored |
rebalance_strategy |
by_disk_size |
by_disk_size or by_shard_count |
shard_transfer_mode |
auto |
auto, block_writes or force_logical |
max_shard_moves |
1000000 |
Cap on moves in one pass |
rebalance_timeout |
PT30M |
How long to poll the job |
auto_remediate |
false |
Allow a rebalance to start |
dry_run |
true |
Must be false before any shard moves |
POSTGRES_USERNAME, POSTGRES_PASSWORD and SLACK_WEBHOOK_URL secrets.jdbc_url to your coordinator.auto_remediate: true and dry_run: false, then resume the approve task to apply the plan.daily trigger, which always runs in safe mode.max_shard_moves to a handful and run it nightly, so a large cluster levels out over a week instead of in one long move.pg_dist_rebalance_strategy that weighs CPU or query volume instead of bytes, then pass its name as rebalance_strategy.citus.rebalancer_by_disk_size_base_cost on the coordinator and record what it was, so the day a cluster's shards grow past the constant does not change the plan without anyone noticing.citus_drain_node before a worker is decommissioned and use the same verify step to prove the node is empty.