using SQLite;
namespace FieldLogger.Sync;
/// Lifecycle of a queued outbound record.
public enum OutboundStatus
{
/// Ready to publish.
Pending = 0,
/// Published and waiting for an application acknowledgement.
InFlight = 1,
/// Terminally rejected by the cloud (schema/validation/authz) — not retried; surfaced for LOG-7.
RejectedTerminal = 2,
}
///
/// A durably-queued outbound point. Survives app restart (persisted in SQLite) and is
/// released only when the cloud application-level ack references its
/// (SRS-SYN-2/7). Broker PUBACK alone does NOT release it.
///
[Table("outbound")]
public sealed class OutboundMessage
{
[PrimaryKey, AutoIncrement] public int Id { get; set; }
/// UUIDv7 idempotency key. Unique — a re-enqueue of the same point is a no-op.
[Indexed(Name = "ux_pointid", Order = 1, Unique = true)]
public string PointId { get; set; } = "";
public string Topic { get; set; } = "";
public string PayloadJson { get; set; } = "";
public string SchemaVersion { get; set; } = "1";
public long EnqueuedUnixMs { get; set; }
public int AttemptCount { get; set; }
public long NextAttemptUnixMs { get; set; }
public int Status { get; set; } = (int)OutboundStatus.Pending;
public string? LastReason { get; set; }
public static OutboundMessage FromPoint(
PointRecord point,
string topic,
DateTimeOffset enqueuedAt,
string? jobId = null,
string? ticket = null)
{
var payload = point.ToAppLogPayloadUtf8(jobId, ticket);
return new OutboundMessage
{
PointId = point.PointId,
Topic = topic,
PayloadJson = System.Text.Encoding.UTF8.GetString(payload),
SchemaVersion = "1",
EnqueuedUnixMs = enqueuedAt.ToUnixTimeMilliseconds(),
NextAttemptUnixMs = enqueuedAt.ToUnixTimeMilliseconds(),
Status = (int)OutboundStatus.Pending,
};
}
}
/// Durable outbound-queue store. Implementations must survive process restart.
public interface IOutboundStore
{
Task InitAsync();
/// Enqueue idempotently; returns false if a row with this pointId already exists.
Task EnqueueAsync(OutboundMessage message);
/// Pending rows whose next-attempt time has arrived, oldest first.
Task> DequeueReadyAsync(DateTimeOffset now, int max = 50);
/// Release a record — accepted by the cloud. Removes it from the queue.
Task ReleaseAckedAsync(string pointId);
/// Record a retryable failure: bump attempt count, schedule the next attempt.
Task RescheduleAsync(string pointId, DateTimeOffset nextAttempt, string reason);
/// Mark a record published-and-awaiting-ack: reschedule a republish (idempotent via
/// UUID) after the ack window WITHOUT counting it as a failed attempt.
Task TouchAwaitingAckAsync(string pointId, DateTimeOffset nextAttempt);
/// Terminal rejection (LOG-7): keep the row for surfacing, mark it not-retryable.
Task MarkRejectedAsync(string pointId, string reason);
Task PendingCountAsync();
Task> GetRejectedAsync();
}
///
/// SQLite-backed . The DB path is injected (not taken from a
/// platform API) so it is testable headless with a temp file; reopening the same path is a
/// process restart. Idempotent enqueue relies on the unique index on pointId.
///
public sealed class SqliteOutboundStore : IOutboundStore
{
private readonly SQLiteAsyncConnection _db;
public SqliteOutboundStore(string dbPath)
{
_db = new SQLiteAsyncConnection(dbPath,
SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create | SQLiteOpenFlags.SharedCache);
}
public async Task InitAsync()
{
await _db.CreateTableAsync();
// A process crash can leave rows in-flight after the broker accepted them but before
// the application ack was applied. Requeue them for immediate replay; pointId makes
// the replay idempotent. A zero due-time also keeps recovery independent of whichever
// clock implementation the sync engine uses.
await _db.ExecuteAsync(
"UPDATE outbound SET Status = ?, NextAttemptUnixMs = ? WHERE Status = ?",
(int)OutboundStatus.Pending, 0,
(int)OutboundStatus.InFlight);
}
public async Task EnqueueAsync(OutboundMessage message)
{
try
{
await _db.InsertAsync(message);
return true;
}
catch (SQLiteException)
{
// Unique-index violation on pointId → already queued/known. Idempotent no-op.
return false;
}
}
public async Task> DequeueReadyAsync(DateTimeOffset now, int max = 50)
{
long nowMs = now.ToUnixTimeMilliseconds();
return await _db.Table()
.Where(m => (m.Status == (int)OutboundStatus.Pending || m.Status == (int)OutboundStatus.InFlight)
&& m.NextAttemptUnixMs <= nowMs)
.OrderBy(m => m.EnqueuedUnixMs)
.Take(max)
.ToListAsync();
}
public Task ReleaseAckedAsync(string pointId) =>
_db.ExecuteAsync("DELETE FROM outbound WHERE PointId = ?", pointId);
public Task RescheduleAsync(string pointId, DateTimeOffset nextAttempt, string reason) =>
_db.ExecuteAsync(
"UPDATE outbound SET AttemptCount = AttemptCount + 1, NextAttemptUnixMs = ?, LastReason = ? WHERE PointId = ? AND Status = ?",
nextAttempt.ToUnixTimeMilliseconds(), reason, pointId, (int)OutboundStatus.Pending);
public Task TouchAwaitingAckAsync(string pointId, DateTimeOffset nextAttempt) =>
_db.ExecuteAsync(
"UPDATE outbound SET Status = ?, AttemptCount = AttemptCount + 1, NextAttemptUnixMs = ? WHERE PointId = ? AND Status != ?",
(int)OutboundStatus.InFlight, nextAttempt.ToUnixTimeMilliseconds(), pointId,
(int)OutboundStatus.RejectedTerminal);
public Task MarkRejectedAsync(string pointId, string reason) =>
_db.ExecuteAsync(
"UPDATE outbound SET Status = ?, LastReason = ? WHERE PointId = ?",
(int)OutboundStatus.RejectedTerminal, reason, pointId);
public async Task PendingCountAsync() =>
await _db.Table()
.Where(m => m.Status == (int)OutboundStatus.Pending || m.Status == (int)OutboundStatus.InFlight)
.CountAsync();
public async Task> GetRejectedAsync() =>
await _db.Table().Where(m => m.Status == (int)OutboundStatus.RejectedTerminal).ToListAsync();
}