Repository: go-sql-driver/mysql

Issue:
transaction canceled context may result in ErrInvalidConn instead of context.Canceled error
When executing a context-aware operation during a transaction, different error types are returned depending on when the context is canceled.

In the following example:
```
               tx, err := db.Begin()
               if err != nil {
                      fmt.Printf("Failed to obtain db connection: %v", err)
                      return
               }
               returnedErrors := make(chan error, 1)
               go func() {
                       _, err := tx.ExecContext(ctx, `INSERT INTO foo VALUES (3, "three")`)
                       returnedErrors <- err
               }()
               cancel()

               err = <-returnedErrors
               switch {
               case errors.Is(err, ErrInvalidConn):
                       fmt.Printf("The context was canceled, but ExecContext got error %v", err)
               case errors.Is(err, context.Canceled):
                       // cool
               case err != nil:
                       fmt.Printf("Got unexpected error %v", err)
               }

               err = tx.Rollback()
               switch {
               case errors.Is(err, ErrInvalidConn):
                      fmt.Printf("The context was canceled, but ROLLBACK got error %v", err)
               case errors.Is(err, context.Canceled):
                       // cool
               case err != nil:
                       fmt.Printf("Got unexpected error %v", err)
               }
```

The context may be canceled (1) before or during the ExecContext operation, (2) after the ExecContext operation returns, or (3) after the ExecContext operation succeeds but before the context is removed from the watcher.

(1) If the context is canceled before or during ExecContext, ExecContext returns the error as cached by the context watcher, returning context.Canceled.
(2) If the context is canceled after the ExecContext, no errors are returned, everything succeeds.
(3) !bug! If the context is canceled after the ExecContext operation succeeds but before the context is removed from the watcher, then `tx.Rollback()` or `tx.Commit()` return ErrInvalidConn.

