proposal(gw): harden pool eviction — recoverable 391 on missing pool + type-owned last-used encoding by guanzhousongmicrosoft · Pull Request #691 · documentdb/documentdb · GitHub
Skip to content

proposal(gw): harden pool eviction — recoverable 391 on missing pool + type-owned last-used encoding - #691

Closed
guanzhousongmicrosoft wants to merge 3 commits into
documentdb:mainfrom
guanzhousongmicrosoft:propose/gw-pool-eviction-hardening
Closed

guanzhousongmicrosoft wants to merge 3 commits into
documentdb:mainfrom
guanzhousongmicrosoft:propose/gw-pool-eviction-hardening

Conversation

@guanzhousongmicrosoft

Copy link
Copy Markdown
Member

Description

Proposal PR — follow-up to #690, for gateway-team discussion. Stacks on that branch: the first commit here is #690's hotfix and will drop out on rebase once it merges. The two new commits implement the parts of the longer-term hardening that are small enough to review as code; the third piece is sketched below for discussion before anyone writes it.

This PR intentionally carries the full RCA in its description (below) rather than as a file in the repo, per review feedback on #690 that an RCA document doesn't belong in the OSS tree.

What this PR adds on top of the hotfix

1. fix(gw): missing user pool → ReauthenticationRequired (391), not InternalError

A missing data pool is not an internal fault. Pools are created at authentication — the only point where the user's password is available — and legitimately disappear when the reaper disposes of one idle past its 2h threshold. The gateway cannot rebuild the pool on its own, but the client can, by re-authenticating. Previously get_data_pool returned InternalError, which reaches clients as An unexpected internal error has occurred. with no recovery path: in the soak run below, a connection that hit this state failed 193 consecutive operations and never recovered until re-auth.

