New to Kestra?
Use blueprints to kickstart your first workflows.
Detect PostgreSQL constraints left NOT VALID, validate them behind an approval gate, then re-read pg_constraint to confirm each one is now trusted.
ALTER TABLE ... ADD CONSTRAINT ... NOT VALID is the standard way to add a FOREIGN KEY or a CHECK to a busy table without a long lock. PostgreSQL starts enforcing it on new rows straight away and skips the scan of the rows already there. The migration is supposed to come back later with VALIDATE CONSTRAINT. That second step gets forgotten. pg_constraint.convalidated then stays false for months with nobody looking at it. Two things are then quietly wrong. The data may already break a rule the application believes is enforced. The planner also refuses to trust an unvalidated CHECK, so constraint exclusion stops working and partition pruning on that table silently degrades. This guard reads the catalog, reports every constraint still in that state with the size of the table behind it, validates them under a gate and proves the catalog now says they are trusted.
This blueprint was created by zkasuran.
dry_run is true and auto_remediate is false, so a plain run reports and validates nothing.auto_remediate is true and dry_run is false. It then waits for a human to resume the Pause with approved set true. An unanswered gate reads as not approved.VALIDATE CONSTRAINT is not free. It takes a SHARE UPDATE EXCLUSIVE lock on the table and reads every row, so it blocks DDL and other maintenance on that table for the length of a full scan. Reads and writes keep working. The log and the Slack message carry each table's data size and estimated row count before the gate, so the cost is visible at approval time.max_table_mb is a ceiling on which tables take part. Anything larger is still reported but left out of the validation set, so an unattended run cannot start a scan of a table far bigger than the window allows.detect (io.kestra.plugin.jdbc.postgresql.Query) reads pg_constraint joined to pg_class and pg_namespace for rows where convalidated is false and contype is f or c. Each row carries pg_get_constraintdef, the table data size from pg_table_size, the estimated row count from pg_class.reltuples, the exact ALTER TABLE ... VALIDATE CONSTRAINT statement built with format('%I') so the database quotes the identifiers, plus an in_scope flag derived from max_table_mb.summarize (io.kestra.plugin.core.output.OutputValues) builds the found list, the target list, the skipped list and the JSON the loop iterates, so every step reads the same set.log_detect (io.kestra.plugin.core.log.Log) logs the count, the mode and each constraint with its table size and row estimate.guard (io.kestra.plugin.core.flow.If) branches on the count. With none found, clean logs and the run ends. Otherwise findings_alert posts the finding on every detection.mode (io.kestra.plugin.core.flow.If) enters the remediation branch only when auto_remediate is true, dry_run is false and at least one target is inside the size ceiling. Otherwise report_only lists what would be validated and stops.approval_gate (io.kestra.plugin.core.flow.Pause) holds the run until a human resumes it. validate_constraints (io.kestra.plugin.core.flow.Loop, concurrencyLimit: 1) then runs one VALIDATE CONSTRAINT per target, with allowFailure so a rejected one does not end the pass.reverify re-runs the same catalog query and verify_result fails the run through io.kestra.plugin.core.execution.Fail if any targeted constraint still reports convalidated false. A constraint added NOT VALID during the run was never a target, so it does not fail the run.pg_constraint shows every target trusted.NOT VALID then VALIDATE pattern and want the second half tracked rather than remembered.PostgreSQL will not remind you. There is no job queue for a forgotten VALIDATE CONSTRAINT, no dry run, no approval step and no proof afterwards. A migration tool does not help either, since the whole point of NOT VALID is that the validation is deferred past the deployment. Doing it by hand means a DBA reading the catalog, judging the scan cost per table, running the statements in turn and checking the catalog again. Kestra does all of that on a schedule. It reads the live catalog, reports the cost per target, keeps the change behind dry-run plus approval, serialises the scans so they do not fight for the same lock, re-reads pg_constraint to turn a hopeful statement into a verified one and posts to Slack the moment something fails.
Run on Kestra 2.0.5 against a PostgreSQL 16 server, with a local HTTP sink standing in for Slack. Two schemas were seeded. app held a CHECK added NOT VALID over clean rows, a FOREIGN KEY added NOT VALID over clean rows, a CHECK added NOT VALID on a 2400 kB table of 40000 rows, plus a CHECK added normally that is already validated. badapp held a CHECK added NOT VALID on a table holding a row that breaks it. Every count was confirmed with psql against pg_constraint.convalidated before and after each run.
app and never the already-validated one. After the validation pass the same query returned 0.convalidated was still false on all 3.approved false) validated nothing. psql showed the same 4 unvalidated constraints as before the run.max_table_mb 1 validated the 2 small targets, reported the 2400 kB table as above the ceiling and left it alone. The re-read found 0 targets still unvalidated and psql showed exactly those 2 flipped to true.app went from 3 unvalidated to 0, confirmed with psql.badapp made PostgreSQL refuse with check constraint "ledger_qty_positive" of relation "ledger" is violated by some row. The re-read reported that constraint still NOT VALID, the run failed with the constraint definition in the message and the Slack alert fired. psql confirmed the constraint was left untouched.constraint_type was checked both ways against one state holding a single unvalidated FOREIGN KEY: check returned 0 and took the clean branch, foreign_key returned that 1.A PostgreSQL server reachable over JDBC. The connecting user must be able to read the catalog and must own the tables it validates, since ALTER TABLE requires table ownership.
POSTGRES_USERNAME: PostgreSQL user, read when use_secret_auth is true.POSTGRES_PASSWORD: password for that user.SLACK_WEBHOOK_URL: used by findings_alert and the errors block.SECRET_.| Name | Type | Default | Description |
|---|---|---|---|
postgres_url |
STRING | jdbc:postgresql://localhost:5432/postgres |
PostgreSQL JDBC URL for the target database. |
use_secret_auth |
BOOL | true |
Read credentials from secrets. Set false only for inline test credentials. |
schema |
STRING | "" |
Optional single schema filter. Empty scans every user schema. |
constraint_type |
SELECT | all |
all, foreign_key or check. |
max_table_mb |
INT | 0 |
Size ceiling in MB of table data for a constraint to be validated. 0 means no ceiling. |
dry_run |
BOOL | true |
Report without validating anything. |
auto_remediate |
BOOL | false |
Validate behind the approval gate when dry_run is false. |
POSTGRES_USERNAME, POSTGRES_PASSWORD and SLACK_WEBHOOK_URL as namespace secrets.postgres_url pointed at your database. The default dry run lists every constraint still NOT VALID with the size of its table.dry_run false and auto_remediate true, then resume the paused execution with approved true.max_table_mb to the largest table you are willing to scan in the window, then raise it for a maintenance run.constraint_type to check to clear the constraints that block constraint exclusion first, since those are the ones costing you query plans.schema to walk one schema at a time on a large database.?options=-c%20statement_timeout%3D600000 to the JDBC URL to cap how long a single validation scan may run.