Skip to content

fix(api-cache): invalidate cached lists when the tables they embed are written - #390

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

roncodes merged 7 commits into
release/v0.6.72from
fix/api-cache-dependencies

Conversation

@roncodes

@roncodes roncodes commented Oct 12, 2026 •

Copy link
Copy Markdown
Member

What and why

FleetOps' cached API lists (core's ApiModelCache, used by every model with HasApiModelCache) hold everything their query eager loads: relations, $with, the filter's and controller's loads, counts. Their keys were only versioned by the model's own table. So editing a place, entity, tracking status, driver or vehicle left the cached order list showing the old data until the list TTL ran out. The same happened to orders themselves when a write skipped model events: Order::dispatch() saves quietly, so a dispatched order stayed "not dispatched" in the cached list.

This PR does two things:

  1. Declares what each cached list embeds with $apiCacheDependencies, so a write to any embedded table invalidates the list. The property is read by 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 fix(api): stop PUT /v1/vehicles/{id} silently discarding the odometer #301 for the declarations to take effect. Until it is released the property is unused and harmless. Everything else here (hooks and bypass invalidation) works with released core today.
  2. Invalidates after writes that skip model events. These are raw inserts, query builder updates and deletes, saveQuietly/updateQuietly, and DB::table. After each one it calls invalidateApiCache(), Model::invalidateApiCacheManually($company) or ApiModelCache::invalidateModelCache(new Model(), $company) (all in released core). The writes themselves stay quiet, so no observer loops or activity logging are added.

Declarations

I checked every entry against the real models with core-api#301's ApiModelCache::dependencyTables() in the dev container, with api.cache.strict_dependencies on. With strict mode on, an unresolvable entry throws; in production it's logged and skipped.

Model $apiCacheDependencies Resolves to
Order payload, payload.pickup, payload.dropoff, payload.return, payload.entities, payload.waypoints, payload.firstWaypointMarker.place, payload.lastWaypointMarker.place, trackingNumber, trackingStatuses, driverAssigned, vehicleAssigned, contacts, vendors, integrated_vendors contacts, drivers, entities, integrated_vendors, payloads, places, tracking_numbers, tracking_statuses, vehicles, vendors, waypoints
Driver user, vehicle, fleets fleet_drivers, fleets, users, vehicles
Vehicle vendor, vendor.place, category, warranty, driver, currentTrailers, fleets asset_connections, assets, categories, drivers, fleet_vehicles, fleets, places, vendors, warranties
Device telematic, warranty, sensors, vehicles, assets assets, sensors, telematics, vehicles, warranties
DeviceEvent device, device.telematic devices, telematics
Vendor place places
ServiceArea zones zones

Where these come from:

  • Order: OrderFilter::queryForInternal() eager loads and whereHas membership (payload with waypoints, pickup and dropoff; tracking number; tracking statuses), the payload relation's own with, Payload's $with, and the public query's loads.
  • morphTo relations: core can't resolve these, so their target tables are named. That covers Order's customer and facilitator (contacts including Customer, vendors, integrated_vendors) and Device's attachable (vehicles, and assets for trailers).
  • Driver: the list loads nothing, but which drivers it holds depends on the user (existence; name, email and phone search), the vehicle search and the fleet filter.
  • Vehicle: the public list's PUBLIC_RELATIONS (Vendor's $with loads its place), plus the driver, trailer and fleet filters. The device filters are covered by Device::attachTo()/detach() (see below).
  • Device and DeviceEvent: the controllers' onQueryRecord loads and withCount('sensors').
  • Request with/expand/count: fix(api): stop PUT /v1/vehicles/{id} silently discarding the odometer #301 versions these automatically, so they aren't declared. Driver currentShift (scheduler) and Issue's console with are covered this way.

