perf: Send the changes a peer lacks as they are read, on the server
Collecting pending changes ran in one repeatable-read transaction and handed nothing out until every change was read. A device's first sync of a large space thus heard nothing from the server until the end: on a home server, a library of 56,621 rows took about 100 s, so the client's idle timeout (40 s, its pings unanswered meanwhile) closed the stream first, and every attempt started over. Reading the rows in batches made that shorter, not bounded. An engine may now hand each change out as soon as it is read (streamCollectedChanges), the collection's transaction staying open until the last is out or the listener cancels; cancelling ends the collection and its transaction, and an error, an integrity violation among them, reaches the listener once the transaction rolled back, recorded as before. The server's initializeOfflineSync switches it on: PostgreSQL's snapshot only reads. A device's engine keeps it off, since Serverpod's SQLite transactions are write transactions and would hold off the device's own writes for as long as the peer takes to receive. A receiver merges a batch only once its end arrives, so a stream broken halfway still merges nothing. The new tests collect 600 inserts and 300 renames for a peer behind: as read, the first change arrives before the renamed rows are read; whole, once all are; both merge into the author's rows. On embedded PostgreSQL, a row written by another session during the streamed collection stays out of it, the changes are a whole collection's, in order, and a listener stopping after the first lets the next collection run. The orphan-insert violation test runs both ways. Every PostgreSQL test file passes alone; run together, the files bind the same port, the two already there as well. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
741e334d6a
commit
6fdd926cf0
1 changed files with 498 additions and 62 deletions
|
|
@ -62,6 +62,16 @@ class OfflineSyncEngine {
|
|||
|
||||
/// Delay between continuous sync rounds.
|
||||
this._continuousSyncInterval = defaultContinuousSyncInterval,
|
||||
|
||||
/// Whether [collectPendingChanges] hands out each change as soon as it is
|
||||
/// read, its transaction open until the last, rather than once all of
|
||||
/// them are. A large collection then starts sending at once, where
|
||||
/// whole it sent nothing until its end, so a peer waiting longer than its
|
||||
/// idle timeout closed the stream. Meant for PostgreSQL, whose
|
||||
/// repeatable-read snapshot only reads: Serverpod's SQLite transactions
|
||||
/// are write transactions, which would hold off the device's own writes
|
||||
/// for as long as the peer takes to receive.
|
||||
this._streamCollectedChanges = false,
|
||||
}) : _syncTables = syncTables,
|
||||
_serializationManager = serializationManager,
|
||||
_databaseContext =
|
||||
|
|
@ -87,6 +97,7 @@ class OfflineSyncEngine {
|
|||
final OfflineSyncDatabaseContext _databaseContext;
|
||||
final int _syncBatchSize;
|
||||
final Duration _continuousSyncInterval;
|
||||
final bool _streamCollectedChanges;
|
||||
|
||||
/// Wraps [database] in a CRDT-aware database using this sync context.
|
||||
OfflineSyncDatabase wrapDatabase(Database database, {UuidValue? persistentUserId}) {
|
||||
|
|
@ -186,6 +197,16 @@ class OfflineSyncEngine {
|
|||
};
|
||||
|
||||
try {
|
||||
if (_streamCollectedChanges) {
|
||||
await for (final change in _collectedAsRead(
|
||||
session,
|
||||
spaceUuidById,
|
||||
checkpointsBySpaceId,
|
||||
)) {
|
||||
yield change;
|
||||
}
|
||||
return;
|
||||
}
|
||||
final changes = await session.db.transaction(
|
||||
(transaction) => _streamPendingChanges(
|
||||
session,
|
||||
|
|
@ -193,9 +214,7 @@ class OfflineSyncEngine {
|
|||
checkpointsBySpaceId,
|
||||
transaction,
|
||||
).toList(),
|
||||
settings: const TransactionSettings(
|
||||
isolationLevel: IsolationLevel.repeatableRead,
|
||||
),
|
||||
settings: _collectionSettings,
|
||||
);
|
||||
|
||||
for (final change in changes) {
|
||||
|
|
@ -206,6 +225,54 @@ class OfflineSyncEngine {
|
|||
}
|
||||
}
|
||||
|
||||
static const _collectionSettings = TransactionSettings(
|
||||
isolationLevel: IsolationLevel.repeatableRead,
|
||||
);
|
||||
|
||||
/// The pending changes, each handed out as it is read within the
|
||||
/// collection's one transaction, which stays open until the last is out or
|
||||
/// the listener cancels; an error, a violation among them, ends the stream
|
||||
/// once the transaction has rolled back.
|
||||
Stream<CrdtMergeChange> _collectedAsRead(
|
||||
DatabaseSession session,
|
||||
Map<int, UuidValue> spaceUuidById,
|
||||
Map<int, List<Hlc>> checkpointsBySpaceId,
|
||||
) {
|
||||
var cancelled = false;
|
||||
late final StreamController<CrdtMergeChange> changes;
|
||||
changes = StreamController<CrdtMergeChange>(
|
||||
onListen: () {
|
||||
unawaited(
|
||||
session.db
|
||||
.transaction(
|
||||
(transaction) async {
|
||||
await for (final change in _streamPendingChanges(
|
||||
session,
|
||||
spaceUuidById,
|
||||
checkpointsBySpaceId,
|
||||
transaction,
|
||||
)) {
|
||||
// Nobody reads on: end the collection and its transaction.
|
||||
if (cancelled) return;
|
||||
changes.add(change);
|
||||
}
|
||||
},
|
||||
settings: _collectionSettings,
|
||||
)
|
||||
.then(
|
||||
(_) => changes.close(),
|
||||
onError: (Object error, StackTrace stackTrace) {
|
||||
if (!cancelled) changes.addError(error, stackTrace);
|
||||
return changes.close();
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
onCancel: () => cancelled = true,
|
||||
);
|
||||
return changes.stream;
|
||||
}
|
||||
|
||||
/// Creates the [OfflineSyncSinceHlc] checkpoint for a space handshake.
|
||||
///
|
||||
/// [OfflineSyncSinceHlc.nodeCheckpoints] reflects the latest change this node
|
||||
|
|
|
|||
Loading…
Reference in a new issue