fix(store-and-forward): standby applies Add/Park/Requeue as upserts so a lost Add self-heals
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +176,67 @@ public class StoreAndForwardStorage
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inserts a message, or updates every mutable column in place if a row with the
|
||||
/// same id already exists (<c>ON CONFLICT(id) DO UPDATE</c>). Used by the standby
|
||||
/// node when applying a replicated Add/Park/Requeue: a Park/Requeue whose original
|
||||
/// Add was lost self-heals (the full message rides in the operation), and a
|
||||
/// duplicate Add (e.g. after an anti-entropy resync) applies newest-wins instead of
|
||||
/// violating the primary key. The original <c>created_at</c> is preserved — it
|
||||
/// orders the retry sweep and must never be overwritten on update.
|
||||
/// </summary>
|
||||
/// <param name="message">The message to insert or update.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task UpsertMessageAsync(StoreAndForwardMessage message)
|
||||
{
|
||||
await using var connection = new SqliteConnection(_connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = @"
|
||||
INSERT INTO sf_messages (id, category, target, payload_json, retry_count, max_retries,
|
||||
retry_interval_ms, created_at, last_attempt_at, status, last_error,
|
||||
origin_instance, execution_id, source_script, parent_execution_id)
|
||||
VALUES (@id, @category, @target, @payload, @retryCount, @maxRetries,
|
||||
@retryIntervalMs, @createdAt, @lastAttempt, @status, @lastError,
|
||||
@origin, @executionId, @sourceScript, @parentExecutionId)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
category = excluded.category,
|
||||
target = excluded.target,
|
||||
payload_json = excluded.payload_json,
|
||||
retry_count = excluded.retry_count,
|
||||
max_retries = excluded.max_retries,
|
||||
retry_interval_ms = excluded.retry_interval_ms,
|
||||
last_attempt_at = excluded.last_attempt_at,
|
||||
status = excluded.status,
|
||||
last_error = excluded.last_error,
|
||||
origin_instance = excluded.origin_instance,
|
||||
execution_id = excluded.execution_id,
|
||||
source_script = excluded.source_script,
|
||||
parent_execution_id = excluded.parent_execution_id";
|
||||
|
||||
cmd.Parameters.AddWithValue("@id", message.Id);
|
||||
cmd.Parameters.AddWithValue("@category", (int)message.Category);
|
||||
cmd.Parameters.AddWithValue("@target", message.Target);
|
||||
cmd.Parameters.AddWithValue("@payload", message.PayloadJson);
|
||||
cmd.Parameters.AddWithValue("@retryCount", message.RetryCount);
|
||||
cmd.Parameters.AddWithValue("@maxRetries", message.MaxRetries);
|
||||
cmd.Parameters.AddWithValue("@retryIntervalMs", message.RetryIntervalMs);
|
||||
cmd.Parameters.AddWithValue("@createdAt", message.CreatedAt.ToString("O"));
|
||||
cmd.Parameters.AddWithValue("@lastAttempt", message.LastAttemptAt.HasValue
|
||||
? message.LastAttemptAt.Value.ToString("O") : DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@status", (int)message.Status);
|
||||
cmd.Parameters.AddWithValue("@lastError", (object?)message.LastError ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@origin", (object?)message.OriginInstanceName ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@executionId",
|
||||
message.ExecutionId.HasValue ? message.ExecutionId.Value.ToString("D") : DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@sourceScript", (object?)message.SourceScript ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@parentExecutionId",
|
||||
message.ParentExecutionId.HasValue ? message.ParentExecutionId.Value.ToString("D") : DBNull.Value);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all messages that are due for retry (Pending status, last attempt older than retry interval).
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user