If icon
Fail icon
Aggregate icon
Log icon
SlackIncomingWebhook icon
Pause icon
Loop icon
Update icon
Find icon
Schedule icon

MongoDB Document Validation Drift Repair Guard

Find MongoDB documents that fail their collection $jsonSchema validator, backfill a missing required field behind an approval, then prove it landed.

Categories
DataInfrastructure

A $jsonSchema validator added to a collection that already holds data applies to new writes only. MongoDB never goes back over what is already there. So every document written before the validator keeps breaking the rule nobody checks: a required field is missing or a field holds the wrong BSON type. Readers that trust the schema break on those documents. The day somebody runs a migration that touches one, the write fails with error 121 even though the migration had nothing to do with the broken field.

This blueprint reads the collection with $nor on the schema, which is exactly the set of documents the validator would reject today. Then it splits that set in two. A document whose only problem is the one missing required field can be repaired from a default. Everything else is reported with its BSON value type and its missing required keys, then left alone. Behind a safe mode gate and an approval it backfills the repairable documents with $set, in batches of document ids, reads the collection again and fails the run if any repairable violator is still there.

This blueprint was created by zkasuran.

Safety

  • Read-only by default. The first run with the defaults only counts and reports. A write needs auto_remediate true, dry_run false, then a human answering the Pause. Three conditions, all of them, before a document is touched.
  • What it refuses to repair is the point. A backfill can supply a default for a field that is absent. It cannot invent the value that field should have held. It must never coerce a field that holds the wrong type, because a guessed cast writes plausible wrong data into production and that is worse than the broken document it replaces. Those documents are counted, named with their BSON type, then left for a human.
  • The input check runs before the first read. A required_field the schema does not require is refused. A default_value whose JSON type contradicts the bsonType the schema declares for that field is refused. A repair that writes a number into a string field only trades one broken rule for another.
  • The write is scoped to the document ids the detection classified, in batches of batch_size, one batch at a time. A document that changed since the detection ran no longer matches the filter, so it is skipped rather than written over. Drift created while the run was paused is never swept in silently. It shows up in the re-read instead.
  • The re-read is the proof, not the modified count. If the collection still holds a document that fails the validator only because the field is missing, the run fails.

How it works

  1. check_inputs (io.kestra.plugin.core.flow.If) refuses a required_field that is not in the schema required list and a default_value whose JSON type contradicts the declared bsonType. It fails the run before the first read.
  2. detect (io.kestra.plugin.mongodb.Aggregate) matches {"$nor": [{"$jsonSchema": schema}]}, then counts in one pass: every violator, the ones repairable by backfilling the field, the capped list of their ids, plus a sample of the refused documents carrying the $type of the field and the required keys they are missing. Repairability is decided by testing the document against the same schema with the target field dropped from required, so a document that is broken in a second way is never claimed as repairable.
  3. report (io.kestra.plugin.core.log.Log) logs the counts. Task outputs are not in the execution JSON, so the numbers the gates act on are written to the log.
  4. route (io.kestra.plugin.core.flow.If): zero violators logs clean and stops. One or more sends alert to Slack and prints planned, which states what a repair 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 id batches and backfill (io.kestra.plugin.mongodb.Update, UPDATE_MANY) applies the $set. With nothing repairable the run logs nothing_to_repair and writes nothing.
  7. repaired_check (io.kestra.plugin.mongodb.Aggregate) measures the targeted ids, reverify (io.kestra.plugin.mongodb.Find) runs the detection predicate across the collection again and verify_gate fails the run with io.kestra.plugin.core.execution.Fail if anything repairable remains. Without approval declined records the decision.
  8. errors posts to Slack when any task fails, for example when MongoDB is unreachable or the validator rejects the repair itself.

What you get

  • The exact count of documents your own validator would reject right now, which no MongoDB command reports for you.
  • That count split into what a default can fix and what needs a person, with the refused documents named by id, by the BSON type of the field and by the required keys they lack.
  • A gated, approved backfill of only the repairable documents, in bounded batches.
  • A re-read that fails the run when the collection still does not match its own validator, so a partial write or a writer still producing drift is caught rather than hidden.

Who it's for

  • Teams who added a validator to a live collection and never cleaned up what predates it.
  • Anyone whose migration failed with error 121 on documents that were already broken.
  • Platform engineers who want schema drift reported on a schedule with an audit trail per run.

Why orchestrate this with Kestra

