add Fabric read model rollback activation
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 27s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
This commit is contained in:
tegwick 2026-08-31 23:07:10 +02:00
parent cf40c7bb4e
commit cdff3b7e08
5 changed files with 133 additions and 0 deletions

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import uuid
from typing import Any
import httpx
@ -10,6 +11,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.models.fabric_graph import FabricGraphEdge, FabricGraphImport, FabricGraphNode
from api.schemas.fabric_graph import (
FabricGraphActivationRequest,
FabricGraphActivationResult,
FabricGraphEdgeRead,
FabricGraphImportRead,
FabricGraphIngestResult,
@ -19,6 +22,7 @@ from api.schemas.fabric_graph import (
)
from api.services.fabric_graph import (
FabricGraphValidationError,
activate_fabric_graph_import,
ingest_fabric_graph_export,
record_fabric_graph_error,
split_graph_ingest_body,
@ -132,6 +136,34 @@ async def latest_graph_import(
return FabricGraphImportRead.model_validate(import_run)
@router.post(
"/graph-exports/{import_id}/activate",
response_model=FabricGraphActivationResult,
)
async def activate_graph_import(
import_id: uuid.UUID,
body: FabricGraphActivationRequest | None = None,
session: AsyncSession = Depends(get_session),
) -> FabricGraphActivationResult:
request = body or FabricGraphActivationRequest()
try:
import_run, previous_import_id, activated = await activate_fabric_graph_import(
session,
import_id,
requested_by=request.requested_by,
)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
if import_run is None:
raise HTTPException(status_code=404, detail=f"Fabric graph import '{import_id}' not found")
return FabricGraphActivationResult(
import_run=FabricGraphImportRead.model_validate(import_run),
previous_import_id=previous_import_id,
activated=activated,
idempotent=not activated,
)
@router.get("/graph/nodes", response_model=list[FabricGraphNodeRead])
async def list_graph_nodes(
source_repo_slug: str = "railiance-fabric",

View file

@ -261,6 +261,17 @@ class FabricGraphIngestResult(BaseModel):
edge_count: int
class FabricGraphActivationRequest(BaseModel):
requested_by: str = "operator"
class FabricGraphActivationResult(BaseModel):
import_run: FabricGraphImportRead
previous_import_id: uuid.UUID | None = None
activated: bool
idempotent: bool
class FabricGraphNodeRead(BaseModel):
model_config = ConfigDict(from_attributes=True)

View file

@ -221,6 +221,55 @@ async def ingest_fabric_graph_export(
return import_run, True, False
async def activate_fabric_graph_import(
session: AsyncSession,
import_id: Any,
*,
requested_by: str,
) -> tuple[FabricGraphImport | None, Any | None, bool]:
"""Select a retained valid import as the active read model.
Imports are immutable. Activation only moves the per-source ``is_latest``
marker, which makes cutover rollback fast and avoids re-fetching an older
authority payload during an incident.
"""
result = await session.execute(
select(FabricGraphImport).where(FabricGraphImport.id == import_id)
)
import_run = result.scalar_one_or_none()
if import_run is None:
return None, None, False
if import_run.validation_status != "valid":
raise ValueError("Only a valid Fabric graph import can be activated.")
previous_result = await session.execute(
select(FabricGraphImport).where(
FabricGraphImport.source_repo_slug == import_run.source_repo_slug,
FabricGraphImport.is_latest.is_(True),
)
)
previous = previous_result.scalars().first()
if previous is not None and previous.id == import_run.id:
return import_run, previous.id, False
await _mark_latest(session, import_run)
import_run.last_seen_at = datetime.now(timezone.utc)
await _record_progress(
session,
"Fabric graph read model activation changed.",
{
"source_repo_slug": import_run.source_repo_slug,
"import_id": str(import_run.id),
"previous_import_id": str(previous.id) if previous else None,
"content_hash": import_run.content_hash,
"requested_by": requested_by,
},
)
await session.commit()
await session.refresh(import_run)
return import_run, previous.id if previous else None, True
def validate_fabric_graph_export(payload: dict[str, Any]) -> FabricGraphExportPayload:
try:
export = FabricGraphExportPayload.model_validate(payload)