MidПрактика15 min

Google Calendar

Проектирование сервиса календарей: события, RRULE, timezone, invitations, напоминания, free/busy, CalDAV

Календарь выглядит обманчиво простым: "таблица events". Но как только появляются повторяющиеся события, часовые пояса, приглашения с ответами и напоминания на 10 минут до начала -- инженерная сложность резко растёт. Разберём как построить такой сервис.

Функциональные требования

  1. CRUD событий: название, время начала/конца, location, описание, color.
  2. Participants: приглашать других пользователей, ждать ответов (accept/decline/maybe).
  3. Recurrence: повторяющиеся события (RRULE по RFC 5545 -- "каждый вторник", "первый понедельник месяца").
  4. Исключения из серии: "каждый вторник, кроме 23 апреля" или "23 апреля -- другое время".
  5. Reminders: push / email за N минут до события.
  6. Timezone-aware: событие в Нью-Йорке видно корректно из Москвы.
  7. Shared calendars: "календарь команды", с подпиской.
  8. Free/busy query: "найди 30 минут свободных у троих в ближайшие 3 дня".
  9. CalDAV для interop с Apple Calendar, Outlook.

Нефункциональные требования

  1. Correctness: timezone/DST handling -- нет ошибок.
  2. Latency: <100 ms на день просмотра, <500 ms на месяц с сотнями событий.
  3. Scale: 1B users, 10 events/user/month -> 10B events/month, ~3800 writes/sec avg, 20K peak.
  4. Availability: 99.95%.
  5. Reliability: reminders не теряются. "At-least-once" delivery, идемпотентно на клиенте.

Оценки нагрузки

  • Пользователей: 1B total, 300M DAU.
  • Event insert: 10B/month = 4K/sec avg, 20K peak.
  • Event read (view calendar): 300M * 5 views/day = 1.5B/day, ~17K/sec avg, 100K peak.
  • Reminders: каждое событие в среднем 1.5 напоминания -> 15B reminders/month, 6K/sec avg, 50K peak (утренние события).
  • Storage: event ~500 байт + participants ~100 байт * 5 = 1 KB. 10B/month * 1 KB * 24 months retention = 240 TB горячих данных.
  • Free/busy index: ~200 байт на событие, 240 TB / 5 = 48 TB.

High-Level Architecture

  ┌─────────┐      ┌─────────────────┐         ┌───────────────────┐
  │ Клиент  │ ───► │  API Gateway    │ ──────► │  Event Service    │
  │ (web,   │      │  (auth,         │         │  (CRUD, RRULE,    │
  │  mobile,│      │   rate limit,   │         │   tz conversion)  │
  │  CalDAV)│      │   CalDAV impl.) │         └─────────┬─────────┘
  └─────────┘      └─────────────────┘                   │
                                                         │
                                                         ▼
                                              ┌──────────────────┐
                                              │  PostgreSQL      │
                                              │  (events, RRULE, │
                                              │   partitioned by │
                                              │   user+month)    │
                                              └──────┬───────────┘
                                                     │
                              ┌──────────────────────┼──────────────────────┐
                              ▼                      ▼                      ▼
                     ┌──────────────┐        ┌──────────────┐        ┌──────────────┐
                     │ Invitation   │        │ Reminder     │        │ Free/Busy    │
                     │ Service      │        │ Scheduler    │        │ Index        │
                     │ (ACL, RSVP,  │        │ (time-bucket │        │ (per-user    │
                     │  mail send)  │        │  queue)      │        │  materialized│
                     └──────────────┘        └──────┬───────┘        │  intervals)  │
                                                    │                └──────────────┘
                                                    ▼
                                           ┌──────────────┐
                                           │ Notifier     │
                                           │ (push, email)│
                                           └──────────────┘

API дизайн

