vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Phase 0: measure metadata writes, git transport, and tree serving
c5626f1b87084870524ea54987ef47a65ea4740b · Keenan Nemetz · 18d ago · parent 17311880
Phase 0: measure metadata writes, git transport, and tree serving Three throwaway spikes under docs/spikes, each with its own harness, README, and reproduce command. Findings are folded into the roadmap and its decision log. - ref-cas: sharded + batched refs sustain 25,637 entity writes/s at 20k entities with zero lost writes; a single unsharded ref manages 30/s with 22 CAS retries per write and decays quadratically as it fills. Metadata refs must be sharded. Unsharded is now off the table. - smart-http: driving git http-backend as a subprocess is correct (hook stderr reaches the client, aborts leave no ref and no orphaned children) and pushes 150 MiB/s on compressible data, but a 1 GiB push peaks at 1.13 GB of git-child RSS. Phase 1 needs a push size cap and a concurrency semaphore, plus chunked-upload spooling. - tree-serving: sites can be served straight from git trees with no export step - 1.2 ms p50 cold, 0.075 ms warm, 170k req/s behind an 18.5 MiB cache. Harness bugs caught during the work are recorded in the spike READMEs, including one that silently dropped 83% of writes while reporting plausible throughput.
docs/spikes/smart-http/main.go +600
| @@ -0,0 +1,600 @@ | |||
| 1 | // Spike B: does driving `git http-backend` as a CGI subprocess from Go give us | ||
| 2 | // a correct and fast smart-HTTP transport? | ||
| 3 | // | ||
| 4 | // Measures a 1 GB push, hook rejection message propagation, client-abort | ||
| 5 | // behaviour, orphaned child processes, and peak memory of the receive-pack | ||
| 6 | // children spawned under load. | ||
| 7 | package main | ||
| 8 | |||
| 9 | import ( | ||
| 10 | "bufio" | ||
| 11 | "bytes" | ||
| 12 | "crypto/rand" | ||
| 13 | "encoding/json" | ||
| 14 | "flag" | ||
| 15 | "fmt" | ||
| 16 | "io" | ||
| 17 | "net" | ||
| 18 | "net/http" | ||
| 19 | "os" | ||
| 20 | "os/exec" | ||
| 21 | "path/filepath" | ||
| 22 | "strconv" | ||
| 23 | "strings" | ||
| 24 | "sync" | ||
| 25 | "sync/atomic" | ||
| 26 | "syscall" | ||
| 27 | "time" | ||
| 28 | ) | ||
| 29 | |||
| 30 | type config struct { | ||
| 31 | root string | ||
| 32 | } | ||
| 33 | |||
| 34 | // spoolThreshold bounds how much request body we buffer in memory when the | ||
| 35 | // client sends chunked encoding and no Content-Length is available. | ||
| 36 | type spoolStats struct { | ||
| 37 | mu sync.Mutex | ||
| 38 | spooled int64 | ||
| 39 | inMemory int64 | ||
| 40 | requests int64 | ||
| 41 | chunked int64 | ||
| 42 | } | ||
| 43 | |||
| 44 | var spooling spoolStats | ||
| 45 | |||
| 46 | // cgiEnv builds the CGI environment git http-backend expects. | ||
| 47 | func cgiEnv(r *http.Request, root string, contentLength int64) []string { | ||
| 48 | env := []string{ | ||
| 49 | "GIT_PROJECT_ROOT=" + root, | ||
| 50 | "GIT_HTTP_EXPORT_ALL=1", | ||
| 51 | "PATH_INFO=" + r.URL.Path, | ||
| 52 | "REQUEST_METHOD=" + r.Method, | ||
| 53 | "QUERY_STRING=" + r.URL.RawQuery, | ||
| 54 | "REMOTE_USER=spike", | ||
| 55 | "REMOTE_ADDR=" + r.RemoteAddr, | ||
| 56 | "SERVER_PROTOCOL=" + r.Proto, | ||
| 57 | "GATEWAY_INTERFACE=CGI/1.1", | ||
| 58 | } | ||
| 59 | if ct := r.Header.Get("Content-Type"); ct != "" { | ||
| 60 | env = append(env, "CONTENT_TYPE="+ct) | ||
| 61 | } | ||
| 62 | env = append(env, "CONTENT_LENGTH="+strconv.FormatInt(contentLength, 10)) | ||
| 63 | for name, vals := range r.Header { | ||
| 64 | key := "HTTP_" + strings.ToUpper(strings.ReplaceAll(name, "-", "_")) | ||
| 65 | switch key { | ||
| 66 | case "HTTP_CONTENT_TYPE", "HTTP_CONTENT_LENGTH": | ||
| 67 | continue | ||
| 68 | } | ||
| 69 | env = append(env, key+"="+strings.Join(vals, ",")) | ||
| 70 | } | ||
| 71 | return env | ||
| 72 | } | ||
| 73 | |||
| 74 | func backendHandler(root string) http.HandlerFunc { | ||
| 75 | return func(w http.ResponseWriter, r *http.Request) { | ||
| 76 | atomic.AddInt64(&spooling.requests, 1) | ||
| 77 | |||
| 78 | // git http-backend needs a real CONTENT_LENGTH. git's client switches | ||
| 79 | // to chunked transfer above http.postBuffer (1 MiB by default), so we | ||
| 80 | // must spool to learn the length. | ||
| 81 | var body io.Reader = r.Body | ||
| 82 | length := r.ContentLength | ||
| 83 | var tmp *os.File | ||
| 84 | if r.ContentLength < 0 && (r.Method == http.MethodPost || r.Method == http.MethodPut) { | ||
| 85 | atomic.AddInt64(&spooling.chunked, 1) | ||
| 86 | f, err := os.CreateTemp("", "vole-spike-b-body-*") | ||
| 87 | if err != nil { | ||
| 88 | http.Error(w, "spool: "+err.Error(), 500) | ||
| 89 | return | ||
| 90 | } | ||
| 91 | tmp = f | ||
| 92 | n, err := io.Copy(f, r.Body) | ||
| 93 | if err != nil { | ||
| 94 | f.Close() | ||
| 95 | os.Remove(f.Name()) | ||
| 96 | http.Error(w, "spool copy: "+err.Error(), 500) | ||
| 97 | return | ||
| 98 | } | ||
| 99 | if _, err := f.Seek(0, io.SeekStart); err != nil { | ||
| 100 | f.Close() | ||
| 101 | os.Remove(f.Name()) | ||
| 102 | http.Error(w, "spool seek: "+err.Error(), 500) | ||
| 103 | return | ||
| 104 | } | ||
| 105 | body = f | ||
| 106 | length = n | ||
| 107 | atomic.AddInt64(&spooling.spooled, n) | ||
| 108 | } | ||
| 109 | |||
| 110 | cmd := exec.Command("git", "http-backend") | ||
| 111 | cmd.Env = append(os.Environ(), cgiEnv(r, root, length)...) | ||
| 112 | stdin, err := cmd.StdinPipe() | ||
| 113 | if err != nil { | ||
| 114 | http.Error(w, err.Error(), 500) | ||
| 115 | return | ||
| 116 | } | ||
| 117 | stdout, err := cmd.StdoutPipe() | ||
| 118 | if err != nil { | ||
| 119 | http.Error(w, err.Error(), 500) | ||
| 120 | return | ||
| 121 | } | ||
| 122 | var stderr bytes.Buffer | ||
| 123 | cmd.Stderr = &stderr | ||
| 124 | if err := cmd.Start(); err != nil { | ||
| 125 | http.Error(w, err.Error(), 500) | ||
| 126 | return | ||
| 127 | } | ||
| 128 | |||
| 129 | go func() { | ||
| 130 | defer stdin.Close() | ||
| 131 | io.Copy(stdin, body) | ||
| 132 | if tmp != nil { | ||
| 133 | name := tmp.Name() | ||
| 134 | tmp.Close() | ||
| 135 | os.Remove(name) | ||
| 136 | } | ||
| 137 | }() | ||
| 138 | |||
| 139 | br := bufio.NewReaderSize(stdout, 256<<10) | ||
| 140 | status := http.StatusOK | ||
| 141 | headers := http.Header{} | ||
| 142 | for { | ||
| 143 | line, err := br.ReadString('\n') | ||
| 144 | if err != nil { | ||
| 145 | // Header parse failed; surface what git said. | ||
| 146 | cmd.Wait() | ||
| 147 | http.Error(w, "cgi header: "+err.Error()+": "+stderr.String(), 500) | ||
| 148 | return | ||
| 149 | } | ||
| 150 | line = strings.TrimRight(line, "\r\n") | ||
| 151 | if line == "" { | ||
| 152 | break | ||
| 153 | } | ||
| 154 | k, v, ok := strings.Cut(line, ":") | ||
| 155 | if !ok { | ||
| 156 | continue | ||
| 157 | } | ||
| 158 | k = strings.TrimSpace(k) | ||
| 159 | v = strings.TrimSpace(v) | ||
| 160 | if strings.EqualFold(k, "Status") { | ||
| 161 | f := strings.Fields(v) | ||
| 162 | if len(f) > 0 { | ||
| 163 | if code, err := strconv.Atoi(f[0]); err == nil { | ||
| 164 | status = code | ||
| 165 | } | ||
| 166 | } | ||
| 167 | continue | ||
| 168 | } | ||
| 169 | headers.Add(k, v) | ||
| 170 | } | ||
| 171 | |||
| 172 | h := w.Header() | ||
| 173 | for k, vs := range headers { | ||
| 174 | for _, v := range vs { | ||
| 175 | if strings.EqualFold(k, "Content-Length") { | ||
| 176 | continue // we stream; let net/http choose framing | ||
| 177 | } | ||
| 178 | h.Add(k, v) | ||
| 179 | } | ||
| 180 | } | ||
| 181 | w.WriteHeader(status) | ||
| 182 | flusher, _ := w.(http.Flusher) | ||
| 183 | buf := make([]byte, 256<<10) | ||
| 184 | for { | ||
| 185 | n, rerr := br.Read(buf) | ||
| 186 | if n > 0 { | ||
| 187 | if _, werr := w.Write(buf[:n]); werr != nil { | ||
| 188 | break | ||
| 189 | } | ||
| 190 | if flusher != nil { | ||
| 191 | flusher.Flush() | ||
| 192 | } | ||
| 193 | } | ||
| 194 | if rerr != nil { | ||
| 195 | break | ||
| 196 | } | ||
| 197 | } | ||
| 198 | cmd.Wait() | ||
| 199 | } | ||
| 200 | } | ||
| 201 | |||
| 202 | // procSampler records peak aggregate RSS of git child processes. | ||
| 203 | type procSampler struct { | ||
| 204 | stop chan struct{} | ||
| 205 | done chan struct{} | ||
| 206 | peakKB int64 | ||
| 207 | count int64 | ||
| 208 | } | ||
| 209 | |||
| 210 | func startSampler() *procSampler { | ||
| 211 | s := &procSampler{stop: make(chan struct{}), done: make(chan struct{})} | ||
| 212 | go func() { | ||
| 213 | defer close(s.done) | ||
| 214 | tick := time.NewTicker(100 * time.Millisecond) | ||
| 215 | defer tick.Stop() | ||
| 216 | for { | ||
| 217 | select { | ||
| 218 | case <-s.stop: | ||
| 219 | return | ||
| 220 | case <-tick.C: | ||
| 221 | rss, n := gitChildren() | ||
| 222 | if rss > s.peakKB { | ||
| 223 | s.peakKB = rss | ||
| 224 | } | ||
| 225 | if n > s.count { | ||
| 226 | s.count = n | ||
| 227 | } | ||
| 228 | } | ||
| 229 | } | ||
| 230 | }() | ||
| 231 | return s | ||
| 232 | } | ||
| 233 | |||
| 234 | func (s *procSampler) Stop() (int64, int64) { | ||
| 235 | close(s.stop) | ||
| 236 | <-s.done | ||
| 237 | return s.peakKB, s.count | ||
| 238 | } | ||
| 239 | |||
| 240 | // gitChildren sums RSS of any git process whose cmdline mentions our work. | ||
| 241 | func gitChildren() (int64, int64) { | ||
| 242 | entries, err := os.ReadDir("/proc") | ||
| 243 | if err != nil { | ||
| 244 | return 0, 0 | ||
| 245 | } | ||
| 246 | var total int64 | ||
| 247 | var n int64 | ||
| 248 | for _, e := range entries { | ||
| 249 | if !e.IsDir() { | ||
| 250 | continue | ||
| 251 | } | ||
| 252 | pid, err := strconv.Atoi(e.Name()) | ||
| 253 | if err != nil { | ||
| 254 | continue | ||
| 255 | } | ||
| 256 | cmdline, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid)) | ||
| 257 | if err != nil { | ||
| 258 | continue | ||
| 259 | } | ||
| 260 | cl := string(bytes.ReplaceAll(cmdline, []byte{0}, []byte(" "))) | ||
| 261 | matched := false | ||
| 262 | for _, name := range []string{"receive-pack", "upload-pack", "http-backend", "index-pack", "pack-objects", "unpack-objects"} { | ||
| 263 | if strings.Contains(cl, name) { | ||
| 264 | matched = true | ||
| 265 | break | ||
| 266 | } | ||
| 267 | } | ||
| 268 | if !matched { | ||
| 269 | continue | ||
| 270 | } | ||
| 271 | status, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid)) | ||
| 272 | if err != nil { | ||
| 273 | continue | ||
| 274 | } | ||
| 275 | for _, line := range strings.Split(string(status), "\n") { | ||
| 276 | if strings.HasPrefix(line, "VmRSS:") { | ||
| 277 | f := strings.Fields(line) | ||
| 278 | if len(f) >= 2 { | ||
| 279 | if kb, err := strconv.ParseInt(f[1], 10, 64); err == nil { | ||
| 280 | total += kb | ||
| 281 | n++ | ||
| 282 | } | ||
| 283 | } | ||
| 284 | } | ||
| 285 | } | ||
| 286 | } | ||
| 287 | return total, n | ||
| 288 | } | ||
| 289 | |||
| 290 | func run(dir string, env []string, stdin string, args ...string) (string, string, error) { | ||
| 291 | cmd := exec.Command(args[0], args[1:]...) | ||
| 292 | cmd.Dir = dir | ||
| 293 | cmd.Env = append(os.Environ(), env...) | ||
| 294 | if stdin != "" { | ||
| 295 | cmd.Stdin = strings.NewReader(stdin) | ||
| 296 | } | ||
| 297 | var out, errb bytes.Buffer | ||
| 298 | cmd.Stdout = &out | ||
| 299 | cmd.Stderr = &errb | ||
| 300 | err := cmd.Run() | ||
| 301 | return out.String(), errb.String(), err | ||
| 302 | } | ||
| 303 | |||
| 304 | func git(dir string, args ...string) (string, error) { | ||
| 305 | out, errb, err := run(dir, nil, "", append([]string{"git"}, args...)...) | ||
| 306 | if err != nil { | ||
| 307 | return out, fmt.Errorf("%s: %w", strings.TrimSpace(errb), err) | ||
| 308 | } | ||
| 309 | return out, nil | ||
| 310 | } | ||
| 311 | |||
| 312 | func newBareRepo(root, name string) (string, error) { | ||
| 313 | p := filepath.Join(root, name) | ||
| 314 | if _, errb, err := run("", nil, "", "git", "init", "--bare", "-q", p); err != nil { | ||
| 315 | return "", fmt.Errorf("%s: %w", errb, err) | ||
| 316 | } | ||
| 317 | // Allow pushes over HTTP. | ||
| 318 | if _, err := git(p, "config", "http.receivepack", "true"); err != nil { | ||
| 319 | return "", err | ||
| 320 | } | ||
| 321 | return p, nil | ||
| 322 | } | ||
| 323 | |||
| 324 | func installHook(repo, script string) error { | ||
| 325 | p := filepath.Join(repo, "hooks", "pre-receive") | ||
| 326 | if err := os.WriteFile(p, []byte("#!/bin/sh\n"+script+"\n"), 0o755); err != nil { | ||
| 327 | return err | ||
| 328 | } | ||
| 329 | return nil | ||
| 330 | } | ||
| 331 | |||
| 332 | // makeWorkingRepo builds a repo with `files` blobs of `sizeMB` each. Content is | ||
| 333 | // either incompressible random bytes (worst case for git) or repetitive text | ||
| 334 | // (typical source), made distinct per file either way so nothing dedupes. | ||
| 335 | func makeWorkingRepo(dir string, files, sizeMB int, compressible bool) error { | ||
| 336 | os.RemoveAll(dir) | ||
| 337 | if _, _, err := run("", nil, "", "git", "init", "-q", "-b", "main", dir); err != nil { | ||
| 338 | return err | ||
| 339 | } | ||
| 340 | base := make([]byte, sizeMB<<20) | ||
| 341 | if compressible { | ||
| 342 | pattern := []byte("the quick brown fox jumps over the lazy dog. ") | ||
| 343 | for i := range base { | ||
| 344 | base[i] = pattern[i%len(pattern)] | ||
| 345 | } | ||
| 346 | } else { | ||
| 347 | rand.Read(base) | ||
| 348 | } | ||
| 349 | for i := 0; i < files; i++ { | ||
| 350 | buf := make([]byte, len(base)) | ||
| 351 | copy(buf, base) | ||
| 352 | // Distinct per file, so git cannot collapse them to one blob. | ||
| 353 | header := []byte(fmt.Sprintf("// file %03d\n", i)) | ||
| 354 | copy(buf, header) | ||
| 355 | if !compressible { | ||
| 356 | mask := byte(i*7 + 1) | ||
| 357 | for j := len(header); j < len(buf); j += 4096 { | ||
| 358 | buf[j] ^= mask | ||
| 359 | } | ||
| 360 | } | ||
| 361 | p := filepath.Join(dir, fmt.Sprintf("blob-%03d.bin", i)) | ||
| 362 | if err := os.WriteFile(p, buf, 0o644); err != nil { | ||
| 363 | return err | ||
| 364 | } | ||
| 365 | } | ||
| 366 | if _, err := git(dir, "add", "-A"); err != nil { | ||
| 367 | return err | ||
| 368 | } | ||
| 369 | if _, _, err := run(dir, []string{ | ||
| 370 | "GIT_AUTHOR_NAME=spike", "GIT_AUTHOR_EMAIL=spike@example.com", | ||
| 371 | "GIT_COMMITTER_NAME=spike", "GIT_COMMITTER_EMAIL=spike@example.com", | ||
| 372 | }, "", "git", "commit", "-q", "-m", "big"); err != nil { | ||
| 373 | return err | ||
| 374 | } | ||
| 375 | return nil | ||
| 376 | } | ||
| 377 | |||
| 378 | func freePort() (string, error) { | ||
| 379 | l, err := net.Listen("tcp", "127.0.0.1:0") | ||
| 380 | if err != nil { | ||
| 381 | return "", err | ||
| 382 | } | ||
| 383 | defer l.Close() | ||
| 384 | return l.Addr().String(), nil | ||
| 385 | } | ||
| 386 | |||
| 387 | type scenarioResult map[string]any | ||
| 388 | |||
| 389 | func main() { | ||
| 390 | sizeMB := flag.Int("sizemb", 1024, "total push payload in MiB") | ||
| 391 | retries := flag.Int("runs", 1, "repeat count for the timing scenarios") | ||
| 392 | flag.Parse() | ||
| 393 | |||
| 394 | root, err := os.MkdirTemp("", "vole-spike-b-") | ||
| 395 | if err != nil { | ||
| 396 | panic(err) | ||
| 397 | } | ||
| 398 | defer os.RemoveAll(root) | ||
| 399 | |||
| 400 | for _, n := range []string{"big.git", "reject.git", "chunked.git", "abort.git", "comp.git"} { | ||
| 401 | if _, err := newBareRepo(root, n); err != nil { | ||
| 402 | panic(err) | ||
| 403 | } | ||
| 404 | } | ||
| 405 | rejRepo := filepath.Join(root, "reject.git") | ||
| 406 | if err := installHook(rejRepo, `echo "POLICY: push rejected by vole (spike)" >&2; exit 1`); err != nil { | ||
| 407 | panic(err) | ||
| 408 | } | ||
| 409 | |||
| 410 | addr, err := freePort() | ||
| 411 | if err != nil { | ||
| 412 | panic(err) | ||
| 413 | } | ||
| 414 | srv := &http.Server{Addr: addr, Handler: backendHandler(root), ReadHeaderTimeout: 10 * time.Second} | ||
| 415 | ln, err := net.Listen("tcp", addr) | ||
| 416 | if err != nil { | ||
| 417 | panic(err) | ||
| 418 | } | ||
| 419 | go srv.Serve(ln) | ||
| 420 | defer srv.Close() | ||
| 421 | |||
| 422 | work := filepath.Join(root, "work") | ||
| 423 | files := 64 | ||
| 424 | perFile := *sizeMB / files | ||
| 425 | if perFile < 1 { | ||
| 426 | perFile = 1 | ||
| 427 | } | ||
| 428 | genStart := time.Now() | ||
| 429 | if err := makeWorkingRepo(work, files, perFile, false); err != nil { | ||
| 430 | panic(err) | ||
| 431 | } | ||
| 432 | genDur := time.Since(genStart) | ||
| 433 | |||
| 434 | results := []scenarioResult{} | ||
| 435 | results = append(results, scenarioResult{ | ||
| 436 | "scenario": "fixture-generation", | ||
| 437 | "total_mib": files * perFile, | ||
| 438 | "files": files, | ||
| 439 | "elapsed_s": genDur.Seconds(), | ||
| 440 | "spool_requests": atomic.LoadInt64(&spooling.requests), | ||
| 441 | }) | ||
| 442 | |||
| 443 | // --- rejection path: does hook stderr reach the client? --- | ||
| 444 | rejURL := "http://" + addr + "/reject.git" | ||
| 445 | _, rejErr := git(work, "push", "--quiet", rejURL, "main:refs/heads/main") | ||
| 446 | headAfter, _ := git(rejRepo, "rev-parse", "--verify", "--quiet", "refs/heads/main") | ||
| 447 | results = append(results, scenarioResult{ | ||
| 448 | "scenario": "hook-rejection", | ||
| 449 | "client_error": errString(rejErr), | ||
| 450 | "message_reached": rejErr != nil && strings.Contains(errString(rejErr), "POLICY: push rejected by vole"), | ||
| 451 | "ref_moved": strings.TrimSpace(headAfter) != "", | ||
| 452 | "nonempty_stderr": rejErr != nil, | ||
| 453 | }) | ||
| 454 | |||
| 455 | // --- large push throughput --- | ||
| 456 | var totalDur time.Duration | ||
| 457 | var best float64 | ||
| 458 | sample := startSampler() | ||
| 459 | for i := 0; i < *retries; i++ { | ||
| 460 | if _, err := git(filepath.Join(root, "big.git"), "update-ref", "-d", "refs/heads/main"); err != nil { | ||
| 461 | _ = err | ||
| 462 | } | ||
| 463 | start := time.Now() | ||
| 464 | _, err := git(work, "-c", "http.postBuffer=1048576000", "push", "--quiet", "http://"+addr+"/big.git", "main:refs/heads/main") | ||
| 465 | d := time.Since(start) | ||
| 466 | if err != nil { | ||
| 467 | results = append(results, scenarioResult{"scenario": "large-push-error", "error": errString(err)}) | ||
| 468 | break | ||
| 469 | } | ||
| 470 | totalDur = d | ||
| 471 | mbps := float64(files*perFile) / d.Seconds() | ||
| 472 | if mbps > best { | ||
| 473 | best = mbps | ||
| 474 | } | ||
| 475 | } | ||
| 476 | peakKB, maxProcs := sample.Stop() | ||
| 477 | results = append(results, scenarioResult{ | ||
| 478 | "scenario": "large-push", | ||
| 479 | "total_mib": files * perFile, | ||
| 480 | "elapsed_s": totalDur.Seconds(), | ||
| 481 | "mib_per_sec": best, | ||
| 482 | "peak_git_child_rss_mb": float64(peakKB) / 1024, | ||
| 483 | "max_concurrent_git_children": maxProcs, | ||
| 484 | "client_post_buffer": "1 GiB (avoids chunked upload)", | ||
| 485 | }) | ||
| 486 | |||
| 487 | // --- compressible fixture, to separate zlib cost from transport cost --- | ||
| 488 | { | ||
| 489 | cw := filepath.Join(root, "work-comp") | ||
| 490 | if err := makeWorkingRepo(cw, files, perFile, true); err != nil { | ||
| 491 | panic(err) | ||
| 492 | } | ||
| 493 | start := time.Now() | ||
| 494 | _, err := git(cw, "-c", "http.postBuffer=1048576000", "push", "--quiet", | ||
| 495 | "http://"+addr+"/comp.git", "main:refs/heads/main") | ||
| 496 | d := time.Since(start) | ||
| 497 | du, _, _ := run("", nil, "", "du", "-sm", filepath.Join(root, "comp.git")) | ||
| 498 | results = append(results, scenarioResult{ | ||
| 499 | "scenario": "large-push-compressible", | ||
| 500 | "ok": err == nil, | ||
| 501 | "error": errString(err), | ||
| 502 | "uncompressed_mib": files * perFile, | ||
| 503 | "elapsed_s": d.Seconds(), | ||
| 504 | "uncompressed_mib_per_sec": float64(files*perFile) / d.Seconds(), | ||
| 505 | "server_repo_on_disk": strings.TrimSpace(du), | ||
| 506 | }) | ||
| 507 | } | ||
| 508 | |||
| 509 | // --- chunked upload: default 1 MiB postBuffer forces chunked transfer --- | ||
| 510 | // Must target a repo that does not already have these objects, otherwise | ||
| 511 | // git negotiates down to a ref update and nothing is transferred. | ||
| 512 | { | ||
| 513 | chunkedRepo := filepath.Join(root, "chunked.git") | ||
| 514 | beforeSpool := atomic.LoadInt64(&spooling.spooled) | ||
| 515 | beforeReq := atomic.LoadInt64(&spooling.chunked) | ||
| 516 | start := time.Now() | ||
| 517 | _, err := git(work, "-c", "http.postBuffer=1048576", "push", "--quiet", | ||
| 518 | "http://"+addr+"/chunked.git", "main:refs/heads/main") | ||
| 519 | d := time.Since(start) | ||
| 520 | spooled := atomic.LoadInt64(&spooling.spooled) - beforeSpool | ||
| 521 | chunkReqs := atomic.LoadInt64(&spooling.chunked) - beforeReq | ||
| 522 | head, _ := git(chunkedRepo, "rev-parse", "--verify", "--quiet", "refs/heads/main") | ||
| 523 | results = append(results, scenarioResult{ | ||
| 524 | "scenario": "chunked-upload-1mib-postbuffer", | ||
| 525 | "ok": err == nil, | ||
| 526 | "error": errString(err), | ||
| 527 | "elapsed_s": d.Seconds(), | ||
| 528 | "mib_per_sec": float64(files*perFile) / d.Seconds(), | ||
| 529 | "chunked_requests": chunkReqs, | ||
| 530 | "spooled_mib": float64(spooled) / (1 << 20), | ||
| 531 | "ref_landed": strings.TrimSpace(head) != "", | ||
| 532 | "temp_space_per_push_mib": float64(spooled) / (1 << 20), | ||
| 533 | }) | ||
| 534 | } | ||
| 535 | |||
| 536 | // --- client abort mid-transfer --- | ||
| 537 | { | ||
| 538 | abortRepo := filepath.Join(root, "abort.git") | ||
| 539 | cmd := exec.Command("git", "-c", "http.postBuffer=1048576", "push", "--quiet", | ||
| 540 | "http://"+addr+"/abort.git", "main:refs/heads/main") | ||
| 541 | cmd.Dir = work | ||
| 542 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | ||
| 543 | var errb bytes.Buffer | ||
| 544 | cmd.Stderr = &errb | ||
| 545 | if err := cmd.Start(); err != nil { | ||
| 546 | panic(err) | ||
| 547 | } | ||
| 548 | time.Sleep(1500 * time.Millisecond) | ||
| 549 | // Kill the whole process group: `git push` forks git-remote-https to do | ||
| 550 | // the upload, and killing only the parent leaves it writing to a | ||
| 551 | // half-dead pipe. | ||
| 552 | if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil { | ||
| 553 | _ = err | ||
| 554 | } | ||
| 555 | cmd.Wait() | ||
| 556 | time.Sleep(3 * time.Second) | ||
| 557 | _, orphans := gitChildren() | ||
| 558 | refAfter, _ := git(abortRepo, "rev-parse", "--verify", "--quiet", "refs/heads/main") | ||
| 559 | leaks := 0 | ||
| 560 | if ents, err := filepath.Glob(filepath.Join(os.TempDir(), "vole-spike-b-body-*")); err == nil { | ||
| 561 | leaks = len(ents) | ||
| 562 | for _, p := range ents { | ||
| 563 | os.Remove(p) | ||
| 564 | } | ||
| 565 | } | ||
| 566 | results = append(results, scenarioResult{ | ||
| 567 | "scenario": "client-abort-mid-push", | ||
| 568 | "orphaned_git_children": orphans, | ||
| 569 | "ref_created_by_abort": strings.TrimSpace(refAfter) != "", | ||
| 570 | "client_stderr_nonempty": errb.Len() > 0, | ||
| 571 | "leaked_spool_files": leaks, | ||
| 572 | }) | ||
| 573 | } | ||
| 574 | |||
| 575 | // --- clone throughput for comparison --- | ||
| 576 | { | ||
| 577 | start := time.Now() | ||
| 578 | dst := filepath.Join(root, "clone") | ||
| 579 | os.RemoveAll(dst) | ||
| 580 | _, err := git("", "clone", "--quiet", "http://"+addr+"/big.git", dst) | ||
| 581 | d := time.Since(start) | ||
| 582 | results = append(results, scenarioResult{ | ||
| 583 | "scenario": "clone", | ||
| 584 | "ok": err == nil, | ||
| 585 | "elapsed_s": d.Seconds(), | ||
| 586 | "mib_per_sec": float64(files*perFile) / d.Seconds(), | ||
| 587 | }) | ||
| 588 | } | ||
| 589 | |||
| 590 | out, _ := json.MarshalIndent(results, "", " ") | ||
| 591 | fmt.Println(string(out)) | ||
| 592 | os.WriteFile(filepath.Join(root, "results.json"), out, 0o644) | ||
| 593 | } | ||
| 594 | |||
| 595 | func errString(err error) string { | ||
| 596 | if err == nil { | ||
| 597 | return "" | ||
| 598 | } | ||
| 599 | return err.Error() | ||
| 600 | } | ||