@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
|
@@ -69,8 +69,8 @@ describe( 'worker/configs', () => {
|
|
|
69
69
|
expect( configs.workerTelemetryIntervalMs ).toBe( 0 );
|
|
70
70
|
expect( configs.activityHeartbeatIntervalMs ).toBe( 2 * 60 * 1000 );
|
|
71
71
|
expect( configs.activityHeartbeatEnabled ).toBe( true );
|
|
72
|
-
expect( configs.shutdownForceTime ).
|
|
73
|
-
expect( configs.shutdownGraceTime ).
|
|
72
|
+
expect( configs.shutdownForceTime ).toBe( '25s' );
|
|
73
|
+
expect( configs.shutdownGraceTime ).toBe( '20s' );
|
|
74
74
|
expect( configs.taskQueue ).toBe( 'test-catalog' );
|
|
75
75
|
expect( configs.catalogId ).toBe( 'test-catalog' );
|
|
76
76
|
} );
|
|
@@ -136,15 +136,15 @@ describe( 'worker/configs', () => {
|
|
|
136
136
|
expect( configs.shutdownGraceTime ).toBe( '15s' );
|
|
137
137
|
} );
|
|
138
138
|
|
|
139
|
-
it( '
|
|
139
|
+
it( 'falls back to the defaults when Temporal shutdown durations are empty', async () => {
|
|
140
140
|
setEnv( {
|
|
141
141
|
TEMPORAL_SHUTDOWN_FORCE_TIME: '',
|
|
142
142
|
TEMPORAL_SHUTDOWN_GRACE_TIME: ''
|
|
143
143
|
} );
|
|
144
144
|
const configs = await loadConfigs();
|
|
145
145
|
|
|
146
|
-
expect( configs.shutdownForceTime ).
|
|
147
|
-
expect( configs.shutdownGraceTime ).
|
|
146
|
+
expect( configs.shutdownForceTime ).toBe( '25s' );
|
|
147
|
+
expect( configs.shutdownGraceTime ).toBe( '20s' );
|
|
148
148
|
} );
|
|
149
149
|
|
|
150
150
|
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
|
};
|
|
@@ -1,44 +1,51 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { TemporalConnectionMonitor } from './connection_monitor.js';
|
|
2
|
+
import { ConnectionLostError, TemporalConnectionMonitor } from './connection_monitor.js';
|
|
3
3
|
|
|
4
4
|
const CHECK_TIMEOUT_MS = 50;
|
|
5
5
|
const CHECK_INTERVAL_MS = 100;
|
|
6
6
|
|
|
7
|
-
const { scheduledDelays, delayMock,
|
|
7
|
+
const { scheduledDelays, delayMock, mockLog } = vi.hoisted( () => {
|
|
8
8
|
const scheduledDelays = [];
|
|
9
|
-
const
|
|
9
|
+
const abortError = () => Object.assign( new Error( 'The operation was aborted' ), { name: 'AbortError' } );
|
|
10
|
+
|
|
11
|
+
// Mirrors node:timers/promises: rejects with an AbortError when the signal aborts, or if already aborted.
|
|
12
|
+
const delayMock = vi.fn( ( ms, value, options ) => new Promise( ( resolve, reject ) => {
|
|
13
|
+
const signal = options?.signal;
|
|
14
|
+
|
|
15
|
+
if ( signal?.aborted ) {
|
|
16
|
+
reject( abortError() );
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
10
20
|
scheduledDelays.push( { ms, value, options, resolve } );
|
|
21
|
+
signal?.addEventListener( 'abort', () => reject( abortError() ), { once: true } );
|
|
11
22
|
} ) );
|
|
12
23
|
|
|
13
24
|
return {
|
|
14
25
|
scheduledDelays,
|
|
15
26
|
delayMock,
|
|
16
|
-
|
|
17
|
-
info: vi.fn(),
|
|
18
|
-
warn: vi.fn()
|
|
19
|
-
}
|
|
27
|
+
mockLog: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }
|
|
20
28
|
};
|
|
21
29
|
} );
|
|
22
30
|
|
|
23
31
|
vi.mock( 'node:timers/promises', () => ( { setTimeout: delayMock } ) );
|
|
24
|
-
vi.mock( '#logger', () => ( { createChildLogger:
|
|
32
|
+
vi.mock( '#logger', () => ( { createChildLogger: () => mockLog } ) );
|
|
25
33
|
|
|
26
|
-
const
|
|
27
|
-
workflowService: { getSystemInfo: check }
|
|
28
|
-
} );
|
|
34
|
+
const delayOptions = { ref: false, signal: expect.any( AbortSignal ) };
|
|
29
35
|
|
|
30
|
-
const createMonitor = ( check, overrides = {}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
36
|
+
const createMonitor = ( check, overrides = {}, signal = new AbortController().signal ) =>
|
|
37
|
+
new TemporalConnectionMonitor( {
|
|
38
|
+
connection: { workflowService: { getSystemInfo: check } },
|
|
39
|
+
signal,
|
|
40
|
+
overrides: { checkIntervalMs: CHECK_INTERVAL_MS, checkTimeoutMs: CHECK_TIMEOUT_MS, ...overrides }
|
|
41
|
+
} );
|
|
35
42
|
|
|
36
43
|
const flushPromises = async () => Array
|
|
37
44
|
.from( { length: 10 } )
|
|
38
45
|
.reduce( promise => promise.then( () => Promise.resolve() ), Promise.resolve() );
|
|
39
46
|
|
|
40
47
|
const resolveNextDelay = ms => {
|
|
41
|
-
const index = scheduledDelays.findIndex(
|
|
48
|
+
const index = scheduledDelays.findIndex( scheduled => scheduled.ms === ms );
|
|
42
49
|
expect( index ).not.toBe( -1 );
|
|
43
50
|
const [ scheduled ] = scheduledDelays.splice( index, 1 );
|
|
44
51
|
scheduled.resolve( scheduled.value );
|
|
@@ -50,133 +57,272 @@ describe( 'TemporalConnectionMonitor', () => {
|
|
|
50
57
|
scheduledDelays.length = 0;
|
|
51
58
|
} );
|
|
52
59
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
60
|
+
describe( 'health checks', () => {
|
|
61
|
+
it( 'logs healthy and schedules the next check when the workflow service is reachable', async () => {
|
|
62
|
+
const check = vi.fn().mockResolvedValue( {} );
|
|
63
|
+
const monitor = createMonitor( check );
|
|
56
64
|
|
|
57
|
-
|
|
58
|
-
|
|
65
|
+
const run = monitor.start();
|
|
66
|
+
await flushPromises();
|
|
59
67
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
68
|
+
expect( check ).toHaveBeenCalledWith( {} );
|
|
69
|
+
expect( mockLog.info ).toHaveBeenCalledWith( 'Healthy' );
|
|
70
|
+
expect( delayMock ).toHaveBeenCalledWith( CHECK_TIMEOUT_MS, 0, delayOptions );
|
|
71
|
+
expect( delayMock ).toHaveBeenCalledWith( CHECK_INTERVAL_MS, true, delayOptions );
|
|
72
|
+
expect( monitor.running ).toBe( true );
|
|
65
73
|
|
|
66
|
-
|
|
67
|
-
|
|
74
|
+
await monitor.stop();
|
|
75
|
+
await run;
|
|
68
76
|
|
|
69
|
-
|
|
70
|
-
|
|
77
|
+
expect( monitor.running ).toBe( false );
|
|
78
|
+
} );
|
|
71
79
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
80
|
+
it( 'counts a timed out check as a transient failure', async () => {
|
|
81
|
+
const check = vi.fn().mockReturnValue( new Promise( () => {} ) );
|
|
82
|
+
const monitor = createMonitor( check );
|
|
75
83
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
84
|
+
const run = monitor.start();
|
|
85
|
+
resolveNextDelay( CHECK_TIMEOUT_MS );
|
|
86
|
+
await flushPromises();
|
|
79
87
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
88
|
+
expect( mockLog.warn ).toHaveBeenCalledWith( 'Connection unhealthy', {
|
|
89
|
+
error: 'Connection health check timed out',
|
|
90
|
+
failures: 1
|
|
91
|
+
} );
|
|
92
|
+
|
|
93
|
+
await monitor.stop();
|
|
94
|
+
await run;
|
|
83
95
|
} );
|
|
84
96
|
|
|
85
|
-
|
|
86
|
-
|
|
97
|
+
it( 'logs recovered without a healthy line when the first check fails', async () => {
|
|
98
|
+
const check = vi.fn()
|
|
99
|
+
.mockRejectedValueOnce( new Error( 'temporary outage' ) )
|
|
100
|
+
.mockResolvedValue( {} );
|
|
101
|
+
const monitor = createMonitor( check );
|
|
87
102
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
.mockRejectedValueOnce( new Error( 'temporary outage' ) )
|
|
91
|
-
.mockResolvedValueOnce( {} );
|
|
92
|
-
const monitor = createMonitor( check );
|
|
103
|
+
const run = monitor.start();
|
|
104
|
+
await flushPromises();
|
|
93
105
|
|
|
94
|
-
|
|
95
|
-
await flushPromises();
|
|
106
|
+
expect( mockLog.warn ).toHaveBeenCalledWith( 'Connection unhealthy', { error: 'temporary outage', failures: 1 } );
|
|
96
107
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
108
|
+
resolveNextDelay( CHECK_INTERVAL_MS );
|
|
109
|
+
await flushPromises();
|
|
110
|
+
|
|
111
|
+
expect( mockLog.info ).toHaveBeenCalledOnce();
|
|
112
|
+
expect( mockLog.info ).toHaveBeenCalledWith( 'Recovered' );
|
|
113
|
+
|
|
114
|
+
await monitor.stop();
|
|
115
|
+
await run;
|
|
100
116
|
} );
|
|
101
117
|
|
|
102
|
-
|
|
103
|
-
|
|
118
|
+
it( 'logs healthy once while the connection stays reachable', async () => {
|
|
119
|
+
const check = vi.fn().mockResolvedValue( {} );
|
|
120
|
+
const monitor = createMonitor( check );
|
|
104
121
|
|
|
105
|
-
|
|
122
|
+
const run = monitor.start();
|
|
123
|
+
await flushPromises();
|
|
124
|
+
resolveNextDelay( CHECK_INTERVAL_MS );
|
|
125
|
+
await flushPromises();
|
|
126
|
+
resolveNextDelay( CHECK_INTERVAL_MS );
|
|
127
|
+
await flushPromises();
|
|
106
128
|
|
|
107
|
-
|
|
108
|
-
|
|
129
|
+
expect( check ).toHaveBeenCalledTimes( 3 );
|
|
130
|
+
expect( mockLog.info ).toHaveBeenCalledOnce();
|
|
131
|
+
expect( mockLog.info ).toHaveBeenCalledWith( 'Healthy' );
|
|
109
132
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
+
await monitor.stop();
|
|
134
|
+
await run;
|
|
135
|
+
} );
|
|
136
|
+
|
|
137
|
+
it( 'logs recovered on a later failure without repeating healthy', async () => {
|
|
138
|
+
const check = vi.fn()
|
|
139
|
+
.mockResolvedValueOnce( {} )
|
|
140
|
+
.mockRejectedValueOnce( new Error( 'temporary outage' ) )
|
|
141
|
+
.mockResolvedValue( {} );
|
|
142
|
+
const monitor = createMonitor( check );
|
|
143
|
+
|
|
144
|
+
const run = monitor.start();
|
|
145
|
+
await flushPromises();
|
|
146
|
+
resolveNextDelay( CHECK_INTERVAL_MS );
|
|
147
|
+
await flushPromises();
|
|
148
|
+
resolveNextDelay( CHECK_INTERVAL_MS );
|
|
149
|
+
await flushPromises();
|
|
150
|
+
|
|
151
|
+
expect( mockLog.info ).toHaveBeenCalledTimes( 2 );
|
|
152
|
+
expect( mockLog.info ).toHaveBeenNthCalledWith( 1, 'Healthy' );
|
|
153
|
+
expect( mockLog.info ).toHaveBeenNthCalledWith( 2, 'Recovered' );
|
|
154
|
+
|
|
155
|
+
await monitor.stop();
|
|
156
|
+
await run;
|
|
157
|
+
} );
|
|
158
|
+
|
|
159
|
+
it( 'describes non error rejections with their string value', async () => {
|
|
160
|
+
const check = vi.fn().mockRejectedValue( 'socket hang up' );
|
|
161
|
+
const monitor = createMonitor( check, { maxFailures: 2 } );
|
|
162
|
+
|
|
163
|
+
const run = monitor.start();
|
|
164
|
+
const rejection = expect( run ).rejects.toThrow( ConnectionLostError );
|
|
165
|
+
|
|
166
|
+
await flushPromises();
|
|
167
|
+
resolveNextDelay( CHECK_INTERVAL_MS );
|
|
168
|
+
await flushPromises();
|
|
169
|
+
await rejection;
|
|
170
|
+
|
|
171
|
+
expect( mockLog.warn ).toHaveBeenCalledWith( 'Connection unhealthy', { error: 'socket hang up', failures: 1 } );
|
|
172
|
+
expect( mockLog.warn ).toHaveBeenCalledWith( 'Connection lost', { error: 'socket hang up', failures: 2 } );
|
|
173
|
+
} );
|
|
133
174
|
} );
|
|
134
175
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
176
|
+
describe( 'connection loss', () => {
|
|
177
|
+
it( 'rejects after the maximum consecutive failures', async () => {
|
|
178
|
+
const check = vi.fn().mockRejectedValue( new Error( 'connection refused' ) );
|
|
179
|
+
const monitor = createMonitor( check, { maxFailures: 3 } );
|
|
180
|
+
|
|
181
|
+
const run = monitor.start();
|
|
182
|
+
const rejection = expect( run ).rejects.toThrow( ConnectionLostError );
|
|
183
|
+
|
|
184
|
+
await flushPromises();
|
|
185
|
+
resolveNextDelay( CHECK_INTERVAL_MS );
|
|
186
|
+
await flushPromises();
|
|
187
|
+
resolveNextDelay( CHECK_INTERVAL_MS );
|
|
188
|
+
await flushPromises();
|
|
189
|
+
await rejection;
|
|
190
|
+
|
|
191
|
+
expect( check ).toHaveBeenCalledTimes( 3 );
|
|
192
|
+
expect( mockLog.warn ).toHaveBeenCalledTimes( 3 );
|
|
193
|
+
expect( mockLog.warn ).toHaveBeenCalledWith( 'Connection lost', { error: 'connection refused', failures: 3 } );
|
|
194
|
+
expect( monitor.running ).toBe( false );
|
|
195
|
+
} );
|
|
196
|
+
|
|
197
|
+
it( 'preserves the original failure as the error cause', async () => {
|
|
198
|
+
const error = new Error( 'connection refused' );
|
|
199
|
+
const check = vi.fn().mockRejectedValue( error );
|
|
200
|
+
const monitor = createMonitor( check, { maxFailures: 1 } );
|
|
138
201
|
|
|
139
|
-
|
|
140
|
-
|
|
202
|
+
await expect( monitor.start() ).rejects.toMatchObject( {
|
|
203
|
+
name: 'ConnectionLostError',
|
|
204
|
+
message: 'Connection lost',
|
|
205
|
+
cause: error
|
|
206
|
+
} );
|
|
207
|
+
} );
|
|
141
208
|
|
|
142
|
-
|
|
143
|
-
|
|
209
|
+
it( 'stops watching once the connection is lost', async () => {
|
|
210
|
+
const check = vi.fn().mockRejectedValue( new Error( 'connection refused' ) );
|
|
211
|
+
const monitor = createMonitor( check, { maxFailures: 1 } );
|
|
212
|
+
|
|
213
|
+
await expect( monitor.start() ).rejects.toThrow( ConnectionLostError );
|
|
214
|
+
await flushPromises();
|
|
215
|
+
|
|
216
|
+
expect( check ).toHaveBeenCalledOnce();
|
|
217
|
+
expect( monitor.running ).toBe( false );
|
|
218
|
+
} );
|
|
144
219
|
|
|
145
|
-
|
|
220
|
+
it( 'reports the loss through stop so it is never silent', async () => {
|
|
221
|
+
const check = vi.fn().mockRejectedValue( new Error( 'connection refused' ) );
|
|
222
|
+
const monitor = createMonitor( check, { maxFailures: 1 } );
|
|
223
|
+
|
|
224
|
+
await expect( monitor.start() ).rejects.toThrow( ConnectionLostError );
|
|
225
|
+
await expect( monitor.stop() ).rejects.toThrow( ConnectionLostError );
|
|
226
|
+
} );
|
|
146
227
|
} );
|
|
147
228
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
229
|
+
describe( 'abort', () => {
|
|
230
|
+
it( 'never checks when the signal is already aborted', async () => {
|
|
231
|
+
const controller = new AbortController();
|
|
232
|
+
controller.abort();
|
|
233
|
+
const check = vi.fn();
|
|
234
|
+
const monitor = createMonitor( check, {}, controller.signal );
|
|
235
|
+
|
|
236
|
+
await expect( monitor.start() ).resolves.toBeUndefined();
|
|
237
|
+
|
|
238
|
+
expect( check ).not.toHaveBeenCalled();
|
|
239
|
+
expect( monitor.running ).toBe( false );
|
|
240
|
+
} );
|
|
241
|
+
|
|
242
|
+
it( 'stops without reporting when aborted during a check', async () => {
|
|
243
|
+
const controller = new AbortController();
|
|
244
|
+
const check = vi.fn().mockReturnValue( new Promise( () => {} ) );
|
|
245
|
+
const monitor = createMonitor( check, {}, controller.signal );
|
|
246
|
+
|
|
247
|
+
const run = monitor.start();
|
|
248
|
+
await flushPromises();
|
|
152
249
|
|
|
153
|
-
|
|
154
|
-
monitor.start();
|
|
250
|
+
expect( monitor.running ).toBe( true );
|
|
155
251
|
|
|
156
|
-
|
|
252
|
+
controller.abort();
|
|
157
253
|
|
|
158
|
-
|
|
254
|
+
await expect( run ).resolves.toBeUndefined();
|
|
255
|
+
expect( mockLog.warn ).not.toHaveBeenCalled();
|
|
256
|
+
expect( monitor.running ).toBe( false );
|
|
257
|
+
} );
|
|
258
|
+
|
|
259
|
+
it( 'stops without reporting when aborted between checks', async () => {
|
|
260
|
+
const controller = new AbortController();
|
|
261
|
+
const check = vi.fn().mockResolvedValue( {} );
|
|
262
|
+
const monitor = createMonitor( check, {}, controller.signal );
|
|
263
|
+
|
|
264
|
+
const run = monitor.start();
|
|
265
|
+
await flushPromises();
|
|
266
|
+
|
|
267
|
+
expect( mockLog.info ).toHaveBeenCalledWith( 'Healthy' );
|
|
159
268
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
269
|
+
controller.abort();
|
|
270
|
+
|
|
271
|
+
await expect( run ).resolves.toBeUndefined();
|
|
272
|
+
expect( check ).toHaveBeenCalledOnce();
|
|
273
|
+
expect( mockLog.warn ).not.toHaveBeenCalled();
|
|
274
|
+
} );
|
|
163
275
|
} );
|
|
164
276
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
277
|
+
describe( 'lifecycle', () => {
|
|
278
|
+
it( 'returns the same watch when started more than once', async () => {
|
|
279
|
+
const check = vi.fn().mockReturnValue( new Promise( () => {} ) );
|
|
280
|
+
const monitor = createMonitor( check );
|
|
281
|
+
|
|
282
|
+
const first = monitor.start();
|
|
283
|
+
const second = monitor.start();
|
|
284
|
+
|
|
285
|
+
expect( second ).toBe( first );
|
|
286
|
+
expect( check ).toHaveBeenCalledOnce();
|
|
287
|
+
|
|
288
|
+
await monitor.stop();
|
|
289
|
+
await first;
|
|
290
|
+
} );
|
|
291
|
+
|
|
292
|
+
it( 'stops an in flight check without reporting a failure', async () => {
|
|
293
|
+
const check = vi.fn().mockReturnValue( new Promise( () => {} ) );
|
|
294
|
+
const monitor = createMonitor( check, { maxFailures: 1 } );
|
|
295
|
+
|
|
296
|
+
const run = monitor.start();
|
|
297
|
+
|
|
298
|
+
expect( monitor.running ).toBe( true );
|
|
299
|
+
|
|
300
|
+
await monitor.stop();
|
|
301
|
+
await run;
|
|
302
|
+
|
|
303
|
+
expect( mockLog.warn ).not.toHaveBeenCalled();
|
|
304
|
+
expect( monitor.running ).toBe( false );
|
|
173
305
|
} );
|
|
174
306
|
|
|
175
|
-
|
|
176
|
-
|
|
307
|
+
it( 'never watches when stopped before starting', async () => {
|
|
308
|
+
const check = vi.fn();
|
|
309
|
+
const monitor = createMonitor( check );
|
|
310
|
+
|
|
311
|
+
await expect( monitor.stop() ).resolves.toBeUndefined();
|
|
312
|
+
await expect( monitor.start() ).resolves.toBeUndefined();
|
|
313
|
+
|
|
314
|
+
expect( check ).not.toHaveBeenCalled();
|
|
315
|
+
expect( monitor.running ).toBe( false );
|
|
316
|
+
} );
|
|
177
317
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
318
|
+
it( 'applies the timing and failure threshold overrides', async () => {
|
|
319
|
+
const check = vi.fn().mockRejectedValue( new Error( 'fast failure' ) );
|
|
320
|
+
const monitor = createMonitor( check, { maxFailures: 1, checkIntervalMs: 7, checkTimeoutMs: 3 } );
|
|
321
|
+
|
|
322
|
+
await expect( monitor.start() ).rejects.toThrow( ConnectionLostError );
|
|
323
|
+
|
|
324
|
+
expect( delayMock ).toHaveBeenCalledWith( 3, 0, delayOptions );
|
|
325
|
+
expect( delayMock ).not.toHaveBeenCalledWith( 7, true, delayOptions );
|
|
326
|
+
} );
|
|
181
327
|
} );
|
|
182
328
|
} );
|