33from collections import defaultdict
44from dataclasses import dataclass
55from datetime import datetime , timedelta , timezone
6+ from functools import cache
7+ from importlib import import_module
68from pathlib import Path
9+ from types import ModuleType
710
811from semble .cache import resolve_cache_folder
912from semble .types import CallType , SearchResult
@@ -37,6 +40,15 @@ class SavingsSummary:
3740 call_type_counts : dict [str , int ]
3841
3942
43+ @cache
44+ def _import_fcntl () -> ModuleType | None :
45+ """Return fcntl when available, otherwise None."""
46+ try :
47+ return import_module ("fcntl" )
48+ except ImportError : # pragma: no cover
49+ return None
50+
51+
4052def save_search_stats (
4153 results : list [SearchResult ],
4254 call_type : CallType ,
@@ -59,12 +71,12 @@ def save_search_stats(
5971 stats_file = _get_stats_file ()
6072 stats_file .parent .mkdir (parents = True , exist_ok = True )
6173 with stats_file .open ("a" ) as f :
74+ fcntl = _import_fcntl ()
6275 try :
63- import fcntl
64-
65- fcntl .flock (f , fcntl .LOCK_EX | fcntl .LOCK_NB )
66- except ImportError : # pragma: no cover
67- pass # Windows has no fcntl, write unlocked
76+ if fcntl is not None :
77+ fcntl .flock (f , fcntl .LOCK_EX | fcntl .LOCK_NB )
78+ except BlockingIOError : # pragma: no cover
79+ return # another process holds the lock; skip this record
6880 except OSError : # pragma: no cover
6981 return # lock contention or unsupported filesystem; skip
7082 f .write (json .dumps (record ) + "\n " )
0 commit comments