Skip to content

Commit 16a9173

Browse files
authored
Tolerate objects deleted in CRDT during FwData sync (#2367)
1 parent f5d0f42 commit 16a9173

12 files changed

Lines changed: 336 additions & 58 deletions

File tree

backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,126 @@ protected override IMiniLcmApi GetApi(SyncFixture fixture)
1818
{
1919
return fixture.CrdtApi;
2020
}
21+
22+
// These delete-win cases live only in the CRDT subclass: the CRDT deletion must win when an object it
23+
// deleted is still edited from the other side, whereas FwData intentionally still throws on a missing target.
24+
25+
[Fact]
26+
public async Task SyncFull_EntryEditedButDeletedInCrdt_DoesNotThrow()
27+
{
28+
var entry = await Api.CreateEntry(new() { Id = Guid.NewGuid(), LexemeForm = { { "en", "victim" } } });
29+
await Api.DeleteEntry(entry.Id);
30+
var after = entry.Copy();
31+
after.CitationForm["en"] = "edited";
32+
33+
await EntrySync.SyncFull(entry, after, Api);
34+
35+
(await Api.GetEntry(entry.Id)).Should().BeNull();
36+
}
37+
38+
[Fact]
39+
public async Task SyncFull_SenseAddedToEntryDeletedInCrdt_DoesNotThrow()
40+
{
41+
var entry = await Api.CreateEntry(new() { Id = Guid.NewGuid(), LexemeForm = { { "en", "victim" } } });
42+
await Api.DeleteEntry(entry.Id);
43+
var after = entry.Copy();
44+
after.Senses.Add(new Sense { Id = Guid.NewGuid(), Gloss = { { "en", "gloss" } } });
45+
46+
await EntrySync.SyncFull(entry, after, Api);
47+
48+
(await Api.GetEntry(entry.Id)).Should().BeNull();
49+
}
50+
51+
[Fact]
52+
public async Task SyncFull_SenseEditedButDeletedInCrdt_DoesNotThrow()
53+
{
54+
var entry = await Api.CreateEntry(new()
55+
{
56+
Id = Guid.NewGuid(),
57+
LexemeForm = { { "en", "victim" } },
58+
Senses = [new Sense { Id = Guid.NewGuid(), Gloss = { { "en", "gloss" } } }]
59+
});
60+
await Api.DeleteSense(entry.Id, entry.Senses[0].Id);
61+
var after = entry.Copy();
62+
after.Senses[0].Gloss["en"] = "edited";
63+
64+
await EntrySync.SyncFull(entry, after, Api);
65+
66+
var actual = await Api.GetEntry(entry.Id);
67+
actual.Should().NotBeNull();
68+
actual.Senses.Should().BeEmpty();
69+
}
70+
71+
[Fact]
72+
public async Task SyncFull_ExampleSentenceEditedButDeletedInCrdt_DoesNotThrow()
73+
{
74+
var entry = await Api.CreateEntry(new()
75+
{
76+
Id = Guid.NewGuid(),
77+
LexemeForm = { { "en", "victim" } },
78+
Senses =
79+
[
80+
new Sense
81+
{
82+
Id = Guid.NewGuid(),
83+
Gloss = { { "en", "gloss" } },
84+
ExampleSentences = [new ExampleSentence { Id = Guid.NewGuid(), Sentence = { { "en", new RichString("sentence") } } }]
85+
}
86+
]
87+
});
88+
var sense = entry.Senses[0];
89+
await Api.DeleteExampleSentence(entry.Id, sense.Id, sense.ExampleSentences[0].Id);
90+
var after = entry.Copy();
91+
after.Senses[0].ExampleSentences[0].Sentence["en"] = new RichString("edited");
92+
93+
await EntrySync.SyncFull(entry, after, Api);
94+
95+
var actual = await Api.GetEntry(entry.Id);
96+
actual.Should().NotBeNull();
97+
actual.Senses[0].ExampleSentences.Should().BeEmpty();
98+
}
99+
100+
[Fact]
101+
public async Task SyncFull_ComplexFormComponentReferencingEntryDeletedInCrdt_DoesNotThrow()
102+
{
103+
var component = await Api.CreateEntry(new() { Id = Guid.NewGuid(), LexemeForm = { { "en", "component" } } });
104+
var complexForm = await Api.CreateEntry(new() { Id = Guid.NewGuid(), LexemeForm = { { "en", "complexForm" } } });
105+
await Api.DeleteEntry(component.Id);
106+
var after = complexForm.Copy();
107+
after.Components.Add(ComplexFormComponent.FromEntries(complexForm, component));
108+
109+
await EntrySync.SyncFull(complexForm, after, Api);
110+
111+
(await Api.GetEntry(component.Id)).Should().BeNull();
112+
(await Api.GetEntry(complexForm.Id))!.Components.Should().BeEmpty();
113+
}
114+
115+
[Fact]
116+
public async Task SyncFull_ComplexFormComponentReorderedButEntryDeletedInCrdt_DoesNotThrow()
117+
{
118+
var componentA = await Api.CreateEntry(new() { Id = Guid.NewGuid(), LexemeForm = { { "en", "a" } } });
119+
var componentB = await Api.CreateEntry(new() { Id = Guid.NewGuid(), LexemeForm = { { "en", "b" } } });
120+
var complexForm = new Entry { Id = Guid.NewGuid(), LexemeForm = { { "en", "complexForm" } } };
121+
complexForm.Components =
122+
[
123+
ComplexFormComponent.FromEntries(complexForm, componentA),
124+
ComplexFormComponent.FromEntries(complexForm, componentB),
125+
];
126+
var before = await Api.CreateEntry(complexForm);
127+
await Api.DeleteEntry(before.Id);
128+
129+
// Id-less components (as FwData produces them) force the move to resolve the now-deleted component — the case that used to throw.
130+
var after = before.Copy();
131+
after.Components =
132+
[
133+
new ComplexFormComponent { ComplexFormEntryId = before.Id, ComponentEntryId = componentB.Id, ComponentHeadword = "b" },
134+
new ComplexFormComponent { ComplexFormEntryId = before.Id, ComponentEntryId = componentA.Id, ComponentHeadword = "a" },
135+
];
136+
137+
await EntrySync.SyncFull(before, after, Api);
138+
139+
(await Api.GetEntry(before.Id)).Should().BeNull();
140+
}
21141
}
22142

