oficina / docs Public

Task 3: the Go host can take and check a snapshot

Add Handle.Snapshot and Handle.VerifySnapshot, calling the new ABI v4
exports docs_core_snapshot_len/_read and docs_core_verify_snapshot.
Snapshot rides the existing len/read machinery as a fourth renderer
name; VerifySnapshot copies Apply's allocate/write/call/free structure
and trap taxonomy, mapping the guest's four outcomes (match, mismatch,
undecodable, everything else) to (bool, error).

Also fixes two carried findings in this file: ErrUpdateLogFull's
message no longer claims compaction is "unsupported under ABI v1" (it
is v4 now, and the export exists), and a ~95-column comment line is
rewrapped to match its block.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 9e615706cd69011ec9a692ab775e6ad49bea4451 parent 3101692
2 files changed, +189 −11
  • internal/core/core.go +98 −11
  • internal/core/core_test.go +91 −0
diff --git a/internal/core/core.go b/internal/core/core.go
index 10bcea4..bc7c7c6 100644
--- a/internal/core/core.go
+++ b/internal/core/core.go
@@ -114,11 +114,12 @@ const defaultGuestCallTimeout = 1 * time.Second
// here — this package does not do either yet.
//
// Until then, this package does the safe thing available without that
-// storage wired up: once a document's log is full, Apply refuses to accept further updates
-// at all (ErrUpdateLogFull), rather than either growing the log without
-// bound or silently dropping old entries — dropping old entries would make
-// a later rebuild replay an incomplete history and produce silently wrong
-// content, which is worse than refusing.
+// storage wired up: once a document's log is full, Apply refuses to
+// accept further updates at all (ErrUpdateLogFull), rather than either
+// growing the log without bound or silently dropping old entries —
+// dropping old entries would make a later rebuild replay an incomplete
+// history and produce silently wrong content, which is worse than
+// refusing.
//
// How reachable this cap actually is, for whoever writes the
// snapshot/compaction plan: the docs app persists one doc_updates row
@@ -145,7 +146,7 @@ const MaxRetainedUpdates = maxRetainedUpdates
// update log has reached maxRetainedUpdates. See that constant's doc
// comment: this is a deliberate, visible failure standing in for
// compaction support this ABI doesn't have yet, not a bug.
-var ErrUpdateLogFull = errors.New("update log exceeds the retained limit; document needs compaction (unsupported under ABI v1) before it can accept further updates")
+var ErrUpdateLogFull = errors.New("update log exceeds the retained limit; document needs compaction (not yet wired up on the host side) before it can accept further updates")
// Option configures a Runtime. See WithGuestCallTimeout.
type Option func(*runtimeOptions)
@@ -575,7 +576,93 @@ func (h *Handle) JSON(ctx context.Context) (string, error) {
return h.read(ctx, "json")
}
-// read is the shared body of Text, HTML and JSON: call the named
+// Snapshot returns the document's entire state as one Yjs update — what
+// doc_snapshots stores so a later replay can start from it instead of
+// from the whole log (the snapshots design, D1).
+//
+// Bytes rather than a string, but it rides the same len/read machinery
+// as Text/HTML/JSON, including their trap-and-retry: a Go string is a
+// byte sequence and nothing here interprets it, so "snapshot" is simply
+// a fourth renderer name to readOn.
+func (h *Handle) Snapshot(ctx context.Context) ([]byte, error) {
+ s, err := h.read(ctx, "snapshot")
+ if err != nil {
+ return nil, err
+ }
+ return []byte(s), nil
+}
+
+// verifySnapshotOn allocates candidate into mod's linear memory, calls
+// docs_core_verify_snapshot against docHandle, frees the buffer, and
+// returns the raw i32 return code. Structured exactly like applyOn —
+// docs_core_verify_snapshot is the only other export besides
+// docs_core_apply that takes a caller-supplied byte buffer into guest
+// memory, so the alloc/write/call/free dance and its error handling are
+// copied from there rather than reinvented.
+func verifySnapshotOn(ctx context.Context, timeout time.Duration, mod api.Module, docHandle uint64, candidate []byte) (int32, error) {
+ ptr, err := allocIn(ctx, timeout, mod, uint64(len(candidate)))
+ if err != nil {
+ return 0, fmt.Errorf("alloc: %w", err)
+ }
+ defer freeIn(ctx, timeout, mod, ptr, uint64(len(candidate)))
+
+ if !mod.Memory().Write(uint32(ptr), candidate) {
+ return 0, fmt.Errorf("writing %d bytes at %d: out of range", len(candidate), ptr)
+ }
+
+ rc, err := callGuest(ctx, timeout, mod.ExportedFunction("docs_core_verify_snapshot"), docHandle, ptr, uint64(len(candidate)))
+ if err != nil {
+ return 0, fmt.Errorf("verify_snapshot trapped or timed out: %w", err)
+ }
+ return int32(rc[0]), nil
+}
+
+// VerifySnapshot asks the core whether candidate, replayed into an empty
+// document, is this document — state vector, delete set and structural
+// render (the snapshots design, D5). It is a smoke test rather than a
+// proof, and it cannot detect a snapshot taken from a stale state; see
+// core/src/snapshot.rs for both limits.
+//
+// docs_core_verify_snapshot's four outcomes map as: 0 (the candidate is
+// this document) -> true, nil; 1 (it is not) -> false, nil; ERR_DECODE
+// (candidate is not a decodable update at all) -> false, nil — bad bytes
+// are a "no" answer, not a failure of this call; anything else (a trap,
+// a deadline exceeded, ERR_NO_DOC, ERR_INVALID_LENGTH, or an
+// unrecognised code) -> false, error.
+//
+// A trap or timeout is handled exactly as in Apply: the instance is
+// discarded and rebuilt from every update applied here so far (the
+// document itself survives), and the returned error is a *RecoveredError
+// so a caller can tell that apart from a rebuild that itself failed.
+// Unlike Apply, there is no update to omit from replay — this call never
+// mutates h's document at all, only a throwaway one inside the guest.
+func (h *Handle) VerifySnapshot(ctx context.Context, candidate []byte) (bool, error) {
+ const errDecode = -2
+
+ code, err := verifySnapshotOn(ctx, h.rt.callTimeout, h.mod, h.handle, candidate)
+ if err != nil {
+ if isFatal(err) {
+ if rebuildErr := h.rebuild(ctx); rebuildErr != nil {
+ return false, errors.Join(err, rebuildErr)
+ }
+ return false, &RecoveredError{err}
+ }
+ return false, err
+ }
+
+ switch code {
+ case 0:
+ return true, nil
+ case 1:
+ return false, nil
+ case errDecode:
+ return false, nil
+ default:
+ return false, fmt.Errorf("verify_snapshot failed: code %d", code)
+ }
+}
+
+// read is the shared body of Text, HTML, JSON and Snapshot: call the named
// renderer's len/read pair, and if the call itself traps or times out,
// discard the instance, rebuild it exactly as Apply does, and retry the
// read once against the fresh one. Retrying rather than reporting an
@@ -608,10 +695,10 @@ func (h *Handle) read(ctx context.Context, renderer string) (string, error) {
// readOn performs one renderer's len-then-read dance against
// docs_core_<renderer>_len and docs_core_<renderer>_read. renderer is
-// always one of this package's own three literals ("text", "html",
-// "json"), never anything derived from a request — the export names it
-// builds are a closed set, and a name that does not resolve to an
-// exported function surfaces below as a nil api.Function.
+// always one of this package's own four literals ("text", "html",
+// "json", "snapshot"), never anything derived from a request — the
+// export names it builds are a closed set, and a name that does not
+// resolve to an exported function surfaces below as a nil api.Function.
func readOn(ctx context.Context, timeout time.Duration, mod api.Module, docHandle uint64, renderer string) (string, error) {
lenFn := mod.ExportedFunction("docs_core_" + renderer + "_len")
readFn := mod.ExportedFunction("docs_core_" + renderer + "_read")
diff --git a/internal/core/core_test.go b/internal/core/core_test.go
index 0702440..54a58ce 100644
--- a/internal/core/core_test.go
+++ b/internal/core/core_test.go
@@ -171,3 +171,94 @@ func TestNewDocSucceedsAgainstTheCurrentModule(t *testing.T) {
}
defer h.Close(ctx)
}
+
+// TestSnapshotRoundTripsThroughAFreshHandle proves Snapshot's bytes are a
+// real Yjs update: applying them into a brand-new, otherwise-empty handle
+// reproduces the same text the snapshot was taken from (the snapshots
+// design, D1).
+func TestSnapshotRoundTripsThroughAFreshHandle(t *testing.T) {
+ ctx := context.Background()
+ rt, err := core.New(ctx)
+ if err != nil {
+ t.Fatalf("core.New: %v", err)
+ }
+ defer rt.Close(ctx)
+
+ h, err := rt.NewDoc(ctx, "snapshot-source", 1)
+ if err != nil {
+ t.Fatalf("NewDoc: %v", err)
+ }
+ defer h.Close(ctx)
+
+ update, want := fixture(t, "hello")
+ if err := h.Apply(ctx, update); err != nil {
+ t.Fatalf("Apply: %v", err)
+ }
+
+ snap, err := h.Snapshot(ctx)
+ if err != nil {
+ t.Fatalf("Snapshot: %v", err)
+ }
+ if len(snap) == 0 {
+ t.Fatal("Snapshot returned no bytes")
+ }
+
+ fresh, err := rt.NewDoc(ctx, "snapshot-target", 2)
+ if err != nil {
+ t.Fatalf("NewDoc fresh: %v", err)
+ }
+ defer fresh.Close(ctx)
+
+ if err := fresh.Apply(ctx, snap); err != nil {
+ t.Fatalf("Apply snapshot: %v", err)
+ }
+ got, err := fresh.Text(ctx)
+ if err != nil {
+ t.Fatalf("Text: %v", err)
+ }
+ if got != want {
+ t.Errorf("snapshot rebuilt %q, want %q", got, want)
+ }
+}
+
+// TestVerifySnapshotAcceptsItsOwnAndRejectsGarbage exercises the two
+// outcomes VerifySnapshot returns without an error: a document's own
+// snapshot verifies true, and bytes that are not a decodable update at
+// all (ERR_DECODE) come back false with no error — a "no" answer, not a
+// failure of the call.
+func TestVerifySnapshotAcceptsItsOwnAndRejectsGarbage(t *testing.T) {
+ ctx := context.Background()
+ rt, err := core.New(ctx)
+ if err != nil {
+ t.Fatalf("core.New: %v", err)
+ }
+ defer rt.Close(ctx)
+
+ h, err := rt.NewDoc(ctx, "verify-test", 1)
+ if err != nil {
+ t.Fatalf("NewDoc: %v", err)
+ }
+ defer h.Close(ctx)
+
+ update, _ := fixture(t, "hello")
+ if err := h.Apply(ctx, update); err != nil {
+ t.Fatalf("Apply: %v", err)
+ }
+
+ snap, err := h.Snapshot(ctx)
+ if err != nil {
+ t.Fatalf("Snapshot: %v", err)
+ }
+
+ ok, err := h.VerifySnapshot(ctx, snap)
+ if err != nil {
+ t.Fatalf("VerifySnapshot: %v", err)
+ }
+ if !ok {
+ t.Error("a document refused its own snapshot")
+ }
+
+ if ok, err := h.VerifySnapshot(ctx, []byte{0xff, 0x00}); err != nil || ok {
+ t.Errorf("garbage verified: ok=%v err=%v", ok, err)
+ }
+}