Repository navigation
feat(api-cache): writes invalidate every cache that embeds the written table - #301
Merged
Merged
Conversation
A cached list stores the Eloquent result, including every relation the cached query eager loads, but its key only carried the parent table's version, so writes to embedded tables (e.g. catalogs embedded in the food truck list) were served stale until the TTL. Keys now carry the versions of the model's own table and of every table its cached result embeds, read in one Cache::many round trip: - tables a model declares in $apiCacheDependencies (relation paths, which include pivot and through tables, or raw table names) - the relations a list request loads or counts (with, expand, count, with_count) - the $with of a model lookup and the relation of a relationship lookup List keys read a table wide version next to the company one, so writes that know no company (pivots, manual invalidation, CLI) invalidate every company. Relation methods are resolved like Eloquent eager loading, on a fresh instance without constraints; client supplied paths never call framework methods and the memo is capped. An unresolvable declaration throws in debug and is logged and skipped otherwise.
roncodes
added a commit
to fleetbase/ledger
that referenced
this pull request
Oct 12, 2026
This was referenced Oct 12, 2026
Merged
…commit A release review found four ways #301 could hurt production: - Client supplied with/expand names were resolved by calling the model method of that name. A public method without required arguments could be anything: ?with=dispatch would call Order::dispatch() on a blank order, which saves a row and fires OrderDispatched. Request paths now only call methods that declare an Eloquent Relation return type (58% of relation methods do; the rest add no versions, as before #301), and are capped at 50 per request. - A relation method throwing on a blank model escaped key generation and failed the request. Resolution now contains it, and key generation runs inside cacheQueryResult's fallback, so a cache outage while reading versions falls back to the query instead of a 500. - An unresolvable declaration threw whenever app.debug was on, which some self-hosted production installs run with. It now only throws when api.cache.strict_dependencies is set (for test suites); otherwise it is logged once per process and skipped. - Model events fire before the surrounding transaction commits, so a read in that window cached the old rows under the new version until the TTL. Versions are bumped again on commit, through the transactions manager all connections share, since the transaction is often on another connection than the written model's.
Version bumps ran outside invalidateModelCache's try block, so a cache outage failed every write that invalidates: evented model writes, and with the extension PRs also quiet write paths such as location pings. A bump that fails inside an after-commit callback would also surface from commit() after the rows were written. Both now go through bumpVersions(), which logs the failure; the entries it would have retired expire within their TTL.
…ency writes Telemetry saves vehicles and trailers with model events on every device event. With dependency versioning, every list embedding vehicles (orders, storefront food trucks) would then be invalidated on every ping in any company with streaming devices, effectively uncached. withoutInvalidating() keeps the save's model events but skips the cache invalidation they trigger, so the caller can coalesce it (fleetops throttles it to once per window per table and company).
roncodes
added a commit
to fleetbase/fleetops
that referenced
this pull request
Oct 13, 2026
…d trailer saves TelematicService saves the vehicle (and trailer) with model events on every device event. Order lists (vehicleAssigned) and storefront food truck lists (vehicle) now depend on vehicles, so in any company with streaming devices they would be invalidated on every ping and never be served from cache. Telemetry saves now run through ApiCacheThrottle::telemetryWrite(): model events still fire, and the cache invalidation is coalesced to once per window per table and company (fleetbase/core-api#301's withoutInvalidating(); with an older core the save invalidates as before).
roncodes
added a commit
that referenced
this pull request
Oct 13, 2026
… methods and an unresolvable transactions manager #301 left three statements in ApiModelCache unrun, so the release branch is under the 100% coverage floor.
This was referenced Oct 13, 2026
Merged
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
Follows fleetbase/core-api#299 (merged), which made versioned keys the invalidation mechanism. A write to a table now also invalidates every cache that embeds that table.
Writes to other tables never invalidated a cache that embedded them. A cached list stores the Eloquent result, including every relation eager-loaded inside the cached query. Examples: model
$with, controlleronQueryRecord/ filter eager loads, requestwith/expand, and counts. Keys only carried the parent table's version. For example, the food truck list eager-loadscatalogs(throughcatalog_subjects), so editing or assigning a catalog never changed thefood_truckskey. Relations that a resource lazy-loads at render time are not affected, because they are queried fresh on every hit.Change
src/Support/ApiModelCache.phpKeys carry the versions of every table they embed. All versions are read in one
Cache::manyround trip:Lists:
{api_query}:{table}:{company}:v{a}.{b}...:{params}. For the model's own table and each dependency table, the key carries:api_query_version:{t}:;api_query_version:{t}:{company}.A write with no company (most pivots,
invalidateApiCacheManually()without a company, CLI/queue writes outside a session) bumps the table-wide version, which every company's keys read. Before this PR those bumps went to a key that no reader checked.Single records and relationships:
{api_model}:{table}:v{...}:{id}...and{api_relation}:{table}:v{...}:{id}:{relationship}. These use per-tableapi_model_version:{t}. Lookups are keyed by id and have no company, so these versions are per table.Dependency tables come from three sources:
protected $apiCacheDependencies = ['catalogs', 'store.networks', 'some_table'];.belongsToMany/morphToMany, and the intermediate table ofhasManyThrough/hasOneThrough.morphTohas no single related table, so it must be declared as table names.LogicExceptiononly when the newapi.cache.strict_dependenciessetting (API_CACHE_STRICT_DEPENDENCIES, default off) is on, which is meant for test suites. It deliberately doesn't key offapp.debug, because some self-hosted production installs run with debug on.with,expand,countandwith_counton list requests. These are matched in camel or snake case, the same waywithRelationships()matches them.cacheModel()adds its$withpaths andcacheRelationship()adds its relationship. A write to the related table therefore invalidates them with no declaration.Resolution mirrors Eloquent's eager loading. It calls the relation method on a fresh instance under
Relation::noConstraints(), so the inspected model is never mutated (some relation methods callsetConnection()). A relation method that throws on a blank model is contained and adds no versions.Request paths are client-supplied, so they're constrained:
Relationreturn type. Without that rule,?with=dispatchwould callOrder::dispatch()on a blank order, which saves a row and firesOrderDispatched. About 58% of relation methods across core-api, fleetops, storefront and ledger declare a return type. Untyped relations named in a request add no versions, which is the same as before this PR. Declared dependencies come from the model's own code and may name untyped relations.Key generation runs inside
cacheQueryResult()'s fallback. A cache outage while reading versions falls back to running the query rather than failing the request. Before this PR, the version read sat outside thetryblock.Writes inside a transaction bump again on commit. Model events fire before the surrounding transaction commits, so a read in that window would cache the old rows under the new version until the TTL. The second bump goes through the transactions manager that all connections share, because the transaction is often on a different connection than the written model's (for example a storefront model written inside a default-connection transaction). A rolled-back write keeps only its immediate bump, which is harmless.
src/Traits/HasApiModelCache.phpAdds
getApiCacheDependencies(). Its docblock explains what to declare: only what the cached query loads. It also explains what invalidates a dependency: the writing model usesHasApiModelBehaviororHasApiModelCache, and the write goes through Eloquent events.On deploy
All keys change format once. That costs one miss per key, and the orphaned entries expire after their TTL.
Follow-up PRs needed in the extensions
Two audits, of storefront plus core-api's cached models and of fleetops, found what each cached model's cached query loads. These declarations belong in the extensions, not here. Each extension also has writes that skip Eloquent events (query-builder
update/delete/insert,saveQuietly,updateQuietly,DB::table). No declaration can catch those, so they need an explicit invalidation.CatalogProduct(pivotcatalog_category_products) has neither cache trait.CatalogCategory::setProducts, theCatalogObserverdelete,syncSubjects,FoodTruck::setCatalogs, theNetworkStorewrites inNetworkController, and theProduct/AddonCategorybuilder updates.AddonCategory(cached undercategories) should declareaddons.Waypoint,AssetConnection,FleetDriver,FleetVehicleandVendorPersonnelhave neither cache trait.ordersitself (e.g.dispatch()usessaveQuietly).zones,ZoneObserver's manual invalidation can go.HasApiModelCache. However, itsTransactionextends core's cachedTransaction, andTransactionFilter::queryForInternaleager-loadsitems,journal.debitAccount,journal.creditAccountand five morphTo relations. So it needs declarations too.Tests
tests/Unit/Support/ApiModelCacheTest.phphas new tests that use real Eloquent relations on sqlite:They cover:
morphTo, or a non-relation method throws under debug; outside debug it's logged and skipped.delete,push,touch,getTable) are never called.with[]=payloadandexpand=tagsinvalidate on writes to their tables only; an unrelated list keeps its cache.$withlookups miss after a write to the related table, and a lookup without$withkeeps its cache.tests/Unit/Traits/HasApiModelBehaviorTest.php: its fake store gainedmany().Merged with #300
fleetbase/core-api#300 is merged into
release/v1.6.69and merged into this branch:SESSION_SCOPEconstant sits beside the dependency memo.{api_query}:{table}:{company}:v{versions}:{hash of params, path, session scope}.v{table-wide}.{company}version this PR introduces.Release review
A pre-release review of this PR found four problems; the last commit fixes them:
?with=dispatchmethod call;app.debug;Each has a test that fails on the previous commit and passes now.
One more fix came out of the live run below. The after-commit bump first used only the model's own connection, which missed a storefront write inside a default-connection transaction.
Reviewing the extension PRs turned up two more core changes:
invalidateModelCache()'stry, so a Redis outage failed every write that invalidates. The extension PRs add invalidation to more write paths, including location pings. A bump failing inside an after-commit callback would also have surfaced fromcommit()after the rows were written. Both now go throughbumpVersions(), which logs the failure; the entries it would have retired expire within their TTL.ApiModelCache::withoutInvalidating(Closure). This scope keeps a write's model events but skips the cache invalidation they trigger, so the caller can coalesce it. It exists for telemetry: fleetops saves vehicles and trailers with model events on every device event, and order and food truck lists now depend on vehicles. Without coalescing, those lists would be invalidated on every ping in any company with streaming devices. fleetops#390 coalesces them to once per minute per company. Nested scopes stay suppressed, and the scope ends even when the callback throws.Validation
ApiModelCacheTest+HasApiModelBehaviorTest+MiddlewareContractsTest: 89 passed.php -d memory_limit=512M vendor/bin/pest): 2076 passed, 0 failed (12828 assertions), on the final commit.php-cs-fixerdry-run on the changed files: no changes.phpstanon the changed files reports nothing new relative to the base.serviceArea.zones: MISS;with=request caches separately;?with=dispatch,cancel,notify_driver_assigned,dispatchWithActivityagainst Order created no orders (339 → 339).MGETreplaces aGET).INCRs when a write happens inside a transaction.Docs / API impact
There are no endpoint, payload or config changes, so nothing is needed in
fleetbase/postman. Extension developers get a new model property,$apiCacheDependencies, which deserves a short section in thefleetbase.ioextension docs on API caching.