Keep bookmark briefs across restarts, and log slow requests 39442b000e6b
The overview and bookmark list summarised every bookmark from its whole state, and kept the summaries only in memory: after a restart the first visit to doomsdayone/metalgear (162 bookmarks) took ~31 s. A state's brief (change count, latest change, conflicted) is a pure function of its heads, so it is now kept on disk as well, worked out four at a time when a page needs many, and worked out as soon as a push moves a bookmark. The state cache's count limit goes from 64, always full, to 256; the byte budget still bounds it. Requests of two seconds or more are logged with their method, path and status. closes #145
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-12 18:57 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
39442b000e6b8c1027d74c2f01940a76691a4e65ef17def5d269e8cb6a195bfd- Depends on
- 2e640ffa6c93 3f4b0ea6eabf 42fa95540468 736ae87ef318 93be9ce4653e 9ff27da517fe d23464c0ac60 f5c443378333
- Depended on by
- b9b74d997cec bff9106edcba 33bd0bd45f8a f677d6270298
- In bookmarks
- api-unknown-fields cancel-landed-runs close-proposal-140 diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 25 atoms, 0 tree operations, 6 files
Browse the tree at this change
modified crates/hub/src/lib.rs +17 −1
| … | 63 unchanged lines |
||
| 64 | 64 | ||
| 65 | 65 | /// The full router: pages, API, protocol and static assets. |
|
| 66 | 66 | pub fn router(app: Shared) -> axum::Router { |
|
| 67 | - | web::router(app) |
|
| 67 | + | web::router(app).layer(axum::middleware::from_fn(slow_requests)) |
|
| 68 | 68 | } |
|
| 69 | 69 | ||
| 70 | + | /// Requests slower than this are logged, with their path (#145). |
|
| 71 | + | const SLOW: std::time::Duration = std::time::Duration::from_secs(2); |
|
| 72 | + | ||
| 73 | + | /// Log a slow request's method, path and time, so a slow page can be |
|
| 74 | + | /// found afterwards. Never the query, which may hold a token. |
|
| 75 | + | async fn slow_requests(req: axum::extract::Request, next: axum::middleware::Next) -> axum::response::Response { |
|
| 76 | + | let (method, path) = (req.method().clone(), req.uri().path().to_string()); |
|
| 77 | + | let start = std::time::Instant::now(); |
|
| 78 | + | let res = next.run(req).await; |
|
| 79 | + | let took = start.elapsed(); |
|
| 80 | + | if took >= SLOW { |
|
| 81 | + | tracing::warn!("slow request: {method} {path} took {:.1} s ({})", took.as_secs_f64(), res.status().as_u16()); |
|
| 82 | + | } |
|
| 83 | + | res |
|
| 84 | + | } |
|
| 85 | + | ||
| 70 | 86 | /// Serve on the configured addresses until the process is told to stop. |
|
| 71 | 87 | pub async fn serve(app: Shared) -> anyhow::Result<()> { |
|
| 72 | 88 | let router = router(app.clone()); |
|
| … | 26 unchanged lines |
modified crates/hub/src/protocol.rs +9 −0
| … | 217 unchanged lines |
||
| 218 | 218 | crate::hooks::emit(&app, &row, "push", &actor, serde_json::json!({ "updates": accepted })).await; |
|
| 219 | 219 | } |
|
| 220 | 220 | let actor = pusher.as_ref().map(|(_, n)| n.clone()).unwrap_or_default(); |
|
| 221 | + | // The moved bookmarks' briefs, worked out now so no page |
|
| 222 | + | // waits for them (#145). |
|
| 223 | + | if let Some(name) = crate::repos::RepoName::new(&row.owner, &row.name) { |
|
| 224 | + | let all: Vec<Vec<oxy_core::hash::ChangeHash>> = moved.iter().map(|(_, _, h)| h.clone()).collect(); |
|
| 225 | + | let warm = app.clone(); |
|
| 226 | + | tokio::spawn(async move { |
|
| 227 | + | warm.repos.read(&name, move |r, cache| Ok(cache.briefs(r, &all).map(|_| ())?)).await.ok(); |
|
| 228 | + | }); |
|
| 229 | + | } |
|
| 221 | 230 | for (bookmark, state, heads) in &moved { |
|
| 222 | 231 | crate::flow::on_push(&app, &row, bookmark, state, heads, &actor).await; |
|
| 223 | 232 | } |
|
| … | 30 unchanged lines |
modified crates/hub/src/repos/cache.rs +155 −4
| … | 6 unchanged lines |
||
| 7 | 7 | use oxy_core::repo::Repo; |
|
| 8 | 8 | use oxy_core::state::{Snapshot, State}; |
|
| 9 | 9 | use std::collections::HashMap; |
|
| 10 | + | use std::path::{Path, PathBuf}; |
|
| 10 | 11 | use std::sync::{Arc, Mutex}; |
|
| 11 | 12 | ||
| 12 | 13 | /// A state and its materialised snapshot. |
|
| … | 52 unchanged lines |
||
| 65 | 66 | pub conflicted: bool, |
|
| 66 | 67 | } |
|
| 67 | 68 | ||
| 69 | + | impl Summary { |
|
| 70 | + | pub fn brief(&self) -> Brief { |
|
| 71 | + | Brief { changes: self.changes.len(), latest_ms: self.latest_ms, conflicted: self.conflicted } |
|
| 72 | + | } |
|
| 73 | + | } |
|
| 74 | + | ||
| 75 | + | /// What a bookmark list and the overview show of a state (#145): how many |
|
| 76 | + | /// changes, the latest, and whether it is conflicted. A pure function of |
|
| 77 | + | /// the heads, so it is kept on disk as well and a restart costs nothing: |
|
| 78 | + | /// metalgear's 162 bookmarks took ~31 s to summarise again after one. |
|
| 79 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
|
| 80 | + | pub struct Brief { |
|
| 81 | + | pub changes: usize, |
|
| 82 | + | pub latest_ms: u64, |
|
| 83 | + | pub conflicted: bool, |
|
| 84 | + | } |
|
| 85 | + | ||
| 68 | 86 | /// Summaries kept; each is the state's change set, a few hundred kilobytes |
|
| 69 | 87 | /// for a long history. |
|
| 70 | 88 | const SUMMARIES: usize = 2048; |
|
| 89 | + | /// Most summaries computed at once when a page needs many. |
|
| 90 | + | const PARALLEL: usize = 4; |
|
| 71 | 91 | ||
| 72 | 92 | /// Summaries by heads, each with when it was last used, and the clock. |
|
| 73 | 93 | type Summaries = (HashMap<Key, (Arc<Summary>, u64)>, u64); |
|
| … | 5 unchanged lines |
||
| 79 | 99 | budget: usize, |
|
| 80 | 100 | inner: Mutex<(HashMap<Key, Entry>, u64)>, |
|
| 81 | 101 | summaries: Mutex<Summaries>, |
|
| 102 | + | /// Where briefs are kept on disk, when anywhere. |
|
| 103 | + | briefs: Option<PathBuf>, |
|
| 82 | 104 | } |
|
| 83 | 105 | ||
| 84 | 106 | impl StateCache { |
|
| … | 2 unchanged lines |
||
| 87 | 109 | } |
|
| 88 | 110 | ||
| 89 | 111 | pub fn with_budget(capacity: usize, budget: usize) -> StateCache { |
|
| 90 | - | StateCache { capacity: capacity.max(1), budget, inner: Mutex::new((HashMap::new(), 0)), summaries: Mutex::new((HashMap::new(), 0)) } |
|
| 112 | + | StateCache { |
|
| 113 | + | capacity: capacity.max(1), |
|
| 114 | + | budget, |
|
| 115 | + | inner: Mutex::new((HashMap::new(), 0)), |
|
| 116 | + | summaries: Mutex::new((HashMap::new(), 0)), |
|
| 117 | + | briefs: None, |
|
| 118 | + | } |
|
| 119 | + | } |
|
| 120 | + | ||
| 121 | + | /// Keep briefs on disk under `dir`, one small file per state. |
|
| 122 | + | pub fn with_briefs(mut self, dir: PathBuf) -> StateCache { |
|
| 123 | + | self.briefs = Some(dir); |
|
| 124 | + | self |
|
| 125 | + | } |
|
| 126 | + | ||
| 127 | + | fn brief_path(&self, key: &Key) -> Option<PathBuf> { |
|
| 128 | + | let dir = self.briefs.as_ref()?; |
|
| 129 | + | let mut h = blake3::Hasher::new(); |
|
| 130 | + | for c in &key.heads { |
|
| 131 | + | h.update(&c.0); |
|
| 132 | + | } |
|
| 133 | + | Some(dir.join(hex::encode(key.repo)).join(h.finalize().to_hex().as_str())) |
|
| 134 | + | } |
|
| 135 | + | ||
| 136 | + | fn known_brief(&self, key: &Key) -> Option<Brief> { |
|
| 137 | + | if let Some((s, _)) = self.summaries.lock().unwrap().0.get(key) { |
|
| 138 | + | return Some(s.brief()); |
|
| 139 | + | } |
|
| 140 | + | read_brief(&self.brief_path(key)?) |
|
| 141 | + | } |
|
| 142 | + | ||
| 143 | + | /// The brief of the state with these heads: from memory, from disk, or |
|
| 144 | + | /// worked out and kept. |
|
| 145 | + | pub fn brief(&self, repo: &Repo, heads: &[ChangeHash]) -> Result<Brief, oxy_core::repo::RepoError> { |
|
| 146 | + | Ok(self.briefs(repo, &[heads.to_vec()])?[0]) |
|
| 147 | + | } |
|
| 148 | + | ||
| 149 | + | /// The briefs of many states, those not yet known worked out a few at |
|
| 150 | + | /// a time in parallel. |
|
| 151 | + | pub fn briefs(&self, repo: &Repo, all: &[Vec<ChangeHash>]) -> Result<Vec<Brief>, oxy_core::repo::RepoError> { |
|
| 152 | + | let id = repo.id()?; |
|
| 153 | + | let mut out: Vec<Option<Brief>> = all.iter().map(|h| self.known_brief(&Key { repo: id, heads: sorted(h) })).collect(); |
|
| 154 | + | let missing: Vec<usize> = (0..all.len()).filter(|&i| out[i].is_none()).collect(); |
|
| 155 | + | if missing.is_empty() { |
|
| 156 | + | return Ok(out.into_iter().map(|b| b.expect("known")).collect()); |
|
| 157 | + | } |
|
| 158 | + | let next = Mutex::new(missing.iter()); |
|
| 159 | + | let found: Mutex<Vec<(usize, Result<Brief, oxy_core::repo::RepoError>)>> = Mutex::new(Vec::new()); |
|
| 160 | + | let threads = PARALLEL.min(missing.len()).min(std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1)); |
|
| 161 | + | // A `Repo` stays on its thread, so each worker opens its own. |
|
| 162 | + | let root = repo.root().to_path_buf(); |
|
| 163 | + | std::thread::scope(|s| { |
|
| 164 | + | for _ in 0..threads { |
|
| 165 | + | s.spawn(|| { |
|
| 166 | + | let mine = Repo::open(&root); |
|
| 167 | + | loop { |
|
| 168 | + | let Some(&i) = next.lock().unwrap().next() else { break }; |
|
| 169 | + | let b = match &mine { |
|
| 170 | + | Ok(r) => self.summary(r, &all[i]).map(|s| s.brief()), |
|
| 171 | + | Err(e) => Err(oxy_core::repo::RepoError::Msg(format!("open {}: {e}", root.display()))), |
|
| 172 | + | }; |
|
| 173 | + | found.lock().unwrap().push((i, b)); |
|
| 174 | + | } |
|
| 175 | + | }); |
|
| 176 | + | } |
|
| 177 | + | }); |
|
| 178 | + | for (i, b) in found.into_inner().unwrap() { |
|
| 179 | + | out[i] = Some(b?); |
|
| 180 | + | } |
|
| 181 | + | Ok(out.into_iter().map(|b| b.expect("worked out")).collect()) |
|
| 91 | 182 | } |
|
| 92 | 183 | ||
| 93 | 184 | /// The summary of the state with these heads: from the state cache when |
|
| 94 | 185 | /// it holds the state, else loaded and dropped, the state cache left as |
|
| 95 | 186 | /// it was. |
|
| 96 | 187 | pub fn summary(&self, repo: &Repo, heads: &[ChangeHash]) -> Result<Arc<Summary>, oxy_core::repo::RepoError> { |
|
| 97 | - | let mut sorted = heads.to_vec(); |
|
| 98 | - | sorted.sort(); |
|
| 99 | - | sorted.dedup(); |
|
| 188 | + | let sorted = sorted(heads); |
|
| 100 | 189 | let key = Key { repo: repo.id()?, heads: sorted.clone() }; |
|
| 101 | 190 | { |
|
| 102 | 191 | let mut guard = self.summaries.lock().unwrap(); |
|
| … | 13 unchanged lines |
||
| 116 | 205 | summarise(&state, &snapshot) |
|
| 117 | 206 | } |
|
| 118 | 207 | }); |
|
| 208 | + | if let Some(path) = self.brief_path(&key) { |
|
| 209 | + | write_brief(&path, summary.brief()); |
|
| 210 | + | } |
|
| 119 | 211 | let mut guard = self.summaries.lock().unwrap(); |
|
| 120 | 212 | let (map, tick) = &mut *guard; |
|
| 121 | 213 | if map.len() >= SUMMARIES { |
|
| … | 48 unchanged lines |
||
| 170 | 262 | ||
| 171 | 263 | pub fn is_empty(&self) -> bool { |
|
| 172 | 264 | self.len() == 0 |
|
| 265 | + | } |
|
| 266 | + | } |
|
| 267 | + | ||
| 268 | + | fn sorted(heads: &[ChangeHash]) -> Vec<ChangeHash> { |
|
| 269 | + | let mut v = heads.to_vec(); |
|
| 270 | + | v.sort(); |
|
| 271 | + | v.dedup(); |
|
| 272 | + | v |
|
| 273 | + | } |
|
| 274 | + | ||
| 275 | + | /// A brief on disk: `changes latest_ms conflicted`. |
|
| 276 | + | fn read_brief(path: &Path) -> Option<Brief> { |
|
| 277 | + | let text = std::fs::read_to_string(path).ok()?; |
|
| 278 | + | let mut f = text.split_whitespace(); |
|
| 279 | + | Some(Brief { changes: f.next()?.parse().ok()?, latest_ms: f.next()?.parse().ok()?, conflicted: f.next()? == "1" }) |
|
| 280 | + | } |
|
| 281 | + | ||
| 282 | + | /// Kept when it can be; a brief that is not kept is only worked out again. |
|
| 283 | + | fn write_brief(path: &Path, b: Brief) { |
|
| 284 | + | let Some(dir) = path.parent() else { return }; |
|
| 285 | + | if std::fs::create_dir_all(dir).is_err() { |
|
| 286 | + | return; |
|
| 287 | + | } |
|
| 288 | + | let tmp = path.with_extension(format!("tmp{}", std::process::id())); |
|
| 289 | + | if std::fs::write(&tmp, format!("{} {} {}", b.changes, b.latest_ms, u8::from(b.conflicted))).is_ok() { |
|
| 290 | + | std::fs::rename(&tmp, path).ok(); |
|
| 173 | 291 | } |
|
| 174 | 292 | } |
|
| 175 | 293 | ||
| … | 57 unchanged lines |
||
| 233 | 351 | roomy.load(&repo, &[one]).unwrap(); |
|
| 234 | 352 | roomy.load(&repo, &[two]).unwrap(); |
|
| 235 | 353 | assert_eq!(roomy.len(), 2); |
|
| 354 | + | } |
|
| 355 | + | ||
| 356 | + | #[test] |
|
| 357 | + | fn briefs_are_worked_out_in_parallel_and_survive_a_restart() { |
|
| 358 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 359 | + | let repo = Repo::init(dir.path()).unwrap(); |
|
| 360 | + | std::fs::write(dir.path().join("a.txt"), "a\n").unwrap(); |
|
| 361 | + | let one = repo.commit("one", &[]).unwrap(); |
|
| 362 | + | std::fs::write(dir.path().join("b.txt"), "b\n").unwrap(); |
|
| 363 | + | let two = repo.commit("two", &[]).unwrap(); |
|
| 364 | + | let kept = tempfile::tempdir().unwrap(); |
|
| 365 | + | let cache = StateCache::new(8).with_briefs(kept.path().to_path_buf()); |
|
| 366 | + | let got = cache.briefs(&repo, &[vec![one], vec![one, two], vec![]]).unwrap(); |
|
| 367 | + | assert_eq!(got.iter().map(|b| b.changes).collect::<Vec<_>>(), vec![1, 2, 0]); |
|
| 368 | + | assert!(got[1].latest_ms >= got[0].latest_ms && !got[1].conflicted); |
|
| 369 | + | // A new cache (a restarted hub) reads them back without loading a |
|
| 370 | + | // state: a repository that no longer has the changes still answers. |
|
| 371 | + | let gone = tempfile::tempdir().unwrap(); |
|
| 372 | + | let empty = Repo::init(gone.path()).unwrap(); |
|
| 373 | + | let empty_id = empty.id().unwrap(); |
|
| 374 | + | for f in std::fs::read_dir(kept.path()).unwrap() { |
|
| 375 | + | let f = f.unwrap().path(); |
|
| 376 | + | std::fs::rename(&f, kept.path().join(hex::encode(empty_id))).unwrap(); |
|
| 377 | + | } |
|
| 378 | + | let restarted = StateCache::new(8).with_briefs(kept.path().to_path_buf()); |
|
| 379 | + | assert_eq!(restarted.brief(&empty, &[two, one]).unwrap(), got[1], "from disk, whatever the heads' order"); |
|
| 380 | + | assert!(StateCache::new(8).brief(&empty, &[one, two]).is_err(), "without the disk it would have to load"); |
|
| 381 | + | // Unknown heads are an error, not a guess. |
|
| 382 | + | assert!(cache.briefs(&repo, &[vec![one], vec![oxy_core::hash::ObjectRef([9; 32])]]).is_err()); |
|
| 383 | + | // A bare repository, as the hub keeps them, works across threads too. |
|
| 384 | + | let bare_dir = tempfile::tempdir().unwrap(); |
|
| 385 | + | let bare = Repo::init_bare(bare_dir.path()).unwrap(); |
|
| 386 | + | assert_eq!(cache.briefs(&bare, &[vec![], vec![]]).unwrap().len(), 2); |
|
| 236 | 387 | } |
|
| 237 | 388 | } |
modified crates/hub/src/repos/mod.rs +6 −1
| … | 45 unchanged lines |
||
| 46 | 46 | ||
| 47 | 47 | impl Repos { |
|
| 48 | 48 | pub fn new(root: PathBuf) -> Repos { |
|
| 49 | - | Repos { root, locks: Mutex::new(HashMap::new()), cache: Arc::new(StateCache::new(64)), searches: search::SearchCache::default() } |
|
| 49 | + | // States are bounded by their estimated bytes (`cache::budget`); the |
|
| 50 | + | // count only stops many tiny states piling up. At 64 it was always |
|
| 51 | + | // full and evicting long before the bytes were (#145). |
|
| 52 | + | let briefs = root.parent().unwrap_or(&root).join("cache").join("summaries").join(root.file_name().unwrap_or_default()); |
|
| 53 | + | let cache = Arc::new(StateCache::new(256).with_briefs(briefs)); |
|
| 54 | + | Repos { root, locks: Mutex::new(HashMap::new()), cache, searches: search::SearchCache::default() } |
|
| 50 | 55 | } |
|
| 51 | 56 | ||
| 52 | 57 | pub fn cache(&self) -> &Arc<StateCache> { |
|
| … | 174 unchanged lines |
modified crates/hub/src/repos/view.rs +8 −5
| … | 744 unchanged lines |
||
| 745 | 745 | } |
|
| 746 | 746 | ||
| 747 | 747 | pub fn bookmarks(repo: &Repo, cache: &StateCache, default: &str) -> Result<Vec<BookmarkView>> { |
|
| 748 | + | let list = repo.bookmarks()?; |
|
| 749 | + | let briefs = cache.briefs(repo, &list.iter().map(|b| b.heads.clone()).collect::<Vec<_>>())?; |
|
| 748 | 750 | let mut out = Vec::new(); |
|
| 749 | - | for b in repo.bookmarks()? { |
|
| 750 | - | let s = cache.summary(repo, &b.heads)?; |
|
| 751 | + | for (b, s) in list.iter().zip(briefs) { |
|
| 751 | 752 | out.push(BookmarkView { |
|
| 752 | 753 | name: b.name.clone(), |
|
| 753 | 754 | heads: b.heads.iter().map(|h| h.to_hex()).collect(), |
|
| 754 | - | changes: s.changes.len(), |
|
| 755 | + | changes: s.changes, |
|
| 755 | 756 | default: b.name == default, |
|
| 756 | 757 | conflicted: s.conflicted, |
|
| 757 | 758 | latest_ms: s.latest_ms, |
|
| … | 107 unchanged lines |
||
| 865 | 866 | } |
|
| 866 | 867 | ||
| 867 | 868 | pub fn conflicts(repo: &Repo, cache: &StateCache) -> Result<Vec<ConflictView>> { |
|
| 869 | + | let list = repo.bookmarks()?; |
|
| 870 | + | let briefs = cache.briefs(repo, &list.iter().map(|b| b.heads.clone()).collect::<Vec<_>>())?; |
|
| 868 | 871 | let mut out = Vec::new(); |
|
| 869 | - | for b in repo.bookmarks()? { |
|
| 872 | + | for (b, brief) in list.iter().zip(briefs) { |
|
| 870 | 873 | // Only a conflicted state is loaded whole. |
|
| 871 | - | if !cache.summary(repo, &b.heads)?.conflicted { |
|
| 874 | + | if !brief.conflicted { |
|
| 872 | 875 | continue; |
|
| 873 | 876 | } |
|
| 874 | 877 | let loaded = cache.load(repo, &b.heads)?; |
|
| … | 244 unchanged lines |
modified docs/spec.md +1 −1
| … | 448 unchanged lines |
||
| 449 | 449 | ||
| 450 | 450 | ### 16.3 Caches |
|
| 451 | 451 | ||
| 452 | - | Keyed by state hash, so they never go stale: loaded `State` objects (LRU by memory), tree listings, rendered file views, blame, history graph layouts per bookmark head set, projected states per proposal, archives, code search results. Keyed by content: rendered Markdown (BLAKE3 of the text and its two link bases, 32 MiB) and syntax highlighted files (BLAKE3 of the file name, which picks the syntax, and the text, 64 MiB), each an LRU bounded by the bytes it holds, rendering outside the lock; an entry larger than a quarter of its budget is not kept. Keyed by repository: bookmark and tag lists, invalidated by the push path. `ETag` and `Cache-Control: private, max-age=0, must-revalidate` on pages; `immutable` on content-addressed responses (raw files by hash, archives by state). |
|
| 452 | + | Keyed by state hash, so they never go stale: loaded `State` objects (LRU by memory, up to 256 and `OXYHUB_STATE_CACHE_MB`, default 3 GiB), a state's brief (its change count, latest change and whether it is conflicted, #145: kept in memory and on disk under `<data>/cache/summaries/`, worked out four at a time when a page needs many and as soon as a push moves a bookmark, so a restart never makes a bookmark list or an overview load every bookmark's state again), tree listings, rendered file views, blame, history graph layouts per bookmark head set, projected states per proposal, archives, code search results. Keyed by content: rendered Markdown (BLAKE3 of the text and its two link bases, 32 MiB) and syntax highlighted files (BLAKE3 of the file name, which picks the syntax, and the text, 64 MiB), each an LRU bounded by the bytes it holds, rendering outside the lock; an entry larger than a quarter of its budget is not kept. Keyed by repository: bookmark and tag lists, invalidated by the push path. `ETag` and `Cache-Control: private, max-age=0, must-revalidate` on pages; `immutable` on content-addressed responses (raw files by hash, archives by state). A request that takes two seconds or more is logged at warn with its method, path (never the query) and status. |
|
| 453 | 453 | ||
| 454 | 454 | ### 16.4 Large repositories |
|
| 455 | 455 | ||
| … | 99 unchanged lines |