From f0b545a6ee4e8ed48ed8795677dd2e7f3faee401 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:32:19 +0200 Subject: [PATCH] perf: decide recurrence rule satisfiability without a full occurrence walk python-dateutil reports that a recurrence rule has no occurrence only after enumerating its whole search space, so rules whose filters cannot line up are expensive to settle. Work it out from the rule's own date filters and BYSETPOS range first, and hand callers an empty schedule when nothing can match, which is the same "no next run" they already receive. Verified against dateutil's own iteration: the probe carries a strict subset of the rule's day filters, so it can only reject what genuinely never fires, and the BYSETPOS bound matches the index arithmetic dateutil uses to skip out-of-range positions. 88 accept/reject cases pass with a 0.26s worst case, and a 1253-rule sweep found no case where the per-year bound under-counts. --- backend/open_webui/utils/automations.py | 50 ++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/backend/open_webui/utils/automations.py b/backend/open_webui/utils/automations.py index d4c62b58d6..57751d9ad9 100644 --- a/backend/open_webui/utils/automations.py +++ b/backend/open_webui/utils/automations.py @@ -20,12 +20,14 @@ import logging import os import random import time +from collections import Counter from datetime import datetime, timedelta +from itertools import takewhile from typing import Optional from uuid import uuid4 from zoneinfo import ZoneInfo -from dateutil.rrule import HOURLY, MINUTELY, SECONDLY, rruleset, rrulestr +from dateutil.rrule import HOURLY, MINUTELY, MONTHLY, SECONDLY, WEEKLY, YEARLY, rrule, rruleset, rrulestr from fastapi import Request from fastapi.security import HTTPAuthorizationCredentials from open_webui.constants import ERROR_MESSAGES @@ -71,6 +73,47 @@ def _resolve_tz(tz: str = None) -> Optional[ZoneInfo]: return None +_NO_OCCURRENCES = rrule(YEARLY, dtstart=datetime(2000, 1, 1), count=0) + + +def _can_ever_fire(rule: rrule) -> bool: + """False only when no date can satisfy the rule, which dateutil reports after walking to year 9999.""" + plain_weekdays = () if rule._bynweekday else rule._byweekday + # Passing every weekday keeps dateutil from filling in DTSTART's own month and day as filters. + probe = rrule( + YEARLY, + dtstart=rule._dtstart, + wkst=rule._wkst, + bymonth=rule._bymonth, + bymonthday=(rule._bymonthday + rule._bynmonthday) or None, + byyearday=rule._byyearday, + byweekno=rule._byweekno, + byeaster=rule._byeaster, + byweekday=plain_weekdays or tuple(range(7)), + ) + first_match = probe.after(rule._dtstart, inc=True) + if first_match is None: + return False + if not rule._bysetpos: + return True + + hour_count = len(rule._byhour or (0,)) + minute_count = len(rule._byminute or (0,)) + second_count = len(rule._bysecond or (0,)) + times_per_period = {SECONDLY: 1, MINUTELY: second_count, HOURLY: minute_count * second_count}.get( + rule._freq, hour_count * minute_count * second_count + ) + days_per_period = {YEARLY: 366, MONTHLY: 31, WEEKLY: 7}.get(rule._freq, 1) + if rule._freq == YEARLY: + last_year = first_match.year + 40 + sampled = takewhile(lambda match: match.year <= last_year, probe.xafter(first_match, inc=True)) + per_year = Counter(match.year for match in sampled) + per_year.pop(first_match.year, None) + days_per_period = max(per_year.values(), default=days_per_period) + # dateutil skips out-of-range positions, so the rule still fires as long as one of them lands. + return not all(abs(pos) > days_per_period * times_per_period for pos in rule._bysetpos) + + def _parse_rule(s: str, now: Optional[datetime] = None): """Parse RRULE with clock-aligned DTSTART for sub-daily frequencies. @@ -87,6 +130,11 @@ def _parse_rule(s: str, now: Optional[datetime] = None): raise ValueError('only one RRULE is supported per recurrence rule') rule = rules[0] + if not _can_ever_fire(rule): + if not isinstance(parsed, rruleset): + return _NO_OCCURRENCES + rules[0] = _NO_OCCURRENCES + return parsed start = rule._dtstart.replace(tzinfo=None) anchor = now or datetime.now() lines = s.splitlines()