23143
public class FwDataEntrySyncTests(ExtraWritingSystemsSyncFixture fixture) : EntrySyncTestsBase(fixture)

backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -566,9 +566,34 @@ await fwdataApi.CreateEntry(new Entry()
566566
]
567567
});
568568

569-
//sync may fail because it will try to create a complex form for an entry which was deleted
569+
// Regression: creating the complex-form component for the CRDT-deleted _testEntry once used to throw and wedge the sync.
570570
await _syncService.Sync(crdtApi, fwdataApi, projectSnapshot);
571571

572+
(await crdtApi.GetEntry(_testEntry.Id)).Should().BeNull();
573+
(await fwdataApi.GetEntry(_testEntry.Id)).Should().BeNull();
574+
var crdtComplexForm = await crdtApi.GetEntry(newEntryId);
575+
crdtComplexForm.Should().NotBeNull();
576+
crdtComplexForm.Components.Should().BeEmpty();
577+
(await fwdataApi.GetEntry(newEntryId)).Should().NotBeNull();
578+
}
579+
580+
[Fact]
581+
[Trait("Category", "Integration")]
582+
public async Task EntryEditedInFwDataButDeletedInCrdt_SyncDoesNotThrow()
583+
{
584+
var crdtApi = _fixture.CrdtApi;
585+
var fwdataApi = _fixture.FwDataApi;
586+
await _syncService.Import(crdtApi, fwdataApi);
587+
var projectSnapshot = await _fixture.RegenerateAndGetSnapshot();
588+
589+
await fwdataApi.UpdateEntry(_testEntry.Id, new UpdateObjectInput<Entry>().Set(e => e.CitationForm["en"], "edited"));
590+
await crdtApi.DeleteEntry(_testEntry.Id);
591+
592+
// Regression: the fwdata vs snapshot diff calls SubmitUpdateEntry on the CRDT-deleted entry, which used to throw NotFound.
593+
await _syncService.Sync(crdtApi, fwdataApi, projectSnapshot);
594+
595+
(await crdtApi.GetEntry(_testEntry.Id)).Should().BeNull();
596+
(await fwdataApi.GetEntry(_testEntry.Id)).Should().BeNull();
572597
}
573598

