main.go 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. // Command ingestd receives alerts via HTTP POST / WebSocket / MQTT / gRPC,
  2. // validates, rate-limits, dedupes, and publishes to NATS JetStream.
  3. //
  4. // M0: HTTP POST endpoint only. Other transports land in M5 / M4 / M11.
  5. package main
  6. import (
  7. "context"
  8. "os"
  9. "os/signal"
  10. "syscall"
  11. "time"
  12. "git3.techno-world.net/lrosales/broad-announce/internal/broker"
  13. "git3.techno-world.net/lrosales/broad-announce/internal/circuitbreaker"
  14. "git3.techno-world.net/lrosales/broad-announce/internal/concurrency"
  15. "git3.techno-world.net/lrosales/broad-announce/internal/config"
  16. "git3.techno-world.net/lrosales/broad-announce/internal/dedupe"
  17. "git3.techno-world.net/lrosales/broad-announce/internal/httpserver"
  18. "git3.techno-world.net/lrosales/broad-announce/internal/observability"
  19. "git3.techno-world.net/lrosales/broad-announce/internal/quarantine"
  20. "git3.techno-world.net/lrosales/broad-announce/internal/ratelimit"
  21. "git3.techno-world.net/lrosales/broad-announce/internal/store"
  22. "git3.techno-world.net/lrosales/broad-announce/internal/tailhub"
  23. )
  24. func main() {
  25. cfg, err := config.LoadIngestd()
  26. if err != nil {
  27. // Logger isn't up yet; stderr is the only thing we have.
  28. os.Stderr.WriteString("config: " + err.Error() + "\n")
  29. os.Exit(1)
  30. }
  31. logger := observability.Init(cfg.Env, cfg.LogLevel, "ingestd")
  32. logger.Info("starting", "env", cfg.Env, "addr", cfg.HTTPAddr)
  33. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
  34. defer stop()
  35. // Redis
  36. r, err := store.ConnectRedis(ctx, cfg.RedisURL)
  37. if err != nil {
  38. logger.Error("redis connect", "err", err)
  39. os.Exit(1)
  40. }
  41. defer r.Close()
  42. logger.Info("redis connected")
  43. // NATS JetStream
  44. br, err := broker.Connect(ctx, cfg.NATSURL)
  45. if err != nil {
  46. logger.Error("nats connect", "err", err)
  47. os.Exit(1)
  48. }
  49. defer br.Close()
  50. logger.Info("nats connected", "url", cfg.NATSURL)
  51. // Metrics
  52. reg, m := observability.NewRegistry("ingestd")
  53. limiter := ratelimit.New(r.Client)
  54. // M6: sliding-window dedupe TTL from config. Default 300s
  55. // (5 min), overridable via BA_INGESTD_DEDUPE_TTL_SECONDS.
  56. dedTTL := time.Duration(cfg.DedupeTTLSeconds) * time.Second
  57. if dedTTL <= 0 {
  58. dedTTL = dedupe.DefaultWindow
  59. }
  60. ded := dedupe.New(r.Client, dedTTL)
  61. // M9: circuit breaker (layer 6). Wraps the NATS publish
  62. // call so a sick broker doesn't take down ingestd.
  63. cbCfg := circuitbreaker.Config{
  64. Name: "nats-publish",
  65. FailureThreshold: cfg.CircuitFailureThreshold,
  66. FailureWindow: time.Duration(cfg.CircuitFailureWindowSecs) * time.Second,
  67. OpenDuration: time.Duration(cfg.CircuitOpenDurationSecs) * time.Second,
  68. MaxHalfOpen: cfg.CircuitMaxHalfOpen,
  69. }
  70. cb := circuitbreaker.New(cbCfg)
  71. // Also report CB state changes to Prometheus.
  72. cb.Measure = func(state int, err error) {
  73. m.CBState.WithLabelValues("nats").Set(float64(state))
  74. }
  75. // Initialize gauge to CLOSED (0) so Prometheus sees the metric before first transition.
  76. m.CBState.WithLabelValues("nats").Set(0)
  77. logger.Info("circuit breaker configured",
  78. "failure_threshold", cfg.CircuitFailureThreshold,
  79. "failure_window_sec", cfg.CircuitFailureWindowSecs,
  80. "open_duration_sec", cfg.CircuitOpenDurationSecs,
  81. "max_half_open", cfg.CircuitMaxHalfOpen,
  82. )
  83. // M9: quarantine manager (layer 7). Per-source error-rate
  84. // limiter backed by Redis so the ban is shared across
  85. // multiple ingestd instances.
  86. quarantineCfg := quarantine.Config{
  87. HitsThreshold: cfg.QuarantineHitsThreshold,
  88. HitsWindow: time.Duration(cfg.QuarantineWindowSeconds) * time.Second,
  89. BanDuration: time.Duration(cfg.QuarantineDurationSecond) * time.Second,
  90. }
  91. q := quarantine.New(r.Client, quarantineCfg)
  92. logger.Info("quarantine configured",
  93. "hits_threshold", cfg.QuarantineHitsThreshold,
  94. "hits_window_sec", cfg.QuarantineWindowSeconds,
  95. "ban_duration_sec", cfg.QuarantineDurationSecond,
  96. )
  97. // M0 source registry: loaded from env. M2 replaces with DB.
  98. sources := loadSourcesFromEnv(logger)
  99. js, err := br.NC().JetStream()
  100. if err != nil {
  101. logger.Error("nats jetstream context", "err", err)
  102. os.Exit(1)
  103. }
  104. // M5: per-IP concurrency cap (SPEC §22 layer 2). The same
  105. // gate is used by the HTTP server (via the WS path; HTTP's
  106. // per-IP cap is M10's "M5-bump" deferred — see SPEC §22
  107. // milestone rollout) and the WebSocket ingest path.
  108. perIP := concurrency.NewPerIP(cfg.MaxConcurrentPerIP)
  109. defer perIP.Close()
  110. // M5: in-process tail hub. nil-safe; both the HTTP and
  111. // MQTT paths call Tail.Publish if non-nil.
  112. hub := tailhub.NewHub()
  113. // M6: per-source monotonic max tracker for dedupe_count.
  114. // Shared by all transports so the
  115. // ba_ingestd_dedupe_count_max_observed gauge reflects the
  116. // global peak across HTTP, MQTT, and WS.
  117. maxSeen := observability.NewMaxSeen()
  118. deps := &httpDeps{
  119. processDeps: processDeps{
  120. Logger: logger.With("component", "http"),
  121. Metrics: m,
  122. Limiter: limiter,
  123. Deduper: ded,
  124. Sources: sources,
  125. JetStream: newNatsPublisher(js),
  126. CompanyRatePerSec: cfg.RateLimitPerCompany,
  127. Tail: hub,
  128. Transport: "http",
  129. MaxSeen: maxSeen,
  130. CircuitBreaker: cb,
  131. Quarantine: q,
  132. },
  133. MaxBytes: cfg.MaxPayloadBytes,
  134. }
  135. // M5: WS ingest + tail handler. The WS path uses a copy of
  136. // deps.processDeps with Transport="ws" so the structured
  137. // log lines and the tail event label are correct.
  138. wsDeps := &wsIngestDeps{
  139. processDeps: deps.processDeps,
  140. PerIP: perIP,
  141. MaxFrameBytes: int64(cfg.MaxPayloadBytes),
  142. ReadDeadline: 30 * time.Second,
  143. WriteDeadline: 10 * time.Second,
  144. }
  145. // Override the transport on the wsDeps copy. We have to
  146. // use a fresh processDeps because the embedded struct is a
  147. // value, not a pointer, in the struct literal above.
  148. wsDeps.processDeps.Logger = logger.With("component", "ws")
  149. wsDeps.processDeps.Transport = "ws"
  150. tailDeps := &wsTailDeps{
  151. Token: os.Getenv("BA_INGESTD_TAIL_TOKEN"),
  152. Hub: hub,
  153. Metrics: m,
  154. }
  155. // HTTP server
  156. srv := httpserver.New(httpserver.Config{
  157. Addr: cfg.HTTPAddr,
  158. ServiceName: "ingestd",
  159. ShutdownGrace: cfg.ShutdownGrace,
  160. }, logger, observability.MetricsHandler(reg))
  161. RegisterRoutes(srv.Mux(), deps)
  162. RegisterWSRoutes(srv.Mux(), wsDeps, tailDeps)
  163. // MQTT subscriber (M4). Disabled if BA_INGESTD_MQTT_BROKER is
  164. // empty. The subscriber shares the processDeps with the HTTP
  165. // handler so the dedupe window, rate limits, and metrics are
  166. // per-source exactly once across both transports.
  167. mqttCfg := loadMQTTConfig(logger)
  168. mqttDeps := deps.processDeps
  169. mqttDeps.Logger = logger.With("component", "mqtt")
  170. mqttDeps.Transport = "mqtt"
  171. mqttErrCh := make(chan error, 1)
  172. go func() {
  173. mqttErrCh <- startMQTT(ctx, mqttCfg, &mqttDeps, logger, m)
  174. }()
  175. // Run + graceful shutdown
  176. errCh := make(chan error, 1)
  177. go func() { errCh <- srv.Start() }()
  178. select {
  179. case <-ctx.Done():
  180. logger.Info("shutdown signal received")
  181. case err := <-errCh:
  182. if err != nil {
  183. logger.Error("http server", "err", err)
  184. os.Exit(1)
  185. }
  186. case err := <-mqttErrCh:
  187. if err != nil {
  188. logger.Error("mqtt subscriber", "err", err)
  189. os.Exit(1)
  190. }
  191. }
  192. if err := srv.Shutdown(ctx); err != nil {
  193. logger.Warn("graceful shutdown", "err", err)
  194. }
  195. logger.Info("bye")
  196. }