oficina / docs Public

Serve snapshot-then-tail from GET /d/{sqid}/updates, plus ?raw=1

updatesGet now fetches the newest usable snapshot and, when it exists
and covers past `since`, writes the snapshot's State first and then
only the doc_updates rows after its seq — the same total content as
the raw log, in fewer bytes, because a snapshot is an ordinary Yjs
update the browser's applyServerLog already replays idempotently.

?raw=1 skips the snapshot lookup entirely and returns exactly the
doc_updates rows, unchanged from before this task — the escape hatch
a browser needs when a snapshot will not decode, since the server has
no way to detect that corruption itself.

Docs-Seq keeps design D2's rule: max(since, the highest seq in the
bytes actually written), now folding in the snapshot's own seq column
when one is served. That's still derived from data already in hand,
never a second query — the snapshot's bytes contain its whole prefix,
so counting its seq is exactly as safe as counting a tail row's.

A failed snapshot lookup falls back to the plain log (logged, not a
500), matching store.go's replay: a snapshot is an accelerator, never
the source of truth.
Paul Campbell pushed by paul@keymail.dev 5d53af6a907c9363c120733ad824f8dd114aa092 parent fb483b6
2 files changed, +171 −8
  • internal/docs/cursor_test.go +102 −0
  • internal/docs/handlers.go +69 −8
