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.

Docs: rewrite the README and organize the guides

520470e19cd906bd6fff987e2d132d793ce56486 · Keenan Nemetz · 16d ago · parent c5902451

Docs: rewrite the README and organize the guides

Replace the long README with a landing page, split feature details into focused guides, and remove the roadmap and spike notes. Update comments that referenced them.

docs/spikes/ref-cas/main.go −585

@@ -1,585 +0,0 @@
1 // Spike A: measure the throughput of writing forge metadata as commits on
2 // sidecar refs, using git compare-and-swap ref updates.
3 //
4 // Every mode stores entity files in a tree, one commit per write (or per
5 // batch), and moves the ref with `git update-ref <ref> <new> <old>` so a
6 // lost race is detected rather than clobbering.
7 package main
8
9 import (
10 "bytes"
11 "encoding/json"
12 "flag"
13 "fmt"
14 "math/rand"
15 "os"
16 "os/exec"
17 "path/filepath"
18 "sort"
19 "strings"
20 "sync"
21 "sync/atomic"
22 "time"
23 )
24
25 const zeroSHA = "0000000000000000000000000000000000000000"
26
27 type repo struct {
28 dir string
29 env []string
30 }
31
32 func newRepo(dir string) (*repo, error) {
33 if err := os.RemoveAll(dir); err != nil {
34 return nil, err
35 }
36 cmd := exec.Command("git", "init", "--bare", "-q", dir)
37 if out, err := cmd.CombinedOutput(); err != nil {
38 return nil, fmt.Errorf("git init: %w: %s", err, out)
39 }
40 return &repo{dir: dir, env: append(os.Environ(),
41 "GIT_DIR="+dir,
42 "GIT_AUTHOR_NAME=spike", "GIT_AUTHOR_EMAIL=spike@example.com",
43 "GIT_COMMITTER_NAME=spike", "GIT_COMMITTER_EMAIL=spike@example.com",
44 )}, nil
45 }
46
47 func (r *repo) run(stdin []byte, args ...string) ([]byte, error) {
48 cmd := exec.Command("git", args...)
49 cmd.Env = r.env
50 if stdin != nil {
51 cmd.Stdin = bytes.NewReader(stdin)
52 }
53 var out, errb bytes.Buffer
54 cmd.Stdout = &out
55 cmd.Stderr = &errb
56 if err := cmd.Run(); err != nil {
57 return out.Bytes(), fmt.Errorf("%w: %s", err, strings.TrimSpace(errb.String()))
58 }
59 return out.Bytes(), nil
60 }
61
62 // revParse returns the commit the ref points at, or "" if it does not exist.
63 func (r *repo) revParse(ref string) (string, error) {
64 out, err := r.run(nil, "rev-parse", "--verify", "--quiet", ref+"^{commit}")
65 if err != nil {
66 return "", nil // missing ref is not an error for us
67 }
68 return strings.TrimSpace(string(out)), nil
69 }
70
71 type entry struct {
72 mode, typ, sha, name string
73 }
74
75 func (r *repo) treeEntries(commit string) ([]entry, error) {
76 if commit == "" {
77 return nil, nil
78 }
79 out, err := r.run(nil, "ls-tree", "-z", commit)
80 if err != nil {
81 return nil, err
82 }
83 var es []entry
84 for _, rec := range bytes.Split(out, []byte{0}) {
85 if len(rec) == 0 {
86 continue
87 }
88 // <mode> SP <type> SP <sha> TAB <name>
89 tab := bytes.IndexByte(rec, '\t')
90 if tab < 0 {
91 return nil, fmt.Errorf("bad ls-tree record %q", rec)
92 }
93 f := strings.Fields(string(rec[:tab]))
94 if len(f) != 3 {
95 return nil, fmt.Errorf("bad ls-tree fields %q", rec)
96 }
97 es = append(es, entry{f[0], f[1], f[2], string(rec[tab+1:])})
98 }
99 return es, nil
100 }
101
102 func (r *repo) hashBlob(payload []byte) (string, error) {
103 out, err := r.run(payload, "hash-object", "-w", "--stdin")
104 if err != nil {
105 return "", err
106 }
107 return strings.TrimSpace(string(out)), nil
108 }
109
110 func (r *repo) hashBlobs(paths []string) ([]string, error) {
111 in := []byte(strings.Join(paths, "\n") + "\n")
112 out, err := r.run(in, "hash-object", "-w", "--stdin-paths")
113 if err != nil {
114 return nil, err
115 }
116 return strings.Fields(string(out)), nil
117 }
118
119 func (r *repo) mkTree(es []entry) (string, error) {
120 sort.Slice(es, func(i, j int) bool { return es[i].name < es[j].name })
121 var buf bytes.Buffer
122 for _, e := range es {
123 fmt.Fprintf(&buf, "%s %s %s\t%s\x00", e.mode, e.typ, e.sha, e.name)
124 }
125 out, err := r.run(buf.Bytes(), "mktree", "-z")
126 if err != nil {
127 return "", err
128 }
129 return strings.TrimSpace(string(out)), nil
130 }
131
132 func (r *repo) commitTree(tree, parent, msg string) (string, error) {
133 args := []string{"commit-tree", tree}
134 if parent != "" {
135 args = append(args, "-p", parent)
136 }
137 out, err := r.run([]byte(msg), args...)
138 if err != nil {
139 return "", err
140 }
141 return strings.TrimSpace(string(out)), nil
142 }
143
144 // casRef moves ref from old to new. Returns false if another writer won.
145 func (r *repo) casRef(ref, new, old string) (bool, error) {
146 if old == "" {
147 old = zeroSHA
148 }
149 _, err := r.run(nil, "update-ref", ref, new, old)
150 if err == nil {
151 return true, nil
152 }
153 if strings.Contains(err.Error(), "cannot lock ref") ||
154 strings.Contains(err.Error(), "is at") ||
155 strings.Contains(err.Error(), "unable to resolve") {
156 return false, nil
157 }
158 return false, err
159 }
160
161 type stats struct {
162 mode string
163 commits int64
164 entities int64
165 elapsed time.Duration
166 retries int64
167 failures int64
168 latencyNanos []int64
169 }
170
171 func (s *stats) report() map[string]any {
172 sort.Slice(s.latencyNanos, func(i, j int) bool { return s.latencyNanos[i] < s.latencyNanos[j] })
173 p := func(q float64) float64 {
174 if len(s.latencyNanos) == 0 {
175 return 0
176 }
177 i := int(q * float64(len(s.latencyNanos)-1))
178 return float64(s.latencyNanos[i]) / 1e6
179 }
180 return map[string]any{
181 "mode": s.mode,
182 "entities": s.entities,
183 "commits": s.commits,
184 "elapsed_s": s.elapsed.Seconds(),
185 "entities_per_sec": float64(s.entities) / s.elapsed.Seconds(),
186 "commits_per_sec": float64(s.commits) / s.elapsed.Seconds(),
187 "cas_retries": s.retries,
188 "failures": s.failures,
189 "retries_per_entity": float64(s.retries) / float64(max64(s.entities, 1)),
190 "write_p50_ms": p(0.50),
191 "write_p95_ms": p(0.95),
192 "write_p99_ms": p(0.99),
193 }
194 }
195
196 func max64(a, b int64) int64 {
197 if a > b {
198 return a
199 }
200 return b
201 }
202
203 type entity struct {
204 id string
205 payload []byte
206 shard int
207 }
208
209 func refFor(shards int, shard int) string {
210 if shards <= 1 {
211 return "refs/vole/issues/0"
212 }
213 return fmt.Sprintf("refs/vole/issues/%03d", shard)
214 }
215
216 func shardOf(id string, shards int) int {
217 if shards <= 1 {
218 return 0
219 }
220 // FNV-1a, matching the sharding scheme the roadmap proposes.
221 var h uint32 = 2166136261
222 for i := 0; i < len(id); i++ {
223 h ^= uint32(id[i])
224 h *= 16777619
225 }
226 return int(h % uint32(shards))
227 }
228
229 // writeOne is the honest per-entity path: build (or update) the shard tree,
230 // commit, and CAS the ref. Retries on a lost race.
231 func (r *repo) writeOne(e entity, shards int, maxRetries int, retries *int64, failures *int64) (time.Duration, bool) {
232 start := time.Now()
233 ref := refFor(shards, e.shard)
234 for attempt := 0; attempt <= maxRetries; attempt++ {
235 if attempt > 0 {
236 atomic.AddInt64(retries, 1)
237 time.Sleep(time.Duration(rand.Intn(200)+50) * time.Microsecond)
238 }
239 old, err := r.revParse(ref)
240 if err != nil {
241 atomic.AddInt64(failures, 1)
242 return time.Since(start), false
243 }
244 es, err := r.treeEntries(old)
245 if err != nil {
246 atomic.AddInt64(failures, 1)
247 return time.Since(start), false
248 }
249 blob, err := r.hashBlob(e.payload)
250 if err != nil {
251 atomic.AddInt64(failures, 1)
252 return time.Since(start), false
253 }
254 es = upsert(es, entry{"100644", "blob", blob, e.id + ".json"})
255 tree, err := r.mkTree(es)
256 if err != nil {
257 atomic.AddInt64(failures, 1)
258 return time.Since(start), false
259 }
260 commit, err := r.commitTree(tree, old, "issue "+e.id)
261 if err != nil {
262 atomic.AddInt64(failures, 1)
263 return time.Since(start), false
264 }
265 ok, err := r.casRef(ref, commit, old)
266 if err != nil {
267 atomic.AddInt64(failures, 1)
268 return time.Since(start), false
269 }
270 if ok {
271 return time.Since(start), true
272 }
273 }
274 atomic.AddInt64(failures, 1)
275 return time.Since(start), false
276 }
277
278 func upsert(es []entry, add entry) []entry {
279 for i := range es {
280 if es[i].name == add.name {
281 es[i] = add
282 return es
283 }
284 }
285 return append(es, add)
286 }
287
288 // runNaive: N writers, no batching, one commit per entity.
289 func runNaive(r *repo, name string, shards, writers, n, maxRetries int, tmp string) *stats {
290 st := &stats{mode: name}
291 jobs := make(chan entity, 256)
292 var wg sync.WaitGroup
293 var mu sync.Mutex
294 start := time.Now()
295 for w := 0; w < writers; w++ {
296 wg.Add(1)
297 go func() {
298 defer wg.Done()
299 for e := range jobs {
300 d, ok := r.writeOne(e, shards, maxRetries, &st.retries, &st.failures)
301 mu.Lock()
302 st.latencyNanos = append(st.latencyNanos, d.Nanoseconds())
303 mu.Unlock()
304 if ok {
305 atomic.AddInt64(&st.entities, 1)
306 atomic.AddInt64(&st.commits, 1)
307 }
308 }
309 }()
310 }
311 for i := 0; i < n; i++ {
312 id := fmt.Sprintf("%06d", i)
313 jobs <- entity{id: id, payload: payloadFor(id), shard: shardOf(id, shards)}
314 }
315 close(jobs)
316 wg.Wait()
317 st.elapsed = time.Since(start)
318 return st
319 }
320
321 // batcher commits a group of entities per shard in one commit.
322 func runBatched(r *repo, name string, shards, batchers, n, batchSize int, maxRetries int) *stats {
323 st := &stats{mode: name}
324 jobs := make(chan entity, 1024)
325 var wg sync.WaitGroup
326 var mu sync.Mutex
327 start := time.Now()
328
329 // A batcher must only ever consume work it owns. Routing happens once, in
330 // the dispatcher, so no batcher can swallow an entity belonging to a peer
331 // that has already exited.
332 owned := make([]chan entity, batchers)
333 for i := range owned {
334 owned[i] = make(chan entity, 1024)
335 }
336
337 for b := 0; b < batchers; b++ {
338 wg.Add(1)
339 go func(b int) {
340 defer wg.Done()
341 pending := map[int][]entity{}
342 flush := func(shard int, final bool) {
343 batch := pending[shard]
344 delete(pending, shard)
345 if len(batch) == 0 {
346 return
347 }
348 t0 := time.Now()
349 if ok := commitBatch(r, shards, shard, batch, maxRetries, &st.retries); ok {
350 d := time.Since(t0)
351 atomic.AddInt64(&st.commits, 1)
352 atomic.AddInt64(&st.entities, int64(len(batch)))
353 mu.Lock()
354 st.latencyNanos = append(st.latencyNanos, d.Nanoseconds())
355 mu.Unlock()
356 } else {
357 atomic.AddInt64(&st.failures, int64(len(batch)))
358 }
359 }
360 tick := time.NewTicker(100 * time.Millisecond)
361 defer tick.Stop()
362 for {
363 select {
364 case e, ok := <-owned[b]:
365 if !ok {
366 for s := range pending {
367 flush(s, true)
368 }
369 return
370 }
371 pending[e.shard] = append(pending[e.shard], e)
372 if len(pending[e.shard]) >= batchSize {
373 flush(e.shard, false)
374 }
375 case <-tick.C:
376 for s := range pending {
377 flush(s, false)
378 }
379 }
380 }
381 }(b)
382 }
383
384 go func() {
385 for i := 0; i < n; i++ {
386 id := fmt.Sprintf("%06d", i)
387 shard := shardOf(id, shards)
388 jobs <- entity{id: id, payload: payloadFor(id), shard: shard}
389 }
390 close(jobs)
391 }()
392
393 // Dispatcher: single reader, deterministic ownership.
394 dispatched := int64(0)
395 for e := range jobs {
396 owner := e.shard % batchers
397 if shards <= 1 {
398 owner = 0
399 }
400 owned[owner] <- e
401 dispatched++
402 }
403 for i := range owned {
404 close(owned[i])
405 }
406 wg.Wait()
407 st.elapsed = time.Since(start)
408 if dispatched != int64(n) {
409 st.failures += int64(n) - dispatched
410 }
411 return st
412 }
413
414 func ownsShard(shard, b, batchers, shards int) bool {
415 if shards <= 1 {
416 return b == 0
417 }
418 return shard%batchers == b
419 }
420
421 // commitBatch writes K entities into one tree update + one commit + one CAS.
422 func commitBatch(r *repo, shards, shard int, batch []entity, maxRetries int, retries *int64) bool {
423 ref := refFor(shards, shard)
424 tmp := filepath.Join(os.TempDir(), fmt.Sprintf("vole-spike-a-%d-%d", os.Getpid(), rand.Int63()))
425 if err := os.MkdirAll(tmp, 0o755); err != nil {
426 return false
427 }
428 defer os.RemoveAll(tmp)
429
430 paths := make([]string, len(batch))
431 for i, e := range batch {
432 p := filepath.Join(tmp, e.id+".json")
433 if err := os.WriteFile(p, e.payload, 0o644); err != nil {
434 return false
435 }
436 paths[i] = p
437 }
438 blobs, err := r.hashBlobs(paths)
439 if err != nil || len(blobs) != len(batch) {
440 return false
441 }
442 for attempt := 0; attempt <= maxRetries; attempt++ {
443 if attempt > 0 {
444 atomic.AddInt64(retries, 1)
445 time.Sleep(time.Duration(rand.Intn(200)+50) * time.Microsecond)
446 }
447 old, _ := r.revParse(ref)
448 es, err := r.treeEntries(old)
449 if err != nil {
450 return false
451 }
452 for i, e := range batch {
453 es = upsert(es, entry{"100644", "blob", blobs[i], e.id + ".json"})
454 }
455 tree, err := r.mkTree(es)
456 if err != nil {
457 return false
458 }
459 commit, err := r.commitTree(tree, old, fmt.Sprintf("batch of %d", len(batch)))
460 if err != nil {
461 return false
462 }
463 ok, err := r.casRef(ref, commit, old)
464 if err != nil {
465 return false
466 }
467 if ok {
468 return true
469 }
470 }
471 return false
472 }
473
474 // countEntities walks every metadata ref and counts tree entries, so the
475 // harness can prove no write was silently dropped.
476 func countEntities(r *repo, shards int) (int64, error) {
477 var refs []string
478 if shards <= 1 {
479 refs = []string{refFor(1, 0)}
480 } else {
481 for i := 0; i < shards; i++ {
482 refs = append(refs, refFor(shards, i))
483 }
484 }
485 var total int64
486 for _, ref := range refs {
487 c, err := r.revParse(ref)
488 if err != nil || c == "" {
489 continue
490 }
491 es, err := r.treeEntries(c)
492 if err != nil {
493 return 0, err
494 }
495 total += int64(len(es))
496 }
497 return total, nil
498 }
499
500 func payloadFor(id string) []byte {
501 b, _ := json.Marshal(map[string]any{
502 "id": id,
503 "title": "issue " + id,
504 "body": strings.Repeat("x", 400),
505 "state": "open",
506 "created": "2026-10-22T00:00:00Z",
507 })
508 return b
509 }
510
511 // spawnCost measures the fixed cost of invoking git, which bounds every
512 // per-commit design.
513 func spawnCost(r *repo, n int) float64 {
514 start := time.Now()
515 for i := 0; i < n; i++ {
516 if _, err := r.run(nil, "rev-parse", "--verify", "--quiet", "HEAD^{commit}"); err != nil {
517 // HEAD does not exist in a fresh bare repo; rev-parse still exits fast.
518 _ = err
519 }
520 }
521 return time.Since(start).Seconds() / float64(n) * 1000 // ms
522 }
523
524 func main() {
525 n := flag.Int("n", 500, "entities per mode")
526 writers := flag.Int("writers", 32, "concurrent writers")
527 maxRetries := flag.Int("retries", 200, "max CAS retries per write")
528 batchSize := flag.Int("batch", 64, "entities per commit in batched modes")
529 modes := flag.String("modes", "shared-naive,sharded-naive,shared-batched,sharded-batched", "comma separated")
530 flag.Parse()
531
532 root, err := os.MkdirTemp("", "vole-spike-a-")
533 if err != nil {
534 panic(err)
535 }
536 defer os.RemoveAll(root)
537
538 results := []map[string]any{}
539
540 r1, err := newRepo(filepath.Join(root, "repo"))
541 if err != nil {
542 panic(err)
543 }
544 results = append(results, map[string]any{
545 "mode": "git-spawn-cost",
546 "ms_per_spawn": spawnCost(r1, 200),
547 "note": "fixed cost of one git invocation; every per-commit design pays 5-6 of these",
548 })
549
550 for _, m := range strings.Split(*modes, ",") {
551 r, err := newRepo(filepath.Join(root, strings.ReplaceAll(m, "/", "-")))
552 if err != nil {
553 panic(err)
554 }
555 var st *stats
556 shardCount := 1
557 switch m {
558 case "shared-naive":
559 st = runNaive(r, m, 1, *writers, *n, *maxRetries, root)
560 case "sharded-naive":
561 shardCount = 256
562 st = runNaive(r, m, 256, *writers, *n, *maxRetries, root)
563 case "shared-batched":
564 st = runBatched(r, m, 1, 1, *n, *batchSize, *maxRetries)
565 case "sharded-batched":
566 shardCount = 256
567 st = runBatched(r, m, 256, 8, *n, *batchSize, *maxRetries)
568 default:
569 panic("unknown mode " + m)
570 }
571 rep := st.report()
572 got, err := countEntities(r, shardCount)
573 if err != nil {
574 panic(err)
575 }
576 rep["shards"] = shardCount
577 rep["entities_in_refs"] = got
578 rep["lost_writes"] = int64(*n) - got
579 results = append(results, rep)
580 }
581
582 out, _ := json.MarshalIndent(results, "", " ")
583 fmt.Println(string(out))
584 os.WriteFile(filepath.Join(root, "results.json"), out, 0o644)
585 }