Skip to content

Prove serialization by message, not by execution - #12

Merged
cardmagic merged 4 commits into
mainfrom
fix/serialization-proof-tolerates-retry
Aug 18, 2026
Merged

Prove serialization by message, not by execution#12
cardmagic merged 4 commits into
mainfrom
fix/serialization-proof-tolerates-retry

Conversation

@cardmagic

Copy link
Copy Markdown
Owner

The failure-recovery demo's serialization proof asserted that the control file
held exactly four events. That is a claim about the machine's speed, not about
the runtime, and it has been failing on GitHub runners.

What happens

proveSerialization sends two messages to one identity and runs two workers.
The runtime is configured with leaseDurationMilliseconds: 250, because
proveCrashRecovery and proveFencing need a short lease to demonstrate
recovery. On a slow runner a worker can lose that lease mid-operation, and the
replacement executes the same message again. The control file is written outside
the transaction, so it records six events instead of four.

That is the at-least-once contract the library documents, and the same behaviour
the crash and fencing proofs assert deliberately (repeatedEffects: 2). The
committed state is still 2, because the stale owner's write is fenced out.

Evidence

The assertion failed five times on GitHub runners and passed on rerun each time:

  • three times on Node 24.4.0
  • once on Node 24.15.0, in the pre-existing quality job
  • once more after a rerun on 24.4.0
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
  actual: 6,
  expected: 4,
    at proveSerialization (examples/failure-recovery/demo.ts:77:10)

It does not reproduce locally. Six runs of the demo pass on both 24.4.0 and
24.18.0, and forcing the lease down to 40 ms still produced four events every
time, because the operation completes long before the lease expires on fast
hardware.

Change

assertSerializedExecution now asserts what the runtime guarantees:

  • executions never overlap, checked across the whole time-ordered sequence
    rather than only the first pair;
  • every start has a matching finish, and none is left open;
  • exactly the two sent messages ran.

assert.equal(snapshot.count, 2) is unchanged, and it is the assertion that
would catch a lost or doubled write.

The lease timing is unchanged. Raising it to hide the retry would weaken
proveCrashRecovery and proveFencing, which depend on it.

Tests

assertSerializedExecution moved into examples/failure-recovery/serialization.ts
so it can be tested at the lowest layer, and the demo imports it.
test/failure-recovery-serialization.test.ts covers:

  • two executions that do not overlap;
  • a message that executes twice after a lost lease, which is the case that fails
    today;
  • executions that overlap;
  • an execution that never finished;
  • a run that lost one of its messages.

The test file was written first and failed with Cannot find module. Replacing
the rule body with return makes the three rejection cases fail, so they test
something rather than passing by construction.

Validation

pnpm run format:check, pnpm run check, pnpm run test (33 files, 249 passed,
11 skipped), pnpm run build, and pnpm run test:recovery all pass locally.

The failure-recovery demo asserted the serialization control file held
exactly four events. A worker that loses its lease mid-operation leaves
the replacement to execute the same message again, so a slower machine
produces six, and the demo failed for the at-least-once behaviour the
library documents and the crash and fencing proofs rely on.

It failed five times on GitHub runners, on Node 24.15.0 as well as the
lower floor, and passed on rerun, so the assertion was reporting the
runner's speed rather than a durability property.

The proof now asserts what the runtime guarantees: executions never
overlap, every start has a matching finish, and exactly the two sent
messages ran. The committed state check is unchanged, and it is the one
that would catch a lost or doubled write.

assertSerializedExecution moves into its own module so it can be tested
at the lowest layer. The unit tests cover the retry, overlap, unfinished,
and lost-message cases, and each rejection case fails when the rule is
removed.

The lease timing stays as it was. proveCrashRecovery and proveFencing
depend on a short lease, so raising it to hide the retry would weaken
them.
@greptile-apps

greptile-apps Bot commented Aug 18, 2026

Copy link
Copy Markdown

Greptile Summary

