Skip to content

Commit cd601e7

Browse files
committed
Resolve the job's cgroup subpath for memory/thread detection; make detectors testable
The cgroup memory_limit / threads detectors read fixed root paths (/sys/fs/cgroup/memory.max, .../cpu.max, ...), which only match when the process runs in the ROOT cgroup. A cgroup-constrained non-SLURM job was therefore undetected and fell back to physical RAM / hardware concurrency -- the over-commit the defaults exist to prevent (F14). The helpers also read /proc and /sys directly, so they could not be unit-tested (F15). Add .cgroupDirs(), which resolves the job's own cgroup subpath from /proc/self/cgroup (cgroup v2 unified "0::<path>" and v1 per-controller "<n>:<ctrls>:<path>", including combined mounts like cpu,cpuacct) and searches it before the mount root. The mount root stays in the candidate list, so detection never regresses where root paths already worked. All detectors (.cgroupDirs, .cgroupMemoryBytes, .cgroupCpus, .physicalMemoryBytes) take injectable path parameters and are covered by fixture-based tests. - R/DuckDBConnection.R: .cgroupDirs, .readCgroupBytes, .cgroupQuotaCpus; subpath-aware .cgroupMemoryBytes/.cgroupCpus; path-param .physicalMemoryBytes; docs - tests/testthat/test-DuckDBConnection.R: fixture tests (v2/v1 subpath, root fallback, meminfo) - DESCRIPTION, NEWS.md: 0.99.13
1 parent ef60757 commit cd601e7

5 files changed

Lines changed: 173 additions & 54 deletions

File tree

DESCRIPTION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
Package: DuckDBDataFrame
2-
Version: 0.99.12
2+
Version: 0.99.13
33
Date: 2026-07-23
44
Title: DuckDB-Backed DataFrame and Table Structures
55
Description:

NEWS.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,20 @@
1+
# DuckDBDataFrame 0.99.13
2+
3+
## Bug fixes
4+
5+
- The cgroup `memory_limit` / `threads` detectors used by `configureOutOfCore()`
6+
now resolve the job's **own** cgroup subpath from `/proc/self/cgroup` (both
7+
cgroup v2 unified and v1 per-controller, including combined mounts like
8+
`cpu,cpuacct`) before falling back to the mount root. A cgroup-constrained
9+
**non-SLURM** job was previously undetected — the fixed root paths
10+
(`/sys/fs/cgroup/memory.max`, …) only match when the process runs in the root
11+
cgroup — so it silently fell back to physical RAM / hardware concurrency, the
12+
over-commit the defaults are meant to prevent. The mount root stays in the
13+
search list, so detection never regresses where it already worked. The
14+
detectors (`.cgroupDirs()`, `.cgroupMemoryBytes()`, `.cgroupCpus()`,
15+
`.physicalMemoryBytes()`) take injectable path parameters and are now covered
16+
by fixture-based unit tests.
17+
118
# DuckDBDataFrame 0.99.12
219

320
## Bug fixes

R/DuckDBConnection.R

Lines changed: 91 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@ reg.finalizer(.duckdb, function(env) {
7272
#' the most-restrictive detected ceiling -- an explicit SLURM allocation
7373
#' (\code{SLURM_MEM_PER_NODE}, or \code{SLURM_MEM_PER_CPU} times
7474
#' \code{SLURM_CPUS_ON_NODE}), the cgroup limit (v2 \code{memory.max} then v1
75-
#' \code{memory.limit_in_bytes}), then physical RAM. DuckDB's own default is 80\%
75+
#' \code{memory.limit_in_bytes}, read from the job's own cgroup resolved via
76+
#' \code{/proc/self/cgroup} before the mount root), then physical RAM. DuckDB's own default is 80\%
7677
#' of *physical* RAM, which ignores a SLURM / cgroup cap and can over-commit
7778
#' (near-OOM on the first large scan); the detected default keeps a big
7879
#' aggregation spilling within the job's allocation. When nothing can be detected
@@ -81,7 +82,8 @@ reg.finalizer(.duckdb, function(env) {
8182
#' \code{threads} is likewise defaulted when unset: the most-restrictive detected
8283
#' CPU allocation -- a SLURM allocation (\code{SLURM_CPUS_PER_TASK}, or
8384
#' \code{SLURM_CPUS_ON_NODE}) then the cgroup CFS quota (v2 \code{cpu.max}, v1
84-
#' \code{cpu.cfs_quota_us}/\code{cpu.cfs_period_us}). DuckDB otherwise defaults to
85+
#' \code{cpu.cfs_quota_us}/\code{cpu.cfs_period_us}, read from the job's own
86+
#' cgroup subpath before the mount root). DuckDB otherwise defaults to
8587
#' hardware concurrency, which ignores a SLURM/cgroup cpuset and over-subscribes
8688
#' on a shared node (each extra thread also carries working memory against the
8789
#' memory cap). When no allocation is detected, DuckDB's default is left in place.
@@ -228,37 +230,73 @@ setExtensionDirectory <- function(conn) {
228230
NULL
229231
}
230232

231-
# Memory ceiling (bytes) from the cgroup limit (v2 then v1). NULL when
232-
# unconstrained ("max", the v1 huge sentinel, or the files are absent).
233-
.cgroupMemoryBytes <- function() {
234-
read_limit <- function(path) {
235-
if (!file.exists(path)) {
236-
return(NULL)
233+
# Ordered candidate cgroup directories for a controller, most specific first
234+
.cgroupDirs <- function(controller,
235+
proc_cgroup = "/proc/self/cgroup",
236+
root = "/sys/fs/cgroup") {
237+
dirs <- character(0)
238+
lines <- if (file.exists(proc_cgroup)) {
239+
tryCatch(readLines(proc_cgroup, warn = FALSE), error = function(e) character())
240+
} else {
241+
character(0)
242+
}
243+
for (ln in lines) {
244+
parts <- strsplit(ln, ":", fixed = TRUE)[[1L]]
245+
if (length(parts) < 3L) {
246+
next
237247
}
238-
val <- tryCatch(readLines(path, n = 1L, warn = FALSE),
239-
error = function(e) character())
240-
if (!length(val) || !nzchar(val[1L]) || val[1L] == "max") {
241-
return(NULL)
248+
ctrls <- parts[2L]
249+
sub <- sub("^/", "", paste(parts[-(1:2)], collapse = ":")) # path may hold ':'
250+
if (!nzchar(ctrls)) {
251+
# v2 unified: files live directly under root/<path>
252+
dirs <- c(dirs, if (nzchar(sub)) file.path(root, sub) else root)
253+
} else if (controller %in% strsplit(ctrls, ",", fixed = TRUE)[[1L]]) {
254+
# v1: files under root/<mount>/<path>; the mount may be the combined
255+
# controller string ("cpu,cpuacct") or a per-controller symlink.
256+
for (mnt in unique(c(ctrls, controller))) {
257+
dirs <- c(dirs,
258+
if (nzchar(sub)) file.path(root, mnt, sub) else file.path(root, mnt))
259+
}
242260
}
243-
b <- suppressWarnings(as.numeric(val[1L]))
244-
# cgroup v1 "unlimited" is a huge sentinel (~PAGE_COUNTER_MAX); treat an
245-
# implausibly large value (>= 1 EiB) as unset.
246-
if (is.na(b) || b <= 0 || b >= 2^60) {
247-
return(NULL)
261+
}
262+
dirs <- c(dirs, root, file.path(root, controller)) # root fallbacks
263+
unique(dirs[dir.exists(dirs)])
264+
}
265+
266+
# Parse a cgroup byte-limit file (v2 memory.max / v1 memory.limit_in_bytes)
267+
.readCgroupBytes <- function(path) {
268+
if (!file.exists(path)) {
269+
return(NULL)
270+
}
271+
val <- tryCatch(readLines(path, n = 1L, warn = FALSE), error = function(e) character())
272+
if (!length(val) || !nzchar(val[1L]) || val[1L] == "max") {
273+
return(NULL)
274+
}
275+
b <- suppressWarnings(as.numeric(val[1L]))
276+
if (is.na(b) || b <= 0 || b >= 2^60) {
277+
return(NULL)
278+
}
279+
b
280+
}
281+
282+
# Memory ceiling (bytes) from the job's cgroup (v2 then v1)
283+
.cgroupMemoryBytes <- function(dirs = .cgroupDirs("memory")) {
284+
for (d in dirs) {
285+
b <- .readCgroupBytes(file.path(d, "memory.max")) %||% # v2
286+
.readCgroupBytes(file.path(d, "memory.limit_in_bytes")) # v1
287+
if (!is.null(b)) {
288+
return(b)
248289
}
249-
b
250290
}
251-
read_limit("/sys/fs/cgroup/memory.max") %||% # cgroup v2
252-
read_limit("/sys/fs/cgroup/memory/memory.limit_in_bytes") # cgroup v1
291+
NULL
253292
}
254293

255294
# Physical RAM (bytes) from /proc/meminfo (Linux only). NULL elsewhere.
256-
.physicalMemoryBytes <- function() {
257-
if (!file.exists("/proc/meminfo")) {
295+
.physicalMemoryBytes <- function(path = "/proc/meminfo") {
296+
if (!file.exists(path)) {
258297
return(NULL)
259298
}
260-
ln <- tryCatch(readLines("/proc/meminfo", warn = FALSE),
261-
error = function(e) character())
299+
ln <- tryCatch(readLines(path, warn = FALSE), error = function(e) character())
262300
kb <- grep("^MemTotal:", ln, value = TRUE)
263301
if (!length(kb)) {
264302
return(NULL)
@@ -301,39 +339,41 @@ setExtensionDirectory <- function(conn) {
301339
NULL
302340
}
303341

304-
# CPU count from the cgroup CFS quota (v2 `cpu.max`, then v1
305-
# `cpu.cfs_quota_us`/`cpu.cfs_period_us`) = floor(quota / period). NULL when
306-
# unconstrained ("max", quota <= 0, or the files are absent).
307-
.cgroupCpus <- function() {
308-
quota_cpus <- function(q, p) {
309-
if (is.na(q) || is.na(p) || q <= 0 || p <= 0) {
310-
return(NULL)
342+
# floor(quota / period) as a CPU count, or NULL when either is non-positive/NA.
343+
.cgroupQuotaCpus <- function(quota, period) {
344+
if (is.na(quota) || is.na(period) || quota <= 0 || period <= 0) {
345+
return(NULL)
346+
}
347+
max(1L, as.integer(floor(quota / period)))
348+
}
349+
350+
# CPU count from the job's cgroup CFS quota
351+
.cgroupCpus <- function(dirs = .cgroupDirs("cpu")) {
352+
for (d in dirs) {
353+
v2 <- file.path(d, "cpu.max")
354+
if (file.exists(v2)) {
355+
val <- tryCatch(strsplit(trimws(readLines(v2, n = 1L, warn = FALSE)),
356+
"[[:space:]]+")[[1L]],
357+
error = function(e) character())
358+
if (length(val) >= 2L && val[1L] != "max") {
359+
n <- .cgroupQuotaCpus(suppressWarnings(as.numeric(val[1L])),
360+
suppressWarnings(as.numeric(val[2L])))
361+
if (!is.null(n)) {
362+
return(n)
363+
}
364+
}
311365
}
312-
max(1L, as.integer(floor(q / p)))
313-
}
314-
v2 <- "/sys/fs/cgroup/cpu.max"
315-
if (file.exists(v2)) {
316-
val <- tryCatch(strsplit(trimws(readLines(v2, n = 1L, warn = FALSE)),
317-
"[[:space:]]+")[[1L]],
318-
error = function(e) character())
319-
if (length(val) >= 2L && val[1L] != "max") {
320-
n <- quota_cpus(suppressWarnings(as.numeric(val[1L])),
321-
suppressWarnings(as.numeric(val[2L])))
366+
qf <- file.path(d, "cpu.cfs_quota_us")
367+
pf <- file.path(d, "cpu.cfs_period_us")
368+
if (file.exists(qf) && file.exists(pf)) {
369+
n <- .cgroupQuotaCpus(
370+
suppressWarnings(as.numeric(readLines(qf, n = 1L, warn = FALSE))),
371+
suppressWarnings(as.numeric(readLines(pf, n = 1L, warn = FALSE))))
322372
if (!is.null(n)) {
323373
return(n)
324374
}
325375
}
326376
}
327-
qf <- "/sys/fs/cgroup/cpu/cpu.cfs_quota_us"
328-
pf <- "/sys/fs/cgroup/cpu/cpu.cfs_period_us"
329-
if (file.exists(qf) && file.exists(pf)) {
330-
n <- quota_cpus(
331-
suppressWarnings(as.numeric(readLines(qf, n = 1L, warn = FALSE))),
332-
suppressWarnings(as.numeric(readLines(pf, n = 1L, warn = FALSE))))
333-
if (!is.null(n)) {
334-
return(n)
335-
}
336-
}
337377
NULL
338378
}
339379

man/DuckDBConnection.Rd

Lines changed: 4 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/testthat/test-DuckDBConnection.R

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,63 @@ test_that("configureOutOfCore defaults threads to the SLURM allocation when unse
109109
DBI::dbGetQuery(con, "SELECT current_setting('threads') AS v")$v)
110110
expect_identical(thr, 1L)
111111
})
112+
113+
test_that(".cgroupDirs / cgroup detectors resolve the job subpath then fall back", {
114+
mk <- function(p, txt) {
115+
dir.create(dirname(p), recursive = TRUE, showWarnings = FALSE)
116+
writeLines(txt, p)
117+
}
118+
119+
# cgroup v2: the job's own subpath must win over the mount root
120+
b <- tempfile("v2_"); root <- file.path(b, "cg"); proc <- file.path(b, "proc")
121+
on.exit(unlink(b, recursive = TRUE), add = TRUE)
122+
mk(file.path(root, "myjob", "memory.max"), "8589934592") # 8 GiB (job)
123+
mk(file.path(root, "memory.max"), "68719476736") # 64 GiB (root)
124+
mk(file.path(root, "myjob", "cpu.max"), "200000 100000") # 2 cpus (job)
125+
mk(file.path(root, "cpu.max"), "800000 100000") # 8 cpus (root)
126+
writeLines("0::/myjob", proc)
127+
expect_identical(
128+
DuckDBDataFrame:::.cgroupMemoryBytes(DuckDBDataFrame:::.cgroupDirs("memory", proc, root)),
129+
8 * 2^30)
130+
expect_identical(
131+
DuckDBDataFrame:::.cgroupCpus(DuckDBDataFrame:::.cgroupDirs("cpu", proc, root)),
132+
2L)
133+
134+
# cgroup v1 with a combined controller mount (cpu,cpuacct) + subpath
135+
b <- tempfile("v1_"); root <- file.path(b, "cg"); proc <- file.path(b, "proc")
136+
on.exit(unlink(b, recursive = TRUE), add = TRUE)
137+
mk(file.path(root, "memory", "mygrp", "memory.limit_in_bytes"), "4294967296") # 4 GiB
138+
mk(file.path(root, "cpu,cpuacct", "mygrp", "cpu.cfs_quota_us"), "300000")
139+
mk(file.path(root, "cpu,cpuacct", "mygrp", "cpu.cfs_period_us"), "100000") # 3 cpus
140+
writeLines(c("7:memory:/mygrp", "5:cpu,cpuacct:/mygrp"), proc)
141+
expect_identical(
142+
DuckDBDataFrame:::.cgroupMemoryBytes(DuckDBDataFrame:::.cgroupDirs("memory", proc, root)),
143+
4 * 2^30)
144+
expect_identical(
145+
DuckDBDataFrame:::.cgroupCpus(DuckDBDataFrame:::.cgroupDirs("cpu", proc, root)),
146+
3L)
147+
148+
# root fallback: no /proc/self/cgroup -> the mount root is still searched
149+
b <- tempfile("rt_"); root <- file.path(b, "cg"); proc <- file.path(b, "absent")
150+
on.exit(unlink(b, recursive = TRUE), add = TRUE)
151+
mk(file.path(root, "memory.max"), "1073741824") # 1 GiB at root
152+
expect_identical(
153+
DuckDBDataFrame:::.cgroupMemoryBytes(DuckDBDataFrame:::.cgroupDirs("memory", proc, root)),
154+
2^30)
155+
156+
# unconstrained ("max") -> NULL
157+
b <- tempfile("mx_"); root <- file.path(b, "cg"); proc <- file.path(b, "proc")
158+
on.exit(unlink(b, recursive = TRUE), add = TRUE)
159+
mk(file.path(root, "memory.max"), "max")
160+
writeLines("0::/", proc)
161+
expect_null(
162+
DuckDBDataFrame:::.cgroupMemoryBytes(DuckDBDataFrame:::.cgroupDirs("memory", proc, root)))
163+
})
164+
165+
test_that(".physicalMemoryBytes parses an injected /proc/meminfo", {
166+
mi <- tempfile()
167+
on.exit(unlink(mi), add = TRUE)
168+
writeLines(c("MemTotal: 16307188 kB", "SwapTotal: 0 kB"), mi)
169+
expect_identical(DuckDBDataFrame:::.physicalMemoryBytes(mi), 16307188 * 1024)
170+
expect_null(DuckDBDataFrame:::.physicalMemoryBytes(tempfile()))
171+
})

0 commit comments

Comments
 (0)