204 lines
6.3 KiB
Python
204 lines
6.3 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from contextvars import ContextVar, Token
|
|
import hashlib
|
|
import json
|
|
from typing import Any
|
|
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
|
|
from govoplan_core.core.recovery import (
|
|
RecoveryGuaranteeError,
|
|
RecoveryMode,
|
|
RecoveryPlan,
|
|
)
|
|
from govoplan_core.core.recovery_runtime import (
|
|
DurableRecoveryOperation,
|
|
RecoveryOperationBusy,
|
|
RecoveryOperationStateConflict,
|
|
begin_durable_recovery_operation,
|
|
)
|
|
from govoplan_core.core.runtime_coordination import process_runtime_identity
|
|
|
|
|
|
class RecordRecoveryError(RuntimeError):
|
|
pass
|
|
|
|
|
|
_recovery_operation_id: ContextVar[str | None] = ContextVar(
|
|
"records_recovery_operation_id", default=None
|
|
)
|
|
|
|
|
|
def bind_record_recovery_operation(operation_id: str) -> Token[str | None]:
|
|
return _recovery_operation_id.set(operation_id)
|
|
|
|
|
|
def current_record_recovery_operation() -> str | None:
|
|
return _recovery_operation_id.get()
|
|
|
|
|
|
def reset_record_recovery_operation(token: Token[str | None]) -> None:
|
|
_recovery_operation_id.reset(token)
|
|
|
|
|
|
def record_session_factory(session: Session) -> sessionmaker[Session]:
|
|
bind = session.get_bind()
|
|
if bind is None:
|
|
raise RecordRecoveryError("Records recovery requires a bound database session.")
|
|
return sessionmaker(bind=bind, expire_on_commit=False)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class RecordAtomicRecovery:
|
|
operation: DurableRecoveryOperation | None
|
|
operation_id: str
|
|
replayed: bool
|
|
|
|
def commit_success(
|
|
self,
|
|
session: Session,
|
|
*,
|
|
result: object,
|
|
resource_id: str,
|
|
) -> None:
|
|
evidence = {
|
|
"verified": True,
|
|
"resource_id": resource_id,
|
|
"result_sha256": _canonical_sha256(result),
|
|
"domain_and_checkpoint_atomic": True,
|
|
"checks": {
|
|
"domain_result_digest_recorded": True,
|
|
"domain_and_checkpoint_atomic": True,
|
|
},
|
|
}
|
|
if self.operation is None:
|
|
session.commit()
|
|
return
|
|
self.operation.commit_atomic_success(session, evidence=evidence)
|
|
|
|
def reject(self, *, summary: str, error_type: str) -> None:
|
|
if self.operation is None:
|
|
return
|
|
self.operation.reject(
|
|
summary=summary,
|
|
evidence={
|
|
"verified": True,
|
|
"domain_mutation_committed": False,
|
|
"error_type": error_type,
|
|
"checks": {"definitive_domain_rejection": True},
|
|
},
|
|
)
|
|
|
|
def fail(self, *, summary: str, error_type: str) -> None:
|
|
if self.operation is None:
|
|
return
|
|
self.operation.fail(
|
|
summary=summary,
|
|
evidence={
|
|
"verified": True,
|
|
"domain_mutation_committed": False,
|
|
"error_type": error_type,
|
|
},
|
|
)
|
|
|
|
|
|
def begin_record_atomic_recovery(
|
|
session: Session,
|
|
*,
|
|
tenant_id: str,
|
|
operation_type: str,
|
|
idempotency_key: str,
|
|
request: dict[str, Any],
|
|
resource_type: str,
|
|
resource_id: str,
|
|
) -> RecordAtomicRecovery:
|
|
operation_key = hashlib.sha256(
|
|
f"{tenant_id}:{operation_type}:{idempotency_key}".encode("utf-8")
|
|
).hexdigest()
|
|
lease_id = hashlib.sha256(
|
|
f"{tenant_id}:{resource_type}:{resource_id}".encode("utf-8")
|
|
).hexdigest()
|
|
try:
|
|
started = begin_durable_recovery_operation(
|
|
record_session_factory(session),
|
|
identity=process_runtime_identity(),
|
|
module_id="records",
|
|
operation_type=operation_type,
|
|
idempotency_key=f"records:{operation_key}",
|
|
request={"tenant_id": tenant_id, **request},
|
|
recovery_plan=RecoveryPlan(
|
|
mode=RecoveryMode.ATOMIC,
|
|
preconditions=(
|
|
"the current actor is authorized for the Records mutation",
|
|
"the expected revision and idempotency key are present",
|
|
"the target resource has no unresolved recovery operation",
|
|
),
|
|
verification_steps=(
|
|
"commit the immutable domain revision and chronology entry",
|
|
"commit the terminal recovery checkpoint in the same transaction",
|
|
"verify the result digest and recovery evidence chain",
|
|
),
|
|
),
|
|
precondition_evidence={
|
|
"tenant_id": tenant_id,
|
|
"resource_type": resource_type,
|
|
"resource_id_sha256": hashlib.sha256(
|
|
resource_id.encode("utf-8")
|
|
).hexdigest(),
|
|
"request_sha256": _canonical_sha256(request),
|
|
"external_effect": False,
|
|
},
|
|
lease_resource_key=f"records:{tenant_id}:{lease_id}",
|
|
lease_ttl_seconds=5 * 60,
|
|
resource_type=resource_type,
|
|
resource_id=resource_id,
|
|
metadata={
|
|
"resources": ["postgresql"],
|
|
"external_effect": False,
|
|
"recovery_declaration": "records-atomic-revision",
|
|
},
|
|
block_unresolved_resource=True,
|
|
)
|
|
except RecoveryOperationBusy as exc:
|
|
raise RecordRecoveryError(
|
|
"Another runtime is changing this Records resource."
|
|
) from exc
|
|
except RecoveryOperationStateConflict as exc:
|
|
raise RecordRecoveryError(
|
|
"This Records resource has an active or unresolved recovery operation."
|
|
) from exc
|
|
except (RecoveryGuaranteeError, RuntimeError) as exc:
|
|
raise RecordRecoveryError(
|
|
"The recovery ledger is unavailable; the Records mutation was not started."
|
|
) from exc
|
|
return RecordAtomicRecovery(
|
|
operation=started.operation,
|
|
operation_id=started.operation_id,
|
|
replayed=started.replayed,
|
|
)
|
|
|
|
|
|
def _canonical_sha256(value: object) -> str:
|
|
return hashlib.sha256(
|
|
json.dumps(
|
|
value,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
ensure_ascii=False,
|
|
default=str,
|
|
).encode("utf-8")
|
|
).hexdigest()
|
|
|
|
|
|
__all__ = [
|
|
"RecordAtomicRecovery",
|
|
"RecordRecoveryError",
|
|
"begin_record_atomic_recovery",
|
|
"bind_record_recovery_operation",
|
|
"current_record_recovery_operation",
|
|
"record_session_factory",
|
|
"reset_record_recovery_operation",
|
|
]
|