Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .github/workflows/chdb-node-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ on:

# update_libchdb.sh pins a published chdb-core libchdb across all four target
# platforms, so the full v2 + v3 suite is expected green here. The pin is
# currently v26.7.3, the first stable release to export the Durable V1 ABI
# (chdb_backup_database_n / chdb_restore_database_n / chdb_classify_query_n);
# v26.7.2-rc.2 was the candidate that introduced them.
# currently the v26.11.0-rc.2 release candidate. v26.7.3 was the first stable
# release to export the Durable V1 ABI (chdb_backup_database_n /
# chdb_restore_database_n / chdb_classify_query_n); v26.7.2-rc.2 was the
# candidate that introduced them.

# Harden the registry fetch so a transient reset (e.g. ECONNRESET during
# install) retries instead of reddening the job. Combined with the per-job
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ and the sync policy with `@clickhouse/client`.
> is injected; Node callers get a default engine over this package's own addon
> from `chdb/durable/node`.
>
> Requires an engine exporting the durable ABI — currently `26.7.3`.
> Requires an engine exporting the durable ABI — currently `26.11.0-rc.2`.
> Compatibility is a floor rather than an equality: an object records
> `min_reader` and `backup_format`, and any engine at or above that floor opens
> it. An object written by `26.7.2-rc.2` stays readable on `26.7.3` and later.
Expand Down
2 changes: 1 addition & 1 deletion docs/design/durable-control-plane.md
Original file line number Diff line number Diff line change
Expand Up @@ -724,7 +724,7 @@ this package:

