Repository navigation
fix(gw): stop reaping in-use connection pools after 2h of uptime - #690
guanzhousongmicrosoft wants to merge 1 commit into
Conversation
1de44c2 to
a1da840
Compare
|
🤖 Auto-triaged by documentdb-triage-tool. Applied: Reasoningcomponent from path globs (gateway, docs); effort from diff stats (349+9 LOC, 6 files); LLM: Regression introduced in v0.114-0 causes authenticated clients to receive internal errors after ~2 hours of process uptime due to incorrect timestamp encoding causing spurious connection pool eviction — a severe correctness/availability regression with a confirmed external reporter and clear release boundary. If a label is wrong, remove it manually and ping |
6dd3e07 to
8a1914f
Compare
`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>
8a1914f to
b4d0dc4
Compare

Description
Fixes a regression, introduced in v0.114-0, where the gateway stops serving already-authenticated clients after 2h05m of process uptime, returning internal errors with
Connection pool missing for user.in the gateway log.Reported by an external user whose 12–48 hour soak harness passed consistently on 0.112 and began failing at ~2 hours on 0.114, reproducibly across multiple machines.
Root cause
ConnectionPool::last_used_nanosholds nanoseconds since the process epoch, and that is howlast_used()decodes it:f02641fe("[RustGW] Correctly migrate to EpochClock in connection pool") changed both acquisition paths to write it withEpochClock::almost_now_timestamp(), which returns seconds since the UNIX epoch (~1.785e9). Decoded as nanoseconds that lands ~1.785 s after process start and then advances one nanosecond per elapsed second — effectively frozen. The two functions look interchangeable at a call site and are not.So
last_used().elapsed()stopped measuring pool idleness and started measuring process uptime. The reaper wakes every 300 s and disposes pools idle beyond 7200 s, so with a frozen stamp:From then on every user data pool is evicted regardless of load, and re-evicted on each subsequent tick.
get_data_poolcannot rebuild an evicted pool because the user's password is not retained past authentication, so requests from an authenticated client fail until it re-authenticates.Ancestry confirms the reported release boundary:
f02641feis not an ancestor ofv0.112-0orv0.113-0, and is an ancestor ofv0.114-0.Fix
Both acquisition paths now route through a single
touch_last_used()helper that writes the encodinglast_used()decodes. Two deliberate choices:One writer. The field is written only by
touch_last_usedand read only bylast_used, so the encoding is owned in one place. Both carry doc comments naming the trap.Precise
Instant::now(), not the coarse clock. The minimal fix preserving the original commit's intent would beinstant_to_u64(EpochClock::almost_now()). That was tried first and the new tests still failed, at 5.004 s of reported idleness:EpochClock's coarse updater is a tokio task spawned on whichever runtime first initialises the global clock, soalmost_now()stops advancing when that runtime goes away. The gateway daemon has one long-lived runtime so it does advance in production, but the reaper's only input shouldn't depend on a background task's liveness.Instant::now()is ~20 ns on a path that then checks out a pooled Postgres connection.The other
almost_now_timestamp()callers (ttl_cache.rs,transaction_store.rs) were audited and are correct — both compare unix-seconds to unix-seconds. The connection pool was the only site mixing bases.Validation: 8-hour three-arm soak
Beyond the unit tests, this was validated on
documentdb-local:pg17-0.114.0(PG17 + documentdb extensions). Three arms, same image, environment, network and load, differing only in the gateway binary — each verified by md5 inside its container rather than assumed:88921707…)3411723d…)dc4de0eb…)The fixed arm completed 28,680 operations with zero client errors while its reaper ticked 97 times — so the reaper ran and evaluated the pool every cycle and simply found nothing to evict. Both broken arms were still failing continuously at cutoff.
The
unfixedarm validates the method rather than the bug: it comes through the same build-and-swap pipeline asfixed, so its failing on schedule establishes that a cleanfixedresult is attributable to the fix and not to an artifact of how the binary was produced. Predictions (including the t=7500 s tick) were recorded before the run.Recovery and recurrence were demonstrated directly. A fresh client pointed at the already-broken control arm ran 226 clean operations — authenticating rebuilds the pool — then failed at the very next reaper tick:
That gateway started at 02:19:54.5 Z, so the tick was due at 05:39:54.5 Z — the transition brackets it within two seconds.
Two notes for anyone reproducing this: the client sees
An unexpected internal error has occurred., never the pool message, so a client-side detector is blind — grep the gateway log. And the failure can present as the workload hanging rather than erroring, matching the original report of the database "appearing to become unresponsive."Tests
Three regression tests, all failing on the unfixed code:
test_last_used_after_acquire_connection_reports_pool_as_recently_usedconnection_pool.rstest_last_used_after_acquire_timeout_connection_reports_pool_as_recently_usedconnection_pool.rstest_clean_unused_pools_with_actively_used_pool_keeps_pool_reachablepool_manager.rsMaking them deterministic took some care — a stamp in the wrong unit still decodes to some instant near the epoch, so it only becomes distinguishable once the process has been up longer than the offset the bad stamp decodes to (~1.785 s). The new
testing::wait_for_epoch_uptimehelper pins uptime past that point first, making it a controllable input rather than something to wait out.The pre-existing
test_last_used_with_fresh_pool_returns_recent_instantcovers only a freshly constructed pool, and the regressing commit left the constructor writing the correct encoding — which is why it kept passing throughout.Also included
docs/rca/2026-07-27-gateway-connection-pool-last-used-regression.md, including two follow-ups deliberately not addressed here: (a) even with correct timestamps, a >2h-idle authenticated client hits the same error because the pool can't be rebuilt without the password — pre-existing behaviour, also present in 0.112; (b) theEpochClockupdater's runtime-lifetime coupling, whichttl_cacheandtransaction_storealso depend on. Happy to drop the RCA file from this PR if maintainers would rather it live outside the repo.Verification
documentdb_gateway_coreunit tests passcargo fmt --all --checkandcargo clippy -p documentdb_gateway_core --all-targetscleanType of Change
AI Disclosure
EpochClockupdater lifetime issue documented above.Checklist
git commit -s)