fix: Fix missing entries when syncing multiple nodes (#58)

This commit is contained in:
Marcelo Mendonça Soares 2026-06-02 01:42:34 -03:00 • committed by GitHub
commit 60cca9cc85
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 305 additions and 73 deletions

View file

@ -7,6 +7,7 @@ import 'package:uuid/uuid.dart';
import '../database/database.dart';
import '../managers/user.dart';
import '../protocol/protocol.dart';
import '../utils/case_when.dart' show Case;
import 'exceptions.dart';
import 'merge.dart';
@ -122,44 +123,50 @@ class CrdtSync {
'${uuid.v5(Namespace.oid.value, canonicalSignature)}';
}
/// Streams local CRDT changes since the last sync checkpoint with [otherNodeId].
/// Streams local CRDT changes that a peer node has not seen yet.
///
/// Changes are emitted in insert, update, then delete order. Domain row and
/// column payloads are resolved incrementally as each change is yielded.
///
/// When [sinceHlc] is provided, it is used instead of the stored checkpoint.
/// All changes for nodes that are not present in the [nodeCheckpoints] list
/// are collected and emitted. Passing an empty list will collect all changes.
Stream<CrdtMergeChange> collectPendingChanges(
DatabaseSession session, {
required UuidValue userId,
required UuidValue otherNodeId,
Hlc? sinceHlc,
required UuidValue peerNodeId,
required List<Hlc> nodeCheckpoints,
}) async* {
final crdtUser = await CrdtUserManager(session).getOrCreate(userId);
final syncCheckpoint =
sinceHlc ??
await _syncCheckpointForNode(
session,
crdtUser,
otherNodeId,
);
yield* _streamInserts(session, crdtUser, syncCheckpoint);
yield* _streamUpdates(session, crdtUser, syncCheckpoint);
yield* _streamDeletes(session, crdtUser, syncCheckpoint);
yield* _streamInserts(session, crdtUser, nodeCheckpoints);
yield* _streamUpdates(session, crdtUser, nodeCheckpoints);
yield* _streamDeletes(session, crdtUser, nodeCheckpoints);
}
/// Creates the [CrdtSyncSinceHlc] checkpoint for the post-connect handshake.
///
/// [CrdtSyncSinceHlc.lastSyncFromPeer] reflects the latest change this node
/// has received from [peerNodeId], tagged with the peer's node id.
/// [CrdtSyncSinceHlc.nodeCheckpoints] reflects the latest change this node
/// has received from each known node, tagged with the source node id.
Future<CrdtSyncSinceHlc> createSyncSinceHlc(
DatabaseSession session, {
required UuidValue userId,
required UuidValue peerNodeId,
}) async {
final crdtUser = await CrdtUserManager(session).getOrCreate(userId);
final lastSeenHlc = await _syncCheckpointForNode(session, crdtUser, peerNodeId);
return CrdtSyncSinceHlc(lastSyncFromPeer: lastSeenHlc);
final nodes = await CrdtNode.db.find(
session,
where: (t) =>
t.userId.equals(crdtUser.id) &
t.uuidNodeId.notEquals(crdtUser.currentNode!.uuidNodeId),
);
return CrdtSyncSinceHlc(
nodeCheckpoints: [
// The local node is always included to avoid collecting its own changes.
Hlc.now(crdtUser.currentNode!.uuidNodeId),
for (final node in nodes) node.lastReceivedHlc ?? Hlc.zero(node.uuidNodeId),
],
);
}
/// Merges a remote [mergeSet] and records the sync checkpoint for [otherNodeId].
@ -190,13 +197,13 @@ class CrdtSync {
DatabaseSession session, {
required UuidValue userId,
required UuidValue peerNodeId,
required Hlc sinceHlc,
required List<Hlc> nodeCheckpoints,
}) async* {
yield* collectPendingChanges(
session,
userId: userId,
otherNodeId: peerNodeId,
sinceHlc: sinceHlc,
peerNodeId: peerNodeId,
nodeCheckpoints: nodeCheckpoints,
)
.chunked(_syncBatchSize)
.map(
@ -209,9 +216,9 @@ class CrdtSync {
///
/// Both peers send [CrdtSyncConnect], read the peer connect frame, then send
/// and read [CrdtSyncSinceHlc] once. After that, both sides exchange framed
/// data batches using (1) the peer's authoritative since HLC for the first
/// send, and (2) [CrdtSyncSinceHlc.lastSyncFromPeer] for subsequent sends
/// within the session.
/// data batches using the peer's per-node checkpoints. Sent changes advance
/// the corresponding in-memory checkpoints for subsequent sends within the
/// session.
///
/// If a batch fails to merge, the exception propagates and the stream closes.
/// The next sync attempt resumes from the last persisted checkpoint on the
@ -258,27 +265,26 @@ class CrdtSync {
);
final peerSinceHlc = await inboundIterator.moveAndThrowIfNot<CrdtSyncSinceHlc>();
if (peerSinceHlc.lastSyncFromPeer.nodeId != localNodeId) {
throw CrdtSyncInvalidSinceHlcException(
receivedNodeId: peerSinceHlc.lastSyncFromPeer.nodeId,
expectedNodeId: localNodeId,
);
}
var lastSentToPeer = peerSinceHlc.lastSyncFromPeer;
final nodeCheckpoints = {
for (final checkpoint in peerSinceHlc.nodeCheckpoints)
checkpoint.nodeId: checkpoint,
};
while (true) {
final pendingLocalChanges = collectPendingChanges(
session,
userId: userId,
otherNodeId: peerNodeId,
sinceHlc: lastSentToPeer,
peerNodeId: peerNodeId,
nodeCheckpoints: nodeCheckpoints.values.toList(),
);
var hasChanges = false;
await for (final changes in pendingLocalChanges.chunked(_syncBatchSize)) {
hasChanges = true;
lastSentToPeer = lastSentToPeer.maxBetween(changes.maxHlc);
for (final change in changes) {
final lastCheckpoint = nodeCheckpoints[change.uuidNodeId];
nodeCheckpoints[change.uuidNodeId] = change.hlc.maxBetween(lastCheckpoint);
}
yield CrdtSyncMergeChunk(changes: changes);
}
if (hasChanges || once) {
@ -302,7 +308,7 @@ class CrdtSync {
if (hasChanges || inboundMergeSet.isNotEmpty) {
await onMergeSuccess?.call(
lastSentToPeer.maxBetween(lastReceivedFromPeer),
nodeCheckpoints.values.max.maxBetween(lastReceivedFromPeer),
);
}
@ -348,27 +354,14 @@ class CrdtSync {
return crdtDb;
}
Future<Hlc> _syncCheckpointForNode(
DatabaseSession session,
CrdtUser crdtUser,
UuidValue otherNodeId,
) async {
final otherNode = await CrdtNode.db.findFirstRow(
session,
where: (t) => t.userId.equals(crdtUser.id) & t.uuidNodeId.equals(otherNodeId),
);
return otherNode?.lastReceivedHlc ?? Hlc.zero(otherNodeId);
}
Stream<CrdtMergeInsert> _streamInserts(
DatabaseSession session,
CrdtUser crdtUser,
Hlc lastSyncHlc,
List<Hlc> nodeCheckpoints,
) async* {
final rows = await CrdtDataRow.db.find(
session,
where: (t) => _rowHlcAfterFilter(t, crdtUser, lastSyncHlc),
where: (t) => _rowHlcAfterFilter(t, crdtUser, nodeCheckpoints),
include: CrdtDataRow.include(
tbl: CrdtSchemaTable.include(),
node: CrdtNode.include(),
@ -406,11 +399,11 @@ class CrdtSync {
Stream<CrdtMergeUpdate> _streamUpdates(
DatabaseSession session,
CrdtUser crdtUser,
Hlc lastSyncHlc,
List<Hlc> nodeCheckpoints,
) async* {
final fields = await CrdtDataField.db.find(
session,
where: (t) => _fieldHlcAfterFilter(t, crdtUser, lastSyncHlc),
where: (t) => _fieldHlcAfterFilter(t, crdtUser, nodeCheckpoints),
include: CrdtDataField.include(
row: CrdtDataRow.include(tbl: CrdtSchemaTable.include()),
column: CrdtSchemaColumn.include(),
@ -445,11 +438,11 @@ class CrdtSync {
Stream<CrdtMergeDelete> _streamDeletes(
DatabaseSession session,
CrdtUser crdtUser,
Hlc lastSyncHlc,
List<Hlc> nodeCheckpoints,
) async* {
final tombstones = await CrdtDataDeleted.db.find(
session,
where: (t) => _tombstoneHlcAfterFilter(t, crdtUser, lastSyncHlc),
where: (t) => _tombstoneHlcAfterFilter(t, crdtUser, nodeCheckpoints),
include: CrdtDataDeleted.include(
row: CrdtDataRow.include(tbl: CrdtSchemaTable.include()),
node: CrdtNode.include(),
@ -475,32 +468,41 @@ class CrdtSync {
Expression _rowHlcAfterFilter(
CrdtDataRowTable t,
CrdtUser crdtUser,
Hlc lastSyncHlc,
List<Hlc> nodeCheckpoints,
) =>
t.userId.equals(crdtUser.id) &
((t.hlcDatetime > lastSyncHlc.datetime) |
(t.hlcDatetime.equals(lastSyncHlc.datetime) &
(t.hlcCounter > lastSyncHlc.counter)));
t.node.afterAnyCheckpointFilter(
t.node.uuidNodeId,
t.hlcDatetime,
t.hlcCounter,
nodeCheckpoints,
);
Expression _fieldHlcAfterFilter(
CrdtDataFieldTable t,
CrdtUser crdtUser,
Hlc lastSyncHlc,
List<Hlc> nodeCheckpoints,
) =>
t.row.userId.equals(crdtUser.id) &
((t.hlcDatetime > lastSyncHlc.datetime) |
(t.hlcDatetime.equals(lastSyncHlc.datetime) &
(t.hlcCounter > lastSyncHlc.counter)));
t.node.afterAnyCheckpointFilter(
t.node.uuidNodeId,
t.hlcDatetime,
t.hlcCounter,
nodeCheckpoints,
);
Expression _tombstoneHlcAfterFilter(
CrdtDataDeletedTable t,
CrdtUser crdtUser,
Hlc lastSyncHlc,
List<Hlc> nodeCheckpoints,
) =>
t.row.userId.equals(crdtUser.id) &
((t.hlcDatetime > lastSyncHlc.datetime) |
(t.hlcDatetime.equals(lastSyncHlc.datetime) &
(t.hlcCounter > lastSyncHlc.counter)));
t.node.afterAnyCheckpointFilter(
t.node.uuidNodeId,
t.hlcDatetime,
t.hlcCounter,
nodeCheckpoints,
);
Future<dynamic> _fetchDomainRow(
DatabaseSession session,
@ -625,3 +627,26 @@ class CrdtSync {
return entries;
}
}
extension on CrdtNodeTable {
Expression afterAnyCheckpointFilter(
ColumnUuid uuidNodeId,
ColumnDateTime hlcDatetime,
ColumnInt hlcCounter,
List<Hlc> nodeCheckpoints,
) {
if (nodeCheckpoints.isEmpty) return Constant.bool(true);
final caseExpression = Case();
for (final checkpoint in nodeCheckpoints) {
caseExpression.when(
uuidNodeId.equals(checkpoint.nodeId),
then:
(hlcDatetime > checkpoint.datetime) |
(hlcDatetime.equals(checkpoint.datetime) &
(hlcCounter > checkpoint.counter)),
);
}
return caseExpression.orElse(Constant.bool(true));
}
}

View file

@ -0,0 +1,79 @@
import 'package:serverpod_database/serverpod_database.dart';
/// A builder for a SQL CASE expression.
///
/// Creates a searched CASE expression when [_column] is omitted and a
/// simple CASE expression when it is provided.
class Case {
/// Creates a new [Case] expression builder.
Case([this._column]);
final Column? _column;
final List<_WhenThen> _whenThenExpressions = [];
/// Adds a WHEN and THEN expression.
void when(Expression expression, {required Expression then}) {
_whenThenExpressions.add(_WhenThen(expression, then));
}
/// Completes the CASE expression with an ELSE expression.
Expression orElse(Expression expression) {
return _CaseExpression(
_column,
List.unmodifiable(_whenThenExpressions),
expression,
);
}
}
class _CaseExpression extends Expression<void> {
_CaseExpression(
this._column,
this._whenThenExpressions,
this._elseExpression,
) : super(null);
final Column? _column;
final List<_WhenThen> _whenThenExpressions;
final Expression _elseExpression;
@override
List<Column> get columns => [
if (_column != null) _column,
..._whenThenExpressions.expand(
(expressions) => [
...expressions.when.columns,
...expressions.then.columns,
],
),
..._elseExpression.columns,
];
@override
Iterable<Expression> get depthFirst sync* {
yield* super.depthFirst;
for (final expressions in _whenThenExpressions) {
yield* expressions.when.depthFirst;
yield* expressions.then.depthFirst;
}
yield* _elseExpression.depthFirst;
}
@override
String toString() {
var expression = 'CASE';
if (_column != null) {
expression += ' $_column';
}
for (final expressions in _whenThenExpressions) {
// No need to use a string buffer here as the expression is not complex.
// ignore: use_string_buffers
expression += ' WHEN ${expressions.when} THEN ${expressions.then}';
}
return '$expression ELSE $_elseExpression END';
}
}
class _WhenThen {
_WhenThen(this.when, this.then);
final Expression when;
final Expression then;
}