Public Access
The server now forgets a deletion past its retention and refuses cursors from before it. The client then reads everything again, but everything again says nothing about what went. So a machine away for longer than the retention kept those items for good, and its next edit of one put it back on the server for everyone. A read from the beginning now marks each item it reads, keeping the mark in the sync state because the read can span passes. When it finishes, it removes the items of the synced types it never marked. Two kinds are kept: items newer than the read's bound, which this machine pushed during it, and items with a change still waiting, which are the user's work. For those, the push finds the server without the item and sends it as a create, as before, but now records a RecreatedAfterCollection conflict and counts it, so the sync message says so instead of undoing someone's deletion silently. The bound is the highest sequence the cache held when the read began, or anything higher it has read since. Taking only what the read returned would have left the last item of a vault in place for ever.
143 lines
5.1 KiB
C#
143 lines
5.1 KiB
C#
using DodoSSH.Client.Storage;
|
|
using static DodoSSH.Client.Sync.Tests.SyncHarness;
|
|
|
|
namespace DodoSSH.Client.Sync.Tests;
|
|
|
|
/// <summary>
|
|
/// A machine away for longer than the server keeps deletions (ADR 0003).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The server forgets a deletion once it is past its retention and refuses any cursor from before it. The
|
|
/// machine then reads everything again — which says nothing about what is gone — so what it held and did
|
|
/// not read again is what has to go. Except the user's own unsent work, which is kept and said out loud.
|
|
/// </remarks>
|
|
public sealed class TombstoneCollectionTests
|
|
{
|
|
[Fact]
|
|
public async Task AnItemDeletedAndForgottenWhileAway_IsRemovedByTheReadFromTheBeginning()
|
|
{
|
|
using var harness = await CreateAsync();
|
|
|
|
var gone = await harness.Second.CreateAsync(Host("old-db"));
|
|
var kept = await harness.Second.CreateAsync(Host("prod-db"));
|
|
await harness.Second.SyncAsync();
|
|
await harness.First.SyncAsync();
|
|
|
|
await harness.Second.DeleteAsync(gone);
|
|
await harness.Second.SyncAsync();
|
|
harness.Server.CollectTombstones();
|
|
|
|
var report = await harness.First.SyncAsync();
|
|
|
|
report.ResyncedFromStart.ShouldBeTrue();
|
|
report.Pruned.ShouldBe(1);
|
|
(await harness.First.ListAsync()).Items.Select(item => item.EntityId).ShouldBe([kept]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TheOnlyItemInAVault_IsRemovedAsWell()
|
|
{
|
|
// The read from the beginning brings back nothing at all, so a sweep bounded by what it read would
|
|
// reach nothing either. It is bounded by what the cache held when it started.
|
|
using var harness = await CreateAsync();
|
|
|
|
var gone = await harness.Second.CreateAsync(Host("old-db"));
|
|
await harness.Second.SyncAsync();
|
|
await harness.First.SyncAsync();
|
|
|
|
await harness.Second.DeleteAsync(gone);
|
|
await harness.Second.SyncAsync();
|
|
harness.Server.CollectTombstones();
|
|
|
|
var report = await harness.First.SyncAsync();
|
|
|
|
report.Pruned.ShouldBe(1);
|
|
(await harness.First.ListAsync()).Items.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AnItemWithAnUnsentEdit_IsKept_PutBack_AndSaidSo()
|
|
{
|
|
using var harness = await CreateAsync();
|
|
|
|
var entityId = await harness.Second.CreateAsync(Host("old-db"));
|
|
await harness.Second.SyncAsync();
|
|
await harness.First.SyncAsync();
|
|
|
|
await harness.First.UpdateAsync(entityId, Host("old-db, still needed"));
|
|
|
|
await harness.Second.DeleteAsync(entityId);
|
|
await harness.Second.SyncAsync();
|
|
harness.Server.CollectTombstones();
|
|
|
|
var report = await harness.First.SyncAsync();
|
|
|
|
report.Pruned.ShouldBe(0);
|
|
report.Recreated.ShouldBe(1);
|
|
report.NeedsAttention.ShouldBeTrue();
|
|
|
|
(await harness.First.FindAsync(entityId)).Secret.Label.ShouldBe("old-db, still needed");
|
|
harness.Server.Find(entityId).ShouldNotBeNull().IsDeleted.ShouldBeFalse();
|
|
|
|
(await harness.First.ConflictsAsync())
|
|
.ShouldContain(conflict => conflict.EntityId == entityId
|
|
&& conflict.Kind == ConflictKind.RecreatedAfterCollection);
|
|
|
|
await harness.Second.SyncAsync();
|
|
(await harness.Second.FindAsync(entityId)).Secret.Label.ShouldBe("old-db, still needed");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AReadSpreadOverSeveralPasses_SweepsAtTheEnd_AndKeepsWhatWasPushedMeanwhile()
|
|
{
|
|
// Pages per pass are capped, so the read from the beginning can take more than one pass, and what it
|
|
// has seen has to outlast each one. A host made here during it is pushed between the passes.
|
|
using var harness = await CreateAsync(new SyncOptions { PullPageSize = 1, MaxPullPages = 2 });
|
|
|
|
harness.Server.MaxPullLimit = 1;
|
|
|
|
var gone = await harness.Second.CreateAsync(Host("old-db"));
|
|
var kept = new List<Guid>();
|
|
|
|
for (var index = 0; index < 4; index++)
|
|
{
|
|
kept.Add(await harness.Second.CreateAsync(Host($"host-{index}")));
|
|
}
|
|
|
|
// Settled by hand: one pass reads at most two pages, and each needs several.
|
|
for (var pass = 0; pass < 6; pass++)
|
|
{
|
|
await harness.Second.SyncAsync();
|
|
await harness.First.SyncAsync();
|
|
}
|
|
|
|
await harness.Second.DeleteAsync(gone);
|
|
await harness.Second.SyncAsync();
|
|
harness.Server.CollectTombstones();
|
|
|
|
kept.Add(await harness.First.CreateAsync(Host("made-meanwhile")));
|
|
|
|
var pruned = 0;
|
|
|
|
for (var pass = 0; pass < 10; pass++)
|
|
{
|
|
pruned += (await harness.First.SyncAsync()).Pruned;
|
|
|
|
var state = await harness.First.SyncState.ReadAsync(VaultId, TestContext.Current.CancellationToken);
|
|
|
|
if (state.SweepMark is null)
|
|
{
|
|
break;
|
|
}
|
|
|
|
// Mid-sweep, nothing has gone yet.
|
|
pruned.ShouldBe(0);
|
|
(await harness.First.ListAsync()).Items.Count.ShouldBe(6);
|
|
}
|
|
|
|
pruned.ShouldBe(1);
|
|
(await harness.First.ListAsync()).Items.Select(item => item.EntityId).ShouldBe(kept, ignoreOrder: true);
|
|
harness.Server.Find(kept[^1]).ShouldNotBeNull();
|
|
}
|
|
}
|