Server: collect tombstones past their retention, compact superseded change entries, and refuse cursors from before a collection

This commit is contained in:
jaap-jan committed 2026-10-02 17:18:22 +02:00
1 parent 656a33b4b1
commit 8cb1457ca9
10 files changed
+2438 -2

No files matched your search

+26 -2
View File
@@ -62,6 +62,14 @@ internal sealed class SyncService(
changes.RemoveAt(changes.Count - 1);
}
// ◆ After the read, not before it. The collector deletes a tombstone's entries and raises the horizon
// in one transaction, so a read that already missed an entry is followed by a horizon that says so;
// checked first, the two could straddle a collection and the deletion would go unnoticed for good.
if (afterSequence > 0 && afterSequence < await HorizonAsync(vault.Id, cancellationToken).ConfigureAwait(false))
{
throw new InvalidCursorException();
}
var hydrated = await HydrateAsync(vault, changes, cancellationToken).ConfigureAwait(false);
// When nothing came back the cursor must not move, or a concurrent write landing between
@@ -622,14 +630,30 @@ internal sealed class SyncService(
/// <summary>The kind behind a change-log entry, or null for a type this server does not synchronise.</summary>
private static IItemKind? KindForChange(ChangeEntityType type) => ItemKinds.For(ToWire(type));
private async Task<long> CurrentHeadAsync(Guid vaultId, CancellationToken cancellationToken) =>
await database.VaultChanges
/// <remarks>
/// Never below the vault's horizon: collecting a tombstone can take the newest entries with it, and a
/// head reported lower than a cursor the client already holds would rewind it under the horizon and
/// cost a full resync for nothing.
/// </remarks>
private async Task<long> CurrentHeadAsync(Guid vaultId, CancellationToken cancellationToken)
{
var head = await database.VaultChanges
.Where(c => c.VaultId == vaultId)
.OrderByDescending(c => c.Sequence)
.Select(c => c.Sequence)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return Math.Max(head, await HorizonAsync(vaultId, cancellationToken).ConfigureAwait(false));
}
/// <summary>The vault's horizon as the database holds it now, not as the request loaded it.</summary>
private Task<long> HorizonAsync(Guid vaultId, CancellationToken cancellationToken) =>
database.Vaults
.Where(v => v.Id == vaultId)
.Select(v => v.SyncHorizon)
.SingleAsync(cancellationToken);
private static SyncPushResult Invalid(SyncPushOperation operation, string detail) =>
new(
operation.OperationId,