perf: Skip the projection passes for merged inserts that cannot change them
A merge that touched any foreign key ran the planning pass for its new rows and then the full projection pass, whose closure follows foreign keys both ways and so loads every row connected to the merged ones. In a library whose tags join its parts, one merged rating, edit, tag or new card loaded all of it: about 9.5 s on a device receiving it and 12 s on the server merging a push, for 8,000 cards. A batch that only inserts new rows (each once) and updates columns no projection reads now leaves both passes out when its inserts are proven, as local inserts are (canLeaveProjectionUnchangedByMergedInserts, on localProjectionDependenciesAreStable): each row references its parents by id, each parent visible or inserted earlier in the same batch, nothing refers to the row yet (no row, no attempted value), and no unique claim is contested. A parent inserted after its child in the batch would find the child's reference held back, which only the pass restores, so it counts only when earlier. Deletes, inserts onto rows there already and anything unproven take the passes as before. The batch's parents are still resolved together for the writes. The new tests cover a child of a visible parent, a parent and child in one batch (both through the shortcut), and a child of a merged delete and a parent arriving after its held-back child (both through the passes); dropping the parent or the reference check fails the matching test. The core, server, integration and simulation suites pass, but for two PostgreSQL tests that cannot start their database while another embedded PostgreSQL runs, with or without this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b5450353b9
commit
9495f616c4
2 changed files with 344 additions and 5 deletions
|
|
@ -36,7 +36,7 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
|
|||
// Nothing in this batch touches a foreign key or a unique index, so no
|
||||
// projection decision can change: skip both passes instead of loading
|
||||
// state for tables that cannot produce a candidate or a conflict.
|
||||
final mayAffectProjection = _foreignKeyProjector.mergeOperationsMayAffectProjection(
|
||||
var mayAffectProjection = _foreignKeyProjector.mergeOperationsMayAffectProjection(
|
||||
operations,
|
||||
);
|
||||
final authoredOverlays = <MergeFieldKey, Object?>{};
|
||||
|
|
@ -48,6 +48,14 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
|
|||
);
|
||||
final nodesByUuid = remoteNodes.nodesByUuid;
|
||||
final context = await _loadMergeContext(mergeSet, transaction);
|
||||
// New rows whose parents are there and which nothing refers to yet (a
|
||||
// rating, an edit, a tag given, a card added) can't change a projection
|
||||
// decision either, though they carry foreign keys: proven so, neither
|
||||
// pass runs, and the batch still resolves their parents in one go.
|
||||
final unchangedByInserts =
|
||||
mayAffectProjection &&
|
||||
await _mergeLeavesProjectionUnchanged(operations, context, transaction);
|
||||
if (unchangedByInserts) mayAffectProjection = false;
|
||||
|
||||
// Deletes carry no authored overlay but still change visibility, so the
|
||||
// seed is every touched row, not just the ones with overlays.
|
||||
|
|
@ -59,7 +67,7 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
|
|||
touchedRows.add((operation.tableName, operation.uuidRowId));
|
||||
}
|
||||
|
||||
final batch = mayAffectProjection
|
||||
final batch = mayAffectProjection || unchangedByInserts
|
||||
? await _planBatchInserts(
|
||||
operations,
|
||||
nodesByUuid,
|
||||
|
|
@ -67,6 +75,7 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
|
|||
touchedTables,
|
||||
touchedRows,
|
||||
transaction,
|
||||
plan: mayAffectProjection,
|
||||
)
|
||||
: (plan: null, pending: const <PendingProjectionRow>[]);
|
||||
// Every merged insert has to know whether the rows its foreign keys name
|
||||
|
|
@ -362,8 +371,11 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
|
|||
MergeContext context,
|
||||
Set<String> seedTables,
|
||||
Set<MergeRowKey> seedRows,
|
||||
Transaction transaction,
|
||||
) async {
|
||||
Transaction transaction, {
|
||||
// Whether to plan them: without, only the rows are listed, for their
|
||||
// parents to be resolved together.
|
||||
bool plan = true,
|
||||
}) async {
|
||||
final pending = <PendingProjectionRow>[];
|
||||
final seenRowKeys = <MergeRowKey>{};
|
||||
|
||||
|
|
@ -391,7 +403,7 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
|
|||
hidden: false,
|
||||
));
|
||||
}
|
||||
if (pending.isEmpty) return (plan: null, pending: pending);
|
||||
if (pending.isEmpty || !plan) return (plan: null, pending: pending);
|
||||
|
||||
return (
|
||||
plan: await _foreignKeyProjector.project(
|
||||
|
|
@ -404,6 +416,49 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
|
|||
);
|
||||
}
|
||||
|
||||
/// Whether this batch leaves every projection decision as it is though it
|
||||
/// touches foreign keys: it only inserts new rows, each once, and updates
|
||||
/// columns no projection reads, and the inserts are proven
|
||||
/// ([CrdtForeignKeyProjector.canLeaveProjectionUnchangedByMergedInserts]).
|
||||
/// A delete, or an insert onto a row there already, takes the full pass.
|
||||
Future<bool> _mergeLeavesProjectionUnchanged(
|
||||
List<CrdtMergeChange> operations,
|
||||
MergeContext context,
|
||||
Transaction transaction,
|
||||
) async {
|
||||
final insertsByTable = <String, Map<UuidValue, Map<String, Object?>>>{};
|
||||
final positions = <MergeRowKey, int>{};
|
||||
for (final operation in operations) {
|
||||
if (!_context.isCrdtTrackedTableName(operation.tableName)) continue;
|
||||
switch (operation) {
|
||||
case CrdtMergeDelete():
|
||||
return false;
|
||||
case final CrdtMergeUpdate update:
|
||||
if (_foreignKeyProjector.needsProjection(update.tableName, {
|
||||
update.columnName,
|
||||
})) {
|
||||
return false;
|
||||
}
|
||||
case final CrdtMergeInsert insert:
|
||||
if (context.rows[(insert.tableName, insert.uuidRowId)] != null) {
|
||||
return false;
|
||||
}
|
||||
final rows = insertsByTable[insert.tableName] ??= {};
|
||||
if (rows.containsKey(insert.uuidRowId)) return false;
|
||||
rows[insert.uuidRowId] = _sanitizeMergeRowData(
|
||||
insert.tableName,
|
||||
insert.databaseColumns,
|
||||
);
|
||||
positions[(insert.tableName, insert.uuidRowId)] = positions.length;
|
||||
}
|
||||
}
|
||||
return _foreignKeyProjector.canLeaveProjectionUnchangedByMergedInserts(
|
||||
insertsByTable,
|
||||
positions,
|
||||
transaction,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _applyMergeInsert(
|
||||
CrdtMergeInsert insert,
|
||||
Map<UuidValue, CrdtNode> remoteNodes,
|
||||
|
|
|
|||
|
|
@ -756,6 +756,99 @@ class CrdtForeignKeyProjector {
|
|||
);
|
||||
}
|
||||
|
||||
/// Proves that merged inserts of new rows, [rowsByTable] (each row's
|
||||
/// authored values by its id, by its table; [positions] says where each
|
||||
/// comes in the batch), leave every projection decision as it is, as
|
||||
/// [canLeaveLocalProjectionUnchanged] proves it for local inserts: each row
|
||||
/// references its parents by id, each parent visible or inserted earlier in
|
||||
/// the same merge (and proven alike; one inserted after its child would
|
||||
/// find the child's reference held back, which only the pass restores),
|
||||
/// nothing refers
|
||||
/// to the row yet (no row, no attempted value), and no unique claim is
|
||||
/// contested. The merge then writes them as authored, without the pass,
|
||||
/// which would load every row connected to them: all of a library whose
|
||||
/// tags join its parts.
|
||||
Future<bool> canLeaveProjectionUnchangedByMergedInserts(
|
||||
Map<String, Map<UuidValue, Map<String, Object?>>> rowsByTable,
|
||||
Map<MergeRowKey, int> positions,
|
||||
Transaction transaction,
|
||||
) async {
|
||||
for (final MapEntry(key: tableName, value: rows) in rowsByTable.entries) {
|
||||
final ids = rows.keys.toSet();
|
||||
final childEdges =
|
||||
_foreignKeys.edgesByChildTable[tableName] ?? const <ForeignKeyEdge>[];
|
||||
final parentEdges =
|
||||
_foreignKeys.edgesByParentTable[tableName] ?? const <ForeignKeyEdge>[];
|
||||
if (childEdges.any((edge) => edge.parentColumn != 'id') ||
|
||||
parentEdges.any((edge) => edge.parentColumn != 'id')) {
|
||||
return false;
|
||||
}
|
||||
final columns = _context.columnsByTableAndName[tableName];
|
||||
Object? valueOf(Map<String, Object?> row, String column) =>
|
||||
canonicalDomainValue(row[column], columns?[column]);
|
||||
|
||||
final parentsByTable = <String, Set<UuidValue>>{};
|
||||
for (final edge in childEdges) {
|
||||
for (final MapEntry(key: rowId, value: row) in rows.entries) {
|
||||
final value = valueOf(row, edge.childColumn);
|
||||
if (value == null) {
|
||||
if (!edge.childNullable || edge.defaultValue != null) return false;
|
||||
continue;
|
||||
}
|
||||
final id = tryUuidValue(value);
|
||||
// A row of the same table inserted by the same merge would have
|
||||
// to be proven first; the pass orders that.
|
||||
if (id == null || (edge.parentTableName == tableName && ids.contains(id))) {
|
||||
return false;
|
||||
}
|
||||
if (positions[(edge.parentTableName, id)] case final parentAt?) {
|
||||
if (parentAt > positions[(tableName, rowId)]!) return false;
|
||||
continue;
|
||||
}
|
||||
(parentsByTable[edge.parentTableName] ??= {}).add(id);
|
||||
}
|
||||
}
|
||||
final referencingColumnsByTable = <String, Set<String>>{};
|
||||
for (final edge in parentEdges) {
|
||||
if (edge.action == ForeignKeyAction.setDefault &&
|
||||
ids.contains(tryUuidValue(edge.defaultValue))) {
|
||||
return false;
|
||||
}
|
||||
(referencingColumnsByTable[edge.childTableName] ??= {}).add(edge.childColumn);
|
||||
}
|
||||
final uniqueClaims = <Map<String, Set<Object?>>>[];
|
||||
for (final index in _uniqueResolver.uniqueIndexesFor(tableName)) {
|
||||
final tuples = <String>{};
|
||||
final claims = <String, Set<Object?>>{};
|
||||
for (final row in rows.values) {
|
||||
final tuple = [
|
||||
for (final column in index.indexedColumns) valueOf(row, column),
|
||||
];
|
||||
if (tuple.any((value) => value == null)) continue;
|
||||
if (!tuples.add(tuple.map(canonicalProjectionValue).join('\x1f'))) {
|
||||
return false;
|
||||
}
|
||||
for (final (i, column) in index.indexedColumns.indexed) {
|
||||
(claims[column] ??= {}).add(tuple[i]);
|
||||
}
|
||||
}
|
||||
if (claims.isNotEmpty) uniqueClaims.add(claims);
|
||||
}
|
||||
if (!await _context.localProjectionDependenciesAreStable(
|
||||
tableName: tableName,
|
||||
rowIds: ids,
|
||||
persisted: false,
|
||||
parentsByTable: parentsByTable,
|
||||
referencingColumnsByTable: referencingColumnsByTable,
|
||||
uniqueClaims: uniqueClaims,
|
||||
transaction: transaction,
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue