Adopt recovery ledger for Campaign builds

This commit is contained in:
2026-08-03 03:03:00 +02:00
parent c6bbdae2e1
commit dd09b06c47
9 changed files with 592 additions and 103 deletions
@@ -1,9 +1,10 @@
from __future__ import annotations
import hashlib
import json
from urllib.parse import quote
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response, status
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response, status
from sqlalchemy.orm import Session
from govoplan_campaign.backend.schemas import (
@@ -22,10 +23,21 @@ from govoplan_campaign.backend.schemas import (
from govoplan_core.auth import ApiPrincipal, has_scope, require_scope
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.core.object_storage import StorageBackendError
from govoplan_core.core.recovery import (
RecoveryGuaranteeError,
RecoveryMode,
RecoveryPlan,
)
from govoplan_core.core.recovery_runtime import (
RecoveryOperationBusy,
RecoveryOperationStateConflict,
begin_durable_recovery_operation,
)
from govoplan_campaign.backend.db.models import (
CampaignVersion,
)
from govoplan_core.db.session import get_session
from govoplan_core.db.session import get_database, get_session
from govoplan_core.server.runtime_agent import application_runtime_identity
from govoplan_campaign.backend.response_security import (
public_campaign_payload,
)
@@ -74,6 +86,47 @@ from govoplan_campaign.backend.routes.attachments import (
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
def _canonical_sha256(value: object) -> str:
return hashlib.sha256(
json.dumps(
value,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
).hexdigest()
def _campaign_build_recovery_plan(raw_json: dict[str, object]) -> RecoveryPlan:
delivery = raw_json.get("delivery")
print_config = delivery.get("print") if isinstance(delivery, dict) else None
persists_managed_output = bool(
isinstance(print_config, dict)
and print_config.get("persist_to_files", True)
)
if persists_managed_output:
return RecoveryPlan(
mode=RecoveryMode.FORWARD_RECOVERY,
preconditions=("validated Campaign version is locked",),
forward_recovery_steps=(
"reuse the Templates render idempotency key",
"reconcile the managed output and Campaign build manifests",
),
verification_steps=(
"compare committed Campaign jobs with generated object evidence",
),
)
return RecoveryPlan(
mode=RecoveryMode.COMPENSATION,
preconditions=("validated Campaign version is locked",),
compensation_steps=("delete every object under the reserved build prefix",),
verification_steps=(
"compare committed Campaign jobs with generated object evidence",
),
)
@router.get("/{campaign_id}/versions/{version_id}/print-output/download")
def download_print_output(
campaign_id: str,
@@ -728,6 +781,7 @@ def validate_version(
@router.post("/versions/{version_id}/build")
def build_version(
version_id: str,
request: Request,
payload: BuildCampaignRequest | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:build")),
@@ -737,14 +791,80 @@ def build_version(
_require_mail_profile_use_if_needed(
principal, version.raw_json if isinstance(version.raw_json, dict) else {}
)
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
write_eml = payload.write_eml if payload else True
source_sha256 = _canonical_sha256(raw_json)
validation_sha256 = _canonical_sha256(
version.validation_summary
if isinstance(version.validation_summary, dict)
else {}
)
idempotency_key = (
payload.idempotency_key
if payload and payload.idempotency_key
else (
f"campaign-build:{version.id}:{source_sha256}:"
f"{validation_sha256}:{int(write_eml)}"
)
)
try:
identity = application_runtime_identity(request.app)
recovery_start = begin_durable_recovery_operation(
get_database().SessionLocal,
identity=identity,
module_id="campaigns",
operation_type="build-artifacts",
idempotency_key=idempotency_key,
request={
"tenant_id": principal.tenant_id,
"campaign_id": version.campaign_id,
"version_id": version.id,
"source_sha256": source_sha256,
"validation_sha256": validation_sha256,
"write_eml": write_eml,
},
recovery_plan=_campaign_build_recovery_plan(raw_json),
precondition_evidence={
"campaign_version_id": version.id,
"source_sha256": source_sha256,
"validation_sha256": validation_sha256,
"locked_at": version.locked_at.isoformat()
if version.locked_at
else None,
"workflow_state": version.workflow_state,
},
lease_resource_key=(
f"campaign:build:{principal.tenant_id}:{version.id}"
),
lease_ttl_seconds=30 * 60,
resource_type="campaign_version",
resource_id=version.id,
metadata={"actor_account_id": principal.account_id},
)
if recovery_start.replayed:
session.refresh(version)
if not isinstance(version.build_summary, dict):
raise RecoveryGuaranteeError(
"A completed build operation has no committed Campaign summary"
)
return public_campaign_payload(
version.build_summary,
include_diagnostics=has_scope(
principal, "campaigns:diagnostic:read"
),
)
recovery_operation = recovery_start.operation
if recovery_operation is None: # pragma: no cover - guarded by replay branch
raise RecoveryGuaranteeError("Campaign build authority was not created")
result = build_campaign_version(
session,
tenant_id=principal.tenant_id,
version_id=version_id,
write_eml=payload.write_eml if payload else True,
write_eml=write_eml,
user_id=principal.user.id,
principal=principal,
recovery_operation=recovery_operation,
build_id=recovery_start.operation_id,
)
audit_from_principal(
session,
@@ -753,8 +873,9 @@ def build_version(
object_type="campaign_version",
object_id=version_id,
details={
"write_eml": payload.write_eml if payload else True,
"write_eml": write_eml,
"built_count": result.get("built_count"),
"recovery_operation_id": recovery_start.operation_id,
},
commit=True,
)
@@ -766,6 +887,21 @@ def build_version(
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
) from exc
except (RecoveryOperationBusy, RecoveryOperationStateConflict) as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except RecoveryGuaranteeError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=str(exc),
) from exc
except RuntimeError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Campaign build coordination is unavailable",
) from exc
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)