main.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422
  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. Counter + exit.
  192. logger.Error("send failed", "err", err)
  193. res.failed++
  194. return
  195. }
  196. ackCh <- pendingAck{sentAt: time.Now(), dk: a.DedupeKey}
  197. i++
  198. }
  199. }
  200. }()
  201. // Consumer goroutine — reads acks and computes RTT.
  202. go func() {
  203. for p := range ackCh {
  204. ack, err := stream.Recv(prodCtx)
  205. if err != nil {
  206. continue
  207. }
  208. rttMs := time.Since(p.sentAt).Milliseconds()
  209. rttHist.Record(rttMs)
  210. if ack.GetError() != nil {
  211. if ack.GetError().Code == pbv1.Error_RATE_LIMITED {
  212. res.rlHits++
  213. } else {
  214. res.failed++
  215. }
  216. } else {
  217. res.sent++
  218. if ack.DedupeCount > 1 {
  219. res.dupes++
  220. }
  221. }
  222. }
  223. }()
  224. <-prodCtx.Done()
  225. }
  226. // companyFromKey extracts company_id from the api-key "company:source:secret".
  227. func companyFromKey(apiKey string) string {
  228. parts := strings.SplitN(apiKey, ":", 3)
  229. if len(parts) >= 1 {
  230. return parts[0]
  231. }
  232. return "acme-001"
  233. }
  234. func mkAlert(mode, companyID, sourceID string, dedupePct int, dedupeKey string, payloadB int) alert.Alert {
  235. severity := pickSeverity(mode)
  236. category := pickCategory(severity)
  237. dk := ""
  238. if dedupeKey != "" {
  239. dk = dedupeKey
  240. } else if rand.IntN(100) < dedupePct {
  241. dk = fmt.Sprintf("burst:%s:probe", category)
  242. }
  243. data := map[string]string{
  244. "host": fmt.Sprintf("host-%d", rand.IntN(100)),
  245. "probe": category,
  246. "raw_msg": strings.Repeat("x", max(0, payloadB-64)),
  247. }
  248. return alert.Alert{
  249. CompanyID: companyID,
  250. SourceID: sourceID,
  251. Severity: severity,
  252. Category: category,
  253. Title: fmt.Sprintf("%s on %s", category, data["host"]),
  254. Body: "synthetic loadgen alert",
  255. Data: data,
  256. DedupeKey: dk,
  257. }
  258. }
  259. func alertToProto(companyID, sourceID string, a alert.Alert) *pbv1.Alert {
  260. data := make(map[string]string)
  261. for k, v := range a.Data {
  262. data[k] = v
  263. }
  264. return &pbv1.Alert{
  265. CompanyId: companyID,
  266. SourceId: sourceID,
  267. Severity: string(a.Severity),
  268. Category: a.Category,
  269. Title: a.Title,
  270. Body: a.Body,
  271. DedupeKey: a.DedupeKey,
  272. ClientTsMs: time.Now().UnixMilli(),
  273. Data: data,
  274. }
  275. }
  276. func pickSeverity(mode string) alert.Severity {
  277. r := rand.IntN(100)
  278. switch {
  279. case r < 70:
  280. return alert.SeverityInfo
  281. case r < 95:
  282. return alert.SeverityWarning
  283. case r < 99:
  284. return alert.SeverityCritical
  285. default:
  286. return alert.SeverityInminentColapse
  287. }
  288. }
  289. var categoriesBySev = map[alert.Severity][]string{
  290. alert.SeverityInfo: {"deploy", "schedule", "audit"},
  291. alert.SeverityWarning: {"disk", "memory", "latency", "queue"},
  292. alert.SeverityCritical: {"storage", "network", "process"},
  293. alert.SeverityInminentColapse: {"power", "hvac", "rack"},
  294. }
  295. func pickCategory(s alert.Severity) string {
  296. opts := categoriesBySev[s]
  297. return opts[rand.IntN(len(opts))]
  298. }
  299. // atomicHistogram is a lock-free fixed-size histogram for RTT values.
  300. // Buckets: 0-1ms, 1-5ms, 5-10ms, 10-25ms, 25-50ms, 50-100ms, 100-250ms, 250ms+.
  301. type atomicHistogram struct {
  302. buckets [8]atomic.Uint64
  303. }
  304. func (h *atomicHistogram) Record(ms int64) {
  305. var bucket int
  306. switch {
  307. case ms < 1:
  308. bucket = 0
  309. case ms < 5:
  310. bucket = 1
  311. case ms < 10:
  312. bucket = 2
  313. case ms < 25:
  314. bucket = 3
  315. case ms < 50:
  316. bucket = 4
  317. case ms < 100:
  318. bucket = 5
  319. case ms < 250:
  320. bucket = 6
  321. default:
  322. bucket = 7
  323. }
  324. h.buckets[bucket].Add(1)
  325. }
  326. func (h *atomicHistogram) String() string {
  327. var total uint64
  328. for i := 0; i < len(h.buckets); i++ {
  329. total += h.buckets[i].Load()
  330. }
  331. if total == 0 {
  332. return "no data"
  333. }
  334. return fmt.Sprintf("total=%d p50=%s p99=%s",
  335. total, h.percentile(50), h.percentile(99))
  336. }
  337. func (h *atomicHistogram) percentile(p int) string {
  338. var total uint64
  339. for i := 0; i < len(h.buckets); i++ {
  340. total += h.buckets[i].Load()
  341. }
  342. threshold := uint64(float64(total) * float64(p) / 100.0)
  343. var cumulative uint64
  344. bounds := []string{"<1ms", "1-5ms", "5-10ms", "10-25ms", "25-50ms", "50-100ms", "100-250ms", ">250ms"}
  345. for i := 0; i < len(h.buckets); i++ {
  346. cumulative += h.buckets[i].Load()
  347. if cumulative >= threshold {
  348. return bounds[i]
  349. }
  350. }
  351. return ">250ms"
  352. }
  353. func runMetrics(addr, clusterID, instance string, sent, failed, dupes, rlHits *atomic.Uint64) {
  354. mux := http.NewServeMux()
  355. mux.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
  356. fmt.Fprintf(w, "# HELP loadgen_alerts_sent_total Alerts successfully accepted.\n")
  357. fmt.Fprintf(w, "# TYPE loadgen_alerts_sent_total counter\n")
  358. fmt.Fprintf(w, "loadgen_alerts_sent_total{instance=%q,cluster_id=%q} %d\n",
  359. instance, clusterID, sent.Load())
  360. fmt.Fprintf(w, "loadgen_alerts_failed_total{instance=%q,cluster_id=%q} %d\n",
  361. instance, clusterID, failed.Load())
  362. fmt.Fprintf(w, "loadgen_dedupe_hits_total{instance=%q,cluster_id=%q} %d\n",
  363. instance, clusterID, dupes.Load())
  364. fmt.Fprintf(w, "loadgen_rate_limited_total{instance=%q,cluster_id=%q} %d\n",
  365. instance, clusterID, rlHits.Load())
  366. })
  367. srv := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
  368. _ = srv.ListenAndServe()
  369. }
  370. func printFinal(logger *slog.Logger, sent, failed, dupes, rlHits uint64, rttHist *atomicHistogram) {
  371. logger.Info("done",
  372. "sent", sent,
  373. "failed", failed,
  374. "dupes", dupes,
  375. "rate_limited", rlHits,
  376. "rtt", rttHist.String(),
  377. )
  378. }
  379. func max(a, b int) int {
  380. if a > b {
  381. return a
  382. }
  383. return b
  384. }
  385. var _ = strconv.FormatInt // for future use