@fleetbase/fleetops-engine 0.6.66 → 0.6.68
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 +9 -8
- package/addon/components/fuel-integration/form.hbs +2 -2
- package/addon/components/fuel-integration/form.js +8 -0
- package/addon/components/fuel-integration/hub.js +1 -1
- 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/addon/templates/connectivity/fuel-providers/details/index.hbs +1 -1
- package/addon/utils/fuel-transaction.js +2 -0
- package/composer.json +2 -1
- package/docs/AFAQY.md +71 -17
- package/docs/SAFEE.md +130 -0
- package/docs/TELEMATICS_QUEUES.md +230 -0
- package/extension.json +1 -1
- package/package.json +1 -1
- package/server/config/fuel-providers.php +53 -0
- 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 +163 -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/FuelProviders/Providers/SascoFuelProvider.php +272 -0
- 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 +45 -1
- package/server/src/Support/Telematics/Telemetry/Configuration.php +16 -0
- package/server/src/Support/Telematics/Telemetry/Inbox.php +49 -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 +545 -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/SascoFuelProviderTest.php +338 -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
|
@@ -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,48 @@ 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->adoptPendingRequest($run);
|
|
113
|
+
$this->manualProgress([
|
|
114
|
+
'last_sync_run_uuid' => $run, 'last_sync_run_job_id' => $this->manualJobId,
|
|
115
|
+
'last_sync_result' => 'running', 'last_sync_phase' => 'fetching_inventory',
|
|
116
|
+
'last_sync_fetched_total' => 0, 'last_sync_page_count' => 0,
|
|
117
|
+
'last_sync_linked_total' => 0, 'last_sync_failed_total' => 0, 'last_sync_error' => null,
|
|
118
|
+
]);
|
|
69
119
|
$provider->connect($telematic);
|
|
70
120
|
$cursor = null;
|
|
71
121
|
$cursors = [];
|
|
72
122
|
$seen = [];
|
|
73
|
-
$started = microtime(true);
|
|
74
123
|
for ($page = 0; $page < ($options['max_pages'] ?? 100); $page++) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
$
|
|
124
|
+
$remaining = $this->remainingBudget($started);
|
|
125
|
+
// Large fleet responses can exceed 20 seconds. Allow a configured
|
|
126
|
+
// request timeout without extending the bounded sweep deadline.
|
|
127
|
+
$timeout = min(max(1, (int) ($options['request_timeout_seconds'] ?? 45)), $remaining);
|
|
128
|
+
$response = $provider->fetchDevices([
|
|
129
|
+
'limit' => min(max(1, (int) ($this->requestOptions['limit'] ?? $options['page_size'] ?? 1000)), 1000),
|
|
130
|
+
'cursor' => $cursor, 'filters' => $this->requestOptions['filters'] ?? [],
|
|
131
|
+
'refresh_inventory' => $this->manualJobId !== null && $cursor === null,
|
|
132
|
+
'timeout' => $timeout, 'connect_timeout' => min(5, $timeout),
|
|
133
|
+
]);
|
|
134
|
+
$this->remainingBudget($started);
|
|
79
135
|
$received = now()->utc();
|
|
80
136
|
$units = $response['devices'];
|
|
81
137
|
$ids = array_map(fn ($unit) => $provider->normalizeDevice($unit)['device_id'] ?? null, $units);
|
|
@@ -86,11 +142,15 @@ class PollTelematicTelemetry implements ShouldQueue, ShouldBeUnique
|
|
|
86
142
|
$seen[(string) $id] = true;
|
|
87
143
|
}
|
|
88
144
|
foreach (array_chunk($units, max(1, (int) ($options['batch_size'] ?? 100))) as $batch) {
|
|
145
|
+
$this->remainingBudget($started);
|
|
89
146
|
$inbox->accept($telematic, $batch, 'poll', $run, $received->toDateTimeString());
|
|
90
147
|
}
|
|
148
|
+
$this->remainingBudget($started);
|
|
91
149
|
DB::table('telematic_sync_runs')->where('uuid', $run)->update(['pages' => $page + 1, 'units' => count($seen), 'updated_at' => now()]);
|
|
150
|
+
$this->manualProgress(['last_sync_fetched_total' => count($seen), 'last_sync_page_count' => $page + 1, 'last_sync_phase' => 'fetching_inventory']);
|
|
92
151
|
if (!$response['has_more']) {
|
|
93
152
|
DB::table('telematic_sync_runs')->where('uuid', $run)->update(['status' => 'ingesting', 'updated_at' => now()]);
|
|
153
|
+
$this->manualProgress(['last_sync_phase' => 'ingesting', 'last_sync_inventory_total' => count($seen)]);
|
|
94
154
|
Inbox::finishRun($run);
|
|
95
155
|
|
|
96
156
|
return;
|
|
@@ -105,6 +165,7 @@ class PollTelematicTelemetry implements ShouldQueue, ShouldBeUnique
|
|
|
105
165
|
throw new \RuntimeException('Maximum page count reached; sweep incomplete.');
|
|
106
166
|
} catch (\Throwable $e) {
|
|
107
167
|
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()]);
|
|
168
|
+
$this->manualProgress(['last_sync_result' => 'retrying', 'last_sync_phase' => 'retrying', 'last_sync_error' => 'Polling failed; retry scheduled. ' . class_basename($e)]);
|
|
108
169
|
if ($e instanceof TelematicRateLimitExceededException && $this->job) {
|
|
109
170
|
$this->release($e->context()['retry_after'] ?? 60);
|
|
110
171
|
|
|
@@ -115,4 +176,94 @@ class PollTelematicTelemetry implements ShouldQueue, ShouldBeUnique
|
|
|
115
176
|
$lock->release();
|
|
116
177
|
}
|
|
117
178
|
}
|
|
179
|
+
|
|
180
|
+
private function remainingBudget(float $started): int
|
|
181
|
+
{
|
|
182
|
+
$remaining = (int) floor(60 - (microtime(true) - $started));
|
|
183
|
+
if ($remaining < 1) {
|
|
184
|
+
throw new \RuntimeException('Polling time budget exceeded; sweep incomplete.');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return $remaining;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Bind a manual request that has no active run to this scheduled sweep.
|
|
192
|
+
*
|
|
193
|
+
* Every poll job for a connection shares one unique lease, so while a scheduled sweep
|
|
194
|
+
* runs, the request's own job is either not queued (the request was recorded while
|
|
195
|
+
* the lease was held) or no longer exists (lost before or after an interrupted run).
|
|
196
|
+
*/
|
|
197
|
+
private function adoptPendingRequest(string $run): void
|
|
198
|
+
{
|
|
199
|
+
if ($this->manualJobId) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
DB::transaction(function () use ($run) {
|
|
203
|
+
$connection = Telematic::withoutGlobalScopes()->where('uuid', $this->telematicUuid)->lockForUpdate()->first();
|
|
204
|
+
$meta = $connection?->meta ?? [];
|
|
205
|
+
$request = data_get($meta, 'last_sync_job_id');
|
|
206
|
+
if (!$request || !in_array(data_get($meta, 'last_sync_result'), Inbox::PENDING_REQUEST_RESULTS, true)) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
$bound = data_get($meta, 'last_sync_run_uuid');
|
|
210
|
+
if ($bound && DB::table('telematic_sync_runs')->where('uuid', $bound)->whereIn('status', ['fetching', 'ingesting'])->exists()) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
$connection->status = 'synchronizing';
|
|
214
|
+
$connection->meta = array_merge($meta, [
|
|
215
|
+
'last_sync_run_uuid' => $run, 'last_sync_run_job_id' => $request,
|
|
216
|
+
'last_sync_result' => 'running', 'last_sync_phase' => 'fetching_inventory',
|
|
217
|
+
'last_sync_fetched_total' => 0, 'last_sync_page_count' => 0,
|
|
218
|
+
'last_sync_linked_total' => 0, 'last_sync_failed_total' => 0, 'last_sync_error' => null,
|
|
219
|
+
'last_sync_progress_at' => now()->toDateTimeString(),
|
|
220
|
+
]);
|
|
221
|
+
$connection->save();
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private function manualProgress(array $attributes): void
|
|
226
|
+
{
|
|
227
|
+
if (!$this->manualJobId) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
DB::transaction(function () use ($attributes) {
|
|
231
|
+
$connection = Telematic::withoutGlobalScopes()->where('uuid', $this->telematicUuid)->lockForUpdate()->first();
|
|
232
|
+
if (!$connection || !Inbox::enabled($connection) || data_get($connection->meta, 'last_sync_job_id') !== $this->manualJobId) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
$connection->status = 'synchronizing';
|
|
236
|
+
$connection->meta = array_merge($connection->meta ?? [], $attributes, ['last_sync_progress_at' => now()->toDateTimeString()]);
|
|
237
|
+
$connection->save();
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
public function failed(\Throwable $error): void
|
|
242
|
+
{
|
|
243
|
+
if (!$this->manualJobId) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
$run = null;
|
|
247
|
+
DB::transaction(function () use ($error, &$run) {
|
|
248
|
+
$connection = Telematic::withoutGlobalScopes()->where('uuid', $this->telematicUuid)->lockForUpdate()->first();
|
|
249
|
+
if (!$connection || !Inbox::enabled($connection) || data_get($connection->meta, 'last_sync_job_id') !== $this->manualJobId) {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (data_get($connection->meta, 'last_sync_run_job_id') === $this->manualJobId) {
|
|
253
|
+
$run = data_get($connection->meta, 'last_sync_run_uuid');
|
|
254
|
+
}
|
|
255
|
+
$connection->status = 'error';
|
|
256
|
+
$connection->meta = array_merge($connection->meta ?? [], [
|
|
257
|
+
'last_sync_result' => 'failed', 'last_sync_phase' => 'failed',
|
|
258
|
+
'last_sync_error' => 'Polling retries exhausted. ' . class_basename($error),
|
|
259
|
+
'last_sync_error_type' => class_basename($error), 'last_sync_failed_at' => now()->toDateTimeString(),
|
|
260
|
+
]);
|
|
261
|
+
$connection->save();
|
|
262
|
+
});
|
|
263
|
+
// Do not lock run rows while holding the connection lock: finishRun takes
|
|
264
|
+
// those locks in the opposite order when the final delivery completes.
|
|
265
|
+
if ($run) {
|
|
266
|
+
DB::table('telematic_sync_runs')->where('uuid', $run)->where('status', 'fetching')->update(['status' => 'incomplete', 'error' => 'Polling attempt interrupted.', 'updated_at' => now()]);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
118
269
|
}
|
|
@@ -38,7 +38,8 @@ class ProcessTelematicDelivery implements ShouldQueue, ShouldBeUnique
|
|
|
38
38
|
|
|
39
39
|
public function handle(Ingestor $ingestor, TelematicService $service): void
|
|
40
40
|
{
|
|
41
|
-
$
|
|
41
|
+
$started = microtime(true);
|
|
42
|
+
$lock = Cache::lock('telemetry:delivery:' . $this->deliveryUuid, 90);
|
|
42
43
|
if (!$lock->get()) {
|
|
43
44
|
return;
|
|
44
45
|
}
|
|
@@ -66,21 +67,48 @@ class ProcessTelematicDelivery implements ShouldQueue, ShouldBeUnique
|
|
|
66
67
|
return;
|
|
67
68
|
}
|
|
68
69
|
$this->update(['status' => 'processing', 'attempts' => $row->attempts + 1, 'available_at' => now()->addSeconds(120)]);
|
|
70
|
+
$failed = [];
|
|
71
|
+
$failureTypes = [];
|
|
69
72
|
try {
|
|
70
|
-
$
|
|
73
|
+
$payload = json_decode(Crypt::decryptString($row->retry_payload ?? $row->payload), true, 512, JSON_THROW_ON_ERROR);
|
|
74
|
+
if ($row->retry_payload && ($payload['checkpoint_version'] ?? null) === 1) {
|
|
75
|
+
// This envelope is internal and encrypted; the original webhook
|
|
76
|
+
// contract was already validated before creating the checkpoint.
|
|
77
|
+
$units = $payload['remaining'] ?? null;
|
|
78
|
+
$failed = $payload['failed'] ?? null;
|
|
79
|
+
$failureTypes = $payload['failure_types'] ?? null;
|
|
80
|
+
foreach ([$units, $failed, $failureTypes] as $items) {
|
|
81
|
+
if (!is_array($items) || !array_is_list($items)) {
|
|
82
|
+
throw new \InvalidArgumentException('Invalid delivery checkpoint.');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} elseif ($row->source === 'poll') {
|
|
86
|
+
// Polling persists the provider's inventory records, including units
|
|
87
|
+
// that have never reported a position. Validate this internal envelope
|
|
88
|
+
// here and let ingestion report invalid positions per unit below.
|
|
89
|
+
if (!is_array($payload) || !array_is_list($payload) || $payload === []) {
|
|
90
|
+
throw new \InvalidArgumentException('Expected a non-empty polling unit list.');
|
|
91
|
+
}
|
|
92
|
+
$units = $payload;
|
|
93
|
+
} else {
|
|
94
|
+
// External deliveries must still pass the provider's position contract.
|
|
95
|
+
$units = $provider->telemetryUnits($payload);
|
|
96
|
+
}
|
|
71
97
|
} catch (\Throwable) {
|
|
72
98
|
$this->update(['status' => 'quarantined', 'failed' => 1, 'error' => 'Unsupported or unreadable position payload; inspect and replay after adapter correction.']);
|
|
73
99
|
Inbox::finishRun($row->run_uuid);
|
|
74
100
|
|
|
75
101
|
return;
|
|
76
102
|
}
|
|
77
|
-
$failed = [];
|
|
78
|
-
$failureTypes = [];
|
|
79
103
|
$applied = 0;
|
|
80
104
|
$invalid = (int) $row->invalid_count;
|
|
81
|
-
|
|
105
|
+
// Polled inventory legitimately includes units without a valid fix (for example,
|
|
106
|
+
// never reported). They stay counted, but only pushed samples are failures to replay.
|
|
107
|
+
$pushed = $row->source !== 'poll';
|
|
108
|
+
$sourceDelay = $row->source_delay_seconds;
|
|
82
109
|
$queueDelay = max(0, now()->timestamp - \Illuminate\Support\Carbon::parse($row->received_at, 'UTC')->timestamp);
|
|
83
|
-
|
|
110
|
+
$checkpointAt = $started;
|
|
111
|
+
foreach ($units as $index => $unit) {
|
|
84
112
|
try {
|
|
85
113
|
$result = $ingestor->ingest($telematic, $provider, $unit, $service, $row->received_at, $row->source);
|
|
86
114
|
$sourceDelay = max($sourceDelay ?? 0, data_get($result['device']->meta, 'telemetry.source_delay_seconds', 0));
|
|
@@ -93,19 +121,45 @@ class ProcessTelematicDelivery implements ShouldQueue, ShouldBeUnique
|
|
|
93
121
|
$failed[] = $unit;
|
|
94
122
|
$failureTypes[] = class_basename($e);
|
|
95
123
|
}
|
|
124
|
+
$elapsed = microtime(true);
|
|
125
|
+
$yield = $elapsed - $started >= 40 && $index + 1 < count($units);
|
|
126
|
+
if ($yield || ($index + 1) % 10 === 0 || $elapsed - $checkpointAt >= 2) {
|
|
127
|
+
// Persist progress independently of the worker reservation. A
|
|
128
|
+
// restart resumes the remaining tail rather than replaying a
|
|
129
|
+
// growing prefix until the delivery exhausts its retry budget.
|
|
130
|
+
$this->update([
|
|
131
|
+
'retry_payload' => Crypt::encryptString(json_encode([
|
|
132
|
+
'checkpoint_version' => 1, 'remaining' => array_slice($units, $index + 1),
|
|
133
|
+
'failed' => $failed, 'failure_types' => array_values(array_unique($failureTypes)),
|
|
134
|
+
], JSON_THROW_ON_ERROR)),
|
|
135
|
+
'applied' => $row->applied + $applied, 'failed' => count($failed) + ($pushed ? $invalid : 0), 'invalid_count' => $invalid,
|
|
136
|
+
'queue_delay_seconds' => $queueDelay, 'source_delay_seconds' => $sourceDelay,
|
|
137
|
+
'status' => $yield ? 'retry' : 'processing',
|
|
138
|
+
// Cooperative continuation is not a failed attempt. Actual
|
|
139
|
+
// crashes keep the increment persisted at the start above.
|
|
140
|
+
'attempts' => $yield ? $row->attempts : $row->attempts + 1,
|
|
141
|
+
'available_at' => $yield ? now() : now()->addSeconds(120),
|
|
142
|
+
]);
|
|
143
|
+
$checkpointAt = $elapsed;
|
|
144
|
+
}
|
|
145
|
+
if ($yield) {
|
|
146
|
+
// The normal inbox drain queues the continuation after this
|
|
147
|
+
// job releases its uniqueness lease; it uses the same queue.
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
96
150
|
}
|
|
97
151
|
$this->update(['queue_delay_seconds' => $queueDelay, 'source_delay_seconds' => $sourceDelay]);
|
|
98
152
|
if ($failed && $row->attempts < 4) {
|
|
99
153
|
$this->update([
|
|
100
154
|
'status' => 'retry', 'retry_payload' => Crypt::encryptString(json_encode($failed, JSON_THROW_ON_ERROR)),
|
|
101
|
-
'applied' => $row->applied + $applied, 'failed' => count($failed) + $invalid, 'invalid_count' => $invalid,
|
|
155
|
+
'applied' => $row->applied + $applied, 'failed' => count($failed) + ($pushed ? $invalid : 0), 'invalid_count' => $invalid,
|
|
102
156
|
'error' => 'Some units failed ingestion; retry scheduled. ' . implode(', ', array_unique($failureTypes)), 'available_at' => now()->addSeconds(15 * (2 ** $row->attempts)),
|
|
103
157
|
]);
|
|
104
158
|
} else {
|
|
105
159
|
$this->update([
|
|
106
|
-
'status' => ($failed || $invalid) ? 'quarantined' : 'processed',
|
|
107
|
-
'applied' => $row->applied + $applied, 'failed' => count($failed) + $invalid, 'invalid_count' => $invalid,
|
|
108
|
-
'error' => ($failed || $invalid) ? 'Invalid positions or exhausted ingestion retries; inspect and replay.' : null,
|
|
160
|
+
'status' => ($failed || ($pushed ? $invalid : 0)) ? 'quarantined' : 'processed',
|
|
161
|
+
'applied' => $row->applied + $applied, 'failed' => count($failed) + ($pushed ? $invalid : 0), 'invalid_count' => $invalid,
|
|
162
|
+
'error' => ($failed || ($pushed ? $invalid : 0)) ? 'Invalid positions or exhausted ingestion retries; inspect and replay.' : null,
|
|
109
163
|
'processed_at' => now(),
|
|
110
164
|
]);
|
|
111
165
|
}
|
|
@@ -40,6 +40,7 @@ class SyncTelematicDevicesJob implements ShouldQueue
|
|
|
40
40
|
$this->telematic = $telematic;
|
|
41
41
|
$this->options = $options;
|
|
42
42
|
$this->jobId = $jobId ?? \Illuminate\Support\Str::uuid()->toString();
|
|
43
|
+
$this->onQueue(\Fleetbase\FleetOps\Support\Telematics\Telemetry\Configuration::pollQueue());
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
/**
|
|
@@ -47,6 +48,24 @@ class SyncTelematicDevicesJob implements ShouldQueue
|
|
|
47
48
|
*/
|
|
48
49
|
public function handle(TelematicProviderRegistry $registry, TelematicService $service): void
|
|
49
50
|
{
|
|
51
|
+
// Jobs queued before a provider opts into batching must use the same bounded
|
|
52
|
+
// path as new manual requests, without acquiring the legacy hour-long lock.
|
|
53
|
+
$provider = $registry->resolve($this->telematic->provider);
|
|
54
|
+
if ($provider instanceof \Fleetbase\FleetOps\Contracts\TelemetryProviderInterface
|
|
55
|
+
&& (\Fleetbase\FleetOps\Support\Telematics\Telemetry\Configuration::options($provider)['manual_batch_sync'] ?? false)) {
|
|
56
|
+
$this->telematic->refresh();
|
|
57
|
+
if (\Fleetbase\FleetOps\Support\Telematics\Telemetry\Inbox::enabled($this->telematic)) {
|
|
58
|
+
try {
|
|
59
|
+
$service->queueTelemetrySync($this->telematic, $this->options, $this->jobId);
|
|
60
|
+
} catch (\Illuminate\Validation\ValidationException) {
|
|
61
|
+
// Polling was paused after this job was queued. Failing here would mark the
|
|
62
|
+
// connection as errored and detach any active request's progress tracking.
|
|
63
|
+
Log::info('Skipped legacy telematics sync redirect.', ['telematic_uuid' => $this->telematic->uuid]);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
50
69
|
$correlationId = \Illuminate\Support\Str::uuid()->toString();
|
|
51
70
|
$lockKey = 'fleetops:sync-telematic-devices:' . $this->telematic->uuid;
|
|
52
71
|
$lock = Cache::lock($lockKey, $this->timeout + 60);
|
|
@@ -96,12 +115,54 @@ class SyncTelematicDevicesJob implements ShouldQueue
|
|
|
96
115
|
$totalEnrichment = 0;
|
|
97
116
|
$totalEnrichmentCompleted = 0;
|
|
98
117
|
$totalEnrichmentFailures = 0;
|
|
118
|
+
$itemsSinceCheckpoint = 0;
|
|
119
|
+
$lastCheckpointAt = now();
|
|
120
|
+
|
|
121
|
+
$checkpoint = function (string $phase, bool $force = false) use (
|
|
122
|
+
&$itemsSinceCheckpoint, &$lastCheckpointAt, &$providerSyncMeta, &$totalFetched,
|
|
123
|
+
&$totalLinked, &$totalLinkAttempts, &$totalEvents, &$totalSensors, &$totalSkipped,
|
|
124
|
+
&$pageCount, &$lastProviderAllCount, &$lastProviderFiltersCount,
|
|
125
|
+
&$inventoryFetched, &$inventoryLinked, &$inventorySkipped,
|
|
126
|
+
&$totalEnrichment, &$totalEnrichmentCompleted, &$totalEnrichmentFailures
|
|
127
|
+
): void {
|
|
128
|
+
if (!$force && $itemsSinceCheckpoint < 25 && $lastCheckpointAt->diffInSeconds(now(), true) < 5) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
$this->telematic->status = 'synchronizing';
|
|
132
|
+
$this->telematic->meta = array_merge($this->telematic->meta ?? [], $providerSyncMeta, [
|
|
133
|
+
'last_sync_job_id' => $this->jobId,
|
|
134
|
+
'last_sync_result' => 'running',
|
|
135
|
+
'last_sync_phase' => $phase,
|
|
136
|
+
'last_sync_progress_at' => now()->toDateTimeString(),
|
|
137
|
+
'last_sync_fetched_total' => $totalFetched,
|
|
138
|
+
'last_sync_linked_total' => $totalLinked,
|
|
139
|
+
'last_sync_link_attempts_total' => $totalLinkAttempts,
|
|
140
|
+
'last_sync_events_total' => $totalEvents,
|
|
141
|
+
'last_sync_sensors_total' => $totalSensors,
|
|
142
|
+
'last_sync_skipped_total' => $totalSkipped,
|
|
143
|
+
'last_sync_page_count' => $pageCount,
|
|
144
|
+
'last_sync_provider_total' => $lastProviderFiltersCount ?? $lastProviderAllCount,
|
|
145
|
+
'last_sync_provider_all_count' => $lastProviderAllCount,
|
|
146
|
+
'last_sync_provider_filters_count' => $lastProviderFiltersCount,
|
|
147
|
+
'last_sync_inventory_total' => $inventoryFetched,
|
|
148
|
+
'last_sync_inventory_linked_total' => $inventoryLinked,
|
|
149
|
+
'last_sync_inventory_skipped_total' => $inventorySkipped,
|
|
150
|
+
'last_sync_enrichment_total' => $totalEnrichment,
|
|
151
|
+
'last_sync_enrichment_completed' => $totalEnrichmentCompleted,
|
|
152
|
+
'last_sync_enrichment_failures' => $totalEnrichmentFailures,
|
|
153
|
+
]);
|
|
154
|
+
$this->telematic->save();
|
|
155
|
+
$itemsSinceCheckpoint = 0;
|
|
156
|
+
$lastCheckpointAt = now();
|
|
157
|
+
};
|
|
99
158
|
|
|
100
159
|
try {
|
|
160
|
+
$checkpoint('connecting', true);
|
|
101
161
|
$provider = $registry->resolve($this->telematic->provider);
|
|
102
162
|
$provider->connect($this->telematic);
|
|
103
163
|
|
|
104
164
|
do {
|
|
165
|
+
$checkpoint('fetching_inventory', true);
|
|
105
166
|
$response = $provider->fetchDevices([
|
|
106
167
|
'limit' => $this->options['limit'] ?? null,
|
|
107
168
|
'cursor' => $cursor,
|
|
@@ -137,6 +198,9 @@ class SyncTelematicDevicesJob implements ShouldQueue
|
|
|
137
198
|
'has_more' => $response['has_more'] ?? false,
|
|
138
199
|
]);
|
|
139
200
|
|
|
201
|
+
// Persist retrieval before any potentially slow per-device database work.
|
|
202
|
+
$inventoryFetched = $totalFetched;
|
|
203
|
+
$checkpoint('ingesting_inventory', true);
|
|
140
204
|
foreach ($devices as $devicePayload) {
|
|
141
205
|
$normalizedDevice = $provider->normalizeDevice($devicePayload);
|
|
142
206
|
try {
|
|
@@ -162,7 +226,12 @@ class SyncTelematicDevicesJob implements ShouldQueue
|
|
|
162
226
|
'imei' => $devicePayload['imei'] ?? null,
|
|
163
227
|
]);
|
|
164
228
|
}
|
|
229
|
+
$inventoryLinked = $totalLinked;
|
|
230
|
+
$inventorySkipped = $totalSkipped;
|
|
231
|
+
$itemsSinceCheckpoint++;
|
|
232
|
+
$checkpoint('ingesting_inventory');
|
|
165
233
|
}
|
|
234
|
+
$checkpoint('ingesting_inventory', true);
|
|
166
235
|
|
|
167
236
|
$cursor = $response['next_cursor'] ?? null;
|
|
168
237
|
|
|
@@ -221,6 +290,7 @@ class SyncTelematicDevicesJob implements ShouldQueue
|
|
|
221
290
|
'device_count' => count($inventoryPayloads),
|
|
222
291
|
]);
|
|
223
292
|
|
|
293
|
+
$checkpoint('fetching_enrichment', true);
|
|
224
294
|
$enrichmentResponse = $provider->fetchDeviceTelemetrySnapshots($inventoryPayloads, [
|
|
225
295
|
'limit' => $this->options['limit'] ?? null,
|
|
226
296
|
'filters' => $this->options['filters'] ?? [],
|
|
@@ -229,6 +299,7 @@ class SyncTelematicDevicesJob implements ShouldQueue
|
|
|
229
299
|
$enrichedDevices = $enrichmentResponse['devices'] ?? [];
|
|
230
300
|
$totalEnrichment += count($enrichedDevices);
|
|
231
301
|
|
|
302
|
+
$checkpoint('ingesting_enrichment', true);
|
|
232
303
|
foreach ($enrichedDevices as $devicePayload) {
|
|
233
304
|
$normalizedDevice = $provider->normalizeDevice($devicePayload);
|
|
234
305
|
try {
|
|
@@ -256,7 +327,10 @@ class SyncTelematicDevicesJob implements ShouldQueue
|
|
|
256
327
|
'imei' => $devicePayload['imei'] ?? null,
|
|
257
328
|
]);
|
|
258
329
|
}
|
|
330
|
+
$itemsSinceCheckpoint++;
|
|
331
|
+
$checkpoint('ingesting_enrichment');
|
|
259
332
|
}
|
|
333
|
+
$checkpoint('ingesting_enrichment', true);
|
|
260
334
|
|
|
261
335
|
Log::info($this->telematic->provider === 'safee' ? 'Safee telemetry enrichment completed' : 'Telematics telemetry enrichment completed', [
|
|
262
336
|
'correlation_id' => $correlationId,
|
|
@@ -289,6 +363,8 @@ class SyncTelematicDevicesJob implements ShouldQueue
|
|
|
289
363
|
'last_sync_job_id' => $this->jobId,
|
|
290
364
|
'last_sync_completed_at' => now()->toDateTimeString(),
|
|
291
365
|
'last_sync_result' => 'success',
|
|
366
|
+
'last_sync_phase' => 'completed',
|
|
367
|
+
'last_sync_progress_at' => now()->toDateTimeString(),
|
|
292
368
|
'last_sync_total' => $totalLinked,
|
|
293
369
|
'last_sync_fetched_total' => $totalFetched,
|
|
294
370
|
'last_sync_linked_total' => $totalLinked,
|
|
@@ -310,7 +386,7 @@ class SyncTelematicDevicesJob implements ShouldQueue
|
|
|
310
386
|
'last_sync_error_context' => null,
|
|
311
387
|
]);
|
|
312
388
|
$this->telematic->save();
|
|
313
|
-
} catch (\
|
|
389
|
+
} catch (\Throwable $e) {
|
|
314
390
|
$failureContext = method_exists($e, 'context') ? $e->context() : [];
|
|
315
391
|
$failureMessage = $this->safeSyncErrorMessage($e);
|
|
316
392
|
|
|
@@ -149,8 +149,10 @@ class FleetOpsServiceProvider extends CoreServiceProvider
|
|
|
149
149
|
$schedule->command('fleetops:process-maintenance-triggers')->daily()->withoutOverlapping()->storeOutputInDb();
|
|
150
150
|
$schedule->command('fleetops:send-maintenance-reminders')->daily()->withoutOverlapping()->storeOutputInDb();
|
|
151
151
|
$schedule->command('fleetops:process-operational-alerts')->everyMinute()->withoutOverlapping()->storeOutputInDb();
|
|
152
|
-
|
|
153
|
-
|
|
152
|
+
// These commands dispatch bounded jobs. A scheduler restart must not
|
|
153
|
+
// leave the default 24-hour overlap lease blocking telemetry recovery.
|
|
154
|
+
$schedule->command('fleetops:sync-telematics')->everyMinute()->withoutOverlapping(2)->storeOutputInDb();
|
|
155
|
+
$schedule->command('fleetops:drain-telematic-inbox')->everyMinute()->withoutOverlapping(2);
|
|
154
156
|
});
|
|
155
157
|
$this->registerNotifications();
|
|
156
158
|
$this->registerAiCapabilities();
|
|
@@ -196,6 +198,7 @@ class FleetOpsServiceProvider extends CoreServiceProvider
|
|
|
196
198
|
$this->mergeConfigFrom(__DIR__ . '/../../config/fleetops.php', 'fleetops');
|
|
197
199
|
$this->mergeConfigFrom(__DIR__ . '/../../config/telematics.php', 'telematics');
|
|
198
200
|
$this->mergeConfigFrom(__DIR__ . '/../../config/afaqy.php', 'telematics.afaqy');
|
|
201
|
+
$this->mergeConfigFrom(__DIR__ . '/../../config/safee.php', 'telematics.safee');
|
|
199
202
|
$this->mergeConfigFrom(__DIR__ . '/../../config/telemetry.php', 'telematics.telemetry');
|
|
200
203
|
$this->mergeConfigFrom(__DIR__ . '/../../config/fuel-providers.php', 'fuel-providers');
|
|
201
204
|
$this->mergeConfigFrom(__DIR__ . '/../../config/api.php', 'api');
|