fix: Refresh node clocks across sync wrappers (#152)
This commit is contained in:
parent
51ce999af0
commit
96271a25ab
10 changed files with 1115 additions and 43 deletions
|
|
@ -168,7 +168,7 @@ class OfflineSyncDatabase implements Database {
|
||||||
));
|
));
|
||||||
try {
|
try {
|
||||||
await transactionForUser<void>(effectiveSpaceId, (tx) async {
|
await transactionForUser<void>(effectiveSpaceId, (tx) async {
|
||||||
await _recorder.lockCurrentUser(tx);
|
await _recorder.lockAndRefreshCurrentNodeHlc(tx);
|
||||||
await _recorder.mergeChanges(mergeSet, tx);
|
await _recorder.mergeChanges(mergeSet, tx);
|
||||||
});
|
});
|
||||||
} on OfflineSyncIntegrityViolationException catch (exception) {
|
} on OfflineSyncIntegrityViolationException catch (exception) {
|
||||||
|
|
@ -308,6 +308,18 @@ class OfflineSyncDatabase implements Database {
|
||||||
return _stripSpaceIdFromSpaceScopedRead([result], include, transaction).single;
|
return _stripSpaceIdFromSpaceScopedRead([result], include, transaction).single;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<R> _runTrackedWrite<R>(
|
||||||
|
Transaction? transaction,
|
||||||
|
TransactionFunction<R> action,
|
||||||
|
) => DatabaseUtil.runInTransactionOrSavepoint(
|
||||||
|
_delegate,
|
||||||
|
transaction,
|
||||||
|
(tx) => _recorder.withCurrentNodeHlc(tx, (tx) async {
|
||||||
|
await _recorder.lockAndRefreshCurrentNodeHlc(tx);
|
||||||
|
return action(tx);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<T>> insert<T extends TableRow>(
|
Future<List<T>> insert<T extends TableRow>(
|
||||||
List<T> rows, {
|
List<T> rows, {
|
||||||
|
|
@ -325,8 +337,7 @@ class OfflineSyncDatabase implements Database {
|
||||||
noReturn: noReturn,
|
noReturn: noReturn,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return DatabaseUtil.runInTransactionOrSavepoint(
|
return _runTrackedWrite(
|
||||||
_delegate,
|
|
||||||
transaction,
|
transaction,
|
||||||
(tx) async {
|
(tx) async {
|
||||||
final prepared = _prepareRowsForInsert(rows, tx);
|
final prepared = _prepareRowsForInsert(rows, tx);
|
||||||
|
|
@ -426,8 +437,7 @@ class OfflineSyncDatabase implements Database {
|
||||||
noReturn: noReturn,
|
noReturn: noReturn,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return DatabaseUtil.runInTransactionOrSavepoint(
|
return _runTrackedWrite(
|
||||||
_delegate,
|
|
||||||
transaction,
|
transaction,
|
||||||
(tx) async {
|
(tx) async {
|
||||||
final prepared = _prepareRowsForInsert(rows, tx);
|
final prepared = _prepareRowsForInsert(rows, tx);
|
||||||
|
|
@ -641,8 +651,7 @@ class OfflineSyncDatabase implements Database {
|
||||||
noReturn: noReturn,
|
noReturn: noReturn,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return DatabaseUtil.runInTransactionOrSavepoint(
|
return _runTrackedWrite(
|
||||||
_delegate,
|
|
||||||
transaction,
|
transaction,
|
||||||
(tx) async {
|
(tx) async {
|
||||||
final plannedUpdates = await _recorder.planLocalUpdates(rows, columns, tx);
|
final plannedUpdates = await _recorder.planLocalUpdates(rows, columns, tx);
|
||||||
|
|
@ -681,8 +690,7 @@ class OfflineSyncDatabase implements Database {
|
||||||
transaction: transaction,
|
transaction: transaction,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return DatabaseUtil.runInTransactionOrSavepoint(
|
return _runTrackedWrite(
|
||||||
_delegate,
|
|
||||||
transaction,
|
transaction,
|
||||||
(tx) async {
|
(tx) async {
|
||||||
final plannedUpdates = await _recorder.planLocalUpdates([row], columns, tx);
|
final plannedUpdates = await _recorder.planLocalUpdates([row], columns, tx);
|
||||||
|
|
@ -786,8 +794,7 @@ class OfflineSyncDatabase implements Database {
|
||||||
}
|
}
|
||||||
|
|
||||||
_assertNoSpaceIdColumnValues<T>(columnValues);
|
_assertNoSpaceIdColumnValues<T>(columnValues);
|
||||||
return DatabaseUtil.runInTransactionOrSavepoint(
|
return _runTrackedWrite(
|
||||||
_delegate,
|
|
||||||
transaction,
|
transaction,
|
||||||
(tx) async {
|
(tx) async {
|
||||||
final result = await _delegate.updateWhere<T>(
|
final result = await _delegate.updateWhere<T>(
|
||||||
|
|
@ -882,8 +889,7 @@ class OfflineSyncDatabase implements Database {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return DatabaseUtil.runInTransactionOrSavepoint(
|
return _runTrackedWrite(
|
||||||
_delegate,
|
|
||||||
transaction,
|
transaction,
|
||||||
(tx) async {
|
(tx) async {
|
||||||
final rows = await _delegate.find<T>(
|
final rows = await _delegate.find<T>(
|
||||||
|
|
@ -956,7 +962,9 @@ class OfflineSyncDatabase implements Database {
|
||||||
// [transaction] before CRDT tables exist. Callers that need CRDT state
|
// [transaction] before CRDT tables exist. Callers that need CRDT state
|
||||||
// ([transactionForUser], mutating ORM methods) initialize explicitly.
|
// ([transactionForUser], mutating ORM methods) initialize explicitly.
|
||||||
return _delegate.transaction(
|
return _delegate.transaction(
|
||||||
transactionFunction,
|
_recorder.hasPersistentSpace
|
||||||
|
? (tx) => _recorder.withCurrentNodeHlc(tx, transactionFunction)
|
||||||
|
: transactionFunction,
|
||||||
settings: settings,
|
settings: settings,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -984,7 +992,7 @@ class OfflineSyncDatabase implements Database {
|
||||||
try {
|
try {
|
||||||
spaceForTransaction[tx] = space;
|
spaceForTransaction[tx] = space;
|
||||||
userForTransaction[tx] = userId;
|
userForTransaction[tx] = userId;
|
||||||
return await transactionFunction(tx);
|
return await _recorder.withCurrentNodeHlc(tx, transactionFunction);
|
||||||
} finally {
|
} finally {
|
||||||
spaceForTransaction.remove(tx);
|
spaceForTransaction.remove(tx);
|
||||||
userForTransaction.remove(tx);
|
userForTransaction.remove(tx);
|
||||||
|
|
|
||||||
|
|
@ -65,20 +65,6 @@ class _PendingInsertAttempts {
|
||||||
|
|
||||||
/// Adds merge-specific behavior to [CrdtMutationRecorder].
|
/// Adds merge-specific behavior to [CrdtMutationRecorder].
|
||||||
extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
|
extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
|
||||||
/// Locks the current user row so merges can serialize with other work.
|
|
||||||
Future<void> lockCurrentUser(Transaction transaction) async {
|
|
||||||
final user = _context.effectiveSpaceFor(transaction);
|
|
||||||
// Use a row lock without fetching the record since the merge path only
|
|
||||||
// needs serialization against concurrent work for the same user.
|
|
||||||
await OfflineSyncSpace.db.lockRows(
|
|
||||||
_session,
|
|
||||||
where: (t) => t.id.equals(user.id),
|
|
||||||
transaction: transaction,
|
|
||||||
lockMode: LockMode.forUpdate,
|
|
||||||
lockBehavior: LockBehavior.wait,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Merges remote CRDT changes into the current database.
|
/// Merges remote CRDT changes into the current database.
|
||||||
Future<void> mergeChanges(
|
Future<void> mergeChanges(
|
||||||
CrdtMergeSet mergeSet,
|
CrdtMergeSet mergeSet,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,15 @@ enum ForeignKeyTargetPresence {
|
||||||
hidden,
|
hidden,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _CurrentNodeHlc {
|
||||||
|
_CurrentNodeHlc(this.node);
|
||||||
|
|
||||||
|
final CrdtNode node;
|
||||||
|
final managers = <int, HlcManager>{};
|
||||||
|
bool active = false;
|
||||||
|
Future<void>? observed;
|
||||||
|
}
|
||||||
|
|
||||||
/// Shared state and database access for the CRDT recorder and its helpers.
|
/// Shared state and database access for the CRDT recorder and its helpers.
|
||||||
///
|
///
|
||||||
/// Owns per-recorder space/HLC management and the low-level domain and CRDT
|
/// Owns per-recorder space/HLC management and the low-level domain and CRDT
|
||||||
|
|
@ -54,10 +63,55 @@ class CrdtRecorderContext {
|
||||||
databaseSession,
|
databaseSession,
|
||||||
);
|
);
|
||||||
|
|
||||||
final Map<UuidValue, HlcManager> _hlcManagers = {};
|
// A node clock belongs to the active transaction, not a wrapper or a space.
|
||||||
|
// Nested wrappers share it; completing or rolling back the scope drops it.
|
||||||
|
static final _currentNodes = Expando<Map<int, _CurrentNodeHlc>>();
|
||||||
|
|
||||||
/// Clears cached [HlcManager] instances for this recorder session.
|
_CurrentNodeHlc _currentNodeFor(OfflineSyncSpace space, Transaction transaction) =>
|
||||||
void clearHlcManagers() => _hlcManagers.clear();
|
(_currentNodes[transaction] ??= {})[space.currentNodeId!] ??= _CurrentNodeHlc(
|
||||||
|
space.currentNode!.copyWith(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<R> withCurrentNodeHlc<R>(
|
||||||
|
Transaction transaction,
|
||||||
|
TransactionFunction<R> action,
|
||||||
|
) async {
|
||||||
|
final space = effectiveSpaceFor(transaction);
|
||||||
|
final nodeId = space.currentNodeId!;
|
||||||
|
final currentNode = _currentNodeFor(space, transaction);
|
||||||
|
if (currentNode.active) return action(transaction);
|
||||||
|
|
||||||
|
currentNode.active = true;
|
||||||
|
try {
|
||||||
|
return await action(transaction);
|
||||||
|
} finally {
|
||||||
|
final nodes = _currentNodes[transaction]!..remove(nodeId);
|
||||||
|
if (nodes.isEmpty) _currentNodes[transaction] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> lockAndRefreshCurrentNodeHlc(Transaction transaction) {
|
||||||
|
final currentNode = _currentNodeFor(effectiveSpaceFor(transaction), transaction);
|
||||||
|
return currentNode.observed ??= _lockAndRefreshCurrentNodeHlc(
|
||||||
|
currentNode,
|
||||||
|
transaction,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _lockAndRefreshCurrentNodeHlc(
|
||||||
|
_CurrentNodeHlc currentNode,
|
||||||
|
Transaction transaction,
|
||||||
|
) async {
|
||||||
|
final nodeId = currentNode.node.id!;
|
||||||
|
final node = await CrdtNode.db.findById(
|
||||||
|
databaseSession,
|
||||||
|
nodeId,
|
||||||
|
transaction: transaction,
|
||||||
|
lockMode: LockMode.forUpdate,
|
||||||
|
);
|
||||||
|
if (node == null) throw StateError('Current CRDT node $nodeId is missing.');
|
||||||
|
currentNode.node.lastHlc = node.lastHlc;
|
||||||
|
}
|
||||||
|
|
||||||
/// CRDT schema ids by table name: `tableName -> (tableId, columnsByName)`.
|
/// CRDT schema ids by table name: `tableName -> (tableId, columnsByName)`.
|
||||||
Map<String, (int, Map<String, CrdtSchemaColumn>)> get schema =>
|
Map<String, (int, Map<String, CrdtSchemaColumn>)> get schema =>
|
||||||
|
|
@ -792,9 +846,9 @@ WHERE "id" IN (${rowIds.sqlLiteralList()})
|
||||||
|
|
||||||
HlcManager hlcManagerFor(Transaction transaction) {
|
HlcManager hlcManagerFor(Transaction transaction) {
|
||||||
final user = effectiveSpaceFor(transaction);
|
final user = effectiveSpaceFor(transaction);
|
||||||
return _hlcManagers.putIfAbsent(
|
final currentNode = _currentNodeFor(user, transaction);
|
||||||
user.uuidSpaceId,
|
return currentNode.managers[user.id!] ??= HlcManager.forSpace(
|
||||||
() => HlcManager.forSpace(user),
|
user.copyWith(currentNode: currentNode.node),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -802,11 +856,12 @@ WHERE "id" IN (${rowIds.sqlLiteralList()})
|
||||||
HlcManager hlcManager,
|
HlcManager hlcManager,
|
||||||
Transaction transaction,
|
Transaction transaction,
|
||||||
) async {
|
) async {
|
||||||
await CrdtNode.db.updateRow(
|
await CrdtNode.db.update(
|
||||||
databaseSession,
|
databaseSession,
|
||||||
hlcManager.getNode(),
|
[hlcManager.getNode()],
|
||||||
columns: (t) => [t.lastHlc],
|
columns: (t) => [t.lastHlc],
|
||||||
transaction: transaction,
|
transaction: transaction,
|
||||||
|
noReturn: true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -234,13 +234,12 @@ class CrdtMutationRecorder {
|
||||||
|
|
||||||
/// Initializes the CRDT recorder.
|
/// Initializes the CRDT recorder.
|
||||||
///
|
///
|
||||||
/// Clears this recorder's live space/HLC caches, then ensures the shared
|
/// Clears this recorder's live space cache, then ensures the shared
|
||||||
/// [OfflineSyncDatabaseContext] metadata is loaded. The shared schema is loaded only
|
/// [OfflineSyncDatabaseContext] metadata is loaded. The shared schema is loaded only
|
||||||
/// once per process and is not reloaded here; see [OfflineSyncDatabaseContext] for
|
/// once per process and is not reloaded here; see [OfflineSyncDatabaseContext] for
|
||||||
/// the cache lifetime assumptions.
|
/// the cache lifetime assumptions.
|
||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
_context.spaceManager.clearCache();
|
_context.spaceManager.clearCache();
|
||||||
_context.clearHlcManagers();
|
|
||||||
_ensureInitializedFuture = null;
|
_ensureInitializedFuture = null;
|
||||||
_isInitialized = false;
|
_isInitialized = false;
|
||||||
|
|
||||||
|
|
@ -327,6 +326,19 @@ class CrdtMutationRecorder {
|
||||||
return _context.spaceManager.getOrCreate(userId);
|
return _context.spaceManager.getOrCreate(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a plain transaction can share an already initialized client node.
|
||||||
|
bool get hasPersistentSpace => _isInitialized && persistentUserId != null;
|
||||||
|
|
||||||
|
/// Shares the current-node clock within an atomic scope without reading it yet.
|
||||||
|
Future<R> withCurrentNodeHlc<R>(
|
||||||
|
Transaction transaction,
|
||||||
|
TransactionFunction<R> action,
|
||||||
|
) => _context.withCurrentNodeHlc(transaction, action);
|
||||||
|
|
||||||
|
/// Locks the node and refreshes its clock before the first tracked write or merge.
|
||||||
|
Future<void> lockAndRefreshCurrentNodeHlc(Transaction transaction) =>
|
||||||
|
_context.lockAndRefreshCurrentNodeHlc(transaction);
|
||||||
|
|
||||||
/// Records the latest acknowledged sync checkpoint for [otherNodeId].
|
/// Records the latest acknowledged sync checkpoint for [otherNodeId].
|
||||||
Future<void> recordSyncCheckpoint(
|
Future<void> recordSyncCheckpoint(
|
||||||
UuidValue userId,
|
UuidValue userId,
|
||||||
|
|
@ -1079,6 +1091,10 @@ class CrdtMutationRecorder {
|
||||||
null,
|
null,
|
||||||
transaction,
|
transaction,
|
||||||
);
|
);
|
||||||
|
await _context.persistCurrentNodeHlc(
|
||||||
|
_context.hlcManagerFor(transaction),
|
||||||
|
transaction,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _softDeleteRowsByTable(
|
Future<void> _softDeleteRowsByTable(
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,7 @@ class Hlc implements Comparable<Hlc> {
|
||||||
|
|
||||||
/// Increments the current timestamp for transmission to another system.
|
/// Increments the current timestamp for transmission to another system.
|
||||||
Hlc increment() {
|
Hlc increment() {
|
||||||
final localWallTime = clock.now().toUtc();
|
final localWallTime = clock.now().toUtcMillisecond();
|
||||||
final datetimeNew = localWallTime.isAfter(datetime) ? localWallTime : datetime;
|
final datetimeNew = localWallTime.isAfter(datetime) ? localWallTime : datetime;
|
||||||
final counterNew = datetimeNew == datetime ? counter + 1 : 0;
|
final counterNew = datetimeNew == datetime ? counter + 1 : 0;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ class HlcManager {
|
||||||
this.uuidSpaceId,
|
this.uuidSpaceId,
|
||||||
this.normalizedSpaceId,
|
this.normalizedSpaceId,
|
||||||
this.normalizedNodeId,
|
this.normalizedNodeId,
|
||||||
this.lastHlc,
|
this._node,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Creates a new [HlcManager] for the current node of [space].
|
/// Creates a new [HlcManager] for the current node of [space].
|
||||||
|
|
@ -19,7 +19,7 @@ class HlcManager {
|
||||||
space.uuidSpaceId,
|
space.uuidSpaceId,
|
||||||
space.id!,
|
space.id!,
|
||||||
space.currentNodeId!,
|
space.currentNodeId!,
|
||||||
space.currentNode!.lastHlc ?? Hlc.zero(space.currentNode!.uuidNodeId),
|
space.currentNode!,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -35,8 +35,12 @@ class HlcManager {
|
||||||
/// The normalized node ID of the current node.
|
/// The normalized node ID of the current node.
|
||||||
final int normalizedNodeId;
|
final int normalizedNodeId;
|
||||||
|
|
||||||
|
final CrdtNode _node;
|
||||||
|
|
||||||
/// The last HLC timestamp for the current node.
|
/// The last HLC timestamp for the current node.
|
||||||
Hlc lastHlc;
|
Hlc get lastHlc => _node.lastHlc ?? Hlc.zero(_node.uuidNodeId);
|
||||||
|
|
||||||
|
set lastHlc(Hlc value) => _node.lastHlc = value;
|
||||||
|
|
||||||
/// Returns the next HLC timestamp for the current node.
|
/// Returns the next HLC timestamp for the current node.
|
||||||
Hlc increment() {
|
Hlc increment() {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
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 '../../../../../benchmark/utils/query_counter.dart';
|
||||||
|
import '../test_tools/client_session.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
initTestClientSession(createSessionPerTest: false);
|
||||||
|
|
||||||
|
group('Given an initialized client wrapper with a persistent space,', () {
|
||||||
|
late QueryCountingDatabase database;
|
||||||
|
late OfflineSyncDatabaseSession app;
|
||||||
|
late Unique row;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
database = QueryCountingDatabase((await createAdditionalTestSession()).db);
|
||||||
|
app = OfflineSyncDatabaseSession(
|
||||||
|
database,
|
||||||
|
syncTables: testSyncTables,
|
||||||
|
persistentUserId: testCrdtUserId,
|
||||||
|
);
|
||||||
|
await app.db.initialize();
|
||||||
|
|
||||||
|
row = await Unique.db.insertRow(app, Unique(name: 'client read'));
|
||||||
|
});
|
||||||
|
|
||||||
|
group('when a plain transaction only reads a row,', () {
|
||||||
|
late Unique? found;
|
||||||
|
late int queries;
|
||||||
|
late int rowsRead;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
final queriesBefore = database.queryCount;
|
||||||
|
final rowsBefore = database.rowsRead;
|
||||||
|
|
||||||
|
found = await app.db.transaction(
|
||||||
|
(tx) => Unique.db.findById(app, row.id!, transaction: tx),
|
||||||
|
);
|
||||||
|
|
||||||
|
queries = database.queryCount - queriesBefore;
|
||||||
|
rowsRead = database.rowsRead - rowsBefore;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then the read uses only membership and domain queries.', () {
|
||||||
|
expect(found?.name, 'client read');
|
||||||
|
expect(queries, 3);
|
||||||
|
expect(rowsRead, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Given an initialized wrapper with a prepared space,', () {
|
||||||
|
late QueryCountingDatabase database;
|
||||||
|
late OfflineSyncDatabaseSession app;
|
||||||
|
late Unique row;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
database = QueryCountingDatabase((await createAdditionalTestSession()).db);
|
||||||
|
app = OfflineSyncDatabaseSession(database, syncTables: testSyncTables);
|
||||||
|
await app.db.initialize();
|
||||||
|
|
||||||
|
row = await app.db.transactionForUser(
|
||||||
|
testCrdtUserId,
|
||||||
|
(tx) => Unique.db.insertRow(
|
||||||
|
app,
|
||||||
|
Unique(name: 'space read'),
|
||||||
|
transaction: tx,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('when a space-bound transaction only reads a row,', () {
|
||||||
|
late Unique? found;
|
||||||
|
late int queries;
|
||||||
|
late int rowsRead;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
final queriesBefore = database.queryCount;
|
||||||
|
final rowsBefore = database.rowsRead;
|
||||||
|
|
||||||
|
found = await app.db.transactionForUser(
|
||||||
|
testCrdtUserId,
|
||||||
|
(tx) => Unique.db.findById(app, row.id!, transaction: tx),
|
||||||
|
);
|
||||||
|
|
||||||
|
queries = database.queryCount - queriesBefore;
|
||||||
|
rowsRead = database.rowsRead - rowsBefore;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then the read uses only membership and domain queries.', () {
|
||||||
|
expect(found?.name, 'space read');
|
||||||
|
expect(queries, 3);
|
||||||
|
expect(rowsRead, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:clock/clock.dart';
|
||||||
|
import 'package:serverpod/serverpod.dart';
|
||||||
|
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
|
||||||
|
import 'package:serverpod_offline_sync_test_server/src/generated/protocol.dart'
|
||||||
|
as server;
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
import '../test_tools/client_session.dart';
|
||||||
|
import '../test_tools/serverpod_test_tools.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
withServerpod(
|
||||||
|
'Server session node clock serialization',
|
||||||
|
rollbackDatabase: RollbackDatabase.disabled,
|
||||||
|
(sessionBuilder, _) {
|
||||||
|
final raw = sessionBuilder.build();
|
||||||
|
|
||||||
|
group('Given two server wrappers with cached clocks sharing a node,', () {
|
||||||
|
late OfflineSyncDatabaseSession first;
|
||||||
|
late OfflineSyncDatabaseSession second;
|
||||||
|
late UuidValue space;
|
||||||
|
late DateTime start;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
first = OfflineSyncDatabaseSession.wraps(raw, syncTables: [server.Unique.t]);
|
||||||
|
second = OfflineSyncDatabaseSession.wraps(
|
||||||
|
sessionBuilder.build(),
|
||||||
|
syncTables: [server.Unique.t],
|
||||||
|
);
|
||||||
|
await first.db.initialize();
|
||||||
|
await second.db.initialize();
|
||||||
|
|
||||||
|
space = const Uuid().v7obj();
|
||||||
|
start = DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
DateTime.now().millisecondsSinceEpoch,
|
||||||
|
isUtc: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
await withClock(Clock.fixed(start), () async {
|
||||||
|
await first.db.transactionForUser(space, (tx) async {
|
||||||
|
await server.Unique.db.insertRow(
|
||||||
|
first,
|
||||||
|
server.Unique(id: const Uuid().v7obj(), name: 'prime first'),
|
||||||
|
transaction: tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await second.db.transactionForUser(space, (tx) async {
|
||||||
|
await server.Unique.db.insertRow(
|
||||||
|
second,
|
||||||
|
server.Unique(id: const Uuid().v7obj(), name: 'prime second'),
|
||||||
|
transaction: tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDownAll(() async {
|
||||||
|
await first.clearUserTables();
|
||||||
|
});
|
||||||
|
|
||||||
|
group('when a stale wrapper writes while a newer node clock is committing,', () {
|
||||||
|
late Hlc firstClock;
|
||||||
|
late Hlc secondClock;
|
||||||
|
late Hlc persistedClock;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
final firstWritten = Completer<void>();
|
||||||
|
final releaseFirst = Completer<void>();
|
||||||
|
|
||||||
|
final firstRow = server.Unique(
|
||||||
|
id: const Uuid().v7obj(),
|
||||||
|
name: 'future first',
|
||||||
|
);
|
||||||
|
final secondRow = server.Unique(
|
||||||
|
id: const Uuid().v7obj(),
|
||||||
|
name: 'later second',
|
||||||
|
);
|
||||||
|
|
||||||
|
final writingFirst = withClock(
|
||||||
|
Clock.fixed(start.add(const Duration(seconds: 10))),
|
||||||
|
() => first.db.transactionForUser(space, (tx) async {
|
||||||
|
await server.Unique.db.insertRow(first, firstRow, transaction: tx);
|
||||||
|
firstWritten.complete();
|
||||||
|
await releaseFirst.future;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await firstWritten.future;
|
||||||
|
|
||||||
|
final writingSecond = withClock(
|
||||||
|
Clock.fixed(start),
|
||||||
|
() => second.db.transactionForUser(space, (tx) async {
|
||||||
|
await server.Unique.db.insertRow(second, secondRow, transaction: tx);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
releaseFirst.complete();
|
||||||
|
await Future.wait([writingFirst, writingSecond]);
|
||||||
|
|
||||||
|
firstClock = (await CrdtDataRow.db.findFirstRow(
|
||||||
|
raw,
|
||||||
|
where: (t) => t.uuidRowId.equals(firstRow.id),
|
||||||
|
include: CrdtDataRow.include(node: CrdtNode.include()),
|
||||||
|
))!.hlc;
|
||||||
|
secondClock = (await CrdtDataRow.db.findFirstRow(
|
||||||
|
raw,
|
||||||
|
where: (t) => t.uuidRowId.equals(secondRow.id),
|
||||||
|
include: CrdtDataRow.include(node: CrdtNode.include()),
|
||||||
|
))!.hlc;
|
||||||
|
|
||||||
|
persistedClock = (await OfflineSyncSpace.db.findFirstRow(
|
||||||
|
raw,
|
||||||
|
where: (t) => t.uuidSpaceId.equals(space),
|
||||||
|
include: OfflineSyncSpace.include(currentNode: CrdtNode.include()),
|
||||||
|
))!.currentNode!.lastHlc!;
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'then the later transaction observes the committed clock without regression.',
|
||||||
|
() {
|
||||||
|
expect(secondClock.compareTo(firstClock), greaterThan(0));
|
||||||
|
expect(persistedClock, secondClock);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,676 @@
|
||||||
|
import 'package:clock/clock.dart';
|
||||||
|
import 'package:serverpod_database/serverpod_database.dart' show Database;
|
||||||
|
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/crdt_probes.dart';
|
||||||
|
import '../test_tools/sync_topology.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
initTestClientSession(createSessionPerTest: false);
|
||||||
|
|
||||||
|
group(
|
||||||
|
'Given a cached app clock and a newer insertion received through another wrapper,',
|
||||||
|
() {
|
||||||
|
late DateTime start;
|
||||||
|
late SyncNode app;
|
||||||
|
late SyncNode existingPeer;
|
||||||
|
late SyncNode emptyPeer;
|
||||||
|
late Unique remote;
|
||||||
|
late Hlc insertionClock;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
start = DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
DateTime.now().millisecondsSinceEpoch,
|
||||||
|
isUtc: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
app = await syncNode(await createAdditionalTestSession(), testSyncTables);
|
||||||
|
existingPeer = await syncNode(
|
||||||
|
await createAdditionalTestSession(),
|
||||||
|
testSyncTables,
|
||||||
|
);
|
||||||
|
emptyPeer = await syncNode(await createAdditionalTestSession(), testSyncTables);
|
||||||
|
|
||||||
|
remote = Unique(id: const Uuid().v7obj(), name: 'remote');
|
||||||
|
|
||||||
|
await withClock(Clock.fixed(start), () async {
|
||||||
|
await app.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||||
|
await Unique.db.insertRow(
|
||||||
|
app.offlineSync,
|
||||||
|
Unique(id: const Uuid().v7obj(), name: 'prime app clock'),
|
||||||
|
transaction: tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final insertion = CrdtMergeInsert(
|
||||||
|
uuidSpaceId: testCrdtUserId,
|
||||||
|
tableName: Unique.t.tableName,
|
||||||
|
uuidRowId: remote.id!,
|
||||||
|
uuidNodeId: const Uuid().v7obj(),
|
||||||
|
hlcDatetime: start.add(const Duration(seconds: 10)),
|
||||||
|
hlcCounter: 0,
|
||||||
|
data: remote,
|
||||||
|
);
|
||||||
|
await existingPeer.offlineSync.db.mergeChanges([
|
||||||
|
insertion,
|
||||||
|
], spaceId: testCrdtUserId);
|
||||||
|
await app.sync.wrapDatabase(app.raw.db).mergeChanges([
|
||||||
|
insertion,
|
||||||
|
], spaceId: testCrdtUserId);
|
||||||
|
});
|
||||||
|
|
||||||
|
insertionClock = await rowHlc(remote.id!, databaseSession: app.offlineSync);
|
||||||
|
});
|
||||||
|
|
||||||
|
group(
|
||||||
|
'when the app authors an edit and a new row and round-trips with both peers,',
|
||||||
|
() {
|
||||||
|
late CrdtDataField authored;
|
||||||
|
late Hlc newRowClock;
|
||||||
|
late Hlc persistedClock;
|
||||||
|
late Unique? existingValue;
|
||||||
|
late CrdtDataField? existingField;
|
||||||
|
late Unique? bootstrapValue;
|
||||||
|
late CrdtDataField? bootstrapField;
|
||||||
|
late Unique? roundTripped;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
final fresh = Unique(
|
||||||
|
id: const Uuid().v7obj(),
|
||||||
|
name: 'causally later insert',
|
||||||
|
);
|
||||||
|
|
||||||
|
await withClock(Clock.fixed(start), () async {
|
||||||
|
await app.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||||
|
await Unique.db.updateRow(
|
||||||
|
app.offlineSync,
|
||||||
|
remote.copyWith(name: 'local edit'),
|
||||||
|
columns: (t) => [t.name],
|
||||||
|
transaction: tx,
|
||||||
|
);
|
||||||
|
await Unique.db.insertRow(app.offlineSync, fresh, transaction: tx);
|
||||||
|
});
|
||||||
|
|
||||||
|
authored = (await CrdtDataField.db.findFirstRow(
|
||||||
|
app.raw,
|
||||||
|
where: (t) =>
|
||||||
|
t.row.uuidRowId.equals(remote.id) & t.column.name.equals('name'),
|
||||||
|
include: CrdtDataField.include(node: CrdtNode.include()),
|
||||||
|
))!;
|
||||||
|
newRowClock = await rowHlc(fresh.id!, databaseSession: app.offlineSync);
|
||||||
|
|
||||||
|
final space = (await OfflineSyncSpace.db.findFirstRow(
|
||||||
|
app.raw,
|
||||||
|
where: (t) => t.uuidSpaceId.equals(testCrdtUserId),
|
||||||
|
include: OfflineSyncSpace.include(currentNode: CrdtNode.include()),
|
||||||
|
))!;
|
||||||
|
persistedClock = space.currentNode!.lastHlc!;
|
||||||
|
|
||||||
|
await pushChanges(app, existingPeer);
|
||||||
|
await pushChanges(app, emptyPeer);
|
||||||
|
|
||||||
|
existingValue = await Unique.db.findById(
|
||||||
|
existingPeer.offlineSync,
|
||||||
|
remote.id!,
|
||||||
|
);
|
||||||
|
existingField = await CrdtDataField.db.findFirstRow(
|
||||||
|
existingPeer.raw,
|
||||||
|
where: (t) =>
|
||||||
|
t.row.uuidRowId.equals(remote.id) & t.column.name.equals('name'),
|
||||||
|
include: CrdtDataField.include(node: CrdtNode.include()),
|
||||||
|
);
|
||||||
|
|
||||||
|
bootstrapValue = await Unique.db.findById(
|
||||||
|
emptyPeer.offlineSync,
|
||||||
|
remote.id!,
|
||||||
|
);
|
||||||
|
bootstrapField = await CrdtDataField.db.findFirstRow(
|
||||||
|
emptyPeer.raw,
|
||||||
|
where: (t) =>
|
||||||
|
t.row.uuidRowId.equals(remote.id) & t.column.name.equals('name'),
|
||||||
|
include: CrdtDataField.include(node: CrdtNode.include()),
|
||||||
|
);
|
||||||
|
|
||||||
|
await pushChanges(existingPeer, app);
|
||||||
|
await pushChanges(emptyPeer, app);
|
||||||
|
roundTripped = await Unique.db.findById(app.offlineSync, remote.id!);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then the authored field clock follows the received row clock.', () {
|
||||||
|
expect(authored.hlc.compareTo(insertionClock), greaterThan(0));
|
||||||
|
expect(persistedClock.compareTo(authored.hlc), greaterThanOrEqualTo(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then an unrelated insertion also observes the received clock.', () {
|
||||||
|
expect(newRowClock.compareTo(insertionClock), greaterThan(0));
|
||||||
|
expect(persistedClock.compareTo(newRowClock), greaterThanOrEqualTo(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'then the existing peer accepts the local edit and its authored clock.',
|
||||||
|
() {
|
||||||
|
expect(existingValue?.name, 'local edit');
|
||||||
|
expect(existingField?.hlc, authored.hlc);
|
||||||
|
expect(roundTripped?.name, 'local edit');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'then the empty bootstrap replica preserves the edit and its field clock.',
|
||||||
|
() {
|
||||||
|
expect(bootstrapValue?.name, 'local edit');
|
||||||
|
expect(bootstrapField?.hlc, authored.hlc);
|
||||||
|
expect(roundTripped?.name, 'local edit');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
group('Given two spaces sharing a node and a cached app clock,', () {
|
||||||
|
late SyncNode app;
|
||||||
|
late DateTime start;
|
||||||
|
late Unique local;
|
||||||
|
late Hlc receivedFieldClock;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
app = await syncNode(await createAdditionalTestSession(), testSyncTables);
|
||||||
|
|
||||||
|
start = DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
DateTime.now().millisecondsSinceEpoch,
|
||||||
|
isUtc: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
local = Unique(id: const Uuid().v7obj(), name: 'first space');
|
||||||
|
final otherSpace = const Uuid().v7obj();
|
||||||
|
final otherRow = Unique(id: const Uuid().v7obj(), name: 'second space');
|
||||||
|
|
||||||
|
final remoteNode = const Uuid().v7obj();
|
||||||
|
receivedFieldClock = Hlc(
|
||||||
|
start.add(const Duration(seconds: 10)),
|
||||||
|
41,
|
||||||
|
remoteNode,
|
||||||
|
);
|
||||||
|
|
||||||
|
await withClock(Clock.fixed(start), () async {
|
||||||
|
await app.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||||
|
await Unique.db.insertRow(app.offlineSync, local, transaction: tx);
|
||||||
|
});
|
||||||
|
await app.offlineSync.db.transactionForUser(otherSpace, (tx) async {
|
||||||
|
await Unique.db.insertRow(app.offlineSync, otherRow, transaction: tx);
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.sync.wrapDatabase(app.raw.db).mergeChanges([
|
||||||
|
CrdtMergeUpdate(
|
||||||
|
uuidSpaceId: otherSpace,
|
||||||
|
tableName: Unique.t.tableName,
|
||||||
|
uuidRowId: otherRow.id!,
|
||||||
|
uuidNodeId: remoteNode,
|
||||||
|
hlcDatetime: receivedFieldClock.datetime,
|
||||||
|
hlcCounter: receivedFieldClock.counter,
|
||||||
|
columnName: 'name',
|
||||||
|
value: 'remote second space',
|
||||||
|
),
|
||||||
|
], spaceId: otherSpace);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group(
|
||||||
|
'when the app edits its row after the other space receives a newer field,',
|
||||||
|
() {
|
||||||
|
late CrdtDataField authored;
|
||||||
|
late Hlc persistedClock;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
await withClock(Clock.fixed(start), () async {
|
||||||
|
await app.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||||
|
await Unique.db.updateRow(
|
||||||
|
app.offlineSync,
|
||||||
|
local.copyWith(name: 'after other space'),
|
||||||
|
columns: (t) => [t.name],
|
||||||
|
transaction: tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
authored = (await CrdtDataField.db.findFirstRow(
|
||||||
|
app.raw,
|
||||||
|
where: (t) =>
|
||||||
|
t.row.uuidRowId.equals(local.id) & t.column.name.equals('name'),
|
||||||
|
include: CrdtDataField.include(node: CrdtNode.include()),
|
||||||
|
))!;
|
||||||
|
|
||||||
|
final space = (await OfflineSyncSpace.db.findFirstRow(
|
||||||
|
app.raw,
|
||||||
|
where: (t) => t.uuidSpaceId.equals(testCrdtUserId),
|
||||||
|
include: OfflineSyncSpace.include(currentNode: CrdtNode.include()),
|
||||||
|
))!;
|
||||||
|
persistedClock = space.currentNode!.lastHlc!;
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'then the local field and persisted node observe the other space clock.',
|
||||||
|
() {
|
||||||
|
expect(authored.hlc.compareTo(receivedFieldClock), greaterThan(0));
|
||||||
|
expect(persistedClock.compareTo(authored.hlc), greaterThanOrEqualTo(0));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Given two wrappers with cached clocks for the same database,', () {
|
||||||
|
late SyncNode app;
|
||||||
|
late OfflineSyncDatabaseSession second;
|
||||||
|
late DateTime start;
|
||||||
|
late Unique row;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
app = await syncNode(await createAdditionalTestSession(), testSyncTables);
|
||||||
|
second = OfflineSyncDatabaseSession.wraps(app.raw, syncTables: testSyncTables);
|
||||||
|
await second.db.initialize();
|
||||||
|
|
||||||
|
start = DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
DateTime.now().millisecondsSinceEpoch,
|
||||||
|
isUtc: true,
|
||||||
|
);
|
||||||
|
row = Unique(id: const Uuid().v7obj(), name: 'first wrapper');
|
||||||
|
|
||||||
|
await withClock(Clock.fixed(start), () async {
|
||||||
|
await app.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||||
|
await Unique.db.insertRow(app.offlineSync, row, transaction: tx);
|
||||||
|
});
|
||||||
|
|
||||||
|
await second.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||||
|
await Unique.db.updateRow(
|
||||||
|
second,
|
||||||
|
row.copyWith(name: 'prime second wrapper'),
|
||||||
|
columns: (t) => [t.name],
|
||||||
|
transaction: tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group(
|
||||||
|
'when both wrappers write in the same transaction as wall time moves back,',
|
||||||
|
() {
|
||||||
|
late Hlc firstClock;
|
||||||
|
late Hlc secondClock;
|
||||||
|
late Hlc persistedClock;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
await app.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||||
|
await withClock(
|
||||||
|
Clock.fixed(start.add(const Duration(seconds: 10))),
|
||||||
|
() async {
|
||||||
|
await Unique.db.updateRow(
|
||||||
|
app.offlineSync,
|
||||||
|
row.copyWith(name: 'future first wrapper'),
|
||||||
|
columns: (t) => [t.name],
|
||||||
|
transaction: tx,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
firstClock = (await CrdtDataField.db.findFirstRow(
|
||||||
|
app.raw,
|
||||||
|
where: (t) =>
|
||||||
|
t.row.uuidRowId.equals(row.id) & t.column.name.equals('name'),
|
||||||
|
include: CrdtDataField.include(node: CrdtNode.include()),
|
||||||
|
transaction: tx,
|
||||||
|
))!.hlc;
|
||||||
|
|
||||||
|
await withClock(Clock.fixed(start), () async {
|
||||||
|
await Unique.db.updateRow(
|
||||||
|
second,
|
||||||
|
row.copyWith(name: 'later second wrapper'),
|
||||||
|
columns: (t) => [t.name],
|
||||||
|
transaction: tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
secondClock = (await CrdtDataField.db.findFirstRow(
|
||||||
|
app.raw,
|
||||||
|
where: (t) =>
|
||||||
|
t.row.uuidRowId.equals(row.id) & t.column.name.equals('name'),
|
||||||
|
include: CrdtDataField.include(node: CrdtNode.include()),
|
||||||
|
transaction: tx,
|
||||||
|
))!.hlc;
|
||||||
|
});
|
||||||
|
|
||||||
|
final space = (await OfflineSyncSpace.db.findFirstRow(
|
||||||
|
app.raw,
|
||||||
|
where: (t) => t.uuidSpaceId.equals(testCrdtUserId),
|
||||||
|
include: OfflineSyncSpace.include(currentNode: CrdtNode.include()),
|
||||||
|
))!;
|
||||||
|
persistedClock = space.currentNode!.lastHlc!;
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'then both wrappers advance one transaction-visible clock monotonically.',
|
||||||
|
() {
|
||||||
|
expect(secondClock.compareTo(firstClock), greaterThan(0));
|
||||||
|
expect(persistedClock, secondClock);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Given a committed local deletion with no subsequent writes,', () {
|
||||||
|
late SyncNode app;
|
||||||
|
late DateTime start;
|
||||||
|
late Hlc deletionClock;
|
||||||
|
late Hlc persistedDeletionClock;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
app = await syncNode(await createAdditionalTestSession(), testSyncTables);
|
||||||
|
|
||||||
|
start = DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
DateTime.now().millisecondsSinceEpoch,
|
||||||
|
isUtc: true,
|
||||||
|
);
|
||||||
|
final row = Unique(id: const Uuid().v7obj(), name: 'deleted');
|
||||||
|
|
||||||
|
await withClock(Clock.fixed(start), () async {
|
||||||
|
await app.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||||
|
await Unique.db.insertRow(app.offlineSync, row, transaction: tx);
|
||||||
|
await Unique.db.deleteRow(app.offlineSync, row, transaction: tx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
deletionClock = (await CrdtDataDeleted.db.findFirstRow(
|
||||||
|
app.raw,
|
||||||
|
where: (t) => t.row.uuidRowId.equals(row.id),
|
||||||
|
include: CrdtDataDeleted.include(node: CrdtNode.include()),
|
||||||
|
))!.hlc;
|
||||||
|
persistedDeletionClock = (await OfflineSyncSpace.db.findFirstRow(
|
||||||
|
app.raw,
|
||||||
|
where: (t) => t.uuidSpaceId.equals(testCrdtUserId),
|
||||||
|
include: OfflineSyncSpace.include(currentNode: CrdtNode.include()),
|
||||||
|
))!.currentNode!.lastHlc!;
|
||||||
|
});
|
||||||
|
|
||||||
|
group('when a new wrapper inserts another row at the same wall time,', () {
|
||||||
|
late Hlc newClock;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
final second = OfflineSyncDatabaseSession.wraps(
|
||||||
|
app.raw,
|
||||||
|
syncTables: testSyncTables,
|
||||||
|
);
|
||||||
|
final row = Unique(id: const Uuid().v7obj(), name: 'after deletion');
|
||||||
|
|
||||||
|
await withClock(Clock.fixed(start), () async {
|
||||||
|
await second.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||||
|
await Unique.db.insertRow(second, row, transaction: tx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
newClock = await rowHlc(row.id!, databaseSession: app.offlineSync);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'then the persisted deletion clock orders the new write after the tombstone.',
|
||||||
|
() {
|
||||||
|
expect(persistedDeletionClock, deletionClock);
|
||||||
|
expect(newClock.compareTo(deletionClock), greaterThan(0));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Given a persistent wrapper inside a caller-owned transaction,', () {
|
||||||
|
late OfflineSyncDatabaseSession app;
|
||||||
|
late Database rawDatabase;
|
||||||
|
late DateTime start;
|
||||||
|
late Hlc committedClock;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
final raw = await createAdditionalTestSession();
|
||||||
|
rawDatabase = raw.db;
|
||||||
|
app = OfflineSyncDatabaseSession.wraps(
|
||||||
|
raw,
|
||||||
|
syncTables: testSyncTables,
|
||||||
|
persistentUserId: testCrdtUserId,
|
||||||
|
);
|
||||||
|
await app.db.initialize();
|
||||||
|
|
||||||
|
start = DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
DateTime.now().millisecondsSinceEpoch,
|
||||||
|
isUtc: true,
|
||||||
|
);
|
||||||
|
final prime = Unique(
|
||||||
|
id: const Uuid().v7obj(),
|
||||||
|
name: 'committed before savepoint',
|
||||||
|
);
|
||||||
|
|
||||||
|
await withClock(Clock.fixed(start), () async {
|
||||||
|
await Unique.db.insertRow(app, prime);
|
||||||
|
});
|
||||||
|
|
||||||
|
committedClock = await rowHlc(prime.id!, databaseSession: app);
|
||||||
|
});
|
||||||
|
|
||||||
|
group(
|
||||||
|
'when the caller rolls back a savepoint containing a future-dated write,',
|
||||||
|
() {
|
||||||
|
late Hlc expectedNext;
|
||||||
|
late Hlc nextClock;
|
||||||
|
late Hlc persistedClock;
|
||||||
|
late Unique? rolledBackRow;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
final rolledBack = Unique(
|
||||||
|
id: const Uuid().v7obj(),
|
||||||
|
name: 'rollback savepoint',
|
||||||
|
);
|
||||||
|
final next = Unique(
|
||||||
|
id: const Uuid().v7obj(),
|
||||||
|
name: 'after savepoint rollback',
|
||||||
|
);
|
||||||
|
|
||||||
|
await rawDatabase.transaction((tx) async {
|
||||||
|
final savepoint = await tx.createSavepoint();
|
||||||
|
await withClock(
|
||||||
|
Clock.fixed(start.add(const Duration(seconds: 30))),
|
||||||
|
() async {
|
||||||
|
await Unique.db.insertRow(app, rolledBack, transaction: tx);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await savepoint.rollback();
|
||||||
|
|
||||||
|
await withClock(Clock.fixed(start), () async {
|
||||||
|
expectedNext = committedClock.increment();
|
||||||
|
await Unique.db.insertRow(app, next, transaction: tx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
nextClock = await rowHlc(next.id!, databaseSession: app);
|
||||||
|
rolledBackRow = await Unique.db.findById(app, rolledBack.id!);
|
||||||
|
|
||||||
|
final space = (await OfflineSyncSpace.db.findFirstRow(
|
||||||
|
app,
|
||||||
|
where: (t) => t.uuidSpaceId.equals(testCrdtUserId),
|
||||||
|
include: OfflineSyncSpace.include(currentNode: CrdtNode.include()),
|
||||||
|
))!;
|
||||||
|
persistedClock = space.currentNode!.lastHlc!;
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'then the next write reloads the clock surviving the caller rollback.',
|
||||||
|
() {
|
||||||
|
expect(rolledBackRow, isNull);
|
||||||
|
expect(nextClock, expectedNext);
|
||||||
|
expect(persistedClock, expectedNext);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group(
|
||||||
|
'Given a client wrapper with a committed clock before explicit cancellation,',
|
||||||
|
() {
|
||||||
|
late OfflineSyncDatabaseSession app;
|
||||||
|
late DateTime start;
|
||||||
|
late Hlc committedClock;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
app = OfflineSyncDatabaseSession.wraps(
|
||||||
|
await createAdditionalTestSession(),
|
||||||
|
syncTables: testSyncTables,
|
||||||
|
persistentUserId: testCrdtUserId,
|
||||||
|
);
|
||||||
|
await app.db.initialize();
|
||||||
|
|
||||||
|
start = DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
DateTime.now().millisecondsSinceEpoch,
|
||||||
|
isUtc: true,
|
||||||
|
);
|
||||||
|
final prime = Unique(id: const Uuid().v7obj(), name: 'before cancellation');
|
||||||
|
|
||||||
|
await withClock(Clock.fixed(start), () async {
|
||||||
|
await Unique.db.insertRow(app, prime);
|
||||||
|
});
|
||||||
|
|
||||||
|
committedClock = await rowHlc(prime.id!, databaseSession: app);
|
||||||
|
});
|
||||||
|
|
||||||
|
group(
|
||||||
|
'when the caller cancels a future-dated transaction and then writes again,',
|
||||||
|
() {
|
||||||
|
String? returned;
|
||||||
|
Object? failure;
|
||||||
|
late Hlc persistedAfterCancel;
|
||||||
|
late Hlc expectedNext;
|
||||||
|
late Hlc nextClock;
|
||||||
|
late Unique? cancelledRow;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
final cancelled = Unique(id: const Uuid().v7obj(), name: 'cancelled');
|
||||||
|
final next = Unique(id: const Uuid().v7obj(), name: 'after cancellation');
|
||||||
|
|
||||||
|
try {
|
||||||
|
returned = await withClock(
|
||||||
|
Clock.fixed(start.add(const Duration(seconds: 30))),
|
||||||
|
() => app.db.transaction((tx) async {
|
||||||
|
await Unique.db.insertRow(app, cancelled, transaction: tx);
|
||||||
|
await tx.cancel();
|
||||||
|
return 'cancelled';
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} on Object catch (error) {
|
||||||
|
failure = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelledRow = await Unique.db.findById(app, cancelled.id!);
|
||||||
|
persistedAfterCancel = (await OfflineSyncSpace.db.findFirstRow(
|
||||||
|
app,
|
||||||
|
where: (t) => t.uuidSpaceId.equals(testCrdtUserId),
|
||||||
|
include: OfflineSyncSpace.include(currentNode: CrdtNode.include()),
|
||||||
|
))!.currentNode!.lastHlc!;
|
||||||
|
|
||||||
|
await withClock(Clock.fixed(start), () async {
|
||||||
|
expectedNext = committedClock.increment();
|
||||||
|
await Unique.db.insertRow(app, next);
|
||||||
|
});
|
||||||
|
nextClock = await rowHlc(next.id!, databaseSession: app);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'then cancellation returns normally and discards the transaction clock.',
|
||||||
|
() {
|
||||||
|
expect(failure, isNull);
|
||||||
|
expect(returned, 'cancelled');
|
||||||
|
expect(cancelledRow, isNull);
|
||||||
|
expect(persistedAfterCancel, committedClock);
|
||||||
|
expect(nextClock, expectedNext);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
group('Given a wrapper with a committed local clock,', () {
|
||||||
|
late SyncNode app;
|
||||||
|
late DateTime start;
|
||||||
|
late Hlc committedClock;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
app = await syncNode(await createAdditionalTestSession(), testSyncTables);
|
||||||
|
|
||||||
|
start = DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
DateTime.now().millisecondsSinceEpoch,
|
||||||
|
isUtc: true,
|
||||||
|
);
|
||||||
|
final prime = Unique(id: const Uuid().v7obj(), name: 'committed');
|
||||||
|
|
||||||
|
await withClock(Clock.fixed(start), () async {
|
||||||
|
await app.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||||
|
await Unique.db.insertRow(app.offlineSync, prime, transaction: tx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
committedClock = await rowHlc(prime.id!, databaseSession: app.offlineSync);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('when a future-dated transaction rolls back before another local write,', () {
|
||||||
|
late Object? failure;
|
||||||
|
late Hlc afterRollback;
|
||||||
|
late Hlc nextClock;
|
||||||
|
late Hlc expectedNext;
|
||||||
|
late Unique? rolledBackRow;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
final rolledBack = Unique(id: const Uuid().v7obj(), name: 'rolled back');
|
||||||
|
final next = Unique(id: const Uuid().v7obj(), name: 'after rollback');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withClock(
|
||||||
|
Clock.fixed(start.add(const Duration(seconds: 30))),
|
||||||
|
() async {
|
||||||
|
await app.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||||
|
await Unique.db.insertRow(app.offlineSync, rolledBack, transaction: tx);
|
||||||
|
throw Exception('rollback clock');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} on Exception catch (error) {
|
||||||
|
failure = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
final space = (await OfflineSyncSpace.db.findFirstRow(
|
||||||
|
app.raw,
|
||||||
|
where: (t) => t.uuidSpaceId.equals(testCrdtUserId),
|
||||||
|
include: OfflineSyncSpace.include(currentNode: CrdtNode.include()),
|
||||||
|
))!;
|
||||||
|
afterRollback = space.currentNode!.lastHlc!;
|
||||||
|
rolledBackRow = await Unique.db.findById(app.offlineSync, rolledBack.id!);
|
||||||
|
|
||||||
|
await withClock(Clock.fixed(start), () async {
|
||||||
|
expectedNext = committedClock.increment();
|
||||||
|
await app.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||||
|
await Unique.db.insertRow(app.offlineSync, next, transaction: tx);
|
||||||
|
});
|
||||||
|
nextClock = await rowHlc(next.id!, databaseSession: app.offlineSync);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'then rollback leaves neither a persisted clock change nor a poisoned cache.',
|
||||||
|
() {
|
||||||
|
expect(failure, isA<Exception>());
|
||||||
|
expect(rolledBackRow, isNull);
|
||||||
|
expect(afterRollback, committedClock);
|
||||||
|
expect(nextClock, expectedNext);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
import 'package:clock/clock.dart';
|
||||||
|
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/crdt_probes.dart';
|
||||||
|
import '../test_tools/sync_topology.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
initTestClientSession(createSessionPerTest: false);
|
||||||
|
|
||||||
|
group('Given a received insertion with a counter in the current millisecond,', () {
|
||||||
|
late SyncNode app;
|
||||||
|
late SyncNode peer;
|
||||||
|
late DateTime start;
|
||||||
|
late Unique row;
|
||||||
|
late Hlc insertionClock;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
app = await syncNode(await createAdditionalTestSession(), testSyncTables);
|
||||||
|
peer = await syncNode(await createAdditionalTestSession(), testSyncTables);
|
||||||
|
|
||||||
|
start = DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
DateTime.now().millisecondsSinceEpoch,
|
||||||
|
isUtc: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
row = Unique(id: const Uuid().v7obj(), name: 'remote within millisecond');
|
||||||
|
final insertion = CrdtMergeInsert(
|
||||||
|
uuidSpaceId: testCrdtUserId,
|
||||||
|
tableName: Unique.t.tableName,
|
||||||
|
uuidRowId: row.id!,
|
||||||
|
uuidNodeId: const Uuid().v7obj(),
|
||||||
|
hlcDatetime: start,
|
||||||
|
hlcCounter: 41,
|
||||||
|
data: row,
|
||||||
|
);
|
||||||
|
|
||||||
|
await withClock(Clock.fixed(start), () async {
|
||||||
|
await app.offlineSync.db.mergeChanges([insertion], spaceId: testCrdtUserId);
|
||||||
|
await peer.offlineSync.db.mergeChanges([insertion], spaceId: testCrdtUserId);
|
||||||
|
});
|
||||||
|
|
||||||
|
insertionClock = await rowHlc(row.id!, databaseSession: app.offlineSync);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('when the same wrapper edits the row half a millisecond later,', () {
|
||||||
|
late Hlc authoredClock;
|
||||||
|
late Hlc persistedClock;
|
||||||
|
late Unique? peerValue;
|
||||||
|
late Unique? roundTripped;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
await withClock(
|
||||||
|
Clock.fixed(start.add(const Duration(microseconds: 500))),
|
||||||
|
() async {
|
||||||
|
await app.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
|
||||||
|
await Unique.db.updateRow(
|
||||||
|
app.offlineSync,
|
||||||
|
row.copyWith(name: 'local within millisecond'),
|
||||||
|
columns: (t) => [t.name],
|
||||||
|
transaction: tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
authoredClock = (await CrdtDataField.db.findFirstRow(
|
||||||
|
app.raw,
|
||||||
|
where: (t) =>
|
||||||
|
t.row.uuidRowId.equals(row.id) & t.column.name.equals('name'),
|
||||||
|
include: CrdtDataField.include(node: CrdtNode.include()),
|
||||||
|
))!.hlc;
|
||||||
|
persistedClock = (await OfflineSyncSpace.db.findFirstRow(
|
||||||
|
app.raw,
|
||||||
|
where: (t) => t.uuidSpaceId.equals(testCrdtUserId),
|
||||||
|
include: OfflineSyncSpace.include(currentNode: CrdtNode.include()),
|
||||||
|
))!.currentNode!.lastHlc!;
|
||||||
|
|
||||||
|
await pushChanges(app, peer);
|
||||||
|
peerValue = await Unique.db.findById(peer.offlineSync, row.id!);
|
||||||
|
|
||||||
|
await pushChanges(peer, app);
|
||||||
|
roundTripped = await Unique.db.findById(app.offlineSync, row.id!);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then the counter advances and the edit survives the peer round trip.', () {
|
||||||
|
expect(authoredClock.compareTo(insertionClock), greaterThan(0));
|
||||||
|
expect(authoredClock.datetime, start);
|
||||||
|
expect(authoredClock.counter, 42);
|
||||||
|
expect(persistedClock, authoredClock);
|
||||||
|
expect(peerValue?.name, 'local within millisecond');
|
||||||
|
expect(roundTripped?.name, 'local within millisecond');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue