@output.ai/core 0.3.6 → 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
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
|
|
@@ -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
|
|
8
|
+
This interceptor wraps every activity execution with cross-cutting concerns:
|
|
9
9
|
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
+
} );
|