@outputai/core 0.5.1-dev.45fb889.0 → 0.5.1-next.8e45051.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.
- package/package.json +1 -1
- package/src/tracing/trace_engine.js +5 -5
- package/src/tracing/trace_engine.spec.js +62 -0
- package/src/utils/index.d.ts +13 -0
- package/src/utils/utils.js +39 -0
- package/src/utils/utils.spec.js +41 -2
- package/src/worker/interceptors/activity.js +73 -18
- package/src/worker/interceptors/activity.spec.js +93 -3
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@ import { serializeError } from './tools/utils.js';
|
|
|
4
4
|
import { isStringboolTrue } from '#utils';
|
|
5
5
|
import * as localProcessor from './processors/local/index.js';
|
|
6
6
|
import * as s3Processor from './processors/s3/index.js';
|
|
7
|
-
import { ComponentType
|
|
7
|
+
import { ComponentType } from '#consts';
|
|
8
8
|
import { createChildLogger } from '#logger';
|
|
9
9
|
import { EventAction } from './trace_consts.js';
|
|
10
10
|
import { BaseAttribute } from './trace_attribute.js';
|
|
@@ -93,14 +93,14 @@ export const addEventAction = ( action, { kind, name, id, parentId, details, exe
|
|
|
93
93
|
export function addEventActionWithContext( action, options ) {
|
|
94
94
|
const storeContent = Storage.load();
|
|
95
95
|
if ( storeContent ) { // If there is no storageContext this was not called from a Temporal environment
|
|
96
|
-
const { parentId,
|
|
96
|
+
const { parentId, executionContext, sendAttributeSignal } = storeContent;
|
|
97
97
|
if ( action === EventAction.ADD_ATTR ) {
|
|
98
98
|
const attribute = options.details;
|
|
99
99
|
if ( !( attribute instanceof BaseAttribute ) ) {
|
|
100
|
-
throw new Error(
|
|
100
|
+
throw new Error( `Event ${EventAction.ADD_ATTR} argument is not a BaseAttribute instance` );
|
|
101
|
+
} else {
|
|
102
|
+
sendAttributeSignal( options.details );
|
|
101
103
|
}
|
|
102
|
-
attribute.setActivity( parentId, parentName );
|
|
103
|
-
workflowHandle.signal( Signal.ADD_ATTRIBUTE, attribute );
|
|
104
104
|
}
|
|
105
105
|
addEventAction( action, { ...options, parentId, executionContext } );
|
|
106
106
|
}
|
|
@@ -5,6 +5,12 @@ vi.mock( '#async_storage', () => ( {
|
|
|
5
5
|
Storage: { load: storageLoadMock }
|
|
6
6
|
} ) );
|
|
7
7
|
|
|
8
|
+
const logWarnMock = vi.fn();
|
|
9
|
+
const logErrorMock = vi.fn();
|
|
10
|
+
vi.mock( '#logger', () => ( {
|
|
11
|
+
createChildLogger: () => ( { warn: logWarnMock, error: logErrorMock } )
|
|
12
|
+
} ) );
|
|
13
|
+
|
|
8
14
|
const localInitMock = vi.fn( async () => {} );
|
|
9
15
|
const localExecMock = vi.fn();
|
|
10
16
|
const localGetDestinationMock = vi.fn( () => '/local/path.json' );
|
|
@@ -117,6 +123,62 @@ describe( 'tracing/trace_engine', () => {
|
|
|
117
123
|
expect( payload.entry.action ).toBe( 'tick' );
|
|
118
124
|
} );
|
|
119
125
|
|
|
126
|
+
it( 'addEventActionWithContext() sends ADD_ATTR attributes through storage context', async () => {
|
|
127
|
+
process.env.OUTPUT_TRACE_LOCAL_ON = 'true';
|
|
128
|
+
const sendAttributeSignalMock = vi.fn();
|
|
129
|
+
const executionContext = { runId: 'r1', disableTrace: false };
|
|
130
|
+
storageLoadMock.mockReturnValue( {
|
|
131
|
+
parentId: 'ctx-p',
|
|
132
|
+
executionContext,
|
|
133
|
+
sendAttributeSignal: sendAttributeSignalMock
|
|
134
|
+
} );
|
|
135
|
+
const { init, addEventActionWithContext } = await loadTraceEngine();
|
|
136
|
+
const { EventAction } = await import( './trace_consts.js' );
|
|
137
|
+
const { Attribute } = await import( './trace_attribute.js' );
|
|
138
|
+
await init();
|
|
139
|
+
|
|
140
|
+
const attribute = new Attribute.HTTPRequestCount( 'https://example.test', 'req-1' );
|
|
141
|
+
addEventActionWithContext( EventAction.ADD_ATTR, { kind: 'http', name: 'request', id: 'req-1', details: attribute } );
|
|
142
|
+
|
|
143
|
+
expect( sendAttributeSignalMock ).toHaveBeenCalledTimes( 1 );
|
|
144
|
+
expect( sendAttributeSignalMock ).toHaveBeenCalledWith( attribute );
|
|
145
|
+
expect( localExecMock ).toHaveBeenCalledTimes( 1 );
|
|
146
|
+
expect( localExecMock.mock.calls[0][0] ).toEqual( {
|
|
147
|
+
executionContext,
|
|
148
|
+
entry: {
|
|
149
|
+
kind: 'http',
|
|
150
|
+
action: EventAction.ADD_ATTR,
|
|
151
|
+
name: 'request',
|
|
152
|
+
id: 'req-1',
|
|
153
|
+
parentId: 'ctx-p',
|
|
154
|
+
timestamp: expect.any( Number ),
|
|
155
|
+
details: attribute
|
|
156
|
+
}
|
|
157
|
+
} );
|
|
158
|
+
} );
|
|
159
|
+
|
|
160
|
+
it( 'addEventActionWithContext() throws on invalid ADD_ATTR signal payloads', async () => {
|
|
161
|
+
process.env.OUTPUT_TRACE_LOCAL_ON = 'true';
|
|
162
|
+
const sendAttributeSignalMock = vi.fn();
|
|
163
|
+
storageLoadMock.mockReturnValue( {
|
|
164
|
+
parentId: 'ctx-p',
|
|
165
|
+
executionContext: { runId: 'r1', disableTrace: false },
|
|
166
|
+
sendAttributeSignal: sendAttributeSignalMock
|
|
167
|
+
} );
|
|
168
|
+
const { init, addEventActionWithContext } = await loadTraceEngine();
|
|
169
|
+
const { EventAction } = await import( './trace_consts.js' );
|
|
170
|
+
await init();
|
|
171
|
+
|
|
172
|
+
const invalidAttribute = { type: 'not-a-base-attribute' };
|
|
173
|
+
expect( () => addEventActionWithContext(
|
|
174
|
+
EventAction.ADD_ATTR,
|
|
175
|
+
{ kind: 'http', name: 'request', id: 'req-1', details: invalidAttribute }
|
|
176
|
+
) ).toThrow( /not a BaseAttribute instance/ );
|
|
177
|
+
|
|
178
|
+
expect( sendAttributeSignalMock ).not.toHaveBeenCalled();
|
|
179
|
+
expect( localExecMock ).not.toHaveBeenCalled();
|
|
180
|
+
} );
|
|
181
|
+
|
|
120
182
|
it( 'addEventActionWithContext() does not emit when storage executionContext.disableTrace is true', async () => {
|
|
121
183
|
process.env.OUTPUT_TRACE_LOCAL_ON = '1';
|
|
122
184
|
storageLoadMock.mockReturnValue( {
|
package/src/utils/index.d.ts
CHANGED
|
@@ -132,3 +132,16 @@ export function deepMerge( a: object, b: object ): object;
|
|
|
132
132
|
* @returns Short string using A–Z, a–z, 0–9, `_`, `-` (typically 21–22 chars).
|
|
133
133
|
*/
|
|
134
134
|
export function toUrlSafeBase64( uuid: string ): string;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Similar to native Promise.allSettled, but rejects with `{ isTimeout: true }`
|
|
138
|
+
* if the execution exceeds the given timeout.
|
|
139
|
+
*
|
|
140
|
+
* @param promises - Values or promises to wait for.
|
|
141
|
+
* @param timeoutMs - Maximum wait time in milliseconds.
|
|
142
|
+
* @returns Native Promise.allSettled-style results.
|
|
143
|
+
*/
|
|
144
|
+
export function allSettledWithTimeout<T>(
|
|
145
|
+
promises: Array<T | PromiseLike<T>>,
|
|
146
|
+
timeoutMs: number
|
|
147
|
+
): Promise<PromiseSettledResult<Awaited<T>>[]>;
|
package/src/utils/utils.js
CHANGED
|
@@ -209,3 +209,42 @@ export const toUrlSafeBase64 = uuid => {
|
|
|
209
209
|
const toDigits = n => n <= 0n ? [] : toDigits( n / base ).concat( alphabet[Number( n % base )] );
|
|
210
210
|
return toDigits( BigInt( '0x' + hex ) ).join( '' );
|
|
211
211
|
};
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Similar to native Promise.allSettled but throws an Error if the execution exceeds a given time.
|
|
215
|
+
*
|
|
216
|
+
* The error thrown will have attribute `.isTimeout` as `true`.
|
|
217
|
+
*
|
|
218
|
+
* @template T
|
|
219
|
+
* @param {Array<T | PromiseLike<T>>} promises
|
|
220
|
+
* @param {number} timeoutMs
|
|
221
|
+
* @returns {Promise<PromiseSettledResult<Awaited<T>>[]>}
|
|
222
|
+
* @throws {Error & { isTimeout: true }}
|
|
223
|
+
*/
|
|
224
|
+
export const allSettledWithTimeout = ( () => {
|
|
225
|
+
class TimeoutError extends Error {
|
|
226
|
+
isTimeout = true;
|
|
227
|
+
constructor() {
|
|
228
|
+
super( 'Timed out before completing all promises' );
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return async ( promises, timeoutMs ) => {
|
|
233
|
+
if ( promises.length === 0 ) {
|
|
234
|
+
return [];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const state = { timeoutMonitor: null };
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
return await Promise.race( [
|
|
241
|
+
Promise.allSettled( promises ),
|
|
242
|
+
new Promise( ( _, reject ) => {
|
|
243
|
+
state.timeoutMonitor = setTimeout( () => reject( new TimeoutError() ), timeoutMs );
|
|
244
|
+
} )
|
|
245
|
+
] );
|
|
246
|
+
} finally {
|
|
247
|
+
clearTimeout( state.timeoutMonitor );
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
} )();
|
package/src/utils/utils.spec.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
2
|
import { Readable } from 'node:stream';
|
|
3
3
|
import {
|
|
4
4
|
clone,
|
|
@@ -6,7 +6,8 @@ import {
|
|
|
6
6
|
serializeFetchResponse,
|
|
7
7
|
deepMerge,
|
|
8
8
|
isPlainObject,
|
|
9
|
-
toUrlSafeBase64
|
|
9
|
+
toUrlSafeBase64,
|
|
10
|
+
allSettledWithTimeout
|
|
10
11
|
} from './utils.js';
|
|
11
12
|
|
|
12
13
|
describe( 'clone', () => {
|
|
@@ -22,6 +23,44 @@ describe( 'clone', () => {
|
|
|
22
23
|
} );
|
|
23
24
|
} );
|
|
24
25
|
|
|
26
|
+
describe( 'allSettledWithTimeout', () => {
|
|
27
|
+
it( 'returns an empty array when no promises are provided', async () => {
|
|
28
|
+
await expect( allSettledWithTimeout( [], 100 ) ).resolves.toEqual( [] );
|
|
29
|
+
} );
|
|
30
|
+
|
|
31
|
+
it( 'returns native allSettled results when all promises settle before timeout', async () => {
|
|
32
|
+
const error = new Error( 'boom' );
|
|
33
|
+
const result = await allSettledWithTimeout( [
|
|
34
|
+
Promise.resolve( 'ok' ),
|
|
35
|
+
Promise.reject( error ),
|
|
36
|
+
42
|
|
37
|
+
], 100 );
|
|
38
|
+
|
|
39
|
+
expect( result ).toEqual( [
|
|
40
|
+
{ status: 'fulfilled', value: 'ok' },
|
|
41
|
+
{ status: 'rejected', reason: error },
|
|
42
|
+
{ status: 'fulfilled', value: 42 }
|
|
43
|
+
] );
|
|
44
|
+
} );
|
|
45
|
+
|
|
46
|
+
it( 'rejects with a timeout-shaped error when promises do not settle in time', async () => {
|
|
47
|
+
vi.useFakeTimers();
|
|
48
|
+
try {
|
|
49
|
+
const pending = new Promise( () => {} );
|
|
50
|
+
const result = allSettledWithTimeout( [ pending ], 1000 );
|
|
51
|
+
const assertion = expect( result ).rejects.toMatchObject( {
|
|
52
|
+
isTimeout: true,
|
|
53
|
+
message: 'Timed out before completing all promises'
|
|
54
|
+
} );
|
|
55
|
+
|
|
56
|
+
await vi.advanceTimersByTimeAsync( 1000 );
|
|
57
|
+
await assertion;
|
|
58
|
+
} finally {
|
|
59
|
+
vi.useRealTimers();
|
|
60
|
+
}
|
|
61
|
+
} );
|
|
62
|
+
} );
|
|
63
|
+
|
|
25
64
|
describe( 'serializeFetchResponse', () => {
|
|
26
65
|
it( 'serializes JSON response body and flattens headers', async () => {
|
|
27
66
|
const payload = { a: 1, b: 'two' };
|
|
@@ -2,10 +2,16 @@ import { Context } from '@temporalio/activity';
|
|
|
2
2
|
import { Storage } from '#async_storage';
|
|
3
3
|
import * as Tracing from '#tracing';
|
|
4
4
|
import { headersToObject } from '../sandboxed_utils.js';
|
|
5
|
-
import { BusEventType, METADATA_ACCESS_SYMBOL } from '#consts';
|
|
6
|
-
import { activityHeartbeatEnabled, activityHeartbeatIntervalMs } from '../configs.js';
|
|
5
|
+
import { BusEventType, METADATA_ACCESS_SYMBOL, Signal } from '#consts';
|
|
6
|
+
import { activityHeartbeatEnabled, activityHeartbeatIntervalMs, namespace } from '../configs.js';
|
|
7
7
|
import { messageBus } from '#bus';
|
|
8
8
|
import { Client } from '@temporalio/client';
|
|
9
|
+
import { createChildLogger } from '#logger';
|
|
10
|
+
import { allSettledWithTimeout } from '#utils';
|
|
11
|
+
|
|
12
|
+
const log = createChildLogger( 'ActivityInterceptor' );
|
|
13
|
+
|
|
14
|
+
const IN_FLIGHT_SIGNALS_TIMEOUT_MS = 30_000;
|
|
9
15
|
|
|
10
16
|
/*
|
|
11
17
|
This interceptor wraps every activity execution with cross-cutting concerns:
|
|
@@ -36,38 +42,87 @@ export class ActivityExecutionInterceptor {
|
|
|
36
42
|
this.connection = connection;
|
|
37
43
|
};
|
|
38
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Returns a workflow entry by its name or throws error
|
|
47
|
+
* @param {string} workflowName
|
|
48
|
+
* @returns {object} Workflow entry
|
|
49
|
+
* @throws {Error}
|
|
50
|
+
*/
|
|
51
|
+
getWorkflowEntry( workflowName ) {
|
|
52
|
+
const workflowEntry = this.workflowsMap.get( workflowName );
|
|
53
|
+
if ( !workflowEntry ) {
|
|
54
|
+
throw new Error( `Activity interceptor: workflow "${workflowName}" not found in workflowsMap.` );
|
|
55
|
+
}
|
|
56
|
+
return workflowEntry;
|
|
57
|
+
}
|
|
58
|
+
|
|
39
59
|
async execute( input, next ) {
|
|
40
60
|
const startDate = Date.now();
|
|
41
|
-
const client = new Client( { connection: this.connection } );
|
|
61
|
+
const client = new Client( { connection: this.connection, namespace } );
|
|
42
62
|
|
|
43
63
|
const { workflowExecution: { workflowId }, activityId: id, activityType: name, workflowType: workflowName } = Context.current().info;
|
|
44
64
|
const { executionContext } = headersToObject( input.headers );
|
|
45
65
|
const { type: kind } = this.activities?.[name]?.[METADATA_ACCESS_SYMBOL];
|
|
66
|
+
const { path: workflowFilename } = this.getWorkflowEntry( workflowName );
|
|
46
67
|
|
|
47
68
|
const workflowHandle = client.workflow.getHandle( workflowId );
|
|
48
69
|
|
|
70
|
+
const state = {
|
|
71
|
+
heartbeat: null,
|
|
72
|
+
activityOutput: undefined,
|
|
73
|
+
signals: []
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const errorContext = {
|
|
77
|
+
workflowId,
|
|
78
|
+
workflowName,
|
|
79
|
+
activityId: id,
|
|
80
|
+
activityName: name
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const sendAttributeSignal = attribute => {
|
|
84
|
+
attribute.setActivity( id, name );
|
|
85
|
+
state.signals.push(
|
|
86
|
+
workflowHandle
|
|
87
|
+
.signal( Signal.ADD_ATTRIBUTE, attribute )
|
|
88
|
+
.catch( e =>
|
|
89
|
+
log.warn( `Signal "${Signal.ADD_ATTRIBUTE}" failed`, { message: e.message, stack: e.stack, activityId: id, ...errorContext } )
|
|
90
|
+
)
|
|
91
|
+
);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const flushSignals = async signals => {
|
|
95
|
+
try {
|
|
96
|
+
await allSettledWithTimeout( signals, IN_FLIGHT_SIGNALS_TIMEOUT_MS );
|
|
97
|
+
} catch ( error ) {
|
|
98
|
+
if ( error.isTimeout ) {
|
|
99
|
+
log.warn( 'Some usage/cost attributes were missed because not all activity signals were sent to the workflow', errorContext );
|
|
100
|
+
} else {
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Wraps the execution with accessible metadata for the activity
|
|
107
|
+
const ctx = { parentId: id, executionContext, workflowFilename, sendAttributeSignal };
|
|
108
|
+
|
|
49
109
|
messageBus.emit( BusEventType.ACTIVITY_START, { id, name, kind, workflowId, workflowName } );
|
|
50
110
|
Tracing.addEventStart( { id, name, kind, parentId: workflowId, details: input.args[0], executionContext } );
|
|
51
111
|
|
|
52
|
-
const workflowEntry = this.workflowsMap.get( workflowName );
|
|
53
|
-
if ( !workflowEntry ) {
|
|
54
|
-
const availableWorkflows = [ ...this.workflowsMap.keys() ].join( ', ' );
|
|
55
|
-
throw new Error( `Activity interceptor: workflow "${workflowName}" not found in workflowsMap. Available: [${availableWorkflows}]` );
|
|
56
|
-
}
|
|
57
|
-
const workflowFilename = workflowEntry.path;
|
|
58
|
-
|
|
59
|
-
const intervals = { heartbeat: null };
|
|
60
112
|
try {
|
|
61
113
|
// Sends heartbeat to communicate that activity is still alive
|
|
62
|
-
|
|
114
|
+
state.heartbeat = activityHeartbeatEnabled && setInterval( () => Context.current().heartbeat(), activityHeartbeatIntervalMs );
|
|
63
115
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
116
|
+
try {
|
|
117
|
+
state.activityOutput = await Storage.runWithContext( async _ => next( input ), ctx );
|
|
118
|
+
} finally {
|
|
119
|
+
// Ensure in-flight signals are delivered (up to a reasonable time) before handling errors
|
|
120
|
+
await flushSignals( state.signals );
|
|
121
|
+
}
|
|
67
122
|
|
|
68
123
|
messageBus.emit( BusEventType.ACTIVITY_END, { id, name, kind, workflowId, workflowName, duration: Date.now() - startDate } );
|
|
69
|
-
Tracing.addEventEnd( { id, details:
|
|
70
|
-
return
|
|
124
|
+
Tracing.addEventEnd( { id, details: state.activityOutput, executionContext } );
|
|
125
|
+
return state.activityOutput;
|
|
71
126
|
|
|
72
127
|
} catch ( error ) {
|
|
73
128
|
messageBus.emit( BusEventType.ACTIVITY_ERROR, { id, name, kind, workflowId, workflowName, duration: Date.now() - startDate, error } );
|
|
@@ -75,7 +130,7 @@ export class ActivityExecutionInterceptor {
|
|
|
75
130
|
|
|
76
131
|
throw error;
|
|
77
132
|
} finally {
|
|
78
|
-
clearInterval(
|
|
133
|
+
clearInterval( state.heartbeat );
|
|
79
134
|
}
|
|
80
135
|
}
|
|
81
136
|
};
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { BusEventType } from '#consts';
|
|
2
|
+
import { BusEventType, Signal } from '#consts';
|
|
3
3
|
|
|
4
4
|
const METADATA_ACCESS_SYMBOL = vi.hoisted( () => Symbol( '__metadata' ) );
|
|
5
5
|
const workflowHandleMock = vi.hoisted( () => ( { signal: vi.fn() } ) );
|
|
6
6
|
const getHandleMock = vi.hoisted( () => vi.fn( () => workflowHandleMock ) );
|
|
7
|
+
const clientConstructorMock = vi.hoisted( () => vi.fn() );
|
|
8
|
+
const allSettledWithTimeoutMock = vi.hoisted( () => vi.fn().mockResolvedValue( [] ) );
|
|
9
|
+
const logWarnMock = vi.hoisted( () => vi.fn() );
|
|
7
10
|
|
|
8
11
|
const heartbeatMock = vi.fn();
|
|
9
12
|
const runWithContextMock = vi.hoisted( () => vi.fn().mockImplementation( async fn => fn() ) );
|
|
@@ -25,6 +28,10 @@ vi.mock( '@temporalio/activity', () => ( {
|
|
|
25
28
|
|
|
26
29
|
vi.mock( '@temporalio/client', () => ( {
|
|
27
30
|
Client: class Client {
|
|
31
|
+
constructor( options ) {
|
|
32
|
+
clientConstructorMock( options );
|
|
33
|
+
}
|
|
34
|
+
|
|
28
35
|
workflow = {
|
|
29
36
|
getHandle: getHandleMock
|
|
30
37
|
};
|
|
@@ -37,6 +44,15 @@ vi.mock( '#async_storage', () => ( {
|
|
|
37
44
|
}
|
|
38
45
|
} ) );
|
|
39
46
|
|
|
47
|
+
vi.mock( '#utils', async importOriginal => {
|
|
48
|
+
const actual = await importOriginal();
|
|
49
|
+
return { ...actual, allSettledWithTimeout: allSettledWithTimeoutMock };
|
|
50
|
+
} );
|
|
51
|
+
|
|
52
|
+
vi.mock( '#logger', () => ( {
|
|
53
|
+
createChildLogger: () => ( { warn: logWarnMock } )
|
|
54
|
+
} ) );
|
|
55
|
+
|
|
40
56
|
const addEventStartMock = vi.fn();
|
|
41
57
|
const addEventEndMock = vi.fn();
|
|
42
58
|
const addEventErrorMock = vi.fn();
|
|
@@ -68,6 +84,9 @@ vi.mock( '../configs.js', () => ( {
|
|
|
68
84
|
},
|
|
69
85
|
get activityHeartbeatIntervalMs() {
|
|
70
86
|
return parseInt( process.env.OUTPUT_ACTIVITY_HEARTBEAT_INTERVAL_MS || '120000', 10 );
|
|
87
|
+
},
|
|
88
|
+
get namespace() {
|
|
89
|
+
return process.env.TEMPORAL_NAMESPACE || 'default';
|
|
71
90
|
}
|
|
72
91
|
} ) );
|
|
73
92
|
|
|
@@ -85,6 +104,8 @@ const makeInput = () => ( {
|
|
|
85
104
|
describe( 'ActivityExecutionInterceptor', () => {
|
|
86
105
|
beforeEach( () => {
|
|
87
106
|
vi.clearAllMocks();
|
|
107
|
+
allSettledWithTimeoutMock.mockResolvedValue( [] );
|
|
108
|
+
workflowHandleMock.signal.mockResolvedValue( undefined );
|
|
88
109
|
vi.useFakeTimers();
|
|
89
110
|
vi.resetModules();
|
|
90
111
|
// Default: heartbeat enabled with 50ms interval for fast tests
|
|
@@ -116,19 +137,88 @@ describe( 'ActivityExecutionInterceptor', () => {
|
|
|
116
137
|
expect( addEventStartMock ).toHaveBeenCalledOnce();
|
|
117
138
|
expect( addEventEndMock ).toHaveBeenCalledOnce();
|
|
118
139
|
expect( addEventErrorMock ).not.toHaveBeenCalled();
|
|
140
|
+
expect( clientConstructorMock ).toHaveBeenCalledWith( { connection: undefined, namespace: 'default' } );
|
|
119
141
|
expect( runWithContextMock ).toHaveBeenCalledWith(
|
|
120
142
|
expect.any( Function ),
|
|
121
143
|
expect.objectContaining( {
|
|
122
144
|
parentId: 'act-1',
|
|
123
|
-
parentName: 'myWorkflow#myStep',
|
|
124
145
|
executionContext: { workflowId: 'wf-1' },
|
|
125
146
|
workflowFilename: '/workflows/myWorkflow.js',
|
|
126
|
-
|
|
147
|
+
sendAttributeSignal: expect.any( Function )
|
|
127
148
|
} )
|
|
128
149
|
);
|
|
129
150
|
expect( getHandleMock ).toHaveBeenCalledWith( 'wf-1' );
|
|
130
151
|
} );
|
|
131
152
|
|
|
153
|
+
it( 'handles next returning a non-Promise value', async () => {
|
|
154
|
+
const { ActivityExecutionInterceptor } = await import( './activity.js' );
|
|
155
|
+
const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
|
|
156
|
+
const next = vi.fn( () => ( { result: 'sync' } ) );
|
|
157
|
+
|
|
158
|
+
await expect( interceptor.execute( makeInput(), next ) ).resolves.toEqual( { result: 'sync' } );
|
|
159
|
+
|
|
160
|
+
expect( allSettledWithTimeoutMock ).toHaveBeenCalledWith( [], 30_000 );
|
|
161
|
+
expect( messageBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_END, expect.any( Object ) );
|
|
162
|
+
expect( addEventEndMock ).toHaveBeenCalledWith( { id: 'act-1', details: { result: 'sync' }, executionContext: { workflowId: 'wf-1' } } );
|
|
163
|
+
expect( addEventErrorMock ).not.toHaveBeenCalled();
|
|
164
|
+
} );
|
|
165
|
+
|
|
166
|
+
it( 'handles signal flush timeout after successful execution', async () => {
|
|
167
|
+
const timeoutError = Object.assign( new Error( 'timeout' ), { isTimeout: true } );
|
|
168
|
+
allSettledWithTimeoutMock.mockRejectedValueOnce( timeoutError );
|
|
169
|
+
const { ActivityExecutionInterceptor } = await import( './activity.js' );
|
|
170
|
+
const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
|
|
171
|
+
const next = vi.fn().mockResolvedValue( { result: 'ok' } );
|
|
172
|
+
|
|
173
|
+
await expect( interceptor.execute( makeInput(), next ) ).resolves.toEqual( { result: 'ok' } );
|
|
174
|
+
|
|
175
|
+
expect( allSettledWithTimeoutMock ).toHaveBeenCalledWith( [], 30_000 );
|
|
176
|
+
expect( logWarnMock ).toHaveBeenCalledWith(
|
|
177
|
+
'Some usage/cost attributes were missed because not all activity signals were sent to the workflow',
|
|
178
|
+
{ workflowId: 'wf-1', workflowName: 'myWorkflow', activityId: 'act-1', activityName: 'myWorkflow#myStep' }
|
|
179
|
+
);
|
|
180
|
+
expect( messageBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_END, expect.any( Object ) );
|
|
181
|
+
expect( addEventEndMock ).toHaveBeenCalledOnce();
|
|
182
|
+
expect( addEventErrorMock ).not.toHaveBeenCalled();
|
|
183
|
+
} );
|
|
184
|
+
|
|
185
|
+
it( 'handles signal flush timeout after failed execution', async () => {
|
|
186
|
+
const timeoutError = Object.assign( new Error( 'timeout' ), { isTimeout: true } );
|
|
187
|
+
allSettledWithTimeoutMock.mockRejectedValueOnce( timeoutError );
|
|
188
|
+
const { ActivityExecutionInterceptor } = await import( './activity.js' );
|
|
189
|
+
const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
|
|
190
|
+
const error = new Error( 'step failed' );
|
|
191
|
+
const next = vi.fn().mockRejectedValue( error );
|
|
192
|
+
|
|
193
|
+
await expect( interceptor.execute( makeInput(), next ) ).rejects.toThrow( 'step failed' );
|
|
194
|
+
|
|
195
|
+
expect( allSettledWithTimeoutMock ).toHaveBeenCalledWith( [], 30_000 );
|
|
196
|
+
expect( logWarnMock ).toHaveBeenCalledWith(
|
|
197
|
+
'Some usage/cost attributes were missed because not all activity signals were sent to the workflow',
|
|
198
|
+
{ workflowId: 'wf-1', workflowName: 'myWorkflow', activityId: 'act-1', activityName: 'myWorkflow#myStep' }
|
|
199
|
+
);
|
|
200
|
+
expect( messageBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_ERROR, expect.objectContaining( { error } ) );
|
|
201
|
+
expect( addEventErrorMock ).toHaveBeenCalledOnce();
|
|
202
|
+
expect( addEventEndMock ).not.toHaveBeenCalled();
|
|
203
|
+
} );
|
|
204
|
+
|
|
205
|
+
it( 'exposes sendAttributeSignal in activity context', async () => {
|
|
206
|
+
const attribute = { setActivity: vi.fn() };
|
|
207
|
+
runWithContextMock.mockImplementationOnce( async ( fn, ctx ) => {
|
|
208
|
+
ctx.sendAttributeSignal( attribute );
|
|
209
|
+
return fn();
|
|
210
|
+
} );
|
|
211
|
+
const { ActivityExecutionInterceptor } = await import( './activity.js' );
|
|
212
|
+
const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
|
|
213
|
+
const next = vi.fn().mockResolvedValue( { result: 'ok' } );
|
|
214
|
+
|
|
215
|
+
await expect( interceptor.execute( makeInput(), next ) ).resolves.toEqual( { result: 'ok' } );
|
|
216
|
+
|
|
217
|
+
expect( attribute.setActivity ).toHaveBeenCalledWith( 'act-1', 'myWorkflow#myStep' );
|
|
218
|
+
expect( workflowHandleMock.signal ).toHaveBeenCalledWith( Signal.ADD_ATTRIBUTE, attribute );
|
|
219
|
+
expect( allSettledWithTimeoutMock ).toHaveBeenCalledWith( [ expect.any( Promise ) ], 30_000 );
|
|
220
|
+
} );
|
|
221
|
+
|
|
132
222
|
it( 'records trace error event on failed execution', async () => {
|
|
133
223
|
const { ActivityExecutionInterceptor } = await import( './activity.js' );
|
|
134
224
|
const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
|