@bratsos/workflow-engine-host-serverless 0.3.2 → 0.4.0-alpha.0
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 +68 -10
- package/dist/index.d.ts +201 -2
- package/dist/index.js +187 -4
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -42,10 +42,16 @@ Creates a new serverless host instance.
|
|
|
42
42
|
| `kernel` | `Kernel` | required | Kernel instance to dispatch commands to |
|
|
43
43
|
| `jobTransport` | `JobTransport` | required | Job transport for complete/suspend/fail lifecycle |
|
|
44
44
|
| `workerId` | `string` | required | Unique worker identifier (e.g. function name) |
|
|
45
|
-
| `staleLeaseThresholdMs` | `number` | `
|
|
45
|
+
| `staleLeaseThresholdMs` | `number` | `300_000` | Heartbeat tier: a job whose lease is older is re-queued (`LEASE_HEARTBEAT_LOST`) |
|
|
46
|
+
| `jobAbsoluteTimeoutMs` | `number` | `3_600_000` | Absolute tier: a job claimed longer ago than this is failed terminally (`LEASE_ABSOLUTE_CAP`); `0` disables |
|
|
47
|
+
| `jobHeartbeatIntervalMs` | `number` | `60_000` | How often `handleJob` dispatches `job.heartbeat` while the stage runs; it renews the lease and aborts `ctx.abortSignal` on cancel or lease loss |
|
|
46
48
|
| `maxClaimsPerTick` | `number` | `10` | Max pending runs to claim per maintenance tick |
|
|
49
|
+
| `serves` | `ServedDefinition[] \| "all"` | derived from registry | Which definition versions this host claims, dequeues and polls; `"all"` restores the pre-1.0 predicate |
|
|
47
50
|
| `maxSuspendedChecksPerTick` | `number` | `10` | Max suspended stages to poll per tick |
|
|
48
51
|
| `maxOutboxFlushPerTick` | `number` | `100` | Max outbox events to flush per tick |
|
|
52
|
+
| `retention` | `RetentionOptions` | off | `{ olderThanMs, statuses?, limit? }`: delete terminal runs through `run.purge` at the end of every tick |
|
|
53
|
+
| `flushOutboxAfterJob` | `boolean` | `true` | Publish the job's outbox events right after `handleJob` settles it |
|
|
54
|
+
| `outboxFlushTimeoutMs` | `number` | `5_000` | Bound on that post-job flush; errors are logged, not thrown |
|
|
49
55
|
|
|
50
56
|
### `ServerlessHost`
|
|
51
57
|
|
|
@@ -66,19 +72,33 @@ interface JobMessage {
|
|
|
66
72
|
workflowId: string;
|
|
67
73
|
stageId: string;
|
|
68
74
|
attempt: number;
|
|
75
|
+
maxAttempts?: number; // retry budget; defaults to 3
|
|
69
76
|
payload: Record<string, unknown>;
|
|
70
77
|
}
|
|
71
78
|
```
|
|
72
79
|
|
|
80
|
+
A message delivered through your own push queue may carry a spilled payload if you wrapped the transport with `createSpillingJobTransport`; resolve it first with `createPayloadSpill({ blobStore }).unpack(msg.payload)` (both from `@bratsos/workflow-engine/kernel`).
|
|
81
|
+
|
|
73
82
|
### `JobResult`
|
|
74
83
|
|
|
75
84
|
```typescript
|
|
76
85
|
interface JobResult {
|
|
77
86
|
outcome: "completed" | "suspended" | "failed";
|
|
78
87
|
error?: string;
|
|
88
|
+
/** The message was an orphan or malformed; it was failed and acknowledged. */
|
|
89
|
+
dead?: boolean;
|
|
90
|
+
/** The stage was left PENDING and the job must run again. */
|
|
91
|
+
willRetry?: boolean;
|
|
92
|
+
/** The job attempt that ran (1 on the first execution). */
|
|
93
|
+
attempt?: number;
|
|
94
|
+
maxAttempts?: number;
|
|
95
|
+
/** Backoff before the retry (2^attempt seconds), when `willRetry`. */
|
|
96
|
+
retryDelayMs?: number;
|
|
79
97
|
}
|
|
80
98
|
```
|
|
81
99
|
|
|
100
|
+
The consumer's ack/retry decision reads `willRetry`, not `outcome`: a failed attempt with attempts left is `outcome: "failed", willRetry: true`, and the stage row is `PENDING` waiting for the next delivery. A transport whose `fail()` re-enqueues (the built-in Prisma and in-memory queues) has already re-queued the job — acknowledge the message. A push transport whose `fail()` cannot re-enqueue (a Cloudflare Queue consumer has to call `msg.retry()` itself) retries the message after `retryDelayMs`; when `willRetry` is false (settled, or `dead`) acknowledge it. Acknowledging a `willRetry: true` result without retrying leaves the run `RUNNING` until `run.reapStuck` heals it. A message without `payload` or `workflowId` is rejected as a dead job (`dead: true`, failed and acknowledged) instead of throwing.
|
|
101
|
+
|
|
82
102
|
### `ProcessJobsResult`
|
|
83
103
|
|
|
84
104
|
```typescript
|
|
@@ -93,13 +113,48 @@ interface ProcessJobsResult {
|
|
|
93
113
|
|
|
94
114
|
```typescript
|
|
95
115
|
interface MaintenanceTickResult {
|
|
96
|
-
claimed: number;
|
|
97
|
-
suspendedChecked: number;
|
|
98
|
-
staleReleased: number;
|
|
99
|
-
eventsFlushed: number;
|
|
116
|
+
claimed: number; // Pending runs claimed
|
|
117
|
+
suspendedChecked: number; // Suspended stages polled
|
|
118
|
+
staleReleased: number; // Stale leases re-queued (heartbeat tier)
|
|
119
|
+
eventsFlushed: number; // Outbox events published
|
|
120
|
+
stuckReaped: number; // RUNNING runs failed for inactivity
|
|
121
|
+
purged: number; // Terminal runs deleted; 0 unless `retention` is set
|
|
122
|
+
eventsFailed: number; // Events the next flush will retry
|
|
123
|
+
eventsDeadLettered: number; // Events that exhausted their retries (disjoint from eventsFailed)
|
|
124
|
+
eventSinkStatus: "healthy" | "degraded";
|
|
125
|
+
eventSinkError?: string; // First publish failure of this tick, when degraded
|
|
100
126
|
}
|
|
101
127
|
```
|
|
102
128
|
|
|
129
|
+
There is no process to carry event-sink state across invocations, so a serverless caller reads `eventSinkStatus` per tick and alerts on a run of `"degraded"` results; runs keep progressing either way.
|
|
130
|
+
|
|
131
|
+
### `runToCompletion(options)`
|
|
132
|
+
|
|
133
|
+
A supported synchronous drain loop for callers who need the run's result inside the request that created it:
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
import { runToCompletion } from "@bratsos/workflow-engine-host-serverless";
|
|
137
|
+
|
|
138
|
+
const result = await runToCompletion({
|
|
139
|
+
kernel,
|
|
140
|
+
jobTransport,
|
|
141
|
+
persistence, // { getRun } — the WorkflowPersistence you gave the kernel works
|
|
142
|
+
command: {
|
|
143
|
+
type: "run.create",
|
|
144
|
+
idempotencyKey: crypto.randomUUID(),
|
|
145
|
+
workflowId: "document-processor",
|
|
146
|
+
input: { url: "https://example.com" },
|
|
147
|
+
},
|
|
148
|
+
maxJobs: 50, // default
|
|
149
|
+
maxClaimRounds: 5, // default
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// { workflowRunId, status, outcome, output?, reason?, jobsProcessed, foreignJobsProcessed, suspendedStageId? }
|
|
153
|
+
if (result.outcome === "completed") return Response.json(result.output);
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Its three limits: it shares the queue, so it may execute another caller's job (`foreignJobsProcessed` says how many; give it its own `jobTransport` when that is unacceptable); it cannot finish a workflow that suspends, and returns `outcome: "suspended"` naming the stage instead of spinning; and it is bounded by `maxJobs` and `maxClaimRounds`, returning `outcome: "incomplete"` with a `reason` rather than looping or throwing. It does not poll suspended stages or reap leases — that is `runMaintenanceTick()`.
|
|
157
|
+
|
|
103
158
|
## Platform Integration Examples
|
|
104
159
|
|
|
105
160
|
### Cloudflare Workers (Queue + Cron)
|
|
@@ -115,10 +170,11 @@ export default {
|
|
|
115
170
|
for (const msg of batch.messages) {
|
|
116
171
|
const result = await host.handleJob(msg.body);
|
|
117
172
|
|
|
118
|
-
if (result.
|
|
119
|
-
|
|
173
|
+
if (result.willRetry) {
|
|
174
|
+
// The CF transport's fail() cannot re-enqueue: redeliver after the backoff.
|
|
175
|
+
msg.retry({ delaySeconds: Math.ceil((result.retryDelayMs ?? 0) / 1000) });
|
|
120
176
|
} else {
|
|
121
|
-
msg.ack();
|
|
177
|
+
msg.ack(); // completed, suspended, terminally failed, or dead
|
|
122
178
|
}
|
|
123
179
|
}
|
|
124
180
|
},
|
|
@@ -177,11 +233,13 @@ export async function GET() {
|
|
|
177
233
|
|
|
178
234
|
Unlike the Node host, the serverless host has **no loops, timers, or signal handlers**. Every method is a single stateless invocation:
|
|
179
235
|
|
|
180
|
-
- **`handleJob(msg)`** -- Dispatches `job.execute` to the kernel, then marks the job complete/suspended/failed via `jobTransport`. On completion, also dispatches `run.transition` to advance the workflow.
|
|
236
|
+
- **`handleJob(msg)`** -- Dispatches `job.execute` to the kernel, then marks the job complete/suspended/failed via `jobTransport`. On completion, also dispatches `run.transition` to advance the workflow, and (unless `flushOutboxAfterJob: false`) runs one bounded `outbox.flush` so the run's events are published by this invocation instead of by the next maintenance tick — there is no process lifecycle to hook a final flush on. A failure with attempts left (`msg.attempt < msg.maxAttempts`, default 3) leaves the stage `PENDING` with the error, emits `stage:retrying`, calls `jobTransport.fail(jobId, error, true)` and returns `willRetry: true` with `retryDelayMs`: either your transport re-enqueues the message with backoff from `fail()`, or your consumer retries it from the result (acknowledging it without a retry strands the run as `RUNNING` until `run.reapStuck`). When no attempts remain the stage is `FAILED`, `stage:failed` is emitted, and `run.transition` fails the run immediately with the stage error. A malformed message is failed and acknowledged as a dead job (`dead: true`).
|
|
181
237
|
|
|
182
238
|
- **`processAvailableJobs(opts?)`** -- Dequeues up to `maxJobs` (default: 1) from the job transport and processes each via `handleJob`. Safe for edge runtimes with CPU limits.
|
|
183
239
|
|
|
184
|
-
- **`runMaintenanceTick()`** -- Runs one bounded pass of all orchestration duties: `run.claimPending
|
|
240
|
+
- **`runMaintenanceTick()`** -- Runs one bounded pass of all orchestration duties: `run.claimPending` (filtered by `serves`), `stage.pollSuspended`, `lease.reapStale` (both tiers), `outbox.flush`, `run.reapStuck`, and `run.purge` when `retention` is set. A run pinned to a definition version this build does not serve is left untouched for a host that does.
|
|
241
|
+
|
|
242
|
+
- **Cancellation** -- `handleJob` dispatches `job.heartbeat` every `jobHeartbeatIntervalMs` while the stage runs; when the run is cancelled or the job lease is lost, the stage's `ctx.abortSignal` aborts with a `StageAbortedError` (`reason: "cancelled" | "lease-lost"`). Acknowledgements are fenced on `{ startedAt, attempt }`, so an invocation that outlived its lease writes nothing and reports `superseded`.
|
|
185
243
|
|
|
186
244
|
Consumers wire platform-specific glue (queue ack/retry, `waitUntil`, cron triggers) around these methods.
|
|
187
245
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Kernel, JobTransport } from '@bratsos/workflow-engine/kernel';
|
|
1
|
+
import { EventSinkStatus, Kernel, JobTransport, ServedDefinition, RetentionOptions, RunCreateCommand } from '@bratsos/workflow-engine/kernel';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Serverless Host for Workflow Engine Command Kernel
|
|
@@ -21,12 +21,43 @@ interface ServerlessHostConfig {
|
|
|
21
21
|
workerId: string;
|
|
22
22
|
/** Stale lease threshold in milliseconds (default: 300_000). */
|
|
23
23
|
staleLeaseThresholdMs?: number;
|
|
24
|
+
/** Absolute cap (ms) on one job claim (default: HOST_DEFAULTS.jobAbsoluteTimeoutMs). */
|
|
25
|
+
jobAbsoluteTimeoutMs?: number;
|
|
24
26
|
/** Max pending runs to claim per maintenance tick (default: 10). */
|
|
25
27
|
maxClaimsPerTick?: number;
|
|
28
|
+
/**
|
|
29
|
+
* Which definition versions this host may adopt and poll. Forwarded to
|
|
30
|
+
* `run.claimPending` and `stage.pollSuspended`.
|
|
31
|
+
*
|
|
32
|
+
* Left unset — the right default — the kernel derives it from the
|
|
33
|
+
* registry: a registry built with `createWorkflowRegistry` claims only
|
|
34
|
+
* runs this build can execute, which is what makes a rolling deploy safe.
|
|
35
|
+
* Pass `"all"` to claim and poll regardless of version (the pre-1.0
|
|
36
|
+
* behaviour), or an explicit list to run maintenance on behalf of
|
|
37
|
+
* another build.
|
|
38
|
+
*/
|
|
39
|
+
serves?: readonly ServedDefinition[] | "all";
|
|
26
40
|
/** Max suspended stages to check per tick (default: 10). */
|
|
27
41
|
maxSuspendedChecksPerTick?: number;
|
|
28
42
|
/** Max outbox events to flush per tick (default: 100). */
|
|
29
43
|
maxOutboxFlushPerTick?: number;
|
|
44
|
+
/**
|
|
45
|
+
* Delete finished runs older than `retention.olderThanMs` (with their
|
|
46
|
+
* stages, logs, artifacts, annotations, step ledger, job rows and blobs)
|
|
47
|
+
* through `run.purge` at the end of every maintenance tick. Off by
|
|
48
|
+
* default: nothing is deleted unless this is set.
|
|
49
|
+
*/
|
|
50
|
+
retention?: RetentionOptions;
|
|
51
|
+
/**
|
|
52
|
+
* Publish this job's outbox events right after `handleJob` settles it
|
|
53
|
+
* (default: true). There is no process lifecycle to hook a final flush
|
|
54
|
+
* on, so without this a run completed by this invocation is announced by
|
|
55
|
+
* whichever invocation runs the next maintenance tick. Bounded by
|
|
56
|
+
* `outboxFlushTimeoutMs`; errors are logged, never thrown.
|
|
57
|
+
*/
|
|
58
|
+
flushOutboxAfterJob?: boolean;
|
|
59
|
+
/** Upper bound (ms) on the post-job outbox flush (default: 5_000). */
|
|
60
|
+
outboxFlushTimeoutMs?: number;
|
|
30
61
|
/** Job lease heartbeat interval in milliseconds (default: 60_000). */
|
|
31
62
|
jobHeartbeatIntervalMs?: number;
|
|
32
63
|
}
|
|
@@ -40,9 +71,26 @@ interface JobMessage {
|
|
|
40
71
|
maxAttempts?: number;
|
|
41
72
|
payload: Record<string, unknown>;
|
|
42
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Outcome of `handleJob`. The consumer's ack/retry decision reads
|
|
76
|
+
* `willRetry`: when true the stage was left `PENDING` and the job must run
|
|
77
|
+
* again — a transport whose `fail()` re-enqueues (the built-in queues) has
|
|
78
|
+
* already done so and the message can be acknowledged; a push transport
|
|
79
|
+
* whose `fail()` cannot (a queue consumer that must `retry()` the message
|
|
80
|
+
* itself) retries the message after `retryDelayMs`. When false the job is
|
|
81
|
+
* settled: acknowledge it.
|
|
82
|
+
*/
|
|
43
83
|
interface JobResult {
|
|
44
84
|
outcome: "completed" | "suspended" | "failed";
|
|
45
85
|
error?: string;
|
|
86
|
+
/** The message was an orphan or malformed; it was failed and acknowledged. */
|
|
87
|
+
dead?: boolean;
|
|
88
|
+
willRetry?: boolean;
|
|
89
|
+
/** The job attempt that ran (1 on the first execution). */
|
|
90
|
+
attempt?: number;
|
|
91
|
+
maxAttempts?: number;
|
|
92
|
+
/** Backoff before the retry (`2^attempt` seconds), when `willRetry`. */
|
|
93
|
+
retryDelayMs?: number;
|
|
46
94
|
}
|
|
47
95
|
interface ProcessJobsResult {
|
|
48
96
|
processed: number;
|
|
@@ -55,6 +103,21 @@ interface MaintenanceTickResult {
|
|
|
55
103
|
staleReleased: number;
|
|
56
104
|
eventsFlushed: number;
|
|
57
105
|
stuckReaped: number;
|
|
106
|
+
/** Terminal runs deleted by `run.purge`; 0 unless `retention` is configured. */
|
|
107
|
+
purged: number;
|
|
108
|
+
/** Events claimed but not published this tick; the next flush retries them. */
|
|
109
|
+
eventsFailed: number;
|
|
110
|
+
/** Events that exhausted their retry budget and moved to the DLQ. */
|
|
111
|
+
eventsDeadLettered: number;
|
|
112
|
+
/**
|
|
113
|
+
* `"degraded"` when the event sink refused at least one event this tick.
|
|
114
|
+
* There is no process to carry the state across invocations, so a
|
|
115
|
+
* serverless caller reports this per tick (and alerts on a run of them);
|
|
116
|
+
* runs keep progressing either way.
|
|
117
|
+
*/
|
|
118
|
+
eventSinkStatus: EventSinkStatus;
|
|
119
|
+
/** First publish failure of this tick, when degraded. */
|
|
120
|
+
eventSinkError?: string;
|
|
58
121
|
}
|
|
59
122
|
interface ServerlessHost {
|
|
60
123
|
/** Execute a single pre-dequeued job. Returns outcome so consumer can ack/retry. */
|
|
@@ -72,4 +135,140 @@ interface ServerlessHost {
|
|
|
72
135
|
}
|
|
73
136
|
declare function createServerlessHost(config: ServerlessHostConfig): ServerlessHost;
|
|
74
137
|
|
|
75
|
-
|
|
138
|
+
/** The one read `runToCompletion` needs: how did the run end. */
|
|
139
|
+
interface RunToCompletionPersistence {
|
|
140
|
+
getRun(id: string): Promise<{
|
|
141
|
+
status: string;
|
|
142
|
+
output: unknown | null;
|
|
143
|
+
} | null>;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Why `runToCompletion` stopped.
|
|
147
|
+
*
|
|
148
|
+
* The first three are the run's own terminal states. The last two are not
|
|
149
|
+
* failures — they say the run is still alive and something else has to carry
|
|
150
|
+
* it, which is the honest answer for a helper that must return inside a
|
|
151
|
+
* request.
|
|
152
|
+
*/
|
|
153
|
+
type RunToCompletionOutcome = "completed" | "failed" | "cancelled"
|
|
154
|
+
/** A stage suspended (a durable sleep, wait or signal). See the caveats. */
|
|
155
|
+
| "suspended"
|
|
156
|
+
/** The budget ran out, or nothing was runnable yet. See `reason`. */
|
|
157
|
+
| "incomplete";
|
|
158
|
+
interface RunToCompletionResult {
|
|
159
|
+
workflowRunId: string;
|
|
160
|
+
/** The run's persisted status when the helper returned. */
|
|
161
|
+
status: string;
|
|
162
|
+
outcome: RunToCompletionOutcome;
|
|
163
|
+
/** The run's output, when it completed. */
|
|
164
|
+
output?: unknown;
|
|
165
|
+
/** Plain-language explanation, always present when `outcome` is not terminal. */
|
|
166
|
+
reason?: string;
|
|
167
|
+
/** Jobs executed by this call, this run's and other callers' alike. */
|
|
168
|
+
jobsProcessed: number;
|
|
169
|
+
/**
|
|
170
|
+
* How many of `jobsProcessed` belonged to a different run. Non-zero means
|
|
171
|
+
* this call did work on someone else's behalf — see the shared-queue
|
|
172
|
+
* caveat.
|
|
173
|
+
*/
|
|
174
|
+
foreignJobsProcessed: number;
|
|
175
|
+
/** The stage that suspended, when `outcome` is `"suspended"`. */
|
|
176
|
+
suspendedStageId?: string;
|
|
177
|
+
}
|
|
178
|
+
interface RunToCompletionOptions {
|
|
179
|
+
kernel: Kernel;
|
|
180
|
+
/** The same transport the kernel enqueues onto. */
|
|
181
|
+
jobTransport: JobTransport;
|
|
182
|
+
/** Read port for the run's final status. */
|
|
183
|
+
persistence: RunToCompletionPersistence;
|
|
184
|
+
/** The run to create, exactly as `kernel.dispatch` takes it. */
|
|
185
|
+
command: RunCreateCommand;
|
|
186
|
+
/** Worker id used for the claim and the job leases. Defaults to `"run-to-completion"`. */
|
|
187
|
+
workerId?: string;
|
|
188
|
+
/**
|
|
189
|
+
* Hard cap on jobs this call executes. Defaults to 50. Reaching it returns
|
|
190
|
+
* `outcome: "incomplete"`; it never throws and never loops past the cap.
|
|
191
|
+
*/
|
|
192
|
+
maxJobs?: number;
|
|
193
|
+
/**
|
|
194
|
+
* How many `run.claimPending` rounds to spend looking for this run's first
|
|
195
|
+
* job. Defaults to 5. Each round claims up to `claimsPerRound` pending
|
|
196
|
+
* runs — including other callers' (see the caveats).
|
|
197
|
+
*/
|
|
198
|
+
maxClaimRounds?: number;
|
|
199
|
+
/** Pending runs claimed per round. Defaults to 10. */
|
|
200
|
+
claimsPerRound?: number;
|
|
201
|
+
/** Job lease heartbeat interval. Defaults to `HOST_DEFAULTS.jobHeartbeatIntervalMs`. */
|
|
202
|
+
jobHeartbeatIntervalMs?: number;
|
|
203
|
+
/**
|
|
204
|
+
* Publish the run's outbox events before returning (default true), so a
|
|
205
|
+
* caller that returns the result straight to an HTTP client has already
|
|
206
|
+
* announced the run.
|
|
207
|
+
*/
|
|
208
|
+
flushOutbox?: boolean;
|
|
209
|
+
/** Max events published by that final flush. Defaults to 100. */
|
|
210
|
+
maxOutboxFlush?: number;
|
|
211
|
+
/** Log prefix for the job loop. Defaults to `"[runToCompletion]"`. */
|
|
212
|
+
logPrefix?: string;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Create a workflow run and drive it to a terminal state inside the calling
|
|
216
|
+
* request — the drain loop every caller writes by hand, exactly once.
|
|
217
|
+
*
|
|
218
|
+
* It creates the run, claims it, then dequeues and executes jobs through the
|
|
219
|
+
* same `executeJobWithHeartbeat` the hosts use, until the run is terminal,
|
|
220
|
+
* a stage suspends, nothing more is runnable, or a bound trips.
|
|
221
|
+
*
|
|
222
|
+
* ## Three things to know before you use it
|
|
223
|
+
*
|
|
224
|
+
* **1. It shares the queue, so it can execute another caller's job.** The
|
|
225
|
+
* kernel's queue is global: `run.claimPending` claims whichever runs are
|
|
226
|
+
* pending, and `dequeue()` returns whichever job is next — neither can be
|
|
227
|
+
* narrowed to one run. So this call may start other callers' runs and
|
|
228
|
+
* execute their jobs against its own budget, and their work lands inside
|
|
229
|
+
* your request's latency and error budget. `foreignJobsProcessed` reports
|
|
230
|
+
* how much of that happened. If it matters, give this call a kernel wired to
|
|
231
|
+
* its own `jobTransport` (an in-request queue) so the only jobs it can see
|
|
232
|
+
* are the ones it created.
|
|
233
|
+
*
|
|
234
|
+
* **2. It cannot complete a workflow that suspends.** A durable sleep, wait
|
|
235
|
+
* or signal parks the stage until a later poll, which by definition is not
|
|
236
|
+
* this request. Rather than spin — polling a clock that has not moved, or
|
|
237
|
+
* sleeping inside a request handler — it returns immediately with
|
|
238
|
+
* `outcome: "suspended"` and the stage that parked. The run is healthy and
|
|
239
|
+
* unfinished: a background host or a scheduled maintenance tick resumes it.
|
|
240
|
+
* Workflows you intend to run this way should have no durable waits.
|
|
241
|
+
*
|
|
242
|
+
* **3. It is bounded, never an unbounded loop.** `maxJobs` caps the jobs one
|
|
243
|
+
* call executes and `maxClaimRounds` caps the search for this run's first
|
|
244
|
+
* job. Hitting either returns `outcome: "incomplete"` with a `reason`; it
|
|
245
|
+
* does not throw, and it does not keep going. The same is true when a stage
|
|
246
|
+
* fails and is re-enqueued with a retry backoff: the job is not due yet, the
|
|
247
|
+
* queue is empty, and the call returns `"incomplete"` rather than waiting.
|
|
248
|
+
*
|
|
249
|
+
* It also deliberately does not poll suspended stages or reap stale leases.
|
|
250
|
+
* Those are maintenance, they are what would make it spin, and
|
|
251
|
+
* `host.runMaintenanceTick()` already owns them.
|
|
252
|
+
*
|
|
253
|
+
* @example
|
|
254
|
+
* ```typescript
|
|
255
|
+
* const result = await runToCompletion({
|
|
256
|
+
* kernel,
|
|
257
|
+
* jobTransport,
|
|
258
|
+
* persistence,
|
|
259
|
+
* command: {
|
|
260
|
+
* type: "run.create",
|
|
261
|
+
* idempotencyKey: `checkout:${orderId}`,
|
|
262
|
+
* workflowId: "checkout",
|
|
263
|
+
* input: { orderId },
|
|
264
|
+
* },
|
|
265
|
+
* });
|
|
266
|
+
*
|
|
267
|
+
* if (result.outcome === "completed") return Response.json(result.output);
|
|
268
|
+
* if (result.outcome === "suspended") return new Response(null, { status: 202 });
|
|
269
|
+
* return new Response(result.reason ?? "run failed", { status: 500 });
|
|
270
|
+
* ```
|
|
271
|
+
*/
|
|
272
|
+
declare function runToCompletion(options: RunToCompletionOptions): Promise<RunToCompletionResult>;
|
|
273
|
+
|
|
274
|
+
export { type JobMessage, type JobResult, type MaintenanceTickResult, type ProcessJobsResult, type RunToCompletionOptions, type RunToCompletionOutcome, type RunToCompletionPersistence, type RunToCompletionResult, type ServerlessHost, type ServerlessHostConfig, createServerlessHost, runToCompletion };
|
package/dist/index.js
CHANGED
|
@@ -6,27 +6,76 @@ var ServerlessHostImpl = class {
|
|
|
6
6
|
jobTransport;
|
|
7
7
|
workerId;
|
|
8
8
|
staleLeaseThresholdMs;
|
|
9
|
+
jobAbsoluteTimeoutMs;
|
|
9
10
|
maxClaimsPerTick;
|
|
11
|
+
/** See `HostConfig.serves`; `undefined` means "derive from the registry". */
|
|
12
|
+
serves;
|
|
10
13
|
maxSuspendedChecksPerTick;
|
|
11
14
|
maxOutboxFlushPerTick;
|
|
15
|
+
retention;
|
|
12
16
|
jobHeartbeatIntervalMs;
|
|
17
|
+
flushOutboxAfterJob;
|
|
18
|
+
outboxFlushTimeoutMs;
|
|
13
19
|
constructor(config) {
|
|
14
20
|
this.kernel = config.kernel;
|
|
15
21
|
this.jobTransport = config.jobTransport;
|
|
16
22
|
this.workerId = config.workerId;
|
|
17
23
|
this.staleLeaseThresholdMs = config.staleLeaseThresholdMs ?? HOST_DEFAULTS.staleLeaseThresholdMs;
|
|
24
|
+
this.jobAbsoluteTimeoutMs = config.jobAbsoluteTimeoutMs ?? HOST_DEFAULTS.jobAbsoluteTimeoutMs;
|
|
18
25
|
this.maxClaimsPerTick = config.maxClaimsPerTick ?? HOST_DEFAULTS.maxClaimsPerTick;
|
|
26
|
+
this.serves = config.serves;
|
|
19
27
|
this.maxSuspendedChecksPerTick = config.maxSuspendedChecksPerTick ?? HOST_DEFAULTS.maxSuspendedChecksPerTick;
|
|
20
28
|
this.maxOutboxFlushPerTick = config.maxOutboxFlushPerTick ?? HOST_DEFAULTS.maxOutboxFlushPerTick;
|
|
29
|
+
this.retention = config.retention;
|
|
21
30
|
this.jobHeartbeatIntervalMs = config.jobHeartbeatIntervalMs ?? HOST_DEFAULTS.jobHeartbeatIntervalMs;
|
|
31
|
+
this.flushOutboxAfterJob = config.flushOutboxAfterJob ?? true;
|
|
32
|
+
this.outboxFlushTimeoutMs = config.outboxFlushTimeoutMs ?? 5e3;
|
|
22
33
|
}
|
|
23
34
|
async handleJob(msg) {
|
|
24
|
-
|
|
35
|
+
const result = await executeJobWithHeartbeat(this.kernel, {
|
|
25
36
|
jobTransport: this.jobTransport,
|
|
26
37
|
job: msg,
|
|
27
38
|
jobHeartbeatIntervalMs: this.jobHeartbeatIntervalMs,
|
|
28
39
|
logPrefix: "[ServerlessHost]"
|
|
29
40
|
});
|
|
41
|
+
if (this.flushOutboxAfterJob) {
|
|
42
|
+
await this.flushOutbox();
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
/** Publish pending outbox events, bounded by `outboxFlushTimeoutMs`. */
|
|
47
|
+
async flushOutbox() {
|
|
48
|
+
let timer;
|
|
49
|
+
const timeout = new Promise((resolve) => {
|
|
50
|
+
timer = setTimeout(() => resolve("timeout"), this.outboxFlushTimeoutMs);
|
|
51
|
+
});
|
|
52
|
+
try {
|
|
53
|
+
const outcome = await Promise.race([
|
|
54
|
+
this.kernel.dispatch({
|
|
55
|
+
type: "outbox.flush",
|
|
56
|
+
maxEvents: this.maxOutboxFlushPerTick
|
|
57
|
+
}),
|
|
58
|
+
timeout
|
|
59
|
+
]);
|
|
60
|
+
if (outcome === "timeout") {
|
|
61
|
+
console.error(
|
|
62
|
+
"[ServerlessHost] outbox.flush after job: timed out; the next maintenance tick publishes the rest"
|
|
63
|
+
);
|
|
64
|
+
} else if (outcome.eventSinkStatus === "degraded") {
|
|
65
|
+
console.error(
|
|
66
|
+
`[ServerlessHost] event sink DEGRADED: ${outcome.failed} event(s) stayed in the outbox for a later flush; runs keep progressing. Last error: ${outcome.eventSinkError ?? "unknown"}`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
if (outcome !== "timeout" && outcome.deadLettered > 0) {
|
|
70
|
+
console.error(
|
|
71
|
+
`[ServerlessHost] event sink DEAD-LETTERED ${outcome.deadLettered} event(s): they will not be delivered until replayed with the plugin.replayDLQ command`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
} catch (error) {
|
|
75
|
+
console.error("[ServerlessHost] outbox.flush after job error:", error);
|
|
76
|
+
} finally {
|
|
77
|
+
if (timer) clearTimeout(timer);
|
|
78
|
+
}
|
|
30
79
|
}
|
|
31
80
|
async processAvailableJobs(opts) {
|
|
32
81
|
const maxJobs = opts?.maxJobs ?? 1;
|
|
@@ -34,7 +83,10 @@ var ServerlessHostImpl = class {
|
|
|
34
83
|
let succeeded = 0;
|
|
35
84
|
let failed = 0;
|
|
36
85
|
while (processed < maxJobs) {
|
|
37
|
-
const
|
|
86
|
+
const serves = this.dequeueServes();
|
|
87
|
+
const job = await this.jobTransport.dequeue(
|
|
88
|
+
serves !== void 0 ? { serves } : void 0
|
|
89
|
+
);
|
|
38
90
|
if (!job) break;
|
|
39
91
|
const result = await this.handleJob({
|
|
40
92
|
jobId: job.jobId,
|
|
@@ -54,21 +106,152 @@ var ServerlessHostImpl = class {
|
|
|
54
106
|
}
|
|
55
107
|
return { processed, succeeded, failed };
|
|
56
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* What to pass to `jobTransport.dequeue`. `"all"` means claim regardless
|
|
111
|
+
* of version, which is expressed by passing nothing; otherwise the
|
|
112
|
+
* kernel's registry answers, so the dequeue narrows on exactly what
|
|
113
|
+
* `run.claimPending` narrows on.
|
|
114
|
+
*/
|
|
115
|
+
dequeueServes() {
|
|
116
|
+
if (this.serves === "all") return void 0;
|
|
117
|
+
return this.serves ?? this.kernel.servedDefinitions?.();
|
|
118
|
+
}
|
|
57
119
|
async runMaintenanceTick() {
|
|
58
|
-
|
|
120
|
+
const counts = await runMaintenanceTick(this.kernel, {
|
|
59
121
|
workerId: this.workerId,
|
|
60
122
|
maxClaimsPerTick: this.maxClaimsPerTick,
|
|
123
|
+
...this.serves !== void 0 ? { serves: this.serves } : {},
|
|
61
124
|
maxSuspendedChecksPerTick: this.maxSuspendedChecksPerTick,
|
|
62
125
|
maxOutboxFlushPerTick: this.maxOutboxFlushPerTick,
|
|
63
126
|
staleLeaseThresholdMs: this.staleLeaseThresholdMs,
|
|
127
|
+
jobAbsoluteTimeoutMs: this.jobAbsoluteTimeoutMs,
|
|
128
|
+
...this.retention !== void 0 ? { retention: this.retention } : {},
|
|
64
129
|
logPrefix: "[ServerlessHost]"
|
|
65
130
|
});
|
|
131
|
+
if (counts.eventSinkStatus === "degraded") {
|
|
132
|
+
console.error(
|
|
133
|
+
`[ServerlessHost] event sink DEGRADED: ${counts.eventsFailed} event(s) stayed in the outbox for a later flush; runs keep progressing. Last error: ${counts.eventSinkError ?? "unknown"}`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
if (counts.eventsDeadLettered > 0) {
|
|
137
|
+
console.error(
|
|
138
|
+
`[ServerlessHost] event sink DEAD-LETTERED ${counts.eventsDeadLettered} event(s): they will not be delivered until replayed with the plugin.replayDLQ command`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
return counts;
|
|
66
142
|
}
|
|
67
143
|
};
|
|
68
144
|
function createServerlessHost(config) {
|
|
69
145
|
return new ServerlessHostImpl(config);
|
|
70
146
|
}
|
|
147
|
+
var TERMINAL = {
|
|
148
|
+
COMPLETED: "completed",
|
|
149
|
+
FAILED: "failed",
|
|
150
|
+
CANCELLED: "cancelled"
|
|
151
|
+
};
|
|
152
|
+
async function runToCompletion(options) {
|
|
153
|
+
const {
|
|
154
|
+
kernel,
|
|
155
|
+
jobTransport,
|
|
156
|
+
persistence,
|
|
157
|
+
command,
|
|
158
|
+
workerId = "run-to-completion",
|
|
159
|
+
maxJobs = 50,
|
|
160
|
+
maxClaimRounds = 5,
|
|
161
|
+
claimsPerRound = 10,
|
|
162
|
+
jobHeartbeatIntervalMs = HOST_DEFAULTS.jobHeartbeatIntervalMs,
|
|
163
|
+
flushOutbox = true,
|
|
164
|
+
maxOutboxFlush = HOST_DEFAULTS.maxOutboxFlushPerTick,
|
|
165
|
+
logPrefix = "[runToCompletion]"
|
|
166
|
+
} = options;
|
|
167
|
+
const created = await kernel.dispatch(command);
|
|
168
|
+
const workflowRunId = created.workflowRunId;
|
|
169
|
+
let jobsProcessed = 0;
|
|
170
|
+
let foreignJobsProcessed = 0;
|
|
171
|
+
let suspendedStageId;
|
|
172
|
+
let reason;
|
|
173
|
+
let claimed = false;
|
|
174
|
+
for (let round = 0; round < maxClaimRounds && !claimed; round++) {
|
|
175
|
+
const result = await kernel.dispatch({
|
|
176
|
+
type: "run.claimPending",
|
|
177
|
+
workerId,
|
|
178
|
+
maxClaims: claimsPerRound
|
|
179
|
+
});
|
|
180
|
+
claimed = result.claimed.some(
|
|
181
|
+
(entry) => entry.workflowRunId === workflowRunId
|
|
182
|
+
);
|
|
183
|
+
if (!claimed && result.claimed.length === 0) break;
|
|
184
|
+
}
|
|
185
|
+
if (!claimed) {
|
|
186
|
+
reason = `run ${workflowRunId} was not claimed within ${maxClaimRounds} claim round(s): another worker claimed it, and it will finish there`;
|
|
187
|
+
}
|
|
188
|
+
while (claimed && jobsProcessed < maxJobs) {
|
|
189
|
+
const job = await jobTransport.dequeue();
|
|
190
|
+
if (!job) {
|
|
191
|
+
reason = `no runnable job remained (${jobsProcessed} executed)`;
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
const outcome = await executeJobWithHeartbeat(kernel, {
|
|
195
|
+
jobTransport,
|
|
196
|
+
job: {
|
|
197
|
+
jobId: job.jobId,
|
|
198
|
+
workflowRunId: job.workflowRunId,
|
|
199
|
+
workflowId: job.workflowId,
|
|
200
|
+
stageId: job.stageId,
|
|
201
|
+
attempt: job.attempt,
|
|
202
|
+
maxAttempts: job.maxAttempts,
|
|
203
|
+
payload: job.payload,
|
|
204
|
+
startedAt: job.startedAt
|
|
205
|
+
},
|
|
206
|
+
jobHeartbeatIntervalMs,
|
|
207
|
+
logPrefix
|
|
208
|
+
});
|
|
209
|
+
jobsProcessed++;
|
|
210
|
+
const mine = job.workflowRunId === workflowRunId;
|
|
211
|
+
if (!mine) foreignJobsProcessed++;
|
|
212
|
+
if (mine && outcome.outcome === "suspended") {
|
|
213
|
+
suspendedStageId = job.stageId;
|
|
214
|
+
reason = `stage "${job.stageId}" suspended on a durable wait; the run resumes on a later poll, not in this request`;
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (claimed && jobsProcessed >= maxJobs && !suspendedStageId) {
|
|
219
|
+
reason = `job budget of ${maxJobs} exhausted before the run finished`;
|
|
220
|
+
}
|
|
221
|
+
if (flushOutbox) {
|
|
222
|
+
try {
|
|
223
|
+
await kernel.dispatch({
|
|
224
|
+
type: "outbox.flush",
|
|
225
|
+
maxEvents: maxOutboxFlush
|
|
226
|
+
});
|
|
227
|
+
} catch (error) {
|
|
228
|
+
console.error(`${logPrefix} outbox.flush failed:`, error);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const record = await persistence.getRun(workflowRunId);
|
|
232
|
+
const status = record?.status ?? "UNKNOWN";
|
|
233
|
+
const terminal = TERMINAL[status];
|
|
234
|
+
if (terminal) {
|
|
235
|
+
return {
|
|
236
|
+
workflowRunId,
|
|
237
|
+
status,
|
|
238
|
+
outcome: terminal,
|
|
239
|
+
...record?.output != null ? { output: record.output } : {},
|
|
240
|
+
jobsProcessed,
|
|
241
|
+
foreignJobsProcessed
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
workflowRunId,
|
|
246
|
+
status,
|
|
247
|
+
outcome: suspendedStageId ? "suspended" : "incomplete",
|
|
248
|
+
reason: reason ?? `the run is still ${status}`,
|
|
249
|
+
jobsProcessed,
|
|
250
|
+
foreignJobsProcessed,
|
|
251
|
+
...suspendedStageId ? { suspendedStageId } : {}
|
|
252
|
+
};
|
|
253
|
+
}
|
|
71
254
|
|
|
72
|
-
export { createServerlessHost };
|
|
255
|
+
export { createServerlessHost, runToCompletion };
|
|
73
256
|
//# sourceMappingURL=index.js.map
|
|
74
257
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/host.ts"],"names":["runMaintenanceTickCommands"],"mappings":";;;AAmGA,IAAM,qBAAN,MAAmD;AAAA,EAChC,MAAA;AAAA,EACA,YAAA;AAAA,EACA,QAAA;AAAA,EACA,qBAAA;AAAA,EACA,gBAAA;AAAA,EACA,yBAAA;AAAA,EACA,qBAAA;AAAA,EACA,sBAAA;AAAA,EAEjB,YAAY,MAAA,EAA8B;AACxC,IAAA,IAAA,CAAK,SAAS,MAAA,CAAO,MAAA;AACrB,IAAA,IAAA,CAAK,eAAe,MAAA,CAAO,YAAA;AAC3B,IAAA,IAAA,CAAK,WAAW,MAAA,CAAO,QAAA;AACvB,IAAA,IAAA,CAAK,qBAAA,GACH,MAAA,CAAO,qBAAA,IAAyB,aAAA,CAAc,qBAAA;AAChD,IAAA,IAAA,CAAK,gBAAA,GACH,MAAA,CAAO,gBAAA,IAAoB,aAAA,CAAc,gBAAA;AAC3C,IAAA,IAAA,CAAK,yBAAA,GACH,MAAA,CAAO,yBAAA,IACP,aAAA,CAAc,yBAAA;AAChB,IAAA,IAAA,CAAK,qBAAA,GACH,MAAA,CAAO,qBAAA,IAAyB,aAAA,CAAc,qBAAA;AAChD,IAAA,IAAA,CAAK,sBAAA,GACH,MAAA,CAAO,sBAAA,IAA0B,aAAA,CAAc,sBAAA;AAAA,EACnD;AAAA,EAEA,MAAM,UAAU,GAAA,EAAqC;AAKnD,IAAA,OAAO,uBAAA,CAAwB,KAAK,MAAA,EAAQ;AAAA,MAC1C,cAAc,IAAA,CAAK,YAAA;AAAA,MACnB,GAAA,EAAK,GAAA;AAAA,MACL,wBAAwB,IAAA,CAAK,sBAAA;AAAA,MAC7B,SAAA,EAAW;AAAA,KACZ,CAAA;AAAA,EACH;AAAA,EAEA,MAAM,qBAAqB,IAAA,EAEI;AAC7B,IAAA,MAAM,OAAA,GAAU,MAAM,OAAA,IAAW,CAAA;AACjC,IAAA,IAAI,SAAA,GAAY,CAAA;AAChB,IAAA,IAAI,SAAA,GAAY,CAAA;AAChB,IAAA,IAAI,MAAA,GAAS,CAAA;AAEb,IAAA,OAAO,YAAY,OAAA,EAAS;AAC1B,MAAA,MAAM,GAAA,GAAM,MAAM,IAAA,CAAK,YAAA,CAAa,OAAA,EAAQ;AAC5C,MAAA,IAAI,CAAC,GAAA,EAAK;AAEV,MAAA,MAAM,MAAA,GAAS,MAAM,IAAA,CAAK,SAAA,CAAU;AAAA,QAClC,OAAO,GAAA,CAAI,KAAA;AAAA,QACX,eAAe,GAAA,CAAI,aAAA;AAAA,QACnB,YAAY,GAAA,CAAI,UAAA;AAAA,QAChB,SAAS,GAAA,CAAI,OAAA;AAAA,QACb,SAAS,GAAA,CAAI,OAAA;AAAA,QACb,aAAa,GAAA,CAAI,WAAA;AAAA,QACjB,SAAS,GAAA,CAAI;AAAA,OACd,CAAA;AAED,MAAA,SAAA,EAAA;AACA,MAAA,IAAI,MAAA,CAAO,YAAY,QAAA,EAAU;AAC/B,QAAA,MAAA,EAAA;AAAA,MACF,CAAA,MAAO;AACL,QAAA,SAAA,EAAA;AAAA,MACF;AAAA,IACF;AAEA,IAAA,OAAO,EAAE,SAAA,EAAW,SAAA,EAAW,MAAA,EAAO;AAAA,EACxC;AAAA,EAEA,MAAM,kBAAA,GAAqD;AAMzD,IAAA,OAAOA,kBAAA,CAA2B,KAAK,MAAA,EAAQ;AAAA,MAC7C,UAAU,IAAA,CAAK,QAAA;AAAA,MACf,kBAAkB,IAAA,CAAK,gBAAA;AAAA,MACvB,2BAA2B,IAAA,CAAK,yBAAA;AAAA,MAChC,uBAAuB,IAAA,CAAK,qBAAA;AAAA,MAC5B,uBAAuB,IAAA,CAAK,qBAAA;AAAA,MAC5B,SAAA,EAAW;AAAA,KACZ,CAAA;AAAA,EACH;AACF,CAAA;AAMO,SAAS,qBACd,MAAA,EACgB;AAChB,EAAA,OAAO,IAAI,mBAAmB,MAAM,CAAA;AACtC","file":"index.js","sourcesContent":["/**\n * Serverless Host for Workflow Engine Command Kernel\n *\n * Platform-agnostic host for serverless environments (Cloudflare Workers,\n * AWS Lambda, Vercel Edge, Deno Deploy, etc.). Unlike the Node host, there\n * are no timers, loops, or signal handlers — every method is a single\n * stateless invocation.\n *\n * Consumers wire platform-specific glue (queue ack/retry, waitUntil,\n * cron triggers) around these methods.\n */\n\nimport {\n executeJobWithHeartbeat,\n HOST_DEFAULTS,\n type JobTransport,\n type Kernel,\n runMaintenanceTick as runMaintenanceTickCommands,\n} from \"@bratsos/workflow-engine/kernel\";\n\n// ============================================================================\n// Public interfaces\n// ============================================================================\n\nexport interface ServerlessHostConfig {\n /** Kernel instance to dispatch commands to. */\n kernel: Kernel;\n\n /** Job transport for complete/suspend/fail lifecycle. */\n jobTransport: JobTransport;\n\n /** Unique worker identifier (e.g. function name, worker name). */\n workerId: string;\n\n /** Stale lease threshold in milliseconds (default: 300_000). */\n staleLeaseThresholdMs?: number;\n\n /** Max pending runs to claim per maintenance tick (default: 10). */\n maxClaimsPerTick?: number;\n\n /** Max suspended stages to check per tick (default: 10). */\n maxSuspendedChecksPerTick?: number;\n\n /** Max outbox events to flush per tick (default: 100). */\n maxOutboxFlushPerTick?: number;\n\n /** Job lease heartbeat interval in milliseconds (default: 60_000). */\n jobHeartbeatIntervalMs?: number;\n}\n\n/** Message shape representing a job to execute. Matches DequeueResult fields. */\nexport interface JobMessage {\n jobId: string;\n workflowRunId: string;\n workflowId: string;\n stageId: string;\n attempt: number;\n maxAttempts?: number;\n payload: Record<string, unknown>;\n}\n\nexport interface JobResult {\n outcome: \"completed\" | \"suspended\" | \"failed\";\n error?: string;\n}\n\nexport interface ProcessJobsResult {\n processed: number;\n succeeded: number;\n failed: number;\n}\n\nexport interface MaintenanceTickResult {\n claimed: number;\n suspendedChecked: number;\n staleReleased: number;\n eventsFlushed: number;\n stuckReaped: number;\n}\n\nexport interface ServerlessHost {\n /** Execute a single pre-dequeued job. Returns outcome so consumer can ack/retry. */\n handleJob(msg: JobMessage): Promise<JobResult>;\n\n /**\n * Dequeue and process jobs from the jobTransport.\n * Defaults to 1 job per call (safe for edge runtimes with CPU limits).\n * Pass maxJobs for longer-running environments like Lambda.\n */\n processAvailableJobs(opts?: { maxJobs?: number }): Promise<ProcessJobsResult>;\n\n /** Run one bounded maintenance tick (claim, poll, reap, flush). */\n runMaintenanceTick(): Promise<MaintenanceTickResult>;\n}\n\n// ============================================================================\n// Implementation\n// ============================================================================\n\nclass ServerlessHostImpl implements ServerlessHost {\n private readonly kernel: Kernel;\n private readonly jobTransport: JobTransport;\n private readonly workerId: string;\n private readonly staleLeaseThresholdMs: number;\n private readonly maxClaimsPerTick: number;\n private readonly maxSuspendedChecksPerTick: number;\n private readonly maxOutboxFlushPerTick: number;\n private readonly jobHeartbeatIntervalMs: number;\n\n constructor(config: ServerlessHostConfig) {\n this.kernel = config.kernel;\n this.jobTransport = config.jobTransport;\n this.workerId = config.workerId;\n this.staleLeaseThresholdMs =\n config.staleLeaseThresholdMs ?? HOST_DEFAULTS.staleLeaseThresholdMs;\n this.maxClaimsPerTick =\n config.maxClaimsPerTick ?? HOST_DEFAULTS.maxClaimsPerTick;\n this.maxSuspendedChecksPerTick =\n config.maxSuspendedChecksPerTick ??\n HOST_DEFAULTS.maxSuspendedChecksPerTick;\n this.maxOutboxFlushPerTick =\n config.maxOutboxFlushPerTick ?? HOST_DEFAULTS.maxOutboxFlushPerTick;\n this.jobHeartbeatIntervalMs =\n config.jobHeartbeatIntervalMs ?? HOST_DEFAULTS.jobHeartbeatIntervalMs;\n }\n\n async handleJob(msg: JobMessage): Promise<JobResult> {\n // Dispatch job.execute under a lease heartbeat and route the outcome\n // (complete/suspend/fail + terminal run.transition) — see\n // executeJobWithHeartbeat in @bratsos/workflow-engine/kernel for the\n // shared command sequence.\n return executeJobWithHeartbeat(this.kernel, {\n jobTransport: this.jobTransport,\n job: msg,\n jobHeartbeatIntervalMs: this.jobHeartbeatIntervalMs,\n logPrefix: \"[ServerlessHost]\",\n });\n }\n\n async processAvailableJobs(opts?: {\n maxJobs?: number;\n }): Promise<ProcessJobsResult> {\n const maxJobs = opts?.maxJobs ?? 1;\n let processed = 0;\n let succeeded = 0;\n let failed = 0;\n\n while (processed < maxJobs) {\n const job = await this.jobTransport.dequeue();\n if (!job) break;\n\n const result = await this.handleJob({\n jobId: job.jobId,\n workflowRunId: job.workflowRunId,\n workflowId: job.workflowId,\n stageId: job.stageId,\n attempt: job.attempt,\n maxAttempts: job.maxAttempts,\n payload: job.payload,\n });\n\n processed++;\n if (result.outcome === \"failed\") {\n failed++;\n } else {\n succeeded++;\n }\n }\n\n return { processed, succeeded, failed };\n }\n\n async runMaintenanceTick(): Promise<MaintenanceTickResult> {\n // Claim pending runs, poll suspended stages, reap stale leases, flush\n // the outbox, and reap stuck runs — see runMaintenanceTick in\n // @bratsos/workflow-engine/kernel for the shared command sequence. The\n // serverless host returns the per-command counts to its caller (unlike\n // the Node host, which fires this on a timer and ignores them).\n return runMaintenanceTickCommands(this.kernel, {\n workerId: this.workerId,\n maxClaimsPerTick: this.maxClaimsPerTick,\n maxSuspendedChecksPerTick: this.maxSuspendedChecksPerTick,\n maxOutboxFlushPerTick: this.maxOutboxFlushPerTick,\n staleLeaseThresholdMs: this.staleLeaseThresholdMs,\n logPrefix: \"[ServerlessHost]\",\n });\n }\n}\n\n// ============================================================================\n// Factory\n// ============================================================================\n\nexport function createServerlessHost(\n config: ServerlessHostConfig,\n): ServerlessHost {\n return new ServerlessHostImpl(config);\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/host.ts","../src/run-to-completion.ts"],"names":["runMaintenanceTickCommands","HOST_DEFAULTS","executeJobWithHeartbeat"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Serverless Host for Workflow Engine Command Kernel\n *\n * Platform-agnostic host for serverless environments (Cloudflare Workers,\n * AWS Lambda, Vercel Edge, Deno Deploy, etc.). Unlike the Node host, there\n * are no timers, loops, or signal handlers — every method is a single\n * stateless invocation.\n *\n * Consumers wire platform-specific glue (queue ack/retry, waitUntil,\n * cron triggers) around these methods.\n */\n\nimport {\n type EventSinkStatus,\n executeJobWithHeartbeat,\n HOST_DEFAULTS,\n type JobTransport,\n type Kernel,\n type RetentionOptions,\n runMaintenanceTick as runMaintenanceTickCommands,\n type ServedDefinition,\n} from \"@bratsos/workflow-engine/kernel\";\n\n// ============================================================================\n// Public interfaces\n// ============================================================================\n\nexport interface ServerlessHostConfig {\n /** Kernel instance to dispatch commands to. */\n kernel: Kernel;\n\n /** Job transport for complete/suspend/fail lifecycle. */\n jobTransport: JobTransport;\n\n /** Unique worker identifier (e.g. function name, worker name). */\n workerId: string;\n\n /** Stale lease threshold in milliseconds (default: 300_000). */\n staleLeaseThresholdMs?: number;\n\n /** Absolute cap (ms) on one job claim (default: HOST_DEFAULTS.jobAbsoluteTimeoutMs). */\n jobAbsoluteTimeoutMs?: number;\n\n /** Max pending runs to claim per maintenance tick (default: 10). */\n maxClaimsPerTick?: number;\n\n /**\n * Which definition versions this host may adopt and poll. Forwarded to\n * `run.claimPending` and `stage.pollSuspended`.\n *\n * Left unset — the right default — the kernel derives it from the\n * registry: a registry built with `createWorkflowRegistry` claims only\n * runs this build can execute, which is what makes a rolling deploy safe.\n * Pass `\"all\"` to claim and poll regardless of version (the pre-1.0\n * behaviour), or an explicit list to run maintenance on behalf of\n * another build.\n */\n serves?: readonly ServedDefinition[] | \"all\";\n\n /** Max suspended stages to check per tick (default: 10). */\n maxSuspendedChecksPerTick?: number;\n\n /** Max outbox events to flush per tick (default: 100). */\n maxOutboxFlushPerTick?: number;\n\n /**\n * Delete finished runs older than `retention.olderThanMs` (with their\n * stages, logs, artifacts, annotations, step ledger, job rows and blobs)\n * through `run.purge` at the end of every maintenance tick. Off by\n * default: nothing is deleted unless this is set.\n */\n retention?: RetentionOptions;\n\n /**\n * Publish this job's outbox events right after `handleJob` settles it\n * (default: true). There is no process lifecycle to hook a final flush\n * on, so without this a run completed by this invocation is announced by\n * whichever invocation runs the next maintenance tick. Bounded by\n * `outboxFlushTimeoutMs`; errors are logged, never thrown.\n */\n flushOutboxAfterJob?: boolean;\n\n /** Upper bound (ms) on the post-job outbox flush (default: 5_000). */\n outboxFlushTimeoutMs?: number;\n\n /** Job lease heartbeat interval in milliseconds (default: 60_000). */\n jobHeartbeatIntervalMs?: number;\n}\n\n/** Message shape representing a job to execute. Matches DequeueResult fields. */\nexport interface JobMessage {\n jobId: string;\n workflowRunId: string;\n workflowId: string;\n stageId: string;\n attempt: number;\n maxAttempts?: number;\n payload: Record<string, unknown>;\n}\n\n/**\n * Outcome of `handleJob`. The consumer's ack/retry decision reads\n * `willRetry`: when true the stage was left `PENDING` and the job must run\n * again — a transport whose `fail()` re-enqueues (the built-in queues) has\n * already done so and the message can be acknowledged; a push transport\n * whose `fail()` cannot (a queue consumer that must `retry()` the message\n * itself) retries the message after `retryDelayMs`. When false the job is\n * settled: acknowledge it.\n */\nexport interface JobResult {\n outcome: \"completed\" | \"suspended\" | \"failed\";\n error?: string;\n /** The message was an orphan or malformed; it was failed and acknowledged. */\n dead?: boolean;\n willRetry?: boolean;\n /** The job attempt that ran (1 on the first execution). */\n attempt?: number;\n maxAttempts?: number;\n /** Backoff before the retry (`2^attempt` seconds), when `willRetry`. */\n retryDelayMs?: number;\n}\n\nexport interface ProcessJobsResult {\n processed: number;\n succeeded: number;\n failed: number;\n}\n\nexport interface MaintenanceTickResult {\n claimed: number;\n suspendedChecked: number;\n staleReleased: number;\n eventsFlushed: number;\n stuckReaped: number;\n /** Terminal runs deleted by `run.purge`; 0 unless `retention` is configured. */\n purged: number;\n /** Events claimed but not published this tick; the next flush retries them. */\n eventsFailed: number;\n /** Events that exhausted their retry budget and moved to the DLQ. */\n eventsDeadLettered: number;\n /**\n * `\"degraded\"` when the event sink refused at least one event this tick.\n * There is no process to carry the state across invocations, so a\n * serverless caller reports this per tick (and alerts on a run of them);\n * runs keep progressing either way.\n */\n eventSinkStatus: EventSinkStatus;\n /** First publish failure of this tick, when degraded. */\n eventSinkError?: string;\n}\n\nexport interface ServerlessHost {\n /** Execute a single pre-dequeued job. Returns outcome so consumer can ack/retry. */\n handleJob(msg: JobMessage): Promise<JobResult>;\n\n /**\n * Dequeue and process jobs from the jobTransport.\n * Defaults to 1 job per call (safe for edge runtimes with CPU limits).\n * Pass maxJobs for longer-running environments like Lambda.\n */\n processAvailableJobs(opts?: { maxJobs?: number }): Promise<ProcessJobsResult>;\n\n /** Run one bounded maintenance tick (claim, poll, reap, flush). */\n runMaintenanceTick(): Promise<MaintenanceTickResult>;\n}\n\n// ============================================================================\n// Implementation\n// ============================================================================\n\nclass ServerlessHostImpl implements ServerlessHost {\n private readonly kernel: Kernel;\n private readonly jobTransport: JobTransport;\n private readonly workerId: string;\n private readonly staleLeaseThresholdMs: number;\n private readonly jobAbsoluteTimeoutMs: number;\n private readonly maxClaimsPerTick: number;\n /** See `HostConfig.serves`; `undefined` means \"derive from the registry\". */\n private readonly serves: readonly ServedDefinition[] | \"all\" | undefined;\n private readonly maxSuspendedChecksPerTick: number;\n private readonly maxOutboxFlushPerTick: number;\n private readonly retention: RetentionOptions | undefined;\n private readonly jobHeartbeatIntervalMs: number;\n private readonly flushOutboxAfterJob: boolean;\n private readonly outboxFlushTimeoutMs: number;\n\n constructor(config: ServerlessHostConfig) {\n this.kernel = config.kernel;\n this.jobTransport = config.jobTransport;\n this.workerId = config.workerId;\n this.staleLeaseThresholdMs =\n config.staleLeaseThresholdMs ?? HOST_DEFAULTS.staleLeaseThresholdMs;\n this.jobAbsoluteTimeoutMs =\n config.jobAbsoluteTimeoutMs ?? HOST_DEFAULTS.jobAbsoluteTimeoutMs;\n this.maxClaimsPerTick =\n config.maxClaimsPerTick ?? HOST_DEFAULTS.maxClaimsPerTick;\n this.serves = config.serves;\n this.maxSuspendedChecksPerTick =\n config.maxSuspendedChecksPerTick ??\n HOST_DEFAULTS.maxSuspendedChecksPerTick;\n this.maxOutboxFlushPerTick =\n config.maxOutboxFlushPerTick ?? HOST_DEFAULTS.maxOutboxFlushPerTick;\n this.retention = config.retention;\n this.jobHeartbeatIntervalMs =\n config.jobHeartbeatIntervalMs ?? HOST_DEFAULTS.jobHeartbeatIntervalMs;\n this.flushOutboxAfterJob = config.flushOutboxAfterJob ?? true;\n this.outboxFlushTimeoutMs = config.outboxFlushTimeoutMs ?? 5_000;\n }\n\n async handleJob(msg: JobMessage): Promise<JobResult> {\n // Dispatch job.execute under a lease heartbeat and route the outcome\n // (complete/suspend/fail + terminal run.transition) — see\n // executeJobWithHeartbeat in @bratsos/workflow-engine/kernel for the\n // shared command sequence.\n const result = await executeJobWithHeartbeat(this.kernel, {\n jobTransport: this.jobTransport,\n job: msg,\n jobHeartbeatIntervalMs: this.jobHeartbeatIntervalMs,\n logPrefix: \"[ServerlessHost]\",\n });\n if (this.flushOutboxAfterJob) {\n await this.flushOutbox();\n }\n return result;\n }\n\n /** Publish pending outbox events, bounded by `outboxFlushTimeoutMs`. */\n private async flushOutbox(): Promise<void> {\n let timer: ReturnType<typeof setTimeout> | undefined;\n const timeout = new Promise<\"timeout\">((resolve) => {\n timer = setTimeout(() => resolve(\"timeout\"), this.outboxFlushTimeoutMs);\n });\n try {\n const outcome = await Promise.race([\n this.kernel.dispatch({\n type: \"outbox.flush\",\n maxEvents: this.maxOutboxFlushPerTick,\n }),\n timeout,\n ]);\n if (outcome === \"timeout\") {\n console.error(\n \"[ServerlessHost] outbox.flush after job: timed out; the next maintenance tick publishes the rest\",\n );\n } else if (outcome.eventSinkStatus === \"degraded\") {\n // Stateless: no transition to detect, so say it every time.\n console.error(\n `[ServerlessHost] event sink DEGRADED: ${outcome.failed} event(s) stayed in the outbox for a later flush; runs keep progressing. Last error: ${\n outcome.eventSinkError ?? \"unknown\"\n }`,\n );\n }\n if (outcome !== \"timeout\" && outcome.deadLettered > 0) {\n console.error(\n `[ServerlessHost] event sink DEAD-LETTERED ${outcome.deadLettered} event(s): they will not be delivered until replayed with the plugin.replayDLQ command`,\n );\n }\n } catch (error) {\n console.error(\"[ServerlessHost] outbox.flush after job error:\", error);\n } finally {\n if (timer) clearTimeout(timer);\n }\n }\n\n async processAvailableJobs(opts?: {\n maxJobs?: number;\n }): Promise<ProcessJobsResult> {\n const maxJobs = opts?.maxJobs ?? 1;\n let processed = 0;\n let succeeded = 0;\n let failed = 0;\n\n while (processed < maxJobs) {\n const serves = this.dequeueServes();\n const job = await this.jobTransport.dequeue(\n serves !== undefined ? { serves } : undefined,\n );\n if (!job) break;\n\n const result = await this.handleJob({\n jobId: job.jobId,\n workflowRunId: job.workflowRunId,\n workflowId: job.workflowId,\n stageId: job.stageId,\n attempt: job.attempt,\n maxAttempts: job.maxAttempts,\n payload: job.payload,\n });\n\n processed++;\n if (result.outcome === \"failed\") {\n failed++;\n } else {\n succeeded++;\n }\n }\n\n return { processed, succeeded, failed };\n }\n\n /**\n * What to pass to `jobTransport.dequeue`. `\"all\"` means claim regardless\n * of version, which is expressed by passing nothing; otherwise the\n * kernel's registry answers, so the dequeue narrows on exactly what\n * `run.claimPending` narrows on.\n */\n private dequeueServes(): readonly ServedDefinition[] | undefined {\n if (this.serves === \"all\") return undefined;\n return this.serves ?? this.kernel.servedDefinitions?.();\n }\n\n async runMaintenanceTick(): Promise<MaintenanceTickResult> {\n // Claim pending runs, poll suspended stages, reap stale leases, flush\n // the outbox, and reap stuck runs — see runMaintenanceTick in\n // @bratsos/workflow-engine/kernel for the shared command sequence. The\n // serverless host returns the per-command counts to its caller (unlike\n // the Node host, which fires this on a timer and ignores them).\n const counts = await runMaintenanceTickCommands(this.kernel, {\n workerId: this.workerId,\n maxClaimsPerTick: this.maxClaimsPerTick,\n ...(this.serves !== undefined ? { serves: this.serves } : {}),\n maxSuspendedChecksPerTick: this.maxSuspendedChecksPerTick,\n maxOutboxFlushPerTick: this.maxOutboxFlushPerTick,\n staleLeaseThresholdMs: this.staleLeaseThresholdMs,\n jobAbsoluteTimeoutMs: this.jobAbsoluteTimeoutMs,\n ...(this.retention !== undefined ? { retention: this.retention } : {}),\n logPrefix: \"[ServerlessHost]\",\n });\n if (counts.eventSinkStatus === \"degraded\") {\n console.error(\n `[ServerlessHost] event sink DEGRADED: ${counts.eventsFailed} event(s) stayed in the outbox for a later flush; runs keep progressing. Last error: ${\n counts.eventSinkError ?? \"unknown\"\n }`,\n );\n }\n if (counts.eventsDeadLettered > 0) {\n console.error(\n `[ServerlessHost] event sink DEAD-LETTERED ${counts.eventsDeadLettered} event(s): they will not be delivered until replayed with the plugin.replayDLQ command`,\n );\n }\n return counts;\n }\n}\n\n// ============================================================================\n// Factory\n// ============================================================================\n\nexport function createServerlessHost(\n config: ServerlessHostConfig,\n): ServerlessHost {\n return new ServerlessHostImpl(config);\n}\n","// packages/workflow-engine-host-serverless/src/run-to-completion.ts\nimport {\n executeJobWithHeartbeat,\n HOST_DEFAULTS,\n type JobTransport,\n type Kernel,\n type RunCreateCommand,\n} from \"@bratsos/workflow-engine/kernel\";\n\n/** The one read `runToCompletion` needs: how did the run end. */\nexport interface RunToCompletionPersistence {\n getRun(id: string): Promise<{\n status: string;\n output: unknown | null;\n } | null>;\n}\n\n/**\n * Why `runToCompletion` stopped.\n *\n * The first three are the run's own terminal states. The last two are not\n * failures — they say the run is still alive and something else has to carry\n * it, which is the honest answer for a helper that must return inside a\n * request.\n */\nexport type RunToCompletionOutcome =\n | \"completed\"\n | \"failed\"\n | \"cancelled\"\n /** A stage suspended (a durable sleep, wait or signal). See the caveats. */\n | \"suspended\"\n /** The budget ran out, or nothing was runnable yet. See `reason`. */\n | \"incomplete\";\n\nexport interface RunToCompletionResult {\n workflowRunId: string;\n /** The run's persisted status when the helper returned. */\n status: string;\n outcome: RunToCompletionOutcome;\n /** The run's output, when it completed. */\n output?: unknown;\n /** Plain-language explanation, always present when `outcome` is not terminal. */\n reason?: string;\n /** Jobs executed by this call, this run's and other callers' alike. */\n jobsProcessed: number;\n /**\n * How many of `jobsProcessed` belonged to a different run. Non-zero means\n * this call did work on someone else's behalf — see the shared-queue\n * caveat.\n */\n foreignJobsProcessed: number;\n /** The stage that suspended, when `outcome` is `\"suspended\"`. */\n suspendedStageId?: string;\n}\n\nexport interface RunToCompletionOptions {\n kernel: Kernel;\n /** The same transport the kernel enqueues onto. */\n jobTransport: JobTransport;\n /** Read port for the run's final status. */\n persistence: RunToCompletionPersistence;\n /** The run to create, exactly as `kernel.dispatch` takes it. */\n command: RunCreateCommand;\n /** Worker id used for the claim and the job leases. Defaults to `\"run-to-completion\"`. */\n workerId?: string;\n /**\n * Hard cap on jobs this call executes. Defaults to 50. Reaching it returns\n * `outcome: \"incomplete\"`; it never throws and never loops past the cap.\n */\n maxJobs?: number;\n /**\n * How many `run.claimPending` rounds to spend looking for this run's first\n * job. Defaults to 5. Each round claims up to `claimsPerRound` pending\n * runs — including other callers' (see the caveats).\n */\n maxClaimRounds?: number;\n /** Pending runs claimed per round. Defaults to 10. */\n claimsPerRound?: number;\n /** Job lease heartbeat interval. Defaults to `HOST_DEFAULTS.jobHeartbeatIntervalMs`. */\n jobHeartbeatIntervalMs?: number;\n /**\n * Publish the run's outbox events before returning (default true), so a\n * caller that returns the result straight to an HTTP client has already\n * announced the run.\n */\n flushOutbox?: boolean;\n /** Max events published by that final flush. Defaults to 100. */\n maxOutboxFlush?: number;\n /** Log prefix for the job loop. Defaults to `\"[runToCompletion]\"`. */\n logPrefix?: string;\n}\n\nconst TERMINAL: Record<string, RunToCompletionOutcome> = {\n COMPLETED: \"completed\",\n FAILED: \"failed\",\n CANCELLED: \"cancelled\",\n};\n\n/**\n * Create a workflow run and drive it to a terminal state inside the calling\n * request — the drain loop every caller writes by hand, exactly once.\n *\n * It creates the run, claims it, then dequeues and executes jobs through the\n * same `executeJobWithHeartbeat` the hosts use, until the run is terminal,\n * a stage suspends, nothing more is runnable, or a bound trips.\n *\n * ## Three things to know before you use it\n *\n * **1. It shares the queue, so it can execute another caller's job.** The\n * kernel's queue is global: `run.claimPending` claims whichever runs are\n * pending, and `dequeue()` returns whichever job is next — neither can be\n * narrowed to one run. So this call may start other callers' runs and\n * execute their jobs against its own budget, and their work lands inside\n * your request's latency and error budget. `foreignJobsProcessed` reports\n * how much of that happened. If it matters, give this call a kernel wired to\n * its own `jobTransport` (an in-request queue) so the only jobs it can see\n * are the ones it created.\n *\n * **2. It cannot complete a workflow that suspends.** A durable sleep, wait\n * or signal parks the stage until a later poll, which by definition is not\n * this request. Rather than spin — polling a clock that has not moved, or\n * sleeping inside a request handler — it returns immediately with\n * `outcome: \"suspended\"` and the stage that parked. The run is healthy and\n * unfinished: a background host or a scheduled maintenance tick resumes it.\n * Workflows you intend to run this way should have no durable waits.\n *\n * **3. It is bounded, never an unbounded loop.** `maxJobs` caps the jobs one\n * call executes and `maxClaimRounds` caps the search for this run's first\n * job. Hitting either returns `outcome: \"incomplete\"` with a `reason`; it\n * does not throw, and it does not keep going. The same is true when a stage\n * fails and is re-enqueued with a retry backoff: the job is not due yet, the\n * queue is empty, and the call returns `\"incomplete\"` rather than waiting.\n *\n * It also deliberately does not poll suspended stages or reap stale leases.\n * Those are maintenance, they are what would make it spin, and\n * `host.runMaintenanceTick()` already owns them.\n *\n * @example\n * ```typescript\n * const result = await runToCompletion({\n * kernel,\n * jobTransport,\n * persistence,\n * command: {\n * type: \"run.create\",\n * idempotencyKey: `checkout:${orderId}`,\n * workflowId: \"checkout\",\n * input: { orderId },\n * },\n * });\n *\n * if (result.outcome === \"completed\") return Response.json(result.output);\n * if (result.outcome === \"suspended\") return new Response(null, { status: 202 });\n * return new Response(result.reason ?? \"run failed\", { status: 500 });\n * ```\n */\nexport async function runToCompletion(\n options: RunToCompletionOptions,\n): Promise<RunToCompletionResult> {\n const {\n kernel,\n jobTransport,\n persistence,\n command,\n workerId = \"run-to-completion\",\n maxJobs = 50,\n maxClaimRounds = 5,\n claimsPerRound = 10,\n jobHeartbeatIntervalMs = HOST_DEFAULTS.jobHeartbeatIntervalMs,\n flushOutbox = true,\n maxOutboxFlush = HOST_DEFAULTS.maxOutboxFlushPerTick,\n logPrefix = \"[runToCompletion]\",\n } = options;\n\n const created = await kernel.dispatch(command);\n const workflowRunId = created.workflowRunId;\n\n let jobsProcessed = 0;\n let foreignJobsProcessed = 0;\n let suspendedStageId: string | undefined;\n let reason: string | undefined;\n\n // Claim until this run is claimed. Each round may also claim other\n // callers' pending runs; that is the queue being shared, not a bug, and it\n // is why the round count is bounded.\n let claimed = false;\n for (let round = 0; round < maxClaimRounds && !claimed; round++) {\n const result = await kernel.dispatch({\n type: \"run.claimPending\",\n workerId,\n maxClaims: claimsPerRound,\n });\n claimed = result.claimed.some(\n (entry) => entry.workflowRunId === workflowRunId,\n );\n // Nothing left to claim and it still was not ours: another worker took\n // it, and its jobs are that worker's to run.\n if (!claimed && result.claimed.length === 0) break;\n }\n if (!claimed) {\n reason =\n `run ${workflowRunId} was not claimed within ${maxClaimRounds} claim round(s): ` +\n `another worker claimed it, and it will finish there`;\n }\n\n while (claimed && jobsProcessed < maxJobs) {\n const job = await jobTransport.dequeue();\n if (!job) {\n reason = `no runnable job remained (${jobsProcessed} executed)`;\n break;\n }\n\n const outcome = await executeJobWithHeartbeat(kernel, {\n jobTransport,\n job: {\n jobId: job.jobId,\n workflowRunId: job.workflowRunId,\n workflowId: job.workflowId,\n stageId: job.stageId,\n attempt: job.attempt,\n maxAttempts: job.maxAttempts,\n payload: job.payload,\n startedAt: job.startedAt,\n },\n jobHeartbeatIntervalMs,\n logPrefix,\n });\n\n jobsProcessed++;\n const mine = job.workflowRunId === workflowRunId;\n if (!mine) foreignJobsProcessed++;\n\n if (mine && outcome.outcome === \"suspended\") {\n suspendedStageId = job.stageId;\n reason =\n `stage \"${job.stageId}\" suspended on a durable wait; the run resumes on a ` +\n `later poll, not in this request`;\n break;\n }\n }\n if (claimed && jobsProcessed >= maxJobs && !suspendedStageId) {\n reason = `job budget of ${maxJobs} exhausted before the run finished`;\n }\n\n if (flushOutbox) {\n // Best effort: the events are already durable in the outbox, and a\n // publish failure must not turn a finished run into a thrown request.\n try {\n await kernel.dispatch({\n type: \"outbox.flush\",\n maxEvents: maxOutboxFlush,\n });\n } catch (error) {\n console.error(`${logPrefix} outbox.flush failed:`, error);\n }\n }\n\n const record = await persistence.getRun(workflowRunId);\n const status = record?.status ?? \"UNKNOWN\";\n const terminal = TERMINAL[status];\n\n if (terminal) {\n return {\n workflowRunId,\n status,\n outcome: terminal,\n ...(record?.output != null ? { output: record.output } : {}),\n jobsProcessed,\n foreignJobsProcessed,\n };\n }\n\n return {\n workflowRunId,\n status,\n outcome: suspendedStageId ? \"suspended\" : \"incomplete\",\n reason: reason ?? `the run is still ${status}`,\n jobsProcessed,\n foreignJobsProcessed,\n ...(suspendedStageId ? { suspendedStageId } : {}),\n };\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bratsos/workflow-engine-host-serverless",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0-alpha.0",
|
|
4
4
|
"description": "Platform-agnostic serverless host for @bratsos/workflow-engine command kernel",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"dist"
|
|
29
29
|
],
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@bratsos/workflow-engine": "0.13
|
|
31
|
+
"@bratsos/workflow-engine": "1.0.0-alpha.13"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"tsup": "^8.5.1",
|