Point-in-time reads
Lock-free watermark snapshots and time-travel reads — generated get_at/all_at, REST ?as_of and /snapshot — with the honest note that the token is a row-count watermark, not a wall-clock instant.
ForgeDB gives you consistent point-in-time reads without per-row version metadata. A snapshot is a row-count watermark — a single cutoff into the append-only log — and reading "as of" that watermark returns the state as it was when the watermark was taken.
Because storage is append-only, every update and delete is a new row version appended past the old one, never an in-place edit. So "the state at time T" is just "every id's newest version whose row index is below the cutoff W." Resolving a read against W is a cutoff comparison, not a walk of version chains.
That is what buys the simplicity: no xmin/xmax, no per-row visibility bits, no vacuum of
dead tuples on the read path. The cost lives elsewhere — dead versions accumulate until
in-process compaction reclaims them, and compaction renumbers rows, which is why a pinned
watermark is only valid within one compaction epoch (see Limits).
Watermark snapshots in generated Rust#
Database::snapshot() captures a DatabaseSnapshot — one watermark per model and junction,
taken atomically on the single writer. Reads then resolve against that watermark:
let snap = db.snapshot();
// Live reads see the newest versions.
db.insert_post(Post { /* … */ })?;
// Snapshot reads still see the state at capture time.
let post = db.get_post_at(&snap, id); // Option<Post> as of the snapshot
let all_posts = db.all_post_at(&snap); // Vec<Post> as of the snapshotIndex probes have _at variants too — see Indexes.
The capture is atomic because it routes through the one writer — no mutation can interleave between reading each model's row count, so the per-model watermarks form a clean commit boundary.
Isolation then follows from newest-within-watermark resolution. get_at / all_at resolve
the newest version of an id that still sits below the captured cutoff. A snapshot taken
before an update resolves the pre-update version; one taken before a delete still resolves
the live value, because the tombstoning version was appended after the cutoff.
Point-in-time reads over REST#
The same watermark is exposed over the generated REST API so clients can time-travel:
# List a model as of a watermark:
curl "http://localhost:8080/api/post?as_of=1200"
# Get one record as of a watermark:
curl "http://localhost:8080/api/post/<id>?as_of=1200"
# Discover the current per-model watermarks:
curl "http://localhost:8080/snapshot"
# → { "watermarks": { "Post": 1450, "User": 320 } }A client reads /snapshot, then passes a model's watermark back as ?as_of=. A non-numeric
as_of is a 400.
The ?as_of branch swaps only the row source — it routes through the same generated
filter/sort/paginate body as a live list, so there is no parallel point-in-time handler to
drift out of sync with the live one.
/snapshot is the read-side peer of /metrics in the unauthenticated ops routes: opaque
usize watermarks over a fixed per-schema key set. It reports models only; junctions are
deferred.
The token is a row-count watermark, not a wall-clock instant
as_of is an opaque usize row-count watermark, never a timestamp. There is no
wall-clock-to-watermark index, so "as of 3pm yesterday" is not answerable — that is a
separate, heavier feature. For offset-based recovery over the replication log, see
Backup & restore.
Limits#
- Row-count watermark, not wall-clock. No time-based ("as of timestamp T") reads.
- Epoch-scoped. Watermarks are valid only within a compaction epoch — an in-process
compact()renumbers rows, so a client must discard pinned tokens when it detects a reopen. - REST
list ?as_ofstill reads full rows server-side to filter and sort; only the wire is not shrunk. - Single-process/single-writer capture. The snapshot is atomic because capture routes through the one writer.
Newest-version resolution is sub-linear, so an ?as_of read does not scan the whole
watermark. Each id carries a per-id ascending list of its physical row versions (appended in
row order, so already sorted). A point read binary-searches that list with
partition_point(|&r| r < watermark): the newest visible version is the last element below
the cutoff (#159). If that newest version is a tombstone, the id resolves to None — deleted
as of the snapshot.
all_at reuses the same per-id search instead of two O(watermark) passes, so a deleted id is
filtered out and an updated id appears exactly once, at its newest in-watermark version — no
duplicate rows.