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
3 changed files with 305 additions and 73 deletions

View file

@ -537,6 +537,88 @@ void main() {
},
);
withServerpod(
'Given a server and two client CRDT sessions for the same user that are initialized with the same sync tables',
rollbackDatabase: RollbackDatabase.disabled,
(sessionBuilder, _) {
final rawServerSession = sessionBuilder.build();
late CrdtDatabaseSession secondClientSession;
rawServerSession.serverpod
..initializeCrdtSync(syncTables: serverSyncTables)
..authenticationHandler = (session, token) async => AuthenticationInfo(
testCrdtUserId.toString(),
<Scope>{},
authId: const Uuid().v4(),
);
setUp(() async {
testClient = client.Client(
'http://localhost:${rawServerSession.server.port}',
)..authKeyProvider = TestClientAuthKeyProvider();
clientSession = CrdtDatabaseSession.wraps(
testSession,
syncTables: clientSyncTables,
persistentUserId: testCrdtUserId,
);
await clientSession.db.initialize();
secondClientSession = CrdtDatabaseSession.wraps(
await createAdditionalTestSession(),
syncTables: clientSyncTables,
persistentUserId: testCrdtUserId,
);
await secondClientSession.db.initialize();
serverSession = CrdtDatabaseSession.wraps(
rawServerSession,
syncTables: serverSyncTables,
);
await serverSession.db.initialize();
});
tearDown(() async {
await serverSession.clearUserTables();
});
test(
'when the first node uploads an offline deletion after the second node synchronized a newer change '
'then the second node receives the deletion on its next sync.',
() async {
final rowToDelete = await client.Unique.db.insertRow(
clientSession,
client.Unique(name: 'deleted-by-first-node'),
);
await testClient.crdt.syncOnce(clientSession);
await testClient.crdt.syncOnce(secondClientSession);
// After both nodes have synchronized, the first node deletes the row.
await client.Unique.db.deleteRow(clientSession, rowToDelete);
// The second node inserts a new row and synchronizes it.
await client.Unique.db.insertRow(
secondClientSession,
client.Unique(name: 'inserted-by-second-node'),
);
await testClient.crdt.syncOnce(secondClientSession);
// The first node synchronizes the deletion with the server.
await testClient.crdt.syncOnce(clientSession);
// This last sync from the second node will only receive the deletion
// from the first node if checkpoints are tracked per known node.
await testClient.crdt.syncOnce(secondClientSession);
final deletedRow = await client.Unique.db.findById(
secondClientSession,
rowToDelete.id!,
);
expect(deletedRow, isNull);
},
);
},
);
withServerpod(
'Given a server and client CRDT session that are initialized with the same sync tables and a continuous sync interval',
rollbackDatabase: RollbackDatabase.disabled,

View file

@ -65,8 +65,9 @@ void main() {
final mergeSet = await crdtSync
.collectPendingChanges(
testSession,
otherNodeId: const Uuid().v7obj(),
peerNodeId: const Uuid().v7obj(),
userId: testCrdtUserId,
nodeCheckpoints: const [],
)
.toList();
@ -117,7 +118,7 @@ void main() {
testSession,
userId: testCrdtUserId,
peerNodeId: const Uuid().v7obj(),
sinceHlc: Hlc.zero(const Uuid().v7obj()),
nodeCheckpoints: const [],
)
.toList();
@ -185,8 +186,9 @@ void main() {
final mergeSet = await crdtSync
.collectPendingChanges(
testSession,
otherNodeId: const Uuid().v7obj(),
peerNodeId: const Uuid().v7obj(),
userId: testCrdtUserId,
nodeCheckpoints: const [],
)
.toList();
@ -208,6 +210,27 @@ void main() {
},
);
});
test(
'Given a CRDT node without local changes '
'when synchronization checkpoints are created '
'then one fresh checkpoint for the local node is included.',
() async {
final user = await CrdtUserManager(testSession).getOrCreate(testCrdtUserId);
final sinceHlc = await crdtSync.createSyncSinceHlc(
testSession,
userId: testCrdtUserId,
peerNodeId: const Uuid().v7obj(),
);
expect(sinceHlc.nodeCheckpoints, hasLength(1));
expect(sinceHlc.nodeCheckpoints.single.nodeId, user.currentNode!.uuidNodeId);
expect(
sinceHlc.nodeCheckpoints.single,
greaterThan(Hlc.zero(sinceHlc.nodeCheckpoints.single.nodeId)),
);
},
);
}
extension on List<int> {

View file

@ -14,6 +14,7 @@ late CrdtDatabaseSession _crdtSession;
late Directory _tempDir;
late UuidValue _testCrdtUserId;
late bool _withPersistentUser;
var _additionalTestSessionCount = 0;
/// A fresh [ClientDatabaseSession] for each test that is automatically closed
/// and removed once no longer needed.
@ -63,6 +64,17 @@ void initTestClientSession({bool withPersistentUser = false}) {
});
}
/// Creates an additional client database session in the shared test temporary
/// directory and closes it when the current test completes.
Future<ClientDatabaseSession> createAdditionalTestSession() async {
final additionalSession = await _testClient.createSession(
p.join(_tempDir.path, 'test-${++_additionalTestSessionCount}.db'),
isDebugMode: true,
);
addTearDown(additionalSession.close);
return additionalSession;
}
Future<void> _initialize() async {
_crdtSession = CrdtDatabaseSession.wraps(
testSession,