Returning wire code 391 (ReauthenticationRequired, already plumbed through the gateway's error machinery and used for expired identity tokens) converts every variant of this state — a legitimately reaped idle pool, or any future eviction bug — from an opaque outage into an actionable request. Note this gap predates the v0.114 regression and exists on v0.112: any client that authenticates and then idles >2h hits it today.

2. refactor(gw): the last-used stamp becomes an AtomicInstant type

The regression was possible because the stamp was a bare AtomicU64 whose encoding lived in a comment, while the clock API offers two u64-shaped readings with different units and bases that look interchangeable at a call site. The hotfix narrowed the field to one writer/one reader with warning comments; this goes the rest of the way: AtomicInstant is a lock-free cell that can only hold "now" — callers store the current instant and load an Instant back, and can never supply a raw integer in any unit. The bug class becomes unrepresentable, and the manual encode/decode disappears from conn_mgmt entirely.

3. Sketch only — evict on ground truth, not a proxy (requesting discussion)

The reaper currently asks "when was this pool last touched?" as a proxy for "is anyone using this?" — but the gateway holds the ground truth: connection contexts know which authenticated users have live connections. Tying eviction to live-session count per user (plus a grace period) would make the timestamp heuristic, and its entire category of encoding bugs, unnecessary.

This is also, I think, the constructive form of the session-idle-timeout point raised in review discussion: session lifecycle should drive pool cleanup — the current design's flaw is that it doesn't consult sessions at all (the pool is keyed by username; reaping an lsid doesn't touch it). Not implemented here because it needs a decision about where the per-user connection count lives and how it interacts with max_connections; if the team likes the direction I'll draft it as its own PR.


RCA (moved here from the repo file, per feedback)

Symptom

On v0.114+, any gateway process stays healthy until ~2h of uptime, then every request from already-authenticated users fails. Clients see An unexpected internal error has occurred. or a hang; the gateway log shows Connection pool missing for user. Recurs every ~5 min for the life of the process. Reported by an external user whose 12–48h soak harness passed on 0.112 and failed at ~2h on 0.114 across multiple machines.

Root cause — three layers

Proximate: last_used_nanos is decoded as nanoseconds since the process epoch, but commit f02641fe wrote it with EpochClock::almost_now_timestamp() — seconds since the UNIX epoch. Decoded, the stamp freezes ~1.785s after process start. last_used().elapsed() therefore measured process uptime, not pool idleness, and the reaper (300s ticks, 7200s threshold) evicted every user data pool once uptime passed the threshold: the t=7200s tick reads 7198.2s and spares the pool; the t=7500s tick evicts it → first failure at exactly 2h05m of uptime.

Enabling: the type system never saw the bug. Two clock functions return u64-shaped values in different units and bases; the field's contract lived in a doc comment; the atomic erased the rest. A careful reviewer approved the regressing PR because there was nothing visibly wrong at the call site. (Commit 2 of this PR addresses exactly this.)

Systemic: pool lifetime is governed by a proxy heuristic while the gateway holds ground truth, and eviction sits on an unrecoverable cliff — get_data_pool can't rebuild without the password, so any eviction of a still-referenced pool becomes an opaque outage. The timestamp bug was the trigger; the cliff is what made it a customer-visible catastrophe. (Commit 1 removes the cliff; sketch 3 removes the proxy.)

Why 2h05m exactly

Quantity Value
Frozen stamp decodes to epoch + ~1.785s
Reaper tick / threshold 300s / 7200s
Condition first true ~7201.8s uptime
First eviction (next tick) t=7500s = 2h05m00s

Measured, not estimated: two independent broken builds first failed at 2h05m00.3s and 2h05m00.1s of gateway uptime. Consequence for testing: a 2-hour run sits below the trigger and passes on a fully broken build. Ask for 3 hours of continuous gateway uptime.

Empirical validation — 8h three-arm soak

Three arms on documentdb-local:pg17-0.114.0, identical image/env/load, differing only in the gateway binary (verified by md5 in each container). Predictions, including the t=7500s tick, were recorded before the run.

Arm Binary First failure Failures in 8h
control stock shipped v0.114.0 2h05m00.3s 43,812
unfixed local build of unmodified v0.114-0 2h05m00.1s 43,038
fixed local build + #690's hotfix none 0 (28,680 ops; reaper ticked 97×)

The unfixed arm validates the method: same build-and-swap pipeline as fixed, so its on-schedule failure attributes the clean result to the fix, not the pipeline. Load was one long-lived authenticated connection per arm at 1 op/s — long-lived because get_data_pool only fails for clients authenticated before eviction; per-op reconnects would re-allocate the pool and mask the bug.

Recovery/recurrence observed directly: a fresh client on the broken control arm ran 226 clean ops (re-auth rebuilds the pool), then failed at the next predicted reaper tick — OK at 05:39:53.2Z, FAIL at 05:39:55.2Z, tick due 05:39:54.5Z. The failing connection was 3.8 minutes old, and once broken never self-healed (193 consecutive failures).

On the session-idle-timeout question

Raised in review: could this be about connections outliving MongoDB's 30-minute session idle timeout? The soak data says no on every axis: the failing connection was 3.8 minutes old (only needs to outlive one 5-min reaper tick); the load client was never idle (1 op/s) and was evicted anyway; the pool is keyed by username, not lsid, so session reaping doesn't touch it; and granting 30-min recycling arithmetic still fails ~92% of operations (first tick lands ~150s after auth, everything fails until recycle at 1800s). The "2 hours" is process uptime — a different clock from connection age entirely.

Detection gaps worth keeping

  • The client never sees the pool message — only An unexpected internal error has occurred. A client-side detector is blind; grep the gateway log. (An early watcher in this investigation did exactly that and reported a false negative for 3 hours while the bug reproduced perfectly.)
  • The failure can present as the workload hanging, matching the original "appears to become unresponsive" report.
  • Nothing in CI runs long enough to see uptime-dependent failures. The regression tests in fix(gw): stop reaping in-use connection pools after 2h of uptime #690 close this specific gap by making uptime a controllable input (wait_for_epoch_uptime) instead of something to wait out.

Type of Change

  • Bug fix
  • New feature
  • Documentation update
  • Refactoring / code cleanup
  • DevOps / tooling
  • Test improvement
  • Other: ___________

AI Disclosure

  • No AI tools were used substantially in this PR
  • AI tools were used to assist with this PR (please complete below)
    • Tool(s) used: Claude Code
    • How it was used: Traced the regression to the introducing commit, designed and ran the three-arm soak, and drafted the fixes, tests, RCA, and this proposal. All findings verified against code and by running tests and the soak locally.

Checklist

  • I can explain every change in this PR if asked during review
  • I have tested these changes locally
  • I have added or updated tests as appropriate
  • All commits are signed off (DCO: git commit -s)
  • I have read the CONTRIBUTING.md guidelines

`ConnectionPool::last_used_nanos` holds nanoseconds since the process
epoch, and that is how `last_used()` decodes it. Both acquisition paths
were writing it with `EpochClock::almost_now_timestamp()`, which returns
seconds since the UNIX epoch. Decoded as nanoseconds, that lands ~1.785s
after process start and then advances a nanosecond per second, so the
stamp was effectively frozen.

`last_used().elapsed()` therefore measured process uptime rather than
pool idleness. The reaper wakes every 300s and disposes pools idle beyond
POSTGRES_POOL_DISPOSE_INTERVAL_SEC (7200s); with a frozen stamp the tick
at t=7200s reads 7198.2s and spares the pool, and the tick at t=7500s
reads 7498.2s and evicts it. From then on every user data pool is evicted
regardless of how heavily it is in use, and re-evicted on each subsequent
tick. `get_data_pool` cannot rebuild an evicted pool because the user
password is not retained past authentication, so requests from an
already-authenticated client fail from 2h05m of uptime onward. The client
sees "An unexpected internal error has occurred."; only the gateway log
carries "Connection pool missing for user."

Route both acquisition paths through a single `touch_last_used` helper
that writes the encoding `last_used` decodes. Use the precise
`Instant::now()` rather than either coarse `EpochClock` reading: the
coarse updater is a tokio task bound to whichever runtime first
initialises the global clock, and the reaper's only input should not
depend on that task still being alive. The syscall is ~20ns on a path
that then checks out a pooled connection.

Regression introduced in v0.114-0; v0.112-0 and v0.113-0 are unaffected.

Adds three regression tests that fail on the unfixed code and a
`wait_for_epoch_uptime` test helper that makes process uptime a
controllable input.

Validated by an 8h three-arm soak on documentdb-local:pg17-0.114.0 in
which the arms differ only by this fix: stock v0.114.0 first failed at
2h05m00.3s and a locally built unmodified v0.114-0 at 2h05m00.1s, while
the fixed build ran 8h and 28,680 operations with zero occurrences and
its reaper ticking 97 times. Full methodology and results are in the PR.

Signed-off-by: Guanzhou Song <guanzhousong@microsoft.com>
A missing data pool is not an internal fault. Pools are created at
authentication - the only point where the user's password is available -
and legitimately disappear when the reaper disposes of one idle past its
threshold. The gateway cannot rebuild the pool on its own, but the client
can, by re-authenticating.

Previously get_data_pool returned InternalError, which reaches clients as
"An unexpected internal error has occurred." with no recovery path: in
the 8h soak run for the v0.114 reaper regression, a connection that hit
this state failed 193 consecutive operations and never recovered until
the client re-authenticated. Returning wire code 391 turns every variant
of this state - a legitimately reaped idle pool, or any future eviction
bug - from an opaque outage into an actionable re-auth request. This is
the pre-existing gap noted as follow-up #1 in the reaper-regression fix:
it exists on v0.112 as well and is not specific to that regression.

The gateway log line now also states that re-authentication was requested
rather than logging an anonymous internal error.

Signed-off-by: Guanzhou Song <guanzhousong@microsoft.com>
The v0.114 reaper regression was possible because the pool's last-used
stamp was a bare AtomicU64 whose encoding (nanoseconds since the process
epoch) lived only in a comment, while the clock API offers two u64-shaped
readings with different units and bases that look interchangeable at a
call site. The hotfix narrowed the field to one writer and one reader;
this goes the rest of the way and makes the mistake unrepresentable.

AtomicInstant, in the time module, is a lock-free timestamp cell that can
only hold "now": callers store the current instant and load an Instant
back, and can never supply a raw integer in any unit. The connection pool
now uses it for last_used, deleting the manual encode/decode from
conn_mgmt entirely. Writes keep the precise Instant::now() for the same
reason as the hotfix: a liveness decision must not depend on the coarse
clock's updater task being alive on whichever runtime initialised it.

Signed-off-by: Guanzhou Song <guanzhousong@microsoft.com>
@guanzhousongmicrosoft

Copy link
Copy Markdown
Member Author

Design reasoning — why this shape, and what was considered and rejected.

Why 391 is the highest-leverage change here. The v0.114 incident was two defects compounding: a wrong timestamp (fixed in #690) and the fact that eviction of a referenced pool is unrecoverable. Fixing only the timestamp leaves the cliff: the >2h-idle path still walks off it today on every released version including 0.112, and any future eviction bug walks off it too. The 391 change is small, uses machinery that already exists (same code path as expired identity tokens), and caps the blast radius of this entire failure class at "client re-authenticates" instead of "client is broken until someone restarts something." Defense in depth for a part of the system that has now demonstrated it can fail.

Alternatives considered for the missing-pool case:

  • Retain credentials (or a derived secret) to rebuild the pool server-side — rejected: extends credential lifetime and widens the security surface to save one round-trip.
  • Pre-emptively skip eviction when the user has live connections — right long-term direction (sketch 3), but it prevents only this trigger; 391 also covers triggers we haven't imagined yet. They compose; do both.
  • Keep InternalError but improve the message — rejected: drivers can act on 391 automatically; no driver acts on a prettier internal error.

One behavioural note reviewers should weigh: clients that previously saw a generic error now see 391. For drivers with automatic re-auth support this is strictly better; for others it surfaces as a clearer, actionable error instead of an opaque one. Either way the state was already fatal for the request — this cannot regress a request that would otherwise have succeeded.

Why AtomicInstant rather than more discipline. The hotfix's single-writer/single-reader + comments is convention, and the regressing commit is proof that convention loses to a plausible-looking refactor. The type makes the wrong call unwritable rather than merely documented — almost_now_timestamp() cannot be stored into it because nothing accepts a raw integer. Cost: ~30 lines in the time module, and conn_mgmt loses its manual encode/decode entirely. I kept instant_to_u64/u64_to_instant public since other call sites still use them; migrating those is possible follow-up but out of scope here.

Why sketch 3 is a sketch and not code. Evicting on live-session count needs two decisions that belong to the gateway team: where the per-user connection count lives (connection context vs. pool manager), and the interaction with max_connections when a user holds sessions across many gateway connections. Guessing both in a proposal PR would produce code to argue with instead of a design to agree on. If the direction is agreeable I'll write it as its own PR.

Validation status of this branch: 463/463 unit tests pass (including two new: missing pool → 391, and the full evict → 391 → re-auth → recover cycle), fmt/clippy clean, full workspace compiles. The 8h soak in the description was run against #690's hotfix; commits here change the error surface and encoding ownership, not the reaper decision logic, so the soak's conclusion carries over. If the team wants, the same three-arm rig is scripted and can re-run against this branch overnight.

@documentdb-triage-tool documentdb-triage-tool Bot added documentation Improvements or additions to documentation enhancement New feature or request gateway for gateway related issues performance labels Jul 28, 2026
@documentdb-triage-tool

Copy link
Copy Markdown

@github-project-automation github-project-automation Bot moved this from Todo to Done in Issue triage Jul 29, 2026
@guanzhousongmicrosoft
guanzhousongmicrosoft deleted the propose/gw-pool-eviction-hardening branch August 20, 2026 18:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation enhancement New feature or request gateway for gateway related issues performance

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant