Skip to content

🐛 Restore the op before fixups when retrying a submit - #719

Open
alecgibson wants to merge 1 commit into
masterfrom
fix-fixup-reapplied-on-submit-retry
Open

🐛 Restore the op before fixups when retrying a submit#719
alecgibson wants to merge 1 commit into
masterfrom
fix-fixup-reapplied-on-submit-retry

Conversation

@alecgibson

@alecgibson alecgibson commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

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

Co-Authored-By: Claude noreply@anthropic.com

@coveralls

coveralls commented Jul 31, 2026

Copy link
Copy Markdown

Coverage Status

coverage: 97.474% (+0.007%) from 97.467% — fix-fixup-reapplied-on-submit-retry into master

Comment thread lib/submit-request.js Outdated
Comment thread lib/submit-request.js Outdated
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
alecgibson force-pushed the fix-fixup-reapplied-on-submit-retry branch from f09c85f to 03c1f2d Compare July 31, 2026 13:43
@alecgibson
alecgibson marked this pull request as ready for review July 31, 2026 13:49
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.

$fixup is not restored across the submit retry loop, so it is applied more than once

2 participants