New to Kestra?
Use blueprints to kickstart your first workflows.
Restore crash durability for small PostgreSQL tables left unlogged with first-seen grace, approval, locked identity checks and verification.
ALTER TABLE ... SET LOGGED rewrites the table and its indexes under an ACCESS EXCLUSIVE lock and writes the whole table to WAL. Readers and writers wait for it. On the wrong table it can also undo a deliberate choice: some tables are unlogged on purpose because they are rebuilt from scratch after a crash. That is why this flow refuses by default and why every guard below is load bearing. Each one was proven by its own run.
| Guard | What it stops |
|---|---|
| One exact schema, system schemas refused | a scan that wanders into pg_catalog, information_schema or a schema nobody named |
staging_schemas allowlist |
rewriting tables that are unlogged on purpose |
| Partition and inheritance exclusion | a rewrite of one member of a family that needs a coordinated plan |
| First-seen grace keyed by OID, storage file and name | converting a table while its bulk load is still running |
max_table_mb and max_tables |
an approval that turns into a long lock and a WAL burst |
auto_remediate false, dry_run true, Pause |
three separate gates before one table is rewritten |
| Identity and content recheck under the lock | acting on a plan that went stale while the approval waited |
Final catalog read plus Assert |
a conversion that returned success being reported as a fix |
allowed_schema (Assert) refuses an empty schema, a system schema and any name containing pg_.detect (io.kestra.plugin.jdbc.postgresql.Query) lists ordinary tables with relpersistence = 'u' in that schema, skipping staging schemas, partition members and inheritance trees. For each table within the size budget it records the OID, storage file, size, row count and a sorted MD5 of the rows. bounded_scan fails the run above 200 candidates.read_watch and write_watch (kv.Get, kv.Set) keep the first time each exact identity was seen. A rewrite or a rename starts a new clock.observation, plan and targets split the tables into converted, waiting for grace, over budget and over the per-run cap.report logs every table with its reason. findings posts the same list to Slack when anything is unlogged.mode continues only with auto_remediate: true, dry_run: false and at least one selected table. Otherwise the run stays a report.approval (Pause) waits for a person. approved_only reads onResume.approved ?? false, so a resume with no answer is a refusal.convert_tables (Loop, concurrencyLimit: 1) runs convert_one one table at a time. Inside one transaction it takes ACCESS EXCLUSIVE, rechecks OID, name, storage file, kind and size, compares the row snapshot with the approved one, runs SET LOGGED, then compares the snapshot again before commit. Any difference raises and rolls back that table only.verify reads the catalog for the approved OIDs, without the age or size filters. verdict fails the run if any of them is still unlogged. failure_alert posts to Slack on any failure.A Schedule trigger ships disabled: true and forces dry_run: true with auto_remediate: false, so enabling it cannot rewrite a table.
PostgreSQL operators and data engineers who load with unlogged tables for speed and need the data to survive a crash once the load is done.
Three things here are orchestration, not SQL. The grace clock needs memory between scheduled scans. The rewrite needs a person to approve it. A sequence of rewrites needs a record of which ones landed. Kestra keeps the observations in KV, holds the run at a Pause and runs the rewrites one at a time with the outcome of each recorded. PostgreSQL does the locking and the rewrite itself.
Kestra 2.0.5, PostgreSQL 16.15 (postgres@sha256:f1c3376c...), JDBC PostgreSQL 2.1.0. Validate was clean: no constraints, warnings or deprecations. Every state was read back with an independent psql query, not from the flow's own output.
The fixture before any run, read from pg_class:
table relpersistence rows note
public.durable_target u 1000 finished load left unlogged
public.too_large u 600 2.5 MiB, over a 1 MiB budget
public.healthy p 100 logged control
public.inherit_parent u 0 inheritance family
public.partition_leaf p 0 partition member
staging.control u 100 unlogged on purpose
Results:
durable_target selected, too_large held back by the size budget. No change.durable_target became logged with the same OID and the same 1,000 rows (MD5 67d2b67328b0...). Then PostgreSQL was killed with pg_ctl stop -m immediate and restarted into crash recovery. durable_target still had all 1,000 rows. staging.control, still unlogged on purpose, came back with 0 of its 100 rows. That is the data loss the guard prevents.reader role owned the first table only. The first table (200 rows) was converted. PostgreSQL refused the second (300 rows), which stayed unlogged. The run failed.Slack was a local HTTP sink.
USAGE on the schema and SELECT on the tables to scan. Ownership of each table to convert. No superuser.POSTGRES_USERNAME, POSTGRES_PASSWORD: the database role.SLACK_WEBHOOK_URL: Slack incoming webhook for findings and failures.| Input | Default | Purpose |
|---|---|---|
postgres_url |
jdbc:postgresql://postgres:5432/app |
Database. Timeouts are added: 5 s connect, 30 s statement, 2 s lock |
schema_filter |
public |
The one schema to scan |
staging_schemas |
[staging] |
Schemas where unlogged tables are intentional |
watch_key |
postgres_unlogged_durability |
KV key for first-seen times. One per database and schema |
grace_minutes |
1440 |
Time a table must stay unlogged before it is selected |
max_table_mb |
16 |
Largest table plus indexes converted |
max_tables |
3 |
Tables converted per approved run |
dry_run |
true |
Report only |
auto_remediate |
false |
Ask for approval to convert |
postgres_url and schema_filter.staging_schemas.dry_run: false and auto_remediate: true. Check disk and WAL headroom, then approve the Pause.watch_key. Tables already converted are not touched again.Limits: flow concurrency serializes this flow only, which is why the lock and recheck stay in place for other writers. The MD5 snapshot detects accidental change, not deliberate tampering. Conversion makes a table crash safe. It does not create a backup or prove a replica is healthy. Row-level security and custom output types need review before adoption.
This blueprint was created by zkasuran.
