anvilsign in

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
203203 "axum",
204204 "axum-extra",
205205 "base64",
206+ "lru",
206207 "maud",
207208 "serde",
208209 "similar",
⋯ 188 unchanged lines
397398
398399 [[package]]
399400 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"
409401 version = "0.10.0"
410402 source = "registry+https://github.com/rust-lang/crates.io-index"
411403 checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d"
412404 dependencies = [
413- "bit-vec 0.9.1",
405+ "bit-vec",
414406 ]
415407
416408 [[package]]
417409 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"
424410 version = "0.9.1"
425411 source = "registry+https://github.com/rust-lang/crates.io-index"
426412 checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
⋯ 689 unchanged lines
11161102 checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
11171103
11181104 [[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]]
11301105 name = "faster-hex"
11311106 version = "0.10.0"
11321107 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 1006 unchanged lines
21392114 source = "registry+https://github.com/rust-lang/crates.io-index"
21402115 checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
21412116 dependencies = [
2117+ "allocator-api2",
2118+ "equivalent",
21422119 "foldhash 0.1.5",
21432120 ]
21442121
⋯ 587 unchanged lines
27322709 checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
27332710
27342711 [[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]]
27352721 name = "matchers"
27362722 version = "0.2.0"
27372723 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 210 unchanged lines
29482934 checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
29492935
29502936 [[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]]
29512959 name = "p256"
29522960 version = "0.14.0-rc.10"
29532961 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 1151 unchanged lines
41054113 checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925"
41064114 dependencies = [
41074115 "bincode",
4108- "fancy-regex",
41094116 "flate2",
41104117 "fnv",
41114118 "once_cell",
4119+ "onig",
41124120 "plist",
41134121 "regex-syntax",
41144122 "serde",
⋯ 120 unchanged lines
42354243 checksum = "17bd9e42ba3c1aa195101d150ba281f3d426439e0d2b5991b4c7c6290204348f"
42364244 dependencies = [
42374245 "async-trait",
4238- "bit-set 0.10.0",
4246+ "bit-set",
42394247 "by_address",
42404248 "deadpool",
42414249 "hashbrown 0.17.1",
⋯ 17 unchanged lines
42594267 checksum = "7f06ecf2a9091a814d7cfa7edc39c24a791460431f55a0ee19c1b2cb4a15b98c"
42604268 dependencies = [
42614269 "async-trait",
4262- "bit-set 0.10.0",
4270+ "bit-set",
42634271 "hashbrown 0.17.1",
42644272 "heck",
42654273 "indexmap 2.14.0",
⋯ 975 unchanged lines
modifiedCargo.toml+5 −1
⋯ 28 unchanged lines
2929 clap = { version = "4", features = ["derive"] }
3030 futures-util = "0.3"
3131 hmac = "0.12"
32+lru = "0.12"
3233 gitserver-core = { path = "vendor/gitserver-core" }
3334 gix = { version = "0.84", default-features = false, features = ["sha1", "max-performance-safe", "blob-diff", "revision"] }
3435 gix-pack = { version = "0.71", features = ["sha1"] }
⋯ 8 unchanged lines
4344 serde_yaml = "0.9"
4445 sha2 = "0.10"
4546 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"] }
4751 tar = "0.4"
4852 thiserror = "2"
4953 time = { version = "0.3", features = ["serde", "formatting"] }
⋯ 16 unchanged lines
modifiedanvil.example.toml+4 −0
⋯ 6 unchanged lines
77 [http]
88 listen = "127.0.0.1:3000"
99 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
1014
1115 [ssh]
1216 enabled = false
⋯ 32 unchanged lines
modifiedcrates/anvil-core/src/config.rs+7 −0
⋯ 77 unchanged lines
7878 pub listen: String,
7979 /// Externally visible base URL, used when constructing clone URLs.
8080 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,
8187 }
8288
8389 #[derive(Debug, Clone, Serialize, Deserialize)]
⋯ 72 unchanged lines
156162 Self {
157163 listen: "127.0.0.1:3000".to_string(),
158164 base_url: "http://localhost:3000".to_string(),
165+ highlight_cache_mb: 16,
159166 }
160167 }
161168 }
⋯ 102 unchanged lines
modifiedcrates/anvil-git/src/browse.rs+12 −1
⋯ 415 unchanged lines
416416 /// Read the raw bytes of the blob at `path` for revision `rev`. Returns `None`
417417 /// if the path does not exist or is not a blob.
418418 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>)>> {
419429 let repo = gix::open(repo_path).map_err(read)?;
420430 let tree = repo
421431 .rev_parse_single(rev)
⋯ 7 unchanged lines
429439
430440 match tree.lookup_entry_by_path(path).map_err(read)? {
431441 Some(entry) if entry.mode().is_blob() => {
442+ let oid = entry.oid().to_string();
432443 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())))
434445 }
435446 _ => Ok(None),
436447 }
⋯ 51 unchanged lines
modifiedcrates/anvil-web/Cargo.toml+1 −0
⋯ 17 unchanged lines
1818 tracing.workspace = true
1919 serde.workspace = true
2020 base64.workspace = true
21+lru.workspace = true
2122 maud.workspace = true
2223 similar.workspace = true
2324 syntect.workspace = true
⋯ 1 unchanged line
modifiedcrates/anvil-web/src/ui.rs+55 −3
⋯ 3 unchanged lines
44
55 use std::collections::{BTreeMap, HashMap};
66 use std::path::PathBuf;
7-use std::sync::OnceLock;
7+use std::sync::{Arc, Mutex, OnceLock};
88
99 use anvil_core::{App, CiRun, Repository, SshKey, User, access, ci, repos, ssh_keys, users};
1010 use anvil_git::browse::{self, ChangeKind, FileChange};
⋯ 805 unchanged lines
816816 Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
817817 ) -> Result<Markup, Response> {
818818 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)
820820 .map_err(server_error)?
821821 .ok_or_else(|| not_found("file not found"))?;
822822
⋯ 1 unchanged line
824824 html! { p.muted { "Binary file (" (bytes.len()) " bytes)" } }
825825 } else {
826826 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);
828829 html! {
829830 table.code {
830831 @for (i, line) in lines.iter().enumerate() {
⋯ 332 unchanged lines
11631164 })
11641165 }
11651166
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+
11661218 /// Syntax-highlight `text` (chosen by file extension), returning per-line HTML.
11671219 /// Falls back to escaped plain text for large files or on any failure.
11681220 fn highlight(path: &str, text: &str) -> Vec<String> {
⋯ 54 unchanged lines