Repository navigation
Share the DB setup pass through the worker lifetime; evict failures instead of retrying - #100
Conversation
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 25 minutes. View limit details
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. Comment |
A resource whose first load fails settled into a dead error: the shared Effect Cache keeps failures forever, so even remounting re-served the same failure until a mutation forced a refetch. The admin Apps panel hit exactly this when listAllApps took a 504 under lane load. The mount path now retries twice with a short delay, invalidating the cached failure first; explicit refetch still fails fast.
…nstead of retrying celld drops a request's pending work when that request answers or its client leaves. Every control request (actions and /assets/* alike, since run_worker_first sends assets through the worker) shares one isolate-level D1 setup pass, so when the starter's request finished first the pass never settled: every waiter burned the full 5 s, then started its own equally orphanable pass. Those 5 s, 10 s and 15 s stalls are flake H and issues #91/#92. The starter now registers its pass with ctx.waitUntil (stamped by 03-db.ts), and waiters that outlast their bound share the single replacement pass instead of each starting their own (at most two concurrent passes, so one slow pass can never turn N waiters into N concurrent migrations). Also: a failed resource load is evicted when it lands instead of poisoning the cache (no timed auto-retry; a remount refetches), and the anchored-admin promotion skips its SELECT when the fresh session role is already admin. Tests: dropped-pass registration, single-flight replacement count, promotion skip statement counts, failure eviction on remount.
A waiter that outlasts the 5 s shared-pass bound but carries no worker lifetime cannot register a replacement, so it now fails fast instead of storing an unregistered pass an abort would orphan (flake H's 15.01 s listEnvironments + errorDetail pair). The middleware always runs first with a lifetime, so it still owns every replacement. The better-auth session read reaches D1 with no timeout anywhere in its chain, so getSessionOrNull now races it at 5 s and throws on expiry: actions fail 500 with cause, browser routes deny as signed-out. Tests: lifetime-less expiry fails fast with the stored pass intact; wedged session store throws at an injected bound; healthy and missing sessions unchanged. docker/check-worker-rules.ts guards the class in CI.
9a1009a to
58ddbf9
Compare
|
Parent review across rounds 1–3: accepted.
Residual, owned upstream: the celld D1 store going unresponsive for ~14 s after client-aborted reads is not observable from the lane artifacts. With this PR a recurrence fails at 5 s with cause instead of a 15 s 504. Tracked as a follow-up. Labelled |
Root cause
Round 1 found that a 504 from
listAllAppsis cached forever (lib/resource.tskeeps failures). Its timed auto-retry is dropped by this round (see Design): the real defect is the stall, and a retry only waits it out while the user watches a 15 s hang.Flake H (run 39782655475,
e2e (multi round 1 shard 1),admin.spec.ts:521). Fromlane-caddy-access.log(start time and duration):POST /api/admin/adminOverviewGET /api/auth/get-sessionPOST /api/admin/listAllAppsPOST /api/prs/branchRulesGetPOST /api/environments/listEnvironmentsEvery stalled request touches the control D1 (session/admin reads plus a runner fetch), while D1-free requests in the same window stay fast (
/api/streams0 ms, static assets ~ms, runner deploys proceeding) — so the isolate event loop was not blocked and the runner was not the holder. Durations cluster at one 5 s wait per request, withlistAllAppsat 5 + 10 (withDb's 10 s bound losing the race to the 15 s middleware bound).Issues #91/#92 (run 39764004222, multi r1 s2). Two workers loaded
/apps/newat 10:37:14.55/14.59; each got every chunk in ms except one/assets/*chunk, which the control worker held for exactly 15.00 s until the timeout middleware returned a JSON 504:GET /assets/rolldown-runtime-hePW80VL.jsGET /assets/dist-CWwpyyv2.jsAssets have no D1 of their own — yet they stalled identically.
void.config.tssetsrun_worker_first: true, so assets traverse the same middleware chain, and03-db.tsrunsensureDbPromise()unconditionally on every request. One mechanism covers both incidents: the shared isolate-level D1 setup pass.The mechanism (already half-documented in SPEC):
ensureDbshares one setup pass per D1 binding; a waiter sits out 5 s on a pass another request started, then runs its own. celld drops a request's pending work when that request answers or its client leaves — and the pass was never registered withctx.waitUntil. So whenever the starter's request finished first (a fast asset on a cold isolate, a navigating page), the pass never settled: every waiter burned the full 5 s, then started its own equally orphanable pass. 5 s, 10 s, 15 s, in cohorts — exactly both incidents. The waiters' herd additionally multiplied the damage: each expired waiter ran a full migration pass, hammering celld's single-threaded D1. Noite-side holder at the statement level is unidentifiable from lane artifacts (worker stdout is not captured); what is proven is the shared unregistered pass plus the herd amplifier, and both are removed here. A concurrent fresh-database DDL race (two passes migrating an empty DB) remains possible at first boot; it is narrowed from N passes to two and noted as residual below.PR #88 is innocent (lane plumbing only, verified from its diff); issues #91/#92 should close with this PR.
Design
lib/db.ts+middleware/03-db.ts: the shared pass outlives its starter.startSetupPassregisters the pass with the worker lifetime (c.executionCtx.waitUntil, stamped by03-db.ts, newEnsureDbOptions{ctx, waitMs}); a starter that answers or disconnects no longer orphans the pass. Waiters that outlast their 5 s bound share the single replacement pass instead of each starting their own (single-flight: the loser of the replace race shares the winner's pass outright). At most two concurrent passes; a failure still clears the entry so the next request retries. Rejected alternative: exempting/assets/*fromensureDb— principled (assets need no D1) but a second semantic, andwaitUntilalready covers all requests uniformly.lib/server/admin.server.tsx: one fewer D1 round-trip on every admin action.ensureAdminAccounttakes the session's fresh role and returns early when it is alreadyadmin(better-auth reads the user row on every session read — verified nocookieCacheinauth.tsand disabled by default in the installed better-auth — so the skip cannot resurrect a demoted admin; the demotion shows up on the very next request). The promotion path for non-admin anchored addresses is unchanged.lib/resource.ts: failures are evicted, never cached, never retried on a timer. A failed load surfaces onerrorand drops the cache entry, so a remount refetches;explicitrefetch()is unchanged. NosetTimeout, no retry budget, no loophole.Security review
Independent
reviewersubagent over the diff (middleware is a sensitive path): sound, no blockers, no concerns. Checked: (a) the promotion skip cannot keep a demoted admin (role is server-allowlisted from the DB-backed session, impersonation refused before it, anchor semantics unchanged); (b) the shared replacement isfailAfter-bounded (5 s + 15 s worst case, middleware 504 independent) and failures still evict, so no poisoned-pass DoS; single-flight is a synchronous check-and-set, at most 2 concurrent passes; (c)waitUntilleaks nothing (WeakMap, swallowed rejection, degrades to old behavior without a ctx); (d)EnsureDbOptionscarries no identity andctx-less callers fail safe. Full findings in the thread.security-reviewedstill needs the parent (never self-applied).Verification
waitUntil(1 assertion); N expired waiters share one replacement (prepare count 2× baseline, not 4×); admin role skips the promotion round-trip (0 statements) while a non-admin still promotes; a failed mount evicts so a remount refetches (calls 1 → 2, error → rows).bun run checkclean,bunx tsc --noEmitclean,bun run test613 pass / 0 fail,bun run buildclean (pre-existing chunk-size warning only). Runner untouched (no cargo gates needed).setTimeout/sleepin tests (docker/check-tests.tspatterns checked);setImmediateloop-turn yields are not wall-clock waits.CI
Release suite on both lanes (
e2elabel present). Run:ci39794442088 (PR #100, SHA9a1009a).e2e (multi round 1 shard 1)pass (5m04s),(multi round 1 shard 2)pass (4m34s),(single round 1 shard 1)pass (3m42s).e2e (single round 1 shard 2)FAIL:worker-errors.spec.ts:267(src/index.tsframe never visible). Server side shows twin 15.01 s middleware 504s at 14:24:49.455/14:24:49.470 (listEnvironments,errorDetail, same app) while D1-backed siblings at the same instant stayed fast (get-session0.04–0.09 s,adminOverview0.07 s) — the hang is past D1-auth, in the unbounded runner fetches for that app (withDb's 10 s bound rules out a D1 hang: 15.01 s exceeds it). None of this PR's paths are in that hang (setup healthy per siblings; not admin actions; client cache cannot 504 a server action). D-family recurrence (S7 unlanded); reported, not retried.check(all slices),rust,worker,image,types,ui,clipass.CodeRabbitpass (rate-limited).security-gatefail: needs the parent-appliedsecurity-reviewedlabel (security-sensitivetripped bymiddleware/**); independent review found no blockers/concerns (see above).ci-okfail (aggregate of the two above).Open risks / residual
waitUntil-registered work on client disconnect (workers semantics say it survives; celld's exact behavior is unverified from here), the single-flight replacement still bounds the damage to one extra pass per 5 s generation. No lane time was available to probe this directly.Round 3 — the 15.01 s pair (run 39794442088
single r1 s2, corrected pin)Correction to the round-2 note above: the 15.01 s hang is NOT past D1-auth in unbounded runner fetches.
findAppswallows every runner error intonull→ 401, andrunnerFetchaborts at 5 s, so a runner stall resolves at ~5 s as 401/422 — it cannot produce a 504 at 15.01 s. The 504 bodies are 29 bytes ({"error":"Request timed out"}, the 01-timeout middleware's exact JSON), so the worker held both requests the full 15 s.withDbprovably rejects at 10 s (probed). The only awaits on these paths that can legally pend to 15.01 s arereadBoundedText(excluded: sibling POST bodies parsed fine in the same window) and the unbounded better-authgetSessionD1 read. Full module-level audit (worker server code, void framework, better-auth/kysely/sql-d1/paranorm internals) found no other unregistered shared promise: the hung pair stalled ingetSession's D1 read while a transient control-D1 stall (seeded ~180 ms after the same app'sapps/get+errorList+eventsSnapshotwere aborted mid-read at 0.29–0.31 s) held it; concurrent identical reads succeeded, so no worker-side promise held them. S7 was already on main, so that explanation is withdrawn.Design (round 3)
lib/db.ts: a waiter without a worker lifetime fails fast. The expiry path previously deleted the stored pass and started an unregistered replacement any waiter could then await — an abort of that waiter orphaned it for the next 15 s. Now a lifetime-less waiter throws instead of storing; the middleware (always first, alwaysctx-carrying) still owns every replacement. Middleware-first ordering makes this unreachable in the healthy path — it only triggers on a genuinely wedged pass.lib/server/session.server.ts: the session read is bounded at 5 s.getSessionOrNullracesauth.api.getSessionagainst the existing abortable sleep helper; expiry throws (actions fail 500 with cause; browser routes deny as signed-out via their existing.catch(() => null)— deny either way, never proceed, never a 15 s hang). The bound is injectable for tests; production callers all use 5 s.docker/check-worker-rules.ts(+ unit tests, +check.ymlstep). CI tripwire for the class: module-level in-flight promise/cache/client in worker server code (allowlist: thewaitUntil-registeredsetupPasses), andauth.api.getSessionoutside the bounded wrapper.changelog.d/admin-app-list-timeout.md. Round-2 wording kept; added the fail-fast and bounded-read rules plus the guard reference.Security review (round 3)
Independent
security-reviewersubagent over the round-3 diff: no blockers, no regressions. All auth decisions fail closed (throw → 500 viaapiAction, or → null → 401/redirect via existing route catches; no path treats throw as authenticated). The 5 s bound is not attacker-settable in prod and reduces hold time (15 s → 5 s). No timer/listener leaks, no unhandled rejection on the losing branch. The CI scanner executes nothing it scans (read-only regex over text), no${{ }}interpolation in the new step, no ReDoS shape in the patterns. Two Low findings (both fixed): changelog wording on route-side 401s (F1), guard-scope wording softened to tripwire (F2).security-reviewedstill needs the parent (never self-applied).Verification (round 3)
/did not answer/at the injected 20 ms bound (fails pre-fix: hangs to the test timeout — the incident shape); healthy store resolves; missing session stays null.bun run checkclean,bunx tsc --noEmitclean,bun run test617 pass / 0 fail (74 files),bun run buildclean (pre-existing chunk-size warning only),bun test docker/59 pass,bun docker/check-changelog.tsclean,bun docker/check-worker-rules.tsclean (53 files).cargo fmt --checkclean; CI'srustjob is the gate).