fix(preprocess): bind DWH artifacts to workspace
This commit is contained in:
@@ -14,6 +14,18 @@ from tht.jobs.locking import _lock_name
|
|||||||
FP = "sha256:" + hashlib.sha256(b"test").hexdigest()
|
FP = "sha256:" + hashlib.sha256(b"test").hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_config(tmp_path, workspace_id="demo"):
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
cfg = SimpleNamespace(
|
||||||
|
paths=SimpleNamespace(artifacts=tmp_path / "artifacts", indexes=tmp_path / "indexes"),
|
||||||
|
_workspace_id=workspace_id,
|
||||||
|
_config_source="test",
|
||||||
|
)
|
||||||
|
cfg.model_dump_json = lambda: "test"
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
def test_dwh_and_evidence_jobs_have_distinct_lock_names():
|
def test_dwh_and_evidence_jobs_have_distinct_lock_names():
|
||||||
assert _lock_name("demo", "dwh") != _lock_name("demo", "evidence")
|
assert _lock_name("demo", "dwh") != _lock_name("demo", "evidence")
|
||||||
|
|
||||||
@@ -43,6 +55,68 @@ def test_selected_dwh_stages_run_in_declared_order(tmp_path):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_shared_root_rejects_other_workspace_before_builder_or_read(tmp_path):
|
||||||
|
calls = []
|
||||||
|
owner = DwhPreprocessPipeline(
|
||||||
|
workspace_id="demo", workspace_root=tmp_path,
|
||||||
|
config_fingerprint=FP, input_fingerprint=FP,
|
||||||
|
introspect=lambda output: output.write_text("catalog"),
|
||||||
|
build_lsh=lambda physical, output: _write_lsh([], physical, output),
|
||||||
|
)
|
||||||
|
published = owner.run()
|
||||||
|
contender = DwhPreprocessPipeline(
|
||||||
|
workspace_id="other", workspace_root=tmp_path,
|
||||||
|
config_fingerprint=FP, input_fingerprint=FP,
|
||||||
|
introspect=lambda output: calls.append("introspect"),
|
||||||
|
build_lsh=lambda physical, output: calls.append("lsh"),
|
||||||
|
)
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
with pytest.raises(Exception, match="different workspace configuration"):
|
||||||
|
contender.run()
|
||||||
|
with pytest.raises(Exception, match="different workspace configuration"):
|
||||||
|
resolve_dwh_snapshot(snapshot_config(tmp_path, "other"))
|
||||||
|
|
||||||
|
assert calls == []
|
||||||
|
assert resolve_dwh_snapshot(snapshot_config(tmp_path)).generation == published.run_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_shared_root_mismatch_fails_without_deadlock_while_owner_reader_is_active(tmp_path):
|
||||||
|
import threading
|
||||||
|
|
||||||
|
owner = DwhPreprocessPipeline(
|
||||||
|
workspace_id="demo", workspace_root=tmp_path,
|
||||||
|
config_fingerprint=FP, input_fingerprint=FP,
|
||||||
|
introspect=lambda output: output.write_text("catalog"),
|
||||||
|
build_lsh=lambda physical, output: _write_lsh([], physical, output),
|
||||||
|
)
|
||||||
|
owner.run()
|
||||||
|
contender = DwhPreprocessPipeline(
|
||||||
|
workspace_id="other", workspace_root=tmp_path,
|
||||||
|
config_fingerprint=FP, input_fingerprint=FP,
|
||||||
|
introspect=lambda output: (_ for _ in ()).throw(AssertionError("builder called")),
|
||||||
|
build_lsh=lambda physical, output: None,
|
||||||
|
)
|
||||||
|
finished = threading.Event()
|
||||||
|
errors = []
|
||||||
|
with lease_dwh_snapshot(snapshot_config(tmp_path)):
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=lambda: (errors.append(_capture_error(contender.run)), finished.set())
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
assert finished.wait(2)
|
||||||
|
thread.join(2)
|
||||||
|
assert "different workspace configuration" in str(errors[0])
|
||||||
|
|
||||||
|
|
||||||
|
def _capture_error(operation):
|
||||||
|
try:
|
||||||
|
operation()
|
||||||
|
except Exception as error:
|
||||||
|
return error
|
||||||
|
raise AssertionError("operation unexpectedly succeeded")
|
||||||
|
|
||||||
|
|
||||||
def _write_lsh(calls, physical: Path, output: Path):
|
def _write_lsh(calls, physical: Path, output: Path):
|
||||||
calls.append("lsh")
|
calls.append("lsh")
|
||||||
assert physical.read_text() == "catalog"
|
assert physical.read_text() == "catalog"
|
||||||
@@ -189,8 +263,6 @@ def test_active_fsync_failure_restores_previous_pointer(monkeypatch, tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
def test_snapshot_stays_on_one_generation_across_publish(tmp_path):
|
def test_snapshot_stays_on_one_generation_across_publish(tmp_path):
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
def pipeline(content):
|
def pipeline(content):
|
||||||
return DwhPreprocessPipeline(
|
return DwhPreprocessPipeline(
|
||||||
workspace_id="demo", workspace_root=tmp_path,
|
workspace_id="demo", workspace_root=tmp_path,
|
||||||
@@ -203,9 +275,7 @@ def test_snapshot_stays_on_one_generation_across_publish(tmp_path):
|
|||||||
)
|
)
|
||||||
|
|
||||||
first = pipeline("old").run()
|
first = pipeline("old").run()
|
||||||
cfg = SimpleNamespace(paths=SimpleNamespace(
|
cfg = snapshot_config(tmp_path)
|
||||||
artifacts=tmp_path / "artifacts", indexes=tmp_path / "indexes"
|
|
||||||
))
|
|
||||||
snapshot = resolve_dwh_snapshot(cfg)
|
snapshot = resolve_dwh_snapshot(cfg)
|
||||||
pipeline("new").run()
|
pipeline("new").run()
|
||||||
assert snapshot.generation == first.run_id
|
assert snapshot.generation == first.run_id
|
||||||
@@ -328,8 +398,6 @@ def test_retention_candidate_swap_to_symlink_is_never_followed(monkeypatch, tmp_
|
|||||||
def test_reader_lease_blocks_retain_one_publisher_until_file_reads_finish(tmp_path):
|
def test_reader_lease_blocks_retain_one_publisher_until_file_reads_finish(tmp_path):
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
def make(content, retain=1):
|
def make(content, retain=1):
|
||||||
return DwhPreprocessPipeline(
|
return DwhPreprocessPipeline(
|
||||||
workspace_id="demo", workspace_root=tmp_path,
|
workspace_id="demo", workspace_root=tmp_path,
|
||||||
@@ -342,9 +410,7 @@ def test_reader_lease_blocks_retain_one_publisher_until_file_reads_finish(tmp_pa
|
|||||||
)
|
)
|
||||||
|
|
||||||
first = make("old").run()
|
first = make("old").run()
|
||||||
cfg = SimpleNamespace(paths=SimpleNamespace(
|
cfg = snapshot_config(tmp_path)
|
||||||
artifacts=tmp_path / "artifacts", indexes=tmp_path / "indexes"
|
|
||||||
))
|
|
||||||
completed = threading.Event()
|
completed = threading.Event()
|
||||||
with lease_dwh_snapshot(cfg) as snapshot:
|
with lease_dwh_snapshot(cfg) as snapshot:
|
||||||
thread = threading.Thread(target=lambda: (make("new").run(), completed.set()))
|
thread = threading.Thread(target=lambda: (make("new").run(), completed.set()))
|
||||||
|
|||||||
@@ -20,9 +20,12 @@ def run_dwh_from_config(
|
|||||||
):
|
):
|
||||||
from tht.cli.lsh_cmd import build_lsh_artifacts
|
from tht.cli.lsh_cmd import build_lsh_artifacts
|
||||||
from tht.cli.schema_cmd import _load_config_or_exit, physical_path, refresh_catalog
|
from tht.cli.schema_cmd import _load_config_or_exit, physical_path, refresh_catalog
|
||||||
from tht.jobs.dwh_pipeline import DwhPreprocessPipeline, active_generation_dir, fingerprint
|
from tht.jobs.dwh_pipeline import (
|
||||||
|
DwhPreprocessPipeline, active_generation_dir, config_dwh_binding,
|
||||||
|
)
|
||||||
|
|
||||||
cfg = _load_config_or_exit(config)
|
cfg = _load_config_or_exit(config)
|
||||||
|
binding = config_dwh_binding(cfg)
|
||||||
workspace_root = cfg.paths.artifacts.parent
|
workspace_root = cfg.paths.artifacts.parent
|
||||||
active = active_generation_dir(workspace_root)
|
active = active_generation_dir(workspace_root)
|
||||||
lsh_names = (
|
lsh_names = (
|
||||||
@@ -31,10 +34,10 @@ def run_dwh_from_config(
|
|||||||
f"{cfg.database.db_schema}_meta.json",
|
f"{cfg.database.db_schema}_meta.json",
|
||||||
)
|
)
|
||||||
pipeline = DwhPreprocessPipeline(
|
pipeline = DwhPreprocessPipeline(
|
||||||
workspace_id=config.stem.lower().replace(".", "-").replace("_", "-"),
|
workspace_id=binding["workspace_id"],
|
||||||
workspace_root=workspace_root,
|
workspace_root=workspace_root,
|
||||||
config_fingerprint=fingerprint(cfg.model_dump_json()),
|
config_fingerprint=binding["config_fingerprint"],
|
||||||
input_fingerprint=fingerprint(config.resolve().as_posix()),
|
input_fingerprint=binding["input_fingerprint"],
|
||||||
introspect=lambda output: refresh_catalog(cfg, output_path=output),
|
introspect=lambda output: refresh_catalog(cfg, output_path=output),
|
||||||
build_lsh=lambda physical, output: build_lsh_artifacts(
|
build_lsh=lambda physical, output: build_lsh_artifacts(
|
||||||
cfg, physical_file=physical, output_dir=output
|
cfg, physical_file=physical, output_dir=output
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
|||||||
from typing import Annotated, Any, Literal
|
from typing import Annotated, Any, Literal
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from pydantic import BaseModel, Field, SecretStr, model_validator, ValidationError
|
from pydantic import BaseModel, Field, PrivateAttr, SecretStr, model_validator, ValidationError
|
||||||
|
|
||||||
from tht.config_compat import translate_legacy_config
|
from tht.config_compat import translate_legacy_config
|
||||||
|
|
||||||
@@ -283,6 +283,8 @@ class ExecutionConfig(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class Config(BaseModel):
|
class Config(BaseModel):
|
||||||
|
_workspace_id: str = PrivateAttr(default="default")
|
||||||
|
_config_source: str = PrivateAttr(default="direct")
|
||||||
dwh: DwhResourceConfig
|
dwh: DwhResourceConfig
|
||||||
vectors: VectorResourceConfig | None = None
|
vectors: VectorResourceConfig | None = None
|
||||||
roots: WorkspaceRoots = WorkspaceRoots()
|
roots: WorkspaceRoots = WorkspaceRoots()
|
||||||
@@ -376,6 +378,8 @@ def load_config(path: Path) -> Config:
|
|||||||
FutureWarning,
|
FutureWarning,
|
||||||
stacklevel=2,
|
stacklevel=2,
|
||||||
)
|
)
|
||||||
|
cfg._workspace_id = path.stem.lower().replace(".", "-").replace("_", "-")
|
||||||
|
cfg._config_source = path.resolve().as_posix()
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,18 @@ _SAFE_FILE = re.compile(r"^[A-Za-z0-9_-]+\.(?:pkl|json)$")
|
|||||||
GENERATION_MANIFEST = "generation-manifest.json"
|
GENERATION_MANIFEST = "generation-manifest.json"
|
||||||
|
|
||||||
|
|
||||||
|
def config_dwh_binding(cfg) -> dict[str, str]:
|
||||||
|
workspace_id = getattr(cfg, "_workspace_id", None)
|
||||||
|
config_source = getattr(cfg, "_config_source", None)
|
||||||
|
if not isinstance(workspace_id, str) or not isinstance(config_source, str):
|
||||||
|
raise CorruptCheckpointError("DWH workspace identity is unavailable; reload configuration")
|
||||||
|
return {
|
||||||
|
"workspace_id": workspace_id,
|
||||||
|
"config_fingerprint": fingerprint(cfg.model_dump_json()),
|
||||||
|
"input_fingerprint": fingerprint(config_source),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class DwhArtifactSnapshot:
|
class DwhArtifactSnapshot:
|
||||||
generation: str | None
|
generation: str | None
|
||||||
@@ -120,7 +132,9 @@ def _read_owned_at(directory_fd: int, name: str, *, readonly: bool) -> bytes:
|
|||||||
os.close(fd)
|
os.close(fd)
|
||||||
|
|
||||||
|
|
||||||
def validate_generation_fd(directory_fd: int, generation: str) -> dict:
|
def validate_generation_fd(
|
||||||
|
directory_fd: int, generation: str, expected_binding: dict[str, str] | None = None,
|
||||||
|
) -> dict:
|
||||||
try:
|
try:
|
||||||
directory_info = os.fstat(directory_fd)
|
directory_info = os.fstat(directory_fd)
|
||||||
if (
|
if (
|
||||||
@@ -138,8 +152,17 @@ def validate_generation_fd(directory_fd: int, generation: str) -> dict:
|
|||||||
or not isinstance(files, dict)
|
or not isinstance(files, dict)
|
||||||
or not re.fullmatch(r"sha256:[0-9a-f]{64}", manifest["job_spec_fingerprint"])
|
or not re.fullmatch(r"sha256:[0-9a-f]{64}", manifest["job_spec_fingerprint"])
|
||||||
or not re.fullmatch(r"[0-9a-f]{64}", manifest["artifact_manifest_sha256"])
|
or not re.fullmatch(r"[0-9a-f]{64}", manifest["artifact_manifest_sha256"])
|
||||||
|
or not re.fullmatch(r"[a-z][a-z0-9_-]{0,63}", manifest["workspace_id"])
|
||||||
|
or not re.fullmatch(r"sha256:[0-9a-f]{64}", manifest["config_fingerprint"])
|
||||||
|
or not re.fullmatch(r"sha256:[0-9a-f]{64}", manifest["input_fingerprint"])
|
||||||
):
|
):
|
||||||
raise ValueError
|
raise ValueError
|
||||||
|
if expected_binding is not None and any(
|
||||||
|
manifest.get(key) != value for key, value in expected_binding.items()
|
||||||
|
):
|
||||||
|
raise CorruptCheckpointError(
|
||||||
|
"DWH artifacts belong to a different workspace configuration"
|
||||||
|
)
|
||||||
if set(os.listdir(directory_fd)) != set(files) | {GENERATION_MANIFEST}:
|
if set(os.listdir(directory_fd)) != set(files) | {GENERATION_MANIFEST}:
|
||||||
raise ValueError
|
raise ValueError
|
||||||
for name, expected in files.items():
|
for name, expected in files.items():
|
||||||
@@ -153,13 +176,15 @@ def validate_generation_fd(directory_fd: int, generation: str) -> dict:
|
|||||||
raise CorruptCheckpointError("published DWH generation is invalid") from error
|
raise CorruptCheckpointError("published DWH generation is invalid") from error
|
||||||
|
|
||||||
|
|
||||||
def validate_generation(target: Path) -> dict:
|
def validate_generation(
|
||||||
|
target: Path, expected_binding: dict[str, str] | None = None,
|
||||||
|
) -> dict:
|
||||||
try:
|
try:
|
||||||
fd = os.open(target, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
fd = os.open(target, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
||||||
except OSError as error:
|
except OSError as error:
|
||||||
raise CorruptCheckpointError("published DWH generation is invalid") from error
|
raise CorruptCheckpointError("published DWH generation is invalid") from error
|
||||||
try:
|
try:
|
||||||
return validate_generation_fd(fd, target.name)
|
return validate_generation_fd(fd, target.name, expected_binding)
|
||||||
finally:
|
finally:
|
||||||
os.close(fd)
|
os.close(fd)
|
||||||
|
|
||||||
@@ -170,7 +195,7 @@ def resolve_dwh_snapshot(cfg) -> DwhArtifactSnapshot:
|
|||||||
return DwhArtifactSnapshot(
|
return DwhArtifactSnapshot(
|
||||||
None, cfg.paths.artifacts / "mschema" / "physical.yaml", cfg.paths.indexes / "lsh"
|
None, cfg.paths.artifacts / "mschema" / "physical.yaml", cfg.paths.indexes / "lsh"
|
||||||
)
|
)
|
||||||
validate_generation(target)
|
validate_generation(target, config_dwh_binding(cfg))
|
||||||
return DwhArtifactSnapshot(target.name, target / "physical.yaml", target)
|
return DwhArtifactSnapshot(target.name, target / "physical.yaml", target)
|
||||||
|
|
||||||
|
|
||||||
@@ -229,10 +254,29 @@ class DwhPreprocessPipeline:
|
|||||||
):
|
):
|
||||||
raise ValueError("LSH filenames must be unique flat safe names")
|
raise ValueError("LSH filenames must be unique flat safe names")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def binding(self) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"workspace_id": self.workspace_id,
|
||||||
|
"config_fingerprint": self.config_fingerprint,
|
||||||
|
"input_fingerprint": self.input_fingerprint,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _assert_active_binding(self) -> None:
|
||||||
|
active = active_generation_dir(self.workspace_root)
|
||||||
|
if active is not None:
|
||||||
|
validate_generation(active, self.binding)
|
||||||
|
|
||||||
def run(
|
def run(
|
||||||
self, steps: tuple[str, ...] = DWH_STAGE_IDS, *, resume_run_id: str | None = None
|
self, steps: tuple[str, ...] = DWH_STAGE_IDS, *, resume_run_id: str | None = None
|
||||||
) -> JobReport:
|
) -> JobReport:
|
||||||
self._validate_steps(steps)
|
self._validate_steps(steps)
|
||||||
|
lease_fd = _acquire_generation_lock(self.workspace_root, exclusive=False)
|
||||||
|
try:
|
||||||
|
self._assert_active_binding()
|
||||||
|
finally:
|
||||||
|
fcntl.flock(lease_fd, fcntl.LOCK_UN)
|
||||||
|
os.close(lease_fd)
|
||||||
if resume_run_id is not None:
|
if resume_run_id is not None:
|
||||||
self._validate_resume_publication(resume_run_id)
|
self._validate_resume_publication(resume_run_id)
|
||||||
spec = JobSpec(
|
spec = JobSpec(
|
||||||
@@ -290,7 +334,7 @@ class DwhPreprocessPipeline:
|
|||||||
if running is None:
|
if running is None:
|
||||||
return set()
|
return set()
|
||||||
target = self.workspace_root / ".tht-dwh" / "generations" / source.run_id
|
target = self.workspace_root / ".tht-dwh" / "generations" / source.run_id
|
||||||
validate_generation(target)
|
validate_generation(target, self.binding)
|
||||||
active = active_generation_dir(self.workspace_root)
|
active = active_generation_dir(self.workspace_root)
|
||||||
if active != target:
|
if active != target:
|
||||||
raise CorruptCheckpointError("sealed DWH publication is not ACTIVE")
|
raise CorruptCheckpointError("sealed DWH publication is not ACTIVE")
|
||||||
@@ -355,6 +399,7 @@ class DwhPreprocessPipeline:
|
|||||||
seal_stage_artifacts(context, stage, required, spec)
|
seal_stage_artifacts(context, stage, required, spec)
|
||||||
lease_fd = _acquire_generation_lock(self.workspace_root, exclusive=True)
|
lease_fd = _acquire_generation_lock(self.workspace_root, exclusive=True)
|
||||||
try:
|
try:
|
||||||
|
self._assert_active_binding()
|
||||||
self._publish(context.run_id, artifacts, required)
|
self._publish(context.run_id, artifacts, required)
|
||||||
if self.after_publish is not None:
|
if self.after_publish is not None:
|
||||||
self.after_publish(context.run_id)
|
self.after_publish(context.run_id)
|
||||||
@@ -392,6 +437,7 @@ class DwhPreprocessPipeline:
|
|||||||
"artifact_manifest_sha256": _digest(
|
"artifact_manifest_sha256": _digest(
|
||||||
artifacts / "artifact-manifest.json"
|
artifacts / "artifact-manifest.json"
|
||||||
),
|
),
|
||||||
|
**self.binding,
|
||||||
}
|
}
|
||||||
manifest_path = temporary / GENERATION_MANIFEST
|
manifest_path = temporary / GENERATION_MANIFEST
|
||||||
manifest_path.write_text(
|
manifest_path.write_text(
|
||||||
@@ -429,9 +475,10 @@ class DwhPreprocessPipeline:
|
|||||||
pointer_tmp.unlink(missing_ok=True)
|
pointer_tmp.unlink(missing_ok=True)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@staticmethod
|
def _validate_published(
|
||||||
def _validate_published(target: Path, artifacts: Path, required: tuple[str, ...]) -> None:
|
self, target: Path, artifacts: Path, required: tuple[str, ...],
|
||||||
manifest = validate_generation(target)
|
) -> None:
|
||||||
|
manifest = validate_generation(target, self.binding)
|
||||||
if set(manifest["files"]) != set(required):
|
if set(manifest["files"]) != set(required):
|
||||||
raise CorruptCheckpointError("published DWH generation is invalid")
|
raise CorruptCheckpointError("published DWH generation is invalid")
|
||||||
if manifest["artifact_manifest_sha256"] != _digest(
|
if manifest["artifact_manifest_sha256"] != _digest(
|
||||||
@@ -463,6 +510,8 @@ class DwhPreprocessPipeline:
|
|||||||
if not root.exists():
|
if not root.exists():
|
||||||
return
|
return
|
||||||
active = active_generation_dir(self.workspace_root)
|
active = active_generation_dir(self.workspace_root)
|
||||||
|
if active is not None:
|
||||||
|
validate_generation(active, self.binding)
|
||||||
active_name = active.name if active else None
|
active_name = active.name if active else None
|
||||||
protected = {active_name} if active_name else set()
|
protected = {active_name} if active_name else set()
|
||||||
runs = self.workspace_root / ".tht-jobs" / "dwh" / "runs"
|
runs = self.workspace_root / ".tht-jobs" / "dwh" / "runs"
|
||||||
@@ -487,7 +536,7 @@ class DwhPreprocessPipeline:
|
|||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
info = os.fstat(candidate_fd)
|
info = os.fstat(candidate_fd)
|
||||||
validate_generation_fd(candidate_fd, name)
|
validate_generation_fd(candidate_fd, name, self.binding)
|
||||||
except CorruptCheckpointError:
|
except CorruptCheckpointError:
|
||||||
continue
|
continue
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
Reference in New Issue
Block a user