Skip to content

pd: bound every wait on the PD path — a frozen peer must not hang the client - #558

Open
eduralph wants to merge 6 commits into
tikv:masterfrom
getwyrd:tso-deadline
Open

eduralph wants to merge 6 commits into
tikv:masterfrom
getwyrd:tso-deadline

Conversation

@eduralph

@eduralph eduralph commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

Why: #516's txn_cleanup_2pc_locks hang, diagnosed end to end. In two CI runs (31042420081, 31043996146), the phase logs from #553 show every phase of the test completing in milliseconds; the only statement after the last phase is count_locks, which begins with client.current_timestamp() — a TSO request. The cluster-logs artifact shows the trigger: all three TiKVs logged monotonic time jumped back by ~65s simultaneously (a hypervisor-level pause of the runner VM), PD then measured a 2.6s scheduling gap on its 50ms TSO loop, lost its 1.67s election lease, resigned, and closed the TSO allocator — and recovered ~15s later, serving normally for the rest of the job, while the test stayed hung until nextest's 600s kill. Whatever holds the client after that point is client-side. And nothing about the trigger is CI-specific: any hypervisor pause or live migration, cgroup throttling under Kubernetes, or a middlebox silently expiring an idle long-lived connection produces the same frozen-open stream in production.

The defects. Nothing on the PD path bounded a wait against a peer that goes silent without terminating the connection:

  1. The TSO receive loop waits on responses.next() unbounded — a request parked in pending_requests on a frozen-open stream hangs forever. (Termination is recoverable; silence is not.)
  2. TSO stream creation (pd_client.tso(...)) is unbounded the same way.
  3. The dial: a frozen peer's kernel still completes the TCP handshake, then the HTTP/2 preface never arrives and connect() hangs unbounded. Connection::connect even took a timeout parameter and ignored it (_timeout), and its get_members call carried no bound either.
  4. Teardown alone cannot deliver failures: the cancelled gRPC request body owns the request channel inside the connection's task, which may not run again — releasing nothing — until the connection thaws (observed in reproduction: sends kept succeeding into a dead worker's channel).

What: follow client-go's shape — its TSO dispatcher arms a per-batch deadline with defaultPDTimeout = 3s and cancels the stream on expiry (pd/client/clients/tso/dispatcher.go), bounds stream creation via checkStreamTimeout (stream.go), and bounds dials — using the client's configured request timeout (Config::timeout, whose documented contract covers PD requests). Every bound is armed only while something is owed:

  • The receive loop waits in whole timeout windows while batches are outstanding: a batch pending across one full silent window declares the stream stalled and the worker exits. A batch dispatched mid-window gets one full window, so teardown takes between one and two windows of genuine silence. Idle streams are never torn down.
  • Stream creation is guarded by a watchdog with the same window rule, armed only while a batch is pending — PD (a gRPC-go server) sends response headers with its first response, so tso(..) legitimately waits while the client is idle; HTTP/2 lets request batches flow (and become pending) before response headers arrive, so a stalled creation is still detected.
  • The worker's exit is signaled: get_timestamp selects on a watch channel the worker closes on exit — while enqueueing as well as while awaiting the response, since a dead worker's request channel can remain open with a full buffer and send would otherwise block forever.
  • Dials are bounded, HTTP/2 handshake included: SecurityManager grows connect_with_timeout (the existing connect signature is preserved, now bounded by the default request timeout, so no API break), Connection::connect uses its hitherto-ignored timeout, and get_members is bounded like every other request in that path.

Stream termination is also observable now: a clean end stays info-level; stalls, creation timeouts, termination with batches outstanding, and stream status errors (previously swallowed) surface as warnings with the outstanding count — resolving the old TODO about indistinguishable terminations.

Considered and omitted: a timer-based backstop in get_timestamp (client-go has no analogue — ambient contexts cover it — and the worker-exit signal is event-driven and precise), and gRPC keepalive (connection-level health; client-go applies it to TiKV connections; a natural follow-up).

What this means for #516: the unbounded hang is gone. Under a short freeze the test recovers and passes; under a freeze longer than the retry budget it fails fast with a descriptive error instead of a 600s timeout — the trigger itself is outside any client's control.

Verification: 10 new unit tests — the receive loop and the creation watchdog driven under a paused clock (stall detected within [1, 2] windows; a mid-window batch granted its full window; idle streams and idle creation never torn down; termination clean only when nothing is owed; a stream status error surfaced; the happy path), get_timestamp failing fast when the worker dies, and enqueueing observing worker death with a full request buffer. The stall test bites: with the stall branch disabled it runs unbounded. 82 lib tests, make check, and the txn/raw/failpoint integration suites green against a local api-v2 cluster.

Manually, with docker pause on PD for 45s (SIGSTOP: TCP open, no FIN — the client-side view of a VM pause): before, one silent 44.5s hang ended only by the unpause; after, a 5s-idle client keeps its worker silently, the stall is detected in ~4s, reconnects against the frozen peer fail in 2s each, callers receive bounded, descriptive errors while the freeze lasts, and the client recovers within ~3s of the peer thawing.

Ref: #516

Summary by CodeRabbit

  • Bug Fixes
    • Added timeout handling for cluster connections, security handshakes, and initial membership requests.
    • Timestamp requests now honor configured time limits and fail promptly when services stall or become unavailable.
    • Improved handling of timestamp stream errors, idle termination, and worker shutdowns to prevent requests from remaining unresolved.
    • Reconnection attempts now apply the configured timeout consistently.
  • Tests
    • Expanded coverage for connection timeouts, timestamp allocation, stream failures, and worker shutdown behavior.

… client

The TSO path had no deadline, keepalive, or health check anywhere: a request
parked in pending_requests waits on responses.next() unbounded. When the
stream terminates that is recoverable — the pending oneshots drop, callers
error, the retry layer reconnects — but a stream that goes silent WITHOUT
terminating (the peer's VM paused by its hypervisor, the process frozen under
resource starvation, a middlebox silently discarding the connection) strands
every pending and future timestamp request forever. The PD dial had the same
defect: a frozen peer's kernel still completes the TCP handshake, then the
HTTP/2 preface never arrives and connect() hangs unbounded — Connection::
connect even took a timeout parameter and ignored it (_timeout), and its
get_members call carried no bound either.

That combination is the mechanism behind the tikv#516 CI hang. The runner VM froze
(all three TiKVs logged "monotonic time jumped back" by ~65s; PD measured a
2.6s scheduling gap against its 1.67s election keep-alive, resigned, and
closed its TSO allocator). PD recovered ~15s later, but the test's in-flight
current_timestamp() hung against the zombie stream until nextest's 600s kill.
Reproduced locally with docker pause on PD (SIGSTOP: TCP open, no FIN): the
in-flight request waited the full 45s pause, resolving only because unpause
terminated the stream; reconnects hung the same way at the dial.

Follow client-go's shape — its TSO dispatcher arms a deadline per batch with
defaultPDTimeout = 3s and cancels the stream on expiry (pd/client/clients/tso/
dispatcher.go), bounds stream creation via checkStreamTimeout (stream.go), and
bounds dials — using the client's configured request timeout (Config::timeout,
whose documented contract covers PD requests). Every bound is armed only while
something is owed:

- The TSO receive loop waits in whole timeout windows while batches are
  outstanding: a batch pending across one full silent window declares the
  stream stalled and the worker exits. A batch dispatched mid-window is
  granted one full window, so teardown happens only after between one and two
  windows of genuine silence. Idle streams (nothing owed) are never torn down.
- Stream creation is guarded by a watchdog with the same window rule, armed
  only while a batch is pending: PD (a gRPC-go server) sends response headers
  with its first response, so tso(..) legitimately waits while the client is
  idle — HTTP/2 lets request batches flow (and become pending) before response
  headers arrive, so a stalled creation is still detected.
- The worker's exit is SIGNALED to callers: get_timestamp selects on a watch
  channel the worker closes on exit — while ENQUEUEING as well as while
  awaiting the response, since a dead worker's request channel can remain open
  with a full buffer (held by the frozen connection's task, which may not run
  again until the connection thaws) and send would otherwise block forever.
- Dials are bounded, HTTP/2 handshake included: SecurityManager grows
  connect_with_timeout (the existing connect signature is preserved, now
  bounded by the default request timeout), Connection::connect uses its
  hitherto-ignored timeout, and its get_members call is bounded like every
  other request in that path.

Stream termination is also made observable instead of silent: a clean end with
nothing owed stays info-level; stalls, creation timeouts, termination with
batches outstanding, and stream status errors (previously swallowed) surface
as warnings with the outstanding count — resolving the old TODO about
indistinguishable terminations.

