From 4b3998050d74ab931f77ce8b94249afd26f670a4 Mon Sep 17 00:00:00 2001 From: Alec Gibson <12036746+alecgibson@users.noreply.github.com> Date: Fri, 19 Jun 2026 21:04:36 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Serve=20current=20snapshot=20whe?= =?UTF-8?q?n=20fetching=20by=20timestamp=20after=20latest=20op?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At the moment, `fetchSnapshotByTimestamp` only fetches the current snapshot directly when the requested timestamp is `null`. For any other timestamp it rebuilds the snapshot from the milestone snapshot plus ops. This means that if the requested timestamp is after the document's latest op (i.e. after the current snapshot's `mtime`), and the ops have since been deleted or TTLed away, the fetch fails with a "Missing ops" error - even though the current snapshot is intact and is exactly the snapshot we should be serving. This change always fetches the current snapshot first, and serves it directly when the requested timestamp is after its `mtime`. As well as fixing the missing-ops case, this avoids replaying ops whenever the timestamp is newer than the current version, at the cost of one extra snapshot lookup in the cases that do still need ops. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- lib/backend.js | 45 ++++++++++++++--------- test/client/snapshot-timestamp-request.js | 15 ++++++++ 2 files changed, 42 insertions(+), 18 deletions(-) diff --git a/lib/backend.js b/lib/backend.js index dcc95aa33..1302e29de 100644 --- a/lib/backend.js +++ b/lib/backend.js @@ -881,29 +881,38 @@ Backend.prototype._fetchSnapshotByTimestamp = function(collection, id, timestamp var from = 0; var to = null; - var shouldGetLatestSnapshot = timestamp === null; - if (shouldGetLatestSnapshot) { - return backend.db.getSnapshot(collection, id, null, null, function(error, snapshot) { - if (error) return callback(error); - - callback(null, snapshot); - }); - } - - milestoneDb.getMilestoneSnapshotAtOrBeforeTime(collection, id, timestamp, function(error, snapshot) { + // Always fetch the current snapshot first. We request its metadata so that we + // can read its mtime, which lets us serve the current snapshot directly when + // the requested timestamp is after it. This avoids replaying ops when they + // aren't needed, and - crucially - still works when older ops have been + // deleted/TTLed and the current version can no longer be rebuilt from ops. + db.getSnapshot(collection, id, null, {metadata: true}, function(error, currentSnapshot) { if (error) return callback(error); - milestoneSnapshot = snapshot; - if (snapshot) from = snapshot.v; - milestoneDb.getMilestoneSnapshotAtOrAfterTime(collection, id, timestamp, function(error, snapshot) { + var mtime = currentSnapshot.m && currentSnapshot.m.mtime; + var shouldGetLatestSnapshot = timestamp === null || (mtime != null && timestamp > mtime); + if (shouldGetLatestSnapshot) { + // Strip the metadata that we only fetched in order to compare the mtime, + // so that the returned snapshot is consistent with the op-replayed path. + currentSnapshot.m = null; + return callback(null, currentSnapshot); + } + + milestoneDb.getMilestoneSnapshotAtOrBeforeTime(collection, id, timestamp, function(error, snapshot) { if (error) return callback(error); - if (snapshot) to = snapshot.v; + milestoneSnapshot = snapshot; + if (snapshot) from = snapshot.v; - var options = {metadata: true}; - db.getOps(collection, id, from, to, options, function(error, ops) { + milestoneDb.getMilestoneSnapshotAtOrAfterTime(collection, id, timestamp, function(error, snapshot) { if (error) return callback(error); - filterOpsInPlaceBeforeTimestamp(ops, timestamp); - backend._buildSnapshotFromOps(id, milestoneSnapshot, ops, callback); + if (snapshot) to = snapshot.v; + + var options = {metadata: true}; + db.getOps(collection, id, from, to, options, function(error, ops) { + if (error) return callback(error); + filterOpsInPlaceBeforeTimestamp(ops, timestamp); + backend._buildSnapshotFromOps(id, milestoneSnapshot, ops, callback); + }); }); }); }); diff --git a/test/client/snapshot-timestamp-request.js b/test/client/snapshot-timestamp-request.js index 1beaaf4fd..37cba6b60 100644 --- a/test/client/snapshot-timestamp-request.js +++ b/test/client/snapshot-timestamp-request.js @@ -139,6 +139,21 @@ describe('SnapshotTimestampRequest', function() { ], done); }); + it('fetches the current snapshot when ops are missing and the timestamp is after the latest op', function(done) { + var connection = backend.connect(); + async.series([ + // Simulate ops having been deleted/TTLed so the snapshot can't be rebuilt from ops. + backend.db.deleteOps.bind(backend.db, 'books', 'time-machine', null, null, null), + function(next) { + connection.fetchSnapshotByTimestamp('books', 'time-machine', day4, function(error, snapshot) { + if (error) return next(error); + expect(snapshot).to.eql(v3); + next(); + }); + } + ], done); + }); + it('fetches the most recent version when not specifying a timestamp', function(done) { var connection = backend.connect(); async.waterfall([