diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/exception/BigQueryConversionException.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/exception/BigQueryConversionException.java index ea78a4f11330..f8bc25442a78 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/exception/BigQueryConversionException.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/exception/BigQueryConversionException.java @@ -24,6 +24,9 @@ public class BigQueryConversionException extends SQLException { public BigQueryConversionException(String message, Throwable cause) { - super(BigQueryJdbcExceptionUtils.formatMessage(message, cause), cause); + super( + BigQueryJdbcExceptionUtils.formatMessage(message, cause), + BigQueryJdbcSqlStates.DATA_EXCEPTION, + cause); } } diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/exception/BigQueryJdbcCoercionException.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/exception/BigQueryJdbcCoercionException.java deleted file mode 100644 index defbbfcca784..000000000000 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/exception/BigQueryJdbcCoercionException.java +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.cloud.bigquery.exception; - -import com.google.api.core.InternalApi; - -/** - * Thrown to indicate that the coercion was attempted but couldn't be performed successfully because - * of some error. - */ -@InternalApi -public class BigQueryJdbcCoercionException extends RuntimeException { - - /** - * Construct a new exception with the specified cause. - * - * @param cause the actual cause which was thrown while performing the coercion. - */ - public BigQueryJdbcCoercionException(Exception cause) { - super(BigQueryJdbcExceptionUtils.formatMessage("Coercion error", cause), cause); - } -} diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/exception/BigQueryJdbcCoercionNotFoundException.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/exception/BigQueryJdbcCoercionNotFoundException.java deleted file mode 100644 index b4eafb2ee583..000000000000 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/exception/BigQueryJdbcCoercionNotFoundException.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.cloud.bigquery.exception; - -import com.google.api.core.InternalApi; - -/** - * Thrown to indicate that the current TypeCoercer can not perform the coercion as the Coercion - * implementation is not registered for the mentioned source and target type. - */ -@InternalApi -public class BigQueryJdbcCoercionNotFoundException extends RuntimeException { - - /** - * Construct a new exception. - * - * @param source the source type. - * @param target the target type. - */ - public BigQueryJdbcCoercionNotFoundException(Class source, Class target) { - super( - String.format( - "Coercion not found for [%s -> %s] conversion", - source.getCanonicalName(), target.getCanonicalName())); - } -} diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/exception/BigQueryJdbcException.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/exception/BigQueryJdbcException.java index a6262b167db4..f47770931c39 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/exception/BigQueryJdbcException.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/exception/BigQueryJdbcException.java @@ -28,7 +28,7 @@ public class BigQueryJdbcException extends SQLException { * @param message The detail message. */ public BigQueryJdbcException(String message) { - super(message); + super(message, BigQueryJdbcSqlStates.GENERAL_ERROR); } /** @@ -37,7 +37,7 @@ public BigQueryJdbcException(String message) { * @param ex The InterruptedException to be thrown. */ public BigQueryJdbcException(InterruptedException ex) { - super(ex); + super(ex.getMessage(), BigQueryJdbcSqlStates.QUERY_CANCELED, ex); } /** @@ -47,7 +47,10 @@ public BigQueryJdbcException(InterruptedException ex) { * @param ex The BigQueryException to be thrown. */ public BigQueryJdbcException(String message, BigQueryException ex) { - super(BigQueryJdbcExceptionUtils.formatMessage(message, ex), ex); + super( + BigQueryJdbcExceptionUtils.formatMessage(message, ex), + BigQueryJdbcExceptionUtils.sqlStateForCause(ex), + ex); this.bigQueryException = ex; } @@ -58,7 +61,10 @@ public BigQueryJdbcException(String message, BigQueryException ex) { * @param cause Throwable that is being converted. */ public BigQueryJdbcException(String message, Throwable cause) { - super(BigQueryJdbcExceptionUtils.formatMessage(message, cause), cause); + super( + BigQueryJdbcExceptionUtils.formatMessage(message, cause), + BigQueryJdbcExceptionUtils.sqlStateForCause(cause), + cause); } /** @@ -68,7 +74,10 @@ public BigQueryJdbcException(String message, Throwable cause) { * @param cause Throwable that is being converted. */ public BigQueryJdbcException(Throwable cause) { - super(cause); + super( + cause == null ? null : cause.getMessage(), + BigQueryJdbcExceptionUtils.sqlStateForCause(cause), + cause); } public BigQueryException getBigQueryException() { diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/exception/BigQueryJdbcExceptionUtils.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/exception/BigQueryJdbcExceptionUtils.java index 2ec3788d3c00..04632e1871a3 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/exception/BigQueryJdbcExceptionUtils.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/exception/BigQueryJdbcExceptionUtils.java @@ -16,6 +16,8 @@ package com.google.cloud.bigquery.exception; +import com.google.cloud.bigquery.BigQueryException; + /** Utility class for JDBC exceptions. */ final class BigQueryJdbcExceptionUtils { @@ -37,4 +39,49 @@ public static String formatMessage(String message, Throwable cause) { ? "\n" + (cause.getMessage() != null ? cause.getMessage() : cause.toString()) : ""); } + + /** + * Maps a cause to a standard SQL:2003 SQLState. + * + *

Returns {@code HY000} (general error) for anything unrecognised, so the result is always a + * valid 5-character state and never null. + * + * @param cause the underlying cause, may be null. + * @return a 5-character SQLState. + */ + static String sqlStateForCause(Throwable cause) { + if (!(cause instanceof BigQueryException)) { + return BigQueryJdbcSqlStates.GENERAL_ERROR; + } + String reason = ((BigQueryException) cause).getReason(); + if (reason == null) { + return BigQueryJdbcSqlStates.GENERAL_ERROR; + } + switch (reason) { + case "invalidQuery": + case "invalid": + case "badRequest": + return BigQueryJdbcSqlStates.SYNTAX_ERROR_OR_ACCESS_RULE_VIOLATION; + case "accessDenied": + return BigQueryJdbcSqlStates.INSUFFICIENT_PRIVILEGE; + case "invalidUser": + return BigQueryJdbcSqlStates.INVALID_AUTHORIZATION; + case "quotaExceeded": + case "rateLimitExceeded": + case "resourcesExceeded": + return BigQueryJdbcSqlStates.INSUFFICIENT_RESOURCES; + case "responseTooLarge": + return BigQueryJdbcSqlStates.PROGRAM_LIMIT_EXCEEDED; + case "stopped": + return BigQueryJdbcSqlStates.QUERY_CANCELED; + case "backendError": + case "internalError": + case "jobInternalError": + return BigQueryJdbcSqlStates.SYSTEM_ERROR; + case "notImplemented": + return BigQueryJdbcSqlStates.FEATURE_NOT_SUPPORTED; + default: + return BigQueryJdbcSqlStates.GENERAL_ERROR; + } + } } diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/exception/BigQueryJdbcSqlFeatureNotSupportedException.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/exception/BigQueryJdbcSqlFeatureNotSupportedException.java index 8039dcd53495..da84cc4abd52 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/exception/BigQueryJdbcSqlFeatureNotSupportedException.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/exception/BigQueryJdbcSqlFeatureNotSupportedException.java @@ -27,7 +27,7 @@ public class BigQueryJdbcSqlFeatureNotSupportedException extends SQLFeatureNotSu * @param message The detail message. */ public BigQueryJdbcSqlFeatureNotSupportedException(String message) { - super(message); + super(message, BigQueryJdbcSqlStates.FEATURE_NOT_SUPPORTED); } /** @@ -36,6 +36,6 @@ public BigQueryJdbcSqlFeatureNotSupportedException(String message) { * @param ex The BigQueryException to be thrown. */ public BigQueryJdbcSqlFeatureNotSupportedException(BigQueryException ex) { - super(ex); + super(ex.getMessage(), BigQueryJdbcSqlStates.FEATURE_NOT_SUPPORTED, ex); } } diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/exception/BigQueryJdbcSqlStates.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/exception/BigQueryJdbcSqlStates.java new file mode 100644 index 000000000000..ad7eb7e309c6 --- /dev/null +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/exception/BigQueryJdbcSqlStates.java @@ -0,0 +1,64 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.cloud.bigquery.exception; + +/** + * Standard SQL:2003 SQLState codes used by the driver. + * + *

A SQLState is a 5-character code: a 2-character class followed by a 3-character subclass. + * These values are defined by the SQL standard and are portable across databases; nothing here is + * BigQuery-specific. {@code BigQueryDatabaseMetaData.getSQLStateType()} declares that the driver + * emits SQL:2003 states, so do not mix in X/Open or ODBC-only codes. + */ +final class BigQueryJdbcSqlStates { + + /** 08 — connection exception. */ + static final String CONNECTION_EXCEPTION = "08006"; + + /** 0A — feature not supported. */ + static final String FEATURE_NOT_SUPPORTED = "0A000"; + + /** 22 — data exception (bad value, failed conversion). */ + static final String DATA_EXCEPTION = "22000"; + + /** 28 — invalid authorization specification (authentication failed). */ + static final String INVALID_AUTHORIZATION = "28000"; + + /** 42 — syntax error or access rule violation. */ + static final String SYNTAX_ERROR_OR_ACCESS_RULE_VIOLATION = "42000"; + + static final String INSUFFICIENT_PRIVILEGE = "42501"; + + /** 53 — insufficient resources. */ + static final String INSUFFICIENT_RESOURCES = "53000"; + + /** 54 — program limit exceeded. */ + static final String PROGRAM_LIMIT_EXCEEDED = "54000"; + + /** 57 — operator intervention. */ + static final String QUERY_CANCELED = "57014"; + + /** 58 — system error. */ + static final String SYSTEM_ERROR = "58000"; + + /** HY — general error; the fallback when nothing more specific applies. */ + static final String GENERAL_ERROR = "HY000"; + + private BigQueryJdbcSqlStates() { + // Utility class, prevent instantiation + } +} diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/exception/BigQueryJdbcSqlSyntaxErrorException.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/exception/BigQueryJdbcSqlSyntaxErrorException.java index 6d51e656542a..4006b4187254 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/exception/BigQueryJdbcSqlSyntaxErrorException.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/exception/BigQueryJdbcSqlSyntaxErrorException.java @@ -32,10 +32,13 @@ public class BigQueryJdbcSqlSyntaxErrorException extends SQLSyntaxErrorException * @param ex The BigQueryException to be thrown. */ public BigQueryJdbcSqlSyntaxErrorException(BigQueryException ex) { - super(ex.getMessage(), "Incorrect SQL syntax."); + super(ex.getMessage(), BigQueryJdbcSqlStates.SYNTAX_ERROR_OR_ACCESS_RULE_VIOLATION, ex); } public BigQueryJdbcSqlSyntaxErrorException(String message, BigQueryException ex) { - super(BigQueryJdbcExceptionUtils.formatMessage(message, ex), ex); + super( + BigQueryJdbcExceptionUtils.formatMessage(message, ex), + BigQueryJdbcSqlStates.SYNTAX_ERROR_OR_ACCESS_RULE_VIOLATION, + ex); } } diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcContextProxy.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcContextProxy.java index 360fba0a8ed1..c6e25df46c46 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcContextProxy.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcContextProxy.java @@ -16,6 +16,7 @@ package com.google.cloud.bigquery.jdbc; +import com.google.cloud.bigquery.jdbc.telemetry.v1.TelemetryManager; import java.lang.reflect.InvocationHandler; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; @@ -177,6 +178,11 @@ public Object invoke(Object proxy, Method method, Object[] args) throws Throwabl LOG.severe("Exception occurred during " + methodName + ": " + errMsg, cause); } + TelemetryManager.recordError( + TelemetryManager.extractErrorCode(cause), + TelemetryManager.extractXdbcCode(cause), + methodName); + throw cause; } } diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/telemetry/v1/DriverEnvironmentBuilder.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/telemetry/v1/DriverEnvironmentDetector.java similarity index 96% rename from java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/telemetry/v1/DriverEnvironmentBuilder.java rename to java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/telemetry/v1/DriverEnvironmentDetector.java index 2e8fff6cf9f6..6479ebe7c688 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/telemetry/v1/DriverEnvironmentBuilder.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/telemetry/v1/DriverEnvironmentDetector.java @@ -27,17 +27,17 @@ import java.util.logging.Logger; /** Utility builder for constructing {@link DriverEnvironment} telemetry protos. */ -final class DriverEnvironmentBuilder { - private static final Logger logger = Logger.getLogger(DriverEnvironmentBuilder.class.getName()); +final class DriverEnvironmentDetector { + private static final Logger logger = Logger.getLogger(DriverEnvironmentDetector.class.getName()); - static final String DRIVER_NAME = "google-bigquery-jdbc-driver"; + static final String DRIVER_NAME = "Google-BigQuery-JDBC-Driver"; static final String CLIENT_LANGUAGE = "java"; static final String DEFAULT_TELEMETRY_TAG_DIR = ".bigquery-jdbc"; static final String DEFAULT_TELEMETRY_TAG_FILE = "telemetry-tag"; static final String UNKNOWN = "unknown"; static final String RESTRICTED = "restricted"; - private DriverEnvironmentBuilder() {} + private DriverEnvironmentDetector() {} static DriverEnvironment build() { return build(null); @@ -171,7 +171,7 @@ static String getOrCreateTelemetryTag(Path customFilePath) { logger.log(Level.WARNING, "Failed to persist telemetry tag to file", e); } return newId; - } catch (SecurityException e) { + } catch (RuntimeException e) { return UUID.randomUUID().toString(); } } diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TelemetryBatcher.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TelemetryBatcher.java index 39e28963cd02..acdb32efc582 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TelemetryBatcher.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TelemetryBatcher.java @@ -39,7 +39,10 @@ final class TelemetryBatcher implements AutoCloseable { private static final Logger logger = new BigQueryJdbcCustomLogger(TelemetryBatcher.class.getName()); - private static final int MAX_UNIQUE_PROFILES = 3000; + private static final int PROFILE_CAP_MULTIPLIER = 2; + + /** Cap used when no usable threshold is configured. */ + private static final int FALLBACK_PROFILE_CAP = 3000; private final TelemetryConfiguration config; private final ClearcutTransport transport; @@ -52,6 +55,12 @@ final class TelemetryBatcher implements AutoCloseable { private ConcurrentHashMap metricsMap = new ConcurrentHashMap<>(); + /** Derived from {@code batchSizeThreshold}; see {@link #PROFILE_CAP_MULTIPLIER}. */ + private final int maxUniqueProfiles; + + /** Set while the last flush failed, to stop the size trigger hammering a failing endpoint. */ + private volatile boolean backoffActive; + private final AtomicBoolean isClosed = new AtomicBoolean(false); private final AtomicLong currentScheduleDelayMs = new AtomicLong(-1); private ScheduledFuture scheduledTask; @@ -75,12 +84,28 @@ final class TelemetryBatcher implements AutoCloseable { this.driverEnvironment = config != null ? config.getDriverEnvironment() : null; this.executorService = executorService; this.ownsExecutor = ownsExecutor; + this.maxUniqueProfiles = computeProfileCap(config); if (this.config != null && this.config.isEnabled()) { reschedule(this.config.getUploadIntervalMs()); } } + /** + * Derives the hard profile cap from the configured queue-size threshold. Package-private so that + * the invariant {@code threshold < cap} can be asserted directly in tests. + */ + static int computeProfileCap(TelemetryConfiguration config) { + if (config == null) { + return FALLBACK_PROFILE_CAP; + } + int threshold = config.getBatchSizeThreshold(); + if (threshold <= 0) { + return FALLBACK_PROFILE_CAP; + } + return (int) Math.min((long) threshold * PROFILE_CAP_MULTIPLIER, Integer.MAX_VALUE); + } + private static ScheduledExecutorService createDefaultExecutor() { return Executors.newSingleThreadScheduledExecutor( r -> { @@ -133,13 +158,33 @@ void offer(Message metric, long durationMs) { if (acc != null) { acc.accumulate(durationMs); } + maybeFlushOnSize(); + } + + /** + * Flushes out of band once the pending queue reaches {@code batchSizeThreshold} entries, so a + * batch is dispatched on size as well as on the scheduled interval. Always dispatched to the + * batcher thread so that no JDBC caller thread performs network I/O. + */ + private void maybeFlushOnSize() { + if (backoffActive) { + return; + } + int threshold = config != null ? config.getBatchSizeThreshold() : 0; + if (threshold <= 0 || metricsMap.size() < threshold) { + return; + } + if (executorService == null || executorService.isShutdown()) { + return; + } + executorService.execute(this::flush); } private A getOrAddAccumulator(TelemetryKey key) { if (isClosed.get() || !isConfigured()) { return null; } - if (metricsMap.size() >= MAX_UNIQUE_PROFILES && !metricsMap.containsKey(key)) { + if (metricsMap.size() >= maxUniqueProfiles && !metricsMap.containsKey(key)) { return null; } return (A) metricsMap.computeIfAbsent(key, TelemetryKey::createAccumulator); @@ -184,8 +229,12 @@ TransportResult flush() { } if (!result.isSuccess()) { + // Suppress the size trigger until a flush succeeds; reschedule() below spaces the retries. + backoffActive = true; // Simple requeue logic for failed requests remergeFailedMetrics(snapMetrics); + } else { + backoffActive = false; } long uploadIntervalMs = config != null ? config.getUploadIntervalMs() : 300_000L; diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TelemetryManager.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TelemetryManager.java index 8e742d934ec1..5ff917dcf967 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TelemetryManager.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TelemetryManager.java @@ -81,7 +81,10 @@ public static TelemetryManager getInstance(Properties properties) { return localRef; } TelemetryConfiguration config = - TelemetryConfiguration.builder().resolveProperties(properties).build(); + TelemetryConfiguration.builder() + .resolveProperties(properties) + .setDriverEnvironment(DriverEnvironmentDetector.build()) + .build(); ClearcutTransport transport = new ClearcutTransport(config); TelemetryBatcher batcher = new TelemetryBatcher(config, transport); localRef = new TelemetryManager(batcher); @@ -306,6 +309,32 @@ public static int extractErrorCode(Throwable t) { return 1000; } + /** + * Extracts a numeric form of the SQLState from the throwable chain. SQLStates are five-character + * strings; the digits-only ones (for example {@code 42000}) carry directly, while states + * containing letters (for example {@code HY000}) have no numeric form and yield {@code 0}. + * + *

Returns {@code 0} when no SQLState is present or none is numeric, matching the proto default + * for {@code error_xdbc_code} so that unset and unmappable are indistinguishable downstream. + */ + public static int extractXdbcCode(Throwable t) { + int depth = 0; + while (t != null && depth++ < 20) { + if (t instanceof SQLException) { + String sqlState = ((SQLException) t).getSQLState(); + if (sqlState != null && !sqlState.isEmpty()) { + try { + return Integer.parseInt(sqlState); + } catch (NumberFormatException ignored) { + // Alphabetic SQLState such as HY000 — no numeric form, keep walking the chain. + } + } + } + t = t.getCause(); + } + return 0; + } + /** * Registers the JVM shutdown hook that flushes pending telemetry. * diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/exception/BigQueryJdbcExceptionTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/exception/BigQueryJdbcExceptionTest.java index f13ace2f140f..7e051c2275ea 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/exception/BigQueryJdbcExceptionTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/exception/BigQueryJdbcExceptionTest.java @@ -40,9 +40,6 @@ static Stream exceptionProvider() { Arguments.of((ExceptionCreator) BigQueryJdbcException::new), Arguments.of((ExceptionCreator) BigQueryJdbcRuntimeException::new), Arguments.of((ExceptionCreator) BigQueryConversionException::new), - Arguments.of( - (ExceptionCreator) - (msg, cause) -> new BigQueryJdbcCoercionException((Exception) cause)), Arguments.of( (ExceptionCreator) (msg, cause) -> @@ -57,9 +54,7 @@ public void testExceptionMessageFormatting(ExceptionCreator creator) { Throwable ex = creator.create(message, cause); - String expectedPrefix = - ex instanceof BigQueryJdbcCoercionException ? "Coercion error" : message; - String expectedMessage = expectedPrefix + "\n" + cause.getMessage(); + String expectedMessage = message + "\n" + cause.getMessage(); assertEquals(expectedMessage, ex.getMessage()); assertEquals(cause, ex.getCause()); diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcContextProxyTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcContextProxyTest.java index 6cb46cb8de21..632b975a0b76 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcContextProxyTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcContextProxyTest.java @@ -20,6 +20,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; @@ -122,4 +123,16 @@ public void testWrapWithNullContextAndExceptionThrown() throws SQLException { assertEquals("Database error", ex.getMessage()); assertNull(BigQueryJdbcMdc.getConnectionId()); } + + @Test + public void testInvoke_rethrowsCauseUnchangedAfterTelemetry() throws Exception { + Statement failing = mock(Statement.class); + SQLException expected = new SQLException("bad syntax", "42000", 404); + when(failing.execute("SELECT 1")).thenThrow(expected); + Statement proxy = BigQueryJdbcContextProxy.wrap(failing, Statement.class, "conn-1"); + SQLException actual = assertThrows(SQLException.class, () -> proxy.execute("SELECT 1")); + assertSame(expected, actual); + assertEquals("42000", actual.getSQLState()); + assertEquals(404, actual.getErrorCode()); + } } diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/ClearcutTransportTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/ClearcutTransportTest.java index 26b078c91a8e..a17ec2b4cf0a 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/ClearcutTransportTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/ClearcutTransportTest.java @@ -25,8 +25,10 @@ import com.google.api.client.testing.http.MockHttpTransport; import com.google.api.client.testing.http.MockLowLevelHttpRequest; import com.google.api.client.testing.http.MockLowLevelHttpResponse; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; public class ClearcutTransportTest { @@ -208,4 +210,44 @@ public LowLevelHttpResponse execute() { assertEquals(1, requestCount.get()); assertEquals(10, result.getNextRequestWaitMillis()); } + + @Test + public void testRecordError_reachesPayload() throws Exception { + AtomicReference captured = new AtomicReference<>(); + MockHttpTransport mockTransport = + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + getStreamingContent().writeTo(out); + captured.set(out.toByteArray()); + MockLowLevelHttpResponse response = new MockLowLevelHttpResponse(); + response.setStatusCode(200); + return response; + } + }; + } + }; + TelemetryConfiguration config = + TelemetryConfiguration.newBuilder() + .setEnabled(true) + .setDriverEnvironment( + DriverEnvironment.newBuilder().setDriverName("SimulatedDriver").build()) + .build(); + TelemetryManager.init(config, new ClearcutTransport(mockTransport, config)); + TelemetryManager.recordError(404, 42000, "executeQuery"); + TelemetryManager.getInstance().getBatcher().flush(); + LogRequest request = LogRequest.parseFrom(captured.get()); + TelemetryPayload payload = + TelemetryPayload.parseFrom(request.getLogEvents(0).getSourceExtension()); + assertEquals(1, payload.getErrorsCount()); + ErrorMetric error = payload.getErrors(0); + assertEquals(404, error.getErrorCode()); + assertEquals(42000, error.getErrorXdbcCode()); + assertEquals("executeQuery", error.getMethodName()); + assertEquals(1, error.getCount()); + } } diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/DriverEnvironmentBuilderTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/DriverEnvironmentDetectorTest.java similarity index 63% rename from java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/DriverEnvironmentBuilderTest.java rename to java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/DriverEnvironmentDetectorTest.java index 835db26e08fc..7d20f5d7166f 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/DriverEnvironmentBuilderTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/DriverEnvironmentDetectorTest.java @@ -29,13 +29,13 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -public class DriverEnvironmentBuilderTest { +public class DriverEnvironmentDetectorTest { @Test public void testBuildDriverEnvironment() { - DriverEnvironment env = DriverEnvironmentBuilder.build(); + DriverEnvironment env = DriverEnvironmentDetector.build(); assertNotNull(env); - assertEquals("google-bigquery-jdbc-driver", env.getDriverName()); + assertEquals("Google-BigQuery-JDBC-Driver", env.getDriverName()); assertNotNull(env.getDriverVersion()); assertEquals("java", env.getClientLanguage()); assertNotNull(env.getClientLanguageVersion()); @@ -47,68 +47,71 @@ public void testBuildDriverEnvironment() { @Test public void testBuildDriverEnvironmentCustomTagPath(@TempDir Path tempDir) { Path tagFile = tempDir.resolve("telemetry-tag"); - DriverEnvironment env = DriverEnvironmentBuilder.build(tagFile); + DriverEnvironment env = DriverEnvironmentDetector.build(tagFile); assertNotNull(env); - assertEquals("google-bigquery-jdbc-driver", env.getDriverName()); + assertEquals("Google-BigQuery-JDBC-Driver", env.getDriverName()); assertNotNull(env.getDriverVersion()); assertEquals("java", env.getClientLanguage()); assertNotNull(env.getClientLanguageVersion()); assertNotNull(env.getOsType()); assertNotNull(env.getOsVersion()); - assertEquals(env.getTelemetryTag(), DriverEnvironmentBuilder.getOrCreateTelemetryTag(tagFile)); + assertEquals(env.getTelemetryTag(), DriverEnvironmentDetector.getOrCreateTelemetryTag(tagFile)); } @Test public void testGetMajorJavaVersion() { - assertEquals("8", DriverEnvironmentBuilder.getMajorJavaVersion("1.8.0_292")); - assertEquals("11", DriverEnvironmentBuilder.getMajorJavaVersion("11.0.12")); - assertEquals("17", DriverEnvironmentBuilder.getMajorJavaVersion("17.0.1")); - assertEquals("21", DriverEnvironmentBuilder.getMajorJavaVersion("21")); - assertEquals("unknown", DriverEnvironmentBuilder.getMajorJavaVersion(null)); - assertEquals("unknown", DriverEnvironmentBuilder.getMajorJavaVersion(" ")); + assertEquals("8", DriverEnvironmentDetector.getMajorJavaVersion("1.8.0_292")); + assertEquals("11", DriverEnvironmentDetector.getMajorJavaVersion("11.0.12")); + assertEquals("17", DriverEnvironmentDetector.getMajorJavaVersion("17.0.1")); + assertEquals("21", DriverEnvironmentDetector.getMajorJavaVersion("21")); + assertEquals("unknown", DriverEnvironmentDetector.getMajorJavaVersion(null)); + assertEquals("unknown", DriverEnvironmentDetector.getMajorJavaVersion(" ")); } @Test public void testDetectOsType() { assertEquals( DriverEnvironment.OsType.OS_TYPE_WINDOWS, - DriverEnvironmentBuilder.detectOsType("Windows 11")); + DriverEnvironmentDetector.detectOsType("Windows 11")); assertEquals( - DriverEnvironment.OsType.OS_TYPE_MACOS, DriverEnvironmentBuilder.detectOsType("Mac OS X")); + DriverEnvironment.OsType.OS_TYPE_MACOS, DriverEnvironmentDetector.detectOsType("Mac OS X")); assertEquals( - DriverEnvironment.OsType.OS_TYPE_MACOS, DriverEnvironmentBuilder.detectOsType("Darwin")); + DriverEnvironment.OsType.OS_TYPE_MACOS, DriverEnvironmentDetector.detectOsType("Darwin")); assertEquals( - DriverEnvironment.OsType.OS_TYPE_LINUX, DriverEnvironmentBuilder.detectOsType("Linux")); + DriverEnvironment.OsType.OS_TYPE_LINUX, DriverEnvironmentDetector.detectOsType("Linux")); assertEquals( - DriverEnvironment.OsType.OS_TYPE_SOLARIS, DriverEnvironmentBuilder.detectOsType("Solaris")); + DriverEnvironment.OsType.OS_TYPE_SOLARIS, + DriverEnvironmentDetector.detectOsType("Solaris")); assertEquals( - DriverEnvironment.OsType.OS_TYPE_FREEBSD, DriverEnvironmentBuilder.detectOsType("FreeBSD")); + DriverEnvironment.OsType.OS_TYPE_FREEBSD, + DriverEnvironmentDetector.detectOsType("FreeBSD")); assertEquals( - DriverEnvironment.OsType.OS_TYPE_OPENBSD, DriverEnvironmentBuilder.detectOsType("OpenBSD")); + DriverEnvironment.OsType.OS_TYPE_OPENBSD, + DriverEnvironmentDetector.detectOsType("OpenBSD")); assertEquals( - DriverEnvironment.OsType.OS_TYPE_NETBSD, DriverEnvironmentBuilder.detectOsType("NetBSD")); + DriverEnvironment.OsType.OS_TYPE_NETBSD, DriverEnvironmentDetector.detectOsType("NetBSD")); assertEquals( - DriverEnvironment.OsType.OS_TYPE_AIX, DriverEnvironmentBuilder.detectOsType("AIX")); + DriverEnvironment.OsType.OS_TYPE_AIX, DriverEnvironmentDetector.detectOsType("AIX")); assertEquals( DriverEnvironment.OsType.OS_TYPE_UNKNOWN, - DriverEnvironmentBuilder.detectOsType("UnknownOS")); + DriverEnvironmentDetector.detectOsType("UnknownOS")); assertEquals( - DriverEnvironment.OsType.OS_TYPE_UNKNOWN, DriverEnvironmentBuilder.detectOsType(null)); + DriverEnvironment.OsType.OS_TYPE_UNKNOWN, DriverEnvironmentDetector.detectOsType(null)); } @Test public void testGetMajorOsVersion() { - assertEquals("10", DriverEnvironmentBuilder.getMajorOsVersion("10.0")); - assertEquals("6", DriverEnvironmentBuilder.getMajorOsVersion("6.1.0")); - assertEquals("5", DriverEnvironmentBuilder.getMajorOsVersion("5")); - assertEquals("unknown", DriverEnvironmentBuilder.getMajorOsVersion(null)); - assertEquals("unknown", DriverEnvironmentBuilder.getMajorOsVersion(" ")); + assertEquals("10", DriverEnvironmentDetector.getMajorOsVersion("10.0")); + assertEquals("6", DriverEnvironmentDetector.getMajorOsVersion("6.1.0")); + assertEquals("5", DriverEnvironmentDetector.getMajorOsVersion("5")); + assertEquals("unknown", DriverEnvironmentDetector.getMajorOsVersion(null)); + assertEquals("unknown", DriverEnvironmentDetector.getMajorOsVersion(" ")); } @Test public void testGetOrCreateTelemetryTag_CreateNew(@TempDir Path tempDir) { Path tagFile = tempDir.resolve("telemetry-tag"); - String tag = DriverEnvironmentBuilder.getOrCreateTelemetryTag(tagFile); + String tag = DriverEnvironmentDetector.getOrCreateTelemetryTag(tagFile); assertNotNull(tag); assertTrue(Files.exists(tagFile)); @@ -122,7 +125,7 @@ public void testGetOrCreateTelemetryTag_ReadExisting(@TempDir Path tempDir) thro String existingUuid = UUID.randomUUID().toString(); Files.write(tagFile, existingUuid.getBytes(StandardCharsets.UTF_8)); - String tag = DriverEnvironmentBuilder.getOrCreateTelemetryTag(tagFile); + String tag = DriverEnvironmentDetector.getOrCreateTelemetryTag(tagFile); assertEquals(existingUuid, tag); } @@ -133,7 +136,7 @@ public void testGetOrCreateTelemetryTag_RegenerateOnCorruptedFile(@TempDir Path String corruptedContent = "not-a-valid-uuid"; Files.write(tagFile, corruptedContent.getBytes(StandardCharsets.UTF_8)); - String newTag = DriverEnvironmentBuilder.getOrCreateTelemetryTag(tagFile); + String newTag = DriverEnvironmentDetector.getOrCreateTelemetryTag(tagFile); assertNotNull(newTag); assertNotEquals(corruptedContent, newTag); diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TelemetryBatcherTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TelemetryBatcherTest.java index 8c631eb33a5c..682cbe581f05 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TelemetryBatcherTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TelemetryBatcherTest.java @@ -27,8 +27,10 @@ import com.google.api.client.testing.http.MockHttpTransport; import com.google.api.client.testing.http.MockLowLevelHttpRequest; import com.google.api.client.testing.http.MockLowLevelHttpResponse; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -396,4 +398,115 @@ public void testCalculateBucket() { 4000000); // Overflow > 3600000 (1 hr), index 17 assertEquals(17, index4); } + + @Test + public void testOffer_flushesWhenQueueSizeThresholdReached() throws Exception { + AtomicInteger requestCount = new AtomicInteger(0); + CountDownLatch flushed = new CountDownLatch(1); + MockHttpTransport mockTransport = + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + requestCount.incrementAndGet(); + flushed.countDown(); + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() { + MockLowLevelHttpResponse response = new MockLowLevelHttpResponse(); + response.setStatusCode(200); + return response; + } + }; + } + }; + + TelemetryConfiguration config = + TelemetryConfiguration.newBuilder() + .setEnabled(true) + .setBatchSizeThreshold(3) + .setDriverEnvironment(driverEnvironment) + .build(); + ClearcutTransport transport = new ClearcutTransport(mockTransport, config); + + try (TelemetryBatcher batcher = + new TelemetryBatcher(config, transport, executorService, false)) { + // Three DISTINCT keys, so the queue reaches the threshold. + batcher.offer(featureNamed("a")); + batcher.offer(featureNamed("b")); + batcher.offer(featureNamed("c")); + + assertTrue(flushed.await(5, TimeUnit.SECONDS)); + assertEquals(1, requestCount.get()); + } + } + + @Test + public void testOffer_repeatedIdenticalMetricDoesNotFlush() throws Exception { + AtomicInteger requestCount = new AtomicInteger(0); + CountDownLatch flushed = new CountDownLatch(1); + MockHttpTransport mockTransport = + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + requestCount.incrementAndGet(); + flushed.countDown(); + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() { + MockLowLevelHttpResponse response = new MockLowLevelHttpResponse(); + response.setStatusCode(200); + return response; + } + }; + } + }; + + TelemetryConfiguration config = + TelemetryConfiguration.newBuilder() + .setEnabled(true) + .setBatchSizeThreshold(3) + .setDriverEnvironment(driverEnvironment) + .build(); + ClearcutTransport transport = new ClearcutTransport(mockTransport, config); + + try (TelemetryBatcher batcher = + new TelemetryBatcher(config, transport, executorService, false)) { + // 50 events, but ONE key, so the queue never grows past 1. + for (int i = 0; i < 50; i++) { + batcher.offer(featureNamed("same")); + } + + // Assert inside the try block: close() flushes and would send a request. + assertFalse(flushed.await(500, TimeUnit.MILLISECONDS)); + assertEquals(0, requestCount.get()); + } + } + + @Test + public void testComputeProfileCap_staysAboveThreshold() { + TelemetryConfiguration config = + TelemetryConfiguration.newBuilder().setEnabled(true).setBatchSizeThreshold(10).build(); + assertEquals(20, TelemetryBatcher.computeProfileCap(config)); + + // Degenerate inputs fall back rather than producing a cap at or below the threshold. + assertEquals(3000, TelemetryBatcher.computeProfileCap(null)); + TelemetryConfiguration zero = + TelemetryConfiguration.newBuilder().setEnabled(true).setBatchSizeThreshold(0).build(); + assertEquals(3000, TelemetryBatcher.computeProfileCap(zero)); + + // No overflow to a negative cap. + TelemetryConfiguration huge = + TelemetryConfiguration.newBuilder() + .setEnabled(true) + .setBatchSizeThreshold(Integer.MAX_VALUE) + .build(); + assertTrue(TelemetryBatcher.computeProfileCap(huge) > 0); + } + + private static FeatureUsage featureNamed(String name) { + return FeatureUsage.newBuilder() + .setDriverFeature(DriverFeature.DRIVER_FEATURE_CUSTOM) + .setCustomFeatureName(name) + .build(); + } } diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TelemetryManagerTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TelemetryManagerTest.java index cf467afaf319..dad50b9a963a 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TelemetryManagerTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/telemetry/v1/TelemetryManagerTest.java @@ -24,6 +24,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import com.google.cloud.bigquery.JobStatistics.QueryStatistics; +import java.sql.SQLException; import java.util.Properties; import java.util.concurrent.atomic.AtomicBoolean; import org.junit.jupiter.api.AfterEach; @@ -215,4 +216,46 @@ public void testGlobalKillSwitch() { assertNull(TelemetryManager.getInstance()); assertNull(TelemetryManager.getInstance(props1)); } + + @Test + public void testGetInstance_populatesDriverEnvironment() { + TelemetryManager manager = TelemetryManager.getInstance(); + + DriverEnvironment environment = manager.getBatcher().getConfig().getDriverEnvironment(); + assertNotNull(environment); + assertEquals(DriverEnvironmentDetector.DRIVER_NAME, environment.getDriverName()); + assertEquals(DriverEnvironmentDetector.CLIENT_LANGUAGE, environment.getClientLanguage()); + assertFalse(environment.getTelemetryTag().isEmpty()); + } + + @Test + public void testExtractXdbcCode_numericSqlState() { + assertEquals(42000, TelemetryManager.extractXdbcCode(new SQLException("bad syntax", "42000"))); + } + + @Test + public void testExtractXdbcCode_alphabeticSqlStateIsUnmappable() { + assertEquals(0, TelemetryManager.extractXdbcCode(new SQLException("general", "HY000"))); + } + + @Test + public void testExtractXdbcCode_walksCauseChain() { + SQLException root = new SQLException("overflow", "22003"); + Throwable wrapped = new RuntimeException(new IllegalStateException(root)); + assertEquals(22003, TelemetryManager.extractXdbcCode(wrapped)); + } + + @Test + public void testExtractXdbcCode_skipsUnmappableAndKeepsWalking() { + SQLException numeric = new SQLException("overflow", "22003"); + SQLException outer = new SQLException("general", "HY000", numeric); + assertEquals(22003, TelemetryManager.extractXdbcCode(outer)); + } + + @Test + public void testExtractXdbcCode_noSqlStateReturnsZero() { + assertEquals(0, TelemetryManager.extractXdbcCode(null)); + assertEquals(0, TelemetryManager.extractXdbcCode(new RuntimeException("no state"))); + assertEquals(0, TelemetryManager.extractXdbcCode(new SQLException("none", (String) null))); + } }