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 6 slice 1: the tree is the site

d3296cda0c18f78097e37373822292707113d245 · Keenan Nemetz · 18d ago · parent 64ac5071

Phase 6 slice 1: the tree is the site

A repository's `pages` branch is served at <repo>.<pages_domain>, straight out
of git. No export step, no build to drift: the push moves the branch and the
next request reads the new tree.

- internal/pages: a pool of `git cat-file --batch` processes per active
  repository (closed after two idle minutes), an LRU keyed by (commit, path),
  and tree parsing in git's binary format with the object-id width detected
  rather than assumed
- ETag is the blob sha, Last-Modified the commit time, Content-Type from the
  extension or a sniff; If-None-Match and If-Modified-Since answer 304
- directories serve index.html and redirect to add the trailing slash; path
  resolution walks trees, so a traversal-shaped request is a missing page
- repository and ACL are resolved once per one-second memo window together with
  the branch tip, which is what makes the read path fast: doing it per request
  spawned three git processes and cost 3.8 ms, against 6.7 µs warm
- sites follow clone visibility, so a private repository answers 404; the repo
  page links to the site when pages_domain is configured

Measured: a site pushed over SSH is live in 1.4 s (the phase criterion is 5 s);
warm requests 6.7 µs, cold 144 µs. Custom domains and their TLS certificates are
the remaining Phase 6 work.

internal/pages/cache.go +144

@@ -0,0 +1,144 @@
1 package pages
2
3 // The blob cache. The Phase 0 spike made this a requirement rather than a
4 // nicety: cold git reads are ~1.2 ms p50, and the same reads served from memory
5 // are 0.075 ms — the difference between a docs site that feels like a file
6 // server and one that feels like a database.
7
8 import (
9 "container/list"
10 "strings"
11 "sync"
12 )
13
14 type cacheEntry struct {
15 key string
16 commit string
17 sha string
18 ctype string
19 body []byte
20 modUnix int64
21 }
22
23 type lruCache struct {
24 mu sync.Mutex
25 max int64
26 used int64
27 ll *list.List // front = most recently used
28 items map[string]*list.Element
29 // maxEntry keeps one large file from evicting the whole site. Bodies bigger
30 // than this are served from git every time.
31 maxEntry int64
32 // lookups caches path resolution: (commit, path) → tree entry. Without it a
33 // warm request still walks the commit and its trees on every hit, which is
34 // most of the cold cost the cache exists to remove.
35 lookups map[string]treeEntry
36 }
37
38 func newLRU(max int64) *lruCache {
39 if max <= 0 {
40 max = 32 << 20
41 }
42 return &lruCache{
43 max: max,
44 maxEntry: min(max/8, 1<<20),
45 ll: list.New(),
46 items: map[string]*list.Element{},
47 lookups: map[string]treeEntry{},
48 }
49 }
50
51 // lookupGet returns a cached resolution.
52 func (c *lruCache) lookup(key string) (treeEntry, bool) {
53 c.mu.Lock()
54 defer c.mu.Unlock()
55 e, ok := c.lookups[key]
56 return e, ok
57 }
58
59 func (c *lruCache) setLookup(key string, e treeEntry) {
60 c.mu.Lock()
61 defer c.mu.Unlock()
62 // Path resolutions are tiny; a plain cap keeps the map from growing with
63 // history. Clearing is cheap and only costs a walk on the next request.
64 if len(c.lookups) > 8192 {
65 c.lookups = map[string]treeEntry{}
66 }
67 c.lookups[key] = e
68 }
69
70 func (c *lruCache) get(key string) (*cacheEntry, bool) {
71 c.mu.Lock()
72 defer c.mu.Unlock()
73 el, ok := c.items[key]
74 if !ok {
75 return nil, false
76 }
77 c.ll.MoveToFront(el)
78 return el.Value.(*cacheEntry), true
79 }
80
81 func (c *lruCache) put(e *cacheEntry) {
82 size := int64(len(e.body))
83 if size > c.maxEntry || size > c.max {
84 return // not cacheable; served straight from git
85 }
86 c.mu.Lock()
87 defer c.mu.Unlock()
88 if old, ok := c.items[e.key]; ok {
89 c.used -= int64(len(old.Value.(*cacheEntry).body))
90 c.ll.Remove(old)
91 delete(c.items, e.key)
92 }
93 el := c.ll.PushFront(e)
94 c.items[e.key] = el
95 c.used += size
96 for c.used > c.max {
97 back := c.ll.Back()
98 if back == nil {
99 break
100 }
101 victim := back.Value.(*cacheEntry)
102 c.ll.Remove(back)
103 delete(c.items, victim.key)
104 c.used -= int64(len(victim.body))
105 }
106 }
107
108 // dropCommit forgets every path of one commit. A push moves the ref, so the old
109 // commit's pages are unreachable and their memory may as well be freed now
110 // rather than when the LRU happens to reach them.
111 func (c *lruCache) dropCommit(commit string) {
112 if commit == "" {
113 return
114 }
115 c.mu.Lock()
116 defer c.mu.Unlock()
117 for key, el := range c.items {
118 e := el.Value.(*cacheEntry)
119 if e.commit != commit {
120 continue
121 }
122 c.ll.Remove(el)
123 delete(c.items, key)
124 c.used -= int64(len(e.body))
125 }
126 for key := range c.lookups {
127 if strings.HasPrefix(key, commit+"\x00") {
128 delete(c.lookups, key)
129 }
130 }
131 }
132
133 func (c *lruCache) stats() (entries int, bytes int64) {
134 c.mu.Lock()
135 defer c.mu.Unlock()
136 return len(c.items), c.used
137 }
138
139 func min(a, b int64) int64 {
140 if a < b {
141 return a
142 }
143 return b
144 }