feat: carry engine reject reason on order-status egress (SBE v6) (#75)#110
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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), butpublishOrderStatus(..., REJECTED, ...)carried no reason on the wire. The OMS therefore surfaced"status":"REJECTED","rejectReason":nullto 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:version5 -> 6.semanticVersionleft at5.2, mirroring the v4 -> v6 history (the v4 -> v5 bump did not touch it either;5.2is the static SBE-spec semantic).RejectReasonCode:uint8,presence="optional",nullValue="255".OrderStatusUpdate(id=5): new message-tail fieldrejectReason(id=11,sinceVersion="6"). Old peers skip it via the message block length.OrderStatusBatch(id=24): new group-tail fieldrejectReasonon the per-order group (id=12,sinceVersion="6"), same tail-extension pattern asstatusSeq(v3) andTradesBatch.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
OrderRejectReasoncode;0 = NONEon non-rejects;255is reserved as "unknown / pre-v6 upstream" (OMS maps it tonull). 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 showsSCHEMA_VERSION 5 -> 6; the substantive additions are therejectReasonencoder/decoder onOrderStatusUpdateandOrderStatusBatch.Orders.New reject reason codes
Three reject paths previously had no
OrderRejectReasonconstant, so every REJECTED now carries a real reason:WOULD_CROSSNO_LIQUIDITYORDER_NOT_FOUNDExisting 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 rejectReasonthreads the full publish chain:Engine.publishOrderStatus->MatchEventSink.publishOrderStatusUpdate->MatchEventPublisher->PublishEvent->MarketPublisher(OrderStatusEntrybuffering) ->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-partialrestReason) since it is free on the wire and useful. Non-reject statuses (NEW / PARTIALLY_FILLED / FILLED / plain-CANCELLED acks) passNONE(0).Mixed-version analysis (verified by tests)
nullValue255 = "unknown" (callers treat 255 as null; OMS maps it tonull). Verified with a genuine v5-shaped buffer (group block length shrunk by the tail byte, header version 5).GatewayStateManager.onOrderStatusBatchis a decode-and-drop no-op andAeronGatewaywraps with the on-wire header version, so v6 batches decode without breaking. Gateway SBE/state tests (57) stay green.Determinism / golden regen
RecordingEventSink/EngineEvent.Statuscarry the field and renderreason=NAME. This regenerated the whole golden corpus once (expected). The diff is purely thereason=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 showreason=NONE.Tests
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).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.mvn teston 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