How it works
The rules
- Bids are accepted only while the auction is live and server time is before ends_at. The current leader cannot bid again.
- Amounts are integer cents. The first bid must meet the starting price; later bids must meet the current price plus its increment.
- The maximum bid is the minimum next bid plus 100 increments, capped at $1,000,000.00.
- Soft close: a bid in the last 10 seconds moves the close to 30 seconds from now, up to 20 times. After the cap, bids are still accepted before the deadline but do not extend it.
- The winner has 180 seconds to pay. Payment is simulated; no money moves. A missed payment records one strike but never blocks bidding.
- If the winner does not pay, the runner-up gets an offer at their own highest bid and a fresh 180-second window. Without a runner-up, the lot is unsold. If the runner-up does not pay, the lot is unsold with no strike.
| Current price from (inclusive) | Minimum increment |
|---|---|
| $0.00 | $0.50 |
| $10.00 | $1.00 |
| $50.00 | $2.00 |
| $100.00 | $5.00 |
| $250.00 | $10.00 |
| $500.00 | $25.00 |
| $1,000.00 | $50.00 |
| $2,500.00 | $100.00 |
| $5,000.00 | $250.00 |
Auction lifecycle
| ID | From (status / payment_state) | To | Trigger | Who/what applies it | Side effects |
|---|---|---|---|---|---|
| T0a | none | scheduled / null | create with starts_at > now | ensure_public_auction(), create_sandbox(), test endpoint | event auction_created |
| T0b | none | live / null | create with starts_at <= now | same | event auction_created |
| T1 | scheduled | live | now >= starts_at | advance_auction_locked() from place_bid, auction_state, pay_offer, tick() | version+1, event auction_started |
| T2 | live | live | accepted bid | place_bid | price, leader, bid_count, version+1; if in snipe window and under cap: ends_at = now + extend_by_s, extension row; events bid_accepted, auction_extended |
| T3 | live | unsold / null | now >= ends_at and bid_count = 0 | advance_auction_locked() (lazy) or tick() | closed_at = ends_at, version+1, event auction_unsold |
| T4 | live | closed / awaiting_payment | now >= ends_at and bid_count > 0 | same | closed_at = ends_at, offer rank 1 to leader at current_price, deadline now + payment_window_s, events auction_closed, offer_opened |
| T5 | closed / awaiting_payment | settled / paid | winner calls pay before deadline | pay_offer | offer paid, settled_at = now, event offer_paid |
| T6 | closed / awaiting_payment | closed / offered_to_runner_up | now >= deadline, runner-up exists | advance_auction_locked() (lazy) or tick() | offer 1 expired, strike to winner, offer rank 2 to runner-up at their highest bid, deadline now + payment_window_s, events offer_expired, strike_recorded, offer_opened |
| T7 | closed / awaiting_payment | unsold / expired | now >= deadline, no runner-up | same | offer 1 expired, strike to winner, events offer_expired, strike_recorded, auction_unsold |
| T8 | closed / offered_to_runner_up | settled / paid | runner-up calls pay before deadline | pay_offer | offer 2 paid, settled_at = now, event offer_paid |
| T9 | closed / offered_to_runner_up | unsold / expired | now >= deadline | advance_auction_locked() or tick() | offer 2 expired, no strike, events offer_expired, auction_unsold |
Why two bids can't both win
Bid A $48.00
lock row
check price
insert bid
commit
Bid B $48.00
waits…
lock · price is $48.00
BID_TOO_LOW
Every bid calls one PostgreSQL function, place_bid, in its own transaction. The first thing it does is lock the auction row with SELECT … FOR UPDATE. A second bid on the same auction waits at that line until the first one commits.
Only after it holds the lock does the function read the clock and the current price. So every bid is checked against the latest committed state, and the time check uses the moment the bid actually got its turn.
Read Committed isolation is enough here because each statement after the lock sees the newest committed data, and the lock makes the check and the write one atomic step. The version column is a second guard: the update only succeeds if the version is still the one the function read.
Retries are safe because each request carries an idempotency key. The key and the original result are stored in the same transaction as the bid, so a retry returns the stored result.
Closing happens in the same function: a bid that arrives after the deadline closes the auction and is rejected. A scheduled job closes auctions nobody touches.
select * into v_auction from bidlock.auctions
where id = p_auction_id for update;
v_now := bidlock.bidlock_now();
select * into v_prev from bidlock.bid_requests r
where r.bidder_id = p_bidder_id and r.idempotency_key = p_idempotency_key;
-- Existing key: return stored result, or IDEMPOTENCY_MISMATCH.
v_auction := bidlock.advance_auction_locked(v_auction, v_now);
v_min := bidlock.min_next_bid(v_auction.starting_price, v_auction.current_price);
v_max := bidlock.max_bid(v_auction.starting_price, v_auction.current_price);
-- Validate status, deadline, leader, minimum and maximum; store rejections.
-- Apply soft close and store the accepted request/result in this transaction.
insert into bidlock.bids (...) values (...);
update bidlock.auctions set current_price = p_amount,
leader_bidder_id = p_bidder_id, version = version + 1, ...
where id = p_auction_id and version = v_auction.version;Benchmark
The p95 latency target was not met: 269.23 ms against a 250 ms target. p99 was 329.69 ms against a 600 ms target. Unexpected errors: 0. Invariant violations: 0.
| Date | 2026-11-07 |
|---|---|
| Commit | 8b957bce45d85cca6bfdd04b774657092f04b87d |
| Runner | Linux · AMD EPYC 7763 64-Core Processor |
| CPUs | 4 |
| Memory (GB) | 15.61 |
| VUs | 500 |
| Duration (s) | 60 |
| Requests | 28,925 |
| Accepted bids | 245 |
| BID_TOO_LOW | 28,180 |
| ALREADY_LEADING | 0 |
| AUCTION_CLOSED | 500 |
| RATE_LIMITED | 0 |
| Unexpected errors | 0 |
| Unexpected error rate | 0 |
| Unexpected error rate target | 0.01 |
| p50 latency (ms) | 124.51 |
| p95 latency (ms) | 269.23 |
| p95 target (ms) | 250 |
| p99 latency (ms) | 329.69 |
| p99 target (ms) | 600 |
| Throughput (req/s) | 463.75 |
| Extension count reached | 20 |
| Extension cap | 20 |
| Threshold outcome | Thresholds not met |
| Invariant | Check | Violations | Result |
|---|---|---|---|
| INV-01 | leader is the bidder of the highest bid | 0 | Pass |
| INV-02 | current_price equals max accepted amount | 0 | Pass |
| INV-03a | bid_no is 1..n without gaps | 0 | Pass |
| INV-03b | each bid meets start or previous + increment | 0 | Pass |
| INV-04 | no consecutive bids by the same bidder | 0 | Pass |
| INV-05 | every bid placed before ends_at at that moment | 0 | Pass |
| INV-06 | no bid at or after the logical close | 0 | Pass |
| INV-07a | one request row per (bidder, key) | 0 | Pass |
| INV-07b | every bid has exactly one accepted request | 0 | Pass |
| INV-07c | every accepted request has a bid | 0 | Pass |
| INV-08a | extension_count within cap and equal to extension rows | 0 | Pass |
| INV-08b | extensions move ends_at forward | 0 | Pass |
| INV-10 | at most one open and one paid offer; settled iff one paid offer | 0 | Pass |
| INV-11 | runner-up offer goes to another bidder at their own highest bid | 0 | Pass |
| INV-12 | bid_count equals accepted bids | 0 | Pass |
| INV-13 | valid status / payment_state pair | 0 | Pass |
This test runs weekly on GitHub Actions against a production build in Docker Compose. It never runs against this site.
All runsRight now
Bids today: 16 · Active sandboxes: 0 · Public lots today: 7
Updated 0 s ago.
Run it yourself
pnpm install
pnpm db:up
pnpm test:all
pnpm load:local