88# Copyright (c) 2016, Shay Palachy <shaypal5@gmail.com>
99import os
1010import pickle # for local caching
11+ import threading
12+ import time
1113from datetime import datetime
1214from typing import Any , Dict , Optional , Tuple , Union
1315
@@ -26,44 +28,43 @@ class _PickleCore(_BaseCore):
2628 """The pickle core class for cachier."""
2729
2830 class CacheChangeHandler (PatternMatchingEventHandler ):
29- """Handles cache- file modification events ."""
31+ """Handler for cache file changes ."""
3032
3133 def __init__ (self , filename , core , key ):
32- PatternMatchingEventHandler .__init__ (
33- self ,
34- patterns = ["*" + filename ],
35- ignore_patterns = None ,
36- ignore_directories = True ,
37- case_sensitive = False ,
34+ super ().__init__ (
35+ patterns = [f"*{ filename } *" ], ignore_patterns = [], ignore_directories = False
3836 )
37+ self .filename = filename
3938 self .core = core
4039 self .key = key
41- self .observer = None
4240 self .value = None
41+ self .observer = None
4342
4443 def inject_observer (self , observer ) -> None :
45- """Inject the observer running this handler ."""
44+ """Inject the observer instance ."""
4645 self .observer = observer
4746
4847 def _check_calculation (self ) -> None :
49- entry = self . core . get_entry_by_key ( self . key , True )[ 1 ]
48+ """Check if calculation is complete."""
5049 try :
51- if not entry ._processing :
52- # print('stopping observer!')
50+ if self .core .separate_files :
51+ entry = self .core ._load_cache_by_key (self .key )
52+ else :
53+ with self .core .lock :
54+ entry = self .core .get_cache_dict ().get (self .key )
55+ if entry and not entry ._processing :
5356 self .value = entry .value
54- self .observer .stop ()
55- # else:
56- # print('NOT stopping observer... :(')
57- except AttributeError : # catching entry being None
58- self .value = None
59- self .observer .stop ()
57+ if self .observer :
58+ self .observer .stop ()
59+ except Exception :
60+ pass
6061
6162 def on_created (self , event ) -> None :
62- """A Watchdog Event Handler method .""" # noqa: D401
63- self ._check_calculation () # pragma: no cover
63+ """Handle file creation events ."""
64+ self ._check_calculation ()
6465
6566 def on_modified (self , event ) -> None :
66- """A Watchdog Event Handler method .""" # noqa: D401
67+ """Handle file modification events ."""
6768 self ._check_calculation ()
6869
6970 def __init__ (
@@ -74,16 +75,20 @@ def __init__(
7475 separate_files : Optional [bool ],
7576 wait_for_calc_timeout : Optional [int ],
7677 ):
77- super ().__init__ (hash_func , wait_for_calc_timeout )
78- self ._cache_dict : Dict [str , CacheEntry ] = {}
79- self .reload = _update_with_defaults (pickle_reload , "pickle_reload" )
80- self .cache_dir = os .path .expanduser (
81- _update_with_defaults (cache_dir , "cache_dir" )
82- )
83- self .separate_files = _update_with_defaults (
84- separate_files , "separate_files"
78+ super ().__init__ (
79+ hash_func = hash_func ,
80+ wait_for_calc_timeout = wait_for_calc_timeout ,
8581 )
82+ self .cache_dir = str (cache_dir ) if cache_dir else "~/.cachier"
83+ self .cache_dir = os .path .expanduser (self .cache_dir )
84+ os .makedirs (self .cache_dir , exist_ok = True )
85+ self .separate_files = separate_files
86+ self .reload = pickle_reload
87+ self ._cache_dict : Optional [Dict [str , CacheEntry ]] = None
8688 self ._cache_used_fpath = ""
89+ # Observer cache to prevent inotify instance exhaustion
90+ self ._observer_cache : Dict [str , Observer ] = {}
91+ self ._observer_lock = threading .Lock ()
8792
8893 @property
8994 def cache_fname (self ) -> str :
@@ -256,29 +261,111 @@ def mark_entry_not_calculated(self, key: str) -> None:
256261 cache [key ]._processing = False
257262 self ._save_cache (cache )
258263
264+ def _get_or_create_observer (self , key : str ) -> Observer :
265+ """Get an existing observer for the key or create a new one."""
266+ with self ._observer_lock :
267+ if key in self ._observer_cache :
268+ observer = self ._observer_cache [key ]
269+ if observer .is_alive ():
270+ return observer
271+ else :
272+ # Clean up dead observer
273+ del self ._observer_cache [key ]
274+
275+ # Create new observer
276+ observer = Observer ()
277+ self ._observer_cache [key ] = observer
278+ return observer
279+
280+ def _cleanup_observer (self , key : str ) -> None :
281+ """Clean up observer for the given key."""
282+ with self ._observer_lock :
283+ if key in self ._observer_cache :
284+ observer = self ._observer_cache [key ]
285+ try :
286+ if observer .is_alive ():
287+ observer .stop ()
288+ observer .join (timeout = 1.0 )
289+ except Exception :
290+ pass # Ignore cleanup errors
291+ del self ._observer_cache [key ]
292+
259293 def wait_on_entry_calc (self , key : str ) -> Any :
294+ """Wait for entry calculation to complete with inotify protection."""
260295 if self .separate_files :
261296 entry = self ._load_cache_by_key (key )
262297 filename = f"{ self .cache_fname } _{ key } "
263298 else :
264299 with self .lock :
265- entry = self .get_cache_dict ()[ key ]
300+ entry = self .get_cache_dict (). get ( key )
266301 filename = self .cache_fname
302+
267303 if entry and not entry ._processing :
268304 return entry .value
305+
306+ # Try to use inotify-based waiting
307+ try :
308+ return self ._wait_with_inotify (key , filename )
309+ except OSError as e :
310+ if "inotify instance limit reached" in str (e ):
311+ # Fall back to polling if inotify limit is reached
312+ return self ._wait_with_polling (key )
313+ else :
314+ raise
315+ except Exception :
316+ # For any other exception, fall back to polling
317+ return self ._wait_with_polling (key )
318+
319+ def _wait_with_inotify (self , key : str , filename : str ) -> Any :
320+ """Wait for calculation using inotify (original method with fixes)."""
269321 event_handler = _PickleCore .CacheChangeHandler (
270322 filename = filename , core = self , key = key
271323 )
272- observer = Observer ()
324+
325+ observer = self ._get_or_create_observer (key )
273326 event_handler .inject_observer (observer )
274- observer .schedule (event_handler , path = self .cache_dir , recursive = True )
275- observer .start ()
327+
328+ try :
329+ observer .schedule (event_handler , path = self .cache_dir , recursive = True )
330+ if not observer .is_alive ():
331+ observer .start ()
332+
333+ time_spent = 0
334+ while observer .is_alive ():
335+ observer .join (timeout = 1.0 )
336+ time_spent += 1
337+ self .check_calc_timeout (time_spent )
338+
339+ # Check if calculation is complete
340+ if event_handler .value is not None :
341+ break
342+
343+ return event_handler .value
344+ finally :
345+ # Always cleanup the observer
346+ self ._cleanup_observer (key )
347+
348+ def _wait_with_polling (self , key : str ) -> Any :
349+ """Fallback method using polling instead of inotify."""
276350 time_spent = 0
277- while observer . is_alive () :
278- observer . join ( timeout = 1.0 )
351+ while True :
352+ time . sleep ( 1 ) # Poll every 1 second (matching other cores )
279353 time_spent += 1
280- self .check_calc_timeout (time_spent )
281- return event_handler .value
354+
355+ try :
356+ if self .separate_files :
357+ entry = self ._load_cache_by_key (key )
358+ else :
359+ with self .lock :
360+ entry = self .get_cache_dict ().get (key )
361+
362+ if entry and not entry ._processing :
363+ return entry .value
364+
365+ self .check_calc_timeout (time_spent )
366+ except Exception :
367+ # Continue polling even if there are errors
368+ pass
282369
283370 def clear_cache (self ) -> None :
284371 if self .separate_files :
0 commit comments