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.

  1. Missing release in SPSC push (line 29, critical). The relaxed store to head_ does not order the earlier write to buf_[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. Store head_ with memory_order_release, as the tail store already does.
  2. Index pointers into a vector dangle (lines 58–60, critical). &level.back() points into a std::vector. A later push_back that reallocates frees the old storage, and every erase in match() or remove() shifts later orders down, so index_ 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::list with stored iterators, or an order pool with intrusive per-level lists.
  3. Erased map iterator is incremented (line 96, critical). side.erase(lvl) invalidates lvl, then the loop runs ++lvl on it. This is undefined behaviour every time an aggressive order clears a level, which is routine. Write lvl = side.erase(lvl) when the level empties and increment only otherwise.
  4. Top of book read without synchronisation (lines 74–76, major). std::map is 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 in std::atomic<int64_t> values after each change, or take the lock.
  5. Fill callback runs under the lock (line 86, major). on_fill_ is called with mu_ held and while iterating the level. A strategy that cancels or re-quotes calls cancel() or add() on the same thread and locks a non-recursive std::mutex again: 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.
  6. Front erase is linear (line 91, minor). Fills always consume the front of the level, and vector::erase at the front shifts every remaining order. Sweeping a level of thousands of orders is quadratic and causes latency spikes. Use a std::deque or an intrusive list.
  7. Head and tail share a cache line (lines 41–42, minor). head_ and tail_ 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.
Next Post Previous Post
No Comment
Add Comment
comment url