main.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  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. for {
  107. select {
  108. case <-ctx.Done():
  109. wg.Wait()
  110. var tsent, tfailed, tdupes, trlHits uint64
  111. for i := range results {
  112. tsent += results[i].sent
  113. tfailed += results[i].failed
  114. tdupes += results[i].dupes
  115. trlHits += results[i].rlHits
  116. }
  117. printFinal(logger, tsent, tfailed, tdupes, trlHits, &rttHist)
  118. return
  119. case <-ticker.C:
  120. // Aggregate per-worker counters.
  121. var tsent, tfailed, tdupes, trlHits uint64
  122. for i := range results {
  123. tsent += results[i].sent
  124. tfailed += results[i].failed
  125. tdupes += results[i].dupes
  126. trlHits += results[i].rlHits
  127. }
  128. logger.Info("progress",
  129. "sent", tsent,
  130. "failed", tfailed,
  131. "dupes", tdupes,
  132. "rate_limited", trlHits,
  133. )
  134. }
  135. }
  136. }
  137. // runWorker opens one gRPC stream and drives it at the target rate.
  138. func runWorker(
  139. ctx context.Context,
  140. logger *slog.Logger,
  141. target, apiKey, sourceID, secret string,
  142. rate int,
  143. duration, rampUp time.Duration,
  144. dedupePct int,
  145. dedupeKey string,
  146. payloadB int,
  147. res *workerResult,
  148. rttHist *atomicHistogram,
  149. ) {
  150. // Connect.
  151. c, err := grpcclient.New(target,
  152. grpcclient.WithAPIKey(apiKey),
  153. grpcclient.WithInsecure(), // loadgen — use mTLS in production
  154. )
  155. if err != nil {
  156. logger.Error("grpcclient.New", "worker", 0, "err", err)
  157. return
  158. }
  159. defer c.Close()
  160. // Open stream.
  161. stream, err := c.Stream(ctx)
  162. if err != nil {
  163. logger.Error("Client.Stream", "err", err)
  164. return
  165. }
  166. defer stream.CloseSend()
  167. // Producer goroutine.
  168. prodCtx, cancelProd := context.WithTimeout(ctx, duration)
  169. defer cancelProd()
  170. type pendingAck struct {
  171. sentAt time.Time
  172. dk string
  173. }
  174. ackCh := make(chan pendingAck, rate*2)
  175. p := pacer.New(rate, rampUp)
  176. tickCh, stopPacer := p.Tick(prodCtx)
  177. defer stopPacer()
  178. go func() {
  179. i := 0
  180. for {
  181. select {
  182. case <-prodCtx.Done():
  183. stream.CloseSend()
  184. close(ackCh)
  185. return
  186. case <-tickCh:
  187. a := mkAlert("normal", companyFromKey(apiKey), sourceID, dedupePct, dedupeKey, payloadB)
  188. if err := stream.Send(prodCtx, alertToProto(companyFromKey(apiKey), sourceID, a)); err != nil {
  189. // stream.Send (loadgen wrapper) already retries 3x on
  190. // retriable codes. Treat any error here as terminal
  191. // for this tick. Close the stream and ackCh so the
  192. // consumer's 'for p := range ackCh' loop exits; wg.Wait
  193. // in main() then unblocks and the worker can shut down.
  194. // Without this close, the consumer spins forever on a
  195. // dead stream and the whole loadgen hangs past --duration.
  196. logger.Error("send failed", "err", err)
  197. res.failed++
  198. stream.CloseSend()
  199. close(ackCh)
  200. return
  201. }
  202. // Push to ackCh inside the select so prodCtx.Done() can
  203. // unblock the producer when --duration expires. Without
  204. // this, a full ackCh blocks the producer indefinitely
  205. // (the consumer drains at the server's ack rate, which
  206. // may be much slower than the pacer under backpressure).
  207. select {
  208. case <-prodCtx.Done():
  209. stream.CloseSend()
  210. close(ackCh)
  211. return
  212. case ackCh <- pendingAck{sentAt: time.Now(), dk: a.DedupeKey}:
  213. }
  214. i++
  215. }
  216. }
  217. }()
  218. // Consumer goroutine — reads acks and computes RTT.
  219. go func() {
  220. for p := range ackCh {
  221. ack, err := stream.Recv(prodCtx)
  222. if err != nil {
  223. // Stream is dead or ctx cancelled. Exit the consumer so
  224. // wg.Wait in main() can unblock — otherwise the loadgen
  225. // hangs past --duration even though the producer exited
  226. // cleanly via the Send-error or prodCtx.Done() path.
  227. return
  228. }
  229. rttMs := time.Since(p.sentAt).Milliseconds()
  230. rttHist.Record(rttMs)
  231. if ack.GetError() != nil {
  232. if ack.GetError().Code == pbv1.Error_RATE_LIMITED {
  233. res.rlHits++
  234. } else {
  235. res.failed++
  236. }
  237. } else {
  238. res.sent++
  239. if ack.DedupeCount > 1 {
  240. res.dupes++
  241. }
  242. }
  243. }
  244. }()
  245. <-prodCtx.Done()
  246. }
  247. // companyFromKey extracts company_id from the api-key "company:source:secret".
  248. func companyFromKey(apiKey string) string {
  249. parts := strings.SplitN(apiKey, ":", 3)
  250. if len(parts) >= 1 {
  251. return parts[0]
  252. }
  253. return "acme-001"
  254. }
  255. func mkAlert(mode, companyID, sourceID string, dedupePct int, dedupeKey string, payloadB int) alert.Alert {
  256. severity := pickSeverity(mode)
  257. category := pickCategory(severity)
  258. dk := ""
  259. if dedupeKey != "" {
  260. dk = dedupeKey
  261. } else if rand.IntN(100) < dedupePct {
  262. dk = fmt.Sprintf("burst:%s:probe", category)
  263. }
  264. data := map[string]string{
  265. "host": fmt.Sprintf("host-%d", rand.IntN(100)),
  266. "probe": category,
  267. "raw_msg": strings.Repeat("x", max(0, payloadB-64)),
  268. }
  269. return alert.Alert{
  270. CompanyID: companyID,
  271. SourceID: sourceID,
  272. Severity: severity,
  273. Category: category,
  274. Title: fmt.Sprintf("%s on %s", category, data["host"]),
  275. Body: "synthetic loadgen alert",
  276. Data: data,
  277. DedupeKey: dk,
  278. }
  279. }
  280. func alertToProto(companyID, sourceID string, a alert.Alert) *pbv1.Alert {
  281. data := make(map[string]string)
  282. for k, v := range a.Data {
  283. data[k] = v
  284. }
  285. return &pbv1.Alert{
  286. CompanyId: companyID,
  287. SourceId: sourceID,
  288. Severity: string(a.Severity),
  289. Category: a.Category,
  290. Title: a.Title,
  291. Body: a.Body,
  292. DedupeKey: a.DedupeKey,
  293. ClientTsMs: time.Now().UnixMilli(),
  294. Data: data,
  295. }
  296. }
  297. func pickSeverity(mode string) alert.Severity {
  298. r := rand.IntN(100)
  299. switch {
  300. case r < 70:
  301. return alert.SeverityInfo
  302. case r < 95:
  303. return alert.SeverityWarning
  304. case r < 99:
  305. return alert.SeverityCritical
  306. default:
  307. return alert.SeverityInminentColapse
  308. }
  309. }
  310. var categoriesBySev = map[alert.Severity][]string{
  311. alert.SeverityInfo: {"deploy", "schedule", "audit"},
  312. alert.SeverityWarning: {"disk", "memory", "latency", "queue"},
  313. alert.SeverityCritical: {"storage", "network", "process"},
  314. alert.SeverityInminentColapse: {"power", "hvac", "rack"},
  315. }
  316. func pickCategory(s alert.Severity) string {
  317. opts := categoriesBySev[s]
  318. return opts[rand.IntN(len(opts))]
  319. }
  320. // atomicHistogram is a lock-free fixed-size histogram for RTT values.
  321. // Buckets: 0-1ms, 1-5ms, 5-10ms, 10-25ms, 25-50ms, 50-100ms, 100-250ms, 250ms+.
  322. type atomicHistogram struct {
  323. buckets [8]atomic.Uint64
  324. }
  325. func (h *atomicHistogram) Record(ms int64) {
  326. var bucket int
  327. switch {
  328. case ms < 1:
  329. bucket = 0
  330. case ms < 5:
  331. bucket = 1
  332. case ms < 10:
  333. bucket = 2
  334. case ms < 25:
  335. bucket = 3
  336. case ms < 50:
  337. bucket = 4
  338. case ms < 100:
  339. bucket = 5
  340. case ms < 250:
  341. bucket = 6
  342. default:
  343. bucket = 7
  344. }
  345. h.buckets[bucket].Add(1)
  346. }
  347. func (h *atomicHistogram) String() string {
  348. var total uint64
  349. for i := 0; i < len(h.buckets); i++ {
  350. total += h.buckets[i].Load()
  351. }
  352. if total == 0 {
  353. return "no data"
  354. }
  355. return fmt.Sprintf("total=%d p50=%s p99=%s",
  356. total, h.percentile(50), h.percentile(99))
  357. }
  358. func (h *atomicHistogram) percentile(p int) string {
  359. var total uint64
  360. for i := 0; i < len(h.buckets); i++ {
  361. total += h.buckets[i].Load()
  362. }
  363. threshold := uint64(float64(total) * float64(p) / 100.0)
  364. var cumulative uint64
  365. bounds := []string{"<1ms", "1-5ms", "5-10ms", "10-25ms", "25-50ms", "50-100ms", "100-250ms", ">250ms"}
  366. for i := 0; i < len(h.buckets); i++ {
  367. cumulative += h.buckets[i].Load()
  368. if cumulative >= threshold {
  369. return bounds[i]
  370. }
  371. }
  372. return ">250ms"
  373. }
  374. func runMetrics(addr, clusterID, instance string, sent, failed, dupes, rlHits *atomic.Uint64) {
  375. mux := http.NewServeMux()
  376. mux.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
  377. fmt.Fprintf(w, "# HELP loadgen_alerts_sent_total Alerts successfully accepted.\n")
  378. fmt.Fprintf(w, "# TYPE loadgen_alerts_sent_total counter\n")
  379. fmt.Fprintf(w, "loadgen_alerts_sent_total{instance=%q,cluster_id=%q} %d\n",
  380. instance, clusterID, sent.Load())
  381. fmt.Fprintf(w, "loadgen_alerts_failed_total{instance=%q,cluster_id=%q} %d\n",
  382. instance, clusterID, failed.Load())
  383. fmt.Fprintf(w, "loadgen_dedupe_hits_total{instance=%q,cluster_id=%q} %d\n",
  384. instance, clusterID, dupes.Load())
  385. fmt.Fprintf(w, "loadgen_rate_limited_total{instance=%q,cluster_id=%q} %d\n",
  386. instance, clusterID, rlHits.Load())
  387. })
  388. srv := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
  389. _ = srv.ListenAndServe()
  390. }
  391. func printFinal(logger *slog.Logger, sent, failed, dupes, rlHits uint64, rttHist *atomicHistogram) {
  392. logger.Info("done",
  393. "sent", sent,
  394. "failed", failed,
  395. "dupes", dupes,
  396. "rate_limited", rlHits,
  397. "rtt", rttHist.String(),
  398. )
  399. }
  400. func max(a, b int) int {
  401. if a > b {
  402. return a
  403. }
  404. return b
  405. }
  406. var _ = strconv.FormatInt // for future use