Files
gateway/conn_memory_test.go
zeekayandGitHub 69b5e25d47 feat(gateway): HIP-0110 standalone edge-process binary (#17)
* feat(middleware): own JWT validation + identity-strip + trust helper

Moves Auth + StripIdentityHeaders from zip/middleware to
gateway/middleware. Adds gateway/trust.go exporting AssertGatewayMinted
for downstream subsystems to verify the request flowed through gateway.

The unified cloud binary's pipeline now reads:
  zip generic middleware (Recover, Logger, RequestID, Telemetry, ...)
  gateway.middleware.StripIdentityHeaders
  gateway.middleware.Auth(verifier)
  ... downstream subsystem handlers ...

* feat(gateway): HIP-0110 ZAP wire envelopes + BuildApp entry

* feat(gateway): cmd/gateway HIP-0110 edge-process main

* refactor(gateway): tighten HIP-0110 surface — 588 LOC across 4 files

* build(gateway): fix module pins, MsgType uint16 overflow, drop legacy jaeger

- go.mod: hanzoai/cloud v0.0.0 → v0.1.0, hanzoai/zip v0.0.0 → v0.1.1
  (zip v0.1.1 carries the json/v2-at-edge work merged in zip#3;
   cloud v0.1.0 carries the types-leaf + clients refactor).
- zap_wire.go: MsgTypeForward/Push/Subscribe were 0x1010/0x1020/0x1030
  (uint16) but zap.FinishWithFlags expects the type in the upper byte
  (type << 8). Anything > 0xFF overflows uint16. Repacked into 0x80/0x81/0x82
  — still upper-half of the byte (no collision with base's 100-103 IDs).
- executor.go: dropped krakend-opencensus/v2/exporter/jaeger blank import.
  The exporter's Thrift API is incompatible with current jaeger-client-go;
  inter-service tracing is migrating to OTel (kotel) + ZAP anyway.
- cmd/gateway/main.go: c.SendString → c.Fiber().SendString (zip.Ctx
  exposes Send via the underlying fiber.Ctx; matches the pattern used
  for binary responses).

Verified: go build -mod=mod ./... clean. Binary at /tmp/gateway = 108 MiB.
TestStrip+TestAuth+TestSecure all pass.

* test(gateway): per-conn memory profile — 8 KiB/conn, 1:1 goroutine

Raw-TCP-dial loadgen (not http.Client, which spawns 3 client-side
goroutines that contaminate the gateway-side measurement). Spins up
zip.App on a local listener, dials N raw TCP conns each holding
/hold, samples runtime.MemStats.HeapAlloc delta.

Apple M1 Max, Go 1.26, zip on Fiber v3 v3.2.0:

  1,000 conns: 8.05 KiB/conn, 1.00 goroutine/conn,  14 MiB heap
  8,454 conns: 8.02 KiB/conn, 1.00 goroutine/conn,  72 MiB heap (linear)

Discipline: fasthttp gives us 1 goroutine per accepted conn already.
The HIP-0110 architecture preserves that — ZAP-forward to cloud is
sync from the gateway goroutine, no fan-out. Reverse-push lookups
are O(1) on a per-replica sync.Map, no extra goroutines.

Extrapolation:
  10k  conns → ~80 MiB / replica
  100k conns → ~800 MiB (1 GiB pod ceiling)
  10 replicas behind ingress → 1M concurrent client conns

Cloud side has zero per-conn cost — worker pool dispatches N ZAP
requests per pod. This is why a stateless gateway LB in front of
cloud maximizes compute efficiency: 10-100× fewer cloud replicas
than gateway replicas required for the same client population.

* deps: bump zip v0.1.1 → v0.2.0; pin cloud at resolvable pseudo-version

cloud cannot be tagged yet due to upstream GOPROXY blockers (kms repo >500MB,
vfs missing root package at v0.3.1). Pin cloud at the pseudo-version derived
from main HEAD so consumers can resolve via proxy.golang.org without local
replace dirs.
2026-05-29 19:59:38 -07:00

147 lines
3.7 KiB
Go

// Copyright © 2026 Hanzo AI. MIT License.
// Per-connection memory profile for the gateway edge. Measures
// heap-alloc growth while N concurrent HTTP/1.1 clients hold long
// requests open — this isolates fasthttp's goroutine-per-conn cost
// from per-request work. Run with:
//
// go test -mod=mod -run=TestConnMemory -v -conn-count=10000
package gateway
import (
"context"
"flag"
"fmt"
"net"
"runtime"
"sync"
"sync/atomic"
"testing"
"time"
luxlog "github.com/luxfi/log"
"github.com/hanzoai/zip"
)
var connCount = flag.Int("conn-count", 1000, "concurrent connections to hold")
func TestConnMemory(t *testing.T) {
if testing.Short() {
t.Skip("skipping memory profile in -short mode")
}
n := *connCount
// Bring up a minimal zip.App that hangs on /hold until the test
// closes the context.
app := zip.New(zip.Config{
Logger: luxlog.New("test", "conn-memory"),
DisableStartupMessage: true,
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var holding atomic.Int64
app.Get("/hold", func(c *zip.Ctx) error {
holding.Add(1)
defer holding.Add(-1)
// Block until shutdown. Fiber returns when the underlying
// fasthttp.RequestCtx is closed; cancelling ctx triggers shutdown.
<-ctx.Done()
return nil
})
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
addr := ln.Addr().String()
go func() { _ = app.Fiber().Listener(ln) }()
defer func() { _ = app.Shutdown() }()
// Settle baseline.
time.Sleep(200 * time.Millisecond)
var baseline, peak runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&baseline)
// Raw TCP dial — no http.Client (which would spawn 3 goroutines
// per request, contaminating the gateway-side memory profile).
// We send a minimal HTTP/1.1 request and then sit on the conn.
req := []byte("GET /hold HTTP/1.1\r\nHost: x\r\n\r\n")
conns := make([]net.Conn, 0, n)
var connsMu sync.Mutex
var wg sync.WaitGroup
wg.Add(n)
for i := 0; i < n; i++ {
go func() {
defer wg.Done()
c, err := net.DialTimeout("tcp", addr, 5*time.Second)
if err != nil {
return
}
if _, err := c.Write(req); err != nil {
_ = c.Close()
return
}
connsMu.Lock()
conns = append(conns, c)
connsMu.Unlock()
}()
}
wg.Wait()
defer func() {
connsMu.Lock()
for _, c := range conns {
_ = c.Close()
}
connsMu.Unlock()
}()
// Wait for all conns to be accepted.
deadline := time.Now().Add(30 * time.Second)
for holding.Load() < int64(n) && time.Now().Before(deadline) {
time.Sleep(50 * time.Millisecond)
}
if got := holding.Load(); got < int64(n) {
t.Logf("only %d/%d conns accepted before deadline", got, n)
n = int(got)
}
runtime.GC()
runtime.ReadMemStats(&peak)
delta := int64(peak.HeapAlloc) - int64(baseline.HeapAlloc)
perConn := float64(delta) / float64(n)
totalGoroutines := runtime.NumGoroutine()
fmt.Printf("\n=== Per-connection memory profile ===\n")
fmt.Printf("conns held : %d\n", n)
fmt.Printf("baseline heap : %s\n", humanBytes(int64(baseline.HeapAlloc)))
fmt.Printf("peak heap : %s\n", humanBytes(int64(peak.HeapAlloc)))
fmt.Printf("delta : %s\n", humanBytes(delta))
fmt.Printf("per-conn heap : %.0f B (%.2f KiB)\n", perConn, perConn/1024)
fmt.Printf("goroutines total : %d\n", totalGoroutines)
fmt.Printf("goroutines / conn: %.2f\n", float64(totalGoroutines)/float64(n))
fmt.Printf("=====================================\n\n")
cancel()
wg.Wait()
}
func humanBytes(n int64) string {
const unit = 1024
if n < 0 {
return "-" + humanBytes(-n)
}
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for x := n / unit; x >= unit; x /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.2f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}