@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@outputai/core",
3
- "version": "0.12.0",
3
+ "version": "0.12.1-next.1243f78.0",
4
4
  "description": "The core module of the output framework",
5
5
  "type": "module",
6
6
  "engines": {
@@ -1,19 +1,3 @@
1
- /**
2
- * Returns a function that invokes the fn argument only once when called, further calls do nothing.
3
- * @param {Function} fn
4
- * @returns {Function}
5
- */
6
- export const runOnce = fn => {
7
- const state = { executed: false, result: undefined };
8
- return ( ...args ) => {
9
- if ( !state.executed ) {
10
- state.executed = true;
11
- return state.result = fn( ...args );
12
- }
13
- return state.result;
14
- };
15
- };
16
-
17
1
  /**
18
2
  * Calls a given function and in case it throws errors, returns `undefined`
19
3
  * @param {Function} fn
@@ -1,51 +1,5 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
- import { runOnce, tryOrUndefined } from './function.js';
3
-
4
- describe( 'runOnce', () => {
5
- it( 'calls the wrapped function only once', () => {
6
- const fn = vi.fn();
7
- const once = runOnce( fn );
8
-
9
- once();
10
- once();
11
- once();
12
-
13
- expect( fn ).toHaveBeenCalledOnce();
14
- } );
15
-
16
- it( 'passes arguments and replays the first call result', () => {
17
- const fn = vi.fn( ( a, b ) => a + b );
18
- const once = runOnce( fn );
19
-
20
- expect( once( 2, 3 ) ).toBe( 5 );
21
- expect( once( 4, 5 ) ).toBe( 5 );
22
- expect( once( 6, 7 ) ).toBe( 5 );
23
- expect( fn ).toHaveBeenCalledWith( 2, 3 );
24
- expect( fn ).toHaveBeenCalledOnce();
25
- } );
26
-
27
- it( 'replays the first returned promise', () => {
28
- const result = Promise.resolve( 'done' );
29
- const fn = vi.fn( () => result );
30
- const once = runOnce( fn );
31
-
32
- expect( once() ).toBe( result );
33
- expect( once() ).toBe( result );
34
- expect( fn ).toHaveBeenCalledOnce();
35
- } );
36
-
37
- it( 'does not retry when the first call throws', () => {
38
- const error = new Error( 'boom' );
39
- const fn = vi.fn( () => {
40
- throw error;
41
- } );
42
- const once = runOnce( fn );
43
-
44
- expect( () => once() ).toThrow( error );
45
- expect( once() ).toBeUndefined();
46
- expect( fn ).toHaveBeenCalledOnce();
47
- } );
48
- } );
2
+ import { tryOrUndefined } from './function.js';
49
3
 
50
4
  describe( 'tryOrUndefined', () => {
51
5
  it( 'returns the invoked function result', () => {
@@ -1,29 +1,11 @@
1
- /**
2
- * Builds a promise that can be resolved from the outside.
3
- */
4
- export class CancellablePromise {
5
- #promise = null;
6
- #complete = null;
7
- #completed = false;
1
+ import { setTimeout as delay } from 'node:timers/promises';
8
2
 
9
- constructor() {
10
- this.#promise = new Promise( resolve => {
11
- this.#complete = () => {
12
- resolve();
13
- this.#completed = true;
14
- };
15
- } );
16
- }
17
- /** Retrieves the promise */
18
- get promise() {
19
- return this.#promise;
20
- }
21
- /** Returns whether the promise is resolved or not */
22
- get completed() {
23
- return this.#completed;
24
- }
25
- /** Resolves the promise */
26
- complete() {
27
- this.#complete();
3
+ export const sleepCancellable = async ( timeoutMs, signal ) => {
4
+ try {
5
+ await delay( timeoutMs, true, { ref: false, signal } );
6
+ } catch ( error ) {
7
+ if ( error?.name !== 'AbortError' ) {
8
+ throw error;
9
+ }
28
10
  }
29
11
  };
@@ -1,35 +1,62 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
- import { CancellablePromise } from './promise.js';
2
+ import { sleepCancellable } from './promise.js';
3
3
 
4
- describe( 'CancellablePromise', () => {
5
- it( 'exposes a pending promise until it is completed', async () => {
6
- const cancellable = new CancellablePromise();
7
- const onComplete = vi.fn();
4
+ // Long enough that a sleep reaching it means the abort was not honored and the test times out.
5
+ const neverElapses = 60000;
6
+
7
+ describe( 'sleepCancellable', () => {
8
+ it( 'resolves only once the timeout elapses', async () => {
9
+ const order = [];
10
+ const sleeping = sleepCancellable( 10 ).then( () => order.push( 'slept' ) );
8
11
 
9
- cancellable.promise.then( onComplete );
10
12
  await Promise.resolve();
13
+ expect( order ).toEqual( [] );
14
+
15
+ await sleeping;
16
+ expect( order ).toEqual( [ 'slept' ] );
17
+ } );
11
18
 
12
- expect( cancellable.completed ).toBe( false );
13
- expect( onComplete ).not.toHaveBeenCalled();
19
+ it( 'resolves without a signal', async () => {
20
+ await expect( sleepCancellable( 10 ) ).resolves.toBeUndefined();
21
+ } );
14
22
 
15
- cancellable.complete();
16
- await cancellable.promise;
23
+ it( 'resolves immediately when the signal is already aborted', async () => {
24
+ const controller = new AbortController();
25
+ controller.abort();
17
26
 
18
- expect( cancellable.completed ).toBe( true );
19
- expect( onComplete ).toHaveBeenCalledOnce();
27
+ await expect( sleepCancellable( neverElapses, controller.signal ) ).resolves.toBeUndefined();
20
28
  } );
21
29
 
22
- it( 'can be completed multiple times without resolving again', async () => {
23
- const cancellable = new CancellablePromise();
24
- const onComplete = vi.fn();
30
+ it( 'resolves as soon as the signal aborts mid sleep', async () => {
31
+ const controller = new AbortController();
32
+ const sleeping = sleepCancellable( neverElapses, controller.signal );
25
33
 
26
- cancellable.promise.then( onComplete );
27
- cancellable.complete();
28
- cancellable.complete();
29
- await cancellable.promise;
30
- await Promise.resolve();
34
+ controller.abort();
35
+
36
+ await expect( sleeping ).resolves.toBeUndefined();
37
+ } );
38
+
39
+ it( 'swallows the abort reason instead of rejecting with it', async () => {
40
+ const controller = new AbortController();
41
+ const sleeping = sleepCancellable( neverElapses, controller.signal );
42
+
43
+ controller.abort( new Error( 'shutting down' ) );
44
+
45
+ await expect( sleeping ).resolves.toBeUndefined();
46
+ } );
47
+
48
+ it( 'rethrows a rejection that is not an abort', async () => {
49
+ const failure = new Error( 'timer failed' );
50
+ vi.doMock( 'node:timers/promises', () => ( { setTimeout: () => Promise.reject( failure ) } ) );
51
+ vi.resetModules();
52
+
53
+ try {
54
+ const { sleepCancellable: sleeping } = await import( './promise.js' );
31
55
 
32
- expect( cancellable.completed ).toBe( true );
33
- expect( onComplete ).toHaveBeenCalledOnce();
56
+ await expect( sleeping( 10 ) ).rejects.toBe( failure );
57
+ } finally {
58
+ vi.doUnmock( 'node:timers/promises' );
59
+ vi.resetModules();
60
+ }
34
61
  } );
35
62
  } );
@@ -0,0 +1,149 @@
1
+ import { Client, WorkflowNotFoundError } from '@temporalio/client';
2
+ import { WorkflowExecutionAlreadyStartedError, WorkflowIdConflictPolicy } from '@temporalio/common';
3
+ import { WORKFLOW_CATALOG } from '#consts';
4
+ import { catalogId as workflowId, taskQueue } from '../configs.js';
5
+ import { createChildLogger } from '#logger';
6
+ import { sleepCancellable } from '#helpers/promise';
7
+ import { serializeError } from '#helpers/error_serializer';
8
+
9
+ const log = createChildLogger( 'Catalog' );
10
+
11
+ const MAX_RETRIES = 2;
12
+ const RETRY_DELAY = 10_000;
13
+
14
+ // The default 10s is not enough for a cold worker: the workflow bundle takes 7-8s to activate,
15
+ // so the first workflow task can exceed it and get retried. Temporal caps this at 120s.
16
+ const WORKFLOW_TASK_TIMEOUT = 30_000;
17
+
18
+ /** Make sure the latest version of the catalog workflow is running. Stateful. */
19
+ export class CatalogPublisher {
20
+ #connection = null;
21
+ #namespace = null;
22
+ #catalog = null;
23
+ #catalogHash = null;
24
+ #client = null;
25
+ #signal = null;
26
+
27
+ #abortCtrl = new AbortController();
28
+ #running = false;
29
+ #execution = null;
30
+
31
+ async #describe() {
32
+ log.info( 'Checking running catalog hash against worker hash...' );
33
+
34
+ try {
35
+ const description = await this.#client.workflow.getHandle( workflowId ).describe();
36
+ return description.closeTime ? null : {
37
+ runId: description.runId,
38
+ hash: description.memo?.hash
39
+ };
40
+ } catch ( error ) {
41
+ if ( !( error instanceof WorkflowNotFoundError ) ) {
42
+ log.warn( 'Ignoring error while describing catalog workflow', { error: serializeError( error, { dropKeys: [ 'stack' ] } ) } );
43
+ }
44
+ return null;
45
+ }
46
+ };
47
+
48
+ async #terminate( runId ) {
49
+ log.info( 'Terminating previous catalog workflow...' );
50
+
51
+ try {
52
+ const handle = await this.#client.workflow.getHandle( workflowId, runId );
53
+ await handle.terminate( `Systematic terminating. Superseded by catalog ${this.#catalogHash}` );
54
+ } catch ( error ) {
55
+ if ( !( error instanceof WorkflowNotFoundError ) ) {
56
+ log.warn( 'Ignoring error while terminating previous catalog workflow', { error: serializeError( error, { dropKeys: [ 'stack' ] } ) } );
57
+ }
58
+ }
59
+ };
60
+
61
+ async #start() {
62
+ log.info( 'Starting catalog workflow...' );
63
+
64
+ try {
65
+ await this.#client.workflow.start( WORKFLOW_CATALOG, {
66
+ taskQueue,
67
+ workflowId,
68
+ workflowIdConflictPolicy: WorkflowIdConflictPolicy.FAIL,
69
+ workflowTaskTimeout: WORKFLOW_TASK_TIMEOUT,
70
+ args: [ this.#catalog ],
71
+ memo: {
72
+ workflowNames: this.#catalog.workflowNames,
73
+ hash: this.#catalogHash
74
+ }
75
+ } );
76
+ return true;
77
+ } catch ( error ) {
78
+ // if catalog exists and its hash is the same as the one from the worker, ignore the error
79
+ if ( error instanceof WorkflowExecutionAlreadyStartedError ) {
80
+ const description = await this.#describe();
81
+ if ( description && description.hash === this.#catalogHash ) {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ throw error;
87
+ }
88
+ }
89
+
90
+ /** Run the sequence to start the catalog */
91
+ async #execute( signal, retries = 0 ) {
92
+ if ( signal.aborted ) {
93
+ return;
94
+ }
95
+
96
+ try {
97
+ const description = await this.#describe();
98
+
99
+ if ( description ) {
100
+ if ( description.hash === this.#catalogHash ) {
101
+ log.info( 'Current catalog workflow hash matches worker, restart skipped' );
102
+ return;
103
+ }
104
+ await this.#terminate( description.runId );
105
+ }
106
+
107
+ const success = await this.#start();
108
+ log.info( success ? 'Catalog workflow started' : 'Found a catalog workflow with matching hashes, start skipped' );
109
+ } catch ( error ) {
110
+ if ( retries < MAX_RETRIES ) {
111
+ log.warn( 'Error while starting catalog workflow, retrying...', { error: serializeError( error, { dropKeys: [ 'stack' ] } ) } );
112
+ await sleepCancellable( RETRY_DELAY, signal );
113
+ await this.#execute( signal, retries + 1 );
114
+ } else {
115
+ throw error;
116
+ }
117
+ }
118
+ }
119
+
120
+ constructor( { connection, namespace, catalog, catalogHash, signal } ) {
121
+ this.#connection = connection;
122
+ this.#namespace = namespace;
123
+ this.#catalog = catalog;
124
+ this.#catalogHash = catalogHash;
125
+ this.#signal = signal;
126
+ this.#client = new Client( { connection: this.#connection, namespace: this.#namespace } );
127
+ }
128
+
129
+ get running() {
130
+ return this.#running;
131
+ }
132
+
133
+ run() {
134
+ if ( this.#execution ) {
135
+ return this.#execution;
136
+ }
137
+
138
+ const signal = AbortSignal.any( [ this.#signal, this.#abortCtrl.signal ] );
139
+
140
+ this.#running = true;
141
+ this.#execution = this.#execute( signal ).finally( _ => this.#running = false );
142
+ return this.#execution;
143
+ }
144
+
145
+ interrupt() {
146
+ this.#abortCtrl.abort();
147
+ return this.#execution ?? Promise.resolve();
148
+ }
149
+ };
@@ -0,0 +1,317 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { WorkflowNotFoundError } from '@temporalio/client';
3
+ import { WorkflowExecutionAlreadyStartedError, WorkflowIdConflictPolicy } from '@temporalio/common';
4
+ import { CatalogPublisher } from './catalog_publisher.js';
5
+
6
+ const {
7
+ catalogId,
8
+ describeMock,
9
+ getHandleMock,
10
+ mockLog,
11
+ serializeErrorMock,
12
+ sleepMock,
13
+ startMock,
14
+ taskQueue,
15
+ terminateMock
16
+ } = vi.hoisted( () => ( {
17
+ catalogId: 'test-catalog',
18
+ describeMock: vi.fn(),
19
+ getHandleMock: vi.fn(),
20
+ mockLog: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
21
+ serializeErrorMock: vi.fn( error => ( { message: error.message } ) ),
22
+ sleepMock: vi.fn(),
23
+ startMock: vi.fn(),
24
+ taskQueue: 'test-queue',
25
+ terminateMock: vi.fn()
26
+ } ) );
27
+
28
+ vi.mock( '#logger', () => ( { createChildLogger: () => mockLog } ) );
29
+ vi.mock( '#helpers/error_serializer', () => ( { serializeError: serializeErrorMock } ) );
30
+ vi.mock( '#consts', () => ( { WORKFLOW_CATALOG: 'catalog' } ) );
31
+ vi.mock( '#helpers/promise', () => ( { sleepCancellable: sleepMock } ) );
32
+ vi.mock( '../configs.js', () => ( { catalogId, taskQueue } ) );
33
+ vi.mock( '@temporalio/client', async importOriginal => {
34
+ const actual = await importOriginal();
35
+ return {
36
+ ...actual,
37
+ Client: vi.fn().mockImplementation( function () {
38
+ return { workflow: { start: startMock, getHandle: getHandleMock } };
39
+ } )
40
+ };
41
+ } );
42
+
43
+ const RETRY_DELAY = 10_000;
44
+ const namespace = 'default';
45
+ const catalogHash = 'catalog-hash';
46
+ const catalog = { workflows: [], workflowNames: { workflow: 'workflow' }, activities: {} };
47
+
48
+ const startArguments = {
49
+ taskQueue,
50
+ workflowId: catalogId,
51
+ workflowIdConflictPolicy: WorkflowIdConflictPolicy.FAIL,
52
+ workflowTaskTimeout: 30_000,
53
+ args: [ catalog ],
54
+ memo: {
55
+ workflowNames: catalog.workflowNames,
56
+ hash: catalogHash
57
+ }
58
+ };
59
+
60
+ const compactError = message => ( { error: { message } } );
61
+
62
+ const running = hash => ( { closeTime: undefined, runId: 'run-1', memo: { hash } } );
63
+ const notFound = () => new WorkflowNotFoundError( 'not found' );
64
+ const alreadyStarted = () => new WorkflowExecutionAlreadyStartedError( 'already started', catalogId, 'catalog' );
65
+
66
+ const createPublisher = ( signal = new AbortController().signal ) =>
67
+ new CatalogPublisher( { connection: {}, namespace, catalog, catalogHash, signal } );
68
+
69
+ const flushPromises = async () => Array
70
+ .from( { length: 10 } )
71
+ .reduce( promise => promise.then( () => Promise.resolve() ), Promise.resolve() );
72
+
73
+ describe( 'CatalogPublisher', () => {
74
+ beforeEach( () => {
75
+ vi.clearAllMocks();
76
+ getHandleMock.mockReturnValue( { describe: describeMock, terminate: terminateMock } );
77
+ describeMock.mockRejectedValue( notFound() );
78
+ terminateMock.mockResolvedValue( undefined );
79
+ startMock.mockResolvedValue( undefined );
80
+ sleepMock.mockResolvedValue( undefined );
81
+ } );
82
+
83
+ describe( 'publish paths', () => {
84
+ it( 'starts the catalog when nothing is running', async () => {
85
+ const publisher = createPublisher();
86
+
87
+ await publisher.run();
88
+
89
+ expect( terminateMock ).not.toHaveBeenCalled();
90
+ expect( startMock ).toHaveBeenCalledWith( 'catalog', startArguments );
91
+ expect( mockLog.info ).toHaveBeenCalledWith( 'Catalog workflow started' );
92
+ expect( mockLog.warn ).not.toHaveBeenCalled();
93
+ expect( publisher.running ).toBe( false );
94
+ } );
95
+
96
+ it( 'starts the catalog when the previous one is closed', async () => {
97
+ describeMock.mockResolvedValue( { closeTime: '2024-01-01T00:00:00Z' } );
98
+
99
+ await createPublisher().run();
100
+
101
+ expect( terminateMock ).not.toHaveBeenCalled();
102
+ expect( startMock ).toHaveBeenCalledWith( 'catalog', startArguments );
103
+ } );
104
+
105
+ it( 'skips the restart when the running hash matches', async () => {
106
+ describeMock.mockResolvedValue( running( catalogHash ) );
107
+
108
+ await createPublisher().run();
109
+
110
+ expect( terminateMock ).not.toHaveBeenCalled();
111
+ expect( startMock ).not.toHaveBeenCalled();
112
+ expect( mockLog.info ).toHaveBeenCalledWith( 'Current catalog workflow hash matches worker, restart skipped' );
113
+ } );
114
+
115
+ it( 'terminates and starts when the running catalog carries no memo', async () => {
116
+ describeMock.mockResolvedValue( { closeTime: undefined, runId: 'run-1', memo: undefined } );
117
+
118
+ await createPublisher().run();
119
+
120
+ expect( mockLog.warn ).not.toHaveBeenCalled();
121
+ expect( terminateMock ).toHaveBeenCalledOnce();
122
+ expect( startMock ).toHaveBeenCalledWith( 'catalog', startArguments );
123
+ } );
124
+
125
+ it( 'terminates the pinned run then starts when the running hash differs', async () => {
126
+ describeMock.mockResolvedValue( running( 'old-hash' ) );
127
+
128
+ await createPublisher().run();
129
+
130
+ expect( getHandleMock ).toHaveBeenCalledWith( catalogId, 'run-1' );
131
+ expect( terminateMock ).toHaveBeenCalledWith( `Systematic terminating. Superseded by catalog ${catalogHash}` );
132
+ expect( startMock ).toHaveBeenCalledWith( 'catalog', startArguments );
133
+ } );
134
+ } );
135
+
136
+ describe( 'race paths', () => {
137
+ it( 'ignores an already started error when the published hash matches', async () => {
138
+ describeMock
139
+ .mockRejectedValueOnce( notFound() )
140
+ .mockResolvedValueOnce( running( catalogHash ) );
141
+ startMock.mockRejectedValue( alreadyStarted() );
142
+
143
+ await createPublisher().run();
144
+
145
+ expect( startMock ).toHaveBeenCalledOnce();
146
+ expect( terminateMock ).not.toHaveBeenCalled();
147
+ expect( mockLog.info ).toHaveBeenCalledWith( 'Found a catalog workflow with matching hashes, start skipped' );
148
+ expect( mockLog.info ).not.toHaveBeenCalledWith( 'Catalog workflow started' );
149
+ } );
150
+
151
+ it( 'retires the winner on retry when an already started error exposes a different hash', async () => {
152
+ describeMock
153
+ .mockRejectedValueOnce( notFound() )
154
+ .mockResolvedValue( running( 'other-hash' ) );
155
+ startMock
156
+ .mockRejectedValueOnce( alreadyStarted() )
157
+ .mockResolvedValue( undefined );
158
+
159
+ await createPublisher().run();
160
+
161
+ expect( sleepMock ).toHaveBeenCalledOnce();
162
+ expect( terminateMock ).toHaveBeenCalledOnce();
163
+ expect( startMock ).toHaveBeenCalledTimes( 2 );
164
+ expect( mockLog.warn ).toHaveBeenCalledWith(
165
+ 'Error while starting catalog workflow, retrying...',
166
+ compactError( 'already started' )
167
+ );
168
+ } );
169
+
170
+ it( 'warns and attempts a start when describing fails', async () => {
171
+ const error = new Error( 'describe failed' );
172
+ describeMock.mockRejectedValue( error );
173
+
174
+ await createPublisher().run();
175
+
176
+ expect( serializeErrorMock ).toHaveBeenCalledWith( error, { dropKeys: [ 'stack' ] } );
177
+ expect( mockLog.warn ).toHaveBeenCalledWith(
178
+ 'Ignoring error while describing catalog workflow',
179
+ compactError( 'describe failed' )
180
+ );
181
+ expect( startMock ).toHaveBeenCalledWith( 'catalog', startArguments );
182
+ } );
183
+
184
+ it( 'warns and still starts when terminating the previous catalog fails', async () => {
185
+ describeMock.mockResolvedValue( running( 'old-hash' ) );
186
+ terminateMock.mockRejectedValue( new Error( 'terminate failed' ) );
187
+
188
+ await createPublisher().run();
189
+
190
+ expect( mockLog.warn ).toHaveBeenCalledWith(
191
+ 'Ignoring error while terminating previous catalog workflow',
192
+ compactError( 'terminate failed' )
193
+ );
194
+ expect( startMock ).toHaveBeenCalledWith( 'catalog', startArguments );
195
+ } );
196
+ } );
197
+
198
+ describe( 'failure', () => {
199
+ it( 'rejects with the original error after exhausting the retries', async () => {
200
+ const error = new Error( 'start failed' );
201
+ startMock.mockRejectedValue( error );
202
+ const publisher = createPublisher();
203
+
204
+ await expect( publisher.run() ).rejects.toBe( error );
205
+
206
+ expect( startMock ).toHaveBeenCalledTimes( 3 );
207
+ expect( sleepMock ).toHaveBeenCalledTimes( 2 );
208
+ expect( publisher.running ).toBe( false );
209
+ } );
210
+
211
+ it( 'waits the retry delay with the abort signal between attempts', async () => {
212
+ startMock.mockRejectedValue( new Error( 'start failed' ) );
213
+
214
+ await expect( createPublisher().run() ).rejects.toThrow( 'start failed' );
215
+
216
+ expect( sleepMock ).toHaveBeenCalledWith( RETRY_DELAY, expect.any( AbortSignal ) );
217
+ } );
218
+ } );
219
+
220
+ describe( 'abort paths', () => {
221
+ it( 'publishes nothing when already aborted', async () => {
222
+ const controller = new AbortController();
223
+ controller.abort();
224
+
225
+ await expect( createPublisher( controller.signal ).run() ).resolves.toBeUndefined();
226
+
227
+ expect( describeMock ).not.toHaveBeenCalled();
228
+ expect( startMock ).not.toHaveBeenCalled();
229
+ } );
230
+
231
+ it( 'stops retrying when the signal aborts during the backoff', async () => {
232
+ const controller = new AbortController();
233
+ startMock.mockRejectedValue( new Error( 'start failed' ) );
234
+ sleepMock.mockImplementation( async () => controller.abort() );
235
+
236
+ await expect( createPublisher( controller.signal ).run() ).resolves.toBeUndefined();
237
+
238
+ expect( startMock ).toHaveBeenCalledOnce();
239
+ expect( sleepMock ).toHaveBeenCalledOnce();
240
+ } );
241
+
242
+ it( 'stops retrying when interrupted during the backoff', async () => {
243
+ startMock.mockRejectedValue( new Error( 'start failed' ) );
244
+ const publisher = createPublisher();
245
+ sleepMock.mockImplementation( async () => {
246
+ publisher.interrupt();
247
+ } );
248
+
249
+ await expect( publisher.run() ).resolves.toBeUndefined();
250
+
251
+ expect( startMock ).toHaveBeenCalledOnce();
252
+ } );
253
+
254
+ it( 'still starts when the abort lands between terminating and starting', async () => {
255
+ const controller = new AbortController();
256
+ describeMock.mockResolvedValue( running( 'old-hash' ) );
257
+ terminateMock.mockImplementation( async () => controller.abort() );
258
+
259
+ await createPublisher( controller.signal ).run();
260
+
261
+ expect( terminateMock ).toHaveBeenCalledOnce();
262
+ expect( startMock ).toHaveBeenCalledWith( 'catalog', startArguments );
263
+ } );
264
+ } );
265
+
266
+ describe( 'lifecycle', () => {
267
+ it( 'reports running while the sequence is pending', async () => {
268
+ const deferred = {};
269
+ describeMock.mockReturnValue( new Promise( resolve => {
270
+ deferred.resolve = resolve;
271
+ } ) );
272
+ const publisher = createPublisher();
273
+
274
+ const run = publisher.run();
275
+ await flushPromises();
276
+
277
+ expect( publisher.running ).toBe( true );
278
+
279
+ deferred.resolve( running( catalogHash ) );
280
+ await run;
281
+
282
+ expect( publisher.running ).toBe( false );
283
+ } );
284
+
285
+ it( 'returns the same execution and publishes once when run more than once', async () => {
286
+ const publisher = createPublisher();
287
+
288
+ const first = publisher.run();
289
+ const second = publisher.run();
290
+
291
+ expect( second ).toBe( first );
292
+
293
+ await first;
294
+
295
+ expect( describeMock ).toHaveBeenCalledOnce();
296
+ expect( startMock ).toHaveBeenCalledOnce();
297
+ } );
298
+
299
+ it( 'resolves when interrupted before running', async () => {
300
+ const publisher = createPublisher();
301
+
302
+ await expect( publisher.interrupt() ).resolves.toBeUndefined();
303
+
304
+ expect( publisher.running ).toBe( false );
305
+ expect( describeMock ).not.toHaveBeenCalled();
306
+ } );
307
+
308
+ it( 'reports the failure when interrupting after a rejected run', async () => {
309
+ startMock.mockRejectedValue( new Error( 'start failed' ) );
310
+ const publisher = createPublisher();
311
+
312
+ await expect( publisher.run() ).rejects.toThrow( 'start failed' );
313
+
314
+ await expect( publisher.interrupt() ).rejects.toThrow( 'start failed' );
315
+ } );
316
+ } );
317
+ } );
@@ -36,10 +36,10 @@ const envVarSchema = z.object( {
36
36
  OUTPUT_ACTIVITY_HEARTBEAT_INTERVAL_MS: z.preprocess( coalesceEmptyString, z.coerce.number().int().positive().default( 2 * 60 * 1000 ) ), // 2min
37
37
  // Whether to send activity heartbeats (enabled by default)
38
38
  OUTPUT_ACTIVITY_HEARTBEAT_ENABLED: z.transform( v => v === undefined ? true : isStringboolTrue( v ) ),
39
- // Set temporal worker shutdown force time
40
- TEMPORAL_SHUTDOWN_FORCE_TIME: durationSchema,
41
- // Set temporal worker shutdown grace time
42
- TEMPORAL_SHUTDOWN_GRACE_TIME: durationSchema,
39
+ // Set temporal worker shutdown force time, kept under the 30s most platforms allow before SIGKILL
40
+ TEMPORAL_SHUTDOWN_FORCE_TIME: durationSchema.default( '25s' ),
41
+ // Set temporal worker shutdown grace time, when in-flight activities are asked to cancel
42
+ TEMPORAL_SHUTDOWN_GRACE_TIME: durationSchema.default( '20s' ),
43
43
  // HTTP CONNECT proxy for Temporal gRPC connections (e.g. "proxy-host:8080").
44
44
  // Must be a bare host:port — no scheme (Temporal's native HTTP CONNECT
45
45
  // option is not a URL).