Resequence quantity increases without vacating the id (#119)#127
Conversation
Codecov Report❌ Patch coverage is
🚀 New features to boost your workflow:
|
joaquinbejar
left a comment
There was a problem hiding this comment.
The map residency change closes the cancel and duplicate admission window, and the migration docs are in sync, but the two step index rekey still permits a missed fill and the public destructive pop can return a demoted maker out of FIFO order. The intended effective verdict is REQUEST_CHANGES.
joaquinbejar
left a comment
There was a problem hiding this comment.
The existing review covers the re-key and pop races. One additional FIFO violation remains because concurrent resizes choose priority from a stale quantity. The intended effective verdict remains REQUEST_CHANGES, submitted as COMMENT because the active account owns this PR.
66fb56d to
4469e5e
Compare
joaquinbejar
left a comment
There was a problem hiding this comment.
The rebased head is patch equivalent to the previously reviewed change, and the existing non linearizable rekey, sequence blind destructive pop, and stale priority decision findings remain unresolved. The intended effective verdict is REQUEST_CHANGES. GitHub requires COMMENT because this is a self review.
4469e5e to
b523bbe
Compare
joaquinbejar
left a comment
There was a problem hiding this comment.
The current head still has the existing non linearizable index rekey, sequence blind destructive pop, and stale priority decision findings. The intended effective verdict is REQUEST_CHANGES. GitHub requires COMMENT because this is a self review.
joaquinbejar
left a comment
There was a problem hiding this comment.
The current head closes the map vacancy window, but the existing non linearizable index rekey, sequence blind destructive pop, and stale priority decision findings remain unresolved. The intended effective verdict is REQUEST_CHANGES. GitHub requires COMMENT because this is a self review.
add_order accepted any order without checking that its price equals the level price or that all makers share one side. A wrong-price maker traded at PriceLevel::price rather than its own stored price, and mixed maker sides produced contradictory taker sides across a single MatchResult (the taker side is derived per-maker). The maker != taker guard was a debug_assert, so release builds could emit self-trades. Admission now validates topology before any counter reservation: a mismatching price or an incompatible side is rejected with InvalidOperation and the level is left byte-identical. The level side is derived from the resting orders (first admitted maker defines it; a drained level accepts either side again) -- a correctness invariant on single-logical-writer admission, with both residual race windows documented (empty-level admission race, and the upsize remove+push transient that #119's in-place re-sequencing closes). Self-trades are now structurally impossible in every build profile: a front maker whose id equals the taker id is parked like a set-aside maker (no trade, no counter movement, makers behind it still match in FIFO). This is order-id identity -- an order can never match itself; account-level STP via user_id remains the composing book's job. BREAKING: matchable_quantity takes the taker id so the fill-or-kill dry run applies the identical skip and can never diverge from the sweep (migration guide updated, README regenerated). from_snapshot validates the same price/side topology; the infallible From<&PriceLevelSnapshot> is documented as the trusted-input path that skips validation. Closes #120
b523bbe to
82ccfbd
Compare
joaquinbejar
left a comment
There was a problem hiding this comment.
The current head closes the map vacancy window, but the existing non linearizable index rekey, sequence blind destructive pop, and stale priority decision remain unresolved. The intended effective verdict is REQUEST_CHANGES. GitHub requires COMMENT because this is a self review.
joaquinbejar
left a comment
There was a problem hiding this comment.
The in place update removes the map absence window, but rekeying still creates an index absence window, pop can consume a demoted maker through a stale sequence, and the priority decision uses an unlocked pre read. GitHub requires this self review to be submitted as COMMENT, but the intended effective verdict is REQUEST_CHANGES.
- side and order count now live in one packed atomic word: admission establishes the side / joins / rejects in a single CAS, and removal decrements and un-pins in the same CAS when the count reaches zero, so the two races derive-from-queue left open (opposite-side admissions into an empty level; an opposite-side admission racing an upsize's transient queue gap) are closed by construction -- and every admission drops the per-call queue iteration, cutting add_order latency ~39% in the admission bench - a topology epoch bumps on every pin / un-pin and snapshot() retries its materialization if the epoch moves, so a checksummed snapshot can never capture a torn old-side/new-side view across a valid drain-then-re-admit transition (single-side backstop bounds the retry) - a taker whose id already rests at the level is now rejected terminally before any sweep for every TIF -- zero trades, level byte-identical -- per the issue's no-mutation self-match contract; the in-sweep skip remains as defense-in-depth for admissions racing the sweep - the set-aside bookkeeping insert moved out from under the shard lock (no allocation under the entry lock; no eager pre-reserve either -- the common zero-set-aside match stays allocation-free)
The quantity-increase branch demoted via remove(id) + push(new): during that gap the id was absent from the queue map, so a concurrent cancel reported no removal while the update re-inserted the maker (lost cancel / resurrection), a concurrent same-id admission could win try_push and then be blindly overwritten (the P1 flagged in the #113 audit), and the transient absence was the second side-topology race window documented in #120. match_front also never verified that the index-selected sequence still owned the live map entry, so a stale front selection could act on a just-demoted maker. New OrderQueue::resequence_to_tail extends the ReplaceAtTail shape: under one continuous hold of the DashMap entry lock it mints a tail sequence, swaps the stored (seq, order) pair in place, and re-keys the index -- the id never leaves the map. A concurrent cancel now either fully precedes (update reports not-found) or fully follows (it removes the re-sequenced order); a same-id admission is always rejected as DuplicateOrderId. match_front gains a stale-seq guard: if the stored sequence no longer equals the index-selected one, the stale key is dropped and the scan retries -- sequences are monotonic and never reused, so the removal is provably safe. Demotion semantics unchanged (fresh tail seq, original timestamp; #109 tests untouched). BREAKING: OrderQueue::push (blind overwrite) has no production caller left and is retired to #[cfg(test)] -- admission uses try_push, demotion uses resequence_to_tail. Migration guide + README updated; the now-closed #113/#120 doc caveats are retired. Barrier race tests (1500 iterations each): upsize-vs-cancel never loses a cancel or resurrects; upsize-vs-duplicate-admission is always rejected with no counter drift (fails 3/3 under the old remove+push); concurrent upsize+match stress drains consistently. Perf: update-heavy bench 26.0us vs 25.7us median -- neutral within noise; the win is correctness. Closes #119
82ccfbd to
9edab14
Compare
- resequence_to_tail and the match sweep's ReplaceAtTail now publish the new index key BEFORE removing the old one, so a continuously resident maker is never transiently absent from the ordered index and a concurrent front scan can never report Empty past resting liquidity; the transient two-key window is discarded on selection by the stale-front guard (the stored sequence is already the new one) - pop_entry validates, under the map entry, that the stored sequence equals the popped index key; a demoted maker's stale old key is dropped and the scan retries, so the destructive pop can neither return a just-demoted maker ahead of older makers nor strand its new key - the allocation-free claim on the re-key path is narrowed: the SkipMap insert allocates the new index node Stress + deterministic tests: 200k front scans against a continuous demoter never observe Empty; a demoted maker drains last and exactly once under a racing pop; map/index end empty and consistent.
joaquinbejar
left a comment
There was a problem hiding this comment.
The in place re-sequence closes the map vacancy window, but the existing non linearizable index rekey, sequence blind destructive pop, and stale priority decision findings remain unresolved. GitHub requires this self review to be submitted as COMMENT, but the intended effective verdict is REQUEST_CHANGES.
There was a problem hiding this comment.
The new key first rekey and sequence validated destructive pop resolve the two queue race findings. The quantity update still chooses increase versus decrease from an unlocked pre read, so the remaining FIFO priority Critical is unresolved. GitHub requires this self review to be submitted as COMMENT, but the intended effective verdict is REQUEST_CHANGES.
Summary
The quantity-increase demotion did
remove(id)+push(new): during that gapa concurrent cancel was lost while the update resurrected the maker, a
concurrent same-id admission could be blindly overwritten (the P1 flagged in
#125's concurrency audit), and the transient absence was the second
side-topology race window documented in #126. Demotion is now an atomic
in-place re-sequencing — the id never leaves the map.
Stack
stack/6-120-topology-invariantsmainis cumulative; review against the base branch.Changes
OrderQueue::resequence_to_tail: under one continuous hold of the DashMapentry lock — mint tail seq, swap the stored
(seq, order)pair in place,re-key the index. Allocation-free beyond the caller's
Arc.update_order's increase branch uses it; counter deltas keyed off thereturned replaced order exactly as before. Demotion semantics unchanged
(fresh tail seq, original timestamp — the Preserve queue-priority demotion across snapshot restore #109/Preserve queue-priority demotion across snapshot restore (#109) #110 tests still pass).
match_frontstale-seq guard: if the stored seq no longer equals theindex-selected seq (concurrent demotion in the gap), drop the stale key and
retry — sequences are monotonic and never reused, so the removal is provably
safe. A demoted maker can no longer be consumed via its stale front position.
OrderQueue::push(blind overwrite) retired to#[cfg(test)]— no production caller remains. Migration guide + READMEupdated; the now-closed price-level: Reject duplicate order IDs atomically #113/price-level: Enforce level admission and trade topology invariants #120 doc caveats retired.
Technical decisions
concurrency-auditorverdict: lock-free safe, commit safe, 0 P1/P2 —walked reseq-vs-cancel, reseq-vs-match, reseq-vs-reseq, and
reseq-vs-snapshot interleavings; the cancel-removes-old-key orphan is
unreachable because the map swap and both index ops happen under one held
entry lock. Two doc-precision notes applied (the index re-key still has a
transient two-op window — a missed fill at worst, same as
ReplaceAtTailhas always had and strictly narrower than the old remove+push map absence;
and the guard-retry liveness caveat).
(duplicate admission through the upsize window — now always rejected), the
Enforce level admission and trade topology invariants (#120) #126 second topology window, and the stale-front FIFO hazard.
Performance
Update-heavy bench (
Update Orders/update_quantity, 50 upsizes/iter):26.02 µs [25.88, 26.18] new vs 25.72 µs [25.65, 25.78] old — neutral within
noise (~1%); the queue op is a small fraction of the full
update_orderpath.The change is a correctness fix, not a throughput claim.
Public API impact
OrderQueue::pushremoved from the public surface (breaking) — migrationguide + README regenerated.
resequence_to_tailispub(crate).Testing
cancel, no resurrection, counters == queue), upsize-vs-duplicate-admission
(always
DuplicateOrderId, no drift — fails 3/3 under the oldremove+push), concurrent upsize+match stress (consistent drain, no
phantom consumption)
484 tests passed; 0 failed (465 lib + 9 + 1 + 9 doctests).
Checklist
rules/global_rules.mdandCLAUDE.md.unwrap()/.expect()/ unchecked indexing in production codeSeqCstorderings justified (next_seqRelaxed: onlyuniqueness/monotonicity ride on it; happens-before carried by the entry lock)
unsafe, no new dependenciesCloses #119