Skip to content

fix: cache hits on falsy values, method and caller in the key, and failure handling - #2

Open
sur-ser wants to merge 4 commits into
m8a-io:mainfrom
sur-ser:fix/cache-correctness
Open

fix: cache hits on falsy values, method and caller in the key, and failure handling#2
sur-ser wants to merge 4 commits into
m8a-io:mainfrom
sur-ser:fix/cache-correctness

Conversation

@sur-ser

@sur-ser sur-ser commented Aug 9, 2026

Copy link
Copy Markdown

Summary

Nine defects in CacheableInterceptor and NestjsCacheableService, each reproduced by a test that fails on main and passes here. The new tests live in cacheable.interceptor.regression.spec.ts.

# Defect Effect
1 if (cachedValue) treats a hit as a miss for falsy values An endpoint returning 0, '', false or null is never served from cache and runs on every request
2 The key is request.url, without the method A POST /items response is stored under /items and then served to GET /items
3 The key ignores the caller Two authenticated users on the same url receive each other's response
4 No single flight Concurrent misses on one key all run the handler, so a cold key hits the origin N times
5 firstValueFrom(next.handle()) Only the first emission of the handler survives; anything emitting more than once is truncated
6 A failing cache read is not caught A Redis hiccup turns a healthy request into a 500
7 A failing cache write is not caught The handler has already produced a correct response, and it is thrown away because the write failed
8 JSON.stringify(value) in the log line of set() Caching an object with a cycle throws TypeError: Converting circular structure to JSON; every response body is also written to the log at log level
9 disconnect() only closes secondary The primary store is left open on shutdown

Two more, outside the runtime:

  • peerDependencies does not list @nestjs/common or @nestjs/core, although the library imports both. Consumers get no compatibility signal and no warning on a mismatched Nest version.
  • Every code example in the README imports from @m8a-io/nestjs-cacheable, while the package is published as @m8a/nestjs-cacheable (the install line one screen above uses the right name). Copy-pasting any example fails to resolve.

What changed

Interceptor

  • Values are stored in a { value } envelope, so a cached falsy value is a hit. A miss is now undefined, not "falsy".
  • The key is METHOD:url, and only GET and HEAD are cached at all. A mutation response can no longer be served to a read.
  • Requests carrying credentials (request.user, Authorization, Cookie) are not cached by default. getCacheKey and isPerCaller are protected, so caching them under a per-user key is a three line subclass, shown in the README.
  • Concurrent misses on the same key share one execution through shareReplay, and the entry is dropped from the in-flight map on completion.
  • The response is piped rather than awaited, so every emission reaches the client.
  • Cache reads and writes are wrapped: a backend failure is logged at debug and the request is answered from the handler.

Service

  • disconnect() closes the primary store as well as the secondary.
  • set() returns the result of the underlying write instead of a hardcoded true.
  • The log line no longer serialises the value: no circular-structure crash, no response bodies in the log, and it moved from log to debug.
  • set() and CacheTTL accept number | string, matching CacheableOptions.ttl, which already allowed both.

Behaviour changes to be aware of

  • Cached payload format. Entries are now { value }. Anything left in a shared Redis from a previous version reads as a miss and is overwritten. Harmless, but worth a line in the release notes.
  • Credentialed requests are no longer cached unless you override getCacheKey. This is the fix for defect 3, and it is the conservative direction: the previous behaviour served one user's response to another. If you would rather keep caching them by url, that is a one line change, but I would not recommend shipping it as a default.
  • Only GET and HEAD are cached. Previously any method was.

Testing

  • npm test: 27 tests, all green. main has 18.
  • Three assertions in the existing cacheable.interceptor.spec.ts were updated, since they encoded the old contract (raw value, key without method).
  • npx tsc --noEmit -p libs/nestjs-cacheable/tsconfig.lib.json and the library build are clean.
  • npm run test:e2e was not run here: test/globalSetup.ts requires a Redis reachable on localhost:6379 and I did not want to write into an unrelated instance. The e2e specs issue plain unauthenticated GET /test requests, so the new key and the credential rule do not change what they assert.
  • npm run lint is not clean on this branch, but it is not clean on main either (19 errors before, and the --fix pass rewrites the whole repository because the committed sources are formatted without semicolons while .prettierrc leaves semi at its default). I kept the existing style in the files I touched rather than reformat them, so the diff stays readable.

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