perf: Skip the projection pass for merged deletes nothing visible outlives

A merge that deleted anything ran the full projection pass, whose closure
follows foreign keys both ways and so loads every row connected to the
deleted ones. A device deleting a card with its revisions, tags and
reviews cost each device receiving it about 4 s, and the server merging
the push about 5.4 s, in a library of 8,000 cards whose tags join it.

A device deleting a row locally tombstones everything visible below it,
so its batch carries those deletes along. What the pass could still
decide is a row below that the deleting device never saw, inserted here
meanwhile, which it hides by cascade. A batch that only deletes (none
restoring) now leaves the pass out when the deleted tables cascade only,
by the same rules as a local delete (_deletesOnlyCascade, shared with
canSkipProjectionAfterLocalDelete now), no attempted value is pending in
the connected tables, and every visible row below a deleted one, found by
walking down the cascades alone, is deleted by the same batch
(canLeaveProjectionUnchangedByMergedDeletes). A batch both inserting and
deleting, or restoring, takes the pass as before.

Measured on PostgreSQL: such a card delete merges in about 35 ms instead
of 5.4 s; received on a device, 99 ms instead of 4 s. The new tests
cover a leaf deleted through the shortcut and a middle row's delete,
which isn't eligible, hiding its children by the pass; in the app's sync
tests a card deleted while another device rated it declines the shortcut,
and dropping the walk's check fails that test. The core, server,
integration and simulation suites pass, but for two PostgreSQL tests
that can't start their database while another embedded PostgreSQL runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-17 21:16:28 +02:00
commit 012d94d0ca
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -0,0 +1,117 @@
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_client.dart';
import 'package:test/test.dart';
import '../test_tools/client_session.dart';
import '../test_tools/crdt_probes.dart';
// Merged deletes of rows below which nothing visible stays leave the
// projection pass out; anything else takes it.
void main() {
initTestClientSession();
Future<void> merge(List<CrdtMergeChange> changes) =>
session.db.mergeChanges(changes, spaceId: testCrdtUserId);
Future<CrdtMergeDelete> deleteOf(String tableName, UuidValue rowId) async =>
CrdtMergeDelete(
uuidSpaceId: testCrdtUserId,
tableName: tableName,
uuidRowId: rowId,
uuidNodeId: const Uuid().v7obj(),
hlcDatetime: (await rowHlc(
rowId,
)).datetime.add(const Duration(milliseconds: 1)),
hlcCounter: 0,
clFlag: 2,
reason: CrdtDataDeletedReason.userDelete,
);
group('Given a set-null middle with two cascade children stored here,', () {
late FkChainSetNullMiddle middle;
late FkChainSetNullCascadeChild deleted;
late FkChainSetNullCascadeChild sibling;
setUp(() async {
await session.db.transactionForUser(testCrdtUserId, (tx) async {
middle = await FkChainSetNullMiddle.db.insertRow(
session,
FkChainSetNullMiddle(id: const Uuid().v7obj(), name: 'middle'),
transaction: tx,
);
deleted = await FkChainSetNullCascadeChild.db.insertRow(
session,
FkChainSetNullCascadeChild(
id: const Uuid().v7obj(),
name: 'deleted',
setNullMiddleId: middle.id,
),
transaction: tx,
);
sibling = await FkChainSetNullCascadeChild.db.insertRow(
session,
FkChainSetNullCascadeChild(
id: const Uuid().v7obj(),
name: 'sibling',
setNullMiddleId: middle.id,
),
transaction: tx,
);
});
});
group('when a remote delete of one child is merged,', () {
setUp(() async {
await merge([
await deleteOf(FkChainSetNullCascadeChild.t.tableName, deleted.id!),
]);
});
test('then only that child is hidden, by its delete.', () async {
expect(
await FkChainSetNullCascadeChild.db.findById(session, deleted.id!),
isNull,
);
expect(
await FkChainSetNullCascadeChild.db.findById(session, sibling.id!),
isNotNull,
);
expect(
await FkChainSetNullMiddle.db.findById(session, middle.id!),
isNotNull,
);
final row = await CrdtDataRow.db.findFirstRow(
session,
where: (t) => t.uuidRowId.equals(deleted.id),
);
expect(row!.visibility, CrdtDataRowVisibility.userDelete);
});
});
group('when a remote delete of the middle alone is merged,', () {
setUp(() async {
await merge([
await deleteOf(FkChainSetNullMiddle.t.tableName, middle.id!),
]);
});
test('then its children are hidden with it, by the cascade.', () async {
expect(
await FkChainSetNullMiddle.db.findById(session, middle.id!),
isNull,
);
for (final child in [deleted, sibling]) {
expect(
await FkChainSetNullCascadeChild.db.findById(session, child.id!),
isNull,
);
final row = await CrdtDataRow.db.findFirstRow(
session,
where: (t) => t.uuidRowId.equals(child.id),
);
expect(row!.visibility, CrdtDataRowVisibility.foreignKeyCascade);
}
});
});
});
}