mongorestore --oplogReplay gives you point-in-time recovery for a whole instance, replayed from the captured oplog of a single backup. It has no equivalent for a single database or a single collection. Say you need “restore just this one collection to 4:35pm” instead of the whole instance. The supported tooling has nothing to offer for in-place namespace point-in-time recovery (PITR). The only way to build it yourself is with applyOps, the server command mongorestore uses to replay oplog entries.
In fact, mongorestore refuses to combine --oplogReplay with any filter, like --db or --nsInclude:
$ mongorestore --oplogReplay --db myapp --drop dump/myapp
The --db and --collection flags are deprecated for this use-case; please use --nsInclude instead, i.e. with --nsInclude=${DATABASE}.${COLLECTION}
Failed: cannot use --oplogReplay with includes specified
0 document(s) restored successfully. 0 document(s) failed to restore.
$ mongorestore --oplogReplay --nsInclude 'myapp.*' --drop dump
Failed: cannot use --oplogReplay with includes specified
0 document(s) restored successfully. 0 document(s) failed to restore.
I was facing these issues while adding point-in-time recovery to the MongoDB DMK. I ran into several things that were not obvious from the applyOps reference page alone.
What applyOps does
applyOps is a database command that takes a list of raw operations, in the exact format oplog entries are stored in. It applies them directly, bypassing the normal insert/update/delete driver API:
db.adminCommand({
applyOps: [
{ op: "i", ns: "myapp.orders", o: { _id: 1, status: "shipped" } }
]
})
Each entry uses the oplog field names:
opisifor insert,ufor update,dfor delete,cfor a collection-level command.nsis the target namespace.othe operation data. The MongoDB documentation describes it as primarily an internal command, mainly there to support sharding.mongorestoreuses it as its replay mechanism, by reading oplog entries out of a backup, and handing them toapplyOps.
This is the part that makes namespace-scoped PITR possible. To achieve this in-place, you need to read the oplog, keep only the entries for the database or collection you care about, and replay just these operations.
Batch problem: not every write is a separate oplog entry
The first problem comes from something as simple as insertMany(). A batch of statements committed together doesn’t produce one oplog entry per statement. It produces a single top-level entry with op: "c", ns: "admin.$cmd", and the real operations nested inside o.applyOps:
{"op":"c","ns":"admin.$cmd","o":{"applyOps":[
{"op":"i","ns":"myapp.orders","o":{"_id":1,"n":1}},
{"op":"i","ns":"myapp.orders","o":{"_id":2,"n":2}},
{"op":"i","ns":"myapp.orders","o":{"_id":3,"n":3}}
]},"ts":{"$timestamp":{"t":1787326648,"i":2}}}
A namespace filter that only looks at the top-level ns field never sees myapp.orders here at all. It sees admin.$cmd and moves on, skipping every write inside the batch. Any manual oplog replay must recursively unpack nested applyOps entries before filtering by namespace, or it will drop real writes without any error.
applyOps rejects entries from a freshly restored collection
Once the batch problem was fixed, replaying entries against a just-restored collection still failed:
MongoServerError: applyOps had one or more errors applying ops
The response contained no per-entry detail beyond results: [false, false]. The real cause is that every oplog entry also has a ui field. It is the internal UUID of the collection at the moment the entry was written. applyOps checks that UUID against the UUID of the live collection and rejects the operation on any mismatch.
mongorestore always assigns a new UUID to a collection it creates during a restore. The exception is when you explicitly pass both --drop and --preserveUUID together. The UUID of a restored collection never matches what the oplog entries reference, which is why every single op gets rejected, correctly but without saying why.
mongorestore oplog replay code strips the ui field before calling applyOps, unless --preserveUUID was explicitly requested:
if !restore.OutputOptions.PreserveUUID {
op.UI = nil
}
When building a custom oplog replay instead of using mongorestore, you need the same fix. Drop ui from every entry before using it with applyOps. The operation then resolves by namespace, instead of a UUID that no longer exists.
EJSON relaxed mode changes your data types
Reading oplog entries back out of an archive, to filter and replay them, means going through EJSON.stringify() and EJSON.parse() in mongosh. Both default to relaxed mode, and relaxed mode collapses NumberLong into a JavaScript number:
> EJSON.stringify({txnNumber: NumberLong(1)})
'{"txnNumber":1}'
> EJSON.stringify({txnNumber: NumberLong(1)}, null, 0, {relaxed: false})
'{"txnNumber":{"$numberLong":"1"}}'
If you feed the relaxed-mode output back into applyOps, the server rejects it:
BSON field 'OplogEntryBase.txnNumber' is the wrong type 'int', expected type 'long'
In this case, nothing about the data itself was wrong, the type was just downgraded through JSON. You must pass {relaxed: false} on both EJSON.stringify() and EJSON.parse(). Do it whenever oplog entries (or any BSON document with Long, Timestamp, or Decimal128 fields) need to persist through a JSON stringify–parse round trip.
dropDatabase inside a replay window
The last important point is more a warning than a bug. A dropDatabase in the oplog window is replayed like any other entry, and it deletes data. I tested it in three steps.
applyOpsrefuses a batch that mixesdropDatabasewith other operations:
> print(EJSON.stringify(db.adminCommand({applyOps: [
... {op: "i", ns: "myapp.orders", o: {_id: 10}},
... {op: "c", ns: "myapp.$cmd", o: {dropDatabase: 1}}
... ]})))
dropDatabase in an applyOps must be the only entry
- The same
dropDatabasesent alone is accepted and applied:
> print(EJSON.stringify(db.adminCommand({applyOps: [{op: "c", ns: "myapp.$cmd", o: {dropDatabase: 1}}]})))
{"applied":1,"results":[true],"ok":1,...}
mongorestore --oplogReplaydoes not stop on adropDatabase. The base dump had the document_id: 1. The captured oplog then inserted_id: 2, dropped the database, re-created the collection and inserted_id: 3:
$ mongorestore --oplogReplay --drop dump
replaying oplog
applied 6 oplog entries
> db.getSiblingDB("myapp").orders.find()
[
{
_id: 3
}
]
Documents created before the drop are gone, because the restore followed the history up to the end of the window. Whether to stop at a dropDatabase depends on what you restore:
- A single collection restored in place: stop before any
dropDatabase. Replaying the drop would also delete the other collections of the database, which are not part of the restore. - A whole database or instance: replaying
dropDatabaseis correct, since the drop is part of the history. The risk is a target time set after the drop, which restores an empty database.
In both cases, I would recommend to detect the dropDatabase in the replay window and report its timestamp. If your target moment is after a real dropDatabase, replay deletes the data, because the drop is part of the history. Pick a target just before the drop’s timestamp instead. Of course, this works only if your base backup is older than the drop. The oplog slices must also cover the gap, since a backup taken after the drop no longer contains the data.
Summary
Building point-in-time recovery (PITR) for a single database or collection on top of applyOps means dealing with four things. mongorestore already handles them for a full-instance restore, and you now have to handle them yourself:
- Unpack nested
applyOpsbatches before filtering by namespace, or multi-document writes are silently ignored. - Strip the
uifield from every entry before replay, matching whatmongorestoredoes by default. - Use
{relaxed: false}on everyEJSON.stringify()/EJSON.parse()round trip, orLong/Timestamp/Decimal128and other fields can be changed. - Stop at a
dropDatabasewhen restoring a single collection in place.