Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
29 changes: 24 additions & 5 deletions documents/configuration/ojp-jdbc-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,21 @@ The OJP JDBC driver supports configurable connection pool settings via an `ojp.p

## Connection Close Behavior

When application code calls `Connection.close()`, the OJP JDBC driver now performs session termination as a **synchronous** operation.
When application code calls `Connection.close()`, the OJP JDBC driver performs session termination **asynchronously by default**.

That means the close call waits for the server to acknowledge session termination instead of sending the request in a fire-and-forget way.
That means the close call returns immediately and session termination runs in the background by default.

If you want strict synchronous behavior, set:

```properties
ojp.jdbc.connection.close.synchronous=true
```

This property also supports datasource prefixes:

```properties
myApp.ojp.jdbc.connection.close.synchronous=true
```

### Retry Rules

Expand All @@ -28,9 +40,10 @@ The driver does **not** retry when the failure is not transient connectivity, fo

### Why This Matters

This makes `close()` more reliable during transient network issues and reduces the chance of leaving server-side sessions alive longer than necessary.
This keeps close-path latency low by default while still allowing strict blocking behavior when required.

If all 3 connection-level attempts fail, `close()` fails and surfaces the error to the caller.
With asynchronous close (default), termination failures are logged.
With synchronous close (`ojp.jdbc.connection.close.synchronous=true`), failures are surfaced to the caller.

## Multi-DataSource Configuration

Expand Down Expand Up @@ -309,6 +322,12 @@ These examples demonstrate recommended settings for each environment and can be
- `ojp.connection.pool.maximumPoolSize=20` (default datasource)
- `myApp.ojp.connection.pool.maximumPoolSize=50` (myApp datasource)

### JDBC Client Properties

| Property | Type | Default | Description | Since |
|----------|------|---------|-------------|-------|
| `ojp.jdbc.connection.close.synchronous` | boolean | false | Controls `Connection.close()` behavior. `false` = async close (default), `true` = close waits for terminate-session RPC. | 0.4.2-beta |

### Programmatic Configuration via `DriverManager.getConnection()`

In addition to `ojp.properties` files and system properties, you can supply pool configuration
Expand Down Expand Up @@ -338,7 +357,7 @@ Connection conn = DriverManager.getConnection(
| 3 | System properties | `-Dojp.connection.pool.maximumPoolSize=50` |
| 4 | Properties file (`ojp.properties`) | `ojp.connection.pool.maximumPoolSize=50` |

Only `ojp.connection.pool.*` and `ojp.xa.*` keys are read from `info`.
Only `ojp.connection.pool.*`, `ojp.xa.*`, and `ojp.jdbc.*` keys are read from `info`.
JDBC-standard keys such as `user` and `password` are extracted separately and are never
treated as pool configuration.

Expand Down
15 changes: 12 additions & 3 deletions documents/ebook/part2-chapter5-jdbc-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,9 +177,15 @@ String orOpts = "jdbc:ojp[localhost:1059]_oracle:thin:@localhost:1521/XEPDB1?" +

### Connection Close Semantics

When your application calls `Connection.close()`, the OJP JDBC driver terminates the server-side session **synchronously**. In practical terms, the close call waits for the termination RPC to complete instead of sending it as fire-and-forget traffic.
When your application calls `Connection.close()`, the OJP JDBC driver terminates the server-side session **asynchronously by default**. In practical terms, `close()` returns immediately and termination runs in the background.

This behavior is intentionally conservative because session termination is what releases the server-side session state that OJP uses to track transactional and result-set resources.
This default behavior is performance-oriented because session termination runs in the background and avoids blocking application threads on close.

If you need blocking behavior, set:

```properties
ojp.jdbc.connection.close.synchronous=true
```

#### Retry Behavior During Close

Expand All @@ -195,7 +201,10 @@ The driver does **not** retry when the failure indicates that retrying will not
- server-side `SQLException`
- other non-connectivity failures

So the operational rule is simple: **`close()` now includes a bounded retry for transient connection failures, but not for logical or database errors.**
So the operational rule is simple:

- default (`ojp.jdbc.connection.close.synchronous=false`): async close, failures are logged
- configured synchronous (`true`): close waits, and failures are surfaced to the caller

---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ public class CommonConstants {
// Multinode configuration property keys
public static final String MULTINODE_RETRY_ATTEMPTS_PROPERTY = "ojp.multinode.retryAttempts";
public static final String MULTINODE_RETRY_DELAY_PROPERTY = "ojp.multinode.retryDelayMs";
public static final String JDBC_CLOSE_SYNC_PROPERTY = "ojp.jdbc.connection.close.synchronous";

// Transaction isolation configuration property key
public static final String DEFAULT_TRANSACTION_ISOLATION_PROPERTY = "ojp.connection.pool.defaultTransactionIsolation";
Expand Down Expand Up @@ -96,6 +97,7 @@ public class CommonConstants {
// Multinode configuration defaults - addressing PR #39 review comment #1
public static final int DEFAULT_MULTINODE_RETRY_ATTEMPTS = -1; // -1 = retry indefinitely
public static final long DEFAULT_MULTINODE_RETRY_DELAY_MS = 5000; // 5 seconds between retries
public static final boolean DEFAULT_JDBC_CLOSE_SYNCHRONOUS = false;

// XA pool evictor defaults (Apache Commons Pool 2)
public static final long DEFAULT_XA_TIME_BETWEEN_EVICTION_RUNS_MS = 30000; // 30 seconds
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,28 @@
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;

@Slf4j
public class Connection implements java.sql.Connection {

private static final int COMPUTED_ASYNC_CLOSE_EXECUTOR_SIZE = Math.max(2, Math.min(8,
Runtime.getRuntime().availableProcessors()));
private static final AtomicInteger ASYNC_CLOSE_THREAD_COUNTER = new AtomicInteger();
// Daemon executor intentionally lives for JVM lifetime; close tasks are lightweight and infrequent.
private static final ExecutorService ASYNC_CLOSE_EXECUTOR = Executors.newFixedThreadPool(
COMPUTED_ASYNC_CLOSE_EXECUTOR_SIZE,
runnable -> {
Thread thread = new Thread(runnable);
thread.setName("ojp-jdbc-close-" + ASYNC_CLOSE_THREAD_COUNTER.incrementAndGet());
thread.setDaemon(true);
return thread;
});

@Getter
@Setter
private SessionInfo session;
Expand All @@ -42,15 +59,21 @@ public class Connection implements java.sql.Connection {
private boolean autoCommit = true;
private boolean readOnly = false;
private boolean closed;
private final boolean closeSynchronously;

// For server recovery and connection redistribution
private volatile boolean forceInvalid = false;

public Connection(SessionInfo session, StatementService statementService, DbName dbName) {
this(session, statementService, dbName, CommonConstants.DEFAULT_JDBC_CLOSE_SYNCHRONOUS);
}

public Connection(SessionInfo session, StatementService statementService, DbName dbName, boolean closeSynchronously) {
this.session = session;
this.statementService = statementService;
this.closed = false;
this.dbName = dbName;
this.closeSynchronously = closeSynchronously;
}

/**
Expand Down Expand Up @@ -181,7 +204,19 @@ public void close() throws SQLException {
// Always call terminateSession to ensure server-side resources are released
// This is critical for multinode scenarios where connect() may have been called on multiple servers
if (this.session != null) {
this.statementService.terminateSession(this.session);
// Capture before nulling to ensure async termination uses the original session reference.
SessionInfo sessionToTerminate = this.session;
if (this.closeSynchronously) {
this.statementService.terminateSession(sessionToTerminate);
} else {
CompletableFuture.runAsync(() -> {
try {
this.statementService.terminateSession(sessionToTerminate);
} catch (SQLException e) {
log.warn("Async terminateSession failed for session {}", sessionToTerminate.getSessionUUID(), e);
}
}, ASYNC_CLOSE_EXECUTOR);
}
this.session = null;
}
this.closed = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,16 @@ public class DatasourcePropertiesLoader {
private static final String DEFAULT_DATASOURCE_NAME = "default";
private static final String OJP_POOL_PREFIX = "ojp.connection.pool.";
private static final String OJP_XA_PREFIX = "ojp.xa.";
private static final String OJP_JDBC_PREFIX = "ojp.jdbc.";

/**
* Apply OJP-relevant properties from the JDBC {@code info} {@link Properties} argument
* (passed to {@link java.sql.Driver#connect}) on top of the existing {@code base} properties.
*
* <p>Properties from {@code info} have the <em>highest</em> precedence and override any value
* already present in {@code base} (which was loaded from the properties file, system properties,
* or environment variables). Only keys matching the {@code ojp.connection.pool.*} or
* {@code ojp.xa.*} pattern are copied; JDBC-standard keys such as {@code user} and
* or environment variables). Only keys matching the {@code ojp.connection.pool.*},
* {@code ojp.xa.*}, or {@code ojp.jdbc.*} pattern are copied; JDBC-standard keys such as {@code user} and
* {@code password} are intentionally ignored.
*
* <p>Full property precedence after merging (highest to lowest):
Expand Down Expand Up @@ -152,11 +153,15 @@ private static void applyEnvProperties(Properties result, String prefixDot, bool
}

private static boolean hasPrefixedOjpKey(String key, String prefixDot) {
return key.startsWith(prefixDot + OJP_POOL_PREFIX) || key.startsWith(prefixDot + OJP_XA_PREFIX);
return key.startsWith(prefixDot + OJP_POOL_PREFIX)
|| key.startsWith(prefixDot + OJP_XA_PREFIX)
|| key.startsWith(prefixDot + OJP_JDBC_PREFIX);
}

private static boolean isUnprefixedOjpKey(String key) {
return key.startsWith(OJP_POOL_PREFIX) || key.startsWith(OJP_XA_PREFIX);
return key.startsWith(OJP_POOL_PREFIX)
|| key.startsWith(OJP_XA_PREFIX)
|| key.startsWith(OJP_JDBC_PREFIX);
}

private static void copyUnprefixedOjpProperties(Properties target, Properties source) {
Expand Down
11 changes: 9 additions & 2 deletions ojp-jdbc-driver/src/main/java/org/openjproxy/jdbc/Driver.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.openjproxy.grpc.ConnectionDetails;
import com.openjproxy.grpc.SessionInfo;
import lombok.extern.slf4j.Slf4j;
import org.openjproxy.constants.CommonConstants;
import org.openjproxy.database.DatabaseUtils;
import org.openjproxy.grpc.ProtoConverter;
import org.openjproxy.grpc.client.MultinodeUrlParser;
Expand Down Expand Up @@ -75,7 +76,7 @@ public java.sql.Connection connect(String url, Properties info) throws SQLExcept
}

// Load ojp.properties file and extract datasource-specific configuration.
// Then merge any ojp.connection.pool.* / ojp.xa.* keys from the caller-supplied info
// Then merge any ojp.connection.pool.* / ojp.xa.* / ojp.jdbc.* keys from the caller-supplied info
// on top (info properties take the highest priority).
Properties ojpProperties = DatasourcePropertiesLoader.loadOjpPropertiesForDataSource(dataSourceName);
ojpProperties = DatasourcePropertiesLoader.applyInfoProperties(ojpProperties, info, dataSourceName);
Expand Down Expand Up @@ -119,8 +120,14 @@ public java.sql.Connection connect(String url, Properties info) throws SQLExcept
log.error("Failed to establish connection", e);
throw e;
}
boolean closeSynchronously = Boolean.parseBoolean(
ojpProperties != null
? ojpProperties.getProperty(
CommonConstants.JDBC_CLOSE_SYNC_PROPERTY,
String.valueOf(CommonConstants.DEFAULT_JDBC_CLOSE_SYNCHRONOUS))
: String.valueOf(CommonConstants.DEFAULT_JDBC_CLOSE_SYNCHRONOUS));
log.debug("Returning new Connection with sessionInfo: {}", sessionInfo);
return new Connection(sessionInfo, statementService, DatabaseUtils.resolveDbName(cleanUrl));
return new Connection(sessionInfo, statementService, DatabaseUtils.resolveDbName(cleanUrl), closeSynchronously);
}


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package org.openjproxy.jdbc;

import com.openjproxy.grpc.DbName;
import com.openjproxy.grpc.SessionInfo;
import org.junit.jupiter.api.Test;
import org.openjproxy.grpc.client.StatementService;

import java.lang.reflect.Proxy;
import java.sql.SQLException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

class ConnectionCloseBehaviorTest {

@Test
void shouldTerminateSessionAsynchronouslyByDefault() throws SQLException, InterruptedException {
CountDownLatch terminated = new CountDownLatch(1);
StatementService statementService = createStatementService(session -> terminated.countDown());
Connection connection = new Connection(buildSessionInfo(), statementService, DbName.POSTGRES);

connection.close();

assertTrue(connection.isClosed());
assertTrue(terminated.await(2, TimeUnit.SECONDS));
}

@Test
void shouldNotPropagateTerminateFailureWhenCloseIsAsynchronous() throws SQLException, InterruptedException {
CountDownLatch terminated = new CountDownLatch(1);
StatementService statementService = createStatementService(session -> {
terminated.countDown();
throw new SQLException("terminate failed");
});
Connection connection = new Connection(buildSessionInfo(), statementService, DbName.POSTGRES);

assertDoesNotThrow(connection::close);
assertTrue(connection.isClosed());
assertTrue(terminated.await(2, TimeUnit.SECONDS));
}

@Test
void shouldPropagateTerminateFailureWhenCloseIsConfiguredSynchronous() {
StatementService statementService = createStatementService(session -> {
throw new SQLException("terminate failed");
});
Connection connection = new Connection(buildSessionInfo(), statementService, DbName.POSTGRES, true);

assertThrows(SQLException.class, connection::close);
}

private SessionInfo buildSessionInfo() {
return SessionInfo.newBuilder()
.setSessionUUID("session-1")
.setConnHash("conn-hash")
.build();
}

private StatementService createStatementService(TerminateSessionBehavior behavior) {
AtomicInteger calls = new AtomicInteger();
return (StatementService) Proxy.newProxyInstance(
StatementService.class.getClassLoader(),
new Class<?>[]{StatementService.class},
(proxy, method, args) -> {
if ("terminateSession".equals(method.getName())) {
calls.incrementAndGet();
behavior.terminate((SessionInfo) args[0]);
return null;
}
if ("toString".equals(method.getName())) {
return "TestStatementService";
}
if ("hashCode".equals(method.getName())) {
return System.identityHashCode(proxy);
}
if ("equals".equals(method.getName())) {
return proxy == args[0];
}
throw new UnsupportedOperationException("Unexpected method call: " + method.getName()
+ " calls=" + calls.get());
});
}

@FunctionalInterface
private interface TerminateSessionBehavior {
void terminate(SessionInfo sessionInfo) throws SQLException;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -225,4 +225,15 @@ void shouldMergePoolAndXaPropertiesFromInfo() {
assertEquals("myApp", result.getProperty(CommonConstants.DATASOURCE_NAME_PROPERTY),
"Datasource name from base should be preserved");
}

@Test
void shouldApplyJdbcClosePropertyFromInfo() {
Properties info = new Properties();
info.setProperty(CommonConstants.JDBC_CLOSE_SYNC_PROPERTY, "true");

Properties result = DatasourcePropertiesLoader.applyInfoProperties(null, info, "default");

assertNotNull(result);
assertEquals("true", result.getProperty(CommonConstants.JDBC_CLOSE_SYNC_PROPERTY));
}
}
Loading