Fence and reconcile Dataflow runs

This commit is contained in:
2026-08-03 06:09:53 +02:00
parent e1c092ece7
commit 3fa7a29f48
13 changed files with 1314 additions and 44 deletions
+122 -28
View File
@@ -40,6 +40,7 @@ from govoplan_dataflow.backend.db.models import (
DataflowPipelineDeployment,
DataflowPipelineRevision,
DataflowRun,
new_uuid,
)
from govoplan_dataflow.backend.executor import (
EXECUTOR_VERSION,
@@ -79,6 +80,12 @@ from govoplan_dataflow.backend.schemas import (
PipelineValidationResponse,
PreviewColumn,
)
from govoplan_dataflow.backend.recovery import (
DataflowRecoveryError,
DataflowRunRecovery,
begin_dataflow_run_recovery,
dataflow_run_recovery_state,
)
from govoplan_dataflow.backend.sql_compiler import (
SqlCompilationError,
compile_sql,
@@ -1071,14 +1078,27 @@ def start_pipeline_run(
principal=principal,
defer_execution=defer_execution,
)
session.add(run)
session.flush()
run.authorization_ = {
**dict(run.authorization_),
"authorization_ref": (
request.invocation.trigger_ref or f"dataflow-run:{run.id}"
),
}
recovery: DataflowRunRecovery | None = None
if not defer_execution:
try:
recovery = begin_dataflow_run_recovery(
session,
run=run,
lease_ttl_seconds=int(
float(run.resource_budget.get("max_wall_seconds") or 30.0)
)
+ 60,
)
except DataflowRecoveryError as exc:
raise DataflowConflictError(str(exc)) from exc
session.add(run)
session.flush()
if defer_execution:
session.flush()
return run, False
@@ -1090,8 +1110,27 @@ def start_pipeline_run(
request=request,
principal=principal,
registry=registry,
recovery=recovery,
)
session.flush()
if recovery is not None:
try:
recovery.finish(session, run=run)
except DataflowRecoveryError as exc:
session.rollback()
if recovery.publication_started:
persisted = session.get(DataflowRun, run.id)
if persisted is not None:
persisted.status = "outcome_unknown"
persisted.finished_at = utcnow()
persisted.progress_phase = "outcome_unknown"
persisted.error = (
"Output publication completed without verifiable "
"terminal recovery evidence; reconcile the sink."
)
session.commit()
raise DataflowConflictError(str(exc)) from exc
else:
session.flush()
return run, False
@@ -1256,6 +1295,7 @@ def _new_pipeline_run(
now = utcnow()
budget = _run_resource_budget(request)
return DataflowRun(
id=new_uuid(),
tenant_id=tenant_id,
pipeline_id=pipeline.id,
pipeline_revision_id=revision.id,
@@ -1360,6 +1400,7 @@ def _execute_pipeline_run(
request: DataflowRunRequest,
principal: ApiPrincipal,
registry: object | None,
recovery: DataflowRunRecovery | None = None,
) -> bool:
try:
if run.cancellation_requested_at is not None:
@@ -1405,6 +1446,7 @@ def _execute_pipeline_run(
result=result,
principal=principal,
registry=registry,
recovery=recovery,
)
run.status = "succeeded"
run.finished_at = utcnow()
@@ -1414,6 +1456,10 @@ def _execute_pipeline_run(
return False
except (DatasourceError, PipelineExecutionError) as exc:
_mark_pipeline_run_failed(run, exc)
if recovery is not None and recovery.publication_started:
run.status = "outcome_unknown"
run.progress_phase = "outcome_unknown"
return False
return isinstance(exc, DatasourceUnavailableError) or bool(
getattr(exc, "retryable", False)
)
@@ -1469,6 +1515,7 @@ def _publish_pipeline_result(
result: PipelineExecutionResult,
principal: ApiPrincipal,
registry: object | None,
recovery: DataflowRunRecovery | None = None,
) -> None:
publisher = datasource_publication(registry)
if publisher is None:
@@ -1479,33 +1526,51 @@ def _publish_pipeline_result(
target = request.publication
if target is None:
return
publication = publisher.publish_rows(
if recovery is None:
raise PipelineExecutionError(
"Publishing Dataflow output requires a durable recovery operation."
)
recovery.prepare_publication(
session,
principal,
request=DatasourcePublicationRequest(
producer_module="dataflow",
producer_run_ref=f"dataflow-run:{run.id}",
idempotency_key=f"{pipeline.id}:{request.idempotency_key.strip()}",
rows=tuple(dict(row) for row in result.rows),
target_datasource_ref=target.target_datasource_ref,
name=target.name or f"{pipeline.name} output",
source_name=target.source_name,
description=target.description,
freeze=target.freeze,
frozen_label=target.frozen_label,
set_current=target.set_current,
provenance={
"pipeline_ref": f"pipeline:{pipeline.id}",
"pipeline_revision": revision.revision,
"definition_hash": revision.content_hash,
"source_fingerprints": result.source_fingerprints,
},
metadata={
**dict(target.metadata),
"dataflow_run_ref": f"dataflow-run:{run.id}",
},
),
run=run,
rows=tuple(dict(row) for row in result.rows),
)
try:
publication = publisher.publish_rows(
session,
principal,
request=DatasourcePublicationRequest(
producer_module="dataflow",
producer_run_ref=f"dataflow-run:{run.id}",
idempotency_key=(
f"{pipeline.id}:{request.idempotency_key.strip()}"
),
rows=tuple(dict(row) for row in result.rows),
target_datasource_ref=target.target_datasource_ref,
name=target.name or f"{pipeline.name} output",
source_name=target.source_name,
description=target.description,
freeze=target.freeze,
frozen_label=target.frozen_label,
set_current=target.set_current,
provenance={
"pipeline_ref": f"pipeline:{pipeline.id}",
"pipeline_revision": revision.revision,
"definition_hash": revision.content_hash,
"source_fingerprints": result.source_fingerprints,
},
metadata={
**dict(target.metadata),
"dataflow_run_ref": f"dataflow-run:{run.id}",
},
),
)
except DatasourceError:
raise
except Exception as exc:
raise PipelineExecutionError(
"The output provider failed after publication dispatch began."
) from exc
run.output_publication_ref = publication.ref
run.output_datasource_ref = publication.datasource.ref
run.output_materialization_ref = publication.materialization.ref
@@ -1573,10 +1638,16 @@ def pipeline_run_response(
run: DataflowRun,
*,
replayed: bool = False,
recovery_state: Mapping[str, object] | None = None,
) -> PipelineRunResponse:
revision = session.get(DataflowPipelineRevision, run.pipeline_revision_id)
if revision is None:
raise DataflowNotFoundError("Dataflow pipeline revision not found")
recovery = (
dict(recovery_state) or None
if recovery_state is not None
else dataflow_run_recovery_state(session, run_id=run.id)
)
return PipelineRunResponse(
ref=f"dataflow-run:{run.id}",
pipeline_id=run.pipeline_id,
@@ -1622,6 +1693,27 @@ def pipeline_run_response(
finished_at=run.finished_at,
created_by=run.created_by,
created_at=run.created_at,
recovery_operation_id=(
str(recovery["operation_id"]) if recovery is not None else None
),
recovery_operation_type=(
str(recovery["operation_type"]) if recovery is not None else None
),
recovery_mode=(
str(recovery["mode"]) if recovery is not None else None
),
recovery_status=(
str(recovery["status"]) if recovery is not None else None
),
recovery_requires_attention=(
bool(recovery["requires_attention"])
or run.status == "outcome_unknown"
if recovery is not None
else run.status == "outcome_unknown"
),
recovery_explanation=(
str(recovery["explanation"]) if recovery is not None else None
),
replayed=replayed,
)
@@ -1635,6 +1727,7 @@ def pipeline_run_descriptor(
revision = session.get(DataflowPipelineRevision, run.pipeline_revision_id)
if revision is None:
raise DataflowNotFoundError("Dataflow pipeline revision not found")
recovery = dataflow_run_recovery_state(session, run_id=run.id)
return DataflowRunDescriptor(
ref=f"dataflow-run:{run.id}",
pipeline_ref=f"pipeline:{run.pipeline_id}",
@@ -1670,6 +1763,7 @@ def pipeline_run_descriptor(
"progress_phase": run.progress_phase,
"source_fingerprints": list(run.source_fingerprints),
"diagnostics": list(run.diagnostics),
"recovery": dict(recovery) if recovery is not None else None,
},
)