feat(dataflow): preserve publication review outcomes
Module Package Release / publish-packages (push) Successful in 13s
Module Package Release / publish-packages (push) Successful in 13s
This commit is contained in:
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-dataflow"
|
name = "govoplan-dataflow"
|
||||||
version = "0.1.18"
|
version = "0.1.19"
|
||||||
description = "Governed graphical and SQL data pipelines for GovOPlaN."
|
description = "Governed graphical and SQL data pipelines for GovOPlaN."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
__version__ = "0.1.18"
|
__version__ = "0.1.19"
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ from govoplan_dataflow.backend.dsar_provider import (
|
|||||||
|
|
||||||
MODULE_ID = "dataflow"
|
MODULE_ID = "dataflow"
|
||||||
MODULE_NAME = "Dataflow"
|
MODULE_NAME = "Dataflow"
|
||||||
MODULE_VERSION = "0.1.18"
|
MODULE_VERSION = "0.1.19"
|
||||||
|
|
||||||
READ_SCOPE = "dataflow:pipeline:read"
|
READ_SCOPE = "dataflow:pipeline:read"
|
||||||
WRITE_SCOPE = "dataflow:pipeline:write"
|
WRITE_SCOPE = "dataflow:pipeline:write"
|
||||||
@@ -262,7 +262,12 @@ DOCUMENTATION = (
|
|||||||
"Scope determines ownership and Policy inheritance. Templates can be derived but not run; complete "
|
"Scope determines ownership and Policy inheritance. Templates can be derived but not run; complete "
|
||||||
"flows may be previewed, revisioned, automated, and executed when effective Policy allows it. Saving "
|
"flows may be previewed, revisioned, automated, and executed when effective Policy allows it. Saving "
|
||||||
"appends an immutable revision. A scoped copy pins its source revision and content hash. Triggers pin "
|
"appends an immutable revision. A scoped copy pins its source revision and content hash. Triggers pin "
|
||||||
"the revision and authorization grant, then re-evaluate authority for every delivery. Runs create "
|
"the revision and authorization grant, then re-evaluate authority for every delivery. Allow runs is "
|
||||||
|
"the definition-level admission boundary and does not grant a caller permission. A one-time run uses "
|
||||||
|
"the configured tenant-local date and time. The missed-run policy either coalesces elapsed interval "
|
||||||
|
"occurrences into one latest delivery or skips them; it never silently replays every missed occurrence. "
|
||||||
|
"The concurrency limit bounds active deliveries for that trigger and does not increase tenant worker "
|
||||||
|
"capacity. Runs create "
|
||||||
"durable command and recovery evidence. Publishing creates a governed Datasource materialization, and "
|
"durable command and recovery evidence. Publishing creates a governed Datasource materialization, and "
|
||||||
"environment promotion changes which immutable revision is eligible for staging or production runs. "
|
"environment promotion changes which immutable revision is eligible for staging or production runs. "
|
||||||
"Recording a reconciliation decision uses optimistic concurrency and appends an immutable revision; "
|
"Recording a reconciliation decision uses optimistic concurrency and appends an immutable revision; "
|
||||||
@@ -285,6 +290,10 @@ DOCUMENTATION = (
|
|||||||
"help_contexts": [
|
"help_contexts": [
|
||||||
"dataflow.field.scope",
|
"dataflow.field.scope",
|
||||||
"dataflow.field.definition-kind",
|
"dataflow.field.definition-kind",
|
||||||
|
"dataflow.field.allow-runs",
|
||||||
|
"dataflow.field.trigger-run-at",
|
||||||
|
"dataflow.field.trigger-missed-runs",
|
||||||
|
"dataflow.field.trigger-concurrency",
|
||||||
"dataflow.action.save",
|
"dataflow.action.save",
|
||||||
"dataflow.action.derive",
|
"dataflow.action.derive",
|
||||||
"dataflow.action.trigger",
|
"dataflow.action.trigger",
|
||||||
@@ -309,7 +318,11 @@ DOCUMENTATION = (
|
|||||||
"environment, progress, cancellation, output, and recovery state. Database-only runs commit atomically. "
|
"environment, progress, cancellation, output, and recovery state. Database-only runs commit atomically. "
|
||||||
"Publication to a governed Datasource uses forward recovery: an unknown provider outcome is reconciled "
|
"Publication to a governed Datasource uses forward recovery: an unknown provider outcome is reconciled "
|
||||||
"before retry so output is not duplicated. Staging and production promotion is explicit and does not "
|
"before retry so output is not duplicated. Staging and production promotion is explicit and does not "
|
||||||
"rewrite a revision. Cancellation is best effort once external work has started; the final evidence "
|
"rewrite a revision. Freezing a published state assigns a durable label to the exact immutable output; "
|
||||||
|
"it does not copy or detach the data from Datasources retention, hold, and access rules. Artifact-backed "
|
||||||
|
"outputs return the same stable publication, datasource, and materialization references as inline "
|
||||||
|
"outputs. Datasource warnings and review-required states remain visible to Workflow instead of being "
|
||||||
|
"collapsed into success. Cancellation is best effort once external work has started; the final evidence "
|
||||||
"states whether work stopped, completed, failed, or requires operator reconciliation. Scheduled, event, "
|
"states whether work stopped, completed, failed, or requires operator reconciliation. Scheduled, event, "
|
||||||
"and queued execution is partitioned by tenant module entitlement before a run is claimed. Disabling "
|
"and queued execution is partitioned by tenant module entitlement before a run is claimed. Disabling "
|
||||||
"Dataflow stops new admission and leaves accepted runs available for an explicit operator decision."
|
"Dataflow stops new admission and leaves accepted runs available for an explicit operator decision."
|
||||||
@@ -325,6 +338,7 @@ DOCUMENTATION = (
|
|||||||
"dataflow.runs",
|
"dataflow.runs",
|
||||||
"dataflow.action.queue-run",
|
"dataflow.action.queue-run",
|
||||||
"dataflow.action.publish",
|
"dataflow.action.publish",
|
||||||
|
"dataflow.field.freeze-publication",
|
||||||
"dataflow.action.promote-staging",
|
"dataflow.action.promote-staging",
|
||||||
"dataflow.action.promote-production",
|
"dataflow.action.promote-production",
|
||||||
"dataflow.state.recovery-attention",
|
"dataflow.state.recovery-attention",
|
||||||
|
|||||||
@@ -1584,6 +1584,26 @@ def _publish_pipeline_result(
|
|||||||
run.output_publication_ref = publication.ref
|
run.output_publication_ref = publication.ref
|
||||||
run.output_datasource_ref = publication.datasource.ref
|
run.output_datasource_ref = publication.datasource.ref
|
||||||
run.output_materialization_ref = publication.materialization.ref
|
run.output_materialization_ref = publication.materialization.ref
|
||||||
|
if publication.status in {"published_with_warnings", "review_required"}:
|
||||||
|
diagnostics = list(run.diagnostics)
|
||||||
|
diagnostics.append(
|
||||||
|
DataflowDiagnostic(
|
||||||
|
severity="warning",
|
||||||
|
code=(
|
||||||
|
"publication.review_required"
|
||||||
|
if publication.status == "review_required"
|
||||||
|
else "publication.warning"
|
||||||
|
),
|
||||||
|
message=(
|
||||||
|
"The output materialization requires review before it can "
|
||||||
|
"become the datasource's current state."
|
||||||
|
if publication.status == "review_required"
|
||||||
|
else "The output was published with datasource validation "
|
||||||
|
"warnings."
|
||||||
|
),
|
||||||
|
).model_dump(mode="json")
|
||||||
|
)
|
||||||
|
run.diagnostics = diagnostics
|
||||||
|
|
||||||
|
|
||||||
def _mark_pipeline_run_failed(
|
def _mark_pipeline_run_failed(
|
||||||
|
|||||||
+33
-2
@@ -123,8 +123,9 @@ def runtime_identity() -> RuntimeIdentity:
|
|||||||
|
|
||||||
|
|
||||||
class FakePublicationProvider:
|
class FakePublicationProvider:
|
||||||
def __init__(self) -> None:
|
def __init__(self, status: str = "published") -> None:
|
||||||
self.requests = []
|
self.requests = []
|
||||||
|
self.status = status
|
||||||
|
|
||||||
def publish_rows(self, _session, _principal, *, request):
|
def publish_rows(self, _session, _principal, *, request):
|
||||||
self.requests.append(request)
|
self.requests.append(request)
|
||||||
@@ -139,7 +140,7 @@ class FakePublicationProvider:
|
|||||||
)
|
)
|
||||||
return DatasourcePublicationResult(
|
return DatasourcePublicationResult(
|
||||||
ref="publication:publication-1",
|
ref="publication:publication-1",
|
||||||
status="published",
|
status=self.status,
|
||||||
datasource=descriptor,
|
datasource=descriptor,
|
||||||
materialization=DatasourceMaterialization(
|
materialization=DatasourceMaterialization(
|
||||||
ref="materialization:materialization-1",
|
ref="materialization:materialization-1",
|
||||||
@@ -539,6 +540,36 @@ class DataflowServiceTests(unittest.TestCase):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_publication_review_state_is_exposed_to_workflow_as_a_warning(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
pipeline = self._create()
|
||||||
|
provider = FakePublicationProvider("review_required")
|
||||||
|
|
||||||
|
run, _ = start_pipeline_run(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
actor_id="user-1",
|
||||||
|
principal=principal(),
|
||||||
|
registry=FakeRegistry(provider),
|
||||||
|
request=DataflowRunRequest(
|
||||||
|
pipeline_ref=f"pipeline:{pipeline.id}",
|
||||||
|
revision=1,
|
||||||
|
idempotency_key="review-publication",
|
||||||
|
publication=DataflowPublicationTarget(
|
||||||
|
name="Review output",
|
||||||
|
source_name="review_output",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("succeeded", run.status)
|
||||||
|
self.assertEqual(
|
||||||
|
"publication.review_required",
|
||||||
|
run.diagnostics[-1]["code"],
|
||||||
|
)
|
||||||
|
self.assertEqual("warning", run.diagnostics[-1]["severity"])
|
||||||
|
|
||||||
def test_publication_without_datasources_finishes_as_failed_run(self) -> None:
|
def test_publication_without_datasources_finishes_as_failed_run(self) -> None:
|
||||||
pipeline = self._create()
|
pipeline = self._create()
|
||||||
run, _ = start_pipeline_run(
|
run, _ = start_pipeline_run(
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/dataflow-webui",
|
"name": "@govoplan/dataflow-webui",
|
||||||
"version": "0.1.18",
|
"version": "0.1.19",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
@@ -1201,6 +1201,10 @@ function DefinitionSettingsDialog({
|
|||||||
/>
|
/>
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
label="Allow runs"
|
label="Allow runs"
|
||||||
|
interfaceId="dataflow.field.allow-runs"
|
||||||
|
helpContextId="dataflow.field.allow-runs"
|
||||||
|
helpModuleId="dataflow"
|
||||||
|
helpTopicId="dataflow.reference.fields-and-consequences"
|
||||||
checked={draft.allowRun}
|
checked={draft.allowRun}
|
||||||
disabled={!editable}
|
disabled={!editable}
|
||||||
onChange={(value) => onChange({ allowRun: value })}
|
onChange={(value) => onChange({ allowRun: value })}
|
||||||
@@ -1737,7 +1741,7 @@ function DataflowTriggersDialog({
|
|||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
{kind === "once" ? (
|
{kind === "once" ? (
|
||||||
<FormField label="Run at" documentation={DATAFLOW_FIELDS_DOCUMENTATION}>
|
<FormField label="Run at" interfaceId="dataflow.field.trigger-run-at" helpContextId="dataflow.field.trigger-run-at" helpModuleId="dataflow" documentation={DATAFLOW_FIELDS_DOCUMENTATION}>
|
||||||
<input disabled={!editable} type="datetime-local" value={runAt} onChange={(event) => setRunAt(event.target.value)} />
|
<input disabled={!editable} type="datetime-local" value={runAt} onChange={(event) => setRunAt(event.target.value)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1752,7 +1756,7 @@ function DataflowTriggersDialog({
|
|||||||
onChange={(event) => setIntervalMinutes(Math.max(1, Number(event.target.value)))}
|
onChange={(event) => setIntervalMinutes(Math.max(1, Number(event.target.value)))}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Missed runs" documentation={DATAFLOW_FIELDS_DOCUMENTATION}>
|
<FormField label="Missed runs" interfaceId="dataflow.field.trigger-missed-runs" helpContextId="dataflow.field.trigger-missed-runs" helpModuleId="dataflow" documentation={DATAFLOW_FIELDS_DOCUMENTATION}>
|
||||||
<select
|
<select
|
||||||
disabled={!editable}
|
disabled={!editable}
|
||||||
value={catchUpPolicy}
|
value={catchUpPolicy}
|
||||||
@@ -1787,7 +1791,7 @@ function DataflowTriggersDialog({
|
|||||||
disabled={busy || !editable}
|
disabled={busy || !editable}
|
||||||
onChange={setEnabled}
|
onChange={setEnabled}
|
||||||
/>
|
/>
|
||||||
<FormField label="Concurrent runs" documentation={DATAFLOW_FIELDS_DOCUMENTATION}>
|
<FormField label="Concurrent runs" interfaceId="dataflow.field.trigger-concurrency" helpContextId="dataflow.field.trigger-concurrency" helpModuleId="dataflow" documentation={DATAFLOW_FIELDS_DOCUMENTATION}>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
@@ -2155,6 +2159,10 @@ function RunPipelineDialog({
|
|||||||
<div className="dataflow-run-freeze">
|
<div className="dataflow-run-freeze">
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
label="Freeze published state"
|
label="Freeze published state"
|
||||||
|
interfaceId="dataflow.field.freeze-publication"
|
||||||
|
helpContextId="dataflow.field.freeze-publication"
|
||||||
|
helpModuleId="dataflow"
|
||||||
|
helpTopicId="dataflow.execution-and-recovery"
|
||||||
checked={freeze}
|
checked={freeze}
|
||||||
onChange={setFreeze}
|
onChange={setFreeze}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user