Building a calendar sync you can trust to run unattended

Building a calendar sync you can trust to run unattended

· 1,052 words · 5 minutes reading time

Building a calendar sync you can trust to run unattended

Post 1 set the bar for this tool: it has to be safe to run on a schedule, forever, without anyone watching it. Post 2 covered getting the data out of Arbor. This post is stage 2 — Sync-OutlookCalendars.ps1 and the TimetableSync module — turning events.json into writes against Microsoft Graph without ever touching an event it doesn't own.

Owning only what you created

The entire safety model rests on one thing: every event this tool creates carries an open extension named timetable-calendar-generator. When it reads a mailbox back, it filters for exactly that:

start/dateTime gt '...' and start/dateTime lt '...' and extensions/any(f:f/id eq 'timetable-calendar-generator')

Nothing outside that filter — a user's own meeting, a lunch reservation, an event from before the sync window — is ever in scope for deletion. The tool literally cannot see it. This is what makes "reconcile against desired state" safe instead of terrifying: the blast radius is bounded to the tool's own tagged events, inside the sync window, full stop.

The diff key

Reconciliation needs a way to say "is this desired event already present." A naive approach — comparing full event objects — makes trivial differences (someone retitles an event, whitespace changes) look like a delete-and-recreate every night. Instead, each event reduces to a key:

"$title|$start|$end|$location"

with one deliberate wrinkle: the title is truncated at the first -. Arbor titles like Maths: Y10Ma/1 can pick up manual suffixes after a dash, and those shouldn't cause the tool to think the event changed. Two full snapshots — what Arbor says should exist, what's currently tagged in the mailbox — get reduced to sets of these keys, and the diff is just set arithmetic: keys desired but absent get added, keys present but not desired get deleted, everything else is left alone.

Duplicate keys are handled the same way in both directions: for desired events, a Group-Object on the key keeps only the first (a defensive de-dupe on Arbor's side); for existing events, if a key has more than one tagged event, every copy past the first is queued for deletion. That's what cleans up duplicates from the next quirk.

Categories and reminders come from the title, not from Arbor

Arbor doesn't tell you "this is a duty" versus "this is a lesson" versus "this is a meeting" in a structured field — that distinction lives in how the event is titled. So category assignment is keyword matching against the title (duty, duties → Duty; meet, line management, brief, mentor, lmm, review, planning, discuss → Meeting; everything else → Timetable), with a reminder switched on specifically for duty events. The three master categories (Timetable/preset2, Duty/preset10, Meeting/preset4) get created on a mailbox the first time the tool touches it, if they're not already there.

This is inherently a bit fragile — it only works as well as the school's own timetable naming conventions — but it means zero configuration per event type, and it matches the categorisation scheme staff were already used to from makecal.

Batching and the 503-that-isn't-a-failure

Writes go through Microsoft Graph's $batch endpoint, four requests per batch — that's the concurrency limit per mailbox, not an arbitrary tuning choice. Batch requests get the same exponential-backoff retry as reads, and honour a Retry-After header when Graph sends one.

One specific failure mode drove the shape of the whole sync loop: Graph sometimes returns a 503 for an event creation that actually succeeded anyway. Retrying a "failed" request that wasn't really a failure means the event gets created twice. Rather than trying to make error handling smart enough to detect this in the moment (there isn't a reliable signal for it), the fix is structural: the whole read-diff-write cycle runs as a loop, up to five passes, and only stops early once a pass finds nothing left to add. A duplicate created by a false 503 on pass 1 simply gets caught by the diff on pass 2 and deleted. The same idea handles DELETE calls that 404 — Graph's eventual consistency can show an event as still existing on a pass after an earlier delete already removed it, so a 404 on delete is treated as success, not an error, since the desired end state (event gone) is already true.

Idempotency as the actual test

None of the above matters if running the sync twice in a row does anything at all. That's the property the whole design optimises for, and it's also the simplest possible test: run it, run it again, and the second run should report zero adds and zero deletes. If it doesn't, something in the key derivation or the tagging is wrong — and that's a much easier bug to reason about than "why did this user's calendar end up with a duplicate event."

Parallel, but no differently

25 mailboxes sync concurrently (ForEach-Object -Parallel, each runspace with its own Graph connection), because doing users one at a time against a tenant with a few hundred staff would take too long to run nightly. But it's parallel users, not parallel logic — each mailbox goes through exactly the same read-diff-write- retry cycle described above, independently. Concurrency is a property of the outer loop, not something the reconciliation logic has to know about.

Watching it work

-ShowChanges prints only the add/delete lines per mailbox — what actually moved — and -ShowEvents prints everything, including the events left untouched, which is the fastest way to convince yourself (or a colleague) that the tool really is leaving unrelated events alone. Both were built for exactly the kind of trust-but- verify checking this series has been about: not "does the code look right," but "can I watch it, on a real mailbox, and confirm it only ever touches what it's supposed to."

That's the three-part shape of this project: an idea borrowed from prior art (post 1), a data source that took real digging to use correctly (post 2), and a reconciliation engine whose entire design is in service of one property — safe to run unattended, forever.

The full source — Get-ArborTimetable.ps1, Sync-OutlookCalendars.ps1, and the TimetableSync module — is up on GitHub at REPO_NAME, MIT licensed. Issues and PRs welcome, especially from anyone hitting Arbor quirks we haven't.