diff --git a/driver.go b/driver.go new file mode 100644 index 0000000..0dfbbbe --- /dev/null +++ b/driver.go @@ -0,0 +1,372 @@ +package mysql + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "io" + "net/url" + "sync" + "time" +) + +var DefaultDriver = &MockDriver{} + +func init() { + sql.Register("mock_mysql", DefaultDriver) +} + +// MockDriver implements driver.Driver for mock MySQL testing. +type MockDriver struct { + mu sync.Mutex + txStartDelay time.Duration + failRollback bool + conns []*MockConn +} + +func (d *MockDriver) Open(name string) (driver.Conn, error) { + d.mu.Lock() + delay := d.txStartDelay + failRB := d.failRollback + d.mu.Unlock() + + if u, err := url.Parse(name); err == nil { + q := u.Query() + if dStr := q.Get("delay"); dStr != "" { + if parsed, err := time.ParseDuration(dStr); err == nil { + delay = parsed + } + } + if q.Get("fail_rollback") == "true" { + failRB = true + } + } + + conn := &MockConn{ + txStartDelay: delay, + failRollback: failRB, + driver: d, + } + + d.mu.Lock() + d.conns = append(d.conns, conn) + d.mu.Unlock() + + return conn, nil +} + +func (d *MockDriver) SetTxStartDelay(delay time.Duration) { + d.mu.Lock() + defer d.mu.Unlock() + d.txStartDelay = delay +} + +func (d *MockDriver) SetFailRollback(fail bool) { + d.mu.Lock() + defer d.mu.Unlock() + d.failRollback = fail +} + +func (d *MockDriver) GetConns() []*MockConn { + d.mu.Lock() + defer d.mu.Unlock() + cp := make([]*MockConn, len(d.conns)) + copy(cp, d.conns) + return cp +} + +func (d *MockDriver) Reset() { + d.mu.Lock() + defer d.mu.Unlock() + d.txStartDelay = 0 + d.failRollback = false + d.conns = nil +} + +// MockConn represents a mock MySQL database connection. +type MockConn struct { + mu sync.Mutex + closed bool + inTx bool + txStartDelay time.Duration + failRollback bool + driver *MockDriver +} + +func (mc *MockConn) Prepare(query string) (driver.Stmt, error) { + mc.mu.Lock() + defer mc.mu.Unlock() + if mc.closed { + return nil, driver.ErrBadConn + } + return &MockStmt{conn: mc, query: query}, nil +} + +func (mc *MockConn) Close() error { + mc.mu.Lock() + defer mc.mu.Unlock() + mc.closed = true + return nil +} + +func (mc *MockConn) Begin() (driver.Tx, error) { + return mc.BeginTx(context.Background(), driver.TxOptions{}) +} + +// BeginTx starts a transaction with context awareness, rollback handling, and pool protection. +func (mc *MockConn) BeginTx(ctx context.Context, opts driver.TxOptions) (driver.Tx, error) { + // 1. Check pre-canceled context + if err := ctx.Err(); err != nil { + return nil, err + } + + mc.mu.Lock() + if mc.closed { + mc.mu.Unlock() + return nil, driver.ErrBadConn + } + delay := mc.txStartDelay + mc.mu.Unlock() + + // 2. Simulate transaction start command ("START TRANSACTION") + done := make(chan error, 1) + + go func() { + if delay > 0 { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-timer.C: + case <-ctx.Done(): + done <- ctx.Err() + return + } + } + + mc.mu.Lock() + defer mc.mu.Unlock() + + if mc.closed { + done <- driver.ErrBadConn + return + } + + mc.inTx = true + done <- nil + }() + + select { + case err := <-done: + if err != nil { + return nil, err + } + + // Context canceled right after START TRANSACTION completed + if err := ctx.Err(); err != nil { + return nil, mc.cleanupCanceledTx(err) + } + + return &MockTx{conn: mc}, nil + + case <-ctx.Done(): + // Wait for initialization goroutine to finish updating connection state + <-done + + mc.mu.Lock() + inTx := mc.inTx + mc.mu.Unlock() + + if !inTx { + // Transaction was never started on server/connection + return nil, ctx.Err() + } + + // Transaction was started; attempt rollback or discard connection + return nil, mc.cleanupCanceledTx(ctx.Err()) + } +} + +// cleanupCanceledTx rolls back an active transaction or closes the connection if state is uncertain. +func (mc *MockConn) cleanupCanceledTx(ctxErr error) error { + mc.mu.Lock() + defer mc.mu.Unlock() + + if mc.closed { + return driver.ErrBadConn + } + + // If rollback fails or connection state is uncertain, discard connection completely + if mc.failRollback { + mc.closed = true + return driver.ErrBadConn + } + + // Successfully execute ROLLBACK to clean connection state + mc.inTx = false + return ctxErr +} + +func (mc *MockConn) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Result, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + mc.mu.Lock() + defer mc.mu.Unlock() + + if mc.closed { + return nil, driver.ErrBadConn + } + + switch query { + case "START TRANSACTION", "BEGIN": + mc.inTx = true + case "COMMIT", "ROLLBACK": + mc.inTx = false + } + return driver.RowsAffected(1), nil +} + +func (mc *MockConn) QueryContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Rows, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + mc.mu.Lock() + defer mc.mu.Unlock() + + if mc.closed { + return nil, driver.ErrBadConn + } + + if query == "SELECT @@in_transaction" || query == "SELECT @@in_transaction AS in_tx" { + inTxVal := int64(0) + if mc.inTx { + inTxVal = 1 + } + return &MockRows{ + columns: []string{"@@in_transaction"}, + values: [][]driver.Value{{inTxVal}}, + }, nil + } + + return &MockRows{ + columns: []string{"result"}, + values: [][]driver.Value{{"ok"}}, + }, nil +} + +func (mc *MockConn) Ping(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + mc.mu.Lock() + defer mc.mu.Unlock() + if mc.closed { + return driver.ErrBadConn + } + return nil +} + +func (mc *MockConn) IsValid() bool { + mc.mu.Lock() + defer mc.mu.Unlock() + return !mc.closed +} + +func (mc *MockConn) InTx() bool { + mc.mu.Lock() + defer mc.mu.Unlock() + return mc.inTx +} + +func (mc *MockConn) IsClosed() bool { + mc.mu.Lock() + defer mc.mu.Unlock() + return mc.closed +} + +// MockTx implements driver.Tx. +type MockTx struct { + conn *MockConn + done bool +} + +func (tx *MockTx) Commit() error { + tx.conn.mu.Lock() + defer tx.conn.mu.Unlock() + + if tx.done { + return sql.ErrTxDone + } + tx.done = true + if tx.conn.closed { + return driver.ErrBadConn + } + if !tx.conn.inTx { + return errors.New("no active transaction") + } + tx.conn.inTx = false + return nil +} + +func (tx *MockTx) Rollback() error { + tx.conn.mu.Lock() + defer tx.conn.mu.Unlock() + + if tx.done { + return sql.ErrTxDone + } + tx.done = true + if tx.conn.closed { + return driver.ErrBadConn + } + if !tx.conn.inTx { + return errors.New("no active transaction") + } + tx.conn.inTx = false + return nil +} + +// MockStmt implements driver.Stmt. +type MockStmt struct { + conn *MockConn + query string +} + +func (s *MockStmt) Close() error { return nil } +func (s *MockStmt) NumInput() int { return -1 } + +func (s *MockStmt) Exec(args []driver.Value) (driver.Result, error) { + return s.conn.ExecContext(context.Background(), s.query, nil) +} + +func (s *MockStmt) Query(args []driver.Value) (driver.Rows, error) { + return s.conn.QueryContext(context.Background(), s.query, nil) +} + +// MockRows implements driver.Rows. +type MockRows struct { + columns []string + values [][]driver.Value + index int +} + +func (mr *MockRows) Columns() []string { + return mr.columns +} + +func (mr *MockRows) Close() error { + return nil +} + +func (mr *MockRows) Next(dest []driver.Value) error { + if mr.index >= len(mr.values) { + return io.EOF + } + row := mr.values[mr.index] + mr.index++ + for i, val := range row { + dest[i] = val + } + return nil +} diff --git a/driver_test.go b/driver_test.go new file mode 100644 index 0000000..6e44099 --- /dev/null +++ b/driver_test.go @@ -0,0 +1,176 @@ +package mysql + +import ( + "context" + "database/sql" + "math/rand" + "sync" + "testing" + "time" +) + +// Helper to verify that all connections in the pool have @@in_transaction = 0 +func verifyPoolCleanliness(t *testing.T, db *sql.DB, numChecks int) { + t.Helper() + for i := 0; i < numChecks; i++ { + var inTx int + err := db.QueryRow("SELECT @@in_transaction").Scan(&inTx) + if err != nil { + t.Fatalf("check %d failed to query @@in_transaction: %v", i, err) + } + if inTx != 0 { + t.Fatalf("check %d pool pollution detected: @@in_transaction = %d, expected 0", i, inTx) + } + } +} + +// Requirement 2: Test calling BeginTx with a pre-canceled context. +func TestBeginTx_PreCanceledContext(t *testing.T) { + DefaultDriver.Reset() + db, err := sql.Open("mock_mysql", "test_precancel") + if err != nil { + t.Fatalf("failed to open db: %v", err) + } + defer db.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // pre-cancel context + + tx, err := db.BeginTx(ctx, nil) + if err == nil { + tx.Rollback() + t.Fatal("expected error calling BeginTx with pre-canceled context, got nil") + } + + if err != context.Canceled { + t.Logf("BeginTx returned error: %v (expected context.Canceled)", err) + } + + // Requirement 3: Verify pool cleanliness by checking @@in_transaction + verifyPoolCleanliness(t, db, 5) +} + +// Requirement 1: Test simulating context cancellation during transaction start. +func TestBeginTx_ContextCanceledDuringStart(t *testing.T) { + DefaultDriver.Reset() + DefaultDriver.SetTxStartDelay(50 * time.Millisecond) + + db, err := sql.Open("mock_mysql", "test_cancel_during_start") + if err != nil { + t.Fatalf("failed to open db: %v", err) + } + defer db.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + + tx, err := db.BeginTx(ctx, nil) + if err == nil { + tx.Rollback() + t.Fatal("expected error due to context timeout during BeginTx, got nil") + } + + // Verify pool cleanliness by checking @@in_transaction + verifyPoolCleanliness(t, db, 5) +} + +// Acceptance Criteria 2: Test rollback or closing connection when state is uncertain. +func TestBeginTx_UncertainState_ClosesConnection(t *testing.T) { + DefaultDriver.Reset() + DefaultDriver.SetTxStartDelay(30 * time.Millisecond) + DefaultDriver.SetFailRollback(true) + + db, err := sql.Open("mock_mysql", "test_uncertain_state") + if err != nil { + t.Fatalf("failed to open db: %v", err) + } + defer db.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + + tx, err := db.BeginTx(ctx, nil) + if err == nil { + tx.Rollback() + t.Fatal("expected error due to failed rollback/uncertain state, got nil") + } + + // Connection should have been closed/discarded by driver, preventing active tx leak + verifyPoolCleanliness(t, db, 5) +} + +// Acceptance Criteria 3: Verify pool cleanliness under high concurrency and frequent timeouts. +func TestPoolCleanliness_HighConcurrency(t *testing.T) { + DefaultDriver.Reset() + DefaultDriver.SetTxStartDelay(5 * time.Millisecond) + + db, err := sql.Open("mock_mysql", "test_high_concurrency") + if err != nil { + t.Fatalf("failed to open db: %v", err) + } + defer db.Close() + + db.SetMaxOpenConns(10) + db.SetMaxIdleConns(10) + + const numWorkers = 50 + var wg sync.WaitGroup + wg.Add(numWorkers) + + for i := 0; i < numWorkers; i++ { + go func(id int) { + defer wg.Done() + + // Random timeout: some will time out during start, some will succeed, some pre-canceled + timeout := time.Duration(rand.Intn(12)) * time.Millisecond + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + tx, err := db.BeginTx(ctx, nil) + if err == nil { + // Simulate brief work inside transaction + time.Sleep(2 * time.Millisecond) + if id%2 == 0 { + _ = tx.Commit() + } else { + _ = tx.Rollback() + } + } + }(i) + } + + wg.Wait() + + // Requirement 3: Verify pool cleanliness across all connections + verifyPoolCleanliness(t, db, 20) +} + +// Acceptance Criteria 4: Verify no race conditions between query execution and cancellation listener. +func TestNoRaceConditions(t *testing.T) { + DefaultDriver.Reset() + DefaultDriver.SetTxStartDelay(2 * time.Millisecond) + + db, err := sql.Open("mock_mysql", "test_race_conditions") + if err != nil { + t.Fatalf("failed to open db: %v", err) + } + defer db.Close() + + var wg sync.WaitGroup + for i := 0; i < 30; i++ { + wg.Add(1) + go func() { + defer wg.Done() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Millisecond) + defer cancel() + + tx, err := db.BeginTx(ctx, nil) + if err == nil { + _ = tx.Rollback() + } + }() + } + + wg.Wait() + verifyPoolCleanliness(t, db, 10) +} diff --git a/main.go b/main.go deleted file mode 100644 index 49f4dee..0000000 --- a/main.go +++ /dev/null @@ -1,7 +0,0 @@ -package main - -import "fmt" - -func main() { - fmt.Println("Hello, Bounty Hunter!") -}