Skip to content

PayPal sandbox only. No real money moves.

LearnHow it works

Crash recovery

What happens when the process dies in the middle of a capture.

Code-checkedread against the code it describes; nothing was run

Verified againstRead against the recovery code listed at the foot of the page (PR #1954). Crashes were injected against the fake PayPal; one in-process crash ran on the sandbox, no real process kill.

Page last changed 7 Nov 2026 (from the git history)

The question a skeptical reader should ask: if the server dies at the worst moment, can the winner be charged twice, or not at all?

Crash recovery: charged exactly once

Fake PayPal
Crash recovery: charged exactly onceTwo timelines. The process timeline runs hammer, truth read, intent saved, PayPal capture call, capture_ok saved, then voiding. Three crash points are marked: A after the intent is saved and before the call, B after PayPal took the capture and before the answer is saved, C after capture_ok is saved. The journal timeline shows the intent as a hatched entry and the outcome as a solid entry. Three panels say what the journal holds and what the restart does at each point. The result is one capture.heldchargedreleasedneeds reviewPROCESS TIMELINEhammertruth readintent savedPayPal capturecapture_ok savedvoid, SETTLEDABCX: the process diesJOURNAL TIMELINE (what survives the crash)INTENT capture $350request id gv1-...capture_ok, winnerintent clearedno write1CRASH ADied after the intent was saved,before PayPal got the callJOURNALintent saved: capture, $350,request id. State: CAPTURING.RESTARTreads the truth of the hold first.PayPal shows no capture.DOESsends the saved intent again,same request id, answered once.2CRASH BDied after PayPal took the capture,before the answer was savedJOURNALthe same intent, no outcome.RESTARTreads the truth first: it showsour capture (id and amount match).DOESadopts it as capture_ok.No new capture call.UNKNOWNtruth unreadable or hold unknown:wait, same id, 10 minutes, thenFAILED_NEEDS_REVIEW. Never thenext bidder.3CRASH CDied after capture_ok was savedJOURNALstate CAPTURED, winner recorded,intent cleared.RESTARTreads the state: already CAPTURED.DOESgoes on to void the other holds.No capture call is made.Charged exactly onceWhere this lives: packages/payments/src/saga/capture.ts (truth first, write-ahead intent),saga/unknown.ts (the 10 minute deadline), saga/machine.ts, packages/payments/src/requestId.ts.

Scroll the figure sideways, or open it full size.

Verified with injected crashes at these boundaries against the fake PayPal (the simulation scenario S6 runs 10 crash points, each with and without the idempotency table). One in-process crash at the same place (spec R10) passed on the real sandbox in the nightly runs of 6 and 7 Nov (redacted reports committed). No real server kill and restart has run on a hosted host.
Text version of this diagram

The capture sequence is: read the truth of the hold, save the intent (capture, amount, request id), call PayPal, save capture_ok.

  • Crash A, after the intent is saved and before the call: on restart the truth shows no capture, and the saved intent is sent again with the same request id.
  • Crash B, after PayPal took the capture and before the answer was saved: on restart the truth shows the capture, which is adopted. If the truth cannot be read, or PayPal does not know the hold, the settlement waits with the same request id for ten minutes and then goes to FAILED_NEEDS_REVIEW. It never tries the next bidder.
  • Crash C, after capture_ok was saved: the state is already CAPTURED, so the restart goes on to void the other holds without a capture call.

Result: the winner is charged once.

The rule behind all three cases

On every run the saga reads the truth at PayPal first, then acts. A capture that is already there is adopted. Only when PayPal shows no capture is the saved intent sent again, with the same request id.

Crash pointWhat the journal holdsWhat the restart does
A: after the intent, before the call reached PayPalIntent saved, state CAPTURINGTruth shows no capture, so the same intent is sent again with the same request id
B: after PayPal took the capture, before the answer was savedThe same intent, no outcomeTruth shows the capture (invoice id and amount match), so it is adopted. No second call
C: after capture_ok was savedState CAPTURED, winner recordedGoes on to void the other holds. No capture call

If the truth cannot be read, or PayPal does not know the hold, that is not an answer: the intent stays, no other candidate is tried, the unknown clock runs, and after ten minutes the settlement goes to FAILED_NEEDS_REVIEW for a person.

The restart, minute by minute

The first diagram shows what each crash leaves behind. This one follows the restart that comes after it. Pick a crash point, then step through what the server does.

Restart timeline: from a crash back to one charge

Fake PayPalSimulated S6
Restart timeline: from a crash back to one chargeTop: the five points of the capture where a crash is injected in the simulation, with the selected one marked, and what the journal and PayPal hold at that moment. Bottom: six restart stages. The process starts and calls resume. A lease from a killed process must expire, up to 320 seconds. The journal is read and missing audit rows are appended. The truth of the hold is read at PayPal. The decision depends on the crash point: resend the saved intent with the same request id, adopt a capture that is already there, or go straight to voiding. Finally the other holds are voided and the settlement is settled.The capture, with the crash point you pickedjournaled:beginpoint 1journaled:intentpoint 2before:capturepoint 3!after:capturepoint 4journaled:capture_okpoint 5Left behindjournal on disk: intent saved, no outcomePayPal: capture taken, the answer was lostprocess killed hereAfter the restart1Process startsresume() at boot2Leaseone runner at a time3Journal and trailread, then catch up4Truth readask PayPal first5Decidedepends on the crash6Finishvoid, settle, sweepkilled process: lease staysuntil it expires (320 s);busy until then, try againon the next 60 s passjournal read from diskaudit rows above auditedSeqare appended firstGET the hold at PayPal:is there a capture, is thehold still open?truth: the capture is there,amount and invoice match.Adopted. No second call.void the other holds,3 at a time: SETTLEDthen sweep stored eventsand abandoned holdsResult in every simulated variant: one charge on the winnerIf the truth cannot be read, or the hold is not found: nothing is guessed. The intent stays, no other bidder is tried, the 10 minute clock runs.

Scroll the figure sideways, or open it full size.

Restart stage 6 of 6. Arrow keys work too.

Finish. The other holds are voided, three at a time, and the settlement ends SETTLED. Each resume pass also applies stored webhook events that have no result and sweeps abandoned holds.

Crash points and results are the simulated scenario S6 (docs/evidence/SIM/SIM-MULTIDAY.md): 10 variants, every restart ends with one charge. The restart stages are read from packages/server/src/runtime/auction-service.ts, boot/compose.ts, packages/payments/src/saga/create.ts, saga/trail.ts and saga/capture.ts. One in-process crash at the same place (R10) passed on the real sandbox; killing a real server process on a deployed host has not been run.
Text version of this diagram

Crash points injected by the simulation, in order: journaled:begin, journaled:intent, before:capture, after:capture, journaled:capture_ok.

  • journaled:begin: journal holds state CAPTURING, no intent; PayPal holds no capture. The journal only says the capture phase began. The restart does the whole phase, intent first.
  • journaled:intent: journal holds intent saved: capture, amount, request id; PayPal holds no capture (call not sent). The intent was saved before the call, so the restart sends exactly that call. Nothing is rebuilt.
  • before:capture: journal holds intent saved: capture, amount, request id; PayPal holds no capture (died just before the call). From the journal this looks the same as the previous point. The truth read tells them apart from point 4.
  • after:capture: journal holds intent saved, no outcome; PayPal holds capture taken, the answer was lost. The worst moment. The truth read finds the capture, so the winner is charged once. This holds even if PayPal forgot the request id (simulated S6 variants).
  • journaled:capture_ok: journal holds state CAPTURED, winner set, intent cleared; PayPal holds capture taken. The capture is finished and saved. The restart goes straight on to releasing the other holds.

Restart stages: resume() runs at boot and every 60 seconds; a lease of a killed process holds until it expires (320 seconds by default); the journal is read and missing audit rows are appended; the truth of the hold is read at PayPal; the decision follows from the crash point; the other holds are voided and the settlement is settled, and stored webhook events and abandoned holds are swept.

The server calls resume()

compose.ts calls service.resume() once when the host boots. The service then calls it again on a timer, every 60 seconds by default.

The lease may still be held

A settlement run takes a lease so that two runners never work on one auction. A process that was killed cannot give it back, so the new process is told "busy" until the lease expires. The default lifetime is 320 seconds: twice the longest a retried money call can take (four attempts of ten seconds plus the Retry-After waits), plus a minute. The next 60 second pass tries again. In the simulation an exception releases the lease on the way out, so those restarts do not show this wait.

The journal and its audit trail are caught up

The journal is read from disk. A transition is saved first and its audit row is written after, so a crash between the two could lose the row. The journal history is the source: the run starts by appending the rows above auditedSeq, before any other effect.

The truth is read, then the decision follows

PayPal is asked about the hold before anything is sent. Depending on where the crash was, the saved intent is sent again, a capture that is already there is adopted, or no capture call is made at all.

The settlement finishes and the sweeps run

The other holds are voided, three at a time, and the settlement ends SETTLED. Every resume pass also applies webhook events that were stored but have no result, and sweeps abandoned holds and orphan voids.

The server side

The host calls resume() on start and then on a timer (every 60 seconds by default). It loads every known room, continues unfinished settlements, applies stored webhook events and retries orphan voids. The audit trail rows of a transition are recovered from the journal history, so a crash between a save and its audit row loses nothing.

What has been tested

Where this lives in the code

Edit on GitHub

Last updated on