Query icon
Log icon
If icon
SlackIncomingWebhook icon
Pause icon
LoopUntil icon
Fail icon
Schedule icon

Citus Shard Imbalance and Rebalance Guard

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.

Categories
DataInfrastructure

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.

Findings

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.
  • A reference table is copied to every worker and a Citus local table lives on one. The rebalancer moves neither. Counting them hides a genuinely empty worker, because a new node receives reference tables as soon as it joins. Shards are split into movable and pinned. Only movable ones count toward skew.
  • 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.
  • A rebalance that starts does not guarantee improvement. 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.

Safety

  • get_rebalance_table_shards_plan changes nothing. The whole detection path, including the plan, is read-only.
  • A move runs only when 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.
  • A rebalance already in flight stops the run. Citus runs one at a time.
  • shard_transfer_mode is explicit. block_writes works on any wal_level, auto needs wal_level=logical on the workers.
  • The run fails when the placement did not improve, because a finished rebalance is not the same thing as a balanced cluster.

How it works

  1. 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.

  2. 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
  3. report logs the verdict, the hottest and coldest worker, the skew, the fair share and the full per-worker table.

  4. route (io.kestra.plugin.core.flow.If):

    • OK: balanced logs it.
    • anything else: 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.
  5. errors alerts Slack when the coordinator cannot be read.

What you get

  • Per-worker shard count, bytes and share of the total, with empty workers named.
  • The exact shard moves Citus would make, with each shard's real size, before anything is touched.
  • A rebalance that only runs after a human resumes it, polled to completion rather than fired and forgotten.
  • A re-read of the placement that fails the run when the move did not help.

Who it's for

  • Teams scaling a Citus cluster out, where the step after citus_add_node is the one people forget.
  • Platform teams whose multi-tenant shards grow unevenly because a few tenants are much larger than the rest.
  • Anyone who wants a record of what moved, when, with the before and after distribution.

Why orchestrate this with Kestra

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.

Tested end to end

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.

Prerequisites

  • A Citus cluster, version 11 or later, reached through its coordinator. citus_rebalance_start and citus_rebalance_status need Citus 11 or later.
  • A role that can read 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.
  • The background task daemon has to be running on the coordinator, which it is by default. 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.

Secrets

  • POSTGRES_USERNAME / POSTGRES_PASSWORD: credentials for the coordinator.
  • SLACK_WEBHOOK_URL: used by alert and the errors block.
  • In Kestra OSS, provide each one as a base64-encoded environment variable prefixed SECRET_.

Inputs

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

Quick start

  1. Add the POSTGRES_USERNAME, POSTGRES_PASSWORD and SLACK_WEBHOOK_URL secrets.
  2. Import the flow and set jdbc_url to your coordinator.
  3. Run it once with the defaults and read the per-worker table and the planned moves.
  4. Run it with auto_remediate: true and dry_run: false, then resume the approve task to apply the plan.
  5. Enable the daily trigger, which always runs in safe mode.

How to extend

  • Set 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.
  • Register a custom strategy in pg_dist_rebalance_strategy that weighs CPU or query volume instead of bytes, then pass its name as rebalance_strategy.
  • Lower 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.
  • Run the detection path against each colocation group separately, so one huge table does not hide skew in a small one.
  • Pair it with a flow that calls citus_drain_node before a worker is decommissioned and use the same verify step to prove the node is empty.
  • Record each run's skew in the KV store, so a cluster that drifts back within days shows up as a tenant problem rather than a placement problem.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.