Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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 }