fix(reputation,qos): stop health-check probes benching endpoints, plus cardinality/solana/archival follow-ups - #528
Open
oten91 wants to merge 21 commits into
Open
fix(reputation,qos): stop health-check probes benching endpoints, plus cardinality/solana/archival follow-ups#528oten91 wants to merge 21 commits into
oten91 wants to merge 21 commits into
Conversation
…om aggregate metrics Follow-up to #527. PNF re-measured at 7.6h of pod age and found the per-pod series accumulation rate statistically unchanged (40k/day/pod vs 37k pre-fix) — the fleet improvement came from the rollout resetting every registry plus a 10 -> 6 replica cut, not from the growth stopping. Two findings, both confirmed against production Prometheus before changing anything. F5 — a label on a histogram costs ~12x what it costs on the counter beside it. path_relay_latency_seconds_bucket was 341,840 series fleet-wide, 31.6% of the entire gateway job and its largest source of ongoing growth. It carried status_code (5 values) x reputation_signal (4) — a 20x pair, multiplied by ~12 series per tuple — and nothing queried either FROM THE HISTOGRAM: all eight dashboard histogram_quantile expressions aggregate to at most (domain, service_id, rpc_type, le), request_type appears only as a selector, and no Prometheus rule references it. Both labels are dropped from the histogram and kept on relays_total, which carries the full outcome taxonomy at 1 series per tuple; join on (domain, rpc_type, service_id, request_type) to correlate the two. Measured on one mainnet pod: 2,731 tuples -> 1,061, so 27,310 bucket series -> 10,610. The ceiling matters more than the immediate drop. The live (domain, service_id, rpc_type) universe is 403 combinations, so the tuple ceiling was 403 x 4 request_type x 20 = 32,240 (~322K bucket series/pod) and the metric was still climbing toward it (2,226 -> 2,732 tuples over 6h). Post-fix the ceiling is ~1,600 tuples, ~16K series/pod: 20x lower. F6 — a cardinality guard bounds the live registry, not the series stream. Six metrics carried a raw supplier label, 303,309 series in a 10-minute window. The supplier set is ~5,200 on chain, grows with the network rather than with our traffic, and rotates every session, so these metrics minted multiples of their live count in distinct series every day. Measured on one pod over 7.7h, live in a 10m window vs distinct over the pod's life: supplier_reputation_score 4,510 -> 74,639 16.5x qos_filter_rejection_total 1,271 -> 24,708 19.4x supplier_signal_total 6,523 -> 60,674 9.3x hedge_supplier_outcome_total 4,306 -> 7,840 1.8x supplier_blacklist_total 806 -> 806 1.0x relay_latency_bucket 27,320 -> 27,320 1.0x (control) Two of these were guarded and honored their guards throughout. That is the point of the finding: a metric can sit at 26% of its cap forever and still be among the largest things in the TSDB. Eviction is not the cause and removing it would not help — re-admitting an evicted tuple recreates the same label set, hence the same series with a gap, never a new one, so the distinct-series count is identical either way. Eviction only decides whether the cost also lands on pod heap. Nothing on the registry side can bound this; only the label's value set can. - path_supplier_reputation_score: REMOVED. Zero dashboard references, zero Prometheus rules. The per-operator reading already ships as path_reputation_mean_score (403 series/pod, 1.0x churn) and the per-supplier one as GET /ready/<service>?detailed=true, which returns score, strikes, latency, tier and cooldown per endpoint. Its publisher walked every service's sessions and did a GetScore per endpoint every 10s to feed it. - path_supplier_signal_total: REMOVED. Zero references. Its cardinality had already been cut once by collapsing 8 signal types to 3 severity classes, which fixed the multiplier and left the base — the base was the problem. The full taxonomy, not the collapse, is on relays_total's reputation_signal. - path_qos_filter_rejection_total: supplier -> domain. Fires ~9,500/s fleet-wide with the worst churn ratio of any gateway metric. Now bounded by (domain x service_id x reason). - path_hedge_supplier_outcome_total: supplier -> domain. Hedge asks an operator-level question; ~26 series/pod. - path_supplier_blacklist_total: supplier dropped, keeps its existing domain. The address is still in the WARN log at the call site. - path_supplier_exhausted_total: unchanged, 313 series fleet-wide. The allowance it reports is per (supplier, session), so the supplier is the subject rather than a way of naming an operator. Same for supplier_nil_pubkey_total and supplier_pubkey_cache_events_total, both zero series in production. Testing Every fix was revert-checked. Two tests were wrong on the first pass and both were caught that way: - A registry walk for the supplier label passed on revert, because Gather() reports the labels of CHILD series and an unpopulated vec reports none. It now emits through each production Record* helper first and asserts the population happened. - The removed-metric test had the same hole; it now detects re-registration by collision instead, so a re-added vec that nothing populates is still caught. A supplier -> domain re-key compiles silently when the call site keeps passing the address — both are strings and the label name is right. The call-site tests therefore assert on the emitted label VALUE through the production caller (basicEndpointValidation, recordWinner/recordLoser): a bech32 address sanitizes to the supplier_addr sentinel, which is the tell. Both revert-checked. Also removes reputation.supplierFromKey, dead with its only caller.
…levm websocket probes kava's `health` and `status` are CometBFT methods but were declared `type: json_rpc`. The check type selects which of an endpoint's per-rpc-type URLs the relay targets, so on a dual-stack chain both were delivered to the EVM JSON-RPC port, which answered -32601 "the method health does not exist/is not available". `status` carries critical_error, so kava's json_rpc mean score sat at ~10.1 while rest read 85.7. This was a rule defect scored against the endpoints, not an endpoint fault. comet_bft read 100 for the opposite reason: no check in this file was typed comet_bft, so that rpc type was never exercised. 100 means never penalized, not healthy. kava now checks each rpc type on its own transport: json_rpc gets eth_blockNumber (sync_check) and eth_chainId asserting 0x8ae; comet_bft gets health and status; rest keeps syncing. `status` opens at major_error rather than critical because comet_bft has never run on any service here, and opening at -50 with cooldown on an unexercised rpc type would mass-cool endpoints over a rule defect. Escalate once the false-positive rate is known, matching how the websocket checks were staged. Verified live before asserting the chain ID: evm.kava.io eth_chainId -> 0x8ae (2222), eth_blockNumber -> 22100394; rpc.data.kava.io latest_block_height -> 22100395. EVM height tracks CometBFT height 1:1, so the single perceived height per service is valid for both sync checks. Separately, xrplevm 10s -> 30s and xrplevm-testnet 15s -> 30s. Websocket checks are the one check type not covered by backend-URL dedup, so an operator holding N registrations behind one node URL takes N connect/close cycles per interval against the same socket server, per environment. An operator reported the resulting flood; the log lines carry our probe's own close reason and a 1000 Normal close, and their websocket server logs every disconnect at Error level regardless of code. Nothing is failing. Interval is the only lever available from config; the structural fix is extending backend-URL dedup to websocket probes whose siblings share an identical websocket URL. Also moved a stray comment that made xrplevm's websocket check read as hyperliquid's. No behavior change; the file parses to the same 69 services and passes ServiceHealthCheckConfig.Validate() before and after.
… endpoint selection
Solana's ValidateEndpoint compared an endpoint's block height against the perceived
height with no tolerance at all:
if endpoint.BlockHeight < s.perceivedBlockHeight { ... reject }
The perceived height is a MAX over endpoint observations, and Solana produces a block
roughly every 400ms. Under a strict comparison only the most recently observed endpoint
can ever be valid: every other endpoint's newest report is, by construction, older than
the one that just raised the bar.
That closes a starvation loop. An endpoint carrying user traffic re-reports its height
continuously and stays valid; an endpoint refreshed only by health checks (~0.35/s per
endpoint against ~2.5 blocks/s) trails permanently and is filtered out, which denies it
the traffic that would have refreshed it. Observed in production on 2026-08-18 during a
~10x traffic surge: the selection pool collapsed to a single operator
(path_selection_pool_operators = 1.00, against 6.77 on eth) and stayed there while
alternative endpoints held a reputation score of 100, zero cooldown and tier 1 and
received no traffic at all. The per-operator concentration cap could not help — with one
operator in the pool there is nothing to reshape.
Health-check tuning cannot fix this. Both operators already receive the same per-endpoint
check rate, and outrunning the chain would need more than one check per endpoint per
block; even then the max-plus-strict-comparison race is re-lost every block.
The allowance was already configured for the service and already consumed by the health
check's own sync check. The health check executor applies it by asserting the QoS
instance to `interface{ SetSyncAllowance(uint64) }`, which EVM, CosmosSDK and NoOp
implement and Solana did not — so the configured value reached one consumer and was
silently dropped for endpoint selection.
Changes:
- ServiceState gains an atomic syncAllowance plus getSyncAllowance/SetSyncAllowance,
promoted to the Solana QoS through the embedded *ServiceState (the same shape as
SetMaxOperatorShare on *EndpointStore).
- ValidateEndpoint uses qos.MinAllowedBlockNumber, and takes the endpoint address so a
rejection can be attributed.
- defaultSolanaBlockNumberSyncAllowance = 750 (~5 minutes of Solana). Unlike EVM and
CosmosSDK, 0 means "not configured" and falls back to the default rather than
disabling the check: defaulting to 0 would restore the strict comparison during the
startup window and whenever external rules fail to load.
- Solana now records path_qos_filter_rejection_total (block_height_lag,
block_height_unknown, invalid_response), keyed on domain and computed lazily so the
passing path does not parse an address on every endpoint of every selection pass.
Previously this exclusion was reported only through a Warn log, invisible at the log
level production runs.
Tests assert through SelectMultipleWithArchival rather than on the state fields, and the
exclusion cases keep one endpoint valid on purpose so the least-stale fallback does not
run and mask the result. Both revert checks were performed: restoring the strict
comparison fails the trailing-endpoint tests, and un-exporting SetSyncAllowance fails the
interface-reachability test.
The perceived-epoch comparison is left strict. It has the same shape, but epochs turn
over roughly every 2.5 days rather than every 400ms.
…uccesses
path_requests_total is recorded per endpoint observation, from the backend's HTTP status.
When a relay never receives one the observation carries 0, and that was unconditionally
defaulted to 200:
statusCode := int(endpointObs.GetEndpointBackendServiceHttpResponseStatusCode())
if statusCode == 0 {
statusCode = 200 // Default success
}
Status 0 is two different outcomes. With no error set the relay succeeded and the status
simply was not recorded. With an error set the relay failed before any HTTP status could
exist: a timeout, a refused or reset connection, an unreachable host, a signature or
payload validation failure. Collapsing both onto 200 counted every transport failure as a
success against the endpoint that produced it.
The distortion is largest exactly where it matters most. Measured on solana 2026-08-18: an
operator generating 242 relay errors/s — 5 second timeouts, path_relay_latency_seconds P95
of 7.6s — reported 0/s non-200 here and read as roughly 95% successful, while an operator
returning honest HTTP error codes in 50ms read as roughly 43%. The supplier-quality panel
ranked the two backwards, and any alert keyed on this metric was blind to the failure mode
that hurts users most: an endpoint that accepts the connection and then never answers.
A relay with no HTTP status and an error set is now recorded under a distinct "error"
status category rather than 200. That is the vocabulary path_relays_total already uses for
the same outcome, so the two metrics can be compared without a translation table; the
literal is promoted to metrics.StatusCategoryError and the existing relay and health-check
call sites now share it.
"error" is deliberately not folded into 5xx. A backend answering 500 is reachable and
answering; one that never answers is not, and the two call for different responses.
The relay's own domain attribution was already correct — it comes from the endpoint URL of
each individual endpoint observation, not from the request's primary supplier.
Dashboards and alerts that compute a success rate as 200 over total will move for any
service whose endpoints time out. That movement is the correction, not a regression.
Tests drive processEndpointObservation itself, since the defect was in how that function
derives the label — a test supplying its own status would prove nothing. Revert-checked:
restoring the default-to-200 fails
Test_RequestStatus_TransportFailureIsNotCountedAsSuccess.
…d value The fallback was set to 750 on the basis that it matched the sync_allowance configured for solana. It does not — pnf_path_rules.yaml carries 1500 (the 750 in the comment there is the original 5-minute formula, since superseded). The whole point of the fallback is that an unloaded config behaves like a loaded one, so a mismatch defeats it: a pod that had not yet loaded external rules would apply a tighter bound than the same pod a second later, and tighter here means endpoints silently leaving the selectable pool. Also records what the value now costs to change. It was sized as a health-check gate, where being generous only risks probing a stale endpoint; it now also decides which endpoints are selectable, so lowering it is a routing change rather than a check-strictness change.
…validator reads Solana's endpoint validation requires a getHealth observation and a block height. The health-check pipeline supplied neither, so an endpoint whose observations came only from health checks was rejected as never-observed — and rejected means no user traffic, which was the only other source of those observations. The configured probes ran, passed, and populated nothing that selection consults. Two independent reasons the pipeline produced nothing: 1. UpdateFromExtractedData never set SolanaGetHealthResponse. Health checks reached QoS through the generic ExtractedData path, which carried a block height and no health status, so errNoGetHealthObs was permanent. 2. ExtractBlockHeight only parsed the getEpochInfo shape (`result.blockHeight`), while the configured probe is getBlockHeight, whose result is a bare number. That response was unparseable, so health checks contributed no block height either. Changes: - ExtractedData gains SyncCheckPerformed, mirroring ArchivalCheckPerformed. Without it, "not syncing" (the zero value) is indistinguishable from "never checked", which is exactly the distinction needed to record a health observation rather than assume one. - Solana's IsSyncing is gated on the request naming getHealth. It previously ran every response through a `result == "ok"` test, so a getBlockHeight response — a bare number — was reported as SYNCING. With SyncCheckPerformed derived from whether IsSyncing errors, an ungated version would also mint a health observation out of a response containing none, which is worse than having no observation at all. - ExtractBlockHeight accepts a bare numeric result when the request names getBlockHeight. Gated on the method, which is what separates it from the absoluteSlot fallback the surrounding comment forbids: that one guessed at a field inside a getEpochInfo result and guessed the slot. Accepting a bare number from any response would reopen the poisoning hole, since getSlot answers with a bare number too. - UpdateFromExtractedData records the health observation when the response was a getHealth response, and no longer returns early when a response carries health but no block height. The per-endpoint Redis write is guarded on having a block height — a health-only observation carries 0, and writing that would clobber a real height across every replica. - Epoch 0 is no longer fatal, in validateBasic or in ValidateEndpoint. The only source of a real epoch is a getEpochInfo response from user traffic; the health-check path leaves it at 0 by construction. Treating that as invalid recreated the same trap: an endpoint benched for a field nothing routinely supplies, and therefore never given the traffic that would supply it. - The epoch comparison gains one epoch of tolerance. Same max-versus-strict shape as the block height check: perceivedEpoch is raised by whichever endpoint reports first, so at a rollover every other endpoint is briefly an epoch behind through no fault of its own. Epochs last roughly 2.5 days, so this costs almost nothing and removes a cliff that would otherwise empty the pool for a few seconds every couple of days. - Filter rejection reasons are split: health_unknown, unhealthy and epoch_lag join the existing set. The first version folded "no health observation" and "no epoch info" into one block_height_unknown bucket, so telling them apart required inferring from the ABSENCE of a sibling series — the diagnosis that mattered rested on a negative. Tests drive the real pipeline (ExtractedData.ExtractAll then UpdateFromExtractedData) rather than hand-filling the endpoint struct, since the defect was in which fields that pipeline populates. Revert-checked four ways: removing the health record, the getBlockHeight shape, the IsSyncing method gate, or the epoch tolerance each fails its own tests.
poktroll v0.1.35 declares `go 1.26.5` (v0.1.34 declared 1.25.8), so the module's own go directive has to move with it. Transitives pulled in by the upgrade: shannon-sdk to 20260812141256, x/crypto 0.53 to 0.54, x/sync 0.21 to 0.22, x/sys 0.46 to 0.47, x/term 0.44 to 0.45, x/text 0.38 to 0.40, plus santhosh-tekuri/jsonschema/v6 as a new indirect. CI needs no change: every workflow step resolves its toolchain with `go-version-file: go.mod` rather than a pinned version, so all five follow the directive automatically. 1.26.5 is a released toolchain, so setup-go can resolve it. Docker needs no change either, but only by luck of tagging. Dockerfile, Dockerfile.race and Dockerfile.local all build on the floating `golang:1.26-alpine` tag, which currently resolves to 1.26.6 and therefore already satisfies the new directive. Dockerfile.release and Dockerfile.release.glibc have no Go builder stage at all — they consume a prebuilt binary on top of alpine and distroless respectively. makefiles/debug.mk was pinned to golang:1.25-alpine, a full minor behind everything else, and is bumped here for consistency. To be clear about what this is NOT: that target runs `go tool pprof` against a remote pprof endpoint inside a throwaway container and never compiles this module, so the go directive does not apply to it and the pin was not going to break the upgrade. It was simply already stale. portal-db/sdk/go (go 1.22.5) and its example (go 1.23) are left alone: separate modules, no poktroll dependency, not built by the root CI Go steps, and versioned independently via auto-version-bump.yml. Build, vet and the full unit suite pass. The only failures are the Redis testcontainer tests, which require a local Docker daemon and fail identically before this change.
… fixes Supersedes the 1.26.5 directive set in the previous commit. poktroll v0.1.35 requires 1.26.5, which is what that commit matched; 1.26.5 is also the last release before seven stdlib advisories were fixed. Fixed in 1.26.6 (also 1.25.13 and 1.27.0-rc.3): CVE-2026-56853 net/http ReadHeaderTimeout not applied during the unencrypted HTTP/2 check CVE-2026-56860 net/url quadratic complexity in resolvePath CVE-2026-56862 crypto/tls unbounded post-handshake messages CVE-2026-46600 net panic parsing an invalid SVCB or HTTPS RR CVE-2026-33818 encoding/asn1 unbounded recursion depth CVE-2026-56859 encoding/xml unbounded recursion depth CVE-2026-56858 html/template Javascript regexp context tracking This is not theoretical for this repository. govulncheck against 1.26.5 reports twelve vulnerabilities that our code actually calls, with traces landing in our own files: net/http ReadHeaderTimeout router/router.go:182 Start -> http.Server.ListenAndServe x/net/idna punycode network/http/http_client.go:195 SendHTTPRelay -> Client.Do encoding/asn1 recursion gateway/health_check_executor.go:470 crypto/tls websockets/connection.go:187, reputation/storage/redis.go:66, network/http/http_client.go:195 The first is the public listener, so the affected path is the one carrying production traffic. These are denial-of-service rather than remote execution, but they sit on the request path of an internet-facing gateway. No other file needs to change. CI resolves its toolchain from this directive via go-version-file, and the Dockerfiles build on the floating golang:1.26-alpine tag, which already resolves to 1.26.6. Deliberately NOT pinning the Dockerfiles to an explicit patch version. A floating minor tag picks up 1.26.7 and everything after it automatically, whereas a pin freezes the build on a known-vulnerable toolchain until somebody remembers to move it. The correct pairing is a floating tag for the ceiling and this directive for the floor, so a too-old toolchain is a hard error rather than a silent vulnerable build. Unaffected by this change, and pre-existing: govulncheck also reports golang.org/x/crypto/ openpgp (GO-2026-5932) and two cosmos-sdk x/crisis findings (GO-2023-1881, GO-2023-1821), all with no fixed version. They reach us only through init() chains in the Shannon and cosmos dependency tree, and the x/crisis pair are consensus-module bugs a gateway never exercises. Build, vet and the unit suite pass under the 1.26.6 toolchain.
CGO with the ethereum_secp256k1 (libsecp) build tag was measured on mainnet in June and
rejected: fleet CPU per relay +38%, p999 +45%, threads per pod +48%, success rate down 2.6
points, 5xx roughly doubled. Root cause is upstream poktroll #1822 — cgocall pins an OS
thread, so under this gateway's goroutine concurrency the thread count balloons and
scheduler contention swamps libsecp's faster field representation. Nothing has been deployed
with it since; every running image is the CGO-disabled build.
CI nevertheless kept building it on every push, and kept pushing it to the registry as
sha-<x>-rc-cgo, latest-cgo and the semver -cgo tags. A binary measured as a significant
regression therefore sat in ghcr one deploy-tag typo away from production, which is the more
important half of this change.
Measured cost of the removal, from the timestamps in a real build log:
Install cross toolchains for CGO 4m47.0s (apt gcc-aarch64-linux-gnu + libc6-dev-arm64-cross)
CGO=1 linux/amd64 1m39.5s
CGO=1 linux/arm64 1m43.2s
Build and push Docker image (cgo) 0m16.0s
-------
8m25.7s
The build job drops from 13m53s to roughly 5m27s. Separately, the unit test job stops
running the whole suite a second time under -tags ethereum_secp256k1, which was 7m44s of its
17m54s.
Note the two workflows are independent and main-build does not run on pull requests, so a PR
sees the 7m44s and a push to main sees both. These are not additive into a single
wall-clock number.
Scope is deliberately narrow. This removes the libsecp variant only:
- Dockerfile.race keeps CGO_ENABLED=1. It is a different use — the race detector — and
e2e_test_race is what caught the hedge-path data race. Worth noting the comment there
claiming CGO is required for -race is not accurate on current Go: CGO_ENABLED=0 go build
-race links the shipped TSan .syso and succeeds. Left alone regardless, because that image
builds on alpine/musl and nothing here tested it.
- Dockerfile.local still builds with CGO and the libsecp tag, so local Tilt development runs
a different binary from production. Left for a separate change.
- release_build_cgo and Dockerfile.release.glibc are retained but no longer invoked, so a
future retest stays one command away if the upstream thread-pinning issue is ever fixed.
Dockerfile.release is unaffected: its BINARY_SUFFIX arg defaults to empty, so it consumes
path-linux-<arch> exactly as release_build_nocgo produces it.
release_build_nocgo compiled each platform in sequence: 2m02.5s for linux/amd64 then 2m01.5s for linux/arm64, measured from a real build log. The two share nothing at runtime, so they can overlap. Do not expect a halving. go build already parallelises internally and saturates the available cores, so the gain comes from overlapping each target's largely single-threaded link phase rather than from the compile phase, and it will be smaller on a CPU-starved runner. The honest range on a 4-vCPU runner is somewhere between roughly neutral and a noticeable improvement; the next CI run will say which, since the make target echoes a line per platform and Actions timestamps them. Failure propagation is explicit and hand-rolled because it has to be: `set -e` does not fire for a background job, so without collecting each PID and checking its status this target would report success while a cross-compile had failed, leaving a missing or stale binary in release/ for the image build to package. Verified both directions — a bogus platform makes the target exit non-zero with a clear message, and the valid case still exits 0.
Follow-up to the health-observation change, which inverted the behaviour of a state it did not anticipate. Solana's health checks run two probes, getHealth and getBlockHeight, and they land at different moments. Between them an endpoint holds a block height and no health observation. That state is not rare: every endpoint passes through it after every restart and between check cycles. Before that change, ExtractBlockHeight could not parse a getBlockHeight response, so nothing was stored and an unprobed endpoint stayed ABSENT from the endpoint store — where filterValidEndpoints waves it through as fresh. Once block heights began being stored, the same endpoint became present-but-incomplete, and a nil SolanaGetHealthResponse was fatal. So learning more about an endpoint made it less selectable, which is backwards. Measured on canary within three minutes of the deploy: solana filter rejections went from ~1.1k/s to ~12.6k/s, and the selectable pool halved from 14.4 to 7.2 endpoints. User-facing impact was nil — canary sat at 21.2% solana errors against mainnet's 24.7% — because the least-stale fallback absorbed the smaller pool. The mechanism was wrong even though the outcome looked fine, and the fallback it leaned on ignores the concentration cap. A missing health observation now passes validation. An observation that reports the node unhealthy still rejects, immediately below: that is a measurement, and it fails. This is the same principle already applied to Epoch in the original change — absence of a measurement is not evidence of badness — which simply was not carried across to the health field two lines above it. Note the nil guard is load-bearing for the unhealthy case as well, not only for the removed one. Result is promoted from the embedded *SolanaGetHealthResponse, so reading it without a nil check dereferences a nil pointer. errNoGetHealthObs is removed, since nothing returns it any more, along with its now-dead metric reason mapping. QoSFilterReasonHealthUnknown stays in the metrics vocabulary: splitting that reason out of block_height_unknown is what made this pool collapse legible within minutes of the deploy that caused it, rather than being inferred from the absence of a sibling series. Tests reproduce the intermediate state with mock probe data rather than asserting on the struct: feed only the getBlockHeight probe, then require that selection still returns the endpoint. Revert-checked — restoring the fatal nil case fails Test_PartiallyProbedEndpoint_StaysSelectable, and Test_ObservedUnhealthy_IsStillRejected guards the opposite direction.
getProgramAccounts returns a top-level array and legitimately returns []
whenever its filters match nothing — routine with dataSize/memcmp filters.
It was missing from emptyArrayValidMethods, so every correct empty response
was classified as a broken supplier, retried twice, and returned to the
client as a 500.
Measured on mainnet: 7803 of 7803 jsonrpc_invalid_empty_array detections in
a 60s sample across three gateway pods were getProgramAccounts, and solana
relay success collapsed from 1472/s to 133/s while errors rose to 454/s.
All five solana operators failed at once at nearly identical rates (72-81%)
on both gateway builds, while health checks — which never call
getProgramAccounts — kept succeeding. Independent operators do not fail
identically at the same second; that uniformity is what identified the
classifier rather than the supply as the fault. The endpoints were penalised
for returning correct data, leaving 41 solana endpoints in cooldown.
Also adds getInflationReward, getSlotLeaders and getConfirmedBlocksWithLimit,
which return top-level arrays for the same reason.
getMultipleAccounts and getTokenAccountsByOwner appeared in the same logs but
are deliberately NOT added: both wrap their array in {context, value}, so a
bare "result":[] from them is genuinely malformed and must stay a detection.
Tests assert through Analyze, the entry point the four production call sites
use, not through ProtocolAnalysis. Revert-checked: the new cases fail without
the fix, while the guards covering still-flagged methods and populated results
pass either way.
Note the shape of this bug for the follow-up: the default for an unknown
method is to flag it, so the allowlist must enumerate every array-returning
method across EVM, Solana, Sei, trace and debug namespaces, forever. Anything
missed becomes an incident like this one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An endpoint returning a signature-valid RelayResponse with an empty body passed
every check PATH performs — signature verification, ValidateBasic, and unmarshal.
Only a content heuristic caught it, and its signal never reached the reputation
system intact. One endpoint produced ~800 empty responses in a two-minute sample
across four gateway pods while holding a reputation score of 100.
Three defects, found in that order:
1. The reason string never matched. context.go builds the error as
"heuristic detected %s (method=%s)", so the classifier received
"empty_response (method=getTokenAccountsByOwner)" and compared it with exact
equality. Every exact-match case in classifyHeuristicErrorAsSignal —
empty_response, small_no_result, html_error_page, bad_gateway,
rest_error_field, rest_code_message_error — was therefore unreachable for any
request carrying a JSON-RPC method, and those responses fell through to the
default unknown_payload_error at MINOR. Only the HasPrefix("error_indicator_")
cases survived, because prefix matching tolerates the suffix, which is why the
gap stayed invisible: the surviving cases covered the common errors. The
method suffix is now stripped before matching.
2. empty_response was weighted MINOR (-3), the same as a passing
blockchain_error, while protocol_error is CRITICAL (-25). No RPC type PATH
forwards has a valid zero-length response, and the relay is signed and
settleable regardless of content, so this is a protocol violation and is now
CRITICAL. small_no_result deliberately stays MINOR: a short response missing a
"result" field is ambiguous — a truncated read or a terse upstream error —
unlike an empty body, which has no valid reading.
3. Raising the severity would have created a new false positive. 204, 205 and 304
carry no body by definition, and an empty payload on those was already being
reported as empty_response — harmless at MINOR, a critical penalty for correct
behaviour once weighted as a violation. The heuristic now exempts them, so the
branch only ever sees a promise of content that was not delivered.
Tests assert through classifyErrorAsSignal and Analyze, the entry points the relay
path calls, not through the classifier branches directly — the parse defect is
invisible to a test that constructs the reason by hand. Each change was
revert-checked independently: reverting the parse fix fails 3 tests, reverting
only the severity fails 2, reverting the 204 exemption fails 3 subtests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sync_allowance for solana is 1500 blocks (~10 min), but getHealth returns "ok" only while the node is inside its own --health-check-slot-distance, default 128 slots (~51s). Past that it answers -32005 "Node is behind by N slots", which does not contain "ok", so the check failed at critical (-25). Two rules in the same block therefore disagreed about what "behind" means, and the stricter one always won — which made the configured 1500 unreachable for health-check purposes. Worse, the threshold that decided the bench is set by the node operator, not by us, so the same rule benched different suppliers at different points. getBlockHeight keeps critical_error and sync_check: true. That is the sync gate that honours the configured allowance, and it is measured against a like quantity: PATH's perceived height is blockHeight (418,347,236 observed) and getBlockHeight returns blockHeight, not absoluteSlot (440,308,279) — the two differ by ~22M on Solana because of skipped slots. getHealth at major still catches a hard-down node while a node drifting a few hundred slots, and serving fine, is no longer a critical fault. This also brings solana in line with the file's usual shape (near: status critical, block major); it was one of only 12 of 69 services with every check at critical. Not yet live: gateways fetch these rules from pocket-network-resources, so this takes effect when it is published there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An endpoint returning zero-length payloads at ~0.2% of its traffic held a reputation score of 100 all day. Neither existing mechanism can reach that: - Additive scoring is outvoted by volume. At one violation per 1000 requests the endpoint earns +998 and loses -25, so the score returns to its ceiling no matter how long the behaviour continues. Raising the per-event penalty to FATAL (-50) leaves the sign unchanged. - The critical-rate detector is tuned for "unambiguously broken" (30% of requests), and more fundamentally CriticalRateEWMAAlpha's ~20-request memory cannot represent a sub-1% rate at all — the EWMA can only be 0 or ~0.05 there. No threshold change to that detector could have worked; the quantity is not measurable at that alpha. So this adds a second detector rather than retuning the first. The two measure different things and share no threshold: a 5xx is a transient the network is expected to absorb, while a structurally invalid response is never legitimate at any rate and warrants a threshold three orders of magnitude lower with a correspondingly longer window. InvalidRateEWMAAlpha = 0.001 (~1000-request memory) InvalidRateThreshold = 0.005 (0.5% structurally invalid) InvalidRateMinObservations = 1000 (>= 1/alpha, or the EWMA has not converged) The threshold is sized from production rather than intuition. Measured 2026-08-19 over one hour, fleet-wide: the two offending domains ran 0.216% and 0.065% of all their relays and ~0.85% of the affected service, while every other domain sat at 0.00003% or exactly zero, sustained across 48 hours. 0.5% is far above that noise floor and below the observed offender. Routing is via an explicit Signal.IsProtocolViolation flag set by the producer, following the IsHealthCheck precedent, rather than string-matching on Reason — the reason string carries a "(method=...)" suffix that already defeated exact matching once. Health-check probes are excluded for the same reason they are excluded from the critical-rate detector: a hard bench must reflect what users receive. The escalation counter and metric are kept separate from the critical-rate detector's so a trip of one cannot be misread as the other. path_reputation_invalid_rate_cooldown_total is expected to be near zero fleet-wide. A broad nonzero rate means the threshold is mistuned, not that the fleet degraded; that is the rollback signal and it is stated in the help text. Revert-checked, which caught two defects tests alone did not: - The detector was first nested inside the critical-rate trip branch, so it only ran when that fired — never, at these rates. The tell was RecentInvalidRate pinned at 0 while RecentCriticalRate moved. - Removing the producer flag broke no test, because four of the five new tests are negative assertions that pass trivially when the detector is dead. Added an explicit assertion that the classifier sets the flag. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…es to archival
Geth's path-based state scheme reports unavailable historical state as
"metadata is not found, <block>". Every archival pattern PATH matched was
hash-based-scheme wording ("missing trie node", "state has been pruned"), so a
PBSS node's honest "I do not retain that state" matched nothing at four sites,
each failing differently:
- errorIndicators: the analyzer classified it as jsonrpc_valid_error and
returned ShouldRetry=false, so the request was never re-tried on an endpoint
that retains the state and the client received the -32000 verbatim.
- IsArchivalRelatedError: without it, recognising the error would then
circuit-break the whole domain over what is a capability mismatch.
- capabilityLimitationSubstrings: the same, on the hedge_failed path where the
structured AnalysisResult is lost and only the error string survives.
- EVM archivalErrorIndicators: the check fell through to its "some other error"
branch and returned an error rather than false, so the endpoint that had just
failed an archival query was never demoted out of the archival pool that kept
sending them.
The deeper defect is why an archival request reached a pruned node at all.
EVMDataExtractor.IsArchival returned true for any successful response to
eth_getBalance / eth_call / eth_getCode / eth_getStorageAt /
eth_getTransactionCount, never reading the block parameter. Those are also the
ordinary way to read current state -- eth_getBalance(addr, "latest") is among
the most common calls on the network -- and a pruned node answers them
perfectly, so it was marked archival and cached for 8h. The archival pool was
polluted by construction.
targetsHistoricalBlock now gates the true return on the request naming a
historical block. An omitted parameter (which clients default to "latest"), the
latest/pending/safe/finalized tags, block hashes and EIP-1898 objects are
inconclusive; "earliest" and numeric hex still prove archival.
Known limitation, documented at the call site: the DataExtractor interface
carries only (request, response), not the perceived chain tip, so a numeric
block a few blocks behind the tip still reads as archival. The tag case was the
whole of the pollution in practice.
TestEVMDataExtractor_IsArchival asserted archival=true on a request carrying no
params at all -- the exact shape a pruned node answers -- so it encoded the bug.
Its request now names a historical block; the error cases are unchanged because
the error branch runs before the gate.
Each of the five changes was revert-checked individually.
Note for review: this shrinks the archival pool, which is the intent, but a
service leaning on falsely-promoted endpoints will start failing archival
requests at selection rather than at the endpoint. Watch
path_qos_filter_rejection_total and the archival counts in
/ready/<service>?detailed=true after deploy.
…etectors
Both volume-independent rate detectors are wrapped in `if !signal.IsHealthCheck`
so a probe can never bench an endpoint on its own: a strict or flaky check can
fail an endpoint that serves user reads perfectly, and must not be able to cool
it out of rotation. That guard was intact. The stamp was not.
Only the health-check executor's own three RecordSignal call sites ever set the
flag. A probe ALSO reaches reputation through the protocol layer, twice — once
from the relay itself via requestContext, and once from Apply*Observations on
the same relay's observations — and neither stamped it. The field doc on
requestContext.isHealthCheck said outright that the flag "does not affect
reputation signals or observations", so the omission read as deliberate.
The result is a self-sustaining loop rather than a one-off penalty. A benched
endpoint receives no user traffic, so probes become its only signal, so its rate
EWMAs are entirely probe-derived, so it re-benches itself on the next probe
failure. Measured on canary 2026-08-20 against a control environment running the
previous build: every solana endpoint tripping roughly twice an hour against a
~12-endpoint pool, with the pool-collapse guard firing 19.3x the control to keep
the service served at all. Ten services tripped the invalid-rate detector where
the threshold was sized for one. Fleet success rate was unaffected in both
environments — the guard was absorbing it, which is why nothing alerted.
Corroborating: over the same 14 hours, zero user-traffic relays fleet-wide were
recorded with a payload-heuristic fault (path_relays_total{status_code=
"heuristic_error"} = 0, and status_code="200" with reputation_signal=
"critical_error" is 100% request_type="health_check"), while the detector fired
2,867 times.
Stamped at every site that records a signal for a relay the executor issued:
the HTTP relay success, error and latency-penalty paths, plus the HTTP and
WebSocket observation paths. Apply{HTTP,WebSocket}Observations take the verdict
as a required parameter rather than defaulting it, so each of the two callers
has to state which it is at compile time — the receiving protocol layer cannot
tell synthetic observations from real ones.
Scope is deliberately narrow: only the rate detectors exclude probes. A probe
result still moves the additive score, which is how a benched endpoint recovers
when it is receiving no user traffic, and it is still counted in SuccessCount /
ErrorCount so no rate's denominator changes shape.
Tests assert on the signal reputation RECEIVES, from the production caller, not
on the flag the caller set — the flag was already true and told us nothing. Each
of the five call sites was revert-checked: reverting it alone fails a test.
Score.InvalidRateCooldownCount is documented as "kept separate from RateCooldownCount so the two detectors escalate independently and a trip of one cannot be misread as a trip of the other". The counters were separate; the timestamp they escalated against was not. Both compared against the shared Score.CooldownUntil, which the strike system also writes, so the intent in that comment was never implemented. Note the sign: time.Since() on a cooldown still in force is negative, hence always below DefaultMaxCooldown. Any bench in force, from any mechanism, made the next trip of either detector read as consecutive. On a churn-heavy service — exactly the population these detectors exist for — an endpoint whose own history had long since aged out resumed its stale count instead of resetting, and a first offence was benched at the escalated duration immediately. Each detector now records the end of the cooldown it set (RateCooldownUntil, InvalidRateCooldownUntil) and escalates against that. CooldownUntil is unchanged and remains the only field selection reads; these two are escalation arithmetic only, never a second gate. Both persist to Redis so escalation survives a restart and is consistent across replicas; absent on older records, which leaves them zero and costs at most one non-escalated trip. The first version of the test for this passed against a revert, because the scenario it set up did not discriminate: incrementing a zero counter yields 1, the same value a correct reset yields. It needed a stale non-zero history plus a foreign bench in force, which is the shape production actually reaches. Asserted on the bench duration the endpoint receives, not on the counter.
Measured against production by sending a deep historical block to endpoints PATH had marked archival, then reading what came back. Two live wordings both missed every entry in archivalErrorIndicators by a single word: gnosis: "historical state is not available" poly: "historical state <hash>" "state not available" does not match "state IS not available", and "historical data" does not match "historical STATE". Both therefore fell through to the "some other error" branch, which returns an error rather than false, so the endpoint that had just failed an archival query was never demoted out of the archival pool and kept receiving them. Identical failure to the geth PBSS entry added in 54659fb, on a different vendor's wording. The bare "historical state" prefix covers both observed forms. It is already present in qos/heuristic/indicators.go, where this error IS recognised — the two catalogues had drifted apart, so this realigns them. Found by probing rather than by reading: three archival-marked gnosis endpoints returned this error for a block 27M deep while answering `latest` correctly, so they were pruned nodes sitting in the archival pool. The test is table-driven because the discriminating detail is the exact string; a single case would pass on a pattern covering only one of the two wordings. Revert-checked — removing the entry fails both cases.
The two sources of archival status had drifted 16x apart, in the direction that does the most damage: health-check mark 30m gateway/health_check_executor.go user-traffic mark 8h qos/evm/qos.go The health-check path pins an exact expected historical value in the rules file, so a node that ignores the block parameter and answers from current state fails it. That verified mark expired in 30 minutes. The user-traffic path cannot pin a value — the query is whatever a client happened to send — so it grants archival status on any successful call to an archival method. 54659fb tightened it to require that the request targeted a historical block, but it still trusts a successful response, which is exactly what a fabricating node always produces. That unverified mark lasted 8 hours. Measured in production: four endpoints on one operator were marked archival while returning current state for every block asked, including one 256x past the chain tip. The archival health-check rule for the service they served had been deleted for failing every endpoint — which was the rule working correctly, that service has no archival nodes — leaving only the unverified 8h path to promote them. Both paths now share gateway.ArchivalStatusTTL, so they cannot drift again. The old comment on the 8h constant claimed it "matches health check archival TTL", which is probably why nobody noticed; a comment cannot hold this invariant. Bootstrapping is unaffected: promotion still happens through requests naming a numeric block within the archival-required threshold, which route freely rather than being filtered to already-archival endpoints. The test asserts the stored expiry through UpdateFromExtractedData rather than comparing constants, so re-hardcoding a duration at the call site fails it. Revert-checked.
Five issues, all from ad729c3, all failing `Run linter` on the PR: - `errInvalidGetEpochInfoEpochZeroObs` was declared and never used. Vestigial: ValidateEndpoint's own comment explains that epoch 0 is deliberately NOT fatal — the health-check path builds a SolanaGetEpochInfoResponse carrying only a block height, so its Epoch is 0 by construction, and rejecting on that would re-create the trap the same commit had just closed. The check was removed on purpose; the error string was left behind. - Four redundant embedded-field selectors in health_observation_test.go (`q.ServiceState.ValidateEndpoint`, `q.ServiceState.perceivedEpoch`, `stored.SolanaGetEpochInfoResponse.Epoch`). Promotion makes the qualifier unnecessary and the compiler rejects it if it were ambiguous, so the shorter form cannot change which field is read. No behaviour change. `golangci-lint run --timeout 5m --build-tags test` over the whole repo is clean, and qos/solana tests pass.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #527. The branch grew past its original scope: it now carries the F5/F6
cardinality follow-up plus four other lines of work that were deployed and validated
together over the last two weeks.
Every commit here is live on canary and mainnet as
sha-064bc62-rcand has beenvalidated in production. That is the main argument for reviewing it as one unit —
these commits were never exercised separately, so splitting them would put untested
combinations into
main.1. Metric cardinality (the branch's original purpose)
e3f5c7e2— bound histogram labels, removesupplierfrom aggregate metrics.Two rounds of a production cardinality incident converged on one rule: only a label's
value set bounds it. A sanitizer bounds a value's shape, never the set. A cardinality
guard bounds the live registry, never the number of distinct series Prometheus retains —
path_supplier_signal_totalsat at 26% of its cap while being one of the two largestseries sources in the job (6,523 tuples live in 10 minutes against 60,674 distinct over one
pod's 7.7h life).
A label on a histogram costs roughly 12× what it costs on the counter beside it.
path_relay_latency_seconds_bucketwas 31.6% of all gateway series because it carriedstatus_code×reputation_signal, a 20× pair no dashboard ever queried from thehistogram. Outcome taxonomy now lives on the counter; the histogram keeps topology labels.
Per-supplier questions are served by
GET /ready/<service>?detailed=true— a point lookupinstead of ~74K retained timeseries. Three metrics keep
supplierdeliberately, where theaddress is the actionable payload rather than a way of naming an operator.
Test_SupplierLabelIsGoneenforces the rest.2. Solana sync allowance and health checks
2304bc9b2d49d8e5ad729c3f5c1c8d600e856cc4068ff990Solana's
ValidateEndpointhad no sync allowance at all —sync_allowance: 750wasconfigured but never implemented. During the 2026-08-18 surge (200 → 2500 rps, ~2.5
blocks/s) only the freshest endpoint stayed valid, producing near-total lock-in on one
operator while others sat at score 100 with no traffic. Health checks cannot correct this;
they run at the same per-endpoint rate on every operator.
Also here: kava CometBFT checks routed to
comet_bft, slower xrplevm websocket probes, amissing solana health observation no longer scored as a fault, and relays that never
received an HTTP status no longer counted as successes.
3. Go, dependencies, CI
d3e0273ce6e5530efa146acdc10e5be6— poktroll v0.1.35, Go directive to 1.26.6 forthe stdlib security fixes, drop the libsecp CGO variant, build release platforms
concurrently.
The Go bump takes
govulncheckfrom 12 reachable findings to 6, all of which areFixed in: N/A(three withdrawn lib/pq advisories, openpgp, two cosmos x/crisis init-only).The
net/httpCVE was genuinely reachable viarouter.goListenAndServe. The DockerfileGo patch is deliberately left floating so future patches are picked up; the directive sets
the floor.
4. Heuristic and reputation detectors
dd710d384931c6f46ef6ca1a22e9dff1An endpoint returning zero-length payloads at ~0.2% of its traffic held a reputation score
of 100 all day, and neither existing mechanism could reach it:
earns +998 and loses −25, so the score returns to its ceiling however long the behaviour
continues. Raising the per-event penalty to FATAL (−50) does not change the sign.
fundamentally
CriticalRateEWMAAlpha's ~20-request memory cannot represent a sub-1%rate at all — the EWMA can only be 0 or ~0.05 there. No threshold change to that
detector could have worked.
So
22e9dff1adds a second detector rather than retuning the first, with a much longerwindow (alpha 0.001, ≈1000 requests) and a threshold three orders of magnitude lower.
Two classifier bugs fixed alongside it: a
"(method=X)"suffix made every exact-match casein
classifyHeuristicErrorAsSignalunreachable, so an empty payload degraded tounknown_payload_error(MINOR) — only theHasPrefix("error_indicator_")case survived,which is why it hid. And
getProgramAccountsreturning an empty array is a valid success,not a fault.
5. Archival promotion and health-check contamination (today)
54659fb6— geth PBSS pruned stateGeth's path-based state scheme reports
metadata is not found, <block>. Every archivalpattern in PATH used hash-based-scheme wording (
missing trie node,state has been pruned), so a PBSS node's honest "I do not retain that state" matched nothing, at fourseparate sites.
The deeper defect:
IsArchivalreturned true for any successfuleth_getBalance/eth_call/eth_getCode/eth_getStorageAt/eth_getTransactionCountwithoutreading the block parameter. Those are also the ordinary way to read current state, and a
pruned node answers them perfectly — so the archival pool was polluted by construction and
marked archival for 8h.
targetsHistoricalBlocknow gates the promotion.Known residual: the
DataExtractorinterface carries no perceived chain tip, so a numericblock a few blocks back still reads as archival. Closing that needs an interface change
across all four extractors.
d8f4c3c1— health-check probes were feeding both rate detectorsBoth volume-independent rate detectors are wrapped in
if !signal.IsHealthCheck, so aprobe cannot bench an endpoint on its own — a strict or flaky check must not cool an
endpoint that serves user reads perfectly. That guard was intact. The stamp was not.
IsHealthCheckwas set at only three call sites, all in the health-check executor. Oneprobe also reaches reputation through the protocol layer twice more — the relay itself via
requestContext, andApply{HTTP,WebSocket}Observationson that same relay's observations— and neither stamped it. The field doc on
requestContext.isHealthCheckstated outrightthat it "does not affect reputation signals or observations", which is why the omission
read as deliberate.
The result is a self-sustaining loop rather than a one-off penalty: a benched endpoint
receives no user traffic, so probes become its only signal, so its rate EWMAs are entirely
probe-derived, so it re-benches itself on the next probe failure.
This predates the new detector. The critical-rate detector has been contaminated since it
shipped;
22e9dff1only made it visible by tripping at a much lower threshold.Apply{HTTP,WebSocket}Observationsnow takeisHealthCheckas a required parameter ratherthan defaulting it — the receiving protocol layer cannot distinguish synthetic observations
from real ones, so each caller states it at compile time.
Scope is narrow on purpose: only the rate detectors exclude probes. A probe still moves the
additive score — that is how a benched endpoint recovers when it receives no user traffic —
and still increments the counters, so no rate's denominator changes shape.
TestHealthCheckSignals_StillMoveTheAdditiveScoreguards that.064bc628— each rate cooldown escalates against its own historyScore.InvalidRateCooldownCountis documented as kept separate fromRateCooldownCountsothe two detectors escalate independently. The counters were separate; the timestamp they
escalated against was not — both compared against the shared
Score.CooldownUntil, whichthe strike system also writes.
Note the sign:
time.Since()on a cooldown still in force is negative, hence always belowDefaultMaxCooldown. Any bench in force, from any mechanism, made the next trip of eitherdetector read as consecutive.
Each detector now records the end of the cooldown it set and escalates against that.
CooldownUntilis unchanged and remains the only field selection reads.b395d183— the "historical state" pruned-state wordingsFound by probing rather than by reading. Sending a block 27M deep to endpoints PATH had
marked archival returned two wordings that missed every pattern in
archivalErrorIndicatorsby a single word:Both fell through to the "some other error" branch, which returns an error rather than
false, so an endpoint that had just failed an archival query was never demoted out of the
archival pool. The bare
"historical state"prefix covers both, and is already present inqos/heuristic/indicators.go— the two catalogues had drifted, so this realigns them.31617122— an unverified archival mark no longer outlives a verified oneThe two sources of archival status had drifted 16× apart, in the damaging direction:
The health-check path pins an exact expected historical value in the rules file, so a node
that ignores the block parameter and answers from current state fails it. The user-traffic
path cannot pin a value — the query is whatever a client sent — so it grants archival status
on any successful archival-method call, and
54659fb6notwithstanding it still trusts asuccessful response, which is what a fabricating node always produces.
Measured in production: four endpoints on one operator were marked archival while returning
current state for every block asked, including one 256× past the chain tip. The archival
health-check rule for the service they served had been deleted for failing every endpoint —
which was the rule working correctly, that service has no archival nodes — leaving only the
unverified 8h path to promote them.
Both paths now share
gateway.ArchivalStatusTTL. The old comment on the 8h constant claimedit "matches health check archival TTL"; it did not, and the false comment is probably why
the drift went unnoticed. The test reads the stored expiry back through
UpdateFromExtractedDatarather than comparing constants, so re-hardcoding a duration atthe call site fails it.
Bootstrapping is unaffected: promotion still happens via requests naming a numeric block
within the archival-required threshold, which route freely rather than being filtered to
already-archival endpoints.
Production validation
Deployed to canary at 07:18 UTC and mainnet at 08:57 UTC on 2026-08-20. Because the two
environments flipped at different times, the same metric collapsing twice — each time
following the build — rules out pod age and traffic composition.
rate_cooldown_totalrate_cooldown_totalpool_collapse_guard{solana}on canaryGuardrails flat throughout, judged against 6h ranges rather than point readings: fleet
success 95.7% mean (min 92.5, max 97.3), solana pool size unchanged, no cooldown spike.
Redis confirms the escalation fix directly. Mainnet DB2 went from 0 of 5,901 keys
carrying
rate_cooldown_untilto 292 of 297 sampled within five minutes of the flip, andtwo solana endpoints that tripped post-deploy each recorded
invalid_rate_cooldown_count = 1with their own timestamp — a first offence, unescalated. For contrast, the pre-flipdistribution had 84 keys above zero with 35 at ≥ 6, i.e. benched the full hour every
time, topping out at 98.
Archival counts from
/ready/<svc>?detailed=truemoved as intended. The old build pinnedfive services at exactly 100% — every endpoint of every operator archival, which is not a
plausible ground truth. The fixed build discriminates: on gnosis it keeps
qspider.comat12/12 while dropping two other operators to 0/17 and 4/17, where the old build had all
three at 100%.
Testing
go build,go vetandgolangci-lintclean. Unit tests pass;reputation/storageneedsDocker for testcontainers-Redis and fails without it.
Every call site in the two reputation fixes was revert-checked individually — seven reverts,
seven confirmed test failures. Two traps found while writing those tests, both recorded in
comments:
in force does not discriminate, because incrementing a zero counter yields 1 — exactly
what a correct reset yields. The discriminating case needs stale non-zero history and a
foreign bench.
runAtRate(4000, 100)trips the detector five times, not once: a trip resets the EWMA andthe loop continues. Any first-offence assertion has to drive one signal at a time and stop
at the first trip. The pre-existing test never noticed because it only asserted
IsInCooldown().Tests assert on the signal reputation receives, from the production caller, rather than on
the flag the caller set — the flag was already true and proved nothing.
CI note: the
xrplevmHTTP E2E check has been failing on this repo independently of thisbranch. Please confirm it also fails on
mainbefore treating it as a blocker here.Known open items
moonbeam_archivalwas deleted from the rules file on 2026-08-07 for failing 100% ofruns — which was the rule working correctly — and the single endpoint still marked
archival there is one that ignores the block parameter. Impact is small: moonbeam draws
1.7–3.5
archival_requiredrejections/s against poly's 761/s.check, including the
targetsHistoricalBlockgate added in54659fb6, which verifiesthe request named a historical block and then trusts a successful response. Measured on
one operator across two services: identical balances at block 1, block 5,000,000 and
block 4,294,967,295. A design for detecting this is written up in
DESIGN_NEGATIVE_HEALTH_CHECK.mdand deliberately parked — the two commits abovecover most of the exposure without a new check type. The cheapest future detector is
eth_getBlockByNumber(H)assertingresult.number == H, which is self-verifying andneeds no external reference.
status comes exclusively from the unverified user-traffic path.
poly_archivalandxrplevm_archivalassertexpected_response_contains: "0x0", asubstring matching a large share of hex values. Weak, and in the external rules file
rather than this repo.
InvalidRateThreshold = 0.005was sized from one hour of data with a contaminateddenominator. Now that the denominator is honest it fires ~31/hour on solana, correctly
confined but not near-silent. Worth re-deriving from the real per-key rate distribution.
context.goand once via theobservation path. Pre-existing double-count, not addressed here.