Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,72 @@
return new ValidatingDataSource(new HikariDataSource(config), validationQuery);
}

/**
* A SEPARATE, read-only, small Hikari pool dedicated to the Jasper report-fill path (Story 29.1).
* The Schedule 9 SQL-in-template fill borrows a JDBC connection for the ENTIRE render (embedded SQL
* + PDF formatting) — the longest-held connection in the app. Drawing it from the {@link Primary}
* transactional pool (default max 5) means a handful of concurrent reports can starve ordinary
* schedule requests. This bean isolates report fills so they can only exhaust their own small pool.
*
* <p>Deliberately NOT {@link Primary}: the {@code jdbcTemplate}/{@code namedParameterJdbcTemplate}/
* {@code transactionManager} beans keep binding to the transactional pool. Only {@code ReportService}
* (via {@code @Qualifier("reportingDataSource")}) draws from here.
*/
@Bean("reportingDataSource")
public DataSource reportingDataSource(
@Value("${spring.datasource.url}") String url,
@Value("${spring.datasource.username}") String username,
@Value("${spring.datasource.password}") String password,
@Value("${spring.datasource.driver-class-name:oracle.jdbc.OracleDriver}") String driverClassName,
@Value("${spring.datasource.reporting.hikari.pool-name:ILCRReportingPool}") String poolName,
@Value("${spring.datasource.reporting.hikari.maximum-pool-size:3}") int maximumPoolSize,
@Value("${spring.datasource.reporting.hikari.minimum-idle:0}") int minimumIdle,
@Value("${spring.datasource.hikari.connection-timeout:30000}") long connectionTimeout,
@Value("${spring.datasource.hikari.idle-timeout:60000}") long idleTimeout,
@Value("${spring.datasource.hikari.max-lifetime:180000}") long maxLifetime,
@Value("${spring.datasource.hikari.keepalive-time:60000}") long keepaliveTime,
@Value("${ilcr.datasource.validation-query:SELECT 1 FROM DUAL}") String validationQuery
) {
requireProperty("spring.datasource.url", url);
requireProperty("spring.datasource.username", username);
requireProperty("spring.datasource.password", password);

HikariConfig config = reportingHikariConfig(url, username, password, driverClassName, poolName,
maximumPoolSize, minimumIdle, connectionTimeout, idleTimeout, maxLifetime, keepaliveTime,
validationQuery);
return new ValidatingDataSource(new HikariDataSource(config), validationQuery);
}

/**
* Build the read-only reporting {@link HikariConfig}. Extracted (package-private) so its
* isolation-critical invariants — read-only, its own pool name, a small dedicated ceiling, and NO
* leak-detection (a render legitimately holds its connection for the whole fill) — are unit-testable
* without opening a real database connection.
*/
static HikariConfig reportingHikariConfig(

Check warning on line 104 in backend/src/main/java/ca/bc/gov/nrs/ilcr/configuration/DataSourceConfiguration.java

View check run for this annotation

SonarQubeCloud / [Interior Logging Costs Reporting] SonarCloud Code Analysis

Method has 12 parameters, which is greater than 7 authorized.

See more on https://sonarcloud.io/project/issues?id=bcgov-sonarcloud_nr-ilcr_backend&issues=AaAWLtu6VG3iXKhIPQT4&open=AaAWLtu6VG3iXKhIPQT4&pullRequest=304
String url, String username, String password, String driverClassName, String poolName,
int maximumPoolSize, int minimumIdle, long connectionTimeout, long idleTimeout,
long maxLifetime, long keepaliveTime, String validationQuery) {
HikariConfig config = new HikariConfig();
config.setJdbcUrl(url);
config.setUsername(username);
config.setPassword(password);
config.setDriverClassName(driverClassName);
config.setPoolName(poolName);
config.setMaximumPoolSize(maximumPoolSize);
config.setMinimumIdle(minimumIdle);
config.setConnectionTimeout(connectionTimeout);
config.setIdleTimeout(idleTimeout);
config.setMaxLifetime(maxLifetime);
config.setKeepaliveTime(keepaliveTime);
config.setConnectionTestQuery(validationQuery);
// Read-only: report fills never write. Leak-detection intentionally left unset (0/off): a Jasper
// render holds its connection for the full fill+format, which would trip a leak warning; the
// dedicated small pool bounds the exposure instead.
config.setReadOnly(true);
return config;
}

@Bean
public JdbcTemplate jdbcTemplate(DataSource dataSource) {
return new JdbcTemplate(dataSource);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import net.sf.jasperreports.pdf.JRPdfExporter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
Expand Down Expand Up @@ -74,7 +75,9 @@ public class ReportService {
private final Map<ScheduleKey, JasperReport> compiledTemplates = new ConcurrentHashMap<>();

/**
* @param dataSource the single {@code @Primary} application datasource Schedule 9 fills from
* @param dataSource the dedicated read-only reporting datasource (Story 29.1) the Schedule 9 fill
* borrows from — isolated from the {@code @Primary} transactional pool so a burst of report
* renders cannot starve ordinary schedule requests
* @param schedule5Service the Schedule 5 read (bean-datasource feed)
* @param schedule6Service the Schedule 6 read (bean-datasource feed)
* @param schedule7aService the Schedule 7A read (bean-datasource feed)
Expand All @@ -83,7 +86,7 @@ public class ReportService {
* @param schedule11Service the Schedule 11 read (bean-datasource feed)
*/
public ReportService(
DataSource dataSource,
@Qualifier("reportingDataSource") DataSource dataSource,
Schedule5Service schedule5Service,
Schedule6Service schedule6Service,
Schedule7aService schedule7aService,
Expand Down
11 changes: 11 additions & 0 deletions backend/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ spring:
max-lifetime: ${SPRING_DATASOURCE_MAX_LIFETIME:180000}
keepalive-time: ${SPRING_DATASOURCE_KEEPALIVE_TIME:60000}
leak-detection-threshold: ${SPRING_DATASOURCE_LEAK_DETECTION_THRESHOLD:60000}
# Story 29.1 — a SEPARATE, read-only, small pool dedicated to the Jasper report-fill path
# (the Schedule 9 SQL-in-template fill borrows a JDBC connection for the whole render). It shares
# the same DB (url/username/password above) but its own small ceiling, so a burst of concurrent
# report renders can only exhaust THIS pool — never the transactional pool that serves ordinary
# schedule requests. Size it to peak concurrent report renders (default 3). No leak-detection: a
# render legitimately holds its connection for the full fill+format.
reporting:
hikari:
pool-name: ${SPRING_DATASOURCE_REPORTING_POOL_NAME:ILCRReportingPool}
minimum-idle: ${SPRING_DATASOURCE_REPORTING_MIN_IDLE:0}
maximum-pool-size: ${SPRING_DATASOURCE_REPORTING_MAX_POOL_SIZE:3}

server:
port: ${SERVER_PORT:8080}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package ca.bc.gov.nrs.ilcr.configuration;

import static org.assertj.core.api.Assertions.assertThat;

import com.zaxxer.hikari.HikariConfig;
import org.junit.jupiter.api.Test;

/**
* Unit coverage for the Story 29.1 reporting-datasource config. Exercises {@code reportingHikariConfig}
* directly (no {@code HikariDataSource}, so no database connection is opened) to pin the
* isolation-critical invariants: read-only, its own named pool, a small dedicated ceiling, and
* leak-detection left disabled (a Jasper render legitimately holds its connection for the whole fill).
*/
class DataSourceConfigurationTest {

@Test
void reportingHikariConfigIsReadOnlyWithItsOwnSmallPoolAndNoLeakDetection() {
HikariConfig config = DataSourceConfiguration.reportingHikariConfig(
"jdbc:oracle:thin:@//db.example:1521/ILCR",
"THE",
"secret",
"oracle.jdbc.OracleDriver",
"ILCRReportingPool",
3,
0,
30000L,
60000L,
180000L,
60000L,
"SELECT 1 FROM DUAL");

// Report fills never write.
assertThat(config.isReadOnly()).isTrue();
// A distinct, small pool of its own — not the @Primary transactional pool.
assertThat(config.getPoolName()).isEqualTo("ILCRReportingPool");
assertThat(config.getMaximumPoolSize()).isEqualTo(3);
assertThat(config.getMinimumIdle()).isEqualTo(0);

Check warning on line 37 in backend/src/test/java/ca/bc/gov/nrs/ilcr/configuration/DataSourceConfigurationTest.java

View check run for this annotation

SonarQubeCloud / [Interior Logging Costs Reporting] SonarCloud Code Analysis

Use isZero() instead.

See more on https://sonarcloud.io/project/issues?id=bcgov-sonarcloud_nr-ilcr_backend&issues=AaAWLtqbVG3iXKhIPQT3&open=AaAWLtqbVG3iXKhIPQT3&pullRequest=304
// Deliberately unset (0 = disabled): a render holds its connection for the full fill+format, which
// would otherwise trip a leak warning; the small dedicated pool bounds the exposure instead.
assertThat(config.getLeakDetectionThreshold()).isZero();
// Credentials/URL/validation still wired through.
assertThat(config.getJdbcUrl()).isEqualTo("jdbc:oracle:thin:@//db.example:1521/ILCR");
assertThat(config.getConnectionTestQuery()).isEqualTo("SELECT 1 FROM DUAL");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package ca.bc.gov.nrs.ilcr.reporting;

import static org.assertj.core.api.Assertions.assertThat;

import ca.bc.gov.nrs.ilcr.support.AbstractOracleIT;
import com.zaxxer.hikari.HikariDataSource;
import java.sql.Connection;
import java.util.ArrayList;
import java.util.List;
import javax.sql.DataSource;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.test.context.TestPropertySource;

/**
* Story 29.1: proves the Jasper report-fill path draws from a SEPARATE, read-only Hikari pool, so a
* burst of report renders can only exhaust that dedicated pool and never starve the {@code @Primary}
* transactional pool serving ordinary schedule requests.
*/
@TestPropertySource(properties = "ilcr.security.enabled=false")
class ReportingDataSourceIsolationIT extends AbstractOracleIT {

@Autowired private DataSource primaryDataSource; // resolves to the @Primary bean

@Autowired
@Qualifier("reportingDataSource")
private DataSource reportingDataSource;

@Test
void reportingDataSourceIsADistinctReadOnlyPool() throws Exception {
assertThat(reportingDataSource).isNotSameAs(primaryDataSource);

HikariDataSource reportingHikari = reportingDataSource.unwrap(HikariDataSource.class);
assertThat(reportingHikari.getPoolName()).isEqualTo("ILCRReportingPool");
assertThat(reportingHikari.isReadOnly()).isTrue();

try (Connection connection = reportingDataSource.getConnection()) {
assertThat(connection.isReadOnly()).isTrue();
}
}

@Test
void saturatingTheReportingPoolLeavesTheTransactionalPoolAcquirable() throws Exception {
int reportingMax = reportingDataSource.unwrap(HikariDataSource.class).getMaximumPoolSize();

List<Connection> held = new ArrayList<>();
try {
// Hold every connection the reporting pool can hand out — the stand-in for concurrent renders,
// each of which pins a connection for its whole fill.
for (int i = 0; i < reportingMax; i++) {
held.add(reportingDataSource.getConnection());
}
// The transactional pool is untouched: an ordinary request still gets a connection right away.
try (Connection primary = primaryDataSource.getConnection()) {
assertThat(primary.isValid(2)).isTrue();
}
} finally {
for (Connection connection : held) {
connection.close();
}
}
}
}
Loading