tywrap 0.1.2 → 0.2.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/README.md +11 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/runtime/node.d.ts +158 -11
- package/dist/runtime/node.d.ts.map +1 -1
- package/dist/runtime/node.js +512 -92
- package/dist/runtime/node.js.map +1 -1
- package/dist/runtime/optimized-node.d.ts +19 -125
- package/dist/runtime/optimized-node.d.ts.map +1 -1
- package/dist/runtime/optimized-node.js +19 -550
- package/dist/runtime/optimized-node.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +1 -1
- package/src/runtime/node.ts +678 -102
- package/src/runtime/optimized-node.ts +19 -761
package/src/runtime/node.ts
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Node.js
|
|
2
|
+
* Node.js Runtime Bridge with Optional Connection Pooling
|
|
3
|
+
*
|
|
4
|
+
* NodeBridge is the unified Node.js runtime bridge that supports both single-process
|
|
5
|
+
* (correctness-first) and multi-process (pooled) configurations. By default, it runs
|
|
6
|
+
* in single-process mode for maximum compatibility with the original NodeBridge behavior.
|
|
7
|
+
*
|
|
8
|
+
* For high-throughput workloads, configure pooling via minProcesses/maxProcesses options.
|
|
3
9
|
*/
|
|
4
10
|
|
|
5
11
|
import { existsSync } from 'node:fs';
|
|
6
12
|
import { delimiter, isAbsolute, join, resolve } from 'node:path';
|
|
7
13
|
import { fileURLToPath } from 'node:url';
|
|
8
14
|
import { createRequire } from 'node:module';
|
|
15
|
+
import type { ChildProcess } from 'child_process';
|
|
16
|
+
import { EventEmitter } from 'events';
|
|
9
17
|
|
|
18
|
+
import { globalCache } from '../utils/cache.js';
|
|
10
19
|
import { autoRegisterArrowDecoder, decodeValueAsync } from '../utils/codec.js';
|
|
11
20
|
import { getDefaultPythonPath } from '../utils/python.js';
|
|
12
21
|
import { getVenvBinDir, getVenvPythonExe } from '../utils/runtime.js';
|
|
@@ -24,27 +33,57 @@ import {
|
|
|
24
33
|
normalizeEnv,
|
|
25
34
|
validateBridgeInfo,
|
|
26
35
|
} from './bridge-core.js';
|
|
36
|
+
import { getComponentLogger } from '../utils/logger.js';
|
|
27
37
|
|
|
38
|
+
const log = getComponentLogger('NodeBridge');
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 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
|
+
*/
|
|
28
46
|
export interface NodeBridgeOptions {
|
|
47
|
+
/** Minimum number of Python processes to keep alive. Default: 1 */
|
|
48
|
+
minProcesses?: number;
|
|
49
|
+
/** Maximum number of Python processes to spawn. Default: 1 (single-process mode) */
|
|
50
|
+
maxProcesses?: number;
|
|
51
|
+
/** Time in ms to keep idle processes alive before termination. Default: 300000 (5 min) */
|
|
52
|
+
maxIdleTime?: number;
|
|
53
|
+
/** Restart process after this many requests. Default: 1000 */
|
|
54
|
+
maxRequestsPerProcess?: number;
|
|
55
|
+
/** Path to Python executable. Auto-detected if not specified. */
|
|
29
56
|
pythonPath?: string;
|
|
30
|
-
|
|
57
|
+
/** Path to python_bridge.py script. Auto-detected if not specified. */
|
|
58
|
+
scriptPath?: string;
|
|
59
|
+
/** Path to Python virtual environment. */
|
|
31
60
|
virtualEnv?: string;
|
|
61
|
+
/** Working directory for Python process. Default: process.cwd() */
|
|
32
62
|
cwd?: string;
|
|
63
|
+
/** Timeout in ms for Python calls. Default: 30000 */
|
|
33
64
|
timeoutMs?: number;
|
|
65
|
+
/** Maximum line length for JSONL protocol. */
|
|
34
66
|
maxLineLength?: number;
|
|
67
|
+
/** Inherit all environment variables from parent process. Default: false */
|
|
35
68
|
inheritProcessEnv?: boolean;
|
|
36
69
|
/**
|
|
37
70
|
* When true, sets TYWRAP_CODEC_FALLBACK=json for the Python process to prefer JSON encoding
|
|
38
71
|
* for rich types (ndarray/dataframe/series). Default: false for fast-fail on Arrow path issues.
|
|
39
72
|
*/
|
|
40
73
|
enableJsonFallback?: boolean;
|
|
41
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
74
|
+
/** Enable result caching for pure functions. Default: false */
|
|
75
|
+
enableCache?: boolean;
|
|
76
|
+
/** Optional extra environment variables to pass to the Python subprocess. */
|
|
44
77
|
env?: Record<string, string | undefined>;
|
|
78
|
+
/** Commands to run on each process at startup for warming up. */
|
|
79
|
+
warmupCommands?: Array<{ method: string; params: unknown }>;
|
|
45
80
|
}
|
|
46
81
|
|
|
47
|
-
interface
|
|
82
|
+
interface ResolvedOptions {
|
|
83
|
+
minProcesses: number;
|
|
84
|
+
maxProcesses: number;
|
|
85
|
+
maxIdleTime: number;
|
|
86
|
+
maxRequestsPerProcess: number;
|
|
48
87
|
pythonPath: string;
|
|
49
88
|
scriptPath: string;
|
|
50
89
|
virtualEnv?: string;
|
|
@@ -53,7 +92,52 @@ interface ResolvedNodeBridgeOptions {
|
|
|
53
92
|
maxLineLength?: number;
|
|
54
93
|
inheritProcessEnv: boolean;
|
|
55
94
|
enableJsonFallback: boolean;
|
|
95
|
+
enableCache: boolean;
|
|
56
96
|
env: Record<string, string | undefined>;
|
|
97
|
+
warmupCommands: Array<{ method: string; params: unknown }>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface WorkerProcess {
|
|
101
|
+
process: ChildProcess;
|
|
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
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface BridgeStats {
|
|
117
|
+
totalRequests: number;
|
|
118
|
+
totalTime: number;
|
|
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
|
+
}>;
|
|
57
141
|
}
|
|
58
142
|
|
|
59
143
|
function resolveDefaultScriptPath(): string {
|
|
@@ -78,13 +162,48 @@ function resolveVirtualEnv(
|
|
|
78
162
|
return { venvPath, binDir, pythonPath };
|
|
79
163
|
}
|
|
80
164
|
|
|
165
|
+
/**
|
|
166
|
+
* Node.js runtime bridge for executing Python code.
|
|
167
|
+
*
|
|
168
|
+
* By default, runs in single-process mode for correctness-first behavior.
|
|
169
|
+
* Configure minProcesses/maxProcesses for process pooling in high-throughput scenarios.
|
|
170
|
+
*
|
|
171
|
+
* @example
|
|
172
|
+
* ```typescript
|
|
173
|
+
* // Single-process mode (default)
|
|
174
|
+
* const bridge = new NodeBridge();
|
|
175
|
+
*
|
|
176
|
+
* // Multi-process pooling for high throughput
|
|
177
|
+
* const pooledBridge = new NodeBridge({
|
|
178
|
+
* minProcesses: 2,
|
|
179
|
+
* maxProcesses: 8,
|
|
180
|
+
* enableCache: true,
|
|
181
|
+
* });
|
|
182
|
+
* ```
|
|
183
|
+
*/
|
|
81
184
|
export class NodeBridge extends RuntimeBridge {
|
|
82
|
-
private
|
|
83
|
-
private
|
|
84
|
-
private
|
|
185
|
+
private processPool: WorkerProcess[] = [];
|
|
186
|
+
private roundRobinIndex = 0;
|
|
187
|
+
private cleanupTimer?: NodeJS.Timeout;
|
|
188
|
+
private options: ResolvedOptions;
|
|
189
|
+
private emitter = new EventEmitter();
|
|
85
190
|
private disposed = false;
|
|
86
|
-
private initPromise?: Promise<void>;
|
|
87
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
|
+
};
|
|
88
207
|
|
|
89
208
|
constructor(options: NodeBridgeOptions = {}) {
|
|
90
209
|
super();
|
|
@@ -94,6 +213,11 @@ export class NodeBridge extends RuntimeBridge {
|
|
|
94
213
|
const scriptPath = options.scriptPath ?? resolveDefaultScriptPath();
|
|
95
214
|
const resolvedScriptPath = isAbsolute(scriptPath) ? scriptPath : resolve(cwd, scriptPath);
|
|
96
215
|
this.options = {
|
|
216
|
+
// Default to single-process mode for backward compatibility
|
|
217
|
+
minProcesses: options.minProcesses ?? 1,
|
|
218
|
+
maxProcesses: options.maxProcesses ?? 1,
|
|
219
|
+
maxIdleTime: options.maxIdleTime ?? 300000, // 5 minutes
|
|
220
|
+
maxRequestsPerProcess: options.maxRequestsPerProcess ?? 1000,
|
|
97
221
|
pythonPath: options.pythonPath ?? venv?.pythonPath ?? getDefaultPythonPath(),
|
|
98
222
|
scriptPath: resolvedScriptPath,
|
|
99
223
|
virtualEnv,
|
|
@@ -102,26 +226,52 @@ export class NodeBridge extends RuntimeBridge {
|
|
|
102
226
|
maxLineLength: options.maxLineLength,
|
|
103
227
|
inheritProcessEnv: options.inheritProcessEnv ?? false,
|
|
104
228
|
enableJsonFallback: options.enableJsonFallback ?? false,
|
|
229
|
+
enableCache: options.enableCache ?? false,
|
|
105
230
|
env: options.env ?? {},
|
|
231
|
+
warmupCommands: options.warmupCommands ?? [],
|
|
106
232
|
};
|
|
233
|
+
|
|
234
|
+
// Start cleanup scheduler for pooled mode
|
|
235
|
+
this.startCleanupScheduler();
|
|
107
236
|
}
|
|
108
237
|
|
|
109
238
|
async init(): Promise<void> {
|
|
110
239
|
if (this.disposed) {
|
|
111
240
|
throw new BridgeDisposedError('Bridge has been disposed');
|
|
112
241
|
}
|
|
113
|
-
if (this.
|
|
114
|
-
return;
|
|
242
|
+
if (this.processPool.length >= this.options.minProcesses) {
|
|
243
|
+
return; // Already initialized
|
|
115
244
|
}
|
|
116
245
|
if (this.initPromise) {
|
|
117
246
|
return this.initPromise;
|
|
118
247
|
}
|
|
248
|
+
this.initPromise = this.doInit();
|
|
249
|
+
return this.initPromise;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private async doInit(): Promise<void> {
|
|
119
253
|
// eslint-disable-next-line security/detect-non-literal-fs-filename -- script path is user-configured
|
|
120
254
|
if (!existsSync(this.options.scriptPath)) {
|
|
121
255
|
throw new BridgeProtocolError(`Python bridge script not found at ${this.options.scriptPath}`);
|
|
122
256
|
}
|
|
123
|
-
|
|
124
|
-
|
|
257
|
+
|
|
258
|
+
const require = createRequire(import.meta.url);
|
|
259
|
+
await autoRegisterArrowDecoder({
|
|
260
|
+
loader: () => require('apache-arrow'),
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// Ensure minimum processes are available
|
|
264
|
+
while (this.processPool.length < this.options.minProcesses) {
|
|
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
|
+
}
|
|
125
275
|
}
|
|
126
276
|
|
|
127
277
|
async getBridgeInfo(options: { refresh?: boolean } = {}): Promise<BridgeInfo> {
|
|
@@ -142,7 +292,42 @@ export class NodeBridge extends RuntimeBridge {
|
|
|
142
292
|
kwargs?: Record<string, unknown>
|
|
143
293
|
): Promise<T> {
|
|
144
294
|
await this.init();
|
|
145
|
-
|
|
295
|
+
const startTime = performance.now();
|
|
296
|
+
|
|
297
|
+
const cacheKey = this.options.enableCache
|
|
298
|
+
? this.safeCacheKey('runtime_call', module, functionName, args, kwargs)
|
|
299
|
+
: null;
|
|
300
|
+
if (cacheKey) {
|
|
301
|
+
const cached = await globalCache.get<T>(cacheKey);
|
|
302
|
+
if (cached !== null) {
|
|
303
|
+
this.stats.cacheHits++;
|
|
304
|
+
this.updateStats(performance.now() - startTime);
|
|
305
|
+
return cached;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
const result = await this.executeRequest<T>({
|
|
311
|
+
method: 'call',
|
|
312
|
+
params: { module, functionName, args, kwargs },
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const duration = performance.now() - startTime;
|
|
316
|
+
|
|
317
|
+
// Cache result for pure functions (simple heuristic)
|
|
318
|
+
if (cacheKey && this.isPureFunctionCandidate(functionName, args)) {
|
|
319
|
+
await globalCache.set(cacheKey, result, {
|
|
320
|
+
computeTime: duration,
|
|
321
|
+
dependencies: [module],
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
this.updateStats(duration);
|
|
326
|
+
return result;
|
|
327
|
+
} catch (error) {
|
|
328
|
+
this.updateStats(performance.now() - startTime, true);
|
|
329
|
+
throw error;
|
|
330
|
+
}
|
|
146
331
|
}
|
|
147
332
|
|
|
148
333
|
async instantiate<T = unknown>(
|
|
@@ -152,7 +337,20 @@ export class NodeBridge extends RuntimeBridge {
|
|
|
152
337
|
kwargs?: Record<string, unknown>
|
|
153
338
|
): Promise<T> {
|
|
154
339
|
await this.init();
|
|
155
|
-
|
|
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
|
+
return result;
|
|
350
|
+
} catch (error) {
|
|
351
|
+
this.updateStats(performance.now() - startTime, true);
|
|
352
|
+
throw error;
|
|
353
|
+
}
|
|
156
354
|
}
|
|
157
355
|
|
|
158
356
|
async callMethod<T = unknown>(
|
|
@@ -162,102 +360,487 @@ export class NodeBridge extends RuntimeBridge {
|
|
|
162
360
|
kwargs?: Record<string, unknown>
|
|
163
361
|
): Promise<T> {
|
|
164
362
|
await this.init();
|
|
165
|
-
|
|
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
|
+
|
|
371
|
+
this.updateStats(performance.now() - startTime);
|
|
372
|
+
return result;
|
|
373
|
+
} catch (error) {
|
|
374
|
+
this.updateStats(performance.now() - startTime, true);
|
|
375
|
+
throw error;
|
|
376
|
+
}
|
|
166
377
|
}
|
|
167
378
|
|
|
168
379
|
async disposeInstance(handle: string): Promise<void> {
|
|
169
380
|
await this.init();
|
|
170
|
-
await this.
|
|
381
|
+
await this.executeRequest<void>({
|
|
382
|
+
method: 'dispose_instance',
|
|
383
|
+
params: { handle },
|
|
384
|
+
});
|
|
171
385
|
}
|
|
172
386
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
387
|
+
/**
|
|
388
|
+
* Execute request with intelligent process selection
|
|
389
|
+
*/
|
|
390
|
+
private async executeRequest<T>(payload: Omit<RpcRequest, 'id' | 'protocol'>): Promise<T> {
|
|
391
|
+
let worker = this.selectOptimalWorker();
|
|
392
|
+
|
|
393
|
+
// Spawn new process if none available and under limit
|
|
394
|
+
if (!worker && this.processPool.length < this.options.maxProcesses) {
|
|
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);
|
|
177
408
|
}
|
|
178
409
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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;
|
|
182
423
|
}
|
|
183
|
-
|
|
184
|
-
|
|
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;
|
|
185
432
|
}
|
|
186
|
-
|
|
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
|
+
});
|
|
187
463
|
}
|
|
188
464
|
|
|
189
|
-
|
|
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
|
+
|
|
190
478
|
try {
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
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
|
+
});
|
|
194
514
|
});
|
|
195
|
-
|
|
515
|
+
}
|
|
196
516
|
|
|
197
|
-
|
|
198
|
-
|
|
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
|
+
},
|
|
550
|
+
};
|
|
199
551
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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),
|
|
209
567
|
}
|
|
568
|
+
);
|
|
210
569
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
570
|
+
// Setup process event handlers
|
|
571
|
+
this.setupProcessHandlers(worker);
|
|
572
|
+
|
|
573
|
+
this.processPool.push(worker);
|
|
574
|
+
this.stats.processSpawns++;
|
|
575
|
+
|
|
576
|
+
return worker;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Setup event handlers for worker process
|
|
581
|
+
*/
|
|
582
|
+
private setupProcessHandlers(worker: WorkerProcess): void {
|
|
583
|
+
const childProcess = worker.process;
|
|
584
|
+
|
|
585
|
+
childProcess.stdout?.on('data', (chunk: Buffer) => {
|
|
586
|
+
worker.core.handleStdoutData(chunk);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
childProcess.stderr?.on('data', (chunk: Buffer) => {
|
|
590
|
+
worker.core.handleStderrData(chunk);
|
|
591
|
+
const errorText = chunk.toString().trim();
|
|
592
|
+
if (errorText) {
|
|
593
|
+
log.warn('Worker stderr', { workerId: worker.id, output: errorText });
|
|
217
594
|
}
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
// Handle process exit
|
|
598
|
+
childProcess.on('exit', code => {
|
|
599
|
+
log.warn('Worker exited', { workerId: worker.id, code });
|
|
600
|
+
worker.core.handleProcessExit();
|
|
601
|
+
this.handleWorkerExit(worker, code);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
// Handle process errors
|
|
605
|
+
childProcess.on('error', error => {
|
|
606
|
+
log.error('Worker error', { workerId: worker.id, error: String(error) });
|
|
607
|
+
worker.core.handleProcessError(error);
|
|
608
|
+
this.handleWorkerExit(worker, -1);
|
|
609
|
+
});
|
|
610
|
+
}
|
|
218
611
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
this.child.stdin.write(data);
|
|
227
|
-
},
|
|
228
|
-
},
|
|
229
|
-
{
|
|
230
|
-
timeoutMs: this.options.timeoutMs,
|
|
231
|
-
maxLineLength,
|
|
232
|
-
decodeValue: decodeValueAsync,
|
|
233
|
-
onFatalError: (): void => this.resetProcess(),
|
|
234
|
-
}
|
|
235
|
-
);
|
|
612
|
+
/**
|
|
613
|
+
* Handle worker process exit
|
|
614
|
+
*/
|
|
615
|
+
private handleWorkerExit(worker: WorkerProcess, _code: number | null): void {
|
|
616
|
+
if (!this.processPool.includes(worker)) {
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
236
619
|
|
|
237
|
-
|
|
238
|
-
this.core?.handleStdoutData(chunk);
|
|
239
|
-
});
|
|
620
|
+
worker.core.clear();
|
|
240
621
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
+
}
|
|
244
628
|
|
|
245
|
-
|
|
246
|
-
|
|
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) });
|
|
247
633
|
});
|
|
634
|
+
}
|
|
635
|
+
}
|
|
248
636
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
637
|
+
/**
|
|
638
|
+
* Warm up processes with configured commands
|
|
639
|
+
*/
|
|
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
|
+
});
|
|
253
653
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
654
|
+
await Promise.all(warmupPromises);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
private safeCacheKey(prefix: string, ...inputs: unknown[]): string | null {
|
|
658
|
+
try {
|
|
659
|
+
return globalCache.generateKey(prefix, ...inputs);
|
|
660
|
+
} catch {
|
|
661
|
+
return null;
|
|
258
662
|
}
|
|
259
663
|
}
|
|
260
664
|
|
|
665
|
+
/**
|
|
666
|
+
* Heuristic to determine if function result should be cached
|
|
667
|
+
*/
|
|
668
|
+
private isPureFunctionCandidate(functionName: string, args: unknown[]): boolean {
|
|
669
|
+
// Simple heuristics - could be made more sophisticated
|
|
670
|
+
const pureFunctionPatterns = [
|
|
671
|
+
/^(get|fetch|read|load|find|search|query|select)_/i,
|
|
672
|
+
/^(compute|calculate|process|transform|convert)_/i,
|
|
673
|
+
/^(encode|decode|serialize|deserialize)_/i,
|
|
674
|
+
];
|
|
675
|
+
|
|
676
|
+
const impureFunctionPatterns = [
|
|
677
|
+
/^(set|save|write|update|insert|delete|create|modify)_/i,
|
|
678
|
+
/^(send|post|put|patch)_/i,
|
|
679
|
+
/random|uuid|timestamp|now|current/i,
|
|
680
|
+
];
|
|
681
|
+
|
|
682
|
+
// Don't cache if function name suggests mutation
|
|
683
|
+
if (impureFunctionPatterns.some(pattern => pattern.test(functionName))) {
|
|
684
|
+
return false;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Cache if function name suggests pure computation
|
|
688
|
+
if (pureFunctionPatterns.some(pattern => pattern.test(functionName))) {
|
|
689
|
+
return true;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Don't cache if args contain mutable objects (very basic check)
|
|
693
|
+
const hasComplexArgs = args.some(
|
|
694
|
+
arg => arg !== null && typeof arg === 'object' && !(arg instanceof Date)
|
|
695
|
+
);
|
|
696
|
+
|
|
697
|
+
return !hasComplexArgs && args.length <= 3; // Cache simple calls with few args
|
|
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
|
+
};
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Cleanup idle processes
|
|
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
|
+
);
|
|
751
|
+
|
|
752
|
+
for (const worker of idleWorkers) {
|
|
753
|
+
await this.terminateWorker(worker);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Restart workers that have handled too many requests
|
|
757
|
+
const overusedWorkers = this.processPool.filter(
|
|
758
|
+
w => !w.busy && w.requestCount >= this.options.maxRequestsPerProcess
|
|
759
|
+
);
|
|
760
|
+
|
|
761
|
+
for (const worker of overusedWorkers) {
|
|
762
|
+
await this.terminateWorker(worker);
|
|
763
|
+
if (this.processPool.length < this.options.minProcesses) {
|
|
764
|
+
await this.spawnProcess();
|
|
765
|
+
}
|
|
766
|
+
}
|
|
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
|
+
}
|
|
785
|
+
|
|
786
|
+
worker.core.handleProcessExit();
|
|
787
|
+
worker.core.clear();
|
|
788
|
+
|
|
789
|
+
// Graceful termination
|
|
790
|
+
try {
|
|
791
|
+
if (worker.process.exitCode === null) {
|
|
792
|
+
worker.process.kill('SIGTERM');
|
|
793
|
+
|
|
794
|
+
// Force kill if not terminated in 5 seconds
|
|
795
|
+
setTimeout(() => {
|
|
796
|
+
if (worker.process.exitCode === null) {
|
|
797
|
+
worker.process.kill('SIGKILL');
|
|
798
|
+
}
|
|
799
|
+
}, 5000);
|
|
800
|
+
}
|
|
801
|
+
} catch (error) {
|
|
802
|
+
log.warn('Error terminating worker', { workerId: worker.id, error: String(error) });
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Start cleanup scheduler
|
|
808
|
+
*/
|
|
809
|
+
private startCleanupScheduler(): void {
|
|
810
|
+
this.cleanupTimer = setInterval(async () => {
|
|
811
|
+
try {
|
|
812
|
+
await this.cleanup();
|
|
813
|
+
} catch (error) {
|
|
814
|
+
log.error('Cleanup error', { error: String(error) });
|
|
815
|
+
}
|
|
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
|
+
}
|
|
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
|
+
}
|
|
843
|
+
|
|
261
844
|
private buildEnv(): NodeJS.ProcessEnv {
|
|
262
845
|
const allowedPrefixes = ['TYWRAP_'];
|
|
263
846
|
const allowedKeys = new Set(['path', 'pythonpath', 'virtual_env', 'pythonhome']);
|
|
@@ -292,33 +875,26 @@ export class NodeBridge extends RuntimeBridge {
|
|
|
292
875
|
}
|
|
293
876
|
|
|
294
877
|
ensurePythonEncoding(env);
|
|
295
|
-
// Respect explicit request for JSON fallback only; otherwise fast-fail by default
|
|
296
878
|
ensureJsonFallback(env, this.options.enableJsonFallback);
|
|
297
879
|
|
|
298
880
|
env = normalizeEnv(env, {});
|
|
299
881
|
return env;
|
|
300
882
|
}
|
|
301
883
|
|
|
302
|
-
private resetProcess(): void {
|
|
303
|
-
this.core?.clear();
|
|
304
|
-
this.core = undefined;
|
|
305
|
-
if (this.child) {
|
|
306
|
-
try {
|
|
307
|
-
if (this.child.exitCode === null) {
|
|
308
|
-
this.child.kill('SIGTERM');
|
|
309
|
-
}
|
|
310
|
-
} catch {
|
|
311
|
-
// ignore
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
this.child = undefined;
|
|
315
|
-
this.initPromise = undefined;
|
|
316
|
-
this.bridgeInfo = undefined;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
884
|
private async refreshBridgeInfo(): Promise<void> {
|
|
320
|
-
const info = await this.
|
|
885
|
+
const info = await this.executeRequest<BridgeInfo>({ method: 'meta', params: {} });
|
|
321
886
|
validateBridgeInfo(info);
|
|
322
887
|
this.bridgeInfo = info;
|
|
323
888
|
}
|
|
324
889
|
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* @deprecated Use NodeBridge with minProcesses/maxProcesses options instead.
|
|
893
|
+
* This alias is provided for backward compatibility.
|
|
894
|
+
*/
|
|
895
|
+
export { NodeBridge as OptimizedNodeBridge };
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* @deprecated Use NodeBridgeOptions instead.
|
|
899
|
+
*/
|
|
900
|
+
export type { NodeBridgeOptions as ProcessPoolOptions };
|