Stop a finished push deleting the edit made while it was in flight, and pull a newly synced type from the start

- The outbox coalesces a new save into the row a push already read, under a new operation id, but
  Complete, Revise and Park found the row by sequence alone. The acknowledgement of the earlier state
  deleted the row holding the later one and the edit was gone without a word. They now take the
  PendingOperation and act only while the row still holds it; a later edit is rebased onto the version
  just accepted, so it pushes cleanly instead of conflicting with its own earlier save.
- A pull filtered by type moves the cursor past changes of types it did not ask for, so a build that
  added a type (SpelunkKey, ObjectStore) never received the items of that type made before it.
  sync_state.pulled_types records the list a cursor was read under, and a different list starts the
  pull over once. Migration AddSyncStatePulledTypes.
This commit is contained in:
jaap-jan committed 2026-10-02 13:16:47 +02:00
1 parent 03c8e8589a
commit f332d9a56f
12 files changed
+757 -31

No files matched your search

@@ -173,7 +173,7 @@ public sealed class OutboxStoreTests : IAsyncLifetime
var merged = new StoredAncestor(4, Payload(seed: 20), new SyncPlaintextFields());
var revised = await harness.Outbox.ReviseAsync(
queued.Sequence,
queued,
SyncOperation.Upsert,
expectedVersion: 4,
Payload(seed: 30),
@@ -199,7 +199,7 @@ public sealed class OutboxStoreTests : IAsyncLifetime
var queued = await harness.Outbox.QueueAsync(Change(parked), Token);
await harness.Outbox.QueueAsync(Change(healthy), Token);
await harness.Outbox.ParkAsync(queued.Sequence, "Entity type not supported.", Token);
await harness.Outbox.ParkAsync(queued, "Entity type not supported.", Token);
var pending = await harness.Outbox.TakeAsync(VaultId, 10, Token);
pending.ShouldHaveSingleItem().EntityId.ShouldBe(healthy);
@@ -216,7 +216,7 @@ public sealed class OutboxStoreTests : IAsyncLifetime
var entityId = Guid.CreateVersion7();
var queued = await harness.Outbox.QueueAsync(Change(entityId), Token);
await harness.Outbox.ParkAsync(queued.Sequence, "nope", Token);
await harness.Outbox.ParkAsync(queued, "nope", Token);
var requeued = await harness.Outbox.QueueAsync(Change(entityId, seed: 5), Token);
@@ -230,11 +230,42 @@ public sealed class OutboxStoreTests : IAsyncLifetime
{
var queued = await harness.Outbox.QueueAsync(Change(Guid.CreateVersion7()), Token);
(await harness.Outbox.CompleteAsync(queued.Sequence, Token)).ShouldBeTrue();
(await harness.Outbox.CompleteAsync(queued, Token)).ShouldBeTrue();
(await harness.Outbox.TakeAsync(VaultId, 10, Token)).ShouldBeEmpty();
// Idempotent: a drain that retries after a crash must not fail on an already-cleared row.
(await harness.Outbox.CompleteAsync(queued.Sequence, Token)).ShouldBeFalse();
(await harness.Outbox.CompleteAsync(queued, Token)).ShouldBeFalse();
}
/// <summary>
/// An answer to an operation that has since been replaced by a newer edit applies to nothing.
/// </summary>
/// <remarks>
/// The newer edit coalesced into the same row, so the sequence still matches; only the operation id says
/// the row is no longer the one that was sent. Completing, revising or parking it would act on the newer
/// edit on the strength of an answer about the older one.
/// </remarks>
[Fact]
public async Task AnAnswerToAReplacedOperation_LeavesTheNewerEditAlone()
{
var entityId = Guid.CreateVersion7();
var sent = await harness.Outbox.QueueAsync(Change(entityId, seed: 1), Token);
var newer = await harness.Outbox.QueueAsync(Change(entityId, seed: 2), Token);
newer.Sequence.ShouldBe(sent.Sequence, "the newer edit coalesces into the same row");
(await harness.Outbox.CompleteAsync(sent, Token)).ShouldBeFalse();
await harness.Outbox.ParkAsync(sent, "refused", Token);
(await harness.Outbox.ReviseAsync(
sent, sent.Operation, sent.ExpectedVersion, sent.Payload, sent.Fields, sent.Ancestor, Token))
.ShouldBeNull();
var left = (await harness.Outbox.TakeAsync(VaultId, 10, Token)).ShouldHaveSingleItem();
left.OperationId.ShouldBe(newer.OperationId);
left.IsParked.ShouldBeFalse();
}
[Fact]