fix(store-and-forward): page retry sweep per (category,target) lane; drop dead GetAllMessagesAsync (review 2026-10-21 02/C2, 02/U2)

This commit is contained in:
Joseph Doherty
2026-10-22 08:28:01 -04:00
parent e968566e9d
commit 76f7960a06
2 changed files with 52 additions and 46 deletions
@@ -123,34 +123,6 @@ public class StoreAndForwardStorage
await cmd.ExecuteNonQueryAsync();
}
/// <summary>
/// Returns every buffered message regardless of status, oldest-first, up to
/// <paramref name="limit"/> rows, plus a flag indicating the buffer holds more
/// than that. Anti-entropy groundwork: the active node snapshots its buffer for
/// a standby resync. One extra row is fetched (<c>limit + 1</c>) purely to
/// compute the truncation flag without a second COUNT round-trip.
/// </summary>
/// <param name="limit">Maximum rows to return; must be positive.</param>
/// <returns>The oldest-first page (at most <paramref name="limit"/> rows) and whether more rows exist beyond it.</returns>
public async Task<(List<StoreAndForwardMessage> Messages, bool Truncated)> GetAllMessagesAsync(int limit)
{
await using var connection = OpenConnection();
await using var cmd = connection.CreateCommand();
cmd.CommandText = @"
SELECT 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
FROM sf_messages
ORDER BY created_at ASC
LIMIT @limitPlusOne";
cmd.Parameters.AddWithValue("@limitPlusOne", limit + 1);
var rows = await ReadMessagesAsync(cmd);
var truncated = rows.Count > limit;
return (rows.Take(limit).ToList(), truncated);
}
/// <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
@@ -224,23 +196,41 @@ public class StoreAndForwardStorage
await using var connection = OpenConnection();
await using var cmd = connection.CreateCommand();
cmd.CommandText = @"
SELECT id, category, target, payload_json, retry_count, max_retries,
const string cols = @"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
FROM sf_messages
WHERE status = @pending
execution_id, source_script, parent_execution_id";
const string due = @"status = @pending
AND (last_attempt_at_ms IS NULL
OR retry_interval_ms = 0
OR (@nowMs - last_attempt_at_ms) >= retry_interval_ms)
ORDER BY created_at ASC";
OR (@nowMs - last_attempt_at_ms) >= retry_interval_ms)";
// Bound the sweep batch (oldest-first). 0 = unlimited (legacy).
if (limit > 0)
{
cmd.CommandText += "\n LIMIT @limit";
// Page FAIRLY across (category, target) lanes: rank each due row within its
// lane (oldest-first) and take the page by rank, so one dead target holding
// hundreds of due rows contributes one row per "round" like every other
// lane instead of owning the whole page. Within a lane rank order == created_at
// order, so per-target FIFO is preserved for the service's GroupBy.
cmd.CommandText = $@"
SELECT {cols}
FROM (
SELECT {cols},
ROW_NUMBER() OVER (PARTITION BY category, target ORDER BY created_at ASC) AS lane_rank
FROM sf_messages
WHERE {due}
)
ORDER BY lane_rank ASC, created_at ASC
LIMIT @limit";
cmd.Parameters.AddWithValue("@limit", limit);
}
else
{
cmd.CommandText = $@"
SELECT {cols}
FROM sf_messages
WHERE {due}
ORDER BY created_at ASC";
}
cmd.Parameters.AddWithValue("@pending", (int)StoreAndForwardMessageStatus.Pending);
cmd.Parameters.AddWithValue("@nowMs", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());