@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@outputai/core",
3
- "version": "0.12.1-next.ce54578.0",
3
+ "version": "0.13.0",
4
4
  "description": "The core module of the output framework",
5
5
  "type": "module",
6
6
  "engines": {
@@ -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 ), flushTimeoutMs ) )
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( 30_000 );
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( 30_000 );
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 } );
@@ -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, kept under the 30s most platforms allow before SIGKILL
40
- TEMPORAL_SHUTDOWN_FORCE_TIME: durationSchema.default( '25s' ),
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( '20s' ),
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( '25s' );
73
- expect( configs.shutdownGraceTime ).toBe( '20s' );
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( '25s' );
147
- expect( configs.shutdownGraceTime ).toBe( '20s' );
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 () => {
@@ -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
- const exitCode = shutdownFailures.some( v => v.service === 'worker' ) || hasError ? 1 : 0;
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 );
@@ -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 ).toHaveBeenCalledOnce();
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] )