This is an automated email from the ASF dual-hosted git repository.
CritasWang pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/iotdb-client-nodejs.git
The following commit(s) were added to refs/heads/develop by this push:
new c13f1e0 Fix session pool lifecycle bugs in
getSession/releaseSession/cleanupIdleSessions (#19)
c13f1e0 is described below
commit c13f1e074207ddb450fd697153a79a571358b3f4
Author: ZIHAN DAI <[email protected]>
AuthorDate: Fri Jul 24 13:22:17 2026 +1000
Fix session pool lifecycle bugs in
getSession/releaseSession/cleanupIdleSessions (#19)
Several related lifecycle bugs in BaseSessionPool where session bookkeeping
(pool / idleSessions / activeSessions / waitQueue) was updated
inconsistently
across await points:
- A timed-out getSession() waiter was never removed from the wait queue: the
timeout matched indexOf(resolve), but the queue holds a wrapper closure,
so
the match always failed. releaseSession() later shifted that dead waiter,
marked the session active, and resolved an already-rejected promise,
leaking
the session and eventually starving the pool.
- The create-new-session branch reclaimed the freshly created session with a
blind idleSessions.shift() (front), but createSession() pushes to the
back;
under interleaving (a session released into idle during the await) it
evicted
a different session and left the new one tracked as both idle and active.
- The idle-reuse branch added the session to activeSessions but never set
inUse=true (the new-session and waiter branches both do), so
syncDatabaseContextToPool treated an actively-in-use session as idle and
could
issue USE on it concurrently with the caller's in-flight request.
- cleanupIdleSessions() checked pool.length > minSize against the constant
pre-cleanup size, so it could queue every idle session and shrink the pool
below minPoolSize.
- cleanupIdleSessions() awaited session.close() before removing the session
from
pool/idle; since isOpen() stays true until the close RPC resolves, a
concurrent getSession() could hand out a session that was being destroyed.
Fix each (named/settled-guarded waiter with correct removal + release loop;
targeted idle removal; inUse on reuse; projected-size cleanup guard;
splice-before-close) and add deterministic regression tests.
Signed-off-by: Zihan Dai <[email protected]>
---
src/client/BaseSessionPool.ts | 108 ++++++++++-----
tests/unit/BaseSessionPoolLifecycle.test.ts | 205 ++++++++++++++++++++++++++++
2 files changed, 281 insertions(+), 32 deletions(-)
diff --git a/src/client/BaseSessionPool.ts b/src/client/BaseSessionPool.ts
index 8580daf..cfd0ff0 100644
--- a/src/client/BaseSessionPool.ts
+++ b/src/client/BaseSessionPool.ts
@@ -50,7 +50,7 @@ export abstract class BaseSessionPool {
protected config: PoolConfig;
protected endPoints: EndPoint[];
protected pool: PooledSession[] = [];
- protected waitQueue: Denque<(session: Session) => void> = new Denque();
+ protected waitQueue: Denque<(session: Session) => boolean> = new Denque();
protected idleSessions: Denque<PooledSession> = new Denque();
protected activeSessions: Set<PooledSession> = new Set();
protected currentEndPointIndex = 0;
@@ -237,6 +237,11 @@ export abstract class BaseSessionPool {
// Verify session is still open
if (pooledSession.session.isOpen()) {
this.activeSessions.add(pooledSession);
+ // Mark in use — the new-session and waiter branches both do this; the
+ // idle-reuse path omitting it left a reused, actively-in-use session
+ // with inUse===false, which syncDatabaseContextToPool (filters
+ // !inUse) would treat as idle and fire a concurrent USE on.
+ pooledSession.inUse = true;
pooledSession.lastUsed = Date.now();
const duration = Date.now() - startTime;
logger.debug(
@@ -261,7 +266,15 @@ export abstract class BaseSessionPool {
const session = await this.createSession();
const pooledSession = this.pool.find((ps) => ps.session === session);
if (pooledSession) {
- this.idleSessions.shift(); // Remove from idle since we just added it
+ // Remove *this* session from idle. createSession() pushed it to the
+ // back of idleSessions; a blind shift() removes the front, which under
+ // concurrent interleaving (a session released into idle while we were
+ // awaiting createSession) would evict a different session and leave
+ // this one double-tracked as both idle and active.
+ const idleIndex = this.idleSessions.toArray().indexOf(pooledSession);
+ if (idleIndex > -1) {
+ this.idleSessions.remove(idleIndex, 1);
+ }
this.activeSessions.add(pooledSession);
pooledSession.inUse = true;
}
@@ -276,9 +289,31 @@ export abstract class BaseSessionPool {
);
const waitTimeout = this.config.waitTimeout || 60000;
return new Promise((resolve, reject) => {
+ let settled = false;
+
+ // The queue stores this exact wrapper. On timeout we must remove *this*
+ // reference (not `resolve`, which is never in the queue), and the
+ // `settled` guard makes timeout and fulfillment mutually exclusive so a
+ // session is never handed to a waiter whose promise already rejected.
+ const waiter = (session: Session): boolean => {
+ if (settled) {
+ return false;
+ }
+ settled = true;
+ clearTimeout(timeoutId);
+ const duration = Date.now() - startTime;
+ logger.debug(`[PERF] getSession (waited): ${duration}ms`);
+ resolve(session);
+ return true;
+ };
+
const timeoutId = setTimeout(() => {
+ if (settled) {
+ return;
+ }
+ settled = true;
const waiters = this.waitQueue.toArray();
- const index = waiters.indexOf(resolve);
+ const index = waiters.indexOf(waiter);
if (index > -1) {
this.waitQueue.remove(index, 1);
}
@@ -290,12 +325,7 @@ export abstract class BaseSessionPool {
timeoutId.unref();
}
- this.waitQueue.push((session: Session) => {
- clearTimeout(timeoutId);
- const duration = Date.now() - startTime;
- logger.debug(`[PERF] getSession (waited): ${duration}ms`);
- resolve(session);
- });
+ this.waitQueue.push(waiter);
});
}
@@ -313,22 +343,27 @@ export abstract class BaseSessionPool {
pooledSession.inUse = false;
pooledSession.lastUsed = Date.now();
- // Check if there are waiting requests
- if (this.waitQueue.length > 0) {
+ // Hand the session to the first waiter that is still pending. A waiter
+ // whose promise already settled (e.g. it timed out) returns false; skip
+ // it and try the next one, so a released session is never leaked to a
+ // dead waiter (which would leave it marked active but held by nobody).
+ while (this.waitQueue.length > 0) {
const waiter = this.waitQueue.shift();
- if (waiter) {
- // Move to active for the waiter
- this.activeSessions.add(pooledSession);
- pooledSession.inUse = true;
- waiter(session);
- } else {
- // No waiter actually found, add back to idle
- this.idleSessions.push(pooledSession);
+ if (!waiter) {
+ continue;
}
- } else {
- // No waiters, add back to idle
- this.idleSessions.push(pooledSession);
+ this.activeSessions.add(pooledSession);
+ pooledSession.inUse = true;
+ if (waiter(session)) {
+ return;
+ }
+ // Stale waiter; undo the active bookkeeping and try the next one.
+ this.activeSessions.delete(pooledSession);
+ pooledSession.inUse = false;
}
+
+ // No live waiter; add back to idle.
+ this.idleSessions.push(pooledSession);
}
}
@@ -342,24 +377,33 @@ export abstract class BaseSessionPool {
const idleArray = this.idleSessions.toArray();
for (const ps of idleArray) {
- if (now - ps.lastUsed > maxIdleTime && this.pool.length > minSize) {
+ // Subtract already-queued removals so the pool never drops below
+ // minPoolSize: without this the guard sees the constant pre-cleanup size
+ // and can queue every idle session, collapsing the pool to 0.
+ if (
+ now - ps.lastUsed > maxIdleTime &&
+ this.pool.length - sessionsToRemove.length > minSize
+ ) {
sessionsToRemove.push(ps);
}
}
await Promise.all(
sessionsToRemove.map(async (ps) => {
+ // Remove from pool + idle BEFORE closing. close() awaits the
+ // closeSession RPC and isOpen() stays true until it resolves, so a
+ // concurrent getSession() could otherwise shift() this session and
+ // hand out a connection that is about to be destroyed.
+ const poolIndex = this.pool.indexOf(ps);
+ if (poolIndex > -1) {
+ this.pool.splice(poolIndex, 1);
+ }
+ const idleIndex = this.idleSessions.toArray().indexOf(ps);
+ if (idleIndex > -1) {
+ this.idleSessions.remove(idleIndex, 1);
+ }
try {
await ps.session.close();
- const poolIndex = this.pool.indexOf(ps);
- if (poolIndex > -1) {
- this.pool.splice(poolIndex, 1);
- }
- // Remove from idle sessions deque
- const idleIndex = this.idleSessions.toArray().indexOf(ps);
- if (idleIndex > -1) {
- this.idleSessions.remove(idleIndex, 1);
- }
logger.debug(`Removed idle session from ${this.getPoolName()}`);
} catch (error) {
logger.error("Error closing idle session:", error);
diff --git a/tests/unit/BaseSessionPoolLifecycle.test.ts
b/tests/unit/BaseSessionPoolLifecycle.test.ts
new file mode 100644
index 0000000..64bf2e6
--- /dev/null
+++ b/tests/unit/BaseSessionPoolLifecycle.test.ts
@@ -0,0 +1,205 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { SessionPool } from "../../src/client/SessionPool";
+
+/**
+ * Test pool that hands out lightweight fake sessions (no real IoTDB
+ * connection) so getSession()/releaseSession() lifecycle bookkeeping can be
+ * exercised deterministically. `createGate`, when set, lets a test hold a
+ * createSession() call open to interleave a concurrent release.
+ */
+class TestPool extends SessionPool {
+ public createGate: Promise<void> | null = null;
+ private counter = 0;
+
+ protected async createPoolSession(): Promise<any> {
+ if (this.createGate) {
+ await this.createGate;
+ }
+ const id = ++this.counter;
+ const session: any = {
+ id,
+ _closed: false,
+ // When set to a promise, close() parks on it while isOpen() keeps
+ // returning true — lets a test hold a close open to probe TOCTOU.
+ closeGate: null as Promise<void> | null,
+ isOpen: () => !session._closed,
+ close: async () => {
+ if (session.closeGate) {
+ await session.closeGate;
+ }
+ session._closed = true;
+ },
+ };
+ return session;
+ }
+
+ pooledFor(session: any): any {
+ return (this as any).pool.find((ps: any) => ps.session === session);
+ }
+ runCleanup(): Promise<void> {
+ return (this as any).cleanupIdleSessions();
+ }
+
+ idleSessionObjects(): any[] {
+ return (this as any).idleSessions.toArray().map((ps: any) => ps.session);
+ }
+ activeSessionObjects(): any[] {
+ return Array.from((this as any).activeSessions as Set<any>).map(
+ (ps: any) => ps.session,
+ );
+ }
+}
+
+function newPool(overrides: Record<string, unknown>): TestPool {
+ return new TestPool({
+ host: "localhost",
+ port: 6667,
+ minPoolSize: 0,
+ ...overrides,
+ } as any);
+}
+
+describe("BaseSessionPool lifecycle", () => {
+ it("does not leak a released session to a timed-out waiter (no starvation)",
async () => {
+ const pool = newPool({ maxPoolSize: 1, waitTimeout: 50 });
+
+ const s1 = await pool.getSession(); // creates S1; pool is now full (1/1)
+
+ // Pool is full, so this acquisition waits and then times out.
+ await expect(pool.getSession()).rejects.toThrow(/Timeout/);
+
+ // Releasing S1 must return it to the pool, not hand it to the dead waiter
+ // (which would mark S1 active-but-held-by-nobody and starve the pool).
+ pool.releaseSession(s1);
+
+ // S1 must be acquirable again. On the buggy code this getSession() starves
+ // and rejects with a timeout.
+ const s2 = await pool.getSession();
+ expect(s2).toBe(s1);
+
+ await pool.close();
+ });
+
+ it("create-branch removes the new session, not a concurrently-released idle
one", async () => {
+ const pool = newPool({ maxPoolSize: 3, waitTimeout: 1000 });
+
+ const s1 = await pool.getSession(); // create S1; active, idle=[]
+
+ // Hold the next createSession() open so we can release S1 mid-flight.
+ let openGate!: () => void;
+ pool.createGate = new Promise<void>((resolve) => {
+ openGate = resolve;
+ });
+
+ const acquireA = pool.getSession(); // enters create-branch, awaits the
gate
+ // One yield is enough: getSession() runs synchronously up to the first
+ // await (the createSession call), so after this tick acquireA is parked on
+ // the gate and the release below interleaves before it resumes.
+ await new Promise((r) => setImmediate(r));
+
+ // Concurrent release pushes S1 to the FRONT of idle while A is awaiting.
+ pool.releaseSession(s1); // idle=[S1]
+
+ openGate(); // A's createSession resolves -> pushes S2 -> idle=[S1,S2]
+ const s2 = await acquireA;
+
+ expect(s2).not.toBe(s1);
+ // S1 must remain the idle session; S2 was handed to A and must not also
+ // linger in idle (the blind shift() bug evicted S1 and left S2 in idle).
+ expect(pool.idleSessionObjects()).toContain(s1);
+ expect(pool.idleSessionObjects()).not.toContain(s2);
+ expect(pool.activeSessionObjects()).toContain(s2);
+
+ await pool.close();
+ });
+
+ it("marks a reused idle session as inUse", async () => {
+ const pool = newPool({ maxPoolSize: 2 });
+
+ const s1 = await pool.getSession();
+ pool.releaseSession(s1); // back to idle, inUse=false
+ const s2 = await pool.getSession(); // idle-reuse branch
+
+ expect(s2).toBe(s1);
+ // The reused session is handed to a caller, so it must be inUse; the
+ // idle-reuse branch used to skip this, leaving it false while active.
+ expect(pool.pooledFor(s2).inUse).toBe(true);
+
+ await pool.close();
+ });
+
+ it("cleanupIdleSessions never shrinks the pool below minPoolSize", async ()
=> {
+ const pool = newPool({ maxPoolSize: 5, minPoolSize: 1, maxIdleTime: 1 });
+
+ const a = await pool.getSession();
+ const b = await pool.getSession();
+ const c = await pool.getSession();
+ pool.releaseSession(a);
+ pool.releaseSession(b);
+ pool.releaseSession(c);
+ // Make every session look long-idle so all three qualify for cleanup.
+ for (const ps of (pool as any).pool) {
+ ps.lastUsed = 0;
+ }
+
+ await pool.runCleanup();
+
+ // Must retain minPoolSize; the buggy guard (constant pre-cleanup size)
+ // removed all three and collapsed the pool to 0.
+ expect(pool.getPoolSize()).toBe(1);
+
+ await pool.close();
+ });
+
+ it("cleanupIdleSessions does not hand out a session that is being closed",
async () => {
+ // minPoolSize=1 (0 would coerce to 1 anyway), 2 idle sessions so cleanup
+ // removes exactly one (the first-queued, s1) and keeps one warm.
+ const pool = newPool({ maxPoolSize: 3, minPoolSize: 1, maxIdleTime: 1 });
+
+ const s1 = await pool.getSession();
+ const s2 = await pool.getSession();
+ pool.releaseSession(s1); // idle=[s1]
+ pool.releaseSession(s2); // idle=[s1, s2]
+ for (const ps of (pool as any).pool) {
+ ps.lastUsed = 0; // both qualify as long-idle
+ }
+
+ // Gate s1's close so it stays "closing" (isOpen()===true) across an await.
+ let openClose!: () => void;
+ (s1 as any).closeGate = new Promise<void>((r) => {
+ openClose = r;
+ });
+
+ const cleanup = pool.runCleanup(); // removes s1 (down to minSize=1),
gated close
+ await new Promise((r) => setImmediate(r)); // let cleanup reach the close
await
+
+ // A concurrent acquire must NOT receive the session being closed. On the
+ // buggy close-then-splice, s1 stays in idle during close() and shift()
+ // hands it out.
+ const acquired = await pool.getSession();
+ expect(acquired).not.toBe(s1);
+
+ openClose(); // let the close finish
+ await cleanup;
+
+ await pool.close();
+ });
+});