fix(store-and-forward): standby applies Add/Park/Requeue as upserts so a lost Add self-heals

This commit is contained in:
Joseph Doherty
2026-07-08 20:42:39 -04:00
parent 9fe3452790
commit 283ce0647c
3 changed files with 128 additions and 3 deletions
@@ -101,6 +101,13 @@ public class ReplicationService
/// <summary>
/// Applies a replicated operation received from the active node.
/// Used by the standby node to keep its SQLite in sync.
///
/// Add/Park/Requeue are applied as <b>upserts</b> (<see cref="StoreAndForwardStorage.UpsertMessageAsync"/>),
/// not blind INSERT/UPDATE: the full message rides in every one of those operations,
/// so a Park/Requeue whose original Add was lost (fire-and-forget replication is
/// best-effort) self-heals by materialising the row, and a duplicate Add (e.g.
/// re-issued by an anti-entropy resync) applies newest-wins instead of throwing a
/// primary-key violation. Remove is a plain delete.
/// </summary>
/// <param name="operation">The replication operation to apply.</param>
/// <param name="storage">The standby node's store-and-forward storage to update.</param>
@@ -112,7 +119,7 @@ public class ReplicationService
switch (operation.OperationType)
{
case ReplicationOperationType.Add when operation.Message != null:
await storage.EnqueueAsync(operation.Message);
await storage.UpsertMessageAsync(operation.Message);
break;
case ReplicationOperationType.Remove:
@@ -121,13 +128,13 @@ public class ReplicationService
case ReplicationOperationType.Park when operation.Message != null:
operation.Message.Status = StoreAndForwardMessageStatus.Parked;
await storage.UpdateMessageAsync(operation.Message);
await storage.UpsertMessageAsync(operation.Message);
break;
case ReplicationOperationType.Requeue when operation.Message != null:
operation.Message.Status = StoreAndForwardMessageStatus.Pending;
operation.Message.RetryCount = 0;
await storage.UpdateMessageAsync(operation.Message);
await storage.UpsertMessageAsync(operation.Message);
break;
}
}