Review: Order Book and Matching Engine Written by an AI Assistant
Your trading systems team asked an AI coding assistant to rewrite the matching core of an internal crossing engine. It opened this pull request. You are the reviewer, and nothing merges without your approval.
The assistant's description:
Price-time priority order book with O(1) cancel through an id index, a lock-free SPSC queue between the feed handler and the matcher, and lock-free top-of-book reads for market data. All book mutations are guarded by a single mutex.
Where it runs: One instrument per book. A feed-handler thread decodes orders into the SpscQueue; the matching thread pops them and calls add() and cancel(); a market-data thread calls best_bid() and best_ask() every 100 µs. Strategies receive fills through on_fill and often cancel or re-quote from inside the callback. Busy price levels hold thousands of resting orders. Built with -O3 for x86-64 production servers and ARM64 simulation hosts.
Your task
- Flag every problem you would block the merge for. Click a line number to comment, and Shift-click to cover a range.
- For each one, choose the issue type and severity, and explain what fails and how to fix it.
- You are scored on the problems you catch, the right issue type and a clear explanation. Missing a critical problem fails the round.
Aim for 25 minutes, as in a real review round.
In SpscQueue::push(), what guarantees the consumer sees buf_[h] once it sees the new head_?
index_ stores raw pointers into a std::vector. What happens to them on push_back and on erase?
A buy order clears an entire ask level. What does ++lvl act on after side.erase(lvl)?
best_bid() runs on another thread with no lock. What is it walking while the matcher inserts a new level?
A strategy cancels its resting quote from inside on_fill. Which lock does cancel() need, and who holds it?
The structure looks textbook, but three bugs are undefined behaviour that corrupt or crash the engine in normal trading, and two more appear as soon as the market-data thread and strategy callbacks are connected.
- Missing release in SPSC push (line 29, critical). The relaxed store to
head_does not order the earlier write tobuf_[h], so the consumer's acquire load has nothing to pair with. On ARM64, or after compiler reordering on x86, the matcher can pop a slot before the order is written and trade on a stale or half-written order. ThreadSanitizer reports the race. Storehead_withmemory_order_release, as the tail store already does. - Index pointers into a vector dangle (lines 58–60, critical).
&level.back()points into astd::vector. A laterpush_backthat reallocates frees the old storage, and everyeraseinmatch()orremove()shifts later orders down, soindex_entries dangle or point at a different order.cancel()then reads freed memory at line 67 (AddressSanitizer reports heap-use-after-free) or uses the wrong price and side. Use storage with stable addresses:std::listwith stored iterators, or an order pool with intrusive per-level lists. - Erased map iterator is incremented (line 96, critical).
side.erase(lvl)invalidateslvl, then the loop runs++lvlon it. This is undefined behaviour every time an aggressive order clears a level, which is routine. Writelvl = side.erase(lvl)when the level empties and increment only otherwise. - Top of book read without synchronisation (lines 74–76, major).
std::mapis not safe to read while another thread inserts or erases. The market-data thread can follow a node that is being rebalanced or freed, and crash or publish garbage; the comment saying no lock is needed is wrong. Have the matcher publish best bid and ask instd::atomic<int64_t>values after each change, or take the lock. - Fill callback runs under the lock (line 86, major).
on_fill_is called withmu_held and while iterating the level. A strategy that cancels or re-quotes callscancel()oradd()on the same thread and locks a non-recursivestd::mutexagain: deadlock, formally undefined behaviour. A recursive mutex would only swap that for modifying the containers mid-iteration. Collect fills and invoke callbacks after the book is consistent and the lock is released, or queue strategy actions. - Front erase is linear (line 91, minor). Fills always consume the front of the level, and
vector::eraseat the front shifts every remaining order. Sweeping a level of thousands of orders is quadratic and causes latency spikes. Use astd::dequeor an intrusive list. - Head and tail share a cache line (lines 41–42, minor).
head_andtail_sit next to each other, so the producer and consumer threads bounce the same cache line on every operation. Align each to 64 bytes (alignas(64)) and keep a cached copy of the other index on each side.