diff --git a/src/semble/stats.py b/src/semble/stats.py index bebc988..12f6c86 100644 --- a/src/semble/stats.py +++ b/src/semble/stats.py @@ -3,7 +3,10 @@ from collections import defaultdict from dataclasses import dataclass from datetime import datetime, timedelta, timezone +from functools import cache +from importlib import import_module from pathlib import Path +from types import ModuleType from semble.cache import resolve_cache_folder from semble.types import CallType, SearchResult @@ -37,6 +40,15 @@ class SavingsSummary: call_type_counts: dict[str, int] +@cache +def _import_fcntl() -> ModuleType | None: + """Return fcntl when available, otherwise None.""" + try: + return import_module("fcntl") + except ImportError: # pragma: no cover + return None + + def save_search_stats( results: list[SearchResult], call_type: CallType, @@ -59,6 +71,14 @@ def save_search_stats( stats_file = _get_stats_file() stats_file.parent.mkdir(parents=True, exist_ok=True) with stats_file.open("a") as f: + fcntl = _import_fcntl() + try: + if fcntl is not None: + fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: # pragma: no cover + return # another process holds the lock; skip this record + except OSError: # pragma: no cover + return # lock contention or unsupported filesystem; skip f.write(json.dumps(record) + "\n") except OSError: pass diff --git a/uv.lock b/uv.lock index d0db5aa..63f2277 100644 --- a/uv.lock +++ b/uv.lock @@ -10,7 +10,7 @@ resolution-markers = [ [options] exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. -exclude-newer-span = "P3D" +exclude-newer-span = "P1W" [[package]] name = "annotated-doc"