@outputai/core 0.10.1-next.af37678.0 → 0.10.1-next.be4ec7f.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.10.1-next.af37678.0",
3
+ "version": "0.10.1-next.be4ec7f.0",
4
4
  "description": "The core module of the output framework",
5
5
  "type": "module",
6
6
  "engines": {
@@ -69,6 +69,7 @@
69
69
  "#consts": "./src/consts.js",
70
70
  "#errors": "./src/errors.js",
71
71
  "#logger": "./src/logger/index.js",
72
+ "#hooks/*": "./src/hooks/*.js",
72
73
  "#helpers/*": "./src/helpers/*.js",
73
74
  "#tracing": "./src/tracing/internal_interface.js",
74
75
  "#trace_attribute": "./src/tracing/trace_attribute.js",
@@ -1,6 +1,6 @@
1
1
  import { FatalError } from '#errors';
2
2
 
3
- /** Matches red int "hot-red-pie", but not int "redact" */
3
+ /** Matches red in "hot-red-pie", but not in "redact" */
4
4
  const wordMatcher = term => new RegExp( `(?<![a-z\\d])${term}(?![a-z\\d])`, 'i' );
5
5
 
6
6
  /** Matches red in "acquired", but not in "redact" */
@@ -1,22 +1,24 @@
1
1
  import { mainEventBus, stepEventBus } from '#bus';
2
2
  import { BusEventType } from '#consts';
3
3
  import { createChildLogger } from '#logger';
4
+ import { pendingHooks } from './pending_hooks.js';
4
5
 
5
6
  const log = createChildLogger( 'Hooks' );
6
7
 
7
8
  /**
8
- * Invokes a function within a try catch and log the error
9
+ * Invokes a function within a try catch and log the error.
9
10
  *
10
11
  * @param {Function} fn
11
12
  * @param {any} args - Args to invoke the function with
12
13
  * @param {string} hookName - hookName to identify this hook function in the logs
13
14
  */
14
- const callHookCb = async ( fn, args, hookName ) => {
15
- try {
16
- await fn( args );
17
- } catch ( error ) {
18
- log.error( `${hookName} hook error`, { message: error.message, stack: error.stack } );
19
- }
15
+ const callHookCb = ( fn, args, hookName ) => {
16
+ const promise = Promise.resolve()
17
+ .then( () => fn( args ) )
18
+ .catch( e => log.error( `${hookName} hook error`, { message: e.message, stack: e.stack } ) )
19
+ .finally( () => pendingHooks.delete( promise ) );
20
+ pendingHooks.add( promise );
21
+ return promise;
20
22
  };
21
23
 
22
24
  // General Life-cycle
@@ -38,6 +38,7 @@ import {
38
38
  onWorkflowError,
39
39
  onWorkflowStart
40
40
  } from './index.js';
41
+ import { pendingHooks } from './pending_hooks.js';
41
42
 
42
43
  const workflowDetails = {
43
44
  workflowId: 'wf-1',
@@ -67,6 +68,7 @@ const aggregations = {
67
68
  describe( 'hooks/index', () => {
68
69
  beforeEach( () => {
69
70
  vi.clearAllMocks();
71
+ pendingHooks.clear();
70
72
  Object.keys( mainOnHandlers ).forEach( k => {
71
73
  delete mainOnHandlers[k];
72
74
  } );
@@ -158,6 +160,23 @@ describe( 'hooks/index', () => {
158
160
  } );
159
161
  } );
160
162
 
163
+ it( 'tracks a hook callback until it settles', async () => {
164
+ const deferred = { resolve: null };
165
+ const handlerPromise = new Promise( resolve => {
166
+ deferred.resolve = resolve;
167
+ } );
168
+ const handler = vi.fn( () => handlerPromise );
169
+ onActivityEnd( handler );
170
+
171
+ const callbackPromise = mainOnHandlers[BusEventType.ACTIVITY_END]( { activityInfo } );
172
+
173
+ expect( pendingHooks.size ).toBe( 1 );
174
+
175
+ deferred.resolve();
176
+ await callbackPromise;
177
+ expect( pendingHooks.size ).toBe( 0 );
178
+ } );
179
+
161
180
  describe( 'workflow lifecycle hooks', () => {
162
181
  const cases = [
163
182
  [ 'onWorkflowStart', onWorkflowStart, BusEventType.WORKFLOW_START, {} ],
@@ -0,0 +1,24 @@
1
+ import { createChildLogger } from '#logger';
2
+
3
+ export const pendingHooks = new Set();
4
+
5
+ const log = createChildLogger( 'Hooks' );
6
+ const flushTimeoutMs = 30_000;
7
+
8
+ /**
9
+ * Await all pending hooks to flush for a certain time
10
+ */
11
+ export const flushPendingHooks = async () => {
12
+ const state = { timeout: null };
13
+ try {
14
+ const flushed = await Promise.race( [
15
+ Promise.allSettled( [ ...pendingHooks ] ).then( _ => true ),
16
+ new Promise( r => state.timeout = setTimeout( () => r( false ), flushTimeoutMs ) )
17
+ ] );
18
+ if ( !flushed ) {
19
+ log.warn( 'Some hook callbacks exceeded the timeout and will not be awaited', { count: pendingHooks.size } );
20
+ }
21
+ } finally {
22
+ clearTimeout( state.timeout );
23
+ }
24
+ };
@@ -0,0 +1,62 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const logMock = vi.hoisted( () => ( { warn: vi.fn() } ) );
4
+
5
+ vi.mock( '#logger', () => ( { createChildLogger: () => logMock } ) );
6
+
7
+ import { flushPendingHooks, pendingHooks } from './pending_hooks.js';
8
+
9
+ describe( 'pending hooks', () => {
10
+ beforeEach( () => {
11
+ pendingHooks.clear();
12
+ vi.clearAllMocks();
13
+ } );
14
+
15
+ afterEach( () => {
16
+ pendingHooks.clear();
17
+ vi.useRealTimers();
18
+ } );
19
+
20
+ it( 'waits for pending hooks to settle', async () => {
21
+ const deferred = { resolve: null };
22
+ const hookPromise = new Promise( resolve => {
23
+ deferred.resolve = resolve;
24
+ } );
25
+ pendingHooks.add( hookPromise );
26
+
27
+ const flushPromise = flushPendingHooks();
28
+ const state = { flushed: false };
29
+ flushPromise.then( () => {
30
+ state.flushed = true;
31
+ } );
32
+
33
+ await Promise.resolve();
34
+ expect( state.flushed ).toBe( false );
35
+
36
+ deferred.resolve();
37
+ await flushPromise;
38
+ expect( state.flushed ).toBe( true );
39
+ } );
40
+
41
+ it( 'stops waiting after the timeout', async () => {
42
+ vi.useFakeTimers();
43
+ pendingHooks.add( new Promise( () => {} ) );
44
+
45
+ const flushPromise = flushPendingHooks();
46
+ await vi.advanceTimersByTimeAsync( 30_000 );
47
+
48
+ await expect( flushPromise ).resolves.toBeUndefined();
49
+ } );
50
+
51
+ it( 'logs the number of hooks that exceeded the timeout', async () => {
52
+ vi.useFakeTimers();
53
+ pendingHooks.add( new Promise( () => {} ) );
54
+ pendingHooks.add( new Promise( () => {} ) );
55
+
56
+ const flushPromise = flushPendingHooks();
57
+ await vi.advanceTimersByTimeAsync( 30_000 );
58
+ await flushPromise;
59
+
60
+ expect( logMock.warn ).toHaveBeenCalledWith( expect.any( String ), { count: 2 } );
61
+ } );
62
+ } );
@@ -36,8 +36,6 @@ 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
- // Time to allow for hooks to flush before shutdown
40
- OUTPUT_PROCESS_FAILURE_SHUTDOWN_DELAY: z.preprocess( coalesceEmptyString, z.coerce.number().int().positive().default( 3000 ) ),
41
39
  // Set temporal worker shutdown force time
42
40
  TEMPORAL_SHUTDOWN_FORCE_TIME: durationSchema,
43
41
  // Set temporal worker shutdown grace time
@@ -70,7 +68,6 @@ export const catalogId = envVars.OUTPUT_CATALOG_ID;
70
68
  export const workerTelemetryIntervalMs = envVars.OUTPUT_WORKER_TELEMETRY_INTERVAL_MS;
71
69
  export const activityHeartbeatIntervalMs = envVars.OUTPUT_ACTIVITY_HEARTBEAT_INTERVAL_MS;
72
70
  export const activityHeartbeatEnabled = envVars.OUTPUT_ACTIVITY_HEARTBEAT_ENABLED;
73
- export const processFailureShutdownDelay = envVars.OUTPUT_PROCESS_FAILURE_SHUTDOWN_DELAY;
74
71
  export const shutdownForceTime = envVars.TEMPORAL_SHUTDOWN_FORCE_TIME;
75
72
  export const shutdownGraceTime = envVars.TEMPORAL_SHUTDOWN_GRACE_TIME;
76
73
  export const grpcProxy = envVars.TEMPORAL_GRPC_PROXY;
@@ -13,6 +13,7 @@ import { createChildLogger } from '#logger';
13
13
  import { setupInterruptionHandler } from './interruption.js';
14
14
  import { CatalogJob } from './catalog_workflow/catalog_job.js';
15
15
  import { mainEventBus } from '#bus';
16
+ import { flushPendingHooks } from '#hooks/pending_hooks';
16
17
  import { BusEventType } from '#consts';
17
18
  import { setupTelemetry } from './telemetry.js';
18
19
  import { TemporalConnectionMonitor } from './connection_monitor.js';
@@ -212,13 +213,18 @@ execute()
212
213
  .catch( e => log.warn( 'Connection close error', { error: e.message } ) );
213
214
  }
214
215
  } )
215
- .then( () => log.info( 'Bye' ) )
216
- .catch( error => {
216
+ .then( async () => {
217
+ log.info( 'Flushing hook callbacks...' );
218
+ await flushPendingHooks();
219
+ log.info( 'Bye' );
220
+ } )
221
+ .catch( async error => {
217
222
  log.error( 'Fatal error', { error: error.message, stack: error.stack } );
218
223
 
219
224
  mainEventBus.emit( BusEventType.RUNTIME_ERROR, { error } );
220
225
 
221
- const timeToFlushEvent = configs.processFailureShutdownDelay;
222
- log.info( `Exiting in ${timeToFlushEvent}ms` );
223
- setTimeout( () => process.exit( 1 ), timeToFlushEvent );
226
+ log.info( 'Flushing hook callbacks...' );
227
+ await flushPendingHooks();
228
+ log.info( 'Exiting...' );
229
+ setTimeout( () => process.exit( 1 ) );
224
230
  } );
@@ -6,6 +6,7 @@ const {
6
6
  configValues,
7
7
  connectionMonitorInstance,
8
8
  createCatalogMock,
9
+ flushPendingHooksMock,
9
10
  initInterceptorsMock,
10
11
  mainEventBusMock,
11
12
  mockConnection,
@@ -46,7 +47,6 @@ const {
46
47
  maxConcurrentActivityTaskPolls: 5,
47
48
  maxConcurrentWorkflowTaskPolls: 5,
48
49
  workerTuner: undefined,
49
- processFailureShutdownDelay: 0,
50
50
  shutdownForceTime: undefined,
51
51
  shutdownGraceTime: undefined
52
52
  };
@@ -105,6 +105,7 @@ const {
105
105
  connectionMonitorInstance,
106
106
  createCatalogMock: vi.fn().mockReturnValue( { workflows: [], activities: {} } ),
107
107
  createWorkflowsEntryPointMock: vi.fn().mockReturnValue( '/fake/workflows/path.js' ),
108
+ flushPendingHooksMock: vi.fn().mockResolvedValue( undefined ),
108
109
  hashSourceCodeMock: vi.fn().mockResolvedValue( 'catalog-hash' ),
109
110
  initInterceptorsMock: vi.fn().mockReturnValue( [] ),
110
111
  loadActivitiesMock: vi.fn().mockResolvedValue( {} ),
@@ -129,6 +130,7 @@ vi.mock( '#consts', async importOriginal => {
129
130
  const initTracing = vi.fn().mockResolvedValue( undefined );
130
131
  vi.mock( '#tracing', () => ( { init: initTracing } ) );
131
132
  vi.mock( '#bus', () => ( { mainEventBus: mainEventBusMock } ) );
133
+ vi.mock( '#hooks/pending_hooks', () => ( { flushPendingHooks: flushPendingHooksMock } ) );
132
134
 
133
135
  const loadWorkflowsMock = vi.fn().mockResolvedValue( { workflows: [], entrypoint: '/fake/workflows/path.js' } );
134
136
  const loadActivitiesMock = vi.fn().mockResolvedValue( { activities: {} } );
@@ -261,6 +263,7 @@ describe( 'worker/index', () => {
261
263
  expect( catalogJobInstance.run ).toHaveBeenCalled();
262
264
 
263
265
  await settleWorker();
266
+ await vi.waitFor( () => expect( flushPendingHooksMock ).toHaveBeenCalledOnce() );
264
267
  expect( mockLog.info ).toHaveBeenCalledWith( 'Bye' );
265
268
  } );
266
269
 
@@ -358,6 +361,9 @@ describe( 'worker/index', () => {
358
361
  } );
359
362
  expect( mainEventBusMock.emit ).toHaveBeenCalledWith( expect.any( String ), { error } );
360
363
  await vi.waitFor( () => expect( exitMock ).toHaveBeenCalledWith( 1 ) );
364
+ expect( flushPendingHooksMock ).toHaveBeenCalledOnce();
365
+ expect( mainEventBusMock.emit.mock.invocationCallOrder[0] ).toBeLessThan( flushPendingHooksMock.mock.invocationCallOrder[0] );
366
+ expect( flushPendingHooksMock.mock.invocationCallOrder[0] ).toBeLessThan( exitMock.mock.invocationCallOrder[0] );
361
367
  } );
362
368
 
363
369
  it( 'throws connection monitor errors after graceful shutdown', async () => {