fix(rotation): isDue() compares wakeTime boundary in UTC, not device-local tz
CI / test (push) Has been cancelled

Symptom: wakeTimes schedules silently never fire on non-UTC devices.
Reported live by Matt's EDT frame: wakeTimes=[12:30 PM NY] saved,
12:30 came and went, no rotation. Same bug pattern would fire
*every* poll on east-of-UTC tzs.

Root cause: device_image_history.served_at is `timestamp without time
zone`, written by `new DateTimeImmutable()` so it stores UTC
components ("2026-05-08 16:28:50"). The boundary in isDue() was
bound through Doctrine with the device's local tz still attached,
so Doctrine's format() emitted local-tz components ("12:30:00").
Postgres compared the strings literally — for west-of-UTC tzs the
UTC timestamp is numerically larger than the local-tz boundary, so
every same-day row falsely satisfied `servedAt >= :wakeTime` and
isDue returned false.

Fix: $boundary->setTimezone(UTC) before binding. Both sides now
format in UTC components, so Postgres's literal compare is correct.

Regression test ID-TZ-01: device in America/New_York, wakeTimes
[12:30 PM NY], history at 12:00 PM NY (= 16:00 UTC). With the fix
isDue returns true; without it the test falsely-matches and fails.
Skipped before 13:00 NY since the assertion needs the wake slot to
have already passed today.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 12:41:40 -04:00
parent b0773e686e
commit b48ed73b4e
2 changed files with 59 additions and 1 deletions
+12 -1
View File
@@ -53,13 +53,24 @@ class RotationService
return false;
}
// device_image_history.served_at is `timestamp without time zone`
// and entries are written by `new DateTimeImmutable()` (UTC). The
// boundary above carries the device's local tz, which Doctrine
// would format with local-tz components — leading to a string
// comparison against UTC-formatted rows in PostgreSQL. For
// anything other than UTC that mismatch quietly breaks `isDue`:
// west-of-UTC tzs falsely match every same-day row (rotation
// never fires); east-of-UTC tzs falsely miss every row (rotation
// fires every poll). Bind in UTC so both sides agree.
$boundaryUtc = $boundary->setTimezone(new \DateTimeZone('UTC'));
$entry = $this->em->createQueryBuilder()
->select('h')
->from(DeviceImageHistory::class, 'h')
->where('h.device = :device')
->andWhere('h.servedAt >= :wakeTime')
->setParameter('device', $device)
->setParameter('wakeTime', $boundary)
->setParameter('wakeTime', $boundaryUtc)
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();