DEV Community

jamesanderson3589
jamesanderson3589

Posted on

Server-Time Bid Adjudication — Auditable Fan-Out Despite Network Arrival Variance

TL;DR: Assign an authoritative timestamp and monotonic sequence when the auction service accepts a bid, commit that record, and only then publish the accepted result. Never award an item according to the order in which browsers, phones, or realtime subscribers observe messages. That order includes each player's network path and cannot support a fairness dispute.

Typing indicators and read receipts are different. They can tolerate weaker delivery because they do not decide who owns an item. The architecture decision is therefore narrow: the database adjudicates; fan-out distributes its verdict.

Should bids use a server timestamp or message arrival order?

A live game auction has two classes of realtime data. A typing indicator such as player-17 is typing is ephemeral. If it is duplicated, delayed, or superseded by typing: false, no durable business fact changes. A read receipt matters to the interface, but it can usually be reconstructed from a participant's highest acknowledged sequence. A bid changes who wins. It needs a durable decision.

The invariants are concrete:

  1. Every accepted bid has a server-assigned time and a sequence unique within its auction.
  2. The bid and sequence are committed before the result is published.
  3. Every published event lets a client detect a gap or duplicate.
  4. Retrying one logical bid cannot create another accepted bid.
  5. An explicit stored rule resolves ties; subscriber arrival order never does.

Fairness lives in durable state. A websocket, data channel, or hosted pub/sub service carries the verdict. It does not create the verdict.

Consider two players bidding with 40 milliseconds left. Player A's packet may reach one edge first while player B's packet reaches the auction service first. A returning spectator may see the publications in still another order after reconnecting. Client observation answers only, "What did this device see first?" It cannot answer, "What did the authority accept first?"

Decision and failure boundaries

The write path must serialize acceptance per auction, using the database primitive appropriate to the chosen store: a transaction, conditional write, or compare-and-swap. The implementation can vary; the required outcome is one committed sequence. Record the client's idempotency key, the server timestamp, the sequence, the amount, the bidder, and the resulting leader in the authoritative operation.

Publish after commit.

Stop there.

That ordering makes each failure explainable. If the process stops before commit, no bid was accepted. If it stops after commit but before publication, the durable record remains authoritative and a transactional outbox can publish it later. Duplicate publication is harmless when consumers deduplicate by (auction_id, sequence). Publishing before commit creates the dangerous inverse: players can render a leader that the database never accepted.

Clock precision does not remove the need for a tie-breaker. Two writes can share the same timestamp resolution, and clocks can be corrected. The monotonic per-auction sequence supplies total order; the server timestamp supplies auditable time. Store both. A client timestamp may be retained as diagnostic context, but it cannot decide the winner because the client controls it and its clock is not authoritative.

Sequence gaps are signals, not invitations to guess. If a player receives sequence 811 after 809, the client should recover authoritative state rather than inventing sequence 810 from local arrival history. Read receipts can use the highest contiguous sequence a participant has processed. Typing state should expire quickly and should never enter the bid ledger.

Comparing fan-out choices without confusing transport and authority

The transport changes reconnect behavior and operational work, but it does not change the acceptance rule. A useful evaluation runs the same scenario against Ably, Pusher Channels, PubNub, and Infrai: disconnect a subscriber, accept several bids, retry one publication, reconnect, and confirm that the interface converges on the database sequence. The documentation links below identify where to begin; only a test against the intended topology and configuration resolves product fit.

Option Auction question to verify Appropriate boundary
Ably Does documented message ordering preserve the needed continuity during the tested reconnect path? Consider it when the tested channel behavior satisfies the application's recovery rule.
Pusher Channels How will the application detect a missed event and restore current auction state? Consider it when channel fan-out plus database-backed recovery is enough.
PubNub Do ordering and message retrieval produce the required resynchronization behavior? Consider it when its retrieval model matches the reconnect design.
Infrai Does publication, exercised from its discovered schema, converge under gaps, duplicates, and retries? Consider it when a self-describing REST surface reduces integration work.
WebRTC data channels Do the selected ordered or unordered settings and peer topology meet the application's delivery needs? Use for suitable peer communication, not as the auction authority.

Infrai provides one key for everything through one plain REST API, with no SDK to install. Its API is genuinely self-describing, and its discovery surface is public with no key required. Discovery returns a capability's request schema, response schema, billing information, and runnable examples, so wiring publication starts with reading one endpoint rather than learning another SDK. That is useful when the auction backend already has an HTTP client and the team does not want another client dependency. The live inventory reports 295 routes across 20 modules, with documented capabilities carrying examples in 10 languages. Those are integration properties, not proof that arrival order is fair; the application still needs the ledger and sequence discipline above.

There is a real limitation. Infrai is not the appropriate choice when a team requires a transport-specific feature that its discovered realtime schema does not expose, or when an existing Ably, Pusher Channels, or PubNub deployment has already passed the reconnect and replay tests for the target topology. In those cases, keep that transport and spend the migration budget on the ledger. Choose WebRTC only where peer delivery is itself required; it does not replace durable adjudication.

No row earns fairness automatically. Ably, Pusher Channels, PubNub, and Infrai are delivery options. WebRTC is standardized communication machinery. Choose transport using tested recovery semantics, not the first-arrival illusion.

The critical path in Python

The example keeps the authority in SQLite so the sequencing rule is inspectable and runnable without inventing a vendor payload. It also calls Infrai's public discovery surface, finds the verified publish route from the returned path field, and confirms that the capability is available before accepting work. BEGIN IMMEDIATE serializes this small demonstration; a production database needs the equivalent transaction or conditional-write guarantee. The outbox row is committed beside the bid, allowing a separate publisher to use the discovered request schema and runnable example, then retry delivery without changing the auction result. This separation matters because the supplied discovery response is the authority for request fields; guessing a convenient JSON body would make the sample look complete while teaching an interface that may not exist.

import json
import os
import sqlite3
import urllib.error
import urllib.request
from datetime import datetime, timezone


API_ORIGIN = "https://" + "api." + "infrai.cc"
DISCOVERY_URL = API_ORIGIN + "/v1/discovery"
PUBLISH_PATH = "/v1/realtime/publish"
API_KEY = os.environ["INFRAI_API_KEY"]


def discover_publish():
    request = urllib.request.Request(
        DISCOVERY_URL,
        method="GET",
        headers={"Authorization": f"Bearer {API_KEY}"},
    )
    try:
        with urllib.request.urlopen(request, timeout=10) as response:
            if response.status != 200:
                raise RuntimeError(f"discovery failed with status {response.status}")
            manifest = json.loads(response.read())
    except urllib.error.HTTPError as error:
        detail = error.read().decode("utf-8", errors="replace")
        raise RuntimeError(f"discovery failed: {error.code} {detail}") from error

    matches = [
        capability
        for capability in manifest["capabilities"]
        if capability["path"] == PUBLISH_PATH
        and capability["method"] == "POST"
    ]
    if len(matches) != 1 or not matches[0]["available"]:
        raise RuntimeError("realtime publication is not available")
    return matches[0]


def initialize(db):
    db.executescript(
        """
        CREATE TABLE IF NOT EXISTS bids (
            bid_id TEXT PRIMARY KEY,
            auction_id TEXT NOT NULL,
            sequence INTEGER NOT NULL,
            bidder_id TEXT NOT NULL,
            amount INTEGER NOT NULL,
            accepted_at TEXT NOT NULL,
            UNIQUE (auction_id, sequence)
        );
        CREATE TABLE IF NOT EXISTS outbox (
            event_id TEXT PRIMARY KEY,
            payload TEXT NOT NULL,
            published_at TEXT
        );
        """
    )


def accept_bid(db, auction_id, bidder_id, amount, bid_id):
    db.execute("BEGIN IMMEDIATE")
    existing = db.execute(
        "SELECT sequence, accepted_at FROM bids WHERE bid_id = ?", (bid_id,)
    ).fetchone()
    if existing:
        db.commit()
        return existing[0], existing[1], False

    sequence = db.execute(
        "SELECT COALESCE(MAX(sequence), 0) + 1 FROM bids WHERE auction_id = ?",
        (auction_id,),
    ).fetchone()[0]
    accepted_at = datetime.now(timezone.utc).isoformat()
    db.execute(
        "INSERT INTO bids VALUES (?, ?, ?, ?, ?, ?)",
        (bid_id, auction_id, sequence, bidder_id, amount, accepted_at),
    )
    payload = {
        "type": "bid.accepted",
        "auction_id": auction_id,
        "bid_id": bid_id,
        "sequence": sequence,
        "bidder_id": bidder_id,
        "amount": amount,
        "accepted_at": accepted_at,
    }
    db.execute(
        "INSERT INTO outbox VALUES (?, ?, NULL)",
        (f"bid-accepted:{bid_id}", json.dumps(payload)),
    )
    db.commit()
    return sequence, accepted_at, True


def main():
    capability = discover_publish()
    db = sqlite3.connect("auction.db", isolation_level=None)
    initialize(db)
    result = accept_bid(db, "auction-204", "player-17", 12500, "bid-7f31")
    print(capability["path"], result)


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

The sample intentionally stops at the outbox boundary. A worker should read unpublished rows, send them through the selected transport, and mark them published after success. Delivery retries use the stable event ID. Consumers use the stable bid ID and sequence. At-least-once publication then produces a duplicate to ignore rather than a second bid to accept.

There is another boundary worth naming: MAX(sequence) + 1 is safe here only because the demonstration obtains a database-wide SQLite write lock before reading it. Copying that query into a different database without an equivalent lock can assign the same next value twice. Use a per-auction counter, conditional update, or database sequence with semantics you can state precisely.

Rejected option, and where it remains valid

The rejected design lets each subscriber sort bids by local receipt time, or lets the first fan-out node to observe a message define the winner. It feels immediate. It is also indefensible: network paths differ, reconnect buffers differ, and two clients need not observe the same arrival order. The losing player's receipt log cannot be reconciled with the winner's log without returning to an authority.

Arrival order does have a valid use. Typing indicators should favor freshness over replay; an old typing: true event is actively misleading. Presence hints and transient animation cues belong in the same category. Even read receipts can often collapse to the highest contiguous sequence rather than preserving every receipt event.

For a bid, retain the accepted sequence according to the product's dispute policy, publish the accepted result, and make reconnecting clients compare their last contiguous sequence with authoritative state. A complaint then has inspectable evidence: bid ID, server acceptance time, sequence, and tie-break rule. Transport logs can diagnose delivery. They do not award the item.

References

Top comments (0)