fix: Refresh node clocks across sync wrappers (#152)
This commit is contained in:
parent
51ce999af0
commit
96271a25ab
6 changed files with 1115 additions and 43 deletions
|
|
@ -168,7 +168,7 @@ class OfflineSyncDatabase implements Database {
|
|||
));
|
||||
try {
|
||||
await transactionForUser<void>(effectiveSpaceId, (tx) async {
|
||||
await _recorder.lockCurrentUser(tx);
|
||||
await _recorder.lockAndRefreshCurrentNodeHlc(tx);
|
||||
await _recorder.mergeChanges(mergeSet, tx);
|
||||
});
|
||||
} on OfflineSyncIntegrityViolationException catch (exception) {
|
||||
|
|
@ -308,6 +308,18 @@ class OfflineSyncDatabase implements Database {
|
|||
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
|
||||
Future<List<T>> insert<T extends TableRow>(
|
||||
List<T> rows, {
|
||||
|
|
@ -325,8 +337,7 @@ class OfflineSyncDatabase implements Database {
|
|||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
return DatabaseUtil.runInTransactionOrSavepoint(
|
||||
_delegate,
|
||||
return _runTrackedWrite(
|
||||
transaction,
|
||||
(tx) async {
|
||||
final prepared = _prepareRowsForInsert(rows, tx);
|
||||
|
|
@ -426,8 +437,7 @@ class OfflineSyncDatabase implements Database {
|
|||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
return DatabaseUtil.runInTransactionOrSavepoint(
|
||||
_delegate,
|
||||
return _runTrackedWrite(
|
||||
transaction,
|
||||
(tx) async {
|
||||
final prepared = _prepareRowsForInsert(rows, tx);
|
||||
|
|
@ -641,8 +651,7 @@ class OfflineSyncDatabase implements Database {
|
|||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
return DatabaseUtil.runInTransactionOrSavepoint(
|
||||
_delegate,
|
||||
return _runTrackedWrite(
|
||||
transaction,
|
||||
(tx) async {
|
||||
final plannedUpdates = await _recorder.planLocalUpdates(rows, columns, tx);
|
||||
|
|
@ -681,8 +690,7 @@ class OfflineSyncDatabase implements Database {
|
|||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
return DatabaseUtil.runInTransactionOrSavepoint(
|
||||
_delegate,
|
||||
return _runTrackedWrite(
|
||||
transaction,
|
||||
(tx) async {
|
||||
final plannedUpdates = await _recorder.planLocalUpdates([row], columns, tx);
|
||||
|
|
@ -786,8 +794,7 @@ class OfflineSyncDatabase implements Database {
|
|||
}
|
||||
|
||||
_assertNoSpaceIdColumnValues<T>(columnValues);
|
||||
return DatabaseUtil.runInTransactionOrSavepoint(
|
||||
_delegate,
|
||||
return _runTrackedWrite(
|
||||
transaction,
|
||||
(tx) async {
|
||||
final result = await _delegate.updateWhere<T>(
|
||||
|
|
@ -882,8 +889,7 @@ class OfflineSyncDatabase implements Database {
|
|||
);
|
||||
}
|
||||
|
||||
return DatabaseUtil.runInTransactionOrSavepoint(
|
||||
_delegate,
|
||||
return _runTrackedWrite(
|
||||
transaction,
|
||||
(tx) async {
|
||||
final rows = await _delegate.find<T>(
|
||||
|
|
@ -956,7 +962,9 @@ class OfflineSyncDatabase implements Database {
|
|||
// [transaction] before CRDT tables exist. Callers that need CRDT state
|
||||
// ([transactionForUser], mutating ORM methods) initialize explicitly.
|
||||
return _delegate.transaction(
|
||||
transactionFunction,
|
||||
_recorder.hasPersistentSpace
|
||||
? (tx) => _recorder.withCurrentNodeHlc(tx, transactionFunction)
|
||||
: transactionFunction,
|
||||
settings: settings,
|
||||
);
|
||||
}
|
||||
|
|
@ -984,7 +992,7 @@ class OfflineSyncDatabase implements Database {
|
|||
try {
|
||||
spaceForTransaction[tx] = space;
|
||||
userForTransaction[tx] = userId;
|
||||
return await transactionFunction(tx);
|
||||
return await _recorder.withCurrentNodeHlc(tx, transactionFunction);
|
||||
} finally {
|
||||
spaceForTransaction.remove(tx);
|
||||
userForTransaction.remove(tx);
|
||||
|
|
|
|||
|
|
@ -65,20 +65,6 @@ class _PendingInsertAttempts {
|
|||
|
||||
/// Adds merge-specific behavior to [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.
|
||||
Future<void> mergeChanges(
|
||||
CrdtMergeSet mergeSet,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,15 @@ enum ForeignKeyTargetPresence {
|
|||
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.
|
||||
///
|
||||
/// Owns per-recorder space/HLC management and the low-level domain and CRDT
|
||||
|
|
@ -54,10 +63,55 @@ class CrdtRecorderContext {
|
|||
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.
|
||||
void clearHlcManagers() => _hlcManagers.clear();
|
||||
_CurrentNodeHlc _currentNodeFor(OfflineSyncSpace space, Transaction transaction) =>
|
||||
(_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)`.
|
||||
Map<String, (int, Map<String, CrdtSchemaColumn>)> get schema =>
|
||||
|
|
@ -792,9 +846,9 @@ WHERE "id" IN (${rowIds.sqlLiteralList()})
|
|||
|
||||
HlcManager hlcManagerFor(Transaction transaction) {
|
||||
final user = effectiveSpaceFor(transaction);
|
||||
return _hlcManagers.putIfAbsent(
|
||||
user.uuidSpaceId,
|
||||
() => HlcManager.forSpace(user),
|
||||
final currentNode = _currentNodeFor(user, transaction);
|
||||
return currentNode.managers[user.id!] ??= HlcManager.forSpace(
|
||||
user.copyWith(currentNode: currentNode.node),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -802,11 +856,12 @@ WHERE "id" IN (${rowIds.sqlLiteralList()})
|
|||
HlcManager hlcManager,
|
||||
Transaction transaction,
|
||||
) async {
|
||||
await CrdtNode.db.updateRow(
|
||||
await CrdtNode.db.update(
|
||||
databaseSession,
|
||||
hlcManager.getNode(),
|
||||
[hlcManager.getNode()],
|
||||
columns: (t) => [t.lastHlc],
|
||||
transaction: transaction,
|
||||
noReturn: true,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -234,13 +234,12 @@ class CrdtMutationRecorder {
|
|||
|
||||
/// 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
|
||||
/// once per process and is not reloaded here; see [OfflineSyncDatabaseContext] for
|
||||
/// the cache lifetime assumptions.
|
||||
Future<void> initialize() async {
|
||||
_context.spaceManager.clearCache();
|
||||
_context.clearHlcManagers();
|
||||
_ensureInitializedFuture = null;
|
||||
_isInitialized = false;
|
||||
|
||||
|
|
@ -327,6 +326,19 @@ class CrdtMutationRecorder {
|
|||
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].
|
||||
Future<void> recordSyncCheckpoint(
|
||||
UuidValue userId,
|
||||
|
|
@ -1079,6 +1091,10 @@ class CrdtMutationRecorder {
|
|||
null,
|
||||
transaction,
|
||||
);
|
||||
await _context.persistCurrentNodeHlc(
|
||||
_context.hlcManagerFor(transaction),
|
||||
transaction,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _softDeleteRowsByTable(
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ class Hlc implements Comparable<Hlc> {
|
|||
|
||||
/// Increments the current timestamp for transmission to another system.
|
||||
Hlc increment() {
|
||||
final localWallTime = clock.now().toUtc();
|
||||
final localWallTime = clock.now().toUtcMillisecond();
|
||||
final datetimeNew = localWallTime.isAfter(datetime) ? localWallTime : datetime;
|
||||
final counterNew = datetimeNew == datetime ? counter + 1 : 0;
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ class HlcManager {
|
|||
this.uuidSpaceId,
|
||||
this.normalizedSpaceId,
|
||||
this.normalizedNodeId,
|
||||
this.lastHlc,
|
||||
this._node,
|
||||
);
|
||||
|
||||
/// Creates a new [HlcManager] for the current node of [space].
|
||||
|
|
@ -19,7 +19,7 @@ class HlcManager {
|
|||
space.uuidSpaceId,
|
||||
space.id!,
|
||||
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.
|
||||
final int normalizedNodeId;
|
||||
|
||||
final CrdtNode _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.
|
||||
Hlc increment() {
|
||||
|
|
|
|||
Loading…
Reference in a new issue