From 9792af6e2f2eb364bec15c7b0fef63e5b18c4861 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 23:17:12 +0800 Subject: [PATCH 1/7] feat(api-cache): declare the tables cached lists embed 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. --- server/src/Models/AssetConnection.php | 3 +++ server/src/Models/Device.php | 12 ++++++++++ server/src/Models/DeviceEvent.php | 7 ++++++ server/src/Models/Driver.php | 11 +++++++++ server/src/Models/FleetDriver.php | 3 +++ server/src/Models/FleetVehicle.php | 3 +++ server/src/Models/Order.php | 32 +++++++++++++++++++++++++++ server/src/Models/ServiceArea.php | 7 ++++++ server/src/Models/Vehicle.php | 13 +++++++++++ server/src/Models/Vendor.php | 7 ++++++ server/src/Models/Waypoint.php | 3 +++ 11 files changed, 101 insertions(+) diff --git a/server/src/Models/AssetConnection.php b/server/src/Models/AssetConnection.php index 204e53216..08c01dd6e 100644 --- a/server/src/Models/AssetConnection.php +++ b/server/src/Models/AssetConnection.php @@ -5,6 +5,7 @@ use Fleetbase\Casts\Json; use Fleetbase\Casts\PolymorphicType; use Fleetbase\Models\Model; +use Fleetbase\Traits\HasApiModelCache; use Fleetbase\Traits\HasPublicId; use Fleetbase\Traits\HasUuid; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -14,6 +15,8 @@ class AssetConnection extends Model { use HasUuid; + // Writes bump the asset_connections API cache version, which cached vehicle lists filter on. + use HasApiModelCache; use HasPublicId; use SoftDeletes; diff --git a/server/src/Models/Device.php b/server/src/Models/Device.php index 06feb64f0..118650efc 100644 --- a/server/src/Models/Device.php +++ b/server/src/Models/Device.php @@ -56,6 +56,18 @@ class Device extends Model */ protected $table = 'devices'; + /** + * Relation paths and tables the cached device lists embed (fleetbase/core-api#301). + * + * Lists load telematic, warranty and attachable and count sensors. + * attachable is a morphTo to a vehicle or trailer, so its tables are named. + * photo (public list) is left out: files is written by every upload, and a + * new photo is set by writing the device's own photo_uuid. + * + * @var string[] + */ + protected $apiCacheDependencies = ['telematic', 'warranty', 'sensors', 'vehicles', 'assets']; + /** * The type of public Id to generate. * diff --git a/server/src/Models/DeviceEvent.php b/server/src/Models/DeviceEvent.php index 6c5b2a5b9..5b5d4a16f 100644 --- a/server/src/Models/DeviceEvent.php +++ b/server/src/Models/DeviceEvent.php @@ -46,6 +46,13 @@ class DeviceEvent extends Model */ protected $table = 'device_events'; + /** + * Relation paths the cached device event lists embed (fleetbase/core-api#301). + * + * @var string[] + */ + protected $apiCacheDependencies = ['device', 'device.telematic']; + /** * The type of public Id to generate. * diff --git a/server/src/Models/Driver.php b/server/src/Models/Driver.php index c796257fa..63164b526 100644 --- a/server/src/Models/Driver.php +++ b/server/src/Models/Driver.php @@ -171,6 +171,17 @@ class Driver extends Model */ protected $with = []; + /** + * Relation paths the cached driver lists depend on (fleetbase/core-api#301). + * + * Lists eager load nothing, but which drivers they hold depends on the + * user (existence, name, email and phone search), the vehicle (vehicle + * search) and the fleets (fleet filter, through fleet_drivers). + * + * @var string[] + */ + protected $apiCacheDependencies = ['user', 'vehicle', 'fleets']; + /** * Dynamic attributes that are appended to object. * diff --git a/server/src/Models/FleetDriver.php b/server/src/Models/FleetDriver.php index e1e5d9fcc..3b61ffaff 100644 --- a/server/src/Models/FleetDriver.php +++ b/server/src/Models/FleetDriver.php @@ -3,12 +3,15 @@ namespace Fleetbase\FleetOps\Models; use Fleetbase\Models\Model; +use Fleetbase\Traits\HasApiModelCache; use Fleetbase\Traits\HasUuid; use Illuminate\Database\Eloquent\Relations\BelongsTo; class FleetDriver extends Model { use HasUuid; + // Writes bump the fleet_drivers API cache version, which cached driver lists filter on. + use HasApiModelCache; /** * The database table used by the model. diff --git a/server/src/Models/FleetVehicle.php b/server/src/Models/FleetVehicle.php index 74dd1ec75..037452ca5 100644 --- a/server/src/Models/FleetVehicle.php +++ b/server/src/Models/FleetVehicle.php @@ -3,12 +3,15 @@ namespace Fleetbase\FleetOps\Models; use Fleetbase\Models\Model; +use Fleetbase\Traits\HasApiModelCache; use Fleetbase\Traits\HasUuid; use Illuminate\Database\Eloquent\Relations\BelongsTo; class FleetVehicle extends Model { use HasUuid; + // Writes bump the fleet_vehicles API cache version, which cached vehicle lists filter on. + use HasApiModelCache; /** * The database table used by the model. diff --git a/server/src/Models/Order.php b/server/src/Models/Order.php index 378d29795..8f404281b 100644 --- a/server/src/Models/Order.php +++ b/server/src/Models/Order.php @@ -196,6 +196,38 @@ class Order extends Model */ protected $with = []; + /** + * Relation paths and tables the cached order lists embed, so a write to any + * of them invalidates those lists (fleetbase/core-api#301). + * + * They mirror OrderFilter::queryForInternal()'s eager loads and whereHas + * membership, the payload relation's own `with`, Payload's `$with` and the + * public query's loads. customer and facilitator are morphTo relations, so + * their tables (contacts, vendors, integrated_vendors) are named instead; + * a vendor loads its place, which payload.pickup already covers. files + * (customer photo, vendor logo) is left out: every upload writes it, and a + * new photo is set by writing the contact's or vendor's own row. + * + * @var string[] + */ + protected $apiCacheDependencies = [ + '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', + ]; + /** * The attributes that should be cast to native types. * diff --git a/server/src/Models/ServiceArea.php b/server/src/Models/ServiceArea.php index d910afb2c..6f3d9738d 100644 --- a/server/src/Models/ServiceArea.php +++ b/server/src/Models/ServiceArea.php @@ -91,6 +91,13 @@ class ServiceArea extends Model */ protected $with = ['zones']; + /** + * Relation paths the cached service area lists embed through `$with` (fleetbase/core-api#301). + * + * @var string[] + */ + protected $apiCacheDependencies = ['zones']; + /** * Dynamic attributes that are appended to object. * diff --git a/server/src/Models/Vehicle.php b/server/src/Models/Vehicle.php index 250b440d9..72813a3db 100644 --- a/server/src/Models/Vehicle.php +++ b/server/src/Models/Vehicle.php @@ -81,6 +81,19 @@ class Vehicle extends Model */ protected $with = []; + /** + * Relation paths the cached vehicle lists embed or filter on (fleetbase/core-api#301). + * + * The public list loads vendor (with its place), category, warranty and + * driver; driver, devices, current trailers (through asset_connections) and + * fleets (through fleet_vehicles) decide which vehicles the filters return. + * photo is left out: files is written by every upload, and a new photo is + * set by writing the vehicle's own photo_uuid. + * + * @var string[] + */ + protected $apiCacheDependencies = ['vendor', 'vendor.place', 'category', 'warranty', 'driver', 'devices', 'currentTrailers', 'fleets']; + /** * Get the activity log options for the model. */ diff --git a/server/src/Models/Vendor.php b/server/src/Models/Vendor.php index 079367acb..f76217355 100644 --- a/server/src/Models/Vendor.php +++ b/server/src/Models/Vendor.php @@ -117,6 +117,13 @@ class Vendor extends Model */ protected $with = ['place']; + /** + * Relation paths the cached vendor lists embed through `$with` (fleetbase/core-api#301). + * + * @var string[] + */ + protected $apiCacheDependencies = ['place']; + /** * Get the activity log options for the model. */ diff --git a/server/src/Models/Waypoint.php b/server/src/Models/Waypoint.php index bfb895df3..1d74a449b 100644 --- a/server/src/Models/Waypoint.php +++ b/server/src/Models/Waypoint.php @@ -8,6 +8,7 @@ use Fleetbase\FleetOps\Traits\HasTrackingNumber; use Fleetbase\FleetOps\Traits\PayloadAccessors; use Fleetbase\Models\Model; +use Fleetbase\Traits\HasApiModelCache; use Fleetbase\Traits\HasPublicId; use Fleetbase\Traits\HasUuid; use Fleetbase\Traits\TracksApiCredential; @@ -20,6 +21,8 @@ class Waypoint extends Model { use HasUuid; + // Writes bump the waypoints API cache version, which cached order lists embed. + use HasApiModelCache; use HasPublicId; use TracksApiCredential; use HasTrackingNumber; From 8ba73cf2c4b91368138446baedf9263fac24b585 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 23:17:30 +0800 Subject: [PATCH 2/7] fix(api-cache): invalidate order lists after quiet order writes 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. --- server/src/Console/Commands/ProcessOperationalAlerts.php | 1 + .../src/Http/Controllers/Internal/v1/DriverController.php | 1 + .../src/Http/Controllers/Internal/v1/OrderController.php | 2 ++ .../src/Http/Controllers/Internal/v1/VehicleController.php | 1 + server/src/Http/Controllers/Public/TrackingController.php | 1 + server/src/Models/Order.php | 6 ++++++ server/src/Observers/DriverObserver.php | 7 ++++++- 7 files changed, 18 insertions(+), 1 deletion(-) diff --git a/server/src/Console/Commands/ProcessOperationalAlerts.php b/server/src/Console/Commands/ProcessOperationalAlerts.php index c9ae5eab7..8f1110fcf 100644 --- a/server/src/Console/Commands/ProcessOperationalAlerts.php +++ b/server/src/Console/Commands/ProcessOperationalAlerts.php @@ -189,6 +189,7 @@ protected function notifyOnce(Order $order, string $key, string $notificationCla 'context' => $context, ]); $order->saveQuietly(); + $order->invalidateApiCache(); } return true; diff --git a/server/src/Http/Controllers/Internal/v1/DriverController.php b/server/src/Http/Controllers/Internal/v1/DriverController.php index 87d591cee..aee020d4b 100644 --- a/server/src/Http/Controllers/Internal/v1/DriverController.php +++ b/server/src/Http/Controllers/Internal/v1/DriverController.php @@ -595,6 +595,7 @@ protected function clearDriverAssignmentsForOrders($orders): void 'driver_assigned_uuid' => null, 'updated_at' => now(), ]); + Order::invalidateApiCacheManually(session('company')); } protected function freshOrders($orders, array $with) diff --git a/server/src/Http/Controllers/Internal/v1/OrderController.php b/server/src/Http/Controllers/Internal/v1/OrderController.php index 1375bc8d3..169c815cf 100644 --- a/server/src/Http/Controllers/Internal/v1/OrderController.php +++ b/server/src/Http/Controllers/Internal/v1/OrderController.php @@ -776,6 +776,7 @@ protected function assignDriverToOrders($orderUuids, Driver $driver): void 'driver_assigned_uuid' => $driver->uuid, 'updated_at' => now(), ]); + Order::invalidateApiCacheManually(session('company')); } protected function dispatchBulkAssignedDriverNotification(array $orderUuids, Driver $driver): void @@ -2005,6 +2006,7 @@ public function scheduleOrder(Request $request) } $order->saveQuietly(); + $order->invalidateApiCache(); return response()->json([ 'status' => 'OK', diff --git a/server/src/Http/Controllers/Internal/v1/VehicleController.php b/server/src/Http/Controllers/Internal/v1/VehicleController.php index b8f113d93..25712b6cb 100644 --- a/server/src/Http/Controllers/Internal/v1/VehicleController.php +++ b/server/src/Http/Controllers/Internal/v1/VehicleController.php @@ -186,6 +186,7 @@ public function unassignOrders(Request $request, string $id): JsonResponse 'updated_at' => now(), ]); }); + Order::invalidateApiCacheManually(session('company')); return response()->json([ 'status' => 'ok', diff --git a/server/src/Http/Controllers/Public/TrackingController.php b/server/src/Http/Controllers/Public/TrackingController.php index eaba4c722..758c3436f 100644 --- a/server/src/Http/Controllers/Public/TrackingController.php +++ b/server/src/Http/Controllers/Public/TrackingController.php @@ -340,6 +340,7 @@ protected function saveInstructions(Order $order, TrackingScope $scope, string $ $instructions[sha1($scope->key())] = $text; $order->setMeta('recipient_instructions', $instructions); $order->saveQuietly(); + $order->invalidateApiCache(); } protected function dataUrl(string $raw): string diff --git a/server/src/Models/Order.php b/server/src/Models/Order.php index 8f404281b..f2e306ae5 100644 --- a/server/src/Models/Order.php +++ b/server/src/Models/Order.php @@ -1091,6 +1091,7 @@ public function setRoute(Route|array|null $attributes = []) $route->save(); $this->updateQuietly(['route_uuid' => $route->uuid]); + $this->invalidateApiCache(); return $this; } @@ -1226,6 +1227,7 @@ public function attachPurchaseRate(PurchaseRate $purchaseRate): bool ]); if ($attached) { + $this->invalidateApiCache(); $this->setRelation('purchaseRate', $purchaseRate); if ($purchaseRate->transaction_uuid) { @@ -1288,6 +1290,7 @@ public function createOrderTransactionWithoutServiceQuote(): ?Transaction ]); $this->updateQuietly(['transaction_uuid' => $transaction->uuid]); + $this->invalidateApiCache(); } catch (\Throwable $e) { } @@ -1327,6 +1330,7 @@ public function dispatch(bool $save = true): self if ($save) { $this->saveQuietly(); $this->flushAttributesCache(); + $this->invalidateApiCache(); } dispatch(fn () => event(new OrderDispatched($this)))->afterCommit(); @@ -1814,6 +1818,7 @@ public function setPreliminaryDistanceAndTime() $matrix = Utils::getPreliminaryDistanceMatrix($origin, $destination); $this->updateQuietly(['distance' => $matrix->distance, 'time' => $matrix->time]); + $this->invalidateApiCache(); return $this; } @@ -1851,6 +1856,7 @@ public function setDistanceAndTime(array $options = []): self 'distance' => $matrix->distance, 'time' => $matrix->time, ]); + $this->invalidateApiCache(); } return $this; diff --git a/server/src/Observers/DriverObserver.php b/server/src/Observers/DriverObserver.php index ba806dc83..6d124eb50 100644 --- a/server/src/Observers/DriverObserver.php +++ b/server/src/Observers/DriverObserver.php @@ -79,7 +79,12 @@ protected function invalidateLiveCache(): void protected function unassignOrders(Driver $driver): int { - return Order::where(['driver_assigned_uuid' => $driver->uuid])->update(['driver_assigned_uuid' => null]); + $unassigned = Order::where(['driver_assigned_uuid' => $driver->uuid])->update(['driver_assigned_uuid' => null]); + if ($unassigned > 0) { + Order::invalidateApiCacheManually($driver->company_uuid); + } + + return $unassigned; } protected function findDriverUser(Driver $driver): ?User From bfd5b87ecf2dbcace12756cac6c222412af1dd09 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 23:17:42 +0800 Subject: [PATCH 3/7] fix(api-cache): invalidate embedded tables after writes that skip events 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. --- .../Controllers/Api/v1/FleetController.php | 14 ++++++++++---- .../Internal/v1/ContactController.php | 3 +++ .../Internal/v1/FleetController.php | 18 ++++++++++++++++-- .../Internal/v1/IssueController.php | 2 ++ .../Internal/v1/OrchestrationController.php | 14 +++++++++++--- .../Internal/v1/TrailerController.php | 1 + server/src/Models/Contact.php | 1 + server/src/Models/Driver.php | 1 + server/src/Models/Entity.php | 7 +++++++ server/src/Models/Payload.php | 6 ++++++ server/src/Models/Place.php | 5 +++++ server/src/Models/TrackingNumber.php | 5 +++++ server/src/Models/TrackingStatus.php | 6 ++++++ server/src/Models/Vehicle.php | 1 + server/src/Models/Waypoint.php | 5 +++++ server/src/Observers/CompanyUserObserver.php | 5 ++++- server/src/Observers/OrderObserver.php | 16 ++++++++++++++++ server/src/Observers/UserObserver.php | 5 ++++- server/src/Observers/VehicleObserver.php | 7 ++++++- .../src/Support/ResolvesOrderServiceStops.php | 5 +++++ .../src/Support/Telematics/Telemetry/Inbox.php | 9 ++++++++- server/src/Traits/HasTrackingNumber.php | 2 ++ 22 files changed, 125 insertions(+), 13 deletions(-) diff --git a/server/src/Http/Controllers/Api/v1/FleetController.php b/server/src/Http/Controllers/Api/v1/FleetController.php index 16e7a0594..42b8d3b71 100644 --- a/server/src/Http/Controllers/Api/v1/FleetController.php +++ b/server/src/Http/Controllers/Api/v1/FleetController.php @@ -434,10 +434,13 @@ protected function assignMembership(string $pivotClass, array $attributes): void protected function removeVehicleFromFleet(Fleet $fleet, Vehicle $vehicle): void { - FleetVehicle::where([ + // Deleted without model events; fleet_vehicles rows carry no company + if (FleetVehicle::where([ 'fleet_uuid' => $fleet->uuid, 'vehicle_uuid' => $vehicle->uuid, - ])->delete(); + ])->delete()) { + FleetVehicle::invalidateApiCacheManually(); + } } protected function assignDriverToFleet(Fleet $fleet, Driver $driver): void @@ -450,10 +453,13 @@ protected function assignDriverToFleet(Fleet $fleet, Driver $driver): void protected function removeDriverFromFleet(Fleet $fleet, Driver $driver): void { - FleetDriver::where([ + // Deleted without model events; fleet_drivers rows carry no company + if (FleetDriver::where([ 'fleet_uuid' => $fleet->uuid, 'driver_uuid' => $driver->uuid, - ])->delete(); + ])->delete()) { + FleetDriver::invalidateApiCacheManually(); + } } protected function vehicleMembershipPayload(Fleet $fleet, Vehicle $vehicle, bool $assigned): array diff --git a/server/src/Http/Controllers/Internal/v1/ContactController.php b/server/src/Http/Controllers/Internal/v1/ContactController.php index f4b3346f2..ecfdf21fd 100644 --- a/server/src/Http/Controllers/Internal/v1/ContactController.php +++ b/server/src/Http/Controllers/Internal/v1/ContactController.php @@ -19,6 +19,7 @@ use Fleetbase\Http\Requests\ExportRequest; use Fleetbase\Http\Requests\ImportRequest; use Fleetbase\Models\User; +use Fleetbase\Support\ApiModelCache; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Mail; @@ -266,6 +267,7 @@ protected function migrateContactCustomerContextToVendor(Contact $contact, Vendo protected function bulkUpdateCustomerContext(string $modelClass, array $filter, array $replacement): void { $modelClass::where($filter)->update($replacement); + ApiModelCache::invalidateModelCache(new $modelClass(), session('company')); } protected function customerPortalIssuesForContact(Contact $contact) @@ -350,6 +352,7 @@ protected function sendCustomerCredentialsMail(User $user, string $password, Con protected function saveContactMetaQuietly(Contact $contact, array $meta): void { $contact->forceFill(['meta' => $meta])->saveQuietly(); + $contact->invalidateApiCache(); } protected function isCustomerPortalInstalled(): bool diff --git a/server/src/Http/Controllers/Internal/v1/FleetController.php b/server/src/Http/Controllers/Internal/v1/FleetController.php index ba5962321..e66c1e1fe 100644 --- a/server/src/Http/Controllers/Internal/v1/FleetController.php +++ b/server/src/Http/Controllers/Internal/v1/FleetController.php @@ -250,10 +250,17 @@ protected static function createFleetDriverAssignment(string $fleetUuid, string protected static function deleteFleetDriverAssignment(string $fleetUuid, string $driverUuid): mixed { - return FleetDriver::where([ + $deleted = FleetDriver::where([ 'fleet_uuid' => $fleetUuid, 'driver_uuid' => $driverUuid, ])->delete(); + + // Deleted without model events; fleet_drivers rows carry no company + if ($deleted) { + FleetDriver::invalidateApiCacheManually(); + } + + return $deleted; } protected static function fleetVehicleAssignmentExists(string $fleetUuid, string $vehicleUuid): bool @@ -274,10 +281,17 @@ protected static function createFleetVehicleAssignment(string $fleetUuid, string protected static function deleteFleetVehicleAssignment(string $fleetUuid, string $vehicleUuid): mixed { - return FleetVehicle::where([ + $deleted = FleetVehicle::where([ 'fleet_uuid' => $fleetUuid, 'vehicle_uuid' => $vehicleUuid, ])->delete(); + + // Deleted without model events; fleet_vehicles rows carry no company + if ($deleted) { + FleetVehicle::invalidateApiCacheManually(); + } + + return $deleted; } protected static function invalidateOperationsMonitor(): void diff --git a/server/src/Http/Controllers/Internal/v1/IssueController.php b/server/src/Http/Controllers/Internal/v1/IssueController.php index 3195f4ed3..bdd9fb98c 100644 --- a/server/src/Http/Controllers/Internal/v1/IssueController.php +++ b/server/src/Http/Controllers/Internal/v1/IssueController.php @@ -38,6 +38,7 @@ public function afterSave(Request $request, Issue $issue) if ($assignee && $assignee->type === 'customer') { $issue->assigned_to_uuid = null; $issue->saveQuietly(); + $issue->invalidateApiCache(); } } @@ -58,6 +59,7 @@ public function afterSave(Request $request, Issue $issue) if ($order) { $issue->order_uuid = $order->uuid; $issue->saveQuietly(); + $issue->invalidateApiCache(); } } } diff --git a/server/src/Http/Controllers/Internal/v1/OrchestrationController.php b/server/src/Http/Controllers/Internal/v1/OrchestrationController.php index cfccd48b5..6db6bdab8 100644 --- a/server/src/Http/Controllers/Internal/v1/OrchestrationController.php +++ b/server/src/Http/Controllers/Internal/v1/OrchestrationController.php @@ -13,6 +13,7 @@ use Fleetbase\FleetOps\Models\Place; use Fleetbase\FleetOps\Models\Vehicle; use Fleetbase\FleetOps\Models\Vendor; +use Fleetbase\FleetOps\Models\Waypoint; use Fleetbase\FleetOps\Orchestration\Contracts\OrchestrationEngineInterface; use Fleetbase\FleetOps\Orchestration\Engines\DriverAssignmentEngine; use Fleetbase\FleetOps\Orchestration\Engines\RouteSequencingEngine; @@ -489,9 +490,10 @@ public function commit(Request $request): JsonResponse return response()->json(['error' => 'No assignments provided.'], 422); } - $committed = []; - $failed = []; - $manifests = []; + $committed = []; + $failed = []; + $manifests = []; + $resequenced = false; $this->beginOrchestrationTransaction(); try { @@ -576,6 +578,7 @@ public function commit(Request $request): JsonResponse if (!empty($assignment['waypoint_sequence']) && $order->payload) { foreach ($assignment['waypoint_sequence'] as $seq => $waypointId) { $this->updateWaypointSequence($order->payload_uuid, $waypointId, $seq); + $resequenced = true; } } @@ -597,6 +600,11 @@ public function commit(Request $request): JsonResponse return response()->json(['error' => 'Commit failed: ' . $e->getMessage()], 500); } + // Waypoint sequences are written without model events + if ($resequenced) { + Waypoint::invalidateApiCacheManually($companyUuid); + } + return response()->json([ 'committed' => $committed, 'failed' => $failed, diff --git a/server/src/Http/Controllers/Internal/v1/TrailerController.php b/server/src/Http/Controllers/Internal/v1/TrailerController.php index 841ab10e8..41420ded5 100644 --- a/server/src/Http/Controllers/Internal/v1/TrailerController.php +++ b/server/src/Http/Controllers/Internal/v1/TrailerController.php @@ -166,6 +166,7 @@ public function detach(string $id) 'updated_by_uuid' => session('user'), 'updated_at' => now(), ])); + AssetConnection::invalidateApiCacheManually(session('company')); return response()->json([ 'status' => 'ok', diff --git a/server/src/Models/Contact.php b/server/src/Models/Contact.php index 28decd7e6..9f0af9374 100644 --- a/server/src/Models/Contact.php +++ b/server/src/Models/Contact.php @@ -417,6 +417,7 @@ public function repairCustomerTypeInvariant(bool $quiet = false): ?User ]); $user->forceFill(['type' => 'customer'])->saveQuietly(); $this->forceFill(['user_uuid' => $user->uuid])->saveQuietly(); + $this->invalidateApiCache(); } else { $user = $this->createUser(); } diff --git a/server/src/Models/Driver.php b/server/src/Models/Driver.php index 63164b526..f445a0006 100644 --- a/server/src/Models/Driver.php +++ b/server/src/Models/Driver.php @@ -683,6 +683,7 @@ public function assignVehicle(Vehicle $vehicle): self { // Unassign vehicle from other drivers static::where('vehicle_uuid', $vehicle->uuid)->update(['vehicle_uuid' => null]); + static::invalidateApiCacheManually($vehicle->company_uuid ?? $this->company_uuid); // Set this vehicle to the driver instance $this->setVehicle($vehicle); diff --git a/server/src/Models/Entity.php b/server/src/Models/Entity.php index 224d15ec0..b45b8d9a1 100644 --- a/server/src/Models/Entity.php +++ b/server/src/Models/Entity.php @@ -9,6 +9,7 @@ use Fleetbase\FleetOps\Traits\HasTrackingNumber; use Fleetbase\FleetOps\Traits\PayloadAccessors; use Fleetbase\Models\Model; +use Fleetbase\Support\ApiModelCache; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasCustomFields; use Fleetbase\Traits\HasInternalId; @@ -437,6 +438,7 @@ public static function insertGetUuid($values = [], ?Payload $payload = null) Entity::where('uuid', $values['uuid'])->update([ 'payload_uuid' => $payload->uuid, ]); + ApiModelCache::invalidateModelCache(new static(), $payload->company_uuid ?? session('company')); return $values['uuid']; } @@ -482,6 +484,11 @@ public static function insertGetUuid($values = [], ?Payload $payload = null) static::where('uuid', $uuid)->update(['tracking_number_uuid' => $trackingNumberId]); } + // Inserted and updated without model events + if ($result) { + ApiModelCache::invalidateModelCache($instance, $values['company_uuid']); + } + return $result ? $uuid : false; } diff --git a/server/src/Models/Payload.php b/server/src/Models/Payload.php index de158cc0f..cb690f625 100644 --- a/server/src/Models/Payload.php +++ b/server/src/Models/Payload.php @@ -12,6 +12,7 @@ use Fleetbase\FleetOps\Support\Utils; use Fleetbase\LaravelMysqlSpatial\Types\Point; use Fleetbase\Models\Model; +use Fleetbase\Support\ApiModelCache; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasMetaAttributes; use Fleetbase\Traits\HasPublicId; @@ -365,6 +366,7 @@ public function setCurrentWaypoint(Place|Waypoint $destination, bool $save = tru $this->current_waypoint_uuid = $destination->uuid; if ($save) { DB::table($this->getTable())->where('uuid', $this->uuid)->update(['current_waypoint_uuid' => $destination->uuid]); + ApiModelCache::invalidateModelCache($this, $this->company_uuid); } return $this; @@ -868,6 +870,7 @@ public function getOrder() public function removeWaypoints() { Waypoint::where('payload_uuid', $this->uuid)->delete(); + Waypoint::invalidateApiCacheManually($this->company_uuid); $this->setRelation('waypoints', collect()); return $this; @@ -895,6 +898,7 @@ public function removePlace($property, array $options = []) if ($save) { $this->updateQuietly([$attr => null]); + ApiModelCache::invalidateModelCache($this, $this->company_uuid); } if (is_callable($callback)) { @@ -928,6 +932,7 @@ public function setPlace($property, Place $place, array $options = []) // If optioned to save if ($save) { $this->updateQuietly([$attr => $id]); + ApiModelCache::invalidateModelCache($this, $this->company_uuid); } if (is_callable($callback)) { @@ -1000,6 +1005,7 @@ public function setFirstWaypoint(?Activity $activity = null, $location = null) $this->current_waypoint_uuid = $destination->uuid; $this->saveQuietly(); + ApiModelCache::invalidateModelCache($this, $this->company_uuid); $this->updateWaypointActivity($activity, $location); return $this->load('currentWaypoint'); diff --git a/server/src/Models/Place.php b/server/src/Models/Place.php index 898259e5f..1c3e8ea27 100644 --- a/server/src/Models/Place.php +++ b/server/src/Models/Place.php @@ -873,6 +873,11 @@ public static function insertGetUuid($values = []) $result = static::insert($values); + // Inserted without model events + if ($result) { + static::invalidateApiCacheManually($values['company_uuid']); + } + return $result ? $uuid : false; } diff --git a/server/src/Models/TrackingNumber.php b/server/src/Models/TrackingNumber.php index 68ae9bd15..a5c9f0c77 100644 --- a/server/src/Models/TrackingNumber.php +++ b/server/src/Models/TrackingNumber.php @@ -8,6 +8,7 @@ use Fleetbase\FleetOps\Support\Utils; use Fleetbase\LaravelMysqlSpatial\Eloquent\SpatialTrait; use Fleetbase\Models\Model; +use Fleetbase\Support\ApiModelCache; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasPublicId; use Fleetbase\Traits\HasUuid; @@ -387,8 +388,12 @@ public static function insertGetUuid($values = [], ?Model $owner = null) // silent update static::updateOwnerStatusColumn($owner, $initialCode ?? 'created'); + ApiModelCache::invalidateModelCache($owner, data_get($owner, 'company_uuid', $values['company_uuid'])); } + // Inserted and updated without model events + ApiModelCache::invalidateModelCache($instance, $values['company_uuid']); + return $uuid; } diff --git a/server/src/Models/TrackingStatus.php b/server/src/Models/TrackingStatus.php index b866a52b3..4166c62ae 100644 --- a/server/src/Models/TrackingStatus.php +++ b/server/src/Models/TrackingStatus.php @@ -7,6 +7,7 @@ use Fleetbase\FleetOps\Support\Utils; use Fleetbase\LaravelMysqlSpatial\Eloquent\SpatialTrait; use Fleetbase\Models\Model; +use Fleetbase\Support\ApiModelCache; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasMetaAttributes; use Fleetbase\Traits\HasPublicId; @@ -177,6 +178,11 @@ public static function insertGetUuid($values = [], ?TrackingNumber $trackingNumb $result = static::insertTrackingStatus($values); + // Inserted without model events + if ($result) { + ApiModelCache::invalidateModelCache($instance, $values['company_uuid']); + } + return $result ? $uuid : false; } diff --git a/server/src/Models/Vehicle.php b/server/src/Models/Vehicle.php index 72813a3db..9f817dfb7 100644 --- a/server/src/Models/Vehicle.php +++ b/server/src/Models/Vehicle.php @@ -642,6 +642,7 @@ public function assignDriver(Driver $driver) public function unassignDriver(): self { Driver::where('vehicle_uuid', $this->uuid)->update(['vehicle_uuid' => null]); + Driver::invalidateApiCacheManually($this->company_uuid); $this->unsetRelation('driver'); return $this; diff --git a/server/src/Models/Waypoint.php b/server/src/Models/Waypoint.php index 1d74a449b..227c87156 100644 --- a/server/src/Models/Waypoint.php +++ b/server/src/Models/Waypoint.php @@ -235,6 +235,11 @@ public static function insertGetUuid($values = [], ?Payload $payload = null) static::updateTrackingNumberUuid($uuid, $trackingNumberId); } + // Inserted and updated without model events + if ($result) { + static::invalidateApiCacheManually($values['company_uuid']); + } + return $result ? $uuid : false; } diff --git a/server/src/Observers/CompanyUserObserver.php b/server/src/Observers/CompanyUserObserver.php index 016830b0f..378c0fe37 100644 --- a/server/src/Observers/CompanyUserObserver.php +++ b/server/src/Observers/CompanyUserObserver.php @@ -34,7 +34,10 @@ public function updated(CompanyUser $companyUser) protected function deleteDrivers(string $userUuid): void { - Driver::where('user_uuid', $userUuid)->delete(); + // A user's drivers can belong to several companies, so this invalidates every company's driver lists + if (Driver::where('user_uuid', $userUuid)->delete()) { + Driver::invalidateApiCacheManually(); + } } protected function findDriver(string $userUuid): ?Driver diff --git a/server/src/Observers/OrderObserver.php b/server/src/Observers/OrderObserver.php index a5b8a8496..796c908c7 100644 --- a/server/src/Observers/OrderObserver.php +++ b/server/src/Observers/OrderObserver.php @@ -10,6 +10,7 @@ use Fleetbase\FleetOps\Models\Waypoint; use Fleetbase\FleetOps\Support\LiveCacheService; use Fleetbase\FleetOps\Support\TrackingPublisher; +use Fleetbase\Support\ApiModelCache; use Illuminate\Support\Facades\Cache; class OrderObserver @@ -126,6 +127,8 @@ protected function cascadeDelete(Order $order): void $trackingNumbers = TrackingNumber::where(fn ($query) => $query->whereIn('uuid', $tree['tracking_numbers'])->orWhereIn('owner_uuid', $tree['owners']))->pluck('uuid')->all(); TrackingStatus::whereIn('tracking_number_uuid', $trackingNumbers)->update(['deleted_at' => $deletedAt]); TrackingNumber::whereIn('uuid', $trackingNumbers)->update(['deleted_at' => $deletedAt]); + + $this->invalidateChildApiCaches($order); } /** @@ -147,6 +150,19 @@ protected function cascadeRestore(Order $order, string $deletedAt): void $trashed(Entity::class)->whereIn('uuid', $tree['entities'])->update(['deleted_at' => null]); $trashed(Waypoint::class)->whereIn('uuid', $tree['waypoints'])->update(['deleted_at' => null]); } + + $this->invalidateChildApiCaches($order); + } + + /** + * The cascades write their tables without model events, so invalidate the API + * caches of every table they touch. + */ + protected function invalidateChildApiCaches(Order $order): void + { + foreach ([Payload::class, Entity::class, Waypoint::class, TrackingNumber::class, TrackingStatus::class] as $modelClass) { + ApiModelCache::invalidateModelCache(new $modelClass(), $order->company_uuid); + } } /** diff --git a/server/src/Observers/UserObserver.php b/server/src/Observers/UserObserver.php index 71f8e7b4c..7c7a3d8b9 100644 --- a/server/src/Observers/UserObserver.php +++ b/server/src/Observers/UserObserver.php @@ -20,6 +20,9 @@ public function deleted(User $user) protected function deleteDrivers(string $userUuid): void { - Driver::where('user_uuid', $userUuid)->delete(); + // A user's drivers can belong to several companies, so this invalidates every company's driver lists + if (Driver::where('user_uuid', $userUuid)->delete()) { + Driver::invalidateApiCacheManually(); + } } } diff --git a/server/src/Observers/VehicleObserver.php b/server/src/Observers/VehicleObserver.php index 761d345f9..1af205e18 100644 --- a/server/src/Observers/VehicleObserver.php +++ b/server/src/Observers/VehicleObserver.php @@ -83,7 +83,12 @@ protected function findDriver(string $identifier): ?Driver protected function deleteDriversAssignedTo(Vehicle $vehicle): mixed { - return Driver::where(['vehicle_uuid' => $vehicle->uuid])->delete(); + $deleted = Driver::where(['vehicle_uuid' => $vehicle->uuid])->delete(); + if ($deleted) { + Driver::invalidateApiCacheManually($vehicle->company_uuid); + } + + return $deleted; } protected function invalidateLiveCache(): void diff --git a/server/src/Support/ResolvesOrderServiceStops.php b/server/src/Support/ResolvesOrderServiceStops.php index cf3aa83a1..17c2d3b12 100644 --- a/server/src/Support/ResolvesOrderServiceStops.php +++ b/server/src/Support/ResolvesOrderServiceStops.php @@ -15,6 +15,7 @@ use Fleetbase\FleetOps\Models\TrackingStatus; use Fleetbase\FleetOps\Models\Waypoint; use Fleetbase\LaravelMysqlSpatial\Types\Point; +use Fleetbase\Support\ApiModelCache; use Fleetbase\Support\TemplateString; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -185,6 +186,7 @@ protected function setPayloadCurrentServiceStop(Payload $payload, array $stop): $payload->current_waypoint_uuid = $place->uuid; $payload->saveQuietly(); + ApiModelCache::invalidateModelCache($payload, $payload->company_uuid); $payload->setRelation('currentWaypoint', $place); if (($stop['waypoint'] ?? null) instanceof Waypoint) { @@ -254,6 +256,7 @@ protected function updateCurrentServiceStopActivity(Order $order, Activity $acti $activityId = $waypoint->insertActivity($activity, $location, $proof); if ($activityId && $waypoint->tracking_number_uuid) { DB::table((new TrackingNumber())->getTable())->where('uuid', $waypoint->tracking_number_uuid)->update(['status_uuid' => $activityId]); + ApiModelCache::invalidateModelCache(new TrackingNumber(), $payload->company_uuid ?? $order->company_uuid); if ($waypoint->relationLoaded('trackingNumber')) { $waypoint->trackingNumber->status_uuid = $activityId; $waypoint->trackingNumber->unsetRelation('status'); @@ -391,6 +394,7 @@ protected function endpointServiceStopTrackingNumber(Order $order, Payload $payl // @codeCoverageIgnoreEnd DB::table($payload->getTable())->where('uuid', $payload->uuid)->update([$column => $trackingNumberUuid]); + ApiModelCache::invalidateModelCache($payload, $payload->company_uuid ?? $order->company_uuid); $payload->{$column} = $trackingNumberUuid; return TrackingNumber::where('uuid', $trackingNumberUuid)->first(); @@ -417,6 +421,7 @@ protected function insertEndpointServiceStopActivity(Order $order, Payload $payl if ($activityId) { DB::table($trackingNumber->getTable())->where('uuid', $trackingNumber->uuid)->update(['status_uuid' => $activityId]); + ApiModelCache::invalidateModelCache($trackingNumber, $payload->company_uuid ?? $order->company_uuid); $trackingNumber->status_uuid = $activityId; } diff --git a/server/src/Support/Telematics/Telemetry/Inbox.php b/server/src/Support/Telematics/Telemetry/Inbox.php index b1e998409..34b43b3e8 100644 --- a/server/src/Support/Telematics/Telemetry/Inbox.php +++ b/server/src/Support/Telematics/Telemetry/Inbox.php @@ -4,6 +4,7 @@ use Fleetbase\FleetOps\Jobs\ProcessTelematicDelivery; use Fleetbase\FleetOps\Models\Telematic; +use Fleetbase\Support\ApiModelCache; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; @@ -98,10 +99,16 @@ public static function finishRun(?string $id): void return; } // Written without model events: sweeps run every minute and the model logs every save. + $connectionStatus = in_array($connection->status, ['error', 'synchronizing'], true) ? 'active' : $connection->status; DB::table('telematics')->where('uuid', $connection->uuid)->update([ - 'status' => in_array($connection->status, ['error', 'synchronizing'], true) ? 'active' : $connection->status, + 'status' => $connectionStatus, 'meta' => json_encode(array_merge($meta, $result), JSON_THROW_ON_ERROR), ]); + // Cached device lists embed the telematic. Only a status change invalidates + // them: the sync meta changes on every sweep and may lag by the list TTL. + if ($connectionStatus !== $connection->status) { + ApiModelCache::invalidateModelCache($connection, $connection->company_uuid); + } }); } } diff --git a/server/src/Traits/HasTrackingNumber.php b/server/src/Traits/HasTrackingNumber.php index 44945b649..ab931862c 100644 --- a/server/src/Traits/HasTrackingNumber.php +++ b/server/src/Traits/HasTrackingNumber.php @@ -9,6 +9,7 @@ use Fleetbase\FleetOps\Models\TrackingStatus; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\LaravelMysqlSpatial\Types\Point; +use Fleetbase\Support\ApiModelCache; use Fleetbase\Support\TemplateString; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\DB; @@ -28,6 +29,7 @@ public static function bootHasTrackingNumber(): void ], $model); DB::table($model->getTable())->where('uuid', $model->uuid)->update(['tracking_number_uuid' => $trackingNumberId]); + ApiModelCache::invalidateModelCache($model, data_get($model, 'company_uuid', session('company'))); if ($model->isFillable('tracking_number_uuid')) { $model->tracking_number_uuid = $trackingNumberId; From 62c7091a2a271904d81ef354e29e50009d8f6c33 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 23:17:52 +0800 Subject: [PATCH 4/7] fix(api-cache): invalidate driver and vehicle lists after location and 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. --- .../Controllers/Api/v1/DriverController.php | 9 +++++ .../Controllers/Api/v1/VehicleController.php | 3 ++ server/src/Support/ApiCacheThrottle.php | 39 +++++++++++++++++++ 3 files changed, 51 insertions(+) create mode 100644 server/src/Support/ApiCacheThrottle.php diff --git a/server/src/Http/Controllers/Api/v1/DriverController.php b/server/src/Http/Controllers/Api/v1/DriverController.php index 82d0d6b16..124373721 100644 --- a/server/src/Http/Controllers/Api/v1/DriverController.php +++ b/server/src/Http/Controllers/Api/v1/DriverController.php @@ -21,6 +21,7 @@ use Fleetbase\FleetOps\Models\Order; use Fleetbase\FleetOps\Models\Vehicle; use Fleetbase\FleetOps\Models\Vendor; +use Fleetbase\FleetOps\Support\ApiCacheThrottle; use Fleetbase\FleetOps\Support\GeofenceIntersectionService; use Fleetbase\FleetOps\Support\OSRM; use Fleetbase\FleetOps\Support\ProfileAccountManager; @@ -374,6 +375,12 @@ public function track(string $id, Request $request) } } + // Location writes are quiet and frequent: invalidate cached lists at most once a window + ApiCacheThrottle::invalidate($driver, $driver->company_uuid); + if ($vehicle) { + ApiCacheThrottle::invalidate($vehicle, $vehicle->company_uuid); + } + broadcast(new DriverLocationChanged($driver)); app(TrackingPublisher::class)->driverMoved($driver); @@ -433,11 +440,13 @@ public function toggleOnline(string $id, Request $request) // Perform a single update call $driver->updateQuietly(['online' => $onlineValue]); + $driver->invalidateApiCache(); // Update vehicle online too $driver->loadMissing('vehicle'); if ($vehicle = $driver->vehicle) { $vehicle->updateQuietly(['online' => $onlineValue]); + $vehicle->invalidateApiCache(); } // Return the updated resource diff --git a/server/src/Http/Controllers/Api/v1/VehicleController.php b/server/src/Http/Controllers/Api/v1/VehicleController.php index 099d1b9ab..387d8a2d2 100644 --- a/server/src/Http/Controllers/Api/v1/VehicleController.php +++ b/server/src/Http/Controllers/Api/v1/VehicleController.php @@ -17,6 +17,7 @@ use Fleetbase\FleetOps\Models\Vehicle; use Fleetbase\FleetOps\Models\Vendor; use Fleetbase\FleetOps\Models\Warranty; +use Fleetbase\FleetOps\Support\ApiCacheThrottle; use Fleetbase\FleetOps\Support\GeofenceIntersectionService; use Fleetbase\FleetOps\Support\TrackingPublisher; use Fleetbase\FleetOps\Support\Utils; @@ -297,6 +298,8 @@ public function track(string $id, Request $request) $vehicle->updateQuietly($positionData); $vehicle->createPosition($positionData); + // Location writes are quiet and frequent: invalidate cached lists at most once a window + ApiCacheThrottle::invalidate($vehicle, $vehicle->company_uuid); broadcast(new VehicleLocationChanged($vehicle)); app(TrackingPublisher::class)->vehicleMoved($vehicle); diff --git a/server/src/Support/ApiCacheThrottle.php b/server/src/Support/ApiCacheThrottle.php new file mode 100644 index 000000000..97fd4080a --- /dev/null +++ b/server/src/Support/ApiCacheThrottle.php @@ -0,0 +1,39 @@ +getTable() . ':' . ($companyUuid ?? 'all'); + if (Cache::add($key, true, $seconds)) { + ApiModelCache::invalidateModelCache($model, $companyUuid); + } + } +} From a2562a24d90c42ac8cf820b31d361e848fc1312e Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 23:17:52 +0800 Subject: [PATCH 5/7] test(api-cache): cover dependency declarations and quiet write invalidation 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. --- .../Unit/Support/ApiCacheDependenciesTest.php | 234 ++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 server/tests/Unit/Support/ApiCacheDependenciesTest.php diff --git a/server/tests/Unit/Support/ApiCacheDependenciesTest.php b/server/tests/Unit/Support/ApiCacheDependenciesTest.php new file mode 100644 index 000000000..eeed07026 --- /dev/null +++ b/server/tests/Unit/Support/ApiCacheDependenciesTest.php @@ -0,0 +1,234 @@ + 'co-1']); +}); + +afterEach(function () { + Carbon::setTestNow(); + EloquentModel::unsetConnectionResolver(); + EloquentModel::unsetEventDispatcher(); +}); + +/** + * The model's declared dependencies, read from the property because + * getApiCacheDependencies() only exists from fleetbase/core-api#301 on. + */ +function apiCacheDeclaredDependencies(EloquentModel $model): array +{ + $property = new ReflectionProperty($model, 'apiCacheDependencies'); + $property->setAccessible(true); + + return $property->getValue($model); +} + +/** + * The relation method a dependency segment names, matched the way core does (as written, camel or snake case). + */ +function apiCacheRelationMethod(EloquentModel $model, string $segment): ?string +{ + foreach (array_unique([$segment, Str::camel($segment), Str::snake($segment)]) as $method) { + if (!method_exists($model, $method)) { + continue; + } + + $reflection = new ReflectionMethod($model, $method); + if ($reflection->isPublic() && !$reflection->isStatic() && $reflection->getNumberOfRequiredParameters() === 0) { + return $method; + } + } + + return null; +} + +function apiCacheQueryVersion(string $table, ?string $companyUuid): int +{ + return (int) Cache::get("api_query_version:{$table}:{$companyUuid}", 0); +} + +function apiCacheDatabase(): SQLiteConnection +{ + $db = TrackingPageDatabase::boot(); + $schema = $db->getSchemaBuilder(); + + // Model hooks bind to the dispatcher present when a class boots + EloquentModel::setEventDispatcher(new Dispatcher()); + EloquentModel::clearBootedModels(); + // Core models clear the HTTP response cache on writes + app()->instance('responsecache', new class { + public function __call($method, $arguments) + { + return null; + } + }); + + foreach (['fleet_drivers' => 'driver_uuid', 'fleet_vehicles' => 'vehicle_uuid'] as $table => $member) { + $schema->create($table, function ($table) use ($member) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('fleet_uuid')->nullable(); + $table->string($member)->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + $schema->create('asset_connections', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + foreach (['connector_type', 'connector_uuid', 'connected_type', 'connected_uuid', 'relationship_type'] as $column) { + $table->string($column)->nullable(); + } + $table->timestamps(); + $table->softDeletes(); + }); + + $schema->table('orders', function ($table) { + $table->timestamp('dispatched_at')->nullable(); + }); + + return $db; +} + +test('declared api cache dependencies resolve to relations or to the tables of morphTo targets', function (string $modelClass, array $morphTargets) { + TrackingPageDatabase::boot(); + + $model = new $modelClass(); + $declared = apiCacheDeclaredDependencies($model); + $named = []; + + expect($declared)->not->toBeEmpty(); + + foreach ($declared as $path) { + $current = $model; + $segments = explode('.', $path); + + foreach ($segments as $segment) { + $method = apiCacheRelationMethod($current, $segment); + + // core treats a single segment naming no method as a table name + if ($method === null && count($segments) === 1) { + $named[] = $path; + continue 2; + } + + expect($method)->not->toBeNull("{$modelClass} dependency {$path} has no relation {$segment}"); + + $relation = Relation::noConstraints(fn () => $current->newInstance()->{$method}()); + + expect($relation)->toBeInstanceOf(Relation::class) + ->and($relation)->not->toBeInstanceOf(MorphTo::class); + + $current = $relation->getRelated(); + } + } + + expect($named)->toEqualCanonicalizing(array_map(fn (string $target) => (new $target())->getTable(), $morphTargets)); +})->with([ + 'order' => [Order::class, [Contact::class, Vendor::class, IntegratedVendor::class]], + 'driver' => [Driver::class, []], + 'vehicle' => [Vehicle::class, []], + 'device' => [Device::class, [Vehicle::class, Trailer::class]], + 'device event' => [DeviceEvent::class, []], + 'vendor' => [Vendor::class, []], + 'service area' => [ServiceArea::class, []], +]); + +test('models only embedded by cached lists invalidate their table on writes', function () { + $db = apiCacheDatabase(); + + $fleetDriver = FleetDriver::create(['fleet_uuid' => 'fleet-1', 'driver_uuid' => 'driver-1']); + expect(apiCacheQueryVersion('fleet_drivers', null))->toBe(1); + + $fleetDriver->delete(); + expect(apiCacheQueryVersion('fleet_drivers', null))->toBe(2); + + FleetVehicle::create(['fleet_uuid' => 'fleet-1', 'vehicle_uuid' => 'vehicle-1']); + expect(apiCacheQueryVersion('fleet_vehicles', null))->toBe(1); + + AssetConnection::create(['company_uuid' => 'co-1', 'connector_type' => Vehicle::class, 'connector_uuid' => 'vehicle-1', 'connected_type' => Trailer::class, 'connected_uuid' => 'trailer-1']); + expect(apiCacheQueryVersion('asset_connections', 'co-1'))->toBe(1); + + TrackingPageDatabase::insert($db, 'waypoints', ['uuid' => 'wp-1', 'company_uuid' => 'co-1', 'payload_uuid' => 'payload-1', 'order' => 0]); + Waypoint::where('uuid', 'wp-1')->first()->update(['order' => 1]); + expect(apiCacheQueryVersion('waypoints', 'co-1'))->toBe(1); +}); + +test('writes that skip model events invalidate the tables they write', function () { + $db = apiCacheDatabase(); + + // Order::dispatch() saves quietly + TrackingPageDatabase::insert($db, 'orders', ['uuid' => 'order-1', 'company_uuid' => 'co-1', 'dispatched' => false]); + Order::where('uuid', 'order-1')->first()->dispatch(); + expect(apiCacheQueryVersion('orders', 'co-1'))->toBe(1) + ->and($db->table('orders')->where('uuid', 'order-1')->value('dispatched'))->toBe(1); + + // Waypoint::insertGetUuid() inserts with the query builder + $waypointUuid = Waypoint::insertGetUuid(['place_uuid' => 'place-1', 'order' => 0]); + expect($waypointUuid)->toBeString() + ->and(apiCacheQueryVersion('waypoints', 'co-1'))->toBe(1); + + // Payload::removeWaypoints() deletes with the query builder + TrackingPageDatabase::insert($db, 'payloads', ['uuid' => 'payload-1', 'company_uuid' => 'co-1']); + $payload = new Payload(); + $payload->setRawAttributes(['uuid' => 'payload-1', 'company_uuid' => 'co-1'], true); + $payload->removeWaypoints(); + expect(apiCacheQueryVersion('waypoints', 'co-1'))->toBe(2); + + // TrackingStatus::insertGetUuid() inserts with the query builder + expect(TrackingStatus::insertGetUuid(['tracking_number_uuid' => 'tn-1', 'status' => 'Created']))->toBeString() + ->and(apiCacheQueryVersion('tracking_statuses', 'co-1'))->toBe(1); +}); + +test('throttled invalidation runs at most once per window and table', function () { + Carbon::setTestNow('2026-10-12 12:00:00'); + + ApiCacheThrottle::invalidate(new Vehicle(), 'co-1'); + ApiCacheThrottle::invalidate(new Vehicle(), 'co-1'); + ApiCacheThrottle::invalidate(new Driver(), 'co-1'); + + expect(apiCacheQueryVersion('vehicles', 'co-1'))->toBe(1) + ->and(apiCacheQueryVersion('drivers', 'co-1'))->toBe(1); + + Carbon::setTestNow(Carbon::now()->addSeconds(ApiCacheThrottle::LOCATION_WINDOW + 1)); + ApiCacheThrottle::invalidate(new Vehicle(), 'co-1'); + + expect(apiCacheQueryVersion('vehicles', 'co-1'))->toBe(2); +}); From 100cb88903ab9dc7d402bd2083f2bb4c26ef7ecd Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 12:50:20 +0800 Subject: [PATCH 6/7] fix(api-cache): keep vehicle lists cacheable under telemetry and pings 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. --- server/src/Models/Device.php | 16 ++++ server/src/Models/Vehicle.php | 12 ++- server/src/Support/ApiCacheThrottle.php | 10 ++- .../Unit/Support/ApiCacheDependenciesTest.php | 76 +++++++++++++++++++ 4 files changed, 108 insertions(+), 6 deletions(-) diff --git a/server/src/Models/Device.php b/server/src/Models/Device.php index 118650efc..d38ecf3c4 100644 --- a/server/src/Models/Device.php +++ b/server/src/Models/Device.php @@ -465,6 +465,10 @@ public function attachTo(Model $attachable): bool }); if ($updated) { + // Cached vehicle and trailer lists filter on attached devices without depending on + // the device table, which telemetry writes on every ping + static::invalidateAttachableApiCache($attachable, $this->company_uuid); + activity('device_attached') ->performedOn($this) ->withProperties([ @@ -478,6 +482,14 @@ public function attachTo(Model $attachable): bool return $updated; } + /** + * Invalidate the cached lists of the attached model's table (vehicles or trailers). + */ + protected static function invalidateAttachableApiCache(\Illuminate\Database\Eloquent\Model $attachable, ?string $companyUuid): void + { + \Fleetbase\Support\ApiModelCache::invalidateModelCache($attachable, $companyUuid); + } + /** * Detach the device from its current attachment. */ @@ -495,6 +507,10 @@ public function detach(): bool return $this->update(['attachable_type' => null, 'attachable_uuid' => null]); }); + if ($updated && $oldAttachableType && is_a($oldAttachableType, \Illuminate\Database\Eloquent\Model::class, true)) { + static::invalidateAttachableApiCache(new $oldAttachableType(), $this->company_uuid); + } + if ($updated) { activity('device_detached') ->performedOn($this) diff --git a/server/src/Models/Vehicle.php b/server/src/Models/Vehicle.php index 9f817dfb7..5819d2891 100644 --- a/server/src/Models/Vehicle.php +++ b/server/src/Models/Vehicle.php @@ -85,14 +85,18 @@ class Vehicle extends Model * Relation paths the cached vehicle lists embed or filter on (fleetbase/core-api#301). * * The public list loads vendor (with its place), category, warranty and - * driver; driver, devices, current trailers (through asset_connections) and - * fleets (through fleet_vehicles) decide which vehicles the filters return. + * driver; driver, current trailers (through asset_connections) and fleets + * (through fleet_vehicles) decide which vehicles the filters return. * photo is left out: files is written by every upload, and a new photo is - * set by writing the vehicle's own photo_uuid. + * set by writing the vehicle's own photo_uuid. devices is left out too: the + * device filters only read which devices are attached, but telemetry saves + * the device row on every ping, which would keep vehicle lists from ever + * being served from cache. Device::attachTo() and detach() invalidate the + * vehicle lists instead. * * @var string[] */ - protected $apiCacheDependencies = ['vendor', 'vendor.place', 'category', 'warranty', 'driver', 'devices', 'currentTrailers', 'fleets']; + protected $apiCacheDependencies = ['vendor', 'vendor.place', 'category', 'warranty', 'driver', 'currentTrailers', 'fleets']; /** * Get the activity log options for the model. diff --git a/server/src/Support/ApiCacheThrottle.php b/server/src/Support/ApiCacheThrottle.php index 97fd4080a..114cfbd12 100644 --- a/server/src/Support/ApiCacheThrottle.php +++ b/server/src/Support/ApiCacheThrottle.php @@ -32,8 +32,14 @@ public static function invalidate(Model $model, ?string $companyUuid, int $secon } $key = 'fleetops:api_cache_throttle:' . $model->getTable() . ':' . ($companyUuid ?? 'all'); - if (Cache::add($key, true, $seconds)) { - ApiModelCache::invalidateModelCache($model, $companyUuid); + + try { + if (Cache::add($key, true, $seconds)) { + ApiModelCache::invalidateModelCache($model, $companyUuid); + } + } catch (\Throwable $e) { + // A location ping must never fail on cache bookkeeping; cached lists then + // show the location until their TTL. Core versions before #301 can throw here. } } } diff --git a/server/tests/Unit/Support/ApiCacheDependenciesTest.php b/server/tests/Unit/Support/ApiCacheDependenciesTest.php index eeed07026..d3e80711e 100644 --- a/server/tests/Unit/Support/ApiCacheDependenciesTest.php +++ b/server/tests/Unit/Support/ApiCacheDependenciesTest.php @@ -232,3 +232,79 @@ public function __call($method, $arguments) expect(apiCacheQueryVersion('vehicles', 'co-1'))->toBe(2); }); + +test('attaching or detaching a device invalidates the attached model lists, and device pings do not', function () { + $db = apiCacheDatabase(); + $db->getSchemaBuilder()->create('devices', function ($table) { + $table->increments('id'); + foreach (['uuid', 'public_id', 'company_uuid', 'attachable_type', 'attachable_uuid', 'name', 'status'] as $column) { + $table->string($column)->nullable(); + } + $table->text('meta')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + TrackingPageDatabase::insert($db, 'devices', ['uuid' => 'device-1', 'company_uuid' => 'co-1']); + // attachTo() and detach() log activity; only the cache versions matter here + app()->instance(Spatie\Activitylog\ActivityLogger::class, new class extends Spatie\Activitylog\ActivityLogger { + public function __construct() + { + } + + public function useLog(?string $logName): static + { + return $this; + } + + public function setLogStatus(Spatie\Activitylog\ActivityLogStatus $logStatus): static + { + return $this; + } + + public function performedOn(EloquentModel $model): static + { + return $this; + } + + public function withProperties(mixed $properties): static + { + return $this; + } + + public function log(string $description): ?Spatie\Activitylog\Contracts\Activity + { + return null; + } + }); + app()->instance(Spatie\Activitylog\ActivityLogStatus::class, new Spatie\Activitylog\ActivityLogStatus(new Illuminate\Config\Repository(['activitylog' => ['enabled' => false]]))); + + $vehicle = new Vehicle(); + $vehicle->setRawAttributes(['uuid' => 'vehicle-1', 'company_uuid' => 'co-1'], true); + $device = Device::where('uuid', 'device-1')->first(); + + expect($device->attachTo($vehicle))->toBeTrue() + ->and(apiCacheQueryVersion('vehicles', 'co-1'))->toBe(1); + + // Telemetry saves the device row on every ping; vehicle lists no longer depend on it + $device->update(['status' => 'online']); + expect(apiCacheQueryVersion('vehicles', 'co-1'))->toBe(1); + + expect($device->detach())->toBeTrue() + ->and(apiCacheQueryVersion('vehicles', 'co-1'))->toBe(2); + + app()->forgetInstance(Spatie\Activitylog\ActivityLogger::class); + app()->forgetInstance(Spatie\Activitylog\ActivityLogStatus::class); +}); + +test('a cache outage never fails a throttled location invalidation', function () { + Cache::swap(new Repository(new class extends ArrayStore { + public function add($key, $value, $seconds) + { + throw new RuntimeException('cache unavailable'); + } + })); + + ApiCacheThrottle::invalidate(new Vehicle(), 'co-1'); + + expect(true)->toBeTrue(); +}); From c0eb5f5bda09c8e6b232b3d8e2408af7dc9b1dc8 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 13:12:35 +0800 Subject: [PATCH 7/7] fix(api-cache): coalesce cache invalidation from telemetry vehicle and 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). --- server/src/Support/ApiCacheThrottle.php | 27 +++++++++++++++++++ .../Support/Telematics/TelematicService.php | 7 +++-- .../Unit/Support/ApiCacheDependenciesTest.php | 27 +++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/server/src/Support/ApiCacheThrottle.php b/server/src/Support/ApiCacheThrottle.php index 114cfbd12..65b571bf9 100644 --- a/server/src/Support/ApiCacheThrottle.php +++ b/server/src/Support/ApiCacheThrottle.php @@ -22,6 +22,33 @@ class ApiCacheThrottle */ public const LOCATION_WINDOW = 60; + /** + * Run a frequent write (a telemetry save) with its model events, but invalidate + * the model's cached lists at most once per window instead of on every write. + * + * Order and food truck lists embed vehicles, so invalidating them on every + * telemetry event would keep them from ever being served from cache for any + * company with streaming devices. Needs ApiModelCache::withoutInvalidating() + * (fleetbase/core-api#301); with an older core the write invalidates as before. + * + * @template T + * + * @param \Closure(): T $write + * + * @return T + */ + public static function telemetryWrite(Model $model, \Closure $write, int $seconds = self::LOCATION_WINDOW): mixed + { + if (!method_exists(ApiModelCache::class, 'withoutInvalidating')) { + return $write(); + } + + $result = ApiModelCache::withoutInvalidating($write); + static::invalidate($model, $model->company_uuid, $seconds); + + return $result; + } + /** * Invalidate the model's table for the company unless that was done within the window. */ diff --git a/server/src/Support/Telematics/TelematicService.php b/server/src/Support/Telematics/TelematicService.php index 2f7f10ce1..250ea5374 100644 --- a/server/src/Support/Telematics/TelematicService.php +++ b/server/src/Support/Telematics/TelematicService.php @@ -13,6 +13,7 @@ use Fleetbase\FleetOps\Models\Telematic; use Fleetbase\FleetOps\Models\Trailer; use Fleetbase\FleetOps\Models\Vehicle; +use Fleetbase\FleetOps\Support\ApiCacheThrottle; use Fleetbase\FleetOps\Support\TrackingPublisher; use Fleetbase\LaravelMysqlSpatial\Types\Point as SpatialPoint; use Illuminate\Support\Carbon; @@ -760,7 +761,8 @@ protected function updateTrailerTelemetry(Trailer $trailer, array $location, arr 'last_provider' => $event->provider, 'last_telemetry_data' => array_filter($eventData, fn ($value) => $value !== null), ]); - $trailer->save(); + // Every telemetry event saves the trailer: model events fire, cache invalidation is coalesced + ApiCacheThrottle::telemetryWrite($trailer, fn () => $trailer->save()); $broadcast = new TrailerLocationChanged($trailer, ['source' => 'telematics', 'device_event_uuid' => $event->uuid, 'provider' => $event->provider, 'position_at' => $event->occurred_at?->toISOString()]); $this->broadcastTelemetry($broadcast, $eventData['_ordered_telemetry'] ?? false); @@ -803,7 +805,8 @@ protected function updateVehicleTelemetry(Vehicle $vehicle, array $location, arr 'fuel_level' => $eventData['fuel_level'] ?? null, ], fn ($value) => $value !== null), ]); - $vehicle->save(); + // Every telemetry event saves the vehicle: model events fire, cache invalidation is coalesced + ApiCacheThrottle::telemetryWrite($vehicle, fn () => $vehicle->save()); $broadcast = new VehicleLocationChanged($vehicle, [ 'source' => 'telematics', diff --git a/server/tests/Unit/Support/ApiCacheDependenciesTest.php b/server/tests/Unit/Support/ApiCacheDependenciesTest.php index d3e80711e..d721343e0 100644 --- a/server/tests/Unit/Support/ApiCacheDependenciesTest.php +++ b/server/tests/Unit/Support/ApiCacheDependenciesTest.php @@ -308,3 +308,30 @@ public function add($key, $value, $seconds) expect(true)->toBeTrue(); }); + +test('telemetry writes fire their model events but invalidate at most once per window', function () { + Carbon::setTestNow('2026-10-12 12:00:00'); + $vehicle = new Vehicle(); + $vehicle->setRawAttributes(['uuid' => 'vehicle-1', 'company_uuid' => 'co-1'], true); + $writes = 0; + // Stands in for an evented save, whose model hooks invalidate the table + $save = function () use ($vehicle, &$writes) { + $writes++; + Fleetbase\Support\ApiModelCache::invalidateModelCache($vehicle, 'co-1'); + + return true; + }; + + foreach (range(1, 3) as $ping) { + expect(ApiCacheThrottle::telemetryWrite($vehicle, $save))->toBeTrue(); + } + + expect($writes)->toBe(3); + if (method_exists(Fleetbase\Support\ApiModelCache::class, 'withoutInvalidating')) { + // fleetbase/core-api#301: the saves' own invalidations are coalesced into one per window + expect(apiCacheQueryVersion('vehicles', 'co-1'))->toBe(1); + } else { + // An older core keeps invalidating on every save, as before, with no extra throttled bump + expect(apiCacheQueryVersion('vehicles', 'co-1'))->toBe(3); + } +});