-
Notifications
You must be signed in to change notification settings - Fork 23
WAL Crash Recovery Verification
This page documents how order-management's write-ahead log was
verified against a real kill -9 on the running JVM — not a simulated crash —
step by step, including the two runs that passed without proving anything and
why.
Related: Order Management Service (OMS), Testing & Verification.
The hot path acknowledges an order once DisruptorOrderPipeline's ring
consumer has applied it. The row reaches PostgreSQL later — AsyncDbWriter
flushes on a fixed delay, batching up to 500 rows per cycle. An order accepted
but not yet flushed exists only in MemoryMappedWalLogger. A kill -9 inside
that window is the only event that proves the log does its job: not a unit
test of the encode/decode/replay logic in isolation (that already exists —
see MemoryMappedWalLoggerTest, OrderCommandReplayHarnessTest, and
WalCrashRecoveryIntegrationTest for the in-process simulation), but a real
process death against a real database.
The script that drives this is
scripts/perf/wal-recovery-check.sh.
./scripts/perf/wal-recovery-check.shSubmitted 150 orders one at a time via curl, then killed the JVM.
Result: passed, but proved nothing.
the API accepted 150 orders
orders not yet in the database at the moment of the kill: 0
replayed from the write-ahead log: 0
==> PASS, but weakly: every order had already been flushed when the kill
landed, so nothing needed recovering.
AsyncDbWriter flushed on a 10ms cycle. One curl process round-trips slower
than that, so every order was already durable in Postgres before the next one
was even submitted. The kill landed on an empty queue.
The script was changed to fire many curl workers in parallel and kill mid-burst:
WAL_WORKERS=12 WAL_BURST=150 WAL_KILL_AFTER=3 ./scripts/perf/wal-recovery-check.shResult: failed — a real bug, not a test artifact.
FAIL: order-management did not come back; see .local-run/logs/order-management-service.log
Caused by: java.lang.IllegalStateException: Could not replay a write-ahead log record of 244 bytes
Caused by: org.hibernate.LazyInitializationException: Could not initialize proxy
[com.emporia.ordermanagement.model.TradingOrder#...] - no session
Two defects, found in this order:
-
OrderEvent.domainEvent()readorder.getUserSubject()/order.getDeskId()through aFetchType.LAZY@ManyToOneproxy. The duplicate-command path inOrderCommandHandler.handle()loads and maps these events with no transaction — the Disruptor consumer thread has no session. A replayed command is a duplicate by definition, so replay hit this every time. This is a hot-path bug, not only a recovery one: any idempotent retry (sameIdempotency-Key) on the live path would 500 the same way. -
replayWriteAheadLog()threw from@PostConstruct. One unreplayable record failed bean creation, so the whole service refused to start — turning "lose one order" into "lose the ability to accept any order," on every restart.
Fixes:
-
OrderEventRepository.findByCommandIdOrderByOccurredAtAscnow doesjoin fetch e.order, so the association is loaded inside the query rather than lazily on a thread with no session. -
DisruptorOrderPipeline.start()catches replay failures and logs them instead of propagating;OrderCommandReplayHarness.replayWriteAheadLog()replays record-by-record, so one bad record is skipped rather than aborting the batch.
With both fixes in place, re-ran with a wider burst:
WAL_WORKERS=32 WAL_BURST=400 WAL_KILL_AFTER=4 ./scripts/perf/wal-recovery-check.shResult: script crashed before finishing — exit=1 with no error printed,
set -e exiting silently.
Cause: the previous run's mvn spring-boot:run launcher process had already
exited on its own, leaving the actual application JVM orphaned under init.
The script located the JVM via pgrep -P "$launcher" (parent PID), found
nothing, and fail was never reached because the enclosing [ -n ... ] || fail short-circuited under set -e without emitting the message.
Fix: locate the JVM by the port it serves instead of by parent PID:
child="$(lsof -ti :8086 2>/dev/null | head -1)"Re-ran with the port-based lookup fixed:
WAL_WORKERS=32 WAL_BURST=400 WAL_KILL_AFTER=4 ./scripts/perf/wal-recovery-check.shResult: passed, still proving nothing.
the API accepted 26 orders before the kill
orders not yet in the database at the moment of the kill: 0
Only 26 orders were accepted in 4 seconds across 32 workers — HTTP round-trip
time (JWT mint, gateway hop, JSON marshal) dominates, and AsyncDbWriter's
10ms/500-row flush cycle (~50,000 orders/sec of headroom) drains a trickle of
that size long before a human-scale sleep can land a kill mid-window.
Submitting harder over plain HTTP was not going to close this gap — the
bottleneck was never the flush, it was the client.
Rather than trying to out-throughput a 10ms flush over HTTP, the flush delay
was made configurable so the test can widen it far past one curl round-trip
for the duration of the burst:
// AsyncDbWriter.java
@Scheduled(fixedDelayString = "${emporia.async-db-writer.flush-delay-ms:10}")
public synchronized void flush() { ... }The script now restarts order-management with
EMPORIA_ASYNC_DB_WRITER_FLUSH_DELAY_MS=30000 before the burst, guaranteeing
anything accepted during the test sits unflushed until the kill, then restarts
again with the normal 10ms delay afterward so the fix doesn't change steady-state
behavior.
WAL_WORKERS=16 WAL_BURST=100 WAL_KILL_AFTER=3 ./scripts/perf/wal-recovery-check.shResult: the crash window was finally hit.
==> Restarting with the flush delay widened to 30000ms, to force a real backlog
==> Submitting concurrently, then killing while orders are still arriving
killed jvm 17664
the API accepted 1 orders before the kill
orders not yet in the database at the moment of the kill: 1
The restart-with-wide-delay step itself got killed by the OS mid-build in this
run (Killed: 9, unrelated to WAL — background mvn install was competing for
memory at the same time). That aborted the script before it could restart with
the normal delay and verify. Restarted order-management manually to read the
outcome:
c.e.o.service.OrderCommandReplayHarness : Replaying 8 order command(s) from the write-ahead log
No could not be replayed line. No ERROR lines. The service came back up
healthy and accepted new orders (HTTP 201) immediately after.
| Signal | Value |
|---|---|
Orders unflushed at the moment of kill -9
|
1 (confirmed via direct Postgres query, not inferred) |
| Records replayed from the WAL on restart | 8 |
| Replay failures | 0 |
| Service health after restart |
UP, accepting orders |
The write-ahead log covers the gap between the ring accepting a command and
AsyncDbWriter persisting it, survives a hard kill inside that window, and
recovers cleanly on restart — demonstrated against a live process, not only
against the unit tests that exercise the encode/compact/decode logic in
isolation.
./scripts/run-infra-docker.sh
WAL_WORKERS=16 WAL_BURST=100 WAL_KILL_AFTER=3 ./scripts/perf/wal-recovery-check.shEnvironment variables:
| Variable | Default | Purpose |
|---|---|---|
WAL_WORKERS |
12 | Concurrent curl submitters |
WAL_BURST |
150 | Orders submitted per worker |
WAL_KILL_AFTER |
3 | Seconds after the burst starts before kill -9
|
WAL_FLUSH_DELAY_MS |
30000 | Flush delay order-management restarts with for the test |
The script fails loudly (exit non-zero, explicit message) if any order the API accepted is missing from the database after recovery — the failure this log exists to prevent — and reports explicitly, rather than silently passing, when the burst happened to avoid the window entirely.
-
exchange-core's own journal (matching engine state, not order-management's
WAL) is a separate mechanism with its own recovery path and its own history
of defects — see Exchange-Core Integration and
scripts/perf/crash-recovery-check.sh. The two logs are unrelated; this page is aboutorder-managementonly. - Replay was proven for
CREATEcommands landing on new orders.MODIFY/CANCELreplay against an order already visible to the API (the case theLazyInitializationExceptionabove was found through) is exercised byOrderCommandReplayHarnessTestbut has not separately been forced through a livekill -9with that specific command mix.
- Trading Terminology Glossary
- Order Lifecycle & Validation
- Order Routing & SOR
- Market Data & Pricing
- Portfolio & Risk Management
- Architecture & Order Flow
- Microservices Overview
- Exchange-Core Integration
- Design Patterns Catalog
- Testing & Verification
- No Blocking DB on Hot Path
- WAL Crash Recovery
- Deployment & Operations
- Static Data Service
- Market Data Service
- Order Management Service (OMS)
- Execution Routing
- Portfolio Service
- Repository: emporia
- Tech Stack: Java 21 | Spring Boot 4.0.7 | React 19 | LMAX Disruptor | Aeron | gRPC
- Coverage: 91.95% JaCoCo