171 lines
8.2 KiB
Python
171 lines
8.2 KiB
Python
"""Stage SQLite/JSON and publish them with a single Neo4j data transaction.
|
|
|
|
There is no distributed transaction between a filesystem and Neo4j. Cooperating
|
|
readers therefore use the publication barrier. Ordinary failures restore local
|
|
files; ambiguous commit/crash leaves a durable marker and readers fail closed.
|
|
Re-running sync creates a complete fresh generation and clears that marker.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sqlite3
|
|
import subprocess
|
|
import tempfile
|
|
import uuid
|
|
from contextlib import closing
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from tools.index_state import (PENDING_FILE, VERSION_FILE, durable_json, fsync_directory,
|
|
publication_lock, sqlite_version)
|
|
from tools.rag.scan_zpwiki import scan_pages
|
|
from tools.rag.build_chunks import build_chunks
|
|
from tools.rag.build_sqlite_index import build_database
|
|
from tools.rag.embedding_utils import embedding_model_name
|
|
from tools.publication import PUBLICATION_POLICY_VERSION
|
|
from tools.graphrag.graphrag import build_graph_payload
|
|
from tools.graphrag.graph_db import create_neo4j_driver, get_neo4j_settings
|
|
from tools.graphrag.build_graphrag import (create_constraints, graph_version,
|
|
write_graph_transaction, graph_counts)
|
|
|
|
FILES = ('documents.json', 'chunks.json', 'zp_index.sqlite', VERSION_FILE)
|
|
|
|
|
|
def digest(path: Path) -> str:
|
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
|
|
|
|
def source_files(wiki_root: Path) -> dict[str, str]:
|
|
if not (wiki_root / 'pages').is_dir():
|
|
raise RuntimeError(f'Chýba wiki priečinok pages: {wiki_root}')
|
|
return {str(p.relative_to(wiki_root)): digest(p)
|
|
for p in sorted((wiki_root / 'pages').rglob('README.md'))}
|
|
|
|
|
|
def git_revision(wiki_root: Path) -> str | None:
|
|
if not (wiki_root / '.git').exists():
|
|
return None
|
|
result = subprocess.run(['git', '-C', str(wiki_root), 'rev-parse', 'HEAD'],
|
|
capture_output=True, text=True, timeout=15, check=True)
|
|
return result.stdout.strip()
|
|
|
|
|
|
def prepare_snapshot(wiki_root: Path, stage: Path) -> tuple[dict, dict, dict]:
|
|
revision = git_revision(wiki_root); sources = source_files(wiki_root)
|
|
frozen_wiki = stage / 'wiki'
|
|
for relative in sources:
|
|
target = frozen_wiki / relative; target.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copyfile(wiki_root / relative, target)
|
|
if source_files(frozen_wiki) != sources:
|
|
raise RuntimeError('Wiki sa počas vytvárania snapshotu zmenila.')
|
|
documents = scan_pages(wiki_root=frozen_wiki, output=stage / 'documents.json')
|
|
chunks = build_chunks(wiki_root=frozen_wiki, output=stage / 'chunks.json')
|
|
counts = build_database(documents_file=stage / 'documents.json', chunks_file=stage / 'chunks.json',
|
|
db_file=stage / 'zp_index.sqlite')
|
|
version = {'version': uuid.uuid4().hex, 'git_revision': revision,
|
|
'created_at_utc': datetime.now(timezone.utc).isoformat(),
|
|
'source_sha256': hashlib.sha256(json.dumps(sources, sort_keys=True).encode()).hexdigest(),
|
|
'documents_sha256': digest(stage / 'documents.json'), 'chunks_sha256': digest(stage / 'chunks.json'),
|
|
'embedding_model': embedding_model_name(), 'publication_policy': PUBLICATION_POLICY_VERSION}
|
|
with closing(sqlite3.connect(stage / 'zp_index.sqlite')) as conn, conn:
|
|
conn.execute('CREATE TABLE index_state (id INTEGER PRIMARY KEY CHECK(id=1), metadata_json TEXT NOT NULL)')
|
|
conn.execute('INSERT INTO index_state VALUES (1, ?)', (json.dumps(version, sort_keys=True),))
|
|
durable_json(stage / VERSION_FILE, version)
|
|
if source_files(wiki_root) != sources or git_revision(wiki_root) != revision:
|
|
raise RuntimeError('Wiki sa počas indexovania zmenila; pripravené indexy sa neaktivujú.')
|
|
return version, counts, build_graph_payload(documents, chunks)
|
|
|
|
|
|
def replace_file(source: Path, target: Path) -> None:
|
|
"""Separate operation so tests can inject a publication I/O failure."""
|
|
os.replace(source, target)
|
|
|
|
|
|
def publish_snapshot(data_dir: Path, stage: Path, version: dict, payload: dict, session) -> dict:
|
|
with publication_lock(data_dir, exclusive=True):
|
|
had_pending = (data_dir / PENDING_FILE).exists()
|
|
old_graph_version = graph_version(session)
|
|
originals = []
|
|
backup = stage / 'previous'; backup.mkdir()
|
|
for name in FILES:
|
|
current = data_dir / name
|
|
if current.exists():
|
|
shutil.copy2(current, backup / name)
|
|
with (backup / name).open('rb') as handle:
|
|
os.fsync(handle.fileno())
|
|
originals.append(name)
|
|
fsync_directory(backup)
|
|
durable_json(data_dir / PENDING_FILE, {'target_version': version['version'],
|
|
'staging_directory': stage.name, 'original_files': originals})
|
|
commit_attempted = False
|
|
try:
|
|
with session.begin_transaction() as tx:
|
|
write_graph_transaction(tx, payload, version=version)
|
|
node_counts, relation_counts = graph_counts(tx)
|
|
graph_summary = {'nodes': sum(n for _, n in node_counts),
|
|
'relationships': sum(n for _, n in relation_counts)}
|
|
for name in FILES:
|
|
# Flush staged contents before the durable directory switch.
|
|
with (stage / name).open('rb') as handle:
|
|
os.fsync(handle.fileno())
|
|
replace_file(stage / name, data_dir / name)
|
|
fsync_directory(data_dir)
|
|
commit_attempted = True
|
|
tx.commit()
|
|
except BaseException:
|
|
if commit_attempted:
|
|
# A lost COMMIT response is not proof of rollback. Never restore
|
|
# old SQLite against a possibly committed new graph.
|
|
try:
|
|
current_graph_version = graph_version(session)
|
|
except Exception:
|
|
raise # Keep marker + backups; reads stay unavailable.
|
|
if current_graph_version == version and sqlite_version(data_dir / 'zp_index.sqlite') == version:
|
|
(data_dir / PENDING_FILE).unlink(); fsync_directory(data_dir)
|
|
return graph_summary
|
|
if current_graph_version != old_graph_version:
|
|
raise # Unknown external writer; fail closed.
|
|
for name in FILES:
|
|
if name in originals:
|
|
os.replace(backup / name, data_dir / name)
|
|
else:
|
|
(data_dir / name).unlink(missing_ok=True)
|
|
fsync_directory(data_dir)
|
|
if not had_pending:
|
|
(data_dir / PENDING_FILE).unlink(); fsync_directory(data_dir)
|
|
raise
|
|
(data_dir / PENDING_FILE).unlink()
|
|
fsync_directory(data_dir)
|
|
return graph_summary
|
|
|
|
|
|
def synchronize_indexes(wiki_root: Path, data_dir: Path, *, progress=None) -> dict:
|
|
data_dir.mkdir(parents=True, exist_ok=True)
|
|
stage = Path(tempfile.mkdtemp(prefix='.sync-stage-', dir=data_dir))
|
|
try:
|
|
version, counts, payload = prepare_snapshot(wiki_root, stage)
|
|
if progress:
|
|
progress('publishing_indexes')
|
|
settings = get_neo4j_settings()
|
|
with create_neo4j_driver(settings) as driver, driver.session(database=settings.database) as session:
|
|
driver.verify_connectivity()
|
|
create_constraints(session)
|
|
graph_summary = publish_snapshot(data_dir, stage, version, payload, session)
|
|
return {'counts': counts, 'documents_scanned': counts['documents'], 'chunks_created': counts['chunks'],
|
|
'database_path': str(data_dir / 'zp_index.sqlite'), 'index_version': version,
|
|
'graph_counts': graph_summary}
|
|
finally:
|
|
# Keep recovery material when a crash/ambiguous commit is unresolved.
|
|
pending = data_dir / PENDING_FILE
|
|
keep_stage = False
|
|
if pending.exists():
|
|
try:
|
|
keep_stage = json.loads(pending.read_text()).get('staging_directory') == stage.name
|
|
except (ValueError, OSError):
|
|
keep_stage = True
|
|
if not keep_stage:
|
|
shutil.rmtree(stage)
|