main.go 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  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/concurrency"
  14. "git3.techno-world.net/lrosales/broad-announce/internal/config"
  15. "git3.techno-world.net/lrosales/broad-announce/internal/dedupe"
  16. "git3.techno-world.net/lrosales/broad-announce/internal/httpserver"
  17. "git3.techno-world.net/lrosales/broad-announce/internal/observability"
  18. "git3.techno-world.net/lrosales/broad-announce/internal/ratelimit"
  19. "git3.techno-world.net/lrosales/broad-announce/internal/store"
  20. "git3.techno-world.net/lrosales/broad-announce/internal/tailhub"
  21. )
  22. func main() {
  23. cfg, err := config.LoadIngestd()
  24. if err != nil {
  25. // Logger isn't up yet; stderr is the only thing we have.
  26. os.Stderr.WriteString("config: " + err.Error() + "\n")
  27. os.Exit(1)
  28. }
  29. logger := observability.Init(cfg.Env, cfg.LogLevel, "ingestd")
  30. logger.Info("starting", "env", cfg.Env, "addr", cfg.HTTPAddr)
  31. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
  32. defer stop()
  33. // Redis
  34. r, err := store.ConnectRedis(ctx, cfg.RedisURL)
  35. if err != nil {
  36. logger.Error("redis connect", "err", err)
  37. os.Exit(1)
  38. }
  39. defer r.Close()
  40. logger.Info("redis connected")
  41. // NATS JetStream
  42. br, err := broker.Connect(ctx, cfg.NATSURL)
  43. if err != nil {
  44. logger.Error("nats connect", "err", err)
  45. os.Exit(1)
  46. }
  47. defer br.Close()
  48. logger.Info("nats connected", "url", cfg.NATSURL)
  49. // Metrics
  50. reg, m := observability.NewRegistry("ingestd")
  51. limiter := ratelimit.New(r.Client)
  52. // M6: sliding-window dedupe TTL from config. Default 300s
  53. // (5 min), overridable via BA_INGESTD_DEDUPE_TTL_SECONDS.
  54. dedTTL := time.Duration(cfg.DedupeTTLSeconds) * time.Second
  55. if dedTTL <= 0 {
  56. dedTTL = dedupe.DefaultWindow
  57. }
  58. ded := dedupe.New(r.Client, dedTTL)
  59. // M0 source registry: loaded from env. M2 replaces with DB.
  60. sources := loadSourcesFromEnv(logger)
  61. js, err := br.NC().JetStream()
  62. if err != nil {
  63. logger.Error("nats jetstream context", "err", err)
  64. os.Exit(1)
  65. }
  66. // M5: per-IP concurrency cap (SPEC §22 layer 2). The same
  67. // gate is used by the HTTP server (via the WS path; HTTP's
  68. // per-IP cap is M10's "M5-bump" deferred — see SPEC §22
  69. // milestone rollout) and the WebSocket ingest path.
  70. perIP := concurrency.NewPerIP(cfg.MaxConcurrentPerIP)
  71. defer perIP.Close()
  72. // M5: in-process tail hub. nil-safe; both the HTTP and
  73. // MQTT paths call Tail.Publish if non-nil.
  74. hub := tailhub.NewHub()
  75. // M6: per-source monotonic max tracker for dedupe_count.
  76. // Shared by all transports so the
  77. // ba_ingestd_dedupe_count_max_observed gauge reflects the
  78. // global peak across HTTP, MQTT, and WS.
  79. maxSeen := observability.NewMaxSeen()
  80. deps := &httpDeps{
  81. processDeps: processDeps{
  82. Logger: logger.With("component", "http"),
  83. Metrics: m,
  84. Limiter: limiter,
  85. Deduper: ded,
  86. Sources: sources,
  87. JetStream: newNatsPublisher(js),
  88. CompanyRatePerSec: cfg.RateLimitPerCompany,
  89. Tail: hub,
  90. Transport: "http",
  91. MaxSeen: maxSeen,
  92. },
  93. MaxBytes: cfg.MaxPayloadBytes,
  94. }
  95. // M5: WS ingest + tail handler. The WS path uses a copy of
  96. // deps.processDeps with Transport="ws" so the structured
  97. // log lines and the tail event label are correct.
  98. wsDeps := &wsIngestDeps{
  99. processDeps: deps.processDeps,
  100. PerIP: perIP,
  101. MaxFrameBytes: int64(cfg.MaxPayloadBytes),
  102. ReadDeadline: 30 * time.Second,
  103. WriteDeadline: 10 * time.Second,
  104. }
  105. // Override the transport on the wsDeps copy. We have to
  106. // use a fresh processDeps because the embedded struct is a
  107. // value, not a pointer, in the struct literal above.
  108. wsDeps.processDeps.Logger = logger.With("component", "ws")
  109. wsDeps.processDeps.Transport = "ws"
  110. tailDeps := &wsTailDeps{
  111. Token: os.Getenv("BA_INGESTD_TAIL_TOKEN"),
  112. Hub: hub,
  113. Metrics: m,
  114. }
  115. // HTTP server
  116. srv := httpserver.New(httpserver.Config{
  117. Addr: cfg.HTTPAddr,
  118. ServiceName: "ingestd",
  119. ShutdownGrace: cfg.ShutdownGrace,
  120. }, logger, observability.MetricsHandler(reg))
  121. RegisterRoutes(srv.Mux(), deps)
  122. RegisterWSRoutes(srv.Mux(), wsDeps, tailDeps)
  123. // MQTT subscriber (M4). Disabled if BA_INGESTD_MQTT_BROKER is
  124. // empty. The subscriber shares the processDeps with the HTTP
  125. // handler so the dedupe window, rate limits, and metrics are
  126. // per-source exactly once across both transports.
  127. mqttCfg := loadMQTTConfig(logger)
  128. mqttDeps := deps.processDeps
  129. mqttDeps.Logger = logger.With("component", "mqtt")
  130. mqttDeps.Transport = "mqtt"
  131. mqttErrCh := make(chan error, 1)
  132. go func() {
  133. mqttErrCh <- startMQTT(ctx, mqttCfg, &mqttDeps, logger, m)
  134. }()
  135. // Run + graceful shutdown
  136. errCh := make(chan error, 1)
  137. go func() { errCh <- srv.Start() }()
  138. select {
  139. case <-ctx.Done():
  140. logger.Info("shutdown signal received")
  141. case err := <-errCh:
  142. if err != nil {
  143. logger.Error("http server", "err", err)
  144. os.Exit(1)
  145. }
  146. case err := <-mqttErrCh:
  147. if err != nil {
  148. logger.Error("mqtt subscriber", "err", err)
  149. os.Exit(1)
  150. }
  151. }
  152. if err := srv.Shutdown(ctx); err != nil {
  153. logger.Warn("graceful shutdown", "err", err)
  154. }
  155. logger.Info("bye")
  156. }