Skip to content

Commit b644770

Browse files
songgaojulienschmidt
authored andcommitted
add rejectReadOnly option (to fix AWS Aurora failover) (#604)
* add RejectReadOnly * update README.md * close connection explicitly before returning ErrBadConn for 1792 (#2) * add test and improve doc * doc/comment changes
1 parent 44fa292 commit b644770

File tree

5 files changed

+106
-5
lines changed

5 files changed

+106
-5
lines changed

Diff for: AUTHORS

+1
Original file line numberDiff line numberDiff line change
@@ -61,5 +61,6 @@ Zhenye Xie <xiezhenye at gmail.com>
6161

6262
Barracuda Networks, Inc.
6363
Google Inc.
64+
Keybase Inc.
6465
Pivotal Inc.
6566
Stripe Inc.

Diff for: README.md

+25
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,31 @@ Default: 0
267267

268268
I/O read timeout. The value must be a decimal number with a unit suffix (*"ms"*, *"s"*, *"m"*, *"h"*), such as *"30s"*, *"0.5m"* or *"1m30s"*.
269269

270+
##### `rejectReadOnly`
271+
272+
```
273+
Type: bool
274+
Valid Values: true, false
275+
Default: false
276+
```
277+
278+
279+
`rejectreadOnly=true` causes the driver to reject read-only connections. This
280+
is for a possible race condition during an automatic failover, where the mysql
281+
client gets connected to a read-only replica after the failover.
282+
283+
Note that this should be a fairly rare case, as an automatic failover normally
284+
happens when the primary is down, and the race condition shouldn't happen
285+
unless it comes back up online as soon as the failover is kicked off. On the
286+
other hand, when this happens, a MySQL application can get stuck on a
287+
read-only connection until restarted. It is however fairly easy to reproduce,
288+
for example, using a manual failover on AWS Aurora's MySQL-compatible cluster.
289+
290+
If you are not relying on read-only transactions to reject writes that aren't
291+
supposed to happen, setting this on some MySQL providers (such as AWS Aurora)
292+
is safer for failovers.
293+
294+
270295
##### `strict`
271296

272297
```

Diff for: driver_test.go

+47-5
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,17 @@ func (dbt *DBTest) mustQuery(query string, args ...interface{}) (rows *sql.Rows)
171171
return rows
172172
}
173173

174+
func maybeSkip(t *testing.T, err error, skipErrno uint16) {
175+
mySQLErr, ok := err.(*MySQLError)
176+
if !ok {
177+
return
178+
}
179+
180+
if mySQLErr.Number == skipErrno {
181+
t.Skipf("skipping test for error: %v", err)
182+
}
183+
}
184+
174185
func TestEmptyQuery(t *testing.T) {
175186
runTests(t, dsn, func(dbt *DBTest) {
176187
// just a comment, no query
@@ -1168,11 +1179,9 @@ func TestStrict(t *testing.T) {
11681179
if conn != nil {
11691180
conn.Close()
11701181
}
1171-
if me, ok := err.(*MySQLError); ok && me.Number == 1231 {
1172-
// Error 1231: Variable 'sql_mode' can't be set to the value of 'ALLOW_INVALID_DATES'
1173-
// => skip test, MySQL server version is too old
1174-
return
1175-
}
1182+
// Error 1231: Variable 'sql_mode' can't be set to the value of
1183+
// 'ALLOW_INVALID_DATES' => skip test, MySQL server version is too old
1184+
maybeSkip(t, err, 1231)
11761185
runTests(t, relaxedDsn, func(dbt *DBTest) {
11771186
dbt.mustExec("CREATE TABLE test (a TINYINT NOT NULL, b CHAR(4))")
11781187

@@ -1949,3 +1958,36 @@ func TestColumnsReusesSlice(t *testing.T) {
19491958
t.Fatalf("expected columnNames to be set, got nil")
19501959
}
19511960
}
1961+
1962+
func TestRejectReadOnly(t *testing.T) {
1963+
runTests(t, dsn, func(dbt *DBTest) {
1964+
// Create Table
1965+
dbt.mustExec("CREATE TABLE test (value BOOL)")
1966+
// Set the session to read-only. We didn't set the `rejectReadOnly`
1967+
// option, so any writes after this should fail.
1968+
_, err := dbt.db.Exec("SET SESSION TRANSACTION READ ONLY")
1969+
// Error 1193: Unknown system variable 'TRANSACTION' => skip test,
1970+
// MySQL server version is too old
1971+
maybeSkip(t, err, 1193)
1972+
if _, err := dbt.db.Exec("DROP TABLE test"); err == nil {
1973+
t.Fatalf("writing to DB in read-only session without " +
1974+
"rejectReadOnly did not error")
1975+
}
1976+
// Set the session back to read-write so runTests() can properly clean
1977+
// up the table `test`.
1978+
dbt.mustExec("SET SESSION TRANSACTION READ WRITE")
1979+
})
1980+
1981+
// Enable the `rejectReadOnly` option.
1982+
runTests(t, dsn+"&rejectReadOnly=true", func(dbt *DBTest) {
1983+
// Create Table
1984+
dbt.mustExec("CREATE TABLE test (value BOOL)")
1985+
// Set the session to read only. Any writes after this should error on
1986+
// a driver.ErrBadConn, and cause `database/sql` to initiate a new
1987+
// connection.
1988+
dbt.mustExec("SET SESSION TRANSACTION READ ONLY")
1989+
// This would error, but `database/sql` should automatically retry on a
1990+
// new connection which is not read-only, and eventually succeed.
1991+
dbt.mustExec("DROP TABLE test")
1992+
})
1993+
}

Diff for: dsn.go

+18
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ type Config struct {
5353
InterpolateParams bool // Interpolate placeholders into query string
5454
MultiStatements bool // Allow multiple statements in one query
5555
ParseTime bool // Parse time values to time.Time
56+
RejectReadOnly bool // Reject read-only connections
5657
Strict bool // Return warnings as errors
5758
}
5859

@@ -195,6 +196,15 @@ func (cfg *Config) FormatDSN() string {
195196
buf.WriteString(cfg.ReadTimeout.String())
196197
}
197198

199+
if cfg.RejectReadOnly {
200+
if hasParam {
201+
buf.WriteString("&rejectReadOnly=true")
202+
} else {
203+
hasParam = true
204+
buf.WriteString("?rejectReadOnly=true")
205+
}
206+
}
207+
198208
if cfg.Strict {
199209
if hasParam {
200210
buf.WriteString("&strict=true")
@@ -472,6 +482,14 @@ func parseDSNParams(cfg *Config, params string) (err error) {
472482
return
473483
}
474484

485+
// Reject read-only connections
486+
case "rejectReadOnly":
487+
var isBool bool
488+
cfg.RejectReadOnly, isBool = readBool(value)
489+
if !isBool {
490+
return errors.New("invalid bool value: " + value)
491+
}
492+
475493
// Strict mode
476494
case "strict":
477495
var isBool bool

Diff for: packets.go

+15
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,21 @@ func (mc *mysqlConn) handleErrorPacket(data []byte) error {
551551
// Error Number [16 bit uint]
552552
errno := binary.LittleEndian.Uint16(data[1:3])
553553

554+
// 1792: ER_CANT_EXECUTE_IN_READ_ONLY_TRANSACTION
555+
if errno == 1792 && mc.cfg.RejectReadOnly {
556+
// Oops; we are connected to a read-only connection, and won't be able
557+
// to issue any write statements. Since RejectReadOnly is configured,
558+
// we throw away this connection hoping this one would have write
559+
// permission. This is specifically for a possible race condition
560+
// during failover (e.g. on AWS Aurora). See README.md for more.
561+
//
562+
// We explicitly close the connection before returning
563+
// driver.ErrBadConn to ensure that `database/sql` purges this
564+
// connection and initiates a new one for next statement next time.
565+
mc.Close()
566+
return driver.ErrBadConn
567+
}
568+
554569
pos := 3
555570

556571
// SQL State [optional: # + 5bytes string]

0 commit comments

Comments
 (0)