@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.
Files changed (66) hide show
  1. package/bin/worker.sh +6 -0
  2. package/package.json +1 -1
  3. package/src/consts.js +0 -4
  4. package/src/errors.js +6 -2
  5. package/src/hooks/index.d.ts +10 -0
  6. package/src/interface/evaluator.js +7 -20
  7. package/src/interface/evaluator.spec.js +117 -1
  8. package/src/interface/step.js +8 -9
  9. package/src/interface/step.spec.js +124 -0
  10. package/src/interface/validations/index.js +108 -0
  11. package/src/interface/validations/index.spec.js +182 -0
  12. package/src/interface/validations/schemas.js +113 -0
  13. package/src/interface/validations/schemas.spec.js +209 -0
  14. package/src/interface/webhook.js +1 -1
  15. package/src/interface/webhook.spec.js +1 -1
  16. package/src/interface/workflow.d.ts +10 -9
  17. package/src/interface/workflow.js +76 -164
  18. package/src/interface/workflow.spec.js +637 -521
  19. package/src/interface/workflow_activity_options.js +16 -0
  20. package/src/interface/workflow_utils.js +1 -1
  21. package/src/interface/zod_integration.spec.js +2 -2
  22. package/src/internal_utils/aggregations.js +0 -10
  23. package/src/internal_utils/aggregations.spec.js +1 -48
  24. package/src/internal_utils/errors.js +14 -8
  25. package/src/internal_utils/errors.spec.js +73 -27
  26. package/src/utils/index.d.ts +19 -0
  27. package/src/utils/utils.js +46 -0
  28. package/src/utils/utils.spec.js +82 -1
  29. package/src/worker/bundle.js +26 -0
  30. package/src/worker/bundle.spec.js +52 -0
  31. package/src/worker/catalog_workflow/catalog_job.js +148 -0
  32. package/src/worker/catalog_workflow/catalog_job.spec.js +232 -0
  33. package/src/worker/check.js +24 -0
  34. package/src/worker/connection_monitor.js +112 -0
  35. package/src/worker/connection_monitor.spec.js +199 -0
  36. package/src/worker/index.js +140 -34
  37. package/src/worker/index.spec.js +280 -108
  38. package/src/worker/interceptors/activity.js +7 -24
  39. package/src/worker/interceptors/activity.spec.js +97 -66
  40. package/src/worker/interceptors/index.js +4 -7
  41. package/src/worker/interceptors/modules.js +15 -0
  42. package/src/worker/interceptors/workflow.js +4 -7
  43. package/src/worker/interceptors/workflow.spec.js +49 -42
  44. package/src/worker/interruption.js +33 -0
  45. package/src/worker/interruption.spec.js +86 -0
  46. package/src/worker/loader_tools.js +1 -1
  47. package/src/worker/loader_tools.spec.js +36 -0
  48. package/src/worker/{setup_telemetry.js → telemetry.js} +9 -4
  49. package/src/worker/{setup_telemetry.spec.js → telemetry.spec.js} +3 -3
  50. package/src/worker/webpack_loaders/workflow_rewriter/collect_target_imports.js +5 -109
  51. package/src/worker/webpack_loaders/workflow_rewriter/collect_target_imports.spec.js +31 -103
  52. package/src/worker/webpack_loaders/workflow_rewriter/index.mjs +5 -6
  53. package/src/worker/webpack_loaders/workflow_rewriter/index.spec.js +11 -83
  54. package/src/worker/webpack_loaders/workflow_rewriter/rewrite_fn_bodies.js +8 -11
  55. package/src/worker/webpack_loaders/workflow_rewriter/rewrite_fn_bodies.spec.js +9 -9
  56. package/src/interface/validations/runtime.js +0 -20
  57. package/src/interface/validations/runtime.spec.js +0 -29
  58. package/src/interface/validations/schema_utils.js +0 -8
  59. package/src/interface/validations/schema_utils.spec.js +0 -67
  60. package/src/interface/validations/static.js +0 -137
  61. package/src/interface/validations/static.spec.js +0 -397
  62. package/src/interface/workflow.replay_compatibility.spec.js +0 -254
  63. package/src/worker/shutdown.js +0 -26
  64. package/src/worker/shutdown.spec.js +0 -82
  65. package/src/worker/start_catalog.js +0 -96
  66. package/src/worker/start_catalog.spec.js +0 -179
@@ -7,34 +7,44 @@ import { init as initTracing } from '#tracing';
7
7
  import { webpackConfigHook } from './bundler_options.js';
8
8
  import { initInterceptors } from './interceptors/index.js';
9
9
  import { createChildLogger } from '#logger';
10
- import { registerShutdown } from './shutdown.js';
11
- import { startCatalog } from './start_catalog.js';
10
+ import { setupInterruptionHandler } from './interruption.js';
11
+ import { CatalogJob } from './catalog_workflow/catalog_job.js';
12
12
  import { bootstrapFetchProxy } from './proxy.js';
13
13
  import { messageBus } from '#bus';
14
14
  import { BusEventType } from '#consts';
15
15
  import { hashSourceCode } from './loader_tools.js';
16
- import { setupTelemetry } from './setup_telemetry.js';
16
+ import { setupTelemetry } from './telemetry.js';
17
+ import { TemporalConnectionMonitor } from './connection_monitor.js';
18
+ import { runOnce } from '#utils';
19
+
17
20
  import './log_hooks.js';
18
21
 
19
22
  const log = createChildLogger( 'Worker' );
20
23
 
24
+ const {
25
+ address,
26
+ apiKey,
27
+ namespace,
28
+ taskQueue,
29
+ grpcProxy,
30
+ maxConcurrentWorkflowTaskExecutions,
31
+ maxConcurrentActivityTaskExecutions,
32
+ maxCachedWorkflows,
33
+ maxConcurrentActivityTaskPolls,
34
+ maxConcurrentWorkflowTaskPolls
35
+ } = configs;
36
+
37
+ const state = {
38
+ connection: null,
39
+ connectionMonitor: null,
40
+ catalogJob: null,
41
+ workerError: null
42
+ };
43
+
21
44
  // Get caller directory from command line arguments
22
45
  const callerDir = process.argv[2];
23
46
 
