fix: Correct checkpoint progress and add delta DST coverage (#158)

This commit is contained in:
Marcelo Mendonça Soares 2026-10-07 15:49:15 -03:00 • committed by GitHub
commit f59a90bc97
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1829 additions and 163 deletions

View file

@ -133,7 +133,11 @@ class OfflineSyncDatabase implements Database {
);
}
/// Records the latest acknowledged sync checkpoint for [otherNodeId].
/// Records acknowledged per-author progress for [otherNodeId].
///
/// [syncedHlc] must carry [otherNodeId] as its author. Throws [ArgumentError]
/// otherwise. A correctly tagged checkpoint replaces previously stored
/// progress tagged with another author; valid progress advances monotonically.
Future<void> recordSyncCheckpoint(
UuidValue otherNodeId,
Hlc syncedHlc, {
@ -670,6 +674,7 @@ class OfflineSyncDatabase implements Database {
columns,
tx,
projectionUnchanged: plannedUpdates.projectionUnchanged,
projectionRows: plannedUpdates.projectionRows,
);
return noReturn ? <T>[] : updatedRows;
},
@ -707,6 +712,7 @@ class OfflineSyncDatabase implements Database {
columns,
tx,
projectionUnchanged: plannedUpdates.projectionUnchanged,
projectionRows: plannedUpdates.projectionRows,
);
return updatedRow;
},
@ -797,28 +803,42 @@ class OfflineSyncDatabase implements Database {
return _runTrackedWrite(
transaction,
(tx) async {
final result = await _delegate.updateWhere<T>(
columnValues: columnValues,
where: (await _whereVisibleWithTombstone<T>(
where,
null,
tx,
membershipWide: false,
))!,
final scopedWhere = (await _whereVisibleWithTombstone<T>(
where,
null,
tx,
membershipWide: false,
))!;
final columns = columnValues.map((e) => e.column).toList();
final plan = await _recorder.planLocalUpdateWhere<T>(
columns,
scopedWhere,
tx,
limit: limit,
offset: offset,
orderBy: orderBy,
orderByList: orderByList,
);
final selectedWhere = plan == null
? scopedWhere
: serializationManager.getTableForType(T)!.id.inSet(plan.rowIds);
final result = await _delegate.updateWhere<T>(
columnValues: columnValues,
where: selectedWhere,
limit: plan == null ? limit : null,
offset: plan == null ? offset : null,
orderBy: orderBy,
orderByList: orderByList,
transaction: tx,
);
final columns = columnValues.map((e) => e.column).toList();
_recorder.validateAuthoredRows(result, columns);
await _recorder.afterUpdate(
result,
columns,
tx,
authoredColumnValues: true,
projectionRows: plan?.projectionRows ?? const {},
);
if (noReturn) return <T>[];
result.forEach(_stripSpaceId);

View file

@ -195,8 +195,11 @@ extension CrdtMergeRecorderExtension on CrdtMutationRecorder {
in maxIncomingHlcByNode.entries) {
final remoteNode = remoteNodes.spaceNodesByUuid[nodeId];
if (remoteNode == null) continue;
final previous = remoteNode.lastReceivedHlc;
final updatedSpaceNode = remoteNode.copyWith(
lastReceivedHlc: incomingHlc.maxBetween(remoteNode.lastReceivedHlc),
lastReceivedHlc: incomingHlc.maxBetween(
previous?.nodeId == nodeId ? previous : null,
),
);
if (updatedSpaceNode.lastReceivedHlc == remoteNode.lastReceivedHlc) {
continue;

View file

@ -340,12 +340,22 @@ class CrdtMutationRecorder {
Future<void> lockAndRefreshCurrentNodeHlc(Transaction transaction) =>
_context.lockAndRefreshCurrentNodeHlc(transaction);
/// Records the latest acknowledged sync checkpoint for [otherNodeId].
/// Records per-author progress, rejecting an HLC not tagged with [otherNodeId].
///
/// Replaces mis-tagged stored progress; correctly tagged progress only advances.
Future<void> recordSyncCheckpoint(
UuidValue userId,
UuidValue otherNodeId,
Hlc syncedHlc,
) async {
if (syncedHlc.nodeId != otherNodeId) {
throw ArgumentError.value(
syncedHlc,
'syncedHlc',
'A per-author checkpoint must carry otherNodeId',
);
}
final space = await _context.spaceManager.getOrCreate(userId);
await _db.transaction((transaction) async {
final node = await _context.findOrCreateNode(otherNodeId, transaction);
@ -356,7 +366,9 @@ class CrdtMutationRecorder {
);
final currentSyncHlc = spaceNode.lastReceivedHlc;
if (currentSyncHlc != null && currentSyncHlc >= syncedHlc) {
if (currentSyncHlc != null &&
currentSyncHlc.nodeId == otherNodeId &&
currentSyncHlc >= syncedHlc) {
return;
}
@ -574,21 +586,35 @@ class CrdtMutationRecorder {
}
/// Plans FK/unique projection for rows that are about to be updated.
Future<({List<T> rows, bool projectionUnchanged})>
Future<({List<T> rows, bool projectionUnchanged, Set<MergeRowKey> projectionRows})>
planLocalUpdates<T extends TableRow>(
List<T> rows,
List<Column>? columns,
Transaction transaction, {
bool restoring = false,
}) async {
if (rows.isEmpty) return (rows: rows, projectionUnchanged: true);
if (rows.isEmpty) {
return (
rows: rows,
projectionUnchanged: true,
projectionRows: const <MergeRowKey>{},
);
}
final tableName = rows.first.table.tableName;
if (!_context.isCrdtTrackedTableName(tableName)) {
return (rows: rows, projectionUnchanged: true);
return (
rows: rows,
projectionUnchanged: true,
projectionRows: const <MergeRowKey>{},
);
}
final columnNames = columns?.map((column) => column.columnName).toSet();
if (!_foreignKeyProjector.needsProjection(tableName, columnNames)) {
return (rows: rows, projectionUnchanged: true);
return (
rows: rows,
projectionUnchanged: true,
projectionRows: const <MergeRowKey>{},
);
}
if (await _foreignKeyProjector.canLeaveLocalProjectionUnchanged(
@ -597,7 +623,11 @@ class CrdtMutationRecorder {
inserting: false,
columns: columns,
)) {
return (rows: rows, projectionUnchanged: true);
return (
rows: rows,
projectionUnchanged: true,
projectionRows: const <MergeRowKey>{},
);
}
final authored = {
@ -646,6 +676,7 @@ class CrdtMutationRecorder {
);
return (
projectionUnchanged: false,
projectionRows: planned.domain.keys.toSet(),
rows: [
for (final row in rows)
_withPlannedDomainValues(
@ -835,6 +866,8 @@ class CrdtMutationRecorder {
CrdtDataDeletedReason.userReinsert,
transaction,
);
// Hidden rows already released their old unique claims; seed projection
// from the restored rows to resolve their new values.
await _maybeProject(tableName, rowIds, null, transaction);
// Only projected fields need a metadata row to hold their authored value.
// All other fields now inherit the insertion timestamp implicitly.
@ -849,6 +882,52 @@ class CrdtMutationRecorder {
});
}
/// Captures the old projection closure before a set-based physical update.
///
/// Updating a winning unique tuple can disconnect its released claimants.
/// Select targets while the write transaction holds the node lock. The
/// caller must update these identities instead of evaluating a limited
/// predicate again, which could select different rows without a total order.
Future<({Set<UuidValue> rowIds, Set<MergeRowKey> projectionRows})?>
planLocalUpdateWhere<T extends TableRow>(
List<Column> columns,
Expression where,
Transaction transaction, {
int? limit,
int? offset,
Column? orderBy,
List<Column>? orderByList,
}) async {
final table = _session.db.serializationManager.getTableForType(T)!;
if (!_foreignKeyProjector.needsProjection(
table.tableName,
columns.map((column) => column.columnName).toSet(),
)) {
return null;
}
final rows = await _session.db.find<T>(
where: where,
limit: limit,
offset: offset,
orderBy: orderBy,
orderByList: orderByList,
transaction: transaction,
);
final rowIds = {for (final row in rows) row.id as UuidValue};
if (rowIds.isEmpty) {
return (rowIds: rowIds, projectionRows: const <MergeRowKey>{});
}
final plan = await _foreignKeyProjector.project(
transaction,
seedTables: {table.tableName},
seedRows: {for (final rowId in rowIds) (table.tableName, rowId)},
materialize: false,
);
return (rowIds: rowIds, projectionRows: plan.domain.keys.toSet());
}
/// Records CRDT field metadata for updated rows.
///
/// [authoredColumnValues] marks a write that reached the domain without a
@ -863,6 +942,7 @@ class CrdtMutationRecorder {
Transaction transaction, {
bool projectionUnchanged = false,
bool authoredColumnValues = false,
Set<MergeRowKey> projectionRows = const {},
Map<MergeRowKey, Map<String, Object?>> domainBeforeUpsert = const {},
List<TableRow> upsertRows = const [],
}) async {
@ -957,6 +1037,7 @@ class CrdtMutationRecorder {
updatedColumnNames,
transaction,
authoredOverlays: authoredOverlays,
projectionRows: {...projectionRows, ...domainBeforeUpsert.keys},
);
});
}
@ -967,12 +1048,15 @@ class CrdtMutationRecorder {
Set<String>? columnNames,
Transaction transaction, {
Map<MergeFieldKey, Object?> authoredOverlays = const {},
Set<MergeRowKey> projectionRows = const {},
}) async {
if (_foreignKeyProjector.needsProjection(tableName, columnNames)) {
await _foreignKeyProjector.project(
transaction,
seedTables: {tableName},
seedRows: {for (final rowId in rowIds) (tableName, rowId)},
// A physical local write can remove the old tuple from the seed row.
// Retain its pre-write closure so released claimants are reconsidered.
seedRows: {...projectionRows, for (final rowId in rowIds) (tableName, rowId)},
authoredOverlays: authoredOverlays,
);
}

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'package:clock/clock.dart';
import 'package:meta/meta.dart';
import 'package:serverpod_database/serverpod_database.dart';
import 'package:uuid/uuid.dart';
@ -192,18 +193,27 @@ class OfflineSyncEngine {
include: OfflineSyncSpaceNode.include(node: CrdtNode.include()),
);
final checkpoints = <UuidValue, Hlc>{
// Cover committed logical ticks as well as wall time. A clock that has
// not advanced (or moved backward) must not make peers echo our facts.
localNodeId: Hlc.now(localNodeId).maxBetween(space.currentNode!.lastHlc),
};
for (final spaceNode in spaceNodes) {
final nodeId = spaceNode.node!.uuidNodeId;
final received = spaceNode.lastReceivedHlc;
// Older inbound bookkeeping could store a relayed author's clock under
// its direct peer. It proves no per-author progress for that peer.
final checkpoint = received?.nodeId == nodeId ? received! : Hlc.zero(nodeId);
checkpoints[nodeId] = checkpoint.maxBetween(checkpoints[nodeId]);
}
return OfflineSyncSinceHlc(
uuidSpaceId: spaceId,
nodeCheckpoints: [
// The local node is always included to avoid collecting its own changes.
Hlc.now(localNodeId),
for (final spaceNode in spaceNodes)
spaceNode.lastReceivedHlc ?? Hlc.zero(spaceNode.node!.uuidNodeId),
],
nodeCheckpoints: checkpoints.values.toList(),
);
}
/// Merges a remote [mergeSet] and records the sync checkpoint for [otherNodeId].
/// Merges a remote [mergeSet] and atomically records its per-author progress.
///
/// Inbound merge applies each remote change, then materializes foreign-key
/// projection into domain tables via [OfflineSyncDatabase.mergeChanges].
@ -213,23 +223,16 @@ class OfflineSyncEngine {
///
/// Returns the greatest HLC synced in the batch, or `null` if the batch is
/// empty.
Future<Hlc?> _mergeInboundBatch(
@visibleForTesting
Future<Hlc?> mergeInboundBatch(
DatabaseSession session, {
required UuidValue spaceId,
required UuidValue otherNodeId,
required CrdtMergeSet mergeSet,
}) async {
if (mergeSet.isEmpty) return null;
final maxSyncedHlc = mergeSet.maxHlc;
final offlineSyncDb = _openOfflineSyncDatabase(session);
await offlineSyncDb.mergeChanges(mergeSet, spaceId: spaceId);
if (maxSyncedHlc != null) {
await offlineSyncDb.recordSyncCheckpoint(
otherNodeId,
maxSyncedHlc,
userId: spaceId,
);
}
return maxSyncedHlc;
}
@ -434,10 +437,9 @@ class OfflineSyncEngine {
changes: entry.value,
);
}
final receivedHlc = await _mergeInboundBatch(
final receivedHlc = await mergeInboundBatch(
session,
spaceId: spaceId,
otherNodeId: spaces.peerNodeId,
mergeSet: entry.value,
);
await _reportMerge(onMergeSuccess, spaces, spaceId, receivedHlc);

View file

@ -197,11 +197,14 @@ class OfflineSyncSpaceState {
/// Records the peer's resume vector for [spaceId] from its [sinceHlc],
/// completing the space's handshake from this peer's side.
void recordPeerHandshake(UuidValue spaceId, OfflineSyncSinceHlc sinceHlc) {
_checkpointsBySpace[spaceId] = {
for (final checkpoint in sinceHlc.nodeCheckpoints) checkpoint.nodeId: checkpoint,
};
_checkpointsBySpace[spaceId] = normalizeCheckpoints(sinceHlc.nodeCheckpoints);
}
/// Normalizes a wire resume vector exactly as a peer handshake does.
static Map<UuidValue, Hlc> normalizeCheckpoints(Iterable<Hlc> checkpoints) => {
for (final checkpoint in checkpoints) checkpoint.nodeId: checkpoint,
};
/// Checkpoints for writable spaces whose handshake completed both ways, keyed
/// by space — the input to a pending-change collection pass.
Map<UuidValue, List<Hlc>> get sendableCheckpoints => {