"""Publication barrier and provenance shared by SQLite and Neo4j. Builders work in a staging directory. Readers hold a shared process lock while using a snapshot; publishers hold its exclusive counterpart. A durable pending marker makes a process crash fail closed until the next successful sync. """ from __future__ import annotations import fcntl import json import os import sqlite3 from contextlib import closing, contextmanager from functools import wraps from pathlib import Path from tools.rag.common import DATA_DIR VERSION_FILE = "index_version.json" PENDING_FILE = ".sync-pending.json" LOCK_FILE = ".index-publication.lock" class IndexUnavailableError(RuntimeError): pass def fsync_directory(path: Path) -> None: descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY) try: os.fsync(descriptor) finally: os.close(descriptor) def durable_json(path: Path, value: dict) -> None: temporary = path.with_suffix(path.suffix + ".tmp") with temporary.open("w", encoding="utf-8") as handle: json.dump(value, handle, ensure_ascii=False, indent=2) handle.write("\n") handle.flush() os.fsync(handle.fileno()) os.replace(temporary, path) fsync_directory(path.parent) @contextmanager def publication_lock(data_dir: Path, *, exclusive: bool = False): data_dir.mkdir(parents=True, exist_ok=True) with (data_dir / LOCK_FILE).open("a+") as handle: try: flags = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH | fcntl.LOCK_NB fcntl.flock(handle.fileno(), flags) except BlockingIOError as error: raise IndexUnavailableError("Prebieha aktivácia novej verzie indexov.") from error try: yield finally: fcntl.flock(handle.fileno(), fcntl.LOCK_UN) def read_version(data_dir: Path = DATA_DIR) -> dict | None: path = data_dir / VERSION_FILE if not path.exists(): return None # Pre-migration index; first coordinated sync adds metadata. try: value = json.loads(path.read_text()) if not isinstance(value, dict) or not value.get("version"): raise ValueError("Missing version") return value except (ValueError, OSError) as error: raise IndexUnavailableError("Neplatná verzia vyhľadávacieho indexu.") from error def sqlite_version(db_file: Path) -> dict | None: with closing(sqlite3.connect(db_file.resolve().as_uri() + "?mode=ro", uri=True)) as conn: if not conn.execute( "SELECT 1 FROM sqlite_master WHERE type='table' AND name='index_state'" ).fetchone(): return None row = conn.execute("SELECT metadata_json FROM index_state WHERE id=1").fetchone() try: return json.loads(row[0]) if row else None except ValueError as error: raise IndexUnavailableError("Neplatná verzia SQLite indexu.") from error @contextmanager def index_snapshot(data_dir: Path = DATA_DIR, *, db_file: Path | None = None): with publication_lock(data_dir): if (data_dir / PENDING_FILE).exists(): raise IndexUnavailableError( "Synchronizácia indexov nebola dokončená; zopakujte synchronizáciu." ) version = read_version(data_dir) if db_file is not None and version is not None and sqlite_version(db_file) != version: raise IndexUnavailableError("SQLite index nezodpovedá aktívnej verzii dát.") yield version def rag_snapshot(function): @wraps(function) def wrapped(*args, **kwargs): db_file = Path(args[0] if args else kwargs.get("db_path", kwargs.get("db_file"))) with index_snapshot(db_file.parent, db_file=db_file) as version: result = function(*args, **kwargs) if version is not None: result["index_version"] = version return result return wrapped