Add governed reusable pipelines and triggers
This commit is contained in:
@@ -8,8 +8,20 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_valida
|
||||
|
||||
|
||||
PipelineStatus = Literal["draft", "active", "archived"]
|
||||
DefinitionScopeType = Literal["system", "tenant", "group", "user"]
|
||||
DefinitionKind = Literal["flow", "template"]
|
||||
EditorMode = Literal["graph", "sql"]
|
||||
DiagnosticSeverity = Literal["info", "warning", "error"]
|
||||
TriggerKind = Literal["once", "interval", "event"]
|
||||
TriggerStatus = Literal["enabled", "disabled", "completed", "blocked"]
|
||||
TriggerDeliveryStatus = Literal[
|
||||
"queued",
|
||||
"running",
|
||||
"succeeded",
|
||||
"failed",
|
||||
"blocked",
|
||||
"skipped",
|
||||
]
|
||||
|
||||
|
||||
class GraphPosition(BaseModel):
|
||||
@@ -68,9 +80,32 @@ class PipelineRevisionResponse(BaseModel):
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class DefinitionActionDecisionResponse(BaseModel):
|
||||
allowed: bool
|
||||
reason: str | None = None
|
||||
source_path: list[dict[str, Any]] = Field(default_factory=list)
|
||||
requirements: list[str] = Field(default_factory=list)
|
||||
details: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PipelineGovernanceResponse(BaseModel):
|
||||
scope_type: DefinitionScopeType
|
||||
scope_id: str | None
|
||||
definition_kind: DefinitionKind
|
||||
inherit_to_lower_scopes: bool
|
||||
allow_run: bool
|
||||
allow_reuse: bool
|
||||
allow_automation: bool
|
||||
derived_from_pipeline_id: str | None
|
||||
derived_from_revision: int | None
|
||||
derived_from_hash: str | None
|
||||
derivation_provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
actions: dict[str, DefinitionActionDecisionResponse]
|
||||
|
||||
|
||||
class PipelineResponse(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
tenant_id: str | None
|
||||
name: str
|
||||
description: str | None
|
||||
status: PipelineStatus
|
||||
@@ -80,6 +115,7 @@ class PipelineResponse(BaseModel):
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
revision: PipelineRevisionResponse
|
||||
governance: PipelineGovernanceResponse
|
||||
|
||||
|
||||
class PipelineListResponse(BaseModel):
|
||||
@@ -93,12 +129,38 @@ class PipelineCreateRequest(BaseModel):
|
||||
graph: PipelineGraph
|
||||
sql_text: str | None = Field(default=None, max_length=100_000)
|
||||
editor_mode: EditorMode = "graph"
|
||||
scope_type: DefinitionScopeType = "tenant"
|
||||
scope_id: str | None = Field(default=None, max_length=36)
|
||||
definition_kind: DefinitionKind = "flow"
|
||||
inherit_to_lower_scopes: bool = False
|
||||
allow_run: bool = True
|
||||
allow_reuse: bool = False
|
||||
allow_automation: bool = False
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_definition_kind(self) -> "PipelineCreateRequest":
|
||||
if self.definition_kind == "template" and self.status == "active":
|
||||
raise ValueError("Templates cannot be active.")
|
||||
return self
|
||||
|
||||
|
||||
class PipelineUpdateRequest(PipelineCreateRequest):
|
||||
expected_revision: int = Field(ge=1)
|
||||
|
||||
|
||||
class PipelineDeriveRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=300)
|
||||
description: str | None = Field(default=None, max_length=4_000)
|
||||
source_revision: int | None = Field(default=None, ge=1)
|
||||
scope_type: DefinitionScopeType = "tenant"
|
||||
scope_id: str | None = Field(default=None, max_length=36)
|
||||
definition_kind: DefinitionKind = "flow"
|
||||
inherit_to_lower_scopes: bool = False
|
||||
allow_run: bool = True
|
||||
allow_reuse: bool = False
|
||||
allow_automation: bool = False
|
||||
|
||||
|
||||
class PipelineDraftRequest(BaseModel):
|
||||
graph: PipelineGraph | None = None
|
||||
sql_text: str | None = Field(default=None, max_length=100_000)
|
||||
@@ -189,6 +251,7 @@ class PipelineRunCreateRequest(BaseModel):
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
row_limit: int = Field(default=500, ge=1, le=500)
|
||||
publication: PipelineRunPublicationRequest | None = None
|
||||
invocation_kind: Literal["manual", "api", "backfill"] = "manual"
|
||||
|
||||
|
||||
class PipelineRunResponse(BaseModel):
|
||||
@@ -208,6 +271,11 @@ class PipelineRunResponse(BaseModel):
|
||||
output_publication_ref: str | None
|
||||
output_datasource_ref: str | None
|
||||
output_materialization_ref: str | None
|
||||
invocation_kind: str
|
||||
trigger_ref: str | None
|
||||
delivery_ref: str | None
|
||||
correlation_id: str | None
|
||||
causation_id: str | None
|
||||
error: str | None
|
||||
started_at: datetime
|
||||
finished_at: datetime | None
|
||||
@@ -220,6 +288,168 @@ class PipelineRunListResponse(BaseModel):
|
||||
runs: list[PipelineRunResponse]
|
||||
|
||||
|
||||
JsonScalar = str | int | float | bool | None
|
||||
|
||||
|
||||
class DataflowTriggerSchedule(BaseModel):
|
||||
run_at: datetime | None = None
|
||||
anchor_at: datetime | None = None
|
||||
interval_seconds: int | None = Field(default=None, ge=60, le=31_536_000)
|
||||
timezone: str = Field(default="UTC", min_length=1, max_length=100)
|
||||
|
||||
|
||||
class DataflowTriggerEvent(BaseModel):
|
||||
event_type: str = Field(
|
||||
min_length=1,
|
||||
max_length=200,
|
||||
pattern=r"^[A-Za-z0-9_.:-]+$",
|
||||
)
|
||||
module_id: str | None = Field(
|
||||
default=None,
|
||||
max_length=100,
|
||||
pattern=r"^[a-z][a-z0-9_]*$",
|
||||
)
|
||||
filters: dict[str, JsonScalar] = Field(default_factory=dict)
|
||||
|
||||
@field_validator("filters")
|
||||
@classmethod
|
||||
def bounded_filters(
|
||||
cls,
|
||||
value: dict[str, JsonScalar],
|
||||
) -> dict[str, JsonScalar]:
|
||||
if len(value) > 20:
|
||||
raise ValueError("Event triggers support at most 20 filters.")
|
||||
for key in value:
|
||||
if not key or len(key) > 120 or "." in key:
|
||||
raise ValueError(
|
||||
"Event filter keys must be direct payload fields."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class DataflowTriggerCreateRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=300)
|
||||
kind: TriggerKind
|
||||
revision: int | None = Field(default=None, ge=1)
|
||||
enabled: bool = False
|
||||
schedule: DataflowTriggerSchedule | None = None
|
||||
event: DataflowTriggerEvent | None = None
|
||||
publication: PipelineRunPublicationRequest | None = None
|
||||
row_limit: int = Field(default=500, ge=1, le=500)
|
||||
catch_up_policy: Literal["skip", "coalesce"] = "coalesce"
|
||||
max_concurrent_runs: int = Field(default=1, ge=1, le=10)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_trigger(self) -> "DataflowTriggerCreateRequest":
|
||||
if self.kind == "once":
|
||||
if self.schedule is None or self.schedule.run_at is None:
|
||||
raise ValueError("One-time triggers require schedule.run_at.")
|
||||
elif self.kind == "interval":
|
||||
if (
|
||||
self.schedule is None
|
||||
or self.schedule.interval_seconds is None
|
||||
):
|
||||
raise ValueError(
|
||||
"Interval triggers require schedule.interval_seconds."
|
||||
)
|
||||
elif self.kind == "event" and self.event is None:
|
||||
raise ValueError("Event triggers require event configuration.")
|
||||
return self
|
||||
|
||||
|
||||
class DataflowTriggerUpdateRequest(DataflowTriggerCreateRequest):
|
||||
expected_revision: int = Field(ge=1)
|
||||
|
||||
|
||||
class DataflowTriggerResponse(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
pipeline_id: str
|
||||
pipeline_revision: int
|
||||
name: str
|
||||
kind: TriggerKind
|
||||
status: TriggerStatus
|
||||
revision: int
|
||||
schedule: DataflowTriggerSchedule | None
|
||||
event: DataflowTriggerEvent | None
|
||||
publication: PipelineRunPublicationRequest | None
|
||||
row_limit: int
|
||||
catch_up_policy: Literal["skip", "coalesce"]
|
||||
max_concurrent_runs: int
|
||||
next_fire_at: datetime | None
|
||||
last_fire_at: datetime | None
|
||||
last_status: str | None
|
||||
last_error: str | None
|
||||
grant_scopes: list[str]
|
||||
authorization_provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class DataflowTriggerListResponse(BaseModel):
|
||||
triggers: list[DataflowTriggerResponse]
|
||||
|
||||
|
||||
class DataflowTriggerDeliveryResponse(BaseModel):
|
||||
id: str
|
||||
trigger_id: str
|
||||
invocation_kind: str
|
||||
status: TriggerDeliveryStatus
|
||||
scheduled_for: datetime | None
|
||||
event_id: str | None
|
||||
run_id: str | None
|
||||
attempts: int
|
||||
authorization_provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
error: str | None
|
||||
created_at: datetime
|
||||
finished_at: datetime | None
|
||||
|
||||
|
||||
class DataflowTriggerDeliveryListResponse(BaseModel):
|
||||
deliveries: list[DataflowTriggerDeliveryResponse]
|
||||
|
||||
|
||||
class DataflowEventDeliveryRequest(BaseModel):
|
||||
event_id: str = Field(min_length=1, max_length=128)
|
||||
type: str = Field(
|
||||
min_length=1,
|
||||
max_length=200,
|
||||
pattern=r"^[A-Za-z0-9_.:-]+$",
|
||||
)
|
||||
module_id: str = Field(
|
||||
min_length=1,
|
||||
max_length=100,
|
||||
pattern=r"^[a-z][a-z0-9_]*$",
|
||||
)
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
occurred_at: datetime
|
||||
correlation_id: str | None = Field(default=None, max_length=128)
|
||||
causation_id: str | None = Field(default=None, max_length=128)
|
||||
classification: Literal[
|
||||
"public",
|
||||
"internal",
|
||||
"confidential",
|
||||
"restricted",
|
||||
] = "internal"
|
||||
|
||||
|
||||
class DataflowTriggerDispatchResponse(BaseModel):
|
||||
queued: int = 0
|
||||
processed: int = 0
|
||||
succeeded: int = 0
|
||||
failed: int = 0
|
||||
blocked: int = 0
|
||||
skipped: int = 0
|
||||
|
||||
|
||||
class DataflowTriggerDeleteResponse(BaseModel):
|
||||
deleted: bool
|
||||
trigger_id: str
|
||||
pipeline_id: str
|
||||
|
||||
|
||||
class PipelineDeleteResponse(BaseModel):
|
||||
deleted: bool
|
||||
pipeline_id: str
|
||||
|
||||
Reference in New Issue
Block a user