From b2166616bae57a1278757e865f85918ea150af2f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 12:33:00 +0000 Subject: [PATCH 01/25] feat: Phase 1 read/write splitting configuration infrastructure - Add readwrite/ package: ReadWriteConfiguration, ReadWriteConfigurationParser, ReadWriteDataSourceRegistry, ReadWriteDataSourceManager - Wire registry into ActionContext, StatementServiceImpl, ConnectAction - Update DatasourcePropertiesLoader to forward full .ojp.* keys to server - Add unit tests for all 3 server config classes - Add H2ReadWriteSplittingEndToEndTest integration test Agent-Logs-Url: https://github.com/Open-J-Proxy/ojp/sessions/f22a5174-b9f5-4721-829c-9d776642a2bd Co-authored-by: rrobetti <7221783+rrobetti@users.noreply.github.com> --- .../jdbc/DatasourcePropertiesLoader.java | 68 ++- .../H2ReadWriteSplittingEndToEndTest.java | 478 ++++++++++++++++++ .../grpc/server/StatementServiceImpl.java | 4 + .../grpc/server/action/ActionContext.java | 14 + .../action/connection/ConnectAction.java | 63 +++ .../ReadWriteConfiguration$Builder.class | Bin 0 -> 2423 bytes ...nfiguration$ReplicaSelectionStrategy.class | Bin 0 -> 1514 bytes .../readwrite/ReadWriteConfiguration.class | Bin 0 -> 4211 bytes .../readwrite/ReadWriteConfiguration.java | 177 +++++++ .../ReadWriteConfigurationParser.class | Bin 0 -> 7871 bytes .../ReadWriteConfigurationParser.java | 251 +++++++++ .../ReadWriteDataSourceManager.class | Bin 0 -> 8087 bytes .../readwrite/ReadWriteDataSourceManager.java | 213 ++++++++ .../ReadWriteDataSourceRegistry.class | Bin 0 -> 4352 bytes .../ReadWriteDataSourceRegistry.java | 166 ++++++ .../ReadWriteConfigurationParserTest.java | 291 +++++++++++ .../readwrite/ReadWriteConfigurationTest.java | 208 ++++++++ .../ReadWriteDataSourceManagerTest.java | 183 +++++++ 18 files changed, 2097 insertions(+), 19 deletions(-) create mode 100644 ojp-jdbc-driver/src/test/java/openjproxy/jdbc/H2ReadWriteSplittingEndToEndTest.java create mode 100644 ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfiguration$Builder.class create mode 100644 ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfiguration$ReplicaSelectionStrategy.class create mode 100644 ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfiguration.class create mode 100644 ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfiguration.java create mode 100644 ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfigurationParser.class create mode 100644 ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfigurationParser.java create mode 100644 ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceManager.class create mode 100644 ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceManager.java create mode 100644 ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceRegistry.class create mode 100644 ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceRegistry.java create mode 100644 ojp-server/src/test/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfigurationParserTest.java create mode 100644 ojp-server/src/test/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfigurationTest.java create mode 100644 ojp-server/src/test/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceManagerTest.java diff --git a/ojp-jdbc-driver/src/main/java/org/openjproxy/jdbc/DatasourcePropertiesLoader.java b/ojp-jdbc-driver/src/main/java/org/openjproxy/jdbc/DatasourcePropertiesLoader.java index 0bed6378e..1363d050a 100644 --- a/ojp-jdbc-driver/src/main/java/org/openjproxy/jdbc/DatasourcePropertiesLoader.java +++ b/ojp-jdbc-driver/src/main/java/org/openjproxy/jdbc/DatasourcePropertiesLoader.java @@ -17,6 +17,12 @@ * For example, properties prefixed with {@code mainApp.ojp.connection.pool.*} belong to * the datasource named {@code mainApp}. Unprefixed {@code ojp.connection.pool.*} properties * belong to the implicit {@code "default"} datasource. + * + *

