Sync: mirror a pulled page in one write and read the outbox once per page, not twice per change

This commit is contained in:
jaap-jan committed 2026-10-02 14:04:36 +02:00
1 parent f997e87c5f
commit 873405e591
4 files changed
+173 -13

No files matched your search

+57 -1
View File
@@ -154,9 +154,10 @@ public sealed class SyncEngine
continue;
}
await ApplyPageAsync(vaultId, response.Changes, report, cancellationToken).ConfigureAwait(false);
foreach (var change in response.Changes)
{
await ApplyAsync(vaultId, change, report, cancellationToken).ConfigureAwait(false);
report.Pulled++;
// Counted apart for the same reason the pushed ones are: a machine reads back the log
@@ -310,6 +311,61 @@ public sealed class SyncEngine
private static string PulledTypes { get; } =
string.Join(',', ItemKinds.SyncedTypes.Select(type => (int)type).Order());
/// <summary>Applies one page of pulled changes, in order, with as few writes as that allows.</summary>
/// <remarks>
/// <para>
/// Each change used to cost a write to mirror it and a read to look for a local edit of the same item —
/// two or more SQLite transactions apiece, which on a first sync of a large keychain was most of the time
/// the sync took. The outbox is read once per page instead, and every run of changes with no local edit
/// waiting is mirrored in a single write.
/// </para>
/// <para>
/// ◆ A change that does have an edit waiting goes through <see cref="ApplyAsync"/> exactly as before, with
/// the run in front of it written first: the reconciler reads the mirror and the outbox as they stand at
/// that change, and both are what the one-by-one loop would have left them as. The one-read snapshot only
/// decides which path a change takes; the path that matters re-reads the outbox for itself.
/// </para>
/// </remarks>
private async Task ApplyPageAsync(
Guid vaultId,
IReadOnlyList<SyncChange> changes,
SyncReportBuilder report,
CancellationToken cancellationToken)
{
if (changes.Count == 0)
{
return;
}
var waiting = (await outbox.ListAllAsync(vaultId, cancellationToken).ConfigureAwait(false))
.Select(pending => (pending.EntityType, pending.EntityId))
.ToHashSet();
var run = new List<SyncChange>();
foreach (var change in changes)
{
if (!reconcilers.ContainsKey(change.EntityType))
{
// See ApplyAsync: a type this build cannot sync is neither mirrored nor reconciled.
continue;
}
if (!waiting.Contains((change.EntityType, change.EntityId)))
{
run.Add(change);
continue;
}
await ItemMirror.WriteManyAsync(items, vaultId, run, cancellationToken).ConfigureAwait(false);
run.Clear();
await ApplyAsync(vaultId, change, report, cancellationToken).ConfigureAwait(false);
}
await ItemMirror.WriteManyAsync(items, vaultId, run, cancellationToken).ConfigureAwait(false);
}
private async Task ApplyAsync(
Guid vaultId,
SyncChange change,