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

Citus Reference Table Replica Guard

Detect Citus workers missing reference table placements, then run replicate_reference_tables behind an approval and re-read the metadata per worker.

Categories
DataInfrastructure

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.

Findings

Five things measured on the rig, each of which changes the design:

  • A node added with 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.
  • The missing replica blocks the rebalance, not just the join. 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.

Safety

  • The whole detection path, including the plan, reads pg_dist_partition, pg_dist_shard, pg_dist_placement, pg_dist_node and citus_shard_sizes(). Nothing is written.
  • The copy runs only when 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.
  • The run fails when the metadata still shows a short worker after the call, because a function that returns void is not evidence that anything landed.

How it works

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

  2. 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
  3. report logs the verdict, the coverage percentage, the named workers and the per-worker table.

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

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

What you get

  • Per-worker reference table coverage, with the short workers and the missing tables named.
  • The bytes a copy would move, before anything is copied, plus a budget that refuses an unexpectedly large one.
  • A cluster-wide replication that runs only after a human resumes it.
  • A per-worker re-read of the placement metadata that fails the run when the copy did not land.

Who it's for

  • Teams scaling a Citus cluster out, where citus_add_node is the step people remember and this is the step they do not.
  • Platform teams whose rebalance onto a new node fails with a missing relation error and who need the cause named.
  • Anyone running multi-tenant Citus where a lookup table is a reference table and a join silently stops being local.

Why orchestrate this with Kestra

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.

Tested end to end

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

Prerequisites

  • A Citus cluster, version 11 or later, reached through its coordinator. replicate_reference_tables exists from Citus 9.3. Its shard_transfer_mode argument needs Citus 11.
  • At least one reference table, created with create_reference_table.
  • A role that can read 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.

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

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 coverage and the planned copies.
  4. Run it with auto_remediate: true and dry_run: false, then resume the approve task to copy.
  5. Enable the daily trigger, which always runs in safe mode.

How to extend

  • Chain it in front of a rebalance flow, so a new node gets its reference tables before any shard is moved onto it.
  • Add a 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.
  • Lower 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.
  • Send the per-worker coverage to your metrics store on every run, so the gap between a node joining and the copy landing becomes a number you can alert on.
  • Run it after every citus_add_node from the same flow that adds the node, with auto_remediate on. Keep the nightly schedule as the backstop.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.