Skip to content

Commit 92b844c

Browse files
committed
feat: implement ConnAcquirer and DedicatedConn interfaces for MySQL and PostgreSQL drivers
1 parent d87d23d commit 92b844c

5 files changed

Lines changed: 280 additions & 28 deletions

File tree

driver/driver.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,3 +121,25 @@ type Stmt interface {
121121
Exec(ctx context.Context, args ...any) (Result, error)
122122
Close() error
123123
}
124+
125+
// ConnAcquirer is an optional interface that pool-based drivers implement
126+
// to provide a dedicated connection from the pool. This is needed for
127+
// operations that require session-level state (e.g., advisory locks)
128+
// to remain on a single connection across multiple queries.
129+
type ConnAcquirer interface {
130+
// AcquireConn acquires a dedicated connection from the pool.
131+
// The returned DedicatedConn provides Exec/Query/QueryRow methods
132+
// that are guaranteed to run on the same underlying connection.
133+
// The caller MUST call Release() when done.
134+
AcquireConn(ctx context.Context) (DedicatedConn, error)
135+
}
136+
137+
// DedicatedConn represents a single, dedicated database connection
138+
// acquired from a pool. All operations execute on the same underlying
139+
// connection, making it safe for session-level state like advisory locks.
140+
type DedicatedConn interface {
141+
Exec(ctx context.Context, query string, args ...any) (Result, error)
142+
Query(ctx context.Context, query string, args ...any) (Rows, error)
143+
QueryRow(ctx context.Context, query string, args ...any) Row
144+
Release()
145+
}

