Self-review pass on ec4a55c1 — applying the standing kettui-grade
rule (see memory/feedback_always_build_kettui_grade.md). Three issues
that would have surfaced on review:
1. Silent tz fallback to UTC
``_resolve_tz`` returned UTC when the IANA name was unknown — no
log, no warning. User on a host without ``tzdata`` who configures
``America/Los_Angeles`` got schedules running silently at UTC
offset with no way to debug. Now logs WARNING once per unknown
name (deduped via ``_UNKNOWN_TZ_WARNED`` set so a misconfigured
row doesn't spam every poll cycle) and the log line names BOTH
real causes — typo or missing tzdata — so the user can fix from
a single grep.
2. ``weeks`` unit drift from engine
I added ``'weeks': 86400*7`` to ``_INTERVAL_MULTIPLIERS`` but the
engine's existing ``_calc_delay_seconds`` only recognises
minutes/hours/days. Until PR 2 collapses both paths through this
function, any row whose config snuck through with ``unit='weeks'``
would get scheduled by the engine as 1-hour and by this function
as 7-day — drift between two live implementations. Dropped
``weeks`` from the map to match the engine. Added a comment
pinning the map to the engine's contract and a regression test
that asserts ``unit='weeks'`` falls back to the same hours
default the engine produces.
3. DST edge cases unverified
The module docstring claims DST-aware via ``zoneinfo`` but no test
pinned the spring-forward gap (02:30 LA on DST-Sunday doesn't
exist) or fall-back ambiguity (01:30 LA on fall-Sunday happens
twice). Three new tests:
- ``test_dst_spring_forward_lands_after_the_gap`` — pins that the
function doesn't crash + lands on a real instant past ``now``.
- ``test_dst_fall_back_handles_ambiguous_local_time`` — pins
zoneinfo's default-earlier-instant resolution for ambiguous
local times (01:30 PDT vs 01:30 PST → picks PDT).
- ``test_weekly_across_dst_boundary_keeps_local_wall_clock`` —
pins that a "every Sunday at 09:00 LA" schedule keeps the
local wall clock across the boundary even though the UTC
equivalent shifts by an hour. This is the exact bug class
that caused the May 2026 "next in 8h" tz mismatch.
Also loosened ``tzdata==2026.2`` to ``tzdata>=2024.1``. IANA tz data
changes a few times a year for real-world DST policy updates; pinning
to one snapshot would freeze the app's tz knowledge to the build date
and miss future government-mandated rule changes.
41 schedule tests pass (5 new); 240 across the full automation suite.
Ruff clean.
321 lines
13 KiB
Python
321 lines
13 KiB
Python
"""Pure functions for computing the next-run datetime of a scheduled
|
|
automation trigger.
|
|
|
|
The Auto-Sync schedule board currently exposes interval-based scheduling
|
|
(``every N hours``) backed by ``trigger_type='schedule'``. The
|
|
automation engine ALSO supports ``daily_time`` and ``weekly_time``
|
|
triggers via separate ``_setup_*_trigger`` methods inline on the engine
|
|
class. None of that logic is currently testable in isolation — the
|
|
engine's ``_finish_run`` reaches for ``datetime.now()``, threads it
|
|
through ``_next_weekly_occurrence``, and writes the result to the DB,
|
|
all on the same call.
|
|
|
|
This module lifts the "given a trigger config, what's the next run?"
|
|
question out of the engine into a pure function:
|
|
|
|
next_run_at(trigger_type, trigger_config, now_utc, default_tz)
|
|
-> Optional[datetime]
|
|
|
|
That means:
|
|
- ``now_utc`` is INJECTED, not pulled from the system clock. Tests
|
|
freeze time without monkeypatching ``datetime.now``.
|
|
- ``default_tz`` is INJECTED. Daily / weekly / monthly schedules are
|
|
inherently in the USER'S timezone (cron "every Monday at 9am" is
|
|
not UTC), and the historic engine implicitly used the server's
|
|
local tz via naive ``datetime.now()``. That broke for users on a
|
|
different tz than their server. The pure function takes the tz
|
|
explicitly so the caller controls it.
|
|
- Returns an aware UTC ``datetime`` ready to serialise to the DB's
|
|
``next_run`` string column, or ``None`` for unrecognised /
|
|
event-based triggers (engine should not store a next_run for those).
|
|
|
|
PR 1 of the schedule-types feature ships ONLY this module + tests.
|
|
The engine continues to compute next_run via its existing inline
|
|
helpers; PR 2 collapses those into a single ``next_run_at`` call.
|
|
Net behavior is identical until the engine is wired through — this
|
|
PR is pure plumbing.
|
|
|
|
Schedule types supported here:
|
|
|
|
- ``schedule`` (interval): ``{interval: N, unit: 'minutes'|'hours'|'days'}``
|
|
— adds the interval to ``now_utc``; no tz needed.
|
|
- ``daily_time``: ``{time: 'HH:MM', tz: '<IANA>'}`` — runs every day at
|
|
the given local time in the given timezone. ``tz`` falls back to
|
|
``default_tz`` when absent.
|
|
- ``weekly_time``: ``{time: 'HH:MM', days: ['mon','wed',...], tz: '<IANA>'}``
|
|
— runs on the matching weekday(s) at the given local time. Empty
|
|
``days`` list means "every day" (matches the engine's existing
|
|
fallback in ``_next_weekly_occurrence``).
|
|
- ``monthly_time``: ``{time: 'HH:MM', day_of_month: 1-31, tz: '<IANA>'}``
|
|
— runs on the given day each month. Days that don't exist in a
|
|
given month (Feb 30, Apr 31) clamp to the LAST valid day of that
|
|
month rather than skipping the run entirely; missing a whole
|
|
month silently because the schedule was over-eager is worse than
|
|
running a day early.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Any, Dict, Optional
|
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
|
|
from utils.logging_config import get_logger
|
|
|
|
logger = get_logger("automation.schedule")
|
|
|
|
|
|
# Unknown-tz names already warned about in this process — avoids
|
|
# spamming the log on every poll cycle for the same misconfigured row.
|
|
_UNKNOWN_TZ_WARNED: set = set()
|
|
|
|
|
|
# Weekday abbreviation → ``datetime.weekday()`` index (Mon=0..Sun=6).
|
|
# Mirrors the engine's existing ``_next_weekly_occurrence`` mapping so
|
|
# schedules created against either implementation accept the same
|
|
# ``days`` strings.
|
|
_WEEKDAY_MAP = {
|
|
'mon': 0, 'tue': 1, 'wed': 2, 'thu': 3, 'fri': 4, 'sat': 5, 'sun': 6,
|
|
}
|
|
|
|
# Interval multipliers — kept aligned with the engine's existing
|
|
# ``_calc_delay_seconds`` in ``core/automation_engine.py``. Adding
|
|
# entries here without also updating the engine would silently drift:
|
|
# this function would honour the new unit while the live engine path
|
|
# defaults it to hours. Keep the maps in sync until PR 2 collapses the
|
|
# engine through this function.
|
|
_INTERVAL_MULTIPLIERS = {
|
|
'minutes': 60,
|
|
'hours': 60 * 60,
|
|
'days': 60 * 60 * 24,
|
|
}
|
|
|
|
|
|
def next_run_at(
|
|
trigger_type: str,
|
|
trigger_config: Dict[str, Any],
|
|
now_utc: datetime,
|
|
default_tz: str = 'UTC',
|
|
) -> Optional[datetime]:
|
|
"""Compute the next-run timestamp (UTC, aware) for a scheduled
|
|
trigger. Returns ``None`` for unrecognised types or event-based
|
|
triggers — callers should not write a next_run for those.
|
|
|
|
See module docstring for supported trigger types + config shapes.
|
|
"""
|
|
if not isinstance(trigger_config, dict):
|
|
trigger_config = {}
|
|
|
|
if trigger_type == 'schedule':
|
|
return _next_interval(trigger_config, now_utc)
|
|
if trigger_type == 'daily_time':
|
|
return _next_daily(trigger_config, now_utc, default_tz)
|
|
if trigger_type == 'weekly_time':
|
|
return _next_weekly(trigger_config, now_utc, default_tz)
|
|
if trigger_type == 'monthly_time':
|
|
return _next_monthly(trigger_config, now_utc, default_tz)
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Interval
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _next_interval(config: Dict[str, Any], now_utc: datetime) -> datetime:
|
|
"""``{interval: N, unit: 'hours'}`` → ``now_utc + N hours``.
|
|
|
|
Mirrors the engine's existing ``_calc_delay_seconds``. Unit defaults
|
|
to ``hours`` for backward compat with legacy DB rows that pre-date
|
|
the unit field being mandatory; interval defaults to 1 so a fully
|
|
empty config doesn't divide-by-zero or schedule for the past."""
|
|
try:
|
|
interval = max(int(config.get('interval', 1)), 1)
|
|
except (TypeError, ValueError):
|
|
interval = 1
|
|
unit = config.get('unit') or 'hours'
|
|
seconds = interval * _INTERVAL_MULTIPLIERS.get(unit, _INTERVAL_MULTIPLIERS['hours'])
|
|
return _ensure_utc(now_utc) + timedelta(seconds=seconds)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Daily
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _next_daily(
|
|
config: Dict[str, Any], now_utc: datetime, default_tz: str,
|
|
) -> datetime:
|
|
"""``{time: 'HH:MM', tz: '<IANA>'}`` → next occurrence of that
|
|
wall-clock time in the user's timezone, expressed as aware UTC.
|
|
|
|
DST-aware via ``zoneinfo``: when the local time falls during a
|
|
spring-forward gap, the ``replace`` lands on a non-existent
|
|
instant; ``zoneinfo`` resolves that to the gap's later side
|
|
(e.g. 02:30 on the DST-forward day becomes 03:30 local). Tests
|
|
pin both spring-forward and fall-back behaviour."""
|
|
tz = _resolve_tz(config.get('tz') or default_tz)
|
|
hour, minute = _parse_hhmm(config.get('time'))
|
|
now_local = _ensure_utc(now_utc).astimezone(tz)
|
|
target_local = now_local.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
|
if target_local <= now_local:
|
|
target_local = target_local + timedelta(days=1)
|
|
return target_local.astimezone(timezone.utc)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Weekly
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _next_weekly(
|
|
config: Dict[str, Any], now_utc: datetime, default_tz: str,
|
|
) -> datetime:
|
|
"""``{time: 'HH:MM', days: ['mon',...], tz: '<IANA>'}`` → next
|
|
occurrence of that wall-clock time on any of the listed weekdays
|
|
in the user's timezone.
|
|
|
|
Empty ``days`` list ≡ every day, matching the engine's existing
|
|
fallback. Unrecognised day abbreviations are silently dropped
|
|
(an empty result-set then triggers the every-day fallback)."""
|
|
tz = _resolve_tz(config.get('tz') or default_tz)
|
|
hour, minute = _parse_hhmm(config.get('time'))
|
|
days = _parse_weekdays(config.get('days'))
|
|
|
|
now_local = _ensure_utc(now_utc).astimezone(tz)
|
|
# Scan today + next 7 days; the matching day with a future
|
|
# local time wins. 8-day scan is enough to handle the case where
|
|
# today already passed the time AND today is the only allowed
|
|
# weekday (next occurrence is exactly one week out).
|
|
for offset in range(8):
|
|
candidate = now_local + timedelta(days=offset)
|
|
if candidate.weekday() not in days:
|
|
continue
|
|
target = candidate.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
|
if target > now_local:
|
|
return target.astimezone(timezone.utc)
|
|
# Shouldn't reach: 8-day scan always finds a hit when ``days``
|
|
# is non-empty. Defensive fallback: next week, same weekday as today.
|
|
fallback = (now_local + timedelta(days=7)).replace(
|
|
hour=hour, minute=minute, second=0, microsecond=0,
|
|
)
|
|
return fallback.astimezone(timezone.utc)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Monthly
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _next_monthly(
|
|
config: Dict[str, Any], now_utc: datetime, default_tz: str,
|
|
) -> datetime:
|
|
"""``{time: 'HH:MM', day_of_month: 1-31, tz: '<IANA>'}`` → next
|
|
occurrence in the user's timezone.
|
|
|
|
``day_of_month`` is clamped to ``[1, 31]``. When the target day
|
|
doesn't exist in a given month (Feb 30, Apr 31), the schedule
|
|
falls back to the LAST valid day of that month — running a day
|
|
or two early in short months is less surprising than skipping
|
|
a month entirely. This matches the convention every cron
|
|
implementation in the wild settled on."""
|
|
tz = _resolve_tz(config.get('tz') or default_tz)
|
|
hour, minute = _parse_hhmm(config.get('time'))
|
|
raw_day = config.get('day_of_month', 1)
|
|
try:
|
|
target_day = max(1, min(31, int(raw_day)))
|
|
except (TypeError, ValueError):
|
|
target_day = 1
|
|
|
|
now_local = _ensure_utc(now_utc).astimezone(tz)
|
|
# Try this month first; if the target day has already passed
|
|
# (or doesn't exist this month and the clamped day is in the
|
|
# past), advance to next month. Loop bounded to 12 iterations
|
|
# so a pathologically broken config can't infinite-loop us.
|
|
year, month = now_local.year, now_local.month
|
|
for _ in range(12):
|
|
day = min(target_day, _days_in_month(year, month))
|
|
target = now_local.replace(
|
|
year=year, month=month, day=day,
|
|
hour=hour, minute=minute, second=0, microsecond=0,
|
|
)
|
|
if target > now_local:
|
|
return target.astimezone(timezone.utc)
|
|
# Roll to next month.
|
|
if month == 12:
|
|
year, month = year + 1, 1
|
|
else:
|
|
month += 1
|
|
# Defensive — should be unreachable.
|
|
return (now_local + timedelta(days=30)).astimezone(timezone.utc)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _ensure_utc(dt: datetime) -> datetime:
|
|
"""Coerce a possibly-naive datetime to aware UTC. Naive inputs
|
|
are assumed UTC (matches the convention the engine uses when
|
|
parsing the DB ``next_run`` column)."""
|
|
if dt.tzinfo is None:
|
|
return dt.replace(tzinfo=timezone.utc)
|
|
return dt.astimezone(timezone.utc)
|
|
|
|
|
|
def _resolve_tz(name: Optional[str]):
|
|
"""Look up an IANA tz by name. Falls back to UTC when the name is
|
|
unknown — ``ZoneInfoNotFoundError`` is the symptom of either a
|
|
typo in the tz string or ``tzdata`` missing on the host. Logged
|
|
once per unknown name so the user can see WHY their schedule
|
|
isn't running in the timezone they configured."""
|
|
if not name:
|
|
return timezone.utc
|
|
try:
|
|
return ZoneInfo(name)
|
|
except ZoneInfoNotFoundError:
|
|
if name not in _UNKNOWN_TZ_WARNED:
|
|
_UNKNOWN_TZ_WARNED.add(name)
|
|
logger.warning(
|
|
"Unknown timezone %r — schedule will run against UTC. "
|
|
"Check the spelling (IANA format like 'America/Los_Angeles') "
|
|
"or install the `tzdata` package on minimal hosts.",
|
|
name,
|
|
)
|
|
return timezone.utc
|
|
|
|
|
|
def _parse_hhmm(time_str: Optional[str]) -> tuple:
|
|
"""Parse ``HH:MM`` → ``(hour, minute)``. Defaults to 00:00 on
|
|
garbage input — same defensive shape as the engine's existing
|
|
daily/weekly time parsing."""
|
|
if not isinstance(time_str, str):
|
|
return 0, 0
|
|
try:
|
|
h, m = time_str.split(':', 1)
|
|
return max(0, min(23, int(h))), max(0, min(59, int(m)))
|
|
except (ValueError, AttributeError):
|
|
return 0, 0
|
|
|
|
|
|
def _parse_weekdays(days) -> set:
|
|
"""``['mon', 'wed']`` → ``{0, 2}``. Empty / missing / all-invalid
|
|
list returns ``set(range(7))`` ("every day"), matching the
|
|
engine's existing ``_next_weekly_occurrence`` fallback."""
|
|
if not isinstance(days, (list, tuple)):
|
|
return set(range(7))
|
|
parsed = {_WEEKDAY_MAP[d.lower()] for d in days
|
|
if isinstance(d, str) and d.lower() in _WEEKDAY_MAP}
|
|
return parsed or set(range(7))
|
|
|
|
|
|
def _days_in_month(year: int, month: int) -> int:
|
|
"""Last calendar day of ``year-month``. Stdlib-only — no calendar
|
|
module import needed; cycle through the 12 months."""
|
|
if month == 12:
|
|
next_first = datetime(year + 1, 1, 1)
|
|
else:
|
|
next_first = datetime(year, month + 1, 1)
|
|
last_day = next_first - timedelta(days=1)
|
|
return last_day.day
|