Skip to content

fix(api-cache): invalidation changes list, model and relationship cache keys - #299

Merged
roncodes merged 2 commits into
release/v1.6.69from
fix/api-model-cache-first-invalidation
Oct 12, 2026
Merged

roncodes merged 2 commits into
release/v1.6.69from
fix/api-model-cache-first-invalidation

Conversation

@roncodes

@roncodes roncodes commented Oct 12, 2026 •

Copy link
Copy Markdown
Member

What

After a write, invalidation now changes the keys of every API cache: lists, single records and relationships. Before this, stale data was served:

  • Lists, on the first write. generateQueryCacheKey() read a missing api_query_version:{table}:{company} as 1. invalidateModelCache() then called Cache::increment(), which creates a missing key at 1. The key stayed at v1, so the list cached before the write kept being served until a second write. This was observed on food_trucks: the list endpoint kept returning old catalogs until a second invalidation moved the key to v2.
  • Single records and relationships, on every write, until their TTL ran out (1 h / 30 min). Their keys had no version, so they relied entirely on the tag flush, and on this stack the flush doesn't delete values (see below).

Change (src/Support/ApiModelCache.php)

  • Missing versions read as 0. INCR creates a missing key at 1, so even the first bump moves readers to a new key. INCR is atomic on Redis, so concurrent first invalidations can't move a version backwards.

  • Shared bumpVersion(). Some stores' increment doesn't create missing keys and returns false (database, memcached). On those stores the version is seeded with Cache::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 because findCached / findByPublicIdCached / loadCached are keyed by id and have no company to read. That is the same granularity as the old api_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() and modelVersionKey() 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 the v{n} segment). That costs one cache miss per key, and the old entries expire after their TTL.

What's still coarse (unchanged by design):

  1. A write in any company bumps that table's model version for all companies. Today that has no cost, because nothing outside core-api's own HasApiModelCache trait calls findCached / findByPublicIdCached / loadCached.
  2. A relationship cache is invalidated when its parent's table is written, not when the related table is written. This is the same as the old tag design.

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 what illuminate/*: ^10.0 resolves to) when it runs with phpredis ≥ 6.1 (the dev container has 6.3.0):

RedisTagSet::entries() starts ZSCAN with cursor '0'. phpredis ≥ 6.1 reads that as "scan already finished" and returns no members. So RedisTaggedCache::flush() deletes nothing in flushValues() 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-1 with a throwaway tag. The tag set held 1 member. zscan(set, '0') returned ["0", []] and zscan(set, null) returned the member. After Cache::tags($t)->flush(), the raw key still existed and Cache::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:

  • A list cached before any invalidation is not served after the first invalidation, even when the tag flush leaves the entry behind. A second invalidation also misses (version 1 → 2).
  • Model and relationship lookups cached before an invalidation are not served after it, even when the tag flush leaves them behind. A second invalidation with no company also moves them (version 1 → 2).
  • Versions are seeded on stores whose increment doesn't create missing keys.

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, HasApiModelBehaviorTest and MiddlewareContractsTest together: 72 passed.
  • php -d memory_limit=512M vendor/bin/pest (the test:unit command): 2058 passed, 0 failed (12747 assertions). The earlier UtilsTest failure was a stub composer.lock in 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.
  • Ran end-to-end against the dev container's real Redis store using a probe table. The sequence was: cache → invalidate → read, repeated twice, for lists, single records and relationships. Every key went 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/postman or fleetbase/fleetbase.io.

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.
@roncodes roncodes added the bug Something isn't working label Oct 12, 2026
@codecov

codecov Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (555ff39) to head (f4a20cb).

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     
Flag Coverage Δ
backend 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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().
@roncodes roncodes changed the title fix(api-cache): first invalidation changes the list cache key fix(api-cache): invalidation changes list, model and relationship cache keys Oct 12, 2026
@roncodes
roncodes merged commit a7b9d49 into release/v1.6.69 Oct 12, 2026
5 checks passed
@roncodes
roncodes deleted the fix/api-model-cache-first-invalidation branch October 12, 2026 13:25
@roncodes roncodes changed the title fix(api-cache): invalidation changes list, model and relationship cache keys fix(api-cache): writes invalidate every cache that embeds the written table Oct 12, 2026
@roncodes roncodes changed the title fix(api-cache): writes invalidate every cache that embeds the written table fix(api-cache): invalidation changes list, model and relationship cache keys Oct 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant