Our Server Was Healthy. The Connection Pool Was Exhausted.

Spring Boot kept passing health checks while search requests waited for GlassFish-managed Oracle connections and eventually timed out.

The dashboard was green.

Glass Fish was running. Spring Boot answered the health endpoint. Oracle continued processing queries.

But users could not use the search page.

Requests reached the application and then waited several seconds before timing out. The search query itself took about 200 milliseconds, which did not appear slow enough to explain the failure.

The missing time was not inside the SQL query.

It happened before SQL execution began.

All 200 connections in the Glass Fish connection pool were active. New requests had to wait until another request returned a connection.

The application process was alive, but the service had no remaining capacity to perform useful work.

The Health Check Answered the Wrong Question

A health check proves only what it measures.

Our existing endpoint checked whether the Spring Boot application could respond. It did not require an Oracle connection, execute the search query, or inspect the GlassFish pool.

It answered this question:

Is the application process alive?

Users needed an answer to a different question:

Can the application still acquire the resources required to complete a search?

Those are not the same condition.

A process can accept HTTP requests while its worker threads wait for database connections. A lightweight health endpoint can continue returning 200 OK throughout the incident.

The health check was not incorrect.

Our interpretation of “healthy” was too broad.

Requests Stopped Before Reaching Oracle

The search endpoint followed a familiar path:

HTTP request
    -> Spring Boot controller
    -> service transaction
    -> acquire connection from GlassFish
    -> execute Oracle query
    -> map result
    -> return response

During normal traffic, connection acquisition was nearly immediate.

During the incident, the request stopped at the connection-pool boundary:

HTTP request
    -> Spring Boot controller
    -> service transaction
    -> wait for available connection
    -> wait
    -> wait
    -> request timeout

No SQL appeared in the Oracle monitoring tools because the waiting requests had not reached Oracle yet.

This distinction matters during performance investigations. A request can be slow even when no slow query is running.

We Initially Blamed Oracle

Oracle was the first suspect.

That was reasonable. The endpoint performed a complex search, and the system handled between 200,000 and 300,000 requests during an eight-hour business period.

We checked:

  • Oracle execution plans
  • Database CPU and I/O
  • Lock waits
  • Active sessions
  • Slow SQL reports
  • JDBC errors
  • Glass Fish thread usage
  • Connection-pool statistics

One important search query took approximately 200 milliseconds.

In isolation, that did not look severe. Running the SQL once from a client produced an acceptable result.

But an isolated execution hides the capacity cost.

Every query occupies a database connection. The longer that connection remains occupied, the fewer requests the pool can serve over the same period.

A 200-millisecond query can be acceptable at 10 requests per second and dangerous during a burst of hundreds of requests per second.

Daily Request Totals Were Misleading

Three hundred thousand requests over eight hours gives an average rate of:

300,000 requests / 28,800 seconds
    approximately equals 10.4 requests per second

If every request held one connection for 200 milliseconds, the average connection demand would be approximately:

10.4 requests/second x 0.2 seconds
    approximately equals 2.08 concurrent connections

That average cannot explain a pool of 200 connections becoming exhausted.

This was an important investigation checkpoint.

The daily total was true, but it was not useful for sizing the pool. We needed peak traffic, connection hold time, and the number of database operations performed by each request.

Production traffic is rarely distributed evenly.

It can arrive in bursts after users log in, scheduled processing completes, another system retries, or a popular search is triggered across multiple clients.

The developer should size constrained resources using peak concurrency, not daily averages.

Measure Where the Request Waits

We separated endpoint latency into four stages:

1. Connection acquisition
2. SQL execution
3. Result mapping
4. Response processing

The old monitoring combined these stages into one request duration. That made the database look responsible for time that was actually spent waiting in GlassFish.

We added explicit measurement around connection acquisition:

@Component
public class MeasuredSearchExecutor {

    private final DataSource dataSource;
    private final SearchRepository searchRepository;
    private final SearchMetrics metrics;

    public MeasuredSearchExecutor(
            DataSource dataSource,
            SearchRepository searchRepository,
            SearchMetrics metrics) {
        this.dataSource = dataSource;
        this.searchRepository = searchRepository;
        this.metrics = metrics;
    }

    public SearchResult execute(SearchCriteria criteria)
            throws SQLException {

        long acquisitionStarted = System.nanoTime();

        try (Connection connection = dataSource.getConnection()) {
            long connectionAcquired = System.nanoTime();

            metrics.recordConnectionWait(
                    connectionAcquired - acquisitionStarted
            );

            long queryStarted = System.nanoTime();

            SearchResult result =
                    searchRepository.execute(connection, criteria);

            long queryCompleted = System.nanoTime();

            metrics.recordSqlDuration(
                    queryCompleted - queryStarted
            );

            return result;
        }
    }
}

The first timer measures how long getConnection() takes.

The second measures the database operation after a connection becomes available. These measurements answer different questions and should not be combined.

A request during saturation looked similar to this:

Connection acquisition:  8,000 ms
SQL execution:              205 ms
Result mapping:              34 ms
Response processing:         18 ms
Total:                    8,257 ms

The query had not become much slower.

The request spent most of its time waiting for permission to run it.

Confirm Which Component Owns the Pool

GlassFish managed the Oracle connection pool and exposed it through JNDI.

Spring Boot used the JNDI resource:

spring.datasource.jndi-name=jdbc/SearchDataSource

With a managed data source, pool sizing and acquisition timeout belong primarily to GlassFish rather than HikariCP.

This is operationally important. Changing settings such as spring.datasource.hikari.maximum-pool-size will not resize a GlassFish-managed pool.

The developer should first confirm which component owns the connection pool.

The application dependencies can remain small:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

The Oracle JDBC driver must be compatible with the Java, Oracle, and GlassFish versions in use. In a GlassFish-managed setup, the driver is commonly installed in the application-server environment so the server can create the pool.

For Spring Boot 3 deployments, the developer should also verify compatibility with the selected Jakarta EE and GlassFish version.

What the Pool Looked Like During Saturation

The useful pool metrics were:

pool.active
pool.idle
pool.max
pool.waiting
pool.acquire.duration
pool.acquire.timeout.count

During the incident, the pattern looked like this:

Connections

200 |                    active ─────────────────────
    |                 ┌──────────────────────────────
150 |              ┌──┘
    |           ┌──┘
100 |        ┌──┘
    |     ┌──┘
 50 |  ┌──┘
    |──┘
  0 +------------------------------------------------
      normal traffic       peak traffic       timeout

idle:       falling to 0
active:     rising to 200
waiting:    rising after active reaches 200
timeouts:   rising after acquisition wait reaches limit

High active usage alone is not necessarily a failure.

The stronger saturation signal is the combination of:

  • Active connections near the maximum
  • Idle connections near zero
  • Waiting requests increasing
  • Acquisition latency increasing
  • Acquisition timeouts occurring

Once the active count reached 200, additional demand became a queue.

Why Queueing Increased Latency So Quickly

Suppose 200 connections are occupied.

A new request cannot begin SQL execution until one connection returns. More requests continue arriving while it waits.

The timeline changes from this:

Normal request

0 ms         acquire connection
3 ms         connection available
3-203 ms     execute query
203-250 ms   map and return response

To this:

Request during saturation

0 ms         request reaches Spring Boot
0-8,000 ms   wait for pooled connection
8,000 ms     acquisition timeout
8,000 ms     request fails before SQL execution

The endpoint’s latency no longer tracks query duration.

It tracks queue depth, connection turnover, and the acquisition timeout.

This is why pool saturation can feel sudden. The system may appear stable until demand reaches the resource limit. After that point, waiting grows faster than the database execution time suggests.

Root Cause: Peak Demand Exceeded Connection Throughput

The root cause was not one bad number.

It was the relationship between:

  • Peak requests per second
  • Database operations per request
  • Connection hold time
  • Transaction scope
  • Pool capacity
  • Capacity available in Oracle

A useful starting estimate comes from Little’s Law:

required concurrent connections
    approximately equals peak requests/second
    x average connection hold time in seconds
    x database operations per request

For example:

500 requests/second x 0.2 seconds
    approximately equals 100 concurrent connections

After reducing the connection hold time:

500 requests/second x 0.02 seconds
    approximately equals 10 concurrent connections

This is an estimate, not a final pool-size formula.

Real systems need additional headroom for traffic variation, slow-tail queries, transactions, retries, reporting jobs, and other endpoints sharing the pool.

The calculation also assumes one database operation per request. If one search performs three sequential operations that each hold a connection for 200 milliseconds, its total demand is different.

The developer should measure the full connection hold time, not only the SQL duration.

Connection Hold Time Can Be Wider Than the Query

A connection may remain occupied after SQL execution finishes.

For example:

@Transactional(readOnly = true)
public SearchResponse search(SearchCriteria criteria) {
    List<SearchRow> rows = repository.search(criteria);

    SearchResponse response = mapRows(rows);

    auditSearch(criteria, rows.size());

    return response;
}

Depending on transaction and connection handling, the connection may remain associated with the transaction while mapping and audit work continue.

The developer should inspect:

  • Transaction boundaries
  • Multiple repository calls in one request
  • Remote calls inside transactions
  • Large result mapping
  • Lazy loading after the main query
  • Logging or auditing inside transactions
  • Connection release mode
  • Exception paths that fail to close resources

Database connections should not be held while the application calls another HTTP service, uploads a file, or performs unrelated CPU-heavy work.

Keep transaction boundaries as narrow as correctness allows.

Rule Out Leaks Before Resizing the Pool

A connection leak can produce the same pool symptoms.

Before increasing capacity, verify that connections are always returned:

try (Connection connection = dataSource.getConnection();
     PreparedStatement statement =
             connection.prepareStatement(SEARCH_SQL)) {

    bindCriteria(statement, criteria);

    try (ResultSet resultSet = statement.executeQuery()) {
        return mapResults(resultSet);
    }
}

Try-with-resources closes the result set, statement, and connection even when mapping throws an exception.

In a framework-managed transaction, Spring handles the connection lifecycle. The developer should avoid manually mixing transaction-managed and manually managed connections without understanding ownership.

Useful evidence against a leak includes:

  • Active connections fall after traffic decreases.
  • Borrowed and returned connection counts remain balanced.
  • No steadily increasing active baseline appears over time.
  • Leak-detection or pool diagnostics do not identify abandoned connections.
  • Heap or thread evidence does not show connections retained unexpectedly.

Our active count followed traffic and eventually recovered. That pointed toward capacity pressure rather than a permanent leak.

Fix Part 1: Reduce Connection Hold Time

The first fix was query optimization.

The search query filtered by customer, status, and creation time. Before optimization, the plan performed too much work for a frequently executed request.

A simplified query looked like this:

SELECT transaction_id,
       customer_id,
       status,
       amount,
       created_at
FROM search_transaction
WHERE customer_id = :customer_id
  AND status = :status
  AND created_at >= :start_time
  AND created_at < :end_time
ORDER BY created_at DESC;

The original Oracle execution plan was similar to:

--------------------------------------------------------------------------------
| Id | Operation            | Name               | Rows | Cost |
--------------------------------------------------------------------------------
|  0 | SELECT STATEMENT     |                    |      | 8421 |
|  1 |  SORT ORDER BY       |                    | 4200 | 8421 |
|  2 |   TABLE ACCESS FULL  | SEARCH_TRANSACTION | 4200 | 7980 |
--------------------------------------------------------------------------------

The developer can inspect an actual plan with:

SELECT *
FROM TABLE(
    DBMS_XPLAN.DISPLAY_CURSOR(
        NULL,
        NULL,
        'ALLSTATS LAST'
    )
);

ALLSTATS LAST helps compare estimated rows with actual rows when runtime statistics are available.

After reviewing the predicates and ordering, an index matching the access pattern was introduced:

CREATE INDEX idx_search_txn_customer_status_time
ON search_transaction (
    customer_id,
    status,
    created_at DESC
);

The resulting plan changed toward an index range scan:

--------------------------------------------------------------------------------
| Id | Operation                    | Name                                | Cost |
--------------------------------------------------------------------------------
|  0 | SELECT STATEMENT             |                                     |  128 |
|  1 |  TABLE ACCESS BY INDEX ROWID | SEARCH_TRANSACTION                  |  128 |
|  2 |   INDEX RANGE SCAN           | IDX_SEARCH_TXN_CUSTOMER_STATUS_TIME |   24 |
--------------------------------------------------------------------------------

The query duration fell from approximately 200 milliseconds to 20 milliseconds.

This index is an example, not a universal recommendation. Column order should be based on actual predicates, selectivity, ordering, data distribution, and the workload’s write cost.

Every additional index consumes storage and adds work to inserts, updates, deletes, statistics collection, and maintenance.

Faster Queries Improve Pool Throughput

Reducing query time from 200 milliseconds to 20 milliseconds is more than an endpoint improvement.

One connection can theoretically complete many more operations during the same period.

Simplifying the idea:

At 200 ms per operation:
1 connection can complete about 5 operations/second

At 20 ms per operation:
1 connection can complete about 50 operations/second

Production behavior will not reach this theoretical maximum because of network time, transaction overhead, mapping, contention, and tail latency.

Still, the direction is important.

A connection returned sooner becomes available to another waiting request. Lower hold time improves effective pool throughput.

Fix Part 2: Increase Pool Capacity Carefully

The second change increased the GlassFish pool maximum from 200 to 600 connections per application-server instance.

Before:

<jdbc-connection-pool
        name="SearchPool"
        datasource-classname="oracle.jdbc.pool.OracleDataSource"
        res-type="javax.sql.DataSource"
        steady-pool-size="50"
        max-pool-size="200"
        pool-resize-quantity="25"
        max-wait-time-in-millis="8000">
</jdbc-connection-pool>

After:

<jdbc-connection-pool
        name="SearchPool"
        datasource-classname="oracle.jdbc.pool.OracleDataSource"
        res-type="javax.sql.DataSource"
        steady-pool-size="100"
        max-pool-size="600"
        pool-resize-quantity="50"
        max-wait-time-in-millis="3000">
</jdbc-connection-pool>

The exact GlassFish attributes and administration commands can vary by version.

The important changes were the maximum size and a bounded acquisition wait. Requests should fail predictably instead of waiting indefinitely and consuming application threads.

Six hundred connections should not become a copied default.

The increase was acceptable only after peak-load testing and Oracle-side capacity checks showed that the database could support the resulting session count.

Calculate the Total, Not Only One Pool

Pool limits multiply across deployed instances.

The total theoretical session demand is:

application instances
    x pools per instance
    x maximum connections per pool

For example:

3 application instances x 600 connections
    = 1,800 possible Oracle sessions

If there are two clusters with three instances each:

2 clusters x 3 instances x 600 connections
    = 3,600 possible Oracle sessions

This can exceed Oracle’s configured session capacity or create severe CPU, I/O, lock, and memory pressure.

The developer should review:

  • Oracle PROCESSES and SESSIONS limits
  • Existing sessions from other applications
  • Database CPU capacity
  • Storage I/O
  • Wait events
  • Query concurrency
  • Parallel execution
  • Maximum sessions during failover
  • Connection storms during application startup

A larger application pool can simply move the queue from GlassFish to Oracle.

That is not a capacity improvement.

It is a bottleneck relocation.

Make Pool Pressure Observable

The new dashboard tracked each application instance separately.

At minimum, it included:

pool.active
pool.idle
pool.max
pool.utilization
pool.waiting
pool.acquire.duration
pool.acquire.timeout.count

Useful derived values include:

pool.utilization = pool.active / pool.max

And:

pool.headroom = pool.max - pool.active

Alerts should focus on sustained pressure rather than a single brief spike.

For example:

Warning:
pool utilization > 80% for 5 minutes

Critical:
pool utilization > 95%
and pool waiting > 0
for 2 minutes

Critical:
connection acquisition p99 > 500 ms

Critical:
acquisition timeout count > 0

The thresholds should reflect the application’s latency target and traffic behavior.

Pool metrics should be broken down by cluster and instance. An aggregate value can hide one saturated instance behind another instance with idle capacity.

Log the Stage That Failed

We also added structured timing fields to search logs:

{
  "event": "search_completed",
  "requestId": "4f13ec70-3d33-4906-9712-cef7b36579f0",
  "instance": "search-cluster-a-02",
  "connectionAcquireMs": 5,
  "sqlDurationMs": 21,
  "mappingDurationMs": 8,
  "totalDurationMs": 42,
  "resultCount": 37,
  "status": "SUCCESS"
}

A failed acquisition looked different:

{
  "event": "search_failed",
  "requestId": "21427d76-421a-4023-ae17-796193ea26f5",
  "instance": "search-cluster-a-02",
  "connectionAcquireMs": 3001,
  "poolActive": 600,
  "poolIdle": 0,
  "poolWaiting": 84,
  "status": "CONNECTION_ACQUISITION_TIMEOUT"
}

These logs made the failure stage visible without assuming every slow search was a slow Oracle query.

Be careful with search criteria in logs. Customer identifiers, account numbers, names, and free-text search values may contain sensitive information.

