fix: Correct checkpoint progress and add delta DST coverage (#158)
This commit is contained in:
parent
e42075cdd9
commit
f59a90bc97
5 changed files with 1829 additions and 163 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue