Repository navigation
feat(l1): multi-connection model + async-stress timeout stabilization - #51
Conversation
Replace the single-active-connection registry with one that allows N independent connections to the same process-wide EmbeddedServer path, so same-path Sessions execute queries in parallel instead of serializing on one shared ChdbClient's per-instance mutex. - native (lib/chdb_node.cpp): g_active single slot -> registry of N connections per bound path; a different data directory is still rejected; the last connection out unbinds the server. Guard mutex added. - index.js: each Session owns its own native connection (also fixes a latent same-path clobber). Non-parameterized async queries run in parallel across connections. Parameterized queries are serialized PROCESS-WIDE (one globalParamChain), because the bundled libchdb does not isolate parameter set/reset per connection under true parallelism — concurrent param queries on different connections otherwise clobber (456 Substitution not set) and can fatally abort the engine (236). The GIL hides this in thread-based bindings (chdb-python); libuv exposes it. - test/v3/multi-connection.test.ts: same-path coexistence, data sharing, different-path rejection, param correctness under concurrency, and real parallelism (~3.7x on 4 cores). - docs: layer1-native-binding.md §9.6.1 documents the model and root cause. This matches chdb-python's model (one ChdbClient per Connection). The prior binding refcount-collapsed every same-path Session onto one ChdbClient, whose client_mutex serialized all queries — which is why node did not parallelize while chdb-python did (verified: the same libchdb runs concurrent chdb_query_n in parallel from independent connections). (Cherry-picked from b01ae32 onto feat/layer2-pluggable-backend. The original commit also touched src/layer2/create_client.ts; this branch removes the byte-compat Layer 2 surface entirely in favor of a pluggable Connection module added in subsequent commits, so the L2 consumer update is intentionally dropped.) Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
…mark The multi-connection suite referenced Layer 2 (`createClient`), which does not exist on this Layer-1-only branch, and asserted a wall-clock speedup that CI cannot observe: CI installs the published `@chdb/lib-*` prebuilt (still the old single-connection registry) and the loader prefers it over `build/Release`, so no parallel speedup shows up there until the platform packages are republished. - Drop the Layer 2 (`createClient`) describe block; it belongs in the Layer 2 PR. - Make the parallel-speedup case an opt-in benchmark (`CHDB_BENCH`) instead of a CI gate; the deterministic correctness-under-concurrency test stays the gate. - Tighten the file header (non-param queries parallel; params serialized). Fixes the failing `test` jobs on PR #51 (createClient TypeError + speedup AssertionError); the remaining multi-connection assertions already passed. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
|
@chibugai review this |
There was a problem hiding this comment.
Pull request overview
Enables Layer 1 same-path session parallelism by moving from a single “active connection” model to a multi-connection registry (one EmbeddedServer path per process, N connections per same path), while stabilizing async-stress tests on slower CI runners.
Changes:
- Native: replace single-slot active connection with a guarded registry supporting multiple connections to the same bound path.
- JS: make each
Sessionown its own native connection; serialize parameterized async queries process-wide viaglobalParamChain. - Tests/docs: add multi-connection coverage, increase stress-test timeouts, and document the new model.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
lib/chdb_node.cpp |
Implements multi-connection registry + cleanup changes. |
index.js |
Updates Session ownership model and parameterized async serialization strategy. |
test/v3/multi-connection.test.ts |
Adds same-path coexistence/parallelism and param-concurrency tests. |
test/v3/async-stress.test.ts |
Increases timeouts for long-running stress/race cases. |
docs/design/layer1-native-binding.md |
Documents the new multi-connection model and param-query serialization rationale. |
Comments suppressed due to low confidence (1)
index.js:251
runExclusiveParam()always callsstartNative()once it reaches the head of the chain, even if the caller’s AbortSignal already fired or the timeout already elapsed while waiting behind earlier parameterized queries. With a process-wide chain this can execute queries (including DDL/DML) after the caller already observed CHDB_ABORT/CHDB_TIMEOUT, which is surprising and can cause unintended side effects. Add an abort/timeout pre-check at execution time to skip starting the native call once the request is already cancelled/expired.
function runExclusiveParam(chain, startNative, opts) {
const nativeP = chain.tail.then(() => startNative());
chain.tail = nativeP.then(() => {}, () => {});
return withAbortTimeout(nativeP, opts);
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Reviewed at Native registry (
Worth fixing (concurring, not re-flagging inline): I independently landed on the same issue as the existing review note on the No net-new blocking issues from my pass. |
Combines the prior fixup commits (71c5aeb, a51768f) with the responses to Copilot's review on PR #51, and removes the wall-clock benchmark that never runs on CI. - async-stress: longer timeouts for slow CI runners (was 71c5aeb). - multi-connection test: drop the Layer-2 (`createClient`) describe block; it belongs in the Layer 2 PR (was a51768f). - review #1 (test/v3/multi-connection.test.ts:67): assert on the stable .name/.code contract instead of `chdb.ChdbConnectionError` (the Layer 1 entrypoint does not re-export the class — toThrow against undefined was asserting "throws anything" rather than the intended failure mode). Matches registry.test.ts:54. - review #2 (lib/chdb_node.cpp): the native registry comment claimed per-connection parameter isolation, but the JS layer explicitly serializes ALL parameterized async queries process-wide via `globalParamChain` because the bundled libchdb cannot isolate parameter set/reset under true parallelism. The comment now matches the implemented behavior. - review #3 (index.js, runExclusiveParam): pre-check the caller's AbortSignal/timeout at the moment the chain head fires, so a queued param query whose caller already observed CHDB_ABORT/CHDB_TIMEOUT does NOT dispatch the native call when its turn comes up. The chain still advances on the guarded promise; non-param queries are unaffected. New regression test asserts the behavior under a head-of-chain congestion scenario. - drop the speedup wall-clock benchmark: it was skipped in CI (CHDB_BENCH is never set), and even when enabled it can't measure the multi-connection effect there — the loader picks the published @chdb/lib-* prebuilt over build/Release, so CI runs the old single-connection registry regardless. The deterministic concurrency-correctness test is the real gate. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
a51768f to
6371b6d
Compare
|
Addressing the suppressed (low-confidence) review note about
function runExclusiveParam(chain, startNative, opts) {
const signal = opts && opts.signal;
const timeout = opts && opts.timeout;
const deadline = timeout ? Date.now() + timeout : 0;
const guardedStart = () => {
if (signal && signal.aborted) {
return Promise.reject(new ChdbAbortError(
"Query aborted before execution (queued behind earlier parameterized queries)"));
}
if (deadline && Date.now() >= deadline) {
return Promise.reject(new ChdbTimeoutError(
`Query timed out (${timeout}ms) before execution (queued behind earlier parameterized queries)`));
}
return startNative();
};
const nativeP = chain.tail.then(guardedStart);
chain.tail = nativeP.then(() => {}, () => {});
return withAbortTimeout(nativeP, opts);
}Notes:
|
Add the pluggable Connection surface (`chdb/connection`) so a user can
plug in-process chDB into `@clickhouse/client`:
import { createClient } from '@clickhouse/client'
import { createChdbConnection } from 'chdb/connection'
const client = createClient({
connection: createChdbConnection({ path: ':memory:' }),
})
ChdbConnection implements `@clickhouse/client-common`'s published
`Connection<Stream.Readable>` interface EXACTLY — same method
signatures, same result shapes, same error semantics. chdb-node
does NOT define its own Connection / ConnQueryResult / ConnInsertParams
/ … types; they are re-exported verbatim from `@clickhouse/client-common`
(now a devDependency). Bumping that package forces a deliberate
decision about which version of the contract we conform to.
Implementation:
src/connection/connection.ts re-export the real types from
@clickhouse/client-common + the
chdb-only ChdbConnectionOptions
src/connection/chdb-connection.ts ChdbConnection class that wraps
Layer 1's Session and bridges:
- query/exec materialize via
Session.queryAsync (eager-buffered;
errors at `await`, not mid-stream)
- insert reconstructs the
`${params.query}\n${body}` inline
INSERT … FORMAT X\n<body> shape
chDB's parser accepts
- ping(params) executes SELECT 1
- query_id synthesized as UUIDv4
- response_headers={}, http_status_code=200
(synthetic for the no-wire embedded path)
src/connection/index.ts public exports
package.json ./connection subpath; @clickhouse/client-common
devDependency
`:memory:` path uses a process-shared, reference-counted temp dir so N
ChdbConnections coexist and see each other's data (matching
clickhouse-js's `chdb://memory` shared-state semantics; the
multi-connection model from PR chdb-io#51 lets N native connections share the
same bound path).
Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
Add the pluggable Connection surface (`chdb/connection`) so a user can
plug in-process chDB into `@clickhouse/client`:
import { createClient } from '@clickhouse/client'
import { createChdbConnection } from 'chdb/connection'
const client = createClient({
connection: createChdbConnection({ path: ':memory:' }),
})
ChdbConnection implements `@clickhouse/client-common`'s published
`Connection<Stream.Readable>` interface EXACTLY — same method
signatures, same result shapes, same error semantics. chdb-node
does NOT define its own Connection / ConnQueryResult / ConnInsertParams
/ … types; they are re-exported verbatim from `@clickhouse/client-common`
(now a devDependency). Bumping that package forces a deliberate
decision about which version of the contract we conform to.
Implementation:
src/connection/connection.ts re-export the real types from
@clickhouse/client-common + the
chdb-only ChdbConnectionOptions
src/connection/chdb-connection.ts ChdbConnection class that wraps
Layer 1's Session and bridges:
- query/exec materialize via
Session.queryAsync (eager-buffered;
errors at `await`, not mid-stream)
- insert reconstructs the
`${params.query}\n${body}` inline
INSERT … FORMAT X\n<body> shape
chDB's parser accepts
- ping(params) executes SELECT 1
- query_id synthesized as UUIDv4
- response_headers={}, http_status_code=200
(synthetic for the no-wire embedded path)
src/connection/index.ts public exports
package.json ./connection subpath; @clickhouse/client-common
devDependency
`:memory:` path uses a process-shared, reference-counted temp dir so N
ChdbConnections coexist and see each other's data (matching
clickhouse-js's `chdb://memory` shared-state semantics; the
multi-connection model from PR chdb-io#51 lets N native connections share the
same bound path).
Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
What
Two related Layer 1 changes, split out from the longer-running feat/layer2-clickhouse-js-compat work so they can land independently:
1.
feat(l1): multi-connection model — same-path Sessions run in parallelReplace the single-active-connection registry with one that allows N independent connections to the same process-wide EmbeddedServer path, so same-path Sessions execute queries in parallel instead of serializing on one shared
ChdbClient's per-instance mutex.lib/chdb_node.cpp):g_activesingle slot → registry of N connections per bound path; a different data directory is still rejected; the last connection out unbinds the server. Guard mutex added.Sessionowns its own native connection (also fixes a latent same-path clobber). Non-parameterized async queries run in parallel across connections. Parameterized queries are serialized process-wide (oneglobalParamChain), because the bundledlibchdbdoes not isolate parameter set/reset per connection under true parallelism — concurrent param queries on different connections otherwise clobber (456 Substitution not set) and can fatally abort the engine (236). The GIL hides this in thread-based bindings (chdb-python); libuv exposes it.test/v3/multi-connection.test.tscovers same-path coexistence, data sharing, different-path rejection, param correctness under concurrency, and real parallelism (~3.7× on 4 cores).layer1-native-binding.md §9.6.1documents the model and root cause.This matches chdb-python's model (one
ChdbClientperConnection). The prior binding refcount-collapsed every same-pathSessiononto oneChdbClient, whoseclient_mutexserialized all queries — which is why node did not parallelize while chdb-python did (verified: the samelibchdbruns concurrentchdb_query_nin parallel from independent connections).2.
test(l1): stabilize async-stress timeouts on slow CI runnersBumps the async-stress test's wall-clock budget so a slow CI runner finishes the stress matrix instead of getting killed mid-flight. No change to what the suite exercises — it still hits the same parallelism/cancellation surfaces; only the per-test deadline grows.
Scope
Why two commits in one PR
The async-stress timeout fix was a direct follow-up after the multi-connection model exposed a slow-path regression on resource-constrained CI runners. They share the same surface (concurrent async ops on a shared engine) and benefit from being reviewed together.
Follow-up
A separate PR will replace the current
feat/layer2-clickhouse-js-compatbyte-compat façade with a pluggableConnectiondesign (one public client API in@clickhouse/clientwithchdb-node'sChdbConnectioninjected via aconnectionoption). The design discussion is in clickhouse-js#865 and the parallel Python proposal is in clickhouse-connect#809. That PR will sit on top of this one.Generated with Claude Code
via Happy
Co-Authored-By: Claude noreply@anthropic.com
Co-Authored-By: Happy yesreply@happy.engineering