Considered and omitted: a timer-based backstop in get_timestamp (client-go has
no analogue — ambient contexts cover it there — and the worker-exit signal is
event-driven and precise), and gRPC keepalive (connection-level health,
client-go applies it to TiKV connections; a follow-up).

Verification: 10 unit tests — the receive loop and the creation watchdog under
a paused clock (stall detected within [1, 2] windows; a mid-window batch
granted its full window; idle streams and idle creation never torn down;
termination clean only when nothing is owed; a stream status error surfaced;
the happy path), get_timestamp failing fast when the worker dies, and
enqueueing observing worker death with a full request buffer. The stall test
bites: with the stall branch disabled it runs unbounded. 82 lib tests and make
check green. Manually, with docker pause on PD for 45s: before, one silent
44.5s hang ended only by the unpause; after, a 5s-idle client keeps its worker
silently, the stall is detected in ~4s, reconnects against the frozen peer
fail in 2s each, callers receive bounded, descriptive errors while the freeze
lasts, and the client recovers within ~3s of the peer thawing.

Ref: tikv#516
Signed-off-by: Eduard R. <eduard@ralphovi.net>
@ti-chi-bot ti-chi-bot Bot added the dco-signoff: yes Indicates the PR's author has signed the dco. label Aug 5, 2026
@ti-chi-bot

ti-chi-bot Bot commented Aug 5, 2026

Copy link
Copy Markdown

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by:
Once this PR has been reviewed and has the lgtm label, please assign tonyxuqqi for approval. For more information see the Code Review Process.
Please ensure that each of them provides their approval before proceeding.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@ti-chi-bot ti-chi-bot Bot added contribution This PR is from a community contributor. size/XL Denotes a PR that changes 500-999 lines, ignoring generated files. labels Aug 5, 2026
@coderabbitai

coderabbitai Bot commented Aug 5, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Important

Review skipped

Review was skipped as selected files did not have any reviewable changes.

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 79337f23-a840-479a-8eb0-3b0c23d5eaac
📥 Commits

Reviewing files that changed from the base of the PR and between f60393d and 59cf4d0.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: f728c7ee-4ce6-4da3-af58-1ad84e249917
📥 Commits

Reviewing files that changed from the base of the PR and between c8027da and cfba5c7.

📒 Files selected for processing: 2
  • Cargo.toml
  • src/pd/timestamp.rs

Included review availability: This review used your included allowance. Your plan provides up to 2 included reviews per hour; 1 remain after this review.

📝 Walkthrough

Connection setup and timestamp operations now use configured timeouts. TimestampOracle monitors worker termination and handles stalled, terminated, or failed streams. Tests cover timeout, idle, shutdown, and timestamp allocation behavior.

Changes: Timeout handling

Layer / File(s) Summary
Connection deadline propagation
src/config.rs, src/common/security.rs, src/pd/cluster.rs
Connection paths apply configured timeouts to channel setup and membership requests. TimestampOracle construction receives the connection timeout.
Timestamp worker timeout behavior
src/pd/timestamp.rs, Cargo.toml
TimestampOracle monitors worker exit and bounds stream creation and response processing. Tests cover stalled streams, idle behavior, stream errors, worker shutdown, lock contention, and timestamp allocation. Development dependencies enable Tokio test utilities and add hyper-util.

Priority: ➖ Normal

Estimated code review effort: 4 (Complex) | ~45 minutes

Change: Bug fix

Sequence Diagram(s)

sequenceDiagram
  participant TimestampOracle
  participant TimestampWorker
  participant PD_TSO_Stream
  TimestampOracle->>TimestampWorker: enqueue timestamp request
  TimestampWorker->>PD_TSO_Stream: create stream and send request
  PD_TSO_Stream-->>TimestampWorker: return response or stream error
  TimestampWorker-->>TimestampOracle: return result or worker-exit error
Loading

Suggested reviewers: pingyu

Merge Risk: ⚪ Minimal · up to cfba5