24
- ( async () => {
25
- const {
26
- address,
27
- apiKey,
28
- namespace,
29
- taskQueue,
30
- grpcProxy,
31
- maxConcurrentWorkflowTaskExecutions,
32
- maxConcurrentActivityTaskExecutions,
33
- maxCachedWorkflows,
34
- maxConcurrentActivityTaskPolls,
35
- maxConcurrentWorkflowTaskPolls
36
- } = configs;
37
-
47
+ const execute = async () => {
38
48
  log.info( 'Loading config...', { callerDir } );
39
49
  await loadHooks( callerDir );
40
50
 
@@ -64,17 +74,23 @@ const callerDir = process.argv[2];
64
74
  if ( proxy ) {
65
75
  log.info( 'Using gRPC proxy', { targetHost: grpcProxy } );
66
76
  }
67
- const connection = await NativeConnection.connect( { address, tls: Boolean( apiKey ), apiKey, proxy } );
77
+ state.connection = await NativeConnection.connect( { address, tls: Boolean( apiKey ), apiKey, proxy } );
78
+
79
+ log.info( 'Creating connection monitor...' );
80
+ state.connectionMonitor = new TemporalConnectionMonitor( state.connection );
81
+
82
+ log.info( 'Creating catalog job manager...' );
83
+ state.catalogJob = new CatalogJob( { connection: state.connection, namespace, catalog, catalogHash } );
68
84
 
69
85
  log.info( 'Creating worker...' );
70
86
  const worker = await Worker.create( {
71
- connection,
87
+ connection: state.connection,
72
88
  namespace,
73
89
  taskQueue,
74
90
  workflowsPath,
75
91
  activities,
76
92
  sinks,
77
- interceptors: initInterceptors( { activities, workflows, connection } ),
93
+ interceptors: initInterceptors( { activities, workflows } ),
78
94
  maxConcurrentWorkflowTaskExecutions,
79
95
  maxConcurrentActivityTaskExecutions,
80
96
  maxCachedWorkflows,
@@ -83,22 +99,112 @@ const callerDir = process.argv[2];
83
99
  bundlerOptions: { webpackConfigHook }
84
100
  } );
85
101
 
86
- registerShutdown( { worker, log } );
87
-
102
+ log.info( 'Setting up telemetry...' );
88
103
  setupTelemetry( { worker } );
89
104
 
90
- log.info( 'Running worker...' );
91
- await Promise.all( [ worker.run(), startCatalog( { connection, namespace, catalog, catalogHash } ) ] );
105
+ /**
106
+ * NOTE
107
+ * Temporal worker shutdown is a bit odd.
108
+ * worker.run() is an async job that only resolves when calling worker.shutdown().
109
+ * But worker.shutdown() is not async and returns nothing, so there is no way to await it.
110
+ * All code that needs to run after shutdown needs to be after `await worker.run()`.
111
+ *
112
+ * The following code needs to cover these scenarios:
113
+ * 1. Connection monitor detects connection loss
114
+ * 2. Catalog.run() has a failure
115
+ * 3. Interruption is received
116
+ * 4. Worker throws an error
117
+ *
118
+ * For each scenario all promises in the Promise.all() need to be completed via functions:
119
+ * connectionMonitor.stop(), catalogJob.interrupt(), worker.shutdown()
120
+ */
121
+
122
+ /**
123
+ * Graceful shutdown
124
+ * Triggers the actions that will resolve all promises in the Promise.all(), so the code can resume
125
+ */
126
+ const shutdown = runOnce( () => {
127
+ log.info( 'Shutdown started...' );
128
+ if ( worker.getStatus().runState === 'RUNNING' ) {
129
+ worker.shutdown();
130
+ }
131
+ state.connectionMonitor.stop();
132
+ state.catalogJob.interrupt();
133
+ } );
92
134
 
93
- log.info( 'Closing connection...' );
94
- await connection.close();
135
+ /** When receiving an interruption, call shutdown */
136
+ setupInterruptionHandler( shutdown );
95
137
 
96
- log.info( 'Bye' );
138
+ /** When the connection is lost, call shutdown */
139
+ state.connectionMonitor.onConnectionLost( shutdown );
97
140
 
98
- process.exit( 0 );
99
- } )().catch( error => {
100
- log.error( 'Fatal error', { message: error.message, stack: error.stack } );
101
- messageBus.emit( BusEventType.RUNTIME_ERROR, { error } );
102
- log.info( `Exiting in ${configs.processFailureShutdownDelay}ms` );
103
- setTimeout( () => process.exit( 1 ), configs.processFailureShutdownDelay );
104
- } );
141
+ /** If the catalog job manager fails, call shutdown */
142
+ state.catalogJob.onError( shutdown );
143
+
144
+ /**
145
+ * Runs the worker, connection monitor and catalogJob (ephemeral)
146
+ * None of these will reject in normal conditions. Errors need to be inspected later
147
+ * They will resolve only when calling the actions in shutdown(),
148
+ * except catalogJob, which can resolve by itself given a bit of time
149
+ */
150
+ log.info( 'Running worker...' );
151
+ await Promise.all( [
152
+ // When the worker fails, store the error and call shutdown
153
+ worker.run().catch( error => {
154
+ state.workerError = error;
155
+ shutdown();
156
+ } ),
157
+ state.connectionMonitor.start(),
158
+ state.catalogJob.run()
159
+ ] );
160
+
161
+ log.info( 'Worker terminated' );
162
+
163
+ /** After the Promise.all() is resolved, check which services had an error, since none rejects the promise */
164
+ const error =
165
+ state.connectionMonitor.connectionLossError ??
166
+ state.catalogJob.error ??
167
+ state.workerError;
168
+
169
+ /** If any error is found, throws it, so this process can exit with code=1 (failure) */
170
+ if ( error ) {
171
+ throw error;
172
+ }
173
+ };
174
+
175
+ execute()
176
+ .finally( async () => {
177
+ /**
178
+ * This will make sure that if we had any uncaught failures, everything is tore down.
179
+ * Ignore any errors here in order to not mask actually errors from before and because at this point
180
+ * the code is already shutting down, so no need to throw anyway.
181
+ *
182
+ * worker.shutdown() is not tried here, because it cannot be awaited. By this point is safe to say
183
+ * that worker never started, already crashed or was stopped anyway.
184
+ */
185
+ if ( state.connectionMonitor?.running ) {
186
+ log.info( 'Stopping connection monitor...' );
187
+ await state.connectionMonitor.stop()
188
+ .catch( e => log.warn( 'Connection monitor stop error', { error: e.message } ) );
189
+ }
190
+ if ( state.catalogJob?.running ) {
191
+ log.info( 'Interrupting catalog job...' );
192
+ await state.catalogJob.interrupt()
193
+ .catch( e => log.warn( 'Catalog job interruption error', { error: e.message } ) );
194
+ }
195
+ if ( state.connection ) {
196
+ log.info( 'Closing connection...' );
197
+ await state.connection.close()
198
+ .catch( e => log.warn( 'Connection close error', { error: e.message } ) );
199
+ }
200
+ } )
201
+ .then( () => log.info( 'Bye' ) )
202
+ .catch( error => {
203
+ log.error( 'Fatal error', { error: error.message, stack: error.stack } );
204
+
205
+ messageBus.emit( BusEventType.RUNTIME_ERROR, { error } );
206
+
207
+ const timeToFlushEvent = configs.processFailureShutdownDelay;
208
+ log.info( `Exiting in ${timeToFlushEvent}ms` );
209
+ setTimeout( () => process.exit( 1 ), timeToFlushEvent );
210
+ } );
@@ -1,125 +1,232 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const {
4
+ catalogJobInstance,
5
+ configValues,
6
+ connectionMonitorInstance,
7
+ createCatalogMock,
8
+ createWorkflowsEntryPointMock,
9
+ hashSourceCodeMock,
10
+ initInterceptorsMock,
11
+ loadActivitiesMock,
12
+ loadHooksMock,
13
+ loadWorkflowsMock,
14
+ messageBusMock,
15
+ mockConnection,
16
+ mockLog,
17
+ mockWorker,
18
+ promises,
19
+ resetPromises,
20
+ setupInterruptionHandlerMock,
21
+ setupTelemetryMock
22
+ } = vi.hoisted( () => {
23
+ const createDeferred = () => {
24
+ const state = {};
25
+ state.promise = new Promise( ( resolve, reject ) => {
26
+ state.resolve = resolve;
27
+ state.reject = reject;
28
+ } );
29
+ return state;
30
+ };
31
+
32
+ const promises = {};
33
+ const resetPromises = () => {
34
+ promises.workerRun = createDeferred();
35
+ promises.connectionMonitor = createDeferred();
36
+ promises.catalogJob = createDeferred();
37
+ };
38
+ resetPromises();
39
+
40
+ const configValues = {
41
+ address: 'localhost:7233',
42
+ apiKey: undefined,
43
+ namespace: 'default',
44
+ taskQueue: 'test-queue',
45
+ catalogId: 'test-catalog',
46
+ grpcProxy: undefined,
47
+ maxConcurrentWorkflowTaskExecutions: 200,
48
+ maxConcurrentActivityTaskExecutions: 40,
49
+ maxCachedWorkflows: 1000,
50
+ maxConcurrentActivityTaskPolls: 5,
51
+ maxConcurrentWorkflowTaskPolls: 5,
52
+ processFailureShutdownDelay: 0
53
+ };
54
+
55
+ const connectionMonitorInstance = {
56
+ running: false,
57
+ connectionLossError: null,
58
+ onConnectionLost: vi.fn( cb => {
59
+ connectionMonitorInstance.connectionLostCb = cb;
60
+ } ),
61
+ start: vi.fn( () => {
62
+ connectionMonitorInstance.running = true;
63
+ return promises.connectionMonitor.promise.finally( () => {
64
+ connectionMonitorInstance.running = false;
65
+ } );
66
+ } ),
67
+ stop: vi.fn( () => {
68
+ connectionMonitorInstance.running = false;
69
+ promises.connectionMonitor.resolve();
70
+ return promises.connectionMonitor.promise;
71
+ } ),
72
+ connectionLostCb: null
73
+ };
74
+
75
+ const catalogJobInstance = {
76
+ running: false,
77
+ error: null,
78
+ onError: vi.fn( cb => {
79
+ catalogJobInstance.errorCb = cb;
80
+ } ),
81
+ run: vi.fn( () => {
82
+ catalogJobInstance.running = true;
83
+ return promises.catalogJob.promise.finally( () => {
84
+ catalogJobInstance.running = false;
85
+ } );
86
+ } ),
87
+ interrupt: vi.fn( () => {
88
+ catalogJobInstance.running = false;
89
+ promises.catalogJob.resolve();
90
+ return promises.catalogJob.promise;
91
+ } ),
92
+ errorCb: null
93
+ };
94
+
95
+ const mockWorker = {
96
+ getStatus: vi.fn( () => ( { runState: mockWorker.runState } ) ),
97
+ run: vi.fn( () => promises.workerRun.promise ),
98
+ runState: 'RUNNING',
99
+ shutdown: vi.fn()
100
+ };
101
+
102
+ return {
103
+ catalogJobInstance,
104
+ configValues,
105
+ connectionMonitorInstance,
106
+ createCatalogMock: vi.fn().mockReturnValue( { workflows: [], activities: {} } ),
107
+ createWorkflowsEntryPointMock: vi.fn().mockReturnValue( '/fake/workflows/path.js' ),
108
+ hashSourceCodeMock: vi.fn().mockResolvedValue( 'catalog-hash' ),
109
+ initInterceptorsMock: vi.fn().mockReturnValue( [] ),
110
+ loadActivitiesMock: vi.fn().mockResolvedValue( {} ),
111
+ loadHooksMock: vi.fn().mockResolvedValue( undefined ),
112
+ loadWorkflowsMock: vi.fn().mockResolvedValue( [] ),
113
+ messageBusMock: { emit: vi.fn(), on: vi.fn() },
114
+ mockConnection: { close: vi.fn().mockResolvedValue( undefined ) },
115
+ mockLog: { error: vi.fn(), info: vi.fn(), warn: vi.fn() },
116
+ mockWorker,
117
+ promises,
118
+ resetPromises,
119
+ setupInterruptionHandlerMock: vi.fn(),
120
+ setupTelemetryMock: vi.fn()
121
+ };
122
+ } );
2
123
 
