main.go 7.2 KB

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