@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.
- package/package.json +1 -1
- package/src/helpers/function.js +0 -16
- package/src/helpers/function.spec.js +1 -47
- package/src/helpers/promise.js +8 -26
- package/src/helpers/promise.spec.js +49 -22
- package/src/worker/catalog_workflow/catalog_publisher.js +149 -0
- package/src/worker/catalog_workflow/catalog_publisher.spec.js +317 -0
- package/src/worker/configs.js +4 -4
- package/src/worker/configs.spec.js +5 -5
- package/src/worker/connection_monitor.js +83 -55
- package/src/worker/connection_monitor.spec.js +258 -112
- package/src/worker/index.js +86 -144
- package/src/worker/index.spec.js +339 -253
- package/src/worker/interruption.js +57 -18
- package/src/worker/interruption.spec.js +139 -60
- package/src/worker/shutdown.js +53 -0
- package/src/worker/shutdown.spec.js +197 -0
- package/src/worker/worker_runner.js +66 -0
- package/src/worker/worker_runner.spec.js +220 -0
- package/src/worker/catalog_workflow/catalog_job.js +0 -152
- package/src/worker/catalog_workflow/catalog_job.spec.js +0 -218
|
@@ -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
|
|
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
|
|
9
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
15
|
-
|
|
33
|
+
/** Handles a signal interruption */
|
|
34
|
+
const handleSignal = signal => {
|
|
35
|
+
log.info( 'Signal Received', { signal } );
|
|
16
36
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
|
5
|
-
|
|
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
|
|
12
|
-
const
|
|
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(
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
32
|
+
spies.exit = vi.spyOn( process, 'exit' ).mockImplementation( () => undefined );
|
|
24
33
|
} );
|
|
25
34
|
|
|
26
35
|
afterEach( () => {
|
|
27
36
|
vi.useRealTimers();
|
|
28
|
-
|
|
29
|
-
process.exit = originalExit;
|
|
37
|
+
vi.restoreAllMocks();
|
|
30
38
|
} );
|
|
31
39
|
|
|
32
|
-
it( '
|
|
33
|
-
setupInterruptionHandler
|
|
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(
|
|
36
|
-
|
|
37
|
-
|
|
45
|
+
expect( Object.keys( handlers ) ).toEqual( [
|
|
46
|
+
'SIGTERM',
|
|
47
|
+
'SIGINT',
|
|
48
|
+
'SIGUSR2',
|
|
49
|
+
'uncaughtException',
|
|
50
|
+
'unhandledRejection'
|
|
51
|
+
] );
|
|
38
52
|
} );
|
|
39
53
|
|
|
40
|
-
it( '
|
|
41
|
-
setupInterruptionHandler
|
|
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
|
-
|
|
59
|
+
setupInterruptionHandler( first );
|
|
60
|
+
const attachedCalls = spies.on.mock.calls.length;
|
|
61
|
+
setupInterruptionHandler( second );
|
|
44
62
|
|
|
45
|
-
expect(
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
76
|
+
setupInterruptionHandler( controller );
|
|
77
|
+
handlers.SIGTERM();
|
|
55
78
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
90
|
+
setupInterruptionHandler( controller );
|
|
91
|
+
handlers.SIGINT();
|
|
92
|
+
vi.advanceTimersByTime( DOUBLE_SIGNAL_IGNORE_TIME - 1 );
|
|
93
|
+
handlers.SIGINT();
|
|
66
94
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
expect( exitMock ).not.toHaveBeenCalled();
|
|
71
|
-
} );
|
|
95
|
+
expect( mockLog.warn ).not.toHaveBeenCalledWith( 'Force quitting...' );
|
|
96
|
+
expect( spies.exit ).not.toHaveBeenCalled();
|
|
97
|
+
} );
|
|
72
98
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
104
|
+
setupInterruptionHandler( controller );
|
|
105
|
+
handlers.SIGINT();
|
|
106
|
+
vi.advanceTimersByTime( DOUBLE_SIGNAL_IGNORE_TIME + 1 );
|
|
107
|
+
handlers.SIGINT();
|
|
79
108
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
expect( exitMock ).not.toHaveBeenCalled();
|
|
109
|
+
expect( mockLog.warn ).toHaveBeenCalledWith( 'Force quitting...' );
|
|
110
|
+
expect( spies.exit ).toHaveBeenCalledWith( 1 );
|
|
111
|
+
} );
|
|
84
112
|
} );
|
|
85
113
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
onHandlers.SIGINT();
|
|
136
|
+
setupInterruptionHandler( controller );
|
|
137
|
+
handlers.unhandledRejection( 'not an error' );
|
|
93
138
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
+
};
|