diff --git a/src/session_recall/metadocs/cli.py b/src/session_recall/metadocs/cli.py index d087c5e..da92358 100644 --- a/src/session_recall/metadocs/cli.py +++ b/src/session_recall/metadocs/cli.py @@ -89,6 +89,8 @@ def run(args: argparse.Namespace) -> int: return 0 if cmd == "run": + import os as _os + from .run import acquire_lock distiller = make_distiller(cfg.engine) if distiller is None: print(f"unknown engine {cfg.engine!r} in metadocs.json") @@ -96,11 +98,18 @@ def run(args: argparse.Namespace) -> int: if not app_config.DB_PATH.exists(): print(f"no index at {app_config.DB_PATH} — run: session-recall index") return 1 + lock_fd = acquire_lock(app_config.DATA_DIR) + if lock_fd is None: + # exit 0 on purpose: the overlap is expected (nightly job vs a long + # manual backfill), and the other run is doing this run's work + print("another meta docs run is already in progress — stepping aside") + return 0 db = open_index(app_config.DB_PATH) try: report = run_once(cfg, db, distiller) finally: db.close() + _os.close(lock_fd) print(report.summary()) return 1 if report.blocked else 0 diff --git a/src/session_recall/metadocs/run.py b/src/session_recall/metadocs/run.py index e044ad5..4e0e7dc 100644 --- a/src/session_recall/metadocs/run.py +++ b/src/session_recall/metadocs/run.py @@ -10,6 +10,8 @@ Other projects still run; each project that changed gets its own commit. """ +import fcntl +import os from dataclasses import dataclass, field from pathlib import Path @@ -19,6 +21,22 @@ from .repo import commit, current_docs, ensure_repo, write_docs +def acquire_lock(data_dir: Path) -> int | None: + """One run at a time. Runs are hours-long during a backfill, so the + nightly launchd job WILL overlap a manual run sooner or later — and two + runs would race each other over watermarks and the git repo. flock, not a + pid file: the lock dies with the process, so a crash never wedges the job. + Returns the fd holding the lock, or None when another run owns it.""" + data_dir.mkdir(parents=True, exist_ok=True) + fd = os.open(data_dir / "metadocs.lock", os.O_CREAT | os.O_WRONLY, 0o600) + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + os.close(fd) + return None + return fd + + @dataclass class RunReport: projects: list = field(default_factory=list) # (project, done, pending, note) diff --git a/tests/test_metadocs.py b/tests/test_metadocs.py index 88243e4..17a2a19 100644 --- a/tests/test_metadocs.py +++ b/tests/test_metadocs.py @@ -257,6 +257,18 @@ def test_run_commit_per_project(db, tmp_path, repo, monkeypatch): assert "meta docs: proj — 1 session(s)" in log +# -- single-run lock ---------------------------------------------------------- +def test_second_run_steps_aside_while_first_holds_lock(tmp_path): + import os + fd = run_mod.acquire_lock(tmp_path) + assert fd is not None + assert run_mod.acquire_lock(tmp_path) is None # nightly vs manual overlap + os.close(fd) + fd2 = run_mod.acquire_lock(tmp_path) # released with the process + assert fd2 is not None + os.close(fd2) + + # -- schedule ----------------------------------------------------------------- def test_plist_shape(tmp_path): plist = schedule.build_plist("21:30", tmp_path / "m.log")