Skip to content

fix(block-proxy): dedup deadline - #1608

Merged
andrewklau merged 10 commits into
mainfrom
fix/block-proxy-dedup-deadline
Aug 24, 2026
Merged

fix(block-proxy): dedup deadline#1608
andrewklau merged 10 commits into
mainfrom
fix/block-proxy-dedup-deadline

Conversation

@andrewklau

Copy link
Copy Markdown
Contributor

No description provided.

A dedup entry was removed only when its promise settled, so an upstream
promise that never settled pinned its block height indefinitely. Every
later request for that height attached to the dead promise instead of
retrying, and because indexers resume from the same persisted height
after a crash, one poisoned height stalled every consumer until the
process was restarted by hand.

- withDeadline() guarantees the stored promise settles within
  DEDUP_TTL_SECS (default 25, validated 5..30 against the 30s client
  abort), so the entry is always released and the next request leads a
  fresh attempt.
- Entries carry createdAt and are evicted on read when stale, so a
  reader is never served an entry that outlived its TTL even if the
  deadline timer failed to fire.
- The .finally delete checks entry identity, so a slow leader settling
  after eviction cannot delete its successor.
- The MAX_DEDUP_SIZE bypass is bounded too, and counts as a leader so
  dedup.saves no longer reports bypassed fetches as deduplications.
- UPSTREAM_TIMEOUT_SECS 30 -> 10. It previously equalled the client
  abort, so indexers gave up at the instant the proxy would have
  answered and the fallback chain was invisible to them.
- Bound cache reads, cancel response bodies before throwing (undici
  holds the socket until the body is read or cancelled), and fail fast
  when no working upstream is configured.
- Add /livez, which reports a stall when requests arrive and none
  complete, plus block_proxy_dedup_saves, block_proxy_dedup_deadlines
  and block_proxy_last_served_timestamp.
- Remove the unused NEAR Lake upstream, including a path that reported a
  block as skipped when it was merely absent from the bucket.
indexer-base and indexer-receipts called startMetricsServer(3010) twice,
from libs/prom.ts and again from libs/http.ts, so every boot logged
EADDRINUSE. The first listen won, so metrics were still exported, but
the server exported from libs/http.ts and passed to server.close() on
shutdown was the failed one, and the port was never released.

Export the server from libs/prom.ts as the other nine indexers already
do, and drop the redundant libs/http.ts.
turbo had no test task, so the suites in the repo never ran anywhere.
Add one, wire it into CI, and fix nb-neardata's test script, which
declared vitest with no test files: it exited 1 in CI and hung in watch
mode locally.

packages/nb-lake had no src, no dependents, and had not been touched
since January.
Four scattered guards, a worstCaseChainMs helper and two startup
warnings collapsed into one block. The warnings are replaced by the
hard guard that subsumes them, and the cache read timeout is now a
single exported constant instead of the same number in two files.
A review proved several additions inert or harmful:

- The config guard coupling DEDUP_TTL_SECS to the upstream chain was
  unsatisfiable: S3 on with a 15s upstream timeout required a TTL above
  32s and at most 30s, so the proxy refused to boot on a legal config.
  The documented 5-30 range was also wrong for every configuration.
- /livez could never fire. The deadline guarantees a written 502, so
  res 'finish' always fired and lastServedAt always tracked
  lastRequestAt. It reported healthy while every request failed.
- The stale-entry eviction and its .finally identity check were
  unreachable: timers run before the poll phase, so a due deadline
  always fires before the next request handler.
- block_proxy_dedup_saves is identically dedup_requests minus
  dedup_leaders, which stats.ts already derives.
- The cache AbortSignal does not interrupt a blocking open and does not
  free the libuv worker, and a hard cap under load turns cache hits into
  misses.
- discardBody on the 404 paths is a measured no-op; undici reclaims a
  small unread body in the same tick.

Inline withDeadline at its one call site and reject before the side
effects, which removes the try/catch, its module and its test file.
- A leader created while the dedup map was at capacity is never stored,
  but still carried the .finally cleanup, so on settling it deleted the
  key belonging to a later leader and singleflight collapsed for that
  height. Only the stored promise carries the cleanup now.
- Rejecting before the deadline's side effects settled the promise but
  did not contain a throw, which escaped the timer callback as an
  uncaught exception and exits the process. Guard the side effects.

Both now have tests that fail without the fix, along with tests for the
capacity cap and the error payload that drives 502 logging.
Every filesystem call shares one libuv worker pool, four wide by
default, and none can be cancelled. A volume that stops responding parks
a worker per call, permanently. Reads, background writes and the
eviction sweep all draw on that pool, so a hung mount drains it within
seconds and stops all file and DNS work process-wide, taking the proxy
down entirely rather than just the cache. Background writes are the
heaviest draw at three calls per block, and nothing observed them.

Count operations that overrun and, after three, stop touching the disk
for the life of the process: the proxy keeps serving from upstream,
slower but serving. One-way on purpose, since re-probing a hung mount
costs another worker that never returns. Exposed as
block_proxy_cache_disabled and block_proxy_cache_timeouts.

Also guard the eviction sweep against overlapping itself, so a stalled
sweep costs one worker rather than one per minute, and raise
UV_THREADPOOL_SIZE so three tolerated stalls leave margin for the DNS
lookups outbound fetches need.
Upstream is billed per query and the cache carries most of the load
during catch-up, so leaving it off until the next deploy over-corrects
for a transient stall.

Suspension is now temporary: after a backoff starting at 60s and
doubling to 15m, one read is let through to see whether the volume
recovered, and a successful one resumes writes and eviction too. Reads
carry the probe because they are the cheapest call. Each probe that
stalls costs a worker for good, so the total is capped at four, after
which only a restart brings the cache back.

Suspension and resumption both log, the reason names the cost impact,
and the state is exposed as block_proxy_cache_disabled and in the
/stats cache.disabled field.
It cannot do the job it was written for, and it introduces a cost
regression on healthy hardware.

The pool is eight workers wide and a block costs four filesystem calls
(one read, three for the write). At catch-up rates that is roughly a
thousand calls a second, so a hung volume parks every worker in about
eight milliseconds. The detector needs a five second timeout to fire.
Even at tip rate the pool is gone in two seconds. A latency-based
detector can never outrun pool drain; it always arrives after the
process is already dead.

Worse, the timeout measures queueing as well as service time. A
thousand offered operations a second against eight workers at ten
milliseconds each is past capacity, the queue grows without bound, and
every operation eventually crosses five seconds. So on a healthy
network volume during catch-up the cache disables itself permanently
and turns cache hits into billed upstream queries.

Recovery never worked either: in-flight operations at the moment of
suspension all count against the probe budget, so a real hang grants
zero probes and the backoff ladder is dead code.

Bounding parked workers needs a limit on concurrent filesystem calls,
not a timer. That is a different design and will come separately.
@andrewklau
andrewklau merged commit 0fed08d into main Aug 24, 2026
1 check passed
@andrewklau
andrewklau deleted the fix/block-proxy-dedup-deadline branch August 24, 2026 14:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant