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

Citus Colocation Violation Guard

Detect Citus tables that drifted into different colocation groups, re-colocate them with update_distributed_table_colocation and verify colocationid changed.

Categories
DataInfrastructure

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.

Findings

Five things that decided how this check is built:

  • The fix is metadata only when the shards already line up. 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.
  • A Citus function reporting success does not mean it achieved anything. 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.
  • Citus refuses some pairs itself, so the audit has to refuse them first. 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.
  • Citus checks the pair, it does not check what already depends on the pair. It refuses to create a foreign key between two tables in different colocation groups, but 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.
  • Whether a split pair is slow or broken depends on one setting. With 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.

Safety

  • The whole detection path is SELECT only against pg_dist_partition, pg_dist_shard, pg_attribute and current_setting.
  • A change runs only when 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.
  • Only tables named in expected_groups are ever touched. A pair that is already correct is not re-issued.
  • A pair Citus would refuse is reported and left alone. The flow never reaches for alter_distributed_table, which rewrites data.
  • The run fails when colocationid did not change, so a silent no-op cannot be reported as a fix.

How it works

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

  2. 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
  3. 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.

  4. report logs the verdict and every pair with both group ids, the distribution column and the shard count.

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

    • OK: colocated lists the pairs that are correct.
    • anything else: 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.
  6. errors alerts Slack when the coordinator cannot be read.

What you get

  • One row per declared pair with both 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.
  • The exact statements the flow would run, before anything changes.
  • A re-colocation that only runs after a human resumes it with an explicit yes.
  • A re-read of pg_dist_partition that fails the run when the group did not change.
  • A plain statement of whether a split pair currently makes joins slow or makes them error, read from the live setting.

Who it's for

  • Teams on Citus whose multi-tenant tables are expected to join on the tenant key, where one table created with colocate_with => 'none' silently costs every join.
  • Platform teams who restore or rebuild one distributed table at a time and need to prove the groups still line up afterwards.
  • Anyone blocked by cannot create foreign key constraint since relations are not colocated on a migration that passed in staging.

Why orchestrate this with Kestra

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.

Tested end to end

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.

Prerequisites

  • A Citus cluster, version 10 or later, reached through its coordinator. update_distributed_table_colocation was added in Citus 10.
  • A role that can read pg_dist_partition, pg_dist_shard and pg_attribute for the detection path.
  • The re-colocation needs the owner of both tables or a superuser. A role that owns neither is rejected and the re-read turns that into a failed run rather than a silent pass.
  • The two tables in a pair must already agree on distribution column type and shard count. Pairs that do not are reported, never forced.
  • citus.enable_repartition_joins is off by default, so a split pair usually shows up as a planner error rather than a slow query.

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

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. Replace expected_groups with the tables you actually join together. Run it once with the defaults and read the per-pair table.
  4. Run it with auto_remediate: true and dry_run: false, then resume the approve task with approved: true to apply the plan.
  5. Enable the daily trigger, which always runs in safe mode.

How to extend

  • Derive expected_groups from the foreign keys you already have or from pg_stat_statements, so the declaration is not maintained by hand.
  • Add the foreign key you actually wanted as a follow-up task, now that the pair is colocated. Let it fail the run if it is still rejected.
  • Record each run's group ids in the KV store, so the day a table changes group shows up as an event rather than as a slow query weeks later.
  • Pair it with a check on citus.enable_repartition_joins, so a cluster that hides this class of bug behind repartitioning is flagged on its own.
  • Point the same audit at a staging cluster before a release, so a migration that needs a foreign key is proved possible before the window opens.
  • Extend the verdicts with alter_distributed_table as a separate, heavier flow for the pairs this one refuses, keeping the data rewriting decision apart from the metadata one.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.