soulsync/tests/test_repair_scheduler_tz.py
BoulderBadgeDad b04010a037 #885: repair-job scheduling is timezone-independent (Australia/Sydney loop)
paksenkin: TZ=Australia/Sydney made the Cache Maintenance job (and any repair
job) run every ~5s. Root cause: finished_at is written by SQLite CURRENT_TIMESTAMP
(always UTC) but the scheduler compared it against datetime.now() (naive LOCAL),
so the local↔UTC offset leaked into the elapsed time. Sydney (+11) made every job
look ~11h stale -> always due -> fired every poll; the Americas (behind UTC)
deflated it and masked the bug (why New_York 'worked').

Fix: compare in UTC. now = datetime.now(timezone.utc), and a new _hours_since()
helper parses the naive CURRENT_TIMESTAMP string AS UTC before subtracting — so
the machine timezone never affects scheduling. 5 tests incl. the literal repro
(a just-run job must not be due under Australia/Sydney) and a due-detection
sanity check; 41 repair-worker tests pass, ruff clean.
2026-06-18 14:02:09 -07:00

73 lines
3.2 KiB
Python

"""#885: repair-job scheduling must be timezone-independent.
`finished_at` is written by SQLite's CURRENT_TIMESTAMP (always UTC), but the
scheduler compared it against `datetime.now()` (naive LOCAL). With TZ=Australia/
Sydney (UTC+11) every job looked ~11h stale and ran every poll; America/New_York
(behind UTC) masked it. The fix parses finished_at as UTC and compares against a
UTC now, so the machine timezone no longer leaks into elapsed time.
"""
from __future__ import annotations
import time
from datetime import datetime, timezone
import pytest
from core.repair_worker import RepairWorker
# ── pure helper ───────────────────────────────────────────────────────────────
def test_hours_since_treats_naive_timestamp_as_utc():
now = datetime(2026, 6, 18, 6, 0, 0, tzinfo=timezone.utc)
# SQLite CURRENT_TIMESTAMP style: UTC, no tz suffix.
assert RepairWorker._hours_since('2026-06-18 00:00:00', now) == pytest.approx(6.0)
def test_hours_since_handles_aware_timestamp():
now = datetime(2026, 6, 18, 6, 0, 0, tzinfo=timezone.utc)
assert RepairWorker._hours_since('2026-06-18T00:00:00+00:00', now) == pytest.approx(6.0)
def test_hours_since_recent_is_near_zero():
now = datetime(2026, 6, 18, 0, 0, 30, tzinfo=timezone.utc)
assert RepairWorker._hours_since('2026-06-18 00:00:00', now) == pytest.approx(30 / 3600, abs=1e-6)
# ── the #885 repro: a just-run job is never due, regardless of timezone ────────
def _set_tz(monkeypatch, tz):
monkeypatch.setenv('TZ', tz)
try:
time.tzset()
except AttributeError:
pytest.skip('time.tzset() unavailable on this platform')
def test_just_run_job_not_due_under_any_timezone(monkeypatch):
w = RepairWorker.__new__(RepairWorker)
w._jobs = {'cache_evictor': object()}
monkeypatch.setattr(RepairWorker, 'get_job_config',
lambda self, jid: {'enabled': True, 'interval_hours': 6})
# Job finished "now" in UTC (exactly how CURRENT_TIMESTAMP records it).
finished = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
monkeypatch.setattr(RepairWorker, '_get_last_run',
lambda self, jid: {'finished_at': finished})
# Australia/Sydney is the exact repro; check the Americas + UTC too.
for tz in ('Australia/Sydney', 'America/New_York', 'UTC'):
_set_tz(monkeypatch, tz)
assert w._pick_next_job() is None, f"just-run job wrongly due under TZ={tz}"
def test_stale_job_is_still_picked_under_sydney(monkeypatch):
# Sanity: a genuinely-overdue job IS picked (we didn't break due-detection).
w = RepairWorker.__new__(RepairWorker)
w._jobs = {'cache_evictor': object()}
monkeypatch.setattr(RepairWorker, 'get_job_config',
lambda self, jid: {'enabled': True, 'interval_hours': 6})
# Finished ~10h ago in UTC.
old = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
monkeypatch.setattr(RepairWorker, '_get_last_run',
lambda self, jid: {'finished_at': old})
_set_tz(monkeypatch, 'Australia/Sydney')
assert w._pick_next_job() == 'cache_evictor'