From c774b1bec5737b932681f255fe6046539c620e2f Mon Sep 17 00:00:00 2001 From: Sam Holland Date: Mon, 28 Sep 2026 12:11:33 +0100 Subject: [PATCH 1/2] fix(schemas): limit subvolumes schema cache - Modified schemas/subvolumes.py to drop cache entries when more than 64 open, to prevent too many files open errors. - Modified tests/unit/test_schemas.py to add test for cache popping. --- src/stamp/schemas/subvolumes.py | 11 +++++++++-- tests/unit/test_schemas.py | 13 +++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/stamp/schemas/subvolumes.py b/src/stamp/schemas/subvolumes.py index cf16343..2d4f9ce 100644 --- a/src/stamp/schemas/subvolumes.py +++ b/src/stamp/schemas/subvolumes.py @@ -4,28 +4,35 @@ # Import external dependencies import numpy as np +from collections import OrderedDict from collections.abc import Iterator, Sequence from dataclasses import dataclass, field from pathlib import Path +# _MAX_OPEN_MMAPS: cap on concurrently open per-tomogram mmaps, well under default OS fd limits (macOS ulimit -n defaults to 256) +_MAX_OPEN_MMAPS = 64 + # Subvolumes: particle -> (tomogram_id, row) index over mmap'd per-tomogram .npy caches @dataclass(frozen=True) class Subvolumes: cache_dir: Path index: list[tuple[str, int]] # index[i] = (tomogram_id, row) for particle i, in extraction order box_voxels: int - _mmaps: dict[str, np.ndarray] = field(default_factory=dict, repr=False, compare=False) + _mmaps: OrderedDict[str, np.ndarray] = field(default_factory=OrderedDict, repr=False, compare=False) def __len__(self) -> int: return len(self.index) - # _mmap_for: open (or reuse) the mmap for one tomogram's cache file + # _mmap_for: open (or reuse) the mmap for one tomogram's cache file, evicting the least-recently-used entry once the cache is full so open file handles stay bounded def _mmap_for(self, tomogram_id: str) -> np.ndarray: cached = self._mmaps.get(tomogram_id) if cached is not None: + self._mmaps.move_to_end(tomogram_id) return cached array = np.load(self.cache_dir / f'{tomogram_id}.npy', mmap_mode='r') self._mmaps[tomogram_id] = array # mutating the dict's contents, not the frozen field itself + if len(self._mmaps) > _MAX_OPEN_MMAPS: + self._mmaps.popitem(last=False) # drop oldest; np.memmap closes its file on garbage collection return array def __getitem__(self, i: int) -> np.ndarray: diff --git a/tests/unit/test_schemas.py b/tests/unit/test_schemas.py index bc5fcc8..07dc180 100644 --- a/tests/unit/test_schemas.py +++ b/tests/unit/test_schemas.py @@ -178,6 +178,19 @@ def test_iter_batches_covers_every_index_grouped_by_tomogram(self, tmp_path): for i in seen: np.testing.assert_array_equal(seen[i], subvols[i]) + def test_mmap_cache_stays_bounded_across_many_tomograms(self, tmp_path): + box_voxels = 2 + n_tomograms = 200 # well past _MAX_OPEN_MMAPS + index: list[tuple[str, int]] = [] + for t in range(n_tomograms): + tomogram_id = f'tomo{t}' + _write_cache(tmp_path, tomogram_id, 1, box_voxels, offset=t * 100) + index.append((tomogram_id, 0)) + subvols = Subvolumes(tmp_path, index, box_voxels) + for i in range(len(subvols)): + _ = subvols[i] # touch every tomogram once, sequentially + assert len(subvols._mmaps) <= 64 + def test_write_rolled_produces_a_readable_subvols(self, tmp_path): box_voxels = 2 _write_cache(tmp_path, 'tomoA', 3, box_voxels) From c2b0d464c141368f5eecd7b921b1586bff4c4c6a Mon Sep 17 00:00:00 2001 From: Sam Holland Date: Mon, 28 Sep 2026 12:11:47 +0100 Subject: [PATCH 2/2] chore: update stamp version to v0.11.2 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1efdbc6..f5dc65c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "stamp" -version = "0.11.1" +version = "0.11.2" description = "A workflow for Sub-Tomogram Averaging Membrane Proteins" readme = "README.md" authors = [ diff --git a/uv.lock b/uv.lock index 2b4d892..9dd97bd 100644 --- a/uv.lock +++ b/uv.lock @@ -786,7 +786,7 @@ wheels = [ [[package]] name = "stamp" -version = "0.11.1" +version = "0.11.2" source = { editable = "." } dependencies = [ { name = "loguru" },