@redflow/client 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +280 -0
- package/package.json +1 -1
- package/src/client.ts +35 -96
- package/src/internal/keys.ts +1 -2
- package/src/types.ts +28 -36
- package/src/worker.ts +18 -37
- package/src/workflow.ts +12 -7
- package/tests/bugfixes.test.ts +21 -32
- package/tests/fixtures/worker-crash.ts +1 -1
- package/tests/fixtures/worker-recover.ts +1 -1
- package/tests/redflow.e2e.test.ts +136 -640
package/README.md
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
# redflow
|
|
2
|
+
|
|
3
|
+
Redis-backed workflow runtime for Bun.
|
|
4
|
+
|
|
5
|
+
## Warning
|
|
6
|
+
|
|
7
|
+
This project is still in early alpha stage.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bun add @redflow/client -E
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Most users start here: `defineWorkflow`
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { defineWorkflow } from "@redflow/client";
|
|
19
|
+
import { z } from "zod";
|
|
20
|
+
|
|
21
|
+
export const sendWelcomeEmail = defineWorkflow(
|
|
22
|
+
"send-welcome-email",
|
|
23
|
+
{
|
|
24
|
+
schema: z.object({ userId: z.string().min(1) })
|
|
25
|
+
},
|
|
26
|
+
async ({ input, step }) => {
|
|
27
|
+
const user = await step.run({ name: "fetch-user" }, async () => {
|
|
28
|
+
return { id: input.userId, email: "test@example.com" };
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
await step.run({ name: "send-email" }, async () => {
|
|
32
|
+
return { ok: true, to: user.email };
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
return { sent: true };
|
|
36
|
+
},
|
|
37
|
+
);
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Handler context gives you:
|
|
41
|
+
|
|
42
|
+
- `input` — validated input (if `schema` is provided)
|
|
43
|
+
- `run` — run metadata (`id`, `workflow`, `queue`, `attempt`, `maxAttempts`)
|
|
44
|
+
- `signal` — cancellation signal
|
|
45
|
+
- `step` — durable step API
|
|
46
|
+
|
|
47
|
+
## Step API (inside workflow handlers)
|
|
48
|
+
|
|
49
|
+
### 1) `step.run`
|
|
50
|
+
|
|
51
|
+
Use for durable, cached units of work.
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
const payment = await step.run(
|
|
55
|
+
{ name: "capture-payment", timeoutMs: 4_000 },
|
|
56
|
+
async ({ signal }) => {
|
|
57
|
+
// pass signal to external APIs when supported
|
|
58
|
+
return { chargeId: "ch_123" };
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### 2) `step.runWorkflow`
|
|
64
|
+
|
|
65
|
+
Use when parent workflow must wait for child workflow output.
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
const receipt = await step.runWorkflow(
|
|
69
|
+
{
|
|
70
|
+
name: "send-receipt",
|
|
71
|
+
timeoutMs: 20_000,
|
|
72
|
+
runAt: new Date(Date.now() + 500),
|
|
73
|
+
idempotencyTtl: 60 * 60 * 24,
|
|
74
|
+
idempotencyKey: `receipt:${input.orderId}`,
|
|
75
|
+
},
|
|
76
|
+
sendReceiptWorkflow,
|
|
77
|
+
{
|
|
78
|
+
orderId: input.orderId,
|
|
79
|
+
email: input.email,
|
|
80
|
+
totalCents: input.totalCents,
|
|
81
|
+
},
|
|
82
|
+
);
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`step.runWorkflow` waits for child completion until the step is canceled.
|
|
86
|
+
Set `timeoutMs` to cap total waiting time.
|
|
87
|
+
|
|
88
|
+
### 3) `step.emitWorkflow`
|
|
89
|
+
|
|
90
|
+
Use to trigger child workflow and keep only child run id.
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
const analyticsRunId = await step.emitWorkflow(
|
|
94
|
+
{
|
|
95
|
+
name: "emit-analytics",
|
|
96
|
+
runAt: new Date(Date.now() + 2_000),
|
|
97
|
+
idempotencyTtl: 60 * 60,
|
|
98
|
+
idempotencyKey: `analytics:${input.orderId}`,
|
|
99
|
+
},
|
|
100
|
+
analyticsWorkflow,
|
|
101
|
+
{
|
|
102
|
+
orderId: input.orderId,
|
|
103
|
+
totalCents: input.totalCents,
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Run workflows
|
|
109
|
+
|
|
110
|
+
The object returned by `defineWorkflow(...)` has `.run(...)`.
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
const handle = await sendWelcomeEmail.run(
|
|
114
|
+
{ userId: "user_123" },
|
|
115
|
+
{
|
|
116
|
+
idempotencyKey: "welcome:user_123",
|
|
117
|
+
idempotencyTtl: 60 * 60,
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const output = await handle.result({ timeoutMs: 15_000 });
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Delayed run:
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
const handle = await sendWelcomeEmail.run(
|
|
128
|
+
{ userId: "user_789" },
|
|
129
|
+
{
|
|
130
|
+
runAt: new Date(Date.now() + 60_000),
|
|
131
|
+
idempotencyKey: "welcome:user_789:delayed",
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const output = await handle.result({ timeoutMs: 90_000 });
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Start a worker
|
|
139
|
+
|
|
140
|
+
Import workflows, then run `startWorker()`.
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
import { startWorker } from "@redflow/client";
|
|
144
|
+
import "./workflows";
|
|
145
|
+
|
|
146
|
+
const worker = await startWorker({
|
|
147
|
+
url: process.env.REDIS_URL,
|
|
148
|
+
prefix: "redflow:prod",
|
|
149
|
+
concurrency: 4,
|
|
150
|
+
});
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Explicit queues + runtime tuning:
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
const worker = await startWorker({
|
|
157
|
+
url: process.env.REDIS_URL,
|
|
158
|
+
prefix: "redflow:prod",
|
|
159
|
+
queues: ["critical", "io", "analytics"],
|
|
160
|
+
concurrency: 8,
|
|
161
|
+
runtime: {
|
|
162
|
+
leaseMs: 5000,
|
|
163
|
+
blmoveTimeoutSec: 1,
|
|
164
|
+
reaperIntervalMs: 500,
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Workflow options examples
|
|
170
|
+
|
|
171
|
+
### Cron
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
defineWorkflow(
|
|
175
|
+
"digest-cron",
|
|
176
|
+
{
|
|
177
|
+
queue: "ops",
|
|
178
|
+
cron: [
|
|
179
|
+
{ id: "digest-10s", expression: "*/10 * * * * *" },
|
|
180
|
+
{ expression: "0 */5 * * * *", timezone: "UTC", input: { source: "cron" } },
|
|
181
|
+
],
|
|
182
|
+
},
|
|
183
|
+
async ({ input }) => ({ tick: true, input }),
|
|
184
|
+
);
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### onFailure
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
import { NonRetriableError } from "@redflow/client";
|
|
191
|
+
|
|
192
|
+
defineWorkflow(
|
|
193
|
+
"invoice-sync",
|
|
194
|
+
{
|
|
195
|
+
queue: "billing",
|
|
196
|
+
retries: { maxAttempts: 4 },
|
|
197
|
+
onFailure: async ({ error, run }) => {
|
|
198
|
+
console.error("workflow failed", run.id, run.workflow, error);
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
async () => {
|
|
202
|
+
throw new NonRetriableError("invoice not found in upstream system");
|
|
203
|
+
},
|
|
204
|
+
);
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
## Client APIs (advanced)
|
|
208
|
+
|
|
209
|
+
Use this when you need manual triggering, inspection, registry sync, or custom client setup.
|
|
210
|
+
|
|
211
|
+
### Setup
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
import { createClient, setDefaultClient } from "@redflow/client";
|
|
215
|
+
|
|
216
|
+
const client = createClient({
|
|
217
|
+
url: process.env.REDIS_URL,
|
|
218
|
+
prefix: "redflow:prod",
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
setDefaultClient(client);
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### Trigger by workflow name
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
const h1 = await client.runByName(
|
|
228
|
+
"checkout",
|
|
229
|
+
{ orderId: "ord_2", totalCents: 1999 },
|
|
230
|
+
{
|
|
231
|
+
queueOverride: "critical",
|
|
232
|
+
idempotencyKey: "checkout:ord_2",
|
|
233
|
+
},
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const h2 = await client.emitWorkflow(
|
|
237
|
+
"send-welcome-email",
|
|
238
|
+
{ userId: "user_456" },
|
|
239
|
+
{ idempotencyKey: "welcome:user_456" },
|
|
240
|
+
);
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### Inspect and control runs
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
const run = await client.getRun("run_123");
|
|
247
|
+
const steps = await client.getRunSteps("run_123");
|
|
248
|
+
|
|
249
|
+
const recent = await client.listRuns({ limit: 50 });
|
|
250
|
+
const failedCheckout = await client.listRuns({
|
|
251
|
+
workflow: "checkout",
|
|
252
|
+
status: "failed",
|
|
253
|
+
limit: 20,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const workflows = await client.listWorkflows();
|
|
257
|
+
const checkoutMeta = await client.getWorkflowMeta("checkout");
|
|
258
|
+
|
|
259
|
+
const canceled = await client.cancelRun("run_123", { reason: "requested by user" });
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### RunHandle
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
const handle = await client.emitWorkflow("checkout", { orderId: "ord_3", totalCents: 2999 });
|
|
266
|
+
|
|
267
|
+
const state = await handle.getState();
|
|
268
|
+
console.log(state?.status);
|
|
269
|
+
|
|
270
|
+
const output = await handle.result({ timeoutMs: 30_000 });
|
|
271
|
+
console.log(output);
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### Registry sync ownership
|
|
275
|
+
|
|
276
|
+
```ts
|
|
277
|
+
import { getDefaultRegistry } from "@redflow/client";
|
|
278
|
+
|
|
279
|
+
await client.syncRegistry(getDefaultRegistry(), { owner: "billing-service" });
|
|
280
|
+
```
|
package/package.json
CHANGED
package/src/client.ts
CHANGED
|
@@ -17,12 +17,11 @@ import { safeJsonParse, safeJsonStringify, safeJsonTryParse } from "./internal/j
|
|
|
17
17
|
import { nowMs } from "./internal/time";
|
|
18
18
|
import { sleep } from "./internal/sleep";
|
|
19
19
|
import type {
|
|
20
|
-
|
|
20
|
+
EmitWorkflowOptions,
|
|
21
21
|
ListedRun,
|
|
22
22
|
ListRunsParams,
|
|
23
23
|
RunHandle,
|
|
24
24
|
RunOptions,
|
|
25
|
-
ScheduleEventOptions,
|
|
26
25
|
RunState,
|
|
27
26
|
RunStatus,
|
|
28
27
|
StepState,
|
|
@@ -50,6 +49,7 @@ export function defaultPrefix(): string {
|
|
|
50
49
|
|
|
51
50
|
const STALE_WORKFLOW_GRACE_MS = 30_000;
|
|
52
51
|
const IDEMPOTENCY_TTL_SEC = 60 * 60 * 24 * 7;
|
|
52
|
+
const POLL_MS = 250;
|
|
53
53
|
|
|
54
54
|
const ENQUEUE_RUN_LUA = `
|
|
55
55
|
local runId = ARGV[1]
|
|
@@ -249,10 +249,6 @@ function encodeCompositePart(value: string): string {
|
|
|
249
249
|
return `${value.length}:${value}`;
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
-
function eventScopedIdempotencyKey(eventName: string, idempotencyKey: string): string {
|
|
253
|
-
return `event:${encodeCompositePart(eventName)}:${encodeCompositePart(idempotencyKey)}`;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
252
|
function defaultRegistryOwner(): string {
|
|
257
253
|
const envOwner = process.env.REDFLOW_SYNC_OWNER?.trim();
|
|
258
254
|
if (envOwner) return envOwner;
|
|
@@ -356,70 +352,35 @@ export class RedflowClient {
|
|
|
356
352
|
const data = await this.redis.hgetall(keys.workflow(this.prefix, name));
|
|
357
353
|
if (!data || Object.keys(data).length === 0) return null;
|
|
358
354
|
|
|
359
|
-
const
|
|
355
|
+
const cron = safeJsonTryParse<any>(data.cronJson ?? null) as any;
|
|
360
356
|
const retries = safeJsonTryParse<any>(data.retriesJson ?? null) as any;
|
|
361
357
|
const updatedAt = Number(data.updatedAt ?? "0");
|
|
362
358
|
const queue = data.queue ?? "default";
|
|
363
359
|
return {
|
|
364
360
|
name,
|
|
365
361
|
queue,
|
|
366
|
-
|
|
362
|
+
cron: Array.isArray(cron) && cron.length > 0 ? cron : undefined,
|
|
367
363
|
retries,
|
|
368
364
|
updatedAt,
|
|
369
365
|
};
|
|
370
366
|
}
|
|
371
367
|
|
|
372
|
-
|
|
373
|
-
|
|
368
|
+
/**
|
|
369
|
+
* Trigger a workflow by name. This is a direct run, not a fan-out.
|
|
370
|
+
* Use this when you want to start a workflow from outside a handler.
|
|
371
|
+
*/
|
|
372
|
+
async emitWorkflow<TOutput = unknown>(
|
|
373
|
+
workflowName: string,
|
|
374
|
+
input: unknown,
|
|
375
|
+
options?: EmitWorkflowOptions,
|
|
376
|
+
): Promise<RunHandle<TOutput>> {
|
|
377
|
+
return await this.runByName<TOutput>(workflowName, input, {
|
|
374
378
|
idempotencyKey: options?.idempotencyKey,
|
|
375
379
|
idempotencyTtl: options?.idempotencyTtl,
|
|
380
|
+
runAt: options?.runAt,
|
|
376
381
|
});
|
|
377
382
|
}
|
|
378
383
|
|
|
379
|
-
async scheduleEvent(name: string, payload: unknown, options: ScheduleEventOptions): Promise<string[]> {
|
|
380
|
-
if (!options || !isValidDate(options.availableAt)) {
|
|
381
|
-
throw new Error("Invalid 'availableAt' for scheduleEvent");
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
return await this.dispatchEvent(name, payload, {
|
|
385
|
-
idempotencyKey: options.idempotencyKey,
|
|
386
|
-
idempotencyTtl: options.idempotencyTtl,
|
|
387
|
-
availableAt: options.availableAt,
|
|
388
|
-
});
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
private async dispatchEvent(
|
|
392
|
-
name: string,
|
|
393
|
-
payload: unknown,
|
|
394
|
-
options: { idempotencyKey?: string; idempotencyTtl?: number; availableAt?: Date },
|
|
395
|
-
): Promise<string[]> {
|
|
396
|
-
const subsKey = keys.eventWorkflows(this.prefix, name);
|
|
397
|
-
const workflowNames = await this.redis.smembers(subsKey);
|
|
398
|
-
const runIds: string[] = [];
|
|
399
|
-
|
|
400
|
-
for (const workflowName of workflowNames) {
|
|
401
|
-
const idempotencyKey = options.idempotencyKey ? eventScopedIdempotencyKey(name, options.idempotencyKey) : undefined;
|
|
402
|
-
try {
|
|
403
|
-
const handle = await this.runByName(workflowName, payload, {
|
|
404
|
-
idempotencyKey,
|
|
405
|
-
idempotencyTtl: options.idempotencyTtl,
|
|
406
|
-
availableAt: options.availableAt,
|
|
407
|
-
});
|
|
408
|
-
runIds.push(handle.id);
|
|
409
|
-
} catch (err) {
|
|
410
|
-
if (err instanceof UnknownWorkflowError) {
|
|
411
|
-
// Stale event subscriptions can remain briefly across deployments.
|
|
412
|
-
// Drop them lazily so valid subscribers still receive events.
|
|
413
|
-
await this.redis.srem(subsKey, workflowName);
|
|
414
|
-
continue;
|
|
415
|
-
}
|
|
416
|
-
throw err;
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
return runIds;
|
|
421
|
-
}
|
|
422
|
-
|
|
423
384
|
async runByName<TOutput = unknown>(workflowName: string, input: unknown, options?: RunOptions): Promise<RunHandle<TOutput>> {
|
|
424
385
|
const queueFromRegistry = await this.getQueueForWorkflow(workflowName);
|
|
425
386
|
const queue = options?.queueOverride ?? queueFromRegistry;
|
|
@@ -440,7 +401,7 @@ export class RedflowClient {
|
|
|
440
401
|
workflowName,
|
|
441
402
|
queue,
|
|
442
403
|
input,
|
|
443
|
-
availableAt: options?.
|
|
404
|
+
availableAt: options?.runAt,
|
|
444
405
|
idempotencyKey: options?.idempotencyKey,
|
|
445
406
|
idempotencyTtl: options?.idempotencyTtl,
|
|
446
407
|
maxAttempts,
|
|
@@ -606,12 +567,11 @@ export class RedflowClient {
|
|
|
606
567
|
|
|
607
568
|
async waitForResult<TOutput = unknown>(
|
|
608
569
|
runId: string,
|
|
609
|
-
options?: { timeoutMs?: number
|
|
570
|
+
options?: { timeoutMs?: number },
|
|
610
571
|
): Promise<TOutput> {
|
|
611
572
|
const timeoutMs = options?.timeoutMs ?? 30_000;
|
|
612
|
-
const pollMs = options?.pollMs ?? 250;
|
|
613
573
|
const deadline = nowMs() + timeoutMs;
|
|
614
|
-
const missingGraceMs = Math.max(250, Math.min(2000,
|
|
574
|
+
const missingGraceMs = Math.max(250, Math.min(2000, POLL_MS * 4));
|
|
615
575
|
|
|
616
576
|
let missingSince: number | null = null;
|
|
617
577
|
let seenState = false;
|
|
@@ -630,7 +590,7 @@ export class RedflowClient {
|
|
|
630
590
|
}
|
|
631
591
|
|
|
632
592
|
if (t > deadline) throw new TimeoutError(`Timed out waiting for run result (${runId})`);
|
|
633
|
-
await sleep(
|
|
593
|
+
await sleep(POLL_MS);
|
|
634
594
|
continue;
|
|
635
595
|
}
|
|
636
596
|
|
|
@@ -642,7 +602,7 @@ export class RedflowClient {
|
|
|
642
602
|
if (state.status === "canceled") throw new CanceledError(state.cancelReason ? `Run canceled: ${state.cancelReason}` : "Run canceled");
|
|
643
603
|
|
|
644
604
|
if (nowMs() > deadline) throw new TimeoutError(`Timed out waiting for run result (${runId})`);
|
|
645
|
-
await sleep(
|
|
605
|
+
await sleep(POLL_MS);
|
|
646
606
|
}
|
|
647
607
|
}
|
|
648
608
|
|
|
@@ -657,39 +617,24 @@ export class RedflowClient {
|
|
|
657
617
|
for (const def of defs) {
|
|
658
618
|
const name = def.options.name;
|
|
659
619
|
const queue = def.options.queue ?? "default";
|
|
660
|
-
const
|
|
620
|
+
const cron = def.options.cron ?? [];
|
|
661
621
|
const retries = def.options.retries ?? {};
|
|
662
622
|
const updatedAt = nowMs();
|
|
663
623
|
|
|
664
624
|
const customCronIds = new Set<string>();
|
|
665
|
-
for (const
|
|
666
|
-
if (!
|
|
667
|
-
if (customCronIds.has(
|
|
668
|
-
throw new Error(`Duplicate cron trigger id '${
|
|
625
|
+
for (const c of cron) {
|
|
626
|
+
if (!c.id) continue;
|
|
627
|
+
if (customCronIds.has(c.id)) {
|
|
628
|
+
throw new Error(`Duplicate cron trigger id '${c.id}' in workflow '${name}'`);
|
|
669
629
|
}
|
|
670
|
-
customCronIds.add(
|
|
630
|
+
customCronIds.add(c.id);
|
|
671
631
|
}
|
|
672
632
|
|
|
673
633
|
const workflowKey = keys.workflow(this.prefix, name);
|
|
674
634
|
await this.redis.sadd(keys.workflows(this.prefix), name);
|
|
675
635
|
|
|
676
|
-
const prevEvents = safeJsonTryParse<string[]>(await this.redis.hget(workflowKey, "eventsJson")) ?? [];
|
|
677
|
-
const nextEvents = Array.from(new Set(triggers.events ?? [])).sort();
|
|
678
|
-
|
|
679
|
-
for (const ev of prevEvents) {
|
|
680
|
-
if (!nextEvents.includes(ev)) {
|
|
681
|
-
await this.redis.srem(keys.eventWorkflows(this.prefix, ev), name);
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
for (const ev of nextEvents) {
|
|
686
|
-
if (!prevEvents.includes(ev)) {
|
|
687
|
-
await this.redis.sadd(keys.eventWorkflows(this.prefix, ev), name);
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
|
|
691
636
|
const prevCronIds = safeJsonTryParse<string[]>(await this.redis.hget(workflowKey, "cronIdsJson")) ?? [];
|
|
692
|
-
const nextCronIds =
|
|
637
|
+
const nextCronIds = cron.map((c) => this.computeCronId(name, c.id, c.expression, c.timezone, c.input));
|
|
693
638
|
|
|
694
639
|
const nextCronIdSet = new Set(nextCronIds);
|
|
695
640
|
for (const oldId of prevCronIds) {
|
|
@@ -699,23 +644,23 @@ export class RedflowClient {
|
|
|
699
644
|
}
|
|
700
645
|
}
|
|
701
646
|
|
|
702
|
-
for (let i = 0; i <
|
|
703
|
-
const
|
|
647
|
+
for (let i = 0; i < cron.length; i++) {
|
|
648
|
+
const c = cron[i]!;
|
|
704
649
|
const cronId = nextCronIds[i]!;
|
|
705
|
-
const cronInput = Object.prototype.hasOwnProperty.call(
|
|
650
|
+
const cronInput = Object.prototype.hasOwnProperty.call(c, "input") ? c.input : {};
|
|
706
651
|
|
|
707
652
|
const cronDef = {
|
|
708
653
|
id: cronId,
|
|
709
654
|
workflow: name,
|
|
710
655
|
queue,
|
|
711
|
-
expression:
|
|
712
|
-
timezone:
|
|
656
|
+
expression: c.expression,
|
|
657
|
+
timezone: c.timezone,
|
|
713
658
|
inputJson: safeJsonStringify(cronInput),
|
|
714
659
|
};
|
|
715
660
|
|
|
716
661
|
await this.redis.hset(keys.cronDef(this.prefix), { [cronId]: safeJsonStringify(cronDef) });
|
|
717
662
|
|
|
718
|
-
const nextAt = this.computeNextCronAtMs(
|
|
663
|
+
const nextAt = this.computeNextCronAtMs(c.expression, c.timezone);
|
|
719
664
|
if (nextAt != null) {
|
|
720
665
|
await this.redis.zadd(keys.cronNext(this.prefix), nextAt, cronId);
|
|
721
666
|
} else {
|
|
@@ -728,9 +673,8 @@ export class RedflowClient {
|
|
|
728
673
|
queue,
|
|
729
674
|
owner,
|
|
730
675
|
updatedAt: String(updatedAt),
|
|
731
|
-
|
|
676
|
+
cronJson: safeJsonStringify(cron),
|
|
732
677
|
retriesJson: safeJsonStringify(retries),
|
|
733
|
-
eventsJson: safeJsonStringify(nextEvents),
|
|
734
678
|
cronIdsJson: safeJsonStringify(nextCronIds),
|
|
735
679
|
};
|
|
736
680
|
|
|
@@ -803,11 +747,6 @@ export class RedflowClient {
|
|
|
803
747
|
private async deleteWorkflowMetadata(workflowName: string): Promise<void> {
|
|
804
748
|
const workflowKey = keys.workflow(this.prefix, workflowName);
|
|
805
749
|
|
|
806
|
-
const prevEvents = safeJsonTryParse<string[]>(await this.redis.hget(workflowKey, "eventsJson")) ?? [];
|
|
807
|
-
for (const eventName of prevEvents) {
|
|
808
|
-
await this.redis.srem(keys.eventWorkflows(this.prefix, eventName), workflowName);
|
|
809
|
-
}
|
|
810
|
-
|
|
811
750
|
const prevCronIds = safeJsonTryParse<string[]>(await this.redis.hget(workflowKey, "cronIdsJson")) ?? [];
|
|
812
751
|
for (const cronId of prevCronIds) {
|
|
813
752
|
await this.redis.hdel(keys.cronDef(this.prefix), cronId);
|
|
@@ -1108,4 +1047,4 @@ export function makeErrorJson(err: unknown): string {
|
|
|
1108
1047
|
} catch {
|
|
1109
1048
|
return '{"name":"Error","message":"Failed to serialize original error","kind":"error"}';
|
|
1110
1049
|
}
|
|
1111
|
-
}
|
|
1050
|
+
}
|
package/src/internal/keys.ts
CHANGED
|
@@ -16,7 +16,6 @@ export const keys = {
|
|
|
16
16
|
// Keep run indexes in a separate namespace to avoid collisions with workflow
|
|
17
17
|
// metadata keys when workflow names contain suffix-like segments (e.g. ":runs").
|
|
18
18
|
workflowRuns: (prefix: KeyPrefix, workflowName: string) => withPrefix(prefix, `workflow-runs:${workflowName}`),
|
|
19
|
-
eventWorkflows: (prefix: KeyPrefix, eventName: string) => withPrefix(prefix, `event:${eventName}:workflows`),
|
|
20
19
|
|
|
21
20
|
runsCreated: (prefix: KeyPrefix) => withPrefix(prefix, "runs:created"),
|
|
22
21
|
runsStatus: (prefix: KeyPrefix, status: string) => withPrefix(prefix, `runs:status:${status}`),
|
|
@@ -35,4 +34,4 @@ export const keys = {
|
|
|
35
34
|
|
|
36
35
|
idempotency: (prefix: KeyPrefix, workflowName: string, idempotencyKey: string) =>
|
|
37
36
|
withPrefix(prefix, `idempo:${encodeCompositePart(workflowName)}:${encodeCompositePart(idempotencyKey)}`),
|
|
38
|
-
};
|
|
37
|
+
};
|
package/src/types.ts
CHANGED
|
@@ -3,11 +3,6 @@ import type { ZodTypeAny } from "zod";
|
|
|
3
3
|
export type RunStatus = "scheduled" | "queued" | "running" | "succeeded" | "failed" | "canceled";
|
|
4
4
|
export type StepStatus = "running" | "succeeded" | "failed";
|
|
5
5
|
|
|
6
|
-
export type WorkflowTriggers = {
|
|
7
|
-
events?: string[];
|
|
8
|
-
cron?: CronTrigger[];
|
|
9
|
-
};
|
|
10
|
-
|
|
11
6
|
export type CronTrigger = {
|
|
12
7
|
/** Standard cron expression with seconds support (6-field) via croner. */
|
|
13
8
|
expression: string;
|
|
@@ -38,7 +33,7 @@ export type DefineWorkflowOptions<TSchema extends ZodTypeAny | undefined = ZodTy
|
|
|
38
33
|
name: string;
|
|
39
34
|
queue?: string;
|
|
40
35
|
schema?: TSchema;
|
|
41
|
-
|
|
36
|
+
cron?: CronTrigger[];
|
|
42
37
|
retries?: WorkflowRetries;
|
|
43
38
|
/** Called when the run reaches terminal failure (retries exhausted or non-retriable error). Not called on cancellation. */
|
|
44
39
|
onFailure?: (ctx: OnFailureContext) => void | Promise<void>;
|
|
@@ -46,15 +41,20 @@ export type DefineWorkflowOptions<TSchema extends ZodTypeAny | undefined = ZodTy
|
|
|
46
41
|
|
|
47
42
|
export type StepRunOptions = {
|
|
48
43
|
name: string;
|
|
44
|
+
/**
|
|
45
|
+
* Overall timeout for the step execution.
|
|
46
|
+
* For step.runWorkflow this also bounds child result waiting.
|
|
47
|
+
*/
|
|
49
48
|
timeoutMs?: number;
|
|
50
49
|
};
|
|
51
50
|
|
|
52
51
|
export type StepRunWorkflowOptions = StepRunOptions & {
|
|
53
|
-
run
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
52
|
+
/** Earliest time when the child run becomes eligible for processing. */
|
|
53
|
+
runAt?: Date;
|
|
54
|
+
/** Override the queue for the child run. */
|
|
55
|
+
queueOverride?: string;
|
|
56
|
+
/** TTL for the idempotency key in seconds. Default: 7 days. */
|
|
57
|
+
idempotencyTtl?: number;
|
|
58
58
|
/**
|
|
59
59
|
* Optional override for child-run idempotency.
|
|
60
60
|
* By default, redflow derives a stable key from parent run id + step name + child workflow name.
|
|
@@ -62,28 +62,23 @@ export type StepRunWorkflowOptions = StepRunOptions & {
|
|
|
62
62
|
idempotencyKey?: string;
|
|
63
63
|
};
|
|
64
64
|
|
|
65
|
-
export type
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
export type StepEmitWorkflowOptions = StepRunOptions & {
|
|
66
|
+
/** Earliest time when the run becomes eligible for processing. */
|
|
67
|
+
runAt?: Date;
|
|
68
|
+
/** Override the queue for the run. */
|
|
69
|
+
queueOverride?: string;
|
|
70
|
+
/** TTL for the idempotency key in seconds. Default: 7 days. */
|
|
71
|
+
idempotencyTtl?: number;
|
|
68
72
|
/**
|
|
69
73
|
* Optional override for emit idempotency.
|
|
70
|
-
* By default, redflow derives a stable key from parent run id + step name +
|
|
71
|
-
*/
|
|
72
|
-
idempotencyKey?: string;
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
export type StepScheduleEventOptions = StepRunOptions & {
|
|
76
|
-
event: string;
|
|
77
|
-
schedule: Omit<ScheduleEventOptions, "idempotencyKey">;
|
|
78
|
-
/**
|
|
79
|
-
* Optional override for schedule idempotency.
|
|
80
|
-
* By default, redflow derives a stable key from parent run id + step name + event name.
|
|
74
|
+
* By default, redflow derives a stable key from parent run id + step name + workflow name.
|
|
81
75
|
*/
|
|
82
76
|
idempotencyKey?: string;
|
|
83
77
|
};
|
|
84
78
|
|
|
85
79
|
export type RunOptions = {
|
|
86
|
-
|
|
80
|
+
/** Earliest time when the run becomes eligible for processing. */
|
|
81
|
+
runAt?: Date;
|
|
87
82
|
idempotencyKey?: string;
|
|
88
83
|
/** TTL for the idempotency key in seconds. Default: 7 days (604 800s). */
|
|
89
84
|
idempotencyTtl?: number;
|
|
@@ -92,20 +87,18 @@ export type RunOptions = {
|
|
|
92
87
|
__maxAttemptsOverride?: number;
|
|
93
88
|
};
|
|
94
89
|
|
|
95
|
-
export type
|
|
90
|
+
export type EmitWorkflowOptions = {
|
|
91
|
+
/** Earliest time when the run becomes eligible for processing. */
|
|
92
|
+
runAt?: Date;
|
|
96
93
|
idempotencyKey?: string;
|
|
97
94
|
/** TTL for the idempotency key in seconds. Default: 7 days (604 800s). */
|
|
98
95
|
idempotencyTtl?: number;
|
|
99
96
|
};
|
|
100
97
|
|
|
101
|
-
export type ScheduleEventOptions = EmitEventOptions & {
|
|
102
|
-
availableAt: Date;
|
|
103
|
-
};
|
|
104
|
-
|
|
105
98
|
export type RunHandle<TOutput = unknown> = {
|
|
106
99
|
id: string;
|
|
107
100
|
getState(): Promise<RunState | null>;
|
|
108
|
-
result(options?: { timeoutMs?: number
|
|
101
|
+
result(options?: { timeoutMs?: number }): Promise<TOutput>;
|
|
109
102
|
};
|
|
110
103
|
|
|
111
104
|
export type WorkflowLike<TInput = unknown, TOutput = unknown> = {
|
|
@@ -120,8 +113,7 @@ export type StepApi = {
|
|
|
120
113
|
workflow: WorkflowLike<TInput, TOutput>,
|
|
121
114
|
input: TInput,
|
|
122
115
|
): Promise<TOutput>;
|
|
123
|
-
|
|
124
|
-
scheduleEvent(options: StepScheduleEventOptions, payload: unknown): Promise<string[]>;
|
|
116
|
+
emitWorkflow(options: StepEmitWorkflowOptions, workflow: WorkflowLike, input: unknown): Promise<string>;
|
|
125
117
|
};
|
|
126
118
|
|
|
127
119
|
export type RunState = {
|
|
@@ -175,7 +167,7 @@ export type ListedRun = {
|
|
|
175
167
|
export type WorkflowMeta = {
|
|
176
168
|
name: string;
|
|
177
169
|
queue: string;
|
|
178
|
-
|
|
170
|
+
cron?: CronTrigger[];
|
|
179
171
|
retries?: WorkflowRetries;
|
|
180
172
|
updatedAt: number;
|
|
181
|
-
};
|
|
173
|
+
};
|