Bidlock
Loading your profile

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

IDFrom (status / payment_state)ToTriggerWho/what applies itSide effects
T0anonescheduled / nullcreate with starts_at > nowensure_public_auction(), create_sandbox(), test endpointevent auction_created
T0bnonelive / nullcreate with starts_at <= nowsameevent auction_created
T1scheduledlivenow >= starts_atadvance_auction_locked() from place_bid, auction_state, pay_offer, tick()version+1, event auction_started
T2liveliveaccepted bidplace_bidprice, 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
T3liveunsold / nullnow >= ends_at and bid_count = 0advance_auction_locked() (lazy) or tick()closed_at = ends_at, version+1, event auction_unsold
T4liveclosed / awaiting_paymentnow >= ends_at and bid_count > 0sameclosed_at = ends_at, offer rank 1 to leader at current_price, deadline now + payment_window_s, events auction_closed, offer_opened
T5closed / awaiting_paymentsettled / paidwinner calls pay before deadlinepay_offeroffer paid, settled_at = now, event offer_paid
T6closed / awaiting_paymentclosed / offered_to_runner_upnow >= deadline, runner-up existsadvance_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
T7closed / awaiting_paymentunsold / expirednow >= deadline, no runner-upsameoffer 1 expired, strike to winner, events offer_expired, strike_recorded, auction_unsold
T8closed / offered_to_runner_upsettled / paidrunner-up calls pay before deadlinepay_offeroffer 2 paid, settled_at = now, event offer_paid
T9closed / offered_to_runner_upunsold / expirednow >= deadlineadvance_auction_locked() or tick()offer 2 expired, no strike, events offer_expired, auction_unsold

Why two bids can't both win

Two bids, one auction row

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.

place_bid · abridged core
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.

Date2026-11-07
Commit8b957bce45d85cca6bfdd04b774657092f04b87d
RunnerLinux · AMD EPYC 7763 64-Core Processor
CPUs4
Memory (GB)15.61
VUs500
Duration (s)60
Requests28,925
Accepted bids245
BID_TOO_LOW28,180
ALREADY_LEADING0
AUCTION_CLOSED500
RATE_LIMITED0
Unexpected errors0
Unexpected error rate0
Unexpected error rate target0.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 reached20
Extension cap20
Threshold outcomeThresholds not met
InvariantCheckViolationsResult
INV-01leader is the bidder of the highest bid0Pass
INV-02current_price equals max accepted amount0Pass
INV-03abid_no is 1..n without gaps0Pass
INV-03beach bid meets start or previous + increment0Pass
INV-04no consecutive bids by the same bidder0Pass
INV-05every bid placed before ends_at at that moment0Pass
INV-06no bid at or after the logical close0Pass
INV-07aone request row per (bidder, key)0Pass
INV-07bevery bid has exactly one accepted request0Pass
INV-07cevery accepted request has a bid0Pass
INV-08aextension_count within cap and equal to extension rows0Pass
INV-08bextensions move ends_at forward0Pass
INV-10at most one open and one paid offer; settled iff one paid offer0Pass
INV-11runner-up offer goes to another bidder at their own highest bid0Pass
INV-12bid_count equals accepted bids0Pass
INV-13valid status / payment_state pair0Pass

This test runs weekly on GitHub Actions against a production build in Docker Compose. It never runs against this site.

All runs

Right 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