06
Deep Dive — RRULE Expansion & Free/Busy
The Core Question
How do you store "every Monday at 10 AM forever" without writing infinite rows, yet answer "show me next week" in under 100 ms — including timezone-correct DST transitions and single-instance exceptions?
Step 1 — Store the rule, not the instances. A recurring event is one row: { id, calendar_id, title, dtstart, rrule: "FREQ=WEEKLY;BYDAY=MO", timezone: "America/New_York" }. No materialized instances. Storage: O(1) per recurring series regardless of how far into the future it repeats.
Step 2 — Expand on read within the window. When a user opens their week view (e.g. Apr 13-19), the Recurrence Expander loads all events whose dtstart ≤ windowEnd and whose rrule could produce instances in the window. It walks the RRULE forward from dtstart using the IANA timezone, generating instances. For "every Monday," it yields Apr 13 at 10:00 EDT. Key: expansion is bounded by the query window — never "expand all."
Step 3 — Merge exceptions. Single-instance overrides (e.g., "move this Monday to Tuesday") are stored as exception rows keyed by (recurring_event_id, original_start_time). The expander generates the virtual instance, checks for an override, and either replaces or deletes it. An EXDATE marks a deleted instance.
Step 4 — Timezone correctness. All RRULEs are expanded in the event's IANA timezone, not UTC. "Every day at 9 AM America/New_York" must produce 9 AM EST in winter and 9 AM EDT in summer. Storing a UTC offset (e.g., -05:00) would break on DST transitions. The expander uses the tz database to resolve each instance.
Step 5 — Free/busy as interval query. Free/busy expands all events across the requested calendars within the window, then merges overlapping busy intervals. For performance, a Redis bitmap cache stores per-calendar busy slots at 15-minute granularity (96 bits/day). Cache is invalidated on event write. At 10K QPS, most free/busy queries hit the bitmap cache — no expansion needed.
Step 6 — Change notifications. On any event mutation, the Event Service publishes to Kafka. The Sync Service maintains per-client syncToken (a monotonic version). Web clients receive changes via WebSocket; mobile via FCM/APNs push. CalDAV clients poll with their sync token. Conflict resolution uses last-writer-wins with a sequence number tiebreaker.
Sequence — Recurring Event Create & Week View QueryMermaid.js
sequenceDiagram
participant C as Client
participant API as API Gateway
participant ES as Event Service
participant DB as Event DB
participant RE as Recurrence Expander
participant R as Redis (free/busy)
C->>API: POST /calendars/{id}/events {rrule: "FREQ=WEEKLY;BYDAY=MO"}
API->>ES: create recurring event
ES->>DB: INSERT event row with RRULE
ES->>R: invalidate free/busy cache for calendar
ES-->>C: 201 Created {eventId}
C->>API: GET /calendars/{id}/events?timeMin=Apr13&timeMax=Apr19
API->>ES: query week view
ES->>DB: SELECT events WHERE calendar_id=X AND dtstart <= Apr19
DB-->>ES: event rows (single + recurring)
ES->>RE: expand RRULEs within [Apr13, Apr19]
RE->>RE: walk RRULE in IANA tz, generate instances
RE->>DB: fetch exception overrides for window
RE-->>ES: expanded instances + merged exceptions
ES-->>C: [{Mon Apr 14 10:00 EDT}, ...]