rayhpeng c38d291505 refactor(schedule): standardize the module on the hexagonal architecture
Replaces the pre-hexagonal scheduled-task implementation with a slice
built to the layering spec: a pure domain (two aggregates, two state
machines, the policy value object), output ports it declares itself,
SQL/launcher/thread adapters implementing them under `app/adapters/`,
and a composition root that is the one place any of them is
instantiated.

The old implementation mixed all of that into `app/scheduler/service.py`
and a router that reached straight into repositories, so the rules that
matter -- overlap policy, lease handling, which write owns which
timestamp -- were only reachable through a live database. They are now
unit-assertable on in-memory fakes, with the contract suite running each
port against both the fake and real sqlite, and the concurrency
invariants pinned by dedicated race tests.

Two bugs the old shape hid are fixed on the way: a completion hook that
replayed a stale snapshot and rolled back the launch write, and a
corrupt stored row surfacing to the client as a 4xx.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 11:16:46 +08:00

192 lines
8.7 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from croniter import croniter
from deerflow.domain.schedule.exceptions import InvalidScheduleError
from deerflow.domain.schedule.model.enums import ScheduleType
CRON_FIELD_COUNT = 5
@dataclass(frozen=True)
class SchedulePolicy:
"""Operator-tunable thresholds the domain needs but must not read itself.
Built by the composition root from the scheduler configuration and passed
in. Deliberately not held by any aggregate: a task whose meaning changes
with deployment config is not a domain object.
The defaults are the permissive ones on purpose -- "nobody configured a
policy" must not invent a business constraint. Real values only ever
arrive from the outer ring.
"""
min_once_delay_seconds: int = 0
"""How far ahead a one-shot schedule must be at submission time. Read by
`ScheduleSpec.ensure_launchable`; a cron schedule is never subject to it."""
max_concurrent_runs: int = 1
"""Ceiling on active scheduled executions across ALL tasks. Long runs
accumulate across polls, so each poll may only claim into what is left."""
lease_seconds: int = 60
"""How long a claim on a task stays valid. Bounds how quickly a task
orphaned between claim and dispatch becomes reachable again."""
@dataclass(frozen=True)
class ScheduleSpec:
"""Parsed, validated view of (schedule_type, schedule_spec, timezone).
The stored JSON spec is mapped in and out by the adapter layer, never here:
a `Mapping[str, Any]` in a domain signature would mean the domain is
handling a persistence/transport format. The two halves of that parsing
split cleanly — structural checks (is the key present? is it a str?) belong
to the boundary, value rules (5-field cron, resolvable timezone, run_at
present) belong to __post_init__ below. Storage keeps the same raw JSON, so
this needs no migration.
Normalization happens in __post_init__ rather than in the factories below,
so direct construction cannot bypass it: a frozen dataclass is still
constructible field-by-field, and "valid on construction" has to hold for
that path too.
"""
schedule_type: ScheduleType
timezone: str
cron: str | None = None
run_at: datetime | None = None
def __post_init__(self) -> None:
# The timezone is checked first because normalizing a naive run_at
# below needs it to already be known-good.
try:
zone = ZoneInfo(self.timezone)
except ZoneInfoNotFoundError as exc:
raise InvalidScheduleError(f"Unknown timezone: {self.timezone}") from exc
if self.schedule_type is ScheduleType.CRON:
if not self.cron:
raise InvalidScheduleError("cron schedule requires schedule_spec.cron")
fields = [part for part in self.cron.split() if part]
if len(fields) != CRON_FIELD_COUNT:
raise InvalidScheduleError(f"Cron expression must contain exactly {CRON_FIELD_COUNT} fields")
object.__setattr__(self, "cron", " ".join(fields))
if self.schedule_type is ScheduleType.ONCE:
if self.run_at is None:
raise InvalidScheduleError("once schedule requires run_at")
if self.run_at.tzinfo is None:
# A naive run_at means wall-clock time in this schedule's own
# timezone (schedules.py:40-43). Localizing here rather than at
# every read site keeps the rest of this class tz-aware only.
object.__setattr__(self, "run_at", self.run_at.replace(tzinfo=zone))
@classmethod
def cron_schedule(cls, expr: str, timezone: str) -> ScheduleSpec:
"""Readability sugar — all validation lives in __post_init__."""
return cls(ScheduleType.CRON, timezone, cron=expr)
@classmethod
def once_at(cls, run_at: datetime, timezone: str) -> ScheduleSpec:
"""Readability sugar — all validation lives in __post_init__."""
return cls(ScheduleType.ONCE, timezone, run_at=run_at)
@classmethod
def from_primitives(cls, schedule_type: str, *, cron: str | None, run_at: str | None, timezone: str) -> ScheduleSpec:
"""Build from the loose strings both boundaries arrive as.
The HTTP body and the stored JSON column both carry a schedule type
plus a mapping, so the rule for turning that into a value object lives
here instead of being written out once per adapter. Each adapter keeps
only what is genuinely its own — which keys its own format uses — and
the errors below stay one family the router maps uniformly.
Four strings rather than a `Mapping[str, Any]` on purpose: a mapping in
this signature would mean the domain is handling a transport or storage
format, which is the thing this class exists to avoid.
`cron` and `run_at` are annotated as the contract expects them but
checked rather than trusted — both callers read from data a client can
influence, so a non-string must be rejected here instead of reaching
`fromisoformat` or the cron parser. The field the given type does not
use is ignored: a boundary that carries both keys is not an error.
Raises:
InvalidScheduleError: unknown schedule type, the type's field
missing or not a string, or an unparseable `run_at`. Value
rules — 5-field cron, resolvable timezone — are left to
__post_init__ and surface as the same error.
"""
try:
kind = ScheduleType(schedule_type)
except ValueError as exc:
raise InvalidScheduleError(f"Unsupported schedule_type: {schedule_type}") from exc
if kind is ScheduleType.CRON:
if not isinstance(cron, str):
raise InvalidScheduleError("cron schedule requires schedule_spec.cron")
return cls.cron_schedule(cron, timezone)
if not isinstance(run_at, str):
raise InvalidScheduleError("once schedule requires run_at")
try:
parsed = datetime.fromisoformat(run_at)
except ValueError as exc:
raise InvalidScheduleError(f"once schedule has an unparseable run_at: {run_at!r}") from exc
return cls.once_at(parsed, timezone)
def next_after(self, now: datetime) -> datetime | None:
"""Next fire time in UTC, or None when there is no future occurrence.
The dispatch-path calculation (was `next_run_at` in schedules.py:24-55).
It applies no submission-time policy — see ensure_launchable for that,
and do not swap the two: re-arming a cron task through the stricter one
would reject it right after a perfectly normal launch.
ONCE returns run_at while it is still ahead of `now`, else None (the
single occurrence is in the past). CRON is evaluated in this schedule's
timezone and returned as UTC. A naive `now` is read as UTC
(schedules.py:32-33).
"""
if now.tzinfo is None:
now = now.replace(tzinfo=UTC)
if self.schedule_type is ScheduleType.ONCE:
return self.run_at if self.run_at > now else None
zone = ZoneInfo(self.timezone)
next_local = croniter(self.cron, now.astimezone(zone)).get_next(datetime)
if next_local.tzinfo is None:
# Unreachable with an aware input on today's croniter, and kept
# verbatim from schedules.py:51-52 rather than dropped: it costs
# one branch and guards a library detail we do not control.
next_local = next_local.replace(tzinfo=zone)
return next_local.astimezone(UTC)
def ensure_launchable(self, now: datetime, policy: SchedulePolicy) -> datetime | None:
"""Next fire time, with the constraints that only apply at submission.
Used by create/update; the dispatch path must use next_after instead.
Raises:
InvalidScheduleError: a ONCE schedule with no future occurrence, or
one closer than policy.min_once_delay_seconds. CRON is never
subject to the delay floor (router:105, router:196).
"""
if now.tzinfo is None:
now = now.replace(tzinfo=UTC)
next_at = self.next_after(now)
if self.schedule_type is not ScheduleType.ONCE:
return next_at
if next_at is None:
raise InvalidScheduleError("once schedule must be in the future")
if (next_at - now).total_seconds() < policy.min_once_delay_seconds:
raise InvalidScheduleError(f"once schedule must be at least {policy.min_once_delay_seconds} seconds in the future")
return next_at