@malloy-publisher/server 0.0.198-dev1 → 0.0.198-dev3
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/build.ts +12 -22
- package/dist/instrumentation.mjs +57 -36
- package/dist/server.mjs +2259 -3180
- package/dist/service/schema_worker.mjs +61 -0
- package/package.json +2 -3
- package/src/health.ts +0 -13
- package/src/instrumentation.ts +50 -0
- package/src/server.ts +5 -0
- package/src/service/environment_store.ts +33 -3
- package/src/service/model.ts +3 -226
- package/src/service/package.spec.ts +11 -7
- package/src/service/package.ts +49 -53
- package/src/service/process_stats_reporter.ts +169 -0
- package/src/service/schema_worker.ts +123 -0
- package/src/service/schema_worker_pool.ts +287 -0
- package/tests/integration/concurrent_environment/concurrent_environment.integration.spec.ts +235 -0
- package/dist/compile_worker.mjs +0 -628
- package/src/compile/compile_pool.spec.ts +0 -227
- package/src/compile/compile_pool.ts +0 -729
- package/src/compile/compile_worker.ts +0 -683
- package/src/compile/protocol.ts +0 -251
- package/src/service/model_worker_path.spec.ts +0 -125
package/src/service/package.ts
CHANGED
|
@@ -1,16 +1,11 @@
|
|
|
1
1
|
import * as fs from "fs/promises";
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
|
|
4
|
-
import { DuckDBConnection } from "@malloydata/db-duckdb";
|
|
5
|
-
import "@malloydata/db-duckdb/native";
|
|
6
4
|
import {
|
|
7
5
|
Connection,
|
|
8
|
-
ConnectionRuntime,
|
|
9
6
|
contextOverlay,
|
|
10
|
-
EmptyURLReader,
|
|
11
7
|
FixedConnectionMap,
|
|
12
8
|
MalloyConfig,
|
|
13
|
-
SourceDef,
|
|
14
9
|
} from "@malloydata/malloy";
|
|
15
10
|
import { metrics } from "@opentelemetry/api";
|
|
16
11
|
import recursive from "recursive-readdir";
|
|
@@ -26,13 +21,12 @@ import { formatDuration, logger } from "../logger";
|
|
|
26
21
|
import { BuildManifest } from "../storage/DatabaseInterface";
|
|
27
22
|
import { ignoreDotfiles } from "../utils";
|
|
28
23
|
import { Model } from "./model";
|
|
24
|
+
import { getSchemaWorkerPool } from "./schema_worker_pool";
|
|
29
25
|
|
|
30
26
|
type ApiDatabase = components["schemas"]["Database"];
|
|
31
27
|
type ApiModel = components["schemas"]["Model"];
|
|
32
28
|
type ApiNotebook = components["schemas"]["Notebook"];
|
|
33
29
|
export type ApiPackage = components["schemas"]["Package"];
|
|
34
|
-
type ApiColumn = components["schemas"]["Column"];
|
|
35
|
-
type ApiTableDescription = components["schemas"]["TableDescription"];
|
|
36
30
|
// A thunk lets callers pass a live reference to the *current* environment
|
|
37
31
|
// MalloyConfig so the package wrapper resolves environment connections against the
|
|
38
32
|
// generation that's active at lookup time, not the one that was current when
|
|
@@ -93,6 +87,8 @@ export class Package {
|
|
|
93
87
|
duration: formatDuration(manifestValidationTime - startTime),
|
|
94
88
|
});
|
|
95
89
|
|
|
90
|
+
let packageMalloyConfig: MalloyConfig | undefined;
|
|
91
|
+
|
|
96
92
|
try {
|
|
97
93
|
const packageConfig = await Package.readPackageConfig(packagePath);
|
|
98
94
|
const packageConfigTime = performance.now();
|
|
@@ -181,6 +177,17 @@ export class Package {
|
|
|
181
177
|
malloy_package_name: packageName,
|
|
182
178
|
status: "error",
|
|
183
179
|
});
|
|
180
|
+
|
|
181
|
+
if (packageMalloyConfig) {
|
|
182
|
+
try {
|
|
183
|
+
await packageMalloyConfig.shutdown("close");
|
|
184
|
+
} catch (releaseError) {
|
|
185
|
+
logger.warn(
|
|
186
|
+
`Failed to release package-local DuckDB for ${packageName}`,
|
|
187
|
+
{ error: releaseError },
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
184
191
|
// Clean up package directory on failure
|
|
185
192
|
try {
|
|
186
193
|
await fs.rm(packagePath, {
|
|
@@ -430,22 +437,43 @@ export class Package {
|
|
|
430
437
|
private static async readDatabases(
|
|
431
438
|
packagePath: string,
|
|
432
439
|
): Promise<ApiDatabase[]> {
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
440
|
+
const databasePaths = await Package.getDatabasePaths(packagePath);
|
|
441
|
+
if (databasePaths.length === 0) return [];
|
|
442
|
+
|
|
443
|
+
// Off-main-thread: schema introspection runs in the
|
|
444
|
+
// SchemaWorkerPool so DuckDB's native thread pool lives inside
|
|
445
|
+
// a worker we control. This is the leak class that OOM-killed
|
|
446
|
+
// prod (466 leaked Bun Pool threads on worker-76b49bdb89-8bsv4)
|
|
447
|
+
// — worker isolation puts a hard ceiling on per-package native
|
|
448
|
+
// thread usage and the worker's connection is reused across all
|
|
449
|
+
// schema queries for the life of the process.
|
|
450
|
+
const pool = getSchemaWorkerPool();
|
|
451
|
+
const settled = await Promise.allSettled(
|
|
452
|
+
databasePaths.map((databasePath) =>
|
|
453
|
+
pool.submit(packagePath, databasePath),
|
|
447
454
|
),
|
|
448
455
|
);
|
|
456
|
+
|
|
457
|
+
const results: ApiDatabase[] = [];
|
|
458
|
+
for (let i = 0; i < settled.length; i++) {
|
|
459
|
+
const outcome = settled[i];
|
|
460
|
+
if (outcome.status === "fulfilled") {
|
|
461
|
+
results.push({
|
|
462
|
+
path: databasePaths[i],
|
|
463
|
+
info: outcome.value,
|
|
464
|
+
type: "embedded",
|
|
465
|
+
});
|
|
466
|
+
} else {
|
|
467
|
+
// A single bad parquet (corrupt footer, unsupported type)
|
|
468
|
+
// must not fail the whole package load. Log and skip.
|
|
469
|
+
logger.warn("Schema introspection failed for database", {
|
|
470
|
+
packagePath,
|
|
471
|
+
databasePath: databasePaths[i],
|
|
472
|
+
error: outcome.reason,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return results;
|
|
449
477
|
}
|
|
450
478
|
|
|
451
479
|
private static async getDatabasePaths(
|
|
@@ -462,38 +490,6 @@ export class Package {
|
|
|
462
490
|
);
|
|
463
491
|
}
|
|
464
492
|
|
|
465
|
-
private static async getDatabaseInfo(
|
|
466
|
-
packagePath: string,
|
|
467
|
-
databasePath: string,
|
|
468
|
-
): Promise<ApiTableDescription> {
|
|
469
|
-
const fullPath = path.join(packagePath, databasePath);
|
|
470
|
-
|
|
471
|
-
// Create a DuckDB source then:
|
|
472
|
-
// 1. Load the model and get the table schema from model
|
|
473
|
-
// 2. Run a query to get the row count from the table
|
|
474
|
-
const runtime = new ConnectionRuntime({
|
|
475
|
-
urlReader: new EmptyURLReader(),
|
|
476
|
-
connections: [new DuckDBConnection("duckdb")],
|
|
477
|
-
});
|
|
478
|
-
// Normalize path to use forward slashes for cross-platform compatibility
|
|
479
|
-
// DuckDB on Windows supports forward slashes, and this avoids escaping issues
|
|
480
|
-
const normalizedPath = fullPath.replace(/\\/g, "/");
|
|
481
|
-
const model = runtime.loadModel(
|
|
482
|
-
`source: temp is duckdb.table('${normalizedPath}')`,
|
|
483
|
-
);
|
|
484
|
-
const modelDef = await model.getModel();
|
|
485
|
-
const fields = (modelDef._modelDef.contents["temp"] as SourceDef).fields;
|
|
486
|
-
const schema = fields.map((field): ApiColumn => {
|
|
487
|
-
return { type: field.type, name: field.name };
|
|
488
|
-
});
|
|
489
|
-
const runner = model.loadQuery(
|
|
490
|
-
"run: temp->{aggregate: row_count is count()}",
|
|
491
|
-
);
|
|
492
|
-
const result = await runner.run();
|
|
493
|
-
const rowCount = result.data.value[0].row_count?.valueOf() as number;
|
|
494
|
-
return { name: databasePath, rowCount, columns: schema };
|
|
495
|
-
}
|
|
496
|
-
|
|
497
493
|
public setName(name: string) {
|
|
498
494
|
this.packageName = name;
|
|
499
495
|
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
|
|
3
|
+
import { logger } from "../logger";
|
|
4
|
+
import type { PackageMemoryGovernor } from "./package_memory_governor";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_INTERVAL_MS = 30_000;
|
|
7
|
+
|
|
8
|
+
interface LinuxProcStatus {
|
|
9
|
+
threads?: number;
|
|
10
|
+
vmRssBytes?: number;
|
|
11
|
+
vmSizeBytes?: number;
|
|
12
|
+
vmPeakBytes?: number;
|
|
13
|
+
vmDataBytes?: number;
|
|
14
|
+
voluntaryCtxSwitches?: number;
|
|
15
|
+
nonvoluntaryCtxSwitches?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parse the subset of `/proc/self/status` that matters for diagnosing
|
|
20
|
+
* thread / virtual-memory leaks. The file is small (<5KB), so reading
|
|
21
|
+
* it synchronously here is cheap and avoids fs-promise queueing.
|
|
22
|
+
*
|
|
23
|
+
* Format is `Key:\t<value> [unit]` per line. Sizes are reported in kB;
|
|
24
|
+
* we normalize to bytes so log output matches `process.memoryUsage()`.
|
|
25
|
+
*/
|
|
26
|
+
function readLinuxProcStatus(): LinuxProcStatus | null {
|
|
27
|
+
try {
|
|
28
|
+
const raw = fs.readFileSync("/proc/self/status", "utf8");
|
|
29
|
+
const out: LinuxProcStatus = {};
|
|
30
|
+
for (const line of raw.split("\n")) {
|
|
31
|
+
const [keyRaw, valueRaw] = line.split(":");
|
|
32
|
+
if (!keyRaw || !valueRaw) continue;
|
|
33
|
+
const key = keyRaw.trim();
|
|
34
|
+
const value = valueRaw.trim();
|
|
35
|
+
switch (key) {
|
|
36
|
+
case "Threads":
|
|
37
|
+
out.threads = Number(value);
|
|
38
|
+
break;
|
|
39
|
+
case "VmRSS":
|
|
40
|
+
out.vmRssBytes = kBToBytes(value);
|
|
41
|
+
break;
|
|
42
|
+
case "VmSize":
|
|
43
|
+
out.vmSizeBytes = kBToBytes(value);
|
|
44
|
+
break;
|
|
45
|
+
case "VmPeak":
|
|
46
|
+
out.vmPeakBytes = kBToBytes(value);
|
|
47
|
+
break;
|
|
48
|
+
case "VmData":
|
|
49
|
+
out.vmDataBytes = kBToBytes(value);
|
|
50
|
+
break;
|
|
51
|
+
case "voluntary_ctxt_switches":
|
|
52
|
+
out.voluntaryCtxSwitches = Number(value);
|
|
53
|
+
break;
|
|
54
|
+
case "nonvoluntary_ctxt_switches":
|
|
55
|
+
out.nonvoluntaryCtxSwitches = Number(value);
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function kBToBytes(value: string): number | undefined {
|
|
66
|
+
const num = Number(value.replace(/\s*kB$/, ""));
|
|
67
|
+
if (!Number.isFinite(num)) return undefined;
|
|
68
|
+
return num * 1024;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Bun exposes JSC heap stats via the `bun:jsc` builtin. Optional —
|
|
73
|
+
* absent under plain Node — and best-effort: failures are swallowed
|
|
74
|
+
* so the reporter never crashes the process.
|
|
75
|
+
*/
|
|
76
|
+
async function readBunJscStats(): Promise<Record<string, number> | null> {
|
|
77
|
+
if (typeof (globalThis as { Bun?: unknown }).Bun === "undefined") {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
// Dynamic import so Node builds don't fail at parse time.
|
|
82
|
+
const jsc = (await import("bun:jsc")) as unknown as {
|
|
83
|
+
heapStats?: () => Record<string, number>;
|
|
84
|
+
memoryUsage?: () => Record<string, number>;
|
|
85
|
+
};
|
|
86
|
+
const heap = jsc.heapStats?.();
|
|
87
|
+
const mem = jsc.memoryUsage?.();
|
|
88
|
+
if (!heap && !mem) return null;
|
|
89
|
+
return { ...(heap ?? {}), ...(mem ?? {}) };
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Periodically logs process memory and thread counts to give ops a
|
|
97
|
+
* cheap, always-on signal for the leak classes that have OOM-killed
|
|
98
|
+
* prod (DuckDB connection thread pools, libuv worker pool, Malloy
|
|
99
|
+
* compile heap, etc.).
|
|
100
|
+
*
|
|
101
|
+
* Logs at `info` so it shows up without flipping `LOG_LEVEL`. Volume
|
|
102
|
+
* is low (~2 lines/minute by default). Pulls the memory governor's
|
|
103
|
+
* snapshot too so RSS/back-pressure state appears in the same line as
|
|
104
|
+
* Node/Bun heap.
|
|
105
|
+
*/
|
|
106
|
+
export class ProcessStatsReporter {
|
|
107
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
108
|
+
private readonly intervalMs: number;
|
|
109
|
+
private readonly memoryGovernor: PackageMemoryGovernor | null;
|
|
110
|
+
|
|
111
|
+
constructor(
|
|
112
|
+
memoryGovernor: PackageMemoryGovernor | null,
|
|
113
|
+
intervalMs: number = DEFAULT_INTERVAL_MS,
|
|
114
|
+
) {
|
|
115
|
+
this.memoryGovernor = memoryGovernor;
|
|
116
|
+
this.intervalMs = intervalMs;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
public start(): void {
|
|
120
|
+
if (this.timer !== null) return;
|
|
121
|
+
// Immediate first sample so a freshly-started pod logs its
|
|
122
|
+
// baseline before the first 30s has elapsed.
|
|
123
|
+
void this.tick();
|
|
124
|
+
this.timer = setInterval(() => void this.tick(), this.intervalMs);
|
|
125
|
+
// Don't keep the event loop alive on our account — if everything
|
|
126
|
+
// else has shut down, the reporter shouldn't block exit.
|
|
127
|
+
(
|
|
128
|
+
this.timer as ReturnType<typeof setInterval> & {
|
|
129
|
+
unref?: () => void;
|
|
130
|
+
}
|
|
131
|
+
).unref?.();
|
|
132
|
+
logger.info(
|
|
133
|
+
`ProcessStatsReporter started (intervalMs=${this.intervalMs})`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
public stop(): void {
|
|
138
|
+
if (this.timer !== null) {
|
|
139
|
+
clearInterval(this.timer);
|
|
140
|
+
this.timer = null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private async tick(): Promise<void> {
|
|
145
|
+
try {
|
|
146
|
+
const mem = process.memoryUsage();
|
|
147
|
+
const proc =
|
|
148
|
+
process.platform === "linux" ? readLinuxProcStatus() : null;
|
|
149
|
+
const bun = await readBunJscStats();
|
|
150
|
+
const governor = this.memoryGovernor?.getStatus() ?? null;
|
|
151
|
+
|
|
152
|
+
logger.info("process stats", {
|
|
153
|
+
uptimeSeconds: Math.round(process.uptime()),
|
|
154
|
+
nodeMemory: {
|
|
155
|
+
rssBytes: mem.rss,
|
|
156
|
+
heapTotalBytes: mem.heapTotal,
|
|
157
|
+
heapUsedBytes: mem.heapUsed,
|
|
158
|
+
externalBytes: mem.external,
|
|
159
|
+
arrayBuffersBytes: mem.arrayBuffers,
|
|
160
|
+
},
|
|
161
|
+
linux: proc,
|
|
162
|
+
bunJsc: bun,
|
|
163
|
+
memoryGovernor: governor,
|
|
164
|
+
});
|
|
165
|
+
} catch (err) {
|
|
166
|
+
logger.warn("ProcessStatsReporter tick failed", { error: err });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker thread that owns one capped DuckDB connection and answers
|
|
3
|
+
* schema-introspection requests for parquet/csv files. Running this
|
|
4
|
+
* off the main thread isolates the native DuckDB thread pool — when
|
|
5
|
+
* the worker exits, its threads die with it, which puts a hard
|
|
6
|
+
* ceiling on the leak class that OOM-killed prod
|
|
7
|
+
* (worker-76b49bdb89-8bsv4: 466 leaked Bun Pool threads).
|
|
8
|
+
*
|
|
9
|
+
* Protocol (parent ↔ worker):
|
|
10
|
+
* parent → worker: { id, packagePath, databasePath }
|
|
11
|
+
* worker → parent: { id, ok: true, result: SchemaResult }
|
|
12
|
+
* | { id, ok: false, error: { message, stack? } }
|
|
13
|
+
*
|
|
14
|
+
* One request at a time per worker — the pool in the parent
|
|
15
|
+
* (`schema_worker_pool.ts`) handles fan-out. Keeping the worker
|
|
16
|
+
* single-threaded from the JS side matches DuckDB's behavior on a
|
|
17
|
+
* single connection and avoids head-of-line blocking inside the
|
|
18
|
+
* worker itself.
|
|
19
|
+
*/
|
|
20
|
+
import { DuckDBConnection } from "@malloydata/db-duckdb";
|
|
21
|
+
import "@malloydata/db-duckdb/native";
|
|
22
|
+
import {
|
|
23
|
+
ConnectionRuntime,
|
|
24
|
+
EmptyURLReader,
|
|
25
|
+
SourceDef,
|
|
26
|
+
} from "@malloydata/malloy";
|
|
27
|
+
import * as path from "path";
|
|
28
|
+
import { parentPort } from "worker_threads";
|
|
29
|
+
|
|
30
|
+
export interface SchemaRequest {
|
|
31
|
+
id: number;
|
|
32
|
+
packagePath: string;
|
|
33
|
+
databasePath: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SchemaResponse {
|
|
37
|
+
id: number;
|
|
38
|
+
ok: boolean;
|
|
39
|
+
result?: {
|
|
40
|
+
name: string;
|
|
41
|
+
rowCount: number;
|
|
42
|
+
columns: Array<{ type: string; name: string }>;
|
|
43
|
+
};
|
|
44
|
+
error?: { message: string; stack?: string };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!parentPort) {
|
|
48
|
+
// Defensive: schema_worker.ts must only be loaded as a worker. If
|
|
49
|
+
// someone accidentally imports it from the main thread the
|
|
50
|
+
// connection below would still allocate its native pool there,
|
|
51
|
+
// recreating the exact leak this file exists to fix.
|
|
52
|
+
throw new Error("schema_worker.ts loaded outside a worker thread");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// One DuckDB connection per worker, capped tight. Schema introspection
|
|
56
|
+
// reads parquet footers / csv headers — it does not need parallelism
|
|
57
|
+
// or a large memory arena. The cap is what keeps the per-worker
|
|
58
|
+
// native-thread cost bounded.
|
|
59
|
+
const connection = new DuckDBConnection({
|
|
60
|
+
name: "duckdb",
|
|
61
|
+
databasePath: ":memory:",
|
|
62
|
+
threads: 1,
|
|
63
|
+
memoryLimit: "256MB",
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
async function handleRequest(req: SchemaRequest): Promise<SchemaResponse> {
|
|
67
|
+
try {
|
|
68
|
+
const fullPath = path.join(req.packagePath, req.databasePath);
|
|
69
|
+
// DuckDB on Windows supports forward slashes, and this avoids
|
|
70
|
+
// escaping issues in the inline SQL below.
|
|
71
|
+
const normalizedPath = fullPath.replace(/\\/g, "/");
|
|
72
|
+
|
|
73
|
+
const runtime = new ConnectionRuntime({
|
|
74
|
+
urlReader: new EmptyURLReader(),
|
|
75
|
+
connections: [connection],
|
|
76
|
+
});
|
|
77
|
+
const model = runtime.loadModel(
|
|
78
|
+
`source: temp is duckdb.table('${normalizedPath}')`,
|
|
79
|
+
);
|
|
80
|
+
const modelDef = await model.getModel();
|
|
81
|
+
const fields = (modelDef._modelDef.contents["temp"] as SourceDef).fields;
|
|
82
|
+
const columns = fields.map((field) => ({
|
|
83
|
+
type: String(field.type),
|
|
84
|
+
name: field.name,
|
|
85
|
+
}));
|
|
86
|
+
|
|
87
|
+
const runner = model.loadQuery(
|
|
88
|
+
"run: temp->{aggregate: row_count is count()}",
|
|
89
|
+
);
|
|
90
|
+
const result = await runner.run();
|
|
91
|
+
const rowCount = result.data.value[0].row_count?.valueOf() as number;
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
id: req.id,
|
|
95
|
+
ok: true,
|
|
96
|
+
result: { name: req.databasePath, rowCount, columns },
|
|
97
|
+
};
|
|
98
|
+
} catch (err) {
|
|
99
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
100
|
+
return {
|
|
101
|
+
id: req.id,
|
|
102
|
+
ok: false,
|
|
103
|
+
error: { message: error.message, stack: error.stack },
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
parentPort.on("message", async (msg: SchemaRequest) => {
|
|
109
|
+
const response = await handleRequest(msg);
|
|
110
|
+
parentPort!.postMessage(response);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// On any termination signal, close the connection so DuckDB releases
|
|
114
|
+
// its native threads cleanly instead of leaking them past worker exit.
|
|
115
|
+
const shutdown = async () => {
|
|
116
|
+
try {
|
|
117
|
+
await connection.close();
|
|
118
|
+
} catch {
|
|
119
|
+
// best effort
|
|
120
|
+
}
|
|
121
|
+
process.exit(0);
|
|
122
|
+
};
|
|
123
|
+
parentPort.on("close", () => void shutdown());
|