Snapshotting Validator State Before collMod

Capturing a collection’s exact validator state the instant before a collMod mutates it is the precondition that makes every schema change reversible, and it is the specific procedure this page delivers under rollback automation for schema changes within the wider Zero-Downtime Schema Migration Automation in MongoDB framework. The reader outcome is a runnable capture function that serializes the current validator, validationLevel, and validationAction into a durable, timestamped artifact keyed to a commit — a stored fact that a later restore replays byte-for-byte instead of reconstructing from memory during an incident. The trap this avoids is the most common one in schema automation: applying a change with no recorded pre-state, so that the only path back is a human recalling what the rule used to look like while the write path is stalled. A correct snapshot removes that guesswork entirely.

Operational Mechanics and Write-Path Impact

A validator is not stored inside documents; it lives in the collection’s catalog entry alongside its indexes and options. That placement is what makes snapshotting cheap and exact: reading it is a metadata lookup, not a scan. Two catalog-facing commands expose it. In mongosh, db.getCollectionInfos({ name: "<coll>" })[0].options returns the validator, validationLevel, and validationAction as they truly are on the primary. From a driver, the same data arrives through listCollections with a name filter, under cursor.firstBatch[0].options. Both read the authoritative state — the ground truth the storage engine enforces — not whatever a deploy script last intended.

The capture itself has zero write-path impact. It issues no collMod, takes no exclusive lock, and touches no document, so it can run against a hot production collection at any moment without a stall. This is deliberate: the snapshot must be safe to take unconditionally, immediately before every change, because a capture that carried operational risk would tempt engineers to skip it — the exact circumstance under which an irreversible change slips through.

What must survive serialization is the full three-field triple, because enforcement posture is defined by all three together. A validator document alone is ambiguous: the same $jsonSchema behaves completely differently under strict + error than under moderate + warn. A snapshot that records only the schema and discards the level and action can restore a collection to a runtime state it was never actually in.

Reading the live triple from the catalog and serializing it to a durable artifact The collection catalog holds the current validator, validationLevel and validationAction. A read via listCollections or getCollectionInfos pulls that triple out without a lock. A serialize step encodes BSON types with extended JSON so nothing is lost. The result is stored as a durable artifact carrying a timestamp and commit hash, ready for a later restore. A caption notes the capture has no write-path impact. Collection catalog validator · level · action Read triple listCollections · no lock Serialize extended JSON types Artifact timestamp + commit Metadata-only capture — no collMod, no lock, no write-path impact; safe to run before every change

The other serialization hazard is type fidelity. A $jsonSchema freely uses BSON constructs — bsonType: "date", bsonType: "objectId", bsonType: "decimal" — and the validator document can itself contain typed literals in enum arrays or minimum/maximum bounds. If you serialize that to plain JSON with a naive encoder, a Decimal128 bound or a date literal collapses to a string or a float, and the restored validator is subtly different from the original. The fix is to serialize with MongoDB Extended JSON (canonical mode), which round-trips every BSON type losslessly. PyMongo ships this in bson.json_util; storing the artifact as a BSON document in a MongoDB collection sidesteps the problem entirely because BSON is the native representation.

Exact Diagnostic Fingerprints and Fast Resolution

Snapshot failures are quiet — a missing or lossy capture causes no error until you try to restore it, which is the worst possible moment to discover the problem. These are the signatures to detect proactively.

Signature Where it appears Root cause Resolution
Restore replays an empty validator: {} when the collection had a rule On restore, enforcement unexpectedly detaches Capture ran against the wrong namespace or before the validator existed Assert the artifact’s validator is non-empty when a rule was expected; re-snapshot from the correct collection
A date or decimal bound comes back as a string or float On restore or artifact review Serialized with a plain JSON encoder, not Extended JSON Re-serialize the live triple with bson.json_util.dumps in canonical mode
KeyError: 'validationLevel' when reading the artifact In restore code Only the validator was captured; level/action dropped Capture all three fields; default missing ones to off/error explicitly
Two artifacts for one collection with the same captured_at window In the snapshot store A change was applied without its own fresh capture Enforce one snapshot per change in the applying automation
listCollections returns an empty firstBatch During capture Collection does not exist yet on this node Treat a non-existent collection as the sentinel triple: empty validator, level off

Confirm a capture is faithful by round-tripping it: serialize, deserialize, and compare the result to the live options field-for-field before you trust it as a rollback target.

from bson import json_util

def is_faithful(live_options: dict, serialized: str) -> bool:
    """A snapshot is only usable if it deserializes back to the live triple exactly."""
    restored = json_util.loads(serialized)
    return all(
        restored.get(k) == live_options.get(k)
        for k in ("validator", "validationLevel", "validationAction")
    )

Step-by-Step Playbook

The capture is a short, deterministic sequence. Each step is safe to re-run and never touches document data.

  1. Read the live triple from the catalog. Use listCollections with a name filter and take options from the first batch entry. This is authoritative state, not deploy-script intent:

    from pymongo import MongoClient
    
    client = MongoClient("mongodb://localhost:27017/?replicaSet=rs0")
    db = client.billing
    
    def read_triple(coll: str) -> dict:
        info = db.command("listCollections", filter={"name": coll})
        batch = info["cursor"]["firstBatch"]
        opts = batch[0].get("options", {}) if batch else {}
        return {
            "validator": opts.get("validator", {}),
            "validationLevel": opts.get("validationLevel", "off"),
            "validationAction": opts.get("validationAction", "error"),
        }
    # read_triple("invoices") -> {'validator': {...}, 'validationLevel': 'strict', ...}
  2. Serialize with type fidelity. Encode the triple as canonical Extended JSON so every BSON type in the schema survives a round-trip:

    from bson import json_util
    
    def serialize(triple: dict) -> str:
        return json_util.dumps(triple, json_options=json_util.CANONICAL_JSON_OPTIONS)
    # Dates and Decimal128 bounds come back as {"$date": ...} / {"$numberDecimal": ...}
  3. Store the artifact with a timestamp and commit. Persist to a durable location keyed to the collection, so a restore can find the most recent pre-change state:

    from datetime import datetime, timezone
    
    def store_snapshot(coll: str, commit: str) -> dict:
        triple = read_triple(coll)
        artifact = {
            "coll": coll,
            "captured_at": datetime.now(timezone.utc),
            "commit": commit,
            **triple,   # native BSON types persist losslessly in a MongoDB doc
        }
        artifact["_id"] = db._schema_snapshots.insert_one(artifact).inserted_id
        return artifact
    # store_snapshot("invoices", "a1b9f3c") -> artifact with a stored _id
  4. Verify before you proceed. Round-trip the artifact against the live options and refuse to continue if they diverge — an unfaithful snapshot is worse than none, because it looks safe:

    live = read_triple("invoices")
    assert is_faithful(live, serialize(live)), "snapshot is not faithful; do not apply the change"

Only after step 4 passes should the collMod that mutates the validator run. The snapshot and the change belong to the same automation transaction of intent: capture, verify, then mutate.

Failure Modes & Rollback

Each step has a distinct way to fail, and because the snapshot itself writes nothing to the target collection, “rollback” here means discarding a bad artifact and re-capturing rather than reversing a data change.

  • Capture against the wrong node (step 1). Reading from a lagging secondary can return an older triple than the primary currently enforces. Read with primary read preference during a migration so the snapshot matches the state the change is about to mutate. Recovery: re-read from the primary and discard the stale artifact.
  • Silent type loss on serialize (step 2). A plain json.dumps will raise on a datetime or coerce a Decimal128, producing an artifact that restores a subtly wrong schema. Recovery: always use bson.json_util; the round-trip assertion in step 4 catches any encoder that slips through.
  • Store write rejected (step 3). If the _schema_snapshots collection has its own validator, a malformed artifact insert can fail with code 121. Keep the snapshot store schema-light or unvalidated. Recovery: the change must not proceed until the artifact is durably stored — treat a failed snapshot insert as a hard stop.
  • Verification mismatch (step 4). A non-zero diff means the capture is not a trustworthy rollback target. Recovery: do not apply the collMod; re-run the capture and investigate the divergence before mutating anything.

The genuine rollback these snapshots enable is on the change they precede, not on the capture itself. Once a stored artifact exists, reverting the subsequent collMod is a single metadata-only command whose time-to-recover is the replication lag of the slowest secondary — typically seconds. Generating that revert automatically from the stored artifact is the whole of automating collMod rollback scripts.

Frequently Asked Questions

Why not just re-derive the old validator from version control instead of snapshotting?

Because version control records what you intended to deploy, not what is actually enforced on the collection right now. Drift is common: a manual collMod, a partially-applied migration, or a concurrent change can leave the live validator different from the last committed schema. Snapshotting reads the catalog directly, so the artifact is the ground truth the storage engine holds — the only state a restore can safely return to.

What breaks if I serialize the snapshot with a plain JSON encoder?

BSON types that JSON cannot represent are lost or mangled. A bsonType: "date" literal bound becomes a string, a Decimal128 minimum becomes a float, and an objectId collapses to a hex string. The restored validator then differs from the original in ways that only surface as unexpected acceptances or rejections later. Serialize with MongoDB Extended JSON in canonical mode, or store the artifact as a native BSON document, so every type round-trips losslessly.

Does taking a snapshot affect live traffic on the collection?

No. The capture is a metadata read through listCollections or getCollectionInfos. It issues no collMod, acquires no exclusive lock, and never touches a document, so it is safe to run against a hot collection immediately before every change. That zero-cost property is intentional — a capture with any operational risk would be skipped under pressure, which is precisely when an irreversible change slips through.