Skip to content

Commit d4d2126

Browse files
committed
Fix projecting deleted and undeleted snapshots of same entity
1 parent 6755b88 commit d4d2126

File tree

2 files changed

+199
-3
lines changed

2 files changed

+199
-3
lines changed
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
using Microsoft.EntityFrameworkCore;
2+
using SIL.Harmony.Changes;
3+
using SIL.Harmony.Sample.Changes;
4+
using SIL.Harmony.Sample.Models;
5+
6+
namespace SIL.Harmony.Tests;
7+
8+
public class DeleteAndCreateTests : DataModelTestBase
9+
{
10+
[Fact]
11+
public async Task DeleteAndUndeleteInSameCommitWorks()
12+
{
13+
var wordId = Guid.NewGuid();
14+
15+
await WriteNextChange(new NewWordChange(wordId, "original"));
16+
17+
await WriteNextChange(
18+
[
19+
new DeleteChange<Word>(wordId),
20+
new NewWordChange(wordId, "Undeleted"),
21+
]);
22+
23+
var word = await DataModel.GetLatest<Word>(wordId);
24+
word.Should().NotBeNull();
25+
word.DeletedAt.Should().BeNull();
26+
word.Text.Should().Be("Undeleted");
27+
28+
var entityWord = await DataModel.QueryLatest<Word>().Where(w => w.Id == wordId).SingleOrDefaultAsync();
29+
entityWord.Should().NotBeNull();
30+
entityWord.DeletedAt.Should().BeNull();
31+
entityWord.Text.Should().Be("Undeleted");
32+
}
33+
34+
[Fact]
35+
public async Task DeleteAndUndeleteInSameSyncWorks()
36+
{
37+
var wordId = Guid.NewGuid();
38+
39+
await WriteNextChange(new NewWordChange(wordId, "original"));
40+
41+
await AddCommitsViaSync([
42+
await WriteNextChange(new DeleteChange<Word>(wordId), add: false),
43+
await WriteNextChange(new NewWordChange(wordId, "Undeleted"), add: false),
44+
]);
45+
46+
var word = await DataModel.GetLatest<Word>(wordId);
47+
word.Should().NotBeNull();
48+
word.DeletedAt.Should().BeNull();
49+
word.Text.Should().Be("Undeleted");
50+
51+
var entityWord = await DataModel.QueryLatest<Word>().Where(w => w.Id == wordId).SingleOrDefaultAsync();
52+
entityWord.Should().NotBeNull();
53+
entityWord.DeletedAt.Should().BeNull();
54+
entityWord.Text.Should().Be("Undeleted");
55+
}
56+
57+
[Fact]
58+
public async Task UpdateAndUndeleteInSameCommitWorks()
59+
{
60+
var wordId = Guid.NewGuid();
61+
62+
await WriteNextChange(new NewWordChange(wordId, "original"));
63+
await WriteNextChange(new DeleteChange<Word>(wordId));
64+
65+
await WriteNextChange([
66+
new SetWordNoteChange(wordId, "overridden-note"),
67+
new NewWordChange(wordId, "Undeleted"),
68+
]);
69+
70+
var word = await DataModel.GetLatest<Word>(wordId);
71+
word.Should().NotBeNull();
72+
word.DeletedAt.Should().BeNull();
73+
word.Text.Should().Be("Undeleted");
74+
75+
var entityWord = await DataModel.QueryLatest<Word>().Where(w => w.Id == wordId).SingleOrDefaultAsync();
76+
entityWord.Should().NotBeNull();
77+
entityWord.DeletedAt.Should().BeNull();
78+
entityWord.Text.Should().Be("Undeleted");
79+
}
80+
81+
[Fact]
82+
public async Task UpdateAndUndeleteInSameSyncWorks()
83+
{
84+
var wordId = Guid.NewGuid();
85+
86+
await WriteNextChange(new NewWordChange(wordId, "original"));
87+
await WriteNextChange(new DeleteChange<Word>(wordId));
88+
89+
await AddCommitsViaSync([
90+
await WriteNextChange(new SetWordNoteChange(wordId, "overridden-note"), add: false),
91+
await WriteNextChange(new NewWordChange(wordId, "Undeleted"), add: false),
92+
]);
93+
94+
var word = await DataModel.GetLatest<Word>(wordId);
95+
word.Should().NotBeNull();
96+
word.DeletedAt.Should().BeNull();
97+
word.Text.Should().Be("Undeleted");
98+
99+
var entityWord = await DataModel.QueryLatest<Word>().Where(w => w.Id == wordId).SingleOrDefaultAsync();
100+
entityWord.Should().NotBeNull();
101+
entityWord.DeletedAt.Should().BeNull();
102+
entityWord.Text.Should().Be("Undeleted");
103+
}
104+
105+
[Fact]
106+
public async Task CreateDeleteAndUndeleteInSameCommitWorks()
107+
{
108+
var wordId = Guid.NewGuid();
109+
110+
await WriteNextChange(
111+
[
112+
new NewWordChange(wordId, "original"),
113+
new DeleteChange<Word>(wordId),
114+
new NewWordChange(wordId, "Undeleted"),
115+
]);
116+
117+
var word = await DataModel.GetLatest<Word>(wordId);
118+
word.Should().NotBeNull();
119+
word.DeletedAt.Should().BeNull();
120+
word.Text.Should().Be("Undeleted");
121+
122+
var entityWord = await DataModel.QueryLatest<Word>().Where(w => w.Id == wordId).SingleOrDefaultAsync();
123+
entityWord.Should().NotBeNull();
124+
entityWord.DeletedAt.Should().BeNull();
125+
entityWord.Text.Should().Be("Undeleted");
126+
}
127+
128+
[Fact]
129+
public async Task CreateDeleteAndUndeleteInSameSyncWorks()
130+
{
131+
var wordId = Guid.NewGuid();
132+
133+
await AddCommitsViaSync([
134+
await WriteNextChange(new NewWordChange(wordId, "original"), add: false),
135+
await WriteNextChange(new DeleteChange<Word>(wordId), add: false),
136+
await WriteNextChange(new NewWordChange(wordId, "Undeleted"), add: false),
137+
]);
138+
139+
var word = await DataModel.GetLatest<Word>(wordId);
140+
word.Should().NotBeNull();
141+
word.DeletedAt.Should().BeNull();
142+
word.Text.Should().Be("Undeleted");
143+
144+
var entityWord = await DataModel.QueryLatest<Word>().Where(w => w.Id == wordId).SingleOrDefaultAsync();
145+
entityWord.Should().NotBeNull();
146+
entityWord.DeletedAt.Should().BeNull();
147+
entityWord.Text.Should().Be("Undeleted");
148+
}
149+
150+
[Fact]
151+
public async Task CreateAndDeleteInSameCommitWorks()
152+
{
153+
var wordId = Guid.NewGuid();
154+
155+
await WriteNextChange(
156+
[
157+
new NewWordChange(wordId, "original"),
158+
new DeleteChange<Word>(wordId),
159+
]);
160+
161+
var word = await DataModel.GetLatest<Word>(wordId);
162+
word.Should().NotBeNull();
163+
word.DeletedAt.Should().NotBeNull();
164+
word.Text.Should().Be("original");
165+
166+
var entityWord = await DataModel.QueryLatest<Word>().Where(w => w.Id == wordId).SingleOrDefaultAsync();
167+
entityWord.Should().BeNull();
168+
}
169+
170+
[Fact]
171+
public async Task CreateAndDeleteInSameSyncWorks()
172+
{
173+
var wordId = Guid.NewGuid();
174+
175+
await AddCommitsViaSync([
176+
await WriteNextChange(new NewWordChange(wordId, "original"), add: false),
177+
await WriteNextChange(new DeleteChange<Word>(wordId), add: false),
178+
]);
179+
180+
var word = await DataModel.GetLatest<Word>(wordId);
181+
word.Should().NotBeNull();
182+
word.DeletedAt.Should().NotBeNull();
183+
word.Text.Should().Be("original");
184+
185+
var entityWord = await DataModel.QueryLatest<Word>().Where(w => w.Id == wordId).SingleOrDefaultAsync();
186+
entityWord.Should().BeNull();
187+
}
188+
}

src/SIL.Harmony/Db/CrdtRepository.cs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -299,16 +299,24 @@ public async Task<ChangesResult<Commit>> GetChanges(SyncState remoteState)
299299

300300
public async Task AddSnapshots(IEnumerable<ObjectSnapshot> snapshots)
301301
{
302-
var projectedEntityIds = new HashSet<Guid>();
302+
var latestProjectByEntityId = new Dictionary<Guid, HybridDateTime>();
303303
foreach (var grouping in snapshots.GroupBy(s => s.EntityIsDeleted).OrderByDescending(g => g.Key))//execute deletes first
304304
{
305305
foreach (var snapshot in grouping.DefaultOrderDescending())
306306
{
307307
_dbContext.Add(snapshot);
308-
if (projectedEntityIds.Add(snapshot.EntityId))
308+
if (latestProjectByEntityId.TryGetValue(snapshot.EntityId, out var latestProjected))
309309
{
310-
await ProjectSnapshot(snapshot);
310+
// there might be a deleted and un-deleted snapshot for the same entity in the same batch
311+
// in that case there's only a 50% chance that they're in the right order, so we need to explicitly only project the latest one
312+
if (snapshot.Commit.HybridDateTime < latestProjected)
313+
{
314+
continue;
315+
}
311316
}
317+
latestProjectByEntityId[snapshot.EntityId] = snapshot.Commit.HybridDateTime;
318+
319+
await ProjectSnapshot(snapshot);
312320
}
313321

314322
try

0 commit comments

Comments
 (0)