A 50,000-Row JDBC Import That Leaks Connections and Commits Partially
The fulfillment team's nightly import loads 50,000 rows into ORDERS and roughly 150,000 into ORDER_LINE from a CSV, using a 10-connection HikariCP pool. Current code borrows a connection for each row, leaves autocommit on, builds a new PreparedStatement per row, calls executeUpdate, and closes the connection only on the happy path. Symptoms: the job takes about 40 minutes; after roughly 10 minutes other services log 'Connection is not available, request timed out after 30000ms'; and when row 12,001 violates a foreign key, rows 1โ12,000 are already committed while the rest are not, so a human cleans up by hand. The business wants the good rows loaded, bad rows skipped and reported with line numbers, and no partial-file state that needs manual repair. A DBA has warned that a trigger on ORDERS can also raise an error per row.
1. The load is not atomic and the business wants per-row recovery. Design the transaction strategy, including its database-specific caveats.
Read the worked answer
The partial load is a transaction-boundary problem, not a parsing problem. With autoCommit=true each executeUpdate is its own transaction, so 12,000 successes are durable and the failure only aborts row 12,001.
Baseline fix for all-or-nothing: conn.setAutoCommit(false), run the whole file, commit() once and rollback() in the catch block. That contradicts the skip-bad-rows requirement, so use savepoints per row (or per small chunk):
try (Connection c = ds.getConnection()) {
c.setAutoCommit(false);
Savepoint sp = c.setSavepoint();
try { insertOrder(c, row); }
catch (SQLException e) {
c.rollback(sp);
failures.add(row.lineNumber() + " -> " + e.getMessage());
}
c.commit();
}
Caveats to verify on the actual database: PostgreSQL aborts the entire transaction on any statement error, so the rollback(sp) must happen before continuing; some drivers implicitly commit before DDL; and savepoints cost a round trip each, so benchmark 200,000 rows. If a parent row fails, its ORDER_LINE children must be skipped together - group by order id and use one savepoint per order, not per line. Finally, never toggle autoCommit on a pooled connection without restoring it, because the proxy returns the same physical connection to the next caller.
2. Where does the 40 minutes go? Propose the batching and statement-reuse changes and explain how batch failures are attributed to rows.
Read the worked answer
The time goes into one network round trip per row, a parse/plan per statement, and a commit (with fsync) per row. Three independent fixes:
- Batch: reuse a single
PreparedStatement,addBatch()per row, callexecuteBatch()every 500โ1,000 rows, thenclearBatch(). This cuts round trips by orders of magnitude. Avoid one giant batch: the driver buffers parameters in memory and a mid-batch failure is harder to attribute. - Reuse the statement and connection: prepare once per batch rather than per row; the server-side statement cache can then help.
- Commit per batch, not per row: 1,000 rows per commit is a common compromise between throughput and recovery cost.
Batch failure semantics matter. executeBatch can partially succeed. Catch BatchUpdateException, inspect getUpdateCounts(): a driver that returns EXECUTE_FAILED for the offending element lets you map failure to the exact row, but many drivers stop at the first failure and return counts only up to that point. In that case you cannot use a large batch for error recovery - fall back to per-row executeUpdate with a savepoint (slower but attributable), or pre-validate rows and quarantine suspects. Use executeLargeBatch only if a single batch can exceed Integer.MAX_VALUE rows, which is unrealistic here; the more common pitfall is forgetting clearBatch() and re-sending the previous batch.
3. Explain the pool exhaustion, and give a resource-management pattern that covers suppressed exceptions and leak detection.
Read the worked answer
The timeout is a leak, not a sizing problem: a connection borrowed on a path that throws before close() never returns to the pool. After ten such failures the pool is empty and every caller blocks until the 30-second timeout. Enlarging the pool only delays the failure and increases pressure on the database.
Pattern:
- try-with-resources on every borrow, declaring resources in creation order so they close in reverse:
try (Connection c = ds.getConnection(); PreparedStatement ps = c.prepareStatement(SQL)) { ... }. Closing aStatementcloses itsResultSet, so that nesting is sufficient and the connection returns to the pool even when aSQLExceptionpropagates. Add aResultSetto the declaration list only when it is used across method boundaries or you need to inspect it after the statement closes. - Suppressed exceptions. If closing a resource throws while a
SQLExceptionis in flight, the close exception is attached and retrievable viagetSuppressed(). Log both: the primary exception is the business failure, and the suppressed one frequently reveals the real cause, such as a broken connection or a commit failure during rollback. - Leak detection. Set
HikariConfig.setLeakDetectionThreshold(20000); it logs a stack trace of the borrower that never returned, which is how you find the remaining offending path. Watch pool metrics during the run. - Never cache a
Connectionin a field or a static, and never close theDataSourcein the same try-with-resources block as a borrowed connection. For generated keys, requestStatement.RETURN_GENERATED_KEYSon the prepared statement and read them immediately afterexecuteUpdate, before the next batch iteration overwrites the result.