Hub memory: two allocator arenas, freed memory returned, cache in /health b210727578e8
With its state cache capped at 3 GiB the hub still grew to 12 GB: glibc keeps an arena per allocating thread and holds what is freed in each. The hub now starts with two arenas (MALLOC_ARENA_MAX still wins) and hands freed pages back every minute. /health reports what the state cache holds and its estimate against the budget, so the process's size can be read against it. refs #117
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-08 20:35 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
b210727578e88cb6d475b2ade065de6cbba939321382c491ea36e361a305d246- Depends on
- 18e381ea88c2 736ae87ef318 9ff27da517fe b64e2537d702 d23464c0ac60 e9bfea8e26db ea85398a51bc
- Depended on by
- e57765dee61a 529c703db24d ed3a36b6414c
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.4.1 v0.4.2 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
- 10 atoms, 0 tree operations, 6 files
Browse the tree at this change
modified Cargo.lock +1 −0
| … | 2228 unchanged lines |
||
| 2229 | 2229 | "hub-api", |
|
| 2230 | 2230 | "image", |
|
| 2231 | 2231 | "lettre", |
|
| 2232 | + | "libc", |
|
| 2232 | 2233 | "lzma-rs", |
|
| 2233 | 2234 | "md-5", |
|
| 2234 | 2235 | "oxy-core", |
|
| … | 2414 unchanged lines |
modified crates/hub/Cargo.toml +4 −0
| … | 72 unchanged lines |
||
| 73 | 73 | tempfile.workspace = true |
|
| 74 | 74 | tower = { version = "0.5", features = ["util"] } |
|
| 75 | 75 | http-body-util = "0.1" |
|
| 76 | + | ||
| 77 | + | [target.'cfg(unix)'.dependencies] |
|
| 78 | + | # The allocator's arenas and returning freed memory (main.rs). |
|
| 79 | + | libc = "0.2" |
modified crates/hub/src/main.rs +35 −2
| … | 99 unchanged lines |
||
| 100 | 100 | }, |
|
| 101 | 101 | } |
|
| 102 | 102 | ||
| 103 | - | #[tokio::main] |
|
| 104 | - | async fn main() -> anyhow::Result<()> { |
|
| 103 | + | fn main() -> anyhow::Result<()> { |
|
| 104 | + | tune_allocator(); |
|
| 105 | + | let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build()?; |
|
| 106 | + | rt.spawn(return_freed_memory()); |
|
| 107 | + | rt.block_on(run()) |
|
| 108 | + | } |
|
| 109 | + | ||
| 110 | + | /// glibc's allocator gives each thread that allocates an arena of its own |
|
| 111 | + | /// and keeps what is freed in it: under the hub's worker threads, loaded |
|
| 112 | + | /// states that the cache had let go still held their pages, and the hub |
|
| 113 | + | /// grew to 12 GB with its state cache capped at 3 GiB (oxygenhub#117). |
|
| 114 | + | /// Two arenas, as oxy found for its own threads; MALLOC_ARENA_MAX in the |
|
| 115 | + | /// environment still has the last word. Called before any thread starts. |
|
| 116 | + | fn tune_allocator() { |
|
| 117 | + | #[cfg(all(target_os = "linux", target_env = "gnu"))] |
|
| 118 | + | if std::env::var_os("MALLOC_ARENA_MAX").is_none() { |
|
| 119 | + | // SAFETY: mallopt is called once, before any other thread exists. |
|
| 120 | + | unsafe { |
|
| 121 | + | libc::mallopt(libc::M_ARENA_MAX, 2); |
|
| 122 | + | } |
|
| 123 | + | } |
|
| 124 | + | } |
|
| 125 | + | ||
| 126 | + | /// Hand freed memory back to the system every minute, so the hub's size |
|
| 127 | + | /// follows what it holds rather than its peak (oxygenhub#117). |
|
| 128 | + | async fn return_freed_memory() { |
|
| 129 | + | #[cfg(all(target_os = "linux", target_env = "gnu"))] |
|
| 130 | + | loop { |
|
| 131 | + | tokio::time::sleep(std::time::Duration::from_secs(60)).await; |
|
| 132 | + | // SAFETY: malloc_trim only releases free pages. |
|
| 133 | + | let _ = tokio::task::spawn_blocking(|| unsafe { libc::malloc_trim(0) }).await; |
|
| 134 | + | } |
|
| 135 | + | } |
|
| 136 | + | ||
| 137 | + | async fn run() -> anyhow::Result<()> { |
|
| 105 | 138 | tracing_subscriber::fmt() |
|
| 106 | 139 | .with_env_filter(tracing_subscriber::EnvFilter::from_default_env().add_directive("oxygenhub=info".parse()?)) |
|
| 107 | 140 | .init(); |
|
| … | 176 unchanged lines |
modified crates/hub/src/repos/cache.rs +10 −1
| … | 48 unchanged lines |
||
| 49 | 49 | /// What the state cache may hold, in bytes: OXYHUB_STATE_CACHE_MB, else |
|
| 50 | 50 | /// 3 GiB. A count alone let a few large repositories' states grow the hub |
|
| 51 | 51 | /// to 17 GB. |
|
| 52 | - | fn budget() -> usize { |
|
| 52 | + | pub fn budget() -> usize { |
|
| 53 | 53 | std::env::var("OXYHUB_STATE_CACHE_MB").ok().and_then(|v| v.parse::<usize>().ok()).unwrap_or(3 * 1024).saturating_mul(1 << 20) |
|
| 54 | 54 | } |
|
| 55 | 55 | ||
| … | 108 unchanged lines |
||
| 164 | 164 | Ok(loaded) |
|
| 165 | 165 | } |
|
| 166 | 166 | ||
| 167 | + | /// What it holds: loaded states, their estimated bytes, and summaries. |
|
| 168 | + | pub fn stats(&self) -> (usize, usize, usize) { |
|
| 169 | + | let (entries, weight) = { |
|
| 170 | + | let guard = self.inner.lock().unwrap(); |
|
| 171 | + | (guard.0.len(), guard.0.values().map(|e| e.weight).sum()) |
|
| 172 | + | }; |
|
| 173 | + | (entries, weight, self.summaries.lock().unwrap().0.len()) |
|
| 174 | + | } |
|
| 175 | + | ||
| 167 | 176 | pub fn len(&self) -> usize { |
|
| 168 | 177 | self.inner.lock().unwrap().0.len() |
|
| 169 | 178 | } |
|
| … | 68 unchanged lines |
modified crates/hub/src/web/home.rs +8 −0
| … | 223 unchanged lines |
||
| 224 | 224 | } |
|
| 225 | 225 | ||
| 226 | 226 | pub async fn health(State(app): State<Shared>) -> Response { |
|
| 227 | + | let (states, weight, summaries) = app.repos.cache().stats(); |
|
| 227 | 228 | let body = serde_json::json!({ |
|
| 228 | 229 | "ok": true, |
|
| 229 | 230 | "version": env!("CARGO_PKG_VERSION"), |
|
| 230 | 231 | "build": env!("OXY_BUILD_ID"), |
|
| 231 | 232 | "uptime_s": app.started.elapsed().as_secs(), |
|
| 233 | + | // What the state cache holds, beside the process's size (#117). |
|
| 234 | + | "state_cache": { |
|
| 235 | + | "states": states, |
|
| 236 | + | "estimated_mb": weight >> 20, |
|
| 237 | + | "budget_mb": crate::repos::cache::budget() >> 20, |
|
| 238 | + | "summaries": summaries, |
|
| 239 | + | }, |
|
| 232 | 240 | }); |
|
| 233 | 241 | axum::Json(body).into_response() |
|
| 234 | 242 | } |
|
| … | 55 unchanged lines |
modified crates/hub/tests/api.rs +2 −0
| … | 1266 unchanged lines |
||
| 1267 | 1267 | assert!(v["oxy_core"].as_str().is_some() && v["flow_core"].as_str().is_some()); |
|
| 1268 | 1268 | let health = call(app, "GET", "/health", None, None, None, None).await.body; |
|
| 1269 | 1269 | assert!(health.contains("\"build\""), "{health}"); |
|
| 1270 | + | let h: serde_json::Value = serde_json::from_str(&health).unwrap(); |
|
| 1271 | + | assert!(h["state_cache"]["budget_mb"].as_u64().is_some_and(|b| b > 0) && h["state_cache"]["states"].is_u64(), "{health}"); |
|
| 1270 | 1272 | let about = call(app, "GET", "/about", None, None, None, None).await.body; |
|
| 1271 | 1273 | assert!(about.contains(&format!("<strong>{}</strong>", env!("CARGO_PKG_VERSION"))) && about.contains("oxy-core"), "{about}"); |
|
| 1272 | 1274 | assert!(about.contains("href=\"/about\""), "the footer links here"); |
|
| … | 72 unchanged lines |