Skip to content

fix(scheduler): cron parse guard + last-tick watermark [修复组②] - #21

Merged
2233admin merged 1 commit into
mainfrom
fix/scheduler-cron-guard-and-tick
Jul 18, 2026
Merged

fix(scheduler): cron parse guard + last-tick watermark [修复组②]#21
2233admin merged 1 commit into
mainfrom
fix/scheduler-cron-guard-and-tick

Conversation

@2233admin

Copy link
Copy Markdown
Owner

修复组② — 调度器 (账本 C2/C4)

Sonnet 实施, Fable 审计通过 (窗口数学 + 故障语义 + deviation 反证测试逐项核过)。

  • C2 cron 表达式解析失败: 裸 except: return False 换成 warning (schedule id + name + 表达式 + 异常), 每 (schedule_id, cron_expression) 进程内只警一次, 表达式被编辑后再坏会重新警。行为保持"跳过", 不动 DB
  • C4 61s 固定回看窗换 last-tick 水位线: 相邻 tick 覆盖不相交无缝的半开区间 (last_tick, now] — 双派发在数学上不可能, 慢 tick 自动加宽窗口补漏而非丢失。窗口内多个 fire 合并单次派发 (debug 记数)。进程启动首 tick 只立水位不派发 (重启无补发风暴)
  • deviation (审计已收): 单 schedule 派发异常改为逐条 try/except — 否则一条炸掉跳过水位线更新, 下轮重派本轮已成功的 schedule, 恰好重开 C4 要关的双发。有专门反证测试 (test_scheduler_loop_dispatch_failure_does_not_stall_watermark)
  • 故障语义分层: DB 查询失败 → 外层捕获, 水位不动, 下轮补; 派发失败 → 内层捕获, 水位照推, 防重派

Test

  • tests/unit/test_scheduler.py 21/21 (冻结时钟 + _now() seam, 零真实 sleep): 坏 cron 警一次/改后重警、窗口内恰好一发、相邻窗口零双发、3 个漏发合并一发、首 tick 不发、派发失败不卡水位
  • 全量 unit: 1244 passed, 2 fail = 账本 P3-6 已知存量 (clean main 复核一致, GBK locale 的 UnicodeDecodeError, 与本改动无关)

C2: an invalid cron_expression made croniter raise inside _is_due, which
was swallowed by a bare `except Exception: return False` with zero
logging — the schedule went permanently silent with no diagnostic trace.
Now logs a WARNING (schedule id, name, offending expression) once per
(schedule_id, cron_expression) pair per process; re-warns if the
expression is later edited. Behavior stays "not due" (skip); the schedule
is not disabled in DB.

C4: the 61s due-check window was decoupled from actual tick cadence
(sleep(60) + loop body time), so a fire could be dispatched by two
consecutive ticks near the boundary, and a slow loop body (>1s) could
silently miss a fire. Replaced with a process-local last_tick watermark:
each tick evaluates the half-open interval (last_tick, now], which is
disjoint and gapless across consecutive ticks, so double-dispatch is
structurally impossible and a slow tick just widens its own window
(catch-up) instead of losing a fire. Multiple fire times in one window
coalesce into a single dispatch (logged at debug). First tick after
process start only establishes the watermark and dispatches nothing, so
a restart never replays everything missed while the process was down.

Deviation from the original ask: a per-schedule dispatch is now wrapped
in its own try/except. Without it, one schedule's dispatch raising would
propagate out of the tick's for-loop and skip the `last_tick = now`
update entirely, so the next tick's re-widened window could re-dispatch
schedules that already fired successfully earlier in the same tick —
reopening the exact double-dispatch bug C4 is meant to close. Verified
empirically: test_scheduler_loop_dispatch_failure_does_not_stall_watermark
fails (sched-ok dispatched twice) without the try/except and passes with
it.
@repowise-bot

repowise-bot Bot commented Jul 18, 2026

Copy link
Copy Markdown

✅ Health: 8.6

📋 At a glance
1 hotspot touched · 5 new findings introduced. Scoped to tests.

🚨 Change risk: 9.2/10 (high)
This change's risk is driven by:

  • more lines added than baseline
🔎 More signals (1)

🔥 Hotspot touched (1)

  • tests/unit/test_scheduler.py — 1 commits/90d, 0 dependents · primary owner: xujinghua (100%)

👀 Suggested reviewers @xujinghua


📊 Full report · ⭐ Star Repowise · 📥 Install bot · Last updated 2026-07-18 14:33 UTC
Silence on a single PR with [skip repowise] in the title · Per-repo toggle on repowise.dev/settings?tab=bot

@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@2233admin, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 2 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f638e913-5a41-42d8-bc28-e4849f246c97

📥 Commits

Reviewing files that changed from the base of the PR and between 5875f0f and e10e452.

📒 Files selected for processing (2)
  • backend/scheduler.py
  • tests/unit/test_scheduler.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request refactors the scheduler loop to use a gapless, window-based approach with a process-local watermark to prevent missed or double-dispatched schedules, and improves error handling for unparseable cron expressions. The review feedback is highly constructive, pointing out a potential scheduler stall if cron.get_next raises an exception, suggesting the addition of timezone support, and recommending initializing last_tick before the loop to simplify logic and eliminate an unnecessary 60-second startup delay.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread backend/scheduler.py
Comment on lines +50 to +85
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

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

Comment thread backend/scheduler.py
Comment on lines 36 to 40
"source_id": sched.source_id,
"cron_expression": sched.cron_expression,
"parameters": sched.parameters,
"name": sched.name,
}

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,
}

Comment thread backend/scheduler.py
Comment on lines +115 to +121
fire_count = _fires_in_window(
sched["cron_expression"],
sched["schedule_id"],
last_tick,
now,
name=sched["name"],
)

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"),
                )

Comment thread backend/scheduler.py
Comment on lines +98 to +109
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

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()

@2233admin
2233admin merged commit cb6c1ab into main Jul 18, 2026
5 checks passed
@2233admin
2233admin deleted the fix/scheduler-cron-guard-and-tick branch July 18, 2026 16:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant