If icon
Fail icon
Aggregate icon
Log icon
SlackIncomingWebhook icon
Pause icon
Loop icon
Delete icon
Schedule icon

MongoDB Duplicate Document Dedup Guard

Detect MongoDB documents sharing a business key, keep one survivor per key, delete the surplus behind an approval, then re-group to prove each key is unique.

Categories
DataInfrastructure

MongoDB keeps a key unique with a unique index. The bundled Kestra MongoDB plugin has no task that creates an index, so a collection cannot be given that constraint from a Kestra flow at all. Nothing then stops the same logical record from being written twice: an at-least-once ingest replays a message, a retried upsert runs again after a timeout, a backfill reloads a file that was already loaded. The second write lands as a new document because the key it should have collided on was never made unique. Over weeks the duplicates pile up on an orderId, an email or an external id and every reader that assumes one document per key quietly double counts a total or picks an arbitrary copy.

This blueprint is the outside repair for that. It groups the collection by a business key you pass in, so it works on any collection and reports the keys carried by more than one document. It keeps one survivor per key by a rule you choose, the oldest _id, the newest _id or the newest by a timestamp field and marks every other copy as surplus. Behind a safe mode gate and an approval it deletes the surplus by _id, never by the key match, so the survivor is safe by construction. Then it groups the collection again and fails the run if any key still has more than one document.

This blueprint was created by zkasuran.

Safety

  • Read-only by default. The first run with the defaults only counts and reports. A delete needs auto_remediate true, dry_run false, then a human answering the Pause. Three conditions, all of them, before a document is removed.
  • The delete filters on the surplus _id list, never on the business key. A key match would select the survivor too. The survivor _id is excluded from the surplus list when it is built, so the keeper cannot be deleted even if the delete and the detection disagree.
  • The survivor is deterministic, not arbitrary. A sort before the group fixes which copy is first, so the same inputs keep the same document every run. Picking at random is how a dedup deletes the record somebody needed.
  • A document missing any business key field or holding null in it, is never grouped and never touched. Dedup applies only to documents that actually carry the key.
  • The delete is capped at delete_limit per run and batched by batch_size, so a large backlog cannot become one giant write. If the surplus is larger than the cap, the re-group fails until a later run finishes, so a partial clear is never reported as done.
  • The re-group is the proof, not the delete count. If any key still has more than one document, the run fails rather than reporting success.

How it works

  1. check_inputs (io.kestra.plugin.core.flow.If) fails the run before any read on a newest_timestamp strategy with no timestamp_field or a batch_size or delete_limit below 1.
  2. detect (io.kestra.plugin.mongodb.Aggregate) matches documents that carry the whole business key, sorts them so the chosen survivor is first, groups by the key with $first capturing the survivor _id and $push capturing all _ids, then keeps only the groups with a count above 1. A $filter marks every id except the survivor as surplus. One $facet returns the exact duplicate key count, the exact surplus total, the capped surplus id list and the worst scan_limit keys.
  3. report (io.kestra.plugin.core.log.Log) logs those numbers, because task outputs are not in the execution JSON.
  4. route (io.kestra.plugin.core.flow.If): zero duplicate keys logs clean and stops. One or more sends alert to Slack and prints planned, which states what a remediation run would do on every run including a dry one.
  5. remediate_gate (io.kestra.plugin.core.flow.If) opens only with auto_remediate true and dry_run false. hold (io.kestra.plugin.core.flow.Pause) waits for an answer.
  6. On approval remediate (io.kestra.plugin.core.flow.Loop, concurrencyLimit: 1) walks the surplus id batches and purge (io.kestra.plugin.mongodb.Delete, DELETE_MANY) removes each batch by _id. With nothing targeted the run logs nothing_to_delete.
  7. reverify (io.kestra.plugin.mongodb.Aggregate) groups the collection by the key again and verify_gate fails the run with io.kestra.plugin.core.execution.Fail if any key still has more than one document. Without approval declined records the decision and nothing changes.
  8. errors posts to Slack when any task fails, for example when MongoDB is unreachable.

What you get

  • The exact number of business keys carried by more than one document and the total surplus, which no MongoDB command reports for you when the collection has no unique index.
  • A Slack alert the moment duplicates exist, before a report that trusts the key starts double counting.
  • A deterministic survivor per key, kept by a rule you pick and recorded in the log, so the copy that stays is a decision rather than an accident.
  • A gated, approved delete of only the surplus copies, by _id, with the survivor safe by construction.
  • A re-group that fails the run when any key is still not unique, so a partial clear or a writer still inserting copies is caught rather than hidden.

Who it's for

  • Teams on an at-least-once ingest or an upsert path that cannot add a unique index, so the same record arrives twice.
  • Data engineers whose counts drifted because a join on a supposedly unique key fans out.
  • Anyone preparing a collection for a unique index who must clear the existing duplicates first, because createIndex with unique: true fails while any duplicate is present.

Why orchestrate this with Kestra

