indexer: break the permission-events throttle livelock - #753
Conversation
Prod recovered on its own — no longer an urgent mergeThe upstream throttle lifted between 21:06Z and 21:34Z. One successful cycle cleared the entire ~4h backlog in 8 seconds:
What the recovery confirms, and one correction to the PR description aboveThe 8-second catch-up is a sharper confirmation of the mechanism than the failure logs were — but it corrects the framing. I described the frontier advancing ~5min per 27min cycle as a throughput problem. It wasn't. The actual work was trivial the whole time: every cycle did a few seconds of it, hit the 429, and discarded the rest. The backlog was only ever large in wall-clock, because nothing could finish. That doesn't change either half of the fix, but it sharpens why each is here:
On the Worth watching separatelyA provider rate limit that stays engaged for ~4.5 hours straight is a long time for a rate limit. If this recurs on a similar cadence, the interesting question stops being "how do we survive it" and becomes "what changed upstream at 16:09Z" — plausibly a plan or quota change on the ledger endpoint rather than anything on our side. This PR makes the symptom survivable either way; it does not explain the trigger. |
Correction: the retry claim in this PR's description is wrongThe description says the SDK does retry the 429 ( On the pinned SDK (
So each cycle got exactly one attempt and aborted — which is why those runs died in 8–14 seconds, nowhere near any budget. #757 bumps the SDK and fixes that. What this does and does not change hereDoes not change: both halves of this PR are still correct and still wanted.
Does change: the mechanism of why the throttle was fatal. Not "3s of linear backoff was too short for a sustained rate limit" but "no retry occurred at all." The Merge order: #757 should land first, or alongside. It is the fix closest to the cause; this PR is the resilience layer that keeps a future throttle from costing hours of freshness even when retries do run out. Sorry for the churn on the description — I should have read the pinned version rather than whatever was in the module cache. |
|
Two notes, both minor. Prod already recovered on its own. The deploy note says permission events won't recover until this ships, but the drain caught up once the RPC pressure eased: Still worth shipping, since this will happen again — but there's no outstanding gap waiting on the deploy. This is the same fix that #740 deliberately left for later. #747 listed "advance the watermark for whatever was actually written, so a failed cycle stops throwing away completed work" as an explicit follow-up. This PR does that for permission events, including the part that's easy to get wrong: zero progress still returns an error, so a genuinely stuck account still escalates instead of quietly succeeding. Worth reading before the telemetry-usage version gets written. After deploy, the thing to check is whether the 429s actually stop. If they don't, the fetches-per-second value is what to adjust, not the approach. |
|
Both fair — thanks. Description updated. Deploy note corrected. You're right and I'd left the description stale: I posted the recovery in a comment but never fixed the note itself, so the PR still claimed prod was waiting on this deploy. It now says there's no outstanding gap, with your timeline, and states plainly that this ships so the next throttle costs minutes rather than hours — not as a recovery. #747 cross-reference added, and it checks out precisely against that PR's deferred follow-up #1:
I've called out the guard explicitly for whoever writes the telemetry-usage version, since it's the half that's easy to miss: Worth flagging that telemetry-usage is genuinely harder for the reason #747 already identified: its baseline cache must only advance once rows are durably written, and its deltas depend on ordered whole-window processing. Permission events has no equivalent coupling — independent chunks, explicit cursor — which is why it was tractable here first. I'd not assume this pattern ports over cheaply. On your post-deploy check — agreed, and there's now a better answer than tuning the dial. The provider confirmed after this PR was opened that these are per-method and overall per-IP caps, both over a 10s rolling window on their edge, and that every rate-limit 429 carries a
Also relevant to the per-IP half: the whole EKS cluster egresses through a single NAT ( |
nikw9944
left a comment
There was a problem hiding this comment.
Re-checked against the current head — cb06934, unchanged since the review was posted. No new commits, no force-push, and no replies on any of the three line threads. All three findings are verbatim-intact at the same line numbers; CI is green and the merge block is this review.
The author's one new comment updates the PR description and answers my earlier issue-level notes, but does not respond to any code finding. Two things in it bear on the review, and both were already folded into my severities:
- Budget margin → Medium (from High). The reported ~230ms round-trip and ~43 rps unpaced peak confirm the limiter rather than concurrency is binding at 25/s, so the mechanism holds. But the full 4h backlog drained in 7.9s — corroborated by the reviewer's own log excerpt, not only the author's — which is roughly 340 signatures total. Triggering this needs four accounts each holding a full 200-signature chunk, which the observed workload never approaches. Latent defect, not an active one.
- Transient swallow → Low (from High), reframed. My original claim that dropping the ERROR line leaves nothing to page was too strong:
Lake Indexer: Ingest Data Staledemonstrably fired and cleared during the incident, the partial path reports the frontier honestly (test-pinned), and CLAUDE.md explicitly places served-stale/degraded fallbacks at WARN. The author's "zero progress still errors" defense is correct as far as it goes and I'm not disputing it. What survives is only the nil-block-time hole, where a partial cycle emits no freshness value at all and the one remaining alert path has nothing to read. - Secondary-network wiring → Medium, unchanged. The new per-IP-cap detail — shared cluster egress through a single NAT — makes per-environment tuning more important, not less. This is a two-line omission that defeats the stated purpose of the flag this PR adds, and it is what the verdict rests on.
|
All three fixed in Budget margin — fixed as suggested, derived rather than fixed. The chain you traced is right, including the part that makes it nasty:
One consequence worth naming: at a very low configured rate the requirement can exceed the whole activity window, and then the zero-progress branch reports Frontier hole — fixed, and it's broader than the nil-block-time case. Chunk tip now comes from the newest dated signature in the chunk rather than only the last element, so the fallback covers everything except a chunk where nothing is dated. But while verifying I found the wider version of your point, and it's worse than either of us framed it: no alert reads Scoping that fairly: partial-success-with-pending is pre-existing — the budget-stop path already did Secondary-network wiring — fixed. Straightforwardly correct; the flag reached exactly one assignment. Secondaries now inherit the primary value via Tests added for the first two: |
|
Folded the What it does. Why status and not the frontier. The alert asks Blast radius. Four places filter
So the one thing that changes is the thing we wanted to change. Worth a glance at those dashboard panels when this lands, but nothing there pages. Accepted consequence. A legitimate multi-cycle drain now trips the 15-minute staleness alert. I think that's correct rather than noisy: during such a drain the data genuinely is behind, which is what the alert exists to say, and it self-clears on convergence. It should also be rare here — the entire 4-hour backlog from the incident drained in 7.9s once unblocked, so sustained partial states aren't a normal mode for this workload. If it does turn out noisy, the lever is the alert's 15-minute threshold rather than this classification. Still worth doing later, deliberately, and not here: point staleness at the frontier instead of run status — dateDiff('minute', max(coalesce(source_max_event_ts, finished_at)), now())That asks "is the data recent" rather than "did a run succeed recently", which is the question that stops needing new status values every time a view grows a partial mode. It needs care because Tests: |
|
Filed the frontier-based alerting follow-up as malbeclabs/infra#2217, so the |
nikw9944
left a comment
There was a problem hiding this comment.
Commit ab9ca79 adds a distinct partial ingestion-log status wired through RefreshResult.Partial, which closes the staleness-clock finding at its root rather than deferring it — verified and dropped, along with the three earlier findings, which remain fixed at this head. What is left is the chunk-budget reserve, untouched across both fix commits and unmentioned in either writeup: chunkBudget still applies the ×10 contention factor unconditionally, and pagination still reserves a flat 30s for a phase whose job is to leave room for a 110s chunk. Agreed that pointing staleness at the frontier belongs in infra#2217 rather than here, and that the per-view meaning of source_max_event_ts needs auditing before an alert can key on it. The multi-cycle-drain alert trade reads as correct to me as well; I could not verify the three out-of-repo status='success' consumers, so that enumeration is the load-bearing part of the blast-radius argument.
Findings not anchored to the current diff:
indexer/pkg/dz/serviceability/permissionevents/view.go:657— medium: Signature pagination can legitimately run the clock down to a point where no chunk can ever start, so the drain pages through everything and then hard-errors having indexed nothing. This check reservesdrainBudgetMargin(30s), but the chunk loop at view.go:548 then demandschunkBudget(200)— 110s at the default rate — so an account whose pagination consumes the window down to that reserve takes thestart == 0error branch every cycle. The same unconditional× maxConcurrentFetchesfactor also means a configured rate below roughly 7.4/s makes the requirement exceed the whole 300s activity window, with nothing validating that at startup. ReservechunkBudget(scanChunkSize)during pagination, and scale the factor to the number of accounts actually draining.indexer/pkg/dz/serviceability/permissionevents/view.go:334— low: The comment explaining how a permanently stalled drain stays visible now describes behaviour this commit removed. It still says each cycle "reports success", butresult.Partialat view.go:319 means a stopped-short cycle is recorded aspartialin the ingestion log. Point it at the new status rather than the metric.
Found by sweeping the other clients after the reviewed fix. permissionEventsRawRPC kept the SDK default pool of 9 while the view fans out to 10. It does not bite today. The 25/s fetch limiter caps effective concurrency near 6 at a 230ms round trip, so the limiter binds before the pool does. Raising --permission-events-fetches-per-second past roughly 43/s makes the pool the constraint instead, and queue time counts against the same per-request timeout. The flag added in #753 can walk into that with no signal. maxConcurrentFetches is now exported so the client and the view read one value rather than repeating it. Checked the other clients while here. The shreds client sizes its own pool (128 conns, 15s) so escrow events at a fan-out of 10 never queues. The Solana client makes its refresh calls in sequence, so the default pool fits.
nikw9944
left a comment
There was a problem hiding this comment.
Commit 548cc2b changes only the flag help string, so all three open findings are unaddressed at this head — verified against the current code rather than inferred from the diff size. Adding one item on that new text: the ~43/s ceiling it documents understates the constraint, since maxConcurrentFetches is 10 against the client's 9-connection per-host cap, so a fetch queues on the pool at every configured rate rather than only above 43/s. Holding at comment — nothing open is blocking, every failure mode here is loud rather than silent, and the reserve mismatch at view.go:548 remains the one I would fix before merge. Worth noting the flag now carries a documented ceiling and a documented floor with no validation at either end, which is the same gap pointing in both directions.
The one ledger client the sizing commit skipped, and it belongs to the component this PR was written for. Its peak is higher than it looks. Up to maxConcurrentFetches accounts drain at once and each paginates getSignaturesForAddress itself, ungated; separately decodeSem caps in-flight getTransaction at maxConcurrentFetches across the view. The two overlap, so the peak is 20 concurrent requests, not 10, against a default pool of 9. MaxConcurrentRPCRequests expresses that in the permissionevents package, so the client and the view read one value instead of repeating it. It lives in rpc.go rather than view.go on purpose: #753 rewrites view.go, and an earlier attempt at this exported a constant from there and produced the only merge conflict across the six branches. Verified clean against #753, #754, #755, #758 and main.
## Summary
One-line `go.mod` bump, but it is the closest thing to a root-cause fix
for the permission-events throttle incident. **On the SDK version we are
running, an RPCPool 429 is not retryable at all** — every cycle got
exactly one attempt and aborted.
Two independent reasons, both fixed upstream:
**1. Retry classification is dead code.** The pinned version tests
retryability with interface assertions:
```go
type hasStatusCode interface{ StatusCode() int }
type hasCode interface{ Code() int }
```
`jsonrpc.RPCError` and `jsonrpc.HTTPError` expose `Code` as a **struct
field, not a method**, so `errors.As` matches neither. The newer version
reads the codes off the concrete types and says so explicitly:
> Both expose their code as a struct field, not a method, so an
interface assertion on `StatusCode()`/`Code()` matches nothing in
production — that was the bug that made every Go ledger reader give up
after one attempt on RPCPool's 503s.
**2. The message fallback does not cover it either.** Pinned list
includes `"rate limited"`; the actual error reads `"Too many requests
for a specific RPC call"`. `"too many requests"` and `"service
unavailable"` are both added in the newer version.
Which explains the shape of the incident precisely: runs failing in 8–14
seconds, nowhere near any budget, for 4.5 hours.
## Also in the bump
- **Jittered backoff** — `d/2 + rand.N(d/2+1)`, added so *"the ~60
doublezerod hosts and dozen services reading the same ledger endpoint do
not retry in lockstep and re-spike an endpoint that is already shedding
load."*
- **Per-request timeout 5m → 10s.** The pinned client uses
`defaultTimeout = 5 * time.Minute` for the HTTP client, dial, and idle
timeout alike, so one hung request can stall a drain goroutine for five
minutes.
- **`doublezero_solana_rpc_retries_total{method}`** and
**`_retries_exhausted_total{method}`**. Every other DoubleZero service
already publishes these — `telemetry-state-ingest`, `controller`,
`devices`, `doublezero_client`, `internet_latency_collector`, `monitor`,
`device-health-oracle`. lake published neither, which is why a 4.5-hour
sustained throttle produced no RPC-layer signal: two ERROR log lines and
a staleness alert were the entire evidence base. Note that no
`getTransaction` series exists from *any* service — lake is the only
consumer of it, and the only one not reporting.
- Non-idempotent methods (`sendTransaction`, `requestAirdrop`) are now
never retried regardless of options.
## Review surface
Narrower than a four-day version jump suggests. Of the nine SDK packages
lake imports, **six are byte-identical**:
| package | changed |
|---|---|
| `config` | — |
| `controlplane/telemetry/pkg/config` | — |
| `sdk/geolocation/go` | — |
| `smartcontract/sdk/go/serviceability` | — |
| `smartcontract/sdk/go/telemetry` | — |
| `tools/maxmind/pkg/{geoip,metrodb}` | — |
| `tools/solana/pkg/jsonrpc` | retry.go, + metrics.go |
| `tools/solana/pkg/rpc` | retry.go |
| `sdk/shreds/go` | rpc.go, state.go |
`sdk/shreds/go` is the one worth a look beyond the RPC layer, since
`rpc.go` and `state.go` both changed.
## Testing Verification
- Full `go test ./indexer/...` suite run against the bump. One failure,
`TestHealthMulticastUserRate` in `dz/mroute` — confirmed
**pre-existing** by running it on `main` without the bump and getting
the identical ClickHouse error (`code: 27, Cannot parse input: expected
'(' before: '-- grp-rate-gap: …'` — a `--` comment inside a `VALUES`
list in `health_multicast_user_rate_test.go:42`). Unrelated to this
change, but it means that package has no working local coverage right
now.
- Builds clean across `indexer`, `api`, `utils`, `admin`. No source
changes required — `go.mod` and `go.sum` only.
## Relationship to #753
#753 (graceful stop on throttle + request pacing) is still correct and
still wanted, but its description overstates the deployed retry behavior
— I have posted a correction there. The two are complementary: this PR
makes a throttled request actually get retried, #753 makes a cycle bank
its committed work when retries do run out, and #753's pacing keeps us
from tripping the limit in the first place. **This one should land
first**, or at least alongside.
A rate-limited getTransaction aborted the whole refresh cycle. Committed chunks survived, but the run was recorded as an error and the drain advanced only one chunk per ~27min cycle — so once a backlog appeared it could never catch up. Prod sat 3h26m behind and widening. Two changes: - A transient decode failure with committed progress is now a budget stop, not a cycle failure — the same shape the deadline check already used. Zero progress still errors, so a persistently throttled account escalates. - Pace getTransaction view-wide. The concurrency semaphore bounded in-flight calls but not request rate, which is what a per-method provider limit measures; 10 in-flight calls against a fast endpoint is an unbounded rate. Tunable via --permission-events-fetches-per-second.
Three findings from review, all real. Derive the chunk deadline reserve from the fetch rate. Pacing put the rate in charge of how long a chunk takes, but drainBudgetMargin was a fixed 30s sized for unpaced fetches. The limiter is view-wide, so a 200-signature chunk is 8s alone and 80s against nine other draining accounts — the drain could start a chunk it had no way to finish. The overrun was silent: rate.Limiter.Wait refuses a reservation that would outlast the deadline rather than blocking, and its "would exceed context deadline" error classifies transient, so the cycle took the stop-with-progress path and reported success after fetching a chunk that committed nothing. chunkBudget now sizes from the actual chunk length and the configured rate, plus drainCommitReserve for the insert and checkpoint. Take a partial cycle's frontier from the newest dated signature in the chunk rather than only its last element. committedTip stayed zero when that one entry had no block time, which left SourceMaxEventTS nil and recorded the stalled cycle as a clean success with no freshness value at all. Thread the fetch-rate knob into the secondary networks. It was assigned in one place, on the primary path, so devnet and testnet stayed pinned at the package default — and testnet carries the high-volume Permission PDA. Secondaries now inherit the primary flag, with a per-env PERMISSION_EVENTS_FETCHES_PER_SECOND_<ENV> override, since the caps are per method per source IP and every env shares this cluster's single egress.
Staleness alerting asks for the last successful run's finished_at, so a cycle that banked its committed chunks and stopped was resetting that clock while the data it covered stayed hours behind. A drain that never converged read as healthy indefinitely. Reporting the honest source timestamp does not cover this: nothing reads source_max_event_ts. RefreshResult gains Partial, and a run that completes without error but knowingly left work undone is recorded as status="partial". An error still outranks it, so the cause is never dropped. Blast radius is narrow. Of the four consumers that filter status='success' — the multicast-delivery source-ingest query, the rollup staleness alert, the ingest staleness alert, and the lake-ops dashboard — only the ingest staleness alert changes behaviour, which is the intent. The other two queries cover activities with no partial mode (snapshot-replace syncs and rollup). Accepted consequence: a legitimate multi-cycle drain now trips the 15-minute staleness alert. That reads as correct rather than noisy — during such a drain the data genuinely is behind — and it self-clears on convergence.
The flag can be raised past the point where the client's connection pool, not the limiter, becomes the constraint. Queue time for a connection counts against the per-request timeout, so the drain would start failing rather than going faster. Sizing the pool needs rpc.Options, which arrives with the SDK bump in #757, so this records the ceiling until that lands.
Addresses the four open findings on #753. The chunk requirement no longer assumes worst-case contention. chunkBudget multiplied by maxConcurrentFetches unconditionally, so a chunk of 200 signatures at 25/s claimed to need 110s when it needs 8s alone. That single term produced both open budget findings: it rejected every cycle holding between the two figures, and below about 7.4/s it put the whole 300s activity window out of reach with nothing validating the number. What the worst case bought was avoiding one chunk of wasted fetches on overrun. That is no longer worth its price, because an overrun is no longer silent: the limiter refuses a reservation that would outlast the deadline, the chunk's rows are dropped uncommitted, and the cycle records partial (with progress) or errors (with none). Contention also varies during a chunk, so no static factor is right; the uncontended cost is at least an honest lower bound with a visible failure. Pagination and the chunk loop now test the same number. Pagination guaranteed a flat 30s while the chunk loop demanded chunkBudget(200), so a cycle exiting pagination inside that band paged its whole backlog and then took the zero-progress error branch having indexed nothing. The reserve is now chunkBudget over the count already paged, and the check moved to after the first page so an account with nothing new costs one request and succeeds however little deadline is left. drainBudgetMargin is gone. Both budget-exhaustion messages now name the configured rate. This branch is where a misconfigured rate lands, and without the rate a starved backlog and a bad number read identically. Validate also rejects a negative rate, which is not a slower drain but a limiter that refuses every reservation. Removing the factor moves the unusable-rate floor from a plausible 7.4/s to an absurd 0.74/s, which is why there is no new pre-flight check: rejecting on the window would need the activity timeout restated inside the view, and the plausible typo (2.5 for 25) drains a full chunk per cycle. The flag help no longer claims a 9-connection ceiling. #757 sizes that pool to permissionevents.MaxConcurrentRPCRequests, which is this view's own peak, so the pool is not the constraint at any rate. The stale comment now points at status=partial rather than at "each cycle reports success". New test lands a refresh inside the old band and pins that pagination is the phase that refuses. It fails against the reconstructed flat reserve with exactly the reported symptom: "refresh budget exhausted before first chunk (200 signatures pending, 32s needed at 100 fetches/second)". The other budget tests run at 1e6 fetches/second, where the two thresholds collapse to one value, which is why the band was invisible to them.
548cc2b to
6398e60
Compare
|
Pushed The two budget findings had one cause. So the factor is gone. What the worst case bought was avoiding one chunk of wasted fetches on overrun. That is no longer worth its price. An overrun is not silent any more: the limiter refuses the reservation, the chunk's rows are dropped uncommitted, and the cycle records Pagination now reserves what the chunk loop demands. The reserve is On rejecting a bad rate at startup — I did not do that, and I think it is the wrong place. Two reasons:
What I did instead: both budget-exhaustion messages name the configured rate, so the branch where a bad number lands says so. Your point stands that a starved backlog and a bad rate read identically — that was true, and the fix is the message, not a pre-flight check:
Flag help. You are right that the number pointed at the wrong constraint, and it is now wrong in the other direction too: #757 sizes that client to Stale comment points at Test. Your note about the other two budget tests is why the band was invisible: at
|
nikw9944
left a comment
There was a problem hiding this comment.
Rebased onto current main and pushed 6398e60; all four open findings are addressed in code, verified independently rather than taken from the writeup. The pagination and chunk phases now call the same chunkBudget over the same count with drainBudgetMargin deleted, and the new test lands a deadline inside the old band and pins that pagination is the phase that refuses. Your argument against startup validation is right and I am not pressing it — restating the 300s activity timeout inside the view is exactly the cross-layer constant duplication that #747 was written to stop, and naming the configured rate in both exhaustion messages solves the diagnosis problem that finding was actually about. One nit left on the other direction of the same change: dropping the contention factor makes the reserve an uncontended estimate, which is right for the N≈1 workload but under-reserves once several accounts drain together.
Summary
getTransactionaborted the entire permission-events refresh cycle. Committed chunks stayed durable, but the run was recorded as an error and the drain advanced only ~one chunk per ~27min cycle — once a backlog appeared it could never catch up. Prod sat 3h26m behind and widening for 4.5 hours.drainAccountalready used for its deadline check. Zero progress still returns an error, so a persistently throttled account escalates instead of no-op succeeding.getTransactionis now paced view-wide. The existing semaphore capped in-flight calls but not request rate, which is what a per-method provider limit actually measures — 10 concurrent calls against a fast endpoint is an unbounded rate, so the drain kept earningToo many requests for a specific RPC callon every cycle.Why the cycle could never catch up
Each cycle committed its first chunk, hit a 429, and discarded the rest — advancing the frontier ~5 minutes per 27-minute cycle.
The graceful stop alone would have converted a loud livelock into a silent one (success-with-pending, frontier still falling behind), which is why the pacing change is in the same PR — they fix the same failure together.
Note for review
defaultFetchesPerSecond = 25is a calibration knob, not a derived constant — no provider publishes a per-method number and it differs per endpoint and plan. It is sized to drain a 200-signature chunk in ~8s so the ~4.5min usable window fits ~30 chunks. It is behind--permission-events-fetches-per-secondso it can be retuned without a rebuild. This is the value most worth a second opinion.Testing Verification
RPCError{Code: 429}, the same shape RPCPool returns) and fails on the pre-change code with the identical production error message; passes after.ThrottledChunkStopsWithProgressAndResumes— asserts the first chunk stays committed, the cursor sits at the chunk boundary,SourceMaxEventTSreports the honest committed frontier rather thannow(this is what feeds the ingest-staleness alert), and the next cycle drains the remainder with no signature skipped or double-counted.ThrottledFirstChunkStillErrors— pins the guard: a throttle before any commit must still surface an error, so suppression can't hide a persistently throttled account.PacesTransactionFetches— asserts the drain cannot finish faster than the configured rate allows.go test -racegreen acrossdz/serviceability/...,dzingest,indexer.Relationship to #747
This is the permission-events instance of the follow-up #747 deliberately deferred:
Same shape, different view. The part worth reading before the telemetry-usage version gets written is the guard, not the happy path:
start > 0 && dberror.IsTransient(decodeErr)stops a cycle discarding committed chunks, but zero progress still returns an error, so a genuinely stuck account escalates instead of quietly succeeding forever. Getting only the first half would convert a loud livelock into a silent one.Note that telemetry-usage is harder for the reason #747 gives: its baseline cache must only advance once rows are durably written, and its deltas depend on ordered whole-window processing. Permission events has no equivalent coupling — chunks are independent and the cursor is explicit — which is why it was tractable here first.
Deploy note
No outstanding gap. Permission events caught up on its own once the upstream RPC pressure eased, before this shipped:
So this is not a recovery deploy. It ships so the next throttle costs minutes of freshness instead of hours, and so a failed cycle stops discarding work it already committed.
After deploy, the thing to verify is whether the 429s stop. If they continue,
--permission-events-fetches-per-secondis the dial — the approach doesn't change. (Since this PR was opened, the provider confirmed these are per-method and per-IP caps over a 10s rolling window on their edge, and every 429 carries aRetry-After; honoring that header is tracked separately in malbeclabs/doublezero#4161 and is the more durable fix than any pacing value.)