3
- const mockLog = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
4
124
  vi.mock( '#logger', () => ( { createChildLogger: () => mockLog } ) );
5
-
6
125
  vi.mock( '#consts', async importOriginal => {
7
126
  const actual = await importOriginal();
8
127
  return { ...actual };
9
128
  } );
10
-
11
129
  vi.mock( '#tracing', () => ( { init: vi.fn().mockResolvedValue( undefined ) } ) );
12
-
13
- const configValues = {
14
- address: 'localhost:7233',
15
- apiKey: undefined,
16
- namespace: 'default',
17
- taskQueue: 'test-queue',
18
- catalogId: 'test-catalog',
19
- grpcProxy: undefined,
20
- maxConcurrentWorkflowTaskExecutions: 200,
21
- maxConcurrentActivityTaskExecutions: 40,
22
- maxCachedWorkflows: 1000,
23
- maxConcurrentActivityTaskPolls: 5,
24
- maxConcurrentWorkflowTaskPolls: 5,
25
- processFailureShutdownDelay: 0
26
- };
27
- vi.mock( './configs.js', () => configValues );
28
-
29
- const messageBusMock = { on: vi.fn(), emit: vi.fn() };
30
130
  vi.mock( '#bus', () => ( { messageBus: messageBusMock } ) );