I argue that the behavior in (3) should be consistent with the behavior in (1) - the error returned by `tx.Rollback()` or `tx.Commit()` should be context.Canceled. The connection being closed is a side effect of the root cause that the context passed to ExecContext has been canceled. (This is also a simpler fix than attempting to remove the race of a context being canceled after ExecContext's operation is successfully executed but before the context is removed from the watcher.)

In our application, we trigger alerts on unexpected errors like ErrInvalidConn, but log and do not alert on expected errors like context.Canceled. The Commit/Rollback behavior ends up causing alerts in our application.

Discussion:

Solution patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 2e07fea..0dc2074 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -29,10 +29,10 @@ jobs:
           import os
           go = [
               # Keep the most recent production release at the top
-              '1.23',
+              '1.24',
               # Older production releases
+              '1.23',
               '1.22',
-              '1.21',
           ]
           mysql = [
               '9.0',
diff --git a/AUTHORS b/AUTHORS
index 510b869..53841ef 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -18,13 +18,14 @@ Alex Snast <alexsn at fb.com>
 Alexey Palazhchenko <alexey.palazhchenko at gmail.com>
 Andrew Reid <andrew.reid at tixtrack.com>
 Animesh Ray <mail.rayanimesh at gmail.com>
-Arne Hormann <arnehormann at gmail.com>
 Ariel Mashraki <ariel at mashraki.co.il>
+Arne Hormann <arnehormann at gmail.com>
 Artur Melanchyk <artur.melanchyk@gmail.com>
 Asta Xie <xiemengjun at gmail.com>
 B Lamarche <blam413 at gmail.com>
 Bes Dollma <bdollma@thousandeyes.com>
 Bogdan Constantinescu <bog.con.bc at gmail.com>
+Brad Higgins <brad at defined.net>
 Brian Hendriks <brian at dolthub.com>
 Bulat Gaifullin <gaifullinbf at gmail.com>
 Caine Jette <jette at alum.mit.edu>
@@ -37,6 +38,7 @@ Daniel Montoya <dsmontoyam at gmail.com>
 Daniel Nichter <nil at codenode.com>
 Daniël van Eeden <git at myname.nl>
 Dave Protasowski <dprotaso at gmail.com>
+Diego Dupin <diego.dupin at gmail.com>
 Dirkjan Bussink <d.bussink at gmail.com>
 DisposaBoy <disposaboy at dby.me>
 Egor Smolyakov <egorsmkv at gmail.com>
@@ -64,6 +66,7 @@ Jeff Hodges <jeff at somethingsimilar.com>
 Jeffrey Charles <jeffreycharles at gmail.com>
 Jennifer Purevsuren <jennifer at dolthub.com>
 Jerome Meyer <jxmeyer at gmail.com>
+Jiabin Zhang <jiabin.z at qq.com>
 Jiajia Zhong <zhong2plus at gmail.com>
 Jian Zhen <zhenjl at gmail.com>
 Joe Mann <contact at joemann.co.uk>
@@ -83,10 +86,11 @@ Linh Tran Tuan <linhduonggnu at gmail.com>
 Lion Yang <lion at aosc.xyz>
 Luca Looz <luca.looz92 at gmail.com>
 Lucas Liu <extrafliu at gmail.com>
-Lunny Xiao <xiaolunwen at gmail.com>
 Luke Scott <luke at webconnex.com>
+Lunny Xiao <xiaolunwen at gmail.com>
 Maciej Zimnoch <maciej.zimnoch at codilime.com>
 Michael Woolnough <michael.woolnough at gmail.com>
+Minh Quang <minhquang4334 at gmail.com>
 Nao Yokotsuka <yokotukanao at gmail.com>
 Nathanial Murphy <nathanial.murphy at gmail.com>
 Nicola Peduzzi <thenikso at gmail.com>
@@ -97,7 +101,6 @@ Paul Bonser <misterpib at gmail.com>
 Paulius Lozys <pauliuslozys at gmail.com>
 Peter Schultz <peter.schultz at classmarkets.com>
 Phil Porada <philporada at gmail.com>
-Minh Quang <minhquang4334 at gmail.com>
 Rebecca Chin <rchin at pivotal.io>
 Reed Allman <rdallman10 at gmail.com>
 Richard Wilkes <wilkes at me.com>
@@ -133,6 +136,7 @@ Ziheng Lyu <zihenglv at gmail.com>
 
 Barracuda Networks, Inc.
 Counting Ltd.
+Defined Networking Inc.
 DigitalOcean Inc.
 Dolthub Inc.
 dyves labs AG
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a1b23c6..66189ed 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
 # Changelog
 
+## v1.9.2 (2025-04-07)
+
+v1.9.2 is a re-release of v1.9.1 due to a release process issue; no changes were made to the content.
+
+
 ## v1.9.1 (2025-03-21)
 
 ### Major Changes
diff --git a/README.md b/README.md
index da4593c..65dd898 100644
--- a/README.md
+++ b/README.md
@@ -42,7 +42,7 @@ A MySQL-Driver for Go's [database/sql](https://golang.org/pkg/database/sql/) pac
 
 ## Requirements
 
-* Go 1.21 or higher. We aim to support the 3 latest versions of Go.
+* Go 1.22 or higher. We aim to support the 3 latest versions of Go.
 * MySQL (5.7+) and MariaDB (10.5+) are supported.
 * [TiDB](https://github.com/pingcap/tidb) is supported by PingCAP.
   * Do not ask questions about TiDB in our issue tracker or forum.
diff --git a/dsn.go b/dsn.go
index ecf6256..89556bf 100644
--- a/dsn.go
+++ b/dsn.go
@@ -414,7 +414,7 @@ func ParseDSN(dsn string) (cfg *Config, err error) {
 					if dsn[j] == '@' {
 						// username[:password]
 						// Find the first ':' in dsn[:j]
-						for k = 0; k < j; k++ {
+						for k = 0; k < j; k++ { // We cannot use k = range j here, because we use dsn[:k] below
 							if dsn[k] == ':' {
 								cfg.Passwd = dsn[k+1 : j]
 								break
diff --git a/fields.go b/fields.go
index be5cd80..25a1662 100644
--- a/fields.go
+++ b/fields.go
@@ -128,6 +128,7 @@ var (
 	scanTypeInt64      = reflect.TypeOf(int64(0))
 	scanTypeNullFloat  = reflect.TypeOf(sql.NullFloat64{})
 	scanTypeNullInt    = reflect.TypeOf(sql.NullInt64{})
+	scanTypeNullUint   = reflect.TypeOf(sql.Null[uint64]{})
 	scanTypeNullTime   = reflect.TypeOf(sql.NullTime{})
 	scanTypeUint8      = reflect.TypeOf(uint8(0))
 	scanTypeUint16     = reflect.TypeOf(uint16(0))
@@ -185,6 +186,9 @@ func (mf *mysqlField) scanType() reflect.Type {
 			}
 			return scanTypeInt64
 		}
+		if mf.flags&flagUnsigned != 0 {
+			return scanTypeNullUint
+		}
 		return scanTypeNullInt
 
 	case fieldTypeFloat:
diff --git a/go.mod b/go.mod
index 187aff1..f17666d 100644
--- a/go.mod
+++ b/go.mod
@@ -1,5 +1,5 @@
 module github.com/go-sql-driver/mysql
 
-go 1.21.0
+go 1.22.0
 
 require filippo.io/edwards25519 v1.1.0
diff --git a/infile.go b/infile.go
index 453ae09..597b5e7 100644
--- a/infile.go
+++ b/infile.go
@@ -95,10 +95,7 @@ const defaultPacketSize = 16 * 1024 // 16KB is small enough for disk readahead a
 
 func (mc *okHandler) handleInFileRequest(name string) (err error) {
 	var rdr io.Reader
-	packetSize := defaultPacketSize
-	if mc.maxWriteSize < packetSize {
-		packetSize = mc.maxWriteSize
-	}
+	packetSize := min(mc.maxWriteSize, defaultPacketSize)
 
 	if idx := strings.Index(name, "Reader::"); idx == 0 || (idx > 0 && name[idx-1] == '/') { // io.Reader
 		// The server might return an an absolute path. See issue #355.
diff --git a/packets.go b/packets.go
index 4b83621..a497a50 100644
--- a/packets.go
+++ b/packets.go
@@ -984,10 +984,7 @@ func (stmt *mysqlStmt) writeExecutePacket(args []driver.Value) error {
 	mc := stmt.mc
 
 	// Determine threshold dynamically to avoid packet size shortage.
-	longDataSize := mc.maxAllowedPacket / (stmt.paramCount + 1)
-	if longDataSize < 64 {
-		longDataSize = 64
-	}
+	longDataSize := max(mc.maxAllowedPacket/(stmt.paramCount+1), 64)
 
 	// Reset packet-sequence
 	mc.resetSequence()
diff --git a/result.go b/result.go
index d516314..82dc0f9 100644
--- a/result.go
+++ b/result.go
@@ -8,6 +8,8 @@
 
 package mysql
 
+import "slices"
+
 import "database/sql/driver"
 
 // Result exposes data not available through *connection.Result.
@@ -42,9 +44,9 @@ func (res *mysqlResult) RowsAffected() (int64, error) {
 }
 
 func (res *mysqlResult) AllLastInsertIds() []int64 {
-	return append([]int64{}, res.insertIds...) // defensive copy
+	return slices.Clone(res.insertIds) // defensive copy
 }
 
 func (res *mysqlResult) AllRowsAffected() []int64 {
-	return append([]int64{}, res.affectedRows...) // defensive copy
+	return slices.Clone(res.affectedRows) // defensive copy
 }
diff --git a/transaction.go b/transaction.go
index 4a4b610..8c502f4 100644
--- a/transaction.go
+++ b/transaction.go
@@ -13,18 +13,32 @@ type mysqlTx struct {
 }
 
 func (tx *mysqlTx) Commit() (err error) {
-	if tx.mc == nil || tx.mc.closed.Load() {
+	if tx.mc == nil {
 		return ErrInvalidConn
 	}
+	if tx.mc.closed.Load() {
+		err = tx.mc.error()
+		if err == nil {
+			err = ErrInvalidConn
+		}
+		return
+	}
 	err = tx.mc.exec("COMMIT")
 	tx.mc = nil
 	return
 }
 
 func (tx *mysqlTx) Rollback() (err error) {
-	if tx.mc == nil || tx.mc.closed.Load() {
+	if tx.mc == nil {
 		return ErrInvalidConn
 	}
+	if tx.mc.closed.Load() {
+		err = tx.mc.error()
+		if err == nil {
+			err = ErrInvalidConn
+		}
+		return
+	}
 	err = tx.mc.exec("ROLLBACK")
 	tx.mc = nil
 	return
diff --git a/utils.go b/utils.go
index 8716c26..b041804 100644
--- a/utils.go
+++ b/utils.go
@@ -182,7 +182,7 @@ func parseDateTime(b []byte, loc *time.Location) (time.Time, error) {
 
 func parseByteYear(b []byte) (int, error) {
 	year, n := 0, 1000
-	for i := 0; i < 4; i++ {
+	for i := range 4 {
 		v, err := bToi(b[i])
 		if err != nil {
 			return 0, err
@@ -207,7 +207,7 @@ func parseByte2Digits(b1, b2 byte) (int, error) {
 
 func parseByteNanoSec(b []byte) (int, error) {
 	ns, digit := 0, 100000 // max is 6-digits
-	for i := 0; i < len(b); i++ {
+	for i := range b {
 		v, err := bToi(b[i])
 		if err != nil {
 			return 0, err
@@ -678,7 +678,7 @@ func escapeStringBackslash(buf []byte, v string) []byte {
 	pos := len(buf)
 	buf = reserveBuffer(buf, len(v)*2)
 
-	for i := 0; i < len(v); i++ {
+	for i := range len(v) {
 		c := v[i]
 		switch c {
 		case '\x00':
@@ -746,7 +746,7 @@ func escapeStringQuotes(buf []byte, v string) []byte {
 	pos := len(buf)
 	buf = reserveBuffer(buf, len(v)*2)
 
-	for i := 0; i < len(v); i++ {
+	for i := range len(v) {
 		c := v[i]
 		if c == '\'' {
 			buf[pos+1] = '\''



Test patch:
diff --git a/benchmark_test.go b/benchmark_test.go
index 5c9a046..912e541 100644
--- a/benchmark_test.go
+++ b/benchmark_test.go
@@ -93,7 +93,7 @@ func benchmarkQueryHelper(b *testing.B, compr bool) {
 	defer wg.Wait()
 	b.StartTimer()
 
-	for i := 0; i < concurrencyLevel; i++ {
+	for range concurrencyLevel {
 		go func() {
 			for {
 				if atomic.AddInt64(&remain, -1) < 0 {
@@ -130,7 +130,7 @@ func BenchmarkExec(b *testing.B) {
 	defer wg.Wait()
 	b.StartTimer()
 
-	for i := 0; i < concurrencyLevel; i++ {
+	for range concurrencyLevel {
 		go func() {
 			for {
 				if atomic.AddInt64(&remain, -1) < 0 {
@@ -345,7 +345,7 @@ func BenchmarkQueryRawBytes(b *testing.B) {
 	for i := range blob {
 		blob[i] = 42
 	}
-	for i := 0; i < 100; i++ {
+	for i := range 100 {
 		_, err := db.Exec("INSERT INTO bench_rawbytes VALUES (?, ?)", i, blob)
 		if err != nil {
 			b.Fatal(err)
@@ -401,7 +401,7 @@ func BenchmarkReceiveMassiveRows(b *testing.B) {
 	}
 	for i := 0; i < 10000; i += 100 {
 		args := make([]any, 200)
-		for j := 0; j < 100; j++ {
+		for j := range 100 {
 			args[j*2] = i + j
 			args[j*2+1] = sval
 		}
diff --git a/connection_test.go b/connection_test.go
index f774089..440ecbf 100644
--- a/connection_test.go
+++ b/connection_test.go
@@ -141,7 +141,7 @@ func TestCleanCancel(t *testing.T) {
 	ctx, cancel := context.WithCancel(context.Background())
 	cancel()
 
-	for i := 0; i < 3; i++ { // Repeat same behavior
+	for range 3 { // Repeat same behavior
 		err := mc.Ping(ctx)
 		if err != context.Canceled {
 			t.Errorf("expected context.Canceled, got %#v", err)
diff --git a/driver_test.go b/driver_test.go
index 00e8286..ec0f287 100644
--- a/driver_test.go
+++ b/driver_test.go
@@ -26,6 +26,7 @@ import (
 	"os"
 	"reflect"
 	"runtime"
+	"slices"
 	"strconv"
 	"strings"
 	"sync"
@@ -1609,10 +1610,12 @@ func TestCollation(t *testing.T) {
 		t.Skipf("MySQL server not running on %s", netAddr)
 	}
 
-	defaultCollation := "utf8mb4_general_ci"
+	// MariaDB may override collation specified by handshake with `character_set_collations` variable.
+	// https://mariadb.com/kb/en/setting-character-sets-and-collations/#changing-default-collation
+	// https://mariadb.com/kb/en/server-system-variables/#character_set_collations
+	// utf8mb4_general_ci, utf8mb3_general_ci will be overridden by default MariaDB.
+	// Collations other than charasets default are not overridden. So utf8mb4_unicode_ci is safe.
 	testCollations := []string{
-		"",               // do not set
-		defaultCollation, // driver default
 		"latin1_general_ci",
 		"binary",
 		"utf8mb4_unicode_ci",
@@ -1620,24 +1623,19 @@ func TestCollation(t *testing.T) {
 	}
 
 	for _, collation := range testCollations {
-		var expected, tdsn string
-		if collation != "" {
-			tdsn = dsn + "&collation=" + collation
-			expected = collation
-		} else {
-			tdsn = dsn
-			expected = defaultCollation
-		}
-
-		runTests(t, tdsn, func(dbt *DBTest) {
-			var got string
-			if err := dbt.db.QueryRow("SELECT @@collation_connection").Scan(&got); err != nil {
-				dbt.Fatal(err)
-			}
+		t.Run(collation, func(t *testing.T) {
+			tdsn := dsn + "&collation=" + collation
+			expected := collation
 
-			if got != expected {
-				dbt.Fatalf("expected connection collation %s but got %s", expected, got)
-			}
+			runTests(t, tdsn, func(dbt *DBTest) {
+				var got string
+				if err := dbt.db.QueryRow("SELECT @@collation_connection").Scan(&got); err != nil {
+					dbt.Fatal(err)
+				}
+				if got != expected {
+					dbt.Fatalf("expected connection collation %s but got %s", expected, got)
+				}
+			})
 		})
 	}
 }
@@ -1685,7 +1683,7 @@ func TestRawBytesResultExceedsBuffer(t *testing.T) {
 }
 
 func TestTimezoneConversion(t *testing.T) {
-	zones := []string{"UTC", "US/Central", "US/Pacific", "Local"}
+	zones := []string{"UTC", "America/New_York", "Asia/Hong_Kong", "Local"}
 
 	// Regression test for timezone handling
 	tzTest := func(dbt *DBTest) {
@@ -1693,8 +1691,8 @@ func TestTimezoneConversion(t *testing.T) {
 		dbt.mustExec("CREATE TABLE test (ts TIMESTAMP)")
 
 		// Insert local time into database (should be converted)
-		usCentral, _ := time.LoadLocation("US/Central")
-		reftime := time.Date(2014, 05, 30, 18, 03, 17, 0, time.UTC).In(usCentral)
+		newYorkTz, _ := time.LoadLocation("America/New_York")
+		reftime := time.Date(2014, 05, 30, 18, 03, 17, 0, time.UTC).In(newYorkTz)
 		dbt.mustExec("INSERT INTO test VALUE (?)", reftime)
 
 		// Retrieve time from DB
@@ -1713,7 +1711,7 @@ func TestTimezoneConversion(t *testing.T) {
 		// Check that dates match
 		if reftime.Unix() != dbTime.Unix() {
 			dbt.Errorf("times do not match.\n")
-			dbt.Errorf(" Now(%v)=%v\n", usCentral, reftime)
+			dbt.Errorf(" Now(%v)=%v\n", newYorkTz, reftime)
 			dbt.Errorf(" Now(UTC)=%v\n", dbTime)
 		}
 	}
@@ -1926,7 +1924,7 @@ func TestPreparedManyCols(t *testing.T) {
 		rows.Close()
 
 		// Create 0byte string which we can't send via STMT_LONG_DATA.
-		for i := 0; i < numParams; i++ {
+		for i := range numParams {
 			params[i] = ""
 		}
 		rows, err = stmt.Query(params...)
@@ -1971,7 +1969,7 @@ func TestConcurrent(t *testing.T) {
 			})
 		}
 
-		for i := 0; i < max; i++ {
+		for i := range max {
 			go func(id int) {
 				defer wg.Done()
 
@@ -2355,7 +2353,7 @@ func TestPing(t *testing.T) {
 			q.Close()
 
 			// Verify that Ping() clears both fields.
-			for i := 0; i < 2; i++ {
+			for range 2 {
 				if err := c.Ping(ctx); err != nil {
 					dbt.fail("Pinger", "Ping", err)
 				}
@@ -2558,7 +2556,7 @@ func TestMultiResultSet(t *testing.T) {
 			}
 			defer stmt.Close()
 
-			for j := 0; j < 2; j++ {
+			for j := range 2 {
 				rows, err := stmt.Query()
 				if err != nil {
 					dbt.Fatalf("%v (i=%d) (j=%d)", err, i, j)
@@ -2665,7 +2663,7 @@ func TestQueryMultipleResults(t *testing.T) {
 			c := conn.(*mysqlConn)
 
 			// Demonstrate that repeated queries reset the affectedRows
-			for i := 0; i < 2; i++ {
+			for range 2 {
 				_, err := qr.Query(`
 				INSERT INTO test (value) VALUES ('a'), ('b');
 				INSERT INTO test (value) VALUES ('c'), ('d'), ('e');
@@ -3293,11 +3291,11 @@ func TestRawBytesAreNotModified(t *testing.T) {
 
 	runTests(t, dsn, func(dbt *DBTest) {
 		dbt.mustExec("CREATE TABLE test (id int, value BLOB) CHARACTER SET utf8")
-		for i := 0; i < insertRows; i++ {
+		for i := range insertRows {
 			dbt.mustExec("INSERT INTO test VALUES (?, ?)", i+1, sqlBlobs[i&1])
 		}
 
-		for i := 0; i < contextRaceIterations; i++ {
+		for i := range contextRaceIterations {
 			func() {
 				ctx, cancel := context.WithCancel(context.Background())
 				defer cancel()
@@ -3541,6 +3539,15 @@ func TestConnectionAttributes(t *testing.T) {
 
 	dbt := &DBTest{t, db}
 
+	var varName string
+	var varValue string
+	err := dbt.db.QueryRow("SHOW VARIABLES LIKE 'performance_schema'").Scan(&varName, &varValue)
+	if err != nil {
+		t.Fatalf("error: %s", err.Error())
+	}
+	if varValue != "ON" {
+		t.Skipf("Performance schema is not enabled. skipping")
+	}
 	queryString := "SELECT ATTR_NAME, ATTR_VALUE FROM performance_schema.session_account_connect_attrs WHERE PROCESSLIST_ID = CONNECTION_ID()"
 	rows := dbt.mustQuery(queryString)
 	defer rows.Close()
@@ -3552,8 +3559,8 @@ func TestConnectionAttributes(t *testing.T) {
 		rowsMap[attrName] = attrValue
 	}
 
-	connAttrs := append(append([]string{}, defaultAttrs...), customAttrs...)
-	expectedAttrValues := append(append([]string{}, defaultAttrValues...), customAttrValues...)
+	connAttrs := slices.Concat(defaultAttrs, customAttrs)
+	expectedAttrValues := slices.Concat(defaultAttrValues, customAttrValues)
 	for i := range connAttrs {
 		if gotValue := rowsMap[connAttrs[i]]; gotValue != expectedAttrValues[i] {
 			dbt.Errorf("expected %q, got %q", expectedAttrValues[i], gotValue)
@@ -3637,7 +3644,7 @@ func TestIssue1567(t *testing.T) {
 			count = max
 		}
 
-		for i := 0; i < count; i++ {
+		for range count {
 			timeout := time.Duration(mrand.Int63n(int64(rtt)))
 			ctx, cancel := context.WithTimeout(context.Background(), timeout)
 			dbt.db.PingContext(ctx)