fix(evidence): lock GC and preflight search ownership

This commit is contained in:
2026-07-12 06:07:30 +02:00
parent d21aac9151
commit efcb0deb31
7 changed files with 55 additions and 6 deletions
+1 -2
View File
@@ -114,8 +114,7 @@ def gc_from_config(config: Path, *, dry_run: bool = False):
retain_published_generations=cfg.vector.retain_published_generations,
)
pipeline.workspace_id = config.stem.lower().replace(".", "-").replace("_", "-")
with pipeline.store.writer_lock():
return pipeline.gc(workspace_root=corpus_root.parent, dry_run=dry_run)
return pipeline.gc(workspace_root=corpus_root.parent, dry_run=dry_run)
@preprocess_app.command("evidence")
+10 -2
View File
@@ -57,13 +57,17 @@ def search_cmd(
from tht.search import combined_search
cfg = _load_config_or_exit(config)
from tht.search.evidence import validate_corpus_workspace
workspace_id = config.stem.lower().replace(".", "-").replace("_", "-")
validate_corpus_workspace(cfg, workspace_id)
dwh_snapshot = _leased_dwh_snapshot(cfg, ctx)
require_vector_cfg(cfg)
from tht.search.evidence import active_searcher
runtime_searcher = active_searcher(
cfg, open_searcher(cfg),
workspace_id=config.stem.lower().replace(".", "-").replace("_", "-"),
workspace_id=workspace_id,
)
if kind is not None and kind not in KIND_MAP:
typer.secho(
@@ -256,6 +260,10 @@ def pack_cmd(
from tht.vectorstore.rest_client import VectorRestError
cfg = _load_config_or_exit(config)
from tht.search.evidence import validate_corpus_workspace
workspace_id = config.stem.lower().replace(".", "-").replace("_", "-")
validate_corpus_workspace(cfg, workspace_id)
dwh_snapshot = _leased_dwh_snapshot(cfg, ctx)
require_vector_cfg(cfg)
@@ -272,7 +280,7 @@ def pack_cmd(
searcher = active_searcher(
cfg, open_searcher(cfg),
workspace_id=config.stem.lower().replace(".", "-").replace("_", "-"),
workspace_id=workspace_id,
)
embedder = make_embedder(cfg.embeddings)
vec = embedder.embed_query(question)
+4
View File
@@ -124,6 +124,10 @@ class CorpusPipeline:
return protected
def gc(self, *, workspace_root: Path, dry_run: bool = False) -> dict:
with self.store.writer_lock():
return self._gc(workspace_root=workspace_root, dry_run=dry_run)
def _gc(self, *, workspace_root: Path, dry_run: bool = False) -> dict:
self._assert_workspace_binding()
published = self.store.published_generations()
list_vectors = getattr(self.vector_store, "list_evidence_generations", None)
+12
View File
@@ -10,6 +10,7 @@ import stat
import shutil
import uuid
import hashlib
import threading
from datetime import UTC, datetime
from pathlib import Path
from contextlib import contextmanager
@@ -49,6 +50,7 @@ class CorpusStore:
self.active_path = self.root / "ACTIVE"
self._replace = os.replace
self._fsync_directory = self._sync_root
self._lock_state = threading.local()
self._ensure_root()
def _ensure_root(self) -> None:
@@ -61,6 +63,14 @@ class CorpusStore:
@contextmanager
def writer_lock(self):
depth = getattr(self._lock_state, "depth", 0)
if depth:
self._lock_state.depth = depth + 1
try:
yield
finally:
self._lock_state.depth -= 1
return
lock_path = self.root / ".writer.lock"
fd = os.open(lock_path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_CLOEXEC, 0o600)
try:
@@ -68,8 +78,10 @@ class CorpusStore:
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_nlink != 1:
raise UnsafeCorpusPath("corpus writer lock is unsafe")
fcntl.flock(fd, fcntl.LOCK_EX)
self._lock_state.depth = 1
yield
finally:
self._lock_state.depth = 0
fcntl.flock(fd, fcntl.LOCK_UN)
os.close(fd)
+20
View File
@@ -76,6 +76,26 @@ def active_searcher(cfg, delegate, *, workspace_id: str | None = None):
return ActiveEvidenceSearcher(CorpusStore(corpus_root), delegate, workspace_id)
def validate_corpus_workspace(cfg, workspace_id: str) -> None:
"""Fail before downstream retrieval setup when configured corpus ownership differs."""
corpus = CorpusStore(cfg.paths.artifacts.parent / "corpus")
with corpus.writer_lock():
manifest = corpus.active_manifest()
if manifest is None:
return
persisted = manifest.metadata.get("workspace_id")
if not isinstance(persisted, str) or re.fullmatch(
r"[a-z][a-z0-9_-]{0,63}", persisted
) is None:
raise CorpusWorkspaceMismatchError(
"corpus workspace ownership is missing or invalid; use a new corpus root or rebuild"
)
if persisted != workspace_id:
raise CorpusWorkspaceMismatchError(
"corpus belongs to a different workspace; use a new corpus root or rebuild"
)
def resolve_evidence_file(
store: CorpusStore, evidence_id: str, *, materialized_root=None,
) -> str: