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 | } | ||