From d950abe3757bae35deb9844c14099f8703b0bd77 Mon Sep 17 00:00:00 2001 From: HomeOS Bootstrap Date: Fri, 17 Jul 2026 23:11:08 +0200 Subject: [PATCH] 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 --- app/Jobs/IngestShellyMessage.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/Jobs/IngestShellyMessage.php b/app/Jobs/IngestShellyMessage.php index 25f60f6..91949f8 100644 --- a/app/Jobs/IngestShellyMessage.php +++ b/app/Jobs/IngestShellyMessage.php @@ -53,11 +53,12 @@ class IngestShellyMessage implements ShouldQueue $observedAt = $this->observedAt ?? (int) round(microtime(true) * 1_000_000); $observedTime = \Illuminate\Support\Carbon::createFromTimestampMs(intdiv($observedAt, 1000)); - // Presence reflects when the message was RECEIVED, and only advances — queue latency, - // retries or stale messages can never refresh (or rewind) last_seen incorrectly. - if ($device->last_seen_at === null || $observedTime->gt($device->last_seen_at)) { - $device->forceFill(['last_seen_at' => $observedTime])->save(); - } + // Presence reflects when the message was RECEIVED, and only ever advances. The guard + // lives in the WHERE clause (one atomic UPDATE), so concurrent workers can't rewind it + // via read-then-write — queue latency, retries or stale messages never mark it wrong. + 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) { $entity = $device->entities()->firstOrCreate(