@outputai/core 0.7.0 → 0.7.1-next.0e958f3.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/bin/worker.sh +6 -0
- package/package.json +1 -1
- package/src/consts.js +0 -4
- package/src/errors.js +6 -2
- package/src/hooks/index.d.ts +10 -0
- package/src/interface/evaluator.js +7 -20
- package/src/interface/evaluator.spec.js +117 -1
- package/src/interface/step.js +8 -9
- package/src/interface/step.spec.js +124 -0
- package/src/interface/validations/index.js +108 -0
- package/src/interface/validations/index.spec.js +182 -0
- package/src/interface/validations/schemas.js +113 -0
- package/src/interface/validations/schemas.spec.js +209 -0
- package/src/interface/webhook.js +1 -1
- package/src/interface/webhook.spec.js +1 -1
- package/src/interface/workflow.d.ts +10 -9
- package/src/interface/workflow.js +76 -164
- package/src/interface/workflow.spec.js +637 -521
- package/src/interface/workflow_activity_options.js +16 -0
- package/src/interface/workflow_utils.js +1 -1
- package/src/interface/zod_integration.spec.js +2 -2
- package/src/internal_utils/aggregations.js +0 -10
- package/src/internal_utils/aggregations.spec.js +1 -48
- package/src/internal_utils/errors.js +14 -8
- package/src/internal_utils/errors.spec.js +73 -27
- package/src/utils/index.d.ts +19 -0
- package/src/utils/utils.js +46 -0
- package/src/utils/utils.spec.js +82 -1
- package/src/worker/bundle.js +26 -0
- package/src/worker/bundle.spec.js +52 -0
- package/src/worker/catalog_workflow/catalog_job.js +148 -0
- package/src/worker/catalog_workflow/catalog_job.spec.js +232 -0
- package/src/worker/check.js +24 -0
- package/src/worker/connection_monitor.js +112 -0
- package/src/worker/connection_monitor.spec.js +199 -0
- package/src/worker/index.js +140 -34
- package/src/worker/index.spec.js +280 -108
- package/src/worker/interceptors/activity.js +7 -24
- package/src/worker/interceptors/activity.spec.js +97 -66
- package/src/worker/interceptors/index.js +4 -7
- package/src/worker/interceptors/modules.js +15 -0
- package/src/worker/interceptors/workflow.js +4 -7
- package/src/worker/interceptors/workflow.spec.js +49 -42
- package/src/worker/interruption.js +33 -0
- package/src/worker/interruption.spec.js +86 -0
- package/src/worker/loader_tools.js +1 -1
- package/src/worker/loader_tools.spec.js +36 -0
- package/src/worker/{setup_telemetry.js → telemetry.js} +9 -4
- package/src/worker/{setup_telemetry.spec.js → telemetry.spec.js} +3 -3
- package/src/worker/webpack_loaders/workflow_rewriter/collect_target_imports.js +5 -109
- package/src/worker/webpack_loaders/workflow_rewriter/collect_target_imports.spec.js +31 -103
- package/src/worker/webpack_loaders/workflow_rewriter/index.mjs +5 -6
- package/src/worker/webpack_loaders/workflow_rewriter/index.spec.js +11 -83
- package/src/worker/webpack_loaders/workflow_rewriter/rewrite_fn_bodies.js +8 -11
- package/src/worker/webpack_loaders/workflow_rewriter/rewrite_fn_bodies.spec.js +9 -9
- package/src/interface/validations/runtime.js +0 -20
- package/src/interface/validations/runtime.spec.js +0 -29
- package/src/interface/validations/schema_utils.js +0 -8
- package/src/interface/validations/schema_utils.spec.js +0 -67
- package/src/interface/validations/static.js +0 -137
- package/src/interface/validations/static.spec.js +0 -397
- package/src/interface/workflow.replay_compatibility.spec.js +0 -254
- package/src/worker/shutdown.js +0 -26
- package/src/worker/shutdown.spec.js +0 -82
- package/src/worker/start_catalog.js +0 -96
- package/src/worker/start_catalog.spec.js +0 -179
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { WorkflowNotFoundError } from '@temporalio/client';
|
|
3
|
+
import { WorkflowExecutionAlreadyStartedError, WorkflowIdConflictPolicy } from '@temporalio/common';
|
|
4
|
+
import { CatalogJob } from './catalog_job.js';
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
catalogId,
|
|
8
|
+
describeMock,
|
|
9
|
+
executeUpdateMock,
|
|
10
|
+
mockLog,
|
|
11
|
+
queryMock,
|
|
12
|
+
taskQueue,
|
|
13
|
+
workflowStartMock
|
|
14
|
+
} = vi.hoisted( () => ( {
|
|
15
|
+
catalogId: 'test-catalog',
|
|
16
|
+
describeMock: vi.fn(),
|
|
17
|
+
executeUpdateMock: vi.fn(),
|
|
18
|
+
mockLog: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
19
|
+
queryMock: vi.fn(),
|
|
20
|
+
taskQueue: 'test-queue',
|
|
21
|
+
workflowStartMock: vi.fn()
|
|
22
|
+
} ) );
|
|
23
|
+
|
|
24
|
+
vi.mock( '#logger', () => ( { createChildLogger: () => mockLog } ) );
|
|
25
|
+
vi.mock( '#consts', () => ( { WORKFLOW_CATALOG: 'catalog' } ) );
|
|
26
|
+
vi.mock( '../configs.js', () => ( { catalogId, taskQueue } ) );
|
|
27
|
+
vi.mock( '@temporalio/client', async importOriginal => {
|
|
28
|
+
const actual = await importOriginal();
|
|
29
|
+
return {
|
|
30
|
+
...actual,
|
|
31
|
+
Client: vi.fn().mockImplementation( function () {
|
|
32
|
+
return {
|
|
33
|
+
workflow: {
|
|
34
|
+
start: workflowStartMock,
|
|
35
|
+
getHandle: () => ( { describe: describeMock, query: queryMock, executeUpdate: executeUpdateMock } )
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
} )
|
|
39
|
+
};
|
|
40
|
+
} );
|
|
41
|
+
|
|
42
|
+
const mockConnection = {};
|
|
43
|
+
const namespace = 'default';
|
|
44
|
+
const catalog = { workflows: [], activities: {} };
|
|
45
|
+
const catalogHash = 'catalog-hash';
|
|
46
|
+
|
|
47
|
+
const createJob = () => new CatalogJob( { connection: mockConnection, namespace, catalog, catalogHash } );
|
|
48
|
+
|
|
49
|
+
const flushPromises = async () => Array
|
|
50
|
+
.from( { length: 10 } )
|
|
51
|
+
.reduce( promise => promise.then( () => Promise.resolve() ), Promise.resolve() );
|
|
52
|
+
|
|
53
|
+
describe( 'CatalogJob', () => {
|
|
54
|
+
beforeEach( () => {
|
|
55
|
+
vi.clearAllMocks();
|
|
56
|
+
describeMock.mockResolvedValue( { closeTime: '2024-01-01T00:00:00Z' } );
|
|
57
|
+
executeUpdateMock.mockResolvedValue( undefined );
|
|
58
|
+
queryMock.mockResolvedValue( catalogHash );
|
|
59
|
+
workflowStartMock.mockResolvedValue( undefined );
|
|
60
|
+
} );
|
|
61
|
+
|
|
62
|
+
it( 'completes a previous running stale catalog before starting the new workflow', async () => {
|
|
63
|
+
describeMock.mockResolvedValue( { closeTime: undefined } );
|
|
64
|
+
queryMock.mockResolvedValue( 'old-catalog-hash' );
|
|
65
|
+
const job = createJob();
|
|
66
|
+
|
|
67
|
+
await job.run();
|
|
68
|
+
|
|
69
|
+
expect( describeMock ).toHaveBeenCalled();
|
|
70
|
+
expect( queryMock ).toHaveBeenCalledWith( 'get_hash' );
|
|
71
|
+
expect( executeUpdateMock ).toHaveBeenCalledWith( 'complete', { args: [] } );
|
|
72
|
+
expect( workflowStartMock ).toHaveBeenCalledWith( 'catalog', {
|
|
73
|
+
taskQueue,
|
|
74
|
+
workflowId: catalogId,
|
|
75
|
+
workflowIdConflictPolicy: WorkflowIdConflictPolicy.FAIL,
|
|
76
|
+
args: [ catalog, catalogHash ]
|
|
77
|
+
} );
|
|
78
|
+
expect( job.error ).toBeNull();
|
|
79
|
+
expect( job.running ).toBe( false );
|
|
80
|
+
} );
|
|
81
|
+
|
|
82
|
+
it( 'keeps the existing catalog workflow when the running hash matches', async () => {
|
|
83
|
+
describeMock.mockResolvedValue( { closeTime: undefined } );
|
|
84
|
+
queryMock.mockResolvedValue( catalogHash );
|
|
85
|
+
const job = createJob();
|
|
86
|
+
|
|
87
|
+
await job.run();
|
|
88
|
+
|
|
89
|
+
expect( queryMock ).toHaveBeenCalledWith( 'get_hash' );
|
|
90
|
+
expect( executeUpdateMock ).not.toHaveBeenCalled();
|
|
91
|
+
expect( workflowStartMock ).not.toHaveBeenCalled();
|
|
92
|
+
expect( mockLog.info ).toHaveBeenCalledWith( 'Current catalog workflow hash matches worker, restart skipped' );
|
|
93
|
+
expect( job.error ).toBeNull();
|
|
94
|
+
} );
|
|
95
|
+
|
|
96
|
+
it( 'starts the catalog workflow when no previous catalog exists', async () => {
|
|
97
|
+
describeMock.mockRejectedValue( new WorkflowNotFoundError( 'not found' ) );
|
|
98
|
+
const job = createJob();
|
|
99
|
+
|
|
100
|
+
await job.run();
|
|
101
|
+
|
|
102
|
+
expect( describeMock ).toHaveBeenCalled();
|
|
103
|
+
expect( queryMock ).not.toHaveBeenCalled();
|
|
104
|
+
expect( executeUpdateMock ).not.toHaveBeenCalled();
|
|
105
|
+
expect( workflowStartMock ).toHaveBeenCalledWith( 'catalog', {
|
|
106
|
+
taskQueue,
|
|
107
|
+
workflowId: catalogId,
|
|
108
|
+
workflowIdConflictPolicy: WorkflowIdConflictPolicy.FAIL,
|
|
109
|
+
args: [ catalog, catalogHash ]
|
|
110
|
+
} );
|
|
111
|
+
expect( mockLog.warn ).not.toHaveBeenCalled();
|
|
112
|
+
} );
|
|
113
|
+
|
|
114
|
+
it( 'starts the catalog workflow when the previous catalog is closed', async () => {
|
|
115
|
+
const job = createJob();
|
|
116
|
+
|
|
117
|
+
await job.run();
|
|
118
|
+
|
|
119
|
+
expect( describeMock ).toHaveBeenCalled();
|
|
120
|
+
expect( queryMock ).not.toHaveBeenCalled();
|
|
121
|
+
expect( executeUpdateMock ).not.toHaveBeenCalled();
|
|
122
|
+
expect( workflowStartMock ).toHaveBeenCalledWith( 'catalog', {
|
|
123
|
+
taskQueue,
|
|
124
|
+
workflowId: catalogId,
|
|
125
|
+
workflowIdConflictPolicy: WorkflowIdConflictPolicy.FAIL,
|
|
126
|
+
args: [ catalog, catalogHash ]
|
|
127
|
+
} );
|
|
128
|
+
} );
|
|
129
|
+
|
|
130
|
+
it( 'warns and continues when describing or completing the previous catalog fails', async () => {
|
|
131
|
+
const completeError = new Error( 'complete failed' );
|
|
132
|
+
describeMock.mockResolvedValue( { closeTime: undefined } );
|
|
133
|
+
queryMock.mockResolvedValue( 'old-catalog-hash' );
|
|
134
|
+
executeUpdateMock.mockRejectedValue( completeError );
|
|
135
|
+
const job = createJob();
|
|
136
|
+
|
|
137
|
+
await job.run();
|
|
138
|
+
|
|
139
|
+
expect( mockLog.warn ).toHaveBeenCalledWith( 'Error completing previous catalog workflow', { error: completeError } );
|
|
140
|
+
expect( workflowStartMock ).toHaveBeenCalledWith( 'catalog', expect.any( Object ) );
|
|
141
|
+
expect( job.error ).toBeNull();
|
|
142
|
+
} );
|
|
143
|
+
|
|
144
|
+
it( 'ignores an already-started error when the running catalog hash matches', async () => {
|
|
145
|
+
const alreadyStartedError = new WorkflowExecutionAlreadyStartedError( 'already started', catalogId, 'catalog' );
|
|
146
|
+
describeMock.mockRejectedValue( new WorkflowNotFoundError( 'not found' ) );
|
|
147
|
+
workflowStartMock.mockRejectedValue( alreadyStartedError );
|
|
148
|
+
queryMock.mockResolvedValue( catalogHash );
|
|
149
|
+
const job = createJob();
|
|
150
|
+
|
|
151
|
+
await job.run();
|
|
152
|
+
|
|
153
|
+
expect( workflowStartMock ).toHaveBeenCalledWith( 'catalog', expect.any( Object ) );
|
|
154
|
+
expect( queryMock ).toHaveBeenCalledWith( 'get_hash' );
|
|
155
|
+
expect( mockLog.info ).toHaveBeenCalledWith(
|
|
156
|
+
'Ignoring start error: it failed because execution already started but catalog hash matches worker'
|
|
157
|
+
);
|
|
158
|
+
expect( job.error ).toBeNull();
|
|
159
|
+
} );
|
|
160
|
+
|
|
161
|
+
it( 'stores start errors and calls the error callback', async () => {
|
|
162
|
+
const error = new Error( 'start failed' );
|
|
163
|
+
const onError = vi.fn();
|
|
164
|
+
workflowStartMock.mockRejectedValue( error );
|
|
165
|
+
const job = createJob();
|
|
166
|
+
|
|
167
|
+
job.onError( onError );
|
|
168
|
+
await job.run();
|
|
169
|
+
|
|
170
|
+
expect( job.error ).toBe( error );
|
|
171
|
+
expect( onError ).toHaveBeenCalledWith( error );
|
|
172
|
+
expect( job.running ).toBe( false );
|
|
173
|
+
} );
|
|
174
|
+
|
|
175
|
+
it( 'stores stale already-started errors and calls the error callback', async () => {
|
|
176
|
+
const alreadyStartedError = new WorkflowExecutionAlreadyStartedError( 'already started', catalogId, 'catalog' );
|
|
177
|
+
const onError = vi.fn();
|
|
178
|
+
describeMock.mockRejectedValue( new WorkflowNotFoundError( 'not found' ) );
|
|
179
|
+
workflowStartMock.mockRejectedValue( alreadyStartedError );
|
|
180
|
+
queryMock.mockResolvedValue( 'old-catalog-hash' );
|
|
181
|
+
const job = createJob();
|
|
182
|
+
|
|
183
|
+
job.onError( onError );
|
|
184
|
+
await job.run();
|
|
185
|
+
|
|
186
|
+
expect( job.error ).toBe( alreadyStartedError );
|
|
187
|
+
expect( onError ).toHaveBeenCalledWith( alreadyStartedError );
|
|
188
|
+
expect( job.running ).toBe( false );
|
|
189
|
+
} );
|
|
190
|
+
|
|
191
|
+
it( 'reports running while a catalog call is pending', async () => {
|
|
192
|
+
describeMock.mockReturnValue( new Promise( () => {} ) );
|
|
193
|
+
const job = createJob();
|
|
194
|
+
|
|
195
|
+
const run = job.run();
|
|
196
|
+
await flushPromises();
|
|
197
|
+
|
|
198
|
+
expect( job.running ).toBe( true );
|
|
199
|
+
|
|
200
|
+
await job.interrupt();
|
|
201
|
+
await run;
|
|
202
|
+
|
|
203
|
+
expect( job.running ).toBe( false );
|
|
204
|
+
expect( job.error ).toBeNull();
|
|
205
|
+
} );
|
|
206
|
+
|
|
207
|
+
it( 'interrupts pending catalog calls without storing an error or calling the callback', async () => {
|
|
208
|
+
const onError = vi.fn();
|
|
209
|
+
describeMock.mockReturnValue( new Promise( () => {} ) );
|
|
210
|
+
const job = createJob();
|
|
211
|
+
|
|
212
|
+
job.onError( onError );
|
|
213
|
+
const run = job.run();
|
|
214
|
+
await flushPromises();
|
|
215
|
+
|
|
216
|
+
const interrupt = job.interrupt();
|
|
217
|
+
|
|
218
|
+
await expect( interrupt ).resolves.toBeUndefined();
|
|
219
|
+
await expect( run ).resolves.toBeUndefined();
|
|
220
|
+
expect( job.error ).toBeNull();
|
|
221
|
+
expect( onError ).not.toHaveBeenCalled();
|
|
222
|
+
expect( job.running ).toBe( false );
|
|
223
|
+
} );
|
|
224
|
+
|
|
225
|
+
it( 'returns a resolved promise when interrupted before running', async () => {
|
|
226
|
+
const job = createJob();
|
|
227
|
+
|
|
228
|
+
await expect( job.interrupt() ).resolves.toBeUndefined();
|
|
229
|
+
expect( job.running ).toBe( false );
|
|
230
|
+
expect( job.error ).toBeNull();
|
|
231
|
+
} );
|
|
232
|
+
} );
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { bundleWorkflows } from './bundle.js';
|
|
2
|
+
|
|
3
|
+
// `output-worker --check` entry: bundle the workflows exactly as the worker would, then
|
|
4
|
+
// exit 0 (ok) / 1 (fail). The exit code is the signal. Workflow-discovery logs write to
|
|
5
|
+
// stdout via the worker logger, so mute stdout to keep output clean for CI/tooling; only
|
|
6
|
+
// real errors surface, on stderr. Catches bad workflow imports (e.g. `node:` built-ins)
|
|
7
|
+
// at build/CI time instead of crash-looping the worker at startup.
|
|
8
|
+
const callerDir = process.argv[2] ?? process.cwd();
|
|
9
|
+
|
|
10
|
+
process.stdout.write = ( ...args ) => {
|
|
11
|
+
const callback = args.find( arg => typeof arg === 'function' );
|
|
12
|
+
if ( callback ) {
|
|
13
|
+
callback();
|
|
14
|
+
}
|
|
15
|
+
return true;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
( async () => {
|
|
19
|
+
await bundleWorkflows( callerDir );
|
|
20
|
+
process.exit( 0 );
|
|
21
|
+
} )().catch( error => {
|
|
22
|
+
console.error( error );
|
|
23
|
+
process.exit( 1 );
|
|
24
|
+
} );
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { createChildLogger } from '#logger';
|
|
2
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
3
|
+
import { CancellablePromise } from '#utils';
|
|
4
|
+
|
|
5
|
+
const ServingStatus = {
|
|
6
|
+
UNKNOWN: 0,
|
|
7
|
+
SERVING: 1,
|
|
8
|
+
NOT_SERVING: 2,
|
|
9
|
+
SERVICE_UNKNOWN: 3
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const log = createChildLogger( 'Connection' );
|
|
13
|
+
|
|
14
|
+
export class TemporalConnectionMonitor {
|
|
15
|
+
#MAX_FAILURES = 3;
|
|
16
|
+
#CHECK_INTERVAL_MS = 60_000;
|
|
17
|
+
#CHECK_TIMEOUT_MS = 5_000;
|
|
18
|
+
|
|
19
|
+
#cancellation = new CancellablePromise();
|
|
20
|
+
#failures = 0;
|
|
21
|
+
#error = null;
|
|
22
|
+
#running = false;
|
|
23
|
+
#watchPromise = null;
|
|
24
|
+
#connection = null;
|
|
25
|
+
#connectionLostCb = null;
|
|
26
|
+
|
|
27
|
+
#getTimeout = async () => delay( this.#CHECK_TIMEOUT_MS, 0, { ref: false } ).then( () => {
|
|
28
|
+
throw new Error( 'Connection health check timed out' );
|
|
29
|
+
} );
|
|
30
|
+
|
|
31
|
+
#healthcheck = async () => this.#connection.healthService.check( {} );
|
|
32
|
+
|
|
33
|
+
#sleep = async () => delay( this.#CHECK_INTERVAL_MS, 0, { ref: false } );
|
|
34
|
+
|
|
35
|
+
#watch = async () => {
|
|
36
|
+
while ( !this.#cancellation.completed ) {
|
|
37
|
+
try {
|
|
38
|
+
const health = await Promise.race( [ this.#healthcheck(), this.#getTimeout(), this.#cancellation.promise ] );
|
|
39
|
+
|
|
40
|
+
// cancellation won the race
|
|
41
|
+
if ( this.#cancellation.completed ) {
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if ( health?.status !== ServingStatus.SERVING ) {
|
|
46
|
+
throw new Error( `Connection not serving (status ${health?.status})` );
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
log.info( this.#failures === 0 ? 'Healthy' : 'Recovered' );
|
|
50
|
+
this.#failures = 0;
|
|
51
|
+
} catch ( error ) {
|
|
52
|
+
// cancellation will ignore warnings and not throw errors;
|
|
53
|
+
if ( this.#cancellation.completed ) {
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if ( ++this.#failures >= this.#MAX_FAILURES ) {
|
|
58
|
+
log.warn( 'Connection lost', { error: error.message, failures: this.#failures } );
|
|
59
|
+
this.#error = error;
|
|
60
|
+
this.#connectionLostCb?.( error );
|
|
61
|
+
this.#cancellation.complete();
|
|
62
|
+
break;
|
|
63
|
+
} else {
|
|
64
|
+
log.warn( 'Connection unhealthy', { error: error.message, failures: this.#failures } );
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
await Promise.race( [ this.#sleep(), this.#cancellation.promise ] );
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
constructor( connection, overrides = {} ) {
|
|
73
|
+
this.#connection = connection;
|
|
74
|
+
if ( Number.isFinite( overrides?.maxFailures ) ) {
|
|
75
|
+
this.#MAX_FAILURES = overrides.maxFailures;
|
|
76
|
+
}
|
|
77
|
+
if ( Number.isFinite( overrides?.checkIntervalMs ) ) {
|
|
78
|
+
this.#CHECK_INTERVAL_MS = overrides.checkIntervalMs;
|
|
79
|
+
}
|
|
80
|
+
if ( Number.isFinite( overrides?.checkTimeoutMs ) ) {
|
|
81
|
+
this.#CHECK_TIMEOUT_MS = overrides.checkTimeoutMs;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
onConnectionLost( cb ) {
|
|
86
|
+
this.#connectionLostCb = cb;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
get running() {
|
|
90
|
+
return this.#running;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
start() {
|
|
94
|
+
if ( this.#watchPromise ) {
|
|
95
|
+
return this.#watchPromise;
|
|
96
|
+
}
|
|
97
|
+
this.#running = true;
|
|
98
|
+
this.#watchPromise = this.#watch().finally( () => {
|
|
99
|
+
this.#running = false;
|
|
100
|
+
} );
|
|
101
|
+
return this.#watchPromise;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
stop() {
|
|
105
|
+
this.#cancellation.complete();
|
|
106
|
+
return this.#watchPromise ?? Promise.resolve();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
get connectionLossError() {
|
|
110
|
+
return this.#error;
|
|
111
|
+
}
|
|
112
|
+
};
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { TemporalConnectionMonitor } from './connection_monitor.js';
|
|
3
|
+
|
|
4
|
+
const SERVING = 1;
|
|
5
|
+
const NOT_SERVING = 2;
|
|
6
|
+
const CHECK_TIMEOUT_MS = 50;
|
|
7
|
+
const CHECK_INTERVAL_MS = 100;
|
|
8
|
+
|
|
9
|
+
const { scheduledDelays, delayMock, mockLogger } = vi.hoisted( () => {
|
|
10
|
+
const scheduledDelays = [];
|
|
11
|
+
const delayMock = vi.fn( ( ms, value, options ) => new Promise( resolve => {
|
|
12
|
+
scheduledDelays.push( { ms, value, options, resolve } );
|
|
13
|
+
} ) );
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
scheduledDelays,
|
|
17
|
+
delayMock,
|
|
18
|
+
mockLogger: {
|
|
19
|
+
info: vi.fn(),
|
|
20
|
+
warn: vi.fn()
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
} );
|
|
24
|
+
|
|
25
|
+
vi.mock( 'node:timers/promises', () => ( { setTimeout: delayMock } ) );
|
|
26
|
+
vi.mock( '#logger', () => ( { createChildLogger: vi.fn( () => mockLogger ) } ) );
|
|
27
|
+
|
|
28
|
+
const createConnection = check => ( {
|
|
29
|
+
healthService: { check }
|
|
30
|
+
} );
|
|
31
|
+
|
|
32
|
+
const createMonitor = ( check, overrides = {} ) => new TemporalConnectionMonitor( createConnection( check ), {
|
|
33
|
+
checkIntervalMs: CHECK_INTERVAL_MS,
|
|
34
|
+
checkTimeoutMs: CHECK_TIMEOUT_MS,
|
|
35
|
+
...overrides
|
|
36
|
+
} );
|
|
37
|
+
|
|
38
|
+
const flushPromises = async () => Array
|
|
39
|
+
.from( { length: 10 } )
|
|
40
|
+
.reduce( promise => promise.then( () => Promise.resolve() ), Promise.resolve() );
|
|
41
|
+
|
|
42
|
+
const resolveNextDelay = ms => {
|
|
43
|
+
const index = scheduledDelays.findIndex( delay => delay.ms === ms );
|
|
44
|
+
expect( index ).not.toBe( -1 );
|
|
45
|
+
const [ scheduled ] = scheduledDelays.splice( index, 1 );
|
|
46
|
+
scheduled.resolve( scheduled.value );
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
describe( 'TemporalConnectionMonitor', () => {
|
|
50
|
+
beforeEach( () => {
|
|
51
|
+
vi.clearAllMocks();
|
|
52
|
+
scheduledDelays.length = 0;
|
|
53
|
+
} );
|
|
54
|
+
|
|
55
|
+
it( 'logs healthy when the connection is serving', async () => {
|
|
56
|
+
const check = vi.fn().mockResolvedValue( { status: SERVING } );
|
|
57
|
+
const monitor = createMonitor( check );
|
|
58
|
+
|
|
59
|
+
const run = monitor.start();
|
|
60
|
+
await flushPromises();
|
|
61
|
+
|
|
62
|
+
expect( check ).toHaveBeenCalledWith( {} );
|
|
63
|
+
expect( mockLogger.info ).toHaveBeenCalledWith( 'Healthy' );
|
|
64
|
+
expect( delayMock ).toHaveBeenCalledWith( CHECK_TIMEOUT_MS, 0, { ref: false } );
|
|
65
|
+
expect( delayMock ).toHaveBeenCalledWith( CHECK_INTERVAL_MS, 0, { ref: false } );
|
|
66
|
+
expect( monitor.running ).toBe( true );
|
|
67
|
+
|
|
68
|
+
await monitor.stop();
|
|
69
|
+
await run;
|
|
70
|
+
|
|
71
|
+
expect( monitor.running ).toBe( false );
|
|
72
|
+
} );
|
|
73
|
+
|
|
74
|
+
it( 'logs transient timeout failures before retrying', async () => {
|
|
75
|
+
const check = vi.fn().mockReturnValue( new Promise( () => {} ) );
|
|
76
|
+
const monitor = createMonitor( check );
|
|
77
|
+
|
|
78
|
+
monitor.start();
|
|
79
|
+
resolveNextDelay( CHECK_TIMEOUT_MS );
|
|
80
|
+
await flushPromises();
|
|
81
|
+
|
|
82
|
+
expect( mockLogger.warn ).toHaveBeenCalledWith( 'Connection unhealthy', {
|
|
83
|
+
error: 'Connection health check timed out',
|
|
84
|
+
failures: 1
|
|
85
|
+
} );
|
|
86
|
+
|
|
87
|
+
await monitor.stop();
|
|
88
|
+
} );
|
|
89
|
+
|
|
90
|
+
it( 'logs recovered after a transient failure succeeds', async () => {
|
|
91
|
+
const check = vi.fn()
|
|
92
|
+
.mockRejectedValueOnce( new Error( 'temporary outage' ) )
|
|
93
|
+
.mockResolvedValueOnce( { status: SERVING } );
|
|
94
|
+
const monitor = createMonitor( check );
|
|
95
|
+
|
|
96
|
+
monitor.start();
|
|
97
|
+
await flushPromises();
|
|
98
|
+
|
|
99
|
+
expect( mockLogger.warn ).toHaveBeenCalledWith( 'Connection unhealthy', {
|
|
100
|
+
error: 'temporary outage',
|
|
101
|
+
failures: 1
|
|
102
|
+
} );
|
|
103
|
+
|
|
104
|
+
resolveNextDelay( CHECK_INTERVAL_MS );
|
|
105
|
+
await flushPromises();
|
|
106
|
+
|
|
107
|
+
expect( mockLogger.info ).toHaveBeenCalledWith( 'Recovered' );
|
|
108
|
+
|
|
109
|
+
await monitor.stop();
|
|
110
|
+
} );
|
|
111
|
+
|
|
112
|
+
it( 'stores connection loss error and calls callback after max consecutive failures', async () => {
|
|
113
|
+
const error = new Error( 'connection refused' );
|
|
114
|
+
const check = vi.fn().mockRejectedValue( error );
|
|
115
|
+
const connectionLost = vi.fn();
|
|
116
|
+
const monitor = createMonitor( check );
|
|
117
|
+
|
|
118
|
+
monitor.onConnectionLost( connectionLost );
|
|
119
|
+
monitor.start();
|
|
120
|
+
|
|
121
|
+
await flushPromises();
|
|
122
|
+
resolveNextDelay( CHECK_INTERVAL_MS );
|
|
123
|
+
await flushPromises();
|
|
124
|
+
resolveNextDelay( CHECK_INTERVAL_MS );
|
|
125
|
+
await flushPromises();
|
|
126
|
+
|
|
127
|
+
expect( mockLogger.warn ).toHaveBeenCalledTimes( 3 );
|
|
128
|
+
expect( mockLogger.warn ).toHaveBeenCalledWith( 'Connection lost', {
|
|
129
|
+
error: 'connection refused',
|
|
130
|
+
failures: 3
|
|
131
|
+
} );
|
|
132
|
+
expect( connectionLost ).toHaveBeenCalledWith( error );
|
|
133
|
+
expect( monitor.connectionLossError ).toBe( error );
|
|
134
|
+
expect( monitor.running ).toBe( false );
|
|
135
|
+
} );
|
|
136
|
+
|
|
137
|
+
it( 'treats non-serving health status as a failure', async () => {
|
|
138
|
+
const check = vi.fn().mockResolvedValue( { status: NOT_SERVING } );
|
|
139
|
+
const monitor = createMonitor( check );
|
|
140
|
+
|
|
141
|
+
monitor.start();
|
|
142
|
+
await flushPromises();
|
|
143
|
+
|
|
144
|
+
expect( mockLogger.warn ).toHaveBeenCalledWith( 'Connection unhealthy', {
|
|
145
|
+
error: `Connection not serving (status ${NOT_SERVING})`,
|
|
146
|
+
failures: 1
|
|
147
|
+
} );
|
|
148
|
+
|
|
149
|
+
await monitor.stop();
|
|
150
|
+
} );
|
|
151
|
+
|
|
152
|
+
it( 'returns the same lifecycle promise when started more than once', async () => {
|
|
153
|
+
const check = vi.fn().mockReturnValue( new Promise( () => {} ) );
|
|
154
|
+
const monitor = createMonitor( check );
|
|
155
|
+
|
|
156
|
+
const firstRun = monitor.start();
|
|
157
|
+
const secondRun = monitor.start();
|
|
158
|
+
|
|
159
|
+
expect( secondRun ).toBe( firstRun );
|
|
160
|
+
expect( check ).toHaveBeenCalledOnce();
|
|
161
|
+
|
|
162
|
+
await monitor.stop();
|
|
163
|
+
} );
|
|
164
|
+
|
|
165
|
+
it( 'stops without calling connection lost callback for in-flight health checks', async () => {
|
|
166
|
+
const check = vi.fn().mockReturnValue( new Promise( () => {} ) );
|
|
167
|
+
const connectionLost = vi.fn();
|
|
168
|
+
const monitor = createMonitor( check, { maxFailures: 1 } );
|
|
169
|
+
|
|
170
|
+
monitor.onConnectionLost( connectionLost );
|
|
171
|
+
monitor.start();
|
|
172
|
+
|
|
173
|
+
expect( monitor.running ).toBe( true );
|
|
174
|
+
|
|
175
|
+
await monitor.stop();
|
|
176
|
+
|
|
177
|
+
expect( connectionLost ).not.toHaveBeenCalled();
|
|
178
|
+
expect( monitor.connectionLossError ).toBeNull();
|
|
179
|
+
expect( monitor.running ).toBe( false );
|
|
180
|
+
} );
|
|
181
|
+
|
|
182
|
+
it( 'applies timing and failure threshold overrides', async () => {
|
|
183
|
+
const error = new Error( 'fast failure' );
|
|
184
|
+
const check = vi.fn().mockRejectedValue( error );
|
|
185
|
+
const connectionLost = vi.fn();
|
|
186
|
+
const monitor = createMonitor( check, {
|
|
187
|
+
maxFailures: 1,
|
|
188
|
+
checkIntervalMs: 7,
|
|
189
|
+
checkTimeoutMs: 3
|
|
190
|
+
} );
|
|
191
|
+
|
|
192
|
+
monitor.onConnectionLost( connectionLost );
|
|
193
|
+
await monitor.start();
|
|
194
|
+
|
|
195
|
+
expect( delayMock ).toHaveBeenCalledWith( 3, 0, { ref: false } );
|
|
196
|
+
expect( delayMock ).not.toHaveBeenCalledWith( 7, 0, { ref: false } );
|
|
197
|
+
expect( connectionLost ).toHaveBeenCalledWith( error );
|
|
198
|
+
} );
|
|
199
|
+
} );
|