Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions src/SIL.Harmony.Tests/RebuildCommitHashesTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using Microsoft.EntityFrameworkCore;
using SIL.Harmony.Core;

namespace SIL.Harmony.Tests;

public class RebuildCommitHashesTests : DataModelTestBase
{
[Fact]
public async Task RebuildCommitHashes_RestoresChainAfterHashesAreCorrupted()
{
// Three commits in chain order. WriteNextChange persists them with correct hashes
// computed against the live chain.
var c1 = await WriteNextChange(SetWord(Guid.NewGuid(), "one"));
var c2 = await WriteNextChange(SetWord(Guid.NewGuid(), "two"));
var c3 = await WriteNextChange(SetWord(Guid.NewGuid(), "three"));

// Corrupt c2's hash directly. With AlwaysValidateCommits on, any further AddChange
// would throw — exactly the scenario RebuildCommitHashes exists to recover from
// (in production, the corruption comes from substituting Commit.Ids in a SQL
// template before the model has seen the chain).
var tracked = await DbContext.Commits.SingleAsync(c => c.Id == c2.Id);

Check warning on line 21 in src/SIL.Harmony.Tests/RebuildCommitHashesTests.cs

View workflow job for this annotation

GitHub Actions / build

Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive. (https://xunit.net/xunit.analyzers/rules/xUnit1051)

Check warning on line 21 in src/SIL.Harmony.Tests/RebuildCommitHashesTests.cs

View workflow job for this annotation

GitHub Actions / build

Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive. (https://xunit.net/xunit.analyzers/rules/xUnit1051)
// "BBAADD" is a valid hex string (3 bytes) — CommitBase.GenerateHash parses parentHash
// via Convert.FromHexString, so the corruption value still needs to look like a hash.
tracked.SetParentHash("BBAADD");
await DbContext.SaveChangesAsync();

Check warning on line 25 in src/SIL.Harmony.Tests/RebuildCommitHashesTests.cs

View workflow job for this annotation

GitHub Actions / build

Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive. (https://xunit.net/xunit.analyzers/rules/xUnit1051)

Check warning on line 25 in src/SIL.Harmony.Tests/RebuildCommitHashesTests.cs

View workflow job for this annotation

GitHub Actions / build

Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive. (https://xunit.net/xunit.analyzers/rules/xUnit1051)

// Sanity: validation now rejects further writes.
Func<Task> beforeRebuild = async () => await WriteNextChange(SetWord(Guid.NewGuid(), "four"));
await beforeRebuild.Should().ThrowAsync<CommitValidationException>();

// Act
await DataModel.RebuildCommitHashes();

// Each commit's persisted Hash must equal the hash computed from its Id + the prior
// commit's hash, walking from the chain root.
var commits = await DbContext.Commits.AsNoTracking().DefaultOrder().ToListAsync();

Check warning on line 36 in src/SIL.Harmony.Tests/RebuildCommitHashesTests.cs

View workflow job for this annotation

GitHub Actions / build

Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive. (https://xunit.net/xunit.analyzers/rules/xUnit1051)

Check warning on line 36 in src/SIL.Harmony.Tests/RebuildCommitHashesTests.cs

View workflow job for this annotation

GitHub Actions / build

Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive. (https://xunit.net/xunit.analyzers/rules/xUnit1051)
var parentHash = CommitBase.NullParentHash;
foreach (var commit in commits)
{
commit.Hash.Should().Be(commit.GenerateHash(parentHash));
commit.ParentHash.Should().Be(parentHash);
parentHash = commit.Hash;
}

// And the chain is once again accepting writes.
Func<Task> afterRebuild = async () => await WriteNextChange(SetWord(Guid.NewGuid(), "four"));
await afterRebuild.Should().NotThrowAsync();
}

[Fact]
public async Task RebuildCommitHashes_IsNoOpOnEmptyChain()
{
// Just don't throw / don't write anything. Guards against future regressions where
// a callable shape (e.g. AsNoTracking + DefaultOrder().FirstAsync()) would crash on
// an empty table.
Func<Task> act = async () => await DataModel.RebuildCommitHashes();
await act.Should().NotThrowAsync();
(await DbContext.Commits.CountAsync()).Should().Be(0);

Check warning on line 58 in src/SIL.Harmony.Tests/RebuildCommitHashesTests.cs

View workflow job for this annotation

GitHub Actions / build

Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive. (https://xunit.net/xunit.analyzers/rules/xUnit1051)

Check warning on line 58 in src/SIL.Harmony.Tests/RebuildCommitHashesTests.cs

View workflow job for this annotation

GitHub Actions / build

Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive. (https://xunit.net/xunit.analyzers/rules/xUnit1051)
}
}
19 changes: 18 additions & 1 deletion src/SIL.Harmony/DataModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,8 @@ private Commit NewCommit(Guid commitId, Guid clientId, CommitMetadata? commitMet
private async Task Add(Commit commit)
{
await using var repo = await _crdtRepositoryFactory.CreateRepository();
if (await repo.HasCommit(commit.Id)) return;
using var locked = await repo.Lock();
if (await repo.HasCommit(commit.Id)) return;
repo.ClearChangeTracker();

await using var transaction = repo.IsInTransaction ? null : await repo.BeginTransactionAsync();
Expand Down Expand Up @@ -237,6 +237,23 @@ private async Task ValidateCommits(CrdtRepository repo)
}
}

/// <summary>
/// Recomputes every persisted <see cref="Commit.Hash"/> against the current Commit.Ids in chain order.
/// Useful when a caller has staged commits whose Ids changed after the fact (e.g. applying a
/// Guid-substituted SQL template) and needs the hash chain brought back in line before sync
/// validates it. Local-only: no replication of the rebuild — the resulting hashes must not
/// have been observed by any peer.
/// </summary>
public async Task RebuildCommitHashes()
{
await using var repo = await _crdtRepositoryFactory.CreateRepository();
using var locked = await repo.Lock();
repo.ClearChangeTracker();
await using var transaction = repo.IsInTransaction ? null : await repo.BeginTransactionAsync();
await repo.RebuildCommitHashes();
if (transaction is not null) await transaction.CommitAsync();
}

public async Task RegenerateSnapshots()
{
await using var repo = await _crdtRepositoryFactory.CreateRepository();
Expand Down
14 changes: 14 additions & 0 deletions src/SIL.Harmony/Db/CrdtRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,20 @@ private void UpdateCommitHashes(SortedSet<Commit> commits, Commit? parentCommit
}
}

/// <summary>
/// Walks every commit in chain order and recomputes <see cref="Commit.Hash"/> / <see cref="Commit.ParentHash"/>
/// against the current Commit.Ids. Used when callers seed commits whose Ids change after the fact
/// (e.g. applying a Guid-substituted SQL template), so the persisted hashes need to be brought
/// back in line before any sync validates them.
/// </summary>
public async Task RebuildCommitHashes()
{
var commits = await Commits.AsTracking().DefaultOrder().ToSortedSetAsync();
if (commits.Count == 0) return;
UpdateCommitHashes(commits);
await _dbContext.SaveChangesAsync();
}

public HybridDateTime? GetLatestDateTime()
{
return Commits
Expand Down
Loading