fyflow-scheduler 0.1.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/AGENTS.md +577 -0
- package/LICENSE +21 -0
- package/README.md +415 -0
- package/dist/browser/index.js +2378 -0
- package/dist/node/index.js +2380 -0
- package/dist/types/core/FyflowScheduler.d.ts +279 -0
- package/dist/types/core/FyflowScheduler.d.ts.map +1 -0
- package/dist/types/core/inlineWrapper.d.ts +45 -0
- package/dist/types/core/inlineWrapper.d.ts.map +1 -0
- package/dist/types/core/threadWrapper.d.ts +59 -0
- package/dist/types/core/threadWrapper.d.ts.map +1 -0
- package/dist/types/core/workerInterface.d.ts +166 -0
- package/dist/types/core/workerInterface.d.ts.map +1 -0
- package/dist/types/core/workerManager.d.ts +141 -0
- package/dist/types/core/workerManager.d.ts.map +1 -0
- package/dist/types/core/workerWrapper.d.ts +2 -0
- package/dist/types/core/workerWrapper.d.ts.map +1 -0
- package/dist/types/core/workerWrapperUrl.bundled.d.ts +2 -0
- package/dist/types/core/workerWrapperUrl.bundled.d.ts.map +1 -0
- package/dist/types/core/workerWrapperUrl.d.ts +2 -0
- package/dist/types/core/workerWrapperUrl.d.ts.map +1 -0
- package/dist/types/groups/concurrentLimitGroup.d.ts +33 -0
- package/dist/types/groups/concurrentLimitGroup.d.ts.map +1 -0
- package/dist/types/groups/keyedRateLimitGroup.d.ts +98 -0
- package/dist/types/groups/keyedRateLimitGroup.d.ts.map +1 -0
- package/dist/types/groups/rateLimitGroup.d.ts +79 -0
- package/dist/types/groups/rateLimitGroup.d.ts.map +1 -0
- package/dist/types/groups/resourceGroup.d.ts +41 -0
- package/dist/types/groups/resourceGroup.d.ts.map +1 -0
- package/dist/types/index.d.ts +15 -0
- package/dist/types/index.d.ts.map +1 -0
- package/package.json +83 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
# FyFlow Scheduler — Reference for AI Agents
|
|
2
|
+
|
|
3
|
+
Complete usage reference for `fyflow-scheduler`. Everything here is exercised by
|
|
4
|
+
`tests/suites/docs.ts`, which runs on every `npm test` — if a snippet stops
|
|
5
|
+
working, the build fails.
|
|
6
|
+
|
|
7
|
+
**Package**: `fyflow-scheduler` (npm) for Node **≥ 22** and browsers /
|
|
8
|
+
`jsr:@fyflow/scheduler` for Deno
|
|
9
|
+
**Dependencies**: none
|
|
10
|
+
|
|
11
|
+
The two are not interchangeable. The JSR package is **Deno-only** and contains no
|
|
12
|
+
Node-specific files; importing it outside Deno throws an error pointing at npm.
|
|
13
|
+
Node and browser support lives entirely in the npm package, which ships prebuilt
|
|
14
|
+
bundles with the worker bootstrap inlined.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## 1. What this library is
|
|
19
|
+
|
|
20
|
+
An in-memory scheduler that runs independent tasks in parallel across pools of
|
|
21
|
+
workers, with resource limits.
|
|
22
|
+
|
|
23
|
+
**Tasks are independent.** There is no dependency graph, no `dependsOn`, no
|
|
24
|
+
topological ordering. Every task runs as soon as (a) its worker pool has a free
|
|
25
|
+
slot and (b) every resource group it belongs to has capacity. To express "B after
|
|
26
|
+
A", either await A and then add B, or have A spawn B (§7).
|
|
27
|
+
|
|
28
|
+
### Things that do NOT exist — do not generate code using them
|
|
29
|
+
|
|
30
|
+
| Not available | Use instead |
|
|
31
|
+
|---|---|
|
|
32
|
+
| Task dependencies (`dependsOn`, `parents`, `children`) | Await a task, or spawn from inside a worker (§7) |
|
|
33
|
+
| `StrictLimitGroup`, token/semaphore acquisition | `ConcurrentLimitGroup` (soft) or `RateLimitGroup` |
|
|
34
|
+
| `DagScheduler`, `DagTask` | `FyflowScheduler`, `FyflowTask` |
|
|
35
|
+
| Priorities, cron/scheduled tasks, persistence | Not implemented |
|
|
36
|
+
| Cancelling a queued task | Not implemented |
|
|
37
|
+
|
|
38
|
+
Both group types are **optimistic**: a limit can be briefly exceeded by up to
|
|
39
|
+
`maxThreads × maxConcurrentTasks` under race conditions. Nothing here guarantees
|
|
40
|
+
a hard cap.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## 2. Minimal working program
|
|
45
|
+
|
|
46
|
+
```typescript
|
|
47
|
+
import { FyflowScheduler, FyflowTask, WorkerManager } from 'fyflow-scheduler';
|
|
48
|
+
|
|
49
|
+
// See §3 - this line differs per runtime
|
|
50
|
+
const workerUrl = new URL('./myWorker.js', import.meta.url).href;
|
|
51
|
+
|
|
52
|
+
const scheduler = new FyflowScheduler({
|
|
53
|
+
MyWorker: new WorkerManager(workerUrl, { maxThreads: 4, inline: true })
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const result = await scheduler.addTask(
|
|
57
|
+
new FyflowTask({ id: 'task-1', workerType: 'MyWorker', payload: { value: 21 } }),
|
|
58
|
+
{ createPromise: true } // REQUIRED to get a promise back
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
console.log(result);
|
|
62
|
+
await scheduler.shutdown(); // REQUIRED or the process stays alive
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Two things bite everyone: `addTask` returns `undefined` without
|
|
66
|
+
`{ createPromise: true }`, and a scheduler that is never shut down keeps live
|
|
67
|
+
workers and a retry timer running.
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## 3. Worker URLs
|
|
72
|
+
|
|
73
|
+
A worker is loaded by URL, and what you pass depends on your runtime.
|
|
74
|
+
|
|
75
|
+
### npm (Node and browser) - ship a `.js` worker
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
const workerUrl = new URL('./myWorker.js', import.meta.url).href;
|
|
79
|
+
|
|
80
|
+
const pool = new WorkerManager(workerUrl, { maxThreads: 4 });
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
That is all. No bundler plugin, no build step for the worker, no query suffix.
|
|
84
|
+
Verified against the published package with both `inline: true` and
|
|
85
|
+
`inline: false`.
|
|
86
|
+
|
|
87
|
+
- The file must be **JavaScript**. Node cannot import TypeScript, so a `.ts`
|
|
88
|
+
worker fails with `Unknown file extension ".ts"`. Compile your worker as part
|
|
89
|
+
of your own build.
|
|
90
|
+
- In the browser the URL must be reachable by the page, and your bundler needs to
|
|
91
|
+
emit the worker as a separate asset. Most bundlers do this for
|
|
92
|
+
`new URL('./myWorker.js', import.meta.url)` automatically.
|
|
93
|
+
|
|
94
|
+
### Deno (JSR) - point at the TypeScript source
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
const workerUrl = new URL('./myWorker.ts', import.meta.url).href;
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Deno loads the source directly. No build step.
|
|
101
|
+
|
|
102
|
+
### Cross-runtime code
|
|
103
|
+
|
|
104
|
+
Only needed if one codebase must run on both:
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
const workerUrl = typeof Deno !== 'undefined'
|
|
108
|
+
? new URL('./myWorker.ts', import.meta.url).href
|
|
109
|
+
: new URL('./myWorker.js', import.meta.url).href;
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
> **`?worker-direct` is not part of this API.** You will see it in this
|
|
113
|
+
> repository's own examples and tests. It is a convention of *this repo's*
|
|
114
|
+
> `esbuild.config.js`, which inlines a `.ts` worker into the bundle so the
|
|
115
|
+
> examples can be written once and run on both runtimes. It does not exist in the
|
|
116
|
+
> published packages, and using it in your own code will fail to resolve.
|
|
117
|
+
|
|
118
|
+
The worker file must `export default` a class. Anything else fails with
|
|
119
|
+
`Worker script <url> must export a default class`.
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## 4. Writing a worker
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
import { BaseWorker, WorkerConfig, BaseWorkerContext, TaskWorkerContext }
|
|
127
|
+
from 'fyflow-scheduler';
|
|
128
|
+
|
|
129
|
+
export default class MyWorker extends BaseWorker {
|
|
130
|
+
private multiplier: number;
|
|
131
|
+
|
|
132
|
+
// The pool calls `new MyWorker(config, workerContext)`.
|
|
133
|
+
// Forward BOTH arguments - dropping the second leaves `this.workerContext`
|
|
134
|
+
// undefined and terminateWithError() silently does nothing.
|
|
135
|
+
constructor(config: WorkerConfig = {}, workerContext?: BaseWorkerContext) {
|
|
136
|
+
super(config, workerContext);
|
|
137
|
+
this.multiplier = (config as any).multiplier ?? 1;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// setup and teardown are ABSTRACT on BaseWorker - both are required, even if
|
|
141
|
+
// empty. Omitting them is a compile error.
|
|
142
|
+
async setup(): Promise<void> {}
|
|
143
|
+
async teardown(): Promise<void> {}
|
|
144
|
+
|
|
145
|
+
async run(payload: any, context?: TaskWorkerContext): Promise<any> {
|
|
146
|
+
context?.sendProgress(0.5, 'halfway'); // 0-1, NOT a percentage
|
|
147
|
+
return { value: payload.value * this.multiplier };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
- `config` comes from the pool's `config` option and is shared by every worker
|
|
153
|
+
instance in that pool.
|
|
154
|
+
- A worker can ask the pool to tear it down - e.g. after a connection goes bad -
|
|
155
|
+
with `this.workerContext?.terminateWithError(err, { canRestart: true })`. The
|
|
156
|
+
task running at the time rejects with a `WorkerTerminationError`, so catch that
|
|
157
|
+
to tell worker shutdown apart from an ordinary task failure:
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
import { WorkerTerminationError } from 'fyflow-scheduler';
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
await scheduler.addTask(task, { createPromise: true });
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (error instanceof WorkerTerminationError) { /* the worker went away */ }
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
- One instance is created per worker, lazily, and reused across tasks. Instance
|
|
169
|
+
state persists between tasks — do not assume a fresh object per task.
|
|
170
|
+
- Throwing from `run()` fails that task. Throwing from `setup()` fails the
|
|
171
|
+
worker.
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## 5. API reference
|
|
176
|
+
|
|
177
|
+
### Everything importable
|
|
178
|
+
|
|
179
|
+
```typescript
|
|
180
|
+
import {
|
|
181
|
+
FyflowScheduler, FyflowTask, WorkerManager, // core
|
|
182
|
+
ConcurrentLimitGroup, RateLimitGroup, KeyedRateLimitGroup,
|
|
183
|
+
BaseWorker, WorkerTerminationError // worker authoring
|
|
184
|
+
} from 'fyflow-scheduler';
|
|
185
|
+
|
|
186
|
+
import type {
|
|
187
|
+
FyflowSchedulerOptions, AddTaskOptions,
|
|
188
|
+
WorkerManagerOptions, WorkerConfig, WorkerInterface, WorkerStatus,
|
|
189
|
+
WorkerInstanceState, BaseWorkerContext, TaskWorkerContext, WorkerContext,
|
|
190
|
+
SpawnTaskConfig, ProgressData,
|
|
191
|
+
ResourceGroup, ResourceGroupMetrics, ResourceGroupStats,
|
|
192
|
+
RateWindow, KeyedRateLimitGroupOptions, KeyedTaskLike
|
|
193
|
+
} from 'fyflow-scheduler';
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
`ThreadWrapper` and `InlineWrapper` are also exported, but they are the internal
|
|
197
|
+
worker wrappers the pool manages for you - you should not construct them.
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
### `new FyflowScheduler(workerPools, resourceGroups?, options?)`
|
|
201
|
+
|
|
202
|
+
| Argument | Type | Notes |
|
|
203
|
+
|---|---|---|
|
|
204
|
+
| `workerPools` | `Record<string, WorkerManager>` | Keys are the `workerType` values tasks refer to |
|
|
205
|
+
| `resourceGroups` | `Record<string, ResourceGroup>` | Keys are the group ids used in `groups` / `workerGroups` |
|
|
206
|
+
| `options.maxCompletedTasks` | `number` | Cap on retained terminal tasks. Default: unlimited |
|
|
207
|
+
| `options.periodicRetryIntervalMs` | `number` | Retry interval for blocked tasks. Default `50` |
|
|
208
|
+
|
|
209
|
+
| Member | Signature | Notes |
|
|
210
|
+
|---|---|---|
|
|
211
|
+
| `addTask` | `(task, opts?) => Promise<any> \| void` | Returns a promise only with `{ createPromise: true }`. **Throws** on unknown `workerType` |
|
|
212
|
+
| `addTasks` | `(tasks, opts?) => Promise<any>[] \| void` | Batched dispatch for bulk adds. **Throws** on unknown `workerType`, validating the whole batch before queueing any of it |
|
|
213
|
+
| `stats` | `{ queued, running, done, failed }` | Counts **tasks**, not attempts - a task that fails after two retries counts once. Includes evicted tasks |
|
|
214
|
+
| `tasks` | `Map<string, FyflowTask>` | Live and completed tasks (see `maxCompletedTasks`) |
|
|
215
|
+
| `getResourceMetrics()` | `Record<string, { limit, running, available, utilization }>` | `utilization` is 0–1 |
|
|
216
|
+
| `getResourceStats()` | `Record<string, { totalAcquired, totalReleased }>` | Lifetime counters |
|
|
217
|
+
| `shutdown()` | `Promise<void>` | Always call when finished |
|
|
218
|
+
|
|
219
|
+
### `new FyflowTask(config)`
|
|
220
|
+
|
|
221
|
+
| Field | Type | Default | Notes |
|
|
222
|
+
|---|---|---|---|
|
|
223
|
+
| `id` | `string` | — | Must be unique; reusing an id overwrites |
|
|
224
|
+
| `workerType` | `string` | — | Key into `workerPools` |
|
|
225
|
+
| `payload` | `any` | — | Passed verbatim to `run()` |
|
|
226
|
+
| `optional` | `boolean` | `false` | If it fails, resolves `null` instead of rejecting |
|
|
227
|
+
| `retryPolicy` | `{ maxRetries, backoffMs }` | none | |
|
|
228
|
+
| `workerGroups` | `string[]` | `[]` | **Added to** the pool's groups, not a replacement |
|
|
229
|
+
| `limitKey` | `string` | none | Bucket for keyed groups. Required if the task belongs to a `KeyedRateLimitGroup` with no custom `keyFrom` |
|
|
230
|
+
| `handleRejection` | `boolean` | `true` | Silences unhandled rejections for fire-and-forget |
|
|
231
|
+
|
|
232
|
+
Readable after the run: `state`, `result`, `error`, `attempts`, `startTime`,
|
|
233
|
+
`endTime`, `executionTime` (worker-measured ms, excludes queue wait).
|
|
234
|
+
|
|
235
|
+
States: `pending` → `running` → `done` | `failed`. A **non-optional** task that
|
|
236
|
+
fails with no retries left ends in **`user_action`**, not `failed`; an
|
|
237
|
+
**optional** one ends in `failed` and resolves `null`. Each attempt settles
|
|
238
|
+
exactly once, so `task.failed` fires once per failed task and the terminal state
|
|
239
|
+
does not change afterwards.
|
|
240
|
+
|
|
241
|
+
Methods: `onCompletePromise()` — this task only; `onCompleteDescendants()` —
|
|
242
|
+
this task plus everything it spawns (§7).
|
|
243
|
+
|
|
244
|
+
### `new WorkerManager(scriptUrl, options)`
|
|
245
|
+
|
|
246
|
+
| Option | Default | Notes |
|
|
247
|
+
|---|---|---|
|
|
248
|
+
| `maxThreads` | `2` | Worker **instances**, not OS threads — see below |
|
|
249
|
+
| `maxConcurrentTasks` | `1` | Tasks per instance. Pool capacity = `maxThreads × maxConcurrentTasks` |
|
|
250
|
+
| `inline` | `false` | `true` runs in the main process — right for async/IO, wrong for CPU-bound |
|
|
251
|
+
| `config` | `{}` | First constructor argument for every worker instance |
|
|
252
|
+
| `groups` | `[]` | Group ids every task in this pool must acquire |
|
|
253
|
+
| `idleTimeout` | `5000` | Ms before an idle worker is terminated. `0` = never |
|
|
254
|
+
| `requeueFailedTasks` | `true` | Requeue in-flight tasks when a worker dies |
|
|
255
|
+
| `maxWorkerRestarts` | `3` | Then `worker.restart_limit_exceeded` |
|
|
256
|
+
|
|
257
|
+
Management: `getWorkerIds()`, `getWorkerStatus(id)`, `getAllWorkerStatuses()`,
|
|
258
|
+
`restartWorker(id, newConfig?)`, `replaceWorker(...)` (alias),
|
|
259
|
+
`updateWorkerConfig(id, config)`, `shutdown()`.
|
|
260
|
+
|
|
261
|
+
`WorkerStatus` = `{ id, state, tasksCompleted, errorCount, uptime, currentTasks,
|
|
262
|
+
resourcesHeld, lastError? }`, where `state` is
|
|
263
|
+
`initializing | healthy | busy | failed | terminated`.
|
|
264
|
+
|
|
265
|
+
#### What `maxThreads` means for an inline pool
|
|
266
|
+
|
|
267
|
+
`maxThreads` counts worker *instances*, not threads. With `inline: false` each
|
|
268
|
+
instance is a real worker thread. With `inline: true` they are all objects in the
|
|
269
|
+
main process — **no threads are created at all** — and concurrency comes from
|
|
270
|
+
overlapping `await`s on the event loop.
|
|
271
|
+
|
|
272
|
+
So for inline pools only the **product** matters for throughput. Measured with
|
|
273
|
+
120 tasks each awaiting 40ms:
|
|
274
|
+
|
|
275
|
+
| Config | Instances | Peak in-flight | Duration |
|
|
276
|
+
|---|---:|---:|---:|
|
|
277
|
+
| `maxThreads: 1, maxConcurrentTasks: 5` | 1 | 5 | 1115 ms |
|
|
278
|
+
| `maxThreads: 4, maxConcurrentTasks: 5` | 4 | 20 | 295 ms |
|
|
279
|
+
| `maxThreads: 1, maxConcurrentTasks: 20` | 1 | 20 | 292 ms |
|
|
280
|
+
| `maxThreads: 4, maxConcurrentTasks: 20` | 4 | 80 | 128 ms |
|
|
281
|
+
|
|
282
|
+
`4 × 5` and `1 × 20` are interchangeable for speed. What the split *does* change
|
|
283
|
+
is **state isolation**: each instance is constructed separately, so
|
|
284
|
+
`maxThreads: 4` gives four connection pools / caches / whatever per-instance
|
|
285
|
+
state your worker holds, while `maxThreads: 1` funnels every concurrent task
|
|
286
|
+
through a single object.
|
|
287
|
+
|
|
288
|
+
Neither knob buys parallelism for CPU-bound work inline — it is one event loop.
|
|
289
|
+
Use `inline: false` for that.
|
|
290
|
+
|
|
291
|
+
### Resource groups
|
|
292
|
+
|
|
293
|
+
```typescript
|
|
294
|
+
new ConcurrentLimitGroup(limit: number, id?: string)
|
|
295
|
+
new RateLimitGroup(windows: { limit: number, windowMs: number }[], id?: string)
|
|
296
|
+
new KeyedRateLimitGroup(windows, { id?, keyFrom?, idleKeyTtlMs? })
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
Both expose `canRun()`, `getMetrics()`, `getStats()`. Tasks over a limit wait in
|
|
300
|
+
a blocked queue and are retried — never dropped. Multiple rate-limit windows are
|
|
301
|
+
enforced together.
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
305
|
+
#### Per-key rate limits
|
|
306
|
+
|
|
307
|
+
`KeyedRateLimitGroup` applies its windows **independently per key**, for when
|
|
308
|
+
each endpoint, tenant or account has its own quota:
|
|
309
|
+
|
|
310
|
+
```typescript
|
|
311
|
+
const api = new KeyedRateLimitGroup(
|
|
312
|
+
[{ limit: 10, windowMs: 1000 }], // 10/sec PER KEY, not in total
|
|
313
|
+
{ id: 'api', keyFrom: t => t.payload.endpoint }
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
const pool = new WorkerManager(url, { groups: ['api'], inline: true });
|
|
317
|
+
const scheduler = new FyflowScheduler({ ApiWorker: pool }, { api });
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
- `keyFrom` defaults to `t => t.limitKey`, so tasks can carry the key directly:
|
|
321
|
+
`new FyflowTask({ id, workerType, payload, limitKey: 'tenant-a' })`
|
|
322
|
+
- **A task with no derivable key throws at `addTask`**, rather than silently
|
|
323
|
+
sharing a bucket or skipping the limit
|
|
324
|
+
- Blocked tasks are queued per key, so a saturated key never delays another one
|
|
325
|
+
- Idle keys are evicted once they hold no running tasks and have been quiet
|
|
326
|
+
longer than `idleKeyTtlMs` (default: twice the largest window), so
|
|
327
|
+
high-cardinality keys do not grow without bound
|
|
328
|
+
- `getMetrics()` is the aggregate across keys plus `activeKeys`;
|
|
329
|
+
`getKeyMetrics(key)` gives one bucket, `getActiveKeys()` lists them
|
|
330
|
+
|
|
331
|
+
The limit is per key, not global: with 3 keys and a limit of 2, up to 6 tasks run
|
|
332
|
+
at once.
|
|
333
|
+
|
|
334
|
+
## 6. Events
|
|
335
|
+
|
|
336
|
+
Attach with `addEventListener(name, e => ...)`; payload is in `e.detail`.
|
|
337
|
+
|
|
338
|
+
**On the scheduler:**
|
|
339
|
+
|
|
340
|
+
| Event | `e.detail` |
|
|
341
|
+
|---|---|
|
|
342
|
+
| `task.running` | the `FyflowTask` |
|
|
343
|
+
| `task.completed` | the `FyflowTask` (with `result`, `executionTime`) |
|
|
344
|
+
| `task.failed` | the task, plus `error` |
|
|
345
|
+
| `task.progress` | `{ taskId, workerId, progress (0–1), message, details }` |
|
|
346
|
+
| `task.user_action` | the task — failed, out of retries, not optional. Fires once, only after the retry budget is spent |
|
|
347
|
+
| `task.spawn_request` | `{ parentTask, spawnConfig, workerId, workerType }` |
|
|
348
|
+
| `task.spawn_failed` | `{ parentTask, spawnConfig, error }` |
|
|
349
|
+
| `scheduler.completed` | the `stats` object |
|
|
350
|
+
|
|
351
|
+
**On a `WorkerManager`:** `task.started`, `task.completed`, `task.failed`,
|
|
352
|
+
`task.progress`, `task.spawn_request`, `task.requeue_required`, `worker.failed`,
|
|
353
|
+
`worker.self_terminated`, `worker.restart_limit_exceeded`.
|
|
354
|
+
|
|
355
|
+
**Worker lifecycle, on a `WorkerManager`:**
|
|
356
|
+
|
|
357
|
+
| Event | `e.detail` |
|
|
358
|
+
|---|---|
|
|
359
|
+
| `worker.initialization.started` | `{ workerId, workerType, timestamp }` |
|
|
360
|
+
| `worker.initialization.completed` | `{ workerId, workerType, timestamp, duration }` |
|
|
361
|
+
| `worker.initialization.failed` | `{ workerId, workerType, timestamp, error }` |
|
|
362
|
+
| `worker.setup.started` | `{ workerId, workerType, timestamp }` |
|
|
363
|
+
| `worker.setup.completed` | `{ workerId, workerType, timestamp, duration }` |
|
|
364
|
+
| `worker.teardown.started` | `{ workerId, workerType, timestamp }` |
|
|
365
|
+
| `worker.teardown.completed` | `{ workerId, workerType, timestamp, duration }` |
|
|
366
|
+
| `worker.teardown.failed` | `{ workerId, workerType, timestamp, error }` |
|
|
367
|
+
|
|
368
|
+
`workerType` is `'inline'` or `'thread'`. Inline and threaded pools emit the same
|
|
369
|
+
set with the same shape. These fire on every worker creation and every
|
|
370
|
+
idle-timeout teardown, so keep their listeners cheap.
|
|
371
|
+
|
|
372
|
+
> `scheduler.completed` fires **every time the scheduler drains**, so it can fire
|
|
373
|
+
> more than once when tasks arrive in waves. It does account for tasks blocked on
|
|
374
|
+
> a resource group, so it will not fire while work is still waiting for capacity.
|
|
375
|
+
|
|
376
|
+
---
|
|
377
|
+
|
|
378
|
+
## 7. Spawning and workflow completion
|
|
379
|
+
|
|
380
|
+
A worker can create tasks while running:
|
|
381
|
+
|
|
382
|
+
```typescript
|
|
383
|
+
async run(payload, context) {
|
|
384
|
+
context?.spawnTask({
|
|
385
|
+
id: `${payload.id}-child`,
|
|
386
|
+
workerType: 'MyWorker', // must be a registered pool
|
|
387
|
+
payload: { value: 1 },
|
|
388
|
+
workerGroups: ['cpu'] // optional
|
|
389
|
+
});
|
|
390
|
+
return { ok: true };
|
|
391
|
+
}
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
To wait for a task *and everything it spawned*, transitively:
|
|
395
|
+
|
|
396
|
+
```typescript
|
|
397
|
+
scheduler.addTask(task); // must be added FIRST
|
|
398
|
+
await task.onCompleteDescendants(); // rejects if called before addTask
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
- Resolves with the tracked task's own result once every descendant is terminal.
|
|
402
|
+
- A **descendant** failing does **not** reject — watch `task.failed` for those.
|
|
403
|
+
- The **tracked task** failing **does** reject, matching `onCompletePromise()`.
|
|
404
|
+
- Safe to call more than once, and after the workflow already finished.
|
|
405
|
+
- `shutdown()` drains outstanding work first, so a workflow that can still finish
|
|
406
|
+
does, and the wait resolves. It rejects only for work that can no longer run
|
|
407
|
+
|
|
408
|
+
This is lineage, not a dependency: spawning never changes dispatch order.
|
|
409
|
+
|
|
410
|
+
---
|
|
411
|
+
|
|
412
|
+
## 8. When workers fail
|
|
413
|
+
|
|
414
|
+
The scheduler never discards queued work to make a broken pool look healthy. What
|
|
415
|
+
happens to a task depends on *how* its worker failed, and the two outcomes are
|
|
416
|
+
quite different:
|
|
417
|
+
|
|
418
|
+
| Failure | Tasks | Promises |
|
|
419
|
+
|---|---|---|
|
|
420
|
+
| A worker **dies or cannot be constructed**, with `requeueFailedTasks` `true` (the default) | requeued, and wait | stay **pending** until a worker can run them |
|
|
421
|
+
| The same, with `requeueFailedTasks` `false` | fail | reject |
|
|
422
|
+
|
|
423
|
+
The first row is deliberate. A pool that cannot keep - or build - a worker is an
|
|
424
|
+
operational problem that needs the worker fixed, not a reason to destroy work
|
|
425
|
+
that will run correctly once it is. So those tasks queue, and
|
|
426
|
+
`await scheduler.addTask(task, { createPromise: true })` simply does not settle
|
|
427
|
+
until the pool recovers.
|
|
428
|
+
|
|
429
|
+
The pool retries up to `maxWorkerRestarts` times, then gives up and emits
|
|
430
|
+
`worker.restart_limit_exceeded`. Construction failures and runtime deaths follow
|
|
431
|
+
the same path, and inline and threaded pools behave identically.
|
|
432
|
+
|
|
433
|
+
**That means you must watch the pool, not just the task.** All of these are
|
|
434
|
+
emitted on the `WorkerManager`:
|
|
435
|
+
|
|
436
|
+
| Event | Meaning |
|
|
437
|
+
|---|---|
|
|
438
|
+
| `worker.initialization.failed` | a worker could not be constructed - its tasks are failing |
|
|
439
|
+
| `worker.failed` | a live worker died |
|
|
440
|
+
| `task.requeue_required` | a task went back to the queue because its worker died |
|
|
441
|
+
| `worker.restart_limit_exceeded` | the pool has stopped rebuilding workers - **intervention needed** |
|
|
442
|
+
|
|
443
|
+
```typescript
|
|
444
|
+
pool.addEventListener('worker.restart_limit_exceeded', (e) => {
|
|
445
|
+
// The pool has given up. Anything queued for it will wait until you fix it.
|
|
446
|
+
alert(`pool exhausted ${e.detail.maxRestarts} restarts, queued: ${scheduler.stats.queued}`);
|
|
447
|
+
});
|
|
448
|
+
pool.addEventListener('worker.initialization.failed', (e) => {
|
|
449
|
+
// Workers cannot even be built - usually a bad worker URL (§3)
|
|
450
|
+
console.error(e.detail.error);
|
|
451
|
+
});
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
> `worker.restart_limit_exceeded` is the one event that means *stop and fix
|
|
455
|
+
> something* - it fires for a pool that cannot build a worker as well as one whose
|
|
456
|
+
> workers keep dying. Everything above it is recoverable noise.
|
|
457
|
+
|
|
458
|
+
## 9. Common mistakes
|
|
459
|
+
|
|
460
|
+
| Symptom | Cause |
|
|
461
|
+
|---|---|
|
|
462
|
+
| `await scheduler.addTask(t)` resolves `undefined` | Missing `{ createPromise: true }` |
|
|
463
|
+
| Process never exits | `shutdown()` not called |
|
|
464
|
+
| `Unknown worker type: X` | `workerType` is not a key of `workerPools` |
|
|
465
|
+
| `Worker script <url> must export a default class` | Worker has no `export default class`, or the URL is wrong for the runtime (§3) |
|
|
466
|
+
| `Unknown file extension ".ts"` on Node | Node cannot import TypeScript - ship a compiled `.js` worker (§3) |
|
|
467
|
+
| `Task must be added to a scheduler before tracking descendants` | `onCompleteDescendants()` called before `addTask` |
|
|
468
|
+
| `terminateWithError` does nothing | Worker constructor did not forward `workerContext` to `super` |
|
|
469
|
+
| Progress bar shows 0–1 instead of 0–100 | `progress` is a fraction; multiply by 100 yourself |
|
|
470
|
+
| Compile error extending `BaseWorker` | `setup()` / `teardown()` not implemented — both are abstract |
|
|
471
|
+
| Task ends in `user_action`, not `failed` | Non-optional task, retries exhausted |
|
|
472
|
+
| Memory grows in a long-running process | Completed tasks are retained; set `maxCompletedTasks` |
|
|
473
|
+
| Limit briefly exceeded | Groups are optimistic by design (§1) |
|
|
474
|
+
| A resource group has no effect at all | The pool never declared it. Registering a group on the scheduler is not enough - add it to the pool's `groups` or the task's `workerGroups` |
|
|
475
|
+
| `Missing limit key for group "x"` | The task belongs to a `KeyedRateLimitGroup` but has no `limitKey`, and the group's `keyFrom` returned nothing |
|
|
476
|
+
|
|
477
|
+
---
|
|
478
|
+
|
|
479
|
+
## 10. Recipes
|
|
480
|
+
|
|
481
|
+
**Bulk work, waiting for all of it**
|
|
482
|
+
|
|
483
|
+
```typescript
|
|
484
|
+
const tasks = items.map((item, i) => new FyflowTask({
|
|
485
|
+
id: `job-${i}`, workerType: 'MyWorker', payload: item
|
|
486
|
+
}));
|
|
487
|
+
const results = await Promise.all(
|
|
488
|
+
scheduler.addTasks(tasks, { createPromise: true }) as Promise<any>[]
|
|
489
|
+
);
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
**Rate-limited API calls**
|
|
493
|
+
|
|
494
|
+
```typescript
|
|
495
|
+
const api = new RateLimitGroup([{ limit: 10, windowMs: 1000 }], 'api');
|
|
496
|
+
const scheduler = new FyflowScheduler(
|
|
497
|
+
{ ApiWorker: new WorkerManager(url, { maxConcurrentTasks: 8, inline: true, groups: ['api'] }) },
|
|
498
|
+
{ api }
|
|
499
|
+
);
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
**Retries and optional work**
|
|
503
|
+
|
|
504
|
+
```typescript
|
|
505
|
+
new FyflowTask({
|
|
506
|
+
id: 'flaky', workerType: 'MyWorker', payload: {},
|
|
507
|
+
retryPolicy: { maxRetries: 3, backoffMs: 250 },
|
|
508
|
+
optional: true // resolves null instead of rejecting
|
|
509
|
+
});
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
**A separate quota per endpoint or tenant**
|
|
513
|
+
|
|
514
|
+
```typescript
|
|
515
|
+
const api = new KeyedRateLimitGroup(
|
|
516
|
+
[{ limit: 10, windowMs: 1000 }],
|
|
517
|
+
{ id: 'api', keyFrom: t => t.payload.endpoint }
|
|
518
|
+
);
|
|
519
|
+
// Each endpoint gets its own 10/sec bucket, and a saturated one does not
|
|
520
|
+
// delay tasks belonging to the others
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
**Monitoring**
|
|
524
|
+
|
|
525
|
+
```typescript
|
|
526
|
+
setInterval(() => {
|
|
527
|
+
const m = scheduler.getResourceMetrics();
|
|
528
|
+
console.log(scheduler.stats, `cpu ${m.cpu.running}/${m.cpu.limit}`);
|
|
529
|
+
}, 1000);
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
**Measuring worker startup cost**
|
|
533
|
+
|
|
534
|
+
```typescript
|
|
535
|
+
pool.addEventListener('worker.setup.completed', (e) => {
|
|
536
|
+
if (e.detail.duration > 500) {
|
|
537
|
+
console.warn(`slow setup: ${e.detail.workerId} took ${e.detail.duration}ms`);
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
pool.addEventListener('worker.initialization.failed', (e) => {
|
|
541
|
+
console.error(`worker could not start: ${e.detail.error.message}`);
|
|
542
|
+
});
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
**Supervising workers**
|
|
546
|
+
|
|
547
|
+
```typescript
|
|
548
|
+
pool.addEventListener('worker.failed', async (e) => {
|
|
549
|
+
const status = pool.getWorkerStatus(e.detail.workerId);
|
|
550
|
+
if (status && status.errorCount > 5) {
|
|
551
|
+
await pool.restartWorker(e.detail.workerId, { resetState: true });
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
**Long-running scheduler**
|
|
557
|
+
|
|
558
|
+
```typescript
|
|
559
|
+
const scheduler = new FyflowScheduler(pools, groups, {
|
|
560
|
+
maxCompletedTasks: 10_000 // otherwise completed tasks are retained forever
|
|
561
|
+
});
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
---
|
|
565
|
+
|
|
566
|
+
## 11. Choosing worker settings
|
|
567
|
+
|
|
568
|
+
| Workload | Settings |
|
|
569
|
+
|---|---|
|
|
570
|
+
| Async / IO (HTTP, DB) | `inline: true`, and pick the product: `maxThreads × maxConcurrentTasks` = how many requests may be in flight |
|
|
571
|
+
| CPU-bound | `inline: false`, `maxConcurrentTasks: 1`, `maxThreads ≈ core count` |
|
|
572
|
+
| Mixed | Separate pools per workload; do not mix in one pool |
|
|
573
|
+
|
|
574
|
+
Inline workers share the main thread — a CPU-bound inline worker blocks the
|
|
575
|
+
scheduler itself. For inline pools, split the product toward `maxThreads` when
|
|
576
|
+
you want more isolated worker instances (separate connections, caches), and
|
|
577
|
+
toward `maxConcurrentTasks` when one shared instance is fine.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 FyFlow Team
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|