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.
Files changed (32) hide show
  1. package/AGENTS.md +577 -0
  2. package/LICENSE +21 -0
  3. package/README.md +415 -0
  4. package/dist/browser/index.js +2378 -0
  5. package/dist/node/index.js +2380 -0
  6. package/dist/types/core/FyflowScheduler.d.ts +279 -0
  7. package/dist/types/core/FyflowScheduler.d.ts.map +1 -0
  8. package/dist/types/core/inlineWrapper.d.ts +45 -0
  9. package/dist/types/core/inlineWrapper.d.ts.map +1 -0
  10. package/dist/types/core/threadWrapper.d.ts +59 -0
  11. package/dist/types/core/threadWrapper.d.ts.map +1 -0
  12. package/dist/types/core/workerInterface.d.ts +166 -0
  13. package/dist/types/core/workerInterface.d.ts.map +1 -0
  14. package/dist/types/core/workerManager.d.ts +141 -0
  15. package/dist/types/core/workerManager.d.ts.map +1 -0
  16. package/dist/types/core/workerWrapper.d.ts +2 -0
  17. package/dist/types/core/workerWrapper.d.ts.map +1 -0
  18. package/dist/types/core/workerWrapperUrl.bundled.d.ts +2 -0
  19. package/dist/types/core/workerWrapperUrl.bundled.d.ts.map +1 -0
  20. package/dist/types/core/workerWrapperUrl.d.ts +2 -0
  21. package/dist/types/core/workerWrapperUrl.d.ts.map +1 -0
  22. package/dist/types/groups/concurrentLimitGroup.d.ts +33 -0
  23. package/dist/types/groups/concurrentLimitGroup.d.ts.map +1 -0
  24. package/dist/types/groups/keyedRateLimitGroup.d.ts +98 -0
  25. package/dist/types/groups/keyedRateLimitGroup.d.ts.map +1 -0
  26. package/dist/types/groups/rateLimitGroup.d.ts +79 -0
  27. package/dist/types/groups/rateLimitGroup.d.ts.map +1 -0
  28. package/dist/types/groups/resourceGroup.d.ts +41 -0
  29. package/dist/types/groups/resourceGroup.d.ts.map +1 -0
  30. package/dist/types/index.d.ts +15 -0
  31. package/dist/types/index.d.ts.map +1 -0
  32. 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.