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

+11
View File
@@ -350,6 +350,17 @@ internal sealed class SyncStateRow
/// decide which edit wins.
/// </remarks>
public long ServerTimeSkewMs { get; set; }
/// <summary>
/// The item types <see cref="Cursor"/> was read under, as this build names them; null before this was kept.
/// </summary>
/// <remarks>
/// A pull asks for the types this build can apply, and the cursor it gets back is past every change it
/// read — including the changes of types it did not ask for. So a build that adds a type to that list
/// and carries on from the old cursor never sees the items of that type made before it arrived. Kept
/// beside the cursor so a change of list starts the pull over; see <c>SyncEngine.PullAsync</c>.
/// </remarks>
public string? PulledTypes { get; set; }
}
/// <summary>Something the merge had to override, or an item that could not be processed.</summary>
@@ -0,0 +1,469 @@
// <auto-generated />
using System;
using DodoSSH.Client.Storage;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace DodoSSH.Client.Storage.Migrations
{
[DbContext(typeof(ClientCacheContext))]
[Migration("20261002104920_AddSyncStatePulledTypes")]
partial class AddSyncStatePulledTypes
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
modelBuilder.Entity("DodoSSH.Client.Storage.CachedItemRow", b =>
{
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<int>("EntityType")
.HasColumnType("INTEGER")
.HasColumnName("entity_type");
b.Property<Guid>("EntityId")
.HasColumnType("TEXT")
.HasColumnName("entity_id");
b.Property<byte>("AadVersion")
.HasColumnType("INTEGER")
.HasColumnName("aad_version");
b.Property<long>("ChangeSequence")
.HasColumnType("INTEGER")
.HasColumnName("change_sequence");
b.Property<Guid?>("DataKeyId")
.HasColumnType("TEXT")
.HasColumnName("data_key_id");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER")
.HasColumnName("is_deleted");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<byte[]>("Payload")
.HasColumnType("BLOB")
.HasColumnName("payload");
b.Property<byte[]>("ProtectedFields")
.HasColumnType("BLOB")
.HasColumnName("protected_fields");
b.Property<long>("UpdatedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("updated_at_utc");
b.Property<int>("Version")
.HasColumnType("INTEGER")
.HasColumnName("version");
b.Property<byte[]>("WrappedDataKey")
.HasColumnType("BLOB")
.HasColumnName("wrapped_data_key");
b.HasKey("VaultId", "EntityType", "EntityId")
.HasName("pk_item");
b.HasIndex("VaultId", "ChangeSequence")
.HasDatabaseName("ix_item_vault_id_change_sequence");
b.HasIndex("VaultId", "EntityType")
.HasDatabaseName("ix_item_vault_id_entity_type");
b.ToTable("item", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultKeyWrapRow", b =>
{
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<byte[]>("WrappedKey")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("wrapped_key");
b.HasKey("VaultId", "KeyGeneration")
.HasName("pk_vault_key_wrap");
b.ToTable("vault_key_wrap", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultRow", b =>
{
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<bool>("Hidden")
.HasColumnType("INTEGER")
.HasColumnName("hidden");
b.Property<bool>("IsPersonal")
.HasColumnType("INTEGER")
.HasColumnName("is_personal");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("Permissions")
.HasColumnType("INTEGER")
.HasColumnName("permissions");
b.Property<bool>("RekeyRequired")
.HasColumnType("INTEGER")
.HasColumnName("rekey_required");
b.Property<Guid?>("TeamId")
.HasColumnType("TEXT")
.HasColumnName("team_id");
b.Property<long>("UpdatedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("updated_at_utc");
b.Property<byte[]>("WrappedVaultKey")
.HasColumnType("BLOB")
.HasColumnName("wrapped_vault_key");
b.HasKey("VaultId")
.HasName("pk_vault");
b.ToTable("vault", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.ConflictRow", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Acknowledged")
.HasColumnType("INTEGER")
.HasColumnName("acknowledged");
b.Property<byte[]>("Detail")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("detail");
b.Property<long>("DetectedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("detected_at_utc");
b.Property<Guid>("EntityId")
.HasColumnType("TEXT")
.HasColumnName("entity_id");
b.Property<int>("EntityType")
.HasColumnType("INTEGER")
.HasColumnName("entity_type");
b.Property<int>("Kind")
.HasColumnType("INTEGER")
.HasColumnName("kind");
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.HasKey("Id")
.HasName("pk_conflict");
b.HasIndex("VaultId", "Acknowledged")
.HasDatabaseName("ix_conflict_vault_id_acknowledged");
b.HasIndex("VaultId", "EntityType", "EntityId")
.HasDatabaseName("ix_conflict_vault_id_entity_type_entity_id");
b.ToTable("conflict", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.OutboxRow", b =>
{
b.Property<long>("Sequence")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("sequence");
b.Property<byte>("AadVersion")
.HasColumnType("INTEGER")
.HasColumnName("aad_version");
b.Property<byte?>("AncestorAadVersion")
.HasColumnType("INTEGER")
.HasColumnName("ancestor_aad_version");
b.Property<Guid?>("AncestorDataKeyId")
.HasColumnType("TEXT")
.HasColumnName("ancestor_data_key_id");
b.Property<uint?>("AncestorKeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("ancestor_key_generation");
b.Property<byte[]>("AncestorPayload")
.HasColumnType("BLOB")
.HasColumnName("ancestor_payload");
b.Property<byte[]>("AncestorProtectedFields")
.HasColumnType("BLOB")
.HasColumnName("ancestor_protected_fields");
b.Property<int?>("AncestorVersion")
.HasColumnType("INTEGER")
.HasColumnName("ancestor_version");
b.Property<byte[]>("AncestorWrappedDataKey")
.HasColumnType("BLOB")
.HasColumnName("ancestor_wrapped_data_key");
b.Property<int>("Attempts")
.HasColumnType("INTEGER")
.HasColumnName("attempts");
b.Property<Guid?>("DataKeyId")
.HasColumnType("TEXT")
.HasColumnName("data_key_id");
b.Property<Guid>("EntityId")
.HasColumnType("TEXT")
.HasColumnName("entity_id");
b.Property<int>("EntityType")
.HasColumnType("INTEGER")
.HasColumnName("entity_type");
b.Property<int?>("ExpectedVersion")
.HasColumnType("INTEGER")
.HasColumnName("expected_version");
b.Property<bool>("IsParked")
.HasColumnType("INTEGER")
.HasColumnName("is_parked");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<string>("LastError")
.HasColumnType("TEXT")
.HasColumnName("last_error");
b.Property<int>("Operation")
.HasColumnType("INTEGER")
.HasColumnName("operation");
b.Property<Guid>("OperationId")
.HasColumnType("TEXT")
.HasColumnName("operation_id");
b.Property<byte[]>("Payload")
.HasColumnType("BLOB")
.HasColumnName("payload");
b.Property<byte[]>("ProtectedFields")
.HasColumnType("BLOB")
.HasColumnName("protected_fields");
b.Property<long>("QueuedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("queued_at_utc");
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<byte[]>("WrappedDataKey")
.HasColumnType("BLOB")
.HasColumnName("wrapped_data_key");
b.HasKey("Sequence")
.HasName("pk_outbox");
b.HasIndex("OperationId")
.IsUnique()
.HasDatabaseName("ix_outbox_operation_id");
b.HasIndex("VaultId", "EntityType", "EntityId")
.IsUnique()
.HasDatabaseName("ix_outbox_vault_id_entity_type_entity_id");
b.HasIndex("VaultId", "IsParked", "Sequence")
.HasDatabaseName("ix_outbox_vault_id_is_parked_sequence");
b.ToTable("outbox", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.RememberedSignInRow", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<byte[]>("SealedRefreshToken")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("sealed_refresh_token");
b.Property<long>("UpdatedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("updated_at_utc");
b.HasKey("Id")
.HasName("pk_remembered_sign_in");
b.ToTable("remembered_sign_in", null, t =>
{
t.HasCheckConstraint("ck_remembered_sign_in_singleton", "id = 1");
});
});
modelBuilder.Entity("DodoSSH.Client.Storage.SyncStateRow", b =>
{
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<string>("Cursor")
.HasColumnType("TEXT")
.HasColumnName("cursor");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<long?>("LastPulledAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("last_pulled_at_utc");
b.Property<long?>("LastPushedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("last_pushed_at_utc");
b.Property<string>("PulledTypes")
.HasColumnType("TEXT")
.HasColumnName("pulled_types");
b.Property<long>("ServerTimeSkewMs")
.HasColumnType("INTEGER")
.HasColumnName("server_time_skew_ms");
b.HasKey("VaultId")
.HasName("pk_sync_state");
b.ToTable("sync_state", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.UnlockMaterialRow", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<Guid?>("DeviceId")
.HasColumnType("TEXT")
.HasColumnName("device_id");
b.Property<byte[]>("DeviceWrappedPrivateKey")
.HasColumnType("BLOB")
.HasColumnName("device_wrapped_private_key");
b.Property<string>("DisplayName")
.HasColumnType("TEXT")
.HasColumnName("display_name");
b.Property<string>("Email")
.HasColumnType("TEXT")
.HasColumnName("email");
b.Property<string>("Issuer")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("issuer");
b.Property<string>("KdfAlgorithm")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("kdf_algorithm");
b.Property<int>("KdfMemoryKibibytes")
.HasColumnType("INTEGER")
.HasColumnName("kdf_memory_kibibytes");
b.Property<int>("KdfParallelism")
.HasColumnType("INTEGER")
.HasColumnName("kdf_parallelism");
b.Property<int>("KdfPasses")
.HasColumnType("INTEGER")
.HasColumnName("kdf_passes");
b.Property<byte[]>("KdfSalt")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("kdf_salt");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<string>("ServerUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("server_url");
b.Property<string>("Subject")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subject");
b.Property<long>("UpdatedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("updated_at_utc");
b.Property<Guid>("UserId")
.HasColumnType("TEXT")
.HasColumnName("user_id");
b.Property<byte[]>("WrappedPrivateKey")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("wrapped_private_key");
b.HasKey("Id")
.HasName("pk_unlock_material");
b.ToTable("unlock_material", null, t =>
{
t.HasCheckConstraint("ck_unlock_material_singleton", "id = 1");
});
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DodoSSH.Client.Storage.Migrations
{
/// <inheritdoc />
public partial class AddSyncStatePulledTypes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "pulled_types",
table: "sync_state",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "pulled_types",
table: "sync_state");
}
}
}
@@ -362,6 +362,10 @@ namespace DodoSSH.Client.Storage.Migrations
.HasColumnType("INTEGER")
.HasColumnName("last_pushed_at_utc");
b.Property<string>("PulledTypes")
.HasColumnType("TEXT")
.HasColumnName("pulled_types");
b.Property<long>("ServerTimeSkewMs")
.HasColumnType("INTEGER")
.HasColumnName("server_time_skew_ms");
+31 -10
View File
@@ -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)
+3 -1
View File
@@ -183,13 +183,15 @@ public sealed record PendingOperation(
/// <param name="LastPulledAt">When the last pull completed.</param>
/// <param name="LastPushedAt">When the last push completed.</param>
/// <param name="ServerTimeSkewMs">Observed clock difference, recorded and never acted on.</param>
/// <param name="PulledTypes">The item types the cursor was read under; see <c>SyncStateRow.PulledTypes</c>.</param>
public sealed record StoredSyncState(
Guid VaultId,
string? Cursor,
uint KeyGeneration,
DateTimeOffset? LastPulledAt = null,
DateTimeOffset? LastPushedAt = null,
long ServerTimeSkewMs = 0);
long ServerTimeSkewMs = 0,
string? PulledTypes = null);
/// <summary>Something the merge overrode, or an item that could not be processed.</summary>
/// <param name="Id">This record's own id, which its sealed detail is bound to.</param>
+3 -1
View File
@@ -46,7 +46,8 @@ public sealed class SyncStateStore(IDbContextFactory<ClientCacheContext> context
row.KeyGeneration,
row.LastPulledAtUtc,
row.LastPushedAtUtc,
row.ServerTimeSkewMs);
row.ServerTimeSkewMs,
row.PulledTypes);
}
/// <summary>Records a vault's position.</summary>
@@ -72,6 +73,7 @@ public sealed class SyncStateStore(IDbContextFactory<ClientCacheContext> context
row.LastPulledAtUtc = state.LastPulledAt;
row.LastPushedAtUtc = state.LastPushedAt;
row.ServerTimeSkewMs = state.ServerTimeSkewMs;
row.PulledTypes = state.PulledTypes;
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}