diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dd5adad..cddbbf3 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: 86cb08a98267371b96b8f4908409aee022e4b4fe 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..7af0a41 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: `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. ## 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,13 @@ 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. +- 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 @@ -60,6 +71,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..1943395 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 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. @@ -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,83 @@ 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 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. 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. Strong retention +capabilities are withheld before schema setup and whenever policy or guard validation +fails: + +```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. -## Implemented Core +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, 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 +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 +239,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..2f096b0 --- /dev/null +++ b/conformance/execution_checkpoint.py @@ -0,0 +1,529 @@ +"""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, + MemoryArtifactResolver, + MemoryExecutionStore, + load_bundle, + register_bundled_execution_stores, + restore_execution_checkpoint, + serialize_execution_checkpoint, +) + +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], 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, + ) -> Any: + del root_instance_id + 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": + ExecutionHost( + _StaticStore(capabilities, vector["checkpoint_retention_mode"]), + MemoryArtifactResolver(), + required_capabilities=requested, + profile=vector["host_profile"], + 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": + 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"} + + uri = { + "memory": "memory:", + "file": "file:///tmp/unused", + "sqlite": "sqlite:///tmp/unused.sqlite", + "postgresql": "postgresql://unused", + }.get(identifier, f"{identifier}:") + 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)) + 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"} + + +def _expected_response( + vector: dict[str, Any], + checkpoint: dict[str, Any] | None, + request: dict[str, Any], +) -> dict[str, Any]: + expected = vector["expect"] + 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: + 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 == _expected_response(vector, None, {}) + 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 + 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 + + +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..d0ac803 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 = "86cb08a98267371b96b8f4908409aee022e4b4fe" +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..cf25110 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()) == 85 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..db8c3c2 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,18 @@ SchemaError, ValidationError, ) +from .host import ( + ExecutionHost, + ExecutionHostError, + SharedExecutionTransaction, + StagedExecutionResult, + 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 +44,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 +92,76 @@ "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", + "SharedExecutionTransaction", + "SQLiteExecutionStore", + "StagedExecutionResult", "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..de034fe --- /dev/null +++ b/src/determa/state/checkpoint.py @@ -0,0 +1,537 @@ +"""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") +_MAX_DECIMAL_DIGITS = 4096 + + +@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 len(value) > _MAX_DECIMAL_DIGITS + or _DECIMAL.fullmatch(value) is None + ): + raise _invalid() + try: + return int(value) + except ValueError as exc: + raise _invalid() from exc + + +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() + + 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": + if status != root_record["terminal_status"]: + raise _invalid() + 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() + + +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..80f6822 --- /dev/null +++ b/src/determa/state/host.py @@ -0,0 +1,1768 @@ +"""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 dataclasses import dataclass +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] +_MAX_DECIMAL_DIGITS = 4096 + + +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 _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, + 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( + store: ExecutionStore, + profile: 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) + ) + 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") + + +@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: + 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"] = _increment_checkpoint_number( + sequence + ) + 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"] = _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"] = _checkpoint_number( + target["component"]["activation_sequence"] + ) + elif "spawned_instance" in target: + target["spawned_instance"]["machine_version"] = _checkpoint_number( + 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, + *, + 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( + cls, + uri: str, + artifact_resolver: ArtifactResolver, + registry: ExecutionStoreRegistry, + *, + 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( + uri, + configuration=configuration, + required_capabilities=required_capabilities, + ) + 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) -> None: + if self._bound_transaction is None: + self._fault("after_commit_before_response") + + 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, + ) -> Any: + 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, + 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, + ) -> RestoredExecutionCheckpoint | None: + with self._transaction(root_instance_id) as transaction: + source = transaction.load() + return ( + None + if source is None + else self._restore(source, root_instance_id) + ) + + def create( + self, + bundle: Bundle | BundleSource, + machine_id: str, + root_instance_id: str, + creation_id: str, + bindings: Mapping[str, Mapping[str, Any]] | 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) as transaction: + source = transaction.load() + if source is not None: + checkpoint = self._restore(source, root_instance_id).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() + return {"result": "committed", "receipt": receipt} + + def _delivery_candidate( + self, candidate: Any + ) -> tuple[str | None, str | None, Any, 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", + } + 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["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(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 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 isinstance(origin, Mapping) + and origin.get("kind") == "internal_emission" + ) + 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") + 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, + ) -> dict[str, Any]: + 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, root_instance_id).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"] = _increment_checkpoint_number( + sequence + ) + 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() + 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"] = _increment_checkpoint_number( + delivery_sequence + ) + 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"] = ( + _increment_checkpoint_number(receipt_sequence) + ) + 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, + ) -> dict[str, Any]: + with self._transaction(root_instance_id) as transaction: + source = transaction.load() + if source is None: + raise ExecutionHostError("wrong_root") + restored = self._restore(source, root_instance_id) + 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() + return response + + def foreground_process_delivery( + self, + root_instance_id: str, + candidate: Any, + *, + expected_revision: str, + expected_checkpoint_digest: str, + ) -> dict[str, Any]: + with self._transaction(root_instance_id) as transaction: + source = transaction.load() + if source is None: + raise ExecutionHostError("wrong_root") + restored = self._restore(source, root_instance_id) + 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() + return response + + def maintenance_migration( + self, + root_instance_id: str, + 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, + ) -> dict[str, Any]: + if ( + not operation_id + or not validate_execution_checkpoint_member( + "sha256", source_aggregate_state_digest + ) + ): + raise ExecutionHostError("invalid_migration_request") + 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, root_instance_id) + checkpoint = restored.document + 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 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 + ) + 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"] = ( + _increment_checkpoint_number(receipt_sequence) + ) + 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_aggregate_state_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() + 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, + ) -> 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) as transaction: + source = transaction.load() + if source is None: + raise ExecutionHostError("wrong_root") + checkpoint = self._restore(source, root_instance_id).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() + 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, + ) -> 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) as transaction: + source = transaction.load() + if source is None: + raise ExecutionHostError("wrong_root") + 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: + 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"] = ( + _increment_checkpoint_number(terminal_sequence) + ) + 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() + return response + + def compact_outbox( + self, + root_instance_id: str, + effect_id: str, + *, + expected_revision: str, + expected_checkpoint_digest: str, + ) -> dict[str, Any]: + 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, root_instance_id).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: _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() + return response + + def delete_outbox_record( + self, + root_instance_id: str, + effect_id: str, + *, + expected_revision: str, + expected_checkpoint_digest: str, + ) -> dict[str, Any]: + 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, root_instance_id).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() + 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, + ) -> dict[str, Any]: + target = copy.deepcopy(dict(target_replay_retention)) + if not validate_execution_checkpoint_member("replayRetention", target): + raise ExecutionHostError("invalid_execution_checkpoint") + 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, root_instance_id).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 _checkpoint_number(target_cutoff) + < _checkpoint_number(current_cutoff) + ) + ): + raise ExecutionHostError("invalid_execution_checkpoint") + 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( + checkpoint, expected_revision, expected_checkpoint_digest + ) + candidate = _mutate(checkpoint) + candidate["replay_retention"] = target + if target_cutoff is not None: + cutoff = _checkpoint_number(target_cutoff) + candidate["operation_receipts"] = [ + receipt + for receipt in candidate["operation_receipts"] + if receipt["receipt_sequence"] == "0" + or _checkpoint_number(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() + return response + + def tombstone_root( + self, + root_instance_id: str, + operation_id: str, + *, + expected_revision: str, + expected_checkpoint_digest: str, + ) -> dict[str, Any]: + if not operation_id: + raise ExecutionHostError("invalid_execution_checkpoint") + with self._transaction(root_instance_id) as transaction: + source = transaction.load() + if source is None: + raise ExecutionHostError("wrong_root") + restored = self._restore(source, root_instance_id) + 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() + 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"}, + } + + +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/__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..8266df7 --- /dev/null +++ b/src/determa/state/stores/base.py @@ -0,0 +1,128 @@ +"""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.""" + + @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.""" + + @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.""" + + @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, + ) -> 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, 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 all( + isinstance(value, str) + for value in (root_instance_id, revision, digest) + ): + raise ExecutionStoreError("invalid_execution_checkpoint") + return root_instance_id, revision, digest diff --git a/src/determa/state/stores/file.py b/src/determa/state/stores/file.py new file mode 100644 index 0000000..0f4270c --- /dev/null +++ b/src/determa/state/stores/file.py @@ -0,0 +1,184 @@ +"""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, 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: + self._current = checkpoint_path.read_bytes() + except FileNotFoundError: + 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) + return True + + def replace( + self, + expected_revision: str, + expected_checkpoint_digest: str, + checkpoint: bytes, + ) -> bool: + if self._current is None: + return False + 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, + ): + 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, + ) -> Iterator[ExecutionStoreTransaction]: + 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(root_instance_id, 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..fc9176f --- /dev/null +++ b/src/determa/state/stores/memory.py @@ -0,0 +1,112 @@ +"""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, + ExecutionStoreError, + 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 + + @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) + return True + + def replace( + self, + expected_revision: str, + expected_checkpoint_digest: str, + checkpoint: bytes, + ) -> bool: + if self._current is None: + return False + 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, + ): + 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, + ) -> Iterator[ExecutionStoreTransaction]: + 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..6a0c996 --- /dev/null +++ b/src/determa/state/stores/postgresql.py @@ -0,0 +1,455 @@ +"""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 ( + COMPACT_EFFECT_IDENTITY_RETENTION, + DURABLE_CONCURRENT, + PERMANENT_OUTBOX_TERMINAL_RETENTION, + PERMANENT_RECEIPT_RETENTION, + ROOT_IDENTITY_RETENTION, + SHARED_APPLICATION_TRANSACTION, + ExecutionStore, + ExecutionStoreError, + ExecutionStoreTransaction, + checkpoint_metadata, +) + +_IDENTIFIER = re.compile(r"[a-z_][a-z0-9_]*\Z") +_REPLAY_RETENTION_MODES = {"bounded", "permanent"} +_OUTBOX_RETENTION_MODES = {"none", "strict", "compact"} +_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: + try: + return import_module("psycopg") + except ImportError as exc: + 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 + ) -> None: + self._connection = connection + 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""" + 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: + 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} + (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: + 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} + 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 host-owned shared transactions.""" + + def __init__( + self, + conninfo: str, + *, + table_name: str = "determa_execution_checkpoints", + replay_retention: str = "bounded", + 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 + ): + raise ExecutionStoreError("invalid_adapter_configuration") + 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 + + @property + def capabilities(self) -> frozenset[str]: + capabilities = { + DURABLE_CONCURRENT, + SHARED_APPLICATION_TRANSACTION, + } + 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": + 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 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 [ + (_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)", + (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() + expected_trigger_type = 11 if table_name == self.table_name else 27 + triggers = connection.execute( + """ + SELECT tgname, tgtype, tgenabled, + tgfoid = to_regprocedure(%s) + FROM pg_trigger + WHERE tgrelid = to_regclass(%s) AND NOT tgisinternal + ORDER BY tgname + """, + (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 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") + + 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_value", "text", "NO", None), + ], + ) + rows = connection.execute( + 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 != self._metadata_rows(): + raise ExecutionStoreError("execution_store_schema_mismatch") + self._validate_guard_function(connection) + + 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, + ) -> Iterator[ExecutionStoreTransaction]: + psycopg = _psycopg() + with psycopg.connect(self.conninfo) as connection: + self._validate_schema(connection) + yield _PostgreSQLTransaction( + connection, self.table_name, root_instance_id + ) + + @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) 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_value TEXT 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 + ) + """ + ) + 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""" + 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) + + def health(self) -> Mapping[str, Any]: + try: + self.validate_schema() + except Exception: + return {"healthy": False, "schema_ready": False} + return { + "healthy": True, + "schema_ready": True, + "schema_version": _SCHEMA_VERSION, + } + + +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", + "replay_retention", + "outbox_retention", + }: + raise ExecutionStoreError("invalid_adapter_configuration") + table_name = configuration.get( + "table_name", "determa_execution_checkpoints" + ) + 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, + replay_retention=replay_retention, + outbox_retention=outbox_retention, + ) diff --git a/src/determa/state/stores/registry.py b/src/determa/state/stores/registry.py new file mode 100644 index 0000000..d646f8f --- /dev/null +++ b/src/determa/state/stores/registry.py @@ -0,0 +1,84 @@ +"""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 required_capabilities and 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..c5d475f --- /dev/null +++ b/src/determa/state/stores/sqlite.py @@ -0,0 +1,499 @@ +"""Explicit-schema SQLite execution store.""" + +from __future__ import annotations + +import re +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 ( + COMPACT_EFFECT_IDENTITY_RETENTION, + DURABLE_SINGLE_WRITER, + PERMANENT_OUTBOX_TERMINAL_RETENTION, + PERMANENT_RECEIPT_RETENTION, + ROOT_IDENTITY_RETENTION, + ExecutionStore, + ExecutionStoreError, + ExecutionStoreTransaction, + checkpoint_metadata, +) + +_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_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]: + return re.findall(r"[a-z_][a-z0-9_]*|[(),]", source.lower()) + + +class _SQLiteTransaction(ExecutionStoreTransaction): + def __init__( + self, connection: sqlite3.Connection, root_instance_id: str + ) -> None: + 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 = ?", + (self._root_instance_id,), + ).fetchone() + return None if row is None else bytes(row[0]) + + def insert(self, checkpoint: bytes) -> bool: + 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""" + 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: + 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} + 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, + 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]: + 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": + 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 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 [ + (_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 + ) + 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 + + 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() + 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 = { + 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_value", "TEXT", 1, None, 0, 0), + ], + f""" + CREATE TABLE {_METADATA_TABLE} ( + schema_key TEXT PRIMARY KEY NOT NULL, + schema_value TEXT NOT NULL + ) + """, + ) + rows = connection.execute( + f"SELECT schema_key, schema_value FROM {_METADATA_TABLE} " + "ORDER BY schema_key" + ).fetchall() + if rows != self._metadata_rows(): + raise ExecutionStoreError("execution_store_schema_mismatch") + self._validate_triggers(connection) + + def validate_schema(self) -> None: + connection = self._connect() + try: + self._validate_schema(connection) + finally: + connection.close() + + @contextmanager + def transaction( + self, + root_instance_id: str, + ) -> Iterator[ExecutionStoreTransaction]: + 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: + connection.rollback() + raise + except BaseException: + connection.rollback() + raise + finally: + connection.close() + + def setup_schema(self) -> None: + connection = self._connect() + try: + connection.execute("BEGIN IMMEDIATE") + 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_value TEXT 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.executemany( + f"INSERT INTO {_METADATA_TABLE} (schema_key, schema_value) " + "VALUES (?, ?)", + 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: + connection.rollback() + raise + finally: + connection.close() + + def health(self) -> Mapping[str, Any]: + try: + connection = self._connect() + try: + self._validate_schema(connection) + finally: + connection.close() + except (OSError, sqlite3.Error, ExecutionStoreError): + return {"healthy": False, "schema_ready": False} + return { + "healthy": True, + "schema_ready": True, + "schema_version": _SCHEMA_VERSION, + } + + +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", + "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: + raise ExecutionStoreError("invalid_adapter_configuration") from exc + return SQLiteExecutionStore( + path, + 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 63509d6..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: @@ -311,6 +321,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 +337,7 @@ def _schema_registry() -> Any: "aggregate_state", "migration_descriptor", "aggregate_state_package", + "execution_checkpoint", ): document = artifact_schema(kind) registry = registry.with_resource( @@ -362,6 +374,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 +404,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 +420,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..3a1aea1 --- /dev/null +++ b/tests/test_checkpoint_host.py @@ -0,0 +1,691 @@ +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 +namespace: test.execution_checkpoint +events: + increment: + direction: input + payload: + amount: { type: int, required: true } + deliberate_fault: { direction: input } +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" } + 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 } +""" + + +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 + + +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") + + 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 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: + 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_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) + 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 + + +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_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..0f7c9a6 --- /dev/null +++ b/tests/test_execution_stores.py @@ -0,0 +1,531 @@ +from __future__ import annotations + +import sqlite3 +from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +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, + ExecutionStoreError, + ExecutionStoreRegistry, + FileExecutionStore, + MemoryArtifactResolver, + MemoryExecutionStore, + PostgreSQLExecutionStore, + SQLiteExecutionStore, + bundled_execution_store_registry, + load_bundle, + portable_envelope, +) + +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) + 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() + 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 + replay = host.create( + load_bundle(MACHINE), "counter", "root", "root-create", {} + ) + 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", + [ + 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 + configured_sqlite = registry.resolve( + 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, + }.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" + ) + 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={ + "replay_retention": "permanent", + "outbox_retention": "compact", + }, + ) + 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: + 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" + + +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_value TEXT NOT NULL)" + ) + connection.execute( + "INSERT INTO determa_execution_store_metadata VALUES " + "('execution_checkpoint_schema_version', '2')" + ) + 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( + "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() + assert version_error.value.code == "execution_store_schema_mismatch" + 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 { + 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, + "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} + 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, + 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: + 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 new file mode 100644 index 0000000..6e7382a --- /dev/null +++ b/tests/test_postgresql_store.py @@ -0,0 +1,326 @@ +from __future__ import annotations + +import os +import uuid +from concurrent.futures import ThreadPoolExecutor + +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, +) + +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", +) + + +def _store() -> PostgreSQLExecutionStore: + pytest.importorskip("psycopg") + return PostgreSQLExecutionStore( + os.environ["DETERMA_POSTGRESQL_DSN"], + table_name=f"determa_checkpoint_test_{uuid.uuid4().hex[:16]}", + ) + + +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"] + + application_table = f"determa_application_test_{uuid.uuid4().hex}" + with psycopg.connect(store.conninfo) as connection: + 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", + ) + assert _STRONG_RETENTION_CAPABILITIES.isdisjoint(store.capabilities) + assert store.checkpoint_retention_mode == "unverified" + 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", + }, + ) + + +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 { + 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, + "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} + 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 = 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"