main.go 11 KB

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