main.go 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. // Command routerd consumes alerts from NATS JetStream, resolves
  2. // recipients (companies → sources → allowed_targets ∪ routing_rules
  3. // → groups → individuals ∩ subscriptions), and enqueues one
  4. // delivery per (individual, channel) to
  5. // deliveries.<channel>.<company_id> subjects.
  6. //
  7. // M0: connects to NATS, /health, /metrics. No business logic.
  8. // M1: broadcast mode — every active fcm_token in the company.
  9. // M2: rules engine from SPEC §6 — uses internal/routing.Resolver
  10. // which honors source.allowed_targets, routing_rules, and
  11. // subscriptions (with min_severity, quiet hours, channel_mask).
  12. // Hard-fails on zero recipients (no silent broadcast).
  13. package main
  14. import (
  15. "context"
  16. "encoding/json"
  17. "fmt"
  18. "log/slog"
  19. "os"
  20. "os/signal"
  21. "strings"
  22. "syscall"
  23. "time"
  24. "git3.techno-world.net/lrosales/broad-announce/internal/alert"
  25. "git3.techno-world.net/lrosales/broad-announce/internal/broker"
  26. "git3.techno-world.net/lrosales/broad-announce/internal/config"
  27. "git3.techno-world.net/lrosales/broad-announce/internal/httpserver"
  28. "git3.techno-world.net/lrosales/broad-announce/internal/observability"
  29. "git3.techno-world.net/lrosales/broad-announce/internal/postgres"
  30. "git3.techno-world.net/lrosales/broad-announce/internal/routing"
  31. "github.com/nats-io/nats.go/jetstream"
  32. )
  33. func main() {
  34. cfg, err := config.LoadCommon("routerd")
  35. if err != nil {
  36. os.Stderr.WriteString("config: " + err.Error() + "\n")
  37. os.Exit(1)
  38. }
  39. logger := observability.Init(cfg.Env, cfg.LogLevel, "routerd")
  40. logger.Info("starting", "env", cfg.Env, "addr", cfg.HTTPAddr)
  41. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
  42. defer stop()
  43. br, err := broker.Connect(ctx, cfg.NATSURL)
  44. if err != nil {
  45. logger.Error("nats connect", "err", err)
  46. os.Exit(1)
  47. }
  48. defer br.Close()
  49. pool, err := postgres.Connect(ctx, cfg.PostgresDSN)
  50. if err != nil {
  51. logger.Error("postgres connect", "err", err)
  52. os.Exit(1)
  53. }
  54. defer pool.Close()
  55. resolver := routing.New(pool, logger.With("subsystem", "routing"))
  56. // Subscribe to all alerts.* subjects.
  57. js := br.JS()
  58. stream, err := js.Stream(ctx, "ALERTS")
  59. if err != nil {
  60. logger.Error("nats stream ALERTS", "err", err)
  61. os.Exit(1)
  62. }
  63. consumer, err := stream.CreateOrUpdateConsumer(ctx, jetstream.ConsumerConfig{
  64. Name: "routerd",
  65. Durable: "routerd",
  66. FilterSubjects: []string{"alerts.>"},
  67. AckPolicy: jetstream.AckExplicitPolicy,
  68. })
  69. if err != nil {
  70. logger.Error("nats consumer", "err", err)
  71. os.Exit(1)
  72. }
  73. runCtx, runCancel := context.WithCancel(ctx)
  74. defer runCancel()
  75. go consume(runCtx, logger, consumer, br, resolver)
  76. reg, _ := observability.NewRegistry("routerd")
  77. srv := httpserver.New(httpserver.Config{
  78. Addr: cfg.HTTPAddr,
  79. ServiceName: "routerd",
  80. ShutdownGrace: cfg.ShutdownGrace,
  81. }, logger, observability.MetricsHandler(reg))
  82. errCh := make(chan error, 1)
  83. go func() { errCh <- srv.Start() }()
  84. select {
  85. case <-ctx.Done():
  86. logger.Info("shutdown signal received")
  87. case err := <-errCh:
  88. if err != nil {
  89. logger.Error("http server", "err", err)
  90. os.Exit(1)
  91. }
  92. }
  93. runCancel()
  94. time.Sleep(500 * time.Millisecond) // let consumer drain
  95. if err := srv.Shutdown(ctx); err != nil {
  96. logger.Warn("graceful shutdown", "err", err)
  97. }
  98. logger.Info("bye")
  99. }
  100. func consume(ctx context.Context, logger *slog.Logger, c jetstream.Consumer, br *broker.Client, r *routing.Resolver) {
  101. for {
  102. if ctx.Err() != nil {
  103. return
  104. }
  105. batch, err := c.Fetch(16, jetstream.FetchMaxWait(2*time.Second))
  106. if err != nil {
  107. if ctx.Err() != nil {
  108. return
  109. }
  110. logger.Warn("nats fetch", "err", err)
  111. time.Sleep(500 * time.Millisecond)
  112. continue
  113. }
  114. for m := range batch.Messages() {
  115. handleOne(ctx, logger, m, br, r)
  116. if batch.Error() != nil {
  117. logger.Warn("batch error", "err", batch.Error())
  118. break
  119. }
  120. }
  121. }
  122. }
  123. func handleOne(ctx context.Context, logger *slog.Logger, m jetstream.Msg, br *broker.Client, r *routing.Resolver) {
  124. var a alert.Alert
  125. if err := json.Unmarshal(m.Data(), &a); err != nil {
  126. logger.Warn("malformed alert payload", "err", err, "subject", m.Subject())
  127. _ = m.Ack()
  128. return
  129. }
  130. // Extract company_id from subject "alerts.<company_id>".
  131. parts := strings.SplitN(m.Subject(), ".", 2)
  132. if len(parts) != 2 {
  133. logger.Warn("bad subject", "subject", m.Subject())
  134. _ = m.Ack()
  135. return
  136. }
  137. companyID := parts[1]
  138. if a.CompanyID != "" && a.CompanyID != companyID {
  139. logger.Warn("company_id mismatch", "subject", companyID, "body", a.CompanyID)
  140. }
  141. a.CompanyID = companyID
  142. targets, err := r.ResolveTargets(ctx, &a)
  143. if err != nil {
  144. logger.Error("resolve targets", "err", err, "alert_id", a.ID, "company", companyID)
  145. // Nack so the message is redelivered. In M9 we add the
  146. // circuit breaker; for M2 we just retry.
  147. _ = m.Nak()
  148. return
  149. }
  150. if len(targets) == 0 {
  151. // M2 hard-fail. The alert is acked (it can't be delivered,
  152. // retrying won't help) and the warning is logged. M3+ will
  153. // route these to dlq.no_recipients.
  154. logger.Warn("zero recipients, dropping",
  155. "alert_id", a.ID,
  156. "company", companyID,
  157. "source_id", a.SourceID,
  158. "severity", string(a.Severity),
  159. "category", a.Category,
  160. )
  161. _ = m.Ack()
  162. return
  163. }
  164. // Enqueue one deliveries.<channel>.<company_id> per (target).
  165. js, err := br.NC().JetStream()
  166. if err != nil {
  167. logger.Error("js ctx", "err", err)
  168. _ = m.Nak()
  169. return
  170. }
  171. enqueued := 0
  172. for _, t := range targets {
  173. envelope := deliveryEnvelope{
  174. Alert: a,
  175. IndividualID: t.IndividualID,
  176. Channel: t.Channel,
  177. Endpoint: t.Endpoint,
  178. Locale: t.Locale,
  179. }
  180. body, err := json.Marshal(envelope)
  181. if err != nil {
  182. logger.Warn("marshal envelope", "err", err)
  183. continue
  184. }
  185. subject := broker.DeliveriesSubject(t.Channel, companyID)
  186. if _, err := js.PublishAsync(subject, body); err != nil {
  187. logger.Warn("publish delivery", "err", err, "subject", subject)
  188. continue
  189. }
  190. enqueued++
  191. }
  192. logger.Info("routed",
  193. "alert_id", a.ID,
  194. "company", companyID,
  195. "source_id", a.SourceID,
  196. "severity", string(a.Severity),
  197. "recipients", len(targets),
  198. "enqueued", enqueued,
  199. )
  200. _ = m.Ack()
  201. }
  202. // deliveryEnvelope is the wire shape published on
  203. // deliveries.<channel>.<company_id>. M2 swaps FCMToken+Locale for
  204. // the channel-agnostic Channel+Endpoint so the same shape works
  205. // for telegram / sms / email / etc. in M3+.
  206. type deliveryEnvelope struct {
  207. Alert alert.Alert `json:"alert"`
  208. IndividualID string `json:"individual_id"`
  209. Channel string `json:"channel"`
  210. Endpoint string `json:"endpoint"`
  211. Locale string `json:"locale,omitempty"`
  212. }
  213. var _ = fmt.Sprintf // keep import