The PR revises the failure-recovery serialization proof to distinguish execution attempts and tolerate documented at-least-once retries while continuing to reject overlap between surviving executions.

  • Adds attempt and process identity to serialization events.
  • Pairs each start with the finish from the same execution.
  • Checks message coverage and overlap between the highest attempts.
  • Adds focused tests for retry, supersession, malformed event sequences, and overlap behavior.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
examples/failure-recovery/actor.ts Adds stable execution metadata to both control-file events so retries cannot cross-pair.
examples/failure-recovery/demo.ts Replaces execution-count and first-pair timing assumptions with the extracted serialization proof while retaining the committed-state assertion.
examples/failure-recovery/serialization.ts Correctly pairs executions, identifies the committing attempt under the runtime’s monotonic claim and fencing invariants, and checks surviving executions for overlap.
test/failure-recovery-serialization.test.ts Covers clean execution, retries, late stale finishes, surviving cross-message overlap, boundary timing, incomplete pairs, duplicate starts, and missing messages.
CHANGELOG.md Documents why retries appear in the external log and how the revised proof distinguishes superseded attempts.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  S[Read serialization events] --> P[Pair start and finish by message, attempt, and process]
  P --> C{Every execution complete?}
  C -- No --> F[Fail proof]
  C -- Yes --> M{Expected messages observed?}
  M -- No --> F
  M -- Yes --> H[Select highest attempt per message]
  H --> O{Surviving executions overlap?}
  O -- Yes --> F
  O -- No --> R[Return execution and retry details]
Loading

Reviews (4): Last reviewed commit: "fix: pair each start with its own finish" | Re-trigger Greptile

Comment on lines +34 to +39
assert.equal(
open,
undefined,
`execution of ${event.messageId} overlaps the open execution of ${open}`,
)
open = event.messageId

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Lease-loss retry triggers overlap assertion

When a worker loses its lease while serialize is still running, a replacement starts the same message before the stale attempt writes its finish. The single open slot rejects that documented at-least-once interleaving as overlapping execution, causing the recovery demo to fail for the behavior this change is intended to accept.

Prompt To Fix With AI
This is a comment left during a code review.
Path: examples/failure-recovery/serialization.ts
Line: 34-39

Comment:
**Lease-loss retry triggers overlap assertion**

When a worker loses its lease while `serialize` is still running, a replacement starts the same message before the stale attempt writes its finish. The single `open` slot rejects that documented at-least-once interleaving as overlapping execution, causing the recovery demo to fail for the behavior this change is intended to accept.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

The first rule demanded strict alternation across the whole log, which
rejects the case it was written to accept. The attempt that loses its
lease is the one that was slow, so it is still running when the
replacement starts, and both write to the control file.

Overlap is now allowed only when a message ran more than once, which is
the only thing that produces a stale owner. Without a retry the proof
still demands strict serialization. The rule tracks open attempts per
message, so a message may be open twice.

The demo reports executions, retried, and overlapped instead of a
hardcoded overlap: false, so a run says what it actually saw.

Found by Greptile on the first commit. The stale-overlap case is now a
test, and it fails against the alternation rule.
@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai review

Valid finding, and it reproduces as a test. The attempt that loses its lease is the one that was slow, so it is still running when the replacement starts, and both write to the control file. The alternation rule rejected exactly the case this PR exists to accept.

Fixed in fd29399: overlap is allowed only when a message ran more than once, since that is the only thing that produces a stale owner. Without a retry the proof still demands strict serialization. The rule now tracks open attempts per message, so one message may be open twice.

accepts a stale attempt that is still running when its replacement starts is a new test, and it fails against the previous rule.

Comment on lines +71 to +73
if (!retried) {
assert.equal(concurrent, 1, "executions overlapped without a lost lease to explain it")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Retry masks cross-message overlap

When one message retries while two different message IDs overlap, retried becomes true and skips the only concurrency assertion, causing the helper to report a successful serialization proof even though distinct messages for the same identity executed concurrently.

Prompt To Fix With AI
This is a comment left during a code review.
Path: examples/failure-recovery/serialization.ts
Line: 71-73

Comment:
**Retry masks cross-message overlap**

When one message retries while two different message IDs overlap, `retried` becomes true and skips the only concurrency assertion, causing the helper to report a successful serialization proof even though distinct messages for the same identity executed concurrently.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Treating any retry as a licence to overlap let a real violation through:
once one message retried, two different messages could run at the same
time and the proof still passed.

A retry excuses only the attempt it superseded. The rule now tracks which
attempt of each message is the last one started, and asserts that at most
one message has its surviving attempt open at any moment. Earlier
attempts may overlap anything, because their writes are fenced.

Found by Greptile on the previous commit. The case is now a test, and
removing the assertion fails it along with the no-retry overlap case.
@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai review

Valid again, and it reproduces as a test: with one message retried, the previous rule skipped the concurrency assertion entirely, so two different messages could run at the same time and still pass.

Fixed in 59e4a4f. A retry excuses only the attempt it superseded. The rule tracks which attempt of each message started last and asserts that at most one message has its surviving attempt open at any moment. Earlier attempts may overlap anything, because their writes are fenced.

rejects overlap between the surviving attempts even after a retry is a new test. Removing the assertion fails it and the no-retry overlap case.

Comment on lines +71 to +74
const startedAt = openForMessage.pop()
open.set(event.messageId, openForMessage)
running -= 1
if (startedAt === lastStartedAt.get(event.messageId)) surviving.delete(event.messageId)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Stale finish clears surviving attempt

When a stale attempt finishes after its replacement starts, openForMessage.pop() attributes that finish to the replacement and removes the replacement from surviving. A second message can then overlap that still-running replacement without triggering the assertion, so the serialization proof accepts concurrent surviving attempts.

Prompt To Fix With AI
This is a comment left during a code review.
Path: examples/failure-recovery/serialization.ts
Line: 71-74

Comment:
**Stale finish clears surviving attempt**

When a stale attempt finishes after its replacement starts, `openForMessage.pop()` attributes that finish to the replacement and removes the replacement from `surviving`. A second message can then overlap that still-running replacement without triggering the assertion, so the serialization proof accepts concurrent surviving attempts.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Two attempts of the same message can both be writing to the control
file at once: the superseded attempt keeps running until it notices the
lost lease, and its finish can land after the replacement's start. The
previous rule paired a start with whichever finish came next, so that
late finish read as the replacement's, and a second message could then
overlap a replacement that was in fact still running.

Each event now carries the message's attempt and the writing process, so
a start pairs only with the finish of that same execution. The proof
still asserts one thing about the surviving attempts, that none of them
overlap another message's surviving attempt, and lets a superseded
attempt overlap anything, because its write is fenced out and the
committed state is the assertion that proves it.

Found by Greptile on the previous commit, and reproduced as a test: a
superseded attempt that finishes after its replacement starts, followed
by a second message that starts while the replacement is still running.
Removing the overlap assertion fails that case and the no-retry case.
@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai review

Valid, and it exposes the actual gap: the log recorded only {event, messageId, at}, so nothing could tell which finish belonged to which attempt when a superseded attempt and its replacement both write to the file. Every rule that guessed from timing alone was going to have a hole somewhere.

Fixed in b201fe2. Each event now carries the message's attempt and the writing process (the actor already has this.currentMessage.attempt), so a start pairs only with the finish of that same execution — never with whichever finish happens to arrive next. The proof still allows a superseded attempt to overlap anything, since its write is fenced out and the committed state proves it, but a surviving attempt may never overlap another message's surviving attempt.

New tests: a superseded attempt whose finish lands after its replacement starts, and a second message that overlaps that still-running replacement — the second one throws. Removing the overlap assertion fails both, plus the original no-retry case.

@cardmagic
cardmagic merged commit 87d91df into main Aug 18, 2026
17 checks passed
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