Skip to content

Commit a8e256a

Browse files
committed
Seralizable transactions should retry automatically
1 parent 2636de9 commit a8e256a

File tree

3 files changed

+74
-1
lines changed

3 files changed

+74
-1
lines changed

coderd/database/db.go

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,42 @@ func (q *sqlQuerier) Ping(ctx context.Context) (time.Duration, error) {
6767
return time.Since(start), err
6868
}
6969

70-
// InTx performs database operations inside a transaction.
7170
func (q *sqlQuerier) InTx(function func(Store) error, txOpts *sql.TxOptions) error {
71+
_, inTx := q.db.(*sqlx.Tx)
72+
isolation := sql.LevelDefault
73+
if txOpts != nil {
74+
isolation = txOpts.Isolation
75+
}
76+
77+
// If we are not already in a transaction, and we are running in serializable
78+
// mode, we need to run the transaction in a retry loop. The caller should be
79+
// prepared to allow retries if using serializable mode.
80+
// If we are in a transaction already, the parent InTx call will handle the retry.
81+
// We do not want to duplicate those retries.
82+
if !inTx && isolation == sql.LevelSerializable {
83+
// This is an arbitrarily chosen number.
84+
const retryAmount = 3
85+
var err error
86+
attempts := 0
87+
for attempts = 0; attempts < retryAmount; attempts++ {
88+
err = q.runTx(function, txOpts)
89+
if err == nil {
90+
// Transaction succeeded.
91+
return nil
92+
}
93+
if err != nil && !IsSerializedError(err) {
94+
// We should only retry if the error is a serialization error.
95+
return err
96+
}
97+
}
98+
// Transaction kept failing in serializable mode.
99+
return xerrors.Errorf("transaction failed after %d attempts: %w", attempts, err)
100+
}
101+
return q.runTx(function, txOpts)
102+
}
103+
104+
// InTx performs database operations inside a transaction.
105+
func (q *sqlQuerier) runTx(function func(Store) error, txOpts *sql.TxOptions) error {
72106
if _, ok := q.db.(*sqlx.Tx); ok {
73107
// If the current inner "db" is already a transaction, we just reuse it.
74108
// We do not need to handle commit/rollback as the outer tx will handle

coderd/database/db_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,44 @@ import (
88
"testing"
99

1010
"github.com/google/uuid"
11+
"github.com/lib/pq"
1112
"github.com/stretchr/testify/require"
1213

1314
"github.com/coder/coder/coderd/database"
1415
"github.com/coder/coder/coderd/database/migrations"
1516
"github.com/coder/coder/coderd/database/postgres"
1617
)
1718

19+
func TestSerializedRetry(t *testing.T) {
20+
t.Parallel()
21+
if testing.Short() {
22+
t.SkipNow()
23+
}
24+
25+
sqlDB := testSQLDB(t)
26+
db := database.New(sqlDB)
27+
28+
called := 0
29+
txOpts := &sql.TxOptions{Isolation: sql.LevelSerializable}
30+
err := db.InTx(func(tx database.Store) error {
31+
// Test nested error
32+
return tx.InTx(func(tx database.Store) error {
33+
// The easiest way to mock a serialization failure is to
34+
// return a serialization failure error.
35+
called++
36+
return &pq.Error{
37+
Code: "40001",
38+
Message: "serialization_failure",
39+
}
40+
}, txOpts)
41+
}, txOpts)
42+
require.Error(t, err, "should fail")
43+
// The double "execute transaction: execute transaction" is from the nested transactions.
44+
// Just want to make sure we don't try 9 times.
45+
require.Equal(t, err.Error(), "transaction failed after 3 attempts: execute transaction: execute transaction: pq: serialization_failure", "error message")
46+
require.Equal(t, called, 3, "should retry 3 times")
47+
}
48+
1849
func TestNestedInTx(t *testing.T) {
1950
t.Parallel()
2051
if testing.Short() {

coderd/database/errors.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ import (
66
"github.com/lib/pq"
77
)
88

9+
func IsSerializedError(err error) bool {
10+
var pqErr *pq.Error
11+
if errors.As(err, &pqErr) {
12+
return pqErr.Code.Name() == "serialization_failure"
13+
}
14+
return false
15+
}
16+
917
// IsUniqueViolation checks if the error is due to a unique violation.
1018
// If one or more specific unique constraints are given as arguments,
1119
// the error must be caused by one of them. If no constraints are given,

0 commit comments

Comments
 (0)