|
@@ -0,0 +1,498 @@
|
|
|
|
|
+# M11 — Detailed Implementation Plan
|
|
|
|
|
+
|
|
|
|
|
+**Status:** planning (post-M10)
|
|
|
|
|
+**Target:** M11 exit criterion from SPEC §19
|
|
|
|
|
+**Goal:** Internal Go services can push ≥ 10k alerts/sec on a single gRPC bidi stream, sustained 10 min, p99 server-side `Ack` ≤ 50ms.
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 0. Recap — what M11 must prove
|
|
|
|
|
+
|
|
|
|
|
+| Milestone | Exit criterion | How it's measured |
|
|
|
|
|
+|---|---|---|
|
|
|
|
|
+| **M11** | internal Go service can publish ≥ 10k alerts/sec on one gRPC bidi stream, sustained 10 min, p99 server-side `Ack` ≤ 50ms | `m11_smoke.py` runs a gRPC loadgen against `ingestd:9090`, queries Prometheus, asserts on rate + p99 + DLQ |
|
|
|
|
|
+
|
|
|
|
|
+**Why this matters beyond "another transport":** HTTP/WS/MQTT each cap out around 1–2k/s per source due to per-request overhead, JSON parsing, or broker round-trips. A first-party Go producer (a peer service in our infra) needs an order-of-magnitude higher ceiling **with strict schemas and explicit backpressure**. gRPC + HTTP/2 + protobuf is the only stack that hits that bar cleanly.
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 1. Workstream overview
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+┌──────────────────────────┐ ┌──────────────────────────┐ ┌──────────────────────────┐
|
|
|
|
|
+│ W1: proto + codegen │ │ W2: grpcserver in │ │ W3: grpcclient lib + │
|
|
|
|
|
+│ (.proto + buf + stubs) │───▶│ ingestd (port 9090) │───▶│ reusable Go client │
|
|
|
|
|
+└──────────────────────────┘ └──────────────┬───────────┘ └──────────────┬───────────┘
|
|
|
|
|
+ │ │
|
|
|
|
|
+ ▼ ▼
|
|
|
|
|
+ ┌──────────────────────────┐ ┌──────────────────────────┐
|
|
|
|
|
+ │ W4: gRPC loadgen │ │ W5: smoke + verify │
|
|
|
|
|
+ │ (Go binary, multi- │───▶│ (m11_smoke.py, 3 green │
|
|
|
|
|
+ │ stream, paced) │ │ runs, M11_VERIFICATION)│
|
|
|
|
|
+ └──────────────────────────┘ └──────────────────────────┘
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Five workstreams, all on `master`, each ends with a passing test before moving on.
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 2. The four layers of risk (and what each WS defuses)
|
|
|
|
|
+
|
|
|
|
|
+| Risk | Why it's scary | How we defuse it |
|
|
|
|
|
+|---|---|---|
|
|
|
|
|
+| **Proto schema drift** | Multiple teams consume these stubs; a v2 bump without a plan is a v1+1 outage | W1: checked-in `.proto`, `buf generate` in CI, no manual edits to `gen/` |
|
|
|
|
|
+| **Server can't sustain 10k/s** | Different concurrency model from HTTP (1 stream = N inflight messages, not 1 conn = 1 req). Naive impl will serialize. | W2: bounded concurrent workers per stream + `sem` on dedupe/publish. Load test in W4 finds the wall. |
|
|
|
|
|
+| **Backpressure invisible to client** | gRPC stream silently buffers. Slow source OOMs the server. | W2: server enforces max-256-inflight; sends `Error.RATE_LIMITED` with `retry_after_ms` when over. Client (W3) honors it. |
|
|
|
|
|
+| **Throughput claim is hand-wavy** | "10k/s" without a measurement plan is just a wish | W5: `m11_smoke.py` runs Go loadgen (not Python) for 10 min, asserts p99 ≤ 50ms + rate ≥ 10k/s, mirrors M10's assertion harness |
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 3. Workstream W1 — Proto schema + code generation
|
|
|
|
|
+
|
|
|
|
|
+### 3.1 Repo additions
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+proto/
|
|
|
|
|
+ buf.yaml # buf module config (v1, go package prefix)
|
|
|
|
|
+ buf.gen.yaml # codegen config → gen/go/
|
|
|
|
|
+ broadannounce/v1/
|
|
|
|
|
+ ingest.proto # the Ingest service + Alert + Ack (from SPEC §19)
|
|
|
|
|
+gen/
|
|
|
|
|
+ go/broadannounce/v1/ # generated stubs (do not edit; CI regenerates)
|
|
|
|
|
+ go/broadannounce/v1/ingest.pb.go
|
|
|
|
|
+ go/broadannounce/v1/ingest_grpc.pb.go
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 3.2 .proto content
|
|
|
|
|
+
|
|
|
|
|
+Exactly as written in SPEC §19 — `Ingest.StreamAlerts` (bidi), `Alert` (9 fields), `Ack` (result oneof with Ok/Error), `Error.Code` enum (UNKNOWN/UNAUTHENTICATED/RATE_LIMITED/INVALID/INTERNAL), plus `retry_after_ms`.
|
|
|
|
|
+
|
|
|
|
|
+### 3.3 Code generation
|
|
|
|
|
+
|
|
|
|
|
+- Add `buf` to dev tooling (Makefile target `proto`)
|
|
|
|
|
+- `buf generate` → outputs to `gen/go/`
|
|
|
|
|
+- `gen/` is committed (per repo convention — see existing `internal/...` is committed)
|
|
|
|
|
+- Pre-commit / CI hook runs `buf format --check` and `buf lint`
|
|
|
|
|
+
|
|
|
|
|
+### 3.4 Go module deps
|
|
|
|
|
+
|
|
|
|
|
+Add to `go.mod`:
|
|
|
|
|
+```
|
|
|
|
|
+google.golang.org/grpc v1.6X.0
|
|
|
|
|
+google.golang.org/protobuf v1.3X.0
|
|
|
|
|
+github.com/bufbuild/buf (tool-only, not in go.mod)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 3.5 Exit criteria for W1
|
|
|
|
|
+
|
|
|
|
|
+- `buf generate` runs clean from a clean clone
|
|
|
|
|
+- `go build ./gen/...` succeeds
|
|
|
|
|
+- A trivial `grpcurl -plaintext localhost:9090 list` returns `broadannounce.v1.Ingest`
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 4. Workstream W2 — gRPC server inside `ingestd`
|
|
|
|
|
+
|
|
|
|
|
+### 4.1 Skeleton
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+internal/grpcserver/
|
|
|
|
|
+ server.go # gRPC server lifecycle, register, graceful stop
|
|
|
|
|
+ handler.go # StreamAlerts implementation
|
|
|
|
|
+ auth.go # API key validation (shared with HTTP)
|
|
|
|
|
+ ratelimit.go # per-stream sliding-window rate limit
|
|
|
|
|
+ flowcontrol.go # in-flight cap, per-message Error.RATE_LIMITED
|
|
|
|
|
+ server_test.go # table-driven tests: auth, dedupe, rate limit, flow control
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 4.2 Server lifecycle (in `cmd/ingestd/main.go`)
|
|
|
|
|
+
|
|
|
|
|
+```go
|
|
|
|
|
+// New in main():
|
|
|
|
|
+grpcServer := grpcserver.New(grpcserver.Deps{
|
|
|
|
|
+ Broker: br,
|
|
|
|
|
+ Dedupe: ded,
|
|
|
|
|
+ Limiter: limiter,
|
|
|
|
|
+ Quarantine: quarantine,
|
|
|
|
|
+ Circuit: cb,
|
|
|
|
|
+ Logger: logger,
|
|
|
|
|
+})
|
|
|
|
|
+go grpcServer.Serve(cfg.GRPCAddr) // :9090 default
|
|
|
|
|
+// defer grpcServer.GracefulStop(15 * time.Second)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+New config:
|
|
|
|
|
+```go
|
|
|
|
|
+// internal/config/config.go
|
|
|
|
|
+GRPCAddr string // BA_INGESTD_GRPC_ADDR, default ":9090"
|
|
|
|
|
+GRPCMaxInflight int // BA_INGESTD_GRPC_MAX_INFLIGHT, default 256
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 4.3 Stream lifecycle (the meat)
|
|
|
|
|
+
|
|
|
|
|
+```go
|
|
|
|
|
+func (s *Server) StreamAlerts(stream pb.Ingest_StreamAlertsServer) error {
|
|
|
|
|
+ ctx := stream.Context()
|
|
|
|
|
+
|
|
|
|
|
+ // 1. Auth — pull API key from metadata, hash, look up source
|
|
|
|
|
+ src, err := s.authenticate(ctx)
|
|
|
|
|
+ if err != nil { return status.Error(codes.Unauthenticated, "...") }
|
|
|
|
|
+
|
|
|
|
|
+ // 2. Per-stream sem (in-flight cap)
|
|
|
|
|
+ sem := make(chan struct{}, s.MaxInflight) // default 256
|
|
|
|
|
+
|
|
|
|
|
+ // 3. Per-stream sliding-window rate limit
|
|
|
|
|
+ rl := s.limiter.New(src.SourceID, src.RateLimitPerSec)
|
|
|
|
|
+
|
|
|
|
|
+ // 4. Loop: receive → process → send Ack
|
|
|
|
|
+ for {
|
|
|
|
|
+ msg, err := stream.Recv()
|
|
|
|
|
+ if err == io.EOF { return nil }
|
|
|
|
|
+ if err != nil { return err }
|
|
|
|
|
+
|
|
|
|
|
+ // Rate-limit check FIRST (cheaper than dedupe)
|
|
|
|
|
+ if !rl.Allow() {
|
|
|
|
|
+ ack := &pb.Ack{
|
|
|
|
|
+ AlertId: msg.DedupeKey, // best-effort correlation
|
|
|
|
|
+ DedupeKey: msg.DedupeKey,
|
|
|
|
|
+ AcceptedAtMs: time.Now().UnixMilli(),
|
|
|
|
|
+ Result: &pb.Ack_Error{
|
|
|
|
|
+ Error: &pb.Error{
|
|
|
|
|
+ Code: pb.Error_RATE_LIMITED,
|
|
|
|
|
+ Message: "per-stream rate limit exceeded",
|
|
|
|
|
+ RetryAfterMs: 100,
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+ stream.Send(ack)
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 5. Bounded concurrency — this is what gets us to 10k/s
|
|
|
|
|
+ sem <- struct{}{}
|
|
|
|
|
+ go func(m *pb.Alert) {
|
|
|
|
|
+ defer func() { <-sem }()
|
|
|
|
|
+
|
|
|
|
|
+ // Shared pipeline (same as HTTP/MQTT path)
|
|
|
|
|
+ result := s.pipeline.Process(ctx, m, src)
|
|
|
|
|
+
|
|
|
|
|
+ ack := buildAck(m, result) // dedupe_count, accepted_at_ms
|
|
|
|
|
+ stream.Send(ack)
|
|
|
|
|
+ }(msg)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**Why the goroutine + sem:** the dedupe Redis call + NATS publish is the slow path (~2–5ms total). If we serialize, we cap at ~250 msg/s. With 256-way concurrency, we cap at ~50k msg/s on the server side. This is the core insight that takes us from 1k/s (HTTP) to 10k/s (gRPC).
|
|
|
|
|
+
|
|
|
|
|
+### 4.4 Pipeline reuse
|
|
|
|
|
+
|
|
|
|
|
+The HTTP path has `processDeps.Process(ctx, alert, source) Result`. We extract that into a shared `internal/pipeline/` package (small refactor as part of W2 — W2.1) so gRPC and HTTP both call the same `pipeline.Process`. This is a **deliberate** refactor: M11 is the first time we have a second transport sharing the pipeline, so DRY-ing it now is the right time. (If we copy-paste, M12/M13 will diverge.)
|
|
|
|
|
+
|
|
|
|
|
+**Estimated refactor scope:** 100–200 lines moved from `cmd/ingestd/process.go` to `internal/pipeline/`. No behavior change. Existing M0–M9 smoke tests must still pass.
|
|
|
|
|
+
|
|
|
|
|
+### 4.5 Keepalive + dead-stream detection
|
|
|
|
|
+
|
|
|
|
|
+```go
|
|
|
|
|
+// In server.go
|
|
|
|
|
+grpc.KeepaliveParams(keepalive.ServerParameters{
|
|
|
|
|
+ Time: 30 * time.Second,
|
|
|
|
|
+ Timeout: 10 * time.Second,
|
|
|
|
|
+})
|
|
|
|
|
+grpc.KeepalivePolicy(keepalive.EnforcementPolicy{
|
|
|
|
|
+ MinTime: 5 * time.Second,
|
|
|
|
|
+ PermittingWithoutStream: false,
|
|
|
|
|
+})
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 4.6 Metrics (layer 6/7)
|
|
|
|
|
+
|
|
|
|
|
+Add to `internal/observability/`:
|
|
|
|
|
+- `ba_ingestd_grpc_streams_active` (gauge)
|
|
|
|
|
+- `ba_ingestd_grpc_inflight_per_stream` (histogram, label `source_id`)
|
|
|
|
|
+- `ba_ingestd_grpc_rate_limited_total{source_id}` (counter)
|
|
|
|
|
+- `ba_ingestd_grpc_ack_latency_seconds{source_id}` (histogram, buckets: 1/5/10/25/50/100/250/500ms) — **this is the M11 exit metric**
|
|
|
|
|
+- `ba_ingestd_alerts_received_total{transport="grpc",result=...}` (counter — extends M0 counter)
|
|
|
|
|
+
|
|
|
|
|
+### 4.7 Tests (table-driven, all in-process via `grpc-go` test helpers)
|
|
|
|
|
+
|
|
|
|
|
+| Test | Asserts |
|
|
|
|
|
+|---|---|
|
|
|
|
|
+| `TestStream_HappyPath` | 100 alerts → 100 Acks, dedupe_count=1 each, p99 < 50ms |
|
|
|
|
|
+| `TestStream_AuthFail` | bad key → stream closed, `codes.Unauthenticated` |
|
|
|
|
|
+| `TestStream_Dedupe` | 100 identical dedupe_keys → first Ack has count=1, rest have count>1 |
|
|
|
|
|
+| `TestStream_RateLimit` | source rate=100/s, send 1000 in 1s → ~100 Ok + ~900 RATE_LIMITED |
|
|
|
|
|
+| `TestStream_InFlightCap` | slow pipeline → RATE_LIMITED with `retry_after_ms` |
|
|
|
|
|
+| `TestStream_BrokerDown` | NATS unreachable → CB opens, stream sees INTERNAL, no panic |
|
|
|
|
|
+| `TestStream_ConcurrentStreams` | 10 streams × 1k/s each → 10k/s total, no message loss |
|
|
|
|
|
+
|
|
|
|
|
+### 4.8 Exit criteria for W2
|
|
|
|
|
+
|
|
|
|
|
+- All 7 tests green
|
|
|
|
|
+- `go test ./internal/grpcserver/...` passes
|
|
|
|
|
+- M0–M9 smoke tests still pass (regression check on pipeline refactor)
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 5. Workstream W3 — Reusable gRPC client library
|
|
|
|
|
+
|
|
|
|
|
+### 5.1 Skeleton
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+internal/grpcclient/
|
|
|
|
|
+ client.go # Dial + StreamAlerts wrapper
|
|
|
|
|
+ backoff.go # honors Error.RATE_LIMITED.retry_after_ms
|
|
|
|
|
+ options.go # functional opts: WithAPIKey, WithMaxRetries, WithKeepalive
|
|
|
|
|
+ client_test.go # against a mock server (in-process)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 5.2 API
|
|
|
|
|
+
|
|
|
|
|
+```go
|
|
|
|
|
+// internal/grpcclient/client.go
|
|
|
|
|
+package grpcclient
|
|
|
|
|
+
|
|
|
|
|
+type Client struct {
|
|
|
|
|
+ conn *grpc.ClientConn
|
|
|
|
|
+ client pb.IngestClient
|
|
|
|
|
+ apiKey string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type Option func(*Client)
|
|
|
|
|
+
|
|
|
|
|
+func New(addr string, opts ...Option) (*Client, error) { ... }
|
|
|
|
|
+func WithAPIKey(k string) Option { ... }
|
|
|
|
|
+func WithMaxRetries(n int) Option { ... }
|
|
|
|
|
+func WithKeepalive(time, timeout time.Duration) Option { ... }
|
|
|
|
|
+
|
|
|
|
|
+func (c *Client) Stream(ctx context.Context) (Stream, error) {
|
|
|
|
|
+ stream, err := c.client.StreamAlerts(ctx)
|
|
|
|
|
+ return Stream{stream: stream}, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type Stream struct {
|
|
|
|
|
+ stream pb.Ingest_StreamAlertsClient
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func (s *Stream) Send(a *pb.Alert) error { ... }
|
|
|
|
|
+func (s *Stream) Recv() (*pb.Ack, error) { ... }
|
|
|
|
|
+func (s *Stream) CloseSend() error { ... }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 5.3 What it gives other teams
|
|
|
|
|
+
|
|
|
|
|
+A peer Go service does:
|
|
|
|
|
+```go
|
|
|
|
|
+c, _ := grpcclient.New("ingestd.tenant.svc.cluster.local:9090",
|
|
|
|
|
+ grpcclient.WithAPIKey(os.Getenv("BA_API_KEY")))
|
|
|
|
|
+stream, _ := c.Stream(ctx)
|
|
|
|
|
+go func() { // producer
|
|
|
|
|
+ for alert := range alertsCh {
|
|
|
|
|
+ stream.Send(alert)
|
|
|
|
|
+ }
|
|
|
|
|
+ stream.CloseSend()
|
|
|
|
|
+}()
|
|
|
|
|
+for { // consumer — handles rate limit + dedupe_count
|
|
|
|
|
+ ack, _ := stream.Recv()
|
|
|
|
|
+ metrics.RecordAck(ack)
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+That's the whole "publish to broad-announce" story in 10 lines. **This is the actual deliverable other teams consume.** The M11 server is the means; the client is the end.
|
|
|
|
|
+
|
|
|
|
|
+### 5.4 Exit criteria for W3
|
|
|
|
|
+
|
|
|
|
|
+- `internal/grpcclient` compiles, all tests pass
|
|
|
|
|
+- A 30-line example service (`cmd/example-grpc-producer/main.go`) demonstrates usage
|
|
|
|
|
+- `go vet` + `go test ./internal/grpcclient/...` clean
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 6. Workstream W4 — gRPC loadgen
|
|
|
|
|
+
|
|
|
|
|
+### 6.1 Repo addition
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+loadgen/
|
|
|
|
|
+ grpc.go # new — uses internal/grpcclient
|
|
|
|
|
+ http.go # existing
|
|
|
|
|
+ mqtt.go # existing
|
|
|
|
|
+ ws.go # existing
|
|
|
|
|
+ main.go # dispatch on --driver
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 6.2 Flags
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+--driver=grpc
|
|
|
|
|
+--target=ingestd:9090
|
|
|
|
|
+--api-key=acme-001:prom-prod:s3cret-acme
|
|
|
|
|
+--rate=10000 # alerts/sec
|
|
|
|
|
+--duration=10m
|
|
|
|
|
+--workers=8 # parallel streams
|
|
|
|
|
+--payload-size=512 # bytes per alert (realistic)
|
|
|
|
|
+--company-id=acme-001
|
|
|
|
|
+--source-id=prom-prod
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 6.3 How it works
|
|
|
|
|
+
|
|
|
|
|
+- Opens N streams (default 8)
|
|
|
|
|
+- Each stream is a producer goroutine that fires `--rate/N` alerts/sec via a paced `time.Ticker`
|
|
|
|
|
+- Each stream is also a consumer goroutine that reads Acks, records `accepted_at_ms - sent_at_ms` for p99
|
|
|
|
|
+- Atomic counters: `sent`, `acked_ok`, `acked_rate_limited`, `errors`
|
|
|
|
|
+- On `Error.RATE_LIMITED`, honor `retry_after_ms` (sleep, then resume) — this is the backpressure test
|
|
|
|
|
+
|
|
|
|
|
+### 6.4 Profile
|
|
|
|
|
+
|
|
|
|
|
+`docker-compose.yml` — new `loadgen-grpc` profile (separate from `loadgen` and `loadgen-m10`):
|
|
|
|
|
+
|
|
|
|
|
+```yaml
|
|
|
|
|
+profiles: ["loadgen-grpc"]
|
|
|
|
|
+services:
|
|
|
|
|
+ loadgen-grpc-1:
|
|
|
|
|
+ build: ./loadgen
|
|
|
|
|
+ command:
|
|
|
|
|
+ - --driver=grpc
|
|
|
|
|
+ - --target=ingestd:9090
|
|
|
|
|
+ - --rate=5000
|
|
|
|
|
+ - --duration=15m # soak is 10m; +ramp +cooldown
|
|
|
|
|
+ ...
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Two instances × 5k/s = 10k/s on the wire (matches M11 exit).
|
|
|
|
|
+
|
|
|
|
|
+### 6.5 Exit criteria for W4
|
|
|
|
|
+
|
|
|
|
|
+- `go build ./loadgen` succeeds
|
|
|
|
|
+- `docker compose --profile loadgen-grpc up -d` starts clean
|
|
|
|
|
+- A 1-minute dry run shows ≥ 5k/s per instance on the wire (Prom confirms)
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 7. Workstream W5 — Smoke test + verification
|
|
|
|
|
+
|
|
|
|
|
+### 7.1 Scripts
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+scripts/m11_smoke.py # assertion harness
|
|
|
|
|
+M11_SMOKE_LOG.md # raw log of 3 green runs
|
|
|
|
|
+M11_VERIFICATION.md # what landed, evidence, exit criterion proof
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 7.2 What `m11_smoke.py` does
|
|
|
|
|
+
|
|
|
|
|
+Mirrors `m10_smoke.py` structure:
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+Step 1 — pre-flight
|
|
|
|
|
+ - ingestd :9090 gRPC port is listening
|
|
|
|
|
+ - prometheus has grpc_streams_active, grpc_ack_latency_seconds
|
|
|
|
|
+ - loadgen-grpc-1, loadgen-grpc-2 are in scrape targets
|
|
|
|
|
+
|
|
|
|
|
+Step 2 — start gRPC loadgen cluster
|
|
|
|
|
+ - docker compose --profile loadgen-grpc up -d
|
|
|
|
|
+ - wait 15s for ramp-up
|
|
|
|
|
+
|
|
|
|
|
+Step 3 — soak for 10 minutes
|
|
|
|
|
+ - every 30s, sample:
|
|
|
|
|
+ - rate = sum(rate(ba_ingestd_alerts_received_total{transport="grpc",result="ok"}[30s]))
|
|
|
|
|
+ - p99 = histogram_quantile(0.99, rate(ba_ingestd_grpc_ack_latency_seconds_bucket[60s]))
|
|
|
|
|
+ - dlq = ba_dlq_messages_total (must stay 0)
|
|
|
|
|
+ - fail on:
|
|
|
|
|
+ - rate < 9k/s for any sample (tolerance ±10% of 10k)
|
|
|
|
|
+ - p99 > 50ms for any sample
|
|
|
|
|
+ - dlq > 0
|
|
|
|
|
+
|
|
|
|
|
+Step 4 — multi-stream backpressure test
|
|
|
|
|
+ - spawn 16 streams × 1k/s each
|
|
|
|
|
+ - assert: total = 16k/s, no message loss, all rate-limited Acks honored
|
|
|
|
|
+
|
|
|
|
|
+Step 5 — teardown
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 7.3 Exit criteria for W5 (= M11 exit)
|
|
|
|
|
+
|
|
|
|
|
+- 3 consecutive green runs on local docker-compose
|
|
|
|
|
+- 1 green run on remote playground `parres` (192.168.44.94)
|
|
|
|
|
+- `M11_VERIFICATION.md` shows:
|
|
|
|
|
+ - 20 soak samples ≥ 9k/s
|
|
|
|
|
+ - 20 soak samples p99 ≤ 50ms
|
|
|
|
|
+ - DLQ = 0 throughout
|
|
|
|
|
+ - Multi-stream backpressure test passes
|
|
|
|
|
+- SPEC.md M11 row updated to **✅ shipped YYYY-MM-DD**
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 8. Sequencing & parallelism
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+W1 ─────────────────┐
|
|
|
|
|
+ ├──▶ W2 ──▶ W3 ──┐
|
|
|
|
|
+ ├──▶ W4 ──▶ W5
|
|
|
|
|
+ (W2 is critical (W3 can start in (W5 is the
|
|
|
|
|
+ path — blocks parallel with W2 ship gate)
|
|
|
|
|
+ everything else) once grpcserver
|
|
|
|
|
+ interface is stable)
|
|
|
|
|
+
|
|
|
|
|
+Estimated wall time:
|
|
|
|
|
+ W1: 1 day (proto + codegen is small, but buf setup + CI is fiddly)
|
|
|
|
|
+ W2: 3 days (server impl + tests + pipeline refactor)
|
|
|
|
|
+ W3: 1 day (client lib is mostly wrapper code)
|
|
|
|
|
+ W4: 1 day (loadgen is just calling W3)
|
|
|
|
|
+ W5: 1 day (smoke is patterned on M10's, so fast)
|
|
|
|
|
+ Total: ~7 working days
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+W2.1 (pipeline refactor) is the riskiest sub-task. It should land first as a standalone commit, with M0–M9 smoke tests run after to prove no regression, **before** W2.2 (gRPC server) starts.
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 9. What M11 is *not*
|
|
|
|
|
+
|
|
|
|
|
+(Stolen from SPEC §19, reinforced here for the implementer)
|
|
|
|
|
+
|
|
|
|
|
+- **Not for public SaaS webhooks** (Grafana/Stripe/Datadog) — those stay on HTTP POST. Don't add a "gRPC webhook shim" — that's a different product.
|
|
|
|
|
+- **Not for browsers** — gRPC needs grpc-web + Envoy. The dashboard story stays on WebSocket.
|
|
|
|
|
+- **Not for IoT/PLCs** — MQTT is the right answer there. Don't migrate MQTT sources to gRPC.
|
|
|
|
|
+- **Not a gRPC gateway for the HTTP path** — HTTP and gRPC are siblings, not parent/child. Each has its own port, auth, metrics.
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 10. Open questions to resolve before W2
|
|
|
|
|
+
|
|
|
|
|
+1. **mTLS**: SPEC says "optional". Do we ship mTLS-ready code (cert plumbing, off by default) or defer? **Recommendation:** ship the cert plumbing, off by default — costs nothing, unblocks future compliance ask.
|
|
|
|
|
+2. **Stream ID propagation**: do we tag the stream with an opaque `stream_id` for OTel traces, or rely on `source_id`? **Recommendation:** add `stream_id` to logs/traces for forensic clarity (cheap).
|
|
|
|
|
+3. **Graceful shutdown on server**: when `ingestd` gets SIGTERM, do we drain in-flight Acks (up to 30s) or hard-close? **Recommendation:** drain for `ShutdownTimeout` (default 15s) — same as HTTP server.
|
|
|
|
|
+4. **Reconnect on transient broker failure**: client (W3) side. Exponential backoff? **Recommendation:** yes, 100ms → 1s cap, 3 retries, then surface to caller. Match typical gRPC client conventions.
|
|
|
|
|
+5. **Per-company rate limit on gRPC**: SPEC says "stream-level rate limit" but companies may have many gRPC sources. **Recommendation:** enforce BOTH (per-source first, then per-company as a backstop). Same as HTTP.
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 11. Definition of done
|
|
|
|
|
+
|
|
|
|
|
+- [ ] `buf generate` runs clean in CI
|
|
|
|
|
+- [ ] All 7 server tests green (`internal/grpcserver/...`)
|
|
|
|
|
+- [ ] All 3+ client tests green (`internal/grpcclient/...`)
|
|
|
|
|
+- [ ] M0–M9 smoke tests still green (pipeline refactor regression check)
|
|
|
|
|
+- [ ] `m11_smoke.py` passes 3× locally + 1× on parres
|
|
|
|
|
+- [ ] `M11_VERIFICATION.md` published with rate / p99 / DLQ evidence
|
|
|
|
|
+- [ ] `docs/sources/grpc.md` quickstart written
|
|
|
|
|
+- [ ] SPEC.md M11 row: **✅ shipped YYYY-MM-DD** with evidence one-liner
|
|
|
|
|
+- [ ] `cmd/example-grpc-producer/main.go` exists and runs
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 12. Risks & mitigations
|
|
|
|
|
+
|
|
|
|
|
+| Risk | Likelihood | Impact | Mitigation |
|
|
|
|
|
+|---|---|---|---|
|
|
|
|
|
+| Pipeline refactor breaks HTTP path | Medium | High (regression) | Land W2.1 standalone, run M0–M9 smoke before starting W2.2 |
|
|
|
|
|
+| gRPC server can't hit 10k/s on docker-compose | Medium | High (M11 exit fails) | W2 has bounded-concurrency design; W5 finds the wall early; fallback: bump MaxInflight to 512 (default 256) |
|
|
|
|
|
+| Backpressure not actually honored | Low | Medium (OOM risk) | W4 deliberately over-sends, asserts no message loss + no OOM |
|
|
|
|
|
+| `buf` toolchain friction in CI | Medium | Low (dev pain) | Pin buf version; document install in README |
|
|
|
|
|
+| Other teams don't migrate to gRPC | Low | Low (no value) | M11 client lib is opt-in; HTTP stays forever |
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+**Next step:** approval to start W1.
|