🤖 perf: bound long-thread aux reads and make query deadlines terminal - #7854
Conversation
…atement deadline Long threads made the aux #e read scan the events table and run unbounded. The e-tag filter is now a set-based, MATERIALIZED-fenced lookup (with GIN parity in schema.sql), each statement runs under a transaction-local 20s deadline that preserves any shorter operator cap, and a replica statement cancel (57014) is surfaced instead of being re-run on the writer. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ription teardown A statement-cancelled read now returns 503 query timed out over HTTP and CLOSED error: query timed out over WS (REQ, COUNT, search) instead of a generic error or a misleading EOSE. The per-connection subscription map is the lifecycle lock: REQs claim their ID with an owner token, and claims, timeout CLOSED, revoke, client CLOSE and connection cleanup all mutate the map, registry, topic retention and terminal frame under it, so a superseded or disconnected request can never tear down or close its replacement. The infra-free lifecycle tests are selected by exact name in just test-unit. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Retrying a read the relay cancelled at its statement deadline just replays the same expensive query. Classify the 503 query timed out body and the timeout CLOSED reason as terminal, and stop the thread-reply queries from retrying them. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Make no-retry-on-deadline the QueryClient default so every #e read path inherits it instead of carrying inline predicates, keep deadline-failed IDs claimed in render-scoped reaction hydration, and treat the timeout CLOSED reason as terminal on native and mobile. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Reproducing the long-thread cliff needs an isolated PG16 + relay with millions of background rows, recorded provenance and controlled GIN pending-list state; these recipes seed that stack and replay the desktop thread read at several limits. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Changes requested. Three P2 findings below prevent the promised terminal/bounded-read behavior. Merge criteria: stop mobile provider-level replay of canonical deadline errors, make failed terminal delivery disconnect rather than silently abandon a live subscription, and apply the e-tag statement cap to the fast COUNT path. Add production-owner regressions for those cases.
Reviewed HEAD 22dd186d935f853d287254b46cf4202d2b48c8a7 against BASE d01e5f82058463709a22e93bb4cd795da5f53e10, source-only. Existing Unit, Mobile, Desktop Core and PostgreSQL CI passed on merge 0ae3181a9540e1cc4becbc30cafdaaa06a7a1bde of that exact pair; no tests/builds/SQL were executed or CI rerun. The security-review check was cancelled. Earlier benchmark results do not establish performance for this final head.
Scope notes, not additional blockers: successful stale same-ID output and FTS-stage cancellation behavior are inherited; cross-connection isolation and a total-request deadline are not promised. Old clients need upgrading to consume the new terminal reason.
…ag deadline to COUNT P2-2: close_if_owner and evict_conn_channel_subscriptions now cancel the connection when conn.send / send_to returns false after retirement. A full or closed outbound channel means the CLOSED frame is silently lost; without this the subscription is retired but the client never learns of it. Added cancel_conn helper on ConnectionManager. Client-initiated CLOSE is exempt: the voluntary ack carries no orphan risk. P2-3: count_events_on now routes through fetch_with_e_tag_deadline when e_tags is non-empty, using the same transaction-local 20 s cap as query_events_on. Previously COUNT used an unbounded fetch_one regardless of e-tag presence. Regressions: - dropped_terminal_frame_cancels_connection: infra-free; fills a capacity-1 send buffer so send returns false below grace_limit, then asserts cancel fires. Removing the cancel call turns it red. - count_events_cancels_at_e_tag_deadline_when_timeout_disabled: postgres_tests; locks events, disables session timeout, runs COUNT with e_tags, asserts 57014 cancellation at ~20 s and that the connection is usable afterwards. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
A relay statement deadline (HTTP 503 / WS CLOSED "error: query timed out")
signals that the same query will hit the same limit if retried. Riverpod
3.1.0's defaultRetry retries up to 10 times by default; add a production
ProviderScope retry hook that suppresses retries for deadline errors and
falls back to defaultRetry for everything else.
Two detection surfaces share a single constant so they cannot drift:
- HTTP: RelayException(503) with JSON body {"error":"query timed out"}
- WebSocket: Exception("error: query timed out") stripped of the
Exception: prefix and routed through classifyRelayClosed
The retry policy is extracted as relayProviderRetry in relay_closed_policy.dart.
main.dart passes it directly; tests import and use the same function so
that deleting the deadline guard in relayProviderRetry turns all deadline
provider tests red.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The root retry policy only governed Riverpod error retry. Forum polling, thread reconnect and live-reply invalidation, the overflow recount backoff, and both HTTP-to-WS fallbacks still re-sent a request that had settled on a relay deadline. Each owner now consults the shared deadline predicate; explicit user retry and ordinary-failure recovery are unchanged. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Each automatic owner kept its own deadline flag, so a second owner of the same request could still replay it: the overflow recount re-ran a thread scan the route query had timed out, and reconnect, the 60s backstop and resume re-sent the channel window and the unread/latest-message batches. One registry, scoped to relay and account and keyed by request identity, now holds terminal outcomes that every automatic path consults. Only explicit user actions or a completed query clear them. Batches report an unavailable outcome instead of [], so existing timestamps and unread state survive. The thread page also shows live and sent replies after a first-load deadline without re-querying. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
WS fallbacks now record their deadline under the operation key (a batch becomes unavailable, not empty); batch keys ignore filter order; building the thread query honors a terminal scan and only an explicit reopen or fenced success clears it, via per-key attempt epochs; the route query watches the scoped registry so a relay/account switch reaches it. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
An in-flight attempt re-checks the key before each fallback send, so a peer's deadline stops it. Older pages remember deadlines per cursor so position/layout listeners don't replay them; reopening clears them. The thread-open reset is cancelled on cleanup and checks mounted. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
A fallback parked on the relay rate-limit gate could still send after a peer attempt made its operation terminal. fetchHistory now takes an optional stopWith check evaluated after the gate wait, before registration and REQ; registry-owned callers pass their key's stored deadline. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
select_aux built one tags @> term per target OR-joined with ORDER BY/LIMIT in the same scope, the planner cliff already fixed for query_events. Reuse push_e_tag_filter inside a MATERIALIZED CTE and sort/limit outside it. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The isolated bench stack (compose, 5M filler rows, GIN-backlog control) was for proving the aux fix and will not be maintained; its numbers live in the PR. Keep a small seeder for reproducing long threads on the local dev relay. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Automatic owners no longer replay a query after a relay deadline, so reopening was the only retry. The channel timeline, thread replies, forum thread and forum posts error states now offer an explicit Retry that clears the deadline record and reloads once, matching desktop. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Riverpod keeps the previous error during a refresh, so the forum error branch re-rendered an enabled Retry mid-retry. A refresh from an error now shows loading; data refreshes keep their content. The thread Retry regains a 48x48 target. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Review clear: no remaining blockers in this re-review. Head 115b1a933ddb9280dc55900207772be5518943e1, pinned base 8f6b66f9ff6b2e42cd713506805763269375b473.
All three previously reported blockers are addressed: mobile's production root retry policy suppresses canonical deadlines; failed required CLOSED delivery cancels the connection; and fast e-tag COUNT uses SELECT's transaction-local min(operator timeout, 20 seconds) cap. The changed mobile request owners preserve terminal state across automatic lifecycle triggers and expose explicit retry paths.
Validation was source-only, including independent DB, relay-lifecycle and mobile review plus integration checks. Tests were inspected, not executed; runtime performance and live recovery were not reproduced. This is a COMMENT review, not approval.
Nonblocking follow-up: add cold-start coverage for unavailable latest-message timestamps followed by successful unread catch-up, and an assertion covering the production root retry wiring. The cold-start seed/mark-read behavior is inherited when the previous fallback also failed; it is not a verified new blocker, and automatic fallback must not be restored.
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
No new actionable defects found in this bounded follow-up. Reviewed head 538e73e92d8f8c56ac809de2602a8412778865fc against base 676e8c43825fa478850a3a320b1367d3288cce1a, carrying forward the previous clear review.
- Reviewed the new thread-window aux fence/shared predicate, explicit mobile Retry across channel/thread/forum surfaces, local seeder, and necessary merge integration. Prior terminal-deadline fixes remain intact; the declared materialization tradeoff is unchanged.
- Source-only review with independent mobile review on the pinned Blox host. No PR code, tests, seeder, or benchmark executed. The new Retry widget tests stub production providers; provider binding and optimistic-reply preservation were source-traced, not integration-tested here. Existing PostgreSQL, Mobile and relay-integration CI passed on synthetic merge
9ff975d04006bd701a85f4963d3cde2914776447of this exact head/base. - CI remains a merge gate: desktop smoke shard 4 repeatedly failed
scroll-history.spec.ts:2205(oldest index stayed 550). This mock-bridge test does not exercise the new SQL/mobile paths; no causal regression was established in this follow-up. Resolve or explicitly disposition that failure before merging. This comment is not an approval or a green-CI claim.
The root scope moves into buildRootProviderScope so a test can assert the production retry policy. A deadline-unavailable latest-message batch skips the WS fallback that used to fill lastMessageAt, so unread catch-up now advances it (never backward), as live events do. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ns on reopen Only the relay's `query timed out` answer is terminal; client-side request timeouts are network stalls and keep their retry. Reaction ids held after a relay deadline are released when the channel is reopened, so reactions recover without a restart. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…adline The query-deadline prefix latched archive sync's persistent subscriptions terminal for the socket's life. Their limit:0 backfill is transient, so they take the normal backoff reopen; finite requests still fail on the CLOSED. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The prior test called the release helper directly, so deleting the hook's channelId release effect failed nothing. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The shared timed-out registry guaranteed no automatic path could re-send a timed-out query, at the cost of plumbing through a dozen files. With the 20s ceiling a re-send is bounded, so keep only the root retry policy, the recount backoff and fallback guards, the forum polling pause, and Retry UI. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The deadline CLOSED is terminal so one-shot history is not re-sent, but a live subscription removed on it would silently stop delivering updates. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
* origin/main: feat(relay): enforce NIP-FI assertion+NIP-98 pairing on HTTP ingress (#7264) Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com> # Conflicts: # Justfile # crates/buzz-relay/src/api/bridge.rs # crates/buzz-relay/src/api/mod.rs
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Review clear: no new actionable blockers in this follow-up. HEAD 719935392da73f8861efdd74a86940ad3f6d16ee, BASE 6410e685a80d42db0645fadc9bbe710559ef911e. Carries forward the previous clear review, with fresh review of the six client follow-ups and base-merge integration.
- Finite reads still stop on the canonical relay deadline without immediate provider retry or equivalent fallback. Client-side timeouts remain retryable. Persistent subscriptions instead recover with existing 1–30s backoff; finite ownership is handled first, and cancellation/replacement fences remain intact. Desktop reaction reopen recovery binds the production hook. The base merge preserves NIP-FI/NIP-98 admission, DB-timeout responses, and both test selections.
- Non-blocking contract/documentation follow-up: update the PR body, which still describes the removed mobile registry. Stateless guards permit bounded later refreshes from live events, reconnect/resume and the unread backstop. Live recovery reuses the original filter, not universally a fresh
since: nowfilter; it can retry persistently at the backoff cap. Correct that comment and document the recovery/load tradeoff. Do not blindly advancesinceto now, which could skip missed events. Also retain the existing mobile cached-refresh visibility gap as a follow-up:_initcan returnAsyncData(cached)after failed history sync, hiding the error/Retry. That fallback is inherited from base, not a new defect requiring the removed registry. - Source-only review on the pinned Blox host, including independent renderer, mobile-provider and native/session lanes. Existing CI reports success for this exact head/base, including Mobile, PostgreSQL, relay integration and all desktop smoke shards. The separate Codex Security Review was cancelled. No PR code, tests, builds or benchmarks executed; no CI reruns. Runtime recovery and performance were not independently reproduced. No accidental media/generated artifacts found in the cumulative diff.
This is a COMMENT review, not approval.
The comments described a removed registry and a since:now backfill that recovery never performs. Live subs resend the original filter under backoff; only the forum poll timers pause. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
* origin/main: 🤖 docs(nip-fi): remove implementation references from the spec (#7912) fix(relay): fail startup on invalid operator listener config (#7933) fix(db): limit event transactions to listener mention kinds (#7932) feat(relay): deliver pubkey mentions to relay companions (#7793) docs(protocol): propose simplified channel artifacts (#7791) feat(push): support configurable HTTP(S) delivery URLs (#7877) fix(ci): select runtime suites from PR changes only (#7843) test(desktop): synchronize upload edit smoke test (#7903) Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
* origin/main: Make relay readiness process-local (#7341) Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> # Conflicts: # Justfile # scripts/run-tests.sh
On a dirty GIN index, tags @> ANY made the planner add an expensive per-target GIN scan to the selective channel/kind access. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
* origin/main: Add owner deletion admission control plane (#7818) fix(sidebar): converge stale-at-open state across devices (sections/sort/stars/mutes) (#7805) feat(nip-fi): harden Blossom kind-24242 verifier to NIP-FI spec (#7288) Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
…in-ui * origin/main: test(desktop): wait for channel head refresh before paging thread summary test (#7955) 🤖 perf: bound long-thread aux reads and make query deadlines terminal (#7854) Add native HPKE encryption for nsec backups (#7849) test(desktop): scope video menu e2e probes to emitted messages (#7953) Add owner deletion admission control plane (#7818) fix(sidebar): converge stale-at-open state across devices (sections/sort/stars/mutes) (#7805) feat(nip-fi): harden Blossom kind-24242 verifier to NIP-FI spec (#7288) Make relay readiness process-local (#7341) 🤖 docs(nip-fi): remove implementation references from the spec (#7912) Signed-off-by: Sol <49aa1f65411fd096d2e2ec144f1e7aa36fdc76d1b907cfdf7be000c66f9d3b8e@buzz.block.builderlab.xyz>
The event query shape test from #7854 asserts the e-tag query contains no ` OR `. Rewrite `kind <> 45010 OR EXISTS (...)` as the equivalent `NOT (kind = 45010 AND NOT EXISTS (...))`. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: murderbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
Long threads (over ~100 replies) time out in desktop. The aux hop that loads reactions and other e-tag references builds its filter as one
tags @> …term per id, joined withOR. Past about 100 ids the planner switches to walking each partition's primary key backward, expecting to stop early. When few rows actually match, it scans the whole community. With a dirty GIN pending list, a 188-reply thread never finishes.What changes
buzz-db). The aux e-tag query now puts the id set behind aMATERIALIZEDCTE and matches withANY. Each e-tag read also runs under a transaction-localstatement_timeoutof 20 s.buzz-relay). When a read hits the deadline, the client gets a terminal timeout (CLOSEDfor REQ, the timeout error for COUNT and search), not a retryable failure.buzz-relay). The per-connection subscription map lock is now the lifecycle lock. Claims, replacement, clientCLOSE, owner-checked timeout and search cleanup, revoke, and connection teardown all change the map, the registry, topic refcounts and terminal frames while holding it. As a result, a timed-out subscription'sCLOSEDgoes out before any replacement with the same id can claim it, and claims made after teardown are refused.buzz-relay). If theCLOSEDfor a retired subscription (owner-checked timeout or channel eviction) can't be queued, the relay cancels the connection instead of leaving the client holding a subscription that will never receive fan-out. A client-initiatedCLOSEack is exempt.buzz-db). The fast COUNT path runs e-tag counts under the same transaction-local 20 s cap as SELECT, so WS COUNT and HTTP/countproduce the terminal timeout too.buzz-db). When a replica-routed read is cancelled bystatement_timeout(SQLSTATE57014), the error is returned instead of re-running the query on the writer, which would run the same slow statement. Other replica errors still fall back to the writer.PLANS/REPLICA_FULL_READ_ROUTING_DESIGN.mdrecords the exception.query timed outcounts as a query deadline. A client-siderequest timed outstays retryable. For one-shot reads a deadline is terminal: the global query retry (budget 1), thread-replies retry and reaction hydration don't re-send the same slow query. Reaction ids held back by a deadline are released when the channel is opened again, so reopening retries them while same-channel re-renders don't. Live subscriptions, in both the renderer and the native archive-sync socket, keep treating a deadlineCLOSEDas transient: they back off and resubscribe with the same filter instead of being dropped.503 {"error":"query timed out"}and the WSCLOSEDreason.ProviderScoperetry policy returns no retry for deadline errors and keeps Riverpod's default backoff for everything else.lastMessageAtforward (never backward) the same way live events do, so a cold start with no known timestamp still gets one.CLOSEDbacks off and resubscribes with the same filter. History requests still fail on it.LoadErrorViewon the channel timeline, forum thread and forum posts list, and an icon button beside the thread replies summary. While the thread query has no value, the thread page shows the route snapshot plus live, cached and optimistic replies, with the loading or error status still visible.just seed-long-thread [replies]posts one thread (default 187 replies) with some reactions to the local dev relay, for reproducing long threads in the apps. It uses a throwaway key, refuses non-loopback relays and ignores ambientBUZZ_*credentials. Writes are paced to fit the stock relay rate limit.Lifecycle concurrency. The lock serializes one connection's subscription changes. Other connections can still be delayed by pressure on the shared pubsub command queue, and by serial eviction loops waiting behind a blocked connection. This PR does not claim strict isolation between connections.
Recovery vs. load. Clients stop automatic retries of the same one-shot read, but they don't remember deadlines across other triggers: reconnect, resume, live events and the unread backstop can each send the read again later, bounded by the 20 s statement deadline. A live subscription that hits the deadline resubscribes with its original filter under the existing 1–30 s backoff, and against a filter that keeps timing out it retries at the 30 s cap indefinitely, so the worst case is one bounded query per subscription every 30 s.
sinceis deliberately not advanced to now on resubscribe, because that would skip events missed while the subscription was closed. Unchanged frommain: when mobile's channel history sync fails with cached messages available, the cached messages are shown without the error or Retry control.Benchmark
Local reproduction on an isolated bench stack (not committed):
include_auxon. Times are total ms for 3 runs, with a 60 s client cap.GIN pending-list state is controlled with
VACUUM(clean) or by refilling the pending list (dirty), and recorded per partition withpgstatginindex. Dirty runs held 402 pending pages on every partition's tags index at the start and end of each matrix. Clean runs held 0–1 pages (relay startup writes).mainmainBaseline
origin/mainb37e47721; this PR at5f2650f5f, whose e-tag read builders are identical to the current head. At head1ad81d868againstorigin/main15d44dcce, limit 200 measured 145–179 ms clean (main683–715) and 4.41–4.51 s dirty (maintimeout ×3).Every completed cell returns the same signed event set: 209 events, 187 replies at limit 200. With
include_auxoff, every cell is ≤66 ms. The thread-window loader (select_aux) is unchanged frommain, and its timings and captured plans matchmain's.Actual aux-hop statements captured from each relay binary with
auto_explain(analyze + buffers), execution ms:main(N-wayOR)MATERIALIZEDfence +ANY)What the fence changes
main, the planner combinesORDER BY created_at … LIMITwith an overestimated match count and walks each partition's primary key backward, expecting to stop early. With few real matches it scans the whole community: the dirty 188-id statement touches ~4.9M shared buffers onmainvs ~0.6M with the fence.Tradeoff: the fence materializes every match before sorting. In this bench small limits stayed within run-to-run noise of
main. A single hot id (5,000 reactions, limit 100) measured 30–34 ms unfenced vs 35 ms fenced.Deadline: each e-tag read (SELECT and COUNT) runs under a transaction-local
statement_timeoutof 20 s. It only tightens: a shorter operator timeout (BUZZ_DB_STATEMENT_TIMEOUT_MS) is kept. It's a per-statement limit, not a per-request one, so a request that runs several pages or hops can take longer than 20 s in total. The benchmark does not force the deadline.