Connection pool sizing safety rests on invariants nothing enforces
Summary
Our database connection handling is safe today because of three numbers that must stay
consistent with each other. That relationship is documented in one source comment and is
enforced nowhere — not by a startup check, not by a test, not by the props template.
This is structurally the same class of problem that produced the September 2023
StackOverflowError incident: the code was correct only while an unwritten invariant held,
and a later change removed the invariant silently.
The failure mode today is far less severe than it was in 2023 (a bounded timeout instead of
a dead JVM), so this is not urgent. It is worth fixing while the reasoning is still fresh.
Background: why the 2023 incident happened
Before HikariCP, connection pooling came from Lift's own StandardDBVendor / ProtoDBVendor.
Its newConnection waited for a free connection by recursing:
case Nil =>
wait(50L)
// ... optionally expand the pool ...
newConnection(name) // not tail-recursive
That recursion was not a defect on its own. It was safe because of an invariant supplied by a
different part of the framework: Lift Mapper tracks its connection in a ThreadLocal with a
reference count (DB.scala: threadStore, ConnectionHolder(conn, cnt, ...)), so nested
DB.use calls on the same thread reuse the same connection (cnt + 1) and the connection is
returned to the pool only when the count falls back to one.
The consequence: one synchronous request could only ever hold one connection. No request
held a connection while waiting for a second one, so hold-and-wait was impossible, every wait
was guaranteed to end, and recursion depth stayed proportional to how long the slowest
in-flight request took. Under that invariant the failure mode was slowness, not death.
Asynchronous execution removed the invariant without touching the pooling code. A Future
runs on a worker thread whose threadStore is empty, so it does not reuse the caller's
connection — it asks the pool for its own. One request could now hold one connection while
queueing for another. That is hold-and-wait. Once the pool could deadlock, a bounded wait
became an unbounded one, and the pre-existing recursion turned it into a stack overflow.
Relevant history:
| Commit |
Date |
Change |
02a80421d |
2023-09-16 |
Copy Lift's StandardDBVendor into the codebase as CustomDBVendor (framework code cannot be patched in place) |
17261bbf1 |
2023-09-18 |
Return Failure(...) instead of throwing RuntimeException on pool exhaustion |
a41c0f178 |
2023-09-19 |
Raise the pool default from 4 to 20 |
eae8d829c |
2023-10-08 |
Replace the copied vendor with HikariCP; delete DatabaseConnectionPoolScheduler.scala |
What HikariCP fixed, and what it did not
HikariCP structurally eliminated the fatal mode. HikariPool.getConnection waits in a
do { connectionBag.borrow(timeout, MILLISECONDS) } while (timeout > 0L) loop and throws a
timeout exception when the budget is exhausted. Stack depth is constant; exhaustion surfaces
as SQLTransientConnectionException after hikari.connectionTimeout (currently 30s) rather
than as an unbounded recursion.
Hold-and-wait itself was not removed. It is still the documented behaviour, in
obp-api/src/main/scala/bootstrap/liftweb/CustomDBVendor.scala:31-34:
// Default 20: each request holds its transaction connection for its whole lifetime,
// so a pool of 10 exhausts at ~5 concurrent requests (rate-limit queries need a 2nd connection).
val maximumPoolSize = APIUtil.getPropsAsIntValue("hikari.maximumPoolSize", 20)
A request holds its transaction connection for its whole lifetime while a rate-limit query on
cache miss needs a second one. We mitigate by sizing the pool, not by removing the pattern.
Several changes have since attacked the duration side of hold-and-wait, which is the right
direction:
| Commit |
Change |
05a7b71bd |
Defer connection acquisition until the first DB call, so REST/SOAP-only endpoints never touch the pool |
8f9ce5e15 |
Skip the transaction for GET/HEAD endpoints |
25c3e2af1 |
Narrow transaction scope to business logic; move read-only validation outside it |
54c271c70 |
Run blocking JDBC/gRPC on a dedicated bounded pool instead of the CPU-sized global one |
The current problem
Three independently configurable numbers have to remain consistent:
| Value |
Default |
Location |
hikari.maximumPoolSize (main pool) |
20 |
bootstrap/liftweb/CustomDBVendor.scala:34 |
stored_procedure_connector.poolMaxSize (second pool) |
20 |
code/bankconnectors/storedprocedure/StoredProcedureUtils.scala:38 |
blocking_io_pool.size (threads that block on acquisition) |
50 |
code/api/util/BlockingIoExecutionContext.scala:16 |
The relationship is stated only in a comment in BlockingIoExecutionContext.scala:10-13:
The default of 50 covers the sum of the Hikari maximumPoolSize values it serves
(main pool 20 + stored-procedure pool 20) with headroom; override via the
blocking_io_pool.size prop if the Hikari pools are resized.
Nothing enforces this. Grepping for blocking_io_pool outside its own definition returns
nothing: no startup validation, no test, no entry in the props template pairing the three.
An operator who raises hikari.maximumPoolSize to 40 to relieve pressure — the exact action
the first comment invites — silently under-provisions the thread pool that does the blocking
acquisition, and the mitigation makes throughput worse rather than better.
This is the 2023 shape again at a smaller scale: correctness resting on an invariant that
lives in a comment.
Proposed remediation
Ordered by cost. Any one of these closes the gap; the first is probably enough.
-
Validate at startup. On boot, if blocking_io_pool.size is smaller than
hikari.maximumPoolSize + stored_procedure_connector.poolMaxSize, log a warning naming all
three values and the prop to change. Cheap, no behaviour change, turns a silent
misconfiguration into a visible one.
-
Derive instead of duplicate. Default blocking_io_pool.size to the sum of the two pool
sizes plus headroom rather than to a hardcoded 50, keeping the explicit prop as an override.
The invariant then holds by construction unless someone deliberately overrides it.
-
Document the three together. The props template currently introduces these values in
separate places. A single block that states the relationship, with the recommended
arithmetic, would make the coupling discoverable to whoever tunes the pool.
-
Add a regression test asserting the derived default satisfies the inequality, so a
future change to either pool default fails the build rather than production.
Longer term, the hold-and-wait pattern itself is worth revisiting: if the rate-limit lookup on
cache miss can be served without a second connection while the request transaction is open,
the sizing rule stops being load-bearing at all.
References
obp-api/src/main/scala/bootstrap/liftweb/CustomDBVendor.scala
obp-api/src/main/scala/code/bankconnectors/storedprocedure/StoredProcedureUtils.scala
obp-api/src/main/scala/code/api/util/BlockingIoExecutionContext.scala
obp-api/src/main/scala/code/api/util/http4s/RequestScopeConnection.scala (states the
underlying ThreadLocal problem in its header comment)
- Lift
net/liftweb/db/DB.scala: threadStore (line 55), ConnectionHolder (line 135),
reference-count reuse (lines 299-300), release only at cnt == 1 (line 312)
- HikariCP
HikariPool.getConnection (lines 154-179)
Connection pool sizing safety rests on invariants nothing enforces
Summary
Our database connection handling is safe today because of three numbers that must stay
consistent with each other. That relationship is documented in one source comment and is
enforced nowhere — not by a startup check, not by a test, not by the props template.
This is structurally the same class of problem that produced the September 2023
StackOverflowErrorincident: the code was correct only while an unwritten invariant held,and a later change removed the invariant silently.
The failure mode today is far less severe than it was in 2023 (a bounded timeout instead of
a dead JVM), so this is not urgent. It is worth fixing while the reasoning is still fresh.
Background: why the 2023 incident happened
Before HikariCP, connection pooling came from Lift's own
StandardDBVendor/ProtoDBVendor.Its
newConnectionwaited for a free connection by recursing:That recursion was not a defect on its own. It was safe because of an invariant supplied by a
different part of the framework: Lift Mapper tracks its connection in a
ThreadLocalwith areference count (
DB.scala:threadStore,ConnectionHolder(conn, cnt, ...)), so nestedDB.usecalls on the same thread reuse the same connection (cnt + 1) and the connection isreturned to the pool only when the count falls back to one.
The consequence: one synchronous request could only ever hold one connection. No request
held a connection while waiting for a second one, so hold-and-wait was impossible, every wait
was guaranteed to end, and recursion depth stayed proportional to how long the slowest
in-flight request took. Under that invariant the failure mode was slowness, not death.
Asynchronous execution removed the invariant without touching the pooling code. A
Futureruns on a worker thread whose
threadStoreis empty, so it does not reuse the caller'sconnection — it asks the pool for its own. One request could now hold one connection while
queueing for another. That is hold-and-wait. Once the pool could deadlock, a bounded wait
became an unbounded one, and the pre-existing recursion turned it into a stack overflow.
Relevant history:
02a80421dStandardDBVendorinto the codebase asCustomDBVendor(framework code cannot be patched in place)17261bbf1Failure(...)instead of throwingRuntimeExceptionon pool exhaustiona41c0f178eae8d829cDatabaseConnectionPoolScheduler.scalaWhat HikariCP fixed, and what it did not
HikariCP structurally eliminated the fatal mode.
HikariPool.getConnectionwaits in ado { connectionBag.borrow(timeout, MILLISECONDS) } while (timeout > 0L)loop and throws atimeout exception when the budget is exhausted. Stack depth is constant; exhaustion surfaces
as
SQLTransientConnectionExceptionafterhikari.connectionTimeout(currently 30s) ratherthan as an unbounded recursion.
Hold-and-wait itself was not removed. It is still the documented behaviour, in
obp-api/src/main/scala/bootstrap/liftweb/CustomDBVendor.scala:31-34:A request holds its transaction connection for its whole lifetime while a rate-limit query on
cache miss needs a second one. We mitigate by sizing the pool, not by removing the pattern.
Several changes have since attacked the duration side of hold-and-wait, which is the right
direction:
05a7b71bd8f9ce5e1525c3e2af154c271c70The current problem
Three independently configurable numbers have to remain consistent:
hikari.maximumPoolSize(main pool)bootstrap/liftweb/CustomDBVendor.scala:34stored_procedure_connector.poolMaxSize(second pool)code/bankconnectors/storedprocedure/StoredProcedureUtils.scala:38blocking_io_pool.size(threads that block on acquisition)code/api/util/BlockingIoExecutionContext.scala:16The relationship is stated only in a comment in
BlockingIoExecutionContext.scala:10-13:Nothing enforces this. Grepping for
blocking_io_pooloutside its own definition returnsnothing: no startup validation, no test, no entry in the props template pairing the three.
An operator who raises
hikari.maximumPoolSizeto 40 to relieve pressure — the exact actionthe first comment invites — silently under-provisions the thread pool that does the blocking
acquisition, and the mitigation makes throughput worse rather than better.
This is the 2023 shape again at a smaller scale: correctness resting on an invariant that
lives in a comment.
Proposed remediation
Ordered by cost. Any one of these closes the gap; the first is probably enough.
Validate at startup. On boot, if
blocking_io_pool.sizeis smaller thanhikari.maximumPoolSize + stored_procedure_connector.poolMaxSize, log a warning naming allthree values and the prop to change. Cheap, no behaviour change, turns a silent
misconfiguration into a visible one.
Derive instead of duplicate. Default
blocking_io_pool.sizeto the sum of the two poolsizes plus headroom rather than to a hardcoded 50, keeping the explicit prop as an override.
The invariant then holds by construction unless someone deliberately overrides it.
Document the three together. The props template currently introduces these values in
separate places. A single block that states the relationship, with the recommended
arithmetic, would make the coupling discoverable to whoever tunes the pool.
Add a regression test asserting the derived default satisfies the inequality, so a
future change to either pool default fails the build rather than production.
Longer term, the hold-and-wait pattern itself is worth revisiting: if the rate-limit lookup on
cache miss can be served without a second connection while the request transaction is open,
the sizing rule stops being load-bearing at all.
References
obp-api/src/main/scala/bootstrap/liftweb/CustomDBVendor.scalaobp-api/src/main/scala/code/bankconnectors/storedprocedure/StoredProcedureUtils.scalaobp-api/src/main/scala/code/api/util/BlockingIoExecutionContext.scalaobp-api/src/main/scala/code/api/util/http4s/RequestScopeConnection.scala(states theunderlying
ThreadLocalproblem in its header comment)net/liftweb/db/DB.scala:threadStore(line 55),ConnectionHolder(line 135),reference-count reuse (lines 299-300), release only at
cnt == 1(line 312)HikariPool.getConnection(lines 154-179)