Repository navigation
[kvstore] Detect and route around stalled BigTable channels - #28441
Merged
Merged
Conversation
nickvikeras
force-pushed
the
nickv/bt-channel-health
branch
12 times, most recently
from
October 24, 2026 19:31
4f6a8ab to
3a23330
Compare
8 tasks
nickvikeras
force-pushed
the
nickv/bt-channel-health
branch
3 times, most recently
from
October 26, 2026 10:48
49cfda6 to
04d9d8b
Compare
nickvikeras
force-pushed
the
nickv/bt-channel-health
branch
from
October 26, 2026 13:14
04d9d8b to
f48d6f2
Compare
nickvikeras
marked this pull request as ready for review
October 26, 2026 13:18
bmwill
approved these changes
Oct 27, 2026
8 tasks
nickvikeras
added a commit
that referenced
this pull request
Oct 28, 2026
## Description Cherry-picks the following kvstore / kv-rpc / indexer changes from `main` to the 1.83 release branch (in order, via `git cherry-pick -x`): - #28426 ledger-history: end pages resumed past their window with the window's reason - #28414 [kvstore] Add opt-in object tombstones to the objects table - #28443 rpc-store: write each checkpoint as soon as it is next in order - #28444 indexer-alt-framework, kvstore: write batches and watermarks as soon as they are ready - #28441 [kvstore] Detect and route around stalled BigTable channels - #28447 [kvstore] Add an opt-in gRPC server with a catch-up readiness health check - #28451 [kv-rpc] Optionally set the served checkpoint from kvstore indexers' watermark streams #28451 was not yet merged to `main` when this branch was created; the pick is of its head commit `c55e29b30db`. All picks applied without conflicts. ## Test plan `cargo check --locked --all-targets` on every touched crate (`sui-kvstore`, `sui-kv-rpc`, `sui-rpc-store`, `sui-rpc-api`, `sui-indexer-alt-framework`, `sui-indexer-alt`, `sui-indexer-alt-consistent-store`, `sui-indexer-alt-e2e-tests`, `sui-analytics-indexer`, `sui-checkpoint-blob-indexer`). ## Release notes - [ ] Protocol: - [ ] Nodes (Validators and Full nodes): - [ ] gRPC: - [ ] JSON-RPC: - [ ] GraphQL: - [ ] CLI: - [ ] Rust SDK: - [ ] Indexing Framework:
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.
Description
kv-rpc (mainnet
sui-kv-rpc, which serves GraphQL's ledger reads from BigTable) had two client-side problems talking to BigTable. Both could leave many reads waiting on one stuck HTTP/2 connection:ListEvents/ListTransactionsopen bitmap-index scans and read them lazily, and data BigTable has sent but kv-rpc hasn't read counts against the connection's window. A few such scans fill it; after that nothing on the connection, the watermark refresh included, receives data until those requests hit their 30 s deadline. This is the likely mechanism of the 30 s freezes before this PR: they began with list requests and ended at the 30 s list deadline. Spreading reads over more connections already shrank the worst lag to 7.7 s; the larger windows remove the starvation itself.What this PR changes:
The design follows Google's own BigTable clients. Pool sizing and its defaults (10 initial channels, 1–25 requests per channel, resize by at most 2 channels a minute, at most 200) match the Java client's channel pool. Least-loaded selection with a 5 s penalty after
Unavailable,ResourceExhaustedorInternalcomes from the Go client, with a weight of 25 instead of 10 (see design details). The health check reuses ideas from the Java client'sChannelPoolHealthChecker: aPingAndWarmprobe, replacing only a channel that stands out from the rest, doing nothing when most of the pool looks bad, and rate-limited replacement. The difference is speed. Java probes each channel every 30 s, evicts one only after at least 60% of its probes fail within 5 minutes, and lets its open requests run on. That can't end a 30 s freeze, so this pool probes as soon as a caller has waited 1 s and fails the stuck requests so they retry on other channels.flowchart TB subgraph before["Before: one connection per pod, most of the time"] direction LR r1["170–700 reads<br/>in flight"] --> c1["1 HTTP/2 connection<br/>5 MiB receive window"] c1 -. "connection stalls, or unread<br/>list scans fill the window" .-> x1["every read on the pod<br/>waits, up to 30 s"] end subgraph after["After"] direction LR r2["reads"] --> p2["pool picks the least-loaded<br/>healthy channel"] p2 --> c2["10–22 connections per pod<br/>256 MiB receive window each"] h2["health check: silent channel<br/>→ probe → quarantine;<br/>its reads retry elsewhere"] -.-> p2 end before ~~~ afterMainnet results
The 1.82 backport (#28445) runs on mainnet
sui-kv-rpc(5 pods, ~1,340 requests/s). The pool changes went out 2026-10-24 20:29Z; the window sizes (set through config, the same values this PR makes the default) went out 2026-10-26 04:02Z. Apart from this change, the "before" build lacks only a version bump, a consensus change and arustls-webpki/webpki-rootslockfile bump for cargo-deny advisories.Unavailableresponses per millionInternalresponses per millionrequest deadline exceededlog linesThe strong comparison is before vs pool fix: at the old rate of 3.6 pod-minutes per day with lag over 10 s, 31 h should have produced about 5, and there were none. The windows column covers only 8.5 h, too short to confirm its zeros on its own (the pool-fix rate predicts about 2 pod-minutes with lag over 3 s in that time). The periods also fall on different days (weekday, weekend, Monday morning), but the request rate was the same in all three, and p99 dropped within ten minutes of the pool deploy: 189–442 ms in 10-minute windows over the 3 h before, 50–88 ms over the 3 h after.
Every major method got faster at the same request rate: p99 dropped 2–5× and p99.9 2–7× (before → with windows):
BatchGetObjectsBatchGetTransactionsGetCheckpointListEventsListTransactionsBigTable itself did not get faster; kv-rpc stopped waiting on its own connection. Server-side p99 per table is unchanged, while the time kv-rpc waits for the same reads dropped to near it:
checkpointstransactionstx_seq_digestobjectswatermark_alt"BigTable reads over 10 s" are server-side durations, which include time BigTable spent waiting for kv-rpc to accept data. Before the fix, point reads whose server p99 is 20–70 ms (
transactions: 438,objects: 112) took over 10 s on the server, consistent with BigTable waiting for kv-rpc to read.Unavailableresponses are monotonic-read misses ("monotonic read at checkpoint N not available on this replica; retry"): the caller has seen a newer checkpoint than this pod serves. Before the fix they came from frozen watermarks. Afterwards they came in a few bursts of a few seconds: one in the pool-fix build, on a pod where the health check quarantined a channel within two minutes, and two in the windows build, on one or two pods, with no channel suspicions, gap timeouts or slow watermark reads at those times.In 40 h with the new pool, 4 channels were quarantined; all 4 passed a later probe and were restored, so none needed replacing. 17 reads cancelled by quarantine and 5 that hit the gap timeout were retried on other channels. No quarantines or gap timeouts in the 8.5 h since the window change.
%%{init: {"xyChart": {"width": 620, "height": 300}, "themeVariables": {"xyChart": {"plotColorPalette": "#d62728"}}}}%% xychart-beta title "Worst kv-rpc watermark lag in each hour" x-axis "October 2026, UTC" 21 --> 26.5 y-axis "seconds" 0 --> 30 line [1.3, 1.2, 1.7, 1.0, 1.3, 25.7, 5.7, 28.6, 1.3, 17.4, 2.1, 10.6, 2.1, 1.6, 26.5, 3.4, 8.2, 1.0, 1.8, 8.5, 1.1, 1.1, 1.6, 1.3, 19.2, 1.2, 1.9, 0.8, 0.9, 0.8, 4.6, 1.1, 0.8, 6.1, 0.9, 0.9, 1.2, 2.0, 9.4, 3.1, 1.1, 1.0, 0.8, 0.9, 0.9, 0.8, 0.9, 1.0, 1.4, 1.1, 0.9, 0.8, 0.8, 1.2, 1.0, 0.9, 1.2, 0.9, 0.9, 1.0, 0.8, 1.2, 2.3, 0.9, 5.0, 0.9, 15.6, 2.2, 1.1, 0.6, 6.8, 0.7, 1.1, 1.0, 0.7, 1.6, 2.0, 1.0, 1.2, 0.5, 0.9, 0.6, 0.7, 0.7, 0.8, 10.9, 7.4, 29.6, 20.3, 1.1, 0.9, 23.2, 15.8, 4.0, 1.0, 3.1, 7.7, 1.1, 0.9, 0.7, 0.8, 7.7, 1.1, 0.7, 1.1, 1.1, 0.7, 4.9, 0.7, 1.4, 0.7, 0.9, 1.9, 1.0, 0.7, 0.8, 1.2, 1.9, 1.0, 2.0, 7.6, 2.9, 0.8, 0.8, 0.9, 0.9, 0.9, 0.8, 0.8, 0.7, 0.5, 0.9]%%{init: {"xyChart": {"width": 620, "height": 300}, "themeVariables": {"xyChart": {"plotColorPalette": "#d62728"}}}}%% xychart-beta title "kv-rpc p99 request latency, hourly" x-axis "October 2026, UTC" 21 --> 26.5 y-axis "ms" 0 --> 450 line [227, 235, 344, 377, 217, 375, 186, 236, 249, 152, 110, 192, 182, 266, 240, 200, 319, 229, 192, 204, 224, 196, 210, 210, 291, 245, 138, 116, 173, 214, 214, 237, 198, 172, 196, 202, 239, 355, 350, 297, 246, 222, 248, 232, 298, 352, 235, 308, 275, 292, 356, 215, 212, 246, 166, 199, 215, 206, 209, 196, 234, 243, 337, 229, 256, 217, 224, 334, 305, 249, 247, 231, 335, 238, 228, 228, 228, 182, 211, 402, 242, 243, 175, 249, 193, 225, 221, 289, 248, 338, 247, 245, 186, 74, 61, 74, 64, 74, 63, 77, 64, 64, 63, 76, 75, 54, 60, 69, 57, 77, 73, 73, 71, 95, 70, 79, 81, 81, 82, 87, 78, 68, 78, 77, 72, 75, 72, 74, 83, 74, 68, 85]Deploys on the x-axis: pool fix at 24.85 (10-24 20:29Z), windows at 26.17 (10-26 04:02Z).
Cost and risk
Settings
New settings (kv-rpc / kvstore indexer):
bigtable-channel-health-check/bigtable-pool.channel-health-check(defaulttrue)bigtable-read-rows-gap-timeout-ms(default5000, must be above 0) andbigtable-read-rows-max-attempts(default3, at least 1;1turns off the gap timeout and retries), same names in both.bigtable-channel-timeout-ms(default60000) still ends every attempt's wait for response headers, so a stall before the headers is retried only while it is longer than the gap timeout; startup logs a warning when it isn't.bigtable-http2-stream-window-bytes/bigtable-http2-connection-window-bytes(kv-rpc) andbigtable-pool.http2-stream-window-bytes/bigtable-pool.http2-connection-window-bytes(indexer), defaults262144(256 KiB) and268435456(256 MiB). Startup rejects values outside 1..=2^31-1, which h2 would otherwise panic on.Retired setting:
bigtable-pool.downscale-thresholdis ignored and logs a deprecation warning, the waycheckpoint-buffer-sizedoes.Design details: selection penalty, quarantine and retry rules
The pool now keeps at least 4 channels and sizes itself on the interval's peak in-flight count, with a request counted until its response body ends. It picks the least-loaded channel. For 5s after
Unavailable,ResourceExhausted,Internal(a gRPC status, or a bare HTTP 400, 429, 502, 503 or 504), a connection failure, or a ReadRows gap timeout, a channel counts asmax-rpcs-per-channel(25) requests busier. Those are the Go client's penalty codes and duration with a larger weight: a channel that fails fast never has anything in flight, so Go's flat +10 still lets it win most requests once the other channels each have more than 10 in flight, which the 1–25 load band allows. With +25, inside the band it gets requests only once every other channel carries 25 more; above 25 per channel, until a resize adds channels, it can still take more than round-robin's 1/N. (A steady-state model with 4 channels, 50ms successes, 2ms failures and a fixed arrival rate gives the failing channel 62% of requests with +10 and 22% with +25 at 25 in flight per channel, and 45% with +25 at 37.5 per channel, against 25% under main's round-robin. Other assumptions move these figures a lot; holding the healthy channels at 25 in flight gives about 84% with +10.) It quarantines a channel where a waiting caller gets nothing for 1s while other channels keep receiving responses. One waiting caller is enough, not the 2 first planned: with fewer requests in flight than channels, a stalled channel rarely collects a second, and the probe is what keeps a merely slow request from quarantining its channel. Callers busy with data they already have don't count, so a slow consumer doesn't by itself make a channel look stalled (its unread data can still fill the connection's flow-control window and starve the callers that are waiting). A quarantine needs aPingAndWarmprobe that times out or fails on the connection; any status BigTable sends back, evenPermissionDenied, shows the connection works. Quarantine fails the channel's open requests locally with a retryableUnavailable. ReadRows applies a per-message gap timeout and resumes on another channel after the last delivered row. It also retries, after a short jittered backoff,Unavailable, streams BigTable refuses with a GOAWAY before response headers, streams whose connection fails underneath them (h2 I/O errors, which tonic reports asUnknown), and requests hyper drops unsent because their connection closed (tonic reports those asCancelled; this is the "not sent" case of gRFC A6 transparent retries, which the Rust SDK client handles the same way). A GOAWAY after response headers is still not retried: hyper ends the body cleanly, and tonic reportsUnknown: missing grpc-status trailerwith nothing that identifies the GOAWAY. ReadRows had no retries before this change. The Java and Go clients also retryDeadlineExceeded,Aborted, and anInternalfrom RST_STREAM; this hotfix doesn't, so it retries only failures that point at a channel or its connection. Mass cancellation relies on thenickvikeras/h2pin (hyperium/h2#983).Safeguards: the pool never quarantines more than half its channels (a wider stall points at BigTable or the network). It replaces at most one unhealthy channel per minute, and the replacement must pass a probe before the old channel is dropped. A repeatedly quarantined channel is only replaced when it stands out from the rest of the pool. A channel that a resize or refresh drops while its probe runs neither counts toward the half-pool cap nor takes the replacement slot, though a failed probe still fails the requests left on it. A probe fetches credentials before its deadline for the connection starts; one whose token fetch fails or times out (say, the metadata server is down at token refresh) never reaches the connection, so it neither quarantines nor restores a channel. The final ReadRows attempt has no gap timeout, so reads that are slow on the server still complete. A quarantine cancellation is not reported to batch-write flow control as overload.
How the mainnet numbers were measured
e10772f4); pool fix = 10-24 20:45Z to 10-26 03:55Z (24e593ec); windows = 10-26 04:10Z to 12:30Z (f9d8bfec). Rollouts are excluded. Error counts use each build's full lifetime from raw counters, so they include pod startup.rpc_request_latencyhistogram over the whole period; quantiles are interpolated within histogram buckets.kv_rpc_served_checkpoint_timestamp_ms) minus the age of the indexer's watermark (lowest pipeline, highest writer), sampled once a minute. Freezes shorter than a minute can be missed.bigtable.googleapis.com/server/latenciesforReadRows. kv-rpc's wait:kv_get_latency_msforcheckpoints,objectsandwatermark_alt, andkv_get_stream_poll_wait_msfortransactionsandtx_seq_digest.{namespace="rpc-kv-mainnet"}restricted to each build's pods, over the periods above.Test plan
New tests use a mock BigTable server and a hand-rolled HTTP/2 server; the list is collapsed below. Mainnet behaviour is under "Mainnet results".
Test list
GOAWAY(ENHANCE_YOUR_CALM, "too_many_internal_resets"). To check, delete theh2line under[patch.crates-io]and runcargo nextest run -p sui-kvstore -E 'test(mass_cancel) | test(late_headers) | test(dropping_most)'.rows_limit(asserts the retried request shape, plus unit cases for open, closed and empty bounds, several ranges, keys mixed with ranges); reads already fully delivered send no retry, so no empty row set reaches BigTable. A header stall and a body stall each trip the gap timeout; the retry avoids the failed channel even when it is the least loaded, the abandoned attempt leaves nothing in flight or waiting, and the channel carries the error penalty. The final attempt waits out a read slower than the gap timeout. A graceful GOAWAY on an unprocessed stream is retried elsewhere, and so are reads cut off when their connection closes, open (h2 I/O error) or queued in hyper (never sent).Unavailablegets 1 of 10 sequential requests, not 5, and the penalty ends after 5s. A penalized channel gets requests only once the other channels each carrymax_rpcs_per_channelmore, and a bare HTTP 503 penalizes a channel likeUnavailable. Peak-based resize holds inside the band and at the floor, and the next interval's peak starts from the requests still open. The circuit breaker blocks quarantine when 3 of 4 channels stall, and a silent channel isn't probed while no other channel is receiving responses. Unpolled streams never make a channel suspect, while waiting ones do, and a probe that answers keeps it in service; an error status counts as an answer, a probe cut off by its connection closing does not. While the client's token fetch fails, or hangs past the probe timeout, a stalled suspect stays in service and a healthy quarantined channel stays out; once credentials work, the next probes quarantine the first and restore the second. A quarantined channel gets no requests until a probe restores it. A repeat offender is replaced. Replacement is rate-limited and keeps the old channel if the new one can't connect or never answers its probe. A channel dropped from the pool during its probe doesn't take the replacement slot, and a suspect dropped during its probe still has its stalled read failed. A quarantine-cancelled write leaves the batch-write rate alone.Release notes
Check each box that your changes affect. If none of the boxes relate to your changes, release notes aren't required.
For each box you select, include information after the relevant heading that describes the impact of your changes that a user might notice and any actions they must take to implement updates.