@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.
Files changed (100) hide show
  1. package/README.md +338 -20
  2. package/dist/AsyncActions-BOO1ikWz.cjs +241 -0
  3. package/dist/AsyncActions-BOO1ikWz.cjs.map +1 -0
  4. package/dist/AsyncActions-CZYO8ShR.js +242 -0
  5. package/dist/AsyncActions-CZYO8ShR.js.map +1 -0
  6. package/dist/{PrismaAdapter-CvM_XNtE.cjs → PrismaAdapter-CUIWhjms.cjs} +57 -81
  7. package/dist/PrismaAdapter-CUIWhjms.cjs.map +1 -0
  8. package/dist/{PrismaAdapter-Dy7MV090.js → PrismaAdapter-D5ACKPbS.js} +57 -81
  9. package/dist/PrismaAdapter-D5ACKPbS.js.map +1 -0
  10. package/dist/adapters/index.cjs +1 -1
  11. package/dist/adapters/index.mjs +1 -1
  12. package/dist/client-BxG7LzLv.cjs +90 -0
  13. package/dist/client-BxG7LzLv.cjs.map +1 -0
  14. package/dist/client-dvHNt8qU.js +91 -0
  15. package/dist/client-dvHNt8qU.js.map +1 -0
  16. package/dist/core/Actions.cjs +184 -16
  17. package/dist/core/Actions.cjs.map +1 -1
  18. package/dist/core/Actions.mjs +184 -16
  19. package/dist/core/Actions.mjs.map +1 -1
  20. package/dist/core/async/AsyncActions.cjs +4 -98
  21. package/dist/core/async/AsyncActions.cjs.map +1 -1
  22. package/dist/core/async/AsyncActions.mjs +4 -98
  23. package/dist/core/async/AsyncActions.mjs.map +1 -1
  24. package/dist/core/async/AsyncTaskManager.cjs +133 -22
  25. package/dist/core/async/AsyncTaskManager.cjs.map +1 -1
  26. package/dist/core/async/AsyncTaskManager.mjs +133 -22
  27. package/dist/core/async/AsyncTaskManager.mjs.map +1 -1
  28. package/dist/index.cjs +517 -35
  29. package/dist/index.cjs.map +1 -1
  30. package/dist/index.mjs +517 -35
  31. package/dist/index.mjs.map +1 -1
  32. package/dist/src/adapters/ITaskStorageAdapter.d.cts +0 -5
  33. package/dist/src/adapters/ITaskStorageAdapter.d.ts +0 -5
  34. package/dist/src/adapters/InMemoryAdapter.d.cts +0 -2
  35. package/dist/src/adapters/InMemoryAdapter.d.ts +0 -2
  36. package/dist/src/adapters/MongoDbAdapter.d.cts +0 -2
  37. package/dist/src/adapters/MongoDbAdapter.d.ts +0 -2
  38. package/dist/src/adapters/PrismaAdapter.d.cts +0 -2
  39. package/dist/src/adapters/PrismaAdapter.d.ts +0 -2
  40. package/dist/src/adapters/types.d.cts +7 -0
  41. package/dist/src/adapters/types.d.ts +7 -0
  42. package/dist/src/core/Actions.d.cts +25 -2
  43. package/dist/src/core/Actions.d.ts +25 -2
  44. package/dist/src/core/TaskHandler.d.cts +13 -5
  45. package/dist/src/core/TaskHandler.d.ts +13 -5
  46. package/dist/src/core/TaskRunner.d.cts +16 -1
  47. package/dist/src/core/TaskRunner.d.ts +16 -1
  48. package/dist/src/core/async/AsyncActions.d.cts +20 -1
  49. package/dist/src/core/async/AsyncActions.d.ts +20 -1
  50. package/dist/src/core/async/AsyncTaskManager.d.cts +36 -4
  51. package/dist/src/core/async/AsyncTaskManager.d.ts +36 -4
  52. package/dist/src/core/async/async-task-manager.d.cts +21 -3
  53. package/dist/src/core/async/async-task-manager.d.ts +21 -3
  54. package/dist/src/core/async/retry-utils.d.cts +15 -0
  55. package/dist/src/core/async/retry-utils.d.ts +15 -0
  56. package/dist/src/core/base/interfaces.d.cts +10 -2
  57. package/dist/src/core/base/interfaces.d.ts +10 -2
  58. package/dist/src/core/entity/IEntityProjectionProvider.d.cts +45 -0
  59. package/dist/src/core/entity/IEntityProjectionProvider.d.ts +45 -0
  60. package/dist/src/core/entity/index.d.cts +1 -0
  61. package/dist/src/core/entity/index.d.ts +1 -0
  62. package/dist/src/core/flow/FlowMiddleware.d.cts +26 -0
  63. package/dist/src/core/flow/FlowMiddleware.d.ts +26 -0
  64. package/dist/src/core/flow/IFlowBarrierProvider.d.cts +46 -0
  65. package/dist/src/core/flow/IFlowBarrierProvider.d.ts +46 -0
  66. package/dist/src/core/flow/InMemoryFlowBarrierProvider.d.cts +10 -0
  67. package/dist/src/core/flow/InMemoryFlowBarrierProvider.d.ts +10 -0
  68. package/dist/src/core/flow/index.d.cts +4 -0
  69. package/dist/src/core/flow/index.d.ts +4 -0
  70. package/dist/src/core/flow/types.d.cts +82 -0
  71. package/dist/src/core/flow/types.d.ts +82 -0
  72. package/dist/src/core/lifecycle.d.cts +9 -4
  73. package/dist/src/core/lifecycle.d.ts +9 -4
  74. package/dist/src/core/log-context.d.cts +10 -0
  75. package/dist/src/core/log-context.d.ts +10 -0
  76. package/dist/src/index.d.cts +4 -0
  77. package/dist/src/index.d.ts +4 -0
  78. package/dist/src/test/adapter-consistency.test.d.cts +11 -0
  79. package/dist/src/test/adapter-consistency.test.d.ts +11 -0
  80. package/dist/src/test/immediate-mode-bugs.test.d.cts +11 -0
  81. package/dist/src/test/immediate-mode-bugs.test.d.ts +11 -0
  82. package/dist/src/test/rfc-001-result-persistence.test.d.cts +17 -0
  83. package/dist/src/test/rfc-001-result-persistence.test.d.ts +17 -0
  84. package/dist/src/test/rfc-002-flow-orchestration.test.d.cts +24 -0
  85. package/dist/src/test/rfc-002-flow-orchestration.test.d.ts +24 -0
  86. package/dist/src/test/rfc-003-entity-projection.test.d.cts +14 -0
  87. package/dist/src/test/rfc-003-entity-projection.test.d.ts +14 -0
  88. package/dist/src/test/rfc-004-async-hardening.test.d.cts +14 -0
  89. package/dist/src/test/rfc-004-async-hardening.test.d.ts +14 -0
  90. package/dist/src/test/rfc-005-log-context.test.d.cts +14 -0
  91. package/dist/src/test/rfc-005-log-context.test.d.ts +14 -0
  92. package/dist/src/test/tq-fixes.test.d.cts +17 -0
  93. package/dist/src/test/tq-fixes.test.d.ts +17 -0
  94. package/package.json +2 -2
  95. package/dist/PrismaAdapter-CvM_XNtE.cjs.map +0 -1
  96. package/dist/PrismaAdapter-Dy7MV090.js.map +0 -1
  97. package/dist/client-BAiCkZv7.js +0 -52
  98. package/dist/client-BAiCkZv7.js.map +0 -1
  99. package/dist/client-DgdG7pT6.cjs +0 -51
  100. 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
- // Set up async task manager
250
- const asyncTaskManager = new AsyncTaskManager(5); // maxTasks parameter
266
+ // Observability
267
+ const metrics = asyncTaskManager.getMetrics();
268
+ // { activeTaskCount, totalHandedOff, totalCompleted, totalRejected,
269
+ // totalTimedOut, oldestTaskMs, maxTasks, utilizationPercent }
251
270
 
252
- // Graceful shutdown with AbortSignal
253
- const abortController = new AbortController();
254
- await asyncTaskManager.shutdown(abortController.signal);
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 {TaskExecutor, ExecutorActions, CronTask} from '@supergrowthai/tq';
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(10); // maxTasks: 10 concurrent async tasks
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
  }