Repository navigation
feat(cache): add scope option to confine invalidation to a tenant - #58
Draft
fratzinger wants to merge 1 commit into
Draft
fratzinger wants to merge 1 commit into
fratzinger wants to merge 1 commit into
Conversation
Without it, any mutation invalidates every cached `find` of every tenant, so a service where each row carries a `companyId` barely ever hits the cache. `scope` is one function branched on `context.method`: for `get`/ `find` it returns the scope the result is confined to, for mutations every scope the mutation may affect. The scope becomes the first key segment, so two tenants can never be served each other's entries. Safety is one-directional by construction. Whenever the scope cannot be determined — the resolver returned nothing usable or threw, `$select` stripped the field, a `patch` may move a row between tenants — the hook falls back to the behaviour it has without the option, so enabling `scope` never invalidates less than before. Every fallback emits a new `degrade` event, because the common fallback does not call `clear()` and would otherwise look exactly like a healthy scoped run. Also fixes three invalidation holes found while reworking the key parser: - an id containing `:` was truncated by the parser, never matched, and its cached `get` entry was never invalidated - a mutation whose result items partially lacked ids invalidated only the known ones and left the rest silently stale - the id `0` was treated as unknown and triggered a full wipe Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deploying feathers-utils with
|
| Latest commit: |
2373c0d
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://4c6b91ae.feathers-utils.pages.dev |
| Branch Preview URL: | https://feat-cache-scope.feathers-utils.pages.dev |
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.
Warning
WIP — opened for early feedback on the API shape and the safety rules. Not ready to merge.
Summary
In a multi-tenant app every row carries a
companyId(oruserId, orlocale, …) and every call is effectively confined to one of them. Today any mutation invalidates every cachedfindof every tenant, so with many tenants the cache barely ever hits.scopeis one function, branched oncontext.method:A
createin companyanow drops companya's cachedfindentries and leaves companyb's alone. The scope also becomes the first key segment, which closes a leak that exists today: derive the tenant fromparams.user.companyIdwhiletransformParamskeeps onlyquery, and two tenants collide on the same key.Never caching too much
The guiding rule was: a false hit is unacceptable, a false miss is merely wasteful. So there are three levels, and the middle one is exactly what the hook does without the option:
findentries plus every unscoped one;getentries by idfindentry;getentries by idundefinedmap.clear()Enabling
scopetherefore never invalidates less than the default. An entry stored without a resolvable scope is invalidated by every mutation, and a scoped mutation still clears those unscoped entries — otherwise an entry written once would go stale forever.Because the common fallback does not call
clear()and emits the sameinvalidateevents a healthy scoped run does, silent under-scoping would be invisible. Hence a newdegradeevent carrying areason(no-scope|scope-error|no-ids), and areasononclear.Three bugs fixed along the way
Found while reworking the key parser, each one a stale-data hole:
:(composite orurn:-style keys) was truncated at the first delimiter, never matched the mutated ids, and its cachedgetentry was never invalidated$select) invalidated only the known ones and left the rest silently stale; now a single unknown id forces a full clear0fell through afilter(Boolean)and triggered an unnecessary full wipeEach has a regression test, and I verified every one fails against the old implementation.
Other Information
Test refactor in the same diff (sorry). The three near-identical storage blocks (
lru-cache/@isaacs/ttlcache/native Map) became onedescribe.each. I diffed them first to confirm thelru-cacheblock was a strict superset, so nothing was lost — and TTLCache and Map now run 12 tests each instead of 7. That is where the ~800 deletions come from; the file shrank from 1976 to 1682 lines while gaining coverage. Happy to split this into its own commit if that makes review easier.Key format changes only when
scopeis set:<scope>:<id>:<params>, with the scope and id segments URI-encoded. Non-adopters keep their format. Entries written before enabling it are never served — a new read computes a three-segment key, which can never equal a two-segment one — but in a persistent store they linger until they expire, so bump your key prefix or flush on deploy. Documented.Type-level only, no runtime break:
CacheEventgains a requiredreasononclearand a newdegrademember. That breaks an exhaustiveswitchwith aneverdefault, nothing else.pnpm testis green: 1514 tests, coverage ofcache.hook.tsat 99.21% statements / 94.94% branches.Open questions
{ read, mutation }pair? I went with one because a per-item resolver cannot express "thispatchmay move rows, give up", which is the decision that actually protects against stale data.{ companyId: { $in: ['a','b'] } }) currently fall back to unscoped. Supporting them means joining scopes into the key segment and splitting/intersecting per key in the invalidation loop. I don't think it earns the complexity — an unscoped read is exactly today's caching, so nothing regresses.🤖 Generated with Claude Code