Skip to content

Commit 3fa4f26

Browse files
gpascucciclaude
andcommitted
refactor(reporting): harden the dedicated reporting pool (PR #304 review)
Addresses SScholefield + Rylan-cgi: - Fast-fail: the reporting pool gets its own short connection-timeout (SPRING_DATASOURCE_REPORTING_CONNECTION_TIMEOUT, default 5000ms) so the Nth queued render fails quickly instead of parking a Tomcat thread 30s. - DRY: both pools now share applyCommonHikari(...) for the URL/credentials/ driver/lifetimes/validation, so a shared setting can't drift between them; reportingHikariConfig layers only the reporting overrides. - Observability: bind both pools to the MeterRegistry (setMetricRegistry via ObjectProvider) so hikaricp_connections_active/pending are exposed — the hand-built + DelegatingDataSource-wrapped pools aren't picked up by Boot's binder otherwise. - read-only wording softened everywhere (javadoc / yml / IT): setReadOnly is a Hikari HINT, not Oracle-enforced write-prevention. - Isolation IT strengthened into a real regression guard: reporting max is set ABOVE the primary (6 > 5) via @TestPropertySource, so saturating it is only possible with a separate pool; asserts the primary pool's active connections stay 0 and it remains acquirable. Dropped the coin-flip connection.isReadOnly() assertion (asserts the pool config instead). Best-effort connection close. Verified: unit test + checkstyle green locally; ReportingDataSourceIsolationIT green in WSL Oracle Testcontainers (Tests run: 2, 0 failures). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f46c9c9 commit 3fa4f26

5 files changed

Lines changed: 103 additions & 55 deletions

File tree

backend/src/main/java/ca/bc/gov/nrs/ilcr/configuration/DataSourceConfiguration.java

Lines changed: 48 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
import com.zaxxer.hikari.HikariConfig;
44
import com.zaxxer.hikari.HikariDataSource;
5+
import io.micrometer.core.instrument.MeterRegistry;
56
import javax.sql.DataSource;
7+
import org.springframework.beans.factory.ObjectProvider;
68
import org.springframework.beans.factory.annotation.Value;
79
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
810
import org.springframework.context.annotation.Bean;
@@ -35,36 +37,37 @@ public DataSource dataSource(
3537
@Value("${spring.datasource.hikari.max-lifetime:180000}") long maxLifetime,
3638
@Value("${spring.datasource.hikari.keepalive-time:60000}") long keepaliveTime,
3739
@Value("${spring.datasource.hikari.leak-detection-threshold:60000}") long leakDetectionThreshold,
38-
@Value("${ilcr.datasource.validation-query:SELECT 1 FROM DUAL}") String validationQuery
40+
@Value("${ilcr.datasource.validation-query:SELECT 1 FROM DUAL}") String validationQuery,
41+
ObjectProvider<MeterRegistry> meterRegistry
3942
) {
4043
requireProperty("spring.datasource.url", url);
4144
requireProperty("spring.datasource.username", username);
4245
requireProperty("spring.datasource.password", password);
4346

4447
HikariConfig config = new HikariConfig();
45-
config.setJdbcUrl(url);
46-
config.setUsername(username);
47-
config.setPassword(password);
48-
config.setDriverClassName(driverClassName);
48+
applyCommonHikari(config, url, username, password, driverClassName, idleTimeout, maxLifetime,
49+
keepaliveTime, validationQuery);
4950
config.setPoolName(poolName);
5051
config.setMaximumPoolSize(maximumPoolSize);
5152
config.setMinimumIdle(minimumIdle);
5253
config.setConnectionTimeout(connectionTimeout);
53-
config.setIdleTimeout(idleTimeout);
54-
config.setMaxLifetime(maxLifetime);
55-
config.setKeepaliveTime(keepaliveTime);
5654
config.setLeakDetectionThreshold(leakDetectionThreshold);
57-
config.setConnectionTestQuery(validationQuery);
55+
// Expose hikaricp_connections_* so pool saturation (this story's whole concern) is alertable,
56+
// not inferred from latency. The hand-built pool + DelegatingDataSource wrapper means Boot's
57+
// Hikari metrics binder doesn't pick it up, so bind it here.
58+
meterRegistry.ifAvailable(config::setMetricRegistry);
5859

5960
return new ValidatingDataSource(new HikariDataSource(config), validationQuery);
6061
}
6162

6263
/**
63-
* A SEPARATE, read-only, small Hikari pool dedicated to the Jasper report-fill path (Story 29.1).
64-
* The Schedule 9 SQL-in-template fill borrows a JDBC connection for the ENTIRE render (embedded SQL
65-
* + PDF formatting) — the longest-held connection in the app. Drawing it from the {@link Primary}
64+
* A SEPARATE, small Hikari pool dedicated to the Jasper report-fill path (Story 29.1). The Schedule 9
65+
* SQL-in-template fill borrows a JDBC connection for the ENTIRE render (embedded SQL + PDF
66+
* formatting) — the longest-held connection in the app. Drawing it from the {@link Primary}
6667
* transactional pool (default max 5) means a handful of concurrent reports can starve ordinary
67-
* schedule requests. This bean isolates report fills so they can only exhaust their own small pool.
68+
* schedule requests. This bean isolates report fills so they can only exhaust their own small pool,
69+
* with a short connection-timeout so the Nth queued render fast-fails instead of parking a Tomcat
70+
* thread for the full 30s.
6871
*
6972
* <p>Deliberately NOT {@link Primary}: the {@code jdbcTemplate}/{@code namedParameterJdbcTemplate}/
7073
* {@code transactionManager} beans keep binding to the transactional pool. Only {@code ReportService}
@@ -79,11 +82,12 @@ public DataSource reportingDataSource(
7982
@Value("${spring.datasource.reporting.hikari.pool-name:ILCRReportingPool}") String poolName,
8083
@Value("${spring.datasource.reporting.hikari.maximum-pool-size:3}") int maximumPoolSize,
8184
@Value("${spring.datasource.reporting.hikari.minimum-idle:0}") int minimumIdle,
82-
@Value("${spring.datasource.hikari.connection-timeout:30000}") long connectionTimeout,
85+
@Value("${spring.datasource.reporting.hikari.connection-timeout:5000}") long connectionTimeout,
8386
@Value("${spring.datasource.hikari.idle-timeout:60000}") long idleTimeout,
8487
@Value("${spring.datasource.hikari.max-lifetime:180000}") long maxLifetime,
8588
@Value("${spring.datasource.hikari.keepalive-time:60000}") long keepaliveTime,
86-
@Value("${ilcr.datasource.validation-query:SELECT 1 FROM DUAL}") String validationQuery
89+
@Value("${ilcr.datasource.validation-query:SELECT 1 FROM DUAL}") String validationQuery,
90+
ObjectProvider<MeterRegistry> meterRegistry
8791
) {
8892
requireProperty("spring.datasource.url", url);
8993
requireProperty("spring.datasource.username", username);
@@ -92,37 +96,53 @@ public DataSource reportingDataSource(
9296
HikariConfig config = reportingHikariConfig(url, username, password, driverClassName, poolName,
9397
maximumPoolSize, minimumIdle, connectionTimeout, idleTimeout, maxLifetime, keepaliveTime,
9498
validationQuery);
99+
meterRegistry.ifAvailable(config::setMetricRegistry);
95100
return new ValidatingDataSource(new HikariDataSource(config), validationQuery);
96101
}
97102

98103
/**
99-
* Build the read-only reporting {@link HikariConfig}. Extracted (package-private) so its
100-
* isolation-critical invariants — read-only, its own pool name, a small dedicated ceiling, and NO
101-
* leak-detection (a render legitimately holds its connection for the whole fill) — are unit-testable
102-
* without opening a real database connection.
104+
* Build the reporting {@link HikariConfig}: the connection settings shared with the primary pool
105+
* (via {@link #applyCommonHikari}) plus the reporting-specific overrides — its own pool name + small
106+
* ceiling, a short connection-timeout (fast-fail rather than parking a Tomcat thread on the Nth
107+
* queued render), NO leak-detection (a render legitimately holds its connection for the whole fill),
108+
* and the read-only HINT. Extracted (package-private) so those invariants are unit-testable without
109+
* opening a database connection.
110+
*
111+
* <p>NOTE: {@code setReadOnly(true)} is a Hikari/JDBC <em>hint</em>, not an enforced privilege — on
112+
* Oracle it does not by itself prevent writes. It is intent-signalling + defence-in-depth only; true
113+
* write-prevention would be a read-only database account for the reporting connection.
103114
*/
104115
static HikariConfig reportingHikariConfig(
105116
String url, String username, String password, String driverClassName, String poolName,
106117
int maximumPoolSize, int minimumIdle, long connectionTimeout, long idleTimeout,
107118
long maxLifetime, long keepaliveTime, String validationQuery) {
108119
HikariConfig config = new HikariConfig();
109-
config.setJdbcUrl(url);
110-
config.setUsername(username);
111-
config.setPassword(password);
112-
config.setDriverClassName(driverClassName);
120+
applyCommonHikari(config, url, username, password, driverClassName, idleTimeout, maxLifetime,
121+
keepaliveTime, validationQuery);
113122
config.setPoolName(poolName);
114123
config.setMaximumPoolSize(maximumPoolSize);
115124
config.setMinimumIdle(minimumIdle);
116125
config.setConnectionTimeout(connectionTimeout);
126+
config.setReadOnly(true);
127+
return config;
128+
}
129+
130+
/**
131+
* The connection settings both pools share (URL / credentials / driver / lifetimes / validation),
132+
* factored out so a change to one pool's shared setting can't silently drift from the other.
133+
* Pool-specific settings (name, sizes, timeout, leak-detection, read-only) are applied by the caller.
134+
*/
135+
private static void applyCommonHikari(HikariConfig config, String url, String username,
136+
String password, String driverClassName, long idleTimeout, long maxLifetime,
137+
long keepaliveTime, String validationQuery) {
138+
config.setJdbcUrl(url);
139+
config.setUsername(username);
140+
config.setPassword(password);
141+
config.setDriverClassName(driverClassName);
117142
config.setIdleTimeout(idleTimeout);
118143
config.setMaxLifetime(maxLifetime);
119144
config.setKeepaliveTime(keepaliveTime);
120145
config.setConnectionTestQuery(validationQuery);
121-
// Read-only: report fills never write. Leak-detection intentionally left unset (0/off): a Jasper
122-
// render holds its connection for the full fill+format, which would trip a leak warning; the
123-
// dedicated small pool bounds the exposure instead.
124-
config.setReadOnly(true);
125-
return config;
126146
}
127147

128148
@Bean

backend/src/main/java/ca/bc/gov/nrs/ilcr/reporting/ReportService.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,10 @@ public class ReportService {
7575
private final Map<ScheduleKey, JasperReport> compiledTemplates = new ConcurrentHashMap<>();
7676

7777
/**
78-
* @param dataSource the dedicated read-only reporting datasource (Story 29.1) the Schedule 9 fill
79-
* borrows from — isolated from the {@code @Primary} transactional pool so a burst of report
80-
* renders cannot starve ordinary schedule requests
78+
* @param dataSource the dedicated reporting datasource (Story 29.1) the Schedule 9 fill borrows from
79+
* — its own small pool, isolated from the {@code @Primary} transactional pool so a burst of report
80+
* renders cannot starve ordinary schedule requests (its connections are read-only as a hint, not
81+
* an enforced privilege)
8182
* @param schedule5Service the Schedule 5 read (bean-datasource feed)
8283
* @param schedule6Service the Schedule 6 read (bean-datasource feed)
8384
* @param schedule7aService the Schedule 7A read (bean-datasource feed)

backend/src/main/resources/application.yml

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,20 @@ spring:
2525
max-lifetime: ${SPRING_DATASOURCE_MAX_LIFETIME:180000}
2626
keepalive-time: ${SPRING_DATASOURCE_KEEPALIVE_TIME:60000}
2727
leak-detection-threshold: ${SPRING_DATASOURCE_LEAK_DETECTION_THRESHOLD:60000}
28-
# Story 29.1 — a SEPARATE, read-only, small pool dedicated to the Jasper report-fill path
29-
# (the Schedule 9 SQL-in-template fill borrows a JDBC connection for the whole render). It shares
30-
# the same DB (url/username/password above) but its own small ceiling, so a burst of concurrent
31-
# report renders can only exhaust THIS pool — never the transactional pool that serves ordinary
32-
# schedule requests. Size it to peak concurrent report renders (default 3). No leak-detection: a
33-
# render legitimately holds its connection for the full fill+format.
28+
# Story 29.1 — a SEPARATE, small pool dedicated to the Jasper report-fill path (the Schedule 9
29+
# SQL-in-template fill borrows a JDBC connection for the whole render). Same DB (url/username/
30+
# password above) but its own small ceiling, so a burst of concurrent report renders can only
31+
# exhaust THIS pool — never the transactional pool that serves ordinary schedule requests. Size it
32+
# to peak concurrent report renders (default 3). Short connection-timeout (5s) so the Nth queued
33+
# render fast-fails instead of parking a Tomcat thread for 30s. No leak-detection: a render
34+
# legitimately holds its connection for the full fill+format. Connections are marked read-only as a
35+
# HINT / intent signal (defence-in-depth) — it is not enforced write-prevention on Oracle.
3436
reporting:
3537
hikari:
3638
pool-name: ${SPRING_DATASOURCE_REPORTING_POOL_NAME:ILCRReportingPool}
3739
minimum-idle: ${SPRING_DATASOURCE_REPORTING_MIN_IDLE:0}
3840
maximum-pool-size: ${SPRING_DATASOURCE_REPORTING_MAX_POOL_SIZE:3}
41+
connection-timeout: ${SPRING_DATASOURCE_REPORTING_CONNECTION_TIMEOUT:5000}
3942

4043
server:
4144
port: ${SERVER_PORT:8080}

backend/src/test/java/ca/bc/gov/nrs/ilcr/configuration/DataSourceConfigurationTest.java

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
class DataSourceConfigurationTest {
1515

1616
@Test
17-
void reportingHikariConfigIsReadOnlyWithItsOwnSmallPoolAndNoLeakDetection() {
17+
void reportingHikariConfigIsReadOnlyWithItsOwnSmallFastFailingPoolAndNoLeakDetection() {
1818
HikariConfig config = DataSourceConfiguration.reportingHikariConfig(
1919
"jdbc:oracle:thin:@//db.example:1521/ILCR",
2020
"THE",
@@ -23,22 +23,24 @@ void reportingHikariConfigIsReadOnlyWithItsOwnSmallPoolAndNoLeakDetection() {
2323
"ILCRReportingPool",
2424
3,
2525
0,
26-
30000L,
26+
5000L,
2727
60000L,
2828
180000L,
2929
60000L,
3030
"SELECT 1 FROM DUAL");
3131

32-
// Report fills never write.
32+
// Read-only HINT (defence-in-depth; not Oracle-enforced write-prevention).
3333
assertThat(config.isReadOnly()).isTrue();
3434
// A distinct, small pool of its own — not the @Primary transactional pool.
3535
assertThat(config.getPoolName()).isEqualTo("ILCRReportingPool");
3636
assertThat(config.getMaximumPoolSize()).isEqualTo(3);
3737
assertThat(config.getMinimumIdle()).isEqualTo(0);
38+
// Short connection-timeout: the Nth queued render fast-fails rather than parking a Tomcat thread 30s.
39+
assertThat(config.getConnectionTimeout()).isEqualTo(5000L);
3840
// Deliberately unset (0 = disabled): a render holds its connection for the full fill+format, which
3941
// would otherwise trip a leak warning; the small dedicated pool bounds the exposure instead.
4042
assertThat(config.getLeakDetectionThreshold()).isZero();
41-
// Credentials/URL/validation still wired through.
43+
// Credentials/URL/validation still wired through the shared builder.
4244
assertThat(config.getJdbcUrl()).isEqualTo("jdbc:oracle:thin:@//db.example:1521/ILCR");
4345
assertThat(config.getConnectionTestQuery()).isEqualTo("SELECT 1 FROM DUAL");
4446
}

backend/src/test/java/ca/bc/gov/nrs/ilcr/reporting/ReportingDataSourceIsolationIT.java

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import ca.bc.gov.nrs.ilcr.support.AbstractOracleIT;
66
import com.zaxxer.hikari.HikariDataSource;
77
import java.sql.Connection;
8+
import java.sql.SQLException;
89
import java.util.ArrayList;
910
import java.util.List;
1011
import javax.sql.DataSource;
@@ -14,11 +15,19 @@
1415
import org.springframework.test.context.TestPropertySource;
1516

1617
/**
17-
* Story 29.1: proves the Jasper report-fill path draws from a SEPARATE, read-only Hikari pool, so a
18-
* burst of report renders can only exhaust that dedicated pool and never starve the {@code @Primary}
18+
* Story 29.1: proves the Jasper report-fill path draws from a SEPARATE Hikari pool, so a burst of
19+
* report renders can only exhaust that dedicated pool and never starve the {@code @Primary}
1920
* transactional pool serving ordinary schedule requests.
21+
*
22+
* <p>The reporting pool is sized ABOVE the primary pool here (6 &gt; the default 5) so the isolation
23+
* test is a real regression guard: if someone dropped the {@code @Qualifier} on {@code ReportService}
24+
* and it fell back to the shared primary pool of 5, saturating "the reporting pool" with 6 connections
25+
* would be impossible (the 6th would block to the timeout and fail) — so the test would go red.
2026
*/
21-
@TestPropertySource(properties = "ilcr.security.enabled=false")
27+
@TestPropertySource(properties = {
28+
"ilcr.security.enabled=false",
29+
"spring.datasource.reporting.hikari.maximum-pool-size=6"
30+
})
2231
class ReportingDataSourceIsolationIT extends AbstractOracleIT {
2332

2433
@Autowired private DataSource primaryDataSource; // resolves to the @Primary bean
@@ -28,37 +37,50 @@ class ReportingDataSourceIsolationIT extends AbstractOracleIT {
2837
private DataSource reportingDataSource;
2938

3039
@Test
31-
void reportingDataSourceIsADistinctReadOnlyPool() throws Exception {
40+
void reportingDataSourceIsADistinctPoolWithItsOwnName() throws Exception {
3241
assertThat(reportingDataSource).isNotSameAs(primaryDataSource);
3342

3443
HikariDataSource reportingHikari = reportingDataSource.unwrap(HikariDataSource.class);
3544
assertThat(reportingHikari.getPoolName()).isEqualTo("ILCRReportingPool");
45+
// The read-only flag is a config/pool HINT (not an Oracle-enforced privilege), so assert the pool
46+
// config rather than connection.isReadOnly() — the driver may report that inconsistently.
3647
assertThat(reportingHikari.isReadOnly()).isTrue();
37-
38-
try (Connection connection = reportingDataSource.getConnection()) {
39-
assertThat(connection.isReadOnly()).isTrue();
40-
}
48+
assertThat(reportingHikari.getMaximumPoolSize()).isGreaterThan(primaryMaxPoolSize());
4149
}
4250

4351
@Test
44-
void saturatingTheReportingPoolLeavesTheTransactionalPoolAcquirable() throws Exception {
45-
int reportingMax = reportingDataSource.unwrap(HikariDataSource.class).getMaximumPoolSize();
52+
void saturatingTheReportingPoolLeavesTheTransactionalPoolUntouched() throws Exception {
53+
HikariDataSource reportingHikari = reportingDataSource.unwrap(HikariDataSource.class);
54+
HikariDataSource primaryHikari = primaryDataSource.unwrap(HikariDataSource.class);
55+
int reportingMax = reportingHikari.getMaximumPoolSize();
4656

4757
List<Connection> held = new ArrayList<>();
4858
try {
49-
// Hold every connection the reporting pool can hand out — the stand-in for concurrent renders,
50-
// each of which pins a connection for its whole fill.
59+
// Hold EVERY connection the reporting pool can hand out (the stand-in for concurrent renders, each
60+
// pinning a connection for its whole fill). Because reportingMax (6) > the primary ceiling (5),
61+
// completing this loop is itself proof of a separate pool — the shared pool could never hand out 6.
5162
for (int i = 0; i < reportingMax; i++) {
5263
held.add(reportingDataSource.getConnection());
5364
}
54-
// The transactional pool is untouched: an ordinary request still gets a connection right away.
65+
66+
// The transactional pool is provably untouched by the reporting saturation...
67+
assertThat(primaryHikari.getHikariPoolMXBean().getActiveConnections()).isZero();
68+
// ...and an ordinary request still gets a connection right away.
5569
try (Connection primary = primaryDataSource.getConnection()) {
5670
assertThat(primary.isValid(2)).isTrue();
5771
}
5872
} finally {
5973
for (Connection connection : held) {
60-
connection.close();
74+
try {
75+
connection.close();
76+
} catch (SQLException ignored) {
77+
// Best-effort cleanup — keep closing the rest so a single failure can't leak sessions.
78+
}
6179
}
6280
}
6381
}
82+
83+
private int primaryMaxPoolSize() throws SQLException {
84+
return primaryDataSource.unwrap(HikariDataSource.class).getMaximumPoolSize();
85+
}
6486
}

0 commit comments

Comments
 (0)