Building a calendar sync you can trust to run unattended
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.