Public Access
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:
1 parent
03c8e8589a
commit
f332d9a56f
12 files changed
+757
-31
No files matched your search
@@ -44,6 +44,14 @@ public sealed record QueuedChange(
|
||||
/// ancestor that is this client's own earlier edit. That merge finds no disagreement, so it converges
|
||||
/// on the newest state with nothing for the user to arbitrate.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>Which is only true if an answer is applied to the operation it answers.</b> A push reads a row,
|
||||
/// sends it, and acts on the reply some time later; a user who saves in between coalesces a new state into
|
||||
/// that same row. So <see cref="CompleteAsync"/>, <see cref="ReviseAsync"/> and <see cref="ParkAsync"/>
|
||||
/// take the operation that was sent and do nothing when the row has since become a different one. Keyed by
|
||||
/// sequence alone, an acknowledgement of the earlier state deleted the row holding the later one, and the
|
||||
/// edit was gone with nothing to say so.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class OutboxStore(
|
||||
IDbContextFactory<ClientCacheContext> contexts,
|
||||
@@ -116,7 +124,7 @@ public sealed class OutboxStore(
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task<PendingOperation?> ReviseAsync(
|
||||
long sequence,
|
||||
PendingOperation revising,
|
||||
SyncOperation operation,
|
||||
int? expectedVersion,
|
||||
EncryptedPayload? payload,
|
||||
@@ -127,12 +135,18 @@ public sealed class OutboxStore(
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
ArgumentNullException.ThrowIfNull(revising);
|
||||
|
||||
var row = await context.Set<OutboxRow>()
|
||||
.SingleOrDefaultAsync(r => r.Sequence == sequence, cancellationToken)
|
||||
.SingleOrDefaultAsync(
|
||||
r => r.Sequence == revising.Sequence && r.OperationId == revising.OperationId,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (row is null)
|
||||
{
|
||||
// Completed, or replaced by a newer edit since it was read. Either way the merge was of a state
|
||||
// that is no longer the one queued, and the next push will meet the server with the current one.
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -233,16 +247,19 @@ public sealed class OutboxStore(
|
||||
|
||||
/// <summary>Records that an operation has been sent, so a repeated failure can be noticed.</summary>
|
||||
public Task MarkDispatchedAsync(long sequence, CancellationToken cancellationToken) =>
|
||||
UpdateAsync(sequence, row => row.Attempts++, cancellationToken);
|
||||
UpdateAsync(sequence, operationId: null, row => row.Attempts++, cancellationToken);
|
||||
|
||||
/// <summary>Removes an operation the server accepted.</summary>
|
||||
public async Task<bool> CompleteAsync(long sequence, CancellationToken cancellationToken)
|
||||
/// <summary>Removes an operation the server accepted, unless a newer edit has replaced it since.</summary>
|
||||
/// <returns>Whether a row was removed.</returns>
|
||||
public async Task<bool> CompleteAsync(PendingOperation operation, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(operation);
|
||||
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var removed = await context.Set<OutboxRow>()
|
||||
.Where(r => r.Sequence == sequence)
|
||||
.Where(r => r.Sequence == operation.Sequence && r.OperationId == operation.OperationId)
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
@@ -257,9 +274,10 @@ public sealed class OutboxStore(
|
||||
/// invalid, or the caller no longer having permission. Retrying either would spin forever and, far
|
||||
/// worse, would block every change queued behind it in a vault the user can still write to.
|
||||
/// </remarks>
|
||||
public Task ParkAsync(long sequence, string reason, CancellationToken cancellationToken) =>
|
||||
public Task ParkAsync(PendingOperation operation, string reason, CancellationToken cancellationToken) =>
|
||||
UpdateAsync(
|
||||
sequence,
|
||||
operation.Sequence,
|
||||
operation.OperationId,
|
||||
row =>
|
||||
{
|
||||
row.IsParked = true;
|
||||
@@ -269,7 +287,7 @@ public sealed class OutboxStore(
|
||||
|
||||
/// <summary>Records a transient failure without parking the operation.</summary>
|
||||
public Task RecordFailureAsync(long sequence, string reason, CancellationToken cancellationToken) =>
|
||||
UpdateAsync(sequence, row => row.LastError = reason, cancellationToken);
|
||||
UpdateAsync(sequence, operationId: null, row => row.LastError = reason, cancellationToken);
|
||||
|
||||
private static Task<OutboxRow?> FindRowAsync(
|
||||
ClientCacheContext context,
|
||||
@@ -302,6 +320,7 @@ public sealed class OutboxStore(
|
||||
|
||||
private async Task UpdateAsync(
|
||||
long sequence,
|
||||
Guid? operationId,
|
||||
Action<OutboxRow> mutate,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -309,7 +328,9 @@ public sealed class OutboxStore(
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var row = await context.Set<OutboxRow>()
|
||||
.SingleOrDefaultAsync(r => r.Sequence == sequence, cancellationToken)
|
||||
.SingleOrDefaultAsync(
|
||||
r => r.Sequence == sequence && (operationId == null || r.OperationId == operationId),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (row is null)
|
||||
|
||||
Reference in new issue
Block a user