Source code for aimmd.cache.mda
"""
aimmd.cache.mda
===============
MDAnalysis reader cache for robust trajectory opening.
AIMMD frequently needs to open trajectories repeatedly, potentially while they
are being written by other processes. Some MDAnalysis formats maintain
auxiliary offset and lock files (e.g., XTC/TRR offsets), which can become stale
and lead to hangs or errors.
This module mitigates that by:
- removing MDAnalysis-generated offset/lock files before opening,
- opening with `refresh_offsets=True`,
- detecting how many frames are safely readable,
- returning only the safe prefix of the trajectory.
Integration
-----------
This cache is constructed during :func:`aimmd._init.initialize` and stored as
``aimmd._config.MDA_CACHE`` for global reuse.
Notes
-----
This cache is process-local. It does not coordinate between processes beyond
filesystem-level effects (offset/lock file removal).
"""
# external
import os
import time
from MDAnalysis.coordinates.core import reader as Reader
# aimmd imports
from .base import AbstractCache
from ..core.utils import extract_folder_and_name
[docs]
def remove_offset_files(fname):
"""
Remove MDAnalysis offset and lock files for a given trajectory file.
Parameters
----------
fname : str
Trajectory file path.
Side Effects
------------
Deletes auxiliary files next to the trajectory (if present), typically:
- `.<name>_offsets.npz`
- `.<name>_offsets.lock`
Notes
-----
The removal is performed in a loop until both files are absent. This
preserves the current behavior under concurrent creation/deletion.
"""
# Avoid MDAnalysis loading to be stuck due to offsets.
folder, name = extract_folder_and_name(fname)
fname1 = f'{folder}/.{name}_offsets.npz'
fname2 = f'{folder}/.{name}_offsets.lock'
while os.path.exists(fname1) or os.path.exists(fname2):
if os.path.exists(fname1):
os.remove(fname1)
if os.path.exists(fname2):
os.remove(fname2)
[docs]
def count_safe_frames(reader):
"""
Determine how many frames are safely readable from an MDAnalysis reader.
Parameters
----------
reader : object
An opened MDAnalysis reader supporting `__len__` and random access
via `reader[i]`.
Returns
-------
int
The largest `n` such that frames `[0 .. n-1]` are readable.
Raises
------
RuntimeError
If no readable frame can be found.
Notes
-----
Algorithm (preserved):
- Start from `len(reader)`.
- Try to access the last frame.
- On `(StopIteration, EOFError, OSError)`, decrement and retry.
"""
n_frames = len(reader)
while n_frames:
try:
reader[n_frames - 1]
return n_frames
except (StopIteration, EOFError, OSError):
n_frames -= 1
if not n_frames:
raise RuntimeError("No readable frames found")
[docs]
class MDAReaderCache(AbstractCache):
"""
Cache for MDAnalysis readers.
Behavior
--------
- Validates file exists and extension is supported.
- Removes stale offset/lock files.
- Retries opening multiple times.
- Returns a reader sliced to only safe frames.
Attributes
----------
max_size : int
Size budget (currently a placeholder heuristic).
Notes
-----
`max_size` is not a byte-accurate budget for readers; it is used only to
limit how many objects remain in the cache (heuristically).
"""
max_size = 48 * 500 # ~500 Readers open
def _open(self, fname, ntries=10):
"""
Open a trajectory robustly and return a reader containing only safe frames.
Parameters
----------
fname : str
Trajectory/topology filename.
ntries : int, default 10
Number of retry attempts.
Returns
-------
object
An MDAnalysis reader (potentially sliced) containing only readable frames.
Raises
------
FileNotFoundError
If `fname` does not exist.
TypeError
If the file extension is not supported.
RuntimeError
If opening fails after all retries.
Notes
-----
The reader is created with `refresh_offsets=True` to force MDAnalysis to
rebuild offsets after auxiliary files are removed.
"""
if not os.path.exists(fname):
raise FileNotFoundError(f"{fname!r} does not exist")
if not fname.endswith(('.trr', '.xtc', '.gro', '.pdb', '.dcd')):
raise TypeError(f"{fname!r} extension not supported")
# Keep last exception message for diagnostics.
exception = ''
for _ in range(ntries):
try:
# Defensive cleanup for MDAnalysis-generated offset/lock files.
remove_offset_files(fname)
# Open reader with refreshed offsets.
reader = Reader(fname, refresh_offsets=True)
# Determine safe readable prefix.
n_safe = count_safe_frames(reader)
return reader[:n_safe]
except Exception as exception:
exception = str(exception)
# Backoff to tolerate concurrent writes/renames.
time.sleep(1.0)
raise RuntimeError(f"Could not open {fname!r} safely ({exception})")
def _close(self, instance):
"""
Close a cached MDAnalysis reader instance.
Parameters
----------
instance : object
Reader to close.
Side Effects
------------
Releases file handles associated with the reader.
"""
instance.close()