3 Commits

Author SHA1 Message Date
rayhpeng
ee33b88893 fix(schedule): normalize a once schedule's next fire time to UTC
Ports #4607 onto the hexagonal path, where the same bug was reproduced:
`next_after` returned ONCE's `run_at` with the task's declared offset
still attached while the CRON branch returned UTC.

That value is persisted into `scheduled_tasks.next_run_at`, and
SQLAlchemy's SQLite dialect discards tzinfo on bind, so the stored
instant was wrong by the whole offset -- a task declared in
Asia/Shanghai fired eight hours late, and a negative offset fired early,
slipping past the `min_once_delay_seconds` floor on the way. Postgres
timestamptz normalizes on write, which is why only SQLite deployments
were affected.

`ensure_launchable` delegates to `next_after`, so both entry points are
covered by the one conversion. The regression cases assert on
`utcoffset()` rather than the instant, because the two are equal as
instants either way -- it is the label that gets discarded on write.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 12:03:22 +08:00
rayhpeng
681c774f32 fix(schedule): validate the cron expression itself, not just its arity
ScheduleSpec.__post_init__ only counted fields, so five fields of
garbage ("x x x x x", out-of-range values) constructed successfully and
surfaced later in next_after as a croniter exception outside the
ScheduleError family -- turning a 422-mappable input error into an
unclassified 500. Probe the normalized expression with croniter at
construction and wrap the failure in InvalidScheduleError.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 16:50:08 +08:00
rayhpeng
d856ae8573 feat(schedule): add the domain model, ports, and application service
The inner ring of the schedule slice, added on its own so it can be read
as domain modelling rather than as a diff against the old implementation:
two aggregates with their state machines, the policy value object, the
output ports the service depends on, and the errors it raises.

Nothing wires it up yet -- no existing code path changes. The service is
exercised end to end against in-memory fakes, which is what makes the
rules (overlap policy, lease handling, which write owns which timestamp)
assertable without a database at all.

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