feat: add memory limits to the go build subprocess (#10666)

* feat: bound go compilation memory with GOMEMLIMIT

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: bound the whole go build tree, not each toolchain process

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep the go build memlimit and parallelism atomic

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: log the go limits actually installed and stop serializing small workers

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: make go build parallelism authoritative over persisted GOFLAGS

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: canonicalize the go build -p value and floor the module-step budget

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: parse GOMAXPROCS for -p the way the go runtime does

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: read GOMAXPROCS with go's own grammar and report limits neutrally

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: derive go build parallelism from the cgroup quota over its own period

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep go's minimum build parallelism under sub-CPU quotas

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep the windows 1CU cap out of go's two-compiler floor

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: record that a worker runs one job at a time

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: scope the one-job-at-a-time rule away from native workers

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-08-13 06:11:34 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent dad4c10c8b
commit 4cb51cf7bc
4 changed files with 556 additions and 3 deletions
+12
View File
@@ -120,6 +120,18 @@ A crash like this takes down every job on that worker, not just yours, so check
backend log after the run rather than only the job's own status. If you cannot run one,
say which path went unexercised instead of implying it was verified.
### How many jobs a worker runs at once
`NUM_WORKERS > 1` falls back to 1 outside native mode (`backend/src/main.rs`, unless
`I_ACK_NUM_WORKERS_IS_UNSAFE`), so a worker serving script tags — `go`, `python3`,
`dependency`, `flow`, … — runs one job at a time: a per-job resource budget (memory, CPU,
temp space) shares the worker with the worker process alone.
Native mode is the exception, and budgets for its tags must divide by its concurrency:
it forces 8 workers, and `NATIVE_TAGS` includes executors that already claim a per-job
share of the worker's memory (`postgresql` and `mysql` through `MAX_SQL_RESULT_SIZE`), so
up to 8 of those run against the same limit at once.
## Banned Patterns
### `$bindable(default_value)` on optional props
+67
View File
@@ -1472,6 +1472,73 @@ pub fn get_vcpus() -> Option<i64> {
(sys.cpus().len() * 100000).try_into().ok()
}
/// The window `get_vcpus`'s quota is spent over, in the same microseconds. Only
/// their ratio is a number of CPUs, and the window is configurable — 100ms is
/// merely its usual value.
#[cfg(not(windows))]
pub fn get_cpu_period() -> Option<i64> {
if Path::new("/sys/fs/cgroup/cpu/cpu.cfs_period_us").exists() {
// cgroup v1
parse_file("/sys/fs/cgroup/cpu/cpu.cfs_period_us")
} else {
// cgroup v2: `cpu.max` is "<quota|max> <period>"
let cgroup_path = get_cgroupv2_path()?;
parse_file::<String>(&format!("{cgroup_path}/cpu.max"))?
.split_whitespace()
.nth(1)?
.parse()
.ok()
}
.filter(|period| *period > 0)
}
#[cfg(windows)]
pub fn get_cpu_period() -> Option<i64> {
Some(100000)
}
/// CPUs the process is allowed to run on, ignoring any bandwidth quota — the count
/// Go's `NumCPU` reports. `available_parallelism` cannot stand in for it: that folds
/// the quota in, so a fraction of a CPU makes it report a single-core machine.
#[cfg(not(windows))]
pub fn get_affinity_cpus() -> Option<usize> {
// "Cpus_allowed_list:\t0-7,16-23"
let status = parse_file::<String>("/proc/self/status")?;
let list = status
.split("Cpus_allowed_list:")
.nth(1)?
.lines()
.next()?
.trim();
let cpus = list
.split(',')
.map(|range| {
let (first, last) = range.split_once('-').unwrap_or((range, range));
let (first, last) = (first.trim().parse::<usize>(), last.trim().parse::<usize>());
match (first, last) {
(Ok(first), Ok(last)) if last >= first => Some(last - first + 1),
_ => None,
}
})
.sum::<Option<usize>>()?;
(cpus > 0).then_some(cpus)
}
#[cfg(windows)]
pub fn get_affinity_cpus() -> Option<usize> {
// The 1CU cap is a policy rather than a bandwidth quota, so it is the whole
// answer here as it is for `get_vcpus` and `get_memory` — a consumer that reads
// this as the hardware count would raise the worker back above the cap.
if *LIMIT_WINDOWS_TO_1CU {
return Some(1);
}
let mut sys = System::new();
sys.refresh_cpu_all();
Some(sys.cpus().len()).filter(|cpus| *cpus > 0)
}
#[cfg(not(windows))]
fn get_memory_from_meminfo() -> Option<i64> {
let memory_info = parse_file::<String>("/proc/meminfo")?;
+268 -3
View File
@@ -26,8 +26,9 @@ use crate::{
OccupancyMetrics, DEV_CONF_NSJAIL,
},
handle_child::handle_child,
is_sandboxing_enabled, read_ee_registry, DISABLE_NUSER, GOPRIVATE, GOPROXY, GO_BIN_CACHE_DIR,
GO_CACHE_DIR, HOME_ENV, NSJAIL_PATH, PATH_ENV, PROXY_ENVS, TRACING_PROXY_CA_CERT_PATH, TZ_ENV,
is_sandboxing_enabled, read_ee_registry, GoBuildLimits, DISABLE_NUSER, GOPRIVATE, GOPROXY,
GO_BIN_CACHE_DIR, GO_BUILD_LIMITS, GO_CACHE_DIR, HOME_ENV, NSJAIL_PATH, PATH_ENV, PROXY_ENVS,
TRACING_PROXY_CA_CERT_PATH, TZ_ENV,
};
use windmill_common::client::AuthedClient;
@@ -84,6 +85,149 @@ lazy_static::lazy_static! {
pub const GO_OBJECT_STORE_PREFIX: &str =
const_format::concatcp!(crate::global_cache::TARGET, "_gobin/");
/// Worker group env vars forwarded to the Go toolchain (`go build`, `go mod …`).
///
/// Restricted to the GC and scheduler knobs, which bound what a compilation costs
/// without changing what it produces: the built binary is cached under a hash of
/// the source and lockfile alone, so a var that alters codegen (`GOFLAGS`, build
/// tags) would let two worker groups disagree about the contents of one cache
/// entry — including the one shared through the object store.
const GO_TOOLCHAIN_TUNING_ENVS: [&str; 3] = ["GOMEMLIMIT", "GOGC", "GOMAXPROCS"];
/// The two shapes a Go toolchain invocation takes, which spend the budget
/// differently: a build fans out into a driver plus compilers, while the module
/// steps are one process with nothing else running.
#[derive(Clone, Copy, PartialEq, Eq)]
enum GoToolchainStep {
Build,
Mod,
}
impl GoToolchainStep {
fn label(&self) -> &'static str {
match self {
GoToolchainStep::Build => "Go compilation",
GoToolchainStep::Mod => "Go dependency resolution",
}
}
}
/// Memory and parallelism settings for the Go toolchain subprocesses, which
/// otherwise inherit nothing (they are spawned with a cleared environment).
///
/// Must be applied before the explicit `.env` calls of each command so the
/// worker's own `PATH`/`GOPATH`/`GOCACHE`/`HOME` win over a worker group setting
/// the same names, as they do on the run step.
fn go_toolchain_envs(step: GoToolchainStep) -> Vec<(String, String)> {
let worker_config = windmill_common::worker::WORKER_CONFIG.load();
let envs = merge_go_toolchain_envs(&worker_config.env_vars, *GO_BUILD_LIMITS, step);
log_go_toolchain_limits(step, &envs);
envs
}
/// A slow compilation is the symptom of a limit set too low, and nothing else would
/// tell an operator that one is in force or what it resolved to. Reports what the
/// toolchain is actually given, since a worker group can pin values of its own, and
/// once per distinct setting rather than per job, since it can move them at runtime.
fn log_go_toolchain_limits(step: GoToolchainStep, envs: &[(String, String)]) {
lazy_static::lazy_static! {
static ref LOGGED: std::sync::Mutex<std::collections::HashSet<String>> =
Default::default();
}
let limits = envs
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join(" ");
// Neutral wording: the settings are reported rather than characterized, since
// a worker group can pin ones that lift the limit (`GOMEMLIMIT=off`) as easily
// as ones that impose it.
let line = if limits.is_empty() {
format!("{} runs with no limits", step.label())
} else {
format!("{} runs with {limits}", step.label())
};
let Ok(mut logged) = LOGGED.lock() else {
return;
};
if logged.insert(line.clone()) {
tracing::info!("{line}");
}
}
fn merge_go_toolchain_envs(
worker_envs: &HashMap<String, String>,
derived: Option<GoBuildLimits>,
step: GoToolchainStep,
) -> Vec<(String, String)> {
// Values reach the toolchain as the worker group wrote them, the same way they
// reach the run step: a `GOMEMLIMIT` the Go runtime rejects then fails both
// steps alike instead of compiling under a rewritten value and dying on the
// binary it produced. An allowlisted name the worker never set resolves to an
// empty string, which would shadow the derived pair with nothing.
let configured = |k: &str| worker_envs.get(k).filter(|v| !v.trim().is_empty());
let mut envs: Vec<(String, String)> = GO_TOOLCHAIN_TUNING_ENVS
.iter()
.filter_map(|k| configured(k).map(|v| (k.to_string(), v.clone())))
.collect();
// Half of the derived pair is not a weaker limit but no limit (see
// `resolve_go_build_limits`), so a worker group that sets either one owns both.
let pinned_by_worker_group = envs
.iter()
.any(|(k, _)| k == "GOMEMLIMIT" || k == "GOMAXPROCS");
if let (false, Some(limits)) = (pinned_by_worker_group, derived) {
match step {
GoToolchainStep::Build => {
envs.push(("GOMEMLIMIT".to_string(), limits.memlimit.to_string()));
envs.push(("GOMAXPROCS".to_string(), limits.parallelism.to_string()));
}
// Nothing shares the budget with a single process, and its work queue
// is sized from `GOMAXPROCS` — throttling it would only slow fetches
// down without bounding anything.
GoToolchainStep::Mod => {
envs.push(("GOMEMLIMIT".to_string(), limits.budget.to_string()));
}
}
}
envs
}
/// `go build`'s `-p`, so the parallelism the aggregate assumes is the one it gets.
///
/// `GOMAXPROCS` only supplies the *default* for `-p`, which a `GOFLAGS=-p=…`
/// persisted in the toolchain's own env file outranks — and that file is read,
/// since the command keeps `HOME`. A flag on the command line is applied last.
fn go_build_parallelism_args(envs: &[(String, String)]) -> Vec<String> {
envs.iter()
.find(|(k, _)| k == "GOMAXPROCS")
.and_then(|(_, v)| go_runtime_int32(v))
.filter(|parallelism| *parallelism > 0)
.map(|parallelism| vec!["-p".to_string(), parallelism.to_string()])
.unwrap_or_default()
}
/// `GOMAXPROCS` as the Go runtime reads it: a signed 32-bit decimal, no padding of
/// any kind, and `None` for everything else — which the runtime silently ignores.
///
/// A worker group's value is forwarded verbatim, so it is not necessarily one the
/// `-p` flag would take, and the two parsers disagree in both directions: `-p`
/// infers the base and rejects the `08` the runtime reads as 8, while it accepts
/// the `2147483648` and the `"8 "` the runtime discards — and it would then start
/// that many build workers. Reading the value the runtime's way keeps `-p` in step
/// with it, so a spelling the runtime ignores leaves the flag off and the build
/// keeps the default the runtime itself would have used.
fn go_runtime_int32(v: &str) -> Option<i32> {
let digits = v.strip_prefix(['+', '-']).unwrap_or(v);
(!digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()))
.then(|| v.parse::<i32>().ok())
.flatten()
}
#[tracing::instrument(level = "trace", skip_all)]
pub async fn handle_go_job(
mem_peak: &mut i32,
@@ -229,10 +373,19 @@ func Run(req Req) (interface{{}}, error){{
}
}
let toolchain_envs = go_toolchain_envs(GoToolchainStep::Build);
let build_args = [
vec!["build".to_string()],
go_build_parallelism_args(&toolchain_envs),
vec!["main.go".to_string()],
]
.concat();
let mut build_go_cmd = Command::new(GO_PATH.as_str());
build_go_cmd
.current_dir(job_dir)
.env_clear()
.envs(toolchain_envs)
.env("PATH", PATH_ENV.as_str())
.env("BASE_INTERNAL_URL", base_internal_url)
.env("GOPATH", {
@@ -248,7 +401,7 @@ func Run(req Req) (interface{{}}, error){{
.env("HOME", HOME_ENV.as_str())
.env("GOCACHE", GO_CACHE_DIR.as_str())
.envs(PROXY_ENVS.clone())
.args(vec!["build", "main.go"])
.args(build_args)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
@@ -526,6 +679,7 @@ pub async fn install_go_dependencies(
child_cmd
.current_dir(job_dir)
.env_clear()
.envs(go_toolchain_envs(GoToolchainStep::Mod))
.args(vec!["mod", "init", "mymod"])
.stdout(Stdio::piped())
.stderr(Stdio::piped());
@@ -610,6 +764,7 @@ pub async fn install_go_dependencies(
child_cmd
.current_dir(job_dir)
.env_clear()
.envs(go_toolchain_envs(GoToolchainStep::Mod))
.env("HOME", HOME_ENV.as_str())
.env("PATH", PATH_ENV.as_str())
.envs(PROXY_ENVS.clone())
@@ -719,3 +874,113 @@ async fn gen_go_mymod(code: &str, job_dir: &str) -> error::Result<()> {
Ok(())
}
#[cfg(test)]
mod go_toolchain_envs_tests {
use super::{
go_build_parallelism_args, merge_go_toolchain_envs, GoBuildLimits, GoToolchainStep, HashMap,
};
const DERIVED: Option<GoBuildLimits> =
Some(GoBuildLimits { budget: 2048, memlimit: 512, parallelism: 3 });
fn worker_envs(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
fn merged(pairs: &[(&str, &str)], derived: Option<GoBuildLimits>) -> Vec<(String, String)> {
let mut envs =
merge_go_toolchain_envs(&worker_envs(pairs), derived, GoToolchainStep::Build);
envs.sort();
envs
}
fn expect(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
let mut envs: Vec<(String, String)> = pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
envs.sort();
envs
}
#[test]
fn worker_group_settings_replace_the_derived_pair_whole() {
assert_eq!(
merged(&[], DERIVED),
expect(&[("GOMEMLIMIT", "512"), ("GOMAXPROCS", "3")])
);
// Either half configured hands the whole policy over, since a cap without a
// process count (or the reverse) bounds nothing.
assert_eq!(
merged(&[("GOMEMLIMIT", "2GiB")], DERIVED),
expect(&[("GOMEMLIMIT", "2GiB")])
);
assert_eq!(
merged(&[("GOMAXPROCS", "32")], DERIVED),
expect(&[("GOMAXPROCS", "32")])
);
// GOGC is orthogonal to the pair, so it rides along with it.
assert_eq!(
merged(&[("GOGC", "50")], DERIVED),
expect(&[("GOGC", "50"), ("GOMEMLIMIT", "512"), ("GOMAXPROCS", "3")])
);
// Forwarded untouched: a trimmed value would compile under a spelling the
// run step then rejects.
assert_eq!(
merged(&[("GOMEMLIMIT", " 2GiB ")], DERIVED),
expect(&[("GOMEMLIMIT", " 2GiB ")])
);
// An allowlisted name the worker never set must not shadow the pair.
assert_eq!(
merged(&[("GOMEMLIMIT", " ")], DERIVED),
expect(&[("GOMEMLIMIT", "512"), ("GOMAXPROCS", "3")])
);
assert_eq!(merged(&[], None), expect(&[]));
}
#[test]
fn the_module_steps_get_the_whole_budget() {
// One process, nothing sharing with it, and no compilers to hold back.
assert_eq!(
merge_go_toolchain_envs(&worker_envs(&[]), DERIVED, GoToolchainStep::Mod),
expect(&[("GOMEMLIMIT", "2048")])
);
}
#[test]
fn parallelism_is_passed_on_the_command_line_when_it_is_a_number() {
assert_eq!(
go_build_parallelism_args(&merged(&[], DERIVED)),
vec!["-p".to_string(), "3".to_string()]
);
// A worker group value is forwarded verbatim, so `-p` is only asserted over
// `GOFLAGS` when it is one the toolchain would accept.
assert!(go_build_parallelism_args(&merged(&[("GOMAXPROCS", "many")], DERIVED)).is_empty());
// `-p` infers the base and rejects `08`, which the runtime reads as 8, so
// the number is emitted rather than the spelling it arrived in.
assert_eq!(
go_build_parallelism_args(&merged(&[("GOMAXPROCS", "08")], DERIVED)),
vec!["-p".to_string(), "8".to_string()]
);
// Past the signed 32-bit range, and padded with whitespace, the runtime
// ignores the value, so `-p` has to as well: the flag would take either and
// start that many workers.
assert!(
go_build_parallelism_args(&merged(&[("GOMAXPROCS", "2147483648")], DERIVED)).is_empty()
);
assert!(
go_build_parallelism_args(&merged(&[("GOMAXPROCS", "2147483647 ")], DERIVED))
.is_empty()
);
// A leading `+` is one the runtime does take, so the flag keeps it too.
assert_eq!(
go_build_parallelism_args(&merged(&[("GOMAXPROCS", "+8")], DERIVED)),
vec!["-p".to_string(), "8".to_string()]
);
assert!(go_build_parallelism_args(&merged(&[], None)).is_empty());
}
}
+209
View File
@@ -1275,6 +1275,28 @@ const SQL_RESULT_SIZE_FRACTION: f64 = 0.15;
// only reject work that would have succeeded.
const MIN_MAX_SQL_RESULT_SIZE: usize = 8 * 1024 * 1024;
// Share of the worker's memory budget a Go compilation may hold. Set high enough
// that an ordinary build never approaches it, so the only builds whose behavior
// changes are those that were about to take the worker down.
//
// What the rest covers is not the worker process, which is tens of MB and would
// argue for a constant: it is everything `GOMEMLIMIT` does not count and that grows
// with the build — the toolchain's mmapped inputs and outputs, its non-Go
// allocations, and the page cache its writes charge to the cgroup.
const GO_BUILD_MEMLIMIT_FRACTION: f64 = 0.75;
// Heap a Go compiler is comfortable in: cores are only put to work while the budget
// still affords each of them this much.
const GO_BUILD_TARGET_MEMLIMIT: usize = 384 * 1024 * 1024;
// Driver plus the compilers below which a build stops overlapping and starts
// waiting. Measured on a dependency-heavy build: at a budget too small to give this
// many the target share, splitting it further still compiles faster than handing
// fewer processes more — one compiler alone costs ~3x what five of them do on the
// same budget — so the process count holds and the share absorbs the difference.
const MIN_GO_BUILD_PROCESSES: usize = 6;
// Floor under the share, past which dividing the budget again buys nothing: the
// processes only trade compiling time for collecting time.
const MIN_GO_BUILD_MEMLIMIT: usize = 128 * 1024 * 1024;
/// `"512"`, `"512MB"`, `"2GiB"`, `"1.5GB"` -> bytes. Suffixes are case-insensitive
/// and binary, so `MB` and `MiB` both mean 1024².
///
@@ -1351,6 +1373,193 @@ lazy_static::lazy_static! {
.unwrap_or(usize::MAX),
}
};
/// What a Go compilation is allowed to cost, derived from the worker's memory
/// budget. Nothing else bounds it: a compilation grows until the cgroup OOM
/// killer takes the worker process down, every job colocated on it with it.
/// `GOMEMLIMIT` is soft — the GC works harder as the heap nears it instead of
/// failing the allocation — so a pathological build turns into a slow one.
///
/// `GO_BUILD_MEMLIMIT` overrides the budget (`512MB`, `2GiB`, …), and `0`/`off`
/// disables the whole thing, as does a worker with no cgroup memory reading to
/// scale from.
pub(crate) static ref GO_BUILD_LIMITS: Option<GoBuildLimits> = resolve_go_build_limits(
std::env::var("GO_BUILD_MEMLIMIT").ok().as_deref(),
windmill_common::worker::get_memory(),
worker_vcpus(),
);
}
/// How much memory the Go toolchain may hold while compiling a script, expressed
/// the only way the toolchain understands it.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) struct GoBuildLimits {
/// What the whole build may hold, and so what a step that is one process gets.
pub budget: usize,
/// `GOMEMLIMIT` for one process of a build that fans out.
pub memlimit: usize,
/// `GOMAXPROCS`, which is also `go build`'s default `-p`: how many compilers it
/// runs at once.
pub parallelism: usize,
}
fn worker_vcpus() -> usize {
effective_vcpus(
windmill_common::worker::get_vcpus(),
windmill_common::worker::get_cpu_period(),
windmill_common::worker::get_affinity_cpus()
.or_else(|| std::thread::available_parallelism().ok().map(|n| n.get()))
.unwrap_or(1),
)
}
/// CPUs worth of work the worker can actually run at once.
///
/// The cgroup states its allowance as a quota over a period, and only their ratio
/// is a number of CPUs — `1500m` is `150000/100000`. A fraction still runs work, so
/// it rounds up the way the Go runtime's own container-aware `GOMAXPROCS` does:
/// flooring would call that worker single-core and serialize its builds.
///
/// `host_cpus` counts the CPUs the worker may run on, quota aside, and is both the
/// answer when there is no quota and the floor under one: Go's own container-aware
/// default never drops below two while the machine has two to give, since even a
/// fraction of a CPU compiles two packages faster than it compiles them in series.
fn effective_vcpus(quota_us: Option<i64>, period_us: Option<i64>, host_cpus: usize) -> usize {
quota_us
.zip(period_us)
.filter(|(quota, period)| *quota > 0 && *period > 0)
.map(|(quota, period)| ((quota + period - 1) / period) as usize)
.unwrap_or(host_cpus)
.max(host_cpus.min(2))
.max(1)
}
/// Split a build budget into the per-process cap and the parallelism it assumes.
///
/// `GOMEMLIMIT` bounds one process, and a build is a driver plus up to `-p`
/// compilers that each inherit the same value, so the budget only holds if the
/// number of processes sharing it is pinned alongside it.
fn resolve_go_build_limits(
env_override: Option<&str>,
worker_memory: Option<i64>,
vcpus: usize,
) -> Option<GoBuildLimits> {
let derived = || {
worker_memory
.filter(|bytes| *bytes > 0)
.map(|bytes| (bytes as f64 * GO_BUILD_MEMLIMIT_FRACTION) as usize)
};
let budget = match env_override.map(str::trim).filter(|v| !v.is_empty()) {
None => derived()?,
Some(v) if v.eq_ignore_ascii_case("off") => return None,
Some(v) => match parse_byte_size(v) {
// A zero budget would have the GC hold the heap at nothing, so it reads
// as "no limit" instead.
Some(0) => return None,
Some(bytes) => bytes,
None => {
// Falling back silently would leave the operator believing the limit
// they wrote is in force.
tracing::warn!(
"Go build memory budget {v:?} is not a byte size (e.g. 512MB, \
2GiB); falling back to the worker's memory-derived budget"
);
derived()?
}
},
};
// One compiler per core while the budget affords each the target share, never
// so few that the build stops overlapping, and never more than the cores can
// run. The floor is the last word: on a worker too small to honor both, the
// budget is the one that gives, since processes squeezed under it make no
// progress to bound.
let cap = vcpus.max(1) + 1;
let processes = (budget / GO_BUILD_TARGET_MEMLIMIT).clamp(MIN_GO_BUILD_PROCESSES.min(cap), cap);
Some(GoBuildLimits {
// Floored like the share it is an alternative to: a step that holds the
// whole budget must never end up with less than one of six compilers.
budget: budget.max(MIN_GO_BUILD_MEMLIMIT),
memlimit: (budget / processes).max(MIN_GO_BUILD_MEMLIMIT),
parallelism: processes - 1,
})
}
#[cfg(test)]
mod go_build_limits_tests {
use super::{
effective_vcpus, resolve_go_build_limits, GoBuildLimits, GO_BUILD_TARGET_MEMLIMIT,
MIN_GO_BUILD_MEMLIMIT,
};
const GIB: i64 = 1024 * 1024 * 1024;
fn limits(budget: usize, memlimit: usize, parallelism: usize) -> Option<GoBuildLimits> {
Some(GoBuildLimits { budget, memlimit, parallelism })
}
#[test]
fn reads_the_cgroup_allowance_as_cpus() {
// 1500m: a fraction of a CPU still runs work, so it is two compilers' worth
// of concurrency rather than one.
assert_eq!(effective_vcpus(Some(150_000), Some(100_000), 24), 2);
assert_eq!(effective_vcpus(Some(400_000), Some(100_000), 24), 4);
// The period is configurable, so only the ratio means anything.
assert_eq!(effective_vcpus(Some(400_000), Some(50_000), 24), 8);
// The quota is the answer whenever there is one to read, since the count
// it would otherwise be clamped to has already floored it.
assert_eq!(effective_vcpus(Some(4_000_000), Some(100_000), 4), 40);
assert_eq!(effective_vcpus(None, None, 8), 8);
// Under a whole CPU the floor is two, as the Go runtime's own default is —
// but only where there are two to give.
assert_eq!(effective_vcpus(Some(50_000), Some(100_000), 24), 2);
assert_eq!(effective_vcpus(Some(50_000), Some(100_000), 1), 1);
// A one-CPU allowance stays one when that is all the worker may use, which
// is how the Windows 1CU cap reports itself.
assert_eq!(effective_vcpus(Some(100_000), Some(100_000), 1), 1);
}
#[test]
fn splits_the_budget_across_the_build_tree() {
// Fewer cores than the budget could feed: every core gets a compiler, and
// they share 3GiB with the driver.
assert_eq!(
resolve_go_build_limits(None, Some(4 * GIB), 4),
limits(3 * GIB as usize, 3 * GIB as usize / 5, 4)
);
// More cores than it can feed at the target share: the extra cores idle
// rather than shrink every compiler.
assert_eq!(
resolve_go_build_limits(None, Some(4 * GIB), 64),
limits(3 * GIB as usize, GO_BUILD_TARGET_MEMLIMIT, 7)
);
// Too small to give even the minimum process count the target share: the
// build keeps overlapping and the share absorbs it.
assert_eq!(
resolve_go_build_limits(None, Some(GIB), 64),
limits(3 * GIB as usize / 4, 3 * GIB as usize / 4 / 6, 5)
);
// Nothing to scale from leaves the toolchain unlimited, as it was before.
assert_eq!(resolve_go_build_limits(None, None, 4), None);
// Under the floor the budget gives instead of the share.
assert_eq!(
resolve_go_build_limits(None, Some(GIB / 8), 4),
limits(MIN_GO_BUILD_MEMLIMIT, MIN_GO_BUILD_MEMLIMIT, 4)
);
assert_eq!(
resolve_go_build_limits(Some("2GiB"), Some(4 * GIB), 4),
limits(2 * GIB as usize, 2 * GIB as usize / 5, 4)
);
assert_eq!(resolve_go_build_limits(Some("off"), Some(4 * GIB), 4), None);
assert_eq!(resolve_go_build_limits(Some("0MB"), Some(4 * GIB), 4), None);
// An unparseable override falls back to the derived budget rather than
// lifting the limit.
assert_eq!(
resolve_go_build_limits(Some("lots"), Some(4 * GIB), 4),
limits(3 * GIB as usize, 3 * GIB as usize / 5, 4)
);
}
}
/// The limit postgres collection is bounded by: the cloud product cap where one