Skip to content
All field notes

3 min read

Moving the Timed-Auction Engine from Python to Go

Rewriting an eBay-style auction engine for a concurrency model we could reason about — and the bug that dragged deadlines backwards

  • Go
  • Concurrency
  • Python
  • Auctions
  • Real-Time

Alongside our live video auctions, CattleUSA runs timed auctions — eBay-style listings where lots close at a scheduled time, late bids extend the clock, and chained lots close in sequence. The first version lived inside our long-running Python auction coordinator. This spring we finished moving the whole engine into our Go API service. This post is about why, and about the bugs that only showed up once real bidders leaned on it.

Why leave Python

The Python coordinator is a fine piece of software for what it does: it owns live auction session state and talks to clients over RabbitMQ. But the timed-auction workload is different — it's a scheduler. It wants a monitor loop, per-lot locking, and lots of small concurrent writes. In the Python version, that meant threads sharing a messaging client that was never designed to be thread-safe, and a series of increasingly careful workarounds. We had commits that wrapped publishes in thread-safe callbacks, got reverted for memory pressure, got re-landed, and got reverted again. When you've flipped the same 13 lines three times, the code is telling you the concurrency model is wrong for the job.

Go's model fit: one goroutine per live auction, a sync.Map of per-lot mutexes guarding bid writes, a one-second monitor tick that closes lots past their stop time and resolves the winning buyer (only if the reserve is met), and a garbage collector for lot locks idle more than 30 minutes. The timed-auction engine wasn't even the first thing we'd moved for this reason — the commit that ported the login endpoint to Go a year earlier said the quiet part out loud: with a producer-facing launch coming, "the login could be a bottleneck" on the old runtime.

The bug that dragged deadlines backwards

The most instructive bug came from anti-sniping. Each lot has an overtime threshold and an extension amount: a bid landing inside the final window pushes the scheduled stop time out, so you can't win by sniping the last second.

The first implementation computed the remaining time and extended the deadline by extension - remaining. Correct — until a bid arrived after the stop time (network latency, clock skew, a bid in flight while the monitor closed the lot). Then remaining went negative, and the arithmetic pulled the deadline backwards. Two days after shipping, the fix was a guard: only extend when time actually remains.

The same incident forced a rewrite of chained lots — sequences where lot 2 starts closing when lot 1 closes. The naive cascade didn't push subsequent lots back when an earlier lot extended into overtime. The rewrite sorts by scheduled start and refuses to advance a lot whose predecessor is still open. Auction mechanics are full of these: rules that are trivial to state and full of edge cases the moment real money and real latency are involved.

The test that paid for itself immediately

The engine's data layer is largely generated code, and generated code tends to be tested by nobody. While porting, we made one generated package mockable and wrote a proper test around the buyer-log path — the first real test in the service. It immediately found a slice-index bug: code checking len(parts) > 0 and then reading parts[1], an out-of-range panic on any buyer row missing a state field. The fix was one character. The lesson wasn't the bug; it was that the act of making code testable is what found it.

Two more from the same sprint, both worth pinning to a wall:

  • A silent auth downgrade. The middleware type-asserted three role claims with value, _ := claims["a"].(float64). A malformed token produced three zeros — and three zeros meant "anonymous public request" instead of "reject". Presence check, then type check, then 401 when all identity claims are empty.
  • The classic Go loop-variable alias. Taking &loopVar inside a range meant every element of an API response pointed at the last item. Pre-1.22 Go, a pinned toolchain, and a one-line deref copy.

What the rewrite actually bought

Not raw speed — the Python engine was fast enough. What we bought was a concurrency model where the locking is explicit, the scheduler is a for-loop you can read, and a bid write can't corrupt a neighboring lot because the mutex is right there in the function signature's line of sight. Rewrites earn their cost when the new language makes the hard invariants visible. This one did.