Cache validated source generation receipts
This commit is contained in:
parent
529accf858
commit
6253c45a5e
3 changed files with 115 additions and 19 deletions
|
|
@ -86,6 +86,15 @@ malformed, incompatible, foreign, or dirty receipt becomes a cache miss and fall
|
||||||
canonical load and row-verification oracle. Successful fallback verification repairs the disposable
|
canonical load and row-verification oracle. Successful fallback verification repairs the disposable
|
||||||
receipt.
|
receipt.
|
||||||
|
|
||||||
|
The final 1,000-node evidence run initially exposed a repeatable 52 ms exact-read maximum against
|
||||||
|
the 50 ms target. Profiling showed no source parsing or SQLite cost; each request rebuilt and
|
||||||
|
revalidated 1,000 `Path` objects from the unchanged JSON receipt twice. The project binding now
|
||||||
|
caches only the strictly validated receipt structure behind its device, inode, size, modification
|
||||||
|
time, and change-time signature. Canonical file and directory identities are still recaptured
|
||||||
|
before and after every query. Receipt replacement or mutation invalidates the cache and fails
|
||||||
|
closed. The same ordered exact-read profile fell from about 40–52 ms to about 18 ms without
|
||||||
|
weakening stale-read refusal.
|
||||||
|
|
||||||
The receipt is deliberately generic-project behavior. Incremental adapter manifests retain
|
The receipt is deliberately generic-project behavior. Incremental adapter manifests retain
|
||||||
authority over generated or specialist source identities. A one-method legacy adapter continues to
|
authority over generated or specialist source identities. A one-method legacy adapter continues to
|
||||||
work even when it cannot provide a cheap generation.
|
work even when it cannot provide a cheap generation.
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,17 @@ class _CapturedGeneration:
|
||||||
directories: tuple[tuple[str, int, int, int, int, int], ...]
|
directories: tuple[tuple[str, int, int, int, int, int], ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _ParsedGenerationReceipt:
|
||||||
|
signature: tuple[int, int, int, int, int]
|
||||||
|
source_hash: str
|
||||||
|
revision: str
|
||||||
|
files: tuple[tuple[object, ...], ...]
|
||||||
|
directories: tuple[tuple[object, ...], ...]
|
||||||
|
file_paths: tuple[Path, ...]
|
||||||
|
directory_paths: tuple[Path, ...]
|
||||||
|
|
||||||
|
|
||||||
def project_root_fingerprint(root: Path) -> str:
|
def project_root_fingerprint(root: Path) -> str:
|
||||||
return hashlib.sha256(str(root).encode()).hexdigest()[:16]
|
return hashlib.sha256(str(root).encode()).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
@ -183,6 +194,22 @@ def _receipt_paths(root: Path, value: object, *, width: int) -> tuple[Path, ...]
|
||||||
return tuple(paths)
|
return tuple(paths)
|
||||||
|
|
||||||
|
|
||||||
|
def _receipt_signature(path: Path) -> tuple[int, int, int, int, int] | None:
|
||||||
|
try:
|
||||||
|
status = path.lstat()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
if not stat.S_ISREG(status.st_mode):
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
status.st_dev,
|
||||||
|
status.st_ino,
|
||||||
|
status.st_size,
|
||||||
|
status.st_mtime_ns,
|
||||||
|
status.st_ctime_ns,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _load_descriptor(root: Path) -> ProjectDescriptor:
|
def _load_descriptor(root: Path) -> ProjectDescriptor:
|
||||||
descriptor_path = root / ".docforge" / "project.toml"
|
descriptor_path = root / ".docforge" / "project.toml"
|
||||||
if not descriptor_path.is_file():
|
if not descriptor_path.is_file():
|
||||||
|
|
@ -698,6 +725,7 @@ class Project:
|
||||||
def __init__(self, descriptor: ProjectDescriptor) -> None:
|
def __init__(self, descriptor: ProjectDescriptor) -> None:
|
||||||
self.descriptor = descriptor
|
self.descriptor = descriptor
|
||||||
self._captured_generation: _CapturedGeneration | None = None
|
self._captured_generation: _CapturedGeneration | None = None
|
||||||
|
self._generation_receipt_cache: _ParsedGenerationReceipt | None = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def open(cls, project_root: str | Path) -> Project:
|
def open(cls, project_root: str | Path) -> Project:
|
||||||
|
|
@ -820,17 +848,54 @@ class Project:
|
||||||
|
|
||||||
def _incremental_state(self) -> ProjectState | None:
|
def _incremental_state(self) -> ProjectState | None:
|
||||||
path = self.generation_path
|
path = self.generation_path
|
||||||
if not path.is_file() or path.is_symlink():
|
signature = _receipt_signature(path)
|
||||||
|
if signature is None:
|
||||||
|
self._generation_receipt_cache = None
|
||||||
return None
|
return None
|
||||||
|
receipt = self._generation_receipt_cache
|
||||||
|
if receipt is None or receipt.signature != signature:
|
||||||
|
receipt = self._parse_generation_receipt(path, signature)
|
||||||
|
self._generation_receipt_cache = receipt
|
||||||
|
if receipt is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
current_directories = _directory_generation(
|
||||||
|
self.descriptor.root,
|
||||||
|
receipt.directory_paths,
|
||||||
|
)
|
||||||
|
except DocForgeError:
|
||||||
|
return None
|
||||||
|
if receipt.directories != cast(tuple[tuple[object, ...], ...], current_directories):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
current_files = _file_generation(self.descriptor.root, receipt.file_paths)
|
||||||
|
except DocForgeError:
|
||||||
|
return None
|
||||||
|
if receipt.files != cast(tuple[tuple[object, ...], ...], current_files):
|
||||||
|
return None
|
||||||
|
if _revision(self.descriptor.root) != receipt.revision:
|
||||||
|
return None
|
||||||
|
return ProjectState(
|
||||||
|
source_hash=receipt.source_hash,
|
||||||
|
revision=receipt.revision,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_generation_receipt(
|
||||||
|
self,
|
||||||
|
path: Path,
|
||||||
|
signature: tuple[int, int, int, int, int],
|
||||||
|
) -> _ParsedGenerationReceipt | None:
|
||||||
try:
|
try:
|
||||||
parsed: object = json.loads(path.read_text(encoding="utf-8"))
|
parsed: object = json.loads(path.read_text(encoding="utf-8"))
|
||||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
||||||
return None
|
return None
|
||||||
if not isinstance(parsed, dict):
|
if _receipt_signature(path) != signature or not isinstance(parsed, dict):
|
||||||
return None
|
return None
|
||||||
payload = cast(dict[str, object], parsed)
|
payload = cast(dict[str, object], parsed)
|
||||||
source_hash = payload.get("source_hash")
|
source_hash = payload.get("source_hash")
|
||||||
revision = payload.get("revision")
|
revision = payload.get("revision")
|
||||||
|
files_value = payload.get("files")
|
||||||
|
directories_value = payload.get("directories")
|
||||||
if (
|
if (
|
||||||
payload.get("schema_version") != SOURCE_GENERATION_SCHEMA_VERSION
|
payload.get("schema_version") != SOURCE_GENERATION_SCHEMA_VERSION
|
||||||
or payload.get("source_contract") != GENERIC_SOURCE_CONTRACT
|
or payload.get("source_contract") != GENERIC_SOURCE_CONTRACT
|
||||||
|
|
@ -841,35 +906,39 @@ class Project:
|
||||||
or not isinstance(source_hash, str)
|
or not isinstance(source_hash, str)
|
||||||
or len(source_hash) != 64
|
or len(source_hash) != 64
|
||||||
or not isinstance(revision, str)
|
or not isinstance(revision, str)
|
||||||
|
or not isinstance(files_value, list)
|
||||||
|
or not isinstance(directories_value, list)
|
||||||
):
|
):
|
||||||
return None
|
return None
|
||||||
directory_paths = _receipt_paths(
|
directory_paths = _receipt_paths(
|
||||||
self.descriptor.root,
|
self.descriptor.root,
|
||||||
payload.get("directories"),
|
cast(list[object], directories_value),
|
||||||
width=6,
|
width=6,
|
||||||
)
|
)
|
||||||
file_paths = _receipt_paths(
|
file_paths = _receipt_paths(
|
||||||
self.descriptor.root,
|
self.descriptor.root,
|
||||||
payload.get("files"),
|
cast(list[object], files_value),
|
||||||
width=7,
|
width=7,
|
||||||
)
|
)
|
||||||
if directory_paths is None or file_paths is None:
|
if directory_paths is None or file_paths is None:
|
||||||
return None
|
return None
|
||||||
try:
|
return _ParsedGenerationReceipt(
|
||||||
current_directories = _directory_generation(self.descriptor.root, directory_paths)
|
signature=signature,
|
||||||
except DocForgeError:
|
source_hash=source_hash,
|
||||||
return None
|
revision=revision,
|
||||||
if payload.get("directories") != [list(identity) for identity in current_directories]:
|
files=tuple(
|
||||||
return None
|
tuple(cast(list[object], item))
|
||||||
try:
|
for item in cast(list[object], files_value)
|
||||||
current_files = _file_generation(self.descriptor.root, file_paths)
|
if isinstance(item, list)
|
||||||
except DocForgeError:
|
),
|
||||||
return None
|
directories=tuple(
|
||||||
if payload.get("files") != [list(identity) for identity in current_files]:
|
tuple(cast(list[object], item))
|
||||||
return None
|
for item in cast(list[object], directories_value)
|
||||||
if _revision(self.descriptor.root) != revision:
|
if isinstance(item, list)
|
||||||
return None
|
),
|
||||||
return ProjectState(source_hash=source_hash, revision=revision)
|
file_paths=file_paths,
|
||||||
|
directory_paths=directory_paths,
|
||||||
|
)
|
||||||
|
|
||||||
def record_generation(self, snapshot: ProjectSnapshot) -> None:
|
def record_generation(self, snapshot: ProjectSnapshot) -> None:
|
||||||
"""Persist a generation only after its complete derived index was verified."""
|
"""Persist a generation only after its complete derived index was verified."""
|
||||||
|
|
|
||||||
|
|
@ -422,6 +422,24 @@ class DocForgeCoreTests(unittest.TestCase):
|
||||||
):
|
):
|
||||||
index.get_node("guide.workflow")
|
index.get_node("guide.workflow")
|
||||||
|
|
||||||
|
def test_source_generation_receipt_cache_is_signature_bound(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = self.copy_fixture("alpha", Path(directory))
|
||||||
|
project = Project.open(root)
|
||||||
|
ProjectIndex(project).build()
|
||||||
|
first = project.incremental_state()
|
||||||
|
self.assertIsNotNone(first)
|
||||||
|
|
||||||
|
with mock.patch.object(
|
||||||
|
Path,
|
||||||
|
"read_text",
|
||||||
|
side_effect=AssertionError("warm generation check reparsed its receipt"),
|
||||||
|
):
|
||||||
|
self.assertEqual(first, project.incremental_state())
|
||||||
|
|
||||||
|
project.generation_path.write_text("{", encoding="utf-8")
|
||||||
|
self.assertIsNone(project.incremental_state())
|
||||||
|
|
||||||
def test_source_set_change_during_load_fails_closed(self) -> None:
|
def test_source_set_change_during_load_fails_closed(self) -> None:
|
||||||
project = Project.open(FIXTURES / "alpha")
|
project = Project.open(FIXTURES / "alpha")
|
||||||
sources, directories = project._canonical_inventory()
|
sources, directories = project._canonical_inventory()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue