mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-04 00:06:53 +00:00
The contacts SyncState lists the group rows on the device, so a group an app hard-deleted (Fossify does) is found by its absence and deleted on the server. The list changed only with a later state op, not with the batch that inserted or deleted the rows. When a run stopped in between (cancelled, out of time, a crash, or turning sync off while a deselected book was being dropped), a group the engine had removed was still listed, taken for a hard delete by the next run or by the teardown's upload, and its card destroyed on the server; a group it had inserted was never listed, so a later hard delete never went up. A batch that inserts or deletes a group row now ends with a state op holding that change, retries included, so the list and the rows never disagree. A group a download finds unchanged is listed too, for rows whose batch lost its state op at a provider yield point. And an absent group whose card is no longer a group or is in no synced book is only forgotten, never destroyed. Checked with a crash at every checkpoint of a deselection, a teardown and a deadline stop during one, a server-side move in a page of several chunks, and a group written before an interruption and then deleted in Fossify.
249 lines
9.0 KiB
TypeScript
249 lines
9.0 KiB
TypeScript
/**
|
|
* Packs item op groups into provider batches and applies them
|
|
* (docs/device-sync.md, "SyncState", "Native module API", "Failure matrix"):
|
|
*
|
|
* - a group is never split; `refs` are rebased to the batch and the group's
|
|
* first op is its yield point, so the provider may commit between groups
|
|
* but never inside one;
|
|
* - at most `maxOps` ops and about `maxBytes` of JSON per batch; groups that
|
|
* carry photo bytes travel alone (Binder budget);
|
|
* - the SyncState op, when there is one, is the last op of the last batch
|
|
* and never a yield point: the stored state can't get ahead of its rows.
|
|
*
|
|
* A failed batch may have been committed up to a yield point the provider
|
|
* took, so its groups are retried one by one (their asserts make a retry of
|
|
* a committed group fail harmlessly); a group that fails an assert is
|
|
* re-read and re-planned up to `maxReplans` times, then given up. `tooLarge`
|
|
* splits the batch; `permission` ends the run.
|
|
*
|
|
* A group whose rows the SyncState describes (a group row: `groups`) carries
|
|
* that `state` change: every batch that applies such a group, a retry
|
|
* included, ends with a state op holding it.
|
|
*/
|
|
import type { OpGroup } from '../planner';
|
|
import type { BatchFailure, OpResult, ProviderOp, ProviderPort } from '../types';
|
|
import { RunAbort } from './errors';
|
|
import { hasWrites } from './provider';
|
|
import type { StateChange, StateStore, Tail } from './sync-state';
|
|
|
|
export interface Work {
|
|
group: OpGroup;
|
|
/** The group applied; `results` are its ops' results (new row ids for inserts). */
|
|
applied?(results: OpResult[]): void | Promise<void>;
|
|
/** After an assert failure: read the item again and plan it again; null when nothing is left to write. */
|
|
replan?(): Promise<Work | null>;
|
|
/** Given up: the item's rows stay as they are. */
|
|
failed?(reason: BatchFailure, message: string): void | Promise<void>;
|
|
/** What the SyncState lists changes with these rows: stored by the batch that applies them. */
|
|
state?: StateChange;
|
|
}
|
|
|
|
export interface BatchWriterOptions {
|
|
maxOps: number;
|
|
maxBytes: number;
|
|
maxReplans: number;
|
|
}
|
|
|
|
function sizeOf(ops: readonly ProviderOp[]): number {
|
|
return JSON.stringify(ops).length;
|
|
}
|
|
|
|
function carriesBlob(ops: readonly ProviderOp[]): boolean {
|
|
return ops.some(
|
|
(op) =>
|
|
(op.op === 'insert' || op.op === 'update') &&
|
|
Object.values(op.values).some((v) => v !== null && typeof v === 'object'),
|
|
);
|
|
}
|
|
|
|
/** A group's ops at `offset` in a batch: refs rebased, the first op a yield point, no other. */
|
|
function placeGroup(ops: readonly ProviderOp[], offset: number): ProviderOp[] {
|
|
return ops.map((op, i) => {
|
|
const copy = { ...op } as ProviderOp & { yieldAllowed?: boolean };
|
|
if (copy.op === 'insert' && copy.refs) {
|
|
const refs: Record<string, number> = {};
|
|
for (const [column, index] of Object.entries(copy.refs)) refs[column] = index + offset;
|
|
copy.refs = refs;
|
|
}
|
|
if (i === 0) copy.yieldAllowed = true;
|
|
else delete copy.yieldAllowed;
|
|
return copy;
|
|
});
|
|
}
|
|
|
|
export function buildBatch(groups: readonly OpGroup[], tailOp?: ProviderOp): ProviderOp[] {
|
|
const out: ProviderOp[] = [];
|
|
for (const group of groups) out.push(...placeGroup(group.ops, out.length));
|
|
if (tailOp) {
|
|
const { yieldAllowed: _never, ...last } = tailOp as ProviderOp & { yieldAllowed?: boolean };
|
|
out.push(last as ProviderOp);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Prepends ops to a group (e.g. an engine-side assert), shifting the group's refs. */
|
|
export function prependOps(group: OpGroup, ops: ProviderOp[]): OpGroup {
|
|
return { ref: group.ref, ops: [...ops, ...placeGroup(group.ops, ops.length).map(stripYield)] };
|
|
}
|
|
|
|
/** One group running `first` then `second` atomically. */
|
|
export function concatGroups(ref: string, first: OpGroup, second: OpGroup): OpGroup {
|
|
return {
|
|
ref,
|
|
ops: [...first.ops.map(stripYield), ...placeGroup(second.ops, first.ops.length).map(stripYield)],
|
|
};
|
|
}
|
|
|
|
function stripYield(op: ProviderOp): ProviderOp {
|
|
const { yieldAllowed: _y, ...rest } = op as ProviderOp & { yieldAllowed?: boolean };
|
|
return rest as ProviderOp;
|
|
}
|
|
|
|
export class BatchWriter {
|
|
/** Batches sent, for tests and logs. */
|
|
batchesSent = 0;
|
|
|
|
constructor(
|
|
private readonly port: ProviderPort,
|
|
private readonly options: BatchWriterOptions,
|
|
/** Where the `state` of works goes; without it, it is ignored. */
|
|
private readonly store?: StateStore,
|
|
) {}
|
|
|
|
/**
|
|
* Applies the works in order; `tail` (the SyncState op) goes last, after
|
|
* every group of `works` was applied or given up.
|
|
*/
|
|
async write(works: readonly Work[], tail?: Tail): Promise<void> {
|
|
const todo = works.filter((w) => hasWrites(w.group.ops));
|
|
const batches = this.pack(todo);
|
|
if (batches.length === 0) {
|
|
if (tail) await this.applyTail(tail);
|
|
return;
|
|
}
|
|
for (let i = 0; i < batches.length; i++) {
|
|
const last = i === batches.length - 1;
|
|
const batch = batches[i];
|
|
// A photo batch is already at the Binder budget: its state op goes alone.
|
|
const tailHere = last && tail && !(batch.length === 1 && carriesBlob(batch[0].group.ops)) ? tail : undefined;
|
|
await this.applyBatch(batch, tailHere);
|
|
if (last && tail && !tailHere) await this.applyTail(tail);
|
|
}
|
|
}
|
|
|
|
private pack(works: readonly Work[]): Work[][] {
|
|
const batches: Work[][] = [];
|
|
let current: Work[] = [];
|
|
let ops = 0;
|
|
let bytes = 0;
|
|
const flush = () => {
|
|
if (current.length) batches.push(current);
|
|
current = [];
|
|
ops = 0;
|
|
bytes = 0;
|
|
};
|
|
for (const work of works) {
|
|
if (carriesBlob(work.group.ops)) {
|
|
flush();
|
|
batches.push([work]);
|
|
continue;
|
|
}
|
|
const n = work.group.ops.length;
|
|
const b = sizeOf(work.group.ops);
|
|
if (current.length && (ops + n > this.options.maxOps || bytes + b > this.options.maxBytes)) flush();
|
|
current.push(work);
|
|
ops += n;
|
|
bytes += b;
|
|
}
|
|
flush();
|
|
return batches;
|
|
}
|
|
|
|
private async send(ops: ProviderOp[]) {
|
|
this.batchesSent++;
|
|
const result = await this.port.applyBatch(ops);
|
|
if (!result.ok) {
|
|
if (result.reason === 'permission') throw new RunAbort('permission', result.message);
|
|
// The native side refused an op outside our account: an engine bug, never retried.
|
|
if (result.reason === 'scope') throw new Error(`Provider refused an op: ${result.message}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** The works' state changes go into the state op of the batch being sent: `tail`, or one of its own. */
|
|
private stage(works: readonly Work[], tail?: Tail): { staged: Work[]; tail?: Tail } {
|
|
const store = this.store;
|
|
const staged = store ? works.filter((w) => w.state) : [];
|
|
for (const work of staged) store!.stage(work, work.state!);
|
|
return { staged, tail: tail ?? (staged.length ? store!.tail(() => undefined) : undefined) };
|
|
}
|
|
|
|
private unstage(staged: readonly Work[]): void {
|
|
for (const work of staged) this.store!.unstage(work);
|
|
}
|
|
|
|
private async applyBatch(works: Work[], tail?: Tail): Promise<void> {
|
|
const stated = this.stage(works, tail);
|
|
let result;
|
|
try {
|
|
result = await this.send(buildBatch(works.map((w) => w.group), stated.tail?.op()));
|
|
} finally {
|
|
this.unstage(stated.staged);
|
|
}
|
|
if (result.ok) {
|
|
let offset = 0;
|
|
for (const work of works) {
|
|
const n = work.group.ops.length;
|
|
await work.applied?.(result.results.slice(offset, offset + n));
|
|
offset += n;
|
|
}
|
|
stated.tail?.applied();
|
|
return;
|
|
}
|
|
if (result.reason === 'tooLarge' && works.length > 1) {
|
|
const middle = Math.ceil(works.length / 2);
|
|
await this.applyBatch(works.slice(0, middle));
|
|
await this.applyBatch(works.slice(middle), tail);
|
|
return;
|
|
}
|
|
if (works.length === 1 && !tail) {
|
|
await this.retryAlone(works[0], result.reason, result.message);
|
|
return;
|
|
}
|
|
for (const work of works) await this.applyAlone(work, 0);
|
|
if (tail) await this.applyTail(tail);
|
|
}
|
|
|
|
private async applyAlone(work: Work, attempt: number): Promise<void> {
|
|
if (!hasWrites(work.group.ops)) return;
|
|
const stated = this.stage([work]);
|
|
let result;
|
|
try {
|
|
result = await this.send(buildBatch([work.group], stated.tail?.op()));
|
|
} finally {
|
|
this.unstage(stated.staged);
|
|
}
|
|
if (result.ok) {
|
|
await work.applied?.(result.results.slice(0, work.group.ops.length));
|
|
stated.tail?.applied();
|
|
return;
|
|
}
|
|
await this.retryAlone(work, result.reason, result.message, attempt);
|
|
}
|
|
|
|
private async retryAlone(work: Work, reason: BatchFailure, message: string, attempt = 0): Promise<void> {
|
|
if (reason === 'assert' && work.replan && attempt < this.options.maxReplans) {
|
|
const next = await work.replan();
|
|
if (next) await this.applyAlone(next, attempt + 1);
|
|
return;
|
|
}
|
|
await work.failed?.(reason, message);
|
|
}
|
|
|
|
private async applyTail(tail: Tail): Promise<void> {
|
|
const result = await this.send([...buildBatch([], tail.op())]);
|
|
if (!result.ok) throw new Error(`Could not store the sync state: ${result.reason} ${result.message}`);
|
|
tail.applied();
|
|
}
|
|
}
|