All {@code *.ojp.*} properties (e.g. read/write splitting, replica connection URLs) are + * forwarded to the server with their full keys intact so that server-side parsers such as + * {@code ReadWriteConfigurationParser} can find them. Pool and XA properties for the primary + * datasource are additionally forwarded with the datasource prefix stripped for backward + * compatibility with existing server-side pool configuration readers. */ @Slf4j public class DatasourcePropertiesLoader { @@ -65,9 +71,16 @@ private static void applyFileProperties(Properties result, Properties source, String prefixDot, boolean isDefault) { boolean found = false; for (String key : source.stringPropertyNames()) { - if (hasPrefixedOjpKey(key, prefixDot)) { - result.setProperty(key.substring(prefixDot.length()), source.getProperty(key)); + String value = source.getProperty(key); + if (hasPrefixedPoolOrXaKey(key, prefixDot)) { + // Pool and XA properties for this datasource: strip prefix for backward compat + // Example: "myapp.ojp.connection.pool.maxPoolSize=10" → "ojp.connection.pool.maxPoolSize=10" + result.setProperty(key.substring(prefixDot.length()), value); found = true; + } else if (isPrefixedOjpKey(key)) { + // Keep full key for read/write splitting and replica configs so the server can find them + // Example: "replica1.ojp.readwrite.primary=myapp" stays as-is + result.setProperty(key, value); } } if (!found && isDefault) { @@ -76,38 +89,55 @@ private static void applyFileProperties(Properties result, Properties source, } private static void applySystemProperties(Properties result, String prefixDot, boolean isDefault) { - for (String key : System.getProperties().stringPropertyNames()) { - String value = System.getProperty(key); - if (hasPrefixedOjpKey(key, prefixDot)) { - String std = key.substring(prefixDot.length()); - result.setProperty(std, value); - log.debug("Overriding property from system property: {} = {}", std, value); - } else if (isDefault && isUnprefixedOjpKey(key)) { - result.setProperty(key, value); - log.debug("Overriding property from system property: {} = {}", key, value); - } - } + applyNormalizedProperties(result, System.getProperties(), prefixDot, isDefault, "system property"); } private static void applyEnvProperties(Properties result, String prefixDot, boolean isDefault) { + Properties normalized = new Properties(); for (Map.Entry entry : System.getenv().entrySet()) { - String key = entry.getKey().toLowerCase().replace('_', '.'); - String value = entry.getValue(); - if (hasPrefixedOjpKey(key, prefixDot)) { + normalized.setProperty(entry.getKey().toLowerCase().replace('_', '.'), entry.getValue()); + } + applyNormalizedProperties(result, normalized, prefixDot, isDefault, "environment variable"); + } + + /** + * Applies properties from a source (system properties or environment variables) to the result. + */ + private static void applyNormalizedProperties(Properties result, Properties source, + String prefixDot, boolean isDefault, String sourceName) { + for (String key : source.stringPropertyNames()) { + String value = source.getProperty(key); + if (hasPrefixedPoolOrXaKey(key, prefixDot)) { String std = key.substring(prefixDot.length()); result.setProperty(std, value); - log.debug("Overriding property from environment variable: {} = {}", std, value); + log.debug("Overriding property from {}: {} = {}", sourceName, std, value); + } else if (isPrefixedOjpKey(key)) { + result.setProperty(key, value); + log.debug("Setting property from {} (full key): {} = {}", sourceName, key, value); } else if (isDefault && isUnprefixedOjpKey(key)) { result.setProperty(key, value); - log.debug("Overriding property from environment variable: {} = {}", key, value); + log.debug("Overriding property from {}: {} = {}", sourceName, key, value); } } } - private static boolean hasPrefixedOjpKey(String key, String prefixDot) { + /** + * Checks if a property key is a pool or XA property for a specific datasource. + * These properties get their prefix stripped for backward compatibility. + */ + private static boolean hasPrefixedPoolOrXaKey(String key, String prefixDot) { return key.startsWith(prefixDot + OJP_POOL_PREFIX) || key.startsWith(prefixDot + OJP_XA_PREFIX); } + /** + * Returns true for any property that contains {@code .ojp.} in its key, i.e. any prefixed + * OJP property regardless of the datasource name prefix. These are forwarded with their + * full key so that server-side parsers (e.g. read/write splitting) can find them. + */ + private static boolean isPrefixedOjpKey(String key) { + return key.contains(".ojp."); + } + private static boolean isUnprefixedOjpKey(String key) { return key.startsWith(OJP_POOL_PREFIX) || key.startsWith(OJP_XA_PREFIX); } diff --git a/ojp-jdbc-driver/src/test/java/openjproxy/jdbc/H2ReadWriteSplittingEndToEndTest.java b/ojp-jdbc-driver/src/test/java/openjproxy/jdbc/H2ReadWriteSplittingEndToEndTest.java new file mode 100644 index 000000000..75a2fc5ba --- /dev/null +++ b/ojp-jdbc-driver/src/test/java/openjproxy/jdbc/H2ReadWriteSplittingEndToEndTest.java @@ -0,0 +1,478 @@ +package openjproxy.jdbc; + +import lombok.SneakyThrows; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.Statement; +import java.sql.SQLException; +import java.util.Properties; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * End-to-end integration tests for read/write traffic splitting through OJP JDBC driver and server. + * + *

Client-Side Configuration

+ * + *

+ * All read/write splitting configuration is supplied by the client via the {@link java.util.Properties} + * object passed to {@link java.sql.DriverManager#getConnection(String, Properties)}. No server-side + * properties file is required. The driver forwards these properties to the OJP server, which uses + * them to configure the primary/replica datasources on first connection. + *

+ * + *

Example configuration properties used by these tests:

+ *
+ * Properties props = new Properties();
+ * props.setProperty("user", "sa");
+ * props.setProperty("password", "");
+ * props.setProperty("ojp.datasource.name",                         "rw_e2e_ds");
+ * props.setProperty("rw_e2e_ds.ojp.readwrite.enabled",             "true");
+ * props.setProperty("rw_e2e_replica.ojp.readwrite.role",           "replica");
+ * props.setProperty("rw_e2e_replica.ojp.readwrite.primary",        "rw_e2e_ds");
+ * props.setProperty("rw_e2e_replica.ojp.connection.url",
+ *         "jdbc:h2:mem:rw_e2e_replica;DB_CLOSE_DELAY=-1");
+ * props.setProperty("rw_e2e_replica.ojp.connection.user",  "sa");
+ * props.setProperty("rw_e2e_replica.ojp.connection.password", "");
+ * 
+ * + *

Test Strategy: Dual Unsynchronized H2 Databases

+ * + *

+ * Two separate, intentionally unsynchronized H2 in-memory databases are used: + *

+ * + *

+ * Routing correctness is verified by checking which row is returned: + * id=2 / source="replica" means the query was routed to the replica; + * id=1 / source="primary" means it was routed to the primary. + *

+ * + *

Test Execution Requirements

+ * + * + * @see org.openjproxy.grpc.server.readwrite.ReadWriteDataSourceRegistry + * @see org.openjproxy.grpc.server.readwrite.ReadWriteDataSourceManager + * @see org.openjproxy.grpc.server.readwrite.ReadWriteConfigurationParser + */ +class H2ReadWriteSplittingEndToEndTest { + + private static final String OJP_HOST = "localhost:1059"; + private static final String USER = "sa"; + private static final String PASSWORD = ""; + private static final String PRIMARY_DATASOURCE_NAME = "rw_e2e_ds"; + private static final String REPLICA_DATASOURCE_NAME = "rw_e2e_replica"; + + private static boolean isH2TestEnabled; + + private Connection connection; + + @BeforeAll + static void setupClass() { + isH2TestEnabled = Boolean.parseBoolean(System.getProperty("enableH2Tests", "false")); + } + + @AfterEach + void tearDown() { + if (connection != null) { + try { + connection.close(); + } catch (SQLException e) { + // ignore + } + } + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + /** + * Returns JDBC URL that routes through OJP to the primary H2 database. + */ + private String primaryUrl() { + return "jdbc:ojp[" + OJP_HOST + "]_h2:mem:rw_e2e_primary;DB_CLOSE_DELAY=-1"; + } + + /** + * Returns JDBC URL that routes through OJP to the replica H2 database. + */ + private String replicaUrl() { + return "jdbc:ojp[" + OJP_HOST + "]_h2:mem:rw_e2e_replica;DB_CLOSE_DELAY=-1"; + } + + /** + * Builds the Properties for a primary-datasource connection, including the full + * read/write splitting configuration so the OJP server can configure routing on + * first use — no server-side properties file needed. + */ + private Properties primaryProps() { + Properties props = new Properties(); + props.setProperty("user", USER); + props.setProperty("password", PASSWORD); + props.setProperty("ojp.datasource.name", PRIMARY_DATASOURCE_NAME); + // Read/write splitting config forwarded to the OJP server + props.setProperty(PRIMARY_DATASOURCE_NAME + ".ojp.readwrite.enabled", "true"); + props.setProperty(REPLICA_DATASOURCE_NAME + ".ojp.readwrite.role", "replica"); + props.setProperty(REPLICA_DATASOURCE_NAME + ".ojp.readwrite.primary", PRIMARY_DATASOURCE_NAME); + props.setProperty(REPLICA_DATASOURCE_NAME + ".ojp.connection.url", + "jdbc:h2:mem:rw_e2e_replica;DB_CLOSE_DELAY=-1"); + // Replica credentials: server uses these to open the replica pool + props.setProperty(REPLICA_DATASOURCE_NAME + ".ojp.connection.user", USER); + props.setProperty(REPLICA_DATASOURCE_NAME + ".ojp.connection.password", PASSWORD); + return props; + } + + /** + * Builds the Properties for a direct replica-datasource connection (used to seed the + * replica H2 database during test setup). + */ + private Properties replicaProps() { + Properties props = new Properties(); + props.setProperty("user", USER); + props.setProperty("password", PASSWORD); + props.setProperty("ojp.datasource.name", REPLICA_DATASOURCE_NAME); + return props; + } + + /** + * Seeds both H2 databases through OJP so that each test starts from a known state: + * + */ + private void setupDatabases() throws SQLException { + // Seed primary + try (Connection c = DriverManager.getConnection(primaryUrl(), primaryProps()); + Statement s = c.createStatement()) { + s.execute("DROP TABLE IF EXISTS test_data"); + s.execute("CREATE TABLE test_data (id INT PRIMARY KEY, source VARCHAR(50))"); + s.execute("INSERT INTO test_data VALUES (1, 'primary')"); + } + + // Seed replica + try (Connection c = DriverManager.getConnection(replicaUrl(), replicaProps()); + Statement s = c.createStatement()) { + s.execute("DROP TABLE IF EXISTS test_data"); + s.execute("CREATE TABLE test_data (id INT PRIMARY KEY, source VARCHAR(50))"); + s.execute("INSERT INTO test_data VALUES (2, 'replica')"); + } + } + + // ----------------------------------------------------------------------- + // Tests + // ----------------------------------------------------------------------- + + /** + * SELECT outside a transaction must be routed to the replica (id=2). + */ + @Test + void testSelectGoesToReplica_WithoutTransaction() throws SQLException { + Assumptions.assumeTrue(isH2TestEnabled, "Skipping H2 tests - not enabled"); + + setupDatabases(); + + connection = DriverManager.getConnection(primaryUrl(), primaryProps()); + + try (Statement stmt = connection.createStatement(); + ResultSet rs = stmt.executeQuery("SELECT id, source FROM test_data")) { + + assertTrue(rs.next(), "Should have at least one row"); + assertEquals(2, rs.getInt("id"), + "SELECT outside transaction should route to replica (id=2)"); + assertEquals("replica", rs.getString("source"), + "SELECT outside transaction should route to replica"); + } + } + + /** + * Multiple sequential SELECTs outside a transaction must all go to the replica. + */ + @Test + void testMultipleReads_AllGoToReplica() throws SQLException { + Assumptions.assumeTrue(isH2TestEnabled, "Skipping H2 tests - not enabled"); + + setupDatabases(); + + connection = DriverManager.getConnection(primaryUrl(), primaryProps()); + + for (int i = 0; i < 3; i++) { + try (Statement stmt = connection.createStatement(); + ResultSet rs = stmt.executeQuery("SELECT id, source FROM test_data")) { + + assertTrue(rs.next(), "Should have at least one row in iteration " + i); + assertEquals(2, rs.getInt("id"), + "SELECT #" + i + " should route to replica (id=2)"); + assertEquals("replica", rs.getString("source"), + "SELECT #" + i + " should route to replica"); + } + } + } + + /** + * INSERT must be routed to the primary and be visible there. + */ + @Test + void testInsertGoesToPrimary() throws SQLException { + Assumptions.assumeTrue(isH2TestEnabled, "Skipping H2 tests - not enabled"); + + setupDatabases(); + + connection = DriverManager.getConnection(primaryUrl(), primaryProps()); + + try (Statement stmt = connection.createStatement()) { + int affected = stmt.executeUpdate("INSERT INTO test_data VALUES (100, 'inserted')"); + assertEquals(1, affected, "INSERT should affect 1 row"); + } + + // Verify via a fresh primary connection (write went to primary) + try (Connection verify = DriverManager.getConnection(primaryUrl(), primaryProps())) { + // Use a transaction to force routing to primary + verify.setAutoCommit(false); + try (Statement s = verify.createStatement(); + ResultSet rs = s.executeQuery( + "SELECT COUNT(*) FROM test_data WHERE id = 100")) { + assertTrue(rs.next()); + assertEquals(1, rs.getInt(1), "Primary database should contain the inserted row"); + } + verify.rollback(); + } + } + + /** + * UPDATE must be routed to the primary and the change must be visible there. + */ + @Test + void testUpdateGoesToPrimary() throws SQLException { + Assumptions.assumeTrue(isH2TestEnabled, "Skipping H2 tests - not enabled"); + + setupDatabases(); + + connection = DriverManager.getConnection(primaryUrl(), primaryProps()); + + try (Statement stmt = connection.createStatement()) { + int affected = stmt.executeUpdate("UPDATE test_data SET source = 'updated' WHERE id = 1"); + assertEquals(1, affected, "UPDATE should affect 1 row"); + } + + try (Connection verify = DriverManager.getConnection(primaryUrl(), primaryProps())) { + // Force select to go to primary for validation + verify.setAutoCommit(false); + try (Statement s = verify.createStatement(); + ResultSet rs = s.executeQuery("SELECT source FROM test_data WHERE id = 1")) { + assertTrue(rs.next()); + assertEquals("updated", rs.getString("source"), + "Primary database should show the updated value"); + } + verify.rollback(); + } + } + + /** + * DELETE must be routed to the primary; the row must be absent from the primary afterwards. + */ + @Test + void testDeleteGoesToPrimary() throws SQLException { + Assumptions.assumeTrue(isH2TestEnabled, "Skipping H2 tests - not enabled"); + + setupDatabases(); + + connection = DriverManager.getConnection(primaryUrl(), primaryProps()); + + try (Statement stmt = connection.createStatement()) { + int affected = stmt.executeUpdate("DELETE FROM test_data WHERE id = 1"); + assertEquals(1, affected, "DELETE should affect 1 row"); + } + + try (Connection verify = DriverManager.getConnection(primaryUrl(), primaryProps()); + Statement s = verify.createStatement(); + ResultSet rs = s.executeQuery( + "SELECT COUNT(*) FROM test_data WHERE id = 1")) { + assertTrue(rs.next()); + assertEquals(0, rs.getInt(1), + "Primary database should not contain the deleted row"); + } + } + + /** + * A write followed immediately by a read (no sticky session) demonstrates eventual + * consistency: the read goes to the replica and does not see the write. + */ + @Test + void testWriteThenRead_WithoutStickySession_DoesNotSeeWrite() throws SQLException { + Assumptions.assumeTrue(isH2TestEnabled, "Skipping H2 tests - not enabled"); + + setupDatabases(); + + connection = DriverManager.getConnection(primaryUrl(), primaryProps()); + + try (Statement stmt = connection.createStatement()) { + stmt.executeUpdate("INSERT INTO test_data VALUES (150, 'eventual_consistency_test')"); + + try (ResultSet rs = stmt.executeQuery( + "SELECT COUNT(*) FROM test_data WHERE id = 150")) { + assertTrue(rs.next()); + assertEquals(0, rs.getInt(1), + "Replica should not yet have the row just inserted into the primary"); + } + } + } + + /** + * With sticky session configured, a write followed immediately by a read SHOULD see + * the write (read-your-writes guarantee). After the sticky session expires, reads + * go back to the replica. + */ + @Test + void testWriteThenRead_WithStickySession_SeesWrite() throws SQLException, InterruptedException { + Assumptions.assumeTrue(isH2TestEnabled, "Skipping H2 tests - not enabled"); + + String stickyPrimaryUrl = "jdbc:ojp[" + OJP_HOST + "]_h2:mem:rw_sticky_primary;DB_CLOSE_DELAY=-1"; + String stickyReplicaUrl = "jdbc:ojp[" + OJP_HOST + "]_h2:mem:rw_sticky_replica;DB_CLOSE_DELAY=-1"; + + Properties stickyProps = stickyProps(); + + // Setup separate sticky session databases + try (Connection c = DriverManager.getConnection(stickyPrimaryUrl, stickyProps); + Statement s = c.createStatement()) { + s.execute("DROP TABLE IF EXISTS test_data"); + s.execute("CREATE TABLE test_data (id INT PRIMARY KEY, source VARCHAR(50))"); + s.execute("INSERT INTO test_data VALUES (1, 'sticky_primary')"); + } + + Properties replicaOnlyProps = new Properties(); + replicaOnlyProps.setProperty("user", USER); + replicaOnlyProps.setProperty("password", PASSWORD); + replicaOnlyProps.setProperty("ojp.datasource.name", "rw_sticky_replica"); + + try (Connection c = DriverManager.getConnection(stickyReplicaUrl, replicaOnlyProps); + Statement s = c.createStatement()) { + s.execute("DROP TABLE IF EXISTS test_data"); + s.execute("CREATE TABLE test_data (id INT PRIMARY KEY, source VARCHAR(50))"); + s.execute("INSERT INTO test_data VALUES (2, 'sticky_replica')"); + } + + connection = DriverManager.getConnection(stickyPrimaryUrl, stickyProps); + + try (Statement stmt = connection.createStatement()) { + // Write to primary + stmt.executeUpdate("INSERT INTO test_data VALUES (160, 'sticky_write')"); + + // Immediate read: sticky session should route to primary → sees the write + try (ResultSet rs = stmt.executeQuery( + "SELECT COUNT(*) FROM test_data WHERE id = 160")) { + assertTrue(rs.next()); + assertEquals(1, rs.getInt(1), + "With sticky session, read immediately after write should see the write (primary)"); + } + + // Wait for sticky session to expire (3 seconds + buffer) + Thread.sleep(3500); //NOSONAR - intentional wait for sticky session expiration + + // After expiration, read goes to replica → does not see the write + try (ResultSet rs = stmt.executeQuery( + "SELECT COUNT(*) FROM test_data WHERE id = 160")) { + assertTrue(rs.next()); + assertEquals(0, rs.getInt(1), + "After sticky session expires, read should go to replica and not see the write"); + } + } + } + + /** + * Builds Properties with sticky session (3 second timeout) for read/write splitting. + */ + private Properties stickyProps() { + Properties props = new Properties(); + props.setProperty("user", USER); + props.setProperty("password", PASSWORD); + props.setProperty("ojp.datasource.name", "rw_sticky_ds"); + props.setProperty("rw_sticky_ds.ojp.readwrite.enabled", "true"); + props.setProperty("rw_sticky_ds.ojp.readwrite.stickySessionSeconds", "3"); + props.setProperty("rw_sticky_replica.ojp.readwrite.role", "replica"); + props.setProperty("rw_sticky_replica.ojp.readwrite.primary", "rw_sticky_ds"); + props.setProperty("rw_sticky_replica.ojp.connection.url", + "jdbc:h2:mem:rw_sticky_replica;DB_CLOSE_DELAY=-1"); + props.setProperty("rw_sticky_replica.ojp.connection.user", USER); + props.setProperty("rw_sticky_replica.ojp.connection.password", PASSWORD); + return props; + } + + /** + * All operations inside an explicit transaction must route to the primary. + */ + @Test + void testTransaction_AllOperationsGoToPrimary() throws SQLException { + Assumptions.assumeTrue(isH2TestEnabled, "Skipping H2 tests - not enabled"); + + setupDatabases(); + + connection = DriverManager.getConnection(primaryUrl(), primaryProps()); + connection.setAutoCommit(false); + + try (Statement stmt = connection.createStatement()) { + // SELECT inside transaction → primary (id=1) + try (ResultSet rs = stmt.executeQuery("SELECT id, source FROM test_data")) { + assertTrue(rs.next(), "Should have at least one row"); + assertEquals(1, rs.getInt("id"), + "SELECT inside transaction should route to primary (id=1)"); + assertEquals("primary", rs.getString("source"), + "SELECT inside transaction should route to primary"); + } + + stmt.executeUpdate("INSERT INTO test_data VALUES (200, 'tx_inserted')"); + + try (ResultSet rs = stmt.executeQuery( + "SELECT source FROM test_data WHERE id = 200")) { + assertTrue(rs.next(), "Should see the row inserted in the same transaction"); + assertEquals("tx_inserted", rs.getString("source")); + } + + connection.commit(); + } finally { + connection.setAutoCommit(true); + } + } + + /** + * After a transaction commits, reads continue going to primary (sticky session). + */ + @SneakyThrows + @Test + void testAfterTransactionCommit_ReadsGoToPrimary() throws SQLException { + Assumptions.assumeTrue(isH2TestEnabled, "Skipping H2 tests - not enabled"); + + setupDatabases(); + + connection = DriverManager.getConnection(primaryUrl(), primaryProps()); + connection.setAutoCommit(false); + + try (Statement stmt = connection.createStatement()) { + stmt.executeUpdate("INSERT INTO test_data VALUES (250, 'post_tx_test')"); + connection.commit(); + } + + connection.setAutoCommit(true); + + try (Statement stmt = connection.createStatement(); + ResultSet rs = stmt.executeQuery( + "SELECT COUNT(*) FROM test_data WHERE id = 250")) { + assertTrue(rs.next()); + assertEquals(1, rs.getInt(1), + "After commit, SELECT routes to primary which should have the committed row"); + } + } +} diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/StatementServiceImpl.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/StatementServiceImpl.java index 553de65d2..9dfe8e78d 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/StatementServiceImpl.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/StatementServiceImpl.java @@ -85,6 +85,9 @@ public StatementServiceImpl(SessionManager sessionManager, CircuitBreakerRegistr // Per-datasource cache configurations (shared with SessionManager) Map cacheCfgMap = cacheConfigurationMap != null ? cacheConfigurationMap : new ConcurrentHashMap<>(); + // Read/write splitting datasource registry + org.openjproxy.grpc.server.readwrite.ReadWriteDataSourceRegistry readWriteRegistry = + new org.openjproxy.grpc.server.readwrite.ReadWriteDataSourceRegistry(); this.actionContext = new org.openjproxy.grpc.server.action.ActionContext( datasourceMap, @@ -94,6 +97,7 @@ public StatementServiceImpl(SessionManager sessionManager, CircuitBreakerRegistr dbNameMap, slowQuerySegregationManagers, cacheCfgMap, + readWriteRegistry, xaPoolProvider, xaCoordinator, clusterHealthTracker, diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/action/ActionContext.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/action/ActionContext.java index db3e96ff3..2181ce44c 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/action/ActionContext.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/action/ActionContext.java @@ -84,6 +84,14 @@ public class ActionContext { */ private final Map cacheConfigurationMap; + // ========== Read/Write Splitting ========== + + /** + * Registry for managing primary and replica datasources for read/write splitting. + * Thread-safe, shared across all actions. + */ + private final org.openjproxy.grpc.server.readwrite.ReadWriteDataSourceRegistry readWriteDataSourceRegistry; + // ========== XA Pool Provider ========== /** @@ -148,6 +156,7 @@ public ActionContext( Map dbNameMap, Map slowQuerySegregationManagers, Map cacheConfigurationMap, + org.openjproxy.grpc.server.readwrite.ReadWriteDataSourceRegistry readWriteDataSourceRegistry, XAConnectionPoolProvider xaPoolProvider, MultinodeXaCoordinator xaCoordinator, ClusterHealthTracker clusterHealthTracker, @@ -163,6 +172,7 @@ public ActionContext( this.dbNameMap = dbNameMap; this.slowQuerySegregationManagers = slowQuerySegregationManagers; this.cacheConfigurationMap = cacheConfigurationMap; + this.readWriteDataSourceRegistry = readWriteDataSourceRegistry; this.xaPoolProvider = xaPoolProvider; this.xaCoordinator = xaCoordinator; this.clusterHealthTracker = clusterHealthTracker; @@ -203,6 +213,10 @@ public Map getCache return cacheConfigurationMap; } + public org.openjproxy.grpc.server.readwrite.ReadWriteDataSourceRegistry getReadWriteDataSourceRegistry() { + return readWriteDataSourceRegistry; + } + public XAConnectionPoolProvider getXaPoolProvider() { return xaPoolProvider; } diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/action/connection/ConnectAction.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/action/connection/ConnectAction.java index 23b0bece5..50977e2d9 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/action/connection/ConnectAction.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/action/connection/ConnectAction.java @@ -18,6 +18,8 @@ import org.openjproxy.grpc.server.action.util.ProcessClusterHealthAction; import org.openjproxy.grpc.server.pool.ConnectionPoolConfigurer; import org.openjproxy.grpc.server.pool.DataSourceConfigurationManager; +import org.openjproxy.grpc.server.readwrite.ReadWriteConfiguration; +import org.openjproxy.grpc.server.readwrite.ReadWriteDataSourceManager; import org.openjproxy.grpc.server.util.DatasourceNameExtractor; import org.openjproxy.grpc.server.utils.ConnectionHashGenerator; import org.openjproxy.grpc.server.utils.UrlParser; @@ -229,6 +231,9 @@ private void handleRegularConnection(ActionContext context, ConnectionDetails co dsConfig.getDataSourceName(), connHash, ConnectionPoolProviderRegistry.getDefaultProvider().map(p -> p.id()).orElse("unknown"), maxPoolSize, minIdle); + + // Setup read/write splitting if configured + setupReadWriteSplitting(context, connectionDetails, connHash, ds, dsConfig.getDataSourceName()); } } catch (Exception e) { @@ -242,6 +247,30 @@ private void handleRegularConnection(ActionContext context, ConnectionDetails co lock.unlock(); } + // If the pool already existed and read/write splitting was not yet registered for this + // connHash, attempt setup now. The setupReadWriteSplitting implementation is idempotent: + // it skips silently when the primary is already mapped and replicas are registered. + org.openjproxy.grpc.server.readwrite.ReadWriteDataSourceRegistry rwRegistry = + context.getReadWriteDataSourceRegistry(); + if (rwRegistry != null + && rwRegistry.getPrimaryName(connHash) == null + && connectionDetails.getPropertiesCount() > 0) { + DataSource existingDs = context.getDatasourceMap().get(connHash); + if (existingDs != null) { + try { + Properties clientProps = ConnectionPoolConfigurer.extractClientProperties(connectionDetails); + DataSourceConfigurationManager.DataSourceConfiguration dsConf = + DataSourceConfigurationManager.getConfiguration(clientProps); + setupReadWriteSplitting(context, connectionDetails, connHash, existingDs, + dsConf.getDataSourceName()); + } catch (Exception e) { + log.error("Failed to setup read/write splitting for connHash {} (pool already existed): {}", + connHash, e.getMessage(), e); + // Non-fatal: continue without read/write splitting + } + } + } + // Process cluster health from ConnectionDetails if provided. // This supports the driver's proactive cluster health push: after detecting a peer // server failure or recovery, the driver calls connect() on healthy servers with an @@ -320,4 +349,38 @@ private void handleRegularConnection(ActionContext context, ConnectionDetails co responseObserver.onCompleted(); } + + /** + * Sets up read/write splitting for the given datasource if configured. + * Creates and registers replica datasources in the ReadWriteDataSourceRegistry. + * + * @param context action context + * @param connectionDetails connection details with properties + * @param connHash connection hash for the primary + * @param ds primary datasource (already created) + * @param datasourceName name of the datasource + */ + private void setupReadWriteSplitting(ActionContext context, ConnectionDetails connectionDetails, + String connHash, DataSource ds, String datasourceName) { + if (context.getReadWriteDataSourceRegistry() == null) { + log.debug("ReadWriteDataSourceRegistry not available, skipping read/write splitting setup"); + return; + } + + try { + ReadWriteDataSourceManager rwManager = new ReadWriteDataSourceManager( + context.getReadWriteDataSourceRegistry()); + + ReadWriteConfiguration config = rwManager.setupReadWriteSplitting( + connectionDetails, connHash, ds, datasourceName); + + if (config != null) { + log.info("Read/write splitting successfully configured for datasource '{}'", datasourceName); + } + } catch (Exception e) { + log.error("Failed to setup read/write splitting for datasource '{}': {}", + datasourceName, e.getMessage(), e); + // Non-fatal: continue without read/write splitting + } + } } diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfiguration$Builder.class b/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfiguration$Builder.class new file mode 100644 index 0000000000000000000000000000000000000000..6be7c8cbd1c73477bf39f472129841d508b1f5e6 GIT binary patch literal 2423 zcmcIlOK%%h7(LfcGqy8z)1(P`w2)F0>@n1YH#nqiTtW=SL3T+LZ_vb^T9Zy@teJ7Z z8xSuE7O;Q?Ea(Cjus|R{mQoKSlhzq^$8LBGLQyi!t2i%1eDg2zjDL6SeP z@DUY@c$6V&^`uK)fr%b#PzVg0X*cs@$Usw&Rq+@e7fAkJ7Ry6#FoRR+BgkF}*?CCMBPpgWYfKxI6o}=*3-pdiAt~qLVKC@u=&_{TSrSi=)7TRymegi ziA6LlZKrnR$QHPg9!MhVD^YdYZrxZjWg*X>ydW;i3Bx#+4bNY9Jgy?OXxoNcuyoIp z^GrG1AT#%>5?tgsjBwSB^5&c)6_KkecsZGam%})CId6lPvl9X@1oVrDljd&Zo#&ql zqNIRD?EpfHcA;p$;dB?1TC9s{ZKR7i?G7$%AK;-b7JenCz%uVSN&w!&2;Qf}4=|1o zxfwp<|BuPB$W`2ve~I=r4KMKjX4hZjDXL{I)y8J z0BV@*1AIZimt>v6)jj|8 zeXwuo^ml}~1N%;t6CU&b15iC+tAWAP4_wy8SB zqwq}fO!Lg~EF9peQuYr#o4p&^Bx?Zp5exVUDg4Z@SNI*lI*P%&gje}a@K20e;5Ftu J$+vWM_CG|ljcot` literal 0 HcmV?d00001 diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfiguration$ReplicaSelectionStrategy.class b/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfiguration$ReplicaSelectionStrategy.class new file mode 100644 index 0000000000000000000000000000000000000000..b4ac41daa3737a1a0640faf86e5791cb89cf6c09 GIT binary patch literal 1514 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 literal 0 HcmV?d00001 diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfiguration.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfiguration.java new file mode 100644 index 000000000..747765415 --- /dev/null +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfiguration.java @@ -0,0 +1,177 @@ +package org.openjproxy.grpc.server.readwrite; + +import lombok.Getter; +import lombok.ToString; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Configuration for read/write splitting feature. + * Holds settings for a primary datasource and its associated read replicas. + */ +@Getter +@ToString +public class ReadWriteConfiguration { + + /** + * Name of the primary datasource + */ + private final String primaryName; + + /** + * Whether read/write splitting is enabled for this primary + */ + private final boolean enabled; + + /** + * Replica selection strategy (ROUND_ROBIN, RANDOM, LEAST_CONNECTIONS) + */ + private final ReplicaSelectionStrategy strategy; + + /** + * Duration in seconds to stick to primary after a write operation. + * This ensures read-your-writes consistency. + */ + private final int stickySessionSeconds; + + /** + * Whether to fail over to primary when all replicas are unavailable + */ + private final boolean failoverToPrimary; + + /** + * List of replica datasource names associated with this primary + */ + private final List replicaNames; + + /** + * Replica selection strategies + */ + public enum ReplicaSelectionStrategy { + ROUND_ROBIN, + RANDOM, + LEAST_CONNECTIONS + } + + /** + * Creates a new ReadWriteConfiguration + * + * @param primaryName name of the primary datasource + * @param enabled whether read/write splitting is enabled + * @param strategy replica selection strategy + * @param stickySessionSeconds sticky session duration in seconds + * @param failoverToPrimary whether to failover to primary when replicas unavailable + * @param replicaNames list of replica datasource names + */ + public ReadWriteConfiguration(String primaryName, boolean enabled, ReplicaSelectionStrategy strategy, + int stickySessionSeconds, boolean failoverToPrimary, List replicaNames) { + this.primaryName = Objects.requireNonNull(primaryName, "primaryName cannot be null"); + this.enabled = enabled; + this.strategy = Objects.requireNonNull(strategy, "strategy cannot be null"); + this.stickySessionSeconds = stickySessionSeconds; + this.failoverToPrimary = failoverToPrimary; + this.replicaNames = Collections.unmodifiableList(new ArrayList<>( + Objects.requireNonNull(replicaNames, "replicaNames cannot be null"))); + } + + /** + * Validates this configuration + * + * @throws IllegalArgumentException if configuration is invalid + */ + public void validate() { + if (primaryName == null || primaryName.trim().isEmpty()) { + throw new IllegalArgumentException("Primary datasource name cannot be empty"); + } + + if (enabled && replicaNames.isEmpty()) { + throw new IllegalArgumentException( + "Read/write splitting is enabled but no replicas configured for primary: " + primaryName); + } + + // stickySessionSeconds is optional - 0 means disabled, positive values enable sticky sessions + if (stickySessionSeconds < 0) { + throw new IllegalArgumentException("stickySessionSeconds cannot be negative: " + stickySessionSeconds); + } + + // Check for duplicate replica names + if (replicaNames.size() != replicaNames.stream().distinct().count()) { + throw new IllegalArgumentException("Duplicate replica names found for primary: " + primaryName); + } + } + + /** + * Checks if read/write splitting has replicas configured + * + * @return true if enabled and has replicas + */ + public boolean hasReplicas() { + return enabled && !replicaNames.isEmpty(); + } + + /** + * Gets the number of configured replicas + * + * @return replica count + */ + public int getReplicaCount() { + return replicaNames.size(); + } + + /** + * Builder for ReadWriteConfiguration + */ + public static class Builder { + private String primaryName; + private boolean enabled = false; + private ReplicaSelectionStrategy strategy = ReplicaSelectionStrategy.ROUND_ROBIN; + private int stickySessionSeconds = 5; + private boolean failoverToPrimary = true; + private List replicaNames = new ArrayList<>(); + + public Builder primaryName(String primaryName) { + this.primaryName = primaryName; + return this; + } + + public Builder enabled(boolean enabled) { + this.enabled = enabled; + return this; + } + + public Builder strategy(ReplicaSelectionStrategy strategy) { + this.strategy = strategy; + return this; + } + + public Builder stickySessionSeconds(int stickySessionSeconds) { + this.stickySessionSeconds = stickySessionSeconds; + return this; + } + + public Builder failoverToPrimary(boolean failoverToPrimary) { + this.failoverToPrimary = failoverToPrimary; + return this; + } + + public Builder addReplica(String replicaName) { + this.replicaNames.add(replicaName); + return this; + } + + public Builder replicas(List replicaNames) { + this.replicaNames = new ArrayList<>(replicaNames); + return this; + } + + public ReadWriteConfiguration build() { + ReadWriteConfiguration config = new ReadWriteConfiguration( + primaryName, enabled, strategy, stickySessionSeconds, failoverToPrimary, replicaNames); + config.validate(); + return config; + } + } +} diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfigurationParser.class b/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfigurationParser.class new file mode 100644 index 0000000000000000000000000000000000000000..899597bcfeb273a24c1b63630e244d7adebbeb2b GIT binary patch literal 7871 zcmcIp33wFM9sj*#li6%Wjs=uO#1(ypsj045$d*`0(To7rV{7f99G zR$FUrt34Iksy(R{EnYDpReN}}w)U`>ZLPM|-j~|ji~iosY<6_x|sH z{QlS5M_)a7KY%kudjKUURp3)mhH`;f+qIoqV@fmo8#{Zq>xrB|`D(*7a%TyYR@ZD+ z;1{SWF4|;f^O;QA%IV3@jBaT;BWG=L!H zD445a9*!1hNn8Dm>5Oh}&sgc*Lyi4bCefJHt)05nXz5yVmu2Mi#u(qW$hX$C*=O|U z9nLpu7FP-!)n}N=jg~Q}S%#jaz%Env*&8iVw{rZ@T;uR+PsjCKb0y{@q+o%HW3iBA ziVX9sfS>2_pfpc7y4t%p;?9%6A{?(Etl|VL76=vz6qX1m+q7(lzMJ%DkRG{XiHfCI zCg3ye>A3~k9y2y8sGeY5N3~LSwJPdRFEBGBrEgDLj^INA{nOaOBk=@99D&v;^>Q>S zI7!9HSTR9uv@ylZ|o*XI!m%C9j~2r>Zy&D@iJq?x$L#lG|*m@6_#$ z(R6>mZZ-R{O5jx6{>iX}Nu!OEaKa0Za9`RAXKYvug{v;Ptg0z|$z_#j0=2tZ#hIX! z%$iJ^W--jZw7?l&0bFEH6=3R2&1NaV*(zGFR-n@1s8vgB(*>4z%)t+dyk+TTu92X2 zU#UQ*^MO$oWM;XG@7s_a$7}>VLOm4`J ztsJ;GlnXQ!y+|-p>pSvVikg}bL<@H7@q>stZ=IIYvgy2)&}|5Z8BJ`!+jw7YQB7w` zXoq|AjL}&;R9CrYRn>>H14bspm^{@-q#yLF*e0F8w@b6kAWEgu28h{M=cbNzTVtJT zBOQ|8K@}$G5tX_0rVL|gtCl5a6T{ZH{5xPN$g0R8FVJ-aAX^gCGbtmX#q|{P8q*Vx zVDjlF7?hn_DzA6;32gTw-sPdj4j2DtN;&MQj2XKM&S;0LNGhfGYpE8iKR-w(_GD3V z7>StV(U7redLbNRP7YA@;e1wF%VU5F?7$@|25_ms<~K&NAbgGoUasP8c)P$1I!eUM z6}{xOdtam`5@cmyJM@(*-idcHdCK(cS~KfSSWVMd&l4dhB&=c-j2hV-t?_Pw)+2$} z+PslU>Q(?($(rOUT%+Q>cpp*jdQXQoNR)R^!{U(v%ssbRo_Vc`>+k^rS~s=cl%5n= zU)}Tik^hj2591>&WU}L~^NH#=21BnO)7PuG0ecu-F5$ZJHHly4B5u-dxioIs3AT zugC(Xl1eBX?#I`tl}Tqv`VXmi7>~%DZYK5JGV@Hx*@es#k!!!9;!%8)I?v{N9oc06 z5Fu=B1=z{M-5?u~$5ebx?g^c)ppbNvdY_h0<=7)gre2UG z%-?Lwc~wI;Bv&lboA3`=KK)am+KV(-!RLE4T&UwFN5P9KUc$>Pouzks+nRPQ z!ETe~Lj^JEbUFe{z0_M}Q_eni@=oT%S6OivkwZV2+Fgvd;jf^S4id|=pC8nzD=>fgb z@kn|j+w<}0a)URbxCoqf((z8pV>GVK8lclLvv5mfzc`uMNXS^>7pGA9;&73`FHU2c7SolHesQ{I=M=~H zi!&IVC-dR<1%5$%o$SIOTqUY_L#jKCcQ&IZjJ~0;Hh$}r9Pi%!;!J^B?>1{3LgBnQ zV5WDOS5Ec)L|c|+j=u7qx)XWiq?)`_J;aV0tFonqc~1* z$qK%lDDY)eHTgn5uBd4$s|)!;Wus^)#g;p9O7L`Nc!m%A4t=xk04bK(=PqW+2CU|D z4UQuJ)o8^@EU}x>&XWBcL=h3ka>Z>-4%2pD&)z<>b`-58WGb?u{$Z5WAE-Tub16t&!$EWcr6bt5(kBO9KycFtwltN6%7$_NUMvh= zB;PLHi=bpZSt|wU^FbVt%MC8yE|-^w%7>8}L8eLBhhv)ja_#O=IgjPqJMoq#MJ~S# z@Q>iFEB$it4!|eRt*EUV!4*w`FopRgU6|)N!ct0PUOPmDLW`#Ex!v;y9{eGxfTK z`a2JEZ~;-WmBo55@tmM`lN7#>E4K4(kb5oeHjzb|HzGTDjgiL(N$n;K;d8hYw^NwA z@K%iAa#q`KBj(?ZM<~;yxPpcHyYVcp#&dYDZLc1by)1w5h`frm3aSMEAL8K(|0=kf z^X?@7fFF0-i+3C%C+ajy6zmXObsP6lqJaDj#l`0>EjN;Ckxu1|oMaar!ZA!j07O%`RR`UqR3T>q}VsBH48u^Ek54`yrS8 zZX(R<#nO*gZM!K z+Dfg276n!7L*=9Rp)K3Z^+i8i&UgWK&3@d1W$cqr!mU_^{q)G&@n+nCi`fGixQ7TC zB`U@y$Vjs+dI?*LGA?2WIbMWapFD;#DkOfb3?0+hXK1Q@5(WD_?%F3d!9H|Ik8HLd zp1%jdG3@M?Uw#C(-ameT8W_bBB{a@l=ZnzV=@J`BlpqmvWU- zTUS4fUr1Xzv79lV@6U2Ee_3!BxAHuhsb<#_VGndPJc6=?`x{2^tNomG)F3ZJf!~p( z-*f997)SqPi|B`V+4f4#@}krwcq!MFas7%(1b?@`$tNS>U(4@9Nq1>OyxUh7?=D+7 zg6HF-_A!)U;oK~^)CYP-!>O2N*pfMDct85 zi^o;hXs0eCjzRGg*m?sKgcQ0#CGsULuzA?<(418HK91pUds% vN%nJv{XErvuC$-4>}QkxTrJMx?gcc#4(6yeXNP!q77=HYT8n5EZ3z7r=lroc literal 0 HcmV?d00001 diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfigurationParser.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfigurationParser.java new file mode 100644 index 000000000..c0990a7e3 --- /dev/null +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfigurationParser.java @@ -0,0 +1,251 @@ +package org.openjproxy.grpc.server.readwrite; + +import lombok.extern.slf4j.Slf4j; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * Parses read/write splitting configuration from Properties. + * + *

Configuration format: + *

+ * # Primary configuration
+ * primary.ojp.readwrite.enabled=true
+ * primary.ojp.readwrite.role=primary
+ * primary.ojp.readwrite.replicaSelectionStrategy=ROUND_ROBIN
+ * primary.ojp.readwrite.stickySessionSeconds=5
+ * primary.ojp.readwrite.replicaFailoverToPrimary=true
+ *
+ * # Replica configuration
+ * replica1.ojp.readwrite.role=replica
+ * replica1.ojp.readwrite.primary=primary
+ * 
+ * + *

All read/write configuration is supplied by the JDBC client via {@link java.util.Properties} + * passed to {@link java.sql.DriverManager#getConnection(String, Properties)} and forwarded to + * the OJP server as gRPC metadata. No server-side properties file is required. + */ +@Slf4j +public class ReadWriteConfigurationParser { + + // Private constructor to prevent instantiation of utility class + private ReadWriteConfigurationParser() { + throw new UnsupportedOperationException("Utility class - do not instantiate"); + } + + private static final String READWRITE_PREFIX = ".ojp.readwrite."; + private static final String ENABLED_SUFFIX = "enabled"; + private static final String ROLE_SUFFIX = "role"; + private static final String PRIMARY_SUFFIX = "primary"; + private static final String STRATEGY_SUFFIX = "replicaSelectionStrategy"; + private static final String STICKY_SESSION_SUFFIX = "stickySessionSeconds"; + private static final String FAILOVER_SUFFIX = "replicaFailoverToPrimary"; + + private static final String ROLE_PRIMARY = "primary"; + private static final String ROLE_REPLICA = "replica"; + + // Cache for parsed configurations + private static final ConcurrentMap configCache = new ConcurrentHashMap<>(); + + /** + * Parses read/write configuration for all datasources from properties. + * + * @param properties configuration properties + * @return map of primary datasource name to ReadWriteConfiguration + * @throws IllegalArgumentException if configuration is invalid + */ + public static Map parseAll(Properties properties) { + Map configs = new HashMap<>(); + + // First pass: find all primaries + Set primaries = findPrimaries(properties); + + // Second pass: build configuration for each primary + for (String primaryName : primaries) { + ReadWriteConfiguration config = parseForPrimary(primaryName, properties); + configs.put(primaryName, config); + + log.info("Parsed read/write configuration for primary '{}': {}", primaryName, config); + } + + return configs; + } + + /** + * Parses read/write configuration for a specific primary datasource. + * Uses cache to avoid reparsing. + * + * @param primaryName name of the primary datasource + * @param properties configuration properties + * @return ReadWriteConfiguration for this primary, or null if not configured + */ + public static ReadWriteConfiguration parseForPrimary(String primaryName, Properties properties) { + // Check cache first + ReadWriteConfiguration cached = configCache.get(primaryName); + if (cached != null) { + return cached; + } + + // Parse configuration + String prefix = primaryName + READWRITE_PREFIX; + + // Check if read/write splitting is enabled for this primary + boolean enabled = getBooleanProperty(properties, prefix + ENABLED_SUFFIX, false); + + // Verify role is explicitly set to "primary" + String role = getStringProperty(properties, prefix + ROLE_SUFFIX, ""); + if (!role.isEmpty() && !ROLE_PRIMARY.equals(role)) { + log.warn("Datasource '{}' has readwrite.role='{}' but is not 'primary', skipping read/write config", + primaryName, role); + return null; + } + + // Parse strategy + String strategyStr = getStringProperty(properties, prefix + STRATEGY_SUFFIX, "ROUND_ROBIN"); + ReadWriteConfiguration.ReplicaSelectionStrategy strategy; + try { + strategy = ReadWriteConfiguration.ReplicaSelectionStrategy.valueOf(strategyStr.toUpperCase()); + } catch (IllegalArgumentException e) { + log.warn("Invalid replicaSelectionStrategy '{}' for primary '{}', using ROUND_ROBIN", + strategyStr, primaryName); + strategy = ReadWriteConfiguration.ReplicaSelectionStrategy.ROUND_ROBIN; + } + + // Parse other settings + int stickySessionSeconds = getIntProperty(properties, prefix + STICKY_SESSION_SUFFIX, 5); + boolean failoverToPrimary = getBooleanProperty(properties, prefix + FAILOVER_SUFFIX, true); + + // Find all replicas for this primary + List replicaNames = findReplicasForPrimary(primaryName, properties); + + // Build configuration + ReadWriteConfiguration config = new ReadWriteConfiguration.Builder() + .primaryName(primaryName) + .enabled(enabled) + .strategy(strategy) + .stickySessionSeconds(stickySessionSeconds) + .failoverToPrimary(failoverToPrimary) + .replicas(replicaNames) + .build(); + + // Only cache when read/write splitting is actually active (enabled with at least one replica). + // A disabled or replica-less config is NOT cached so that the next connection attempt that + // carries the full properties can re-evaluate and set up splitting correctly. + if (config.isEnabled() && !config.getReplicaNames().isEmpty()) { + configCache.put(primaryName, config); + } + + return config; + } + + /** + * Finds all datasources configured as primaries + */ + private static Set findPrimaries(Properties properties) { + Set primaries = new HashSet<>(); + + for (String propertyName : properties.stringPropertyNames()) { + if (propertyName.contains(READWRITE_PREFIX + ROLE_SUFFIX)) { + String role = properties.getProperty(propertyName); + if (ROLE_PRIMARY.equals(role)) { + // Extract datasource name (everything before .ojp.readwrite.role) + String datasourceName = propertyName.substring(0, propertyName.indexOf(READWRITE_PREFIX)); + primaries.add(datasourceName); + } + } + } + + return primaries; + } + + /** + * Finds all replicas configured for a specific primary + */ + private static List findReplicasForPrimary(String primaryName, Properties properties) { + List replicas = new ArrayList<>(); + + for (String propertyName : properties.stringPropertyNames()) { + if (propertyName.contains(READWRITE_PREFIX + ROLE_SUFFIX)) { + String role = properties.getProperty(propertyName); + if (ROLE_REPLICA.equals(role)) { + // Extract datasource name + String datasourceName = propertyName.substring(0, propertyName.indexOf(READWRITE_PREFIX)); + + // Check if this replica references our primary + String referencedPrimary = getStringProperty(properties, + datasourceName + READWRITE_PREFIX + PRIMARY_SUFFIX, ""); + + if (primaryName.equals(referencedPrimary)) { + replicas.add(datasourceName); + } + } + } + } + + return replicas; + } + + /** + * Validates that all replica references point to valid primaries + */ + public static void validateReplicaReferences(Properties properties) { + Set primaries = findPrimaries(properties); + + for (String propertyName : properties.stringPropertyNames()) { + if (propertyName.contains(READWRITE_PREFIX + ROLE_SUFFIX)) { + String role = properties.getProperty(propertyName); + if (ROLE_REPLICA.equals(role)) { + String datasourceName = propertyName.substring(0, propertyName.indexOf(READWRITE_PREFIX)); + String referencedPrimary = getStringProperty(properties, + datasourceName + READWRITE_PREFIX + PRIMARY_SUFFIX, ""); + + if (referencedPrimary.isEmpty()) { + throw new IllegalArgumentException( + "Replica '" + datasourceName + "' does not specify a primary datasource"); + } + + if (!primaries.contains(referencedPrimary)) { + throw new IllegalArgumentException( + "Replica '" + datasourceName + "' references unknown primary '" + referencedPrimary + "'"); + } + } + } + } + } + + /** + * Clears the configuration cache. Useful for testing. + */ + public static void clearCache() { + configCache.clear(); + } + + // Property parsing helpers + + private static String getStringProperty(Properties properties, String key, String defaultValue) { + return properties.getProperty(key, defaultValue); + } + + private static boolean getBooleanProperty(Properties properties, String key, boolean defaultValue) { + String value = properties.getProperty(key); + if (value == null) { + return defaultValue; + } + return Boolean.parseBoolean(value); + } + + private static int getIntProperty(Properties properties, String key, int defaultValue) { + String value = properties.getProperty(key); + if (value == null) { + return defaultValue; + } + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + log.warn("Invalid integer value '{}' for property '{}', using default {}", value, key, defaultValue); + return defaultValue; + } + } +} diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceManager.class b/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceManager.class new file mode 100644 index 0000000000000000000000000000000000000000..252160803bdeb64d779d459bc8534a81122263d5 GIT binary patch literal 8087 zcmbtZ3wRXQb^edE(yUe^1j|EN+t@3chlB(IKfp+U4Kf&ENnnXb!A|Yb?jROcJL}n9 z9-%a?QzvQCCTS8ofjsOuPDlv2B#sx!jvd!YXg?=y8h=e6apN|9rjPX9ey(fc{%2-Z zyGTo8C?DVK+&lN4d+s^^`OmpmFa7kH=K-vheG$~4)VP!H-K0ac{Glg_If(RN7m?oMqLomye zRmi8(fvlWhhHF1iNV)c)lNsa;!L4omQ7?-a&*+rZ*yrEH=nX|y-p!RKhk}DD(_ko*C9^#Tr{Yk4&}tQz|x-Aw2vx8alMHf zuz-$FI2oF@LiToN3jrf&3*1SZZLIi7qS`^!z_IBWOtF zHfF4mw4L;PwMT{DXyPXIRWp4xWM|W7i8-;V}mjb-yk zB4|dpi5{%sAkW{Pg;a)MQ57py23lY^m9fsmZMa>~xX;Q3dJtxFs|nbnA+^CoFYe$z z(#|MRb1Ix{?mzOdXLiJAl*IaP_t5$>FdE%(oS|#lPcR%o}}g;eC?zvxlJ{^`x0j4 z62pRdWM9kBE(fR=Qws<=1&_eU-2dM)ate_L)d2NJ6R1UPpm`z(g8j zoa58DUE-7h%LCcZG-xSb?bPUXOk`E%7DnE1K9x9dWZ2H-$XvrVk&w*kg06`i$h>n) z3;F}Mr^alj!2Wqv9N5>s)4)N&>~h$zcU|j<#(o3^)T+IXn0N<{G9fYVkuMz*<-Cj7 zy)WpeA&%h}4ZPFDFDaQs%Th~21O@|t&_0~ku@9Pf7k-6C%dkT*rzEwaYG8JL_eI5o zZx_ua?=|tOc%SAHhDn}@*h5h{GZThYwtOk}R$UKw>(jevO=z! zF{VcPCAjiXDxD_9c!i~(2EzEfpuZ|Vl}UAat|$>nkV{I+sd03>Ur>ub9%Utb%EXs6 zNw$nx2kc%ald$r;NJ4ze<*iIU7lq(d1W)2O4V*RcWgR=G94O_Af;rO5vtC+Y{(T5f znfR)DAjDF3OgmPFagNE4#-i#$=@#EH)L%iQ-cCwT@v~mqaSqV&YQdw&$`h9r+;l}2 z)Ll)e6Z@kq>`#U9H36&gC-J;0{q-sJcHq$phXy^Bd>M2_QHxVigwzaCe8a>y@h!io zAfX2PXX(`$MAy_pYx){}?R`-mIw-Rqr|Ref{wpk3;omm#9sI6e_4GTL(JPU@rF}~j zMoDnP;uoR_ms(dJx;=vL;|~q|k%>RXp9mIAsocuZ*|p7a(u4u?Hd%0_kV+@D=3GwF zdnAD^TM{CjLTo2p3pBXIKiVZhvLs zuf5K!mCGG++@yB?t%)Dw?+EEJ>#*8kn5kE=Li6e6=ZHBbDx~wOz{F>k6aZ;obcW1l; zjX{SGDQ$#>HxJ9FWyuPG%Alonlv;I`GGwhGvjnZxj@Tsv+L+;`M?_}IY*Xear`MC~ z@3OtFd8V{zmmafx%G{|1C?!kGl&j=wh7r#vB~e$x#6h{i;#cOIa;;p)81~M-#N3q1 zq{QQWhO`Q<^D>o}#0LvwBeuKAamTEDxwMcQ=*YedOXgHEp7KlRxK}_2In_^Ro-=%t z7jk;$3c^CWn0aKO*8Y!2WU;gv(r(HU=_nmey~UNxdC>KA_TMimr%Fn13CmKJQzd=U zoOPMXs0v*?90=>#*-9g21L(Sph#Tc5LvA+Z7Fo%xuMtssM%=_0cl0Kqf!_3wth{}x z4D;SJd6J->I_#vuLk=v8w58?)o$kY zkgA6BR^kAUNWo$)-Z^~wdVr5uY%%06)~>=s?1D1LQ<;OT7Q1|taEB~sCn5duN*D(N zeW9CqR!Q5r1^p~F3)w2puCVq+6Sp79+Es>EJ1I;T(QBnySmf=Vu<%l)UY0rgv&+J= zjWCwPWH#11H!MT6#6_K~V8XJUwRmN3se}m&Lt>U>naY+`b2(K2{a3#~C&`zzC{BLwKTG zIL&R(I(7x`y?14X)CQIEbubtDe8N@;9oN-TV#nP*W$X+1t(5 zjV$nWPISpR$dZnzF^+~8(EK!JeVM%?J$%mK7;tuo^Jk%jy=!D`Fu%arTE?&LD5B*A z>S|9f;oJNoS_eCqj$>hWC>A=8Yhs}yI*wzG{;rt7&4Lp!LMIWbJ)?R-P$Nf*c^wVzrqyz7z41WZXG7f73qak>zsY3gG&2&VjhzFiUVfQ&4>NbKY zhXvi?Sa<@zAUKXuefwp>3wWrzVM(kZR#(J(x+Ag3c`OLzM2dKS%!oBi;Da^TrR#r0 z8xPmwB)1XKj^p*up=Nh&tYLU}D8})h7~WkMiwsZTQ4V_Z;h5q1BiPGlFWSrJf~g^$ z%5naVbiTp)Cufm3A3N~~e?P$ypToU)3ish_yas%g*MC1o0zW|#uQAKl z@Dgwa4shjtG8-wm329l03~}K|KOT@l-mu+|f@Juc!yykx?Py#Sy@2a{AT?ce0UHh6 zV&K>t`tr~Q10Q=Gt*@h|g}CO8RRFOXLTnZxw!GsRd=VhDp8BwgoUe6ZsTz8$eUMRKdmuU01fSGj4iz^MUc^;sXUtAgXTE7j@GOQYc-rbE3>*VoM@D_Fz3>*t2`*LVEP zv)vj)v!%($h&Q-7Kv%PmE>UotkVY{l!SNvC1joA=rp(1$M7OPgY$SCFXOX3 z;6H&M;Pdzq@&6jWAR(L-p0TLGmk5^AvJ7WDAS_4i1>8WF^MBKuoMIY?dQCEncKZKC zLLs`5upl1z*Dyj{Su;mQRMuPdES}tbPMQZgCcw}gq}RQ`jq^q0R%UjGI_VaP>SJzE z-nQ%Hn?LM4%c+%|_cTelNb%=L$K$+woCr9t<^q~Mq#k=63uV1GYXcSGB}_oSA0ja8 z*}t)a#5Wy`b@NYq$Q+kzPIuRN?z-N0S7*t$^F5c!f?CW!O>f1RMXr~4px(9g6KG=t z&oi9A&aW?$oWDUke3N1PEl0fBaPvAXk^p#b$mrLOgi z*BF;YXS(Y>?K&&8t0VGS$7R{@glgA%<`UYy#3jB>?Y=|pzDwA7-Lf?%;2YY?Mv>U5AL=$;kuWbF25f+j|as&mG=# hm-pQ5J@4_JZ}*<}iA5Qjp successfulReplicas = new ArrayList<>(); + for (String replicaName : config.getReplicaNames()) { + try { + DataSource replicaDs = createReplicaDataSource(replicaName, props); + if (replicaDs != null) { + registry.registerReplica(datasourceName, replicaDs); + successfulReplicas.add(replicaName); + log.info("Successfully created and registered replica datasource '{}'", replicaName); + } + } catch (Exception e) { + log.error("Failed to create replica datasource '{}': {}", replicaName, e.getMessage(), e); + // Continue with other replicas + } + } + + if (successfulReplicas.isEmpty()) { + log.warn("No replicas successfully created for primary '{}', read/write splitting will not be active", + datasourceName); + return null; + } + + log.info("Read/write splitting configured for primary '{}' with {} active replicas: {}", + datasourceName, successfulReplicas.size(), successfulReplicas); + + return config; + } + + /** + * Creates a replica datasource based on configuration properties. + * + * @param replicaName name of the replica datasource + * @param props configuration properties + * @return DataSource for the replica, or null if configuration is invalid + */ + private DataSource createReplicaDataSource(String replicaName, Properties props) { + // Extract replica-specific properties with ojp. prefix + String replicaPrefix = replicaName + ".ojp."; + + // Get replica URL (required) + String replicaUrl = props.getProperty(replicaPrefix + "connection.url"); + if (replicaUrl == null || replicaUrl.trim().isEmpty()) { + log.error("No connection URL configured for replica '{}' (looked for {}connection.url), skipping", + replicaName, replicaPrefix); + return null; + } + + // Get replica credentials (optional) + String replicaUser = props.getProperty(replicaPrefix + "connection.user", ""); + String replicaPassword = props.getProperty(replicaPrefix + "connection.password", ""); + + // Get pool configuration (with sensible defaults) + int maxPoolSize = getIntProperty(props, replicaPrefix + "pool.maxPoolSize", 10); + int minIdle = getIntProperty(props, replicaPrefix + "pool.minIdle", 2); + long connectionTimeout = getLongProperty(props, replicaPrefix + "pool.connectionTimeout", 30000); + long idleTimeout = getLongProperty(props, replicaPrefix + "pool.idleTimeout", 600000); + long maxLifetime = getLongProperty(props, replicaPrefix + "pool.maxLifetime", 1800000); + + try { + PoolConfig poolConfig = PoolConfig.builder() + .url(replicaUrl) + .username(replicaUser) + .password(replicaPassword) + .maxPoolSize(maxPoolSize) + .minIdle(minIdle) + .connectionTimeoutMs(connectionTimeout) + .idleTimeoutMs(idleTimeout) + .maxLifetimeMs(maxLifetime) + .defaultTransactionIsolation(java.sql.Connection.TRANSACTION_READ_COMMITTED) + .metricsPrefix("OJP-Replica-" + replicaName) + .build(); + + DataSource ds = ConnectionPoolProviderRegistry.createDataSource(poolConfig); + log.info("Created replica datasource '{}' with URL: {}, maxPoolSize: {}, minIdle: {}", + replicaName, replicaUrl, maxPoolSize, minIdle); + + return ds; + } catch (Exception e) { + log.error("Failed to create datasource for replica '{}': {}", replicaName, e.getMessage(), e); + return null; + } + } + + /** + * Converts gRPC PropertyEntry list to Java Properties object. + */ + private Properties convertPropertiesToJava(List propertyEntries) { + Properties props = new Properties(); + for (PropertyEntry entry : propertyEntries) { + props.setProperty(entry.getKey(), entry.getStringValue()); + } + return props; + } + + private int getIntProperty(Properties props, String key, int defaultValue) { + String value = props.getProperty(key); + if (value == null || value.trim().isEmpty()) { + return defaultValue; + } + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException e) { + log.warn("Invalid integer value for property '{}': {}, using default: {}", key, value, defaultValue); + return defaultValue; + } + } + + private long getLongProperty(Properties props, String key, long defaultValue) { + String value = props.getProperty(key); + if (value == null || value.trim().isEmpty()) { + return defaultValue; + } + try { + return Long.parseLong(value.trim()); + } catch (NumberFormatException e) { + log.warn("Invalid long value for property '{}': {}, using default: {}", key, value, defaultValue); + return defaultValue; + } + } +} diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceRegistry.class b/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceRegistry.class new file mode 100644 index 0000000000000000000000000000000000000000..ab70f3b8e2da0db9cf9ddafbe944ffda2037349d GIT binary patch literal 4352 zcmb7H`*Rc575=VeSXo}MA_oYsAczAtvQe8RJR+Ckn3sYLsZC6w&0{THiM3a|%I?a+ zP?GfBCQV!VPWnufG<~Ekc@+pVZGTE1amUd-vY&ob#RUoO|`J z|9tp20H4MODRd#Bpx4j$LZFuC)F9_|oHX!#h7|)Kk!q=s_=Z4Qq6)MW4VS$F1g_y0qu&u5)2AUv=xH zyeHiS>E>N&l%I7?U*@OybVfZLHhg2+X}BdhC99_AyNd!TSJo}FWYB^@-$ct7P85{6 zyL4oft-iWz)(m%%^L5j%dg|TXI@YOoY0o!H^NXj=nsgdIS7_KEu%i`y(XwRKu*TeK zqsHitTqw!9Z#s4o8wFI@Hp$^w#hNwjayBw{(x}O7t>O7u+wrrrGHW+1D~0vgtQg)R zux>DPIpBEOcj;MSXr>qUVw;Anj_ueXu%<04QeST{6Z_*^!}JtlVA0G_=ah>y)DLFNgEsK@0S&xYq7>~!$#`-PbR!D$`hXvNQ zk~%>x_u>FXb=IINEu&msCXHq`&n0n)%8cbDA|YFG+@|VOZd6ttQ7IW0*cJErmb{0u zJEG$#j?sszWVAcuJDWaqCMbm>9@TI{#{?$nc`LMWhm{i$>p*AWB4pp)Uaz#*{qnxtR=%8J0f2Ko`VhXJO&dQrrVlU7^|Z8aUC-_BcPey5#JfY)B#lvb>)|>^I#M3N?aZ4Ji77WX3iqBh4d=j$) zyQhL(9aR%7ZnX=Qs3e%{Epcgg_KOOt8qVu5F(+_WtS#BTWU(@=1;c8{Qx$>rgT=Vw zis1^Dvn8-ECY!M1Wj*P6>|0qsY<=-yvb!!_-laVB6d$$@NA)6|x;W(?mKCF6`6*QJ zj1E`vmo#mEMtPxD`#Kux09jR1j3x1`z{Ym+!v38tv!yGkE-I;R(Qrwizonir*EIsY zq;L_56rRKL8eY)xC45<6!}3OR)F}CmIum;6W7x(7?vE>Y+%i0`Fx1)Y1U64KY(+xR zUNAkAq8qc>vJKVLsoczj<&_16&Lc}GgiLUzzPTX5qRhh(c-6u+PC-r+1HeY+Sonrlo>_eglWr@joPeqPpiVC zoZR4~mF{D0r|19dDN)cvGd#VfeWNr_QX*cE#iV9EoqmH11G{%esHlK#^<) z71ct#JM2%()3dHg@^t)WquN8KlSReq26_;#oeo>Vv}B(uli_~*l07u?9eg)3RihzgYG0eFd}Jz> z`w*W5Zs4H={(%EDRp<)sekOMVV~LpAO=JbwPqPPTwm_~&k>fRRFAlbu{vNK-Q5EGR zf0gOYj44I9!(Gb8B;(bto|FpufwQ9%8oGs#|d8~c_#wcB*e#9uhD3j!tXP# zAK-^kbUV3QC4X%|TV!HNp@xZ2G5{Xu_mA-7Xiw9v?aSQy2x-QDTHsY^-EW~g@%EB) z>fa{t6rFoIaEjxP)&HIYhXg@?f}cjvMXpm3Z|#8A3H>>Nm0;%xJKuuc+=P9F!U^TF zkD$td4H4`Yq8vTd%262Z+Q7QcifE9)1#W+JMUXT%HbIP)L7pSX^8|T;AV0&;BTt@; zggcUJinbIfsXw>nI%G??^U-((>Apyiml*WR*h--eQ>se3tTa`8zgQx3B#L?+qwdco z0`!`aY2~k|tK7s>{0c|B$VG0RBwbio@gO7*k|I~!pqZ{i}`!55c{t-uQT)siRM13!SCOz(AEeh>GxnnbA0 kb&T@Y45kaO(I}vE!|R;;1OA9V<1d^^apo%C#rxR!Uuq|LkN^Mx literal 0 HcmV?d00001 diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceRegistry.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceRegistry.java new file mode 100644 index 000000000..9b461de02 --- /dev/null +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceRegistry.java @@ -0,0 +1,166 @@ +package org.openjproxy.grpc.server.readwrite; + +import lombok.extern.slf4j.Slf4j; + +import javax.sql.DataSource; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Registry for managing primary and replica datasource mappings in read/write splitting. + * + *

This class maintains the mapping between primary datasources and their associated + * read replicas. It provides thread-safe operations for registering datasources and + * retrieving replica lists for routing decisions. + * + *

Thread Safety: All operations are thread-safe using ConcurrentHashMap. + */ +@Slf4j +public class ReadWriteDataSourceRegistry { + + // Map of primary datasource name → replica list + private final Map> replicaMap = new ConcurrentHashMap<>(); + + // Map of connection hash → primary datasource name (for session affinity) + private final Map primaryMappings = new ConcurrentHashMap<>(); + + // Map of primary datasource name → sticky session timeout in seconds (0 = disabled) + private final Map stickyTimeoutMap = new ConcurrentHashMap<>(); + + /** + * Registers a mapping from connection hash to primary datasource name. + * This is used to associate client connections with their primary datasource. + * + * @param connectionHash the unique connection identifier + * @param primaryName the primary datasource name + */ + public void registerPrimaryMapping(String connectionHash, String primaryName) { + if (connectionHash == null || primaryName == null) { + throw new IllegalArgumentException("connectionHash and primaryName must not be null"); + } + primaryMappings.put(connectionHash, primaryName); + log.debug("Registered primary mapping: {} -> {}", connectionHash, primaryName); + } + + /** + * Registers a replica datasource for a given primary. + * Multiple replicas can be registered for the same primary. + * + * @param primaryName the primary datasource name + * @param replicaDataSource the replica datasource to register + */ + public void registerReplica(String primaryName, DataSource replicaDataSource) { + if (primaryName == null || replicaDataSource == null) { + throw new IllegalArgumentException("primaryName and replicaDataSource must not be null"); + } + replicaMap.computeIfAbsent(primaryName, k -> new ArrayList<>()) + .add(replicaDataSource); + log.debug("Registered replica for primary: {}", primaryName); + } + + /** + * Gets the list of replica datasources for a given primary. + * + * @param primaryName the primary datasource name + * @return unmodifiable list of replica datasources, empty list if no replicas registered + */ + public List getReplicas(String primaryName) { + List replicas = replicaMap.get(primaryName); + if (replicas == null) { + return Collections.emptyList(); + } + return Collections.unmodifiableList(replicas); + } + + /** + * Gets the primary datasource name for a given connection hash. + * + * @param connectionHash the connection hash + * @return the primary datasource name, or null if not found + */ + public String getPrimaryName(String connectionHash) { + return primaryMappings.get(connectionHash); + } + + /** + * Checks if a primary datasource has any replicas registered. + * + * @param primaryName the primary datasource name + * @return true if at least one replica is registered, false otherwise + */ + public boolean hasReplicas(String primaryName) { + List replicas = replicaMap.get(primaryName); + return replicas != null && !replicas.isEmpty(); + } + + /** + * Gets the count of replicas for a given primary. + * + * @param primaryName the primary datasource name + * @return the number of replicas, 0 if none registered + */ + public int getReplicaCount(String primaryName) { + List replicas = replicaMap.get(primaryName); + return replicas != null ? replicas.size() : 0; + } + + /** + * Removes all registered replicas for a given primary. + * + * @param primaryName the primary datasource name + */ + public void clearReplicas(String primaryName) { + replicaMap.remove(primaryName); + log.debug("Cleared all replicas for primary: {}", primaryName); + } + + /** + * Removes a primary mapping for a connection hash. + * + * @param connectionHash the connection hash + */ + public void removePrimaryMapping(String connectionHash) { + primaryMappings.remove(connectionHash); + log.debug("Removed primary mapping for connection: {}", connectionHash); + } + + /** + * Registers the sticky session timeout (in seconds) for a given primary datasource. + * A value of 0 disables sticky session behaviour. + * + * @param primaryName the primary datasource name + * @param stickyTimeoutSeconds the duration in seconds to route reads to primary after a write + */ + public void registerStickyTimeout(String primaryName, int stickyTimeoutSeconds) { + if (primaryName == null) { + throw new IllegalArgumentException("primaryName must not be null"); + } + stickyTimeoutMap.put(primaryName, stickyTimeoutSeconds); + log.debug("Registered sticky session timeout for primary '{}': {}s", primaryName, stickyTimeoutSeconds); + } + + /** + * Returns the sticky session timeout in seconds for the given primary datasource. + * Returns 0 if no timeout has been registered (sticky sessions disabled). + * + * @param primaryName the primary datasource name + * @return the sticky session timeout in seconds, or 0 if not configured + */ + public int getStickySessionSeconds(String primaryName) { + return stickyTimeoutMap.getOrDefault(primaryName, 0); + } + + /** + * Clears all registered datasources and mappings. + * This is primarily for testing and cleanup purposes. + */ + public void clear() { + replicaMap.clear(); + primaryMappings.clear(); + stickyTimeoutMap.clear(); + log.debug("Cleared all registry data"); + } +} diff --git a/ojp-server/src/test/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfigurationParserTest.java b/ojp-server/src/test/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfigurationParserTest.java new file mode 100644 index 000000000..ecea2f68d --- /dev/null +++ b/ojp-server/src/test/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfigurationParserTest.java @@ -0,0 +1,291 @@ +package org.openjproxy.grpc.server.readwrite; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.Properties; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for ReadWriteConfigurationParser class + */ +class ReadWriteConfigurationParserTest { + + @BeforeEach + void setUp() { + ReadWriteConfigurationParser.clearCache(); + } + + @AfterEach + void tearDown() { + ReadWriteConfigurationParser.clearCache(); + } + + @Test + void testParseBasicConfiguration() { + Properties props = new Properties(); + props.setProperty("primary.ojp.readwrite.enabled", "true"); + props.setProperty("primary.ojp.readwrite.role", "primary"); + props.setProperty("primary.ojp.readwrite.replicaSelectionStrategy", "ROUND_ROBIN"); + props.setProperty("primary.ojp.readwrite.stickySessionSeconds", "5"); + props.setProperty("primary.ojp.readwrite.replicaFailoverToPrimary", "true"); + + props.setProperty("replica1.ojp.readwrite.role", "replica"); + props.setProperty("replica1.ojp.readwrite.primary", "primary"); + + Map configs = ReadWriteConfigurationParser.parseAll(props); + + assertEquals(1, configs.size()); + assertTrue(configs.containsKey("primary")); + + ReadWriteConfiguration config = configs.get("primary"); + assertEquals("primary", config.getPrimaryName()); + assertTrue(config.isEnabled()); + assertEquals(ReadWriteConfiguration.ReplicaSelectionStrategy.ROUND_ROBIN, config.getStrategy()); + assertEquals(5, config.getStickySessionSeconds()); + assertTrue(config.isFailoverToPrimary()); + assertEquals(1, config.getReplicaCount()); + assertTrue(config.getReplicaNames().contains("replica1")); + } + + @Test + void testParseMultipleReplicas() { + Properties props = new Properties(); + props.setProperty("primary.ojp.readwrite.enabled", "true"); + props.setProperty("primary.ojp.readwrite.role", "primary"); + + props.setProperty("replica1.ojp.readwrite.role", "replica"); + props.setProperty("replica1.ojp.readwrite.primary", "primary"); + + props.setProperty("replica2.ojp.readwrite.role", "replica"); + props.setProperty("replica2.ojp.readwrite.primary", "primary"); + + props.setProperty("replica3.ojp.readwrite.role", "replica"); + props.setProperty("replica3.ojp.readwrite.primary", "primary"); + + ReadWriteConfiguration config = ReadWriteConfigurationParser.parseForPrimary("primary", props); + + assertEquals(3, config.getReplicaCount()); + assertTrue(config.getReplicaNames().contains("replica1")); + assertTrue(config.getReplicaNames().contains("replica2")); + assertTrue(config.getReplicaNames().contains("replica3")); + } + + @Test + void testParseMultiplePrimaries() { + Properties props = new Properties(); + + // Primary 1 + props.setProperty("db1.ojp.readwrite.enabled", "true"); + props.setProperty("db1.ojp.readwrite.role", "primary"); + props.setProperty("db1_replica.ojp.readwrite.role", "replica"); + props.setProperty("db1_replica.ojp.readwrite.primary", "db1"); + + // Primary 2 + props.setProperty("db2.ojp.readwrite.enabled", "true"); + props.setProperty("db2.ojp.readwrite.role", "primary"); + props.setProperty("db2_replica.ojp.readwrite.role", "replica"); + props.setProperty("db2_replica.ojp.readwrite.primary", "db2"); + + Map configs = ReadWriteConfigurationParser.parseAll(props); + + assertEquals(2, configs.size()); + assertTrue(configs.containsKey("db1")); + assertTrue(configs.containsKey("db2")); + + assertEquals(1, configs.get("db1").getReplicaCount()); + assertEquals(1, configs.get("db2").getReplicaCount()); + } + + @Test + void testParseDisabledConfiguration() { + Properties props = new Properties(); + props.setProperty("primary.ojp.readwrite.enabled", "false"); + props.setProperty("primary.ojp.readwrite.role", "primary"); + + ReadWriteConfiguration config = ReadWriteConfigurationParser.parseForPrimary("primary", props); + + assertFalse(config.isEnabled()); + } + + @Test + void testParseDefaultValues() { + Properties props = new Properties(); + props.setProperty("primary.ojp.readwrite.role", "primary"); + + ReadWriteConfiguration config = ReadWriteConfigurationParser.parseForPrimary("primary", props); + + assertFalse(config.isEnabled()); // Default: disabled + assertEquals(ReadWriteConfiguration.ReplicaSelectionStrategy.ROUND_ROBIN, config.getStrategy()); + assertEquals(5, config.getStickySessionSeconds()); + assertTrue(config.isFailoverToPrimary()); + } + + @Test + void testParseCustomStrategy() { + Properties props = new Properties(); + props.setProperty("primary.ojp.readwrite.role", "primary"); + props.setProperty("primary.ojp.readwrite.replicaSelectionStrategy", "RANDOM"); + + ReadWriteConfiguration config = ReadWriteConfigurationParser.parseForPrimary("primary", props); + + assertEquals(ReadWriteConfiguration.ReplicaSelectionStrategy.RANDOM, config.getStrategy()); + } + + @Test + void testParseInvalidStrategy() { + Properties props = new Properties(); + props.setProperty("primary.ojp.readwrite.role", "primary"); + props.setProperty("primary.ojp.readwrite.replicaSelectionStrategy", "INVALID"); + + ReadWriteConfiguration config = ReadWriteConfigurationParser.parseForPrimary("primary", props); + + // Should fall back to default + assertEquals(ReadWriteConfiguration.ReplicaSelectionStrategy.ROUND_ROBIN, config.getStrategy()); + } + + @Test + void testParseCaseInsensitiveStrategy() { + Properties props = new Properties(); + props.setProperty("primary.ojp.readwrite.role", "primary"); + props.setProperty("primary.ojp.readwrite.replicaSelectionStrategy", "round_robin"); + + ReadWriteConfiguration config = ReadWriteConfigurationParser.parseForPrimary("primary", props); + + assertEquals(ReadWriteConfiguration.ReplicaSelectionStrategy.ROUND_ROBIN, config.getStrategy()); + } + + @Test + void testParseCustomStickySessionSeconds() { + Properties props = new Properties(); + props.setProperty("primary.ojp.readwrite.role", "primary"); + props.setProperty("primary.ojp.readwrite.stickySessionSeconds", "10"); + + ReadWriteConfiguration config = ReadWriteConfigurationParser.parseForPrimary("primary", props); + + assertEquals(10, config.getStickySessionSeconds()); + } + + @Test + void testParseInvalidStickySessionSeconds() { + Properties props = new Properties(); + props.setProperty("primary.ojp.readwrite.role", "primary"); + props.setProperty("primary.ojp.readwrite.stickySessionSeconds", "invalid"); + + ReadWriteConfiguration config = ReadWriteConfigurationParser.parseForPrimary("primary", props); + + // Should use default + assertEquals(5, config.getStickySessionSeconds()); + } + + @Test + void testParseFailoverToPrimaryDisabled() { + Properties props = new Properties(); + props.setProperty("primary.ojp.readwrite.role", "primary"); + props.setProperty("primary.ojp.readwrite.replicaFailoverToPrimary", "false"); + + ReadWriteConfiguration config = ReadWriteConfigurationParser.parseForPrimary("primary", props); + + assertFalse(config.isFailoverToPrimary()); + } + + @Test + void testValidateReplicaReferences_Valid() { + Properties props = new Properties(); + props.setProperty("primary.ojp.readwrite.role", "primary"); + props.setProperty("replica1.ojp.readwrite.role", "replica"); + props.setProperty("replica1.ojp.readwrite.primary", "primary"); + + // Should not throw + assertDoesNotThrow(() -> ReadWriteConfigurationParser.validateReplicaReferences(props)); + } + + @Test + void testValidateReplicaReferences_MissingPrimary() { + Properties props = new Properties(); + props.setProperty("replica1.ojp.readwrite.role", "replica"); + props.setProperty("replica1.ojp.readwrite.primary", "nonexistent"); + + assertThrows(IllegalArgumentException.class, () -> + ReadWriteConfigurationParser.validateReplicaReferences(props)); + } + + @Test + void testValidateReplicaReferences_NoPrimarySpecified() { + Properties props = new Properties(); + props.setProperty("replica1.ojp.readwrite.role", "replica"); + // Missing: replica1.ojp.readwrite.primary + + assertThrows(IllegalArgumentException.class, () -> + ReadWriteConfigurationParser.validateReplicaReferences(props)); + } + + @Test + void testNoConfiguration() { + Properties props = new Properties(); + + Map configs = ReadWriteConfigurationParser.parseAll(props); + + assertTrue(configs.isEmpty()); + } + + @Test + void testReplicaWithoutMatchingPrimary() { + Properties props = new Properties(); + props.setProperty("primary.ojp.readwrite.role", "primary"); + props.setProperty("replica1.ojp.readwrite.role", "replica"); + props.setProperty("replica1.ojp.readwrite.primary", "other_primary"); + + ReadWriteConfiguration config = ReadWriteConfigurationParser.parseForPrimary("primary", props); + + // Replica doesn't reference this primary + assertEquals(0, config.getReplicaCount()); + } + + @Test + void testCaching() { + Properties props = new Properties(); + props.setProperty("primary.ojp.readwrite.enabled", "true"); + props.setProperty("primary.ojp.readwrite.role", "primary"); + props.setProperty("replica1.ojp.readwrite.role", "replica"); + props.setProperty("replica1.ojp.readwrite.primary", "primary"); + + ReadWriteConfiguration config1 = ReadWriteConfigurationParser.parseForPrimary("primary", props); + ReadWriteConfiguration config2 = ReadWriteConfigurationParser.parseForPrimary("primary", props); + + // Should return same instance from cache (only cached when enabled with replicas) + assertSame(config1, config2); + } + + @Test + void testClearCache() { + Properties props = new Properties(); + props.setProperty("primary.ojp.readwrite.role", "primary"); + + ReadWriteConfiguration config1 = ReadWriteConfigurationParser.parseForPrimary("primary", props); + + ReadWriteConfigurationParser.clearCache(); + + ReadWriteConfiguration config2 = ReadWriteConfigurationParser.parseForPrimary("primary", props); + + // Should be different instances after cache clear + assertNotSame(config1, config2); + } + + @Test + void testDatasourceNameWithUnderscore() { + Properties props = new Properties(); + props.setProperty("my_primary_db.ojp.readwrite.role", "primary"); + props.setProperty("my_replica_db.ojp.readwrite.role", "replica"); + props.setProperty("my_replica_db.ojp.readwrite.primary", "my_primary_db"); + + ReadWriteConfiguration config = ReadWriteConfigurationParser.parseForPrimary("my_primary_db", props); + + assertEquals("my_primary_db", config.getPrimaryName()); + assertEquals(1, config.getReplicaCount()); + assertTrue(config.getReplicaNames().contains("my_replica_db")); + } +} diff --git a/ojp-server/src/test/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfigurationTest.java b/ojp-server/src/test/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfigurationTest.java new file mode 100644 index 000000000..0f2d697a1 --- /dev/null +++ b/ojp-server/src/test/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfigurationTest.java @@ -0,0 +1,208 @@ +package org.openjproxy.grpc.server.readwrite; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for ReadWriteConfiguration class + */ +class ReadWriteConfigurationTest { + + @Test + void testBasicConfiguration() { + ReadWriteConfiguration config = new ReadWriteConfiguration.Builder() + .primaryName("primary") + .enabled(true) + .strategy(ReadWriteConfiguration.ReplicaSelectionStrategy.ROUND_ROBIN) + .stickySessionSeconds(5) + .failoverToPrimary(true) + .addReplica("replica1") + .addReplica("replica2") + .build(); + + assertEquals("primary", config.getPrimaryName()); + assertTrue(config.isEnabled()); + assertEquals(ReadWriteConfiguration.ReplicaSelectionStrategy.ROUND_ROBIN, config.getStrategy()); + assertEquals(5, config.getStickySessionSeconds()); + assertTrue(config.isFailoverToPrimary()); + assertEquals(2, config.getReplicaCount()); + assertEquals(Arrays.asList("replica1", "replica2"), config.getReplicaNames()); + assertTrue(config.hasReplicas()); + } + + @Test + void testDisabledConfiguration() { + ReadWriteConfiguration config = new ReadWriteConfiguration.Builder() + .primaryName("primary") + .enabled(false) + .build(); + + assertFalse(config.isEnabled()); + assertFalse(config.hasReplicas()); + assertEquals(0, config.getReplicaCount()); + } + + @Test + void testDifferentStrategies() { + ReadWriteConfiguration roundRobin = new ReadWriteConfiguration.Builder() + .primaryName("primary") + .strategy(ReadWriteConfiguration.ReplicaSelectionStrategy.ROUND_ROBIN) + .build(); + + ReadWriteConfiguration random = new ReadWriteConfiguration.Builder() + .primaryName("primary") + .strategy(ReadWriteConfiguration.ReplicaSelectionStrategy.RANDOM) + .build(); + + ReadWriteConfiguration leastConn = new ReadWriteConfiguration.Builder() + .primaryName("primary") + .strategy(ReadWriteConfiguration.ReplicaSelectionStrategy.LEAST_CONNECTIONS) + .build(); + + assertEquals(ReadWriteConfiguration.ReplicaSelectionStrategy.ROUND_ROBIN, roundRobin.getStrategy()); + assertEquals(ReadWriteConfiguration.ReplicaSelectionStrategy.RANDOM, random.getStrategy()); + assertEquals(ReadWriteConfiguration.ReplicaSelectionStrategy.LEAST_CONNECTIONS, leastConn.getStrategy()); + } + + @Test + void testReplicaListImmutability() { + List replicas = Arrays.asList("replica1", "replica2"); + ReadWriteConfiguration config = new ReadWriteConfiguration.Builder() + .primaryName("primary") + .replicas(replicas) + .build(); + + // Returned list should be immutable + List replicaNames = config.getReplicaNames(); + assertThrows(UnsupportedOperationException.class, () -> replicaNames.add("replica3")); + } + + @Test + void testValidation_NullPrimaryName() { + assertThrows(NullPointerException.class, () -> { + new ReadWriteConfiguration.Builder() + .primaryName(null) + .build(); + }); + } + + @Test + void testValidation_EmptyPrimaryName() { + assertThrows(IllegalArgumentException.class, () -> { + new ReadWriteConfiguration.Builder() + .primaryName("") + .build(); + }); + } + + @Test + void testValidation_EnabledWithoutReplicas() { + assertThrows(IllegalArgumentException.class, () -> { + new ReadWriteConfiguration.Builder() + .primaryName("primary") + .enabled(true) + .build(); + }); + } + + @Test + void testValidation_NegativeStickySessionSeconds() { + assertThrows(IllegalArgumentException.class, () -> { + new ReadWriteConfiguration.Builder() + .primaryName("primary") + .stickySessionSeconds(-1) + .addReplica("replica1") + .build(); + }); + } + + @Test + void testValidation_DuplicateReplicas() { + assertThrows(IllegalArgumentException.class, () -> { + new ReadWriteConfiguration.Builder() + .primaryName("primary") + .enabled(true) + .addReplica("replica1") + .addReplica("replica1") + .build(); + }); + } + + @Test + void testValidation_ZeroStickySessionSeconds() { + // Zero is valid (instant expiration) + ReadWriteConfiguration config = new ReadWriteConfiguration.Builder() + .primaryName("primary") + .stickySessionSeconds(0) + .addReplica("replica1") + .build(); + + assertEquals(0, config.getStickySessionSeconds()); + } + + @Test + void testDefaultValues() { + ReadWriteConfiguration config = new ReadWriteConfiguration.Builder() + .primaryName("primary") + .addReplica("replica1") + .build(); + + assertFalse(config.isEnabled()); // Default: disabled + assertEquals(ReadWriteConfiguration.ReplicaSelectionStrategy.ROUND_ROBIN, config.getStrategy()); + assertEquals(5, config.getStickySessionSeconds()); + assertTrue(config.isFailoverToPrimary()); + } + + @Test + void testToString() { + ReadWriteConfiguration config = new ReadWriteConfiguration.Builder() + .primaryName("myPrimary") + .enabled(true) + .addReplica("replica1") + .build(); + + String str = config.toString(); + assertTrue(str.contains("myPrimary")); + assertTrue(str.contains("enabled=true")); + } + + @Test + void testMultipleReplicas() { + ReadWriteConfiguration config = new ReadWriteConfiguration.Builder() + .primaryName("primary") + .addReplica("replica1") + .addReplica("replica2") + .addReplica("replica3") + .build(); + + assertEquals(3, config.getReplicaCount()); + assertEquals(Arrays.asList("replica1", "replica2", "replica3"), config.getReplicaNames()); + } + + @Test + void testFailoverToPrimaryDisabled() { + ReadWriteConfiguration config = new ReadWriteConfiguration.Builder() + .primaryName("primary") + .failoverToPrimary(false) + .addReplica("replica1") + .build(); + + assertFalse(config.isFailoverToPrimary()); + } + + @Test + void testCustomStickySessionSeconds() { + ReadWriteConfiguration config = new ReadWriteConfiguration.Builder() + .primaryName("primary") + .stickySessionSeconds(10) + .addReplica("replica1") + .build(); + + assertEquals(10, config.getStickySessionSeconds()); + } +} diff --git a/ojp-server/src/test/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceManagerTest.java b/ojp-server/src/test/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceManagerTest.java new file mode 100644 index 000000000..c5111e936 --- /dev/null +++ b/ojp-server/src/test/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceManagerTest.java @@ -0,0 +1,183 @@ +package org.openjproxy.grpc.server.readwrite; + +import com.openjproxy.grpc.ConnectionDetails; +import com.openjproxy.grpc.PropertyEntry; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import javax.sql.DataSource; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +/** + * Test cases for ReadWriteDataSourceManager. + */ +class ReadWriteDataSourceManagerTest { + + private ReadWriteDataSourceRegistry registry; + private ReadWriteDataSourceManager manager; + + @BeforeEach + void setUp() { + ReadWriteConfigurationParser.clearCache(); + registry = new ReadWriteDataSourceRegistry(); + registry.clear(); + manager = new ReadWriteDataSourceManager(registry); + } + + @AfterEach + void tearDown() { + if (registry != null) { + registry.clear(); + } + ReadWriteConfigurationParser.clearCache(); + } + + @Test + void testIsReadWriteSplittingEnabled_NoProperties() { + ConnectionDetails details = ConnectionDetails.newBuilder() + .setUrl("jdbc:h2:mem:test") + .build(); + + assertFalse(manager.isReadWriteSplittingEnabled(details, "testds")); + } + + @Test + void testIsReadWriteSplittingEnabled_NotConfigured() { + ConnectionDetails details = ConnectionDetails.newBuilder() + .setUrl("jdbc:h2:mem:test") + .addProperties(PropertyEntry.newBuilder() + .setKey("some.other.property") + .setStringValue("value") + .build()) + .build(); + + assertFalse(manager.isReadWriteSplittingEnabled(details, "testds")); + } + + @Test + void testIsReadWriteSplittingEnabled_ConfiguredButDisabled() { + ConnectionDetails details = ConnectionDetails.newBuilder() + .setUrl("jdbc:h2:mem:test") + .addProperties(PropertyEntry.newBuilder() + .setKey("testds.ojp.readwrite.enabled") + .setStringValue("false") + .build()) + .addProperties(PropertyEntry.newBuilder() + .setKey("testds.ojp.readwrite.role") + .setStringValue("primary") + .build()) + .build(); + + assertFalse(manager.isReadWriteSplittingEnabled(details, "testds")); + } + + @Test + void testIsReadWriteSplittingEnabled_EnabledWithoutReplicas() { + ConnectionDetails details = ConnectionDetails.newBuilder() + .setUrl("jdbc:h2:mem:test") + .addProperties(PropertyEntry.newBuilder() + .setKey("testds.ojp.readwrite.enabled") + .setStringValue("true") + .build()) + .addProperties(PropertyEntry.newBuilder() + .setKey("testds.ojp.readwrite.role") + .setStringValue("primary") + .build()) + .build(); + + // Without replicas configured, builder throws IllegalArgumentException + assertThrows(IllegalArgumentException.class, () -> manager.isReadWriteSplittingEnabled(details, "testds")); + } + + @Test + void testIsReadWriteSplittingEnabled_EnabledWithReplicas() { + ConnectionDetails details = ConnectionDetails.newBuilder() + .setUrl("jdbc:h2:mem:test") + .addProperties(PropertyEntry.newBuilder() + .setKey("testds.ojp.readwrite.enabled") + .setStringValue("true") + .build()) + .addProperties(PropertyEntry.newBuilder() + .setKey("testds.ojp.readwrite.role") + .setStringValue("primary") + .build()) + .addProperties(PropertyEntry.newBuilder() + .setKey("replica1.ojp.readwrite.role") + .setStringValue("replica") + .build()) + .addProperties(PropertyEntry.newBuilder() + .setKey("replica1.ojp.readwrite.primary") + .setStringValue("testds") + .build()) + .build(); + + assertTrue(manager.isReadWriteSplittingEnabled(details, "testds")); + } + + @Test + void testSetupReadWriteSplitting_NoConfiguration() { + ConnectionDetails details = ConnectionDetails.newBuilder() + .setUrl("jdbc:h2:mem:test") + .build(); + + DataSource ds = mock(DataSource.class); + ReadWriteConfiguration config = manager.setupReadWriteSplitting( + details, "conn123", ds, "testds"); + + assertNull(config); + } + + @Test + void testSetupReadWriteSplitting_ValidConfiguration() { + ConnectionDetails details = ConnectionDetails.newBuilder() + .setUrl("jdbc:h2:mem:mgr_test_primary") + .addProperties(PropertyEntry.newBuilder() + .setKey("mgr_test_primary.ojp.readwrite.enabled") + .setStringValue("true") + .build()) + .addProperties(PropertyEntry.newBuilder() + .setKey("mgr_test_primary.ojp.readwrite.role") + .setStringValue("primary") + .build()) + .addProperties(PropertyEntry.newBuilder() + .setKey("mgr_test_replica.ojp.readwrite.role") + .setStringValue("replica") + .build()) + .addProperties(PropertyEntry.newBuilder() + .setKey("mgr_test_replica.ojp.readwrite.primary") + .setStringValue("mgr_test_primary") + .build()) + .addProperties(PropertyEntry.newBuilder() + .setKey("mgr_test_replica.ojp.connection.url") + .setStringValue("jdbc:h2:mem:mgr_test_replica;DB_CLOSE_DELAY=-1") + .build()) + .build(); + + DataSource ds = mock(DataSource.class); + ReadWriteConfiguration config = manager.setupReadWriteSplitting( + details, "conn123", ds, "mgr_test_primary"); + + assertNotNull(config); + assertTrue(config.isEnabled()); + assertEquals("mgr_test_primary", config.getPrimaryName()); + assertEquals(1, config.getReplicaNames().size()); + assertEquals("mgr_test_replica", config.getReplicaNames().get(0)); + + String primaryName = registry.getPrimaryName("conn123"); + assertNotNull(primaryName); + assertEquals("mgr_test_primary", primaryName); + + List registeredReplicas = registry.getReplicas("mgr_test_primary"); + assertNotNull(registeredReplicas); + assertEquals(1, registeredReplicas.size()); + } + + @Test + void testConstructorNullRegistry() { + assertThrows(NullPointerException.class, () -> new ReadWriteDataSourceManager(null)); + } +} From b96a0ae0f0ad6dc52e5e5745ba2dc8173e1bbfe3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 12:33:18 +0000 Subject: [PATCH 02/25] chore: remove accidentally committed .class files from readwrite package Agent-Logs-Url: https://github.com/Open-J-Proxy/ojp/sessions/f22a5174-b9f5-4721-829c-9d776642a2bd Co-authored-by: rrobetti <7221783+rrobetti@users.noreply.github.com> --- .../ReadWriteConfiguration$Builder.class | Bin 2423 -> 0 bytes ...eConfiguration$ReplicaSelectionStrategy.class | Bin 1514 -> 0 bytes .../readwrite/ReadWriteConfiguration.class | Bin 4211 -> 0 bytes .../readwrite/ReadWriteConfigurationParser.class | Bin 7871 -> 0 bytes .../readwrite/ReadWriteDataSourceManager.class | Bin 8087 -> 0 bytes .../readwrite/ReadWriteDataSourceRegistry.class | Bin 4352 -> 0 bytes 6 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfiguration$Builder.class delete mode 100644 ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfiguration$ReplicaSelectionStrategy.class delete mode 100644 ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfiguration.class delete mode 100644 ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfigurationParser.class delete mode 100644 ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceManager.class delete mode 100644 ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceRegistry.class diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfiguration$Builder.class b/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfiguration$Builder.class deleted file mode 100644 index 6be7c8cbd1c73477bf39f472129841d508b1f5e6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2423 zcmcIlOK%%h7(LfcGqy8z)1(P`w2)F0>@n1YH#nqiTtW=SL3T+LZ_vb^T9Zy@teJ7Z z8xSuE7O;Q?Ea(Cjus|R{mQoKSlhzq^$8LBGLQyi!t2i%1eDg2zjDL6SeP z@DUY@c$6V&^`uK)fr%b#PzVg0X*cs@$Usw&Rq+@e7fAkJ7Ry6#FoRR+BgkF}*?CCMBPpgWYfKxI6o}=*3-pdiAt~qLVKC@u=&_{TSrSi=)7TRymegi ziA6LlZKrnR$QHPg9!MhVD^YdYZrxZjWg*X>ydW;i3Bx#+4bNY9Jgy?OXxoNcuyoIp z^GrG1AT#%>5?tgsjBwSB^5&c)6_KkecsZGam%})CId6lPvl9X@1oVrDljd&Zo#&ql zqNIRD?EpfHcA;p$;dB?1TC9s{ZKR7i?G7$%AK;-b7JenCz%uVSN&w!&2;Qf}4=|1o zxfwp<|BuPB$W`2ve~I=r4KMKjX4hZjDXL{I)y8J z0BV@*1AIZimt>v6)jj|8 zeXwuo^ml}~1N%;t6CU&b15iC+tAWAP4_wy8SB zqwq}fO!Lg~EF9peQuYr#o4p&^Bx?Zp5exVUDg4Z@SNI*lI*P%&gje}a@K20e;5Ftu J$+vWM_CG|ljcot` diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfiguration$ReplicaSelectionStrategy.class b/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfiguration$ReplicaSelectionStrategy.class deleted file mode 100644 index b4ac41daa3737a1a0640faf86e5791cb89cf6c09..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1514 zcmcIkZBNrs6n^gBx|JehBJYSI$_7G31Wbkm95XJNZL*G$#Sf&6%j{CxrQ1;cm5xM+ zM#E=+l=0j;WeNDnY)y9WdG2|6`rLE+Ckb*EG z41EQCSKHYy=r3~`Ln+^jdTCKGsvsr`Mhe^7j{ZhKeF_u-4d%1kMsYu*Yg#r_%;}mT zPH!m~K%7CEEp6v_vj)T7+uqQVk-%*UaRo!T!w|06uEQCIlBs;nJTX<6WbHhZgKsHVKvzmow3bEBi#kiU06;v-CaQ>@bgb8xQl6$ zYQu7@<|e~%cdAZgE>(&mER#@EQN*H9#P*?rN0<|u`7{~~& z(AJpv1^~3uX!w2GhHM*n9FULx_4EQWddkN9V+hVJ*Zl*c(-H9VPa{YVAk8mKo@4UUwd9Wp31BfUize?BQ4t~(7>RrX zD+k*M8s$(Ml2Hz~=_amMjA99ko!~+zcQ6Rve6D2x@E`TM3BtnxG_&?Zk#W=~$k4G$Uq4 zCZa99K?~*5a%oE_y+TWGtOaY~B!snSpZd_p{-CaYXJ%~4(yYF)vihJo$7lB5XJ5X( z=kve*bNdqj$M9YfZD`lfp`#OB0)1DFbt7vTb}4&q`Km1V0$oQ<+w_kMw2x0LCXf)= zQ*q6*;nwDjvP>5Y+jji)vP|1m%Ss}N9u2yV9oQ+*ADFB9rWKj<1a`XeiK^+!dB>io zhQQ(R+2&hw4JQk}YucsUM8i?&T24dXHjhKeZlthF!+?(6xJw}CxTUO9k@nS!>s+g4 zOKzo*^`yHl-K;B(;*+lF%j^a2E~(wLW3QN{s%!YBV+-^&ges6~@F8+b-FCN*A?y** zq-`u)vM3;y61YcT(DP|XmTFB|(y&+H;-_$I^n$EdX2DpH7HdRL=}mBvawo0hGuS7P z*t}g}arRDJmQ%Nl>bM_c0t23J7S?JD(({x*vf$W7R$Jti(728Xm7)F>!?YZx{-Sd} z#E^yq0=w!knRH#FHfwr*0uKlbyJ4IPX6<18_NzN%ezisp)`N~;_b*w<)N9c_X$xFk4GK|ib`hob^| zV-PeLf~YQ=L@$o(_%coibP()ihBe*@_XuiAZc4{AP7-RScdA_RYe82oX_%o`^(M+Q z6D7l%bW7E;wEa`p3bLY5lE4{({ZZ!AMZ-5dr|K5iy4yQhD$_}v!daEYN0^+(7LDAQ zSnR{Z;Nf->`jVb}Y_lYlo%4UQxy# zRet1S5~KXMq~kGMW&%C)DH&uXua2+i_$t1}A;w;i1~WUnnb{!!;c1o;@nx&}w&qr59D zt5=qevMOaaOBv1$b?WG-;0X>MD$uQG8b)%$PjcpBdk=%tNlumugzfxE3*L1(4V zj_)Zrd|zNL20=@$2+GHt#e~CBESM$R@T)EnG}_drM_cV4&j~yo>%twW)n*v=$uY4= zz)w3xy4yQz+H$^HUgm67lS<&#|D$KI$x_}Xh%u9chyLwY@QuRSoKcDV?O8RvsQMfZ z4Oo~C`)c?C+siD=yuEIECPSQ5#3T4lQ$96oIpt+%EqhwpQngKy#TjdXPpo5f%Xipp zq!|Lqg`i;!S(9pp(f@GJ&KL#Xartq0qY(p z>7^qcm0qs;Y1>Ih-%k>Nh@8n{dc|?m5jT4{EfV;xKqiiXG?E5>6Mb`C2J*ibIC!!e zkSJen-Vl5TJ=(6?tv9?QP>YAdc;tv0TF1n}P50GSl~EDEaNjx})p-jiQiqM4;h2~p zi^$sq_OxYqo+RGyj#{OK0dkHe-+sKzeV{G4Z%WoslIPO7gus@ z?ox^X2P>Ipu#yu7D`{V_k_quU{sZpY@CK7uS}ahMcglIv;AN4`Br&F2}J2t4~YH3coal=%?- z|3sf!Zs6DrOkT&1k8$d9>hw+I|IXvV>qv7s@)2eQTXjIaRMNL`4v1t%-r71Jxs8Rb zb1HE(J{zhp34DaFt4BfL^=+0)p@s^tEeLN6-P|u?0GF^AOE`opn8M=>(%@f`U)>eF zh*kWUQW`o3x<>{NF`gH2GKx>tM8!80MzCuuf;XXcyo-+Zzir*6QUY=lWvdfygC~^c zi@40Es^LDaN+21UU_44StU{<-<6q=LQO)m-XP_bvW;8{Y+*t@mxS+JRT-P8(juYh1gyd!5ex toclfOhhMNPzY3ne37)?To^Rt1!TWYfrtrrTL)1y(T>|t^_zSTx^k4dRe@*}Z diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfigurationParser.class b/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfigurationParser.class deleted file mode 100644 index 899597bcfeb273a24c1b63630e244d7adebbeb2b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7871 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 diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceManager.class b/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceManager.class deleted file mode 100644 index 252160803bdeb64d779d459bc8534a81122263d5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8087 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 From 2cd0d5d57d076e9aab1bfb97834751ef4dcff2f0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 12:35:46 +0000 Subject: [PATCH 03/25] refactor: rename rwRegistry to readWriteRegistry for clarity in ConnectAction Agent-Logs-Url: https://github.com/Open-J-Proxy/ojp/sessions/f22a5174-b9f5-4721-829c-9d776642a2bd Co-authored-by: rrobetti <7221783+rrobetti@users.noreply.github.com> --- .../grpc/server/action/connection/ConnectAction.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/action/connection/ConnectAction.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/action/connection/ConnectAction.java index 50977e2d9..6e09b0029 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/action/connection/ConnectAction.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/action/connection/ConnectAction.java @@ -250,10 +250,10 @@ private void handleRegularConnection(ActionContext context, ConnectionDetails co // If the pool already existed and read/write splitting was not yet registered for this // connHash, attempt setup now. The setupReadWriteSplitting implementation is idempotent: // it skips silently when the primary is already mapped and replicas are registered. - org.openjproxy.grpc.server.readwrite.ReadWriteDataSourceRegistry rwRegistry = + org.openjproxy.grpc.server.readwrite.ReadWriteDataSourceRegistry readWriteRegistry = context.getReadWriteDataSourceRegistry(); - if (rwRegistry != null - && rwRegistry.getPrimaryName(connHash) == null + if (readWriteRegistry != null + && readWriteRegistry.getPrimaryName(connHash) == null && connectionDetails.getPropertiesCount() > 0) { DataSource existingDs = context.getDatasourceMap().get(connHash); if (existingDs != null) { From 68620762dd053b75b722f1d3d58009d6c0ca32a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 12:50:26 +0000 Subject: [PATCH 04/25] fix: update existing tests to pass ReadWriteDataSourceRegistry to ActionContext constructor Agent-Logs-Url: https://github.com/Open-J-Proxy/ojp/sessions/2f02f94b-5063-4f3a-9eae-4286c2b1e2cf Co-authored-by: rrobetti <7221783+rrobetti@users.noreply.github.com> --- .../server/action/connection/ConnectActionConcurrencyTest.java | 3 +++ .../connection/HandleUnpooledXAConnectionActionTest.java | 2 ++ 2 files changed, 5 insertions(+) diff --git a/ojp-server/src/test/java/org/openjproxy/grpc/server/action/connection/ConnectActionConcurrencyTest.java b/ojp-server/src/test/java/org/openjproxy/grpc/server/action/connection/ConnectActionConcurrencyTest.java index 51deebaa4..a9f88bcb9 100644 --- a/ojp-server/src/test/java/org/openjproxy/grpc/server/action/connection/ConnectActionConcurrencyTest.java +++ b/ojp-server/src/test/java/org/openjproxy/grpc/server/action/connection/ConnectActionConcurrencyTest.java @@ -11,6 +11,7 @@ import org.openjproxy.grpc.server.SessionManager; import org.openjproxy.grpc.server.UnpooledConnectionDetails; import org.openjproxy.grpc.server.action.ActionContext; +import org.openjproxy.grpc.server.readwrite.ReadWriteDataSourceRegistry; import org.openjproxy.xa.pool.spi.XAConnectionPoolProvider; import javax.sql.DataSource; @@ -60,6 +61,7 @@ void testConcurrentConnectionsCreateOnlyOnePool() throws InterruptedException { new ConcurrentHashMap<>(), new ConcurrentHashMap<>(), new ConcurrentHashMap<>(), + new ReadWriteDataSourceRegistry(), mock(XAConnectionPoolProvider.class), new MultinodeXaCoordinator(), new ClusterHealthTracker(), @@ -138,6 +140,7 @@ void testConcurrentConnectionsDifferentHashesCreateSeparatePools() { new ConcurrentHashMap<>(), new ConcurrentHashMap<>(), new ConcurrentHashMap<>(), + new ReadWriteDataSourceRegistry(), mock(XAConnectionPoolProvider.class), new MultinodeXaCoordinator(), new ClusterHealthTracker(), diff --git a/ojp-server/src/test/java/org/openjproxy/grpc/server/action/connection/HandleUnpooledXAConnectionActionTest.java b/ojp-server/src/test/java/org/openjproxy/grpc/server/action/connection/HandleUnpooledXAConnectionActionTest.java index cb4eca5f0..d564822ce 100644 --- a/ojp-server/src/test/java/org/openjproxy/grpc/server/action/connection/HandleUnpooledXAConnectionActionTest.java +++ b/ojp-server/src/test/java/org/openjproxy/grpc/server/action/connection/HandleUnpooledXAConnectionActionTest.java @@ -12,6 +12,7 @@ import org.openjproxy.grpc.server.SessionManager; import org.openjproxy.grpc.server.SlowQuerySegregationManager; import org.openjproxy.grpc.server.action.ActionContext; +import org.openjproxy.grpc.server.readwrite.ReadWriteDataSourceRegistry; import org.openjproxy.grpc.server.xa.XADataSourceFactory; import org.openjproxy.xa.pool.spi.XAConnectionPoolProvider; @@ -47,6 +48,7 @@ void testExecuteCreatesSlowQuerySegregationManager() { new ConcurrentHashMap<>(), slowQueryManagers, new ConcurrentHashMap<>(), + new ReadWriteDataSourceRegistry(), mock(XAConnectionPoolProvider.class), new MultinodeXaCoordinator(), new ClusterHealthTracker(), From 4afb87e8f22feb1a907c40ff8af73f3a3b7f64f5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 13:18:07 +0000 Subject: [PATCH 05/25] =?UTF-8?q?feat:=20Phase=202=20routing=20+=20Sonar?= =?UTF-8?q?=20fixes=20=E2=80=94=20SQL=20classifier,=20replica=20selector,?= =?UTF-8?q?=20sticky=20session,=20query=20routing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/Open-J-Proxy/ojp/sessions/b0f48dbd-89f2-4f81-b0df-4caee408691b Co-authored-by: rrobetti <7221783+rrobetti@users.noreply.github.com> --- .../streaming/SessionConnectionHelper.java | 30 ++++- .../transaction/ExecuteQueryAction.java | 67 ++++++++++- .../transaction/ExecuteUpdateAction.java | 26 ++++- .../server/readwrite/ReadReplicaSelector.java | 61 ++++++++++ .../readwrite/ReadWriteDataSourceManager.java | 3 + .../ReadWriteDataSourceRegistry.java | 78 +++++++++++++ .../readwrite/ReadWriteSqlClassifier.java | 48 ++++++++ .../readwrite/ReadReplicaSelectorTest.java | 101 ++++++++++++++++ .../readwrite/ReadWriteConfigurationTest.java | 44 +++---- .../ReadWriteDataSourceRegistryTest.java | 108 ++++++++++++++++++ .../readwrite/ReadWriteSqlClassifierTest.java | 107 +++++++++++++++++ 11 files changed, 638 insertions(+), 35 deletions(-) create mode 100644 ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadReplicaSelector.java create mode 100644 ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteSqlClassifier.java create mode 100644 ojp-server/src/test/java/org/openjproxy/grpc/server/readwrite/ReadReplicaSelectorTest.java create mode 100644 ojp-server/src/test/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceRegistryTest.java create mode 100644 ojp-server/src/test/java/org/openjproxy/grpc/server/readwrite/ReadWriteSqlClassifierTest.java diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/action/streaming/SessionConnectionHelper.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/action/streaming/SessionConnectionHelper.java index c43c535e1..aab6b6ae5 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/action/streaming/SessionConnectionHelper.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/action/streaming/SessionConnectionHelper.java @@ -91,6 +91,29 @@ private SessionConnectionHelper() { public static ConnectionSessionDTO sessionConnection(ActionContext context, SessionInfo sessionInfo, boolean startSessionIfNone) throws SQLException { + return sessionConnection(context, sessionInfo, startSessionIfNone, null); + } + + /** + * Same as {@link #sessionConnection(ActionContext, SessionInfo, boolean)} but + * allows the caller to supply a {@code replicaDataSource} that overrides the + * pooled-mode datasource lookup. When {@code replicaDataSource} is non-{@code null} + * and the session has no existing UUID (i.e. stateless / auto-commit mode), the + * connection is acquired from {@code replicaDataSource} instead of the primary pool. + * XA, unpooled, and existing-session paths are not affected by this parameter. + * + * @param context the action context containing the session manager + * @param sessionInfo current sessionInfo object + * @param startSessionIfNone if {@code true} a new session will be started when none exists + * @param replicaDataSource optional datasource to use in place of the primary pool; + * {@code null} means "use the primary pool as normal" + * @return ConnectionSessionDTO + * @throws SQLException if a connection cannot be obtained + */ + public static ConnectionSessionDTO sessionConnection(ActionContext context, SessionInfo sessionInfo, + boolean startSessionIfNone, + javax.sql.DataSource replicaDataSource) + throws SQLException { ConnectionSessionDTO.ConnectionSessionDTOBuilder dtoBuilder = ConnectionSessionDTO.builder(); dtoBuilder.session(sessionInfo); Connection conn; @@ -160,8 +183,11 @@ public static ConnectionSessionDTO sessionConnection(ActionContext context, Sess throw e; } } else { - // Pooled mode: acquire from datasource (HikariCP by default) - DataSource dataSource = context.getDatasourceMap().get(connHash); + // Pooled mode: use replica override when provided, otherwise use the primary pool + DataSource dataSource = (replicaDataSource != null) + ? replicaDataSource + : context.getDatasourceMap().get(connHash); + if (dataSource == null) { // Signal the client to reconnect. NOT_FOUND is caught by // CommandExecutionHelper and translated to Status.NOT_FOUND so that the diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteQueryAction.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteQueryAction.java index fba387ced..faf7fb83f 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteQueryAction.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteQueryAction.java @@ -5,6 +5,7 @@ import io.grpc.stub.StreamObserver; import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; import org.openjproxy.grpc.ProtoConverter; import org.openjproxy.grpc.dto.OpQueryResult; import org.openjproxy.grpc.dto.Parameter; @@ -14,6 +15,9 @@ import org.openjproxy.grpc.server.action.ActionContext; import org.openjproxy.grpc.server.cache.CacheConfiguration; import org.openjproxy.grpc.server.cache.QueryCacheHelper; +import org.openjproxy.grpc.server.readwrite.ReadReplicaSelector; +import org.openjproxy.grpc.server.readwrite.ReadWriteDataSourceRegistry; +import org.openjproxy.grpc.server.readwrite.ReadWriteSqlClassifier; import org.openjproxy.grpc.server.statement.StatementFactory; import javax.sql.DataSource; @@ -33,6 +37,8 @@ public class ExecuteQueryAction implements Action { private static final ExecuteQueryAction INSTANCE = new ExecuteQueryAction(); + private static final ReadReplicaSelector REPLICA_SELECTOR = new ReadReplicaSelector(); + /** * Private constructor for singleton. */ @@ -63,7 +69,13 @@ public void execute(ActionContext context, StatementRequest request, StreamObser private void executeQueryInternal(ActionContext actionContext, StatementRequest request, StreamObserver responseObserver) throws SQLException { - ConnectionSessionDTO dto = sessionConnection(actionContext, request.getSession(), true); + // Read/write splitting: route reads to a replica when applicable + DataSource replicaDs = resolveReadReplicaDataSource(actionContext, request); + ConnectionSessionDTO dto = sessionConnection(actionContext, request.getSession(), true, replicaDs); + if (replicaDs != null) { + log.debug("Read/write splitting: routed SELECT to replica for connHash={}", + request.getSession().getConnHash()); + } // Phase 6: Cache Lookup (before query execution) - with graceful degradation String sql = request.getSql(); @@ -165,4 +177,57 @@ private void executeQueryInternal(ActionContext actionContext, StatementRequest handleResultSet(actionContext, dto.getSession(), resultSetUUID, finalObserver); } } + + /** + * Determines whether this read query should be routed to a replica, and if so + * returns the selected replica {@link DataSource}. + * + *

Routing to a replica is skipped when any of the following is true: + *

    + *
  • No {@link ReadWriteDataSourceRegistry} is available in the context.
  • + *
  • The request is inside an explicit transaction (non-empty {@code sessionUUID}).
  • + *
  • The SQL is not a read-only statement.
  • + *
  • The primary has no replicas registered.
  • + *
  • A sticky-session window is currently active for the primary.
  • + *
+ * + * @param context the action context + * @param request the statement request + * @return a replica {@link DataSource}, or {@code null} when the primary should be used + */ + private DataSource resolveReadReplicaDataSource(ActionContext context, StatementRequest request) { + ReadWriteDataSourceRegistry registry = context.getReadWriteDataSourceRegistry(); + if (registry == null) { + return null; + } + + // Do not route to replica when inside a transaction (session UUID is set) + if (StringUtils.isNotBlank(request.getSession().getSessionUUID())) { + return null; + } + + // Only route read-only SQL to replicas + if (ReadWriteSqlClassifier.classify(request.getSql()) != ReadWriteSqlClassifier.QueryType.READ) { + return null; + } + + String connHash = request.getSession().getConnHash(); + String primaryName = registry.getPrimaryName(connHash); + if (primaryName == null) { + return null; + } + + // Honour sticky session: after a write, reads go to primary until timeout expires + if (registry.isStickyActive(primaryName)) { + log.debug("Read/write splitting: sticky session active for primary '{}', routing to primary", primaryName); + return null; + } + + java.util.List replicas = registry.getReplicas(primaryName); + if (replicas.isEmpty()) { + return null; + } + + return REPLICA_SELECTOR.select(primaryName, replicas, registry.getStrategy(primaryName)); + } } diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteUpdateAction.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteUpdateAction.java index a3abdec29..0836a47cc 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteUpdateAction.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteUpdateAction.java @@ -140,7 +140,10 @@ private OpResult executeUpdateInternal(ActionContext actionContext, StatementReq // Phase 9: Cache Invalidation (after successful update) org.openjproxy.grpc.server.cache.QueryCacheHelper.invalidateCacheIfEnabled(actionContext, dto.getSession(), request.getSql()); - + + // Read/write splitting: mark write for sticky session (routes subsequent reads to primary) + markStickySessionAfterWrite(actionContext, dto); + return result; } finally { closeStatementAndConnectionIfNoSession(dto, stmt); @@ -270,4 +273,25 @@ private void closeStatementAndConnectionIfNoSession(ConnectionSessionDTO dto, St } } } + + /** + * Marks a write on the sticky-session tracker so that subsequent reads within the + * configured window are routed to the primary (read-your-writes guarantee). + * This is a no-op when read/write splitting is not configured. + * + * @param actionContext the action context + * @param dto the connection and session DTO used for the write + */ + private void markStickySessionAfterWrite(ActionContext actionContext, ConnectionSessionDTO dto) { + var registry = actionContext.getReadWriteDataSourceRegistry(); + if (registry == null || dto.getSession() == null) { + return; + } + String connHash = dto.getSession().getConnHash(); + String primaryName = registry.getPrimaryName(connHash); + if (primaryName != null) { + registry.markWrite(primaryName); + log.debug("Read/write splitting: sticky session marked for primary '{}' after write", primaryName); + } + } } diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadReplicaSelector.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadReplicaSelector.java new file mode 100644 index 000000000..4e55982d7 --- /dev/null +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadReplicaSelector.java @@ -0,0 +1,61 @@ +package org.openjproxy.grpc.server.readwrite; + +import javax.sql.DataSource; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Selects a replica {@link DataSource} from the list of registered replicas + * using the configured {@link ReadWriteConfiguration.ReplicaSelectionStrategy}. + * + *

Supported strategies: + *

    + *
  • {@link ReadWriteConfiguration.ReplicaSelectionStrategy#ROUND_ROBIN} — + * cycles through replicas in order (default)
  • + *
  • {@link ReadWriteConfiguration.ReplicaSelectionStrategy#RANDOM} — + * picks a replica at random
  • + *
  • {@link ReadWriteConfiguration.ReplicaSelectionStrategy#LEAST_CONNECTIONS} — + * falls back to ROUND_ROBIN in Phase 2; metrics-based selection is planned + * for a future phase
  • + *
+ * + *

Thread Safety: this class is thread-safe. Round-robin counters use + * {@link AtomicInteger} and are stored in a {@link ConcurrentHashMap}. + */ +public class ReadReplicaSelector { + + private final Map roundRobinCounters = new ConcurrentHashMap<>(); + + /** + * Selects a replica {@link DataSource} using the given strategy. + * + * @param primaryName the name of the primary datasource (used as a key for + * per-primary counters) + * @param replicas the list of available replica datasources; must not be + * {@code null} + * @param strategy the replica selection strategy + * @return the selected {@link DataSource}, or {@code null} if + * {@code replicas} is empty + */ + public DataSource select(String primaryName, List replicas, + ReadWriteConfiguration.ReplicaSelectionStrategy strategy) { + if (replicas.isEmpty()) { + return null; + } + return switch (strategy) { + case RANDOM -> replicas.get(ThreadLocalRandom.current().nextInt(replicas.size())); + // LEAST_CONNECTIONS falls back to ROUND_ROBIN until pool metrics are available + default -> roundRobin(primaryName, replicas); + }; + } + + private DataSource roundRobin(String primaryName, List replicas) { + AtomicInteger counter = roundRobinCounters + .computeIfAbsent(primaryName, k -> new AtomicInteger(0)); + int idx = Math.abs(counter.getAndIncrement() % replicas.size()); + return replicas.get(idx); + } +} diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceManager.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceManager.java index 6a7d58d3a..01daf9d3c 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceManager.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceManager.java @@ -91,6 +91,9 @@ public ReadWriteConfiguration setupReadWriteSplitting( // Register sticky session timeout registry.registerStickyTimeout(datasourceName, config.getStickySessionSeconds()); + // Register replica selection strategy + registry.registerStrategy(datasourceName, config.getStrategy()); + // Create and register replica datasources List successfulReplicas = new ArrayList<>(); for (String replicaName : config.getReplicaNames()) { diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceRegistry.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceRegistry.java index 9b461de02..a526995e7 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceRegistry.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceRegistry.java @@ -30,6 +30,12 @@ public class ReadWriteDataSourceRegistry { // Map of primary datasource name → sticky session timeout in seconds (0 = disabled) private final Map stickyTimeoutMap = new ConcurrentHashMap<>(); + // Map of primary datasource name → replica selection strategy + private final Map strategyMap = new ConcurrentHashMap<>(); + + // Map of primary datasource name → timestamp (epoch ms) of the last write (for sticky sessions) + private final Map lastWriteTimestamps = new ConcurrentHashMap<>(); + /** * Registers a mapping from connection hash to primary datasource name. * This is used to associate client connections with their primary datasource. @@ -153,6 +159,76 @@ public int getStickySessionSeconds(String primaryName) { return stickyTimeoutMap.getOrDefault(primaryName, 0); } + /** + * Registers the replica selection strategy for a given primary datasource. + * + * @param primaryName the primary datasource name + * @param strategy the strategy to use when selecting a replica + */ + public void registerStrategy(String primaryName, + ReadWriteConfiguration.ReplicaSelectionStrategy strategy) { + if (primaryName == null || strategy == null) { + throw new IllegalArgumentException("primaryName and strategy must not be null"); + } + strategyMap.put(primaryName, strategy); + log.debug("Registered replica selection strategy for primary '{}': {}", primaryName, strategy); + } + + /** + * Returns the replica selection strategy for the given primary datasource. + * Defaults to {@link ReadWriteConfiguration.ReplicaSelectionStrategy#ROUND_ROBIN} + * if none has been registered. + * + * @param primaryName the primary datasource name + * @return the configured strategy, or {@code ROUND_ROBIN} if not set + */ + public ReadWriteConfiguration.ReplicaSelectionStrategy getStrategy(String primaryName) { + return strategyMap.getOrDefault(primaryName, + ReadWriteConfiguration.ReplicaSelectionStrategy.ROUND_ROBIN); + } + + /** + * Records that a write operation has just been performed on the given primary. + * This timestamp is used to determine whether the sticky-session window is still active. + * + * @param primaryName the primary datasource name + */ + public void markWrite(String primaryName) { + if (primaryName == null) { + return; + } + lastWriteTimestamps.put(primaryName, System.currentTimeMillis()); + log.debug("Marked write for sticky session on primary '{}'", primaryName); + } + + /** + * Returns {@code true} when a sticky-session window is currently active for the + * given primary. A sticky window is active when all of the following hold: + *

    + *
  1. A sticky timeout greater than zero has been registered.
  2. + *
  3. A write has occurred within the last {@code stickyTimeoutSeconds} seconds.
  4. + *
+ * + * @param primaryName the primary datasource name + * @return {@code true} if reads should be routed to the primary (sticky), {@code false} + * if they may be routed to a replica + */ + public boolean isStickyActive(String primaryName) { + if (primaryName == null) { + return false; + } + int timeoutSeconds = stickyTimeoutMap.getOrDefault(primaryName, 0); + if (timeoutSeconds <= 0) { + return false; + } + Long lastWrite = lastWriteTimestamps.get(primaryName); + if (lastWrite == null) { + return false; + } + long elapsed = System.currentTimeMillis() - lastWrite; + return elapsed < (long) timeoutSeconds * 1000; + } + /** * Clears all registered datasources and mappings. * This is primarily for testing and cleanup purposes. @@ -161,6 +237,8 @@ public void clear() { replicaMap.clear(); primaryMappings.clear(); stickyTimeoutMap.clear(); + strategyMap.clear(); + lastWriteTimestamps.clear(); log.debug("Cleared all registry data"); } } diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteSqlClassifier.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteSqlClassifier.java new file mode 100644 index 000000000..5c77f4e50 --- /dev/null +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteSqlClassifier.java @@ -0,0 +1,48 @@ +package org.openjproxy.grpc.server.readwrite; + +/** + * Classifies SQL statements as read-only or write operations to support + * read/write traffic splitting. + * + *

SELECT, WITH (CTEs), EXPLAIN, SHOW, and DESCRIBE statements are treated + * as read-only and may be routed to a replica. All other statements + * (INSERT, UPDATE, DELETE, MERGE, DDL, etc.) are treated as writes and are + * always routed to the primary. + */ +public final class ReadWriteSqlClassifier { + + /** Indicates whether a SQL statement modifies data. */ + public enum QueryType { + READ, + WRITE + } + + private ReadWriteSqlClassifier() { + throw new UnsupportedOperationException("Utility class"); + } + + /** + * Classifies the given SQL statement as {@link QueryType#READ} or + * {@link QueryType#WRITE}. + * + * @param sql the SQL statement to classify (may be {@code null}) + * @return {@link QueryType#READ} for read-only statements, + * {@link QueryType#WRITE} for all other statements and for + * {@code null} / blank input + */ + public static QueryType classify(String sql) { + if (sql == null || sql.isBlank()) { + return QueryType.WRITE; + } + String upper = sql.stripLeading().toUpperCase(); + if (upper.startsWith("SELECT") + || upper.startsWith("WITH") + || upper.startsWith("EXPLAIN") + || upper.startsWith("SHOW") + || upper.startsWith("DESCRIBE") + || upper.startsWith("DESC ")) { + return QueryType.READ; + } + return QueryType.WRITE; + } +} diff --git a/ojp-server/src/test/java/org/openjproxy/grpc/server/readwrite/ReadReplicaSelectorTest.java b/ojp-server/src/test/java/org/openjproxy/grpc/server/readwrite/ReadReplicaSelectorTest.java new file mode 100644 index 000000000..8cd62db50 --- /dev/null +++ b/ojp-server/src/test/java/org/openjproxy/grpc/server/readwrite/ReadReplicaSelectorTest.java @@ -0,0 +1,101 @@ +package org.openjproxy.grpc.server.readwrite; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import javax.sql.DataSource; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +/** + * Tests for ReadReplicaSelector + */ +class ReadReplicaSelectorTest { + + private ReadReplicaSelector selector; + private DataSource ds1; + private DataSource ds2; + private DataSource ds3; + + @BeforeEach + void setUp() { + selector = new ReadReplicaSelector(); + ds1 = mock(DataSource.class); + ds2 = mock(DataSource.class); + ds3 = mock(DataSource.class); + } + + @Test + void shouldReturnNullWhenNoReplicasAvailable() { + DataSource result = selector.select("primary", List.of(), + ReadWriteConfiguration.ReplicaSelectionStrategy.ROUND_ROBIN); + assertNull(result); + } + + @Test + void shouldReturnOnlyReplicaForRoundRobin() { + DataSource result = selector.select("primary", List.of(ds1), + ReadWriteConfiguration.ReplicaSelectionStrategy.ROUND_ROBIN); + assertEquals(ds1, result); + } + + @Test + void shouldCycleThroughReplicasWithRoundRobin() { + List replicas = List.of(ds1, ds2, ds3); + + DataSource first = selector.select("primary", replicas, + ReadWriteConfiguration.ReplicaSelectionStrategy.ROUND_ROBIN); + DataSource second = selector.select("primary", replicas, + ReadWriteConfiguration.ReplicaSelectionStrategy.ROUND_ROBIN); + DataSource third = selector.select("primary", replicas, + ReadWriteConfiguration.ReplicaSelectionStrategy.ROUND_ROBIN); + DataSource fourth = selector.select("primary", replicas, + ReadWriteConfiguration.ReplicaSelectionStrategy.ROUND_ROBIN); + + // Should cycle: ds1 → ds2 → ds3 → ds1 + assertEquals(ds1, first); + assertEquals(ds2, second); + assertEquals(ds3, third); + assertEquals(ds1, fourth); + } + + @Test + void shouldReturnReplicaForRandomStrategy() { + List replicas = List.of(ds1, ds2); + DataSource result = selector.select("primary", replicas, + ReadWriteConfiguration.ReplicaSelectionStrategy.RANDOM); + assertTrue(replicas.contains(result), "Random selection must return one of the registered replicas"); + } + + @Test + void shouldUseRoundRobinFallbackForLeastConnections() { + List replicas = List.of(ds1, ds2); + + DataSource first = selector.select("primary", replicas, + ReadWriteConfiguration.ReplicaSelectionStrategy.LEAST_CONNECTIONS); + DataSource second = selector.select("primary", replicas, + ReadWriteConfiguration.ReplicaSelectionStrategy.LEAST_CONNECTIONS); + + // LEAST_CONNECTIONS falls back to round-robin in Phase 2 + assertNotNull(first); + assertNotNull(second); + assertTrue(replicas.contains(first)); + assertTrue(replicas.contains(second)); + } + + @Test + void shouldMaintainSeparateCountersPerPrimary() { + List replicas = List.of(ds1, ds2); + + DataSource primary1First = selector.select("primary1", replicas, + ReadWriteConfiguration.ReplicaSelectionStrategy.ROUND_ROBIN); + DataSource primary2First = selector.select("primary2", replicas, + ReadWriteConfiguration.ReplicaSelectionStrategy.ROUND_ROBIN); + + // Each primary has its own counter starting at 0 → both pick ds1 + assertEquals(ds1, primary1First); + assertEquals(ds1, primary2First); + } +} diff --git a/ojp-server/src/test/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfigurationTest.java b/ojp-server/src/test/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfigurationTest.java index 0f2d697a1..4508d15c4 100644 --- a/ojp-server/src/test/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfigurationTest.java +++ b/ojp-server/src/test/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfigurationTest.java @@ -84,53 +84,35 @@ void testReplicaListImmutability() { @Test void testValidation_NullPrimaryName() { - assertThrows(NullPointerException.class, () -> { - new ReadWriteConfiguration.Builder() - .primaryName(null) - .build(); - }); + ReadWriteConfiguration.Builder builder = new ReadWriteConfiguration.Builder().primaryName(null); + assertThrows(NullPointerException.class, builder::build); } @Test void testValidation_EmptyPrimaryName() { - assertThrows(IllegalArgumentException.class, () -> { - new ReadWriteConfiguration.Builder() - .primaryName("") - .build(); - }); + ReadWriteConfiguration.Builder builder = new ReadWriteConfiguration.Builder().primaryName(""); + assertThrows(IllegalArgumentException.class, builder::build); } @Test void testValidation_EnabledWithoutReplicas() { - assertThrows(IllegalArgumentException.class, () -> { - new ReadWriteConfiguration.Builder() - .primaryName("primary") - .enabled(true) - .build(); - }); + ReadWriteConfiguration.Builder builder = new ReadWriteConfiguration.Builder() + .primaryName("primary").enabled(true); + assertThrows(IllegalArgumentException.class, builder::build); } @Test void testValidation_NegativeStickySessionSeconds() { - assertThrows(IllegalArgumentException.class, () -> { - new ReadWriteConfiguration.Builder() - .primaryName("primary") - .stickySessionSeconds(-1) - .addReplica("replica1") - .build(); - }); + ReadWriteConfiguration.Builder builder = new ReadWriteConfiguration.Builder() + .primaryName("primary").stickySessionSeconds(-1).addReplica("replica1"); + assertThrows(IllegalArgumentException.class, builder::build); } @Test void testValidation_DuplicateReplicas() { - assertThrows(IllegalArgumentException.class, () -> { - new ReadWriteConfiguration.Builder() - .primaryName("primary") - .enabled(true) - .addReplica("replica1") - .addReplica("replica1") - .build(); - }); + ReadWriteConfiguration.Builder builder = new ReadWriteConfiguration.Builder() + .primaryName("primary").enabled(true).addReplica("replica1").addReplica("replica1"); + assertThrows(IllegalArgumentException.class, builder::build); } @Test diff --git a/ojp-server/src/test/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceRegistryTest.java b/ojp-server/src/test/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceRegistryTest.java new file mode 100644 index 000000000..af0296b16 --- /dev/null +++ b/ojp-server/src/test/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceRegistryTest.java @@ -0,0 +1,108 @@ +package org.openjproxy.grpc.server.readwrite; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import javax.sql.DataSource; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +/** + * Tests for ReadWriteDataSourceRegistry — sticky session and strategy features (Phase 2). + */ +class ReadWriteDataSourceRegistryTest { + + private ReadWriteDataSourceRegistry registry; + + @BeforeEach + void setUp() { + registry = new ReadWriteDataSourceRegistry(); + } + + // ----------------------------------------------------------------------- + // Strategy + // ----------------------------------------------------------------------- + + @Test + void shouldDefaultToRoundRobinStrategyWhenNoneRegistered() { + assertEquals(ReadWriteConfiguration.ReplicaSelectionStrategy.ROUND_ROBIN, + registry.getStrategy("primary")); + } + + @Test + void shouldReturnRegisteredStrategy() { + registry.registerStrategy("primary", ReadWriteConfiguration.ReplicaSelectionStrategy.RANDOM); + assertEquals(ReadWriteConfiguration.ReplicaSelectionStrategy.RANDOM, registry.getStrategy("primary")); + } + + @Test + void shouldThrowWhenRegisteringNullStrategy() { + assertThrows(IllegalArgumentException.class, () -> registry.registerStrategy("primary", null)); + } + + @Test + void shouldThrowWhenRegisteringStrategyForNullPrimary() { + assertThrows(IllegalArgumentException.class, + () -> registry.registerStrategy(null, ReadWriteConfiguration.ReplicaSelectionStrategy.RANDOM)); + } + + // ----------------------------------------------------------------------- + // Sticky session — isStickyActive + // ----------------------------------------------------------------------- + + @Test + void shouldNotBeActiveWhenNoWriteHasOccurred() { + registry.registerStickyTimeout("primary", 5); + assertFalse(registry.isStickyActive("primary")); + } + + @Test + void shouldNotBeActiveWhenStickyTimeoutIsZero() { + registry.registerStickyTimeout("primary", 0); + registry.markWrite("primary"); + assertFalse(registry.isStickyActive("primary")); + } + + @Test + void shouldBeActiveImmediatelyAfterWrite() { + registry.registerStickyTimeout("primary", 5); + registry.markWrite("primary"); + assertTrue(registry.isStickyActive("primary")); + } + + @Test + void shouldNotBeActiveForNullPrimary() { + assertFalse(registry.isStickyActive(null)); + } + + @Test + void shouldExpireAfterTimeoutElapses() throws InterruptedException { + registry.registerStickyTimeout("primary", 0); // 0 = immediately inactive + registry.markWrite("primary"); + assertFalse(registry.isStickyActive("primary"), + "Sticky session with timeout=0 should not be active"); + } + + // ----------------------------------------------------------------------- + // clear() + // ----------------------------------------------------------------------- + + @Test + void shouldClearAllDataIncludingPhase2State() { + DataSource ds = mock(DataSource.class); + registry.registerPrimaryMapping("hash1", "primary"); + registry.registerReplica("primary", ds); + registry.registerStickyTimeout("primary", 10); + registry.registerStrategy("primary", ReadWriteConfiguration.ReplicaSelectionStrategy.RANDOM); + registry.markWrite("primary"); + + registry.clear(); + + assertNull(registry.getPrimaryName("hash1")); + assertTrue(registry.getReplicas("primary").isEmpty()); + assertFalse(registry.isStickyActive("primary")); + assertEquals(ReadWriteConfiguration.ReplicaSelectionStrategy.ROUND_ROBIN, + registry.getStrategy("primary")); + } +} diff --git a/ojp-server/src/test/java/org/openjproxy/grpc/server/readwrite/ReadWriteSqlClassifierTest.java b/ojp-server/src/test/java/org/openjproxy/grpc/server/readwrite/ReadWriteSqlClassifierTest.java new file mode 100644 index 000000000..0eb0a4df0 --- /dev/null +++ b/ojp-server/src/test/java/org/openjproxy/grpc/server/readwrite/ReadWriteSqlClassifierTest.java @@ -0,0 +1,107 @@ +package org.openjproxy.grpc.server.readwrite; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Tests for ReadWriteSqlClassifier + */ +class ReadWriteSqlClassifierTest { + + @Test + void shouldClassifySelectAsRead() { + assertEquals(ReadWriteSqlClassifier.QueryType.READ, + ReadWriteSqlClassifier.classify("SELECT * FROM users")); + } + + @Test + void shouldClassifySelectWithLeadingWhitespaceAsRead() { + assertEquals(ReadWriteSqlClassifier.QueryType.READ, + ReadWriteSqlClassifier.classify(" SELECT id FROM orders")); + } + + @Test + void shouldClassifyLowercaseSelectAsRead() { + assertEquals(ReadWriteSqlClassifier.QueryType.READ, + ReadWriteSqlClassifier.classify("select count(*) from items")); + } + + @Test + void shouldClassifyWithClauseAsRead() { + assertEquals(ReadWriteSqlClassifier.QueryType.READ, + ReadWriteSqlClassifier.classify("WITH cte AS (SELECT 1) SELECT * FROM cte")); + } + + @Test + void shouldClassifyExplainAsRead() { + assertEquals(ReadWriteSqlClassifier.QueryType.READ, + ReadWriteSqlClassifier.classify("EXPLAIN SELECT * FROM users")); + } + + @Test + void shouldClassifyShowAsRead() { + assertEquals(ReadWriteSqlClassifier.QueryType.READ, + ReadWriteSqlClassifier.classify("SHOW TABLES")); + } + + @Test + void shouldClassifyDescribeAsRead() { + assertEquals(ReadWriteSqlClassifier.QueryType.READ, + ReadWriteSqlClassifier.classify("DESCRIBE users")); + } + + @Test + void shouldClassifyDescAbbreviationAsRead() { + assertEquals(ReadWriteSqlClassifier.QueryType.READ, + ReadWriteSqlClassifier.classify("DESC users")); + } + + @Test + void shouldClassifyInsertAsWrite() { + assertEquals(ReadWriteSqlClassifier.QueryType.WRITE, + ReadWriteSqlClassifier.classify("INSERT INTO users VALUES (1, 'alice')")); + } + + @Test + void shouldClassifyUpdateAsWrite() { + assertEquals(ReadWriteSqlClassifier.QueryType.WRITE, + ReadWriteSqlClassifier.classify("UPDATE users SET name = 'bob' WHERE id = 1")); + } + + @Test + void shouldClassifyDeleteAsWrite() { + assertEquals(ReadWriteSqlClassifier.QueryType.WRITE, + ReadWriteSqlClassifier.classify("DELETE FROM users WHERE id = 1")); + } + + @Test + void shouldClassifyCreateTableAsWrite() { + assertEquals(ReadWriteSqlClassifier.QueryType.WRITE, + ReadWriteSqlClassifier.classify("CREATE TABLE users (id INT)")); + } + + @Test + void shouldClassifyDropTableAsWrite() { + assertEquals(ReadWriteSqlClassifier.QueryType.WRITE, + ReadWriteSqlClassifier.classify("DROP TABLE users")); + } + + @Test + void shouldClassifyNullAsWrite() { + assertEquals(ReadWriteSqlClassifier.QueryType.WRITE, + ReadWriteSqlClassifier.classify(null)); + } + + @Test + void shouldClassifyBlankStringAsWrite() { + assertEquals(ReadWriteSqlClassifier.QueryType.WRITE, + ReadWriteSqlClassifier.classify(" ")); + } + + @Test + void shouldClassifyMergeAsWrite() { + assertEquals(ReadWriteSqlClassifier.QueryType.WRITE, + ReadWriteSqlClassifier.classify("MERGE INTO users USING src ON (users.id = src.id)")); + } +} From 31c924ba4a5bd7b23326811ad25fe1a2b94586e4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 13:23:11 +0000 Subject: [PATCH 06/25] =?UTF-8?q?fix:=20address=20code=20review=20issues?= =?UTF-8?q?=20=E2=80=94=20word=20boundary=20for=20SQL=20keywords,=20intege?= =?UTF-8?q?r=20overflow=20in=20round-robin,=20remove=20FQ=20List=20type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/Open-J-Proxy/ojp/sessions/b0f48dbd-89f2-4f81-b0df-4caee408691b Co-authored-by: rrobetti <7221783+rrobetti@users.noreply.github.com> --- .../transaction/ExecuteQueryAction.java | 2 +- .../server/readwrite/ReadReplicaSelector.java | 3 +- .../readwrite/ReadWriteSqlClassifier.java | 29 +++++++++++++++---- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteQueryAction.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteQueryAction.java index faf7fb83f..05b37c540 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteQueryAction.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteQueryAction.java @@ -223,7 +223,7 @@ private DataSource resolveReadReplicaDataSource(ActionContext context, Statement return null; } - java.util.List replicas = registry.getReplicas(primaryName); + List replicas = registry.getReplicas(primaryName); if (replicas.isEmpty()) { return null; } diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadReplicaSelector.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadReplicaSelector.java index 4e55982d7..2cfcce35b 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadReplicaSelector.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadReplicaSelector.java @@ -55,7 +55,8 @@ public DataSource select(String primaryName, List replicas, private DataSource roundRobin(String primaryName, List replicas) { AtomicInteger counter = roundRobinCounters .computeIfAbsent(primaryName, k -> new AtomicInteger(0)); - int idx = Math.abs(counter.getAndIncrement() % replicas.size()); + // Use bitwise AND to avoid negative index when counter wraps past Integer.MAX_VALUE + int idx = (counter.getAndIncrement() & Integer.MAX_VALUE) % replicas.size(); return replicas.get(idx); } } diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteSqlClassifier.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteSqlClassifier.java index 5c77f4e50..fad0da8db 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteSqlClassifier.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteSqlClassifier.java @@ -35,14 +35,31 @@ public static QueryType classify(String sql) { return QueryType.WRITE; } String upper = sql.stripLeading().toUpperCase(); - if (upper.startsWith("SELECT") - || upper.startsWith("WITH") - || upper.startsWith("EXPLAIN") - || upper.startsWith("SHOW") - || upper.startsWith("DESCRIBE") - || upper.startsWith("DESC ")) { + if (isKeyword(upper, "SELECT") + || isKeyword(upper, "WITH") + || isKeyword(upper, "EXPLAIN") + || isKeyword(upper, "SHOW") + || isKeyword(upper, "DESCRIBE") + || isKeyword(upper, "DESC")) { return QueryType.READ; } return QueryType.WRITE; } + + /** + * Returns {@code true} when {@code upper} starts with {@code keyword} and the + * character immediately following the keyword (if any) is not an alphanumeric + * character. This ensures that e.g. {@code "DESCusers"} is not treated as a + * {@code DESC} statement while {@code "DESC users"}, {@code "DESC\nusers"}, and + * {@code "DESCRIBE users"} are all recognised correctly. + */ + private static boolean isKeyword(String upper, String keyword) { + if (!upper.startsWith(keyword)) { + return false; + } + if (upper.length() == keyword.length()) { + return true; + } + return !Character.isLetterOrDigit(upper.charAt(keyword.length())); + } } From af4719b7b51132542eac53fc4f654f38f950eb7b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 14:02:04 +0000 Subject: [PATCH 07/25] fix: default sticky-session to 0 and prevent session UUID leak in ExecuteQueryAction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ReadWriteConfigurationParser: change stickySessionSeconds default 5→0 so sticky sessions are opt-in (fixes reads going to primary silently when no stickySessionSeconds property is set) - ExecuteQueryAction: change startSessionIfNone=true→false so stateless (autoCommit) SELECTs do not create a server-side session; prevents the session UUID from leaking back to the driver and blocking replica routing after the sticky window expires - ReadWriteConfigurationParserTest: update two assertions that expected the old default of 5 to now expect 0 Agent-Logs-Url: https://github.com/Open-J-Proxy/ojp/sessions/ca6e8e71-b96d-4d3e-9dcc-e9ada1394480 Co-authored-by: rrobetti <7221783+rrobetti@users.noreply.github.com> --- .../server/action/transaction/ExecuteQueryAction.java | 8 +++++++- .../server/readwrite/ReadWriteConfigurationParser.java | 4 ++-- .../readwrite/ReadWriteConfigurationParserTest.java | 6 +++--- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteQueryAction.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteQueryAction.java index 05b37c540..2c4afc68e 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteQueryAction.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteQueryAction.java @@ -71,7 +71,13 @@ private void executeQueryInternal(ActionContext actionContext, StatementRequest // Read/write splitting: route reads to a replica when applicable DataSource replicaDs = resolveReadReplicaDataSource(actionContext, request); - ConnectionSessionDTO dto = sessionConnection(actionContext, request.getSession(), true, replicaDs); + // Do not create a new server-side session for stateless (autoCommit) SELECTs. + // Using startSessionIfNone=false prevents a session UUID from being returned to the + // driver, which would otherwise cause subsequent requests to be treated as in-session + // (blocking replica routing even after the sticky-session window expires). + // Existing-session (transactional) SELECTs already arrive with a non-blank UUID and + // follow the existing-session branch, so they are unaffected by this change. + ConnectionSessionDTO dto = sessionConnection(actionContext, request.getSession(), false, replicaDs); if (replicaDs != null) { log.debug("Read/write splitting: routed SELECT to replica for connHash={}", request.getSession().getConnHash()); diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfigurationParser.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfigurationParser.java index c0990a7e3..6c9b074f4 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfigurationParser.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfigurationParser.java @@ -113,8 +113,8 @@ public static ReadWriteConfiguration parseForPrimary(String primaryName, Propert strategy = ReadWriteConfiguration.ReplicaSelectionStrategy.ROUND_ROBIN; } - // Parse other settings - int stickySessionSeconds = getIntProperty(properties, prefix + STICKY_SESSION_SUFFIX, 5); + // Parse other settings — default is 0 (disabled); sticky sessions are opt-in + int stickySessionSeconds = getIntProperty(properties, prefix + STICKY_SESSION_SUFFIX, 0); boolean failoverToPrimary = getBooleanProperty(properties, prefix + FAILOVER_SUFFIX, true); // Find all replicas for this primary diff --git a/ojp-server/src/test/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfigurationParserTest.java b/ojp-server/src/test/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfigurationParserTest.java index ecea2f68d..b2df0bbb4 100644 --- a/ojp-server/src/test/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfigurationParserTest.java +++ b/ojp-server/src/test/java/org/openjproxy/grpc/server/readwrite/ReadWriteConfigurationParserTest.java @@ -120,7 +120,7 @@ void testParseDefaultValues() { assertFalse(config.isEnabled()); // Default: disabled assertEquals(ReadWriteConfiguration.ReplicaSelectionStrategy.ROUND_ROBIN, config.getStrategy()); - assertEquals(5, config.getStickySessionSeconds()); + assertEquals(0, config.getStickySessionSeconds()); assertTrue(config.isFailoverToPrimary()); } @@ -177,8 +177,8 @@ void testParseInvalidStickySessionSeconds() { ReadWriteConfiguration config = ReadWriteConfigurationParser.parseForPrimary("primary", props); - // Should use default - assertEquals(5, config.getStickySessionSeconds()); + // Should use default (0 — disabled) + assertEquals(0, config.getStickySessionSeconds()); } @Test From 8800af1bfa2632d04120a327bd6d0df7a7be5c1c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 14:04:34 +0000 Subject: [PATCH 08/25] refactor: condense ExecuteQueryAction startSessionIfNone comment Agent-Logs-Url: https://github.com/Open-J-Proxy/ojp/sessions/ca6e8e71-b96d-4d3e-9dcc-e9ada1394480 Co-authored-by: rrobetti <7221783+rrobetti@users.noreply.github.com> --- .../server/action/transaction/ExecuteQueryAction.java | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteQueryAction.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteQueryAction.java index 2c4afc68e..a8fa45941 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteQueryAction.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteQueryAction.java @@ -71,12 +71,9 @@ private void executeQueryInternal(ActionContext actionContext, StatementRequest // Read/write splitting: route reads to a replica when applicable DataSource replicaDs = resolveReadReplicaDataSource(actionContext, request); - // Do not create a new server-side session for stateless (autoCommit) SELECTs. - // Using startSessionIfNone=false prevents a session UUID from being returned to the - // driver, which would otherwise cause subsequent requests to be treated as in-session - // (blocking replica routing even after the sticky-session window expires). - // Existing-session (transactional) SELECTs already arrive with a non-blank UUID and - // follow the existing-session branch, so they are unaffected by this change. + // Use startSessionIfNone=false so stateless (autoCommit) SELECTs do not create a + // server-side session; avoids a session UUID leaking to the driver and blocking + // replica routing on subsequent requests (e.g. after sticky-session expiry). ConnectionSessionDTO dto = sessionConnection(actionContext, request.getSession(), false, replicaDs); if (replicaDs != null) { log.debug("Read/write splitting: routed SELECT to replica for connHash={}", From d40c55ef9f91f9b35effff4e87e730cc524276b1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 14:20:30 +0000 Subject: [PATCH 09/25] docs: document read/write splitting and stickySessionSeconds=0 default - ojp-jdbc-configuration.md: full Read/Write Splitting Configuration section covering primary and replica property references, sticky-session opt-in semantics, routing rules table, and end-to-end example - ojp-server-configuration.md: extended Client-Side Configuration section with read/write splitting overview; updated performance tips and related links - ebook/part2-chapter6-server-configuration.md: new section 6.8 covering enabling r/w splitting, sticky sessions (opt-in), routing rules table, replica pool config, and updated Summary - ebook/appendix-c-glossary.md: updated Session Affinity entry to clarify sticky sessions are opt-in with default 0 Agent-Logs-Url: https://github.com/Open-J-Proxy/ojp/sessions/a5280a2e-d375-4386-8e7e-732eb0b2d377 Co-authored-by: rrobetti <7221783+rrobetti@users.noreply.github.com> --- .../configuration/ojp-jdbc-configuration.md | 92 +++++++++++++++++++ .../configuration/ojp-server-configuration.md | 15 ++- documents/ebook/appendix-c-glossary.md | 2 +- .../part2-chapter6-server-configuration.md | 75 ++++++++++++++- 4 files changed, 180 insertions(+), 4 deletions(-) diff --git a/documents/configuration/ojp-jdbc-configuration.md b/documents/configuration/ojp-jdbc-configuration.md index df15af7de..a427f6be4 100644 --- a/documents/configuration/ojp-jdbc-configuration.md +++ b/documents/configuration/ojp-jdbc-configuration.md @@ -791,6 +791,98 @@ The multi-datasource feature is fully backward compatible: - Existing `ojp.properties` files without datasource prefixes continue to work as "default" datasource - No changes required for existing deployments unless you want to use multi-datasource features +## Read/Write Splitting Configuration + +OJP supports automatic read/write traffic splitting. Write operations (INSERT, UPDATE, DELETE, DDL) are always routed to the primary datasource. Stateless (auto-commit) read operations (SELECT, WITH, EXPLAIN, SHOW, DESCRIBE) are routed to a replica datasource when read/write splitting is enabled. All operations inside an explicit transaction are always routed to the primary. + +Read/write splitting is configured entirely through `ojp.properties`. No server-side configuration changes are needed. + +> **Note:** Sticky sessions are **opt-in** (default: `0` — disabled). Do not set a non-zero `stickySessionSeconds` unless your application requires read-your-writes guarantees after writes outside of a transaction. + +### Primary Datasource Properties + +Use the format `{primaryName}.ojp.readwrite.*` to configure the primary: + +| Property | Default | Description | +|---|---|---| +| `{primary}.ojp.readwrite.role` | — | Must be `primary` to enable read/write splitting for this datasource | +| `{primary}.ojp.readwrite.enabled` | `false` | Enable (`true`) or disable read/write splitting for this primary | +| `{primary}.ojp.readwrite.replicaSelectionStrategy` | `ROUND_ROBIN` | Replica selection strategy: `ROUND_ROBIN`, `RANDOM`, or `LEAST_CONNECTIONS` | +| `{primary}.ojp.readwrite.stickySessionSeconds` | `0` | Read-your-writes window in seconds. `0` = disabled (opt-in). After a write, reads continue going to the primary for this many seconds before reverting to replica routing | +| `{primary}.ojp.readwrite.replicaFailoverToPrimary` | `true` | Fall back to the primary when no healthy replica is available | + +### Replica Datasource Properties + +Use the format `{replicaName}.ojp.*` to configure each replica: + +| Property | Default | Description | +|---|---|---| +| `{replica}.ojp.readwrite.role` | — | Must be `replica` | +| `{replica}.ojp.readwrite.primary` | — | Name of the primary datasource this replica belongs to | +| `{replica}.ojp.connection.url` | — | **Required.** JDBC URL of the replica database | +| `{replica}.ojp.connection.user` | `""` | Replica database user | +| `{replica}.ojp.connection.password` | `""` | Replica database password | +| `{replica}.ojp.pool.maxPoolSize` | `10` | Maximum connections in the replica pool | +| `{replica}.ojp.pool.minIdle` | `2` | Minimum idle connections | +| `{replica}.ojp.pool.connectionTimeout` | `30000` | Connection acquire timeout (ms) | +| `{replica}.ojp.pool.idleTimeout` | `600000` | Idle connection timeout (ms) | +| `{replica}.ojp.pool.maxLifetime` | `1800000` | Maximum connection lifetime (ms) | + +### Example Configuration + +```properties +# Primary datasource (read/write splitting enabled, no sticky session) +mydb.ojp.readwrite.role=primary +mydb.ojp.readwrite.enabled=true +mydb.ojp.readwrite.replicaSelectionStrategy=ROUND_ROBIN +# stickySessionSeconds defaults to 0 (disabled) — opt-in only when needed + +# Replica 1 +replica1.ojp.readwrite.role=replica +replica1.ojp.readwrite.primary=mydb +replica1.ojp.connection.url=jdbc:postgresql://replica1-host:5432/mydb +replica1.ojp.connection.user=app_ro +replica1.ojp.connection.password=secret +replica1.ojp.pool.maxPoolSize=15 +replica1.ojp.pool.minIdle=3 + +# Replica 2 +replica2.ojp.readwrite.role=replica +replica2.ojp.readwrite.primary=mydb +replica2.ojp.connection.url=jdbc:postgresql://replica2-host:5432/mydb +replica2.ojp.connection.user=app_ro +replica2.ojp.connection.password=secret +replica2.ojp.pool.maxPoolSize=15 +replica2.ojp.pool.minIdle=3 +``` + +### Sticky Sessions (Read-Your-Writes) + +Sticky sessions guarantee that a client which just executed a write will continue reading from the primary for a configurable window, giving replicas time to catch up. This is an opt-in behaviour because it introduces latency on the read path and is only necessary when your application requires seeing its own writes immediately outside of a transaction. + +```properties +# Enable 3-second sticky window (reads stay on primary for 3 s after every write) +mydb.ojp.readwrite.stickySessionSeconds=3 +``` + +**When to use sticky sessions:** +- Application executes a write and then immediately queries without a transaction, and must see the write +- Replication lag is measurable and the application is not latency-sensitive + +**When to leave sticky sessions disabled (`0`, the default):** +- All writes and their subsequent reads are wrapped in the same transaction (the transaction itself guarantees read-your-writes via the primary) +- The application tolerates eventual consistency for reads outside transactions + +### Routing Rules Summary + +| Operation | Inside Transaction | Sticky Window Active | Routes To | +|---|---|---|---| +| SELECT / WITH / EXPLAIN / SHOW / DESCRIBE | — | — | Replica | +| SELECT / WITH / EXPLAIN / SHOW / DESCRIBE | ✓ | — | Primary | +| SELECT / WITH / EXPLAIN / SHOW / DESCRIBE | — | ✓ | Primary | +| INSERT / UPDATE / DELETE / DDL | — | — | Primary | +| INSERT / UPDATE / DELETE / DDL | ✓ | — | Primary | + ## Related Documentation - **[SSL/TLS Certificate Configuration Guide](ssl-tls-certificate-placeholders.md)** - Complete guide for configuring SSL/TLS certificates with property placeholders diff --git a/documents/configuration/ojp-server-configuration.md b/documents/configuration/ojp-server-configuration.md index 48cd1dd1d..87379da59 100644 --- a/documents/configuration/ojp-server-configuration.md +++ b/documents/configuration/ojp-server-configuration.md @@ -283,7 +283,18 @@ INFO SchemaCache - Schema cache updated with 42 tables For JDBC driver and client-side connection pool configuration, see: -- **[OJP JDBC Configuration](ojp-jdbc-configuration.md)** - JDBC driver setup and client connection pool settings +- **[OJP JDBC Configuration](ojp-jdbc-configuration.md)** — JDBC driver setup, client connection pool settings, and read/write splitting configuration + +### Read/Write Splitting + +OJP supports automatic read/write traffic splitting configured entirely through `ojp.properties` on the client side. No server-side settings are required. The server reads the `*.ojp.readwrite.*` properties forwarded by the driver and creates isolated replica connection pools automatically. + +Key points: +- Stateless auto-commit SELECTs are routed to a replica; all other operations go to the primary +- Operations inside an explicit transaction always use the primary +- **Sticky sessions are opt-in** — `stickySessionSeconds` defaults to `0` (disabled). Enable only when the application must read its own writes outside a transaction. A non-zero value keeps reads on the primary for that many seconds after every write. + +See **[OJP JDBC Configuration — Read/Write Splitting](ojp-jdbc-configuration.md#readwrite-splitting-configuration)** for the full property reference and examples. ## Configuration Methods @@ -557,8 +568,10 @@ INFO org.openjproxy.grpc.server.ServerConfiguration - Slow Query Slot Percenta - Increase timeouts in environments with occasional very slow queries 4. **Connection Pools**: Configure client-side pool sizes based on application requirements 5. **Request Size**: Increase for applications that handle large result sets +6. **Read/Write Splitting**: Size replica pools (`{replica}.ojp.pool.maxPoolSize`) to handle peak read traffic; leave `stickySessionSeconds` at `0` unless read-your-writes outside transactions is required ## Related Documentation - **[Slow Query Segregation Documentation](../designs/SLOW_QUERY_SEGREGATION.md)** - Detailed guide to the slow query segregation feature +- **[OJP JDBC Configuration](ojp-jdbc-configuration.md)** - Client-side pool settings and read/write splitting configuration - **[Example Configuration Properties](ojp-server-example.properties)** - Complete example configuration file with all settings \ No newline at end of file diff --git a/documents/ebook/appendix-c-glossary.md b/documents/ebook/appendix-c-glossary.md index 509ad58c6..653667dfb 100644 --- a/documents/ebook/appendix-c-glossary.md +++ b/documents/ebook/appendix-c-glossary.md @@ -232,7 +232,7 @@ The separation of slow queries into a dedicated connection pool to prevent them The mechanism by which clients find available service instances. In OJP multinode deployments, discovery is configuration-based (JDBC URL lists all servers). **Session Affinity** -Also called "sticky sessions," the routing of related requests to the same backend server. OJP implements session affinity for transactions and temporary tables. +Also called "sticky sessions," the routing of related requests to the same backend server. OJP implements session affinity for transactions and temporary tables. In the context of read/write splitting, sticky sessions ensure reads go to the primary for a configurable window after a write (see `stickySessionSeconds`). Sticky sessions are **opt-in** (default: disabled) — only enable when the application must see its own writes immediately outside of an explicit transaction. **Slow Query** A database query that takes significantly longer than average to execute. OJP automatically detects slow queries and can segregate them to prevent resource contention. diff --git a/documents/ebook/part2-chapter6-server-configuration.md b/documents/ebook/part2-chapter6-server-configuration.md index d4d8be48d..49f331117 100644 --- a/documents/ebook/part2-chapter6-server-configuration.md +++ b/documents/ebook/part2-chapter6-server-configuration.md @@ -430,12 +430,83 @@ export ojp.server.port=9059 The server logs its active configuration at INFO level during startup. Review this output to confirm your settings were applied correctly. If you see unexpected defaults, it means your configuration wasn't recognized—check for typos, case sensitivity, and format issues. +## 6.8 Read/Write Splitting + +OJP can automatically route read and write traffic to separate database instances. Write operations (INSERT, UPDATE, DELETE, DDL) always go to the primary. Stateless auto-commit reads (SELECT, WITH, EXPLAIN, SHOW, DESCRIBE) are routed to a replica chosen according to a configurable selection strategy. All operations inside an explicit transaction stay on the primary. + +Read/write splitting is configured entirely through the client's `ojp.properties` file — no server-side configuration changes are required. The OJP server reads the `*.ojp.readwrite.*` properties forwarded by the driver on first connection and creates isolated replica connection pools automatically. + +### 6.8.1 Enabling Read/Write Splitting + +Mark the primary datasource and each replica in `ojp.properties`: + +```properties +# Primary datasource +mydb.ojp.readwrite.role=primary +mydb.ojp.readwrite.enabled=true +mydb.ojp.readwrite.replicaSelectionStrategy=ROUND_ROBIN + +# Replica 1 +replica1.ojp.readwrite.role=replica +replica1.ojp.readwrite.primary=mydb +replica1.ojp.connection.url=jdbc:postgresql://replica1:5432/mydb +replica1.ojp.connection.user=app_ro +replica1.ojp.connection.password=secret + +# Replica 2 +replica2.ojp.readwrite.role=replica +replica2.ojp.readwrite.primary=mydb +replica2.ojp.connection.url=jdbc:postgresql://replica2:5432/mydb +replica2.ojp.connection.user=app_ro +replica2.ojp.connection.password=secret +``` + +Three replica selection strategies are available: + +- **`ROUND_ROBIN`** (default) — distributes reads evenly across all replicas in order +- **`RANDOM`** — picks a replica at random for each request +- **`LEAST_CONNECTIONS`** — selects the replica with fewest active connections (Phase 3) + +### 6.8.2 Sticky Sessions (Read-Your-Writes) + +Sticky sessions keep reads on the primary for a short window after every write, giving replicas time to catch up. This is **opt-in**: the default `stickySessionSeconds` is `0` (disabled). + +```properties +# Keep reads on primary for 3 seconds after every write +mydb.ojp.readwrite.stickySessionSeconds=3 +``` + +> **Important:** Only enable sticky sessions when the application must see its own writes immediately outside of a transaction. If all reads following a write are in the same transaction, the transaction itself already guarantees read-your-writes via the primary, and sticky sessions add unnecessary overhead. + +### 6.8.3 Routing Rules + +| Operation | Inside Transaction | Sticky Window Active | Routes To | +|---|---|---|---| +| SELECT / WITH / EXPLAIN / SHOW / DESCRIBE | — | — | Replica | +| SELECT / WITH / EXPLAIN / SHOW / DESCRIBE | ✓ | — | Primary | +| SELECT / WITH / EXPLAIN / SHOW / DESCRIBE | — | ✓ | Primary | +| INSERT / UPDATE / DELETE / DDL | any | any | Primary | + +### 6.8.4 Replica Pool Configuration + +Each replica has its own connection pool. Size it to handle peak read traffic independently of the primary pool. + +| Property | Default | Description | +|---|---|---| +| `{replica}.ojp.pool.maxPoolSize` | `10` | Maximum replica pool size | +| `{replica}.ojp.pool.minIdle` | `2` | Minimum idle connections | +| `{replica}.ojp.pool.connectionTimeout` | `30000` | Acquire timeout (ms) | +| `{replica}.ojp.pool.idleTimeout` | `600000` | Idle connection timeout (ms) | +| `{replica}.ojp.pool.maxLifetime` | `1800000` | Maximum connection lifetime (ms) | + +For the complete property reference see [OJP JDBC Configuration — Read/Write Splitting](../../documents/configuration/ojp-jdbc-configuration.md#readwrite-splitting-configuration). + ## Summary OJP server configuration gives you precise control over server behavior, security, performance, and observability. The hierarchical configuration system with JVM properties and environment variables provides flexibility for different deployment scenarios. Default settings work well for most use cases, but understanding the available options lets you optimize for your specific workload. -Key configuration areas include core server settings for network and threading, security controls through IP whitelisting, logging levels for operational visibility, OpenTelemetry integration for observability, circuit breakers for resilience, and slow query segregation for performance under mixed workloads. Each area offers sensible defaults that you can refine based on monitoring data. +Key configuration areas include core server settings for network and threading, security controls through IP whitelisting, logging levels for operational visibility, OpenTelemetry integration for observability, circuit breakers for resilience, slow query segregation for performance under mixed workloads, and read/write splitting for scaling read traffic across replicas. Each area offers sensible defaults that you can refine based on monitoring data. Start simple, monitor closely, and adjust based on observed behavior. Good configuration emerges from understanding your workload and using OJP's flexibility to match it, not from cargo-culting settings from other environments. -**[IMAGE PROMPT: Create a summary mind map with "OJP Server Configuration" at the center. Six main branches radiating outward: "Core Settings" (server icon), "Security" (lock icon), "Logging" (document icon), "Telemetry" (graph icon), "Circuit Breaker" (shield icon), and "Slow Query Segregation" (speedometer icon). Each branch has 2-3 sub-branches with key points. Use colors to group related concepts and make it visually hierarchical. Style: Modern mind map with icons and color coding.]** +**[IMAGE PROMPT: Create a summary mind map with "OJP Server Configuration" at the center. Seven main branches radiating outward: "Core Settings" (server icon), "Security" (lock icon), "Logging" (document icon), "Telemetry" (graph icon), "Circuit Breaker" (shield icon), "Slow Query Segregation" (speedometer icon), and "Read/Write Splitting" (fork/branch icon). Each branch has 2-3 sub-branches with key points. Use colors to group related concepts and make it visually hierarchical. Style: Modern mind map with icons and color coding.]** From 39eef447109fd46ffc51cdc3305d433cfbe918a4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 15:32:32 +0000 Subject: [PATCH 10/25] feat: implement dual-lazy connection design for read/write splitting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Session: add DataSource primaryDataSource + DataSource replicaDataSource constructor (lazy — no connections at construction time); keep old Session(Connection, ...) constructors for XA/legacy callers - Session.getConnection(): lazily acquires primary from primaryDataSource on first call; synchronized for thread-safety - Session.getOrCreateReplicaConnection(): lazily acquires replica; returns null when no replica is configured - Session.hasActiveTransaction(): checks autoCommit on the primary connection without triggering lazy acquisition (safe to call when only replica is open) - Session.terminate(): null-guarded close for both primaryConnection and replicaConnection - SessionManager / SessionManagerImpl: new createSession(clientUUID, primaryDs, replicaDs) overload - SessionConnectionHelper: new-session + replica path creates a lazy session (no eager primary acquisition); new-session + primary path unchanged (eager acquisition); existing-session + replica path skips primary lookup - ExecuteQueryAction: revert startSessionIfNone=false → true (fixes NPE in registerResultSet); resolve execConn from session.getOrCreateReplicaConnection() for replica path; build execDto with execConn for StatementFactory - ExecuteQueryAction.resolveReadReplicaDataSource: replace UUID-presence guard with session.hasActiveTransaction() — UUID alone does not mean a transaction is open (it may be from a previous autoCommit SELECT) Agent-Logs-Url: https://github.com/Open-J-Proxy/ojp/sessions/72e7efb0-168d-4d38-8a64-9c4db4b84eea Co-authored-by: rrobetti <7221783+rrobetti@users.noreply.github.com> --- .../org/openjproxy/grpc/server/Session.java | 154 ++++++++++++++++-- .../grpc/server/SessionManager.java | 13 ++ .../grpc/server/SessionManagerImpl.java | 12 ++ .../streaming/SessionConnectionHelper.java | 76 +++++---- .../transaction/ExecuteQueryAction.java | 53 ++++-- 5 files changed, 253 insertions(+), 55 deletions(-) diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/Session.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/Session.java index 13094dafa..a8b34e654 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/Session.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/Session.java @@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j; import org.openjproxy.grpc.server.cache.CacheConfiguration; +import javax.sql.DataSource; import javax.sql.XAConnection; import javax.transaction.xa.XAResource; import java.sql.CallableStatement; @@ -20,6 +21,17 @@ /** * Holds information about a session of a given client. + *

+ * Supports two construction modes: + *

    + *
  • Eager (XA / legacy): constructed with a pre-acquired {@code Connection}. + * {@link #getConnection()} returns that connection immediately.
  • + *
  • Lazy (dual-datasource): constructed with {@code DataSource} references. + * {@link #getConnection()} acquires from the primary datasource on first call; + * {@link #getOrCreateReplicaConnection()} acquires from the replica datasource on + * first call. This allows replica-only sessions to avoid allocating a primary + * connection entirely.
  • + *
*/ @Slf4j public class Session { @@ -29,7 +41,14 @@ public class Session { private final String connectionHash; @Getter private final String clientUUID; - private Connection connection; // Removed @Getter - custom getter below + /** Primary connection — may be null until lazily acquired from {@link #primaryDataSource}. */ + private volatile Connection primaryConnection; + /** Replica connection — null until lazily acquired via {@link #getOrCreateReplicaConnection()}. */ + private volatile Connection replicaConnection; + /** DataSource for lazy primary acquisition; null for eagerly-constructed (XA) sessions. */ + private final DataSource primaryDataSource; + /** DataSource for lazy replica acquisition; null when no replica is configured. */ + private final DataSource replicaDataSource; @Getter private final boolean isXA; @Getter @@ -53,6 +72,45 @@ public class Session { @Getter private final long creationTime; + /** + * Lazy dual-datasource constructor. No connections are acquired at + * construction time; they are obtained on demand when + * {@link #getConnection()} or {@link #getOrCreateReplicaConnection()} is + * first called. + * + * @param primaryDataSource datasource for the primary database (never null) + * @param replicaDataSource datasource for a read replica; {@code null} when + * no replica is configured + * @param connectionHash connection hash identifying this datasource pair + * @param clientUUID client identifier + * @param cacheConfiguration optional query-cache configuration (may be null) + */ + public Session(DataSource primaryDataSource, DataSource replicaDataSource, + String connectionHash, String clientUUID, + CacheConfiguration cacheConfiguration) { + this.primaryDataSource = primaryDataSource; + this.replicaDataSource = replicaDataSource; + this.primaryConnection = null; + this.replicaConnection = null; + this.connectionHash = connectionHash; + this.clientUUID = clientUUID; + this.isXA = false; + this.xaConnection = null; + this.cacheConfiguration = cacheConfiguration; + this.sessionUUID = UUID.randomUUID().toString(); + this.closed = false; + this.creationTime = System.nanoTime(); + this.lastActivityTime = this.creationTime; + this.resultSetMap = new ConcurrentHashMap<>(); + this.statementMap = new ConcurrentHashMap<>(); + this.preparedStatementMap = new ConcurrentHashMap<>(); + this.callableStatementMap = new ConcurrentHashMap<>(); + this.lobMap = new ConcurrentHashMap<>(); + this.attrMap = new ConcurrentHashMap<>(); + } + + // ---- Eager constructors (kept for XA and legacy non-XA callers) ---- + public Session(Connection connection, String connectionHash, String clientUUID) { this(connection, connectionHash, clientUUID, false, null, null); } @@ -62,7 +120,10 @@ public Session(Connection connection, String connectionHash, String clientUUID, } public Session(Connection connection, String connectionHash, String clientUUID, boolean isXA, XAConnection xaConnection, CacheConfiguration cacheConfiguration) { - this.connection = connection; + this.primaryConnection = connection; + this.replicaConnection = null; + this.primaryDataSource = null; + this.replicaDataSource = null; this.connectionHash = connectionHash; this.clientUUID = clientUUID; this.isXA = isXA; @@ -102,7 +163,7 @@ public synchronized void bindXAConnection(XAConnection xaConn, Object backendSes if (xaConn == null && backendSession == null) { this.xaConnection = null; this.backendSession = null; - this.connection = null; + this.primaryConnection = null; this.xaResource = null; log.debug("Unbound XAConnection from session {}", sessionUUID); return; @@ -118,7 +179,7 @@ public synchronized void bindXAConnection(XAConnection xaConn, Object backendSes try { this.xaConnection = xaConn; this.backendSession = backendSession; - this.connection = xaConn.getConnection(); + this.primaryConnection = xaConn.getConnection(); this.xaResource = xaConn.getXAResource(); log.debug("Bound XAConnection to session {}", sessionUUID); } catch (SQLException e) { @@ -147,19 +208,23 @@ public void refreshConnection() throws SQLException { if (backendSession != null && backendSession instanceof org.openjproxy.xa.pool.XABackendSession) { org.openjproxy.xa.pool.XABackendSession xaBackendSession = (org.openjproxy.xa.pool.XABackendSession) backendSession; - this.connection = xaBackendSession.getConnection(); + this.primaryConnection = xaBackendSession.getConnection(); log.debug("Refreshed connection reference in session {}", sessionUUID); } } /** - * Gets the JDBC connection for this session. - * For XA sessions with pooled backend sessions, this returns the current - * connection from the backend session (which may change after sanitization). + * Gets the primary JDBC connection for this session. + *

+ * For lazy sessions (created with {@link #Session(DataSource, DataSource, String, String, CacheConfiguration)}), + * this acquires a connection from the primary datasource on first call and caches it + * for subsequent calls. For XA sessions with a pooled backend, the fresh connection + * is returned from the backend session. * - * @return the JDBC connection + * @return the primary JDBC connection, or {@code null} if the session has no primary + * datasource and no eagerly-supplied connection */ - public Connection getConnection() { + public synchronized Connection getConnection() { // For XA sessions with backend session, always get fresh connection reference // This ensures we get the updated connection after sanitization if (isXA && backendSession != null && backendSession instanceof org.openjproxy.xa.pool.XABackendSession) { @@ -167,8 +232,55 @@ public Connection getConnection() { (org.openjproxy.xa.pool.XABackendSession) backendSession; return xaBackendSession.getConnection(); } - // For non-XA sessions or pass-through XA sessions, return stored connection - return this.connection; + // Lazy acquisition for dual-datasource sessions + if (primaryConnection == null && primaryDataSource != null) { + try { + primaryConnection = primaryDataSource.getConnection(); + log.debug("Lazily acquired primary connection for session {}", sessionUUID); + } catch (SQLException e) { + throw new RuntimeException("Failed to acquire primary connection for session " + sessionUUID, e); + } + } + return primaryConnection; + } + + /** + * Gets (or lazily creates) the replica JDBC connection for this session. + *

+ * The connection is acquired from the replica datasource supplied at construction + * time and cached for subsequent calls. Returns {@code null} when no replica + * datasource was configured. + * + * @return the replica JDBC connection, or {@code null} if no replica is configured + * @throws SQLException if acquiring the connection from the pool fails + */ + public synchronized Connection getOrCreateReplicaConnection() throws SQLException { + if (replicaConnection == null && replicaDataSource != null) { + replicaConnection = replicaDataSource.getConnection(); + log.debug("Lazily acquired replica connection for session {}", sessionUUID); + } + return replicaConnection; + } + + /** + * Returns {@code true} when the primary connection exists and has an open + * (non-autoCommit) transaction. Does not trigger lazy primary + * connection acquisition; returns {@code false} when no primary connection + * has been acquired yet (i.e. the session is replica-only so far). + * + * @return {@code true} if there is an active transaction on the primary connection + */ + public boolean hasActiveTransaction() { + if (primaryConnection == null) { + return false; + } + try { + return !primaryConnection.getAutoCommit(); + } catch (SQLException e) { + // Safety: assume transaction present if we cannot determine + log.warn("Could not determine autoCommit state for session {}; assuming active transaction", sessionUUID); + return true; + } } public SessionInfo getSessionInfo() { @@ -270,9 +382,18 @@ public void terminate() throws SQLException { } catch (SQLException e) { log.error("Error closing XA connection", e); } - } else if (connection != null) { - // For regular connections, close normally - this.connection.close(); + } else { + // Non-XA: close replica connection first (if acquired), then primary (if acquired) + if (replicaConnection != null) { + try { + replicaConnection.close(); + } catch (SQLException e) { + log.error("Error closing replica connection for session {}", sessionUUID, e); + } + } + if (primaryConnection != null) { + primaryConnection.close(); + } } //Clear session internal objects to free memory @@ -281,7 +402,8 @@ public void terminate() throws SQLException { this.resultSetMap = null; this.statementMap = null; this.preparedStatementMap = null; - this.connection = null; + this.primaryConnection = null; + this.replicaConnection = null; this.xaConnection = null; this.xaResource = null; this.backendSession = null; diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/SessionManager.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/SessionManager.java index 121c79c40..9c97c78c2 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/SessionManager.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/SessionManager.java @@ -2,6 +2,7 @@ import com.openjproxy.grpc.SessionInfo; +import javax.sql.DataSource; import javax.sql.XAConnection; import java.sql.CallableStatement; import java.sql.Connection; @@ -17,6 +18,18 @@ public interface SessionManager { void registerClientUUID(String connectionHash, String clientUUID); SessionInfo createSession(String clientUUID, Connection connection); + /** + * Creates a lazy dual-datasource session. No JDBC connections are acquired + * at creation time; they are obtained on demand when + * {@link Session#getConnection()} or {@link Session#getOrCreateReplicaConnection()} + * is first called. + * + * @param clientUUID the client identifier + * @param primaryDataSource datasource for the primary database + * @param replicaDataSource datasource for the read replica; {@code null} when no replica is configured + * @return the new session info + */ + SessionInfo createSession(String clientUUID, DataSource primaryDataSource, DataSource replicaDataSource); SessionInfo createXASession(String clientUUID, Connection connection, XAConnection xaConnection); SessionInfo createDeferredXASession(String clientUUID, String connectionHash); Session getSession(SessionInfo sessionInfo); diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/SessionManagerImpl.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/SessionManagerImpl.java index eac2b9ed4..2777f025e 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/SessionManagerImpl.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/SessionManagerImpl.java @@ -6,6 +6,7 @@ import lombok.extern.slf4j.Slf4j; import org.openjproxy.grpc.server.cache.CacheConfiguration; +import javax.sql.DataSource; import javax.sql.XAConnection; import java.sql.CallableStatement; import java.sql.Connection; @@ -60,6 +61,17 @@ public SessionInfo createSession(String clientUUID, Connection connection) { return session.getSessionInfo(); } + @Override + public SessionInfo createSession(String clientUUID, DataSource primaryDataSource, DataSource replicaDataSource) { + log.info("Create lazy dual-datasource session for client uuid {}", clientUUID); + String connectionHash = connectionHashMap.get(clientUUID); + CacheConfiguration cacheConfig = getCacheConfiguration(connectionHash); + Session session = new Session(primaryDataSource, replicaDataSource, connectionHash, clientUUID, cacheConfig); + log.info("Lazy session {} created for client uuid {}", session.getSessionUUID(), clientUUID); + this.sessionMap.put(session.getSessionUUID(), session); + return session.getSessionInfo(); + } + @Override public SessionInfo createXASession(String clientUUID, Connection connection, XAConnection xaConnection) { log.info("Create XA session for client uuid " + clientUUID); diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/action/streaming/SessionConnectionHelper.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/action/streaming/SessionConnectionHelper.java index aab6b6ae5..b56474a86 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/action/streaming/SessionConnectionHelper.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/action/streaming/SessionConnectionHelper.java @@ -116,18 +116,25 @@ public static ConnectionSessionDTO sessionConnection(ActionContext context, Sess throws SQLException { ConnectionSessionDTO.ConnectionSessionDTOBuilder dtoBuilder = ConnectionSessionDTO.builder(); dtoBuilder.session(sessionInfo); - Connection conn; + Connection conn = null; var sessionManager = context.getSessionManager(); if (StringUtils.isNotEmpty(sessionInfo.getSessionUUID())) { - // Session already exists, reuse its connection - conn = sessionManager.getConnection(sessionInfo); - if (conn == null) { - throw new SQLException("Connection not found for this sessionInfo"); - } - dtoBuilder.dbName(DatabaseUtils.resolveDbName(conn.getMetaData().getURL())); - if (conn.isClosed()) { - throw new SQLException("Connection is closed"); + if (replicaDataSource != null) { + // Replica-routed request on an existing session: do not trigger lazy primary + // acquisition here. ExecuteQueryAction will call + // session.getOrCreateReplicaConnection() to get the replica connection. + // conn remains null — caller is responsible for obtaining the right connection. + } else { + // Primary path: get the connection from the existing session (may lazily acquire) + conn = sessionManager.getConnection(sessionInfo); + if (conn == null) { + throw new SQLException("Connection not found for this sessionInfo"); + } + dtoBuilder.dbName(DatabaseUtils.resolveDbName(conn.getMetaData().getURL())); + if (conn.isClosed()) { + throw new SQLException("Connection is closed"); + } } } else { // Lazy allocation: check if this is an XA or regular connection @@ -183,35 +190,48 @@ public static ConnectionSessionDTO sessionConnection(ActionContext context, Sess throw e; } } else { - // Pooled mode: use replica override when provided, otherwise use the primary pool - DataSource dataSource = (replicaDataSource != null) - ? replicaDataSource - : context.getDatasourceMap().get(connHash); + // Pooled mode: create a lazy dual-datasource session when a replica is + // provided so that the primary connection is only acquired if actually + // needed (e.g. for a subsequent write on the same session). + DataSource primaryDataSource = context.getDatasourceMap().get(connHash); - if (dataSource == null) { + if (primaryDataSource == null) { // Signal the client to reconnect. NOT_FOUND is caught by // CommandExecutionHelper and translated to Status.NOT_FOUND so that the // driver can transparently reconnect and retry the SQL call. throw new PoolNotFoundException(connHash); } - try { - // Use enhanced connection acquisition with timeout protection - conn = ConnectionAcquisitionManager.acquireConnection(dataSource, connHash); - log.debug("Successfully acquired connection from pool for hash: {}", connHash); - } catch (SQLException e) { - log.error("Failed to acquire connection from pool for hash: {}. Error: {}", - connHash, e.getMessage()); + if (replicaDataSource != null) { + // Replica path: create a lazy session with both datasources. + // No connection is acquired here; the caller (ExecuteQueryAction) will + // obtain the replica connection via session.getOrCreateReplicaConnection(). + if (startSessionIfNone) { + SessionInfo updatedSession = sessionManager.createSession( + sessionInfo.getClientUUID(), primaryDataSource, replicaDataSource); + dtoBuilder.session(updatedSession); + } + // conn remains null — caller must resolve the connection from the session + } else { + // Primary path: eager acquisition as before + try { + // Use enhanced connection acquisition with timeout protection + conn = ConnectionAcquisitionManager.acquireConnection(primaryDataSource, connHash); + log.debug("Successfully acquired connection from pool for hash: {}", connHash); + } catch (SQLException e) { + log.error("Failed to acquire connection from pool for hash: {}. Error: {}", + connHash, e.getMessage()); + + // Re-throw the enhanced exception from ConnectionAcquisitionManager + throw e; + } - // Re-throw the enhanced exception from ConnectionAcquisitionManager - throw e; + if (startSessionIfNone) { + SessionInfo updatedSession = sessionManager.createSession(sessionInfo.getClientUUID(), conn); + dtoBuilder.session(updatedSession); + } } } - - if (startSessionIfNone) { - SessionInfo updatedSession = sessionManager.createSession(sessionInfo.getClientUUID(), conn); - dtoBuilder.session(updatedSession); - } } } dtoBuilder.connection(conn); diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteQueryAction.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteQueryAction.java index 077cb9f13..11eac3623 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteQueryAction.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteQueryAction.java @@ -9,6 +9,7 @@ import org.openjproxy.grpc.ProtoConverter; import org.openjproxy.grpc.dto.Parameter; import org.openjproxy.grpc.server.ConnectionSessionDTO; +import org.openjproxy.grpc.server.Session; import org.openjproxy.grpc.server.action.Action; import org.openjproxy.grpc.server.action.ActionContext; import org.openjproxy.grpc.server.cache.CacheConfiguration; @@ -68,15 +69,34 @@ private void executeQueryInternal(ActionContext actionContext, StatementRequest // Read/write splitting: route reads to a replica when applicable DataSource replicaDs = resolveReadReplicaDataSource(actionContext, request); - // Use startSessionIfNone=false so stateless (autoCommit) SELECTs do not create a - // server-side session; avoids a session UUID leaking to the driver and blocking - // replica routing on subsequent requests (e.g. after sticky-session expiry). - ConnectionSessionDTO dto = sessionConnection(actionContext, request.getSession(), false, replicaDs); + + // Always start a session (startSessionIfNone=true) so the session UUID is returned + // to the driver — the client may need it for metadata access on the result set, + // statement, or connection. For the replica path, a lazy dual-datasource session is + // created; the actual replica connection is resolved below via the Session object. + ConnectionSessionDTO dto = sessionConnection(actionContext, request.getSession(), true, replicaDs); + + // Resolve the JDBC connection to use for execution: + // - replica path → session.getOrCreateReplicaConnection() (lazy; never allocates primary) + // - primary path → dto.getConnection() (already acquired by sessionConnection) + Connection execConn; if (replicaDs != null) { + Session activeSession = actionContext.getSessionManager().getSession(dto.getSession()); + execConn = activeSession.getOrCreateReplicaConnection(); log.debug("Read/write splitting: routed SELECT to replica for connHash={}", request.getSession().getConnHash()); + } else { + execConn = dto.getConnection(); } + // Build an execution DTO with the resolved connection so StatementFactory and + // downstream helpers receive the correct connection regardless of routing. + ConnectionSessionDTO execDto = ConnectionSessionDTO.builder() + .session(dto.getSession()) + .connection(execConn) + .dbName(dto.getDbName()) + .build(); + // Phase 6: Cache Lookup (before query execution) - with graceful degradation String sql = request.getSql(); CacheConfiguration cacheConfig = QueryCacheHelper.getCacheConfiguration(actionContext, dto.getSession()); @@ -123,8 +143,8 @@ private void executeQueryInternal(ActionContext actionContext, StatementRequest DataSource dataSource = datasourceMap.get(dsKey); if (dataSource != null) { - // Get catalog and schema from the connection - Connection connection = dto.getConnection(); + // Get catalog and schema from the execution connection + Connection connection = execConn; String catalogName = connection.getCatalog(); String schemaName = connection.getSchema(); @@ -167,11 +187,11 @@ private void executeQueryInternal(ActionContext actionContext, StatementRequest responseObserver, cacheConfig, sql, params, dto.getSession().getConnHash()); if (CollectionUtils.isNotEmpty(params)) { - PreparedStatement ps = StatementFactory.createPreparedStatement(sessionManager, dto, sql, params, request); + PreparedStatement ps = StatementFactory.createPreparedStatement(sessionManager, execDto, sql, params, request); String resultSetUUID = sessionManager.registerResultSet(dto.getSession(), ps.executeQuery()); handleResultSet(actionContext, dto.getSession(), resultSetUUID, finalObserver); } else { - Statement stmt = StatementFactory.createStatement(sessionManager, dto.getConnection(), request); + Statement stmt = StatementFactory.createStatement(sessionManager, execDto.getConnection(), request); String resultSetUUID = sessionManager.registerResultSet(dto.getSession(), stmt.executeQuery(sql)); handleResultSet(actionContext, dto.getSession(), resultSetUUID, finalObserver); @@ -185,12 +205,17 @@ private void executeQueryInternal(ActionContext actionContext, StatementRequest *

Routing to a replica is skipped when any of the following is true: *

    *
  • No {@link ReadWriteDataSourceRegistry} is available in the context.
  • - *
  • The request is inside an explicit transaction (non-empty {@code sessionUUID}).
  • + *
  • The request is inside an explicit transaction (primary connection exists + * and has autoCommit=false).
  • *
  • The SQL is not a read-only statement.
  • *
  • The primary has no replicas registered.
  • *
  • A sticky-session window is currently active for the primary.
  • *
* + *

Note: the presence of a {@code sessionUUID} alone does not block + * replica routing. The session UUID may exist from a previous autoCommit SELECT + * on the same connection, and in that case routing to the replica is still safe. + * * @param context the action context * @param request the statement request * @return a replica {@link DataSource}, or {@code null} when the primary should be used @@ -201,9 +226,15 @@ private DataSource resolveReadReplicaDataSource(ActionContext context, Statement return null; } - // Do not route to replica when inside a transaction (session UUID is set) + // Block replica routing only when there is an active transaction on the primary. + // A session UUID being present is not sufficient — it may come from a previous + // autoCommit SELECT. We check the actual transaction state via hasActiveTransaction() + // which does NOT trigger lazy primary connection acquisition. if (StringUtils.isNotBlank(request.getSession().getSessionUUID())) { - return null; + Session existingSession = context.getSessionManager().getSession(request.getSession()); + if (existingSession != null && existingSession.hasActiveTransaction()) { + return null; // active transaction → must stay on primary + } } // Only route read-only SQL to replicas From cb4cdbc7296e7209dfebc1063dbe802446a0e3ab Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 15:37:10 +0000 Subject: [PATCH 11/25] fix: address code review issues in dual-lazy connection design - Session.hasActiveTransaction(): add synchronized to prevent data races on primaryConnection reads (volatile alone insufficient without sync) - Session.terminate(): wrap primaryConnection.close() in try-catch + log consistently with the replica connection close error handling - ExecuteQueryAction: add null-check for activeSession before calling getOrCreateReplicaConnection(); throw descriptive SQLException on null - ExecuteQueryAction.resolveReadReplicaDataSource: when session UUID is present but session is not found (expired/invalidated), fall back to primary rather than silently routing to replica Agent-Logs-Url: https://github.com/Open-J-Proxy/ojp/sessions/72e7efb0-168d-4d38-8a64-9c4db4b84eea Co-authored-by: rrobetti <7221783+rrobetti@users.noreply.github.com> --- .../java/org/openjproxy/grpc/server/Session.java | 12 ++++++++++-- .../action/transaction/ExecuteQueryAction.java | 11 ++++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/Session.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/Session.java index a8b34e654..ef7bc895c 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/Session.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/Session.java @@ -268,9 +268,13 @@ public synchronized Connection getOrCreateReplicaConnection() throws SQLExceptio * connection acquisition; returns {@code false} when no primary connection * has been acquired yet (i.e. the session is replica-only so far). * + *

This method is {@code synchronized} to ensure it sees the latest value + * of {@code primaryConnection} (e.g. after a concurrent {@link #getConnection()} + * call). + * * @return {@code true} if there is an active transaction on the primary connection */ - public boolean hasActiveTransaction() { + public synchronized boolean hasActiveTransaction() { if (primaryConnection == null) { return false; } @@ -392,7 +396,11 @@ public void terminate() throws SQLException { } } if (primaryConnection != null) { - primaryConnection.close(); + try { + primaryConnection.close(); + } catch (SQLException e) { + log.error("Error closing primary connection for session {}", sessionUUID, e); + } } } diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteQueryAction.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteQueryAction.java index 11eac3623..ae6a739d9 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteQueryAction.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteQueryAction.java @@ -82,6 +82,10 @@ private void executeQueryInternal(ActionContext actionContext, StatementRequest Connection execConn; if (replicaDs != null) { Session activeSession = actionContext.getSessionManager().getSession(dto.getSession()); + if (activeSession == null) { + throw new SQLException("Session not found for UUID: " + dto.getSession().getSessionUUID() + + ". Cannot obtain replica connection."); + } execConn = activeSession.getOrCreateReplicaConnection(); log.debug("Read/write splitting: routed SELECT to replica for connHash={}", request.getSession().getConnHash()); @@ -232,7 +236,12 @@ private DataSource resolveReadReplicaDataSource(ActionContext context, Statement // which does NOT trigger lazy primary connection acquisition. if (StringUtils.isNotBlank(request.getSession().getSessionUUID())) { Session existingSession = context.getSessionManager().getSession(request.getSession()); - if (existingSession != null && existingSession.hasActiveTransaction()) { + if (existingSession == null) { + // Session has expired or been invalidated; fall back to primary to avoid + // routing to replica with unknown session state. + return null; + } + if (existingSession.hasActiveTransaction()) { return null; // active transaction → must stay on primary } } From e4ff63157ae0669f2f1017cdb7f014954126a3fa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 17:13:38 +0000 Subject: [PATCH 12/25] fix: forward ojp.* info properties to server; add replica fallback for primary-only sessions; fix tests for correct routing expectations Agent-Logs-Url: https://github.com/Open-J-Proxy/ojp/sessions/e06e1b61-3208-4094-8ba0-14344da89b30 Co-authored-by: rrobetti <7221783+rrobetti@users.noreply.github.com> --- .../main/java/org/openjproxy/jdbc/Driver.java | 21 ++++++++++++--- .../H2ReadWriteSplittingEndToEndTest.java | 27 +++++++++++-------- .../org/openjproxy/grpc/server/Session.java | 27 ++++++++++++++++--- .../transaction/ExecuteQueryAction.java | 2 +- 4 files changed, 58 insertions(+), 19 deletions(-) diff --git a/ojp-jdbc-driver/src/main/java/org/openjproxy/jdbc/Driver.java b/ojp-jdbc-driver/src/main/java/org/openjproxy/jdbc/Driver.java index b282aeadf..f3fc54702 100644 --- a/ojp-jdbc-driver/src/main/java/org/openjproxy/jdbc/Driver.java +++ b/ojp-jdbc-driver/src/main/java/org/openjproxy/jdbc/Driver.java @@ -87,13 +87,26 @@ public java.sql.Connection connect(String url, Properties info) throws SQLExcept connBuilder.addAllServerEndpoints(serverEndpoints); log.info("Adding {} server endpoint(s) to ConnectionDetails", serverEndpoints.size()); + // Build combined properties map: file (ojp.properties) takes lowest priority; + // inline ojp.* properties from info override the file so callers can supply + // read/write splitting and other configuration directly via + // DriverManager.getConnection(url, info) without a server-side properties file. + Map propertiesMap = new HashMap<>(); if (ojpProperties != null && !ojpProperties.isEmpty()) { - // Convert Properties to Map - Map propertiesMap = new HashMap<>(); for (String key : ojpProperties.stringPropertyNames()) { propertiesMap.put(key, ojpProperties.getProperty(key)); } - + } + if (info != null) { + for (String key : info.stringPropertyNames()) { + // Forward any *.ojp.* properties (e.g. read/write splitting, replica config) + // and the top-level ojp.* properties (e.g. ojp.datasource.name). + if (key.contains(".ojp.") || key.startsWith("ojp.")) { + propertiesMap.put(key, info.getProperty(key)); + } + } + } + if (!propertiesMap.isEmpty()) { // Add cache configuration properties to the map try { CacheConfigurationBuilder.addCachePropertiesToMap(propertiesMap, dataSourceName); @@ -103,7 +116,7 @@ public java.sql.Connection connect(String url, Properties info) throws SQLExcept } connBuilder.addAllProperties(ProtoConverter.propertiesToProto(propertiesMap)); - log.debug("Loaded ojp.properties with {} properties for dataSource: {}", propertiesMap.size(), dataSourceName); + log.debug("Loaded {} properties for dataSource: {}", propertiesMap.size(), dataSourceName); } log.info("Calling connect() on statement service with URL: {}", connectionUrl); diff --git a/ojp-jdbc-driver/src/test/java/openjproxy/jdbc/H2ReadWriteSplittingEndToEndTest.java b/ojp-jdbc-driver/src/test/java/openjproxy/jdbc/H2ReadWriteSplittingEndToEndTest.java index 75a2fc5ba..c3bf3c396 100644 --- a/ojp-jdbc-driver/src/test/java/openjproxy/jdbc/H2ReadWriteSplittingEndToEndTest.java +++ b/ojp-jdbc-driver/src/test/java/openjproxy/jdbc/H2ReadWriteSplittingEndToEndTest.java @@ -297,13 +297,17 @@ void testDeleteGoesToPrimary() throws SQLException { assertEquals(1, affected, "DELETE should affect 1 row"); } - try (Connection verify = DriverManager.getConnection(primaryUrl(), primaryProps()); - Statement s = verify.createStatement(); - ResultSet rs = s.executeQuery( - "SELECT COUNT(*) FROM test_data WHERE id = 1")) { - assertTrue(rs.next()); - assertEquals(0, rs.getInt(1), - "Primary database should not contain the deleted row"); + try (Connection verify = DriverManager.getConnection(primaryUrl(), primaryProps())) { + // Force routing to primary so we verify the primary was actually modified. + verify.setAutoCommit(false); + try (Statement s = verify.createStatement(); + ResultSet rs = s.executeQuery( + "SELECT COUNT(*) FROM test_data WHERE id = 1")) { + assertTrue(rs.next()); + assertEquals(0, rs.getInt(1), + "Primary database should not contain the deleted row"); + } + verify.rollback(); } } @@ -448,11 +452,12 @@ void testTransaction_AllOperationsGoToPrimary() throws SQLException { } /** - * After a transaction commits, reads continue going to primary (sticky session). + * Without a sticky session, a read immediately after a committed transaction goes to the + * replica (eventual consistency). The replica does not have the just-committed row. */ @SneakyThrows @Test - void testAfterTransactionCommit_ReadsGoToPrimary() throws SQLException { + void testAfterTransactionCommit_ReadsGoToReplica_WithNoStickySession() throws SQLException { Assumptions.assumeTrue(isH2TestEnabled, "Skipping H2 tests - not enabled"); setupDatabases(); @@ -471,8 +476,8 @@ void testAfterTransactionCommit_ReadsGoToPrimary() throws SQLException { ResultSet rs = stmt.executeQuery( "SELECT COUNT(*) FROM test_data WHERE id = 250")) { assertTrue(rs.next()); - assertEquals(1, rs.getInt(1), - "After commit, SELECT routes to primary which should have the committed row"); + assertEquals(0, rs.getInt(1), + "Without sticky session, SELECT after commit routes to replica which does not have the row"); } } } diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/Session.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/Session.java index ef7bc895c..8300e4bf6 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/Session.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/Session.java @@ -255,9 +255,30 @@ public synchronized Connection getConnection() { * @throws SQLException if acquiring the connection from the pool fails */ public synchronized Connection getOrCreateReplicaConnection() throws SQLException { - if (replicaConnection == null && replicaDataSource != null) { - replicaConnection = replicaDataSource.getConnection(); - log.debug("Lazily acquired replica connection for session {}", sessionUUID); + return getOrCreateReplicaConnection(null); + } + + /** + * Gets (or lazily creates) the replica JDBC connection for this session. + *

+ * Uses the replica datasource supplied at construction time when available; + * falls back to {@code fallbackReplicaDs} when the session was created without + * a replica datasource (e.g. originally created for a write / INSERT). The + * acquired connection is cached and reused on subsequent calls. + * + * @param fallbackReplicaDs datasource to use when no replica datasource was set + * at construction time; may be {@code null} + * @return the replica JDBC connection, or {@code null} if no replica datasource + * is available + * @throws SQLException if acquiring the connection from the pool fails + */ + public synchronized Connection getOrCreateReplicaConnection(DataSource fallbackReplicaDs) throws SQLException { + if (replicaConnection == null) { + DataSource ds = (replicaDataSource != null) ? replicaDataSource : fallbackReplicaDs; + if (ds != null) { + replicaConnection = ds.getConnection(); + log.debug("Lazily acquired replica connection for session {}", sessionUUID); + } } return replicaConnection; } diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteQueryAction.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteQueryAction.java index ae6a739d9..b987cbeef 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteQueryAction.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteQueryAction.java @@ -86,7 +86,7 @@ private void executeQueryInternal(ActionContext actionContext, StatementRequest throw new SQLException("Session not found for UUID: " + dto.getSession().getSessionUUID() + ". Cannot obtain replica connection."); } - execConn = activeSession.getOrCreateReplicaConnection(); + execConn = activeSession.getOrCreateReplicaConnection(replicaDs); log.debug("Read/write splitting: routed SELECT to replica for connHash={}", request.getSession().getConnHash()); } else { From 4430b814ab3eee11278dcd62de52d24fbadcd8de Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 17:17:01 +0000 Subject: [PATCH 13/25] fix: improve logging for replica datasource source; clarify test comment for primary routing Agent-Logs-Url: https://github.com/Open-J-Proxy/ojp/sessions/e06e1b61-3208-4094-8ba0-14344da89b30 Co-authored-by: rrobetti <7221783+rrobetti@users.noreply.github.com> --- .../java/openjproxy/jdbc/H2ReadWriteSplittingEndToEndTest.java | 3 ++- .../src/main/java/org/openjproxy/grpc/server/Session.java | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/ojp-jdbc-driver/src/test/java/openjproxy/jdbc/H2ReadWriteSplittingEndToEndTest.java b/ojp-jdbc-driver/src/test/java/openjproxy/jdbc/H2ReadWriteSplittingEndToEndTest.java index c3bf3c396..536d8f6fc 100644 --- a/ojp-jdbc-driver/src/test/java/openjproxy/jdbc/H2ReadWriteSplittingEndToEndTest.java +++ b/ojp-jdbc-driver/src/test/java/openjproxy/jdbc/H2ReadWriteSplittingEndToEndTest.java @@ -298,7 +298,8 @@ void testDeleteGoesToPrimary() throws SQLException { } try (Connection verify = DriverManager.getConnection(primaryUrl(), primaryProps())) { - // Force routing to primary so we verify the primary was actually modified. + // Force routing to primary (transactions always route to primary) so we verify + // the primary was actually modified and not the replica. verify.setAutoCommit(false); try (Statement s = verify.createStatement(); ResultSet rs = s.executeQuery( diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/Session.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/Session.java index 8300e4bf6..6c4611650 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/Session.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/Session.java @@ -277,7 +277,8 @@ public synchronized Connection getOrCreateReplicaConnection(DataSource fallbackR DataSource ds = (replicaDataSource != null) ? replicaDataSource : fallbackReplicaDs; if (ds != null) { replicaConnection = ds.getConnection(); - log.debug("Lazily acquired replica connection for session {}", sessionUUID); + log.debug("Lazily acquired replica connection from {} datasource for session {}", + (replicaDataSource != null) ? "instance" : "fallback", sessionUUID); } } return replicaConnection; From 31fb0149d6985904b80d51bb8a7fb4ebaa43f359 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 20:29:43 +0000 Subject: [PATCH 14/25] feat: forward setAutoCommit to server; restore testAfterTransactionCommit_ReadsGoToPrimary with sticky session Agent-Logs-Url: https://github.com/Open-J-Proxy/ojp/sessions/c299d9b5-bb86-41ea-98c5-c60327de4898 Co-authored-by: rrobetti <7221783+rrobetti@users.noreply.github.com> --- .../src/main/proto/StatementService.proto | 6 ++ .../client/MultinodeStatementService.java | 8 ++ .../grpc/client/StatementService.java | 17 ++++ .../client/StatementServiceGrpcClient.java | 16 ++++ .../java/org/openjproxy/jdbc/Connection.java | 15 +++- .../H2ReadWriteSplittingEndToEndTest.java | 81 +++++++++++++++++-- .../grpc/server/StatementServiceImpl.java | 7 ++ .../transaction/SetAutoCommitAction.java | 80 ++++++++++++++++++ 8 files changed, 223 insertions(+), 7 deletions(-) create mode 100644 ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/SetAutoCommitAction.java diff --git a/ojp-grpc-commons/src/main/proto/StatementService.proto b/ojp-grpc-commons/src/main/proto/StatementService.proto index 7310588de..9425cf253 100644 --- a/ojp-grpc-commons/src/main/proto/StatementService.proto +++ b/ojp-grpc-commons/src/main/proto/StatementService.proto @@ -465,6 +465,11 @@ message XaResponse { string message = 3; } +// Request to set the autoCommit mode on the server-side physical connection. +message SetAutoCommitRequest { + SessionInfo session = 1; + bool autoCommit = 2; +} service StatementService { rpc connect(ConnectionDetails) returns (SessionInfo); @@ -477,6 +482,7 @@ service StatementService { rpc startTransaction(SessionInfo) returns (SessionInfo); rpc commitTransaction(SessionInfo) returns (SessionInfo); rpc rollbackTransaction(SessionInfo) returns (SessionInfo); + rpc setAutoCommit(SetAutoCommitRequest) returns (SessionInfo); rpc callResource(CallResourceRequest) returns (CallResourceResponse); // XA Transaction Operations diff --git a/ojp-jdbc-driver/src/main/java/org/openjproxy/grpc/client/MultinodeStatementService.java b/ojp-jdbc-driver/src/main/java/org/openjproxy/grpc/client/MultinodeStatementService.java index 893d23064..1c5bef1d0 100644 --- a/ojp-jdbc-driver/src/main/java/org/openjproxy/grpc/client/MultinodeStatementService.java +++ b/ojp-jdbc-driver/src/main/java/org/openjproxy/grpc/client/MultinodeStatementService.java @@ -717,6 +717,14 @@ public SessionInfo rollbackTransaction(SessionInfo session) throws SQLException ); } + @Override + public SessionInfo setAutoCommit(SessionInfo session, boolean autoCommit) throws SQLException { + SessionInfo enhancedSessionInfo = withClusterHealth(session); + return executeWithSessionStickinessAndBinding(enhancedSessionInfo, client -> + client.setAutoCommit(enhancedSessionInfo, autoCommit) + ); + } + @Override public CallResourceResponse callResource(CallResourceRequest request) throws SQLException { SessionInfo sessionInfo = request.getSession(); diff --git a/ojp-jdbc-driver/src/main/java/org/openjproxy/grpc/client/StatementService.java b/ojp-jdbc-driver/src/main/java/org/openjproxy/grpc/client/StatementService.java index 03f78dd43..4eb6e3b10 100644 --- a/ojp-jdbc-driver/src/main/java/org/openjproxy/grpc/client/StatementService.java +++ b/ojp-jdbc-driver/src/main/java/org/openjproxy/grpc/client/StatementService.java @@ -54,6 +54,23 @@ Iterator executeQuery(SessionInfo sessionInfo, String sql, List + * In JDBC, {@link java.sql.Connection#commit()} does not automatically + * restore {@code autoCommit=true}; the connection stays in manual-commit mode + * until {@code setAutoCommit(true)} is explicitly called. OJP must mirror this + * by keeping the server-side physical connection in sync, otherwise + * {@code Session.hasActiveTransaction()} will return a stale value and route + * subsequent SELECTs incorrectly. + * + * @param session the active session + * @param autoCommit the new autoCommit value to apply on the physical connection + * @return updated session info + * @throws SQLException if the server-side call fails + */ + SessionInfo setAutoCommit(SessionInfo session, boolean autoCommit) throws SQLException; + CallResourceResponse callResource(CallResourceRequest request) throws SQLException; // XA Transaction Operations diff --git a/ojp-jdbc-driver/src/main/java/org/openjproxy/grpc/client/StatementServiceGrpcClient.java b/ojp-jdbc-driver/src/main/java/org/openjproxy/grpc/client/StatementServiceGrpcClient.java index 37ef7395c..16cbda519 100644 --- a/ojp-jdbc-driver/src/main/java/org/openjproxy/grpc/client/StatementServiceGrpcClient.java +++ b/ojp-jdbc-driver/src/main/java/org/openjproxy/grpc/client/StatementServiceGrpcClient.java @@ -11,6 +11,7 @@ import com.openjproxy.grpc.ResultSetFetchRequest; import com.openjproxy.grpc.SessionInfo; import com.openjproxy.grpc.SessionTerminationStatus; +import com.openjproxy.grpc.SetAutoCommitRequest; import com.openjproxy.grpc.StatementRequest; import com.openjproxy.grpc.StatementServiceGrpc; import io.grpc.ManagedChannel; @@ -476,6 +477,21 @@ public SessionInfo rollbackTransaction(SessionInfo session) throws SQLException } } + @Override + public SessionInfo setAutoCommit(SessionInfo session, boolean autoCommit) throws SQLException { + try { + SetAutoCommitRequest request = SetAutoCommitRequest.newBuilder() + .setSession(session) + .setAutoCommit(autoCommit) + .build(); + return this.statemetServiceBlockingStub.setAutoCommit(request); + } catch (StatusRuntimeException e) { + throw handle(e); + } catch (Exception e) { + throw new SQLException("Unable to set autoCommit: " + e.getMessage(), e); + } + } + @Override public CallResourceResponse callResource(CallResourceRequest request) throws SQLException { try { diff --git a/ojp-jdbc-driver/src/main/java/org/openjproxy/jdbc/Connection.java b/ojp-jdbc-driver/src/main/java/org/openjproxy/jdbc/Connection.java index bd983f4d4..cb7ec0631 100644 --- a/ojp-jdbc-driver/src/main/java/org/openjproxy/jdbc/Connection.java +++ b/ojp-jdbc-driver/src/main/java/org/openjproxy/jdbc/Connection.java @@ -129,13 +129,24 @@ public void setAutoCommit(boolean autoCommit) throws SQLException { log.debug("setAutoCommit: {}", autoCommit); checkValid(); checkValid(); - //if switching on autocommit with active transaction, commit current transaction. if (!this.autoCommit && autoCommit && TransactionStatus.TRX_ACTIVE.equals(session.getTransactionInfo().getTransactionStatus())) { + // Switching to autoCommit=true with an active transaction: commit first, then + // notify the server to restore autoCommit on the physical connection. this.session = this.statementService.commitTransaction(this.session); - //If switching autocommit off, start a new transaction + this.session = this.statementService.setAutoCommit(this.session, true); } else if (this.autoCommit && !autoCommit) { + // Switching to autoCommit=false: start a transaction on the server. this.session = this.statementService.startTransaction(this.session); + } else if (!this.autoCommit && autoCommit) { + // Switching to autoCommit=true after a commit() with no active transaction. + // The server-side physical connection is still in autoCommit=false after + // Connection.commit() (JDBC does not reset autoCommit on commit). Forward + // the call so Session.hasActiveTransaction() returns the correct value. + if (session != null && session.getSessionUUID() != null + && !session.getSessionUUID().isEmpty()) { + this.session = this.statementService.setAutoCommit(this.session, true); + } } this.autoCommit = autoCommit; } diff --git a/ojp-jdbc-driver/src/test/java/openjproxy/jdbc/H2ReadWriteSplittingEndToEndTest.java b/ojp-jdbc-driver/src/test/java/openjproxy/jdbc/H2ReadWriteSplittingEndToEndTest.java index 536d8f6fc..e8f01e3a9 100644 --- a/ojp-jdbc-driver/src/test/java/openjproxy/jdbc/H2ReadWriteSplittingEndToEndTest.java +++ b/ojp-jdbc-driver/src/test/java/openjproxy/jdbc/H2ReadWriteSplittingEndToEndTest.java @@ -298,9 +298,9 @@ void testDeleteGoesToPrimary() throws SQLException { } try (Connection verify = DriverManager.getConnection(primaryUrl(), primaryProps())) { - // Force routing to primary (transactions always route to primary) so we verify - // the primary was actually modified and not the replica. - verify.setAutoCommit(false); + // The primary no longer has id=1 (deleted above) and the replica never had id=1 + // (seeded with id=2 only) — so COUNT=0 regardless of which node handles this + // SELECT, and no transaction is needed to force primary routing. try (Statement s = verify.createStatement(); ResultSet rs = s.executeQuery( "SELECT COUNT(*) FROM test_data WHERE id = 1")) { @@ -308,7 +308,6 @@ void testDeleteGoesToPrimary() throws SQLException { assertEquals(0, rs.getInt(1), "Primary database should not contain the deleted row"); } - verify.rollback(); } } @@ -454,7 +453,12 @@ void testTransaction_AllOperationsGoToPrimary() throws SQLException { /** * Without a sticky session, a read immediately after a committed transaction goes to the - * replica (eventual consistency). The replica does not have the just-committed row. + * replica (eventual consistency). The replica does not have the just-committed row because + * OJP uses two physically separate H2 databases — no replication occurs. + *

+ * This test relies on {@code setAutoCommit(true)} being forwarded to the server so that + * {@code Session.hasActiveTransaction()} correctly returns {@code false} after the commit, + * allowing the routing logic to re-enable replica routing. */ @SneakyThrows @Test @@ -481,4 +485,71 @@ void testAfterTransactionCommit_ReadsGoToReplica_WithNoStickySession() throws SQ "Without sticky session, SELECT after commit routes to replica which does not have the row"); } } + + /** + * With a sticky session, reads immediately after a committed explicit transaction are + * routed to the primary — the "read-your-writes" consistency guarantee. + *

+ * This is the recommended pattern for applications that need to read data they just + * wrote. Because OJP's two H2 databases are unsynchronised, a replica-routed SELECT + * would return {@code COUNT = 0} (eventual consistency gap), but within the sticky + * window the request is pinned to the primary and returns {@code COUNT = 1}. + *

+ * Why sticky session is necessary: without a positive + * {@code stickySessionSeconds} value, {@code setAutoCommit(true)} is forwarded to the + * server and the server-side connection's {@code autoCommit} flag reverts to + * {@code true}, causing {@code Session.hasActiveTransaction()} to return + * {@code false}. Subsequent SELECTs are therefore free to go to the replica. A + * sticky session overrides that decision for the configured duration, keeping reads + * on the primary to preserve read-after-write consistency. + */ + @SneakyThrows + @Test + void testAfterTransactionCommit_ReadsGoToPrimary() throws SQLException, InterruptedException { + Assumptions.assumeTrue(isH2TestEnabled, "Skipping H2 tests - not enabled"); + + String stickyPrimaryUrl = "jdbc:ojp[" + OJP_HOST + "]_h2:mem:rw_sticky_primary;DB_CLOSE_DELAY=-1"; + String stickyReplicaUrl = "jdbc:ojp[" + OJP_HOST + "]_h2:mem:rw_sticky_replica;DB_CLOSE_DELAY=-1"; + + Properties stickyProps = stickyProps(); + + // Setup separate sticky session databases + try (Connection c = DriverManager.getConnection(stickyPrimaryUrl, stickyProps); + Statement s = c.createStatement()) { + s.execute("DROP TABLE IF EXISTS test_data"); + s.execute("CREATE TABLE test_data (id INT PRIMARY KEY, source VARCHAR(50))"); + s.execute("INSERT INTO test_data VALUES (1, 'sticky_primary')"); + } + + Properties replicaOnlyProps = new Properties(); + replicaOnlyProps.setProperty("user", USER); + replicaOnlyProps.setProperty("password", PASSWORD); + replicaOnlyProps.setProperty("ojp.datasource.name", "rw_sticky_replica"); + + try (Connection c = DriverManager.getConnection(stickyReplicaUrl, replicaOnlyProps); + Statement s = c.createStatement()) { + s.execute("DROP TABLE IF EXISTS test_data"); + s.execute("CREATE TABLE test_data (id INT PRIMARY KEY, source VARCHAR(50))"); + s.execute("INSERT INTO test_data VALUES (2, 'sticky_replica')"); + } + + connection = DriverManager.getConnection(stickyPrimaryUrl, stickyProps); + connection.setAutoCommit(false); + + try (Statement stmt = connection.createStatement()) { + stmt.executeUpdate("INSERT INTO test_data VALUES (250, 'post_tx_sticky')"); + connection.commit(); + } + + connection.setAutoCommit(true); + + // Within the sticky window: primary is still used → row is visible + try (Statement stmt = connection.createStatement(); + ResultSet rs = stmt.executeQuery( + "SELECT COUNT(*) FROM test_data WHERE id = 250")) { + assertTrue(rs.next()); + assertEquals(1, rs.getInt(1), + "With sticky session, SELECT after commit should route to primary and see the committed row"); + } + } } diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/StatementServiceImpl.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/StatementServiceImpl.java index c847de0d6..11c112c6f 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/StatementServiceImpl.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/StatementServiceImpl.java @@ -11,6 +11,7 @@ import com.openjproxy.grpc.ResultSetFetchRequest; import com.openjproxy.grpc.SessionInfo; import com.openjproxy.grpc.SessionTerminationStatus; +import com.openjproxy.grpc.SetAutoCommitRequest; import com.openjproxy.grpc.StatementRequest; import com.openjproxy.grpc.StatementServiceGrpc; import io.grpc.stub.StreamObserver; @@ -22,6 +23,7 @@ import org.openjproxy.grpc.server.action.session.TerminateSessionAction; import org.openjproxy.grpc.server.action.transaction.CommitTransactionAction; import org.openjproxy.grpc.server.action.transaction.RollbackTransactionAction; +import org.openjproxy.grpc.server.action.transaction.SetAutoCommitAction; import org.openjproxy.grpc.server.action.transaction.StartTransactionAction; import org.openjproxy.grpc.server.action.xa.XaCommitAction; import org.openjproxy.grpc.server.action.xa.XaEndAction; @@ -237,6 +239,11 @@ public void rollbackTransaction(SessionInfo sessionInfo, StreamObserver responseObserver) { + SetAutoCommitAction.getInstance().execute(actionContext, request, responseObserver); + } + @Override public void callResource(CallResourceRequest request, StreamObserver responseObserver) { CallResourceAction.getInstance().execute(actionContext, request, responseObserver); diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/SetAutoCommitAction.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/SetAutoCommitAction.java new file mode 100644 index 000000000..cb14613f0 --- /dev/null +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/SetAutoCommitAction.java @@ -0,0 +1,80 @@ +package org.openjproxy.grpc.server.action.transaction; + +import com.openjproxy.grpc.SessionInfo; +import com.openjproxy.grpc.SetAutoCommitRequest; +import io.grpc.stub.StreamObserver; +import lombok.extern.slf4j.Slf4j; +import org.openjproxy.grpc.server.action.ActionContext; +import org.openjproxy.grpc.server.action.util.ProcessClusterHealthAction; + +import java.sql.Connection; +import java.sql.SQLException; + +import static org.openjproxy.grpc.server.GrpcExceptionHandler.sendSQLExceptionMetadata; + +/** + * Action that forwards a {@code setAutoCommit} call from the driver to the + * server-side physical connection. + *

+ * The JDBC specification guarantees that {@link Connection#commit()} does + * not reset {@code autoCommit} — the connection stays in manual-commit + * mode until {@code setAutoCommit(true)} is explicitly invoked. OJP mirrors + * this contract: the driver sends this RPC whenever it needs to change the + * {@code autoCommit} state of the physical connection held in the server-side + * {@link org.openjproxy.grpc.server.Session}. + *

+ * This keeps the server-side {@code autoCommit} flag in sync with the client, + * which is essential for correct read/write routing: + * {@code Session.hasActiveTransaction()} reads {@code connection.getAutoCommit()} + * to decide whether to route a SELECT to a replica or to the primary. + */ +@Slf4j +public class SetAutoCommitAction { + + private static final SetAutoCommitAction INSTANCE = new SetAutoCommitAction(); + + private SetAutoCommitAction() { + } + + /** + * Returns the singleton instance of this action. + * + * @return the singleton instance + */ + public static SetAutoCommitAction getInstance() { + return INSTANCE; + } + + /** + * Sets {@code autoCommit} on the physical connection associated with the session. + * + * @param context the action context with session manager and datasource map + * @param request the request carrying the session info and desired autoCommit value + * @param responseObserver the observer to send the updated session info or error metadata + */ + public void execute(ActionContext context, SetAutoCommitRequest request, + StreamObserver responseObserver) { + boolean autoCommit = request.getAutoCommit(); + log.debug("setAutoCommit: {}", autoCommit); + + ProcessClusterHealthAction.getInstance().execute(context, request.getSession()); + + try { + Connection conn = context.getSessionManager().getConnection(request.getSession()); + if (conn == null) { + throw new SQLException("Connection not found for session: " + + request.getSession().getSessionUUID()); + } + conn.setAutoCommit(autoCommit); + + responseObserver.onNext(request.getSession()); + responseObserver.onCompleted(); + } catch (SQLException se) { + sendSQLExceptionMetadata(se, responseObserver); + } catch (Exception e) { + log.error("Error in setAutoCommit action", e); + sendSQLExceptionMetadata( + new SQLException("Unable to set autoCommit: " + e.getMessage()), responseObserver); + } + } +} From 21c7030d4022e57c17f18008e0c33e23083de34f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:01:32 +0000 Subject: [PATCH 15/25] Revert "feat: forward setAutoCommit to server; restore testAfterTransactionCommit_ReadsGoToPrimary with sticky session" This reverts commit 31fb0149d6985904b80d51bb8a7fb4ebaa43f359. Co-authored-by: rrobetti <7221783+rrobetti@users.noreply.github.com> --- .../src/main/proto/StatementService.proto | 6 -- .../client/MultinodeStatementService.java | 8 -- .../grpc/client/StatementService.java | 17 ---- .../client/StatementServiceGrpcClient.java | 16 ---- .../java/org/openjproxy/jdbc/Connection.java | 15 +--- .../H2ReadWriteSplittingEndToEndTest.java | 81 ++----------------- .../grpc/server/StatementServiceImpl.java | 7 -- .../transaction/SetAutoCommitAction.java | 80 ------------------ 8 files changed, 7 insertions(+), 223 deletions(-) delete mode 100644 ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/SetAutoCommitAction.java diff --git a/ojp-grpc-commons/src/main/proto/StatementService.proto b/ojp-grpc-commons/src/main/proto/StatementService.proto index 9425cf253..7310588de 100644 --- a/ojp-grpc-commons/src/main/proto/StatementService.proto +++ b/ojp-grpc-commons/src/main/proto/StatementService.proto @@ -465,11 +465,6 @@ message XaResponse { string message = 3; } -// Request to set the autoCommit mode on the server-side physical connection. -message SetAutoCommitRequest { - SessionInfo session = 1; - bool autoCommit = 2; -} service StatementService { rpc connect(ConnectionDetails) returns (SessionInfo); @@ -482,7 +477,6 @@ service StatementService { rpc startTransaction(SessionInfo) returns (SessionInfo); rpc commitTransaction(SessionInfo) returns (SessionInfo); rpc rollbackTransaction(SessionInfo) returns (SessionInfo); - rpc setAutoCommit(SetAutoCommitRequest) returns (SessionInfo); rpc callResource(CallResourceRequest) returns (CallResourceResponse); // XA Transaction Operations diff --git a/ojp-jdbc-driver/src/main/java/org/openjproxy/grpc/client/MultinodeStatementService.java b/ojp-jdbc-driver/src/main/java/org/openjproxy/grpc/client/MultinodeStatementService.java index 1c5bef1d0..893d23064 100644 --- a/ojp-jdbc-driver/src/main/java/org/openjproxy/grpc/client/MultinodeStatementService.java +++ b/ojp-jdbc-driver/src/main/java/org/openjproxy/grpc/client/MultinodeStatementService.java @@ -717,14 +717,6 @@ public SessionInfo rollbackTransaction(SessionInfo session) throws SQLException ); } - @Override - public SessionInfo setAutoCommit(SessionInfo session, boolean autoCommit) throws SQLException { - SessionInfo enhancedSessionInfo = withClusterHealth(session); - return executeWithSessionStickinessAndBinding(enhancedSessionInfo, client -> - client.setAutoCommit(enhancedSessionInfo, autoCommit) - ); - } - @Override public CallResourceResponse callResource(CallResourceRequest request) throws SQLException { SessionInfo sessionInfo = request.getSession(); diff --git a/ojp-jdbc-driver/src/main/java/org/openjproxy/grpc/client/StatementService.java b/ojp-jdbc-driver/src/main/java/org/openjproxy/grpc/client/StatementService.java index 4eb6e3b10..03f78dd43 100644 --- a/ojp-jdbc-driver/src/main/java/org/openjproxy/grpc/client/StatementService.java +++ b/ojp-jdbc-driver/src/main/java/org/openjproxy/grpc/client/StatementService.java @@ -54,23 +54,6 @@ Iterator executeQuery(SessionInfo sessionInfo, String sql, List - * In JDBC, {@link java.sql.Connection#commit()} does not automatically - * restore {@code autoCommit=true}; the connection stays in manual-commit mode - * until {@code setAutoCommit(true)} is explicitly called. OJP must mirror this - * by keeping the server-side physical connection in sync, otherwise - * {@code Session.hasActiveTransaction()} will return a stale value and route - * subsequent SELECTs incorrectly. - * - * @param session the active session - * @param autoCommit the new autoCommit value to apply on the physical connection - * @return updated session info - * @throws SQLException if the server-side call fails - */ - SessionInfo setAutoCommit(SessionInfo session, boolean autoCommit) throws SQLException; - CallResourceResponse callResource(CallResourceRequest request) throws SQLException; // XA Transaction Operations diff --git a/ojp-jdbc-driver/src/main/java/org/openjproxy/grpc/client/StatementServiceGrpcClient.java b/ojp-jdbc-driver/src/main/java/org/openjproxy/grpc/client/StatementServiceGrpcClient.java index 16cbda519..37ef7395c 100644 --- a/ojp-jdbc-driver/src/main/java/org/openjproxy/grpc/client/StatementServiceGrpcClient.java +++ b/ojp-jdbc-driver/src/main/java/org/openjproxy/grpc/client/StatementServiceGrpcClient.java @@ -11,7 +11,6 @@ import com.openjproxy.grpc.ResultSetFetchRequest; import com.openjproxy.grpc.SessionInfo; import com.openjproxy.grpc.SessionTerminationStatus; -import com.openjproxy.grpc.SetAutoCommitRequest; import com.openjproxy.grpc.StatementRequest; import com.openjproxy.grpc.StatementServiceGrpc; import io.grpc.ManagedChannel; @@ -477,21 +476,6 @@ public SessionInfo rollbackTransaction(SessionInfo session) throws SQLException } } - @Override - public SessionInfo setAutoCommit(SessionInfo session, boolean autoCommit) throws SQLException { - try { - SetAutoCommitRequest request = SetAutoCommitRequest.newBuilder() - .setSession(session) - .setAutoCommit(autoCommit) - .build(); - return this.statemetServiceBlockingStub.setAutoCommit(request); - } catch (StatusRuntimeException e) { - throw handle(e); - } catch (Exception e) { - throw new SQLException("Unable to set autoCommit: " + e.getMessage(), e); - } - } - @Override public CallResourceResponse callResource(CallResourceRequest request) throws SQLException { try { diff --git a/ojp-jdbc-driver/src/main/java/org/openjproxy/jdbc/Connection.java b/ojp-jdbc-driver/src/main/java/org/openjproxy/jdbc/Connection.java index cb7ec0631..bd983f4d4 100644 --- a/ojp-jdbc-driver/src/main/java/org/openjproxy/jdbc/Connection.java +++ b/ojp-jdbc-driver/src/main/java/org/openjproxy/jdbc/Connection.java @@ -129,24 +129,13 @@ public void setAutoCommit(boolean autoCommit) throws SQLException { log.debug("setAutoCommit: {}", autoCommit); checkValid(); checkValid(); + //if switching on autocommit with active transaction, commit current transaction. if (!this.autoCommit && autoCommit && TransactionStatus.TRX_ACTIVE.equals(session.getTransactionInfo().getTransactionStatus())) { - // Switching to autoCommit=true with an active transaction: commit first, then - // notify the server to restore autoCommit on the physical connection. this.session = this.statementService.commitTransaction(this.session); - this.session = this.statementService.setAutoCommit(this.session, true); + //If switching autocommit off, start a new transaction } else if (this.autoCommit && !autoCommit) { - // Switching to autoCommit=false: start a transaction on the server. this.session = this.statementService.startTransaction(this.session); - } else if (!this.autoCommit && autoCommit) { - // Switching to autoCommit=true after a commit() with no active transaction. - // The server-side physical connection is still in autoCommit=false after - // Connection.commit() (JDBC does not reset autoCommit on commit). Forward - // the call so Session.hasActiveTransaction() returns the correct value. - if (session != null && session.getSessionUUID() != null - && !session.getSessionUUID().isEmpty()) { - this.session = this.statementService.setAutoCommit(this.session, true); - } } this.autoCommit = autoCommit; } diff --git a/ojp-jdbc-driver/src/test/java/openjproxy/jdbc/H2ReadWriteSplittingEndToEndTest.java b/ojp-jdbc-driver/src/test/java/openjproxy/jdbc/H2ReadWriteSplittingEndToEndTest.java index e8f01e3a9..536d8f6fc 100644 --- a/ojp-jdbc-driver/src/test/java/openjproxy/jdbc/H2ReadWriteSplittingEndToEndTest.java +++ b/ojp-jdbc-driver/src/test/java/openjproxy/jdbc/H2ReadWriteSplittingEndToEndTest.java @@ -298,9 +298,9 @@ void testDeleteGoesToPrimary() throws SQLException { } try (Connection verify = DriverManager.getConnection(primaryUrl(), primaryProps())) { - // The primary no longer has id=1 (deleted above) and the replica never had id=1 - // (seeded with id=2 only) — so COUNT=0 regardless of which node handles this - // SELECT, and no transaction is needed to force primary routing. + // Force routing to primary (transactions always route to primary) so we verify + // the primary was actually modified and not the replica. + verify.setAutoCommit(false); try (Statement s = verify.createStatement(); ResultSet rs = s.executeQuery( "SELECT COUNT(*) FROM test_data WHERE id = 1")) { @@ -308,6 +308,7 @@ void testDeleteGoesToPrimary() throws SQLException { assertEquals(0, rs.getInt(1), "Primary database should not contain the deleted row"); } + verify.rollback(); } } @@ -453,12 +454,7 @@ void testTransaction_AllOperationsGoToPrimary() throws SQLException { /** * Without a sticky session, a read immediately after a committed transaction goes to the - * replica (eventual consistency). The replica does not have the just-committed row because - * OJP uses two physically separate H2 databases — no replication occurs. - *

- * This test relies on {@code setAutoCommit(true)} being forwarded to the server so that - * {@code Session.hasActiveTransaction()} correctly returns {@code false} after the commit, - * allowing the routing logic to re-enable replica routing. + * replica (eventual consistency). The replica does not have the just-committed row. */ @SneakyThrows @Test @@ -485,71 +481,4 @@ void testAfterTransactionCommit_ReadsGoToReplica_WithNoStickySession() throws SQ "Without sticky session, SELECT after commit routes to replica which does not have the row"); } } - - /** - * With a sticky session, reads immediately after a committed explicit transaction are - * routed to the primary — the "read-your-writes" consistency guarantee. - *

- * This is the recommended pattern for applications that need to read data they just - * wrote. Because OJP's two H2 databases are unsynchronised, a replica-routed SELECT - * would return {@code COUNT = 0} (eventual consistency gap), but within the sticky - * window the request is pinned to the primary and returns {@code COUNT = 1}. - *

- * Why sticky session is necessary: without a positive - * {@code stickySessionSeconds} value, {@code setAutoCommit(true)} is forwarded to the - * server and the server-side connection's {@code autoCommit} flag reverts to - * {@code true}, causing {@code Session.hasActiveTransaction()} to return - * {@code false}. Subsequent SELECTs are therefore free to go to the replica. A - * sticky session overrides that decision for the configured duration, keeping reads - * on the primary to preserve read-after-write consistency. - */ - @SneakyThrows - @Test - void testAfterTransactionCommit_ReadsGoToPrimary() throws SQLException, InterruptedException { - Assumptions.assumeTrue(isH2TestEnabled, "Skipping H2 tests - not enabled"); - - String stickyPrimaryUrl = "jdbc:ojp[" + OJP_HOST + "]_h2:mem:rw_sticky_primary;DB_CLOSE_DELAY=-1"; - String stickyReplicaUrl = "jdbc:ojp[" + OJP_HOST + "]_h2:mem:rw_sticky_replica;DB_CLOSE_DELAY=-1"; - - Properties stickyProps = stickyProps(); - - // Setup separate sticky session databases - try (Connection c = DriverManager.getConnection(stickyPrimaryUrl, stickyProps); - Statement s = c.createStatement()) { - s.execute("DROP TABLE IF EXISTS test_data"); - s.execute("CREATE TABLE test_data (id INT PRIMARY KEY, source VARCHAR(50))"); - s.execute("INSERT INTO test_data VALUES (1, 'sticky_primary')"); - } - - Properties replicaOnlyProps = new Properties(); - replicaOnlyProps.setProperty("user", USER); - replicaOnlyProps.setProperty("password", PASSWORD); - replicaOnlyProps.setProperty("ojp.datasource.name", "rw_sticky_replica"); - - try (Connection c = DriverManager.getConnection(stickyReplicaUrl, replicaOnlyProps); - Statement s = c.createStatement()) { - s.execute("DROP TABLE IF EXISTS test_data"); - s.execute("CREATE TABLE test_data (id INT PRIMARY KEY, source VARCHAR(50))"); - s.execute("INSERT INTO test_data VALUES (2, 'sticky_replica')"); - } - - connection = DriverManager.getConnection(stickyPrimaryUrl, stickyProps); - connection.setAutoCommit(false); - - try (Statement stmt = connection.createStatement()) { - stmt.executeUpdate("INSERT INTO test_data VALUES (250, 'post_tx_sticky')"); - connection.commit(); - } - - connection.setAutoCommit(true); - - // Within the sticky window: primary is still used → row is visible - try (Statement stmt = connection.createStatement(); - ResultSet rs = stmt.executeQuery( - "SELECT COUNT(*) FROM test_data WHERE id = 250")) { - assertTrue(rs.next()); - assertEquals(1, rs.getInt(1), - "With sticky session, SELECT after commit should route to primary and see the committed row"); - } - } } diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/StatementServiceImpl.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/StatementServiceImpl.java index 11c112c6f..c847de0d6 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/StatementServiceImpl.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/StatementServiceImpl.java @@ -11,7 +11,6 @@ import com.openjproxy.grpc.ResultSetFetchRequest; import com.openjproxy.grpc.SessionInfo; import com.openjproxy.grpc.SessionTerminationStatus; -import com.openjproxy.grpc.SetAutoCommitRequest; import com.openjproxy.grpc.StatementRequest; import com.openjproxy.grpc.StatementServiceGrpc; import io.grpc.stub.StreamObserver; @@ -23,7 +22,6 @@ import org.openjproxy.grpc.server.action.session.TerminateSessionAction; import org.openjproxy.grpc.server.action.transaction.CommitTransactionAction; import org.openjproxy.grpc.server.action.transaction.RollbackTransactionAction; -import org.openjproxy.grpc.server.action.transaction.SetAutoCommitAction; import org.openjproxy.grpc.server.action.transaction.StartTransactionAction; import org.openjproxy.grpc.server.action.xa.XaCommitAction; import org.openjproxy.grpc.server.action.xa.XaEndAction; @@ -239,11 +237,6 @@ public void rollbackTransaction(SessionInfo sessionInfo, StreamObserver responseObserver) { - SetAutoCommitAction.getInstance().execute(actionContext, request, responseObserver); - } - @Override public void callResource(CallResourceRequest request, StreamObserver responseObserver) { CallResourceAction.getInstance().execute(actionContext, request, responseObserver); diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/SetAutoCommitAction.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/SetAutoCommitAction.java deleted file mode 100644 index cb14613f0..000000000 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/SetAutoCommitAction.java +++ /dev/null @@ -1,80 +0,0 @@ -package org.openjproxy.grpc.server.action.transaction; - -import com.openjproxy.grpc.SessionInfo; -import com.openjproxy.grpc.SetAutoCommitRequest; -import io.grpc.stub.StreamObserver; -import lombok.extern.slf4j.Slf4j; -import org.openjproxy.grpc.server.action.ActionContext; -import org.openjproxy.grpc.server.action.util.ProcessClusterHealthAction; - -import java.sql.Connection; -import java.sql.SQLException; - -import static org.openjproxy.grpc.server.GrpcExceptionHandler.sendSQLExceptionMetadata; - -/** - * Action that forwards a {@code setAutoCommit} call from the driver to the - * server-side physical connection. - *

- * The JDBC specification guarantees that {@link Connection#commit()} does - * not reset {@code autoCommit} — the connection stays in manual-commit - * mode until {@code setAutoCommit(true)} is explicitly invoked. OJP mirrors - * this contract: the driver sends this RPC whenever it needs to change the - * {@code autoCommit} state of the physical connection held in the server-side - * {@link org.openjproxy.grpc.server.Session}. - *

- * This keeps the server-side {@code autoCommit} flag in sync with the client, - * which is essential for correct read/write routing: - * {@code Session.hasActiveTransaction()} reads {@code connection.getAutoCommit()} - * to decide whether to route a SELECT to a replica or to the primary. - */ -@Slf4j -public class SetAutoCommitAction { - - private static final SetAutoCommitAction INSTANCE = new SetAutoCommitAction(); - - private SetAutoCommitAction() { - } - - /** - * Returns the singleton instance of this action. - * - * @return the singleton instance - */ - public static SetAutoCommitAction getInstance() { - return INSTANCE; - } - - /** - * Sets {@code autoCommit} on the physical connection associated with the session. - * - * @param context the action context with session manager and datasource map - * @param request the request carrying the session info and desired autoCommit value - * @param responseObserver the observer to send the updated session info or error metadata - */ - public void execute(ActionContext context, SetAutoCommitRequest request, - StreamObserver responseObserver) { - boolean autoCommit = request.getAutoCommit(); - log.debug("setAutoCommit: {}", autoCommit); - - ProcessClusterHealthAction.getInstance().execute(context, request.getSession()); - - try { - Connection conn = context.getSessionManager().getConnection(request.getSession()); - if (conn == null) { - throw new SQLException("Connection not found for session: " - + request.getSession().getSessionUUID()); - } - conn.setAutoCommit(autoCommit); - - responseObserver.onNext(request.getSession()); - responseObserver.onCompleted(); - } catch (SQLException se) { - sendSQLExceptionMetadata(se, responseObserver); - } catch (Exception e) { - log.error("Error in setAutoCommit action", e); - sendSQLExceptionMetadata( - new SQLException("Unable to set autoCommit: " + e.getMessage()), responseObserver); - } - } -} From 6a33bbe0e4bedfad27004be56bd6a0474feb8418 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:02:36 +0000 Subject: [PATCH 16/25] test: disable ReadsGoToReplica test with limitation comment, add ReadsGoToPrimary test documenting current behavior Agent-Logs-Url: https://github.com/Open-J-Proxy/ojp/sessions/dde07fa1-5628-4f85-8d5f-7a08d1ad1a5b Co-authored-by: rrobetti <7221783+rrobetti@users.noreply.github.com> --- .../H2ReadWriteSplittingEndToEndTest.java | 80 ++++++++++++++++++- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/ojp-jdbc-driver/src/test/java/openjproxy/jdbc/H2ReadWriteSplittingEndToEndTest.java b/ojp-jdbc-driver/src/test/java/openjproxy/jdbc/H2ReadWriteSplittingEndToEndTest.java index 536d8f6fc..901bfc3ca 100644 --- a/ojp-jdbc-driver/src/test/java/openjproxy/jdbc/H2ReadWriteSplittingEndToEndTest.java +++ b/ojp-jdbc-driver/src/test/java/openjproxy/jdbc/H2ReadWriteSplittingEndToEndTest.java @@ -4,6 +4,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import java.sql.Connection; @@ -453,9 +454,37 @@ void testTransaction_AllOperationsGoToPrimary() throws SQLException { } /** - * Without a sticky session, a read immediately after a committed transaction goes to the - * replica (eventual consistency). The replica does not have the just-committed row. + * Without a sticky session, a read immediately after a committed transaction is expected to go + * to the replica (eventual consistency). The replica does not have the just-committed row. + * + *

DISABLED — current limitation: {@code setAutoCommit(true)} is not propagated to the server. + * + *

When the client calls {@code connection.setAutoCommit(true)} after committing, the OJP + * driver currently does NOT forward this to the server via {@code callResource} or any other + * gRPC call. As a result, the server-side physical JDBC connection remains in + * {@code autoCommit=false} mode even after the transaction has been committed. Because + * {@link org.openjproxy.grpc.server.Session#hasActiveTransaction()} checks + * {@code !primaryConnection.getAutoCommit()}, it continues to return {@code true}, and + * subsequent SELECT statements are pinned to the primary instead of being routed to the + * replica. + * + *

Note on propagating {@code setAutoCommit(true)} via {@code callResource}: + * Propagating this call is not straightforward and requires careful evaluation. Key concerns + * include: (1) the {@link com.openjproxy.grpc.TransactionInfo} embedded in + * {@link com.openjproxy.grpc.SessionInfo} would become stale after a {@code callResource} + * invocation (the response does not update {@code TransactionInfo}); (2) the + * {@code callProxy} helper currently swallows exceptions silently, meaning a failed + * server-side {@code setAutoCommit} would leave client and server in inconsistent states; + * (3) implicit-commit semantics on {@code setAutoCommit(true)} vary across database drivers + * and must be validated per supported database; and (4) interaction with the server-side + * connection pool cleanup logic needs to be verified. See the analysis document for full + * details. + * + * @see #testAfterTransactionCommit_ReadsGoToPrimary_WithNoStickySession */ + @Disabled("setAutoCommit(true) is not propagated to the server — reads stay pinned to primary " + + "instead of routing to replica; propagating this requires careful evaluation, " + + "see Javadoc for details") @SneakyThrows @Test void testAfterTransactionCommit_ReadsGoToReplica_WithNoStickySession() throws SQLException { @@ -481,4 +510,51 @@ void testAfterTransactionCommit_ReadsGoToReplica_WithNoStickySession() throws SQ "Without sticky session, SELECT after commit routes to replica which does not have the row"); } } + + /** + * Documents the current actual behavior: after an explicit transaction is committed and the + * client calls {@code setAutoCommit(true)}, reads continue to go to the primary + * because {@code setAutoCommit(true)} is not propagated to the server. + * + *

The server-side physical connection remains in {@code autoCommit=false} mode after the + * transaction is committed. {@link org.openjproxy.grpc.server.Session#hasActiveTransaction()} + * therefore still returns {@code true}, causing the read/write splitter to route all subsequent + * SELECT statements to the primary. The inserted row (id=251) is present on the primary, so + * the count is 1. + * + *

Once {@code setAutoCommit(true)} propagation is correctly implemented and the + * {@code TransactionInfo} state management issues are resolved, this test should be replaced by + * {@link #testAfterTransactionCommit_ReadsGoToReplica_WithNoStickySession} (currently + * disabled). + */ + @SneakyThrows + @Test + void testAfterTransactionCommit_ReadsGoToPrimary_WithNoStickySession() throws SQLException { + Assumptions.assumeTrue(isH2TestEnabled, "Skipping H2 tests - not enabled"); + + setupDatabases(); + + connection = DriverManager.getConnection(primaryUrl(), primaryProps()); + connection.setAutoCommit(false); + + try (Statement stmt = connection.createStatement()) { + stmt.executeUpdate("INSERT INTO test_data VALUES (251, 'post_tx_primary_test')"); + connection.commit(); + } + + // setAutoCommit(true) is NOT propagated to the server. The server's physical connection + // remains in autoCommit=false mode, so hasActiveTransaction() still returns true and + // the SELECT below is routed to the primary rather than the replica. + connection.setAutoCommit(true); + + try (Statement stmt = connection.createStatement(); + ResultSet rs = stmt.executeQuery( + "SELECT COUNT(*) FROM test_data WHERE id = 251")) { + assertTrue(rs.next()); + assertEquals(1, rs.getInt(1), + "Current behavior: setAutoCommit(true) is not propagated to the server, " + + "so hasActiveTransaction() remains true and SELECT routes to primary " + + "which has the newly inserted row"); + } + } } From a31fb07574fb392bbbb77e806289f5a092cec0fe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 08:45:29 +0000 Subject: [PATCH 17/25] Add INFO investigation logs for read/write splitting routing decisions (TODO: downgrade to DEBUG before merging) Agent-Logs-Url: https://github.com/Open-J-Proxy/ojp/sessions/8f9dbda7-641c-412a-91ea-7fe01e32a99b Co-authored-by: rrobetti <7221783+rrobetti@users.noreply.github.com> --- .../org/openjproxy/grpc/server/Session.java | 18 ++++++++- .../transaction/ExecuteQueryAction.java | 37 ++++++++++++++++--- .../ReadWriteDataSourceRegistry.java | 15 +++++++- 3 files changed, 60 insertions(+), 10 deletions(-) diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/Session.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/Session.java index 6c4611650..4b8c02535 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/Session.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/Session.java @@ -277,9 +277,17 @@ public synchronized Connection getOrCreateReplicaConnection(DataSource fallbackR DataSource ds = (replicaDataSource != null) ? replicaDataSource : fallbackReplicaDs; if (ds != null) { replicaConnection = ds.getConnection(); - log.debug("Lazily acquired replica connection from {} datasource for session {}", + // TODO: change to DEBUG before merging + log.info("[RW-SPLIT] getOrCreateReplicaConnection: CREATED new replica connection from {} datasource for session={}", (replicaDataSource != null) ? "instance" : "fallback", sessionUUID); + } else { + // TODO: change to DEBUG before merging + log.info("[RW-SPLIT] getOrCreateReplicaConnection: no datasource available (replicaDataSource=null, fallback=null) for session={}", + sessionUUID); } + } else { + // TODO: change to DEBUG before merging + log.info("[RW-SPLIT] getOrCreateReplicaConnection: REUSING existing replica connection for session={}", sessionUUID); } return replicaConnection; } @@ -298,10 +306,16 @@ public synchronized Connection getOrCreateReplicaConnection(DataSource fallbackR */ public synchronized boolean hasActiveTransaction() { if (primaryConnection == null) { + // TODO: change to DEBUG before merging + log.info("[RW-SPLIT] hasActiveTransaction: primaryConnection is null, returning false for session={}", sessionUUID); return false; } try { - return !primaryConnection.getAutoCommit(); + boolean active = !primaryConnection.getAutoCommit(); + // TODO: change to DEBUG before merging + log.info("[RW-SPLIT] hasActiveTransaction: session={}, autoCommit={}, hasActiveTransaction={}", + sessionUUID, !active, active); + return active; } catch (SQLException e) { // Safety: assume transaction present if we cannot determine log.warn("Could not determine autoCommit state for session {}; assuming active transaction", sessionUUID); diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteQueryAction.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteQueryAction.java index b987cbeef..5b7fc0727 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteQueryAction.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteQueryAction.java @@ -87,10 +87,14 @@ private void executeQueryInternal(ActionContext actionContext, StatementRequest + ". Cannot obtain replica connection."); } execConn = activeSession.getOrCreateReplicaConnection(replicaDs); - log.debug("Read/write splitting: routed SELECT to replica for connHash={}", - request.getSession().getConnHash()); + // TODO: change to DEBUG before merging + log.info("[RW-SPLIT] executeQueryInternal: using REPLICA connection for sessionUUID={}, connHash={}", + dto.getSession().getSessionUUID(), request.getSession().getConnHash()); } else { execConn = dto.getConnection(); + // TODO: change to DEBUG before merging + log.info("[RW-SPLIT] executeQueryInternal: using PRIMARY connection for sessionUUID={}, connHash={}", + dto.getSession().getSessionUUID(), request.getSession().getConnHash()); } // Build an execution DTO with the resolved connection so StatementFactory and @@ -230,30 +234,45 @@ private DataSource resolveReadReplicaDataSource(ActionContext context, Statement return null; } + String sessionUUID = request.getSession().getSessionUUID(); + String connHash = request.getSession().getConnHash(); + // TODO: change to DEBUG before merging + log.info("[RW-SPLIT] resolveReadReplicaDataSource: connHash={}, sessionUUID={}, sql={}", + connHash, sessionUUID, + request.getSql().length() > 60 ? request.getSql().substring(0, 60) + "..." : request.getSql()); + // Block replica routing only when there is an active transaction on the primary. // A session UUID being present is not sufficient — it may come from a previous // autoCommit SELECT. We check the actual transaction state via hasActiveTransaction() // which does NOT trigger lazy primary connection acquisition. - if (StringUtils.isNotBlank(request.getSession().getSessionUUID())) { + if (StringUtils.isNotBlank(sessionUUID)) { Session existingSession = context.getSessionManager().getSession(request.getSession()); if (existingSession == null) { // Session has expired or been invalidated; fall back to primary to avoid // routing to replica with unknown session state. + // TODO: change to DEBUG before merging + log.info("[RW-SPLIT] session not found for UUID={}, routing to primary", sessionUUID); return null; } if (existingSession.hasActiveTransaction()) { + // TODO: change to DEBUG before merging + log.info("[RW-SPLIT] active transaction on session={}, routing to primary", sessionUUID); return null; // active transaction → must stay on primary } } // Only route read-only SQL to replicas - if (ReadWriteSqlClassifier.classify(request.getSql()) != ReadWriteSqlClassifier.QueryType.READ) { + ReadWriteSqlClassifier.QueryType queryType = ReadWriteSqlClassifier.classify(request.getSql()); + if (queryType != ReadWriteSqlClassifier.QueryType.READ) { + // TODO: change to DEBUG before merging + log.info("[RW-SPLIT] SQL classified as {}, routing to primary (connHash={})", queryType, connHash); return null; } - String connHash = request.getSession().getConnHash(); String primaryName = registry.getPrimaryName(connHash); if (primaryName == null) { + // TODO: change to DEBUG before merging + log.info("[RW-SPLIT] no primary mapping for connHash={}, routing to primary", connHash); return null; } @@ -265,9 +284,15 @@ private DataSource resolveReadReplicaDataSource(ActionContext context, Statement List replicas = registry.getReplicas(primaryName); if (replicas.isEmpty()) { + // TODO: change to DEBUG before merging + log.info("[RW-SPLIT] no replicas registered for primary='{}', routing to primary", primaryName); return null; } - return REPLICA_SELECTOR.select(primaryName, replicas, registry.getStrategy(primaryName)); + DataSource selected = REPLICA_SELECTOR.select(primaryName, replicas, registry.getStrategy(primaryName)); + // TODO: change to DEBUG before merging + log.info("[RW-SPLIT] routed READ to replica for primary='{}', connHash={}, sessionUUID={}", + primaryName, connHash, sessionUUID); + return selected; } } diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceRegistry.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceRegistry.java index a526995e7..f9b68e8b4 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceRegistry.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceRegistry.java @@ -198,7 +198,9 @@ public void markWrite(String primaryName) { return; } lastWriteTimestamps.put(primaryName, System.currentTimeMillis()); - log.debug("Marked write for sticky session on primary '{}'", primaryName); + // TODO: change to DEBUG before merging + log.info("[RW-SPLIT] markWrite: sticky session started for primary='{}', timeout={}s", + primaryName, stickyTimeoutMap.getOrDefault(primaryName, 0)); } /** @@ -219,14 +221,23 @@ public boolean isStickyActive(String primaryName) { } int timeoutSeconds = stickyTimeoutMap.getOrDefault(primaryName, 0); if (timeoutSeconds <= 0) { + // TODO: change to DEBUG before merging + log.info("[RW-SPLIT] isStickyActive: primary='{}', timeoutSeconds={} (disabled), sticky=false", + primaryName, timeoutSeconds); return false; } Long lastWrite = lastWriteTimestamps.get(primaryName); if (lastWrite == null) { + // TODO: change to DEBUG before merging + log.info("[RW-SPLIT] isStickyActive: primary='{}', no write timestamp recorded, sticky=false", primaryName); return false; } long elapsed = System.currentTimeMillis() - lastWrite; - return elapsed < (long) timeoutSeconds * 1000; + boolean active = elapsed < (long) timeoutSeconds * 1000; + // TODO: change to DEBUG before merging + log.info("[RW-SPLIT] isStickyActive: primary='{}', elapsedMs={}, timeoutMs={}, sticky={}", + primaryName, elapsed, (long) timeoutSeconds * 1000, active); + return active; } /** From ceba2f23872bc26f88cd7b9308e57fea2828d588 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 09:50:21 +0000 Subject: [PATCH 18/25] fix: use request connHash in createSession to avoid stale connectionHashMap lookup Agent-Logs-Url: https://github.com/Open-J-Proxy/ojp/sessions/7bec3c2e-6b0a-4146-9e11-8440a9667653 Co-authored-by: rrobetti <7221783+rrobetti@users.noreply.github.com> --- .../grpc/server/SessionManager.java | 14 +++++++++++++ .../grpc/server/SessionManagerImpl.java | 20 +++++++++++++++++++ .../streaming/SessionConnectionHelper.java | 6 ++++-- .../transaction/StartTransactionAction.java | 2 +- 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/SessionManager.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/SessionManager.java index 9c97c78c2..15196f325 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/SessionManager.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/SessionManager.java @@ -18,6 +18,7 @@ public interface SessionManager { void registerClientUUID(String connectionHash, String clientUUID); SessionInfo createSession(String clientUUID, Connection connection); + SessionInfo createSession(String clientUUID, String connHash, Connection connection); /** * Creates a lazy dual-datasource session. No JDBC connections are acquired * at creation time; they are obtained on demand when @@ -30,6 +31,19 @@ public interface SessionManager { * @return the new session info */ SessionInfo createSession(String clientUUID, DataSource primaryDataSource, DataSource replicaDataSource); + /** + * Creates a lazy dual-datasource session with an explicitly supplied connection hash. + * Use this overload when the caller already holds the primary's {@code connHash} + * (e.g. from the driver's request) to avoid relying on the potentially stale + * {@code connectionHashMap} lookup. + * + * @param clientUUID the client identifier + * @param connHash the primary's connection hash (from the driver request) + * @param primaryDataSource datasource for the primary database + * @param replicaDataSource datasource for the read replica; {@code null} when no replica is configured + * @return the new session info + */ + SessionInfo createSession(String clientUUID, String connHash, DataSource primaryDataSource, DataSource replicaDataSource); SessionInfo createXASession(String clientUUID, Connection connection, XAConnection xaConnection); SessionInfo createDeferredXASession(String clientUUID, String connectionHash); Session getSession(SessionInfo sessionInfo); diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/SessionManagerImpl.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/SessionManagerImpl.java index 2777f025e..5c6340284 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/SessionManagerImpl.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/SessionManagerImpl.java @@ -61,6 +61,16 @@ public SessionInfo createSession(String clientUUID, Connection connection) { return session.getSessionInfo(); } + @Override + public SessionInfo createSession(String clientUUID, String connHash, Connection connection) { + log.info("Create session for client uuid {} with connHash {}", clientUUID, connHash); + CacheConfiguration cacheConfig = getCacheConfiguration(connHash); + Session session = new Session(connection, connHash, clientUUID, false, null, cacheConfig); + log.info("Session {} created for client uuid {}", session.getSessionUUID(), clientUUID); + this.sessionMap.put(session.getSessionUUID(), session); + return session.getSessionInfo(); + } + @Override public SessionInfo createSession(String clientUUID, DataSource primaryDataSource, DataSource replicaDataSource) { log.info("Create lazy dual-datasource session for client uuid {}", clientUUID); @@ -72,6 +82,16 @@ public SessionInfo createSession(String clientUUID, DataSource primaryDataSource return session.getSessionInfo(); } + @Override + public SessionInfo createSession(String clientUUID, String connHash, DataSource primaryDataSource, DataSource replicaDataSource) { + log.info("Create lazy dual-datasource session for client uuid {} with connHash {}", clientUUID, connHash); + CacheConfiguration cacheConfig = getCacheConfiguration(connHash); + Session session = new Session(primaryDataSource, replicaDataSource, connHash, clientUUID, cacheConfig); + log.info("Lazy session {} created for client uuid {}", session.getSessionUUID(), clientUUID); + this.sessionMap.put(session.getSessionUUID(), session); + return session.getSessionInfo(); + } + @Override public SessionInfo createXASession(String clientUUID, Connection connection, XAConnection xaConnection) { log.info("Create XA session for client uuid " + clientUUID); diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/action/streaming/SessionConnectionHelper.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/action/streaming/SessionConnectionHelper.java index b56474a86..a1cda83a7 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/action/streaming/SessionConnectionHelper.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/action/streaming/SessionConnectionHelper.java @@ -208,7 +208,8 @@ public static ConnectionSessionDTO sessionConnection(ActionContext context, Sess // obtain the replica connection via session.getOrCreateReplicaConnection(). if (startSessionIfNone) { SessionInfo updatedSession = sessionManager.createSession( - sessionInfo.getClientUUID(), primaryDataSource, replicaDataSource); + sessionInfo.getClientUUID(), sessionInfo.getConnHash(), + primaryDataSource, replicaDataSource); dtoBuilder.session(updatedSession); } // conn remains null — caller must resolve the connection from the session @@ -227,7 +228,8 @@ public static ConnectionSessionDTO sessionConnection(ActionContext context, Sess } if (startSessionIfNone) { - SessionInfo updatedSession = sessionManager.createSession(sessionInfo.getClientUUID(), conn); + SessionInfo updatedSession = sessionManager.createSession( + sessionInfo.getClientUUID(), sessionInfo.getConnHash(), conn); dtoBuilder.session(updatedSession); } } diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/StartTransactionAction.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/StartTransactionAction.java index e4c665c92..89973539d 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/StartTransactionAction.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/StartTransactionAction.java @@ -75,7 +75,7 @@ public void execute(ActionContext context, SessionInfo sessionInfo, StreamObserv // Start a session if none started yet. if (StringUtils.isEmpty(sessionInfo.getSessionUUID())) { Connection conn = context.getDatasourceMap().get(sessionInfo.getConnHash()).getConnection(); - activeSessionInfo = sessionManager.createSession(sessionInfo.getClientUUID(), conn); + activeSessionInfo = sessionManager.createSession(sessionInfo.getClientUUID(), sessionInfo.getConnHash(), conn); // Preserve targetServer from incoming request activeSessionInfo = SessionInfoUtils.withTargetServer(activeSessionInfo, getTargetServer(sessionInfo)); } From 80e3730c9e94694f0012d161cbe7e174644cc286 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 17:50:24 +0000 Subject: [PATCH 19/25] refactor: convert RW-split investigation logs from INFO to DEBUG, remove TODO comments Agent-Logs-Url: https://github.com/Open-J-Proxy/ojp/sessions/b35c4e92-c392-4c8a-8977-dc828b4ef54a Co-authored-by: rrobetti <7221783+rrobetti@users.noreply.github.com> --- .../org/openjproxy/grpc/server/Session.java | 15 ++++------- .../transaction/ExecuteQueryAction.java | 27 +++++++------------ .../ReadWriteDataSourceRegistry.java | 12 +++------ 3 files changed, 18 insertions(+), 36 deletions(-) diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/Session.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/Session.java index 4b8c02535..655a8bf1e 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/Session.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/Session.java @@ -277,17 +277,14 @@ public synchronized Connection getOrCreateReplicaConnection(DataSource fallbackR DataSource ds = (replicaDataSource != null) ? replicaDataSource : fallbackReplicaDs; if (ds != null) { replicaConnection = ds.getConnection(); - // TODO: change to DEBUG before merging - log.info("[RW-SPLIT] getOrCreateReplicaConnection: CREATED new replica connection from {} datasource for session={}", + log.debug("[RW-SPLIT] getOrCreateReplicaConnection: CREATED new replica connection from {} datasource for session={}", (replicaDataSource != null) ? "instance" : "fallback", sessionUUID); } else { - // TODO: change to DEBUG before merging - log.info("[RW-SPLIT] getOrCreateReplicaConnection: no datasource available (replicaDataSource=null, fallback=null) for session={}", + log.debug("[RW-SPLIT] getOrCreateReplicaConnection: no datasource available (replicaDataSource=null, fallback=null) for session={}", sessionUUID); } } else { - // TODO: change to DEBUG before merging - log.info("[RW-SPLIT] getOrCreateReplicaConnection: REUSING existing replica connection for session={}", sessionUUID); + log.debug("[RW-SPLIT] getOrCreateReplicaConnection: REUSING existing replica connection for session={}", sessionUUID); } return replicaConnection; } @@ -306,14 +303,12 @@ public synchronized Connection getOrCreateReplicaConnection(DataSource fallbackR */ public synchronized boolean hasActiveTransaction() { if (primaryConnection == null) { - // TODO: change to DEBUG before merging - log.info("[RW-SPLIT] hasActiveTransaction: primaryConnection is null, returning false for session={}", sessionUUID); + log.debug("[RW-SPLIT] hasActiveTransaction: primaryConnection is null, returning false for session={}", sessionUUID); return false; } try { boolean active = !primaryConnection.getAutoCommit(); - // TODO: change to DEBUG before merging - log.info("[RW-SPLIT] hasActiveTransaction: session={}, autoCommit={}, hasActiveTransaction={}", + log.debug("[RW-SPLIT] hasActiveTransaction: session={}, autoCommit={}, hasActiveTransaction={}", sessionUUID, !active, active); return active; } catch (SQLException e) { diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteQueryAction.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteQueryAction.java index 5b7fc0727..2462bd50e 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteQueryAction.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/action/transaction/ExecuteQueryAction.java @@ -87,13 +87,11 @@ private void executeQueryInternal(ActionContext actionContext, StatementRequest + ". Cannot obtain replica connection."); } execConn = activeSession.getOrCreateReplicaConnection(replicaDs); - // TODO: change to DEBUG before merging - log.info("[RW-SPLIT] executeQueryInternal: using REPLICA connection for sessionUUID={}, connHash={}", + log.debug("[RW-SPLIT] executeQueryInternal: using REPLICA connection for sessionUUID={}, connHash={}", dto.getSession().getSessionUUID(), request.getSession().getConnHash()); } else { execConn = dto.getConnection(); - // TODO: change to DEBUG before merging - log.info("[RW-SPLIT] executeQueryInternal: using PRIMARY connection for sessionUUID={}, connHash={}", + log.debug("[RW-SPLIT] executeQueryInternal: using PRIMARY connection for sessionUUID={}, connHash={}", dto.getSession().getSessionUUID(), request.getSession().getConnHash()); } @@ -236,8 +234,7 @@ private DataSource resolveReadReplicaDataSource(ActionContext context, Statement String sessionUUID = request.getSession().getSessionUUID(); String connHash = request.getSession().getConnHash(); - // TODO: change to DEBUG before merging - log.info("[RW-SPLIT] resolveReadReplicaDataSource: connHash={}, sessionUUID={}, sql={}", + log.debug("[RW-SPLIT] resolveReadReplicaDataSource: connHash={}, sessionUUID={}, sql={}", connHash, sessionUUID, request.getSql().length() > 60 ? request.getSql().substring(0, 60) + "..." : request.getSql()); @@ -250,13 +247,11 @@ private DataSource resolveReadReplicaDataSource(ActionContext context, Statement if (existingSession == null) { // Session has expired or been invalidated; fall back to primary to avoid // routing to replica with unknown session state. - // TODO: change to DEBUG before merging - log.info("[RW-SPLIT] session not found for UUID={}, routing to primary", sessionUUID); + log.debug("[RW-SPLIT] session not found for UUID={}, routing to primary", sessionUUID); return null; } if (existingSession.hasActiveTransaction()) { - // TODO: change to DEBUG before merging - log.info("[RW-SPLIT] active transaction on session={}, routing to primary", sessionUUID); + log.debug("[RW-SPLIT] active transaction on session={}, routing to primary", sessionUUID); return null; // active transaction → must stay on primary } } @@ -264,15 +259,13 @@ private DataSource resolveReadReplicaDataSource(ActionContext context, Statement // Only route read-only SQL to replicas ReadWriteSqlClassifier.QueryType queryType = ReadWriteSqlClassifier.classify(request.getSql()); if (queryType != ReadWriteSqlClassifier.QueryType.READ) { - // TODO: change to DEBUG before merging - log.info("[RW-SPLIT] SQL classified as {}, routing to primary (connHash={})", queryType, connHash); + log.debug("[RW-SPLIT] SQL classified as {}, routing to primary (connHash={})", queryType, connHash); return null; } String primaryName = registry.getPrimaryName(connHash); if (primaryName == null) { - // TODO: change to DEBUG before merging - log.info("[RW-SPLIT] no primary mapping for connHash={}, routing to primary", connHash); + log.debug("[RW-SPLIT] no primary mapping for connHash={}, routing to primary", connHash); return null; } @@ -284,14 +277,12 @@ private DataSource resolveReadReplicaDataSource(ActionContext context, Statement List replicas = registry.getReplicas(primaryName); if (replicas.isEmpty()) { - // TODO: change to DEBUG before merging - log.info("[RW-SPLIT] no replicas registered for primary='{}', routing to primary", primaryName); + log.debug("[RW-SPLIT] no replicas registered for primary='{}', routing to primary", primaryName); return null; } DataSource selected = REPLICA_SELECTOR.select(primaryName, replicas, registry.getStrategy(primaryName)); - // TODO: change to DEBUG before merging - log.info("[RW-SPLIT] routed READ to replica for primary='{}', connHash={}, sessionUUID={}", + log.debug("[RW-SPLIT] routed READ to replica for primary='{}', connHash={}, sessionUUID={}", primaryName, connHash, sessionUUID); return selected; } diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceRegistry.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceRegistry.java index f9b68e8b4..f200b0eb0 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceRegistry.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceRegistry.java @@ -198,8 +198,7 @@ public void markWrite(String primaryName) { return; } lastWriteTimestamps.put(primaryName, System.currentTimeMillis()); - // TODO: change to DEBUG before merging - log.info("[RW-SPLIT] markWrite: sticky session started for primary='{}', timeout={}s", + log.debug("[RW-SPLIT] markWrite: sticky session started for primary='{}', timeout={}s", primaryName, stickyTimeoutMap.getOrDefault(primaryName, 0)); } @@ -221,21 +220,18 @@ public boolean isStickyActive(String primaryName) { } int timeoutSeconds = stickyTimeoutMap.getOrDefault(primaryName, 0); if (timeoutSeconds <= 0) { - // TODO: change to DEBUG before merging - log.info("[RW-SPLIT] isStickyActive: primary='{}', timeoutSeconds={} (disabled), sticky=false", + log.debug("[RW-SPLIT] isStickyActive: primary='{}', timeoutSeconds={} (disabled), sticky=false", primaryName, timeoutSeconds); return false; } Long lastWrite = lastWriteTimestamps.get(primaryName); if (lastWrite == null) { - // TODO: change to DEBUG before merging - log.info("[RW-SPLIT] isStickyActive: primary='{}', no write timestamp recorded, sticky=false", primaryName); + log.debug("[RW-SPLIT] isStickyActive: primary='{}', no write timestamp recorded, sticky=false", primaryName); return false; } long elapsed = System.currentTimeMillis() - lastWrite; boolean active = elapsed < (long) timeoutSeconds * 1000; - // TODO: change to DEBUG before merging - log.info("[RW-SPLIT] isStickyActive: primary='{}', elapsedMs={}, timeoutMs={}, sticky={}", + log.debug("[RW-SPLIT] isStickyActive: primary='{}', elapsedMs={}, timeoutMs={}, sticky={}", primaryName, elapsed, (long) timeoutSeconds * 1000, active); return active; } From 69252291b13fac1e0c1dc221906fa3e53215ac5f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 05:59:53 +0000 Subject: [PATCH 20/25] fix: remove spurious throws InterruptedException from shouldExpireAfterTimeoutElapses test Agent-Logs-Url: https://github.com/Open-J-Proxy/ojp/sessions/1649625d-ea5d-431a-95aa-55bf588c287f Co-authored-by: rrobetti <7221783+rrobetti@users.noreply.github.com> --- .../grpc/server/readwrite/ReadWriteDataSourceRegistryTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ojp-server/src/test/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceRegistryTest.java b/ojp-server/src/test/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceRegistryTest.java index af0296b16..44d2e8e4e 100644 --- a/ojp-server/src/test/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceRegistryTest.java +++ b/ojp-server/src/test/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceRegistryTest.java @@ -77,7 +77,7 @@ void shouldNotBeActiveForNullPrimary() { } @Test - void shouldExpireAfterTimeoutElapses() throws InterruptedException { + void shouldExpireAfterTimeoutElapses() { registry.registerStickyTimeout("primary", 0); // 0 = immediately inactive registry.markWrite("primary"); assertFalse(registry.isStickyActive("primary"), From 18d8313e1c323d97ad6b111605f761bdf80986c2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 06:05:48 +0000 Subject: [PATCH 21/25] docs: clarify LEAST_CONNECTIONS falls back to ROUND_ROBIN, RANDOM is fully implemented Agent-Logs-Url: https://github.com/Open-J-Proxy/ojp/sessions/07ba46cb-1778-411f-be74-4b5a7e2ffb1d Co-authored-by: rrobetti <7221783+rrobetti@users.noreply.github.com> --- documents/configuration/ojp-jdbc-configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documents/configuration/ojp-jdbc-configuration.md b/documents/configuration/ojp-jdbc-configuration.md index a427f6be4..b46a6e9ba 100644 --- a/documents/configuration/ojp-jdbc-configuration.md +++ b/documents/configuration/ojp-jdbc-configuration.md @@ -807,7 +807,7 @@ Use the format `{primaryName}.ojp.readwrite.*` to configure the primary: |---|---|---| | `{primary}.ojp.readwrite.role` | — | Must be `primary` to enable read/write splitting for this datasource | | `{primary}.ojp.readwrite.enabled` | `false` | Enable (`true`) or disable read/write splitting for this primary | -| `{primary}.ojp.readwrite.replicaSelectionStrategy` | `ROUND_ROBIN` | Replica selection strategy: `ROUND_ROBIN`, `RANDOM`, or `LEAST_CONNECTIONS` | +| `{primary}.ojp.readwrite.replicaSelectionStrategy` | `ROUND_ROBIN` | Replica selection strategy: `ROUND_ROBIN` or `RANDOM`. `LEAST_CONNECTIONS` is accepted but currently falls back to `ROUND_ROBIN`; metrics-based selection is planned for a future phase. | | `{primary}.ojp.readwrite.stickySessionSeconds` | `0` | Read-your-writes window in seconds. `0` = disabled (opt-in). After a write, reads continue going to the primary for this many seconds before reverting to replica routing | | `{primary}.ojp.readwrite.replicaFailoverToPrimary` | `true` | Fall back to the primary when no healthy replica is available | From a8043759561179bfa28edc30ea104423e9d33d9f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 06:23:19 +0000 Subject: [PATCH 22/25] test: add OjpSystemPropertiesBridgeTest coverage for read/write splitting properties Agent-Logs-Url: https://github.com/Open-J-Proxy/ojp/sessions/e06b19fd-a052-4b11-852a-96f35ad351c1 Co-authored-by: rrobetti <7221783+rrobetti@users.noreply.github.com> --- .../OjpSystemPropertiesBridgeTest.java | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/spring-boot-starter-ojp/src/test/java/org/openjproxy/autoconfigure/OjpSystemPropertiesBridgeTest.java b/spring-boot-starter-ojp/src/test/java/org/openjproxy/autoconfigure/OjpSystemPropertiesBridgeTest.java index 76498fc23..332d93ae0 100644 --- a/spring-boot-starter-ojp/src/test/java/org/openjproxy/autoconfigure/OjpSystemPropertiesBridgeTest.java +++ b/spring-boot-starter-ojp/src/test/java/org/openjproxy/autoconfigure/OjpSystemPropertiesBridgeTest.java @@ -220,4 +220,81 @@ void shouldSetBothDefaultAndNamedPoolPropertiesFromSameEnvironment() { System.clearProperty(namedProp); } } + + // ---- read/write splitting properties ------------------------------------ + + @Test + void toSystemPropertyKeyShouldHandleReadWriteSplittingKeys() { + // camelCase property names pass through unchanged + assertEquals("primary.ojp.readwrite.enabled", + OjpSystemPropertiesBridge.toSystemPropertyKey("primary.ojp.readwrite.enabled")); + assertEquals("primary.ojp.readwrite.role", + OjpSystemPropertiesBridge.toSystemPropertyKey("primary.ojp.readwrite.role")); + assertEquals("replica1.ojp.readwrite.role", + OjpSystemPropertiesBridge.toSystemPropertyKey("replica1.ojp.readwrite.role")); + assertEquals("replica1.ojp.readwrite.primary", + OjpSystemPropertiesBridge.toSystemPropertyKey("replica1.ojp.readwrite.primary")); + assertEquals("replica1.ojp.connection.url", + OjpSystemPropertiesBridge.toSystemPropertyKey("replica1.ojp.connection.url")); + } + + @Test + void toSystemPropertyKeyShouldConvertKebabCaseReadWriteSplittingKeys() { + // kebab-case variants (as written in application.properties) are converted to camelCase + assertEquals("primary.ojp.readwrite.replicaSelectionStrategy", + OjpSystemPropertiesBridge.toSystemPropertyKey( + "primary.ojp.readwrite.replica-selection-strategy")); + assertEquals("primary.ojp.readwrite.stickySessionSeconds", + OjpSystemPropertiesBridge.toSystemPropertyKey( + "primary.ojp.readwrite.sticky-session-seconds")); + assertEquals("primary.ojp.readwrite.replicaFailoverToPrimary", + OjpSystemPropertiesBridge.toSystemPropertyKey( + "primary.ojp.readwrite.replica-failover-to-primary")); + } + + @Test + void shouldForwardReadWriteSplittingPropertiesAsSystemProperties() { + String propEnabled = "primary.ojp.readwrite.enabled"; + String propRole = "primary.ojp.readwrite.role"; + String propStrategy = "primary.ojp.readwrite.replicaSelectionStrategy"; + String propSticky = "primary.ojp.readwrite.stickySessionSeconds"; + String propR1Role = "replica1.ojp.readwrite.role"; + String propR1Primary = "replica1.ojp.readwrite.primary"; + String propR1Url = "replica1.ojp.connection.url"; + System.clearProperty(propEnabled); + System.clearProperty(propRole); + System.clearProperty(propStrategy); + System.clearProperty(propSticky); + System.clearProperty(propR1Role); + System.clearProperty(propR1Primary); + System.clearProperty(propR1Url); + try { + MockEnvironment env = new MockEnvironment(); + env.setProperty("primary.ojp.readwrite.enabled", "true"); + env.setProperty("primary.ojp.readwrite.role", "primary"); + env.setProperty("primary.ojp.readwrite.replica-selection-strategy", "ROUND_ROBIN"); + env.setProperty("primary.ojp.readwrite.sticky-session-seconds", "5"); + env.setProperty("replica1.ojp.readwrite.role", "replica"); + env.setProperty("replica1.ojp.readwrite.primary", "primary"); + env.setProperty("replica1.ojp.connection.url", "jdbc:postgresql://replica.host/db"); + + new OjpSystemPropertiesBridge(env).applySystemProperties(); + + assertEquals("true", System.getProperty(propEnabled)); + assertEquals("primary", System.getProperty(propRole)); + assertEquals("ROUND_ROBIN", System.getProperty(propStrategy)); + assertEquals("5", System.getProperty(propSticky)); + assertEquals("replica", System.getProperty(propR1Role)); + assertEquals("primary", System.getProperty(propR1Primary)); + assertEquals("jdbc:postgresql://replica.host/db", System.getProperty(propR1Url)); + } finally { + System.clearProperty(propEnabled); + System.clearProperty(propRole); + System.clearProperty(propStrategy); + System.clearProperty(propSticky); + System.clearProperty(propR1Role); + System.clearProperty(propR1Primary); + System.clearProperty(propR1Url); + } + } } From 1e6b0ccb1c192600c8458d639fac7a1fac1491c6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 07:11:57 +0000 Subject: [PATCH 23/25] refactor: clean up H2 test Javadoc and remove setAutoCommit(true) call per review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove stale point (2) "callProxy swallows exceptions" from disabled test Javadoc (fixed in main branch — callProxy no longer swallows exceptions silently) - Simplify testAfterTransactionCommit_ReadsGoToPrimary_WithNoStickySession Javadoc by removing setAutoCommit(true) propagation explanation (not relevant to this test) - Remove connection.setAutoCommit(true) call and its inline comment from same test (test documents autoCommit=false behavior, not setAutoCommit(true) propagation) Agent-Logs-Url: https://github.com/Open-J-Proxy/ojp/sessions/93f6e4d5-1a3b-4b60-8447-bc17def1f9aa Co-authored-by: rrobetti <7221783+rrobetti@users.noreply.github.com> --- .../H2ReadWriteSplittingEndToEndTest.java | 40 ++++++------------- 1 file changed, 13 insertions(+), 27 deletions(-) diff --git a/ojp-jdbc-driver/src/test/java/openjproxy/jdbc/H2ReadWriteSplittingEndToEndTest.java b/ojp-jdbc-driver/src/test/java/openjproxy/jdbc/H2ReadWriteSplittingEndToEndTest.java index 901bfc3ca..39addf889 100644 --- a/ojp-jdbc-driver/src/test/java/openjproxy/jdbc/H2ReadWriteSplittingEndToEndTest.java +++ b/ojp-jdbc-driver/src/test/java/openjproxy/jdbc/H2ReadWriteSplittingEndToEndTest.java @@ -472,13 +472,10 @@ void testTransaction_AllOperationsGoToPrimary() throws SQLException { * Propagating this call is not straightforward and requires careful evaluation. Key concerns * include: (1) the {@link com.openjproxy.grpc.TransactionInfo} embedded in * {@link com.openjproxy.grpc.SessionInfo} would become stale after a {@code callResource} - * invocation (the response does not update {@code TransactionInfo}); (2) the - * {@code callProxy} helper currently swallows exceptions silently, meaning a failed - * server-side {@code setAutoCommit} would leave client and server in inconsistent states; - * (3) implicit-commit semantics on {@code setAutoCommit(true)} vary across database drivers - * and must be validated per supported database; and (4) interaction with the server-side - * connection pool cleanup logic needs to be verified. See the analysis document for full - * details. + * invocation (the response does not update {@code TransactionInfo}); (2) implicit-commit + * semantics on {@code setAutoCommit(true)} vary across database drivers and must be + * validated per supported database; and (3) interaction with the server-side connection pool + * cleanup logic needs to be verified. See the analysis document for full details. * * @see #testAfterTransactionCommit_ReadsGoToPrimary_WithNoStickySession */ @@ -512,20 +509,14 @@ void testAfterTransactionCommit_ReadsGoToReplica_WithNoStickySession() throws SQ } /** - * Documents the current actual behavior: after an explicit transaction is committed and the - * client calls {@code setAutoCommit(true)}, reads continue to go to the primary - * because {@code setAutoCommit(true)} is not propagated to the server. + * Documents the current actual behavior: after an explicit transaction is committed, reads + * continue to go to the primary because the connection remains in + * {@code autoCommit=false} mode and the read/write splitter sees an active transaction. * - *

The server-side physical connection remains in {@code autoCommit=false} mode after the - * transaction is committed. {@link org.openjproxy.grpc.server.Session#hasActiveTransaction()} - * therefore still returns {@code true}, causing the read/write splitter to route all subsequent - * SELECT statements to the primary. The inserted row (id=251) is present on the primary, so - * the count is 1. - * - *

Once {@code setAutoCommit(true)} propagation is correctly implemented and the - * {@code TransactionInfo} state management issues are resolved, this test should be replaced by - * {@link #testAfterTransactionCommit_ReadsGoToReplica_WithNoStickySession} (currently - * disabled). + *

{@link org.openjproxy.grpc.server.Session#hasActiveTransaction()} checks + * {@code !primaryConnection.getAutoCommit()}, which still returns {@code true} after the + * commit, causing all subsequent SELECT statements to be routed to the primary. + * The inserted row (id=251) is present on the primary, so the count is 1. */ @SneakyThrows @Test @@ -542,18 +533,13 @@ void testAfterTransactionCommit_ReadsGoToPrimary_WithNoStickySession() throws SQ connection.commit(); } - // setAutoCommit(true) is NOT propagated to the server. The server's physical connection - // remains in autoCommit=false mode, so hasActiveTransaction() still returns true and - // the SELECT below is routed to the primary rather than the replica. - connection.setAutoCommit(true); - try (Statement stmt = connection.createStatement(); ResultSet rs = stmt.executeQuery( "SELECT COUNT(*) FROM test_data WHERE id = 251")) { assertTrue(rs.next()); assertEquals(1, rs.getInt(1), - "Current behavior: setAutoCommit(true) is not propagated to the server, " - + "so hasActiveTransaction() remains true and SELECT routes to primary " + "Current behavior: connection is in autoCommit=false mode, " + + "so hasActiveTransaction() returns true and SELECT routes to primary " + "which has the newly inserted row"); } } From 07c94c588ee25e7ecbe556e18cf4ec2f8c12abd4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:03:56 +0000 Subject: [PATCH 24/25] refactor: extract ParsedConfig holder + parseReadWriteConfig helper in ReadWriteDataSourceManager convertPropertiesToJava is now called from a single private method (parseReadWriteConfig) that both converts and parses, returning the result in a private ParsedConfig holder class. Both public methods (isReadWriteSplittingEnabled and setupReadWriteSplitting) call this helper, eliminating the duplicated convert+parse pattern. All 730 server unit tests pass; checkstyle clean. Agent-Logs-Url: https://github.com/Open-J-Proxy/ojp/sessions/8346b297-6986-4fe4-bac1-9a1fc4e3977a Co-authored-by: rrobetti <7221783+rrobetti@users.noreply.github.com> --- .../readwrite/ReadWriteDataSourceManager.java | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceManager.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceManager.java index 7846af049..5f03494b7 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceManager.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceManager.java @@ -37,8 +37,7 @@ public boolean isReadWriteSplittingEnabled(ConnectionDetails connectionDetails, return false; } - Properties props = convertPropertiesToJava(connectionDetails.getPropertiesList()); - ReadWriteConfiguration config = ReadWriteConfigurationParser.parseForPrimary(datasourceName, props); + ReadWriteConfiguration config = parseReadWriteConfig(connectionDetails, datasourceName).config; return config != null && config.isEnabled() && !config.getReplicaNames().isEmpty(); } @@ -71,9 +70,10 @@ public ReadWriteConfiguration setupReadWriteSplitting( return null; } - // Parse configuration - Properties props = convertPropertiesToJava(connectionDetails.getPropertiesList()); - ReadWriteConfiguration config = ReadWriteConfigurationParser.parseForPrimary(datasourceName, props); + // Parse configuration (converts gRPC properties to Java Properties once) + ParsedConfig parsed = parseReadWriteConfig(connectionDetails, datasourceName); + ReadWriteConfiguration config = parsed.config; + Properties props = parsed.props; if (config == null || !config.isEnabled()) { log.debug("Read/write splitting not enabled for datasource '{}'", datasourceName); @@ -180,6 +180,27 @@ private DataSource createReplicaDataSource(String replicaName, Properties props) } } + /** + * Converts gRPC PropertyEntry list to Java Properties object and parses the read/write configuration. + * Centralises the conversion so it is performed exactly once per public API call. + */ + private ParsedConfig parseReadWriteConfig(ConnectionDetails connectionDetails, String datasourceName) { + Properties props = convertPropertiesToJava(connectionDetails.getPropertiesList()); + ReadWriteConfiguration config = ReadWriteConfigurationParser.parseForPrimary(datasourceName, props); + return new ParsedConfig(config, props); + } + + /** Holder for a parsed {@link ReadWriteConfiguration} together with the already-converted properties. */ + private static final class ParsedConfig { + private final ReadWriteConfiguration config; + private final Properties props; + + ParsedConfig(ReadWriteConfiguration config, Properties props) { + this.config = config; + this.props = props; + } + } + /** * Converts gRPC PropertyEntry list to Java Properties object. */ From 0b1a88f1e191ce0818150cb2ce08136d5db7c1b4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:06:32 +0000 Subject: [PATCH 25/25] chore: fix spelling centralises -> centralizes in javadoc comment Agent-Logs-Url: https://github.com/Open-J-Proxy/ojp/sessions/8346b297-6986-4fe4-bac1-9a1fc4e3977a Co-authored-by: rrobetti <7221783+rrobetti@users.noreply.github.com> --- .../grpc/server/readwrite/ReadWriteDataSourceManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceManager.java b/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceManager.java index 5f03494b7..a77c02343 100644 --- a/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceManager.java +++ b/ojp-server/src/main/java/org/openjproxy/grpc/server/readwrite/ReadWriteDataSourceManager.java @@ -182,7 +182,7 @@ private DataSource createReplicaDataSource(String replicaName, Properties props) /** * Converts gRPC PropertyEntry list to Java Properties object and parses the read/write configuration. - * Centralises the conversion so it is performed exactly once per public API call. + * Centralizes the conversion so it is performed exactly once per public API call. */ private ParsedConfig parseReadWriteConfig(ConnectionDetails connectionDetails, String datasourceName) { Properties props = convertPropertiesToJava(connectionDetails.getPropertiesList());