31
-
32
- const loadWorkflowsMock = vi.fn().mockResolvedValue( [] );
33
- const loadActivitiesMock = vi.fn().mockResolvedValue( {} );
34
- const loadHooksMock = vi.fn().mockResolvedValue( undefined );
35
- const createWorkflowsEntryPointMock = vi.fn().mockReturnValue( '/fake/workflows/path.js' );
131
+ vi.mock( './configs.js', () => configValues );
36
132
  vi.mock( './loader.js', () => ( {
37
- loadWorkflows: loadWorkflowsMock,
133
+ createWorkflowsEntryPoint: createWorkflowsEntryPointMock,
38
134
  loadActivities: loadActivitiesMock,
39
135
  loadHooks: loadHooksMock,
40
- createWorkflowsEntryPoint: createWorkflowsEntryPointMock
136
+ loadWorkflows: loadWorkflowsMock
41
137
  } ) );
42
-
43
- const hashSourceCodeMock = vi.fn().mockResolvedValue( 'catalog-hash' );
44
138
  vi.mock( './loader_tools.js', () => ( { hashSourceCode: hashSourceCodeMock } ) );
45
-
46
139
  vi.mock( './sinks.js', () => ( { sinks: {} } ) );
47
-
48
- const createCatalogMock = vi.fn().mockReturnValue( { workflows: [], activities: {} } );
49
140
  vi.mock( './catalog_workflow/index.js', () => ( { createCatalog: createCatalogMock } ) );
50
-
51
141
  vi.mock( './bundler_options.js', () => ( { webpackConfigHook: vi.fn() } ) );
52
-
53
- const initInterceptorsMock = vi.fn().mockReturnValue( [] );
54
142
  vi.mock( './interceptors/index.js', () => ( { initInterceptors: initInterceptorsMock } ) );
55
-
56
- const startCatalogMock = vi.fn().mockResolvedValue( undefined );
57
- vi.mock( './start_catalog.js', () => ( { startCatalog: startCatalogMock } ) );
58
-
59
- const bootstrapFetchProxyMock = vi.fn();
60
- vi.mock( './proxy.js', () => ( { bootstrapFetchProxy: bootstrapFetchProxyMock } ) );
61
-
62
- const registerShutdownMock = vi.fn();
63
- vi.mock( './shutdown.js', () => ( { registerShutdown: registerShutdownMock } ) );
64
-
65
- const setupTelemetryMock = vi.fn();
66
- vi.mock( './setup_telemetry.js', () => ( { setupTelemetry: setupTelemetryMock } ) );
67
-
143
+ vi.mock( './proxy.js', () => ( { bootstrapFetchProxy: vi.fn() } ) );
144
+ vi.mock( './telemetry.js', () => ( { setupTelemetry: setupTelemetryMock } ) );
145
+ vi.mock( './interruption.js', () => ( { setupInterruptionHandler: setupInterruptionHandlerMock } ) );
146
+ vi.mock( './connection_monitor.js', () => ( {
147
+ TemporalConnectionMonitor: vi.fn( function () {
148
+ return connectionMonitorInstance;
149
+ } )
150
+ } ) );
151
+ vi.mock( './catalog_workflow/catalog_job.js', () => ( {
152
+ CatalogJob: vi.fn( function () {
153
+ return catalogJobInstance;
154
+ } )
155
+ } ) );
68
156
  vi.mock( './log_hooks.js', () => ( {} ) );
69
-
70
- const runState = { resolve: null };
71
- const runPromise = new Promise( r => {
72
- runState.resolve = r;
73
- } );
74
- const shutdownMock = vi.fn();
75
- const mockConnection = { close: vi.fn().mockResolvedValue( undefined ) };
76
- const mockWorker = { run: () => runPromise, shutdown: shutdownMock };
77
-
78
157
  vi.mock( '@temporalio/worker', () => ( {
79
- Worker: { create: vi.fn().mockResolvedValue( mockWorker ) },
80
- NativeConnection: { connect: vi.fn().mockResolvedValue( mockConnection ) }
158
+ NativeConnection: { connect: vi.fn().mockResolvedValue( mockConnection ) },
159
+ Worker: { create: vi.fn().mockResolvedValue( mockWorker ) }
81
160
  } ) );
82
161
 
162
+ const importWorker = async () => {
163
+ vi.resetModules();
164
+ await import( './index.js' );
165
+ };
166
+
167
+ const settleWorker = async () => {
168
+ promises.catalogJob.resolve();
169
+ promises.connectionMonitor.resolve();
170
+ promises.workerRun.resolve();
171
+ await vi.waitFor( () => expect( mockConnection.close ).toHaveBeenCalled() );
172
+ };
173
+
83
174
  describe( 'worker/index', () => {
84
175
  const exitMock = vi.fn();
85
176
  const originalArgv = process.argv;
86
- const originalExit = process.exit;
87
177
 
88
178
  beforeEach( () => {
89
179
  vi.clearAllMocks();
180
+ resetPromises();
181
+ configValues.apiKey = undefined;
182
+ configValues.grpcProxy = undefined;
183
+ catalogJobInstance.error = null;
184
+ catalogJobInstance.errorCb = null;
185
+ catalogJobInstance.running = false;
186
+ connectionMonitorInstance.connectionLossError = null;
187
+ connectionMonitorInstance.connectionLostCb = null;
188
+ connectionMonitorInstance.running = false;
189
+ mockConnection.close.mockResolvedValue( undefined );
190
+ mockWorker.runState = 'RUNNING';
90
191
  process.argv = [ ...originalArgv.slice( 0, 2 ), '/test/caller/dir' ];
91
- process.exit = exitMock;
192
+ vi.spyOn( process, 'exit' ).mockImplementation( exitMock );
92
193
  } );
93
194
 
94
195
  afterEach( () => {
95
196
  process.argv = originalArgv;
96
- process.exit = originalExit;
97
- configValues.apiKey = undefined;
197
+ vi.restoreAllMocks();
98
198
  } );
99
199
 
100
- it( 'loads configs, workflows, activities and creates worker with correct options', async () => {
101
- const { Worker, NativeConnection } = await import( '@temporalio/worker' );
102
- const { init: initTracing } = await import( '#tracing' );
200
+ it( 'creates the worker lifecycle jobs with expected dependencies', async () => {
201
+ const { NativeConnection, Worker } = await import( '@temporalio/worker' );
202
+ const { TemporalConnectionMonitor } = await import( './connection_monitor.js' );
203
+ const { CatalogJob } = await import( './catalog_workflow/catalog_job.js' );
103
204
 
104
- import( './index.js' );
205
+ await importWorker();
105
206
 
106
- await vi.waitFor( () => {
107
- expect( loadHooksMock ).toHaveBeenCalledWith( '/test/caller/dir' );
108
- } );
207
+ await vi.waitFor( () => expect( Worker.create ).toHaveBeenCalled() );
208
+
209
+ expect( loadHooksMock ).toHaveBeenCalledWith( '/test/caller/dir' );
109
210
  expect( loadWorkflowsMock ).toHaveBeenCalledWith( '/test/caller/dir' );
110
211
  expect( loadActivitiesMock ).toHaveBeenCalledWith( '/test/caller/dir', [] );
111
212
  expect( createWorkflowsEntryPointMock ).toHaveBeenCalledWith( [] );
112
- expect( initTracing ).toHaveBeenCalled();
113
213
  expect( createCatalogMock ).toHaveBeenCalledWith( { workflows: [], activities: {} } );
114
214
  expect( hashSourceCodeMock ).toHaveBeenCalledWith( '/test/caller/dir' );
115
- expect( bootstrapFetchProxyMock ).toHaveBeenCalled();
116
215
  expect( NativeConnection.connect ).toHaveBeenCalledWith( {
117
216
  address: configValues.address,
118
217
  tls: false,
119
218
  apiKey: undefined,
120
219
  proxy: undefined
121
220
  } );
221
+ expect( TemporalConnectionMonitor ).toHaveBeenCalledWith( mockConnection );
222
+ expect( CatalogJob ).toHaveBeenCalledWith( {
223
+ connection: mockConnection,
224
+ namespace: configValues.namespace,
225
+ catalog: { workflows: [], activities: {} },
226
+ catalogHash: 'catalog-hash'
227
+ } );
122
228
  expect( Worker.create ).toHaveBeenCalledWith( expect.objectContaining( {
229
+ connection: mockConnection,
123
230
  namespace: configValues.namespace,
124
231
  taskQueue: configValues.taskQueue,
125
232
  workflowsPath: '/fake/workflows/path.js',
@@ -130,63 +237,128 @@ describe( 'worker/index', () => {
130
237
  maxConcurrentActivityTaskPolls: configValues.maxConcurrentActivityTaskPolls,
131
238
  maxConcurrentWorkflowTaskPolls: configValues.maxConcurrentWorkflowTaskPolls
132
239
  } ) );
133
- expect( initInterceptorsMock ).toHaveBeenCalledWith( { activities: {}, workflows: [], connection: mockConnection } );
134
- expect( registerShutdownMock ).toHaveBeenCalledWith( { worker: mockWorker, log: mockLog } );
240
+ expect( initInterceptorsMock ).toHaveBeenCalledWith( { activities: {}, workflows: [] } );
135
241
  expect( setupTelemetryMock ).toHaveBeenCalledWith( { worker: mockWorker } );
136
- expect( startCatalogMock ).toHaveBeenCalledWith( {
137
- connection: mockConnection,
138
- namespace: configValues.namespace,
139
- catalog: { workflows: [], activities: {} },
140
- catalogHash: 'catalog-hash'
141
- } );
142
-
143
- runState.resolve();
144
- await vi.waitFor( () => {
145
- expect( mockConnection.close ).toHaveBeenCalled();
146
- } );
147
- expect( exitMock ).toHaveBeenCalledWith( 0 );
242
+ expect( setupInterruptionHandlerMock ).toHaveBeenCalledWith( expect.any( Function ) );
243
+ expect( connectionMonitorInstance.onConnectionLost ).toHaveBeenCalledWith( expect.any( Function ) );
244
+ expect( catalogJobInstance.onError ).toHaveBeenCalledWith( expect.any( Function ) );
245
+ expect( mockWorker.run ).toHaveBeenCalled();
246
+ expect( connectionMonitorInstance.start ).toHaveBeenCalled();
247
+ expect( catalogJobInstance.run ).toHaveBeenCalled();
248
+
249
+ await settleWorker();
250
+ expect( mockLog.info ).toHaveBeenCalledWith( 'Bye' );
148
251
  } );
149
252
 
150
253
  it( 'enables TLS when apiKey is set', async () => {
151
254
  configValues.apiKey = 'secret';
152
- vi.resetModules();
153
-
154
255
  const { NativeConnection } = await import( '@temporalio/worker' );
155
- import( './index.js' );
256
+
257
+ await importWorker();
258
+
259
+ await vi.waitFor( () => expect( NativeConnection.connect ).toHaveBeenCalledWith( expect.objectContaining( {
260
+ apiKey: 'secret',
261
+ tls: true
262
+ } ) ) );
263
+
264
+ await settleWorker();
265
+ } );
266
+
267
+ it( 'runs graceful shutdown when interrupted', async () => {
268
+ await importWorker();
269
+
270
+ await vi.waitFor( () => expect( setupInterruptionHandlerMock ).toHaveBeenCalled() );
271
+ const [ shutdown ] = setupInterruptionHandlerMock.mock.calls[0];
272
+
273
+ shutdown();
274
+
275
+ expect( mockWorker.shutdown ).toHaveBeenCalledOnce();
276
+ expect( connectionMonitorInstance.stop ).toHaveBeenCalledOnce();
277
+ expect( catalogJobInstance.interrupt ).toHaveBeenCalledOnce();
278
+
279
+ promises.workerRun.resolve();
280
+ await vi.waitFor( () => expect( mockConnection.close ).toHaveBeenCalled() );
281
+ expect( mockLog.info ).toHaveBeenCalledWith( 'Bye' );
282
+ } );
283
+
284
+ it( 'does not call worker.shutdown when worker has already failed', async () => {
285
+ const error = new Error( 'Big Failure' );
286
+
287
+ await importWorker();
288
+ await vi.waitFor( () => expect( mockWorker.run ).toHaveBeenCalled() );
289
+
290
+ mockWorker.runState = 'FAILED';
291
+ promises.workerRun.reject( error );
292
+
293
+ await vi.waitFor( () => expect( connectionMonitorInstance.stop ).toHaveBeenCalled() );
294
+ expect( mockWorker.shutdown ).not.toHaveBeenCalled();
295
+
296
+ promises.connectionMonitor.resolve();
297
+ promises.catalogJob.resolve();
156
298
 
157
299
  await vi.waitFor( () => {
158
- expect( NativeConnection.connect ).toHaveBeenCalledWith( expect.objectContaining( {
159
- tls: true,
160
- apiKey: 'secret'
300
+ expect( mockLog.error ).toHaveBeenCalledWith( 'Fatal error', expect.objectContaining( {
301
+ error: 'Big Failure'
161
302
  } ) );
162
303
  } );
163
- runState.resolve();
164
- await vi.waitFor( () => expect( exitMock ).toHaveBeenCalled() );
304
+ expect( messageBusMock.emit ).toHaveBeenCalledWith( expect.any( String ), { error } );
305
+ await vi.waitFor( () => expect( exitMock ).toHaveBeenCalledWith( 1 ) );
165
306
  } );
166
307
 
167
- it( 'calls registerShutdown with worker and log', async () => {
168
- vi.resetModules();
308
+ it( 'throws connection monitor errors after graceful shutdown', async () => {
309
+ const error = new Error( 'connection lost' );
169
310
 
170
- import( './index.js' );
311
+ await importWorker();
312
+ await vi.waitFor( () => expect( connectionMonitorInstance.onConnectionLost ).toHaveBeenCalled() );
313
+
314
+ connectionMonitorInstance.connectionLossError = error;
315
+ connectionMonitorInstance.connectionLostCb( error );
316
+ promises.workerRun.resolve();
171
317
 
172
318
  await vi.waitFor( () => {
173
- expect( registerShutdownMock ).toHaveBeenCalledWith( { worker: mockWorker, log: mockLog } );
319
+ expect( mockLog.error ).toHaveBeenCalledWith( 'Fatal error', expect.objectContaining( {
320
+ error: 'connection lost'
321
+ } ) );
174
322
  } );
175
- runState.resolve();
176
- await vi.waitFor( () => expect( exitMock ).toHaveBeenCalled() );
323
+ expect( mockWorker.shutdown ).toHaveBeenCalledOnce();
324
+ expect( catalogJobInstance.interrupt ).toHaveBeenCalledOnce();
325
+ expect( messageBusMock.emit ).toHaveBeenCalledWith( expect.any( String ), { error } );
177
326
  } );
178
327
 
179
- it( 'calls process.exit(1) on fatal error', async () => {
180
- loadWorkflowsMock.mockRejectedValueOnce( new Error( 'load failed' ) );
181
- vi.resetModules();
328
+ it( 'throws catalog job errors after graceful shutdown', async () => {
329
+ const error = new Error( 'catalog failed' );
330
+
331
+ await importWorker();
332
+ await vi.waitFor( () => expect( catalogJobInstance.onError ).toHaveBeenCalled() );
182
333
 
183
- import( './index.js' );
334
+ catalogJobInstance.error = error;
335
+ catalogJobInstance.errorCb( error );
336
+ promises.workerRun.resolve();
184
337
 
185
338
  await vi.waitFor( () => {
186
- expect( mockLog.error ).toHaveBeenCalledWith( 'Fatal error', expect.any( Object ) );
339
+ expect( mockLog.error ).toHaveBeenCalledWith( 'Fatal error', expect.objectContaining( {
340
+ error: 'catalog failed'
341
+ } ) );
187
342
  } );
343
+ expect( mockWorker.shutdown ).toHaveBeenCalledOnce();
344
+ expect( connectionMonitorInstance.stop ).toHaveBeenCalledOnce();
345
+ expect( messageBusMock.emit ).toHaveBeenCalledWith( expect.any( String ), { error } );
346
+ } );
347
+
348
+ it( 'cleans up partial startup failures after connecting', async () => {
349
+ const { Worker } = await import( '@temporalio/worker' );
350
+ const error = new Error( 'worker create failed' );
351
+ Worker.create.mockRejectedValueOnce( error );
352
+
353
+ await importWorker();
354
+
355
+ await vi.waitFor( () => expect( mockConnection.close ).toHaveBeenCalled() );
356
+ expect( connectionMonitorInstance.stop ).not.toHaveBeenCalled();
357
+ expect( catalogJobInstance.interrupt ).not.toHaveBeenCalled();
188
358
  await vi.waitFor( () => {
189
- expect( exitMock ).toHaveBeenCalledWith( 1 );
359
+ expect( mockLog.error ).toHaveBeenCalledWith( 'Fatal error', expect.objectContaining( {
360
+ error: 'worker create failed'
361
+ } ) );
190
362
  } );
191
363
  } );
192
364
  } );