From c335b7b6c8081c77212ba99884855390e987da2d Mon Sep 17 00:00:00 2001 From: Christian-Manuel Butzke Date: Fri, 31 Jul 2026 05:08:36 +0900 Subject: [PATCH 1/4] Implement portable execution checkpoint host --- .github/workflows/test.yml | 33 +- AGENTS.md | 25 +- Makefile | 6 +- README.md | 65 +- conformance/execution_checkpoint.py | 465 ++++++ conformance/pins.py | 4 +- conformance/test_conformance.py | 43 +- pyproject.toml | 4 + scripts/sync_schema.py | 3 +- src/determa/state/__init__.py | 86 + src/determa/state/checkpoint.py | 480 ++++++ .../data/execution-checkpoint.schema.json | 1330 +++++++++++++++ src/determa/state/host.py | 1450 +++++++++++++++++ src/determa/state/stores/__init__.py | 58 + src/determa/state/stores/base.py | 104 ++ src/determa/state/stores/file.py | 173 ++ src/determa/state/stores/memory.py | 96 ++ src/determa/state/stores/postgresql.py | 190 +++ src/determa/state/stores/registry.py | 82 + src/determa/state/stores/sqlite.py | 232 +++ src/determa/state/wire.py | 12 + tests/test_checkpoint_host.py | 230 +++ tests/test_cli.py | 8 +- tests/test_execution_stores.py | 191 +++ tests/test_postgresql_store.py | 91 ++ 25 files changed, 5433 insertions(+), 28 deletions(-) create mode 100644 conformance/execution_checkpoint.py create mode 100644 src/determa/state/checkpoint.py create mode 100644 src/determa/state/data/execution-checkpoint.schema.json create mode 100644 src/determa/state/host.py create mode 100644 src/determa/state/stores/__init__.py create mode 100644 src/determa/state/stores/base.py create mode 100644 src/determa/state/stores/file.py create mode 100644 src/determa/state/stores/memory.py create mode 100644 src/determa/state/stores/postgresql.py create mode 100644 src/determa/state/stores/registry.py create mode 100644 src/determa/state/stores/sqlite.py create mode 100644 tests/test_checkpoint_host.py create mode 100644 tests/test_execution_stores.py create mode 100644 tests/test_postgresql_store.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dd5adad..6035c4a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,13 +36,13 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: fruwehq/determa-state-conformance - ref: 600523ca08c3b8a6ee790439a32dc4ce47f71b95 + ref: c6637066c1923e451edad62b7dc2ae73babfbec0 path: .pinned/determa-state-conformance - name: Check out pinned specification uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: fruwehq/determa-state-spec - ref: c1635d74e6a216301a8986d37be8ce7e7111dfd7 + ref: 318ef1f16ae024770090bd338c8b70056df2855b path: .pinned/determa-state-spec - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: @@ -55,3 +55,32 @@ jobs: DETERMA_CONFORMANCE_DIR: ${{ github.workspace }}/.pinned/determa-state-conformance DETERMA_SPEC_DIR: ${{ github.workspace }}/.pinned/determa-state-spec run: pytest conformance -q + + postgresql: + runs-on: ubuntu-24.04 + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: determa_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d determa_test" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + cache: pip + - name: Install + run: pip install -e '.[dev,postgresql]' + - name: PostgreSQL adapter tests + env: + DETERMA_POSTGRESQL_DSN: postgresql://postgres:postgres@localhost:5432/determa_test + run: pytest tests/test_postgresql_store.py -q diff --git a/AGENTS.md b/AGENTS.md index e768f51..c084a48 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,27 +11,31 @@ package so it can coexist with the umbrella `determa` launcher. The implementation is conformant only when it passes the language-neutral suite. The synchronized 0.1.0 release uses these immutable inputs: -- specification: `c1635d74e6a216301a8986d37be8ce7e7111dfd7`; -- conformance: `600523ca08c3b8a6ee790439a32dc4ce47f71b95` (110 core cases plus - persistence profiles). +- specification: `318ef1f16ae024770090bd338c8b70056df2855b`; +- conformance: `c6637066c1923e451edad62b7dc2ae73babfbec0` (110 core cases, + persistence profiles, and the 83-vector execution-checkpoint profile). The package metadata is `0.1.0` for the next synchronized release; the specification, conformance suite, Python engine, and Rust engine version together. ## Boundaries -The implemented public API is `load_bundle`, `create`, and `dispatch`, plus validation -and error types exported by `determa.state`. It implements the exact `format: 1` +The pure public API remains `load_bundle`, `create`, and `dispatch`, plus validation +and error types exported by `determa.state`. The optional synchronous `ExecutionHost` +and execution-store APIs wrap that core without changing its exact `format: 1` grammar. Do not restore abandoned draft field names or compatibility aliases. The core is a pure foreground transform over one root ownership aggregate. It has no -hidden queues, timers, stores, snapshots, migration, enabled-event inspection, or -standardized execution CLI. Snapshot portability, machine definition migration or -hot-swap, package imports, and living tutorials are separate initiatives. +hidden queues, timers, stores, or standardized execution CLI. Portable aggregate +migration remains pure. The optional host owns checkpoint transactions, accepted +pending delivery, receipts, outbox state, retention, and tombstones. The CLI remains +validation-only. Layout: - `src/determa/state/` — loader, validator, CEL profile, model, and engine; +- `src/determa/state/checkpoint.py`, `host.py`, and `stores/` — optional portable + checkpoint validation, synchronous host orchestration, registry, and adapters; - `src/determa/state/data/machine.schema.json` — exact pinned normative schema; - `tests/` — hermetic implementation tests; - `conformance/` — black-box format-1 harness and immutable pins; @@ -49,6 +53,8 @@ Layout: - Keep JSON/public identifiers unabbreviated and use only exact normative grammar. - Unit tests remain hermetic and offline. Conformance may use its immutable checkouts. - Preserve lazy CEL and JSON Schema imports where practical. +- Preserve lazy Psycopg import and explicit file/database schema setup. Never add + checkpoint or root-marker deletion. ## Gates @@ -60,6 +66,9 @@ pytest -q DETERMA_CONFORMANCE_DIR=/path/to/conformance \ DETERMA_SPEC_DIR=/path/to/spec \ pytest conformance -q + +# Optional, only with a configured service and installed postgresql extra +DETERMA_POSTGRESQL_DSN=postgresql://... pytest tests/test_postgresql_store.py -q ``` `make check` runs lint, type checking, and unit tests. `make conformance` fetches or diff --git a/Makefile b/Makefile index c9b694c..ccfb286 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: test conformance lint typecheck check all sync-schema +.PHONY: test conformance postgresql-test lint typecheck check all sync-schema # Unit tests — the implementation's own suite. Hermetic and offline. test: @@ -10,6 +10,10 @@ test: conformance: pytest conformance -q +# Optional live adapter test. Requires DETERMA_POSTGRESQL_DSN and the postgresql extra. +postgresql-test: + pytest tests/test_postgresql_store.py -q + # Refresh the bundled JSON Schema from the immutable format-1 specification pin # (or DETERMA_SPEC_DIR=/path/to/determa-state-spec). sync-schema: diff --git a/README.md b/README.md index a084fe9..40503b2 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ Python implementation of [Determa State](https://github.com/fruwehq/determa-stat a language-agnostic statechart engine with a shared normative conformance suite. This release implements Determa State `format: 1` at the synchronized specification -commit `c1635d74e6a216301a8986d37be8ce7e7111dfd7`. Correctness is determined by the -110-case core suite and persistence profiles at conformance commit -`600523ca08c3b8a6ee790439a32dc4ce47f71b95`. +commit `318ef1f16ae024770090bd338c8b70056df2855b`. Correctness is determined by the +110-case core suite, persistence profiles, and 83-vector execution-checkpoint profile +at conformance commit `c6637066c1923e451edad62b7dc2ae73babfbec0`. The package metadata is `0.1.0` for the next synchronized release of the specification, conformance suite, Python engine, and Rust engine. @@ -26,6 +26,12 @@ python -m pip install -e . The distribution is `determa-state`; the import is `determa.state`. It also installs `determa-state` and `determa-state-python` commands. +PostgreSQL support is optional and imports Psycopg only when that adapter is used: + +```sh +python -m pip install -e '.[postgresql]' +``` + ## Define A Bundle Format 1 uses one self-contained bundle containing one or more machines: @@ -141,11 +147,43 @@ migrations return a deterministic `MigrationFailure` and do not mutate the suppl artifact or resolver. Definition and descriptor resolvers are protocols, so applications can back them with -an immutable registry or a transaction-local cache. Database schemas, broker -acknowledgement, retries, and quarantine remain host responsibilities; the conformance -persistence profile verifies the required transaction ordering. +an immutable registry or a transaction-local cache. + +## Run A Checkpoint Host + +`ExecutionHost` is an optional synchronous durable-host layer. It stores one strict +portable checkpoint per root and implements durable acceptance, committed receipts, +pending delivery, outbox lifecycle, keyed migration, bounded replay retention, CAS, +and terminal tombstones. Direct store injection does not require a registry: + +```python +store = ds.SQLiteExecutionStore("state.db") +store.setup_schema() # always explicit +resolver = ds.MemoryArtifactResolver(definitions={bundle.fingerprint: bundle}) +host = ds.ExecutionHost(store, resolver) + +created = host.create( + bundle, + machine_id="counter", + root_instance_id="counter-42", + creation_id="create-counter-42", + bindings={}, +) +checkpoint = host.read_checkpoint("counter-42").document +``` + +`MemoryExecutionStore` is ephemeral. `FileExecutionStore` provides locked atomic +replacement and restart persistence only. SQLite advertises durable single-writer +storage only with its verified transaction, journal, and synchronization settings. +The optional PostgreSQL adapter provides concurrent CAS and can join a caller-owned +native transaction. File and database schema setup is never implicit. -## Implemented Core +`ExecutionStoreRegistry` starts empty. `register_bundled_execution_stores` registers +`memory`, `file`, `sqlite`, and `postgresql` through the same public operation used by +third-party factories. URI resolution extracts only the scheme; each factory owns its +configuration. Root checkpoint deletion is unsupported. + +## Implemented Surface - strict format-1 loading, default materialization, bundle fingerprinting, and exact source-level scalar handling; @@ -161,10 +199,15 @@ persistence profile verifies the required transaction ordering. - canonical aggregate serialization/restoration, portable typed values, package attachments, exact definition resolution, trusted lazy migration, deterministic audits, resource limits, and atomic migrate-and-dispatch results. - -Format 1 deliberately does not define native queues, timers, deferral, dead letters, -database schemas, package imports, standardized enabled-event inspection, or a -standardized execution CLI. +- strict portable execution-checkpoint parsing, canonical digests, semantic + validation, synchronous transaction/CAS/replay orchestration, receipts, pending + delivery, outbox lifecycle, replay retention, and root tombstones; +- public direct execution-store injection and explicit registration for memory, file, + SQLite, optional PostgreSQL, and third-party adapters. + +Format 1 deliberately does not define timers, a broker implementation, package +imports, standardized enabled-event inspection, or a standardized execution CLI. +Adapter storage schemas are implementation-owned and require explicit setup. The implementation-local CLI only validates a bundle: diff --git a/conformance/execution_checkpoint.py b/conformance/execution_checkpoint.py new file mode 100644 index 0000000..c8bfe60 --- /dev/null +++ b/conformance/execution_checkpoint.py @@ -0,0 +1,465 @@ +"""Driver for the optional execution-checkpoint host profile.""" + +from __future__ import annotations + +import copy +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + +import determa.state.host as host_module +from determa.state import ( + ArtifactError, + ExecutionHost, + ExecutionHostError, + ExecutionStore, + ExecutionStoreError, + ExecutionStoreRegistry, + FileExecutionStore, + MemoryArtifactResolver, + MemoryExecutionStore, + PostgreSQLExecutionStore, + SQLiteExecutionStore, + load_bundle, + memory_execution_store_factory, + restore_execution_checkpoint, + serialize_execution_checkpoint, + validate_host_profile, +) + +from .harness import conformance_root + +PROFILE_DIR = ( + conformance_root() / "conformance" / "profiles" / "execution-checkpoint" +) + + +@dataclass(frozen=True) +class ExecutionCheckpointCase: + name: str + path: Path + + @property + def test(self) -> dict[str, Any]: + return yaml.safe_load((self.path / "test.yaml").read_text(encoding="utf-8")) + + +@dataclass(frozen=True) +class ExecutionCheckpointVector: + case: ExecutionCheckpointCase + vector: dict[str, Any] + + @property + def name(self) -> str: + return f"{self.case.name}/{self.vector['name']}" + + +def execution_checkpoint_cases() -> list[ExecutionCheckpointCase]: + if not PROFILE_DIR.exists(): + return [] + return [ + ExecutionCheckpointCase(path.name, path) + for path in sorted(PROFILE_DIR.iterdir()) + if path.is_dir() and (path / "test.yaml").exists() + ] + + +def execution_checkpoint_vectors() -> list[ExecutionCheckpointVector]: + return [ + ExecutionCheckpointVector(case, vector) + for case in execution_checkpoint_cases() + for vector in case.test["execution_checkpoint_profile"]["vectors"] + ] + + +def _json(path: Path) -> Any: + return json.loads(path.read_text(encoding="utf-8")) + + +def _pointer(document: Any, pointer: str) -> Any: + current = document + for part in pointer.removeprefix("/").split("/"): + current = current[part.replace("~1", "/").replace("~0", "~")] + return current + + +def _resolver(case: ExecutionCheckpointCase) -> MemoryArtifactResolver: + definitions = {} + descriptors = {} + for path in case.path.glob("*.yaml"): + try: + bundle = load_bundle(path.read_text(encoding="utf-8")) + except Exception: + continue + definitions[bundle.fingerprint] = bundle + from determa.state.wire import migration_descriptor_digest + + for path in case.path.glob("*migration-descriptor*.json"): + descriptor = _json(path) + descriptors[migration_descriptor_digest(descriptor)] = descriptor + return MemoryArtifactResolver( + definitions=definitions, migration_descriptors=descriptors + ) + + +def _checkpoint_root(path: Path) -> str: + return str(_json(path)["root_instance_id"]) + + +def _delivery_candidate(request: dict[str, Any]) -> dict[str, Any] | None: + if "candidate" in request: + return request["candidate"] + return { + name: copy.deepcopy(request[name]) + for name in ( + "root_instance_id", + "delivery_mode", + "origin", + "envelope", + "envelope_digest", + ) + if name in request + } + + +def _fault_injector(boundary: str | None) -> Any: + def inject(actual: str) -> None: + if actual == boundary == "before_commit": + raise ExecutionHostError("injected_pre_commit_failure") + if actual == boundary == "after_commit_before_response": + raise ExecutionHostError("response_lost_after_commit") + + return inject + + +def _invoke_host( + host: ExecutionHost, + operation: str, + root_instance_id: str, + request: dict[str, Any], +) -> dict[str, Any]: + expected = { + "expected_revision": request.get("expected_revision", ""), + "expected_checkpoint_digest": request.get( + "expected_checkpoint_digest", "" + ), + } + if operation == "create": + bundle = load_bundle( + (Path(request["_case_path"]) / request["bundle_file"]).read_text( + encoding="utf-8" + ) + ) + return host.create( + bundle, + request["machine_id"], + request["root_instance_id"], + request["creation_id"], + request["bindings"], + ) + if operation == "accept_delivery": + return host.accept_delivery( + root_instance_id, _delivery_candidate(request), **expected + ) + if operation == "process_pending_delivery": + return host.process_pending_delivery( + root_instance_id, _delivery_candidate(request), **expected + ) + if operation == "foreground_process_delivery": + return host.foreground_process_delivery( + root_instance_id, _delivery_candidate(request), **expected + ) + if operation == "maintenance_migration": + return host.maintenance_migration( + root_instance_id, + request["operation_id"], + request["target_validated_bundle_fingerprint"], + request["migration_descriptor_digest_route"], + source_aggregate_state_digest=request[ + "source_aggregate_state_digest" + ], + maintenance_mode=request["maintenance_mode"], + **expected, + ) + if operation == "update_pending_outbox": + return host.update_pending_outbox( + root_instance_id, + request["effect_id"], + request["desired_pending_state"], + **expected, + ) + if operation == "terminalize_outbox": + return host.terminalize_outbox( + root_instance_id, + request["effect_id"], + request["terminal_outcome"], + **expected, + ) + if operation == "compact_outbox": + return host.compact_outbox( + root_instance_id, request["effect_id"], **expected + ) + if operation == "delete_outbox_record": + return host.delete_outbox_record( + root_instance_id, request["effect_id"], **expected + ) + if operation == "update_replay_retention": + return host.update_replay_retention( + root_instance_id, + request["target_replay_retention"], + **expected, + ) + if operation == "tombstone_root": + return host.tombstone_root( + root_instance_id, request["operation_id"], **expected + ) + if operation == "delete_checkpoint": + return host.delete_checkpoint(root_instance_id, **expected) + raise AssertionError(f"unsupported host operation {operation}") + + +class _StaticStore(ExecutionStore): + def __init__(self, capabilities: list[str]) -> None: + self._capabilities = frozenset(capabilities) + + @property + def capabilities(self) -> frozenset[str]: + return self._capabilities + + def transaction( + self, + root_instance_id: str, + *, + native_transaction: Any | None = None, + ) -> Any: + del root_instance_id, native_transaction + raise AssertionError("profile-only store must not process roots") + + def setup_schema(self) -> None: + return None + + def health(self) -> dict[str, Any]: + return {"healthy": True} + + +def _adapter_operation(vector: dict[str, Any]) -> dict[str, Any]: + operation = vector["operation"] + if operation == "inject_execution_store": + ExecutionHost(MemoryExecutionStore(), MemoryArtifactResolver()) + return {"result": "accepted"} + capabilities = vector.get("advertised_capabilities", []) + requested = set(vector.get("requested_capabilities", [])) + if operation == "validate_host_profile": + if not requested.issubset(capabilities): + raise ExecutionHostError("adapter_capability_mismatch") + validate_host_profile( + frozenset(capabilities), + vector["host_profile"], + checkpoint_retention_mode=vector["checkpoint_retention_mode"], + host_features=frozenset(vector["host_features"]), + ) + return {"result": "accepted"} + + registry = ExecutionStoreRegistry() + identifier = vector["adapter_identifier"] + + def static_factory(uri: str, configuration: dict[str, Any]) -> ExecutionStore: + del uri + if not vector["configuration_valid"] or configuration: + raise ExecutionStoreError("invalid_adapter_configuration") + return _StaticStore(capabilities) + + if operation == "register_adapter": + if identifier == "memory": + registry.register(identifier, memory_execution_store_factory) + registry.resolve( + "memory:", required_capabilities=frozenset(requested) + ) + else: + registry.register(identifier, static_factory) + registry.register(identifier, static_factory) + return {"result": "accepted"} + + def bundled_factory( + _uri: str, _configuration: dict[str, Any] + ) -> ExecutionStore: + if identifier == "memory": + return MemoryExecutionStore() + if identifier == "file": + return FileExecutionStore("/tmp/unused") + if identifier == "sqlite": + return SQLiteExecutionStore("/tmp/unused.sqlite") + if identifier == "postgresql": + return PostgreSQLExecutionStore("postgresql://unused") + return static_factory(_uri, _configuration) + + factory = bundled_factory + uri = { + "memory": "memory:", + "file": "file:///tmp/unused", + "sqlite": "sqlite:///tmp/unused.sqlite", + "postgresql": "postgresql://unused", + }.get(identifier, f"{identifier}:") + if identifier != "absent-store": + registry.register(identifier, factory) + registry.resolve(uri, required_capabilities=frozenset(requested)) + return {"result": "accepted"} + + +def _assert_response(vector: dict[str, Any], response: dict[str, Any]) -> None: + expected = vector["expect"] + assert response["result"] == expected["result"] + if "receipt_sequence" in expected and "receipt" in response: + assert response["receipt"]["receipt_sequence"] == expected["receipt_sequence"] + if "delivery_sequence" in expected: + assert response["delivery_sequence"] == expected["delivery_sequence"] + if "accepted_revision" in expected: + assert response["accepted_revision"] == expected["accepted_revision"] + if "code" in expected: + assert response["failure"]["code"] == expected["code"] + + +def run_execution_checkpoint_vector(item: ExecutionCheckpointVector) -> None: + case = item.case + vector = item.vector + expected = vector["expect"] + before_name = vector.get("checkpoint_before") + after_name = expected["checkpoint_after"] + if vector["operation"] in { + "inject_execution_store", + "register_adapter", + "resolve_adapter", + "validate_host_profile", + }: + try: + response = _adapter_operation(vector) + except (ExecutionHostError, ExecutionStoreError) as exc: + response = {"result": "failure", "failure": {"code": exc.code}} + _assert_response(vector, response) + return + + initial = {} + if before_name is not None: + before_path = case.path / before_name + root_instance_id = _checkpoint_root(before_path) + initial[root_instance_id] = before_path.read_bytes() + else: + request_reference = vector.get("input") + assert request_reference is not None + request_document = _json(case.path / request_reference["file"]) + request = copy.deepcopy( + _pointer(request_document, request_reference["pointer"]) + ) + root_instance_id = request["root_instance_id"] + store = MemoryExecutionStore(initial) + host = ExecutionHost( + store, + _resolver(case), + fault_injector=_fault_injector(vector.get("failure_boundary")), + ) + request_reference = vector.get("input") + request = ( + {} + if request_reference is None + else copy.deepcopy( + _pointer( + _json(case.path / request_reference["file"]), + request_reference["pointer"], + ) + ) + ) + request["_case_path"] = str(case.path) + + calls: list[str] = [] + originals = ( + host_module.core_create, + host_module.core_dispatch, + host_module.migrate_aggregate, + ) + + def observed_create(*args: Any, **kwargs: Any) -> Any: + calls.append("create") + return originals[0](*args, **kwargs) + + def observed_dispatch(*args: Any, **kwargs: Any) -> Any: + calls.append("dispatch") + return originals[1](*args, **kwargs) + + def observed_migrate(*args: Any, **kwargs: Any) -> Any: + calls.append("migrate") + return originals[2](*args, **kwargs) + + host_module.core_create = observed_create + host_module.core_dispatch = observed_dispatch + host_module.migrate_aggregate = observed_migrate + try: + try: + response = _invoke_host( + host, vector["operation"], root_instance_id, request + ) + except ExecutionHostError as exc: + response = { + "result": ( + "response_lost" + if exc.code == "response_lost_after_commit" + else "failure" + ), + "failure": {"code": exc.code}, + } + finally: + ( + host_module.core_create, + host_module.core_dispatch, + host_module.migrate_aggregate, + ) = originals + _assert_response(vector, response) + assert calls == ([] if expected["core_call"] == "none" else [expected["core_call"]]) + + restored = host.read_checkpoint(root_instance_id) + actual_checkpoint = None if restored is None else restored.document + expected_checkpoint = ( + None if after_name is None else _json(case.path / after_name) + ) + assert actual_checkpoint == expected_checkpoint + + +def validate_execution_checkpoint_artifact( + case: ExecutionCheckpointCase, artifact: dict[str, Any] +) -> None: + path = case.path / artifact["file"] + resolver = _resolver(case) + if artifact.get("canonical_of"): + expected = _json(case.path / artifact["canonical_of"]) + assert path.read_bytes() == serialize_execution_checkpoint(expected) + if artifact.get("semantic_probe") == "compact_intent_digest": + source_path = case.path / artifact["semantic_source"] + root_instance_id = _checkpoint_root(source_path) + request = _pointer( + _json(case.path / artifact["semantic_input_file"]), + artifact["semantic_input_pointer"], + ) + host = ExecutionHost( + MemoryExecutionStore({root_instance_id: source_path.read_bytes()}), + resolver, + ) + host.compact_outbox( + root_instance_id, + request["effect_id"], + expected_revision=request["expected_revision"], + expected_checkpoint_digest=request["expected_checkpoint_digest"], + ) + actual = host.read_checkpoint(root_instance_id) + assert actual is not None + assert actual.document == _json(case.path / artifact["semantic_expected"]) + assert actual.document != _json(path) + return + try: + restore_execution_checkpoint(path.read_bytes(), resolver) + code = None + except ArtifactError as exc: + code = exc.code + expected = None if artifact["valid"] else artifact["error"] + assert code == expected diff --git a/conformance/pins.py b/conformance/pins.py index 3e4266e..008d02a 100644 --- a/conformance/pins.py +++ b/conformance/pins.py @@ -4,8 +4,8 @@ from pathlib import Path -CONFORMANCE_COMMIT = "600523ca08c3b8a6ee790439a32dc4ce47f71b95" -SPEC_COMMIT = "c1635d74e6a216301a8986d37be8ce7e7111dfd7" +CONFORMANCE_COMMIT = "c6637066c1923e451edad62b7dc2ae73babfbec0" +SPEC_COMMIT = "318ef1f16ae024770090bd338c8b70056df2855b" ROOT = Path(__file__).resolve().parent.parent CONFORMANCE_CACHE = ROOT / ".cache" / f"determa-state-conformance-{CONFORMANCE_COMMIT[:12]}" diff --git a/conformance/test_conformance.py b/conformance/test_conformance.py index e74e6fe..4232fa3 100644 --- a/conformance/test_conformance.py +++ b/conformance/test_conformance.py @@ -13,6 +13,12 @@ from determa.state.validator import schema as bundled_schema from determa.state.wire import artifact_schema +from .execution_checkpoint import ( + execution_checkpoint_cases, + execution_checkpoint_vectors, + run_execution_checkpoint_vector, + validate_execution_checkpoint_artifact, +) from .harness import CORE_DIR, CoreCase, core_cases, run_case from .persistence import persistence_vector_cases, run_persistence_vectors from .persistence_profiles import ( @@ -40,6 +46,7 @@ def _spec_root() -> Path | None: def test_suite_present() -> None: assert CORE_DIR.exists(), "pinned conformance suite is unavailable" assert len(core_cases()) == 110 + assert len(execution_checkpoint_vectors()) == 83 def test_bundled_schema_matches_pinned_spec() -> None: @@ -54,6 +61,7 @@ def test_bundled_schema_matches_pinned_spec() -> None: ("aggregate-state.schema.json", "aggregate_state"), ("migration-descriptor.schema.json", "migration_descriptor"), ("aggregate-state-package.schema.json", "aggregate_state_package"), + ("execution-checkpoint.schema.json", "execution_checkpoint"), ], ) def test_bundled_artifact_schemas_match_pinned_spec(name: str, kind: str) -> None: @@ -64,7 +72,13 @@ def test_bundled_artifact_schemas_match_pinned_spec(name: str, kind: str) -> Non @pytest.mark.parametrize( - "kind", ["aggregate_state", "migration_descriptor", "aggregate_state_package"] + "kind", + [ + "aggregate_state", + "migration_descriptor", + "aggregate_state_package", + "execution_checkpoint", + ], ) def test_bundled_artifact_schema_is_valid_draft_2020_12(kind: str) -> None: Draft202012Validator.check_schema(artifact_schema(kind)) @@ -101,3 +115,30 @@ def test_persistence_vectors(case: CoreCase) -> None: ) def test_persistence_profile(case) -> None: run_persistence_profile(case) + + +@pytest.mark.parametrize( + "item", execution_checkpoint_vectors(), ids=lambda item: item.name +) +def test_execution_checkpoint_profile(item) -> None: + run_execution_checkpoint_vector(item) + + +@pytest.mark.parametrize( + ("case", "artifact"), + [ + (case, artifact) + for case in execution_checkpoint_cases() + for artifact in case.test["artifacts"]["documents"] + if artifact["kind"] == "execution_checkpoint" + ], + ids=lambda value: ( + value.name + if hasattr(value, "name") + else value["file"] + if isinstance(value, dict) + else None + ), +) +def test_execution_checkpoint_artifact(case, artifact) -> None: + validate_execution_checkpoint_artifact(case, artifact) diff --git a/pyproject.toml b/pyproject.toml index a3fd6e8..378eb20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,9 @@ Specification = "https://github.com/fruwehq/determa-state-spec" Issues = "https://github.com/fruwehq/determa-state-python/issues" [project.optional-dependencies] +postgresql = [ + "psycopg[binary]>=3.1,<4", +] dev = [ "pytest>=8", "ruff", @@ -61,6 +64,7 @@ packages = ["src/determa"] "src/determa/state/data/aggregate-state.schema.json" = "determa/state/data/aggregate-state.schema.json" "src/determa/state/data/migration-descriptor.schema.json" = "determa/state/data/migration-descriptor.schema.json" "src/determa/state/data/aggregate-state-package.schema.json" = "determa/state/data/aggregate-state-package.schema.json" +"src/determa/state/data/execution-checkpoint.schema.json" = "determa/state/data/execution-checkpoint.schema.json" [tool.ruff] line-length = 100 diff --git a/scripts/sync_schema.py b/scripts/sync_schema.py index 51b21d0..85e4830 100644 --- a/scripts/sync_schema.py +++ b/scripts/sync_schema.py @@ -24,8 +24,9 @@ "aggregate-state.schema.json", "migration-descriptor.schema.json", "aggregate-state-package.schema.json", + "execution-checkpoint.schema.json", ) -SPEC_COMMIT = "c1635d74e6a216301a8986d37be8ce7e7111dfd7" +SPEC_COMMIT = "318ef1f16ae024770090bd338c8b70056df2855b" def _fetch(name: str) -> str: diff --git a/src/determa/state/__init__.py b/src/determa/state/__init__.py index 11b4d5d..336ad8f 100644 --- a/src/determa/state/__init__.py +++ b/src/determa/state/__init__.py @@ -5,6 +5,15 @@ import logging from .__about__ import __version__ +from .checkpoint import ( + RestoredExecutionCheckpoint, + execution_checkpoint_digest, + restore_execution_checkpoint, + seal_execution_checkpoint, + serialize_execution_checkpoint, + validate_execution_checkpoint_member, + validate_execution_checkpoint_semantics, +) from .definition import Bundle, BundleSource, load_bundle from .engine import Delivery, Result, create, dispatch from .errors import ( @@ -15,6 +24,16 @@ SchemaError, ValidationError, ) +from .host import ( + ExecutionHost, + ExecutionHostError, + creation_request_digest, + delivery_request_digest, + maintenance_migration_request_digest, + outbox_intent_digest, + portable_envelope, + validate_host_profile, +) from .migration import ( MigrationDispatchResult, MigrationFailure, @@ -23,6 +42,33 @@ migrate_aggregate, migrate_and_dispatch, ) +from .stores import ( + COMPACT_EFFECT_IDENTITY_RETENTION, + DURABLE_CONCURRENT, + DURABLE_SINGLE_WRITER, + EPHEMERAL, + PERMANENT_OUTBOX_TERMINAL_RETENTION, + PERMANENT_RECEIPT_RETENTION, + RESTART_PERSISTENT, + ROOT_IDENTITY_RETENTION, + SHARED_APPLICATION_TRANSACTION, + STANDARD_CAPABILITIES, + ExecutionStore, + ExecutionStoreError, + ExecutionStoreFactory, + ExecutionStoreRegistry, + ExecutionStoreTransaction, + FileExecutionStore, + MemoryExecutionStore, + PostgreSQLExecutionStore, + SQLiteExecutionStore, + bundled_execution_store_registry, + file_execution_store_factory, + memory_execution_store_factory, + postgresql_execution_store_factory, + register_bundled_execution_stores, + sqlite_execution_store_factory, +) from .validator import collect_errors, validate from .wire import ( ArtifactResolver, @@ -44,34 +90,74 @@ "Bundle", "BundleSource", "CelError", + "COMPACT_EFFECT_IDENTITY_RETENTION", + "DURABLE_CONCURRENT", + "DURABLE_SINGLE_WRITER", "DetermaError", "DefinitionResolver", "Delivery", "ErrorRecord", + "EPHEMERAL", + "ExecutionHost", + "ExecutionHostError", + "ExecutionStore", + "ExecutionStoreError", + "ExecutionStoreFactory", + "ExecutionStoreRegistry", + "ExecutionStoreTransaction", + "FileExecutionStore", "MemoryArtifactResolver", + "MemoryExecutionStore", "MigrationDescriptorResolver", "MigrationDispatchResult", "MigrationFailure", "MigrationLimits", "MigrationResult", + "PERMANENT_OUTBOX_TERMINAL_RETENTION", + "PERMANENT_RECEIPT_RETENTION", + "PostgreSQLExecutionStore", + "RESTART_PERSISTENT", + "ROOT_IDENTITY_RETENTION", "Result", "RestoredAggregate", "RestoredAggregatePackage", + "RestoredExecutionCheckpoint", + "SHARED_APPLICATION_TRANSACTION", + "STANDARD_CAPABILITIES", "SchemaError", + "SQLiteExecutionStore", "ValidationError", "__version__", "aggregate_envelope", "aggregate_shape_fingerprint", "collect_errors", "create", + "creation_request_digest", + "bundled_execution_store_registry", + "delivery_request_digest", "dispatch", + "execution_checkpoint_digest", + "file_execution_store_factory", "load_bundle", "migrate_aggregate", "migrate_and_dispatch", + "memory_execution_store_factory", + "maintenance_migration_request_digest", + "outbox_intent_digest", + "portable_envelope", + "postgresql_execution_store_factory", + "register_bundled_execution_stores", "restore_aggregate", "restore_aggregate_package", + "restore_execution_checkpoint", + "seal_execution_checkpoint", "serialize_aggregate", + "serialize_execution_checkpoint", + "sqlite_execution_store_factory", "validate", + "validate_execution_checkpoint_member", + "validate_execution_checkpoint_semantics", + "validate_host_profile", ] logging.getLogger("determa.state").addHandler(logging.NullHandler()) diff --git a/src/determa/state/checkpoint.py b/src/determa/state/checkpoint.py new file mode 100644 index 0000000..16c570b --- /dev/null +++ b/src/determa/state/checkpoint.py @@ -0,0 +1,480 @@ +"""Portable execution-checkpoint artifacts and semantic validation.""" + +from __future__ import annotations + +import copy +import re +from collections.abc import Mapping +from dataclasses import dataclass +from functools import cache +from typing import Any + +from .errors import ArtifactError +from .wire import ( + ArtifactSource, + DefinitionResolver, + RestoredAggregate, + _schema_registry, + artifact_schema, + canonical_bytes, + hash_value, + load_json_artifact, + restore_aggregate, +) + +_DECIMAL = re.compile(r"(?:0|[1-9][0-9]*)\Z") + + +@dataclass(frozen=True) +class RestoredExecutionCheckpoint: + """One verified checkpoint and its optional retained aggregate.""" + + document: dict[str, Any] + aggregate: RestoredAggregate | None + canonical_bytes: bytes + source_bytes: bytes + + +def execution_checkpoint_digest(document: Mapping[str, Any]) -> str: + """Compute the exact schema-version-1 checkpoint digest.""" + body = copy.deepcopy(dict(document)) + body.pop("execution_checkpoint_digest", None) + return hash_value(["determa-execution-checkpoint-digest-1", body]) + + +def seal_execution_checkpoint(document: Mapping[str, Any]) -> dict[str, Any]: + """Return a copied checkpoint with its digest recomputed.""" + result = copy.deepcopy(dict(document)) + result.pop("execution_checkpoint_digest", None) + result["execution_checkpoint_digest"] = execution_checkpoint_digest(result) + return result + + +def serialize_execution_checkpoint(document: Mapping[str, Any]) -> bytes: + """Return the exact RFC 8785 checkpoint representation.""" + return canonical_bytes(seal_execution_checkpoint(document)) + + +def _invalid() -> ArtifactError: + return ArtifactError("invalid_execution_checkpoint") + + +@cache +def _member_validator(name: str) -> Any: + import jsonschema + + schema = artifact_schema("execution_checkpoint") + return jsonschema.Draft202012Validator( + {"$ref": f"{schema['$id']}#/$defs/{name}"}, + registry=_schema_registry(), + ) + + +def validate_execution_checkpoint_member(name: str, value: Any) -> bool: + """Return whether a value matches one closed checkpoint schema member.""" + return next(_member_validator(name).iter_errors(value), None) is None + + +def _decimal(value: Any) -> int: + if not isinstance(value, str) or _DECIMAL.fullmatch(value) is None: + raise _invalid() + return int(value) + + +def _ordered_unique(values: list[int]) -> bool: + return values == sorted(values) and len(values) == len(set(values)) + + +def _target_root_instance_id(target: Any) -> str: + if not isinstance(target, dict) or len(target) != 1: + raise _invalid() + member = next(iter(target.values())) + if not isinstance(member, dict): + raise _invalid() + root_instance_id = member.get("root_instance_id") + if not isinstance(root_instance_id, str): + raise _invalid() + return root_instance_id + + +def _validate_receipts( + document: dict[str, Any], +) -> tuple[dict[str, dict[str, Any]], list[dict[str, Any]], set[str], set[str]]: + revision = _decimal(document["revision"]) + receipts = document["operation_receipts"] + creation = receipts[0] + if creation["operation_kind"] != "creation" or creation["receipt_sequence"] != "0": + raise _invalid() + + next_receipt = _decimal(document["next_operation_receipt_sequence"]) + sequences = [_decimal(receipt["receipt_sequence"]) for receipt in receipts] + if not _ordered_unique(sequences) or any(sequence >= next_receipt for sequence in sequences): + raise _invalid() + + retention = document["replay_retention"] + cutoff_value = retention["pruned_through_receipt_sequence"] + cutoff = _decimal(cutoff_value) if cutoff_value is not None else None + if retention["mode"] == "permanent" or cutoff is None: + expected = list(range(next_receipt)) + else: + expected = [0, *range(cutoff + 1, next_receipt)] + if sequences != expected: + raise _invalid() + + by_sequence = {receipt["receipt_sequence"]: receipt for receipt in receipts} + delivery_receipts: list[dict[str, Any]] = [] + referenced_effects: set[str] = set() + referenced_migrations: set[str] = set() + operation_ids: set[str] = set() + prior_revision = -1 + for receipt in receipts: + committed_revision = _decimal(receipt["committed_revision"]) + if committed_revision > revision or committed_revision <= prior_revision: + raise _invalid() + prior_revision = committed_revision + operation_kind = receipt["operation_kind"] + if operation_kind == "creation": + if committed_revision != 0 or receipt is not creation: + raise _invalid() + elif operation_kind == "delivery": + delivery_receipts.append(receipt) + accepted_revision = _decimal(receipt["accepted_revision"]) + accepted_sequence = _decimal(receipt["accepted_delivery_sequence"]) + if ( + accepted_revision > committed_revision + or accepted_sequence >= _decimal(document["next_delivery_sequence"]) + or ( + receipt["delivery_mode"] == "input" + and accepted_revision == 0 + ) + or ( + receipt["delivery_mode"] == "internal" + and accepted_revision == committed_revision + ) + ): + raise _invalid() + else: + operation_id = receipt["operation_id"] + if operation_id in operation_ids: + raise _invalid() + operation_ids.add(operation_id) + migration_sequences = [ + _decimal(sequence) for sequence in receipt["migration_sequences"] + ] + if migration_sequences != sorted(migration_sequences): + raise _invalid() + if ( + receipt["result_code"] == "migration_no_operation" + and receipt["source_aggregate_state_digest"] + != receipt["resulting_aggregate_state_digest"] + ): + raise _invalid() + referenced_migrations.update(receipt["migration_sequences"]) + + for index, emission in enumerate(receipt.get("emission_references", [])): + if _decimal(emission["emission_index"]) != index: + raise _invalid() + if emission["kind"] == "external_outbox": + referenced_effects.add(emission["effect_id"]) + return by_sequence, delivery_receipts, referenced_effects, referenced_migrations + + +def _validate_deliveries( + document: dict[str, Any], + receipts_by_sequence: dict[str, dict[str, Any]], + delivery_receipts: list[dict[str, Any]], +) -> None: + revision = _decimal(document["revision"]) + root_instance_id = document["root_instance_id"] + next_delivery = _decimal(document["next_delivery_sequence"]) + pending = document["pending_deliveries"] + pending_sequences = [_decimal(item["delivery_sequence"]) for item in pending] + if ( + not _ordered_unique(pending_sequences) + or any(sequence >= next_delivery for sequence in pending_sequences) + ): + raise _invalid() + + pending_event_ids = [item["envelope"]["event_id"] for item in pending] + receipt_event_ids = [receipt["event_id"] for receipt in delivery_receipts] + if ( + len(pending_event_ids) != len(set(pending_event_ids)) + or len(receipt_event_ids) != len(set(receipt_event_ids)) + or set(pending_event_ids) & set(receipt_event_ids) + ): + raise _invalid() + + allocated_sequences = [ + *pending_sequences, + *[_decimal(receipt["accepted_delivery_sequence"]) for receipt in delivery_receipts], + ] + if len(allocated_sequences) != len(set(allocated_sequences)): + raise _invalid() + if document["replay_retention"]["mode"] == "permanent" and sorted( + allocated_sequences + ) != list(range(next_delivery)): + raise _invalid() + + deliveries: dict[str, tuple[str, str, str, dict[str, Any]]] = {} + for item in pending: + parsed_accepted_revision = _decimal(item["accepted_revision"]) + if parsed_accepted_revision > revision or ( + item["delivery_mode"] == "input" + and parsed_accepted_revision == 0 + ): + raise _invalid() + expected_digest = hash_value( + [ + "determa-inbox-envelope-digest-1", + "1", + root_instance_id, + item["delivery_mode"], + item["envelope"], + ] + ) + if ( + item["envelope_digest"] != expected_digest + or _target_root_instance_id(item["envelope"]["target"]) != root_instance_id + ): + raise _invalid() + deliveries[item["delivery_sequence"]] = ( + item["envelope"]["event_id"], + item["accepted_revision"], + item["delivery_mode"], + item["origin"], + ) + for receipt in delivery_receipts: + deliveries[receipt["accepted_delivery_sequence"]] = ( + receipt["event_id"], + receipt["accepted_revision"], + receipt["delivery_mode"], + receipt["origin"], + ) + + for sequence, (event_id, accepted_revision, mode, origin) in deliveries.items(): + if mode != "internal": + continue + producer = receipts_by_sequence.get(origin["producing_receipt_sequence"]) + if producer is None or accepted_revision != producer["committed_revision"]: + raise _invalid() + emission_index = _decimal(origin["emission_index"]) + emissions = producer.get("emission_references", []) + if emission_index >= len(emissions): + raise _invalid() + emission = emissions[emission_index] + if ( + emission.get("kind") != "internal_delivery" + or emission.get("event_id") != event_id + or emission.get("delivery_sequence") != sequence + ): + raise _invalid() + + permanent = document["replay_retention"]["mode"] == "permanent" + for receipt in document["operation_receipts"]: + for emission in receipt.get("emission_references", []): + if emission["kind"] != "internal_delivery": + continue + linked = deliveries.get(emission["delivery_sequence"]) + if permanent and (linked is None or linked[0] != emission["event_id"]): + raise _invalid() + + +def _validate_outbox(document: dict[str, Any], referenced_effects: set[str]) -> None: + revision = _decimal(document["revision"]) + pending = document["pending_outbox_intents"] + terminal = document["terminal_outbox_records"] + tombstones = document["outbox_effect_tombstones"] + pending_sequences = [_decimal(item["intent"]["sequence"]) for item in pending] + terminal_sequences = [_decimal(item["terminal_sequence"]) for item in terminal] + tombstone_sequences = [_decimal(item["terminal_sequence"]) for item in tombstones] + if ( + pending_sequences != sorted(pending_sequences) + or terminal_sequences != sorted(terminal_sequences) + or tombstone_sequences != sorted(tombstone_sequences) + ): + raise _invalid() + + full_sequences = [ + *pending_sequences, + *[_decimal(item["intent"]["sequence"]) for item in terminal], + ] + if len(full_sequences) != len(set(full_sequences)): + raise _invalid() + all_terminal_sequences = [*terminal_sequences, *tombstone_sequences] + if ( + len(all_terminal_sequences) != len(set(all_terminal_sequences)) + or any( + sequence >= _decimal(document["next_outbox_terminal_sequence"]) + for sequence in all_terminal_sequences + ) + ): + raise _invalid() + + pending_effects = [item["intent"]["effect_id"] for item in pending] + terminal_effects = [item["intent"]["effect_id"] for item in terminal] + tombstone_effects = [item["effect_id"] for item in tombstones] + all_effects = [*pending_effects, *terminal_effects, *tombstone_effects] + if len(all_effects) != len(set(all_effects)): + raise _invalid() + effect_set = set(all_effects) + if not referenced_effects.issubset(effect_set): + raise _invalid() + if document["replay_retention"]["mode"] == "permanent" and effect_set != referenced_effects: + raise _invalid() + + for item in pending: + state_revision = _decimal(item["state_revision"]) + if state_revision > revision: + raise _invalid() + effect_id = item["intent"]["effect_id"] + producers = [ + receipt + for receipt in document["operation_receipts"] + if any( + emission.get("kind") == "external_outbox" + and emission.get("effect_id") == effect_id + for emission in receipt.get("emission_references", []) + ) + ] + if len(producers) != 1: + if document["replay_retention"]["mode"] == "permanent" or producers: + raise _invalid() + continue + producer_revision = _decimal(producers[0]["committed_revision"]) + if item["delivery_state"]["status"] == "not_attempted": + if state_revision != producer_revision: + raise _invalid() + elif state_revision <= producer_revision: + raise _invalid() + receipt_by_effect = { + emission["effect_id"]: receipt + for receipt in document["operation_receipts"] + for emission in receipt.get("emission_references", []) + if emission["kind"] == "external_outbox" + } + for item in [*terminal, *tombstones]: + committed_revision = _decimal(item["committed_revision"]) + if committed_revision > revision: + raise _invalid() + effect_id = ( + item["intent"]["effect_id"] + if "intent" in item + else item["effect_id"] + ) + producer = receipt_by_effect.get(effect_id) + if producer is not None and committed_revision <= _decimal( + producer["committed_revision"] + ): + raise _invalid() + + +def _validate_audit_and_root( + document: dict[str, Any], referenced_migrations: set[str] +) -> None: + root_instance_id = document["root_instance_id"] + root_record = document["root_record"] + creation = document["operation_receipts"][0] + if root_record["status"] == "retained": + aggregate = root_record["aggregate_state"] + if aggregate["root_instance_id"] != root_instance_id: + raise _invalid() + creation_id = aggregate["creation_id"] + root_runtime_id = aggregate["root_runtime_id"] + else: + creation_id = root_record["creation_id"] + root_runtime_id = root_record["root_runtime_id"] + if document["pending_deliveries"] or document["pending_outbox_intents"]: + raise _invalid() + if creation["creation_id"] != creation_id: + raise _invalid() + + audits = document["migration_audit_records"] + audit_sequences = [_decimal(item["migration_sequence"]) for item in audits] + if not _ordered_unique(audit_sequences): + raise _invalid() + available = {item["migration_sequence"] for item in audits} + if not referenced_migrations.issubset(available): + raise _invalid() + if document["replay_retention"]["mode"] == "permanent" and referenced_migrations != available: + raise _invalid() + if any( + audit["root_instance_id"] != root_instance_id + or audit["root_runtime_id"] != root_runtime_id + for audit in audits + ): + raise _invalid() + audit_by_sequence = {item["migration_sequence"]: item for item in audits} + for receipt in document["operation_receipts"]: + if receipt["operation_kind"] != "maintenance_migration": + continue + linked = [ + audit_by_sequence[sequence] + for sequence in receipt["migration_sequences"] + if sequence in audit_by_sequence + ] + if len(linked) != len(receipt["migration_sequences"]): + raise _invalid() + if linked and ( + linked[0]["source_aggregate_state_digest"] + != receipt["source_aggregate_state_digest"] + or linked[-1]["target_aggregate_state_digest"] + != receipt["resulting_aggregate_state_digest"] + or any( + left["target_aggregate_state_digest"] + != right["source_aggregate_state_digest"] + for left, right in zip(linked, linked[1:], strict=False) + ) + ): + raise _invalid() + + if root_record["status"] == "tombstone": + final_digest = document["operation_receipts"][-1][ + "resulting_aggregate_state_digest" + ] + if root_record["final_aggregate_state_digest"] != final_digest: + raise _invalid() + elif ( + root_record["aggregate_state"]["aggregate_state_digest"] + != document["operation_receipts"][-1]["resulting_aggregate_state_digest"] + ): + raise _invalid() + + +def validate_execution_checkpoint_semantics(document: dict[str, Any]) -> None: + """Validate all schema-version-1 portable cross-field invariants.""" + receipts, deliveries, referenced_effects, referenced_migrations = _validate_receipts( + document + ) + _validate_deliveries(document, receipts, deliveries) + _validate_outbox(document, referenced_effects) + _validate_audit_and_root(document, referenced_migrations) + + +def restore_execution_checkpoint( + source: ArtifactSource, definition_resolver: DefinitionResolver +) -> RestoredExecutionCheckpoint: + """Parse, verify, and restore one strict execution checkpoint.""" + document, raw = load_json_artifact(source, "execution_checkpoint") + aggregate: RestoredAggregate | None = None + if document["root_record"]["status"] == "retained": + try: + aggregate = restore_aggregate( + document["root_record"]["aggregate_state"], definition_resolver + ) + except ArtifactError as exc: + if exc.code in { + "source_definition_unavailable", + "definition_untrusted", + "definition_fingerprint_mismatch", + }: + raise + raise _invalid() from exc + if execution_checkpoint_digest(document) != document["execution_checkpoint_digest"]: + raise ArtifactError("execution_checkpoint_digest_mismatch") + validate_execution_checkpoint_semantics(document) + return RestoredExecutionCheckpoint( + document=copy.deepcopy(document), + aggregate=aggregate, + canonical_bytes=canonical_bytes(document), + source_bytes=raw, + ) diff --git a/src/determa/state/data/execution-checkpoint.schema.json b/src/determa/state/data/execution-checkpoint.schema.json new file mode 100644 index 0000000..e30ad6d --- /dev/null +++ b/src/determa/state/data/execution-checkpoint.schema.json @@ -0,0 +1,1330 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://determa.dev/state/schema/execution-checkpoint.schema.json", + "title": "Determa State portable execution checkpoint", + "description": "Closed durable-host checkpoint for one root ownership aggregate.", + "$ref": "#/$defs/executionCheckpoint", + "$defs": { + "nonEmptyString": { + "type": "string", + "minLength": 1 + }, + "identifier": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + }, + "eventName": { + "oneOf": [ + { + "$ref": "#/$defs/identifier" + }, + { + "enum": [ + "determa.component_completed", + "determa.component_failed", + "determa.spawned_instance_failed" + ] + } + ] + }, + "canonicalDecimal": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)$" + }, + "positiveCanonicalDecimal": { + "type": "string", + "pattern": "^[1-9][0-9]*$" + }, + "sha256": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "typedMap": { + "type": "array", + "prefixItems": [ + { + "const": "map" + }, + { + "type": "array", + "items": { + "type": "array", + "prefixItems": [ + { + "type": "string" + }, + { + "$ref": "aggregate-state.schema.json#/$defs/typedValue" + } + ], + "minItems": 2, + "maxItems": 2 + } + } + ], + "minItems": 2, + "maxItems": 2 + }, + "envelope": { + "type": "object", + "required": [ + "event", + "event_id", + "target", + "payload" + ], + "additionalProperties": false, + "properties": { + "event": { + "$ref": "#/$defs/eventName" + }, + "event_id": { + "$ref": "#/$defs/nonEmptyString" + }, + "target": { + "$ref": "aggregate-state.schema.json#/$defs/targetIdentity" + }, + "payload": { + "$ref": "#/$defs/typedMap" + }, + "correlation_id": { + "$ref": "#/$defs/nonEmptyString" + } + } + }, + "hostInputOrigin": { + "type": "object", + "required": [ + "kind" + ], + "additionalProperties": false, + "properties": { + "kind": { + "const": "host_input" + } + } + }, + "internalEmissionOrigin": { + "type": "object", + "required": [ + "kind", + "producing_receipt_sequence", + "emission_index" + ], + "additionalProperties": false, + "properties": { + "kind": { + "const": "internal_emission" + }, + "producing_receipt_sequence": { + "$ref": "#/$defs/canonicalDecimal" + }, + "emission_index": { + "$ref": "#/$defs/canonicalDecimal" + } + } + }, + "deliveryOrigin": { + "oneOf": [ + { + "$ref": "#/$defs/hostInputOrigin" + }, + { + "$ref": "#/$defs/internalEmissionOrigin" + } + ] + }, + "preAcceptanceFailure": { + "type": "object", + "required": [ + "code" + ], + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "malformed_delivery", + "wrong_root", + "invalid_delivery_mode", + "invalid_delivery_origin", + "delivery_digest_mismatch", + "event_id_conflict", + "tombstoned_root" + ] + } + } + }, + "notAcceptedResult": { + "type": "object", + "required": [ + "result", + "failure" + ], + "additionalProperties": false, + "properties": { + "result": { + "const": "not_accepted" + }, + "failure": { + "$ref": "#/$defs/preAcceptanceFailure" + } + } + }, + "pendingAcceptanceResult": { + "type": "object", + "required": [ + "result", + "event_id", + "delivery_sequence", + "accepted_revision" + ], + "additionalProperties": false, + "properties": { + "result": { + "const": "pending" + }, + "event_id": { + "$ref": "#/$defs/nonEmptyString" + }, + "delivery_sequence": { + "$ref": "#/$defs/canonicalDecimal" + }, + "accepted_revision": { + "$ref": "#/$defs/canonicalDecimal" + } + } + }, + "committedDeliveryResult": { + "type": "object", + "required": [ + "result", + "receipt" + ], + "additionalProperties": false, + "properties": { + "result": { + "const": "committed" + }, + "receipt": { + "$ref": "#/$defs/deliveryReceipt" + } + } + }, + "acceptanceResult": { + "oneOf": [ + { + "$ref": "#/$defs/pendingAcceptanceResult" + }, + { + "$ref": "#/$defs/committedDeliveryResult" + }, + { + "$ref": "#/$defs/notAcceptedResult" + } + ] + }, + "pendingDelivery": { + "type": "object", + "required": [ + "delivery_sequence", + "accepted_revision", + "delivery_mode", + "origin", + "envelope", + "envelope_digest" + ], + "additionalProperties": false, + "properties": { + "delivery_sequence": { + "$ref": "#/$defs/canonicalDecimal" + }, + "accepted_revision": { + "$ref": "#/$defs/canonicalDecimal" + }, + "delivery_mode": { + "enum": [ + "input", + "internal" + ] + }, + "origin": { + "$ref": "#/$defs/deliveryOrigin" + }, + "envelope": { + "$ref": "#/$defs/envelope" + }, + "envelope_digest": { + "$ref": "#/$defs/sha256" + } + }, + "allOf": [ + { + "if": { + "properties": { + "delivery_mode": { + "const": "input" + } + }, + "required": [ + "delivery_mode" + ] + }, + "then": { + "properties": { + "origin": { + "$ref": "#/$defs/hostInputOrigin" + } + } + } + }, + { + "if": { + "properties": { + "delivery_mode": { + "const": "internal" + } + }, + "required": [ + "delivery_mode" + ] + }, + "then": { + "properties": { + "origin": { + "$ref": "#/$defs/internalEmissionOrigin" + } + } + } + } + ] + }, + "internalDeliveryEmissionReference": { + "type": "object", + "required": [ + "kind", + "emission_index", + "event_id", + "delivery_sequence" + ], + "additionalProperties": false, + "properties": { + "kind": { + "const": "internal_delivery" + }, + "emission_index": { + "$ref": "#/$defs/canonicalDecimal" + }, + "event_id": { + "$ref": "#/$defs/nonEmptyString" + }, + "delivery_sequence": { + "$ref": "#/$defs/canonicalDecimal" + } + } + }, + "externalOutboxEmissionReference": { + "type": "object", + "required": [ + "kind", + "emission_index", + "effect_id" + ], + "additionalProperties": false, + "properties": { + "kind": { + "const": "external_outbox" + }, + "emission_index": { + "$ref": "#/$defs/canonicalDecimal" + }, + "effect_id": { + "$ref": "#/$defs/sha256" + } + } + }, + "emissionReference": { + "oneOf": [ + { + "$ref": "#/$defs/internalDeliveryEmissionReference" + }, + { + "$ref": "#/$defs/externalOutboxEmissionReference" + } + ] + }, + "rejection": { + "type": "object", + "required": [ + "code" + ], + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "invalid_event", + "invalid_payload", + "invalid_correlation", + "invalid_instance_target", + "inactive_component_target", + "invalid_prior_state", + "incompatible_bundle" + ] + } + } + }, + "handledOutcome": { + "type": "object", + "required": [ + "status", + "disposition", + "fault", + "rejection" + ], + "additionalProperties": false, + "properties": { + "status": { + "enum": [ + "running", + "completed" + ] + }, + "disposition": { + "const": "handled" + }, + "fault": { + "type": "null" + }, + "rejection": { + "type": "null" + } + } + }, + "unhandledOutcome": { + "type": "object", + "required": [ + "status", + "disposition", + "fault", + "rejection" + ], + "additionalProperties": false, + "properties": { + "status": { + "const": "running" + }, + "disposition": { + "const": "unhandled" + }, + "fault": { + "type": "null" + }, + "rejection": { + "type": "null" + } + } + }, + "rejectedOutcome": { + "type": "object", + "required": [ + "status", + "disposition", + "fault", + "rejection" + ], + "additionalProperties": false, + "properties": { + "status": { + "enum": [ + "running", + "completed", + "faulted" + ] + }, + "disposition": { + "const": "rejected" + }, + "fault": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "aggregate-state.schema.json#/$defs/fault" + } + ] + }, + "rejection": { + "$ref": "#/$defs/rejection" + } + }, + "allOf": [ + { + "if": { + "properties": { + "status": { + "const": "faulted" + } + }, + "required": [ + "status" + ] + }, + "then": { + "properties": { + "fault": { + "$ref": "aggregate-state.schema.json#/$defs/fault" + } + } + }, + "else": { + "properties": { + "fault": { + "type": "null" + } + } + } + } + ] + }, + "faultedOutcome": { + "type": "object", + "required": [ + "status", + "disposition", + "fault", + "rejection" + ], + "additionalProperties": false, + "properties": { + "status": { + "enum": [ + "running", + "faulted" + ] + }, + "disposition": { + "const": "faulted" + }, + "fault": { + "$ref": "aggregate-state.schema.json#/$defs/fault" + }, + "rejection": { + "type": "null" + } + } + }, + "deliveryOutcome": { + "oneOf": [ + { + "$ref": "#/$defs/handledOutcome" + }, + { + "$ref": "#/$defs/unhandledOutcome" + }, + { + "$ref": "#/$defs/rejectedOutcome" + }, + { + "$ref": "#/$defs/faultedOutcome" + } + ] + }, + "creationReceipt": { + "type": "object", + "required": [ + "operation_kind", + "receipt_sequence", + "creation_id", + "request_digest", + "committed_revision", + "resulting_aggregate_state_digest", + "status", + "fault", + "emission_references" + ], + "additionalProperties": false, + "properties": { + "operation_kind": { + "const": "creation" + }, + "receipt_sequence": { + "const": "0" + }, + "creation_id": { + "$ref": "#/$defs/nonEmptyString" + }, + "request_digest": { + "$ref": "#/$defs/sha256" + }, + "committed_revision": { + "const": "0" + }, + "resulting_aggregate_state_digest": { + "$ref": "#/$defs/sha256" + }, + "status": { + "enum": [ + "running", + "completed", + "faulted" + ] + }, + "fault": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "aggregate-state.schema.json#/$defs/fault" + } + ] + }, + "emission_references": { + "type": "array", + "items": { + "$ref": "#/$defs/emissionReference" + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "status": { + "const": "faulted" + } + }, + "required": [ + "status" + ] + }, + "then": { + "properties": { + "fault": { + "$ref": "aggregate-state.schema.json#/$defs/fault" + } + } + }, + "else": { + "properties": { + "fault": { + "type": "null" + } + } + } + } + ] + }, + "deliveryReceipt": { + "type": "object", + "required": [ + "operation_kind", + "receipt_sequence", + "event_id", + "request_digest", + "accepted_delivery_sequence", + "accepted_revision", + "delivery_mode", + "origin", + "committed_revision", + "resulting_aggregate_state_digest", + "outcome", + "emission_references" + ], + "additionalProperties": false, + "properties": { + "operation_kind": { + "const": "delivery" + }, + "receipt_sequence": { + "$ref": "#/$defs/canonicalDecimal" + }, + "event_id": { + "$ref": "#/$defs/nonEmptyString" + }, + "request_digest": { + "$ref": "#/$defs/sha256" + }, + "accepted_delivery_sequence": { + "$ref": "#/$defs/canonicalDecimal" + }, + "accepted_revision": { + "$ref": "#/$defs/canonicalDecimal" + }, + "delivery_mode": { + "enum": [ + "input", + "internal" + ] + }, + "origin": { + "$ref": "#/$defs/deliveryOrigin" + }, + "committed_revision": { + "$ref": "#/$defs/canonicalDecimal" + }, + "resulting_aggregate_state_digest": { + "$ref": "#/$defs/sha256" + }, + "outcome": { + "$ref": "#/$defs/deliveryOutcome" + }, + "emission_references": { + "type": "array", + "items": { + "$ref": "#/$defs/emissionReference" + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "delivery_mode": { + "const": "input" + } + }, + "required": [ + "delivery_mode" + ] + }, + "then": { + "properties": { + "origin": { + "$ref": "#/$defs/hostInputOrigin" + } + } + } + }, + { + "if": { + "properties": { + "delivery_mode": { + "const": "internal" + } + }, + "required": [ + "delivery_mode" + ] + }, + "then": { + "properties": { + "origin": { + "$ref": "#/$defs/internalEmissionOrigin" + } + } + } + } + ] + }, + "maintenanceMigrationReceipt": { + "type": "object", + "required": [ + "operation_kind", + "receipt_sequence", + "operation_id", + "request_digest", + "committed_revision", + "source_aggregate_state_digest", + "resulting_aggregate_state_digest", + "migration_sequences", + "result_code" + ], + "additionalProperties": false, + "properties": { + "operation_kind": { + "const": "maintenance_migration" + }, + "receipt_sequence": { + "$ref": "#/$defs/canonicalDecimal" + }, + "operation_id": { + "$ref": "#/$defs/nonEmptyString" + }, + "request_digest": { + "$ref": "#/$defs/sha256" + }, + "committed_revision": { + "$ref": "#/$defs/canonicalDecimal" + }, + "source_aggregate_state_digest": { + "$ref": "#/$defs/sha256" + }, + "resulting_aggregate_state_digest": { + "$ref": "#/$defs/sha256" + }, + "migration_sequences": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/$defs/positiveCanonicalDecimal" + } + }, + "result_code": { + "enum": [ + "migration_applied", + "migration_no_operation" + ] + } + }, + "allOf": [ + { + "if": { + "properties": { + "result_code": { + "const": "migration_applied" + } + }, + "required": [ + "result_code" + ] + }, + "then": { + "properties": { + "migration_sequences": { + "minItems": 1 + } + } + }, + "else": { + "properties": { + "migration_sequences": { + "maxItems": 0 + } + } + } + } + ] + }, + "operationReceipt": { + "oneOf": [ + { + "$ref": "#/$defs/creationReceipt" + }, + { + "$ref": "#/$defs/deliveryReceipt" + }, + { + "$ref": "#/$defs/maintenanceMigrationReceipt" + } + ] + }, + "outboxIntent": { + "type": "object", + "required": [ + "effect_id", + "sequence", + "event", + "payload", + "correlation_id" + ], + "additionalProperties": false, + "properties": { + "effect_id": { + "$ref": "#/$defs/sha256" + }, + "sequence": { + "$ref": "#/$defs/canonicalDecimal" + }, + "event": { + "$ref": "#/$defs/identifier" + }, + "payload": { + "$ref": "#/$defs/typedMap" + }, + "correlation_id": { + "$ref": "#/$defs/nonEmptyString" + } + } + }, + "notAttemptedOutboxState": { + "type": "object", + "required": [ + "status" + ], + "additionalProperties": false, + "properties": { + "status": { + "const": "not_attempted" + } + } + }, + "retryableFailureOutboxState": { + "type": "object", + "required": [ + "status", + "reason_code" + ], + "additionalProperties": false, + "properties": { + "status": { + "const": "retryable_failure" + }, + "reason_code": { + "$ref": "#/$defs/identifier" + } + } + }, + "ambiguousOutboxState": { + "type": "object", + "required": [ + "status", + "reason_code" + ], + "additionalProperties": false, + "properties": { + "status": { + "const": "ambiguous" + }, + "reason_code": { + "$ref": "#/$defs/identifier" + } + } + }, + "pendingOutboxState": { + "oneOf": [ + { + "$ref": "#/$defs/notAttemptedOutboxState" + }, + { + "$ref": "#/$defs/retryableFailureOutboxState" + }, + { + "$ref": "#/$defs/ambiguousOutboxState" + } + ] + }, + "pendingOutboxIntent": { + "type": "object", + "required": [ + "intent", + "state_revision", + "delivery_state" + ], + "additionalProperties": false, + "properties": { + "intent": { + "$ref": "#/$defs/outboxIntent" + }, + "state_revision": { + "$ref": "#/$defs/canonicalDecimal" + }, + "delivery_state": { + "$ref": "#/$defs/pendingOutboxState" + } + } + }, + "pendingOutboxUpdateResult": { + "type": "object", + "required": [ + "result", + "record" + ], + "additionalProperties": false, + "properties": { + "result": { + "const": "committed" + }, + "record": { + "$ref": "#/$defs/pendingOutboxIntent" + } + } + }, + "confirmedOutboxOutcome": { + "type": "object", + "required": [ + "status" + ], + "additionalProperties": false, + "properties": { + "status": { + "const": "confirmed" + } + } + }, + "reasonedTerminalOutboxOutcome": { + "type": "object", + "required": [ + "status", + "reason_code" + ], + "additionalProperties": false, + "properties": { + "status": { + "enum": [ + "permanently_rejected", + "operator_cancelled", + "discarded", + "dead_lettered" + ] + }, + "reason_code": { + "$ref": "#/$defs/identifier" + } + } + }, + "terminalOutboxOutcome": { + "oneOf": [ + { + "$ref": "#/$defs/confirmedOutboxOutcome" + }, + { + "$ref": "#/$defs/reasonedTerminalOutboxOutcome" + } + ] + }, + "terminalOutboxRecord": { + "type": "object", + "required": [ + "terminal_sequence", + "intent", + "committed_revision", + "outcome" + ], + "additionalProperties": false, + "properties": { + "terminal_sequence": { + "$ref": "#/$defs/canonicalDecimal" + }, + "intent": { + "$ref": "#/$defs/outboxIntent" + }, + "committed_revision": { + "$ref": "#/$defs/canonicalDecimal" + }, + "outcome": { + "$ref": "#/$defs/terminalOutboxOutcome" + } + } + }, + "outboxEffectTombstone": { + "type": "object", + "required": [ + "terminal_sequence", + "effect_id", + "intent_digest", + "committed_revision", + "outcome" + ], + "additionalProperties": false, + "properties": { + "terminal_sequence": { + "$ref": "#/$defs/canonicalDecimal" + }, + "effect_id": { + "$ref": "#/$defs/sha256" + }, + "intent_digest": { + "$ref": "#/$defs/sha256" + }, + "committed_revision": { + "$ref": "#/$defs/canonicalDecimal" + }, + "outcome": { + "$ref": "#/$defs/terminalOutboxOutcome" + } + } + }, + "migrationAuditRecord": { + "type": "object", + "required": [ + "migration_audit_record_schema_version", + "root_instance_id", + "root_runtime_id", + "migration_sequence", + "source_validated_bundle_fingerprint", + "target_validated_bundle_fingerprint", + "migration_descriptor_digest", + "source_aggregate_state_digest", + "target_aggregate_state_digest", + "result_code" + ], + "additionalProperties": false, + "properties": { + "migration_audit_record_schema_version": { + "const": 1 + }, + "root_instance_id": { + "$ref": "#/$defs/nonEmptyString" + }, + "root_runtime_id": { + "$ref": "#/$defs/nonEmptyString" + }, + "migration_sequence": { + "$ref": "#/$defs/canonicalDecimal" + }, + "source_validated_bundle_fingerprint": { + "$ref": "#/$defs/sha256" + }, + "target_validated_bundle_fingerprint": { + "$ref": "#/$defs/sha256" + }, + "migration_descriptor_digest": { + "$ref": "#/$defs/sha256" + }, + "source_aggregate_state_digest": { + "$ref": "#/$defs/sha256" + }, + "target_aggregate_state_digest": { + "$ref": "#/$defs/sha256" + }, + "result_code": { + "const": "migration_applied" + } + } + }, + "retainedRootRecord": { + "type": "object", + "required": [ + "status", + "aggregate_state" + ], + "additionalProperties": false, + "properties": { + "status": { + "const": "retained" + }, + "aggregate_state": { + "$ref": "aggregate-state.schema.json" + } + } + }, + "rootTombstone": { + "type": "object", + "required": [ + "status", + "root_runtime_id", + "creation_id", + "terminal_status", + "final_aggregate_state_digest", + "tombstone_operation_id" + ], + "additionalProperties": false, + "properties": { + "status": { + "const": "tombstone" + }, + "root_runtime_id": { + "$ref": "#/$defs/nonEmptyString" + }, + "creation_id": { + "$ref": "#/$defs/nonEmptyString" + }, + "terminal_status": { + "enum": [ + "completed", + "faulted" + ] + }, + "final_aggregate_state_digest": { + "$ref": "#/$defs/sha256" + }, + "tombstone_operation_id": { + "$ref": "#/$defs/nonEmptyString" + } + } + }, + "rootRecord": { + "oneOf": [ + { + "$ref": "#/$defs/retainedRootRecord" + }, + { + "$ref": "#/$defs/rootTombstone" + } + ] + }, + "permanentReplayRetention": { + "type": "object", + "required": [ + "mode", + "permanent_replay_eligible", + "pruned_through_receipt_sequence", + "policy_identifier" + ], + "additionalProperties": false, + "properties": { + "mode": { + "const": "permanent" + }, + "permanent_replay_eligible": { + "const": true + }, + "pruned_through_receipt_sequence": { + "type": "null" + }, + "policy_identifier": { + "type": "null" + } + } + }, + "boundedReplayRetention": { + "type": "object", + "required": [ + "mode", + "permanent_replay_eligible", + "pruned_through_receipt_sequence", + "policy_identifier" + ], + "additionalProperties": false, + "properties": { + "mode": { + "const": "bounded" + }, + "permanent_replay_eligible": { + "const": false + }, + "pruned_through_receipt_sequence": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/positiveCanonicalDecimal" + } + ] + }, + "policy_identifier": { + "$ref": "#/$defs/nonEmptyString" + } + } + }, + "replayRetention": { + "oneOf": [ + { + "$ref": "#/$defs/permanentReplayRetention" + }, + { + "$ref": "#/$defs/boundedReplayRetention" + } + ] + }, + "executionCheckpoint": { + "type": "object", + "required": [ + "execution_checkpoint_format", + "execution_checkpoint_schema_version", + "root_instance_id", + "revision", + "root_record", + "replay_retention", + "next_delivery_sequence", + "pending_deliveries", + "next_operation_receipt_sequence", + "operation_receipts", + "pending_outbox_intents", + "next_outbox_terminal_sequence", + "terminal_outbox_records", + "outbox_effect_tombstones", + "migration_audit_records", + "execution_checkpoint_digest" + ], + "additionalProperties": false, + "properties": { + "execution_checkpoint_format": { + "const": "determa.execution_checkpoint" + }, + "execution_checkpoint_schema_version": { + "const": 1 + }, + "root_instance_id": { + "$ref": "#/$defs/nonEmptyString" + }, + "revision": { + "$ref": "#/$defs/canonicalDecimal" + }, + "root_record": { + "$ref": "#/$defs/rootRecord" + }, + "replay_retention": { + "$ref": "#/$defs/replayRetention" + }, + "next_delivery_sequence": { + "$ref": "#/$defs/canonicalDecimal" + }, + "pending_deliveries": { + "type": "array", + "items": { + "$ref": "#/$defs/pendingDelivery" + } + }, + "next_operation_receipt_sequence": { + "$ref": "#/$defs/canonicalDecimal" + }, + "operation_receipts": { + "type": "array", + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/creationReceipt" + } + ], + "items": { + "$ref": "#/$defs/operationReceipt" + } + }, + "pending_outbox_intents": { + "type": "array", + "items": { + "$ref": "#/$defs/pendingOutboxIntent" + } + }, + "next_outbox_terminal_sequence": { + "$ref": "#/$defs/canonicalDecimal" + }, + "terminal_outbox_records": { + "type": "array", + "items": { + "$ref": "#/$defs/terminalOutboxRecord" + } + }, + "outbox_effect_tombstones": { + "type": "array", + "items": { + "$ref": "#/$defs/outboxEffectTombstone" + } + }, + "migration_audit_records": { + "type": "array", + "items": { + "$ref": "#/$defs/migrationAuditRecord" + } + }, + "execution_checkpoint_digest": { + "$ref": "#/$defs/sha256" + } + }, + "allOf": [ + { + "if": { + "properties": { + "root_record": { + "properties": { + "status": { + "const": "tombstone" + } + }, + "required": [ + "status" + ] + } + }, + "required": [ + "root_record" + ] + }, + "then": { + "properties": { + "pending_deliveries": { + "maxItems": 0 + }, + "pending_outbox_intents": { + "maxItems": 0 + } + } + } + } + ] + } + } +} diff --git a/src/determa/state/host.py b/src/determa/state/host.py new file mode 100644 index 0000000..5b59914 --- /dev/null +++ b/src/determa/state/host.py @@ -0,0 +1,1450 @@ +"""Optional synchronous host for portable execution checkpoints.""" + +from __future__ import annotations + +import copy +from collections.abc import Callable, Mapping, Sequence +from contextlib import nullcontext +from typing import Any, cast + +from .checkpoint import ( + RestoredExecutionCheckpoint, + restore_execution_checkpoint, + seal_execution_checkpoint, + serialize_execution_checkpoint, + validate_execution_checkpoint_member, +) +from .definition import Bundle, BundleSource, load_bundle +from .engine import create as core_create +from .engine import dispatch as core_dispatch +from .errors import DetermaError +from .migration import MigrationLimits, migrate_aggregate +from .stores import ( + COMPACT_EFFECT_IDENTITY_RETENTION, + DURABLE_CONCURRENT, + DURABLE_SINGLE_WRITER, + PERMANENT_OUTBOX_TERMINAL_RETENTION, + PERMANENT_RECEIPT_RETENTION, + ROOT_IDENTITY_RETENTION, + SHARED_APPLICATION_TRANSACTION, + ExecutionStore, + ExecutionStoreRegistry, + ExecutionStoreTransaction, +) +from .wire import ( + ArtifactResolver, + aggregate_envelope, + decoded_typed_value, + hash_value, + typed_value, +) + +FaultInjector = Callable[[str], None] + + +class ExecutionHostError(DetermaError): + """A closed host-layer failure.""" + + def __init__(self, code: str, message: str = "") -> None: + self.code = code + self.message = message or code + super().__init__(self.message) + + +def creation_request_digest( + bundle: Bundle | BundleSource, + machine_id: str, + root_instance_id: str, + creation_id: str, + bindings: Mapping[str, Any], +) -> str: + """Compute one canonical creation operation identity.""" + validated = bundle if isinstance(bundle, Bundle) else load_bundle(bundle) + machine = next( + ( + item + for item in validated.raw["machines"] + if item["machine_id"] == machine_id + ), + None, + ) + machine_version = "0" if machine is None else str(machine["version"]) + return hash_value( + [ + "determa-creation-request-digest-1", + "1", + validated.fingerprint, + validated.namespace, + machine_id, + machine_version, + root_instance_id, + creation_id, + typed_value(dict(bindings)), + ] + ) + + +def delivery_request_digest( + root_instance_id: str, delivery_mode: str, envelope: Mapping[str, Any] +) -> str: + """Compute one canonical pending/receipt delivery identity.""" + return hash_value( + [ + "determa-inbox-envelope-digest-1", + "1", + root_instance_id, + delivery_mode, + dict(envelope), + ] + ) + + +def portable_envelope( + event: str, + event_id: str, + target: Mapping[str, Any], + payload: Mapping[str, Any], + *, + correlation_id: str | None = None, +) -> dict[str, Any]: + """Project one native host envelope into the checkpoint wire shape.""" + result = { + "event": event, + "event_id": event_id, + "target": copy.deepcopy(dict(target)), + "payload": typed_value(dict(payload)), + } + if correlation_id is not None: + result["correlation_id"] = correlation_id + if not validate_execution_checkpoint_member("envelope", result): + raise ExecutionHostError("malformed_delivery") + return result + + +def maintenance_migration_request_digest( + root_instance_id: str, + operation_id: str, + source_aggregate_state_digest: str, + target_validated_bundle_fingerprint: str, + migration_descriptor_digest_route: Sequence[str], + maintenance_mode: bool, +) -> str: + """Compute one canonical keyed maintenance-migration identity.""" + return hash_value( + [ + "determa-maintenance-migration-request-digest-1", + "1", + root_instance_id, + operation_id, + source_aggregate_state_digest, + target_validated_bundle_fingerprint, + list(migration_descriptor_digest_route), + maintenance_mode, + ] + ) + + +def outbox_intent_digest( + root_instance_id: str, intent: Mapping[str, Any] +) -> str: + """Compute the compact evidence digest for one complete outbox intent.""" + return hash_value( + [ + "determa-outbox-intent-digest-1", + "1", + root_instance_id, + dict(intent), + ] + ) + + +def validate_host_profile( + capabilities: set[str] | frozenset[str], + profile: str, + *, + checkpoint_retention_mode: str, + host_features: set[str] | frozenset[str], +) -> None: + """Validate one composed checkpoint-host profile without name inference.""" + durable = bool( + {DURABLE_SINGLE_WRITER, DURABLE_CONCURRENT}.intersection(capabilities) + ) + common = durable and ROOT_IDENTITY_RETENTION in capabilities + atomic = "atomic_checkpoint_processing" in host_features + valid = False + if profile == "durable_embedded_processing": + valid = common and atomic + elif profile == "exactly_once_committed_processing": + valid = ( + common + and atomic + and checkpoint_retention_mode == "permanent" + and PERMANENT_RECEIPT_RETENTION in capabilities + ) + elif profile == "broker_integrated": + valid = common and atomic and { + "acknowledge_after_checkpoint_commit", + "durable_redelivery", + "outbox_worker", + }.issubset(host_features) + elif profile == "strict_durable_outbox": + valid = ( + common + and atomic + and PERMANENT_OUTBOX_TERMINAL_RETENTION in capabilities + and { + "outbox_worker", + "total_outbox_lifecycle", + "retain_unresolved_outbox", + }.issubset(host_features) + ) + elif profile == "compact_durable_outbox": + valid = ( + common + and atomic + and COMPACT_EFFECT_IDENTITY_RETENTION in capabilities + and { + "outbox_worker", + "total_outbox_lifecycle", + "retain_referenced_effect_tombstones", + }.issubset(host_features) + ) + elif profile == "shared_application_transaction": + valid = ( + common + and atomic + and SHARED_APPLICATION_TRANSACTION in capabilities + and "native_shared_application_transaction" in host_features + ) + if not valid: + raise ExecutionHostError("adapter_capability_mismatch") + + +def _project_fault( + result: Mapping[str, Any], aggregate: Mapping[str, Any] | None +) -> dict[str, Any] | None: + fault = result["fault"] + if fault is None or aggregate is None: + return None + for runtime in aggregate["runtimes"]: + candidate = runtime["fault"] + if candidate is not None and candidate["runtime_id"] == fault["runtime_id"]: + return cast(dict[str, Any], copy.deepcopy(candidate)) + raise ExecutionHostError("invalid_execution_checkpoint") + + +def _project_emission(emission: Mapping[str, Any]) -> dict[str, Any]: + if emission["target"] == "external": + return { + "kind": "external", + "effect_id": emission["effect_id"], + "sequence": str(emission["sequence"]), + "event": emission["event"], + "payload": typed_value(emission["payload"]), + "correlation_id": emission["correlation_id"], + } + projected = { + "kind": "internal", + "event": emission["event"], + "event_id": emission["event_id"], + "target": copy.deepcopy(emission["target"]), + "payload": typed_value(emission["payload"]), + } + if "correlation_id" in emission: + projected["correlation_id"] = emission["correlation_id"] + return projected + + +def _project_core_result( + bundle: Bundle, result: Mapping[str, Any] +) -> dict[str, Any]: + aggregate = ( + aggregate_envelope(bundle, result["state"]) + if result["state"] is not None + else None + ) + return { + "status": result["status"], + "disposition": result["disposition"], + "aggregate_state": aggregate, + "emissions": [_project_emission(item) for item in result["emissions"]], + "fault": _project_fault(result, aggregate), + "rejection": copy.deepcopy(result["rejection"]), + } + + +def _append_emissions( + checkpoint: dict[str, Any], + receipt: dict[str, Any], + projected_result: Mapping[str, Any], +) -> None: + for index, emission in enumerate(projected_result["emissions"]): + if emission["kind"] == "internal": + sequence = checkpoint["next_delivery_sequence"] + checkpoint["next_delivery_sequence"] = str(int(sequence) + 1) + origin = { + "kind": "internal_emission", + "producing_receipt_sequence": receipt["receipt_sequence"], + "emission_index": str(index), + } + envelope = { + name: copy.deepcopy(emission[name]) + for name in ("event", "event_id", "target", "payload") + } + if "correlation_id" in emission: + envelope["correlation_id"] = emission["correlation_id"] + checkpoint["pending_deliveries"].append( + { + "delivery_sequence": sequence, + "accepted_revision": checkpoint["revision"], + "delivery_mode": "internal", + "origin": origin, + "envelope": envelope, + "envelope_digest": delivery_request_digest( + checkpoint["root_instance_id"], "internal", envelope + ), + } + ) + receipt["emission_references"].append( + { + "kind": "internal_delivery", + "emission_index": str(index), + "event_id": emission["event_id"], + "delivery_sequence": sequence, + } + ) + else: + checkpoint["pending_outbox_intents"].append( + { + "intent": { + "effect_id": emission["effect_id"], + "sequence": emission["sequence"], + "event": emission["event"], + "payload": copy.deepcopy(emission["payload"]), + "correlation_id": emission["correlation_id"], + }, + "state_revision": checkpoint["revision"], + "delivery_state": {"status": "not_attempted"}, + } + ) + receipt["emission_references"].append( + { + "kind": "external_outbox", + "emission_index": str(index), + "effect_id": emission["effect_id"], + } + ) + + +def _new_checkpoint( + aggregate: dict[str, Any], + request_digest: str, + projected_result: Mapping[str, Any], +) -> dict[str, Any]: + receipt = { + "operation_kind": "creation", + "receipt_sequence": "0", + "creation_id": aggregate["creation_id"], + "request_digest": request_digest, + "committed_revision": "0", + "resulting_aggregate_state_digest": aggregate["aggregate_state_digest"], + "status": projected_result["status"], + "fault": copy.deepcopy(projected_result["fault"]), + "emission_references": [], + } + checkpoint = { + "execution_checkpoint_format": "determa.execution_checkpoint", + "execution_checkpoint_schema_version": 1, + "root_instance_id": aggregate["root_instance_id"], + "revision": "0", + "root_record": { + "status": "retained", + "aggregate_state": copy.deepcopy(aggregate), + }, + "replay_retention": { + "mode": "permanent", + "permanent_replay_eligible": True, + "pruned_through_receipt_sequence": None, + "policy_identifier": None, + }, + "next_delivery_sequence": "0", + "pending_deliveries": [], + "next_operation_receipt_sequence": "1", + "operation_receipts": [receipt], + "pending_outbox_intents": [], + "next_outbox_terminal_sequence": "0", + "terminal_outbox_records": [], + "outbox_effect_tombstones": [], + "migration_audit_records": [], + } + _append_emissions(checkpoint, receipt, projected_result) + return seal_execution_checkpoint(checkpoint) + + +def _mutate(checkpoint: Mapping[str, Any]) -> dict[str, Any]: + result = copy.deepcopy(dict(checkpoint)) + result.pop("execution_checkpoint_digest", None) + result["revision"] = str(int(result["revision"]) + 1) + return result + + +def _delivery_from_wire(mode: str, envelope: Mapping[str, Any]) -> dict[str, Any]: + target = copy.deepcopy(envelope["target"]) + if "component" in target: + target["component"]["activation_sequence"] = int( + target["component"]["activation_sequence"] + ) + elif "spawned_instance" in target: + target["spawned_instance"]["machine_version"] = int( + target["spawned_instance"]["machine_version"] + ) + native_envelope = { + "event": envelope["event"], + "event_id": envelope["event_id"], + "target": target, + "payload": decoded_typed_value(envelope["payload"]), + } + if "correlation_id" in envelope: + native_envelope["correlation_id"] = envelope["correlation_id"] + return {mode: native_envelope} + + +class ExecutionHost: + """Synchronous checkpoint orchestration around the pure core.""" + + def __init__( + self, + store: ExecutionStore, + artifact_resolver: ArtifactResolver, + *, + fault_injector: FaultInjector | None = None, + ) -> None: + self.store = store + self.artifact_resolver = artifact_resolver + self.fault_injector = fault_injector + + @classmethod + def from_uri( + cls, + uri: str, + artifact_resolver: ArtifactResolver, + registry: ExecutionStoreRegistry, + *, + configuration: Mapping[str, Any] | None = None, + required_capabilities: set[str] | frozenset[str] = frozenset(), + fault_injector: FaultInjector | None = None, + ) -> ExecutionHost: + store = registry.resolve( + uri, + configuration=configuration, + required_capabilities=required_capabilities, + ) + return cls(store, artifact_resolver, fault_injector=fault_injector) + + def _fault(self, boundary: str) -> None: + if self.fault_injector is not None: + self.fault_injector(boundary) + + def _after_commit( + self, + native_transaction: Any | None, + store_transaction: ExecutionStoreTransaction | None, + ) -> None: + if native_transaction is None and store_transaction is None: + self._fault("after_commit_before_response") + + def _restore(self, source: bytes) -> RestoredExecutionCheckpoint: + return restore_execution_checkpoint(source, self.artifact_resolver) + + def _transaction( + self, + root_instance_id: str, + native_transaction: Any | None, + store_transaction: ExecutionStoreTransaction | None, + ) -> Any: + if store_transaction is not None: + return nullcontext(store_transaction) + return self.store.transaction( + root_instance_id, native_transaction=native_transaction + ) + + def _check_expected( + self, + checkpoint: Mapping[str, Any], + expected_revision: str, + expected_checkpoint_digest: str, + ) -> None: + if ( + checkpoint["revision"] != expected_revision + or checkpoint["execution_checkpoint_digest"] + != expected_checkpoint_digest + ): + raise ExecutionHostError("checkpoint_revision_conflict") + + def _stage_insert( + self, transaction: ExecutionStoreTransaction, candidate: dict[str, Any] + ) -> None: + restore_execution_checkpoint(candidate, self.artifact_resolver) + self._fault("before_commit") + if not transaction.insert(serialize_execution_checkpoint(candidate)): + raise ExecutionHostError("checkpoint_revision_conflict") + + def _stage_replace( + self, + transaction: ExecutionStoreTransaction, + previous: Mapping[str, Any], + candidate: dict[str, Any], + ) -> None: + restore_execution_checkpoint(candidate, self.artifact_resolver) + self._fault("before_commit") + if not transaction.replace( + previous["revision"], + previous["execution_checkpoint_digest"], + serialize_execution_checkpoint(candidate), + ): + raise ExecutionHostError("checkpoint_revision_conflict") + + def read_checkpoint( + self, + root_instance_id: str, + *, + native_transaction: Any | None = None, + store_transaction: ExecutionStoreTransaction | None = None, + ) -> RestoredExecutionCheckpoint | None: + with self._transaction( + root_instance_id, native_transaction, store_transaction + ) as transaction: + source = transaction.load() + return None if source is None else self._restore(source) + + def create( + self, + bundle: Bundle | BundleSource, + machine_id: str, + root_instance_id: str, + creation_id: str, + bindings: Mapping[str, Mapping[str, Any]] | None = None, + *, + native_transaction: Any | None = None, + store_transaction: ExecutionStoreTransaction | None = None, + ) -> dict[str, Any]: + validated = bundle if isinstance(bundle, Bundle) else load_bundle(bundle) + normalized_bindings = { + name: copy.deepcopy(dict(value)) + for name, value in (bindings or {}).items() + } + request_digest = creation_request_digest( + validated, + machine_id, + root_instance_id, + creation_id, + normalized_bindings, + ) + with self._transaction( + root_instance_id, native_transaction, store_transaction + ) as transaction: + source = transaction.load() + if source is not None: + checkpoint = self._restore(source).document + receipt = checkpoint["operation_receipts"][0] + if ( + receipt["creation_id"] == creation_id + and receipt["request_digest"] == request_digest + ): + return {"result": "committed", "receipt": copy.deepcopy(receipt)} + raise ExecutionHostError("creation_id_conflict") + result = core_create( + validated, + machine_id, + root_instance_id, + creation_id, + normalized_bindings, + ) + projected = _project_core_result(validated, result) + aggregate = projected["aggregate_state"] + if aggregate is None: + raise ExecutionHostError("creation_rejected") + candidate = _new_checkpoint(aggregate, request_digest, projected) + self._stage_insert(transaction, candidate) + receipt = copy.deepcopy(candidate["operation_receipts"][0]) + self._after_commit(native_transaction, store_transaction) + return {"result": "committed", "receipt": receipt} + + def _delivery_candidate( + self, candidate: Any + ) -> tuple[str | None, str | None, dict[str, Any] | None, dict[str, Any] | None, str | None]: + if not isinstance(candidate, Mapping): + return None, None, None, None, None + allowed = { + "root_instance_id", + "delivery_mode", + "origin", + "envelope", + "envelope_digest", + } + if not set(candidate).issubset(allowed): + return None, None, None, None, None + root_instance_id = candidate.get("root_instance_id") + mode = candidate.get("delivery_mode") + origin = candidate.get("origin") + envelope = candidate.get("envelope") + supplied_digest = candidate.get("envelope_digest") + if ( + not isinstance(root_instance_id, str) + or not root_instance_id + or not isinstance(mode, str) + or not isinstance(origin, dict) + or not isinstance(envelope, dict) + or not validate_execution_checkpoint_member("envelope", envelope) + or (supplied_digest is not None and not isinstance(supplied_digest, str)) + ): + return None, None, None, None, None + return ( + root_instance_id, + mode, + copy.deepcopy(origin), + copy.deepcopy(envelope), + supplied_digest, + ) + + def _not_accepted(self, code: str) -> dict[str, Any]: + return {"result": "not_accepted", "failure": {"code": code}} + + def _delivery_replay( + self, + checkpoint: Mapping[str, Any], + event_id: str, + digest: str, + ) -> dict[str, Any] | None: + for pending in checkpoint["pending_deliveries"]: + if pending["envelope"]["event_id"] == event_id: + if pending["envelope_digest"] != digest: + return self._not_accepted("event_id_conflict") + return { + "result": "pending", + "event_id": event_id, + "delivery_sequence": pending["delivery_sequence"], + "accepted_revision": pending["accepted_revision"], + } + for receipt in checkpoint["operation_receipts"]: + if receipt["operation_kind"] == "delivery" and receipt["event_id"] == event_id: + if receipt["request_digest"] != digest: + return self._not_accepted("event_id_conflict") + return {"result": "committed", "receipt": copy.deepcopy(receipt)} + return None + + def _prepare_acceptance( + self, + checkpoint: Mapping[str, Any], + candidate: Any, + ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: + parsed = self._delivery_candidate(candidate) + root_instance_id, mode, origin, envelope, supplied_digest = parsed + if root_instance_id is None or mode is None or origin is None or envelope is None: + return None, self._not_accepted("malformed_delivery") + if root_instance_id != checkpoint["root_instance_id"]: + return None, self._not_accepted("wrong_root") + + valid_mode = mode in {"input", "internal"} + valid_origin = validate_execution_checkpoint_member("deliveryOrigin", origin) + valid_pair = ( + mode == "input" and origin == {"kind": "host_input"} + ) or ( + mode == "internal" and origin.get("kind") == "internal_emission" + ) + digest = ( + delivery_request_digest(root_instance_id, mode, envelope) + if valid_mode and valid_origin and valid_pair + else None + ) + if digest is not None: + replay = self._delivery_replay( + checkpoint, envelope["event_id"], digest + ) + if replay is not None: + return None, replay + if checkpoint["root_record"]["status"] == "tombstone": + return None, self._not_accepted("tombstoned_root") + if not valid_mode: + return None, self._not_accepted("invalid_delivery_mode") + if not valid_origin or not valid_pair: + return None, self._not_accepted("invalid_delivery_origin") + assert digest is not None + if supplied_digest is not None and supplied_digest != digest: + return None, self._not_accepted("delivery_digest_mismatch") + if ( + _target_root_instance_id(envelope["target"]) + != checkpoint["root_instance_id"] + ): + return None, self._not_accepted("wrong_root") + return { + "root_instance_id": root_instance_id, + "delivery_mode": mode, + "origin": origin, + "envelope": envelope, + "envelope_digest": digest, + }, None + + def accept_delivery( + self, + root_instance_id: str, + candidate: Any, + *, + expected_revision: str, + expected_checkpoint_digest: str, + native_transaction: Any | None = None, + store_transaction: ExecutionStoreTransaction | None = None, + ) -> dict[str, Any]: + with self._transaction( + root_instance_id, native_transaction, store_transaction + ) as transaction: + source = transaction.load() + if source is None: + return self._not_accepted("wrong_root") + checkpoint = self._restore(source).document + prepared, result = self._prepare_acceptance(checkpoint, candidate) + if result is not None: + return result + assert prepared is not None + self._check_expected( + checkpoint, expected_revision, expected_checkpoint_digest + ) + next_checkpoint = _mutate(checkpoint) + sequence = next_checkpoint["next_delivery_sequence"] + next_checkpoint["next_delivery_sequence"] = str(int(sequence) + 1) + pending = { + "delivery_sequence": sequence, + "accepted_revision": next_checkpoint["revision"], + "delivery_mode": prepared["delivery_mode"], + "origin": prepared["origin"], + "envelope": prepared["envelope"], + "envelope_digest": prepared["envelope_digest"], + } + next_checkpoint["pending_deliveries"].append(pending) + next_checkpoint = seal_execution_checkpoint(next_checkpoint) + self._stage_replace(transaction, checkpoint, next_checkpoint) + response = { + "result": "pending", + "event_id": pending["envelope"]["event_id"], + "delivery_sequence": sequence, + "accepted_revision": pending["accepted_revision"], + } + self._after_commit(native_transaction, store_transaction) + return response + + def _commit_delivery( + self, + checkpoint: Mapping[str, Any], + restored: RestoredExecutionCheckpoint, + request: dict[str, Any], + projected: Mapping[str, Any], + *, + foreground: bool, + pending: Mapping[str, Any] | None = None, + ) -> tuple[dict[str, Any], dict[str, Any]]: + candidate = _mutate(checkpoint) + if foreground: + delivery_sequence = candidate["next_delivery_sequence"] + candidate["next_delivery_sequence"] = str(int(delivery_sequence) + 1) + accepted_revision = candidate["revision"] + else: + assert pending is not None + candidate["pending_deliveries"] = [ + item + for item in candidate["pending_deliveries"] + if item["delivery_sequence"] != pending["delivery_sequence"] + ] + delivery_sequence = pending["delivery_sequence"] + accepted_revision = pending["accepted_revision"] + request = { + "delivery_mode": pending["delivery_mode"], + "origin": copy.deepcopy(pending["origin"]), + "envelope": copy.deepcopy(pending["envelope"]), + "envelope_digest": pending["envelope_digest"], + } + receipt_sequence = candidate["next_operation_receipt_sequence"] + candidate["next_operation_receipt_sequence"] = str( + int(receipt_sequence) + 1 + ) + aggregate = copy.deepcopy(projected["aggregate_state"]) + if aggregate is None or restored.aggregate is None: + raise ExecutionHostError("invalid_execution_checkpoint") + candidate["root_record"]["aggregate_state"] = aggregate + receipt = { + "operation_kind": "delivery", + "receipt_sequence": receipt_sequence, + "event_id": request["envelope"]["event_id"], + "request_digest": request["envelope_digest"], + "accepted_delivery_sequence": delivery_sequence, + "accepted_revision": accepted_revision, + "delivery_mode": request["delivery_mode"], + "origin": copy.deepcopy(request["origin"]), + "committed_revision": candidate["revision"], + "resulting_aggregate_state_digest": aggregate[ + "aggregate_state_digest" + ], + "outcome": { + "status": projected["status"], + "disposition": projected["disposition"], + "fault": copy.deepcopy(projected["fault"]), + "rejection": copy.deepcopy(projected["rejection"]), + }, + "emission_references": [], + } + candidate["operation_receipts"].append(receipt) + _append_emissions(candidate, receipt, projected) + return seal_execution_checkpoint(candidate), receipt + + def process_pending_delivery( + self, + root_instance_id: str, + candidate: Any, + *, + expected_revision: str, + expected_checkpoint_digest: str, + native_transaction: Any | None = None, + store_transaction: ExecutionStoreTransaction | None = None, + ) -> dict[str, Any]: + with self._transaction( + root_instance_id, native_transaction, store_transaction + ) as transaction: + source = transaction.load() + if source is None: + raise ExecutionHostError("wrong_root") + restored = self._restore(source) + checkpoint = restored.document + parsed = self._delivery_candidate(candidate) + candidate_root, mode, origin, envelope, supplied_digest = parsed + if ( + candidate_root != root_instance_id + or mode not in {"input", "internal"} + or origin is None + or envelope is None + ): + raise ExecutionHostError("malformed_delivery") + digest = delivery_request_digest(root_instance_id, mode, envelope) + if supplied_digest is not None and supplied_digest != digest: + raise ExecutionHostError("delivery_digest_mismatch") + replay = self._delivery_replay( + checkpoint, envelope["event_id"], digest + ) + if replay is not None and replay["result"] == "committed": + return replay + if replay is not None and replay["result"] == "not_accepted": + raise ExecutionHostError(replay["failure"]["code"]) + pending = next( + ( + item + for item in checkpoint["pending_deliveries"] + if item["envelope"]["event_id"] == envelope["event_id"] + ), + None, + ) + if pending is None or pending["envelope_digest"] != digest: + raise ExecutionHostError("event_id_conflict") + self._check_expected( + checkpoint, expected_revision, expected_checkpoint_digest + ) + if restored.aggregate is None: + raise ExecutionHostError("tombstoned_root") + result = core_dispatch( + restored.aggregate.bundle, + restored.aggregate.state, + _delivery_from_wire( + pending["delivery_mode"], pending["envelope"] + ), + ) + projected = _project_core_result(restored.aggregate.bundle, result) + next_checkpoint, receipt = self._commit_delivery( + checkpoint, + restored, + {}, + projected, + foreground=False, + pending=pending, + ) + self._stage_replace(transaction, checkpoint, next_checkpoint) + response = {"result": "committed", "receipt": copy.deepcopy(receipt)} + self._after_commit(native_transaction, store_transaction) + return response + + def foreground_process_delivery( + self, + root_instance_id: str, + candidate: Any, + *, + expected_revision: str, + expected_checkpoint_digest: str, + native_transaction: Any | None = None, + store_transaction: ExecutionStoreTransaction | None = None, + ) -> dict[str, Any]: + with self._transaction( + root_instance_id, native_transaction, store_transaction + ) as transaction: + source = transaction.load() + if source is None: + raise ExecutionHostError("wrong_root") + restored = self._restore(source) + checkpoint = restored.document + prepared, replay = self._prepare_acceptance(checkpoint, candidate) + if replay is not None: + return replay + assert prepared is not None + self._check_expected( + checkpoint, expected_revision, expected_checkpoint_digest + ) + if restored.aggregate is None: + raise ExecutionHostError("tombstoned_root") + result = core_dispatch( + restored.aggregate.bundle, + restored.aggregate.state, + _delivery_from_wire( + prepared["delivery_mode"], prepared["envelope"] + ), + ) + projected = _project_core_result(restored.aggregate.bundle, result) + next_checkpoint, receipt = self._commit_delivery( + checkpoint, + restored, + prepared, + projected, + foreground=True, + ) + self._stage_replace(transaction, checkpoint, next_checkpoint) + response = {"result": "committed", "receipt": copy.deepcopy(receipt)} + self._after_commit(native_transaction, store_transaction) + return response + + def maintenance_migration( + self, + root_instance_id: str, + operation_id: str, + target_validated_bundle_fingerprint: str, + migration_descriptor_digest_route: Sequence[str], + *, + expected_revision: str, + expected_checkpoint_digest: str, + source_aggregate_state_digest: str | None = None, + maintenance_mode: bool = True, + limits: MigrationLimits | None = None, + native_transaction: Any | None = None, + store_transaction: ExecutionStoreTransaction | None = None, + ) -> dict[str, Any]: + if not operation_id: + raise ExecutionHostError("invalid_migration_request") + with self._transaction( + root_instance_id, native_transaction, store_transaction + ) as transaction: + source = transaction.load() + if source is None: + raise ExecutionHostError("wrong_root") + restored = self._restore(source) + checkpoint = restored.document + if restored.aggregate is None: + raise ExecutionHostError("tombstoned_root") + current_source_digest = restored.aggregate.aggregate_envelope[ + "aggregate_state_digest" + ] + source_digest = ( + current_source_digest + if source_aggregate_state_digest is None + else source_aggregate_state_digest + ) + request_digest = maintenance_migration_request_digest( + root_instance_id, + operation_id, + source_digest, + target_validated_bundle_fingerprint, + migration_descriptor_digest_route, + maintenance_mode, + ) + for receipt in checkpoint["operation_receipts"]: + if ( + receipt["operation_kind"] == "maintenance_migration" + and receipt["operation_id"] == operation_id + ): + if receipt["request_digest"] == request_digest: + return { + "result": "committed", + "receipt": copy.deepcopy(receipt), + } + raise ExecutionHostError("operation_id_conflict") + if source_digest != current_source_digest: + raise ExecutionHostError("invalid_migration_request") + self._check_expected( + checkpoint, expected_revision, expected_checkpoint_digest + ) + result = migrate_aggregate( + restored.aggregate.aggregate_envelope, + target_validated_bundle_fingerprint, + migration_descriptor_digest_route, + self.artifact_resolver, + maintenance_mode=maintenance_mode, + resource_limits=limits, + ) + if result.failure is not None or result.aggregate_envelope is None: + code = ( + "migration_failed" + if result.failure is None + else result.failure.code + ) + raise ExecutionHostError(code) + candidate = _mutate(checkpoint) + receipt_sequence = candidate["next_operation_receipt_sequence"] + candidate["next_operation_receipt_sequence"] = str( + int(receipt_sequence) + 1 + ) + migration_sequences = [ + item["migration_sequence"] for item in result.audit_records + ] + receipt = { + "operation_kind": "maintenance_migration", + "receipt_sequence": receipt_sequence, + "operation_id": operation_id, + "request_digest": request_digest, + "committed_revision": candidate["revision"], + "source_aggregate_state_digest": source_digest, + "resulting_aggregate_state_digest": result.aggregate_envelope[ + "aggregate_state_digest" + ], + "migration_sequences": migration_sequences, + "result_code": ( + "migration_applied" + if migration_sequences + else "migration_no_operation" + ), + } + candidate["root_record"]["aggregate_state"] = copy.deepcopy( + result.aggregate_envelope + ) + candidate["operation_receipts"].append(receipt) + candidate["migration_audit_records"].extend( + copy.deepcopy(result.audit_records) + ) + candidate = seal_execution_checkpoint(candidate) + self._stage_replace(transaction, checkpoint, candidate) + response = {"result": "committed", "receipt": copy.deepcopy(receipt)} + self._after_commit(native_transaction, store_transaction) + return response + + def update_pending_outbox( + self, + root_instance_id: str, + effect_id: str, + desired_pending_state: Mapping[str, Any], + *, + expected_revision: str, + expected_checkpoint_digest: str, + native_transaction: Any | None = None, + store_transaction: ExecutionStoreTransaction | None = None, + ) -> dict[str, Any]: + desired = copy.deepcopy(dict(desired_pending_state)) + if not validate_execution_checkpoint_member("pendingOutboxState", desired): + raise ExecutionHostError("invalid_execution_checkpoint") + with self._transaction( + root_instance_id, native_transaction, store_transaction + ) as transaction: + source = transaction.load() + if source is None: + raise ExecutionHostError("wrong_root") + checkpoint = self._restore(source).document + item = next( + ( + value + for value in checkpoint["pending_outbox_intents"] + if value["intent"]["effect_id"] == effect_id + ), + None, + ) + if item is None: + raise ExecutionHostError("effect_id_conflict") + if item["delivery_state"] == desired: + return {"result": "committed", "record": copy.deepcopy(item)} + self._check_expected( + checkpoint, expected_revision, expected_checkpoint_digest + ) + candidate = _mutate(checkpoint) + candidate_item = next( + value + for value in candidate["pending_outbox_intents"] + if value["intent"]["effect_id"] == effect_id + ) + candidate_item["delivery_state"] = desired + candidate_item["state_revision"] = candidate["revision"] + candidate = seal_execution_checkpoint(candidate) + self._stage_replace(transaction, checkpoint, candidate) + record = copy.deepcopy(candidate_item) + self._after_commit(native_transaction, store_transaction) + return {"result": "committed", "record": record} + + def terminalize_outbox( + self, + root_instance_id: str, + effect_id: str, + terminal_outcome: Mapping[str, Any], + *, + expected_revision: str, + expected_checkpoint_digest: str, + native_transaction: Any | None = None, + store_transaction: ExecutionStoreTransaction | None = None, + ) -> dict[str, Any]: + outcome = copy.deepcopy(dict(terminal_outcome)) + if not validate_execution_checkpoint_member("terminalOutboxOutcome", outcome): + raise ExecutionHostError("invalid_execution_checkpoint") + with self._transaction( + root_instance_id, native_transaction, store_transaction + ) as transaction: + source = transaction.load() + if source is None: + raise ExecutionHostError("wrong_root") + checkpoint = self._restore(source).document + for record in checkpoint["terminal_outbox_records"]: + if record["intent"]["effect_id"] == effect_id: + if record["outcome"] == outcome: + return { + "result": "committed", + "record": copy.deepcopy(record), + } + raise ExecutionHostError("effect_id_conflict") + for record in checkpoint["outbox_effect_tombstones"]: + if record["effect_id"] == effect_id: + if record["outcome"] == outcome: + return { + "result": "committed", + "record": copy.deepcopy(record), + } + raise ExecutionHostError("effect_id_conflict") + pending = next( + ( + value + for value in checkpoint["pending_outbox_intents"] + if value["intent"]["effect_id"] == effect_id + ), + None, + ) + if pending is None: + raise ExecutionHostError("effect_id_conflict") + self._check_expected( + checkpoint, expected_revision, expected_checkpoint_digest + ) + candidate = _mutate(checkpoint) + candidate_pending = next( + value + for value in candidate["pending_outbox_intents"] + if value["intent"]["effect_id"] == effect_id + ) + candidate["pending_outbox_intents"].remove(candidate_pending) + terminal_sequence = candidate["next_outbox_terminal_sequence"] + candidate["next_outbox_terminal_sequence"] = str( + int(terminal_sequence) + 1 + ) + record = { + "terminal_sequence": terminal_sequence, + "intent": candidate_pending["intent"], + "committed_revision": candidate["revision"], + "outcome": outcome, + } + candidate["terminal_outbox_records"].append(record) + candidate = seal_execution_checkpoint(candidate) + self._stage_replace(transaction, checkpoint, candidate) + response = {"result": "committed", "record": copy.deepcopy(record)} + self._after_commit(native_transaction, store_transaction) + return response + + def compact_outbox( + self, + root_instance_id: str, + effect_id: str, + *, + expected_revision: str, + expected_checkpoint_digest: str, + native_transaction: Any | None = None, + store_transaction: ExecutionStoreTransaction | None = None, + ) -> dict[str, Any]: + with self._transaction( + root_instance_id, native_transaction, store_transaction + ) as transaction: + source = transaction.load() + if source is None: + raise ExecutionHostError("wrong_root") + checkpoint = self._restore(source).document + existing = next( + ( + record + for record in checkpoint["outbox_effect_tombstones"] + if record["effect_id"] == effect_id + ), + None, + ) + if existing is not None: + return {"result": "committed", "record": copy.deepcopy(existing)} + terminal = next( + ( + record + for record in checkpoint["terminal_outbox_records"] + if record["intent"]["effect_id"] == effect_id + ), + None, + ) + if terminal is None: + raise ExecutionHostError("effect_id_conflict") + self._check_expected( + checkpoint, expected_revision, expected_checkpoint_digest + ) + candidate = _mutate(checkpoint) + candidate_terminal = next( + record + for record in candidate["terminal_outbox_records"] + if record["intent"]["effect_id"] == effect_id + ) + candidate["terminal_outbox_records"].remove(candidate_terminal) + tombstone = { + "terminal_sequence": candidate_terminal["terminal_sequence"], + "effect_id": effect_id, + "intent_digest": outbox_intent_digest( + root_instance_id, candidate_terminal["intent"] + ), + "committed_revision": candidate_terminal["committed_revision"], + "outcome": candidate_terminal["outcome"], + } + candidate["outbox_effect_tombstones"].append(tombstone) + candidate["outbox_effect_tombstones"].sort( + key=lambda item: int(item["terminal_sequence"]) + ) + candidate = seal_execution_checkpoint(candidate) + self._stage_replace(transaction, checkpoint, candidate) + response = {"result": "committed", "record": copy.deepcopy(tombstone)} + self._after_commit(native_transaction, store_transaction) + return response + + def delete_outbox_record( + self, + root_instance_id: str, + effect_id: str, + *, + expected_revision: str, + expected_checkpoint_digest: str, + native_transaction: Any | None = None, + store_transaction: ExecutionStoreTransaction | None = None, + ) -> dict[str, Any]: + with self._transaction( + root_instance_id, native_transaction, store_transaction + ) as transaction: + source = transaction.load() + if source is None: + raise ExecutionHostError("wrong_root") + checkpoint = self._restore(source).document + if any( + emission.get("kind") == "external_outbox" + and emission.get("effect_id") == effect_id + for receipt in checkpoint["operation_receipts"] + for emission in receipt.get("emission_references", []) + ): + raise ExecutionHostError("invalid_execution_checkpoint") + self._check_expected( + checkpoint, expected_revision, expected_checkpoint_digest + ) + candidate = _mutate(checkpoint) + prior_count = len(candidate["terminal_outbox_records"]) + len( + candidate["outbox_effect_tombstones"] + ) + candidate["terminal_outbox_records"] = [ + item + for item in candidate["terminal_outbox_records"] + if item["intent"]["effect_id"] != effect_id + ] + candidate["outbox_effect_tombstones"] = [ + item + for item in candidate["outbox_effect_tombstones"] + if item["effect_id"] != effect_id + ] + if prior_count == len(candidate["terminal_outbox_records"]) + len( + candidate["outbox_effect_tombstones"] + ): + raise ExecutionHostError("effect_id_conflict") + candidate = seal_execution_checkpoint(candidate) + self._stage_replace(transaction, checkpoint, candidate) + self._after_commit(native_transaction, store_transaction) + return {"result": "committed"} + + def update_replay_retention( + self, + root_instance_id: str, + target_replay_retention: Mapping[str, Any], + *, + expected_revision: str, + expected_checkpoint_digest: str, + native_transaction: Any | None = None, + store_transaction: ExecutionStoreTransaction | None = None, + ) -> dict[str, Any]: + target = copy.deepcopy(dict(target_replay_retention)) + if not validate_execution_checkpoint_member("replayRetention", target): + raise ExecutionHostError("invalid_execution_checkpoint") + with self._transaction( + root_instance_id, native_transaction, store_transaction + ) as transaction: + source = transaction.load() + if source is None: + raise ExecutionHostError("wrong_root") + checkpoint = self._restore(source).document + current = checkpoint["replay_retention"] + if current == target: + return {"result": "committed", "replay_retention": copy.deepcopy(current)} + if current["mode"] == "bounded" and target["mode"] == "permanent": + raise ExecutionHostError("invalid_execution_checkpoint") + current_cutoff = current["pruned_through_receipt_sequence"] + target_cutoff = target["pruned_through_receipt_sequence"] + if target["mode"] == "bounded": + if ( + current["mode"] == "bounded" + and current["policy_identifier"] + != target["policy_identifier"] + ): + raise ExecutionHostError("invalid_execution_checkpoint") + if ( + current_cutoff is not None + and ( + target_cutoff is None + or int(target_cutoff) < int(current_cutoff) + ) + ): + raise ExecutionHostError("invalid_execution_checkpoint") + if target_cutoff is not None and int(target_cutoff) >= int( + checkpoint["next_operation_receipt_sequence"] + ): + raise ExecutionHostError("invalid_execution_checkpoint") + self._check_expected( + checkpoint, expected_revision, expected_checkpoint_digest + ) + candidate = _mutate(checkpoint) + candidate["replay_retention"] = target + if target_cutoff is not None: + cutoff = int(target_cutoff) + candidate["operation_receipts"] = [ + receipt + for receipt in candidate["operation_receipts"] + if receipt["receipt_sequence"] == "0" + or int(receipt["receipt_sequence"]) > cutoff + ] + referenced_migrations = { + sequence + for receipt in candidate["operation_receipts"] + if receipt["operation_kind"] == "maintenance_migration" + for sequence in receipt["migration_sequences"] + } + candidate["migration_audit_records"] = [ + item + for item in candidate["migration_audit_records"] + if item["migration_sequence"] in referenced_migrations + ] + referenced_effects = { + emission["effect_id"] + for receipt in candidate["operation_receipts"] + for emission in receipt.get("emission_references", []) + if emission["kind"] == "external_outbox" + } + candidate["terminal_outbox_records"] = [ + item + for item in candidate["terminal_outbox_records"] + if item["intent"]["effect_id"] in referenced_effects + ] + candidate["outbox_effect_tombstones"] = [ + item + for item in candidate["outbox_effect_tombstones"] + if item["effect_id"] in referenced_effects + ] + candidate = seal_execution_checkpoint(candidate) + try: + self._stage_replace(transaction, checkpoint, candidate) + except Exception as exc: + if getattr(exc, "code", None) == "invalid_execution_checkpoint": + raise ExecutionHostError("invalid_execution_checkpoint") from exc + raise + response = { + "result": "committed", + "replay_retention": copy.deepcopy(target), + } + self._after_commit(native_transaction, store_transaction) + return response + + def tombstone_root( + self, + root_instance_id: str, + operation_id: str, + *, + expected_revision: str, + expected_checkpoint_digest: str, + native_transaction: Any | None = None, + store_transaction: ExecutionStoreTransaction | None = None, + ) -> dict[str, Any]: + if not operation_id: + raise ExecutionHostError("invalid_execution_checkpoint") + with self._transaction( + root_instance_id, native_transaction, store_transaction + ) as transaction: + source = transaction.load() + if source is None: + raise ExecutionHostError("wrong_root") + restored = self._restore(source) + checkpoint = restored.document + root_record = checkpoint["root_record"] + if root_record["status"] == "tombstone": + if root_record["tombstone_operation_id"] == operation_id: + return { + "result": "tombstoned", + "tombstone": copy.deepcopy(root_record), + } + raise ExecutionHostError("operation_id_conflict") + self._check_expected( + checkpoint, expected_revision, expected_checkpoint_digest + ) + if restored.aggregate is None: + raise ExecutionHostError("invalid_execution_checkpoint") + root_runtime = restored.aggregate.state["runtimes"][ + restored.aggregate.state["root_runtime_id"] + ] + if ( + root_runtime["status"] not in {"completed", "faulted"} + or checkpoint["pending_deliveries"] + or checkpoint["pending_outbox_intents"] + ): + raise ExecutionHostError("invalid_execution_checkpoint") + aggregate = root_record["aggregate_state"] + candidate = _mutate(checkpoint) + tombstone = { + "status": "tombstone", + "root_runtime_id": aggregate["root_runtime_id"], + "creation_id": aggregate["creation_id"], + "terminal_status": root_runtime["status"], + "final_aggregate_state_digest": aggregate[ + "aggregate_state_digest" + ], + "tombstone_operation_id": operation_id, + } + candidate["root_record"] = tombstone + candidate = seal_execution_checkpoint(candidate) + self._stage_replace(transaction, checkpoint, candidate) + response = { + "result": "tombstoned", + "tombstone": copy.deepcopy(tombstone), + } + self._after_commit(native_transaction, store_transaction) + return response + + def delete_checkpoint( + self, + root_instance_id: str, + *, + expected_revision: str, + expected_checkpoint_digest: str, + ) -> dict[str, Any]: + del root_instance_id, expected_revision, expected_checkpoint_digest + return { + "result": "unsupported", + "failure": {"code": "physical_deletion_unsupported"}, + } + + +def _target_root_instance_id(target: Mapping[str, Any]) -> str: + member = next(iter(target.values())) + return str(member["root_instance_id"]) diff --git a/src/determa/state/stores/__init__.py b/src/determa/state/stores/__init__.py new file mode 100644 index 0000000..62a3a50 --- /dev/null +++ b/src/determa/state/stores/__init__.py @@ -0,0 +1,58 @@ +"""Public execution-store adapters and registration.""" + +from .base import ( + COMPACT_EFFECT_IDENTITY_RETENTION, + DURABLE_CONCURRENT, + DURABLE_SINGLE_WRITER, + EPHEMERAL, + PERMANENT_OUTBOX_TERMINAL_RETENTION, + PERMANENT_RECEIPT_RETENTION, + RESTART_PERSISTENT, + ROOT_IDENTITY_RETENTION, + SHARED_APPLICATION_TRANSACTION, + STANDARD_CAPABILITIES, + ExecutionStore, + ExecutionStoreError, + ExecutionStoreTransaction, +) +from .file import FileExecutionStore, file_execution_store_factory +from .memory import MemoryExecutionStore, memory_execution_store_factory +from .postgresql import ( + PostgreSQLExecutionStore, + postgresql_execution_store_factory, +) +from .registry import ( + ExecutionStoreFactory, + ExecutionStoreRegistry, + bundled_execution_store_registry, + register_bundled_execution_stores, +) +from .sqlite import SQLiteExecutionStore, sqlite_execution_store_factory + +__all__ = [ + "COMPACT_EFFECT_IDENTITY_RETENTION", + "DURABLE_CONCURRENT", + "DURABLE_SINGLE_WRITER", + "EPHEMERAL", + "ExecutionStore", + "ExecutionStoreError", + "ExecutionStoreFactory", + "ExecutionStoreRegistry", + "ExecutionStoreTransaction", + "FileExecutionStore", + "MemoryExecutionStore", + "PERMANENT_OUTBOX_TERMINAL_RETENTION", + "PERMANENT_RECEIPT_RETENTION", + "PostgreSQLExecutionStore", + "RESTART_PERSISTENT", + "ROOT_IDENTITY_RETENTION", + "SHARED_APPLICATION_TRANSACTION", + "STANDARD_CAPABILITIES", + "SQLiteExecutionStore", + "bundled_execution_store_registry", + "file_execution_store_factory", + "memory_execution_store_factory", + "postgresql_execution_store_factory", + "register_bundled_execution_stores", + "sqlite_execution_store_factory", +] diff --git a/src/determa/state/stores/base.py b/src/determa/state/stores/base.py new file mode 100644 index 0000000..f8b311d --- /dev/null +++ b/src/determa/state/stores/base.py @@ -0,0 +1,104 @@ +"""Public synchronous execution-store contracts.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Mapping +from contextlib import AbstractContextManager +from typing import Any + +from ..errors import ArtifactError, DetermaError +from ..wire import strict_json + +EPHEMERAL = "ephemeral" +RESTART_PERSISTENT = "restart_persistent" +DURABLE_SINGLE_WRITER = "durable_single_writer" +DURABLE_CONCURRENT = "durable_concurrent" +SHARED_APPLICATION_TRANSACTION = "shared_application_transaction" +PERMANENT_RECEIPT_RETENTION = "permanent_receipt_retention" +ROOT_IDENTITY_RETENTION = "root_identity_retention" +PERMANENT_OUTBOX_TERMINAL_RETENTION = "permanent_outbox_terminal_retention" +COMPACT_EFFECT_IDENTITY_RETENTION = "compact_effect_identity_retention" + +STANDARD_CAPABILITIES = frozenset( + { + EPHEMERAL, + RESTART_PERSISTENT, + DURABLE_SINGLE_WRITER, + DURABLE_CONCURRENT, + SHARED_APPLICATION_TRANSACTION, + PERMANENT_RECEIPT_RETENTION, + ROOT_IDENTITY_RETENTION, + PERMANENT_OUTBOX_TERMINAL_RETENTION, + COMPACT_EFFECT_IDENTITY_RETENTION, + } +) + + +class ExecutionStoreError(DetermaError): + """A closed execution-store or adapter failure.""" + + def __init__(self, code: str, message: str = "") -> None: + self.code = code + self.message = message or code + super().__init__(self.message) + + +class ExecutionStoreTransaction(ABC): + """One exclusive or serializable transaction for a single root.""" + + @abstractmethod + def load(self) -> bytes | None: + """Read the current checkpoint bytes.""" + + @abstractmethod + def insert(self, checkpoint: bytes) -> bool: + """Stage an absent-root insert, returning false if the root exists.""" + + @abstractmethod + def replace( + self, + expected_revision: str, + expected_checkpoint_digest: str, + checkpoint: bytes, + ) -> bool: + """Stage an exact revision/digest compare-and-swap.""" + + +class ExecutionStore(ABC): + """Configured execution-store instance suitable for direct injection.""" + + @property + @abstractmethod + def capabilities(self) -> frozenset[str]: + """Capabilities proved by this configured instance.""" + + @abstractmethod + def transaction( + self, + root_instance_id: str, + *, + native_transaction: Any | None = None, + ) -> AbstractContextManager[ExecutionStoreTransaction]: + """Open one root transaction.""" + + @abstractmethod + def setup_schema(self) -> None: + """Explicitly create the adapter's storage schema.""" + + @abstractmethod + def health(self) -> Mapping[str, Any]: + """Return adapter health without mutating checkpoint storage.""" + + +def checkpoint_metadata(source: bytes) -> tuple[str, str]: + """Extract CAS metadata from structurally closed checkpoint bytes.""" + try: + document, _ = strict_json(source) + revision = document["revision"] + digest = document["execution_checkpoint_digest"] + except (ArtifactError, KeyError, TypeError) as exc: + raise ExecutionStoreError("invalid_execution_checkpoint") from exc + if not isinstance(revision, str) or not isinstance(digest, str): + raise ExecutionStoreError("invalid_execution_checkpoint") + return revision, digest diff --git a/src/determa/state/stores/file.py b/src/determa/state/stores/file.py new file mode 100644 index 0000000..0c02b78 --- /dev/null +++ b/src/determa/state/stores/file.py @@ -0,0 +1,173 @@ +"""Locked restart-persistent file execution store.""" + +from __future__ import annotations + +import hashlib +import os +import tempfile +from collections.abc import Iterator, Mapping +from contextlib import contextmanager +from pathlib import Path +from typing import Any, BinaryIO +from urllib.parse import unquote, urlsplit + +from .base import ( + RESTART_PERSISTENT, + ExecutionStore, + ExecutionStoreError, + ExecutionStoreTransaction, + checkpoint_metadata, +) + +_SCHEMA_MARKER = ".determa-execution-store-v1" + + +class _FileTransaction(ExecutionStoreTransaction): + def __init__(self, checkpoint_path: Path) -> None: + self._checkpoint_path = checkpoint_path + self._current: bytes | None + try: + self._current = checkpoint_path.read_bytes() + except FileNotFoundError: + self._current = None + self._candidate: bytes | None = self._current + + def load(self) -> bytes | None: + return self._current + + def insert(self, checkpoint: bytes) -> bool: + if self._current is not None: + return False + self._candidate = bytes(checkpoint) + return True + + def replace( + self, + expected_revision: str, + expected_checkpoint_digest: str, + checkpoint: bytes, + ) -> bool: + if self._current is None: + return False + if checkpoint_metadata(self._current) != ( + expected_revision, + expected_checkpoint_digest, + ): + return False + self._candidate = bytes(checkpoint) + return True + + def commit(self) -> None: + if self._candidate is self._current: + return + assert self._candidate is not None + descriptor, temporary_name = tempfile.mkstemp( + dir=self._checkpoint_path.parent, + prefix=f".{self._checkpoint_path.name}.", + suffix=".tmp", + ) + try: + with os.fdopen(descriptor, "wb") as stream: + stream.write(self._candidate) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary_name, self._checkpoint_path) + directory_descriptor = os.open(self._checkpoint_path.parent, os.O_RDONLY) + try: + os.fsync(directory_descriptor) + finally: + os.close(directory_descriptor) + except BaseException: + try: + os.unlink(temporary_name) + except FileNotFoundError: + pass + raise + + +class FileExecutionStore(ExecutionStore): + """Atomic locked files with restart persistence but no crash-durability claim.""" + + def __init__(self, directory: str | os.PathLike[str]) -> None: + self.directory = Path(directory) + + @property + def capabilities(self) -> frozenset[str]: + return frozenset({RESTART_PERSISTENT}) + + def _require_schema(self) -> None: + if not (self.directory / _SCHEMA_MARKER).is_file(): + raise ExecutionStoreError("execution_store_schema_unavailable") + + def _stem(self, root_instance_id: str) -> str: + return hashlib.sha256(root_instance_id.encode("utf-8")).hexdigest() + + @contextmanager + def transaction( + self, + root_instance_id: str, + *, + native_transaction: Any | None = None, + ) -> Iterator[ExecutionStoreTransaction]: + if native_transaction is not None: + raise ValueError("file does not accept a native transaction") + self._require_schema() + import fcntl + + stem = self._stem(root_instance_id) + lock_path = self.directory / f"{stem}.lock" + checkpoint_path = self.directory / f"{stem}.json" + lock: BinaryIO + with lock_path.open("a+b") as lock: + fcntl.flock(lock.fileno(), fcntl.LOCK_EX) + try: + transaction = _FileTransaction(checkpoint_path) + yield transaction + transaction.commit() + finally: + fcntl.flock(lock.fileno(), fcntl.LOCK_UN) + + def setup_schema(self) -> None: + self.directory.mkdir(parents=True, exist_ok=True) + marker = self.directory / _SCHEMA_MARKER + if marker.exists(): + if marker.read_text(encoding="ascii") != "1\n": + raise ExecutionStoreError("execution_store_schema_mismatch") + return + descriptor, temporary_name = tempfile.mkstemp( + dir=self.directory, prefix=f".{_SCHEMA_MARKER}.", suffix=".tmp" + ) + try: + with os.fdopen(descriptor, "w", encoding="ascii") as stream: + stream.write("1\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary_name, marker) + except BaseException: + try: + os.unlink(temporary_name) + except FileNotFoundError: + pass + raise + + def health(self) -> Mapping[str, Any]: + ready = (self.directory / _SCHEMA_MARKER).is_file() + return {"healthy": ready, "schema_ready": ready} + + +def file_execution_store_factory( + uri: str, configuration: Mapping[str, Any] +) -> ExecutionStore: + """Create the ordinary bundled file adapter.""" + parsed = urlsplit(uri) + if parsed.scheme != "file" or parsed.netloc not in {"", "localhost"}: + raise ExecutionStoreError("invalid_adapter_configuration") + if parsed.query or parsed.fragment or set(configuration) - {"directory"}: + raise ExecutionStoreError("invalid_adapter_configuration") + configured = configuration.get("directory") + if configured is not None and not isinstance(configured, str): + raise ExecutionStoreError("invalid_adapter_configuration") + directory = configured if configured is not None else unquote(parsed.path) + if not directory or not Path(directory).is_absolute(): + raise ExecutionStoreError("invalid_adapter_configuration") + return FileExecutionStore(directory) diff --git a/src/determa/state/stores/memory.py b/src/determa/state/stores/memory.py new file mode 100644 index 0000000..94ee427 --- /dev/null +++ b/src/determa/state/stores/memory.py @@ -0,0 +1,96 @@ +"""Ephemeral in-memory execution store.""" + +from __future__ import annotations + +import threading +from collections.abc import Iterator, Mapping +from contextlib import contextmanager +from typing import Any + +from .base import EPHEMERAL, ExecutionStore, ExecutionStoreTransaction, checkpoint_metadata + + +class _MemoryTransaction(ExecutionStoreTransaction): + def __init__( + self, records: dict[str, bytes], root_instance_id: str + ) -> None: + self._records = records + self._root_instance_id = root_instance_id + self._current = records.get(root_instance_id) + self._candidate = self._current + + def load(self) -> bytes | None: + return self._current + + def insert(self, checkpoint: bytes) -> bool: + if self._current is not None: + return False + self._candidate = bytes(checkpoint) + return True + + def replace( + self, + expected_revision: str, + expected_checkpoint_digest: str, + checkpoint: bytes, + ) -> bool: + if self._current is None: + return False + if checkpoint_metadata(self._current) != ( + expected_revision, + expected_checkpoint_digest, + ): + return False + self._candidate = bytes(checkpoint) + return True + + def commit(self) -> None: + if self._candidate is not self._current: + assert self._candidate is not None + self._records[self._root_instance_id] = self._candidate + + +class MemoryExecutionStore(ExecutionStore): + """Process-local checkpoint storage with no durability claim.""" + + def __init__(self, initial: Mapping[str, bytes] | None = None) -> None: + self._records = { + root_instance_id: bytes(checkpoint) + for root_instance_id, checkpoint in (initial or {}).items() + } + self._lock = threading.RLock() + + @property + def capabilities(self) -> frozenset[str]: + return frozenset({EPHEMERAL}) + + @contextmanager + def transaction( + self, + root_instance_id: str, + *, + native_transaction: Any | None = None, + ) -> Iterator[ExecutionStoreTransaction]: + if native_transaction is not None: + raise ValueError("memory does not accept a native transaction") + with self._lock: + transaction = _MemoryTransaction(self._records, root_instance_id) + yield transaction + transaction.commit() + + def setup_schema(self) -> None: + return None + + def health(self) -> Mapping[str, Any]: + return {"healthy": True, "record_count": len(self._records)} + + +def memory_execution_store_factory( + uri: str, configuration: Mapping[str, Any] +) -> ExecutionStore: + """Create the ordinary bundled memory adapter.""" + if uri != "memory:" or configuration: + from .base import ExecutionStoreError + + raise ExecutionStoreError("invalid_adapter_configuration") + return MemoryExecutionStore() diff --git a/src/determa/state/stores/postgresql.py b/src/determa/state/stores/postgresql.py new file mode 100644 index 0000000..bc8b6e6 --- /dev/null +++ b/src/determa/state/stores/postgresql.py @@ -0,0 +1,190 @@ +"""Optional lazy Psycopg 3 PostgreSQL execution store.""" + +from __future__ import annotations + +import re +from collections.abc import Iterator, Mapping +from contextlib import contextmanager +from importlib import import_module +from typing import Any +from urllib.parse import urlsplit + +from .base import ( + DURABLE_CONCURRENT, + ROOT_IDENTITY_RETENTION, + SHARED_APPLICATION_TRANSACTION, + ExecutionStore, + ExecutionStoreError, + ExecutionStoreTransaction, + checkpoint_metadata, +) + +_IDENTIFIER = re.compile(r"[a-z_][a-z0-9_]*\Z") + + +def _psycopg() -> Any: + try: + return import_module("psycopg") + except ImportError as exc: + raise ExecutionStoreError("optional_dependency_unavailable") from exc + + +class _PostgreSQLTransaction(ExecutionStoreTransaction): + def __init__( + self, connection: Any, table_name: str, root_instance_id: str + ) -> None: + self._connection = connection + self._table_name = table_name + self._root_instance_id = root_instance_id + + def load(self) -> bytes | None: + row = self._connection.execute( + f""" + SELECT checkpoint + FROM {self._table_name} + WHERE root_instance_id = %s + FOR UPDATE + """, + (self._root_instance_id,), + ).fetchone() + return None if row is None else bytes(row[0]) + + def insert(self, checkpoint: bytes) -> bool: + revision, digest = checkpoint_metadata(checkpoint) + cursor = self._connection.execute( + f""" + INSERT INTO {self._table_name} + (root_instance_id, revision, checkpoint_digest, checkpoint) + VALUES (%s, %s, %s, %s) + ON CONFLICT (root_instance_id) DO NOTHING + """, + (self._root_instance_id, revision, digest, checkpoint), + ) + return bool(cursor.rowcount == 1) + + def replace( + self, + expected_revision: str, + expected_checkpoint_digest: str, + checkpoint: bytes, + ) -> bool: + revision, digest = checkpoint_metadata(checkpoint) + cursor = self._connection.execute( + f""" + UPDATE {self._table_name} + SET revision = %s, checkpoint_digest = %s, checkpoint = %s + WHERE root_instance_id = %s + AND revision = %s + AND checkpoint_digest = %s + """, + ( + revision, + digest, + checkpoint, + self._root_instance_id, + expected_revision, + expected_checkpoint_digest, + ), + ) + return bool(cursor.rowcount == 1) + + +class PostgreSQLExecutionStore(ExecutionStore): + """Concurrent CAS storage with optional native transaction reuse.""" + + def __init__( + self, + conninfo: str, + *, + table_name: str = "determa_execution_checkpoints", + ) -> None: + if not conninfo or _IDENTIFIER.fullmatch(table_name) is None: + raise ExecutionStoreError("invalid_adapter_configuration") + self.conninfo = conninfo + self.table_name = table_name + + @property + def capabilities(self) -> frozenset[str]: + return frozenset( + { + DURABLE_CONCURRENT, + SHARED_APPLICATION_TRANSACTION, + ROOT_IDENTITY_RETENTION, + } + ) + + @contextmanager + def transaction( + self, + root_instance_id: str, + *, + native_transaction: Any | None = None, + ) -> Iterator[ExecutionStoreTransaction]: + psycopg = _psycopg() + owns_connection = native_transaction is None + connection: Any = native_transaction + if owns_connection: + connection = psycopg.connect(self.conninfo) + if not owns_connection and ( + connection.info.transaction_status == psycopg.pq.TransactionStatus.IDLE + ): + raise ExecutionStoreError("invalid_adapter_configuration") + try: + if owns_connection: + connection.execute("BEGIN ISOLATION LEVEL READ COMMITTED") + yield _PostgreSQLTransaction( + connection, self.table_name, root_instance_id + ) + if owns_connection: + connection.commit() + except BaseException: + if owns_connection: + connection.rollback() + raise + finally: + if owns_connection: + connection.close() + + def setup_schema(self) -> None: + psycopg = _psycopg() + with psycopg.connect(self.conninfo, autocommit=True) as connection: + connection.execute( + f""" + CREATE TABLE IF NOT EXISTS {self.table_name} ( + root_instance_id TEXT PRIMARY KEY, + revision TEXT NOT NULL, + checkpoint_digest TEXT NOT NULL, + checkpoint BYTEA NOT NULL + ) + """ + ) + + def health(self) -> Mapping[str, Any]: + try: + psycopg = _psycopg() + with psycopg.connect(self.conninfo) as connection: + row = connection.execute( + "SELECT to_regclass(%s)", + (self.table_name,), + ).fetchone() + except Exception: + return {"healthy": False, "schema_ready": False} + ready = row is not None and row[0] is not None + return {"healthy": ready, "schema_ready": ready} + + +def postgresql_execution_store_factory( + uri: str, configuration: Mapping[str, Any] +) -> ExecutionStore: + """Create the ordinary bundled PostgreSQL adapter without importing Psycopg.""" + parsed = urlsplit(uri) + if parsed.scheme != "postgresql" or parsed.fragment: + raise ExecutionStoreError("invalid_adapter_configuration") + if set(configuration) - {"table_name"}: + raise ExecutionStoreError("invalid_adapter_configuration") + table_name = configuration.get( + "table_name", "determa_execution_checkpoints" + ) + if not isinstance(table_name, str): + raise ExecutionStoreError("invalid_adapter_configuration") + return PostgreSQLExecutionStore(uri, table_name=table_name) diff --git a/src/determa/state/stores/registry.py b/src/determa/state/stores/registry.py new file mode 100644 index 0000000..998e615 --- /dev/null +++ b/src/determa/state/stores/registry.py @@ -0,0 +1,82 @@ +"""Public execution-store adapter registration and generic URI resolution.""" + +from __future__ import annotations + +import re +from collections.abc import Callable, Mapping +from typing import Any +from urllib.parse import urlsplit + +from .base import ExecutionStore, ExecutionStoreError + +ExecutionStoreFactory = Callable[[str, Mapping[str, Any]], ExecutionStore] +_IDENTIFIER = re.compile(r"[a-z][a-z0-9+.-]*\Z") + + +class ExecutionStoreRegistry: + """An initially empty, explicit adapter registry.""" + + def __init__(self) -> None: + self._factories: dict[str, ExecutionStoreFactory] = {} + + @property + def identifiers(self) -> tuple[str, ...]: + return tuple(sorted(self._factories)) + + def register(self, identifier: str, factory: ExecutionStoreFactory) -> None: + if _IDENTIFIER.fullmatch(identifier) is None: + raise ExecutionStoreError("invalid_adapter_configuration") + if identifier in self._factories: + raise ExecutionStoreError("duplicate_adapter_registration") + self._factories[identifier] = factory + + def resolve( + self, + uri: str, + *, + configuration: Mapping[str, Any] | None = None, + required_capabilities: set[str] | frozenset[str] = frozenset(), + ) -> ExecutionStore: + if not isinstance(uri, str): + raise ExecutionStoreError("invalid_adapter_configuration") + scheme = urlsplit(uri).scheme + factory = self._factories.get(scheme) + if factory is None: + raise ExecutionStoreError("unknown_adapter") + try: + store = factory(uri, dict(configuration or {})) + except ExecutionStoreError: + raise + except (TypeError, ValueError) as exc: + raise ExecutionStoreError("invalid_adapter_configuration") from exc + if not required_capabilities.issubset(store.capabilities): + raise ExecutionStoreError("adapter_capability_mismatch") + return store + + +def register_bundled_execution_stores( + registry: ExecutionStoreRegistry, *, include_postgresql: bool = True +) -> None: + """Register bundled adapters through the public operation.""" + from .file import file_execution_store_factory + from .memory import memory_execution_store_factory + from .sqlite import sqlite_execution_store_factory + + registry.register("memory", memory_execution_store_factory) + registry.register("file", file_execution_store_factory) + registry.register("sqlite", sqlite_execution_store_factory) + if include_postgresql: + from .postgresql import postgresql_execution_store_factory + + registry.register("postgresql", postgresql_execution_store_factory) + + +def bundled_execution_store_registry( + *, include_postgresql: bool = True +) -> ExecutionStoreRegistry: + """Return a new registry populated only through public registration.""" + registry = ExecutionStoreRegistry() + register_bundled_execution_stores( + registry, include_postgresql=include_postgresql + ) + return registry diff --git a/src/determa/state/stores/sqlite.py b/src/determa/state/stores/sqlite.py new file mode 100644 index 0000000..0686da6 --- /dev/null +++ b/src/determa/state/stores/sqlite.py @@ -0,0 +1,232 @@ +"""Explicit-schema SQLite execution store.""" + +from __future__ import annotations + +import sqlite3 +from collections.abc import Iterator, Mapping +from contextlib import contextmanager +from pathlib import Path +from typing import Any +from urllib.parse import parse_qs, unquote, urlsplit + +from .base import ( + DURABLE_SINGLE_WRITER, + ROOT_IDENTITY_RETENTION, + ExecutionStore, + ExecutionStoreError, + ExecutionStoreTransaction, + checkpoint_metadata, +) + +_TABLE = "determa_execution_checkpoints" +_JOURNAL_MODES = {"DELETE", "WAL"} +_SYNCHRONOUS_MODES = {"FULL"} + + +class _SQLiteTransaction(ExecutionStoreTransaction): + def __init__( + self, connection: sqlite3.Connection, root_instance_id: str + ) -> None: + self._connection = connection + self._root_instance_id = root_instance_id + + def load(self) -> bytes | None: + row = self._connection.execute( + f"SELECT checkpoint FROM {_TABLE} WHERE root_instance_id = ?", + (self._root_instance_id,), + ).fetchone() + return None if row is None else bytes(row[0]) + + def insert(self, checkpoint: bytes) -> bool: + revision, digest = checkpoint_metadata(checkpoint) + try: + self._connection.execute( + f""" + INSERT INTO {_TABLE} + (root_instance_id, revision, checkpoint_digest, checkpoint) + VALUES (?, ?, ?, ?) + """, + (self._root_instance_id, revision, digest, checkpoint), + ) + except sqlite3.IntegrityError: + return False + return True + + def replace( + self, + expected_revision: str, + expected_checkpoint_digest: str, + checkpoint: bytes, + ) -> bool: + revision, digest = checkpoint_metadata(checkpoint) + cursor = self._connection.execute( + f""" + UPDATE {_TABLE} + SET revision = ?, checkpoint_digest = ?, checkpoint = ? + WHERE root_instance_id = ? + AND revision = ? + AND checkpoint_digest = ? + """, + ( + revision, + digest, + checkpoint, + self._root_instance_id, + expected_revision, + expected_checkpoint_digest, + ), + ) + return cursor.rowcount == 1 + + +class SQLiteExecutionStore(ExecutionStore): + """Single-writer durable SQLite storage under verified PRAGMA settings.""" + + def __init__( + self, + path: str | Path, + *, + journal_mode: str = "WAL", + synchronous: str = "FULL", + timeout: float = 30.0, + ) -> None: + self.path = str(path) + self.journal_mode = journal_mode.upper() + self.synchronous = synchronous.upper() + self.timeout = timeout + if ( + not self.path + or self.path == ":memory:" + or self.journal_mode not in _JOURNAL_MODES + or self.synchronous not in _SYNCHRONOUS_MODES + or timeout <= 0 + ): + raise ExecutionStoreError("invalid_adapter_configuration") + + @property + def capabilities(self) -> frozenset[str]: + return frozenset({DURABLE_SINGLE_WRITER, ROOT_IDENTITY_RETENTION}) + + def _connect(self) -> sqlite3.Connection: + connection = sqlite3.connect( + self.path, timeout=self.timeout, isolation_level=None + ) + journal_mode = connection.execute( + f"PRAGMA journal_mode = {self.journal_mode}" + ).fetchone() + connection.execute(f"PRAGMA synchronous = {self.synchronous}") + actual_synchronous = connection.execute("PRAGMA synchronous").fetchone() + expected_synchronous = {"FULL": 2}[self.synchronous] + if ( + journal_mode is None + or str(journal_mode[0]).upper() != self.journal_mode + or actual_synchronous is None + or int(actual_synchronous[0]) != expected_synchronous + ): + connection.close() + raise ExecutionStoreError("invalid_adapter_configuration") + return connection + + @contextmanager + def transaction( + self, + root_instance_id: str, + *, + native_transaction: Any | None = None, + ) -> Iterator[ExecutionStoreTransaction]: + if native_transaction is not None: + raise ValueError("sqlite does not expose shared application transactions") + connection = self._connect() + try: + connection.execute("BEGIN IMMEDIATE") + transaction = _SQLiteTransaction(connection, root_instance_id) + yield transaction + connection.commit() + except sqlite3.OperationalError as exc: + connection.rollback() + if "no such table" in str(exc): + raise ExecutionStoreError( + "execution_store_schema_unavailable" + ) from exc + raise + except BaseException: + connection.rollback() + raise + finally: + connection.close() + + def setup_schema(self) -> None: + connection = self._connect() + try: + connection.execute("BEGIN IMMEDIATE") + connection.execute( + f""" + CREATE TABLE IF NOT EXISTS {_TABLE} ( + root_instance_id TEXT PRIMARY KEY NOT NULL, + revision TEXT NOT NULL, + checkpoint_digest TEXT NOT NULL, + checkpoint BLOB NOT NULL + ) + """ + ) + connection.commit() + except BaseException: + connection.rollback() + raise + finally: + connection.close() + + def health(self) -> Mapping[str, Any]: + try: + connection = self._connect() + row = connection.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", + (_TABLE,), + ).fetchone() + connection.close() + except (OSError, sqlite3.Error, ExecutionStoreError): + return {"healthy": False, "schema_ready": False} + ready = row is not None + return {"healthy": ready, "schema_ready": ready} + + +def _single_query(query: Mapping[str, list[str]], key: str, default: str) -> str: + values = query.get(key) + if values is None: + return default + if len(values) != 1: + raise ExecutionStoreError("invalid_adapter_configuration") + return values[0] + + +def sqlite_execution_store_factory( + uri: str, configuration: Mapping[str, Any] +) -> ExecutionStore: + """Create the ordinary bundled SQLite adapter.""" + parsed = urlsplit(uri) + if ( + parsed.scheme != "sqlite" + or parsed.netloc not in {"", "localhost"} + or parsed.fragment + or configuration + ): + raise ExecutionStoreError("invalid_adapter_configuration") + path = unquote(parsed.path) + if not path or not Path(path).is_absolute(): + raise ExecutionStoreError("invalid_adapter_configuration") + query = parse_qs(parsed.query, keep_blank_values=True) + if set(query) - {"journal_mode", "synchronous", "timeout"}: + raise ExecutionStoreError("invalid_adapter_configuration") + journal_mode = _single_query(query, "journal_mode", "WAL") + synchronous = _single_query(query, "synchronous", "FULL") + timeout_text = _single_query(query, "timeout", "30") + try: + timeout = float(timeout_text) + except ValueError as exc: + raise ExecutionStoreError("invalid_adapter_configuration") from exc + return SQLiteExecutionStore( + path, + journal_mode=journal_mode, + synchronous=synchronous, + timeout=timeout, + ) diff --git a/src/determa/state/wire.py b/src/determa/state/wire.py index 63509d6..7d238b5 100644 --- a/src/determa/state/wire.py +++ b/src/determa/state/wire.py @@ -311,6 +311,7 @@ def artifact_schema(kind: str) -> dict[str, Any]: "aggregate_state": "aggregate-state.schema.json", "migration_descriptor": "migration-descriptor.schema.json", "aggregate_state_package": "aggregate-state-package.schema.json", + "execution_checkpoint": "execution-checkpoint.schema.json", }[kind] return cast( dict[str, Any], json.loads((_DATA / filename).read_text(encoding="utf-8")) @@ -326,6 +327,7 @@ def _schema_registry() -> Any: "aggregate_state", "migration_descriptor", "aggregate_state_package", + "execution_checkpoint", ): document = artifact_schema(kind) registry = registry.with_resource( @@ -362,6 +364,14 @@ def _format_code(document: Any, kind: str) -> str | None: "unsupported_aggregate_state_package_format", "unsupported_aggregate_state_package_schema_version", ), + "execution_checkpoint": ( + "execution_checkpoint_format", + "determa.execution_checkpoint", + "execution_checkpoint_schema_version", + 1, + "unsupported_execution_checkpoint_format", + "unsupported_execution_checkpoint_schema_version", + ), } format_member, expected_format, version_member, expected_version, format_code, version_code = ( definitions[kind] @@ -384,6 +394,7 @@ def load_json_artifact( "aggregate_state": "invalid_aggregate_state", "migration_descriptor": "invalid_migration_descriptor", "aggregate_state_package": "invalid_aggregate_state_package", + "execution_checkpoint": "invalid_execution_checkpoint", }[kind] raise ArtifactError(code) from exc unsupported = _format_code(document, kind) @@ -399,6 +410,7 @@ def load_json_artifact( "aggregate_state": "invalid_aggregate_state", "migration_descriptor": "invalid_migration_descriptor", "aggregate_state_package": "invalid_aggregate_state_package", + "execution_checkpoint": "invalid_execution_checkpoint", }[kind] raise ArtifactError(code) return document, raw diff --git a/tests/test_checkpoint_host.py b/tests/test_checkpoint_host.py new file mode 100644 index 0000000..91f2587 --- /dev/null +++ b/tests/test_checkpoint_host.py @@ -0,0 +1,230 @@ +from __future__ import annotations + +import copy + +import pytest + +from determa.state import ( + ArtifactError, + ExecutionHost, + ExecutionHostError, + MemoryArtifactResolver, + MemoryExecutionStore, + delivery_request_digest, + load_bundle, + portable_envelope, + restore_execution_checkpoint, +) + +MACHINE = """ +format: 1 +namespace: test.execution_checkpoint +events: + increment: + direction: input + payload: + amount: { type: int, required: true } +machines: + - machine_id: counter + version: 1 + root: + type: simple + variables: + count: { type: int, init: 0 } + on_events: + increment: + action: + - assign: { count: "count + event.payload.amount" } +""" + + +def _host( + *, + store: MemoryExecutionStore | None = None, + fault_injector=None, +) -> tuple[ExecutionHost, MemoryExecutionStore]: + bundle = load_bundle(MACHINE) + resolver = MemoryArtifactResolver(definitions={bundle.fingerprint: bundle}) + selected = store or MemoryExecutionStore() + return ( + ExecutionHost( + selected, resolver, fault_injector=fault_injector + ), + selected, + ) + + +def _created(host: ExecutionHost, root: str = "root") -> dict: + result = host.create( + load_bundle(MACHINE), "counter", root, f"{root}-create", {} + ) + assert result["result"] == "committed" + restored = host.read_checkpoint(root) + assert restored is not None + return restored.document + + +def _candidate(checkpoint: dict, event_id: str = "increment-1") -> dict: + aggregate = checkpoint["root_record"]["aggregate_state"] + envelope = portable_envelope( + "increment", + event_id, + { + "root": { + "root_instance_id": checkpoint["root_instance_id"], + "root_runtime_id": aggregate["root_runtime_id"], + } + }, + {"amount": 1}, + ) + return { + "root_instance_id": checkpoint["root_instance_id"], + "delivery_mode": "input", + "origin": {"kind": "host_input"}, + "envelope": envelope, + "envelope_digest": delivery_request_digest( + checkpoint["root_instance_id"], "input", envelope + ), + } + + +def test_creation_response_loss_replays_the_committed_receipt() -> None: + def response_loss(boundary: str) -> None: + if boundary == "after_commit_before_response": + raise ExecutionHostError("response_lost_after_commit") + + host, store = _host(fault_injector=response_loss) + with pytest.raises(ExecutionHostError, match="response_lost_after_commit"): + host.create(load_bundle(MACHINE), "counter", "root", "create", {}) + + replay, _ = _host(store=store) + result = replay.create( + load_bundle(MACHINE), "counter", "root", "create", {} + ) + assert result["receipt"]["receipt_sequence"] == "0" + assert replay.read_checkpoint("root") is not None + + +def test_pre_commit_failure_leaves_no_checkpoint() -> None: + def rollback(boundary: str) -> None: + if boundary == "before_commit": + raise ExecutionHostError("injected_pre_commit_failure") + + host, _ = _host(fault_injector=rollback) + with pytest.raises(ExecutionHostError, match="injected_pre_commit_failure"): + host.create(load_bundle(MACHINE), "counter", "root", "create", {}) + assert host.read_checkpoint("root") is None + + +def test_caller_owned_transaction_defers_after_commit_boundary() -> None: + def response_loss(boundary: str) -> None: + if boundary == "after_commit_before_response": + raise ExecutionHostError("response_lost_after_commit") + + host, store = _host(fault_injector=response_loss) + with store.transaction("root") as transaction: + result = host.create( + load_bundle(MACHINE), + "counter", + "root", + "create", + {}, + store_transaction=transaction, + ) + assert result["result"] == "committed" + assert host.read_checkpoint("root") is not None + + +def test_accept_process_and_replay_use_durable_host_receipts() -> None: + host, _ = _host() + created = _created(host) + candidate = _candidate(created) + pending = host.accept_delivery( + "root", + candidate, + expected_revision=created["revision"], + expected_checkpoint_digest=created["execution_checkpoint_digest"], + ) + assert pending["result"] == "pending" + + accepted = host.read_checkpoint("root") + assert accepted is not None + committed = host.process_pending_delivery( + "root", + candidate, + expected_revision=accepted.document["revision"], + expected_checkpoint_digest=accepted.document[ + "execution_checkpoint_digest" + ], + ) + replay = host.accept_delivery( + "root", + candidate, + expected_revision=created["revision"], + expected_checkpoint_digest=created["execution_checkpoint_digest"], + ) + assert replay == committed + + +def test_checkpoint_digest_mismatch_is_classified_after_structure() -> None: + host, _ = _host() + checkpoint = _created(host) + checkpoint["execution_checkpoint_digest"] = "sha256:" + ("0" * 64) + resolver = host.artifact_resolver + with pytest.raises(ArtifactError) as error: + restore_execution_checkpoint(checkpoint, resolver) + assert error.value.code == "execution_checkpoint_digest_mismatch" + + +def test_unknown_checkpoint_member_is_structurally_rejected() -> None: + host, _ = _host() + checkpoint = _created(host) + checkpoint["extra"] = True + with pytest.raises(ArtifactError) as error: + restore_execution_checkpoint(checkpoint, host.artifact_resolver) + assert error.value.code == "invalid_execution_checkpoint" + + +def test_root_deletion_is_unsupported_and_preserves_bytes() -> None: + host, _ = _host() + checkpoint = _created(host) + result = host.delete_checkpoint( + "root", + expected_revision=checkpoint["revision"], + expected_checkpoint_digest=checkpoint["execution_checkpoint_digest"], + ) + assert result == { + "result": "unsupported", + "failure": {"code": "physical_deletion_unsupported"}, + } + restored = host.read_checkpoint("root") + assert restored is not None + assert restored.document == checkpoint + + +def test_bounded_retention_cannot_attest_unallocated_receipts() -> None: + host, _ = _host() + checkpoint = _created(host) + with pytest.raises(ExecutionHostError) as error: + host.update_replay_retention( + "root", + { + "mode": "bounded", + "permanent_replay_eligible": False, + "pruned_through_receipt_sequence": "1", + "policy_identifier": "bounded-test", + }, + expected_revision=checkpoint["revision"], + expected_checkpoint_digest=checkpoint[ + "execution_checkpoint_digest" + ], + ) + assert error.value.code == "invalid_execution_checkpoint" + + +def test_checkpoint_restore_does_not_mutate_caller_document() -> None: + host, _ = _host() + checkpoint = _created(host) + original = copy.deepcopy(checkpoint) + restore_execution_checkpoint(checkpoint, host.artifact_resolver) + assert checkpoint == original diff --git a/tests/test_cli.py b/tests/test_cli.py index 78d54e3..115f2f8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -38,7 +38,11 @@ def test_package_import_keeps_heavy_validators_lazy() -> None: "-c", ( "import sys; import determa.state; " - "print('celpy' in sys.modules, 'jsonschema' in sys.modules)" + "print(" + "'celpy' in sys.modules, " + "'jsonschema' in sys.modules, " + "'psycopg' in sys.modules" + ")" ), ], check=True, @@ -46,4 +50,4 @@ def test_package_import_keeps_heavy_validators_lazy() -> None: text=True, ) - assert result.stdout.strip() == "False False" + assert result.stdout.strip() == "False False False" diff --git a/tests/test_execution_stores.py b/tests/test_execution_stores.py new file mode 100644 index 0000000..69abaf5 --- /dev/null +++ b/tests/test_execution_stores.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import pytest + +from determa.state import ( + DURABLE_SINGLE_WRITER, + EPHEMERAL, + RESTART_PERSISTENT, + ExecutionHost, + ExecutionHostError, + ExecutionStore, + ExecutionStoreError, + ExecutionStoreRegistry, + FileExecutionStore, + MemoryArtifactResolver, + MemoryExecutionStore, + SQLiteExecutionStore, + bundled_execution_store_registry, + load_bundle, + portable_envelope, +) + +from .test_checkpoint_host import MACHINE + + +def _resolver() -> MemoryArtifactResolver: + bundle = load_bundle(MACHINE) + return MemoryArtifactResolver(definitions={bundle.fingerprint: bundle}) + + +def _create(store: ExecutionStore, root: str = "root") -> ExecutionHost: + host = ExecutionHost(store, _resolver()) + host.create(load_bundle(MACHINE), "counter", root, f"{root}-create", {}) + return host + + +def _factories(tmp_path: Path) -> list[Callable[[], ExecutionStore]]: + return [ + MemoryExecutionStore, + lambda: FileExecutionStore(tmp_path / "file-store"), + lambda: SQLiteExecutionStore(tmp_path / "store.sqlite"), + ] + + +@pytest.mark.parametrize("index", range(3)) +def test_shared_adapter_contract_round_trip(tmp_path: Path, index: int) -> None: + store = _factories(tmp_path)[index]() + store.setup_schema() + host = _create(store) + restored = host.read_checkpoint("root") + assert restored is not None + replay = host.create( + load_bundle(MACHINE), "counter", "root", "root-create", {} + ) + assert replay["receipt"]["receipt_sequence"] == "0" + + +@pytest.mark.parametrize( + "store_factory", + [ + lambda path: FileExecutionStore(path / "file-store"), + lambda path: SQLiteExecutionStore(path / "store.sqlite"), + ], +) +def test_persistent_adapters_require_explicit_schema_setup( + tmp_path: Path, store_factory +) -> None: + store = store_factory(tmp_path) + host = ExecutionHost(store, _resolver()) + with pytest.raises(ExecutionStoreError) as error: + host.read_checkpoint("root") + assert error.value.code == "execution_store_schema_unavailable" + + +@pytest.mark.parametrize( + "store_factory", + [ + lambda path: FileExecutionStore(path / "file-store"), + lambda path: SQLiteExecutionStore(path / "store.sqlite"), + ], +) +def test_file_and_sqlite_survive_adapter_restart( + tmp_path: Path, store_factory +) -> None: + first = store_factory(tmp_path) + first.setup_schema() + _create(first) + second = store_factory(tmp_path) + restored = ExecutionHost(second, _resolver()).read_checkpoint("root") + assert restored is not None + assert restored.document["revision"] == "0" + + +@pytest.mark.parametrize("index", range(3)) +def test_concurrent_stale_writer_cannot_overwrite( + tmp_path: Path, index: int +) -> None: + store = _factories(tmp_path)[index]() + store.setup_schema() + host = _create(store) + checkpoint = host.read_checkpoint("root") + assert checkpoint is not None + document = checkpoint.document + aggregate = document["root_record"]["aggregate_state"] + + def process(event_id: str) -> str: + candidate = { + "root_instance_id": "root", + "delivery_mode": "input", + "origin": {"kind": "host_input"}, + "envelope": portable_envelope( + "increment", + event_id, + { + "root": { + "root_instance_id": "root", + "root_runtime_id": aggregate["root_runtime_id"], + } + }, + {"amount": 1}, + ), + } + try: + ExecutionHost(store, _resolver()).foreground_process_delivery( + "root", + candidate, + expected_revision=document["revision"], + expected_checkpoint_digest=document[ + "execution_checkpoint_digest" + ], + ) + except ExecutionHostError as exc: + return exc.code + return "committed" + + with ThreadPoolExecutor(max_workers=2) as executor: + outcomes = sorted(executor.map(process, ["event-a", "event-b"])) + assert outcomes == ["checkpoint_revision_conflict", "committed"] + + +def test_registry_is_empty_and_duplicate_registration_never_overrides() -> None: + registry = ExecutionStoreRegistry() + assert registry.identifiers == () + registry.register("custom", lambda _uri, _config: MemoryExecutionStore()) + with pytest.raises(ExecutionStoreError) as error: + registry.register("custom", lambda _uri, _config: MemoryExecutionStore()) + assert error.value.code == "duplicate_adapter_registration" + + +def test_registry_checks_configuration_before_capabilities() -> None: + registry = ExecutionStoreRegistry() + + def invalid(_uri, _configuration): + raise ExecutionStoreError("invalid_adapter_configuration") + + registry.register("custom", invalid) + with pytest.raises(ExecutionStoreError) as error: + registry.resolve( + "custom:", required_capabilities={DURABLE_SINGLE_WRITER} + ) + assert error.value.code == "invalid_adapter_configuration" + + +def test_bundled_adapters_use_public_registration_and_exact_capabilities( + tmp_path: Path, +) -> None: + registry = bundled_execution_store_registry() + assert registry.identifiers == ("file", "memory", "postgresql", "sqlite") + assert registry.resolve("memory:").capabilities == frozenset({EPHEMERAL}) + assert registry.resolve( + f"file://{tmp_path / 'files'}" + ).capabilities == frozenset({RESTART_PERSISTENT}) + assert DURABLE_SINGLE_WRITER in registry.resolve( + f"sqlite://{tmp_path / 'store.sqlite'}" + ).capabilities + + +def test_unknown_adapter_and_capability_mismatch_are_closed() -> None: + registry = bundled_execution_store_registry() + with pytest.raises(ExecutionStoreError) as unknown: + registry.resolve("absent:") + assert unknown.value.code == "unknown_adapter" + with pytest.raises(ExecutionStoreError) as mismatch: + registry.resolve( + "memory:", required_capabilities={DURABLE_SINGLE_WRITER} + ) + assert mismatch.value.code == "adapter_capability_mismatch" diff --git a/tests/test_postgresql_store.py b/tests/test_postgresql_store.py new file mode 100644 index 0000000..76aba42 --- /dev/null +++ b/tests/test_postgresql_store.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import os +import uuid +from concurrent.futures import ThreadPoolExecutor + +import pytest + +from determa.state import ( + ExecutionHost, + ExecutionHostError, + PostgreSQLExecutionStore, + load_bundle, + portable_envelope, +) + +from .test_checkpoint_host import MACHINE, _host + +pytestmark = pytest.mark.skipif( + not os.environ.get("DETERMA_POSTGRESQL_DSN"), + reason="DETERMA_POSTGRESQL_DSN is not configured", +) + + +def _store() -> PostgreSQLExecutionStore: + pytest.importorskip("psycopg") + return PostgreSQLExecutionStore( + os.environ["DETERMA_POSTGRESQL_DSN"], + table_name=f"determa_checkpoint_test_{uuid.uuid4().hex}", + ) + + +def test_postgresql_cas_and_shared_native_transaction() -> None: + psycopg = pytest.importorskip("psycopg") + store = _store() + store.setup_schema() + local_host, _ = _host() + resolver = local_host.artifact_resolver + host = ExecutionHost(store, resolver) + host.create(load_bundle(MACHINE), "counter", "root", "create", {}) + checkpoint = host.read_checkpoint("root") + assert checkpoint is not None + document = checkpoint.document + aggregate = document["root_record"]["aggregate_state"] + + def process(event_id: str) -> str: + candidate = { + "root_instance_id": "root", + "delivery_mode": "input", + "origin": {"kind": "host_input"}, + "envelope": portable_envelope( + "increment", + event_id, + { + "root": { + "root_instance_id": "root", + "root_runtime_id": aggregate["root_runtime_id"], + } + }, + {"amount": 1}, + ), + } + try: + ExecutionHost(store, resolver).foreground_process_delivery( + "root", + candidate, + expected_revision=document["revision"], + expected_checkpoint_digest=document[ + "execution_checkpoint_digest" + ], + ) + except ExecutionHostError as exc: + return exc.code + return "committed" + + with ThreadPoolExecutor(max_workers=2) as executor: + outcomes = sorted(executor.map(process, ["event-a", "event-b"])) + assert outcomes == ["checkpoint_revision_conflict", "committed"] + + with psycopg.connect(store.conninfo) as connection: + with pytest.raises(RuntimeError), connection.transaction(): + host.create( + load_bundle(MACHINE), + "counter", + "rolled-back-root", + "create", + {}, + native_transaction=connection, + ) + raise RuntimeError("application rollback") + assert host.read_checkpoint("rolled-back-root") is None From 6bfe678269087b462293090034ea0836982285f9 Mon Sep 17 00:00:00 2001 From: Christian-Manuel Butzke Date: Fri, 31 Jul 2026 06:43:23 +0900 Subject: [PATCH 2/4] Address execution checkpoint review findings --- .github/workflows/test.yml | 2 +- AGENTS.md | 9 +- README.md | 43 +- conformance/execution_checkpoint.py | 150 ++++-- conformance/pins.py | 2 +- conformance/test_conformance.py | 2 +- src/determa/state/__init__.py | 4 + src/determa/state/checkpoint.py | 75 ++- src/determa/state/host.py | 650 ++++++++++++++++++------- src/determa/state/stores/base.py | 34 +- src/determa/state/stores/file.py | 25 +- src/determa/state/stores/memory.py | 28 +- src/determa/state/stores/postgresql.py | 283 +++++++++-- src/determa/state/stores/sqlite.py | 216 ++++++-- src/determa/state/wire.py | 14 +- tests/test_checkpoint_host.py | 485 +++++++++++++++++- tests/test_execution_stores.py | 230 +++++++++ tests/test_postgresql_store.py | 156 +++++- 18 files changed, 2051 insertions(+), 357 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6035c4a..cddbbf3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,7 +36,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: fruwehq/determa-state-conformance - ref: c6637066c1923e451edad62b7dc2ae73babfbec0 + ref: 86cb08a98267371b96b8f4908409aee022e4b4fe path: .pinned/determa-state-conformance - name: Check out pinned specification uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/AGENTS.md b/AGENTS.md index c084a48..7af0a41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,8 +12,8 @@ The implementation is conformant only when it passes the language-neutral suite. The synchronized 0.1.0 release uses these immutable inputs: - specification: `318ef1f16ae024770090bd338c8b70056df2855b`; -- conformance: `c6637066c1923e451edad62b7dc2ae73babfbec0` (110 core cases, - persistence profiles, and the 83-vector execution-checkpoint profile). +- conformance: `86cb08a98267371b96b8f4908409aee022e4b4fe` (110 core cases, + persistence profiles, and the 85-vector execution-checkpoint profile). The package metadata is `0.1.0` for the next synchronized release; the specification, conformance suite, Python engine, and Rust engine version together. @@ -55,6 +55,11 @@ Layout: - Preserve lazy CEL and JSON Schema imports where practical. - Preserve lazy Psycopg import and explicit file/database schema setup. Never add checkpoint or root-marker deletion. +- Every execution-store transaction is root-bound. Shared application transactions + use the host-owned callback API; never expose raw native/store transaction injection + on portable host operations or return committed/pending responses before commit. +- Durable and retention profile checks use the configured store instance. SQLite and + PostgreSQL schema health requires the exact explicit schema version and shape. ## Gates diff --git a/README.md b/README.md index 40503b2..e453b63 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@ a language-agnostic statechart engine with a shared normative conformance suite. This release implements Determa State `format: 1` at the synchronized specification commit `318ef1f16ae024770090bd338c8b70056df2855b`. Correctness is determined by the -110-case core suite, persistence profiles, and 83-vector execution-checkpoint profile -at conformance commit `c6637066c1923e451edad62b7dc2ae73babfbec0`. +110-case core suite, persistence profiles, and 85-vector execution-checkpoint profile +at conformance commit `86cb08a98267371b96b8f4908409aee022e4b4fe`. The package metadata is `0.1.0` for the next synchronized release of the specification, conformance suite, Python engine, and Rust engine. @@ -175,8 +175,43 @@ checkpoint = host.read_checkpoint("counter-42").document `MemoryExecutionStore` is ephemeral. `FileExecutionStore` provides locked atomic replacement and restart persistence only. SQLite advertises durable single-writer storage only with its verified transaction, journal, and synchronization settings. -The optional PostgreSQL adapter provides concurrent CAS and can join a caller-owned -native transaction. File and database schema setup is never implicit. +The optional PostgreSQL adapter provides concurrent CAS and host-owned shared +application transactions. Every store transaction is bound to one exact root. + +SQLite and PostgreSQL accept explicit `replay_retention="permanent"` and +`outbox_retention="strict" | "compact"` configuration. These settings add only the +retention capabilities they actually enforce. `ExecutionHost` validates required +capabilities and composed profiles against the injected store: + +```python +store = ds.SQLiteExecutionStore( + "bank.db", + replay_retention="permanent", + outbox_retention="strict", +) +store.setup_schema() +host = ds.ExecutionHost( + store, + resolver, + required_capabilities={ + ds.DURABLE_SINGLE_WRITER, + ds.ROOT_IDENTITY_RETENTION, + ds.PERMANENT_RECEIPT_RETENTION, + }, + profile="exactly_once_committed_processing", +) +``` + +For PostgreSQL application composition, `run_shared_transaction` opens and owns one +native transaction. Its callback receives the Psycopg connection plus a root-bound +staging surface for exactly one host operation. That operation returns only +`StagedExecutionResult`; the portable committed or pending response is returned by +`run_shared_transaction` after the native transaction commits. Callback failure rolls +back both application writes and checkpoint work. + +File and database schema setup is never implicit. SQLite and PostgreSQL validate an +explicit schema version and the exact required tables, columns, types, nullability, +primary keys, indexes, and triggers before checkpoint use. `ExecutionStoreRegistry` starts empty. `register_bundled_execution_stores` registers `memory`, `file`, `sqlite`, and `postgresql` through the same public operation used by diff --git a/conformance/execution_checkpoint.py b/conformance/execution_checkpoint.py index c8bfe60..a764db6 100644 --- a/conformance/execution_checkpoint.py +++ b/conformance/execution_checkpoint.py @@ -18,16 +18,12 @@ ExecutionStore, ExecutionStoreError, ExecutionStoreRegistry, - FileExecutionStore, MemoryArtifactResolver, MemoryExecutionStore, - PostgreSQLExecutionStore, - SQLiteExecutionStore, load_bundle, - memory_execution_store_factory, + register_bundled_execution_stores, restore_execution_checkpoint, serialize_execution_checkpoint, - validate_host_profile, ) from .harness import conformance_root @@ -222,20 +218,25 @@ def _invoke_host( class _StaticStore(ExecutionStore): - def __init__(self, capabilities: list[str]) -> None: + def __init__( + self, capabilities: list[str], checkpoint_retention_mode: str = "permanent" + ) -> None: self._capabilities = frozenset(capabilities) + self._checkpoint_retention_mode = checkpoint_retention_mode @property def capabilities(self) -> frozenset[str]: return self._capabilities + @property + def checkpoint_retention_mode(self) -> str: + return self._checkpoint_retention_mode + def transaction( self, root_instance_id: str, - *, - native_transaction: Any | None = None, ) -> Any: - del root_instance_id, native_transaction + del root_instance_id raise AssertionError("profile-only store must not process roots") def setup_schema(self) -> None: @@ -253,12 +254,11 @@ def _adapter_operation(vector: dict[str, Any]) -> dict[str, Any]: capabilities = vector.get("advertised_capabilities", []) requested = set(vector.get("requested_capabilities", [])) if operation == "validate_host_profile": - if not requested.issubset(capabilities): - raise ExecutionHostError("adapter_capability_mismatch") - validate_host_profile( - frozenset(capabilities), - vector["host_profile"], - checkpoint_retention_mode=vector["checkpoint_retention_mode"], + ExecutionHost( + _StaticStore(capabilities, vector["checkpoint_retention_mode"]), + MemoryArtifactResolver(), + required_capabilities=requested, + profile=vector["host_profile"], host_features=frozenset(vector["host_features"]), ) return {"result": "accepted"} @@ -274,52 +274,107 @@ def static_factory(uri: str, configuration: dict[str, Any]) -> ExecutionStore: if operation == "register_adapter": if identifier == "memory": - registry.register(identifier, memory_execution_store_factory) - registry.resolve( + register_bundled_execution_stores(registry) + store = registry.resolve( "memory:", required_capabilities=frozenset(requested) ) + assert store.capabilities == frozenset(capabilities) else: registry.register(identifier, static_factory) registry.register(identifier, static_factory) return {"result": "accepted"} - def bundled_factory( - _uri: str, _configuration: dict[str, Any] - ) -> ExecutionStore: - if identifier == "memory": - return MemoryExecutionStore() - if identifier == "file": - return FileExecutionStore("/tmp/unused") - if identifier == "sqlite": - return SQLiteExecutionStore("/tmp/unused.sqlite") - if identifier == "postgresql": - return PostgreSQLExecutionStore("postgresql://unused") - return static_factory(_uri, _configuration) - - factory = bundled_factory uri = { "memory": "memory:", "file": "file:///tmp/unused", "sqlite": "sqlite:///tmp/unused.sqlite", "postgresql": "postgresql://unused", }.get(identifier, f"{identifier}:") - if identifier != "absent-store": - registry.register(identifier, factory) - registry.resolve(uri, required_capabilities=frozenset(requested)) + if vector["registration_source"] == "bundled": + register_bundled_execution_stores(registry) + elif identifier != "absent-store": + registry.register(identifier, static_factory) + store = registry.resolve(uri, required_capabilities=frozenset(requested)) + assert store.capabilities == frozenset(capabilities) return {"result": "accepted"} -def _assert_response(vector: dict[str, Any], response: dict[str, Any]) -> None: +def _expected_response( + vector: dict[str, Any], + checkpoint: dict[str, Any] | None, + request: dict[str, Any], +) -> dict[str, Any]: expected = vector["expect"] - assert response["result"] == expected["result"] - if "receipt_sequence" in expected and "receipt" in response: - assert response["receipt"]["receipt_sequence"] == expected["receipt_sequence"] - if "delivery_sequence" in expected: - assert response["delivery_sequence"] == expected["delivery_sequence"] - if "accepted_revision" in expected: - assert response["accepted_revision"] == expected["accepted_revision"] - if "code" in expected: - assert response["failure"]["code"] == expected["code"] + result = expected["result"] + if result in {"failure", "response_lost", "not_accepted", "unsupported"}: + return {"result": result, "failure": {"code": expected["code"]}} + if result == "accepted": + return {"result": "accepted"} + assert checkpoint is not None + if result == "pending": + pending = next( + item + for item in checkpoint["pending_deliveries"] + if item["delivery_sequence"] == expected["delivery_sequence"] + ) + return { + "result": "pending", + "event_id": pending["envelope"]["event_id"], + "delivery_sequence": pending["delivery_sequence"], + "accepted_revision": pending["accepted_revision"], + } + if result == "tombstoned": + return { + "result": "tombstoned", + "tombstone": copy.deepcopy(checkpoint["root_record"]), + } + assert result == "committed" + operation = vector["operation"] + if "receipt_sequence" in expected: + receipt = next( + item + for item in checkpoint["operation_receipts"] + if item["receipt_sequence"] == expected["receipt_sequence"] + ) + return {"result": "committed", "receipt": copy.deepcopy(receipt)} + if operation == "update_pending_outbox": + record = next( + item + for item in checkpoint["pending_outbox_intents"] + if item["intent"]["effect_id"] == request["effect_id"] + ) + return {"result": "committed", "record": copy.deepcopy(record)} + if operation == "terminalize_outbox": + records = [ + *checkpoint["terminal_outbox_records"], + *checkpoint["outbox_effect_tombstones"], + ] + record = next( + item + for item in records + if ( + item["intent"]["effect_id"] + if "intent" in item + else item["effect_id"] + ) + == request["effect_id"] + ) + return {"result": "committed", "record": copy.deepcopy(record)} + if operation == "compact_outbox": + record = next( + item + for item in checkpoint["outbox_effect_tombstones"] + if item["effect_id"] == request["effect_id"] + ) + return {"result": "committed", "record": copy.deepcopy(record)} + if operation == "update_replay_retention": + return { + "result": "committed", + "replay_retention": copy.deepcopy(checkpoint["replay_retention"]), + } + if operation == "delete_outbox_record": + return {"result": "committed"} + raise AssertionError(f"no exact response projection for {operation}") def run_execution_checkpoint_vector(item: ExecutionCheckpointVector) -> None: @@ -338,7 +393,7 @@ def run_execution_checkpoint_vector(item: ExecutionCheckpointVector) -> None: response = _adapter_operation(vector) except (ExecutionHostError, ExecutionStoreError) as exc: response = {"result": "failure", "failure": {"code": exc.code}} - _assert_response(vector, response) + assert response == _expected_response(vector, None, {}) return initial = {} @@ -415,14 +470,13 @@ def observed_migrate(*args: Any, **kwargs: Any) -> Any: host_module.core_dispatch, host_module.migrate_aggregate, ) = originals - _assert_response(vector, response) - assert calls == ([] if expected["core_call"] == "none" else [expected["core_call"]]) - restored = host.read_checkpoint(root_instance_id) actual_checkpoint = None if restored is None else restored.document expected_checkpoint = ( None if after_name is None else _json(case.path / after_name) ) + assert response == _expected_response(vector, expected_checkpoint, request) + assert calls == ([] if expected["core_call"] == "none" else [expected["core_call"]]) assert actual_checkpoint == expected_checkpoint diff --git a/conformance/pins.py b/conformance/pins.py index 008d02a..d0ac803 100644 --- a/conformance/pins.py +++ b/conformance/pins.py @@ -4,7 +4,7 @@ from pathlib import Path -CONFORMANCE_COMMIT = "c6637066c1923e451edad62b7dc2ae73babfbec0" +CONFORMANCE_COMMIT = "86cb08a98267371b96b8f4908409aee022e4b4fe" SPEC_COMMIT = "318ef1f16ae024770090bd338c8b70056df2855b" ROOT = Path(__file__).resolve().parent.parent diff --git a/conformance/test_conformance.py b/conformance/test_conformance.py index 4232fa3..cf25110 100644 --- a/conformance/test_conformance.py +++ b/conformance/test_conformance.py @@ -46,7 +46,7 @@ def _spec_root() -> Path | None: def test_suite_present() -> None: assert CORE_DIR.exists(), "pinned conformance suite is unavailable" assert len(core_cases()) == 110 - assert len(execution_checkpoint_vectors()) == 83 + assert len(execution_checkpoint_vectors()) == 85 def test_bundled_schema_matches_pinned_spec() -> None: diff --git a/src/determa/state/__init__.py b/src/determa/state/__init__.py index 336ad8f..db8c3c2 100644 --- a/src/determa/state/__init__.py +++ b/src/determa/state/__init__.py @@ -27,6 +27,8 @@ from .host import ( ExecutionHost, ExecutionHostError, + SharedExecutionTransaction, + StagedExecutionResult, creation_request_digest, delivery_request_digest, maintenance_migration_request_digest, @@ -125,7 +127,9 @@ "SHARED_APPLICATION_TRANSACTION", "STANDARD_CAPABILITIES", "SchemaError", + "SharedExecutionTransaction", "SQLiteExecutionStore", + "StagedExecutionResult", "ValidationError", "__version__", "aggregate_envelope", diff --git a/src/determa/state/checkpoint.py b/src/determa/state/checkpoint.py index 16c570b..de034fe 100644 --- a/src/determa/state/checkpoint.py +++ b/src/determa/state/checkpoint.py @@ -23,6 +23,7 @@ ) _DECIMAL = re.compile(r"(?:0|[1-9][0-9]*)\Z") +_MAX_DECIMAL_DIGITS = 4096 @dataclass(frozen=True) @@ -76,9 +77,16 @@ def validate_execution_checkpoint_member(name: str, value: Any) -> bool: def _decimal(value: Any) -> int: - if not isinstance(value, str) or _DECIMAL.fullmatch(value) is None: + if ( + not isinstance(value, str) + or len(value) > _MAX_DECIMAL_DIGITS + or _DECIMAL.fullmatch(value) is None + ): raise _invalid() - return int(value) + try: + return int(value) + except ValueError as exc: + raise _invalid() from exc def _ordered_unique(values: list[int]) -> bool: @@ -427,15 +435,64 @@ def _validate_audit_and_root( ): raise _invalid() + final_digest = ( + root_record["final_aggregate_state_digest"] + if root_record["status"] == "tombstone" + else root_record["aggregate_state"]["aggregate_state_digest"] + ) + retention = document["replay_retention"] + cutoff = retention["pruned_through_receipt_sequence"] + last_receipt = document["operation_receipts"][-1] + has_final_receipt_evidence = ( + cutoff is None or last_receipt["receipt_sequence"] != "0" + ) + if ( + has_final_receipt_evidence + and last_receipt["resulting_aggregate_state_digest"] != final_digest + ): + raise _invalid() + + status_evidence = next( + ( + receipt + for receipt in reversed(document["operation_receipts"]) + if receipt["operation_kind"] in {"creation", "delivery"} + ), + None, + ) + if ( + status_evidence is None + or ( + cutoff is not None + and status_evidence["receipt_sequence"] == "0" + ) + ): + return + if status_evidence["operation_kind"] == "creation": + status = status_evidence["status"] + fault = status_evidence["fault"] + else: + status = status_evidence["outcome"]["status"] + fault = status_evidence["outcome"]["fault"] + if root_record["status"] == "tombstone": - final_digest = document["operation_receipts"][-1][ - "resulting_aggregate_state_digest" - ] - if root_record["final_aggregate_state_digest"] != final_digest: + if status != root_record["terminal_status"]: raise _invalid() - elif ( - root_record["aggregate_state"]["aggregate_state_digest"] - != document["operation_receipts"][-1]["resulting_aggregate_state_digest"] + return + + aggregate = root_record["aggregate_state"] + root_runtime = next( + ( + runtime + for runtime in aggregate["runtimes"] + if runtime["runtime_id"] == aggregate["root_runtime_id"] + ), + None, + ) + if ( + root_runtime is None + or root_runtime["status"] != status + or root_runtime["fault"] != fault ): raise _invalid() diff --git a/src/determa/state/host.py b/src/determa/state/host.py index 5b59914..80f6822 100644 --- a/src/determa/state/host.py +++ b/src/determa/state/host.py @@ -5,6 +5,7 @@ import copy from collections.abc import Callable, Mapping, Sequence from contextlib import nullcontext +from dataclasses import dataclass from typing import Any, cast from .checkpoint import ( @@ -40,6 +41,7 @@ ) FaultInjector = Callable[[str], None] +_MAX_DECIMAL_DIGITS = 4096 class ExecutionHostError(DetermaError): @@ -51,6 +53,34 @@ def __init__(self, code: str, message: str = "") -> None: super().__init__(self.message) +def _checkpoint_number(value: Any) -> int: + if ( + not isinstance(value, str) + or len(value) > _MAX_DECIMAL_DIGITS + or ( + value != "0" + and ( + not value + or value[0] == "0" + or not value.isascii() + or not value.isdigit() + ) + ) + ): + raise ExecutionHostError("invalid_execution_checkpoint") + try: + return int(value) + except ValueError as exc: + raise ExecutionHostError("invalid_execution_checkpoint") from exc + + +def _increment_checkpoint_number(value: Any) -> str: + result = str(_checkpoint_number(value) + 1) + if len(result) > _MAX_DECIMAL_DIGITS: + raise ExecutionHostError("invalid_execution_checkpoint") + return result + + def creation_request_digest( bundle: Bundle | BundleSource, machine_id: str, @@ -159,13 +189,14 @@ def outbox_intent_digest( def validate_host_profile( - capabilities: set[str] | frozenset[str], + store: ExecutionStore, profile: str, *, - checkpoint_retention_mode: str, host_features: set[str] | frozenset[str], ) -> None: """Validate one composed checkpoint-host profile without name inference.""" + capabilities = store.capabilities + checkpoint_retention_mode = store.checkpoint_retention_mode durable = bool( {DURABLE_SINGLE_WRITER, DURABLE_CONCURRENT}.intersection(capabilities) ) @@ -220,6 +251,14 @@ def validate_host_profile( raise ExecutionHostError("adapter_capability_mismatch") +@dataclass(frozen=True) +class StagedExecutionResult: + """An operation staged inside a host-owned shared transaction.""" + + operation: str + state: str = "staged" + + def _project_fault( result: Mapping[str, Any], aggregate: Mapping[str, Any] | None ) -> dict[str, Any] | None: @@ -281,7 +320,9 @@ def _append_emissions( for index, emission in enumerate(projected_result["emissions"]): if emission["kind"] == "internal": sequence = checkpoint["next_delivery_sequence"] - checkpoint["next_delivery_sequence"] = str(int(sequence) + 1) + checkpoint["next_delivery_sequence"] = _increment_checkpoint_number( + sequence + ) origin = { "kind": "internal_emission", "producing_receipt_sequence": receipt["receipt_sequence"], @@ -384,18 +425,18 @@ def _new_checkpoint( def _mutate(checkpoint: Mapping[str, Any]) -> dict[str, Any]: result = copy.deepcopy(dict(checkpoint)) result.pop("execution_checkpoint_digest", None) - result["revision"] = str(int(result["revision"]) + 1) + result["revision"] = _increment_checkpoint_number(result["revision"]) return result def _delivery_from_wire(mode: str, envelope: Mapping[str, Any]) -> dict[str, Any]: target = copy.deepcopy(envelope["target"]) if "component" in target: - target["component"]["activation_sequence"] = int( + target["component"]["activation_sequence"] = _checkpoint_number( target["component"]["activation_sequence"] ) elif "spawned_instance" in target: - target["spawned_instance"]["machine_version"] = int( + target["spawned_instance"]["machine_version"] = _checkpoint_number( target["spawned_instance"]["machine_version"] ) native_envelope = { @@ -417,11 +458,23 @@ def __init__( store: ExecutionStore, artifact_resolver: ArtifactResolver, *, + required_capabilities: set[str] | frozenset[str] = frozenset(), + profile: str | None = None, + host_features: set[str] | frozenset[str] = frozenset( + {"atomic_checkpoint_processing"} + ), fault_injector: FaultInjector | None = None, ) -> None: + if not required_capabilities.issubset(store.capabilities): + raise ExecutionHostError("adapter_capability_mismatch") + if required_capabilities or profile is not None: + store.validate_schema() + if profile is not None: + validate_host_profile(store, profile, host_features=host_features) self.store = store self.artifact_resolver = artifact_resolver self.fault_injector = fault_injector + self._bound_transaction: ExecutionStoreTransaction | None = None @classmethod def from_uri( @@ -432,6 +485,10 @@ def from_uri( *, configuration: Mapping[str, Any] | None = None, required_capabilities: set[str] | frozenset[str] = frozenset(), + profile: str | None = None, + host_features: set[str] | frozenset[str] = frozenset( + {"atomic_checkpoint_processing"} + ), fault_injector: FaultInjector | None = None, ) -> ExecutionHost: store = registry.resolve( @@ -439,34 +496,82 @@ def from_uri( configuration=configuration, required_capabilities=required_capabilities, ) - return cls(store, artifact_resolver, fault_injector=fault_injector) + return cls( + store, + artifact_resolver, + required_capabilities=required_capabilities, + profile=profile, + host_features=host_features, + fault_injector=fault_injector, + ) def _fault(self, boundary: str) -> None: if self.fault_injector is not None: self.fault_injector(boundary) - def _after_commit( - self, - native_transaction: Any | None, - store_transaction: ExecutionStoreTransaction | None, - ) -> None: - if native_transaction is None and store_transaction is None: + def _after_commit(self) -> None: + if self._bound_transaction is None: self._fault("after_commit_before_response") - def _restore(self, source: bytes) -> RestoredExecutionCheckpoint: - return restore_execution_checkpoint(source, self.artifact_resolver) + def _restore( + self, + source: bytes, + root_instance_id: str, + ) -> RestoredExecutionCheckpoint: + restored = restore_execution_checkpoint(source, self.artifact_resolver) + if restored.document["root_instance_id"] != root_instance_id: + raise ExecutionHostError("transaction_root_mismatch") + if ( + PERMANENT_RECEIPT_RETENTION in self.store.capabilities + and restored.document["replay_retention"]["mode"] != "permanent" + ): + raise ExecutionHostError("adapter_capability_mismatch") + if ( + PERMANENT_OUTBOX_TERMINAL_RETENTION in self.store.capabilities + and restored.document["outbox_effect_tombstones"] + ): + raise ExecutionHostError("adapter_capability_mismatch") + return restored def _transaction( self, root_instance_id: str, - native_transaction: Any | None, - store_transaction: ExecutionStoreTransaction | None, ) -> Any: - if store_transaction is not None: - return nullcontext(store_transaction) - return self.store.transaction( - root_instance_id, native_transaction=native_transaction - ) + if self._bound_transaction is not None: + if self._bound_transaction.root_instance_id != root_instance_id: + raise ExecutionHostError("transaction_root_mismatch") + return nullcontext(self._bound_transaction) + return self.store.transaction(root_instance_id) + + def _bound(self, transaction: ExecutionStoreTransaction) -> ExecutionHost: + bound = copy.copy(self) + bound._bound_transaction = transaction + return bound + + def run_shared_transaction( + self, + root_instance_id: str, + callback: Callable[[Any, SharedExecutionTransaction], None], + ) -> dict[str, Any]: + """Commit application writes and exactly one staged host operation together.""" + if SHARED_APPLICATION_TRANSACTION not in self.store.capabilities: + raise ExecutionHostError("adapter_capability_mismatch") + with self.store.shared_transaction(root_instance_id) as ( + native_transaction, + store_transaction, + ): + if store_transaction.root_instance_id != root_instance_id: + raise ExecutionHostError("transaction_root_mismatch") + shared = SharedExecutionTransaction( + self._bound(store_transaction), root_instance_id + ) + try: + callback(native_transaction, shared) + response = shared._finish() + finally: + shared._deactivate() + self._fault("after_commit_before_response") + return response def _check_expected( self, @@ -507,15 +612,14 @@ def _stage_replace( def read_checkpoint( self, root_instance_id: str, - *, - native_transaction: Any | None = None, - store_transaction: ExecutionStoreTransaction | None = None, ) -> RestoredExecutionCheckpoint | None: - with self._transaction( - root_instance_id, native_transaction, store_transaction - ) as transaction: + with self._transaction(root_instance_id) as transaction: source = transaction.load() - return None if source is None else self._restore(source) + return ( + None + if source is None + else self._restore(source, root_instance_id) + ) def create( self, @@ -524,9 +628,6 @@ def create( root_instance_id: str, creation_id: str, bindings: Mapping[str, Mapping[str, Any]] | None = None, - *, - native_transaction: Any | None = None, - store_transaction: ExecutionStoreTransaction | None = None, ) -> dict[str, Any]: validated = bundle if isinstance(bundle, Bundle) else load_bundle(bundle) normalized_bindings = { @@ -540,12 +641,10 @@ def create( creation_id, normalized_bindings, ) - with self._transaction( - root_instance_id, native_transaction, store_transaction - ) as transaction: + with self._transaction(root_instance_id) as transaction: source = transaction.load() if source is not None: - checkpoint = self._restore(source).document + checkpoint = self._restore(source, root_instance_id).document receipt = checkpoint["operation_receipts"][0] if ( receipt["creation_id"] == creation_id @@ -567,12 +666,12 @@ def create( candidate = _new_checkpoint(aggregate, request_digest, projected) self._stage_insert(transaction, candidate) receipt = copy.deepcopy(candidate["operation_receipts"][0]) - self._after_commit(native_transaction, store_transaction) + self._after_commit() return {"result": "committed", "receipt": receipt} def _delivery_candidate( self, candidate: Any - ) -> tuple[str | None, str | None, dict[str, Any] | None, dict[str, Any] | None, str | None]: + ) -> tuple[str | None, str | None, Any, dict[str, Any] | None, str | None]: if not isinstance(candidate, Mapping): return None, None, None, None, None allowed = { @@ -582,18 +681,18 @@ def _delivery_candidate( "envelope", "envelope_digest", } - if not set(candidate).issubset(allowed): + required = {"root_instance_id", "delivery_mode", "origin", "envelope"} + if not required.issubset(candidate) or not set(candidate).issubset(allowed): return None, None, None, None, None - root_instance_id = candidate.get("root_instance_id") - mode = candidate.get("delivery_mode") - origin = candidate.get("origin") - envelope = candidate.get("envelope") + root_instance_id = candidate["root_instance_id"] + mode = candidate["delivery_mode"] + origin = candidate["origin"] + envelope = candidate["envelope"] supplied_digest = candidate.get("envelope_digest") if ( not isinstance(root_instance_id, str) or not root_instance_id or not isinstance(mode, str) - or not isinstance(origin, dict) or not isinstance(envelope, dict) or not validate_execution_checkpoint_member("envelope", envelope) or (supplied_digest is not None and not isinstance(supplied_digest, str)) @@ -640,36 +739,33 @@ def _prepare_acceptance( ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: parsed = self._delivery_candidate(candidate) root_instance_id, mode, origin, envelope, supplied_digest = parsed - if root_instance_id is None or mode is None or origin is None or envelope is None: + if root_instance_id is None or mode is None or envelope is None: return None, self._not_accepted("malformed_delivery") if root_instance_id != checkpoint["root_instance_id"]: return None, self._not_accepted("wrong_root") + digest = delivery_request_digest(root_instance_id, mode, envelope) + replay = self._delivery_replay( + checkpoint, envelope["event_id"], digest + ) + if replay is not None: + return None, replay + if checkpoint["root_record"]["status"] == "tombstone": + return None, self._not_accepted("tombstoned_root") + valid_mode = mode in {"input", "internal"} valid_origin = validate_execution_checkpoint_member("deliveryOrigin", origin) valid_pair = ( mode == "input" and origin == {"kind": "host_input"} ) or ( - mode == "internal" and origin.get("kind") == "internal_emission" - ) - digest = ( - delivery_request_digest(root_instance_id, mode, envelope) - if valid_mode and valid_origin and valid_pair - else None + mode == "internal" + and isinstance(origin, Mapping) + and origin.get("kind") == "internal_emission" ) - if digest is not None: - replay = self._delivery_replay( - checkpoint, envelope["event_id"], digest - ) - if replay is not None: - return None, replay - if checkpoint["root_record"]["status"] == "tombstone": - return None, self._not_accepted("tombstoned_root") if not valid_mode: return None, self._not_accepted("invalid_delivery_mode") if not valid_origin or not valid_pair: return None, self._not_accepted("invalid_delivery_origin") - assert digest is not None if supplied_digest is not None and supplied_digest != digest: return None, self._not_accepted("delivery_digest_mismatch") if ( @@ -692,16 +788,12 @@ def accept_delivery( *, expected_revision: str, expected_checkpoint_digest: str, - native_transaction: Any | None = None, - store_transaction: ExecutionStoreTransaction | None = None, ) -> dict[str, Any]: - with self._transaction( - root_instance_id, native_transaction, store_transaction - ) as transaction: + with self._transaction(root_instance_id) as transaction: source = transaction.load() if source is None: return self._not_accepted("wrong_root") - checkpoint = self._restore(source).document + checkpoint = self._restore(source, root_instance_id).document prepared, result = self._prepare_acceptance(checkpoint, candidate) if result is not None: return result @@ -711,7 +803,9 @@ def accept_delivery( ) next_checkpoint = _mutate(checkpoint) sequence = next_checkpoint["next_delivery_sequence"] - next_checkpoint["next_delivery_sequence"] = str(int(sequence) + 1) + next_checkpoint["next_delivery_sequence"] = _increment_checkpoint_number( + sequence + ) pending = { "delivery_sequence": sequence, "accepted_revision": next_checkpoint["revision"], @@ -729,7 +823,7 @@ def accept_delivery( "delivery_sequence": sequence, "accepted_revision": pending["accepted_revision"], } - self._after_commit(native_transaction, store_transaction) + self._after_commit() return response def _commit_delivery( @@ -745,7 +839,9 @@ def _commit_delivery( candidate = _mutate(checkpoint) if foreground: delivery_sequence = candidate["next_delivery_sequence"] - candidate["next_delivery_sequence"] = str(int(delivery_sequence) + 1) + candidate["next_delivery_sequence"] = _increment_checkpoint_number( + delivery_sequence + ) accepted_revision = candidate["revision"] else: assert pending is not None @@ -763,8 +859,8 @@ def _commit_delivery( "envelope_digest": pending["envelope_digest"], } receipt_sequence = candidate["next_operation_receipt_sequence"] - candidate["next_operation_receipt_sequence"] = str( - int(receipt_sequence) + 1 + candidate["next_operation_receipt_sequence"] = ( + _increment_checkpoint_number(receipt_sequence) ) aggregate = copy.deepcopy(projected["aggregate_state"]) if aggregate is None or restored.aggregate is None: @@ -802,16 +898,12 @@ def process_pending_delivery( *, expected_revision: str, expected_checkpoint_digest: str, - native_transaction: Any | None = None, - store_transaction: ExecutionStoreTransaction | None = None, ) -> dict[str, Any]: - with self._transaction( - root_instance_id, native_transaction, store_transaction - ) as transaction: + with self._transaction(root_instance_id) as transaction: source = transaction.load() if source is None: raise ExecutionHostError("wrong_root") - restored = self._restore(source) + restored = self._restore(source, root_instance_id) checkpoint = restored.document parsed = self._delivery_candidate(candidate) candidate_root, mode, origin, envelope, supplied_digest = parsed @@ -865,7 +957,7 @@ def process_pending_delivery( ) self._stage_replace(transaction, checkpoint, next_checkpoint) response = {"result": "committed", "receipt": copy.deepcopy(receipt)} - self._after_commit(native_transaction, store_transaction) + self._after_commit() return response def foreground_process_delivery( @@ -875,16 +967,12 @@ def foreground_process_delivery( *, expected_revision: str, expected_checkpoint_digest: str, - native_transaction: Any | None = None, - store_transaction: ExecutionStoreTransaction | None = None, ) -> dict[str, Any]: - with self._transaction( - root_instance_id, native_transaction, store_transaction - ) as transaction: + with self._transaction(root_instance_id) as transaction: source = transaction.load() if source is None: raise ExecutionHostError("wrong_root") - restored = self._restore(source) + restored = self._restore(source, root_instance_id) checkpoint = restored.document prepared, replay = self._prepare_acceptance(checkpoint, candidate) if replay is not None: @@ -912,7 +1000,7 @@ def foreground_process_delivery( ) self._stage_replace(transaction, checkpoint, next_checkpoint) response = {"result": "committed", "receipt": copy.deepcopy(receipt)} - self._after_commit(native_transaction, store_transaction) + self._after_commit() return response def maintenance_migration( @@ -922,42 +1010,33 @@ def maintenance_migration( target_validated_bundle_fingerprint: str, migration_descriptor_digest_route: Sequence[str], *, + source_aggregate_state_digest: str, expected_revision: str, expected_checkpoint_digest: str, - source_aggregate_state_digest: str | None = None, maintenance_mode: bool = True, limits: MigrationLimits | None = None, - native_transaction: Any | None = None, - store_transaction: ExecutionStoreTransaction | None = None, ) -> dict[str, Any]: - if not operation_id: + if ( + not operation_id + or not validate_execution_checkpoint_member( + "sha256", source_aggregate_state_digest + ) + ): raise ExecutionHostError("invalid_migration_request") - with self._transaction( - root_instance_id, native_transaction, store_transaction - ) as transaction: + request_digest = maintenance_migration_request_digest( + root_instance_id, + operation_id, + source_aggregate_state_digest, + target_validated_bundle_fingerprint, + migration_descriptor_digest_route, + maintenance_mode, + ) + with self._transaction(root_instance_id) as transaction: source = transaction.load() if source is None: raise ExecutionHostError("wrong_root") - restored = self._restore(source) + restored = self._restore(source, root_instance_id) checkpoint = restored.document - if restored.aggregate is None: - raise ExecutionHostError("tombstoned_root") - current_source_digest = restored.aggregate.aggregate_envelope[ - "aggregate_state_digest" - ] - source_digest = ( - current_source_digest - if source_aggregate_state_digest is None - else source_aggregate_state_digest - ) - request_digest = maintenance_migration_request_digest( - root_instance_id, - operation_id, - source_digest, - target_validated_bundle_fingerprint, - migration_descriptor_digest_route, - maintenance_mode, - ) for receipt in checkpoint["operation_receipts"]: if ( receipt["operation_kind"] == "maintenance_migration" @@ -969,7 +1048,12 @@ def maintenance_migration( "receipt": copy.deepcopy(receipt), } raise ExecutionHostError("operation_id_conflict") - if source_digest != current_source_digest: + if restored.aggregate is None: + raise ExecutionHostError("tombstoned_root") + current_source_digest = restored.aggregate.aggregate_envelope[ + "aggregate_state_digest" + ] + if source_aggregate_state_digest != current_source_digest: raise ExecutionHostError("invalid_migration_request") self._check_expected( checkpoint, expected_revision, expected_checkpoint_digest @@ -991,8 +1075,8 @@ def maintenance_migration( raise ExecutionHostError(code) candidate = _mutate(checkpoint) receipt_sequence = candidate["next_operation_receipt_sequence"] - candidate["next_operation_receipt_sequence"] = str( - int(receipt_sequence) + 1 + candidate["next_operation_receipt_sequence"] = ( + _increment_checkpoint_number(receipt_sequence) ) migration_sequences = [ item["migration_sequence"] for item in result.audit_records @@ -1003,7 +1087,7 @@ def maintenance_migration( "operation_id": operation_id, "request_digest": request_digest, "committed_revision": candidate["revision"], - "source_aggregate_state_digest": source_digest, + "source_aggregate_state_digest": source_aggregate_state_digest, "resulting_aggregate_state_digest": result.aggregate_envelope[ "aggregate_state_digest" ], @@ -1024,7 +1108,7 @@ def maintenance_migration( candidate = seal_execution_checkpoint(candidate) self._stage_replace(transaction, checkpoint, candidate) response = {"result": "committed", "receipt": copy.deepcopy(receipt)} - self._after_commit(native_transaction, store_transaction) + self._after_commit() return response def update_pending_outbox( @@ -1035,19 +1119,15 @@ def update_pending_outbox( *, expected_revision: str, expected_checkpoint_digest: str, - native_transaction: Any | None = None, - store_transaction: ExecutionStoreTransaction | None = None, ) -> dict[str, Any]: desired = copy.deepcopy(dict(desired_pending_state)) if not validate_execution_checkpoint_member("pendingOutboxState", desired): raise ExecutionHostError("invalid_execution_checkpoint") - with self._transaction( - root_instance_id, native_transaction, store_transaction - ) as transaction: + with self._transaction(root_instance_id) as transaction: source = transaction.load() if source is None: raise ExecutionHostError("wrong_root") - checkpoint = self._restore(source).document + checkpoint = self._restore(source, root_instance_id).document item = next( ( value @@ -1074,7 +1154,7 @@ def update_pending_outbox( candidate = seal_execution_checkpoint(candidate) self._stage_replace(transaction, checkpoint, candidate) record = copy.deepcopy(candidate_item) - self._after_commit(native_transaction, store_transaction) + self._after_commit() return {"result": "committed", "record": record} def terminalize_outbox( @@ -1085,19 +1165,15 @@ def terminalize_outbox( *, expected_revision: str, expected_checkpoint_digest: str, - native_transaction: Any | None = None, - store_transaction: ExecutionStoreTransaction | None = None, ) -> dict[str, Any]: outcome = copy.deepcopy(dict(terminal_outcome)) if not validate_execution_checkpoint_member("terminalOutboxOutcome", outcome): raise ExecutionHostError("invalid_execution_checkpoint") - with self._transaction( - root_instance_id, native_transaction, store_transaction - ) as transaction: + with self._transaction(root_instance_id) as transaction: source = transaction.load() if source is None: raise ExecutionHostError("wrong_root") - checkpoint = self._restore(source).document + checkpoint = self._restore(source, root_instance_id).document for record in checkpoint["terminal_outbox_records"]: if record["intent"]["effect_id"] == effect_id: if record["outcome"] == outcome: @@ -1135,8 +1211,8 @@ def terminalize_outbox( ) candidate["pending_outbox_intents"].remove(candidate_pending) terminal_sequence = candidate["next_outbox_terminal_sequence"] - candidate["next_outbox_terminal_sequence"] = str( - int(terminal_sequence) + 1 + candidate["next_outbox_terminal_sequence"] = ( + _increment_checkpoint_number(terminal_sequence) ) record = { "terminal_sequence": terminal_sequence, @@ -1148,7 +1224,7 @@ def terminalize_outbox( candidate = seal_execution_checkpoint(candidate) self._stage_replace(transaction, checkpoint, candidate) response = {"result": "committed", "record": copy.deepcopy(record)} - self._after_commit(native_transaction, store_transaction) + self._after_commit() return response def compact_outbox( @@ -1158,16 +1234,14 @@ def compact_outbox( *, expected_revision: str, expected_checkpoint_digest: str, - native_transaction: Any | None = None, - store_transaction: ExecutionStoreTransaction | None = None, ) -> dict[str, Any]: - with self._transaction( - root_instance_id, native_transaction, store_transaction - ) as transaction: + if PERMANENT_OUTBOX_TERMINAL_RETENTION in self.store.capabilities: + raise ExecutionHostError("adapter_capability_mismatch") + with self._transaction(root_instance_id) as transaction: source = transaction.load() if source is None: raise ExecutionHostError("wrong_root") - checkpoint = self._restore(source).document + checkpoint = self._restore(source, root_instance_id).document existing = next( ( record @@ -1209,12 +1283,12 @@ def compact_outbox( } candidate["outbox_effect_tombstones"].append(tombstone) candidate["outbox_effect_tombstones"].sort( - key=lambda item: int(item["terminal_sequence"]) + key=lambda item: _checkpoint_number(item["terminal_sequence"]) ) candidate = seal_execution_checkpoint(candidate) self._stage_replace(transaction, checkpoint, candidate) response = {"result": "committed", "record": copy.deepcopy(tombstone)} - self._after_commit(native_transaction, store_transaction) + self._after_commit() return response def delete_outbox_record( @@ -1224,16 +1298,17 @@ def delete_outbox_record( *, expected_revision: str, expected_checkpoint_digest: str, - native_transaction: Any | None = None, - store_transaction: ExecutionStoreTransaction | None = None, ) -> dict[str, Any]: - with self._transaction( - root_instance_id, native_transaction, store_transaction - ) as transaction: + if { + PERMANENT_OUTBOX_TERMINAL_RETENTION, + COMPACT_EFFECT_IDENTITY_RETENTION, + }.intersection(self.store.capabilities): + raise ExecutionHostError("adapter_capability_mismatch") + with self._transaction(root_instance_id) as transaction: source = transaction.load() if source is None: raise ExecutionHostError("wrong_root") - checkpoint = self._restore(source).document + checkpoint = self._restore(source, root_instance_id).document if any( emission.get("kind") == "external_outbox" and emission.get("effect_id") == effect_id @@ -1264,7 +1339,7 @@ def delete_outbox_record( raise ExecutionHostError("effect_id_conflict") candidate = seal_execution_checkpoint(candidate) self._stage_replace(transaction, checkpoint, candidate) - self._after_commit(native_transaction, store_transaction) + self._after_commit() return {"result": "committed"} def update_replay_retention( @@ -1274,19 +1349,20 @@ def update_replay_retention( *, expected_revision: str, expected_checkpoint_digest: str, - native_transaction: Any | None = None, - store_transaction: ExecutionStoreTransaction | None = None, ) -> dict[str, Any]: target = copy.deepcopy(dict(target_replay_retention)) if not validate_execution_checkpoint_member("replayRetention", target): raise ExecutionHostError("invalid_execution_checkpoint") - with self._transaction( - root_instance_id, native_transaction, store_transaction - ) as transaction: + if ( + PERMANENT_RECEIPT_RETENTION in self.store.capabilities + and target["mode"] != "permanent" + ): + raise ExecutionHostError("adapter_capability_mismatch") + with self._transaction(root_instance_id) as transaction: source = transaction.load() if source is None: raise ExecutionHostError("wrong_root") - checkpoint = self._restore(source).document + checkpoint = self._restore(source, root_instance_id).document current = checkpoint["replay_retention"] if current == target: return {"result": "committed", "replay_retention": copy.deepcopy(current)} @@ -1305,12 +1381,17 @@ def update_replay_retention( current_cutoff is not None and ( target_cutoff is None - or int(target_cutoff) < int(current_cutoff) + or _checkpoint_number(target_cutoff) + < _checkpoint_number(current_cutoff) ) ): raise ExecutionHostError("invalid_execution_checkpoint") - if target_cutoff is not None and int(target_cutoff) >= int( - checkpoint["next_operation_receipt_sequence"] + if ( + target_cutoff is not None + and _checkpoint_number(target_cutoff) + >= _checkpoint_number( + checkpoint["next_operation_receipt_sequence"] + ) ): raise ExecutionHostError("invalid_execution_checkpoint") self._check_expected( @@ -1319,12 +1400,12 @@ def update_replay_retention( candidate = _mutate(checkpoint) candidate["replay_retention"] = target if target_cutoff is not None: - cutoff = int(target_cutoff) + cutoff = _checkpoint_number(target_cutoff) candidate["operation_receipts"] = [ receipt for receipt in candidate["operation_receipts"] if receipt["receipt_sequence"] == "0" - or int(receipt["receipt_sequence"]) > cutoff + or _checkpoint_number(receipt["receipt_sequence"]) > cutoff ] referenced_migrations = { sequence @@ -1364,7 +1445,7 @@ def update_replay_retention( "result": "committed", "replay_retention": copy.deepcopy(target), } - self._after_commit(native_transaction, store_transaction) + self._after_commit() return response def tombstone_root( @@ -1374,18 +1455,14 @@ def tombstone_root( *, expected_revision: str, expected_checkpoint_digest: str, - native_transaction: Any | None = None, - store_transaction: ExecutionStoreTransaction | None = None, ) -> dict[str, Any]: if not operation_id: raise ExecutionHostError("invalid_execution_checkpoint") - with self._transaction( - root_instance_id, native_transaction, store_transaction - ) as transaction: + with self._transaction(root_instance_id) as transaction: source = transaction.load() if source is None: raise ExecutionHostError("wrong_root") - restored = self._restore(source) + restored = self._restore(source, root_instance_id) checkpoint = restored.document root_record = checkpoint["root_record"] if root_record["status"] == "tombstone": @@ -1428,7 +1505,7 @@ def tombstone_root( "result": "tombstoned", "tombstone": copy.deepcopy(tombstone), } - self._after_commit(native_transaction, store_transaction) + self._after_commit() return response def delete_checkpoint( @@ -1445,6 +1522,247 @@ def delete_checkpoint( } +class SharedExecutionTransaction: + """Root-bound staging surface for one host-owned shared transaction.""" + + def __init__(self, host: ExecutionHost, root_instance_id: str) -> None: + self._host = host + self.root_instance_id = root_instance_id + self._active = True + self._response: dict[str, Any] | None = None + + def _stage( + self, + operation: str, + invoke: Callable[[], dict[str, Any]], + ) -> StagedExecutionResult: + if not self._active: + raise ExecutionHostError("shared_transaction_closed") + if self._response is not None: + raise ExecutionHostError("shared_transaction_operation_conflict") + self._response = invoke() + return StagedExecutionResult(operation) + + def _finish(self) -> dict[str, Any]: + if self._response is None: + raise ExecutionHostError("shared_transaction_operation_required") + if self._response["result"] not in { + "committed", + "pending", + "tombstoned", + }: + failure = self._response.get("failure", {}) + raise ExecutionHostError( + failure.get("code", "shared_transaction_operation_failed") + ) + return self._response + + def _deactivate(self) -> None: + self._active = False + + def create( + self, + bundle: Bundle | BundleSource, + machine_id: str, + creation_id: str, + bindings: Mapping[str, Mapping[str, Any]] | None = None, + ) -> StagedExecutionResult: + return self._stage( + "create", + lambda: self._host.create( + bundle, + machine_id, + self.root_instance_id, + creation_id, + bindings, + ), + ) + + def accept_delivery( + self, + candidate: Any, + *, + expected_revision: str, + expected_checkpoint_digest: str, + ) -> StagedExecutionResult: + return self._stage( + "accept_delivery", + lambda: self._host.accept_delivery( + self.root_instance_id, + candidate, + expected_revision=expected_revision, + expected_checkpoint_digest=expected_checkpoint_digest, + ), + ) + + def process_pending_delivery( + self, + candidate: Any, + *, + expected_revision: str, + expected_checkpoint_digest: str, + ) -> StagedExecutionResult: + return self._stage( + "process_pending_delivery", + lambda: self._host.process_pending_delivery( + self.root_instance_id, + candidate, + expected_revision=expected_revision, + expected_checkpoint_digest=expected_checkpoint_digest, + ), + ) + + def foreground_process_delivery( + self, + candidate: Any, + *, + expected_revision: str, + expected_checkpoint_digest: str, + ) -> StagedExecutionResult: + return self._stage( + "foreground_process_delivery", + lambda: self._host.foreground_process_delivery( + self.root_instance_id, + candidate, + expected_revision=expected_revision, + expected_checkpoint_digest=expected_checkpoint_digest, + ), + ) + + def maintenance_migration( + self, + operation_id: str, + target_validated_bundle_fingerprint: str, + migration_descriptor_digest_route: Sequence[str], + *, + source_aggregate_state_digest: str, + expected_revision: str, + expected_checkpoint_digest: str, + maintenance_mode: bool = True, + limits: MigrationLimits | None = None, + ) -> StagedExecutionResult: + return self._stage( + "maintenance_migration", + lambda: self._host.maintenance_migration( + self.root_instance_id, + operation_id, + target_validated_bundle_fingerprint, + migration_descriptor_digest_route, + source_aggregate_state_digest=source_aggregate_state_digest, + expected_revision=expected_revision, + expected_checkpoint_digest=expected_checkpoint_digest, + maintenance_mode=maintenance_mode, + limits=limits, + ), + ) + + def update_pending_outbox( + self, + effect_id: str, + desired_pending_state: Mapping[str, Any], + *, + expected_revision: str, + expected_checkpoint_digest: str, + ) -> StagedExecutionResult: + return self._stage( + "update_pending_outbox", + lambda: self._host.update_pending_outbox( + self.root_instance_id, + effect_id, + desired_pending_state, + expected_revision=expected_revision, + expected_checkpoint_digest=expected_checkpoint_digest, + ), + ) + + def terminalize_outbox( + self, + effect_id: str, + terminal_outcome: Mapping[str, Any], + *, + expected_revision: str, + expected_checkpoint_digest: str, + ) -> StagedExecutionResult: + return self._stage( + "terminalize_outbox", + lambda: self._host.terminalize_outbox( + self.root_instance_id, + effect_id, + terminal_outcome, + expected_revision=expected_revision, + expected_checkpoint_digest=expected_checkpoint_digest, + ), + ) + + def compact_outbox( + self, + effect_id: str, + *, + expected_revision: str, + expected_checkpoint_digest: str, + ) -> StagedExecutionResult: + return self._stage( + "compact_outbox", + lambda: self._host.compact_outbox( + self.root_instance_id, + effect_id, + expected_revision=expected_revision, + expected_checkpoint_digest=expected_checkpoint_digest, + ), + ) + + def delete_outbox_record( + self, + effect_id: str, + *, + expected_revision: str, + expected_checkpoint_digest: str, + ) -> StagedExecutionResult: + return self._stage( + "delete_outbox_record", + lambda: self._host.delete_outbox_record( + self.root_instance_id, + effect_id, + expected_revision=expected_revision, + expected_checkpoint_digest=expected_checkpoint_digest, + ), + ) + + def update_replay_retention( + self, + target_replay_retention: Mapping[str, Any], + *, + expected_revision: str, + expected_checkpoint_digest: str, + ) -> StagedExecutionResult: + return self._stage( + "update_replay_retention", + lambda: self._host.update_replay_retention( + self.root_instance_id, + target_replay_retention, + expected_revision=expected_revision, + expected_checkpoint_digest=expected_checkpoint_digest, + ), + ) + + def tombstone_root( + self, + operation_id: str, + *, + expected_revision: str, + expected_checkpoint_digest: str, + ) -> StagedExecutionResult: + return self._stage( + "tombstone_root", + lambda: self._host.tombstone_root( + self.root_instance_id, + operation_id, + expected_revision=expected_revision, + expected_checkpoint_digest=expected_checkpoint_digest, + ), + ) + + def _target_root_instance_id(target: Mapping[str, Any]) -> str: member = next(iter(target.values())) return str(member["root_instance_id"]) diff --git a/src/determa/state/stores/base.py b/src/determa/state/stores/base.py index f8b311d..8266df7 100644 --- a/src/determa/state/stores/base.py +++ b/src/determa/state/stores/base.py @@ -47,6 +47,11 @@ def __init__(self, code: str, message: str = "") -> None: class ExecutionStoreTransaction(ABC): """One exclusive or serializable transaction for a single root.""" + @property + @abstractmethod + def root_instance_id(self) -> str: + """The exact root identity bound to this transaction.""" + @abstractmethod def load(self) -> bytes | None: """Read the current checkpoint bytes.""" @@ -73,32 +78,51 @@ class ExecutionStore(ABC): def capabilities(self) -> frozenset[str]: """Capabilities proved by this configured instance.""" + @property + def checkpoint_retention_mode(self) -> str: + """Configured replay-retention mode used for profile validation.""" + return "permanent" + @abstractmethod def transaction( self, root_instance_id: str, - *, - native_transaction: Any | None = None, ) -> AbstractContextManager[ExecutionStoreTransaction]: """Open one root transaction.""" + def shared_transaction( + self, + root_instance_id: str, + ) -> AbstractContextManager[tuple[Any, ExecutionStoreTransaction]]: + """Open one host-owned native transaction for application composition.""" + del root_instance_id + raise ExecutionStoreError("adapter_capability_mismatch") + @abstractmethod def setup_schema(self) -> None: """Explicitly create the adapter's storage schema.""" + def validate_schema(self) -> None: + """Validate the configured adapter schema before durable host use.""" + return None + @abstractmethod def health(self) -> Mapping[str, Any]: """Return adapter health without mutating checkpoint storage.""" -def checkpoint_metadata(source: bytes) -> tuple[str, str]: +def checkpoint_metadata(source: bytes) -> tuple[str, str, str]: """Extract CAS metadata from structurally closed checkpoint bytes.""" try: document, _ = strict_json(source) + root_instance_id = document["root_instance_id"] revision = document["revision"] digest = document["execution_checkpoint_digest"] except (ArtifactError, KeyError, TypeError) as exc: raise ExecutionStoreError("invalid_execution_checkpoint") from exc - if not isinstance(revision, str) or not isinstance(digest, str): + if not all( + isinstance(value, str) + for value in (root_instance_id, revision, digest) + ): raise ExecutionStoreError("invalid_execution_checkpoint") - return revision, digest + return root_instance_id, revision, digest diff --git a/src/determa/state/stores/file.py b/src/determa/state/stores/file.py index 0c02b78..0f4270c 100644 --- a/src/determa/state/stores/file.py +++ b/src/determa/state/stores/file.py @@ -23,7 +23,8 @@ class _FileTransaction(ExecutionStoreTransaction): - def __init__(self, checkpoint_path: Path) -> None: + def __init__(self, root_instance_id: str, checkpoint_path: Path) -> None: + self._root_instance_id = root_instance_id self._checkpoint_path = checkpoint_path self._current: bytes | None try: @@ -32,10 +33,17 @@ def __init__(self, checkpoint_path: Path) -> None: self._current = None self._candidate: bytes | None = self._current + @property + def root_instance_id(self) -> str: + return self._root_instance_id + def load(self) -> bytes | None: return self._current def insert(self, checkpoint: bytes) -> bool: + root_instance_id, _, _ = checkpoint_metadata(checkpoint) + if root_instance_id != self._root_instance_id: + raise ExecutionStoreError("transaction_root_mismatch") if self._current is not None: return False self._candidate = bytes(checkpoint) @@ -49,7 +57,14 @@ def replace( ) -> bool: if self._current is None: return False - if checkpoint_metadata(self._current) != ( + root_instance_id, revision, digest = checkpoint_metadata(self._current) + candidate_root, _, _ = checkpoint_metadata(checkpoint) + if ( + root_instance_id != self._root_instance_id + or candidate_root != self._root_instance_id + ): + raise ExecutionStoreError("transaction_root_mismatch") + if (revision, digest) != ( expected_revision, expected_checkpoint_digest, ): @@ -106,11 +121,7 @@ def _stem(self, root_instance_id: str) -> str: def transaction( self, root_instance_id: str, - *, - native_transaction: Any | None = None, ) -> Iterator[ExecutionStoreTransaction]: - if native_transaction is not None: - raise ValueError("file does not accept a native transaction") self._require_schema() import fcntl @@ -121,7 +132,7 @@ def transaction( with lock_path.open("a+b") as lock: fcntl.flock(lock.fileno(), fcntl.LOCK_EX) try: - transaction = _FileTransaction(checkpoint_path) + transaction = _FileTransaction(root_instance_id, checkpoint_path) yield transaction transaction.commit() finally: diff --git a/src/determa/state/stores/memory.py b/src/determa/state/stores/memory.py index 94ee427..fc9176f 100644 --- a/src/determa/state/stores/memory.py +++ b/src/determa/state/stores/memory.py @@ -7,7 +7,13 @@ from contextlib import contextmanager from typing import Any -from .base import EPHEMERAL, ExecutionStore, ExecutionStoreTransaction, checkpoint_metadata +from .base import ( + EPHEMERAL, + ExecutionStore, + ExecutionStoreError, + ExecutionStoreTransaction, + checkpoint_metadata, +) class _MemoryTransaction(ExecutionStoreTransaction): @@ -19,10 +25,17 @@ def __init__( self._current = records.get(root_instance_id) self._candidate = self._current + @property + def root_instance_id(self) -> str: + return self._root_instance_id + def load(self) -> bytes | None: return self._current def insert(self, checkpoint: bytes) -> bool: + root_instance_id, _, _ = checkpoint_metadata(checkpoint) + if root_instance_id != self._root_instance_id: + raise ExecutionStoreError("transaction_root_mismatch") if self._current is not None: return False self._candidate = bytes(checkpoint) @@ -36,7 +49,14 @@ def replace( ) -> bool: if self._current is None: return False - if checkpoint_metadata(self._current) != ( + root_instance_id, revision, digest = checkpoint_metadata(self._current) + candidate_root, _, _ = checkpoint_metadata(checkpoint) + if ( + root_instance_id != self._root_instance_id + or candidate_root != self._root_instance_id + ): + raise ExecutionStoreError("transaction_root_mismatch") + if (revision, digest) != ( expected_revision, expected_checkpoint_digest, ): @@ -68,11 +88,7 @@ def capabilities(self) -> frozenset[str]: def transaction( self, root_instance_id: str, - *, - native_transaction: Any | None = None, ) -> Iterator[ExecutionStoreTransaction]: - if native_transaction is not None: - raise ValueError("memory does not accept a native transaction") with self._lock: transaction = _MemoryTransaction(self._records, root_instance_id) yield transaction diff --git a/src/determa/state/stores/postgresql.py b/src/determa/state/stores/postgresql.py index bc8b6e6..31b0663 100644 --- a/src/determa/state/stores/postgresql.py +++ b/src/determa/state/stores/postgresql.py @@ -10,7 +10,10 @@ from urllib.parse import urlsplit from .base import ( + COMPACT_EFFECT_IDENTITY_RETENTION, DURABLE_CONCURRENT, + PERMANENT_OUTBOX_TERMINAL_RETENTION, + PERMANENT_RECEIPT_RETENTION, ROOT_IDENTITY_RETENTION, SHARED_APPLICATION_TRANSACTION, ExecutionStore, @@ -20,6 +23,10 @@ ) _IDENTIFIER = re.compile(r"[a-z_][a-z0-9_]*\Z") +_REPLAY_RETENTION_MODES = {"bounded", "permanent"} +_OUTBOX_RETENTION_MODES = {"none", "strict", "compact"} +_SCHEMA_KEY = "execution_checkpoint" +_SCHEMA_VERSION = 1 def _psycopg() -> Any: @@ -29,6 +36,10 @@ def _psycopg() -> Any: raise ExecutionStoreError("optional_dependency_unavailable") from exc +def _database_value(value: Any) -> Any: + return value.decode("ascii") if isinstance(value, bytes) else value + + class _PostgreSQLTransaction(ExecutionStoreTransaction): def __init__( self, connection: Any, table_name: str, root_instance_id: str @@ -37,6 +48,10 @@ def __init__( self._table_name = table_name self._root_instance_id = root_instance_id + @property + def root_instance_id(self) -> str: + return self._root_instance_id + def load(self) -> bytes | None: row = self._connection.execute( f""" @@ -50,7 +65,9 @@ def load(self) -> bytes | None: return None if row is None else bytes(row[0]) def insert(self, checkpoint: bytes) -> bool: - revision, digest = checkpoint_metadata(checkpoint) + root_instance_id, revision, digest = checkpoint_metadata(checkpoint) + if root_instance_id != self._root_instance_id: + raise ExecutionStoreError("transaction_root_mismatch") cursor = self._connection.execute( f""" INSERT INTO {self._table_name} @@ -68,7 +85,9 @@ def replace( expected_checkpoint_digest: str, checkpoint: bytes, ) -> bool: - revision, digest = checkpoint_metadata(checkpoint) + root_instance_id, revision, digest = checkpoint_metadata(checkpoint) + if root_instance_id != self._root_instance_id: + raise ExecutionStoreError("transaction_root_mismatch") cursor = self._connection.execute( f""" UPDATE {self._table_name} @@ -90,87 +109,231 @@ def replace( class PostgreSQLExecutionStore(ExecutionStore): - """Concurrent CAS storage with optional native transaction reuse.""" + """Concurrent CAS storage with host-owned shared transactions.""" def __init__( self, conninfo: str, *, table_name: str = "determa_execution_checkpoints", + replay_retention: str = "bounded", + outbox_retention: str = "none", ) -> None: - if not conninfo or _IDENTIFIER.fullmatch(table_name) is None: + metadata_table = f"{table_name}_metadata" + if ( + not conninfo + or len(metadata_table) > 63 + or _IDENTIFIER.fullmatch(table_name) is None + or replay_retention not in _REPLAY_RETENTION_MODES + or outbox_retention not in _OUTBOX_RETENTION_MODES + ): raise ExecutionStoreError("invalid_adapter_configuration") self.conninfo = conninfo self.table_name = table_name + self.metadata_table = metadata_table + self.replay_retention = replay_retention + self.outbox_retention = outbox_retention @property def capabilities(self) -> frozenset[str]: - return frozenset( - { - DURABLE_CONCURRENT, - SHARED_APPLICATION_TRANSACTION, - ROOT_IDENTITY_RETENTION, - } + capabilities = { + DURABLE_CONCURRENT, + SHARED_APPLICATION_TRANSACTION, + ROOT_IDENTITY_RETENTION, + } + if self.replay_retention == "permanent": + capabilities.add(PERMANENT_RECEIPT_RETENTION) + if self.outbox_retention == "strict": + capabilities.add(PERMANENT_OUTBOX_TERMINAL_RETENTION) + elif self.outbox_retention == "compact": + capabilities.add(COMPACT_EFFECT_IDENTITY_RETENTION) + return frozenset(capabilities) + + @property + def checkpoint_retention_mode(self) -> str: + return self.replay_retention + + def _relation_state(self, connection: Any) -> tuple[Any, Any]: + row = connection.execute( + "SELECT to_regclass(%s), to_regclass(%s)", + (self.table_name, self.metadata_table), + ).fetchone() + assert row is not None + return row[0], row[1] + + def _validate_table( + self, + connection: Any, + table_name: str, + expected_columns: list[tuple[str, str, str, Any]], + ) -> None: + columns = connection.execute( + """ + SELECT column_name, data_type, is_nullable, column_default + FROM information_schema.columns + WHERE table_schema = current_schema() AND table_name = %s + ORDER BY ordinal_position + """, + (table_name,), + ).fetchall() + normalized_columns = [ + tuple(_database_value(value) for value in row) for row in columns + ] + if normalized_columns != expected_columns: + raise ExecutionStoreError("execution_store_schema_mismatch") + primary_key = connection.execute( + """ + SELECT attribute.attname + FROM pg_constraint AS con + JOIN unnest(con.conkey) WITH ORDINALITY AS keys(attnum, ordinal) + ON TRUE + JOIN pg_attribute AS attribute + ON attribute.attrelid = con.conrelid + AND attribute.attnum = keys.attnum + WHERE con.conrelid = to_regclass(%s) + AND con.contype = 'p' + ORDER BY keys.ordinal + """, + (table_name,), + ).fetchall() + if [_database_value(row[0]) for row in primary_key] != [ + "root_instance_id" if table_name == self.table_name else "schema_key" + ]: + raise ExecutionStoreError("execution_store_schema_mismatch") + constraint_types = connection.execute( + """ + SELECT contype + FROM pg_constraint + WHERE conrelid = to_regclass(%s) + ORDER BY contype + """, + (table_name,), + ).fetchall() + index_count = connection.execute( + "SELECT count(*) FROM pg_index WHERE indrelid = to_regclass(%s)", + (table_name,), + ).fetchone() + trigger_count = connection.execute( + """ + SELECT count(*) + FROM pg_trigger + WHERE tgrelid = to_regclass(%s) AND NOT tgisinternal + """, + (table_name,), + ).fetchone() + if ( + [_database_value(row[0]) for row in constraint_types] != ["p"] + or index_count is None + or index_count[0] != 1 + or trigger_count is None + or trigger_count[0] != 0 + ): + raise ExecutionStoreError("execution_store_schema_mismatch") + + def _validate_schema(self, connection: Any) -> None: + checkpoint_relation, metadata_relation = self._relation_state(connection) + if checkpoint_relation is None and metadata_relation is None: + raise ExecutionStoreError("execution_store_schema_unavailable") + if checkpoint_relation is None or metadata_relation is None: + raise ExecutionStoreError("execution_store_schema_mismatch") + self._validate_table( + connection, + self.table_name, + [ + ("root_instance_id", "text", "NO", None), + ("revision", "text", "NO", None), + ("checkpoint_digest", "text", "NO", None), + ("checkpoint", "bytea", "NO", None), + ], + ) + self._validate_table( + connection, + self.metadata_table, + [ + ("schema_key", "text", "NO", None), + ("schema_version", "integer", "NO", None), + ], ) + rows = connection.execute( + f"SELECT schema_key, schema_version FROM {self.metadata_table}" + ).fetchall() + normalized_rows = [ + tuple(_database_value(value) for value in row) for row in rows + ] + if normalized_rows != [(_SCHEMA_KEY, _SCHEMA_VERSION)]: + raise ExecutionStoreError("execution_store_schema_mismatch") + + def validate_schema(self) -> None: + psycopg = _psycopg() + with psycopg.connect(self.conninfo) as connection: + self._validate_schema(connection) @contextmanager def transaction( self, root_instance_id: str, - *, - native_transaction: Any | None = None, ) -> Iterator[ExecutionStoreTransaction]: psycopg = _psycopg() - owns_connection = native_transaction is None - connection: Any = native_transaction - if owns_connection: - connection = psycopg.connect(self.conninfo) - if not owns_connection and ( - connection.info.transaction_status == psycopg.pq.TransactionStatus.IDLE - ): - raise ExecutionStoreError("invalid_adapter_configuration") - try: - if owns_connection: - connection.execute("BEGIN ISOLATION LEVEL READ COMMITTED") + with psycopg.connect(self.conninfo) as connection: + self._validate_schema(connection) yield _PostgreSQLTransaction( connection, self.table_name, root_instance_id ) - if owns_connection: - connection.commit() - except BaseException: - if owns_connection: - connection.rollback() - raise - finally: - if owns_connection: - connection.close() + + @contextmanager + def shared_transaction( + self, + root_instance_id: str, + ) -> Iterator[tuple[Any, ExecutionStoreTransaction]]: + psycopg = _psycopg() + with psycopg.connect(self.conninfo) as connection: + self._validate_schema(connection) + yield ( + connection, + _PostgreSQLTransaction( + connection, self.table_name, root_instance_id + ), + ) def setup_schema(self) -> None: psycopg = _psycopg() - with psycopg.connect(self.conninfo, autocommit=True) as connection: - connection.execute( - f""" - CREATE TABLE IF NOT EXISTS {self.table_name} ( - root_instance_id TEXT PRIMARY KEY, - revision TEXT NOT NULL, - checkpoint_digest TEXT NOT NULL, - checkpoint BYTEA NOT NULL + with psycopg.connect(self.conninfo) as connection: + checkpoint_relation, metadata_relation = self._relation_state(connection) + if checkpoint_relation is None and metadata_relation is None: + connection.execute( + f""" + CREATE TABLE {self.metadata_table} ( + schema_key TEXT PRIMARY KEY NOT NULL, + schema_version INTEGER NOT NULL + ) + """ ) - """ - ) + connection.execute( + f""" + CREATE TABLE {self.table_name} ( + root_instance_id TEXT PRIMARY KEY NOT NULL, + revision TEXT NOT NULL, + checkpoint_digest TEXT NOT NULL, + checkpoint BYTEA NOT NULL + ) + """ + ) + connection.execute( + f""" + INSERT INTO {self.metadata_table} + (schema_key, schema_version) + VALUES (%s, %s) + """, + (_SCHEMA_KEY, _SCHEMA_VERSION), + ) + self._validate_schema(connection) def health(self) -> Mapping[str, Any]: try: - psycopg = _psycopg() - with psycopg.connect(self.conninfo) as connection: - row = connection.execute( - "SELECT to_regclass(%s)", - (self.table_name,), - ).fetchone() + self.validate_schema() except Exception: return {"healthy": False, "schema_ready": False} - ready = row is not None and row[0] is not None - return {"healthy": ready, "schema_ready": ready} + return {"healthy": True, "schema_ready": True, "schema_version": 1} def postgresql_execution_store_factory( @@ -180,11 +343,25 @@ def postgresql_execution_store_factory( parsed = urlsplit(uri) if parsed.scheme != "postgresql" or parsed.fragment: raise ExecutionStoreError("invalid_adapter_configuration") - if set(configuration) - {"table_name"}: + if set(configuration) - { + "table_name", + "replay_retention", + "outbox_retention", + }: raise ExecutionStoreError("invalid_adapter_configuration") table_name = configuration.get( "table_name", "determa_execution_checkpoints" ) - if not isinstance(table_name, str): + replay_retention = configuration.get("replay_retention", "bounded") + outbox_retention = configuration.get("outbox_retention", "none") + if not all( + isinstance(value, str) + for value in (table_name, replay_retention, outbox_retention) + ): raise ExecutionStoreError("invalid_adapter_configuration") - return PostgreSQLExecutionStore(uri, table_name=table_name) + return PostgreSQLExecutionStore( + uri, + table_name=table_name, + replay_retention=replay_retention, + outbox_retention=outbox_retention, + ) diff --git a/src/determa/state/stores/sqlite.py b/src/determa/state/stores/sqlite.py index 0686da6..c003562 100644 --- a/src/determa/state/stores/sqlite.py +++ b/src/determa/state/stores/sqlite.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re import sqlite3 from collections.abc import Iterator, Mapping from contextlib import contextmanager @@ -10,7 +11,10 @@ from urllib.parse import parse_qs, unquote, urlsplit from .base import ( + COMPACT_EFFECT_IDENTITY_RETENTION, DURABLE_SINGLE_WRITER, + PERMANENT_OUTBOX_TERMINAL_RETENTION, + PERMANENT_RECEIPT_RETENTION, ROOT_IDENTITY_RETENTION, ExecutionStore, ExecutionStoreError, @@ -19,8 +23,17 @@ ) _TABLE = "determa_execution_checkpoints" +_METADATA_TABLE = "determa_execution_store_metadata" _JOURNAL_MODES = {"DELETE", "WAL"} _SYNCHRONOUS_MODES = {"FULL"} +_REPLAY_RETENTION_MODES = {"bounded", "permanent"} +_OUTBOX_RETENTION_MODES = {"none", "strict", "compact"} +_SCHEMA_KEY = "execution_checkpoint" +_SCHEMA_VERSION = 1 + + +def _schema_tokens(source: str) -> list[str]: + return re.findall(r"[a-z_][a-z0-9_]*|[(),]", source.lower()) class _SQLiteTransaction(ExecutionStoreTransaction): @@ -30,6 +43,10 @@ def __init__( self._connection = connection self._root_instance_id = root_instance_id + @property + def root_instance_id(self) -> str: + return self._root_instance_id + def load(self) -> bytes | None: row = self._connection.execute( f"SELECT checkpoint FROM {_TABLE} WHERE root_instance_id = ?", @@ -38,7 +55,9 @@ def load(self) -> bytes | None: return None if row is None else bytes(row[0]) def insert(self, checkpoint: bytes) -> bool: - revision, digest = checkpoint_metadata(checkpoint) + root_instance_id, revision, digest = checkpoint_metadata(checkpoint) + if root_instance_id != self._root_instance_id: + raise ExecutionStoreError("transaction_root_mismatch") try: self._connection.execute( f""" @@ -58,7 +77,9 @@ def replace( expected_checkpoint_digest: str, checkpoint: bytes, ) -> bool: - revision, digest = checkpoint_metadata(checkpoint) + root_instance_id, revision, digest = checkpoint_metadata(checkpoint) + if root_instance_id != self._root_instance_id: + raise ExecutionStoreError("transaction_root_mismatch") cursor = self._connection.execute( f""" UPDATE {_TABLE} @@ -89,23 +110,40 @@ def __init__( journal_mode: str = "WAL", synchronous: str = "FULL", timeout: float = 30.0, + replay_retention: str = "bounded", + outbox_retention: str = "none", ) -> None: self.path = str(path) self.journal_mode = journal_mode.upper() self.synchronous = synchronous.upper() self.timeout = timeout + self.replay_retention = replay_retention + self.outbox_retention = outbox_retention if ( not self.path or self.path == ":memory:" or self.journal_mode not in _JOURNAL_MODES or self.synchronous not in _SYNCHRONOUS_MODES or timeout <= 0 + or replay_retention not in _REPLAY_RETENTION_MODES + or outbox_retention not in _OUTBOX_RETENTION_MODES ): raise ExecutionStoreError("invalid_adapter_configuration") @property def capabilities(self) -> frozenset[str]: - return frozenset({DURABLE_SINGLE_WRITER, ROOT_IDENTITY_RETENTION}) + capabilities = {DURABLE_SINGLE_WRITER, ROOT_IDENTITY_RETENTION} + if self.replay_retention == "permanent": + capabilities.add(PERMANENT_RECEIPT_RETENTION) + if self.outbox_retention == "strict": + capabilities.add(PERMANENT_OUTBOX_TERMINAL_RETENTION) + elif self.outbox_retention == "compact": + capabilities.add(COMPACT_EFFECT_IDENTITY_RETENTION) + return frozenset(capabilities) + + @property + def checkpoint_retention_mode(self) -> str: + return self.replay_retention def _connect(self) -> sqlite3.Connection: connection = sqlite3.connect( @@ -127,27 +165,116 @@ def _connect(self) -> sqlite3.Connection: raise ExecutionStoreError("invalid_adapter_configuration") return connection + def _validate_table( + self, + connection: sqlite3.Connection, + table: str, + expected_columns: list[tuple[str, str, int, Any, int, int]], + expected_sql: str, + ) -> None: + columns = [ + (row[1], str(row[2]).upper(), row[3], row[4], row[5], row[6]) + for row in connection.execute(f"PRAGMA table_xinfo({table})") + ] + if columns != expected_columns: + raise ExecutionStoreError("execution_store_schema_mismatch") + schema_row = connection.execute( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?", + (table,), + ).fetchone() + if ( + schema_row is None + or not isinstance(schema_row[0], str) + or _schema_tokens(schema_row[0]) != _schema_tokens(expected_sql) + ): + raise ExecutionStoreError("execution_store_schema_mismatch") + indexes = [ + (row[2], row[3], row[4]) + for row in connection.execute(f"PRAGMA index_list({table})") + ] + if indexes != [(1, "pk", 0)]: + raise ExecutionStoreError("execution_store_schema_mismatch") + foreign_keys = connection.execute( + f"PRAGMA foreign_key_list({table})" + ).fetchall() + triggers = connection.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'trigger' AND tbl_name = ?", + (table,), + ).fetchone() + if foreign_keys or triggers is not None: + raise ExecutionStoreError("execution_store_schema_mismatch") + + def _validate_schema(self, connection: sqlite3.Connection) -> None: + tables = { + row[0] + for row in connection.execute( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (?, ?)", + (_TABLE, _METADATA_TABLE), + ) + } + if not tables: + raise ExecutionStoreError("execution_store_schema_unavailable") + if tables != {_TABLE, _METADATA_TABLE}: + raise ExecutionStoreError("execution_store_schema_mismatch") + self._validate_table( + connection, + _TABLE, + [ + ("root_instance_id", "TEXT", 1, None, 1, 0), + ("revision", "TEXT", 1, None, 0, 0), + ("checkpoint_digest", "TEXT", 1, None, 0, 0), + ("checkpoint", "BLOB", 1, None, 0, 0), + ], + f""" + CREATE TABLE {_TABLE} ( + root_instance_id TEXT PRIMARY KEY NOT NULL, + revision TEXT NOT NULL, + checkpoint_digest TEXT NOT NULL, + checkpoint BLOB NOT NULL + ) + """, + ) + self._validate_table( + connection, + _METADATA_TABLE, + [ + ("schema_key", "TEXT", 1, None, 1, 0), + ("schema_version", "INTEGER", 1, None, 0, 0), + ], + f""" + CREATE TABLE {_METADATA_TABLE} ( + schema_key TEXT PRIMARY KEY NOT NULL, + schema_version INTEGER NOT NULL + ) + """, + ) + rows = connection.execute( + f"SELECT schema_key, schema_version FROM {_METADATA_TABLE}" + ).fetchall() + if rows != [(_SCHEMA_KEY, _SCHEMA_VERSION)]: + raise ExecutionStoreError("execution_store_schema_mismatch") + + def validate_schema(self) -> None: + connection = self._connect() + try: + self._validate_schema(connection) + finally: + connection.close() + @contextmanager def transaction( self, root_instance_id: str, - *, - native_transaction: Any | None = None, ) -> Iterator[ExecutionStoreTransaction]: - if native_transaction is not None: - raise ValueError("sqlite does not expose shared application transactions") connection = self._connect() try: + self._validate_schema(connection) connection.execute("BEGIN IMMEDIATE") transaction = _SQLiteTransaction(connection, root_instance_id) yield transaction connection.commit() - except sqlite3.OperationalError as exc: + except sqlite3.OperationalError: connection.rollback() - if "no such table" in str(exc): - raise ExecutionStoreError( - "execution_store_schema_unavailable" - ) from exc raise except BaseException: connection.rollback() @@ -159,16 +286,39 @@ def setup_schema(self) -> None: connection = self._connect() try: connection.execute("BEGIN IMMEDIATE") - connection.execute( - f""" - CREATE TABLE IF NOT EXISTS {_TABLE} ( - root_instance_id TEXT PRIMARY KEY NOT NULL, - revision TEXT NOT NULL, - checkpoint_digest TEXT NOT NULL, - checkpoint BLOB NOT NULL + tables = { + row[0] + for row in connection.execute( + "SELECT name FROM sqlite_master " + "WHERE type = 'table' AND name IN (?, ?)", + (_TABLE, _METADATA_TABLE), ) - """ - ) + } + if not tables: + connection.execute( + f""" + CREATE TABLE {_METADATA_TABLE} ( + schema_key TEXT PRIMARY KEY NOT NULL, + schema_version INTEGER NOT NULL + ) + """ + ) + connection.execute( + f""" + CREATE TABLE {_TABLE} ( + root_instance_id TEXT PRIMARY KEY NOT NULL, + revision TEXT NOT NULL, + checkpoint_digest TEXT NOT NULL, + checkpoint BLOB NOT NULL + ) + """ + ) + connection.execute( + f"INSERT INTO {_METADATA_TABLE} (schema_key, schema_version) " + "VALUES (?, ?)", + (_SCHEMA_KEY, _SCHEMA_VERSION), + ) + self._validate_schema(connection) connection.commit() except BaseException: connection.rollback() @@ -179,15 +329,13 @@ def setup_schema(self) -> None: def health(self) -> Mapping[str, Any]: try: connection = self._connect() - row = connection.execute( - "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", - (_TABLE,), - ).fetchone() - connection.close() + try: + self._validate_schema(connection) + finally: + connection.close() except (OSError, sqlite3.Error, ExecutionStoreError): return {"healthy": False, "schema_ready": False} - ready = row is not None - return {"healthy": ready, "schema_ready": ready} + return {"healthy": True, "schema_ready": True, "schema_version": 1} def _single_query(query: Mapping[str, list[str]], key: str, default: str) -> str: @@ -215,11 +363,19 @@ def sqlite_execution_store_factory( if not path or not Path(path).is_absolute(): raise ExecutionStoreError("invalid_adapter_configuration") query = parse_qs(parsed.query, keep_blank_values=True) - if set(query) - {"journal_mode", "synchronous", "timeout"}: + if set(query) - { + "journal_mode", + "synchronous", + "timeout", + "replay_retention", + "outbox_retention", + }: raise ExecutionStoreError("invalid_adapter_configuration") journal_mode = _single_query(query, "journal_mode", "WAL") synchronous = _single_query(query, "synchronous", "FULL") timeout_text = _single_query(query, "timeout", "30") + replay_retention = _single_query(query, "replay_retention", "bounded") + outbox_retention = _single_query(query, "outbox_retention", "none") try: timeout = float(timeout_text) except ValueError as exc: @@ -229,4 +385,6 @@ def sqlite_execution_store_factory( journal_mode=journal_mode, synchronous=synchronous, timeout=timeout, + replay_retention=replay_retention, + outbox_retention=outbox_retention, ) diff --git a/src/determa/state/wire.py b/src/determa/state/wire.py index 7d238b5..3b3a7f7 100644 --- a/src/determa/state/wire.py +++ b/src/determa/state/wire.py @@ -25,6 +25,7 @@ _DATA = Path(__file__).parent / "data" _INT_MIN = -(2**63) _INT_MAX = 2**63 - 1 +_MAX_DECIMAL_DIGITS = 4096 class DefinitionResolver(Protocol): @@ -291,9 +292,18 @@ def _signed_decimal(value: str) -> int: return 0 negative = value.startswith("-") digits = value[1:] if negative else value - if not digits or not digits.isascii() or not digits.isdigit() or digits.startswith("0"): + if ( + not digits + or len(digits) > _MAX_DECIMAL_DIGITS + or not digits.isascii() + or not digits.isdigit() + or digits.startswith("0") + ): raise ArtifactError("invalid_aggregate_state") - return int(value) + try: + return int(value) + except ValueError as exc: + raise ArtifactError("invalid_aggregate_state") from exc def decimal(value: Any, *, positive: bool = False) -> int: diff --git a/tests/test_checkpoint_host.py b/tests/test_checkpoint_host.py index 91f2587..3a1aea1 100644 --- a/tests/test_checkpoint_host.py +++ b/tests/test_checkpoint_host.py @@ -1,20 +1,27 @@ from __future__ import annotations import copy +from contextlib import contextmanager import pytest from determa.state import ( + EPHEMERAL, + SHARED_APPLICATION_TRANSACTION, ArtifactError, ExecutionHost, ExecutionHostError, MemoryArtifactResolver, MemoryExecutionStore, + StagedExecutionResult, + aggregate_shape_fingerprint, delivery_request_digest, load_bundle, portable_envelope, restore_execution_checkpoint, + seal_execution_checkpoint, ) +from determa.state.wire import migration_descriptor_digest MACHINE = """ format: 1 @@ -24,6 +31,7 @@ direction: input payload: amount: { type: int, required: true } + deliberate_fault: { direction: input } machines: - machine_id: counter version: 1 @@ -35,6 +43,22 @@ increment: action: - assign: { count: "count + event.payload.amount" } + deliberate_fault: + action: + - assign: { count: "count / 0" } +""" + +TERMINAL_MACHINE = """ +format: 1 +namespace: test.execution_checkpoint_terminal +machines: + - machine_id: terminal + version: 1 + root: + type: composite + initial: { transition_to: done } + states: + done: { type: final } """ @@ -116,23 +140,104 @@ def rollback(boundary: str) -> None: assert host.read_checkpoint("root") is None -def test_caller_owned_transaction_defers_after_commit_boundary() -> None: +class _SharedMemoryStore(MemoryExecutionStore): + def __init__(self, *, cross_root: bool = False) -> None: + super().__init__() + self.business_rows: list[str] = [] + self.cross_root = cross_root + + @property + def capabilities(self) -> frozenset[str]: + return frozenset({EPHEMERAL, SHARED_APPLICATION_TRANSACTION}) + + @contextmanager + def shared_transaction(self, root_instance_id: str): + transaction_root = "other-root" if self.cross_root else root_instance_id + pending_business_rows: list[str] = [] + with self.transaction(transaction_root) as transaction: + yield pending_business_rows, transaction + self.business_rows.extend(pending_business_rows) + + +def test_host_owned_shared_transaction_returns_only_after_commit() -> None: + store = _SharedMemoryStore() + host, _ = _host(store=store) + staged_results: list[StagedExecutionResult] = [] + + def callback(connection, execution) -> None: + connection.append("business-row") + staged = execution.create( + load_bundle(MACHINE), "counter", "create", {} + ) + assert not isinstance(staged, dict) + staged_results.append(staged) + + result = host.run_shared_transaction("root", callback) + + assert result["result"] == "committed" + assert staged_results == [StagedExecutionResult("create")] + assert store.business_rows == ["business-row"] + assert host.read_checkpoint("root") is not None + + +def test_host_owned_shared_transaction_rollback_returns_no_committed_result() -> None: + store = _SharedMemoryStore() + host, _ = _host(store=store) + + def callback(connection, execution) -> None: + connection.append("business-row") + execution.create(load_bundle(MACHINE), "counter", "create", {}) + raise RuntimeError("application rollback") + + with pytest.raises(RuntimeError, match="application rollback"): + host.run_shared_transaction("root", callback) + + assert store.business_rows == [] + assert host.read_checkpoint("root") is None + + +def test_shared_transaction_response_loss_occurs_only_after_outer_commit() -> None: def response_loss(boundary: str) -> None: if boundary == "after_commit_before_response": raise ExecutionHostError("response_lost_after_commit") - host, store = _host(fault_injector=response_loss) - with store.transaction("root") as transaction: - result = host.create( - load_bundle(MACHINE), - "counter", - "root", - "create", - {}, - store_transaction=transaction, - ) + store = _SharedMemoryStore() + host, _ = _host(store=store, fault_injector=response_loss) + + def callback(connection, execution) -> None: + connection.append("business-row") + execution.create(load_bundle(MACHINE), "counter", "create", {}) + + with pytest.raises(ExecutionHostError, match="response_lost_after_commit"): + host.run_shared_transaction("root", callback) + + replay, _ = _host(store=store) + result = replay.create( + load_bundle(MACHINE), "counter", "root", "create", {} + ) assert result["result"] == "committed" - assert host.read_checkpoint("root") is not None + assert store.business_rows == ["business-row"] + + +def test_shared_transaction_rejects_a_store_transaction_bound_to_another_root() -> None: + host, _ = _host(store=_SharedMemoryStore(cross_root=True)) + with pytest.raises(ExecutionHostError) as error: + host.run_shared_transaction("root", lambda _connection, _execution: None) + assert error.value.code == "transaction_root_mismatch" + + +def test_shared_transaction_accepts_exactly_one_host_operation() -> None: + store = _SharedMemoryStore() + host, _ = _host(store=store) + + def callback(_connection, execution) -> None: + execution.create(load_bundle(MACHINE), "counter", "create", {}) + execution.create(load_bundle(MACHINE), "counter", "create", {}) + + with pytest.raises(ExecutionHostError) as error: + host.run_shared_transaction("root", callback) + assert error.value.code == "shared_transaction_operation_conflict" + assert host.read_checkpoint("root") is None def test_accept_process_and_replay_use_durable_host_receipts() -> None: @@ -166,6 +271,80 @@ def test_accept_process_and_replay_use_durable_host_receipts() -> None: assert replay == committed +def test_delivery_replay_precedes_origin_and_tombstone_validation() -> None: + host, _ = _host() + created = _created(host) + aggregate = created["root_record"]["aggregate_state"] + envelope = portable_envelope( + "deliberate_fault", + "fault-replay", + { + "root": { + "root_instance_id": "root", + "root_runtime_id": aggregate["root_runtime_id"], + } + }, + {}, + ) + candidate = { + "root_instance_id": "root", + "delivery_mode": "input", + "origin": {"kind": "host_input"}, + "envelope": envelope, + } + committed = host.foreground_process_delivery( + "root", + candidate, + expected_revision=created["revision"], + expected_checkpoint_digest=created["execution_checkpoint_digest"], + ) + faulted = host.read_checkpoint("root") + assert faulted is not None + host.tombstone_root( + "root", + "tombstone", + expected_revision=faulted.document["revision"], + expected_checkpoint_digest=faulted.document[ + "execution_checkpoint_digest" + ], + ) + invalid_origin = copy.deepcopy(candidate) + invalid_origin["origin"] = {"kind": "invalid"} + replay = host.accept_delivery( + "root", + invalid_origin, + expected_revision=created["revision"], + expected_checkpoint_digest=created["execution_checkpoint_digest"], + ) + assert replay == committed + + invalid_mode = copy.deepcopy(candidate) + invalid_mode["delivery_mode"] = "invalid" + mode_conflict = host.accept_delivery( + "root", + invalid_mode, + expected_revision=created["revision"], + expected_checkpoint_digest=created["execution_checkpoint_digest"], + ) + assert mode_conflict == { + "result": "not_accepted", + "failure": {"code": "event_id_conflict"}, + } + + conflicting = copy.deepcopy(invalid_origin) + conflicting["envelope"]["event"] = "increment" + conflict = host.accept_delivery( + "root", + conflicting, + expected_revision=created["revision"], + expected_checkpoint_digest=created["execution_checkpoint_digest"], + ) + assert conflict == { + "result": "not_accepted", + "failure": {"code": "event_id_conflict"}, + } + + def test_checkpoint_digest_mismatch_is_classified_after_structure() -> None: host, _ = _host() checkpoint = _created(host) @@ -228,3 +407,285 @@ def test_checkpoint_restore_does_not_mutate_caller_document() -> None: original = copy.deepcopy(checkpoint) restore_execution_checkpoint(checkpoint, host.artifact_resolver) assert checkpoint == original + + +def test_restore_rejects_creation_status_inconsistent_with_aggregate() -> None: + host, _ = _host() + checkpoint = _created(host) + checkpoint["operation_receipts"][0]["status"] = "completed" + mutated = seal_execution_checkpoint(checkpoint) + with pytest.raises(ArtifactError) as error: + restore_execution_checkpoint(mutated, host.artifact_resolver) + assert error.value.code == "invalid_execution_checkpoint" + + +def test_restore_rejects_delivery_fault_inconsistent_with_aggregate() -> None: + host, _ = _host() + created = _created(host) + aggregate = created["root_record"]["aggregate_state"] + envelope = portable_envelope( + "deliberate_fault", + "fault-1", + { + "root": { + "root_instance_id": "root", + "root_runtime_id": aggregate["root_runtime_id"], + } + }, + {}, + ) + host.foreground_process_delivery( + "root", + { + "root_instance_id": "root", + "delivery_mode": "input", + "origin": {"kind": "host_input"}, + "envelope": envelope, + }, + expected_revision=created["revision"], + expected_checkpoint_digest=created["execution_checkpoint_digest"], + ) + restored = host.read_checkpoint("root") + assert restored is not None + checkpoint = restored.document + checkpoint["operation_receipts"][-1]["outcome"]["fault"]["code"] = ( + "different_fault" + ) + mutated = seal_execution_checkpoint(checkpoint) + with pytest.raises(ArtifactError) as error: + restore_execution_checkpoint(mutated, host.artifact_resolver) + assert error.value.code == "invalid_execution_checkpoint" + + +def test_restore_rejects_completed_tombstone_relabeled_faulted() -> None: + bundle = load_bundle(TERMINAL_MACHINE) + resolver = MemoryArtifactResolver(definitions={bundle.fingerprint: bundle}) + host = ExecutionHost(MemoryExecutionStore(), resolver) + host.create(bundle, "terminal", "terminal-root", "create", {}) + completed = host.read_checkpoint("terminal-root") + assert completed is not None + host.tombstone_root( + "terminal-root", + "tombstone", + expected_revision=completed.document["revision"], + expected_checkpoint_digest=completed.document[ + "execution_checkpoint_digest" + ], + ) + restored = host.read_checkpoint("terminal-root") + assert restored is not None + checkpoint = restored.document + checkpoint["root_record"]["terminal_status"] = "faulted" + mutated = seal_execution_checkpoint(checkpoint) + with pytest.raises(ArtifactError) as error: + restore_execution_checkpoint(mutated, resolver) + assert error.value.code == "invalid_execution_checkpoint" + + +def test_bounded_pruning_through_latest_receipt_remains_valid() -> None: + host, _ = _host() + created = _created(host) + host.foreground_process_delivery( + "root", + _candidate(created), + expected_revision=created["revision"], + expected_checkpoint_digest=created["execution_checkpoint_digest"], + ) + processed = host.read_checkpoint("root") + assert processed is not None + result = host.update_replay_retention( + "root", + { + "mode": "bounded", + "permanent_replay_eligible": False, + "pruned_through_receipt_sequence": "1", + "policy_identifier": "bounded-test", + }, + expected_revision=processed.document["revision"], + expected_checkpoint_digest=processed.document[ + "execution_checkpoint_digest" + ], + ) + assert result["result"] == "committed" + bounded = host.read_checkpoint("root") + assert bounded is not None + assert [ + receipt["receipt_sequence"] + for receipt in bounded.document["operation_receipts"] + ] == ["0"] + + +def test_maintenance_replay_precedes_tombstone_eligibility() -> None: + bundle = load_bundle(TERMINAL_MACHINE) + resolver = MemoryArtifactResolver(definitions={bundle.fingerprint: bundle}) + host = ExecutionHost(MemoryExecutionStore(), resolver) + host.create(bundle, "terminal", "terminal-root", "create", {}) + created = host.read_checkpoint("terminal-root") + assert created is not None + source_digest = created.document["root_record"]["aggregate_state"][ + "aggregate_state_digest" + ] + committed = host.maintenance_migration( + "terminal-root", + "migration", + bundle.fingerprint, + [], + source_aggregate_state_digest=source_digest, + expected_revision=created.document["revision"], + expected_checkpoint_digest=created.document[ + "execution_checkpoint_digest" + ], + ) + migrated = host.read_checkpoint("terminal-root") + assert migrated is not None + invalid_receipt = copy.deepcopy(migrated.document) + invalid_receipt["operation_receipts"][-1][ + "resulting_aggregate_state_digest" + ] = "sha256:" + ("0" * 64) + with pytest.raises(ArtifactError) as invalid_error: + restore_execution_checkpoint( + seal_execution_checkpoint(invalid_receipt), resolver + ) + assert invalid_error.value.code == "invalid_execution_checkpoint" + host.tombstone_root( + "terminal-root", + "tombstone", + expected_revision=migrated.document["revision"], + expected_checkpoint_digest=migrated.document[ + "execution_checkpoint_digest" + ], + ) + replay = host.maintenance_migration( + "terminal-root", + "migration", + bundle.fingerprint, + [], + source_aggregate_state_digest=source_digest, + expected_revision=created.document["revision"], + expected_checkpoint_digest=created.document[ + "execution_checkpoint_digest" + ], + ) + assert replay == committed + with pytest.raises(ExecutionHostError) as conflict: + host.maintenance_migration( + "terminal-root", + "migration", + bundle.fingerprint, + [], + source_aggregate_state_digest=source_digest, + expected_revision=created.document["revision"], + expected_checkpoint_digest=created.document[ + "execution_checkpoint_digest" + ], + maintenance_mode=False, + ) + assert conflict.value.code == "operation_id_conflict" + + +def test_restore_checks_status_evidence_across_maintenance_migration() -> None: + source = load_bundle(MACHINE) + target = load_bundle(MACHINE.replace( + "namespace: test.execution_checkpoint", + "namespace: test.execution_checkpoint\nmeta: {release: target}", + )) + shape = aggregate_shape_fingerprint(source) + assert aggregate_shape_fingerprint(target) == shape + descriptor = { + "migration_descriptor_format": "determa.aggregate_migration", + "migration_descriptor_schema_version": 1, + "source_machine_format": 1, + "target_machine_format": 1, + "source_validated_bundle_fingerprint": source.fingerprint, + "target_validated_bundle_fingerprint": target.fingerprint, + "source_aggregate_shape_fingerprint": shape, + "target_aggregate_shape_fingerprint": shape, + "mode": "compatible", + "mappings": { + "machines": [], + "active_states": [], + "variables": [], + "history": [], + "components": [], + "owned_runtimes": [], + "lifetime_holders": [], + "counters": [], + }, + "terminal_policy": {"completed": "preserve", "faulted": "preserve"}, + "resource_requirements": { + "maximum_transformed_output_bytes": "0", + "maximum_cel_expression_length": "0", + "maximum_cel_ast_nodes": "0", + "maximum_cel_evaluation_steps": "0", + }, + } + descriptor["migration_descriptor_digest"] = migration_descriptor_digest( + descriptor + ) + resolver = MemoryArtifactResolver( + definitions={ + source.fingerprint: source, + target.fingerprint: target, + }, + migration_descriptors={ + descriptor["migration_descriptor_digest"]: descriptor, + }, + ) + host = ExecutionHost(MemoryExecutionStore(), resolver) + host.create(source, "counter", "migration-root", "create", {}) + created = host.read_checkpoint("migration-root") + assert created is not None + source_digest = created.document["root_record"]["aggregate_state"][ + "aggregate_state_digest" + ] + host.maintenance_migration( + "migration-root", + "migration", + target.fingerprint, + [descriptor["migration_descriptor_digest"]], + source_aggregate_state_digest=source_digest, + expected_revision=created.document["revision"], + expected_checkpoint_digest=created.document[ + "execution_checkpoint_digest" + ], + ) + migrated = host.read_checkpoint("migration-root") + assert migrated is not None + checkpoint = migrated.document + assert ( + checkpoint["operation_receipts"][0]["resulting_aggregate_state_digest"] + != checkpoint["root_record"]["aggregate_state"]["aggregate_state_digest"] + ) + checkpoint["operation_receipts"][0]["status"] = "completed" + with pytest.raises(ArtifactError) as error: + restore_execution_checkpoint( + seal_execution_checkpoint(checkpoint), resolver + ) + assert error.value.code == "invalid_execution_checkpoint" + + +def test_maintenance_request_requires_exact_source_digest() -> None: + host, _ = _host() + created = _created(host) + bundle = load_bundle(MACHINE) + with pytest.raises(TypeError): + host.maintenance_migration( + "root", + "migration", + bundle.fingerprint, + [], + expected_revision=created["revision"], + expected_checkpoint_digest=created[ + "execution_checkpoint_digest" + ], + ) + + +def test_oversized_checkpoint_decimal_is_closed_invalidity() -> None: + host, _ = _host() + checkpoint = _created(host) + checkpoint["revision"] = "9" * 5000 + mutated = seal_execution_checkpoint(checkpoint) + with pytest.raises(ArtifactError) as error: + restore_execution_checkpoint(mutated, host.artifact_resolver) + assert error.value.code == "invalid_execution_checkpoint" diff --git a/tests/test_execution_stores.py b/tests/test_execution_stores.py index 69abaf5..34aea32 100644 --- a/tests/test_execution_stores.py +++ b/tests/test_execution_stores.py @@ -1,5 +1,6 @@ from __future__ import annotations +import sqlite3 from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor from pathlib import Path @@ -7,9 +8,13 @@ import pytest from determa.state import ( + COMPACT_EFFECT_IDENTITY_RETENTION, DURABLE_SINGLE_WRITER, EPHEMERAL, + PERMANENT_OUTBOX_TERMINAL_RETENTION, + PERMANENT_RECEIPT_RETENTION, RESTART_PERSISTENT, + ROOT_IDENTITY_RETENTION, ExecutionHost, ExecutionHostError, ExecutionStore, @@ -50,6 +55,8 @@ def _factories(tmp_path: Path) -> list[Callable[[], ExecutionStore]]: def test_shared_adapter_contract_round_trip(tmp_path: Path, index: int) -> None: store = _factories(tmp_path)[index]() store.setup_schema() + with store.transaction("bound-root") as transaction: + assert transaction.root_instance_id == "bound-root" host = _create(store) restored = host.read_checkpoint("root") assert restored is not None @@ -59,6 +66,47 @@ def test_shared_adapter_contract_round_trip(tmp_path: Path, index: int) -> None: assert replay["receipt"]["receipt_sequence"] == "0" +@pytest.mark.parametrize("index", range(3)) +def test_store_transactions_reject_checkpoint_bytes_for_another_root( + tmp_path: Path, index: int +) -> None: + store = _factories(tmp_path)[index]() + store.setup_schema() + host = _create(store) + checkpoint = host.read_checkpoint("root") + assert checkpoint is not None + with pytest.raises(ExecutionStoreError) as error: + with store.transaction("other-root") as transaction: + transaction.insert(checkpoint.canonical_bytes) + assert error.value.code == "transaction_root_mismatch" + + _create(store, "other-root") + other = ExecutionHost(store, _resolver()).read_checkpoint("other-root") + assert other is not None + with pytest.raises(ExecutionStoreError) as replace_error: + with store.transaction("root") as transaction: + transaction.replace( + checkpoint.document["revision"], + checkpoint.document["execution_checkpoint_digest"], + other.canonical_bytes, + ) + assert replace_error.value.code == "transaction_root_mismatch" + + +def test_host_rejects_checkpoint_loaded_under_another_root_key() -> None: + source_store = MemoryExecutionStore() + source_host = _create(source_store) + checkpoint = source_host.read_checkpoint("root") + assert checkpoint is not None + mismatched_store = MemoryExecutionStore( + {"other-root": checkpoint.canonical_bytes} + ) + mismatched_host = ExecutionHost(mismatched_store, _resolver()) + with pytest.raises(ExecutionHostError) as error: + mismatched_host.read_checkpoint("other-root") + assert error.value.code == "transaction_root_mismatch" + + @pytest.mark.parametrize( "store_factory", [ @@ -177,6 +225,25 @@ def test_bundled_adapters_use_public_registration_and_exact_capabilities( assert DURABLE_SINGLE_WRITER in registry.resolve( f"sqlite://{tmp_path / 'store.sqlite'}" ).capabilities + configured_sqlite = registry.resolve( + f"sqlite://{tmp_path / 'strict.sqlite'}" + "?replay_retention=permanent&outbox_retention=strict" + ) + assert { + PERMANENT_RECEIPT_RETENTION, + PERMANENT_OUTBOX_TERMINAL_RETENTION, + }.issubset(configured_sqlite.capabilities) + configured_postgresql = registry.resolve( + "postgresql://unused", + configuration={ + "replay_retention": "permanent", + "outbox_retention": "compact", + }, + ) + assert { + PERMANENT_RECEIPT_RETENTION, + COMPACT_EFFECT_IDENTITY_RETENTION, + }.issubset(configured_postgresql.capabilities) def test_unknown_adapter_and_capability_mismatch_are_closed() -> None: @@ -189,3 +256,166 @@ def test_unknown_adapter_and_capability_mismatch_are_closed() -> None: "memory:", required_capabilities={DURABLE_SINGLE_WRITER} ) assert mismatch.value.code == "adapter_capability_mismatch" + + +def test_sqlite_rejects_malformed_or_wrong_version_schema(tmp_path: Path) -> None: + malformed_path = tmp_path / "malformed.sqlite" + with sqlite3.connect(malformed_path) as connection: + connection.execute( + "CREATE TABLE determa_execution_checkpoints " + "(root_instance_id TEXT PRIMARY KEY NOT NULL)" + ) + malformed = SQLiteExecutionStore(malformed_path) + with pytest.raises(ExecutionStoreError) as malformed_error: + malformed.setup_schema() + assert malformed_error.value.code == "execution_store_schema_mismatch" + assert malformed.health() == {"healthy": False, "schema_ready": False} + with pytest.raises(ExecutionStoreError) as host_error: + ExecutionHost( + malformed, + _resolver(), + required_capabilities={DURABLE_SINGLE_WRITER}, + ) + assert host_error.value.code == "execution_store_schema_mismatch" + + constrained_path = tmp_path / "extra-constraint.sqlite" + with sqlite3.connect(constrained_path) as connection: + connection.execute( + "CREATE TABLE determa_execution_store_metadata " + "(schema_key TEXT PRIMARY KEY NOT NULL, schema_version INTEGER NOT NULL)" + ) + connection.execute( + "INSERT INTO determa_execution_store_metadata VALUES " + "('execution_checkpoint', 1)" + ) + connection.execute( + "CREATE TABLE determa_execution_checkpoints (" + "root_instance_id TEXT PRIMARY KEY NOT NULL, " + "revision TEXT NOT NULL CHECK (length(revision) > 0), " + "checkpoint_digest TEXT NOT NULL, checkpoint BLOB NOT NULL)" + ) + constrained = SQLiteExecutionStore(constrained_path) + with pytest.raises(ExecutionStoreError) as constrained_error: + constrained.setup_schema() + assert constrained_error.value.code == "execution_store_schema_mismatch" + assert constrained.health() == {"healthy": False, "schema_ready": False} + + versioned_path = tmp_path / "wrong-version.sqlite" + versioned = SQLiteExecutionStore(versioned_path) + versioned.setup_schema() + with sqlite3.connect(versioned_path) as connection: + connection.execute( + "UPDATE determa_execution_store_metadata SET schema_version = 2" + ) + with pytest.raises(ExecutionStoreError) as version_error: + versioned.validate_schema() + assert version_error.value.code == "execution_store_schema_mismatch" + assert versioned.health() == {"healthy": False, "schema_ready": False} + + +def test_direct_injection_checks_actual_store_capabilities() -> None: + with pytest.raises(ExecutionHostError) as error: + ExecutionHost( + MemoryExecutionStore(), + _resolver(), + required_capabilities={DURABLE_SINGLE_WRITER}, + ) + assert error.value.code == "adapter_capability_mismatch" + + +def test_configured_sqlite_satisfies_bank_and_outbox_profiles( + tmp_path: Path, +) -> None: + store = SQLiteExecutionStore( + tmp_path / "bank.sqlite", + replay_retention="permanent", + outbox_retention="strict", + ) + store.setup_schema() + assert { + DURABLE_SINGLE_WRITER, + ROOT_IDENTITY_RETENTION, + PERMANENT_RECEIPT_RETENTION, + PERMANENT_OUTBOX_TERMINAL_RETENTION, + }.issubset(store.capabilities) + host = ExecutionHost( + store, + _resolver(), + required_capabilities={ + DURABLE_SINGLE_WRITER, + ROOT_IDENTITY_RETENTION, + PERMANENT_RECEIPT_RETENTION, + }, + profile="exactly_once_committed_processing", + ) + ExecutionHost( + store, + _resolver(), + profile="strict_durable_outbox", + host_features={ + "atomic_checkpoint_processing", + "outbox_worker", + "total_outbox_lifecycle", + "retain_unresolved_outbox", + }, + ) + checkpoint = host.create( + load_bundle(MACHINE), "counter", "bank-root", "create", {} + ) + assert checkpoint["result"] == "committed" + current = host.read_checkpoint("bank-root") + assert current is not None + expected = { + "expected_revision": current.document["revision"], + "expected_checkpoint_digest": current.document[ + "execution_checkpoint_digest" + ], + } + with pytest.raises(ExecutionHostError) as retention_error: + host.update_replay_retention( + "bank-root", + { + "mode": "bounded", + "permanent_replay_eligible": False, + "pruned_through_receipt_sequence": None, + "policy_identifier": "forbidden", + }, + **expected, + ) + assert retention_error.value.code == "adapter_capability_mismatch" + with pytest.raises(ExecutionHostError) as compact_error: + host.compact_outbox("bank-root", "effect", **expected) + assert compact_error.value.code == "adapter_capability_mismatch" + with pytest.raises(ExecutionHostError) as delete_error: + host.delete_outbox_record("bank-root", "effect", **expected) + assert delete_error.value.code == "adapter_capability_mismatch" + + +def test_configured_sqlite_satisfies_compact_outbox_profile( + tmp_path: Path, +) -> None: + store = SQLiteExecutionStore( + tmp_path / "compact.sqlite", + outbox_retention="compact", + ) + store.setup_schema() + assert COMPACT_EFFECT_IDENTITY_RETENTION in store.capabilities + host = ExecutionHost( + store, + _resolver(), + profile="compact_durable_outbox", + host_features={ + "atomic_checkpoint_processing", + "outbox_worker", + "total_outbox_lifecycle", + "retain_referenced_effect_tombstones", + }, + ) + with pytest.raises(ExecutionHostError) as error: + host.delete_outbox_record( + "root", + "effect", + expected_revision="0", + expected_checkpoint_digest="sha256:" + ("0" * 64), + ) + assert error.value.code == "adapter_capability_mismatch" diff --git a/tests/test_postgresql_store.py b/tests/test_postgresql_store.py index 76aba42..ad284f7 100644 --- a/tests/test_postgresql_store.py +++ b/tests/test_postgresql_store.py @@ -7,9 +7,16 @@ import pytest from determa.state import ( + COMPACT_EFFECT_IDENTITY_RETENTION, + DURABLE_CONCURRENT, + PERMANENT_OUTBOX_TERMINAL_RETENTION, + PERMANENT_RECEIPT_RETENTION, + ROOT_IDENTITY_RETENTION, ExecutionHost, ExecutionHostError, + ExecutionStoreError, PostgreSQLExecutionStore, + StagedExecutionResult, load_bundle, portable_envelope, ) @@ -26,7 +33,7 @@ def _store() -> PostgreSQLExecutionStore: pytest.importorskip("psycopg") return PostgreSQLExecutionStore( os.environ["DETERMA_POSTGRESQL_DSN"], - table_name=f"determa_checkpoint_test_{uuid.uuid4().hex}", + table_name=f"determa_checkpoint_test_{uuid.uuid4().hex[:16]}", ) @@ -77,15 +84,142 @@ def process(event_id: str) -> str: outcomes = sorted(executor.map(process, ["event-a", "event-b"])) assert outcomes == ["checkpoint_revision_conflict", "committed"] + application_table = f"determa_application_test_{uuid.uuid4().hex}" with psycopg.connect(store.conninfo) as connection: - with pytest.raises(RuntimeError), connection.transaction(): - host.create( - load_bundle(MACHINE), - "counter", - "rolled-back-root", - "create", - {}, - native_transaction=connection, - ) - raise RuntimeError("application rollback") + connection.execute( + f"CREATE TABLE {application_table} (root_instance_id TEXT PRIMARY KEY)" + ) + + def commit_callback(connection, execution) -> None: + connection.execute( + f"INSERT INTO {application_table} (root_instance_id) VALUES (%s)", + ("shared-root",), + ) + staged = execution.create( + load_bundle(MACHINE), "counter", "create", {} + ) + assert staged == StagedExecutionResult("create") + + committed = host.run_shared_transaction("shared-root", commit_callback) + assert committed["result"] == "committed" + with psycopg.connect(store.conninfo) as connection: + rows = connection.execute( + f"SELECT root_instance_id FROM {application_table}" + ).fetchall() + value = rows[0][0] + if isinstance(value, bytes): + value = value.decode("ascii") + assert value == "shared-root" + + def rollback_callback(connection, execution) -> None: + connection.execute( + f"INSERT INTO {application_table} (root_instance_id) VALUES (%s)", + ("rolled-back-root",), + ) + execution.create(load_bundle(MACHINE), "counter", "create", {}) + raise RuntimeError("application rollback") + + with pytest.raises(RuntimeError, match="application rollback"): + host.run_shared_transaction("rolled-back-root", rollback_callback) assert host.read_checkpoint("rolled-back-root") is None + with psycopg.connect(store.conninfo) as connection: + assert connection.execute( + f"SELECT root_instance_id FROM {application_table} " + "WHERE root_instance_id = %s", + ("rolled-back-root",), + ).fetchall() == [] + + +def test_postgresql_rejects_a_malformed_existing_schema() -> None: + psycopg = pytest.importorskip("psycopg") + store = _store() + with psycopg.connect(store.conninfo) as connection: + connection.execute( + f"CREATE TABLE {store.table_name} (root_instance_id TEXT PRIMARY KEY)" + ) + with pytest.raises(ExecutionStoreError) as error: + store.setup_schema() + assert error.value.code == "execution_store_schema_mismatch" + assert store.health() == {"healthy": False, "schema_ready": False} + + constrained = _store() + with psycopg.connect(constrained.conninfo) as connection: + connection.execute( + f""" + CREATE TABLE {constrained.metadata_table} ( + schema_key TEXT PRIMARY KEY NOT NULL, + schema_version INTEGER NOT NULL + ) + """ + ) + connection.execute( + f"INSERT INTO {constrained.metadata_table} VALUES (%s, %s)", + ("execution_checkpoint", 1), + ) + connection.execute( + f""" + CREATE TABLE {constrained.table_name} ( + root_instance_id TEXT PRIMARY KEY NOT NULL, + revision TEXT NOT NULL CHECK (length(revision) > 0), + checkpoint_digest TEXT NOT NULL, + checkpoint BYTEA NOT NULL + ) + """ + ) + with pytest.raises(ExecutionStoreError) as constrained_error: + constrained.setup_schema() + assert constrained_error.value.code == "execution_store_schema_mismatch" + assert constrained.health() == {"healthy": False, "schema_ready": False} + + +def test_postgresql_configured_permanent_strict_profile() -> None: + pytest.importorskip("psycopg") + store = PostgreSQLExecutionStore( + os.environ["DETERMA_POSTGRESQL_DSN"], + table_name=f"determa_bank_{uuid.uuid4().hex[:16]}", + replay_retention="permanent", + outbox_retention="strict", + ) + store.setup_schema() + assert { + DURABLE_CONCURRENT, + ROOT_IDENTITY_RETENTION, + PERMANENT_RECEIPT_RETENTION, + PERMANENT_OUTBOX_TERMINAL_RETENTION, + }.issubset(store.capabilities) + local_host, _ = _host() + ExecutionHost( + store, + local_host.artifact_resolver, + profile="exactly_once_committed_processing", + ) + ExecutionHost( + store, + local_host.artifact_resolver, + profile="strict_durable_outbox", + host_features={ + "atomic_checkpoint_processing", + "outbox_worker", + "total_outbox_lifecycle", + "retain_unresolved_outbox", + }, + ) + + compact_store = PostgreSQLExecutionStore( + os.environ["DETERMA_POSTGRESQL_DSN"], + table_name=f"determa_compact_{uuid.uuid4().hex[:16]}", + outbox_retention="compact", + ) + compact_store.setup_schema() + assert COMPACT_EFFECT_IDENTITY_RETENTION in compact_store.capabilities + ExecutionHost( + compact_store, + local_host.artifact_resolver, + profile="compact_durable_outbox", + host_features={ + "atomic_checkpoint_processing", + "outbox_worker", + "total_outbox_lifecycle", + "retain_referenced_effect_tombstones", + }, + ) From fa8fee5a9d201235bb1883a5d364ab38b89d5a93 Mon Sep 17 00:00:00 2001 From: Christian-Manuel Butzke Date: Fri, 31 Jul 2026 11:56:48 +0900 Subject: [PATCH 3/4] Harden durable execution store retention --- README.md | 7 +- src/determa/state/stores/postgresql.py | 115 ++++++++++++++++++---- src/determa/state/stores/sqlite.py | 129 ++++++++++++++++++++++--- tests/test_execution_stores.py | 83 +++++++++++++++- tests/test_postgresql_store.py | 72 ++++++++++++++ 5 files changed, 367 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index e453b63..7a6fbae 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,9 @@ application transactions. Every store transaction is bound to one exact root. SQLite and PostgreSQL accept explicit `replay_retention="permanent"` and `outbox_retention="strict" | "compact"` configuration. These settings add only the -retention capabilities they actually enforce. `ExecutionHost` validates required +retention capabilities they actually enforce. Database setup records that policy +immutably; reopening with a different policy is rejected, and database guards reject +native root-checkpoint deletion or policy mutation. `ExecutionHost` validates required capabilities and composed profiles against the injected store: ```python @@ -211,7 +213,8 @@ back both application writes and checkpoint work. File and database schema setup is never implicit. SQLite and PostgreSQL validate an explicit schema version and the exact required tables, columns, types, nullability, -primary keys, indexes, and triggers before checkpoint use. +primary keys, indexes, immutable policy rows, and deletion-protection triggers before +checkpoint use. `ExecutionStoreRegistry` starts empty. `register_bundled_execution_stores` registers `memory`, `file`, `sqlite`, and `postgresql` through the same public operation used by diff --git a/src/determa/state/stores/postgresql.py b/src/determa/state/stores/postgresql.py index 31b0663..0cf6052 100644 --- a/src/determa/state/stores/postgresql.py +++ b/src/determa/state/stores/postgresql.py @@ -25,8 +25,12 @@ _IDENTIFIER = re.compile(r"[a-z_][a-z0-9_]*\Z") _REPLAY_RETENTION_MODES = {"bounded", "permanent"} _OUTBOX_RETENTION_MODES = {"none", "strict", "compact"} -_SCHEMA_KEY = "execution_checkpoint" -_SCHEMA_VERSION = 1 +_SCHEMA_VERSION = 2 +_SCHEMA_VERSION_KEY = "execution_checkpoint_schema_version" +_REPLAY_RETENTION_KEY = "replay_retention" +_OUTBOX_RETENTION_KEY = "outbox_retention" +_TRIGGER_NAME = "determa_execution_store_immutable" +_IMMUTABLE_MESSAGE = "execution_store_immutable" def _psycopg() -> Any: @@ -120,9 +124,11 @@ def __init__( outbox_retention: str = "none", ) -> None: metadata_table = f"{table_name}_metadata" + guard_function = f"{table_name}_guard" if ( not conninfo or len(metadata_table) > 63 + or len(guard_function) > 63 or _IDENTIFIER.fullmatch(table_name) is None or replay_retention not in _REPLAY_RETENTION_MODES or outbox_retention not in _OUTBOX_RETENTION_MODES @@ -131,6 +137,7 @@ def __init__( self.conninfo = conninfo self.table_name = table_name self.metadata_table = metadata_table + self.guard_function = guard_function self.replay_retention = replay_retention self.outbox_retention = outbox_retention @@ -153,6 +160,13 @@ def capabilities(self) -> frozenset[str]: def checkpoint_retention_mode(self) -> str: return self.replay_retention + def _metadata_rows(self) -> list[tuple[str, str]]: + return [ + (_SCHEMA_VERSION_KEY, str(_SCHEMA_VERSION)), + (_OUTBOX_RETENTION_KEY, self.outbox_retention), + (_REPLAY_RETENTION_KEY, self.replay_retention), + ] + def _relation_state(self, connection: Any) -> tuple[Any, Any]: row = connection.execute( "SELECT to_regclass(%s), to_regclass(%s)", @@ -213,20 +227,44 @@ def _validate_table( "SELECT count(*) FROM pg_index WHERE indrelid = to_regclass(%s)", (table_name,), ).fetchone() - trigger_count = connection.execute( + expected_trigger_type = 11 if table_name == self.table_name else 27 + triggers = connection.execute( """ - SELECT count(*) + SELECT tgname, tgtype, tgenabled, + tgfoid = to_regprocedure(%s) FROM pg_trigger WHERE tgrelid = to_regclass(%s) AND NOT tgisinternal + ORDER BY tgname """, - (table_name,), - ).fetchone() + (f"{self.guard_function}()", table_name), + ).fetchall() + normalized_triggers = [ + tuple(_database_value(value) for value in row) for row in triggers + ] if ( [_database_value(row[0]) for row in constraint_types] != ["p"] or index_count is None or index_count[0] != 1 - or trigger_count is None - or trigger_count[0] != 0 + or normalized_triggers != [ + (_TRIGGER_NAME, expected_trigger_type, "A", True) + ] + ): + raise ExecutionStoreError("execution_store_schema_mismatch") + + def _validate_guard_function(self, connection: Any) -> None: + row = connection.execute( + """ + SELECT prosrc + FROM pg_proc + WHERE oid = to_regprocedure(%s) + """, + (f"{self.guard_function}()",), + ).fetchone() + source = None if row is None else _database_value(row[0]) + if ( + not isinstance(source, str) + or " ".join(source.split()) + != "BEGIN RAISE EXCEPTION 'execution_store_immutable'; END;" ): raise ExecutionStoreError("execution_store_schema_mismatch") @@ -251,17 +289,19 @@ def _validate_schema(self, connection: Any) -> None: self.metadata_table, [ ("schema_key", "text", "NO", None), - ("schema_version", "integer", "NO", None), + ("schema_value", "text", "NO", None), ], ) rows = connection.execute( - f"SELECT schema_key, schema_version FROM {self.metadata_table}" + f"SELECT schema_key, schema_value FROM {self.metadata_table} " + "ORDER BY schema_key" ).fetchall() normalized_rows = [ tuple(_database_value(value) for value in row) for row in rows ] - if normalized_rows != [(_SCHEMA_KEY, _SCHEMA_VERSION)]: + if normalized_rows != self._metadata_rows(): raise ExecutionStoreError("execution_store_schema_mismatch") + self._validate_guard_function(connection) def validate_schema(self) -> None: psycopg = _psycopg() @@ -304,7 +344,7 @@ def setup_schema(self) -> None: f""" CREATE TABLE {self.metadata_table} ( schema_key TEXT PRIMARY KEY NOT NULL, - schema_version INTEGER NOT NULL + schema_value TEXT NOT NULL ) """ ) @@ -318,13 +358,48 @@ def setup_schema(self) -> None: ) """ ) + for metadata_row in self._metadata_rows(): + connection.execute( + f""" + INSERT INTO {self.metadata_table} + (schema_key, schema_value) + VALUES (%s, %s) + """, + metadata_row, + ) connection.execute( f""" - INSERT INTO {self.metadata_table} - (schema_key, schema_version) - VALUES (%s, %s) - """, - (_SCHEMA_KEY, _SCHEMA_VERSION), + CREATE FUNCTION {self.guard_function}() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + BEGIN + RAISE EXCEPTION '{_IMMUTABLE_MESSAGE}'; + END; + $$ + """ + ) + connection.execute( + f""" + CREATE TRIGGER {_TRIGGER_NAME} + BEFORE DELETE ON {self.table_name} + FOR EACH ROW EXECUTE FUNCTION {self.guard_function}() + """ + ) + connection.execute( + f"ALTER TABLE {self.table_name} ENABLE ALWAYS TRIGGER " + f"{_TRIGGER_NAME}" + ) + connection.execute( + f""" + CREATE TRIGGER {_TRIGGER_NAME} + BEFORE UPDATE OR DELETE ON {self.metadata_table} + FOR EACH ROW EXECUTE FUNCTION {self.guard_function}() + """ + ) + connection.execute( + f"ALTER TABLE {self.metadata_table} ENABLE ALWAYS TRIGGER " + f"{_TRIGGER_NAME}" ) self._validate_schema(connection) @@ -333,7 +408,11 @@ def health(self) -> Mapping[str, Any]: self.validate_schema() except Exception: return {"healthy": False, "schema_ready": False} - return {"healthy": True, "schema_ready": True, "schema_version": 1} + return { + "healthy": True, + "schema_ready": True, + "schema_version": _SCHEMA_VERSION, + } def postgresql_execution_store_factory( diff --git a/src/determa/state/stores/sqlite.py b/src/determa/state/stores/sqlite.py index c003562..8fd7646 100644 --- a/src/determa/state/stores/sqlite.py +++ b/src/determa/state/stores/sqlite.py @@ -28,8 +28,15 @@ _SYNCHRONOUS_MODES = {"FULL"} _REPLAY_RETENTION_MODES = {"bounded", "permanent"} _OUTBOX_RETENTION_MODES = {"none", "strict", "compact"} -_SCHEMA_KEY = "execution_checkpoint" -_SCHEMA_VERSION = 1 +_SCHEMA_VERSION = 2 +_SCHEMA_VERSION_KEY = "execution_checkpoint_schema_version" +_REPLAY_RETENTION_KEY = "replay_retention" +_OUTBOX_RETENTION_KEY = "outbox_retention" +_CHECKPOINT_DELETE_TRIGGER = "determa_execution_checkpoints_forbid_delete" +_METADATA_INSERT_TRIGGER = "determa_execution_metadata_forbid_insert" +_METADATA_UPDATE_TRIGGER = "determa_execution_metadata_forbid_update" +_METADATA_DELETE_TRIGGER = "determa_execution_metadata_forbid_delete" +_IMMUTABLE_MESSAGE = "execution_store_immutable" def _schema_tokens(source: str) -> list[str]: @@ -145,6 +152,13 @@ def capabilities(self) -> frozenset[str]: def checkpoint_retention_mode(self) -> str: return self.replay_retention + def _metadata_rows(self) -> list[tuple[str, str]]: + return [ + (_SCHEMA_VERSION_KEY, str(_SCHEMA_VERSION)), + (_OUTBOX_RETENTION_KEY, self.outbox_retention), + (_REPLAY_RETENTION_KEY, self.replay_retention), + ] + def _connect(self) -> sqlite3.Connection: connection = sqlite3.connect( self.path, timeout=self.timeout, isolation_level=None @@ -197,12 +211,66 @@ def _validate_table( foreign_keys = connection.execute( f"PRAGMA foreign_key_list({table})" ).fetchall() - triggers = connection.execute( - "SELECT 1 FROM sqlite_master WHERE type = 'trigger' AND tbl_name = ?", - (table,), - ).fetchone() - if foreign_keys or triggers is not None: + if foreign_keys: + raise ExecutionStoreError("execution_store_schema_mismatch") + + def _validate_triggers(self, connection: sqlite3.Connection) -> None: + expected = { + _CHECKPOINT_DELETE_TRIGGER: f""" + CREATE TRIGGER {_CHECKPOINT_DELETE_TRIGGER} + BEFORE DELETE ON {_TABLE} + BEGIN + SELECT RAISE(ABORT, '{_IMMUTABLE_MESSAGE}'); + END + """, + _METADATA_INSERT_TRIGGER: f""" + CREATE TRIGGER {_METADATA_INSERT_TRIGGER} + BEFORE INSERT ON {_METADATA_TABLE} + BEGIN + SELECT RAISE(ABORT, '{_IMMUTABLE_MESSAGE}'); + END + """, + _METADATA_UPDATE_TRIGGER: f""" + CREATE TRIGGER {_METADATA_UPDATE_TRIGGER} + BEFORE UPDATE ON {_METADATA_TABLE} + BEGIN + SELECT RAISE(ABORT, '{_IMMUTABLE_MESSAGE}'); + END + """, + _METADATA_DELETE_TRIGGER: f""" + CREATE TRIGGER {_METADATA_DELETE_TRIGGER} + BEFORE DELETE ON {_METADATA_TABLE} + BEGIN + SELECT RAISE(ABORT, '{_IMMUTABLE_MESSAGE}'); + END + """, + } + rows = connection.execute( + """ + SELECT name, tbl_name, sql + FROM sqlite_master + WHERE type = 'trigger' + AND tbl_name IN (?, ?) + ORDER BY name + """, + (_TABLE, _METADATA_TABLE), + ).fetchall() + if len(rows) != len(expected): raise ExecutionStoreError("execution_store_schema_mismatch") + for name, table, source in rows: + expected_source = expected.get(name) + expected_table = ( + _TABLE + if name == _CHECKPOINT_DELETE_TRIGGER + else _METADATA_TABLE + ) + if ( + table != expected_table + or not isinstance(source, str) + or expected_source is None + or _schema_tokens(source) != _schema_tokens(expected_source) + ): + raise ExecutionStoreError("execution_store_schema_mismatch") def _validate_schema(self, connection: sqlite3.Connection) -> None: tables = { @@ -239,20 +307,22 @@ def _validate_schema(self, connection: sqlite3.Connection) -> None: _METADATA_TABLE, [ ("schema_key", "TEXT", 1, None, 1, 0), - ("schema_version", "INTEGER", 1, None, 0, 0), + ("schema_value", "TEXT", 1, None, 0, 0), ], f""" CREATE TABLE {_METADATA_TABLE} ( schema_key TEXT PRIMARY KEY NOT NULL, - schema_version INTEGER NOT NULL + schema_value TEXT NOT NULL ) """, ) rows = connection.execute( - f"SELECT schema_key, schema_version FROM {_METADATA_TABLE}" + f"SELECT schema_key, schema_value FROM {_METADATA_TABLE} " + "ORDER BY schema_key" ).fetchall() - if rows != [(_SCHEMA_KEY, _SCHEMA_VERSION)]: + if rows != self._metadata_rows(): raise ExecutionStoreError("execution_store_schema_mismatch") + self._validate_triggers(connection) def validate_schema(self) -> None: connection = self._connect() @@ -299,7 +369,7 @@ def setup_schema(self) -> None: f""" CREATE TABLE {_METADATA_TABLE} ( schema_key TEXT PRIMARY KEY NOT NULL, - schema_version INTEGER NOT NULL + schema_value TEXT NOT NULL ) """ ) @@ -313,11 +383,34 @@ def setup_schema(self) -> None: ) """ ) - connection.execute( - f"INSERT INTO {_METADATA_TABLE} (schema_key, schema_version) " + connection.executemany( + f"INSERT INTO {_METADATA_TABLE} (schema_key, schema_value) " "VALUES (?, ?)", - (_SCHEMA_KEY, _SCHEMA_VERSION), + self._metadata_rows(), ) + connection.execute( + f""" + CREATE TRIGGER {_CHECKPOINT_DELETE_TRIGGER} + BEFORE DELETE ON {_TABLE} + BEGIN + SELECT RAISE(ABORT, '{_IMMUTABLE_MESSAGE}'); + END + """ + ) + for name, operation in ( + (_METADATA_INSERT_TRIGGER, "INSERT"), + (_METADATA_UPDATE_TRIGGER, "UPDATE"), + (_METADATA_DELETE_TRIGGER, "DELETE"), + ): + connection.execute( + f""" + CREATE TRIGGER {name} + BEFORE {operation} ON {_METADATA_TABLE} + BEGIN + SELECT RAISE(ABORT, '{_IMMUTABLE_MESSAGE}'); + END + """ + ) self._validate_schema(connection) connection.commit() except BaseException: @@ -335,7 +428,11 @@ def health(self) -> Mapping[str, Any]: connection.close() except (OSError, sqlite3.Error, ExecutionStoreError): return {"healthy": False, "schema_ready": False} - return {"healthy": True, "schema_ready": True, "schema_version": 1} + return { + "healthy": True, + "schema_ready": True, + "schema_version": _SCHEMA_VERSION, + } def _single_query(query: Mapping[str, list[str]], key: str, default: str) -> str: diff --git a/tests/test_execution_stores.py b/tests/test_execution_stores.py index 34aea32..380ecf9 100644 --- a/tests/test_execution_stores.py +++ b/tests/test_execution_stores.py @@ -233,6 +233,15 @@ def test_bundled_adapters_use_public_registration_and_exact_capabilities( PERMANENT_RECEIPT_RETENTION, PERMANENT_OUTBOX_TERMINAL_RETENTION, }.issubset(configured_sqlite.capabilities) + configured_sqlite.setup_schema() + reopened_sqlite = registry.resolve( + f"sqlite://{tmp_path / 'strict.sqlite'}" + "?replay_retention=permanent&outbox_retention=strict" + ) + reopened_sqlite.validate_schema() + with pytest.raises(ExecutionStoreError) as sqlite_policy_mismatch: + registry.resolve(f"sqlite://{tmp_path / 'strict.sqlite'}").validate_schema() + assert sqlite_policy_mismatch.value.code == "execution_store_schema_mismatch" configured_postgresql = registry.resolve( "postgresql://unused", configuration={ @@ -282,11 +291,11 @@ def test_sqlite_rejects_malformed_or_wrong_version_schema(tmp_path: Path) -> Non with sqlite3.connect(constrained_path) as connection: connection.execute( "CREATE TABLE determa_execution_store_metadata " - "(schema_key TEXT PRIMARY KEY NOT NULL, schema_version INTEGER NOT NULL)" + "(schema_key TEXT PRIMARY KEY NOT NULL, schema_value TEXT NOT NULL)" ) connection.execute( "INSERT INTO determa_execution_store_metadata VALUES " - "('execution_checkpoint', 1)" + "('execution_checkpoint_schema_version', '2')" ) connection.execute( "CREATE TABLE determa_execution_checkpoints (" @@ -305,7 +314,11 @@ def test_sqlite_rejects_malformed_or_wrong_version_schema(tmp_path: Path) -> Non versioned.setup_schema() with sqlite3.connect(versioned_path) as connection: connection.execute( - "UPDATE determa_execution_store_metadata SET schema_version = 2" + "DROP TRIGGER determa_execution_metadata_forbid_update" + ) + connection.execute( + "UPDATE determa_execution_store_metadata SET schema_value = '3' " + "WHERE schema_key = 'execution_checkpoint_schema_version'" ) with pytest.raises(ExecutionStoreError) as version_error: versioned.validate_schema() @@ -313,6 +326,70 @@ def test_sqlite_rejects_malformed_or_wrong_version_schema(tmp_path: Path) -> Non assert versioned.health() == {"healthy": False, "schema_ready": False} +def test_sqlite_persists_policy_and_forbids_native_root_or_policy_mutation( + tmp_path: Path, +) -> None: + path = tmp_path / "bank.sqlite" + store = SQLiteExecutionStore( + path, + replay_retention="permanent", + outbox_retention="strict", + ) + store.setup_schema() + host = _create(store, "bank-root") + + with sqlite3.connect(path) as connection: + with pytest.raises(sqlite3.IntegrityError, match="execution_store_immutable"): + connection.execute( + "DELETE FROM determa_execution_checkpoints " + "WHERE root_instance_id = ?", + ("bank-root",), + ) + with pytest.raises(sqlite3.IntegrityError, match="execution_store_immutable"): + connection.execute( + "UPDATE determa_execution_store_metadata " + "SET schema_value = 'bounded' " + "WHERE schema_key = 'replay_retention'" + ) + + assert host.read_checkpoint("bank-root") is not None + with pytest.raises(ExecutionHostError) as recreate: + host.create( + load_bundle(MACHINE), "counter", "bank-root", "replacement", {} + ) + assert recreate.value.code == "creation_id_conflict" + + reopened = SQLiteExecutionStore( + path, + replay_retention="permanent", + outbox_retention="strict", + ) + reopened.validate_schema() + assert reopened.health() == { + "healthy": True, + "schema_ready": True, + "schema_version": 2, + } + weaker = SQLiteExecutionStore(path) + with pytest.raises(ExecutionStoreError) as mismatch: + weaker.validate_schema() + assert mismatch.value.code == "execution_store_schema_mismatch" + assert weaker.health() == {"healthy": False, "schema_ready": False} + + +def test_sqlite_health_requires_immutable_policy_and_root_guards( + tmp_path: Path, +) -> None: + path = tmp_path / "guarded.sqlite" + store = SQLiteExecutionStore(path) + store.setup_schema() + with sqlite3.connect(path) as connection: + connection.execute( + "DROP TRIGGER determa_execution_checkpoints_forbid_delete" + ) + assert store.health() == {"healthy": False, "schema_ready": False} + + def test_direct_injection_checks_actual_store_capabilities() -> None: with pytest.raises(ExecutionHostError) as error: ExecutionHost( diff --git a/tests/test_postgresql_store.py b/tests/test_postgresql_store.py index ad284f7..a88924f 100644 --- a/tests/test_postgresql_store.py +++ b/tests/test_postgresql_store.py @@ -223,3 +223,75 @@ def test_postgresql_configured_permanent_strict_profile() -> None: "retain_referenced_effect_tombstones", }, ) + + +def test_postgresql_persists_policy_and_forbids_native_deletion( +) -> None: + psycopg = pytest.importorskip("psycopg") + store = PostgreSQLExecutionStore( + os.environ["DETERMA_POSTGRESQL_DSN"], + table_name=f"determa_guarded_{uuid.uuid4().hex[:16]}", + replay_retention="permanent", + outbox_retention="strict", + ) + store.setup_schema() + local_host, _ = _host() + host = ExecutionHost( + store, + local_host.artifact_resolver, + profile="exactly_once_committed_processing", + ) + host.create(load_bundle(MACHINE), "counter", "bank-root", "create", {}) + + with psycopg.connect(store.conninfo) as connection: + with pytest.raises(psycopg.Error, match="execution_store_immutable"): + connection.execute( + f"DELETE FROM {store.table_name} WHERE root_instance_id = %s", + ("bank-root",), + ) + assert host.read_checkpoint("bank-root") is not None + with pytest.raises(ExecutionHostError) as recreate: + host.create( + load_bundle(MACHINE), "counter", "bank-root", "replacement", {} + ) + assert recreate.value.code == "creation_id_conflict" + + def native_delete(connection, execution) -> None: + del execution + connection.execute( + f"DELETE FROM {store.table_name} WHERE root_instance_id = %s", + ("bank-root",), + ) + + with pytest.raises(psycopg.Error, match="execution_store_immutable"): + host.run_shared_transaction("bank-root", native_delete) + assert host.read_checkpoint("bank-root") is not None + + reopened = PostgreSQLExecutionStore( + store.conninfo, + table_name=store.table_name, + replay_retention="permanent", + outbox_retention="strict", + ) + reopened.validate_schema() + assert reopened.health() == { + "healthy": True, + "schema_ready": True, + "schema_version": 2, + } + weaker = PostgreSQLExecutionStore(store.conninfo, table_name=store.table_name) + with pytest.raises(ExecutionStoreError) as mismatch: + weaker.validate_schema() + assert mismatch.value.code == "execution_store_schema_mismatch" + assert weaker.health() == {"healthy": False, "schema_ready": False} + + +def test_postgresql_health_requires_immutable_policy_and_root_guards() -> None: + psycopg = pytest.importorskip("psycopg") + store = _store() + store.setup_schema() + with psycopg.connect(store.conninfo) as connection: + connection.execute( + f"DROP TRIGGER determa_execution_store_immutable ON {store.table_name}" + ) + assert store.health() == {"healthy": False, "schema_ready": False} From 9fc90f482f6a4e68789426e4652684300d76af09 Mon Sep 17 00:00:00 2001 From: Christian-Manuel Butzke Date: Fri, 31 Jul 2026 12:26:51 +0900 Subject: [PATCH 4/4] Make retention capabilities fail closed --- README.md | 4 ++- conformance/execution_checkpoint.py | 12 ++++++- src/determa/state/stores/postgresql.py | 13 ++++++-- src/determa/state/stores/registry.py | 4 ++- src/determa/state/stores/sqlite.py | 16 +++++++-- tests/test_execution_stores.py | 45 ++++++++++++++++++++++---- tests/test_postgresql_store.py | 31 +++++++++++++++++- 7 files changed, 111 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 7a6fbae..1943395 100644 --- a/README.md +++ b/README.md @@ -183,7 +183,9 @@ SQLite and PostgreSQL accept explicit `replay_retention="permanent"` and retention capabilities they actually enforce. Database setup records that policy immutably; reopening with a different policy is rejected, and database guards reject native root-checkpoint deletion or policy mutation. `ExecutionHost` validates required -capabilities and composed profiles against the injected store: +capabilities and composed profiles against the injected store. Strong retention +capabilities are withheld before schema setup and whenever policy or guard validation +fails: ```python store = ds.SQLiteExecutionStore( diff --git a/conformance/execution_checkpoint.py b/conformance/execution_checkpoint.py index a764db6..2f096b0 100644 --- a/conformance/execution_checkpoint.py +++ b/conformance/execution_checkpoint.py @@ -295,7 +295,17 @@ def static_factory(uri: str, configuration: dict[str, Any]) -> ExecutionStore: elif identifier != "absent-store": registry.register(identifier, static_factory) store = registry.resolve(uri, required_capabilities=frozenset(requested)) - assert store.capabilities == frozenset(capabilities) + actual_capabilities = store.capabilities + expected_capabilities = frozenset(capabilities) + if ( + vector["registration_source"] == "bundled" + and identifier in {"sqlite", "postgresql"} + ): + # An uninitialized database adapter must not claim persisted guarantees. + assert requested.issubset(actual_capabilities) + assert actual_capabilities.issubset(expected_capabilities) + else: + assert actual_capabilities == expected_capabilities return {"result": "accepted"} diff --git a/src/determa/state/stores/postgresql.py b/src/determa/state/stores/postgresql.py index 0cf6052..6a0c996 100644 --- a/src/determa/state/stores/postgresql.py +++ b/src/determa/state/stores/postgresql.py @@ -146,8 +146,10 @@ def capabilities(self) -> frozenset[str]: capabilities = { DURABLE_CONCURRENT, SHARED_APPLICATION_TRANSACTION, - ROOT_IDENTITY_RETENTION, } + if not self._policy_is_valid(): + return frozenset(capabilities) + capabilities.add(ROOT_IDENTITY_RETENTION) if self.replay_retention == "permanent": capabilities.add(PERMANENT_RECEIPT_RETENTION) if self.outbox_retention == "strict": @@ -158,7 +160,14 @@ def capabilities(self) -> frozenset[str]: @property def checkpoint_retention_mode(self) -> str: - return self.replay_retention + return self.replay_retention if self._policy_is_valid() else "unverified" + + def _policy_is_valid(self) -> bool: + try: + self.validate_schema() + except Exception: + return False + return True def _metadata_rows(self) -> list[tuple[str, str]]: return [ diff --git a/src/determa/state/stores/registry.py b/src/determa/state/stores/registry.py index 998e615..d646f8f 100644 --- a/src/determa/state/stores/registry.py +++ b/src/determa/state/stores/registry.py @@ -49,7 +49,9 @@ def resolve( raise except (TypeError, ValueError) as exc: raise ExecutionStoreError("invalid_adapter_configuration") from exc - if not required_capabilities.issubset(store.capabilities): + if required_capabilities and not required_capabilities.issubset( + store.capabilities + ): raise ExecutionStoreError("adapter_capability_mismatch") return store diff --git a/src/determa/state/stores/sqlite.py b/src/determa/state/stores/sqlite.py index 8fd7646..c5d475f 100644 --- a/src/determa/state/stores/sqlite.py +++ b/src/determa/state/stores/sqlite.py @@ -139,7 +139,10 @@ def __init__( @property def capabilities(self) -> frozenset[str]: - capabilities = {DURABLE_SINGLE_WRITER, ROOT_IDENTITY_RETENTION} + capabilities = {DURABLE_SINGLE_WRITER} + if not self._policy_is_valid(): + return frozenset(capabilities) + capabilities.add(ROOT_IDENTITY_RETENTION) if self.replay_retention == "permanent": capabilities.add(PERMANENT_RECEIPT_RETENTION) if self.outbox_retention == "strict": @@ -150,7 +153,16 @@ def capabilities(self) -> frozenset[str]: @property def checkpoint_retention_mode(self) -> str: - return self.replay_retention + return self.replay_retention if self._policy_is_valid() else "unverified" + + def _policy_is_valid(self) -> bool: + if not Path(self.path).is_file(): + return False + try: + self.validate_schema() + except (OSError, sqlite3.Error, ExecutionStoreError): + return False + return True def _metadata_rows(self) -> list[tuple[str, str]]: return [ diff --git a/tests/test_execution_stores.py b/tests/test_execution_stores.py index 380ecf9..0f7c9a6 100644 --- a/tests/test_execution_stores.py +++ b/tests/test_execution_stores.py @@ -23,6 +23,7 @@ FileExecutionStore, MemoryArtifactResolver, MemoryExecutionStore, + PostgreSQLExecutionStore, SQLiteExecutionStore, bundled_execution_store_registry, load_bundle, @@ -31,6 +32,13 @@ from .test_checkpoint_host import MACHINE +_STRONG_RETENTION_CAPABILITIES = { + ROOT_IDENTITY_RETENTION, + PERMANENT_RECEIPT_RETENTION, + PERMANENT_OUTBOX_TERMINAL_RETENTION, + COMPACT_EFFECT_IDENTITY_RETENTION, +} + def _resolver() -> MemoryArtifactResolver: bundle = load_bundle(MACHINE) @@ -229,11 +237,18 @@ def test_bundled_adapters_use_public_registration_and_exact_capabilities( f"sqlite://{tmp_path / 'strict.sqlite'}" "?replay_retention=permanent&outbox_retention=strict" ) + assert configured_sqlite.capabilities == frozenset({DURABLE_SINGLE_WRITER}) + assert configured_sqlite.checkpoint_retention_mode == "unverified" assert { PERMANENT_RECEIPT_RETENTION, PERMANENT_OUTBOX_TERMINAL_RETENTION, - }.issubset(configured_sqlite.capabilities) + }.isdisjoint(configured_sqlite.capabilities) configured_sqlite.setup_schema() + assert { + ROOT_IDENTITY_RETENTION, + PERMANENT_RECEIPT_RETENTION, + PERMANENT_OUTBOX_TERMINAL_RETENTION, + }.issubset(configured_sqlite.capabilities) reopened_sqlite = registry.resolve( f"sqlite://{tmp_path / 'strict.sqlite'}" "?replay_retention=permanent&outbox_retention=strict" @@ -249,10 +264,9 @@ def test_bundled_adapters_use_public_registration_and_exact_capabilities( "outbox_retention": "compact", }, ) - assert { - PERMANENT_RECEIPT_RETENTION, - COMPACT_EFFECT_IDENTITY_RETENTION, - }.issubset(configured_postgresql.capabilities) + assert isinstance(configured_postgresql, PostgreSQLExecutionStore) + assert configured_postgresql.replay_retention == "permanent" + assert configured_postgresql.outbox_retention == "compact" def test_unknown_adapter_and_capability_mismatch_are_closed() -> None: @@ -365,6 +379,12 @@ def test_sqlite_persists_policy_and_forbids_native_root_or_policy_mutation( outbox_retention="strict", ) reopened.validate_schema() + assert { + ROOT_IDENTITY_RETENTION, + PERMANENT_RECEIPT_RETENTION, + PERMANENT_OUTBOX_TERMINAL_RETENTION, + }.issubset(reopened.capabilities) + assert reopened.checkpoint_retention_mode == "permanent" assert reopened.health() == { "healthy": True, "schema_ready": True, @@ -375,19 +395,32 @@ def test_sqlite_persists_policy_and_forbids_native_root_or_policy_mutation( weaker.validate_schema() assert mismatch.value.code == "execution_store_schema_mismatch" assert weaker.health() == {"healthy": False, "schema_ready": False} + assert _STRONG_RETENTION_CAPABILITIES.isdisjoint(weaker.capabilities) + assert weaker.checkpoint_retention_mode == "unverified" def test_sqlite_health_requires_immutable_policy_and_root_guards( tmp_path: Path, ) -> None: path = tmp_path / "guarded.sqlite" - store = SQLiteExecutionStore(path) + store = SQLiteExecutionStore( + path, + replay_retention="permanent", + outbox_retention="strict", + ) store.setup_schema() + assert { + ROOT_IDENTITY_RETENTION, + PERMANENT_RECEIPT_RETENTION, + PERMANENT_OUTBOX_TERMINAL_RETENTION, + }.issubset(store.capabilities) with sqlite3.connect(path) as connection: connection.execute( "DROP TRIGGER determa_execution_checkpoints_forbid_delete" ) assert store.health() == {"healthy": False, "schema_ready": False} + assert _STRONG_RETENTION_CAPABILITIES.isdisjoint(store.capabilities) + assert store.checkpoint_retention_mode == "unverified" def test_direct_injection_checks_actual_store_capabilities() -> None: diff --git a/tests/test_postgresql_store.py b/tests/test_postgresql_store.py index a88924f..6e7382a 100644 --- a/tests/test_postgresql_store.py +++ b/tests/test_postgresql_store.py @@ -23,6 +23,13 @@ from .test_checkpoint_host import MACHINE, _host +_STRONG_RETENTION_CAPABILITIES = { + ROOT_IDENTITY_RETENTION, + PERMANENT_RECEIPT_RETENTION, + PERMANENT_OUTBOX_TERMINAL_RETENTION, + COMPACT_EFFECT_IDENTITY_RETENTION, +} + pytestmark = pytest.mark.skipif( not os.environ.get("DETERMA_POSTGRESQL_DSN"), reason="DETERMA_POSTGRESQL_DSN is not configured", @@ -180,6 +187,8 @@ def test_postgresql_configured_permanent_strict_profile() -> None: replay_retention="permanent", outbox_retention="strict", ) + assert _STRONG_RETENTION_CAPABILITIES.isdisjoint(store.capabilities) + assert store.checkpoint_retention_mode == "unverified" store.setup_schema() assert { DURABLE_CONCURRENT, @@ -274,6 +283,12 @@ def native_delete(connection, execution) -> None: outbox_retention="strict", ) reopened.validate_schema() + assert { + ROOT_IDENTITY_RETENTION, + PERMANENT_RECEIPT_RETENTION, + PERMANENT_OUTBOX_TERMINAL_RETENTION, + }.issubset(reopened.capabilities) + assert reopened.checkpoint_retention_mode == "permanent" assert reopened.health() == { "healthy": True, "schema_ready": True, @@ -284,14 +299,28 @@ def native_delete(connection, execution) -> None: weaker.validate_schema() assert mismatch.value.code == "execution_store_schema_mismatch" assert weaker.health() == {"healthy": False, "schema_ready": False} + assert _STRONG_RETENTION_CAPABILITIES.isdisjoint(weaker.capabilities) + assert weaker.checkpoint_retention_mode == "unverified" def test_postgresql_health_requires_immutable_policy_and_root_guards() -> None: psycopg = pytest.importorskip("psycopg") - store = _store() + store = PostgreSQLExecutionStore( + os.environ["DETERMA_POSTGRESQL_DSN"], + table_name=f"determa_guard_health_{uuid.uuid4().hex[:16]}", + replay_retention="permanent", + outbox_retention="strict", + ) store.setup_schema() + assert { + ROOT_IDENTITY_RETENTION, + PERMANENT_RECEIPT_RETENTION, + PERMANENT_OUTBOX_TERMINAL_RETENTION, + }.issubset(store.capabilities) with psycopg.connect(store.conninfo) as connection: connection.execute( f"DROP TRIGGER determa_execution_store_immutable ON {store.table_name}" ) assert store.health() == {"healthy": False, "schema_ready": False} + assert _STRONG_RETENTION_CAPABILITIES.isdisjoint(store.capabilities) + assert store.checkpoint_retention_mode == "unverified"