fix(recovery): support batched SQLite blob writes
This commit is contained in:
@@ -165,11 +165,16 @@ the original archive and stores only the selected members. Password-protected
|
|||||||
ZIP passwords remain request-only and are never included in the preview token.
|
ZIP passwords remain request-only and are never included in the preview token.
|
||||||
|
|
||||||
Managed blob writes and applied orphan cleanup use Core's durable recovery
|
Managed blob writes and applied orphan cleanup use Core's durable recovery
|
||||||
ledger. Intent, request digests, recovery mode, and a distributed lease are
|
ledger. On PostgreSQL, intent, request digests, recovery mode, and a distributed
|
||||||
committed before physical storage effects; the Files session commit verifies
|
lease are committed before physical storage effects; the Files session commit
|
||||||
database and streamed object evidence, while rollback compensates only a newly
|
verifies database and streamed object evidence, while rollback compensates only
|
||||||
reserved unreferenced key. New object keys are opaque and do not retain the
|
a newly reserved unreferenced key. Development SQLite records blob intent in
|
||||||
uploaded filename. Uncertain or mismatched effects remain visible through Ops.
|
the caller transaction to avoid its second-writer deadlock, then verifies on
|
||||||
|
commit or reconstructs compensation evidence after handled rollback. Because a
|
||||||
|
hard loss before that commit can leave an unrecorded object, SQLite requires a
|
||||||
|
complete integrity scan after a crash and is not a production recovery profile.
|
||||||
|
New object keys are opaque and do not retain the uploaded filename. Uncertain
|
||||||
|
or mismatched effects remain visible through Ops.
|
||||||
|
|
||||||
Operators with `files:file:admin` can run bounded, resumable integrity scans in
|
Operators with `files:file:admin` can run bounded, resumable integrity scans in
|
||||||
Administration. Scan batches and finding actions carry monotonic revisions;
|
Administration. Scan batches and finding actions carry monotonic revisions;
|
||||||
|
|||||||
+15
-3
@@ -573,15 +573,27 @@ candidates until those controls are implemented.
|
|||||||
|
|
||||||
### Recovery ledger for object effects
|
### Recovery ledger for object effects
|
||||||
|
|
||||||
Every managed blob creation or integrity repair starts a Core recovery
|
On PostgreSQL, every managed blob creation or integrity repair starts a Core
|
||||||
operation in an independent committed transaction before Files protects or
|
recovery operation in an independent committed transaction before Files
|
||||||
writes bytes. The operation records tenant/blob identifiers, an opaque object
|
protects or writes bytes. The operation records tenant/blob identifiers, an opaque object
|
||||||
locator or locator digest, semantic SHA-256/size evidence, the recovery mode,
|
locator or locator digest, semantic SHA-256/size evidence, the recovery mode,
|
||||||
and a distributed lease fence. It never records file contents, ZIP passwords,
|
and a distributed lease fence. It never records file contents, ZIP passwords,
|
||||||
connector credentials, or a newly uploaded filename. New object keys are opaque;
|
connector credentials, or a newly uploaded filename. New object keys are opaque;
|
||||||
legacy filename-bearing keys remain readable but repair operations record only
|
legacy filename-bearing keys remain readable but repair operations record only
|
||||||
their digest and recover through the blob ID.
|
their digest and recover through the blob ID.
|
||||||
|
|
||||||
|
SQLite is a supported local-development database but permits only one writer.
|
||||||
|
Files therefore uses an explicit reduced-durability mode there: recovery intent
|
||||||
|
and its lease are written in the caller transaction, while a process-local
|
||||||
|
fence prevents competing effects in the same runtime. Commit makes the intent
|
||||||
|
durable before independent verification. A handled rollback reconstructs a
|
||||||
|
durable recovery operation and verifies compensation or forward completion.
|
||||||
|
A hard process loss before commit can leave an object without a surviving
|
||||||
|
ledger row, so SQLite is not a production recovery profile; after such a loss,
|
||||||
|
run a complete Files integrity scan and reconcile every reported orphan before
|
||||||
|
resuming writes. PostgreSQL retains the independent pre-effect durability
|
||||||
|
guarantee.
|
||||||
|
|
||||||
The Files business transaction then creates or updates the blob, version, and
|
The Files business transaction then creates or updates the blob, version, and
|
||||||
asset rows. Its actual SQLAlchemy commit or rollback settles every pending
|
asset rows. Its actual SQLAlchemy commit or rollback settles every pending
|
||||||
operation:
|
operation:
|
||||||
|
|||||||
@@ -1178,7 +1178,7 @@ manifest = ModuleManifest(
|
|||||||
title="Operate Files integrity, recovery, and connector transport safety",
|
title="Operate Files integrity, recovery, and connector transport safety",
|
||||||
summary="Back up database evidence, blob ciphertext, and Encryption custody as one recovery unit, and pin every SDK-managed connector peer.",
|
summary="Back up database evidence, blob ciphertext, and Encryption custody as one recovery unit, and pin every SDK-managed connector peer.",
|
||||||
body=(
|
body=(
|
||||||
"Local durable storage is the operational baseline. Recover Files from a coordinated database/blob snapshot with the matching Encryption tables and original deployment master key, then run the bounded resumable integrity scan from Administration and verify representative protected and unprotected access paths. Each scan batch and finding action requires the revision shown to the operator, so a stale screen cannot recheck or delete after concurrent reconciliation. Protected scans verify stored ciphertext before decryption and then verify plaintext semantic evidence. Managed blob creation/repair and applied orphan cleanup commit lease-fenced Core recovery intent before object effects; success, compensation, and forward completion require independent database and object checks, while mismatch is quarantined and unresolved work remains visible in Ops. Missing or mismatched blobs are quarantined; orphan objects are reported before dry-run-first, explicitly authorized cleanup. "
|
"Local durable storage is the operational baseline. Recover Files from a coordinated database/blob snapshot with the matching Encryption tables and original deployment master key, then run the bounded resumable integrity scan from Administration and verify representative protected and unprotected access paths. Each scan batch and finding action requires the revision shown to the operator, so a stale screen cannot recheck or delete after concurrent reconciliation. Protected scans verify stored ciphertext before decryption and then verify plaintext semantic evidence. On PostgreSQL, managed blob creation/repair and applied orphan cleanup commit lease-fenced Core recovery intent before object effects; success, compensation, and forward completion require independent database and object checks, while mismatch is quarantined and unresolved work remains visible in Ops. Development SQLite instead records blob intent in the caller transaction to avoid a second-writer deadlock, uses a process-local fence, verifies after commit, and reconstructs durable compensation evidence after handled rollback. A hard process loss before the SQLite caller commits can therefore leave an unrecorded object; SQLite is not a production recovery profile and operators must run an integrity scan after such a loss. Missing or mismatched blobs are quarantined; orphan objects are reported before dry-run-first, explicitly authorized cleanup. "
|
||||||
"S3 connector pools pin every retry, redirect, discovered endpoint, and provider alias while retaining the configured TLS authority; outbound proxies and ambient credential discovery are disabled. SMB initial connections, reconnects, aliases, and DFS referrals use a Files-owned pinned transport and cache. Both apply the deployment private-network policy immediately before each socket opens and fail closed if an SDK no longer exposes the verified transport seam. Installer-owned Garage storage is supported only at the exact deployment service endpoint with its explicit trust marker. Destructive module retirement drops database tables but does not remove backend blob objects."
|
"S3 connector pools pin every retry, redirect, discovered endpoint, and provider alias while retaining the configured TLS authority; outbound proxies and ambient credential discovery are disabled. SMB initial connections, reconnects, aliases, and DFS referrals use a Files-owned pinned transport and cache. Both apply the deployment private-network policy immediately before each socket opens and fail closed if an SDK no longer exposes the verified transport seam. Installer-owned Garage storage is supported only at the exact deployment service endpoint with its explicit trust marker. Destructive module retirement drops database tables but does not remove backend blob objects."
|
||||||
),
|
),
|
||||||
layer="configured",
|
layer="configured",
|
||||||
|
|||||||
@@ -1,26 +1,39 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
import hashlib
|
import hashlib
|
||||||
|
from threading import Lock
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
|
|
||||||
from sqlalchemy import event
|
from sqlalchemy import event, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.core.recovery import (
|
from govoplan_core.core.recovery import (
|
||||||
RecoveryGuaranteeError,
|
RecoveryGuaranteeError,
|
||||||
RecoveryMode,
|
RecoveryMode,
|
||||||
|
RecoveryOperation,
|
||||||
RecoveryPlan,
|
RecoveryPlan,
|
||||||
RecoveryStatus,
|
RecoveryStatus,
|
||||||
|
plan_recovery_operation,
|
||||||
|
prepare_recovery_operation,
|
||||||
|
start_recovery_operation,
|
||||||
|
verify_recovery_evidence_chain,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.recovery_runtime import (
|
from govoplan_core.core.recovery_runtime import (
|
||||||
DurableRecoveryOperation,
|
DurableRecoveryOperation,
|
||||||
|
DurableRecoveryStart,
|
||||||
RecoveryOperationBusy,
|
RecoveryOperationBusy,
|
||||||
RecoveryOperationStateConflict,
|
RecoveryOperationStateConflict,
|
||||||
begin_durable_recovery_operation,
|
begin_durable_recovery_operation,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.runtime_coordination import process_runtime_identity
|
from govoplan_core.core.runtime_coordination import (
|
||||||
|
RuntimeIdentity,
|
||||||
|
acquire_lease,
|
||||||
|
process_runtime_identity,
|
||||||
|
release_lease,
|
||||||
|
)
|
||||||
from govoplan_core.db.session import get_database
|
from govoplan_core.db.session import get_database
|
||||||
from govoplan_files.backend.db.models import (
|
from govoplan_files.backend.db.models import (
|
||||||
FileBlob,
|
FileBlob,
|
||||||
@@ -38,6 +51,8 @@ from govoplan_files.backend.storage.common import FileStorageError
|
|||||||
_PENDING_EFFECTS_KEY = "govoplan_files_pending_recovery_effects"
|
_PENDING_EFFECTS_KEY = "govoplan_files_pending_recovery_effects"
|
||||||
_HOOKS_INSTALLED_KEY = "govoplan_files_recovery_hooks_installed"
|
_HOOKS_INSTALLED_KEY = "govoplan_files_recovery_hooks_installed"
|
||||||
_ROLLBACK_ERRORS_KEY = "govoplan_files_recovery_rollback_errors"
|
_ROLLBACK_ERRORS_KEY = "govoplan_files_recovery_rollback_errors"
|
||||||
|
_SQLITE_FENCES: set[str] = set()
|
||||||
|
_SQLITE_FENCES_LOCK = Lock()
|
||||||
|
|
||||||
|
|
||||||
class _PendingEffect(Protocol):
|
class _PendingEffect(Protocol):
|
||||||
@@ -58,6 +73,8 @@ class PendingBlobWrite:
|
|||||||
expected_storage_checksum_sha256: str | None = None
|
expected_storage_checksum_sha256: str | None = None
|
||||||
expected_storage_size_bytes: int | None = None
|
expected_storage_size_bytes: int | None = None
|
||||||
expected_envelope_id: str | None = None
|
expected_envelope_id: str | None = None
|
||||||
|
sqlite_fence_key: str | None = None
|
||||||
|
restart_after_rollback: Callable[[], DurableRecoveryOperation] | None = None
|
||||||
|
|
||||||
def prepare_stored_bytes(
|
def prepare_stored_bytes(
|
||||||
self,
|
self,
|
||||||
@@ -72,6 +89,19 @@ class PendingBlobWrite:
|
|||||||
self.expected_envelope_id = envelope_id
|
self.expected_envelope_id = envelope_id
|
||||||
|
|
||||||
def settle(self, *, committed: bool) -> None:
|
def settle(self, *, committed: bool) -> None:
|
||||||
|
if not committed and self.restart_after_rollback is not None:
|
||||||
|
try:
|
||||||
|
self.operation = self.restart_after_rollback()
|
||||||
|
except Exception:
|
||||||
|
# SQLite cannot make the pre-effect ledger row independent of
|
||||||
|
# the caller transaction. If reconstructing the evidence row
|
||||||
|
# also fails, still avoid leaving a known new orphan behind.
|
||||||
|
if self.created_new:
|
||||||
|
try:
|
||||||
|
self.backend.delete(self.storage_key)
|
||||||
|
except (StorageBackendError, OSError):
|
||||||
|
pass
|
||||||
|
raise
|
||||||
evidence = _blob_write_evidence(self)
|
evidence = _blob_write_evidence(self)
|
||||||
if _blob_write_complete(evidence):
|
if _blob_write_complete(evidence):
|
||||||
self.operation.succeed(evidence=evidence)
|
self.operation.succeed(evidence=evidence)
|
||||||
@@ -219,12 +249,12 @@ def begin_blob_write_recovery(
|
|||||||
f"files-blob-{disposition}:{blob_id}:{state_token[:48]}"
|
f"files-blob-{disposition}:{blob_id}:{state_token[:48]}"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
started = begin_durable_recovery_operation(
|
identity = process_runtime_identity()
|
||||||
get_database().SessionLocal,
|
except RuntimeError as exc:
|
||||||
identity=process_runtime_identity(),
|
raise FileStorageError(
|
||||||
module_id="files",
|
"The Files recovery ledger is unavailable; no object was written"
|
||||||
operation_type=f"blob-{disposition}",
|
) from exc
|
||||||
idempotency_key=idempotency_key,
|
lease_resource_key = f"files:blob:{tenant_id}:{blob_id}"
|
||||||
request = {
|
request = {
|
||||||
"tenant_id": tenant_id,
|
"tenant_id": tenant_id,
|
||||||
"blob_id": blob_id,
|
"blob_id": blob_id,
|
||||||
@@ -234,7 +264,7 @@ def begin_blob_write_recovery(
|
|||||||
"semantic_size_bytes": semantic_size_bytes,
|
"semantic_size_bytes": semantic_size_bytes,
|
||||||
"protection_discriminator": protection_discriminator,
|
"protection_discriminator": protection_discriminator,
|
||||||
"disposition": disposition,
|
"disposition": disposition,
|
||||||
},
|
}
|
||||||
recovery_plan = RecoveryPlan(
|
recovery_plan = RecoveryPlan(
|
||||||
mode=(
|
mode=(
|
||||||
RecoveryMode.COMPENSATION
|
RecoveryMode.COMPENSATION
|
||||||
@@ -262,34 +292,102 @@ def begin_blob_write_recovery(
|
|||||||
"reload FileBlob metadata through an independent session",
|
"reload FileBlob metadata through an independent session",
|
||||||
"stream and hash the managed object independently",
|
"stream and hash the managed object independently",
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
precondition_evidence = {
|
precondition_evidence = {
|
||||||
"blob_id": blob_id,
|
"blob_id": blob_id,
|
||||||
"storage_key_sha256": key_digest,
|
"storage_key_sha256": key_digest,
|
||||||
"semantic_checksum_sha256": semantic_checksum_sha256,
|
"semantic_checksum_sha256": semantic_checksum_sha256,
|
||||||
"semantic_size_bytes": semantic_size_bytes,
|
"semantic_size_bytes": semantic_size_bytes,
|
||||||
"created_new": created_new,
|
"created_new": created_new,
|
||||||
},
|
}
|
||||||
lease_resource_key=(
|
metadata = {
|
||||||
f"files:blob:{tenant_id}:{blob_id}"
|
"resources": ["postgresql", "object-storage"],
|
||||||
|
"storage_backend": backend.name,
|
||||||
|
"durability_mode": "independent_transaction",
|
||||||
|
}
|
||||||
|
session_factory = get_database().SessionLocal
|
||||||
|
sqlite_mode = session.get_bind().dialect.name == "sqlite"
|
||||||
|
sqlite_fence_key: str | None = None
|
||||||
|
|
||||||
|
def start_independent(*, reconstructed_after_rollback: bool = False) -> DurableRecoveryStart:
|
||||||
|
return begin_durable_recovery_operation(
|
||||||
|
session_factory,
|
||||||
|
identity=identity,
|
||||||
|
module_id="files",
|
||||||
|
operation_type=f"blob-{disposition}",
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
request=request,
|
||||||
|
recovery_plan=recovery_plan,
|
||||||
|
precondition_evidence={
|
||||||
|
**precondition_evidence,
|
||||||
|
**(
|
||||||
|
{
|
||||||
|
"sqlite_caller_transaction_rolled_back": True,
|
||||||
|
"effect_may_have_preceded_durable_intent": True,
|
||||||
|
}
|
||||||
|
if reconstructed_after_rollback
|
||||||
|
else {}
|
||||||
),
|
),
|
||||||
|
},
|
||||||
|
lease_resource_key=lease_resource_key,
|
||||||
lease_ttl_seconds=15 * 60,
|
lease_ttl_seconds=15 * 60,
|
||||||
resource_type="file_blob",
|
resource_type="file_blob",
|
||||||
resource_id=blob_id,
|
resource_id=blob_id,
|
||||||
metadata={
|
metadata={
|
||||||
"resources": ["postgresql", "object-storage"],
|
**metadata,
|
||||||
"storage_backend": backend.name,
|
"durability_mode": (
|
||||||
|
"sqlite_post_rollback_reconstruction"
|
||||||
|
if reconstructed_after_rollback
|
||||||
|
else "independent_transaction"
|
||||||
|
),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if sqlite_mode:
|
||||||
|
_reserve_sqlite_fence(lease_resource_key)
|
||||||
|
sqlite_fence_key = lease_resource_key
|
||||||
|
started = _begin_caller_transaction_recovery_operation(
|
||||||
|
session,
|
||||||
|
session_factory=session_factory,
|
||||||
|
identity=identity,
|
||||||
|
module_id="files",
|
||||||
|
operation_type=f"blob-{disposition}",
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
request=request,
|
||||||
|
recovery_plan=recovery_plan,
|
||||||
|
precondition_evidence={
|
||||||
|
**precondition_evidence,
|
||||||
|
"sqlite_caller_transaction": True,
|
||||||
|
"reduced_crash_durability": True,
|
||||||
|
},
|
||||||
|
lease_resource_key=lease_resource_key,
|
||||||
|
lease_ttl_seconds=15 * 60,
|
||||||
|
resource_type="file_blob",
|
||||||
|
resource_id=blob_id,
|
||||||
|
metadata={
|
||||||
|
**metadata,
|
||||||
|
"resources": ["sqlite", "object-storage"],
|
||||||
|
"durability_mode": "sqlite_caller_transaction",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
started = start_independent()
|
||||||
except (RecoveryOperationBusy, RecoveryOperationStateConflict) as exc:
|
except (RecoveryOperationBusy, RecoveryOperationStateConflict) as exc:
|
||||||
|
if sqlite_fence_key is not None:
|
||||||
|
_release_sqlite_fence(sqlite_fence_key)
|
||||||
raise FileStorageError(
|
raise FileStorageError(
|
||||||
"This managed blob is already owned by another recovery operation"
|
"This managed blob is already owned by another recovery operation"
|
||||||
) from exc
|
) from exc
|
||||||
except (RecoveryGuaranteeError, RuntimeError) as exc:
|
except (RecoveryGuaranteeError, RuntimeError) as exc:
|
||||||
|
if sqlite_fence_key is not None:
|
||||||
|
_release_sqlite_fence(sqlite_fence_key)
|
||||||
raise FileStorageError(
|
raise FileStorageError(
|
||||||
"The Files recovery ledger is unavailable; no object was written"
|
"The Files recovery ledger is unavailable; no object was written"
|
||||||
) from exc
|
) from exc
|
||||||
if started.replayed or started.operation is None:
|
if started.replayed or started.operation is None:
|
||||||
|
if sqlite_fence_key is not None:
|
||||||
|
_release_sqlite_fence(sqlite_fence_key)
|
||||||
raise FileStorageError(
|
raise FileStorageError(
|
||||||
"The matching Files blob operation was already completed; reload before retrying"
|
"The matching Files blob operation was already completed; reload before retrying"
|
||||||
)
|
)
|
||||||
@@ -303,6 +401,14 @@ def begin_blob_write_recovery(
|
|||||||
semantic_size_bytes=semantic_size_bytes,
|
semantic_size_bytes=semantic_size_bytes,
|
||||||
protection_discriminator=protection_discriminator,
|
protection_discriminator=protection_discriminator,
|
||||||
created_new=created_new,
|
created_new=created_new,
|
||||||
|
sqlite_fence_key=sqlite_fence_key,
|
||||||
|
restart_after_rollback=(
|
||||||
|
lambda: _require_started_operation(
|
||||||
|
start_independent(reconstructed_after_rollback=True)
|
||||||
|
)
|
||||||
|
if sqlite_mode
|
||||||
|
else None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
_register_pending_effect(session, pending)
|
_register_pending_effect(session, pending)
|
||||||
return pending
|
return pending
|
||||||
@@ -398,10 +504,17 @@ def _register_pending_effect(session: Session, effect: _PendingEffect) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _after_session_commit(session: Session) -> None:
|
def _after_session_commit(session: Session) -> None:
|
||||||
|
# SQLAlchemy also emits after_commit for a released SAVEPOINT. A Files
|
||||||
|
# batch may open one while creating a later recovery row; settle only when
|
||||||
|
# the outer business transaction has actually become visible.
|
||||||
|
if session.in_nested_transaction():
|
||||||
|
return
|
||||||
_settle_pending_effects(session, committed=True)
|
_settle_pending_effects(session, committed=True)
|
||||||
|
|
||||||
|
|
||||||
def _after_session_rollback(session: Session) -> None:
|
def _after_session_rollback(session: Session) -> None:
|
||||||
|
if session.in_nested_transaction():
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
_settle_pending_effects(session, committed=False)
|
_settle_pending_effects(session, committed=False)
|
||||||
except RecoveryGuaranteeError as exc:
|
except RecoveryGuaranteeError as exc:
|
||||||
@@ -422,12 +535,139 @@ def _settle_pending_effects(session: Session, *, committed: bool) -> None:
|
|||||||
operation.release_unresolved()
|
operation.release_unresolved()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
finally:
|
||||||
|
sqlite_fence_key = getattr(effect, "sqlite_fence_key", None)
|
||||||
|
if sqlite_fence_key:
|
||||||
|
_release_sqlite_fence(sqlite_fence_key)
|
||||||
if failures:
|
if failures:
|
||||||
raise RecoveryGuaranteeError(
|
raise RecoveryGuaranteeError(
|
||||||
f"{len(failures)} Files recovery operation(s) could not be finalized"
|
f"{len(failures)} Files recovery operation(s) could not be finalized"
|
||||||
) from failures[0]
|
) from failures[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _reserve_sqlite_fence(resource_key: str) -> None:
|
||||||
|
with _SQLITE_FENCES_LOCK:
|
||||||
|
if resource_key in _SQLITE_FENCES:
|
||||||
|
raise RecoveryOperationBusy(
|
||||||
|
f"Another local SQLite transaction owns {resource_key}"
|
||||||
|
)
|
||||||
|
_SQLITE_FENCES.add(resource_key)
|
||||||
|
|
||||||
|
|
||||||
|
def _release_sqlite_fence(resource_key: str) -> None:
|
||||||
|
with _SQLITE_FENCES_LOCK:
|
||||||
|
_SQLITE_FENCES.discard(resource_key)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_started_operation(started: DurableRecoveryStart) -> DurableRecoveryOperation:
|
||||||
|
if started.replayed or started.operation is None:
|
||||||
|
raise RecoveryOperationStateConflict(started.operation_id, started.status)
|
||||||
|
return started.operation
|
||||||
|
|
||||||
|
|
||||||
|
def _begin_caller_transaction_recovery_operation(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
session_factory: Callable[[], Session],
|
||||||
|
identity: RuntimeIdentity,
|
||||||
|
module_id: str,
|
||||||
|
operation_type: str,
|
||||||
|
idempotency_key: str,
|
||||||
|
request: dict[str, object],
|
||||||
|
recovery_plan: RecoveryPlan,
|
||||||
|
precondition_evidence: dict[str, object],
|
||||||
|
lease_resource_key: str,
|
||||||
|
lease_ttl_seconds: int,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
metadata: dict[str, object],
|
||||||
|
) -> DurableRecoveryStart:
|
||||||
|
"""Start SQLite recovery evidence inside the caller transaction.
|
||||||
|
|
||||||
|
SQLite permits only one writer, so opening the normal independent ledger
|
||||||
|
transaction after an earlier member of a batch has written will deadlock
|
||||||
|
until the busy timeout. The caller-transaction mode preserves fencing,
|
||||||
|
request hashing, checkpoints, commit verification, and rollback
|
||||||
|
compensation, but cannot make the intent survive a hard process loss
|
||||||
|
before the caller commits. PostgreSQL never uses this reduced mode.
|
||||||
|
"""
|
||||||
|
|
||||||
|
claim = acquire_lease(
|
||||||
|
session,
|
||||||
|
installation_id=identity.installation_id,
|
||||||
|
resource_key=lease_resource_key,
|
||||||
|
holder_node_id=identity.node_id,
|
||||||
|
holder_incarnation=identity.incarnation,
|
||||||
|
ttl_seconds=lease_ttl_seconds,
|
||||||
|
metadata={
|
||||||
|
"module_id": module_id,
|
||||||
|
"operation_type": operation_type,
|
||||||
|
"durability_mode": "sqlite_caller_transaction",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if claim is None:
|
||||||
|
raise RecoveryOperationBusy(
|
||||||
|
f"Another runtime owns the recovery fence for {lease_resource_key}"
|
||||||
|
)
|
||||||
|
existing = session.execute(
|
||||||
|
select(RecoveryOperation).where(
|
||||||
|
RecoveryOperation.installation_id == identity.installation_id,
|
||||||
|
RecoveryOperation.module_id == module_id,
|
||||||
|
RecoveryOperation.idempotency_key == idempotency_key,
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
operation = plan_recovery_operation(
|
||||||
|
session,
|
||||||
|
installation_id=identity.installation_id,
|
||||||
|
module_id=module_id,
|
||||||
|
operation_type=operation_type,
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
request=request,
|
||||||
|
recovery_plan=recovery_plan,
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
lease_claim=claim,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
if operation.status == RecoveryStatus.SUCCEEDED.value:
|
||||||
|
release_lease(session, claim)
|
||||||
|
return DurableRecoveryStart(
|
||||||
|
operation_id=operation.id,
|
||||||
|
status=operation.status,
|
||||||
|
replayed=True,
|
||||||
|
operation=None,
|
||||||
|
)
|
||||||
|
raise RecoveryOperationStateConflict(operation.id, operation.status)
|
||||||
|
prepare_recovery_operation(
|
||||||
|
session,
|
||||||
|
operation,
|
||||||
|
evidence=precondition_evidence,
|
||||||
|
lease_claim=claim,
|
||||||
|
)
|
||||||
|
start_recovery_operation(
|
||||||
|
session,
|
||||||
|
operation,
|
||||||
|
evidence={"lease_resource_key": lease_resource_key},
|
||||||
|
lease_claim=claim,
|
||||||
|
)
|
||||||
|
if not verify_recovery_evidence_chain(session, operation.id):
|
||||||
|
raise RecoveryGuaranteeError(
|
||||||
|
"Recovery checkpoint chain verification failed before side effects"
|
||||||
|
)
|
||||||
|
return DurableRecoveryStart(
|
||||||
|
operation_id=operation.id,
|
||||||
|
status=operation.status,
|
||||||
|
replayed=False,
|
||||||
|
operation=DurableRecoveryOperation(
|
||||||
|
session_factory=session_factory,
|
||||||
|
operation_id=operation.id,
|
||||||
|
lease_claim=claim,
|
||||||
|
lease_ttl_seconds=lease_ttl_seconds,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _blob_write_evidence(effect: PendingBlobWrite) -> dict[str, object]:
|
def _blob_write_evidence(effect: PendingBlobWrite) -> dict[str, object]:
|
||||||
database_blob_present: bool | None
|
database_blob_present: bool | None
|
||||||
database_matches: bool | None
|
database_matches: bool | None
|
||||||
|
|||||||
@@ -115,9 +115,10 @@ class StorageRecoveryTests(unittest.TestCase):
|
|||||||
|
|
||||||
def put_bytes(backend_self, key, data, *, content_type=None):
|
def put_bytes(backend_self, key, data, *, content_type=None):
|
||||||
with self.Session() as evidence_session:
|
with self.Session() as evidence_session:
|
||||||
operation = evidence_session.query(RecoveryOperation).one()
|
operation = evidence_session.query(RecoveryOperation).one_or_none()
|
||||||
observed_running_operation.append(
|
observed_running_operation.append(
|
||||||
operation.status == RecoveryStatus.RUNNING.value
|
operation is not None
|
||||||
|
and operation.status == RecoveryStatus.RUNNING.value
|
||||||
and len(operation.request_sha256) == 64
|
and len(operation.request_sha256) == 64
|
||||||
)
|
)
|
||||||
put_bytes(key, data, content_type=content_type)
|
put_bytes(key, data, content_type=content_type)
|
||||||
@@ -140,7 +141,14 @@ class StorageRecoveryTests(unittest.TestCase):
|
|||||||
|
|
||||||
operation = self._only_operation()
|
operation = self._only_operation()
|
||||||
self.assertEqual(RecoveryStatus.SUCCEEDED.value, operation.status)
|
self.assertEqual(RecoveryStatus.SUCCEEDED.value, operation.status)
|
||||||
self.assertEqual([True], observed_running_operation)
|
# SQLite uses an explicit caller-transaction mode because it permits
|
||||||
|
# only one writer. The intent becomes visible with the business commit;
|
||||||
|
# PostgreSQL retains the independent pre-effect commit guarantee.
|
||||||
|
self.assertEqual([False], observed_running_operation)
|
||||||
|
self.assertEqual(
|
||||||
|
"sqlite_caller_transaction",
|
||||||
|
operation.metadata_["durability_mode"],
|
||||||
|
)
|
||||||
self.assertTrue(self.backend.exists(blob.storage_key))
|
self.assertTrue(self.backend.exists(blob.storage_key))
|
||||||
self.assertNotIn("private-name", blob.storage_key)
|
self.assertNotIn("private-name", blob.storage_key)
|
||||||
self.assertEqual(".blob", Path(blob.storage_key).suffix)
|
self.assertEqual(".blob", Path(blob.storage_key).suffix)
|
||||||
@@ -164,10 +172,54 @@ class StorageRecoveryTests(unittest.TestCase):
|
|||||||
|
|
||||||
operation = self._only_operation()
|
operation = self._only_operation()
|
||||||
self.assertEqual(RecoveryStatus.RECOVERED.value, operation.status)
|
self.assertEqual(RecoveryStatus.RECOVERED.value, operation.status)
|
||||||
|
self.assertEqual(
|
||||||
|
"sqlite_post_rollback_reconstruction",
|
||||||
|
operation.metadata_["durability_mode"],
|
||||||
|
)
|
||||||
self.assertFalse(self.backend.exists(storage_key))
|
self.assertFalse(self.backend.exists(storage_key))
|
||||||
with self.Session() as evidence_session:
|
with self.Session() as evidence_session:
|
||||||
self.assertIsNone(evidence_session.get(FileBlob, blob.id))
|
self.assertIsNone(evidence_session.get(FileBlob, blob.id))
|
||||||
|
|
||||||
|
def test_multiple_blob_writes_share_one_sqlite_business_transaction(self) -> None:
|
||||||
|
with patch(
|
||||||
|
"govoplan_files.backend.storage.files.get_storage_backend",
|
||||||
|
return_value=self.backend,
|
||||||
|
):
|
||||||
|
first = _get_or_create_blob(
|
||||||
|
self.session,
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
data=b"first archive member",
|
||||||
|
filename="first.txt",
|
||||||
|
content_type="text/plain",
|
||||||
|
actor_id=USER_ID,
|
||||||
|
)
|
||||||
|
second = _get_or_create_blob(
|
||||||
|
self.session,
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
data=b"second archive member",
|
||||||
|
filename="second.txt",
|
||||||
|
content_type="text/plain",
|
||||||
|
actor_id=USER_ID,
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
self.assertTrue(self.backend.exists(first.storage_key))
|
||||||
|
self.assertTrue(self.backend.exists(second.storage_key))
|
||||||
|
with self.Session() as evidence_session:
|
||||||
|
operations = evidence_session.query(RecoveryOperation).all()
|
||||||
|
self.assertEqual(2, len(operations))
|
||||||
|
self.assertEqual(
|
||||||
|
{RecoveryStatus.SUCCEEDED.value},
|
||||||
|
{operation.status for operation in operations},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{"sqlite_caller_transaction"},
|
||||||
|
{
|
||||||
|
operation.metadata_["durability_mode"]
|
||||||
|
for operation in operations
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def test_post_write_tamper_is_quarantined_and_recovery_required(self) -> None:
|
def test_post_write_tamper_is_quarantined_and_recovery_required(self) -> None:
|
||||||
with patch(
|
with patch(
|
||||||
"govoplan_files.backend.storage.files.get_storage_backend",
|
"govoplan_files.backend.storage.files.get_storage_backend",
|
||||||
|
|||||||
Reference in New Issue
Block a user