New to Kestra?
Use blueprints to kickstart your first workflows.
Detect Citus workers missing reference table placements, then run replicate_reference_tables behind an approval and re-read the metadata per worker.
A Citus reference table is meant to exist in full on every worker. That is what lets a join between a distributed table and a reference table run locally on each node instead of shipping rows across the cluster. It is also what lets a foreign key from a distributed table to that reference table be enforced where the shard lives. Add a worker with citus_add_node and Citus does not copy the existing reference tables to it. Nothing errors at add time. The cluster looks bigger, the new node is in pg_dist_node as an active primary. The reference tables are simply not there.
This blueprint crosses every reference table shard with every active primary worker and asks pg_dist_placement whether that pair exists, so a worker that holds nothing still gets a row. It names the worker and the table, adds up the bytes a copy would move, then calls replicate_reference_tables behind an approval and reads the placement metadata again per worker. A call that returns without error while a worker is still short fails the run.
This blueprint was created by zkasuran.
Five things measured on the rig, each of which changes the design:
citus_add_node gets no reference tables, even with citus.replicate_reference_tables_on_activate left at its default of on. Verified on Citus 12.1: a cluster with two reference tables and one worker, then a second worker added, came back with 2 placements on the first worker and 0 on the second. So the default configuration is already the broken case, which is why this check is worth running on a schedule rather than only after a known config change.citus_move_shard_placement of a distributed table shard onto the new worker failed with ERROR: relation "public.countries_102008" does not exist, CONTEXT: while executing command on <new worker>, because the shard's foreign key to the reference table cannot be created there. The same CREATE TABLE ... REFERENCES <reference table> that failed while the worker was short succeeded once the copy had run. A new node therefore stays unusable for distributed data until the reference tables are on it.replicate_reference_tables() defaults to shard_transfer_mode => 'auto', which fails outright on a stock cluster. On default wal_level = replica it returned ERROR: logical decoding requires wal_level >= logical after printing the NOTICE that it was replicating. block_writes works on any wal_level, so it is the default here. The copy is real IO over the network, which is what the approval and the byte budget are for.shouldhaveshards = false does not exclude a node. A worker marked as draining still received the reference tables and still needs them, because reference tables are not what the rebalancer moves. The check covers every active primary worker and reports the draining flag for context instead of skipping the node.replicate_reference_tables returns void over a placement that exists but is not healthy. A placement row left at shardstate = 3 was not repaired and the call still returned success, so the cluster stayed at 1 of 2 tables on that worker. The check counts a placement as present only at shardstate = 1. The re-read after the call turns that silence into a failed run.One trap worth knowing while confirming any of this by hand: Citus hides shard tables from pg_class for ordinary clients, so \dt on a worker shows the shell tables and no shards at all. SET citus.show_shards_for_app_name_prefixes = '*' reveals countries_102008 and friends.
pg_dist_partition, pg_dist_shard, pg_dist_placement, pg_dist_node and citus_shard_sizes(). Nothing is written.auto_remediate is true, dry_run is false, the plan is not empty and the bytes fit max_copy_mb. Even then it runs only after the Pause is resumed.shard_transfer_mode is explicit. block_writes blocks writes on the reference tables while they copy and needs nothing from wal_level.coverage (io.kestra.plugin.jdbc.postgresql.Query, read-only) crosses every reference table shard with every active primary worker and returns one row per worker: how many reference tables it should hold, how many it holds, which ones are missing and the bytes behind them. partmethod = 'n' in pg_dist_partition is what identifies a reference table.
assess turns the same data into a verdict:
| State | Meaning |
|---|---|
MISSING_REPLICAS |
At least one active primary worker has no healthy placement for at least one reference table |
OK |
Every active primary worker holds every reference table, including the cases of no workers and no reference tables |
report logs the verdict, the coverage percentage, the named workers and the per-worker table.
route (io.kestra.plugin.core.flow.If):
complete logs it.MISSING_REPLICAS: alert posts to Slack.plan lists every worker and table pair with no healthy placement, with the bytes that copy would move. Citus has no plan function for reference replication, so the plan comes from the metadata. log_plan prints it.remediate_gate continues only with auto_remediate true, dry_run false, a non-empty plan and the bytes inside max_copy_mb. Otherwise reported_only says what would have been copied and why it was not.approve (io.kestra.plugin.core.flow.Pause) waits for a human.replicate calls replicate_reference_tables(shard_transfer_mode). The call is cluster-wide: it copies every under-replicated reference table to every short worker in one go, rather than per table or per node.recoverage and reverify read the metadata again. reverify wraps the assessment with the before numbers so fixed is decided in SQL, not by comparing rendered strings.verify fails the run unless every active primary worker now holds every reference table.errors alerts Slack when the coordinator cannot be read.
citus_add_node is the step people remember and this is the step they do not.Reference table coverage drifts the moment a node joins. Nothing in the cluster complains until a rebalance or a foreign key fails. Kestra reads the metadata on a schedule, sends the short workers to Slack, holds the copy behind an approval and keeps the plan, the byte count and the before and after coverage on the execution. The read-only path is safe to run every night, which is how the gap gets found before the next rebalance does.
On Kestra 2.0.5 OSS against Citus 12.1 on PostgreSQL 16, one coordinator and two workers, with a local HTTP sink standing in for Slack. Two reference tables (countries, currencies) and one distributed table (orders, 4 shards, 2000 rows) were created while only the first worker was registered, then a second worker was added with citus_add_node so it joined with no reference table placements. Every number below was checked with psql against pg_dist_placement and pg_dist_node on the coordinator, not only from the Kestra logs.
| Run | Inputs | Result |
|---|---|---|
| 1 | defaults | MISSING_REPLICAS. 2 of 4 placements present, 50.0% coverage, the new worker missing both tables. Alert sent, 2 copies planned at 64 kB, nothing applied |
| 2 | auto_remediate: true, dry_run: true |
Same plan, nothing applied. psql confirms 2 placements on the first worker and 0 on the second |
| 3 | auto_remediate: true, dry_run: false |
Paused, resumed, copied with block_writes. 2 missing placements before, 0 after, coverage 50.0% to 100.0%. psql confirms both reference tables on both workers and the first worker's 4 distributed shards untouched |
| 4 | defaults, healthy cluster | OK, 4 of 4 placements, no alert, nothing planned |
| 5 | auto_remediate: true, dry_run: false, approval killed instead of resumed |
Execution killed at the Pause. replicate never ran and psql still showed 0 placements on the new worker |
| 6 | auto_remediate: true, dry_run: false, max_copy_mb: 0 |
reported_only refused the copy over the budget and named the byte count. No Pause, no copy |
| 7 | shard_transfer_mode: auto |
replicate raised logical decoding requires wal_level >= logical on a default wal_level. The run failed, the errors block alerted Slack and the placement counts were unchanged |
| 8 | auto_remediate: true, dry_run: false, one placement row forced to shardstate = 3 |
The re-verify failure. replicate_reference_tables returned void and changed nothing. The re-read found 1 placement still missing, remediation_failed fired with the before and after numbers and the errors block alerted Slack |
| 9 | unreachable coordinator | coverage failed on connect and the errors block alerted Slack |
replicate_reference_tables exists from Citus 9.3. Its shard_transfer_mode argument needs Citus 11.create_reference_table.pg_dist_* and call citus_shard_sizes() for the detection path. The copy needs a role that can write on the workers, usually a superuser.shard_transfer_mode: auto or force_logical needs wal_level = logical on the coordinator. Use block_writes when it is not set, which is the default here.replicate_reference_tables blocks until the copy finishes, so size max_copy_mb and the run window to the reference tables you actually have.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 |
shard_transfer_mode |
block_writes |
block_writes, auto or force_logical |
max_copy_mb |
1024 |
Refuse a copy larger than this |
auto_remediate |
false |
Allow the copy to run |
dry_run |
true |
Must be false before anything is copied |
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 copy.daily trigger, which always runs in safe mode.run_command_on_workers step that checks the shard table really exists on each worker, since replicate_reference_tables trusts the metadata and will not repair a placement row whose table was dropped by hand.max_copy_mb to the size of your largest reference table, so a table that grows past it shows up as a refusal rather than as a long copy.citus_add_node from the same flow that adds the node, with auto_remediate on. Keep the nightly schedule as the backstop.