Bound adapter assemblies and extraction caches
This commit is contained in:
parent
9cd7c4e424
commit
f00aa65a73
4 changed files with 232 additions and 8 deletions
|
|
@ -21,6 +21,8 @@ from .adapter_validation import (
|
||||||
from .config_validation import ID_PATTERN
|
from .config_validation import ID_PATTERN
|
||||||
from .errors import DocForgeError
|
from .errors import DocForgeError
|
||||||
from .incremental import (
|
from .incremental import (
|
||||||
|
MAX_EXTRACTION_CACHE_BYTES,
|
||||||
|
MAX_EXTRACTION_CACHE_SOURCES,
|
||||||
CachedSource,
|
CachedSource,
|
||||||
ExtractionCache,
|
ExtractionCache,
|
||||||
affected_sources,
|
affected_sources,
|
||||||
|
|
@ -40,6 +42,10 @@ from .models import (
|
||||||
)
|
)
|
||||||
from .telemetry import increment, stage
|
from .telemetry import increment, stage
|
||||||
|
|
||||||
|
MAX_ADAPTER_EDGE_MULTIPLIER = 32
|
||||||
|
MAX_ADAPTER_LOGIC_NODE_MULTIPLIER = 32
|
||||||
|
MAX_ADAPTER_LOGIC_EDGE_MULTIPLIER = 64
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class AdapterNode:
|
class AdapterNode:
|
||||||
|
|
@ -464,6 +470,9 @@ class AdapterProject:
|
||||||
canonical_sources,
|
canonical_sources,
|
||||||
captured,
|
captured,
|
||||||
)
|
)
|
||||||
|
self._enforce_assembly_limits(
|
||||||
|
AdapterAssembly(projection=projection, logic=self._last_logic)
|
||||||
|
)
|
||||||
return ProjectSnapshot(
|
return ProjectSnapshot(
|
||||||
descriptor=self.descriptor,
|
descriptor=self.descriptor,
|
||||||
nodes=projection.core_nodes(),
|
nodes=projection.core_nodes(),
|
||||||
|
|
@ -628,11 +637,14 @@ class AdapterProject:
|
||||||
validate_manifest(manifest)
|
validate_manifest(manifest)
|
||||||
if manifest.identity() != self._identity:
|
if manifest.identity() != self._identity:
|
||||||
raise DocForgeError("adapter_changed", "Adapter identity changed during the operation")
|
raise DocForgeError("adapter_changed", "Adapter identity changed during the operation")
|
||||||
|
self._enforce_manifest_limits(manifest)
|
||||||
cache = load_extraction_cache(
|
cache = load_extraction_cache(
|
||||||
self._cache_path,
|
self._cache_path,
|
||||||
project_id=manifest.project_id,
|
project_id=manifest.project_id,
|
||||||
adapter_id=manifest.adapter_id,
|
adapter_id=manifest.adapter_id,
|
||||||
adapter_version=manifest.adapter_version,
|
adapter_version=manifest.adapter_version,
|
||||||
|
max_bytes=MAX_EXTRACTION_CACHE_BYTES,
|
||||||
|
max_sources=MAX_EXTRACTION_CACHE_SOURCES,
|
||||||
)
|
)
|
||||||
cached = {source.source_id: source for source in cache.sources} if cache else {}
|
cached = {source.source_id: source for source in cache.sources} if cache else {}
|
||||||
current = {source.source_id: source for source in manifest.sources}
|
current = {source.source_id: source for source in manifest.sources}
|
||||||
|
|
@ -737,6 +749,7 @@ class AdapterProject:
|
||||||
"Incremental assembly changed the manifest-bound project identity",
|
"Incremental assembly changed the manifest-bound project identity",
|
||||||
)
|
)
|
||||||
_validate_adapter_assembly(assembly)
|
_validate_adapter_assembly(assembly)
|
||||||
|
self._enforce_assembly_limits(assembly)
|
||||||
stable = loader.load_manifest()
|
stable = loader.load_manifest()
|
||||||
validate_manifest(stable)
|
validate_manifest(stable)
|
||||||
if stable != manifest:
|
if stable != manifest:
|
||||||
|
|
@ -752,6 +765,8 @@ class AdapterProject:
|
||||||
adapter_version=manifest.adapter_version,
|
adapter_version=manifest.adapter_version,
|
||||||
sources=tuple(cache_records),
|
sources=tuple(cache_records),
|
||||||
),
|
),
|
||||||
|
max_bytes=MAX_EXTRACTION_CACHE_BYTES,
|
||||||
|
max_sources=MAX_EXTRACTION_CACHE_SOURCES,
|
||||||
)
|
)
|
||||||
self._last_logic = logic_projections
|
self._last_logic = logic_projections
|
||||||
self._last_build_report = {
|
self._last_build_report = {
|
||||||
|
|
@ -766,6 +781,40 @@ class AdapterProject:
|
||||||
}
|
}
|
||||||
return assembly
|
return assembly
|
||||||
|
|
||||||
|
def _enforce_manifest_limits(self, manifest: AdapterManifest) -> None:
|
||||||
|
if len(manifest.sources) > MAX_EXTRACTION_CACHE_SOURCES:
|
||||||
|
raise DocForgeError(
|
||||||
|
"adapter_limit",
|
||||||
|
"Adapter manifest exceeds the bounded source limit",
|
||||||
|
maximum=MAX_EXTRACTION_CACHE_SOURCES,
|
||||||
|
actual=len(manifest.sources),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _enforce_assembly_limits(self, assembly: AdapterAssembly) -> None:
|
||||||
|
maximum_nodes = self.descriptor.limits.max_nodes
|
||||||
|
maximum_edges = maximum_nodes * MAX_ADAPTER_EDGE_MULTIPLIER
|
||||||
|
maximum_logic_nodes = maximum_nodes * MAX_ADAPTER_LOGIC_NODE_MULTIPLIER
|
||||||
|
maximum_logic_edges = maximum_nodes * MAX_ADAPTER_LOGIC_EDGE_MULTIPLIER
|
||||||
|
actual_nodes = len(assembly.projection.nodes)
|
||||||
|
actual_edges = len(assembly.projection.edges)
|
||||||
|
actual_logic_nodes = sum(len(projection.nodes) for projection in assembly.logic)
|
||||||
|
actual_logic_edges = sum(len(projection.edges) for projection in assembly.logic)
|
||||||
|
limits = (
|
||||||
|
("nodes", actual_nodes, maximum_nodes),
|
||||||
|
("edges", actual_edges, maximum_edges),
|
||||||
|
("logic_nodes", actual_logic_nodes, maximum_logic_nodes),
|
||||||
|
("logic_edges", actual_logic_edges, maximum_logic_edges),
|
||||||
|
)
|
||||||
|
for label, actual, maximum in limits:
|
||||||
|
if actual > maximum:
|
||||||
|
raise DocForgeError(
|
||||||
|
"adapter_limit",
|
||||||
|
f"Adapter assembly exceeds the configured {label.replace('_', ' ')} limit",
|
||||||
|
kind=label,
|
||||||
|
maximum=maximum,
|
||||||
|
actual=actual,
|
||||||
|
)
|
||||||
|
|
||||||
def canonical_source_paths(self) -> tuple[Path, ...]:
|
def canonical_source_paths(self) -> tuple[Path, ...]:
|
||||||
"""Adapters validate their own source sets before producing a projection."""
|
"""Adapters validate their own source sets before producing a projection."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import stat
|
||||||
import tempfile
|
import tempfile
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -12,6 +13,8 @@ from typing import Any, cast
|
||||||
from .errors import DocForgeError
|
from .errors import DocForgeError
|
||||||
|
|
||||||
EXTRACTION_CACHE_SCHEMA_VERSION = 1
|
EXTRACTION_CACHE_SCHEMA_VERSION = 1
|
||||||
|
MAX_EXTRACTION_CACHE_BYTES = 64_000_000
|
||||||
|
MAX_EXTRACTION_CACHE_SOURCES = 10_000
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -42,13 +45,39 @@ def load_extraction_cache(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
adapter_id: str,
|
adapter_id: str,
|
||||||
adapter_version: str,
|
adapter_version: str,
|
||||||
|
max_bytes: int = MAX_EXTRACTION_CACHE_BYTES,
|
||||||
|
max_sources: int = MAX_EXTRACTION_CACHE_SOURCES,
|
||||||
) -> ExtractionCache | None:
|
) -> ExtractionCache | None:
|
||||||
"""Read a cache generation, treating malformed or incompatible data as a miss."""
|
"""Read a cache generation, treating malformed or incompatible data as a miss."""
|
||||||
|
|
||||||
if not path.is_file() or path.is_symlink():
|
if max_bytes < 1 or max_sources < 1:
|
||||||
|
raise ValueError("Extraction cache limits must be positive")
|
||||||
|
try:
|
||||||
|
descriptor = os.open(
|
||||||
|
path,
|
||||||
|
os.O_RDONLY
|
||||||
|
| getattr(os, "O_CLOEXEC", 0)
|
||||||
|
| getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
| getattr(os, "O_NONBLOCK", 0),
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
with os.fdopen(descriptor, "rb") as handle:
|
||||||
|
before = os.fstat(handle.fileno())
|
||||||
|
if not stat.S_ISREG(before.st_mode) or before.st_size > max_bytes:
|
||||||
|
return None
|
||||||
|
encoded = handle.read(max_bytes + 1)
|
||||||
|
after = os.fstat(handle.fileno())
|
||||||
|
if (
|
||||||
|
len(encoded) > max_bytes
|
||||||
|
or before.st_dev != after.st_dev
|
||||||
|
or before.st_ino != after.st_ino
|
||||||
|
or before.st_size != after.st_size
|
||||||
|
or before.st_mtime_ns != after.st_mtime_ns
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
raw = json.loads(encoded.decode("utf-8"))
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
return None
|
return None
|
||||||
document = cast(dict[str, Any], raw)
|
document = cast(dict[str, Any], raw)
|
||||||
|
|
@ -62,8 +91,11 @@ def load_extraction_cache(
|
||||||
raw_sources = document.get("sources")
|
raw_sources = document.get("sources")
|
||||||
if not isinstance(raw_sources, list):
|
if not isinstance(raw_sources, list):
|
||||||
return None
|
return None
|
||||||
|
source_items = cast(list[object], raw_sources)
|
||||||
|
if len(source_items) > max_sources:
|
||||||
|
return None
|
||||||
sources: list[CachedSource] = []
|
sources: list[CachedSource] = []
|
||||||
for raw_source in cast(list[object], raw_sources):
|
for raw_source in source_items:
|
||||||
if not isinstance(raw_source, dict):
|
if not isinstance(raw_source, dict):
|
||||||
return None
|
return None
|
||||||
item = cast(dict[str, object], raw_source)
|
item = cast(dict[str, object], raw_source)
|
||||||
|
|
@ -110,9 +142,24 @@ def load_extraction_cache(
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def write_extraction_cache(path: Path, cache: ExtractionCache) -> None:
|
def write_extraction_cache(
|
||||||
|
path: Path,
|
||||||
|
cache: ExtractionCache,
|
||||||
|
*,
|
||||||
|
max_bytes: int = MAX_EXTRACTION_CACHE_BYTES,
|
||||||
|
max_sources: int = MAX_EXTRACTION_CACHE_SOURCES,
|
||||||
|
) -> None:
|
||||||
"""Atomically publish one validated extraction-cache generation."""
|
"""Atomically publish one validated extraction-cache generation."""
|
||||||
|
|
||||||
|
if max_bytes < 1 or max_sources < 1:
|
||||||
|
raise ValueError("Extraction cache limits must be positive")
|
||||||
|
if len(cache.sources) > max_sources:
|
||||||
|
raise DocForgeError(
|
||||||
|
"cache_limit",
|
||||||
|
"Incremental extraction cache exceeds its source limit",
|
||||||
|
maximum=max_sources,
|
||||||
|
actual=len(cache.sources),
|
||||||
|
)
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
document = {
|
document = {
|
||||||
"schema_version": EXTRACTION_CACHE_SCHEMA_VERSION,
|
"schema_version": EXTRACTION_CACHE_SCHEMA_VERSION,
|
||||||
|
|
@ -131,17 +178,25 @@ def write_extraction_cache(path: Path, cache: ExtractionCache) -> None:
|
||||||
for source in cache.sources
|
for source in cache.sources
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
encoded = (
|
||||||
|
json.dumps(document, sort_keys=True, separators=(",", ":")).encode("utf-8") + b"\n"
|
||||||
|
)
|
||||||
|
if len(encoded) > max_bytes:
|
||||||
|
raise DocForgeError(
|
||||||
|
"cache_limit",
|
||||||
|
"Incremental extraction cache exceeds its byte limit",
|
||||||
|
maximum=max_bytes,
|
||||||
|
actual=len(encoded),
|
||||||
|
)
|
||||||
with tempfile.NamedTemporaryFile(
|
with tempfile.NamedTemporaryFile(
|
||||||
mode="w",
|
mode="wb",
|
||||||
encoding="utf-8",
|
|
||||||
prefix="extractions-",
|
prefix="extractions-",
|
||||||
suffix=".json",
|
suffix=".json",
|
||||||
dir=path.parent,
|
dir=path.parent,
|
||||||
delete=False,
|
delete=False,
|
||||||
) as descriptor:
|
) as descriptor:
|
||||||
temporary = Path(descriptor.name)
|
temporary = Path(descriptor.name)
|
||||||
json.dump(document, descriptor, sort_keys=True, separators=(",", ":"))
|
descriptor.write(encoded)
|
||||||
descriptor.write("\n")
|
|
||||||
descriptor.flush()
|
descriptor.flush()
|
||||||
os.fsync(descriptor.fileno())
|
os.fsync(descriptor.fileno())
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ from docforge.mcp_server import (
|
||||||
)
|
)
|
||||||
from docforge.models import (
|
from docforge.models import (
|
||||||
Edge,
|
Edge,
|
||||||
|
Limits,
|
||||||
LogicEdge,
|
LogicEdge,
|
||||||
LogicNode,
|
LogicNode,
|
||||||
LogicProjection,
|
LogicProjection,
|
||||||
|
|
@ -341,6 +342,23 @@ class AdapterContractTests(unittest.TestCase):
|
||||||
self.assertEqual("Workflow", index.get_node("guide.workflow")["node"]["title"])
|
self.assertEqual("Workflow", index.get_node("guide.workflow")["node"]["title"])
|
||||||
self.assertEqual(projection.identity(), projection.identity())
|
self.assertEqual(projection.identity(), projection.identity())
|
||||||
|
|
||||||
|
def test_adapter_project_enforces_graph_bounds_before_publication(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory).resolve()
|
||||||
|
project = AdapterProject(
|
||||||
|
Loader(self.projection(root)),
|
||||||
|
cache_root=root / ".cache" / "bounded",
|
||||||
|
settings=AdapterProjectSettings(limits=Limits(max_nodes=1)),
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(DocForgeError) as captured:
|
||||||
|
project.load()
|
||||||
|
|
||||||
|
self.assertEqual("adapter_limit", captured.exception.code)
|
||||||
|
self.assertEqual("nodes", captured.exception.details["kind"])
|
||||||
|
self.assertEqual(1, captured.exception.details["maximum"])
|
||||||
|
self.assertEqual(2, captured.exception.details["actual"])
|
||||||
|
|
||||||
def test_projection_rejects_unsorted_metadata_graph_and_identity_changes(self) -> None:
|
def test_projection_rejects_unsorted_metadata_graph_and_identity_changes(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
root = Path(directory).resolve()
|
root = Path(directory).resolve()
|
||||||
|
|
|
||||||
102
tests/test_incremental_cache.py
Normal file
102
tests/test_incremental_cache.py
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from docforge.errors import DocForgeError
|
||||||
|
from docforge.incremental import (
|
||||||
|
CachedSource,
|
||||||
|
ExtractionCache,
|
||||||
|
load_extraction_cache,
|
||||||
|
write_extraction_cache,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExtractionCacheBoundsTests(unittest.TestCase):
|
||||||
|
@staticmethod
|
||||||
|
def cache(*, payload: str = "ok", count: int = 1) -> ExtractionCache:
|
||||||
|
sources = tuple(
|
||||||
|
CachedSource(
|
||||||
|
source_id=f"source-{index}",
|
||||||
|
source_path=f"src/{index}.txt",
|
||||||
|
fingerprint=hashlib.sha256(str(index).encode()).hexdigest(),
|
||||||
|
extractor_version="fixture@1",
|
||||||
|
dependencies=(),
|
||||||
|
payload={"content": payload},
|
||||||
|
)
|
||||||
|
for index in range(count)
|
||||||
|
)
|
||||||
|
return ExtractionCache("fixture", "fixture-adapter", "1", sources)
|
||||||
|
|
||||||
|
def test_bounded_round_trip_and_oversized_read_is_a_cache_miss(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "extractions.json"
|
||||||
|
write_extraction_cache(path, self.cache(), max_bytes=1_000, max_sources=2)
|
||||||
|
loaded = load_extraction_cache(
|
||||||
|
path,
|
||||||
|
project_id="fixture",
|
||||||
|
adapter_id="fixture-adapter",
|
||||||
|
adapter_version="1",
|
||||||
|
max_bytes=1_000,
|
||||||
|
max_sources=2,
|
||||||
|
)
|
||||||
|
self.assertEqual(self.cache(), loaded)
|
||||||
|
|
||||||
|
with path.open("ab") as handle:
|
||||||
|
handle.write(b" " * 1_000)
|
||||||
|
self.assertIsNone(
|
||||||
|
load_extraction_cache(
|
||||||
|
path,
|
||||||
|
project_id="fixture",
|
||||||
|
adapter_id="fixture-adapter",
|
||||||
|
adapter_version="1",
|
||||||
|
max_bytes=1_000,
|
||||||
|
max_sources=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_write_rejects_byte_and_source_limits_without_replacing_target(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "extractions.json"
|
||||||
|
path.write_bytes(b"preserved\n")
|
||||||
|
for cache, maximum_bytes, maximum_sources in (
|
||||||
|
(self.cache(payload="x" * 500), 100, 2),
|
||||||
|
(self.cache(count=2), 1_000, 1),
|
||||||
|
):
|
||||||
|
with self.assertRaises(DocForgeError) as captured:
|
||||||
|
write_extraction_cache(
|
||||||
|
path,
|
||||||
|
cache,
|
||||||
|
max_bytes=maximum_bytes,
|
||||||
|
max_sources=maximum_sources,
|
||||||
|
)
|
||||||
|
self.assertEqual("cache_limit", captured.exception.code)
|
||||||
|
self.assertEqual(b"preserved\n", path.read_bytes())
|
||||||
|
|
||||||
|
def test_symlink_and_non_regular_targets_are_cache_misses(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
real = root / "real.json"
|
||||||
|
write_extraction_cache(real, self.cache(), max_bytes=1_000, max_sources=2)
|
||||||
|
linked = root / "linked.json"
|
||||||
|
linked.symlink_to(real)
|
||||||
|
fifo = root / "fifo"
|
||||||
|
os.mkfifo(fifo)
|
||||||
|
for path in (linked, fifo):
|
||||||
|
self.assertIsNone(
|
||||||
|
load_extraction_cache(
|
||||||
|
path,
|
||||||
|
project_id="fixture",
|
||||||
|
adapter_id="fixture-adapter",
|
||||||
|
adapter_version="1",
|
||||||
|
max_bytes=1_000,
|
||||||
|
max_sources=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue