@outputai/core 0.12.1-next.a4a41b6.0 → 0.12.1-next.d1d27f7.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.1-next.a4a41b6.0",
3
+ "version": "0.12.1-next.d1d27f7.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
  } );
@@ -3,17 +3,17 @@ import { createChildLogger } from '#logger';
3
3
  export const pendingHooks = new Set();
4
4
 
5
5
  const log = createChildLogger( 'Hooks' );
6
- const flushTimeoutMs = 30_000;
7
6
 
8
7
  /**
9
8
  * Await all pending hooks to flush for a certain time
9
+ * @param {number} timeoutMs - How long to await the pending hooks before giving up
10
10
  */
11
- export const flushPendingHooks = async () => {
11
+ export const flushPendingHooks = async timeoutMs => {
12
12
  const state = { timeout: null };
13
13
  try {
14
14
  const flushed = await Promise.race( [
15
15
  Promise.allSettled( [ ...pendingHooks ] ).then( _ => true ),
16
- new Promise( r => state.timeout = setTimeout( () => r( false ), flushTimeoutMs ) )
16
+ new Promise( r => state.timeout = setTimeout( () => r( false ), timeoutMs ) )
17
17
  ] );
18
18
  if ( !flushed ) {
19
19
  log.warn( 'Some hook callbacks exceeded the timeout and will not be awaited', { count: pendingHooks.size } );
@@ -24,7 +24,7 @@ describe( 'pending hooks', () => {
24
24
  } );
25
25
  pendingHooks.add( hookPromise );
26
26
 
27
- const flushPromise = flushPendingHooks();
27
+ const flushPromise = flushPendingHooks( 5000 );
28
28
  const state = { flushed: false };
29
29
  flushPromise.then( () => {
30
30
  state.flushed = true;
@@ -42,8 +42,8 @@ describe( 'pending hooks', () => {
42
42
  vi.useFakeTimers();
43
43
  pendingHooks.add( new Promise( () => {} ) );
44
44
 
45
- const flushPromise = flushPendingHooks();
46
- await vi.advanceTimersByTimeAsync( 30_000 );
45
+ const flushPromise = flushPendingHooks( 5000 );
46
+ await vi.advanceTimersByTimeAsync( 5000 );
47
47
 
48
48
  await expect( flushPromise ).resolves.toBeUndefined();
49
49
  } );
@@ -53,8 +53,8 @@ describe( 'pending hooks', () => {
53
53
  pendingHooks.add( new Promise( () => {} ) );
54
54
  pendingHooks.add( new Promise( () => {} ) );
55
55
 
56
- const flushPromise = flushPendingHooks();
57
- await vi.advanceTimersByTimeAsync( 30_000 );
56
+ const flushPromise = flushPendingHooks( 5000 );
57
+ await vi.advanceTimersByTimeAsync( 5000 );
58
58
  await flushPromise;
59
59
 
60
60
  expect( logMock.warn ).toHaveBeenCalledWith( expect.any( String ), { count: 2 } );
@@ -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
+ };