From 9c5ceef4df3a0e6ae152c967f6216d0e0f0606fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Knut=20Olav=20L=C3=B8ite?= Date: Tue, 11 Aug 2026 14:56:56 +0200 Subject: [PATCH] perf(spanner-jdbc): cache positional to named param conversion Cache the result of converting a JDBC SQL string containing positional parameters (?), so we do not need to do this conversion over and over each time a SQL string is executed. --- .../connection/AbstractStatementParser.java | 43 ++++++++++++++++++- .../connection/StatementParserTest.java | 39 +++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/AbstractStatementParser.java b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/AbstractStatementParser.java index fea032e2f525..caf6286cce37 100644 --- a/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/AbstractStatementParser.java +++ b/java-spanner/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/AbstractStatementParser.java @@ -483,7 +483,13 @@ private static boolean isRecordStatementCacheStats() { * Cache for parsed statements. This prevents statements that are executed multiple times by the * application to be parsed over and over again. The default maximum size is 5Mb. */ - private final Cache statementCache; + @Nullable private final Cache statementCache; + + /** + * Cache for positional parameters info. This prevents statements that are executed multiple times + * using positional parameters from having to be scanned and translated repeatedly. + */ + @Nullable private final Cache positionalParametersCache; AbstractStatementParser(Set statements) { this.statements = Collections.unmodifiableSet(statements); @@ -502,16 +508,36 @@ private static boolean isRecordStatementCacheStats() { cacheBuilder.recordStats(); } this.statementCache = cacheBuilder.build(); + + CacheBuilder positionalCacheBuilder = + CacheBuilder.newBuilder() + .maximumWeight(maxCacheSize * 1024L * 1024L) + .weigher( + (String key, ParametersInfo value) -> + 2 * key.length() + 2 * value.sqlWithNamedParameters.length()) + .concurrencyLevel(Runtime.getRuntime().availableProcessors()); + if (isRecordStatementCacheStats()) { + positionalCacheBuilder.recordStats(); + } + this.positionalParametersCache = positionalCacheBuilder.build(); } else { this.statementCache = null; + this.positionalParametersCache = null; } } @VisibleForTesting + @Nullable CacheStats getStatementCacheStats() { return statementCache == null ? null : statementCache.stats(); } + @VisibleForTesting + @Nullable + CacheStats getPositionalParametersCacheStats() { + return positionalParametersCache == null ? null : positionalParametersCache.stats(); + } + @VisibleForTesting Set getClientSideStatements() { return statements; @@ -776,8 +802,21 @@ public static class ParametersInfo { @InternalApi public ParametersInfo convertPositionalParametersToNamedParameters(char paramChar, String sql) { Preconditions.checkNotNull(sql); + if (positionalParametersCache == null) { + return internalConvertPositionalParametersToNamedParameters(paramChar, sql); + } + String cacheKey = paramChar == '?' ? sql : paramChar + "\0" + sql; + ParametersInfo info = positionalParametersCache.getIfPresent(cacheKey); + if (info == null) { + info = internalConvertPositionalParametersToNamedParameters(paramChar, sql); + positionalParametersCache.put(cacheKey, info); + } + return info; + } + + ParametersInfo internalConvertPositionalParametersToNamedParameters(char paramChar, String sql) { final String namedParamPrefix = getQueryParameterPrefix(); - StringBuilder named = new StringBuilder(sql.length() + countOccurrencesOf(paramChar, sql)); + StringBuilder named = new StringBuilder(sql.length() + 32); int index = 0; int paramIndex = 1; while (index < sql.length()) { diff --git a/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/StatementParserTest.java b/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/StatementParserTest.java index 300517faaf0c..9b7bc55ec24f 100644 --- a/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/StatementParserTest.java +++ b/java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/StatementParserTest.java @@ -20,6 +20,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -29,6 +30,7 @@ import com.google.cloud.spanner.ErrorCode; import com.google.cloud.spanner.SpannerException; import com.google.cloud.spanner.Statement; +import com.google.cloud.spanner.connection.AbstractStatementParser.ParametersInfo; import com.google.cloud.spanner.connection.AbstractStatementParser.ParsedStatement; import com.google.cloud.spanner.connection.AbstractStatementParser.StatementType; import com.google.cloud.spanner.connection.ClientSideStatementImpl.CompileException; @@ -1864,6 +1866,43 @@ public void testStatementCache_ParameterizedStatement() { assertEquals(1, stats.hitCount()); } + @Test + public void testPositionalParametersCache() { + CacheStats statsBefore = parser.getPositionalParametersCacheStats(); + + String sql = "select * from foo where id=? and name=? and value=" + UUID.randomUUID(); + ParametersInfo info1 = parser.convertPositionalParametersToNamedParameters('?', sql); + assertEquals(2, info1.numberOfParameters); + if (dialect == Dialect.POSTGRESQL) { + assertTrue(info1.sqlWithNamedParameters.contains("$1")); + assertTrue(info1.sqlWithNamedParameters.contains("$2")); + } else { + assertTrue(info1.sqlWithNamedParameters.contains("@p1")); + assertTrue(info1.sqlWithNamedParameters.contains("@p2")); + } + + ParametersInfo info2 = parser.convertPositionalParametersToNamedParameters('?', sql); + assertEquals(info1.numberOfParameters, info2.numberOfParameters); + assertEquals(info1.sqlWithNamedParameters, info2.sqlWithNamedParameters); + assertSame(info1, info2); + + // Test with non-'?' parameter character. + String sqlDollar = "select * from foo where id=$ and name=$ and value=" + UUID.randomUUID(); + ParametersInfo infoDollar1 = + parser.convertPositionalParametersToNamedParameters('$', sqlDollar); + assertEquals(2, infoDollar1.numberOfParameters); + ParametersInfo infoDollar2 = + parser.convertPositionalParametersToNamedParameters('$', sqlDollar); + assertSame(infoDollar1, infoDollar2); + + CacheStats statsAfter = parser.getPositionalParametersCacheStats(); + CacheStats stats = statsAfter.minus(statsBefore); + + // Two distinct queries had cache misses. Two repeated queries had cache hits. + assertEquals(2, stats.missCount()); + assertEquals(2, stats.hitCount()); + } + @Test public void testClientSideStatementWithComment() { String sql = "-- Null (no timeout)\n" + "SET STATEMENT_TIMEOUT=null";