574599
[Fact]

backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,71 @@ public Task RemovePublication(Guid entryId, Guid publicationId)
360360
return Task.CompletedTask;
361361
}
362362

363+
#region Submit (sync's result-less write variants)
364+
// Record-only. Overridden explicitly (not inherited from the interface default, which would route to the
365+
// returning Update* and re-read the object) so a dry-run of a conflicted project doesn't throw on the
366+
// now-deleted object.
367+
public Task SubmitUpdateEntry(Guid id, UpdateObjectInput<Entry> update)
368+
{
369+
DryRunRecords.Add(new DryRunRecord(nameof(SubmitUpdateEntry), $"Update entry {id}"));
370+
return Task.CompletedTask;
371+
}
372+
373+
public Task SubmitUpdateSense(Guid entryId, Guid senseId, UpdateObjectInput<Sense> update)
374+
{
375+
DryRunRecords.Add(new DryRunRecord(nameof(SubmitUpdateSense), $"Update sense {senseId}, changes: {update.Summarize()}"));
376+
return Task.CompletedTask;
377+
}
378+
379+
public Task SubmitUpdateExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId, UpdateObjectInput<ExampleSentence> update)
380+
{
381+
DryRunRecords.Add(new DryRunRecord(nameof(SubmitUpdateExampleSentence), $"Update example sentence {exampleSentenceId}, changes: {update.Summarize()}"));
382+
return Task.CompletedTask;
383+
}
384+
385+
public Task SubmitCreateSense(Guid entryId, Sense sense, BetweenPosition? position = null)
386+
{
387+
DryRunRecords.Add(new DryRunRecord(nameof(SubmitCreateSense), $"Create sense {sense.Gloss}"));
388+
return Task.CompletedTask;
389+
}
390+
391+
public Task SubmitCreateExampleSentence(Guid entryId, Guid senseId, ExampleSentence exampleSentence, BetweenPosition? position = null)
392+
{
393+
DryRunRecords.Add(new DryRunRecord(nameof(SubmitCreateExampleSentence), $"Create example sentence {exampleSentence.Sentence}"));
394+
return Task.CompletedTask;
395+
}
396+
397+
public Task SubmitCreateComplexFormComponent(ComplexFormComponent complexFormComponent, BetweenPosition<ComplexFormComponent>? position = null)
398+
{
399+
DryRunRecords.Add(new DryRunRecord(nameof(SubmitCreateComplexFormComponent), $"Create complex form component {ComplexFormComponentName(complexFormComponent)}"));
400+
return Task.CompletedTask;
401+
}
402+
403+
public Task SubmitUpdatePartOfSpeech(Guid id, UpdateObjectInput<PartOfSpeech> update)
404+
{
405+
DryRunRecords.Add(new DryRunRecord(nameof(SubmitUpdatePartOfSpeech), $"Update part of speech {id}"));
406+
return Task.CompletedTask;
407+
}
408+
409+
public Task SubmitUpdatePublication(Guid id, UpdateObjectInput<Publication> update)
410+
{
411+
DryRunRecords.Add(new DryRunRecord(nameof(SubmitUpdatePublication), $"Update publication {id}"));
412+
return Task.CompletedTask;
413+
}
414+
415+
public Task SubmitUpdateSemanticDomain(Guid id, UpdateObjectInput<SemanticDomain> update)
416+
{
417+
DryRunRecords.Add(new DryRunRecord(nameof(SubmitUpdateSemanticDomain), $"Update semantic domain {id}"));
418+
return Task.CompletedTask;
419+
}
420+
421+
public Task SubmitUpdateComplexFormType(Guid id, UpdateObjectInput<ComplexFormType> update)
422+
{
423+
DryRunRecords.Add(new DryRunRecord(nameof(SubmitUpdateComplexFormType), $"Update complex form type {id}"));
424+
return Task.CompletedTask;
425+
}
426+
#endregion
427+
363428
private string ComplexFormComponentName(ComplexFormComponent? component)
364429
{
365430
if (component == null) return "null";

0 commit comments

Comments
 (0)