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(); }