perf: Batch projection reads and engine writes (#157)

This commit is contained in:
Marcelo Mendonça Soares 2026-10-03 00:27:56 -03:00 • committed by GitHub
commit e42075cdd9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1124 additions and 355 deletions

View file

@ -22,6 +22,16 @@
- Remove unnecessary comments from production code. - Remove unnecessary comments from production code.
- Ensure linting passes before considering the task finished. - Ensure linting passes before considering the task finished.
## Performance Changes
- Do not infer application bulk-write intent by inspecting or comparing domain
row values. Applications express uniform bulk edits explicitly through
`updateWhere`; the engine must not rediscover that intent from equal values.
- Engine-owned planning may drive batching, including projected explicit-null
column sets, query predicates, schema identities, and merge operation boundaries.
Preserve domain inspection required for CRDT correctness and dependency traversal.
- Compare proposed gains against the best existing API for the operation.
## Pubspec Dependency Management ## Pubspec Dependency Management
- Do not add any new dependencies, unless explicitly asked. - Do not add any new dependencies, unless explicitly asked.

View file

@ -1,68 +1,5 @@
part of 'recorder.dart'; part of 'recorder.dart';
/// Field metadata for merged inserts, waiting to be written as one batch.
///
/// Every query behind [CrdtForeignKeyProjector.recordInsertAttempts] already
/// takes a set of row ids, so a batch of inserts into one table costs the same
/// as one insert. Rows are grouped by node as well as table because the merge
/// cache stores each field with the node that authored it.
class _PendingInsertAttempts {
final Map<(String, int), ({Set<UuidValue> rowIds, CrdtNode node})> _byGroup = {};
final Map<String, ProjectionAttemptsByField> _attemptsByTable = {};
final Set<MergeRowKey> _rows = {};
bool get isEmpty => _byGroup.isEmpty;
/// Whether metadata for [rowKey] is still unwritten.
bool holds(MergeRowKey rowKey) => _rows.contains(rowKey);
void add({
required String tableName,
required UuidValue rowId,
required CrdtNode node,
required ProjectionAttemptsByField attempts,
}) {
_byGroup
.putIfAbsent(
(tableName, node.id!),
() => (rowIds: <UuidValue>{}, node: node),
)
.rowIds
.add(rowId);
_rows.add((tableName, rowId));
if (attempts.isEmpty) return;
_attemptsByTable
.putIfAbsent(tableName, () => <MergeFieldKey, ProjectionAttempt>{})
.addAll(attempts);
}
/// Empties the collector and returns what it held.
List<
({
String tableName,
Set<UuidValue> rowIds,
CrdtNode node,
ProjectionAttemptsByField attempts,
})
>
take() {
final groups = [
for (final MapEntry(key: key, value: group) in _byGroup.entries)
(
tableName: key.$1,
rowIds: group.rowIds,
node: group.node,
attempts:
_attemptsByTable[key.$1] ?? const <MergeFieldKey, ProjectionAttempt>{},
),
];
_byGroup.clear();
_attemptsByTable.clear();
_rows.clear();
return groups;
}
}
/// Adds merge-specific behavior to [CrdtMutationRecorder]. /// Adds merge-specific behavior to [CrdtMutationRecorder].
extension CrdtMergeRecorderExtension on CrdtMutationRecorder { extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
/// Merges remote CRDT changes into the current database. /// Merges remote CRDT changes into the current database.
@ -142,62 +79,61 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
transaction, transaction,
); );
// Field metadata for merged inserts is recorded per table and node rather final writes = _MergeWriteBatch(
// than per row: every query behind it already takes a set of row ids. session: _session,
final pendingAttempts = _PendingInsertAttempts(); projector: _foreignKeyProjector,
fields: context.fields,
transaction: transaction,
);
final deferredDeletes = <CrdtMergeDelete>[]; final deferredDeletes = <CrdtMergeDelete>[];
for (final operation in operations) { await writes.apply(
if (!_context.isCrdtTrackedTableName(operation.tableName)) { operations.where(
continue; (operation) => _context.isCrdtTrackedTableName(operation.tableName),
} ),
// A later operation on a row resolves its field metadata through the (operation) async {
// merge cache, so anything still pending for that row is written first. switch (operation) {
if (pendingAttempts.holds((operation.tableName, operation.uuidRowId))) { case final CrdtMergeInsert insert:
await _flushInsertAttempts(pendingAttempts, context, transaction); await _applyMergeInsert(
} insert,
nodesByUuid,
context,
authoredOverlays,
batch.plan,
writes,
presence,
transaction,
);
case final CrdtMergeUpdate update:
await _applyMergeUpdate(
update,
nodesByUuid,
context,
authoredOverlays,
writes,
transaction,
);
case final CrdtMergeDelete delete:
// A higher delete/restore generation can have an older HLC than a
// concurrent reinsertion. Bootstrap must retain it until this batch
// has inserted the row it belongs to.
if (!context.rows.containsKey((delete.tableName, delete.uuidRowId))) {
deferredDeletes.add(delete);
return;
}
await _applyMergeDelete(
delete,
nodesByUuid,
context,
transaction,
);
// A delete can hide the row, and a resurrecting one can bring it
// back; either way what was known about it no longer holds.
presence?.forget((delete.tableName, delete.uuidRowId));
}
},
);
switch (operation) {
case final CrdtMergeInsert insert:
await _applyMergeInsert(
insert,
nodesByUuid,
context,
authoredOverlays,
batch.plan,
pendingAttempts,
presence,
transaction,
);
case final CrdtMergeUpdate update:
await _applyMergeUpdate(
update,
nodesByUuid,
context,
authoredOverlays,
transaction,
);
case final CrdtMergeDelete delete:
// A higher delete/restore generation can have an older HLC than a
// concurrent reinsertion. Bootstrap must retain it until this batch
// has inserted the row it belongs to.
if (!context.rows.containsKey((delete.tableName, delete.uuidRowId))) {
deferredDeletes.add(delete);
continue;
}
await _applyMergeDelete(
delete,
nodesByUuid,
context,
transaction,
);
// A delete can hide the row, and a resurrecting one can bring it
// back; either way what was known about it no longer holds.
presence?.forget((delete.tableName, delete.uuidRowId));
}
}
await _flushInsertAttempts(pendingAttempts, context, transaction);
for (final delete in deferredDeletes) { for (final delete in deferredDeletes) {
await _applyMergeDelete(delete, nodesByUuid, context, transaction); await _applyMergeDelete(delete, nodesByUuid, context, transaction);
} }
@ -471,7 +407,7 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
MergeContext context, MergeContext context,
Map<MergeFieldKey, Object?> authoredOverlays, Map<MergeFieldKey, Object?> authoredOverlays,
ProjectionPlan? batchPlan, ProjectionPlan? batchPlan,
_PendingInsertAttempts pendingAttempts, _MergeWriteBatch writes,
CrdtForeignKeyPresenceCache? presence, CrdtForeignKeyPresenceCache? presence,
Transaction transaction, Transaction transaction,
) async { ) async {
@ -525,7 +461,7 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
// same-space recovery), so same-merge field updates resolve ownership // same-space recovery), so same-merge field updates resolve ownership
// from the cache instead of re-reading the domain row. // from the cache instead of re-reading the domain row.
context.domainOwners[rowKey] = (exists: true, spaceId: mergingSpaceId); context.domainOwners[rowKey] = (exists: true, spaceId: mergingSpaceId);
pendingAttempts.add( writes.recordInsertAttempts(
tableName: insert.tableName, tableName: insert.tableName,
rowId: insert.uuidRowId, rowId: insert.uuidRowId,
node: remoteNode, node: remoteNode,
@ -616,36 +552,12 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
return (domain: plannedDomain, attempts: attempts); return (domain: plannedDomain, attempts: attempts);
} }
/// Writes the field metadata collected for merged inserts, one call per
/// table and node.
///
/// Grouped by node because the merge cache pairs every field it stores with
/// the node that authored it, which is how the cached field resolves its own
/// clock.
Future<void> _flushInsertAttempts(
_PendingInsertAttempts pending,
MergeContext context,
Transaction transaction,
) async {
if (pending.isEmpty) return;
final groups = pending.take();
for (final group in groups) {
await _foreignKeyProjector.recordInsertAttempts(
group.tableName,
group.rowIds,
transaction,
group.attempts,
mergeCache: (fields: context.fields, node: group.node),
);
}
}
Future<void> _applyMergeUpdate( Future<void> _applyMergeUpdate(
CrdtMergeUpdate update, CrdtMergeUpdate update,
Map<UuidValue, CrdtNode> remoteNodes, Map<UuidValue, CrdtNode> remoteNodes,
MergeContext context, MergeContext context,
Map<MergeFieldKey, Object?> authoredOverlays, Map<MergeFieldKey, Object?> authoredOverlays,
_MergeWriteBatch writes,
Transaction transaction, Transaction transaction,
) async { ) async {
final rowKey = (update.tableName, update.uuidRowId); final rowKey = (update.tableName, update.uuidRowId);
@ -673,6 +585,7 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
remoteNode: remoteNode, remoteNode: remoteNode,
incomingHlc: update.hlc, incomingHlc: update.hlc,
fields: context.fields, fields: context.fields,
writes: writes,
transaction: transaction, transaction: transaction,
); );
@ -780,8 +693,9 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
CrdtNode remoteNode, CrdtNode remoteNode,
Hlc incomingHlc, Hlc incomingHlc,
CrdtDataField? currentField, CrdtDataField? currentField,
Transaction transaction, Transaction transaction, {
) async { _MergeWriteBatch? writes,
}) async {
// Projection can create field metadata for a row this merge context loaded // Projection can create field metadata for a row this merge context loaded
// before the row existed, so an unseen field may still be persisted. Adopt // before the row existed, so an unseen field may still be persisted. Adopt
// it instead of inserting a second row onto the row/column unique index. // it instead of inserting a second row onto the row/column unique index.
@ -794,6 +708,8 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
); );
if (existingField == null) { if (existingField == null) {
// Dependent metadata needs the field identity, so new fields cannot
// wait in the batch of existing field-clock updates.
final insertedField = await CrdtDataField.db.insertRow( final insertedField = await CrdtDataField.db.insertRow(
_session, _session,
CrdtDataField( CrdtDataField(
@ -822,12 +738,16 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
hlcCounter: incomingHlc.counter, hlcCounter: incomingHlc.counter,
); );
await CrdtDataField.db.update( if (writes != null) {
_session, writes.recordFieldUpdate(updatedField);
[updatedField], } else {
columns: (t) => [t.nodeId, t.hlcDatetime, t.hlcCounter], await CrdtDataField.db.update(
transaction: transaction, _session,
); [updatedField],
columns: (t) => [t.nodeId, t.hlcDatetime, t.hlcCounter],
transaction: transaction,
);
}
return updatedField; return updatedField;
} }
@ -1018,6 +938,7 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
required Map<MergeFieldKey, CrdtDataField> fields, required Map<MergeFieldKey, CrdtDataField> fields,
required Transaction transaction, required Transaction transaction,
CrdtSchemaColumn? schemaColumn, CrdtSchemaColumn? schemaColumn,
_MergeWriteBatch? writes,
}) async { }) async {
final resolvedSchemaColumn = final resolvedSchemaColumn =
schemaColumn ?? _context.schemaColumn(tableName, columnName); schemaColumn ?? _context.schemaColumn(tableName, columnName);
@ -1035,6 +956,7 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
incomingHlc, incomingHlc,
currentField, currentField,
transaction, transaction,
writes: writes,
); );
return true; return true;
} }

View file

@ -94,7 +94,7 @@ TableRow<UuidValue?> withExplicitInsertNulls(
/// Keeps per-row default semantics when a batch contains projected nulls. /// Keeps per-row default semantics when a batch contains projected nulls.
/// PostgreSQL builds a batch using the first row's table, so rows with different /// PostgreSQL builds a batch using the first row's table, so rows with different
/// null overrides must be written separately within the caller's transaction. /// null overrides use separate consecutive batches in the caller's transaction.
@internal @internal
Future<List<T>> insertWithExplicitNulls<T extends TableRow>( Future<List<T>> insertWithExplicitNulls<T extends TableRow>(
Database database, Database database,
@ -112,15 +112,30 @@ Future<List<T>> insertWithExplicitNulls<T extends TableRow>(
noReturn: noReturn, noReturn: noReturn,
); );
} }
return [ final inserted = <T>[];
for (final row in rows) var start = 0;
...(await database.insert<TableRow<UuidValue?>>( while (start < rows.length) {
[withExplicitInsertNulls(row, explicitNulls[row.id] ?? const {})], final columns = explicitNulls[rows[start].id] ?? const <String>{};
transaction: transaction, var end = start + 1;
ignoreConflicts: ignoreConflicts, while (end < rows.length) {
noReturn: noReturn, final next = explicitNulls[rows[end].id] ?? const <String>{};
)).cast<T>(), if (columns.length != next.length || !columns.containsAll(next)) break;
]; end++;
}
final result = await database.insert<TableRow<UuidValue?>>(
[
for (var index = start; index < end; index++)
withExplicitInsertNulls(rows[index], columns),
],
transaction: transaction,
ignoreConflicts: ignoreConflicts,
noReturn: noReturn,
);
inserted.addAll(result.cast<T>());
start = end;
}
return inserted;
} }
class _ExplicitNullRow implements TableRow<UuidValue?> { class _ExplicitNullRow implements TableRow<UuidValue?> {

View file

@ -11,6 +11,8 @@ import 'recorder_context.dart';
import 'types.dart'; import 'types.dart';
import 'unique_resolver.dart'; import 'unique_resolver.dart';
part 'projection_reads.dart';
typedef _ProjectedForeignKeyRow = ({ typedef _ProjectedForeignKeyRow = ({
MergeRowKey key, MergeRowKey key,
CrdtDataRow crdtRow, CrdtDataRow crdtRow,
@ -880,10 +882,11 @@ class CrdtForeignKeyProjector {
Set<String>? seedTables, Set<String>? seedTables,
Set<MergeRowKey>? seedRows, Set<MergeRowKey>? seedRows,
}) async { }) async {
final rows = <MergeRowKey, _ProjectedForeignKeyRow>{}; final reader = _ProjectionReader(_context, transaction);
final fieldIds = <MergeFieldKey, int>{}; final rows = reader.rows;
final attemptedValues = <MergeFieldKey, CrdtDataAttemptedValue>{}; final fieldIds = reader.fieldIds;
final fieldHlcs = <MergeFieldKey, Hlc>{}; final attemptedValues = reader.attemptedValues;
final fieldHlcs = reader.fieldHlcs;
final tablesToLoad = _tablesToLoad( final tablesToLoad = _tablesToLoad(
seedTables: seedTables, seedTables: seedTables,
@ -897,18 +900,10 @@ class CrdtForeignKeyProjector {
); );
if (seedRows == null) { if (seedRows == null) {
for (final tableName in tablesToLoad) { await reader.loadRows(
await _loadTableRowsInto( requestedRows: {for (final table in tablesToLoad) table: null},
tableName: tableName, columnsByTable: columnsByTable,
rowIds: null, );
columnNames: columnsByTable[tableName] ?? const <String>{},
rows: rows,
fieldIds: fieldIds,
attemptedValues: attemptedValues,
fieldHlcs: fieldHlcs,
transaction: transaction,
);
}
} else { } else {
await _loadRowClosureInto( await _loadRowClosureInto(
tablesToLoad: tablesToLoad, tablesToLoad: tablesToLoad,
@ -916,11 +911,7 @@ class CrdtForeignKeyProjector {
pendingInserts: pendingInserts, pendingInserts: pendingInserts,
authoredOverlays: authoredOverlays, authoredOverlays: authoredOverlays,
columnsByTable: columnsByTable, columnsByTable: columnsByTable,
rows: rows, reader: reader,
fieldIds: fieldIds,
attemptedValues: attemptedValues,
fieldHlcs: fieldHlcs,
transaction: transaction,
); );
} }
@ -1105,78 +1096,6 @@ class CrdtForeignKeyProjector {
return childRowsByAttempt; return childRowsByAttempt;
} }
/// Loads [rowIds] of [tableName], or every row of it when [rowIds] is null.
///
/// Returns the row ids that exist, so a closure pass can expand from them.
Future<Set<UuidValue>> _loadTableRowsInto({
required String tableName,
required Set<UuidValue>? rowIds,
required Set<String> columnNames,
required Map<MergeRowKey, _ProjectedForeignKeyRow> rows,
required Map<MergeFieldKey, int> fieldIds,
required Map<MergeFieldKey, CrdtDataAttemptedValue> attemptedValues,
required Map<MergeFieldKey, Hlc> fieldHlcs,
required Transaction transaction,
}) async {
if (rowIds != null && rowIds.isEmpty) return const {};
final (tableId, _) = _context.schema[tableName]!;
final userId = _context.hlcManagerFor(transaction).normalizedSpaceId;
final crdtRows = await CrdtDataRow.db.find(
_context.databaseSession,
where: (t) {
final inSpace = t.spaceId.equals(userId) & t.tblId.equals(tableId);
return rowIds == null ? inSpace : inSpace & t.uuidRowId.inSet(rowIds);
},
include: CrdtDataRow.include(
node: CrdtNode.include(),
deleted: CrdtDataDeleted.include(node: CrdtNode.include()),
),
orderBy: (t) => t.uuidRowId,
transaction: transaction,
);
if (crdtRows.isEmpty) return const {};
final loadedIds = {for (final row in crdtRows) row.uuidRowId};
final valuesByRowId = columnNames.isEmpty
? <UuidValue, Map<String, Object?>>{}
: await _context.readDomainColumnValues(
tableName,
loadedIds,
columnNames.toList(),
transaction,
);
for (final row in crdtRows) {
final key = (tableName, row.uuidRowId);
rows[key] = (
key: key,
crdtRow: row,
values: Map<String, Object?>.from(valuesByRowId[row.uuidRowId] ?? const {}),
);
}
if (columnNames.isNotEmpty) {
final loadedFields = await _loadFields(
tableName: tableName,
rowIds: loadedIds,
columnNames: columnNames,
transaction: transaction,
);
for (final field in loadedFields) {
final fieldKey = (tableName, field.row!.uuidRowId, field.column!.name);
fieldIds[fieldKey] = field.id!;
fieldHlcs[fieldKey] = field.hlc;
final attempted = field.attemptedValue;
if (attempted != null) {
attemptedValues[fieldKey] = attempted;
}
}
}
return loadedIds;
}
/// Loads the rows the seeds can reach instead of every row of their tables. /// Loads the rows the seeds can reach instead of every row of their tables.
/// ///
/// Defaults are loaded as evaluation context. Their implicit dependents join /// Defaults are loaded as evaluation context. Their implicit dependents join
@ -1199,12 +1118,13 @@ class CrdtForeignKeyProjector {
required List<PendingProjectionRow> pendingInserts, required List<PendingProjectionRow> pendingInserts,
required Map<MergeFieldKey, Object?> authoredOverlays, required Map<MergeFieldKey, Object?> authoredOverlays,
required Map<String, Set<String>> columnsByTable, required Map<String, Set<String>> columnsByTable,
required Map<MergeRowKey, _ProjectedForeignKeyRow> rows, required _ProjectionReader reader,
required Map<MergeFieldKey, int> fieldIds,
required Map<MergeFieldKey, CrdtDataAttemptedValue> attemptedValues,
required Map<MergeFieldKey, Hlc> fieldHlcs,
required Transaction transaction,
}) async { }) async {
final transaction = reader._transaction;
final rows = reader.rows;
final attemptedValues = reader.attemptedValues;
final hasAttemptedClaims = await reader.hasAttemptedClaims(tablesToLoad);
final unwrittenValues = _unwrittenValues(pendingInserts, authoredOverlays); final unwrittenValues = _unwrittenValues(pendingInserts, authoredOverlays);
final requested = <String, Set<UuidValue>>{}; final requested = <String, Set<UuidValue>>{};
@ -1245,20 +1165,10 @@ class CrdtForeignKeyProjector {
final wave = queued; final wave = queued;
queued = <String, Set<UuidValue>>{}; queued = <String, Set<UuidValue>>{};
final loadedNow = <String, Set<UuidValue>>{}; final loadedNow = await reader.loadRows(
for (final MapEntry(key: tableName, value: ids) in wave.entries) { requestedRows: wave,
final loaded = await _loadTableRowsInto( columnsByTable: columnsByTable,
tableName: tableName, );
rowIds: ids,
columnNames: columnsByTable[tableName] ?? const <String>{},
rows: rows,
fieldIds: fieldIds,
attemptedValues: attemptedValues,
fieldHlcs: fieldHlcs,
transaction: transaction,
);
if (loaded.isNotEmpty) loadedNow[tableName] = loaded;
}
for (final MapEntry(key: tableName, value: ids) in frontier.entries) { for (final MapEntry(key: tableName, value: ids) in frontier.entries) {
(loadedNow[tableName] ??= <UuidValue>{}).addAll(ids); (loadedNow[tableName] ??= <UuidValue>{}).addAll(ids);
@ -1268,6 +1178,7 @@ class CrdtForeignKeyProjector {
await _expandRowClosure( await _expandRowClosure(
loadedNow: loadedNow, loadedNow: loadedNow,
hasAttemptedClaims: hasAttemptedClaims,
tablesToLoad: tablesToLoad, tablesToLoad: tablesToLoad,
rows: rows, rows: rows,
unwrittenValues: unwrittenValues, unwrittenValues: unwrittenValues,
@ -1535,6 +1446,7 @@ class CrdtForeignKeyProjector {
required Map<MergeRowKey, Map<String, Object?>> unwrittenValues, required Map<MergeRowKey, Map<String, Object?>> unwrittenValues,
required Map<MergeFieldKey, CrdtDataAttemptedValue> attemptedValues, required Map<MergeFieldKey, CrdtDataAttemptedValue> attemptedValues,
required void Function(String tableName, Iterable<UuidValue> ids) enqueue, required void Function(String tableName, Iterable<UuidValue> ids) enqueue,
required bool hasAttemptedClaims,
required Transaction transaction, required Transaction transaction,
}) async { }) async {
// Both the stored and the unwritten value matter: the walk has to reach the // Both the stored and the unwritten value matter: the walk has to reach the
@ -1553,14 +1465,19 @@ class CrdtForeignKeyProjector {
}..remove(null); }..remove(null);
} }
final claims = _ProjectionClaimReads(
_context,
transaction,
includeAttemptedClaims: hasAttemptedClaims,
);
for (final edge in _foreignKeys.edges) { for (final edge in _foreignKeys.edges) {
await _expandToChildren( _expandToChildren(
edge: edge, edge: edge,
loadedNow: loadedNow, loadedNow: loadedNow,
tablesToLoad: tablesToLoad, tablesToLoad: tablesToLoad,
valuesFor: valuesFor, valuesFor: valuesFor,
enqueue: enqueue, collectClaims: claims.add,
transaction: transaction,
); );
await _expandToParents( await _expandToParents(
edge: edge, edge: edge,
@ -1574,27 +1491,27 @@ class CrdtForeignKeyProjector {
); );
} }
await _expandUniqueClaimants( _expandUniqueClaimants(
loadedNow: loadedNow, loadedNow: loadedNow,
attemptedValues: attemptedValues, attemptedValues: attemptedValues,
valuesFor: valuesFor, valuesFor: valuesFor,
enqueue: enqueue, collectClaims: claims.add,
transaction: transaction,
); );
await claims.enqueueMatches(enqueue);
} }
/// Enqueues the children of the parents loaded in this wave. /// Enqueues the children of the parents loaded in this wave.
/// ///
/// Hiding or restoring a parent decides its children's fate, so they join the /// Hiding or restoring a parent decides its children's fate, so they join the
/// walk whether they still name it or only remember it as an attempt. /// walk whether they still name it or only remember it as an attempt.
Future<void> _expandToChildren({ void _expandToChildren({
required ForeignKeyEdge edge, required ForeignKeyEdge edge,
required Map<String, Set<UuidValue>> loadedNow, required Map<String, Set<UuidValue>> loadedNow,
required Set<String> tablesToLoad, required Set<String> tablesToLoad,
required Set<Object?> Function(MergeRowKey, String) valuesFor, required Set<Object?> Function(MergeRowKey, String) valuesFor,
required void Function(String tableName, Iterable<UuidValue> ids) enqueue, required void Function(String, Map<String, Set<Object?>>) collectClaims,
required Transaction transaction, }) {
}) async {
final parentIds = loadedNow[edge.parentTableName]; final parentIds = loadedNow[edge.parentTableName];
if (parentIds == null || parentIds.isEmpty) return; if (parentIds == null || parentIds.isEmpty) return;
if (!tablesToLoad.contains(edge.childTableName)) return; if (!tablesToLoad.contains(edge.childTableName)) return;
@ -1611,12 +1528,7 @@ class CrdtForeignKeyProjector {
} }
if (references.isEmpty) return; if (references.isEmpty) return;
await _enqueueRowsClaiming( collectClaims(edge.childTableName, {edge.childColumn: references});
tableName: edge.childTableName,
valuesByColumn: {edge.childColumn: references},
enqueue: enqueue,
transaction: transaction,
);
} }
/// Enqueues the parents of the children loaded in this wave. /// Enqueues the parents of the children loaded in this wave.
@ -1686,13 +1598,12 @@ class CrdtForeignKeyProjector {
/// own unique index for it. A row that wants a value it does not hold is /// own unique index for it. A row that wants a value it does not hold is
/// already loaded, because holding a projection means holding an attempted /// already loaded, because holding a projection means holding an attempted
/// value. /// value.
Future<void> _expandUniqueClaimants({ void _expandUniqueClaimants({
required Map<String, Set<UuidValue>> loadedNow, required Map<String, Set<UuidValue>> loadedNow,
required Map<MergeFieldKey, CrdtDataAttemptedValue> attemptedValues, required Map<MergeFieldKey, CrdtDataAttemptedValue> attemptedValues,
required Set<Object?> Function(MergeRowKey, String) valuesFor, required Set<Object?> Function(MergeRowKey, String) valuesFor,
required void Function(String tableName, Iterable<UuidValue> ids) enqueue, required void Function(String, Map<String, Set<Object?>>) collectClaims,
required Transaction transaction, }) {
}) async {
for (final MapEntry(key: tableName, value: rowIds) in loadedNow.entries) { for (final MapEntry(key: tableName, value: rowIds) in loadedNow.entries) {
for (final uniqueIndex in _uniqueResolver.uniqueIndexesFor(tableName)) { for (final uniqueIndex in _uniqueResolver.uniqueIndexesFor(tableName)) {
final claimedByColumn = <String, Set<Object?>>{}; final claimedByColumn = <String, Set<Object?>>{};
@ -1713,46 +1624,11 @@ class CrdtForeignKeyProjector {
continue; continue;
} }
await _enqueueRowsClaiming( collectClaims(tableName, claimedByColumn);
tableName: tableName,
valuesByColumn: claimedByColumn,
enqueue: enqueue,
transaction: transaction,
);
} }
} }
} }
/// Enqueues every row of [tableName] that holds, or is owed, these values.
///
/// A row that was repaired or released away from a value no longer names it
/// in the domain column, so the attempted value is the only record left of
/// the claim it is waiting to take back.
Future<void> _enqueueRowsClaiming({
required String tableName,
required Map<String, Set<Object?>> valuesByColumn,
required void Function(String tableName, Iterable<UuidValue> ids) enqueue,
required Transaction transaction,
}) async {
enqueue(
tableName,
await _context.findDomainRowIdsWhereColumnsIn(
tableName: tableName,
valuesByColumn: valuesByColumn,
transaction: transaction,
),
);
enqueue(
tableName,
await _context.findRowIdsHoldingAttemptedValues(
tableName: tableName,
columnNames: valuesByColumn.keys.toSet(),
values: {for (final values in valuesByColumn.values) ...values},
transaction: transaction,
),
);
}
Future<List<CrdtDataField>> _loadFields({ Future<List<CrdtDataField>> _loadFields({
required String tableName, required String tableName,
required Set<UuidValue> rowIds, required Set<UuidValue> rowIds,

View file

@ -0,0 +1,193 @@
part of 'foreign_key_projector.dart';
/// Loads metadata for one projection read phase in one space and transaction.
/// Field identities are resolved through the scoped rows and registered columns,
/// so equal domain UUIDs in different tables remain distinct.
///
/// Its maps become mutable projection state. Complete loading before applying
/// pending inserts or authored overlays to them.
class _ProjectionReader {
_ProjectionReader(this._context, this._transaction);
final CrdtRecorderContext _context;
final Transaction _transaction;
final rows = <MergeRowKey, _ProjectedForeignKeyRow>{};
final fieldIds = <MergeFieldKey, int>{};
final attemptedValues = <MergeFieldKey, CrdtDataAttemptedValue>{};
final fieldHlcs = <MergeFieldKey, Hlc>{};
/// Whether this space and these tables hold any attempted claims.
/// A false result lets the closure skip attempted-claim queries, but is
/// valid only until the next write phase; each projection checks again.
Future<bool> hasAttemptedClaims(Set<String> tablesToLoad) async {
final spaceId = _context.hlcManagerFor(_transaction).normalizedSpaceId;
return tablesToLoad.isNotEmpty &&
await CrdtDataAttemptedValue.db.findFirstRow(
_context.databaseSession,
where: (t) =>
t.field.row.spaceId.equals(spaceId) &
t.field.row.tblId.inSet({
for (final table in tablesToLoad) _context.schema[table]!.$1,
}),
transaction: _transaction,
) !=
null;
}
/// Loads one closure wave, sharing metadata reads across its tables.
/// A null row-id set requests the whole table during a full rebuild.
Future<Map<String, Set<UuidValue>>> loadRows({
required Map<String, Set<UuidValue>?> requestedRows,
required Map<String, Set<String>> columnsByTable,
}) async {
final tablesById = {
for (final entry in requestedRows.entries)
if (entry.value == null || entry.value!.isNotEmpty)
_context.schema[entry.key]!.$1: entry.key,
};
if (tablesById.isEmpty) return {};
final spaceId = _context.hlcManagerFor(_transaction).normalizedSpaceId;
final crdtRows = await CrdtDataRow.db.find(
_context.databaseSession,
where: (t) =>
t.spaceId.equals(spaceId) &
tablesById.entries
.map((entry) {
final rowIds = requestedRows[entry.value];
final table = t.tblId.equals(entry.key);
return rowIds == null ? table : table & t.uuidRowId.inSet(rowIds);
})
.reduce((left, right) => left | right),
include: CrdtDataRow.include(
node: CrdtNode.include(),
deleted: CrdtDataDeleted.include(node: CrdtNode.include()),
),
orderBy: (t) => t.uuidRowId,
transaction: _transaction,
);
final metadataByTable = <String, List<CrdtDataRow>>{};
for (final row in crdtRows) {
(metadataByTable[tablesById[row.tblId]!] ??= []).add(row);
}
final loadedByTable = <String, Set<UuidValue>>{};
for (final tableName in requestedRows.keys) {
final tableRows = metadataByTable[tableName];
if (tableRows == null) continue;
final loadedIds = {for (final row in tableRows) row.uuidRowId};
loadedByTable[tableName] = loadedIds;
final columns = columnsByTable[tableName] ?? const <String>{};
final valuesByRowId = columns.isEmpty
? <UuidValue, Map<String, Object?>>{}
: await _context.readDomainColumnValues(
tableName,
loadedIds,
columns.toList(),
_transaction,
);
for (final row in tableRows) {
final key = (tableName, row.uuidRowId);
rows[key] = (
key: key,
crdtRow: row,
values: Map<String, Object?>.from(valuesByRowId[row.uuidRowId] ?? const {}),
);
}
}
final columnsById = <int, String>{};
final fieldRequests = <({Set<int> rows, Set<int> columns})>[];
for (final tableName in metadataByTable.keys) {
final columns = _context.schemaColumnIds(
tableName,
columnsByTable[tableName] ?? const <String>{},
);
if (columns.isEmpty) continue;
columnsById.addAll({for (final entry in columns.entries) entry.value: entry.key});
fieldRequests.add((
rows: {for (final row in metadataByTable[tableName]!) row.id!},
columns: columns.values.toSet(),
));
}
if (fieldRequests.isEmpty) return loadedByTable;
final rowKeysById = {
for (final row in crdtRows) row.id!: (tablesById[row.tblId]!, row.uuidRowId),
};
final fields = await CrdtDataField.db.find(
_context.databaseSession,
where: (t) => fieldRequests
.map((request) {
return t.rowId.inSet(request.rows) & t.columnId.inSet(request.columns);
})
.reduce((left, right) => left | right),
include: CrdtDataField.include(
node: CrdtNode.include(),
attemptedValue: CrdtDataAttemptedValue.include(),
),
transaction: _transaction,
);
for (final field in fields) {
final rowKey = rowKeysById[field.rowId]!;
final fieldKey = (rowKey.$1, rowKey.$2, columnsById[field.columnId]!);
fieldIds[fieldKey] = field.id!;
fieldHlcs[fieldKey] = field.hlc;
if (field.attemptedValue case final attempted?) {
attemptedValues[fieldKey] = attempted;
}
}
return loadedByTable;
}
}
/// Collects the required claim lookups for one closure wave by table.
/// Traversal selects the claims; this collector handles grouped execution.
/// Each composite claim remains independent when the queries are combined.
class _ProjectionClaimReads {
_ProjectionClaimReads(
this._context,
this._transaction, {
required this._includeAttemptedClaims,
});
final CrdtRecorderContext _context;
final Transaction _transaction;
final bool _includeAttemptedClaims;
final _byTable = <String, List<Map<String, Set<Object?>>>>{};
void add(String tableName, Map<String, Set<Object?>> claim) {
(_byTable[tableName] ??= []).add(claim);
}
Future<void> enqueueMatches(
void Function(String tableName, Iterable<UuidValue> ids) enqueue,
) async {
for (final MapEntry(key: tableName, value: claims) in _byTable.entries) {
enqueue(
tableName,
await _context.findDomainRowIdsWhereAnyColumnsIn(
tableName: tableName,
alternatives: claims,
transaction: _transaction,
),
);
if (!_includeAttemptedClaims) continue;
enqueue(
tableName,
await _context.findRowIdsHoldingAnyAttemptedValues(
tableName: tableName,
alternatives: [
for (final claim in claims)
(
columnNames: claim.keys.toSet(),
values: {for (final values in claim.values) ...values},
),
],
transaction: _transaction,
),
);
}
}
}

View file

@ -531,22 +531,43 @@ WHERE r."spaceId" = $spaceId AND r."tblId" = $childTableId
required Transaction transaction, required Transaction transaction,
}) async { }) async {
if (columnNames.isEmpty || values.isEmpty) return const {}; if (columnNames.isEmpty || values.isEmpty) return const {};
return findRowIdsHoldingAnyAttemptedValues(
tableName: tableName,
alternatives: [(columnNames: columnNames, values: values)],
transaction: transaction,
);
}
/// Matches the union of claim lookups without mixing their columns and values.
Future<Set<UuidValue>> findRowIdsHoldingAnyAttemptedValues({
required String tableName,
required List<({Set<String> columnNames, Set<Object?> values})> alternatives,
required Transaction transaction,
}) async {
final (tableId, columns) = schema[tableName]!; final (tableId, columns) = schema[tableName]!;
final columnIds = { final predicates = <String>{};
for (final columnName in columnNames) ?columns[columnName]?.id, for (final alternative in alternatives) {
}; final columnIds = {
if (columnIds.isEmpty) return const {}; for (final columnName in alternative.columnNames) ?columns[columnName]?.id,
};
if (columnIds.isEmpty) continue;
final encodedValues = { final encodedValues = {
for (final value in values) for (final value in alternative.values)
if (value != null) if (value != null)
ValueEncoder.instance.encodeColumnValue( ValueEncoder.instance.encodeColumnValue(
CrdtDataAttemptedValue.t.value, CrdtDataAttemptedValue.t.value,
Protocol().dynamicFieldToJson(value), Protocol().dynamicFieldToJson(value),
), ),
}; };
if (encodedValues.isEmpty) return const {}; if (encodedValues.isEmpty) continue;
predicates.add(
'(f."columnId" IN (${columnIds.join(', ')}) '
'AND a."value" IN (${encodedValues.join(', ')}))',
);
}
if (predicates.isEmpty) return const {};
final spaceId = hlcManagerFor(transaction).normalizedSpaceId; final spaceId = hlcManagerFor(transaction).normalizedSpaceId;
final result = await database.unsafeQuery( final result = await database.unsafeQuery(
@ -557,8 +578,7 @@ JOIN "crdt_data_fields" f ON f."id" = a."fieldId"
JOIN "crdt_data_rows" r ON r."id" = f."rowId" JOIN "crdt_data_rows" r ON r."id" = f."rowId"
WHERE r."spaceId" = $spaceId WHERE r."spaceId" = $spaceId
AND r."tblId" = $tableId AND r."tblId" = $tableId
AND f."columnId" IN (${columnIds.join(', ')}) AND (${predicates.join(' OR ')})
AND a."value" IN (${encodedValues.join(', ')})
''', ''',
transaction: transaction, transaction: transaction,
); );
@ -618,22 +638,38 @@ WHERE r."spaceId" = $spaceId
required String tableName, required String tableName,
required Map<String, Set<Object?>> valuesByColumn, required Map<String, Set<Object?>> valuesByColumn,
required Transaction transaction, required Transaction transaction,
}) async { }) => findDomainRowIdsWhereAnyColumnsIn(
if (valuesByColumn.isEmpty) return const {}; tableName: tableName,
if (valuesByColumn.values.any((values) => values.isEmpty)) return const {}; alternatives: [valuesByColumn],
transaction: transaction,
);
final predicates = valuesByColumn.entries /// Matches each composite claim independently and unions the matching row ids.
.map( Future<Set<UuidValue>> findDomainRowIdsWhereAnyColumnsIn({
(entry) => required String tableName,
'"${entry.key.escapeIdentifier()}" IN (${entry.value.sqlLiteralList()})', required List<Map<String, Set<Object?>>> alternatives,
) required Transaction transaction,
.join(') AND ('); }) async {
final predicates = <String>{};
for (final valuesByColumn in alternatives) {
if (valuesByColumn.isEmpty) continue;
if (valuesByColumn.values.any((values) => values.isEmpty)) continue;
final claim = valuesByColumn.entries
.map(
(entry) =>
'"${entry.key.escapeIdentifier()}" IN (${entry.value.sqlLiteralList()})',
)
.join(') AND (');
predicates.add('(($claim))');
}
if (predicates.isEmpty) return const {};
final result = await database.unsafeQuery( final result = await database.unsafeQuery(
''' '''
SELECT "id" SELECT "id"
FROM "${tableName.escapeIdentifier()}" FROM "${tableName.escapeIdentifier()}"
WHERE ($predicates) WHERE ${predicates.join(' OR ')}
''', ''',
transaction: transaction, transaction: transaction,
); );

View file

@ -0,0 +1,154 @@
part of 'recorder.dart';
/// Owns pending metadata for one merge and the boundaries that make it readable.
/// Context fields advance immediately. Existing field writes wait until an
/// insert, delete, pending insert's next operation, or successful completion.
/// Insert attempts are flushed before another operation touches their row.
class _MergeWriteBatch {
_MergeWriteBatch({
required this._session,
required this._projector,
required this._fields,
required this._transaction,
});
final DatabaseSession _session;
final CrdtForeignKeyProjector _projector;
final Map<MergeFieldKey, CrdtDataField> _fields;
final Transaction _transaction;
final _insertAttempts = _PendingInsertAttempts();
final _fieldUpdates = <int, CrdtDataField>{};
/// Successful completion persists all metadata before projection can run.
/// Failure leaves rollback to the enclosing transaction, without flushing.
///
/// Update callbacks must use the merge context's current clocks and record
/// changes to existing fields through [recordFieldUpdate].
Future<void> apply(
Iterable<CrdtMergeChange> operations,
Future<void> Function(CrdtMergeChange operation) applyOperation,
) async {
for (final operation in operations) {
final needsInsertMetadata = _insertAttempts.holds((
operation.tableName,
operation.uuidRowId,
));
// Reinserts can advance field clocks immediately. Flush older queued
// updates first so the final flush cannot overwrite those newer clocks.
if (operation is! CrdtMergeUpdate || needsInsertMetadata) {
await _flushFieldUpdates();
}
if (needsInsertMetadata) {
await _flushInsertAttempts();
}
await applyOperation(operation);
}
await _flushFieldUpdates();
await _flushInsertAttempts();
}
void recordFieldUpdate(CrdtDataField field) {
_fieldUpdates[field.id!] = field;
}
void recordInsertAttempts({
required String tableName,
required UuidValue rowId,
required CrdtNode node,
required ProjectionAttemptsByField attempts,
}) {
_insertAttempts.add(
tableName: tableName,
rowId: rowId,
node: node,
attempts: attempts,
);
}
Future<void> _flushFieldUpdates() async {
if (_fieldUpdates.isEmpty) return;
await CrdtDataField.db.update(
_session,
_fieldUpdates.values.toList(),
columns: (t) => [t.nodeId, t.hlcDatetime, t.hlcCounter],
transaction: _transaction,
noReturn: true,
);
_fieldUpdates.clear();
}
Future<void> _flushInsertAttempts() async {
for (final group in _insertAttempts.take()) {
await _projector.recordInsertAttempts(
group.tableName,
group.rowIds,
_transaction,
group.attempts,
mergeCache: (fields: _fields, node: group.node),
);
}
}
}
/// Field metadata for merged inserts, waiting to be written as one batch.
///
/// Groups rows by table and author: cached field clocks need the node that
/// authored each group.
class _PendingInsertAttempts {
final Map<(String, int), ({Set<UuidValue> rowIds, CrdtNode node})> _byGroup = {};
final Map<String, ProjectionAttemptsByField> _attemptsByTable = {};
final Set<MergeRowKey> _rows = {};
/// Whether metadata for [rowKey] is still unwritten.
bool holds(MergeRowKey rowKey) => _rows.contains(rowKey);
void add({
required String tableName,
required UuidValue rowId,
required CrdtNode node,
required ProjectionAttemptsByField attempts,
}) {
_byGroup
.putIfAbsent(
(tableName, node.id!),
() => (rowIds: <UuidValue>{}, node: node),
)
.rowIds
.add(rowId);
_rows.add((tableName, rowId));
if (attempts.isEmpty) return;
_attemptsByTable
.putIfAbsent(tableName, () => <MergeFieldKey, ProjectionAttempt>{})
.addAll(attempts);
}
/// Empties the collector and returns what it held.
List<
({
String tableName,
Set<UuidValue> rowIds,
CrdtNode node,
ProjectionAttemptsByField attempts,
})
>
take() {
final groups = [
for (final MapEntry(key: key, value: group) in _byGroup.entries)
(
tableName: key.$1,
rowIds: group.rowIds,
node: group.node,
attempts:
_attemptsByTable[key.$1] ?? const <MergeFieldKey, ProjectionAttempt>{},
),
];
_byGroup.clear();
_attemptsByTable.clear();
_rows.clear();
return groups;
}
}

View file

@ -21,6 +21,7 @@ import 'schema.dart';
import 'unique_index_utils.dart'; import 'unique_index_utils.dart';
part 'merge.dart'; part 'merge.dart';
part 'merge_write_batch.dart';
typedef _CrdtSchema = Map<String, (int, Map<String, CrdtSchemaColumn>)>; typedef _CrdtSchema = Map<String, (int, Map<String, CrdtSchemaColumn>)>;

View file

@ -0,0 +1,360 @@
import 'dart:convert';
import 'package:serverpod/serverpod.dart' show DatabaseQueryException;
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/sync_topology.dart';
void main() {
initTestClientSession(createSessionPerTest: false);
group('Given an edited city and a town arriving with another parent,', () {
late SyncNode node;
late City originalParent;
late City replacementParent;
late Town town;
late Hlc originalNameClock;
late CrdtMergeUpdate finalName;
late CrdtMergeUpdate finalParent;
late CrdtMergeSet changes;
setUpAll(() async {
node = await syncNode(await createAdditionalTestSession(), testSyncTables);
originalParent = City(id: const Uuid().v7obj(), name: 'original parent');
replacementParent = City(id: const Uuid().v7obj(), name: 'replacement parent');
town = Town(id: const Uuid().v7obj(), name: 'town', cityId: originalParent.id);
await node.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
await City.db.insert(node.offlineSync, [
originalParent,
replacementParent,
], transaction: tx);
await City.db.updateRow(
node.offlineSync,
replacementParent.copyWith(name: 'locally edited'),
columns: (t) => [t.name],
transaction: tx,
);
});
originalNameClock = (await _fieldClock(
node,
'city',
replacementParent.id!,
'name',
))!;
final remote = Hlc(
originalNameClock.datetime.add(const Duration(seconds: 1)),
0,
const Uuid().v7obj(),
);
finalName = _update(
'city',
replacementParent.id!,
'name',
'final name',
Hlc(remote.datetime, 1, remote.nodeId),
);
finalParent = _update(
'town',
town.id!,
'cityId',
replacementParent.id,
Hlc(remote.datetime, 3, remote.nodeId),
);
changes = [
_update('city', replacementParent.id!, 'name', 'intermediate name', remote),
finalName,
CrdtMergeInsert(
uuidSpaceId: testCrdtUserId,
tableName: 'town',
uuidRowId: town.id!,
uuidNodeId: remote.nodeId,
hlcDatetime: remote.datetime,
hlcCounter: remote.counter + 2,
data: town,
),
finalParent,
CrdtMergeDelete(
uuidSpaceId: testCrdtUserId,
tableName: 'city',
uuidRowId: originalParent.id!,
uuidNodeId: remote.nodeId,
hlcDatetime: remote.datetime,
hlcCounter: remote.counter + 4,
clFlag: 2,
reason: CrdtDataDeletedReason.userDelete,
),
];
});
group(
'when one merge edits the city, inserts and reparents the town, and deletes its old parent,',
() {
late City? mergedCity;
late City? deletedParent;
late Town? mergedTown;
late Hlc? nameClock;
late Hlc? parentClock;
late String firstExport;
late String replayedExport;
setUpAll(() async {
await node.offlineSync.db.mergeChanges(changes, spaceId: testCrdtUserId);
mergedCity = await City.db.findById(node.offlineSync, replacementParent.id!);
deletedParent = await City.db.findById(node.offlineSync, originalParent.id!);
mergedTown = await Town.db.findById(node.offlineSync, town.id!);
nameClock = await _fieldClock(node, 'city', replacementParent.id!, 'name');
parentClock = await _fieldClock(node, 'town', town.id!, 'cityId');
firstExport = await _export(node);
await node.offlineSync.db.mergeChanges(changes, spaceId: testCrdtUserId);
replayedExport = await _export(node);
});
test(
'then the repeated city edit retains its newest value and authored clock.',
() {
expect(originalNameClock < finalName.hlc, isTrue);
expect(mergedCity?.name, 'final name');
expect(nameClock, finalName.hlc);
},
);
test(
'then the inserted town retains its new parent and authored clock after the old parent is deleted.',
() {
expect(deletedParent, isNull);
expect(mergedTown, isNotNull);
expect(mergedTown!.cityId, replacementParent.id);
expect(parentClock, finalParent.hlc);
},
);
test('then replay leaves the exported authored history byte stable.', () {
expect(replayedExport, firstExport);
});
},
);
});
group('Given a city with a local name edit,', () {
late SyncNode node;
late City city;
late Hlc localNameClock;
setUpAll(() async {
node = await syncNode(await createAdditionalTestSession(), testSyncTables);
city = City(id: const Uuid().v7obj(), name: 'original');
await node.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
await City.db.insertRow(node.offlineSync, city, transaction: tx);
await City.db.updateRow(
node.offlineSync,
city.copyWith(name: 'locally edited'),
columns: (t) => [t.name],
transaction: tx,
);
});
localNameClock = (await _fieldClock(node, 'city', city.id!, 'name'))!;
});
group(
'when one batch edits and reinserts it before an intermediate edit arrives,',
() {
late Hlc reinsertClock;
late City? merged;
late Hlc? mergedClock;
late City? afterLateEdit;
late Hlc? afterLateClock;
setUpAll(() async {
final editClock = Hlc(
localNameClock.datetime.add(const Duration(seconds: 1)),
0,
const Uuid().v7obj(),
);
reinsertClock = Hlc(editClock.datetime, 2, const Uuid().v7obj());
await node.offlineSync.db.mergeChanges([
_update('city', city.id!, 'name', 'edited', editClock),
CrdtMergeInsert(
uuidSpaceId: testCrdtUserId,
tableName: 'city',
uuidRowId: city.id!,
uuidNodeId: reinsertClock.nodeId,
hlcDatetime: reinsertClock.datetime,
hlcCounter: reinsertClock.counter,
data: city.copyWith(name: 'reinserted'),
),
], spaceId: testCrdtUserId);
merged = await City.db.findById(node.offlineSync, city.id!);
mergedClock = await _fieldClock(node, 'city', city.id!, 'name');
await node.offlineSync.db.mergeChanges([
_update(
'city',
city.id!,
'name',
'late edit',
Hlc(editClock.datetime, 1, editClock.nodeId),
),
], spaceId: testCrdtUserId);
afterLateEdit = await City.db.findById(node.offlineSync, city.id!);
afterLateClock = await _fieldClock(node, 'city', city.id!, 'name');
});
test('then the batch preserves the reinsertion value and author clock.', () {
expect(merged?.name, 'reinserted');
expect(mergedClock, reinsertClock);
});
test('then the intermediate edit cannot overwrite the newer reinsertion.', () {
expect(afterLateEdit?.name, 'reinserted');
expect(afterLateClock, reinsertClock);
});
},
);
});
group('Given two cities with existing name edits,', () {
late SyncNode node;
late City first;
late City second;
late Hlc firstClock;
late Hlc secondClock;
late Hlc incoming;
setUpAll(() async {
node = await syncNode(await createAdditionalTestSession(), testSyncTables);
first = City(id: const Uuid().v7obj(), name: 'first');
second = City(id: const Uuid().v7obj(), name: 'second');
await node.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
await City.db.insert(node.offlineSync, [first, second], transaction: tx);
await City.db.updateRow(
node.offlineSync,
first.copyWith(name: 'first edited'),
columns: (t) => [t.name],
transaction: tx,
);
await City.db.updateRow(
node.offlineSync,
second.copyWith(name: 'second edited'),
columns: (t) => [t.name],
transaction: tx,
);
});
firstClock = (await _fieldClock(node, 'city', first.id!, 'name'))!;
secondClock = (await _fieldClock(node, 'city', second.id!, 'name'))!;
incoming = Hlc(
secondClock.datetime.add(const Duration(seconds: 1)),
0,
const Uuid().v7obj(),
);
});
group('when a merge contains a valid edit followed by an invalid null name,', () {
Object? failure;
late List<String> rolledBackNames;
late List<Hlc?> rolledBackClocks;
late CrdtMergeUpdate recovery;
late City? recoveredCity;
late Hlc? recoveredClock;
setUpAll(() async {
try {
await node.offlineSync.db.mergeChanges([
_update(
'city',
first.id!,
'name',
'must roll back',
Hlc(incoming.datetime, 2, incoming.nodeId),
),
_update(
'city',
second.id!,
'name',
null,
Hlc(incoming.datetime, 3, incoming.nodeId),
),
], spaceId: testCrdtUserId);
} on Exception catch (error) {
failure = error;
}
rolledBackNames = [
(await City.db.findById(node.offlineSync, first.id!))!.name,
(await City.db.findById(node.offlineSync, second.id!))!.name,
];
rolledBackClocks = [
await _fieldClock(node, 'city', first.id!, 'name'),
await _fieldClock(node, 'city', second.id!, 'name'),
];
recovery = _update('city', first.id!, 'name', 'recovered', incoming);
await node.offlineSync.db.mergeChanges([recovery], spaceId: testCrdtUserId);
recoveredCity = await City.db.findById(node.offlineSync, first.id!);
recoveredClock = await _fieldClock(node, 'city', first.id!, 'name');
});
test('then neither row retains values or clocks from the rejected merge.', () {
expect(failure, isA<DatabaseQueryException>());
expect(rolledBackNames, ['first edited', 'second edited']);
expect(rolledBackClocks, [firstClock, secondClock]);
});
test(
'then a subsequent merge can accept an edit older than the rejected edits.',
() {
expect(recoveredCity?.name, 'recovered');
expect(recoveredClock, recovery.hlc);
},
);
});
});
}
CrdtMergeUpdate _update(
String table,
UuidValue row,
String column,
Object? value,
Hlc clock,
) {
return CrdtMergeUpdate(
uuidSpaceId: testCrdtUserId,
tableName: table,
uuidRowId: row,
uuidNodeId: clock.nodeId,
hlcDatetime: clock.datetime,
hlcCounter: clock.counter,
columnName: column,
value: value,
);
}
Future<Hlc?> _fieldClock(
SyncNode node,
String table,
UuidValue row,
String column,
) async {
final field = await CrdtDataField.db.findFirstRow(
node.offlineSync,
where: (t) =>
t.row.tbl.name.equals(table) &
t.row.uuidRowId.equals(row) &
t.column.name.equals(column),
include: CrdtDataField.include(node: CrdtNode.include()),
);
return field?.hlc;
}
Future<String> _export(SyncNode node) async {
final changes = await node.sync
.collectPendingChanges(
node.raw,
checkpointsBySpaceUuid: {testCrdtUserId: const []},
)
.toList();
return jsonEncode(changes);
}

View file

@ -0,0 +1,202 @@
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/sync_topology.dart';
void main() {
initTestClientSession(createSessionPerTest: false);
group(
'Given a town and organization sharing a UUID with their missing parents,',
() {
late SyncNode author;
late SyncNode observer;
late UuidValue sharedId;
late Town town;
late Organization organization;
late Hlc authored;
late Map<(String, String), Hlc> originalClocks;
setUpAll(() async {
author = await syncNode(
await createAdditionalTestSession(),
testSyncTables,
);
observer = await syncNode(
await createAdditionalTestSession(),
testSyncTables,
);
sharedId = const Uuid().v7obj();
town = Town(
id: sharedId,
name: 'town',
cityId: sharedId,
mayorId: sharedId,
);
organization = Organization(
id: sharedId,
name: 'organization',
cityId: sharedId,
);
authored = Hlc(DateTime.utc(2026, 9, 1), 0, const Uuid().v7obj());
await author.offlineSync.db.mergeChanges([
CrdtMergeInsert(
uuidSpaceId: testCrdtUserId,
tableName: Town.t.tableName,
uuidRowId: sharedId,
uuidNodeId: authored.nodeId,
hlcDatetime: authored.datetime,
hlcCounter: authored.counter,
data: town,
),
CrdtMergeInsert(
uuidSpaceId: testCrdtUserId,
tableName: Organization.t.tableName,
uuidRowId: sharedId,
uuidNodeId: authored.nodeId,
hlcDatetime: authored.datetime,
hlcCounter: authored.counter + 1,
data: organization,
),
], spaceId: testCrdtUserId);
originalClocks = await _dependentClocks(author, sharedId);
});
group('when the city and mayor arrive together and synchronize,', () {
late Town? authorTown;
late Town? observerTown;
late Organization? authorOrganization;
late Organization? observerOrganization;
late Person? authorMayor;
late Person? observerMayor;
late Map<(String, String), Hlc> authorClocks;
late Map<(String, String), Hlc> observerClocks;
late int authorAttempts;
late int observerAttempts;
setUpAll(() async {
final city = City(id: sharedId, name: 'city');
final mayor = Person(
id: sharedId,
name: 'mayor',
cityId: sharedId,
organizationId: sharedId,
);
await author.offlineSync.db.mergeChanges([
CrdtMergeInsert(
uuidSpaceId: testCrdtUserId,
tableName: City.t.tableName,
uuidRowId: sharedId,
uuidNodeId: authored.nodeId,
hlcDatetime: authored.datetime,
hlcCounter: authored.counter + 2,
data: city,
),
CrdtMergeInsert(
uuidSpaceId: testCrdtUserId,
tableName: Person.t.tableName,
uuidRowId: sharedId,
uuidNodeId: authored.nodeId,
hlcDatetime: authored.datetime,
hlcCounter: authored.counter + 3,
data: mayor,
),
], spaceId: testCrdtUserId);
await syncWithServer(author, observer);
authorTown = await Town.db.findById(author.offlineSync, sharedId);
observerTown = await Town.db.findById(observer.offlineSync, sharedId);
authorOrganization = await Organization.db.findById(
author.offlineSync,
sharedId,
);
observerOrganization = await Organization.db.findById(
observer.offlineSync,
sharedId,
);
authorMayor = await Person.db.findById(author.offlineSync, sharedId);
observerMayor = await Person.db.findById(
observer.offlineSync,
sharedId,
);
authorClocks = await _dependentClocks(author, sharedId);
observerClocks = await _dependentClocks(observer, sharedId);
authorAttempts = await CrdtDataAttemptedValue.db.count(
author.offlineSync,
);
observerAttempts = await CrdtDataAttemptedValue.db.count(
observer.offlineSync,
);
});
test(
'then both replicas restore each reference to its own parent table.',
() {
for (final value in [authorTown, observerTown]) {
expect(value, isNotNull);
expect(
(value!.name, value.cityId, value.mayorId),
('town', sharedId, sharedId),
);
}
for (final value in [authorOrganization, observerOrganization]) {
expect(value, isNotNull);
expect((value!.name, value.cityId), ('organization', sharedId));
}
for (final value in [authorMayor, observerMayor]) {
expect(value, isNotNull);
expect(
(value!.name, value.cityId, value.organizationId),
('mayor', sharedId, sharedId),
);
}
},
);
test(
'then restoring each dependency preserves its authored field clocks.',
() {
expect(originalClocks.keys.toSet(), {
('town', 'cityId'),
('town', 'mayorId'),
('organization', 'cityId'),
});
expect(authorClocks, originalClocks);
expect(observerClocks, originalClocks);
},
);
test(
'then neither replica retains a repaired-away parent reference.',
() {
expect(authorAttempts, 0);
expect(observerAttempts, 0);
},
);
});
},
);
}
Future<Map<(String, String), Hlc>> _dependentClocks(
SyncNode node,
UuidValue id,
) async {
final fields = await CrdtDataField.db.find(
node.offlineSync,
where: (t) =>
t.row.uuidRowId.equals(id) &
t.row.tbl.name.inSet({'town', 'organization'}) &
t.column.name.inSet({'cityId', 'mayorId'}),
include: CrdtDataField.include(
row: CrdtDataRow.include(tbl: CrdtSchemaTable.include()),
column: CrdtSchemaColumn.include(),
node: CrdtNode.include(),
),
);
return {
for (final field in fields) (field.row!.tbl!.name, field.column!.name): field.hlc,
};
}