dlq.go 2.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990
  1. // Package dlq is the M8 dead-letter queue writer used by
  2. // deliverd-fcm and deliverd-telegram. When a delivery
  3. // exhausts its retry budget, the worker calls Write()
  4. // once to insert a forensic row into deliveries_dlq and
  5. // mark the live deliveries row as status='dlq'.
  6. //
  7. // The package is intentionally tiny: one function, one
  8. // row insert, one row update. The retry loop itself
  9. // stays in the deliverd main.go (it's small and per-
  10. // channel, since the HTTP target and the request shape
  11. // differ per channel).
  12. //
  13. // Schema reference: migrations/008_dlq.up.sql.
  14. package dlq
  15. import (
  16. "context"
  17. "encoding/json"
  18. "fmt"
  19. "git3.techno-world.net/lrosales/broad-announce/internal/postgres"
  20. )
  21. // Entry is the minimal payload we need to write a DLQ
  22. // row. It mirrors the columns of `deliveries_dlq` that
  23. // the worker fills in; everything else (id, created_at,
  24. // discarded) is set by the database.
  25. type Entry struct {
  26. AlertID string
  27. CompanyID string
  28. IndividualID string
  29. Channel string // fcm | telegram | …
  30. Target string // fcm_token, chat_id, …
  31. OriginalSubject string // deliveries.fcm.<co>, for replay
  32. Attempts int
  33. LastError string
  34. Payload json.RawMessage // raw NATS envelope bytes
  35. }
  36. // Write inserts one row into deliveries_dlq. It also
  37. // updates the matching `deliveries` row to status='dlq'
  38. // for the audit trail (best-effort: we don't fail the
  39. // DLQ write if the update misses, because the DLQ row
  40. // is the source of truth for replay).
  41. //
  42. // Returns the new DLQ row's id (for logging).
  43. func Write(ctx context.Context, pool *postgres.Pool, e Entry) (int64, error) {
  44. if e.OriginalSubject == "" {
  45. return 0, fmt.Errorf("dlq.Write: OriginalSubject is required for replay")
  46. }
  47. if e.Payload == nil {
  48. // Store the literal JSON null rather than an
  49. // empty byte slice so CH's String column gets
  50. // a sensible value.
  51. e.Payload = json.RawMessage("null")
  52. }
  53. // The DLQ insert.
  54. var newID int64
  55. err := pool.QueryRow(ctx, `
  56. INSERT INTO deliveries_dlq
  57. (alert_id, company_id, individual_id, channel, target,
  58. original_subject, attempts, last_error, payload)
  59. VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
  60. RETURNING id
  61. `,
  62. e.AlertID, e.CompanyID, e.IndividualID, e.Channel, e.Target,
  63. e.OriginalSubject, e.Attempts, e.LastError, e.Payload,
  64. ).Scan(&newID)
  65. if err != nil {
  66. return 0, fmt.Errorf("dlq insert: %w", err)
  67. }
  68. // Best-effort status flip on the live deliveries row.
  69. // We do a soft match: same (alert_id, company_id,
  70. // individual_id, channel) and attempts column matches.
  71. // This is good enough for the audit trail; the DLQ
  72. // row id is the source of truth for replay.
  73. _, _ = pool.Exec(ctx, `
  74. UPDATE deliveries
  75. SET status = 'dlq',
  76. last_error = $1
  77. WHERE alert_id = $2
  78. AND company_id = $3
  79. AND individual_id = $4
  80. AND channel = $5
  81. AND status NOT IN ('sent', 'dlq')
  82. `, e.LastError, e.AlertID, e.CompanyID, e.IndividualID, e.Channel)
  83. return newID, nil
  84. }