fix kind='once' silently parked next_run_at at year 9999

Bug: POST /v1/tasks with schedule_kind='once' accepted any spec, ignored
it, and stored next_run_at = 9999-01-01T00:00:00Z. Row was visible in
GET /v1/tasks but never returned by GET /v1/tasks/due — task never
fired. No validation error at create time, no client-visible signal
that the spec was discarded.

Three fixes:

1. compute_next_run('once', spec, ...) now parses spec as an ISO-8601
   timestamp and returns it verbatim (in UTC). Handles trailing 'Z',
   explicit offsets, microseconds, and naive timestamps (treated as
   UTC).

2. validate_schedule('once', spec, ...) now exercises the same parser
   so unparseable specs fail fast at create/update time with HTTP 400
   instead of being silently stored.

3. Doc comment on schedule.py corrected — kind='once' uses an absolute
   ISO-8601 timestamp, not a parked-far-future sentinel.

Tests: 12 new across test_schedule.py and test_endpoints.py covering
ISO with Z / offset / microseconds / naive, rejection of relative
durations / garbage / empty, and end-to-end POST behavior (200 + correct
next_run_at on valid spec, 400 on bad spec).

Reported with reproduction by user against PR head 4a78324.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krrish Dholakia 2026-04-29 17:58:28 -07:00
parent 58105c1c9c
commit c6f21d0d84
3 changed files with 138 additions and 11 deletions

View file

