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/README.md ADDED
@@ -0,0 +1,415 @@
1
+ # FyFlow Scheduler
2
+
3
+ Zero-dependency parallel task scheduler with resource management and cross-platform support.
4
+
5
+ [![npm version](https://badge.fury.io/js/fyflow-scheduler.svg)](https://badge.fury.io/js/fyflow-scheduler)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+
8
+ > **Note**: This project was mostly "vibe coded" with [Claude Code](https://claude.ai/code) - it was an experiment, if such a complex library can be built with sufficient code quality.
9
+
10
+ > **Using this library from an AI agent?** [AGENTS.md](AGENTS.md) is a complete
11
+ > single-file reference: exact signatures, defaults, events, gotchas and recipes.
12
+ > Every snippet in it is executed by the test suite.
13
+
14
+ ## Overview
15
+
16
+ FyFlow is a self-contained parallel task scheduler with resource management. Fully in-memory operation with zero dependencies. Extensible APIs allow enhancement for persistence, distributed execution, or multi-machine coordination.
17
+
18
+ ## Installation
19
+
20
+ **Node.js and browser** - npm, which ships prebuilt bundles with the worker
21
+ bootstrap inlined, so no bundler configuration is needed:
22
+
23
+ ```bash
24
+ npm install fyflow-scheduler
25
+ # or
26
+ yarn add fyflow-scheduler
27
+ ```
28
+
29
+ **Deno** - JSR, which ships the TypeScript sources and loads the worker directly:
30
+
31
+ ```bash
32
+ deno add jsr:@fyflow/scheduler
33
+ ```
34
+
35
+ The two packages are not interchangeable. **The JSR package is Deno-only**: it
36
+ contains no Node-specific files, and importing it from Node or a browser throws
37
+ an error pointing you at npm. Node and browser support lives entirely in the npm
38
+ package.
39
+
40
+ ## Quick Start
41
+
42
+ ```typescript
43
+ import { FyflowScheduler, FyflowTask, WorkerManager, ConcurrentLimitGroup } from 'fyflow-scheduler';
44
+
45
+ // A pool must DECLARE the groups it uses - registering a group on the scheduler
46
+ // alone does not constrain anything
47
+ const workerPool = new WorkerManager('./worker.js', { maxThreads: 2, groups: ['cpu'] });
48
+
49
+ const scheduler = new FyflowScheduler(
50
+ { MyWorker: workerPool },
51
+ { cpu: new ConcurrentLimitGroup(4) }
52
+ );
53
+
54
+ const tasks = [
55
+ new FyflowTask({ id: 'task1', workerType: 'MyWorker', payload: { data: 'input' } }),
56
+ new FyflowTask({ id: 'task2', workerType: 'MyWorker', payload: { data: 'processed' } })
57
+ ];
58
+
59
+ // createPromise is required to get results back - addTask is fire-and-forget
60
+ const results = await Promise.all(scheduler.addTasks(tasks, { createPromise: true }));
61
+
62
+ await scheduler.shutdown(); // required, or live workers keep the process alive
63
+ ```
64
+
65
+ See [Loading Workers](#loading-workers-differs-per-runtime) for how `./worker.js`
66
+ is resolved - it differs between Deno, Node and the browser.
67
+
68
+ ## Key Features
69
+
70
+ - **Parallel Task Execution**: High-performance concurrent task processing
71
+ - **Resource Management**: Named concurrency and rate-limit groups applied per pool or per task
72
+ - **Dynamic Task Spawning**: Workers can create new tasks at runtime
73
+ - **Cross-Platform**: Node.js, Browser, and Deno support
74
+ - **Worker Pools**: Thread-based and inline execution modes
75
+ - **Progress Reporting**: Real-time task progress and event monitoring
76
+ - **Zero Dependencies**: Self-contained with no external requirements
77
+ - **In-Memory**: Fast coordination with extensible persistence APIs
78
+
79
+ ## Basic Usage
80
+
81
+ ### Creating Workers
82
+
83
+ A worker script must `export default` a class.
84
+
85
+ ```typescript
86
+ import { BaseWorker } from 'fyflow-scheduler';
87
+
88
+ export default class MyWorker extends BaseWorker {
89
+ // The pool calls `new MyWorker(config, workerContext)` - forward both
90
+ constructor(config = {}, workerContext) {
91
+ super(config, workerContext);
92
+ }
93
+
94
+ // setup and teardown are abstract on BaseWorker: both required, may be empty
95
+ async setup() {}
96
+ async teardown() {}
97
+
98
+ async run(payload, context) {
99
+ context?.sendProgress(0.5, "Processing..."); // progress is 0-1
100
+
101
+ // Spawn additional tasks if needed
102
+ context?.spawnTask({
103
+ id: 'child-task',
104
+ workerType: 'MyWorker',
105
+ payload: { data: 'child-data' }
106
+ });
107
+
108
+ return { result: 'completed', data: payload.data };
109
+ }
110
+ }
111
+ ```
112
+
113
+ ### Loading Workers
114
+
115
+ Point the pool at your worker file's URL.
116
+
117
+ **npm (Node and browser)** - ship a compiled `.js` worker:
118
+
119
+ ```javascript
120
+ const workerUrl = new URL('./myWorker.js', import.meta.url).href;
121
+ const pool = new WorkerManager(workerUrl, { maxThreads: 4 });
122
+ ```
123
+
124
+ No bundler plugin and no build step for the worker itself. Node cannot import
125
+ TypeScript, so a `.ts` worker fails with `Unknown file extension`; compile it as
126
+ part of your own build. In the browser, your bundler needs to emit the worker as
127
+ a separate asset, which most do automatically for
128
+ `new URL('./myWorker.js', import.meta.url)`.
129
+
130
+ **Deno (JSR)** - point at the TypeScript source, which Deno loads directly:
131
+
132
+ ```typescript
133
+ const workerUrl = new URL('./myWorker.ts', import.meta.url).href;
134
+ ```
135
+
136
+ > The `?worker-direct` suffix in this repository's examples and tests is a
137
+ > convention of its own esbuild config, used so the `.ts` examples run on both
138
+ > runtimes. It is not part of the published API and will not resolve in your
139
+ > code.
140
+
141
+ ### Getting Results Back
142
+
143
+ `addTask` and `addTasks` are fire-and-forget by default and return nothing. Ask
144
+ for a promise explicitly:
145
+
146
+ ```typescript
147
+ const result = await scheduler.addTask(task, { createPromise: true });
148
+
149
+ const results = await Promise.all(
150
+ scheduler.addTasks(tasks, { createPromise: true })
151
+ );
152
+
153
+ await scheduler.shutdown(); // required, or workers keep the process alive
154
+ ```
155
+
156
+ ### Parallel Task Execution
157
+
158
+ ```typescript
159
+ const tasks = [
160
+ new FyflowTask({ id: 'fetch', workerType: 'DataWorker', payload: { url: 'api/data' } }),
161
+ new FyflowTask({ id: 'validate', workerType: 'ValidationWorker', payload: { url: 'api/data2' } }),
162
+ new FyflowTask({ id: 'process', workerType: 'ProcessWorker', payload: { batch: 1 } }),
163
+ new FyflowTask({ id: 'save', workerType: 'SaveWorker', payload: { batch: 2 } })
164
+ ];
165
+
166
+ // All tasks execute in parallel (subject to resource constraints)
167
+ tasks.forEach(task => scheduler.addTask(task));
168
+ ```
169
+
170
+ ### Resource Groups
171
+
172
+ ```typescript
173
+ const cpuGroup = new ConcurrentLimitGroup(8); // Max 8 concurrent CPU tasks
174
+ const gpuGroup = new ConcurrentLimitGroup(2); // Max 2 concurrent GPU tasks
175
+
176
+ const scheduler = new FyflowScheduler(workerPools, {
177
+ cpu: cpuGroup,
178
+ gpu: gpuGroup
179
+ });
180
+
181
+ // Workers automatically use groups specified in WorkerManager
182
+ const gpuWorkerPool = new WorkerManager('./gpu-worker.js', {
183
+ maxThreads: 2,
184
+ groups: ['gpu'] // This pool uses GPU constraints
185
+ });
186
+ ```
187
+
188
+ ### Event Monitoring
189
+
190
+ ```typescript
191
+ scheduler.addEventListener('task.completed', (e) => {
192
+ console.log(`Task ${e.detail.id} completed:`, e.detail.result);
193
+ });
194
+
195
+ scheduler.addEventListener('task.progress', (e) => {
196
+ // progress is 0-1
197
+ console.log(`Task ${e.detail.taskId}: ${(e.detail.progress * 100).toFixed(0)}%`);
198
+ });
199
+
200
+ scheduler.addEventListener('scheduler.completed', (e) => {
201
+ console.log(`All tasks completed. Stats:`, e.detail);
202
+ });
203
+ ```
204
+
205
+ ## API Reference
206
+
207
+ ### Core Classes
208
+
209
+ **FyflowScheduler(workerPools, resourceGroups, options?)**
210
+ - `options.maxCompletedTasks`: cap how many terminal tasks stay in
211
+ `scheduler.tasks` (default: unlimited). Set it for long-lived schedulers -
212
+ completed tasks otherwise pin their payloads and results forever
213
+ - `options.periodicRetryIntervalMs`: retry interval for blocked tasks (default 50ms)
214
+ - `addTask(task, options?)`: Add a task. Fire-and-forget by default; pass
215
+ `{ createPromise: true }` to get a promise back
216
+ - `addTasks(tasks, options?)`: Batch version, optimised for bulk additions.
217
+ Throws on an unknown `workerType`, validating the batch before queueing any of it
218
+ - `stats`: Current execution statistics
219
+ - `getResourceMetrics()` / `getResourceStats()`: Per-group utilisation and lifetime counters
220
+ - `shutdown()`: Terminate worker pools and release all listeners
221
+ - `addEventListener(event, handler)`: Event monitoring
222
+
223
+ **FyflowTask(config)**
224
+ - `id`: Unique task identifier
225
+ - `workerType`: Worker pool to use
226
+ - `payload`: Data passed to worker
227
+ - `workerGroups`: Optional resource group names
228
+ - `optional`: Mark task as optional (failures don't block workflow)
229
+ - `limitKey`: Bucket for keyed resource groups (see `KeyedRateLimitGroup`)
230
+ - `retryPolicy`: `{ maxRetries, backoffMs }`
231
+ - `onCompletePromise()`: Promise for this task alone
232
+ - `onCompleteDescendants()`: Promise for this task plus everything it spawns
233
+
234
+ **WorkerManager(scriptUrl, options)**
235
+ - `maxThreads`: Maximum worker **instances** (not OS threads). With
236
+ `inline: false` each is a real worker thread; with `inline: true` they are
237
+ objects in the main process, so only `maxThreads × maxConcurrentTasks` matters
238
+ for throughput — the split controls how much per-instance state (connections,
239
+ caches) is duplicated
240
+ - `maxConcurrentTasks`: Tasks per instance
241
+ - `groups`: Resource group names
242
+ - `inline`: Use inline execution (default: false)
243
+ - `idleTimeout`: Ms before an idle worker is terminated (0 = never, default 5000)
244
+
245
+ **ConcurrentLimitGroup(limit, id?)**
246
+ - Resource constraint with a concurrent execution limit. Optimistic: may briefly
247
+ exceed the limit by up to `maxThreads × maxConcurrentTasks` under contention
248
+
249
+ **RateLimitGroup(windows, id?)**
250
+ - Time-window throttling, e.g. `[{ limit: 10, windowMs: 1000 }]`. Multiple
251
+ overlapping windows are enforced together
252
+
253
+ **KeyedRateLimitGroup(windows, options?)**
254
+ - The same windows applied **independently per key**, for when each endpoint,
255
+ tenant or account has its own quota
256
+ - `options.keyFrom` derives the key from a task, defaulting to `task.limitKey`
257
+ - A task with no derivable key throws at `addTask` rather than silently sharing
258
+ a bucket
259
+ - Blocked tasks queue per key, so a saturated key never delays another
260
+ - Idle keys are evicted after `options.idleKeyTtlMs` (default: twice the largest
261
+ window), so high-cardinality keys do not grow without bound
262
+ - `getKeyMetrics(key)` and `getActiveKeys()` give the per-bucket view
263
+
264
+ **Worker management** (on a `WorkerManager`)
265
+ - `getWorkerIds()`: ids of the workers that currently exist (created lazily)
266
+ - `getWorkerStatus(id)` / `getAllWorkerStatuses()`: `{ id, state, tasksCompleted,
267
+ errorCount, uptime, currentTasks, resourcesHeld, lastError? }`
268
+ - `restartWorker(id, newConfig?)` / `replaceWorker(...)`: terminate and rebuild a
269
+ worker, optionally with new config. The replacement gets a new id
270
+ - `updateWorkerConfig(id, config)`: merge config into a live worker
271
+ - `shutdown()`: terminate the pool
272
+
273
+ **Worker self-termination** — a worker can ask to be torn down, e.g. after
274
+ detecting a bad connection. In-flight tasks are requeued when the pool's
275
+ `requeueFailedTasks` allows it:
276
+
277
+ ```typescript
278
+ this.workerContext?.terminateWithError(new Error('connection lost'), { canRestart: true });
279
+ ```
280
+
281
+ ### When Workers Fail
282
+
283
+ The scheduler never discards queued work to make a broken pool look healthy:
284
+
285
+ - A worker that **dies or cannot be constructed**, with `requeueFailedTasks: true`
286
+ (the default), has its tasks requeued. The pool retries up to
287
+ `maxWorkerRestarts` times, then gives up. If it never manages to run a worker
288
+ those tasks wait, and their promises stay pending - a pool that cannot keep or
289
+ build a worker needs fixing, not its work thrown away.
290
+ - With `requeueFailedTasks: false` the tasks fail and their promises reject.
291
+
292
+ So watch the pool, not only the task. `worker.restart_limit_exceeded` means the
293
+ pool has given up rebuilding workers and needs intervention - it covers both a
294
+ worker that cannot be built and one that keeps dying.
295
+
296
+ ### Events
297
+
298
+ On the **scheduler**: `task.running`, `task.completed`, `task.failed`,
299
+ `task.progress`, `task.user_action`, `task.spawn_request`, `task.spawn_failed`,
300
+ `scheduler.completed`.
301
+
302
+ On a **WorkerManager**: `task.started`, `task.completed`, `task.failed`,
303
+ `task.progress`, `task.spawn_request`, `task.requeue_required`, `worker.failed`,
304
+ `worker.self_terminated`, `worker.restart_limit_exceeded`, plus the worker
305
+ lifecycle events `worker.initialization.started|completed|failed`,
306
+ `worker.setup.started|completed` and `worker.teardown.started|completed|failed`.
307
+
308
+ Lifecycle events carry `{ workerId, workerType, timestamp }`, plus `duration` on
309
+ `*.completed` and `error` on `*.failed`. Inline and threaded pools emit the same
310
+ set. They fire on every worker creation and idle-timeout teardown, so keep those
311
+ listeners cheap.
312
+
313
+ `scheduler.completed` fires whenever the scheduler drains, so it can fire more
314
+ than once if tasks arrive in waves.
315
+
316
+ ### Worker Interface
317
+
318
+ Workers extend `BaseWorker` and implement:
319
+ - `constructor(config)`: Setup worker configuration
320
+ - `async run(payload, context)`: Execute task logic
321
+ - `async setup()`: Initialize worker (required by `BaseWorker`; may be empty)
322
+ - `async teardown()`: Cleanup worker (required by `BaseWorker`; may be empty)
323
+
324
+ ## Performance
325
+
326
+ Measured with `npm run benchmark:quick`:
327
+
328
+ - **Throughput (inline)**: tens of thousands of tasks/sec at 10K tasks, falling
329
+ at larger volumes as coordination grows relative to per-task work
330
+ - **Efficiency (threaded, CPU work)**: 96-98%. This is achieved average
331
+ concurrency over configured concurrency - "how much of the parallelism I asked
332
+ for did I get" - so steady-state coordination overhead is a few percent
333
+ - **Memory**: roughly 1MB per 1,000 in-flight tasks at 10K scale. Completed tasks
334
+ are retained by default; see `maxCompletedTasks`
335
+
336
+ **Worker startup** is measured separately (`npm run benchmark:startup`), because
337
+ every other scenario pre-warms its pool. It is the largest difference between the
338
+ two worker types, and between runtimes:
339
+
340
+ | | Deno | Node 22 |
341
+ |---|---:|---:|
342
+ | 1 worker thread | ~12 ms | ~54 ms |
343
+ | 8 inline instances (each) | ~85 µs | ~324 µs |
344
+
345
+ Threads cost roughly three orders of magnitude more to start than inline
346
+ instances, so a short-lived threaded pool can spend most of its life starting up.
347
+
348
+ Numbers vary substantially between runs and machines, and a scenario's result
349
+ depends on what ran before it in the suite. Re-run the suite rather than relying
350
+ on these figures, and compare full-suite runs only against other full-suite runs.
351
+
352
+ ## Platform Support
353
+
354
+ | Platform | Package | Worker Threads | Inline Workers | Build Required |
355
+ |----------|---------|---------------|----------------|----------------|
356
+ | Node.js | npm `fyflow-scheduler` | ✅ | ✅ | Prebuilt in the package |
357
+ | Browser | npm `fyflow-scheduler` | ✅ | ✅ | Prebuilt in the package |
358
+ | Deno | JSR `@fyflow/scheduler` | ✅ | ✅ | None - sources are loaded directly |
359
+
360
+ Only four files differ between runtimes, and the split is resolved at build time
361
+ rather than at runtime: the worker bootstrap (`workerWrapper.ts` vs
362
+ `workerWrapper.node.ts`), the URL that locates it, and two feature checks in
363
+ `ThreadWrapper` for Node's `worker_threads` event emitter. Everything else -
364
+ the scheduler, worker manager and all resource groups - is platform-agnostic.
365
+
366
+ Node.js 22 or newer is required: the library and its tests rely on `CustomEvent`
367
+ being a global, which landed in Node 19.
368
+
369
+ ## Development
370
+
371
+ ```bash
372
+ # Build library
373
+ npm run build
374
+
375
+ # Run tests - core, error handling, spawning and the documentation examples
376
+ npm test # Node.js tests (requires Node >= 22)
377
+ npm run test:browser # Browser tests (Playwright)
378
+ npm run test:deno # Deno tests
379
+ npm run test:docs # Just the executable documentation examples
380
+ npm run test:performance # Contention scaling (not part of the default run)
381
+
382
+ # Type check library, tests, examples and benchmarks
383
+ deno task check
384
+
385
+ # Benchmarks
386
+ npm run benchmark # Full benchmark suite
387
+ npm run benchmark:quick # Quick performance test
388
+ npm run benchmark:startup # Worker startup cost, for comparing runtimes
389
+ npm run benchmark:baseline # The set behind benchmark-baseline-comprehensive.md
390
+ ```
391
+
392
+ ## Examples
393
+
394
+ See the `examples/` directory for comprehensive usage examples:
395
+ - `getting-started.ts`: Basic parallel task execution
396
+ - `enhanced-features.ts`: Progress reporting, dynamic task spawning and `onCompleteDescendants()`
397
+ - `worker-types.ts`: Thread vs inline worker comparison
398
+ - `performance-groups.ts`: Resource management, rate limits and constraints
399
+ - `monitor-resource-health.ts`: Drop-in resource health monitor helper
400
+ - `deno-only/minimal.ts`: The plain Deno form - a worker URL with no build step
401
+ and none of this repo's bundler convention
402
+
403
+ The examples above the `deno-only/` folder run on both Deno and Node, which is
404
+ why they carry the `?worker-direct` branch. Run them all with
405
+ `npm run build:dev && deno task examples`.
406
+
407
+ ## License
408
+
409
+ MIT License.
410
+
411
+ ## Links
412
+
413
+ - [GitHub Repository](https://github.com/fyflow/fyflow-scheduler)
414
+ - [NPM Package](https://www.npmjs.com/package/fyflow-scheduler)
415
+ - [Issue Tracker](https://github.com/fyflow/fyflow-scheduler/issues)