dp-zp-agent/tools/sync/index_state.py

113 lines
3.9 KiB
Python

"""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