Skip to content

feat: carry engine reject reason on order-status egress (SBE v6) (#75)#110

Merged
emrebulutlar merged 1 commit into
mainfrom
fix/match75-reject-reason-wire
Jul 7, 2026
Merged

feat: carry engine reject reason on order-status egress (SBE v6) (#75)#110
emrebulutlar merged 1 commit into
mainfrom
fix/match75-reject-reason-wire

Conversation

@emrebulutlar

Copy link
Copy Markdown
Member

Problem

The engine knows exactly why it rejects an order (OrderRejectReason: PRICE_OFF_TICK, PRICE_OUT_OF_RANGE, OVERFLOW, INVALID_QUANTITY, MATCH_LIMIT, LEVEL_FULL, BOOK_FULL, plus would-cross / no-liquidity / order-not-found cases), but publishOrderStatus(..., REJECTED, ...) carried no reason on the wire. The OMS therefore surfaced "status":"REJECTED","rejectReason":null to end users. With self-registered demo users typing arbitrary prices, a silent reject is a confusing dead end ("my order just vanished", the real cause was an off-tick price).

This carries the reason on the order-status egress, mixed-version safe exactly like v5's takerSide.

Schema (SBE v5 -> v6)

match-common/src/main/resources/sbe/order-schema.xml:

  • version 5 -> 6. semanticVersion left at 5.2, mirroring the v4 -> v6 history (the v4 -> v5 bump did not touch it either; 5.2 is the static SBE-spec semantic).
  • New type RejectReasonCode: uint8, presence="optional", nullValue="255".
  • OrderStatusUpdate (id=5): new message-tail field rejectReason (id=11, sinceVersion="6"). Old peers skip it via the message block length.
  • OrderStatusBatch (id=24): new group-tail field rejectReason on the per-order group (id=12, sinceVersion="6"), same tail-extension pattern as statusSeq (v3) and TradesBatch.takerSide (v5): old peers skip it via the on-wire group block length; new peers reading a pre-v6 stream get the null value.

Wire value is the raw OrderRejectReason code; 0 = NONE on non-rejects; 255 is reserved as "unknown / pre-v6 upstream" (OMS maps it to null). Codes never reach 255, so null is unambiguous.

Generated SBE sources are committed in this repo (codegen writes into src/main/java), so the regenerated codecs are part of the diff. Every generated file shows SCHEMA_VERSION 5 -> 6; the substantive additions are the rejectReason encoder/decoder on OrderStatusUpdate and OrderStatusBatch.Orders.

New reject reason codes

Three reject paths previously had no OrderRejectReason constant, so every REJECTED now carries a real reason:

Code Value Fires when
WOULD_CROSS 8 Post-only (LIMIT_MAKER) would cross the opposite best. Both the create branch and the amend branch (match#92).
NO_LIQUIDITY 9 A well-formed MARKET order matched nothing (empty opposite book). Distinct from INVALID_QUANTITY (garbage size before the sweep).
ORDER_NOT_FOUND 10 A CANCEL/UPDATE referenced an order not resting on the book.

Existing codes (PRICE_OUT_OF_RANGE=1, PRICE_OFF_TICK=2, LEVEL_FULL=3, BOOK_FULL=4, OVERFLOW=5, INVALID_QUANTITY=6, MATCH_LIMIT=7) are carried as-is. describe() updated.

Plumbing

An int rejectReason threads the full publish chain:

Engine.publishOrderStatus -> MatchEventSink.publishOrderStatusUpdate -> MatchEventPublisher -> PublishEvent -> MarketPublisher (OrderStatusEntry buffering) -> encodeOrderStatusBatch.

Every Engine reject site populates the correct reason (create: validity rejects, LIMIT_MAKER would-cross, market no-liquidity, market invalid-quantity; update: validity rejects, would-cross, cancel-miss ORDER_NOT_FOUND, could-not-rest restReason on both LIMIT and LIMIT_MAKER paths). Reason-carrying CANCELLED terminals also carry it (capped-market MATCH_LIMIT, could-not-rest-after-partial restReason) since it is free on the wire and useful. Non-reject statuses (NEW / PARTIALLY_FILLED / FILLED / plain-CANCELLED acks) pass NONE (0).

Mixed-version analysis (verified by tests)

  • Old reader (v5 acting version) on a v6 stream: strides by the on-wire block length (message or group), so it reads every field it knows and skips the tail byte cleanly. Verified across a multi-entry batch (statusSeq + orderId intact per entry).
  • New reader (v6) on a v5 stream: the field getter returns nullValue 255 = "unknown" (callers treat 255 as null; OMS maps it to null). Verified with a genuine v5-shaped buffer (group block length shrunk by the tail byte, header version 5).
  • Gateway: GatewayStateManager.onOrderStatusBatch is a decode-and-drop no-op and AeronGateway wraps with the on-wire header version, so v6 batches decode without breaking. Gateway SBE/state tests (57) stay green.

Determinism / golden regen

RecordingEventSink / EngineEvent.Status carry the field and render reason=NAME. This regenerated the whole golden corpus once (expected). The diff is purely the reason= suffix on every STATUS line: 256 lines replaced 1:1, no TRADE line touched, no event added / removed / reordered, and stripping the suffix reproduces the old lines exactly (verified mechanically). Reject scenarios show the correct code (e.g. reason=PRICE_OFF_TICK, reason=NO_LIQUIDITY, reason=WOULD_CROSS, reason=INVALID_QUANTITY); non-rejects show reason=NONE.

Tests

  • SBE round-trip (MarketPublisherOrderStatusReasonTest, new): v6 writer -> v6 reader (reason survives), v6 writer -> v5-acting reader (tail skipped, fields intact, reason reads null), genuine v5 stream -> v6 reader (reads nullValue 255).
  • Engine -> publish chain (EnginePublisherIntegrationTest, +5): PRICE_OFF_TICK, PRICE_OUT_OF_RANGE, WOULD_CROSS (post-only cross), NO_LIQUIDITY (market empty book), and NONE on an accepted NEW.
  • Determinism: full corpus + A-B parity (array vs direct) + snapshot-replay green after golden regen; only the reason-field addition in the diff.

mvn test on match-common (green) and match-cluster excluding EmbeddedClusterTest/ArchiveHousekeepingTest: 442 tests, 0 failures, 0 errors. Gateway SBE/state subset: 57, 0 failures.

Closes #75. Part 1 of #64 (part 2, exposing tick/range rules on GET /markets, is out of scope here).

🤖 Generated with Claude Code

The engine knows exactly why it rejects an order (OrderRejectReason:
PRICE_OFF_TICK, PRICE_OUT_OF_RANGE, OVERFLOW, INVALID_QUANTITY,
MATCH_LIMIT, LEVEL_FULL, BOOK_FULL, plus would-cross / no-liquidity /
order-not-found cases) but publishOrderStatus carried no reason on the
wire, so the OMS surfaced "rejectReason":null to users. Carry it, mixed-
version safe like v5's takerSide.

- SBE schema v6: OrderStatusUpdate gains a rejectReason message-tail field
  and OrderStatusBatch's per-order group gains a rejectReason group-tail
  field (both sinceVersion=6, uint8 optional, nullValue=255 = "unknown /
  pre-v6 upstream"). Old peers skip the tail via block length; new peers
  reading a v5 stream get 255. Wire value is the raw OrderRejectReason code
  (0=NONE on non-rejects). semanticVersion left at 5.2, exactly as the
  v4->v5 bump did.
- OrderRejectReason: three codes for reject paths that previously had none,
  so every REJECTED now carries a real reason: WOULD_CROSS=8 (post-only
  create + amend), NO_LIQUIDITY=9 (market order matched nothing),
  ORDER_NOT_FOUND=10 (cancel/amend of a non-resting order). describe()
  updated.
- Plumbing: an int rejectReason threads Engine.publishOrderStatus ->
  MatchEventSink.publishOrderStatusUpdate -> MatchEventPublisher ->
  PublishEvent -> MarketPublisher OrderStatusEntry ->
  encodeOrderStatusBatch. Every Engine reject site populates the reason;
  reason-carrying CANCELLED terminals (capped-market MATCH_LIMIT,
  could-not-rest-after-partial) carry it too. Non-rejects pass NONE.
- Determinism: RecordingEventSink / EngineEvent.Status carry the field and
  render "reason=NAME"; the whole golden corpus is regenerated. The diff is
  purely the reason= suffix on every STATUS line (256 lines replaced 1:1,
  no TRADE line touched, no event added / removed / reordered, no other
  field changed).
- Tests: SBE round-trip for OrderStatusBatch (v6->v6 reason survives;
  v6->v5-acting reader strides the tail cleanly and reads null; genuine v5
  stream -> v6 reader reads nullValue 255); Engine->publish-chain reason
  for PRICE_OFF_TICK / PRICE_OUT_OF_RANGE / WOULD_CROSS / NO_LIQUIDITY and
  NONE on accepted statuses; full corpus + A-B parity + snapshot-replay
  green after golden regen; gateway OrderStatusBatch decode-and-drop still
  green on v6.

Closes #75. Part 1 of #64.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@emrebulutlar
emrebulutlar merged commit 191672c into main Jul 7, 2026
2 checks passed
@emrebulutlar
emrebulutlar deleted the fix/match75-reject-reason-wire branch July 7, 2026 21:07
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.

carry the engine's reject reason on order-status egress — user-facing rejects surface as rejectReason:null

1 participant