mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user