@outputai/core 0.12.1-next.a4a41b6.0 → 0.12.1-next.d1d27f7.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/hooks/pending_hooks.js +3 -3
- package/src/hooks/pending_hooks.spec.js +5 -5
- 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 +8 -4
- package/src/worker/configs.spec.js +11 -7
- package/src/worker/connection_monitor.js +83 -55
- package/src/worker/connection_monitor.spec.js +258 -112
- package/src/worker/index.js +91 -146
- package/src/worker/index.spec.js +362 -255
- 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
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { WorkflowNotFoundError } from '@temporalio/client';
|
|
3
|
+
import { WorkflowExecutionAlreadyStartedError, WorkflowIdConflictPolicy } from '@temporalio/common';
|
|
4
|
+
import { CatalogPublisher } from './catalog_publisher.js';
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
catalogId,
|
|
8
|
+
describeMock,
|
|
9
|
+
getHandleMock,
|
|
10
|
+
mockLog,
|
|
11
|
+
serializeErrorMock,
|
|
12
|
+
sleepMock,
|
|
13
|
+
startMock,
|
|
14
|
+
taskQueue,
|
|
15
|
+
terminateMock
|
|
16
|
+
} = vi.hoisted( () => ( {
|
|
17
|
+
catalogId: 'test-catalog',
|
|
18
|
+
describeMock: vi.fn(),
|
|
19
|
+
getHandleMock: vi.fn(),
|
|
20
|
+
mockLog: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
21
|
+
serializeErrorMock: vi.fn( error => ( { message: error.message } ) ),
|
|
22
|
+
sleepMock: vi.fn(),
|
|
23
|
+
startMock: vi.fn(),
|
|
24
|
+
taskQueue: 'test-queue',
|
|
25
|
+
terminateMock: vi.fn()
|
|
26
|
+
} ) );
|
|
27
|
+
|
|
28
|
+
vi.mock( '#logger', () => ( { createChildLogger: () => mockLog } ) );
|
|
29
|
+
vi.mock( '#helpers/error_serializer', () => ( { serializeError: serializeErrorMock } ) );
|
|
30
|
+
vi.mock( '#consts', () => ( { WORKFLOW_CATALOG: 'catalog' } ) );
|
|
31
|
+
vi.mock( '#helpers/promise', () => ( { sleepCancellable: sleepMock } ) );
|
|
32
|
+
vi.mock( '../configs.js', () => ( { catalogId, taskQueue } ) );
|
|
33
|
+
vi.mock( '@temporalio/client', async importOriginal => {
|
|
34
|
+
const actual = await importOriginal();
|
|
35
|
+
return {
|
|
36
|
+
...actual,
|
|
37
|
+
Client: vi.fn().mockImplementation( function () {
|
|
38
|
+
return { workflow: { start: startMock, getHandle: getHandleMock } };
|
|
39
|
+
} )
|
|
40
|
+
};
|
|
41
|
+
} );
|
|
42
|
+
|
|
43
|
+
const RETRY_DELAY = 10_000;
|
|
44
|
+
const namespace = 'default';
|
|
45
|
+
const catalogHash = 'catalog-hash';
|
|
46
|
+
const catalog = { workflows: [], workflowNames: { workflow: 'workflow' }, activities: {} };
|
|
47
|
+
|
|
48
|
+
const startArguments = {
|
|
49
|
+
taskQueue,
|
|
50
|
+
workflowId: catalogId,
|
|
51
|
+
workflowIdConflictPolicy: WorkflowIdConflictPolicy.FAIL,
|
|
52
|
+
workflowTaskTimeout: 30_000,
|
|
53
|
+
args: [ catalog ],
|
|
54
|
+
memo: {
|
|
55
|
+
workflowNames: catalog.workflowNames,
|
|
56
|
+
hash: catalogHash
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const compactError = message => ( { error: { message } } );
|
|
61
|
+
|
|
62
|
+
const running = hash => ( { closeTime: undefined, runId: 'run-1', memo: { hash } } );
|
|
63
|
+
const notFound = () => new WorkflowNotFoundError( 'not found' );
|
|
64
|
+
const alreadyStarted = () => new WorkflowExecutionAlreadyStartedError( 'already started', catalogId, 'catalog' );
|
|
65
|
+
|
|
66
|
+
const createPublisher = ( signal = new AbortController().signal ) =>
|
|
67
|
+
new CatalogPublisher( { connection: {}, namespace, catalog, catalogHash, signal } );
|
|
68
|
+
|
|
69
|
+
const flushPromises = async () => Array
|
|
70
|
+
.from( { length: 10 } )
|
|
71
|
+
.reduce( promise => promise.then( () => Promise.resolve() ), Promise.resolve() );
|
|
72
|
+
|
|
73
|
+
describe( 'CatalogPublisher', () => {
|
|
74
|
+
beforeEach( () => {
|
|
75
|
+
vi.clearAllMocks();
|
|
76
|
+
getHandleMock.mockReturnValue( { describe: describeMock, terminate: terminateMock } );
|
|
77
|
+
describeMock.mockRejectedValue( notFound() );
|
|
78
|
+
terminateMock.mockResolvedValue( undefined );
|
|
79
|
+
startMock.mockResolvedValue( undefined );
|
|
80
|
+
sleepMock.mockResolvedValue( undefined );
|
|
81
|
+
} );
|
|
82
|
+
|
|
83
|
+
describe( 'publish paths', () => {
|
|
84
|
+
it( 'starts the catalog when nothing is running', async () => {
|
|
85
|
+
const publisher = createPublisher();
|
|
86
|
+
|
|
87
|
+
await publisher.run();
|
|
88
|
+
|
|
89
|
+
expect( terminateMock ).not.toHaveBeenCalled();
|
|
90
|
+
expect( startMock ).toHaveBeenCalledWith( 'catalog', startArguments );
|
|
91
|
+
expect( mockLog.info ).toHaveBeenCalledWith( 'Catalog workflow started' );
|
|
92
|
+
expect( mockLog.warn ).not.toHaveBeenCalled();
|
|
93
|
+
expect( publisher.running ).toBe( false );
|
|
94
|
+
} );
|
|
95
|
+
|
|
96
|
+
it( 'starts the catalog when the previous one is closed', async () => {
|
|
97
|
+
describeMock.mockResolvedValue( { closeTime: '2024-01-01T00:00:00Z' } );
|
|
98
|
+
|
|
99
|
+
await createPublisher().run();
|
|
100
|
+
|
|
101
|
+
expect( terminateMock ).not.toHaveBeenCalled();
|
|
102
|
+
expect( startMock ).toHaveBeenCalledWith( 'catalog', startArguments );
|
|
103
|
+
} );
|
|
104
|
+
|
|
105
|
+
it( 'skips the restart when the running hash matches', async () => {
|
|
106
|
+
describeMock.mockResolvedValue( running( catalogHash ) );
|
|
107
|
+
|
|
108
|
+
await createPublisher().run();
|
|
109
|
+
|
|
110
|
+
expect( terminateMock ).not.toHaveBeenCalled();
|
|
111
|
+
expect( startMock ).not.toHaveBeenCalled();
|
|
112
|
+
expect( mockLog.info ).toHaveBeenCalledWith( 'Current catalog workflow hash matches worker, restart skipped' );
|
|
113
|
+
} );
|
|
114
|
+
|
|
115
|
+
it( 'terminates and starts when the running catalog carries no memo', async () => {
|
|
116
|
+
describeMock.mockResolvedValue( { closeTime: undefined, runId: 'run-1', memo: undefined } );
|
|
117
|
+
|
|
118
|
+
await createPublisher().run();
|
|
119
|
+
|
|
120
|
+
expect( mockLog.warn ).not.toHaveBeenCalled();
|
|
121
|
+
expect( terminateMock ).toHaveBeenCalledOnce();
|
|
122
|
+
expect( startMock ).toHaveBeenCalledWith( 'catalog', startArguments );
|
|
123
|
+
} );
|
|
124
|
+
|
|
125
|
+
it( 'terminates the pinned run then starts when the running hash differs', async () => {
|
|
126
|
+
describeMock.mockResolvedValue( running( 'old-hash' ) );
|
|
127
|
+
|
|
128
|
+
await createPublisher().run();
|
|
129
|
+
|
|
130
|
+
expect( getHandleMock ).toHaveBeenCalledWith( catalogId, 'run-1' );
|
|
131
|
+
expect( terminateMock ).toHaveBeenCalledWith( `Systematic terminating. Superseded by catalog ${catalogHash}` );
|
|
132
|
+
expect( startMock ).toHaveBeenCalledWith( 'catalog', startArguments );
|
|
133
|
+
} );
|
|
134
|
+
} );
|
|
135
|
+
|
|
136
|
+
describe( 'race paths', () => {
|
|
137
|
+
it( 'ignores an already started error when the published hash matches', async () => {
|
|
138
|
+
describeMock
|
|
139
|
+
.mockRejectedValueOnce( notFound() )
|
|
140
|
+
.mockResolvedValueOnce( running( catalogHash ) );
|
|
141
|
+
startMock.mockRejectedValue( alreadyStarted() );
|
|
142
|
+
|
|
143
|
+
await createPublisher().run();
|
|
144
|
+
|
|
145
|
+
expect( startMock ).toHaveBeenCalledOnce();
|
|
146
|
+
expect( terminateMock ).not.toHaveBeenCalled();
|
|
147
|
+
expect( mockLog.info ).toHaveBeenCalledWith( 'Found a catalog workflow with matching hashes, start skipped' );
|
|
148
|
+
expect( mockLog.info ).not.toHaveBeenCalledWith( 'Catalog workflow started' );
|
|
149
|
+
} );
|
|
150
|
+
|
|
151
|
+
it( 'retires the winner on retry when an already started error exposes a different hash', async () => {
|
|
152
|
+
describeMock
|
|
153
|
+
.mockRejectedValueOnce( notFound() )
|
|
154
|
+
.mockResolvedValue( running( 'other-hash' ) );
|
|
155
|
+
startMock
|
|
156
|
+
.mockRejectedValueOnce( alreadyStarted() )
|
|
157
|
+
.mockResolvedValue( undefined );
|
|
158
|
+
|
|
159
|
+
await createPublisher().run();
|
|
160
|
+
|
|
161
|
+
expect( sleepMock ).toHaveBeenCalledOnce();
|
|
162
|
+
expect( terminateMock ).toHaveBeenCalledOnce();
|
|
163
|
+
expect( startMock ).toHaveBeenCalledTimes( 2 );
|
|
164
|
+
expect( mockLog.warn ).toHaveBeenCalledWith(
|
|
165
|
+
'Error while starting catalog workflow, retrying...',
|
|
166
|
+
compactError( 'already started' )
|
|
167
|
+
);
|
|
168
|
+
} );
|
|
169
|
+
|
|
170
|
+
it( 'warns and attempts a start when describing fails', async () => {
|
|
171
|
+
const error = new Error( 'describe failed' );
|
|
172
|
+
describeMock.mockRejectedValue( error );
|
|
173
|
+
|
|
174
|
+
await createPublisher().run();
|
|
175
|
+
|
|
176
|
+
expect( serializeErrorMock ).toHaveBeenCalledWith( error, { dropKeys: [ 'stack' ] } );
|
|
177
|
+
expect( mockLog.warn ).toHaveBeenCalledWith(
|
|
178
|
+
'Ignoring error while describing catalog workflow',
|
|
179
|
+
compactError( 'describe failed' )
|
|
180
|
+
);
|
|
181
|
+
expect( startMock ).toHaveBeenCalledWith( 'catalog', startArguments );
|
|
182
|
+
} );
|
|
183
|
+
|
|
184
|
+
it( 'warns and still starts when terminating the previous catalog fails', async () => {
|
|
185
|
+
describeMock.mockResolvedValue( running( 'old-hash' ) );
|
|
186
|
+
terminateMock.mockRejectedValue( new Error( 'terminate failed' ) );
|
|
187
|
+
|
|
188
|
+
await createPublisher().run();
|
|
189
|
+
|
|
190
|
+
expect( mockLog.warn ).toHaveBeenCalledWith(
|
|
191
|
+
'Ignoring error while terminating previous catalog workflow',
|
|
192
|
+
compactError( 'terminate failed' )
|
|
193
|
+
);
|
|
194
|
+
expect( startMock ).toHaveBeenCalledWith( 'catalog', startArguments );
|
|
195
|
+
} );
|
|
196
|
+
} );
|
|
197
|
+
|
|
198
|
+
describe( 'failure', () => {
|
|
199
|
+
it( 'rejects with the original error after exhausting the retries', async () => {
|
|
200
|
+
const error = new Error( 'start failed' );
|
|
201
|
+
startMock.mockRejectedValue( error );
|
|
202
|
+
const publisher = createPublisher();
|
|
203
|
+
|
|
204
|
+
await expect( publisher.run() ).rejects.toBe( error );
|
|
205
|
+
|
|
206
|
+
expect( startMock ).toHaveBeenCalledTimes( 3 );
|
|
207
|
+
expect( sleepMock ).toHaveBeenCalledTimes( 2 );
|
|
208
|
+
expect( publisher.running ).toBe( false );
|
|
209
|
+
} );
|
|
210
|
+
|
|
211
|
+
it( 'waits the retry delay with the abort signal between attempts', async () => {
|
|
212
|
+
startMock.mockRejectedValue( new Error( 'start failed' ) );
|
|
213
|
+
|
|
214
|
+
await expect( createPublisher().run() ).rejects.toThrow( 'start failed' );
|
|
215
|
+
|
|
216
|
+
expect( sleepMock ).toHaveBeenCalledWith( RETRY_DELAY, expect.any( AbortSignal ) );
|
|
217
|
+
} );
|
|
218
|
+
} );
|
|
219
|
+
|
|
220
|
+
describe( 'abort paths', () => {
|
|
221
|
+
it( 'publishes nothing when already aborted', async () => {
|
|
222
|
+
const controller = new AbortController();
|
|
223
|
+
controller.abort();
|
|
224
|
+
|
|
225
|
+
await expect( createPublisher( controller.signal ).run() ).resolves.toBeUndefined();
|
|
226
|
+
|
|
227
|
+
expect( describeMock ).not.toHaveBeenCalled();
|
|
228
|
+
expect( startMock ).not.toHaveBeenCalled();
|
|
229
|
+
} );
|
|
230
|
+
|
|
231
|
+
it( 'stops retrying when the signal aborts during the backoff', async () => {
|
|
232
|
+
const controller = new AbortController();
|
|
233
|
+
startMock.mockRejectedValue( new Error( 'start failed' ) );
|
|
234
|
+
sleepMock.mockImplementation( async () => controller.abort() );
|
|
235
|
+
|
|
236
|
+
await expect( createPublisher( controller.signal ).run() ).resolves.toBeUndefined();
|
|
237
|
+
|
|
238
|
+
expect( startMock ).toHaveBeenCalledOnce();
|
|
239
|
+
expect( sleepMock ).toHaveBeenCalledOnce();
|
|
240
|
+
} );
|
|
241
|
+
|
|
242
|
+
it( 'stops retrying when interrupted during the backoff', async () => {
|
|
243
|
+
startMock.mockRejectedValue( new Error( 'start failed' ) );
|
|
244
|
+
const publisher = createPublisher();
|
|
245
|
+
sleepMock.mockImplementation( async () => {
|
|
246
|
+
publisher.interrupt();
|
|
247
|
+
} );
|
|
248
|
+
|
|
249
|
+
await expect( publisher.run() ).resolves.toBeUndefined();
|
|
250
|
+
|
|
251
|
+
expect( startMock ).toHaveBeenCalledOnce();
|
|
252
|
+
} );
|
|
253
|
+
|
|
254
|
+
it( 'still starts when the abort lands between terminating and starting', async () => {
|
|
255
|
+
const controller = new AbortController();
|
|
256
|
+
describeMock.mockResolvedValue( running( 'old-hash' ) );
|
|
257
|
+
terminateMock.mockImplementation( async () => controller.abort() );
|
|
258
|
+
|
|
259
|
+
await createPublisher( controller.signal ).run();
|
|
260
|
+
|
|
261
|
+
expect( terminateMock ).toHaveBeenCalledOnce();
|
|
262
|
+
expect( startMock ).toHaveBeenCalledWith( 'catalog', startArguments );
|
|
263
|
+
} );
|
|
264
|
+
} );
|
|
265
|
+
|
|
266
|
+
describe( 'lifecycle', () => {
|
|
267
|
+
it( 'reports running while the sequence is pending', async () => {
|
|
268
|
+
const deferred = {};
|
|
269
|
+
describeMock.mockReturnValue( new Promise( resolve => {
|
|
270
|
+
deferred.resolve = resolve;
|
|
271
|
+
} ) );
|
|
272
|
+
const publisher = createPublisher();
|
|
273
|
+
|
|
274
|
+
const run = publisher.run();
|
|
275
|
+
await flushPromises();
|
|
276
|
+
|
|
277
|
+
expect( publisher.running ).toBe( true );
|
|
278
|
+
|
|
279
|
+
deferred.resolve( running( catalogHash ) );
|
|
280
|
+
await run;
|
|
281
|
+
|
|
282
|
+
expect( publisher.running ).toBe( false );
|
|
283
|
+
} );
|
|
284
|
+
|
|
285
|
+
it( 'returns the same execution and publishes once when run more than once', async () => {
|
|
286
|
+
const publisher = createPublisher();
|
|
287
|
+
|
|
288
|
+
const first = publisher.run();
|
|
289
|
+
const second = publisher.run();
|
|
290
|
+
|
|
291
|
+
expect( second ).toBe( first );
|
|
292
|
+
|
|
293
|
+
await first;
|
|
294
|
+
|
|
295
|
+
expect( describeMock ).toHaveBeenCalledOnce();
|
|
296
|
+
expect( startMock ).toHaveBeenCalledOnce();
|
|
297
|
+
} );
|
|
298
|
+
|
|
299
|
+
it( 'resolves when interrupted before running', async () => {
|
|
300
|
+
const publisher = createPublisher();
|
|
301
|
+
|
|
302
|
+
await expect( publisher.interrupt() ).resolves.toBeUndefined();
|
|
303
|
+
|
|
304
|
+
expect( publisher.running ).toBe( false );
|
|
305
|
+
expect( describeMock ).not.toHaveBeenCalled();
|
|
306
|
+
} );
|
|
307
|
+
|
|
308
|
+
it( 'reports the failure when interrupting after a rejected run', async () => {
|
|
309
|
+
startMock.mockRejectedValue( new Error( 'start failed' ) );
|
|
310
|
+
const publisher = createPublisher();
|
|
311
|
+
|
|
312
|
+
await expect( publisher.run() ).rejects.toThrow( 'start failed' );
|
|
313
|
+
|
|
314
|
+
await expect( publisher.interrupt() ).rejects.toThrow( 'start failed' );
|
|
315
|
+
} );
|
|
316
|
+
} );
|
|
317
|
+
} );
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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' ),
|
|
42
|
+
// Set temporal worker shutdown grace time, when in-flight activities are asked to cancel
|
|
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 ).
|
|
73
|
-
expect( configs.shutdownGraceTime ).
|
|
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
|
} );
|
|
@@ -136,15 +138,17 @@ describe( 'worker/configs', () => {
|
|
|
136
138
|
expect( configs.shutdownGraceTime ).toBe( '15s' );
|
|
137
139
|
} );
|
|
138
140
|
|
|
139
|
-
it( '
|
|
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 ).
|
|
147
|
-
expect( configs.shutdownGraceTime ).
|
|
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,101 +1,129 @@
|
|
|
1
|
+
import { sleepCancellable } from '#helpers/promise';
|
|
1
2
|
import { createChildLogger } from '#logger';
|
|
2
3
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
3
|
-
import { CancellablePromise } from '#helpers/promise';
|
|
4
4
|
|
|
5
5
|
const log = createChildLogger( 'Connection' );
|
|
6
6
|
|
|
7
|
+
/** Raised when the connection health check fails repeatedly. */
|
|
8
|
+
export class ConnectionLostError extends Error {
|
|
9
|
+
name = 'ConnectionLostError';
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const MAX_FAILURES = 3;
|
|
13
|
+
const CHECK_INTERVAL_MS = 60_000;
|
|
14
|
+
const CHECK_TIMEOUT_MS = 5_000;
|
|
15
|
+
const HEALTHY_LOG_EVERY_LOOPS = 60;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Watches the Temporal connection health.
|
|
19
|
+
* Rejects with a ConnectionLostError once the connection is considered lost.
|
|
20
|
+
*/
|
|
7
21
|
export class TemporalConnectionMonitor {
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
22
|
+
#maxFailures = MAX_FAILURES;
|
|
23
|
+
#checkIntervalMs = CHECK_INTERVAL_MS;
|
|
24
|
+
#checkTimeoutMs = CHECK_TIMEOUT_MS;
|
|
11
25
|
|
|
12
|
-
#
|
|
26
|
+
#abortCtrl = new AbortController();
|
|
13
27
|
#failures = 0;
|
|
14
|
-
#
|
|
28
|
+
#loops = 0;
|
|
15
29
|
#running = false;
|
|
16
|
-
#
|
|
30
|
+
#execution = null;
|
|
17
31
|
#connection = null;
|
|
18
|
-
#
|
|
19
|
-
|
|
20
|
-
#getTimeout = async () => delay( this.#CHECK_TIMEOUT_MS, 0, { ref: false } ).then( () => {
|
|
21
|
-
throw new Error( 'Connection health check timed out' );
|
|
22
|
-
} );
|
|
23
|
-
|
|
24
|
-
#healthcheck = async () => this.#connection.workflowService.getSystemInfo( {} );
|
|
32
|
+
#signal = null;
|
|
25
33
|
|
|
26
|
-
#
|
|
27
|
-
|
|
28
|
-
#watch = async () => {
|
|
29
|
-
while ( !this.#cancellation.completed ) {
|
|
34
|
+
#watch = async signal => {
|
|
35
|
+
while ( !signal.aborted ) {
|
|
30
36
|
try {
|
|
31
|
-
await Promise.race( [
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
37
|
+
await Promise.race( [
|
|
38
|
+
this.#connection.workflowService.getSystemInfo( {} ),
|
|
39
|
+
delay( this.#checkTimeoutMs, 0, { ref: false, signal } ).then( () => {
|
|
40
|
+
throw new Error( 'Connection health check timed out' );
|
|
41
|
+
} )
|
|
42
|
+
] );
|
|
43
|
+
|
|
44
|
+
if ( this.#failures > 0 ) {
|
|
45
|
+
log.info( 'Recovered' );
|
|
46
|
+
}
|
|
47
|
+
if ( this.#loops === 0 || this.#loops % HEALTHY_LOG_EVERY_LOOPS === 0 ) {
|
|
48
|
+
log.info( 'Healthy' );
|
|
36
49
|
}
|
|
37
|
-
|
|
38
|
-
log.info( this.#failures === 0 ? 'Healthy' : 'Recovered' );
|
|
39
50
|
this.#failures = 0;
|
|
51
|
+
|
|
40
52
|
} catch ( error ) {
|
|
41
|
-
//
|
|
42
|
-
if (
|
|
43
|
-
|
|
53
|
+
// aborting will ignore warnings and not report errors
|
|
54
|
+
if ( signal.aborted ) {
|
|
55
|
+
return;
|
|
44
56
|
}
|
|
45
57
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
this.#
|
|
49
|
-
this.#
|
|
50
|
-
|
|
51
|
-
break;
|
|
52
|
-
} else {
|
|
53
|
-
log.warn( 'Connection unhealthy', { error: error.message, failures: this.#failures } );
|
|
58
|
+
const failureMessage = error?.message ?? String( error );
|
|
59
|
+
if ( ++this.#failures >= this.#maxFailures ) {
|
|
60
|
+
log.warn( 'Connection lost', { error: failureMessage, failures: this.#failures } );
|
|
61
|
+
this.#abortCtrl.abort();
|
|
62
|
+
throw new ConnectionLostError( 'Connection lost', { cause: error } );
|
|
54
63
|
}
|
|
64
|
+
|
|
65
|
+
log.warn( 'Connection unhealthy', { error: failureMessage, failures: this.#failures } );
|
|
55
66
|
}
|
|
56
67
|
|
|
57
|
-
|
|
68
|
+
this.#loops++;
|
|
69
|
+
await sleepCancellable( this.#checkIntervalMs, signal );
|
|
58
70
|
}
|
|
59
71
|
};
|
|
60
72
|
|
|
61
|
-
|
|
73
|
+
/**
|
|
74
|
+
* @param {object} options
|
|
75
|
+
* @param {import('@temporalio/worker').NativeConnection} options.connection - Temporal connection
|
|
76
|
+
* @param {AbortSignal} options.signal - stops watching when aborted
|
|
77
|
+
* @param {object} [options.overrides] - health check tuning, mostly for tests
|
|
78
|
+
*/
|
|
79
|
+
constructor( { connection, signal, overrides = {} } ) {
|
|
62
80
|
this.#connection = connection;
|
|
81
|
+
this.#signal = signal;
|
|
63
82
|
if ( Number.isFinite( overrides?.maxFailures ) ) {
|
|
64
|
-
this.#
|
|
83
|
+
this.#maxFailures = overrides.maxFailures;
|
|
65
84
|
}
|
|
66
85
|
if ( Number.isFinite( overrides?.checkIntervalMs ) ) {
|
|
67
|
-
this.#
|
|
86
|
+
this.#checkIntervalMs = overrides.checkIntervalMs;
|
|
68
87
|
}
|
|
69
88
|
if ( Number.isFinite( overrides?.checkTimeoutMs ) ) {
|
|
70
|
-
this.#
|
|
89
|
+
this.#checkTimeoutMs = overrides.checkTimeoutMs;
|
|
71
90
|
}
|
|
72
91
|
}
|
|
73
92
|
|
|
74
|
-
|
|
75
|
-
this.#connectionLostCb = cb;
|
|
76
|
-
}
|
|
77
|
-
|
|
93
|
+
/** Returns whether the monitor is watching */
|
|
78
94
|
get running() {
|
|
79
95
|
return this.#running;
|
|
80
96
|
}
|
|
81
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Starts watching the connection. Aborting the signal stops the watch.
|
|
100
|
+
* @returns {Promise<void>} resolves when the watch has stopped, rejects when the connection is lost
|
|
101
|
+
*/
|
|
82
102
|
start() {
|
|
83
|
-
if ( this.#
|
|
84
|
-
return this.#
|
|
103
|
+
if ( this.#execution ) {
|
|
104
|
+
return this.#execution;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const signal = AbortSignal.any( [ this.#signal, this.#abortCtrl.signal ] );
|
|
108
|
+
|
|
109
|
+
if ( signal.aborted ) {
|
|
110
|
+
return Promise.resolve();
|
|
85
111
|
}
|
|
112
|
+
|
|
86
113
|
this.#running = true;
|
|
87
|
-
this.#
|
|
114
|
+
this.#execution = this.#watch( signal ).finally( () => {
|
|
88
115
|
this.#running = false;
|
|
89
116
|
} );
|
|
90
|
-
return this.#watchPromise;
|
|
91
|
-
}
|
|
92
117
|
|
|
93
|
-
|
|
94
|
-
this.#cancellation.complete();
|
|
95
|
-
return this.#watchPromise ?? Promise.resolve();
|
|
118
|
+
return this.#execution;
|
|
96
119
|
}
|
|
97
120
|
|
|
98
|
-
|
|
99
|
-
|
|
121
|
+
/**
|
|
122
|
+
* Stops watching, without reporting a connection loss.
|
|
123
|
+
* @returns {Promise<void>} resolves when the watch has fully stopped, rejects when it failed
|
|
124
|
+
*/
|
|
125
|
+
stop() {
|
|
126
|
+
this.#abortCtrl.abort();
|
|
127
|
+
return this.#execution ?? Promise.resolve();
|
|
100
128
|
}
|
|
101
129
|
};
|