| 1 | // Command oakmark measures per-repo push (and clone) throughput of an oak
|
| 2 | // server — the oak analogue of ForgeMark. It forks ForgeMark's measurement
|
| 3 | // harness (concurrency sweep, warm-up exclusion, nearest-rank latency
|
| 4 | // percentiles, ops/sec → ops/hour, JSON schema in stats.go — kept verbatim for
|
| 5 | // comparability) and swaps the git-over-smart-HTTP transport for oak's own
|
| 6 | // protocol via one of two backends:
|
| 7 | //
|
| 8 | // -transport cli shell out to the real `oak` binary (honest real-agent
|
| 9 | // floor; includes client chunking + process spin-up)
|
| 10 | // -transport protocol drive the in-process oak-engine (Rust, on oakvcs-core);
|
| 11 | // the ForgeMark-comparable ceiling
|
| 12 | //
|
| 13 | // Strategies mirror ForgeMark: branch (headline single-repo push throughput),
|
| 14 | // repo (horizontal across N repos), session (clone+checkpoint read/write mix;
|
| 15 | // cli-only). Each agent owns its own branch (oak's flat model), so branch/repo
|
| 16 | // pushes never lose a CAS race — we measure the server's per-repo throughput.
|
| 17 | package main
|
| 18 |
|
| 19 | import (
|
| 20 | "context"
|
| 21 | "flag"
|
| 22 | "fmt"
|
| 23 | "io"
|
| 24 | "os"
|
| 25 | "strconv"
|
| 26 | "strings"
|
| 27 | "sync"
|
| 28 | "time"
|
| 29 | )
|
| 30 |
|
| 31 | // engineStderr is where every protocol-transport oak-engine child sends its
|
| 32 | // diagnostics. One shared sink keeps engine noise out of stdout (which carries
|
| 33 | // the JSON protocol).
|
| 34 | var engineStderr io.Writer = os.Stderr
|
| 35 |
|
| 36 | type repoRef struct{ owner, name string }
|
| 37 |
|
| 38 | func (r repoRef) String() string { return r.owner + "/" + r.name }
|
| 39 |
|
| 40 | func main() {
|
| 41 | remote := flag.String("remote", "http://127.0.0.1:8080", "oak server base URL (e.g. https://oak.space or a local `oak serve`)")
|
| 42 | transportKind := flag.String("transport", "cli", "push backend: cli | protocol")
|
| 43 | oakBin := flag.String("oak-bin", "oak", "path to the oak binary (cli transport + repo seeding)")
|
| 44 | engineBin := flag.String("engine-bin", "./oak-engine/target/release/oak-engine", "path to oak-engine (protocol transport)")
|
| 45 | tokenFile := flag.String("token-file", "", "file holding the oak.space bearer token; '-' for stdin. Falls back to $OAK_API_KEY")
|
| 46 | strategy := flag.String("strategy", "branch", "workload strategy: branch | repo | session")
|
| 47 | owner := flag.String("owner", "bench", "org/owner slug that owns the target repos (must exist on oak.space)")
|
| 48 | repoBase := flag.String("repo", "oakmark-tp", "target repo name (branch/session); base name for the repo strategy (repo-<n>)")
|
| 49 | repoCount := flag.Int("repo-count", 4, "number of repos for the repo strategy")
|
| 50 | concurrencyCSV := flag.String("concurrency", "1,8,32", "comma-separated agent counts to sweep")
|
| 51 | duration := flag.Duration("duration", 30*time.Second, "measurement window per concurrency level")
|
| 52 | warmup := flag.Duration("warmup", 5*time.Second, "discarded warm-up window per level")
|
| 53 | filesMin := flag.Int("files-min", 1, "min files per changeset")
|
| 54 | filesMax := flag.Int("files-max", 10, "max files per changeset")
|
| 55 | fileSize := flag.Int("file-size", 2048, "bytes per file")
|
| 56 | sessionCommits := flag.Int("session-commits", 5, "checkpoint pushes per session (session strategy)")
|
| 57 | cloneDepth := flag.Int("clone-depth", 1, "clone depth for the session read (0 = full history, else shallow)")
|
| 58 | branchPrefix := flag.String("branch-prefix", "", "branch name prefix (default: oakmark-<runid>)")
|
| 59 | out := flag.String("out", "", "write JSON results to this path (default: stdout only)")
|
| 60 | reportOut := flag.String("report", "", "write a markdown throughput report to this path")
|
| 61 | doSeed := flag.Bool("seed", true, "create/seed the target repos before the sweep")
|
| 62 | flag.Parse()
|
| 63 |
|
| 64 | runID := fmt.Sprintf("run%d", time.Now().Unix())
|
| 65 | prefix := *branchPrefix
|
| 66 | if prefix == "" {
|
| 67 | prefix = "oakmark-" + runID
|
| 68 | }
|
| 69 | token := loadToken(*tokenFile)
|
| 70 |
|
| 71 | if *strategy == "session" && *transportKind == "protocol" {
|
| 72 | fatalf("%v", errNoSession)
|
| 73 | }
|
| 74 | if *strategy != "branch" && *strategy != "repo" && *strategy != "session" {
|
| 75 | fatalf("unknown -strategy %q (want branch|repo|session)", *strategy)
|
| 76 | }
|
| 77 | if *transportKind != "cli" && *transportKind != "protocol" {
|
| 78 | fatalf("unknown -transport %q (want cli|protocol)", *transportKind)
|
| 79 | }
|
| 80 |
|
| 81 | repos := repoList(*owner, *repoBase, *strategy, *repoCount)
|
| 82 | levels, err := parseLevels(*concurrencyCSV)
|
| 83 | if err != nil {
|
| 84 | fatalf("bad -concurrency: %v", err)
|
| 85 | }
|
| 86 | cfg := &commitConfig{filesMin: *filesMin, filesMax: *filesMax, fileSize: *fileSize}
|
| 87 | commitFiles := fmt.Sprintf("%d-%d x %dB", cfg.filesMin, cfg.filesMax, cfg.fileSize)
|
| 88 | var sess *sessionConfig
|
| 89 | if *strategy == "session" {
|
| 90 | sess = &sessionConfig{commits: *sessionCommits, cloneDepth: *cloneDepth}
|
| 91 | }
|
| 92 |
|
| 93 | fmt.Fprintf(os.Stderr, "oakmark: remote=%s transport=%s strategy=%s repos=%d changeset=%q window=%s warmup=%s\n",
|
| 94 | *remote, *transportKind, *strategy, len(repos), commitFiles, *duration, *warmup)
|
| 95 |
|
| 96 | if *doSeed {
|
| 97 | for _, r := range repos {
|
| 98 | if err := seedRepo(*oakBin, *remote, r); err != nil {
|
| 99 | fatalf("seed %s: %v", r, err)
|
| 100 | }
|
| 101 | }
|
| 102 | fmt.Fprintf(os.Stderr, "oakmark: seeded %d repo(s)\n", len(repos))
|
| 103 | }
|
| 104 |
|
| 105 | runCfg := &runConfig{
|
| 106 | transport: *transportKind, oakBin: *oakBin, engineBin: *engineBin,
|
| 107 | remote: *remote, token: token, prefix: prefix, cfg: cfg, sess: sess,
|
| 108 | cloneDepth: *cloneDepth, repos: repos,
|
| 109 | }
|
| 110 |
|
| 111 | results := make([]levelResult, 0, len(levels))
|
| 112 | for _, level := range levels {
|
| 113 | fmt.Fprintf(os.Stderr, "oakmark: level c=%d …\n", level)
|
| 114 | samples := runLevel(runCfg, level, *warmup, *duration)
|
| 115 | r := summarize(samples, level, *strategy, len(repos), 1, *warmup, *duration, commitFiles)
|
| 116 | results = append(results, r)
|
| 117 | printLevel(r)
|
| 118 | }
|
| 119 |
|
| 120 | emitJSON(results, *out, runID, *remote, *transportKind)
|
| 121 | report := renderReport(results, runID, *remote, *transportKind, *strategy)
|
| 122 | if *reportOut != "" {
|
| 123 | if err := os.WriteFile(*reportOut, []byte(report), 0o644); err != nil {
|
| 124 | fmt.Fprintf(os.Stderr, "oakmark: write report: %v\n", err)
|
| 125 | } else {
|
| 126 | fmt.Fprintf(os.Stderr, "oakmark: report → %s\n", *reportOut)
|
| 127 | }
|
| 128 | }
|
| 129 | fmt.Print(report)
|
| 130 | }
|
| 131 |
|
| 132 | // runConfig is the immutable per-run setup a level needs to build agents.
|
| 133 | type runConfig struct {
|
| 134 | transport string
|
| 135 | oakBin string
|
| 136 | engineBin string
|
| 137 | remote string
|
| 138 | token string
|
| 139 | prefix string
|
| 140 | cfg *commitConfig
|
| 141 | sess *sessionConfig
|
| 142 | cloneDepth int
|
| 143 | repos []repoRef
|
| 144 | }
|
| 145 |
|
| 146 | // runLevel runs `level` concurrent agents for warmup+window and returns every
|
| 147 | // sample. Agents are assigned round-robin across repos (the repo strategy
|
| 148 | // spreads load; branch/session use a single repo). Each owns branch
|
| 149 | // <prefix>-a<i>.
|
| 150 | func runLevel(rc *runConfig, level int, warmup, window time.Duration) []sample {
|
| 151 | ctx, cancel := context.WithTimeout(context.Background(), warmup+window)
|
| 152 | defer cancel()
|
| 153 |
|
| 154 | agents := make([]*agent, level)
|
| 155 | for i := range level {
|
| 156 | repo := rc.repos[i%len(rc.repos)]
|
| 157 | branch := fmt.Sprintf("%s-a%d", rc.prefix, i)
|
| 158 | tr := buildTransport(rc, repo, i, branch)
|
| 159 | agents[i] = newAgent(i, rc.cfg, rc.sess, tr)
|
| 160 | }
|
| 161 |
|
| 162 | start := time.Now()
|
| 163 | var wg sync.WaitGroup
|
| 164 | for _, a := range agents {
|
| 165 | wg.Add(1)
|
| 166 | go func(a *agent) { defer wg.Done(); a.run(ctx, start) }(a)
|
| 167 | }
|
| 168 | wg.Wait()
|
| 169 |
|
| 170 | var all []sample
|
| 171 | for _, a := range agents {
|
| 172 | all = append(all, a.samples...)
|
| 173 | }
|
| 174 | return all
|
| 175 | }
|
| 176 |
|
| 177 | func buildTransport(rc *runConfig, repo repoRef, id int, branch string) Transport {
|
| 178 | if rc.transport == "protocol" {
|
| 179 | author := fmt.Sprintf("agent-%d", id)
|
| 180 | return newProtocolTransport(rc.engineBin, rc.remote, repo.owner, repo.name, rc.token, author, branch)
|
| 181 | }
|
| 182 | return newCLITransport(rc.oakBin, rc.remote, repo.owner, repo.name, id, branch, rc.cloneDepth)
|
| 183 | }
|
| 184 |
|
| 185 | // repoList builds the target repo set. branch/session use one repo; repo
|
| 186 | // spreads across repo-<0..n-1>.
|
| 187 | func repoList(owner, base, strategy string, count int) []repoRef {
|
| 188 | if strategy == "repo" {
|
| 189 | if count < 1 {
|
| 190 | count = 1
|
| 191 | }
|
| 192 | repos := make([]repoRef, count)
|
| 193 | for i := range count {
|
| 194 | repos[i] = repoRef{owner: owner, name: fmt.Sprintf("%s-%d", base, i)}
|
| 195 | }
|
| 196 | return repos
|
| 197 | }
|
| 198 | return []repoRef{{owner: owner, name: base}}
|
| 199 | }
|
| 200 |
|
| 201 | func parseLevels(csv string) ([]int, error) {
|
| 202 | parts := strings.Split(csv, ",")
|
| 203 | levels := make([]int, 0, len(parts))
|
| 204 | for _, p := range parts {
|
| 205 | p = strings.TrimSpace(p)
|
| 206 | if p == "" {
|
| 207 | continue
|
| 208 | }
|
| 209 | n, err := strconv.Atoi(p)
|
| 210 | if err != nil || n < 1 {
|
| 211 | return nil, fmt.Errorf("%q is not a positive integer", p)
|
| 212 | }
|
| 213 | levels = append(levels, n)
|
| 214 | }
|
| 215 | if len(levels) == 0 {
|
| 216 | return nil, fmt.Errorf("no levels")
|
| 217 | }
|
| 218 | return levels, nil
|
| 219 | }
|
| 220 |
|
| 221 | // loadToken resolves the bearer token: -token-file (or '-' for stdin), else
|
| 222 | // $OAK_API_KEY, else "" (anonymous — fine for an open loopback `oak serve`).
|
| 223 | func loadToken(tokenFile string) string {
|
| 224 | if tokenFile == "-" {
|
| 225 | b, _ := io.ReadAll(os.Stdin)
|
| 226 | return strings.TrimSpace(string(b))
|
| 227 | }
|
| 228 | if tokenFile != "" {
|
| 229 | b, err := os.ReadFile(tokenFile)
|
| 230 | if err != nil {
|
| 231 | fatalf("read token file %s: %v", tokenFile, err)
|
| 232 | }
|
| 233 | return strings.TrimSpace(string(b))
|
| 234 | }
|
| 235 | return strings.TrimSpace(os.Getenv("OAK_API_KEY"))
|
| 236 | }
|
| 237 |
|
| 238 | // seedRepo creates the target repo on the server so agents can clone it: a
|
| 239 | // fresh `oak init` + `oak commit` + `oak push --repo owner/name`. Idempotent —
|
| 240 | // a second seed of an existing repo just re-links and pushes a new branch.
|
| 241 | func seedRepo(oakBin, remote string, repo repoRef) error {
|
| 242 | dir, err := os.MkdirTemp("", "oakmark-seed-")
|
| 243 | if err != nil {
|
| 244 | return err
|
| 245 | }
|
| 246 | defer os.RemoveAll(dir)
|
| 247 | env := append(os.Environ(), "OAK_REMOTE="+remote)
|
| 248 | runOak := func(d string, args ...string) error {
|
| 249 | return runCmd(oakBin, d, env, args...)
|
| 250 | }
|
| 251 | if err := runOak(dir, "init", "."); err != nil {
|
| 252 | return fmt.Errorf("init: %w", err)
|
| 253 | }
|
| 254 | if err := os.WriteFile(dir+"/README.md", []byte("oakmark seed for "+repo.String()+"\n"), 0o644); err != nil {
|
| 255 | return err
|
| 256 | }
|
| 257 | if err := runOak(dir, "commit", "--quiet"); err != nil {
|
| 258 | return fmt.Errorf("commit: %w", err)
|
| 259 | }
|
| 260 | if err := runOak(dir, "push", "--repo", repo.String(), "-r", remote); err != nil {
|
| 261 | return fmt.Errorf("push: %w", err)
|
| 262 | }
|
| 263 | return nil
|
| 264 | }
|
| 265 |
|
| 266 | func fatalf(format string, args ...any) {
|
| 267 | fmt.Fprintf(os.Stderr, "oakmark: "+format+"\n", args...)
|
| 268 | os.Exit(1)
|
| 269 | }
|