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:

  • op is i for insert, u for update, d for delete, c for a collection-level command.
  • ns is the target namespace.
  • o the operation data. The MongoDB documentation describes it as primarily an internal command, mainly there to support sharding. mongorestore uses it as its replay mechanism, by reading oplog entries out of a backup, and handing them to applyOps.

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.

  1. applyOps refuses a batch that mixes dropDatabase with 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
  1. The same dropDatabase sent 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,...}
  1. mongorestore --oplogReplay does not stop on a dropDatabase. 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 dropDatabase is 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 applyOps batches before filtering by namespace, or multi-document writes are silently ignored.
  • Strip the ui field from every entry before replay, matching what mongorestore does by default.
  • Use {relaxed: false} on every EJSON.stringify()/EJSON.parse() round trip, or Long/Timestamp/Decimal128 and other fields can be changed.
  • Stop at a dropDatabase when restoring a single collection in place.