New to Kestra?
Use blueprints to kickstart your first workflows.
Find MongoDB documents orphaned or stale against a PostgreSQL source of truth, alert Slack, delete and refresh them, then re-diff and fail if any remain.
A denormalized read model drifts from the table it is built from in two ways that neither system can see alone. A row deleted in PostgreSQL can leave its document behind in MongoDB, so the application keeps serving a record that no longer exists. A row updated in PostgreSQL can leave an older copy in the collection, so the application serves a document that is behind the truth.
This blueprint reconciles the two. It reads the authoritative keyset and a per-row version from
PostgreSQL first, then reads the read-model documents from MongoDB and diffs both sides. PostgreSQL
is the source of truth, so the keyset is driven from it and every run is deterministic. It reports
the divergence, alerts Slack and on request deletes the orphans with a single DELETE_MANY and
refreshes each stale document from the database row with an UPDATE_ONE. Every delete and refresh
sits behind a Pause for human approval. After the writes it reads both sides again and fails the
run if any divergence remains. PostgreSQL is never written.
This blueprint was created by zkasuran.
| Finding | Meaning | Fix |
|---|---|---|
ORPHAN |
The document key is gone from PostgreSQL (the row was deleted) but the document is still in MongoDB | DELETE_MANY the key |
STALE |
The row is live but the document version is behind the database version | UPDATE_ONE the document to the current database version and payload |
A live row with no document is a read-model gap, not a divergence, so it is counted and left alone. A deleted row with no document is already correct. Only orphan and stale documents are remediated, so the gate that must reach zero is the orphan plus stale count.
pg_read (io.kestra.plugin.jdbc.postgresql.Query, read-only) returns every live row with its
key, its version and the payload column, in key order, up to batch_limit rows.mongo_read (io.kestra.plugin.mongodb.Find, read-only) returns every read-model document with
its key and version, with _id dropped.diff compares the two in code. An orphan is a document whose key is not in the PostgreSQL
keyset. A stale document is one whose version is below the database version. plan builds the
DELETE_MANY filter for the orphan keys and a per-document refresh list for the stale keys.
summary logs the counts.route (io.kestra.plugin.core.flow.If):alert posts to Slack.remediate_gate runs only when auto_remediate is true and dry_run is false.approval (io.kestra.plugin.core.flow.Pause) holds the run. It resumes only when a human
sets approved true, so an unanswered or declined resume applies nothing.delete_orphans (io.kestra.plugin.mongodb.Delete, DELETE_MANY) removes the orphan
documents. remediate_stale (io.kestra.plugin.core.flow.Loop, concurrencyLimit: 1)
refreshes each stale document with io.kestra.plugin.mongodb.Update.reverify_pg and reverify_mongo read both sides again, reverify_diff re-runs the diff
and verify_result fails the run if any orphan or stale document is left.errors alerts Slack when the flow fails.auto_remediate, dry_run and a human approval, one
document at a time for the writes, then a second read of both sides as proof.A read model drifts quietly and the drift spans two systems that each look healthy alone. Kestra reads the authoritative keyset from PostgreSQL, reads the documents from MongoDB, sends the divergence to Slack, deletes and refreshes only behind two flags and an approval, verifies the result and keeps a history of every reconcile.
On Kestra 2.0.5 against PostgreSQL 16 and MongoDB 7.0, with a local HTTP sink standing in for
Slack. The source table held 5 live rows (1 v5, 2 v5, 3 v7, 4 v3, 5 v2). The read model
held 6 documents (1 v5, 2 v5, 3 v4, 4 v2, 6 v9, 7 v4), so 3 and 4 were stale, 6
and 7 were orphaned and 5 was a read-model gap.
| Run | Inputs | Result |
|---|---|---|
| 1 | defaults | 4 divergences (orphan 6, 7; stale 3 db=7 doc=4, 4 db=3 doc=2), 1 gap. Alert sent. mongosh confirmed the 6 documents unchanged |
| 2 | auto_remediate: true, dry_run: true |
Same 4 divergences, the gate stayed closed, mongosh confirmed no document changed |
| 3 | auto_remediate: true, dry_run: false, approved |
Deleted 2 orphans, refreshed 2 stale documents, re-read reported 0 remaining. mongosh after: 6 and 7 gone, 3 now v7, 4 now v3, 1 and 2 untouched. psql confirmed the table unchanged |
| 4 | clean read model, auto_remediate: true, dry_run: false |
0 divergences, clean branch, no alert |
| 5 | auto_remediate: true, dry_run: false, approval declined |
4 divergences, no write, mongosh confirmed the 6 documents unchanged |
| 6 | auto_remediate: true, dry_run: false, approved, with a writer inserting a new orphan during the pause |
Re-read still found the new orphan, verify_result failed the run rather than reporting success |
| 7 | unreachable MongoDB host | mongo_read failed, the run failed, the errors block posted to Slack |
Detector proof: the degraded read model reported 4 and the clean read model reported 0, both
matching the counts measured straight from psql and mongosh. PostgreSQL held 5 rows before and
after every run.
docker run -d --name pg --network kw7 -e POSTGRES_PASSWORD=pw postgres:16;docker run -d --name view --network kw7 mongo:7.0;POSTGRES_PASSWORD: the password for pg_user, read by every query.MONGODB_URI: the MongoDB connection string, for example mongodb://user:pass@host:27017.SLACK_WEBHOOK_URL: used by alert and the errors block.SECRET_.| Input | Default | Purpose |
|---|---|---|
pg_host / pg_port / pg_db / pg_user |
localhost, 5432, postgres, postgres | PostgreSQL connection |
pg_password |
secret('POSTGRES_PASSWORD') |
PostgreSQL password |
pg_table |
orders |
Source of truth table |
pg_key_column / pg_version_column / pg_payload_column |
order_id, version, status | Row key, version and payload |
mongo_uri |
secret('MONGODB_URI') |
MongoDB connection string |
mongo_db / mongo_collection |
appdb, orders_view | Read-model location |
doc_key_field / doc_version_field / doc_payload_field |
order_id, version, status | Document key, version and payload |
batch_limit |
5000 |
Upper bound on the keyset read from each side |
auto_remediate |
false |
Allow deletes and refreshes |
dry_run |
true |
Must be false for a change to apply |
POSTGRES_PASSWORD, MONGODB_URI and SLACK_WEBHOOK_URL secrets.auto_remediate: true and dry_run: false, approve the pause, then read the
remediated log for the proof.hourly trigger, which always runs in safe mode.updated_at instead of a numeric version by returning it from the query and storing
it in the document.io.kestra.plugin.mongodb.InsertOne
branch for the keys that are in PostgreSQL but not in MongoDB.