feat: run dataflows through durable workers

This commit is contained in:
2026-07-30 02:33:02 +02:00
parent d61e9d8942
commit 5af02933ef
16 changed files with 2306 additions and 67 deletions
+73 -2
View File
@@ -214,8 +214,10 @@ export type PipelineRun = {
pipeline_id: string;
revision: number;
run_type: string;
status: "running" | "succeeded" | "failed" | "cancelled";
status: "queued" | "retrying" | "running" | "succeeded" | "failed" | "cancelled";
idempotency_key?: string | null;
execution_backend: "auto" | "reference" | "duckdb";
environment: "development" | "staging" | "production";
definition_hash: string;
executor_version: string;
source_fingerprints: Record<string, unknown>[];
@@ -231,14 +233,37 @@ export type PipelineRun = {
delivery_ref?: string | null;
correlation_id?: string | null;
causation_id?: string | null;
attempts: number;
max_attempts: number;
available_at?: string | null;
claimed_at?: string | null;
lease_expires_at?: string | null;
cancellation_requested_at?: string | null;
progress_percent: number;
progress_phase: string;
retention_until?: string | null;
purged_at?: string | null;
error?: string | null;
started_at: string;
started_at?: string | null;
finished_at?: string | null;
created_by?: string | null;
created_at: string;
replayed: boolean;
};
export type PipelineDeployment = {
id: string;
pipeline_id: string;
revision: number;
environment: "development" | "staging" | "production";
source_environment: "development" | "staging" | "production";
status: string;
provenance: Record<string, unknown>;
promoted_by?: string | null;
created_at: string;
updated_at: string;
};
export type DataflowTriggerKind = "once" | "interval" | "event";
export type DataflowTrigger = {
id: string;
@@ -505,6 +530,10 @@ export function runDataflowPipeline(
revision: number;
idempotency_key: string;
row_limit?: number;
execution_backend?: "reference" | "duckdb" | "auto";
environment?: "development" | "staging" | "production";
max_attempts?: number;
retention_days?: number;
publication?: {
target_datasource_ref?: string | null;
name?: string | null;
@@ -526,3 +555,45 @@ export function runDataflowPipeline(
}
);
}
export function cancelDataflowPipelineRun(
settings: ApiSettings,
runRef: string
): Promise<PipelineRun> {
const runId = runRef.replace(/^dataflow-run:/, "");
return apiFetch(
settings,
`/api/v1/dataflow/runs/${encodeURIComponent(runId)}/cancel`,
{ method: "POST" }
);
}
export async function listDataflowPipelineDeployments(
settings: ApiSettings,
pipelineId: string
): Promise<PipelineDeployment[]> {
const response = await apiFetch<{ deployments: PipelineDeployment[] }>(
settings,
`/api/v1/dataflow/pipelines/${encodeURIComponent(pipelineId)}/deployments`
);
return response.deployments;
}
export function promoteDataflowPipeline(
settings: ApiSettings,
pipelineId: string,
payload: {
revision: number;
source_environment: "development" | "staging";
target_environment: "staging" | "production";
}
): Promise<PipelineDeployment> {
return apiFetch(
settings,
`/api/v1/dataflow/pipelines/${encodeURIComponent(pipelineId)}/promotions`,
{
method: "POST",
body: JSON.stringify(payload)
}
);
}
+130 -8
View File
@@ -8,6 +8,7 @@ import {
} from "react";
import {
CheckCircle2,
CircleStop,
Clock3,
Code2,
CopyPlus,
@@ -16,6 +17,7 @@ import {
Play,
Plus,
RefreshCw,
Rocket,
RotateCcw,
Save,
Settings2,
@@ -46,6 +48,7 @@ import { ReactFlowProvider } from "@xyflow/react";
import { useLocation } from "react-router-dom";
import {
compileDataflowSql,
cancelDataflowPipelineRun,
createDataflowTrigger,
createDataflowPipeline,
createDataflowSourceSnapshot,
@@ -55,10 +58,12 @@ import {
deriveDataflowPipeline,
listDataflowNodeTypes,
listDataflowPipelineRuns,
listDataflowPipelineDeployments,
listDataflowPipelines,
listDataflowSources,
listDataflowTriggers,
previewDataflowPipeline,
promoteDataflowPipeline,
runDataflowPipeline,
renderDataflowSql,
updateDataflowPipeline,
@@ -75,6 +80,7 @@ import {
type Pipeline,
type PipelineGraphNode,
type PipelinePreview,
type PipelineDeployment,
type PipelineRun,
type TabularSource
} from "../../api/dataflow";
@@ -925,6 +931,7 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
revision: draft.currentRevision
} : null}
sources={sources}
canPromote={canWrite}
onClose={() => setRunOpen(false)}
onCompleted={(run) => {
if (run.status === "succeeded") {
@@ -933,8 +940,10 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
? `Run published ${run.output_row_count} rows to ${run.output_datasource_ref}.`
: `Run completed with ${run.output_row_count} output rows.`
);
} else {
} else if (run.status === "failed") {
setError(run.error || "Dataflow run failed.");
} else {
setSuccess(`Run ${run.ref} was queued.`);
}
if (run.output_datasource_ref) {
void listDataflowSources(settings).then((catalogue) => {
@@ -1586,6 +1595,7 @@ function RunPipelineDialog({
settings,
pipeline,
sources,
canPromote,
onClose,
onCompleted
}: {
@@ -1593,6 +1603,7 @@ function RunPipelineDialog({
settings: ApiSettings;
pipeline: { id: string; name: string; revision: number } | null;
sources: TabularSource[];
canPromote: boolean;
onClose: () => void;
onCompleted: (run: PipelineRun) => void;
}) {
@@ -1604,6 +1615,8 @@ function RunPipelineDialog({
const [freeze, setFreeze] = useState(false);
const [frozenLabel, setFrozenLabel] = useState("");
const [idempotencyKey, setIdempotencyKey] = useState("");
const [environment, setEnvironment] = useState<"development" | "staging" | "production">("development");
const [deployments, setDeployments] = useState<PipelineDeployment[]>([]);
const [runs, setRuns] = useState<PipelineRun[]>([]);
const [loadingRuns, setLoadingRuns] = useState(false);
const [busy, setBusy] = useState(false);
@@ -1622,14 +1635,35 @@ function RunPipelineDialog({
setFreeze(false);
setFrozenLabel("");
setIdempotencyKey(crypto.randomUUID());
setEnvironment("development");
setError("");
setLoadingRuns(true);
void listDataflowPipelineRuns(settings, pipeline.id)
.then(setRuns)
void Promise.all([
listDataflowPipelineRuns(settings, pipeline.id),
listDataflowPipelineDeployments(settings, pipeline.id)
])
.then(([loadedRuns, loadedDeployments]) => {
setRuns(loadedRuns);
setDeployments(loadedDeployments);
})
.catch((loadError) => setError(apiErrorMessage(loadError)))
.finally(() => setLoadingRuns(false));
}, [open, pipeline?.id, pipeline?.revision, settings]);
const hasActiveRuns = runs.some((run) =>
run.status === "queued" || run.status === "retrying" || run.status === "running"
);
useEffect(() => {
if (!open || !pipeline || !hasActiveRuns) return;
const timer = window.setInterval(() => {
void listDataflowPipelineRuns(settings, pipeline.id)
.then(setRuns)
.catch(() => undefined);
}, 2000);
return () => window.clearInterval(timer);
}, [hasActiveRuns, open, pipeline?.id, settings]);
const start = async () => {
if (!pipeline) return;
setBusy(true);
@@ -1639,6 +1673,8 @@ function RunPipelineDialog({
revision: pipeline.revision,
idempotency_key: idempotencyKey,
row_limit: 500,
execution_backend: "auto",
environment,
publication: mode === "publish" ? {
target_datasource_ref: targetRef === "new" ? null : targetRef,
name: targetRef === "new" ? name.trim() : null,
@@ -1655,7 +1691,7 @@ function RunPipelineDialog({
]);
onCompleted(run);
setIdempotencyKey(crypto.randomUUID());
if (run.status !== "succeeded") {
if (run.status === "failed") {
setError(run.error || "Dataflow run failed.");
}
} catch (runError) {
@@ -1665,6 +1701,39 @@ function RunPipelineDialog({
}
};
const cancelRun = async (run: PipelineRun) => {
setError("");
try {
const cancelled = await cancelDataflowPipelineRun(settings, run.ref);
setRuns((current) => current.map((item) =>
item.ref === cancelled.ref ? cancelled : item
));
} catch (cancelError) {
setError(apiErrorMessage(cancelError));
}
};
const promote = async (target: "staging" | "production") => {
if (!pipeline) return;
setBusy(true);
setError("");
try {
const deployment = await promoteDataflowPipeline(settings, pipeline.id, {
revision: pipeline.revision,
source_environment: target === "staging" ? "development" : "staging",
target_environment: target
});
setDeployments((current) => [
...current.filter((item) => item.environment !== target),
deployment
]);
} catch (promotionError) {
setError(apiErrorMessage(promotionError));
} finally {
setBusy(false);
}
};
const targetComplete = mode === "run"
|| targetRef !== "new"
|| Boolean(name.trim() && sourceName.trim());
@@ -1685,7 +1754,7 @@ function RunPipelineDialog({
onClick={() => void start()}
disabled={busy || !pipeline || !targetComplete}
>
<Play size={16} /> {busy ? "Running..." : "Run revision"}
<Play size={16} /> {busy ? "Queueing..." : "Queue run"}
</Button>
</>
)}
@@ -1710,6 +1779,44 @@ function RunPipelineDialog({
Revision {pipeline?.revision ?? "-"}
</span>
</div>
<div className="dataflow-run-publication-fields">
<FormField label="Environment">
<select
value={environment}
onChange={(event) => setEnvironment(
event.target.value as "development" | "staging" | "production"
)}
>
<option value="development">Development</option>
<option value="staging">Staging</option>
<option value="production">Production</option>
</select>
</FormField>
{canPromote ? (
<>
<Button
onClick={() => void promote("staging")}
disabled={busy || deployments.some((item) =>
item.environment === "staging" && item.revision === pipeline?.revision
)}
>
<Rocket size={16} /> Promote to staging
</Button>
<Button
onClick={() => void promote("production")}
disabled={busy
|| !deployments.some((item) =>
item.environment === "staging" && item.revision === pipeline?.revision
)
|| deployments.some((item) =>
item.environment === "production" && item.revision === pipeline?.revision
)}
>
<Rocket size={16} /> Promote to production
</Button>
</>
) : null}
</div>
{mode === "publish" ? (
<div className="dataflow-run-publication-fields">
<FormField label="Target">
@@ -1774,27 +1881,42 @@ function RunPipelineDialog({
<table>
<thead>
<tr>
<th>Started</th>
<th>Queued</th>
<th>Revision</th>
<th>Status</th>
<th>Progress</th>
<th>Rows</th>
<th>Output</th>
<th aria-label="Actions" />
</tr>
</thead>
<tbody>
{runs.map((run) => (
<tr key={run.ref}>
<td>{new Date(run.started_at).toLocaleString()}</td>
<td>{new Date(run.started_at ?? run.created_at).toLocaleString()}</td>
<td>{run.revision}</td>
<td><StatusBadge status={run.status} label={run.status} /></td>
<td>{run.progress_percent}% · {run.progress_phase}</td>
<td>{run.output_row_count.toLocaleString()}</td>
<td title={run.output_materialization_ref ?? ""}>
{run.output_datasource_ref ?? "Run evidence"}
</td>
<td>
{run.status === "queued"
|| run.status === "retrying"
|| run.status === "running" ? (
<IconButton
label="Cancel run"
icon={<CircleStop size={16} />}
variant="danger"
onClick={() => void cancelRun(run)}
/>
) : null}
</td>
</tr>
))}
{!loadingRuns && !runs.length ? (
<tr><td colSpan={5}>No runs yet</td></tr>
<tr><td colSpan={7}>No runs yet</td></tr>
) : null}
</tbody>
</table>