@output.ai/core 0.3.5 → 0.3.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@output.ai/core",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "The core module of the output framework",
5
5
  "type": "module",
6
6
  "exports": {
package/src/consts.js CHANGED
@@ -10,3 +10,7 @@ export const ComponentType = {
10
10
  INTERNAL_STEP: 'internal_step',
11
11
  STEP: 'step'
12
12
  };
13
+
14
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 2 * 60 * 1000; // 2 minutes
15
+ export const HEARTBEAT_INTERVAL_MS = Number( process.env.OUTPUT_HEARTBEAT_INTERVAL_MS ) || DEFAULT_HEARTBEAT_INTERVAL_MS;
16
+ export const HEARTBEAT_ENABLED = process.env.OUTPUT_HEARTBEAT_ENABLED !== 'false'; // on by default, set to 'false' to disable
@@ -40,6 +40,7 @@ class Context {
40
40
 
41
41
  const defaultActivityOptions = {
42
42
  startToCloseTimeout: '20m',
43
+ heartbeatTimeout: '5m',
43
44
  retry: {
44
45
  initialInterval: '10s',
45
46
  backoffCoefficient: 2.0,
@@ -6,7 +6,17 @@ const envVarSchema = z.object( {
6
6
  CATALOG_ID: z.string().regex( /^[a-z0-9_.@-]+$/i ),
7
7
  TEMPORAL_ADDRESS: z.string().default( 'localhost:7233' ),
8
8
  TEMPORAL_API_KEY: z.string().optional(),
9
- TEMPORAL_NAMESPACE: z.string().optional().default( 'default' )
9
+ TEMPORAL_NAMESPACE: z.string().optional().default( 'default' ),
10
+ // Worker concurrency — tune these via env vars to adjust for your workload.
11
+ // Each step (API, LLM, etc.) call is one activity. Lower this to reduce memory pressure.
12
+ MAX_CONCURRENT_ACTIVITY_TASKS: z.coerce.number().int().positive().default( 40 ),
13
+ // Workflows are lightweight state machines — this can be high.
14
+ MAX_CONCURRENT_WORKFLOW_TASKS: z.coerce.number().int().positive().default( 200 ),
15
+ // LRU cache for sticky workflow execution. Lower values free memory faster after surges.
16
+ MAX_CACHED_WORKFLOWS: z.coerce.number().int().positive().default( 1000 ),
17
+ // How aggressively the worker pulls tasks from Temporal.
18
+ MAX_CONCURRENT_ACTIVITY_POLLS: z.coerce.number().int().positive().default( 5 ),
19
+ MAX_CONCURRENT_WORKFLOW_POLLS: z.coerce.number().int().positive().default( 5 )
10
20
  } );
11
21
 
12
22
  const { data: envVars, error } = envVarSchema.safeParse( process.env );
@@ -17,8 +27,11 @@ if ( error ) {
17
27
  export const address = envVars.TEMPORAL_ADDRESS;
18
28
  export const apiKey = envVars.TEMPORAL_API_KEY;
19
29
  export const executionTimeout = '1m';
20
- export const maxActivities = 100;
21
- export const maxWorkflows = 100;
30
+ export const maxActivities = envVars.MAX_CONCURRENT_ACTIVITY_TASKS;
31
+ export const maxWorkflows = envVars.MAX_CONCURRENT_WORKFLOW_TASKS;
32
+ export const maxCachedWorkflows = envVars.MAX_CACHED_WORKFLOWS;
33
+ export const maxActivityPolls = envVars.MAX_CONCURRENT_ACTIVITY_POLLS;
34
+ export const maxWorkflowPolls = envVars.MAX_CONCURRENT_WORKFLOW_POLLS;
22
35
  export const namespace = envVars.TEMPORAL_NAMESPACE;
23
36
  export const taskQueue = envVars.CATALOG_ID;
24
37
  export const catalogId = envVars.CATALOG_ID;
@@ -1,7 +1,10 @@
1
1
  import { Worker, NativeConnection } from '@temporalio/worker';
2
2
  import { Client } from '@temporalio/client';
3
3
  import { WorkflowIdConflictPolicy } from '@temporalio/common';
4
- import { address, apiKey, maxActivities, maxWorkflows, namespace, taskQueue, catalogId } from './configs.js';
4
+ import {
5
+ address, apiKey, maxActivities, maxWorkflows, maxCachedWorkflows,
6
+ maxActivityPolls, maxWorkflowPolls, namespace, taskQueue, catalogId
7
+ } from './configs.js';
5
8
  import { loadActivities, loadWorkflows, createWorkflowsEntryPoint } from './loader.js';
6
9
  import { sinks } from './sinks.js';
7
10
  import { createCatalog } from './catalog_workflow/index.js';
@@ -47,6 +50,9 @@ const callerDir = process.argv[2];
47
50
  interceptors: initInterceptors( { activities } ),
48
51
  maxConcurrentWorkflowTaskExecutions: maxWorkflows,
49
52
  maxConcurrentActivityTaskExecutions: maxActivities,
53
+ maxCachedWorkflows,
54
+ maxConcurrentActivityTaskPolls: maxActivityPolls,
55
+ maxConcurrentWorkflowTaskPolls: maxWorkflowPolls,
50
56
  bundlerOptions: { webpackConfigHook }
51
57
  } );
52
58
 
@@ -2,16 +2,23 @@ import { Context } from '@temporalio/activity';
2
2
  import { Storage } from '#async_storage';
3
3
  import { addEventStart, addEventEnd, addEventError } from '#tracing';
4
4
  import { headersToObject } from '../sandboxed_utils.js';
5
- import { METADATA_ACCESS_SYMBOL } from '#consts';
5
+ import { METADATA_ACCESS_SYMBOL, HEARTBEAT_INTERVAL_MS, HEARTBEAT_ENABLED } from '#consts';
6
6
 
7
7
  /*
8
- This interceptor is called for every activity execution
8
+ This interceptor wraps every activity execution with cross-cutting concerns:
9
9
 
10
- It will have access to the activity scope.
10
+ 1. Tracing: records start/end/error events and sets up AsyncLocalStorage context
11
+ so nested operations (e.g. HTTP calls inside steps) can be traced back to the parent activity.
11
12
 
12
- What it does is to wrap the execution using Node's AsyncLocalStorage to save context info for tracing.
13
+ 2. Heartbeating: sends periodic heartbeat signals to Temporal so it can detect dead workers
14
+ without waiting for the full startToCloseTimeout (which can be up to 1h+).
15
+ This is critical during deploys — when a worker restarts, Temporal will notice
16
+ the missing heartbeat within the heartbeatTimeout window and retry the activity
17
+ on a new worker, instead of waiting the entire startToCloseTimeout.
13
18
 
14
- Some information it needs for its context comes from Temporal's Activity Context others are injected in the headers
19
+ Context information comes from two sources:
20
+ - Temporal's Activity Context (workflowId, activityId, activityType)
21
+ - Headers injected by the workflow interceptor (executionContext)
15
22
  */
16
23
  export class ActivityExecutionInterceptor {
17
24
  constructor( activities ) {
@@ -23,17 +30,27 @@ export class ActivityExecutionInterceptor {
23
30
  const { executionContext } = headersToObject( input.headers );
24
31
  const { type: kind } = this.activities?.[activityType]?.[METADATA_ACCESS_SYMBOL];
25
32
 
33
+ // --- Tracing: record the start of the activity ---
26
34
  const traceArguments = { kind, id: activityId, parentId: workflowId, name: activityType, executionContext };
27
35
  addEventStart( { details: input.args[0], ...traceArguments } );
28
36
 
29
- // creates a context for the nested tracing
37
+ // --- Heartbeating: signal Temporal periodically that this worker is still alive ---
38
+ const heartbeatInterval = HEARTBEAT_ENABLED ?
39
+ setInterval( () => Context.current().heartbeat(), HEARTBEAT_INTERVAL_MS ) :
40
+ null;
41
+
30
42
  try {
43
+ // --- Execution: run the activity within an AsyncLocalStorage context for nested tracing ---
31
44
  const output = await Storage.runWithContext( async _ => next( input ), { parentId: activityId, executionContext } );
32
45
  addEventEnd( { details: output, ...traceArguments } );
33
46
  return output;
34
47
  } catch ( error ) {
35
48
  addEventError( { details: error, ...traceArguments } );
36
49
  throw error;
50
+ } finally {
51
+ if ( heartbeatInterval ) {
52
+ clearInterval( heartbeatInterval );
53
+ }
37
54
  }
38
55
  }
39
56
  };
@@ -0,0 +1,178 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+
3
+ const METADATA_ACCESS_SYMBOL = Symbol( '__metadata' );
4
+
5
+ const heartbeatMock = vi.fn();
6
+ const contextInfoMock = {
7
+ workflowExecution: { workflowId: 'wf-1' },
8
+ activityId: 'act-1',
9
+ activityType: 'myWorkflow#myStep'
10
+ };
11
+
12
+ vi.mock( '@temporalio/activity', () => ( {
13
+ Context: {
14
+ current: () => ( {
15
+ info: contextInfoMock,
16
+ heartbeat: heartbeatMock
17
+ } )
18
+ }
19
+ } ) );
20
+
21
+ vi.mock( '#async_storage', () => ( {
22
+ Storage: {
23
+ runWithContext: async fn => fn()
24
+ }
25
+ } ) );
26
+
27
+ const addEventStartMock = vi.fn();
28
+ const addEventEndMock = vi.fn();
29
+ const addEventErrorMock = vi.fn();
30
+ vi.mock( '#tracing', () => ( {
31
+ addEventStart: addEventStartMock,
32
+ addEventEnd: addEventEndMock,
33
+ addEventError: addEventErrorMock
34
+ } ) );
35
+
36
+ vi.mock( '../sandboxed_utils.js', () => ( {
37
+ headersToObject: () => ( { executionContext: { workflowId: 'wf-1' } } )
38
+ } ) );
39
+
40
+ const mockConfig = { heartbeatEnabled: true, heartbeatIntervalMs: 50 };
41
+
42
+ vi.mock( '#consts', () => ( {
43
+ get METADATA_ACCESS_SYMBOL() {
44
+ return METADATA_ACCESS_SYMBOL;
45
+ },
46
+ get HEARTBEAT_ENABLED() {
47
+ return mockConfig.heartbeatEnabled;
48
+ },
49
+ get HEARTBEAT_INTERVAL_MS() {
50
+ return mockConfig.heartbeatIntervalMs;
51
+ }
52
+ } ) );
53
+
54
+ const makeActivities = () => ( {
55
+ 'myWorkflow#myStep': { [METADATA_ACCESS_SYMBOL]: { type: 'step' } }
56
+ } );
57
+
58
+ const makeInput = () => ( {
59
+ args: [ { someInput: 'data' } ],
60
+ headers: {}
61
+ } );
62
+
63
+ describe( 'ActivityExecutionInterceptor', () => {
64
+ beforeEach( () => {
65
+ vi.clearAllMocks();
66
+ vi.useFakeTimers();
67
+ mockConfig.heartbeatEnabled = true;
68
+ mockConfig.heartbeatIntervalMs = 50;
69
+ } );
70
+
71
+ afterEach( () => {
72
+ vi.useRealTimers();
73
+ } );
74
+
75
+ it( 'records trace start and end events on successful execution', async () => {
76
+ const { ActivityExecutionInterceptor } = await import( './activity.js' );
77
+ const interceptor = new ActivityExecutionInterceptor( makeActivities() );
78
+ const next = vi.fn().mockResolvedValue( { result: 'ok' } );
79
+
80
+ const promise = interceptor.execute( makeInput(), next );
81
+ vi.advanceTimersByTime( 0 );
82
+ const output = await promise;
83
+
84
+ expect( output ).toEqual( { result: 'ok' } );
85
+ expect( addEventStartMock ).toHaveBeenCalledOnce();
86
+ expect( addEventEndMock ).toHaveBeenCalledOnce();
87
+ expect( addEventErrorMock ).not.toHaveBeenCalled();
88
+ } );
89
+
90
+ it( 'records trace error event on failed execution', async () => {
91
+ const { ActivityExecutionInterceptor } = await import( './activity.js' );
92
+ const interceptor = new ActivityExecutionInterceptor( makeActivities() );
93
+ const error = new Error( 'step failed' );
94
+ const next = vi.fn().mockRejectedValue( error );
95
+
96
+ const promise = interceptor.execute( makeInput(), next );
97
+ vi.advanceTimersByTime( 0 );
98
+
99
+ await expect( promise ).rejects.toThrow( 'step failed' );
100
+ expect( addEventStartMock ).toHaveBeenCalledOnce();
101
+ expect( addEventErrorMock ).toHaveBeenCalledOnce();
102
+ expect( addEventEndMock ).not.toHaveBeenCalled();
103
+ } );
104
+
105
+ it( 'sends periodic heartbeats during activity execution', async () => {
106
+ const { ActivityExecutionInterceptor } = await import( './activity.js' );
107
+ const interceptor = new ActivityExecutionInterceptor( makeActivities() );
108
+
109
+ // next() resolves only after we manually resolve it, simulating a long-running activity
110
+ const deferred = { resolve: null };
111
+ const next = vi.fn().mockImplementation( () => new Promise( r => {
112
+ deferred.resolve = r;
113
+ } ) );
114
+
115
+ const promise = interceptor.execute( makeInput(), next );
116
+
117
+ expect( heartbeatMock ).not.toHaveBeenCalled();
118
+
119
+ vi.advanceTimersByTime( 50 );
120
+ expect( heartbeatMock ).toHaveBeenCalledTimes( 1 );
121
+
122
+ vi.advanceTimersByTime( 50 );
123
+ expect( heartbeatMock ).toHaveBeenCalledTimes( 2 );
124
+
125
+ vi.advanceTimersByTime( 50 );
126
+ expect( heartbeatMock ).toHaveBeenCalledTimes( 3 );
127
+
128
+ deferred.resolve( { result: 'done' } );
129
+ await promise;
130
+ } );
131
+
132
+ it( 'clears heartbeat interval after activity completes', async () => {
133
+ const { ActivityExecutionInterceptor } = await import( './activity.js' );
134
+ const interceptor = new ActivityExecutionInterceptor( makeActivities() );
135
+ const next = vi.fn().mockResolvedValue( { result: 'ok' } );
136
+
137
+ const promise = interceptor.execute( makeInput(), next );
138
+ vi.advanceTimersByTime( 0 );
139
+ await promise;
140
+
141
+ heartbeatMock.mockClear();
142
+ vi.advanceTimersByTime( 500 );
143
+ expect( heartbeatMock ).not.toHaveBeenCalled();
144
+ } );
145
+
146
+ it( 'clears heartbeat interval after activity fails', async () => {
147
+ const { ActivityExecutionInterceptor } = await import( './activity.js' );
148
+ const interceptor = new ActivityExecutionInterceptor( makeActivities() );
149
+ const next = vi.fn().mockRejectedValue( new Error( 'boom' ) );
150
+
151
+ const promise = interceptor.execute( makeInput(), next );
152
+ vi.advanceTimersByTime( 0 );
153
+ await promise.catch( () => {} );
154
+
155
+ heartbeatMock.mockClear();
156
+ vi.advanceTimersByTime( 500 );
157
+ expect( heartbeatMock ).not.toHaveBeenCalled();
158
+ } );
159
+
160
+ it( 'does not heartbeat when HEARTBEAT_ENABLED is false', async () => {
161
+ mockConfig.heartbeatEnabled = false;
162
+ const { ActivityExecutionInterceptor } = await import( './activity.js' );
163
+ const interceptor = new ActivityExecutionInterceptor( makeActivities() );
164
+
165
+ const deferred = { resolve: null };
166
+ const next = vi.fn().mockImplementation( () => new Promise( r => {
167
+ deferred.resolve = r;
168
+ } ) );
169
+
170
+ const promise = interceptor.execute( makeInput(), next );
171
+
172
+ vi.advanceTimersByTime( 200 );
173
+ expect( heartbeatMock ).not.toHaveBeenCalled();
174
+
175
+ deferred.resolve( { result: 'done' } );
176
+ await promise;
177
+ } );
178
+ } );