Fix R15: make the last_seen update atomic (no read-then-write race)

Two concurrent workers could both read the old last_seen_at, pass the in-memory
comparison and save in reverse order, rewinding presence. The guard now lives in
the WHERE clause of a single conditional UPDATE, so last_seen_at only ever
advances even under concurrent ingestion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/phase-1-bootstrap
HomeOS Bootstrap 2026-07-17 23:11:08 +02:00
parent 23ef993cb2
commit d950abe375
1 changed files with 6 additions and 5 deletions

View File

@ -53,11 +53,12 @@ class IngestShellyMessage implements ShouldQueue
$observedAt = $this->observedAt ?? (int) round(microtime(true) * 1_000_000); $observedAt = $this->observedAt ?? (int) round(microtime(true) * 1_000_000);
$observedTime = \Illuminate\Support\Carbon::createFromTimestampMs(intdiv($observedAt, 1000)); $observedTime = \Illuminate\Support\Carbon::createFromTimestampMs(intdiv($observedAt, 1000));
// Presence reflects when the message was RECEIVED, and only advances — queue latency, // Presence reflects when the message was RECEIVED, and only ever advances. The guard
// retries or stale messages can never refresh (or rewind) last_seen incorrectly. // lives in the WHERE clause (one atomic UPDATE), so concurrent workers can't rewind it
if ($device->last_seen_at === null || $observedTime->gt($device->last_seen_at)) { // via read-then-write — queue latency, retries or stale messages never mark it wrong.
$device->forceFill(['last_seen_at' => $observedTime])->save(); Device::whereKey($device->id)
} ->where(fn ($q) => $q->whereNull('last_seen_at')->orWhere('last_seen_at', '<', $observedTime))
->update(['last_seen_at' => $observedTime, 'updated_at' => now()]);
foreach (ShellyNormalizer::normalize($component, $data) as $update) { foreach (ShellyNormalizer::normalize($component, $data) as $update) {
$entity = $device->entities()->firstOrCreate( $entity = $device->entities()->firstOrCreate(