Skip to content

feat(api-cache): writes invalidate every cache that embeds the written table - #301

Merged
roncodes merged 5 commits into
release/v1.6.69from
feat/api-cache-dependencies
Oct 13, 2026
Merged

roncodes merged 5 commits into
release/v1.6.69from
feat/api-cache-dependencies

Conversation

@roncodes

@roncodes roncodes commented Oct 12, 2026 •

Copy link
Copy Markdown
Member

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, controller onQueryRecord / filter eager loads, request with / expand, and counts. Keys only carried the parent table's version. For example, the food truck list eager-loads catalogs (through catalog_subjects), so editing or assigning a catalog never changed the food_trucks key. Relations that a resource lazy-loads at render time are not affected, because they are queried fresh on every hit.

Change

src/Support/ApiModelCache.php

Keys carry the versions of every table they embed. All versions are read in one Cache::many round trip:

  • Lists: {api_query}:{table}:{company}:v{a}.{b}...:{params}. For the model's own table and each dependency table, the key carries:

    • the table-wide version, api_query_version:{t}:;
    • the company version, 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-table api_model_version:{t}. Lookups are keyed by id and have no company, so these versions are per table.

Dependency tables come from three sources:

  • Declared on the model as protected $apiCacheDependencies = ['catalogs', 'store.networks', 'some_table'];.
    • Each entry is a relation path or a raw table name.
    • A relation contributes its related table, the pivot table of belongsToMany / morphToMany, and the intermediate table of hasManyThrough / hasOneThrough.
    • morphTo has no single related table, so it must be declared as table names.
    • An entry that can't be resolved is logged once per process and skipped, so an extension's bad declaration never fails a request. It throws a LogicException only when the new api.cache.strict_dependencies setting (API_CACHE_STRICT_DEPENDENCIES, default off) is on, which is meant for test suites. It deliberately doesn't key off app.debug, because some self-hosted production installs run with debug on.
  • Request relations: with, expand, count and with_count on list requests. These are matched in camel or snake case, the same way withRelationships() matches them.
  • Lookups: cacheModel() adds its $with paths and cacheRelationship() 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 call setConnection()). A relation method that throws on a blank model is contained and adds no versions.

Request paths are client-supplied, so they're constrained:

  • A method is only called if it declares an Eloquent Relation return type. Without that rule, ?with=dispatch would call Order::dispatch() on a blank order, which saves a row and fires OrderDispatched. 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.
  • At most 50 paths are read per request.
  • Resolution is memoized per process, capped at 1000 entries, because Octane workers are long-lived.

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 the try block.

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.php

