Repository navigation
Conversation
SessionPool only tracked sessions held by an open query result set (the "occupied" map). A session executing a request (an insert, a non-query statement, or a query whose result set has not been returned yet) was in neither that map nor the idle queue. Several places treated the map as if it held every session taken from the pool: - currentOccupiedSize() ignored sessions executing a request, so a saturated write pool reported 0 occupied and 0 available. - setFetchSize, setTimeZone, setEnableRedirection, setEnableQueryRedirection, setVersion and setQueryTimeout did not reach sessions executing a request, which kept the old value after being put back. - close() did not close sessions executing a request; they were later put back into the cleared queue and their connections leaked. "occupied" is now a set of all sessions handed out by getSession() until they are put back, cleaned after a connection error or released by closeResultSet(), and the places above use it. The result-set map is renamed to occupiedByResultSet and maps each session to the result set holding it. closeResultSet() releases a session only if it still belongs to that result set. Before, the second release of a result set (hasNext() auto-releases an exhausted one, the caller's finally releases it again) could put back a session that another thread had meanwhile taken for a new query. It also ignores a wrapper without a session instead of throwing an NPE, and closes the session when closing the operation handle fails instead of leaking its connection.
JackieTien97
left a comment
There was a problem hiding this comment.
Thanks for the thorough analysis and the regression tests. The diagnosis is right: occupied only tracked result-set sessions, but was treated as "all sessions in use". Claiming the session with occupiedByResultSet.remove(session, wrapper) is a neat fix for the double release. I checked the tests locally: running this PR's SessionPoolTest against master's SessionPool, 8 of the new tests fail; on this branch all 105 pass, and Spotless is clean.
My main concern is the new close(), which now closes sessions that other threads are still using. In a reproduction against a fake server, close() blocked until the in-flight RPC finished, holding the pool monitor the whole time. With the default connectionTimeoutInMs = 0, that wait has no upper bound. The inline comment on close() has the details and a suggested alternative: close sessions when they are returned to a closed pool. The other comments are smaller:
- a narrow window between
queueandoccupied, TableSessionWrapperstill bypassing the new invariant,setTimeZonerobustness,- test nits.
| } | ||
| for (ISession session : occupied.keySet()) { | ||
| // sessions executing a request are closed as well; that request then fails | ||
| for (ISession session : occupied) { |
There was a problem hiding this comment.
Closing sessions that are still in use from another thread has side effects beyond making the running request fail:
close()now waits for every in-flight RPC while holding the pool monitor.Session.close()→SessionConnection.close()sendscloseSessionthrough the client fromRpcUtils.newSynchronizedClient.SynchronizedHandlerholdssynchronized (client)for the whole duration of a call, socloseSessionhas to queue behind the in-flight call (an insert,flush, a long query, …). Meanwhile, everyputBack()blocks on the pool monitor, and so does anygetSession()that has to wait or create a session. With the defaultconnectionTimeoutInMs = 0there is no read timeout, so a hung DataNode or a half-open connection makesclose()hang indefinitely.
I reproduced this with a fake Thrift server (a mockedIClientRPCService.IfacebehindTThreadPoolServer) and a realSessionPool, with oneinsertTabletheld server-side for ~3 s:- on this branch,
close()returned after ~4 s, and another thread that tried to synchronize on the pool was blocked the whole time; - on master,
close()returned immediately, leaking that session (bug 3).
- on this branch,
- Sessions borrowed from
TableSessionPoolare closed under the user. A laterITableSession.close()(e.g. at the end of a try-with-resources block) throwsIoTDBConnectionException: Session is not open, please invoke Session.open() firstif the user changed the database. The reason is thatTableSessionWrapper.close()runsuse <pool database>on the closed session and only catchesStatementExecutionException. Reproduced with the same fake server: pool built withdatabase("db1"), thenuse db2, thenpool.close(), thentableSession.close()throws. On master it returns normally. - A retrying request can bring the closed session back to life. If the request is between attempts in
SessionConnection.callWithRetry(by default up to 60 × 500 ms), the nextreconnect()re-inits the transport. It then callssession.setDefaultSessionConnection(this)on aSessionthat is already marked closed. From then onSession.close()returns early, so the new connection is never closed: the same symptom as bug 3.
Suggestion: instead of closing sessions while they are in use, close them when they come back to a closed pool:
- In
putBack(), dooccupied.remove, theclosedcheck, and thenqueue.pushorsession.close(), all inside thesynchronized (this)block it already has. - In
getSession(), close the polled session when the pool turns out to be closed. - Make
closedvolatile.
In-flight requests then finish normally, their connections are closed when they are returned, and close() never has to wait on another thread's RPC while holding the monitor. If you'd still like close() to wait for in-flight work, a bounded wait outside the monitor would be safer. Happy to share the probe test if it helps.
|
|
||
| @SuppressWarnings({"squid:S2446"}) | ||
| protected void putBack(ISession session) { | ||
| occupied.remove(session); |
There was a problem hiding this comment.
occupied.remove(session) and queue.push(session) run outside the monitor, and so do queue.poll() → occupied.add(session) in getSession(). On the create path the gap is between the closed check (L782–L788) and L801. While a session is between the two calls, it is in neither collection, and a close() or setter that runs in that window misses it:
close()doesn't close it, so it is later pushed into the cleared queue and its connection leaks.- A setter doesn't update it, so the session keeps the old value.
These are the same symptoms as bugs (2) and (3), just in a narrower window. Moving the remove/push (and a closed check) into the existing synchronized (this) block would close the window on the putBack() side. For the getSession() side, see the suggestion on close(); for the setters, the pool could also re-apply its current settings when it hands out a session.
| private final ConcurrentMap<ISession, ISession> occupied = new ConcurrentHashMap<>(); | ||
| // Every session taken by getSession() and not yet returned (put back or cleaned), whatever it | ||
| // is used for. | ||
| private final Set<ISession> occupied = ConcurrentHashMap.newKeySet(); |
There was a problem hiding this comment.
TableSessionWrapper.close() (not part of this diff) still bypasses this invariant. When restoring sql_dialect or the database fails, it either calls session.close() directly (TableSessionWrapper.java L121, L137) or lets an IoTDBConnectionException escape (L114, L131). In both cases the session never leaves occupied, so currentOccupiedSize() can still drift. In a unit test it stays at 1 after such a failure; master reports 0.
Routing these branches through sessionPool.cleanSessionAndMayThrowConnectionException(session) would keep the count right. It would also stop the pool from losing capacity, because size is never decremented on those paths today.
| } | ||
| for (ISession session : occupied.keySet()) { | ||
| for (ISession session : occupied) { | ||
| session.setTimeZoneOfSession(zoneId); |
There was a problem hiding this comment.
closeSession() removes a session from occupied and then closes it, but this loop uses a weakly consistent iterator, so it can still return a session that another thread has just removed and closed. That happens after a request fails with a connection error, or on the new closeResultSet() failure path. Session.setTimeZoneOfSession() then throws IoTDBConnectionException("Session is not open") from getDefaultSessionConnection(). By that point the server-side time zone and this.zoneId have already changed, but the rest of occupied is skipped, so the pool ends up with mixed time zones again. Catching and ignoring the failure per session would make the loop robust; that session is going away anyway. This is the only one of the six setters that can throw; the others only assign fields.
Unrelated to this PR, but in the same method: the retry loop above never breaks or returns after a successful session.setTimeZone(zoneId); putBack(session);, so it sends the RPC RETRY (3) times. If the 3rd attempt hits a connection error, cleanSessionAndMayThrowConnectionException(session, FINAL_RETRY, e) throws, even though the time zone was already set.
| return; | ||
| } | ||
| try { | ||
| wrapper.getSessionDataSet().closeOperationHandle(); |
There was a problem hiding this comment.
Minor: the old finally guaranteed that the session was always put back or dropped. Now the session is claimed first (L883), and only IoTDBConnectionException/StatementExecutionException lead to cleanup. Any other RuntimeException/Error thrown here leaves the session neither put back nor closed, while it is still counted in occupied. One example is an NPE when getSessionDataSet() is null (setSessionDataSet is public). This is unlikely, because SynchronizedHandler wraps RPC-level failures into TException. Still, keeping a try/finally around the release, or also catching RuntimeException, would be cheap insurance.
| sessionPool.closeResultSet(wrapper); | ||
|
|
||
| assertEquals(0, sessionPool.currentOccupiedSize()); | ||
| assertEquals(0, sessionPool.currentAvailableSize()); |
There was a problem hiding this comment.
Minor: this test, test_CurrentOccupiedSize_WhenBrokenSessionWasCleanedUp_IsZero and test_CloseResultSet_WhenCalledAgainAfterFailedClose_DoesNotRetryClose all reach tryConstructNewSession(). Each of them therefore opens a real Session to the fake host:11, which took 0.7–1.3 s per test locally, spent waiting for DNS resolution to fail.
This assertion effectively checks that the replacement connection failed, which depends on the environment. With the default connectionTimeoutInMs = 0, a resolver that maps host to an address that silently drops packets would also make these tests hang until the OS TCP timeout. Consider one of these:
- point the pool at
127.0.0.1and a port with nothing listening, so the replacement fails fast without a DNS lookup; - assert on
occupiedinstead ofcurrentAvailableSize().
| SessionDataSet resp = session.executeQueryStatement(sql); | ||
| SessionDataSetWrapper wrapper = new SessionDataSetWrapper(resp, session, this); | ||
| occupy(session); | ||
| occupy(session, wrapper); |
There was a problem hiding this comment.
Nit: wrapper construction followed by occupy(session, wrapper) is now repeated in 12 query methods. A small helper such as wrapResultSet(resp, session) that builds and registers the wrapper would replace occupy(). It would also keep a future query method from forgetting the registration; if that happened, closeResultSet() would silently return and the session would never go back to the pool.
Description
TL;DR
SessionPool only tracks sessions held by open result sets (the
occupiedmap), but treats that map as "all sessions in use".As a result,
currentOccupiedSize(), six setters andclose()ignore sessions executing a request.closeResultSet()can also release a session that a later query already has taken, leaks the session when closing fails,and throws an NPE for a wrapper without a session.
All these bugs stem from the same root cause. Each bug has one or more tests in
SessionPoolTestthat fails on master. See table below.Longer Description
Problem
SessionPoolkeeps idle sessions inqueue. Apart from that, it has an additional collection: theoccupiedmap.It contains only sessions whose query result set is still open, until the result set is closed.
A session executing a request – an insert, a non-query statement, or a query whose result set has not been returned yet – is in neither
queuenoroccupied.Note that in the following, I will just refer sessions executing requests instead of spelling out that these requests are inserts, non-query statements, or queries whose result set has not been returned yet.
However, several parts of the class treat
occupiedas if it held every session taken from the pool,which it doesn't:
currentOccupiedSize()ignores sessions executing a request. On a write-heavy pool whose sessions are allwriting, it reports 0 occupied and 0 available. We saw exactly this in production: "occupied" stayed at 0–1 and "available" was 0,
all while writes queued up.
setFetchSize,setTimeZone,setEnableRedirection,setEnableQueryRedirection,setVersionandsetQueryTimeoutapply the new value toqueueandoccupiedonly. A session executing a requestmisses the change and keeps the old value for its whole life after being put back, so the pool ends up with mixed settings.
close()closesqueueandoccupiedonly. A session executing a request at that moment is later putback into the already cleared queue, and its connection is never closed.
closeResultSet()has problems of its own:twice: by
SessionDataSetWrapper.hasNext()once it is exhausted, and again by the caller'scloseResultSet()/close()in afinallyblock. The second call only checks whether thesession is still in
occupied. If another thread took that session in between and opened anew result set with it, the late call puts the session back while the other thread is still
reading, and two threads end up sharing one connection.
session is opened, but the failed session is neither put back nor closed, so its connection
leaks. The failed close also leaves the data set open, so the second release (see 4) sends the
close request again over the broken session and opens yet another replacement for it.
tryConstructNewSession()does not count replacements insize, so the pool can end up withmore sessions than
maxSize.NullPointerException(ConcurrentHashMap.remove(null)).Fix
Only one implementation file was changed:
SessionPool.occupiedis now a concurrent set of all sessions handed out bygetSession(). A session leavesit when it is put back (
putBack), closed after a connection error (closeSession, used by bothcleanSessionAndMayThrowConnectionExceptionoverloads), or released bycloseResultSet().currentOccupiedSize()and the timeout warning reportoccupied.size()(1).close()iterateoccupiedinstead of the result-set sessions (2, 3).The setters only change client-side fields of the session, as they already did for sessions
held by result sets. A session closed by
close()makes its running request fail with aconnection error, the same as reading a result set of a closed pool already did. The retry then
fails with "session pool is closed".
Session.close()returns early when already closed, so asecond close by the request's error handling is harmless.
occupiedmap is renamed tooccupiedByResultSetand maps each session to theSessionDataSetWrapperholding it;occupy(session, wrapper)stores it.closeResultSet()first claims the session withoccupiedByResultSet.remove(session, wrapper)and returns if that fail. So only the first release of this result set closes its operation
handle and hands the session back (4), even if that close failed (5). A failed close goes through
cleanSessionAndMayThrowConnectionException(session), which closes the session and opens areplacement (5). A wrapper without a session is ignored (6).
No public API changes.
Tests
New tests in
SessionPoolTest. Eight of them fail on master, at least one per bug above; the otherfour cover every way a session goes back, so the count cannot drift.
test_CurrentOccupiedSize_WhenInsertIsRunning_CountsItsSessiontest_CurrentOccupiedSize_WhenNonQueryIsRunning_CountsItsSessiontest_Setters_WhenCalledWhileInsertIsRunning_ReachSessionExecutingInserttest_Close_WhenInsertIsRunning_ClosesSessionExecutingInserttest_CloseResultSet_WhenCalledAgainAfterNextQueryTookSession_LeavesItToNextQuerytest_CloseResultSet_WhenClosingOperationHandleFails_ClosesSessionAndStopsCountingIttest_CloseResultSet_WhenCalledAgainAfterFailedClose_DoesNotRetryClosetest_CloseResultSet_WhenWrapperHasNoSession_DoesNothingtest_CurrentOccupiedSize_WhenInsertWasRejected_IsZeroputBacktest_CurrentOccupiedSize_WhenBrokenSessionWasCleanedUp_IsZerotest_CurrentOccupiedSize_WhenResultSetIsOpen_CountsItsSessionUntilClosedcloseResultSettest_CloseResultSet_WhenCalledTwice_PutsSessionBackIntoPoolOnlyOnceThis PR has:
for an unfamiliar reader.
for code coverage.
Key changed/added classes (or packages if there are too many classes) in this PR
SessionPool.java+ tests