@outputai/core 0.12.1-next.ce54578.0 → 0.13.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
CHANGED
|
@@ -3,17 +3,17 @@ import { createChildLogger } from '#logger';
|
|
|
3
3
|
export const pendingHooks = new Set();
|
|
4
4
|
|
|
5
5
|
const log = createChildLogger( 'Hooks' );
|
|
6
|
-
const flushTimeoutMs = 30_000;
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
8
|
* Await all pending hooks to flush for a certain time
|
|
9
|
+
* @param {number} timeoutMs - How long to await the pending hooks before giving up
|
|
10
10
|
*/
|
|
11
|
-
export const flushPendingHooks = async
|
|
11
|
+
export const flushPendingHooks = async timeoutMs => {
|
|
12
12
|
const state = { timeout: null };
|
|
13
13
|
try {
|
|
14
14
|
const flushed = await Promise.race( [
|
|
15
15
|
Promise.allSettled( [ ...pendingHooks ] ).then( _ => true ),
|
|
16
|
-
new Promise( r => state.timeout = setTimeout( () => r( false ),
|
|
16
|
+
new Promise( r => state.timeout = setTimeout( () => r( false ), timeoutMs ) )
|
|
17
17
|
] );
|
|
18
18
|
if ( !flushed ) {
|
|
19
19
|
log.warn( 'Some hook callbacks exceeded the timeout and will not be awaited', { count: pendingHooks.size } );
|
|
@@ -24,7 +24,7 @@ describe( 'pending hooks', () => {
|
|
|
24
24
|
} );
|
|
25
25
|
pendingHooks.add( hookPromise );
|
|
26
26
|
|
|
27
|
-
const flushPromise = flushPendingHooks();
|
|
27
|
+
const flushPromise = flushPendingHooks( 5000 );
|
|
28
28
|
const state = { flushed: false };
|
|
29
29
|
flushPromise.then( () => {
|
|
30
30
|
state.flushed = true;
|
|
@@ -42,8 +42,8 @@ describe( 'pending hooks', () => {
|
|
|
42
42
|
vi.useFakeTimers();
|
|
43
43
|
pendingHooks.add( new Promise( () => {} ) );
|
|
44
44
|
|
|
45
|
-
const flushPromise = flushPendingHooks();
|
|
46
|
-
await vi.advanceTimersByTimeAsync(
|
|
45
|
+
const flushPromise = flushPendingHooks( 5000 );
|
|
46
|
+
await vi.advanceTimersByTimeAsync( 5000 );
|
|
47
47
|
|
|
48
48
|
await expect( flushPromise ).resolves.toBeUndefined();
|
|
49
49
|
} );
|
|
@@ -53,8 +53,8 @@ describe( 'pending hooks', () => {
|
|
|
53
53
|
pendingHooks.add( new Promise( () => {} ) );
|
|
54
54
|
pendingHooks.add( new Promise( () => {} ) );
|
|
55
55
|
|
|
56
|
-
const flushPromise = flushPendingHooks();
|
|
57
|
-
await vi.advanceTimersByTimeAsync(
|
|
56
|
+
const flushPromise = flushPendingHooks( 5000 );
|
|
57
|
+
await vi.advanceTimersByTimeAsync( 5000 );
|
|
58
58
|
await flushPromise;
|
|
59
59
|
|
|
60
60
|
expect( logMock.warn ).toHaveBeenCalledWith( expect.any( String ), { count: 2 } );
|
package/src/worker/configs.js
CHANGED
|
@@ -36,10 +36,13 @@ const envVarSchema = z.object( {
|
|
|
36
36
|
OUTPUT_ACTIVITY_HEARTBEAT_INTERVAL_MS: z.preprocess( coalesceEmptyString, z.coerce.number().int().positive().default( 2 * 60 * 1000 ) ), // 2min
|
|
37
37
|
// Whether to send activity heartbeats (enabled by default)
|
|
38
38
|
OUTPUT_ACTIVITY_HEARTBEAT_ENABLED: z.transform( v => v === undefined ? true : isStringboolTrue( v ) ),
|
|
39
|
-
// Set temporal worker shutdown force time
|
|
40
|
-
|
|
39
|
+
// Set temporal worker shutdown force time. Defaults budget a shutdown against the 30s most
|
|
40
|
+
// platforms allow before SIGKILL: force time + OUTPUT_HOOK_FLUSH_TIMEOUT_MS + 5s of margin.
|
|
41
|
+
TEMPORAL_SHUTDOWN_FORCE_TIME: durationSchema.default( '20s' ),
|
|
41
42
|
// Set temporal worker shutdown grace time, when in-flight activities are asked to cancel
|
|
42
|
-
TEMPORAL_SHUTDOWN_GRACE_TIME: durationSchema.default( '
|
|
43
|
+
TEMPORAL_SHUTDOWN_GRACE_TIME: durationSchema.default( '15s' ),
|
|
44
|
+
// How long the worker awaits pending hook callbacks once the drain is over, before exiting anyway
|
|
45
|
+
OUTPUT_HOOK_FLUSH_TIMEOUT_MS: z.preprocess( coalesceEmptyString, z.coerce.number().int().positive().default( 5 * 1000 ) ), // 5s
|
|
43
46
|
// HTTP CONNECT proxy for Temporal gRPC connections (e.g. "proxy-host:8080").
|
|
44
47
|
// Must be a bare host:port — no scheme (Temporal's native HTTP CONNECT
|
|
45
48
|
// option is not a URL).
|
|
@@ -70,4 +73,5 @@ export const activityHeartbeatIntervalMs = envVars.OUTPUT_ACTIVITY_HEARTBEAT_INT
|
|
|
70
73
|
export const activityHeartbeatEnabled = envVars.OUTPUT_ACTIVITY_HEARTBEAT_ENABLED;
|
|
71
74
|
export const shutdownForceTime = envVars.TEMPORAL_SHUTDOWN_FORCE_TIME;
|
|
72
75
|
export const shutdownGraceTime = envVars.TEMPORAL_SHUTDOWN_GRACE_TIME;
|
|
76
|
+
export const hookFlushTimeoutMs = envVars.OUTPUT_HOOK_FLUSH_TIMEOUT_MS;
|
|
73
77
|
export const grpcProxy = envVars.TEMPORAL_GRPC_PROXY;
|
|
@@ -15,7 +15,8 @@ const CONFIG_KEYS = [
|
|
|
15
15
|
'OUTPUT_ACTIVITY_HEARTBEAT_INTERVAL_MS',
|
|
16
16
|
'OUTPUT_ACTIVITY_HEARTBEAT_ENABLED',
|
|
17
17
|
'TEMPORAL_SHUTDOWN_FORCE_TIME',
|
|
18
|
-
'TEMPORAL_SHUTDOWN_GRACE_TIME'
|
|
18
|
+
'TEMPORAL_SHUTDOWN_GRACE_TIME',
|
|
19
|
+
'OUTPUT_HOOK_FLUSH_TIMEOUT_MS'
|
|
19
20
|
];
|
|
20
21
|
|
|
21
22
|
const setEnv = ( overrides = {} ) => {
|
|
@@ -69,8 +70,9 @@ describe( 'worker/configs', () => {
|
|
|
69
70
|
expect( configs.workerTelemetryIntervalMs ).toBe( 0 );
|
|
70
71
|
expect( configs.activityHeartbeatIntervalMs ).toBe( 2 * 60 * 1000 );
|
|
71
72
|
expect( configs.activityHeartbeatEnabled ).toBe( true );
|
|
72
|
-
expect( configs.shutdownForceTime ).toBe( '
|
|
73
|
-
expect( configs.shutdownGraceTime ).toBe( '
|
|
73
|
+
expect( configs.shutdownForceTime ).toBe( '20s' );
|
|
74
|
+
expect( configs.shutdownGraceTime ).toBe( '15s' );
|
|
75
|
+
expect( configs.hookFlushTimeoutMs ).toBe( 5000 );
|
|
74
76
|
expect( configs.taskQueue ).toBe( 'test-catalog' );
|
|
75
77
|
expect( configs.catalogId ).toBe( 'test-catalog' );
|
|
76
78
|
} );
|
|
@@ -139,12 +141,14 @@ describe( 'worker/configs', () => {
|
|
|
139
141
|
it( 'falls back to the defaults when Temporal shutdown durations are empty', async () => {
|
|
140
142
|
setEnv( {
|
|
141
143
|
TEMPORAL_SHUTDOWN_FORCE_TIME: '',
|
|
142
|
-
TEMPORAL_SHUTDOWN_GRACE_TIME: ''
|
|
144
|
+
TEMPORAL_SHUTDOWN_GRACE_TIME: '',
|
|
145
|
+
OUTPUT_HOOK_FLUSH_TIMEOUT_MS: ''
|
|
143
146
|
} );
|
|
144
147
|
const configs = await loadConfigs();
|
|
145
148
|
|
|
146
|
-
expect( configs.shutdownForceTime ).toBe( '
|
|
147
|
-
expect( configs.shutdownGraceTime ).toBe( '
|
|
149
|
+
expect( configs.shutdownForceTime ).toBe( '20s' );
|
|
150
|
+
expect( configs.shutdownGraceTime ).toBe( '15s' );
|
|
151
|
+
expect( configs.hookFlushTimeoutMs ).toBe( 5000 );
|
|
148
152
|
} );
|
|
149
153
|
|
|
150
154
|
it( 'throws when Temporal shutdown durations are invalid', async () => {
|
package/src/worker/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Worker, NativeConnection } from '@temporalio/worker';
|
|
1
|
+
import { Worker, NativeConnection, GracefulShutdownPeriodExpiredError } from '@temporalio/worker';
|
|
2
2
|
import * as configs from './configs.js';
|
|
3
3
|
import { loadActivities } from './loader/activities.js';
|
|
4
4
|
import { loadWorkflows } from './loader/workflows.js';
|
|
@@ -41,6 +41,7 @@ const {
|
|
|
41
41
|
maxConcurrentWorkflowTaskPolls,
|
|
42
42
|
shutdownForceTime,
|
|
43
43
|
shutdownGraceTime,
|
|
44
|
+
hookFlushTimeoutMs,
|
|
44
45
|
workerTuner
|
|
45
46
|
} = configs;
|
|
46
47
|
|
|
@@ -171,9 +172,11 @@ execute()
|
|
|
171
172
|
}
|
|
172
173
|
|
|
173
174
|
log.info( 'Flushing hook callbacks...' );
|
|
174
|
-
await flushPendingHooks();
|
|
175
|
+
await flushPendingHooks( hookFlushTimeoutMs );
|
|
175
176
|
|
|
176
|
-
|
|
177
|
+
// An expired drain is the bound working as intended, not a worker failure, so it keeps exit code 0.
|
|
178
|
+
const workerFailedToStop = shutdownFailures.some( v => v.service === 'worker' && !( v.error instanceof GracefulShutdownPeriodExpiredError ) );
|
|
179
|
+
const exitCode = workerFailedToStop || hasError ? 1 : 0;
|
|
177
180
|
setTimeout( () => {
|
|
178
181
|
log.info( 'Bye' );
|
|
179
182
|
process.exit( exitCode );
|
package/src/worker/index.spec.js
CHANGED
|
@@ -8,6 +8,7 @@ const {
|
|
|
8
8
|
connectionMonitorInstance,
|
|
9
9
|
createCatalogMock,
|
|
10
10
|
flushPendingHooksMock,
|
|
11
|
+
GracefulShutdownPeriodExpiredError,
|
|
11
12
|
hashSourceCodeMock,
|
|
12
13
|
initInterceptorsMock,
|
|
13
14
|
interruption,
|
|
@@ -58,7 +59,8 @@ const {
|
|
|
58
59
|
maxConcurrentWorkflowTaskPolls: 5,
|
|
59
60
|
workerTuner: undefined,
|
|
60
61
|
shutdownForceTime: undefined,
|
|
61
|
-
shutdownGraceTime: undefined
|
|
62
|
+
shutdownGraceTime: undefined,
|
|
63
|
+
hookFlushTimeoutMs: 5000
|
|
62
64
|
};
|
|
63
65
|
|
|
64
66
|
/**
|
|
@@ -116,6 +118,11 @@ const {
|
|
|
116
118
|
name = 'UncaughtError';
|
|
117
119
|
};
|
|
118
120
|
|
|
121
|
+
/** Stands in for the Temporal error thrown when the drain outlives the force time */
|
|
122
|
+
class GracefulShutdownPeriodExpiredError extends Error {
|
|
123
|
+
name = 'GracefulShutdownPeriodExpiredError';
|
|
124
|
+
};
|
|
125
|
+
|
|
119
126
|
const interruption = { controller: null, KillSignError, UncaughtError };
|
|
120
127
|
|
|
121
128
|
return {
|
|
@@ -125,6 +132,7 @@ const {
|
|
|
125
132
|
connectionMonitorInstance,
|
|
126
133
|
createCatalogMock: vi.fn().mockReturnValue( { workflowNames: [ 'demo' ] } ),
|
|
127
134
|
flushPendingHooksMock: vi.fn().mockResolvedValue( undefined ),
|
|
135
|
+
GracefulShutdownPeriodExpiredError,
|
|
128
136
|
hashSourceCodeMock: vi.fn().mockResolvedValue( 'catalog-hash' ),
|
|
129
137
|
initInterceptorsMock: vi.fn().mockReturnValue( [] ),
|
|
130
138
|
interruption,
|
|
@@ -196,7 +204,8 @@ vi.mock( './catalog_workflow/catalog_publisher.js', () => ( {
|
|
|
196
204
|
vi.mock( './shutdown.js', () => ( { shutdownServices: shutdownServicesMock } ) );
|
|
197
205
|
vi.mock( '@temporalio/worker', () => ( {
|
|
198
206
|
NativeConnection: { connect: vi.fn().mockResolvedValue( mockConnection ) },
|
|
199
|
-
Worker: { create: vi.fn().mockResolvedValue( mockWorker ) }
|
|
207
|
+
Worker: { create: vi.fn().mockResolvedValue( mockWorker ) },
|
|
208
|
+
GracefulShutdownPeriodExpiredError
|
|
200
209
|
} ) );
|
|
201
210
|
|
|
202
211
|
const importWorker = async () => {
|
|
@@ -403,6 +412,18 @@ describe( 'worker/index', () => {
|
|
|
403
412
|
expect( process.exit ).toHaveBeenCalledWith( 0 );
|
|
404
413
|
} );
|
|
405
414
|
|
|
415
|
+
it( 'still exits cleanly when the drain outlived the force time', async () => {
|
|
416
|
+
await bootWorker();
|
|
417
|
+
const error = new GracefulShutdownPeriodExpiredError( 'Timed out while waiting for worker to shutdown gracefully' );
|
|
418
|
+
shutdownServicesMock.mockResolvedValueOnce( [ { service: 'worker', error } ] );
|
|
419
|
+
|
|
420
|
+
interruption.controller.abort( new interruption.KillSignError( 'SIGTERM' ) );
|
|
421
|
+
await waitForExit();
|
|
422
|
+
|
|
423
|
+
expect( mockLog.error ).not.toHaveBeenCalled();
|
|
424
|
+
expect( process.exit ).toHaveBeenCalledWith( 0 );
|
|
425
|
+
} );
|
|
426
|
+
|
|
406
427
|
it( 'does not report the worker as terminated when a signal ended the run', async () => {
|
|
407
428
|
await bootWorker();
|
|
408
429
|
|
|
@@ -428,7 +449,7 @@ describe( 'worker/index', () => {
|
|
|
428
449
|
interruption.controller.abort( new interruption.KillSignError( 'SIGTERM' ) );
|
|
429
450
|
await waitForExit();
|
|
430
451
|
|
|
431
|
-
expect( flushPendingHooksMock ).
|
|
452
|
+
expect( flushPendingHooksMock ).toHaveBeenCalledWith( 5000 );
|
|
432
453
|
expect( shutdownServicesMock.mock.invocationCallOrder[0] )
|
|
433
454
|
.toBeLessThan( flushPendingHooksMock.mock.invocationCallOrder[0] );
|
|
434
455
|
expect( flushPendingHooksMock.mock.invocationCallOrder[0] )
|