POST   /api/v1/events
GET    /api/v1/events/{id}
PATCH  /api/v1/events/{id}
DELETE /api/v1/events/{id}
GET    /api/v1/calendars/{cid}/events?from=&to=
GET    /api/v1/freebusy?users=a,b,c&from=&to=
POST   /api/v1/events/{id}/rsvp        body: {status: accepted|declined|maybe}
<?php

declare(strict_types=1);

final readonly class CreateEventRequest
{
    /**
     * @param list<string> $attendees   // emails
     * @param list<int>    $remindersMinutes
     */
    public function __construct(
        public string $title,
        public \DateTimeImmutable $startsAt,      // in UTC
        public \DateTimeImmutable $endsAt,        // in UTC
        public string $timezone,                  // IANA id, e.g. "Europe/Moscow"
        public ?string $location = null,
        public ?string $description = null,
        public ?string $rrule = null,             // e.g. "FREQ=WEEKLY;BYDAY=TU;UNTIL=20260901T000000Z"
        public array $attendees = [],
        public array $remindersMinutes = [10],
    ) {}
}

final readonly class EventDTO
{
    public function __construct(
        public string $id,
        public string $title,
        public \DateTimeImmutable $startsAt,
        public \DateTimeImmutable $endsAt,
        public string $timezone,
        public ?string $rrule,
        public ?string $recurrenceId,   // если это override серии
        public ?string $masterId,        // ссылка на родителя (для overrides)
    ) {}
}
## Схема данных
CREATE TABLE calendars (
    id          UUID PRIMARY KEY,
    owner_id    UUID NOT NULL,
    name        TEXT NOT NULL,
    timezone    TEXT NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Master events (single or recurring master).
CREATE TABLE events (
    id            UUID PRIMARY KEY,
    calendar_id   UUID NOT NULL,
    owner_id      UUID NOT NULL,
    title         TEXT NOT NULL,
    starts_at     TIMESTAMPTZ NOT NULL,   -- UTC absolute
    ends_at       TIMESTAMPTZ NOT NULL,
    timezone      TEXT NOT NULL,          -- IANA tz
    rrule         TEXT,                   -- e.g. FREQ=WEEKLY;...
    exdates       TIMESTAMPTZ[],          -- removed occurrences
    location      TEXT,
    description   TEXT,
    master_id     UUID,                   -- for instance overrides
    recurrence_id TIMESTAMPTZ,            -- original occurrence timestamp
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at    TIMESTAMPTZ NOT NULL DEFAULT now()
) PARTITION BY RANGE (starts_at);

CREATE INDEX idx_events_cal_time ON events (calendar_id, starts_at);
CREATE INDEX idx_events_master ON events (master_id) WHERE master_id IS NOT NULL;

CREATE TABLE attendees (
    event_id    UUID NOT NULL,
    user_id     UUID NOT NULL,
    status      SMALLINT NOT NULL,      -- 1=pending, 2=accepted, 3=declined, 4=tentative
    role        SMALLINT NOT NULL,      -- 1=organizer, 2=required, 3=optional
    responded_at TIMESTAMPTZ,
    PRIMARY KEY (event_id, user_id)
);

-- Reminder queue. Partitioned by fire_at (15-минутные бакеты).
CREATE TABLE reminders (
    id           UUID PRIMARY KEY,
    event_id     UUID NOT NULL,
    user_id      UUID NOT NULL,
    fire_at      TIMESTAMPTZ NOT NULL,
    delivered    BOOLEAN NOT NULL DEFAULT FALSE
) PARTITION BY RANGE (fire_at);

CREATE INDEX idx_reminders_fire ON reminders (fire_at) WHERE NOT delivered;

-- Free/busy materialized per user для быстрых запросов.
CREATE TABLE free_busy_intervals (
    user_id    UUID NOT NULL,
    starts_at  TIMESTAMPTZ NOT NULL,
    ends_at    TIMESTAMPTZ NOT NULL,
    event_id   UUID NOT NULL,
    PRIMARY KEY (user_id, starts_at, event_id)
) PARTITION BY RANGE (starts_at);
CREATE INDEX idx_fb_user_time ON free_busy_intervals (user_id, starts_at);

Events партиционируются по starts_at (месяц на партицию). Старые партиции -- cold storage.

Ключевые компоненты

1. Timezones и DST

Событие "Совещание в 10:00 по Москве каждую пятницу". Хранить нужно два атрибута:

  • wall-clock time в локальной tz (10:00) + tz ("Europe/Moscow");
  • или UTC absolute time + tz.

Google/Apple рекомендуют local + tz для recurring (иначе DST ломает серию: после перехода часов "10:00" может стать "11:00" UTC, а должно остаться "10:00" local). Для single-occurrence -- UTC достаточно.

На практике хранят оба: starts_at TIMESTAMPTZ (absolute) + timezone TEXT (IANA id). При RRULE expansion применяем RRULE в local tz, потом конвертируем в UTC.

2. RRULE

RFC 5545. Примеры:

FREQ=DAILY;INTERVAL=2;COUNT=10             # каждые 2 дня, 10 раз
FREQ=WEEKLY;BYDAY=MO,WE,FR                 # пн/ср/пт
FREQ=MONTHLY;BYDAY=1MO                     # первый пн месяца
FREQ=YEARLY;BYMONTH=12;BYDAY=4TH           # 4-й четверг декабря

Два варианта хранения occurrences:

А. Virtual (on-demand): храним только master + RRULE. При запросе календаря разворачиваем RRULE -> список occurrences в интервале.

  • Плюсы: компактно, можно менять серию централизованно.
  • Минусы: expansion каждый раз стоит CPU; overrides (exception instances) нужно хранить отдельно и "overlay" поверх.

Б. Materialized: при создании серии сразу генерируем все occurrences в events.

  • Плюсы: просто выборка по диапазону.
  • Минусы: серия "каждый день навсегда" -- бесконечно. Меняешь серию -- надо update всех.

Используют гибрид: virtual + materialized на ближайшие 2 года для быстрых запросов и free/busy. Редкий запрос "что у меня через 5 лет?" развернёт RRULE on-demand.

<?php

declare(strict_types=1);

use Recurr\Rule;
use Recurr\Transformer\ArrayTransformer;
use Recurr\Transformer\Constraint\BetweenConstraint;

final class RruleExpander
{
    public function expand(
        \DateTimeImmutable $masterStart,
        string $rrule,
        string $timezone,
        \DateTimeImmutable $from,
        \DateTimeImmutable $to,
    ): array {
        $tz = new \DateTimeZone($timezone);
        // Recurr operates on DateTime instances in a specific timezone.
        $startInTz = $masterStart->setTimezone($tz);

        $rule = new Rule($rrule, \DateTime::createFromImmutable($startInTz), null, $timezone);
        $transformer = new ArrayTransformer();

        $constraint = new BetweenConstraint(
            \DateTime::createFromImmutable($from),
            \DateTime::createFromImmutable($to),
            true,
        );

        $occurrences = $transformer->transform($rule, $constraint);

        $results = [];
        foreach ($occurrences as $o) {
            $results[] = \DateTimeImmutable::createFromMutable($o->getStart());
        }
        return $results;
    }
}
### 3. Instance overrides

Пользователь сдвинул один экземпляр серии: "23 апреля -- не 10:00, а 12:00". В базе появляется дополнительная запись:

  • master_id = ID серии;
  • recurrence_id = original time (10:00 UTC 23 апреля);
  • starts_at = new time (12:00 UTC 23 апреля);
  • exdates в мастере добавляет original time (чтобы не показывать исходный).

При expansion: разворачиваем RRULE минус exdates + оverrides с master_id=master.

4. Invitations и RSVP

Когда создаётся event с attendees:

  1. Event вставляется в календарь организатора.
  2. На каждого attendee создаётся запись в attendees (event_id, user_id, status=pending).
  3. Отправляется email/push "You're invited".
  4. Копия события автоматически появляется в календарях attendees.
  5. Когда attendee нажимает "accept" -> UPDATE attendees ... SET status=2; событие остаётся.
  6. "Decline" -> status=3; UI может скрыть или показать как "declined".

5. Reminders

Масштабный background job. Механика:

  • При создании события insert в reminders (event_id, user_id, fire_at, delivered=false).
  • Таблица партиционирована по 15-минутным bucket'ам.
  • Воркер раз в минуту берёт следующий бакет, делает SELECT ... WHERE fire_at <= now() AND NOT delivered LIMIT 10000, отправляет push/email, отмечает delivered=true.
  • Идемпотентность: если воркер упал после отправки, но до update -- отправим дубль. Клиент дедуплицирует по event_id + fire_at.

Для масштаба 50K fires/sec: шардируем воркеры по hash(user_id) % N, каждый обрабатывает свой shard.

<?php

declare(strict_types=1);

final class ReminderWorker
{
    public function __construct(
        private readonly \PDO $db,
        private readonly Notifier $notifier,
        private readonly LoggerInterface $logger,
    ) {}

    public function runBatch(int $shardId, int $shardCount): int
    {
        $stmt = $this->db->prepare(<<<SQL
            SELECT id, event_id, user_id, fire_at
            FROM reminders
            WHERE fire_at <= now()
              AND NOT delivered
              AND hashtext(user_id::text) % :shards = :shard
            ORDER BY fire_at
            LIMIT 5000
            FOR UPDATE SKIP LOCKED
        SQL);
        $stmt->execute(['shards' => $shardCount, 'shard' => $shardId]);
        $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC);

        $count = 0;
        foreach ($rows as $r) {
            try {
                $this->notifier->send($r['user_id'], $r['event_id']);
                $this->db->prepare('UPDATE reminders SET delivered=true WHERE id = :id')
                    ->execute(['id' => $r['id']]);
                $count++;
            } catch (\Throwable $e) {
                $this->logger->error('reminder failed', ['id' => $r['id'], 'err' => $e->getMessage()]);
            }
        }
        return $count;
    }
}
### 6. Free/busy

Запрос "когда все трое свободны в ближайшие 3 дня":

  1. Для каждого user_id вытащить free_busy_intervals в запрошенном диапазоне.
  2. Собрать union busy.
  3. Найти "gaps" длиной >= requested duration.
  4. Вернуть первые N.

Материализованная free_busy_intervals позволяет не ходить в events + не разворачивать RRULE каждый раз. Поддерживается insert/update триггерами / async worker'ом.

package freebusy

import (
    "context"
    "database/sql"
    "sort"
    "time"
)

type Interval struct {
    Start time.Time
    End   time.Time
}

// FindSlots returns available slots of given duration that are free for all users.
func FindSlots(ctx context.Context, db *sql.DB, users []string, from, to time.Time, duration time.Duration) ([]Interval, error) {
    busy := make([]Interval, 0)
    for _, u := range users {
        rows, err := db.QueryContext(ctx,
            `SELECT starts_at, ends_at FROM free_busy_intervals
             WHERE user_id = $1 AND ends_at > $2 AND starts_at < $3`,
            u, from, to)
        if err != nil {
            return nil, err
        }
        for rows.Next() {
            var iv Interval
            if err := rows.Scan(&iv.Start, &iv.End); err != nil {
                rows.Close()
                return nil, err
            }
            busy = append(busy, iv)
        }
        rows.Close()
    }

    // Merge overlapping busy intervals.
    sort.Slice(busy, func(i, j int) bool { return busy[i].Start.Before(busy[j].Start) })
    merged := make([]Interval, 0, len(busy))
    for _, iv := range busy {
        if n := len(merged); n > 0 && !iv.Start.After(merged[n-1].End) {
            if iv.End.After(merged[n-1].End) {
                merged[n-1].End = iv.End
            }
            continue
        }
        merged = append(merged, iv)
    }

    // Find gaps of >= duration in [from, to].
    slots := make([]Interval, 0)
    cursor := from
    for _, iv := range merged {
        if iv.Start.After(cursor) && iv.Start.Sub(cursor) >= duration {
            slots = append(slots, Interval{Start: cursor, End: iv.Start})
        }
        if iv.End.After(cursor) {
            cursor = iv.End
        }
    }
    if to.Sub(cursor) >= duration {
        slots = append(slots, Interval{Start: cursor, End: to})
    }
    return slots, nil
}
### 7. CalDAV

