tywrap 0.2.0 → 0.2.1
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/README.md +1 -1
- package/dist/index.d.ts +20 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +22 -2
- package/dist/index.js.map +1 -1
- package/dist/runtime/base.d.ts +17 -7
- package/dist/runtime/base.d.ts.map +1 -1
- package/dist/runtime/base.js +18 -1
- package/dist/runtime/base.js.map +1 -1
- package/dist/runtime/bounded-context.d.ts +252 -0
- package/dist/runtime/bounded-context.d.ts.map +1 -0
- package/dist/runtime/bounded-context.js +454 -0
- package/dist/runtime/bounded-context.js.map +1 -0
- package/dist/runtime/bridge-protocol.d.ts +167 -0
- package/dist/runtime/bridge-protocol.d.ts.map +1 -0
- package/dist/runtime/bridge-protocol.js +247 -0
- package/dist/runtime/bridge-protocol.js.map +1 -0
- package/dist/runtime/disposable.d.ts +40 -0
- package/dist/runtime/disposable.d.ts.map +1 -0
- package/dist/runtime/disposable.js +49 -0
- package/dist/runtime/disposable.js.map +1 -0
- package/dist/runtime/http-io.d.ts +91 -0
- package/dist/runtime/http-io.d.ts.map +1 -0
- package/dist/runtime/http-io.js +195 -0
- package/dist/runtime/http-io.js.map +1 -0
- package/dist/runtime/http.d.ts +47 -13
- package/dist/runtime/http.d.ts.map +1 -1
- package/dist/runtime/http.js +55 -74
- package/dist/runtime/http.js.map +1 -1
- package/dist/runtime/node.d.ts +97 -130
- package/dist/runtime/node.d.ts.map +1 -1
- package/dist/runtime/node.js +256 -523
- package/dist/runtime/node.js.map +1 -1
- package/dist/runtime/pooled-transport.d.ts +131 -0
- package/dist/runtime/pooled-transport.d.ts.map +1 -0
- package/dist/runtime/pooled-transport.js +175 -0
- package/dist/runtime/pooled-transport.js.map +1 -0
- package/dist/runtime/process-io.d.ts +204 -0
- package/dist/runtime/process-io.d.ts.map +1 -0
- package/dist/runtime/process-io.js +695 -0
- package/dist/runtime/process-io.js.map +1 -0
- package/dist/runtime/pyodide-io.d.ts +155 -0
- package/dist/runtime/pyodide-io.d.ts.map +1 -0
- package/dist/runtime/pyodide-io.js +397 -0
- package/dist/runtime/pyodide-io.js.map +1 -0
- package/dist/runtime/pyodide.d.ts +51 -19
- package/dist/runtime/pyodide.d.ts.map +1 -1
- package/dist/runtime/pyodide.js +57 -186
- package/dist/runtime/pyodide.js.map +1 -1
- package/dist/runtime/safe-codec.d.ts +81 -0
- package/dist/runtime/safe-codec.d.ts.map +1 -0
- package/dist/runtime/safe-codec.js +345 -0
- package/dist/runtime/safe-codec.js.map +1 -0
- package/dist/runtime/transport.d.ts +186 -0
- package/dist/runtime/transport.d.ts.map +1 -0
- package/dist/runtime/transport.js +86 -0
- package/dist/runtime/transport.js.map +1 -0
- package/dist/runtime/validators.d.ts +131 -0
- package/dist/runtime/validators.d.ts.map +1 -0
- package/dist/runtime/validators.js +219 -0
- package/dist/runtime/validators.js.map +1 -0
- package/dist/runtime/worker-pool.d.ts +196 -0
- package/dist/runtime/worker-pool.d.ts.map +1 -0
- package/dist/runtime/worker-pool.js +371 -0
- package/dist/runtime/worker-pool.js.map +1 -0
- package/dist/utils/codec.d.ts.map +1 -1
- package/dist/utils/codec.js +120 -1
- package/dist/utils/codec.js.map +1 -1
- package/package.json +2 -2
- package/runtime/python_bridge.py +30 -3
- package/runtime/safe_codec.py +344 -0
- package/src/index.ts +48 -5
- package/src/runtime/base.ts +18 -26
- package/src/runtime/bounded-context.ts +608 -0
- package/src/runtime/bridge-protocol.ts +319 -0
- package/src/runtime/disposable.ts +65 -0
- package/src/runtime/http-io.ts +244 -0
- package/src/runtime/http.ts +71 -117
- package/src/runtime/node.ts +358 -691
- package/src/runtime/pooled-transport.ts +252 -0
- package/src/runtime/process-io.ts +902 -0
- package/src/runtime/pyodide-io.ts +485 -0
- package/src/runtime/pyodide.ts +75 -215
- package/src/runtime/safe-codec.ts +443 -0
- package/src/runtime/transport.ts +273 -0
- package/src/runtime/validators.ts +241 -0
- package/src/runtime/worker-pool.ts +498 -0
- package/src/utils/codec.ts +126 -1
package/src/runtime/node.ts
CHANGED
|
@@ -1,145 +1,138 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Node.js
|
|
2
|
+
* Node.js runtime bridge for BridgeProtocol.
|
|
3
3
|
*
|
|
4
|
-
* NodeBridge
|
|
5
|
-
*
|
|
6
|
-
* in single-process mode for maximum compatibility with the original NodeBridge behavior.
|
|
4
|
+
* NodeBridge extends BridgeProtocol and uses ProcessIO transports with
|
|
5
|
+
* optional pooling for concurrent Python execution.
|
|
7
6
|
*
|
|
8
|
-
*
|
|
7
|
+
* @see https://github.com/bbopen/tywrap/issues/149
|
|
9
8
|
*/
|
|
10
9
|
|
|
11
10
|
import { existsSync } from 'node:fs';
|
|
12
11
|
import { delimiter, isAbsolute, join, resolve } from 'node:path';
|
|
13
12
|
import { fileURLToPath } from 'node:url';
|
|
14
13
|
import { createRequire } from 'node:module';
|
|
15
|
-
import type { ChildProcess } from 'child_process';
|
|
16
|
-
import { EventEmitter } from 'events';
|
|
17
14
|
|
|
18
|
-
import {
|
|
19
|
-
import { autoRegisterArrowDecoder, decodeValueAsync } from '../utils/codec.js';
|
|
15
|
+
import { autoRegisterArrowDecoder } from '../utils/codec.js';
|
|
20
16
|
import { getDefaultPythonPath } from '../utils/python.js';
|
|
21
17
|
import { getVenvBinDir, getVenvPythonExe } from '../utils/runtime.js';
|
|
22
|
-
import
|
|
23
|
-
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
validateBridgeInfo,
|
|
35
|
-
} from './bridge-core.js';
|
|
36
|
-
import { getComponentLogger } from '../utils/logger.js';
|
|
37
|
-
|
|
38
|
-
const log = getComponentLogger('NodeBridge');
|
|
18
|
+
import { globalCache } from '../utils/cache.js';
|
|
19
|
+
|
|
20
|
+
import { BridgeProtocol, type BridgeProtocolOptions } from './bridge-protocol.js';
|
|
21
|
+
import { BridgeProtocolError } from './errors.js';
|
|
22
|
+
import { ProcessIO } from './process-io.js';
|
|
23
|
+
import { PooledTransport } from './pooled-transport.js';
|
|
24
|
+
import type { CodecOptions } from './safe-codec.js';
|
|
25
|
+
import type { PooledWorker } from './worker-pool.js';
|
|
26
|
+
|
|
27
|
+
// =============================================================================
|
|
28
|
+
// OPTIONS
|
|
29
|
+
// =============================================================================
|
|
39
30
|
|
|
40
31
|
/**
|
|
41
32
|
* Configuration options for NodeBridge.
|
|
42
|
-
*
|
|
43
|
-
* By default, NodeBridge runs in single-process mode (minProcesses=1, maxProcesses=1).
|
|
44
|
-
* For high-throughput workloads, increase these values to enable process pooling.
|
|
45
33
|
*/
|
|
46
34
|
export interface NodeBridgeOptions {
|
|
47
35
|
/** Minimum number of Python processes to keep alive. Default: 1 */
|
|
48
36
|
minProcesses?: number;
|
|
37
|
+
|
|
49
38
|
/** Maximum number of Python processes to spawn. Default: 1 (single-process mode) */
|
|
50
39
|
maxProcesses?: number;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
40
|
+
|
|
41
|
+
/** Maximum concurrent requests per process. Default: 10 */
|
|
42
|
+
maxConcurrentPerProcess?: number;
|
|
43
|
+
|
|
55
44
|
/** Path to Python executable. Auto-detected if not specified. */
|
|
56
45
|
pythonPath?: string;
|
|
46
|
+
|
|
57
47
|
/** Path to python_bridge.py script. Auto-detected if not specified. */
|
|
58
48
|
scriptPath?: string;
|
|
49
|
+
|
|
59
50
|
/** Path to Python virtual environment. */
|
|
60
51
|
virtualEnv?: string;
|
|
52
|
+
|
|
61
53
|
/** Working directory for Python process. Default: process.cwd() */
|
|
62
54
|
cwd?: string;
|
|
55
|
+
|
|
63
56
|
/** Timeout in ms for Python calls. Default: 30000 */
|
|
64
57
|
timeoutMs?: number;
|
|
65
|
-
|
|
66
|
-
|
|
58
|
+
|
|
59
|
+
/** Timeout in ms for waiting in pool queue. Default: 30000 */
|
|
60
|
+
queueTimeoutMs?: number;
|
|
61
|
+
|
|
67
62
|
/** Inherit all environment variables from parent process. Default: false */
|
|
68
63
|
inheritProcessEnv?: boolean;
|
|
69
|
-
|
|
70
|
-
* When true, sets TYWRAP_CODEC_FALLBACK=json for the Python process to prefer JSON encoding
|
|
71
|
-
* for rich types (ndarray/dataframe/series). Default: false for fast-fail on Arrow path issues.
|
|
72
|
-
*/
|
|
73
|
-
enableJsonFallback?: boolean;
|
|
64
|
+
|
|
74
65
|
/** Enable result caching for pure functions. Default: false */
|
|
75
66
|
enableCache?: boolean;
|
|
67
|
+
|
|
76
68
|
/** Optional extra environment variables to pass to the Python subprocess. */
|
|
77
69
|
env?: Record<string, string | undefined>;
|
|
70
|
+
|
|
71
|
+
/** Codec options for validation/serialization */
|
|
72
|
+
codec?: CodecOptions;
|
|
73
|
+
|
|
78
74
|
/** Commands to run on each process at startup for warming up. */
|
|
79
|
-
warmupCommands?: Array<
|
|
75
|
+
warmupCommands?: Array<
|
|
76
|
+
| { module: string; functionName: string; args?: unknown[] }
|
|
77
|
+
| { method: string; params: unknown } // Legacy format for backwards compatibility
|
|
78
|
+
>;
|
|
79
|
+
|
|
80
|
+
// ===========================================================================
|
|
81
|
+
// DEPRECATED OPTIONS (kept for backwards compatibility, ignored internally)
|
|
82
|
+
// ===========================================================================
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @deprecated No longer used. Pool idle time is managed by WorkerPool.
|
|
86
|
+
*/
|
|
87
|
+
maxIdleTime?: number;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @deprecated No longer used. Process restart is managed by ProcessIO.
|
|
91
|
+
*/
|
|
92
|
+
maxRequestsPerProcess?: number;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @deprecated Use codec.bytesHandling option instead.
|
|
96
|
+
*/
|
|
97
|
+
enableJsonFallback?: boolean;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @deprecated Use ProcessIO options instead.
|
|
101
|
+
*/
|
|
102
|
+
maxLineLength?: number;
|
|
80
103
|
}
|
|
81
104
|
|
|
105
|
+
// =============================================================================
|
|
106
|
+
// INTERNAL TYPES
|
|
107
|
+
// =============================================================================
|
|
108
|
+
|
|
82
109
|
interface ResolvedOptions {
|
|
83
110
|
minProcesses: number;
|
|
84
111
|
maxProcesses: number;
|
|
85
|
-
|
|
86
|
-
maxRequestsPerProcess: number;
|
|
112
|
+
maxConcurrentPerProcess: number;
|
|
87
113
|
pythonPath: string;
|
|
88
114
|
scriptPath: string;
|
|
89
115
|
virtualEnv?: string;
|
|
90
116
|
cwd: string;
|
|
91
117
|
timeoutMs: number;
|
|
92
|
-
|
|
118
|
+
queueTimeoutMs: number;
|
|
93
119
|
inheritProcessEnv: boolean;
|
|
94
|
-
enableJsonFallback: boolean;
|
|
95
120
|
enableCache: boolean;
|
|
96
121
|
env: Record<string, string | undefined>;
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
id: string;
|
|
103
|
-
requestCount: number;
|
|
104
|
-
lastUsed: number;
|
|
105
|
-
busy: boolean;
|
|
106
|
-
quarantined: boolean;
|
|
107
|
-
core: BridgeCore;
|
|
108
|
-
stats: {
|
|
109
|
-
totalRequests: number;
|
|
110
|
-
totalTime: number;
|
|
111
|
-
averageTime: number;
|
|
112
|
-
errorCount: number;
|
|
113
|
-
};
|
|
122
|
+
codec?: CodecOptions;
|
|
123
|
+
warmupCommands: Array<
|
|
124
|
+
| { module: string; functionName: string; args?: unknown[] }
|
|
125
|
+
| { method: string; params: unknown }
|
|
126
|
+
>;
|
|
114
127
|
}
|
|
115
128
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
cacheHits: number;
|
|
120
|
-
poolHits: number;
|
|
121
|
-
poolMisses: number;
|
|
122
|
-
processSpawns: number;
|
|
123
|
-
processDeaths: number;
|
|
124
|
-
memoryPeak: number;
|
|
125
|
-
averageTime: number;
|
|
126
|
-
cacheHitRate: number;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
interface BridgeStatsSnapshot extends BridgeStats {
|
|
130
|
-
poolSize: number;
|
|
131
|
-
busyWorkers: number;
|
|
132
|
-
memoryUsage: NodeJS.MemoryUsage;
|
|
133
|
-
workerStats: Array<{
|
|
134
|
-
id: string;
|
|
135
|
-
requestCount: number;
|
|
136
|
-
averageTime: number;
|
|
137
|
-
errorCount: number;
|
|
138
|
-
busy: boolean;
|
|
139
|
-
pendingRequests: number;
|
|
140
|
-
}>;
|
|
141
|
-
}
|
|
129
|
+
// =============================================================================
|
|
130
|
+
// UTILITIES
|
|
131
|
+
// =============================================================================
|
|
142
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Resolve the default bridge script path.
|
|
135
|
+
*/
|
|
143
136
|
function resolveDefaultScriptPath(): string {
|
|
144
137
|
try {
|
|
145
138
|
return fileURLToPath(new URL('../../runtime/python_bridge.py', import.meta.url));
|
|
@@ -148,173 +141,202 @@ function resolveDefaultScriptPath(): string {
|
|
|
148
141
|
}
|
|
149
142
|
}
|
|
150
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Resolve virtual environment paths.
|
|
146
|
+
*/
|
|
151
147
|
function resolveVirtualEnv(
|
|
152
148
|
virtualEnv: string,
|
|
153
149
|
cwd: string
|
|
154
|
-
): {
|
|
155
|
-
venvPath: string;
|
|
156
|
-
binDir: string;
|
|
157
|
-
pythonPath: string;
|
|
158
|
-
} {
|
|
150
|
+
): { venvPath: string; binDir: string; pythonPath: string } {
|
|
159
151
|
const venvPath = resolve(cwd, virtualEnv);
|
|
160
152
|
const binDir = join(venvPath, getVenvBinDir());
|
|
161
153
|
const pythonPath = join(binDir, getVenvPythonExe());
|
|
162
154
|
return { venvPath, binDir, pythonPath };
|
|
163
155
|
}
|
|
164
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Get the environment variable key for PATH (case-insensitive on Windows).
|
|
159
|
+
*/
|
|
160
|
+
function getPathKey(env: Record<string, string | undefined>): string {
|
|
161
|
+
for (const key of Object.keys(env)) {
|
|
162
|
+
if (key.toLowerCase() === 'path') {
|
|
163
|
+
return key;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return 'PATH';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// =============================================================================
|
|
170
|
+
// NODE BRIDGE
|
|
171
|
+
// =============================================================================
|
|
172
|
+
|
|
165
173
|
/**
|
|
166
174
|
* Node.js runtime bridge for executing Python code.
|
|
167
175
|
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
176
|
+
* NodeBridge provides subprocess-based Python execution with optional pooling
|
|
177
|
+
* for high-throughput workloads. By default, it runs in single-process mode.
|
|
178
|
+
*
|
|
179
|
+
* Features:
|
|
180
|
+
* - Single or multi-process execution via process pooling
|
|
181
|
+
* - Virtual environment support
|
|
182
|
+
* - Full SafeCodec validation (NaN/Infinity rejection, key validation)
|
|
183
|
+
* - Automatic Arrow decoding for DataFrames/ndarrays
|
|
184
|
+
* - Optional result caching for pure functions
|
|
185
|
+
* - Process warmup commands
|
|
170
186
|
*
|
|
171
187
|
* @example
|
|
172
188
|
* ```typescript
|
|
173
189
|
* // Single-process mode (default)
|
|
174
190
|
* const bridge = new NodeBridge();
|
|
191
|
+
* await bridge.init();
|
|
192
|
+
*
|
|
193
|
+
* const result = await bridge.call('math', 'sqrt', [16]);
|
|
194
|
+
* console.log(result); // 4.0
|
|
175
195
|
*
|
|
196
|
+
* await bridge.dispose();
|
|
197
|
+
* ```
|
|
198
|
+
*
|
|
199
|
+
* @example
|
|
200
|
+
* ```typescript
|
|
176
201
|
* // Multi-process pooling for high throughput
|
|
177
202
|
* const pooledBridge = new NodeBridge({
|
|
178
|
-
*
|
|
179
|
-
*
|
|
203
|
+
* maxProcesses: 4,
|
|
204
|
+
* maxConcurrentPerProcess: 2,
|
|
180
205
|
* enableCache: true,
|
|
181
206
|
* });
|
|
207
|
+
* await pooledBridge.init();
|
|
182
208
|
* ```
|
|
183
209
|
*/
|
|
184
|
-
export class NodeBridge extends
|
|
185
|
-
private
|
|
186
|
-
private
|
|
187
|
-
private cleanupTimer?: NodeJS.Timeout;
|
|
188
|
-
private options: ResolvedOptions;
|
|
189
|
-
private emitter = new EventEmitter();
|
|
190
|
-
private disposed = false;
|
|
191
|
-
private bridgeInfo?: BridgeInfo;
|
|
192
|
-
private initPromise?: Promise<void>;
|
|
193
|
-
|
|
194
|
-
// Performance monitoring
|
|
195
|
-
private stats: BridgeStats = {
|
|
196
|
-
totalRequests: 0,
|
|
197
|
-
totalTime: 0,
|
|
198
|
-
cacheHits: 0,
|
|
199
|
-
poolHits: 0,
|
|
200
|
-
poolMisses: 0,
|
|
201
|
-
processSpawns: 0,
|
|
202
|
-
processDeaths: 0,
|
|
203
|
-
memoryPeak: 0,
|
|
204
|
-
averageTime: 0,
|
|
205
|
-
cacheHitRate: 0,
|
|
206
|
-
};
|
|
210
|
+
export class NodeBridge extends BridgeProtocol {
|
|
211
|
+
private readonly resolvedOptions: ResolvedOptions;
|
|
212
|
+
private readonly pooledTransport: PooledTransport;
|
|
207
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Create a new NodeBridge instance.
|
|
216
|
+
*
|
|
217
|
+
* @param options - Configuration options for the bridge
|
|
218
|
+
*/
|
|
208
219
|
constructor(options: NodeBridgeOptions = {}) {
|
|
209
|
-
super();
|
|
210
220
|
const cwd = options.cwd ?? process.cwd();
|
|
211
221
|
const virtualEnv = options.virtualEnv ? resolve(cwd, options.virtualEnv) : undefined;
|
|
212
222
|
const venv = virtualEnv ? resolveVirtualEnv(virtualEnv, cwd) : undefined;
|
|
213
223
|
const scriptPath = options.scriptPath ?? resolveDefaultScriptPath();
|
|
214
224
|
const resolvedScriptPath = isAbsolute(scriptPath) ? scriptPath : resolve(cwd, scriptPath);
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
225
|
+
|
|
226
|
+
const maxProcesses = options.maxProcesses ?? 1;
|
|
227
|
+
const minProcesses = Math.min(options.minProcesses ?? 1, maxProcesses);
|
|
228
|
+
|
|
229
|
+
const resolvedOptions: ResolvedOptions = {
|
|
230
|
+
minProcesses,
|
|
231
|
+
maxProcesses,
|
|
232
|
+
maxConcurrentPerProcess: options.maxConcurrentPerProcess ?? 10,
|
|
221
233
|
pythonPath: options.pythonPath ?? venv?.pythonPath ?? getDefaultPythonPath(),
|
|
222
234
|
scriptPath: resolvedScriptPath,
|
|
223
235
|
virtualEnv,
|
|
224
236
|
cwd,
|
|
225
237
|
timeoutMs: options.timeoutMs ?? 30000,
|
|
226
|
-
|
|
238
|
+
queueTimeoutMs: options.queueTimeoutMs ?? 30000,
|
|
227
239
|
inheritProcessEnv: options.inheritProcessEnv ?? false,
|
|
228
|
-
enableJsonFallback: options.enableJsonFallback ?? false,
|
|
229
240
|
enableCache: options.enableCache ?? false,
|
|
230
241
|
env: options.env ?? {},
|
|
242
|
+
codec: options.codec,
|
|
231
243
|
warmupCommands: options.warmupCommands ?? [],
|
|
232
244
|
};
|
|
233
245
|
|
|
234
|
-
//
|
|
235
|
-
|
|
236
|
-
|
|
246
|
+
// Build environment for ProcessIO
|
|
247
|
+
const processEnv = buildProcessEnv(resolvedOptions);
|
|
248
|
+
|
|
249
|
+
// Create warmup callback for per-worker initialization
|
|
250
|
+
const onWorkerReady = resolvedOptions.warmupCommands.length > 0
|
|
251
|
+
? createWarmupCallback(resolvedOptions.warmupCommands, resolvedOptions.timeoutMs)
|
|
252
|
+
: undefined;
|
|
253
|
+
|
|
254
|
+
// Create pooled transport with ProcessIO workers
|
|
255
|
+
const transport = new PooledTransport({
|
|
256
|
+
createTransport: () =>
|
|
257
|
+
new ProcessIO({
|
|
258
|
+
pythonPath: resolvedOptions.pythonPath,
|
|
259
|
+
bridgeScript: resolvedOptions.scriptPath,
|
|
260
|
+
env: processEnv,
|
|
261
|
+
cwd: resolvedOptions.cwd,
|
|
262
|
+
}),
|
|
263
|
+
maxWorkers: resolvedOptions.maxProcesses,
|
|
264
|
+
minWorkers: resolvedOptions.minProcesses,
|
|
265
|
+
queueTimeoutMs: resolvedOptions.queueTimeoutMs,
|
|
266
|
+
maxConcurrentPerWorker: resolvedOptions.maxConcurrentPerProcess,
|
|
267
|
+
onWorkerReady,
|
|
268
|
+
});
|
|
237
269
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
this.
|
|
249
|
-
return this.initPromise;
|
|
270
|
+
// Initialize BridgeProtocol with pooled transport
|
|
271
|
+
const protocolOptions: BridgeProtocolOptions = {
|
|
272
|
+
transport,
|
|
273
|
+
codec: resolvedOptions.codec,
|
|
274
|
+
defaultTimeoutMs: resolvedOptions.timeoutMs,
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
super(protocolOptions);
|
|
278
|
+
|
|
279
|
+
this.resolvedOptions = resolvedOptions;
|
|
280
|
+
this.pooledTransport = transport;
|
|
250
281
|
}
|
|
251
282
|
|
|
252
|
-
|
|
283
|
+
// ===========================================================================
|
|
284
|
+
// LIFECYCLE
|
|
285
|
+
// ===========================================================================
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Initialize the bridge.
|
|
289
|
+
*
|
|
290
|
+
* Validates the bridge script exists, registers Arrow decoder,
|
|
291
|
+
* and initializes the transport pool (which runs warmup commands per-worker).
|
|
292
|
+
*/
|
|
293
|
+
protected override async doInit(): Promise<void> {
|
|
294
|
+
// Validate script exists
|
|
253
295
|
// eslint-disable-next-line security/detect-non-literal-fs-filename -- script path is user-configured
|
|
254
|
-
if (!existsSync(this.
|
|
255
|
-
throw new BridgeProtocolError(
|
|
296
|
+
if (!existsSync(this.resolvedOptions.scriptPath)) {
|
|
297
|
+
throw new BridgeProtocolError(
|
|
298
|
+
`Python bridge script not found at ${this.resolvedOptions.scriptPath}`
|
|
299
|
+
);
|
|
256
300
|
}
|
|
257
301
|
|
|
302
|
+
// Register Arrow decoder for DataFrames/ndarrays
|
|
258
303
|
const require = createRequire(import.meta.url);
|
|
259
304
|
await autoRegisterArrowDecoder({
|
|
260
305
|
loader: () => require('apache-arrow'),
|
|
261
306
|
});
|
|
262
307
|
|
|
263
|
-
//
|
|
264
|
-
|
|
265
|
-
await this.spawnProcess();
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// Validate protocol version
|
|
269
|
-
await this.refreshBridgeInfo();
|
|
270
|
-
|
|
271
|
-
// Warm up processes if configured
|
|
272
|
-
if (this.options.warmupCommands.length > 0) {
|
|
273
|
-
await this.warmupProcesses();
|
|
274
|
-
}
|
|
308
|
+
// Initialize parent (which initializes transport and runs warmup per-worker)
|
|
309
|
+
await super.doInit();
|
|
275
310
|
}
|
|
276
311
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
await this.refreshBridgeInfo();
|
|
281
|
-
}
|
|
282
|
-
if (!this.bridgeInfo) {
|
|
283
|
-
throw new BridgeProtocolError('Bridge info unavailable');
|
|
284
|
-
}
|
|
285
|
-
return this.bridgeInfo;
|
|
286
|
-
}
|
|
312
|
+
// ===========================================================================
|
|
313
|
+
// CACHING OVERRIDE
|
|
314
|
+
// ===========================================================================
|
|
287
315
|
|
|
288
|
-
|
|
316
|
+
/**
|
|
317
|
+
* Override call() to add optional caching.
|
|
318
|
+
*/
|
|
319
|
+
override async call<T = unknown>(
|
|
289
320
|
module: string,
|
|
290
321
|
functionName: string,
|
|
291
322
|
args: unknown[],
|
|
292
323
|
kwargs?: Record<string, unknown>
|
|
293
324
|
): Promise<T> {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
if (cached !== null) {
|
|
303
|
-
this.stats.cacheHits++;
|
|
304
|
-
this.updateStats(performance.now() - startTime);
|
|
305
|
-
return cached;
|
|
325
|
+
// Check cache if enabled
|
|
326
|
+
if (this.resolvedOptions.enableCache) {
|
|
327
|
+
const cacheKey = this.safeCacheKey('runtime_call', module, functionName, args, kwargs);
|
|
328
|
+
if (cacheKey) {
|
|
329
|
+
const cached = await globalCache.get<T>(cacheKey);
|
|
330
|
+
if (cached !== null) {
|
|
331
|
+
return cached;
|
|
332
|
+
}
|
|
306
333
|
}
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
try {
|
|
310
|
-
const result = await this.executeRequest<T>({
|
|
311
|
-
method: 'call',
|
|
312
|
-
params: { module, functionName, args, kwargs },
|
|
313
|
-
});
|
|
314
334
|
|
|
335
|
+
// Execute and cache if pure function
|
|
336
|
+
const startTime = performance.now();
|
|
337
|
+
const result = await super.call<T>(module, functionName, args, kwargs);
|
|
315
338
|
const duration = performance.now() - startTime;
|
|
316
339
|
|
|
317
|
-
// Cache result for pure functions (simple heuristic)
|
|
318
340
|
if (cacheKey && this.isPureFunctionCandidate(functionName, args)) {
|
|
319
341
|
await globalCache.set(cacheKey, result, {
|
|
320
342
|
computeTime: duration,
|
|
@@ -322,338 +344,74 @@ export class NodeBridge extends RuntimeBridge {
|
|
|
322
344
|
});
|
|
323
345
|
}
|
|
324
346
|
|
|
325
|
-
this.updateStats(duration);
|
|
326
|
-
return result;
|
|
327
|
-
} catch (error) {
|
|
328
|
-
this.updateStats(performance.now() - startTime, true);
|
|
329
|
-
throw error;
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
async instantiate<T = unknown>(
|
|
334
|
-
module: string,
|
|
335
|
-
className: string,
|
|
336
|
-
args: unknown[],
|
|
337
|
-
kwargs?: Record<string, unknown>
|
|
338
|
-
): Promise<T> {
|
|
339
|
-
await this.init();
|
|
340
|
-
const startTime = performance.now();
|
|
341
|
-
|
|
342
|
-
try {
|
|
343
|
-
const result = await this.executeRequest<T>({
|
|
344
|
-
method: 'instantiate',
|
|
345
|
-
params: { module, className, args, kwargs },
|
|
346
|
-
});
|
|
347
|
-
|
|
348
|
-
this.updateStats(performance.now() - startTime);
|
|
349
347
|
return result;
|
|
350
|
-
} catch (error) {
|
|
351
|
-
this.updateStats(performance.now() - startTime, true);
|
|
352
|
-
throw error;
|
|
353
348
|
}
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
async callMethod<T = unknown>(
|
|
357
|
-
handle: string,
|
|
358
|
-
methodName: string,
|
|
359
|
-
args: unknown[],
|
|
360
|
-
kwargs?: Record<string, unknown>
|
|
361
|
-
): Promise<T> {
|
|
362
|
-
await this.init();
|
|
363
|
-
const startTime = performance.now();
|
|
364
|
-
|
|
365
|
-
try {
|
|
366
|
-
const result = await this.executeRequest<T>({
|
|
367
|
-
method: 'call_method',
|
|
368
|
-
params: { handle, methodName, args, kwargs },
|
|
369
|
-
});
|
|
370
349
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
} catch (error) {
|
|
374
|
-
this.updateStats(performance.now() - startTime, true);
|
|
375
|
-
throw error;
|
|
376
|
-
}
|
|
350
|
+
// No caching - direct call
|
|
351
|
+
return super.call<T>(module, functionName, args, kwargs);
|
|
377
352
|
}
|
|
378
353
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
method: 'dispose_instance',
|
|
383
|
-
params: { handle },
|
|
384
|
-
});
|
|
385
|
-
}
|
|
354
|
+
// ===========================================================================
|
|
355
|
+
// POOL STATISTICS
|
|
356
|
+
// ===========================================================================
|
|
386
357
|
|
|
387
358
|
/**
|
|
388
|
-
*
|
|
359
|
+
* Get current pool statistics.
|
|
389
360
|
*/
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
try {
|
|
396
|
-
worker = await this.spawnProcess();
|
|
397
|
-
this.stats.poolMisses++;
|
|
398
|
-
} catch (error) {
|
|
399
|
-
throw new Error(`Failed to spawn worker process: ${error}`);
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
// Wait for worker if all are busy
|
|
404
|
-
worker ??= await this.waitForAvailableWorker();
|
|
405
|
-
|
|
406
|
-
this.stats.poolHits++;
|
|
407
|
-
return this.sendToWorker<T>(worker, payload);
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
/**
|
|
411
|
-
* Select optimal worker based on load and performance
|
|
412
|
-
*/
|
|
413
|
-
private selectOptimalWorker(): WorkerProcess | null {
|
|
414
|
-
// For single-process mode, allow concurrent requests to the same worker.
|
|
415
|
-
// BridgeCore handles request ID multiplexing, so multiple in-flight requests are safe.
|
|
416
|
-
// This preserves original NodeBridge behavior where concurrent calls were allowed.
|
|
417
|
-
if (this.options.maxProcesses === 1 && this.processPool.length === 1) {
|
|
418
|
-
const worker = this.processPool[0];
|
|
419
|
-
if (worker && !worker.quarantined && worker.process.exitCode === null) {
|
|
420
|
-
return worker;
|
|
421
|
-
}
|
|
422
|
-
return null;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
// For multi-worker pools, use busy status for load balancing
|
|
426
|
-
const availableWorkers = this.processPool.filter(
|
|
427
|
-
w => !w.busy && !w.quarantined && w.process.exitCode === null
|
|
428
|
-
);
|
|
429
|
-
|
|
430
|
-
if (availableWorkers.length === 0) {
|
|
431
|
-
return null;
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
// Simple round-robin for now, could be enhanced with load-based selection
|
|
435
|
-
const worker = availableWorkers[this.roundRobinIndex % availableWorkers.length];
|
|
436
|
-
this.roundRobinIndex = (this.roundRobinIndex + 1) % availableWorkers.length;
|
|
437
|
-
|
|
438
|
-
return worker ?? null;
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
/**
|
|
442
|
-
* Wait for any worker to become available
|
|
443
|
-
*/
|
|
444
|
-
private async waitForAvailableWorker(timeoutMs: number = 5000): Promise<WorkerProcess> {
|
|
445
|
-
return new Promise((resolvePromise, reject) => {
|
|
446
|
-
const timeout = setTimeout(() => {
|
|
447
|
-
reject(new Error('Timeout waiting for available worker'));
|
|
448
|
-
}, timeoutMs);
|
|
449
|
-
|
|
450
|
-
const checkWorker = (): void => {
|
|
451
|
-
const worker = this.selectOptimalWorker();
|
|
452
|
-
if (worker) {
|
|
453
|
-
clearTimeout(timeout);
|
|
454
|
-
resolvePromise(worker);
|
|
455
|
-
} else {
|
|
456
|
-
// Check again in 10ms
|
|
457
|
-
setTimeout(checkWorker, 10);
|
|
458
|
-
}
|
|
459
|
-
};
|
|
460
|
-
|
|
461
|
-
checkWorker();
|
|
462
|
-
});
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
/**
|
|
466
|
-
* Send request to specific worker
|
|
467
|
-
*/
|
|
468
|
-
private async sendToWorker<T>(
|
|
469
|
-
worker: WorkerProcess,
|
|
470
|
-
payload: Omit<RpcRequest, 'id' | 'protocol'>
|
|
471
|
-
): Promise<T> {
|
|
472
|
-
worker.busy = true;
|
|
473
|
-
worker.requestCount++;
|
|
474
|
-
worker.lastUsed = Date.now();
|
|
475
|
-
|
|
476
|
-
const startTime = performance.now();
|
|
477
|
-
|
|
478
|
-
try {
|
|
479
|
-
const result = await worker.core.send<T>(payload);
|
|
480
|
-
const duration = performance.now() - startTime;
|
|
481
|
-
worker.stats.totalTime += duration;
|
|
482
|
-
worker.stats.totalRequests++;
|
|
483
|
-
worker.stats.averageTime = worker.stats.totalTime / worker.stats.totalRequests;
|
|
484
|
-
return result;
|
|
485
|
-
} catch (error) {
|
|
486
|
-
worker.stats.errorCount++;
|
|
487
|
-
throw error;
|
|
488
|
-
} finally {
|
|
489
|
-
worker.busy = false;
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
private quarantineWorker(worker: WorkerProcess, error: Error): void {
|
|
494
|
-
if (worker.quarantined) {
|
|
495
|
-
return;
|
|
496
|
-
}
|
|
497
|
-
worker.quarantined = true;
|
|
498
|
-
log.warn('Quarantining worker', { workerId: worker.id, error: String(error) });
|
|
499
|
-
this.terminateWorker(worker, { force: true })
|
|
500
|
-
.then(() => {
|
|
501
|
-
if (!this.disposed && this.processPool.length < this.options.minProcesses) {
|
|
502
|
-
this.spawnProcess().catch(spawnError => {
|
|
503
|
-
log.error('Failed to spawn replacement worker after quarantine', {
|
|
504
|
-
error: String(spawnError),
|
|
505
|
-
});
|
|
506
|
-
});
|
|
507
|
-
}
|
|
508
|
-
})
|
|
509
|
-
.catch(terminateError => {
|
|
510
|
-
log.warn('Failed to terminate quarantined worker', {
|
|
511
|
-
workerId: worker.id,
|
|
512
|
-
error: String(terminateError),
|
|
513
|
-
});
|
|
514
|
-
});
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
/**
|
|
518
|
-
* Spawn new worker process with optimizations
|
|
519
|
-
*/
|
|
520
|
-
private async spawnProcess(): Promise<WorkerProcess> {
|
|
521
|
-
const { spawn } = await import('child_process');
|
|
522
|
-
|
|
523
|
-
const workerId = `worker_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
524
|
-
|
|
525
|
-
const env = this.buildEnv();
|
|
526
|
-
env.PYTHONUNBUFFERED = '1'; // Ensure immediate output
|
|
527
|
-
env.PYTHONDONTWRITEBYTECODE = '1'; // Skip .pyc files for faster startup
|
|
528
|
-
const maxLineLength = this.options.maxLineLength ?? getMaxLineLengthFromEnv(env);
|
|
529
|
-
|
|
530
|
-
const childProcess = spawn(this.options.pythonPath, [this.options.scriptPath], {
|
|
531
|
-
cwd: this.options.cwd,
|
|
532
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
533
|
-
env,
|
|
534
|
-
});
|
|
535
|
-
|
|
536
|
-
const worker: WorkerProcess = {
|
|
537
|
-
process: childProcess,
|
|
538
|
-
id: workerId,
|
|
539
|
-
requestCount: 0,
|
|
540
|
-
lastUsed: Date.now(),
|
|
541
|
-
busy: false,
|
|
542
|
-
quarantined: false,
|
|
543
|
-
core: null as unknown as BridgeCore,
|
|
544
|
-
stats: {
|
|
545
|
-
totalRequests: 0,
|
|
546
|
-
totalTime: 0,
|
|
547
|
-
averageTime: 0,
|
|
548
|
-
errorCount: 0,
|
|
549
|
-
},
|
|
361
|
+
getPoolStats(): { workerCount: number; queueLength: number; totalInFlight: number } {
|
|
362
|
+
return {
|
|
363
|
+
workerCount: this.pooledTransport.workerCount,
|
|
364
|
+
queueLength: this.pooledTransport.queueLength,
|
|
365
|
+
totalInFlight: this.pooledTransport.totalInFlight,
|
|
550
366
|
};
|
|
551
|
-
|
|
552
|
-
worker.core = new BridgeCore(
|
|
553
|
-
{
|
|
554
|
-
write: (data: string): void => {
|
|
555
|
-
if (!worker.process.stdin?.writable) {
|
|
556
|
-
throw new BridgeProtocolError('Worker process stdin not writable');
|
|
557
|
-
}
|
|
558
|
-
worker.process.stdin.write(data);
|
|
559
|
-
},
|
|
560
|
-
},
|
|
561
|
-
{
|
|
562
|
-
timeoutMs: this.options.timeoutMs,
|
|
563
|
-
maxLineLength,
|
|
564
|
-
decodeValue: decodeValueAsync,
|
|
565
|
-
onFatalError: (error: Error): void => this.quarantineWorker(worker, error),
|
|
566
|
-
onTimeout: (error: Error): void => this.quarantineWorker(worker, error),
|
|
567
|
-
}
|
|
568
|
-
);
|
|
569
|
-
|
|
570
|
-
// Setup process event handlers
|
|
571
|
-
this.setupProcessHandlers(worker);
|
|
572
|
-
|
|
573
|
-
this.processPool.push(worker);
|
|
574
|
-
this.stats.processSpawns++;
|
|
575
|
-
|
|
576
|
-
return worker;
|
|
577
367
|
}
|
|
578
368
|
|
|
579
369
|
/**
|
|
580
|
-
*
|
|
370
|
+
* Get bridge statistics.
|
|
371
|
+
*
|
|
372
|
+
* @deprecated Use getPoolStats() instead. This method is provided for
|
|
373
|
+
* backwards compatibility and returns a subset of the previous stats.
|
|
581
374
|
*/
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
375
|
+
getStats(): {
|
|
376
|
+
totalRequests: number;
|
|
377
|
+
totalTime: number;
|
|
378
|
+
cacheHits: number;
|
|
379
|
+
poolHits: number;
|
|
380
|
+
poolMisses: number;
|
|
381
|
+
processSpawns: number;
|
|
382
|
+
processDeaths: number;
|
|
383
|
+
memoryPeak: number;
|
|
384
|
+
averageTime: number;
|
|
385
|
+
cacheHitRate: number;
|
|
386
|
+
poolSize: number;
|
|
387
|
+
busyWorkers: number;
|
|
388
|
+
} {
|
|
389
|
+
const poolStats = this.getPoolStats();
|
|
390
|
+
return {
|
|
391
|
+
// Legacy stats (no longer tracked, return 0)
|
|
392
|
+
totalRequests: 0,
|
|
393
|
+
totalTime: 0,
|
|
394
|
+
cacheHits: 0,
|
|
395
|
+
poolHits: 0,
|
|
396
|
+
poolMisses: 0,
|
|
397
|
+
processSpawns: 0,
|
|
398
|
+
processDeaths: 0,
|
|
399
|
+
memoryPeak: 0,
|
|
400
|
+
averageTime: 0,
|
|
401
|
+
cacheHitRate: 0,
|
|
402
|
+
// Current pool stats
|
|
403
|
+
poolSize: poolStats.workerCount,
|
|
404
|
+
busyWorkers: poolStats.totalInFlight,
|
|
405
|
+
};
|
|
610
406
|
}
|
|
611
407
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
private handleWorkerExit(worker: WorkerProcess, _code: number | null): void {
|
|
616
|
-
if (!this.processPool.includes(worker)) {
|
|
617
|
-
return;
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
worker.core.clear();
|
|
621
|
-
|
|
622
|
-
// Remove from pool
|
|
623
|
-
const index = this.processPool.indexOf(worker);
|
|
624
|
-
if (index >= 0) {
|
|
625
|
-
this.processPool.splice(index, 1);
|
|
626
|
-
this.stats.processDeaths++;
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
// Spawn replacement if needed and not disposing
|
|
630
|
-
if (!this.disposed && this.processPool.length < this.options.minProcesses) {
|
|
631
|
-
this.spawnProcess().catch(error => {
|
|
632
|
-
log.error('Failed to spawn replacement worker', { error: String(error) });
|
|
633
|
-
});
|
|
634
|
-
}
|
|
635
|
-
}
|
|
408
|
+
// ===========================================================================
|
|
409
|
+
// PRIVATE HELPERS
|
|
410
|
+
// ===========================================================================
|
|
636
411
|
|
|
637
412
|
/**
|
|
638
|
-
*
|
|
413
|
+
* Generate a cache key, returning null if generation fails.
|
|
639
414
|
*/
|
|
640
|
-
private async warmupProcesses(): Promise<void> {
|
|
641
|
-
const warmupPromises = this.processPool.map(async worker => {
|
|
642
|
-
for (const cmd of this.options.warmupCommands) {
|
|
643
|
-
try {
|
|
644
|
-
await this.sendToWorker(worker, {
|
|
645
|
-
method: cmd.method as 'call' | 'instantiate' | 'call_method' | 'dispose_instance',
|
|
646
|
-
params: cmd.params,
|
|
647
|
-
});
|
|
648
|
-
} catch (error) {
|
|
649
|
-
log.warn('Warmup command failed', { workerId: worker.id, error: String(error) });
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
});
|
|
653
|
-
|
|
654
|
-
await Promise.all(warmupPromises);
|
|
655
|
-
}
|
|
656
|
-
|
|
657
415
|
private safeCacheKey(prefix: string, ...inputs: unknown[]): string | null {
|
|
658
416
|
try {
|
|
659
417
|
return globalCache.generateKey(prefix, ...inputs);
|
|
@@ -663,10 +421,9 @@ export class NodeBridge extends RuntimeBridge {
|
|
|
663
421
|
}
|
|
664
422
|
|
|
665
423
|
/**
|
|
666
|
-
* Heuristic to determine if function result should be cached
|
|
424
|
+
* Heuristic to determine if function result should be cached.
|
|
667
425
|
*/
|
|
668
426
|
private isPureFunctionCandidate(functionName: string, args: unknown[]): boolean {
|
|
669
|
-
// Simple heuristics - could be made more sophisticated
|
|
670
427
|
const pureFunctionPatterns = [
|
|
671
428
|
/^(get|fetch|read|load|find|search|query|select)_/i,
|
|
672
429
|
/^(compute|calculate|process|transform|convert)_/i,
|
|
@@ -679,222 +436,132 @@ export class NodeBridge extends RuntimeBridge {
|
|
|
679
436
|
/random|uuid|timestamp|now|current/i,
|
|
680
437
|
];
|
|
681
438
|
|
|
682
|
-
// Don't cache if function name suggests mutation
|
|
683
439
|
if (impureFunctionPatterns.some(pattern => pattern.test(functionName))) {
|
|
684
440
|
return false;
|
|
685
441
|
}
|
|
686
442
|
|
|
687
|
-
// Cache if function name suggests pure computation
|
|
688
443
|
if (pureFunctionPatterns.some(pattern => pattern.test(functionName))) {
|
|
689
444
|
return true;
|
|
690
445
|
}
|
|
691
446
|
|
|
692
|
-
// Don't cache if args contain mutable objects (very basic check)
|
|
693
447
|
const hasComplexArgs = args.some(
|
|
694
448
|
arg => arg !== null && typeof arg === 'object' && !(arg instanceof Date)
|
|
695
449
|
);
|
|
696
450
|
|
|
697
|
-
return !hasComplexArgs && args.length <= 3;
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
/**
|
|
701
|
-
* Update performance statistics
|
|
702
|
-
*/
|
|
703
|
-
private updateStats(duration: number, _error: boolean = false): void {
|
|
704
|
-
this.stats.totalRequests++;
|
|
705
|
-
this.stats.totalTime += duration;
|
|
706
|
-
|
|
707
|
-
const currentMemory = process.memoryUsage().heapUsed;
|
|
708
|
-
if (currentMemory > this.stats.memoryPeak) {
|
|
709
|
-
this.stats.memoryPeak = currentMemory;
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
/**
|
|
714
|
-
* Get performance statistics
|
|
715
|
-
*/
|
|
716
|
-
getStats(): BridgeStatsSnapshot {
|
|
717
|
-
const avgTime =
|
|
718
|
-
this.stats.totalRequests > 0 ? this.stats.totalTime / this.stats.totalRequests : 0;
|
|
719
|
-
const hitRate =
|
|
720
|
-
this.stats.totalRequests > 0 ? this.stats.cacheHits / this.stats.totalRequests : 0;
|
|
721
|
-
|
|
722
|
-
return {
|
|
723
|
-
...this.stats,
|
|
724
|
-
averageTime: avgTime,
|
|
725
|
-
cacheHitRate: hitRate,
|
|
726
|
-
poolSize: this.processPool.length,
|
|
727
|
-
busyWorkers: this.processPool.filter(w => w.busy).length,
|
|
728
|
-
memoryUsage: process.memoryUsage(),
|
|
729
|
-
workerStats: this.processPool.map(w => ({
|
|
730
|
-
id: w.id,
|
|
731
|
-
requestCount: w.requestCount,
|
|
732
|
-
averageTime: w.stats.averageTime,
|
|
733
|
-
errorCount: w.stats.errorCount,
|
|
734
|
-
busy: w.busy,
|
|
735
|
-
pendingRequests: w.core.getPendingCount(),
|
|
736
|
-
})),
|
|
737
|
-
};
|
|
451
|
+
return !hasComplexArgs && args.length <= 3;
|
|
738
452
|
}
|
|
453
|
+
}
|
|
739
454
|
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
private async cleanup(): Promise<void> {
|
|
744
|
-
const now = Date.now();
|
|
745
|
-
const idleWorkers = this.processPool.filter(
|
|
746
|
-
w =>
|
|
747
|
-
!w.busy &&
|
|
748
|
-
now - w.lastUsed > this.options.maxIdleTime &&
|
|
749
|
-
this.processPool.length > this.options.minProcesses
|
|
750
|
-
);
|
|
455
|
+
// =============================================================================
|
|
456
|
+
// WARMUP CALLBACK
|
|
457
|
+
// =============================================================================
|
|
751
458
|
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
459
|
+
/**
|
|
460
|
+
* Simple request ID generator for warmup commands.
|
|
461
|
+
*/
|
|
462
|
+
let warmupRequestId = 0;
|
|
463
|
+
function generateWarmupId(): string {
|
|
464
|
+
return `warmup_${++warmupRequestId}_${Date.now()}`;
|
|
465
|
+
}
|
|
755
466
|
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
467
|
+
/**
|
|
468
|
+
* Create a callback that runs warmup commands on each worker.
|
|
469
|
+
*
|
|
470
|
+
* The callback sends warmup commands directly to the worker's transport,
|
|
471
|
+
* bypassing the pool to ensure each worker gets warmed up individually.
|
|
472
|
+
*/
|
|
473
|
+
function createWarmupCallback(
|
|
474
|
+
warmupCommands: Array<
|
|
475
|
+
| { module: string; functionName: string; args?: unknown[] }
|
|
476
|
+
| { method: string; params: unknown }
|
|
477
|
+
>,
|
|
478
|
+
timeoutMs: number
|
|
479
|
+
): (worker: PooledWorker) => Promise<void> {
|
|
480
|
+
return async (worker: PooledWorker) => {
|
|
481
|
+
for (const cmd of warmupCommands) {
|
|
482
|
+
try {
|
|
483
|
+
// Handle both new and legacy warmup command formats
|
|
484
|
+
if ('module' in cmd && 'functionName' in cmd) {
|
|
485
|
+
// Build the protocol message
|
|
486
|
+
const message = JSON.stringify({
|
|
487
|
+
id: generateWarmupId(),
|
|
488
|
+
type: 'call',
|
|
489
|
+
module: cmd.module,
|
|
490
|
+
functionName: cmd.functionName,
|
|
491
|
+
args: cmd.args ?? [],
|
|
492
|
+
kwargs: {},
|
|
493
|
+
});
|
|
760
494
|
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
495
|
+
// Send directly to this worker's transport
|
|
496
|
+
await worker.transport.send(message, timeoutMs);
|
|
497
|
+
}
|
|
498
|
+
// Legacy format { method, params } is ignored as it's not supported
|
|
499
|
+
} catch {
|
|
500
|
+
// Ignore warmup errors - they're not critical
|
|
765
501
|
}
|
|
766
502
|
}
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
/**
|
|
770
|
-
* Gracefully terminate a worker
|
|
771
|
-
*/
|
|
772
|
-
private async terminateWorker(
|
|
773
|
-
worker: WorkerProcess,
|
|
774
|
-
options: { force?: boolean } = {}
|
|
775
|
-
): Promise<void> {
|
|
776
|
-
if (worker.busy && !options.force) {
|
|
777
|
-
return;
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
const index = this.processPool.indexOf(worker);
|
|
781
|
-
if (index >= 0) {
|
|
782
|
-
this.processPool.splice(index, 1);
|
|
783
|
-
this.stats.processDeaths++;
|
|
784
|
-
}
|
|
503
|
+
};
|
|
504
|
+
}
|
|
785
505
|
|
|
786
|
-
|
|
787
|
-
|
|
506
|
+
// =============================================================================
|
|
507
|
+
// ENVIRONMENT BUILDING
|
|
508
|
+
// =============================================================================
|
|
788
509
|
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
510
|
+
/**
|
|
511
|
+
* Build environment variables for ProcessIO.
|
|
512
|
+
*/
|
|
513
|
+
function buildProcessEnv(options: ResolvedOptions): Record<string, string> {
|
|
514
|
+
const allowedPrefixes = ['TYWRAP_'];
|
|
515
|
+
const allowedKeys = new Set(['path', 'pythonpath', 'virtual_env', 'pythonhome']);
|
|
516
|
+
const env: Record<string, string> = {};
|
|
517
|
+
|
|
518
|
+
// Copy allowed env vars from process.env
|
|
519
|
+
if (options.inheritProcessEnv) {
|
|
520
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
521
|
+
if (value !== undefined) {
|
|
522
|
+
env[key] = value;
|
|
800
523
|
}
|
|
801
|
-
} catch (error) {
|
|
802
|
-
log.warn('Error terminating worker', { workerId: worker.id, error: String(error) });
|
|
803
524
|
}
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
try {
|
|
812
|
-
await this.cleanup();
|
|
813
|
-
} catch (error) {
|
|
814
|
-
log.error('Cleanup error', { error: String(error) });
|
|
525
|
+
} else {
|
|
526
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
527
|
+
if (
|
|
528
|
+
value !== undefined &&
|
|
529
|
+
(allowedKeys.has(key.toLowerCase()) || allowedPrefixes.some(p => key.startsWith(p)))
|
|
530
|
+
) {
|
|
531
|
+
env[key] = value;
|
|
815
532
|
}
|
|
816
|
-
}, 60000); // Cleanup every minute
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
/**
|
|
820
|
-
* Dispose all resources
|
|
821
|
-
*/
|
|
822
|
-
async dispose(): Promise<void> {
|
|
823
|
-
if (this.disposed) {
|
|
824
|
-
return;
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
this.disposed = true;
|
|
828
|
-
|
|
829
|
-
if (this.cleanupTimer) {
|
|
830
|
-
clearInterval(this.cleanupTimer);
|
|
831
|
-
this.cleanupTimer = undefined;
|
|
832
533
|
}
|
|
833
|
-
|
|
834
|
-
// Terminate all workers
|
|
835
|
-
const terminationPromises = this.processPool.map(worker =>
|
|
836
|
-
this.terminateWorker(worker, { force: true })
|
|
837
|
-
);
|
|
838
|
-
await Promise.all(terminationPromises);
|
|
839
|
-
|
|
840
|
-
this.processPool.length = 0;
|
|
841
|
-
this.emitter.removeAllListeners();
|
|
842
534
|
}
|
|
843
535
|
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
if (this.options.inheritProcessEnv) {
|
|
849
|
-
for (const [key, value] of Object.entries(process.env)) {
|
|
850
|
-
// eslint-disable-next-line security/detect-object-injection -- env keys are dynamic by design
|
|
851
|
-
baseEnv[key] = value;
|
|
852
|
-
}
|
|
853
|
-
} else {
|
|
854
|
-
for (const [key, value] of Object.entries(process.env)) {
|
|
855
|
-
if (
|
|
856
|
-
allowedKeys.has(key.toLowerCase()) ||
|
|
857
|
-
allowedPrefixes.some(prefix => key.startsWith(prefix))
|
|
858
|
-
) {
|
|
859
|
-
// eslint-disable-next-line security/detect-object-injection -- env keys are dynamic by design
|
|
860
|
-
baseEnv[key] = value;
|
|
861
|
-
}
|
|
862
|
-
}
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
let env = normalizeEnv(baseEnv, this.options.env);
|
|
866
|
-
|
|
867
|
-
if (this.options.virtualEnv) {
|
|
868
|
-
const venv = resolveVirtualEnv(this.options.virtualEnv, this.options.cwd);
|
|
869
|
-
env.VIRTUAL_ENV = venv.venvPath;
|
|
870
|
-
const pathKey = getPathKey(env);
|
|
871
|
-
// eslint-disable-next-line security/detect-object-injection -- env keys are dynamic by design
|
|
872
|
-
const currentPath = env[pathKey] ?? '';
|
|
873
|
-
// eslint-disable-next-line security/detect-object-injection -- env keys are dynamic by design
|
|
874
|
-
env[pathKey] = `${venv.binDir}${delimiter}${currentPath}`;
|
|
536
|
+
// Apply user overrides
|
|
537
|
+
for (const [key, value] of Object.entries(options.env)) {
|
|
538
|
+
if (value !== undefined) {
|
|
539
|
+
env[key] = value;
|
|
875
540
|
}
|
|
541
|
+
}
|
|
876
542
|
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
env =
|
|
881
|
-
|
|
543
|
+
// Configure virtual environment
|
|
544
|
+
if (options.virtualEnv) {
|
|
545
|
+
const venv = resolveVirtualEnv(options.virtualEnv, options.cwd);
|
|
546
|
+
env.VIRTUAL_ENV = venv.venvPath;
|
|
547
|
+
const pathKey = getPathKey(env);
|
|
548
|
+
const currentPath = env[pathKey] ?? '';
|
|
549
|
+
env[pathKey] = `${venv.binDir}${delimiter}${currentPath}`;
|
|
882
550
|
}
|
|
883
551
|
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
552
|
+
// Add cwd to PYTHONPATH so Python can find modules in the working directory
|
|
553
|
+
if (options.cwd) {
|
|
554
|
+
const currentPythonPath = env.PYTHONPATH ?? '';
|
|
555
|
+
env.PYTHONPATH = currentPythonPath
|
|
556
|
+
? `${options.cwd}${delimiter}${currentPythonPath}`
|
|
557
|
+
: options.cwd;
|
|
888
558
|
}
|
|
889
|
-
}
|
|
890
559
|
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
560
|
+
// Ensure Python uses UTF-8
|
|
561
|
+
env.PYTHONUTF8 = '1';
|
|
562
|
+
env.PYTHONIOENCODING = 'UTF-8';
|
|
563
|
+
env.PYTHONUNBUFFERED = '1';
|
|
564
|
+
env.PYTHONDONTWRITEBYTECODE = '1';
|
|
896
565
|
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
*/
|
|
900
|
-
export type { NodeBridgeOptions as ProcessPoolOptions };
|
|
566
|
+
return env;
|
|
567
|
+
}
|