mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-19 02:56:17 +00:00
Two secondary adapters plus the spec mapping they share, and the first real payoff of the port boundary: test_schedule_fakes.py becomes a contract suite that runs all 31 cases against both the in-memory doubles and the SQL adapters on a real sqlite file. A rule stated in a port docstring now has to hold for both, and a divergence is a failure rather than a surprise in production. The queries come over unchanged. The claim statement's FOR UPDATE SKIP LOCKED and both protect_terminal conditional writes are this module's concurrency contract, not style, and the IntegrityError translation in add() is what lets the service collapse a lost active-slot race into the same outcome as its own non-atomic fast path. Two things the adapters own that the domain deliberately does not. The claiming process's identity is generated here, because who claimed a task is an identity rather than a rule and nothing reads it back. And `Unsupported schedule_type` is raised by the mapping, because ScheduleSpec only accepts the enum -- structural checks belong to the boundary, value rules to __post_init__, and both surface as the same domain error so the router maps one family. _to_domain introduces a failure mode the legacy repository did not have: a row whose stored schedule no longer parses. Single-row reads let it propagate; list reads skip and log, so one corrupt row cannot 500 an entire listing. The contract suite reaches past the port in exactly one place -- seeding a task that already carries a claim, a shape claim_due would never produce -- and says so where it does.
69 lines
2.9 KiB
Python
69 lines
2.9 KiB
Python
"""Wire/storage <-> ScheduleSpec mapping.
|
|
|
|
`schedule_spec` is both an HTTP request field and a JSON column -- one shape,
|
|
two boundaries -- so the mapping lives here once and both the router and the
|
|
SQL adapter import it, rather than the domain growing a `Mapping[str, Any]` in
|
|
its signatures.
|
|
|
|
The split is deliberate: **structural** checks (is the key present? is it a
|
|
str?) belong to this boundary, **value** rules (5-field cron, resolvable
|
|
timezone, run_at present) belong to `ScheduleSpec.__post_init__`. That is why
|
|
this module can look thin -- most of what could go wrong is caught one layer
|
|
in, and reported with the same domain error.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Mapping
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
from deerflow.domain.schedule.model import InvalidScheduleError, ScheduleSpec, ScheduleType
|
|
|
|
|
|
def spec_to_domain(schedule_type: str, spec: Mapping[str, Any] | None, timezone: str) -> ScheduleSpec:
|
|
"""Parse the stored/submitted triple into the value object.
|
|
|
|
Raises:
|
|
InvalidScheduleError: unknown schedule type, or the type's required key
|
|
is missing or not a string. Raising a *domain* error from an
|
|
adapter is intentional -- domain errors are the vocabulary the
|
|
outer ring uses to say "this violates a domain rule", and the
|
|
router maps this one family uniformly.
|
|
"""
|
|
try:
|
|
kind = ScheduleType(schedule_type)
|
|
except ValueError as exc:
|
|
raise InvalidScheduleError(f"Unsupported schedule_type: {schedule_type}") from exc
|
|
|
|
fields = spec or {}
|
|
if kind is ScheduleType.CRON:
|
|
raw_cron = fields.get("cron")
|
|
if not isinstance(raw_cron, str):
|
|
raise InvalidScheduleError("cron schedule requires schedule_spec.cron")
|
|
return ScheduleSpec.cron_schedule(raw_cron, timezone)
|
|
|
|
raw_run_at = fields.get("run_at")
|
|
if not isinstance(raw_run_at, str):
|
|
raise InvalidScheduleError("once schedule requires run_at")
|
|
try:
|
|
run_at = datetime.fromisoformat(raw_run_at)
|
|
except ValueError as exc:
|
|
raise InvalidScheduleError(f"once schedule has an unparseable run_at: {raw_run_at!r}") from exc
|
|
return ScheduleSpec.once_at(run_at, timezone)
|
|
|
|
|
|
def spec_to_wire(spec: ScheduleSpec) -> dict[str, str]:
|
|
"""Rebuild the persisted/wire JSON shape.
|
|
|
|
Note this normalizes the stored string rather than echoing the caller's
|
|
bytes: the frontend submits an already-UTC-aware ISO value
|
|
(`zonedLocalToUtcIso`), so a trailing-Z input round-trips out as "+00:00".
|
|
Both forms parse on either side, so the normalization is deliberate --
|
|
preferable to carrying the raw dict on the value object just to preserve
|
|
the exact input spelling.
|
|
"""
|
|
if spec.schedule_type is ScheduleType.CRON:
|
|
return {"cron": spec.cron or ""}
|
|
return {"run_at": spec.run_at.isoformat() if spec.run_at else ""}
|