Skip to content

Blockfrost: replace blind sleep with condition-based polling #2753

Description

@v0d1ch

The Blockfrost chain backend is extremely slow — not because of the network or the API, but
because it models freshness with fixed threadDelays and processes blocks at a pace that
cannot exceed chain speed. A full head lifecycle (publish → seed → init → close → fanout) on an
idle preview network takes well over an hour, which is why the only Blockfrost e2e test is
parked as pendingWith "… very slow" (BlockfrostChainSpec.hs#L68)
— a coverage gap that let #2751 ship unnoticed.

Measurements (preview, 2026-07-07, network idle)

stage wall-clock gap
publish νHead → publish νCRS (2 txs) ~10 min
publish done → first faucet seeding tx ~30 min
tx submission → on-chain inclusion (for comparison) ~2 s (next block)

Network congestion is ruled out: the six most recent preview blocks at the time carried 0–2
transactions each
(4 B – 3 KB used of a ~90 KB limit), and our own transactions were included
in the next block after submission.

The chain follower also falls behind and cannot recover: in one run it needed 3m47s of
wall-clock time to advance 50 seconds of chain, ending up minutes behind tip — which then made a
30s observation timeout in the e2e spec fire even though the transaction was long since on-chain.

Root causes

  1. Blind 30s sleep before every address-UTxO query
    Client.hs#L460-L463:

    -- NOTE: We can't know at the time of doing a query if the information on specific
    -- address UTxO is _fresh_ or not so we try to wait for sufficient period of time
    -- and hope for best.
    liftIO $ threadDelay $ fromIntegral queryTimeout

    Every wallet/seeding/publishing step pays this, serially, and it is still not safe —
    a fixed sleep is usually too long and occasionally too short.

  2. The follower advances at most one block per poll interval
    Blockfrost.hs#L277-L291:
    pollForNewBlocks sleeps a full block time (~20s), then processes exactly one block.
    Best case it moves at chain speed, so any hiccup becomes permanent lag (catch-up only
    happens once, at startup).

  3. Two+ HTTP round-trips per block, even for empty blocks
    Blockfrost.hs#L330-L353:
    rollForward fetches the block header and then always pages through
    getBlockTxsCBOR' — although the header response already contains tx_count
    (currently unused) and most testnet blocks are empty.

  4. Submission errors are reported after sleeping a full block time
    Blockfrost.hs#L386-L393.

  5. Rate limiting is handled preemptively instead of reactively. Blockfrost allows ~10 req/s
    with a burst allowance; 1s condition-polling is nowhere near it. A 429 with Retry-After
    is the signal to back off — sleeping in advance buys nothing.

Design principle for the fix

Never wait a fixed time and hope; poll a verifiable condition with a short interval and a
deadline.

The API provides enough anchors for every wait we currently do blindly:

  • Transaction inclusion is monotone: /txs/{hash} 404s until indexed and then never
    disappears (absent a rollback). Poll that — not an address query preceded by a sleep.
  • Read-your-writes: after submitTx we hold the full transaction, so the UTxO it creates is
    known locally. Only the inclusion signal needs the API.
  • Height anchoring: when a query must reflect some block B we already observed, poll the
    cheap /blocks/latest until height ≥ B, then query. Address-UTxO responses also carry their
    block anchor, so staleness is detectable instead of assumed away.
  • Snapshot consistency: for multi-request reads, check /blocks/latest before and after and
    re-read if the tip changed.

Proposed changes

  • Remove the unconditional threadDelay queryTimeout from queryUTxO; replace call sites
    that used it as "wait for my tx to be visible" with /txs/{hash} presence-polling
    (1s interval, deadline), and derive the resulting UTxO locally from the submitted tx.
  • Reinterpret queryTimeout / retryTimeout as deadlines for condition-polling rather
    than sleep durations (keeps CLI options backward-compatible).
  • Follower: skip getBlockTxsCBOR' when the block header says tx_count == 0.
  • Follower: process blocks in a loop until reaching tip on every poll iteration (bounded
    catch-up), not one block per interval; keep the block-time sleep only for the at-tip case.
  • Submission client: report PostTxError immediately; add reactive backoff on HTTP 429
    honoring Retry-After.
  • Re-enable BlockfrostChainSpec as a smoke test once the lifecycle fits a sane budget,
    with observation timeouts sized to follower behavior rather than 30s.

Expected outcome

Non-goals

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status
    Triage 🏥

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions