OXYGENHUB

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