Skip to content

Fix race conditions and correctness issues in caching layers#417

Merged
kibertoad merged 2 commits into
mainfrom
claude/synthesize-cache-race-fixes
Jul 3, 2026
Merged

Fix race conditions and correctness issues in caching layers#417
kibertoad merged 2 commits into
mainfrom
claude/synthesize-cache-race-fixes

Conversation

@kibertoad

Copy link
Copy Markdown
Owner

Synthesizes #414 and #415 into a single change, combining the best-of-breed solution from each and addressing every issue both raised. Where the two overlapped (getAsyncOnly write-back fencing, Redis channel filtering, the GroupLoader background-refresh in-memory propagation) the strongest version of each fix is kept; where they were disjoint, both are included.

Race conditions between invalidation and in-flight loads (from #414)

  • getAsyncOnly (flat + group) fences its write-back: the resolved value is persisted only if the dedup entry still belongs to that load. Any invalidation or forced write that evicted the entry cancels the write-back, while callers already awaiting the promise still receive the value.
  • Invalidation paths (invalidateCache, invalidateCacheFor, invalidateCacheForMany, invalidateCacheForGroup) evict running loads before and after the awaited async delete and clear the in-memory entry last, so a get racing a slow async delete cannot repopulate memory after the invalidation resolves.
  • deleteGroupRunningLoad only drops the group entry when it still points at the settling load's map, so a load settling against a stale map cannot evict a newer load's map and break deduplication.

Correctness fixes (from #415)

  • InMemoryCache.getMany / InMemoryGroupCache.getManyFromGroup treated cached falsy values (0, '', false, null) as misses via a truthy check; they now use an undefined check, consistent with single get and RedisCache.
  • InMemoryGroupCache read operations (getFromGroup, getManyFromGroup, getExpirationTimeFromGroup, deleteFromGroup) no longer insert empty groups into the LRU on a miss, which could evict a populated group's still-valid data.
  • Loader/GroupLoader preemptive-refresh expiration lookups now have a .catch, so an async-cache error no longer becomes an unhandled promise rejection.
  • RedisGroupCache.deleteGroup expires the group index with groupTtlInMsecs instead of the entry ttlInMsecs, consistent with setForGroup/setManyForGroup.

Redis notification consumers (best-of-breed merge of both)

Notes

  • The GroupLoader background-refresh in-memory propagation that both PRs added has since landed on main via Add optional staleness checker for conditional cache refresh #416 (in refreshOrBumpTtl), so it is not re-applied here; the associated regression test is still added.
  • All fixes are covered by regression tests. The full suite (322 tests) passes and tsc --noEmit is clean. Biome reports only the 10 pre-existing formatting diagnostics already present on main; this change adds none.

Supersedes #414 and #415.

Synthesizes PR #414 (invalidation/in-flight race audit) and PR #415
(correctness audit), combining best-of-breed fixes from both.

Race conditions between invalidation and in-flight loads:
- getAsyncOnly (flat + group) fences its write-back: the resolved value is
  persisted only if the dedup entry still belongs to that load, so any
  invalidation or forced write that evicted the entry cancels the write-back
  while awaiting callers still receive the value.
- Invalidation paths (invalidateCache/invalidateCacheFor/invalidateCacheForMany/
  invalidateCacheForGroup) evict running loads before and after the awaited
  async delete and clear the in-memory entry last, so a get racing a slow async
  delete cannot repopulate memory after the invalidation resolves.
- deleteGroupRunningLoad only drops the group entry when it still points at the
  settling load's map, so a load settling against a stale map cannot evict a
  newer load's map and break deduplication.

Correctness fixes:
- InMemoryCache.getMany / InMemoryGroupCache.getManyFromGroup treated cached
  falsy values (0, '', false, null) as misses via a truthy check; use an
  undefined check instead, consistent with single get and RedisCache.
- InMemoryGroupCache reads (getFromGroup, getManyFromGroup,
  getExpirationTimeFromGroup, deleteFromGroup) no longer insert empty groups
  into the LRU on a miss, which could evict a populated group's still-valid data.
- Loader/GroupLoader preemptive-refresh expiration lookups now have a .catch, so
  an async-cache error no longer becomes an unhandled promise rejection.
- RedisGroupCache.deleteGroup expires the group index with groupTtlInMsecs
  instead of the entry ttlInMsecs, consistent with setForGroup/setManyForGroup.

Redis notification consumers:
- Register the message listener before subscribing, so no message is dropped in
  the window between the SUBSCRIBE ack and listener attachment (from #414).
- Filter incoming messages by channel, so consumers sharing one connection no
  longer cross-apply each other's messages.
- Guard JSON.parse, so a malformed payload no longer crashes the consumer (#415).

All fixes are covered by regression tests.
@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@kibertoad, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 45 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 712763f7-a19b-4974-9e94-3176bde72f1d

📥 Commits

Reviewing files that changed from the base of the PR and between b2ac497 and 0d9e212.

📒 Files selected for processing (21)
  • lib/AbstractCache.ts
  • lib/AbstractFlatCache.ts
  • lib/AbstractGroupCache.ts
  • lib/GroupLoader.ts
  • lib/Loader.ts
  • lib/memory/InMemoryCache.ts
  • lib/memory/InMemoryGroupCache.ts
  • lib/redis/RedisGroupCache.ts
  • lib/redis/RedisGroupNotificationConsumer.ts
  • lib/redis/RedisNotificationConsumer.ts
  • test/GroupLoader-async-refresh.spec.ts
  • test/GroupLoader-main.spec.ts
  • test/Loader-async-refresh.spec.ts
  • test/Loader-main.spec.ts
  • test/fakes/DelayedDeleteCache.ts
  • test/fakes/MultiDelayedCountingGroupedLoader.ts
  • test/memory/InMemoryCache.spec.ts
  • test/memory/InMemoryGroupCache.spec.ts
  • test/redis/RedisGroupCache.spec.ts
  • test/redis/RedisGroupNotificationPublisher.spec.ts
  • test/redis/RedisNotificationPublisher.spec.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/synthesize-cache-race-fixes

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.

❤️ Share

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

The race-condition/correctness commit added branches that were untested,
dropping line/statement coverage below the 100% threshold and functions
below 97%, which turned CI red. Cover the new paths:

- Loader/GroupLoader: the .catch on the fire-and-forget expiration lookup,
  exercised by stubbing expirationTimeLoadingOperation to reject.
- RedisGroupNotificationConsumer: the malformed-payload guard and the
  wrong-channel filter on a shared consumer connection, mirroring the
  existing flat-consumer tests.
@kibertoad kibertoad merged commit 7ff9ec0 into main Jul 3, 2026
6 checks passed
@kibertoad kibertoad deleted the claude/synthesize-cache-race-fixes branch July 3, 2026 11:52
kibertoad added a commit that referenced this pull request Jul 3, 2026
* Apply performance optimizations from #418 on top of #417

The bug fixes originally bundled in #418 (falsy value caching in
InMemoryCache/InMemoryGroupCache getMany, and reads no longer inserting
empty groups into the LRU) already landed via #417, so only the
performance and code-quality changes remain:

- AbstractFlatCache/AbstractGroupCache: resolve the cache key once and
  reuse it across getInMemoryOnly/getAsyncOnly/get instead of resolving
  it multiple times per call
- getMany merge appends async results into the in-memory result array in
  place (it is always freshly allocated) rather than spreading into a new
  array
- Loader/GroupLoader: build cache entries with an explicit loop, merge
  cached and loaded values with concat (cachedValues may be owned by a
  user-implemented async cache), and hoist the dataSource local
- AbstractRedisCache: precompute keyPrefix; RedisGroupCache: precompute
  the group index prefix and the per-group entry prefix
- unique(): early return for arrays of 0-1 elements

Regression tests for the falsy-caching path at the loader level and for
the unique() early return are included.

* Update toad-cache
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant