ADR 006: ICS Two-Way Sync via Polling Worker¶
Date: 2026-03-22 Status: Accepted
Note: Retrospective ADR written 2026-04-26. The pattern landed across plans 013 (ICS export, 2026-02-20), 014 (negative export, 2026-02-26), 015 (ICS import, 2026-02-28 → 2026-03-11), and 017 (two-way sync, 2026-03-22 / commit cf0b9d1). The integration approach was chosen up-front in docs/research/calendars/ics-integration-research.md (2026-02-19); this ADR consolidates the rationale for the worker-based runtime.
Context¶
Partners list on multiple platforms (Airbnb, Booking.com, GetYourGuide) and manage personal commitments in Google Calendar / Outlook. A booking on PO that doesn't reflect in the partner's external calendars — and an external commitment that doesn't block PO availability — produces double-bookings.
Three approaches were on the table (research doc §3):
| Approach | Direction | Mechanism | Real-time? |
|---|---|---|---|
| ICS Export (read-only feed) | PO → external | Static .ics URL |
No (consumer polls) |
| ICS Import (one-shot bootstrap) | external → PO | Manual upload / fetch | One-time |
| ICS Two-Way Polling | both | Background worker polls external feeds | No (~minutes) |
| Direct API (OAuth) | both | Google Calendar API + webhooks | Yes |
Direct API integration via OAuth (the Calendly approach) gives the best UX but adds OAuth flows, per-provider client registrations, refresh-token storage, webhook receivers, and per-provider quirks (Microsoft Graph vs Google Calendar vs Apple). The platform's partner persona — small-to-medium Portuguese tourism businesses, many already comfortable with Airbnb/Booking ICS feeds — does not yet justify that effort.
Decision¶
Adopt ICS over polling as the cross-platform sync mechanism, in three phases that compose:
- Phase 1 — Negative ICS Export (plans 013, 014). PO publishes a per-service / per-template tokenized ICS feed at a stable URL. The "negative" strategy emits
OPAQUEVEVENTblocks for unavailable time so external calendars treat PO availability as busy time. Tokens are revocable UUIDs; results are cached per(token_id, range:strategy)with invalidation hooks on template / binding / exception changes. - Phase 2 — ICS Import (plan 015). Partners upload
.icsfiles or provide a remote URL. The parser handles RFC 5545 line unfolding, RRULE decomposition, EXDATE, TZID extraction, and TRANSP→AVAILABLE/UNAVAILABLE mapping. Import is preview-then-confirm so partners cherry-pick rules. - Phase 3 — Two-Way Sync (plan 017). A
SyncWorkerService(@nestjs/schedulecron, every minute) polls registered external connections, diffs events against thesynced_external_eventtable byUID+ content hash, and creates / updates / deletes partner-scopedUNAVAILABLEexceptions accordingly.
Architecture for Phase 3¶
SyncWorkerService (@Cron EVERY_MINUTE)
↓ raw ICS text (HTTP GET with ETag/If-Modified-Since)
SyncDiffService (pure logic, no I/O)
↓ { toCreate, toUpdate, toDelete }
ExceptionsService + SyncService (DB writes)
Key choices within the worker¶
- UID-based diff: every VEVENT carries a unique
UID(RFC 5545). New events have a UID we don't track; updates have a matching UID with a different content-hash; deletions have a UID we track but the feed dropped. - Content hash: SHA-256 prefix of
DTSTART + DTEND + SUMMARY + isAllDay. Detects meaningful changes; ignores DESCRIPTION cosmetics. - Conditional GET: ETag / If-Modified-Since on every poll. Most polls return 304 Not Modified, saving bandwidth and CPU at the every-minute cadence.
- Sync window: today → today + 90 days. Historical events ignored; far-future events deferred to subsequent polls.
- Concurrency guard: in-memory
Set<connectionId>prevents overlapping polls on slow feeds. - Auto-deactivation: 5 consecutive failures → connection deactivated. Re-activation is an explicit API call.
- TRANSPARENT events filtered out per RFC 5545 (
TRANSP:TRANSPARENTmeans "this doesn't block time"). - Partner-level scope (not per-service): one Google Calendar connection blocks across all the partner's services via the existing layered availability model from ADR-001.
- Cascade deletes: removing a connection cascades to
synced_external_eventand to itscalendar_exceptionrows via FK CASCADE.
Why ICS over channel-manager APIs¶
The research doc surveyed Airbnb / Booking.com / GetYourGuide. Channel managers (Bókun, Regiondo, Palisis) provide richer real-time sync but require commercial agreements and per-channel onboarding the platform isn't ready for. ICS is the universal lowest-common-denominator: every relevant external platform exports an ICS feed; every relevant calendar app subscribes to one. The trade is freshness (minutes-to-hours latency) for breadth (works with everything from day one).
Consequences¶
Positive¶
- Universal compatibility: any partner whose external system can export
.ics(Airbnb, Booking, Google Calendar, Outlook, Apple, Calendly, etc.) is a viable connection. - No OAuth coordination: no per-provider client registrations, no refresh-token storage, no webhook receivers.
- Pure-logic diff:
SyncDiffServiceis I/O-free, fully unit-testable. - Bounded operational cost: ETag caching keeps the polling-every-minute cadence cheap; auto-deactivation prevents dead-feed amplification.
- Composable phases: export-only worked alone (plan 013-014); import-only worked alone (plan 015); two-way (017) layered on without rewriting the engine.
Negative¶
- Latency floor of one minute at best, more in practice (Google Calendar's ICS endpoint is itself stale by minutes-to-hours). Not suitable for high-frequency booking flows.
- One-occurrence-only for recurring events. Full RRULE expansion was deferred — currently only the first occurrence of a recurring VEVENT becomes an exception. Partners with truly recurring blocked time need to confirm coverage manually.
- Polling at minute cadence is wasteful when most polls return 304. Mitigated by ETag, but still real load on the calendar-service at scale (per-connection cron).
- No real-time push: if Google or Microsoft adds CalDAV-push or webhook subscriptions later, the architecture allows a webhook receiver alongside the poller — but that's net-new code.
- Token revocation responsibility falls on partners: a leaked feed-token URL exposes availability data until the partner regenerates it. Tokens are unauth'd-by-design (so any calendar app can subscribe).
References¶
services/calendar-service/src/modules/sync/—SyncWorkerService,SyncDiffService,SyncServiceservices/calendar-service/src/modules/ics/— RFC 5545 parser/generatordocs/research/calendars/ics-integration-research.md— full landscape study (2026-02-19)docs/implementation-plans/017-two-way-ics-sync/human/explanation.md— design decisions for Phase 3 specifically- ADR-001 — the layered availability model that exception writes feed into