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.
This commit is contained in:
Linus Rath
2026-09-27 07:10:47 +02:00
parent 6785276377
commit c777dc4089
3 changed files with 99 additions and 7 deletions
@@ -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<string, unknown> = {
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<string, unknown> = {};
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);
});
});
+6 -5
View File
@@ -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;
+3 -2
View File
@@ -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]);