Files
Agent-System-Dev/tests/load/collaboration/sse_probe/main.go
T
weichen f705f99b8f Add U7 two-process acceptance, k6 load harness and release checklist
Why: collaboration storage U7 (proposal sections 3/5/7) requires two-process
HTTP-level acceptance over shared PG, a reproducible load-test entry honoring
the load contract, and an auditable pre-release checklist; capacity windows and
dual-host runs need user-scheduled resources, so the deliverable here is the
verifiable entry, not capacity claims.
Evidence: make test green (composite); make test-multi-instance green locally
against an isolated PG (five scenarios incl. crash-takeover and restart
invariants); run_load.sh --profile smoke end-to-end green (SSE probe p95~18ms,
zero seq duplicates, stack auto-recycled); backend pinned to feature branch
with the three cross-instance fixes.
Impact: new tests/integration/multi-instance (CI test-pg job step), new
tests/load/collaboration (k6 + SSE probe + run_load.sh), release checklist at
docs/release-multi-instance.md; no runtime behavior change in Dev repo itself.
Refs: backend PR #164; task card feature/u7-load-fault-release.
2026-10-12 02:31:35 +00:00

242 lines
7.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 协作存储 U7:SSE 持久事件可见延迟探针(负载合同:P95≤1s)。
// k6 核心不支持 SSE 流式读取,此项由本 Go 探针承担;与 k6 结果记录在
// 同一目录(run_load.sh 编排)。
//
// 行为:并发 C 个会话各挂一个 SSE 订阅(共享登录 cookie);写者按固定
// 间隔轮转 POST 消息(mock 即时回合);统计「POST 前基准 seq → 会话内
// 新帧到达」延迟分布与 seq 单调性,输出 JSON 报告并以退出码执行
// P95≤1s、零乱序断言(数据不变量零违反,方案 §3)。
package main
import (
"bufio"
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"
)
type sample struct {
Conversation string `json:"conversation"`
SendSeq int64 `json:"sendSeq"`
LatencyMS float64 `json:"latencyMs"`
}
type report struct {
Target string `json:"target"`
Duration string `json:"duration"`
Subscribers int `json:"subscribers"`
Messages int `json:"messages"`
Samples int `json:"samples"`
P50MS float64 `json:"p50Ms"`
P95MS float64 `json:"p95Ms"`
P99MS float64 `json:"p99Ms"`
MaxMS float64 `json:"maxMs"`
OutOfOrder int `json:"outOfOrder"` // seq 乱序/回退(不变量违反)
ThresholdMS float64 `json:"thresholdMs"`
Pass bool `json:"pass"`
Detail []sample `json:"samplesDetail"`
}
var (
jar, _ = cookiejar.New(nil)
client = &http.Client{Timeout: 10 * time.Second, Jar: jar}
streamClient = &http.Client{Jar: jar} // SSE:无整体超时,stop 控制
pendingMu sync.Mutex
pending = map[string]pendingSend{}
outOfOrderMu sync.Mutex
outOfOrderN int
samplesMu sync.Mutex
samples []sample
)
type pendingSend struct {
seq int64
sent time.Time
}
func main() {
base := flag.String("base", "http://127.0.0.1:18980", "目标实例基址")
owner := flag.String("owner", "probe_user", "探针用户")
password := flag.String("password", "probe-user-pass-Aa9", "探针用户口令")
convs := flag.Int("convs", 10, "并发订阅会话数")
every := flag.Duration("every", 500*time.Millisecond, "写者间隔(轮转各会话)")
dur := flag.Duration("dur", 60*time.Second, "采样时长")
out := flag.String("out", "", "JSON 报告输出路径(空 = stdout)")
flag.Parse()
login(*base, *owner, *password)
projectID := apiJSON("POST", *base, "/api/projects", map[string]any{"name": "sse-probe"})["id"].(string)
convIDs := make([]string, *convs)
for i := range convIDs {
convIDs[i] = apiJSON("POST", *base, "/api/projects/"+projectID+"/conversations", map[string]any{"title": fmt.Sprintf("probe-%d", i)})["id"].(string)
}
stop := make(chan struct{})
var wg sync.WaitGroup
for _, cid := range convIDs {
wg.Add(1)
go func(cid string) {
defer wg.Done()
subscribe(*base, cid, stop)
}(cid)
}
time.Sleep(500 * time.Millisecond) // 订阅就绪
deadline := time.Now().Add(*dur)
sent := 0
for i := 0; time.Now().Before(deadline); i++ {
cid := convIDs[i%len(convIDs)]
detail := apiJSON("GET", *base, "/api/conversations/"+cid, nil)
seq, _ := detail["lastSeq"].(float64)
pendingMu.Lock()
pending[cid] = pendingSend{seq: int64(seq), sent: time.Now()}
pendingMu.Unlock()
apiJSON("POST", *base, "/api/conversations/"+cid+"/messages", map[string]any{"text": fmt.Sprintf("probe %d", i)})
sent++
time.Sleep(*every)
}
time.Sleep(3 * time.Second) // 最后一批帧在途余量
close(stop)
wg.Wait()
r := buildReport(*base, *dur, *convs, sent)
data, _ := json.MarshalIndent(r, "", " ")
if *out != "" {
if err := os.WriteFile(*out, data, 0o644); err != nil {
fmt.Fprintln(os.Stderr, "write report:", err)
os.Exit(2)
}
fmt.Println("report:", *out)
} else {
fmt.Println(string(data))
}
if !r.Pass {
fmt.Fprintf(os.Stderr, "sse probe FAIL: p95=%.1fms (≤%.0fms) samples=%d outOfOrder=%d\n", r.P95MS, r.ThresholdMS, r.Samples, r.OutOfOrder)
os.Exit(1)
}
fmt.Printf("sse probe PASS: p95=%.1fms samples=%d\n", r.P95MS, r.Samples)
}
func login(base, owner, pass string) {
body, _ := json.Marshal(map[string]string{"owner": owner, "password": pass})
resp, err := client.Post(base+"/api/auth/login", "application/json", bytes.NewReader(body))
if err != nil || resp.StatusCode != 200 {
fmt.Fprintf(os.Stderr, "probe login: err=%v status=%v\n", err, statusOf(resp))
os.Exit(2)
}
resp.Body.Close()
}
func statusOf(resp *http.Response) string {
if resp == nil {
return "<nil>"
}
return strconv.Itoa(resp.StatusCode)
}
func apiJSON(method, base, path string, body any) map[string]any {
var rd io.Reader
_ = rd
if body != nil {
buf, _ := json.Marshal(body)
rd = bytes.NewReader(buf)
}
req, _ := http.NewRequest(method, base+path, rd)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := client.Do(req)
if err != nil {
fmt.Fprintf(os.Stderr, "%s %s: %v\n", method, path, err)
os.Exit(2)
}
defer resp.Body.Close()
out := map[string]any{}
if resp.StatusCode != 200 {
fmt.Fprintf(os.Stderr, "%s %s: status %d\n", method, path, resp.StatusCode)
os.Exit(2)
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
fmt.Fprintf(os.Stderr, "%s %s: decode %v\n", method, path, err)
os.Exit(2)
}
return out
}
// subscribe 读会话 SSE:seq 严格递增校验;帧到达时与写者基准配对成延迟样本。
func subscribe(base, cid string, stop <-chan struct{}) {
req, _ := http.NewRequest(http.MethodGet, base+"/api/events?conversationId="+cid, nil)
resp, err := streamClient.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
sc := bufio.NewScanner(resp.Body)
sc.Buffer(make([]byte, 0, 64*1024), 4<<20)
last := int64(-1)
for sc.Scan() {
select {
case <-stop:
return
default:
}
line := sc.Text()
if !strings.HasPrefix(line, "id: ") {
continue
}
seq, _ := strconv.ParseInt(strings.TrimPrefix(line, "id: "), 10, 64)
arrived := time.Now()
if last != -1 && seq <= last {
outOfOrderMu.Lock()
outOfOrderN++
outOfOrderMu.Unlock()
}
last = seq
pendingMu.Lock()
p, ok := pending[cid]
pendingMu.Unlock()
if ok && seq > p.seq {
pendingMu.Lock()
delete(pending, cid)
pendingMu.Unlock()
samplesMu.Lock()
samples = append(samples, sample{Conversation: cid, SendSeq: seq, LatencyMS: float64(arrived.Sub(p.sent)) / float64(time.Millisecond)})
samplesMu.Unlock()
}
}
}
func buildReport(base string, dur time.Duration, subs, msgs int) report {
samplesMu.Lock()
detail := append([]sample(nil), samples...)
samplesMu.Unlock()
outOfOrderMu.Lock()
ooo := outOfOrderN
outOfOrderMu.Unlock()
r := report{Target: base, Duration: dur.String(), Subscribers: subs, Messages: msgs, Samples: len(detail), OutOfOrder: ooo, ThresholdMS: 1000, Detail: detail}
if len(detail) > 0 {
vals := make([]float64, len(detail))
for i, s := range detail {
vals[i] = s.LatencyMS
}
sort.Float64s(vals)
pct := func(p float64) float64 { return vals[int(float64(len(vals)-1)*p)] }
r.P50MS, r.P95MS, r.P99MS, r.MaxMS = pct(0.50), pct(0.95), pct(0.99), vals[len(vals)-1]
}
r.Pass = len(detail) > 0 && r.P95MS <= r.ThresholdMS && ooo == 0
return r
}