@outputai/core 0.12.0 → 0.12.1-next.1243f78.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.
@@ -1,33 +1,72 @@
1
+ import { serializeError } from '#helpers/error_serializer';
1
2
  import { createChildLogger } from '#logger';
2
3
 
3
4
  const log = createChildLogger( 'Interruption' );
4
5
 
5
- const FORCE_QUIT_GRACE_MS = 1000;
6
+ const DOUBLE_SIGNAL_IGNORE_TIME = 1000;
7
+ const KILL_AFTER_UNCAUGHT_TIME = 60_000;
6
8
  const INTERRUPTION_SIGNALS = [ 'SIGTERM', 'SIGINT', 'SIGUSR2' ];
9
+ const UNCAUGHT_ERROR_TYPES = [ 'uncaughtException', 'unhandledRejection' ];
7
10
 
8
- export const setupInterruptionHandler = cb => {
9
- const state = { interruptionReceivedAt: null };
11
+ export class KillSignError extends Error {
12
+ name = 'KillSignError';
13
+ };
14
+
15
+ export class UncaughtError extends Error {
16
+ name = 'UncaughtError';
17
+ };
18
+
19
+ const state = {
20
+ lastSignalDate: null,
21
+ setupCompleted: false,
22
+ uncaughtHandled: false,
23
+ abortController: null
24
+ };
10
25
 
11
- const handle = signal => {
12
- log.info( 'Signal Received', { signal } );
26
+ /** Creates a countdown to kill the code */
27
+ const createTerminationWatchdog = ( message, timeoutMs ) =>
28
+ setTimeout( () => {
29
+ log.warn( message );
30
+ process.exit( 1 );
31
+ }, timeoutMs ).unref();
13
32
 
14
- if ( state.interruptionReceivedAt ) {
15
- const elapsed = Date.now() - state.interruptionReceivedAt;
33
+ /** Handles a signal interruption */
34
+ const handleSignal = signal => {
35
+ log.info( 'Signal Received', { signal } );
16
36
 
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
- }
37
+ if ( state.lastSignalDate ) {
38
+ const elapsed = Date.now() - state.lastSignalDate;
39
+
40
+ // If running with npx, 2 kill signals are received in rapid succession,
41
+ // this ignores the second interruption when it is right after the first.
42
+ if ( elapsed > DOUBLE_SIGNAL_IGNORE_TIME ) {
22
43
  log.warn( 'Force quitting...' );
23
44
  process.exit( 1 );
24
- return;
25
45
  }
46
+ return;
47
+ }
48
+
49
+ state.lastSignalDate = Date.now();
50
+ state.abortController.abort( new KillSignError( signal ) );
51
+ };
26
52
 
27
- state.interruptionReceivedAt = Date.now();
28
- log.warn( 'Initiating shutdown...' );
29
- cb();
30
- };
53
+ /** handles uncaught exceptions, unhandled promises */
54
+ const handleUncaught = ( error, type ) => {
55
+ log.warn( 'Uncaught exception', { type, error: serializeError( error ) } );
56
+
57
+ if ( !state.uncaughtHandled ) {
58
+ state.abortController.abort( new UncaughtError( type, { cause: error } ) );
59
+ // after detecting an uncaught exception, starts a countdown, if the code doesn't finish in time, force quit
60
+ createTerminationWatchdog( 'Uncaught exception handling timed out, force quitting...', KILL_AFTER_UNCAUGHT_TIME );
61
+ state.uncaughtHandled = true;
62
+ }
63
+ };
31
64
 
32
- INTERRUPTION_SIGNALS.forEach( signal => process.on( signal, () => handle( signal ) ) );
65
+ export const setupInterruptionHandler = abortController => {
66
+ if ( !state.setupCompleted ) {
67
+ state.abortController = abortController;
68
+ INTERRUPTION_SIGNALS.forEach( signal => process.on( signal, () => handleSignal( signal ) ) );
69
+ UNCAUGHT_ERROR_TYPES.forEach( type => process.on( type, error => handleUncaught( error, type ) ) );
70
+ state.setupCompleted = true;
71
+ }
33
72
  };
@@ -1,98 +1,177 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
- import { setupInterruptionHandler } from './interruption.js';
3
2
 
4
- const { mockLog } = vi.hoisted( () => ( {
5
- mockLog: { info: vi.fn(), warn: vi.fn() }
3
+ const DOUBLE_SIGNAL_IGNORE_TIME = 1000;
4
+ const KILL_AFTER_UNCAUGHT_TIME = 60_000;
5
+
6
+ const { mockLog, serializeErrorMock } = vi.hoisted( () => ( {
7
+ mockLog: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
8
+ serializeErrorMock: vi.fn( () => 'serialized' )
6
9
  } ) );
7
10
 
8
11
  vi.mock( '#logger', () => ( { createChildLogger: () => mockLog } ) );
12
+ vi.mock( '#helpers/error_serializer', () => ( { serializeError: serializeErrorMock } ) );
13
+
14
+ // The module keeps its attachment state at module scope, so every test needs a fresh copy.
15
+ const loadModule = async () => {
16
+ vi.resetModules();
17
+ return import( './interruption.js' );
18
+ };
9
19
 
10
20
  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;
21
+ const handlers = {};
22
+ const spies = {};
16
23
 
17
24
  beforeEach( () => {
18
25
  vi.clearAllMocks();
19
- Object.keys( onHandlers ).forEach( key => delete onHandlers[key] );
20
- process.on = vi.fn( ( event, handler ) => {
21
- onHandlers[event] = handler;
26
+ Object.keys( handlers ).forEach( key => delete handlers[key] );
27
+
28
+ spies.on = vi.spyOn( process, 'on' ).mockImplementation( ( event, handler ) => {
29
+ handlers[event] = handler;
30
+ return process;
22
31
  } );
23
- process.exit = exitMock;
32
+ spies.exit = vi.spyOn( process, 'exit' ).mockImplementation( () => undefined );
24
33
  } );
25
34
 
26
35
  afterEach( () => {
27
36
  vi.useRealTimers();
28
- process.on = originalOn;
29
- process.exit = originalExit;
37
+ vi.restoreAllMocks();
30
38
  } );
31
39
 
32
- it( 'registers interruption signal handlers', () => {
33
- setupInterruptionHandler( callback );
40
+ it( 'attaches a handler for every signal and uncaught error type', async () => {
41
+ const { setupInterruptionHandler } = await loadModule();
42
+
43
+ setupInterruptionHandler( new AbortController() );
34
44
 
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 ) );
45
+ expect( Object.keys( handlers ) ).toEqual( [
46
+ 'SIGTERM',
47
+ 'SIGINT',
48
+ 'SIGUSR2',
49
+ 'uncaughtException',
50
+ 'unhandledRejection'
51
+ ] );
38
52
  } );
39
53
 
40
- it( 'logs and invokes callback on first SIGTERM', () => {
41
- setupInterruptionHandler( callback );
54
+ it( 'attaches once and keeps the first abort controller', async () => {
55
+ const { setupInterruptionHandler } = await loadModule();
56
+ const first = new AbortController();
57
+ const second = new AbortController();
42
58
 
43
- onHandlers.SIGTERM();
59
+ setupInterruptionHandler( first );
60
+ const attachedCalls = spies.on.mock.calls.length;
61
+ setupInterruptionHandler( second );
44
62
 
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();
63
+ expect( spies.on ).toHaveBeenCalledTimes( attachedCalls );
64
+
65
+ handlers.SIGTERM();
66
+
67
+ expect( first.signal.aborted ).toBe( true );
68
+ expect( second.signal.aborted ).toBe( false );
49
69
  } );
50
70
 
51
- it( 'logs and invokes callback on first SIGINT', () => {
52
- setupInterruptionHandler( callback );
71
+ describe( 'signals', () => {
72
+ it( 'aborts with a kill signal error naming the signal', async () => {
73
+ const { setupInterruptionHandler, KillSignError } = await loadModule();
74
+ const controller = new AbortController();
53
75
 
54
- onHandlers.SIGINT();
76
+ setupInterruptionHandler( controller );
77
+ handlers.SIGTERM();
55
78
 
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
- } );
79
+ expect( mockLog.info ).toHaveBeenCalledWith( 'Signal Received', { signal: 'SIGTERM' } );
80
+ expect( controller.signal.reason ).toBeInstanceOf( KillSignError );
81
+ expect( controller.signal.reason.message ).toBe( 'SIGTERM' );
82
+ expect( spies.exit ).not.toHaveBeenCalled();
83
+ } );
61
84
 
62
- it( 'logs and invokes callback on first SIGUSR2', () => {
63
- setupInterruptionHandler( callback );
85
+ it( 'ignores a second signal received within the grace window', async () => {
86
+ vi.useFakeTimers();
87
+ const { setupInterruptionHandler } = await loadModule();
88
+ const controller = new AbortController();
64
89
 
65
- onHandlers.SIGUSR2();
90
+ setupInterruptionHandler( controller );
91
+ handlers.SIGINT();
92
+ vi.advanceTimersByTime( DOUBLE_SIGNAL_IGNORE_TIME - 1 );
93
+ handlers.SIGINT();
66
94
 
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
- } );
95
+ expect( mockLog.warn ).not.toHaveBeenCalledWith( 'Force quitting...' );
96
+ expect( spies.exit ).not.toHaveBeenCalled();
97
+ } );
72
98
 
73
- it( 'ignores a second signal received within the grace period', () => {
74
- vi.useFakeTimers();
75
- setupInterruptionHandler( callback );
99
+ it( 'force quits on a second signal received after the grace window', async () => {
100
+ vi.useFakeTimers();
101
+ const { setupInterruptionHandler } = await loadModule();
102
+ const controller = new AbortController();
76
103
 
77
- onHandlers.SIGTERM();
78
- onHandlers.SIGINT();
104
+ setupInterruptionHandler( controller );
105
+ handlers.SIGINT();
106
+ vi.advanceTimersByTime( DOUBLE_SIGNAL_IGNORE_TIME + 1 );
107
+ handlers.SIGINT();
79
108
 
80
- expect( callback ).toHaveBeenCalledOnce();
81
- expect( mockLog.warn ).toHaveBeenCalledTimes( 1 );
82
- expect( mockLog.warn ).toHaveBeenCalledWith( 'Initiating shutdown...' );
83
- expect( exitMock ).not.toHaveBeenCalled();
109
+ expect( mockLog.warn ).toHaveBeenCalledWith( 'Force quitting...' );
110
+ expect( spies.exit ).toHaveBeenCalledWith( 1 );
111
+ } );
84
112
  } );
85
113
 
86
- it( 'force quits on a second signal after the grace period', () => {
87
- vi.useFakeTimers();
88
- setupInterruptionHandler( callback );
114
+ describe( 'uncaught errors', () => {
115
+ it( 'aborts with an uncaught error wrapping the original', async () => {
116
+ const { setupInterruptionHandler, UncaughtError } = await loadModule();
117
+ const controller = new AbortController();
118
+ const error = new TypeError( 'boom' );
119
+
120
+ setupInterruptionHandler( controller );
121
+ handlers.uncaughtException( error );
122
+
123
+ // Recorded here because a prior abort would leave the reason handler with nothing to report.
124
+ expect( mockLog.warn ).toHaveBeenCalledWith( 'Uncaught exception', { type: 'uncaughtException', error: 'serialized' } );
125
+ expect( serializeErrorMock ).toHaveBeenCalledWith( error );
126
+ expect( mockLog.error ).not.toHaveBeenCalled();
127
+ expect( controller.signal.reason ).toBeInstanceOf( UncaughtError );
128
+ expect( controller.signal.reason.message ).toBe( 'uncaughtException' );
129
+ expect( controller.signal.reason.cause ).toBe( error );
130
+ } );
131
+
132
+ it( 'wraps non error rejection values for unhandled rejections', async () => {
133
+ const { setupInterruptionHandler, UncaughtError } = await loadModule();
134
+ const controller = new AbortController();
89
135
 
90
- onHandlers.SIGTERM();
91
- vi.advanceTimersByTime( 1001 );
92
- onHandlers.SIGINT();
136
+ setupInterruptionHandler( controller );
137
+ handlers.unhandledRejection( 'not an error' );
93
138
 
94
- expect( callback ).toHaveBeenCalledOnce();
95
- expect( mockLog.warn ).toHaveBeenCalledWith( 'Force quitting...' );
96
- expect( exitMock ).toHaveBeenCalledWith( 1 );
139
+ expect( mockLog.warn ).toHaveBeenCalledWith( 'Uncaught exception', { type: 'unhandledRejection', error: 'serialized' } );
140
+ expect( serializeErrorMock ).toHaveBeenCalledWith( 'not an error' );
141
+ expect( mockLog.error ).not.toHaveBeenCalled();
142
+ expect( controller.signal.reason ).toBeInstanceOf( UncaughtError );
143
+ expect( controller.signal.reason.message ).toBe( 'unhandledRejection' );
144
+ expect( controller.signal.reason.cause ).toBe( 'not an error' );
145
+ } );
146
+
147
+ it( 'forces an exit when the shutdown outlives the watchdog', async () => {
148
+ vi.useFakeTimers();
149
+ const { setupInterruptionHandler } = await loadModule();
150
+
151
+ setupInterruptionHandler( new AbortController() );
152
+ handlers.uncaughtException( new Error( 'boom' ) );
153
+
154
+ vi.advanceTimersByTime( KILL_AFTER_UNCAUGHT_TIME - 1 );
155
+
156
+ expect( spies.exit ).not.toHaveBeenCalled();
157
+
158
+ vi.advanceTimersByTime( 1 );
159
+
160
+ expect( mockLog.warn ).toHaveBeenCalledWith( 'Uncaught exception handling timed out, force quitting...' );
161
+ expect( spies.exit ).toHaveBeenCalledWith( 1 );
162
+ } );
163
+
164
+ it( 'keeps the watchdog from holding the process open', async () => {
165
+ const { setupInterruptionHandler } = await loadModule();
166
+ setupInterruptionHandler( new AbortController() );
167
+
168
+ const timer = { unref: vi.fn() };
169
+ const setTimeoutSpy = vi.spyOn( globalThis, 'setTimeout' ).mockReturnValue( timer );
170
+
171
+ handlers.uncaughtException( new Error( 'boom' ) );
172
+
173
+ expect( setTimeoutSpy ).toHaveBeenCalledWith( expect.any( Function ), KILL_AFTER_UNCAUGHT_TIME );
174
+ expect( timer.unref ).toHaveBeenCalledOnce();
175
+ } );
97
176
  } );
98
177
  } );
@@ -0,0 +1,53 @@
1
+ import { serializeError } from '#helpers/error_serializer';
2
+ import { createChildLogger } from '#logger';
3
+
4
+ const log = createChildLogger( 'Worker Shutdown' );
5
+
6
+ /**
7
+ * Gracefully shuts every running service down, in order.
8
+ * A service that fails to stop never interrupts the sequence, it is reported back instead.
9
+ * @returns {Promise<Array<{ service: string, error: Error }>>} one entry per service that failed to stop
10
+ */
11
+ export const shutdownServices = async ( { workerRunner, connectionMonitor, catalogPublisher, connection } ) => {
12
+ const shutdownJobs = [
13
+ {
14
+ service: 'worker',
15
+ label: 'Stopping Worker',
16
+ isActive: () => workerRunner?.running,
17
+ stop: () => workerRunner.stop()
18
+ },
19
+ {
20
+ service: 'connection_monitor',
21
+ label: 'Stopping Connection Monitor',
22
+ isActive: () => connectionMonitor?.running,
23
+ stop: () => connectionMonitor.stop()
24
+ },
25
+ {
26
+ service: 'catalog_publisher',
27
+ label: 'Interrupting Catalog Publisher',
28
+ isActive: () => catalogPublisher?.running,
29
+ stop: () => catalogPublisher.interrupt()
30
+ },
31
+ {
32
+ service: 'connection',
33
+ label: 'Closing Connection',
34
+ isActive: () => connection,
35
+ stop: () => connection.close()
36
+ }
37
+ ];
38
+
39
+ const failures = [];
40
+ for ( const { service, label, isActive, stop } of shutdownJobs ) {
41
+ if ( isActive() ) {
42
+ log.info( `${label}...` );
43
+ // a try covers a service that throws instead of rejecting, which a catch on the promise would miss
44
+ try {
45
+ await stop();
46
+ } catch ( error ) {
47
+ failures.push( { service, error } );
48
+ log.warn( `${label} error`, { error: serializeError( error ) } );
49
+ }
50
+ }
51
+ }
52
+ return failures;
53
+ };
@@ -0,0 +1,197 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { shutdownServices } from './shutdown.js';
3
+
4
+ const { mockLog, serializeErrorMock } = vi.hoisted( () => ( {
5
+ mockLog: { info: vi.fn(), warn: vi.fn() },
6
+ serializeErrorMock: vi.fn( error => ( { name: error.name, message: error.message } ) )
7
+ } ) );
8
+
9
+ vi.mock( '#logger', () => ( { createChildLogger: () => mockLog } ) );
10
+ vi.mock( '#helpers/error_serializer', () => ( { serializeError: serializeErrorMock } ) );
11
+
12
+ /** Stands in for the services registry built during startup, with every service active. */
13
+ const createServices = () => ( {
14
+ workerRunner: { running: true, stop: vi.fn().mockResolvedValue( undefined ) },
15
+ connectionMonitor: { running: true, stop: vi.fn().mockResolvedValue( undefined ) },
16
+ catalogPublisher: { running: true, interrupt: vi.fn().mockResolvedValue( undefined ) },
17
+ connection: { close: vi.fn().mockResolvedValue( undefined ) }
18
+ } );
19
+
20
+ /** The registry as it looks before startup assigns anything. */
21
+ const createEmptyServices = () => ( {
22
+ workerRunner: null,
23
+ connectionMonitor: null,
24
+ catalogPublisher: null,
25
+ connection: null
26
+ } );
27
+
28
+ describe( 'shutdownServices', () => {
29
+ beforeEach( () => {
30
+ vi.clearAllMocks();
31
+ } );
32
+
33
+ it( 'stops every active service, worker first and connection last', async () => {
34
+ const services = createServices();
35
+
36
+ await shutdownServices( services );
37
+
38
+ expect( services.workerRunner.stop ).toHaveBeenCalledOnce();
39
+ expect( services.connectionMonitor.stop ).toHaveBeenCalledOnce();
40
+ expect( services.catalogPublisher.interrupt ).toHaveBeenCalledOnce();
41
+ expect( services.connection.close ).toHaveBeenCalledOnce();
42
+
43
+ expect( services.workerRunner.stop.mock.invocationCallOrder[0] )
44
+ .toBeLessThan( services.connectionMonitor.stop.mock.invocationCallOrder[0] );
45
+ expect( services.connectionMonitor.stop.mock.invocationCallOrder[0] )
46
+ .toBeLessThan( services.catalogPublisher.interrupt.mock.invocationCallOrder[0] );
47
+ expect( services.catalogPublisher.interrupt.mock.invocationCallOrder[0] )
48
+ .toBeLessThan( services.connection.close.mock.invocationCallOrder[0] );
49
+ } );
50
+
51
+ it( 'announces each step it takes', async () => {
52
+ await shutdownServices( createServices() );
53
+
54
+ expect( mockLog.info ).toHaveBeenCalledWith( 'Stopping Worker...' );
55
+ expect( mockLog.info ).toHaveBeenCalledWith( 'Stopping Connection Monitor...' );
56
+ expect( mockLog.info ).toHaveBeenCalledWith( 'Interrupting Catalog Publisher...' );
57
+ expect( mockLog.info ).toHaveBeenCalledWith( 'Closing Connection...' );
58
+ expect( mockLog.warn ).not.toHaveBeenCalled();
59
+ } );
60
+
61
+ it( 'waits for a service to settle before stopping the next one', async () => {
62
+ const deferred = {};
63
+ const services = createServices();
64
+ services.workerRunner.stop.mockReturnValue( new Promise( resolve => {
65
+ deferred.resolve = resolve;
66
+ } ) );
67
+
68
+ const stopped = shutdownServices( services );
69
+ await Promise.resolve();
70
+
71
+ expect( services.connectionMonitor.stop ).not.toHaveBeenCalled();
72
+
73
+ deferred.resolve();
74
+ await stopped;
75
+
76
+ expect( services.connectionMonitor.stop ).toHaveBeenCalledOnce();
77
+ } );
78
+
79
+ it( 'skips a worker runner that is no longer running', async () => {
80
+ const services = createServices();
81
+ services.workerRunner.running = false;
82
+
83
+ await shutdownServices( services );
84
+
85
+ expect( services.workerRunner.stop ).not.toHaveBeenCalled();
86
+ expect( mockLog.info ).not.toHaveBeenCalledWith( 'Stopping Worker...' );
87
+ expect( services.connection.close ).toHaveBeenCalledOnce();
88
+ } );
89
+
90
+ it( 'skips a connection monitor that already exited', async () => {
91
+ const services = createServices();
92
+ services.connectionMonitor.running = false;
93
+
94
+ await shutdownServices( services );
95
+
96
+ expect( services.connectionMonitor.stop ).not.toHaveBeenCalled();
97
+ expect( mockLog.info ).not.toHaveBeenCalledWith( 'Stopping Connection Monitor...' );
98
+ } );
99
+
100
+ it( 'skips a catalog publisher that already finished', async () => {
101
+ const services = createServices();
102
+ services.catalogPublisher.running = false;
103
+
104
+ await shutdownServices( services );
105
+
106
+ expect( services.catalogPublisher.interrupt ).not.toHaveBeenCalled();
107
+ expect( mockLog.info ).not.toHaveBeenCalledWith( 'Interrupting Catalog Publisher...' );
108
+ } );
109
+
110
+ it( 'reads the state at each turn, so a service stopped by an earlier step is skipped', async () => {
111
+ const services = createServices();
112
+ services.workerRunner.stop.mockImplementation( async () => {
113
+ services.connectionMonitor.running = false;
114
+ } );
115
+
116
+ await shutdownServices( services );
117
+
118
+ expect( services.connectionMonitor.stop ).not.toHaveBeenCalled();
119
+ expect( services.connection.close ).toHaveBeenCalledOnce();
120
+ } );
121
+
122
+ it( 'does nothing when startup never built a service', async () => {
123
+ await expect( shutdownServices( createEmptyServices() ) ).resolves.toEqual( [] );
124
+
125
+ expect( mockLog.info ).not.toHaveBeenCalled();
126
+ expect( mockLog.warn ).not.toHaveBeenCalled();
127
+ } );
128
+
129
+ it( 'closes the connection when startup failed before anything else existed', async () => {
130
+ const services = { ...createEmptyServices(), connection: { close: vi.fn().mockResolvedValue( undefined ) } };
131
+
132
+ await shutdownServices( services );
133
+
134
+ expect( services.connection.close ).toHaveBeenCalledOnce();
135
+ expect( mockLog.info ).toHaveBeenCalledWith( 'Closing Connection...' );
136
+ } );
137
+
138
+ it( 'reports nothing when every service stops cleanly', async () => {
139
+ await expect( shutdownServices( createServices() ) ).resolves.toEqual( [] );
140
+ } );
141
+
142
+ it( 'warns, reports the failure and keeps going when a service fails to stop', async () => {
143
+ const error = new Error( 'drain failed' );
144
+ const services = createServices();
145
+ services.workerRunner.stop.mockRejectedValue( error );
146
+
147
+ await expect( shutdownServices( services ) ).resolves.toEqual( [ { service: 'worker', error } ] );
148
+
149
+ expect( serializeErrorMock ).toHaveBeenCalledWith( error );
150
+ expect( mockLog.warn ).toHaveBeenCalledWith( 'Stopping Worker error', {
151
+ error: { name: 'Error', message: 'drain failed' }
152
+ } );
153
+ expect( services.connectionMonitor.stop ).toHaveBeenCalledOnce();
154
+ expect( services.catalogPublisher.interrupt ).toHaveBeenCalledOnce();
155
+ expect( services.connection.close ).toHaveBeenCalledOnce();
156
+ } );
157
+
158
+ it( 'warns and reports when closing the connection fails', async () => {
159
+ const error = new Error( 'close failed' );
160
+ const services = createServices();
161
+ services.connection.close.mockRejectedValue( error );
162
+
163
+ await expect( shutdownServices( services ) ).resolves.toEqual( [ { service: 'connection', error } ] );
164
+
165
+ expect( mockLog.warn ).toHaveBeenCalledWith( 'Closing Connection error', {
166
+ error: { name: 'Error', message: 'close failed' }
167
+ } );
168
+ } );
169
+
170
+ it( 'warns and reports a service that throws instead of rejecting', async () => {
171
+ const error = new Error( 'shutdown blew up' );
172
+ const services = createServices();
173
+ services.workerRunner.stop.mockImplementation( () => {
174
+ throw error;
175
+ } );
176
+
177
+ await expect( shutdownServices( services ) ).resolves.toEqual( [ { service: 'worker', error } ] );
178
+
179
+ expect( mockLog.warn ).toHaveBeenCalledWith( 'Stopping Worker error', {
180
+ error: { name: 'Error', message: 'shutdown blew up' }
181
+ } );
182
+ expect( services.connection.close ).toHaveBeenCalledOnce();
183
+ } );
184
+
185
+ it( 'reports every service that failed, in the order they were stopped', async () => {
186
+ const drainError = new Error( 'drain failed' );
187
+ const closeError = new Error( 'close failed' );
188
+ const services = createServices();
189
+ services.workerRunner.stop.mockRejectedValue( drainError );
190
+ services.connection.close.mockRejectedValue( closeError );
191
+
192
+ await expect( shutdownServices( services ) ).resolves.toEqual( [
193
+ { service: 'worker', error: drainError },
194
+ { service: 'connection', error: closeError }
195
+ ] );
196
+ } );
197
+ } );
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Runs a Temporal worker and owns its graceful drain.
3
+ *
4
+ * NOTE:
5
+ * Temporal worker shutdown is a bit odd.
6
+ * worker.run() is an async job that only resolves when calling worker.shutdown().
7
+ * But worker.shutdown() is not async and returns nothing, so there is no way to await it.
8
+ * All code that needs to run after shutdown needs to be after `await worker.run()`.
9
+ */
10
+ export class WorkerRunner {
11
+ #worker = null;
12
+ #signal = null;
13
+ #running = false;
14
+ #execution = null;
15
+
16
+ /**
17
+ * @param {object} options
18
+ * @param {import('@temporalio/worker').Worker} options.worker - already created Temporal worker
19
+ * @param {AbortSignal} options.signal - drains the worker when aborted
20
+ */
21
+ constructor( { worker, signal } ) {
22
+ this.#worker = worker;
23
+ this.#signal = signal;
24
+ }
25
+
26
+ /** Returns whether the worker is polling */
27
+ get running() {
28
+ return this.#running;
29
+ }
30
+
31
+ /**
32
+ * Starts polling the task queue. Aborting the signal drains the worker.
33
+ * @returns {Promise<void>} resolves when the worker has fully stopped, rejects when it fails
34
+ */
35
+ start() {
36
+ if ( this.#execution ) {
37
+ return this.#execution;
38
+ }
39
+ if ( this.#signal.aborted ) {
40
+ return Promise.resolve();
41
+ }
42
+
43
+ // the listener must not return the drain, Node rethrows a rejection returned by an event listener
44
+ this.#signal.addEventListener( 'abort', () => {
45
+ this.stop();
46
+ }, { once: true } );
47
+
48
+ this.#running = true;
49
+ this.#execution = this.#worker.run().finally( () => {
50
+ this.#running = false;
51
+ } );
52
+
53
+ return this.#execution;
54
+ }
55
+
56
+ /**
57
+ * Triggers the graceful drain.
58
+ * @returns {Promise<void>} resolves when the worker has fully stopped, rejects when the drain fails
59
+ */
60
+ stop() {
61
+ if ( this.#worker.getStatus().runState === 'RUNNING' ) {
62
+ this.#worker.shutdown();
63
+ }
64
+ return this.#execution ?? Promise.resolve();
65
+ }
66
+ };