Repository: GoogleCloudPlatform/pgadapter
Pull request #796 (2023-04-24 18:09:26)

feat: support savepoints
Add support for emulated savepoints. Setting and releasing savepoints work in all cases. Rolling back to a savepoint is not guaranteed to work, as the underlying implementation will rollback the entire transaction and retry up to where the savepoint was set. Note that the retry will only be executed if the transaction is actually used after the rollback.

codecov[bot]: ## [Codecov](https://codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/796?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform) Report
> Merging [#796](https://codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/796?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform) (cfe5a6a) into [postgresql-dialect](https://codecov.io/gh/GoogleCloudPlatform/pgadapter/commit/b0279f31d32f12962460a1d1d9581bda01ad66fb?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform) (b0279f3) will **decrease** coverage by `0.16%`.
> The diff coverage is `48.27%`.

```diff
@@                   Coverage Diff                    @@
##             postgresql-dialect     #796      +/-   ##
========================================================
- Coverage                 89.61%   89.45%   -0.16%     
  Complexity                 2366     2366              
========================================================
  Files                       131      131              
  Lines                      7837     7863      +26     
  Branches                   1122     1122              
========================================================
+ Hits                       7023     7034      +11     
- Misses                      566      581      +15     
  Partials                    248      248              
```

| Flag | Coverage Δ | |
|---|---|---|
| all_tests | `89.45% <48.27%> (-0.16%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/796?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform) | Coverage Δ | |
|---|---|---|
| [...panner/pgadapter/statements/BackendConnection.java](https://codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/796?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform#diff-c3JjL21haW4vamF2YS9jb20vZ29vZ2xlL2Nsb3VkL3NwYW5uZXIvcGdhZGFwdGVyL3N0YXRlbWVudHMvQmFja2VuZENvbm5lY3Rpb24uamF2YQ==) | `91.49% <42.30%> (-2.18%)` | :arrow_down: |
| [...gle/cloud/spanner/pgadapter/ConnectionHandler.java](https://codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/796?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform#diff-c3JjL21haW4vamF2YS9jb20vZ29vZ2xlL2Nsb3VkL3NwYW5uZXIvcGdhZGFwdGVyL0Nvbm5lY3Rpb25IYW5kbGVyLmphdmE=) | `88.44% <100.00%> (+0.02%)` | :arrow_up: |
| [...google/cloud/spanner/pgadapter/error/SQLState.java](https://codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/796?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform#diff-c3JjL21haW4vamF2YS9jb20vZ29vZ2xlL2Nsb3VkL3NwYW5uZXIvcGdhZGFwdGVyL2Vycm9yL1NRTFN0YXRlLmphdmE=) | `99.44% <100.00%> (+<0.01%)` | :arrow_up: |

:mega: We’re building smart automated test selection to slash your CI/CD build times. [Learn more](https://about.codecov.io/iterative-testing/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform)


Patch:
diff --git a/.github/workflows/docker-build-and-push.yaml b/.github/workflows/docker-build-and-push.yaml
index a91d0680..2a0ec2c7 100644
--- a/.github/workflows/docker-build-and-push.yaml
+++ b/.github/workflows/docker-build-and-push.yaml
@@ -34,21 +34,16 @@ jobs:
       # Configure docker to use the gcloud command-line tool as a credential helper
       - run: gcloud auth configure-docker gcr.io
 
-      # Build the Docker image
-      - name: Build
+      # Build and Publish the Docker image
+      - name: Build and Publish
         run: |
           export TAG=`echo $GITHUB_REF | awk -F/ '{print $NF}'`
           echo $TAG
-          docker build . -f build/Dockerfile \
+          docker buildx create --name multi_platform --use
+          docker buildx build --platform linux/amd64,linux/arm64 \
+            . -f build/Dockerfile \
             -t "$GCR_HOSTNAME"/"$IMAGE":"$TAG" \
+            -t "$GCR_HOSTNAME"/"$IMAGE":latest \
+            --push \
             --build-arg GITHUB_SHA="$GITHUB_SHA" \
             --build-arg GITHUB_REF="$GITHUB_REF"
-
-      # Push the Docker image to Google Artifact Registry
-      - name: Publish
-        run: |
-          export TAG=`echo $GITHUB_REF | awk -F/ '{print $NF}'`
-          echo $TAG
-          docker push "$GCR_HOSTNAME"/"$IMAGE":"$TAG"
-          docker tag "$GCR_HOSTNAME"/"$IMAGE":"$TAG" "$GCR_HOSTNAME"/"$IMAGE":latest
-          docker push "$GCR_HOSTNAME"/"$IMAGE":latest
diff --git a/docs/psycopg3.md b/docs/psycopg3.md
index 96cabd60..b7b120cc 100644
--- a/docs/psycopg3.md
+++ b/docs/psycopg3.md
@@ -75,13 +75,9 @@ psycopg.errors.RaiseException: Unknown statement: DECLARE "my_cursor" CURSOR FOR
 
 ### Nested Transactions
 `psycopg3` implements [nested transactions](https://www.psycopg.org/psycopg3/docs/basic/transactions.html#nested-transactions)
-using `SAVEPOINT`. This feature is currently not supported with PGAdapter.
-
-Creating a nested transaction in `psycopg3` with PGAdapter will cause an error like the following:
-
-```
-psycopg.errors.RaiseException: Unknown statement: SAVEPOINT "_pg3_2"
-```
+using `SAVEPOINT`. Rolling back to a `SAVEPOINT` can fail if the transaction contained at least one
+query that called a volatile function or if the underlying data that has been accessed by the
+transaction has been modified by another transaction.
 
 ## Performance Considerations
 
diff --git a/samples/python/sqlalchemy-sample/README.md b/samples/python/sqlalchemy-sample/README.md
index a736b768..84c4f368 100644
--- a/samples/python/sqlalchemy-sample/README.md
+++ b/samples/python/sqlalchemy-sample/README.md
@@ -83,7 +83,7 @@ The following limitations are currently known:
 | DDL Transactions             | Cloud Spanner does not support DDL statements in a transaction. Add `?options=-c spanner.ddl_transaction_mode=AutocommitExplicitTransaction` to your connection string to automatically convert DDL transactions to [non-atomic DDL batches](../../../docs/ddl.md). |
 | Generated primary keys       | Manually assign a value to the primary key column in your code. The recommended primary key type is a random UUID. Sequences / SERIAL / IDENTITY columns are currently not supported.                                                                               |
 | INSERT ... ON CONFLICT       | `INSERT ... ON CONFLICT` is not supported.                                                                                                                                                                                                                          |
-| SAVEPOINT                    | Nested transactions and savepoints are not supported.                                                                                                                                                                                                               |
+| SAVEPOINT                    | Rolling back to a `SAVEPOINT` can fail if the transaction contained at least one query that called a volatile function.                                                                                                                                             |
 | SELECT ... FOR UPDATE        | `SELECT ... FOR UPDATE` is not supported.                                                                                                                                                                                                                           |
 | Server side cursors          | Server side cursors are currently not supported.                                                                                                                                                                                                                    |
 | Transaction isolation level  | Only SERIALIZABLE and AUTOCOMMIT are supported. `postgresql_readonly=True` is also supported. It is recommended to use either autocommit or read-only for workloads that only read data and/or that do not need to be atomic to get the best possible performance.  |
@@ -116,9 +116,9 @@ or https://docs.sqlalchemy.org/en/14/dialects/postgresql.html#sqlalchemy.dialect
 will fail.
 
 ### SAVEPOINT - Nested transactions
-`SAVEPOINT`s are not supported by Cloud Spanner. Nested transactions in SQLAlchemy are translated to
-savepoints and are therefore not supported. Trying to use `Session.begin_nested()`
-(https://docs.sqlalchemy.org/en/14/orm/session_api.html#sqlalchemy.orm.Session.begin_nested) will fail.
+Rolling back to a `SAVEPOINT` can fail if the transaction contained at least one query that called a
+volatile function or if the underlying data that has been accessed by the transaction has been
+modified by another transaction.
 
 ### Locking - SELECT ... FOR UPDATE
 Locking clauses, like `SELECT ... FOR UPDATE`, are not supported (see also https://docs.sqlalchemy.org/en/20/orm/queryguide/query.html#sqlalchemy.orm.Query.with_for_update).
diff --git a/samples/python/sqlalchemy2-sample/README.md b/samples/python/sqlalchemy2-sample/README.md
index 9f0d2504..45528ea4 100644
--- a/samples/python/sqlalchemy2-sample/README.md
+++ b/samples/python/sqlalchemy2-sample/README.md
@@ -77,7 +77,7 @@ The following limitations are currently known:
 | DDL Transactions               | Cloud Spanner does not support DDL statements in a transaction. Add `?options=-c spanner.ddl_transaction_mode=AutocommitExplicitTransaction` to your connection string to automatically convert DDL transactions to [non-atomic DDL batches](../../../docs/ddl.md). |
 | Generated primary keys         | Manually assign a value to the primary key column in your code. The recommended primary key type is a random UUID. Sequences / SERIAL / IDENTITY columns are currently not supported.                                                                               |
 | INSERT ... ON CONFLICT         | `INSERT ... ON CONFLICT` is not supported.                                                                                                                                                                                                                          |
-| SAVEPOINT                      | Nested transactions and savepoints are not supported.                                                                                                                                                                                                               |
+| SAVEPOINT                      | Rolling back to a `SAVEPOINT` can fail if the transaction contained at least one query that called a volatile function.                                                                                                                                             |
 | SELECT ... FOR UPDATE          | `SELECT ... FOR UPDATE` is not supported.                                                                                                                                                                                                                           |
 | Server side cursors            | Server side cursors are currently not supported.                                                                                                                                                                                                                    |
 | Transaction isolation level    | Only SERIALIZABLE and AUTOCOMMIT are supported. `postgresql_readonly=True` is also supported. It is recommended to use either autocommit or read-only for workloads that only read data and/or that do not need to be atomic to get the best possible performance.  |
@@ -110,9 +110,9 @@ or https://docs.sqlalchemy.org/en/20/dialects/postgresql.html#sqlalchemy.dialect
 will fail.
 
 ### SAVEPOINT - Nested transactions
-`SAVEPOINT`s are not supported by Cloud Spanner. Nested transactions in SQLAlchemy are translated to
-savepoints and are therefore not supported. Trying to use `Session.begin_nested()`
-(https://docs.sqlalchemy.org/en/20/orm/session_api.html#sqlalchemy.orm.Session.begin_nested) will fail.
+Rolling back to a `SAVEPOINT` can fail if the transaction contained at least one query that called a
+volatile function or if the underlying data that has been accessed by the transaction has been
+modified by another transaction.
 
 ### Locking - SELECT ... FOR UPDATE
 Locking clauses, like `SELECT ... FOR UPDATE`, are not supported (see also https://docs.sqlalchemy.org/en/20/orm/queryguide/query.html#sqlalchemy.orm.Query.with_for_update).
diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/ConnectionHandler.java b/src/main/java/com/google/cloud/spanner/pgadapter/ConnectionHandler.java
index 94a0bcbc..5d62b171 100644
--- a/src/main/java/com/google/cloud/spanner/pgadapter/ConnectionHandler.java
+++ b/src/main/java/com/google/cloud/spanner/pgadapter/ConnectionHandler.java
@@ -34,6 +34,7 @@ import com.google.cloud.spanner.connection.AbstractStatementParser.StatementType
 import com.google.cloud.spanner.connection.Connection;
 import com.google.cloud.spanner.connection.ConnectionOptions;
 import com.google.cloud.spanner.connection.ConnectionOptionsHelper;
+import com.google.cloud.spanner.connection.SavepointSupport;
 import com.google.cloud.spanner.pgadapter.error.PGException;
 import com.google.cloud.spanner.pgadapter.error.PGExceptionFactory;
 import com.google.cloud.spanner.pgadapter.error.SQLState;
@@ -240,6 +241,7 @@ public class ConnectionHandler extends Thread {
       spannerConnection.close();
       throw e;
     }
+    spannerConnection.setSavepointSupport(SavepointSupport.ENABLED);
     this.spannerConnection = spannerConnection;
     this.databaseId = connectionOptions.getDatabaseId();
     this.extendedQueryProtocolHandler = new ExtendedQueryProtocolHandler(this);
diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/error/SQLState.java b/src/main/java/com/google/cloud/spanner/pgadapter/error/SQLState.java
index 89be8e33..21b20e01 100644
--- a/src/main/java/com/google/cloud/spanner/pgadapter/error/SQLState.java
+++ b/src/main/java/com/google/cloud/spanner/pgadapter/error/SQLState.java
@@ -149,6 +149,10 @@ public enum SQLState {
   InFailedSqlTransaction("25P02"),
   IdleInTransactionSessionTimeout("25P03"),
 
+  // Class 3B — Savepoint Exception
+  SavepointException("3B000"),
+  InvalidSavepointSpecification("3B001"),
+
   // Class 42 — Syntax Error or Access Rule Violation
   SyntaxErrorOrAccessRuleViolation("42000"),
   SyntaxError("42601"),
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 ccbd57d4..1cef6a1a 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
@@ -604,7 +604,16 @@ public class BackendConnection {
 
     @Override
     void execute() {
-      result.set(NO_RESULT);
+      try {
+        checkConnectionState();
+        spannerConnection.savepoint(savepointStatement.getSavepointName());
+        result.set(NO_RESULT);
+      } catch (Exception exception) {
+        PGException pgException =
+            PGException.newBuilder(exception).setSQLState(SQLState.SavepointException).build();
+        result.setException(pgException);
+        throw pgException;
+      }
     }
   }
 
@@ -623,7 +632,16 @@ public class BackendConnection {
 
     @Override
     void execute() {
-      result.set(NO_RESULT);
+      try {
+        checkConnectionState();
+        spannerConnection.releaseSavepoint(releaseStatement.getSavepointName());
+        result.set(NO_RESULT);
+      } catch (Exception exception) {
+        PGException pgException =
+            PGException.newBuilder(exception).setSQLState(SQLState.SavepointException).build();
+        result.setException(pgException);
+        throw pgException;
+      }
     }
   }
 
@@ -642,11 +660,15 @@ public class BackendConnection {
 
     @Override
     void execute() {
-      throw setAndReturn(
-          result,
-          PGExceptionFactory.newPGException(
-              "Statement 'ROLLBACK [WORK | TRANSACTION] TO [SAVEPOINT] savepoint_name' is not supported",
-              SQLState.FeatureNotSupported));
+      try {
+        spannerConnection.rollbackToSavepoint(rollbackToStatement.getSavepointName());
+        result.set(NO_RESULT);
+      } catch (Exception exception) {
+        PGException pgException =
+            PGException.newBuilder(exception).setSQLState(SQLState.SavepointException).build();
+        result.setException(pgException);
+        throw pgException;
+      }
     }
   }
 


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 4c99de89..c0a3c08e 100644
--- a/src/test/java/com/google/cloud/spanner/pgadapter/JdbcMockServerTest.java
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/JdbcMockServerTest.java
@@ -81,6 +81,7 @@ import java.sql.PreparedStatement;
 import java.sql.ResultSet;
 import java.sql.ResultSetMetaData;
 import java.sql.SQLException;
+import java.sql.Savepoint;
 import java.sql.Types;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
@@ -102,11 +103,9 @@ import org.junit.runners.Parameterized.Parameters;
 import org.postgresql.PGConnection;
 import org.postgresql.PGStatement;
 import org.postgresql.core.Oid;
-import org.postgresql.jdbc.PSQLSavepoint;
 import org.postgresql.jdbc.PgStatement;
 import org.postgresql.util.PGobject;
 import org.postgresql.util.PSQLException;
-import org.postgresql.util.PSQLState;
 
 @RunWith(Parameterized.class)
 public class JdbcMockServerTest extends AbstractMockServerTest {
@@ -4678,7 +4677,12 @@ public class JdbcMockServerTest extends AbstractMockServerTest {
   public void testUnnamedSavepoint() throws SQLException {
     try (Connection connection = DriverManager.getConnection(createUrl())) {
       connection.setAutoCommit(false);
-      assertNotNull(connection.setSavepoint());
+      Savepoint savepoint = connection.setSavepoint();
+      assertNotNull(savepoint);
+      assertEquals(0, savepoint.getSavepointId());
+
+      Savepoint savepoint2 = connection.setSavepoint();
+      assertEquals(1, savepoint2.getSavepointId());
     }
   }
 
@@ -4686,7 +4690,7 @@ public class JdbcMockServerTest extends AbstractMockServerTest {
   public void testNamedSavepoint() throws SQLException {
     try (Connection connection = DriverManager.getConnection(createUrl())) {
       connection.setAutoCommit(false);
-      assertEquals("my-savepoint", connection.setSavepoint("my-savepoint").getSavepointName());
+      assertEquals("my_savepoint", connection.setSavepoint("my_savepoint").getSavepointName());
     }
   }
 
@@ -4694,7 +4698,7 @@ public class JdbcMockServerTest extends AbstractMockServerTest {
   public void testReleaseSavepoint() throws SQLException {
     try (Connection connection = DriverManager.getConnection(createUrl())) {
       connection.setAutoCommit(false);
-      PSQLSavepoint savepoint = new PSQLSavepoint("my-savepoint");
+      Savepoint savepoint = connection.setSavepoint("my_savepoint");
       connection.releaseSavepoint(savepoint);
     }
   }
@@ -4703,10 +4707,8 @@ public class JdbcMockServerTest extends AbstractMockServerTest {
   public void testRollbackToSavepoint() throws SQLException {
     try (Connection connection = DriverManager.getConnection(createUrl())) {
       connection.setAutoCommit(false);
-      PSQLSavepoint savepoint = new PSQLSavepoint("my-savepoint");
-      PSQLException exception =
-          assertThrows(PSQLException.class, () -> connection.rollback(savepoint));
-      assertEquals(PSQLState.NOT_IMPLEMENTED.getState(), exception.getSQLState());
+      Savepoint savepoint = connection.setSavepoint("my_savepoint");
+      connection.rollback(savepoint);
     }
   }
 
diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/SavepointMockServerTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/SavepointMockServerTest.java
new file mode 100644
index 00000000..ce84aa79
--- /dev/null
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/SavepointMockServerTest.java
@@ -0,0 +1,598 @@
+// 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
+//
+//      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.spanner.pgadapter;
+
+import static com.google.cloud.spanner.connection.AbstractMockServerTest.RANDOM_RESULT_SET_ROW_COUNT;
+import static com.google.cloud.spanner.connection.AbstractMockServerTest.SELECT_RANDOM_STATEMENT;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+import com.google.cloud.spanner.Dialect;
+import com.google.cloud.spanner.MockSpannerServiceImpl.StatementResult;
+import com.google.cloud.spanner.Statement;
+import com.google.cloud.spanner.connection.RandomResultSetGenerator;
+import com.google.cloud.spanner.pgadapter.error.SQLState;
+import com.google.common.base.Strings;
+import com.google.protobuf.AbstractMessage;
+import com.google.spanner.v1.BeginTransactionRequest;
+import com.google.spanner.v1.CommitRequest;
+import com.google.spanner.v1.ExecuteBatchDmlRequest;
+import com.google.spanner.v1.ExecuteSqlRequest;
+import com.google.spanner.v1.RollbackRequest;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Savepoint;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.postgresql.jdbc.PSQLSavepoint;
+
+@RunWith(JUnit4.class)
+public class SavepointMockServerTest extends AbstractMockServerTest {
+
+  @BeforeClass
+  public static void loadPgJdbcDriver() throws Exception {
+    // Make sure the PG JDBC driver is loaded.
+    Class.forName("org.postgresql.Driver");
+  }
+
+  @Before
+  public void setupRandomResults() {
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            SELECT_RANDOM_STATEMENT,
+            new RandomResultSetGenerator(RANDOM_RESULT_SET_ROW_COUNT, Dialect.POSTGRESQL)
+                .generate()));
+  }
+
+  @After
+  public void clearRequests() {
+    mockSpanner.clearRequests();
+  }
+
+  private String createUrl() {
+    return String.format("jdbc:postgresql://localhost:%d/", pgServer.getLocalPort());
+  }
+
+  private Connection createConnection() throws SQLException {
+    return DriverManager.getConnection(createUrl());
+  }
+
+  @Test
+  public void testCreateSavepoint() throws SQLException {
+    try (Connection connection = createConnection()) {
+      connection.setAutoCommit(false);
+      connection.setSavepoint("s1");
+
+      // PostgreSQL allows multiple savepoints with the same name.
+      connection.setSavepoint("s1");
+
+      // The PostgreSQL JDBC driver also allows named savepoints without a name...
+      PSQLSavepoint savepoint = (PSQLSavepoint) connection.setSavepoint(null);
+      assertTrue(savepoint.getPGName(), savepoint.getPGName().startsWith("JDBC_SAVEPOINT_"));
+
+      // Test invalid identifiers.
+      assertThrows(SQLException.class, () -> connection.setSavepoint(""));
+      assertThrows(SQLException.class, () -> connection.setSavepoint("1"));
+      assertThrows(SQLException.class, () -> connection.setSavepoint("-foo"));
+      assertThrows(SQLException.class, () -> connection.setSavepoint(Strings.repeat("t", 129)));
+    }
+  }
+
+  @Test
+  public void testReleaseSavepoint() throws SQLException {
+    try (Connection connection = createConnection()) {
+      connection.setAutoCommit(false);
+      Savepoint s1 = connection.setSavepoint("s1");
+      connection.releaseSavepoint(s1);
+      assertThrows(SQLException.class, () -> connection.releaseSavepoint(s1));
+
+      Savepoint s1_2 = connection.setSavepoint("s1");
+      Savepoint s2 = connection.setSavepoint("s2");
+      connection.releaseSavepoint(s1_2);
+      // Releasing a savepoint also removes all savepoints after it.
+      SQLException exception =
+          assertThrows(SQLException.class, () -> connection.releaseSavepoint(s2));
+      assertEquals(SQLState.SavepointException.toString(), exception.getSQLState());
+      // Rollback the entire transaction to make the connection usable again.
+      connection.rollback();
+
+      // PostgreSQL allows multiple savepoints with the same name.
+      Savepoint savepoint1 = connection.setSavepoint("s1");
+      Savepoint savepoint2 = connection.setSavepoint("s2");
+      Savepoint savepoint1_2 = connection.setSavepoint("s1");
+      connection.releaseSavepoint(savepoint1_2);
+      connection.releaseSavepoint(savepoint2);
+      connection.releaseSavepoint(savepoint1);
+      assertThrows(SQLException.class, () -> connection.releaseSavepoint(savepoint1));
+    }
+  }
+
+  @Test
+  public void testRollbackToSavepoint() throws SQLException {
+    try (Connection connection = createConnection()) {
+      connection.setAutoCommit(false);
+      Savepoint s1 = connection.setSavepoint("s1");
+      connection.rollback(s1);
+      // Rolling back to a savepoint does not remove it, so we can roll back multiple times to the
+      // same savepoint.
+      connection.rollback(s1);
+
+      Savepoint s2 = connection.setSavepoint("s2");
+      connection.rollback(s1);
+      // Rolling back to a savepoint removes all savepoints after it.
+      assertThrows(SQLException.class, () -> connection.rollback(s2));
+      // Rollback again to make the connection usable again.
+      connection.rollback();
+
+      // PostgreSQL allows multiple savepoints with the same name.
+      connection.setSavepoint("s1");
+      Savepoint savepoint2 = connection.setSavepoint("s2");
+      Savepoint savepoint1 = connection.setSavepoint("s1");
+      connection.rollback(savepoint1);
+      connection.rollback(savepoint2);
+      connection.rollback(savepoint1);
+      connection.rollback(savepoint1);
+      connection.releaseSavepoint(savepoint1);
+      assertThrows(SQLException.class, () -> connection.rollback(savepoint1));
+    }
+  }
+
+  @Test
+  public void testSavepointInAutoCommit() throws SQLException {
+    try (Connection connection = createConnection()) {
+      connection.setAutoCommit(true);
+      assertThrows(SQLException.class, () -> connection.setSavepoint("s1"));
+
+      // Starting a 'manual' transaction in autocommit mode does not enable savepoints in the PG
+      // JDBC driver.
+      connection.createStatement().execute("begin transaction");
+      SQLException exception =
+          assertThrows(SQLException.class, () -> connection.setSavepoint("s1"));
+      assertEquals(SQLState.NoActiveSqlTransaction.toString(), exception.getSQLState());
+    }
+  }
+
+  @Test
+  public void testRollbackToSavepointInReadOnlyTransaction() throws SQLException {
+    try (Connection connection = createConnection()) {
+      connection.setReadOnly(true);
+      connection.setAutoCommit(false);
+
+      // Read-only transactions also support savepoints, but they do not do anything. This feature
+      // is here purely for compatibility.
+      Savepoint s1 = connection.setSavepoint("s1");
+      try (ResultSet resultSet =
+          connection.createStatement().executeQuery(SELECT_RANDOM_STATEMENT.getSql())) {
+        int count = 0;
+        while (resultSet.next()) {
+          count++;
+        }
+        assertEquals(RANDOM_RESULT_SET_ROW_COUNT, count);
+      }
+
+      connection.rollback(s1);
+      try (ResultSet resultSet =
+          connection.createStatement().executeQuery(SELECT_RANDOM_STATEMENT.getSql())) {
+        int count = 0;
+        while (resultSet.next()) {
+          count++;
+        }
+        assertEquals(RANDOM_RESULT_SET_ROW_COUNT, count);
+      }
+      // Committing a read-only transaction is necessary to mark the end of the transaction.
+      // It is a no-op on Cloud Spanner.
+      connection.commit();
+
+      assertEquals(1, mockSpanner.countRequestsOfType(BeginTransactionRequest.class));
+      BeginTransactionRequest beginRequest =
+          mockSpanner.getRequestsOfType(BeginTransactionRequest.class).get(0);
+      assertTrue(beginRequest.getOptions().hasReadOnly());
+      assertEquals(0, mockSpanner.countRequestsOfType(CommitRequest.class));
+    }
+  }
+
+  @Test
+  public void testRollbackToSavepointInReadWriteTransaction() throws SQLException {
+    try (Connection connection = createConnection()) {
+      connection.setAutoCommit(false);
+      Savepoint s1 = connection.setSavepoint("s1");
+      try (ResultSet resultSet =
+          connection.createStatement().executeQuery(SELECT_RANDOM_STATEMENT.getSql())) {
+        int count = 0;
+        while (resultSet.next()) {
+          count++;
+        }
+        assertEquals(RANDOM_RESULT_SET_ROW_COUNT, count);
+      }
+
+      connection.rollback(s1);
+      try (ResultSet resultSet =
+          connection.createStatement().executeQuery(SELECT_RANDOM_STATEMENT.getSql())) {
+        int count = 0;
+        while (resultSet.next()) {
+          count++;
+        }
+        assertEquals(RANDOM_RESULT_SET_ROW_COUNT, count);
+      }
+      connection.commit();
+
+      // Read/write transactions are started with inlined Begin transaction options.
+      assertEquals(0, mockSpanner.countRequestsOfType(BeginTransactionRequest.class));
+      assertEquals(1, mockSpanner.countRequestsOfType(RollbackRequest.class));
+      assertEquals(1, mockSpanner.countRequestsOfType(CommitRequest.class));
+      assertEquals(2, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
+
+      List<AbstractMessage> requests =
+          mockSpanner.getRequests().stream()
+              .filter(
+                  request ->
+                      request instanceof ExecuteSqlRequest
+                          || request instanceof RollbackRequest
+                          || request instanceof CommitRequest)
+              .collect(Collectors.toList());
+      assertEquals(4, requests.size());
+      int index = 0;
+      assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+      assertEquals(RollbackRequest.class, requests.get(index++).getClass());
+      assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+      assertEquals(CommitRequest.class, requests.get(index++).getClass());
+    }
+  }
+
+  @Test
+  public void testRollbackToSavepointWithDmlStatements() throws SQLException {
+    try (Connection connection = createConnection()) {
+      connection.setAutoCommit(false);
+      // First do a query that is included in the transaction.
+      try (ResultSet resultSet =
+          connection.createStatement().executeQuery(SELECT_RANDOM_STATEMENT.getSql())) {
+        int count = 0;
+        while (resultSet.next()) {
+          count++;
+        }
+        assertEquals(RANDOM_RESULT_SET_ROW_COUNT, count);
+      }
+      // Set a savepoint and execute a couple of DML statements.
+      Savepoint s1 = connection.setSavepoint("s1");
+      connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql());
+      Savepoint s2 = connection.setSavepoint("s2");
+      connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql());
+      // Rollback the last DML statement and commit.
+      connection.rollback(s2);
+
+      connection.commit();
+
+      // Read/write transactions are started with inlined Begin transaction options.
+      assertEquals(0, mockSpanner.countRequestsOfType(BeginTransactionRequest.class));
+      assertEquals(1, mockSpanner.countRequestsOfType(RollbackRequest.class));
+      assertEquals(1, mockSpanner.countRequestsOfType(CommitRequest.class));
+      assertEquals(5, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
+
+      List<AbstractMessage> requests =
+          mockSpanner.getRequests().stream()
+              .filter(
+                  request ->
+                      request instanceof ExecuteSqlRequest
+                          || request instanceof RollbackRequest
+                          || request instanceof CommitRequest)
+              .collect(Collectors.toList());
+      assertEquals(7, requests.size());
+      int index = 0;
+      assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+      assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+      assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+      assertEquals(RollbackRequest.class, requests.get(index++).getClass());
+      assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+      assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+      assertEquals(CommitRequest.class, requests.get(index++).getClass());
+    }
+  }
+
+  @Test
+  public void testRollbackToSavepointFails() throws SQLException {
+    Statement statement = Statement.of("select * from foo where bar=true");
+    int numRows = 10;
+    RandomResultSetGenerator generator = new RandomResultSetGenerator(numRows, Dialect.POSTGRESQL);
+    mockSpanner.putStatementResult(StatementResult.query(statement, generator.generate()));
+    try (Connection connection = createConnection()) {
+      connection.setAutoCommit(false);
+      try (ResultSet resultSet = connection.createStatement().executeQuery(statement.getSql())) {
+        int count = 0;
+        while (resultSet.next()) {
+          count++;
+        }
+        assertEquals(numRows, count);
+      }
+      // Set a savepoint and execute a couple of DML statements.
+      Savepoint s1 = connection.setSavepoint("s1");
+      connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql());
+      connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql());
+      // Change the result of the initial query.
+      mockSpanner.putStatementResult(StatementResult.query(statement, generator.generate()));
+      // Rollback to before the DML statements.
+      // This will succeed as long as we don't execute any further statements.
+      connection.rollback(s1);
+
+      // Trying to commit the transaction or execute any other statements on the transaction will
+      // fail.
+      assertThrows(SQLException.class, connection::commit);
+
+      // Read/write transactions are started with inlined Begin transaction options.
+      assertEquals(0, mockSpanner.countRequestsOfType(BeginTransactionRequest.class));
+      assertEquals(2, mockSpanner.countRequestsOfType(RollbackRequest.class));
+      assertEquals(0, mockSpanner.countRequestsOfType(CommitRequest.class));
+      assertEquals(4, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
+
+      List<AbstractMessage> requests =
+          mockSpanner.getRequests().stream()
+              .filter(
+                  request ->
+                      request instanceof ExecuteSqlRequest
+                          || request instanceof RollbackRequest
+                          || request instanceof CommitRequest)
+              .collect(Collectors.toList());
+      assertEquals(6, requests.size());
+      int index = 0;
+      assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+      assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+      assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+      assertEquals(RollbackRequest.class, requests.get(index++).getClass());
+      assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+      assertEquals(RollbackRequest.class, requests.get(index++).getClass());
+    }
+  }
+
+  @Test
+  public void testRollbackToSavepointSucceedsWithRollback() throws SQLException {
+    Statement statement = Statement.of("select * from foo where bar=true");
+    int numRows = 10;
+    RandomResultSetGenerator generator = new RandomResultSetGenerator(numRows, Dialect.POSTGRESQL);
+    mockSpanner.putStatementResult(StatementResult.query(statement, generator.generate()));
+    try (Connection connection = createConnection()) {
+      connection.setAutoCommit(false);
+      try (ResultSet resultSet = connection.createStatement().executeQuery(statement.getSql())) {
+        int count = 0;
+        while (resultSet.next()) {
+          count++;
+        }
+        assertEquals(numRows, count);
+      }
+      // Change the result of the initial query and set a savepoint.
+      Savepoint s1 = connection.setSavepoint("s1");
+      mockSpanner.putStatementResult(StatementResult.query(statement, generator.generate()));
+      // This will succeed as long as we don't execute any further statements.
+      connection.rollback(s1);
+
+      // Rolling back the transaction should now be a no-op, as it has already been rolled back.
+      connection.rollback();
+
+      // Read/write transactions are started with inlined Begin transaction options.
+      assertEquals(0, mockSpanner.countRequestsOfType(BeginTransactionRequest.class));
+      assertEquals(1, mockSpanner.countRequestsOfType(RollbackRequest.class));
+      assertEquals(0, mockSpanner.countRequestsOfType(CommitRequest.class));
+      assertEquals(1, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
+    }
+  }
+
+  @Test
+  public void testMultipleRollbacksWithChangedResults() throws SQLException {
+    Statement statement = Statement.of("select * from foo where bar=true");
+    int numRows = 10;
+    RandomResultSetGenerator generator = new RandomResultSetGenerator(numRows, Dialect.POSTGRESQL);
+    mockSpanner.putStatementResult(StatementResult.query(statement, generator.generate()));
+    try (Connection connection = createConnection()) {
+      connection.setAutoCommit(false);
+      try (ResultSet resultSet = connection.createStatement().executeQuery(statement.getSql())) {
+        int count = 0;
+        while (resultSet.next()) {
+          count++;
+        }
+        assertEquals(numRows, count);
+      }
+      Savepoint s1 = connection.setSavepoint("s1");
+      connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql());
+      Savepoint s2 = connection.setSavepoint("s2");
+      connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql());
+
+      // Change the result of the initial query to make sure that any retry will fail.
+      mockSpanner.putStatementResult(StatementResult.query(statement, generator.generate()));
+      // This will succeed as long as we don't execute any further statements.
+      connection.rollback(s2);
+      // Rolling back one further should also work.
+      connection.rollback(s1);
+
+      // Rolling back the transaction should now be a no-op, as it has already been rolled back.
+      connection.rollback();
+
+      assertEquals(1, mockSpanner.countRequestsOfType(RollbackRequest.class));
+      assertEquals(0, mockSpanner.countRequestsOfType(CommitRequest.class));
+      assertEquals(3, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
+    }
+  }
+
+  @Test
+  public void testMultipleRollbacks() throws SQLException {
+    Statement statement = Statement.of("select * from foo where bar=true");
+    int numRows = 10;
+    RandomResultSetGenerator generator = new RandomResultSetGenerator(numRows, Dialect.POSTGRESQL);
+    mockSpanner.putStatementResult(StatementResult.query(statement, generator.generate()));
+    try (Connection connection = createConnection()) {
+      connection.setAutoCommit(false);
+      try (ResultSet resultSet = connection.createStatement().executeQuery(statement.getSql())) {
+        int count = 0;
+        while (resultSet.next()) {
+          count++;
+        }
+        assertEquals(numRows, count);
+      }
+      Savepoint s1 = connection.setSavepoint("s1");
+      connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql());
+      Savepoint s2 = connection.setSavepoint("s2");
+      connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql());
+
+      // First roll back one step and then one more.
+      connection.rollback(s2);
+      connection.rollback(s1);
+
+      // This will only commit the SELECT query.
+      connection.commit();
+
+      assertEquals(1, mockSpanner.countRequestsOfType(RollbackRequest.class));
+      assertEquals(1, mockSpanner.countRequestsOfType(CommitRequest.class));
+      assertEquals(4, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
+
+      List<AbstractMessage> requests =
+          mockSpanner.getRequests().stream()
+              .filter(
+                  request ->
+                      request instanceof ExecuteSqlRequest
+                          || request instanceof RollbackRequest
+                          || request instanceof CommitRequest)
+              .collect(Collectors.toList());
+      assertEquals(6, requests.size());
+      int index = 0;
+      assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+      assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+      assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+      assertEquals(RollbackRequest.class, requests.get(index++).getClass());
+      assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+      assertEquals(CommitRequest.class, requests.get(index++).getClass());
+    }
+  }
+
+  @Test
+  public void testRollbackBatchDml() throws SQLException {
+    try (Connection connection = createConnection()) {
+      connection.setAutoCommit(false);
+      connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql());
+      Savepoint s1 = connection.setSavepoint("s1");
+      try (java.sql.Statement statement = connection.createStatement()) {
+        statement.addBatch(INSERT_STATEMENT.getSql());
+        statement.addBatch(INSERT_STATEMENT.getSql());
+        statement.executeBatch();
+      }
+      Savepoint s2 = connection.setSavepoint("s2");
+
+      connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql());
+      Savepoint s3 = connection.setSavepoint("s3");
+      try (java.sql.Statement statement = connection.createStatement()) {
+        statement.addBatch(INSERT_STATEMENT.getSql());
+        statement.addBatch(INSERT_STATEMENT.getSql());
+        statement.executeBatch();
+      }
+      connection.setSavepoint("s4");
+
+      connection.rollback(s2);
+
+      connection.commit();
+
+      assertEquals(1, mockSpanner.countRequestsOfType(RollbackRequest.class));
+      assertEquals(1, mockSpanner.countRequestsOfType(CommitRequest.class));
+      assertEquals(3, mockSpanner.countRequestsOfType(ExecuteSqlRequest.class));
+      assertEquals(3, mockSpanner.countRequestsOfType(ExecuteBatchDmlRequest.class));
+
+      List<AbstractMessage> requests =
+          mockSpanner.getRequests().stream()
+              .filter(
+                  request ->
+                      request instanceof ExecuteSqlRequest
+                          || request instanceof RollbackRequest
+                          || request instanceof CommitRequest
+                          || request instanceof ExecuteBatchDmlRequest)
+              .collect(Collectors.toList());
+      assertEquals(8, requests.size());
+      int index = 0;
+      assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+      assertEquals(ExecuteBatchDmlRequest.class, requests.get(index++).getClass());
+      assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+      assertEquals(ExecuteBatchDmlRequest.class, requests.get(index++).getClass());
+      assertEquals(RollbackRequest.class, requests.get(index++).getClass());
+      assertEquals(ExecuteSqlRequest.class, requests.get(index++).getClass());
+      assertEquals(ExecuteBatchDmlRequest.class, requests.get(index++).getClass());
+      assertEquals(CommitRequest.class, requests.get(index++).getClass());
+    }
+  }
+
+  @Test
+  public void testRollbackToSavepointWithoutInternalRetries() throws SQLException {
+    Statement statement = Statement.of("select * from foo where bar=true");
+    int numRows = 10;
+    RandomResultSetGenerator generator = new RandomResultSetGenerator(numRows, Dialect.POSTGRESQL);
+    mockSpanner.putStatementResult(StatementResult.query(statement, generator.generate()));
+    try (Connection connection = createConnection()) {
+      connection.setAutoCommit(false);
+      connection.createStatement().execute("set spanner.retry_aborts_internally=false");
+
+      Savepoint s1 = connection.setSavepoint("s1");
+      try (ResultSet resultSet = connection.createStatement().executeQuery(statement.getSql())) {
+        int count = 0;
+        while (resultSet.next()) {
+          count++;
+        }
+        assertEquals(numRows, count);
+      }
+      // This should work.
+      connection.rollback(s1);
+      // Resuming after a rollback is not supported without internal retries enabled.
+      assertThrows(
+          SQLException.class,
+          () -> connection.createStatement().executeUpdate(INSERT_STATEMENT.getSql()));
+    }
+  }
+
+  @Test
+  public void testRollbackToSavepointWithoutInternalRetriesInReadOnlyTransaction()
+      throws SQLException {
+    Statement statement = Statement.of("select * from foo where bar=true");
+    int numRows = 10;
+    RandomResultSetGenerator generator = new RandomResultSetGenerator(numRows, Dialect.POSTGRESQL);
+    mockSpanner.putStatementResult(StatementResult.query(statement, generator.generate()));
+    try (Connection connection = createConnection()) {
+      connection.setAutoCommit(false);
+      connection.createStatement().execute("set spanner.retry_aborts_internally=false");
+      connection.commit();
+      connection.setReadOnly(true);
+
+      Savepoint s1 = connection.setSavepoint("s1");
+      try (ResultSet resultSet = connection.createStatement().executeQuery(statement.getSql())) {
+        int count = 0;
+        while (resultSet.next()) {
+          count++;
+        }
+        assertEquals(numRows, count);
+      }
+
+      // Both rolling back and resuming after a rollback are supported in a read-only transaction,
+      // even if internal retries have been disabled.
+      connection.rollback(s1);
+      try (ResultSet resultSet = connection.createStatement().executeQuery(statement.getSql())) {
+        int count = 0;
+        while (resultSet.next()) {
+          count++;
+        }
+        assertEquals(numRows, count);
+      }
+    }
+  }
+}
diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/python/psycopg3/Psycopg3MockServerTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/python/psycopg3/Psycopg3MockServerTest.java
index 82edd05d..e93e50f8 100644
--- a/src/test/java/com/google/cloud/spanner/pgadapter/python/psycopg3/Psycopg3MockServerTest.java
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/python/psycopg3/Psycopg3MockServerTest.java
@@ -1368,7 +1368,6 @@ public class Psycopg3MockServerTest extends AbstractMockServerTest {
   }
 
   @Test
-  @Ignore("Nested transactions are not yet supported")
   public void testNestedTransaction() throws Exception {
     String sql = "SELECT * FROM all_types WHERE col_bigint=$1";
     mockSpanner.putStatementResult(
@@ -1389,7 +1388,34 @@ public class Psycopg3MockServerTest extends AbstractMockServerTest {
             + "col_timestamptz: 2022-02-16 13:18:02.123456+00:00\n"
             + "col_date: 2022-03-29\n"
             + "col_string: test\n"
-            + "col_jsonb: {'key': 'value'}\n",
+            + "col_jsonb: {'key': 'value'}\n"
+            + "col_array_bigint: [1, None, 2]\n"
+            + "col_array_bool: [True, None, False]\n"
+            + "col_array_bytea: [b'bytes1', None, b'bytes2']\n"
+            + "col_array_float8: [3.14, None, -99.99]\n"
+            + "col_array_int: [-100, None, -200]\n"
+            + "col_array_numeric: [Decimal('6.626'), None, Decimal('-3.14')]\n"
+            + "col_array_timestamptz: [datetime.datetime(2022, 2, 16, 16, 18, 2, 123456, tzinfo=<UTC>), None, datetime.datetime(2000, 1, 1, 0, 0, tzinfo=<UTC>)]\n"
+            + "col_array_date: [datetime.date(2023, 2, 20), None, datetime.date(2000, 1, 1)]\n"
+            + "col_array_string: ['string1', None, 'string2']\n"
+            + "col_array_jsonb: [{'key': 'value1'}, None, {'key': 'value2'}]\n",
+        result);
+  }
+
+  @Test
+  public void testRollbackNestedTransaction() throws Exception {
+    String sql = "SELECT * FROM all_types WHERE col_bigint=$1";
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            Statement.of(sql), ALL_TYPES_RESULTSET.toBuilder().clearRows().build()));
+    mockSpanner.putStatementResult(
+        StatementResult.query(
+            Statement.newBuilder(sql).bind("p1").to(1L).build(), ALL_TYPES_RESULTSET));
+
+    String result = execute("rollback_nested_transaction");
+    assertEquals(
+        "Nested transaction failed with error: Test rollback of savepoint\n"
+            + "Rolling back to a savepoint succeeded\n",
         result);
   }
 
diff --git a/src/test/python/psycopg3/psycopg3_tests.py b/src/test/python/psycopg3/psycopg3_tests.py
index 9180ef54..e3ca90f3 100644
--- a/src/test/python/psycopg3/psycopg3_tests.py
+++ b/src/test/python/psycopg3/psycopg3_tests.py
@@ -19,7 +19,8 @@ from decimal import Decimal
 
 import pytz
 import psycopg
-from psycopg import Copy
+from psycopg import Copy, Rollback
+from psycopg.errors import InFailedSqlTransaction
 from psycopg.types.json import Jsonb
 
 
@@ -496,8 +497,6 @@ def named_cursor(conn_string: str):
       print_all_types(row)
 
 
-# This method is currently not being used, as nested transactions are not yet
-# supported.
 def nested_transaction(conn_string: str):
   with psycopg.connect(conn_string) as conn:
     with conn.transaction() as tx1:
@@ -507,6 +506,27 @@ def nested_transaction(conn_string: str):
         print_all_types(row)
 
 
+def rollback_nested_transaction(conn_string: str):
+  with psycopg.connect(conn_string) as conn:
+    with conn.transaction():
+      try:
+        with conn.transaction():
+          conn.execute(
+            "SELECT * FROM all_types WHERE col_bigint=%s", (1,)).fetchone()
+          raise ValueError("Test rollback of savepoint")
+        print("Nested transaction succeeded")
+      except ValueError as e:
+        # We should come here, as the inner transaction always raises an error.
+        print("Nested transaction failed with error:", e)
+      try:
+        conn.execute(
+          "SELECT * FROM all_types WHERE col_bigint=%s", (1,)).fetchone()
+        print("Rolling back to a savepoint succeeded")
+      except InFailedSqlTransaction as e:
+        print("Outer transaction failed with error:", e)
+        raise Rollback()
+
+
 def create_batch_insert_values(batch_size: int):
   values = []
   for i in range(batch_size):