feat(records): govern lifecycle recovery and transfer evidence
This commit is contained in:
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "assisted-service-to-decision-record",
|
||||
"description": "A completed assisted service is filed, closed, held, appraised, independently approved, and exercised through the archive simulation boundary.",
|
||||
"record": {
|
||||
"record_id": "record-1",
|
||||
"record_number": "2026/0001",
|
||||
"class_id": "class-permit"
|
||||
},
|
||||
"expected": {
|
||||
"closed_state": "retention_running",
|
||||
"appraised_state": "appraised",
|
||||
"approved_state": "transfer_pending",
|
||||
"hold_blocks_disposition": true,
|
||||
"disposition_status": "approved",
|
||||
"transfer_status": "simulated_accepted",
|
||||
"custody_transferred": false
|
||||
}
|
||||
}
|
||||
@@ -30,9 +30,12 @@ class RecordsMigrationTests(unittest.TestCase):
|
||||
"record_chronology_entries",
|
||||
"record_class_revisions",
|
||||
"record_file_plan_revisions",
|
||||
"record_hold_revisions",
|
||||
"record_identities",
|
||||
"record_items",
|
||||
"record_disposition_revisions",
|
||||
"record_revisions",
|
||||
"record_transfer_package_revisions",
|
||||
"record_volume_revisions",
|
||||
}.issubset(inspector.get_table_names())
|
||||
)
|
||||
@@ -42,7 +45,7 @@ class RecordsMigrationTests(unittest.TestCase):
|
||||
)
|
||||
with engine.connect() as connection:
|
||||
self.assertIn(
|
||||
"6e4a2c8f1d9b",
|
||||
"7f5b3d9a2c1e",
|
||||
set(MigrationContext.configure(connection).get_current_heads()),
|
||||
)
|
||||
finally:
|
||||
|
||||
+416
-5
@@ -2,16 +2,22 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.records import (
|
||||
RecordArchiveProviderState,
|
||||
RecordArchiveReceipt,
|
||||
RecordFilingRequest,
|
||||
RecordSourceLocator,
|
||||
RecordSourceReference,
|
||||
)
|
||||
from govoplan_core.core.recovery import RecoveryCheckpoint, RecoveryOperation
|
||||
from govoplan_core.core.approvals import ApprovalCheck, ApprovalRequestRef
|
||||
from govoplan_core.core.temporal import (
|
||||
TemporalDataContext,
|
||||
bind_temporal_data_context,
|
||||
@@ -20,12 +26,16 @@ from govoplan_core.core.temporal import (
|
||||
from govoplan_records.backend.db.models import (
|
||||
RecordChronologyEntry,
|
||||
RecordClassRevision,
|
||||
RecordDispositionRevision,
|
||||
RecordFilePlanRevision,
|
||||
RecordHoldRevision,
|
||||
RecordIdentity,
|
||||
RecordItem,
|
||||
RecordRevision,
|
||||
RecordTransferPackageRevision,
|
||||
RecordVolumeRevision,
|
||||
)
|
||||
from govoplan_records.backend.archive import SimulatedRecordArchiveProvider
|
||||
from govoplan_records.backend.service import RecordConflictError, SqlRecordRegistry
|
||||
|
||||
|
||||
@@ -63,21 +73,114 @@ class SourceProvider:
|
||||
)
|
||||
|
||||
|
||||
class UnknownOutcomeArchiveProvider:
|
||||
provider_id = "unknown_simulation"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.dispatch_count = 0
|
||||
|
||||
def state(self):
|
||||
return RecordArchiveProviderState(
|
||||
provider_id=self.provider_id,
|
||||
label="Unknown-outcome simulation",
|
||||
profiles=("govoplan-unknown-outcome-v1",),
|
||||
authority_modes=("linked_reference",),
|
||||
healthy=True,
|
||||
checked_at=NOW,
|
||||
simulated=True,
|
||||
)
|
||||
|
||||
def dispatch(self, session, principal, *, request):
|
||||
del session, principal
|
||||
self.dispatch_count += 1
|
||||
return RecordArchiveReceipt(
|
||||
provider_id=self.provider_id,
|
||||
package_id=request.package.package_id,
|
||||
outcome="outcome_unknown",
|
||||
observed_at=NOW + timedelta(minutes=12),
|
||||
receipt_sha256="b" * 64,
|
||||
retry_safe=False,
|
||||
simulated=True,
|
||||
metadata={"custody_transferred": False},
|
||||
)
|
||||
|
||||
|
||||
class Registry:
|
||||
def __init__(self) -> None:
|
||||
self.provider = SourceProvider()
|
||||
self.archive_provider = SimulatedRecordArchiveProvider()
|
||||
self.unknown_archive_provider = UnknownOutcomeArchiveProvider()
|
||||
self.approvals = ApprovalProvider()
|
||||
|
||||
def capability_names(self):
|
||||
return ("records.source.files",)
|
||||
return (
|
||||
"records.source.files",
|
||||
"records.archive.simulation",
|
||||
"records.archive.unknown_simulation",
|
||||
)
|
||||
|
||||
def tenant_capability(self, name, session, *, tenant_id):
|
||||
del session
|
||||
return (
|
||||
self.provider
|
||||
if name == "records.source.files" and tenant_id == "tenant-1"
|
||||
else None
|
||||
if tenant_id != "tenant-1":
|
||||
return None
|
||||
return {
|
||||
"records.source.files": self.provider,
|
||||
"records.archive.simulation": self.archive_provider,
|
||||
"records.archive.unknown_simulation": self.unknown_archive_provider,
|
||||
"approvals.requests": self.approvals,
|
||||
}.get(name)
|
||||
|
||||
|
||||
class ApprovalProvider:
|
||||
def __init__(self) -> None:
|
||||
self.approved = False
|
||||
self.request = None
|
||||
|
||||
def create_request(self, session, principal, *, command, idempotency_key):
|
||||
del session, principal, idempotency_key
|
||||
self.request = command
|
||||
return ApprovalRequestRef(id="approval-1", revision=1, state="pending")
|
||||
|
||||
def check_approved(
|
||||
self,
|
||||
session,
|
||||
principal,
|
||||
*,
|
||||
request_id,
|
||||
subject_module,
|
||||
subject_type,
|
||||
subject_id,
|
||||
subject_version,
|
||||
subject_digest,
|
||||
):
|
||||
del session, principal
|
||||
return ApprovalCheck(
|
||||
request_id=request_id,
|
||||
revision=2 if self.approved else 1,
|
||||
state="approved" if self.approved else "pending",
|
||||
approved=self.approved,
|
||||
subject_module=subject_module,
|
||||
subject_type=subject_type,
|
||||
subject_id=subject_id,
|
||||
subject_version=subject_version,
|
||||
subject_digest=subject_digest,
|
||||
)
|
||||
|
||||
def create_template(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def revise_template(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def publish_template(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def get_request(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
def decide(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class RecordsTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
@@ -90,6 +193,11 @@ class RecordsTests(unittest.TestCase):
|
||||
RecordVolumeRevision.__table__,
|
||||
RecordItem.__table__,
|
||||
RecordChronologyEntry.__table__,
|
||||
RecordHoldRevision.__table__,
|
||||
RecordDispositionRevision.__table__,
|
||||
RecordTransferPackageRevision.__table__,
|
||||
RecoveryOperation.__table__,
|
||||
RecoveryCheckpoint.__table__,
|
||||
):
|
||||
table.create(self.engine)
|
||||
self.session = Session(self.engine)
|
||||
@@ -400,6 +508,309 @@ class RecordsTests(unittest.TestCase):
|
||||
self.records.source_providers(self.session, self.principal),
|
||||
)
|
||||
|
||||
def test_governed_lifecycle_hold_disposition_and_transfer_simulation(self) -> None:
|
||||
fixture = json.loads(
|
||||
(
|
||||
Path(__file__).parent / "fixtures/service_to_decision_journey.json"
|
||||
).read_text(encoding="utf-8")
|
||||
)
|
||||
expected = fixture["expected"]
|
||||
self._create_record()
|
||||
closed = self.records.close_record(
|
||||
self.session,
|
||||
self.principal,
|
||||
record_id="record-1",
|
||||
payload={
|
||||
"expected_revision": 1,
|
||||
"purpose": "close completed case record",
|
||||
"reason": "The administrative decision is final.",
|
||||
"recorded_at": NOW + timedelta(minutes=2),
|
||||
"idempotency_key": "record-close-1",
|
||||
},
|
||||
)
|
||||
self.assertEqual(expected["closed_state"], closed["state"])
|
||||
self.assertEqual(
|
||||
NOW + timedelta(minutes=2, days=3650),
|
||||
datetime.fromisoformat(str(closed["retention_due_at"])),
|
||||
)
|
||||
appraised = self.records.appraise_record(
|
||||
self.session,
|
||||
self.principal,
|
||||
record_id="record-1",
|
||||
payload={
|
||||
"expected_revision": 2,
|
||||
"outcome": "transfer",
|
||||
"purpose": "appraise completed record",
|
||||
"reason": "Transfer to the institutional archive after review.",
|
||||
"policy_refs": ["records-policy:v1"],
|
||||
"override_retention_not_due": True,
|
||||
"recorded_at": NOW + timedelta(minutes=3),
|
||||
"idempotency_key": "record-appraise-1",
|
||||
},
|
||||
)
|
||||
self.assertEqual(expected["appraised_state"], appraised["state"])
|
||||
hold = self.records.apply_hold(
|
||||
self.session,
|
||||
self.principal,
|
||||
record_id="record-1",
|
||||
payload={
|
||||
"expected_record_revision": 3,
|
||||
"reason": "Pending judicial review.",
|
||||
"authority": "Court order 2026-17",
|
||||
"purpose": "preserve evidence",
|
||||
"recorded_at": NOW + timedelta(minutes=4),
|
||||
"idempotency_key": "record-hold-1",
|
||||
},
|
||||
)
|
||||
with self.assertRaisesRegex(RecordConflictError, "active record hold"):
|
||||
self.records.propose_disposition(
|
||||
self.session,
|
||||
self.principal,
|
||||
record_id="record-1",
|
||||
payload={
|
||||
"expected_record_revision": 3,
|
||||
"action": "transfer",
|
||||
"reason": "Transfer after retention.",
|
||||
"purpose": "dispose record",
|
||||
"recorded_at": NOW + timedelta(minutes=5),
|
||||
"idempotency_key": "record-disposition-blocked",
|
||||
},
|
||||
)
|
||||
self.records.release_hold(
|
||||
self.session,
|
||||
self.principal,
|
||||
record_id="record-1",
|
||||
hold_id=str(hold["hold_id"]),
|
||||
payload={
|
||||
"expected_hold_revision": 1,
|
||||
"reason": "Judicial review is complete.",
|
||||
"purpose": "resume disposition",
|
||||
"recorded_at": NOW + timedelta(minutes=6),
|
||||
"idempotency_key": "record-hold-release-1",
|
||||
},
|
||||
)
|
||||
disposition = self.records.propose_disposition(
|
||||
self.session,
|
||||
self.principal,
|
||||
record_id="record-1",
|
||||
payload={
|
||||
"disposition_id": "disposition-1",
|
||||
"expected_record_revision": 3,
|
||||
"action": "transfer",
|
||||
"reason": "Transfer after independent review.",
|
||||
"purpose": "dispose record",
|
||||
"policy_refs": ["records-policy:v1"],
|
||||
"recorded_at": NOW + timedelta(minutes=7),
|
||||
"idempotency_key": "record-disposition-1",
|
||||
},
|
||||
)
|
||||
self.assertEqual("review_pending", disposition["status"])
|
||||
self.assertFalse(disposition["consequence_preview"]["external_effect"])
|
||||
self.records.registry.approvals.approved = True
|
||||
finalized = self.records.finalize_disposition(
|
||||
self.session,
|
||||
self.principal,
|
||||
record_id="record-1",
|
||||
disposition_id="disposition-1",
|
||||
payload={
|
||||
"expected_disposition_revision": 1,
|
||||
"purpose": "approve disposition",
|
||||
"recorded_at": NOW + timedelta(minutes=8),
|
||||
"idempotency_key": "record-disposition-finalize-1",
|
||||
},
|
||||
)
|
||||
self.assertEqual(expected["approved_state"], finalized["record"]["state"])
|
||||
package = self.records.prepare_transfer_package(
|
||||
self.session,
|
||||
self.principal,
|
||||
record_id="record-1",
|
||||
payload={
|
||||
"package_id": "package-1",
|
||||
"disposition_id": "disposition-1",
|
||||
"expected_record_revision": 4,
|
||||
"provider_id": "simulation",
|
||||
"profile": "govoplan-simulation-v1",
|
||||
"purpose": "validate archive transfer",
|
||||
"recorded_at": NOW + timedelta(minutes=9),
|
||||
"idempotency_key": "record-package-1",
|
||||
},
|
||||
)
|
||||
self.assertEqual("prepared", package["status"])
|
||||
receipt = self.records.dispatch_transfer_package(
|
||||
self.session,
|
||||
self.principal,
|
||||
record_id="record-1",
|
||||
package_id="package-1",
|
||||
payload={
|
||||
"expected_package_revision": 1,
|
||||
"purpose": "validate archive transfer",
|
||||
"recorded_at": NOW + timedelta(minutes=10),
|
||||
"idempotency_key": "record-package-dispatch-1",
|
||||
},
|
||||
)
|
||||
self.session.commit()
|
||||
self.assertEqual(expected["transfer_status"], receipt["status"])
|
||||
self.assertTrue(receipt["simulated"])
|
||||
self.assertEqual(
|
||||
expected["custody_transferred"],
|
||||
receipt["receipt"]["metadata"]["custody_transferred"],
|
||||
)
|
||||
|
||||
detail = self.records.get_record(
|
||||
self.session, self.principal, record_id="record-1"
|
||||
)
|
||||
self.assertEqual("released", detail["holds"][0]["status"])
|
||||
self.assertEqual(
|
||||
expected["disposition_status"], detail["dispositions"][0]["status"]
|
||||
)
|
||||
self.assertEqual("simulated_accepted", detail["transfer_packages"][0]["status"])
|
||||
|
||||
unknown_package = self.records.prepare_transfer_package(
|
||||
self.session,
|
||||
self.principal,
|
||||
record_id="record-1",
|
||||
payload={
|
||||
"package_id": "package-unknown",
|
||||
"disposition_id": "disposition-1",
|
||||
"expected_record_revision": 4,
|
||||
"provider_id": "unknown_simulation",
|
||||
"profile": "govoplan-unknown-outcome-v1",
|
||||
"purpose": "exercise unknown transfer recovery",
|
||||
"recorded_at": NOW + timedelta(minutes=11),
|
||||
"idempotency_key": "record-package-unknown",
|
||||
},
|
||||
)
|
||||
unknown_receipt = self.records.dispatch_transfer_package(
|
||||
self.session,
|
||||
self.principal,
|
||||
record_id="record-1",
|
||||
package_id="package-unknown",
|
||||
payload={
|
||||
"expected_package_revision": unknown_package["revision"],
|
||||
"purpose": "exercise unknown transfer recovery",
|
||||
"recorded_at": NOW + timedelta(minutes=12),
|
||||
"idempotency_key": "record-package-unknown-dispatch",
|
||||
},
|
||||
)
|
||||
self.assertEqual("outcome_unknown", unknown_receipt["status"])
|
||||
self.assertFalse(unknown_receipt["receipt"]["retry_safe"])
|
||||
with self.assertRaisesRegex(
|
||||
ValueError, "unknown outcomes require reconciliation"
|
||||
):
|
||||
self.records.dispatch_transfer_package(
|
||||
self.session,
|
||||
self.principal,
|
||||
record_id="record-1",
|
||||
package_id="package-unknown",
|
||||
payload={
|
||||
"expected_package_revision": 2,
|
||||
"purpose": "must not retry unknown transfer",
|
||||
"recorded_at": NOW + timedelta(minutes=13),
|
||||
"idempotency_key": "record-package-unknown-retry",
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
1, self.records.registry.unknown_archive_provider.dispatch_count
|
||||
)
|
||||
self.assertEqual(
|
||||
"transfer_pending",
|
||||
self.records.get_record(self.session, self.principal, record_id="record-1")[
|
||||
"record"
|
||||
]["state"],
|
||||
)
|
||||
recovery = self.records.recovery_status(
|
||||
self.session, self.principal, record_id="record-1"
|
||||
)
|
||||
self.assertTrue(recovery["healthy"])
|
||||
self.assertTrue(recovery["package_checks"][0]["manifest_verified"])
|
||||
|
||||
def test_disposition_waits_for_optional_approvals_and_can_be_withdrawn(
|
||||
self,
|
||||
) -> None:
|
||||
self.records.registry.approvals = None
|
||||
self._create_record()
|
||||
self.records.close_record(
|
||||
self.session,
|
||||
self.principal,
|
||||
record_id="record-1",
|
||||
payload={
|
||||
"expected_revision": 1,
|
||||
"purpose": "close record",
|
||||
"reason": "Work completed.",
|
||||
"recorded_at": NOW + timedelta(minutes=2),
|
||||
"idempotency_key": "close-without-approvals",
|
||||
},
|
||||
)
|
||||
self.records.appraise_record(
|
||||
self.session,
|
||||
self.principal,
|
||||
record_id="record-1",
|
||||
payload={
|
||||
"expected_revision": 2,
|
||||
"outcome": "retain",
|
||||
"purpose": "appraise record",
|
||||
"reason": "Retain permanently.",
|
||||
"override_retention_not_due": True,
|
||||
"recorded_at": NOW + timedelta(minutes=3),
|
||||
"idempotency_key": "appraise-without-approvals",
|
||||
},
|
||||
)
|
||||
proposal = self.records.propose_disposition(
|
||||
self.session,
|
||||
self.principal,
|
||||
record_id="record-1",
|
||||
payload={
|
||||
"expected_record_revision": 3,
|
||||
"action": "retain",
|
||||
"purpose": "dispose record",
|
||||
"reason": "Retain permanently.",
|
||||
"recorded_at": NOW + timedelta(minutes=4),
|
||||
"idempotency_key": "proposal-without-approvals",
|
||||
},
|
||||
)
|
||||
self.assertEqual("review_unavailable", proposal["status"])
|
||||
with self.assertRaisesRegex(ValueError, "not awaiting an available approval"):
|
||||
self.records.finalize_disposition(
|
||||
self.session,
|
||||
self.principal,
|
||||
record_id="record-1",
|
||||
disposition_id=str(proposal["disposition_id"]),
|
||||
payload={
|
||||
"expected_disposition_revision": 1,
|
||||
"purpose": "finalize disposition",
|
||||
"recorded_at": NOW + timedelta(minutes=5),
|
||||
"idempotency_key": "finalize-without-approvals",
|
||||
},
|
||||
)
|
||||
withdrawn = self.records.withdraw_disposition(
|
||||
self.session,
|
||||
self.principal,
|
||||
record_id="record-1",
|
||||
disposition_id=str(proposal["disposition_id"]),
|
||||
payload={
|
||||
"expected_disposition_revision": 1,
|
||||
"purpose": "correct disposition",
|
||||
"reason": "The appraisal evidence needs correction.",
|
||||
"recorded_at": NOW + timedelta(minutes=6),
|
||||
"idempotency_key": "withdraw-without-approvals",
|
||||
},
|
||||
)
|
||||
self.assertEqual("withdrawn", withdrawn["status"])
|
||||
replacement = self.records.propose_disposition(
|
||||
self.session,
|
||||
self.principal,
|
||||
record_id="record-1",
|
||||
payload={
|
||||
"expected_record_revision": 3,
|
||||
"action": "retain",
|
||||
"purpose": "dispose record",
|
||||
"reason": "Corrected permanent-retention proposal.",
|
||||
"recorded_at": NOW + timedelta(minutes=7),
|
||||
"idempotency_key": "replacement-without-approvals",
|
||||
},
|
||||
)
|
||||
self.assertEqual("review_unavailable", replacement["status"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.recovery import (
|
||||
RecoveryCheckpoint,
|
||||
RecoveryOperation,
|
||||
RecoveryStatus,
|
||||
verify_recovery_evidence_chain,
|
||||
)
|
||||
from govoplan_core.core.runtime_coordination import (
|
||||
DistributedLease,
|
||||
RuntimeIdentity,
|
||||
bind_process_runtime_identity,
|
||||
)
|
||||
from govoplan_records.backend.recovery import (
|
||||
RecordRecoveryError,
|
||||
begin_record_atomic_recovery,
|
||||
)
|
||||
|
||||
|
||||
class RecordsRecoveryTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.directory = tempfile.TemporaryDirectory(prefix="records-recovery-")
|
||||
self.database_path = Path(self.directory.name) / "recovery.db"
|
||||
self.engine = create_engine(f"sqlite:///{self.database_path}")
|
||||
for table in (
|
||||
DistributedLease.__table__,
|
||||
RecoveryOperation.__table__,
|
||||
RecoveryCheckpoint.__table__,
|
||||
):
|
||||
table.create(self.engine)
|
||||
self.session = Session(self.engine)
|
||||
bind_process_runtime_identity(
|
||||
RuntimeIdentity(
|
||||
installation_id="test-installation",
|
||||
node_id="records-test-node",
|
||||
incarnation="11111111-1111-4111-8111-111111111111",
|
||||
role="api",
|
||||
software_version="test",
|
||||
composition_hash="a" * 64,
|
||||
)
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
bind_process_runtime_identity(None)
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
self.directory.cleanup()
|
||||
|
||||
def test_atomic_evidence_chain_replays_only_the_same_request(self) -> None:
|
||||
started = begin_record_atomic_recovery(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
operation_type="record.close",
|
||||
idempotency_key="close-1",
|
||||
request={"record_id": "record-1", "expected_revision": 1},
|
||||
resource_type="record",
|
||||
resource_id="record-1",
|
||||
)
|
||||
started.commit_success(
|
||||
self.session,
|
||||
result={"record_id": "record-1", "revision": 2},
|
||||
resource_id="record-1",
|
||||
)
|
||||
|
||||
operation = self.session.get(RecoveryOperation, started.operation_id)
|
||||
self.assertIsNotNone(operation)
|
||||
self.assertEqual(RecoveryStatus.SUCCEEDED.value, operation.status)
|
||||
self.assertTrue(
|
||||
verify_recovery_evidence_chain(self.session, started.operation_id)
|
||||
)
|
||||
|
||||
replay = begin_record_atomic_recovery(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
operation_type="record.close",
|
||||
idempotency_key="close-1",
|
||||
request={"record_id": "record-1", "expected_revision": 1},
|
||||
resource_type="record",
|
||||
resource_id="record-1",
|
||||
)
|
||||
self.assertTrue(replay.replayed)
|
||||
self.assertIsNone(replay.operation)
|
||||
|
||||
with self.assertRaisesRegex(RecordRecoveryError, "not started"):
|
||||
begin_record_atomic_recovery(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
operation_type="record.close",
|
||||
idempotency_key="close-1",
|
||||
request={"record_id": "record-1", "expected_revision": 9},
|
||||
resource_type="record",
|
||||
resource_id="record-1",
|
||||
)
|
||||
|
||||
def test_unresolved_resource_blocks_another_runtime_effect(self) -> None:
|
||||
started = begin_record_atomic_recovery(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
operation_type="transfer.simulate",
|
||||
idempotency_key="dispatch-1",
|
||||
request={"record_id": "record-1", "package_id": "package-1"},
|
||||
resource_type="record",
|
||||
resource_id="record-1",
|
||||
)
|
||||
bind_process_runtime_identity(
|
||||
RuntimeIdentity(
|
||||
installation_id="test-installation",
|
||||
node_id="other-records-test-node",
|
||||
incarnation="22222222-2222-4222-8222-222222222222",
|
||||
role="api",
|
||||
software_version="test",
|
||||
composition_hash="a" * 64,
|
||||
)
|
||||
)
|
||||
with self.assertRaisesRegex(RecordRecoveryError, "Another runtime"):
|
||||
begin_record_atomic_recovery(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
operation_type="record.reopen",
|
||||
idempotency_key="reopen-1",
|
||||
request={"record_id": "record-1"},
|
||||
resource_type="record",
|
||||
resource_id="record-1",
|
||||
)
|
||||
bind_process_runtime_identity(
|
||||
RuntimeIdentity(
|
||||
installation_id="test-installation",
|
||||
node_id="records-test-node",
|
||||
incarnation="11111111-1111-4111-8111-111111111111",
|
||||
role="api",
|
||||
software_version="test",
|
||||
composition_hash="a" * 64,
|
||||
)
|
||||
)
|
||||
started.fail(summary="Test cleanup", error_type="TestInterruption")
|
||||
|
||||
def test_recovery_evidence_survives_database_restore(self) -> None:
|
||||
started = begin_record_atomic_recovery(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
operation_type="record.close",
|
||||
idempotency_key="restore-close-1",
|
||||
request={"record_id": "record-restore", "expected_revision": 1},
|
||||
resource_type="record",
|
||||
resource_id="record-restore",
|
||||
)
|
||||
started.commit_success(
|
||||
self.session,
|
||||
result={"record_id": "record-restore", "revision": 2},
|
||||
resource_id="record-restore",
|
||||
)
|
||||
|
||||
restored_path = Path(self.directory.name) / "restored.db"
|
||||
with (
|
||||
sqlite3.connect(self.database_path) as source,
|
||||
sqlite3.connect(restored_path) as target,
|
||||
):
|
||||
source.backup(target)
|
||||
restored_engine = create_engine(f"sqlite:///{restored_path}")
|
||||
try:
|
||||
with Session(restored_engine) as restored:
|
||||
operation = restored.get(RecoveryOperation, started.operation_id)
|
||||
self.assertIsNotNone(operation)
|
||||
self.assertEqual(RecoveryStatus.SUCCEEDED.value, operation.status)
|
||||
self.assertTrue(
|
||||
verify_recovery_evidence_chain(restored, started.operation_id)
|
||||
)
|
||||
finally:
|
||||
restored_engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user