main.go 5.8 KB

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