Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
b5450353b9
perf: Skip the projection pass after a local delete that cannot change it
insteadOfDelete ran the full foreign key projection after every local
delete. Its closure follows edges both ways, so it loads every row
connected to the deleted one, which in a schema whose tables all hang off
one another is nearly the whole database: one deleted row of a person's
8,000 flashcards loaded 56,000 of 56,350 CRDT rows and took about 8 s on
the calling isolate, most of it deserialising rows. Inserts and updates
have had a proof that the pass is unnecessary
(canLeaveLocalProjectionUnchanged); deletes had none.

canSkipProjectionAfterLocalDelete proves it for a delete: every table
the delete can cascade into is reached by cascade edges on id only, none
of them restricts its parent (a deleted child can release a parent
delete held back by it), none has a unique index (a released claim can
bring a contestant back) or lies in a component with a SET DEFAULT edge,
and no attempted value is pending in the connected tables. The delete's
own cascade (applyDeleteActions) then is the whole projection; otherwise
the full pass runs as before. applyDeleteActions also asks for the
visible children of a whole batch of parents at once for cascades by id,
instead of one query per parent.

On that database a single delete now takes 6-63 ms, a card with its
revisions, tags and reviews 46-145 ms instead of 28-39 s. The new tests
cover a restrict child deleted locally releasing a merged parent delete
(on the person tables, and on the chain tables, where the shortcut is
asked and declines: the test fails with the restrict condition removed)
and a cascade child deleted locally through the shortcut, its parent
then deleted by a merged remote delete. The core, server, integration
and simulation suites pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:38:40 +02:00
0345f56787
feat: Report merges of inbound changes apart from pushes
onMergeSuccess is called for every space a sync touched, also for one
this peer only sent changes of, so a client re-read its data after every
sync that pushed. onInboundMerge is called only for a space whose
changes from the remote peer were merged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:38:35 +02:00
7 changed files with 292 additions and 6 deletions

View file

@ -120,6 +120,7 @@ class OfflineSyncDatabase implements Database {
UuidValue? userId,
bool once = false,
OfflineSyncOnMergeSuccess? onMergeSuccess,
OfflineSyncOnInboundMerge? onInboundMerge,
}) async* {
await _ensureInitialized();
final effectiveUserId = await _requireUserId(userId);
@ -129,6 +130,7 @@ class OfflineSyncDatabase implements Database {
inbound: inbound,
once: once,
onMergeSuccess: onMergeSuccess,
onInboundMerge: onInboundMerge,
mode: mode,
);
}

View file

