id: mongodb-dangling-reference-orphan-cleanup-guard
namespace: company.team
description: |
Find the MongoDB child documents whose manual reference points at a parent document that no
longer exists. MongoDB has no foreign keys, so deleting a parent leaves every child pointing at
a key nothing resolves. Those children keep working until something joins on them: a `$lookup`
returns an empty array, an inner join drops the row, a report that counts by parent silently
loses it. This flow joins the child collection to the parent collection server side with
`$lookup`, treats an empty join result as a dangling reference, spares any child written too
recently to judge, deletes the aged orphans behind a safe mode gate and an approval, then runs
the join again and fails the run if an aged orphan survived.
inputs:
- id: connection_uri
type: STRING
displayName: MongoDB connection string
description: >-
Leave empty in production so the flow reads the MONGODB_URI secret. Set it
to a plain connection string only for a local test run.
defaults: ""
- id: database
type: STRING
displayName: Database
defaults: appdata
- id: child_collection
type: STRING
displayName: Child collection
description: The collection that holds the references, for example orders.
defaults: orders
- id: parent_collection
type: STRING
displayName: Parent collection
description: The collection the references point at, for example customers.
defaults: customers
- id: reference_field
type: STRING
displayName: Reference field on the child
description: >-
The field on a child document that holds the parent key, for example
customerId. A child that is missing this field or holds null in it, is
never read as an orphan.
defaults: customerId
- id: parent_key_field
type: STRING
displayName: Key field on the parent
description: >-
The field on a parent document the reference resolves against. Usually
_id. The join is a strict BSON comparison, so the two fields must hold the
same type.
defaults: _id
- id: age_field
type: STRING
displayName: Child write time field
description: >-
Optional BSON date field that records when the child was written. Leave
empty to take the write time from the timestamp inside a stock ObjectId
_id. A child whose age cannot be read at all counts as brand new, so it is
spared.
defaults: ""
- id: min_orphan_age_minutes
type: INT
displayName: Minimum orphan age in minutes
description: >-
A dangling child younger than this is reported but never deleted. It
exists because a child can legitimately be written microseconds before its
parent. It can also land in the middle of a multi document insert that is
still in flight. Raise it if your writers insert children first.
defaults: 15
- id: max_orphan_ratio
type: FLOAT
displayName: Maximum orphan share
description: >-
Refuse to delete when the orphan share of all referencing children is
above this. A share near 1 almost always means the join itself is wrong,
usually a type mismatch between the reference field and the parent key,
not that every row lost its parent.
defaults: 0.5
- id: scan_limit
type: INT
displayName: Orphans named per report
description: >-
Ceiling on how many orphans the report lists. The counts stay exact and
server side, so the cap only bounds the sample.
defaults: 25
- id: delete_limit
type: INT
displayName: Orphans deleted per run
description: >-
Ceiling on how many aged orphans one run deletes, so a huge backlog cannot
become one giant write. If more are aged than this, the run clears part of
the backlog then the re-join fails until a later run finishes the job.
defaults: 50000
- id: batch_size
type: INT
displayName: Document ids per delete batch
description: The delete runs in batches of this many ids, one batch at a time.
defaults: 500
- id: approval_timeout
type: STRING
displayName: Approval timeout
description: ISO-8601 duration to wait for an answer before the pause expires.
defaults: "PT2H"
- id: auto_remediate
type: BOOL
displayName: Auto remediate
description: Allow the delete. Still needs dry_run false and an approval.
defaults: false
- id: dry_run
type: BOOL
displayName: Dry run
description: Must be false, together with auto_remediate, before any document is
deleted.
defaults: true
variables:
cutoff_iso: '{{ execution.startDate | date("yyyy-MM-dd''T''HH:mm:ss.SSS''Z''",
timeZone="UTC") }}'
type_probe_pipeline: >-
{{ {'pc': inputs.parent_collection, 'rf': inputs.reference_field, 'pk':
inputs.parent_key_field} | jq('.pc as $pc | .rf as $rf | .pk as $pk |
[{"$match": {($rf): {"$exists": true}}}, {"$group": {"_id": {"$type": ("$" +
$rf)}, "n": {"$sum": 1}}}, {"$project": {"_id": 0, "side": {"$literal":
"child_reference"}, "bson_type": "$_id", "docs": "$n"}}, {"$unionWith":
{"coll": $pc, "pipeline": [{"$match": {($pk): {"$exists": true}}},
{"$group": {"_id": {"$type": ("$" + $pk)}, "n": {"$sum": 1}}}, {"$project":
{"_id": 0, "side": {"$literal": "parent_key"}, "bson_type": "$_id", "docs":
"$n"}}]}}]') | first | toJson }}
type_mismatch: >-
{{ {'rows': outputs.type_probe.rows} | jq('[.rows[] | select(.side ==
"child_reference") | .bson_type] as $c | [.rows[] | select(.side ==
"parent_key") | .bson_type] as $p | (if (($c | length) > 0) and (($p |
length) > 0) and ((($c - ($c - $p)) | length) == 0) then "true" else "false"
end)') | first }}
detect_pipeline: >-
{{ {'pc': inputs.parent_collection, 'rf': inputs.reference_field, 'pk':
inputs.parent_key_field, 'af': inputs.age_field, 'ms':
inputs.min_orphan_age_minutes * 60000, 'sd': render(vars.cutoff_iso), 'sl':
inputs.scan_limit, 'dl': inputs.delete_limit} | jq('.pc as $pc | .rf as $rf
| .pk as $pk | .af as $af | .ms as $ms | .sd as $sd | .sl as $sl | .dl as
$dl | {"$subtract": [{"$date": $sd}, $ms]} as $cut | {"$convert": {"input":
"$_id", "to": "date", "onError": "$$NOW", "onNull": "$$NOW"}} as $fromid |
(if $af == "" then $fromid else {"$ifNull": [("$" + $af), $fromid]} end) as
$born | [{"$match": {($rf): {"$exists": true, "$ne": null}}}, {"$lookup":
{"from": $pc, "localField": $rf, "foreignField": $pk, "as": "_parent"}},
{"$set": {"_dangling": {"$eq": [{"$size": "$_parent"}, 0]}, "_born": $born,
"_cut": $cut}}, {"$set": {"_aged": {"$lte": ["$_born", "$_cut"]}}},
{"$facet": {"sample": [{"$match": {"_dangling": true}}, {"$sort": {"_born":
1}}, {"$limit": $sl}, {"$project": {"_id": 0, "doc_id": {"$toString":
"$_id"}, "reference": {"$toString": ("$" + $rf)}, "written_at": "$_born",
"past_age_gate": "$_aged"}}], "targets": [{"$match": {"_dangling": true,
"_aged": true}}, {"$sort": {"_born": 1}}, {"$limit": $dl}, {"$group":
{"_id": "all", "ids": {"$push": {"$toString": "$_id"}}}}], "totals":
[{"$group": {"_id": "all", "referencing": {"$sum": 1}, "orphans": {"$sum":
{"$cond": ["$_dangling", 1, 0]}}, "deletable": {"$sum": {"$cond": [{"$and":
["$_dangling", "$_aged"]}, 1, 0]}}, "spared_fresh": {"$sum": {"$cond":
[{"$and": ["$_dangling", {"$not": ["$_aged"]}]}, 1, 0]}}}}]}}, {"$project":
{"referencing": {"$ifNull": [{"$arrayElemAt": ["$totals.referencing", 0]},
0]}, "orphans": {"$ifNull": [{"$arrayElemAt": ["$totals.orphans", 0]}, 0]},
"deletable": {"$ifNull": [{"$arrayElemAt": ["$totals.deletable", 0]}, 0]},
"spared_fresh": {"$ifNull": [{"$arrayElemAt": ["$totals.spared_fresh", 0]},
0]}, "orphan_ids": {"$ifNull": [{"$arrayElemAt": ["$targets.ids", 0]}, []]},
"sample": "$sample"}}, {"$set": {"targeted": {"$size": "$orphan_ids"},
"orphan_ratio": {"$cond": [{"$gt": ["$referencing", 0]}, {"$round":
[{"$divide": ["$orphans", "$referencing"]}, 4]}, 0]}}}]') | first | toJson
}}
reverify_pipeline: >-
{{ {'pc': inputs.parent_collection, 'rf': inputs.reference_field, 'pk':
inputs.parent_key_field, 'af': inputs.age_field, 'ms':
inputs.min_orphan_age_minutes * 60000, 'sd': render(vars.cutoff_iso)} |
jq('.pc as $pc | .rf as $rf | .pk as $pk | .af as $af | .ms as $ms | .sd as
$sd | {"$subtract": [{"$date": $sd}, $ms]} as $cut | {"$convert": {"input":
"$_id", "to": "date", "onError": "$$NOW", "onNull": "$$NOW"}} as $fromid |
(if $af == "" then $fromid else {"$ifNull": [("$" + $af), $fromid]} end) as
$born | [{"$match": {($rf): {"$exists": true, "$ne": null}}}, {"$lookup":
{"from": $pc, "localField": $rf, "foreignField": $pk, "as": "_parent"}},
{"$set": {"_dangling": {"$eq": [{"$size": "$_parent"}, 0]}, "_born": $born,
"_cut": $cut}}, {"$set": {"_aged": {"$lte": ["$_born", "$_cut"]}}},
{"$facet": {"totals": [{"$group": {"_id": "all", "referencing": {"$sum": 1},
"orphans": {"$sum": {"$cond": ["$_dangling", 1, 0]}}, "deletable": {"$sum":
{"$cond": [{"$and": ["$_dangling", "$_aged"]}, 1, 0]}}}}], "worst":
[{"$match": {"_dangling": true, "_aged": true}}, {"$sort": {"_born": 1}},
{"$limit": 5}, {"$project": {"_id": 0, "doc_id": {"$toString": "$_id"},
"reference": {"$toString": ("$" + $rf)}}}]}}, {"$project":
{"still_referencing": {"$ifNull": [{"$arrayElemAt": ["$totals.referencing",
0]}, 0]}, "remaining_orphans": {"$ifNull": [{"$arrayElemAt":
["$totals.orphans", 0]}, 0]}, "remaining_deletable": {"$ifNull":
[{"$arrayElemAt": ["$totals.deletable", 0]}, 0]}, "worst": "$worst"}}]') |
first | toJson }}
delete_batches: >-
{{ {'ids': outputs.detect.rows[0].orphan_ids, 'n': inputs.batch_size} |
jq('.n as $n | [.ids | to_entries | group_by(.key / $n | floor) | .[] |
map({"$oid": .value}) | tojson]') | first | toJson }}
tasks:
- id: check_inputs
type: io.kestra.plugin.core.flow.If
description: >-
Refuse a child and parent collection that are the same, a batch_size or
delete_limit below 1, a negative age gate or an orphan share ceiling
outside 0 to 1. Each of those makes the delete undefined, so the run stops
before a single read.
condition: "{{ inputs.child_collection == inputs.parent_collection or
inputs.batch_size < 1 or inputs.delete_limit < 1 or
inputs.min_orphan_age_minutes < 0 or inputs.max_orphan_ratio <= 0 or
inputs.max_orphan_ratio > 1 }}"
then:
- id: bad_inputs
type: io.kestra.plugin.core.execution.Fail
errorMessage: >-
Input check failed. child_collection={{ inputs.child_collection }}
parent_collection={{ inputs.parent_collection }} batch_size={{
inputs.batch_size }} delete_limit={{ inputs.delete_limit }}
min_orphan_age_minutes={{ inputs.min_orphan_age_minutes }}
max_orphan_ratio={{ inputs.max_orphan_ratio }}. The two collections
must differ, batch_size and delete_limit must be at least 1, the age
gate cannot be negative and the orphan share ceiling must sit in 0 to
1. Nothing was read. Nothing was written.
- id: type_probe
type: io.kestra.plugin.mongodb.Aggregate
description: >-
Read-only. Groups the reference field by its BSON type on the child side,
then unions the same grouping of the parent key field. A `$lookup`
compares types strictly, so a string reference against an ObjectId key
matches nothing at all. Without this probe such a join reports every
referencing child as an orphan.
connection:
uri: "{{ inputs.connection_uri != '' ? inputs.connection_uri :
secret('MONGODB_URI') }}"
database: "{{ inputs.database }}"
collection: "{{ inputs.child_collection }}"
pipeline: "{{ render(vars.type_probe_pipeline) }}"
store: FETCH
- id: log_types
type: io.kestra.plugin.core.log.Log
description: Print the join compatibility verdict, because task outputs are not
in the execution JSON.
message: >-
Join types for {{ inputs.child_collection }}.{{ inputs.reference_field }}
against {{ inputs.parent_collection }}.{{ inputs.parent_key_field }}: {{
outputs.type_probe.rows ?? 'none' }}. Types disjoint: {{
render(vars.type_mismatch) }}. A disjoint pair means the join can never
match, so no delete is allowed.
- id: detect
type: io.kestra.plugin.mongodb.Aggregate
description: >-
Read-only. Joins every child that carries the reference to the parent
collection with `$lookup`, marks a child whose join array is empty as
dangling, derives when each child was written, then splits the dangling
set by the age gate. One `$facet` returns the exact referencing total, the
exact orphan count, how many are past the age gate, how many were spared
as fresh, the capped id list the delete acts on and a sample of the oldest
orphans.
connection:
uri: "{{ inputs.connection_uri != '' ? inputs.connection_uri :
secret('MONGODB_URI') }}"
database: "{{ inputs.database }}"
collection: "{{ inputs.child_collection }}"
pipeline: "{{ render(vars.detect_pipeline) }}"
store: FETCH
- id: report
type: io.kestra.plugin.core.log.Log
description: Log the counts the gates below read. The age cutoff is printed so a
spared child can be checked by hand.
message: >-
Checked {{ inputs.database }}.{{ inputs.child_collection }} references to
{{ inputs.parent_collection }}.{{ inputs.parent_key_field }}. Children
carrying a reference: {{ outputs.detect.rows[0].referencing ?? 0 }}.
Dangling: {{ outputs.detect.rows[0].orphans ?? 0 }}. Past the {{
inputs.min_orphan_age_minutes }} minute age gate: {{
outputs.detect.rows[0].deletable ?? 0 }}. Spared as too fresh to judge: {{
outputs.detect.rows[0].spared_fresh ?? 0 }}. Targeted this run: {{
outputs.detect.rows[0].targeted ?? 0 }} (delete_limit {{
inputs.delete_limit }}). Orphan share {{
outputs.detect.rows[0].orphan_ratio ?? 0 }} against ceiling {{
inputs.max_orphan_ratio }}. Age cutoff {{ render(vars.cutoff_iso) }}.
Oldest orphans: {{ outputs.detect.rows[0].sample ?? 'none' }}.
- id: route
type: io.kestra.plugin.core.flow.If
description: A dangling reference means alert and consider the delete. A
collection whose references all resolve just logs and stops.
condition: "{{ (outputs.detect.rows[0].orphans ?? 0) > 0 }}"
then:
- id: alert
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{{ {'text': ('MongoDB orphan guard on ' ~ inputs.database ~ '.' ~ inputs.child_collection ~ ': ' ~ (outputs.detect.rows[0].orphans ?? 0) ~ ' document(s) reference a missing ' ~ inputs.parent_collection ~ ' parent through ' ~ inputs.reference_field ~ '. ' ~ (outputs.detect.rows[0].deletable ?? 0) ~ ' past the age gate, ' ~ (outputs.detect.rows[0].spared_fresh ?? 0) ~ ' spared as fresh. Orphan share ' ~ (outputs.detect.rows[0].orphan_ratio ?? 0) ~ '. Execution ' ~ execution.id ~ '.')} | toJson }}
- id: planned
type: io.kestra.plugin.core.log.Log
description: What a remediation run would do, printed on every run including a
dry one.
message: >-
A cleanup would delete {{ outputs.detect.rows[0].targeted ?? 0 }} aged
orphan(s) from {{ inputs.database }}.{{ inputs.child_collection }} in
batches of {{ inputs.batch_size }}, by _id only. {{
outputs.detect.rows[0].spared_fresh ?? 0 }} dangling child(ren) stay
because they are younger than {{ inputs.min_orphan_age_minutes }}
minutes. dry_run={{ inputs.dry_run }} auto_remediate={{
inputs.auto_remediate }}. A delete runs only when auto_remediate is
true and dry_run is false and a reviewer approves the pause.
- id: remediate_gate
type: io.kestra.plugin.core.flow.If
description: The delete opens only when auto_remediate is true and dry_run is false.
condition: "{{ inputs.auto_remediate and not inputs.dry_run }}"
then:
- id: safety_check
type: io.kestra.plugin.core.flow.If
description: >-
Stop the write when the join looks broken rather than the data.
Disjoint BSON types on the two sides of the join read as a join
that matches nothing. So does an orphan share above the ceiling.
Deleting on that evidence would empty the collection.
condition: "{{ render(vars.type_mismatch) == 'true' or
(outputs.detect.rows[0].orphan_ratio ?? 0) >
inputs.max_orphan_ratio }}"
then:
- id: refuse_delete
type: io.kestra.plugin.core.execution.Fail
errorMessage: >-
Refusing to delete from {{ inputs.database }}.{{
inputs.child_collection }}. Join types disjoint: {{
render(vars.type_mismatch) }}. Orphan share {{
outputs.detect.rows[0].orphan_ratio ?? 0 }} against ceiling {{
inputs.max_orphan_ratio }}. Types seen: {{
outputs.type_probe.rows ?? 'none' }}. The reference field and
the parent key may hold different BSON types, which leaves the
join incapable of matching. Or so much of the collection looks
orphaned that the join is the likelier fault. Fix the
reference types or raise max_orphan_ratio deliberately.
Nothing was deleted.
else:
- id: hold
type: io.kestra.plugin.core.flow.Pause
description: >-
The delete is irreversible, so a human answers first. With no
answer nothing is deleted and the findings stay open.
pauseDuration: "{{ inputs.approval_timeout }}"
onResume:
- id: approved
type: BOOL
displayName: Delete the aged orphan documents?
defaults: false
- id: note
type: STRING
displayName: Decision note
defaults: ""
- id: apply
type: io.kestra.plugin.core.flow.If
condition: "{{ outputs.hold.onResume.approved ?? false }}"
then:
- id: delete_branch
type: io.kestra.plugin.core.flow.If
description: Only open the write path when the detection targeted at least one
aged orphan.
condition: "{{ (outputs.detect.rows[0].targeted ?? 0) > 0 }}"
then:
- id: remediate
type: io.kestra.plugin.core.flow.Loop
description: >-
One batch of orphan ids per iteration.
concurrencyLimit 1 keeps the batches sequential,
because parallel deletes on one collection race for no
gain.
concurrencyLimit: 1
values: "{{ render(vars.delete_batches) }}"
tasks:
- id: purge
type: io.kestra.plugin.mongodb.Delete
description: >-
One DELETE_MANY per batch, filtered on the
targeted _id list only. It never filters on the
reference field, so a child whose parent exists
cannot be caught by a stale filter, nor can a
fresh child the age gate spared.
connection:
uri: "{{ inputs.connection_uri != '' ? inputs.connection_uri :
secret('MONGODB_URI') }}"
database: "{{ inputs.database }}"
collection: "{{ inputs.child_collection }}"
operation: DELETE_MANY
filter: "{{ {'ids': item.value} | jq('{\"_id\": {\"$in\": (.ids | fromjson)}}')
| first | toJson }}"
- id: log_deleted
type: io.kestra.plugin.core.log.Log
message: >-
Delete loop finished for {{
outputs.detect.rows[0].targeted ?? 0 }} aged
orphan(s). The re-join below is the proof, not this
count.
- id: reverify
type: io.kestra.plugin.mongodb.Aggregate
description: >-
Read-only. Runs the same `$lookup` over the whole
child collection again, against the same frozen age
cutoff, so a child a writer inserted while the run was
paused is counted rather than hidden.
connection:
uri: "{{ inputs.connection_uri != '' ? inputs.connection_uri :
secret('MONGODB_URI') }}"
database: "{{ inputs.database }}"
collection: "{{ inputs.child_collection }}"
pipeline: "{{ render(vars.reverify_pipeline) }}"
store: FETCH
- id: log_reverify
type: io.kestra.plugin.core.log.Log
message: >-
Re-join after the delete found {{
outputs.reverify.rows[0].remaining_orphans ?? 0 }}
dangling reference(s) left, of which {{
outputs.reverify.rows[0].remaining_deletable ?? 0 }}
are past the age gate. Children still carrying a
reference: {{
outputs.reverify.rows[0].still_referencing ?? 0 }}.
Worst remaining: {{ outputs.reverify.rows[0].worst ??
'none' }}.
- id: verify_gate
type: io.kestra.plugin.core.flow.If
description: Fail the run if any orphan past the age gate is still there after
the delete.
condition: "{{ (outputs.reverify.rows[0].remaining_deletable ?? 0) > 0 }}"
then:
- id: remediation_failed
type: io.kestra.plugin.core.execution.Fail
errorMessage: >-
The delete ran but the re-join still finds {{
outputs.reverify.rows[0].remaining_deletable ?? 0
}} aged orphan(s) in {{ inputs.database }}.{{
inputs.child_collection }}. Causes to check: more
were aged than delete_limit {{ inputs.delete_limit
}} allowed; a writer added another child for a
missing parent during the run; the delete was
refused by the server. A refused write still
reports success, so this re-join is the only thing
that decides.
else:
- id: remediated
type: io.kestra.plugin.core.log.Log
message: >-
Cleared {{ outputs.detect.rows[0].targeted ?? 0 }}
aged orphan(s) from {{ inputs.database }}.{{
inputs.child_collection }}. The re-join finds {{
outputs.reverify.rows[0].remaining_deletable ?? 0
}} aged orphan(s) left and {{
outputs.reverify.rows[0].remaining_orphans ?? 0 }}
dangling reference(s) in total, the difference
being children too fresh to judge. Every remaining
reference that is old enough to judge now resolves
to a parent.
else:
- id: nothing_to_delete
type: io.kestra.plugin.core.log.Log
message: >-
{{ outputs.detect.rows[0].orphans ?? 0 }} dangling
reference(s) were reported but none is past the age
gate, so nothing was deleted. Every one of them was
written in the last {{ inputs.min_orphan_age_minutes
}} minutes. Lower min_orphan_age_minutes only if your
writers always insert the parent first.
else:
- id: declined
type: io.kestra.plugin.core.log.Log
message: >-
Approval not granted{{ (outputs.hold.onResume.note ?? '')
== '' ? '' : (', note: ' ~ outputs.hold.onResume.note) }}.
No document was deleted. The dangling references stay in
place.
else:
- id: clean
type: io.kestra.plugin.core.log.Log
message: >-
No dangling references. All {{ outputs.detect.rows[0].referencing ?? 0
}} child document(s) in {{ inputs.database }}.{{
inputs.child_collection }} that carry {{ inputs.reference_field }}
resolve to a {{ inputs.parent_collection }} parent.
errors:
- id: alert_flow_failure
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{{ {'text': ('MongoDB orphan guard failed on ' ~ inputs.database ~ '.' ~ inputs.child_collection ~ ' (execution ' ~ execution.id ~ '). Check the logs.')} | toJson }}
triggers:
- id: daily
type: io.kestra.plugin.core.trigger.Schedule
description: >-
Shipped disabled. Its inputs force safe mode, so even an enabled schedule
only ever reports and never deletes on its own.
cron: "0 3 * * *"
disabled: true
inputs:
dry_run: true
auto_remediate: false