Public Access
Sync: after a read from the beginning, remove what the server no longer has, and flag an edit that brings a forgotten item back
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.
This commit is contained in:
1 parent
55e5de0ce4
commit
858e36279d
13 files changed
+1048
-63
No files matched your search
@@ -102,6 +102,7 @@ internal sealed class FakeVaultServer : ISyncApi
|
||||
SyncPullRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var issuedUnder = horizon;
|
||||
var after = DecodeCursor(request.Cursor);
|
||||
var limit = Math.Clamp(request.Limit ?? MaxPullLimit, 1, MaxPullLimit);
|
||||
|
||||
@@ -128,7 +129,7 @@ internal sealed class FakeVaultServer : ISyncApi
|
||||
|
||||
return Task.FromResult(new SyncPullResponse(
|
||||
[.. page.Select(entry => Hydrate(entry))],
|
||||
EncodeCursor(next),
|
||||
EncodeCursor(next, issuedUnder),
|
||||
hasMore,
|
||||
Now,
|
||||
KeyGeneration));
|
||||
@@ -206,7 +207,25 @@ internal sealed class FakeVaultServer : ISyncApi
|
||||
internal Row? Find(Guid entityId, SyncEntityType entityType = SyncEntityType.Host) =>
|
||||
rows.TryGetValue((entityType, entityId), out var row) ? row : null;
|
||||
|
||||
private long Head => log.Count == 0 ? 0 : log[^1].Sequence;
|
||||
/// <summary>
|
||||
/// Forgets every deletion, as the real server does once one is past its retention: the tombstone and
|
||||
/// its log entries go, and a cursor from before them is refused from then on.
|
||||
/// </summary>
|
||||
internal void CollectTombstones()
|
||||
{
|
||||
foreach (var (key, row) in rows.Where(entry => entry.Value.IsDeleted).ToList())
|
||||
{
|
||||
rows.Remove(key);
|
||||
log.RemoveAll(entry => entry.EntityType == key.Type && entry.EntityId == key.EntityId);
|
||||
horizon = Math.Max(horizon, row.ChangeSequence);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The newest sequence ever issued, which a collection must not take back.</summary>
|
||||
private long Head => Math.Max(log.Count == 0 ? 0 : log[^1].Sequence, horizon);
|
||||
|
||||
/// <summary>The last deletion forgotten; see <see cref="CollectTombstones"/>.</summary>
|
||||
private long horizon;
|
||||
|
||||
// ---- The decision table ----
|
||||
|
||||
@@ -481,8 +500,12 @@ internal sealed class FakeVaultServer : ISyncApi
|
||||
/// rejects. The real server HMAC-tags them; the property that matters to the client is only that it
|
||||
/// must round-trip what it is given.
|
||||
/// </remarks>
|
||||
private static string EncodeCursor(long sequence) =>
|
||||
"fake-v1:" + sequence.ToString(CultureInfo.InvariantCulture);
|
||||
/// <remarks>
|
||||
/// Carries the horizon it was read under, as the real one does, so a read from the beginning that started
|
||||
/// after a collection is not refused on its own early pages. See <c>DodoSSH.Domain.Sync.SyncCursor</c>.
|
||||
/// </remarks>
|
||||
private static string EncodeCursor(long sequence, long issuedUnder = 0) =>
|
||||
string.Create(CultureInfo.InvariantCulture, $"fake-v1:{sequence}@{issuedUnder}");
|
||||
|
||||
private long DecodeCursor(string? cursor)
|
||||
{
|
||||
@@ -502,12 +525,26 @@ internal sealed class FakeVaultServer : ISyncApi
|
||||
return 0;
|
||||
}
|
||||
|
||||
var at = cursor.IndexOf('@', StringComparison.Ordinal);
|
||||
|
||||
if (!cursor.StartsWith("fake-v1:", StringComparison.Ordinal)
|
||||
|| !long.TryParse(cursor.AsSpan(8), CultureInfo.InvariantCulture, out var sequence))
|
||||
|| at < 0
|
||||
|| !long.TryParse(cursor.AsSpan(8, at - 8), CultureInfo.InvariantCulture, out var sequence)
|
||||
|| !long.TryParse(cursor.AsSpan(at + 1), CultureInfo.InvariantCulture, out var issuedUnder))
|
||||
{
|
||||
throw new InvalidOperationException($"A client sent a cursor it should not have: '{cursor}'.");
|
||||
}
|
||||
|
||||
if (sequence < horizon && issuedUnder < horizon)
|
||||
{
|
||||
// From before a deletion this server no longer remembers, so it could never be told of it.
|
||||
throw new DodoSshApiException(
|
||||
HttpStatusCode.BadRequest,
|
||||
ProblemCodes.InvalidCursor,
|
||||
"The server returned 400: The sync cursor is not valid for this vault. "
|
||||
+ "Resync from the beginning.");
|
||||
}
|
||||
|
||||
return sequence;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user