Skip to content

Commit 570fb96

Browse files
authored
feat: support savepoints (#796)
* 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. * docs: update documentation * test: add tests and documentation * fix: update license header
1 parent e0b48ee commit 570fb96

File tree

10 files changed

+706
-36
lines changed

10 files changed

+706
-36
lines changed

docs/psycopg3.md

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,9 @@ psycopg.errors.RaiseException: Unknown statement: DECLARE "my_cursor" CURSOR FOR
7575

7676
### Nested Transactions
7777
`psycopg3` implements [nested transactions](https://www.psycopg.org/psycopg3/docs/basic/transactions.html#nested-transactions)
78-
using `SAVEPOINT`. This feature is currently not supported with PGAdapter.
79-
80-
Creating a nested transaction in `psycopg3` with PGAdapter will cause an error like the following:
81-
82-
```
83-
psycopg.errors.RaiseException: Unknown statement: SAVEPOINT "_pg3_2"
84-
```
78+
using `SAVEPOINT`. Rolling back to a `SAVEPOINT` can fail if the transaction contained at least one
79+
query that called a volatile function or if the underlying data that has been accessed by the
80+
transaction has been modified by another transaction.
8581

8682
## Performance Considerations
8783

samples/python/sqlalchemy-sample/README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ The following limitations are currently known:
8383
| 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). |
8484
| 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. |
8585
| INSERT ... ON CONFLICT | `INSERT ... ON CONFLICT` is not supported. |
86-
| SAVEPOINT | Nested transactions and savepoints are not supported. |
86+
| SAVEPOINT | Rolling back to a `SAVEPOINT` can fail if the transaction contained at least one query that called a volatile function. |
8787
| SELECT ... FOR UPDATE | `SELECT ... FOR UPDATE` is not supported. |
8888
| Server side cursors | Server side cursors are currently not supported. |
8989
| 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
116116
will fail.
117117

118118
### SAVEPOINT - Nested transactions
119-
`SAVEPOINT`s are not supported by Cloud Spanner. Nested transactions in SQLAlchemy are translated to
120-
savepoints and are therefore not supported. Trying to use `Session.begin_nested()`
121-
(https://docs.sqlalchemy.org/en/14/orm/session_api.html#sqlalchemy.orm.Session.begin_nested) will fail.
119+
Rolling back to a `SAVEPOINT` can fail if the transaction contained at least one query that called a
120+
volatile function or if the underlying data that has been accessed by the transaction has been
121+
modified by another transaction.
122122

123123
### Locking - SELECT ... FOR UPDATE
124124
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).

samples/python/sqlalchemy2-sample/README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ The following limitations are currently known:
7777
| 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). |
7878
| 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. |
7979
| INSERT ... ON CONFLICT | `INSERT ... ON CONFLICT` is not supported. |
80-
| SAVEPOINT | Nested transactions and savepoints are not supported. |
80+
| SAVEPOINT | Rolling back to a `SAVEPOINT` can fail if the transaction contained at least one query that called a volatile function. |
8181
| SELECT ... FOR UPDATE | `SELECT ... FOR UPDATE` is not supported. |
8282
| Server side cursors | Server side cursors are currently not supported. |
8383
| 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
110110
will fail.
111111

112112
### SAVEPOINT - Nested transactions
113-
`SAVEPOINT`s are not supported by Cloud Spanner. Nested transactions in SQLAlchemy are translated to
114-
savepoints and are therefore not supported. Trying to use `Session.begin_nested()`
115-
(https://docs.sqlalchemy.org/en/20/orm/session_api.html#sqlalchemy.orm.Session.begin_nested) will fail.
113+
Rolling back to a `SAVEPOINT` can fail if the transaction contained at least one query that called a
114+
volatile function or if the underlying data that has been accessed by the transaction has been
115+
modified by another transaction.
116116

117117
### Locking - SELECT ... FOR UPDATE
118118
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).

src/main/java/com/google/cloud/spanner/pgadapter/ConnectionHandler.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import com.google.cloud.spanner.connection.Connection;
3535
import com.google.cloud.spanner.connection.ConnectionOptions;
3636
import com.google.cloud.spanner.connection.ConnectionOptionsHelper;
37+
import com.google.cloud.spanner.connection.SavepointSupport;
3738
import com.google.cloud.spanner.pgadapter.error.PGException;
3839
import com.google.cloud.spanner.pgadapter.error.PGExceptionFactory;
3940
import com.google.cloud.spanner.pgadapter.error.SQLState;
@@ -240,6 +241,7 @@ public void connectToSpanner(String database, @Nullable Credentials credentials)
240241
spannerConnection.close();
241242
throw e;
242243
}
244+
spannerConnection.setSavepointSupport(SavepointSupport.ENABLED);
243245
this.spannerConnection = spannerConnection;
244246
this.databaseId = connectionOptions.getDatabaseId();
245247
this.extendedQueryProtocolHandler = new ExtendedQueryProtocolHandler(this);