A unique index is the right long-term fix, but it cannot be created through the bundled plugin and it fails outright until the existing duplicates are gone. The usual answer is a one-off script somebody runs by hand. This flow makes it a scheduled, reviewable job. The detection is one server-side pipeline, the survivor rule is explicit, the delete sits behind auto_remediate, dry_run and a Pause with the decision recorded on the execution, the batches are serialised by concurrencyLimit: 1 and the re-group turns the delete into a checked outcome. The Slack alert and the error path are the same ones the rest of your flows use.

Tested end to end

On Kestra 2.0.5 OSS against a MongoDB 7.0 container, on the final file. appdata.orders was seeded with three documents sharing email of amy@example.com, two sharing ben@example.com, plus three singleton documents with their own emails. Every before and after number was read back with mongosh, not only from the Kestra log.

Run Inputs Result
Clean business_key: email on a collection with no duplicates 0 duplicate keys. clean logged, no Slack alert, SUCCESS. mongosh confirms one document per email
Detect business_key: email, defaults 2 duplicate keys, 3 surplus copies. Slack alert, SUCCESS. mongosh groups confirm amy 3, ben 2
Dry run the same Reports the delete it would make, nothing deleted. mongosh count unchanged
Armed but dry auto_remediate: true, dry_run: true Gate stayed closed, no Pause, nothing deleted
Declined auto_remediate: true, dry_run: false, resumed approved: false Paused, then declined. mongosh count unchanged
Remediate the same, resumed approved: true 3 surplus deleted, re-group 0, SUCCESS. mongosh amy 1, ben 1, the three singletons untouched
Survivor by id survivor_strategy: oldest_id vs newest_id on the same seed mongosh confirms the kept _id is the min and the max respectively
Survivor by time survivor_strategy: newest_timestamp mongosh confirms the kept document is the one with the largest createdAt
Still dirty remediate with a writer inserting another amy@example.com during the Pause Surplus deleted, re-group still found a duplicate key, verify_gate failed the run, errors alerted
Unreachable connection_uri pointed at a dead host detect failed, errors posted to Slack, execution FAILED

Does the detector actually fail on duplicates

The clean run and the detect run isolate it. Nothing changed between them except the data: a collection with one document per email returned 0 duplicate keys, the seeded one returned 2 with 3 surplus copies. mongosh confirmed both with an aggregation grouping on email. The payoff is the other half: after the remediate run a reader grouping on email returned exactly one document per key, where before it saw three for amy@example.com. The still-dirty run proved the gate the other way, failing when a key stayed duplicated.

Prerequisites

  • A reachable MongoDB instance and a user that can read the collection and run a delete on it.
  • A business key that should identify one logical record. The _id of each document is expected to be a stock ObjectId, which the survivor rule and the delete round-trip rely on.
  • Local testing: docker run -d --name mdb --network mynet mongo:7.0, seed a few documents that share a key plus some singletons, then run the flow with connection_uri: mongodb://mdb:27017.

Secrets

  • MONGODB_URI: the MongoDB connection string. Read when connection_uri is left empty, which is how the flow should run in production. In Kestra OSS, provide it as the base64-encoded environment variable SECRET_MONGODB_URI.
  • SLACK_WEBHOOK_URL: used by alert and the errors block. Provide it as SECRET_SLACK_WEBHOOK_URL.

Inputs

Input Default Purpose
connection_uri empty Plain connection string for a local test. Empty reads MONGODB_URI
database, collection appdata, orders The collection to deduplicate
business_key email The field or comma separated fields that identify one record
survivor_strategy oldest_id Which copy to keep: oldest_id, newest_id or newest_timestamp
timestamp_field createdAt The date field for newest_timestamp, ignored otherwise
scan_limit 25 How many duplicate keys the report names. Counts stay exact
delete_limit 50000 Ceiling on surplus copies deleted per run
batch_size 500 Surplus ids per delete batch
approval_timeout PT2H How long the pause waits for an answer
auto_remediate false Allow the delete
dry_run true Must be false for a delete to apply

Quick start

  1. Add the MONGODB_URI and SLACK_WEBHOOK_URL secrets.
  2. Set database, collection and business_key, then run the flow. It reports the duplicate keys and the surplus. It deletes nothing.
  3. Pick a survivor_strategy, set auto_remediate: true and dry_run: false, run it again, then answer the pause. The surplus is deleted and the re-group proves each key is unique.
  4. Create the unique index the flow stands in for, so the key stays unique between runs.
  5. Enable the daily schedule, which always runs in safe mode.

How to extend

  • Deduplicate several collections by wrapping the tasks in a io.kestra.plugin.core.flow.Loop over a list of collection and key pairs, with concurrencyLimit: 1 on the part that deletes.
  • Archive the surplus before deleting it: add a $merge of the surplus documents into a backup collection ahead of the delete, so a wrong survivor rule is recoverable.
  • Keep an index on the business key, so the detection group is served without a collection scan.
  • Replace the Pause with a Slack approval. Or drop it for a collection you are sure about.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.