drivers/mysqldriver/mysql.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ type MysqlDB struct {
2929

3030
var _ driver.Driver = (*MysqlDB)(nil)
3131
var _ driver.Preparer = (*MysqlDB)(nil)
32+
var _ driver.ConnAcquirer = (*MysqlDB)(nil)
3233

3334
// New creates a new unconnected MysqlDB. Call Open to establish a connection pool.
3435
func New() *MysqlDB {
@@ -178,6 +179,54 @@ func (db *MysqlDB) Prepare(ctx context.Context, query string) (driver.Stmt, erro
178179
return &mysqlStmt{stmt: stmt}, nil
179180
}
180181

182+
// AcquireConn acquires a dedicated connection from the pool.
183+
// All operations on the returned DedicatedConn execute on the same
184+
// underlying MySQL session, making it safe for session-level state
185+
// such as GET_LOCK advisory locks.
186+
func (db *MysqlDB) AcquireConn(ctx context.Context) (driver.DedicatedConn, error) {
187+
conn, err := db.db.Conn(ctx)
188+
if err != nil {
189+
return nil, fmt.Errorf("mysqldriver: acquire dedicated conn: %w", err)
190+
}
191+
return &mysqlDedicatedConn{conn: conn}, nil
192+
}
193+
194+
// mysqlDedicatedConn wraps a single *sql.Conn to implement driver.DedicatedConn.
195+
// All queries execute on the same underlying connection.
196+
type mysqlDedicatedConn struct {
197+
conn *sql.Conn
198+
}
199+
200+
var _ driver.DedicatedConn = (*mysqlDedicatedConn)(nil)
201+
202+
func (c *mysqlDedicatedConn) Exec(ctx context.Context, query string, args ...any) (driver.Result, error) {
203+
res, err := c.conn.ExecContext(ctx, query, args...)
204+
if err != nil {
205+
return nil, fmt.Errorf("mysqldriver: dedicated exec: %w", err)
206+
}
207+
return &mysqlResult{res: res}, nil
208+
}
209+
210+
func (c *mysqlDedicatedConn) Query(ctx context.Context, query string, args ...any) (driver.Rows, error) {
211+
rows, err := c.conn.QueryContext(ctx, query, args...) //nolint:rowserrcheck // caller checks Err() via driver.Rows
212+
if err != nil {
213+
return nil, fmt.Errorf("mysqldriver: dedicated query: %w", err)
214+
}
215+
return &mysqlRows{rows: rows}, nil
216+
}
217+
218+
func (c *mysqlDedicatedConn) QueryRow(ctx context.Context, query string, args ...any) driver.Row {
219+
row := c.conn.QueryRowContext(ctx, query, args...)
220+
return &mysqlRow{row: row}
221+
}
222+
223+
func (c *mysqlDedicatedConn) Release() {
224+
if c.conn != nil {
225+
_ = c.conn.Close()
226+
c.conn = nil
227+
}
228+
}
229+
181230
// mapIsolationLevel converts a driver.IsolationLevel to the corresponding
182231
// sql.IsolationLevel constant.
183232
func mapIsolationLevel(level driver.IsolationLevel) sql.IsolationLevel {

drivers/mysqldriver/mysqlmigrate/executor.go

Lines changed: 80 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,15 @@ const (
2424
)
2525

2626
// Executor implements migrate.Executor for MySQL.
27+
//
28+
// When the underlying driver supports [driver.ConnAcquirer], the executor
29+
// acquires a dedicated connection in [AcquireLock] and routes ALL subsequent
30+
// operations through it until [ReleaseLock]. This guarantees that the
31+
// session-level GET_LOCK is acquired and released on the same connection,
32+
// preventing advisory-lock leaks in pooled environments.
2733
type Executor struct {
28-
drv driver.Driver
34+
drv driver.Driver
35+
dedicated driver.DedicatedConn // non-nil while migration lock is held
2936
}
3037

3138
var _ migrate.Executor = (*Executor)(nil)
@@ -35,14 +42,44 @@ func New(drv driver.Driver) *Executor {
3542
return &Executor{drv: drv}
3643
}
3744

45+
// --- routing helpers: prefer dedicated conn when available ---
46+
47+
func (e *Executor) exec(ctx context.Context, query string, args ...any) (driver.Result, error) {
48+
if e.dedicated != nil {
49+
return e.dedicated.Exec(ctx, query, args...)
50+
}
51+
return e.drv.Exec(ctx, query, args...)
52+
}
53+
54+
func (e *Executor) query(ctx context.Context, query string, args ...any) (driver.Rows, error) {
55+
if e.dedicated != nil {
56+
return e.dedicated.Query(ctx, query, args...)
57+
}
58+
return e.drv.Query(ctx, query, args...)
59+
}
60+
61+
func (e *Executor) queryRow(ctx context.Context, query string, args ...any) driver.Row {
62+
if e.dedicated != nil {
63+
return e.dedicated.QueryRow(ctx, query, args...)
64+
}
65+
return e.drv.QueryRow(ctx, query, args...)
66+
}
67+
68+
func (e *Executor) releaseDedicated() {
69+
if e.dedicated != nil {
70+
e.dedicated.Release()
71+
e.dedicated = nil
72+
}
73+
}
74+
3875
// Exec executes a SQL statement that does not return rows.
3976
func (e *Executor) Exec(ctx context.Context, query string, args ...any) (driver.Result, error) {
40-
return e.drv.Exec(ctx, query, args...)
77+
return e.exec(ctx, query, args...)
4178
}
4279

4380
// Query executes a SQL statement that returns rows.
4481
func (e *Executor) Query(ctx context.Context, query string, args ...any) (driver.Rows, error) {
45-
return e.drv.Query(ctx, query, args...)
82+
return e.query(ctx, query, args...)
4683
}
4784

4885
// EnsureMigrationTable creates the grove_migrations table if it doesn't exist.
@@ -55,7 +92,7 @@ func (e *Executor) EnsureMigrationTable(ctx context.Context) error {
5592
"`migrated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), "+
5693
"UNIQUE KEY `uq_version_group` (`version`, `group`)"+
5794
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", migrationTableName)
58-
_, err := e.drv.Exec(ctx, query)
95+
_, err := e.exec(ctx, query)
5996
return err
6097
}
6198

@@ -67,21 +104,37 @@ func (e *Executor) EnsureLockTable(ctx context.Context) error {
67104
"`locked_by` VARCHAR(255), "+
68105
"CONSTRAINT `single_lock` CHECK (`id` = 1)"+
69106
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", lockTableName)
70-
_, err := e.drv.Exec(ctx, query)
107+
_, err := e.exec(ctx, query)
71108
return err
72109
}
73110

74111
// AcquireLock attempts to acquire the distributed migration lock using
75112
// MySQL advisory locks (GET_LOCK) for immediate feedback.
113+
//
114+
// If the driver implements [driver.ConnAcquirer], a dedicated connection is
115+
// acquired first so that the advisory lock and all subsequent migration
116+
// operations share the same session. This prevents lock leaks when using
117+
// connection pools.
76118
func (e *Executor) AcquireLock(ctx context.Context, lockedBy string) error {
119+
// Acquire a dedicated connection if the driver supports it.
120+
if acq, ok := e.drv.(driver.ConnAcquirer); ok {
121+
conn, err := acq.AcquireConn(ctx)
122+
if err != nil {
123+
return fmt.Errorf("mysqlmigrate: acquire dedicated conn: %w", err)
124+
}
125+
e.dedicated = conn
126+
}
127+
77128
// Use MySQL advisory lock. GET_LOCK returns 1 if acquired, 0 if timeout.
78129
// Timeout of 0 means try immediately without waiting.
79-
row := e.drv.QueryRow(ctx, "SELECT GET_LOCK(?, 0)", advisoryLockName)
130+
row := e.queryRow(ctx, "SELECT GET_LOCK(?, 0)", advisoryLockName)
80131
var acquired int
81132
if err := row.Scan(&acquired); err != nil {
133+
e.releaseDedicated()
82134
return fmt.Errorf("mysqlmigrate: advisory lock: %w", err)
83135
}
84136
if acquired != 1 {
137+
e.releaseDedicated()
85138
return fmt.Errorf("mysqlmigrate: migration lock is held by another process")
86139
}
87140

@@ -90,19 +143,32 @@ func (e *Executor) AcquireLock(ctx context.Context, lockedBy string) error {
90143
"VALUES (1, NOW(6), ?) "+
91144
"ON DUPLICATE KEY UPDATE `locked_at` = NOW(6), `locked_by` = ?",
92145
lockTableName)
93-
_, err := e.drv.Exec(ctx, query, lockedBy, lockedBy)
94-
return err
146+
if _, err := e.exec(ctx, query, lockedBy, lockedBy); err != nil {
147+
// Best-effort unlock before releasing the connection.
148+
_, _ = e.exec(ctx, "SELECT RELEASE_LOCK(?)", advisoryLockName) //nolint:errcheck // best-effort cleanup on error path
149+
e.releaseDedicated()
150+
return err
151+
}
152+
153+
return nil
95154
}
96155

97156
// ReleaseLock releases the distributed migration lock.
157+
// If a dedicated connection was acquired in [AcquireLock], the advisory lock
158+
// is released on that same connection before the connection is returned to
159+
// the pool.
98160
func (e *Executor) ReleaseLock(ctx context.Context) error {
99161
// Clear the lock record.
100162
query := fmt.Sprintf("UPDATE `%s` SET `locked_at` = NULL, `locked_by` = NULL WHERE `id` = 1",
101163
lockTableName)
102-
_, _ = e.drv.Exec(ctx, query)
164+
_, _ = e.exec(ctx, query) //nolint:errcheck // best-effort lock record clearing
165+
166+
// Release the advisory lock (on the SAME connection that acquired it).
167+
_, err := e.exec(ctx, "SELECT RELEASE_LOCK(?)", advisoryLockName)
168+
169+
// Release the dedicated connection back to the pool.
170+
e.releaseDedicated()
103171

104-
// Release the advisory lock.
105-
_, err := e.drv.Exec(ctx, "SELECT RELEASE_LOCK(?)", advisoryLockName)
106172
return err
107173
}
108174

@@ -113,7 +179,7 @@ func (e *Executor) ListApplied(ctx context.Context) ([]*migrate.AppliedMigration
113179
"SELECT `id`, `version`, `name`, `group`, CAST(`migrated_at` AS CHAR) FROM `%s` ORDER BY `id` ASC",
114180
migrationTableName)
115181

116-
rows, err := e.drv.Query(ctx, query)
182+
rows, err := e.query(ctx, query)
117183
if err != nil {
118184
return nil, err
119185
}
@@ -135,7 +201,7 @@ func (e *Executor) RecordApplied(ctx context.Context, m *migrate.Migration) erro
135201
query := fmt.Sprintf(
136202
"INSERT INTO `%s` (`version`, `name`, `group`) VALUES (?, ?, ?)",
137203
migrationTableName)
138-
_, err := e.drv.Exec(ctx, query, m.Version, m.Name, m.Group)
204+
_, err := e.exec(ctx, query, m.Version, m.Name, m.Group)
139205
return err
140206
}
141207

@@ -144,6 +210,6 @@ func (e *Executor) RemoveApplied(ctx context.Context, m *migrate.Migration) erro
144210
query := fmt.Sprintf(
145211
"DELETE FROM `%s` WHERE `version` = ? AND `group` = ?",
146212
migrationTableName)
147-
_, err := e.drv.Exec(ctx, query, m.Version, m.Group)
213+
_, err := e.exec(ctx, query, m.Version, m.Group)
148214
return err
149215
}

drivers/pgdriver/pg.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ type PgDB struct {
3030
var _ driver.Driver = (*PgDB)(nil)
3131
var _ driver.StreamCapable = (*PgDB)(nil)
3232
var _ driver.Preparer = (*PgDB)(nil)
33+
var _ driver.ConnAcquirer = (*PgDB)(nil)
3334

3435
// New creates a new unconnected PgDB. Call Open to establish a connection pool.
3536
func New() *PgDB {
@@ -201,6 +202,54 @@ func (db *PgDB) Prepare(ctx context.Context, query string) (driver.Stmt, error)
201202
return &pgPoolStmt{conn: conn, sd: sd}, nil
202203
}
203204

205+
// AcquireConn acquires a dedicated connection from the pool.
206+
// All operations on the returned DedicatedConn execute on the same
207+
// underlying PostgreSQL session, making it safe for session-level state
208+
// such as advisory locks.
209+
func (db *PgDB) AcquireConn(ctx context.Context) (driver.DedicatedConn, error) {
210+
conn, err := db.pool.Acquire(ctx)
211+
if err != nil {
212+
return nil, fmt.Errorf("pgdriver: acquire dedicated conn: %w", err)
213+
}
214+
return &pgDedicatedConn{conn: conn}, nil
215+
}
216+
217+
// pgDedicatedConn wraps a single pgxpool.Conn to implement driver.DedicatedConn.
218+
// All queries execute on the same underlying connection.
219+
type pgDedicatedConn struct {
220+
conn *pgxpool.Conn
221+
}
222+
223+
var _ driver.DedicatedConn = (*pgDedicatedConn)(nil)
224+
225+
func (c *pgDedicatedConn) Exec(ctx context.Context, query string, args ...any) (driver.Result, error) {
226+
ct, err := c.conn.Exec(ctx, query, args...)
227+
if err != nil {
228+
return nil, fmt.Errorf("pgdriver: dedicated exec: %w", err)
229+
}
230+
return &pgResult{ct: ct}, nil
231+
}
232+
233+
func (c *pgDedicatedConn) Query(ctx context.Context, query string, args ...any) (driver.Rows, error) {
234+
rows, err := c.conn.Query(ctx, query, args...)
235+
if err != nil {
236+
return nil, fmt.Errorf("pgdriver: dedicated query: %w", err)
237+
}
238+
return &pgRows{rows: rows}, nil
239+
}
240+
241+
func (c *pgDedicatedConn) QueryRow(ctx context.Context, query string, args ...any) driver.Row {
242+
row := c.conn.QueryRow(ctx, query, args...)
243+
return &pgRow{row: row}
244+
}
245+
246+
func (c *pgDedicatedConn) Release() {
247+
if c.conn != nil {
248+
c.conn.Release()
249+
c.conn = nil
250+
}
251+
}
252+
204253
// mapIsolationLevel converts a driver.IsolationLevel to the corresponding
205254
// pgx.TxIsoLevel string constant.
206255
func mapIsolationLevel(level driver.IsolationLevel) pgx.TxIsoLevel {

0 commit comments

Comments
 (0)