// ===================================================================== // RETAIL PHARMACY PLATFORM — Counter Helper // SpoolQueue.cs : disk-backed print queue // // THE RULE THIS FILE EXISTS TO ENFORCE: // a printer problem must never stop a chemist from billing. // // Out of paper, jammed, switched off, cable pulled by a cleaner — all // routine, all mid-rush. The billing screen posts a job and gets an // immediate acceptance; this queue deals with the printer afterwards // and shows a quiet indicator, never a modal dialog. A modal error at // a busy counter is worse than a missing slip: the slip can be // reprinted, the queue behind the customer cannot. // // Jobs live as files so a service restart, or a power cut, loses // nothing. // ===================================================================== using System; using System.Collections.Concurrent; using System.IO; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; namespace CounterHelper; public sealed class PrintJob { [JsonPropertyName("job_id")] public string JobId { get; set; } = ""; [JsonPropertyName("printer")] public string Printer { get; set; } = ""; [JsonPropertyName("device_type")] public string DeviceType { get; set; } = "DOTMATRIX"; [JsonPropertyName("copies")] public int Copies { get; set; } = 1; [JsonPropertyName("payload_b64")] public string PayloadB64 { get; set; } = ""; [JsonPropertyName("doc_type")] public string DocType { get; set; } = "SALE"; [JsonPropertyName("doc_no")] public string DocNo { get; set; } = ""; [JsonPropertyName("priority")] public string Priority { get; set; } = "IMMEDIATE"; [JsonPropertyName("attempts")] public int Attempts { get; set; } [JsonPropertyName("last_error")] public string? LastError { get; set; } [JsonPropertyName("queued_at")] public DateTime QueuedAt { get; set; } = DateTime.UtcNow; [JsonPropertyName("next_try_at")] public DateTime NextTryAt { get; set; } = DateTime.UtcNow; [JsonPropertyName("status")] public string Status { get; set; } = "QUEUED"; } public sealed class SpoolQueue : IDisposable { // 2s, 5s, 15s. Three attempts covers the common causes — paper // reloaded, printer switched back on, cable pushed in. Beyond that a // human has to look at it, and hammering the spooler helps nobody. private static readonly int[] BackoffSeconds = { 2, 5, 15 }; private readonly string _dir; private readonly Action _telemetry; private readonly ConcurrentDictionary _seen = new(); private readonly CancellationTokenSource _cts = new(); private readonly SemaphoreSlim _wake = new(0); public SpoolQueue(string dataDir, Action telemetry) { _dir = Path.Combine(dataDir, "spool"); Directory.CreateDirectory(_dir); _telemetry = telemetry; foreach (var f in Directory.GetFiles(_dir, "*.job")) _seen.TryAdd(Path.GetFileNameWithoutExtension(f), 0); _ = Task.Run(WorkerAsync); } /// /// Accept a job. Returns false when the job_id has been seen before. /// /// IDEMPOTENCY IS NOT OPTIONAL. job_id is the ULID of the originating /// event, and the billing screen may resubmit after a timeout or a /// page reload. Printing a bill twice puts two slips in a customer's /// hand with the same invoice number on both. /// public bool Enqueue(PrintJob job) { if (string.IsNullOrWhiteSpace(job.JobId)) throw new ArgumentException("job_id is required"); if (!_seen.TryAdd(job.JobId, 0)) return false; job.QueuedAt = DateTime.UtcNow; job.NextTryAt = DateTime.UtcNow; job.Status = "QUEUED"; Persist(job); _wake.Release(); return true; } public PrintJob[] Pending() { var list = new System.Collections.Generic.List(); foreach (var f in Directory.GetFiles(_dir, "*.job")) { var j = Load(f); if (j != null) list.Add(j); } return list.ToArray(); } public bool Retry(string jobId) { var path = PathFor(jobId); var job = Load(path); if (job == null) return false; job.Attempts = 0; job.Status = "QUEUED"; job.NextTryAt = DateTime.UtcNow; Persist(job); _wake.Release(); return true; } // ---- worker ----------------------------------------------------- private async Task WorkerAsync() { while (!_cts.IsCancellationRequested) { bool didWork = false; foreach (var path in Directory.GetFiles(_dir, "*.job")) { var job = Load(path); if (job == null) { TryDelete(path); continue; } if (job.Status == "FAILED") continue; if (job.NextTryAt > DateTime.UtcNow) continue; didWork = true; try { var bytes = Convert.FromBase64String(job.PayloadB64); for (int c = 0; c < Math.Max(1, job.Copies); c++) RawPrinter.Send(job.Printer, bytes, $"{job.DocType} {job.DocNo}"); // Done. Remove the file but KEEP the id in memory so a // late duplicate submission is still rejected. TryDelete(path); _telemetry("HEALTH", "PRINT_OK", $"{job.DocType} {job.DocNo}"); } catch (Exception ex) { job.Attempts++; job.LastError = ex.Message; if (job.Attempts >= BackoffSeconds.Length) { job.Status = "FAILED"; // Surfaced to the counter as a quiet indicator and // to us as telemetry. Never as a modal dialog. _telemetry("ERROR", "PRINT_FAIL", $"{job.DocType} {job.DocNo} after {job.Attempts} attempts: {ex.Message}"); } else { job.NextTryAt = DateTime.UtcNow.AddSeconds(BackoffSeconds[job.Attempts - 1]); job.Status = "RETRY"; } Persist(job); } } if (!didWork) await _wake.WaitAsync(TimeSpan.FromSeconds(2), _cts.Token).ContinueWith(_ => { }); } } // ---- persistence ------------------------------------------------ private string PathFor(string id) => Path.Combine(_dir, id + ".job"); private void Persist(PrintJob job) { // Write to a temp file and move into place. A power cut halfway // through a write must not leave a truncated job that the worker // then fails to parse forever. var final = PathFor(job.JobId); var tmp = final + ".tmp"; File.WriteAllText(tmp, JsonSerializer.Serialize(job)); // Move with overwrite, NOT delete-then-move. The two-step version // left a window in which the job file did not exist at all, and // anything listing the spool in that window — the worker itself, // or /v1/spool — would report the bill as gone. File.Move(tmp, final, overwrite: true); } private static PrintJob? Load(string path) { try { return JsonSerializer.Deserialize(File.ReadAllText(path)); } catch { return null; } } private static void TryDelete(string path) { try { File.Delete(path); } catch { /* next pass will retry */ } } public void Dispose() { _cts.Cancel(); _cts.Dispose(); _wake.Dispose(); } }