@outputai/core 0.7.1-next.de30052.0 → 0.8.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/bin/worker.sh +6 -0
- package/package.json +1 -1
- package/src/consts.js +0 -4
- package/src/errors.js +6 -2
- package/src/interface/evaluator.js +7 -20
- package/src/interface/evaluator.spec.js +117 -1
- package/src/interface/step.js +8 -9
- package/src/interface/step.spec.js +124 -0
- package/src/interface/validations/index.js +108 -0
- package/src/interface/validations/index.spec.js +182 -0
- package/src/interface/validations/schemas.js +113 -0
- package/src/interface/validations/schemas.spec.js +209 -0
- package/src/interface/webhook.js +1 -1
- package/src/interface/webhook.spec.js +1 -1
- package/src/interface/workflow.d.ts +10 -9
- package/src/interface/workflow.js +76 -164
- package/src/interface/workflow.spec.js +637 -521
- package/src/interface/workflow_activity_options.js +16 -0
- package/src/interface/workflow_utils.js +1 -1
- package/src/interface/zod_integration.spec.js +2 -2
- package/src/internal_utils/aggregations.js +0 -10
- package/src/internal_utils/aggregations.spec.js +1 -48
- package/src/internal_utils/errors.js +14 -8
- package/src/internal_utils/errors.spec.js +73 -27
- package/src/utils/index.d.ts +19 -0
- package/src/utils/utils.js +53 -0
- package/src/utils/utils.spec.js +105 -1
- package/src/worker/bundle.js +26 -0
- package/src/worker/bundle.spec.js +53 -0
- package/src/worker/bundler_options.js +1 -1
- package/src/worker/bundler_options.spec.js +1 -1
- package/src/worker/catalog_workflow/catalog_job.js +148 -0
- package/src/worker/catalog_workflow/catalog_job.spec.js +232 -0
- package/src/worker/check.js +24 -0
- package/src/worker/connection_monitor.js +112 -0
- package/src/worker/connection_monitor.spec.js +199 -0
- package/src/worker/index.js +146 -41
- package/src/worker/index.spec.js +281 -109
- package/src/worker/interceptors/activity.js +7 -24
- package/src/worker/interceptors/activity.spec.js +97 -66
- package/src/worker/interceptors/index.js +4 -7
- package/src/worker/interceptors/modules.js +15 -0
- package/src/worker/interceptors/workflow.js +6 -8
- package/src/worker/interceptors/workflow.spec.js +49 -42
- package/src/worker/interruption.js +33 -0
- package/src/worker/interruption.spec.js +98 -0
- package/src/worker/loader/activities.js +75 -0
- package/src/worker/loader/activities.spec.js +213 -0
- package/src/worker/loader/hooks.js +28 -0
- package/src/worker/loader/hooks.spec.js +64 -0
- package/src/worker/loader/matchers.js +46 -0
- package/src/worker/loader/matchers.spec.js +140 -0
- package/src/worker/{loader_tools.js → loader/tools.js} +19 -67
- package/src/worker/{loader_tools.spec.js → loader/tools.spec.js} +53 -85
- package/src/worker/loader/workflows.js +82 -0
- package/src/worker/loader/workflows.spec.js +256 -0
- package/src/worker/{setup_telemetry.js → telemetry.js} +9 -4
- package/src/worker/{setup_telemetry.spec.js → telemetry.spec.js} +3 -3
- package/src/worker/webpack_loaders/workflow_rewriter/collect_target_imports.js +5 -109
- package/src/worker/webpack_loaders/workflow_rewriter/collect_target_imports.spec.js +31 -103
- package/src/worker/webpack_loaders/workflow_rewriter/index.mjs +5 -6
- package/src/worker/webpack_loaders/workflow_rewriter/index.spec.js +11 -83
- package/src/worker/webpack_loaders/workflow_rewriter/rewrite_fn_bodies.js +8 -11
- package/src/worker/webpack_loaders/workflow_rewriter/rewrite_fn_bodies.spec.js +9 -9
- package/src/interface/validations/runtime.js +0 -20
- package/src/interface/validations/runtime.spec.js +0 -29
- package/src/interface/validations/schema_utils.js +0 -8
- package/src/interface/validations/schema_utils.spec.js +0 -67
- package/src/interface/validations/static.js +0 -137
- package/src/interface/validations/static.spec.js +0 -397
- package/src/interface/workflow.replay_compatibility.spec.js +0 -254
- package/src/worker/loader.js +0 -202
- package/src/worker/loader.spec.js +0 -498
- package/src/worker/shutdown.js +0 -26
- package/src/worker/shutdown.spec.js +0 -82
- package/src/worker/start_catalog.js +0 -96
- package/src/worker/start_catalog.spec.js +0 -179
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import { ApplicationFailure } from '@temporalio/common';
|
|
3
|
+
import { ACTIVITY_WRAPPER_VERSION_FIELD, BusEventType } from '#consts';
|
|
3
4
|
import { Attribute } from '#trace_attribute';
|
|
4
5
|
|
|
5
6
|
const METADATA_ACCESS_SYMBOL = vi.hoisted( () => Symbol( '__metadata' ) );
|
|
6
|
-
const workflowHandleMock = vi.hoisted( () => ( { signal: vi.fn() } ) );
|
|
7
|
-
const getHandleMock = vi.hoisted( () => vi.fn( () => workflowHandleMock ) );
|
|
8
|
-
const clientConstructorMock = vi.hoisted( () => vi.fn() );
|
|
9
|
-
const logWarnMock = vi.hoisted( () => vi.fn() );
|
|
10
7
|
const heartbeatMock = vi.hoisted( () => vi.fn() );
|
|
11
8
|
const runWithContextMock = vi.hoisted( () => vi.fn().mockImplementation( async fn => fn() ) );
|
|
12
9
|
const activityInfoMock = vi.hoisted( () => ( {
|
|
@@ -32,27 +29,19 @@ const workflowDetailsMock = vi.hoisted( () => ( {
|
|
|
32
29
|
attempt: 1
|
|
33
30
|
} ) );
|
|
34
31
|
|
|
35
|
-
vi.mock( '@temporalio/activity',
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
vi.mock( '@temporalio/client', () => ( {
|
|
46
|
-
Client: class Client {
|
|
47
|
-
constructor( options ) {
|
|
48
|
-
clientConstructorMock( options );
|
|
32
|
+
vi.mock( '@temporalio/activity', async importOriginal => {
|
|
33
|
+
const actual = await importOriginal();
|
|
34
|
+
return {
|
|
35
|
+
...actual,
|
|
36
|
+
activityInfo: () => activityInfoMock,
|
|
37
|
+
Context: {
|
|
38
|
+
current: () => ( {
|
|
39
|
+
info: activityInfoMock,
|
|
40
|
+
heartbeat: heartbeatMock
|
|
41
|
+
} )
|
|
49
42
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
getHandle: getHandleMock
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
} ) );
|
|
43
|
+
};
|
|
44
|
+
} );
|
|
56
45
|
|
|
57
46
|
vi.mock( '#async_storage', () => ( {
|
|
58
47
|
Storage: {
|
|
@@ -60,10 +49,6 @@ vi.mock( '#async_storage', () => ( {
|
|
|
60
49
|
}
|
|
61
50
|
} ) );
|
|
62
51
|
|
|
63
|
-
vi.mock( '#logger', () => ( {
|
|
64
|
-
createChildLogger: () => ( { warn: logWarnMock } )
|
|
65
|
-
} ) );
|
|
66
|
-
|
|
67
52
|
const addEventStartMock = vi.fn();
|
|
68
53
|
const addEventEndMock = vi.fn();
|
|
69
54
|
const addEventErrorMock = vi.fn();
|
|
@@ -95,9 +80,6 @@ vi.mock( '../configs.js', () => ( {
|
|
|
95
80
|
},
|
|
96
81
|
get activityHeartbeatIntervalMs() {
|
|
97
82
|
return parseInt( process.env.OUTPUT_ACTIVITY_HEARTBEAT_INTERVAL_MS || '120000', 10 );
|
|
98
|
-
},
|
|
99
|
-
get namespace() {
|
|
100
|
-
return process.env.TEMPORAL_NAMESPACE || 'default';
|
|
101
83
|
}
|
|
102
84
|
} ) );
|
|
103
85
|
|
|
@@ -122,7 +104,6 @@ describe( 'ActivityExecutionInterceptor', () => {
|
|
|
122
104
|
beforeEach( () => {
|
|
123
105
|
vi.clearAllMocks();
|
|
124
106
|
activityInfoMock.workflowType = 'myWorkflow';
|
|
125
|
-
workflowHandleMock.signal.mockResolvedValue( undefined );
|
|
126
107
|
vi.useFakeTimers();
|
|
127
108
|
vi.resetModules();
|
|
128
109
|
// Default: heartbeat enabled with 50ms interval for fast tests
|
|
@@ -167,7 +148,6 @@ describe( 'ActivityExecutionInterceptor', () => {
|
|
|
167
148
|
} );
|
|
168
149
|
expect( addEventEndMock ).toHaveBeenCalledWith( { id: 'act-1', details: { result: 'ok' }, traceInfo: traceInfoMock } );
|
|
169
150
|
expect( addEventErrorMock ).not.toHaveBeenCalled();
|
|
170
|
-
expect( clientConstructorMock ).not.toHaveBeenCalled();
|
|
171
151
|
expect( runWithContextMock ).toHaveBeenCalledWith(
|
|
172
152
|
expect.any( Function ),
|
|
173
153
|
expect.objectContaining( {
|
|
@@ -180,7 +160,6 @@ describe( 'ActivityExecutionInterceptor', () => {
|
|
|
180
160
|
addAttribute: expect.any( Function )
|
|
181
161
|
} )
|
|
182
162
|
);
|
|
183
|
-
expect( getHandleMock ).not.toHaveBeenCalled();
|
|
184
163
|
} );
|
|
185
164
|
|
|
186
165
|
it( 'handles next returning a non-Promise value', async () => {
|
|
@@ -199,7 +178,7 @@ describe( 'ActivityExecutionInterceptor', () => {
|
|
|
199
178
|
expect( addEventErrorMock ).not.toHaveBeenCalled();
|
|
200
179
|
} );
|
|
201
180
|
|
|
202
|
-
it( '
|
|
181
|
+
it( 'returns collected aggregations after successful execution', async () => {
|
|
203
182
|
runWithContextMock.mockImplementationOnce( async ( fn, ctx ) => {
|
|
204
183
|
ctx.addAttribute( httpRequestAttribute );
|
|
205
184
|
return fn();
|
|
@@ -218,11 +197,9 @@ describe( 'ActivityExecutionInterceptor', () => {
|
|
|
218
197
|
[ACTIVITY_WRAPPER_VERSION_FIELD]: 1
|
|
219
198
|
} );
|
|
220
199
|
|
|
221
|
-
expect( workflowHandleMock.signal ).not.toHaveBeenCalled();
|
|
222
|
-
expect( clientConstructorMock ).not.toHaveBeenCalled();
|
|
223
200
|
} );
|
|
224
201
|
|
|
225
|
-
it( '
|
|
202
|
+
it( 'stores collected aggregations in ApplicationFailure details after failed execution', async () => {
|
|
226
203
|
runWithContextMock.mockImplementationOnce( async ( fn, ctx ) => {
|
|
227
204
|
ctx.addAttribute( httpRequestAttribute );
|
|
228
205
|
return fn();
|
|
@@ -232,52 +209,106 @@ describe( 'ActivityExecutionInterceptor', () => {
|
|
|
232
209
|
const error = new Error( 'step failed' );
|
|
233
210
|
const next = vi.fn().mockRejectedValue( error );
|
|
234
211
|
|
|
235
|
-
await
|
|
236
|
-
|
|
237
|
-
expect(
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
212
|
+
const thrown = await interceptor.execute( makeInput(), next ).catch( e => e );
|
|
213
|
+
expect( thrown ).toBeInstanceOf( ApplicationFailure );
|
|
214
|
+
expect( thrown ).toMatchObject( {
|
|
215
|
+
message: 'step failed',
|
|
216
|
+
type: 'Error',
|
|
217
|
+
details: [ {
|
|
218
|
+
aggregations: {
|
|
219
|
+
cost: { total: 0 },
|
|
220
|
+
tokens: { total: 0 },
|
|
221
|
+
httpRequests: { total: 1 }
|
|
222
|
+
}
|
|
223
|
+
} ],
|
|
224
|
+
cause: error
|
|
243
225
|
} );
|
|
244
226
|
expect( messageBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_ERROR, expect.objectContaining( { error } ) );
|
|
245
227
|
expect( addEventErrorMock ).toHaveBeenCalledOnce();
|
|
246
228
|
expect( addEventEndMock ).not.toHaveBeenCalled();
|
|
247
229
|
} );
|
|
248
230
|
|
|
249
|
-
it( '
|
|
231
|
+
it( 'appends collected aggregations to existing failure details', async () => {
|
|
232
|
+
runWithContextMock.mockImplementationOnce( async ( fn, ctx ) => {
|
|
233
|
+
ctx.addAttribute( httpRequestAttribute );
|
|
234
|
+
return fn();
|
|
235
|
+
} );
|
|
250
236
|
const { ActivityExecutionInterceptor } = await import( './activity.js' );
|
|
251
237
|
const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
|
|
252
|
-
const
|
|
238
|
+
const error = new Error( 'step failed' );
|
|
239
|
+
error.details = [ { domain: { reason: 'bad-input' } } ];
|
|
240
|
+
const next = vi.fn().mockRejectedValue( error );
|
|
241
|
+
|
|
242
|
+
const thrown = await interceptor.execute( makeInput(), next ).catch( e => e );
|
|
243
|
+
|
|
244
|
+
expect( thrown.details ).toEqual( [
|
|
245
|
+
{ domain: { reason: 'bad-input' } },
|
|
246
|
+
{
|
|
247
|
+
aggregations: {
|
|
248
|
+
cost: { total: 0 },
|
|
249
|
+
tokens: { total: 0 },
|
|
250
|
+
httpRequests: { total: 1 }
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
] );
|
|
254
|
+
} );
|
|
253
255
|
|
|
254
|
-
|
|
256
|
+
it( 'rethrows the original error when failed execution collected no attributes', async () => {
|
|
257
|
+
const { ActivityExecutionInterceptor } = await import( './activity.js' );
|
|
258
|
+
const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
|
|
259
|
+
const error = new Error( 'step failed' );
|
|
260
|
+
const next = vi.fn().mockRejectedValue( error );
|
|
255
261
|
|
|
256
|
-
expect(
|
|
257
|
-
expect( clientConstructorMock ).not.toHaveBeenCalled();
|
|
262
|
+
await expect( interceptor.execute( makeInput(), next ) ).rejects.toBe( error );
|
|
258
263
|
} );
|
|
259
264
|
|
|
260
|
-
it( '
|
|
261
|
-
const
|
|
262
|
-
|
|
265
|
+
it( 'rethrows the original error with existing details when failed execution collected no attributes', async () => {
|
|
266
|
+
const { ActivityExecutionInterceptor } = await import( './activity.js' );
|
|
267
|
+
const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
|
|
268
|
+
const error = new Error( 'step failed' );
|
|
269
|
+
error.details = [ { domain: { reason: 'bad-input' } } ];
|
|
270
|
+
const next = vi.fn().mockRejectedValue( error );
|
|
271
|
+
|
|
272
|
+
await expect( interceptor.execute( makeInput(), next ) ).rejects.toBe( error );
|
|
273
|
+
expect( error.details ).toEqual( [ { domain: { reason: 'bad-input' } } ] );
|
|
274
|
+
} );
|
|
275
|
+
|
|
276
|
+
it( 'wraps existing ApplicationFailure without creating a self-cause', async () => {
|
|
263
277
|
runWithContextMock.mockImplementationOnce( async ( fn, ctx ) => {
|
|
264
278
|
ctx.addAttribute( httpRequestAttribute );
|
|
265
279
|
return fn();
|
|
266
280
|
} );
|
|
281
|
+
const error = ApplicationFailure.create( {
|
|
282
|
+
message: 'application failed',
|
|
283
|
+
type: 'OriginalType',
|
|
284
|
+
nonRetryable: true,
|
|
285
|
+
details: [ { domain: { reason: 'bad-input' } } ]
|
|
286
|
+
} );
|
|
267
287
|
const { ActivityExecutionInterceptor } = await import( './activity.js' );
|
|
268
288
|
const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
|
|
269
|
-
const next = vi.fn().mockRejectedValue(
|
|
270
|
-
|
|
271
|
-
await expect( interceptor.execute( makeInput(), next ) ).rejects.toThrow( 'step failed' );
|
|
289
|
+
const next = vi.fn().mockRejectedValue( error );
|
|
272
290
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
291
|
+
const thrown = await interceptor.execute( makeInput(), next ).catch( e => e );
|
|
292
|
+
|
|
293
|
+
expect( thrown ).toBeInstanceOf( ApplicationFailure );
|
|
294
|
+
expect( thrown ).not.toBe( error );
|
|
295
|
+
expect( thrown.cause ).toBe( error );
|
|
296
|
+
expect( thrown.cause ).not.toBe( thrown );
|
|
297
|
+
expect( thrown ).toMatchObject( {
|
|
298
|
+
message: 'application failed',
|
|
299
|
+
type: 'OriginalType',
|
|
300
|
+
nonRetryable: true,
|
|
301
|
+
details: [
|
|
302
|
+
{ domain: { reason: 'bad-input' } },
|
|
303
|
+
{
|
|
304
|
+
aggregations: {
|
|
305
|
+
cost: { total: 0 },
|
|
306
|
+
tokens: { total: 0 },
|
|
307
|
+
httpRequests: { total: 1 }
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
]
|
|
311
|
+
} );
|
|
281
312
|
} );
|
|
282
313
|
|
|
283
314
|
it( 'records trace error event on failed execution', async () => {
|
|
@@ -1,14 +1,11 @@
|
|
|
1
|
-
import { dirname, join } from 'path';
|
|
2
|
-
import { fileURLToPath } from 'node:url';
|
|
3
1
|
import { ActivityExecutionInterceptor } from './activity.js';
|
|
2
|
+
import { workflowInterceptorModules } from './modules.js';
|
|
4
3
|
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
export const initInterceptors = ( { activities, workflows, connection } ) => ( {
|
|
8
|
-
workflowModules: [ join( __dirname, './workflow.js' ) ],
|
|
4
|
+
export const initInterceptors = ( { activities, workflows } ) => ( {
|
|
5
|
+
workflowModules: workflowInterceptorModules,
|
|
9
6
|
activity: [
|
|
10
7
|
() => ( {
|
|
11
|
-
inbound: new ActivityExecutionInterceptor( { activities, workflows
|
|
8
|
+
inbound: new ActivityExecutionInterceptor( { activities, workflows } )
|
|
12
9
|
} )
|
|
13
10
|
]
|
|
14
11
|
} );
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { dirname, join } from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
const __dirname = dirname( fileURLToPath( import.meta.url ) );
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Workflow-side interceptor modules bundled into the workflow code.
|
|
8
|
+
*
|
|
9
|
+
* Kept in its own module — free of activity/config imports — so the bundle check
|
|
10
|
+
* (`bundleWorkflows`) can register the exact same modules as the worker without
|
|
11
|
+
* pulling in worker runtime config (e.g. the OUTPUT_CATALOG_ID env validation).
|
|
12
|
+
* This is the single source of truth shared by `initInterceptors` and the check,
|
|
13
|
+
* keeping `output-worker --check` in parity with `Worker.create`.
|
|
14
|
+
*/
|
|
15
|
+
export const workflowInterceptorModules = [ join( __dirname, './workflow.js' ) ];
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// THIS RUNS IN THE TEMPORAL'S SANDBOX ENVIRONMENT
|
|
2
|
-
import { workflowInfo, proxySinks,
|
|
2
|
+
import { workflowInfo, proxySinks, ContinueAsNew, isCancellation } from '@temporalio/workflow';
|
|
3
3
|
import { memoToHeaders } from './headers.js';
|
|
4
4
|
import { deepMerge } from '#utils';
|
|
5
|
+
import { buildApplicationFailureWithDetails } from '#internal_utils/errors';
|
|
5
6
|
import { METADATA_ACCESS_SYMBOL, WorkflowSpecialOutput } from '#consts';
|
|
7
|
+
import { createWorkflowDetails } from '#internal_utils/temporal_context';
|
|
8
|
+
|
|
6
9
|
// this is a dynamic generated file with activity configs overwrites
|
|
7
10
|
import stepOptions from '../temp/__activity_options.js';
|
|
8
|
-
import { createWorkflowDetails } from '#internal_utils/temporal_context';
|
|
9
11
|
|
|
10
12
|
/*
|
|
11
13
|
This interceptor adds Memo and serialized workflowInfo() to the Activity invocation headers.
|
|
@@ -55,17 +57,13 @@ class WorkflowExecutionInterceptor {
|
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
sinks.workflow.error( error );
|
|
58
|
-
const failure = new ApplicationFailure( error.message, error.constructor.name, undefined, undefined, error );
|
|
59
60
|
|
|
60
61
|
/*
|
|
61
|
-
*
|
|
62
|
+
* Add internal error .details to Temporal's ApplicationFailure .details
|
|
62
63
|
* This make it possible for this information be retrieved by Temporal's client instance.
|
|
63
64
|
* Ref: https://typescript.temporal.io/api/classes/common.ApplicationFailure#details
|
|
64
65
|
*/
|
|
65
|
-
|
|
66
|
-
failure.details = [ error[METADATA_ACCESS_SYMBOL] ];
|
|
67
|
-
}
|
|
68
|
-
throw failure;
|
|
66
|
+
throw error[METADATA_ACCESS_SYMBOL] ? buildApplicationFailureWithDetails( error, error[METADATA_ACCESS_SYMBOL] ) : error;
|
|
69
67
|
}
|
|
70
68
|
}
|
|
71
69
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
import { ApplicationFailure } from '@temporalio/common';
|
|
3
|
+
import { METADATA_ACCESS_SYMBOL } from '#consts';
|
|
4
4
|
|
|
5
5
|
const workflowInfoMock = vi.fn();
|
|
6
6
|
const workflowStartMock = vi.fn();
|
|
@@ -41,16 +41,6 @@ vi.mock( '@temporalio/workflow', () => ( {
|
|
|
41
41
|
proxySinks: () => ( {
|
|
42
42
|
workflow: { start: workflowStartMock, end: workflowEndMock, error: workflowErrorMock }
|
|
43
43
|
} ),
|
|
44
|
-
ApplicationFailure: class ApplicationFailure {
|
|
45
|
-
constructor( message, type, nonRetryable, cause, originalError ) {
|
|
46
|
-
this.message = message;
|
|
47
|
-
this.type = type;
|
|
48
|
-
this.nonRetryable = nonRetryable;
|
|
49
|
-
this.cause = cause;
|
|
50
|
-
this.originalError = originalError;
|
|
51
|
-
this.details = undefined;
|
|
52
|
-
}
|
|
53
|
-
},
|
|
54
44
|
ContinueAsNew: class ContinueAsNew extends Error {
|
|
55
45
|
constructor() {
|
|
56
46
|
super( 'ContinueAsNew' );
|
|
@@ -66,15 +56,6 @@ vi.mock( './headers.js', () => ( { memoToHeaders: ( ...args ) => memoToHeadersMo
|
|
|
66
56
|
const deepMergeMock = vi.fn( ( a, b ) => ( { ...( a || {} ), ...( b || {} ) } ) );
|
|
67
57
|
vi.mock( '#utils', () => ( { deepMerge: ( ...args ) => deepMergeMock( ...args ) } ) );
|
|
68
58
|
|
|
69
|
-
vi.mock( '#consts', async importOriginal => {
|
|
70
|
-
const actual = await importOriginal();
|
|
71
|
-
return {
|
|
72
|
-
...actual, get METADATA_ACCESS_SYMBOL() {
|
|
73
|
-
return METADATA_ACCESS_SYMBOL;
|
|
74
|
-
}
|
|
75
|
-
};
|
|
76
|
-
} );
|
|
77
|
-
|
|
78
59
|
const stepOptionsDefault = {};
|
|
79
60
|
vi.mock( '../temp/__activity_options.js', () => ( { default: stepOptionsDefault } ) );
|
|
80
61
|
|
|
@@ -150,7 +131,7 @@ describe( 'workflow interceptors', () => {
|
|
|
150
131
|
expect( workflowErrorMock ).not.toHaveBeenCalled();
|
|
151
132
|
} );
|
|
152
133
|
|
|
153
|
-
it( 'calls sinks.workflow.error and
|
|
134
|
+
it( 'calls sinks.workflow.error and rethrows workflow errors', async () => {
|
|
154
135
|
const { interceptors } = await import( './workflow.js' );
|
|
155
136
|
const { inbound } = interceptors();
|
|
156
137
|
const interceptor = inbound[0];
|
|
@@ -158,41 +139,68 @@ describe( 'workflow interceptors', () => {
|
|
|
158
139
|
const err = new Error( 'workflow failed' );
|
|
159
140
|
const next = vi.fn().mockRejectedValue( err );
|
|
160
141
|
|
|
161
|
-
await expect( interceptor.execute( input, next ) ).rejects.
|
|
142
|
+
await expect( interceptor.execute( input, next ) ).rejects.toBe( err );
|
|
143
|
+
expect( workflowStartMock ).toHaveBeenCalled();
|
|
144
|
+
expect( workflowErrorMock ).toHaveBeenCalledWith( err );
|
|
145
|
+
expect( workflowEndMock ).not.toHaveBeenCalled();
|
|
146
|
+
} );
|
|
147
|
+
|
|
148
|
+
it( 'wraps workflow errors with metadata in ApplicationFailure details', async () => {
|
|
149
|
+
const { interceptors } = await import( './workflow.js' );
|
|
150
|
+
const { inbound } = interceptors();
|
|
151
|
+
const interceptor = inbound[0];
|
|
152
|
+
const trace = { trace: { destinations: { local: '/tmp/trace' } } };
|
|
153
|
+
const err = new Error( 'workflow failed' );
|
|
154
|
+
err[METADATA_ACCESS_SYMBOL] = trace;
|
|
155
|
+
const next = vi.fn().mockRejectedValue( err );
|
|
156
|
+
|
|
157
|
+
const thrown = await interceptor.execute( { args: [ {} ] }, next ).catch( e => e );
|
|
158
|
+
|
|
159
|
+
expect( thrown ).toBeInstanceOf( ApplicationFailure );
|
|
160
|
+
expect( thrown ).toMatchObject( {
|
|
162
161
|
message: 'workflow failed',
|
|
163
162
|
type: 'Error',
|
|
164
|
-
|
|
163
|
+
details: [ trace ],
|
|
164
|
+
cause: err
|
|
165
165
|
} );
|
|
166
|
-
expect( workflowStartMock ).toHaveBeenCalled();
|
|
167
166
|
expect( workflowErrorMock ).toHaveBeenCalledWith( err );
|
|
168
167
|
expect( workflowEndMock ).not.toHaveBeenCalled();
|
|
169
168
|
} );
|
|
170
169
|
|
|
171
|
-
it( '
|
|
170
|
+
it( 'preserves ApplicationFailure metadata when wrapping workflow errors with details', async () => {
|
|
172
171
|
const { interceptors } = await import( './workflow.js' );
|
|
173
|
-
const { ApplicationFailure } = await import( '@temporalio/workflow' );
|
|
174
172
|
const { inbound } = interceptors();
|
|
175
173
|
const interceptor = inbound[0];
|
|
176
|
-
const
|
|
177
|
-
const err =
|
|
178
|
-
|
|
174
|
+
const trace = { trace: { destinations: { local: '/tmp/trace' } } };
|
|
175
|
+
const err = ApplicationFailure.create( {
|
|
176
|
+
message: 'domain failed',
|
|
177
|
+
type: 'DomainFailure',
|
|
178
|
+
nonRetryable: true,
|
|
179
|
+
details: [ { domain: { reason: 'bad-input' } } ]
|
|
180
|
+
} );
|
|
181
|
+
err[METADATA_ACCESS_SYMBOL] = trace;
|
|
179
182
|
const next = vi.fn().mockRejectedValue( err );
|
|
180
183
|
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
184
|
+
const thrown = await interceptor.execute( { args: [ {} ] }, next ).catch( e => e );
|
|
185
|
+
|
|
186
|
+
expect( thrown ).toBeInstanceOf( ApplicationFailure );
|
|
187
|
+
expect( thrown ).not.toBe( err );
|
|
188
|
+
expect( thrown.cause ).toBe( err );
|
|
189
|
+
expect( thrown.cause ).not.toBe( thrown );
|
|
190
|
+
expect( thrown ).toMatchObject( {
|
|
191
|
+
message: 'domain failed',
|
|
192
|
+
type: 'DomainFailure',
|
|
193
|
+
nonRetryable: true,
|
|
194
|
+
details: [
|
|
195
|
+
{ domain: { reason: 'bad-input' } },
|
|
196
|
+
trace
|
|
197
|
+
]
|
|
198
|
+
} );
|
|
199
|
+
expect( workflowErrorMock ).toHaveBeenCalledWith( err );
|
|
191
200
|
} );
|
|
192
201
|
|
|
193
202
|
it( 'calls sinks.workflow.error and rethrows cancellation errors without wrapping', async () => {
|
|
194
203
|
const { interceptors } = await import( './workflow.js' );
|
|
195
|
-
const { ApplicationFailure } = await import( '@temporalio/workflow' );
|
|
196
204
|
const { inbound } = interceptors();
|
|
197
205
|
const interceptor = inbound[0];
|
|
198
206
|
const cancellation = new Error( 'Workflow cancelled' );
|
|
@@ -201,7 +209,6 @@ describe( 'workflow interceptors', () => {
|
|
|
201
209
|
|
|
202
210
|
await expect( interceptor.execute( { args: [ {} ] }, next ) ).rejects.toBe( cancellation );
|
|
203
211
|
expect( isCancellationMock ).toHaveBeenCalledWith( cancellation );
|
|
204
|
-
expect( cancellation ).not.toBeInstanceOf( ApplicationFailure );
|
|
205
212
|
expect( workflowErrorMock ).toHaveBeenCalledWith( cancellation );
|
|
206
213
|
expect( workflowEndMock ).not.toHaveBeenCalled();
|
|
207
214
|
} );
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createChildLogger } from '#logger';
|
|
2
|
+
|
|
3
|
+
const log = createChildLogger( 'Interruption' );
|
|
4
|
+
|
|
5
|
+
const FORCE_QUIT_GRACE_MS = 1000;
|
|
6
|
+
const INTERRUPTION_SIGNALS = [ 'SIGTERM', 'SIGINT', 'SIGUSR2' ];
|
|
7
|
+
|
|
8
|
+
export const setupInterruptionHandler = cb => {
|
|
9
|
+
const state = { interruptionReceivedAt: null };
|
|
10
|
+
|
|
11
|
+
const handle = signal => {
|
|
12
|
+
log.info( 'Signal Received', { signal } );
|
|
13
|
+
|
|
14
|
+
if ( state.interruptionReceivedAt ) {
|
|
15
|
+
const elapsed = Date.now() - state.interruptionReceivedAt;
|
|
16
|
+
|
|
17
|
+
// If running with npx, 2 kill signals are received in rapid succession,
|
|
18
|
+
// this ignores the second interruption when it is right after the first.
|
|
19
|
+
if ( elapsed < FORCE_QUIT_GRACE_MS ) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
log.warn( 'Force quitting...' );
|
|
23
|
+
process.exit( 1 );
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
state.interruptionReceivedAt = Date.now();
|
|
28
|
+
log.warn( 'Initiating shutdown...' );
|
|
29
|
+
cb();
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
INTERRUPTION_SIGNALS.forEach( signal => process.on( signal, () => handle( signal ) ) );
|
|
33
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { setupInterruptionHandler } from './interruption.js';
|
|
3
|
+
|
|
4
|
+
const { mockLog } = vi.hoisted( () => ( {
|
|
5
|
+
mockLog: { info: vi.fn(), warn: vi.fn() }
|
|
6
|
+
} ) );
|
|
7
|
+
|
|
8
|
+
vi.mock( '#logger', () => ( { createChildLogger: () => mockLog } ) );
|
|
9
|
+
|
|
10
|
+
describe( 'setupInterruptionHandler', () => {
|
|
11
|
+
const onHandlers = {};
|
|
12
|
+
const callback = vi.fn();
|
|
13
|
+
const exitMock = vi.fn();
|
|
14
|
+
const originalOn = process.on;
|
|
15
|
+
const originalExit = process.exit;
|
|
16
|
+
|
|
17
|
+
beforeEach( () => {
|
|
18
|
+
vi.clearAllMocks();
|
|
19
|
+
Object.keys( onHandlers ).forEach( key => delete onHandlers[key] );
|
|
20
|
+
process.on = vi.fn( ( event, handler ) => {
|
|
21
|
+
onHandlers[event] = handler;
|
|
22
|
+
} );
|
|
23
|
+
process.exit = exitMock;
|
|
24
|
+
} );
|
|
25
|
+
|
|
26
|
+
afterEach( () => {
|
|
27
|
+
vi.useRealTimers();
|
|
28
|
+
process.on = originalOn;
|
|
29
|
+
process.exit = originalExit;
|
|
30
|
+
} );
|
|
31
|
+
|
|
32
|
+
it( 'registers interruption signal handlers', () => {
|
|
33
|
+
setupInterruptionHandler( callback );
|
|
34
|
+
|
|
35
|
+
expect( process.on ).toHaveBeenCalledWith( 'SIGTERM', expect.any( Function ) );
|
|
36
|
+
expect( process.on ).toHaveBeenCalledWith( 'SIGINT', expect.any( Function ) );
|
|
37
|
+
expect( process.on ).toHaveBeenCalledWith( 'SIGUSR2', expect.any( Function ) );
|
|
38
|
+
} );
|
|
39
|
+
|
|
40
|
+
it( 'logs and invokes callback on first SIGTERM', () => {
|
|
41
|
+
setupInterruptionHandler( callback );
|
|
42
|
+
|
|
43
|
+
onHandlers.SIGTERM();
|
|
44
|
+
|
|
45
|
+
expect( mockLog.info ).toHaveBeenCalledWith( 'Signal Received', { signal: 'SIGTERM' } );
|
|
46
|
+
expect( mockLog.warn ).toHaveBeenCalledWith( 'Initiating shutdown...' );
|
|
47
|
+
expect( callback ).toHaveBeenCalledOnce();
|
|
48
|
+
expect( exitMock ).not.toHaveBeenCalled();
|
|
49
|
+
} );
|
|
50
|
+
|
|
51
|
+
it( 'logs and invokes callback on first SIGINT', () => {
|
|
52
|
+
setupInterruptionHandler( callback );
|
|
53
|
+
|
|
54
|
+
onHandlers.SIGINT();
|
|
55
|
+
|
|
56
|
+
expect( mockLog.info ).toHaveBeenCalledWith( 'Signal Received', { signal: 'SIGINT' } );
|
|
57
|
+
expect( mockLog.warn ).toHaveBeenCalledWith( 'Initiating shutdown...' );
|
|
58
|
+
expect( callback ).toHaveBeenCalledOnce();
|
|
59
|
+
expect( exitMock ).not.toHaveBeenCalled();
|
|
60
|
+
} );
|
|
61
|
+
|
|
62
|
+
it( 'logs and invokes callback on first SIGUSR2', () => {
|
|
63
|
+
setupInterruptionHandler( callback );
|
|
64
|
+
|
|
65
|
+
onHandlers.SIGUSR2();
|
|
66
|
+
|
|
67
|
+
expect( mockLog.info ).toHaveBeenCalledWith( 'Signal Received', { signal: 'SIGUSR2' } );
|
|
68
|
+
expect( mockLog.warn ).toHaveBeenCalledWith( 'Initiating shutdown...' );
|
|
69
|
+
expect( callback ).toHaveBeenCalledOnce();
|
|
70
|
+
expect( exitMock ).not.toHaveBeenCalled();
|
|
71
|
+
} );
|
|
72
|
+
|
|
73
|
+
it( 'ignores a second signal received within the grace period', () => {
|
|
74
|
+
vi.useFakeTimers();
|
|
75
|
+
setupInterruptionHandler( callback );
|
|
76
|
+
|
|
77
|
+
onHandlers.SIGTERM();
|
|
78
|
+
onHandlers.SIGINT();
|
|
79
|
+
|
|
80
|
+
expect( callback ).toHaveBeenCalledOnce();
|
|
81
|
+
expect( mockLog.warn ).toHaveBeenCalledTimes( 1 );
|
|
82
|
+
expect( mockLog.warn ).toHaveBeenCalledWith( 'Initiating shutdown...' );
|
|
83
|
+
expect( exitMock ).not.toHaveBeenCalled();
|
|
84
|
+
} );
|
|
85
|
+
|
|
86
|
+
it( 'force quits on a second signal after the grace period', () => {
|
|
87
|
+
vi.useFakeTimers();
|
|
88
|
+
setupInterruptionHandler( callback );
|
|
89
|
+
|
|
90
|
+
onHandlers.SIGTERM();
|
|
91
|
+
vi.advanceTimersByTime( 1001 );
|
|
92
|
+
onHandlers.SIGINT();
|
|
93
|
+
|
|
94
|
+
expect( callback ).toHaveBeenCalledOnce();
|
|
95
|
+
expect( mockLog.warn ).toHaveBeenCalledWith( 'Force quitting...' );
|
|
96
|
+
expect( exitMock ).toHaveBeenCalledWith( 1 );
|
|
97
|
+
} );
|
|
98
|
+
} );
|