mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
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:
parent
58105c1c9c
commit
c6f21d0d84
3 changed files with 138 additions and 11 deletions
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue