feat: run pinned pipelines and publish outputs
This commit is contained in:
14
README.md
14
README.md
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
GovOPlaN Dataflow defines and runs governed tabular transformation pipelines.
|
GovOPlaN Dataflow defines and runs governed tabular transformation pipelines.
|
||||||
Power users can work with the same pipeline as a graphical node graph or as a
|
Power users can work with the same pipeline as a graphical node graph or as a
|
||||||
constrained SQL query. Every saved definition change produces an immutable revision, and
|
constrained SQL query. Every saved definition change produces an immutable
|
||||||
every preview records diagnostics and reproducibility metadata without storing
|
revision, and every preview records diagnostics and reproducibility metadata
|
||||||
the previewed row contents.
|
without storing the previewed row contents.
|
||||||
|
|
||||||
## Boundary
|
## Boundary
|
||||||
|
|
||||||
@@ -55,6 +55,14 @@ result-byte, graph-node, and response-row bounds. Saved previews record the
|
|||||||
pipeline revision, executor version, source fingerprints, node diagnostics,
|
pipeline revision, executor version, source fingerprints, node diagnostics,
|
||||||
and output summary, but not source or result rows.
|
and output summary, but not source or result rows.
|
||||||
|
|
||||||
|
Saved revisions can also be started through the versioned
|
||||||
|
`dataflow.runLifecycle` capability or the Run dialog. The first runner is
|
||||||
|
synchronous and bounded. It records an idempotent terminal run, pins the
|
||||||
|
pipeline revision, and can atomically publish a complete result through
|
||||||
|
`datasources.publication`. Publication is rejected when a source or result was
|
||||||
|
truncated; larger asynchronous and artifact-backed execution belongs to a
|
||||||
|
subsequent runner slice.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -73,6 +73,12 @@ class DataflowPipelineRevision(Base, TimestampMixin):
|
|||||||
class DataflowRun(Base, TimestampMixin):
|
class DataflowRun(Base, TimestampMixin):
|
||||||
__tablename__ = "dataflow_runs"
|
__tablename__ = "dataflow_runs"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"pipeline_id",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_dataflow_run_idempotency",
|
||||||
|
),
|
||||||
Index("ix_dataflow_runs_tenant_status", "tenant_id", "status"),
|
Index("ix_dataflow_runs_tenant_status", "tenant_id", "status"),
|
||||||
Index("ix_dataflow_runs_pipeline_created", "pipeline_id", "created_at"),
|
Index("ix_dataflow_runs_pipeline_created", "pipeline_id", "created_at"),
|
||||||
)
|
)
|
||||||
@@ -93,11 +99,35 @@ class DataflowRun(Base, TimestampMixin):
|
|||||||
status: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
status: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||||
executor_version: Mapped[str] = mapped_column(String(40), nullable=False)
|
executor_version: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||||
definition_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
definition_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
idempotency_key: Mapped[str | None] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
request_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
request_: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
"request",
|
||||||
|
JSON,
|
||||||
|
default=dict,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
source_fingerprints: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
|
source_fingerprints: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
|
||||||
result_schema: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
|
result_schema: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
|
||||||
diagnostics: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
|
diagnostics: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
|
||||||
input_row_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
input_row_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
output_row_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
output_row_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
output_publication_ref: Mapped[str | None] = mapped_column(
|
||||||
|
String(500),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
output_datasource_ref: Mapped[str | None] = mapped_column(
|
||||||
|
String(500),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
output_materialization_ref: Mapped[str | None] = mapped_column(
|
||||||
|
String(500),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
error: Mapped[str | None] = mapped_column(Text)
|
error: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from govoplan_core.core.module_guards import (
|
|||||||
drop_table_retirement_provider,
|
drop_table_retirement_provider,
|
||||||
persistent_table_uninstall_guard,
|
persistent_table_uninstall_guard,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.dataflows import CAPABILITY_DATAFLOW_RUN_LIFECYCLE
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
FrontendModule,
|
FrontendModule,
|
||||||
@@ -21,6 +22,7 @@ from govoplan_core.core.modules import (
|
|||||||
from govoplan_core.core.datasources import (
|
from govoplan_core.core.datasources import (
|
||||||
CAPABILITY_DATASOURCE_CATALOGUE,
|
CAPABILITY_DATASOURCE_CATALOGUE,
|
||||||
CAPABILITY_DATASOURCE_LIFECYCLE,
|
CAPABILITY_DATASOURCE_LIFECYCLE,
|
||||||
|
CAPABILITY_DATASOURCE_PUBLICATION,
|
||||||
)
|
)
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_dataflow.backend.db import models as dataflow_models
|
from govoplan_dataflow.backend.db import models as dataflow_models
|
||||||
@@ -143,6 +145,12 @@ def _dataflow_router(context: ModuleContext):
|
|||||||
return router
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
def _run_provider(context: ModuleContext):
|
||||||
|
from govoplan_dataflow.backend.service import SqlDataflowRunLifecycleProvider
|
||||||
|
|
||||||
|
return SqlDataflowRunLifecycleProvider(registry=context.registry)
|
||||||
|
|
||||||
|
|
||||||
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||||
return {
|
return {
|
||||||
"dataflow_pipelines": (
|
"dataflow_pipelines": (
|
||||||
@@ -180,6 +188,7 @@ manifest = ModuleManifest(
|
|||||||
optional_capabilities=(
|
optional_capabilities=(
|
||||||
CAPABILITY_DATASOURCE_CATALOGUE,
|
CAPABILITY_DATASOURCE_CATALOGUE,
|
||||||
CAPABILITY_DATASOURCE_LIFECYCLE,
|
CAPABILITY_DATASOURCE_LIFECYCLE,
|
||||||
|
CAPABILITY_DATASOURCE_PUBLICATION,
|
||||||
),
|
),
|
||||||
provides_interfaces=(
|
provides_interfaces=(
|
||||||
ModuleInterfaceProvider(name="dataflow.pipeline_catalog", version=MODULE_VERSION),
|
ModuleInterfaceProvider(name="dataflow.pipeline_catalog", version=MODULE_VERSION),
|
||||||
@@ -200,6 +209,12 @@ manifest = ModuleManifest(
|
|||||||
version_max_exclusive="1.0.0",
|
version_max_exclusive="1.0.0",
|
||||||
optional=True,
|
optional=True,
|
||||||
),
|
),
|
||||||
|
ModuleInterfaceRequirement(
|
||||||
|
name="datasources.publication",
|
||||||
|
version_min="0.1.0",
|
||||||
|
version_max_exclusive="1.0.0",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
permissions=PERMISSIONS,
|
permissions=PERMISSIONS,
|
||||||
role_templates=ROLE_TEMPLATES,
|
role_templates=ROLE_TEMPLATES,
|
||||||
@@ -226,6 +241,9 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
route_factory=_dataflow_router,
|
route_factory=_dataflow_router,
|
||||||
|
capability_factories={
|
||||||
|
CAPABILITY_DATAFLOW_RUN_LIFECYCLE: _run_provider,
|
||||||
|
},
|
||||||
tenant_summary_providers=(_tenant_summary,),
|
tenant_summary_providers=(_tenant_summary,),
|
||||||
migration_spec=MigrationSpec(
|
migration_spec=MigrationSpec(
|
||||||
module_id=MODULE_ID,
|
module_id=MODULE_ID,
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""v0.1.14 Dataflow run lifecycle
|
||||||
|
|
||||||
|
Revision ID: b8e3c6a1f4d9
|
||||||
|
Revises: d4f7a1c8e2b0
|
||||||
|
Create Date: 2026-07-28 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "b8e3c6a1f4d9"
|
||||||
|
down_revision = "d4f7a1c8e2b0"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("dataflow_runs") as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column("idempotency_key", sa.String(length=255), nullable=True)
|
||||||
|
)
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column("request_hash", sa.String(length=64), nullable=True)
|
||||||
|
)
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column(
|
||||||
|
"request",
|
||||||
|
sa.JSON(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("'{}'"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column(
|
||||||
|
"output_publication_ref",
|
||||||
|
sa.String(length=500),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column(
|
||||||
|
"output_datasource_ref",
|
||||||
|
sa.String(length=500),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column(
|
||||||
|
"output_materialization_ref",
|
||||||
|
sa.String(length=500),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
batch_op.create_unique_constraint(
|
||||||
|
"uq_dataflow_run_idempotency",
|
||||||
|
["tenant_id", "pipeline_id", "idempotency_key"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_dataflow_runs_idempotency_key"),
|
||||||
|
"dataflow_runs",
|
||||||
|
["idempotency_key"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_dataflow_runs_idempotency_key"),
|
||||||
|
table_name="dataflow_runs",
|
||||||
|
)
|
||||||
|
with op.batch_alter_table("dataflow_runs") as batch_op:
|
||||||
|
batch_op.drop_constraint(
|
||||||
|
"uq_dataflow_run_idempotency",
|
||||||
|
type_="unique",
|
||||||
|
)
|
||||||
|
batch_op.drop_column("output_materialization_ref")
|
||||||
|
batch_op.drop_column("output_datasource_ref")
|
||||||
|
batch_op.drop_column("output_publication_ref")
|
||||||
|
batch_op.drop_column("request")
|
||||||
|
batch_op.drop_column("request_hash")
|
||||||
|
batch_op.drop_column("idempotency_key")
|
||||||
@@ -5,6 +5,10 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from govoplan_core.audit.logging import audit_event
|
from govoplan_core.audit.logging import audit_event
|
||||||
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
||||||
|
from govoplan_core.core.dataflows import (
|
||||||
|
DataflowPublicationTarget,
|
||||||
|
DataflowRunRequest,
|
||||||
|
)
|
||||||
from govoplan_core.core.datasources import (
|
from govoplan_core.core.datasources import (
|
||||||
DatasourceAccessError,
|
DatasourceAccessError,
|
||||||
DatasourceDescriptor,
|
DatasourceDescriptor,
|
||||||
@@ -32,6 +36,9 @@ from govoplan_dataflow.backend.schemas import (
|
|||||||
PipelineListResponse,
|
PipelineListResponse,
|
||||||
PipelinePreviewRequest,
|
PipelinePreviewRequest,
|
||||||
PipelinePreviewResponse,
|
PipelinePreviewResponse,
|
||||||
|
PipelineRunCreateRequest,
|
||||||
|
PipelineRunListResponse,
|
||||||
|
PipelineRunResponse,
|
||||||
PipelineResponse,
|
PipelineResponse,
|
||||||
PipelineSqlResponse,
|
PipelineSqlResponse,
|
||||||
PipelineUpdateRequest,
|
PipelineUpdateRequest,
|
||||||
@@ -48,12 +55,17 @@ from govoplan_dataflow.backend.service import (
|
|||||||
DataflowValidationError,
|
DataflowValidationError,
|
||||||
compile_sql_draft,
|
compile_sql_draft,
|
||||||
create_pipeline,
|
create_pipeline,
|
||||||
|
cancel_pipeline_run,
|
||||||
delete_pipeline,
|
delete_pipeline,
|
||||||
get_pipeline,
|
get_pipeline,
|
||||||
|
get_pipeline_run,
|
||||||
|
list_pipeline_runs,
|
||||||
list_pipelines,
|
list_pipelines,
|
||||||
pipeline_response,
|
pipeline_response,
|
||||||
|
pipeline_run_response,
|
||||||
preview_pipeline,
|
preview_pipeline,
|
||||||
render_graph_sql,
|
render_graph_sql,
|
||||||
|
start_pipeline_run,
|
||||||
update_pipeline,
|
update_pipeline,
|
||||||
validate_draft,
|
validate_draft,
|
||||||
)
|
)
|
||||||
@@ -167,6 +179,7 @@ def _source_response(source: DatasourceDescriptor) -> TabularSourceResponse:
|
|||||||
source_name=source.source_name,
|
source_name=source.source_name,
|
||||||
name=source.name,
|
name=source.name,
|
||||||
description=source.description,
|
description=source.description,
|
||||||
|
mode=source.mode,
|
||||||
columns=[
|
columns=[
|
||||||
TabularSourceColumnResponse(
|
TabularSourceColumnResponse(
|
||||||
name=column.name,
|
name=column.name,
|
||||||
@@ -421,6 +434,152 @@ def api_delete_pipeline(
|
|||||||
return PipelineDeleteResponse(deleted=True, pipeline_id=pipeline.id)
|
return PipelineDeleteResponse(deleted=True, pipeline_id=pipeline.id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/pipelines/{pipeline_id}/runs",
|
||||||
|
response_model=PipelineRunListResponse,
|
||||||
|
)
|
||||||
|
def api_list_pipeline_runs(
|
||||||
|
pipeline_id: str,
|
||||||
|
limit: int = 50,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> PipelineRunListResponse:
|
||||||
|
_require_any_scope(principal, READ_SCOPE, RUN_SCOPE, ADMIN_SCOPE)
|
||||||
|
try:
|
||||||
|
runs = list_pipeline_runs(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
pipeline_id=pipeline_id,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
return PipelineRunListResponse(
|
||||||
|
runs=[pipeline_run_response(session, run) for run in runs]
|
||||||
|
)
|
||||||
|
except DataflowError as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/pipelines/{pipeline_id}/runs",
|
||||||
|
response_model=PipelineRunResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def api_start_pipeline_run(
|
||||||
|
pipeline_id: str,
|
||||||
|
payload: PipelineRunCreateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> PipelineRunResponse:
|
||||||
|
_require_any_scope(principal, RUN_SCOPE, ADMIN_SCOPE)
|
||||||
|
try:
|
||||||
|
pipeline = get_pipeline(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
pipeline_id=pipeline_id,
|
||||||
|
)
|
||||||
|
publication = (
|
||||||
|
DataflowPublicationTarget(
|
||||||
|
target_datasource_ref=payload.publication.target_datasource_ref,
|
||||||
|
name=payload.publication.name,
|
||||||
|
source_name=payload.publication.source_name,
|
||||||
|
description=payload.publication.description,
|
||||||
|
freeze=payload.publication.freeze,
|
||||||
|
frozen_label=payload.publication.frozen_label,
|
||||||
|
set_current=payload.publication.set_current,
|
||||||
|
metadata=payload.publication.metadata,
|
||||||
|
)
|
||||||
|
if payload.publication
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
run, replayed = start_pipeline_run(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
actor_id=_actor_id(principal),
|
||||||
|
principal=principal,
|
||||||
|
registry=get_registry(),
|
||||||
|
request=DataflowRunRequest(
|
||||||
|
pipeline_ref=f"pipeline:{pipeline.id}",
|
||||||
|
revision=payload.revision or pipeline.current_revision,
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
row_limit=payload.row_limit,
|
||||||
|
publication=publication,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except DataflowError as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
audit_event(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=getattr(principal.user, "id", None),
|
||||||
|
api_key_id=principal.api_key_id,
|
||||||
|
action=(
|
||||||
|
"dataflow.run.replayed"
|
||||||
|
if replayed
|
||||||
|
else f"dataflow.run.{run.status}"
|
||||||
|
),
|
||||||
|
object_type="dataflow_run",
|
||||||
|
object_id=run.id,
|
||||||
|
details={
|
||||||
|
"pipeline_id": run.pipeline_id,
|
||||||
|
"revision_id": run.pipeline_revision_id,
|
||||||
|
"status": run.status,
|
||||||
|
"output_datasource_ref": run.output_datasource_ref,
|
||||||
|
"output_materialization_ref": run.output_materialization_ref,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response = pipeline_run_response(session, run, replayed=replayed)
|
||||||
|
session.commit()
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/runs/{run_id}", response_model=PipelineRunResponse)
|
||||||
|
def api_get_pipeline_run(
|
||||||
|
run_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> PipelineRunResponse:
|
||||||
|
_require_any_scope(principal, READ_SCOPE, RUN_SCOPE, ADMIN_SCOPE)
|
||||||
|
try:
|
||||||
|
run = get_pipeline_run(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
run_ref=f"dataflow-run:{run_id}",
|
||||||
|
)
|
||||||
|
return pipeline_run_response(session, run)
|
||||||
|
except DataflowError as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/runs/{run_id}/cancel", response_model=PipelineRunResponse)
|
||||||
|
def api_cancel_pipeline_run(
|
||||||
|
run_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> PipelineRunResponse:
|
||||||
|
_require_any_scope(principal, RUN_SCOPE, ADMIN_SCOPE)
|
||||||
|
try:
|
||||||
|
run = cancel_pipeline_run(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
run_ref=f"dataflow-run:{run_id}",
|
||||||
|
)
|
||||||
|
except DataflowError as exc:
|
||||||
|
raise _http_error(exc) from exc
|
||||||
|
audit_event(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=getattr(principal.user, "id", None),
|
||||||
|
api_key_id=principal.api_key_id,
|
||||||
|
action="dataflow.run.cancelled",
|
||||||
|
object_type="dataflow_run",
|
||||||
|
object_id=run.id,
|
||||||
|
details={"pipeline_id": run.pipeline_id},
|
||||||
|
)
|
||||||
|
response = pipeline_run_response(session, run)
|
||||||
|
session.commit()
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.post("/validate", response_model=PipelineValidationResponse)
|
@router.post("/validate", response_model=PipelineValidationResponse)
|
||||||
def api_validate_pipeline(
|
def api_validate_pipeline(
|
||||||
payload: PipelineDraftRequest,
|
payload: PipelineDraftRequest,
|
||||||
|
|||||||
@@ -158,6 +158,68 @@ class PipelinePreviewResponse(BaseModel):
|
|||||||
executor_version: str
|
executor_version: str
|
||||||
|
|
||||||
|
|
||||||
|
class PipelineRunPublicationRequest(BaseModel):
|
||||||
|
target_datasource_ref: str | None = Field(default=None, max_length=500)
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=300)
|
||||||
|
source_name: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
min_length=1,
|
||||||
|
max_length=120,
|
||||||
|
pattern=r"^[A-Za-z_][A-Za-z0-9_]*$",
|
||||||
|
)
|
||||||
|
description: str | None = Field(default=None, max_length=4_000)
|
||||||
|
freeze: bool = False
|
||||||
|
frozen_label: str | None = Field(default=None, max_length=300)
|
||||||
|
set_current: bool = True
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_target(self) -> "PipelineRunPublicationRequest":
|
||||||
|
if self.target_datasource_ref:
|
||||||
|
return self
|
||||||
|
if not self.name or not self.source_name:
|
||||||
|
raise ValueError(
|
||||||
|
"New publication targets require name and source_name."
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class PipelineRunCreateRequest(BaseModel):
|
||||||
|
revision: int | None = Field(default=None, ge=1)
|
||||||
|
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||||
|
row_limit: int = Field(default=500, ge=1, le=500)
|
||||||
|
publication: PipelineRunPublicationRequest | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PipelineRunResponse(BaseModel):
|
||||||
|
ref: str
|
||||||
|
pipeline_id: str
|
||||||
|
revision: int
|
||||||
|
run_type: str
|
||||||
|
status: Literal["running", "succeeded", "failed", "cancelled"]
|
||||||
|
idempotency_key: str | None
|
||||||
|
definition_hash: str
|
||||||
|
executor_version: str
|
||||||
|
source_fingerprints: list[dict[str, Any]]
|
||||||
|
result_schema: list[PreviewColumn]
|
||||||
|
diagnostics: list[DataflowDiagnostic]
|
||||||
|
input_row_count: int
|
||||||
|
output_row_count: int
|
||||||
|
output_publication_ref: str | None
|
||||||
|
output_datasource_ref: str | None
|
||||||
|
output_materialization_ref: str | None
|
||||||
|
error: str | None
|
||||||
|
started_at: datetime
|
||||||
|
finished_at: datetime | None
|
||||||
|
created_by: str | None
|
||||||
|
created_at: datetime
|
||||||
|
replayed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class PipelineRunListResponse(BaseModel):
|
||||||
|
runs: list[PipelineRunResponse]
|
||||||
|
|
||||||
|
|
||||||
class PipelineDeleteResponse(BaseModel):
|
class PipelineDeleteResponse(BaseModel):
|
||||||
deleted: bool
|
deleted: bool
|
||||||
pipeline_id: str
|
pipeline_id: str
|
||||||
@@ -210,6 +272,7 @@ class TabularSourceResponse(BaseModel):
|
|||||||
source_name: str
|
source_name: str
|
||||||
name: str
|
name: str
|
||||||
description: str | None
|
description: str | None
|
||||||
|
mode: Literal["live", "cached", "static"]
|
||||||
columns: list[TabularSourceColumnResponse]
|
columns: list[TabularSourceColumnResponse]
|
||||||
schema_version: str
|
schema_version: str
|
||||||
fingerprint: str
|
fingerprint: str
|
||||||
|
|||||||
@@ -1,15 +1,26 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.auth import ApiPrincipal
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.dataflows import (
|
||||||
|
DataflowRunConflictError,
|
||||||
|
DataflowRunDescriptor,
|
||||||
|
DataflowRunError,
|
||||||
|
DataflowRunNotFoundError,
|
||||||
|
DataflowRunRequest,
|
||||||
|
)
|
||||||
from govoplan_core.core.datasources import (
|
from govoplan_core.core.datasources import (
|
||||||
DatasourceError,
|
DatasourceError,
|
||||||
|
DatasourcePublicationRequest,
|
||||||
DatasourceReadRequest,
|
DatasourceReadRequest,
|
||||||
datasource_catalogue,
|
datasource_catalogue,
|
||||||
|
datasource_publication,
|
||||||
)
|
)
|
||||||
from govoplan_core.db.base import utcnow
|
from govoplan_core.db.base import utcnow
|
||||||
from govoplan_dataflow.backend.db.models import (
|
from govoplan_dataflow.backend.db.models import (
|
||||||
@@ -34,6 +45,7 @@ from govoplan_dataflow.backend.schemas import (
|
|||||||
PipelinePreviewResponse,
|
PipelinePreviewResponse,
|
||||||
PipelineResponse,
|
PipelineResponse,
|
||||||
PipelineRevisionResponse,
|
PipelineRevisionResponse,
|
||||||
|
PipelineRunResponse,
|
||||||
PipelineSqlResponse,
|
PipelineSqlResponse,
|
||||||
PipelineUpdateRequest,
|
PipelineUpdateRequest,
|
||||||
PipelineValidationResponse,
|
PipelineValidationResponse,
|
||||||
@@ -45,15 +57,15 @@ from govoplan_dataflow.backend.sql_compiler import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class DataflowError(RuntimeError):
|
class DataflowError(DataflowRunError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class DataflowNotFoundError(DataflowError):
|
class DataflowNotFoundError(DataflowError, DataflowRunNotFoundError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class DataflowConflictError(DataflowError):
|
class DataflowConflictError(DataflowError, DataflowRunConflictError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@@ -495,6 +507,363 @@ def preview_pipeline(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_pipeline_runs(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
pipeline_id: str | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> list[DataflowRun]:
|
||||||
|
statement = (
|
||||||
|
select(DataflowRun)
|
||||||
|
.where(DataflowRun.tenant_id == tenant_id)
|
||||||
|
.order_by(DataflowRun.created_at.desc(), DataflowRun.id.desc())
|
||||||
|
.limit(max(1, min(int(limit), 100)))
|
||||||
|
)
|
||||||
|
if pipeline_id:
|
||||||
|
get_pipeline(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
pipeline_id=pipeline_id,
|
||||||
|
)
|
||||||
|
statement = statement.where(DataflowRun.pipeline_id == pipeline_id)
|
||||||
|
return list(session.scalars(statement))
|
||||||
|
|
||||||
|
|
||||||
|
def get_pipeline_run(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
run_ref: str,
|
||||||
|
) -> DataflowRun:
|
||||||
|
run_id = _strip_ref(run_ref, "dataflow-run:")
|
||||||
|
if not run_id:
|
||||||
|
raise DataflowNotFoundError("Dataflow run not found")
|
||||||
|
run = session.scalar(
|
||||||
|
select(DataflowRun).where(
|
||||||
|
DataflowRun.id == run_id,
|
||||||
|
DataflowRun.tenant_id == tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if run is None:
|
||||||
|
raise DataflowNotFoundError("Dataflow run not found")
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
def start_pipeline_run(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
actor_id: str | None,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
registry: object | None,
|
||||||
|
request: DataflowRunRequest,
|
||||||
|
) -> tuple[DataflowRun, bool]:
|
||||||
|
pipeline_id = _strip_ref(request.pipeline_ref, "pipeline:")
|
||||||
|
if not pipeline_id:
|
||||||
|
raise DataflowNotFoundError("Dataflow pipeline not found")
|
||||||
|
pipeline = get_pipeline(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
pipeline_id=pipeline_id,
|
||||||
|
)
|
||||||
|
revision = get_pipeline_revision(
|
||||||
|
session,
|
||||||
|
pipeline=pipeline,
|
||||||
|
revision=request.revision,
|
||||||
|
)
|
||||||
|
idempotency_key = request.idempotency_key.strip()
|
||||||
|
if not idempotency_key or len(idempotency_key) > 255:
|
||||||
|
raise DataflowConflictError(
|
||||||
|
"A Dataflow run idempotency key of at most 255 characters is required."
|
||||||
|
)
|
||||||
|
if request.row_limit < 1 or request.row_limit > 500:
|
||||||
|
raise DataflowConflictError(
|
||||||
|
"The bounded Dataflow runner supports between 1 and 500 output rows."
|
||||||
|
)
|
||||||
|
request_hash = _run_request_hash(request)
|
||||||
|
existing = session.scalar(
|
||||||
|
select(DataflowRun).where(
|
||||||
|
DataflowRun.tenant_id == tenant_id,
|
||||||
|
DataflowRun.pipeline_id == pipeline.id,
|
||||||
|
DataflowRun.idempotency_key == idempotency_key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
if existing.request_hash != request_hash:
|
||||||
|
raise DataflowConflictError(
|
||||||
|
"The Dataflow run idempotency key was already used with "
|
||||||
|
"different parameters."
|
||||||
|
)
|
||||||
|
return existing, True
|
||||||
|
|
||||||
|
graph = PipelineGraph.model_validate(revision.graph)
|
||||||
|
started_at = utcnow()
|
||||||
|
run = DataflowRun(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
pipeline_id=pipeline.id,
|
||||||
|
pipeline_revision_id=revision.id,
|
||||||
|
run_type="published" if request.publication else "run",
|
||||||
|
status="running",
|
||||||
|
executor_version=EXECUTOR_VERSION,
|
||||||
|
definition_hash=revision.content_hash,
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
request_hash=request_hash,
|
||||||
|
request_=_run_request_payload(request),
|
||||||
|
source_fingerprints=[],
|
||||||
|
result_schema=[],
|
||||||
|
diagnostics=[],
|
||||||
|
input_row_count=0,
|
||||||
|
output_row_count=0,
|
||||||
|
started_at=started_at,
|
||||||
|
created_by=actor_id,
|
||||||
|
)
|
||||||
|
session.add(run)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = execute_preview(
|
||||||
|
graph,
|
||||||
|
row_limit=request.row_limit,
|
||||||
|
source_resolver=_datasource_source_resolver(
|
||||||
|
session=session,
|
||||||
|
principal=principal,
|
||||||
|
registry=registry,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if request.publication and (
|
||||||
|
result.truncated
|
||||||
|
or any(
|
||||||
|
bool(item.get("truncated"))
|
||||||
|
for item in result.source_fingerprints
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise PipelineExecutionError(
|
||||||
|
"The bounded runner cannot publish a truncated result or a "
|
||||||
|
"result calculated from truncated source data."
|
||||||
|
)
|
||||||
|
|
||||||
|
run.source_fingerprints = result.source_fingerprints
|
||||||
|
run.result_schema = [
|
||||||
|
item.model_dump(mode="json") for item in result.columns
|
||||||
|
]
|
||||||
|
run.diagnostics = [
|
||||||
|
item.model_dump(mode="json") for item in result.diagnostics
|
||||||
|
]
|
||||||
|
run.input_row_count = result.input_row_count
|
||||||
|
run.output_row_count = result.total_rows
|
||||||
|
|
||||||
|
if request.publication:
|
||||||
|
publisher = datasource_publication(registry)
|
||||||
|
if publisher is None:
|
||||||
|
raise PipelineExecutionError(
|
||||||
|
"Publishing Dataflow output requires the Datasources "
|
||||||
|
"publication capability."
|
||||||
|
)
|
||||||
|
target = request.publication
|
||||||
|
publication = publisher.publish_rows(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
request=DatasourcePublicationRequest(
|
||||||
|
producer_module="dataflow",
|
||||||
|
producer_run_ref=f"dataflow-run:{run.id}",
|
||||||
|
idempotency_key=f"{pipeline.id}:{idempotency_key}",
|
||||||
|
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.output_publication_ref = publication.ref
|
||||||
|
run.output_datasource_ref = publication.datasource.ref
|
||||||
|
run.output_materialization_ref = publication.materialization.ref
|
||||||
|
run.status = "succeeded"
|
||||||
|
run.finished_at = utcnow()
|
||||||
|
run.error = None
|
||||||
|
except (DatasourceError, PipelineExecutionError) as exc:
|
||||||
|
run.status = "failed"
|
||||||
|
run.finished_at = utcnow()
|
||||||
|
run.error = str(exc)
|
||||||
|
diagnostics = list(getattr(exc, "diagnostics", ()))
|
||||||
|
diagnostics.append(
|
||||||
|
DataflowDiagnostic(
|
||||||
|
severity="error",
|
||||||
|
code="run.execution",
|
||||||
|
message=str(exc),
|
||||||
|
node_id=getattr(exc, "node_id", None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
run.diagnostics = [
|
||||||
|
item.model_dump(mode="json") for item in diagnostics
|
||||||
|
]
|
||||||
|
run.source_fingerprints = list(
|
||||||
|
getattr(exc, "source_fingerprints", ())
|
||||||
|
)
|
||||||
|
run.input_row_count = int(getattr(exc, "input_row_count", 0))
|
||||||
|
session.flush()
|
||||||
|
return run, False
|
||||||
|
|
||||||
|
|
||||||
|
def cancel_pipeline_run(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
run_ref: str,
|
||||||
|
) -> DataflowRun:
|
||||||
|
run = get_pipeline_run(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
run_ref=run_ref,
|
||||||
|
)
|
||||||
|
if run.status not in {"queued", "running"}:
|
||||||
|
raise DataflowConflictError(
|
||||||
|
f"Dataflow run is already {run.status} and cannot be cancelled."
|
||||||
|
)
|
||||||
|
run.status = "cancelled"
|
||||||
|
run.finished_at = utcnow()
|
||||||
|
run.error = "Cancelled by request."
|
||||||
|
session.flush()
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
def pipeline_run_response(
|
||||||
|
session: Session,
|
||||||
|
run: DataflowRun,
|
||||||
|
*,
|
||||||
|
replayed: bool = False,
|
||||||
|
) -> PipelineRunResponse:
|
||||||
|
revision = session.get(DataflowPipelineRevision, run.pipeline_revision_id)
|
||||||
|
if revision is None:
|
||||||
|
raise DataflowNotFoundError("Dataflow pipeline revision not found")
|
||||||
|
return PipelineRunResponse(
|
||||||
|
ref=f"dataflow-run:{run.id}",
|
||||||
|
pipeline_id=run.pipeline_id,
|
||||||
|
revision=revision.revision,
|
||||||
|
run_type=run.run_type,
|
||||||
|
status=run.status, # type: ignore[arg-type]
|
||||||
|
idempotency_key=run.idempotency_key,
|
||||||
|
definition_hash=run.definition_hash,
|
||||||
|
executor_version=run.executor_version,
|
||||||
|
source_fingerprints=list(run.source_fingerprints),
|
||||||
|
result_schema=list(run.result_schema),
|
||||||
|
diagnostics=list(run.diagnostics),
|
||||||
|
input_row_count=run.input_row_count,
|
||||||
|
output_row_count=run.output_row_count,
|
||||||
|
output_publication_ref=run.output_publication_ref,
|
||||||
|
output_datasource_ref=run.output_datasource_ref,
|
||||||
|
output_materialization_ref=run.output_materialization_ref,
|
||||||
|
error=run.error,
|
||||||
|
started_at=run.started_at,
|
||||||
|
finished_at=run.finished_at,
|
||||||
|
created_by=run.created_by,
|
||||||
|
created_at=run.created_at,
|
||||||
|
replayed=replayed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def pipeline_run_descriptor(
|
||||||
|
session: Session,
|
||||||
|
run: DataflowRun,
|
||||||
|
*,
|
||||||
|
replayed: bool = False,
|
||||||
|
) -> DataflowRunDescriptor:
|
||||||
|
revision = session.get(DataflowPipelineRevision, run.pipeline_revision_id)
|
||||||
|
if revision is None:
|
||||||
|
raise DataflowNotFoundError("Dataflow pipeline revision not found")
|
||||||
|
return DataflowRunDescriptor(
|
||||||
|
ref=f"dataflow-run:{run.id}",
|
||||||
|
pipeline_ref=f"pipeline:{run.pipeline_id}",
|
||||||
|
revision=revision.revision,
|
||||||
|
status=run.status,
|
||||||
|
definition_hash=run.definition_hash,
|
||||||
|
executor_version=run.executor_version,
|
||||||
|
input_row_count=run.input_row_count,
|
||||||
|
output_row_count=run.output_row_count,
|
||||||
|
output_publication_ref=run.output_publication_ref,
|
||||||
|
output_datasource_ref=run.output_datasource_ref,
|
||||||
|
output_materialization_ref=run.output_materialization_ref,
|
||||||
|
error=run.error,
|
||||||
|
started_at=run.started_at,
|
||||||
|
finished_at=run.finished_at,
|
||||||
|
replayed=replayed,
|
||||||
|
metadata={
|
||||||
|
"run_type": run.run_type,
|
||||||
|
"source_fingerprints": list(run.source_fingerprints),
|
||||||
|
"diagnostics": list(run.diagnostics),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SqlDataflowRunLifecycleProvider:
|
||||||
|
def __init__(self, *, registry: object | None = None) -> None:
|
||||||
|
self._registry = registry
|
||||||
|
|
||||||
|
def start_run(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: DataflowRunRequest,
|
||||||
|
) -> DataflowRunDescriptor:
|
||||||
|
db, api_principal = _run_context(session, principal)
|
||||||
|
run, replayed = start_pipeline_run(
|
||||||
|
db,
|
||||||
|
tenant_id=api_principal.tenant_id,
|
||||||
|
actor_id=_principal_actor_id(api_principal),
|
||||||
|
principal=api_principal,
|
||||||
|
registry=self._registry,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
return pipeline_run_descriptor(db, run, replayed=replayed)
|
||||||
|
|
||||||
|
def get_run(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
run_ref: str,
|
||||||
|
) -> DataflowRunDescriptor | None:
|
||||||
|
db, api_principal = _run_context(session, principal)
|
||||||
|
try:
|
||||||
|
run = get_pipeline_run(
|
||||||
|
db,
|
||||||
|
tenant_id=api_principal.tenant_id,
|
||||||
|
run_ref=run_ref,
|
||||||
|
)
|
||||||
|
except DataflowNotFoundError:
|
||||||
|
return None
|
||||||
|
return pipeline_run_descriptor(db, run)
|
||||||
|
|
||||||
|
def cancel_run(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
run_ref: str,
|
||||||
|
) -> DataflowRunDescriptor:
|
||||||
|
db, api_principal = _run_context(session, principal)
|
||||||
|
run = cancel_pipeline_run(
|
||||||
|
db,
|
||||||
|
tenant_id=api_principal.tenant_id,
|
||||||
|
run_ref=run_ref,
|
||||||
|
)
|
||||||
|
return pipeline_run_descriptor(db, run)
|
||||||
|
|
||||||
|
|
||||||
def normalize_definition(
|
def normalize_definition(
|
||||||
*,
|
*,
|
||||||
graph: PipelineGraph,
|
graph: PipelineGraph,
|
||||||
@@ -541,6 +910,109 @@ def _source_nodes(
|
|||||||
return nodes
|
return nodes
|
||||||
|
|
||||||
|
|
||||||
|
def _datasource_source_resolver(
|
||||||
|
*,
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
registry: object | None,
|
||||||
|
):
|
||||||
|
provider = datasource_catalogue(registry)
|
||||||
|
|
||||||
|
def resolve_source(node: GraphNode, limit: int) -> ResolvedSource:
|
||||||
|
if provider is None:
|
||||||
|
raise PipelineExecutionError(
|
||||||
|
"Datasource-backed execution requires the Datasources "
|
||||||
|
"catalogue capability.",
|
||||||
|
node_id=node.id,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
resolved = provider.read_datasource(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
request=DatasourceReadRequest(
|
||||||
|
datasource_ref=str(node.config["source_ref"]),
|
||||||
|
consistency=str(
|
||||||
|
node.config.get("consistency") or "current"
|
||||||
|
), # type: ignore[arg-type]
|
||||||
|
limit=limit,
|
||||||
|
expected_fingerprint=_clean_optional(
|
||||||
|
node.config.get("expected_fingerprint")
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except DatasourceError as exc:
|
||||||
|
raise PipelineExecutionError(str(exc), node_id=node.id) from exc
|
||||||
|
return ResolvedSource(
|
||||||
|
rows=tuple(dict(row) for row in resolved.rows),
|
||||||
|
source_ref=resolved.datasource.ref,
|
||||||
|
provider=resolved.datasource.provider or "datasources",
|
||||||
|
fingerprint=resolved.datasource.fingerprint,
|
||||||
|
total_rows=resolved.total_rows,
|
||||||
|
truncated=resolved.truncated,
|
||||||
|
)
|
||||||
|
|
||||||
|
return resolve_source
|
||||||
|
|
||||||
|
|
||||||
|
def _run_context(
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
) -> tuple[Session, ApiPrincipal]:
|
||||||
|
if not isinstance(session, Session):
|
||||||
|
raise TypeError("Dataflow run providers require a SQLAlchemy session.")
|
||||||
|
if not isinstance(principal, ApiPrincipal):
|
||||||
|
raise DataflowConflictError("A tenant API principal is required.")
|
||||||
|
if not principal.tenant_id:
|
||||||
|
raise DataflowConflictError("A tenant API principal is required.")
|
||||||
|
return session, principal
|
||||||
|
|
||||||
|
|
||||||
|
def _principal_actor_id(principal: ApiPrincipal) -> str | None:
|
||||||
|
return principal.account_id or principal.membership_id or principal.identity_id
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_ref(value: str, prefix: str) -> str | None:
|
||||||
|
cleaned = str(value or "").strip()
|
||||||
|
if not cleaned:
|
||||||
|
return None
|
||||||
|
if cleaned.startswith(prefix):
|
||||||
|
return cleaned[len(prefix) :]
|
||||||
|
return cleaned if ":" not in cleaned else None
|
||||||
|
|
||||||
|
|
||||||
|
def _run_request_payload(request: DataflowRunRequest) -> dict[str, object]:
|
||||||
|
target = request.publication
|
||||||
|
return {
|
||||||
|
"pipeline_ref": request.pipeline_ref,
|
||||||
|
"revision": request.revision,
|
||||||
|
"row_limit": request.row_limit,
|
||||||
|
"publication": (
|
||||||
|
{
|
||||||
|
"target_datasource_ref": target.target_datasource_ref,
|
||||||
|
"name": target.name,
|
||||||
|
"source_name": target.source_name,
|
||||||
|
"description": target.description,
|
||||||
|
"freeze": target.freeze,
|
||||||
|
"frozen_label": target.frozen_label,
|
||||||
|
"set_current": target.set_current,
|
||||||
|
"metadata": dict(target.metadata),
|
||||||
|
}
|
||||||
|
if target
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _run_request_hash(request: DataflowRunRequest) -> str:
|
||||||
|
encoded = json.dumps(
|
||||||
|
_run_request_payload(request),
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
default=str,
|
||||||
|
)
|
||||||
|
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def _clean_optional(value: str | None) -> str | None:
|
def _clean_optional(value: str | None) -> str | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
@@ -553,16 +1025,23 @@ __all__ = [
|
|||||||
"DataflowError",
|
"DataflowError",
|
||||||
"DataflowNotFoundError",
|
"DataflowNotFoundError",
|
||||||
"DataflowValidationError",
|
"DataflowValidationError",
|
||||||
|
"SqlDataflowRunLifecycleProvider",
|
||||||
|
"cancel_pipeline_run",
|
||||||
"compile_sql_draft",
|
"compile_sql_draft",
|
||||||
"create_pipeline",
|
"create_pipeline",
|
||||||
"delete_pipeline",
|
"delete_pipeline",
|
||||||
"get_pipeline",
|
"get_pipeline",
|
||||||
"get_pipeline_revision",
|
"get_pipeline_revision",
|
||||||
|
"get_pipeline_run",
|
||||||
|
"list_pipeline_runs",
|
||||||
"list_pipelines",
|
"list_pipelines",
|
||||||
"normalize_definition",
|
"normalize_definition",
|
||||||
"pipeline_response",
|
"pipeline_response",
|
||||||
|
"pipeline_run_descriptor",
|
||||||
|
"pipeline_run_response",
|
||||||
"preview_pipeline",
|
"preview_pipeline",
|
||||||
"render_graph_sql",
|
"render_graph_sql",
|
||||||
|
"start_pipeline_run",
|
||||||
"update_pipeline",
|
"update_pipeline",
|
||||||
"validate_draft",
|
"validate_draft",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.core.dataflows import CAPABILITY_DATAFLOW_RUN_LIFECYCLE
|
||||||
from govoplan_core.core.modules import ModuleManifest
|
from govoplan_core.core.modules import ModuleManifest
|
||||||
from govoplan_dataflow.backend.manifest import get_manifest
|
from govoplan_dataflow.backend.manifest import get_manifest
|
||||||
|
|
||||||
@@ -19,6 +20,10 @@ class DataflowManifestTests(unittest.TestCase):
|
|||||||
self.assertEqual(manifest.frontend.package_name, "@govoplan/dataflow-webui")
|
self.assertEqual(manifest.frontend.package_name, "@govoplan/dataflow-webui")
|
||||||
self.assertIsNotNone(manifest.route_factory)
|
self.assertIsNotNone(manifest.route_factory)
|
||||||
self.assertIsNotNone(manifest.migration_spec)
|
self.assertIsNotNone(manifest.migration_spec)
|
||||||
|
self.assertIn(
|
||||||
|
CAPABILITY_DATAFLOW_RUN_LIFECYCLE,
|
||||||
|
manifest.capability_factories,
|
||||||
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
{
|
{
|
||||||
"dataflow.pipeline_catalog",
|
"dataflow.pipeline_catalog",
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class DataflowMigrationTests(unittest.TestCase):
|
|||||||
try:
|
try:
|
||||||
with engine.connect() as connection:
|
with engine.connect() as connection:
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"d4f7a1c8e2b0",
|
"b8e3c6a1f4d9",
|
||||||
set(MigrationContext.configure(connection).get_current_heads()),
|
set(MigrationContext.configure(connection).get_current_heads()),
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
|
|||||||
@@ -5,6 +5,18 @@ import unittest
|
|||||||
from sqlalchemy import create_engine, func, select
|
from sqlalchemy import create_engine, func, select
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
from govoplan_core.core.dataflows import (
|
||||||
|
DataflowPublicationTarget,
|
||||||
|
DataflowRunRequest,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.datasources import (
|
||||||
|
CAPABILITY_DATASOURCE_PUBLICATION,
|
||||||
|
DatasourceDescriptor,
|
||||||
|
DatasourceMaterialization,
|
||||||
|
DatasourcePublicationResult,
|
||||||
|
)
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_dataflow.backend.db.models import (
|
from govoplan_dataflow.backend.db.models import (
|
||||||
DataflowPipeline,
|
DataflowPipeline,
|
||||||
@@ -28,6 +40,7 @@ from govoplan_dataflow.backend.service import (
|
|||||||
get_pipeline,
|
get_pipeline,
|
||||||
list_pipelines,
|
list_pipelines,
|
||||||
preview_pipeline,
|
preview_pipeline,
|
||||||
|
start_pipeline_run,
|
||||||
update_pipeline,
|
update_pipeline,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -71,6 +84,61 @@ def sample_graph(*, minimum: int = 10) -> PipelineGraph:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def principal(tenant_id: str = "tenant-1") -> ApiPrincipal:
|
||||||
|
return ApiPrincipal(
|
||||||
|
principal=PrincipalRef(
|
||||||
|
account_id="account-1",
|
||||||
|
membership_id="membership-1",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
scopes=frozenset(),
|
||||||
|
),
|
||||||
|
account=object(),
|
||||||
|
user=object(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakePublicationProvider:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.requests = []
|
||||||
|
|
||||||
|
def publish_rows(self, _session, _principal, *, request):
|
||||||
|
self.requests.append(request)
|
||||||
|
descriptor = DatasourceDescriptor(
|
||||||
|
ref=request.target_datasource_ref or "datasource:output-1",
|
||||||
|
source_name=request.source_name or "existing_output",
|
||||||
|
name=request.name or "Existing output",
|
||||||
|
kind="custom",
|
||||||
|
mode="static",
|
||||||
|
shape="tabular",
|
||||||
|
fingerprint="published-fingerprint",
|
||||||
|
)
|
||||||
|
return DatasourcePublicationResult(
|
||||||
|
ref="publication:publication-1",
|
||||||
|
status="published",
|
||||||
|
datasource=descriptor,
|
||||||
|
materialization=DatasourceMaterialization(
|
||||||
|
ref="materialization:materialization-1",
|
||||||
|
datasource_ref=descriptor.ref,
|
||||||
|
revision=1,
|
||||||
|
state="published",
|
||||||
|
fingerprint=descriptor.fingerprint,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRegistry:
|
||||||
|
def __init__(self, publication_provider: FakePublicationProvider) -> None:
|
||||||
|
self.publication_provider = publication_provider
|
||||||
|
|
||||||
|
def has_capability(self, name: str) -> bool:
|
||||||
|
return name == CAPABILITY_DATASOURCE_PUBLICATION
|
||||||
|
|
||||||
|
def capability(self, name: str):
|
||||||
|
if not self.has_capability(name):
|
||||||
|
raise KeyError(name)
|
||||||
|
return self.publication_provider
|
||||||
|
|
||||||
|
|
||||||
class DataflowServiceTests(unittest.TestCase):
|
class DataflowServiceTests(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.engine = create_engine("sqlite:///:memory:")
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
@@ -263,6 +331,109 @@ class DataflowServiceTests(unittest.TestCase):
|
|||||||
self.assertEqual(run.source_fingerprints, response.source_fingerprints)
|
self.assertEqual(run.source_fingerprints, response.source_fingerprints)
|
||||||
self.assertEqual("preview.execution", run.diagnostics[-1]["code"])
|
self.assertEqual("preview.execution", run.diagnostics[-1]["code"])
|
||||||
|
|
||||||
|
def test_pinned_run_publishes_once_and_replays_idempotently(self) -> None:
|
||||||
|
pipeline = self._create()
|
||||||
|
publication_provider = FakePublicationProvider()
|
||||||
|
request = DataflowRunRequest(
|
||||||
|
pipeline_ref=f"pipeline:{pipeline.id}",
|
||||||
|
revision=1,
|
||||||
|
idempotency_key="monthly-2026-07",
|
||||||
|
publication=DataflowPublicationTarget(
|
||||||
|
name="Monthly result",
|
||||||
|
source_name="monthly_result",
|
||||||
|
freeze=True,
|
||||||
|
frozen_label="July 2026",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
first, first_replayed = start_pipeline_run(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
actor_id="user-1",
|
||||||
|
principal=principal(),
|
||||||
|
registry=FakeRegistry(publication_provider),
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
replay, replayed = start_pipeline_run(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
actor_id="user-1",
|
||||||
|
principal=principal(),
|
||||||
|
registry=FakeRegistry(publication_provider),
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
self.assertFalse(first_replayed)
|
||||||
|
self.assertTrue(replayed)
|
||||||
|
self.assertEqual(first.id, replay.id)
|
||||||
|
self.assertEqual("succeeded", first.status)
|
||||||
|
self.assertEqual("datasource:output-1", first.output_datasource_ref)
|
||||||
|
self.assertEqual(
|
||||||
|
"materialization:materialization-1",
|
||||||
|
first.output_materialization_ref,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[{"id": 2, "amount": 15}, {"id": 3, "amount": 25}],
|
||||||
|
list(publication_provider.requests[0].rows),
|
||||||
|
)
|
||||||
|
self.assertEqual(1, len(publication_provider.requests))
|
||||||
|
|
||||||
|
def test_run_idempotency_key_rejects_changed_parameters(self) -> None:
|
||||||
|
pipeline = self._create()
|
||||||
|
publication_provider = FakePublicationProvider()
|
||||||
|
registry = FakeRegistry(publication_provider)
|
||||||
|
start_pipeline_run(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
actor_id="user-1",
|
||||||
|
principal=principal(),
|
||||||
|
registry=registry,
|
||||||
|
request=DataflowRunRequest(
|
||||||
|
pipeline_ref=f"pipeline:{pipeline.id}",
|
||||||
|
revision=1,
|
||||||
|
idempotency_key="stable-key",
|
||||||
|
row_limit=100,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(DataflowConflictError):
|
||||||
|
start_pipeline_run(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
actor_id="user-1",
|
||||||
|
principal=principal(),
|
||||||
|
registry=registry,
|
||||||
|
request=DataflowRunRequest(
|
||||||
|
pipeline_ref=f"pipeline:{pipeline.id}",
|
||||||
|
revision=1,
|
||||||
|
idempotency_key="stable-key",
|
||||||
|
row_limit=10,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_publication_without_datasources_finishes_as_failed_run(self) -> None:
|
||||||
|
pipeline = self._create()
|
||||||
|
run, _ = start_pipeline_run(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
actor_id="user-1",
|
||||||
|
principal=principal(),
|
||||||
|
registry=None,
|
||||||
|
request=DataflowRunRequest(
|
||||||
|
pipeline_ref=f"pipeline:{pipeline.id}",
|
||||||
|
revision=1,
|
||||||
|
idempotency_key="missing-publisher",
|
||||||
|
publication=DataflowPublicationTarget(
|
||||||
|
name="Output",
|
||||||
|
source_name="output",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("failed", run.status)
|
||||||
|
self.assertIn("Datasources publication capability", run.error)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ export type TabularSource = {
|
|||||||
source_name: string;
|
source_name: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
mode: "live" | "cached" | "static";
|
||||||
columns: TabularSourceColumn[];
|
columns: TabularSourceColumn[];
|
||||||
schema_version: string;
|
schema_version: string;
|
||||||
fingerprint: string;
|
fingerprint: string;
|
||||||
@@ -161,6 +162,31 @@ export type TabularSourceCatalogue = {
|
|||||||
sources: TabularSource[];
|
sources: TabularSource[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PipelineRun = {
|
||||||
|
ref: string;
|
||||||
|
pipeline_id: string;
|
||||||
|
revision: number;
|
||||||
|
run_type: string;
|
||||||
|
status: "running" | "succeeded" | "failed" | "cancelled";
|
||||||
|
idempotency_key?: string | null;
|
||||||
|
definition_hash: string;
|
||||||
|
executor_version: string;
|
||||||
|
source_fingerprints: Record<string, unknown>[];
|
||||||
|
result_schema: PreviewColumn[];
|
||||||
|
diagnostics: DataflowDiagnostic[];
|
||||||
|
input_row_count: number;
|
||||||
|
output_row_count: number;
|
||||||
|
output_publication_ref?: string | null;
|
||||||
|
output_datasource_ref?: string | null;
|
||||||
|
output_materialization_ref?: string | null;
|
||||||
|
error?: string | null;
|
||||||
|
started_at: string;
|
||||||
|
finished_at?: string | null;
|
||||||
|
created_by?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
replayed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export async function listDataflowNodeTypes(settings: ApiSettings): Promise<NodeTypeDefinition[]> {
|
export async function listDataflowNodeTypes(settings: ApiSettings): Promise<NodeTypeDefinition[]> {
|
||||||
const response = await apiFetch<{ nodes: NodeTypeDefinition[] }>(
|
const response = await apiFetch<{ nodes: NodeTypeDefinition[] }>(
|
||||||
settings,
|
settings,
|
||||||
@@ -280,3 +306,43 @@ export function previewDataflowPipeline(
|
|||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listDataflowPipelineRuns(
|
||||||
|
settings: ApiSettings,
|
||||||
|
pipelineId: string
|
||||||
|
): Promise<PipelineRun[]> {
|
||||||
|
const response = await apiFetch<{ runs: PipelineRun[] }>(
|
||||||
|
settings,
|
||||||
|
`/api/v1/dataflow/pipelines/${encodeURIComponent(pipelineId)}/runs?limit=25`
|
||||||
|
);
|
||||||
|
return response.runs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runDataflowPipeline(
|
||||||
|
settings: ApiSettings,
|
||||||
|
pipelineId: string,
|
||||||
|
payload: {
|
||||||
|
revision: number;
|
||||||
|
idempotency_key: string;
|
||||||
|
row_limit?: number;
|
||||||
|
publication?: {
|
||||||
|
target_datasource_ref?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
source_name?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
freeze?: boolean;
|
||||||
|
frozen_label?: string | null;
|
||||||
|
set_current?: boolean;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
): Promise<PipelineRun> {
|
||||||
|
return apiFetch(
|
||||||
|
settings,
|
||||||
|
`/api/v1/dataflow/pipelines/${encodeURIComponent(pipelineId)}/runs`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Code2,
|
Code2,
|
||||||
|
DatabaseZap,
|
||||||
Network,
|
Network,
|
||||||
Play,
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -28,6 +29,7 @@ import {
|
|||||||
LoadingFrame,
|
LoadingFrame,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
|
ToggleSwitch,
|
||||||
hasScope,
|
hasScope,
|
||||||
isApiError,
|
isApiError,
|
||||||
useUnsavedChanges,
|
useUnsavedChanges,
|
||||||
@@ -42,9 +44,11 @@ import {
|
|||||||
createDataflowSourceSnapshot,
|
createDataflowSourceSnapshot,
|
||||||
deleteDataflowPipeline,
|
deleteDataflowPipeline,
|
||||||
listDataflowNodeTypes,
|
listDataflowNodeTypes,
|
||||||
|
listDataflowPipelineRuns,
|
||||||
listDataflowPipelines,
|
listDataflowPipelines,
|
||||||
listDataflowSources,
|
listDataflowSources,
|
||||||
previewDataflowPipeline,
|
previewDataflowPipeline,
|
||||||
|
runDataflowPipeline,
|
||||||
renderDataflowSql,
|
renderDataflowSql,
|
||||||
updateDataflowPipeline,
|
updateDataflowPipeline,
|
||||||
validateDataflowPipeline,
|
validateDataflowPipeline,
|
||||||
@@ -56,6 +60,7 @@ import {
|
|||||||
type Pipeline,
|
type Pipeline,
|
||||||
type PipelineGraphNode,
|
type PipelineGraphNode,
|
||||||
type PipelinePreview,
|
type PipelinePreview,
|
||||||
|
type PipelineRun,
|
||||||
type TabularSource
|
type TabularSource
|
||||||
} from "../../api/dataflow";
|
} from "../../api/dataflow";
|
||||||
import DataflowCanvas, { updateGraphNode } from "./DataflowCanvas";
|
import DataflowCanvas, { updateGraphNode } from "./DataflowCanvas";
|
||||||
@@ -96,6 +101,7 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
|
|||||||
const [resultTab, setResultTab] = useState<ResultTab>("preview");
|
const [resultTab, setResultTab] = useState<ResultTab>("preview");
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
const [snapshotOpen, setSnapshotOpen] = useState(false);
|
const [snapshotOpen, setSnapshotOpen] = useState(false);
|
||||||
|
const [runOpen, setRunOpen] = useState(false);
|
||||||
const [nodeLibrary, setNodeLibrary] = useState<NodeTypeDefinition[]>(FALLBACK_NODE_LIBRARY);
|
const [nodeLibrary, setNodeLibrary] = useState<NodeTypeDefinition[]>(FALLBACK_NODE_LIBRARY);
|
||||||
const [sources, setSources] = useState<TabularSource[]>([]);
|
const [sources, setSources] = useState<TabularSource[]>([]);
|
||||||
const [sourceCatalogueAvailable, setSourceCatalogueAvailable] = useState(false);
|
const [sourceCatalogueAvailable, setSourceCatalogueAvailable] = useState(false);
|
||||||
@@ -533,6 +539,13 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
|
|||||||
<Button variant="primary" onClick={() => void runPreview()} disabled={working || !canRun}>
|
<Button variant="primary" onClick={() => void runPreview()} disabled={working || !canRun}>
|
||||||
<Play size={16} /> Preview
|
<Play size={16} /> Preview
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setRunOpen(true)}
|
||||||
|
disabled={working || !canRun || dirty || !draft.id || !draft.currentRevision}
|
||||||
|
title={dirty ? "Save the pipeline before starting a pinned run." : undefined}
|
||||||
|
>
|
||||||
|
<DatabaseZap size={16} /> Run
|
||||||
|
</Button>
|
||||||
<IconButton
|
<IconButton
|
||||||
label="Discard changes"
|
label="Discard changes"
|
||||||
icon={<RotateCcw size={16} />}
|
icon={<RotateCcw size={16} />}
|
||||||
@@ -724,10 +737,265 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
|
|||||||
setSelectedNodeId(node.id);
|
setSelectedNodeId(node.id);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<RunPipelineDialog
|
||||||
|
open={runOpen}
|
||||||
|
settings={settings}
|
||||||
|
pipeline={draft?.id && draft.currentRevision ? {
|
||||||
|
id: draft.id,
|
||||||
|
name: draft.name,
|
||||||
|
revision: draft.currentRevision
|
||||||
|
} : null}
|
||||||
|
sources={sources}
|
||||||
|
onClose={() => setRunOpen(false)}
|
||||||
|
onCompleted={(run) => {
|
||||||
|
if (run.status === "succeeded") {
|
||||||
|
setSuccess(
|
||||||
|
run.output_datasource_ref
|
||||||
|
? `Run published ${run.output_row_count} rows to ${run.output_datasource_ref}.`
|
||||||
|
: `Run completed with ${run.output_row_count} output rows.`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setError(run.error || "Dataflow run failed.");
|
||||||
|
}
|
||||||
|
if (run.output_datasource_ref) {
|
||||||
|
void listDataflowSources(settings).then((catalogue) => {
|
||||||
|
setSources(catalogue.sources);
|
||||||
|
setSourceCatalogueAvailable(catalogue.available);
|
||||||
|
setSourceCatalogueWritable(catalogue.writable);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RunMode = "run" | "publish";
|
||||||
|
|
||||||
|
function RunPipelineDialog({
|
||||||
|
open,
|
||||||
|
settings,
|
||||||
|
pipeline,
|
||||||
|
sources,
|
||||||
|
onClose,
|
||||||
|
onCompleted
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
settings: ApiSettings;
|
||||||
|
pipeline: { id: string; name: string; revision: number } | null;
|
||||||
|
sources: TabularSource[];
|
||||||
|
onClose: () => void;
|
||||||
|
onCompleted: (run: PipelineRun) => void;
|
||||||
|
}) {
|
||||||
|
const [mode, setMode] = useState<RunMode>("run");
|
||||||
|
const [targetRef, setTargetRef] = useState("new");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [sourceName, setSourceName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [freeze, setFreeze] = useState(false);
|
||||||
|
const [frozenLabel, setFrozenLabel] = useState("");
|
||||||
|
const [idempotencyKey, setIdempotencyKey] = useState("");
|
||||||
|
const [runs, setRuns] = useState<PipelineRun[]>([]);
|
||||||
|
const [loadingRuns, setLoadingRuns] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const publicationTargets = sources.filter((source) =>
|
||||||
|
source.mode !== "live" && source.capabilities.includes("read")
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !pipeline) return;
|
||||||
|
setMode("run");
|
||||||
|
setTargetRef("new");
|
||||||
|
setName(`${pipeline.name} output`);
|
||||||
|
setSourceName(sourceNameFromFile(`${pipeline.name}_output`));
|
||||||
|
setDescription("");
|
||||||
|
setFreeze(false);
|
||||||
|
setFrozenLabel("");
|
||||||
|
setIdempotencyKey(crypto.randomUUID());
|
||||||
|
setError("");
|
||||||
|
setLoadingRuns(true);
|
||||||
|
void listDataflowPipelineRuns(settings, pipeline.id)
|
||||||
|
.then(setRuns)
|
||||||
|
.catch((loadError) => setError(apiErrorMessage(loadError)))
|
||||||
|
.finally(() => setLoadingRuns(false));
|
||||||
|
}, [open, pipeline?.id, pipeline?.revision, settings]);
|
||||||
|
|
||||||
|
const start = async () => {
|
||||||
|
if (!pipeline) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const run = await runDataflowPipeline(settings, pipeline.id, {
|
||||||
|
revision: pipeline.revision,
|
||||||
|
idempotency_key: idempotencyKey,
|
||||||
|
row_limit: 500,
|
||||||
|
publication: mode === "publish" ? {
|
||||||
|
target_datasource_ref: targetRef === "new" ? null : targetRef,
|
||||||
|
name: targetRef === "new" ? name.trim() : null,
|
||||||
|
source_name: targetRef === "new" ? sourceName.trim() : null,
|
||||||
|
description: targetRef === "new" ? description.trim() || null : null,
|
||||||
|
freeze,
|
||||||
|
frozen_label: freeze ? frozenLabel.trim() || null : null,
|
||||||
|
set_current: true
|
||||||
|
} : null
|
||||||
|
});
|
||||||
|
setRuns((current) => [
|
||||||
|
run,
|
||||||
|
...current.filter((item) => item.ref !== run.ref)
|
||||||
|
]);
|
||||||
|
onCompleted(run);
|
||||||
|
setIdempotencyKey(crypto.randomUUID());
|
||||||
|
if (run.status !== "succeeded") {
|
||||||
|
setError(run.error || "Dataflow run failed.");
|
||||||
|
}
|
||||||
|
} catch (runError) {
|
||||||
|
setError(apiErrorMessage(runError));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const targetComplete = mode === "run"
|
||||||
|
|| targetRef !== "new"
|
||||||
|
|| Boolean(name.trim() && sourceName.trim());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
title={`Run ${pipeline?.name ?? "pipeline"}`}
|
||||||
|
className="dataflow-run-dialog"
|
||||||
|
onClose={() => {
|
||||||
|
if (!busy) onClose();
|
||||||
|
}}
|
||||||
|
footer={(
|
||||||
|
<>
|
||||||
|
<Button onClick={onClose} disabled={busy}>Close</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => void start()}
|
||||||
|
disabled={busy || !pipeline || !targetComplete}
|
||||||
|
>
|
||||||
|
<Play size={16} /> {busy ? "Running..." : "Run revision"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="dataflow-run-dialog-content">
|
||||||
|
{error ? (
|
||||||
|
<DismissibleAlert tone="danger" resetKey={error}>
|
||||||
|
{error}
|
||||||
|
</DismissibleAlert>
|
||||||
|
) : null}
|
||||||
|
<div className="dataflow-run-controls">
|
||||||
|
<SegmentedControl<RunMode>
|
||||||
|
ariaLabel="Run output"
|
||||||
|
options={[
|
||||||
|
{ id: "run", label: "Run only" },
|
||||||
|
{ id: "publish", label: "Publish result" }
|
||||||
|
]}
|
||||||
|
value={mode}
|
||||||
|
onChange={setMode}
|
||||||
|
/>
|
||||||
|
<span className="dataflow-run-revision">
|
||||||
|
Revision {pipeline?.revision ?? "-"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{mode === "publish" ? (
|
||||||
|
<div className="dataflow-run-publication-fields">
|
||||||
|
<FormField label="Target">
|
||||||
|
<select
|
||||||
|
value={targetRef}
|
||||||
|
onChange={(event) => setTargetRef(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="new">New datasource</option>
|
||||||
|
{publicationTargets.map((source) => (
|
||||||
|
<option key={source.ref} value={source.ref}>
|
||||||
|
{source.name} ({source.mode})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
{targetRef === "new" ? (
|
||||||
|
<>
|
||||||
|
<FormField label="Name">
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Logical source name">
|
||||||
|
<input
|
||||||
|
value={sourceName}
|
||||||
|
onChange={(event) => setSourceName(event.target.value)}
|
||||||
|
pattern="[A-Za-z_][A-Za-z0-9_]*"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Description">
|
||||||
|
<input
|
||||||
|
value={description}
|
||||||
|
onChange={(event) => setDescription(event.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<div className="dataflow-run-freeze">
|
||||||
|
<ToggleSwitch
|
||||||
|
label="Freeze published state"
|
||||||
|
checked={freeze}
|
||||||
|
onChange={setFreeze}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{freeze ? (
|
||||||
|
<FormField label="Frozen-state label">
|
||||||
|
<input
|
||||||
|
value={frozenLabel}
|
||||||
|
onChange={(event) => setFrozenLabel(event.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<section className="dataflow-run-history">
|
||||||
|
<div className="dataflow-run-history-heading">
|
||||||
|
<strong>Recent runs</strong>
|
||||||
|
<small>{loadingRuns ? "Loading..." : `${runs.length} shown`}</small>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Started</th>
|
||||||
|
<th>Revision</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Rows</th>
|
||||||
|
<th>Output</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{runs.map((run) => (
|
||||||
|
<tr key={run.ref}>
|
||||||
|
<td>{new Date(run.started_at).toLocaleString()}</td>
|
||||||
|
<td>{run.revision}</td>
|
||||||
|
<td><StatusBadge status={run.status} label={run.status} /></td>
|
||||||
|
<td>{run.output_row_count.toLocaleString()}</td>
|
||||||
|
<td title={run.output_materialization_ref ?? ""}>
|
||||||
|
{run.output_datasource_ref ?? "Run evidence"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!loadingRuns && !runs.length ? (
|
||||||
|
<tr><td colSpan={5}>No runs yet</td></tr>
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SourceSnapshotDialog({
|
function SourceSnapshotDialog({
|
||||||
open,
|
open,
|
||||||
settings,
|
settings,
|
||||||
|
|||||||
@@ -601,6 +601,106 @@
|
|||||||
width: min(720px, calc(100vw - 32px));
|
width: min(720px, calc(100vw - 32px));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dataflow-run-dialog {
|
||||||
|
width: min(860px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataflow-run-dialog-content {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
max-height: min(680px, calc(100vh - 210px));
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataflow-run-controls,
|
||||||
|
.dataflow-run-history-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataflow-run-revision,
|
||||||
|
.dataflow-run-history-heading small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataflow-run-publication-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-top: var(--border-line);
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataflow-run-publication-fields input,
|
||||||
|
.dataflow-run-publication-fields select {
|
||||||
|
margin-top: 5px;
|
||||||
|
padding: 7px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataflow-run-freeze {
|
||||||
|
display: flex;
|
||||||
|
min-height: 54px;
|
||||||
|
align-items: end;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataflow-run-history {
|
||||||
|
display: flex;
|
||||||
|
min-height: 190px;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataflow-run-history-heading {
|
||||||
|
min-height: 36px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataflow-run-history-heading strong {
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataflow-run-history > div:last-child {
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataflow-run-history table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataflow-run-history th,
|
||||||
|
.dataflow-run-history td {
|
||||||
|
max-width: 230px;
|
||||||
|
border-bottom: var(--border-line);
|
||||||
|
padding: 7px 9px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: left;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataflow-run-history th {
|
||||||
|
position: sticky;
|
||||||
|
z-index: 1;
|
||||||
|
top: 0;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataflow-run-history tbody tr:hover {
|
||||||
|
background: var(--primary-soft);
|
||||||
|
}
|
||||||
|
|
||||||
.dataflow-source-dialog-fields {
|
.dataflow-source-dialog-fields {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
@@ -942,4 +1042,8 @@
|
|||||||
.dataflow-results {
|
.dataflow-results {
|
||||||
flex-basis: 220px;
|
flex-basis: 220px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dataflow-run-publication-fields {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user