@ -1,11 +1,12 @@
"""
Schedule parsing + next-run computation for LiteLLM_ScheduledTaskTable.
Ported from test_local_agent_2/task_runner.py:299-340. Three schedule kinds:
Three schedule kinds:
interval: '30s', '5m', '2h', '1d'
cron: standard 5-field crontab; honours schedule_tz (IANA)
once: parked at year 9999; fire_once=True flips status='fired' after
first fire so we never consult next_run_at again.
once: ISO-8601 absolute timestamp ('2026-04-30T00:00:00Z' or with
offset/microseconds). Fires once at that instant; fire_once=True
flips status='fired' after the first claim.
All returned datetimes are UTC, timezone-aware.
"""
@ -40,6 +41,27 @@ def _parse_interval(spec: str) -> timedelta:
return timedelta(**{unit: n})
def _parse_once(spec: str) -> datetime:
"""
Parse an ISO-8601 timestamp for kind='once'. Accepts trailing 'Z'
(Python <3.11 datetime.fromisoformat doesn't), explicit offsets, and
naive timestamps (treated as UTC). Always returns a tz-aware UTC
datetime.
"""
if not spec:
raise ValueError("schedule_spec for kind='once' must not be empty")
normalized = spec.strip().replace("Z", "+00:00")
try:
dt = datetime.fromisoformat(normalized)
except ValueError as e:
raise ValueError(
f"schedule_spec for kind='once' must be ISO-8601, got {spec!r}"
) from e
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def validate_schedule(
*,
kind: str,
@ -60,7 +82,7 @@ def validate_schedule(
except ZoneInfoNotFoundError as e:
raise ValueError(f"invalid schedule_tz: {tz!r}") from e
elif kind == "once":
return
_parse_once(spec)
else:
raise ValueError(f"unknown schedule_kind: {kind!r}")
@ -73,9 +95,14 @@ def compute_next_run(
from_time: datetime,
) -> datetime:
"""
Returns UTC datetime. For 'once', returns far-future sentinel.
Returns UTC datetime.
`from_time` should be timezone-aware; if naive, treated as UTC.
For 'once', schedule_spec is an absolute ISO-8601 timestamp and is
returned verbatim (in UTC). This is the value the caller wants the
task to fire at — fire_once=True flips it to status='fired' after
the first claim, so we never re-consult it.
"""
if from_time.tzinfo is None:
from_time = from_time.replace(tzinfo=timezone.utc)
@ -88,5 +115,5 @@ def compute_next_run(
return _FAR_FUTURE
return nxt.astimezone(timezone.utc)
if kind == "once":
return _FAR_FUTURE
return _parse_once(spec)
raise ValueError(f"unknown schedule_kind: {kind!r}")

View file

@ -392,6 +392,33 @@ class TestCreate:
r = self.client.post("/v1/tasks", json=payload)
assert r.status_code == 400
def test_once_with_iso_spec_uses_it_verbatim(self):
"""Regression: kind='once' must honor schedule_spec, not park at year 9999."""
fire_at = _now() + timedelta(minutes=2)
payload = _create_payload(
schedule_kind="once",
schedule_spec=fire_at.isoformat(),
)
with _patch_prisma(self.prisma):
r = self.client.post("/v1/tasks", json=payload)
assert r.status_code == 200, r.text
body = r.json()
# next_run_at should match the supplied spec to the second.
got = datetime.fromisoformat(body["next_run_at"].replace("Z", "+00:00"))
assert abs((got - fire_at).total_seconds()) < 1
assert got.year == fire_at.year
def test_once_with_unparseable_spec_rejected(self):
"""Regression: kind='once' with non-ISO spec must 400, not silently store."""
payload = _create_payload(
schedule_kind="once",
schedule_spec="1m", # was silently parked at year 9999 prior to fix
)
with _patch_prisma(self.prisma):
r = self.client.post("/v1/tasks", json=payload)
assert r.status_code == 400
assert "ISO-8601" in r.json()["detail"]
class TestListAndGet:
def setup_method(self):

View file

@ -39,8 +39,37 @@ class TestValidateSchedule:
with pytest.raises(ValueError, match="invalid"):
validate_schedule(kind="cron", spec="0 9 * * *", tz="Europe/Atlantis")
def test_once_ok(self):
validate_schedule(kind="once", spec="ignored", tz=None)
def test_once_iso_with_z(self):
validate_schedule(kind="once", spec="2030-06-15T12:00:00Z", tz=None)
def test_once_iso_with_offset(self):
validate_schedule(
kind="once",
spec="2030-06-15T12:00:00+00:00",
tz=None,
)
def test_once_iso_with_microseconds(self):
validate_schedule(
kind="once",
spec="2030-06-15T12:00:00.123456Z",
tz=None,
)
def test_once_naive_iso_treated_utc(self):
validate_schedule(kind="once", spec="2030-06-15T12:00:00", tz=None)
def test_once_rejects_relative_duration(self):
with pytest.raises(ValueError, match="ISO-8601"):
validate_schedule(kind="once", spec="1m", tz=None)
def test_once_rejects_garbage(self):
with pytest.raises(ValueError, match="ISO-8601"):
validate_schedule(kind="once", spec="not a date", tz=None)
def test_once_rejects_empty(self):
with pytest.raises(ValueError):
validate_schedule(kind="once", spec="", tz=None)
def test_unknown_kind(self):
with pytest.raises(ValueError, match="unknown schedule_kind"):
@ -71,10 +100,54 @@ class TestComputeNextRun:
assert nxt.utcoffset() == timedelta(0)
assert nxt > now
def test_once_returns_far_future(self):
def test_once_uses_schedule_spec_verbatim(self):
"""Regression: kind='once' must use the supplied ISO timestamp,
not silently park at year 9999."""
now = datetime(2026, 4, 29, 12, 0, 0, tzinfo=timezone.utc)
nxt = compute_next_run(kind="once", spec="x", tz=None, from_time=now)
assert nxt == _FAR_FUTURE
fire_at = datetime(2030, 6, 15, 12, 0, 0, tzinfo=timezone.utc)
nxt = compute_next_run(
kind="once",
spec=fire_at.isoformat(),
tz=None,
from_time=now,
)
assert nxt == fire_at
assert nxt != _FAR_FUTURE
def test_once_accepts_z_suffix(self):
now = datetime(2026, 4, 29, 12, 0, 0, tzinfo=timezone.utc)
nxt = compute_next_run(
kind="once",
spec="2030-06-15T12:00:00Z",
tz=None,
from_time=now,
)
assert nxt == datetime(2030, 6, 15, 12, 0, 0, tzinfo=timezone.utc)
def test_once_accepts_microseconds(self):
now = datetime(2026, 4, 29, 12, 0, 0, tzinfo=timezone.utc)
nxt = compute_next_run(
kind="once",
spec="2030-06-15T12:00:00.500000Z",
tz=None,
from_time=now,
)
assert nxt == datetime(2030, 6, 15, 12, 0, 0, 500000, tzinfo=timezone.utc)
def test_once_naive_input_treated_utc(self):
now = datetime(2026, 4, 29, 12, 0, 0, tzinfo=timezone.utc)
nxt = compute_next_run(
kind="once",
spec="2030-06-15T12:00:00",
tz=None,
from_time=now,
)
assert nxt == datetime(2030, 6, 15, 12, 0, 0, tzinfo=timezone.utc)
def test_once_rejects_unparseable_spec(self):
now = datetime(2026, 4, 29, 12, 0, 0, tzinfo=timezone.utc)
with pytest.raises(ValueError, match="ISO-8601"):
compute_next_run(kind="once", spec="1m", tz=None, from_time=now)
def test_unknown_kind_raises(self):
now = datetime(2026, 4, 29, 12, 0, 0, tzinfo=timezone.utc)