Repository navigation
Conversation
Track borrowed table sessions until release and close them when the pool closes. Make wrapper close idempotent and preserve pool capacity on failure paths.
Return the borrowed session when sortTablet succeeds or throws, and remove an unused connection-exception cleanup overload.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #19247 +/- ##
============================================
+ Coverage 48.89% 49.04% +0.14%
Complexity 689 689
============================================
Files 5588 5590 +2
Lines 413444 414011 +567
Branches 54537 54645 +108
============================================
+ Hits 202154 203052 +898
+ Misses 211290 210959 -331 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Coordinate pool close with session borrowing and release to verify that sessions are not returned to a closed pool.
|
JackieTien97
left a comment
There was a problem hiding this comment.
The lifecycle problems described in this PR are real. I checked the fixes with a fake Thrift server probe (a mock IClientRPCService.Iface behind a TThreadPoolServer, plus mocked Sessions), running the same probes against the merge base and against this head:
| Scenario | merge base | this PR |
|---|---|---|
pool.close() while a table session is borrowed |
session left open | session closed |
sql_dialect restore fails, maxSize=1 |
size=1 and empty queue, so the pool is exhausted for good |
capacity kept |
tree-model result set closed after pool.close() |
session pushed into the closed pool's queue | session closed |
ITableSession.close() after a connection failure |
NPE | returns normally |
There is one new problem, though. pool.close() now waits for in-flight RPCs on borrowed table sessions, and it holds the pool monitor while it waits (see the comment on close()). On this head, with one table session running a statement that the server holds for about 4.5 s, close() took 4528 ms, and a concurrent getSession() was blocked on the monitor for 1.5 s before it failed with Session pool is closed. On the merge base, close() returned in 0 ms. Because connectionTimeoutInMs defaults to 0 (no socket read timeout), one stuck RPC can hang close(), and every other borrow and return, indefinitely. I think this should be fixed before merging.
The other comments are smaller:
- The tree-model query path still has the close race that this PR fixes for table sessions.
- The wrapper still throws an NPE if it is reused after a connection failure.
- Two of the capacity fixes have no test.
- A few nits, marked as such.
CI: the only failing IT job is IoTDBLoadTsFileClusterIT.testRestartedDataNodeIsSyncedWithTheLoadedFile (tree model, 1C3D), which looks unrelated. The new unit tests and table-model ITs ran and passed.
| // do nothing | ||
| LOGGER.warn(CLOSE_THE_SESSION_FAILED, e); | ||
| } | ||
| closeSession(session); |
There was a problem hiding this comment.
Borrowed table sessions are now tracked in occupied, so this loop closes sessions that other threads may be using at that moment, and it holds the pool monitor while doing so. Session.close() calls SessionConnection.close(), which sends closeSession through the synchronized client proxy (SynchronizedHandler locks on the client). The call therefore waits until the other thread's in-flight RPC returns.
Probe: a fake server, a table pool with maxSize=2, and one borrowed session running a statement that the server holds for about 4.5 s.
- This PR:
close()returned after 4528 ms. A concurrentgetSession()waited on the pool monitor for 1.5 s and then gotSession pool is closed. - Merge base:
close()returned in 0 ms, because borrowed table sessions were not touched here.
connectionTimeoutInMs defaults to 0, which means no socket read timeout. One stuck RPC can therefore hang close() indefinitely. Everything that needs the monitor hangs with it: getSession(), putBack(), releaseSession(), and TableSessionWrapper.close() (through isPoolClosed()).
Suggestion: under the lock, set closed = true and take and clear snapshots of queue and occupied. Close the snapshots after leaving the monitor. For sessions that are still in use, consider closing the transport directly instead of queueing a closeSession RPC behind the in-flight call. SessionConnection already has forceCloseTransport().
| closeSession(session); | ||
| throw new IoTDBConnectionException(SESSION_POOL_IS_CLOSED); | ||
| } | ||
| occupied.put(session, session); |
There was a problem hiding this comment.
This fixes the borrow/close race for table sessions, but the tree-model query path still has it. The 12 query methods call occupy(session) after the RPC returns, and occupy() does not check closed. If close() runs while the query RPC is in flight, the session goes into occupied after close() has already cleared it.
Probe: executeQueryStatement blocked inside the RPC, then pool.close(), then the RPC returns. The wrapper is still returned, session.close() was called 0 times, and currentOccupiedSize() is 1 on the closed pool. The new releaseSession() does close the session once the caller closes the result set. If the caller relies on pool.close() for cleanup, for example by abandoning result sets at shutdown, the connection stays open.
Could occupy() do the same check under the lock as this code, closing the session and throwing if the pool is closed? Another option is to register every borrowed session in getSession() itself, under the lock that already checks closed. That would also remove the window between the two lock acquisitions here.
| @Override | ||
| public void insert(Tablet tablet) throws StatementExecutionException, IoTDBConnectionException { | ||
| try { | ||
| session.insertRelationalTablet(tablet); |
There was a problem hiding this comment.
releaseOnConnectionFailure() and close() both set session to null. If the same wrapper is called again, this line dereferences null and throws NullPointerException instead of an IoTDBConnectionException. Both executeQueryStatement overloads and executeNonQueryStatement behave the same way.
Probe: the first executeNonQueryStatement fails with IoTDBConnectionException. The second throws NullPointerException: Cannot invoke "org.apache.iotdb.session.Session.executeNonQueryStatement(String)" because "this.session" is null. The merge base behaves the same, so this is not a regression. Still, a caller that retries on the same ITableSession after a connection error will hit it, and this PR already makes close() safe after a connection failure. A guard at the top of these methods that throws IoTDBConnectionException, with the message as an i18n constant, would make the behavior consistent.
| } | ||
|
|
||
| sessionPool.putBack(session); | ||
| sessionPool.releaseSession(sessionToRelease, reusable); |
There was a problem hiding this comment.
Two of the fixes listed in the description have no test:
- Restoring
sql_dialect/database fails: the session is closed and pool capacity is kept (this line withreusable = false). - A connection failure in
insert/execute*:releaseOnConnectionFailure()closes the session and replenishes the pool.
If either path went back to a bare session.close(), every test in this PR would still pass.
I checked (1) with a probe: maxSize=1, a getSqlDialect() that differs from the pool's, and a restore that throws StatementExecutionException. The merge base ends with size=1 and an empty queue, so the pool is exhausted for good. This PR ends with size=0: in the probe the replacement could not connect, so the slot was freed. In SessionPoolTest, mocking a failing executeNonQueryStatement and then asserting size, currentOccupiedSize() and currentAvailableSize() would protect both paths.
| } | ||
| } | ||
|
|
||
| protected void cleanSessionAndMayThrowConnectionException(ISession session) { |
There was a problem hiding this comment.
SessionPool is public and not final, and this protected overload has existed since 2.0.1-beta. Removing it breaks any subclass that calls it. Recompiling against the new jar fails, and running an old build against the new jar throws NoSuchMethodError. If compatibility matters here, a @Deprecated overload that delegates to discardSession(session) would keep those subclasses working. Also note that releaseSession and isPoolClosed become new protected API.
| } | ||
| if (reusable && !closed) { | ||
| queue.push(session); | ||
| this.notifyAll(); |
There was a problem hiding this comment.
nit: only one session is pushed here, but notifyAll() wakes every waiting borrower. putBack() and tryConstructNewSession() use notify(). With many waiters, each release wakes all of them; all but one get null from poll(), and each of those calls notify() again. notify() is enough here and matches putBack().
| ISession session, int times, IoTDBConnectionException e) throws IoTDBConnectionException { | ||
| closeSession(session); | ||
| tryConstructNewSession(); | ||
| if (!releaseSession(session, false)) { |
There was a problem hiding this comment.
nit: every caller of this method is a tree-model operation, and its session is not yet in occupied when the exception happens. releaseSession(session, false) therefore always returns false here, and discardSession() does the actual work. discardSession() also duplicates the close-then-replenish tail of releaseSession(). A single private discard(session) that removes the session from occupied, closes it, and replenishes if the pool is open could serve both callers, so the two paths cannot drift apart.
| session = null; | ||
| } | ||
|
|
||
| if (sessionToRelease == null) { |
There was a problem hiding this comment.
nit: closed = true is always set together with session = null. sessionToRelease can only be null here if closed was already true, so this branch is unreachable. Either use session == null as the closed state, or keep closed and drop this branch. The this.closed = false in the constructor is also redundant.
| sessionPool.releaseSession(sessionToRelease, reusable); | ||
| } | ||
|
|
||
| private IoTDBConnectionException releaseOnConnectionFailure(IoTDBConnectionException exception) { |
There was a problem hiding this comment.
nit (stale comments): the class Javadoc still says the wrapper calls cleanSessionAndMayThrowConnectionException on connection errors, but that overload is gone and this method releases through releaseSession. Likewise, the comment on occupied in SessionPool (// for session whose resultSet is not released.) is no longer accurate, because borrowed table sessions are tracked there now. That also changes what currentOccupiedSize() reports.
| // the borrowed session is registered in occupied. | ||
| while (!pool.isPoolClosed()) { | ||
| try { | ||
| pool.wait(5_000); |
There was a problem hiding this comment.
nit: both race tests depend on implementation details, and if those change, the failures hide the cause.
- This
pool.wait()works only becausegetSession()callspoll()while holding the pool monitor. Ifpoll()moves out of the lock,pool.wait()throwsIllegalMonitorStateExceptioninside the borrow. The test then fails onassertTrue(e.getCause() instanceof IoTDBConnectionException)with no message, so the real exception is never shown. testTableSessionReleaseRacingWithPoolClosedepends ongetDatabase()being evaluated beforesessionPool.database != null. If someone swaps that condition, which is a natural micro-optimization, the latch never counts down. The test then fails atassertTrue(aboutToRelease.await(...))after 5 s, again with no message.




Problem
TableSessionPool.getSession()borrows a rawSessionfrom the underlyingSessionPool, but the table-session path did not register thatSessioninoccupied. A laterpool.close()therefore only saw idle sessions and sessions tracked for old tree-model result sets. Active table sessions could remain open, and returning one after the pool had closed could put it back into the closed pool.A concrete failure sequence is:
Before this change:
pool.close()did not see the borrowed table session because it was absent from bothqueueandoccupied.session.close()then returned the connection to the already-closed pool, leaving it unmanaged and open.After this change, the borrowed session is tracked until release;
pool.close()closes it, and releasing a session into a closed pool closes it directly instead of publishing it back intoqueue.The same cleanup logic also fixes an expected exception leak in tree-model
sortTablet():Before: the borrowed session was not returned, so subsequent operations waited until timeout even though
size == 1. After:try/finallyreturns the session, allowing the next operation to borrow it normally.Changes
occupiedfrom acquisition until wrapper release.occupied, reuses the connection only when safe, and replaces failed sessions while the pool remains open.sortTablet()returns its session when sorting succeeds or throws.maxSize=1, andsortTablet()failure recovery.pool.close()between queue removal and occupied registration, and immediately before session release.Validation
git diff --checkpassed.iotdb-client/sessionandintegration-test.