-
Notifications
You must be signed in to change notification settings - Fork 40
Description
Background
I'm integrating isolation levels for transactions in the Serverpod Framework and wanted to create a test that highlighted the difference between REPEATABLE READ
and SERIALIZABLE
transaction isolation level.
One way to prove the integration is correct and the transaction isolation levels are respected is to insert two rows in a table. And then do the following operations with two transactions (T1 and T2):
- T1 - Read the first row in the table
- T2 - Read the second row in the table
- T1 - Update the second row in the table
- T2 - Update the first row in the table.
- Commit both transactions
If this is done using the REPEATABLE READ
isolation level the modification is allowed and both entries are updated.
If this is done using the SERIALIZABLE
isolation level only the first modification is allowed and the second one will be rejected with a 40001 serialization_failure
exception.
Validation
To validate the behavior in Postgres I created two SQL scrips and ran them through two separate connections directly towards a database, starting the first script first and then running the second one.
Create database
DROP TABLE IF EXISTS t;
CREATE TABLE t (id INT PRIMARY KEY, counter INT);
INSERT INTO t VALUES (1, 0);
INSERT INTO t VALUES (2, 1);
Repeatable read
First transaction
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT * from t WHERE id=1;
SELECT pg_sleep(5);
UPDATE t SET counter = counter + 10 WHERE id=2;
COMMIT;
Second transaction
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT * from t WHERE id=2;
UPDATE t SET counter = counter + 20 WHERE id=1;
COMMIT;
Result - Transaction will complete without exception and first row will have value 20 and second row will have value 11.
Serializable
First transaction
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT * from t WHERE id=1;
SELECT pg_sleep(5);
UPDATE t SET counter = counter + 10 WHERE id=2;
COMMIT;
Second transaction
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT * from t WHERE id=2;
UPDATE t SET counter = counter + 20 WHERE id=1;
-- Optional sleep to simulate a long running transaction
-- SELECT pg_sleep(5);
COMMIT;
Result Only one transaction will complete while the other transaction will throw an exception.
Problem
When using the driver, I noticed that if two SERIALIZABLE
transactions are allowed to complete in parallel a PgException
with the description Session or transaction has already finished, did you forget to await a statement?
is thrown instead of the expected ServerException
with code 40001
.
To capture my findings, I created three tests, one validating the REPEATABLE READ
isolation level behavior, one validating the SERIALIZABLE
isolation level behavior with special care, and then the last one where the unexpected behavior is exhibited.
Tests are available as a single commit on my branch here: SandPod#1
The last test that exhibits the unexpected behavior and is also available here:
Validating test
withPostgresServer('Transaction isolation level', (server) {
group('Given two rows in the database and two database connections', () {
late Connection conn1;
late Connection conn2;
setUp(() async {
conn1 = await server.newConnection();
conn2 = await server.newConnection();
await conn1.execute('CREATE TABLE t (id INT PRIMARY KEY, counter INT)');
await conn1.execute('INSERT INTO t VALUES (1, 0)');
await conn1.execute('INSERT INTO t VALUES (2, 1)');
});
tearDown(() async {
await conn1.execute('DROP TABLE t;');
await conn1.close();
await conn2.close();
});
test(
'when two transactions using repeatable read isolation level'
'reads the row updated by the other transaction'
'then one transaction throws exception ', () async {
final c1 = Completer();
final c2 = Completer();
final f1 = Future.microtask(
() => conn1.runTx(
settings: TransactionSettings(
isolationLevel: IsolationLevel.serializable,
),
(session) async {
await session.execute('SELECT * from t WHERE id=1');
c1.complete();
await c2.future;
await session
.execute('UPDATE t SET counter = counter + 10 WHERE id=2');
},
),
);
final f2 = Future.microtask(
() => conn2.runTx(
settings: TransactionSettings(
isolationLevel: IsolationLevel.serializable,
),
(session) async {
await session.execute('SELECT * from t WHERE id=2');
await c1.future;
// If we complete both transactions in parallel, we get an unexpected
// exception
c2.complete();
await session
.execute('UPDATE t SET counter = counter + 20 WHERE id=1');
// If we complete the first transaction after the second transaction
// the correct exception is thrown
// c2.complete();
},
),
);
// This test throws Severity.error Session or transaction has already
// finished, did you forget to await a statement?
await expectLater(
() => Future.wait([f1, f2]),
throwsA(
isA<ServerException>()
.having((e) => e.severity, 'Exception severity', Severity.error)
.having((e) => e.code, 'Exception code', '40001'),
),
);
});
});
});