CalDAV -- протокол над WebDAV для синхронизации календарей между клиентами (Apple Calendar, Outlook, Thunderbird). API Gateway реализует набор методов (PROPFIND, REPORT, PUT, DELETE) и возвращает события как VCALENDAR/ICS. Внутри мы transform'им между VCALENDAR <-> наш EventDTO. Реализовать полный CalDAV -- большой проект, поэтому обычно берут готовую библиотеку (sabre/dav для PHP).

Масштабирование и bottlenecks

Bottleneck 1: month view query

Пользователь открывает календарь на месяц с серией "каждый день по 3 раза" -> разворачиваем 90 occurrences + миксуем overrides. На 10 пользователях в team view -- тысячи occurrences. Решения:

  • материализация occurrences на ближайший год;
  • кэширование месячного view в Redis на 60 секунд;
  • incremental render на клиенте.

Bottleneck 2: reminder accuracy

Если воркеры отстают на 5 минут -- напоминание "за 10 минут" придёт только за 5. Решение: slot-based queue (bucket = 15s), воркеры берут свой shard + bucket. Утром (7-10 AM) пиковая нагрузка -> масштабируем worker pool в 5x.

Bottleneck 3: partition management

Events по месяцам -- старые партиции через 24 месяца переносим в cold storage (S3 + read-through). Новые автоматически создаются pg_partman.

