Skip to content

Serve gap-free sync state + Doc#pending? / #compacted_state_update - #36

Merged
jpcamara merged 3 commits into
mainfrom
feat/gap-free-sync
Jul 1, 2026
Merged

Serve gap-free sync state + Doc#pending? / #compacted_state_update#36
jpcamara merged 3 commits into
mainfrom
feat/gap-free-sync

Conversation

@jpcamara

@jpcamara jpcamara commented Jul 1, 2026

Copy link
Copy Markdown
Owner

Deliverable 1 of the gap-free-sync plan. Fixes the endless-resync poison from legacy pending structs, and exposes the supporting primitives.

The bug

A pending struct (a gappy update whose causally-prior update is missing — e.g. data recorded before the update_ready? gate existed) has an empty integrated state vector, but encode_state_as_update merges the pending bytes back in (yrs merge_pending_v1). So answering a peer's SyncStep1 handed it content it couldn't integrate → the peer parked the same pending forever, and the empty-SV / non-empty-content mismatch drove endless resync traffic (a browser re-sending frames ~several times/sec).

Verified with the exact repro (server holds a gappy update → fresh client syncs → client ends up pending?, empty, looping).

The fix

handle_sync_message now answers SyncStep1 with integrated-only state. The enabling primitive is yrs's public WriteTxn::prune_pending (takes the pending out and returns it) — so no upstream patch, no Snapshot/GC fragility. We rebuild state into a throwaway doc, prune it, and re-encode; the live doc is never mutated, and a fast path skips the rebuild entirely when nothing is pending (clean case stays zero-copy).

  • Neutralizes existing poisoned server state on deploy — no migration.
  • The server's own pending is untouched and still heals if the missing dependency later arrives (only then does the content become visible in sync).
  • Live delta relay (Update frames) is unchanged.

New primitives

  • Doc#pending? — holds un-integrable pending structs / delete set?
  • Doc#compacted_state_update — full state, gap-free, non-destructive. For when you persist/serve state yourself. encode_state_as_update stays lossless for raw-update recovery.

Tests

  • Rust: has_pending / integrated_update — strips pending, non-destructive (source keeps pending), fast-path byte-identical to direct encode when clean, heals after the dep arrives.
  • Ruby (pending_test.rb): detection + heal; compacted excludes pending while encode_state_as_update keeps it; the exact server-store poison repro proving the SyncStep2 reply no longer poisons a peer; SyncStep2 still delivers real content.
  • New real-Y.js Gap fixture added to generate_fixtures.mjs (all other fixtures regenerate byte-identically).
  • Full suite green: 86 Ruby runs, 20 Rust tests, clippy + rustfmt + rubocop clean.

Bumps yrby 0.2.3 → 0.3.0.

Coordination: live-maps #33 is also 0.3.0. Whichever merges first keeps 0.3.0; the other rebases to 0.4.0. Deliverable 2 (yrby-actioncable delivery defense / retry-cap) is a separate follow-up.

🤖 Generated with Claude Code

jpcamara and others added 3 commits July 1, 2026 15:01
… (0.3.0)

A pending struct (a gappy update whose causally-prior update is missing — e.g.
legacy data recorded before the update_ready? gate existed) has an empty
integrated state vector, but encode_state_as_update merges the pending bytes
back in (yrs merge_pending_v1). Answering a peer's SyncStep1 with that state
handed the peer content it couldn't integrate: it parked the same pending
forever, and the empty-SV / non-empty-content mismatch drove endless resync
traffic (a browser re-sending frames several times a second).

Fix: handle_sync_message now answers SyncStep1 with integrated-only state. The
enabling primitive is yrs's public WriteTxn::prune_pending, which takes the
pending out; we rebuild the state into a throwaway doc, prune it, and re-encode,
so the live doc is never mutated. A fast path skips the rebuild when nothing is
pending, keeping the clean case zero-copy. Neutralizes existing poisoned server
state on deploy — no migration — and the server's pending still heals if the
missing dependency later arrives (only then is the content served).

Also exposes the primitives:
- Doc#pending? — holds un-integrable pending structs / delete set?
- Doc#compacted_state_update — full state, gap-free, non-destructive.
  encode_state_as_update stays lossless for raw-update recovery.

Tests: Rust unit tests for has_pending / integrated_update (strips pending,
non-destructive, fast-path byte-identical, heals); Ruby pending_test.rb incl.
the exact server-store poison repro proving SyncStep2 no longer poisons a peer;
a real-Y.js Gap fixture added to the generator.

Bumps yrby 0.2.3 -> 0.3.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Turns the ad-hoc verifications into regressions and covers the branches the
first pass missed:

- Mixed doc (real integrated content + a pending struct): prune keeps the
  content and drops only the pending (Rust + Ruby).
- Diff/sv path -- integrated_update(doc, peer_sv), the signature
  handle_sync_message actually calls -- excludes pending in a diff (Rust + Ruby).
- Pending *delete set* (pending_ds): an orphan deletion parks and is stripped
  from served state (Rust + Ruby).
- Channel-level integration: Y::ActionCable::Sync answers SyncStep1 from a
  poisoned store with gap-free state (sync_test.rb).
- Thread safety: pending?/compacted_state_update hammered under contention.

New real-Y.js fixtures: Gap::DEPENDENT_OTHER (a second-client gappy insert) and
PendingDelete::UPDATE (a delete-set-only orphan). All other fixtures regenerate
byte-identically.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@jpcamara
jpcamara merged commit 6cebc6f into main Jul 1, 2026
7 checks passed
@jpcamara
jpcamara deleted the feat/gap-free-sync branch July 1, 2026 20:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant