Skip to content

Array-backed order book + egress hardening (v0.2.0-alpha)#27

Merged
emrebulutlar merged 4 commits into
mainfrom
array-backed-order-book
Jun 27, 2026
Merged

Array-backed order book + egress hardening (v0.2.0-alpha)#27
emrebulutlar merged 4 commits into
mainfrom
array-backed-order-book

Conversation

@emrebulutlar

Copy link
Copy Markdown
Member

Replaces the preallocated order book with an array-backed, geometry-free engine, and bounds the egress pipeline so matching nodes can't OOM under load. Engine selected by match.engine.impl / MATCH_ENGINE_IMPL (default flipped to array; direct falls back to the old engine).

This branch stacks on top of the determinism/durability suite. Review commit-by-commit:

  • 41c3cf2 test(cluster): determinism golden-master + snapshot/durability suite (the guardrail)
  • de01971 feat(cluster): array-backed order book (geometry-free, memory ∝ orders, no 64-cap)
  • f45266f fix(cluster): bound the MarketPublisher egress buffers (entry cap)
  • cff3fbe fix(cluster): byte-bound the egress queues (eliminate OOM under extreme load)

What changed

  • Array-backed order book — AA tree + pooled doubly-linked FIFO in flat primitive arrays. Geometry-free (no fixed range/tick), no 64-orders-per-level cap (book-wide tunable pool, loud BOOK_FULL), zero hot-path allocation. Orders key on price (not node id) so AA delete-by-copy stays correct. Cross-thread top-of-book via a seqlock.
  • Egress hardened — the egress buffers were bounded by entry count but not bytes, so a slow/backed-up consumer (or replaying a large log) could exhaust the heap and OOM the matching/consensus threads. Now byte-bounded end to end (MarketPublisher buffers + omsEgressQueue/marketDataQueue, ~176 MB cap); sheds with a loud log + OMS reconciliation instead of crashing.
  • Validation — both engines run the determinism corpus; byte-identical except the intended 64-cap change (golden regenerated). Added A/B equivalence, cross-impl snapshot round-trip, model-stress-vs-TreeMap, heap-footprint, trade-batch-chunking, and BOOK_FULL tests.

Validated (single 13700K, 3-node Raft cluster, dev/local)

  • Sub-microsecond matching (p50 ~0.3 µs), flat across load.
  • 281,000 orders/sec at 100% success (limited by the load generator, not the cluster).
  • ~3x less order-book memory (668 → 222 MB in-process; long[] 735 → 182 MB live).
  • The 400k orders/sec overload that previously OOM'd two nodes now runs clean, 0 OOM.

Tunables / notes

  • MATCH_ENGINE_IMPL=direct reverts to the preallocated engine.
  • match.engine.book.capacity (default 131072/side), match.egress.buffer.max (default 200k).
  • FixedPoint.multiply large-price overflow is preserved as-is (tracked separately).

Alpha. Tag: v0.2.0-alpha.

🤖 Generated with Claude Code

emrebulutlar and others added 4 commits June 27, 2026 14:45
Same-input->same-output regression net for the matching engine.

- MatchEventSink interface: capture engine output synchronously (no Disruptor)
- SnapshotCodec extracted from AppClusteredService (byte format unchanged)
- DeterminismCorpusTest: 15 .scenario/.golden pairs (golden + run-twice + wall-clock invariance)
- SnapshotCodecTest + EngineSnapshotReplayTest: round-trip, byte-determinism, snapshot-transparent replay
- MatchingInvariantsTest: seeded conservation + no-crossed-book + determinism
- FixedPointTest: exactness/round-trip/commutativity cases
- tools/durability: deterministic multi-node scenarios via Admin API

508 tests green (incl. real-Aeron EmbeddedClusterTest). No hot-path behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s, no 64-cap)

Replace the preallocated DirectIndexOrderBook/DirectMatchingEngine with an
array-backed, pooled, intrusive ordered index behind a new MatchingEngine
interface, selected by match.engine.impl / MATCH_ENGINE_IMPL (default "array";
"direct" falls back to the preallocated engine).

- ArrayOrderBook: AA tree of price levels in parallel primitive arrays (ported
  from AATree) + a shared pooled doubly-linked FIFO order list. Orders key on
  price (not node id) so AA delete-by-copy stays correct for external refs.
  Geometry-free, depth bounded only by a tunable pool (loud BOOK_FULL), zero
  hot-path allocation, no GC references.
- ArrayMatchingEngine: drop-in match loop on level node ids; cross-thread
  top-of-book published on the writer thread via a seqlock (the tree cannot be
  walked lock-free like the positionally-stable direct book).
- PriceRules: re-homed tick/range validation (range now runtime-adjustable),
  preserving exact existing rejection behavior.
- MarketPublisher: chunk the trade-execution batch like the order-status batch
  (pre-existing fixed 256KB encodeBuffer overflowed on a >~3600-trade flush
  burst, e.g. egress re-emission on a leader takeover).

Guarded by a MatchingEngine interface + flag so both impls run the determinism
corpus: byte-identical except level_full_reject (64-cap removed -> 65th order
rests as NEW; golden regenerated). Added A/B equivalence, cross-impl snapshot
round-trip, model-stress-vs-TreeMap, heap-footprint, and trade-batch-chunking
tests. Validated on the live 3-node cluster: ~580MB heap freed (848->267MB),
latency parity (baseline p99 10.4us, stress p99 6.0ms), zero flush errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… under load

The egress pipeline had exactly one unbounded stage: MarketPublisher's per-event
OMS-bound buffers (tradeExecutionBuffer, orderStatusBuffer), cleared only by the
50ms flush. The disruptor ring upstream and the omsEgressQueue/marketDataQueue
downstream are both bounded, but these ArrayLists were not — so if the flush fell
behind (CPU starvation, a GC pause, or a slow/stalled egress consumer) they grew
without bound until the heap was exhausted and the matching/consensus threads OOM'd.
Reproduced by a sustained ~118k orders/sec load test: two nodes OOM'd (auto-restarted
and recovered via Raft, but a real production-disaster failure mode).

Backpressuring the matching thread isn't an option: the egress drain (flush()) runs
on the same cluster thread, so blocking it would deadlock. Instead, bound the buffers
and drop-with-loud-log on overflow — the same policy the egress queues already use.
Dropped settlement events are recoverable from the authoritative cluster log via OMS
reconciliation. Memory now caps at a few tens of MB; under overload the system sheds
load (Aeron ingress backpressure) instead of crashing. Cap tunable via
-Dmatch.egress.buffer.max (default 200k); exposed getDropped{Trade,Status}Events()
for monitoring. Pre-existing bug, independent of the array engine (whose freed heap
only delayed it). Regression test drives the buffer past a small cap and asserts it
drops rather than grows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eme load

The MarketPublisher buffer fix (f45266f) closed one link of the egress chain, but the
next link OOM'd: AppClusteredService's omsEgressQueue/marketDataQueue are bounded by
ENTRY COUNT (262144 / 10000), yet each entry holds a copied batch buffer (up to ~150KB),
so a backed-up egress consumer (or replaying a huge log) fills the heap and OOMs the
matching/consensus threads. A 400k orders/sec load test crashed two nodes and broke
recovery this way.

Also cap total queued BYTES (omsEgress 128MB, marketData 32MB) — incremented on offer,
decremented on drain; drop with a loud log on overflow. The egress is now byte-bounded
end-to-end (ring -> MarketPublisher buffers -> these queues), capping egress heap at
~176MB regardless of consumer speed. Dropped settlement is recoverable via OMS
reconciliation against the cluster log.

Validated: the same 400k/sec overload that previously OOM'd the cluster now runs at
281k orders/sec, 100% success, ZERO OOM — gracefully shedding 0.08% of egress (loud
CRITICAL log) instead of crashing; heap bounded, all 3 nodes healthy throughout.
Throughput is HIGHER (281k vs 209k) because egress no longer destabilizes the cluster.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@emrebulutlar
emrebulutlar merged commit cff3fbe into main Jun 27, 2026
1 check 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