New to Kestra?
Use blueprints to kickstart your first workflows.
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.
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.
auto_remediate true, dry_run false, then a human answering the Pause. Three conditions,
all of them, before a document is removed._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.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.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.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.report (io.kestra.plugin.core.log.Log) logs those numbers, because task outputs are not
in the execution JSON.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.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.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.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.errors posts to Slack when any task fails, for example when MongoDB is unreachable._id, with the survivor safe by
construction.createIndex with unique: true fails while any duplicate is present.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.
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 |
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.
_id of each document is expected
to be a stock ObjectId, which the survivor rule and the delete round-trip rely on.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.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.| 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 |
MONGODB_URI and SLACK_WEBHOOK_URL secrets.database, collection and business_key, then run the flow. It reports the duplicate
keys and the surplus. It deletes nothing.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.daily schedule, which always runs in safe mode.io.kestra.plugin.core.flow.Loop
over a list of collection and key pairs, with concurrencyLimit: 1 on the part that deletes.$merge of the surplus documents into a backup
collection ahead of the delete, so a wrong survivor rule is recoverable.Pause with a Slack approval. Or drop it for a collection you are sure about.$group aggregation: https://www.mongodb.com/docs/manual/reference/operator/aggregation/group/