main.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  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. )
  35. type workerResult struct {
  36. sent, failed, dupes, rlHits uint64
  37. }
  38. func main() {
  39. var (
  40. target = flag.String("target", "localhost:9090", "ingestd gRPC address")
  41. apiKey = flag.String("api-key", "", "company_id:source_id:secret")
  42. rate = flag.Int("rate", 1000, "target alerts/sec total (across all workers)")
  43. workers = flag.Int("workers", 8, "parallel gRPC streams")
  44. duration = flag.Duration("duration", 30*time.Second, "total run time")
  45. rampUp = flag.Duration("ramp-up", 0, "linear ramp from 0 to --rate over this duration")
  46. metrics = flag.String("metrics", ":8892", "Prometheus metrics listen addr (empty to disable)")
  47. clusterID = flag.String("cluster-id", "default", "tag added as label on all loadgen metrics")
  48. instance = flag.String("instance", "default", "loadgen instance name")
  49. dedupePct = flag.Int("dedupe-pct", 30, "percent of alerts sharing a dedupe_key")
  50. dedupeKey = flag.String("dedupe-key", "", "force a specific dedupe_key on every alert")
  51. payloadB = flag.Int("payload-bytes", 256, "approximate payload size in bytes (Data map)")
  52. )
  53. flag.Parse()
  54. if *apiKey == "" {
  55. fmt.Fprintln(os.Stderr, "loadgen-grpc: --api-key is required (company:source:secret)")
  56. os.Exit(2)
  57. }
  58. parts := strings.SplitN(*apiKey, ":", 3)
  59. if len(parts) != 3 {
  60. fmt.Fprintln(os.Stderr, "loadgen-grpc: --api-key must be company:source:secret")
  61. os.Exit(2)
  62. }
  63. _, source, secret := parts[0], parts[1], parts[2]
  64. logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
  65. logger.Info("starting",
  66. "target", *target,
  67. "rate", *rate,
  68. "workers", *workers,
  69. "ramp_up", *rampUp,
  70. "duration", *duration,
  71. "cluster_id", *clusterID,
  72. "instance", *instance,
  73. )
  74. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
  75. defer stop()
  76. var (
  77. sent atomic.Uint64
  78. failed atomic.Uint64
  79. dupes atomic.Uint64
  80. rlHits atomic.Uint64
  81. rttHist atomicHistogram // lives outside workers
  82. )
  83. results := make([]workerResult, *workers)
  84. var wg sync.WaitGroup
  85. // Rate per worker.
  86. ratePerWorker := *rate / *workers
  87. if ratePerWorker < 1 {
  88. ratePerWorker = 1
  89. }
  90. for wid := 0; wid < *workers; wid++ {
  91. wg.Add(1)
  92. go func(id int) {
  93. defer wg.Done()
  94. res := &results[id]
  95. runWorker(ctx, logger, *target, *apiKey, source, secret,
  96. ratePerWorker, *duration, *rampUp, *dedupePct, *dedupeKey, *payloadB, res, &rttHist)
  97. }(wid)
  98. }
  99. // Metrics endpoint.
  100. if *metrics != "" {
  101. go runMetrics(*metrics, *clusterID, *instance, &sent, &failed, &dupes, &rlHits)
  102. }
  103. // Status ticker.
  104. ticker := time.NewTicker(5 * time.Second)
  105. defer ticker.Stop()
  106. // done is closed when every worker has returned (e.g. when
  107. // --duration expires in runWorker via prodCtx.Done()). Without
  108. // this, main only exits on SIGTERM and the loadgen hangs past
  109. // --duration even though the workers themselves shut down cleanly
  110. // (producer closes ackCh, consumer's `for range ackCh` exits,
  111. // runWorker returns). Symptom: smoke step 3 times out at 60s
  112. // with "backpressure loadgen did not exit within 60s".
  113. done := make(chan struct{})
  114. go func() {
  115. wg.Wait()
  116. close(done)
  117. }()
  118. for {
  119. select {
  120. case <-ctx.Done():
  121. // Signal received: wait for in-flight workers to drain
  122. // via the done channel, then print and return.
  123. <-done
  124. var tsent, tfailed, tdupes, trlHits uint64
  125. for i := range results {
  126. tsent += results[i].sent
  127. tfailed += results[i].failed
  128. tdupes += results[i].dupes
  129. trlHits += results[i].rlHits
  130. }
  131. printFinal(logger, tsent, tfailed, tdupes, trlHits, &rttHist)
  132. return
  133. case <-done:
  134. // All workers finished naturally (e.g. --duration expired).
  135. var tsent, tfailed, tdupes, trlHits uint64
  136. for i := range results {
  137. tsent += results[i].sent
  138. tfailed += results[i].failed
  139. tdupes += results[i].dupes
  140. trlHits += results[i].rlHits
  141. }
  142. printFinal(logger, tsent, tfailed, tdupes, trlHits, &rttHist)
  143. return
  144. case <-ticker.C:
  145. // Aggregate per-worker counters.
  146. var tsent, tfailed, tdupes, trlHits uint64
  147. for i := range results {
  148. tsent += results[i].sent
  149. tfailed += results[i].failed
  150. tdupes += results[i].dupes
  151. trlHits += results[i].rlHits
  152. }
  153. logger.Info("progress",
  154. "sent", tsent,
  155. "failed", tfailed,
  156. "dupes", tdupes,
  157. "rate_limited", trlHits,
  158. )
  159. }
  160. }
  161. }
  162. // runWorker opens one gRPC stream and drives it at the target rate.
  163. func runWorker(
  164. ctx context.Context,
  165. logger *slog.Logger,
  166. target, apiKey, sourceID, secret string,
  167. rate int,
  168. duration, rampUp time.Duration,
  169. dedupePct int,
  170. dedupeKey string,
  171. payloadB int,
  172. res *workerResult,
  173. rttHist *atomicHistogram,
  174. ) {
  175. // Connect.
  176. c, err := grpcclient.New(target,
  177. grpcclient.WithAPIKey(apiKey),
  178. grpcclient.WithInsecure(), // loadgen — use mTLS in production
  179. )
  180. if err != nil {
  181. logger.Error("grpcclient.New", "worker", 0, "err", err)
  182. return
  183. }
  184. defer c.Close()
  185. // Open stream.
  186. stream, err := c.Stream(ctx)
  187. if err != nil {
  188. logger.Error("Client.Stream", "err", err)
  189. return
  190. }
  191. defer stream.CloseSend()
  192. // Producer goroutine.
  193. prodCtx, cancelProd := context.WithTimeout(ctx, duration)
  194. defer cancelProd()
  195. type pendingAck struct {
  196. sentAt time.Time
  197. dk string
  198. }
  199. ackCh := make(chan pendingAck, rate*2)
  200. p := pacer.New(rate, rampUp)
  201. tickCh, stopPacer := p.Tick(prodCtx)
  202. defer stopPacer()
  203. go func() {
  204. i := 0
  205. for {
  206. select {
  207. case <-prodCtx.Done():
  208. stream.CloseSend()
  209. close(ackCh)
  210. return
  211. case <-tickCh:
  212. a := mkAlert("normal", companyFromKey(apiKey), sourceID, dedupePct, dedupeKey, payloadB)
  213. if err := stream.Send(prodCtx, alertToProto(companyFromKey(apiKey), sourceID, a)); err != nil {
  214. // stream.Send (loadgen wrapper) already retries 3x on
  215. // retriable codes. Treat any error here as terminal
  216. // for this tick. Close the stream and ackCh so the
  217. // consumer's 'for p := range ackCh' loop exits; wg.Wait
  218. // in main() then unblocks and the worker can shut down.
  219. // Without this close, the consumer spins forever on a
  220. // dead stream and the whole loadgen hangs past --duration.
  221. logger.Error("send failed", "err", err)
  222. res.failed++
  223. stream.CloseSend()
  224. close(ackCh)
  225. return
  226. }
  227. // Push to ackCh inside the select so prodCtx.Done() can
  228. // unblock the producer when --duration expires. Without
  229. // this, a full ackCh blocks the producer indefinitely
  230. // (the consumer drains at the server's ack rate, which
  231. // may be much slower than the pacer under backpressure).
  232. select {
  233. case <-prodCtx.Done():
  234. stream.CloseSend()
  235. close(ackCh)
  236. return
  237. case ackCh <- pendingAck{sentAt: time.Now(), dk: a.DedupeKey}:
  238. }
  239. i++
  240. }
  241. }
  242. }()
  243. // Consumer goroutine — reads acks and computes RTT.
  244. go func() {
  245. for p := range ackCh {
  246. ack, err := stream.Recv(prodCtx)
  247. if err != nil {
  248. // Stream is dead or ctx cancelled. Exit the consumer so
  249. // wg.Wait in main() can unblock — otherwise the loadgen
  250. // hangs past --duration even though the producer exited
  251. // cleanly via the Send-error or prodCtx.Done() path.
  252. return
  253. }
  254. rttMs := time.Since(p.sentAt).Milliseconds()
  255. rttHist.Record(rttMs)
  256. if ack.GetError() != nil {
  257. if ack.GetError().Code == pbv1.Error_RATE_LIMITED {
  258. res.rlHits++
  259. } else {
  260. res.failed++
  261. }
  262. } else {
  263. res.sent++
  264. if ack.DedupeCount > 1 {
  265. res.dupes++
  266. }
  267. }
  268. }
  269. }()
  270. <-prodCtx.Done()
  271. }
  272. // companyFromKey extracts company_id from the api-key "company:source:secret".
  273. func companyFromKey(apiKey string) string {
  274. parts := strings.SplitN(apiKey, ":", 3)
  275. if len(parts) >= 1 {
  276. return parts[0]
  277. }
  278. return "acme-001"
  279. }
  280. func mkAlert(mode, companyID, sourceID string, dedupePct int, dedupeKey string, payloadB int) alert.Alert {
  281. severity := pickSeverity(mode)
  282. category := pickCategory(severity)
  283. dk := ""
  284. if dedupeKey != "" {
  285. dk = dedupeKey
  286. } else if rand.IntN(100) < dedupePct {
  287. dk = fmt.Sprintf("burst:%s:probe", category)
  288. }
  289. data := map[string]string{
  290. "host": fmt.Sprintf("host-%d", rand.IntN(100)),
  291. "probe": category,
  292. "raw_msg": strings.Repeat("x", max(0, payloadB-64)),
  293. }
  294. return alert.Alert{
  295. CompanyID: companyID,
  296. SourceID: sourceID,
  297. Severity: severity,
  298. Category: category,
  299. Title: fmt.Sprintf("%s on %s", category, data["host"]),
  300. Body: "synthetic loadgen alert",
  301. Data: data,
  302. DedupeKey: dk,
  303. }
  304. }
  305. func alertToProto(companyID, sourceID string, a alert.Alert) *pbv1.Alert {
  306. data := make(map[string]string)
  307. for k, v := range a.Data {
  308. data[k] = v
  309. }
  310. return &pbv1.Alert{
  311. CompanyId: companyID,
  312. SourceId: sourceID,
  313. Severity: string(a.Severity),
  314. Category: a.Category,
  315. Title: a.Title,
  316. Body: a.Body,
  317. DedupeKey: a.DedupeKey,
  318. ClientTsMs: time.Now().UnixMilli(),
  319. Data: data,
  320. }
  321. }
  322. func pickSeverity(mode string) alert.Severity {
  323. r := rand.IntN(100)
  324. switch {
  325. case r < 70:
  326. return alert.SeverityInfo
  327. case r < 95:
  328. return alert.SeverityWarning
  329. case r < 99:
  330. return alert.SeverityCritical
  331. default:
  332. return alert.SeverityInminentColapse
  333. }
  334. }
  335. var categoriesBySev = map[alert.Severity][]string{
  336. alert.SeverityInfo: {"deploy", "schedule", "audit"},
  337. alert.SeverityWarning: {"disk", "memory", "latency", "queue"},
  338. alert.SeverityCritical: {"storage", "network", "process"},
  339. alert.SeverityInminentColapse: {"power", "hvac", "rack"},
  340. }
  341. func pickCategory(s alert.Severity) string {
  342. opts := categoriesBySev[s]
  343. return opts[rand.IntN(len(opts))]
  344. }
  345. // atomicHistogram is a lock-free fixed-size histogram for RTT values.
  346. // Buckets: 0-1ms, 1-5ms, 5-10ms, 10-25ms, 25-50ms, 50-100ms, 100-250ms, 250ms+.
  347. type atomicHistogram struct {
  348. buckets [8]atomic.Uint64
  349. }
  350. func (h *atomicHistogram) Record(ms int64) {
  351. var bucket int
  352. switch {
  353. case ms < 1:
  354. bucket = 0
  355. case ms < 5:
  356. bucket = 1
  357. case ms < 10:
  358. bucket = 2
  359. case ms < 25:
  360. bucket = 3
  361. case ms < 50:
  362. bucket = 4
  363. case ms < 100:
  364. bucket = 5
  365. case ms < 250:
  366. bucket = 6
  367. default:
  368. bucket = 7
  369. }
  370. h.buckets[bucket].Add(1)
  371. }
  372. func (h *atomicHistogram) String() string {
  373. var total uint64
  374. for i := 0; i < len(h.buckets); i++ {
  375. total += h.buckets[i].Load()
  376. }
  377. if total == 0 {
  378. return "no data"
  379. }
  380. return fmt.Sprintf("total=%d p50=%s p99=%s",
  381. total, h.percentile(50), h.percentile(99))
  382. }
  383. func (h *atomicHistogram) percentile(p int) string {
  384. var total uint64
  385. for i := 0; i < len(h.buckets); i++ {
  386. total += h.buckets[i].Load()
  387. }
  388. threshold := uint64(float64(total) * float64(p) / 100.0)
  389. var cumulative uint64
  390. bounds := []string{"<1ms", "1-5ms", "5-10ms", "10-25ms", "25-50ms", "50-100ms", "100-250ms", ">250ms"}
  391. for i := 0; i < len(h.buckets); i++ {
  392. cumulative += h.buckets[i].Load()
  393. if cumulative >= threshold {
  394. return bounds[i]
  395. }
  396. }
  397. return ">250ms"
  398. }
  399. func runMetrics(addr, clusterID, instance string, sent, failed, dupes, rlHits *atomic.Uint64) {
  400. mux := http.NewServeMux()
  401. mux.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
  402. fmt.Fprintf(w, "# HELP loadgen_alerts_sent_total Alerts successfully accepted.\n")
  403. fmt.Fprintf(w, "# TYPE loadgen_alerts_sent_total counter\n")
  404. fmt.Fprintf(w, "loadgen_alerts_sent_total{instance=%q,cluster_id=%q} %d\n",
  405. instance, clusterID, sent.Load())
  406. fmt.Fprintf(w, "loadgen_alerts_failed_total{instance=%q,cluster_id=%q} %d\n",
  407. instance, clusterID, failed.Load())
  408. fmt.Fprintf(w, "loadgen_dedupe_hits_total{instance=%q,cluster_id=%q} %d\n",
  409. instance, clusterID, dupes.Load())
  410. fmt.Fprintf(w, "loadgen_rate_limited_total{instance=%q,cluster_id=%q} %d\n",
  411. instance, clusterID, rlHits.Load())
  412. })
  413. srv := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
  414. _ = srv.ListenAndServe()
  415. }
  416. func printFinal(logger *slog.Logger, sent, failed, dupes, rlHits uint64, rttHist *atomicHistogram) {
  417. logger.Info("done",
  418. "sent", sent,
  419. "failed", failed,
  420. "dupes", dupes,
  421. "rate_limited", rlHits,
  422. "rtt", rttHist.String(),
  423. )
  424. }
  425. func max(a, b int) int {
  426. if a > b {
  427. return a
  428. }
  429. return b
  430. }
  431. var _ = strconv.FormatInt // for future use