Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 94 additions & 11 deletions backend/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,21 @@

import asyncio
import logging
from datetime import datetime, timezone, timedelta
from datetime import datetime, timezone

from croniter import croniter

logger = logging.getLogger(__name__)

_scheduler_task: asyncio.Task | None = None

# AUDIT C2: (schedule_id, cron_expression) pairs we've already warned about,
# so a permanently-malformed cron doesn't spam a warning every tick. Keyed on
# the expression too, so editing the schedule to a new (still-bad) value
# warns again instead of staying silent forever. Process-lifetime cache —
# intentionally not cleared on scheduler stop/start within the same process.
_warned_bad_cron: set[tuple[str, str]] = set()


async def _get_enabled_schedules() -> list[dict]:
from sqlalchemy import select
Expand All @@ -29,39 +36,115 @@ async def _get_enabled_schedules() -> list[dict]:
"source_id": sched.source_id,
"cron_expression": sched.cron_expression,
"parameters": sched.parameters,
"name": sched.name,
}
Comment on lines 36 to 40

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The CronSchedule model has a timezone field (defaulting to "UTC"), but the scheduler currently ignores it and evaluates all cron expressions in UTC. This means a schedule configured for a specific local timezone (e.g., America/New_York) will fire at the wrong time (e.g., 9 AM UTC instead of 9 AM EST/EDT).

We can easily fix this by retrieving the timezone from the database and converting the UTC window boundaries (window_start and window_end) to the schedule's target timezone before passing them to croniter.

Suggested change
"source_id": sched.source_id,
"cron_expression": sched.cron_expression,
"parameters": sched.parameters,
"name": sched.name,
}
"source_id": sched.source_id,
"cron_expression": sched.cron_expression,
"parameters": sched.parameters,
"name": sched.name,
"timezone": sched.timezone,
}

for sched, _ in result.all()
]


def _is_due(cron_expression: str, now: datetime) -> bool:
"""Return True if cron fired within the last 60 seconds."""
def _now() -> datetime:
"""Thin seam over datetime.now so tests can drive the clock without sleeping."""
return datetime.now(timezone.utc)


def _fires_in_window(
cron_expression: str,
schedule_id: str,
window_start: datetime,
window_end: datetime,
*,
name: str | None = None,
) -> int:
"""Count cron fire times in the half-open interval (window_start, window_end].

AUDIT C2: a cron_expression croniter can't parse used to be swallowed by
a bare `except Exception: return False` — the schedule went permanently
silent with zero log trace. Now it warns once per (schedule_id,
cron_expression) pair and is treated as "not due" (0 fires) so one bad
schedule can't crash the loop or take down the others.
"""
try:
base = now - timedelta(seconds=61)
cron = croniter(cron_expression, base)
cron = croniter(cron_expression, window_start)
except Exception as exc:
warn_key = (schedule_id, cron_expression)
if warn_key not in _warned_bad_cron:
_warned_bad_cron.add(warn_key)
logger.warning(
"schedule %s (%s) has an unparseable cron_expression %r; "
"skipping until fixed: %s",
schedule_id, name or "?", cron_expression, exc,
)
return 0

count = 0
while True:
next_fire = cron.get_next(datetime)
return next_fire <= now
except Exception:
return False
if next_fire > window_end:
break
count += 1
return count
Comment on lines +50 to +85

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If croniter successfully parses the expression but cron.get_next(datetime) raises an exception (e.g., due to logically impossible date combinations like leap years, DST transitions, or bugs in croniter's calculation), the exception will propagate out of _fires_in_window.

Since the call to _fires_in_window in _scheduler_loop is not wrapped in a try-except block, this exception will propagate to the outer loop, preventing other schedules from being evaluated in the current tick, and preventing last_tick from being updated. In subsequent ticks, the window will widen and the same failing schedule will be evaluated again, leading to a permanent stall of the entire scheduler.

Wrapping the entire calculation (including the while loop) in the try-except block ensures complete robustness. Additionally, we can add support for the schedule's timezone by converting the UTC window boundaries to the target timezone using zoneinfo.ZoneInfo.

def _fires_in_window(
    cron_expression: str,
    schedule_id: str,
    window_start: datetime,
    window_end: datetime,
    *,
    name: str | None = None,
    timezone_str: str = "UTC",
) -> int:
    """Count cron fire times in the half-open interval (window_start, window_end].

    AUDIT C2: a cron_expression croniter can't parse used to be swallowed by
    a bare `except Exception: return False` — the schedule went permanently
    silent with zero log trace. Now it warns once per (schedule_id,
    cron_expression) pair and is treated as "not due" (0 fires) so one bad
    schedule can't crash the loop or take down the others.
    """
    try:
        from zoneinfo import ZoneInfo
        tz = ZoneInfo(timezone_str)
    except Exception:
        tz = timezone.utc

    try:
        local_start = window_start.astimezone(tz)
        local_end = window_end.astimezone(tz)
        cron = croniter(cron_expression, local_start)
        count = 0
        while True:
            next_fire = cron.get_next(datetime)
            if next_fire > local_end:
                break
            count += 1
        return count
    except Exception as exc:
        warn_key = (schedule_id, cron_expression)
        if warn_key not in _warned_bad_cron:
            _warned_bad_cron.add(warn_key)
            logger.warning(
                "schedule %s (%s) has an unparseable or failing cron_expression %r; "
                "skipping until fixed: %s",
                schedule_id, name or "?", cron_expression, exc,
            )
        return 0



async def _scheduler_loop() -> None:
logger.info("Local scheduler started")
# AUDIT C4: the previous "due within the last 61s" check was a fixed
# window decoupled from actual tick cadence (sleep(60) + loop body
# time) — drift near the boundary could get one fire dispatched by two
# consecutive ticks, and a slow loop body (>1s) could silently miss a
# fire. A process-local watermark instead makes consecutive ticks cover
# disjoint, gapless (last_tick, now] windows: no fire time can ever fall
# in two windows, and a slow tick just widens its own window (catching
# up) instead of losing anything.
last_tick: datetime | None = None
while True:
try:
await asyncio.sleep(60)
now = datetime.now(timezone.utc)
now = _now()

if last_tick is None:
# First tick after process start: establish the watermark
# without dispatching, so a restart never replays everything
# that fired while the process was down.
last_tick = now
continue
Comment on lines +98 to +109

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Currently, last_tick is initialized to None, and on the first tick after process start, it is set to now and the loop continues. This introduces an extra 60-second delay on startup before any schedules are evaluated (first evaluation happens 120 seconds after startup), and adds conditional logic inside the loop.

If we instead initialize last_tick = _now() immediately before entering the while True loop:

  1. The first evaluation will happen on the first tick (60 seconds after startup).
  2. The "no catch-up storm" guarantee is still perfectly preserved because the first window will be exactly (startup_time, startup_time + 60s].
  3. The loop logic is simplified by removing the if last_tick is None: check.

Note: If you apply this, remember to update or remove the test test_scheduler_loop_first_tick_establishes_watermark_no_dispatch since the first tick will now evaluate the first 60-second window.

Suggested change
last_tick: datetime | None = None
while True:
try:
await asyncio.sleep(60)
now = datetime.now(timezone.utc)
now = _now()
if last_tick is None:
# First tick after process start: establish the watermark
# without dispatching, so a restart never replays everything
# that fired while the process was down.
last_tick = now
continue
last_tick = _now()
while True:
try:
await asyncio.sleep(60)
now = _now()


schedules = await _get_enabled_schedules()
from backend.executor import get_executor
executor = get_executor()
for sched in schedules:
if _is_due(sched["cron_expression"], now):
logger.info("Firing schedule %s", sched["schedule_id"])
fire_count = _fires_in_window(
sched["cron_expression"],
sched["schedule_id"],
last_tick,
now,
name=sched["name"],
)
Comment on lines +115 to +121

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Pass the retrieved timezone from the schedule dictionary to _fires_in_window so that cron expressions are evaluated in the correct local timezone.

                fire_count = _fires_in_window(
                    sched["cron_expression"],
                    sched["schedule_id"],
                    last_tick,
                    now,
                    name=sched["name"],
                    timezone_str=sched.get("timezone", "UTC"),
                )

if fire_count == 0:
continue
if fire_count > 1:
logger.debug(
"schedule %s coalesced %d fire times into one dispatch",
sched["schedule_id"], fire_count,
)
logger.info("Firing schedule %s", sched["schedule_id"])
try:
await executor.dispatch_scheduled_collection(
sched["schedule_id"],
sched["source_id"],
sched["parameters"],
)
except Exception as exc:
# AUDIT C4: one schedule's dispatch raising must not stop
# the rest of this tick's schedules from being evaluated,
# and must not stall last_tick below — otherwise the next
# tick's re-widened window would re-dispatch schedules
# that already fired successfully earlier in this same
# tick, reopening the double-dispatch bug this fix closes.
logger.warning(
"schedule %s dispatch failed: %s", sched["schedule_id"], exc,
)

last_tick = now
except asyncio.CancelledError:
break
except Exception as exc:
Expand Down
Loading
Loading