Skip to content

Fix update_advances? to be exact for delete-bearing updates - #34

Merged
jpcamara merged 1 commit into
mainfrom
fix/update-advances-delete-noop
Jul 1, 2026
Merged

Fix update_advances? to be exact for delete-bearing updates#34
jpcamara merged 1 commit into
mainfrom
fix/update-advances-delete-noop

Conversation

@jpcamara

@jpcamara jpcamara commented Jul 1, 2026

Copy link
Copy Markdown
Owner

Prod issue

On docs with pure-delete retries, yrby-actioncable re-recorded and re-broadcast deletions the server had already integrated. Doc#update_advances? returned true for any delete-bearing update, so a lost-ack retry of a deletion looked like it advanced the doc every time — durable spam (duplicate rows, extra broadcasts, history-job noise) that the app had to compensate for with its own encode-and-compare guard around every write.

Root cause

Deletes don't move the state vector (a deletion tombstones an existing struct rather than adding one), so the cheap state-vector-growth probe couldn't tell a new deletion from an already-applied one. The old code took the safe-but-blunt route: any delete set ⇒ true (record it).

if !update.delete_set().is_empty() {
    return Ok(true); // can't cheaply prove a delete is a duplicate; record it
}

Fix

Measure the real effect on an isolated probe (never mutating the real doc), then branch on how to compare:

  • Insert/format-only updates grow the state vector → compare that (cheap, unchanged).
  • Delete-bearing updates → compare the full encoded document state (which carries the delete set) before vs. after the trial apply. A genuinely new deletion changes it (true); an already-applied retry re-encodes byte-identically (false).

Only delete-bearing frames — a minority — pay for the exact comparison. The exactly-once guarantee is unchanged in the safe direction: a real deletion is never dropped.

This flows straight through sync.rb's existing gate:

return :applied unless doc.update_advances?(update)  # now: dup pure-delete => :applied
sync_record_change(update)
sync_distribute(encoded)

A duplicate pure-delete frame is now acked but not stored or relayed — so the app-level replay-and-compare guard can be removed.

Tests

  • protocol.rs — a pure-delete retry does not advance; a delete bundled with a new insert still advances (and its byte-identical retry does not).
  • sync_test.rb — end-to-end ActionCable: a lost-ack delete retry is acked ([1,2,3]) but recorded/broadcast only once. Uses a new DeleteRetry fixture generated from real Y.js (added to generate_fixtures.mjs; the Y.js-emitted delete bytes match yrby's byte-for-byte).
  • Full suite green: 77 Ruby runs, 17 Rust tests, clippy + rustfmt + rubocop clean.

Bumps yrby to 0.2.3.

Note: independent of the live-maps PR (#33). If that merges first, this needs a trivial version/CHANGELOG reconcile (0.2.3 → 0.3.x).

🤖 Generated with Claude Code

`Doc#update_advances?` conservatively returned true for any update carrying a
delete set, because deletes don't move the state vector, so the cheap
state-vector probe couldn't prove a duplicate. A lost-ack retry of a deletion
the server had already integrated was therefore re-recorded and re-broadcast
every time -- durable spam that apps had to guard against with their own
encode-and-compare around every write.

For delete-bearing updates, compare the full encoded document state (which
includes the delete set) before vs. after a trial apply on an isolated probe:
a genuinely new deletion changes it (true); an already-applied retry re-encodes
identically (false). Insert/format-only updates keep the cheaper state-vector
path, so only delete-bearing frames -- a minority -- pay for the exact compare.
The exactly-once guarantee is unchanged in the safe direction: a real deletion
is never dropped.

This lets yrby-actioncable settle a duplicate pure-delete frame as :applied
(acked, not stored or relayed), so `sync.rb`'s `update_advances?` gate now
does the right thing without app-level compensation.

Tests:
- protocol.rs: pure-delete retry does not advance; a delete bundled with a new
  insert still advances (and its byte-identical retry does not).
- sync_test.rb: end-to-end ActionCable -- a lost-ack delete retry is acked but
  not re-recorded or re-broadcast, via a new real-Y.js DeleteRetry fixture
  (added to the fixture generator).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@jpcamara
jpcamara merged commit bd8669c into main Jul 1, 2026
7 checks passed
@jpcamara
jpcamara deleted the fix/update-advances-delete-noop branch July 1, 2026 15:26
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