diff --git a/backend/open_webui/models/calendar.py b/backend/open_webui/models/calendar.py index 36651536e7..2067ccfab5 100644 --- a/backend/open_webui/models/calendar.py +++ b/backend/open_webui/models/calendar.py @@ -695,35 +695,34 @@ class CalendarEventTable: self, now_ns: int, default_lookahead_ns: int, + grace_ns: int = 0, db: Optional[AsyncSession] = None, ) -> list[tuple[CalendarEventModel, Optional[str]]]: """Events starting between now and now + lookahead, for alert processing. Per-event lookahead is read from meta.alert_minutes (falls back to default_lookahead_ns). Returns (event, user_timezone) pairs. + + *grace_ns* widens the SQL lower bound so that events whose start_at + is up to *grace_ns* nanoseconds in the past are still fetched. This + ensures "At time of event" alerts (alert_minutes=0) are not missed + when the scheduler polls a few seconds after the event's exact start + time. """ from open_webui.models.users import User as UserRow - from open_webui.utils.automations import SCHEDULER_POLL_INTERVAL # Use the maximum possible lookahead (60 min) to cast a wide net; # per-event filtering happens in Python after fetching. max_lookahead_ns = max(default_lookahead_ns, 60 * 60 * 1_000_000_000) upper = now_ns + max_lookahead_ns - # Grace window: allow events whose start_at is up to one poll cycle - # in the past. This ensures "At time of event" alerts (alert_minutes=0) - # are not missed when the scheduler polls a few seconds after the - # event's exact start time. - grace_ns = (SCHEDULER_POLL_INTERVAL + 5) * 1_000_000_000 - lower = now_ns - grace_ns - async with get_async_db_context(db) as db: result = await db.execute( select(CalendarEvent, UserRow.timezone) .outerjoin(UserRow, UserRow.id == CalendarEvent.user_id) .filter( CalendarEvent.is_cancelled == False, - CalendarEvent.start_at >= lower, + CalendarEvent.start_at >= now_ns - grace_ns, CalendarEvent.start_at <= upper, ) ) @@ -741,9 +740,7 @@ class CalendarEventTable: if alert_minutes < 0: # alert_minutes < 0 means "no alert" continue - # For "at time of event" (0 minutes), use the grace window - # so events that just started are still captured. - event_lookahead_ns = max(alert_minutes * 60 * 1_000_000_000, grace_ns) + event_lookahead_ns = alert_minutes * 60 * 1_000_000_000 else: event_lookahead_ns = default_lookahead_ns diff --git a/backend/open_webui/utils/automations.py b/backend/open_webui/utils/automations.py index 1097a308ed..c0b119d9c2 100644 --- a/backend/open_webui/utils/automations.py +++ b/backend/open_webui/utils/automations.py @@ -500,9 +500,14 @@ async def _check_calendar_alerts(app) -> None: now_ns = int(time.time_ns()) default_lookahead_ns = CALENDAR_ALERT_LOOKAHEAD_MINUTES * 60 * 1_000_000_000 + # Grace window covers one poll cycle + jitter so "At time of event" + # alerts (alert_minutes=0) are not missed. + grace_ns = (SCHEDULER_POLL_INTERVAL + 5) * 1_000_000_000 async with get_async_db() as db: - upcoming = await CalendarEvents.get_upcoming_events(now_ns, default_lookahead_ns, db=db) + upcoming = await CalendarEvents.get_upcoming_events( + now_ns, default_lookahead_ns, grace_ns=grace_ns, db=db + ) if not upcoming: return