Repository navigation
Start one stale-while-revalidate refresh per record at a time - #4367
Merged
kriszyp merged 7 commits intoNov 14, 2026
Merged
Conversation
ensureLoadedFromSource started getFromSource for every read of an expired record before asking allowStaleWhileRevalidate. While one refresh ran, every further stale read queued another background fill on the record lock; when the refresh failed, each queued fill called the source again in turn and logged its own warning. The stale policy is now decided first. A read that serves stale refreshes only if it acquires the record lock: getFromSource tries the lock without a callback and, when another fill (on any thread) holds it, returns without parking, arming a timer or touching the request's context. Using the acquisition itself as the gate leaves no window between a check and the lock. Reads that must wait for a fresh value are unchanged, and the read after a failed refresh starts a new one. Refs #1398 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A stale read now tries the record lock synchronously, without a callback, before getFromSource runs: when another fill holds the key it returns at once, minting no timestamp, encoding no key and allocating no promise. getFromSource takes over the acquired lock (lockHeld) instead of trying it again, and the FILL_IN_FLIGHT sentinel is gone. A tryLock that throws (a closed store) skips the refresh rather than failing the read. The docs now promise only what the lock gives: no other request is holding the record's fetch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, and report an untryable lock A stale read takes the record lock before getFromSource runs, so a throw while reading the context's timestamp or encoding the key would have left that lock with no owner to release it; getFromSource now releases a handed-over lock when that setup throws. (A throw from the issuance fallback means the store is closing, and closing releases the handle's locks.) A tryLock that throws is logged as a warning, as it was when the attempt ran inside getFromSource, and the read serves stale. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ngines A context whose timestamp read throws once the stale read holds the lock now reaches the release in getFromSource; without that release the next stale read never refreshes. The other-thread test takes the record lock from a worker thread instead of a second handle on this one, so it runs on LMDB too. Teardown waits for every refresh to commit and release its lock. A moved comment that restated the code is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5.4.0 shipped before this change, so it lands in a 5.4 patch release, and patch releases are described in GitHub releases, not in fragments. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er inert under mocha Stacked on the fill hold limit, a stale refresh whose source never settles now holds the record only until the limit; a test leaves one refresh unsettled and checks the next stale read refreshes after it. The worker helper does nothing unless the test passes workerData, as mocha's resources glob also loads it on the main thread, where it threw and aborted the run. The setup guard now also covers issuing the source version, whose fallback can throw on a closing store. The comment above the stale admission is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Nov 13, 2026
ldt1996
marked this pull request as ready for review
November 14, 2026 14:36
kriszyp
approved these changes
Nov 14, 2026
kriszyp
left a comment
Member
There was a problem hiding this comment.
🤖 Reviewed with Muse Wonder 1.0 · adjudicated by Codex
| } | ||
| }); | ||
|
|
||
| it('starts no refresh while another thread holds the record lock', async function () { |
Member
There was a problem hiding this comment.
[nit] Possible LMDB-only failure in the foreign-lock test remains disputed
kriszyp
pushed a commit
that referenced
this pull request
Nov 14, 2026
…#4366) * Add a test seam for the source-fill lock timeout setSourceFillLockTimeoutForTesting() lowers how long a request waits on a source fill's record lock before its timeout fires, so a test can reach that path without waiting ten seconds. Behavior is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Release a source fill's record lock only from the fill that took it A request waiting on a sourcedFrom fill's record lock unlocked it after LOCK_TIMEOUT, and neither engine's unlock checks ownership. When the source took longer than that (a slow origin), a waiter released the lock its live fill still held, a second fill started, and the first fill's own release later freed the second fill's lock, waking every waiter again. The fill that acquires the lock now releases it exactly once, through an idempotent releaseFill: when its commit settles, or when its tenure reaches LOCK_TIMEOUT, so a source call or commit that never settles still frees the key. A waiter's timer only rechecks hasLock() and keeps its one registration instead of unlocking or re-queueing; a closed store makes hasLock() throw, so a waiter whose own handle closed still settles. A holder that goes away releases its locks natively: closing a RocksDB handle (lockReleaseByOwner) or an LMDB environment wrapper (closeEnv) frees them and wakes their waiters. Refs #1398 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Cite the close test in the record-lock note and guard the tenure test's teardown The note's close half is still enforced by cacheFillClose.test.js. The test file's header comment said a waiter only wakes on its timeout, but an unlock wakes it too, so it is gone; afterEach no longer assumes beforeEach ran. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Badge the change for 5.4.1 and drop its 5.4 release-note fragment 5.4.0 shipped before this change, so it lands in a 5.4 patch release, and patch releases are described in GitHub releases, not in fragments. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Let the tenure tests' teardown wait for every fill they started A read left unsettled by a test, such as the stuck first fill, resumed after the hooks returned and committed into the next test's database. Teardown now settles the reads, waits until their records are unlocked, and waits for pending commits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Start one stale-while-revalidate refresh per record at a time (#4367) * Start one stale-while-revalidate refresh per record, not one per read ensureLoadedFromSource started getFromSource for every read of an expired record before asking allowStaleWhileRevalidate. While one refresh ran, every further stale read queued another background fill on the record lock; when the refresh failed, each queued fill called the source again in turn and logged its own warning. The stale policy is now decided first. A read that serves stale refreshes only if it acquires the record lock: getFromSource tries the lock without a callback and, when another fill (on any thread) holds it, returns without parking, arming a timer or touching the request's context. Using the acquisition itself as the gate leaves no window between a check and the lock. Reads that must wait for a fresh value are unchanged, and the read after a failed refresh starts a new one. Refs #1398 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Admit a stale refresh before starting it, so a skipped read does no work A stale read now tries the record lock synchronously, without a callback, before getFromSource runs: when another fill holds the key it returns at once, minting no timestamp, encoding no key and allocating no promise. getFromSource takes over the acquired lock (lockHeld) instead of trying it again, and the FILL_IN_FLIGHT sentinel is gone. A tryLock that throws (a closed store) skips the refresh rather than failing the read. The docs now promise only what the lock gives: no other request is holding the record's fetch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Release a lock a stale read handed over if the refresh fails to start, and report an untryable lock A stale read takes the record lock before getFromSource runs, so a throw while reading the context's timestamp or encoding the key would have left that lock with no owner to release it; getFromSource now releases a handed-over lock when that setup throws. (A throw from the issuance fallback means the store is closing, and closing releases the handle's locks.) A tryLock that throws is logged as a warning, as it was when the attempt ran inside getFromSource, and the read serves stale. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Test the stale refresh's lock release and other-thread skip on both engines A context whose timestamp read throws once the stale read holds the lock now reaches the release in getFromSource; without that release the next stale read never refreshes. The other-thread test takes the record lock from a worker thread instead of a second handle on this one, so it runs on LMDB too. Teardown waits for every refresh to commit and release its lock. A moved comment that restated the code is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Badge the change for 5.4.1 and drop its 5.4 release-note fragment 5.4.0 shipped before this change, so it lands in a 5.4 patch release, and patch releases are described in GitHub releases, not in fragments. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Free a hung stale refresh at its hold limit, and keep the worker helper inert under mocha Stacked on the fill hold limit, a stale refresh whose source never settles now holds the record only until the limit; a test leaves one refresh unsettled and checks the next stale read refreshes after it. The worker helper does nothing unless the test passes workerData, as mocha's resources glob also loads it on the main thread, where it threw and aborted the run. The setup guard now also covers issuing the source version, whose fallback can throw on a closing store. The comment above the stale admission is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Drop a test comment that narrated the teardown wait Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Lavinia <lavinia@Lavinias-MacBook-Pro.local> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Lavinia <lavinia@Lavinias-MacBook-Pro.local> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.
⊙ Problem
When
allowStaleWhileRevalidateserves an expired record, every read also queued its own background refresh behind the running one. If the source kept failing, each queued refresh called it again and logged a warning: one source call and one warning per read. 300 stale reads of a record whose source fails after 150 ms made 300 calls and 300 warnings over 45 seconds.💡 Solution
allowStaleWhileRevalidatenow runs before the refresh starts.🤔 Skip, or queue one retry?
A stale read that finds the record busy skips the refresh, so after a failed refresh the record stays stale until the next read. Queueing one retry per record would refresh sooner, at the cost of a waiting request per record. Switching later touches only the stale path.
⚖️ Alternatives
The planning review found a better approach than the one first proposed, a
hasLockpre-check: take the lock atomically with a callback-freetryLock, and refresh only on success. This PR adopts it.Framing-Verdict: better-alternative-exists
hasLock, then lock inside the fetch — two threads can both see the record free and both refresh.🔧 Changes
resources/Table.ts: the stale path takes the record lock with a callback-freetryLockand hands it togetFromSourcethrough a newlockHeldparameter, which skips the fetch's owntryLock. A busy record returns stale at once. AtryLockthat throws is logged, and the read serves stale.getFromSourcereleases a handed-over lock if anything before the fetch owns it throws, from reading the context to issuing the version.docs/reference/resources/resource-api.md: a v5.4.1 paragraph on the single refresh and the earlier hook call.Product and architecture tour
Product and architecture tour
One refresh per expired record
What does a read of an expired record do when stale serving is allowed?
A stale read, step by step
allowStaleWhileRevalidatedecides whether this read may be served stale.tryLock, so a busy record registers no waiter.getFromSourceand returns the stale record.✅ Verification
unitTests/resources/staleRefreshOncePerKey.test.js: reads queue a refresh each; the query path makes 2 calls, expected 1; a record another thread holds is refreshed anyway.tryLock, and a busy record skips the refresh setup.Test cases
tryLockthat throws is logged, and the read serves stale.🤖 Generated by Claude Opus 5.5 (Claude Code); posted via @ldt1996.
Release target: v5.4 — main is the 5.4 line; fixes stale-while-revalidate behavior that shipped in 5.4.0
Related PRs: #2155 overlaps (its drain may assume getFromSource takes the fill lock), #2918 overlaps (a lock handed in by a stale read skips the acquisition it orders), 5 others independent
Complexity: complicated
Deferred findings
78defa8349fe2006minor correctness —resources/Table.ts— Database-wide bare-id fill lock makes a stale read skip its refresh when another table fills the same id — NOT FILED6aef72546c35fcc8nit correctness —resources/Table.ts— Background refresh .then mutates the leading stale read's context after it returned — NOT FILED210a587193a7fbb1nit maintainability —resources/Table.ts— New comments in the lock-tenure prerequisite (Table.ts:461, :14338, sourceFillLockTenure.test.js) — NOT FILEDReview-Coverage: authored=claude; ran=codex,gemini,gemini-carbon; late=gemini-carbon:adjudicated; adjudicated=domain; blocked=cursor-composer(not-installed); declined=cursor-grok,muse; rounds=8; full=3 @ 8414d3c
Review-Attention: deep ~35m (critical: Table.ts; decisions: stale-busy-skip, waiters-never-unlock, fixed-10s-tenure, do-less-alternative, release-target-unclear; raised: degraded review) @ 8414d3c