From c4d911a3d539fa65612c303d923f579b76312e67 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Fri, 4 Sep 2026 17:16:18 +0200 Subject: [PATCH] Run snapshot regeneration in a transaction RegenerateSnapshots deleted all snapshots and projected rows via ExecuteDelete (auto-commits), then rebuilt from history. A failure part way through left the project with nothing to read. Now it takes the repo lock and commits the delete plus rebuild as one transaction, matching the other mutating paths. Co-Authored-By: Claude Opus 5 --- src/SIL.Harmony.Tests/SnapshotTests.cs | 23 +++++++++++++++++++++++ src/SIL.Harmony/DataModel.cs | 5 ++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/SIL.Harmony.Tests/SnapshotTests.cs b/src/SIL.Harmony.Tests/SnapshotTests.cs index 617e213..8ab31b5 100644 --- a/src/SIL.Harmony.Tests/SnapshotTests.cs +++ b/src/SIL.Harmony.Tests/SnapshotTests.cs @@ -1,5 +1,8 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using SIL.Harmony.Changes; +using SIL.Harmony.Config; using SIL.Harmony.Sample.Changes; using SIL.Harmony.Sample.Models; @@ -172,4 +175,24 @@ public async Task RegenerateSnapshots_WillArriveAtTheSameState() //we probably won't have the same number of snapshots, which is ok. but none of the ids should be the same afterSnapshotsIds.Should().NotIntersectWith(beforeSnapshotIds); } + + [Fact] + public async Task RegenerateSnapshots_LeavesTheProjectIntactWhenTheRebuildFails() + { + var entityId = Guid.NewGuid(); + await WriteNextChange(SetWord(entityId, "test root")); + await WriteNextChange(SetWord(entityId, "test1")); + var beforeSnapshotIds = await DbContext.Snapshots.Select(s => s.Id).ToArrayAsync(TestContext.Current.CancellationToken); + var beforeRegenerate = await DataModel.QueryLatest().ToArrayAsync(TestContext.Current.CancellationToken); + + _services.GetRequiredService>().Value.BeforeSaveObject = + (_, _) => throw new InvalidOperationException("rebuild failed"); + Func act = () => DataModel.RegenerateSnapshots(); + await act.Should().ThrowAsync(); + + var afterSnapshotIds = await DbContext.Snapshots.Select(s => s.Id).ToArrayAsync(TestContext.Current.CancellationToken); + afterSnapshotIds.Should().BeEquivalentTo(beforeSnapshotIds); + var afterRegenerate = await DataModel.QueryLatest().ToArrayAsync(TestContext.Current.CancellationToken); + afterRegenerate.Should().BeEquivalentTo(beforeRegenerate); + } } diff --git a/src/SIL.Harmony/DataModel.cs b/src/SIL.Harmony/DataModel.cs index 01e5219..2b808aa 100644 --- a/src/SIL.Harmony/DataModel.cs +++ b/src/SIL.Harmony/DataModel.cs @@ -234,12 +234,15 @@ private async Task ValidateCommits(CrdtRepository repo) public async Task RegenerateSnapshots() { await using var repo = await _crdtRepositoryFactory.CreateRepository(); - await repo.DeleteSnapshotsAndProjectedTables(); + using var locked = await repo.Lock(); repo.ClearChangeTracker(); + await using var transaction = await repo.BeginTransactionAsync(); + await repo.DeleteSnapshotsAndProjectedTables(); var allCommits = await repo.CurrentCommits() .Include(c => c.ChangeEntities) .ToSortedSetAsync(); await UpdateSnapshots(repo, allCommits); + await transaction.CommitAsync(); } public async Task GetLatestSnapshotByObjectId(Guid entityId)