55# Copyright the MNE-Python contributors.
66
77import hashlib
8- import json
98import os
109import pickle
11- import stat
1210from pathlib import Path
1311
1412import numpy as np
2018_RAW_PRELOAD_LOCK_TIMEOUT = 300.0
2119
2220
23- def _raw_preload_open_regular ( path ):
24- """Open and validate a regular cache file ."""
25- file = open ( path , "rb" )
26- try :
27- if not stat . S_ISREG ( os . fstat ( file . fileno ()). st_mode ):
28- raise OSError ( "Decoded data cache entries must be regular files" )
29- except Exception :
30- file . close ( )
31- raise
32- return file
33-
21+ def _raw_preload_cache_info ( raw ):
22+ """Return the cache path and decoded array description ."""
23+ cache_root = get_config ( "MNE_CACHE_DIR" , None )
24+ if cache_root is None :
25+ raise ValueError (
26+ 'preload="auto" requires a configured cache directory; use '
27+ "mne.set_cache_dir(...) first"
28+ )
29+ cache_dir = Path ( cache_root ). expanduser (). resolve ()
30+ cache_dir = cache_dir / f"raw-preload-v { _RAW_PRELOAD_CACHE_VERSION } "
31+ cache_dir . mkdir ( mode = 0o700 , parents = True , exist_ok = True )
3432
35- def _raw_preload_source_signature (raw ):
36- """Return filesystem identities for the source data files."""
3733 sources = []
3834 for filename in raw .filenames :
3935 if filename is None :
@@ -42,206 +38,71 @@ def _raw_preload_source_signature(raw):
4238 "or an explicit memory-map path"
4339 )
4440 path = Path (filename ).resolve (strict = True )
45- if path .suffix == ".gz" :
46- raise ValueError (
47- 'preload="auto" supports only uncompressed source files; use '
48- "preload=True for compressed files"
49- )
5041 result = path .stat ()
51- if not stat .S_ISREG (result .st_mode ):
52- raise OSError ("Raw source data must be regular files" )
53- # ponytail: hash contents only if path, size, and mtime prove insufficient.
5442 sources .append ((str (path ), int (result .st_size ), int (result .st_mtime_ns )))
55- return sources
56-
5743
58- def _raw_preload_cache_dir (cache_root = None ):
59- """Resolve and validate the managed cache directory."""
60- if cache_root is None :
61- cache_root = get_config ("MNE_CACHE_DIR" , None )
62- if cache_root is None :
63- raise ValueError (
64- 'preload="auto" requires a configured cache directory; use '
65- "mne.set_cache_dir(...) first"
66- )
67- cache_root = Path (cache_root ).expanduser ().resolve ()
68- cache_dir = cache_root / f"raw-preload-v{ _RAW_PRELOAD_CACHE_VERSION } "
69- cache_dir .mkdir (mode = 0o700 , parents = True , exist_ok = True )
70- if cache_dir .is_symlink () or not cache_dir .is_dir ():
71- raise OSError (f"Decoded data cache must be a regular directory: { cache_dir } " )
72- return cache_dir
73-
74-
75- def _raw_preload_cache_info (raw ):
76- """Return the managed cache location and expected array description."""
77- cache_dir = _raw_preload_cache_dir ()
78- sources = _raw_preload_source_signature (raw )
7944 dtype = np .dtype (raw ._dtype )
8045 shape = (int (raw .info ["nchan" ]), int (raw .n_times ))
81- identity = dict (
82- version = _RAW_PRELOAD_CACHE_VERSION ,
83- mne_version = MNE_VERSION ,
84- reader = (type (raw ).__module__ , type (raw ).__qualname__ ),
85- sources = sources ,
86- raw_extras = raw ._raw_extras ,
87- read_picks = raw ._read_picks ,
88- cals = raw ._cals ,
89- projector = raw ._projector ,
90- compensator = raw ._comp ,
91- first_samps = raw ._first_samps ,
92- last_samps = raw ._last_samps ,
93- dtype = dtype .str ,
94- shape = shape ,
46+ identity = (
47+ _RAW_PRELOAD_CACHE_VERSION ,
48+ MNE_VERSION ,
49+ type (raw ).__module__ ,
50+ type (raw ).__qualname__ ,
51+ sources ,
52+ raw ._raw_extras ,
53+ raw ._cals ,
54+ dtype .str ,
55+ shape ,
9556 )
9657 try :
97- serialized = pickle .dumps (identity , protocol = 5 )
58+ key = hashlib . sha256 ( pickle .dumps (identity , protocol = 5 )). hexdigest ( )
9859 except Exception as exc :
9960 raise ValueError (
10061 f'preload="auto" cannot identify this { type (raw ).__name__ } source'
10162 ) from exc
102- key = hashlib .sha256 (serialized ).hexdigest ()
103- return cache_dir , key , sources , shape , dtype
104-
105-
106- def _raw_preload_generation_valid (name , key ):
107- """Check that a manifest generation is a managed basename."""
108- prefix = f"{ key } ."
109- suffix = ".data"
110- if (
111- not isinstance (name , str )
112- or not name .startswith (prefix )
113- or not name .endswith (suffix )
114- ):
115- return False
116- token = name [len (prefix ) : - len (suffix )]
117- return len (token ) == 32 and all (char in "0123456789abcdef" for char in token )
63+ return cache_dir / f"{ key } .data" , sources , shape , dtype
11864
11965
120- def _raw_preload_read_manifest (cache_dir , key ):
121- """Read one manifest through its validated handle."""
122- path = cache_dir / f"{ key } .json"
123- with _raw_preload_open_regular (path ) as file :
124- if os .fstat (file .fileno ()).st_size > 4096 :
125- raise ValueError ("Oversized Raw preload manifest" )
126- return json .loads (file .read ().decode ("utf-8" ))
127-
128-
129- def _raw_preload_cache_read (raw , cache_dir , key , sources , shape , dtype ):
130- """Read and validate one managed decoded-data cache entry."""
66+ def _raw_preload_cache_read (path , shape , dtype ):
67+ """Map a complete decoded-data cache entry."""
13168 try :
132- manifest = _raw_preload_read_manifest (cache_dir , key )
133- if set (manifest ) != {"generation" }:
134- return None
13569 nbytes = int (np .prod (shape , dtype = np .int64 )) * dtype .itemsize
136- if not _raw_preload_generation_valid ( manifest [ "generation" ], key ) :
70+ if path . stat (). st_size != nbytes :
13771 return None
138- generation = cache_dir / manifest ["generation" ]
139- with _raw_preload_open_regular (generation ) as file :
140- if os .fstat (file .fileno ()).st_size != nbytes :
141- return None
142- data = np .memmap (file , mode = "c" , dtype = dtype , shape = shape )
143- data .filename = str (generation ) # ty: ignore[invalid-assignment]
144- if _raw_preload_source_signature (raw ) != sources :
145- data ._mmap .close () # ty: ignore[unresolved-attribute] # memmap private
146- return None
147- except (OSError , TypeError , ValueError , json .JSONDecodeError ):
72+ return np .memmap (path , mode = "c" , dtype = dtype , shape = shape )
73+ except OSError :
14874 return None
149- logger .info (f"Reusing decoded data from { generation } " )
150- return data
151-
152-
153- def _raw_preload_scavenge_key (cache_dir , key ):
154- """Remove abandoned temporary and unreferenced same-key generations."""
155- referenced = None
156- try :
157- manifest = _raw_preload_read_manifest (cache_dir , key )
158- candidate = manifest .get ("generation" )
159- if _raw_preload_generation_valid (candidate , key ):
160- referenced = candidate
161- except (OSError , TypeError , ValueError , json .JSONDecodeError ):
162- pass
163- patterns = (f".{ key } .*.tmp" , f"{ key } .*.data" )
164- for pattern in patterns :
165- for path in cache_dir .glob (pattern ):
166- if path .name == referenced :
167- continue
168- try :
169- path .unlink ()
170- except OSError :
171- logger .debug (
172- f"Could not remove abandoned Raw preload cache file { path } "
173- )
174-
175-
176- def _raw_preload_cache_create (raw , cache_dir , key , sources , shape , dtype ):
177- """Decode, durably publish, and reopen an immutable cache generation."""
178- token = os .urandom (16 ).hex ()
179- generation_name = f"{ key } .{ token } .data"
180- generation = cache_dir / generation_name
181- temporary = cache_dir / f".{ generation_name } .tmp"
182- manifest_temporary = None
183- manifest_published = False
184- nbytes = int (np .prod (shape , dtype = np .int64 )) * dtype .itemsize
185- descriptor = os .open (temporary , os .O_CREAT | os .O_EXCL | os .O_RDWR , 0o600 )
186- try :
187- with os .fdopen (descriptor , "r+b" ) as file :
188- file .truncate (nbytes )
189- data = np .memmap (file , mode = "r+" , dtype = dtype , shape = shape )
190- try :
191- raw ._read_segment (data_buffer = data )
192- data .flush ()
193- finally :
194- data ._mmap .close () # ty: ignore[unresolved-attribute] # memmap private
195- os .fsync (file .fileno ())
196- if _raw_preload_source_signature (raw ) != sources :
197- raise RuntimeError (
198- "Source data changed while decoded cache was created; retry"
199- )
200- os .replace (temporary , generation )
201- manifest = dict (generation = generation_name )
202- manifest_temporary = cache_dir / f".{ key } .{ os .urandom (16 ).hex ()} .json.tmp"
203- descriptor = os .open (
204- manifest_temporary , os .O_CREAT | os .O_EXCL | os .O_WRONLY , 0o600
205- )
206- with os .fdopen (descriptor , "w" , encoding = "utf-8" ) as file :
207- json .dump (manifest , file , sort_keys = True , separators = ("," , ":" ))
208- file .flush ()
209- os .fsync (file .fileno ())
210- os .replace (manifest_temporary , cache_dir / f"{ key } .json" )
211- manifest_published = True
212- with _raw_preload_open_regular (generation ) as file :
213- result = np .memmap (file , mode = "c" , dtype = dtype , shape = shape )
214- result .filename = str (generation ) # ty: ignore[invalid-assignment]
215- return result
216- finally :
217- for path in (temporary , manifest_temporary ):
218- if path is not None :
219- try :
220- path .unlink (missing_ok = True )
221- except OSError :
222- pass
223- if not manifest_published :
224- try :
225- generation .unlink (missing_ok = True )
226- except OSError :
227- pass
22875
22976
23077def _raw_preload_auto (raw ):
23178 """Reuse or create an automatic decoded-data cache entry."""
232- cache_dir , key , sources , shape , dtype = _raw_preload_cache_info (raw )
233- key_lock = cache_dir / f"{ key } .lock"
234- data = _raw_preload_cache_read (raw , cache_dir , key , sources , shape , dtype )
79+ path , sources , shape , dtype = _raw_preload_cache_info (raw )
80+ data = _raw_preload_cache_read (path , shape , dtype )
23581 if data is not None :
82+ logger .info (f"Reusing decoded data from { path } " )
23683 return data
84+
23785 # Importing filelock is measurable, so keep it off the cache-hit path.
23886 filelock = _soft_import ("filelock" , "locking the decoded-data cache" )
239-
240- with filelock .FileLock (key_lock , timeout = _RAW_PRELOAD_LOCK_TIMEOUT ):
241- _raw_preload_scavenge_key (cache_dir , key )
242- data = _raw_preload_cache_read (raw , cache_dir , key , sources , shape , dtype )
87+ with filelock .FileLock (f"{ path } .lock" , timeout = _RAW_PRELOAD_LOCK_TIMEOUT ):
88+ data = _raw_preload_cache_read (path , shape , dtype )
24389 if data is None :
244- logger .info (f"Creating decoded data cache in { cache_dir } " )
245- data = _raw_preload_cache_create (raw , cache_dir , key , sources , shape , dtype )
246- _raw_preload_scavenge_key (cache_dir , key )
90+ logger .info (f"Creating decoded data cache in { path .parent } " )
91+ temporary = path .with_suffix (".tmp" )
92+ try :
93+ temporary .unlink (missing_ok = True )
94+ data = np .memmap (temporary , mode = "w+" , dtype = dtype , shape = shape )
95+ try :
96+ raw ._read_segment (data_buffer = data )
97+ data .flush ()
98+ finally :
99+ data ._mmap .close () # ty: ignore[unresolved-attribute]
100+ if _raw_preload_cache_info (raw )[1 ] != sources :
101+ raise RuntimeError (
102+ "Source data changed while decoded cache was created; retry"
103+ )
104+ os .replace (temporary , path )
105+ finally :
106+ temporary .unlink (missing_ok = True )
107+ data = _raw_preload_cache_read (path , shape , dtype )
247108 return data
0 commit comments