Skip to content

Commit 652c5e5

Browse files
authored
refactor: extract pkg/mysql from pkg/db (#5287)
* refactor: copy new mysql package * fix: generate into new mysql package
1 parent 6e20136 commit 652c5e5

20 files changed

+1740
-96
lines changed

Makefile

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,16 @@ install: install-go ## Install all dependencies
2323

2424
.PHONY: generate-sql
2525
generate-sql:
26-
@rm -f ./pkg/db/schema.sql || true
27-
@touch ./pkg/db/schema.sql
28-
@echo "-- Code generated by Makefile. DO NOT EDIT." >> ./pkg/db/schema.sql
29-
@echo "--" >> ./pkg/db/schema.sql
30-
@echo "-- Source: web/internal/db/src/schema" >> ./pkg/db/schema.sql
26+
@rm -f ./pkg/mysql/schema.sql || true
27+
@touch ./pkg/mysql/schema.sql
28+
@echo "-- Code generated by Makefile. DO NOT EDIT." >> ./pkg/mysql/schema.sql
29+
@echo "--" >> ./pkg/mysql/schema.sql
30+
@echo "-- Source: web/internal/db/src/schema" >> ./pkg/mysql/schema.sql
3131
@rm -rf ./web/internal/db/out
3232
@cd web/internal/db && pnpm drizzle-kit generate --schema=src/schema/index.ts --dialect=mysql --out=out --name=init --breakpoints=false
3333
@echo "\n" >> ./web/internal/db/out/0000_init.sql
34-
@cat ./web/internal/db/out/0000_init.sql >> ./pkg/db/schema.sql
34+
@cat ./web/internal/db/out/0000_init.sql >> ./pkg/mysql/schema.sql
35+
@cp ./pkg/mysql/schema.sql ./pkg/db/schema.sql
3536

3637
@rm -rf ./web/internal/db/out
3738

pkg/db/BUILD.bazel

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,10 +319,10 @@ go_library(
319319
deps = [
320320
"//pkg/assert",
321321
"//pkg/codes",
322-
"//pkg/db/metrics",
323322
"//pkg/db/types",
324323
"//pkg/fault",
325324
"//pkg/logger",
325+
"//pkg/mysql/metrics",
326326
"//pkg/otel/tracing",
327327
"//pkg/retry",
328328
"@com_github_go_sql_driver_mysql//:mysql",

pkg/db/metrics/prometheus.go

Lines changed: 0 additions & 85 deletions
This file was deleted.

pkg/db/replica.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ import (
77
"database/sql"
88
"time"
99

10-
"github.com/unkeyed/unkey/pkg/db/metrics"
1110
"github.com/unkeyed/unkey/pkg/logger"
11+
"github.com/unkeyed/unkey/pkg/mysql/metrics"
1212
"github.com/unkeyed/unkey/pkg/otel/tracing"
1313
"go.opentelemetry.io/otel/attribute"
1414
)

pkg/db/traced_tx.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import (
55
"database/sql"
66
"time"
77

8-
"github.com/unkeyed/unkey/pkg/db/metrics"
8+
"github.com/unkeyed/unkey/pkg/mysql/metrics"
99
"github.com/unkeyed/unkey/pkg/otel/tracing"
1010
"go.opentelemetry.io/otel/attribute"
1111
)

pkg/mysql/BUILD.bazel

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
load("@rules_go//go:def.bzl", "go_library")
2+
3+
go_library(
4+
name = "mysql",
5+
srcs = [
6+
"database.go",
7+
"doc.go",
8+
"handle_err_deadlock.go",
9+
"handle_err_duplicate_key.go",
10+
"handle_err_no_rows.go",
11+
"interface.go",
12+
"replica.go",
13+
"retry.go",
14+
"traced_tx.go",
15+
"tx.go",
16+
],
17+
importpath = "github.com/unkeyed/unkey/pkg/mysql",
18+
visibility = ["//visibility:public"],
19+
deps = [
20+
"//pkg/assert",
21+
"//pkg/codes",
22+
"//pkg/fault",
23+
"//pkg/logger",
24+
"//pkg/mysql/metrics",
25+
"//pkg/otel/tracing",
26+
"//pkg/retry",
27+
"@com_github_go_sql_driver_mysql//:mysql",
28+
"@io_opentelemetry_go_otel//attribute",
29+
],
30+
)

pkg/mysql/database.go

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
package mysql
2+
3+
import (
4+
"context"
5+
"database/sql"
6+
"strings"
7+
"time"
8+
9+
_ "github.com/go-sql-driver/mysql"
10+
11+
"github.com/unkeyed/unkey/pkg/assert"
12+
"github.com/unkeyed/unkey/pkg/fault"
13+
"github.com/unkeyed/unkey/pkg/logger"
14+
"github.com/unkeyed/unkey/pkg/retry"
15+
)
16+
17+
// Config defines the parameters needed to establish database connections.
18+
// It supports separate connections for read and write operations to allow
19+
// for primary/replica setups.
20+
type Config struct {
21+
// The primary DSN for your database. This must support both reads and writes.
22+
PrimaryDSN string
23+
24+
// The readonly replica will be used for most read queries.
25+
// If omitted, the primary is used.
26+
ReadOnlyDSN string
27+
}
28+
29+
// database implements the Database interface, providing access to database replicas
30+
// and handling connection lifecycle.
31+
type database struct {
32+
writeReplica *Replica // Primary database connection used for write operations
33+
readReplica *Replica // Connection used for read operations (may be same as primary)
34+
}
35+
36+
func open(dsn string) (db *sql.DB, err error) {
37+
if !strings.Contains(dsn, "parseTime=true") {
38+
return nil, fault.New("DSN must contain parseTime=true, see https://stackoverflow.com/questions/29341590/how-to-parse-time-from-database/29343013#29343013")
39+
}
40+
41+
// sql.Open only validates the DSN, it doesn't actually connect.
42+
// We need to call Ping() to verify connectivity.
43+
db, err = sql.Open("mysql", dsn)
44+
if err != nil {
45+
return nil, fault.Wrap(err, fault.Internal("failed to open database"))
46+
}
47+
48+
// Configure connection pool for better performance
49+
// These settings prevent cold-start latency by maintaining warm connections
50+
db.SetMaxOpenConns(25) // Max concurrent connections
51+
db.SetMaxIdleConns(10) // Keep 10 idle connections ready
52+
db.SetConnMaxLifetime(5 * time.Minute) // Refresh connections every 5 min (PlanetScale recommendation)
53+
db.SetConnMaxIdleTime(1 * time.Minute) // Close idle connections after 1 min of inactivity
54+
55+
// Verify connectivity at startup with retries - this establishes at least one connection
56+
// so the first request doesn't pay the connection establishment cost
57+
err = retry.New(
58+
retry.Attempts(5),
59+
retry.Backoff(func(n int) time.Duration {
60+
return time.Duration(n) * time.Second
61+
}),
62+
).Do(func() error {
63+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
64+
defer cancel()
65+
pingErr := db.PingContext(ctx)
66+
if pingErr != nil {
67+
logger.Info("mysql not ready yet, retrying...", "error", pingErr.Error())
68+
}
69+
return pingErr
70+
})
71+
if err != nil {
72+
_ = db.Close()
73+
return nil, fault.Wrap(err, fault.Internal("failed to ping database after retries"))
74+
}
75+
76+
logger.Info("database connection pool initialized successfully")
77+
return db, nil
78+
}
79+
80+
func NewReplica(url string, mode string) (*Replica, error) {
81+
db, err := open(url)
82+
if err != nil {
83+
return nil, fault.Wrap(err, fault.Internal("cannot open replica"))
84+
}
85+
86+
return &Replica{
87+
db: db,
88+
mode: mode,
89+
debugLogs: false,
90+
}, nil
91+
}
92+
93+
// New creates a new database instance with the provided configuration.
94+
// It establishes connections to the primary database and optionally to a read-only replica.
95+
// Returns an error if connections cannot be established or if DSNs are misconfigured.
96+
func New(config Config) (*database, error) {
97+
err := assert.All(
98+
assert.NotEmpty(config.PrimaryDSN),
99+
)
100+
if err != nil {
101+
return nil, fault.Wrap(err, fault.Internal("invalid configuration"))
102+
}
103+
104+
// Initialize primary replica
105+
writeReplica, err := NewReplica(config.PrimaryDSN, "rw")
106+
if err != nil {
107+
return nil, fault.Wrap(err, fault.Internal("cannot initialize primary replica"))
108+
}
109+
110+
// Initialize read replica with primary by default
111+
readReplica := &Replica{
112+
db: writeReplica.db,
113+
mode: "rw",
114+
debugLogs: false,
115+
}
116+
117+
// If a separate read-only DSN is provided, establish that connection
118+
if config.ReadOnlyDSN != "" {
119+
readReplica, err = NewReplica(config.ReadOnlyDSN, "ro")
120+
if err != nil {
121+
return nil, fault.Wrap(err, fault.Internal("cannot initialize read replica"))
122+
}
123+
logger.Info("database configured with separate read replica")
124+
} else {
125+
logger.Info("database configured without separate read replica, using primary for reads")
126+
}
127+
128+
return &database{
129+
writeReplica: writeReplica,
130+
readReplica: readReplica,
131+
}, nil
132+
}
133+
134+
// RW returns the write replica for performing database write operations.
135+
func (d *database) RW() *Replica {
136+
return d.writeReplica
137+
}
138+
139+
// RO returns the read replica for performing database read operations.
140+
// If no dedicated read replica is configured, it returns the write replica.
141+
func (d *database) RO() *Replica {
142+
if d.readReplica != nil {
143+
return d.readReplica
144+
}
145+
return d.writeReplica
146+
}
147+
148+
// Close properly closes all database connections.
149+
// This should be called when the application is shutting down.
150+
func (d *database) Close() error {
151+
// Close the write replica connection
152+
writeCloseErr := d.writeReplica.db.Close()
153+
154+
// Only close the read replica if it's a separate connection
155+
if d.readReplica != nil {
156+
readCloseErr := d.readReplica.db.Close()
157+
if readCloseErr != nil {
158+
return fault.Wrap(readCloseErr)
159+
}
160+
}
161+
162+
// Return any write replica close error
163+
if writeCloseErr != nil {
164+
return fault.Wrap(writeCloseErr)
165+
}
166+
return nil
167+
}

pkg/mysql/doc.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Package mysql provides a minimal shared MySQL abstraction for backend services.
2+
//
3+
// The package intentionally stays small: it owns connection setup, read/write
4+
// replica selection, tracing/metrics wrappers, transaction helpers, and
5+
// MySQL-specific error classification. Query logic stays in caller packages.
6+
// This keeps Bazel cache keys stable for dependents and avoids pulling
7+
// service-specific SQL concerns into a base dependency.
8+
//
9+
// New requires [Config.PrimaryDSN] and enforces `parseTime=true` in DSNs so
10+
// datetime values decode correctly. When [Config.ReadOnlyDSN] is empty, reads
11+
// and writes use the same underlying pool. Use [Database.RW] for writes and
12+
// [Database.RO] for read paths.
13+
//
14+
// Generated query code should depend on [DBTX], so the same query methods can
15+
// run against either a [Replica] or a transaction. Use [Tx] / [TxWithResult]
16+
// for single-attempt transactions, and [TxRetry] / [TxWithResultRetry] when the
17+
// operation is safe to retry on transient failures.
18+
//
19+
// Retry helpers only retry errors classified as transient by [IsTransientError].
20+
// [IsNotFound] and [IsDuplicateKeyError] are treated as terminal and are not
21+
// retried.
22+
package mysql

0 commit comments

Comments
 (0)