oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1# Snapshots — Design
2
3**Status:** designed, not yet reviewed, not yet a plan.
4**Extends:** `docs/superpowers/specs/2026-08-30-docs-design.md` §4.1 (the log is
5bounded), §5.1/§5.2 (what the core does, and why the sandbox is load-bearing).
6**Follows:** `2026-09-01-live-sync-design.md`, whose cursor rules this must not
7disturb.
8
9## 1. Why now, and it is not latency
10
11The obvious reason to want this is speed: `DEBOUNCE_MS` is 2000ms because every
12accepted update is one `doc_updates` row, and a shorter debounce multiplies rows.
13That is real, and it is the second reason.
14
15The first is a cliff already in production. `internal/core/core.go`'s
16`maxRetainedUpdates` is 4096, and once a document's retained log reaches it,
17`Handle.Apply` refuses every further update — `ErrUpdateLogFull`, surfaced to the
18browser as a 400 that the save queue treats as permanent. **The document stops
19accepting edits.** That constant's own comment is explicit that this is a
20deliberate, visible failure standing in for snapshot support, chosen because
21silently dropping old entries would make a later rebuild replay an incomplete
22history and produce quietly wrong content.
23
24At today's 2s debounce that is roughly 2h16m of continuous typing on one
25document. A person writing all day reaches it. Nothing warns them first; the
26document simply starts refusing.
27
28So this work is not a performance optimisation with a latency bonus. It is the
29thing that stops a document going read-only, and it happens to be what makes
30near-realtime affordable afterwards.
31
32## 2. Scope
33
34**Snapshots only.** Paul's ruling, 2026-09-01: nothing is deleted.
35
36§4.1 names three mechanisms — coalescing before persistence, a snapshot cadence,
37and a compaction horizon that drops superseded updates. This slice builds the
38middle one. The horizon is deferred because deletion is irreversible and
39forecloses any future history feature over the window it eats; coalescing is
40deferred because it belongs with the transport work that makes a short debounce
41worth having.
42
43The consequence worth stating plainly: **this slice makes storage grow faster,
44not slower.** The update log still grows, and snapshots are added on top of it —
45a full copy of the document every 256 updates, all retained under "nothing is
46deleted", all replicated by Litestream. For a large document the snapshots
47outgrow the log they accelerate. This slice bounds *replay cost* and removes the
48cap; bounding storage is what the horizon is for.
49
50One correction to that, though, which review surfaced and which does not need the
51horizon ruling: **a superseded snapshot is derived data, not history.** It is
52reproducible from the log at any time, so deleting it deletes nothing a reader
53could ever have asked for — unlike an update row, whose loss is irreversible.
54This design therefore rules that **only the newest snapshot per document is
55retained**, and older ones are deleted as they are superseded. That keeps the
56added storage at one document-sized blob rather than one per 256 updates, and it
57is not the compaction horizon in disguise: no update row is touched, and the
58"nothing is deleted" ruling is about history, which this is not.
59
60## 3. What a snapshot is
61
62One Yjs update encoding the document's entire state at a point in the log —
63exactly what `yrs` produces internally today and what `core/src/abi.rs` does not
64yet expose. Applying it to an empty document yields the same document the whole
65log yields.
66
67**The call is `ReadTxn::encode_state_as_update_v1(&StateVector::default())`, and
68which one is not a detail.** Against the empty state vector it encodes all
69blocks, the entire delete set, and — through `merge_pending_v1` — the pending
70queue, meaning updates whose dependencies never arrived and which `apply_update`
71accepted and parked. That is Yjs's own persistence mechanism, and it is
72merge-equivalent to the log rather than merely render-equivalent: every clock
73range and every tombstone survives, so a later concurrent merge integrates
74against a snapshot exactly as it would against the full history.
75
76Two neighbours in the same API are traps, and an implementer reaching for the
77nearest-looking name will find them first:
78
79- **`encode_diff_v1(&StateVector::default())`** looks identical and silently
80 drops the pending queue (its own doc comment says so). A log holding an update
81 whose dependency arrives later heals under full replay and does not heal under
82 a diff-built snapshot. That is precisely the failure this design most fears —
83 renders identically today, breaks a merge tomorrow — and §D5's comparison
84 cannot see it, because pending content is invisible in both documents.
85- **yrs's own `Snapshot` type** and `encode_state_from_snapshot` are a name
86 collision with this spec's vocabulary and a different feature entirely
87 (historical views, requiring `skip_gc`). Not this.
88
89The fixed guid and client id play no part in the encoded state, so nothing here
90touches the no-entropy rule; the `"prosemirror"` root travels inside the update;
91and GC settings match on both sides, so tombstone handling is symmetric with log
92replay.
93
94That single property is what makes everything below cheap: a snapshot is not a
95new kind of thing, it is *an update*. Every path that already knows how to apply
96concatenated updates — `Store.replay`, and the browser's own `applyServerLog` —
97handles one with no changes at all.
98
99## 4. Decisions
100
101### D1 — The ABI gains a state export, v3 → v4
102
103`docs_core_snapshot_len` / `docs_core_snapshot_read`, mirroring the existing
104`html_len`/`html_read` pair. `internal/core`'s version check is exact-match, so
105the wasm artifact and the host move together.
106
107This is the export `core.go`'s `maxRetainedUpdates` comment already specifies:
108"an ABI export that encodes a document's current state as one update… so rebuild
109could restore a compacted snapshot plus only the updates since it".
110
111### D2 — `doc_snapshots`, and it is never authoritative
112
113New table (migration `0004`), extending §4's data model with the columns that
114failure handling actually needs: `doc_id`, `seq`, `state`, **`abi_version`**,
115`created_at`.
116
117`seq` names the last update the snapshot includes. `abi_version` is what makes
118the failure table's "from an older ABI → discarded" implementable at all; without
119it the row records nothing about what produced it, and the rule is unenforceable
120prose.
121
122Two schema decisions the implementer would otherwise have to invent, settled
123here. `(doc_id, seq)` is **not** unique: under §D3's before-apply placement two
124concurrent boundary POSTs can legitimately produce snapshots at the same seq, and
125they are equivalent by construction, so a collision is not an error to raise.
126"Newest usable" therefore means **highest `seq`, then highest `id`**, so a
127duplicate resolves deterministically to the last one written.
128
129The migration adds a table and touches no existing row, which is the whole of its
130effect on documents that already exist — migration `0003`'s own comment sets the
131precedent that this is spec-level material rather than an implementation detail.
132
133`state` is plaintext for now, exactly as `doc_updates` is; sealing (§2) is its own
134work. One honest note it does change, though: a `doc_updates` row is a fragment of
135an edit, while a snapshot row is **the entire document in a single plaintext
136blob**. That is a materially fatter target in the same threat model, and worth a
137sentence in the sealing work rather than a redesign here.
138
139**A snapshot is an accelerator, never the source of truth.** The log remains
140complete and authoritative. Anything unreadable, corrupt, or produced by an older
141ABI is discarded and the full log replayed instead — slower, always correct. This
142is the property that makes the whole slice safe to ship: the worst failure is a
143performance regression, never wrong content.
144
145### D3 — Taken from the replayed state, before the new update is applied
146
147`Store.ApplyUpdate` replays the log into a `Handle`, applies the incoming update,
148then persists it. The snapshot is taken from **the replayed state, before
149`Apply`**, at `seq` = the highest seq that replay actually read.
150
151The obvious placement — after a successful write, snapshotting the handle that
152just accepted the update — is wrong twice, and both ways were found in review
153rather than in production:
154
155**It can record a state that is missing a committed row.** The replay runs
156*outside* `ApplyUpdate`'s transaction; only MAX(seq)+INSERT is serialised by the
157single-connection write pool. Two concurrent POSTs to one document — the flush
158timer racing the `beforeunload` beacon, a pair the live-sync design already names
159as real — both replay rows 1..10; A commits seq 11, B commits seq 12. B's handle
160holds 1..10 plus its own update and *not row 11*. A snapshot written there claims
161seq 12 while missing row 11's content, and §D5 cannot catch it, because it
162compares the candidate against the same stale handle that produced it. Every
163later render and every §D7 response would then be missing that content, with
164`Docs-Seq: 12` genuinely lying about the bytes in hand — the live-sync design's
165D2 failure re-entering through a seq column instead of a `MAX(seq)` query. It
166would heal at the next cadence boundary 256 updates later, and never if the
167document went idle first.
168
169**And it never rescues a document that is already at the cap** — the case §1
170opens with, and the one that may exist in production now. A capped document
171replays to exactly `maxRetainedUpdates`, so `Handle.Apply` returns
172`ErrUpdateLogFull` *before* any update succeeds. Snapshot-after-success is never
173reached, and the document 400s forever.
174
175Taking it from the replayed state fixes both by construction. The replayed prefix
176is complete — seq is gapless by MAX+1, and replay read every row that existed
177when it ran — so the seq column is unconditionally truthful. And the read happens
178against a fully usable handle whether or not the incoming update will be
179accepted, so the first POST to a capped document writes a snapshot at seq 4096,
180still fails once with a 400, and the client's own retry replays snapshot plus an
181empty tail and succeeds. One visible failure, then healed.
182
183**Still on the write path, and still never on a timer.** §7.3's rule is that a
184hibernating instance runs no timers, so a background snapshotter stops working
185exactly when a document goes idle enough to be worth snapshotting. A request
186means the instance is awake by definition.
187
188Two costs to state. The boundary POST pays a state export, §D5's verification and
189one INSERT, all bounded by the existing guest-call timeout — which now indirectly
190bounds snapshot size: a document whose export or re-apply exceeds that timeout
191traps every attempt, never gets a snapshot, and pays a rebuild penalty per
192attempt. `WithGuestCallTimeout` is the existing knob if that ever bites. And once
193the distance reaches the cadence, *every* subsequent POST retries until one
194succeeds — not "the next Nth write".
195
196### D4 — Cadence of 256 updates
197
198Replay is then at most one snapshot plus 256 updates, and the retained-update
199count in a `Handle` stays around 16× clear of `maxRetainedUpdates`.
200
201**Conditionally, and the condition belongs in writing.** That bound holds only
202while snapshot writes keep succeeding. Roughly fifteen consecutive cadence
203failures — each logged at Error, so noisy rather than silent — walk the tail back
204to the cliff. Before D8 that is where this section ended: the next replay past
205that point met `ErrUpdateLogFull` inside `Store.replay` itself, for a read exactly
206as for a write, and the failure table's "correct, possibly slower" bar was not
207met. D8 closes that: a tail replay that meets the cap re-seeds a fresh handle from
208its own state, in memory, and carries on, so reaching the cliff this way no longer
209ends a read, or the write riding the same replay. What is left of the condition is
210cost, not failure — a re-seed on every replay until a persisted snapshot finally
211succeeds and this section's 16× bound is restored. The cliff is removed in every
212ordinary case and merely deferred to a per-replay cost in a persistently failing
213one, which is a weaker claim than "retired" and the true one.
214
215The cadence distance is read from the snapshot row §D6's replay already loads,
216not from a separate query — the same discipline as the live-sync design's D2, for
217the same reason.
218
219A byte-size trigger (§4.1's "or M bytes, whichever first") is deliberately not in
220this slice: with no coalescing yet, one update is one debounced burst of typing,
221so update count and bytes track each other closely enough that a second trigger
222would add a knob nobody can yet set from evidence.
223
224### D5 — A snapshot is verified before it is trusted, inside the core
225
226Before persisting, the candidate is applied to a fresh document *inside the Rust
227core* — no second wasm instance, no second host-side handle — and three things
228are compared against the document it was taken from:
229
230- **state vector equality**, as a Rust struct comparison;
231- **delete set equality**, likewise;
232- **`fragment_json` equality**, not rendered text.
233
234Rust-side struct comparison avoids depending on encoding determinism, which does
235not hold: update encoding is not canonical, so no cheap byte-level proof exists
236and the spec should stop wishing for one. Comparing structure rather than text
237also catches formatting-only divergence, which a text comparison misses at
238identical cost.
239
240**This is not a proof, and it is the right place to stop.** What survives it is
241"yrs corrupted content while preserving every clock range, every deletion, and
242the full structural render", which is a narrow enough residual to accept.
243
244What it can never catch is a snapshot taken from a stale state, because that
245compares a candidate against the very document that produced it and they agree
246perfectly. That failure is closed by §D3's placement, not here — a comparator
247cannot detect a truthful copy of the wrong thing.
248
249### D6 — `Store.replay` reads snapshot-then-tail
250
251Load the newest usable snapshot, apply it, then apply only `seq > snapshot.seq`.
252Falls back to a full replay when there is no snapshot or it fails D2's usability
253check.
254
255### D7 — `GET /d/{sqid}/updates` serves snapshot-then-tail
256
257When the requesting cursor is behind the newest snapshot, the response is that
258snapshot followed by the updates after it, instead of every row since the cursor.
259
260This needs **no client change**. `applyServerLog` already loops `Y.readUpdate`
261over a concatenated body, a snapshot is an update, and Yjs integration is
262idempotent — a client that asks `?since=5` and receives a snapshot covering
263through 256 has lost nothing, because the snapshot contains rows 6-256.
264
265Two things it fixes beyond tidiness: initial load stops being O(all history), and
266`maxLogBytes` (8MB) stops being a second cliff a long-lived document eventually
267hits.
268
269**`Docs-Seq` keeps its meaning and its rule.** It is still the highest seq the
270returned bytes actually reach, still derived from what is in hand and never from
271a separate `MAX(seq)` query — the snapshot's own `seq` column is a property of the
272row being sent, not a second question asked of the database. The live-sync
273design's D2 (empty slice echoes `since`) and D3 (cursor+1) are untouched.
274
275The endpoint's own doc comment must stop describing the response as the raw log,
276because it will no longer be one.
277
278Select the snapshot row first and the tail as `seq > snapshot.seq`. The reverse
279order is also loss-free, since overlap is idempotent, but one order should be
280stated so two readers do not each pick their own.
281
282**The one place §D2's safety story does not reach, stated rather than papered
283over.** `Store.replay` can discard a bad snapshot because it applies it and sees
284the failure. `updatesGet` cannot: it serves the blob without a core call, so a row
285corrupted on disk *after* §D5 verified it goes straight to the browser, where
286`Y.readUpdate` throws mid-loop and the resync controller refetches the same bytes
287forever. Server-side rendering keeps working; the editor becomes permanently
288unloadable for that document. That contradicts both "the worst failure is a
289performance regression" and "this needs no client change", so this design pays for
290it: **`?raw=1` forces the pre-snapshot behaviour**, returning only `doc_updates`
291rows, and `applyServerLog` refetches with it once when a decode throws. Small, and
292it makes the log's authority reachable from the one reader for whom it matters
293most.
294
295### D8 — A tail replay past the cap re-seeds in memory rather than failing
296
297Found after this design was reviewed, in a task review of its implementation:
298what happens when `Store.replay` has no usable snapshot to start from — D2's
299ordinary "discarded, full replay" story for a corrupt row, a stale one, or (the
300one that will actually happen) every row at once on the deploy that ships the
301next ABI bump, since `newestSnapshot`'s exact-ABI match invalidates them all
302together — and the log it falls back to is longer than
303`core.MaxRetainedUpdates`? Full replay is precisely what §1 opens by naming as
304already unsafe past that count: it is the cliff `Handle.Apply` was built to stop
305at, met this time from inside `Store.replay`'s own tail loop instead of from a
306client's POST, and reached with no snapshot in hand to shorten the log that
307triggers it.
308
309§3's property closes it the same way it closes everything else here: a snapshot
310is nothing but a handle's own state, exported, and applying it to an empty
311document reproduces the handle. `Store.replay`'s tail loop uses that on itself.
312When applying a row meets `core.ErrUpdateLogFull`, it takes that same export of
313the handle it already has — mid-loop, via `Handle.Snapshot` — opens a fresh
314handle from the document's own stored GUID and client id (never a new identity;
315see Document), applies the export, and continues from the row that was refused,
316which is re-applied into the fresh handle and not dropped. `reseedForReplay` in
317`internal/docs/store.go` is the mechanism; its call site is the one place in the
318tail loop that catches `core.ErrUpdateLogFull` rather than surfacing it. A log of
319any length gets through in as many re-seeds as it needs, and what a replay
320retains at once stays bounded by the cap regardless — the same bound §D4 relies
321on, just re-established mid-replay instead of assumed for it.
322
323**It writes nothing**, deliberately, and this is the one place that needs saying
324twice: `Store.replay` is the read path (Text, HTML and JSON all reach it), and a
325read that persisted a row would be a materially different change — the one place
326today's design allows a snapshot to originate is a request that has already
327proved the instance awake (D3), which a plain read has not. The persisted
328snapshot this document is still owed arrives the ordinary way, on its next
329`ApplyUpdate`: crossing the cap here means the gap already exceeds
330`snapshotCadence` many times over, so that write's own cadence check (D3, D4)
331passes trivially and it snapshots at the current ABI — which is exactly what an
332ABI bump needs repaired, and the reason this stays self-healing rather than
333becoming a second, competing writer to reason about.
334
335**It logs at Warn, once per re-seed** — not Error, and not silent either, and
336both halves of that were argued from evidence rather than assumed. Not Error,
337because nothing is wrong with the document: it is complete and correct, only
338slower to read, exactly the distinction D4 draws between a cliff and a cost. Not
339silent, which is what this was at first, on the reasoning that the document's
340next write repairs it — reasoning that assumes a write arrives. Reads write
341nothing, so a long document that is finished and only ever read again, which is
342most of what an ABI bump invalidates at once, would re-seed on every single read,
343forever, with degraded latency as the only trace. One line per read is the price
344of that not being invisible to an operator.
345
346## 5. What does not change
347
348The wire format (a snapshot is an ordinary Yjs update); the browser bundle; the
349cursor protocol; the stream and its hub; ownership scoping; `doc_updates` itself,
350which keeps every row it has.
351
352## 6. Failure modes
353
354| Failure | Behaviour |
355|---|---|
356| No snapshot exists | Full replay, as today |
357| Snapshot row unreadable or from an older ABI | Discarded, full replay, logged |
358| D5's verification fails | Snapshot not written, log untouched, logged at Error |
359| Snapshot write fails after the update committed | Update stands, no snapshot, next Nth write tries again |
360| Core call traps producing the snapshot | Same as above; the update is already durable |
361| A full replay (no usable snapshot) crosses `core.MaxRetainedUpdates` mid-tail | Re-seeds a fresh handle from its own state, in memory, and continues; writes nothing, logged at Warn per re-seed (D8) |
362
363Every row of that table ends in "correct, possibly slower", which is the bar this
364design sets for itself.
365
366## 7. Non-goals
367
368The compaction horizon and any deletion of rows; coalescing before persistence;
369sealing `state`; named versions (§14), which will need to pin points a future
370horizon must not collapse; the WebSocket transport; presence.
371
372## 8. Testing
373
374- **Equivalence is the central test.** For a document with more updates than the
375 cadence, the text and HTML produced through snapshot-then-tail must equal what a
376 full replay produces, byte for byte. Property-shaped rather than one fixture:
377 several documents, several cadences, including a snapshot at the exact boundary.
378- **The cap stops being reachable.** Apply more than `maxRetainedUpdates` updates
379 to one document and assert it still accepts writes — the regression test for the
380 cliff this exists to remove. It should fail against `main` today.
381- **A document that is ALREADY at the cap recovers**, which the test above does
382 not cover: driven through the new path a document never reaches the cap, so the
383 production-shaped case needs its own fixture — a log of `maxRetainedUpdates`
384 rows and no snapshot, asserting one 400 followed by a successful retry. This is
385 §D3's second defect, and without this test the fix for it is unverified.
386- **The concurrent boundary race** (§D3's first defect): two POSTs racing at the
387 cadence boundary must never produce a snapshot whose seq outruns its content.
388 Assert against the rendered document, not against the snapshot row, since the
389 row is exactly what a stale snapshot would make look right.
390- **`?raw=1` returns only `doc_updates` rows**, and a client that hits a decode
391 failure recovers through it.
392- **Fallbacks are exercised, not assumed:** a corrupted snapshot row, a snapshot
393 from a wrong ABI, and a missing snapshot each still produce correct content.
394- **D5's verifier rejects a bad snapshot** — constructed by hand, since the core
395 will not produce one on request.
396- **The GET's cursor contract survives:** `?since=` behind, at, and ahead of the
397 snapshot; `Docs-Seq` still derived from bytes in hand; the live-sync suite green
398 unchanged.
399- **Wire fixtures unchanged.** They are the contract, not an output.
400
401## 9. Risks
402
4031. **The ABI bump is a coordination point.** `internal/core`'s check is
404 exact-match, so a stale wasm artifact fails at load. Anyone rebasing across
405 this rebuilds the artifact.
4062. **A wrong snapshot is the worst bug this repo could ship** — silently wrong
407 content for every future reader, with the log that would prove it still sitting
408 there unread. D2's never-authoritative rule and D5's verification exist for
409 that one risk, and the review should be hardest on exactly that.
4103. **Storage still grows, and this slice adds to it.** §2 states the shape and the
411 supersession rule that keeps it to one blob per document. The log itself is
412 untouched and unbounded until the horizon lands.
4134. **`maxLogBytes` is bounded against history, not against size.** Snapshot-then-
414 tail means a long-lived document no longer sends its whole past, but a document
415 whose *current state* exceeds 8MB still fails the GET — with the snapshot in
416 the body. Different cliff, further away, not removed.
417
418## 10. Review record
419
420Reviewed by **Fable** on 2026-09-01, at the spec stage, per AGENTS.md. Verdict:
421approve with changes; all folded in above.
422
423Two were correctness defects with the same root — where the snapshot is taken
424from. Snapshotting the handle that just accepted an update can record a state
425missing a concurrently-committed row (§D3), and can never rescue a document
426already at the cap, which is the case §1 opens with. Taking it from the replayed
427state before `Apply` closes both by construction.
428
429The rest: the exact `yrs` call named along with the two neighbours that look
430right and are not (§3); §D5 moved inside the core and strengthened from a text
431comparison to state-vector, delete-set and structural equality, with its honest
432limit stated; the `abi_version` column without which the failure table's own rule
433is unenforceable (§D2); the `?raw=1` escape hatch for a corrupt snapshot reaching
434a browser that has no way to refuse it (§D7); the cap's retirement restated as
435conditional on snapshot writes succeeding (§D4); and the supersession ruling that
436keeps this from making storage worse than the problem it solves (§2).
437
438The review also confirmed what §3 rests on: against an empty state vector,
439`encode_state_as_update_v1` is merge-equivalent to the log and not merely
440render-equivalent, so a snapshot is safe to build future concurrent merges on.
441
442**§D8 was added after this review**, not folded into it: a finding from a later
443task review of the implementation, that a full replay falling back to no usable
444snapshot on a log already past `core.MaxRetainedUpdates` met `ErrUpdateLogFull`
445inside `Store.replay` itself with no way out, which is the ABI-bump case this
446same design's D2 and §9's first risk both name as the thing that will actually
447happen. The fix — re-seeding a fresh handle from the replay's own state, in
448memory, mid-tail — was implemented and tested before this section existed; §D8
449is this spec catching up to what the code already does, not a proposal.
450