Skip to content

Commit 238fc49

Browse files
committed
Add serial add mutation coordinator
1 parent 4616e49 commit 238fc49

2 files changed

Lines changed: 150 additions & 0 deletions

File tree

openkb/add_coordinator.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
import shutil
5+
from collections.abc import Callable, Sequence
6+
from dataclasses import dataclass, field
7+
from pathlib import Path
8+
9+
import click
10+
11+
from openkb.locks import kb_ingest_lock_held
12+
from openkb.mutation import snapshot_paths
13+
14+
logger = logging.getLogger(__name__)
15+
16+
MutationBody = Callable[[], None]
17+
PostCommitHook = Callable[[], None]
18+
19+
20+
class DirtyRollbackError(RuntimeError):
21+
"""A mutation's rollback failed, leaving an active journal on disk.
22+
23+
The KB may be in a partially-applied state that the retained journal will
24+
attempt to roll back on the next exclusive-lock acquisition. Batch owners
25+
(the parallel/serial ``add`` loops) MUST stop committing further mutations
26+
on top of this dirty state instead of continuing — otherwise the next
27+
recovery rolls this journal back over the shared paths it recorded
28+
(``hashes.json``, ``index.md``, ``concepts/``, ``entities/``) and silently
29+
clobbers the later commits. Single-mutation callers should let it propagate
30+
so the command fails loudly; rerunning recovers via the drain.
31+
"""
32+
33+
def __init__(self, operation: str, journal_path: Path) -> None:
34+
super().__init__(
35+
f"Dirty rollback for {operation}; journal retained at {journal_path}. "
36+
f"Rerun the command to recover."
37+
)
38+
self.operation = operation
39+
self.journal_path = journal_path
40+
41+
42+
@dataclass(slots=True)
43+
class AddMutationPlan:
44+
operation: str
45+
details: dict
46+
touched_paths: Sequence[Path]
47+
body: MutationBody
48+
post_commit_hooks: Sequence[PostCommitHook] = field(default_factory=tuple)
49+
hardlink_dirs: set[Path] = field(default_factory=set)
50+
staging_dirs: Sequence[Path | None] = field(default_factory=tuple)
51+
rollback_error_message: str = "Rollback failed; mutation journal retained for recovery"
52+
53+
54+
def _cleanup_staging_dirs(staging_dirs: Sequence[Path | None]) -> None:
55+
for staging_dir in staging_dirs:
56+
if staging_dir is not None:
57+
shutil.rmtree(staging_dir, ignore_errors=True)
58+
59+
60+
def _rollback_snapshot(plan: AddMutationPlan, snapshot) -> Path | None:
61+
"""Best-effort rollback; returns the retained journal path on dirty failure.
62+
63+
Returns ``snapshot.journal_path`` when the snapshot existed but rollback
64+
FAILED (the active journal is retained for next-run recovery), otherwise
65+
``None`` — covering both "snapshot is None" (nothing was applied; the
66+
failure happened during snapshot setup before the body ran) and a clean
67+
rollback that discarded its journal.
68+
"""
69+
if snapshot is None:
70+
_cleanup_staging_dirs(plan.staging_dirs)
71+
return None
72+
rollback_error = snapshot.rollback_best_effort()
73+
if rollback_error is None:
74+
snapshot.discard_best_effort()
75+
else:
76+
click.echo(f" [ERROR] {plan.rollback_error_message}: {snapshot.journal_path}")
77+
_cleanup_staging_dirs(plan.staging_dirs)
78+
return snapshot.journal_path if rollback_error is not None else None
79+
80+
81+
def _failure_target(details: dict) -> str:
82+
for key in ("name", "doc_name", "doc_id"):
83+
value = details.get(key)
84+
if value:
85+
return f" for {value}"
86+
return ""
87+
88+
89+
def run_add_mutation(kb_dir: Path, plan: AddMutationPlan) -> bool:
90+
if not kb_ingest_lock_held(kb_dir / ".openkb"):
91+
raise RuntimeError(
92+
"run_add_mutation requires the caller to hold kb_ingest_lock"
93+
)
94+
snapshot = None
95+
try:
96+
snapshot = snapshot_paths(
97+
kb_dir,
98+
list(plan.touched_paths),
99+
operation=plan.operation,
100+
details=plan.details,
101+
hardlink_dirs=plan.hardlink_dirs,
102+
)
103+
plan.body()
104+
snapshot.mark_committed()
105+
except Exception as exc:
106+
dirty_journal = _rollback_snapshot(plan, snapshot)
107+
if dirty_journal is not None:
108+
# Rollback failed and left an active journal. Stop the batch rather
109+
# than committing more docs on top of dirty state that the next
110+
# recovery would roll back over.
111+
raise DirtyRollbackError(plan.operation, dirty_journal)
112+
click.echo(
113+
f" [ERROR] {plan.operation} failed{_failure_target(plan.details)}: {exc}"
114+
)
115+
logger.debug("%s mutation failed:", plan.operation, exc_info=True)
116+
return False
117+
except BaseException:
118+
# Interrupt (KeyboardInterrupt / SystemExit): best-effort rollback for
119+
# its side-effects only. Do NOT raise DirtyRollbackError — propagate the
120+
# interrupt so the user's abort is honored. Any retained journal or
121+
# orphaned staging is recovered next run by the drain + reaper.
122+
_rollback_snapshot(plan, snapshot)
123+
raise
124+
finally:
125+
_cleanup_staging_dirs(plan.staging_dirs)
126+
127+
for hook in plan.post_commit_hooks:
128+
try:
129+
hook()
130+
except Exception as exc:
131+
logger.warning("Post-commit hook failed for %s: %s", plan.operation, exc)
132+
133+
cleanup_error = snapshot.discard_best_effort()
134+
if cleanup_error is not None:
135+
click.echo(f" [WARN] mutation journal cleanup failed: {cleanup_error}")
136+
return True

openkb/locks.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,20 @@ def kb_read_lock(openkb_dir: Path):
179179
return kb_lock(openkb_dir, exclusive=False)
180180

181181

182+
def kb_ingest_lock_held(openkb_dir: Path) -> bool:
183+
"""Return True iff the *current thread* holds the exclusive ingest lock.
184+
185+
Reentrancy is tracked per-thread (``threading.local``), so a worker thread
186+
returns ``False`` even when the main thread holds the lock. Mutation
187+
primitives use this to assert they run on the lock-owning thread rather
188+
than silently deadlocking on a worker's separate OS ``flock`` acquire.
189+
"""
190+
held = _held_locks()
191+
resolved = (openkb_dir / "ingest.lock").resolve()
192+
exclusive_depth, _ = held.get(resolved, (0, 0))
193+
return exclusive_depth > 0
194+
195+
182196
def _fsync_directory(path: Path) -> None:
183197
if os.name == "nt":
184198
# Windows cannot open a directory handle to fsync it. os.replace is

0 commit comments

Comments
 (0)