From c6f21d0d845a2988794a1ada8c02cb3442c3ebf0 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Wed, 29 Apr 2026 17:58:28 -0700 Subject: [PATCH] fix kind='once' silently parked next_run_at at year 9999 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- litellm/proxy/scheduled_tasks/schedule.py | 39 +++++++-- .../proxy/scheduled_tasks/test_endpoints.py | 27 ++++++ .../proxy/scheduled_tasks/test_schedule.py | 83 +++++++++++++++++-- 3 files changed, 138 insertions(+), 11 deletions(-) diff --git a/litellm/proxy/scheduled_tasks/schedule.py b/litellm/proxy/scheduled_tasks/schedule.py index c66cdd7b0b9..b4442224e1f 100644 --- a/litellm/proxy/scheduled_tasks/schedule.py +++ b/litellm/proxy/scheduled_tasks/schedule.py @@ -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}") diff --git a/tests/test_litellm/proxy/scheduled_tasks/test_endpoints.py b/tests/test_litellm/proxy/scheduled_tasks/test_endpoints.py index 3e267c14d3e..d9e87e3cc74 100644 --- a/tests/test_litellm/proxy/scheduled_tasks/test_endpoints.py +++ b/tests/test_litellm/proxy/scheduled_tasks/test_endpoints.py @@ -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): diff --git a/tests/test_litellm/proxy/scheduled_tasks/test_schedule.py b/tests/test_litellm/proxy/scheduled_tasks/test_schedule.py index c0083ea0327..6f1c07fbce6 100644 --- a/tests/test_litellm/proxy/scheduled_tasks/test_schedule.py +++ b/tests/test_litellm/proxy/scheduled_tasks/test_schedule.py @@ -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)