From c777dc4089c13911ddcec473ff4ce6310db607ef Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 27 Sep 2026 07:01:24 +0200 Subject: [PATCH] fix: keep syncing a big meeting after it was edited on the phone #34 After an edit on the phone, every write into a recurring event asserted each attendee and reminder row of every occurrence. For a meeting with 25 attendees and 52 occurrences with details of their own that alone made the write 1.6 MB, over Android's 1 MB transaction limit: the event could no longer be written, went stale in every run and kept its edit. Only rows that are dirty themselves now assert their attendees and reminders. A clean row's DIRTY flag already catches an app edit of them, since CalendarProvider sets it on every app write to an event, its attendees or reminders, so such a write still fails and is planned again. The same meeting's writes now take about 0.65 MB. Checked with the meeting above through the planner, and with an app edit of a clean occurrence's attendees between planning and writing. --- .../__tests__/calendar/big-events.test.ts | 90 +++++++++++++++++++ src/device-sync/calendar/merge.ts | 11 +-- src/device-sync/calendar/planner.ts | 5 +- 3 files changed, 99 insertions(+), 7 deletions(-) create mode 100644 src/device-sync/__tests__/calendar/big-events.test.ts diff --git a/src/device-sync/__tests__/calendar/big-events.test.ts b/src/device-sync/__tests__/calendar/big-events.test.ts new file mode 100644 index 00000000..d367ab0f --- /dev/null +++ b/src/device-sync/__tests__/calendar/big-events.test.ts @@ -0,0 +1,90 @@ +// Big meetings (many attendees on many overrides): a write into such an event +// has to fit one provider transaction (Binder: about 1 MB; a Parcel carries +// the JSON as UTF-16, estimated at 2 bytes per character plus ~300 per op). +import { describe, expect, it } from 'vitest'; +import { Attendees, Events } from '../../android-columns'; +import { calendarPlanner } from '../../calendar/planner'; +import type { OpGroup } from '../../planner'; +import type { CalendarEventWire } from '../../wire'; +import { Harness, ME, serverApply } from './harness'; + +const TRANSACTION = 1_000_000; +const estimate = (group: OpGroup) => 2 * JSON.stringify(group.ops).length + 300 * group.ops.length; + +/** A daily meeting with `attendees` guests whose `overrides` occurrences each carry the whole participants map. */ +function meeting(attendees: number, overrides: number): CalendarEventWire { + const participants: Record = { + me: { '@type': 'Participant', calendarAddress: `mailto:${ME}`, roles: { owner: true, attendee: true }, participationStatus: 'accepted' }, + }; + for (let i = 0; i < attendees; i++) { + participants[`p${i}`] = { '@type': 'Participant', calendarAddress: `mailto:person${i}@example.com`, name: `Person ${i}`, roles: { attendee: true }, participationStatus: 'needs-action', expectReply: true }; + } + const recurrenceOverrides: Record = {}; + for (let i = 0; i < overrides; i++) { + const day = new Date(Date.UTC(2026, 9, 6) + i * 86_400_000).toISOString().slice(0, 10); + recurrenceOverrides[`${day}T09:00:00`] = { title: `Standup #${i}`, participants }; + } + return { + id: 'big', + '@type': 'Event', + uid: 'big@example.org', + calendarIds: { b: true }, + title: 'Standup', + description: 'Daily standup', + start: '2026-10-05T09:00:00', + timeZone: 'Europe/Berlin', + duration: 'PT15M', + recurrenceRule: { frequency: 'daily', count: 300 }, + organizerCalendarAddress: `mailto:${ME}`, + participants, + recurrenceOverrides, + } as unknown as CalendarEventWire; +} + +async function synced(event: CalendarEventWire) { + const h = await new Harness().setup(); + await h.download(event); + return { h, id: Number(h.masterRow(event.id)._id) }; +} + +describe('calendar planner: big meetings', () => { + it('fits the writes after an edit of a big meeting into one transaction', async () => { + const event = meeting(25, 52); + const { h, id } = await synced(event); + // Only the series row is edited; its 52 occurrences with 26 attendees each stay clean. + h.fake.user.updateEvent(id, { [Events.TITLE]: 'Daily standup' }); + const local = (await h.local('big'))!; + + const plan = calendarPlanner.planUpload(local, h.ctx); + if (plan.kind !== 'upload' || plan.actions[0].kind !== 'update') throw new Error(plan.kind); + const server = serverApply(event, plan.actions[0].patch); + const accepted = calendarPlanner.planAccepted(local, server, h.ctx); + expect(estimate(accepted.ops)).toBeLessThan(TRANSACTION); + expect(estimate(accepted.keepDirtyOps)).toBeLessThan(TRANSACTION); + + // A download into the edited event (the server changed one occurrence meanwhile). + const remote = { ...event, recurrenceOverrides: { ...event.recurrenceOverrides, '2026-10-09T09:00:00': { title: 'Moved to Friday' } } } as CalendarEventWire; + expect(estimate(calendarPlanner.planDownload(remote, local, h.ctx).ops)).toBeLessThan(TRANSACTION); + + // A save that changed nothing mapped is cleared the same way. + h.fake.user.updateEvent(id, { [Events.TITLE]: 'Standup' }); + const clean = calendarPlanner.planUpload((await h.local('big'))!, h.ctx); + expect(clean.kind).toBe('clean'); + if (clean.kind === 'clean') expect(estimate(clean.ops)).toBeLessThan(TRANSACTION); + }); + + it('still fails a write when an app changed a clean occurrence\'s attendees meanwhile', async () => { + const event = meeting(3, 4); + const { h, id } = await synced(event); + h.fake.user.updateEvent(id, { [Events.TITLE]: 'Daily standup' }); + const local = (await h.local('big'))!; + const x = local.exceptions[0]; + const plan = calendarPlanner.planUpload(local, h.ctx); + if (plan.kind !== 'upload' || plan.actions[0].kind !== 'update') throw new Error(plan.kind); + const accepted = calendarPlanner.planAccepted(local, serverApply(event, plan.actions[0].patch), h.ctx); + // The occurrence's attendees are not asserted one by one: CalendarProvider marks the event DIRTY on any app write to them. + h.fake.beforeNextBatch(() => h.fake.user.setAttendeeStatus(x.eventId, 'person1@example.com', 1)); + expect(await h.tryApply(accepted.ops)).toMatchObject({ ok: false, reason: 'assert' }); + expect(h.fake.rows('attendees', `${Attendees.EVENT_ID} = ? AND ${Attendees.ATTENDEE_EMAIL} = ?`, [x.eventId, 'person1@example.com'])[0][Attendees.ATTENDEE_STATUS]).toBe(1); + }); +}); diff --git a/src/device-sync/calendar/merge.ts b/src/device-sync/calendar/merge.ts index dc65b48e..eadd6b71 100644 --- a/src/device-sync/calendar/merge.ts +++ b/src/device-sync/calendar/merge.ts @@ -15,7 +15,8 @@ * * Every write into an existing item is guarded: the group first asserts * DIRTY and the projection the plan was made from (with attendees and - * reminders when the item is dirty) and the number of exception rows. + * reminders for the rows that are dirty: a clean row's DIRTY catches any app + * edit of them) and the number of exception rows. */ import { Events } from '../android-columns'; import type { CalendarContext, EventBaseline, LocalEvent, LocalEventRow, LocalException } from '../planner'; @@ -284,11 +285,11 @@ export function writeEvent( const baseTarget = options.base ? { calendarRowId: local.calendarRowId, calendarId: ctx.calendarIdOfRow(local.calendarRowId)?.calendarId ?? target.calendarId } : target; const baseImage = options.base ? eventImage(baseEvent, baseTarget.calendarRowId, baseTarget.calendarId, ctx) ?? image : image; const lossy = isLossyEditor(local.mutators); - const itemDirty = local.dirty || local.exceptions.some((x) => x.dirty || x.deleted); - // Asserts first: the rows as read. - assertRow(group, local, false, itemDirty); - for (const x of local.exceptions) assertRow(group, x, true, itemDirty); + // Asserts first: the rows as read. Attendees and reminders only for dirty rows (see `assertRow`): a big + // meeting's occurrences would otherwise assert every attendee and outgrow one provider transaction. + assertRow(group, local, false, local.dirty || local.deleted); + for (const x of local.exceptions) assertRow(group, x, true, x.dirty || x.deleted); assertExceptionCount(group, local.eventId, linkedExceptionCount(local)); let conflicts = 0; diff --git a/src/device-sync/calendar/planner.ts b/src/device-sync/calendar/planner.ts index abda5068..08a2ac15 100644 --- a/src/device-sync/calendar/planner.ts +++ b/src/device-sync/calendar/planner.ts @@ -223,8 +223,9 @@ function revertGroup(local: LocalEvent, ctx: CalendarContext): OpGroup | null { /** Clears DIRTY where nothing uploadable changed, taking the rows as they are as the new baselines. */ function cleanGroup(local: LocalEvent, computation: UploadComputation | null): OpGroup { const group = new GroupBuilder(refOf(local)); - assertRow(group, local, false, true); - for (const x of local.exceptions) assertRow(group, x, true, true); + // Attendees and reminders of the dirty rows only (see `assertRow`), so a big meeting fits one transaction. + assertRow(group, local, false, local.dirty || local.deleted); + for (const x of local.exceptions) assertRow(group, x, true, x.dirty || x.deleted); const settle = (row: LocalEventRow, isException: boolean) => { if (!row.dirty) return; const truncated = isTruncatedBaseline(row.baseline?.cells[Events.DESCRIPTION]);