🐛 Restore the op before fixups when retrying a submit - #719
Open
alecgibson wants to merge 1 commit into
Open
Conversation
alecgibson
commented
Jul 31, 2026
Fixes #716 At the moment, `$fixup()` composes its op permanently into `op.op` (or `op.create.data`), but the `apply` middleware is re-triggered on every submit retry. `apply()` resets the fixup *bookkeeping* on each attempt, while leaving `op.op` carrying the abandoned attempt's fixup, and nothing restores it. This causes two problems for a submit that loses a commit race. First, `op.op` accumulates fixups: with N retries the fixup lands N+1 times. Second, the ack under-reports what was committed, since `Agent` only sends the surviving attempt's `_fixupOps`. So we can commit `na: 2` while telling the client `na: 1` — a client/server divergence at the same version, produced entirely by our own bookkeeping. It happens even for a middleware whose fixup is a pure function of the request; existing `$fixup` users tend to write idempotent metadata, which masks it. Restoring at the top of `apply()` isn't enough, because `_transformOp` legitimately mutates `op.op` between attempts, and we want the *original* op transformed forward, not the fixed-up one. So this change stashes the op as it was before the attempt's first fixup, and resets all of the fixup state — the bookkeeping, and the op itself — in `retry()` before re-submitting. The middleware then recomputes its fixup against the newer snapshot, and `_fixupOps` matches what we actually commit. The stash is taken lazily in `$fixup()` rather than unconditionally in `apply()`, so submits that don't fix anything up don't pay to clone their op. Cloning is necessary because OT types make no guarantees about mutating their inputs. It's wrapped in an object so that its own truthiness tells us whether this attempt has stashed yet: the stashed value can legitimately be falsy, since `checkOp()` only requires that `op.op` is present, and `op.create.data` is whatever the type's `create()` returned. Note the `delete op.m.fixup` is *not* made redundant by this: `commit()` only sets `m.fixup` when `_fixupOps` is non-empty, so an attempt that fixes nothing up would otherwise commit an op still carrying the previous attempt's `m.fixup`. Resetting in `retry()` rather than `apply()` also covers the retry that finds our own op already committed, since that bails out during transform without ever reaching `apply()`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
alecgibson
force-pushed
the
fix-fixup-reapplied-on-submit-retry
branch
from
July 31, 2026 13:43
f09c85f to
03c1f2d
Compare
alecgibson
marked this pull request as ready for review
July 31, 2026 13:49
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #716
At the moment,
$fixup()composes its op permanently intoop.op(orop.create.data), but theapplymiddleware is re-triggered on every submit retry.apply()resets the fixup bookkeeping on each attempt, while leavingop.opcarrying the abandoned attempt's fixup, and nothing restores it.This causes two problems for a submit that loses a commit race. First,
op.opaccumulates fixups: with N retries the fixup lands N+1 times. Second, the ack under-reports what was committed, sinceAgentonly sends the surviving attempt's_fixupOps. So we can commitna: 2while telling the clientna: 1— a client/server divergence at the same version, produced entirely by our own bookkeeping. It happens even for a middleware whose fixup is a pure function of the request; existing$fixupusers tend to write idempotent metadata, which masks it.Restoring at the top of
apply()isn't enough, because_transformOplegitimately mutatesop.opbetween attempts, and we want the original op transformed forward, not the fixed-up one. So this change stashes the op as it was before the attempt's first fixup, and resets all of the fixup state — the bookkeeping, and the op itself — inretry()before re-submitting. The middleware then recomputes its fixup against the newer snapshot, and_fixupOpsmatches what we actually commit.The stash is taken lazily in
$fixup()rather than unconditionally inapply(), so submits that don't fix anything up don't pay to clone their op. Cloning is necessary because OT types make no guarantees about mutating their inputs. It's wrapped in an object so that its own truthiness tells us whether this attempt has stashed yet: the stashed value can legitimately be falsy, sincecheckOp()only requires thatop.opis present, andop.create.datais whatever the type'screate()returned.Note the
delete op.m.fixupis not made redundant by this:commit()only setsm.fixupwhen_fixupOpsis non-empty, so an attempt that fixes nothing up would otherwise commit an op still carrying the previous attempt'sm.fixup. Resetting inretry()rather thanapply()also covers the retry that finds our own op already committed, since that bails out during transform without ever reachingapply().🤖 Generated with Claude Code
Co-Authored-By: Claude noreply@anthropic.com