tywrap 0.1.2 → 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 +11 -4
- 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 +142 -28
- package/dist/runtime/node.d.ts.map +1 -1
- package/dist/runtime/node.js +321 -168
- 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/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 +460 -217
- package/src/runtime/optimized-node.ts +19 -761
- 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,5 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Node.js runtime bridge
|
|
2
|
+
* Node.js runtime bridge for BridgeProtocol.
|
|
3
|
+
*
|
|
4
|
+
* NodeBridge extends BridgeProtocol and uses ProcessIO transports with
|
|
5
|
+
* optional pooling for concurrent Python execution.
|
|
6
|
+
*
|
|
7
|
+
* @see https://github.com/bbopen/tywrap/issues/149
|
|
3
8
|
*/
|
|
4
9
|
|
|
5
10
|
import { existsSync } from 'node:fs';
|
|
@@ -7,55 +12,127 @@ import { delimiter, isAbsolute, join, resolve } from 'node:path';
|
|
|
7
12
|
import { fileURLToPath } from 'node:url';
|
|
8
13
|
import { createRequire } from 'node:module';
|
|
9
14
|
|
|
10
|
-
import { autoRegisterArrowDecoder
|
|
15
|
+
import { autoRegisterArrowDecoder } from '../utils/codec.js';
|
|
11
16
|
import { getDefaultPythonPath } from '../utils/python.js';
|
|
12
17
|
import { getVenvBinDir, getVenvPythonExe } from '../utils/runtime.js';
|
|
13
|
-
import
|
|
14
|
-
|
|
15
|
-
import { RuntimeBridge } from './base.js';
|
|
16
|
-
import { BridgeDisposedError, BridgeProtocolError } from './errors.js';
|
|
17
|
-
import {
|
|
18
|
-
BridgeCore,
|
|
19
|
-
type RpcRequest,
|
|
20
|
-
ensureJsonFallback,
|
|
21
|
-
ensurePythonEncoding,
|
|
22
|
-
getMaxLineLengthFromEnv,
|
|
23
|
-
getPathKey,
|
|
24
|
-
normalizeEnv,
|
|
25
|
-
validateBridgeInfo,
|
|
26
|
-
} from './bridge-core.js';
|
|
18
|
+
import { globalCache } from '../utils/cache.js';
|
|
27
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
|
+
// =============================================================================
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Configuration options for NodeBridge.
|
|
33
|
+
*/
|
|
28
34
|
export interface NodeBridgeOptions {
|
|
35
|
+
/** Minimum number of Python processes to keep alive. Default: 1 */
|
|
36
|
+
minProcesses?: number;
|
|
37
|
+
|
|
38
|
+
/** Maximum number of Python processes to spawn. Default: 1 (single-process mode) */
|
|
39
|
+
maxProcesses?: number;
|
|
40
|
+
|
|
41
|
+
/** Maximum concurrent requests per process. Default: 10 */
|
|
42
|
+
maxConcurrentPerProcess?: number;
|
|
43
|
+
|
|
44
|
+
/** Path to Python executable. Auto-detected if not specified. */
|
|
29
45
|
pythonPath?: string;
|
|
30
|
-
|
|
46
|
+
|
|
47
|
+
/** Path to python_bridge.py script. Auto-detected if not specified. */
|
|
48
|
+
scriptPath?: string;
|
|
49
|
+
|
|
50
|
+
/** Path to Python virtual environment. */
|
|
31
51
|
virtualEnv?: string;
|
|
52
|
+
|
|
53
|
+
/** Working directory for Python process. Default: process.cwd() */
|
|
32
54
|
cwd?: string;
|
|
55
|
+
|
|
56
|
+
/** Timeout in ms for Python calls. Default: 30000 */
|
|
33
57
|
timeoutMs?: number;
|
|
34
|
-
|
|
58
|
+
|
|
59
|
+
/** Timeout in ms for waiting in pool queue. Default: 30000 */
|
|
60
|
+
queueTimeoutMs?: number;
|
|
61
|
+
|
|
62
|
+
/** Inherit all environment variables from parent process. Default: false */
|
|
35
63
|
inheritProcessEnv?: boolean;
|
|
64
|
+
|
|
65
|
+
/** Enable result caching for pure functions. Default: false */
|
|
66
|
+
enableCache?: boolean;
|
|
67
|
+
|
|
68
|
+
/** Optional extra environment variables to pass to the Python subprocess. */
|
|
69
|
+
env?: Record<string, string | undefined>;
|
|
70
|
+
|
|
71
|
+
/** Codec options for validation/serialization */
|
|
72
|
+
codec?: CodecOptions;
|
|
73
|
+
|
|
74
|
+
/** Commands to run on each process at startup for warming up. */
|
|
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
|
+
|
|
36
94
|
/**
|
|
37
|
-
*
|
|
38
|
-
* for rich types (ndarray/dataframe/series). Default: false for fast-fail on Arrow path issues.
|
|
95
|
+
* @deprecated Use codec.bytesHandling option instead.
|
|
39
96
|
*/
|
|
40
97
|
enableJsonFallback?: boolean;
|
|
98
|
+
|
|
41
99
|
/**
|
|
42
|
-
*
|
|
100
|
+
* @deprecated Use ProcessIO options instead.
|
|
43
101
|
*/
|
|
44
|
-
|
|
102
|
+
maxLineLength?: number;
|
|
45
103
|
}
|
|
46
104
|
|
|
47
|
-
|
|
105
|
+
// =============================================================================
|
|
106
|
+
// INTERNAL TYPES
|
|
107
|
+
// =============================================================================
|
|
108
|
+
|
|
109
|
+
interface ResolvedOptions {
|
|
110
|
+
minProcesses: number;
|
|
111
|
+
maxProcesses: number;
|
|
112
|
+
maxConcurrentPerProcess: number;
|
|
48
113
|
pythonPath: string;
|
|
49
114
|
scriptPath: string;
|
|
50
115
|
virtualEnv?: string;
|
|
51
116
|
cwd: string;
|
|
52
117
|
timeoutMs: number;
|
|
53
|
-
|
|
118
|
+
queueTimeoutMs: number;
|
|
54
119
|
inheritProcessEnv: boolean;
|
|
55
|
-
|
|
120
|
+
enableCache: boolean;
|
|
56
121
|
env: Record<string, string | undefined>;
|
|
122
|
+
codec?: CodecOptions;
|
|
123
|
+
warmupCommands: Array<
|
|
124
|
+
| { module: string; functionName: string; args?: unknown[] }
|
|
125
|
+
| { method: string; params: unknown }
|
|
126
|
+
>;
|
|
57
127
|
}
|
|
58
128
|
|
|
129
|
+
// =============================================================================
|
|
130
|
+
// UTILITIES
|
|
131
|
+
// =============================================================================
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Resolve the default bridge script path.
|
|
135
|
+
*/
|
|
59
136
|
function resolveDefaultScriptPath(): string {
|
|
60
137
|
try {
|
|
61
138
|
return fileURLToPath(new URL('../../runtime/python_bridge.py', import.meta.url));
|
|
@@ -64,261 +141,427 @@ function resolveDefaultScriptPath(): string {
|
|
|
64
141
|
}
|
|
65
142
|
}
|
|
66
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Resolve virtual environment paths.
|
|
146
|
+
*/
|
|
67
147
|
function resolveVirtualEnv(
|
|
68
148
|
virtualEnv: string,
|
|
69
149
|
cwd: string
|
|
70
|
-
): {
|
|
71
|
-
venvPath: string;
|
|
72
|
-
binDir: string;
|
|
73
|
-
pythonPath: string;
|
|
74
|
-
} {
|
|
150
|
+
): { venvPath: string; binDir: string; pythonPath: string } {
|
|
75
151
|
const venvPath = resolve(cwd, virtualEnv);
|
|
76
152
|
const binDir = join(venvPath, getVenvBinDir());
|
|
77
153
|
const pythonPath = join(binDir, getVenvPythonExe());
|
|
78
154
|
return { venvPath, binDir, pythonPath };
|
|
79
155
|
}
|
|
80
156
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
+
// =============================================================================
|
|
88
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Node.js runtime bridge for executing Python code.
|
|
175
|
+
*
|
|
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
|
|
186
|
+
*
|
|
187
|
+
* @example
|
|
188
|
+
* ```typescript
|
|
189
|
+
* // Single-process mode (default)
|
|
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
|
|
195
|
+
*
|
|
196
|
+
* await bridge.dispose();
|
|
197
|
+
* ```
|
|
198
|
+
*
|
|
199
|
+
* @example
|
|
200
|
+
* ```typescript
|
|
201
|
+
* // Multi-process pooling for high throughput
|
|
202
|
+
* const pooledBridge = new NodeBridge({
|
|
203
|
+
* maxProcesses: 4,
|
|
204
|
+
* maxConcurrentPerProcess: 2,
|
|
205
|
+
* enableCache: true,
|
|
206
|
+
* });
|
|
207
|
+
* await pooledBridge.init();
|
|
208
|
+
* ```
|
|
209
|
+
*/
|
|
210
|
+
export class NodeBridge extends BridgeProtocol {
|
|
211
|
+
private readonly resolvedOptions: ResolvedOptions;
|
|
212
|
+
private readonly pooledTransport: PooledTransport;
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Create a new NodeBridge instance.
|
|
216
|
+
*
|
|
217
|
+
* @param options - Configuration options for the bridge
|
|
218
|
+
*/
|
|
89
219
|
constructor(options: NodeBridgeOptions = {}) {
|
|
90
|
-
super();
|
|
91
220
|
const cwd = options.cwd ?? process.cwd();
|
|
92
221
|
const virtualEnv = options.virtualEnv ? resolve(cwd, options.virtualEnv) : undefined;
|
|
93
222
|
const venv = virtualEnv ? resolveVirtualEnv(virtualEnv, cwd) : undefined;
|
|
94
223
|
const scriptPath = options.scriptPath ?? resolveDefaultScriptPath();
|
|
95
224
|
const resolvedScriptPath = isAbsolute(scriptPath) ? scriptPath : resolve(cwd, scriptPath);
|
|
96
|
-
|
|
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,
|
|
97
233
|
pythonPath: options.pythonPath ?? venv?.pythonPath ?? getDefaultPythonPath(),
|
|
98
234
|
scriptPath: resolvedScriptPath,
|
|
99
235
|
virtualEnv,
|
|
100
236
|
cwd,
|
|
101
237
|
timeoutMs: options.timeoutMs ?? 30000,
|
|
102
|
-
|
|
238
|
+
queueTimeoutMs: options.queueTimeoutMs ?? 30000,
|
|
103
239
|
inheritProcessEnv: options.inheritProcessEnv ?? false,
|
|
104
|
-
|
|
240
|
+
enableCache: options.enableCache ?? false,
|
|
105
241
|
env: options.env ?? {},
|
|
242
|
+
codec: options.codec,
|
|
243
|
+
warmupCommands: options.warmupCommands ?? [],
|
|
244
|
+
};
|
|
245
|
+
|
|
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
|
+
});
|
|
269
|
+
|
|
270
|
+
// Initialize BridgeProtocol with pooled transport
|
|
271
|
+
const protocolOptions: BridgeProtocolOptions = {
|
|
272
|
+
transport,
|
|
273
|
+
codec: resolvedOptions.codec,
|
|
274
|
+
defaultTimeoutMs: resolvedOptions.timeoutMs,
|
|
106
275
|
};
|
|
276
|
+
|
|
277
|
+
super(protocolOptions);
|
|
278
|
+
|
|
279
|
+
this.resolvedOptions = resolvedOptions;
|
|
280
|
+
this.pooledTransport = transport;
|
|
107
281
|
}
|
|
108
282
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
119
295
|
// eslint-disable-next-line security/detect-non-literal-fs-filename -- script path is user-configured
|
|
120
|
-
if (!existsSync(this.
|
|
121
|
-
throw new BridgeProtocolError(
|
|
296
|
+
if (!existsSync(this.resolvedOptions.scriptPath)) {
|
|
297
|
+
throw new BridgeProtocolError(
|
|
298
|
+
`Python bridge script not found at ${this.resolvedOptions.scriptPath}`
|
|
299
|
+
);
|
|
122
300
|
}
|
|
123
|
-
this.initPromise = this.startProcess();
|
|
124
|
-
return this.initPromise;
|
|
125
|
-
}
|
|
126
301
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
if (!this.bridgeInfo) {
|
|
133
|
-
throw new BridgeProtocolError('Bridge info unavailable');
|
|
134
|
-
}
|
|
135
|
-
return this.bridgeInfo;
|
|
136
|
-
}
|
|
302
|
+
// Register Arrow decoder for DataFrames/ndarrays
|
|
303
|
+
const require = createRequire(import.meta.url);
|
|
304
|
+
await autoRegisterArrowDecoder({
|
|
305
|
+
loader: () => require('apache-arrow'),
|
|
306
|
+
});
|
|
137
307
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
functionName: string,
|
|
141
|
-
args: unknown[],
|
|
142
|
-
kwargs?: Record<string, unknown>
|
|
143
|
-
): Promise<T> {
|
|
144
|
-
await this.init();
|
|
145
|
-
return this.send<T>({ method: 'call', params: { module, functionName, args, kwargs } });
|
|
308
|
+
// Initialize parent (which initializes transport and runs warmup per-worker)
|
|
309
|
+
await super.doInit();
|
|
146
310
|
}
|
|
147
311
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
args: unknown[],
|
|
152
|
-
kwargs?: Record<string, unknown>
|
|
153
|
-
): Promise<T> {
|
|
154
|
-
await this.init();
|
|
155
|
-
return this.send<T>({ method: 'instantiate', params: { module, className, args, kwargs } });
|
|
156
|
-
}
|
|
312
|
+
// ===========================================================================
|
|
313
|
+
// CACHING OVERRIDE
|
|
314
|
+
// ===========================================================================
|
|
157
315
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
316
|
+
/**
|
|
317
|
+
* Override call() to add optional caching.
|
|
318
|
+
*/
|
|
319
|
+
override async call<T = unknown>(
|
|
320
|
+
module: string,
|
|
321
|
+
functionName: string,
|
|
161
322
|
args: unknown[],
|
|
162
323
|
kwargs?: Record<string, unknown>
|
|
163
324
|
): Promise<T> {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
async dispose(): Promise<void> {
|
|
174
|
-
this.disposed = true;
|
|
175
|
-
this.core?.handleProcessExit();
|
|
176
|
-
this.resetProcess();
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
private async send<T>(payload: Omit<RpcRequest, 'id' | 'protocol'>): Promise<T> {
|
|
180
|
-
if (this.disposed) {
|
|
181
|
-
throw new BridgeDisposedError('Bridge has been disposed');
|
|
182
|
-
}
|
|
183
|
-
if (!this.core) {
|
|
184
|
-
throw new BridgeProtocolError('Python process not available');
|
|
185
|
-
}
|
|
186
|
-
return this.core.send<T>(payload);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
private async startProcess(): Promise<void> {
|
|
190
|
-
try {
|
|
191
|
-
const require = createRequire(import.meta.url);
|
|
192
|
-
await autoRegisterArrowDecoder({
|
|
193
|
-
loader: () => require('apache-arrow'),
|
|
194
|
-
});
|
|
195
|
-
const { spawn } = await import('child_process');
|
|
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
|
+
}
|
|
333
|
+
}
|
|
196
334
|
|
|
197
|
-
|
|
198
|
-
const
|
|
335
|
+
// Execute and cache if pure function
|
|
336
|
+
const startTime = performance.now();
|
|
337
|
+
const result = await super.call<T>(module, functionName, args, kwargs);
|
|
338
|
+
const duration = performance.now() - startTime;
|
|
199
339
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
205
|
-
env,
|
|
340
|
+
if (cacheKey && this.isPureFunctionCandidate(functionName, args)) {
|
|
341
|
+
await globalCache.set(cacheKey, result, {
|
|
342
|
+
computeTime: duration,
|
|
343
|
+
dependencies: [module],
|
|
206
344
|
});
|
|
207
|
-
} catch (err) {
|
|
208
|
-
throw new BridgeProtocolError(`Failed to start Python process: ${(err as Error).message}`);
|
|
209
345
|
}
|
|
210
346
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
child.once('spawn', () => done(null));
|
|
214
|
-
});
|
|
215
|
-
if (startupError) {
|
|
216
|
-
throw new BridgeProtocolError(`Failed to start Python process: ${startupError.message}`);
|
|
217
|
-
}
|
|
347
|
+
return result;
|
|
348
|
+
}
|
|
218
349
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
write: (data: string): void => {
|
|
223
|
-
if (!this.child?.stdin) {
|
|
224
|
-
throw new BridgeProtocolError('Python process not available');
|
|
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
|
-
);
|
|
350
|
+
// No caching - direct call
|
|
351
|
+
return super.call<T>(module, functionName, args, kwargs);
|
|
352
|
+
}
|
|
236
353
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
354
|
+
// ===========================================================================
|
|
355
|
+
// POOL STATISTICS
|
|
356
|
+
// ===========================================================================
|
|
240
357
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
358
|
+
/**
|
|
359
|
+
* Get current pool statistics.
|
|
360
|
+
*/
|
|
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,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
244
368
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
369
|
+
/**
|
|
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.
|
|
374
|
+
*/
|
|
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
|
+
};
|
|
406
|
+
}
|
|
248
407
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
});
|
|
408
|
+
// ===========================================================================
|
|
409
|
+
// PRIVATE HELPERS
|
|
410
|
+
// ===========================================================================
|
|
253
411
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
412
|
+
/**
|
|
413
|
+
* Generate a cache key, returning null if generation fails.
|
|
414
|
+
*/
|
|
415
|
+
private safeCacheKey(prefix: string, ...inputs: unknown[]): string | null {
|
|
416
|
+
try {
|
|
417
|
+
return globalCache.generateKey(prefix, ...inputs);
|
|
418
|
+
} catch {
|
|
419
|
+
return null;
|
|
258
420
|
}
|
|
259
421
|
}
|
|
260
422
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
}
|
|
423
|
+
/**
|
|
424
|
+
* Heuristic to determine if function result should be cached.
|
|
425
|
+
*/
|
|
426
|
+
private isPureFunctionCandidate(functionName: string, args: unknown[]): boolean {
|
|
427
|
+
const pureFunctionPatterns = [
|
|
428
|
+
/^(get|fetch|read|load|find|search|query|select)_/i,
|
|
429
|
+
/^(compute|calculate|process|transform|convert)_/i,
|
|
430
|
+
/^(encode|decode|serialize|deserialize)_/i,
|
|
431
|
+
];
|
|
432
|
+
|
|
433
|
+
const impureFunctionPatterns = [
|
|
434
|
+
/^(set|save|write|update|insert|delete|create|modify)_/i,
|
|
435
|
+
/^(send|post|put|patch)_/i,
|
|
436
|
+
/random|uuid|timestamp|now|current/i,
|
|
437
|
+
];
|
|
438
|
+
|
|
439
|
+
if (impureFunctionPatterns.some(pattern => pattern.test(functionName))) {
|
|
440
|
+
return false;
|
|
280
441
|
}
|
|
281
442
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
if (this.options.virtualEnv) {
|
|
285
|
-
const venv = resolveVirtualEnv(this.options.virtualEnv, this.options.cwd);
|
|
286
|
-
env.VIRTUAL_ENV = venv.venvPath;
|
|
287
|
-
const pathKey = getPathKey(env);
|
|
288
|
-
// eslint-disable-next-line security/detect-object-injection -- env keys are dynamic by design
|
|
289
|
-
const currentPath = env[pathKey] ?? '';
|
|
290
|
-
// eslint-disable-next-line security/detect-object-injection -- env keys are dynamic by design
|
|
291
|
-
env[pathKey] = `${venv.binDir}${delimiter}${currentPath}`;
|
|
443
|
+
if (pureFunctionPatterns.some(pattern => pattern.test(functionName))) {
|
|
444
|
+
return true;
|
|
292
445
|
}
|
|
293
446
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
447
|
+
const hasComplexArgs = args.some(
|
|
448
|
+
arg => arg !== null && typeof arg === 'object' && !(arg instanceof Date)
|
|
449
|
+
);
|
|
297
450
|
|
|
298
|
-
|
|
299
|
-
return env;
|
|
451
|
+
return !hasComplexArgs && args.length <= 3;
|
|
300
452
|
}
|
|
453
|
+
}
|
|
301
454
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
455
|
+
// =============================================================================
|
|
456
|
+
// WARMUP CALLBACK
|
|
457
|
+
// =============================================================================
|
|
458
|
+
|
|
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
|
+
}
|
|
466
|
+
|
|
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) {
|
|
306
482
|
try {
|
|
307
|
-
|
|
308
|
-
|
|
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
|
+
});
|
|
494
|
+
|
|
495
|
+
// Send directly to this worker's transport
|
|
496
|
+
await worker.transport.send(message, timeoutMs);
|
|
309
497
|
}
|
|
498
|
+
// Legacy format { method, params } is ignored as it's not supported
|
|
310
499
|
} catch {
|
|
311
|
-
//
|
|
500
|
+
// Ignore warmup errors - they're not critical
|
|
312
501
|
}
|
|
313
502
|
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// =============================================================================
|
|
507
|
+
// ENVIRONMENT BUILDING
|
|
508
|
+
// =============================================================================
|
|
509
|
+
|
|
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;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
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;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Apply user overrides
|
|
537
|
+
for (const [key, value] of Object.entries(options.env)) {
|
|
538
|
+
if (value !== undefined) {
|
|
539
|
+
env[key] = value;
|
|
540
|
+
}
|
|
317
541
|
}
|
|
318
542
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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}`;
|
|
323
550
|
}
|
|
551
|
+
|
|
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;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Ensure Python uses UTF-8
|
|
561
|
+
env.PYTHONUTF8 = '1';
|
|
562
|
+
env.PYTHONIOENCODING = 'UTF-8';
|
|
563
|
+
env.PYTHONUNBUFFERED = '1';
|
|
564
|
+
env.PYTHONDONTWRITEBYTECODE = '1';
|
|
565
|
+
|
|
566
|
+
return env;
|
|
324
567
|
}
|