Repository: GoogleCloudPlatform/pgadapter
Pull request #3437 (2025-06-10 15:31:50)

build(deps-dev): bump @types/node from 22.15.31 to 24.0.0 in /src/test/nodejs/typeorm/data-test
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 22.15.31 to 24.0.0.
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a href="https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@types/node&package-manager=npm_and_yarn&previous-version=22.15.31&new-version=24.0.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)


</details>


Patch:
diff --git a/benchmarks/latency-comparison/java/pom.xml b/benchmarks/latency-comparison/java/pom.xml
index 60b613b4..f45893f8 100644
--- a/benchmarks/latency-comparison/java/pom.xml
+++ b/benchmarks/latency-comparison/java/pom.xml
@@ -59,7 +59,7 @@
     <dependency>
       <groupId>com.google.cloud</groupId>
       <artifactId>google-cloud-spanner-jdbc</artifactId>
-      <version>2.30.2</version>
+      <version>2.30.4</version>
       <exclusions>
         <exclusion>
           <groupId>io.opentelemetry</groupId>
diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/statements/BackendConnection.java b/src/main/java/com/google/cloud/spanner/pgadapter/statements/BackendConnection.java
index f54bffe4..f76882cc 100644
--- a/src/main/java/com/google/cloud/spanner/pgadapter/statements/BackendConnection.java
+++ b/src/main/java/com/google/cloud/spanner/pgadapter/statements/BackendConnection.java
@@ -182,6 +182,28 @@ public class BackendConnection {
     return pgException;
   }
 
+  static boolean isQueryCancelled(SpannerException spannerException) {
+    if (Thread.interrupted()) {
+      return true;
+    }
+    if (spannerException.getErrorCode() == ErrorCode.CANCELLED) {
+      return true;
+    }
+    final int maxDepth = 100;
+    Throwable cause = spannerException.getCause();
+    Throwable prevCause = null;
+    int depth = 0;
+    while (cause != null && cause != prevCause && depth < maxDepth) {
+      if (cause instanceof InterruptedException) {
+        return true;
+      }
+      prevCause = cause;
+      cause = cause.getCause();
+      depth++;
+    }
+    return false;
+  }
+
   boolean shouldReplaceStatement(Statement statement) {
     if (!localStatements.get().isEmpty() && localStatements.get().containsKey(statement.getSql())) {
       LocalStatement localStatement = localStatements.get().get(statement.getSql());
@@ -397,7 +419,7 @@ public class BackendConnection {
             throw setAndReturn(result, exception);
           }
         }
-        if (spannerException.getErrorCode() == ErrorCode.CANCELLED || Thread.interrupted()) {
+        if (isQueryCancelled(spannerException)) {
           throw setAndReturn(result, PGExceptionFactory.newQueryCancelledException());
         } else {
           throw setAndReturn(result, spannerException);


Test patch:
diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/JdbcMockServerTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/JdbcMockServerTest.java
index bafd429c..0a885ac7 100644
--- a/src/test/java/com/google/cloud/spanner/pgadapter/JdbcMockServerTest.java
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/JdbcMockServerTest.java
@@ -6024,51 +6024,124 @@ public class JdbcMockServerTest extends AbstractMockServerTest {
   }
 
   @Test
-  public void testCancel() throws Exception {
-    ExecutorService executor = Executors.newSingleThreadExecutor();
+  public void testCancelQuery() throws Exception {
+    String sql = "SELECT 1";
+    for (boolean autocommit : new boolean[] {true, false}) {
+      ExecutorService executor = Executors.newSingleThreadExecutor();
 
-    try (Connection connection = DriverManager.getConnection(createUrl());
-        java.sql.Statement statement = connection.createStatement()) {
-      mockSpanner.freeze();
-      Future<Long> queryResult =
-          executor.submit(
-              () -> {
-                try (ResultSet resultSet = statement.executeQuery("SELECT 1")) {
-                  if (resultSet.next()) {
-                    return resultSet.getLong(1);
+      try (Connection connection = DriverManager.getConnection(createUrl());
+          java.sql.Statement statement = connection.createStatement()) {
+        connection.setAutoCommit(autocommit);
+        mockSpanner.freeze();
+        Future<Long> queryResult =
+            executor.submit(
+                () -> {
+                  try (ResultSet resultSet = statement.executeQuery(sql)) {
+                    if (resultSet.next()) {
+                      return resultSet.getLong(1);
+                    }
+                    return 0L;
                   }
-                  return 0L;
-                }
-              });
-      // Wait for the request to have landed on the server.
-      mockSpanner.waitForRequestsToContain(
-          msg -> {
-            if (!(msg instanceof ExecuteSqlRequest)) {
-              return false;
-            }
-            ExecuteSqlRequest executeSqlRequest = (ExecuteSqlRequest) msg;
-            return executeSqlRequest.getSql().equals("SELECT 1");
-          },
-          1000L);
-      // Cancel the statement.
-      statement.cancel();
-      ExecutionException exception = assertThrows(ExecutionException.class, queryResult::get);
-      assertEquals(PSQLException.class, exception.getCause().getClass());
-      PSQLException psqlException = (PSQLException) exception.getCause();
-      assertNotNull(psqlException.getServerErrorMessage());
-      assertEquals(
-          SQLState.QueryCanceled.toString(), psqlException.getServerErrorMessage().getSQLState());
+                });
+        // Wait for the request to have landed on the server.
+        mockSpanner.waitForRequestsToContain(
+            msg -> {
+              if (!(msg instanceof ExecuteSqlRequest)) {
+                return false;
+              }
+              ExecuteSqlRequest executeSqlRequest = (ExecuteSqlRequest) msg;
+              return executeSqlRequest.getSql().equals(sql);
+            },
+            1000L);
+        // Cancel the statement.
+        statement.cancel();
+        ExecutionException exception = assertThrows(ExecutionException.class, queryResult::get);
+        assertEquals(PSQLException.class, exception.getCause().getClass());
+        PSQLException psqlException = (PSQLException) exception.getCause();
+        assertNotNull(psqlException.getServerErrorMessage());
+        assertEquals(
+            SQLState.QueryCanceled.toString(), psqlException.getServerErrorMessage().getSQLState());
 
-      mockSpanner.unfreeze();
-      // Verify that the connection is still usable.
-      try (ResultSet resultSet = statement.executeQuery("SELECT 1")) {
-        assertTrue(resultSet.next());
-        assertEquals(1L, resultSet.getLong(1));
-        assertFalse(resultSet.next());
+        mockSpanner.unfreeze();
+        if (!autocommit) {
+          // The transaction is invalid due to the cancelled query.
+          psqlException = assertThrows(PSQLException.class, () -> statement.executeQuery(sql));
+          assertNotNull(psqlException.getServerErrorMessage());
+          assertEquals(
+              SQLState.InFailedSqlTransaction.toString(),
+              psqlException.getServerErrorMessage().getSQLState());
+          // Rolling back should make the connection usable again.
+          connection.rollback();
+        }
+        // Verify that the connection is still usable.
+        assertTrue(connection.isValid(5000));
+        try (ResultSet resultSet = statement.executeQuery("SELECT 1")) {
+          assertTrue(resultSet.next());
+          assertEquals(1L, resultSet.getLong(1));
+          assertFalse(resultSet.next());
+        }
+      } finally {
+        mockSpanner.unfreeze();
+        mockSpanner.clearRequests();
+        executor.shutdown();
+      }
+    }
+  }
+
+  @Test
+  public void testCancelDml() throws Exception {
+    String sql = "update test set foo=1";
+    mockSpanner.putStatementResult(StatementResult.update(Statement.of(sql), 1L));
+
+    for (boolean autocommit : new boolean[] {false, true}) {
+      ExecutorService executor = Executors.newSingleThreadExecutor();
+      try (Connection connection = DriverManager.getConnection(createUrl());
+          java.sql.Statement statement = connection.createStatement()) {
+        connection.setAutoCommit(autocommit);
+        mockSpanner.freeze();
+        Future<Long> queryResult = executor.submit(() -> statement.executeLargeUpdate(sql));
+        // Wait for the request to have landed on the server.
+        mockSpanner.waitForRequestsToContain(
+            msg -> {
+              if (!(msg instanceof ExecuteSqlRequest)) {
+                return false;
+              }
+              ExecuteSqlRequest executeSqlRequest = (ExecuteSqlRequest) msg;
+              return executeSqlRequest.getSql().equals(sql);
+            },
+            1000L);
+        // Cancel the statement.
+        statement.cancel();
+        ExecutionException exception = assertThrows(ExecutionException.class, queryResult::get);
+        assertEquals(PSQLException.class, exception.getCause().getClass());
+        PSQLException psqlException = (PSQLException) exception.getCause();
+        assertNotNull(psqlException.getServerErrorMessage());
+        assertEquals(
+            SQLState.QueryCanceled.toString(), psqlException.getServerErrorMessage().getSQLState());
+
+        mockSpanner.unfreeze();
+        // The connection should still be valid.
+        assertTrue(connection.isValid(5000));
+
+        if (!autocommit) {
+          // The transaction is invalid due to the cancelled query.
+          psqlException =
+              assertThrows(PSQLException.class, () -> statement.executeLargeUpdate(sql));
+          assertNotNull(psqlException.getServerErrorMessage());
+          assertEquals(
+              SQLState.InFailedSqlTransaction.toString(),
+              psqlException.getServerErrorMessage().getSQLState());
+          // Rolling back should make the connection usable again.
+          connection.rollback();
+        }
+
+        // Verify that the connection is still usable.
+        assertEquals(1L, connection.createStatement().executeLargeUpdate(sql));
+      } finally {
+        mockSpanner.unfreeze();
+        mockSpanner.clearRequests();
+        executor.shutdown();
       }
-    } finally {
-      mockSpanner.unfreeze();
-      executor.shutdown();
     }
   }
 
diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/statements/BackendConnectionTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/statements/BackendConnectionTest.java
index df4956a9..6f6c8ca6 100644
--- a/src/test/java/com/google/cloud/spanner/pgadapter/statements/BackendConnectionTest.java
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/statements/BackendConnectionTest.java
@@ -15,6 +15,7 @@
 package com.google.cloud.spanner.pgadapter.statements;
 
 import static com.google.cloud.spanner.pgadapter.statements.BackendConnection.extractDdlUpdateCounts;
+import static com.google.cloud.spanner.pgadapter.statements.BackendConnection.isQueryCancelled;
 import static com.google.cloud.spanner.pgadapter.utils.ClientAutoDetector.EMPTY_LOCAL_STATEMENTS;
 import static org.junit.Assert.assertArrayEquals;
 import static org.junit.Assert.assertEquals;
@@ -993,4 +994,50 @@ public class BackendConnectionTest {
     verify(connection).setTransactionMode(TransactionMode.READ_ONLY_TRANSACTION);
     verify(connection).beginTransaction();
   }
+
+  @Test
+  public void testIsQueryCancelled() {
+    // InterruptedExceptions should be treated as 'Query cancelled' errors, also when it is
+    // somewhere deep down the stack.
+    assertTrue(
+        isQueryCancelled(SpannerExceptionFactory.propagateInterrupt(new InterruptedException())));
+    assertTrue(
+        isQueryCancelled(
+            SpannerExceptionFactory.newSpannerException(
+                ErrorCode.UNKNOWN, "test", new InterruptedException())));
+    assertTrue(
+        isQueryCancelled(
+            SpannerExceptionFactory.newSpannerException(
+                ErrorCode.INVALID_ARGUMENT,
+                "test",
+                new IllegalArgumentException("test", new InterruptedException()))));
+    // Error code CANCELLED should be translated to 'Query cancelled'.
+    assertTrue(
+        isQueryCancelled(
+            SpannerExceptionFactory.newSpannerException(
+                ErrorCode.CANCELLED, "Query was cancelled")));
+
+    // Other Spanner errors should not be considered as 'Query cancelled' errors.
+    assertFalse(
+        isQueryCancelled(
+            SpannerExceptionFactory.newSpannerException(ErrorCode.INVALID_ARGUMENT, "test")));
+
+    // If the thread has been interrupted, then any error should be treated as if the query was
+    // cancelled, and the interrupted flag should be cleared.
+    Thread.currentThread().interrupt();
+    assertTrue(
+        isQueryCancelled(
+            SpannerExceptionFactory.newSpannerException(ErrorCode.INVALID_ARGUMENT, "test")));
+    assertFalse(Thread.currentThread().isInterrupted());
+
+    // A circular set of causes should not lead to an eternal loop.
+    Exception e1 = new Exception("test");
+    Exception e2 = new Exception("test", e1);
+    Exception e3 = new Exception("test", e2);
+    Exception e4 = new Exception("test", e3);
+    e1.initCause(e4);
+    SpannerException spannerException =
+        SpannerExceptionFactory.newSpannerException(ErrorCode.UNKNOWN, "test", e4);
+    assertFalse(isQueryCancelled(spannerException));
+  }
 }
diff --git a/src/test/nodejs/typeorm/data-test/package.json b/src/test/nodejs/typeorm/data-test/package.json
index 0a3b20e5..85eda538 100644
--- a/src/test/nodejs/typeorm/data-test/package.json
+++ b/src/test/nodejs/typeorm/data-test/package.json
@@ -5,7 +5,7 @@
    "type": "commonjs",
    "devDependencies": {
       "ts-node": "10.9.2",
-      "@types/node": "^22.1.0",
+      "@types/node": "^24.0.0",
       "typescript": "5.8.3"
    },
    "dependencies": {