fix: Refresh node clocks across sync wrappers (#152)

This commit is contained in:
Marcelo Mendonça Soares 2026-09-21 16:52:03 -03:00 • committed by GitHub
commit 96271a25ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1115 additions and 43 deletions

View file

@ -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);

View file

@ -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,

View file

@ -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,
);
}

View file

@ -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(

View file

@ -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;

View file

@ -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() {