Deliberately left out:

  • files, for customer photo, vendor logo, vehicle photo and device photo. Every upload writes files (order attachments, proofs, avatars), so declaring it would invalidate every order, vehicle and device list on every upload. A new photo is set by writing the owner's own photo_uuid/logo_uuid, and that write already invalidates.
  • Vehicle devices. The device filters only read which devices are attached, but telemetry saves the device row on every contact. Declaring it would invalidate every vehicle list on every ping in any company with streaming devices. Instead, Device::attachTo() and detach() invalidate the attached model's lists (vehicles or trailers).
  • driverAssigned.vehicle: nothing loads it. Driver's $with is empty, and the relation adds without(['jobs','currentJob']).
  • Issue, FuelReport, Position, Place, Contact, Zone: no server-side eager loads, so no declaration.

Models given invalidation hooks

Waypoint, FleetDriver, FleetVehicle and AssetConnection had no cache trait, so writes to them bumped nothing. They now use HasApiModelCache, which is what core documents for this. None of them has HasApiModelBehavior, so the trait only registers the created/updated/deleted/restored invalidation and none of them gains a cached list. fleet_drivers/fleet_vehicles rows carry no company, so they bump the table-wide version that every company's keys include.

ZoneObserver still invalidates service areas by hand. Once #301 is released, ServiceArea::$apiCacheDependencies = ['zones'] makes that redundant. I kept it so service areas stay correct with released core in the meantime. It can be removed once fleetops requires a core with #301.

Writes that skip events and now invalidate

orders (the main correctness issue):

  • Order::dispatch(), setRoute, attachPurchaseRate, createOrderTransactionWithoutServiceQuote, setPreliminaryDistanceAndTime, setDistanceAndTime
  • Internal OrderController: bulk driver assignment and scheduleOrder
  • Internal DriverController and VehicleController: bulk unassign
  • DriverObserver: a deleted driver's orders
  • Public TrackingController: recipient instructions
  • ProcessOperationalAlerts
  • ContactController: customer reassignment to a vendor
  • TrackingNumber owner status (DB::table)
  • HasTrackingNumber owner tracking_number_uuid (DB::table)

waypoints:

  • Waypoint::insertGetUuid (raw insert plus a builder update)
  • Payload::removeWaypoints (builder delete)
  • Orchestrator commit resequence (DB::table), invalidated once after the transaction commits

payloads:

  • setCurrentWaypoint (DB::table)
  • setPlace/removePlace (updateQuietly)
  • setFirstWaypoint (saveQuietly)
  • ResolvesOrderServiceStops: current stop saveQuietly and service stop tracking number DB::table

tracking_numbers and tracking_statuses:

  • TrackingNumber::insertGetUuid (raw insert plus a status pointer update)
  • TrackingStatus::insertGetUuid (raw insert)
  • ResolvesOrderServiceStops status pointer DB::table updates (×2)

entities: Entity::insertGetUuid, both branches.

places: Place::insertGetUuid (raw insert).

drivers:

  • Driver::assignVehicle and Vehicle::unassignDriver (builder updates)
  • VehicleObserver, UserObserver and CompanyUserObserver builder deletes. The user cascades can span companies, so they bump the table-wide version.

fleet_drivers / fleet_vehicles: builder deletes in the internal and public FleetControllers.

asset_connections: internal trailer detach (builder update).

contacts: Contact portal user link and ContactController meta (saveQuietly).

issues: IssueController afterSave (saveQuietly ×2).

OrderObserver delete and restore cascades: payloads, entities, waypoints, tracking_numbers and tracking_statuses are invalidated after the builder soft-delete and restore.

telematics: Inbox::finishRun (DB::table). It invalidates only when the sweep changes the connection status. Sweeps run every minute and rewrite the sync meta each time, so device lists may show that meta up to the list TTL old.

Not changed: Order::insertPayload uses Payload::insert, but it already fires created on the payload afterwards, which invalidates.

Hot paths

  • Driver and vehicle track endpoints (updateQuietly per GPS ping):
    • Invalidating on every ping would keep the driver, vehicle and order lists (and storefront lists embedding vehicles, such as food trucks) from ever being served from cache for any company with a moving device. Each invalidation is two INCRs plus tag flushes.
    • The new ApiCacheThrottle invalidates a table at most once per 60 s per company. A ping costs one Cache::add, and a cached location is at most about a minute old instead of up to the list TTL.
    • The console's live map still gets positions over sockets.
    • The online toggle is infrequent and invalidates on every call.
  • Telematics ingestion: TelematicService saves vehicles and trailers with model events on every device event. Their cache invalidation is now coalesced the same way (see Release review). Devices and device events are still invalidated per event, which only affects device and device-event lists; they change on every event anyway.

