@outputai/core 0.15.1-next.e254388.0 → 0.15.1-next.fd6bb62.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.15.1-next.e254388.0",
3
+ "version": "0.15.1-next.fd6bb62.0",
4
4
  "description": "The core module of the output framework",
5
5
  "type": "module",
6
6
  "engines": {
@@ -22,13 +22,31 @@ const envVarSchema = z.object( {
22
22
  // Worker concurrency — tune these via env vars to adjust for your workload.
23
23
  // Each step (API, LLM, etc.) call is one activity. Lower this to reduce memory pressure.
24
24
  TEMPORAL_MAX_CONCURRENT_ACTIVITY_TASK_EXECUTIONS: z.preprocess( coalesceEmptyString, z.coerce.number().int().positive().default( 40 ) ),
25
- // Workflows are lightweight state machines — this can be high.
26
- TEMPORAL_MAX_CONCURRENT_WORKFLOW_TASK_EXECUTIONS: z.preprocess( coalesceEmptyString, z.coerce.number().int().positive().default( 200 ) ),
27
- // LRU cache for sticky workflow execution. Lower values free memory faster after surges.
28
- TEMPORAL_MAX_CACHED_WORKFLOWS: z.preprocess( coalesceEmptyString, z.coerce.number().int().positive().default( 1000 ) ),
25
+ /*
26
+ Workflow tasks that may run at once. These are short state transitions, so this is rarely the
27
+ throughput limit - activity concurrency above is. Left unset so @temporalio/worker applies its
28
+ own default of 40; the startup 'Worker sizing' log reports it as workflowTaskSlots.
29
+ https://typescript.temporal.io/api/interfaces/worker.WorkerOptions#maxconcurrentworkflowtaskexecutions
30
+ */
31
+ TEMPORAL_MAX_CONCURRENT_WORKFLOW_TASK_EXECUTIONS: z.preprocess( coalesceEmptyString, z.coerce.number().int().positive().optional() ),
32
+ /*
33
+ LRU cache for sticky workflow execution, holding each workflow's state until it completes or is
34
+ evicted. Left unset so @temporalio/worker sizes it from the isolate's heap limit, which a flat
35
+ value here cannot track. That formula assumes roughly 600 cached workflows per GB, so set this
36
+ where payloads are heavier - the startup 'Worker sizing' log reports what it resolved to.
37
+ https://typescript.temporal.io/api/interfaces/worker.WorkerOptions#maxcachedworkflows
38
+ */
39
+ TEMPORAL_MAX_CACHED_WORKFLOWS: z.preprocess( coalesceEmptyString, z.coerce.number().int().positive().optional() ),
29
40
  // How aggressively the worker pulls tasks from Temporal.
30
41
  TEMPORAL_MAX_CONCURRENT_ACTIVITY_TASK_POLLS: z.preprocess( coalesceEmptyString, z.coerce.number().int().positive().default( 5 ) ),
31
42
  TEMPORAL_MAX_CONCURRENT_WORKFLOW_TASK_POLLS: z.preprocess( coalesceEmptyString, z.coerce.number().int().positive().default( 5 ) ),
43
+ /*
44
+ Threads running workflow sandboxes. Each is a V8 isolate and NODE_OPTIONS heap caps apply per
45
+ isolate, so this multiplies the heap the process can commit - the startup 'Worker sizing'
46
+ log reports the total. Left unset so @temporalio/worker picks: 1 with reuseV8Context, 2 without.
47
+ https://typescript.temporal.io/api/interfaces/worker.WorkerOptions#workflowthreadpoolsize
48
+ */
49
+ TEMPORAL_WORKFLOW_THREAD_POOL_SIZE: z.preprocess( coalesceEmptyString, z.coerce.number().int().positive().optional() ),
32
50
  // JSON-encoded Temporal Worker tuner options.
33
51
  TEMPORAL_WORKER_TUNER: workerTunerEnvSchema,
34
52
  // Activity configs
@@ -64,6 +82,7 @@ export const maxConcurrentWorkflowTaskExecutions = envVars.TEMPORAL_MAX_CONCURRE
64
82
  export const maxCachedWorkflows = envVars.TEMPORAL_MAX_CACHED_WORKFLOWS;
65
83
  export const maxConcurrentActivityTaskPolls = envVars.TEMPORAL_MAX_CONCURRENT_ACTIVITY_TASK_POLLS;
66
84
  export const maxConcurrentWorkflowTaskPolls = envVars.TEMPORAL_MAX_CONCURRENT_WORKFLOW_TASK_POLLS;
85
+ export const workflowThreadPoolSize = envVars.TEMPORAL_WORKFLOW_THREAD_POOL_SIZE;
67
86
  export const workerTuner = envVars.TEMPORAL_WORKER_TUNER;
68
87
  export const namespace = envVars.TEMPORAL_NAMESPACE;
69
88
  export const taskQueue = envVars.OUTPUT_CATALOG_ID;
@@ -1,5 +1,5 @@
1
1
  import { Worker, NativeConnection } from '@temporalio/worker';
2
- import * as configs from './configs.js';
2
+ import { address, apiKey, namespace, taskQueue, grpcProxy, hookFlushTimeoutMs } from './configs.js';
3
3
  import { loadActivities } from './loader/activities.js';
4
4
  import { loadWorkflows } from './loader/workflows.js';
5
5
  import { loadHooks } from './loader/hooks.js';
@@ -15,7 +15,7 @@ import { CatalogPublisher } from './catalog_workflow/catalog_publisher.js';
15
15
  import { mainEventBus } from '#bus';
16
16
  import { flushPendingHooks } from '#hooks/pending_hooks';
17
17
  import { BusEventType } from '#consts';
18
- import { setupTelemetry } from './telemetry.js';
18
+ import { setupTelemetry, logIsolateSizing } from './telemetry.js';
19
19
  import { TemporalConnectionMonitor } from './connection_monitor.js';
20
20
  import { bindGlobalFunctions } from './global_functions.js';
21
21
  import { setupClientConfig } from '#temporal/client';
@@ -24,28 +24,12 @@ import { setupTemporalLogger } from './temporal_logger.js';
24
24
  import { WorkerRunner } from './worker_runner.js';
25
25
  import { shutdownServices } from './shutdown.js';
26
26
  import { init as initCredentials } from '#credentials/init';
27
+ import { buildWorkerOptions } from './worker_options.js';
27
28
 
28
29
  import './log_hooks.js';
29
30
 
30
31
  const log = createChildLogger( 'Worker' );
31
32
 
32
- const {
33
- address,
34
- apiKey,
35
- namespace,
36
- taskQueue,
37
- grpcProxy,
38
- maxConcurrentWorkflowTaskExecutions,
39
- maxConcurrentActivityTaskExecutions,
40
- maxCachedWorkflows,
41
- maxConcurrentActivityTaskPolls,
42
- maxConcurrentWorkflowTaskPolls,
43
- shutdownForceTime,
44
- shutdownGraceTime,
45
- hookFlushTimeoutMs,
46
- workerTuner
47
- } = configs;
48
-
49
33
  const services = {
50
34
  connection: null,
51
35
  connectionMonitor: null,
@@ -113,34 +97,19 @@ const execute = async () => {
113
97
  await run( () => services.catalogPublisher.run() );
114
98
 
115
99
  log.info( 'Creating Temporal worker...' );
116
- const worker = await run( () => {
117
- if ( workerTuner ) {
118
- log.info( 'Using worker tuner options', { ...workerTuner } );
119
- }
120
-
121
- return Worker.create( {
122
- connection,
123
- namespace,
124
- taskQueue,
125
- workflowsPath,
126
- activities,
127
- sinks,
128
- interceptors: initInterceptors( { activities, workflows } ),
129
- // tuner isn't compatible with concurrent task executions configs
130
- ...( workerTuner ? {
131
- tuner: workerTuner
132
- } : {
133
- maxConcurrentWorkflowTaskExecutions,
134
- maxConcurrentActivityTaskExecutions
135
- } ),
136
- maxCachedWorkflows,
137
- maxConcurrentActivityTaskPolls,
138
- maxConcurrentWorkflowTaskPolls,
139
- bundlerOptions: { webpackConfigHook },
140
- ...( shutdownForceTime !== undefined && { shutdownForceTime } ),
141
- ...( shutdownGraceTime !== undefined && { shutdownGraceTime } )
142
- } );
143
- } );
100
+ const worker = await run( () => Worker.create( {
101
+ connection,
102
+ namespace,
103
+ taskQueue,
104
+ workflowsPath,
105
+ activities,
106
+ sinks,
107
+ interceptors: initInterceptors( { activities, workflows } ),
108
+ bundlerOptions: { webpackConfigHook },
109
+ ...buildWorkerOptions()
110
+ } ) );
111
+
112
+ run( () => logIsolateSizing( { worker } ) );
144
113
 
145
114
  log.info( 'Setting up telemetry...' );
146
115
  run( () => setupTelemetry( { worker } ) );
@@ -1,21 +1,141 @@
1
+ import { totalmem } from 'node:os';
2
+ import { getHeapStatistics } from 'node:v8';
1
3
  import { serializeError } from '#helpers/error_serializer';
2
4
  import { createChildLogger } from '#logger';
3
5
  import { workerTelemetryIntervalMs } from './configs.js';
4
6
 
5
7
  const log = createChildLogger( 'Telemetry' );
6
8
 
9
+ /**
10
+ * Resolve the memory limit to compare the worker against: the container's limit, or the
11
+ * machine's total memory when there is none.
12
+ *
13
+ * `process.constrainedMemory()` returns a sentinel near 2^64 rather than 0 when the process is
14
+ * unconstrained, so values at or above the machine's memory are treated as no limit.
15
+ *
16
+ * @returns {number} Bytes.
17
+ */
18
+ const resolveMemoryLimit = () => {
19
+ const constrainedMemory = process.constrainedMemory();
20
+ const machineMemory = totalmem();
21
+ return constrainedMemory > 0 && constrainedMemory < machineMemory ? constrainedMemory : machineMemory;
22
+ };
23
+
24
+ /**
25
+ * Log the worker's isolate and cache sizing. Warn when the projected
26
+ * heap exceeds the memory limit, or when workflow task slots exceed
27
+ * the cache.
28
+ *
29
+ * The worker runs one isolate on the main thread and one per workflow
30
+ * thread. A NODE_OPTIONS heap cap applies to each isolate, not to the
31
+ * process, so on a 4 GB container `--max-old-space-size-percentage=85`
32
+ * allows each isolate ~3.4 GB and two isolates can commit well over
33
+ * the container's memory.
34
+ *
35
+ * The cache and the task slots are sized independently:
36
+ * `@temporalio/worker` derives `maxCachedWorkflows` from the heap limit
37
+ * but fixes `workflowTaskSlots` at 40. On a small container the slots
38
+ * can exceed the cache, which causes tasks to evict each other's
39
+ * workflows and forces history replays.
40
+ *
41
+ * Logged fields are read from `worker.options`, so each is the resolved
42
+ * value rather than the requested one:
43
+ *
44
+ * - `workflowThreadPoolSize`: threads running workflow sandboxes.
45
+ * Defaults to 1 with `reuseV8Context`, 2 without. Ignored under
46
+ * `debugMode`.
47
+ * - `reuseV8Context`: whether workflows share one VM context per thread
48
+ * or each get a new one. Enabled by default. Upstream estimates
49
+ * roughly 600 cached workflows per GB with it and 250 without.
50
+ * - `isolateCount`: `1 + workflowThreadPoolSize`, the multiplier on the
51
+ * per-isolate heap cap. 1 under `debugMode`, which runs workflows in
52
+ * the main isolate and spawns no workflow threads.
53
+ * - `heapSizeLimit`: the main isolate's V8 heap ceiling, as resolved
54
+ * from NODE_OPTIONS.
55
+ * - `projectedCommittedHeap`: `isolateCount * heapSizeLimit`, the heap
56
+ * the process can commit if every isolate fills. Only the main
57
+ * thread's limit is readable, so this assumes the workflow threads
58
+ * have the same one.
59
+ * - `memoryLimit`: the limit the projection is compared against. The
60
+ * container's limit, or the machine's total memory when unconstrained.
61
+ * - `maxCachedWorkflows`: sticky cache size. Derived from
62
+ * `heapSizeLimit` when TEMPORAL_MAX_CACHED_WORKFLOWS is unset.
63
+ * - `workflowTaskSlots`: workflow tasks that may run at once, from the
64
+ * tuner. Undefined with a resource-based tuner, which sizes slots at
65
+ * runtime.
66
+ *
67
+ * @param {object} params Parameters.
68
+ * @param {import('@temporalio/worker').Worker} params.worker The created Temporal worker.
69
+ */
70
+ export const logIsolateSizing = ( { worker } ) => {
71
+ const { workflowThreadPoolSize, reuseV8Context, maxCachedWorkflows, tuner, debugMode } = worker.options;
72
+ // debugMode runs workflows in the main isolate and spawns no workflow threads.
73
+ const isolateCount = debugMode ? 1 : 1 + workflowThreadPoolSize;
74
+ const heapSizeLimit = getHeapStatistics().heap_size_limit;
75
+ const memoryLimit = resolveMemoryLimit();
76
+ const workflowTaskSlots = tuner?.workflowTaskSlotSupplier?.numSlots;
77
+ const projectedCommittedHeap = isolateCount * heapSizeLimit;
78
+
79
+ log.info( 'Worker sizing', {
80
+ workflowThreadPoolSize,
81
+ reuseV8Context,
82
+ isolateCount,
83
+ heapSizeLimit,
84
+ projectedCommittedHeap,
85
+ memoryLimit,
86
+ maxCachedWorkflows,
87
+ workflowTaskSlots
88
+ } );
89
+
90
+ if ( projectedCommittedHeap > memoryLimit ) {
91
+ log.warn( 'Worker isolates may commit more heap than the host allows', { isolateCount, heapSizeLimit, projectedCommittedHeap, memoryLimit } );
92
+ }
93
+
94
+ if ( workflowTaskSlots > maxCachedWorkflows ) {
95
+ log.warn( 'Worker runs more workflow tasks than it can cache, forcing replays', { workflowTaskSlots, maxCachedWorkflows } );
96
+ }
97
+ };
98
+
99
+ /**
100
+ * Start an interval that logs worker status and memory every
101
+ * OUTPUT_WORKER_TELEMETRY_INTERVAL_MS. Disabled when it is 0.
102
+ *
103
+ * Each record holds the Temporal worker's `status` and a `memory`
104
+ * block. The memory fields mix process-wide and main-isolate scopes:
105
+ *
106
+ * - `availableMemory`: memory the process can still allocate, within
107
+ * the container limit.
108
+ * - `memoryLimit`: the container's limit, or the machine's total memory
109
+ * when unconstrained.
110
+ * - `memoryUsage`: raw `process.memoryUsage()`. `rss` is process-wide;
111
+ * `heapTotal` and `heapUsed` cover only the main isolate.
112
+ * - `nonMainHeap`: `memoryUsage.rss - memoryUsage.heapUsed`, the
113
+ * resident memory outside the main isolate's live objects: workflow
114
+ * thread heaps, Rust core allocations, buffers, and heap pages V8 has
115
+ * committed but not filled. An upper bound on the workflow thread
116
+ * heap, not a measurement of it. Cached workflow memory is counted
117
+ * here, so it grows with the cache while `heapUsed` stays flat.
118
+ * - `heapSizeLimit`: the main isolate's V8 heap ceiling. Constant, but
119
+ * included in each sample so records are self-contained.
120
+ *
121
+ * @param {object} params Parameters.
122
+ * @param {import('@temporalio/worker').Worker} params.worker The created Temporal worker.
123
+ */
7
124
  export const setupTelemetry = ( { worker } ) => {
8
125
  if ( workerTelemetryIntervalMs <= 0 ) {
9
126
  return;
10
127
  }
11
128
  setInterval( () => {
12
129
  try {
130
+ const memoryUsage = process.memoryUsage();
13
131
  log.info( 'Worker', {
14
132
  status: worker.getStatus(),
15
133
  memory: {
16
134
  availableMemory: process.availableMemory(),
17
- constrainedMemory: process.constrainedMemory(),
18
- memoryUsage: process.memoryUsage()
135
+ memoryLimit: resolveMemoryLimit(),
136
+ memoryUsage,
137
+ nonMainHeap: memoryUsage.rss - memoryUsage.heapUsed,
138
+ heapSizeLimit: getHeapStatistics().heap_size_limit
19
139
  }
20
140
  } );
21
141
  } catch ( error ) {
@@ -0,0 +1,43 @@
1
+ import * as configs from './configs.js';
2
+ import { createChildLogger } from '#logger';
3
+
4
+ const log = createChildLogger( 'Worker' );
5
+
6
+ const {
7
+ maxConcurrentWorkflowTaskExecutions,
8
+ maxConcurrentActivityTaskExecutions,
9
+ maxCachedWorkflows,
10
+ maxConcurrentActivityTaskPolls,
11
+ maxConcurrentWorkflowTaskPolls,
12
+ workflowThreadPoolSize,
13
+ shutdownForceTime,
14
+ shutdownGraceTime,
15
+ workerTuner
16
+ } = configs;
17
+
18
+ /**
19
+ * Builds the concurrency, cache and shutdown options passed to `Worker.create`.
20
+ * Unset values are omitted so @temporalio/worker applies its own defaults.
21
+ * @returns {object} partial Temporal worker options
22
+ */
23
+ export const buildWorkerOptions = () => {
24
+ if ( workerTuner ) {
25
+ log.info( 'Using worker tuner options', { ...workerTuner } );
26
+ }
27
+ return {
28
+ // tuner isn't compatible with concurrent task executions configs
29
+ ...( workerTuner ? {
30
+ tuner: workerTuner
31
+ } : {
32
+ ...( maxConcurrentWorkflowTaskExecutions !== undefined && { maxConcurrentWorkflowTaskExecutions } ),
33
+ maxConcurrentActivityTaskExecutions
34
+ } ),
35
+ ...( maxCachedWorkflows !== undefined && { maxCachedWorkflows } ),
36
+ maxConcurrentActivityTaskPolls,
37
+ maxConcurrentWorkflowTaskPolls,
38
+ // Omitted when unset so @temporalio/worker applies its own default, which depends on reuseV8Context.
39
+ ...( workflowThreadPoolSize !== undefined && { workflowThreadPoolSize } ),
40
+ ...( shutdownForceTime !== undefined && { shutdownForceTime } ),
41
+ ...( shutdownGraceTime !== undefined && { shutdownGraceTime } )
42
+ };
43
+ };