collin/anvil · 870d1591
perf: fast blob views — onig regex backend + budgeted highlight cache
Collin Richards · 2026-06-09 23:33 UTC · 870d159196f2e6d2d76d8c4e04965256472cd297 · parent c4800e5f · browse files
modifiedCargo.lock+38 −30
| ⋯ 202 unchanged lines | |||
| 203 | 203 | "axum", | |
| 204 | 204 | "axum-extra", | |
| 205 | 205 | "base64", | |
| 206 | + | "lru", | |
| 206 | 207 | "maud", | |
| 207 | 208 | "serde", | |
| 208 | 209 | "similar", | |
| ⋯ 188 unchanged lines | |||
| 397 | 398 | ||
| 398 | 399 | [[package]] | |
| 399 | 400 | name = "bit-set" | |
| 400 | - | version = "0.8.0" | |
| 401 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 402 | - | checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" | |
| 403 | - | dependencies = [ | |
| 404 | - | "bit-vec 0.8.0", | |
| 405 | - | ] | |
| 406 | - | ||
| 407 | - | [[package]] | |
| 408 | - | name = "bit-set" | |
| 409 | 401 | version = "0.10.0" | |
| 410 | 402 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 411 | 403 | checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" | |
| 412 | 404 | dependencies = [ | |
| 413 | - | "bit-vec 0.9.1", | |
| 405 | + | "bit-vec", | |
| 414 | 406 | ] | |
| 415 | 407 | ||
| 416 | 408 | [[package]] | |
| 417 | 409 | name = "bit-vec" | |
| 418 | - | version = "0.8.0" | |
| 419 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 420 | - | checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" | |
| 421 | - | ||
| 422 | - | [[package]] | |
| 423 | - | name = "bit-vec" | |
| 424 | 410 | version = "0.9.1" | |
| 425 | 411 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 426 | 412 | checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" | |
| ⋯ 689 unchanged lines | |||
| 1116 | 1102 | checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" | |
| 1117 | 1103 | ||
| 1118 | 1104 | [[package]] | |
| 1119 | - | name = "fancy-regex" | |
| 1120 | - | version = "0.16.2" | |
| 1121 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1122 | - | checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" | |
| 1123 | - | dependencies = [ | |
| 1124 | - | "bit-set 0.8.0", | |
| 1125 | - | "regex-automata", | |
| 1126 | - | "regex-syntax", | |
| 1127 | - | ] | |
| 1128 | - | ||
| 1129 | - | [[package]] | |
| 1130 | 1105 | name = "faster-hex" | |
| 1131 | 1106 | version = "0.10.0" | |
| 1132 | 1107 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ 1006 unchanged lines | |||
| 2139 | 2114 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2140 | 2115 | checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" | |
| 2141 | 2116 | dependencies = [ | |
| 2117 | + | "allocator-api2", | |
| 2118 | + | "equivalent", | |
| 2142 | 2119 | "foldhash 0.1.5", | |
| 2143 | 2120 | ] | |
| 2144 | 2121 | ||
| ⋯ 587 unchanged lines | |||
| 2732 | 2709 | checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" | |
| 2733 | 2710 | ||
| 2734 | 2711 | [[package]] | |
| 2712 | + | name = "lru" | |
| 2713 | + | version = "0.12.5" | |
| 2714 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2715 | + | checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" | |
| 2716 | + | dependencies = [ | |
| 2717 | + | "hashbrown 0.15.5", | |
| 2718 | + | ] | |
| 2719 | + | ||
| 2720 | + | [[package]] | |
| 2735 | 2721 | name = "matchers" | |
| 2736 | 2722 | version = "0.2.0" | |
| 2737 | 2723 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ 210 unchanged lines | |||
| 2948 | 2934 | checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" | |
| 2949 | 2935 | ||
| 2950 | 2936 | [[package]] | |
| 2937 | + | name = "onig" | |
| 2938 | + | version = "6.5.3" | |
| 2939 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2940 | + | checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" | |
| 2941 | + | dependencies = [ | |
| 2942 | + | "bitflags", | |
| 2943 | + | "libc", | |
| 2944 | + | "once_cell", | |
| 2945 | + | "onig_sys", | |
| 2946 | + | ] | |
| 2947 | + | ||
| 2948 | + | [[package]] | |
| 2949 | + | name = "onig_sys" | |
| 2950 | + | version = "69.9.3" | |
| 2951 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2952 | + | checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" | |
| 2953 | + | dependencies = [ | |
| 2954 | + | "cc", | |
| 2955 | + | "pkg-config", | |
| 2956 | + | ] | |
| 2957 | + | ||
| 2958 | + | [[package]] | |
| 2951 | 2959 | name = "p256" | |
| 2952 | 2960 | version = "0.14.0-rc.10" | |
| 2953 | 2961 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ 1151 unchanged lines | |||
| 4105 | 4113 | checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" | |
| 4106 | 4114 | dependencies = [ | |
| 4107 | 4115 | "bincode", | |
| 4108 | - | "fancy-regex", | |
| 4109 | 4116 | "flate2", | |
| 4110 | 4117 | "fnv", | |
| 4111 | 4118 | "once_cell", | |
| 4119 | + | "onig", | |
| 4112 | 4120 | "plist", | |
| 4113 | 4121 | "regex-syntax", | |
| 4114 | 4122 | "serde", | |
| ⋯ 120 unchanged lines | |||
| 4235 | 4243 | checksum = "17bd9e42ba3c1aa195101d150ba281f3d426439e0d2b5991b4c7c6290204348f" | |
| 4236 | 4244 | dependencies = [ | |
| 4237 | 4245 | "async-trait", | |
| 4238 | - | "bit-set 0.10.0", | |
| 4246 | + | "bit-set", | |
| 4239 | 4247 | "by_address", | |
| 4240 | 4248 | "deadpool", | |
| 4241 | 4249 | "hashbrown 0.17.1", | |
| ⋯ 17 unchanged lines | |||
| 4259 | 4267 | checksum = "7f06ecf2a9091a814d7cfa7edc39c24a791460431f55a0ee19c1b2cb4a15b98c" | |
| 4260 | 4268 | dependencies = [ | |
| 4261 | 4269 | "async-trait", | |
| 4262 | - | "bit-set 0.10.0", | |
| 4270 | + | "bit-set", | |
| 4263 | 4271 | "hashbrown 0.17.1", | |
| 4264 | 4272 | "heck", | |
| 4265 | 4273 | "indexmap 2.14.0", | |
| ⋯ 975 unchanged lines | |||
modifiedCargo.toml+5 −1
| ⋯ 28 unchanged lines | |||
| 29 | 29 | clap = { version = "4", features = ["derive"] } | |
| 30 | 30 | futures-util = "0.3" | |
| 31 | 31 | hmac = "0.12" | |
| 32 | + | lru = "0.12" | |
| 32 | 33 | gitserver-core = { path = "vendor/gitserver-core" } | |
| 33 | 34 | gix = { version = "0.84", default-features = false, features = ["sha1", "max-performance-safe", "blob-diff", "revision"] } | |
| 34 | 35 | gix-pack = { version = "0.71", features = ["sha1"] } | |
| ⋯ 8 unchanged lines | |||
| 43 | 44 | serde_yaml = "0.9" | |
| 44 | 45 | sha2 = "0.10" | |
| 45 | 46 | similar = "2" | |
| 46 | - | syntect = { version = "5", default-features = false, features = ["default-fancy"] } | |
| 47 | + | # `default-onig` (C Oniguruma regex engine) over the pure-Rust `default-fancy`: | |
| 48 | + | # several times faster on large files, and zig's cc cross-compiles the C just | |
| 49 | + | # fine for the static musl build (verified via deploy/build.sh's toolchain). | |
| 50 | + | syntect = { version = "5", default-features = false, features = ["default-onig"] } | |
| 47 | 51 | tar = "0.4" | |
| 48 | 52 | thiserror = "2" | |
| 49 | 53 | time = { version = "0.3", features = ["serde", "formatting"] } | |
| ⋯ 16 unchanged lines | |||
modifiedanvil.example.toml+4 −0
| ⋯ 6 unchanged lines | |||
| 7 | 7 | [http] | |
| 8 | 8 | listen = "127.0.0.1:3000" | |
| 9 | 9 | base_url = "http://localhost:3000" | |
| 10 | + | # Memory budget (MiB) for the cache of syntax-highlighted file views. | |
| 11 | + | # Highlighting large files is CPU-heavy, so repeat views are served from this | |
| 12 | + | # cache. Set to 0 to disable it entirely on RAM-constrained hosts. | |
| 13 | + | highlight_cache_mb = 16 | |
| 10 | 14 | ||
| 11 | 15 | [ssh] | |
| 12 | 16 | enabled = false | |
| ⋯ 32 unchanged lines | |||
modifiedcrates/anvil-core/src/config.rs+7 −0
| ⋯ 77 unchanged lines | |||
| 78 | 78 | pub listen: String, | |
| 79 | 79 | /// Externally visible base URL, used when constructing clone URLs. | |
| 80 | 80 | pub base_url: String, | |
| 81 | + | /// Memory budget, in MiB, for the cache of syntax-highlighted file views | |
| 82 | + | /// (rendered HTML keyed by blob oid). Highlighting large files is the most | |
| 83 | + | /// CPU-expensive page render, so repeat views are served from this cache. | |
| 84 | + | /// `0` disables it — lowest memory, every view re-highlights. Defaults | |
| 85 | + | /// to 16. | |
| 86 | + | pub highlight_cache_mb: usize, | |
| 81 | 87 | } | |
| 82 | 88 | ||
| 83 | 89 | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| ⋯ 72 unchanged lines | |||
| 156 | 162 | Self { | |
| 157 | 163 | listen: "127.0.0.1:3000".to_string(), | |
| 158 | 164 | base_url: "http://localhost:3000".to_string(), | |
| 165 | + | highlight_cache_mb: 16, | |
| 159 | 166 | } | |
| 160 | 167 | } | |
| 161 | 168 | } | |
| ⋯ 102 unchanged lines | |||
modifiedcrates/anvil-git/src/browse.rs+12 −1
| ⋯ 415 unchanged lines | |||
| 416 | 416 | /// Read the raw bytes of the blob at `path` for revision `rev`. Returns `None` | |
| 417 | 417 | /// if the path does not exist or is not a blob. | |
| 418 | 418 | pub fn read_blob(repo_path: &Path, rev: &str, path: &str) -> Result<Option<Vec<u8>>> { | |
| 419 | + | Ok(read_blob_with_id(repo_path, rev, path)?.map(|(_, bytes)| bytes)) | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | /// Like [`read_blob`], but also returns the blob's object id — a stable | |
| 423 | + | /// identity for the content, usable as a render-cache key. | |
| 424 | + | pub fn read_blob_with_id( | |
| 425 | + | repo_path: &Path, | |
| 426 | + | rev: &str, | |
| 427 | + | path: &str, | |
| 428 | + | ) -> Result<Option<(String, Vec<u8>)>> { | |
| 419 | 429 | let repo = gix::open(repo_path).map_err(read)?; | |
| 420 | 430 | let tree = repo | |
| 421 | 431 | .rev_parse_single(rev) | |
| ⋯ 7 unchanged lines | |||
| 429 | 439 | ||
| 430 | 440 | match tree.lookup_entry_by_path(path).map_err(read)? { | |
| 431 | 441 | Some(entry) if entry.mode().is_blob() => { | |
| 442 | + | let oid = entry.oid().to_string(); | |
| 432 | 443 | let object = repo.find_object(entry.oid().to_owned()).map_err(read)?; | |
| 433 | - | Ok(Some(object.data.clone())) | |
| 444 | + | Ok(Some((oid, object.data.clone()))) | |
| 434 | 445 | } | |
| 435 | 446 | _ => Ok(None), | |
| 436 | 447 | } | |
| ⋯ 51 unchanged lines | |||
modifiedcrates/anvil-web/Cargo.toml+1 −0
| ⋯ 17 unchanged lines | |||
| 18 | 18 | tracing.workspace = true | |
| 19 | 19 | serde.workspace = true | |
| 20 | 20 | base64.workspace = true | |
| 21 | + | lru.workspace = true | |
| 21 | 22 | maud.workspace = true | |
| 22 | 23 | similar.workspace = true | |
| 23 | 24 | syntect.workspace = true | |
| ⋯ 1 unchanged line | |||
modifiedcrates/anvil-web/src/ui.rs+55 −3
| ⋯ 3 unchanged lines | |||
| 4 | 4 | ||
| 5 | 5 | use std::collections::{BTreeMap, HashMap}; | |
| 6 | 6 | use std::path::PathBuf; | |
| 7 | - | use std::sync::OnceLock; | |
| 7 | + | use std::sync::{Arc, Mutex, OnceLock}; | |
| 8 | 8 | ||
| 9 | 9 | use anvil_core::{App, CiRun, Repository, SshKey, User, access, ci, repos, ssh_keys, users}; | |
| 10 | 10 | use anvil_git::browse::{self, ChangeKind, FileChange}; | |
| ⋯ 805 unchanged lines | |||
| 816 | 816 | Path((owner, repo, rev, path)): Path<(String, String, String, String)>, | |
| 817 | 817 | ) -> Result<Markup, Response> { | |
| 818 | 818 | let (repo_path, _) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?; | |
| 819 | - | let bytes = browse::read_blob(&repo_path, &rev, &path) | |
| 819 | + | let (oid, bytes) = browse::read_blob_with_id(&repo_path, &rev, &path) | |
| 820 | 820 | .map_err(server_error)? | |
| 821 | 821 | .ok_or_else(|| not_found("file not found"))?; | |
| 822 | 822 | ||
| ⋯ 1 unchanged line | |||
| 824 | 824 | html! { p.muted { "Binary file (" (bytes.len()) " bytes)" } } | |
| 825 | 825 | } else { | |
| 826 | 826 | let text = String::from_utf8_lossy(&bytes); | |
| 827 | - | let lines = highlight(&path, &text); | |
| 827 | + | let budget = app.config.http.highlight_cache_mb.saturating_mul(1 << 20); | |
| 828 | + | let lines = cached_highlight(budget, &oid, &path, &text); | |
| 828 | 829 | html! { | |
| 829 | 830 | table.code { | |
| 830 | 831 | @for (i, line) in lines.iter().enumerate() { | |
| ⋯ 332 unchanged lines | |||
| 1163 | 1164 | }) | |
| 1164 | 1165 | } | |
| 1165 | 1166 | ||
| 1167 | + | /// [`highlight`] through a byte-budgeted LRU keyed by blob oid + extension: a | |
| 1168 | + | /// blob's rendered HTML is immutable for its object id (the extension is part | |
| 1169 | + | /// of the key because it picks the syntax), so each file is highlighted once | |
| 1170 | + | /// rather than once per request — highlighting large files is by far the most | |
| 1171 | + | /// expensive thing a page view can do. The budget is | |
| 1172 | + | /// `http.highlight_cache_mb`; `0` bypasses the cache entirely (for | |
| 1173 | + | /// RAM-constrained hosts). Concurrent misses may both compute and the last | |
| 1174 | + | /// insert wins; that's benign. | |
| 1175 | + | fn cached_highlight(budget_bytes: usize, oid: &str, path: &str, text: &str) -> Arc<Vec<String>> { | |
| 1176 | + | if budget_bytes == 0 { | |
| 1177 | + | return Arc::new(highlight(path, text)); | |
| 1178 | + | } | |
| 1179 | + | struct Cache { | |
| 1180 | + | lru: lru::LruCache<String, Arc<Vec<String>>>, | |
| 1181 | + | bytes: usize, | |
| 1182 | + | } | |
| 1183 | + | fn cost(key: &str, lines: &[String]) -> usize { | |
| 1184 | + | key.len() + lines.iter().map(String::len).sum::<usize>() | |
| 1185 | + | } | |
| 1186 | + | static CACHE: OnceLock<Mutex<Cache>> = OnceLock::new(); | |
| 1187 | + | let cache = CACHE.get_or_init(|| { | |
| 1188 | + | Mutex::new(Cache { | |
| 1189 | + | lru: lru::LruCache::unbounded(), | |
| 1190 | + | bytes: 0, | |
| 1191 | + | }) | |
| 1192 | + | }); | |
| 1193 | + | ||
| 1194 | + | let ext = std::path::Path::new(path) | |
| 1195 | + | .extension() | |
| 1196 | + | .and_then(|e| e.to_str()) | |
| 1197 | + | .unwrap_or(""); | |
| 1198 | + | let key = format!("{oid}\x00{ext}"); | |
| 1199 | + | if let Some(hit) = cache.lock().expect("cache lock").lru.get(&key) { | |
| 1200 | + | return hit.clone(); | |
| 1201 | + | } | |
| 1202 | + | ||
| 1203 | + | let lines = Arc::new(highlight(path, text)); | |
| 1204 | + | let mut c = cache.lock().expect("cache lock"); | |
| 1205 | + | c.bytes += cost(&key, &lines); | |
| 1206 | + | if let Some(old) = c.lru.put(key.clone(), Arc::clone(&lines)) { | |
| 1207 | + | c.bytes -= cost(&key, &old); // concurrent miss inserted it first | |
| 1208 | + | } | |
| 1209 | + | // Evict oldest entries until we're back under budget. An entry larger than | |
| 1210 | + | // the whole budget evicts itself — memory stays bounded, it just never caches. | |
| 1211 | + | while c.bytes > budget_bytes { | |
| 1212 | + | let Some((k, v)) = c.lru.pop_lru() else { break }; | |
| 1213 | + | c.bytes -= cost(&k, &v); | |
| 1214 | + | } | |
| 1215 | + | lines | |
| 1216 | + | } | |
| 1217 | + | ||
| 1166 | 1218 | /// Syntax-highlight `text` (chosen by file extension), returning per-line HTML. | |
| 1167 | 1219 | /// Falls back to escaped plain text for large files or on any failure. | |
| 1168 | 1220 | fn highlight(path: &str, text: &str) -> Vec<String> { | |
| ⋯ 54 unchanged lines | |||