The reviewed PD timeout paths have no identified issue that needs resolution before merge.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 67.74% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 31 functions across 2 files. (1 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly and concisely describes the main change: bounding waits across the PD client path so a non-responsive peer cannot hang the client.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 67.74% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 31 functions across 2 files. (1 skipped: 1 unsupported.)

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@eduralph

eduralph commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

@pingyu for attention

pingyu and others added 2 commits August 27, 2026 18:41
Brings tikv#562 (client: fall back to followers when leader is unreachable) and the
two OWNERS syncs under this PR.

Conflict: src/common/security.rs. Both sides had independently grown a method
named `connect_with_timeout`, by different mechanisms:

  - this PR bounds the dial with `tokio::time::timeout` around
    `channel.connect()`, and makes bare `connect()` delegate to it with the
    default request timeout, so every dial is bounded;
  - tikv#562 added a private `connect_inner(addr, Option<Duration>, factory)` whose
    `Some` arm sets tonic's `Endpoint::connect_timeout`, leaving `connect()`
    unbounded.

Resolved by keeping this PR's shape and mechanism, because the two are not
interchangeable. tonic 0.12.3 installs `connect_timeout` on hyper's
`HttpConnector` (transport/channel/endpoint.rs: `Endpoint::connect` calls
`http.set_connect_timeout` and only then hands off to `Channel::connect`), so
it bounds the TCP connection phase — TLS and the HTTP/2 handshake both run
after the connector returns. tikv#516's hang is precisely a frozen peer whose
kernel completes the TCP handshake and then never answers the preface, so
adopting tikv#562's mechanism alone would have reintroduced the bug this PR exists
to fix.

tikv#562's bound is not discarded, though: the endpoint still receives
`connect_timeout`, which caps the TCP phase and keeps applying to reconnects
the channel makes on its own — neither of which the outer deadline reaches.
The two run concurrently rather than additively; whichever trips first decides
the error a caller sees, which is noted at the call site.

`connect_inner` is dropped as unreachable: with `connect()` bounded by the
default timeout, nothing constructs the `None` arm. It was private, so no API
is lost, and the public `connect()` signature is unchanged.

src/store/client.rs no longer appears in this PR's diff against master —
tikv#562 made the identical change there, so the two sides converged.

Verified: cargo check/fmt/clippy -D warnings clean; 112 lib tests, including
all 11 of this PR's pd::timestamp tests and tikv#562's kv-client-cache tests;
27 integration, 8 failpoint and 28 sync-transaction tests green against a
local api-v2 TiKV v8.5.5 (client-rust's own config/tikv.toml).

Signed-off-by: Eduard R. <eduard@ralphovi.net>

@pingyu pingyu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two correctness findings from reviewing c8027da against the PR base and comparing with client-go / the PD Go client, TiDB callers, and PD server handling. All 10 existing TSO tests passed (cargo test --offline --lib pd::timestamp::tests). Separate temporary transport and wakeup reproductions confirmed the cases below; no live-cluster integration tests were run.

Comment thread src/pd/timestamp.rs Outdated
Comment thread src/pd/timestamp.rs
@ti-chi-bot ti-chi-bot Bot added size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files. and removed size/XL Denotes a PR that changes 500-999 lines, ignoring generated files. labels Oct 17, 2026
…wakeup

Two review findings on the TSO deadlines:

1. Lost wakeup. The watchdog and the receive loop inspected the pending
   queue through a tokio Mutex. If `TsoRequestStream::poll_next` met that
   lock held, it dropped its lock future (and with it the mutex waiter) and
   returned Pending; releasing the inspection's guard then woke nobody, and
   a queued request was never dispatched. The queue is now a std Mutex,
   never held across an await, so `poll_next` waits out the lock instead of
   parking.

2. Queued requests ignored. Only batches already taken by the transport
   counted as owed. The transport polls the request stream only when it can
   send, so a request enqueued while it cannot (connection not ready, no
   send window) left the deadline disarmed and the caller waiting forever
   on a live worker. What is owed is now pending batches plus requests still
   in the channel, read through a WeakSender so the worker does not hold
   the channel open; client-go likewise arms its batch deadline before the
   RPC is sent.

Regression tests: the stream polled while another thread holds the lock;
the creation watchdog and the receive loop each with a request queued but
not dispatched (paused clock); and end to end through a tonic channel whose
connection never completes. Each fails or hangs with its fix disabled.

Ref: tikv#516
Signed-off-by: Eduard R. <eduard@ralphovi.net>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contribution This PR is from a community contributor. dco-signoff: yes Indicates the PR's author has signed the dco. size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants