Skip to content
Open
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
2 changes: 1 addition & 1 deletion .claude/agents/test-auditor.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ cover the changes.
- **`Enum.GetValues<T>()`** instead of casting `int`s.
- **New enum values** (e.g. `RegressionVersion`) need parallel
`[InlineData]` rows.
- **Reproduce sync bugs with `DryRunMiniLcmApi` before fixing** (PR #2252).
- **Reproduce sync bugs with a dry-run sync (`SyncDryRun`/`ImportDryRun`) before fixing** (PR #2252).
- **Test cleanup.** Unique filenames derived from `code` variable so
reruns don't trip on prior state (PR #2219).
- **`[Skip]` / `[SkipWhen]`** new additions: ask for justification.
Expand Down
2 changes: 1 addition & 1 deletion backend/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ To build against local Harmony source (e.g. when developing the CRDT substrate),
- **Nullable**: Enabled globally, `Nullable` warnings are errors
- **Implicit usings**: Enabled
- **Target framework**: net10.0 (unless platform-specific)
- **Async**: Use `async/await`, not `.Result` or `.Wait()`
- **Async**: Use `async/await`, not `.Result` or `.Wait()`. Prefer `return await Foo()` over returning the task directly — awaiting keeps this method on the stack trace when the inner call throws. Only drop the `await` (return the task) as a hot-path micro-optimization ([#2435](https://github.com/sillsdev/languageforge-lexbox/pull/2435#discussion_r3584331713))
- **Records**: Prefer for DTOs and immutable data

## Key Patterns
Expand Down
2 changes: 1 addition & 1 deletion backend/FwHeadless/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ dotnet test ../FwLite/FwLiteProjectSync.Tests
## Debugging Tips

1. **Enable detailed logging** in `appsettings.Development.json`
2. **Use `DryRunMiniLcmApi`** to see what sync would do without applying
2. **Use a dry-run sync** (`SyncDryRun`/`ImportDryRun`) to see what sync would do without applying
3. **Check ProjectSnapshot** - if it's wrong, sync will be wrong
4. **Compare CRDT and FwData states** manually when debugging
5. **Mercurial log** (`hg log -v`) shows commit history
Expand Down
2 changes: 1 addition & 1 deletion backend/FwLite/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ This is major work. Follow the pattern of `Sense`:
### "Fix a sync bug"

1. Reproduce with a test in `FwLiteProjectSync.Tests/`
2. Use `DryRunMiniLcmApi` to see what changes would be made
2. Use a dry-run sync (`SyncDryRun`/`ImportDryRun`, which record via `RecordingMiniLcmApi`) to see what changes would be made
3. Debug through `CrdtFwdataProjectSyncService.Sync()`
4. Check `ProjectSnapshot` handling

Expand Down
19 changes: 19 additions & 0 deletions backend/FwLite/FwLiteProjectSync.Tests/CrdtRepairTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,25 @@ public async Task CrdtEntryMissingTranslationId_NoChanges_FullSync()
updatedSnapshotEntry.SingleTranslation().Id.Should().Be(fwTranslationId);
}

[Fact]
public async Task CrdtEntryMissingTranslationId_DryRunSync_LeavesRealCrdtUntouched()
{
// arrange
var (fwEntry, crdtEntry, _) = await CreateSyncedEntryMissingTranslationId();
var crdtTranslationIdBefore = crdtEntry.SingleTranslation().Id;
var fwTranslationId = fwEntry.SingleTranslation().Id;
crdtTranslationIdBefore.Should().NotBe(fwTranslationId, "the repair hasn't run yet");

// act - a dry run repairs translation IDs on the throwaway copy, never the real project
var projectSnapshot = await GetSnapshot();
await SyncService.SyncDryRun(CrdtApi, FwDataApi, projectSnapshot);

// assert - the real crdt translation ID is unchanged (a real sync would have written the fwdata ID)
var crdtEntryAfter = await CrdtApi.GetEntry(crdtEntry.Id);
crdtEntryAfter.Should().NotBeNull();
crdtEntryAfter.SingleTranslation().Id.Should().Be(crdtTranslationIdBefore);
}

[Fact]
public async Task CrdtEntryMissingTranslationId_FwTranslationChanged_FullSync()
{
Expand Down
64 changes: 35 additions & 29 deletions backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
using FwDataMiniLcmBridge.Api;
using FwLiteProjectSync.Tests.Fixtures;
using LcmCrdt;
using MiniLcm.Import;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
Expand Down Expand Up @@ -68,21 +67,14 @@ private void ShouldAllBeEquivalentTo(Dictionary<Guid, Entry> crdtEntries, Dictio
}
}

//by default the first sync is an import, this will skip that so that the sync will actually sync data
private async Task<ProjectSnapshot> CreateAndSaveMinimalSnapshot(bool withWritingSystems = false)
private async Task<ProjectSnapshot> PrepareToSync()
{
var snapshot = ProjectSnapshot.Empty;
if (withWritingSystems) snapshot = snapshot with { WritingSystems = await _fwDataApi.GetWritingSystems() };
// crdt needs a default vernacular WS in order to be readable/syncable
var fwdataWritingSystems = await _fwDataApi.GetWritingSystems();
await _crdtApi.CreateWritingSystem(fwdataWritingSystems.Vernacular.First());

//saving the snapshot will try to read the Id but it will be empty when coming from FwData
foreach (var ws in snapshot.WritingSystems.Analysis)
{
if (ws.MaybeId is null) ws.Id = Guid.NewGuid();
}
foreach (var ws in snapshot.WritingSystems.Vernacular)
{
if (ws.MaybeId is null) ws.Id = Guid.NewGuid();
}
//by default the first sync is an import, this will skip that so that the sync will actually sync data
var snapshot = await _crdtApi.TakeProjectSnapshot();
await ProjectSnapshotService.SaveProjectSnapshot(_fwDataApi.Project, snapshot);
return await _snapshotService.GetProjectSnapshot(_fwDataApi.Project)
?? throw new InvalidOperationException("Expected snapshot to exist after saving");
Expand All @@ -101,10 +93,18 @@ private async Task WorkaroundMissingWritingSystems()
public async Task DryRunImport_MakesNoChanges()
{
await WorkaroundMissingWritingSystems();
_crdtApi.GetEntries().ToBlockingEnumerable().Should().BeEmpty();

var crdtSnapshotBefore = await _crdtApi.TakeProjectSnapshot();
crdtSnapshotBefore.Entries.Should().BeEmpty();
var fwdataSnapshotBefore = await _fwDataApi.TakeProjectSnapshot();

await _syncService.ImportDryRun(_crdtApi, _fwDataApi);
//should still be empty
_crdtApi.GetEntries().ToBlockingEnumerable().Should().BeEmpty();

var crdtSnapshotAfter = await _crdtApi.TakeProjectSnapshot();
var fwdataSnapshotAfter = await _fwDataApi.TakeProjectSnapshot();

crdtSnapshotAfter.Should().BeEquivalentTo(crdtSnapshotBefore);
fwdataSnapshotAfter.Should().BeEquivalentTo(fwdataSnapshotBefore);
}

[Fact]
Expand All @@ -120,28 +120,33 @@ public async Task DryRunImport_MakesTheSameChangesAsImport()
[Trait("Category", "Integration")]
public async Task DryRunSync_MakesNoChanges()
{
var projectSnapshot = await CreateAndSaveMinimalSnapshot();
await WorkaroundMissingWritingSystems();
_crdtApi.GetEntries().ToBlockingEnumerable().Should().BeEmpty();
var projectSnapshot = await PrepareToSync();

var crdtSnapshotBefore = await _crdtApi.TakeProjectSnapshot();
crdtSnapshotBefore.Entries.Should().BeEmpty();
var fwdataSnapshotBefore = await _fwDataApi.TakeProjectSnapshot();

await _syncService.SyncDryRun(_crdtApi, _fwDataApi, projectSnapshot);
//should still be empty
_crdtApi.GetEntries().ToBlockingEnumerable().Should().BeEmpty();

var crdtSnapshotAfter = await _crdtApi.TakeProjectSnapshot();
var fwdataSnapshotAfter = await _fwDataApi.TakeProjectSnapshot();

crdtSnapshotAfter.Should().BeEquivalentTo(crdtSnapshotBefore);
fwdataSnapshotAfter.Should().BeEquivalentTo(fwdataSnapshotBefore);
}

[Fact]
[Trait("Category", "Slow")]
[Trait("Category", "Integration")]
public async Task DryRunSync_MakesTheSameChangesAsSync()
{
//syncing requires querying entries, which fails if there are no writing systems, so we import those first
await _project.Services.GetRequiredService<ProjectImporter>()
.ImportWritingSystems(_crdtApi, await _fwDataApi.GetWritingSystems());
var projectSnapshot = await CreateAndSaveMinimalSnapshot(true);
var projectSnapshot = await PrepareToSync();

var dryRunSyncResult = await _syncService.SyncDryRun(_crdtApi, _fwDataApi, projectSnapshot);
var syncResult = await _syncService.Sync(_crdtApi, _fwDataApi, projectSnapshot);
dryRunSyncResult.CrdtChanges.Should().Be(syncResult.CrdtChanges);
//can't test fwdata changes as they will not work correctly since the sync code expects Crdts to contain data from FWData
//this throws off the algorithm and it will try to delete everything in fwdata since there's no data in the crdt since it was a dry run
// the CRDT side ran against a throwaway copy, so a dry run sync back to fwdata also reflects a real sync
dryRunSyncResult.FwdataChanges.Should().Be(syncResult.FwdataChanges);
}

[Fact]
Expand Down Expand Up @@ -172,7 +177,8 @@ await _crdtApi.TakeProjectSnapshot()
[Trait("Category", "Integration")]
public async Task SyncWithoutImport_CrdtShouldMatchFwdata()
{
var projectSnapshot = await CreateAndSaveMinimalSnapshot();
var projectSnapshot = await PrepareToSync();

var results = await _syncService.Sync(_crdtApi, _fwDataApi, projectSnapshot);
results.FwdataChanges.Should().Be(0);
results.CrdtChanges.Should().BeGreaterThan(_fwDataApi.EntryCount);
Expand Down
40 changes: 25 additions & 15 deletions backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@ namespace FwLiteProjectSync;

public class CrdtFwdataProjectSyncService(MiniLcmImport miniLcmImport,
ILogger<CrdtFwdataProjectSyncService> logger,
MiniLcmApiValidationWrapperFactory validationWrapperFactory)
MiniLcmApiValidationWrapperFactory validationWrapperFactory,
CrdtProjectsService crdtProjectsService)
{
public record DryRunSyncResult(
int CrdtChanges,
int FwdataChanges,
List<DryRunMiniLcmApi.DryRunRecord> CrdtDryRunRecords,
List<DryRunMiniLcmApi.DryRunRecord> FwDataDryRunRecords) : SyncResult(CrdtChanges, FwdataChanges);
List<RecordingMiniLcmApi.RunRecord> CrdtDryRunRecords,
List<RecordingMiniLcmApi.RunRecord> FwDataDryRunRecords) : SyncResult(CrdtChanges, FwdataChanges);

public async Task<DryRunSyncResult> SyncDryRun(IMiniLcmApi crdtApi, FwDataMiniLcmApi fwdataApi, ProjectSnapshot projectSnapshot)
{
Expand Down Expand Up @@ -61,21 +62,30 @@ private async Task<SyncResult> SyncOrImportInternal(IMiniLcmApi crdtApi, IMiniLc
throw new InvalidOperationException("Project sync state does not match presence of snapshot.");
}

await using var crdtCopy = dryRun ? await crdtProjectsService.OpenTempProjectCopy(crdt.Project) : null;
if (dryRun)
{
// Point the whole CRDT side at the copy, crdt included — the translation-id repair below writes
// through crdt, so rebinding it keeps that write off the real project and lets the copy read it back.
crdt = crdtCopy?.Api ?? throw new InvalidOperationException("crdtCopy must be defined in a dryRun");
crdtApi = crdt;
}

// No write normalization: Data is already normalised on both sides.
// No query normalization: The sync doesn't do any querying.
crdtApi = validationWrapperFactory.Create(crdtApi);
fwdataApi = validationWrapperFactory.Create(fwdataApi);

if (dryRun)
{
crdtApi = new DryRunMiniLcmApi(crdtApi);
fwdataApi = new DryRunMiniLcmApi(fwdataApi);
crdtApi = new RecordingMiniLcmApi(crdtApi);
fwdataApi = new RecordingMiniLcmApi(new WriteIgnoringMiniLcmApi(fwdataApi));
}

if (projectSnapshot is not null)
{
// Repair any missing translation IDs before doing the full sync, so the sync doesn't have to deal with them
var syncedIdCount = await CrdtRepairs.SyncMissingTranslationIds(projectSnapshot.Entries, fwdata, crdt, dryRun);
await CrdtRepairs.SyncMissingTranslationIds(projectSnapshot.Entries, fwdata, crdt);

// Patch legacy snapshots that were created before morph-type support.
// After seeding, the CRDT has morph-types but the snapshot still has [].
Expand All @@ -100,10 +110,10 @@ private async Task<SyncResult> SyncOrImportInternal(IMiniLcmApi crdtApi, IMiniLc
return syncResult;
}

LogDryRun(crdtApi, "crdt");
LogDryRun(fwdataApi, "fwdata");
LogRecordedRun(crdtApi, "crdt");
LogRecordedRun(fwdataApi, "fwdata");
return new DryRunSyncResult(syncResult.CrdtChanges, syncResult.FwdataChanges,
GetDryRunRecords(crdtApi), GetDryRunRecords(fwdataApi));
GetRunRecords(crdtApi), GetRunRecords(fwdataApi));
}

private async Task<SyncResult> ImportInternal(IMiniLcmApi crdtApi, IMiniLcmApi fwdataApi, int entryCount)
Expand Down Expand Up @@ -145,19 +155,19 @@ private async Task<SyncResult> SyncInternal(IMiniLcmApi crdtApi, IMiniLcmApi fwd
return new SyncResult(crdtChanges, fwdataChanges);
}

private void LogDryRun(IMiniLcmApi api, string type)
private void LogRecordedRun(IMiniLcmApi api, string type)
{
if (api is not DryRunMiniLcmApi dryRunApi) return;
foreach (var dryRunRecord in dryRunApi.DryRunRecords)
if (api is not RecordingMiniLcmApi recorder) return;
foreach (var dryRunRecord in recorder.RunRecords)
{
logger.LogInformation($"Dry run {type} record: {dryRunRecord.Method} {dryRunRecord.Description}");
}

logger.LogInformation($"Dry run {type} changes: {dryRunApi.DryRunRecords.Count}");
logger.LogInformation($"Dry run {type} changes: {recorder.RunRecords.Count}");
}

private List<DryRunMiniLcmApi.DryRunRecord> GetDryRunRecords(IMiniLcmApi api)
private List<RecordingMiniLcmApi.RunRecord> GetRunRecords(IMiniLcmApi api)
{
return ((DryRunMiniLcmApi)api).DryRunRecords;
return ((RecordingMiniLcmApi)api).RunRecords;
}
}
4 changes: 2 additions & 2 deletions backend/FwLite/FwLiteProjectSync/CrdtRepairs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ namespace FwLiteProjectSync;
public static class CrdtRepairs
{
#pragma warning disable CS0618 // Type or member is obsolete
public static async Task<int> SyncMissingTranslationIds(Entry[] snapshotEntries, FwDataMiniLcmApi fwDataApi, CrdtMiniLcmApi crdtApi, bool dryRun = false)
public static async Task<int> SyncMissingTranslationIds(Entry[] snapshotEntries, FwDataMiniLcmApi fwDataApi, CrdtMiniLcmApi crdtApi)
{
using var activity = FwLiteProjectSyncActivitySource.Value.StartActivity();
// Sync any available IDs from fwdata to the snapshot and the crdt entries
Expand Down Expand Up @@ -78,7 +78,7 @@ public static async Task<int> SyncMissingTranslationIds(Entry[] snapshotEntries,
}
}

if (!dryRun && exampleSentenceIdToTranslationId.Any())
if (exampleSentenceIdToTranslationId.Any())
{
await crdtApi.SetFirstTranslationIds(exampleSentenceIdToTranslationId);
}
Expand Down
Loading
Loading