| 1 | package main
|
| 2 |
|
| 3 | import (
|
| 4 | "context"
|
| 5 | "fmt"
|
| 6 | "math/rand"
|
| 7 | "regexp"
|
| 8 | "time"
|
| 9 | )
|
| 10 |
|
| 11 | // agent is one synthetic writer. It drives a Transport (cli or protocol) to
|
| 12 | // push a stream of small changesets to its own branch on its assigned repo.
|
| 13 | // Each agent owns its own branch (oak's flat model — every branch parents onto
|
| 14 | // main and has an independent head), so in branch/repo strategies its pushes
|
| 15 | // never lose a CAS race; what we measure is the server's per-repo push
|
| 16 | // throughput, not client retry. This mirrors ForgeMark's agent, with the
|
| 17 | // go-git calls replaced by the Transport seam.
|
| 18 | type agent struct {
|
| 19 | id int
|
| 20 | cfg *commitConfig
|
| 21 | sess *sessionConfig // non-nil → "session" strategy (clone+push loop)
|
| 22 | tr Transport
|
| 23 |
|
| 24 | samples []sample
|
| 25 | }
|
| 26 |
|
| 27 | func newAgent(id int, cfg *commitConfig, sess *sessionConfig, tr Transport) *agent {
|
| 28 | return &agent{id: id, cfg: cfg, sess: sess, tr: tr}
|
| 29 | }
|
| 30 |
|
| 31 | // run pushes changesets in a tight loop until ctx is cancelled, recording one
|
| 32 | // sample per attempt. start anchors sample offsets so the caller drops the
|
| 33 | // warm-up window afterwards. Identical control flow to ForgeMark's agent.run.
|
| 34 | func (a *agent) run(ctx context.Context, start time.Time) {
|
| 35 | defer a.tr.Close()
|
| 36 | if a.sess != nil {
|
| 37 | a.runSessions(ctx, start)
|
| 38 | return
|
| 39 | }
|
| 40 | rng := rand.New(rand.NewSource(int64(a.id)*1_000_003 + 1)) //nolint:gosec // synthetic load, not security-sensitive
|
| 41 | if err := a.tr.Init(ctx); err != nil {
|
| 42 | // A setup failure takes this agent out of the level; record it so one
|
| 43 | // bad agent is visible rather than silently absent.
|
| 44 | a.samples = append(a.samples, sample{offset: time.Since(start), res: outcomeErr, msg: fmt.Sprintf("init: %v", err)})
|
| 45 | return
|
| 46 | }
|
| 47 | for ctx.Err() == nil {
|
| 48 | files := generateFiles(rng, a.cfg)
|
| 49 | t0 := time.Now()
|
| 50 | err := a.tr.Push(ctx, files)
|
| 51 | dur := time.Since(t0)
|
| 52 | // Drop the attempt that lost the ctx-cancellation race: it's a truncated
|
| 53 | // measurement, not real latency.
|
| 54 | if ctx.Err() != nil {
|
| 55 | break
|
| 56 | }
|
| 57 | o := classifyOak(err)
|
| 58 | a.samples = append(a.samples, sample{offset: t0.Sub(start), dur: dur, res: o, msg: errMsg(o, err)})
|
| 59 | }
|
| 60 | }
|
| 61 |
|
| 62 | // runSessions is the session-strategy loop: shallow-clone the base repo (the
|
| 63 | // measured read), push `commits` checkpoints to a fresh ephemeral branch (the
|
| 64 | // measured writes), then abandon it. Clone latency is op==clone, push latency
|
| 65 | // op==push, so they summarize separately. Mirrors ForgeMark's runSessions.
|
| 66 | func (a *agent) runSessions(ctx context.Context, start time.Time) {
|
| 67 | rng := rand.New(rand.NewSource(int64(a.id)*1_000_003 + 1)) //nolint:gosec // synthetic load
|
| 68 | for session := 0; ctx.Err() == nil; session++ {
|
| 69 | t0 := time.Now()
|
| 70 | err := a.tr.CloneSession(ctx)
|
| 71 | if ctx.Err() != nil {
|
| 72 | return
|
| 73 | }
|
| 74 | res := readOutcome(err)
|
| 75 | a.samples = append(a.samples, sample{offset: t0.Sub(start), dur: time.Since(t0), res: res, op: opClone, msg: errMsg(res, err)})
|
| 76 | if err != nil {
|
| 77 | continue // clone failed — next session retries
|
| 78 | }
|
| 79 | pushed := false
|
| 80 | for k := 0; k < a.sess.commits && ctx.Err() == nil; k++ {
|
| 81 | files := generateFiles(rng, a.cfg)
|
| 82 | t1 := time.Now()
|
| 83 | perr := a.tr.PushCheckpoint(ctx, session, files)
|
| 84 | if perr == nil {
|
| 85 | pushed = true
|
| 86 | }
|
| 87 | if ctx.Err() != nil {
|
| 88 | break // window closed mid-push: stop, but still clean up
|
| 89 | }
|
| 90 | o := classifyOak(perr)
|
| 91 | a.samples = append(a.samples, sample{offset: t1.Sub(start), dur: time.Since(t1), res: o, op: opPush, msg: errMsg(o, perr)})
|
| 92 | }
|
| 93 | if pushed {
|
| 94 | a.tr.CleanupSession(session) // best-effort, unmeasured
|
| 95 | }
|
| 96 | }
|
| 97 | }
|
| 98 |
|
| 99 | // maxErrMsgLen caps a retained error message so a long URL can't crowd the
|
| 100 | // signal out of the window.
|
| 101 | const maxErrMsgLen = 200
|
| 102 |
|
| 103 | // reURL/reRef collapse the volatile substrings that vary by node/repo/agent
|
| 104 | // (server URL, branch ref) so identical failures group together in the results.
|
| 105 | var (
|
| 106 | reURL = regexp.MustCompile(`https?://\S+`)
|
| 107 | reRef = regexp.MustCompile(`refs?/\S+|oakmark-\S+`)
|
| 108 | )
|
| 109 |
|
| 110 | // normalizeErr turns a raw error message into a stable grouping key: collapse
|
| 111 | // this run's volatile URLs/branch refs, then truncate. Order matters —
|
| 112 | // truncating first could cut a status code that follows a long URL. Same
|
| 113 | // contract as ForgeMark's normalizeErr (consumed by stats.go errGroups).
|
| 114 | func normalizeErr(msg string) string {
|
| 115 | if msg == "" {
|
| 116 | return "(no message)"
|
| 117 | }
|
| 118 | msg = reURL.ReplaceAllString(msg, "<url>")
|
| 119 | msg = reRef.ReplaceAllString(msg, "<ref>")
|
| 120 | if len(msg) > maxErrMsgLen {
|
| 121 | msg = msg[:maxErrMsgLen]
|
| 122 | }
|
| 123 | return msg
|
| 124 | }
|