Skip to content

feat(cache): cold-miss single-flight + opt-in cross-process locking (LAB-519)#77

Open
27Bslash6 wants to merge 1 commit into
mainfrom
lab-519-cold-miss-single-flight
Open

feat(cache): cold-miss single-flight + opt-in cross-process locking (LAB-519)#77
27Bslash6 wants to merge 1 commit into
mainfrom
lab-519-cold-miss-single-flight

Conversation

@27Bslash6

Copy link
Copy Markdown
Contributor

Closes LAB-519.

Problem

The SDK prevented refresh stampedes (SWR version tokens + refreshingKeys + maxConcurrentRefreshes) but not cold-miss stampedes: wrap()'s cold path was a bare await fn(...args), so N concurrent cold callers all executed the function, all issued the L2 GET (N billed misses under metered-misses), and all wrote L2. The LockableBackend capability (acquireLock/releaseLock) existed with zero callers in the live cache path.

Changes

In-process single-flight (always on) — concurrent wrap() calls for one cache key share a single in-flight promise. The flight covers the L2 read, the compute, and the write: the GET-miss is the billed event, so deduping only the compute would still bill N misses. A failed flight rejects every waiter and is evicted, so the next call retries fresh.

Cross-process distributed lock (opt-in, stampede.distributedLock) — wires LockableBackend around the miss path, mirroring cachekit-py's acquire_lock flow (wrapper.py): acquire → double-check L2 → compute → write → release. Since acquireLock never blocks on contention (LAB-240), contested waiters retry the lock on an interval bounded by lockWaitMs, then fall through to computing — deliberately not polling get(), because on a metered-misses backend every poll GET against a still-cold key is itself a billed miss. Lock failures degrade to an unlocked compute (best-effort mitigation, never a correctness gate), and lock calls stay outside the reliability executor so they neither stack retry latency nor open the circuit breaker for data ops. Config-based SaaS backends automatically get the lockable wrapper when the opt-in is set; an opt-in on a lock-incapable backend throws ConfigurationError rather than silently doing nothing.

Admission control beyond maxConcurrentRefreshes: evaluated and declined (recorded on StampedeConfig + README): on Node's single-threaded event loop concurrent misses don't compete for threads, single-flight collapses the per-key herd (the amplification vector metered-misses punishes), and distinct-key miss floods are bounded by backend timeouts + circuit breaker. A global semaphore would add queueing latency and a tuning knob without a failure mode it prevents. Revisit only with evidence of backend connection exhaustion.

Tests

10 new tests in cache.single-flight.test.ts: herd shares one read/compute/write; distinct keys don't share; flight eviction after settle; shared rejection + fresh retry; two cache instances over one backend simulating two processes (contested waiter picks up the winner's write via post-grant double-check — calls === 1); permanent contention falls through to compute; acquireLock throwing degrades to compute; lock released when compute throws; config validation. Full suite: 566 passed, 1 skipped (pre-existing), lint + format + type-check green.

Docs

Package README: stampede-protection section + feature bullet. Protocol sdk-feature-matrix.md and docs.cachekit.io follow in companion PRs (matrix cell for TS stampede moves from refresh-path-only to full).

…LAB-519)

A cold L1+L2 miss previously executed the wrapped function once per
concurrent caller — N cold callers meant N upstream calls, N L2 GETs
(N billed misses under metered-misses), and N L2 writes.

In-process (always on): concurrent wrap() calls for one cache key share
a single in-flight promise covering the L2 read, compute, and write.
The L2 read must be inside the flight — the GET-miss is the billed
event, so deduping only the compute would still bill N misses.

Cross-process (opt-in, stampede.distributedLock): wires the previously
caller-less LockableBackend capability around the miss path, mirroring
cachekit-py's acquire_lock flow — acquire, double-check L2, compute,
write, release. Contested waiters retry the lock on an interval rather
than polling get() (a poll GET against a still-cold key is itself a
billed miss), then fall through to computing after lockWaitMs: the
lease is best-effort mitigation, never a correctness gate. Lock calls
stay outside the reliability executor so lock failures neither stack
retry latency nor open the circuit breaker for data operations.

Deliberately no general admission-control cap beyond L1's
maxConcurrentRefreshes: on Node's single-threaded event loop concurrent
misses don't compete for threads, single-flight collapses the per-key
herd, and distinct-key floods are bounded by backend timeouts plus the
circuit breaker. Decision recorded on StampedeConfig.

Co-authored-by: multica-agent <github@multica.ai>
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 15 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 08398465-2fb9-4208-a106-ec0d911f7651

📥 Commits

Reviewing files that changed from the base of the PR and between a70e23f and 8d7e9d3.

📒 Files selected for processing (6)
  • packages/cachekit/README.md
  • packages/cachekit/src/cache.single-flight.test.ts
  • packages/cachekit/src/cache.ts
  • packages/cachekit/src/constants.ts
  • packages/cachekit/src/index.ts
  • packages/cachekit/src/types/cache.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch lab-519-cold-miss-single-flight

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

@27Bslash6

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant