Design a meeting scheduler and calendar¶
TL;DR¶
- You build a calendar service: meetings expand into
Intervals, busy blocks merge, the first gap that fits wins, and a room is booked under its own lock. - Three decisions carry the interview: intervals are half-open (
[start, end), so 10-11 and 11-12 do not clash), recurrence repeats wall-clock time (09:00 local stays 09:00 across a DST switch), and the room lock is per room so two organisers racing for one room have exactly one winner. - Patterns that earn their place: Strategy (recurrence, slot finding), Builder, Observer, Command with undo, Repository. A
Calendarclass is deliberately absent.
Problem statement¶
"Design a meeting scheduler. Users have calendars; an organiser creates a meeting with a time range, attendees, optionally a room, and optionally a recurrence rule. Attendees accept, decline or answer tentatively. The system detects conflicts, finds the earliest free slot for a group inside a search window, and sends notifications and reminders. Attendees are in different timezones. Handle cancelling or moving one occurrence of a recurring series, and tell me what happens when two people book the last room at the same instant."
Requirements¶
Functional
- Per-user calendars; create a meeting with a time range, attendees, an optional room and an optional recurrence rule.
- Conflict detection for attendees and rooms; rooms have a capacity.
- Find the first free slot of a given length for N attendees inside a window.
- Invitations with accept, decline and tentative; declining frees the attendee's time.
- Update, reschedule (with undo) and cancel, each notifying the attendees.
- Reminders a fixed lead time before an occurrence starts.
- Recurring meetings (daily, weekly on chosen weekdays, with a count or an end date), including cancelling or moving a single occurrence.
- Timezones: every attendee sees their own; the stored truth is one instant.
Non-functional and constraints
- Two organisers must never double-book a room, whatever the interleaving.
- Recurrence expansion is bounded: no query may expand an endless series.
- Deterministic and testable: the clock is injected, expansion is pure, no wall-clock reads inside services.
- In-memory behind a
Repositoryprotocol; a database is a swap, not a redesign.
Out of scope: external calendar sync (Exchange, Google), video-conference links, resource types other than rooms, free/busy sharing across organisations, mail delivery.
Clarifying questions and assumptions¶
| Question to ask | Assumption taken here |
|---|---|
| Are the endpoints inclusive? | Half-open, [start, end). Everything downstream (merge, gaps, conflicts) is simpler and the 11:00 boundary stops being a bug. |
| Does a pending invitation block time? | Yes. Pending and tentative are busy, declined is not - that is what a real calendar shows. |
| Where does a recurring meeting live? | One row with a rule plus an exception list. Materialising every occurrence is a follow-up for very long series. |
| What does "every day at 09:00" mean across a DST switch? | 09:00 local, always. That is what the organiser meant, and it is why the start time carries the organiser's zone. |
| Can an attendee be double-booked? | Yes - real calendars allow it and warn. Only rooms are hard-enforced. |
| How far ahead does a room get booked for an endless series? | 60 days, rolled forward by a job. Booking a room until the end of time is not a feature. |
| Who is the source of truth for "now"? | An injected Clock. Reminders and tests need the same one. |
Core entities and relationships¶
- Interval — a frozen, timezone-aware, half-open range. Every conflict question in this design is one
overlapscall. - Meeting — one series: organiser, attendees, first start (in the organiser's zone), duration, optional
RecurrenceRule, optional room, pluscancelled_datesandmoved_datesfor the exceptions. - MeetingBuilder — eight fields, four optional and interdependent; the Builder is the one place a meeting can be born invalid.
- Invitation — per attendee, with a five-value status and guarded transitions.
- User — id, name and an IANA timezone; MeetingRoom — id, name, capacity; RoomBooking — a room, a meeting and one interval.
- RecurrenceStrategy (
NoRecurrence,DailyRecurrence,WeeklyRecurrence) — expands a series into starts inside a window;strategy_foris the factory that picks one from the rule. - SlotFinder (
EarliestSlotFinder,WorkingHoursSlotFinder) — picks the slot; the second one adds each attendee's out-of-hours time as busy. - AvailabilityService — expand, drop exceptions, merge. RoomRegistry — bookings and the per-room locks. MeetingScheduler — the facade. NotificationService — an observer. RescheduleCommand — move with undo.
Multiplicities: meeting 1 -> * invitations, meeting 1 -> 0..1 recurrence rule, meeting 1 -> * occurrences (computed, never stored), room 1 -> * bookings, user 1 -> * meetings.
Class diagram¶
The domain: intervals, the meeting they come from, and the builder that assembles one.
classDiagram
class Interval {
+start: datetime
+end: datetime
+duration: timedelta
+overlaps(other) bool
+clipped_to(window) Interval
+utc() Interval
}
class Meeting {
+id: str
+title: str
+organizer_id: str
+attendee_ids: tuple
+start: datetime
+duration: timedelta
+status: MeetingStatus
+cancelled_dates: Set~date~
+moved_dates: Dict~date,datetime~
+blocks(attendee_id) bool
}
class MeetingBuilder {
+titled(title) MeetingBuilder
+organized_by(organizer) MeetingBuilder
+starting_at(local, zone) MeetingBuilder
+lasting(minutes) MeetingBuilder
+repeating(rule) MeetingBuilder
+in_room(room_id) MeetingBuilder
+build() Meeting
}
class RecurrenceRule {
+frequency: Frequency
+every: int
+count: int
+until: datetime
+weekdays: tuple
}
class Invitation {
+attendee_id: str
+status: InvitationStatus
+respond(status, at) None
+withdraw() None
}
class User {
+id: str
+timezone: str
+zone: ZoneInfo
}
class MeetingRoom {
+id: str
+capacity: int
}
class RoomBooking {
+room_id: str
+meeting_id: str
+interval: Interval
}
class Reminder {
+meeting_id: str
+minutes_before: int
+fires_at: datetime
}
MeetingBuilder ..> Meeting : builds
Meeting "1" *-- "many" Invitation
Meeting --> "0..1" RecurrenceRule
Meeting ..> Interval : expands into
Invitation --> User
RoomBooking --> MeetingRoom
RoomBooking --> Interval
Reminder --> Interval
The services and the two Strategy families.
classDiagram
class MeetingScheduler {
-meeting_locks: Dict~str,RLock~
+schedule(meeting) Meeting
+schedule_first_available(title, organizer, attendee_ids, minutes, window) Meeting
+find_slot(attendee_ids, minutes, window, finder) Interval
+respond(meeting_id, attendee_id, status) None
+cancel(meeting_id) Meeting
+cancel_occurrence(meeting_id, on) Meeting
+reschedule(meeting_id, new_start) RescheduleCommand
+due_reminders(lead_minutes) List~Reminder~
}
class AvailabilityService {
+occurrences(meeting, window) List~Interval~
+busy(user_ids, window) List~Interval~
+free(user_ids, window) List~Interval~
}
class RoomRegistry {
-locks: Dict~str,Lock~
+book(room_id, meeting_id, intervals) List~RoomBooking~
+release(room_id, meeting_id) int
+book_first_available(meeting_id, intervals, attendees) str
}
class MeetingRepository {
<<interface>>
+add(meeting) None
+get(meeting_id) Meeting
+for_attendee(user_id) List~Meeting~
}
class InMemoryMeetingRepository
class MeetingObserver {
<<interface>>
+on_meeting_event(event, meeting, recipients) None
}
class NotificationService {
+inbox(user_id) List~str~
}
class RescheduleCommand {
+execute() Meeting
+undo() Meeting
}
class RecurrenceStrategy {
<<interface>>
+starts(first, window) Iterator~datetime~
}
class DailyRecurrence
class WeeklyRecurrence
class NoRecurrence
class SlotFinder {
<<interface>>
+find(attendees, busy, window, duration) Interval
}
class EarliestSlotFinder
class WorkingHoursSlotFinder
MeetingScheduler --> AvailabilityService
MeetingScheduler --> RoomRegistry
MeetingScheduler --> MeetingRepository
MeetingScheduler --> "many" MeetingObserver
MeetingScheduler ..> RescheduleCommand : creates
MeetingScheduler ..> SlotFinder
MeetingRepository <|.. InMemoryMeetingRepository
MeetingObserver <|.. NotificationService
AvailabilityService ..> RecurrenceStrategy
RecurrenceStrategy <|.. DailyRecurrence
RecurrenceStrategy <|.. WeeklyRecurrence
RecurrenceStrategy <|.. NoRecurrence
SlotFinder <|.. EarliestSlotFinder
SlotFinder <|.. WorkingHoursSlotFinder
Design patterns applied¶
| Pattern | Where | Why it earns its place |
|---|---|---|
| Strategy | RecurrenceStrategy, SlotFinder |
The two things interviewers change mid-question. "Now only weekdays" is a class; "now respect working hours" is a class that reuses the same gap scan. |
| Builder | MeetingBuilder |
Eight fields with rules between them (a start needs a zone, attendees always include the organiser). One build() gate means an invalid Meeting cannot exist. |
| Factory Method | strategy_for(rule) |
The rule is data that can be stored or sent over the wire; the expansion is behaviour. Keeping them apart is what lets a rule be a database row. |
| Observer | MeetingObserver -> NotificationService |
The scheduler announces; it does not know that email, push or an audit log exist. The announce loop runs outside every lock. |
| Command | RescheduleCommand |
"Move it, no wait, put it back" is a real user action, and the command carries the previous start so undo is exact rather than reconstructed. |
| Repository | MeetingRepository |
Availability is the query layer; swapping the in-memory dict for SQL changes one class. |
| State (lightweight) | InvitationStatus with guarded transitions |
Five values and one rule (a withdrawn invitation cannot be answered). Enum plus a guard beats five classes. |
What was deliberately not used: a Calendar class. It is the first noun everybody writes, and it turns out to be an empty shell - a calendar is the set of meetings a user is invited to, which AvailabilityService.busy computes on demand. Modelling it means keeping two copies of the same truth in sync every time an invitation changes. Say that in the room; the interviewer is watching for whether you can delete a noun.
Key flows¶
Find a slot and book it: expand, merge, first gap, room, notify.
sequenceDiagram
autonumber
actor O as Organizer
participant S as MeetingScheduler
participant A as AvailabilityService
participant F as SlotFinder
participant R as RoomRegistry
participant N as NotificationService
O->>S: schedule_first_available(title, attendees, 30 min, window)
S->>A: busy(attendee_ids, window)
loop each meeting of each attendee
A->>A: expand recurrence, drop cancelled dates
end
A-->>S: merged busy blocks
S->>F: find(attendees, busy, window, 30 min)
alt no gap is long enough
F-->>S: None
S-->>O: NoFreeSlotError
else gap found
F-->>S: first gap of at least 30 minutes
S->>R: book_first_available(meeting_id, slot, headcount)
R->>R: per-room lock, overlap check, insert
R-->>S: room id
S->>N: on_meeting_event(invited, meeting, attendees)
S-->>O: Meeting with invitations pending
end
Invitation lifecycle. Every answer is reversible until the organiser cancels; WITHDRAWN is the only terminal state, and it is reached from all four others.
stateDiagram-v2
[*] --> PENDING : invitation sent
PENDING --> ACCEPTED : accept
PENDING --> TENTATIVE : maybe
PENDING --> DECLINED : decline
TENTATIVE --> ACCEPTED : confirm
ACCEPTED --> DECLINED : change of mind
DECLINED --> ACCEPTED : reconsider
PENDING --> WITHDRAWN : meeting cancelled
ACCEPTED --> WITHDRAWN : meeting cancelled
TENTATIVE --> WITHDRAWN : meeting cancelled
DECLINED --> WITHDRAWN : meeting cancelled
WITHDRAWN --> [*]
Implementation¶
Write the interval first. Every later method is a two-line call on it, and getting the half-open comparison right up front is what stops the rest of the hour turning into off-by-one debugging.
class Frequency(StrEnum):
DAILY = "daily"
WEEKLY = "weekly"
class MeetingStatus(StrEnum):
SCHEDULED = "scheduled"
CANCELLED = "cancelled"
class InvitationStatus(StrEnum):
PENDING = "pending" # invited, no answer yet - still counts as busy
ACCEPTED = "accepted"
TENTATIVE = "tentative" # busy, but movable
DECLINED = "declined" # not busy
WITHDRAWN = "withdrawn" # the meeting was cancelled
@property
def blocks_time(self) -> bool:
return self in (InvitationStatus.PENDING, InvitationStatus.ACCEPTED, InvitationStatus.TENTATIVE)
class SlotConflictError(ConflictError):
"""The room or an attendee is already busy for the requested interval."""
class NoFreeSlotError(ConflictError):
"""No gap long enough exists in the search window."""
class InvitationStateError(InvalidStateError):
"""The invitation cannot move to that status (answering a withdrawn invitation)."""
class MeetingNotFoundError(NotFoundError):
"""Unknown meeting, room or user id."""
merge_intervals sorts and folds; free_gaps is its complement inside a window. Those two functions are the whole availability engine, and both are pure.
@dataclass(frozen=True, slots=True, order=True)
class Interval:
"""A half-open range ``[start, end)``. Back-to-back meetings do not overlap."""
start: datetime
end: datetime
def __post_init__(self) -> None:
for moment in (self.start, self.end):
if moment.tzinfo is None or moment.utcoffset() is None:
raise ValidationError(f"interval bounds must be timezone-aware: {moment!r}")
if self.end <= self.start:
raise ValidationError(f"interval must be positive: {self.start} to {self.end}")
@property
def duration(self) -> timedelta:
return self.end - self.start
def overlaps(self, other: Interval) -> bool:
"""Half-open comparison: ``10:00-11:00`` and ``11:00-12:00`` are *not* a clash."""
return self.start < other.end and other.start < self.end
def contains(self, moment: datetime) -> bool:
return self.start <= moment < self.end
def clipped_to(self, window: Interval) -> Interval | None:
start, end = max(self.start, window.start), min(self.end, window.end)
return Interval(start, end) if start < end else None
def utc(self) -> Interval:
return Interval(self.start.astimezone(UTC), self.end.astimezone(UTC))
def __str__(self) -> str:
utc = self.utc()
return f"{utc.start:%Y-%m-%d %H:%M}-{utc.end:%H:%M}Z"
def merge_intervals(intervals: list[Interval]) -> list[Interval]:
"""Sort by start, then fold anything that touches or overlaps into one block."""
merged: list[Interval] = []
for interval in sorted(interval.utc() for interval in intervals):
if merged and interval.start <= merged[-1].end:
merged[-1] = Interval(merged[-1].start, max(merged[-1].end, interval.end))
else:
merged.append(interval)
return merged
def free_gaps(busy: list[Interval], window: Interval) -> list[Interval]:
"""The parts of ``window`` no busy block covers. Merging first is what makes it O(n log n)."""
bounds = window.utc()
gaps: list[Interval] = []
cursor = bounds.start
for block in merge_intervals(busy):
if block.start > cursor:
gaps.append(Interval(cursor, min(block.start, bounds.end)))
cursor = max(cursor, block.end)
if cursor >= bounds.end:
return gaps
gaps.append(Interval(cursor, bounds.end))
return gaps
The entities are small because the behaviour lives in the services. Note Meeting.start keeping the organiser's zone and the two exception collections.
@dataclass(frozen=True, slots=True)
class User:
id: str
name: str
timezone: str = "UTC"
@property
def zone(self) -> ZoneInfo:
return ZoneInfo(self.timezone)
@dataclass(frozen=True, slots=True)
class MeetingRoom:
id: str
name: str
capacity: int
@dataclass(frozen=True, slots=True)
class Reminder:
"""A reminder that is due now for one occurrence of a meeting."""
meeting_id: str
occurrence: Interval
minutes_before: int
@property
def fires_at(self) -> datetime:
return self.occurrence.start - timedelta(minutes=self.minutes_before)
@dataclass(frozen=True, slots=True)
class RoomBooking:
room_id: str
meeting_id: str
interval: Interval
@dataclass(frozen=True, slots=True)
class RecurrenceRule:
"""``every`` counts periods: DAILY/1 is every day, WEEKLY/2 is every other week."""
frequency: Frequency
every: int = 1
count: int | None = None
until: datetime | None = None
weekdays: tuple[int, ...] = () # 0 = Monday, only used by WEEKLY
def __post_init__(self) -> None:
if self.every < 1:
raise ValidationError("recurrence interval must be at least 1")
if self.count is not None and self.count < 1:
raise ValidationError("recurrence count must be at least 1")
if any(day not in range(7) for day in self.weekdays):
raise ValidationError("weekdays must be 0 (Monday) to 6 (Sunday)")
@dataclass(slots=True)
class Invitation:
meeting_id: str
attendee_id: str
status: InvitationStatus = InvitationStatus.PENDING
responded_at: float | None = None
def respond(self, status: InvitationStatus, at: float) -> None:
if self.status is InvitationStatus.WITHDRAWN:
raise InvitationStateError(f"invitation for {self.meeting_id} was withdrawn")
if status is InvitationStatus.WITHDRAWN:
raise InvitationStateError("use MeetingScheduler.cancel to withdraw an invitation")
self.status = status
self.responded_at = at
def withdraw(self) -> None:
self.status = InvitationStatus.WITHDRAWN
@dataclass(slots=True)
class Meeting:
"""One series. ``start`` carries the organiser's zone, so recurrence is wall-clock."""
id: str
title: str
organizer_id: str
attendee_ids: tuple[str, ...]
start: datetime
duration: timedelta
recurrence: RecurrenceRule | None = None
room_id: str | None = None
status: MeetingStatus = MeetingStatus.SCHEDULED
invitations: dict[str, Invitation] = field(default_factory=dict)
cancelled_dates: set[date] = field(default_factory=set)
moved_dates: dict[date, datetime] = field(default_factory=dict)
@property
def first_interval(self) -> Interval:
return Interval(self.start, self.start + self.duration)
def is_active(self) -> bool:
return self.status is MeetingStatus.SCHEDULED
def blocks(self, attendee_id: str) -> bool:
invitation = self.invitations.get(attendee_id)
return self.is_active() and invitation is not None and invitation.status.blocks_time
The Builder is where the rules live. Everything it accepts is optional until build, which is the single gate.
class MeetingBuilder:
"""Builder: a meeting has eight fields, four of them optional and interdependent.
Every rule lives in ``build``, so there is exactly one place where an invalid
meeting can be born - and the fluent calls stay free of validation noise.
"""
def __init__(self, ids: IdGenerator) -> None:
self._ids = ids
self._title = ""
self._organizer: User | None = None
self._start: datetime | None = None
self._duration: timedelta | None = None
self._attendees: list[str] = []
self._recurrence: RecurrenceRule | None = None
self._room_id: str | None = None
def titled(self, title: str) -> MeetingBuilder:
self._title = title.strip()
return self
def organized_by(self, organizer: User) -> MeetingBuilder:
self._organizer = organizer
return self
def starting_at(self, local: datetime | str, zone: str | None = None) -> MeetingBuilder:
"""Local wall-clock time. Without a zone the organiser's own zone is used."""
moment = datetime.fromisoformat(local) if isinstance(local, str) else local
if moment.tzinfo is None:
if zone is None and self._organizer is None:
raise ValidationError("set the organiser or pass a zone before the start time")
key = zone or (self._organizer.timezone if self._organizer else "UTC")
moment = moment.replace(tzinfo=ZoneInfo(key))
self._start = moment
return self
def lasting(self, minutes: int) -> MeetingBuilder:
self._duration = timedelta(minutes=minutes)
return self
def with_attendees(self, *user_ids: str) -> MeetingBuilder:
self._attendees.extend(user_ids)
return self
def repeating(self, rule: RecurrenceRule) -> MeetingBuilder:
self._recurrence = rule
return self
def in_room(self, room_id: str | None) -> MeetingBuilder:
self._room_id = room_id
return self
def build(self) -> Meeting:
if not self._title:
raise ValidationError("a meeting needs a title")
if self._organizer is None:
raise ValidationError("a meeting needs an organiser")
if self._start is None or self._duration is None:
raise ValidationError("a meeting needs a start time and a duration")
if self._duration <= timedelta(0):
raise ValidationError("a meeting must last longer than zero minutes")
attendees = dict.fromkeys([self._organizer.id, *self._attendees]) # ordered, deduplicated
meeting = Meeting(
id=self._ids.next_id(),
title=self._title,
organizer_id=self._organizer.id,
attendee_ids=tuple(attendees),
start=self._start,
duration=self._duration,
recurrence=self._recurrence,
room_id=self._room_id,
)
meeting.invitations = {
attendee: Invitation(meeting.id, attendee, InvitationStatus.ACCEPTED)
if attendee == self._organizer.id
else Invitation(meeting.id, attendee)
for attendee in meeting.attendee_ids
}
return meeting
Now the part interviewers actually push on. Adding 24 hours to an instant is wrong twice a year; adding one day to the local naive time and re-attaching the zone is right. localise also handles the hour that does not exist on the morning the clocks jump forward.
def localise(naive: datetime, zone: ZoneInfo) -> datetime:
"""Attach a zone to a wall-clock time, stepping over the spring-forward gap.
On the morning the clocks jump, 02:30 local does not exist. Python will happily
build the value and then give it the pre-transition offset, so a round trip
through UTC comes back as a different wall time - that is the test for a gap.
"""
candidate = naive.replace(tzinfo=zone)
round_trip = candidate.astimezone(UTC).astimezone(zone).replace(tzinfo=None)
if round_trip != naive:
candidate = (naive + timedelta(hours=1)).replace(tzinfo=zone)
return candidate
def shifted(first: datetime, days: int) -> datetime:
"""The same wall-clock time, ``days`` later, in the same zone."""
zone = first.tzinfo
if not isinstance(zone, ZoneInfo):
return first + timedelta(days=days)
return localise(first.replace(tzinfo=None) + timedelta(days=days), zone)
class RecurrenceStrategy(Protocol):
"""Expands a series into start times that fall inside a window."""
def starts(self, first: datetime, window: Interval) -> Iterator[datetime]: ...
class NoRecurrence:
def starts(self, first: datetime, window: Interval) -> Iterator[datetime]:
if window.start <= first < window.end:
yield first
class DailyRecurrence:
def __init__(self, rule: RecurrenceRule) -> None:
self._rule = rule
def starts(self, first: datetime, window: Interval) -> Iterator[datetime]:
rule = self._rule
for index in range(MAX_OCCURRENCES):
if rule.count is not None and index >= rule.count:
return
moment = shifted(first, index * rule.every)
if rule.until is not None and moment > rule.until:
return
if moment >= window.end:
return
if moment >= window.start:
yield moment
class WeeklyRecurrence:
"""``weekdays`` empty means "the weekday the series starts on"."""
def __init__(self, rule: RecurrenceRule) -> None:
self._rule = rule
def starts(self, first: datetime, window: Interval) -> Iterator[datetime]:
rule = self._rule
weekdays = sorted(rule.weekdays) or [first.weekday()]
emitted = 0
for week in range(MAX_OCCURRENCES):
monday = shifted(first, -first.weekday() + week * 7 * rule.every)
for weekday in weekdays:
moment = shifted(monday, weekday)
if moment < first:
continue
if rule.until is not None and moment > rule.until:
return
if moment >= window.end or (rule.count is not None and emitted >= rule.count):
return
emitted += 1
if moment >= window.start:
yield moment
def strategy_for(rule: RecurrenceRule | None) -> RecurrenceStrategy:
"""Factory: the rule is data, the expansion is behaviour."""
if rule is None:
return NoRecurrence()
if rule.frequency is Frequency.DAILY:
return DailyRecurrence(rule)
if rule.frequency is Frequency.WEEKLY:
return WeeklyRecurrence(rule)
raise ValidationError(f"unsupported recurrence frequency {rule.frequency}")
Slot finding is a scan over the gaps. WorkingHoursSlotFinder does not reimplement it: it adds each attendee's out-of-hours time as busy and delegates.
class SlotFinder(Protocol):
"""Picks an interval of ``duration`` that clears every busy block in ``window``."""
def find(
self,
attendees: Sequence[User],
busy: list[Interval],
window: Interval,
duration: timedelta,
) -> Interval | None: ...
class EarliestSlotFinder:
"""Merge the busy blocks, then take the first gap that is long enough."""
def find(
self,
attendees: Sequence[User],
busy: list[Interval],
window: Interval,
duration: timedelta,
) -> Interval | None:
for gap in free_gaps(busy, window):
if gap.duration >= duration:
return Interval(gap.start, gap.start + duration)
return None
class WorkingHoursSlotFinder:
"""Same scan, but every attendee's out-of-hours time counts as busy.
Each attendee's working day is in *their* zone, so this is where three people on
three continents discover they have no common hour - and the finder returns None
instead of booking someone's 03:00.
"""
def __init__(self, start_hour: int = 9, end_hour: int = 17) -> None:
if not 0 <= start_hour < end_hour <= 24:
raise ValidationError("working hours must satisfy 0 <= start < end <= 24")
self._start_hour = start_hour
self._end_hour = end_hour
self._inner = EarliestSlotFinder()
def find(
self,
attendees: Sequence[User],
busy: list[Interval],
window: Interval,
duration: timedelta,
) -> Interval | None:
blocks = list(busy)
for attendee in attendees:
blocks.extend(self._off_hours(attendee, window))
return self._inner.find(attendees, blocks, window, duration)
def _off_hours(self, attendee: User, window: Interval) -> list[Interval]:
"""Build this attendee's working days, then treat everything else as busy."""
zone = attendee.zone
working: list[Interval] = []
day = window.start.astimezone(zone).date() - timedelta(days=1)
last = window.end.astimezone(zone).date() + timedelta(days=1)
while day <= last:
opens = localise(datetime.combine(day, time(self._start_hour)), zone)
closes = (
localise(datetime.combine(day, time(self._end_hour)), zone)
if self._end_hour < 24
else localise(datetime.combine(day + timedelta(days=1), time(0)), zone)
)
working.append(Interval(opens, closes))
day += timedelta(days=1)
return free_gaps(working, window)
Availability turns meetings into busy blocks: expand within the window, drop cancelled dates, apply moved ones, merge.
class AvailabilityService:
"""Turns meetings into busy blocks: expand, drop exceptions, merge."""
def __init__(self, meetings: MeetingRepository) -> None:
self._meetings = meetings
def occurrences(self, meeting: Meeting, window: Interval) -> list[Interval]:
"""Every occurrence overlapping ``window``, with cancelled dates removed."""
# Widen the search so a meeting that starts before the window but runs into it counts.
expansion = Interval(window.start - meeting.duration, window.end)
found: list[Interval] = []
for start in strategy_for(meeting.recurrence).starts(meeting.start, expansion):
on = start.astimezone(meeting.start.tzinfo).date()
if on in meeting.cancelled_dates:
continue
moved = meeting.moved_dates.get(on)
actual = moved if moved is not None else start
found.append(Interval(actual, actual + meeting.duration))
return found
def busy(self, user_ids: Sequence[str], window: Interval) -> list[Interval]:
blocks: list[Interval] = []
for user_id in user_ids:
for meeting in self._meetings.for_attendee(user_id):
if meeting.blocks(user_id):
blocks.extend(self.occurrences(meeting, window))
return merge_intervals(blocks)
def free(self, user_ids: Sequence[str], window: Interval) -> list[Interval]:
return free_gaps(self.busy(user_ids, window), window)
def is_free(self, user_ids: Sequence[str], interval: Interval) -> bool:
return not any(block.overlaps(interval) for block in self.busy(user_ids, interval))
The room registry owns the lock that matters. book is all-or-nothing across every occurrence of a series.
class RoomRegistry:
"""Rooms and their bookings. One lock per room: the finest granularity that works.
``book`` is all-or-nothing across every occurrence of a series - a weekly meeting
that clashes on one Tuesday books no Tuesdays at all.
"""
def __init__(self, rooms: Iterable[MeetingRoom]) -> None:
self._rooms: dict[str, MeetingRoom] = {room.id: room for room in rooms}
self._bookings: dict[str, list[RoomBooking]] = {room_id: [] for room_id in self._rooms}
self._locks: dict[str, threading.Lock] = {room_id: threading.Lock() for room_id in self._rooms}
def room(self, room_id: str) -> MeetingRoom:
try:
return self._rooms[room_id]
except KeyError:
raise MeetingNotFoundError(f"unknown room {room_id}") from None
def bookings(self, room_id: str) -> list[RoomBooking]:
with self._locks[room_id]:
return list(self._bookings[room_id])
def book(self, room_id: str, meeting_id: str, intervals: Sequence[Interval]) -> list[RoomBooking]:
room = self.room(room_id)
with self._locks[room_id]:
for existing in self._bookings[room_id]:
clash = next((i for i in intervals if existing.interval.overlaps(i)), None)
if clash is not None:
raise SlotConflictError(f"{room.name} is already booked for {clash}")
fresh = [RoomBooking(room_id, meeting_id, interval) for interval in intervals]
self._bookings[room_id].extend(fresh)
return fresh
def release(self, room_id: str, meeting_id: str) -> int:
with self._locks[room_id]:
before = len(self._bookings[room_id])
self._bookings[room_id] = [b for b in self._bookings[room_id] if b.meeting_id != meeting_id]
return before - len(self._bookings[room_id])
def book_first_available(
self, meeting_id: str, intervals: Sequence[Interval], attendees: int
) -> str | None:
"""Try rooms smallest-that-fits first. Losing a race is a retry, not an error."""
candidates = sorted(
(r for r in self._rooms.values() if r.capacity >= attendees),
key=lambda r: (r.capacity, r.id),
)
for room in candidates:
try:
self.book(room.id, meeting_id, intervals)
except SlotConflictError:
continue
return room.id
return None
The scheduler is a facade: it composes the pieces, holds one lock per meeting for status changes, and announces outside every lock.
class MeetingScheduler:
"""Facade over repository, availability, rooms and notifications.
Locks: one per meeting for status and invitation changes (``_lock_for``), one per
room inside ``RoomRegistry``. Order is always meeting first, then room.
"""
BOOKING_HORIZON_DAYS = 60
def __init__(
self,
meetings: MeetingRepository,
rooms: RoomRegistry,
users: Iterable[User],
observers: Iterable[MeetingObserver] = (),
clock: Clock | None = None,
ids: IdGenerator | None = None,
) -> None:
self._meetings = meetings
self._rooms = rooms
self._users = {user.id: user for user in users}
self._observers = list(observers)
self._clock = clock or SystemClock()
self._ids = ids or SequentialIdGenerator("M")
self._availability = AvailabilityService(meetings)
self._meeting_locks: dict[str, threading.RLock] = {}
self._registry_lock = threading.Lock()
@property
def availability(self) -> AvailabilityService:
return self._availability
@property
def rooms(self) -> RoomRegistry:
return self._rooms
def builder(self) -> MeetingBuilder:
return MeetingBuilder(self._ids)
def user(self, user_id: str) -> User:
try:
return self._users[user_id]
except KeyError:
raise MeetingNotFoundError(f"unknown user {user_id}") from None
def schedule(self, meeting: Meeting) -> Meeting:
"""Book the room for every occurrence in the horizon, store, then announce."""
occurrences = self.horizon_occurrences(meeting)
if meeting.room_id is not None:
room = self._rooms.room(meeting.room_id)
if room.capacity < len(meeting.attendee_ids):
raise ValidationError(
f"{room.name} seats {room.capacity}, meeting has {len(meeting.attendee_ids)} attendees"
)
self._rooms.book(meeting.room_id, meeting.id, occurrences)
self._meetings.add(meeting)
self._announce("invited", meeting)
return meeting
def schedule_first_available(
self,
title: str,
organizer: User,
attendee_ids: Sequence[str],
minutes: int,
window: Interval,
finder: SlotFinder | None = None,
with_room: bool = True,
) -> Meeting:
"""The headline flow: busy blocks, merge, first gap, room, invitations, notify."""
attendees = tuple(dict.fromkeys([organizer.id, *attendee_ids]))
slot = self.find_slot(attendees, minutes, window, finder)
if slot is None:
raise NoFreeSlotError(f"no {minutes}-minute slot for {len(attendees)} people in {window}")
meeting = (
self.builder()
.titled(title)
.organized_by(organizer)
.with_attendees(*attendee_ids)
.starting_at(slot.start.astimezone(organizer.zone))
.lasting(minutes)
.build()
)
if with_room:
room_id = self._rooms.book_first_available(meeting.id, [slot], len(attendees))
if room_id is None:
raise NoFreeSlotError(f"no room free for {slot}")
meeting.room_id = room_id
self._meetings.add(meeting)
self._announce("invited", meeting)
return meeting
def find_slot(
self, attendee_ids: Sequence[str], minutes: int, window: Interval, finder: SlotFinder | None = None
) -> Interval | None:
attendees = [self.user(user_id) for user_id in attendee_ids]
busy = self._availability.busy(attendee_ids, window)
return (finder or EarliestSlotFinder()).find(attendees, busy, window, timedelta(minutes=minutes))
def horizon_occurrences(self, meeting: Meeting) -> list[Interval]:
horizon = Interval(meeting.start, meeting.start + timedelta(days=self.BOOKING_HORIZON_DAYS))
return self._availability.occurrences(meeting, horizon)
def respond(self, meeting_id: str, attendee_id: str, status: InvitationStatus) -> None:
meeting = self._meetings.get(meeting_id)
with self._lock_for(meeting_id):
invitation = meeting.invitations.get(attendee_id)
if invitation is None:
raise MeetingNotFoundError(f"{attendee_id} was not invited to {meeting_id}")
invitation.respond(status, self._clock.now())
self._announce(f"{attendee_id} is {status}", meeting, (meeting.organizer_id,))
def cancel(self, meeting_id: str) -> Meeting:
meeting = self._meetings.get(meeting_id)
with self._lock_for(meeting_id):
meeting.status = MeetingStatus.CANCELLED
for invitation in meeting.invitations.values():
invitation.withdraw()
if meeting.room_id is not None:
self._rooms.release(meeting.room_id, meeting.id)
self._announce("cancelled", meeting)
return meeting
def cancel_occurrence(self, meeting_id: str, on: date) -> Meeting:
"""One Tuesday off, the series intact: the exception list, not a new meeting."""
meeting = self._meetings.get(meeting_id)
with self._lock_for(meeting_id):
meeting.cancelled_dates.add(on)
self._announce(f"occurrence on {on} cancelled", meeting)
return meeting
def move_occurrence(self, meeting_id: str, on: date, new_start: datetime) -> Meeting:
meeting = self._meetings.get(meeting_id)
with self._lock_for(meeting_id):
meeting.moved_dates[on] = new_start
self._announce(f"occurrence on {on} moved", meeting)
return meeting
def reschedule(self, meeting_id: str, new_start: datetime) -> RescheduleCommand:
command = RescheduleCommand(self, self._meetings.get(meeting_id), new_start)
command.execute()
return command
def due_reminders(self, lead_minutes: int = 10) -> list[Reminder]:
now = self._clock.now_dt()
window = Interval(now, now + timedelta(minutes=lead_minutes))
return [
Reminder(meeting.id, occurrence, lead_minutes)
for meeting in self._meetings.all()
if meeting.is_active()
for occurrence in self._availability.occurrences(meeting, window)
if now <= occurrence.start < window.end
]
def move(self, meeting: Meeting, new_start: datetime) -> Meeting:
"""Used by the reschedule command in both directions."""
with self._lock_for(meeting.id):
if meeting.room_id is not None:
self._rooms.release(meeting.room_id, meeting.id)
previous, meeting.start = meeting.start, new_start
if meeting.room_id is not None:
try:
self._rooms.book(meeting.room_id, meeting.id, self.horizon_occurrences(meeting))
except SlotConflictError:
meeting.start = previous
self._rooms.book(meeting.room_id, meeting.id, self.horizon_occurrences(meeting))
raise
self._announce("rescheduled", meeting)
return meeting
def _lock_for(self, meeting_id: str) -> threading.RLock:
with self._registry_lock:
return self._meeting_locks.setdefault(meeting_id, threading.RLock())
def _announce(self, event: str, meeting: Meeting, recipients: Sequence[str] | None = None) -> None:
targets = recipients if recipients is not None else meeting.attendee_ids
for observer in self._observers: # outside every lock: a slow listener blocks nobody
observer.on_meeting_event(event, meeting, targets)
class RescheduleCommand:
"""Command with undo: the organiser moved it by mistake, put it back."""
def __init__(self, scheduler: MeetingScheduler, meeting: Meeting, new_start: datetime) -> None:
self._scheduler = scheduler
self._meeting = meeting
self._new_start = new_start
self._previous_start = meeting.start
def execute(self) -> Meeting:
return self._scheduler.move(self._meeting, self._new_start)
def undo(self) -> Meeting:
return self._scheduler.move(self._meeting, self._previous_start)
python -m lld.meeting_scheduler.demo runs a standup across the March DST switch, two slot searches and a room clash:
M-1 Standup: 09:00 Europe/Berlin daily, 7 in the week
before the DST switch: 2026-03-25 08:00-08:15Z | after it: 2026-03-31 07:00-07:15Z
cancelled 2026-03-27: 6 occurrences left
ada is busy ['2026-03-26 08:00-08:15Z', '2026-03-26 13:00-14:00Z']
first 30 min for ada+linus: 2026-03-26 08:15-08:45Z
inside 09:00-17:00 in Berlin and New York: 2026-03-26 14:00-14:30Z
adding Grace in Kolkata: None
room clash: Turing is already booked for 2026-03-26 13:30-14:00Z
invitations: {'ada': 'accepted', 'linus': 'declined', 'grace': 'tentative'}
rescheduled standup to 2026-03-25 10:00-10:15Z
undo put it back to 2026-03-25 08:00-08:15Z; 16 notifications sent
reminders due at 07:55Z: ['M-1']
Concurrency and edge cases¶
Which lock protects what.
RoomRegistry._locks[room_id]— one lock per room, held across the overlap check and the insert. This is the only hard-enforced conflict in the system, so it is the only place that needs the check-then-act to be atomic. Per room rather than one global lock: two organisers booking two different rooms never wait for each other.MeetingScheduler._meeting_locks[meeting_id]— one per meeting, guarding status, invitations and the exception lists. Two attendees answering at the same instant touch the same dict; the lock makes "cancel the meeting" and "accept the invitation" a total order rather than a coin flip.InMemoryMeetingRepository._lockandNotificationService._lock— the dictionaries themselves.
The ordering rule: meeting lock, then room lock, never the reverse. cancel holds the meeting lock and calls release; nothing holds a room lock and reaches for a meeting.
The race it prevents. Eight threads book the same room for the same hour. Each one reads the booking list, sees no clash, and inserts. Without the lock all eight succeed and the room is eight times booked; with it, one wins and seven get SlotConflictError. book_first_available turns that loss into a retry on the next room, which is the optimistic pattern: never hold a lock while searching, only while claiming.
Half-open intervals. 10:00-11:00 and 11:00-12:00 do not overlap. With closed intervals they do, and you will spend the last ten minutes of the interview explaining why nobody can book the hour after yours. The test parametrises all four boundary cases.
Recurrence bounds. Every expansion takes a window and a hard cap (MAX_OCCURRENCES). A daily meeting with no end date is not a bug in the rule, it is a bug in any code that expands it eagerly. Room bookings use a 60-day horizon for the same reason - a rolling job extends it.
DST. Two things break, and both are in the tests: the 09:00 standup must stay 09:00 across the switch (its UTC instant moves by an hour), and a time that does not exist (02:30 on the spring-forward morning) must be pushed forward rather than silently given the wrong offset. The general rule to state: store instants, schedule wall-clock, convert at the edges.
Editing one occurrence. cancelled_dates removes a single Tuesday and moved_dates shifts one, both keyed by the occurrence's local date; the series stays one row. The honest limitation to name: an occurrence moved out of an expansion window disappears from that window's results, which is why production systems promote a moved occurrence to its own row that points back at the series.
Cost check. Searching a week for ten attendees with twenty meetings each is 200 intervals; merging is one sort, ~200 x log2(200) ≈ 1.5k comparisons. At the estimation cheatsheet's 100 ns main-memory reference that is well under a millisecond of real work - the expansion, not the merge, is what to bound.
Common mistake
Storing recurring meetings as UTC instants plus "add 86400 seconds". It passes every test you write in June and produces 08:00 standups in November. The fix is one line - add days to the local naive time and re-attach the zone - but only if the meeting kept its zone, which is why Meeting.start is aware and in the organiser's zone rather than normalised to UTC on the way in.
Extensibility and follow-ups¶
- Minimum number of rooms for a set of meetings (the "Meeting Rooms II" question): sort starts, push ends onto a heap, pop everything that ended before the next start; the answer is the maximum heap size. Your
Intervalalready has what it needs. - Ranked suggestions instead of the first gap: another
SlotFinderthat scores candidate gaps (fewest attendees dragged out of hours, least fragmentation of the day) and returns the best. Nothing else changes - that is the Strategy paying rent. - Working hours and holidays per user:
WorkingHoursSlotFinderalready reads each attendee's zone; giveUsera working-hours field and a holiday calendar and feed both into_off_hours. - External sync (Exchange, Google): an adapter that maps their event model onto
Meetingplus a sync token; conflicts become last-writer-wins per field or a merge dialogue. - Materialised occurrences: for a series older than the horizon, write rows into an occurrence table so queries become index scans instead of expansions. That is the moment this becomes an HLD conversation about sharding by user and fanning out reminders.
- Reminders at scale: today
due_reminderspolls. At millions of users that becomes a time-bucketed queue, which is the same design as a distributed job scheduler.
Interview tip
Draw the timeline before you write any code: a horizontal line, three attendees' busy blocks stacked, the merged row underneath, the gap you would pick. Then say "merge, then scan the gaps" and write the ten lines. Interviewers grade this problem on whether you found the interval algebra, and a drawing gets you there faster than a class diagram does.
Tests¶
tests/test_meeting_scheduler.py has 12 cases (16 with parametrisation). The boundary cases are parametrised (11:00-12:00 after 10:00-11:00 is not a clash); merge_intervals and free_gaps are checked as inverses on the same input; weekly recurrence expands only the named weekdays; a cancelled occurrence disappears while the series stays active; a declined invitation frees the attendee but not the organiser; a cancelled meeting withdraws invitations, releases the room and refuses further answers; the builder refuses an incomplete meeting three ways.
The two to walk through are DST and the room race:
def test_a_daily_meeting_keeps_local_time_across_the_dst_switch(scheduler: MeetingScheduler) -> None:
standup = scheduler.schedule(
scheduler.builder()
.titled("Standup")
.organized_by(ADA)
.starting_at("2026-03-25 09:00") # 09:00 in Europe/Berlin
.lasting(15)
.repeating(RecurrenceRule(Frequency.DAILY))
.build()
)
week = interval("2026-03-25 00:00", "2026-04-01 00:00")
starts = [o.start.astimezone(UTC).strftime("%m-%d %H:%M") for o in scheduler.availability.occurrences(standup, week)]
assert starts[:2] == ["03-25 08:00", "03-26 08:00"] # CET, UTC+1
assert starts[-2:] == ["03-30 07:00", "03-31 07:00"] # CEST, UTC+2 - same 09:00 local
assert len(starts) == 7
def test_a_wall_clock_time_that_does_not_exist_moves_forward() -> None:
gap = localise(datetime(2026, 3, 29, 2, 30), BERLIN) # the clocks jump 02:00 -> 03:00
assert gap.hour == 3 and gap.minute == 30
normal = localise(datetime(2026, 3, 30, 2, 30), BERLIN)
assert normal.hour == 2 and normal.minute == 30
def test_only_one_of_many_racing_bookings_gets_the_room(scheduler: MeetingScheduler) -> None:
slot = interval("2026-03-26 13:00", "2026-03-26 14:00")
def grab(i: int) -> str | None:
try:
scheduler.rooms.book("R-1", f"M-{i}", [slot])
except SlotConflictError:
return None
return f"M-{i}"
with ThreadPoolExecutor(max_workers=8) as pool:
winners = [name for name in pool.map(grab, range(8)) if name is not None]
assert len(winners) == 1 # the per-room lock decides, everybody else is told no
assert len(scheduler.rooms.bookings("R-1")) == 1
The reschedule test is worth a mention because it asserts on the room, not the meeting: after execute the booking has moved, after undo it is back where it started. Run everything with uv run pytest code/lld/meeting_scheduler -q.
45-minute pacing¶
| Minutes | What to do | What to say or write |
|---|---|---|
| 0-5 | Clarify | Half-open or closed? Do pending invitations block time? Recurrence in scope? Timezones? Out of scope: external sync, video links. |
| 5-10 | Entities and the timeline | Draw busy blocks, the merged row and the gap. Name the entities: Interval, Meeting, Invitation, Room, RoomBooking. |
| 10-16 | Class diagram | Meeting with a rule and two exception collections; the two Strategy families; where the locks live. |
| 16-26 | Interval algebra | Write overlaps, merge_intervals, free_gaps and the earliest-slot scan. This is the core and it should be on the board by minute 26. |
| 26-34 | Recurrence and DST | Expand within a window, drop exceptions, and say "wall-clock, not UTC arithmetic". Write shifted. |
| 34-40 | Rooms and concurrency | Per-room lock, all-or-nothing booking, the eight-thread test, the lock order rule. |
| 40-45 | Extensions | Minimum rooms with a heap, ranked suggestions as a strategy, materialised occurrences and the hand-off to HLD. |
Related¶
- Builder — the fluent
MeetingBuilderand its single validation gate - Strategy — recurrence expansion and slot finding
- Observer — notifications without the scheduler knowing its listeners
- Design a hotel management system — the same overlapping-range problem with rooms and nights
- Design a distributed job scheduler — where reminders go when there are millions of them
- RFC 5545 (iCalendar) — the primary source for recurrence rules and floating vs absolute time