fix(calendar): cap RRULE expansion (#2902)

This commit is contained in:
ooovenenoso
2026-06-05 10:05:14 -04:00
committed by GitHub
parent c9d0c6db18
commit 4bfe0c690a
2 changed files with 42 additions and 7 deletions
+25 -7
View File
@@ -460,6 +460,9 @@ def _event_to_dict(ev: CalendarEvent) -> dict:
# ── Recurrence expansion ──
_RRULE_EXPANSION_LIMIT = 1000
def _expand_rrule(
ev: CalendarEvent, start: datetime, end: datetime
) -> List[dict]:
@@ -482,6 +485,7 @@ def _expand_rrule(
d = _event_to_dict(ev)
d["is_recurrence"] = False
d["series_uid"] = ev.uid
d["truncated"] = False
return [d]
# Parse the rrule, applying it to the base dtstart.
@@ -507,6 +511,7 @@ def _expand_rrule(
d = _event_to_dict(ev)
d["is_recurrence"] = False
d["series_uid"] = ev.uid
d["truncated"] = False
# Malformed RRULE rows are fetched by the recurring SQL branch
# with only dtstart < end_dt — the base event may not actually
# overlap the window. Only return if it does.
@@ -519,22 +524,26 @@ def _expand_rrule(
# (matching non-recurring overlap semantics: dtstart < end AND
# dtend > start).
expand_start = start - duration
occurrences = rule.between(expand_start, end, inc=True)
if not occurrences:
return []
results = []
truncated = False
base = _event_to_dict(ev)
for occ_start in occurrences:
for occ_start in rule.xafter(expand_start, inc=True):
if occ_start >= end:
break
occ_end = occ_start + duration
# Overlap filter: occurrence must intersect [start, end).
# This enforces exclusive-end semantics (occ_start >= end is
# excluded) and includes multi-day crossings (occ_end > start).
if occ_start >= end or occ_end <= start:
if occ_end <= start:
continue
if len(results) >= _RRULE_EXPANSION_LIMIT:
truncated = True
break
# Build the compound uid: {base_uid}::{date} or ::{datetime}
if ev.all_day:
occ_uid = f"{ev.uid}::{occ_start.strftime('%Y-%m-%d')}"
@@ -545,6 +554,7 @@ def _expand_rrule(
d["uid"] = occ_uid
d["series_uid"] = ev.uid
d["is_recurrence"] = True
d["truncated"] = False
if ev.all_day:
d["dtstart"] = occ_start.strftime("%Y-%m-%d")
@@ -557,6 +567,10 @@ def _expand_rrule(
results.append(d)
if truncated:
for d in results:
d["truncated"] = True
return results
@@ -786,8 +800,12 @@ def setup_calendar_routes() -> APIRouter:
expanded.extend(_expand_rrule(e, start_dt, end_dt))
# Sort by occurrence start time for consistent frontend ordering.
truncated = any(e.get("truncated") for e in expanded)
expanded.sort(key=lambda d: d["dtstart"])
return {"events": expanded}
response: dict = {"events": expanded}
if truncated:
response["truncated"] = True
return response
except HTTPException:
raise
except Exception as e:
+17
View File
@@ -319,3 +319,20 @@ def test_expand_metadata_inheritance():
assert r["importance"] == "critical"
assert r["event_type"] == "work"
assert r["location"] == "Room 42"
def test_expand_daily_rrule_large_window_is_capped_and_marked_truncated():
"""Wide recurring windows must not materialize unbounded occurrence lists."""
cal = _import_calendar_helpers()
ev = _make_event(
uid="evt-daily-cap",
dtstart=datetime(2020, 1, 1, 9, 0),
dtend=datetime(2020, 1, 1, 10, 0),
rrule="FREQ=DAILY",
)
results = cal._expand_rrule(ev, datetime(2020, 1, 1), datetime(2030, 1, 1))
assert len(results) == cal._RRULE_EXPANSION_LIMIT
assert results[-1]["uid"] == "evt-daily-cap::2022-09-26T09:00"
assert all(r["truncated"] is True for r in results)