main.go 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  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. logger.Info("circuit breaker configured",
  76. "failure_threshold", cfg.CircuitFailureThreshold,
  77. "failure_window_sec", cfg.CircuitFailureWindowSecs,
  78. "open_duration_sec", cfg.CircuitOpenDurationSecs,
  79. "max_half_open", cfg.CircuitMaxHalfOpen,
  80. )
  81. // M9: quarantine manager (layer 7). Per-source error-rate
  82. // limiter backed by Redis so the ban is shared across
  83. // multiple ingestd instances.
  84. quarantineCfg := quarantine.Config{
  85. HitsThreshold: cfg.QuarantineHitsThreshold,
  86. HitsWindow: time.Duration(cfg.QuarantineWindowSeconds) * time.Second,
  87. BanDuration: time.Duration(cfg.QuarantineDurationSecond) * time.Second,
  88. }
  89. q := quarantine.New(r.Client, quarantineCfg)
  90. logger.Info("quarantine configured",
  91. "hits_threshold", cfg.QuarantineHitsThreshold,
  92. "hits_window_sec", cfg.QuarantineWindowSeconds,
  93. "ban_duration_sec", cfg.QuarantineDurationSecond,
  94. )
  95. // M0 source registry: loaded from env. M2 replaces with DB.
  96. sources := loadSourcesFromEnv(logger)
  97. js, err := br.NC().JetStream()
  98. if err != nil {
  99. logger.Error("nats jetstream context", "err", err)
  100. os.Exit(1)
  101. }
  102. // M5: per-IP concurrency cap (SPEC §22 layer 2). The same
  103. // gate is used by the HTTP server (via the WS path; HTTP's
  104. // per-IP cap is M10's "M5-bump" deferred — see SPEC §22
  105. // milestone rollout) and the WebSocket ingest path.
  106. perIP := concurrency.NewPerIP(cfg.MaxConcurrentPerIP)
  107. defer perIP.Close()
  108. // M5: in-process tail hub. nil-safe; both the HTTP and
  109. // MQTT paths call Tail.Publish if non-nil.
  110. hub := tailhub.NewHub()
  111. // M6: per-source monotonic max tracker for dedupe_count.
  112. // Shared by all transports so the
  113. // ba_ingestd_dedupe_count_max_observed gauge reflects the
  114. // global peak across HTTP, MQTT, and WS.
  115. maxSeen := observability.NewMaxSeen()
  116. deps := &httpDeps{
  117. processDeps: processDeps{
  118. Logger: logger.With("component", "http"),
  119. Metrics: m,
  120. Limiter: limiter,
  121. Deduper: ded,
  122. Sources: sources,
  123. JetStream: newNatsPublisher(js),
  124. CompanyRatePerSec: cfg.RateLimitPerCompany,
  125. Tail: hub,
  126. Transport: "http",
  127. MaxSeen: maxSeen,
  128. CircuitBreaker: cb,
  129. Quarantine: q,
  130. },
  131. MaxBytes: cfg.MaxPayloadBytes,
  132. }
  133. // M5: WS ingest + tail handler. The WS path uses a copy of
  134. // deps.processDeps with Transport="ws" so the structured
  135. // log lines and the tail event label are correct.
  136. wsDeps := &wsIngestDeps{
  137. processDeps: deps.processDeps,
  138. PerIP: perIP,
  139. MaxFrameBytes: int64(cfg.MaxPayloadBytes),
  140. ReadDeadline: 30 * time.Second,
  141. WriteDeadline: 10 * time.Second,
  142. }
  143. // Override the transport on the wsDeps copy. We have to
  144. // use a fresh processDeps because the embedded struct is a
  145. // value, not a pointer, in the struct literal above.
  146. wsDeps.processDeps.Logger = logger.With("component", "ws")
  147. wsDeps.processDeps.Transport = "ws"
  148. tailDeps := &wsTailDeps{
  149. Token: os.Getenv("BA_INGESTD_TAIL_TOKEN"),
  150. Hub: hub,
  151. Metrics: m,
  152. }
  153. // HTTP server
  154. srv := httpserver.New(httpserver.Config{
  155. Addr: cfg.HTTPAddr,
  156. ServiceName: "ingestd",
  157. ShutdownGrace: cfg.ShutdownGrace,
  158. }, logger, observability.MetricsHandler(reg))
  159. RegisterRoutes(srv.Mux(), deps)
  160. RegisterWSRoutes(srv.Mux(), wsDeps, tailDeps)
  161. // MQTT subscriber (M4). Disabled if BA_INGESTD_MQTT_BROKER is
  162. // empty. The subscriber shares the processDeps with the HTTP
  163. // handler so the dedupe window, rate limits, and metrics are
  164. // per-source exactly once across both transports.
  165. mqttCfg := loadMQTTConfig(logger)
  166. mqttDeps := deps.processDeps
  167. mqttDeps.Logger = logger.With("component", "mqtt")
  168. mqttDeps.Transport = "mqtt"
  169. mqttErrCh := make(chan error, 1)
  170. go func() {
  171. mqttErrCh <- startMQTT(ctx, mqttCfg, &mqttDeps, logger, m)
  172. }()
  173. // Run + graceful shutdown
  174. errCh := make(chan error, 1)
  175. go func() { errCh <- srv.Start() }()
  176. select {
  177. case <-ctx.Done():
  178. logger.Info("shutdown signal received")
  179. case err := <-errCh:
  180. if err != nil {
  181. logger.Error("http server", "err", err)
  182. os.Exit(1)
  183. }
  184. case err := <-mqttErrCh:
  185. if err != nil {
  186. logger.Error("mqtt subscriber", "err", err)
  187. os.Exit(1)
  188. }
  189. }
  190. if err := srv.Shutdown(ctx); err != nil {
  191. logger.Warn("graceful shutdown", "err", err)
  192. }
  193. logger.Info("bye")
  194. }