Separate Liveness, Readiness, and Capacity

We kept liveness lightweight.

A liveness check should answer whether the process is running and capable of making progress. Making liveness depend on an already saturated connection pool can create a restart loop.

For example:

Pool becomes busy
    -> liveness fails
    -> orchestrator restarts instance
    -> remaining instances receive more traffic
    -> their pools become busy
    -> more instances restart

Readiness can include dependency information, but it also needs careful design.

If every saturated instance immediately becomes unready, the load balancer may remove capacity and concentrate traffic on fewer remaining instances.

Pool saturation is primarily a capacity and performance signal. It usually belongs in dashboards, alerts, and traffic-control decisions rather than a simplistic binary liveness rule.

A practical model is:

  • Liveness: Is the process alive and its critical threads functioning?
  • Readiness: Can this instance accept normal traffic under current dependency conditions?
  • Capacity metrics: How close is the instance to resource exhaustion?
  • Service-level indicators: Are real requests completing within their latency and error targets?

One green endpoint cannot replace all four.

Verify the Fix Under Peak Load

The team tested both changes together and separately.

A useful load test should reproduce peak traffic, not the eight-hour average.

For example:

Warm-up:       5 minutes at 100 requests/second
Normal load:  10 minutes at 250 requests/second
Peak load:    10 minutes at 500 requests/second
Burst:         2 minutes at 700 requests/second
Recovery:      5 minutes at 100 requests/second

The search criteria should reflect production data distribution. Repeating one highly cached query can make both Oracle and the application look unrealistically fast.

During the test, measure:

  • Search latency at p50, p95, and p99
  • Connection-acquisition latency at p50, p95, and p99
  • Active, idle, and waiting connections
  • Acquisition timeouts
  • HTTP timeout rate
  • Oracle sessions
  • Database CPU and I/O
  • Oracle wait events
  • Query execution plans
  • Application thread count
  • Garbage collection and heap usage

Also test the limit deliberately.

A system should fail predictably when the pool is exhausted. Bounded acquisition time, clear error handling, and overload protection are better than requests waiting until every application thread is occupied.

Result After the Changes

The final result combined efficiency, capacity, and observability.

Representative production measurements looked like this:

Query duration:            200 ms -> 20 ms
Connection acquisition: 8,000 ms -> 5 ms
Waiting requests:             140 -> 0
Search timeout rate:          12% -> 0.1%

These values should be replaced with measurements from the actual environment before they are used as formal performance claims.

More importantly:

  • Connections returned to the pool sooner.
  • Acquisition latency stayed low during expected peaks.
  • Waiting requests remained near zero.
  • Search timeouts dropped.
  • Oracle stayed within validated session and resource limits.
  • Pool pressure became visible before users reported failures.

The application was no longer considered healthy only because its process could answer a lightweight endpoint.

Should Workloads Use Separate Pools?

Separate pools can isolate workloads such as interactive searches and large reporting jobs.

For example, a report should not consume every connection needed by user-facing requests.

However, separate pools do not create database capacity. Their maximum sizes still contribute to Oracle’s total session and workload limits.

Use separate pools when:

  • Workloads have genuinely different latency requirements.
  • One workload can monopolize shared capacity.
  • Each pool has an intentional limit.
  • Oracle can support the combined maximum.
  • Operational ownership is clear.

Do not use separate pools only to hide saturation metrics.

Lessons Learned

  • Liveness proves that a process can respond. It does not prove that the service has enough capacity to serve users.
  • User-visible latency includes connection acquisition, not only SQL execution.
  • Daily request totals hide peak concurrency and are insufficient for pool sizing.
  • Query efficiency reduces connection hold time, while pool size controls concurrency capacity.
  • Transaction scope can keep a connection occupied after the main query finishes.
  • A larger pool must be validated against total Oracle session and resource capacity.
  • Pool pressure should be visible per instance before waiting requests become timeouts.

Conclusion

Our server was healthy according to the health check we had written.

Our users were waiting according to the system we had actually built.

The search query took about 200 milliseconds, but some requests waited eight seconds for a connection before that query could even begin.

Optimizing the query improved connection turnover. Increasing the pool added concurrency headroom. Monitoring made the pressure visible.

The real lesson was not to increase every connection pool to 600.

It was to ask a better operational question.

Not only:

Is the application responding?

But also:

Can it still acquire the resources required to serve users?