Skip to content

Commit ded2aaf

Browse files
authored
Cache subprocess rbc invocations via RBC_STYXCACHE_DIR (#312)
* Wire styxcache into setup_runner for subprocess rbc invocations tests/integration/test_all.py spawns `rbc all` via subprocess.run; that fresh process hits setup_runner() rather than the pytest autouse fixture, so it was running the full pipeline uncached. Cache diagnose confirmed 0 new entries during warm runs and revealed `setup` of those two fixtures took ~81 min combined. setup_runner now wraps the configured runner with styxcache's CachingRunner when RBC_STYXCACHE_DIR is set, matching the behavior we already had in tests/conftest.py. The wrapping logic (attribute proxy + policy) now lives in rbc.core.niwrap.maybe_wrap_with_cache; tests/conftest.py imports and reuses it. styxcache moves from dev to runtime dependency since rbc.core now imports it unconditionally. Drop the try/except fallback and the deptry DEP004 ignore. * Clear RBC_STYXCACHE_DIR for setup_runner isinstance tests, evict corrupt gz cache entries - tests/unit/test_niwrap.py::TestSetupRunner checks the concrete runner type; ensure the env var is unset so setup_runner returns the unwrapped runner. - Prepare styx cache now runs gzip -t on every .styxcache.stdout.gz and .styxcache.stderr.gz, evicting any entry with a truncated stream. Prompted by an EOFError from a half-written gz in an older cache entry that survived into a warm run. * Extend corrupt-gz eviction to all .gz files in cache shards Earlier scan only looked at .styxcache.stdout.gz / .stderr.gz, but nibabel also reads cached .nii.gz outputs directly from shard paths. A truncated cached BOLD blew up single_session_qc with the same EOFError signature. * Move rbc scratch dirs off cache shards via independent scratch root Two problems with the prior generate_exec_folder pattern: 1. It hijacked the niwrap runner's data_dir + uid + execution_counter, forcing _CacheProxyingRunner to forward those attributes through to the base runner on every access. 2. Several rbc sites then wrote sibling files via `input_file.parent / ...`. When input_file was served from a styxcache shard (which is the norm under caching), those writes landed inside the shard, racing other workers' reads and silently truncating cached files. The test_single_session_qc EOFError was a cached .nii.gz truncated this way. generate_exec_folder now owns its own root (RBC_SCRATCH_DIR override, else a process-unique tempfile.mkdtemp). Every touched call site was migrated off `input_file.parent / ...` to `generate_exec_folder(...) / ...`. Sites audited: - core/functional/resampling.py:107, 201 - core/functional/nuisance.py:112 - core/longitudinal/transform.py:145 - core/longitudinal/freesurfer.py:142 - core/metrics/reho.py:191 - core/metrics/alff.py:259 - core/metrics/standardization.py:74 - core/metrics/timeseries.py:159 distortion.py's topup.parent is a read-only lookup into the tool's own output set (no write), left as-is. Unit tests for generate_exec_folder rewritten for the new semantics: no more uid/counter-based naming, just unique scratch dirs. * Unify niwrap data_dir and rbc scratch under a single RBC_WORK_DIR root Previously generate_exec_folder had its own RBC_SCRATCH_DIR while niwrap's data_dir defaulted to the system tmp. One knob is cleaner: RBC_WORK_DIR now parents both (niwrap/, scratch/). CI points it at JOB_TMP so the per-run cleanup reclaims both trees in one sweep. * CLI --tmp-dir now parents rbc scratch too, not just niwrap data_dir
1 parent 5e35d78 commit ded2aaf

16 files changed

Lines changed: 169 additions & 103 deletions

File tree

.github/workflows/test_full.yaml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,27 @@ jobs:
5050
# Purge stale staging dirs from crashed previous runs (safe: atomic-rename commits)
5151
rm -rf "$STYXCACHE_DIR/.incoming" 2>/dev/null || true
5252
53+
# Drop entries that contain any truncated .gz (styxcache's own
54+
# stdout/stderr streams or cached .nii.gz outputs). Atomic commits
55+
# should prevent these, but crashes before commit have been observed.
56+
corrupt=0
57+
while IFS= read -r entry; do
58+
bad=0
59+
for gz in $(find "$entry" -type f -name '*.gz'); do
60+
if ! gzip -t "$gz" 2>/dev/null; then
61+
bad=1
62+
break
63+
fi
64+
done
65+
if [ "$bad" = "1" ]; then
66+
rm -rf "$entry"
67+
corrupt=$((corrupt + 1))
68+
fi
69+
done < <(find "$STYXCACHE_DIR" -mindepth 2 -maxdepth 2 -type d 2>/dev/null)
70+
if [ "$corrupt" -gt 0 ]; then
71+
echo "evicted $corrupt entries with corrupt gzip streams"
72+
fi
73+
5374
size_kb=$(du -sk "$STYXCACHE_DIR" 2>/dev/null | awk '{print $1}')
5475
size_kb=${size_kb:-0}
5576
cap_kb=$(( STYXCACHE_MAX_GB * 1024 * 1024 ))
@@ -85,6 +106,9 @@ jobs:
85106
export PYTHONPYCACHEPREFIX="$JOB_TMP/__pycache__"
86107
export COVERAGE_FILE="$JOB_TMP/.coverage"
87108
export RBC_STYXCACHE_DIR="$STYXCACHE_DIR"
109+
# Pin rbc's scratch + niwrap data_dir under JOB_TMP so the Cleanup step
110+
# reclaims them automatically. Default is a system tmpdir otherwise.
111+
export RBC_WORK_DIR="$JOB_TMP/rbc_work"
88112
89113
uv run pytest \
90114
-n 8 \

pyproject.toml

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ dependencies = [
1313
"numpy>=2.4.2",
1414
"polars>=1.38.1",
1515
"scipy>=1.17.0",
16+
"styxcache>=0.2.0,<0.3",
1617
"styxpodman>=0.1.1",
1718
"tqdm>=4.67.3"
1819
]
@@ -28,8 +29,7 @@ dev = [
2829
"pytest-cov>=7.0.0",
2930
"ruff>=0.8.1",
3031
"deptry>=0.23.0",
31-
"pytest-xdist[psutil]>=3.8.0",
32-
"styxcache>=0.2.0,<0.3"
32+
"pytest-xdist[psutil]>=3.8.0"
3333
]
3434
docs = ["pdoc>=15.0.0"]
3535

@@ -44,11 +44,6 @@ markers = [
4444
"full_pipeline: End-to-end workflow tests"
4545
]
4646

47-
[tool.deptry.per_rule_ignores]
48-
# styxcache is optional — imported via try/except so the dependency is only
49-
# needed on CI where the cache is wired up. src/rbc runs fine without it.
50-
DEP004 = ["styxcache"]
51-
5247
[tool.coverage.report]
5348
omit = ["src/rbc_resources/"]
5449

src/rbc/core/common.py

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,29 +7,20 @@
77

88
from __future__ import annotations
99

10-
import contextlib
1110
from typing import TYPE_CHECKING
1211

1312
import nibabel as nib
13+
import styxcache
1414
from niwrap import afni
1515

1616
if TYPE_CHECKING:
17-
from collections.abc import Iterator, Sequence
17+
from collections.abc import Sequence
1818
from pathlib import Path
1919

2020
from rbc.core.fileops import file_tmp_copy
2121
from rbc.core.nifti import strip_afni_volatile_metadata
2222
from rbc.core.niwrap import generate_exec_folder
2323

24-
try:
25-
from styxcache import bypass as _styxcache_bypass
26-
except ImportError: # styxcache is optional — only used on CI
27-
28-
@contextlib.contextmanager
29-
def _styxcache_bypass() -> Iterator[None]:
30-
yield
31-
32-
3324
__all__ = ["deoblique_and_reorient", "merge_3d_to_4d", "split_4d"]
3425

3526

@@ -52,11 +43,11 @@ def deoblique_and_reorient(
5243
AFNI 3dresample outputs (use ``.out_file`` for the reoriented image).
5344
"""
5445
with file_tmp_copy(in_file) as tmp_file:
55-
# 3drefit mutates in place, and styxcache 0.2.0 does not replay
46+
# 3drefit mutates in place, and styxcache does not replay
5647
# mutable-input mutations on cache hits. Bypass it so it always runs,
5748
# then strip AFNI's non-deterministic extension (timestamps + random
5849
# UUID) so the downstream cached 3dresample call keys on stable bytes.
59-
with _styxcache_bypass():
50+
with styxcache.bypass():
6051
afni.v_3drefit(in_file=tmp_file, deoblique=True)
6152
strip_afni_volatile_metadata(tmp_file)
6253
return afni.v_3dresample(

src/rbc/core/functional/nuisance.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ def bandpass_regressor_file(
109109
f_data[~freq_mask] = 0.0
110110
filtered[:, col] = np.real(ifft(f_data))[:n_tp]
111111

112-
out_path = regressor_file.parent / "regressors_filtered.1D"
112+
out_path = generate_exec_folder("regressors_filtered") / "regressors_filtered.1D"
113113
with out_path.open("w") as f:
114114
for line in header_lines:
115115
f.write(line)

src/rbc/core/functional/resampling.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ def apply_motion_transforms(
104104
)
105105
transformed_vols.append(result.output.output_image_outfile)
106106

107-
out_path = transformed_vols[0].parent / "preproc_bold.nii.gz"
107+
out_path = generate_exec_folder("preproc_bold_merge") / "preproc_bold.nii.gz"
108108
merged = merge_3d_to_4d(transformed_vols, out_path)
109109

110110
# antsApplyTransforms writes the reference image's pixdim into the output
@@ -198,7 +198,10 @@ def resample_bold_to_template(
198198
)
199199
transformed_vols.append(result.output.output_image_outfile)
200200

201-
out_path = transformed_vols[0].parent / "bold_to_template_resampled.nii.gz"
201+
out_path = (
202+
generate_exec_folder("bold_to_template_merge")
203+
/ "bold_to_template_resampled.nii.gz"
204+
)
202205
merged = merge_3d_to_4d(transformed_vols, out_path)
203206

204207
# antsApplyTransforms writes the reference (template) image's pixdim into

src/rbc/core/longitudinal/freesurfer.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from niwrap import freesurfer
1717

1818
from rbc.core.fsl2itk import mat_to_itk
19+
from rbc.core.niwrap import generate_exec_folder
1920

2021
if TYPE_CHECKING:
2122
from collections.abc import Sequence
@@ -139,7 +140,7 @@ def fs_to_itk_xfm(
139140
for ses, source, in_xfm in zip(sessions, sources, in_xfms, strict=True):
140141
fsl_fname = in_xfm.with_suffix(".mat").name
141142
lta = freesurfer.lta_convert(in_lta=in_xfm, out_fsl=fsl_fname)
142-
itk_path = in_xfm.parent / itk_filename(sub, ses)
143+
itk_path = generate_exec_folder("itk_xfm") / itk_filename(sub, ses)
143144
mat_to_itk(
144145
mat=lta.root / fsl_fname,
145146
reference=reference,

src/rbc/core/longitudinal/transform.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from rbc.core.common import merge_3d_to_4d, split_4d
1010
from rbc.core.functional.resampling import _restore_tr
11+
from rbc.core.niwrap import generate_exec_folder
1112

1213
if TYPE_CHECKING:
1314
from pathlib import Path
@@ -142,7 +143,10 @@ def _transform_4d_chunked(in_file: Path, template: Path, xfm: Path) -> Path:
142143
)
143144
transformed_vols.append(result.output.output_image_outfile)
144145

145-
out_path = transformed_vols[0].parent / "bold_to_longitudinal.nii.gz"
146+
out_path = (
147+
generate_exec_folder("bold_to_longitudinal_merge")
148+
/ "bold_to_longitudinal.nii.gz"
149+
)
146150
merged = merge_3d_to_4d(transformed_vols, out_path)
147151
_restore_tr(merged, in_file)
148152
return merged

src/rbc/core/metrics/alff.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import numpy as np
1818

1919
from rbc.core.nifti import Volume
20+
from rbc.core.niwrap import generate_exec_folder
2021

2122
if TYPE_CHECKING:
2223
from typing import Literal
@@ -256,7 +257,8 @@ def compute_alff(
256257

257258
stem = in_file.name.split(".nii")[0]
258259
if out_file is None:
259-
alff_path = in_file.parent / f"{stem}_alff.nii.gz"
260+
out_dir = generate_exec_folder("alff")
261+
alff_path = out_dir / f"{stem}_alff.nii.gz"
260262
else:
261263
alff_path = Path(out_file)
262264

src/rbc/core/metrics/reho.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from scipy.stats import rankdata
1616

1717
from rbc.core.nifti import Volume
18+
from rbc.core.niwrap import generate_exec_folder
1819

1920
if TYPE_CHECKING:
2021
from typing import Literal
@@ -188,7 +189,7 @@ def compute_reho(
188189

189190
if out_file is None:
190191
stem = in_file.name.split(".nii")[0]
191-
out_file = in_file.parent / f"{stem}_reho.nii.gz"
192+
out_file = generate_exec_folder("reho") / f"{stem}_reho.nii.gz"
192193
out_file = Path(out_file)
193194

194195
bold.derive(reho_map).save(out_file)

src/rbc/core/metrics/standardization.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
import numpy as np
1313

14+
from rbc.core.niwrap import generate_exec_folder
15+
1416

1517
def zscore(data: np.ndarray, mask: np.ndarray) -> np.ndarray:
1618
"""Z-score a 3D map within a brain mask.
@@ -71,7 +73,7 @@ def compute_zscore(
7173

7274
if out_file is None:
7375
stem = in_file.name.split(".nii")[0]
74-
out_file = in_file.parent / f"{stem}_zscored.nii.gz"
76+
out_file = generate_exec_folder("zscore") / f"{stem}_zscored.nii.gz"
7577
out_file = Path(out_file)
7678

7779
nib.nifti1.Nifti1Image(zscored, img.affine, img.header).to_filename(str(out_file))

0 commit comments

Comments
 (0)