main.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416
  1. // loadgen/cmd/grpc is the gRPC (StreamAlerts) traffic generator for M11.
  2. // Each instance opens N parallel bidirectional streams (--workers).
  3. //
  4. // Example (single instance, 10k/s):
  5. //
  6. // loadgen-grpc --target localhost:9090 \
  7. // --api-key acme-001:prom-prod:s3cret \
  8. // --rate 10000 --workers 8 --duration 10m
  9. //
  10. // Example (2-instance cluster, 10k/s total):
  11. //
  12. // loadgen-grpc-1 --rate 5000 --workers 8 --duration 15m --instance loadgen-grpc-1
  13. // loadgen-grpc-2 --rate 5000 --workers 8 --duration 15m --instance loadgen-grpc-2
  14. package main
  15. import (
  16. "context"
  17. "flag"
  18. "fmt"
  19. "log/slog"
  20. "math/rand/v2"
  21. "net/http"
  22. "os"
  23. "os/signal"
  24. "strconv"
  25. "strings"
  26. "sync"
  27. "sync/atomic"
  28. "syscall"
  29. "time"
  30. grpcclient "git3.techno-world.net/lrosales/broad-announce/internal/grpcclient"
  31. pbv1 "git3.techno-world.net/lrosales/broad-announce/gen/go/broadannounce/v1"
  32. "git3.techno-world.net/lrosales/broad-announce/internal/alert"
  33. "git3.techno-world.net/lrosales/broad-announce/loadgen/internal/pacer"
  34. "google.golang.org/grpc/codes"
  35. "google.golang.org/grpc/status"
  36. )
  37. type workerResult struct {
  38. sent, failed, dupes, rlHits uint64
  39. }
  40. func main() {
  41. var (
  42. target = flag.String("target", "localhost:9090", "ingestd gRPC address")
  43. apiKey = flag.String("api-key", "", "company_id:source_id:secret")
  44. rate = flag.Int("rate", 1000, "target alerts/sec total (across all workers)")
  45. workers = flag.Int("workers", 8, "parallel gRPC streams")
  46. duration = flag.Duration("duration", 30*time.Second, "total run time")
  47. rampUp = flag.Duration("ramp-up", 0, "linear ramp from 0 to --rate over this duration")
  48. metrics = flag.String("metrics", ":8892", "Prometheus metrics listen addr (empty to disable)")
  49. clusterID = flag.String("cluster-id", "default", "tag added as label on all loadgen metrics")
  50. instance = flag.String("instance", "default", "loadgen instance name")
  51. dedupePct = flag.Int("dedupe-pct", 30, "percent of alerts sharing a dedupe_key")
  52. dedupeKey = flag.String("dedupe-key", "", "force a specific dedupe_key on every alert")
  53. payloadB = flag.Int("payload-bytes", 256, "approximate payload size in bytes (Data map)")
  54. )
  55. flag.Parse()
  56. if *apiKey == "" {
  57. fmt.Fprintln(os.Stderr, "loadgen-grpc: --api-key is required (company:source:secret)")
  58. os.Exit(2)
  59. }
  60. parts := strings.SplitN(*apiKey, ":", 3)
  61. if len(parts) != 3 {
  62. fmt.Fprintln(os.Stderr, "loadgen-grpc: --api-key must be company:source:secret")
  63. os.Exit(2)
  64. }
  65. _, source, secret := parts[0], parts[1], parts[2]
  66. logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
  67. logger.Info("starting",
  68. "target", *target,
  69. "rate", *rate,
  70. "workers", *workers,
  71. "ramp_up", *rampUp,
  72. "duration", *duration,
  73. "cluster_id", *clusterID,
  74. "instance", *instance,
  75. )
  76. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
  77. defer stop()
  78. var (
  79. sent atomic.Uint64
  80. failed atomic.Uint64
  81. dupes atomic.Uint64
  82. rlHits atomic.Uint64
  83. rttHist atomicHistogram // lives outside workers
  84. )
  85. results := make([]workerResult, *workers)
  86. var wg sync.WaitGroup
  87. // Rate per worker.
  88. ratePerWorker := *rate / *workers
  89. if ratePerWorker < 1 {
  90. ratePerWorker = 1
  91. }
  92. for wid := 0; wid < *workers; wid++ {
  93. wg.Add(1)
  94. go func(id int) {
  95. defer wg.Done()
  96. res := &results[id]
  97. runWorker(ctx, logger, *target, *apiKey, source, secret,
  98. ratePerWorker, *duration, *rampUp, *dedupePct, *dedupeKey, *payloadB, res, &rttHist)
  99. }(wid)
  100. }
  101. // Metrics endpoint.
  102. if *metrics != "" {
  103. go runMetrics(*metrics, *clusterID, *instance, &sent, &failed, &dupes, &rlHits)
  104. }
  105. // Status ticker.
  106. ticker := time.NewTicker(5 * time.Second)
  107. defer ticker.Stop()
  108. for {
  109. select {
  110. case <-ctx.Done():
  111. wg.Wait()
  112. printFinal(logger, &sent, &failed, &dupes, &rlHits, &rttHist)
  113. return
  114. case <-ticker.C:
  115. logger.Info("progress",
  116. "sent", sent.Load(),
  117. "failed", failed.Load(),
  118. "dupes", dupes.Load(),
  119. "rate_limited", rlHits.Load(),
  120. )
  121. }
  122. }
  123. }
  124. // runWorker opens one gRPC stream and drives it at the target rate.
  125. func runWorker(
  126. ctx context.Context,
  127. logger *slog.Logger,
  128. target, apiKey, sourceID, secret string,
  129. rate int,
  130. duration, rampUp time.Duration,
  131. dedupePct int,
  132. dedupeKey string,
  133. payloadB int,
  134. res *workerResult,
  135. rttHist *atomicHistogram,
  136. ) {
  137. // Connect.
  138. c, err := grpcclient.New(target,
  139. grpcclient.WithAPIKey(apiKey),
  140. grpcclient.WithInsecure(), // loadgen — use mTLS in production
  141. )
  142. if err != nil {
  143. logger.Error("grpcclient.New", "worker", 0, "err", err)
  144. return
  145. }
  146. defer c.Close()
  147. // Open stream.
  148. stream, err := c.Stream(ctx)
  149. if err != nil {
  150. logger.Error("Client.Stream", "err", err)
  151. return
  152. }
  153. defer stream.CloseSend()
  154. // Producer goroutine.
  155. prodCtx, cancelProd := context.WithTimeout(ctx, duration)
  156. defer cancelProd()
  157. type pendingAck struct {
  158. sentAt time.Time
  159. dk string
  160. }
  161. ackCh := make(chan pendingAck, rate*2)
  162. p := pacer.New(rate, rampUp)
  163. tickCh, stopPacer := p.Tick(prodCtx)
  164. defer stopPacer()
  165. go func() {
  166. i := 0
  167. for {
  168. select {
  169. case <-prodCtx.Done():
  170. stream.CloseSend()
  171. close(ackCh)
  172. return
  173. case <-tickCh:
  174. a := mkAlert("normal", sourceID, dedupePct, dedupeKey, payloadB)
  175. if err := stream.Send(prodCtx, alertToProto(companyFromKey(apiKey), sourceID, a)); err != nil {
  176. // Check if it's a retriable error.
  177. st, _ := status.FromError(err)
  178. if st.Code() == codes.ResourceExhausted || st.Code() == codes.Unavailable {
  179. // Retry with backoff.
  180. time.Sleep(5 * time.Millisecond)
  181. if err := stream.Send(prodCtx, alertToProto(companyFromKey(apiKey), sourceID, a)); err != nil {
  182. res.failed++
  183. return
  184. }
  185. } else {
  186. res.failed++
  187. return
  188. }
  189. }
  190. ackCh <- pendingAck{sentAt: time.Now(), dk: a.DedupeKey}
  191. i++
  192. }
  193. }
  194. }()
  195. // Consumer goroutine — reads acks and computes RTT.
  196. go func() {
  197. for p := range ackCh {
  198. ack, err := stream.Recv(prodCtx)
  199. if err != nil {
  200. continue
  201. }
  202. rttMs := time.Since(p.sentAt).Milliseconds()
  203. rttHist.Record(rttMs)
  204. if ack.GetError() != nil {
  205. if ack.GetError().Code == pbv1.Error_RATE_LIMITED {
  206. res.rlHits++
  207. } else {
  208. res.failed++
  209. }
  210. } else {
  211. res.sent++
  212. if ack.DedupeCount > 1 {
  213. res.dupes++
  214. }
  215. }
  216. }
  217. }()
  218. <-prodCtx.Done()
  219. }
  220. // companyFromKey extracts company_id from the api-key "company:source:secret".
  221. func companyFromKey(apiKey string) string {
  222. parts := strings.SplitN(apiKey, ":", 3)
  223. if len(parts) >= 1 {
  224. return parts[0]
  225. }
  226. return "acme-001"
  227. }
  228. func mkAlert(mode, sourceID string, dedupePct int, dedupeKey string, payloadB int) alert.Alert {
  229. severity := pickSeverity(mode)
  230. category := pickCategory(severity)
  231. dk := ""
  232. if dedupeKey != "" {
  233. dk = dedupeKey
  234. } else if rand.IntN(100) < dedupePct {
  235. dk = fmt.Sprintf("burst:%s:probe", category)
  236. }
  237. data := map[string]string{
  238. "host": fmt.Sprintf("host-%d", rand.IntN(100)),
  239. "probe": category,
  240. "raw_msg": strings.Repeat("x", max(0, payloadB-64)),
  241. }
  242. return alert.Alert{
  243. CompanyID: "acme-001",
  244. SourceID: sourceID,
  245. Severity: severity,
  246. Category: category,
  247. Title: fmt.Sprintf("%s on %s", category, data["host"]),
  248. Body: "synthetic loadgen alert",
  249. Data: data,
  250. DedupeKey: dk,
  251. }
  252. }
  253. func alertToProto(companyID, sourceID string, a alert.Alert) *pbv1.Alert {
  254. data := make(map[string]string)
  255. for k, v := range a.Data {
  256. data[k] = v
  257. }
  258. return &pbv1.Alert{
  259. CompanyId: companyID,
  260. SourceId: sourceID,
  261. Severity: string(a.Severity),
  262. Category: a.Category,
  263. Title: a.Title,
  264. Body: a.Body,
  265. DedupeKey: a.DedupeKey,
  266. ClientTsMs: time.Now().UnixMilli(),
  267. Data: data,
  268. }
  269. }
  270. func pickSeverity(mode string) alert.Severity {
  271. r := rand.IntN(100)
  272. switch {
  273. case r < 70:
  274. return alert.SeverityInfo
  275. case r < 95:
  276. return alert.SeverityWarning
  277. case r < 99:
  278. return alert.SeverityCritical
  279. default:
  280. return alert.SeverityInminentColapse
  281. }
  282. }
  283. var categoriesBySev = map[alert.Severity][]string{
  284. alert.SeverityInfo: {"deploy", "schedule", "audit"},
  285. alert.SeverityWarning: {"disk", "memory", "latency", "queue"},
  286. alert.SeverityCritical: {"storage", "network", "process"},
  287. alert.SeverityInminentColapse: {"power", "hvac", "rack"},
  288. }
  289. func pickCategory(s alert.Severity) string {
  290. opts := categoriesBySev[s]
  291. return opts[rand.IntN(len(opts))]
  292. }
  293. // atomicHistogram is a lock-free fixed-size histogram for RTT values.
  294. // Buckets: 0-1ms, 1-5ms, 5-10ms, 10-25ms, 25-50ms, 50-100ms, 100-250ms, 250ms+.
  295. type atomicHistogram struct {
  296. buckets [8]atomic.Uint64
  297. }
  298. func (h *atomicHistogram) Record(ms int64) {
  299. var bucket int
  300. switch {
  301. case ms < 1:
  302. bucket = 0
  303. case ms < 5:
  304. bucket = 1
  305. case ms < 10:
  306. bucket = 2
  307. case ms < 25:
  308. bucket = 3
  309. case ms < 50:
  310. bucket = 4
  311. case ms < 100:
  312. bucket = 5
  313. case ms < 250:
  314. bucket = 6
  315. default:
  316. bucket = 7
  317. }
  318. h.buckets[bucket].Add(1)
  319. }
  320. func (h *atomicHistogram) String() string {
  321. var total uint64
  322. for i := 0; i < len(h.buckets); i++ {
  323. total += h.buckets[i].Load()
  324. }
  325. if total == 0 {
  326. return "no data"
  327. }
  328. return fmt.Sprintf("total=%d p50=%s p99=%s",
  329. total, h.percentile(50), h.percentile(99))
  330. }
  331. func (h *atomicHistogram) percentile(p int) string {
  332. var total uint64
  333. for i := 0; i < len(h.buckets); i++ {
  334. total += h.buckets[i].Load()
  335. }
  336. threshold := uint64(float64(total) * float64(p) / 100.0)
  337. var cumulative uint64
  338. bounds := []string{"<1ms", "1-5ms", "5-10ms", "10-25ms", "25-50ms", "50-100ms", "100-250ms", ">250ms"}
  339. for i := 0; i < len(h.buckets); i++ {
  340. cumulative += h.buckets[i].Load()
  341. if cumulative >= threshold {
  342. return bounds[i]
  343. }
  344. }
  345. return ">250ms"
  346. }
  347. func runMetrics(addr, clusterID, instance string, sent, failed, dupes, rlHits *atomic.Uint64) {
  348. mux := http.NewServeMux()
  349. mux.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
  350. fmt.Fprintf(w, "# HELP loadgen_alerts_sent_total Alerts successfully accepted.\n")
  351. fmt.Fprintf(w, "# TYPE loadgen_alerts_sent_total counter\n")
  352. fmt.Fprintf(w, "loadgen_alerts_sent_total{instance=%q,cluster_id=%q} %d\n",
  353. instance, clusterID, sent.Load())
  354. fmt.Fprintf(w, "loadgen_alerts_failed_total{instance=%q,cluster_id=%q} %d\n",
  355. instance, clusterID, failed.Load())
  356. fmt.Fprintf(w, "loadgen_dedupe_hits_total{instance=%q,cluster_id=%q} %d\n",
  357. instance, clusterID, dupes.Load())
  358. fmt.Fprintf(w, "loadgen_rate_limited_total{instance=%q,cluster_id=%q} %d\n",
  359. instance, clusterID, rlHits.Load())
  360. })
  361. srv := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
  362. _ = srv.ListenAndServe()
  363. }
  364. func printFinal(logger *slog.Logger, sent, failed, dupes, rlHits *atomic.Uint64, rttHist *atomicHistogram) {
  365. logger.Info("done",
  366. "sent", sent.Load(),
  367. "failed", failed.Load(),
  368. "dupes", dupes.Load(),
  369. "rate_limited", rlHits.Load(),
  370. "rtt", rttHist.String(),
  371. )
  372. }
  373. func max(a, b int) int {
  374. if a > b {
  375. return a
  376. }
  377. return b
  378. }
  379. var _ = strconv.FormatInt // for future use