Known remaining gaps

  • Model-event invalidation inside a DB transaction runs before commit. A read between the bump and the commit can re-cache pre-commit data under the new version, bounded by the list TTL. This is general core behaviour, not specific to these sites.
  • One-off maintenance commands (FixCustomerCompanies, FixInvalidPolymorphicRelationTypeNamespaces) and ProfileAccountManager's user company_uuid save still write quietly without invalidating. Caches catch up within the list TTL.
  • User quiet saves (type, company_uuid) don't invalidate drivers, because the driver filters don't read those columns.
  • files isn't declared (see above). A file row edited in place, rather than replaced, would not invalidate.

Release review

A pre-release review found three performance and resilience problems; the last two commits fix them:

  1. Telemetry would have uncached order and food truck lists. TelematicService saves the vehicle (and trailer) with model events on every device event. Order lists now depend on vehicles (through vehicleAssigned), and so do storefront food truck lists. In any company with streaming devices those lists would have been invalidated on every ping. Telemetry saves now run through ApiCacheThrottle::telemetryWrite():
    • all model events still fire (observers, activity log);
    • the cache invalidation those events trigger is coalesced to once per 60 s per table and company, using core-api#301's ApiModelCache::withoutInvalidating();
    • with an older core, the save invalidates as it does today.
  2. Vehicle devices churn. The dependency was dropped, and attach/detach now invalidate instead (above).
  3. A cache outage could fail a location ping. ApiCacheThrottle::invalidate() called Cache::add() and the invalidation without a guard, so a Redis blip failed the ping; core before fix(api): stop PUT /v1/vehicles/{id} silently discarding the odometer #301 can also throw from the invalidation itself. It now swallows the error, and cached lists catch up within their TTL.

The affected list keys are coalesced, not lost. While a device streams, order, food truck and vehicle lists show its vehicle's position and online state at most about a minute old (plus the gap to the next ping), and never older than the list TTL.

Validation

  • Dependency resolution: ApiModelCache::dependencyTables() from core-api#301 in tinker with app.debug=true. Every declaration resolved to the tables in the table above. A deliberately bad entry (payload.nope) threw LogicException.
  • New tests (server/tests/Unit/Support/ApiCacheDependenciesTest.php, 13 tests, passing against both the released core and core-api#301). They use only released core APIs and read the property directly:
    • every declared path walks to real relations (not morphTo), and named tables equal the morph targets' tables;
    • the four newly hooked models bump their table on writes;
    • Order::dispatch(), Waypoint::insertGetUuid, Payload::removeWaypoints and TrackingStatus::insertGetUuid invalidate;
    • the throttle runs once per window;
    • device attach/detach invalidate vehicle lists, and a telemetry-style device save doesn't;
    • a cache outage never fails a throttled invalidation;
    • telemetry writes fire their events but invalidate once per window (with fix(api): stop PUT /v1/vehicles/{id} silently discarding the odometer #301), or on every save (older core).
    • Mutation check: reverting the Waypoint or Order changes makes them fail.
  • Release review changes: the 44 test files that exercise Device, TelematicService, ApiCacheThrottle or the declarations were run per file on this branch and on its merge base. All 43 shared files pass on both, and the new cache test file passes.
  • Full server suite (before the release review; scripts/pest-runner.php per file, 498 files) against core-api release/v1.6.69: no new failures. 14 files fail both before and after this branch, with identical failing-test counts per file. They are unrelated: tracking page/notification URL expectations, composer.lock detection, socket channel and replay authorization, telematics prune and a QR code resource. The local vendor's core-api was replaced with release/v1.6.69, because the main checkout's vendor is 1.6.65 and ^1.6.69 isn't on Packagist yet.
  • php-cs-fixer fix --dry-run on all touched files: 0 of 40 files need fixing.
  • phpstan (composer test:types, not run in CI): the touched files already report ~3,080 errors at level max on the base branch. The 38 new ones are the same kinds the codebase is full of (session() not found, magic company_uuid property).
  • No frontend changes, so no ember build or tests.

Docs impact

  • No API shapes, endpoints or payloads change, so no fleetbase/postman update is expected.
  • fleetbase/fleetbase.io extension caching docs: the response caching section should document $apiCacheDependencies (with core-api#301), explain that quiet writes need an explicit invalidation, and note that cached driver/vehicle locations can be up to a minute old.

Cached API lists hold every relation their query eager loads, and the
whereHas filters that decide membership, but their keys were only
versioned by the model's own table. Declare `$apiCacheDependencies` on
the cached models (Order, Driver, Vehicle, Device, DeviceEvent, Vendor,
ServiceArea) so a write to an embedded table invalidates the list. The
property is read by fleetbase/core-api#301; until that is released it is
unused.

Waypoint, FleetDriver, FleetVehicle and AssetConnection had no cache
trait, so their writes bumped nothing. They now use HasApiModelCache,
which only registers the invalidation hooks: they have no
HasApiModelBehavior, so none of them gains a cached list.
Order writes that skip model events left the cached order lists, and the
order's own cached lookups, on the old version: dispatching, attaching a
route, purchase rate or transaction, setting distance and time,
scheduling, bulk driver assignment and unassignment, unassigning a
deleted driver's or a vehicle's orders, saving recipient instructions
and recording operational alerts. Each now invalidates the orders cache
for the order's company after the write. The writes themselves stay
quiet.
Raw inserts, query builder updates and deletes, and quiet saves bump no
cache version, so lists embedding those tables kept serving stale rows.
Invalidate the written table after each of them:

- waypoints: raw insert, payload waypoint removal, orchestrator resequence
- payloads: current waypoint and pickup/dropoff/return writes, service
  stop tracking numbers
- tracking numbers and statuses: raw inserts, status pointer updates, and
  the owner's status and tracking number columns
- entities and places: raw inserts and payload reassignment
- drivers: vehicle unassignment and deletes cascaded from vehicles and users
- fleet memberships, trailer detach, contact and issue quiet saves,
  customer reassignment to a vendor
- order delete and restore cascades (payloads, entities, waypoints,
  tracking numbers and statuses)
- telematics: a sweep that changes the connection status; the sync meta
  it rewrites every minute does not invalidate

The writes themselves stay quiet.
…d online writes

The driver and vehicle track endpoints write location quietly on every
ping, so cached driver, vehicle and order lists (and storefront lists
that embed vehicles) showed a location up to the list TTL old.
Invalidating on every ping would keep those lists from ever being served
from cache, so ApiCacheThrottle invalidates a table at most once a
minute per company: a ping costs one Cache::add, and a cached location
is at most a minute old. The online toggle is infrequent and
invalidates on every call.
…dation

Walk every declared relation path through the real models and check
that named tables are exactly the morphTo targets' tables, so a renamed
relation fails here rather than in production (fleetbase/core-api#301
throws on unresolvable entries only in debug). Check that the newly
hooked models bump their table on writes, that Order::dispatch(), raw
waypoint and tracking status inserts and waypoint removal invalidate,
and that throttled invalidation runs once per window.

The tests read the property directly and use only released core-api
APIs, since CI installs the released package.
…s safe in outages

Release review of the cache dependency work:

- Vehicle lists declared devices, but telemetry saves the device row on
  every contact, so vehicle lists in any company with streaming devices
  were invalidated on every ping. The vehicle filters only read which
  devices are attached, so devices is no longer a dependency and
  Device::attachTo()/detach() invalidate the attached model's lists.
- ApiCacheThrottle runs on every location ping; a cache outage there
  (or any throwing invalidation, as in core-api before #301) now no
  longer fails the ping.
…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 c64c3be into release/v0.6.72 Oct 13, 2026
4 of 6 checks passed
@roncodes
roncodes deleted the fix/api-cache-dependencies branch October 13, 2026 06:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant