From 45c551d2c63b3bf13c4169d5caa57f1e748e9331 Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Sun, 20 Sep 2026 14:04:49 -0500 Subject: [PATCH] ci: gate Jetstream releases on local client checks and benchmarks --- .agents/skills/release/SKILL.md | 21 +- .gitignore | 2 + .tangled/workflows/checks.yml | 30 +++ .tangled/workflows/release.yml | 25 ++ README.md | 5 + build.zig | 13 + build.zig.zon | 2 +- checks/archive.go | 131 ++++++++++ checks/catchup.go | 291 ++++++++++++++++++++++ checks/client.zig | 129 ++++++++++ checks/go.mod | 32 +++ checks/go.sum | 98 ++++++++ checks/install-tools.sh | 20 ++ checks/live.dict | Bin 0 -> 65536 bytes checks/load.go | 359 +++++++++++++++++++++++++++ checks/main.go | 428 ++++++++++++++++++++++++++++++++ checks/release-ready | 48 ++++ checks/run | 74 ++++++ docs/verification.md | 128 ++++++++++ 19 files changed, 1829 insertions(+), 7 deletions(-) create mode 100644 .tangled/workflows/checks.yml create mode 100644 .tangled/workflows/release.yml create mode 100644 checks/archive.go create mode 100644 checks/catchup.go create mode 100644 checks/client.zig create mode 100644 checks/go.mod create mode 100644 checks/go.sum create mode 100755 checks/install-tools.sh create mode 100644 checks/live.dict create mode 100644 checks/load.go create mode 100644 checks/main.go create mode 100755 checks/release-ready create mode 100755 checks/run create mode 100644 docs/verification.md diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md index 6cfdf31..5ed4555 100644 --- a/.agents/skills/release/SKILL.md +++ b/.agents/skills/release/SKILL.md @@ -24,8 +24,10 @@ Release this repository. Use the version or scope Nate specifies. ## Verify -- Run `zig fmt --check` on the release's Zig/ZON files, `git diff --check`, - `zig build test --summary all`, and `zig build` to compile the examples. +- Run `checks/run release` for formatting, Go analysis, Debug/ReleaseSafe SDK + tests, examples, differential conformance and regressions, and the full + repeated live/catch-up benchmarks. See [verification](../../../docs/verification.md). + Failures, including known semantic defects, block release; do not waive them. Report test execution time separately from compilation time. - The default tests include tiny loopback archive, cutover, failover, and stall fixtures. Keep them local and bounded. The fake server must answer @@ -52,11 +54,18 @@ candidate and present its version, changes, and check results for approval. - Confirm the candidate is on the intended release branch and the tag does not already exist locally or remotely. Never move an existing release tag or force-push to resolve a rejection. -- Tag the verified release commit with `vx.y.z`. Push the intended branch - and that exact tag to origin; do not use `--tags`, which can publish - unrelated local tags. Prefer an atomic push when supported. If the push +- Push the candidate branch before tagging. Trigger `release.yml` on that + exact commit with `tg pipeline trigger`; inspect its logs and verdict. + Run `checks/release-ready`, which requires a clean checkout and the latest + successful `checks.yml` and `release.yml` runs for HEAD on the repository's + selected spindle. Missing, pending, cancelled, and failed CI block publication. + Re-run both workflows for any changed commit, including a merge commit. +- Only after that gate passes, tag the unchanged commit with `vx.y.z` and + push that exact tag to origin; do not use `--tags`, which can publish + unrelated local tags. If the push fails or its result is uncertain, inspect remote refs before retrying. - Confirm the remote branch and tag resolve to the release commit. If this - repository has Tangled CI, use the Tangled skill to check its verdict. + repository's tag-triggered Tangled CI runs, use the Tangled skill to check + its verdict as a follow-up; it does not replace the pre-tag gate. - Report the version, commit, checks, and publication result. Updating consumers or publishing in sibling repositories requires its own scope. diff --git a/.gitignore b/.gitignore index dfc7be3..6c3fd51 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ zig-out/ .zig-cache/ zig-pkg/ +checks/results/ +.ci-tools/ diff --git a/.tangled/workflows/checks.yml b/.tangled/workflows/checks.yml new file mode 100644 index 0000000..055308b --- /dev/null +++ b/.tangled/workflows/checks.yml @@ -0,0 +1,30 @@ +when: + - event: ["push"] + branch: ["**"] + tag: ["v*"] + - event: ["pull_request"] + branch: ["main"] + - event: ["manual"] + +engine: nixery + +dependencies: + nixpkgs: + - bash + - jq + - curl + - coreutils + - gnutar + - gzip + - xz + - git + +steps: + - name: install pinned compilers + command: bash checks/install-tools.sh + - name: correctness and performance + command: | + set -euo pipefail + export PATH="$PWD/.ci-tools/zig-x86_64-linux-0.16.0:$PWD/.ci-tools/go/bin:$PATH" + export GOTOOLCHAIN=local + checks/run ci diff --git a/.tangled/workflows/release.yml b/.tangled/workflows/release.yml new file mode 100644 index 0000000..577d314 --- /dev/null +++ b/.tangled/workflows/release.yml @@ -0,0 +1,25 @@ +when: + - event: ["manual"] + +engine: nixery + +dependencies: + nixpkgs: + - bash + - jq + - curl + - coreutils + - gnutar + - gzip + - xz + - git + +steps: + - name: install pinned compilers + command: bash checks/install-tools.sh + - name: release correctness and performance + command: | + set -euo pipefail + export PATH="$PWD/.ci-tools/zig-x86_64-linux-0.16.0:$PWD/.ci-tools/go/bin:$PATH" + export GOTOOLCHAIN=local + checks/run release diff --git a/README.md b/README.md index dc12f37..7f7743b 100644 --- a/README.md +++ b/README.md @@ -57,8 +57,13 @@ authentication, and error handling. return `false` from `onEvent` to stop. ```sh zig build test --summary all zig build +checks/run ci ``` +the loopback checks require Go 1.27, Bash, and jq. they compare the local SDK +with the pinned official Go client and record bounded live/catch-up measurements. +see [verification and release gates](docs/verification.md). + ## docs [docs/](docs/) covers the client contract, failover, and upstream comparisons. diff --git a/build.zig b/build.zig index e851a1b..da24f98 100644 --- a/build.zig +++ b/build.zig @@ -61,6 +61,19 @@ pub fn build(b: *std.Build) void { const test_step = b.step("test", "run unit tests"); test_step.dependOn(&run_tests.step); + const check_client = b.addExecutable(.{ + .name = "zig-client", + .root_module = b.createModule(.{ + .root_source_file = b.path("checks/client.zig"), + .target = target, + .optimize = optimize, + .imports = &.{.{ .name = "jetstream", .module = mod }}, + }), + }); + const install_check_client = b.addInstallArtifact(check_client, .{}); + const check_step = b.step("check-client", "build the local SDK adapter for differential checks"); + check_step.dependOn(&install_check_client.step); + const example = b.addExecutable(.{ .name = "example-streamplace-chat", .root_module = b.createModule(.{ diff --git a/build.zig.zon b/build.zig.zon index 92579b8..1264b06 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -17,5 +17,5 @@ .hash = "websocket-0.1.12-ZPISdTJEBQDiqWKUP5mPPK9IXJ5xscVHybOlUbPHM9Rr", }, }, - .paths = .{ "build.zig", "build.zig.zon", "src", "examples" }, + .paths = .{ "build.zig", "build.zig.zon", "src", "examples", "checks/client.zig" }, } diff --git a/checks/archive.go b/checks/archive.go new file mode 100644 index 0000000..2af0176 --- /dev/null +++ b/checks/archive.go @@ -0,0 +1,131 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/bluesky-social/jetstream/segment" + "github.com/jcalabro/atmos/cbor" +) + +// Generate real sealed JSS through upstream's writer, then serve its block +// frames. This keeps the wire fixture independent of the Zig decoder. +func archiveFrames() ([][]byte, error) { return archiveFramesFor("") } +func archiveFramesFor(mode string) ([][]byte, error) { + dir, err := os.MkdirTemp("", "jetstream-conformance-") + if err != nil { + return nil, err + } + defer os.RemoveAll(dir) + path := filepath.Join(dir, "fixture.jss") + writer, err := segment.New(segment.Config{Path: path, MaxEventsPerBlock: 1}) + if err != nil { + return nil, err + } + defer writer.Close() + for seq := 1; seq <= 3; seq++ { + collection := "app.bsky.feed.post" + if seq == 2 { + collection = "app.bsky.feed.like" + } + payload, err := cbor.Marshal(map[string]any{"$type": collection, "text": "hi"}) + if err != nil { + return nil, err + } + kind := segment.KindCreate + if seq == 2 && strings.HasPrefix(mode, "archive-kinds") { + if !strings.HasPrefix(mode, "archive-kinds-selected") { + kind = segment.KindIdentity + } + if mode == "archive-kinds-valid" { + payload, err = cbor.Marshal(map[string]any{"handle": "fixture.test"}) + if err != nil { + return nil, err + } + } else { + payload = []byte{0xff} + } + } + _, err = writer.Append(segment.Event{Seq: uint64(seq), WitnessedAt: 1783900801000000, Kind: kind, DID: "did:plc:fixture", Collection: collection, Rkey: "rkey1", Rev: "rev1", Payload: payload}) + if err != nil { + return nil, err + } + if seq < 3 { + if err = writer.Flush(); err != nil { + return nil, err + } + } + } + if _, err = writer.Seal(); err != nil { + return nil, err + } + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + header, err := segment.ReadSealedHeader(bytes.NewReader(raw)) + if err != nil { + return nil, err + } + if header.BlockCount != 3 { + return nil, fmt.Errorf("expected three archive blocks, got %d", header.BlockCount) + } + var frames [][]byte + for i := 0; i < 3; i++ { + frame, err := segment.ReadBlockFrame(bytes.NewReader(raw), header, i) + if err != nil { + return nil, err + } + frames = append(frames, frame) + } + return frames, nil +} +func (f *fixture) serveArchive(w http.ResponseWriter, r *http.Request) bool { + if f.mode != "archive-pages" && f.mode != "archive-handoff" && !strings.HasPrefix(f.mode, "archive-kinds") { + return false + } + if r.URL.Path == "/xrpc/network.bsky.jetstream.subscribeEvents" { + return false + } + tip := 3 + if f.mode == "archive-handoff" { + tip = 2 + } + switch r.URL.Path { + case "/xrpc/network.bsky.jetstream.planSnapshot": + var input struct { + After int `json:"afterSeq"` + Before *int `json:"beforeSeq"` + } + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + http.Error(w, "invalid plan body", 400) + return true + } + after := input.After + if after < 0 || after >= tip { + http.Error(w, "unexpected plan cursor", 400) + return true + } + if after > 0 && (input.Before == nil || *input.Before != tip) { + http.Error(w, "snapshot tip must remain pinned", 400) + return true + } + _ = json.NewEncoder(w).Encode(map[string]any{"plannedThroughSeq": after + 1, "sealedTipSeq": tip, "segments": []any{map[string]any{"name": "fixture", "index": 0, "minSeq": after + 1, "maxSeq": after + 1, "mode": "blocks", "blocks": []any{map[string]any{"first": after, "last": after}}}}}) + case "/xrpc/network.bsky.jetstream.getBlock": + index, err := strconv.Atoi(r.URL.Query().Get("blockIndex")) + if err != nil || index < 0 || index >= len(f.frames) { + http.Error(w, "invalid block", 400) + return true + } + _, _ = w.Write(f.frames[index]) + default: + http.Error(w, "unexpected archive request", 400) + } + return true +} diff --git a/checks/catchup.go b/checks/catchup.go new file mode 100644 index 0000000..e246ec5 --- /dev/null +++ b/checks/catchup.go @@ -0,0 +1,291 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "sync/atomic" + "time" + + js "github.com/bluesky-social/jetstream" + "github.com/bluesky-social/jetstream/segment" + "github.com/coder/websocket" + "github.com/jcalabro/atmos/cbor" +) + +const digestSeed uint64 = 14695981039346656037 + +func recordDigest(h uint64, b []byte) uint64 { + for _, v := range b { + h = (h ^ uint64(v)) * 1099511628211 + } + return h +} +func catchupRecord(seq int) map[string]any { + return map[string]any{"$type": "app.bsky.feed.post", "text": fmt.Sprintf("post %d %s", seq, strings.Repeat("synthetic archive content. ", 8))} +} + +type catchupFixture struct { + count int + frames [][]byte + checksum uint64 + delay time.Duration + requests, wireBytes, liveConnections atomic.Int64 + active, peak atomic.Int64 +} + +func newCatchup(count int) (*catchupFixture, error) { + f := &catchupFixture{count: count, checksum: digestSeed} + dir, err := os.MkdirTemp("", "jetstream-catchup-") + if err != nil { + return nil, err + } + defer os.RemoveAll(dir) + path := filepath.Join(dir, "backlog.jss") + writer, err := segment.New(segment.Config{Path: path, MaxEventsPerBlock: 1000}) + if err != nil { + return nil, err + } + defer writer.Close() + for seq := 1; seq <= count+1; seq++ { + payload, err := cbor.Marshal(catchupRecord(seq)) + if err != nil { + return nil, err + } + f.checksum = recordDigest(f.checksum, payload) + if seq > count { + break + } + _, err = writer.Append(segment.Event{Seq: uint64(seq), WitnessedAt: 1783900801000000, Kind: segment.KindCreate, DID: "did:plc:fixture", Collection: "app.bsky.feed.post", Rkey: strconv.Itoa(seq), Rev: "rev1", Payload: payload}) + if err != nil { + return nil, err + } + if seq%1000 == 0 && seq < count { + if err = writer.Flush(); err != nil { + return nil, err + } + } + } + if _, err = writer.Seal(); err != nil { + return nil, err + } + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + header, err := segment.ReadSealedHeader(bytes.NewReader(raw)) + if err != nil { + return nil, err + } + for i := 0; i < int(header.BlockCount); i++ { + frame, err := segment.ReadBlockFrame(bytes.NewReader(raw), header, i) + if err != nil { + return nil, err + } + f.frames = append(f.frames, frame) + } + return f, nil +} +func (f *catchupFixture) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/xrpc/network.bsky.jetstream.planSnapshot": + var in struct { + After int `json:"afterSeq"` + } + if json.NewDecoder(r.Body).Decode(&in) != nil || in.After != 0 { + http.Error(w, "invalid catch-up start", 400) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"plannedThroughSeq": f.count, "sealedTipSeq": f.count, "segments": []any{map[string]any{"name": "backlog", "index": 0, "minSeq": 1, "maxSeq": f.count, "mode": "blocks", "blocks": []any{map[string]any{"first": 0, "last": len(f.frames) - 1}}}}}) + case "/xrpc/network.bsky.jetstream.getBlock": + active := f.active.Add(1) + defer f.active.Add(-1) + for old := f.peak.Load(); active > old; old = f.peak.Load() { + if f.peak.CompareAndSwap(old, active) { + break + } + } + i, err := strconv.Atoi(r.URL.Query().Get("blockIndex")) + if err != nil || i < 0 || i >= len(f.frames) { + http.Error(w, "invalid block", 400) + return + } + if f.delay > 0 { + timer := time.NewTimer(f.delay) + defer timer.Stop() + select { + case <-timer.C: + case <-r.Context().Done(): + return + } + } + f.requests.Add(1) + n, _ := w.Write(f.frames[i]) + f.wireBytes.Add(int64(n)) + case "/xrpc/network.bsky.jetstream.subscribeEvents": + if r.URL.Query().Get("cursor") != strconv.Itoa(f.count) { + http.Error(w, "wrong handoff cursor", 400) + return + } + f.liveConnections.Add(1) + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{Subprotocols: []string{"xrpc.v1.json"}}) + if err != nil { + return + } + defer conn.CloseNow() + ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + defer cancel() + for _, seq := range []int{f.count, f.count + 1} { + b, err := json.Marshal(map[string]any{"$type": "message", "payload": map[string]any{"$type": "network.bsky.jetstream.subscribeEvents#commit", "collection": "app.bsky.feed.post", "did": "did:plc:fixture", "operation": "create", "record": catchupRecord(seq), "rev": "rev1", "rkey": strconv.Itoa(seq), "seq": seq, "time": "2026-07-13T00:00:01.000000Z"}}) + if err != nil { + return + } + if conn.Write(ctx, websocket.MessageText, b) != nil { + return + } + } + _, _, _ = conn.Read(ctx) + default: + http.NotFound(w, r) + } +} +func catchupClient(args []string) error { + if len(args) != 2 { + return fmt.Errorf("catchup-client HOST ARCHIVE_COUNT") + } + count, err := strconv.Atoi(args[1]) + if err != nil || count < 1 || count > 1_000_000 { + return fmt.Errorf("invalid archive count") + } + fmt.Println("READY") + start := time.Now() + client, err := js.Subscribe(args[0], js.WithAfterSeq(0), js.WithBatchSize(1), js.WithDownloadConcurrency(4), js.WithZstdCompression(false)) + if err != nil { + return err + } + defer client.Close() + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + seen := 0 + digest := digestSeed + for batch, err := range client.Events(ctx) { + if err != nil { + return err + } + for _, event := range batch.Events() { + seen++ + if event.Seq != uint64(seen) || event.Commit == nil { + return fmt.Errorf("out-of-order catch-up") + } + digest = recordDigest(digest, event.Commit.RecordCBOR) + } + if seen == count+1 { + break + } + } + if seen != count+1 { + return fmt.Errorf("incomplete catch-up") + } + fmt.Printf("CATCHUP %d %d %d\n", seen, digest, time.Since(start).Nanoseconds()) + return nil +} + +type catchupResult struct { + Profile string `json:"profile"` + PeakRequests int64 `json:"peak_block_requests"` + Client string `json:"client"` + Repetition int `json:"repetition"` + ArchiveCount int `json:"archive_count"` + RequestDelayMS int64 `json:"request_delay_ms"` + CatchupSeconds float64 `json:"catchup_seconds"` + CPUSeconds float64 `json:"cpu_seconds"` + PeakRSSBytes int64 `json:"peak_rss_bytes"` + Checksum uint64 `json:"record_checksum"` + Blocks int64 `json:"block_requests"` + PayloadBytes int64 `json:"archive_payload_bytes"` +} + +func catchupMain(args []string) error { + counts := []int{10000, 100000, 1000000} + profile, repetitions := "full", 4 + if len(args) > 1 { + return fmt.Errorf("catchup [ci|ARCHIVE_COUNT]") + } + if len(args) == 1 && args[0] == "ci" { + profile, repetitions = "ci", 2 + counts = []int{10000} + } else if len(args) == 1 { + n, err := strconv.Atoi(args[0]) + if err != nil || n < 1000 || n > 1_000_000 || n%1000 != 0 { + return fmt.Errorf("count must be a multiple of 1000 between 1000 and 1000000") + } + counts = []int{n} + profile = "count" + } + for _, count := range counts { + fixture, err := newCatchup(count) + if err != nil { + return err + } + for rep := 0; rep < repetitions; rep++ { + clients := []string{"go", "zig"} + if rep%2 == 1 { + clients = []string{"zig", "go"} + } + for _, delay := range []time.Duration{0, 20 * time.Millisecond} { + for _, client := range clients { + f := &catchupFixture{count: count, frames: fixture.frames, checksum: fixture.checksum, delay: delay} + server := httptest.NewServer(f) + exe, err := os.Executable() + if err != nil { + server.Close() + return err + } + args := []string{"catchup-client", server.URL, strconv.Itoa(count)} + if client == "zig" { + exe = "zig-out/bin/zig-client" + args = []string{server.URL, "catchup", strconv.Itoa(count)} + } + ctx, cancel := context.WithTimeout(context.Background(), 65*time.Second) + measured, err := measureClient(ctx, exe, args) + cancel() + server.Close() + if err != nil { + return err + } + var seen int + var digest uint64 + var ns int64 + if _, err = fmt.Sscanf(catchupSummary(measured.Output), "CATCHUP %d %d %d", &seen, &digest, &ns); err != nil { + return err + } + if seen != count+1 || digest != f.checksum || f.requests.Load() != int64(len(f.frames)) || f.liveConnections.Load() != 1 { + return fmt.Errorf("catch-up delivery/work mismatch: count=%d digest=%d/%d blocks=%d live=%d", seen, digest, f.checksum, f.requests.Load(), f.liveConnections.Load()) + } + result := catchupResult{profile, f.peak.Load(), client, rep, count, delay.Milliseconds(), float64(ns) / 1e9, measured.CPUSeconds, measured.PeakRSSBytes, digest, f.requests.Load(), f.wireBytes.Load()} + if err = json.NewEncoder(os.Stdout).Encode(result); err != nil { + return err + } + fmt.Fprintf(os.Stderr, "PASS catchup %s count=%d delay=%s repeat=%d\n", client, count, delay, rep) + } + } + } + } + return nil +} + +func catchupSummary(output string) string { + for _, line := range strings.Split(output, "\n") { + if strings.HasPrefix(line, "CATCHUP ") { + return line + } + } + return "" +} diff --git a/checks/client.zig b/checks/client.zig new file mode 100644 index 0000000..1cd879c --- /dev/null +++ b/checks/client.zig @@ -0,0 +1,129 @@ +const std = @import("std"); +const sdk = @import("jetstream"); +const LoadHandler = struct { + io: std.Io, + samples: []i64, + count: usize = 0, + failed: bool = false, + delay_ns: i64, + rate: i64, + epoch: i64 = 0, + pub fn onEvent(self: *@This(), event: sdk.Event) bool { + if (event.seq != self.count + 1) { + self.failed = true; + return false; + } + var scheduled = std.fmt.parseInt(i64, event.payload.commit.rkey, 10) catch { + self.failed = true; + return false; + }; + if (self.count == 0) self.epoch = scheduled; + scheduled = self.epoch + @divTrunc(@as(i64, @intCast(self.count)) * std.time.ns_per_s, self.rate); + self.samples[self.count] = @intCast(std.Io.Clock.real.now(self.io).toNanoseconds() - scheduled); + self.count += 1; + if (self.delay_ns > 0) self.io.sleep(std.Io.Duration.fromNanoseconds(self.delay_ns), .awake) catch { + self.failed = true; + return false; + }; + return self.count < self.samples.len; + } + pub fn onError(self: *@This(), _: anyerror) bool { + self.failed = true; + return false; + } +}; +const CatchupHandler = struct { + target: usize, + count: usize = 0, + digest: u64 = 14695981039346656037, + failed: bool = false, + pub fn onEvent(self: *@This(), event: sdk.Event) bool { + self.count += 1; + if (event.seq != self.count) { + self.failed = true; + return false; + } + for (event.payload.commit.record_cbor) |byte| self.digest = (self.digest ^ byte) *% 1099511628211; + return self.count < self.target; + } + pub fn onError(self: *@This(), _: anyerror) bool { + self.failed = true; + return false; + } +}; +const Handler = struct { + target: usize, + count: usize = 0, + stop_on_error: bool, + error_count: usize = 0, + max_errors: usize = 0, + pub fn onEvent(self: *@This(), event: sdk.Event) bool { + const c = event.payload.commit; + std.debug.print("EVENT\t{d}\t{s}\t{s}\t{s}\t{s}\n", .{ event.seq, event.did, c.collection, c.rkey, @tagName(c.operation) }); + self.count += 1; + return self.count < self.target; + } + pub fn onError(self: *@This(), err: anyerror) bool { + std.debug.print("ERROR\nDETAIL {s}\n", .{@errorName(err)}); + self.error_count += 1; + return !self.stop_on_error and (self.max_errors == 0 or self.error_count < self.max_errors); + } +}; +pub fn main(init: std.process.Init) !void { + const args = try init.minimal.args.toSlice(init.arena.allocator()); + if (args.len == 4 and std.mem.eql(u8, args[2], "catchup")) { + const count = try std.fmt.parseInt(usize, args[3], 10); + if (count == 0 or count > 1_000_000) return error.InvalidCount; + var handler = CatchupHandler{ .target = count + 1 }; + std.debug.print("READY\n", .{}); + const start = std.Io.Clock.awake.now(init.io); + try sdk.subscribe(init.io, init.gpa, .{ .hosts = &.{args[1]}, .after_seq = 0, .zstd_compression = false, .concurrency = 4 }, &handler); + if (handler.failed or handler.count != count + 1) return error.InvalidDelivery; + const elapsed = start.durationTo(std.Io.Clock.awake.now(init.io)).nanoseconds; + std.debug.print("CATCHUP {d} {d} {d}\n", .{ handler.count, handler.digest, elapsed }); + return; + } + if (args.len == 7 and std.mem.eql(u8, args[2], "load")) { + const count = try std.fmt.parseInt(usize, args[3], 10); + if (count == 0 or count > 1_000_000) return error.InvalidCount; + const samples = try init.gpa.alloc(i64, count); + defer init.gpa.free(samples); + std.debug.print("READY\n", .{}); + var handler = LoadHandler{ .io = init.io, .samples = samples, .delay_ns = try std.fmt.parseInt(i64, args[5], 10), .rate = try std.fmt.parseInt(i64, args[6], 10) }; + try sdk.subscribe(init.io, init.gpa, .{ .hosts = &.{args[1]}, .zstd_compression = std.mem.eql(u8, args[4], "compressed") }, &handler); + if (handler.failed or handler.count != count) return error.InvalidDelivery; + std.mem.sort(i64, samples, {}, std.sort.asc(i64)); + std.debug.print("LOAD {d} {d} {d} {d}\n", .{ count, samples[count / 2], samples[@min(count - 1, count * 99 / 100)], samples[count - 1] }); + return; + } + if (args.len != 3) return error.ExpectedHostAndScenario; + const mode = args[2]; + const exact = std.mem.eql(u8, mode, "exact"); + const wildcard = std.mem.eql(u8, mode, "wildcard"); + const resuming = std.mem.eql(u8, mode, "resume"); + const kinds = std.mem.startsWith(u8, mode, "archive-kinds"); + const many_dids = std.mem.eql(u8, mode, "many-dids"); + var did_storage: [120][32]u8 = undefined; + var dids: [120][]const u8 = undefined; + const alphabet = "abcdefghijklmnopqrstuvwxyz234567"; + for (&did_storage, 0..) |*did, i| { + @memcpy(did[0..8], "did:plc:"); + @memset(did[8..30], 'a'); + did[30] = alphabet[i / 32]; + did[31] = alphabet[i % 32]; + dids[i] = did; + } + var handler = Handler{ .target = if (exact or resuming or kinds) 2 else 3, .stop_on_error = std.mem.endsWith(u8, mode, "-stop"), .max_errors = if (many_dids) 3 else 0 }; + try sdk.subscribe(init.io, init.gpa, .{ + .hosts = &.{args[1]}, + .collections = if (exact) &.{"app.bsky.feed.post"} else if (wildcard) &.{"app.bsky.feed.*"} else &.{}, + .dids = if (many_dids) &dids else &.{}, + .kinds = if (kinds) &.{.commit} else &.{}, + .backoff_min_ns = std.time.ns_per_ms, + .backoff_max_ns = std.time.ns_per_ms, + .live_cursor = if (resuming) 1 else null, + .after_seq = if (std.mem.startsWith(u8, mode, "archive-")) 0 else null, + .snapshot_only = std.mem.eql(u8, mode, "archive-pages") or kinds, + .zstd_compression = !std.mem.eql(u8, mode, "plain") and !std.mem.startsWith(u8, mode, "archive-") and !std.mem.startsWith(u8, mode, "terminal-") and !many_dids, + }, &handler); +} diff --git a/checks/go.mod b/checks/go.mod new file mode 100644 index 0000000..9cffc20 --- /dev/null +++ b/checks/go.mod @@ -0,0 +1,32 @@ +module tangled.org/zat.dev/jetstream/checks + +go 1.26.6 + +require ( + github.com/bluesky-social/jetstream v0.2.3-0.20260904210514-58c4d7f7a913 + github.com/coder/websocket v1.8.15 + github.com/jcalabro/atmos v0.4.0 + github.com/klauspost/compress v1.19.2 +) + +require ( + github.com/bluesky-social/gttp v0.1.0 // indirect + github.com/cockroachdb/errors v1.14.0 // indirect + github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect + github.com/cockroachdb/pebble v1.1.5 // indirect + github.com/cockroachdb/redact v1.1.5 // indirect + github.com/getsentry/sentry-go v0.46.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/jcalabro/gloom v0.1.0 // indirect + github.com/jcalabro/gt v0.0.14 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect +) diff --git a/checks/go.sum b/checks/go.sum new file mode 100644 index 0000000..6560ddc --- /dev/null +++ b/checks/go.sum @@ -0,0 +1,98 @@ +github.com/bluesky-social/gttp v0.1.0 h1:jsG6ssqLYboxxdx+eBScIvKfMfBB6qvN8E4SW+NeoaM= +github.com/bluesky-social/gttp v0.1.0/go.mod h1:TPtZ67MOhePifYaztEI42hUhluaE2GJ33+oFxPermuw= +github.com/bluesky-social/jetstream v0.2.3-0.20260904210514-58c4d7f7a913 h1:cAhCPRjdAS4vqYtOFhASPZnsm68XdzITgesGllxgjYM= +github.com/bluesky-social/jetstream v0.2.3-0.20260904210514-58c4d7f7a913/go.mod h1:grkENUXlo6RuMec66MfyfB3tMCoNfjlgFvH3oNTS8Zw= +github.com/cockroachdb/datadriven v1.0.3-0.20230413201302-be42291fc80f h1:otljaYPt5hWxV3MUfO5dFPFiOXg9CyG5/kCfayTqsJ4= +github.com/cockroachdb/datadriven v1.0.3-0.20230413201302-be42291fc80f/go.mod h1:a9RdTaap04u637JoCzcUoIcDmvwSUtcUFtT/C3kJlTU= +github.com/cockroachdb/errors v1.14.0 h1:EfdVEJpN3z8rPMo43Yit59LxoiIa470fSXpZXuEs+ZI= +github.com/cockroachdb/errors v1.14.0/go.mod h1:xRa70jZ9sNBQmISt5KmJmAD++E4dQHm89oCRiZGEdq0= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= +github.com/cockroachdb/pebble v1.1.5 h1:5AAWCBWbat0uE0blr8qzufZP5tBjkRyy/jWe1QWLnvw= +github.com/cockroachdb/pebble v1.1.5/go.mod h1:17wO9el1YEigxkP/YtV8NtCivQDgoCyBg5c4VR/eOWo= +github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30= +github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/getsentry/sentry-go v0.46.0 h1:mbdDaarbUdOt9X+dx6kDdntkShLEX3/+KyOsVDTPDj0= +github.com/getsentry/sentry-go v0.46.0/go.mod h1:evVbw2qotNUdYG8KxXbAdjOQWWvWIwKxpjdZZIvcIPw= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/jcalabro/atmos v0.4.0 h1:jP0SZlYO2E6F+8fPFepybEgjxRDYvWH+wlMmy3ph8h4= +github.com/jcalabro/atmos v0.4.0/go.mod h1:xPADdLqXVyJwfkajuZIdm3X7KoPmmkWPbieTBSKSU7w= +github.com/jcalabro/gloom v0.1.0 h1:Z8VTAMrdRNXr11tt9c5pIpDDJxAHg3QIBrHFt5x2I2M= +github.com/jcalabro/gloom v0.1.0/go.mod h1:6uAawINljQYLN3a2YsekJ8LDYT/SobmJyfeKrSjHVx0= +github.com/jcalabro/gt v0.0.14 h1:m5Go+0xX+Atb3PbS1rLuNrFWiO0w1bdZlAoxbBITzXc= +github.com/jcalabro/gt v0.0.14/go.mod h1:iBnF7jE8LG8mW0zjXVYTuXoT+L9UfwP7RNgMrmuuOTs= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= +github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/checks/install-tools.sh b/checks/install-tools.sh new file mode 100755 index 0000000..8ee4740 --- /dev/null +++ b/checks/install-tools.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +[[ $(uname -s) == Linux && $(uname -m) == x86_64 ]] || { + echo 'CI tool installation requires Linux x86_64' >&2 + exit 1 +} +mkdir -p .ci-tools +cd .ci-tools +curl --fail --silent --show-error --location --retry 3 \ + https://ziglang.org/download/0.16.0/zig-x86_64-linux-0.16.0.tar.xz -o zig.tar.xz +printf '%s %s\n' 70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00 zig.tar.xz | sha256sum --check +curl --fail --silent --show-error --location --retry 3 \ + https://go.dev/dl/go1.27.0.linux-amd64.tar.gz -o go.tar.gz +printf '%s %s\n' 675c26c449cbb18fc24b74650de1eabbae6e16f64326fd85a283fb3b58280685 go.tar.gz | sha256sum --check +tar -xJf zig.tar.xz +tar -xzf go.tar.gz +rm zig.tar.xz go.tar.gz +test "$(./zig-x86_64-linux-0.16.0/zig version)" = 0.16.0 +./go/bin/go version diff --git a/checks/live.dict b/checks/live.dict new file mode 100644 index 0000000000000000000000000000000000000000..1f88712815a3a2abe87555adaae535f2fd117f73 GIT binary patch literal 65536 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�jN;B*gVw6DhaUQ7( z$r+`|**U4j3dQ+pB{`Kk3dJCs^3p*iQATPJ*edWLV4&_L=)6$yB9_YhQU%aWVY%Q* zR`B8{kS*bfAm1dGrGgZCf@}d*9`I@c`^mZa=2;bKCP}8*MxfPi;A+C$($pM$db&wr zUUIToN@j6Zu32t*PML8@g;{c*X-cwrj&W*QRh3bqae7vMN>NrKXlw(^C{dDWCTL}; zNnv@ZQC?wkg=u0@Mn(yaBO7yzi%oKJ(#=cI&pRnD&daMZO3Ez;Uw?pH*yL3inG|M& zg~2X2H8wTFRoH+gye;(1jf@RJr*zP#8<3chTv=IZT#=obQl3(hS&*NcVOpMCZj@S3 zo{?#8R+^nvSXgD0QBjdpoC`e58sa&nPj=NXyAA zD9J2Ltjq%+9zw-#0QfdYBU8|pR|5mk43ece-sym(;xeN|qoSPh^pd2K?2M|sM6;Zv ztTeOCB$J}D!pih?Gm~Por2PEkl9F{=vL<|pNq7bIn8MknHFW1SD0sH z6_^^8Rh48XXQmk?X6NT6XQid&WEq>}m{%2+r(~rh<7hP{m6)a(R~emabq~j{4^7zLUZ$+io~Rf9J8w8yp$aCs_cq_Jad!6 zl!~;(RAY1FyppmCW0T?{^U8wE(zJ5$!THz<2-4h^WS(f2Zkm$}jag`!4>~Rxd>0aE z7l1+zc;{wHemUrt$RY*s;tFs%47yaQNFgJ$0D22vdS)4D_y@F1q9h|TJp(+{1L7qX zgSH%iN@N{{;+*_)g`(0V*ujpV9Rvy~ndzX5?-D_a6cdv(6hNAii}FiA%d|c63)Dep zKIWyT7L{Zc6e}bp7J(Lkrxztwfvf`+)WxMmpi7p(Exyzug|z$}(6-QGg$&T3QCfa- zDR_xWK4{5!BIpq4)I^X&K($Ivr9w$+B3K$^Dd;9BP!$ELT0rAb(EFo6QmLR?E-x8v z`6%`}AU7?ku%gf`!w5Xk0?q>F76xYETa-Y%86XGA7iU(0m$`zQiN&DR>fm6_NUcDX4BBEwXF)A?2OD`zQ2JdKv_{!MK2wbxblI8D-g_Q+m z6&X3H8Cki7CT3}+r4>~fStj|J1*u7uiAgCLspS=^87A3@i3R34Sw%TjnWb2ViIPES zEiE%OMGvwE71Vt!sdU4BqrFKy{7h3L zv-GOUv^8ot>PUmQzxhVw9AZnUs}PoLXXDk(iTQ zQk9%ike`-Qm6Dj8mR6dXQ<;%ln4MaXj_YPX65UpioNZiXUXTo(kcL(sF-i)-g{7I0 zODZb!OF`utbQgi10;rYlsgP2dmj|BYDN+D!6H_l%$jF2o?+dygBL_5H0a}s{UL^*u zM7&eWauSO`ZFumq!2-~|rl3L#WE}Wt;mi~r@c2BaLIBrPi6si)ooK<5Hy8Ae8+h5C@v(!|gZytpP4B#6GUA}cSkJlP1eg$B7rUXYbrX$snG2=RX_9eiRY|2$zDas&fpJBmIcOgi31uwe zBtK}gA9ty0YG`F-pl50hUb~1?s-i5=NixdLsxUH1D@rOi$}KU^Oe!|3%FnGZuPQN4 zFDb7ysW8h-NzF(vNh&QV%Q7~~OiV4sQ7n|^8>QtXnwTe>l$li}=Vj+qq-7+RW#PJw zr#Lg+IIBD}8FY}PjuN(oDjB)i1y!ZRS>+~q*`{Xcg=T5FiHWABDXDoW>6tlMX$6_d zCb@~pxk)93IZ2g;nZ{)WIOd*;(@Qdqvx?J8jdMyf(~L|?^0UiIGK|sJf+H_f$tg`M zDyxL(E)~8W@Eb8Cw~dg10t9)_~wjK8bmz zxn)^Ksd>ejCh0|01v!;w+1dFSrJ1G1#ueo`xmA^U*%hWqm3bBEX2qr{DQQVXAkSlI z_M4_<<>wa{m*-SfmYC+2m*kjcrY9B^WABpWlw~BRnP*p|fUo)nRV&b?L^zHYE6YeO zHqI+DDFcs9f=e<(BV#jj@Wc+bh98}d7ek&wfe)dBJCe}cjk?A!Bi%eb%`BrVC#^E2 zxVS8{ve39JDWeQ@P8D(yl9HN}S^^q=K}xr|IcE7L1>k8dMA>YfXH=1tV{8oGH2@A3 zV^cFjJfkI`>x3;W%q_vw0o6*-9FS3xk(OLmVqQ>?T5491Z&qAvTw$JCTxpb8T9sF6 zZd{&`o?dKRR#=r*o|BlJT9T7l2I|%0$pO%zQ&iv6t+{2WYiJN+XkukzYGq)lXKDaG z`~ozfj=kd#mLWXlk(iQHQeKsqRZ(nIl9`m4l2K8WRGOVtT9960l9N`HoL-n&T9}=b zTbh<*nplUapnw*%FnU@Ob;(_?-iMgquMo?l-Nd~C%2U^7s>O>cT8Y`J4 z;F}am!8-szszF1k*&yYxAx-rZ(2|DSB*?}{kdlIfYuWO<;S6p1B>mJf7(q8Pl) z2HbFhtdL1f1z82s>X8rH8?B>|nUVt%0{4hPS2N_Mg0|TuXM^TEDiy#NH9>Eyk5N)^ z%*g?d<${JxK_j*)`JnsAGC(H?WTYw-q^2b1fCpzm7Nq6m=NCbin80>smO%DdfTnjq zD#|lbQ**$(t@WVU6s;0A(KR&GH3p|4V^b?56FnnK^sWs`C7hO=nO{<#lw)3AVVq}N zoLQc3mY9{8m1C4yk(HB^Us{-2kz|x!P;6wJTUlakT$q(t1!{p{NgK(Tskv!6g;l2I z#idC_#s!I`rFkjknK@X~MozL>a$corT6wZj2})-NM=6?YmRw$uRAdSo2ZFZc3=PZ& z*A1wi0nQ7)#rTM8jrb)&|NyR3m<%wq5CPh_MCdQQ+#)-yB z>1Cjx#9kI9CYR(F>48@BWafa%Q`GV(-83^lEj2GKKclKNqomBdxGX!fAhkG)j8&QF z7NMmlq}9i!2396!dIpdqg;Fw$K{*L}{(NyxB53nzhHh$JR(@$}ksi3M2-&EOGB#42 zS5T5#RFz`{@;S6N0If3uEeA*~RI&oCcmNM|VyhVqKpSGrElrG!!7J#qW0Vw%Qgdu$ zl=AY^z=I14dHJBdV5vnhN}wE>SDcerk_wiOQ3CB3G)@JR$;AbUc?!uniN(dXF-n=> zjnqjwsW3IL)eoTK${_0XK^nkJYzDyeRpytLlqRKumqwI>u28Wrvvn+R&5qE`Hp(cq z0P9c2Zxn`GaA?v`1WQ0&qhA2wDd{LB6=kNRSNa!$wwNU5gcgBrD}ebY4|K#`o*pDt z^c~7Fi_$Z5;!84rOdpP)C$mWIs61aa}$&U{EQ7z z&+kh?KKIu%N5LhtIJKw*bbrpF?Fyb{iFx@7Zu!N2g4;u;mgZ)-7KfS|SQ(h=8CT_+mlc<16cw5#XIG?D zR3(~aB_*e3l$DkxnpY(inWhyNXBuUvXONH#V}EU!wg%*`+^ zEGR9)T4?7MgRVO(GfRiv6^h)*D>F^YD=`9{)eOzn;L{Xx%S;oCa=^l1PZ?X9f>!up zuFf$u0&V*@Gyom{W=X#ect%Q6xk;*dMXs4yX%$E6c^)2MtSM^<;7+uiG}5<1r>>9>8VvEIr)VtRk)H-UY3z@Nk*|z z5=z|8222a56G6H822g#xO84)ip5EH8KEQ4Qylr>Nc8y zPLaUl>HMl>vx>Be%A&Nyg6uS-T+_;w#MI(S^ThIka+9J8)5?_e!d#P*jN;7lf}Db? zlFDRI{fni+W0I0sQBi7^k)52Kl$BDFRbE(PY*JiWjMdXQnYq~ol_t4Kpqp@z2d{86 zcrtTy%E}Y-GR=)Zl{(ndhK9yQ=BBvr=c7}%5q#7utn*cDW?ouYSY>9OQ&Ny?l3rz8 zWn595l9!!fQfi!(n_`xlW1LY@Xr5b{oMKW|RA`czh;_aITvH&YYt+V0W>SesVtQ&( za=BS*Mruk~a!#pfWllOMDI#}0A(?@Q#WC0$&ZrF?Lt{Nt&?+F@=_uK##5ASE+%zjW zIVmYO(b%XkKPk~DF*Va9sVu3gB0JA0H@UJrFWa;%!@MxDAlW#t0@UWgl8%ZB%`1{h zO)|>M(+jhbii^{8^0F$*OmHnvF3v7CGRrVZHmU+=9?-ta;`H=_#HyT%ipuo-!XC z=)yP9C3C4o3dOL&WcUy>=$<%*M9AQCQE3uvWEpf_9H=J*+SX89Qks?qT6mV14(e2a z&v*to6tv(5dLTziCU`q8=vbpf(89P>h17~f(9tO%x1uynu}?3(WeMp{xyMWIPXvUy<%XkVgf8Uq3C6bG%P1!s8BW?@i5%Pui5OHMA%tIEqUt~5@|OUpM& zHY-R46_8|QMI?(L*%#Loor#`_A$aNu%SQP0$}A(3qC|5Ov+SxA(5eY6d91uDEvv*d zC#x{k$kZ&q$~?a?DK(|A82eU6P}QGoW}25}WMm4?A+UB1j4D$Fq|2CsrAkVr5Rh*54@eyWjic}7}FX-a8nZfiL<$g3IUWrur6wW zopWnqX=PxcXJ`T5AqFqWvJ#O7n4awJdNzIOq@G`*GvS$4!$TgBbmf z8|OW4-t&G>BLqEeT=BTE>v2=l8)iLjT>WhAZj{2NI4`%PC_UdC zyv(gAI~BA99nxSfE+{W62A`JzuJny94e(tbU;vu2G6l^HBey9)4XI@FB-09`a!`GU zWPDz-xp8s^SQu=)iHV6Ru5-CeEJ5Q(rk17_7MAqd$e5U&l9!uZS!A4)T3TshR+d<0 zT2Y>qlAe>3U6oW>T9BDuW|m%;u7+T$51DP@H@rlwU@W|hV1MP?bLRb}Z} z>B&{a#pY(om1#x=g=R%&rb%Y0DcL23`5CEY1)xR{mf|imr=TjiB+m@Ag1;=QGP5Gt z*t|G58GU;+D0hLEre{`~W+hi;gBGvim=G(-$}GsvEy*o5$xkoKC@)AZ&MGQN%c;uE z%qcD@HmWQ(uF5UQN=(Yk$S5i`Evu{oU+#{@X-QR;rbSsK8QElD5bzODKR5Cy~-@3BokC2VX-^2 zG|eo{)F?m0926d9X(jpQIf>;Z=yoH5r68%q$TZm~4W$++F3u|`D@-lT1#jd;&b$R> zg+?Z&U}13XvamEa#nXj11??F(F)}cqSMEy6&MY-gG0HDW$p@{YE-NpqNJ`1ct}IMR zu1qm6FE%PmPtGn(&dDw?Hcc;1$up}c0@bIa=C0h_Y}2B=l+x6~l)}`M%DjS%)Z~n! zvND?HE=xTNBk*t(&fHa)T~%C`1-d}TBt1FT+_cQZw4y4@D9<<}Bd@^3#Jt!fyWG?$ zzcj0~&^)IwDcux2RfHuOnHLsj7aCQiloy(0nPz3?rI%)>my~8>pD)ZUF3!%+$S}{EAq)jW0QjEZ7f+HuRJkyGztYWY*I2oB3S(@OQE(8_Q271Ou7Mb7+Z_rv%rn&}Z zx<*Fe1w3X}hL(DkpyRc0cWN?<(+et73XQYP%afAx3rY*~%CpK+GtzU-lZ?#r6H_w_ z%uUQp6DtZcaw^ixP4cVC%yD#T$_uJ;i*k)h6OF5ki;|0T($h0?in7hH&lKep6&YnG z6&q*6x+Jhnj-$alI$i%QZmle0{Uv&}Qj z%*%}O(~YZ&Gs}x}Q}QyB%S$SalC#r`jFXd%3XHM~(~AhyWT0XRqrqj8VP2J(o>7op zURrKiQe;$AQE8r;n*_?XWVguiHn>2u02X@C4RooXQBo6)&JwCW$$q z&4-}7WO3~KwKOt7?E5va03A;Y(QAb`CnqT=sVo3pPXkt>pIZR(ZBeRe zWm0NdN?~DIUS5h>ZibOrv9WooaY=5rS$S?zMSfycm05O@X=Zsrwkc>B0!zj=F3+sU zE-23`u1GXBDKs@nElDXYH7PbhtD8U(p>Ld6G|R{+y{I@j zH$6SwIJ+PnyeONjGz)Fwp_P3|13I8%Buw?p4DfCcNG>y~NJ=eBPs+$J&M2u&Pb#iV z&r8qE$Sck^%g!t_%`QtbN=eHz$_E|Bm}#D$U50CeK(g^Gfp5(hHK(Qwo!FQZloQii?X%jd3){%uMr3vePpwQ_YN%(@l#r%W}&T(<@4_ z&l%??W|(E08I?kI`OMo0(ad zo0*nfT#{N+Sek8Yngw1_hovwe(QW4D#bvq4rP$B?`r$GaI01 zyeH*XfEE!XD&%CAl;nU<+y@Owfu@#1K&M!Mj>E{$1}(}@&d*CuEy`0UPb|&=UC9I* zgad8O1Wl{h&n%`Ji$w|#iPX#Y+fYtuR*oWj2Gs{hrD|5||SJE4rT7p;7gVGtEa=;R_>(SBz?}~_| zLZjj;BctM!lCsjuvfQeooU}5N$`sJCeT9X&S>`6CDdl%*)6w zHz}x0tVm8bE~!kZ%qz_)OiDo;1S+V^tjZ_`?+8JT*n-N;vTTSjIASdg4Gr-1BTe-z z3=AzH{m5kUHi?<(8kmHDj!3Wst%5c%053cPl`yoK*#{q7o)0=T9xW!zt1^mn%=5EM zGt9H|(hJKf(@HB#OtNsy=o^`$%;+1SPUu5wGiXWdkda>iIW#A!G!J=w3V2z)2Y8=U zacL3gL|IU|?3@Fh>;SKTPXlk_g0Ip7Ux=4l1S)ik6>{^7@<1oQg34|1lu3Gi31|vJ z0kmBmyt!QwY=NGh9`u}B(019xBJi%*#GIVeqV&vUP{EIqGO!nXc^Ro0rpbv_pp$8! zi=fOc4NSljp@U>DBE77*u&gS>q^h*6%*3o9slueJFf}KyDz_}(B&DjNyrL|*BC#kp zCB34!AT!6TBE8fEcb3OJC75THl#*>$l%5JY&!yZXB{|VJv9h=XR5IijSn2D-<|``F zbJI=IGSX8k%S%%VQmV|%GAhlp%5g4l$jM4fFEuwZ%0O?>=N9Hynwx-!nvgSUZee~- zW+_-0oKcO<%`L$75SHiwmB5fAu8oZ8RRSlPmX>D~mlc_sRHc@Kt|K(hFwH8c2b6Mc3!f1m04PLes)G~YDQvtMy63#c4Ag?s&Q6wPPSQA z6=)g_OUp4cC)v0($vm;bIH@u}Bd;{C!o)l!xe_VWL4vXvd;@P$L1J>YzHv^PNmf#U zdA3nL=xRmO`oAzOr!3tRJlKJp`U=xb%9As}3#-AY&(h4m7@Yc2ax#i4%*^vm3i5Na z(^AYUOUm;rla0~Z9mqqhIcC{O=|)Co=;^*N(cCz%9J~er#pXn_!g4dPFvMnKQxjZk zw?P-y8|#6N(J`jmG(w7LQel;89_U8E)XYi~qcpSpwEXf^;tbdS3j0v$+MXkwO~XJTHEU7Bi~nU|87Vq8d0qDQs_cZxT$ zv@$f(GcX5l;fA+55JM!%`MG+~3ug2{(F;1WIX^EwC>69W3uWDJQeI+VX`-o7eoB@} zaaLBLSxQksSx#wgMoC4EX=zS=Sy5)CablKnd0}~RL2;#7VR{iLgs`OcGULjuq{8I< z%tGU&(yW4Y(FwQH>&Cbm? z0#{#HLM1WBG$$)1v!tZRw5rNDFT*%D$+$ExDGzI?&2XhbtPC%-hs8Po_=vQjcM)Uz}&HZw5? zwbBYeH>;v-69}%2Uh}6Vpt}Q!7j}ODfDjwH=Y6igiN~?qUrzK5waKjQ&8Cz=<2&YhMizLR3mIvpRp!ZNrRL>X zg=v+b8G@{m)FPvl%z~=ybd!|)^nz5=+^USK6w}1YvZ~zj%xqj!24w|liDgL{}jGxy41rNfm`9DaDvGPlZMm#%9IfD=CVybCRu+N>bAEGV*h? z^Gj2+ip}yfLFHs-UMdcQGb@cUO^i}NL#t?W5&5NwNk%0`pmG4YtjjM=G%?Eo3xm_A znTe4JxDSG*2MoHC++5GZ($d(7Zex%sW_g(viOHF!l_jP|pgmKWMM>r5Sp|6&nP$nQ zrm0osDVgSFW|^g#c}C?0DaDm(xgam%NuMPdMX8A?SeHkpRG5@xlx3%87*%Ffnp7oM z=A~q$=74V}A*;Ycu_`eqC%+u~E*o&?0ijqK(sxcRg52<?0L9$s=rFmj%QetIZqFF{# zVS!OLQl}mq3!q*XxaKJ?&dV<@tTHY{+u#UVJ(QDUZj@*Q+B=P$l?rmQ%M&ZX!r-iA zVP;@}vqf(JI*!H6*u;>2St+@q*eEH}IKQMktu(iwvZ^>OE2XqNt)$4Tv@D}2r=Y|* zr81)+(b&Yas4zXbq#!%Jv=p?I0J%k#9cl z67!_w%!)kF>KNp74Cy3cO~fsS)kn`x#=bqg~nwj<;7K*nHgr;8Ri+urNzZ5xkkB! zc9B4;LW~04$T-C~C(op;va%{KFEKIIxUf9e#4MBSEdp?Bux16r5JMv?Q$z5usUhC= zKZ*GnM&_9rsU>CENfn7XnFYDVS*6)&rl!SJ#+7DCsfD=(rddfwWk!W%*@-1a#p&hX z6_;3w)cl;p?A*$#GUNR0JhPOnjHKj(>~y0d9IIN2i?a%|OAFJoLB|oHjr(U-rBxOh zffmjpvO;lQVP=(?8AKSIlq}8748g4(Y;A5sD?{V_#FCWc>|9W7jK#SnIT@KnNg26` z6-G%FNmaR}=6S^>h1uBm#^e?k<>r(cCnbSSXGE?6ii`6KixWXP7t|?3ac*&9T3MbE zNEqx~LjwZ~+>@4uRz^m829~B~CUmO-QY-WFD^tzN^HWkX&9e$CQ?imwl8a1|ip)WG z`(&4uBAtd?T|Gqhzz3cWe2;=5VAZqwW1`oC@&GzMuRi0s+Cewi<65o3qWmB zB`c-K{L+xpq*Mj)VpGuWN6;-tpyTgASM(_9D1mR=01XDi*1%$}Q$-mYE=t9EwRd8! zc~+HKRY`t&X=-JvdAfO0g=tBCN|||Dv2k{~NoHb6a!GzcvQc_fVwH($Sw&j531~37 zII}8M$;!;g%o4O_wj{G82edFY1Ke3mP0a%hNTsKOM%*A7A9iX1@`(k-8Tmye#rmmH zZXxFRDZ#o%p7zC=wu#POsTD?Lc~vfz*@dO)CeTR2xx54tqL8yOQJOC(6D)}Z8F@K{ z+36+eiG`^J#TjLlrs=sE$yJ$VDTzge8Af@9S zGt0CrJ*6PU+`P21q9Dt>z^FVUuP84A*A^GETr;C&(`?X4S}my9LR#8F`H>XlgeKM# zDnK(hpyMinK{LZjR>l^17PNp?n}F`|Dh5R}=y>_uRPY5$3gCO5Kx+m;%Su4&QoznL zGRg&|7wo6zmlaf*r=(Zr6=jxIrX`ja6{dm4kaAKhjZ1S2lg!K0j7`e(jPvrVGAqq8 zO7qN&Q*rc?i&N8zl9G$lN~+4U%yTM>5>v}dQ_Yfcv6cb3#l<;ASrx_EV7Gz#GN~1y zi968Rz?}U2ykdp&%shqsyj1wj6zQ1-1*t_~e?U)N19h{_v$9i)z~^NnS3CLUDWH?A zK$EfHYRA;fzzom4p{c2!iGh&;Xb%Z}Mo!aH(o;(+Gplk6N{h=eq?qejSb+N-@Rh~c`Jf${ zgpL_cOUlU4D9J7;N-j)KOU)`P%PCJUGpR_bFitEsE;lQxEG{iBHZRXDD>E@NEiK4T zPAdV8)?q0g%u~yZbIPi!iZk-l%*&0kQwp=d<5jpiLP;3~Rauo~`IV;m#uXL0S$WAB zxy8xF$=L-ECZMBoax#olv&@W2l5)#Iv4O?raBAP^lk=0yQ}gl?K~)!| z$!2J7W?^P*T3ZXcJd51cNJ%1SwE>8dyo@L(F)1}i4}9=B=%6+q5Em2=;6X^paAsK|XthH@eo>wh*f;P(A7ufqM`A%i zr2^QM3Q%V%Dky{bP%bENkk$j`)Rx#qTZ*wzCoF(KyOz0+?73P!`<|daHn-!L4X62RU8s{cwfG+u{GA}Po zP0KGeO({#st4b;@F{{kVO-arMABc{ 0 { + timer := time.NewTimer(wait) + select { + case <-timer.C: + case <-ctx.Done(): + timer.Stop() + f.err = ctx.Err() + return + } + } + late := time.Since(scheduled).Nanoseconds() + if late > f.lateNS { + f.lateNS = late + } + payload := f.frames[seq-1] + typ := websocket.MessageText + if compressed { + typ = websocket.MessageBinary + } + // Only the first frame carries the epoch; all others are pre-encoded. + if seq == 1 { + payload = loadPayload(seq, start.UnixNano()) + if compressed { + payload = enc.EncodeAll(payload, nil) + } + } + if err = c.Write(ctx, typ, payload); err != nil { + f.err = err + return + } + f.bytes += int64(len(payload)) + } + _, _, _ = c.Read(ctx) +} +func loadClient(args []string) error { + if len(args) != 5 { + return fmt.Errorf("load-client HOST COUNT MODE DELAY_NS RATE") + } + count, err := strconv.Atoi(args[1]) + if err != nil || count < 1 || count > 1_000_000 { + return fmt.Errorf("invalid count") + } + delay, err := strconv.ParseInt(args[3], 10, 64) + if err != nil || delay < 0 { + return fmt.Errorf("invalid delay") + } + rate, err := strconv.Atoi(args[4]) + if err != nil || rate <= 0 { + return fmt.Errorf("invalid rate") + } + var epoch int64 + samples := make([]int64, 0, count) + fmt.Println("READY") + client, err := js.Subscribe(args[0], js.WithBatchSize(1), js.WithZstdCompression(args[2] == "compressed")) + if err != nil { + return err + } + defer client.Close() + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + for batch, err := range client.Events(ctx) { + if err != nil { + return err + } + for _, ev := range batch.Events() { + if ev.Seq != uint64(len(samples)+1) || ev.Commit == nil { + return fmt.Errorf("out-of-order or non-commit event") + } + scheduled, err := strconv.ParseInt(ev.Commit.Rkey, 10, 64) + if err != nil { + return err + } + if len(samples) == 0 { + epoch = scheduled + } + scheduled = epoch + int64(len(samples))*int64(time.Second)/int64(rate) + samples = append(samples, time.Now().UnixNano()-scheduled) + if delay > 0 { + time.Sleep(time.Duration(delay)) + } + } + if len(samples) >= count { + break + } + } + if len(samples) != count { + return fmt.Errorf("incomplete delivery: %d/%d", len(samples), count) + } + sort.Slice(samples, func(i, j int) bool { return samples[i] < samples[j] }) + fmt.Printf("LOAD %d %d %d %d\n", count, samples[count/2], samples[min(count-1, count*99/100)], samples[count-1]) + return nil +} + +type loadResult struct { + Profile string `json:"profile"` + Client string `json:"client"` + Mode string `json:"mode"` + Repetition int `json:"repetition"` + Count int `json:"count"` + Rate int `json:"offered_events_per_second"` + DelayNS int64 `json:"handler_delay_ns"` + WallSeconds float64 `json:"wall_seconds"` + CPUSeconds float64 `json:"cpu_seconds"` + PeakRSSBytes int64 `json:"peak_rss_bytes"` + P50NS int64 `json:"lag_p50_ns"` + P99NS int64 `json:"lag_p99_ns"` + MaxNS int64 `json:"lag_max_ns"` + PayloadBytes int64 `json:"payload_bytes"` + ServerLateNS int64 `json:"server_max_schedule_lateness_ns"` +} + +func measureLoad(client, mode string, repeat, count, rate int, delay int64) (loadResult, error) { + f := &loadFixture{count: count, rate: rate, compressed: mode == "compressed"} + enc, err := zstd.NewWriter(nil, zstd.WithEncoderDict(dictionary), zstd.WithEncoderConcurrency(1)) + if err != nil { + return loadResult{}, err + } + for seq := 1; seq <= count; seq++ { + p := loadPayload(seq, 0) + if f.compressed { + p = enc.EncodeAll(p, nil) + } + f.frames = append(f.frames, p) + } + enc.Close() + server := httptest.NewServer(f) + defer server.Close() + ctx, cancel := context.WithTimeout(context.Background(), 65*time.Second) + defer cancel() + executable, err := os.Executable() + if err != nil { + return loadResult{}, err + } + args := []string{"load-client", server.URL, strconv.Itoa(count), mode, strconv.FormatInt(delay, 10), strconv.Itoa(rate)} + if client == "zig" { + executable = "zig-out/bin/zig-client" + args = []string{server.URL, "load", strconv.Itoa(count), mode, strconv.FormatInt(delay, 10), strconv.Itoa(rate)} + } + measured, err := measureClient(ctx, executable, args) + if err != nil { + return loadResult{}, err + } + out := measured.Output + var n int + var p50, p99, maxLag int64 + if _, err = fmt.Sscanf(strings.TrimSpace(strings.TrimPrefix(string(out), "READY\n")), "LOAD %d %d %d %d", &n, &p50, &p99, &maxLag); err != nil || n != count { + return loadResult{}, fmt.Errorf("bad child result: %s", out) + } + f.mu.Lock() + defer f.mu.Unlock() + if f.err != nil { + return loadResult{}, f.err + } + return loadResult{"", client, mode, repeat, count, rate, delay, measured.WallSeconds, measured.CPUSeconds, measured.PeakRSSBytes, p50, p99, maxLag, f.bytes, f.lateNS}, nil +} + +type clientProcess struct { + Output string + WallSeconds, CPUSeconds float64 + PeakRSSBytes int64 +} + +func measureClient(ctx context.Context, executable string, args []string) (clientProcess, error) { + cmd := exec.CommandContext(ctx, executable, args...) + start := time.Now() + var output loadOutput + cmd.Stdout = &output + cmd.Stderr = &output + if err := cmd.Start(); err != nil { + return clientProcess{}, err + } + peakDone := make(chan int64, 1) + stopPeak := make(chan struct{}) + go func() { + var peak int64 + ticker := time.NewTicker(time.Millisecond) + defer ticker.Stop() + for { + select { + case <-stopPeak: + peakDone <- peak + return + case <-ticker.C: + if runtime.GOOS == "linux" && output.ready.Load() { + exe, _ := os.Readlink(fmt.Sprintf("/proc/%d/exe", cmd.Process.Pid)) + if filepath.Base(exe) != filepath.Base(executable) { + continue + } + data, _ := os.ReadFile(fmt.Sprintf("/proc/%d/status", cmd.Process.Pid)) + for _, line := range strings.Split(string(data), "\n") { + var kb int64 + if _, e := fmt.Sscanf(line, "VmHWM: %d kB", &kb); e == nil && kb*1024 > peak { + peak = kb * 1024 + } + } + } + } + } + }() + err := cmd.Wait() + close(stopPeak) + observedPeak := <-peakDone + out := output.String() + wall := time.Since(start).Seconds() + if err != nil { + return clientProcess{}, fmt.Errorf("client process: %w: %s", err, out) + } + usage, ok := cmd.ProcessState.SysUsage().(*syscall.Rusage) + if !ok { + return clientProcess{}, fmt.Errorf("resource usage unavailable") + } + rss := usage.Maxrss + if runtime.GOOS != "darwin" { + rss = observedPeak + if rss == 0 { + return clientProcess{}, fmt.Errorf("no post-exec Linux memory sample") + } + } + return clientProcess{out, wall, cmd.ProcessState.UserTime().Seconds() + cmd.ProcessState.SystemTime().Seconds(), rss}, nil +} +func loadMain(args []string) error { + if len(args) == 0 { + return fmt.Errorf("expected load [ci] or catchup [ci|ARCHIVE_COUNT]") + } + if args[0] == "catchup-client" { + return catchupClient(args[1:]) + } + if args[0] == "catchup" { + return catchupMain(args[1:]) + } + if args[0] == "load-client" { + return loadClient(args[1:]) + } + if args[0] != "load" { + return fmt.Errorf("expected load or load-client") + } + if len(args) > 2 || len(args) == 2 && args[1] != "ci" { + return fmt.Errorf("load [ci]") + } + profile, repetitions := "full", 4 + type workload struct { + count, rate int + delay int64 + } + workloads := []workload{{4000, 1000, 0}, {20000, 10000, 0}, {1000, 1000, 2_000_000}} + if len(args) == 2 { + profile, repetitions = "ci", 2 + workloads = []workload{{1000, 1000, 0}, {10000, 10000, 0}, {100, 1000, 2_000_000}} + } + for rep := 0; rep < repetitions; rep++ { + clients := []string{"go", "zig"} + if rep%2 == 1 { + clients = []string{"zig", "go"} + } + for _, work := range workloads { + for _, mode := range []string{"plain", "compressed"} { + for _, client := range clients { + r, err := measureLoad(client, mode, rep, work.count, work.rate, work.delay) + if err != nil { + return err + } + r.Profile = profile + if err = json.NewEncoder(os.Stdout).Encode(r); err != nil { + return err + } + fmt.Fprintf(os.Stderr, "PASS load %s %s rate=%d delay=%d repeat=%d\n", client, mode, work.rate, work.delay, rep) + } + } + } + } + return nil +} diff --git a/checks/main.go b/checks/main.go new file mode 100644 index 0000000..3f75634 --- /dev/null +++ b/checks/main.go @@ -0,0 +1,428 @@ +package main + +import ( + "context" + _ "embed" + "encoding/binary" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "reflect" + "strings" + "sync" + "time" + + js "github.com/bluesky-social/jetstream" + "github.com/coder/websocket" + "github.com/klauspost/compress/zstd" +) + +//go:embed live.dict +var dictionary []byte + +type fixture struct { + mode string + frames [][]byte + mu sync.Mutex + dicts, connections int + didCount int + cursors []string +} + +func (f *fixture) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if f.serveArchive(w, r) { + return + } + switch r.URL.Path { + case "/xrpc/network.bsky.jetstream.getZstdDictionary": + f.mu.Lock() + f.dicts++ + dicts := f.dicts + f.mu.Unlock() + if f.mode == "refresh-failed" && dicts > 1 { + http.Error(w, "dictionary unavailable", http.StatusBadRequest) + return + } + d := append([]byte(nil), dictionary...) + if (f.mode == "rotation" || f.mode == "rotation-stop") && dicts > 1 { + binary.LittleEndian.PutUint32(d[4:8], binary.LittleEndian.Uint32(d[4:8])+1) + } + w.Write(d) + case "/xrpc/network.bsky.jetstream.subscribeEvents": + f.mu.Lock() + f.connections++ + connections := f.connections + if strings.HasPrefix(f.mode, "terminal-") { + f.cursors = append(f.cursors, r.URL.Query().Get("cursor")) + } + if f.mode == "many-dids" { + f.didCount = len(r.URL.Query()["dids"]) + } + f.mu.Unlock() + if f.mode == "many-dids" && !reflect.DeepEqual(r.URL.Query()["dids"], filterDIDs()) { + http.Error(w, "DID filters changed in transit", http.StatusBadRequest) + return + } + if strings.HasPrefix(f.mode, "refresh-") && connections > 1 && r.URL.Query().Get("zstdDictionary") != "" { + http.Error(w, "expected uncompressed fallback", http.StatusBadRequest) + return + } + if (f.mode == "reconnect" || strings.HasPrefix(f.mode, "terminal-")) && connections > 1 && r.URL.Query().Get("cursor") != "1" { + http.Error(w, "expected resume cursor 1", http.StatusBadRequest) + return + } + if (f.mode == "rotation" || f.mode == "rotation-stop" || f.mode == "refresh-failed" || f.mode == "refresh-same") && connections == 1 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + fmt.Fprint(w, `{"error":"UnknownZstdDictionary"}`) + return + } + c, e := websocket.Accept(w, r, &websocket.AcceptOptions{Subprotocols: []string{"xrpc.v1.json"}}) + if e != nil { + return + } + defer c.CloseNow() + ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second) + defer cancel() + d := append([]byte(nil), dictionary...) + if f.mode == "rotation" || f.mode == "rotation-stop" { + binary.LittleEndian.PutUint32(d[4:8], binary.LittleEndian.Uint32(d[4:8])+1) + } + enc, e := zstd.NewWriter(nil, zstd.WithEncoderDict(d), zstd.WithEncoderConcurrency(1)) + if e != nil { + http.Error(w, e.Error(), http.StatusInternalServerError) + return + } + defer enc.Close() + compressed := r.URL.Query().Get("zstdDictionary") != "" + if f.mode == "malformed" || f.mode == "malformed-stop" { + if e = c.Write(ctx, websocket.MessageBinary, []byte("bad zstd")); e != nil { + return + } + } + seqs := []int{1, 2, 3} + if f.mode == "archive-handoff" { + if r.URL.Query().Get("cursor") != "2" { + return + } + seqs = []int{2, 3} + } + if (f.mode == "reconnect" || strings.HasPrefix(f.mode, "terminal-")) && connections == 1 { + seqs = []int{1} + } + if f.mode == "duplicate" { + seqs = []int{1, 1, 2, 3} + } + for _, seq := range seqs { + collection := "app.bsky.feed.post" + if seq == 2 { + collection = "app.bsky.feed.like" + } + did := "did:plc:fixture" + if f.mode == "many-dids" { + did = filterDIDs()[0] + } + b := []byte(fmt.Sprintf(`{"$type":"message","payload":{"$type":"network.bsky.jetstream.subscribeEvents#commit","collection":%q,"did":%q,"operation":"create","record":{"$type":"app.bsky.feed.post","text":"hi"},"rev":"rev1","rkey":"rkey1","seq":%d,"time":"2026-07-13T00:00:01.000000Z"}}`, collection, did, seq)) + typ := websocket.MessageText + if compressed { + typ = websocket.MessageBinary + b = enc.EncodeAll(b, nil) + } + if e = c.Write(ctx, typ, b); e != nil { + return + } + } + if strings.HasPrefix(f.mode, "terminal-") && connections == 1 { + _ = c.Write(ctx, websocket.MessageText, []byte(`{"$type":"error","error":"FutureCursor","message":"fixture terminal error"}`)) + return + } + if f.mode == "reconnect" && connections == 1 { + return + } + // Wait for consumer stop rather than inducing reconnect/error differences. + _, _, _ = c.Read(ctx) + default: + http.NotFound(w, r) + } +} + +type result struct { + Scenario string `json:"scenario"` + Transcript []string `json:"transcript"` + DictionaryRequests int `json:"dictionary_requests"` + Connections int `json:"connections"` + DIDCount int `json:"did_count,omitempty"` + Cursors []string `json:"cursors,omitempty"` + Details []string `json:"error_details,omitempty"` + Failure string `json:"failure,omitempty"` +} + +func run(mode string, zig bool) (outcome result, runErr error) { + outcome.Scenario = mode + outcome.Transcript = []string{} + f := &fixture{mode: mode} + defer func() { + if runErr != nil { + outcome.Failure = runErr.Error() + } + }() + if strings.HasPrefix(mode, "archive-") { + frames, err := archiveFramesFor(mode) + if err != nil { + return outcome, err + } + f.frames = frames + } + server := httptest.NewUnstartedServer(f) + server.Config.ReadHeaderTimeout = 2 * time.Second + server.Config.ReadTimeout = 5 * time.Second + server.Config.WriteTimeout = 5 * time.Second + server.Start() + defer server.Close() + defer func() { + f.mu.Lock() + defer f.mu.Unlock() + outcome.Scenario = mode + outcome.DictionaryRequests, outcome.Connections = f.dicts, f.connections + outcome.DIDCount, outcome.Cursors = f.didCount, f.cursors + }() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + lines := []string{} + defer func() { outcome.Transcript = lines }() + target := 3 + if mode == "exact" || mode == "resume" { + target = 2 + } + if strings.HasPrefix(mode, "archive-kinds") { + target = 2 + } + if zig { + cmd := exec.CommandContext(ctx, "zig-out/bin/zig-client", server.URL, mode) + out, e := cmd.CombinedOutput() + for _, l := range strings.Split(string(out), "\n") { + if strings.HasPrefix(l, "DETAIL ") { + outcome.Details = append(outcome.Details, strings.TrimPrefix(l, "DETAIL ")) + } + if strings.HasPrefix(l, "EVENT\t") || l == "ERROR" { + lines = append(lines, l) + } + } + if e != nil { + return outcome, fmt.Errorf("Zig %s: %w (deadline: %v): %s", mode, e, ctx.Err(), out) + } + } else { + opts := []js.Option{js.WithBatchSize(1), js.WithZstdCompression(mode != "plain" && !isRegression(mode) && !strings.HasPrefix(mode, "archive-"))} + if strings.HasPrefix(mode, "archive-") { + opts = append(opts, js.WithAfterSeq(0)) + if mode == "archive-pages" || strings.HasPrefix(mode, "archive-kinds") { + opts = append(opts, js.WithSnapshotOnly()) + } + } + if strings.HasPrefix(mode, "archive-kinds") { + opts = append(opts, js.WithKinds([]js.Kind{js.KindCommit})) + } + if mode == "many-dids" { + opts = append(opts, js.WithDIDs(filterDIDs())) + } + if mode == "exact" { + opts = append(opts, js.WithCollection("app.bsky.feed.post")) + } + if mode == "wildcard" { + opts = append(opts, js.WithCollection("app.bsky.feed.*")) + } + if mode == "resume" { + opts = append(opts, js.WithLiveCursor(1)) + } + client, e := js.Subscribe(server.URL, opts...) + if e != nil { + return outcome, e + } + defer client.Close() + count := 0 + for batch, e := range client.Events(ctx) { + if e != nil { + if ctx.Err() != nil { + return outcome, ctx.Err() + } + lines = append(lines, "ERROR") + outcome.Details = append(outcome.Details, e.Error()) + if strings.HasSuffix(mode, "-stop") { + break + } + continue + } + for _, ev := range batch.Events() { + c := ev.Commit + if c == nil { + return outcome, fmt.Errorf("unexpected non-commit") + } + lines = append(lines, fmt.Sprintf("EVENT\t%d\t%s\t%s\t%s\t%s", ev.Seq, ev.DID, c.Collection, c.Rkey, c.Operation)) + count++ + } + if count >= target { + break + } + } + } + if ctx.Err() != nil { + return outcome, ctx.Err() + } + return outcome, nil +} +func expected(mode string) result { + lines := []string{} + if mode == "malformed" || mode == "rotation" || strings.HasSuffix(mode, "-stop") || strings.HasPrefix(mode, "refresh-") { + lines = append(lines, "ERROR") + } + for _, seq := range []int{1, 2, 3} { + if strings.HasSuffix(mode, "-stop") { + break + } + if mode == "exact" && seq == 2 || mode == "resume" && seq == 1 { + continue + } + if mode == "reconnect" && seq == 2 { + lines = append(lines, "ERROR") + } + coll := "app.bsky.feed.post" + if seq == 2 { + coll = "app.bsky.feed.like" + } + lines = append(lines, fmt.Sprintf("EVENT\t%d\tdid:plc:fixture\t%s\trkey1\tcreate", seq, coll)) + } + dicts, conns := 1, 1 + if strings.HasPrefix(mode, "archive-") { + dicts = 0 + if mode == "archive-pages" || strings.HasPrefix(mode, "archive-kinds") { + conns = 0 + } + } + if mode == "plain" { + dicts = 0 + } + if mode == "rotation" || strings.HasPrefix(mode, "refresh-") { + dicts = 2 + conns = 2 + } + if mode == "reconnect" { + conns = 2 + } + if mode == "rotation-stop" { + dicts = 2 + } + return result{Scenario: mode, Transcript: lines, DictionaryRequests: dicts, Connections: conns} +} +func main() { + if len(os.Args) > 2 && os.Args[1] == "regressions" { + fmt.Fprintln(os.Stderr, "usage: conformance regressions") + os.Exit(2) + } + if len(os.Args) > 1 && os.Args[1] != "regressions" { + if err := loadMain(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + } + type comparison struct { + Scenario string `json:"scenario"` + Match bool `json:"match"` + Expected result `json:"expected"` + Go result `json:"go"` + Zig result `json:"zig"` + } + results := []comparison{} + failed := false + modes := []string{"plain", "compressed", "rotation", "exact", "wildcard", "duplicate", "resume", "malformed", "rotation-stop", "malformed-stop", "refresh-failed", "refresh-same", "reconnect", "archive-pages", "archive-handoff"} + if len(os.Args) > 1 { + modes = regressionModes + } + for _, mode := range modes { + goResult, goErr := run(mode, false) + zigResult, zigErr := run(mode, true) + want := expected(mode) + if isRegression(mode) { + want = regressionExpected(mode) + } + match := goErr == nil && zigErr == nil && matches(goResult, want) && matches(zigResult, want) + verdict := "PASS" + if !match { + verdict = "FAIL" + failed = true + } + fmt.Fprintln(os.Stderr, verdict, mode) + results = append(results, comparison{mode, match, want, goResult, zigResult}) + } + if e := json.NewEncoder(os.Stdout).Encode(results); e != nil { + fmt.Fprintln(os.Stderr, e) + os.Exit(1) + } + if failed { + os.Exit(1) + } +} + +var regressionModes = []string{"terminal-continue", "terminal-stop", "many-dids", "archive-kinds-excluded", "archive-kinds-excluded-stop", "archive-kinds-valid", "archive-kinds-selected", "archive-kinds-selected-stop"} + +func isRegression(mode string) bool { + for _, m := range regressionModes { + if mode == m { + return true + } + } + return false +} +func filterDIDs() []string { + const alphabet = "abcdefghijklmnopqrstuvwxyz234567" + dids := make([]string, 120) + for i := range dids { + dids[i] = "did:plc:" + strings.Repeat("a", 22) + string(alphabet[i/32]) + string(alphabet[i%32]) + } + return dids +} +func matches(got, want result) bool { got.Details = nil; return reflect.DeepEqual(got, want) } +func regressionExpected(mode string) result { + r := result{Scenario: mode, Transcript: []string{}} + did := "did:plc:fixture" + if mode == "many-dids" { + did = filterDIDs()[0] + r.DIDCount = 120 + } + if !strings.HasPrefix(mode, "archive-") { + r.Connections = 1 + } + if strings.HasPrefix(mode, "terminal-") { + r.Cursors = []string{""} + if mode == "terminal-continue" { + r.Connections = 2 + r.Cursors = append(r.Cursors, "1") + } + } + for seq := 1; seq <= 3; seq++ { + if seq == 2 && strings.HasPrefix(mode, "terminal-") { + r.Transcript = append(r.Transcript, "ERROR") + if mode == "terminal-stop" { + break + } + } + if seq == 2 && strings.HasPrefix(mode, "archive-kinds") { + if strings.HasPrefix(mode, "archive-kinds-selected") { + r.Transcript = append(r.Transcript, "ERROR") + if strings.HasSuffix(mode, "-stop") { + break + } + } + continue + } + collection := "app.bsky.feed.post" + if seq == 2 { + collection = "app.bsky.feed.like" + } + r.Transcript = append(r.Transcript, fmt.Sprintf("EVENT\t%d\t%s\t%s\trkey1\tcreate", seq, did, collection)) + } + return r +} diff --git a/checks/release-ready b/checks/release-ready new file mode 100755 index 0000000..e62ccb2 --- /dev/null +++ b/checks/release-ready @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +for tool in git curl jq; do command -v "$tool" >/dev/null; done +[[ -z $(git status --porcelain) ]] || { + echo 'Release requires a clean working tree' >&2 + exit 1 +} +sha=$(git rev-parse HEAD) +owner=did:plc:mkqt76xvfgxuemlwlx6ruc3w +pds=$(curl -fsS --max-time 30 "https://plc.directory/$owner" | jq -er '.service[] | select(.type == "AtprotoPersonalDataServer") | .serviceEndpoint') +record=$(curl -fsSG --max-time 30 "$pds/xrpc/com.atproto.repo.getRecord" \ + --data-urlencode "repo=$owner" --data-urlencode collection=sh.tangled.repo --data-urlencode rkey=jetstream) +repo=$(jq -er '.value.repoDid | strings | select(length > 0)' <<<"$record") +spindle=$(jq -er '.value.spindle | strings | select(length > 0)' <<<"$record") || { + echo 'Jetstream has no selected spindle' >&2 + exit 1 +} +results='[]' +cursor='' +while :; do + page=$(curl -fsSG --max-time 30 "https://$spindle/xrpc/sh.tangled.ci.queryPipelines" \ + --data-urlencode "repo=$repo" --data-urlencode limit=250 --data-urlencode "cursor=$cursor") + jq -e '.pipelines | type == "array"' <<<"$page" >/dev/null + results=$(jq -cn --argjson previous "$results" --argjson page "$page" --arg sha "$sha" \ + '$previous + [$page.pipelines[] | select(.commit == $sha)]') + if jq -e 'all(["checks.yml", "release.yml"][]; . as $name | any($pipelines[]; any(.workflows[]; .name == $name)))' \ + --argjson pipelines "$results" <</dev/null; then + break + fi + next=$(jq -r '.cursor // empty' <<<"$page") + [[ -n $next && $next != "$cursor" ]] || break + cursor=$next +done +jq -r --arg sha "$sha" ' + . as $pipelines | ["checks.yml", "release.yml"][] as $name | + ([$pipelines[] | . as $pipeline | .workflows[] | select(.name == $name) | + {pipeline: $pipeline.id, status: .status}][0] // {pipeline: "missing", status: "missing"}) as $run | + "\($sha) \($name) \($run.status) (\($run.pipeline))" +' <<<"$results" +jq -e ' + . as $pipelines | all(["checks.yml", "release.yml"][]; . as $name | + ([$pipelines[] | .workflows[] | select(.name == $name)][0].status // "missing") == "success") +' <<<"$results" >/dev/null || { + echo 'Release blocked: latest exact-commit checks and release workflows must both succeed' >&2 + exit 1 +} +echo "Release checks passed for $sha; no tag created or pushed" diff --git a/checks/run b/checks/run new file mode 100755 index 0000000..ef8a4df --- /dev/null +++ b/checks/run @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." + +profile=${1:-ci} +if [[ $# -gt 1 || ( "$profile" != ci && "$profile" != release ) ]]; then + echo 'usage: checks/run [ci|release]' >&2 + exit 2 +fi +zig=${ZIG:-zig} +results="checks/results/$profile" +mkdir -p "$results" zig-out/bin + +jq -n \ + --arg commit "$(git rev-parse HEAD)" \ + --arg status "$(git status --porcelain)" \ + --arg profile "$profile" \ + --arg zig "$("$zig" version)" \ + --arg go "$(go version)" \ + --arg platform "$(uname -sm)" \ + --arg date "$(date -u +%FT%TZ)" \ + --arg upstream "$(cd checks && go list -m github.com/bluesky-social/jetstream)" \ + --argjson cpus "$(nproc)" \ + '{commit: $commit, dirty: ($status != ""), profile: $profile, + zig: $zig, go: $go, platform: $platform, logical_cpus: $cpus, + upstream: $upstream, started_at: $date, optimize: "ReleaseSafe"}' \ + | tee "$results/metadata.json" + +failed=0 +run() { + local name=$1 + shift + echo "CHECK $name" + if "$@" > "$results/$name.log" 2>&1; then + cat "$results/$name.log" + echo "PASS $name" + else + cat "$results/$name.log" + echo "FAIL $name" >&2 + failed=1 + fi +} +measure() { + local name=$1 + shift + echo "CHECK $name" + if zig-out/bin/conformance "$@" > "$results/$name.json" 2> "$results/$name.log"; then + cat "$results/$name.log" "$results/$name.json" + echo "PASS $name" + else + cat "$results/$name.log" "$results/$name.json" + echo "FAIL $name" >&2 + failed=1 + fi +} + +run format "$zig" fmt --check build.zig build.zig.zon src examples checks/client.zig +run whitespace git diff --check +run go-checks bash -c 'cd checks && test -z "$(gofmt -l .)" && go vet ./...' +run debug "$zig" build test --summary all +run release-safe "$zig" build test -Doptimize=ReleaseSafe --summary all +run examples "$zig" build -Doptimize=ReleaseSafe +"$zig" build check-client -Doptimize=ReleaseSafe +(cd checks && go build -mod=readonly -o ../zig-out/bin/conformance .) +measure conformance +if [[ "$profile" == ci ]]; then + measure live load ci + measure catchup catchup ci +else + measure live load + measure catchup catchup +fi +measure regressions regressions +exit "$failed" diff --git a/docs/verification.md b/docs/verification.md new file mode 100644 index 0000000..68b4e36 --- /dev/null +++ b/docs/verification.md @@ -0,0 +1,128 @@ +# verification + +Jetstream client checks live beside the SDK so every run builds the checkout +under review. The official Go client remains pinned in `checks/go.mod` and +`go.sum` at `58c4d7f7a9130e53b40348ad3d1f7aafed0e4843`. Update that reference +deliberately and review changed behavior; agreement alone is not correctness. +The harness originated in atproto-bench `28d83d1`. Historical measurements +remain [there](https://tangled.org/zzstoatzz.io/atproto-bench/tree/main/docs). + +## run + +Requires Zig 0.16.0, Go 1.27.0, Bash, jq, Git, and GNU coreutils (including nproc). +Dependency setup needs network access; all fixtures then use bounded local +HTTP/WebSocket connections. No API key, production archive, or firehose is used. + +```sh +checks/run ci # unit tests, examples, conformance, short measurements, regressions +checks/run release # same checks with the full repeated measurement matrices +``` + +Both commands check formatting and Go static analysis, run SDK tests in Debug +and ReleaseSafe, build examples, and build the Zig adapter against the root +build's SDK module. There is no separate SDK archive pin. The two client +processes receive fresh instances of the same fixture and explicit expected +outputs. The live dictionary fixture is copied unchanged from the original +harness (Stream dictionary 20260811). + +Results are written to `checks/results/{ci,release}/` and printed into the +Tangled workflow log. Metadata identifies the commit, dirty state, toolchains, +reference client, platform, CPU count, optimization mode, and start time. +Keep metadata with raw measurements. Generated output is ignored by Git. +The script runs the regression lane last so a known semantic failure doesn't +hide the performance evidence. Any failed check makes the command fail. + +For focused reruns after building: + +```sh +zig-out/bin/conformance # 15 established conformance scenarios +zig-out/bin/conformance regressions # audited defects and controls +zig-out/bin/conformance load ci +zig-out/bin/conformance catchup ci +zig-out/bin/conformance catchup 100000 # one archive size, four repeats +``` + +## correctness + +The established lane covers plain/compressed subscriptions, exact/wildcard +filters, cursor deduplication, reconnect cursor preservation, dictionary +refresh/fallback, callback refusal, real JSS pagination, and archive/live +boundary overlap. Transcripts and request counts must match explicit expected +outcomes for both clients. Error categories remain language-specific. + +The regression lane adds terminal error callbacks, 120 unique DID filters, +and archive kind admission before decoding, including malformed selected and +excluded row controls. These intentionally assert the desired contract, not +the current defective output. At introduction, SDK v0.1.5 fails the terminal +continue, large-filter, and excluded-malformed-row cases. No expected-failure +allowlist converts those results into success. These are release blockers +until separately reviewed SDK fixes land. + +Multi-host failover remains an SDK extension exercised by its own loopback +unit tests. The single-host Go client cannot establish its correctness. +Stream server retention, hot/cold cursor translation, and storage behavior +need server fixtures; this client harness does not claim to cover them. + +## performance + +Correct delivery, request counts, checksums, and bounded completion gate every +measurement. CPU/RSS/latency numbers are recorded, without arbitrary thresholds +on shared CI hardware. Compare repeated distributions on comparable hardware +before calling a difference a regression or an improvement. + +| workload | every change | full release profile | +| --- | --- | --- | +| live | 1k events at 1k/s; 10k at 10k/s; 100 at 1k/s with a 2ms handler | 4k at 1k/s; 20k at 10k/s; 1k with the slow handler | +| archive catch-up | 10k archived records | 10k, 100k, 1M archived records | +| repetitions | two, alternating Go/Zig order | four, alternating Go/Zig order | + +Live runs use plain and dictionary compression. They verify every sequence, +then report process CPU, peak RSS, lag p50/p99/max, payload bytes, and fixture +scheduling lateness. Frames are prepared before consumer startup, except the +first frame establishing the schedule. Lag includes fixture scheduling and +socket queues. The slow handler tests a finite backlog, not an indefinitely +bounded queue. Samples and sorting contribute to consumer CPU and memory. + +Catch-up uses the official Go JSS writer, 1,000 records per block, and both +zero and 20ms injected block-request delay. It verifies ordered delivery, +a canonical-record checksum, one fetch per block, one live connection, and +suppression of the overlapping archive boundary. Both clients set concurrency +to four, but their fetch/decode policies differ; observed request concurrency +is reported. Time runs from subscription initialization to the first new live +event. Whole-process CPU includes startup, checksum work, and shutdown. + +Both corpora are synthetic and compressible. Payload bytes exclude protocol +framing. Server and clients share CPUs, although client CPU/RSS exclude the +fixture process. Linux memory uses post-exec VmHWM sampling; macOS wait4 peak +RSS can include inherited memory and is unsuitable for cross-client memory +claims. CI is a reproducibility check, not production capacity certification. + +## Tangled and releases + +`checks.yml` runs on every branch push, pull requests targeting main, version +tags, and manual triggers. `release.yml` runs manually before tagging and uses +the full profile. Tool downloads are versioned and SHA256-checked. The SDK +repository must select `spindle.zzstoatzz.io`; workflow files alone do not +enable CI. See [Tangled's workflow documentation](https://docs.tangled.org/spindles). + +Prepare and commit all version/changelog changes first, push the candidate +branch, and trigger `release.yml` for that exact commit using `tg pipeline +trigger` (see its installed `--help`). Inspect verdicts with `tg pipeline +status` and logs with `tg pipeline logs`. Then run: + +```sh +checks/release-ready +``` + +This refuses a dirty checkout, absent/missing CI, pending/failed/cancelled +workflows, and successes from another commit. It consults the selected +spindle's authoritative API and requires the newest run of each required +workflow for HEAD to succeed. Only then create and push the version tag on +that unchanged commit. Re-run after any amendment or merge commit. Tag-triggered +CI is a follow-up check; it cannot replace verification before publication. +This is an enforced check in the release procedure, not a server-side ban on +manual Git tags. + +Transport changes additionally require the bounded live smoke described in +the repository release skill. Keep network failures separate from local +fixture failures and resolve them before publishing. -- 2.51.2