New to Kestra?
Use blueprints to kickstart your first workflows.
Detect Citus tables that drifted into different colocation groups, re-colocate them with update_distributed_table_colocation and verify colocationid changed.
Citus colocates two distributed tables when they share a distribution column type and a shard count. That is what lets a join between them run locally on every worker, with no data crossing the network. Create one of them with colocate_with => 'none', load the two through different paths or restore one from a dump and they land in different colocation groups while looking identical in every other way.
Nothing errors at create time. The cost shows up later and somewhere else. Every join between the two tables becomes a repartition join that ships rows between workers. It fails outright if citus.enable_repartition_joins is off, which is the default. A foreign key between them is rejected with "cannot create foreign key constraint since relations are not colocated", so a migration that worked in staging stops at the gate in production.
This blueprint takes the groups of tables you expect to join as an input, reads colocationid for each of them from pg_dist_partition then reports the pairs that drifted apart. It calls update_distributed_table_colocation behind a dry run and an approval, then reads colocationid again and fails the run if the pair is still split.
This blueprint was created by zkasuran.
Five things that decided how this check is built:
update_distributed_table_colocation rewrites pg_dist_partition.colocationid and the pg_dist_colocation row count. It copies nothing and moves no shard, because shard N of both tables already lives on the same worker by construction: same distribution column type, same shard count, same hash ranges. That is worth stating plainly, because the neighbouring operation is a shard rebalance, which does move bytes between workers and takes as long as the data is large. This one returned in milliseconds on every pair tested.update_distributed_table_colocation returns void. There is no row count, no job id, nothing to branch on. So the flow re-reads colocationid from pg_dist_partition for exactly the tables it targeted and fails the run when any of them is still outside the anchor's group. The function's return is never the gate.update_distributed_table_colocation('shipments', colocate_with => 'orders') raises cannot colocate tables orders and shipments with Shard counts don't match. The same call across a bigint and a text distribution column raises Distribution column types don't match. A pair that disagrees on either is reported as refused, never attempted. Fixing one of those means alter_distributed_table, which does rewrite the data, so it is a different decision and does not belong behind this approval.update_distributed_table_colocation does not look for foreign keys at all. Measured: with a valid FOREIGN KEY (tenant_id, order_id) from order_items to orders, moving order_items into a third table's group succeeded. The constraint was still there afterwards with convalidated = true, now spanning two colocation groups. So this call can quietly undo the thing a colocation group was set up to allow. That is the reason the change sits behind an approval even though it is cheap. It is also why the flow only ever moves a table toward an anchor you named.citus.enable_repartition_joins off, which is the shipped default, a join across colocation groups does not merely repartition, the planner raises the query contains a join that requires repartitioning. With it on, the same query runs as two MapMergeJob stages. The flow reads the setting and says which of the two a reader is looking at, because the symptom people report is either "this query is slow" or "this query errors" and both come from the same cause.SELECT only against pg_dist_partition, pg_dist_shard, pg_attribute and current_setting.auto_remediate is true, dry_run is false, the audit found at least one colocatable pair, a human resumed the approve task and that resume carried approved: true. An unanswered resume is not an approval.expected_groups are ever touched. A pair that is already correct is not re-issued.alter_distributed_table, which rewrites data.colocationid did not change, so a silent no-op cannot be reported as a fix.audit (io.kestra.plugin.jdbc.postgresql.Query, read-only) expands expected_groups into one row per pair and joins each side to pg_dist_partition by to_regclass, so a schema qualified or quoted name resolves the way Postgres resolves it. Each row carries both colocationid values, the distribution column from column_to_column_name, its format_type and the shard count from pg_dist_shard.
Each pair gets a verdict:
| Verdict | Meaning |
|---|---|
COLOCATED |
Both sides share one colocationid. Joins plan locally per shard |
VIOLATION |
Same distribution column type and shard count, different colocationid. Fixable, which is what this flow fixes |
SHARD_COUNT_MISMATCH |
Shard counts differ. Citus refuses to colocate them |
TYPE_MISMATCH |
Distribution column types differ. Citus refuses to colocate them |
UNKNOWN_TABLE |
One side is not a hash distributed Citus table on this coordinator |
assess rolls the pairs up into VIOLATION, NOT_COLOCATABLE or OK, builds the target list for the loop, writes out the exact statements for the dry run and reads citus.enable_repartition_joins.
report logs the verdict and every pair with both group ids, the distribution column and the shard count.
route (io.kestra.plugin.core.flow.If):
colocated lists the pairs that are correct.alert posts to Slack.cannot_colocate ends the run when nothing is fixable, naming what Citus would refuse and why.log_plan prints the update_distributed_table_colocation statements, which is the dry run.remediate_gate continues only with auto_remediate true and dry_run false. Otherwise reported_only says what would have changed.approve (io.kestra.plugin.core.flow.Pause) waits for a human. approval_result stops at declined unless the resume carried approved: true.recolocate (io.kestra.plugin.core.flow.Loop, concurrencyLimit: 1) runs one update_distributed_table_colocation per target, with allowFailure so one refusal does not hide the rest.recheck re-reads colocationid for the targeted tables and compares it with the anchor again.verify logs the before and after groups. Otherwise remediation_failed fails the run with the tables that are still split.errors alerts Slack when the coordinator cannot be read.
colocationid values, the distribution column, its type and the shard count, so the reason a pair is split is on screen next to the pair.pg_dist_partition that fails the run when the group did not change.colocate_with => 'none' silently costs every join.cannot create foreign key constraint since relations are not colocated on a migration that passed in staging.Colocation drifts quietly. It drifts when a table is rebuilt, when a shard count is set by hand, when a dump is restored into a fresh cluster. Nothing raises at the time and the join that pays for it may not run until month end. Kestra reads the declaration against the live metadata on a schedule, sends the split pairs to Slack, holds the metadata change behind an approval and keeps the before and after group ids on the execution, so the day a join gets slow there is a record of when the groups diverged.
On Kestra 2.0.5 OSS against Citus 12.1.6 on PostgreSQL 16, single node with the coordinator holding shards, with a local HTTP sink standing in for Slack. Six distributed tables: orders (bigint, 8 shards), order_items (bigint, 8 shards, created with colocate_with => 'none', the planted violation), customers and customer_events (bigint, 8 shards, correctly colocated with each other), shipments (bigint, 4 shards) and regions (text, 8 shards).
| Run | Inputs | Result |
|---|---|---|
| 1 | defaults | VIOLATION. order_items in group 2 against orders in group 1, customers and customer_events both group 3 and untouched. Alert sent, one statement planned, nothing applied |
| 2 | only the correctly colocated pair declared | OK. The detector reports zero on a clean declaration, which is what makes run 1 mean something |
| 3 | auto_remediate: true, dry_run: true |
Same plan, nothing applied. psql confirms order_items still in group 2 |
| 4 | auto_remediate: true, dry_run: false |
Paused, resumed with approved: true. order_items moved from group 2 to group 1, recheck read it back from pg_dist_partition and verify passed |
| 5 | defaults, straight after run 4 | OK. Both pairs colocated |
| 6 | auto_remediate: true, dry_run: false, resumed with no answer |
declined. Nothing changed, group ids unmoved |
| 7 | declaration pairing orders with shipments and regions |
NOT_COLOCATABLE. Reported as a shard count mismatch (8 against 4) and a type mismatch (text against bigint), alert sent, nothing attempted |
| 8 | auto_remediate: true, dry_run: false as a role that does not own the table |
The failure path. update_distributed_table_colocation was rejected, recheck found the table still in its old group and remediation_failed failed the run with the before and after ids |
| 9 | unreachable coordinator | errors alert sent |
The payoff was measured directly on the same cluster, not inferred. While order_items sat in group 2, ALTER TABLE order_items ADD CONSTRAINT ... FOREIGN KEY (tenant_id, order_id) REFERENCES orders (tenant_id, order_id) was rejected with cannot create foreign key constraint since relations are not colocated or not referencing a reference table. SELECT count(*) FROM orders JOIN order_items USING (tenant_id, order_id) was rejected with the query contains a join that requires repartitioning. After the flow re-colocated the table, the same foreign key was accepted and the same join planned as Citus Adaptive, Task Count: 8 with a local hash join inside each shard pair. Every group id in the table above was read from pg_dist_partition in psql on the coordinator, not only from the Kestra logs.
update_distributed_table_colocation was added in Citus 10.pg_dist_partition, pg_dist_shard and pg_attribute for the detection path.citus.enable_repartition_joins is off by default, so a split pair usually shows up as a planner error rather than a slow query.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 |
expected_groups |
two example groups | Lists of table names that should colocate, first entry of each is the anchor |
auto_remediate |
false |
Allow a colocation group to be changed |
dry_run |
true |
Must be false before any group is changed |
POSTGRES_USERNAME, POSTGRES_PASSWORD and SLACK_WEBHOOK_URL secrets.jdbc_url to your coordinator.expected_groups with the tables you actually join together. Run it once with the defaults and read the per-pair table.auto_remediate: true and dry_run: false, then resume the approve task with approved: true to apply the plan.daily trigger, which always runs in safe mode.expected_groups from the foreign keys you already have or from pg_stat_statements, so the declaration is not maintained by hand.citus.enable_repartition_joins, so a cluster that hides this class of bug behind repartitioning is flagged on its own.alter_distributed_table as a separate, heavier flow for the pairs this one refuses, keeping the data rewriting decision apart from the metadata one.