Skip to content

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

Open
27Bslash6 wants to merge 3 commits 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 3 commits 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: 13 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: e0bd1637-633c-46a8-bb49-080afb1b9747

📥 Commits

Reviewing files that changed from the base of the PR and between d0a0e3d and 33ba044.

📒 Files selected for processing (7)
  • packages/cachekit/README.md
  • packages/cachekit/src/cache-core.ts
  • packages/cachekit/src/cache.single-flight.test.ts
  • packages/cachekit/src/cache.ts
  • packages/cachekit/src/constants.ts
  • packages/cachekit/src/exports-common.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.

Port LAB-519 single-flight + distributed-lock cold path onto the
cache-core.ts extraction from LAB-595 (#78): CacheImpl moved to the
shared core, so the single-flight map, stampede config validation, and
resolveMiss/resolveUnderLock/computeAndStore land there (now shared
with the Workers entrypoint). Backend construction stays in the
runtimes — CacheRuntime.resolveBackend grows an optional stampede
param so the Node runtime can select cachekitioWithLocking for apiKey
configs; the Workers runtime is deliberately untouched (lock-capable
backends remain an explicit construction there). StampedeConfig export
moves to exports-common.ts (platform-neutral surface).
27Bslash6 added a commit that referenced this pull request Jul 24, 2026
…lab-517-wire-metrics

Stacks LAB-517 on LAB-519 per the LAB-761 merge plan (metrics observe
the cache, so they land on top; when #77 merges this PR's diff
collapses to metrics-only). Ports the metrics wiring onto the
cache-core.ts extraction from LAB-595:

- Instrumentation (run/execute/instrument, hit/miss/L1-stats recording)
  moves into the shared CacheImpl in cache-core.ts, composing with the
  LAB-519 single-flight cold path — followers share one recorded miss.
- cache-core imports metrics/prometheus.js TYPES only (prom-client is
  Node-only and dynamic import still enters esbuild's resolve graph);
  the collector is injected via a new optional CacheRuntime.createMetrics
  hook, with an inline no-op fallback, so Workers degrade to no-ops the
  same way a missing prom-client peer does on Node.
- resolveBackend gains an l1Telemetry getter param so the Node runtime
  auto-wires the SaaS X-CacheKit-L1-* metricsProvider from live
  counters, composed with the LAB-519 lockable-wrapper selection.
- setLogger/CachekitLogger move to exports-common.ts: logger.js is in
  the shared graph (background-refresh, invalidation) and Workers apps
  need the same error sink control. Prometheus exports stay Node-only.
- intents metrics?: boolean | MetricsConfig widening ports to
  intents-core.ts (type-only import, workers-safe).
@27Bslash6

Copy link
Copy Markdown
Contributor Author

Resolved the merge conflict with main (LAB-595 extracted CacheImpl into the shared cache-core.ts): the single-flight map, stampede validation, and resolveMiss/resolveUnderLock/computeAndStore are ported into cache-core.ts (now shared with the Workers entrypoint), backend construction stays in the runtimes via a new optional stampede param on CacheRuntime.resolveBackend, and the StampedeConfig export moved to exports-common.ts. workers/runtime.ts deliberately untouched to avoid a new conflict edge with #80/#81 — on Workers, distributedLock with an apiKey config throws the existing ConfigurationError; a lock-capable backend can still be passed directly (cachekitioWithLocking is exported there).

Verified locally: type-check, lint, format, full vitest (571 passed incl. the single-flight suite). Merge-sequencing context: LAB-761.

@27Bslash6

Copy link
Copy Markdown
Contributor Author

Auto-rebase merged main in cleanly (only conflict was packages/cachekit/README.md, resolved as a non-overlapping union of the "Stampede Protection" and "Backends" sections). Blocked at the pre-push gate, not on the merge itself: the local detect-secrets hook flags high-entropy hex strings in packages/cachekit/test/protocol/fixtures/interop-mode.json (crypto interop test vectors, not real secrets) — that path isn't covered by the .pre-commit-config.yaml exclude list (only test/workers/fixtures/.* is excluded) or .secrets.baseline. Confirmed this is pre-existing and unrelated to this merge: the same failure reproduces on main's own tip in isolation, even though GitHub CI is green there. Left un-pushed since fixing it means editing security-tooling config (baseline or exclude regex), which needs a human call, not something I should force through. Once that's sorted, the merge itself is ready to redo and push.

@27Bslash6

Copy link
Copy Markdown
Contributor Author

Auto-rebase attempted: merging main resolved cleanly (only a doc conflict in packages/cachekit/README.md, resolved as a union of both sides' new sections — Stampede Protection + Backends). Lint/format/type-check all pass. Test failures (13, all encryption/protocol) reproduce identically on the unmerged branch tip in an isolated clone — a pre-existing native-binding (napi/rust) build issue in this environment, not caused by the merge.

Blocked at push: the repo's detect-secrets pre-push hook flags ~40 hex strings in packages/cachekit/test/protocol/fixtures/interop-mode.json as high-entropy secrets. Root cause is a gap in .pre-commit-config.yaml's exclude list — it excludes packages/cachekit/test/workers/fixtures/.* ("vendored protocol vectors: public hex ground truth, not secrets") but not the sibling packages/cachekit/test/protocol/fixtures/.*, which holds the same kind of AES-GCM interop test vectors. Not something I should bypass with --no-verify — needs a human call: either extend the exclude regex or baseline the file.

Merge commit is ready locally but not pushed (nothing changed on the remote branch). Once the pre-commit config is fixed, a re-run of this automation (or a manual push) will pick it up cleanly.

@27Bslash6

Copy link
Copy Markdown
Contributor Author

Attempted auto-rebase onto main (merged cleanly, only conflict was a trivial non-overlapping addition in packages/cachekit/README.md between this PR's "Stampede Protection" section and main's "Backends" section — resolved as a union). Aborted the push without committing: the repo's pre-push detect-secrets gate fails on packages/cachekit/test/protocol/fixtures/interop-mode.json (pre-existing content already on main, unrelated to this merge — the file isn't covered by .secrets.baseline or the hook's exclude list). Needs a human to update .secrets.baseline (or extend the exclude pattern) before this branch — or main itself — can pass its own gates.

@27Bslash6

Copy link
Copy Markdown
Contributor Author

Auto-rebase attempted: merging main resolved cleanly with only the same non-overlapping doc conflict as prior attempts (packages/cachekit/README.md — "Stampede Protection" vs "Backends" sections, resolved as a union). detect-secrets now passes at commit time (the earlier interop-mode.json exclude-list gap from previous attempts appears fixed).

Blocked at the test gate this time: pnpm run test has 13 failures, all in cachekit's encryption/protocol suites, root-caused to Cannot find native binding for @cachekit-io/cachekit-core-ts-linux-x64-gnu — this sandbox has no cargo/rustc on PATH at all, so napi build fails with cargo metadata failed to run and the native addon can never be built here, on this branch or on main's own tip. Lint, format, and type-check all pass; only the native-binding-dependent tests are affected.

This reproduces the same failure mode documented in earlier auto-rebase comments on this PR, now traced to a concrete cause (missing Rust toolchain in the automation sandbox) rather than something about this merge. Leaving unpushed per the "gates must pass" rule rather than judging it a safe exception — needs a human to either add a Rust toolchain to the sandbox or verify off-sandbox before this can go through automatically.

@27Bslash6

Copy link
Copy Markdown
Contributor Author

Resolved conflict with main: non-overlapping README.md sections (Stampede Protection vs. Backends) unioned in doc order. Everything else auto-merged clean. Lint/format/type-check pass; auto-rebased onto main; CI will re-run.

@kodus-27b

kodus-27b Bot commented Jul 26, 2026

Copy link
Copy Markdown

Kody Review Complete

Great news! 🎉
No issues were found that match your current review configurations.

Keep up the excellent work! 🚀

Kody Guide: Usage and Configuration
Interacting with Kody
  • Request a Review: Ask Kody to review your PR manually by adding a comment with the @kody start-review command at the root of your PR.

  • Validate Business Logic: Ask Kody to validate your code against business rules by adding a comment with the @kody -v business-logic command.

  • Provide Feedback: Help Kody learn and improve by reacting to its comments with a 👍 for helpful suggestions or a 👎 if improvements are needed.

Current Kody Configuration
Review Options

The following review options are enabled or disabled:

Options Enabled
Bug
Performance
Security
Business Logic

Access your configuration settings here.

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