fix(rotation): isDue() compares wakeTime boundary in UTC, not device-local tz
CI / test (push) Has been cancelled
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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user