New to Kestra?
Use blueprints to kickstart your first workflows.
Detect Citus workers stuck inactive in pg_dist_node, health check them, activate the reachable ones with citus_activate_node and verify isactive flipped.
A Citus worker goes unreachable for ten minutes. Something takes it out of the cluster. Maybe citus_disable_node run by an operator or by a failover tool. Maybe a node registered with citus_add_inactive_node whose activation step never finished. Either way pg_dist_node now holds a row with isactive = false.
The node comes back. The flag does not. Citus has no failure detector that walks it back, so the coordinator keeps routing nothing to that worker. Distributed queries fan out to fewer nodes, create_distributed_table never places a shard there and a rebalance cannot move work onto it. Nothing errors. The cluster is simply narrower than the architecture diagram says. The only symptom is that everything is a little slower and a lot less resilient than it looks.
This blueprint reads every row of pg_dist_node, proves with citus_check_connection_to_node which inactive primaries answer a connection right now, then calls citus_activate_node behind a dry run and an approval for those nodes only. It re-reads isactive and fails the run if a target is still inactive. A node that does not answer the probe is reported and never activated.
This blueprint was created by zkasuran.
Six things measured on a live cluster that decided how this flow is built:
citus_check_connection_to_node is the right probe and it does not raise. It opens a real connection from the coordinator to the node and returns false with a warning when it cannot, so one read-only query classifies every inactive node without a shell task and without aborting on the first dead host. Measured against a stopped worker it returned f; against a live one, t.citus_activate_node raises on an unreachable node rather than quietly doing nothing. That sounds safe enough to skip the probe. It is not. Without the probe the flow turns a known-down node into a failed execution and a pager alert that reads like the flow is broken. Inside a loop the raise would hide the nodes that could have been fixed. With the probe, a node that is still down is a clean reported outcome and the healthy nodes in the same run are still repaired.citus_add_node leaves no row at all, so the inactive row has a different origin. Running citus_add_node against a stopped worker raised a connection error and rolled the whole thing back: pg_dist_node was unchanged. So an isactive = false row is not provisioning debris. It was put there deliberately by citus_add_inactive_node or citus_disable_node, which is exactly why the flow ships an exclude_nodes list for nodes that are staged on purpose.citus_disable_node refuses a worker that holds the only copy of a shard. It answers cannot remove or disable the node ... because it contains the only shard placement for shard N. On a single worker cluster it refuses again with disabling the first worker node in the metadata is not allowed. Drain the node first and it succeeds. So the inactive state belongs to a node that was drained, replicated or never loaded, which is also why the flow reports the shard placements recorded in an inactive node's group instead of assuming there are none.citus_drain_node sets shouldhaveshards = false on its way out. citus_activate_node flips isactive back and syncs the metadata, but it leaves shouldhaveshards alone. The node then answers queries for shards it already holds while no new shard is ever placed on it, which looks healthy in pg_dist_node and still costs capacity. So the re-read reports shouldhaveshards per node and says in plain words when citus_set_node_property is still needed.SELECT only against pg_dist_node, pg_dist_placement and the two Citus health functions.auto_remediate is true, dry_run is false, the probe found at least one inactive node that answered, a human resumed the approve task and that resume carried approved: true. An unanswered resume is not an approval.noderole other than primary are never acted on. Neither is the coordinator row in group 0.exclude_nodes is reported and left alone.citus.node_connection_timeout, so probe_timeout bounds the whole pass and a hung cluster reports through the errors block instead of running forever.isactive did not flip, so a silent no-op cannot be reported as a repair.audit (io.kestra.plugin.jdbc.postgresql.Query, read-only) lists every row of pg_dist_node with its role, isactive, metadatasynced, whether it is the coordinator group and how many shard placements sit in its group, from pg_dist_placement.
Each node gets a verdict:
| Verdict | Meaning |
|---|---|
ACTIVE |
In service. Queries fan out to it |
INACTIVE |
A primary with isactive = false. The candidate set |
PARKED |
Inactive and named in exclude_nodes. Staged on purpose, never touched |
COORDINATOR_INACTIVE |
The group 0 row. Reported, never activated by this flow |
NOT_PRIMARY |
A secondary. Out of scope |
healthcheck probes the INACTIVE nodes with citus_check_connection_to_node, so a healthy cluster runs zero probes. It returns the state (OK, REACTIVATE, UNREACHABLE), the counts, the target list for the loop and the exact statements for the dry run.
report logs how wide the cluster currently fans out against how wide it was provisioned, then one line per node.
route (io.kestra.plugin.core.flow.If):
all_active lists the active nodes and any parked ones.alert posts to Slack.unreachable_only ends the run when no inactive node answered, naming them and saying why activating them would make things worse.log_plan prints the citus_activate_node 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.remediate_nodes (io.kestra.plugin.core.flow.Loop, concurrencyLimit: 1) runs one citus_activate_node per target with allowFailure, so one refusal does not hide the rest.recheck re-reads isactive for the targeted nodes and asks every active node for a trivial answer through run_command_on_all_nodes.verify logs the before and after. Otherwise remediation_failed fails the run with the nodes that are still inactive.errors alerts Slack when the coordinator cannot be read or the probe pass hits its ceiling.
isactive, the role, the group and the shard placements recorded against it, so an inactive worker is visible next to the ones carrying the load.pg_dist_node that fails the run when isactive did not flip, plus a count of how many nodes answered a query afterwards.citus_add_inactive_node as part of provisioning and need the activation step to be auditable rather than remembered.Nothing in Citus walks isactive back after an outage. Nothing warns that it is still false. The gap is invisible until someone reads pg_dist_node by hand. A schedule closes it: read the membership every morning, send the degraded cluster to Slack, hold the activation behind an approval and keep the probe result plus the before and after isactive on the execution, so the day the cluster got narrower is a record rather than a guess.
On Kestra 2.0.5 OSS against Citus 12.1 on PostgreSQL 16, with a coordinator and two workers on one Docker network and a local HTTP sink standing in for Slack. orders was distributed over 8 shards. The first worker stayed active and untouched in every run, which is what proves the flow is precise rather than lucky. The second worker was taken out of the cluster twice, once with citus_add_inactive_node and once with citus_disable_node after a drain. Its container was stopped or started to drive the two health paths.
| Run | Inputs | Result |
|---|---|---|
| 1 | defaults, inactive worker running | REACTIVATE. 2 of 3 primary nodes active, one node answered the probe, one statement planned, nothing applied |
| 2 | auto_remediate: true, dry_run: true |
Same plan, nothing applied. psql confirms isactive still false |
| 3 | armed, resumed with no answer | declined. Nothing changed |
| 4 | armed, approved, worker stopped during the approval window | The re-verify failure. citus_activate_node raised, recheck read isactive false again, remediation_failed failed the run, Slack alerted |
| 5 | armed, worker still down | The refusal. The probe returned false, unreachable_only ended the run, no plan was built, no activation attempted. psql confirms isactive still false |
| 6 | armed, approved, worker running | isactive false to true with metadatasynced true. 3 of 3 nodes answered run_command_on_all_nodes and verify passed |
| 7 | defaults, straight after run 6 | OK. 3 of 3 primary nodes active, zero probes run |
| 8 | armed, the node named in exclude_nodes |
OK with the node reported PARKED, never probed, never activated |
| 9 | armed, approved, node drained before it went inactive | Activated, then flagged: shouldhaveshards is still false, so the log says citus_set_node_property is still needed |
| 10 | armed, approved, shouldhaveshards true |
Activated with nothing left to do |
| 11 | armed, with an inactive secondary in pg_dist_node pointing at a host that does not exist |
OK. Reported NOT_PRIMARY, never probed, never activated |
| 12 | unreachable coordinator | audit failed and the errors block alerted |
Every isactive value in that table was read from pg_dist_node in psql on the coordinator, not only from the Kestra logs. The capacity payoff was measured the same way. While the worker sat inactive, get_rebalance_table_shards_plan('orders') returned 0 moves, because a rebalance has nowhere to put a shard. All 8 shards sat on one worker. After the flow activated the node the same call planned 4 moves, the rebalance ran, citus_shards showed 4 shards on each worker, SELECT count(*) FROM orders still returned all 2000 rows and EXPLAIN listed the reactivated host in the plan. The detector was proved the other way too: 1 INACTIVE row on the degraded cluster, 0 once every primary was active, with the probe skipped entirely in that case.
citus_activate_node and citus_check_connection_to_node available. Both are present in Citus 12.1, which is the version this was tested on.pg_dist_node and pg_dist_placement for the detection path.citus_activate_node needs a superuser. A role without it is rejected and the re-read turns that into a failed run rather than a silent pass.pg_dist_node, which is what the probe tests.citus.node_connection_timeout, 30 seconds by default, bounds each probe of a dead node. Keep probe_timeout above the worst case for your node count.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 |
exclude_nodes |
[] |
host:port entries that are inactive on purpose |
auto_remediate |
false |
Allow a node to be activated |
dry_run |
true |
Must be false before any node is activated |
probe_timeout |
PT5M |
Ceiling on the whole reachability pass |
POSTGRES_USERNAME, POSTGRES_PASSWORD and SLACK_WEBHOOK_URL secrets.jdbc_url to your coordinator.exclude_nodes.auto_remediate: true and dry_run: false, then resume the approve task with approved: true to activate the nodes that answered.daily trigger, which always runs in safe mode.metadatasynced, so a node that is active but out of sync is caught in the same pass.