Repository navigation
fix(api-cache): invalidation changes list, model and relationship cache keys - #299
Merged
roncodes merged 2 commits intoOct 12, 2026
Merged
Conversation
A missing api_query_version key was read as 1 and Cache::increment creates it at 1, so the first invalidation for a table/company left list cache keys unchanged and stale lists were served until a second write. Missing versions now read as 0, and stores whose increment does not create missing keys get the version seeded. The version bump is what actually invalidates list caches: on Laravel 10 with phpredis >= 6.1, RedisTagSet::entries() starts ZSCAN at cursor '0', which phpredis treats as a finished scan, so the tag flush deletes the tag sets but leaves the tagged values in Redis until their TTL.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## release/v1.6.69 #299 +/- ##
===================================================
Coverage 100.00% 100.00%
- Complexity 8271 8276 +5
===================================================
Files 454 454
Lines 26445 26453 +8
===================================================
+ Hits 26445 26453 +8
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Single model and relationship caches had no version, so with the Redis tag flush leaving values behind they were served stale until their TTL after a write. Their keys now carry a per table api_model_version that invalidateModelCache() bumps alongside the query version. It is per table because findCached/findByPublicIdCached/loadCached are keyed by id and know no company. The increment-or-seed logic is shared via bumpVersion().
This was referenced Oct 12, 2026
This was referenced Oct 12, 2026
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.
What
After a write, invalidation now changes the keys of every API cache: lists, single records and relationships. Before this, stale data was served:
generateQueryCacheKey()read a missingapi_query_version:{table}:{company}as1.invalidateModelCache()then calledCache::increment(), which creates a missing key at1. The key stayed atv1, so the list cached before the write kept being served until a second write. This was observed onfood_trucks: the list endpoint kept returning oldcatalogsuntil a second invalidation moved the key tov2.Change (
src/Support/ApiModelCache.php)Missing versions read as
0.INCRcreates a missing key at1, so even the first bump moves readers to a new key.INCRis atomic on Redis, so concurrent first invalidations can't move a version backwards.Shared
bumpVersion(). Some stores'incrementdoesn't create missing keys and returnsfalse(database, memcached). On those stores the version is seeded withCache::add($key, 1), or incremented if another writer created it first.Model and relationship keys are versioned. They now carry a per-table
api_model_version:{table}:{api_model}:{table}:v{n}:{id}[:{withHash}]{api_relation}:{table}:v{n}:{id}:{relationship}invalidateModelCache()bumps it alongside the query version. It is per table rather than per company becausefindCached/findByPublicIdCached/loadCachedare keyed by id and have no company to read. That is the same granularity as the oldapi_model:{table}tag. Relationships share their parent table's version because they were tagged with the parent's model tags.One key helper per version.
queryVersionKey()andmodelVersionKey()are used by both the read and write paths, so the key names can't drift.Comments. They now say that the version bumps are what invalidate caches.
When this deploys, existing keys move to
v0(model and relationship keys also gain thev{n}segment). That costs one cache miss per key, and the old entries expire after their TTL.What's still coarse (unchanged by design):
HasApiModelCachetrait callsfindCached/findByPublicIdCached/loadCached.Why the tag flush leaves entries behind
The
{api_query}hash tag and our tag naming aren't the cause. The cause is a framework bug in Laravel 10.50.3 (the final 10.x release, which is whatilluminate/*: ^10.0resolves to) when it runs with phpredis ≥ 6.1 (the dev container has 6.3.0):RedisTagSet::entries()startsZSCANwith cursor'0'. phpredis ≥ 6.1 reads that as "scan already finished" and returns no members. SoRedisTaggedCache::flush()deletes nothing influshValues()and then deletes only the tag sorted sets. The tagged values stay in Redis until their TTL runs out.I reproduced this in
fleetbase-dev-application-1with a throwaway tag. The tag set held 1 member.zscan(set, '0')returned["0", []]andzscan(set, null)returned the member. AfterCache::tags($t)->flush(), the raw key still existed andCache::tags($t)->get()still returned the value. This PR doesn't patch the framework. Versioned keys are now the documented invalidation mechanism.Worth a decision later (not changed here): if the flush did work (phpredis < 6.1, predis, or a later Laravel), every write would flush the union of its tags. That union includes
api_cache, so one write would wipe every API cache for every table and every tenant.Tests
tests/Unit/Support/ApiModelCacheTest.php. The fake store can now simulate a flush that leaves values behind and an increment that doesn't create missing keys. Three tests are new:All three fail against the previous code. Existing key expectations changed to the
v0/v{n}forms.Validation
vendor/bin/pest tests/Unit/Support/ApiModelCacheTest.php: 18 passed (133 assertions).ApiModelCacheTest,HasApiModelBehaviorTestandMiddlewareContractsTesttogether: 72 passed.php -d memory_limit=512M vendor/bin/pest(thetest:unitcommand): 2058 passed, 0 failed (12747 assertions). The earlierUtilsTestfailure was a stubcomposer.lockin my local worktree, and it passes now that the real lock is in place.composer test:date-drift: passed.php-cs-fixer fix -v --dry-run: 0 files to fix.v0→v1→v2, and each read after an invalidation was a MISS returning fresh data. Probe keys were removed afterwards.Docs / API impact
None. This is internal cache invalidation: no endpoint, payload, or config changes, so nothing is needed in
fleetbase/postmanorfleetbase/fleetbase.io.