Skip to content

Commit 313cc87

Browse files
authored
Merge pull request #107 from FOCONIS/db2-tests
Fix leaking connections when it was not possible to close them.
2 parents d672910 + 6d9c0ab commit 313cc87

File tree

9 files changed

+408
-19
lines changed

9 files changed

+408
-19
lines changed

.github/workflows/build.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ jobs:
2424
java-version: ${{ matrix.java_version }}
2525
distribution: 'zulu'
2626
- name: Maven cache
27-
uses: actions/cache@v2
27+
uses: actions/cache@v4
2828
env:
2929
cache-name: maven-cache
3030
with:

ebean-datasource-api/src/main/java/io/ebean/datasource/DataSourceBuilder.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -749,6 +749,27 @@ default DataSourceBuilder initDatabaseForPlatform(String platform) {
749749
*/
750750
DataSourceBuilder loadSettings(Properties properties, String poolName);
751751

752+
753+
/**
754+
* When enabled, the datasource enforces a clean close. This means, if you close a possible dirty
755+
* connection, that was not committed or rolled back, an exception is thrown.
756+
* <p>
757+
* When disabled, the situation is logged as warning.
758+
* <p>
759+
* This option has no effect on readonly or autocommit connections.
760+
* <p>
761+
* Note: It is recommended to enable this option in tests/test systems to find possible
762+
* programming errors. See https://github.com/ebean-orm/ebean-datasource/issues/116 for details.
763+
*/
764+
DataSourceBuilder enforceCleanClose(boolean enforceCleanClose);
765+
766+
/**
767+
* When <code>true</code>, an exception is thrown when a dirty connection is closed.
768+
* <p>
769+
* See {@link #enforceCleanClose(boolean)}.
770+
*/
771+
boolean enforceCleanClose();
772+
752773
/**
753774
* The settings of the DataSourceBuilder. Provides getters/accessors
754775
* to read the configured properties of this DataSourceBuilder.

ebean-datasource-api/src/main/java/io/ebean/datasource/DataSourceConfig.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ public class DataSourceConfig implements DataSourceBuilder.Settings {
8383
private String applicationName;
8484
private boolean shutdownOnJvmExit;
8585
private boolean validateOnHeartbeat = !System.getenv().containsKey("LAMBDA_TASK_ROOT");
86+
private boolean enforceCleanClose;
8687

8788
@Override
8889
public Settings settings() {
@@ -144,6 +145,7 @@ public DataSourceConfig copy() {
144145
copy.initSql = initSql;
145146
copy.alert = alert;
146147
copy.listener = listener;
148+
copy.enforceCleanClose = enforceCleanClose;
147149
return copy;
148150
}
149151

@@ -798,6 +800,8 @@ private void loadSettings(ConfigPropertiesHelper properties) {
798800
offline = properties.getBoolean("offline", offline);
799801
shutdownOnJvmExit = properties.getBoolean("shutdownOnJvmExit", shutdownOnJvmExit);
800802
validateOnHeartbeat = properties.getBoolean("validateOnHeartbeat", validateOnHeartbeat);
803+
enforceCleanClose = properties.getBoolean("enforceCleanClose", enforceCleanClose);
804+
801805

802806
String isoLevel = properties.get("isolationLevel", _isolationLevel(isolationLevel));
803807
this.isolationLevel = _isolationLevel(isoLevel);
@@ -846,6 +850,17 @@ Map<String, String> parseCustom(String customProperties) {
846850
return propertyMap;
847851
}
848852

853+
@Override
854+
public DataSourceBuilder enforceCleanClose(boolean enforceCleanClose) {
855+
this.enforceCleanClose = enforceCleanClose;
856+
return this;
857+
}
858+
859+
@Override
860+
public boolean enforceCleanClose() {
861+
return enforceCleanClose;
862+
}
863+
849864
/**
850865
* Return the isolation level description from the associated Connection int value.
851866
*/

ebean-datasource-api/src/main/java/io/ebean/datasource/DataSourcePoolListener.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,12 @@ public interface DataSourcePoolListener {
1717
/**
1818
* Called after a connection has been retrieved from the connection pool
1919
*/
20-
void onAfterBorrowConnection(Connection connection);
20+
default void onAfterBorrowConnection(Connection connection) {}
2121

2222
/**
2323
* Called before a connection will be put back to the connection pool
2424
*/
25-
void onBeforeReturnConnection(Connection connection);
25+
default void onBeforeReturnConnection(Connection connection) {}
26+
2627

2728
}

ebean-datasource/pom.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,13 @@
7676
<scope>test</scope>
7777
</dependency>
7878

79+
<dependency>
80+
<groupId>com.ibm.db2</groupId>
81+
<artifactId>jcc</artifactId>
82+
<version>11.5.9.0</version>
83+
<scope>test</scope>
84+
</dependency>
85+
7986
</dependencies>
8087

8188
</project>

ebean-datasource/src/main/java/io/ebean/datasource/pool/ConnectionPool.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ final class ConnectionPool implements DataSourcePool {
5555
private final boolean failOnStart;
5656
private final int maxInactiveMillis;
5757
private final long validateStaleMillis;
58+
private final boolean enforceCleanClose;
5859
/**
5960
* Max age a connection is allowed in millis.
6061
* A value of 0 means no limit (no trimming based on max age).
@@ -128,6 +129,7 @@ final class ConnectionPool implements DataSourcePool {
128129
this.user = params.getUsername();
129130
this.shutdownOnJvmExit = params.isShutdownOnJvmExit();
130131
this.source = DriverDataSource.of(name, params);
132+
this.enforceCleanClose = params.enforceCleanClose();
131133
if (!params.isOffline()) {
132134
init();
133135
}
@@ -516,6 +518,13 @@ boolean invalidConnection(PooledConnection conn) {
516518
}
517519
}
518520

521+
/**
522+
* Fail hard in close() when there is uncommitted work. This is for debugging to find wrong code.
523+
*/
524+
boolean enforceCleanClose() {
525+
return enforceCleanClose;
526+
}
527+
519528
/**
520529
* Called by the PooledConnection themselves, returning themselves to the
521530
* pool when they have been finished with.

ebean-datasource/src/main/java/io/ebean/datasource/pool/PooledConnection.java

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ String description() {
219219
}
220220

221221
String fullDescription() {
222-
return "name[" + name + "] startTime[" + startUseTime() + "] busySeconds[" + busySeconds() + "] stackTrace[" + stackTraceAsString() + "] stmt[" + lastStatement() + "]";
222+
return "name[" + name + "] startTime[" + startUseTime() + "] busySeconds[" + busySeconds() + "] stackTrace[" + stackTraceAsString(stackTrace) + "] stmt[" + lastStatement() + "]";
223223
}
224224

225225
/**
@@ -448,6 +448,11 @@ public void close() throws SQLException {
448448
if (status == STATUS_IDLE) {
449449
throw new SQLException(IDLE_CONNECTION_ACCESSED_ERROR + "close()");
450450
}
451+
boolean mayHaveUncommittedChanges = !autoCommit && !readOnly && status == STATUS_ACTIVE;
452+
if (mayHaveUncommittedChanges && pool.enforceCleanClose()) {
453+
pool.returnConnectionForceClose(this);
454+
throw new AssertionError("Tried to close a dirty connection. See https://github.com/ebean-orm/ebean-datasource/issues/116 for details.");
455+
}
451456
if (hadErrors) {
452457
if (failoverToReadOnly) {
453458
pool.returnConnectionReset(this);
@@ -464,6 +469,11 @@ public void close() throws SQLException {
464469
pool.removeClosedConnection(this);
465470
return;
466471
}
472+
if (mayHaveUncommittedChanges) {
473+
Log.warn("Tried to close a dirty connection at {0}. See https://github.com/ebean-orm/ebean-datasource/issues/116 for details.",
474+
stackTraceAsString(Thread.currentThread().getStackTrace()));
475+
connection.rollback();
476+
}
467477
// reset the autoCommit back if client code changed it
468478
if (autoCommit != pool.isAutoCommit()) {
469479
connection.setAutoCommit(pool.isAutoCommit());
@@ -942,35 +952,23 @@ void setStackTrace(StackTraceElement[] stackTrace) {
942952
/**
943953
* Return the stackTrace as a String for logging purposes.
944954
*/
945-
private String stackTraceAsString() {
946-
StackTraceElement[] stackTrace = stackTrace();
955+
private String stackTraceAsString(StackTraceElement[] stackTrace) {
947956
if (stackTrace == null) {
948957
return "";
949958
}
950-
return Arrays.toString(stackTrace);
951-
}
952-
953-
/**
954-
* Return the full stack trace that got the connection from the pool. You
955-
* could use this if getCreatedByMethod() doesn't work for you.
956-
*/
957-
private StackTraceElement[] stackTrace() {
958-
if (stackTrace == null) {
959-
return null;
960-
}
961959

962960
// filter off the top of the stack that we are not interested in
963961
ArrayList<StackTraceElement> filteredList = new ArrayList<>();
964962
boolean include = false;
965963
for (StackTraceElement stackTraceElement : stackTrace) {
966-
if (!include && includeMethodLine(stackTraceElement.toString())) {
964+
if (!include && includeMethodLine(stackTraceElement.getClassName())) {
967965
include = true;
968966
}
969967
if (include && filteredList.size() < maxStackTrace) {
970968
filteredList.add(stackTraceElement);
971969
}
972970
}
973-
return filteredList.toArray(new StackTraceElement[0]);
971+
return filteredList.toString();
974972
}
975973

976974
}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
package io.ebean.datasource.pool;
2+
3+
import ch.qos.logback.classic.Level;
4+
import ch.qos.logback.classic.spi.ILoggingEvent;
5+
import ch.qos.logback.core.read.ListAppender;
6+
import io.ebean.datasource.DataSourceBuilder;
7+
import io.ebean.datasource.DataSourcePool;
8+
import org.junit.jupiter.api.AfterAll;
9+
import org.junit.jupiter.api.BeforeAll;
10+
import org.junit.jupiter.api.BeforeEach;
11+
import org.junit.jupiter.api.Test;
12+
import org.slf4j.LoggerFactory;
13+
14+
import java.sql.Connection;
15+
import java.sql.SQLException;
16+
import java.sql.SQLSyntaxErrorException;
17+
import java.util.List;
18+
import java.util.stream.Collectors;
19+
20+
import static org.assertj.core.api.Assertions.assertThat;
21+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
22+
23+
/**
24+
* Tests, if connections are clean when closing them.
25+
* <p>
26+
* Test must run with "do not use module path" option in IntelliJ.
27+
* <p>
28+
* See also https://github.com/ebean-orm/ebean-datasource/issues/116 for more details
29+
*/
30+
class ConnectionPoolCloseTest {
31+
32+
private static ListAppender<ILoggingEvent> logCapture = new ListAppender<>();
33+
34+
private static DataSourcePool pool = createPool(false, false);
35+
private static DataSourcePool poolRo = createPool(false, true);
36+
private static DataSourcePool poolEnforce = createPool(true, false);
37+
private static DataSourcePool poolEnforceRo = createPool(true, true);
38+
39+
40+
@BeforeAll
41+
static void before() {
42+
// attach logback appender to capture log messages
43+
((ch.qos.logback.classic.Logger) LoggerFactory.getLogger("io.ebean.datasource")).addAppender(logCapture);
44+
logCapture.start();
45+
}
46+
47+
@BeforeEach
48+
void beforeEach() {
49+
logCapture.list.clear();
50+
}
51+
52+
@AfterAll
53+
static void after() {
54+
logCapture.stop();
55+
pool.shutdown();
56+
poolRo.shutdown();
57+
poolEnforce.shutdown();
58+
poolEnforceRo.shutdown();
59+
}
60+
61+
@Test
62+
void testNoCommitOrRollback() throws SQLException {
63+
64+
doNoCommitOrRollback(pool);
65+
assertThat(getAndResetLogWarinings())
66+
.hasSize(1)
67+
.first().asString().startsWith("[WARN] Tried to close a dirty connection at");
68+
69+
assertThatThrownBy(() -> doNoCommitOrRollback(poolEnforce)).isInstanceOf(AssertionError.class);
70+
71+
doNoCommitOrRollback(poolRo);
72+
assertThat(getAndResetLogWarinings()).isEmpty();
73+
74+
doNoCommitOrRollback(poolEnforceRo);
75+
assertThat(getAndResetLogWarinings()).isEmpty();
76+
77+
}
78+
79+
@Test
80+
void testCommit() throws SQLException {
81+
doCommit(pool);
82+
doCommit(poolRo);
83+
doCommit(poolEnforce);
84+
doCommit(poolEnforceRo);
85+
assertThat(getAndResetLogWarinings()).isEmpty();
86+
}
87+
88+
@Test
89+
void testRollback() throws SQLException {
90+
doRollback(pool);
91+
doRollback(poolRo);
92+
doRollback(poolEnforce);
93+
doRollback(poolEnforceRo);
94+
assertThat(getAndResetLogWarinings()).isEmpty();
95+
}
96+
97+
@Test
98+
void testExecuteValidSql() throws SQLException {
99+
doExecute(pool, "SELECT 1");
100+
doExecute(poolRo, "SELECT 1");
101+
doExecute(poolEnforce, "SELECT 1");
102+
doExecute(poolEnforceRo, "SELECT 1");
103+
assertThat(getAndResetLogWarinings()).isEmpty();
104+
}
105+
106+
@Test
107+
void testExecuteInvalidSql() throws SQLException {
108+
assertThatThrownBy(() -> doExecute(pool, "invalid query"))
109+
.isInstanceOf(SQLSyntaxErrorException.class)
110+
.hasNoSuppressedExceptions();
111+
// (un)fortunately, we will log a missing rollback, when exception in try-with-resourches is thrown.
112+
assertThat(getAndResetLogWarinings())
113+
.hasSize(1)
114+
.first().asString().startsWith("[WARN] Tried to close a dirty connection at");
115+
116+
assertThatThrownBy(() -> doExecute(poolEnforce, "invalid query"))
117+
.isInstanceOf(SQLSyntaxErrorException.class)
118+
.hasSuppressedException(new AssertionError("Tried to close a dirty connection. See https://github.com/ebean-orm/ebean-datasource/issues/116 for details."));
119+
120+
assertThatThrownBy(() -> doExecute(poolRo, "invalid query"))
121+
.isInstanceOf(SQLSyntaxErrorException.class)
122+
.hasNoSuppressedExceptions();
123+
124+
assertThatThrownBy(() -> doExecute(poolEnforceRo, "invalid query"))
125+
.isInstanceOf(SQLSyntaxErrorException.class)
126+
.hasNoSuppressedExceptions();
127+
assertThat(getAndResetLogWarinings()).isEmpty();
128+
}
129+
130+
private static void doNoCommitOrRollback(DataSourcePool pool) throws SQLException {
131+
try (Connection connection = pool.getConnection()) {
132+
// we do nothing here.
133+
}
134+
}
135+
136+
private static void doCommit(DataSourcePool pool) throws SQLException {
137+
try (Connection connection = pool.getConnection()) {
138+
connection.commit();
139+
}
140+
}
141+
142+
private static void doRollback(DataSourcePool pool) throws SQLException {
143+
try (Connection connection = pool.getConnection()) {
144+
connection.rollback();
145+
}
146+
}
147+
148+
private static void doExecute(DataSourcePool pool, String query) throws SQLException {
149+
try (Connection connection = pool.getConnection()) {
150+
connection.createStatement().execute(query);
151+
connection.commit();
152+
}
153+
}
154+
155+
private static List<ILoggingEvent> getAndResetLogWarinings() {
156+
List<ILoggingEvent> warnings = logCapture.list.stream().filter(e -> e.getLevel().levelInt >= Level.WARN_INT).collect(Collectors.toList());
157+
logCapture.list.clear();
158+
return warnings;
159+
}
160+
161+
private static DataSourcePool createPool(boolean enforceCleanClose, boolean readOnly) {
162+
DataSourcePool ret = DataSourceBuilder.create()
163+
.url("jdbc:h2:mem:tests")
164+
.username("sa")
165+
.password("")
166+
.minConnections(2)
167+
.maxConnections(4)
168+
.enforceCleanClose(enforceCleanClose)
169+
.readOnly(readOnly)
170+
.build();
171+
// clear capturer after create
172+
logCapture.list.clear();
173+
return ret;
174+
}
175+
}

0 commit comments

Comments
 (0)