1. Publish `@chdb/lib-<platform>` packages built from a tree that binds the
durable ABI. `optionalDependencies` and `update_libchdb.sh` already name
`26.7.3-stable.1`, and the release workflow derives the subpackage version
`26.11.0-rc.2.1`, and the release workflow derives the subpackage version
from the latter — but nothing has been tagged since, so that version is not
on npm at all. The newest published is `26.7.0-stable.1` (main `chdb@3.3.0`
pins it), which carries an addon from before this work and exports none of
Expand Down
26 changes: 24 additions & 2 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const chdbNode = require('./dist/loader.js').loadNative();
const { mkdtempSync, rmSync, realpathSync } = fs;
const { join, resolve: resolvePath } = require('path');
const os = require('os');
const { isMainThread } = require('worker_threads');
const {
ChdbConnectionError,
ChdbClosedError,
Expand Down Expand Up @@ -568,12 +569,33 @@ let exitHookInstalled = false;
function ensureExitHook() {
if (exitHookInstalled) return;
exitHookInstalled = true;
process.on('exit', () => {
const teardown = () => {
for (const s of openSessions) {
try { s.close(); } catch (_) { /* best effort */ }
}
});
// Then what the sessions do not cover: the standalone default connection,
// which the addon opens on first use, and any connection close() had to
// defer. The addon closes the idle ones and stops the engine here, before
// C exit(), because its atexit backstop runs after statics the engine
// still needs are destroyed: a process.exit() with MergeTree merges in
// flight on the default connection crashed there. As with the sessions, an
// 'exit' listener that runs after this one finds the engine closed. Main
// thread only: the connections and the engine are process-wide, and a
// worker that exits must not close them under the main thread.
if (isMainThread && typeof chdbNode.ExitTeardown === 'function') {
try { chdbNode.ExitTeardown(); } catch (_) { /* best effort */ }
}
};
process.on('exit', teardown);
// Deno.exit() skips the process 'exit' event and dispatches 'unload'
// instead. Running both is harmless: the second finds nothing left to do.
if (typeof Deno !== 'undefined' && typeof globalThis.addEventListener === 'function') {
globalThis.addEventListener('unload', teardown);
}
}
// Installed up front rather than with the first Session: the standalone
// functions reach the engine through that default connection without one.
ensureExitHook();

// Session class with connection-based path handling
const SESSION_SIGNALS = ['SIGINT', 'SIGTERM'];
Expand Down
124 changes: 108 additions & 16 deletions lib/chdb_node.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#include <string.h>
#include <iostream>
#include <memory>
#include <atomic>
#include <mutex>
#include <set>
#include <unordered_map>
Expand Down Expand Up @@ -201,8 +202,9 @@ class ConnGuard {
static void erase_arrow_tables_for_conn(chdb_connection *conn);
static void clear_all_arrow_tables();

// Close every live connection. atexit backstop (the JS layer closes sessions on
// 'exit' too); also used so a leaked connection never outlives the process.
// Close every live connection. atexit backstop (the JS layer closes sessions and
// calls ExitTeardownWrapper on 'exit' first); also used so a leaked connection
// never outlives the process.
static void hard_close_all() {
std::lock_guard<std::mutex> lk(g_reg_mu);
clear_all_arrow_tables();
Expand All @@ -214,20 +216,50 @@ static void hard_close_all() {
g_reg.boundKey.clear();
}

// Node's environment cleanup closes connections, but the engine may still own
// background threads when C++ static destructors run. On macOS with core
// v26.9.0, a CLI that queried once could hang indefinitely inside exit().
// Run this process-wide backstop before the engine's own exit handlers.
// The atexit backstop: close whatever is still open when the process reaches C
// exit(), then stop the engine, so it owns no background threads while C++
// static destructors run. On macOS with core v26.9.0, a CLI that queried once
// could hang indefinitely inside exit() without it.
//
// It is only the fallback. index.js calls ExitTeardownWrapper (below) from the
// process 'exit' event, which closes everything it safely can before exit().
// atexit handlers and static destructors run in reverse order of registration,
// and this one is registered once, at the first connect, so it runs after two
// things registered later: the exit teardown the engine registers on every
// start (since chdb-core v26.11.0-rc.2), so after an engine restart that runs
// first, and every function-local static constructed after the first connect,
// including ones a query or a MergeTree merge constructed. Closing a connection
// here tears the server down onto those destroyed statics; with merges in
// flight, a process.exit() on the default connection crashed in this handler.
//
// chdb_shutdown() is one of them: it keeps a function-local static mutex,
// constructed by its first call. Once ExitTeardownWrapper has called it, a
// second call from here locks that mutex after its destructor ran, which macOS
// reports as "mutex lock failed: Invalid argument" and aborts. So the exit
// paths call it once between them.
static std::atomic<bool> g_exit_shutdown_called{false};

static void shutdown_engine_once() {
if (!g_exit_shutdown_called.exchange(true)) (void)chdb_shutdown();
}

static void shutdown_on_process_exit() {
hard_close_all();
(void)chdb_shutdown();
shutdown_engine_once();
}

// Set at the first connect. Guarded by g_reg_mu: both callers hold it.
static bool g_atexit_registered = false;

// Environments that loaded the addon and have not been torn down: the main
// thread, and each worker_threads Worker that required it. Init counts one in,
// its env cleanup hook counts it out. ExitTeardownWrapper reads it.
static std::atomic<int> g_live_envs{0};

static void ensure_atexit() {
static bool done = false;
if (!done) {
if (!g_atexit_registered) {
std::atexit(shutdown_on_process_exit);
done = true;
g_atexit_registered = true;
}
}

Expand All @@ -253,8 +285,9 @@ static chdb_connection *get_default_conn(char **error_message) {
g_reg.defaultConn = c;
g_reg.boundKey = "";
g_reg.conns.insert(c);
// Register only after chdb_connect initialized the engine: atexit handlers
// run in reverse registration order, so shutdown must precede its teardown.
// Register only after chdb_connect started the engine, so the backstop runs
// before the exit teardown that start registered (see shutdown_on_process_exit
// for what it still runs after).
ensure_atexit();
return c;
}
Expand Down Expand Up @@ -1760,6 +1793,56 @@ Napi::Value EngineVersionWrapper(const Napi::CallbackInfo &info) {
return Napi::String::New(env, version);
}

// Exit teardown, called by index.js from the main thread's process 'exit'
// event: the last point before Node calls C exit(), where every static is still
// alive (shutdown_on_process_exit explains why the atexit backstop is too late).
// index.js has closed its sessions by then. This closes what is left, the
// standalone default connection above all, and then stops the engine, so
// nothing of chDB runs into Node's own teardown.
//
// A connection an async call is still running on (the in-flight count) stays
// open: closing it under the call aborts the engine, and Node joins that libuv
// thread only after this returns. For those, the backstop is registered once
// more. exit() then runs it first: after Node has joined the thread, and before
// the statics that the first registration runs after.
//
// While another environment has the addon loaded (a worker_threads Worker),
// nothing is closed: that thread may be using any connection, its own sessions
// or the shared default, and synchronous calls are not in the in-flight count.
// Node stops it only after this returns, so closing one here freed it under
// the worker's next query. The backstop is registered again instead; by the
// time exit() runs it, Node has stopped and joined every worker.
Napi::Value ExitTeardownWrapper(const Napi::CallbackInfo &info) {
bool started;
bool busy = g_live_envs.load() > 1;
{
std::lock_guard<std::mutex> lk(g_reg_mu);
started = g_atexit_registered;
for (auto it = g_reg.conns.begin(); !busy && it != g_reg.conns.end();) {
chdb_connection *c = *it;
if (c && inflight_locked(*c) > 0) {
busy = true;
++it;
continue;
}
if (c) {
erase_arrow_tables_for_conn(c);
chdb_close_conn(c);
}
if (c == g_reg.defaultConn) g_reg.defaultConn = nullptr;
it = g_reg.conns.erase(it);
}
if (g_reg.conns.empty()) g_reg.boundKey.clear();
}
if (started) {
if (busy)
std::atexit(shutdown_on_process_exit);
else
shutdown_engine_once();
}
return info.Env().Undefined();
}

Napi::Object Init(Napi::Env env, Napi::Object exports) {
// Export the functions
exports.Set("Query", Napi::Function::New(env, QueryWrapper));
Expand Down Expand Up @@ -1796,10 +1879,19 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) {
exports.Set("ArrowRegisterColumns", Napi::Function::New(env, ArrowRegisterColumnsWrapper));
exports.Set("ArrowUnregister", Napi::Function::New(env, ArrowUnregisterWrapper));

// Most-reliable exit cleanup: close every live connection on env teardown,
// ahead of (and complementary to) the std::atexit backstop. Both call the
// idempotent hard_close_all, so running twice is harmless.
env.AddCleanupHook([] { hard_close_all(); });
// For index.js's process 'exit' hook.
exports.Set("ExitTeardown", Napi::Function::New(env, ExitTeardownWrapper));

// Close every live connection on env teardown, ahead of the std::atexit
// backstop. Both call the idempotent hard_close_all, so running twice is
// harmless. Node runs no cleanup hooks on process.exit(), which is one reason
// index.js closes on the 'exit' event instead (ExitTeardownWrapper). The
// hook also counts this environment out of g_live_envs.
g_live_envs.fetch_add(1);
env.AddCleanupHook([] {
g_live_envs.fetch_sub(1);
hard_close_all();
});

return exports;
}
Expand Down
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,10 @@
"node-gyp-build": "^4.6.0"
},
"optionalDependencies": {
"@chdb/lib-darwin-arm64": "26.7.3-stable.1",
"@chdb/lib-darwin-x64": "26.7.3-stable.1",
"@chdb/lib-linux-arm64-gnu": "26.7.3-stable.1",
"@chdb/lib-linux-x64-gnu": "26.7.3-stable.1"
"@chdb/lib-darwin-arm64": "26.11.0-rc.2.1",
"@chdb/lib-darwin-x64": "26.11.0-rc.2.1",
"@chdb/lib-linux-arm64-gnu": "26.11.0-rc.2.1",
"@chdb/lib-linux-x64-gnu": "26.11.0-rc.2.1"
},
"peerDependencies": {
"@clickhouse/client": ">=1.23.0",
Expand Down
Loading
Loading