fix(store-and-forward): resolve StoreAndForward-004,005,010,013 — accurate handler-contract doc, conditional sweep writes, reset LastAttemptAt on parked retry, test coverage
This commit is contained in:
@@ -36,8 +36,20 @@ public class StoreAndForwardService
|
||||
private int _retryInProgress;
|
||||
|
||||
/// <summary>
|
||||
/// WP-10: Delivery handler delegate. Returns true on success, throws on transient failure.
|
||||
/// Permanent failures should return false (message will NOT be buffered).
|
||||
/// WP-10: Delivery handler delegate. The return value / exception is interpreted
|
||||
/// the same way on both the immediate-delivery path (<see cref="EnqueueAsync"/>)
|
||||
/// and the background retry path (<c>RetryMessageAsync</c>):
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>true</c> — delivered successfully. The message is
|
||||
/// removed from the buffer (or, on the immediate path, never buffered).</description></item>
|
||||
/// <item><description><c>false</c> — permanent failure. On the immediate path
|
||||
/// the message is NOT buffered; on a retry the message is already buffered and
|
||||
/// is parked immediately (no further retries).</description></item>
|
||||
/// <item><description>throws — transient failure. On the immediate path the
|
||||
/// message is buffered for retry; on a retry the retry count is incremented and
|
||||
/// the message is parked once <see cref="StoreAndForwardMessage.MaxRetries"/> is
|
||||
/// reached.</description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
private readonly Dictionary<StoreAndForwardCategory, Func<StoreAndForwardMessage, Task<bool>>> _deliveryHandlers = new();
|
||||
|
||||
@@ -59,7 +71,9 @@ public class StoreAndForwardService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a delivery handler for a given message category.
|
||||
/// Registers a delivery handler for a given message category. See the
|
||||
/// <c>_deliveryHandlers</c> field documentation for the true/false/throws contract,
|
||||
/// which applies identically on the immediate and retry paths.
|
||||
/// </summary>
|
||||
public void RegisterDeliveryHandler(
|
||||
StoreAndForwardCategory category,
|
||||
@@ -241,11 +255,22 @@ public class StoreAndForwardService
|
||||
return;
|
||||
}
|
||||
|
||||
// Permanent failure on retry — park immediately
|
||||
// Permanent failure on retry — park immediately.
|
||||
// StoreAndForward-005: the sweep observed this row as Pending; only commit
|
||||
// the park if it is still Pending so a concurrent operator action that
|
||||
// moved it (retry/discard) is not silently overwritten.
|
||||
message.Status = StoreAndForwardMessageStatus.Parked;
|
||||
message.LastAttemptAt = DateTimeOffset.UtcNow;
|
||||
message.LastError = "Permanent failure (handler returned false)";
|
||||
await _storage.UpdateMessageAsync(message);
|
||||
var parked = await _storage.UpdateMessageIfStatusAsync(
|
||||
message, StoreAndForwardMessageStatus.Pending);
|
||||
if (!parked)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Message {MessageId} changed status during delivery; sweep park skipped",
|
||||
message.Id);
|
||||
return;
|
||||
}
|
||||
_replication?.ReplicatePark(message);
|
||||
RaiseActivity("Parked", message.Category,
|
||||
$"Permanent failure for {message.Target}: handler returned false");
|
||||
@@ -259,8 +284,18 @@ public class StoreAndForwardService
|
||||
|
||||
if (message.MaxRetries > 0 && message.RetryCount >= message.MaxRetries)
|
||||
{
|
||||
// StoreAndForward-005: conditional park — see the permanent-failure
|
||||
// branch above for rationale.
|
||||
message.Status = StoreAndForwardMessageStatus.Parked;
|
||||
await _storage.UpdateMessageAsync(message);
|
||||
var parked = await _storage.UpdateMessageIfStatusAsync(
|
||||
message, StoreAndForwardMessageStatus.Pending);
|
||||
if (!parked)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Message {MessageId} changed status during delivery; sweep park skipped",
|
||||
message.Id);
|
||||
return;
|
||||
}
|
||||
_replication?.ReplicatePark(message);
|
||||
RaiseActivity("Parked", message.Category,
|
||||
$"Max retries ({message.MaxRetries}) reached for {message.Target}");
|
||||
@@ -270,7 +305,17 @@ public class StoreAndForwardService
|
||||
}
|
||||
else
|
||||
{
|
||||
await _storage.UpdateMessageAsync(message);
|
||||
// StoreAndForward-005: the retry-count increment is also conditional
|
||||
// on the row still being Pending so it cannot clobber an operator
|
||||
// action that ran during the failed delivery.
|
||||
if (!await _storage.UpdateMessageIfStatusAsync(
|
||||
message, StoreAndForwardMessageStatus.Pending))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Message {MessageId} changed status during delivery; sweep retry-count update skipped",
|
||||
message.Id);
|
||||
return;
|
||||
}
|
||||
RaiseActivity("Retried", message.Category,
|
||||
$"Retry {message.RetryCount}/{message.MaxRetries} for {message.Target}: {ex.Message}");
|
||||
}
|
||||
|
||||
@@ -164,6 +164,47 @@ public class StoreAndForwardStorage
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP-10: Updates a message after a delivery attempt, but only if the row is still
|
||||
/// in the expected status. Returns true if the row was updated, false if it had
|
||||
/// already been changed (e.g. an operator retried or discarded the message) and so
|
||||
/// was skipped.
|
||||
///
|
||||
/// StoreAndForward-005: the retry sweep uses this for its state-changing writes so
|
||||
/// it cannot clobber a concurrent operator action (RetryParkedMessageAsync /
|
||||
/// DiscardParkedMessageAsync). Those operator operations are themselves SQL-
|
||||
/// conditional on <c>status = Parked</c>; making the sweep's writes conditional on
|
||||
/// the status the sweep observed closes the sweep-vs-management race rather than
|
||||
/// relying only on the in-process overlapping-sweep guard.
|
||||
/// </summary>
|
||||
public async Task<bool> UpdateMessageIfStatusAsync(
|
||||
StoreAndForwardMessage message,
|
||||
StoreAndForwardMessageStatus expectedStatus)
|
||||
{
|
||||
await using var connection = new SqliteConnection(_connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = @"
|
||||
UPDATE sf_messages
|
||||
SET retry_count = @retryCount,
|
||||
last_attempt_at = @lastAttempt,
|
||||
status = @status,
|
||||
last_error = @lastError
|
||||
WHERE id = @id AND status = @expectedStatus";
|
||||
|
||||
cmd.Parameters.AddWithValue("@id", message.Id);
|
||||
cmd.Parameters.AddWithValue("@retryCount", message.RetryCount);
|
||||
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("@expectedStatus", (int)expectedStatus);
|
||||
|
||||
var rows = await cmd.ExecuteNonQueryAsync();
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP-10: Removes a successfully delivered message.
|
||||
/// </summary>
|
||||
@@ -221,6 +262,13 @@ public class StoreAndForwardStorage
|
||||
|
||||
/// <summary>
|
||||
/// WP-12: Moves a parked message back to pending for retry.
|
||||
///
|
||||
/// StoreAndForward-010: <c>last_attempt_at</c> is reset to NULL so the re-queued
|
||||
/// message is unambiguously due on the next retry sweep. An operator-initiated
|
||||
/// retry means "attempt this again now"; leaving the stale parked timestamp in
|
||||
/// place would make the message's retry timing depend on the configured retry
|
||||
/// interval relative to the original (pre-park) attempt — "try immediately" only
|
||||
/// by accident, and a long interval would instead delay the operator's retry.
|
||||
/// </summary>
|
||||
public async Task<bool> RetryParkedMessageAsync(string messageId)
|
||||
{
|
||||
@@ -230,7 +278,7 @@ public class StoreAndForwardStorage
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = @"
|
||||
UPDATE sf_messages
|
||||
SET status = @pending, retry_count = 0, last_error = NULL
|
||||
SET status = @pending, retry_count = 0, last_error = NULL, last_attempt_at = NULL
|
||||
WHERE id = @id AND status = @parked";
|
||||
|
||||
cmd.Parameters.AddWithValue("@id", messageId);
|
||||
|
||||
Reference in New Issue
Block a user