src/main/java/com/google/cloud/spanner/pgadapter/error/SQLState.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,10 @@ public enum SQLState {
149149
InFailedSqlTransaction("25P02"),
150150
IdleInTransactionSessionTimeout("25P03"),
151151

152+
// Class 3B — Savepoint Exception
153+
SavepointException("3B000"),
154+
InvalidSavepointSpecification("3B001"),
155+
152156
// Class 42 — Syntax Error or Access Rule Violation
153157
SyntaxErrorOrAccessRuleViolation("42000"),
154158
SyntaxError("42601"),

src/main/java/com/google/cloud/spanner/pgadapter/statements/BackendConnection.java

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -604,7 +604,16 @@ boolean isUpdate() {
604604

605605
@Override
606606
void execute() {
607-
result.set(NO_RESULT);
607+
try {
608+
checkConnectionState();
609+
spannerConnection.savepoint(savepointStatement.getSavepointName());
610+
result.set(NO_RESULT);
611+
} catch (Exception exception) {
612+
PGException pgException =
613+
PGException.newBuilder(exception).setSQLState(SQLState.SavepointException).build();
614+
result.setException(pgException);
615+
throw pgException;
616+
}
608617
}
609618
}
610619

@@ -623,7 +632,16 @@ boolean isUpdate() {
623632

624633
@Override
625634
void execute() {
626-
result.set(NO_RESULT);
635+
try {
636+
checkConnectionState();
637+
spannerConnection.releaseSavepoint(releaseStatement.getSavepointName());
638+
result.set(NO_RESULT);
639+
} catch (Exception exception) {
640+
PGException pgException =
641+
PGException.newBuilder(exception).setSQLState(SQLState.SavepointException).build();
642+
result.setException(pgException);
643+
throw pgException;
644+
}
627645
}
628646
}
629647

@@ -642,11 +660,15 @@ boolean isUpdate() {
642660

643661
@Override
644662
void execute() {
645-
throw setAndReturn(
646-
result,
647-
PGExceptionFactory.newPGException(
648-
"Statement 'ROLLBACK [WORK | TRANSACTION] TO [SAVEPOINT] savepoint_name' is not supported",
649-
SQLState.FeatureNotSupported));
663+
try {
664+
spannerConnection.rollbackToSavepoint(rollbackToStatement.getSavepointName());
665+
result.set(NO_RESULT);
666+
} catch (Exception exception) {
667+
PGException pgException =
668+
PGException.newBuilder(exception).setSQLState(SQLState.SavepointException).build();
669+
result.setException(pgException);
670+
throw pgException;
671+
}
650672
}
651673
}
652674

src/test/java/com/google/cloud/spanner/pgadapter/JdbcMockServerTest.java

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@
8181
import java.sql.ResultSet;
8282
import java.sql.ResultSetMetaData;
8383
import java.sql.SQLException;
84+
import java.sql.Savepoint;
8485
import java.sql.Types;
8586
import java.time.LocalDate;
8687
import java.time.LocalDateTime;
@@ -102,11 +103,9 @@
102103
import org.postgresql.PGConnection;
103104
import org.postgresql.PGStatement;
104105
import org.postgresql.core.Oid;
105-
import org.postgresql.jdbc.PSQLSavepoint;
106106
import org.postgresql.jdbc.PgStatement;
107107
import org.postgresql.util.PGobject;
108108
import org.postgresql.util.PSQLException;
109-
import org.postgresql.util.PSQLState;
110109

111110
@RunWith(Parameterized.class)
112111
public class JdbcMockServerTest extends AbstractMockServerTest {
@@ -4678,23 +4677,28 @@ public void testDescribeTruncate() throws SQLException {
46784677
public void testUnnamedSavepoint() throws SQLException {
46794678
try (Connection connection = DriverManager.getConnection(createUrl())) {
46804679
connection.setAutoCommit(false);
4681-
assertNotNull(connection.setSavepoint());
4680+
Savepoint savepoint = connection.setSavepoint();
4681+
assertNotNull(savepoint);
4682+
assertEquals(0, savepoint.getSavepointId());
4683+
4684+
Savepoint savepoint2 = connection.setSavepoint();
4685+
assertEquals(1, savepoint2.getSavepointId());
46824686
}
46834687
}
46844688

46854689
@Test
46864690
public void testNamedSavepoint() throws SQLException {
46874691
try (Connection connection = DriverManager.getConnection(createUrl())) {
46884692
connection.setAutoCommit(false);
4689-
assertEquals("my-savepoint", connection.setSavepoint("my-savepoint").getSavepointName());
4693+
assertEquals("my_savepoint", connection.setSavepoint("my_savepoint").getSavepointName());
46904694
}
46914695
}
46924696

46934697
@Test
46944698
public void testReleaseSavepoint() throws SQLException {
46954699
try (Connection connection = DriverManager.getConnection(createUrl())) {
46964700
connection.setAutoCommit(false);
4697-
PSQLSavepoint savepoint = new PSQLSavepoint("my-savepoint");
4701+
Savepoint savepoint = connection.setSavepoint("my_savepoint");
46984702
connection.releaseSavepoint(savepoint);
46994703
}
47004704
}
@@ -4703,10 +4707,8 @@ public void testReleaseSavepoint() throws SQLException {
47034707
public void testRollbackToSavepoint() throws SQLException {
47044708
try (Connection connection = DriverManager.getConnection(createUrl())) {
47054709
connection.setAutoCommit(false);
4706-
PSQLSavepoint savepoint = new PSQLSavepoint("my-savepoint");
4707-
PSQLException exception =
4708-
assertThrows(PSQLException.class, () -> connection.rollback(savepoint));
4709-
assertEquals(PSQLState.NOT_IMPLEMENTED.getState(), exception.getSQLState());
4710+
Savepoint savepoint = connection.setSavepoint("my_savepoint");
4711+
connection.rollback(savepoint);
47104712
}
47114713
}
47124714

0 commit comments

Comments
 (0)