Schedule icon
Assert icon
Query icon
Get icon
OutputValues icon
Set icon
Log icon
If icon
SlackIncomingWebhook icon
Pause icon
Loop icon

Restore durability for forgotten unlogged PostgreSQL tables

Restore crash durability for small PostgreSQL tables left unlogged with first-seen grace, approval, locked identity checks and verification.

Categories
DataInfrastructure

Safety

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

How it works

  1. allowed_schema (Assert) refuses an empty schema, a system schema and any name containing pg_.
  2. 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.
  3. 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.
  4. observation, plan and targets split the tables into converted, waiting for grace, over budget and over the per-run cap.
  5. report logs every table with its reason. findings posts the same list to Slack when anything is unlogged.
  6. mode continues only with auto_remediate: true, dry_run: false and at least one selected table. Otherwise the run stays a report.
  7. approval (Pause) waits for a person. approved_only reads onResume.approved ?? false, so a resume with no answer is a refusal.
  8. 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.
  9. 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.

What you get

  • A daily list of forgotten unlogged tables, each with its size, row count and why it was or was not selected.
  • Conversions that keep the table's OID, rows and content, checked under the same lock that does the rewrite.
  • A run that fails loudly when a table could not be converted, while keeping the ones that were.
  • Staging tables that stay unlogged on purpose.

Who it's for

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.

Why orchestrate this with Kestra

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.

Tested end to end

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:

  • Healthy controls: nothing reported, nothing changed.
  • Dry run: two tables reported, only durable_target selected, too_large held back by the size budget. No change.
  • Declined approval: the table, its storage file and its rows were unchanged.
  • Remediation: 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.
  • Repeat: nothing left to convert, no rewrite.
  • Refused write: the 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.
  • Partial retry: run again as the owner, only the second table was converted. The first kept its storage file and checksum.
  • Content changed during the approval: the recheck under the lock refused the change and the table stayed unlogged with the new rows.
  • First observation with a 60-minute grace: reported only.
  • One-table cap with two eligible tables: one converted, the other left for the next run.
  • Fresh fixture: the remediation and crash test were repeated on a new container with the same result.
  • An enabled copy of the Schedule ran in safe mode on its own. The shipped trigger stays disabled.

Slack was a local HTTP sink.

Prerequisites

  • PostgreSQL with PL/pgSQL. Tested on 16.15 only.
  • Kestra 2.0.5 with the JDBC PostgreSQL and Slack plugins.
  • Minimum permissions: USAGE on the schema and SELECT on the tables to scan. Ownership of each table to convert. No superuser.
  • Disk and WAL headroom for one rewrite of each selected table. Replicas receive the full table in WAL.

Secrets

  • POSTGRES_USERNAME, POSTGRES_PASSWORD: the database role.
  • SLACK_WEBHOOK_URL: Slack incoming webhook for findings and failures.

Inputs

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

Quick start

  1. Add the three secrets and set postgres_url and schema_filter.
  2. Execute with the defaults. Read the report: every unlogged table with its size and reason.
  3. Move tables that are unlogged on purpose into a schema listed in staging_schemas.
  4. After the grace period, execute with dry_run: false and auto_remediate: true. Check disk and WAL headroom, then approve the Pause.
  5. Read the verification lines. If a table failed, fix the cause and run again with the same watch_key. Tables already converted are not touched again.
  6. Enable the Schedule for a daily report.

How to extend

  • Add a separate, reviewed plan for partition and inheritance families.
  • Pair the guard with replication lag and WAL volume monitoring before raising the budgets.
  • Send the report to a ticket instead of Slack so each table has an owner.

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.

Links

This blueprint was created by zkasuran.

See How

New to Kestra?

Use blueprints to kickstart your first workflows.