Repository navigation
fix(api-cache): invalidate cached lists when the tables they embed are written - #390
Merged
Merged
Conversation
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.
This was referenced Oct 12, 2026
Merged
…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).
7 of 16 tasks
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 and why
FleetOps' cached API lists (core's
ApiModelCache, used by every model withHasApiModelCache) 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:
$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.saveQuietly/updateQuietly, andDB::table. After each one it callsinvalidateApiCache(),Model::invalidateApiCacheManually($company)orApiModelCache::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, withapi.cache.strict_dependencieson. With strict mode on, an unresolvable entry throws; in production it's logged and skipped.$apiCacheDependenciespayload,payload.pickup,payload.dropoff,payload.return,payload.entities,payload.waypoints,payload.firstWaypointMarker.place,payload.lastWaypointMarker.place,trackingNumber,trackingStatuses,driverAssigned,vehicleAssigned,contacts,vendors,integrated_vendorsuser,vehicle,fleetsvendor,vendor.place,category,warranty,driver,currentTrailers,fleetstelematic,warranty,sensors,vehicles,assetsdevice,device.telematicplacezonesWhere these come from:
OrderFilter::queryForInternal()eager loads and whereHas membership (payload with waypoints, pickup and dropoff; tracking number; tracking statuses), thepayloadrelation's ownwith, Payload's$with, and the public query's loads.customerandfacilitator(contacts including Customer, vendors, integrated_vendors) and Device'sattachable(vehicles, and assets for trailers).PUBLIC_RELATIONS(Vendor's$withloads its place), plus the driver, trailer and fleet filters. The device filters are covered byDevice::attachTo()/detach()(see below).onQueryRecordloads andwithCount('sensors').with/expand/count: fix(api): stop PUT /v1/vehicles/{id} silently discarding the odometer #301 versions these automatically, so they aren't declared. DrivercurrentShift(scheduler) and Issue's consolewithare covered this way.Deliberately left out:
files, for customer photo, vendor logo, vehicle photo and device photo. Every upload writesfiles(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 ownphoto_uuid/logo_uuid, and that write already invalidates.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()anddetach()invalidate the attached model's lists (vehicles or trailers).driverAssigned.vehicle: nothing loads it. Driver's$withis empty, and the relation addswithout(['jobs','currentJob']).Models given invalidation hooks
Waypoint,FleetDriver,FleetVehicleandAssetConnectionhad no cache trait, so writes to them bumped nothing. They nowuse HasApiModelCache, which is what core documents for this. None of them hasHasApiModelBehavior, so the trait only registers the created/updated/deleted/restored invalidation and none of them gains a cached list.fleet_drivers/fleet_vehiclesrows carry no company, so they bump the table-wide version that every company's keys include.ZoneObserverstill 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,setDistanceAndTimescheduleOrderDB::table)tracking_number_uuid(DB::table)waypoints:
Waypoint::insertGetUuid(raw insert plus a builder update)Payload::removeWaypoints(builder delete)DB::table), invalidated once after the transaction commitspayloads:
setCurrentWaypoint(DB::table)setPlace/removePlace(updateQuietly)setFirstWaypoint(saveQuietly)saveQuietlyand service stop tracking numberDB::tabletracking_numbers and tracking_statuses:
TrackingNumber::insertGetUuid(raw insert plus a status pointer update)TrackingStatus::insertGetUuid(raw insert)DB::tableupdates (×2)entities:
Entity::insertGetUuid, both branches.places:
Place::insertGetUuid(raw insert).drivers:
Driver::assignVehicleandVehicle::unassignDriver(builder updates)fleet_drivers / fleet_vehicles: builder deletes in the internal and public FleetControllers.
asset_connections: internal trailer detach (builder update).
contacts:
Contactportal 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::insertPayloadusesPayload::insert, but it already firescreatedon the payload afterwards, which invalidates.Hot paths
trackendpoints (updateQuietlyper GPS ping):ApiCacheThrottleinvalidates a table at most once per 60 s per company. A ping costs oneCache::add, and a cached location is at most about a minute old instead of up to the list TTL.TelematicServicesaves 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
FixCustomerCompanies,FixInvalidPolymorphicRelationTypeNamespaces) andProfileAccountManager's usercompany_uuidsave still write quietly without invalidating. Caches catch up within the list TTL.type,company_uuid) don't invalidate drivers, because the driver filters don't read those columns.filesisn'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:
TelematicServicesaves the vehicle (and trailer) with model events on every device event. Order lists now depend onvehicles(throughvehicleAssigned), 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 throughApiCacheThrottle::telemetryWrite():ApiModelCache::withoutInvalidating();deviceschurn. The dependency was dropped, and attach/detach now invalidate instead (above).ApiCacheThrottle::invalidate()calledCache::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
ApiModelCache::dependencyTables()from core-api#301 in tinker withapp.debug=true. Every declaration resolved to the tables in the table above. A deliberately bad entry (payload.nope) threwLogicException.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:Order::dispatch(),Waypoint::insertGetUuid,Payload::removeWaypointsandTrackingStatus::insertGetUuidinvalidate;Device,TelematicService,ApiCacheThrottleor 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.scripts/pest-runner.phpper file, 498 files) against core-apirelease/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.lockdetection, socket channel and replay authorization, telematics prune and a QR code resource. The local vendor's core-api was replaced withrelease/v1.6.69, because the main checkout's vendor is 1.6.65 and^1.6.69isn't on Packagist yet.php-cs-fixer fix --dry-runon all touched files: 0 of 40 files need fixing.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, magiccompany_uuidproperty).Docs impact
fleetbase/postmanupdate is expected.fleetbase/fleetbase.ioextension 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.