Bottleneck 4: free/busy across many calendars

Team calendar с 1000 members -> 1000 * 100 events = 100K events. Аггрегация real-time дорого. Решение: per-user pre-computed free_busy, а team free/busy -- union с LIMIT по времени.

Trade-offs и альтернативы

Решение Плюс Минус
Virtual RRULE Компактно, гибко CPU на каждый read
Materialized occurrences Быстрые read Storage, update-cascade
Гибрид (2y materialized) Лучший баланс Сложнее логика
Store UTC + tz Правильный DST Всегда нужна конверсия
Store local + tz Recurring стабильны Сложнее timezone queries
Reminder bucket table Масштабируется Много партиций
Reminder via RabbitMQ Native delayed Зависимость от брокера
Per-user free/busy Быстрые групповые запросы Дублирование данных
On-the-fly free/busy Нет дублей Медленнее

Альтернативный storage -- Cassandra/ScyllaDB для events с partition key = user_id. Масштабируется проще Postgres, но транзакционность теряем (RSVP + invitation cascade сложнее).

Reminder delivery можно доверить external: AWS EventBridge Scheduler, GCP Cloud Tasks. Сокращает сопровождение, но привязывает к cloud.

Выводы

Календарь -- пример где timezone / DST сложнее, чем кажется: хранить именно local time для recurring, applying RRULE в той же tz. RRULE expansion -- дорогая операция, поэтому материализуем ближайший горизонт и компенсируем overrides отдельной таблицей. Reminders делаем через time-bucketed queue, что даёт и масштаб, и точность. Free/busy выносим в отдельный materialized index -- группа из 10 человек не должна порождать 10 разворачиваний серий. Всё остальное (invitations, RSVP, sharing) -- стандартные приёмы CRUD + push-уведомлений.