refactor(schedule): drop the UNSET sentinel for plain None defaults

Every top-level update field is non-nullable as a business value, so
None can safely double as "not supplied" -- the one field whose None is
meaningful, thread_id, already travels inside ContextChange where it is
unambiguous. The UnsetType singleton solved a three-state problem this
command does not currently have; plain `| None = None` reads better.

The constraint is documented on UpdateScheduledTask: a future field
whose None is meaningful must ride inside a small change object the way
thread_id does, rather than reintroducing a second convention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
rayhpeng 2026-07-31 17:16:42 +08:00
parent 71169c9f83
commit 376c272f52
4 changed files with 31 additions and 55 deletions

View File

@ -6,14 +6,12 @@ are contracts consumed by adapters and tests, not everyday call-site symbols.
"""
from deerflow.domain.schedule.commands import (
UNSET,
ContextChange,
CreateScheduledTask,
DeleteTask,
PauseTask,
ResumeTask,
TriggerTask,
UnsetType,
UpdateScheduledTask,
)
from deerflow.domain.schedule.exceptions import (
@ -51,7 +49,6 @@ __all__ = [
"ACTIVE_RUN_STATUSES",
"TERMINAL_RUN_STATUSES",
"TERMINAL_TASK_STATUSES",
"UNSET",
"ActiveRunConflictError",
"ConcurrentUpdateError",
"ContextChange",
@ -82,6 +79,5 @@ __all__ = [
"ThreadNotFoundError",
"TriggerKind",
"TriggerTask",
"UnsetType",
"UpdateScheduledTask",
]

View File

@ -28,38 +28,12 @@ the client's intent.
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Final, final
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from deerflow.domain.schedule.model import ContextMode, ScheduleSpec
@final
class UnsetType:
"""The type of ``UNSET`` -- "the client did not supply this field".
Partial updates need three states (absent, null, value). ``None`` cannot
carry two of them, so absence gets its own singleton; a field that is
``UNSET`` is left untouched by the handler.
"""
_instance: UnsetType | None = None
def __new__(cls) -> UnsetType:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __repr__(self) -> str:
return "UNSET"
def __bool__(self) -> bool:
return False
UNSET: Final[UnsetType] = UnsetType()
@dataclass(frozen=True)
class ContextChange:
"""A requested change of execution context.
@ -88,14 +62,21 @@ class CreateScheduledTask:
@dataclass(frozen=True)
class UpdateScheduledTask:
"""Partially update a task; ``UNSET`` means "not supplied"."""
"""Partially update a task; ``None`` means "not supplied".
``None`` can double as the absence marker ONLY because every field here
is non-nullable as a business value -- there is no "set the title to
null". Keep it that way: a future field whose ``None`` is meaningful
must travel inside a small change object instead, the way the nullable
``thread_id`` already rides inside ``ContextChange``.
"""
task_id: str
user_id: str
title: str | UnsetType = UNSET
prompt: str | UnsetType = UNSET
schedule: ScheduleSpec | UnsetType = UNSET
context: ContextChange | UnsetType = UNSET
title: str | None = None
prompt: str | None = None
schedule: ScheduleSpec | None = None
context: ContextChange | None = None
@dataclass(frozen=True)

View File

@ -20,7 +20,6 @@ from dataclasses import dataclass, replace
from datetime import datetime
from deerflow.domain.schedule.commands import (
UNSET,
CreateScheduledTask,
DeleteTask,
PauseTask,
@ -164,10 +163,11 @@ class ScheduleService:
return await self._tasks.add(task)
async def update_scheduled_task(self, cmd: UpdateScheduledTask, *, now: datetime) -> ScheduledTask:
"""Partially update a task; ``UNSET`` means "not supplied".
"""Partially update a task; ``None`` means "not supplied".
The one field for which ``None`` is a meaningful value, `thread_id`,
travels inside `ContextChange` where it is unambiguous.
Safe because every command field is non-nullable as a business value;
the one field for which ``None`` is meaningful, `thread_id`, travels
inside `ContextChange` where it is unambiguous.
Context and schedule are applied through the aggregate's own
transitions, so the re-arm rule and the running-task gate cannot be
@ -176,14 +176,14 @@ class ScheduleService:
async def apply(task: ScheduledTask) -> ScheduledTask:
task.ensure_mutable()
if cmd.context is not UNSET:
if cmd.context is not None:
task = task.with_context(cmd.context.context_mode, cmd.context.thread_id)
await self._require_thread(task)
if cmd.schedule is not UNSET:
if cmd.schedule is not None:
task = task.with_schedule(cmd.schedule, now=now, policy=self._policy)
if cmd.title is not UNSET:
if cmd.title is not None:
task = replace(task, title=cmd.title)
if cmd.prompt is not UNSET:
if cmd.prompt is not None:
task = replace(task, prompt=cmd.prompt)
return task

View File

@ -24,14 +24,12 @@ from schedule_fakes import (
)
from deerflow.domain.schedule.commands import (
UNSET,
ContextChange,
CreateScheduledTask,
DeleteTask,
PauseTask,
ResumeTask,
TriggerTask,
UnsetType,
UpdateScheduledTask,
)
from deerflow.domain.schedule.exceptions import (
@ -947,15 +945,16 @@ class TestTaskManagement:
cmd = CreateScheduledTask(user_id="u", title="", prompt="", schedule=CRON, context_mode="not-a-mode", thread_id=None)
assert cmd.context_mode == "not-a-mode"
async def test_unset_is_a_singleton_distinct_from_none(self):
# Three states, not two: an update field is UNSET (leave it alone),
# None can stay a meaningful value elsewhere, and UNSET is falsy so
# it cannot masquerade as a supplied value.
assert UnsetType() is UNSET
async def test_an_omitted_update_field_defaults_to_none(self):
# None means "not supplied" on every top-level update field. That is
# safe because none of them admits null as a business value -- the one
# nullable field, thread_id, travels inside ContextChange where None
# is unambiguous.
cmd = UpdateScheduledTask(task_id="t", user_id="u")
assert cmd.title is UNSET
assert cmd.title is not None
assert not UNSET
assert cmd.title is None
assert cmd.prompt is None
assert cmd.schedule is None
assert cmd.context is None
async def test_update_leaves_omitted_fields_alone(self):
service = make_service()
@ -967,7 +966,7 @@ class TestTaskManagement:
assert updated.prompt == task.prompt
assert updated.schedule == task.schedule
async def test_update_with_everything_unset_changes_nothing(self):
async def test_update_with_everything_omitted_changes_nothing(self):
service = make_service()
task = await create_cron_task(service)