MongoDB enforces a validator at write time and offers nothing to audit what is already stored. There is no command that lists the documents a validator would reject, so the usual answer is a one-off script somebody runs by hand once. This flow makes it a scheduled, reviewable job. The detection is one server-side pipeline, the repair 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-read turns the write 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 held 13 documents written before the validator was added with collMod: 6 missing the required status field, 4 fully compliant, 2 holding status as an int and a bool, 1 missing both required fields. The validator requires order_id and status as strings. Every before and after number was read back with mongosh, not only from the Kestra log.

Run Inputs Result
Clean a collection with 3 compliant documents 0 violators. clean logged, no Slack alert, SUCCESS
Detect defaults 9 violators, 6 repairable, 3 refused with types int, bool, missing keys. Slack alert, SUCCESS
Dry run defaults Reports the repair it would make. mongosh still counts 7 documents with no status
Armed but dry auto_remediate: true, dry_run: true Gate stayed closed, no Pause, nothing written
Declined auto_remediate: true, dry_run: false, resumed approved: false Paused, then declined. Nothing written
Repair the same, resumed approved: true 6 backfilled. mongosh: violators 9 to 3, compliant 4 to 10, the 4 compliant documents byte for byte unchanged
Nothing repairable run again on the repaired collection 3 violators, 0 repairable, nothing_to_repair, no write
Re-verify fails validator set to validationAction: warn, a writer unset status on a compliant document during the Pause The 6 targeted documents were repaired, the collection wide re-read found 1 left, verify_gate FAILED the run and errors alerted
Bad default default_value: 0 against a string field Refused before any read
Wrong field required_field: total, which the schema does not require Refused before any read
Unreachable connection_uri pointed at a dead host detect failed, errors posted to Slack, execution FAILED

Does the detector actually fail

Four ways, all measured. The clean collection returned 0 while the drifted one returned 9 with nothing else changed. The refused class is not a rounding error: 3 documents the flow could have quietly coerced were counted, named by BSON type and left alone, which mongosh confirms after the repair run. The repair itself is checked twice, by id and collection wide. And the gate was forced to fire by unsetting a required field on a compliant document while the run was paused, which the id scoped write correctly ignored, so only the re-read caught it and the run failed.

The real world bite was reproduced too. With a violator present, updateOne setting an unrelated field on it was rejected with error 121 by the validator, because the result still failed the schema. After the repair run the identical update succeeded.

Prerequisites

  • A reachable MongoDB instance and a user that can read the collection and run an update on it.
  • A collection with a $jsonSchema validator. Read the schema with db.getCollectionInfos({name: 'orders'})[0].options.validator and pass the $jsonSchema object as the json_schema input.
  • A value worth writing into the missing field. If no safe default exists, run the flow read-only and use the report.
  • Local testing: docker run -d --name mdb --network mynet mongo:7.0, insert documents, add the validator with collMod afterwards, 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 audit
json_schema the worked example The $jsonSchema the collection is validated against
required_field status The one missing required field this run backfills
default_value "unknown" JSON literal written into that field. Quote a string value
repair_limit 1000 Ceiling on documents repaired per run
batch_size 250 Document ids per write batch
sample_limit 25 Refused documents named per report, also the re-check read cap
approval_timeout PT2H How long the pause waits for an answer
auto_remediate false Allow the backfill
dry_run true Must be false for a write to apply

Quick start

  1. Add the MONGODB_URI and SLACK_WEBHOOK_URL secrets.
  2. Paste your collection $jsonSchema into json_schema, set database and collection, then run the flow. It reports the drift and stops.
  3. Read the refused list. Decide whether a default is right for the field you are backfilling.
  4. Set required_field with its default_value, then auto_remediate: true and dry_run: false, run it again and answer the pause. The backfill runs and the re-read proves the repairable drift is gone.
  5. Enable the daily schedule, which always runs in safe mode.

How to extend

  • Audit several collections by wrapping the tasks in a io.kestra.plugin.core.flow.Loop over a list of collection and schema pairs, with concurrencyLimit: 1 on the part that writes.
  • Read the schema from the collection itself with a runCommand style task instead of passing it as an input, once your plugin build ships one, so the audit can never drift from the live validator.
  • Repair more than one missing field in a run by extending the $set document. Apply them in one write, because under validationLevel: strict an intermediate document that is still invalid is rejected.
  • Route the refused documents to a ticket instead of a log line, so the ones needing a human decision get an owner.
  • Once the collection is clean, move the validator to validationLevel: strict with validationAction: error so the drift cannot come back.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.