@fleetbase/fleetops-engine 0.6.66 → 0.6.67
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/RELEASE.md +15 -8
- package/addon/components/telematic/details.hbs +1 -1
- package/addon/components/telematic/telemetry-status.hbs +93 -34
- package/addon/components/widget/radar.hbs +1 -1
- package/addon/components/widget/radar.js +7 -0
- package/composer.json +2 -1
- package/docs/AFAQY.md +71 -17
- package/docs/SAFEE.md +125 -0
- package/docs/TELEMATICS_QUEUES.md +179 -0
- package/extension.json +1 -1
- package/package.json +1 -1
- package/server/config/safee.php +10 -0
- package/server/config/telematics.php +8 -1
- package/server/config/telemetry.php +4 -1
- package/server/migrations/2026_09_17_000001_add_device_event_uuid_lookup_index.php +90 -0
- package/server/src/Console/Commands/SyncTelematics.php +11 -5
- package/server/src/Events/DeviceTelemetryUpdated.php +2 -1
- package/server/src/Events/TrailerLocationChanged.php +2 -0
- package/server/src/Events/VehicleLocationChanged.php +5 -0
- package/server/src/Jobs/PollTelematicTelemetry.php +127 -12
- package/server/src/Jobs/ProcessTelematicDelivery.php +64 -10
- package/server/src/Jobs/SyncTelematicDevicesJob.php +77 -1
- package/server/src/Jobs/TestTelematicConnectionJob.php +1 -0
- package/server/src/Providers/FleetOpsServiceProvider.php +5 -2
- package/server/src/Support/Telematics/Providers/AfaqyProvider.php +65 -10
- package/server/src/Support/Telematics/Providers/SafeeProvider.php +213 -139
- package/server/src/Support/Telematics/Safee/Transport.php +210 -0
- package/server/src/Support/Telematics/TelematicService.php +43 -1
- package/server/src/Support/Telematics/Telemetry/Configuration.php +16 -0
- package/server/src/Support/Telematics/Telemetry/Inbox.php +31 -3
- package/server/src/Support/Telematics/Telemetry/Ingestor.php +1 -1
- package/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php +147 -58
- package/server/tests/Feature/Http/Api/DeviceEventUuidIndexMigrationTest.php +106 -0
- package/server/tests/Feature/Http/ManualTelemetryPollingTest.php +461 -0
- package/server/tests/Feature/Http/SafeeRealtimeIngestionTest.php +208 -0
- package/server/tests/Feature/Http/TelematicDeliveryCheckpointTest.php +198 -0
- package/server/tests/Feature/Http/TelematicMixedFleetDeliveryTest.php +166 -0
- package/server/tests/ProviderContractsTest.php +50 -1
- package/server/tests/Support/AfaqyTestCrypto.php +6 -23
- package/server/tests/SupportJobAndAiCoverageTest.php +102 -5
- package/server/tests/TelematicsHardeningTest.php +18 -13
- package/server/tests/Unit/Console/SyncTelematicsCommandTest.php +140 -0
- package/server/tests/Unit/Support/Telematics/Providers/AfaqyProviderTransportTest.php +132 -0
- package/server/tests/Unit/Support/Telematics/Providers/ProviderTransportSeamsTest.php +1 -1
- package/server/tests/Unit/Support/Telematics/Providers/SafeeLiveContractTest.php +335 -0
- package/server/tests/Unit/Support/Telematics/Providers/SafeeProviderTest.php +17 -27
- package/server/tests/Unit/Support/Telematics/TelematicsQueueRoutingTest.php +36 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# Dedicated telematics queue workers
|
|
2
|
+
|
|
3
|
+
By default, all telematics work runs on the `default` queue alongside every other
|
|
4
|
+
background job. On an instance with a large fleet, that work can crowd out
|
|
5
|
+
notifications, order events, and other jobs. This guide moves telematics work to
|
|
6
|
+
its own queues so dedicated worker containers process it while the existing
|
|
7
|
+
worker keeps serving `default`.
|
|
8
|
+
|
|
9
|
+
This is an **opt-in, per-instance** setup. With no environment variables set,
|
|
10
|
+
nothing changes: every job and broadcast stays on the same queue as before.
|
|
11
|
+
|
|
12
|
+
## What each setting routes
|
|
13
|
+
|
|
14
|
+
| Setting | Default | Work routed |
|
|
15
|
+
| --- | --- | --- |
|
|
16
|
+
| `TELEMATICS_POLL_QUEUE` | `default` | Scheduled and manual polls (`PollTelematicTelemetry`), legacy device discovery (`SyncTelematicDevicesJob`), and queued connection tests (`TestTelematicConnectionJob`). |
|
|
17
|
+
| `TELEMATICS_INGESTION_QUEUE` | `default` | Applying polled or pushed positions (`ProcessTelematicDelivery`), including jobs re-queued by `fleetops:drain-telematic-inbox` and manual replays. |
|
|
18
|
+
| `TELEMATICS_BROADCAST_QUEUE` | unset | Live-map broadcasts created by telematics ingestion: `DeviceTelemetryUpdated`, and `VehicleLocationChanged` / `TrailerLocationChanged` when a device is attached. When unset, broadcasts use the queue connection's default queue. |
|
|
19
|
+
|
|
20
|
+
`TELEMATICS_BROADCAST_QUEUE` affects only broadcasts that telematics ingestion
|
|
21
|
+
creates. Vehicle and trailer location broadcasts from the driver app and public API
|
|
22
|
+
remain on the default queue.
|
|
23
|
+
|
|
24
|
+
Broadcasts can outnumber sync jobs. Each ingested unit with a newer position or
|
|
25
|
+
contact time queues one device broadcast, plus one vehicle or trailer broadcast
|
|
26
|
+
when attached. A fleet that reports every minute can queue hundreds of broadcast
|
|
27
|
+
jobs per minute, so route broadcasts as well as polling and ingestion.
|
|
28
|
+
|
|
29
|
+
## Before you start: check which sync path the provider uses
|
|
30
|
+
|
|
31
|
+
Separate workers keep telematics from blocking other jobs. They do not make a slow
|
|
32
|
+
sync faster, and they do not fix an unsafe job.
|
|
33
|
+
|
|
34
|
+
- **Bounded polling path** (Safee scheduled and manual syncs; AFAQY scheduled polls
|
|
35
|
+
with `AFAQY_POLLING_ENABLED=true`): poll attempts are limited to an 80-second
|
|
36
|
+
hard timeout and a 60-second sweep budget, and ingestion yields every 40 seconds. These jobs are safe for the stock Redis
|
|
37
|
+
`retry_after` of 90 seconds and for multiple concurrent workers.
|
|
38
|
+
- **Legacy discovery path** (other providers, AFAQY manual syncs, and AFAQY scheduled
|
|
39
|
+
syncs while polling is disabled):
|
|
40
|
+
`SyncTelematicDevicesJob` allows a 3,600-second run while Redis `retry_after`
|
|
41
|
+
is 90 seconds. A run lasting over 90 seconds can be handed to another worker and
|
|
42
|
+
end with `SyncTelematicDevicesJob has been attempted too many times`. Adding
|
|
43
|
+
workers increases the likelihood that another worker picks it up. Where possible,
|
|
44
|
+
move the provider to the bounded polling path, for example by setting
|
|
45
|
+
`AFAQY_POLLING_ENABLED=true` for AFAQY connections.
|
|
46
|
+
|
|
47
|
+
## Setup
|
|
48
|
+
|
|
49
|
+
Start the workers **before** setting the queue variables. Jobs sent to a queue with
|
|
50
|
+
no worker wait indefinitely.
|
|
51
|
+
|
|
52
|
+
### 1. Add telematics workers
|
|
53
|
+
|
|
54
|
+
Add worker services with the same image, environment, and volumes as the existing
|
|
55
|
+
`queue` service. Change only the command and healthcheck. Docker Compose example:
|
|
56
|
+
|
|
57
|
+
```yaml
|
|
58
|
+
services:
|
|
59
|
+
telematics-queue:
|
|
60
|
+
image: fleetbase/fleetbase-api:latest
|
|
61
|
+
command: ["php", "artisan", "queue:work", "--queue=telematics", "--sleep=1", "--max-time=3600"]
|
|
62
|
+
restart: unless-stopped
|
|
63
|
+
deploy:
|
|
64
|
+
replicas: 2
|
|
65
|
+
# environment, volumes and depends_on: copy from the existing `queue` service
|
|
66
|
+
|
|
67
|
+
telematics-broadcast-queue:
|
|
68
|
+
image: fleetbase/fleetbase-api:latest
|
|
69
|
+
command: ["php", "artisan", "queue:work", "--queue=telematics-broadcasts", "--sleep=1", "--max-time=3600"]
|
|
70
|
+
restart: unless-stopped
|
|
71
|
+
# environment, volumes and depends_on: copy from the existing `queue` service
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Notes:
|
|
75
|
+
|
|
76
|
+
- Leave the existing `queue` service unchanged. `queue:work` without `--queue`
|
|
77
|
+
processes the connection's default queue (`REDIS_QUEUE`, normally `default`).
|
|
78
|
+
- Leave `--timeout` at its default (60 seconds) or set any value below the Redis
|
|
79
|
+
connection's `retry_after` (90 seconds). Polls (80 seconds) and ingestion
|
|
80
|
+
(60 seconds) define their own timeouts, which override the worker setting.
|
|
81
|
+
Broadcast jobs use the worker value.
|
|
82
|
+
- `--max-time` recycles long-running workers so they pick up deployments and release
|
|
83
|
+
memory. `restart: unless-stopped` starts them again.
|
|
84
|
+
- On Kubernetes, ECS, or another platform, create equivalent deployments with the
|
|
85
|
+
same image, environment, and commands.
|
|
86
|
+
- Every worker container needs the same `APP_KEY`, database, Redis, and broadcasting
|
|
87
|
+
settings as the application. Deliveries are encrypted with `APP_KEY`.
|
|
88
|
+
|
|
89
|
+
### 2. Set the queue variables for this instance only
|
|
90
|
+
|
|
91
|
+
Add these to the environment of the application, scheduler, **and every queue
|
|
92
|
+
worker** container. The scheduler dispatches polls and the application dispatches
|
|
93
|
+
manual syncs, so every process must agree on the queue names:
|
|
94
|
+
|
|
95
|
+
```dotenv
|
|
96
|
+
TELEMATICS_POLL_QUEUE=telematics
|
|
97
|
+
TELEMATICS_INGESTION_QUEUE=telematics
|
|
98
|
+
TELEMATICS_BROADCAST_QUEUE=telematics-broadcasts
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
To use one worker type for all telematics work, set all three to `telematics` and
|
|
102
|
+
omit the broadcast worker.
|
|
103
|
+
|
|
104
|
+
AFAQY polls and ingestion also read `AFAQY_POLL_QUEUE` and `AFAQY_INGESTION_QUEUE`.
|
|
105
|
+
Those take precedence over the `TELEMATICS_*` values when set, so remove or align
|
|
106
|
+
them. Legacy discovery and connection-test jobs always use `TELEMATICS_POLL_QUEUE`.
|
|
107
|
+
|
|
108
|
+
### 3. Deploy the change
|
|
109
|
+
|
|
110
|
+
1. Deploy the new worker containers and confirm they are running.
|
|
111
|
+
2. Apply the environment variables to the application, scheduler, and workers.
|
|
112
|
+
3. If configuration is cached, rebuild it with `php artisan config:cache` in each
|
|
113
|
+
container, or through the normal image/deploy process.
|
|
114
|
+
4. Restart long-running processes so they read the new configuration:
|
|
115
|
+
`php artisan queue:restart`, then reload Octane (`php artisan octane:reload`)
|
|
116
|
+
if the application runs under Octane.
|
|
117
|
+
|
|
118
|
+
### 4. Existing backlog on `default`
|
|
119
|
+
|
|
120
|
+
Jobs already on `default` stay there, and the existing worker must process them.
|
|
121
|
+
The new setting affects only newly dispatched jobs.
|
|
122
|
+
|
|
123
|
+
- Do not run `php artisan queue:clear` on `default`. It deletes every pending job,
|
|
124
|
+
including non-telematics work.
|
|
125
|
+
- For opted-in batch providers such as Safee, a queued `SyncTelematicDevicesJob`
|
|
126
|
+
hands off to bounded polling and exits within seconds.
|
|
127
|
+
- Queued legacy discovery jobs for other providers can each occupy the default
|
|
128
|
+
worker for a long time. If the backlog does not drain, inspect those specific
|
|
129
|
+
jobs rather than clearing the entire queue.
|
|
130
|
+
|
|
131
|
+
## Sizing
|
|
132
|
+
|
|
133
|
+
Start with **2 telematics workers and 1 broadcast worker**, then adjust based on
|
|
134
|
+
queue depth.
|
|
135
|
+
|
|
136
|
+
Current work per connection:
|
|
137
|
+
|
|
138
|
+
- One poll per connection per minute, coalesced while its previous sweep is still
|
|
139
|
+
ingesting. Safee requests at most 1,000 vehicle IDs per `last-state` call.
|
|
140
|
+
- Up to `ceil(units / TELEMATICS_BATCH_SIZE)` ingestion jobs per sweep (default
|
|
141
|
+
100 units per job). A fleet of about 470 reporting devices is about 5 ingestion
|
|
142
|
+
jobs per minute.
|
|
143
|
+
- Workers can process different connections, deliveries, and devices concurrently.
|
|
144
|
+
Per-connection and per-device locks prevent duplicate polling or out-of-order
|
|
145
|
+
position updates.
|
|
146
|
+
|
|
147
|
+
For reference, a local single-worker run for 93 Safee units applied each delivery in
|
|
148
|
+
about 5-8 seconds (about 55-85 ms per unit), including database work. This was a
|
|
149
|
+
development machine, not production. Measure production queue depth rather than
|
|
150
|
+
extrapolating this value.
|
|
151
|
+
|
|
152
|
+
Additional workers do not increase provider limits. Safee connections share a limit
|
|
153
|
+
of 50 requests per second per account across all workers.
|
|
154
|
+
|
|
155
|
+
## Verification
|
|
156
|
+
|
|
157
|
+
Queue depth should stay close to zero between minute ticks:
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
php artisan queue:monitor redis:default,redis:telematics,redis:telematics-broadcasts --max=500
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Also verify:
|
|
164
|
+
|
|
165
|
+
- `telematic_sync_runs` for the connection shows a new `completed` run about once
|
|
166
|
+
per minute. `incomplete` means the fetch failed and will retry.
|
|
167
|
+
- The worker logs show `PollTelematicTelemetry` and `ProcessTelematicDelivery` only
|
|
168
|
+
on the telematics workers, and broadcast jobs only on the broadcast worker.
|
|
169
|
+
- Other jobs on `default`, such as notifications and order events, start promptly
|
|
170
|
+
again.
|
|
171
|
+
|
|
172
|
+
## Rollback
|
|
173
|
+
|
|
174
|
+
1. Remove the three variables from the application, scheduler, and workers, rebuild
|
|
175
|
+
cached configuration, and restart processes as in setup step 3.
|
|
176
|
+
2. Keep the telematics workers running until `telematics` and
|
|
177
|
+
`telematics-broadcasts` are empty. Jobs remaining on those queues are not moved
|
|
178
|
+
back to `default`.
|
|
179
|
+
3. Remove the worker containers.
|
package/extension.json
CHANGED
package/package.json
CHANGED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
return [
|
|
4
|
+
'polling_enabled' => env('SAFEE_POLLING_ENABLED', true),
|
|
5
|
+
'webhooks_enabled' => false,
|
|
6
|
+
'manual_batch_sync' => true,
|
|
7
|
+
'page_size' => 1000,
|
|
8
|
+
'inventory_cache_seconds' => env('SAFEE_INVENTORY_CACHE_SECONDS', 300),
|
|
9
|
+
'request_timeout_seconds' => env('SAFEE_REQUEST_TIMEOUT_SECONDS', 30),
|
|
10
|
+
];
|
|
@@ -176,6 +176,7 @@ return [
|
|
|
176
176
|
'driver_class' => Fleetbase\FleetOps\Support\Telematics\Providers\SafeeProvider::class,
|
|
177
177
|
'icon' => '/engines-dist/images/telematics/providers/safee.webp',
|
|
178
178
|
'description' => 'Safee Tracking REST integration for vehicle discovery, last state, live positions, odometer, fuel, and sensor data.',
|
|
179
|
+
'docs_url' => 'https://www.safee.com/_docs_/Safee_Tracking_REST_Service.pdf',
|
|
179
180
|
'required_fields' => [
|
|
180
181
|
[
|
|
181
182
|
'name' => 'server_uri',
|
|
@@ -229,7 +230,13 @@ return [
|
|
|
229
230
|
'supports_discovery' => true,
|
|
230
231
|
'metadata' => [
|
|
231
232
|
'rate_limit' => 3000,
|
|
232
|
-
'
|
|
233
|
+
'rate_limit_per_second' => 50,
|
|
234
|
+
'pagination' => 'inventory_cursor',
|
|
235
|
+
'telemetry' => [
|
|
236
|
+
'durable_ingestion' => true,
|
|
237
|
+
'secure_webhooks' => false,
|
|
238
|
+
'reconciliation' => true,
|
|
239
|
+
],
|
|
233
240
|
'capabilities' => ['tracking', 'speed', 'heading', 'odometer', 'fuel_level', 'ignition'],
|
|
234
241
|
],
|
|
235
242
|
],
|
|
@@ -5,9 +5,12 @@ return [
|
|
|
5
5
|
'webhooks_enabled' => false,
|
|
6
6
|
'poll_queue' => env('TELEMATICS_POLL_QUEUE', 'default'),
|
|
7
7
|
'ingestion_queue' => env('TELEMATICS_INGESTION_QUEUE', 'default'),
|
|
8
|
+
// Null keeps telemetry broadcasts on the connection's default queue.
|
|
9
|
+
'broadcast_queue' => env('TELEMATICS_BROADCAST_QUEUE'),
|
|
8
10
|
'page_size' => 1000,
|
|
9
11
|
'max_pages' => 100,
|
|
10
|
-
'
|
|
12
|
+
'request_timeout_seconds' => env('TELEMATICS_REQUEST_TIMEOUT_SECONDS', 45),
|
|
13
|
+
'batch_size' => env('TELEMATICS_BATCH_SIZE', 100),
|
|
11
14
|
'max_payload_bytes' => 2097152,
|
|
12
15
|
'max_pending_deliveries' => 10000,
|
|
13
16
|
'processed_retention_hours' => 24,
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
use Illuminate\Database\Migrations\Migration;
|
|
4
|
+
use Illuminate\Database\Schema\Blueprint;
|
|
5
|
+
use Illuminate\Support\Facades\Schema;
|
|
6
|
+
|
|
7
|
+
return new class extends Migration {
|
|
8
|
+
private const INDEX = 'device_events_uuid_lookup_index';
|
|
9
|
+
|
|
10
|
+
public function up(): void
|
|
11
|
+
{
|
|
12
|
+
if (!Schema::hasTable('device_events') || !Schema::hasColumn('device_events', 'uuid')) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
$indexes = $this->indexes();
|
|
17
|
+
foreach ($indexes as $index) {
|
|
18
|
+
// A primary, unique, or composite index with uuid first already
|
|
19
|
+
// supports Eloquent's uuid lookup, regardless of the index name.
|
|
20
|
+
if ($index['usable'] && ($index['columns'][0] ?? null) === 'uuid') {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (isset($indexes[self::INDEX])) {
|
|
26
|
+
throw new RuntimeException('The device event UUID lookup index name is already used by another index.');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The legacy table uses an integer primary key, while DeviceEvent uses
|
|
30
|
+
// uuid. In particular, activity logging refreshes every saved event by
|
|
31
|
+
// uuid. Keep this non-unique to preserve legacy nullable/duplicate data.
|
|
32
|
+
Schema::table('device_events', fn (Blueprint $table) => $table->index('uuid', self::INDEX));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
public function down(): void
|
|
36
|
+
{
|
|
37
|
+
if (!Schema::hasTable('device_events')) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
$index = $this->indexes()[self::INDEX] ?? null;
|
|
42
|
+
if ($index && $index['columns'] === ['uuid']) {
|
|
43
|
+
Schema::table('device_events', fn (Blueprint $table) => $table->dropIndex(self::INDEX));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Inspect indexes without relying on Schema::getIndexes, which is absent
|
|
49
|
+
* in Laravel 9 and early Laravel 10. Fleet-Ops uses MySQL in deployments
|
|
50
|
+
* and SQLite for its migration tests.
|
|
51
|
+
*
|
|
52
|
+
* @return array<string, array{columns: array, usable: bool}>
|
|
53
|
+
*/
|
|
54
|
+
private function indexes(): array
|
|
55
|
+
{
|
|
56
|
+
$connection = Schema::getConnection();
|
|
57
|
+
$table = $connection->getTablePrefix() . 'device_events';
|
|
58
|
+
$indexes = [];
|
|
59
|
+
|
|
60
|
+
if ($connection->getDriverName() === 'sqlite') {
|
|
61
|
+
$table = str_replace('"', '""', $table);
|
|
62
|
+
foreach ($connection->select('PRAGMA index_list("' . $table . '")') as $index) {
|
|
63
|
+
$name = str_replace('"', '""', $index->name);
|
|
64
|
+
$columns = $connection->select('PRAGMA index_info("' . $name . '")');
|
|
65
|
+
usort($columns, fn ($a, $b) => $a->seqno <=> $b->seqno);
|
|
66
|
+
$indexes[$index->name] = ['columns' => array_column($columns, 'name'), 'usable' => !($index->partial ?? false)];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return $indexes;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if ($connection->getDriverName() !== 'mysql') {
|
|
73
|
+
throw new RuntimeException('Device event UUID index inspection requires MySQL or SQLite.');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
$table = str_replace('`', '``', $table);
|
|
77
|
+
foreach ($connection->select('SHOW INDEX FROM `' . $table . '`') as $index) {
|
|
78
|
+
$indexes[$index->Key_name]['columns'][(int) $index->Seq_in_index - 1] = $index->Column_name;
|
|
79
|
+
$indexes[$index->Key_name]['usable'] = in_array(strtoupper($index->Index_type), ['BTREE', 'HASH'], true)
|
|
80
|
+
&& strtoupper($index->Visible ?? 'YES') !== 'NO'
|
|
81
|
+
&& strtoupper($index->Ignored ?? 'NO') !== 'YES';
|
|
82
|
+
}
|
|
83
|
+
foreach ($indexes as &$index) {
|
|
84
|
+
ksort($index['columns']);
|
|
85
|
+
$index['columns'] = array_values($index['columns']);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return $indexes;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
@@ -24,7 +24,7 @@ class SyncTelematics extends Command
|
|
|
24
24
|
$lock = null;
|
|
25
25
|
|
|
26
26
|
if ($useLock) {
|
|
27
|
-
$lock = Cache::lock('fleetops:sync-telematics',
|
|
27
|
+
$lock = Cache::lock('fleetops:sync-telematics', 120);
|
|
28
28
|
if (!$lock->get()) {
|
|
29
29
|
$this->warn('Another telematics sync run appears to be in progress.');
|
|
30
30
|
|
|
@@ -40,6 +40,7 @@ class SyncTelematics extends Command
|
|
|
40
40
|
return self::SUCCESS;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
$queued = 0;
|
|
43
44
|
$telemetryProviders = [];
|
|
44
45
|
foreach ($providerKeys as $key) {
|
|
45
46
|
if (!data_get($registry->findByKey($key)?->metadata, 'telemetry.durable_ingestion', false)) {
|
|
@@ -50,18 +51,24 @@ class SyncTelematics extends Command
|
|
|
50
51
|
continue;
|
|
51
52
|
}
|
|
52
53
|
$options = \Fleetbase\FleetOps\Support\Telematics\Telemetry\Configuration::options($provider);
|
|
54
|
+
// Batch-only providers have no legacy fallback: pausing polling pauses their sync.
|
|
53
55
|
if (!($options['polling_enabled'] ?? false)) {
|
|
56
|
+
if ($options['manual_batch_sync'] ?? false) {
|
|
57
|
+
$telemetryProviders[] = $key;
|
|
58
|
+
}
|
|
54
59
|
continue;
|
|
55
60
|
}
|
|
56
61
|
$telemetryProviders[] = $key;
|
|
57
62
|
Telematic::withoutGlobalScopes()->where('provider', $key)->whereIn('status', ['active', 'connected', 'error', 'synchronizing'])
|
|
58
|
-
->whereNotNull('company_uuid')->orderBy('id')->chunkById(100, function ($connections) use ($options) {
|
|
63
|
+
->whereNotNull('company_uuid')->orderBy('id')->chunkById(100, function ($connections) use ($options, &$queued) {
|
|
59
64
|
foreach ($connections as $connection) {
|
|
60
65
|
if (\Fleetbase\FleetOps\Support\Telematics\Telemetry\Inbox::enabled($connection)) {
|
|
61
66
|
try {
|
|
62
|
-
\Fleetbase\FleetOps\Support\Telematics\Telemetry\Queue::dispatch((new \Fleetbase\FleetOps\Jobs\PollTelematicTelemetry($connection->uuid))
|
|
67
|
+
if (\Fleetbase\FleetOps\Support\Telematics\Telemetry\Queue::dispatch((new \Fleetbase\FleetOps\Jobs\PollTelematicTelemetry($connection->uuid))
|
|
63
68
|
->onQueue($options['poll_queue'] ?? 'default')
|
|
64
|
-
->delay(now()->addSeconds(abs(crc32($connection->uuid)) % 10)))
|
|
69
|
+
->delay(now()->addSeconds(abs(crc32($connection->uuid)) % 10)))) {
|
|
70
|
+
$queued++;
|
|
71
|
+
}
|
|
65
72
|
} catch (\Throwable) {
|
|
66
73
|
\Illuminate\Support\Facades\Log::warning('Telemetry polling dispatch failed; next tick will retry.', ['telematic_uuid' => $connection->uuid]);
|
|
67
74
|
}
|
|
@@ -75,7 +82,6 @@ class SyncTelematics extends Command
|
|
|
75
82
|
->whereIn('status', ['active', 'connected'])
|
|
76
83
|
->whereNotNull('company_uuid');
|
|
77
84
|
|
|
78
|
-
$queued = 0;
|
|
79
85
|
$query->orderBy('id')->chunkById(100, function ($telematics) use (&$queued) {
|
|
80
86
|
foreach ($telematics as $telematic) {
|
|
81
87
|
SyncTelematicDevicesJob::dispatch($telematic, [
|
|
@@ -8,7 +8,8 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
|
|
8
8
|
|
|
9
9
|
class DeviceTelemetryUpdated implements ShouldBroadcast
|
|
10
10
|
{
|
|
11
|
-
public bool $afterCommit
|
|
11
|
+
public bool $afterCommit = true;
|
|
12
|
+
public ?string $broadcastQueue = null;
|
|
12
13
|
public string $companyUuid;
|
|
13
14
|
public string $deviceUuid;
|
|
14
15
|
public array $data;
|
|
@@ -25,6 +25,8 @@ class TrailerLocationChanged implements ShouldBroadcast
|
|
|
25
25
|
public mixed $heading;
|
|
26
26
|
public mixed $speed;
|
|
27
27
|
public array $additionalData;
|
|
28
|
+
// Optional queue for telematics-originated broadcasts. Null keeps Laravel's default.
|
|
29
|
+
public ?string $broadcastQueue = null;
|
|
28
30
|
|
|
29
31
|
public function __construct(Trailer $trailer, array $additionalData = [])
|
|
30
32
|
{
|
|
@@ -25,6 +25,11 @@ class VehicleLocationChanged implements ShouldBroadcast
|
|
|
25
25
|
public ?string $companyUuid;
|
|
26
26
|
public ?string $apiCredentialUuid;
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Optional queue for telematics-originated broadcasts. Null keeps Laravel's default.
|
|
30
|
+
*/
|
|
31
|
+
public ?string $broadcastQueue = null;
|
|
32
|
+
|
|
28
33
|
/**
|
|
29
34
|
* The datetime instance the broadcast ws triggered.
|
|
30
35
|
*
|
|
@@ -23,12 +23,21 @@ class PollTelematicTelemetry implements ShouldQueue, ShouldBeUnique
|
|
|
23
23
|
use InteractsWithQueue;
|
|
24
24
|
use Queueable;
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
public int $
|
|
28
|
-
public int $
|
|
26
|
+
// Reservations released while earlier batches drain are not provider failures.
|
|
27
|
+
public int $tries = 0;
|
|
28
|
+
public int $maxExceptions = 5;
|
|
29
|
+
// Keep every attempt below Laravel's baseline Redis retry_after of 90 seconds.
|
|
30
|
+
public int $timeout = 80;
|
|
31
|
+
public int $uniqueFor = 3600;
|
|
32
|
+
public ?string $manualJobId = null;
|
|
33
|
+
public array $requestOptions = [];
|
|
34
|
+
public ?int $retryDeadline = null;
|
|
29
35
|
|
|
30
|
-
public function __construct(public string $telematicUuid)
|
|
36
|
+
public function __construct(public string $telematicUuid, ?string $manualJobId = null, array $requestOptions = [])
|
|
31
37
|
{
|
|
38
|
+
$this->manualJobId = $manualJobId;
|
|
39
|
+
$this->requestOptions = $requestOptions;
|
|
40
|
+
$this->retryDeadline = now()->addMinutes(15)->getTimestamp();
|
|
32
41
|
}
|
|
33
42
|
|
|
34
43
|
public function uniqueId(): string
|
|
@@ -38,11 +47,35 @@ class PollTelematicTelemetry implements ShouldQueue, ShouldBeUnique
|
|
|
38
47
|
|
|
39
48
|
public function backoff(): array
|
|
40
49
|
{
|
|
41
|
-
|
|
50
|
+
// A scheduled retry holds the uniqueness lease that suppresses minute ticks,
|
|
51
|
+
// so it must not wait longer than the polling interval after a transient failure.
|
|
52
|
+
return $this->manualJobId ? [15, 60, 180, 300] : [15, 30, 60, 60];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
public function retryUntil(): \Illuminate\Support\Carbon
|
|
56
|
+
{
|
|
57
|
+
if ($this->retryDeadline === null) {
|
|
58
|
+
// Older serialized jobs have no deadline property. Anchor their first
|
|
59
|
+
// observed attempt once, rather than extending it on each redelivery.
|
|
60
|
+
$key = 'telemetry:poll:retry-deadline:' . ($this->job?->uuid() ?? $this->manualJobId ?? $this->telematicUuid);
|
|
61
|
+
$this->retryDeadline = Cache::remember($key, 86400, fn () => now()->addMinutes(15)->getTimestamp());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return \Illuminate\Support\Carbon::createFromTimestampUTC($this->retryDeadline);
|
|
42
65
|
}
|
|
43
66
|
|
|
44
67
|
public function handle(TelematicProviderRegistry $registry, Inbox $inbox): void
|
|
45
68
|
{
|
|
69
|
+
if (now()->gte($this->retryUntil())) {
|
|
70
|
+
$error = new \Illuminate\Queue\MaxAttemptsExceededException('Telemetry polling retry deadline exceeded.');
|
|
71
|
+
if ($this->job) {
|
|
72
|
+
$this->fail($error);
|
|
73
|
+
} else {
|
|
74
|
+
$this->failed($error);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
46
79
|
$telematic = Telematic::withoutGlobalScopes()->where('uuid', $this->telematicUuid)->first();
|
|
47
80
|
if (!$telematic || !Inbox::enabled($telematic)) {
|
|
48
81
|
return;
|
|
@@ -57,25 +90,47 @@ class PollTelematicTelemetry implements ShouldQueue, ShouldBeUnique
|
|
|
57
90
|
}
|
|
58
91
|
// Coalesce ticks while a previous sweep is still being ingested.
|
|
59
92
|
if (DB::table('telematic_deliveries')->where('telematic_uuid', $this->telematicUuid)->where('source', 'poll')->whereIn('status', ['pending', 'retry', 'processing'])->exists()) {
|
|
93
|
+
if ($this->manualJobId && $this->job) {
|
|
94
|
+
$this->manualProgress(['last_sync_result' => 'retrying', 'last_sync_phase' => 'waiting_for_ingestion']);
|
|
95
|
+
$this->release(15);
|
|
96
|
+
}
|
|
97
|
+
|
|
60
98
|
return;
|
|
61
99
|
}
|
|
62
|
-
$lock = Cache::lock('telemetry:poll:' . $this->telematicUuid,
|
|
100
|
+
$lock = Cache::lock('telemetry:poll:' . $this->telematicUuid, 85);
|
|
63
101
|
if (!$lock->get()) {
|
|
102
|
+
if ($this->manualJobId && $this->job) {
|
|
103
|
+
$this->release(15);
|
|
104
|
+
}
|
|
105
|
+
|
|
64
106
|
return;
|
|
65
107
|
}
|
|
66
108
|
$run = (string) Str::uuid();
|
|
67
|
-
DB::table('telematic_sync_runs')->insert(['uuid' => $run, 'telematic_uuid' => $telematic->uuid, 'status' => 'fetching', 'created_at' => now(), 'updated_at' => now()]);
|
|
68
109
|
try {
|
|
110
|
+
$started = microtime(true);
|
|
111
|
+
DB::table('telematic_sync_runs')->insert(['uuid' => $run, 'telematic_uuid' => $telematic->uuid, 'status' => 'fetching', 'created_at' => now(), 'updated_at' => now()]);
|
|
112
|
+
$this->manualProgress([
|
|
113
|
+
'last_sync_run_uuid' => $run, 'last_sync_run_job_id' => $this->manualJobId,
|
|
114
|
+
'last_sync_result' => 'running', 'last_sync_phase' => 'fetching_inventory',
|
|
115
|
+
'last_sync_fetched_total' => 0, 'last_sync_page_count' => 0,
|
|
116
|
+
'last_sync_linked_total' => 0, 'last_sync_failed_total' => 0, 'last_sync_error' => null,
|
|
117
|
+
]);
|
|
69
118
|
$provider->connect($telematic);
|
|
70
119
|
$cursor = null;
|
|
71
120
|
$cursors = [];
|
|
72
121
|
$seen = [];
|
|
73
|
-
$started = microtime(true);
|
|
74
122
|
for ($page = 0; $page < ($options['max_pages'] ?? 100); $page++) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
$
|
|
123
|
+
$remaining = $this->remainingBudget($started);
|
|
124
|
+
// Large fleet responses can exceed 20 seconds. Allow a configured
|
|
125
|
+
// request timeout without extending the bounded sweep deadline.
|
|
126
|
+
$timeout = min(max(1, (int) ($options['request_timeout_seconds'] ?? 45)), $remaining);
|
|
127
|
+
$response = $provider->fetchDevices([
|
|
128
|
+
'limit' => min(max(1, (int) ($this->requestOptions['limit'] ?? $options['page_size'] ?? 1000)), 1000),
|
|
129
|
+
'cursor' => $cursor, 'filters' => $this->requestOptions['filters'] ?? [],
|
|
130
|
+
'refresh_inventory' => $this->manualJobId !== null && $cursor === null,
|
|
131
|
+
'timeout' => $timeout, 'connect_timeout' => min(5, $timeout),
|
|
132
|
+
]);
|
|
133
|
+
$this->remainingBudget($started);
|
|
79
134
|
$received = now()->utc();
|
|
80
135
|
$units = $response['devices'];
|
|
81
136
|
$ids = array_map(fn ($unit) => $provider->normalizeDevice($unit)['device_id'] ?? null, $units);
|
|
@@ -86,11 +141,15 @@ class PollTelematicTelemetry implements ShouldQueue, ShouldBeUnique
|
|
|
86
141
|
$seen[(string) $id] = true;
|
|
87
142
|
}
|
|
88
143
|
foreach (array_chunk($units, max(1, (int) ($options['batch_size'] ?? 100))) as $batch) {
|
|
144
|
+
$this->remainingBudget($started);
|
|
89
145
|
$inbox->accept($telematic, $batch, 'poll', $run, $received->toDateTimeString());
|
|
90
146
|
}
|
|
147
|
+
$this->remainingBudget($started);
|
|
91
148
|
DB::table('telematic_sync_runs')->where('uuid', $run)->update(['pages' => $page + 1, 'units' => count($seen), 'updated_at' => now()]);
|
|
149
|
+
$this->manualProgress(['last_sync_fetched_total' => count($seen), 'last_sync_page_count' => $page + 1, 'last_sync_phase' => 'fetching_inventory']);
|
|
92
150
|
if (!$response['has_more']) {
|
|
93
151
|
DB::table('telematic_sync_runs')->where('uuid', $run)->update(['status' => 'ingesting', 'updated_at' => now()]);
|
|
152
|
+
$this->manualProgress(['last_sync_phase' => 'ingesting', 'last_sync_inventory_total' => count($seen)]);
|
|
94
153
|
Inbox::finishRun($run);
|
|
95
154
|
|
|
96
155
|
return;
|
|
@@ -105,6 +164,7 @@ class PollTelematicTelemetry implements ShouldQueue, ShouldBeUnique
|
|
|
105
164
|
throw new \RuntimeException('Maximum page count reached; sweep incomplete.');
|
|
106
165
|
} catch (\Throwable $e) {
|
|
107
166
|
DB::table('telematic_sync_runs')->where('uuid', $run)->update(['status' => 'incomplete', 'error' => $e instanceof TelematicRateLimitExceededException ? 'Rate limited; retry scheduled.' : 'Polling failed; retry scheduled. ' . class_basename($e), 'updated_at' => now()]);
|
|
167
|
+
$this->manualProgress(['last_sync_result' => 'retrying', 'last_sync_phase' => 'retrying', 'last_sync_error' => 'Polling failed; retry scheduled. ' . class_basename($e)]);
|
|
108
168
|
if ($e instanceof TelematicRateLimitExceededException && $this->job) {
|
|
109
169
|
$this->release($e->context()['retry_after'] ?? 60);
|
|
110
170
|
|
|
@@ -115,4 +175,59 @@ class PollTelematicTelemetry implements ShouldQueue, ShouldBeUnique
|
|
|
115
175
|
$lock->release();
|
|
116
176
|
}
|
|
117
177
|
}
|
|
178
|
+
|
|
179
|
+
private function remainingBudget(float $started): int
|
|
180
|
+
{
|
|
181
|
+
$remaining = (int) floor(60 - (microtime(true) - $started));
|
|
182
|
+
if ($remaining < 1) {
|
|
183
|
+
throw new \RuntimeException('Polling time budget exceeded; sweep incomplete.');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return $remaining;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private function manualProgress(array $attributes): void
|
|
190
|
+
{
|
|
191
|
+
if (!$this->manualJobId) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
DB::transaction(function () use ($attributes) {
|
|
195
|
+
$connection = Telematic::withoutGlobalScopes()->where('uuid', $this->telematicUuid)->lockForUpdate()->first();
|
|
196
|
+
if (!$connection || !Inbox::enabled($connection) || data_get($connection->meta, 'last_sync_job_id') !== $this->manualJobId) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
$connection->status = 'synchronizing';
|
|
200
|
+
$connection->meta = array_merge($connection->meta ?? [], $attributes, ['last_sync_progress_at' => now()->toDateTimeString()]);
|
|
201
|
+
$connection->save();
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
public function failed(\Throwable $error): void
|
|
206
|
+
{
|
|
207
|
+
if (!$this->manualJobId) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
$run = null;
|
|
211
|
+
DB::transaction(function () use ($error, &$run) {
|
|
212
|
+
$connection = Telematic::withoutGlobalScopes()->where('uuid', $this->telematicUuid)->lockForUpdate()->first();
|
|
213
|
+
if (!$connection || !Inbox::enabled($connection) || data_get($connection->meta, 'last_sync_job_id') !== $this->manualJobId) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (data_get($connection->meta, 'last_sync_run_job_id') === $this->manualJobId) {
|
|
217
|
+
$run = data_get($connection->meta, 'last_sync_run_uuid');
|
|
218
|
+
}
|
|
219
|
+
$connection->status = 'error';
|
|
220
|
+
$connection->meta = array_merge($connection->meta ?? [], [
|
|
221
|
+
'last_sync_result' => 'failed', 'last_sync_phase' => 'failed',
|
|
222
|
+
'last_sync_error' => 'Polling retries exhausted. ' . class_basename($error),
|
|
223
|
+
'last_sync_error_type' => class_basename($error), 'last_sync_failed_at' => now()->toDateTimeString(),
|
|
224
|
+
]);
|
|
225
|
+
$connection->save();
|
|
226
|
+
});
|
|
227
|
+
// Do not lock run rows while holding the connection lock: finishRun takes
|
|
228
|
+
// those locks in the opposite order when the final delivery completes.
|
|
229
|
+
if ($run) {
|
|
230
|
+
DB::table('telematic_sync_runs')->where('uuid', $run)->where('status', 'fetching')->update(['status' => 'incomplete', 'error' => 'Polling attempt interrupted.', 'updated_at' => now()]);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
118
233
|
}
|