feat(runner): queue workflow runs with BullMQ (phase A)
Enqueue HTTP, cron, and manual runs via Redis/BullMQ; workers execute asynchronously with configurable concurrency. Triggers return 202 and clients poll GET /api/runs/:id for progress (queued → running → done). Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
This commit is contained in:
@@ -271,8 +271,10 @@ export function useRun(id) {
|
||||
queryKey: ["runs", id],
|
||||
queryFn: async () => (await api.get(`/runs/${encodeURIComponent(id)}`)).data,
|
||||
enabled: Boolean(id),
|
||||
refetchInterval: (query) =>
|
||||
query.state.data?.status === "running" ? 2000 : false,
|
||||
refetchInterval: (query) => {
|
||||
const status = query.state.data?.status;
|
||||
return status === "running" || status === "queued" ? 1500 : false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { LuMaximize2, LuMinimize2, LuPlay } from "react-icons/lu";
|
||||
import { errorMessage } from "../../api/client.js";
|
||||
import { api, errorMessage } from "../../api/client.js";
|
||||
import { useRunWorkflow } from "../../api/hooks.js";
|
||||
import { CodeEditor } from "../CodeEditor.jsx";
|
||||
import { StatusBadge } from "../../lib/format.jsx";
|
||||
@@ -14,11 +14,25 @@ import {
|
||||
import { SchemaTooltip } from "./FieldHelp.jsx";
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 300;
|
||||
const POLL_MS = 1500;
|
||||
|
||||
function initialDataJson(owner, file, defaultData) {
|
||||
return prettyJson(overlayWorkflowTestData(defaultData, readWorkflowTestData(owner, file)));
|
||||
}
|
||||
|
||||
function isTerminalStatus(status) {
|
||||
return status === "success" || status === "failed";
|
||||
}
|
||||
|
||||
async function waitForRun(runId, { signal } = {}) {
|
||||
while (!signal?.aborted) {
|
||||
const run = (await api.get(`/runs/${encodeURIComponent(runId)}`)).data;
|
||||
if (isTerminalStatus(run.status)) return run;
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
|
||||
}
|
||||
throw new Error("polling aborted");
|
||||
}
|
||||
|
||||
export function WorkflowTestPanel({
|
||||
owner,
|
||||
file,
|
||||
@@ -34,8 +48,10 @@ export function WorkflowTestPanel({
|
||||
const [inputTouched, setInputTouched] = useState(() => readWorkflowTestData(owner, file) != null);
|
||||
const [parseError, setParseError] = useState(null);
|
||||
const [last, setLast] = useState(null);
|
||||
const [polling, setPolling] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const saveTimer = useRef(null);
|
||||
const pollAbort = useRef(null);
|
||||
|
||||
const seedJson = prettyJson(
|
||||
overlayWorkflowTestData(defaultData, readWorkflowTestData(owner, file)),
|
||||
@@ -44,6 +60,7 @@ export function WorkflowTestPanel({
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
pollAbort.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -79,16 +96,42 @@ export function WorkflowTestPanel({
|
||||
return;
|
||||
}
|
||||
persist(data);
|
||||
pollAbort.current?.abort();
|
||||
const controller = new AbortController();
|
||||
pollAbort.current = controller;
|
||||
|
||||
run.mutate(
|
||||
{ owner, file, data },
|
||||
{
|
||||
onSuccess: (res) => {
|
||||
onSuccess: async (res) => {
|
||||
const runId = res.runId ?? null;
|
||||
setLast({
|
||||
status: res.status ?? "success",
|
||||
runId: res.runId ?? null,
|
||||
result: res.result,
|
||||
status: res.status ?? "queued",
|
||||
runId,
|
||||
result: null,
|
||||
error: null,
|
||||
});
|
||||
if (!runId) return;
|
||||
setPolling(true);
|
||||
try {
|
||||
const finished = await waitForRun(runId, { signal: controller.signal });
|
||||
setLast({
|
||||
status: finished.status,
|
||||
runId,
|
||||
result: finished.output,
|
||||
error: finished.error ?? null,
|
||||
});
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) return;
|
||||
setLast({
|
||||
status: "failed",
|
||||
runId,
|
||||
result: null,
|
||||
error: errorMessage(err),
|
||||
});
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setPolling(false);
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
const runId = err?.response?.data?.runId;
|
||||
@@ -107,11 +150,15 @@ export function WorkflowTestPanel({
|
||||
? prettyJson(last.error ? { error: last.error } : last.result)
|
||||
: "";
|
||||
|
||||
const busy = run.isPending || polling;
|
||||
|
||||
function close() {
|
||||
if (saveTimer.current) {
|
||||
clearTimeout(saveTimer.current);
|
||||
saveTimer.current = null;
|
||||
}
|
||||
pollAbort.current?.abort();
|
||||
setPolling(false);
|
||||
try {
|
||||
persist(JSON.parse(dataJson || "null"));
|
||||
} catch {
|
||||
@@ -134,8 +181,8 @@ export function WorkflowTestPanel({
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-bold text-lg">Test workflow</h3>
|
||||
<p className="text-sm opacity-70">
|
||||
Runs the <strong>saved</strong> YAML and writes an Event. Manual run works even when
|
||||
the workflow is disabled. Save before testing unsaved edits.
|
||||
Enqueues the <strong>saved</strong> YAML and polls the Event until it finishes.
|
||||
Manual run works even when the workflow is disabled. Save before testing unsaved edits.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -192,10 +239,10 @@ export function WorkflowTestPanel({
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={disabled || run.isPending}
|
||||
disabled={disabled || busy}
|
||||
onClick={onRun}
|
||||
>
|
||||
{run.isPending ? (
|
||||
{busy ? (
|
||||
<span className="loading loading-spinner loading-xs" />
|
||||
) : (
|
||||
<LuPlay className="size-4" />
|
||||
|
||||
@@ -13,9 +13,11 @@ export function StatusBadge({ status }) {
|
||||
? "badge-error"
|
||||
: status === "running"
|
||||
? "badge-warning"
|
||||
: status === "skipped"
|
||||
: status === "queued"
|
||||
? "badge-info"
|
||||
: "badge-ghost";
|
||||
: status === "skipped"
|
||||
? "badge-ghost"
|
||||
: "badge-ghost";
|
||||
return <span className={`badge badge-sm ${cls}`}>{status ?? "—"}</span>;
|
||||
}
|
||||
|
||||
@@ -53,6 +55,12 @@ export function WorkflowStatusBadge({ workflow }) {
|
||||
failed
|
||||
</span>,
|
||||
);
|
||||
} else if (workflow.lastStatus === "queued") {
|
||||
badges.push(
|
||||
<span key="queued" className="badge badge-info badge-sm">
|
||||
queued
|
||||
</span>,
|
||||
);
|
||||
} else if (workflow.lastStatus === "running") {
|
||||
badges.push(
|
||||
<span key="running" className="badge badge-warning badge-sm">
|
||||
|
||||
@@ -43,7 +43,7 @@ export function HomePage() {
|
||||
<div className="stat-figure text-warning">
|
||||
<LuActivity className="size-7" />
|
||||
</div>
|
||||
<div className="stat-title">Running</div>
|
||||
<div className="stat-title">Active</div>
|
||||
<div className="stat-value text-2xl">{data.running?.length ?? 0}</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
@@ -58,7 +58,7 @@ export function HomePage() {
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-2">Running</h2>
|
||||
<h2 className="text-lg font-semibold mb-2">Queued / running</h2>
|
||||
<RunList runs={data.running} empty="None" />
|
||||
</section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user