diff --git a/internal/docs/cursor_test.go b/internal/docs/cursor_test.go
index 42a68de..4d44b1b 100644
--- a/internal/docs/cursor_test.go
+++ b/internal/docs/cursor_test.go
@@ -207,6 +207,108 @@ func TestEmptySliceEchoesSinceRatherThanTheCurrentMax(t *testing.T) {
}
}
+// pastCadence builds a document with more updates than the cadence, so a
+// snapshot exists, and returns it.
+func pastCadence(t *testing.T, app *App, title string) *Document {
+ t.Helper()
+ ctx := context.Background()
+ owner := app.TestPerson(t, "owner@example.com")
+ doc, err := app.Store().Create(ctx, owner, title)
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ for i := 0; i < snapshotCadence+3; i++ {
+ if _, err := app.Store().ApplyUpdate(ctx, doc, validUpdate(t)); err != nil {
+ t.Fatalf("ApplyUpdate %d: %v", i, err)
+ }
+ }
+ return doc
+}
+
+// A client far behind the snapshot receives the snapshot plus the tail
+// rather than the whole log, and the header still names the highest seq
+// the bytes actually reach.
+func TestUpdatesGetServesSnapshotThenTail(t *testing.T) {
+ app, err := NewForTest(t)
+ if err != nil {
+ t.Fatalf("NewForTest: %v", err)
+ }
+ doc := pastCadence(t, app, "Snapshot then tail")
+
+ snap := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet,
+ "/d/"+doc.Sqid+"/updates?since=1", nil))
+ if snap.Code != http.StatusOK {
+ t.Fatalf("GET = %d, want 200", snap.Code)
+ }
+ raw := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet,
+ "/d/"+doc.Sqid+"/updates?since=1&raw=1", nil))
+
+ if snap.Body.Len() >= raw.Body.Len() {
+ t.Errorf("snapshot response %d bytes, raw log %d — the snapshot bought nothing",
+ snap.Body.Len(), raw.Body.Len())
+ }
+ if got := docsSeq(t, snap.Result()); got != int64(snapshotCadence+3) {
+ t.Errorf("Docs-Seq = %d, want %d", got, snapshotCadence+3)
+ }
+}
+
+// raw=1 is the escape hatch a browser needs when a snapshot will not
+// decode: doc_updates rows only, which is where the log's authority is
+// actually reachable from.
+func TestRawParameterReturnsOnlyLogRows(t *testing.T) {
+ app, err := NewForTest(t)
+ if err != nil {
+ t.Fatalf("NewForTest: %v", err)
+ }
+ ctx := context.Background()
+ doc := pastCadence(t, app, "Raw")
+
+ raw := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet,
+ "/d/"+doc.Sqid+"/updates?raw=1", nil))
+ if raw.Code != http.StatusOK {
+ t.Fatalf("GET raw = %d, want 200", raw.Code)
+ }
+
+ var rows []DocUpdate
+ if err := app.Store().db.WithContext(ctx).
+ Where("doc_id = ?", doc.ID).Order("seq ASC").Find(&rows).Error; err != nil {
+ t.Fatalf("load log: %v", err)
+ }
+ var want []byte
+ for _, r := range rows {
+ want = append(want, r.UpdateBin...)
+ }
+ if !bytes.Equal(raw.Body.Bytes(), want) {
+ t.Error("raw=1 did not return exactly the concatenated log")
+ }
+}
+
+// The live-sync design's D2 survives snapshots: the header is still
+// derived from the bytes in hand.
+func TestDocsSeqStillDerivedWithSnapshots(t *testing.T) {
+ app, err := NewForTest(t)
+ if err != nil {
+ t.Fatalf("NewForTest: %v", err)
+ }
+ doc := pastCadence(t, app, "Header")
+ top := int64(snapshotCadence + 3)
+
+ behind := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet,
+ "/d/"+doc.Sqid+"/updates?since=2", nil))
+ if got := docsSeq(t, behind.Result()); got != top {
+ t.Errorf("Docs-Seq behind the snapshot = %d, want %d", got, top)
+ }
+
+ ahead := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet,
+ "/d/"+doc.Sqid+"/updates?since="+strconv.FormatInt(top+5, 10), nil))
+ if ahead.Body.Len() != 0 {
+ t.Errorf("a cursor ahead of the log returned %d bytes, want none", ahead.Body.Len())
+ }
+ if got := docsSeq(t, ahead.Result()); got != top+5 {
+ t.Errorf("Docs-Seq ahead of the log = %d, want %d (an echo of since)", got, top+5)
+ }
+}
+
// An unparseable or negative since is the client's error, and saying
// so beats guessing at an intent: silently treating it as 0 would
// resend the whole log, which is the expensive answer to a question
diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go
index dce9d6d..4a2a995 100644
--- a/internal/docs/handlers.go
+++ b/internal/docs/handlers.go
@@ -423,10 +423,25 @@ func (h *handlers) getDocumentForUpdates(w http.ResponseWriter, r *http.Request)
return doc, true
}
-// updatesGet serves GET /d/{sqid}/updates: the document's raw update
-// log, concatenated in seq order — what the browser's Yjs doc
-// replays to build its own state (Task 6). Owner-scoped exactly like
-// show (getDocumentForUpdates), and capped at maxLogBytes above.
+// updatesGet serves GET /d/{sqid}/updates: by default, the newest
+// usable snapshot (if any covers past `since`) followed by the tail
+// of doc_updates rows after it — snapshot-then-tail, not the raw log
+// (Task 6). `?raw=1` is the escape hatch that returns exactly the
+// doc_updates rows and nothing else, for a browser whose snapshot
+// will not decode; the server cannot detect that corruption itself
+// (it never calls into the core here), so the browser is the only
+// reader that can, and raw=1 is what lets it fall back to the one
+// source it can always trust. Owner-scoped exactly like show
+// (getDocumentForUpdates), and capped at maxLogBytes above.
+//
+// Why snapshot-then-tail loses nothing: a snapshot IS an ordinary Yjs
+// update, covering every row up to and including its own seq. The
+// browser's applyServerLog already loops Y.readUpdate over a
+// concatenated body, and Yjs integration is idempotent, so a client
+// asking `?since=5` that receives a snapshot reaching seq 256 has not
+// skipped rows 6-256 — the snapshot contains them. That is why no
+// client change is needed for the happy path; only raw=1 (wired up by
+// a later task) exists for the unhappy one.
//
// The size is summed and checked BEFORE anything is written to w:
// writing the 200 status and a partial body first, then discovering
@@ -445,9 +460,38 @@ func (h *handlers) updatesGet(w http.ResponseWriter, r *http.Request) {
return
}
+ // raw=1 skips the snapshot lookup entirely and behaves exactly as
+ // this handler always has: doc_updates rows after `since`, nothing
+ // else. That is deliberate, not merely "don't bother fetching a
+ // snapshot we'd discard" — it is what makes raw=1 trustworthy as an
+ // escape hatch: its behavior does not depend on snapshot.go at all.
+ raw := r.URL.Query().Get("raw") == "1"
+
+ // A snapshot is an accelerator, never the source of truth (the
+ // snapshots design's D2, echoed in store.go's replay): if the
+ // lookup itself fails, this falls back to serving the plain log
+ // rather than 500ing a request the log alone can still answer.
+ var snapshot *DocSnapshot
+ if !raw {
+ snap, err := h.store.newestSnapshot(r.Context(), doc.ID)
+ if err != nil {
+ h.logger.Error("docs: load newest snapshot, serving the plain log instead", "sqid", doc.Sqid, "err", err)
+ } else if snap != nil && snap.Seq > since {
+ // A snapshot at or behind `since` buys nothing — the
+ // client already holds everything it covers — so it is
+ // only used when it actually advances the tail's start.
+ snapshot = snap
+ }
+ }
+
+ tailSince := since
+ if snapshot != nil {
+ tailSince = snapshot.Seq
+ }
+
var updates []DocUpdate
if err := h.store.db.WithContext(r.Context()).
- Where("doc_id = ? AND seq > ?", doc.ID, since).
+ Where("doc_id = ? AND seq > ?", doc.ID, tailSince).
Order("seq ASC").
Find(&updates).Error; err != nil {
h.logger.Error("docs: load update log", "sqid", doc.Sqid, "err", err)
@@ -455,7 +499,10 @@ func (h *handlers) updatesGet(w http.ResponseWriter, r *http.Request) {
return
}
- var total int
+ total := 0
+ if snapshot != nil {
+ total = len(snapshot.State)
+ }
for _, u := range updates {
total += len(u.UpdateBin)
}
@@ -465,11 +512,18 @@ func (h *handlers) updatesGet(w http.ResponseWriter, r *http.Request) {
return
}
- // Docs-Seq is derived from the rows in hand — max(since, the
+ // Docs-Seq is derived from the bytes in hand — max(since, the
// highest seq actually being written) — and never from a second
// MAX(seq) query. See parseSince's own doc comment and the design's
- // D2 for the update this loses if that is ever "simplified".
+ // D2 for the update this loses if that is ever "simplified". A
+ // snapshot's own seq column is fine to fold in here even though it
+ // is not itself a doc_updates row: it travels with bytes (its
+ // State) that contain its whole prefix, so counting it is exactly
+ // as safe as counting the highest tail row's seq.
highest := since
+ if snapshot != nil && snapshot.Seq > highest {
+ highest = snapshot.Seq
+ }
for _, u := range updates {
if u.Seq > highest {
highest = u.Seq
@@ -478,6 +532,13 @@ func (h *handlers) updatesGet(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Docs-Seq", strconv.FormatInt(highest, 10))
+ if snapshot != nil {
+ if _, err := w.Write(snapshot.State); err != nil {
+ // See below: the client went away mid-response.
+ h.logger.Error("docs: write snapshot", "sqid", doc.Sqid, "err", err)
+ return
+ }
+ }
for _, u := range updates {
if _, err := w.Write(u.UpdateBin); err != nil {
// The client went away mid-response; nothing left to do