@supergrowthai/tq 1.0.11 → 1.0.13
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 +338 -20
- package/dist/AsyncActions-BOO1ikWz.cjs +241 -0
- package/dist/AsyncActions-BOO1ikWz.cjs.map +1 -0
- package/dist/AsyncActions-CZYO8ShR.js +242 -0
- package/dist/AsyncActions-CZYO8ShR.js.map +1 -0
- package/dist/{PrismaAdapter-CvM_XNtE.cjs → PrismaAdapter-CUIWhjms.cjs} +57 -81
- package/dist/PrismaAdapter-CUIWhjms.cjs.map +1 -0
- package/dist/{PrismaAdapter-Dy7MV090.js → PrismaAdapter-D5ACKPbS.js} +57 -81
- package/dist/PrismaAdapter-D5ACKPbS.js.map +1 -0
- package/dist/adapters/index.cjs +1 -1
- package/dist/adapters/index.mjs +1 -1
- package/dist/client-BxG7LzLv.cjs +90 -0
- package/dist/client-BxG7LzLv.cjs.map +1 -0
- package/dist/client-dvHNt8qU.js +91 -0
- package/dist/client-dvHNt8qU.js.map +1 -0
- package/dist/core/Actions.cjs +184 -16
- package/dist/core/Actions.cjs.map +1 -1
- package/dist/core/Actions.mjs +184 -16
- package/dist/core/Actions.mjs.map +1 -1
- package/dist/core/async/AsyncActions.cjs +4 -98
- package/dist/core/async/AsyncActions.cjs.map +1 -1
- package/dist/core/async/AsyncActions.mjs +4 -98
- package/dist/core/async/AsyncActions.mjs.map +1 -1
- package/dist/core/async/AsyncTaskManager.cjs +133 -22
- package/dist/core/async/AsyncTaskManager.cjs.map +1 -1
- package/dist/core/async/AsyncTaskManager.mjs +133 -22
- package/dist/core/async/AsyncTaskManager.mjs.map +1 -1
- package/dist/index.cjs +517 -35
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +517 -35
- package/dist/index.mjs.map +1 -1
- package/dist/src/adapters/ITaskStorageAdapter.d.cts +0 -5
- package/dist/src/adapters/ITaskStorageAdapter.d.ts +0 -5
- package/dist/src/adapters/InMemoryAdapter.d.cts +0 -2
- package/dist/src/adapters/InMemoryAdapter.d.ts +0 -2
- package/dist/src/adapters/MongoDbAdapter.d.cts +0 -2
- package/dist/src/adapters/MongoDbAdapter.d.ts +0 -2
- package/dist/src/adapters/PrismaAdapter.d.cts +0 -2
- package/dist/src/adapters/PrismaAdapter.d.ts +0 -2
- package/dist/src/adapters/types.d.cts +7 -0
- package/dist/src/adapters/types.d.ts +7 -0
- package/dist/src/core/Actions.d.cts +25 -2
- package/dist/src/core/Actions.d.ts +25 -2
- package/dist/src/core/TaskHandler.d.cts +13 -5
- package/dist/src/core/TaskHandler.d.ts +13 -5
- package/dist/src/core/TaskRunner.d.cts +16 -1
- package/dist/src/core/TaskRunner.d.ts +16 -1
- package/dist/src/core/async/AsyncActions.d.cts +20 -1
- package/dist/src/core/async/AsyncActions.d.ts +20 -1
- package/dist/src/core/async/AsyncTaskManager.d.cts +36 -4
- package/dist/src/core/async/AsyncTaskManager.d.ts +36 -4
- package/dist/src/core/async/async-task-manager.d.cts +21 -3
- package/dist/src/core/async/async-task-manager.d.ts +21 -3
- package/dist/src/core/async/retry-utils.d.cts +15 -0
- package/dist/src/core/async/retry-utils.d.ts +15 -0
- package/dist/src/core/base/interfaces.d.cts +10 -2
- package/dist/src/core/base/interfaces.d.ts +10 -2
- package/dist/src/core/entity/IEntityProjectionProvider.d.cts +45 -0
- package/dist/src/core/entity/IEntityProjectionProvider.d.ts +45 -0
- package/dist/src/core/entity/index.d.cts +1 -0
- package/dist/src/core/entity/index.d.ts +1 -0
- package/dist/src/core/flow/FlowMiddleware.d.cts +26 -0
- package/dist/src/core/flow/FlowMiddleware.d.ts +26 -0
- package/dist/src/core/flow/IFlowBarrierProvider.d.cts +46 -0
- package/dist/src/core/flow/IFlowBarrierProvider.d.ts +46 -0
- package/dist/src/core/flow/InMemoryFlowBarrierProvider.d.cts +10 -0
- package/dist/src/core/flow/InMemoryFlowBarrierProvider.d.ts +10 -0
- package/dist/src/core/flow/index.d.cts +4 -0
- package/dist/src/core/flow/index.d.ts +4 -0
- package/dist/src/core/flow/types.d.cts +82 -0
- package/dist/src/core/flow/types.d.ts +82 -0
- package/dist/src/core/lifecycle.d.cts +9 -4
- package/dist/src/core/lifecycle.d.ts +9 -4
- package/dist/src/core/log-context.d.cts +10 -0
- package/dist/src/core/log-context.d.ts +10 -0
- package/dist/src/index.d.cts +4 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/test/adapter-consistency.test.d.cts +11 -0
- package/dist/src/test/adapter-consistency.test.d.ts +11 -0
- package/dist/src/test/immediate-mode-bugs.test.d.cts +11 -0
- package/dist/src/test/immediate-mode-bugs.test.d.ts +11 -0
- package/dist/src/test/rfc-001-result-persistence.test.d.cts +17 -0
- package/dist/src/test/rfc-001-result-persistence.test.d.ts +17 -0
- package/dist/src/test/rfc-002-flow-orchestration.test.d.cts +24 -0
- package/dist/src/test/rfc-002-flow-orchestration.test.d.ts +24 -0
- package/dist/src/test/rfc-003-entity-projection.test.d.cts +14 -0
- package/dist/src/test/rfc-003-entity-projection.test.d.ts +14 -0
- package/dist/src/test/rfc-004-async-hardening.test.d.cts +14 -0
- package/dist/src/test/rfc-004-async-hardening.test.d.ts +14 -0
- package/dist/src/test/rfc-005-log-context.test.d.cts +14 -0
- package/dist/src/test/rfc-005-log-context.test.d.ts +14 -0
- package/dist/src/test/tq-fixes.test.d.cts +17 -0
- package/dist/src/test/tq-fixes.test.d.ts +17 -0
- package/package.json +2 -2
- package/dist/PrismaAdapter-CvM_XNtE.cjs.map +0 -1
- package/dist/PrismaAdapter-Dy7MV090.js.map +0 -1
- package/dist/client-BAiCkZv7.js +0 -52
- package/dist/client-BAiCkZv7.js.map +0 -1
- package/dist/client-DgdG7pT6.cjs +0 -51
- package/dist/client-DgdG7pT6.cjs.map +0 -1
package/README.md
CHANGED
|
@@ -12,6 +12,8 @@ Built on top of `@supergrowthai/mq` for flexible message queue backends.
|
|
|
12
12
|
- **Queue Integration**: Works with any message queue backend via `@supergrowthai/mq`
|
|
13
13
|
- **Named Exports**: Tree-shakable, explicit imports
|
|
14
14
|
- **Fail-Fast Design**: Required dependencies enforce proper configuration
|
|
15
|
+
- **Entity Projection**: Automatic entity-task status tracking for dashboards and orchestration
|
|
16
|
+
- **Flow Orchestration**: Built-in fan-out/fan-in with barrier tracking, failure policies, and timeouts
|
|
15
17
|
|
|
16
18
|
## Installation
|
|
17
19
|
|
|
@@ -51,10 +53,10 @@ taskQueue.register('email-queue', 'send-email', {
|
|
|
51
53
|
async onTask(task, actions) {
|
|
52
54
|
try {
|
|
53
55
|
await sendEmail(task.payload.to, task.payload.subject);
|
|
54
|
-
actions.success(task);
|
|
56
|
+
actions.success(task, { messageId: 'abc-123' });
|
|
55
57
|
} catch (error) {
|
|
56
58
|
console.error('Failed to send email:', error);
|
|
57
|
-
actions.fail(task);
|
|
59
|
+
actions.fail(task, error instanceof Error ? error : String(error));
|
|
58
60
|
}
|
|
59
61
|
}
|
|
60
62
|
});
|
|
@@ -164,7 +166,7 @@ const emailExecutor: ISingleTaskNonParallel<EmailData> = {
|
|
|
164
166
|
actions.success(task);
|
|
165
167
|
} catch (error) {
|
|
166
168
|
console.error('Email sending failed:', error);
|
|
167
|
-
actions.fail(task);
|
|
169
|
+
actions.fail(task, error instanceof Error ? error : String(error));
|
|
168
170
|
}
|
|
169
171
|
}
|
|
170
172
|
};
|
|
@@ -189,10 +191,10 @@ const imageProcessorExecutor: ISingleTaskParallel<ImageData> = {
|
|
|
189
191
|
async onTask(task, actions) {
|
|
190
192
|
try {
|
|
191
193
|
await processImage(task.payload.imageUrl, task.payload.filters);
|
|
192
|
-
actions.success(task);
|
|
194
|
+
actions.success(task, { processedUrl: task.payload.imageUrl });
|
|
193
195
|
} catch (error) {
|
|
194
196
|
console.error('Image processing failed:', error);
|
|
195
|
-
actions.fail(task);
|
|
197
|
+
actions.fail(task, error instanceof Error ? error : String(error));
|
|
196
198
|
}
|
|
197
199
|
}
|
|
198
200
|
};
|
|
@@ -221,7 +223,7 @@ const batchProcessorExecutor: IMultiTaskExecutor<BatchData> = {
|
|
|
221
223
|
actions.success(task);
|
|
222
224
|
} catch (error) {
|
|
223
225
|
console.error('Batch item failed:', error);
|
|
224
|
-
actions.fail(task);
|
|
226
|
+
actions.fail(task, error instanceof Error ? error : String(error));
|
|
225
227
|
|
|
226
228
|
// Optionally add retry tasks
|
|
227
229
|
if ((task.retries || 0) < 3) {
|
|
@@ -245,13 +247,30 @@ For long-running tasks that might exceed normal timeouts:
|
|
|
245
247
|
|
|
246
248
|
```typescript
|
|
247
249
|
import {AsyncTaskManager} from '@supergrowthai/tq';
|
|
250
|
+
import type {AsyncTaskManagerOptions} from '@supergrowthai/tq';
|
|
251
|
+
|
|
252
|
+
// Simple usage (backward-compatible)
|
|
253
|
+
const asyncTaskManager = new AsyncTaskManager(10); // maxTasks shorthand
|
|
254
|
+
|
|
255
|
+
// Full options
|
|
256
|
+
const asyncTaskManager = new AsyncTaskManager({
|
|
257
|
+
maxTasks: 10,
|
|
258
|
+
sweepIntervalMs: 5000, // How often to check for hung tasks (default: 5s)
|
|
259
|
+
defaultMaxDurationMs: 600000, // Max task duration before eviction (default: 10 min)
|
|
260
|
+
shutdownGracePeriodMs: 15000, // Grace period on shutdown (default: 10s)
|
|
261
|
+
onTaskTimeout: (taskId, task, durationMs) => {
|
|
262
|
+
console.error(`Task ${taskId} (${task.type}) timed out after ${durationMs}ms`);
|
|
263
|
+
}
|
|
264
|
+
});
|
|
248
265
|
|
|
249
|
-
//
|
|
250
|
-
const
|
|
266
|
+
// Observability
|
|
267
|
+
const metrics = asyncTaskManager.getMetrics();
|
|
268
|
+
// { activeTaskCount, totalHandedOff, totalCompleted, totalRejected,
|
|
269
|
+
// totalTimedOut, oldestTaskMs, maxTasks, utilizationPercent }
|
|
251
270
|
|
|
252
|
-
// Graceful shutdown
|
|
253
|
-
const
|
|
254
|
-
|
|
271
|
+
// Graceful shutdown — returns ShutdownResult
|
|
272
|
+
const result = await asyncTaskManager.shutdown(abortController.signal);
|
|
273
|
+
console.log(`Completed: ${result.completedDuringGrace}, Abandoned: ${result.abandonedTaskIds}`);
|
|
255
274
|
|
|
256
275
|
const heavyProcessingExecutor: ISingleTaskNonParallel<ProcessingData> = {
|
|
257
276
|
multiple: false,
|
|
@@ -268,9 +287,9 @@ const heavyProcessingExecutor: ISingleTaskNonParallel<ProcessingData> = {
|
|
|
268
287
|
try {
|
|
269
288
|
// This might take a very long time
|
|
270
289
|
const result = await performHeavyComputation(task.payload);
|
|
271
|
-
actions.success(task);
|
|
290
|
+
actions.success(task, result);
|
|
272
291
|
} catch (error) {
|
|
273
|
-
actions.fail(task);
|
|
292
|
+
actions.fail(task, error instanceof Error ? error : String(error));
|
|
274
293
|
}
|
|
275
294
|
}
|
|
276
295
|
};
|
|
@@ -285,6 +304,12 @@ const taskHandler = new TaskHandler(
|
|
|
285
304
|
);
|
|
286
305
|
```
|
|
287
306
|
|
|
307
|
+
Key features:
|
|
308
|
+
- **Stale task sweep**: Periodically evicts tasks exceeding `defaultMaxDurationMs`
|
|
309
|
+
- **Duplicate rejection**: Rejects handoff if the same task ID is already tracked
|
|
310
|
+
- **Shutdown-aware gate**: Stops accepting new tasks once `shutdown()` is called
|
|
311
|
+
- **Observability**: `getMetrics()` exposes utilization, counters, and oldest task age
|
|
312
|
+
|
|
288
313
|
## Lifecycle Callbacks
|
|
289
314
|
|
|
290
315
|
Monitor task and worker lifecycle events for health checks, metrics collection, and observability:
|
|
@@ -423,6 +448,258 @@ const taskHandler = new TaskHandler(
|
|
|
423
448
|
| started_at | Date | When worker started |
|
|
424
449
|
| enabled_queues | string[] | Queues being processed |
|
|
425
450
|
|
|
451
|
+
## Entity Task Projection
|
|
452
|
+
|
|
453
|
+
Track task lifecycle at the entity level without querying internal task tables. Useful for dashboards, customer-facing status pages, and flow orchestration.
|
|
454
|
+
|
|
455
|
+
### How It Works
|
|
456
|
+
|
|
457
|
+
Tasks can carry an `entity` binding — an external domain object (e.g., a user, order, or campaign) that the task operates on. When configured, tq automatically projects status transitions to your provider:
|
|
458
|
+
|
|
459
|
+
```
|
|
460
|
+
scheduled → processing → executed
|
|
461
|
+
→ failed
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
Projections are **non-fatal** — provider errors are logged but never disrupt task processing.
|
|
465
|
+
|
|
466
|
+
### Binding an Entity to a Task
|
|
467
|
+
|
|
468
|
+
```typescript
|
|
469
|
+
await taskHandler.addTasks([{
|
|
470
|
+
type: 'generate-report',
|
|
471
|
+
queue_id: 'reports',
|
|
472
|
+
execute_at: new Date(),
|
|
473
|
+
payload: { reportType: 'monthly', userId: 'u_123' },
|
|
474
|
+
|
|
475
|
+
// Entity binding — ties this task to a domain object
|
|
476
|
+
entity: { id: 'u_123', type: 'user' },
|
|
477
|
+
|
|
478
|
+
// Entity tasks MUST have a persistent ID for projection keying.
|
|
479
|
+
// Use store_on_failure: true on the executor, or force_store: true here.
|
|
480
|
+
}]);
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
**Fail-fast guarantee**: If a task has `entity` but no `id`, `buildProjection()` throws at runtime with an actionable error message pointing to `store_on_failure`, `force_store`, or manual ID assignment. This catches misconfiguration during development, not in production at 3 AM.
|
|
484
|
+
|
|
485
|
+
### Implementing a Projection Provider
|
|
486
|
+
|
|
487
|
+
```typescript
|
|
488
|
+
import type {IEntityProjectionProvider, EntityTaskProjection} from '@supergrowthai/tq';
|
|
489
|
+
|
|
490
|
+
class PostgresProjectionProvider implements IEntityProjectionProvider<string> {
|
|
491
|
+
async upsertProjections(entries: EntityTaskProjection<string>[]): Promise<void> {
|
|
492
|
+
// Batch upsert to your projection table
|
|
493
|
+
await db.query(`
|
|
494
|
+
INSERT INTO entity_task_projections
|
|
495
|
+
(task_id, entity_id, entity_type, task_type, queue_id, status,
|
|
496
|
+
payload, error, result, created_at, updated_at)
|
|
497
|
+
VALUES ${entries.map((_, i) => `($${i * 11 + 1}, ...)`).join(', ')}
|
|
498
|
+
ON CONFLICT (task_id) DO UPDATE SET
|
|
499
|
+
status = EXCLUDED.status,
|
|
500
|
+
error = EXCLUDED.error,
|
|
501
|
+
result = EXCLUDED.result,
|
|
502
|
+
updated_at = EXCLUDED.updated_at
|
|
503
|
+
`, entries.flatMap(e => [
|
|
504
|
+
e.task_id, e.entity_id, e.entity_type, e.task_type, e.queue_id,
|
|
505
|
+
e.status, e.payload, e.error, e.result, e.created_at, e.updated_at
|
|
506
|
+
]));
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
### Wiring It Up
|
|
512
|
+
|
|
513
|
+
```typescript
|
|
514
|
+
const taskHandler = new TaskHandler(
|
|
515
|
+
messageQueue,
|
|
516
|
+
taskQueue,
|
|
517
|
+
databaseAdapter,
|
|
518
|
+
cacheAdapter,
|
|
519
|
+
asyncTaskManager,
|
|
520
|
+
notificationProvider,
|
|
521
|
+
{
|
|
522
|
+
lifecycleProvider: myLifecycleProvider,
|
|
523
|
+
workerProvider: myWorkerProvider,
|
|
524
|
+
|
|
525
|
+
// RFC-003: Entity projection
|
|
526
|
+
entityProjection: new PostgresProjectionProvider(),
|
|
527
|
+
entityProjectionConfig: {
|
|
528
|
+
includePayload: false // default; set true to persist payload in projections
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
);
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
### Projection Lifecycle
|
|
535
|
+
|
|
536
|
+
| Event | Status | When | Where |
|
|
537
|
+
|-------|--------|------|-------|
|
|
538
|
+
| Task added | `scheduled` | After `addTasks()` completes (all 3 routing paths) | `TaskHandler.addTasks` |
|
|
539
|
+
| Worker picks up task | `processing` | Before executor runs (first attempt only, not retries) | `TaskRunner.run` |
|
|
540
|
+
| Task succeeds | `executed` | After `markTasksAsSuccess` | `TaskHandler.postProcessTasks` / `AsyncActions` |
|
|
541
|
+
| Task exhausts retries | `failed` | After `markTasksAsFailed` or discard | `TaskHandler.postProcessTasks` / `AsyncActions` |
|
|
542
|
+
|
|
543
|
+
### EntityTaskProjection Shape
|
|
544
|
+
|
|
545
|
+
| Field | Type | Description |
|
|
546
|
+
|-------|------|-------------|
|
|
547
|
+
| `task_id` | `ID` | Task identifier (required — fail-fast if missing) |
|
|
548
|
+
| `entity_id` | `string` | External entity identifier |
|
|
549
|
+
| `entity_type` | `string` | Entity type (e.g., `'user'`, `'order'`) |
|
|
550
|
+
| `task_type` | `string` | Task type identifier |
|
|
551
|
+
| `queue_id` | `string` | Queue the task belongs to |
|
|
552
|
+
| `status` | `'scheduled' \| 'processing' \| 'executed' \| 'failed'` | Current lifecycle status |
|
|
553
|
+
| `payload` | `unknown?` | Task payload (only if `includePayload: true`) |
|
|
554
|
+
| `error` | `string?` | Error message (only on `failed` status) |
|
|
555
|
+
| `result` | `unknown?` | Execution result (only on `executed` status) |
|
|
556
|
+
| `created_at` | `Date` | Task creation time |
|
|
557
|
+
| `updated_at` | `Date` | Projection update time |
|
|
558
|
+
|
|
559
|
+
### Design Decisions
|
|
560
|
+
|
|
561
|
+
- **Non-fatal**: Provider errors are caught and logged. Task processing is never interrupted by projection failures.
|
|
562
|
+
- **Batch-efficient**: All projections within a processing batch are collected and sent in a single `upsertProjections()` call.
|
|
563
|
+
- **No retry-spam**: `processing` projection is only emitted on the first attempt, not on retries.
|
|
564
|
+
- **Async-aware**: Async tasks (via `handoffTimeout`) emit terminal projections from `AsyncActions` when the promise resolves.
|
|
565
|
+
- **Fail-fast on misconfiguration**: Entity tasks without an ID throw immediately with an actionable fix, rather than silently producing broken projections.
|
|
566
|
+
|
|
567
|
+
## Flow Orchestration
|
|
568
|
+
|
|
569
|
+
Fan-out/fan-in flow orchestration built into the task pipeline. Dispatch N parallel steps, track completion via a barrier, and automatically dispatch a join task when all steps complete.
|
|
570
|
+
|
|
571
|
+
### How It Works
|
|
572
|
+
|
|
573
|
+
```
|
|
574
|
+
actions.startFlow()
|
|
575
|
+
-> fan-out: [images.resize, video.transcode, metadata.validate]
|
|
576
|
+
-> barrier: tracks completion of all 3 steps
|
|
577
|
+
-> fan-in: item.process.completed (receives merged results)
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
Steps execute as normal tasks with no awareness of the flow. The framework handles barrier tracking and join dispatch via `FlowMiddleware` in the post-processing pipeline.
|
|
581
|
+
|
|
582
|
+
### Starting a Flow
|
|
583
|
+
|
|
584
|
+
```typescript
|
|
585
|
+
const flowId = actions.startFlow({
|
|
586
|
+
steps: [
|
|
587
|
+
{ type: 'images.resize', queue_id: 'media', payload: { imageId: 'img_1' } },
|
|
588
|
+
{ type: 'video.transcode', queue_id: 'media', payload: { videoId: 'vid_1' } },
|
|
589
|
+
{ type: 'metadata.validate', queue_id: 'default', payload: { itemId: 'item_1' } },
|
|
590
|
+
],
|
|
591
|
+
config: {
|
|
592
|
+
join: { type: 'item.process.completed', queue_id: 'default' },
|
|
593
|
+
failure_policy: 'continue', // or 'abort'
|
|
594
|
+
timeout_ms: 300000, // optional — 5 minute deadline
|
|
595
|
+
entity: { id: 'item-123', type: 'Item' }, // optional — flow-level entity tracking
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
// flowId is a UUID identifying this flow instance
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
### Join Task
|
|
602
|
+
|
|
603
|
+
When the barrier is met, a join task is dispatched with aggregated results in `payload.flow_results`:
|
|
604
|
+
|
|
605
|
+
```typescript
|
|
606
|
+
taskQueue.register('default', 'item.process.completed', {
|
|
607
|
+
multiple: false,
|
|
608
|
+
parallel: false,
|
|
609
|
+
default_retries: 2,
|
|
610
|
+
store_on_failure: true,
|
|
611
|
+
|
|
612
|
+
async onTask(task, actions) {
|
|
613
|
+
const results: FlowResults = task.payload.flow_results;
|
|
614
|
+
// results.steps = [
|
|
615
|
+
// { step_index: 0, status: 'success', result: { thumbnails: [...] } },
|
|
616
|
+
// { step_index: 1, status: 'success', result: { video_url: '...' } },
|
|
617
|
+
// { step_index: 2, status: 'fail', error: 'Validation timeout' }
|
|
618
|
+
// ]
|
|
619
|
+
|
|
620
|
+
if (results.steps.every(s => s.status === 'success')) {
|
|
621
|
+
actions.success(task, { merged: true });
|
|
622
|
+
} else {
|
|
623
|
+
actions.fail(task, 'Some steps failed');
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
});
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
### Failure Policies
|
|
630
|
+
|
|
631
|
+
| Policy | Behavior |
|
|
632
|
+
|--------|----------|
|
|
633
|
+
| `continue` (default) | Failed steps still decrement the barrier. Join receives mixed results. Join executor decides what to do. |
|
|
634
|
+
| `abort` | On first final failure, join is dispatched immediately with partial results and `aborted: true`. Remaining step completions become no-ops. |
|
|
635
|
+
|
|
636
|
+
### Timeout
|
|
637
|
+
|
|
638
|
+
When `timeout_ms` is set, a sentinel task is created that fires after the deadline. If the barrier hasn't been met by then, the join is dispatched with partial results and `timed_out: true`. If the barrier was already met, the timeout is a no-op.
|
|
639
|
+
|
|
640
|
+
### Barrier Provider
|
|
641
|
+
|
|
642
|
+
Flow barrier tracking requires an `IFlowBarrierProvider`. An `InMemoryFlowBarrierProvider` is included for testing. For production, implement a Redis-backed provider with atomic Lua scripts for deduplication (HSETNX) and batch decrement.
|
|
643
|
+
|
|
644
|
+
```typescript
|
|
645
|
+
import { IFlowBarrierProvider, BarrierDecrementResult, FlowStepResult } from '@supergrowthai/tq';
|
|
646
|
+
|
|
647
|
+
class RedisFlowBarrierProvider implements IFlowBarrierProvider {
|
|
648
|
+
async initBarrier(flowId: string, totalSteps: number): Promise<void> { /* ... */ }
|
|
649
|
+
async batchDecrementAndCheck(flowId: string, results: FlowStepResult[]): Promise<BarrierDecrementResult> { /* ... */ }
|
|
650
|
+
async getStepResults(flowId: string): Promise<FlowStepResult[]> { /* ... */ }
|
|
651
|
+
async markAborted(flowId: string): Promise<boolean> { /* ... */ }
|
|
652
|
+
async isComplete(flowId: string): Promise<boolean> { /* ... */ }
|
|
653
|
+
}
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
`BarrierDecrementResult.remaining`: `0` = barrier met (dispatch join), `>0` = steps pending, `-1` = already complete/aborted (no-op).
|
|
657
|
+
|
|
658
|
+
### Wiring It Up
|
|
659
|
+
|
|
660
|
+
```typescript
|
|
661
|
+
import { FlowMiddleware, InMemoryFlowBarrierProvider } from '@supergrowthai/tq';
|
|
662
|
+
|
|
663
|
+
const barrierProvider = new InMemoryFlowBarrierProvider(); // or your Redis impl
|
|
664
|
+
const flowMiddleware = new FlowMiddleware(barrierProvider, generateId);
|
|
665
|
+
|
|
666
|
+
const taskHandler = new TaskHandler(
|
|
667
|
+
messageQueue,
|
|
668
|
+
taskQueue,
|
|
669
|
+
databaseAdapter,
|
|
670
|
+
cacheAdapter,
|
|
671
|
+
asyncTaskManager,
|
|
672
|
+
notificationProvider,
|
|
673
|
+
{
|
|
674
|
+
lifecycleProvider: myLifecycleProvider,
|
|
675
|
+
workerProvider: myWorkerProvider,
|
|
676
|
+
entityProjection: myProjectionProvider,
|
|
677
|
+
flowMiddleware, // RFC-002
|
|
678
|
+
}
|
|
679
|
+
);
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
### Entity Tracking on Flows
|
|
683
|
+
|
|
684
|
+
When `config.entity` is provided, the flow lifecycle is projected through the same entity projection system (RFC-003):
|
|
685
|
+
|
|
686
|
+
| Event | Projection Status |
|
|
687
|
+
|-------|-------------------|
|
|
688
|
+
| `startFlow()` called | `processing` (keyed on `flow_id`) |
|
|
689
|
+
| Join task succeeds | `executed` |
|
|
690
|
+
| Join task fails / abort / timeout | `failed` |
|
|
691
|
+
|
|
692
|
+
Individual step tasks do **not** carry `CronTask.entity` — entity tracking is at the flow level, avoiding N separate projection rows per step.
|
|
693
|
+
|
|
694
|
+
### Design Decisions
|
|
695
|
+
|
|
696
|
+
- **Flow metadata in `metadata.flow_meta`**: User payload is never polluted. Framework data lives in the `metadata` namespace alongside `log_context` (RFC-005).
|
|
697
|
+
- **Batch barrier operations**: Multiple steps from the same flow completing in one processing cycle are batched into a single barrier call.
|
|
698
|
+
- **HSETNX deduplication**: At-least-once MQ delivery means duplicate step completions are safely ignored via set-if-not-exists semantics.
|
|
699
|
+
- **FlowMiddleware returns data, doesn't write**: Returns `{ joinTasks, projections }` to `TaskHandler`, which owns all writes. Clean separation of concerns.
|
|
700
|
+
- **Nested flows**: A join executor can call `actions.startFlow()` to start another flow. Flow IDs are independent UUIDs — no special handling needed.
|
|
701
|
+
- **IMultiTaskExecutor optimization**: Flow steps of the same type in the same processing cycle batch into a single executor call automatically via `TaskRunner`'s existing grouping logic.
|
|
702
|
+
|
|
426
703
|
## Error Handling and Retries
|
|
427
704
|
|
|
428
705
|
```typescript
|
|
@@ -437,7 +714,7 @@ const resilientExecutor: ISingleTaskNonParallel<ApiCallData> = {
|
|
|
437
714
|
const response = await callExternalAPI(task.payload.endpoint, task.payload.data);
|
|
438
715
|
|
|
439
716
|
if (response.status === 200) {
|
|
440
|
-
actions.success(task);
|
|
717
|
+
actions.success(task, { status: response.status });
|
|
441
718
|
} else {
|
|
442
719
|
throw new Error(`API returned status: ${response.status}`);
|
|
443
720
|
}
|
|
@@ -455,13 +732,50 @@ const resilientExecutor: ISingleTaskNonParallel<ApiCallData> = {
|
|
|
455
732
|
execute_at: new Date(Date.now() + retryDelay)
|
|
456
733
|
}]);
|
|
457
734
|
} else {
|
|
458
|
-
actions.fail(task);
|
|
735
|
+
actions.fail(task, error instanceof Error ? error : String(error), { attempt: currentRetries + 1 });
|
|
459
736
|
}
|
|
460
737
|
}
|
|
461
738
|
}
|
|
462
739
|
};
|
|
463
740
|
```
|
|
464
741
|
|
|
742
|
+
## Task Result Persistence
|
|
743
|
+
|
|
744
|
+
Executors can persist structured results on task completion or failure via `ExecutorActions`:
|
|
745
|
+
|
|
746
|
+
```typescript
|
|
747
|
+
// Store result on success — saved to task.execution_result (256 KB limit)
|
|
748
|
+
actions.success(task, { outputUrl: 'https://cdn.example.com/result.pdf', pages: 42 });
|
|
749
|
+
|
|
750
|
+
// Store error details on failure — saved to task.execution_stats
|
|
751
|
+
actions.fail(task, new Error('Upstream timeout'), { endpoint: '/api/render', latencyMs: 30000 });
|
|
752
|
+
```
|
|
753
|
+
|
|
754
|
+
Results are stored by the `TaskRunner` and persisted via your `ITaskStorageAdapter`. Oversized results (>256 KB serialized) are silently dropped to protect storage.
|
|
755
|
+
|
|
756
|
+
### Executor-Level Partition Key
|
|
757
|
+
|
|
758
|
+
Executors can declare a `getPartitionKey` function to control Kinesis partition routing for ordering guarantees:
|
|
759
|
+
|
|
760
|
+
```typescript
|
|
761
|
+
taskQueue.register('order-queue', 'process-order', {
|
|
762
|
+
multiple: false,
|
|
763
|
+
parallel: false,
|
|
764
|
+
default_retries: 3,
|
|
765
|
+
store_on_failure: true,
|
|
766
|
+
|
|
767
|
+
// All tasks for the same user land on the same Kinesis shard
|
|
768
|
+
getPartitionKey: (task) => task.payload.user_id,
|
|
769
|
+
|
|
770
|
+
async onTask(task, actions) {
|
|
771
|
+
await processOrder(task.payload);
|
|
772
|
+
actions.success(task, { orderId: task.payload.order_id });
|
|
773
|
+
}
|
|
774
|
+
});
|
|
775
|
+
```
|
|
776
|
+
|
|
777
|
+
The returned value is set as `partition_key` on the message, overriding the default Kinesis partition routing.
|
|
778
|
+
|
|
465
779
|
## Working with Different Queue Providers
|
|
466
780
|
|
|
467
781
|
```typescript
|
|
@@ -522,7 +836,11 @@ kinesisTaskQueue.register('real-time', 'notification', notificationExecutor);
|
|
|
522
836
|
Full TypeScript definitions with generic task types:
|
|
523
837
|
|
|
524
838
|
```typescript
|
|
525
|
-
import type {
|
|
839
|
+
import type {
|
|
840
|
+
TaskExecutor, ExecutorActions, CronTask, AsyncTaskManagerOptions, ShutdownResult,
|
|
841
|
+
IEntityProjectionProvider, EntityTaskProjection, EntityProjectionConfig,
|
|
842
|
+
StartFlowInput, FlowResults, FlowMeta, IFlowBarrierProvider
|
|
843
|
+
} from '@supergrowthai/tq';
|
|
526
844
|
|
|
527
845
|
// Define your task data type
|
|
528
846
|
interface EmailTaskData {
|
|
@@ -550,7 +868,7 @@ const typedExecutor: ISingleTaskNonParallel<EmailTaskData> = {
|
|
|
550
868
|
await sendEmail(task.payload);
|
|
551
869
|
actions.success(task);
|
|
552
870
|
} catch (error) {
|
|
553
|
-
actions.fail(task);
|
|
871
|
+
actions.fail(task, error instanceof Error ? error : String(error));
|
|
554
872
|
}
|
|
555
873
|
}
|
|
556
874
|
};
|
|
@@ -597,7 +915,7 @@ await mongoClient.connect();
|
|
|
597
915
|
|
|
598
916
|
const messageQueue = new ProductionMongoDBQueue(cacheProvider, mongoClient);
|
|
599
917
|
const taskQueue = new TaskQueuesManager(messageQueue);
|
|
600
|
-
const asyncTaskManager = new AsyncTaskManager(
|
|
918
|
+
const asyncTaskManager = new AsyncTaskManager({ maxTasks: 10 }); // See AsyncTaskManagerOptions
|
|
601
919
|
|
|
602
920
|
const taskHandler = new TaskHandler(
|
|
603
921
|
messageQueue,
|
|
@@ -642,7 +960,7 @@ const idempotentExecutor: ISingleTaskNonParallel<UserUpdateData> = {
|
|
|
642
960
|
await updateUser(task.payload.userId, task.payload.updates);
|
|
643
961
|
actions.success(task);
|
|
644
962
|
} catch (error) {
|
|
645
|
-
actions.fail(task);
|
|
963
|
+
actions.fail(task, error instanceof Error ? error : String(error));
|
|
646
964
|
}
|
|
647
965
|
}
|
|
648
966
|
};
|
|
@@ -668,7 +986,7 @@ const apiExecutor: ISingleTaskParallel<ApiTaskData> = {
|
|
|
668
986
|
const result = await callAPI(task.payload);
|
|
669
987
|
actions.success(task);
|
|
670
988
|
} catch (error) {
|
|
671
|
-
actions.fail(task);
|
|
989
|
+
actions.fail(task, error instanceof Error ? error : String(error));
|
|
672
990
|
} finally {
|
|
673
991
|
rateLimiter.release();
|
|
674
992
|
}
|