From 226d8ba55801d728e3af191b9f6ce1a02f505ba3 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 10:45:53 +0000 Subject: [PATCH 1/5] Implement progress tracking for adding and syncing changes - Added HarmonyProgress record struct to SIL.Harmony.Core. - Updated ISyncable interface and DataModel to support IProgress. - Implemented progress reporting in SnapshotWorker by change count. - Added ProgressTests to verify the implementation. Co-authored-by: hahn-kev <4575355+hahn-kev@users.noreply.github.com> --- src/SIL.Harmony.Core/HarmonyProgress.cs | 3 ++ src/SIL.Harmony.Tests/ProgressTests.cs | 52 +++++++++++++++++++++++++ src/SIL.Harmony/DataModel.cs | 21 +++++----- src/SIL.Harmony/ISyncable.cs | 12 +++--- src/SIL.Harmony/SnapshotWorker.cs | 18 +++++++-- src/SIL.Harmony/SyncHelper.cs | 9 +++-- 6 files changed, 91 insertions(+), 24 deletions(-) create mode 100644 src/SIL.Harmony.Core/HarmonyProgress.cs create mode 100644 src/SIL.Harmony.Tests/ProgressTests.cs diff --git a/src/SIL.Harmony.Core/HarmonyProgress.cs b/src/SIL.Harmony.Core/HarmonyProgress.cs new file mode 100644 index 0000000..8cf4bb2 --- /dev/null +++ b/src/SIL.Harmony.Core/HarmonyProgress.cs @@ -0,0 +1,3 @@ +namespace SIL.Harmony.Core; + +public record struct HarmonyProgress(int Current, int Total, string Status); diff --git a/src/SIL.Harmony.Tests/ProgressTests.cs b/src/SIL.Harmony.Tests/ProgressTests.cs new file mode 100644 index 0000000..ac911a5 --- /dev/null +++ b/src/SIL.Harmony.Tests/ProgressTests.cs @@ -0,0 +1,52 @@ +using System.Collections.Concurrent; +using SIL.Harmony.Changes; +using SIL.Harmony.Sample; +using SIL.Harmony.Sample.Changes; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace SIL.Harmony.Tests; + +public class ProgressTests : DataModelTestBase +{ + [Fact] + public async Task AddManyChanges_ReportsProgress() + { + var clientId = Guid.NewGuid(); + var changes = Enumerable.Range(0, 10).Select(i => new SetTagChange(Guid.NewGuid(), $"Tag {i}")).ToArray(); + var progressReports = new ConcurrentQueue(); + var progress = new Progress(p => progressReports.Enqueue(p)); + + await DataModel.AddManyChanges(clientId, changes, () => new CommitMetadata(), 2, progress); + + // Wait a bit for Progress to report (it's often asynchronous) + await Task.Delay(100); + + Assert.NotEmpty(progressReports); + Assert.Contains(progressReports, p => p.Current == 10 && p.Total == 10); + Assert.All(progressReports, p => Assert.NotEmpty(p.Status)); + Assert.All(progressReports, p => Assert.True(p.Current <= p.Total)); + } + + [Fact] + public async Task SyncWith_ReportsProgress() + { + var model1 = DataModel; + var testBase2 = new DataModelTestBase(); + var model2 = testBase2.DataModel; + + var clientId = Guid.NewGuid(); + await model2.AddChanges(clientId, [new SetTagChange(Guid.NewGuid(), "Tag 1"), new SetTagChange(Guid.NewGuid(), "Tag 2")]); + + var progressReports = new ConcurrentQueue(); + var progress = new Progress(p => progressReports.Enqueue(p)); + + await model1.SyncWith(model2, progress); + + await Task.Delay(100); + + Assert.NotEmpty(progressReports); + // model2 has 2 changes, model1 should report progress for those 2 changes + Assert.Contains(progressReports, p => p.Current == 2 && p.Total == 2); + } +} diff --git a/src/SIL.Harmony/DataModel.cs b/src/SIL.Harmony/DataModel.cs index e120e1c..2830af6 100644 --- a/src/SIL.Harmony/DataModel.cs +++ b/src/SIL.Harmony/DataModel.cs @@ -69,7 +69,8 @@ public async Task AddChange( public async Task AddManyChanges(Guid clientId, IEnumerable changes, Func commitMetadata, - int changesPerCommitMax = 100) + int changesPerCommitMax = 100, + IProgress? progress = null) { await using var repo = await _crdtRepositoryFactory.CreateRepository(); var commits = changes @@ -82,7 +83,7 @@ public async Task AddManyChanges(Guid clientId, await using var transaction = await repo.BeginTransactionAsync(); var updatedCommits = await repo.AddCommits(commits); - await UpdateSnapshots(repo, updatedCommits); + await UpdateSnapshots(repo, updatedCommits, progress); await ValidateCommits(repo); await transaction.CommitAsync(); } @@ -141,7 +142,7 @@ private static ChangeEntity ToChangeEntity(IChange change, int index) }; } - async Task ISyncable.AddRangeFromSync(IEnumerable commits) + async Task ISyncable.AddRangeFromSync(IEnumerable commits, IProgress? progress) { commits = commits.ToArray(); try @@ -156,7 +157,7 @@ async Task ISyncable.AddRangeFromSync(IEnumerable commits) await using var transaction = await repo.BeginTransactionAsync(); var updatedCommits = await repo.AddCommits(newCommits); - await UpdateSnapshots(repo, updatedCommits); + await UpdateSnapshots(repo, updatedCommits, progress); await ValidateCommits(repo); await transaction.CommitAsync(); } @@ -193,7 +194,7 @@ ValueTask ISyncable.ShouldSync() return ValueTask.FromResult(true); } - private async Task UpdateSnapshots(CrdtRepository repo, SortedSet commitsToApply) + private async Task UpdateSnapshots(CrdtRepository repo, SortedSet commitsToApply, IProgress? progress = null) { if (commitsToApply.Count == 0) return; var oldestAddedCommit = commitsToApply.First(); @@ -213,7 +214,7 @@ private async Task UpdateSnapshots(CrdtRepository repo, SortedSet commit snapshotLookup = []; } - var snapshotWorker = new SnapshotWorker(snapshotLookup, repo, _crdtConfig.Value); + var snapshotWorker = new SnapshotWorker(snapshotLookup, repo, _crdtConfig.Value, progress); await snapshotWorker.UpdateSnapshots(commitsToApply); } @@ -372,13 +373,13 @@ public async Task> GetChanges(SyncState remoteState) return await repo.GetChanges(remoteState); } - public async Task SyncWith(ISyncable remoteModel) + public async Task SyncWith(ISyncable remoteModel, IProgress? progress = null) { - return await SyncHelper.SyncWith(this, remoteModel, _serializerOptions); + return await SyncHelper.SyncWith(this, remoteModel, _serializerOptions, progress); } - public async Task SyncMany(ISyncable[] remotes) + public async Task SyncMany(ISyncable[] remotes, IProgress? progress = null) { - await SyncHelper.SyncMany(this, remotes, _serializerOptions); + await SyncHelper.SyncMany(this, remotes, _serializerOptions, progress); } } diff --git a/src/SIL.Harmony/ISyncable.cs b/src/SIL.Harmony/ISyncable.cs index e97b3ce..414f9f0 100644 --- a/src/SIL.Harmony/ISyncable.cs +++ b/src/SIL.Harmony/ISyncable.cs @@ -2,11 +2,11 @@ namespace SIL.Harmony; public interface ISyncable { - Task AddRangeFromSync(IEnumerable commits); + Task AddRangeFromSync(IEnumerable commits, IProgress? progress = null); Task GetSyncState(); Task> GetChanges(SyncState otherHeads); - Task SyncWith(ISyncable remoteModel); - Task SyncMany(ISyncable[] remotes); + Task SyncWith(ISyncable remoteModel, IProgress? progress = null); + Task SyncMany(ISyncable[] remotes, IProgress? progress = null); ValueTask ShouldSync(); } @@ -14,7 +14,7 @@ public class NullSyncable : ISyncable { public static readonly ISyncable Instance = new NullSyncable(); - public Task AddRangeFromSync(IEnumerable commits) + public Task AddRangeFromSync(IEnumerable commits, IProgress? progress = null) { return Task.CompletedTask; } @@ -29,12 +29,12 @@ public Task> GetChanges(SyncState otherHeads) return Task.FromResult(ChangesResult.Empty); } - public Task SyncWith(ISyncable remoteModel) + public Task SyncWith(ISyncable remoteModel, IProgress? progress = null) { return Task.FromResult(new SyncResults([], [], false)); } - public Task SyncMany(ISyncable[] remotes) + public Task SyncMany(ISyncable[] remotes, IProgress? progress = null) { return Task.CompletedTask; } diff --git a/src/SIL.Harmony/SnapshotWorker.cs b/src/SIL.Harmony/SnapshotWorker.cs index c4fe70f..2914024 100644 --- a/src/SIL.Harmony/SnapshotWorker.cs +++ b/src/SIL.Harmony/SnapshotWorker.cs @@ -16,36 +16,42 @@ internal class SnapshotWorker private readonly Dictionary _pendingSnapshots = []; private readonly Dictionary _rootSnapshots = []; private readonly List _newIntermediateSnapshots = []; + private readonly IProgress? _progress; private SnapshotWorker(Dictionary snapshots, Dictionary snapshotLookup, CrdtRepository crdtRepository, - CrdtConfig crdtConfig) + CrdtConfig crdtConfig, + IProgress? progress = null) { _pendingSnapshots = snapshots; _crdtRepository = crdtRepository; _snapshotLookup = snapshotLookup; _crdtConfig = crdtConfig; + _progress = progress; } internal static async Task> ApplyCommitsToSnapshots( Dictionary snapshots, CrdtRepository crdtRepository, SortedSet commits, - CrdtConfig crdtConfig) + CrdtConfig crdtConfig, + IProgress? progress = null) { //we need to pass in the snapshots because we expect it to be modified, this is intended. //if the constructor makes a copy in the future this will need to be updated - await new SnapshotWorker(snapshots, [], crdtRepository, crdtConfig).ApplyCommitChanges(commits); + await new SnapshotWorker(snapshots, [], crdtRepository, crdtConfig, progress).ApplyCommitChanges(commits); return snapshots; } /// a dictionary of entity id to latest snapshot id /// /// + /// internal SnapshotWorker(Dictionary snapshotLookup, CrdtRepository crdtRepository, - CrdtConfig crdtConfig): this([], snapshotLookup, crdtRepository, crdtConfig) + CrdtConfig crdtConfig, + IProgress? progress = null): this([], snapshotLookup, crdtRepository, crdtConfig, progress) { } @@ -63,11 +69,15 @@ private async ValueTask ApplyCommitChanges(SortedSet commits) { var intermediateSnapshots = new Dictionary(); var commitIndex = 0; + var totalChanges = commits.Sum(c => c.ChangeEntities.Count); + var currentChange = 0; foreach (var commit in commits) { commitIndex++; foreach (var commitChange in commit.ChangeEntities.OrderBy(c => c.Index)) { + currentChange++; + _progress?.Report(new HarmonyProgress(currentChange, totalChanges, $"Applying {commitChange.Change.GetType().Name}")); IObjectBase entity; var prevSnapshot = await GetSnapshot(commitChange.EntityId); var changeContext = new ChangeContext(commit, commitIndex, intermediateSnapshots, this, _crdtConfig); diff --git a/src/SIL.Harmony/SyncHelper.cs b/src/SIL.Harmony/SyncHelper.cs index f9ca2c6..8311ea5 100644 --- a/src/SIL.Harmony/SyncHelper.cs +++ b/src/SIL.Harmony/SyncHelper.cs @@ -23,7 +23,8 @@ public static async Task SyncWithResourceUpload(this DataModel loca /// internal static async Task SyncWith(ISyncable localModel, ISyncable remoteModel, - JsonSerializerOptions serializerOptions) + JsonSerializerOptions serializerOptions, + IProgress? progress = null) { if (!await localModel.ShouldSync() || !await remoteModel.ShouldSync()) return new SyncResults([], [], false); var localSyncState = await localModel.GetSyncState(); @@ -39,13 +40,13 @@ internal static async Task SyncWith(ISyncable localModel, } if (missingFromLocal.Length > 0) - await localModel.AddRangeFromSync(missingFromLocal); + await localModel.AddRangeFromSync(missingFromLocal, progress); if (missingFromRemote.Length > 0) await remoteModel.AddRangeFromSync(missingFromRemote); return new SyncResults(missingFromLocal, missingFromRemote, true); } - internal static async Task SyncMany(ISyncable localModel, ISyncable[] remotes, JsonSerializerOptions serializerOptions) + internal static async Task SyncMany(ISyncable localModel, ISyncable[] remotes, JsonSerializerOptions serializerOptions, IProgress? progress = null) { var localSyncState = await localModel.GetSyncState(); var remoteSyncStates = new SyncState[remotes.Length]; @@ -59,7 +60,7 @@ internal static async Task SyncMany(ISyncable localModel, ISyncable[] remotes, J missingFromLocal = Clone(missingFromLocal, serializerOptions); } remoteSyncStates[i] = remoteSyncState; - await localModel.AddRangeFromSync(missingFromLocal); + await localModel.AddRangeFromSync(missingFromLocal, progress); } // Now the localModel has all the changes from all remotes, so all remotes will get the changes from the localModel as well as all other remotes From e4dced0f29b4709c0d3bf8fab2ac008f2b2c39fa Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 03:03:59 +0000 Subject: [PATCH 2/5] Refactor progress tracking with HarmonyProgressReporter and SyncStage - Introduced SyncStage enum in Core. - Refactored HarmonyProgress to use SyncStage and nullable counts. - Added HarmonyDetailedProgress for rich progress reporting. - Introduced HarmonyProgressReporter wrapper to support multiple IProgress types and avoid unnecessary string allocations. - Updated ISyncable, DataModel, SyncHelper, and SnapshotWorker to use the new reporter. - Added comprehensive tests for simple and detailed progress. Co-authored-by: hahn-kev <4575355+hahn-kev@users.noreply.github.com> --- src/SIL.Harmony.Core/HarmonyProgress.cs | 2 +- src/SIL.Harmony.Core/SyncStage.cs | 8 ++++ src/SIL.Harmony.Tests/ProgressTests.cs | 53 ++++++++++++++++++---- src/SIL.Harmony/DataModel.cs | 10 ++-- src/SIL.Harmony/HarmonyDetailedProgress.cs | 5 ++ src/SIL.Harmony/HarmonyProgressReporter.cs | 32 +++++++++++++ src/SIL.Harmony/ISyncable.cs | 12 ++--- src/SIL.Harmony/SnapshotWorker.cs | 10 ++-- src/SIL.Harmony/SyncHelper.cs | 12 +++-- 9 files changed, 115 insertions(+), 29 deletions(-) create mode 100644 src/SIL.Harmony.Core/SyncStage.cs create mode 100644 src/SIL.Harmony/HarmonyDetailedProgress.cs create mode 100644 src/SIL.Harmony/HarmonyProgressReporter.cs diff --git a/src/SIL.Harmony.Core/HarmonyProgress.cs b/src/SIL.Harmony.Core/HarmonyProgress.cs index 8cf4bb2..63c88d2 100644 --- a/src/SIL.Harmony.Core/HarmonyProgress.cs +++ b/src/SIL.Harmony.Core/HarmonyProgress.cs @@ -1,3 +1,3 @@ namespace SIL.Harmony.Core; -public record struct HarmonyProgress(int Current, int Total, string Status); +public record struct HarmonyProgress(SyncStage Stage, int? Current, int? Total); diff --git a/src/SIL.Harmony.Core/SyncStage.cs b/src/SIL.Harmony.Core/SyncStage.cs new file mode 100644 index 0000000..0696d8f --- /dev/null +++ b/src/SIL.Harmony.Core/SyncStage.cs @@ -0,0 +1,8 @@ +namespace SIL.Harmony.Core; + +public enum SyncStage +{ + FetchingChanges, + ApplyingChanges, + UploadingResources +} diff --git a/src/SIL.Harmony.Tests/ProgressTests.cs b/src/SIL.Harmony.Tests/ProgressTests.cs index ac911a5..5958896 100644 --- a/src/SIL.Harmony.Tests/ProgressTests.cs +++ b/src/SIL.Harmony.Tests/ProgressTests.cs @@ -9,23 +9,55 @@ namespace SIL.Harmony.Tests; public class ProgressTests : DataModelTestBase { + private readonly ITestOutputHelper _output; + + public ProgressTests(ITestOutputHelper output) + { + _output = output; + } + [Fact] public async Task AddManyChanges_ReportsProgress() { var clientId = Guid.NewGuid(); var changes = Enumerable.Range(0, 10).Select(i => new SetTagChange(Guid.NewGuid(), $"Tag {i}")).ToArray(); var progressReports = new ConcurrentQueue(); - var progress = new Progress(p => progressReports.Enqueue(p)); + var progress = new Progress(p => + { + _output.WriteLine($"Progress: {p.Current}/{p.Total} - {p.Stage}"); + progressReports.Enqueue(p); + }); + var reporter = new HarmonyProgressReporter(progress); - await DataModel.AddManyChanges(clientId, changes, () => new CommitMetadata(), 2, progress); + await DataModel.AddManyChanges(clientId, changes, () => new CommitMetadata(), 2, reporter); // Wait a bit for Progress to report (it's often asynchronous) await Task.Delay(100); Assert.NotEmpty(progressReports); - Assert.Contains(progressReports, p => p.Current == 10 && p.Total == 10); + Assert.Contains(progressReports, p => p.Current == 10 && p.Total == 10 && p.Stage == SyncStage.ApplyingChanges); + } + + [Fact] + public async Task AddManyChanges_ReportsDetailedProgress() + { + var clientId = Guid.NewGuid(); + var changes = Enumerable.Range(0, 10).Select(i => new SetTagChange(Guid.NewGuid(), $"Tag {i}")).ToArray(); + var progressReports = new ConcurrentQueue(); + var progress = new Progress(p => + { + _output.WriteLine($"Detailed Progress: {p.Current}/{p.Total} - {p.Status} @ {p.DateTime}"); + progressReports.Enqueue(p); + }); + var reporter = new HarmonyProgressReporter(progress); + + await DataModel.AddManyChanges(clientId, changes, () => new CommitMetadata(), 2, reporter); + + await Task.Delay(100); + + Assert.NotEmpty(progressReports); + Assert.Contains(progressReports, p => p.Current == 10 && p.Total == 10 && p.Change is SetTagChange); Assert.All(progressReports, p => Assert.NotEmpty(p.Status)); - Assert.All(progressReports, p => Assert.True(p.Current <= p.Total)); } [Fact] @@ -39,14 +71,19 @@ public async Task SyncWith_ReportsProgress() await model2.AddChanges(clientId, [new SetTagChange(Guid.NewGuid(), "Tag 1"), new SetTagChange(Guid.NewGuid(), "Tag 2")]); var progressReports = new ConcurrentQueue(); - var progress = new Progress(p => progressReports.Enqueue(p)); + var progress = new Progress(p => + { + _output.WriteLine($"Sync Progress: {p.Current}/{p.Total} - {p.Stage}"); + progressReports.Enqueue(p); + }); + var reporter = new HarmonyProgressReporter(progress); - await model1.SyncWith(model2, progress); + await model1.SyncWith(model2, reporter); await Task.Delay(100); Assert.NotEmpty(progressReports); - // model2 has 2 changes, model1 should report progress for those 2 changes - Assert.Contains(progressReports, p => p.Current == 2 && p.Total == 2); + Assert.Contains(progressReports, p => p.Stage == SyncStage.FetchingChanges); + Assert.Contains(progressReports, p => p.Current == 2 && p.Total == 2 && p.Stage == SyncStage.ApplyingChanges); } } diff --git a/src/SIL.Harmony/DataModel.cs b/src/SIL.Harmony/DataModel.cs index 2830af6..e1fe2df 100644 --- a/src/SIL.Harmony/DataModel.cs +++ b/src/SIL.Harmony/DataModel.cs @@ -70,7 +70,7 @@ public async Task AddManyChanges(Guid clientId, IEnumerable changes, Func commitMetadata, int changesPerCommitMax = 100, - IProgress? progress = null) + HarmonyProgressReporter? progress = null) { await using var repo = await _crdtRepositoryFactory.CreateRepository(); var commits = changes @@ -142,7 +142,7 @@ private static ChangeEntity ToChangeEntity(IChange change, int index) }; } - async Task ISyncable.AddRangeFromSync(IEnumerable commits, IProgress? progress) + async Task ISyncable.AddRangeFromSync(IEnumerable commits, HarmonyProgressReporter? progress) { commits = commits.ToArray(); try @@ -194,7 +194,7 @@ ValueTask ISyncable.ShouldSync() return ValueTask.FromResult(true); } - private async Task UpdateSnapshots(CrdtRepository repo, SortedSet commitsToApply, IProgress? progress = null) + private async Task UpdateSnapshots(CrdtRepository repo, SortedSet commitsToApply, HarmonyProgressReporter? progress = null) { if (commitsToApply.Count == 0) return; var oldestAddedCommit = commitsToApply.First(); @@ -373,12 +373,12 @@ public async Task> GetChanges(SyncState remoteState) return await repo.GetChanges(remoteState); } - public async Task SyncWith(ISyncable remoteModel, IProgress? progress = null) + public async Task SyncWith(ISyncable remoteModel, HarmonyProgressReporter? progress = null) { return await SyncHelper.SyncWith(this, remoteModel, _serializerOptions, progress); } - public async Task SyncMany(ISyncable[] remotes, IProgress? progress = null) + public async Task SyncMany(ISyncable[] remotes, HarmonyProgressReporter? progress = null) { await SyncHelper.SyncMany(this, remotes, _serializerOptions, progress); } diff --git a/src/SIL.Harmony/HarmonyDetailedProgress.cs b/src/SIL.Harmony/HarmonyDetailedProgress.cs new file mode 100644 index 0000000..cd9a72b --- /dev/null +++ b/src/SIL.Harmony/HarmonyDetailedProgress.cs @@ -0,0 +1,5 @@ +using SIL.Harmony.Changes; + +namespace SIL.Harmony; + +public record struct HarmonyDetailedProgress(SyncStage Stage, int? Current, int? Total, IChange? Change, string Status, DateTimeOffset DateTime); diff --git a/src/SIL.Harmony/HarmonyProgressReporter.cs b/src/SIL.Harmony/HarmonyProgressReporter.cs new file mode 100644 index 0000000..3e52c27 --- /dev/null +++ b/src/SIL.Harmony/HarmonyProgressReporter.cs @@ -0,0 +1,32 @@ +using SIL.Harmony.Changes; + +namespace SIL.Harmony; + +public class HarmonyProgressReporter +{ + private readonly IProgress? _progress; + private readonly IProgress? _detailedProgress; + + public HarmonyProgressReporter(IProgress progress) + { + _progress = progress; + } + + public HarmonyProgressReporter(IProgress detailedProgress) + { + _detailedProgress = detailedProgress; + } + + public void Report(SyncStage stage, int? current = null, int? total = null, IChange? change = null) + { + if (_progress is not null) + { + _progress.Report(new HarmonyProgress(stage, current, total)); + } + else if (_detailedProgress is not null) + { + var status = change != null ? $"Applying {change.GetType().Name}" : stage.ToString(); + _detailedProgress.Report(new HarmonyDetailedProgress(stage, current, total, change, status, DateTimeOffset.Now)); + } + } +} diff --git a/src/SIL.Harmony/ISyncable.cs b/src/SIL.Harmony/ISyncable.cs index 414f9f0..96f0460 100644 --- a/src/SIL.Harmony/ISyncable.cs +++ b/src/SIL.Harmony/ISyncable.cs @@ -2,11 +2,11 @@ namespace SIL.Harmony; public interface ISyncable { - Task AddRangeFromSync(IEnumerable commits, IProgress? progress = null); + Task AddRangeFromSync(IEnumerable commits, HarmonyProgressReporter? progress = null); Task GetSyncState(); Task> GetChanges(SyncState otherHeads); - Task SyncWith(ISyncable remoteModel, IProgress? progress = null); - Task SyncMany(ISyncable[] remotes, IProgress? progress = null); + Task SyncWith(ISyncable remoteModel, HarmonyProgressReporter? progress = null); + Task SyncMany(ISyncable[] remotes, HarmonyProgressReporter? progress = null); ValueTask ShouldSync(); } @@ -14,7 +14,7 @@ public class NullSyncable : ISyncable { public static readonly ISyncable Instance = new NullSyncable(); - public Task AddRangeFromSync(IEnumerable commits, IProgress? progress = null) + public Task AddRangeFromSync(IEnumerable commits, HarmonyProgressReporter? progress = null) { return Task.CompletedTask; } @@ -29,12 +29,12 @@ public Task> GetChanges(SyncState otherHeads) return Task.FromResult(ChangesResult.Empty); } - public Task SyncWith(ISyncable remoteModel, IProgress? progress = null) + public Task SyncWith(ISyncable remoteModel, HarmonyProgressReporter? progress = null) { return Task.FromResult(new SyncResults([], [], false)); } - public Task SyncMany(ISyncable[] remotes, IProgress? progress = null) + public Task SyncMany(ISyncable[] remotes, HarmonyProgressReporter? progress = null) { return Task.CompletedTask; } diff --git a/src/SIL.Harmony/SnapshotWorker.cs b/src/SIL.Harmony/SnapshotWorker.cs index 2914024..8107f6a 100644 --- a/src/SIL.Harmony/SnapshotWorker.cs +++ b/src/SIL.Harmony/SnapshotWorker.cs @@ -16,13 +16,13 @@ internal class SnapshotWorker private readonly Dictionary _pendingSnapshots = []; private readonly Dictionary _rootSnapshots = []; private readonly List _newIntermediateSnapshots = []; - private readonly IProgress? _progress; + private readonly HarmonyProgressReporter? _progress; private SnapshotWorker(Dictionary snapshots, Dictionary snapshotLookup, CrdtRepository crdtRepository, CrdtConfig crdtConfig, - IProgress? progress = null) + HarmonyProgressReporter? progress = null) { _pendingSnapshots = snapshots; _crdtRepository = crdtRepository; @@ -36,7 +36,7 @@ internal static async Task> ApplyCommitsToSnaps CrdtRepository crdtRepository, SortedSet commits, CrdtConfig crdtConfig, - IProgress? progress = null) + HarmonyProgressReporter? progress = null) { //we need to pass in the snapshots because we expect it to be modified, this is intended. //if the constructor makes a copy in the future this will need to be updated @@ -51,7 +51,7 @@ internal static async Task> ApplyCommitsToSnaps internal SnapshotWorker(Dictionary snapshotLookup, CrdtRepository crdtRepository, CrdtConfig crdtConfig, - IProgress? progress = null): this([], snapshotLookup, crdtRepository, crdtConfig, progress) + HarmonyProgressReporter? progress = null): this([], snapshotLookup, crdtRepository, crdtConfig, progress) { } @@ -77,7 +77,7 @@ private async ValueTask ApplyCommitChanges(SortedSet commits) foreach (var commitChange in commit.ChangeEntities.OrderBy(c => c.Index)) { currentChange++; - _progress?.Report(new HarmonyProgress(currentChange, totalChanges, $"Applying {commitChange.Change.GetType().Name}")); + _progress?.Report(SyncStage.ApplyingChanges, currentChange, totalChanges, commitChange.Change); IObjectBase entity; var prevSnapshot = await GetSnapshot(commitChange.EntityId); var changeContext = new ChangeContext(commit, commitIndex, intermediateSnapshots, this, _crdtConfig); diff --git a/src/SIL.Harmony/SyncHelper.cs b/src/SIL.Harmony/SyncHelper.cs index 8311ea5..c578e0a 100644 --- a/src/SIL.Harmony/SyncHelper.cs +++ b/src/SIL.Harmony/SyncHelper.cs @@ -8,10 +8,12 @@ public static async Task SyncWithResourceUpload(this DataModel loca ISyncable remoteModel, ResourceService resourceService, IRemoteResourceService remoteResourceService, - Guid localClientId) + Guid localClientId, + HarmonyProgressReporter? progress = null) { + progress?.Report(SyncStage.UploadingResources); await resourceService.UploadPendingResources(localClientId, remoteResourceService); - return await localModel.SyncWith(remoteModel); + return await localModel.SyncWith(remoteModel, progress); } /// @@ -24,9 +26,10 @@ public static async Task SyncWithResourceUpload(this DataModel loca internal static async Task SyncWith(ISyncable localModel, ISyncable remoteModel, JsonSerializerOptions serializerOptions, - IProgress? progress = null) + HarmonyProgressReporter? progress = null) { if (!await localModel.ShouldSync() || !await remoteModel.ShouldSync()) return new SyncResults([], [], false); + progress?.Report(SyncStage.FetchingChanges); var localSyncState = await localModel.GetSyncState(); var (missingFromLocal, remoteSyncState) = await remoteModel.GetChanges(localSyncState); @@ -46,8 +49,9 @@ internal static async Task SyncWith(ISyncable localModel, return new SyncResults(missingFromLocal, missingFromRemote, true); } - internal static async Task SyncMany(ISyncable localModel, ISyncable[] remotes, JsonSerializerOptions serializerOptions, IProgress? progress = null) + internal static async Task SyncMany(ISyncable localModel, ISyncable[] remotes, JsonSerializerOptions serializerOptions, HarmonyProgressReporter? progress = null) { + progress?.Report(SyncStage.FetchingChanges); var localSyncState = await localModel.GetSyncState(); var remoteSyncStates = new SyncState[remotes.Length]; for (var i = 0; i < remotes.Length; i++) From 2050eceb52cb8919ecd9fbf3dffa4a1802a79470 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 04:25:08 +0000 Subject: [PATCH 3/5] Refine progress tracking with specialized reporter methods - Added Finished stages to SyncStage enum. - Refactored HarmonyProgressReporter to use named methods (e.g., ReportStartApplyingChanges, ReportApplyingChange). - Implemented internal status string generation to avoid unnecessary allocations. - Updated SnapshotWorker and SyncHelper call sites. - Added and verified tests for the new specialized methods and stages. Co-authored-by: hahn-kev <4575355+hahn-kev@users.noreply.github.com> --- src/SIL.Harmony.Core/SyncStage.cs | 5 ++- src/SIL.Harmony.Tests/ProgressTests.cs | 4 +++ src/SIL.Harmony/HarmonyProgressReporter.cs | 38 ++++++++++++++++++++-- src/SIL.Harmony/SnapshotWorker.cs | 4 ++- src/SIL.Harmony/SyncHelper.cs | 9 +++-- 5 files changed, 53 insertions(+), 7 deletions(-) diff --git a/src/SIL.Harmony.Core/SyncStage.cs b/src/SIL.Harmony.Core/SyncStage.cs index 0696d8f..0667011 100644 --- a/src/SIL.Harmony.Core/SyncStage.cs +++ b/src/SIL.Harmony.Core/SyncStage.cs @@ -3,6 +3,9 @@ namespace SIL.Harmony.Core; public enum SyncStage { FetchingChanges, + FetchingChangesFinished, ApplyingChanges, - UploadingResources + ApplyingChangesFinished, + UploadingResources, + UploadingResourcesFinished } diff --git a/src/SIL.Harmony.Tests/ProgressTests.cs b/src/SIL.Harmony.Tests/ProgressTests.cs index 5958896..4596dfa 100644 --- a/src/SIL.Harmony.Tests/ProgressTests.cs +++ b/src/SIL.Harmony.Tests/ProgressTests.cs @@ -36,6 +36,7 @@ public async Task AddManyChanges_ReportsProgress() Assert.NotEmpty(progressReports); Assert.Contains(progressReports, p => p.Current == 10 && p.Total == 10 && p.Stage == SyncStage.ApplyingChanges); + Assert.Contains(progressReports, p => p.Stage == SyncStage.ApplyingChangesFinished); } [Fact] @@ -57,6 +58,7 @@ public async Task AddManyChanges_ReportsDetailedProgress() Assert.NotEmpty(progressReports); Assert.Contains(progressReports, p => p.Current == 10 && p.Total == 10 && p.Change is SetTagChange); + Assert.Contains(progressReports, p => p.Stage == SyncStage.ApplyingChangesFinished && p.Status == "Finished applying changes."); Assert.All(progressReports, p => Assert.NotEmpty(p.Status)); } @@ -84,6 +86,8 @@ public async Task SyncWith_ReportsProgress() Assert.NotEmpty(progressReports); Assert.Contains(progressReports, p => p.Stage == SyncStage.FetchingChanges); + Assert.Contains(progressReports, p => p.Stage == SyncStage.FetchingChangesFinished); Assert.Contains(progressReports, p => p.Current == 2 && p.Total == 2 && p.Stage == SyncStage.ApplyingChanges); + Assert.Contains(progressReports, p => p.Stage == SyncStage.ApplyingChangesFinished); } } diff --git a/src/SIL.Harmony/HarmonyProgressReporter.cs b/src/SIL.Harmony/HarmonyProgressReporter.cs index 3e52c27..0759514 100644 --- a/src/SIL.Harmony/HarmonyProgressReporter.cs +++ b/src/SIL.Harmony/HarmonyProgressReporter.cs @@ -6,6 +6,7 @@ public class HarmonyProgressReporter { private readonly IProgress? _progress; private readonly IProgress? _detailedProgress; + private int? _totalChanges; public HarmonyProgressReporter(IProgress progress) { @@ -17,7 +18,25 @@ public HarmonyProgressReporter(IProgress detailedProgre _detailedProgress = detailedProgress; } - public void Report(SyncStage stage, int? current = null, int? total = null, IChange? change = null) + public void ReportFetchingChanges() => Report(SyncStage.FetchingChanges); + public void ReportFetchingChangesFinished() => Report(SyncStage.FetchingChangesFinished); + public void ReportUploadingResources() => Report(SyncStage.UploadingResources); + public void ReportUploadingResourcesFinished() => Report(SyncStage.UploadingResourcesFinished); + + public void ReportStartApplyingChanges(int total) + { + _totalChanges = total; + Report(SyncStage.ApplyingChanges, 0, total); + } + + public void ReportApplyingChange(int current, IChange change) + { + Report(SyncStage.ApplyingChanges, current, _totalChanges, change); + } + + public void ReportApplyingChangesFinished() => Report(SyncStage.ApplyingChangesFinished, _totalChanges, _totalChanges); + + private void Report(SyncStage stage, int? current = null, int? total = null, IChange? change = null) { if (_progress is not null) { @@ -25,8 +44,23 @@ public void Report(SyncStage stage, int? current = null, int? total = null, ICha } else if (_detailedProgress is not null) { - var status = change != null ? $"Applying {change.GetType().Name}" : stage.ToString(); + var status = GetStatus(stage, change); _detailedProgress.Report(new HarmonyDetailedProgress(stage, current, total, change, status, DateTimeOffset.Now)); } } + + private static string GetStatus(SyncStage stage, IChange? change) + { + if (change != null) return $"Applying {change.GetType().Name}"; + return stage switch + { + SyncStage.FetchingChanges => "Fetching changes...", + SyncStage.FetchingChangesFinished => "Finished fetching changes.", + SyncStage.ApplyingChanges => "Applying changes...", + SyncStage.ApplyingChangesFinished => "Finished applying changes.", + SyncStage.UploadingResources => "Uploading resources...", + SyncStage.UploadingResourcesFinished => "Finished uploading resources.", + _ => stage.ToString() + }; + } } diff --git a/src/SIL.Harmony/SnapshotWorker.cs b/src/SIL.Harmony/SnapshotWorker.cs index 8107f6a..78f0482 100644 --- a/src/SIL.Harmony/SnapshotWorker.cs +++ b/src/SIL.Harmony/SnapshotWorker.cs @@ -70,6 +70,7 @@ private async ValueTask ApplyCommitChanges(SortedSet commits) var intermediateSnapshots = new Dictionary(); var commitIndex = 0; var totalChanges = commits.Sum(c => c.ChangeEntities.Count); + _progress?.ReportStartApplyingChanges(totalChanges); var currentChange = 0; foreach (var commit in commits) { @@ -77,7 +78,7 @@ private async ValueTask ApplyCommitChanges(SortedSet commits) foreach (var commitChange in commit.ChangeEntities.OrderBy(c => c.Index)) { currentChange++; - _progress?.Report(SyncStage.ApplyingChanges, currentChange, totalChanges, commitChange.Change); + _progress?.ReportApplyingChange(currentChange, commitChange.Change); IObjectBase entity; var prevSnapshot = await GetSnapshot(commitChange.EntityId); var changeContext = new ChangeContext(commit, commitIndex, intermediateSnapshots, this, _crdtConfig); @@ -117,6 +118,7 @@ private async ValueTask ApplyCommitChanges(SortedSet commits) _newIntermediateSnapshots.AddRange(intermediateSnapshots.Values); intermediateSnapshots.Clear(); } + _progress?.ReportApplyingChangesFinished(); } /// diff --git a/src/SIL.Harmony/SyncHelper.cs b/src/SIL.Harmony/SyncHelper.cs index c578e0a..b814ffd 100644 --- a/src/SIL.Harmony/SyncHelper.cs +++ b/src/SIL.Harmony/SyncHelper.cs @@ -11,8 +11,9 @@ public static async Task SyncWithResourceUpload(this DataModel loca Guid localClientId, HarmonyProgressReporter? progress = null) { - progress?.Report(SyncStage.UploadingResources); + progress?.ReportUploadingResources(); await resourceService.UploadPendingResources(localClientId, remoteResourceService); + progress?.ReportUploadingResourcesFinished(); return await localModel.SyncWith(remoteModel, progress); } @@ -29,12 +30,13 @@ internal static async Task SyncWith(ISyncable localModel, HarmonyProgressReporter? progress = null) { if (!await localModel.ShouldSync() || !await remoteModel.ShouldSync()) return new SyncResults([], [], false); - progress?.Report(SyncStage.FetchingChanges); + progress?.ReportFetchingChanges(); var localSyncState = await localModel.GetSyncState(); var (missingFromLocal, remoteSyncState) = await remoteModel.GetChanges(localSyncState); //todo abort if local and remote heads are the same var (missingFromRemote, _) = await localModel.GetChanges(remoteSyncState); + progress?.ReportFetchingChangesFinished(); if (localModel is DataModel && remoteModel is DataModel) { //cloning just to simulate the objects going over the wire @@ -51,7 +53,7 @@ internal static async Task SyncWith(ISyncable localModel, internal static async Task SyncMany(ISyncable localModel, ISyncable[] remotes, JsonSerializerOptions serializerOptions, HarmonyProgressReporter? progress = null) { - progress?.Report(SyncStage.FetchingChanges); + progress?.ReportFetchingChanges(); var localSyncState = await localModel.GetSyncState(); var remoteSyncStates = new SyncState[remotes.Length]; for (var i = 0; i < remotes.Length; i++) @@ -67,6 +69,7 @@ internal static async Task SyncMany(ISyncable localModel, ISyncable[] remotes, J await localModel.AddRangeFromSync(missingFromLocal, progress); } + progress?.ReportFetchingChangesFinished(); // Now the localModel has all the changes from all remotes, so all remotes will get the changes from the localModel as well as all other remotes for (var i = 0; i < remotes.Length; i++) { From b2f28b433f065b658d9e287e9290f04c3583604a Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 05:10:08 +0000 Subject: [PATCH 4/5] Finalize progress tracking with commits-based total and uploading stages - Refactored HarmonyProgressReporter to calculate total changes from IEnumerable. - Added UploadingChanges and UploadingChangesFinished stages to SyncStage enum. - Integrated uploading changes reporting in SyncHelper (SyncWith and SyncMany). - Updated SnapshotWorker to use the refined ReportStartApplyingChanges. - Verified all changes with updated ProgressTests. Co-authored-by: hahn-kev <4575355+hahn-kev@users.noreply.github.com> --- src/SIL.Harmony.Core/SyncStage.cs | 4 +++- src/SIL.Harmony.Tests/ProgressTests.cs | 22 ++++++++++++++++------ src/SIL.Harmony/HarmonyProgressReporter.cs | 14 +++++++++----- src/SIL.Harmony/SnapshotWorker.cs | 3 +-- src/SIL.Harmony/SyncHelper.cs | 11 ++++++++++- 5 files changed, 39 insertions(+), 15 deletions(-) diff --git a/src/SIL.Harmony.Core/SyncStage.cs b/src/SIL.Harmony.Core/SyncStage.cs index 0667011..7d55c03 100644 --- a/src/SIL.Harmony.Core/SyncStage.cs +++ b/src/SIL.Harmony.Core/SyncStage.cs @@ -7,5 +7,7 @@ public enum SyncStage ApplyingChanges, ApplyingChangesFinished, UploadingResources, - UploadingResourcesFinished + UploadingResourcesFinished, + UploadingChanges, + UploadingChangesFinished } diff --git a/src/SIL.Harmony.Tests/ProgressTests.cs b/src/SIL.Harmony.Tests/ProgressTests.cs index 4596dfa..968a77d 100644 --- a/src/SIL.Harmony.Tests/ProgressTests.cs +++ b/src/SIL.Harmony.Tests/ProgressTests.cs @@ -70,24 +70,34 @@ public async Task SyncWith_ReportsProgress() var model2 = testBase2.DataModel; var clientId = Guid.NewGuid(); + // remote changes to be downloaded by model1 await model2.AddChanges(clientId, [new SetTagChange(Guid.NewGuid(), "Tag 1"), new SetTagChange(Guid.NewGuid(), "Tag 2")]); + // local changes to be uploaded to model2 + await model1.AddChanges(clientId, [new SetTagChange(Guid.NewGuid(), "Tag 3")]); - var progressReports = new ConcurrentQueue(); - var progress = new Progress(p => + var progressReports = new ConcurrentQueue(); + var progress = new Progress(p => { - _output.WriteLine($"Sync Progress: {p.Current}/{p.Total} - {p.Stage}"); + _output.WriteLine($"Sync Progress: {p.Current}/{p.Total} - {p.Status} ({p.Stage})"); progressReports.Enqueue(p); }); var reporter = new HarmonyProgressReporter(progress); await model1.SyncWith(model2, reporter); - await Task.Delay(100); + await Task.Delay(500); + + _output.WriteLine($"Reports count: {progressReports.Count}"); + foreach(var report in progressReports) + { + _output.WriteLine($"- {report.Stage}: {report.Current}/{report.Total} {report.Status}"); + } Assert.NotEmpty(progressReports); Assert.Contains(progressReports, p => p.Stage == SyncStage.FetchingChanges); Assert.Contains(progressReports, p => p.Stage == SyncStage.FetchingChangesFinished); - Assert.Contains(progressReports, p => p.Current == 2 && p.Total == 2 && p.Stage == SyncStage.ApplyingChanges); - Assert.Contains(progressReports, p => p.Stage == SyncStage.ApplyingChangesFinished); + // We expect UploadingChanges happens when sending local changes to remote + Assert.Contains(progressReports, p => p.Stage == SyncStage.UploadingChanges && p.Total == 1 && p.Status.Contains("Uploading 1 changes")); + Assert.Contains(progressReports, p => p.Stage == SyncStage.UploadingChangesFinished); } } diff --git a/src/SIL.Harmony/HarmonyProgressReporter.cs b/src/SIL.Harmony/HarmonyProgressReporter.cs index 0759514..7044855 100644 --- a/src/SIL.Harmony/HarmonyProgressReporter.cs +++ b/src/SIL.Harmony/HarmonyProgressReporter.cs @@ -22,11 +22,13 @@ public HarmonyProgressReporter(IProgress detailedProgre public void ReportFetchingChangesFinished() => Report(SyncStage.FetchingChangesFinished); public void ReportUploadingResources() => Report(SyncStage.UploadingResources); public void ReportUploadingResourcesFinished() => Report(SyncStage.UploadingResourcesFinished); + public void ReportUploadingChanges(int? count = null) => Report(SyncStage.UploadingChanges, total: count); + public void ReportUploadingChangesFinished() => Report(SyncStage.UploadingChangesFinished); - public void ReportStartApplyingChanges(int total) + public void ReportStartApplyingChanges(IEnumerable commits) { - _totalChanges = total; - Report(SyncStage.ApplyingChanges, 0, total); + _totalChanges = commits.Sum(c => c.ChangeEntities.Count); + Report(SyncStage.ApplyingChanges, 0, _totalChanges); } public void ReportApplyingChange(int current, IChange change) @@ -44,12 +46,12 @@ private void Report(SyncStage stage, int? current = null, int? total = null, ICh } else if (_detailedProgress is not null) { - var status = GetStatus(stage, change); + var status = GetStatus(stage, change, total); _detailedProgress.Report(new HarmonyDetailedProgress(stage, current, total, change, status, DateTimeOffset.Now)); } } - private static string GetStatus(SyncStage stage, IChange? change) + private static string GetStatus(SyncStage stage, IChange? change, int? total) { if (change != null) return $"Applying {change.GetType().Name}"; return stage switch @@ -60,6 +62,8 @@ private static string GetStatus(SyncStage stage, IChange? change) SyncStage.ApplyingChangesFinished => "Finished applying changes.", SyncStage.UploadingResources => "Uploading resources...", SyncStage.UploadingResourcesFinished => "Finished uploading resources.", + SyncStage.UploadingChanges => total.HasValue ? $"Uploading {total} changes to remote..." : "Uploading changes to remote...", + SyncStage.UploadingChangesFinished => "Finished uploading changes.", _ => stage.ToString() }; } diff --git a/src/SIL.Harmony/SnapshotWorker.cs b/src/SIL.Harmony/SnapshotWorker.cs index 78f0482..20ebf2e 100644 --- a/src/SIL.Harmony/SnapshotWorker.cs +++ b/src/SIL.Harmony/SnapshotWorker.cs @@ -69,8 +69,7 @@ private async ValueTask ApplyCommitChanges(SortedSet commits) { var intermediateSnapshots = new Dictionary(); var commitIndex = 0; - var totalChanges = commits.Sum(c => c.ChangeEntities.Count); - _progress?.ReportStartApplyingChanges(totalChanges); + _progress?.ReportStartApplyingChanges(commits); var currentChange = 0; foreach (var commit in commits) { diff --git a/src/SIL.Harmony/SyncHelper.cs b/src/SIL.Harmony/SyncHelper.cs index b814ffd..0859c25 100644 --- a/src/SIL.Harmony/SyncHelper.cs +++ b/src/SIL.Harmony/SyncHelper.cs @@ -47,7 +47,11 @@ internal static async Task SyncWith(ISyncable localModel, if (missingFromLocal.Length > 0) await localModel.AddRangeFromSync(missingFromLocal, progress); if (missingFromRemote.Length > 0) + { + progress?.ReportUploadingChanges(missingFromRemote.Sum(c => c.ChangeEntities.Count)); await remoteModel.AddRangeFromSync(missingFromRemote); + progress?.ReportUploadingChangesFinished(); + } return new SyncResults(missingFromLocal, missingFromRemote, true); } @@ -81,7 +85,12 @@ internal static async Task SyncMany(ISyncable localModel, ISyncable[] remotes, J //cloning just to simulate the objects going over the wire missingFromRemote = Clone(missingFromRemote, serializerOptions); } - await remote.AddRangeFromSync(missingFromRemote); + if (missingFromRemote.Length > 0) + { + progress?.ReportUploadingChanges(missingFromRemote.Sum(c => c.ChangeEntities.Count)); + await remote.AddRangeFromSync(missingFromRemote); + progress?.ReportUploadingChangesFinished(); + } } } From 03deca92a37d4a5ad17679cf43c6e2b4d9db3d31 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 05:36:09 +0000 Subject: [PATCH 5/5] Optimize ReportStartApplyingChanges and finalize progress tracking - Updated ReportStartApplyingChanges to only calculate total change count if a progress listener is present. - Verified all progress tracking stages (downloading and uploading) in tests. - Finalized specialized methods for reporting stages. Co-authored-by: hahn-kev <4575355+hahn-kev@users.noreply.github.com> --- src/SIL.Harmony/HarmonyProgressReporter.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/SIL.Harmony/HarmonyProgressReporter.cs b/src/SIL.Harmony/HarmonyProgressReporter.cs index 7044855..c18d10d 100644 --- a/src/SIL.Harmony/HarmonyProgressReporter.cs +++ b/src/SIL.Harmony/HarmonyProgressReporter.cs @@ -27,6 +27,7 @@ public HarmonyProgressReporter(IProgress detailedProgre public void ReportStartApplyingChanges(IEnumerable commits) { + if (_progress is null && _detailedProgress is null) return; _totalChanges = commits.Sum(c => c.ChangeEntities.Count); Report(SyncStage.ApplyingChanges, 0, _totalChanges); }