@outputai/core 0.5.3-next.69060d7.0 → 0.5.3-next.bdf47aa.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@outputai/core",
3
- "version": "0.5.3-next.69060d7.0",
3
+ "version": "0.5.3-next.bdf47aa.0",
4
4
  "description": "The core module of the output framework",
5
5
  "type": "module",
6
6
  "exports": {
@@ -44,6 +44,7 @@
44
44
  "@temporalio/worker": "1.17.0",
45
45
  "@temporalio/workflow": "1.17.0",
46
46
  "decimal.js": "10.6.0",
47
+ "folder-hash": "4.1.3",
47
48
  "json-stream-stringify": "3.1.6",
48
49
  "redis": "5.12.1",
49
50
  "stacktrace-parser": "0.1.11",
@@ -7,12 +7,15 @@ import { defineQuery, setHandler, condition, defineUpdate } from '@temporalio/wo
7
7
  *
8
8
  * @param {object} catalog - The catalog information
9
9
  */
10
- export default async function catalogWorkflow( catalog ) {
10
+ export default async function catalogWorkflow( catalog, catalogHash ) {
11
11
  const state = { canEnd: false };
12
12
 
13
13
  // Returns the catalog
14
14
  setHandler( defineQuery( 'get' ), () => catalog );
15
15
 
16
+ // Returns the catalog hash
17
+ setHandler( defineQuery( 'get_hash' ), () => catalogHash );
18
+
16
19
  // Politely respond to a ping
17
20
  setHandler( defineQuery( 'ping' ), () => 'pong' );
18
21
 
@@ -13,6 +13,7 @@ import { bootstrapFetchProxy } from './proxy.js';
13
13
  import { messageBus } from '#bus';
14
14
  import './log_hooks.js';
15
15
  import { BusEventType } from '#consts';
16
+ import { hashSourceCode } from './loader_tools.js';
16
17
 
17
18
  const log = createChildLogger( 'Worker' );
18
19
 
@@ -54,6 +55,9 @@ const callerDir = process.argv[2];
54
55
  log.info( 'Creating workflows catalog...' );
55
56
  const catalog = createCatalog( { workflows, activities } );
56
57
 
58
+ log.info( 'Computing catalog source code hash...' );
59
+ const catalogHash = await hashSourceCode( callerDir );
60
+
57
61
  log.info( 'Connecting Temporal...' );
58
62
  const proxy = grpcProxy ? { type: 'http-connect', targetHost: grpcProxy } : undefined;
59
63
  if ( proxy ) {
@@ -81,7 +85,7 @@ const callerDir = process.argv[2];
81
85
  registerShutdown( { worker, log } );
82
86
 
83
87
  log.info( 'Running worker...' );
84
- await Promise.all( [ worker.run(), startCatalog( { connection, namespace, catalog } ) ] );
88
+ await Promise.all( [ worker.run(), startCatalog( { connection, namespace, catalog, catalogHash } ) ] );
85
89
 
86
90
  log.info( 'Closing connection...' );
87
91
  await connection.close();
@@ -40,6 +40,9 @@ vi.mock( './loader.js', () => ( {
40
40
  createWorkflowsEntryPoint: createWorkflowsEntryPointMock
41
41
  } ) );
42
42
 
43
+ const hashSourceCodeMock = vi.fn().mockResolvedValue( 'catalog-hash' );
44
+ vi.mock( './loader_tools.js', () => ( { hashSourceCode: hashSourceCodeMock } ) );
45
+
43
46
  vi.mock( './sinks.js', () => ( { sinks: {} } ) );
44
47
 
45
48
  const createCatalogMock = vi.fn().mockReturnValue( { workflows: [], activities: {} } );
@@ -105,6 +108,7 @@ describe( 'worker/index', () => {
105
108
  expect( createWorkflowsEntryPointMock ).toHaveBeenCalledWith( [] );
106
109
  expect( initTracing ).toHaveBeenCalled();
107
110
  expect( createCatalogMock ).toHaveBeenCalledWith( { workflows: [], activities: {} } );
111
+ expect( hashSourceCodeMock ).toHaveBeenCalledWith( '/test/caller/dir' );
108
112
  expect( bootstrapFetchProxyMock ).toHaveBeenCalled();
109
113
  expect( NativeConnection.connect ).toHaveBeenCalledWith( {
110
114
  address: configValues.address,
@@ -128,7 +132,8 @@ describe( 'worker/index', () => {
128
132
  expect( startCatalogMock ).toHaveBeenCalledWith( {
129
133
  connection: mockConnection,
130
134
  namespace: configValues.namespace,
131
- catalog: { workflows: [], activities: {} }
135
+ catalog: { workflows: [], activities: {} },
136
+ catalogHash: 'catalog-hash'
132
137
  } );
133
138
 
134
139
  runState.resolve();
@@ -6,5 +6,9 @@ const __dirname = dirname( fileURLToPath( import.meta.url ) );
6
6
 
7
7
  export const initInterceptors = ( { activities, workflows, connection } ) => ( {
8
8
  workflowModules: [ join( __dirname, './interceptors/workflow.js' ) ],
9
- activityInbound: [ () => new ActivityExecutionInterceptor( { activities, workflows, connection } ) ]
9
+ activity: [
10
+ () => ( {
11
+ inbound: new ActivityExecutionInterceptor( { activities, workflows, connection } )
12
+ } )
13
+ ]
10
14
  } );
@@ -2,6 +2,7 @@ import { dirname, resolve, sep } from 'path';
2
2
  import { pathToFileURL } from 'url';
3
3
  import { METADATA_ACCESS_SYMBOL } from '#consts';
4
4
  import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from 'fs';
5
+ import { hashElement } from 'folder-hash';
5
6
 
6
7
  /**
7
8
  * Returns the real path for symlink
@@ -302,3 +303,27 @@ export async function *importComponents( files ) {
302
303
  }
303
304
  }
304
305
  };
306
+
307
+ /**
308
+ * Creates a hash of all source code in a given dir
309
+ *
310
+ * @param {string} rootDir
311
+ * @returns {string} Hash value
312
+ */
313
+ export const hashSourceCode = async rootDir => {
314
+ try {
315
+ const { hash } = await hashElement( rootDir, {
316
+ folders: {
317
+ exclude: [ '.*', 'node_modules', 'test_coverage', 'vendor', 'test' ],
318
+ ignoreRootName: true
319
+ },
320
+ files: {
321
+ include: [ '*.js', '*.cjs', '*.mjs', '*.ts', '*.yaml', '*.yml', '*.json', '*.prompt' ],
322
+ ignoreRootName: true
323
+ }
324
+ } );
325
+ return hash;
326
+ } catch ( error ) {
327
+ throw new Error( `Error calculating hash from "${error}": ${error.message}`, { cause: error } );
328
+ }
329
+ };
@@ -1,36 +1,96 @@
1
1
  import { Client, WorkflowNotFoundError } from '@temporalio/client';
2
- import { WorkflowIdConflictPolicy } from '@temporalio/common';
2
+ import { WorkflowExecutionAlreadyStartedError, WorkflowIdConflictPolicy } from '@temporalio/common';
3
3
  import { WORKFLOW_CATALOG } from '#consts';
4
4
  import { catalogId, taskQueue } from './configs.js';
5
5
  import { createChildLogger } from '#logger';
6
6
 
7
7
  const log = createChildLogger( 'Catalog' );
8
8
 
9
- export const startCatalog = async ( { connection, namespace, catalog } ) => {
10
- const client = new Client( { connection, namespace } );
11
- const catalogWorkflowHandle = client.workflow.getHandle( catalogId );
9
+ // Note, functions don't log on "WorkflowNotFound" errors,
10
+ // because they happen when the catalog is not running at all.
12
11
 
12
+ /**
13
+ * Check if the currently running catalog has the same hash as the passed argument.
14
+ * @param {import('@temporalio/client').WorkflowHandle} handle
15
+ * @param {string} hash
16
+ * @returns {boolean}
17
+ */
18
+ const checkCatalogIsTheSame = async ( handle, hash ) => {
13
19
  try {
14
- const catalogWorkflowDescription = await catalogWorkflowHandle.describe();
15
- if ( !catalogWorkflowDescription.closeTime ) {
16
- log.info( 'Completing previous catalog workflow...' );
17
- await catalogWorkflowHandle.executeUpdate( 'complete', { args: [] } );
20
+ log.info( 'Checking running catalog hash against worker hash....' );
21
+ const runningHash = await handle.query( 'get_hash' );
22
+ return runningHash === hash;
23
+ } catch ( error ) {
24
+ if ( !( error instanceof WorkflowNotFoundError ) ) {
25
+ log.warn( 'Error retrieving catalog hash', { error } );
18
26
  }
27
+ return false;
28
+ }
29
+ };
30
+
31
+ /**
32
+ * Check if the catalog workflow is running.
33
+ * @param {import('@temporalio/client').WorkflowHandle} handle
34
+ * @returns
35
+ */
36
+ const checkCatalogRunning = async handle => {
37
+ try {
38
+ log.info( 'Checking if the catalog workflow is running....' );
39
+ const description = await handle.describe();
40
+ return !description.closeTime;
19
41
  } catch ( error ) {
20
- // When "not found", it's either a cold start or the catalog was already stopped/terminated, ignore it.
21
- // Otherwise, create a log and try the next operation:
22
- // A. If the workflow is still running, the start() will fail and throw;
23
- // B. If the workflow is no running, the start() will succeed, and the error was transient;
24
42
  if ( !( error instanceof WorkflowNotFoundError ) ) {
25
- log.warn( 'Error interacting with previous catalog workflow', { error } );
43
+ log.warn( 'Error describing catalog workflow', { error } );
26
44
  }
45
+ return false;
27
46
  }
47
+ };
28
48
 
29
- log.info( 'Starting catalog workflow...' );
30
- await client.workflow.start( WORKFLOW_CATALOG, {
49
+ /**
50
+ * Complete previous running catalog workflow.
51
+ * @param {import('@temporalio/client').WorkflowHandle} handle
52
+ */
53
+ const completePreviousCatalog = async handle => {
54
+ try {
55
+ log.info( 'Completing previous catalog workflow...' );
56
+ await handle.executeUpdate( 'complete', { args: [] } );
57
+ } catch ( error ) {
58
+ if ( !( error instanceof WorkflowNotFoundError ) ) {
59
+ log.warn( 'Error completing previous catalog workflow', { error } );
60
+ }
61
+ }
62
+ };
63
+
64
+ export const startCatalog = async ( { connection, namespace, catalog, catalogHash } ) => {
65
+ const client = new Client( { connection, namespace } );
66
+ const handle = client.workflow.getHandle( catalogId );
67
+
68
+ if ( await checkCatalogRunning( handle ) ) {
69
+ if ( await checkCatalogIsTheSame( handle, catalogHash ) ) {
70
+ log.info( 'Current catalog workflow hash matches worker, restart skipped' );
71
+ return;
72
+ }
73
+ await completePreviousCatalog( handle );
74
+ }
75
+
76
+ const startArguments = {
31
77
  taskQueue,
32
- workflowId: catalogId, // use the name of the task queue as the catalog name, ensuring uniqueness
78
+ workflowId: catalogId,
33
79
  workflowIdConflictPolicy: WorkflowIdConflictPolicy.FAIL,
34
- args: [ catalog ]
35
- } );
80
+ args: [ catalog, catalogHash ]
81
+ };
82
+
83
+ log.info( 'Starting catalog workflow...' );
84
+ try {
85
+ await client.workflow.start( WORKFLOW_CATALOG, startArguments );
86
+ } catch ( error ) {
87
+ // if the error was caused by the catalog existing and its hash is the same as the one from the worker, just ignore the error
88
+ if ( error instanceof WorkflowExecutionAlreadyStartedError && await checkCatalogIsTheSame( handle, catalogHash ) ) {
89
+ log.info( 'Ignoring start error: it failed because execution already started but catalog hash matches worker' );
90
+ } else {
91
+ throw error;
92
+ }
93
+ }
94
+
95
+ log.info( 'Startup completed' );
36
96
  };
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { WorkflowNotFoundError } from '@temporalio/client';
3
+ import { WorkflowExecutionAlreadyStartedError } from '@temporalio/common';
3
4
 
4
5
  const mockLog = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
5
6
  vi.mock( '#logger', () => ( { createChildLogger: () => mockLog } ) );
@@ -11,6 +12,7 @@ const taskQueue = 'test-queue';
11
12
  vi.mock( './configs.js', () => ( { catalogId, taskQueue } ) );
12
13
 
13
14
  const describeMock = vi.fn();
15
+ const queryMock = vi.fn();
14
16
  const executeUpdateMock = vi.fn();
15
17
  const workflowStartMock = vi.fn().mockResolvedValue( undefined );
16
18
  vi.mock( '@temporalio/client', async importOriginal => {
@@ -21,33 +23,40 @@ vi.mock( '@temporalio/client', async importOriginal => {
21
23
  return {
22
24
  workflow: {
23
25
  start: workflowStartMock,
24
- getHandle: () => ( { describe: describeMock, executeUpdate: executeUpdateMock } )
26
+ getHandle: () => ( { describe: describeMock, query: queryMock, executeUpdate: executeUpdateMock } )
25
27
  }
26
28
  };
27
29
  } )
28
30
  };
29
31
  } );
30
32
 
31
- vi.mock( '@temporalio/common', () => ( { WorkflowIdConflictPolicy: { FAIL: 'FAIL' } } ) );
32
-
33
33
  describe( 'worker/start_catalog', () => {
34
34
  const mockConnection = {};
35
35
  const namespace = 'default';
36
36
  const catalog = { workflows: [], activities: {} };
37
+ const catalogHash = 'catalog-hash';
37
38
 
38
39
  beforeEach( () => {
39
- vi.clearAllMocks();
40
+ mockLog.info.mockClear();
41
+ mockLog.warn.mockClear();
42
+ mockLog.error.mockClear();
43
+ describeMock.mockReset();
44
+ queryMock.mockReset();
45
+ executeUpdateMock.mockReset();
46
+ workflowStartMock.mockReset();
40
47
  workflowStartMock.mockResolvedValue( undefined );
41
48
  } );
42
49
 
43
- it( 'when previous catalog still running: completes it then starts catalog workflow', async () => {
50
+ it( 'when previous catalog still running with different hash: completes it then starts catalog workflow', async () => {
44
51
  describeMock.mockResolvedValue( { closeTime: undefined } );
52
+ queryMock.mockResolvedValue( 'old-catalog-hash' );
45
53
  executeUpdateMock.mockResolvedValue( undefined );
46
54
 
47
55
  const { startCatalog } = await import( './start_catalog.js' );
48
- await startCatalog( { connection: mockConnection, namespace, catalog } );
56
+ await startCatalog( { connection: mockConnection, namespace, catalog, catalogHash } );
49
57
 
50
58
  expect( describeMock ).toHaveBeenCalled();
59
+ expect( queryMock ).toHaveBeenCalledWith( 'get_hash' );
51
60
  expect( mockLog.info ).toHaveBeenCalledWith( 'Completing previous catalog workflow...' );
52
61
  expect( executeUpdateMock ).toHaveBeenCalledWith( 'complete', { args: [] } );
53
62
  expect( mockLog.info ).toHaveBeenCalledWith( 'Starting catalog workflow...' );
@@ -55,17 +64,32 @@ describe( 'worker/start_catalog', () => {
55
64
  taskQueue,
56
65
  workflowId: catalogId,
57
66
  workflowIdConflictPolicy: 'FAIL',
58
- args: [ catalog ]
67
+ args: [ catalog, catalogHash ]
59
68
  } );
60
69
  } );
61
70
 
71
+ it( 'when previous catalog still running with same hash: keeps existing catalog workflow', async () => {
72
+ describeMock.mockResolvedValue( { closeTime: undefined } );
73
+ queryMock.mockResolvedValue( catalogHash );
74
+
75
+ const { startCatalog } = await import( './start_catalog.js' );
76
+ await startCatalog( { connection: mockConnection, namespace, catalog, catalogHash } );
77
+
78
+ expect( describeMock ).toHaveBeenCalled();
79
+ expect( queryMock ).toHaveBeenCalledWith( 'get_hash' );
80
+ expect( mockLog.info ).toHaveBeenCalledWith( 'Current catalog workflow hash matches worker, restart skipped' );
81
+ expect( executeUpdateMock ).not.toHaveBeenCalled();
82
+ expect( workflowStartMock ).not.toHaveBeenCalled();
83
+ } );
84
+
62
85
  it( 'when no previous catalog: ignores and starts catalog workflow', async () => {
63
86
  describeMock.mockRejectedValue( new WorkflowNotFoundError( 'not found' ) );
64
87
 
65
88
  const { startCatalog } = await import( './start_catalog.js' );
66
- await startCatalog( { connection: mockConnection, namespace, catalog } );
89
+ await startCatalog( { connection: mockConnection, namespace, catalog, catalogHash } );
67
90
 
68
91
  expect( describeMock ).toHaveBeenCalled();
92
+ expect( queryMock ).not.toHaveBeenCalled();
69
93
  expect( mockLog.warn ).not.toHaveBeenCalled();
70
94
  expect( mockLog.info ).toHaveBeenCalledWith( 'Starting catalog workflow...' );
71
95
  expect( executeUpdateMock ).not.toHaveBeenCalled();
@@ -73,7 +97,7 @@ describe( 'worker/start_catalog', () => {
73
97
  taskQueue,
74
98
  workflowId: catalogId,
75
99
  workflowIdConflictPolicy: 'FAIL',
76
- args: [ catalog ]
100
+ args: [ catalog, catalogHash ]
77
101
  } );
78
102
  } );
79
103
 
@@ -81,9 +105,10 @@ describe( 'worker/start_catalog', () => {
81
105
  describeMock.mockResolvedValue( { closeTime: '2024-01-01T00:00:00Z' } );
82
106
 
83
107
  const { startCatalog } = await import( './start_catalog.js' );
84
- await startCatalog( { connection: mockConnection, namespace, catalog } );
108
+ await startCatalog( { connection: mockConnection, namespace, catalog, catalogHash } );
85
109
 
86
110
  expect( describeMock ).toHaveBeenCalled();
111
+ expect( queryMock ).not.toHaveBeenCalled();
87
112
  expect( mockLog.info ).not.toHaveBeenCalledWith( 'Completing previous catalog workflow...' );
88
113
  expect( executeUpdateMock ).not.toHaveBeenCalled();
89
114
  expect( mockLog.info ).toHaveBeenCalledWith( 'Starting catalog workflow...' );
@@ -91,20 +116,22 @@ describe( 'worker/start_catalog', () => {
91
116
  taskQueue,
92
117
  workflowId: catalogId,
93
118
  workflowIdConflictPolicy: 'FAIL',
94
- args: [ catalog ]
119
+ args: [ catalog, catalogHash ]
95
120
  } );
96
121
  } );
97
122
 
98
123
  it( 'when describe or complete fails with other error: logs warn and still starts catalog workflow', async () => {
99
124
  describeMock.mockResolvedValue( { closeTime: undefined } );
125
+ queryMock.mockResolvedValue( 'old-catalog-hash' );
100
126
  executeUpdateMock.mockRejectedValue( new Error( 'Connection refused' ) );
101
127
 
102
128
  const { startCatalog } = await import( './start_catalog.js' );
103
- await startCatalog( { connection: mockConnection, namespace, catalog } );
129
+ await startCatalog( { connection: mockConnection, namespace, catalog, catalogHash } );
104
130
 
105
131
  expect( describeMock ).toHaveBeenCalled();
132
+ expect( queryMock ).toHaveBeenCalledWith( 'get_hash' );
106
133
  expect( executeUpdateMock ).toHaveBeenCalledWith( 'complete', { args: [] } );
107
- expect( mockLog.warn ).toHaveBeenCalledWith( 'Error interacting with previous catalog workflow', {
134
+ expect( mockLog.warn ).toHaveBeenCalledWith( 'Error completing previous catalog workflow', {
108
135
  error: expect.any( Error )
109
136
  } );
110
137
  expect( mockLog.info ).toHaveBeenCalledWith( 'Starting catalog workflow...' );
@@ -112,7 +139,41 @@ describe( 'worker/start_catalog', () => {
112
139
  taskQueue,
113
140
  workflowId: catalogId,
114
141
  workflowIdConflictPolicy: 'FAIL',
115
- args: [ catalog ]
142
+ args: [ catalog, catalogHash ]
143
+ } );
144
+ } );
145
+
146
+ it( 'when another worker starts matching catalog concurrently: ignores already-started error', async () => {
147
+ const alreadyStartedError = new WorkflowExecutionAlreadyStartedError( 'already started', catalogId, 'catalog' );
148
+ describeMock.mockRejectedValue( new WorkflowNotFoundError( 'not found' ) );
149
+ workflowStartMock.mockRejectedValue( alreadyStartedError );
150
+ queryMock.mockResolvedValue( catalogHash );
151
+
152
+ const { startCatalog } = await import( './start_catalog.js' );
153
+ await startCatalog( { connection: mockConnection, namespace, catalog, catalogHash } );
154
+
155
+ expect( workflowStartMock ).toHaveBeenCalledWith( 'catalog', {
156
+ taskQueue,
157
+ workflowId: catalogId,
158
+ workflowIdConflictPolicy: 'FAIL',
159
+ args: [ catalog, catalogHash ]
116
160
  } );
161
+ expect( queryMock ).toHaveBeenCalledWith( 'get_hash' );
162
+ expect( mockLog.info ).toHaveBeenCalledWith(
163
+ 'Ignoring start error: it failed because execution already started but catalog hash matches worker'
164
+ );
165
+ } );
166
+
167
+ it( 'when another worker starts stale catalog concurrently: rethrows already-started error', async () => {
168
+ const alreadyStartedError = new WorkflowExecutionAlreadyStartedError( 'already started', catalogId, 'catalog' );
169
+ describeMock.mockRejectedValue( new WorkflowNotFoundError( 'not found' ) );
170
+ workflowStartMock.mockRejectedValue( alreadyStartedError );
171
+ queryMock.mockResolvedValue( 'old-catalog-hash' );
172
+
173
+ const { startCatalog } = await import( './start_catalog.js' );
174
+ await expect( startCatalog( { connection: mockConnection, namespace, catalog, catalogHash } ) )
175
+ .rejects.toBe( alreadyStartedError );
176
+
177
+ expect( queryMock ).toHaveBeenCalledWith( 'get_hash' );
117
178
  } );
118
179
  } );