Skip to content

fix(cache): invalidate storefront API list caches when the tables they embed change - #140

Merged
roncodes merged 3 commits into
release/v0.4.25from
fix/api-cache-dependencies
Oct 13, 2026
Merged

roncodes merged 3 commits into
release/v0.4.25from
fix/api-cache-dependencies

Conversation

@roncodes

@roncodes roncodes commented Oct 12, 2026 •

Copy link
Copy Markdown
Member

Summary

Storefront's cached API lists embed related tables (eager loads, withAvg, the catalogs_count subquery, whereHas membership filters), but ApiModelCache only versioned a list by its own table. A write to an embedded table left the list stale until the TTL ran out. The case seen live was the food truck list returning its old catalogs after they were reassigned. Two things caused it, and this PR fixes both:

  1. Cached models now declare the tables they embed with $apiCacheDependencies. The property is read by ApiModelCache starting with feat(api-cache): writes invalidate every cache that embeds the written table core-api#301 (feat(api-cache): writes invalidate every cache that embeds the written table core-api#301). This PR depends on #301 to take effect. Until #301 ships, the property is unused and harmless.
  2. Writes that raise no model events now invalidate explicitly. These are query-builder updates and deletes, plus one DB::table delete. The writes stay quiet; an invalidation call is added after each, with the company when it is known.

Related Issue

Related to fleetbase/core-api#301 (dependency support); there is no storefront issue.

Type of Change

  • Bug fix
  • Feature
  • Refactor
  • Documentation
  • Test
  • Chore

Implementation Notes

Declarations ($apiCacheDependencies)

Model (table) Declared Why
Catalog (catalogs) categories, categories.products, hours, assignments, stores, food_trucks Console onQueryRecord loads categories.products, hours, assignments.subject. subject is a morphTo, so its target tables are named directly.
Store (stores) reviews, networks withAvg('reviews','rating'), and networks (loaded with network + with_category; also the network filter's whereHas).
FoodTruck (food_trucks) store, store.networks, network, vehicle, vehicle.driver, serviceArea, serviceArea.zones, zone, catalogs Console and v1 eager loads (fleetops ServiceArea::$with = ['zones']). store.networks drives the network membership filter and the v1 network truck list.
Product (products) addonCategories, addonCategories.category, addonCategories.category.addons, variants, variants.options, store, store.networks, catalogCategories v1 list eager loads. AddonCategory loads addons. store.networks is the network storefront whereHas. catalogCategories covers the internal catalogs_count subquery (categories + catalog_category_products). Product images (files, feeding images / primary_image_url) are handled by FileObserver instead (below).
AddonCategory (categories) addons $with = ['addons'].

These were resolved against the running dev container with #301's ApiModelCache::dependencyTables(), in strict mode (an unresolvable entry throws there; in production it's logged and skipped). Product has since dropped files:

Catalog catalogs     => ["catalog_category_products","catalog_hours","catalog_subjects","categories","food_trucks","products","stores"]
Store stores         => ["network_stores","networks","reviews"]
FoodTruck food_trucks => ["catalog_subjects","catalogs","drivers","network_stores","networks","service_areas","stores","vehicles","zones"]
Product products     => ["catalog_category_products","categories","network_stores","networks","product_addon_categories","product_addons","product_variant_options","product_variants","stores"]
AddonCategory categories => ["product_addons"]
(control) Catalog 'categories.nope' => LogicException: ... cannot be resolved at "nope"

Left out on purpose:

  • Store logo / backdrop. A new logo or backdrop changes logo_uuid / backdrop_uuid on the store row, which already bumps stores. File rows aren't edited, so depending on the busy files table would only cost cache hits.
  • Product store.logo / store.backdrop. Covered by store, for the same reason.
  • Product files. Every upload in a company writes files (proof-of-delivery photos, signatures, avatars, imports). Declaring it would invalidate that company's product lists, the busiest public storefront endpoint, on every upload. Product images are handled by FileObserver instead.
  • Review, Network, Cart. No cached eager loads, so no property.

Writes that raise no model events, now invalidating

  • CatalogProduct (catalog_category_products) now uses HasApiModelBehavior, matching its sibling pivots CatalogSubject and NetworkStore, so pivot creates bump it. Before this, product override edits bumped no table at all.
  • CatalogCategory::setProducts: builder delete (removals) and builder update (override edits) invalidate catalog_category_products once per call, only when something was written.
  • CatalogObserver::deleted: builder delete of category products.
  • CatalogController::syncSubjects: builder delete of catalog_subjects, invalidated only when rows were removed.
  • FoodTruck::setCatalogs: builder delete of catalog_subjects. The existing food_trucks invalidation is kept; catalog lists embed the assignments too.
  • NetworkController::forgetCachedResponses() now invalidates network_stores alongside stores. That covers addStores, rejectStores, approve/suspend/reinstate (setMembershipStatus) and removeStores. deleteCategory previously had no invalidation; it now calls forgetCachedResponses() and also invalidates categories after its builder delete of the category. The company is session('company') (the network operator), the same one the evented NetworkStore path uses. The network list itself embeds nothing from network_stores, so networks needs no invalidation.
  • Product::setAddonCategories / setProductVariants: builder updates of product_addon_categories, product_variants, product_variant_options.
  • AddonCategory::setAddons: builder update of product_addons.
  • v1/CheckoutController::discardFailedCapture: builder delete of core TransactionItem rows. That model only has HasApiModelBehavior, so this uses ApiModelCache::invalidateModelCache(new TransactionItem(), company).
  • FileObserver (new, registered on core's File): when a file whose subject is (or was) a product is saved or deleted, it invalidates product lists for the file's company. Attaching an image goes through File::setKey($product), and deleting one is a plain file delete; neither touches the product row. Other uploads leave product lists cached.
  • storefront:purge-carts: DB::table('carts') delete across companies, so it advances the table-wide carts version (Cart::invalidateApiCacheManually()). This site was not in the original audit; it was found while scanning for builder writes.

CatalogObserver::saved runs setCategories even on a clean save. Category and pivot creates are now evented and the builder writes invalidate by hand, so a catalog whose own row didn't change is still invalidated when its categories or overrides change.

Not changed here: NetworkController::addStores removes a store from every network because its delete has no network_uuid filter. That bug is being fixed separately; this PR only adds invalidation around it.

Known remaining gaps

  • Food truck location. It comes from the vehicle. fleetbase/fleetops#390 invalidates vehicle lists after location writes, coalesced to at most once a minute per company (track endpoints and telemetry alike). So a food truck list shows a position at most about a minute old while the truck moves, and never older than the list TTL.
  • morphTo targets can't be resolved by core, so Catalog names stores / food_trucks directly. Any new subject type has to be added to that list.
  • Cross-company network lists. List keys are per company. A member store (company A) writing its products or trucks bumps company A's versions, not those of the network owner (company B), whose network storefront lists can stay stale until the TTL. Fixing that would mean table-wide bumps on every write, which is a core design decision.
  • Core Category lists (v1 CategoryController) aren't covered by declarations here, since that model lives in core-api; with_products is loaded outside the cached query, so it is fresh anyway.

Release review

A pre-release review changed one thing: Product no longer declares files (above). That dependency would have invalidated the company's product lists on every upload in the company. A FileObserver scoped to product images replaces it. Tests cover a product image saved, deleted, and moved off a product (each invalidates), an unrelated upload (keeps the cache), and the provider registering the observer.

Validation

PHP tests ran in the worktree with a cloned server_vendor, with Pest's JUnit log used for results (Pest's console output is swallowed locally). No frontend changes, so no ember build or tests.

  • Tests: composer test:unit equivalent (scripts/pest-runner.php server/tests)
  • Lint: php-cs-fixer fix --dry-run on all touched files
  • Build: n/a (no frontend changes)
  • Manual validation: declarations resolved against the dev container with #301's ApiModelCache
Full suite after the release review, run on this branch and on its merge base with the same vendor, compared test by test:
  base:        663 tests, 641 passed, 22 errors
  this branch: 677 tests, 655 passed, 22 errors
  failing on this branch but not on base: 0; the 14 extra tests are new and pass
  The 22 errors are identical on base: Class "Fleetbase\Support\SocketCluster\Socket{Token,Principal,ChannelRegistry}"
  not found. The local server_vendor has core-api 1.6.55, older than the required ^1.6.69. Unrelated to this change.

New tests (ApiCacheDependenciesTest, ApiCacheInvalidationTest): 11 passed after the release review.
Run against the unfixed source, they fail (5 errors: property missing; 4 failures: version not advanced).
Touched existing files run in isolation: CatalogPriceOverrideTest 4/4, FoodTruckCatalogsTest 2/2,
ProductBehaviorTest 9/9, CommandContractsTest 17/17. StorefrontModelContractsTest has 1 isolation-only
error ("no such table: catalogs"), identical on base, and passes in the full suite.

php-cs-fixer --dry-run on the 24 changed files: 2 findings, both on lines already on base
(an alignment in CatalogPriceOverrideTest, import order and a docblock in v1/CheckoutController); left alone.
phpstan (level max, no larastan) on the touched src files: error set identical to base.

The tests use only APIs in released core-api (no dependencyTables / getApiCacheDependencies):

  • The declaration test walks every declared relation path ($model->{$segment}()->getRelated()), checking each segment is a non-morphTo Relation and each single-segment table entry is a real storefront table.
  • The invalidation tests check that each write site advances the api_query_version:{table}:{company} key (table-wide for the cart purge).

Documentation Impact

  • No documentation changes needed
  • Documentation updated in fleetbase/fleetbase.io
  • Documentation needed but not included

API Reference Impact

  • No API reference changes needed
  • Updated fleetbase/postman
  • API reference updates required but not included

API reference notes: response shapes, endpoints and validation are unchanged; only cache freshness changes.

Documentation Notes

The extension caching docs in fleetbase/fleetbase.io should cover two things once core-api#301 is released. Where it belongs depends on what #301 documents in core, so it is flagged needs-docs rather than written here.

  • How an extension declares $apiCacheDependencies.
  • That query-builder writes need an explicit ApiModelCache::invalidateModelCache() / invalidateApiCacheManually().

Risk

  • More invalidation means fewer cache hits. Store, product and truck lists now turn over on network membership changes, product lists on product image changes, and truck lists at most once a minute while a truck's vehicle reports its location. That's the intended trade for correctness; the lists rebuild with the same queries as an uncached request.
  • CatalogProduct gains HasApiModelBehavior. It now fires invalidation on create/update/delete and gains the trait's query helpers, the same as the sibling pivots. Nothing else about the pivot changes.
  • Checkout. discardFailedCapture only gains a cache invalidation after the existing delete, inside the existing try/catch. Payment and customer data handling is unchanged, but it is in the checkout path, so it's flagged for human review.
  • Before core-api#301 is released, the declarations are unused and the new invalidations only bump versions nothing reads yet, apart from each table's own lists and tags.

Screenshots / Recordings

n/a (server-only change)

…events

Query builder updates and deletes bump no ApiModelCache version, so the
cached lists embedding those rows kept serving them until the TTL. Each
such write now invalidates its table explicitly, with the company when
known; the writes themselves stay quiet.

- CatalogProduct (catalog_category_products) uses HasApiModelBehavior like
  the sibling CatalogSubject and NetworkStore pivots, so pivot creates bump
  it; CatalogCategory::setProducts removals and override edits and
  CatalogObserver::deleted invalidate it by hand.
- CatalogController::syncSubjects and FoodTruck::setCatalogs invalidate
  catalog_subjects after their builder deletes.
- NetworkController membership writes (add/remove/approve/reject/suspend/
  reinstate, deleteCategory) invalidate network_stores alongside stores;
  deleteCategory also invalidates categories after its builder delete.
- Product::setAddonCategories / setProductVariants and
  AddonCategory::setAddons invalidate product_addon_categories,
  product_variants, product_variant_options and product_addons.
- v1 CheckoutController::discardFailedCapture invalidates transaction_items.
- storefront:purge-carts advances the table wide carts version.

The lists only read these versions once they declare the tables they
embed (fleetbase/core-api#301), which the next commit does.
ApiModelCache caches list results with every relation eager loaded inside
the query, but versioned them by the model's own table only, so a write to
an embedded table (a truck's catalog assignments, a product's variants, a
store's network membership) left the list stale. fleetbase/core-api#301
adds $apiCacheDependencies; until it is released the property is unused.

- Catalog: categories, categories.products, hours, assignments, and the
  assignment subjects' tables (morphTo: stores, food_trucks).
- Store: reviews (rating average), networks (membership filter/loading).
  logo and backdrop are left out: a new one changes the store row, and
  depending on the busy files table would only cost cache hits.
- FoodTruck: store, store.networks, network, vehicle, vehicle.driver,
  serviceArea, serviceArea.zones, zone, catalogs.
- Product: addonCategories(.category(.addons)), variants(.options), files
  (images added to a product do not touch its row), store, store.networks,
  catalogCategories (catalogs_count).
- AddonCategory: addons ($with).

A test walks every declared relation path, so a renamed relation fails
here instead of being logged and skipped in production.
@roncodes roncodes added needs-docs Requires documentation updates needs-human-review Requires human review before proceeding type:bug Bug fix labels Oct 12, 2026
… every upload

Product declared files as a cache dependency, but every upload in a
company writes the files table (proof of delivery photos, signatures,
avatars), so product lists, the busiest public storefront endpoint,
would rarely be served from cache. A FileObserver now invalidates
product lists only when a file whose subject is (or was) a product is
saved or deleted.
@roncodes
roncodes merged commit 502c162 into release/v0.4.25 Oct 13, 2026
@roncodes
roncodes deleted the fix/api-cache-dependencies branch October 13, 2026 06:20
roncodes added a commit that referenced this pull request Oct 13, 2026
Conflict in NetworkController::addStores: #139 scoped the `remove`
deletes to the network the same way; kept its version and comment.
deleteCategory keeps the scoped network uuid plus #140's cache
invalidation.

Tests from #139/#140 that call network actions now give the network a
company and set the session company; ApiCacheInvalidationTest calls the
public reinstateStores action instead of setMembershipStatus by
reflection (it now takes the resolved Network).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-docs Requires documentation updates needs-human-review Requires human review before proceeding type:bug Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant