Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/session_recall/metadocs/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,18 +89,27 @@ 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")
return 1
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

Expand Down
18 changes: 18 additions & 0 deletions src/session_recall/metadocs/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions tests/test_metadocs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading