feat: run pinned pipelines and publish outputs
This commit is contained in:
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.dataflows import CAPABILITY_DATAFLOW_RUN_LIFECYCLE
|
||||
from govoplan_core.core.modules import ModuleManifest
|
||||
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.assertIsNotNone(manifest.route_factory)
|
||||
self.assertIsNotNone(manifest.migration_spec)
|
||||
self.assertIn(
|
||||
CAPABILITY_DATAFLOW_RUN_LIFECYCLE,
|
||||
manifest.capability_factories,
|
||||
)
|
||||
self.assertEqual(
|
||||
{
|
||||
"dataflow.pipeline_catalog",
|
||||
|
||||
@@ -24,7 +24,7 @@ class DataflowMigrationTests(unittest.TestCase):
|
||||
try:
|
||||
with engine.connect() as connection:
|
||||
self.assertIn(
|
||||
"d4f7a1c8e2b0",
|
||||
"b8e3c6a1f4d9",
|
||||
set(MigrationContext.configure(connection).get_current_heads()),
|
||||
)
|
||||
self.assertEqual(
|
||||
|
||||
@@ -5,6 +5,18 @@ import unittest
|
||||
from sqlalchemy import create_engine, func, select
|
||||
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_dataflow.backend.db.models import (
|
||||
DataflowPipeline,
|
||||
@@ -28,6 +40,7 @@ from govoplan_dataflow.backend.service import (
|
||||
get_pipeline,
|
||||
list_pipelines,
|
||||
preview_pipeline,
|
||||
start_pipeline_run,
|
||||
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):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
@@ -263,6 +331,109 @@ class DataflowServiceTests(unittest.TestCase):
|
||||
self.assertEqual(run.source_fingerprints, response.source_fingerprints)
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user