homeos/app/Jobs/Concerns/AppliesDeviceState.php

55 lines
2.1 KiB
PHP

<?php
namespace App\Jobs\Concerns;
use App\Models\DeviceState;
use Illuminate\Database\UniqueConstraintViolationException;
/**
* Monotonic, race-safe upsert of a single entity's state. Shared by every ingest path (H4) so
* the ordering guarantee is written once: concurrent Horizon workers can finish out of order,
* and an older message must never overwrite a newer one. The guard lives in the WHERE clause,
* so it holds without row locks.
*/
trait AppliesDeviceState
{
/**
* Returns true when this message's state was applied, false when it was stale
* (an equal-or-newer state is already stored).
*
* @param array<string,mixed> $state
*/
protected function applyState(int $entityId, array $state, int $observedAt): bool
{
$encoded = json_encode($state);
// Overwrite an existing row only when this message is newer.
$updated = DeviceState::query()
->where('entity_id', $entityId)
->where(fn ($q) => $q->whereNull('observed_at')->orWhere('observed_at', '<', $observedAt))
->update(['state' => $encoded, 'observed_at' => $observedAt, 'updated_at' => now()]);
if ($updated > 0) {
return true;
}
// No row yet → insert (unique(entity_id) guards against a concurrent insert).
if (! DeviceState::where('entity_id', $entityId)->exists()) {
try {
DeviceState::create(['entity_id' => $entityId, 'state' => $state, 'observed_at' => $observedAt]);
return true;
} catch (UniqueConstraintViolationException) {
// Lost the insert race — fall through and retry the conditional update.
return DeviceState::query()
->where('entity_id', $entityId)
->where(fn ($q) => $q->whereNull('observed_at')->orWhere('observed_at', '<', $observedAt))
->update(['state' => $encoded, 'observed_at' => $observedAt, 'updated_at' => now()]) > 0;
}
}
// Existing row is equal-or-newer → this message is stale.
return false;
}
}