Query icon
Find icon
OutputValues icon
Log icon
If icon
SlackIncomingWebhook icon
Pause icon
Delete icon
Loop icon
Update icon
Fail icon
Schedule icon

MongoDB Read Model vs PostgreSQL Reconcile Guard

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.

Categories
DataInfrastructure

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.

Findings

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.

How it works

  1. 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.
  2. mongo_read (io.kestra.plugin.mongodb.Find, read-only) returns every read-model document with its key and version, with _id dropped.
  3. 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.
  4. route (io.kestra.plugin.core.flow.If):
    • clean: logs it, nothing to do, no alert.
    • divergence: 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.
  5. errors alerts Slack when the flow fails.

What you get

  • A per-key reconcile across two live systems, with PostgreSQL as the source of truth, not a scan of MongoDB on its own.
  • The orphaned and stale documents named, with the database version next to the document version.
  • Deletes and refreshes applied only behind 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.

Who it's for

  • Teams that build a denormalized MongoDB view from a PostgreSQL table and have served a deleted record or a stale document.
  • SREs who want a scheduled check that a read model has not drifted from its database.
  • Teams evaluating the PostgreSQL and MongoDB plugins who want one flow that reads from both and writes only to the read model.

Why orchestrate this with Kestra

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.

Tested end to end

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.

Prerequisites

  • A PostgreSQL table that is the source of truth, with a key column, a version that increases on every update and a hard delete that removes the row.
  • A MongoDB collection that holds one document per row under a key field, with a version field it was written at and a payload field.
  • For the audit, a PostgreSQL role that can read the table. For remediation, a MongoDB user allowed to delete and update documents in the collection.
  • Local testing:
    • docker run -d --name pg --network kw7 -e POSTGRES_PASSWORD=pw postgres:16;
    • docker run -d --name view --network kw7 mongo:7.0;
    • seed a few rows, mirror some as documents at the right version, mirror others a version behind, then leave a document for a deleted row.

Secrets

  • 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.
  • In Kestra OSS, provide each one as a base64-encoded environment variable prefixed SECRET_.

Inputs

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

Quick start

  1. Add the POSTGRES_PASSWORD, MONGODB_URI and SLACK_WEBHOOK_URL secrets.
  2. Import the flow and set the connection inputs and the field names to match your read model.
  3. Run it once and read the orphaned and stale documents.
  4. Run it with auto_remediate: true and dry_run: false, approve the pause, then read the remediated log for the proof.
  5. Enable the hourly trigger, which always runs in safe mode.

How to extend

  • Compare on updated_at instead of a numeric version by returning it from the query and storing it in the document.
  • Insert a missing document for a read-model gap by adding an io.kestra.plugin.mongodb.InsertOne branch for the keys that are in PostgreSQL but not in MongoDB.
  • Page through a large table by ordering on the key and carrying a cursor in a KV store.
  • Reconcile several collections by looping over a list of tables and collections.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.