@ -33,6 +33,15 @@ String domainColumnPredicate(
String alias = 'd',
}) => '$alias."${columnName.escapeIdentifier()}" = ${value.sqlLiteral()}';
/// SQL predicate matching rows where [columnName] holds one of [values],
/// which must not be empty.
@internal
String domainColumnInPredicate(
String columnName,
Iterable<Object?> values, {
String alias = 'd',
}) => '$alias."${columnName.escapeIdentifier()}" IN (${values.sqlLiteralList()})';
/// SQL predicate matching rows where [columnName] differs from [value].
@internal
String domainColumnNotPredicate(

View file

@ -159,6 +159,24 @@ class CrdtForeignKeyProjector {
_foreignKeys.referencingKeysByParentTable[parentTableName] ??
const <ReferencingForeignKey>[];
for (final reference in foreignKeys) {
// A cascade by id needs no per-parent answer: ask for every visible
// child of the whole batch at once instead of one query per parent.
if (reference.action == ForeignKeyAction.cascade &&
reference.parentColumn == 'id') {
final childIds = await _context.findVisibleDomainRowIdsWhere(
tableName: reference.childTableName,
predicates: [
domainColumnInPredicate(reference.childColumn, parentIds),
],
transaction: transaction,
);
if (childIds.isNotEmpty) {
cascadeDeletes
.putIfAbsent(reference.childTableName, () => {})
.addAll(childIds);
}
continue;
}
final parentValuesById = reference.parentColumn == 'id'
? null
: await _context.readDomainColumnValues(
@ -738,6 +756,54 @@ class CrdtForeignKeyProjector {
);
}
/// Proves that a local delete of [tableName] rows leaves nothing for the
/// projection pass to decide: the delete's own cascade
/// (`applyDeleteActions`, recursively) already hid everything it hides.
///
/// Holds when, in every table the delete can cascade into, every incoming
/// edge cascades by id (no SET NULL / SET DEFAULT repairs), no outgoing edge
/// restricts (the deleted row could be the blocker of a withheld parent
/// delete), no table has a unique index (a released claim could restore a
/// contestant) or a SET DEFAULT dependency, and no attempted value is
/// pending in the tables the pass would load. Otherwise the full pass runs.
Future<bool> canSkipProjectionAfterLocalDelete(
String tableName,
Transaction transaction,
) async {
final cascadeTables = <String>{};
final queue = [tableName];
while (queue.isNotEmpty) {
final table = queue.removeLast();
if (!cascadeTables.add(table)) continue;
for (final edge
in _foreignKeys.edgesByParentTable[table] ?? const <ForeignKeyEdge>[]) {
if (edge.action != ForeignKeyAction.cascade || edge.parentColumn != 'id') {
return false;
}
queue.add(edge.childTableName);
}
}
for (final table in cascadeTables) {
if (_uniqueResolver.tableHasUniqueIndexes(table)) return false;
if (_foreignKeys.tablesWithDefaultDependencies.contains(table)) return false;
for (final edge
in _foreignKeys.edgesByChildTable[table] ?? const <ForeignKeyEdge>[]) {
if (edge.action == ForeignKeyAction.restrict ||
edge.action == ForeignKeyAction.noAction) {
return false;
}
}
}
final reachable = _foreignKeys
.connectedTables({tableName})
.where(_context.syncTableByName.containsKey)
.toSet();
return !await _ProjectionReader(
_context,
transaction,
).hasAttemptedClaims(reachable);
}
/// Whether a mutation of [tableName] may require FK or unique projection.
bool needsProjection(String tableName, Set<String>? columnNames) {
if (_foreignKeys.columnsMayAffectForeignKeys(tableName, columnNames)) {

View file

@ -1232,12 +1232,19 @@ class CrdtMutationRecorder {
null,
CrdtDataDeletedReason.userDelete,
);
await _maybeProject(
// The delete's own cascade (applyDeleteActions above) usually is the
// whole projection; only run the full pass when it can decide more.
if (!await _foreignKeyProjector.canSkipProjectionAfterLocalDelete(
tableName,
deletedRows.uuidRowIds,
null,
transaction,
);
)) {
await _maybeProject(
tableName,
deletedRows.uuidRowIds,
null,
transaction,
);
}
await _context.persistCurrentNodeHlc(
_context.hlcManagerFor(transaction),
transaction,

View file

@ -43,8 +43,14 @@ class OfflineSyncClient {
Future<void> syncOnce(
DatabaseSession session, {
OfflineSyncOnMergeSuccess? onMergeSuccess,
OfflineSyncOnInboundMerge? onInboundMerge,
}) async {
await _startSyncSession(session, once: true, onMergeSuccess: onMergeSuccess).done;
await _startSyncSession(
session,
once: true,
onMergeSuccess: onMergeSuccess,
onInboundMerge: onInboundMerge,
).done;
}
/// Keeps synchronizing [session] with the remote peer until cancelled through
@ -54,12 +60,19 @@ class OfflineSyncClient {
OfflineSyncSubscription syncContinuously(
DatabaseSession session, {
OfflineSyncOnMergeSuccess? onMergeSuccess,
}) => _startSyncSession(session, once: false, onMergeSuccess: onMergeSuccess);
OfflineSyncOnInboundMerge? onInboundMerge,
}) => _startSyncSession(
session,
once: false,
onMergeSuccess: onMergeSuccess,
onInboundMerge: onInboundMerge,
);
OfflineSyncSubscription _startSyncSession(
DatabaseSession session, {
required bool once,
OfflineSyncOnMergeSuccess? onMergeSuccess,
OfflineSyncOnInboundMerge? onInboundMerge,
}) {
// The stream will be closed using [outboundChanges.closeOrSkip].
// ignore: close_sinks
@ -78,6 +91,7 @@ class OfflineSyncClient {
inbound: remoteStream,
once: once,
onMergeSuccess: onMergeSuccess,
onInboundMerge: onInboundMerge,
mode: OfflineSyncPeerMode.follower,
)
.listen(

View file

@ -27,6 +27,12 @@ export 'space_state.dart' show OfflineSyncPeerMode;
typedef OfflineSyncOnMergeSuccess =
FutureOr<void> Function(UuidValue spaceUuid, Hlc syncedHlc);
/// Callback function for when changes from the remote peer were merged into
/// a space. Unlike [OfflineSyncOnMergeSuccess], it isn't called for a space
/// this peer only sent changes of, so a listener re-reads its data only
/// when there is something new to read.
typedef OfflineSyncOnInboundMerge = FutureOr<void> Function(UuidValue spaceUuid);
/// A tuple representing the ownership of a domain row.
typedef DomainRowOwner = ({bool exists, int? spaceId});
@ -275,6 +281,7 @@ class OfflineSyncEngine {
required OfflineSyncPeerMode mode,
bool once = false,
OfflineSyncOnMergeSuccess? onMergeSuccess,
OfflineSyncOnInboundMerge? onInboundMerge,
}) async* {
// Idle timeouts are a continuous-only affordance: they let an idle cycle
// settle into an empty batch without closing the stream. A `once` session
@ -369,6 +376,7 @@ class OfflineSyncEngine {
batch,
outboundSpaces,
onMergeSuccess,
onInboundMerge,
);
if (once) {
@ -429,6 +437,7 @@ class OfflineSyncEngine {
OfflineSyncCycleBatch batch,
Set<UuidValue> outboundSpaces,
OfflineSyncOnMergeSuccess? onMergeSuccess,
OfflineSyncOnInboundMerge? onInboundMerge,
) async {
if (batch.spaceSet != null) {
await spaces.adoptPeerGrants(batch.spaceSet!.spaces);
@ -462,6 +471,9 @@ class OfflineSyncEngine {
mergeSet: entry.value,
);
await _reportMerge(onMergeSuccess, spaces, spaceId, receivedHlc);
if (entry.value.isNotEmpty) {
await onInboundMerge?.call(spaceId);
}
}
for (final spaceId in outboundSpaces.difference(mergedSpaces)) {
await _reportMerge(onMergeSuccess, spaces, spaceId, null);

View file

@ -157,6 +157,182 @@ void main() {
expect(tombstone.hlc, remoteParentDelete.hlc);
},
);
group('and the restrict child is then deleted locally,', () {
setUp(() async {
await session.db.transactionForUser(testCrdtUserId, (tx) async {
await RestrictChild.db.deleteRow(session, child, transaction: tx);
});
});
test('then the merged parent delete takes effect.', () async {
expect(await Person.db.findById(session, parent.id!), isNull);
expect(await RestrictChild.db.findById(session, child.id!), isNull);
});
});
});
},
);
group(
'Given a set-null middle with two cascade children,',
() {
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 child',
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 one cascade child is deleted locally,', () {
setUp(() async {
await session.db.transactionForUser(testCrdtUserId, (tx) async {
await FkChainSetNullCascadeChild.db.deleteRow(
session,
deleted,
transaction: tx,
);
});
});
test('then only that child is hidden, by a user delete.', () async {
expect(
await FkChainSetNullCascadeChild.db.findById(session, deleted.id!),
isNull,
);
final visibleSibling = await FkChainSetNullCascadeChild.db.findById(
session,
sibling.id!,
);
expect(visibleSibling, isNotNull);
expect(visibleSibling!.setNullMiddleId, middle.id);
expect(
await FkChainSetNullMiddle.db.findById(session, middle.id!),
isNotNull,
);
final tombstone = await CrdtDataDeleted.db.findFirstRow(
session,
where: (t) => t.row.uuidRowId.equals(deleted.id),
);
expect(tombstone, isNotNull);
expect(tombstone!.isDeleted, isTrue);
expect(tombstone.reason, CrdtDataDeletedReason.userDelete);
});
group('and the middle is then deleted by a merged remote delete,', () {
setUp(() async {
await session.db.mergeChanges(
[
_deleteChange(
tableName: FkChainSetNullMiddle.t.tableName,
rowId: middle.id!,
after: await rowHlc(middle.id!),
),
],
spaceId: testCrdtUserId,
);
});
test('then the remaining child is hidden with it.', () async {
expect(
await FkChainSetNullMiddle.db.findById(session, middle.id!),
isNull,
);
expect(
await FkChainSetNullCascadeChild.db.findById(session, sibling.id!),
isNull,
);
});
});
});
},
);
group(
'Given a set-null middle kept by its restrict child against a merged delete,',
() {
late FkChainSetNullMiddle middle;
late FkChainSetNullRestrictChild blocker;
setUp(() async {
await session.db.transactionForUser(testCrdtUserId, (tx) async {
middle = await FkChainSetNullMiddle.db.insertRow(
session,
FkChainSetNullMiddle(id: const Uuid().v7obj(), name: 'kept middle'),
transaction: tx,
);
blocker = await FkChainSetNullRestrictChild.db.insertRow(
session,
FkChainSetNullRestrictChild(
id: const Uuid().v7obj(),
name: 'blocker',
setNullMiddleId: middle.id,
),
transaction: tx,
);
});
await session.db.mergeChanges(
[
_deleteChange(
tableName: FkChainSetNullMiddle.t.tableName,
rowId: middle.id!,
after: await rowHlc(middle.id!),
),
],
spaceId: testCrdtUserId,
);
expect(
await FkChainSetNullMiddle.db.findById(session, middle.id!),
isNotNull,
);
});
group('when the restrict child is deleted locally,', () {
setUp(() async {
await session.db.transactionForUser(testCrdtUserId, (tx) async {
await FkChainSetNullRestrictChild.db.deleteRow(
session,
blocker,
transaction: tx,
);
});
});
test('then the merged delete of the middle takes effect.', () async {
expect(
await FkChainSetNullMiddle.db.findById(session, middle.id!),
isNull,
);
expect(
await FkChainSetNullRestrictChild.db.findById(session, blocker.id!),
isNull,
);
});
});
},
);