Adds getApiCacheDependencies(). Its docblock explains what to declare: only what the cached query loads. It also explains what invalidates a dependency: the writing model uses HasApiModelBehavior or HasApiModelCache, 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.

  • storefront (fleetbase/storefront#140): declarations for FoodTruck, Catalog, Product and Store.
    • CatalogProduct (pivot catalog_category_products) has neither cache trait.
    • Builder writes need invalidation: CatalogCategory::setProducts, the CatalogObserver delete, syncSubjects, FoodTruck::setCatalogs, the NetworkStore writes in NetworkController, and the Product / AddonCategory builder updates.
    • AddonCategory (cached under categories) should declare addons.
  • fleetops (fleetbase/fleetops#390): declarations for Order, Vehicle, Driver, Device, DeviceEvent, Vendor, ServiceArea, Issue and FuelReport.
    • Order is the largest: payload with places, waypoints and entities; tracking numbers and statuses; driver and vehicle assigned; and the morphTo customer/facilitator tables.
    • Waypoint, AssetConnection, FleetDriver, FleetVehicle and VendorPersonnel have neither cache trait.
    • There are many quiet or builder writes, including on orders itself (e.g. dispatch() uses saveQuietly).
    • Once ServiceArea declares zones, ZoneObserver's manual invalidation can go.
  • ledger (fleetbase/ledger#33, into new release branch fleetbase/ledger#32): no model uses HasApiModelCache. However, its Transaction extends core's cached Transaction, and TransactionFilter::queryForInternal eager-loads items, journal.debitAccount, journal.creditAccount and five morphTo relations. So it needs declarations too.
  • core-api: the other cached models (ApiEvent, Category, ApiRequestLog, WebhookRequestLog, Permission) eager-load nothing by default and need no declarations.

Tests

tests/Unit/Support/ApiModelCacheTest.php has new tests that use real Eloquent relations on sqlite:

  • a belongsTo;
  • a belongsToMany through a custom pivot class that uses the trait;
  • a hasManyThrough;
  • a morphTo;
  • a raw table name.

They cover:

  • Resolving declarations.
    • Relation, pivot, through and raw-table declarations resolve to the right tables.
    • An unresolvable declaration, a morphTo, or a non-relation method throws under debug; outside debug it's logged and skipped.
    • Lookup paths skip what they can't resolve.
  • Request-style paths.
    • snake_case paths resolve.
    • Framework methods (delete, push, touch, getTable) are never called.
    • The inspected model's connection is unchanged.
  • Company scoping of declared dependencies.
    • Another company's write keeps the cache.
    • The same company's write misses.
    • A no-company pivot write misses for every company.
  • Through Eloquent events. Creating or updating a dependency, and attaching or detaching through a custom pivot, invalidate the parent list.
  • Request relations. with[]=payload and expand=tags invalidate on writes to their tables only; an unrelated list keeps its cache.
  • Lookups. Relationship and $with lookups miss after a write to the related table, and a lookup without $with keeps its cache.

tests/Unit/Traits/HasApiModelBehaviorTest.php: its fake store gained many().

Merged with #300

fleetbase/core-api#300 is merged into release/v1.6.69 and merged into this branch:

Release review

A pre-release review of this PR found four problems; the last commit fixes them:

  • the ?with=dispatch method call;
  • an exception from a relation method escaping key generation;
  • strict mode keyed off app.debug;
  • the uncommitted-transaction window.

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:

  • Invalidation never fails the write that triggered it. The version bumps sat outside invalidateModelCache()'s try, 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 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.
  • 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.
  • Full suite (php -d memory_limit=512M vendor/bin/pest): 2076 passed, 0 failed (12828 assertions), on the final commit.
  • php-cs-fixer dry-run on the changed files: no changes.
  • phpstan on the changed files reports nothing new relative to the base.
  • Live run in the dev container against real Redis and the real FoodTruck, Catalog, Zone, Network and Order models, using a throwaway company id (its keys were removed afterwards). All 10 checks pass:
    • first read is a MISS, second a HIT;
    • a catalog write in the same company: MISS with fresh data;
    • a catalog write in another company: still a HIT;
    • a zone write, reached through serviceArea.zones: MISS;
    • a network write: MISS;
    • a with= request caches separately;
    • a read during an uncommitted write, then the commit: the next read is a MISS with fresh data;
    • ?with=dispatch,cancel,notify_driver_assigned,dispatchWithActivity against Order created no orders (339 → 339).
  • Performance, measured in the same dev container, where each Redis round trip costs about 0.4–0.5 ms over Docker networking:
    • The list key read is still one Redis round trip (an MGET replaces a GET).
    • A model with no declarations costs the same as before.
    • Order, with fleetops' 15 declarations and 24 version keys, costs roughly 0.2–0.3 ms more per list request. The timings are noisy at that size.
    • Memoized dependency resolution takes about 0.02 ms.
    • The write path is unchanged, apart from two extra 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 the fleetbase.io extension docs on API caching.

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.
Keeps #300's SESSION_SCOPE alongside the dependency memo, and the list
key now combines #300's path and session scoped params hash with the
dependency version segment. #300's store scoping test expects the
v{table-wide}.{company} list version this branch introduced.
…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
roncodes merged commit 21c56c7 into release/v1.6.69 Oct 13, 2026
2 of 3 checks passed
@roncodes
roncodes deleted the feat/api-cache-dependencies branch October 13, 2026 05:48
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.
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