From d4593db7fca17c573ed32bbc84dc10610aaa60a6 Mon Sep 17 00:00:00 2001 From: Derek Reynolds Date: Wed, 10 Jun 2026 10:03:10 -0700 Subject: [PATCH] feat: zload writers can run as distinct authenticated identities -authkey mints per-writer signed dbugs_auth cookies with the server's key, exercising the per-identity write limiter (P0.3) the way real traffic would. Rate-limited writes (200 + 'too fast' notice) are now counted separately, and the doc notes measuring push latency from a separate idle instance at high reader counts. Co-Authored-By: Claude Fable 5 --- cmd/zload/main.go | 96 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 79 insertions(+), 17 deletions(-) diff --git a/cmd/zload/main.go b/cmd/zload/main.go index a238fb6..411b038 100644 --- a/cmd/zload/main.go +++ b/cmd/zload/main.go @@ -8,21 +8,41 @@ // planting uniquely-stamped "probe" issues and timing when an independent // observer stream sees them. // -// Run the server with -writelimit=false so the per-session limiter doesn't cap -// write throughput, then e.g.: +// Writers can run as distinct authenticated identities: pass -authkey pointing +// at the server's auth_key file and each writer mints its own signed +// dbugs_auth cookie (login load-w00, load-w01, …). That exercises the +// per-identity write limiter the way real traffic would; without -authkey the +// server must run with -dev-user so anonymous sessions may write at all. // -// go run ./cmd/zload -readers 200 -writers 40 -rate 1 -dur 20s +// For raw throughput numbers run the server with -writelimit=false; with the +// limiter on, rejected writes (HTTP 200 + "too fast" notice) are counted +// separately as "limited". E.g.: +// +// go run ./cmd/zload -authkey auth_key -readers 200 -writers 40 -rate 1 -dur 20s +// +// At high reader counts (~1000) this process saturates a core decompressing +// streams and the probe observer goroutine starves, inflating push latency by +// seconds. Measure write→visible from a second, idle instance instead: +// +// go run ./cmd/zload -authkey auth_key -readers 1 -writers 0 -dur 30s & +// go run ./cmd/zload -authkey auth_key -readers 1000 -writers 100 -rate 2 -probe=false package main import ( "bufio" "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" "flag" "fmt" "io" "math/rand" "net/http" "net/http/cookiejar" + "net/url" + "os" "sort" "strconv" "strings" @@ -42,10 +62,28 @@ var ( ramp = flag.Duration("ramp", 3*time.Second, "spread reader connection setup over this window") probeOn = flag.Bool("probe", true, "run the write→visible latency probe (off = pure idle hold)") maxshort = flag.Int("maxshort", 1001000, "highest seeded short_id (for random select/edit)") + authkey = flag.String("authkey", "", "path to the server's auth_key; writers/probe sign in as distinct identities") ) +// signKey is the server's cookie-signing key when -authkey is set. +var signKey []byte + +// mintAuth builds a signed dbugs_auth cookie value for login, mirroring the +// server's scheme in auth.go: base64(json payload) "." base64(hmac-sha256). +func mintAuth(login string) string { + payload, _ := json.Marshal(map[string]any{ + "p": "zload", "id": login, "u": login, "n": login, "a": "", + "e": time.Now().Add(time.Hour).Unix(), + }) + mac := hmac.New(sha256.New, signKey) + mac.Write(payload) + return base64.RawURLEncoding.EncodeToString(payload) + "." + + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + type stats struct { writeOK, writeErr atomic.Int64 + writeLimited atomic.Int64 projections atomic.Int64 readerErr atomic.Int64 @@ -81,8 +119,9 @@ func pct(xs []time.Duration, p float64) time.Duration { } // newSession does GET / to obtain a session cookie, returning a client whose -// jar carries it. -func newSession() (*http.Client, error) { +// jar carries it. A non-empty login (with -authkey set) also plants a signed +// dbugs_auth cookie so the session writes as that identity. +func newSession(login string) (*http.Client, error) { jar, _ := cookiejar.New(nil) c := &http.Client{Jar: jar, Timeout: 0} resp, err := c.Get(*addr + "/") @@ -90,28 +129,37 @@ func newSession() (*http.Client, error) { return nil, err } resp.Body.Close() + if login != "" && signKey != nil { + u, err := url.Parse(*addr) + if err != nil { + return nil, err + } + jar.SetCookies(u, []*http.Cookie{{Name: "dbugs_auth", Value: mintAuth(login)}}) + } return c, nil } -func post(c *http.Client, path, body string) (int, error) { +// post returns the status code plus whether the server's write limiter +// rejected the command (those come back 200 with a "too fast" notice patch). +func post(c *http.Client, path, body string) (int, bool, error) { req, err := http.NewRequest("POST", *addr+path, strings.NewReader(body)) if err != nil { - return 0, err + return 0, false, err } req.Header.Set("Content-Type", "application/json") resp, err := c.Do(req) if err != nil { - return 0, err + return 0, false, err } - io.Copy(io.Discard, resp.Body) // drain so the connection can be reused + b, _ := io.ReadAll(resp.Body) // read fully so the connection can be reused resp.Body.Close() - return resp.StatusCode, nil + return resp.StatusCode, strings.Contains(string(b), "too fast"), nil } // reader holds one SSE stream open until ctx is done, counting projections and // scanning for probe markers. probe lines look like PROBE-. func reader(ctx context.Context, st *stats, observe bool) { - c, err := newSession() + c, err := newSession("") // readers stay anonymous; reading needs no identity if err != nil { st.readerErr.Add(1) return @@ -159,8 +207,8 @@ func reader(ctx context.Context, st *stats, observe bool) { } // writer creates/edits/comments at the configured rate from one session. -func writer(ctx context.Context, st *stats, rng *rand.Rand) { - c, err := newSession() +func writer(ctx context.Context, st *stats, rng *rand.Rand, login string) { + c, err := newSession(login) if err != nil { st.writeErr.Add(1) return @@ -190,7 +238,11 @@ func writer(ctx context.Context, st *stats, rng *rand.Rand) { path, body = "/cmd/create", `{"compose":{"title":"load create","desc":"x","labels":["bug"]}}` } start := time.Now() - code, err := post(c, path, body) + code, limited, err := post(c, path, body) + if limited { + st.writeLimited.Add(1) + continue + } st.addWrite(time.Since(start), err == nil && code == 200) } } @@ -198,7 +250,7 @@ func writer(ctx context.Context, st *stats, rng *rand.Rand) { // probe creates uniquely-stamped issues so observers can measure write→visible. func probe(ctx context.Context, st *stats) { - c, err := newSession() + c, err := newSession("load-probe") if err != nil { return } @@ -217,6 +269,14 @@ func probe(ctx context.Context, st *stats) { func main() { flag.Parse() + if *authkey != "" { + key, err := os.ReadFile(*authkey) + if err != nil { + fmt.Println("read -authkey:", err) + os.Exit(1) + } + signKey = key + } st := &stats{} ctx, cancel := context.WithTimeout(context.Background(), *dur) defer cancel() @@ -240,7 +300,8 @@ func main() { for i := 0; i < *nwrite; i++ { wg.Add(1) seed := int64(i*7919 + 1) - go func() { defer wg.Done(); writer(ctx, st, rand.New(rand.NewSource(seed))) }() + login := fmt.Sprintf("load-w%02d", i) + go func() { defer wg.Done(); writer(ctx, st, rand.New(rand.NewSource(seed)), login) }() } fmt.Printf("zload: %d readers, %d writers @ %.1f w/s each, %s\n", *nread, *nwrite, *rate, *dur) @@ -250,7 +311,8 @@ func main() { wOK := st.writeOK.Load() fmt.Println("\n──────── results ────────") - fmt.Printf("writes: %d ok, %d err (%.0f writes/s)\n", wOK, st.writeErr.Load(), float64(wOK)/elapsed) + fmt.Printf("writes: %d ok, %d err, %d limited (%.0f writes/s)\n", + wOK, st.writeErr.Load(), st.writeLimited.Load(), float64(wOK)/elapsed) fmt.Printf("write latency: p50=%v p95=%v p99=%v\n", pct(st.writeLat, 50).Round(time.Millisecond), pct(st.writeLat, 95).Round(time.Millisecond), -- 2.51.2