mirror of
https://github.com/MedUnes/go-kata.git
synced 2026-07-28 10:40:14 +07:00
@@ -0,0 +1,146 @@
|
|||||||
|
package defer_cleanup_chain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tx models the transaction lifecycle we need for this kata.
|
||||||
|
// Keeping this small makes it easy to provide fake implementations in tests.
|
||||||
|
type Tx interface {
|
||||||
|
Commit() error
|
||||||
|
Rollback() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rows models a streaming result set.
|
||||||
|
// Values returns the current row payload after Next() moved to a valid item.
|
||||||
|
type Rows interface {
|
||||||
|
Next() bool
|
||||||
|
Values() (id int, payload string, err error)
|
||||||
|
Err() error
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB is the minimal database contract needed by BackupDatabase.
|
||||||
|
// The implementation can be real SQL, in-memory, or fully mocked in tests.
|
||||||
|
type DB interface {
|
||||||
|
Begin(ctx context.Context) (Tx, error)
|
||||||
|
QueryBackupRows(ctx context.Context, tx Tx) (Rows, error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connector abstracts DB creation so tests can inject controlled failures.
|
||||||
|
type Connector func(ctx context.Context, dbURL string) (DB, error)
|
||||||
|
|
||||||
|
// defaultConnector is intentionally unconfigured.
|
||||||
|
// In real code this would be wired to an actual driver in init/bootstrap code.
|
||||||
|
var defaultConnector Connector = func(ctx context.Context, dbURL string) (DB, error) {
|
||||||
|
return nil, errors.New("no database connector configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackupDatabase performs the backup flow while preserving both operation and
|
||||||
|
// cleanup errors. It uses a named return error so deferred cleanups can append
|
||||||
|
// their own failures via errors.Join.
|
||||||
|
func BackupDatabase(ctx context.Context, dbURL, filename string) (err error) {
|
||||||
|
return backupDatabase(ctx, dbURL, filename, defaultConnector, os.Create)
|
||||||
|
}
|
||||||
|
|
||||||
|
// backupDatabase is split from BackupDatabase to keep the public API clean while
|
||||||
|
// allowing tests to inject open/connect behavior and force edge cases.
|
||||||
|
func backupDatabase(
|
||||||
|
ctx context.Context,
|
||||||
|
dbURL, filename string,
|
||||||
|
connect Connector,
|
||||||
|
openFile func(name string) (*os.File, error),
|
||||||
|
) (err error) {
|
||||||
|
// Acquire #1: output file.
|
||||||
|
// Defer is registered immediately so every return path closes the file.
|
||||||
|
file, err := openFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open output file: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if closeErr := file.Close(); closeErr != nil {
|
||||||
|
err = errors.Join(err, fmt.Errorf("close output file: %w", closeErr))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wrap the file with a buffered writer. This is another acquired resource:
|
||||||
|
// data can remain in memory until Flush runs, so we must defer Flush as well.
|
||||||
|
writer := bufio.NewWriter(file)
|
||||||
|
defer func() {
|
||||||
|
if flushErr := writer.Flush(); flushErr != nil {
|
||||||
|
err = errors.Join(err, fmt.Errorf("flush writer: %w", flushErr))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Acquire #2: DB connection.
|
||||||
|
db, err := connect(ctx, dbURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect DB: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if closeErr := db.Close(); closeErr != nil {
|
||||||
|
err = errors.Join(err, fmt.Errorf("close DB connection: %w", closeErr))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Acquire #3: transaction.
|
||||||
|
// We avoid manual rollback branches by using a committed flag consumed by defer.
|
||||||
|
tx, err := db.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
committed := false
|
||||||
|
defer func() {
|
||||||
|
if committed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||||
|
err = errors.Join(err, fmt.Errorf("rollback transaction: %w", rollbackErr))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Acquire #4: row stream (logical cursor/network resource).
|
||||||
|
rows, err := db.QueryBackupRows(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("query backup rows: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if closeErr := rows.Close(); closeErr != nil {
|
||||||
|
err = errors.Join(err, fmt.Errorf("close rows: %w", closeErr))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Stream each row to the backup file.
|
||||||
|
// We intentionally do not defer anything inside this loop.
|
||||||
|
for rows.Next() {
|
||||||
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||||
|
return fmt.Errorf("backup canceled: %w", ctxErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
id, payload, rowErr := rows.Values()
|
||||||
|
if rowErr != nil {
|
||||||
|
return fmt.Errorf("read row values: %w", rowErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, writeErr := fmt.Fprintf(writer, "%d,%s\n", id, payload); writeErr != nil {
|
||||||
|
return fmt.Errorf("write backup row: %w", writeErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rowsErr := rows.Err(); rowsErr != nil {
|
||||||
|
return fmt.Errorf("iterate backup rows: %w", rowsErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit is the success boundary.
|
||||||
|
// If commit fails, committed remains false and deferred rollback still runs.
|
||||||
|
if commitErr := tx.Commit(); commitErr != nil {
|
||||||
|
return fmt.Errorf("commit transaction: %w", commitErr)
|
||||||
|
}
|
||||||
|
committed = true
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,547 @@
|
|||||||
|
package defer_cleanup_chain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeRow struct {
|
||||||
|
id int
|
||||||
|
payload string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeRows struct {
|
||||||
|
data []fakeRow
|
||||||
|
nextIdx int
|
||||||
|
iterErr error
|
||||||
|
closeErr error
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRows) Next() bool {
|
||||||
|
if r.nextIdx >= len(r.data) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
r.nextIdx++
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRows) Values() (int, string, error) {
|
||||||
|
if r.nextIdx == 0 || r.nextIdx > len(r.data) {
|
||||||
|
return 0, "", errors.New("values called without current row")
|
||||||
|
}
|
||||||
|
row := r.data[r.nextIdx-1]
|
||||||
|
if row.err != nil {
|
||||||
|
return 0, "", row.err
|
||||||
|
}
|
||||||
|
return row.id, row.payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRows) Err() error {
|
||||||
|
return r.iterErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRows) Close() error {
|
||||||
|
r.closed = true
|
||||||
|
return r.closeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeTx struct {
|
||||||
|
commitErr error
|
||||||
|
rollbackErr error
|
||||||
|
commitCalled int
|
||||||
|
rollbackCalled int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tx *fakeTx) Commit() error {
|
||||||
|
tx.commitCalled++
|
||||||
|
return tx.commitErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tx *fakeTx) Rollback() error {
|
||||||
|
tx.rollbackCalled++
|
||||||
|
return tx.rollbackErr
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeDB struct {
|
||||||
|
tx Tx
|
||||||
|
rows Rows
|
||||||
|
beginErr error
|
||||||
|
queryErr error
|
||||||
|
closeErr error
|
||||||
|
beginCalled int
|
||||||
|
queryCalled int
|
||||||
|
closeCalled int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *fakeDB) Begin(context.Context) (Tx, error) {
|
||||||
|
db.beginCalled++
|
||||||
|
if db.beginErr != nil {
|
||||||
|
return nil, db.beginErr
|
||||||
|
}
|
||||||
|
return db.tx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *fakeDB) QueryBackupRows(context.Context, Tx) (Rows, error) {
|
||||||
|
db.queryCalled++
|
||||||
|
if db.queryErr != nil {
|
||||||
|
return nil, db.queryErr
|
||||||
|
}
|
||||||
|
return db.rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *fakeDB) Close() error {
|
||||||
|
db.closeCalled++
|
||||||
|
return db.closeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackupDatabase_Matrix(t *testing.T) {
|
||||||
|
type tc struct {
|
||||||
|
name string
|
||||||
|
|
||||||
|
connectErr error
|
||||||
|
openErr error
|
||||||
|
beginErr error
|
||||||
|
queryErr error
|
||||||
|
rows []fakeRow
|
||||||
|
iterErr error
|
||||||
|
rowsCloseErr error
|
||||||
|
commitErr error
|
||||||
|
rollbackErr error
|
||||||
|
dbCloseErr error
|
||||||
|
cancelCtx bool
|
||||||
|
openReadOnly bool
|
||||||
|
largePayloadRow bool
|
||||||
|
|
||||||
|
wantErr bool
|
||||||
|
wantErrContains []string
|
||||||
|
wantErrIs []error
|
||||||
|
wantCommitCalls int
|
||||||
|
wantRollbackCalls int
|
||||||
|
wantDBCloseCalls int
|
||||||
|
wantRowsClosed bool
|
||||||
|
wantOutput string
|
||||||
|
}
|
||||||
|
|
||||||
|
errConnectBoom := errors.New("connect boom")
|
||||||
|
errOpenBoom := errors.New("open boom")
|
||||||
|
errBeginBoom := errors.New("begin boom")
|
||||||
|
errQueryBoom := errors.New("query boom")
|
||||||
|
errRollbackBoom := errors.New("rollback boom")
|
||||||
|
errDBCloseBoom := errors.New("db close boom")
|
||||||
|
errValuesBoom := errors.New("values boom")
|
||||||
|
errIterateBoom := errors.New("iterate boom")
|
||||||
|
errCommitBoom := errors.New("commit boom")
|
||||||
|
|
||||||
|
tests := []tc{
|
||||||
|
{
|
||||||
|
name: "success commits and writes all rows",
|
||||||
|
rows: []fakeRow{{id: 1, payload: "alpha"}, {id: 2, payload: "beta"}},
|
||||||
|
wantCommitCalls: 1,
|
||||||
|
wantRollbackCalls: 0,
|
||||||
|
wantDBCloseCalls: 1,
|
||||||
|
wantRowsClosed: true,
|
||||||
|
wantOutput: "1,alpha\n2,beta\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "connect failure returns wrapped error",
|
||||||
|
connectErr: errConnectBoom,
|
||||||
|
wantErr: true,
|
||||||
|
wantErrContains: []string{"connect DB"},
|
||||||
|
wantErrIs: []error{errConnectBoom},
|
||||||
|
wantCommitCalls: 0,
|
||||||
|
wantRollbackCalls: 0,
|
||||||
|
wantDBCloseCalls: 0,
|
||||||
|
wantRowsClosed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "begin failure keeps cleanup error with join",
|
||||||
|
beginErr: errBeginBoom,
|
||||||
|
dbCloseErr: errDBCloseBoom,
|
||||||
|
wantErr: true,
|
||||||
|
wantErrContains: []string{"begin transaction", "close DB connection"},
|
||||||
|
wantErrIs: []error{errBeginBoom, errDBCloseBoom},
|
||||||
|
wantCommitCalls: 0,
|
||||||
|
wantRollbackCalls: 0,
|
||||||
|
wantDBCloseCalls: 1,
|
||||||
|
wantRowsClosed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "query failure triggers rollback and preserves cleanup failures",
|
||||||
|
queryErr: errQueryBoom,
|
||||||
|
rollbackErr: errRollbackBoom,
|
||||||
|
dbCloseErr: errDBCloseBoom,
|
||||||
|
wantErr: true,
|
||||||
|
wantErrContains: []string{"query backup rows", "rollback transaction", "close DB connection"},
|
||||||
|
wantErrIs: []error{errQueryBoom, errRollbackBoom, errDBCloseBoom},
|
||||||
|
wantCommitCalls: 0,
|
||||||
|
wantRollbackCalls: 1,
|
||||||
|
wantDBCloseCalls: 1,
|
||||||
|
wantRowsClosed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "row values failure stops stream and rolls back",
|
||||||
|
rows: []fakeRow{{id: 1, payload: "ok"}, {err: errValuesBoom}},
|
||||||
|
wantErr: true,
|
||||||
|
wantErrContains: []string{"read row values"},
|
||||||
|
wantErrIs: []error{errValuesBoom},
|
||||||
|
wantCommitCalls: 0,
|
||||||
|
wantRollbackCalls: 1,
|
||||||
|
wantDBCloseCalls: 1,
|
||||||
|
wantRowsClosed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rows err after iteration rolls back",
|
||||||
|
rows: []fakeRow{{id: 1, payload: "ok"}},
|
||||||
|
iterErr: errIterateBoom,
|
||||||
|
wantErr: true,
|
||||||
|
wantErrContains: []string{"iterate backup rows"},
|
||||||
|
wantErrIs: []error{errIterateBoom},
|
||||||
|
wantCommitCalls: 0,
|
||||||
|
wantRollbackCalls: 1,
|
||||||
|
wantDBCloseCalls: 1,
|
||||||
|
wantRowsClosed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "commit failure keeps rollback and close errors",
|
||||||
|
rows: []fakeRow{{id: 1, payload: "ok"}},
|
||||||
|
commitErr: errCommitBoom,
|
||||||
|
rollbackErr: errRollbackBoom,
|
||||||
|
dbCloseErr: errDBCloseBoom,
|
||||||
|
wantErr: true,
|
||||||
|
wantErrContains: []string{"commit transaction", "rollback transaction", "close DB connection"},
|
||||||
|
wantErrIs: []error{errCommitBoom, errRollbackBoom, errDBCloseBoom},
|
||||||
|
wantCommitCalls: 1,
|
||||||
|
wantRollbackCalls: 1,
|
||||||
|
wantDBCloseCalls: 1,
|
||||||
|
wantRowsClosed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "context cancellation in loop returns cancellation error",
|
||||||
|
rows: []fakeRow{{id: 1, payload: "ok"}},
|
||||||
|
cancelCtx: true,
|
||||||
|
wantErr: true,
|
||||||
|
wantErrContains: []string{"backup canceled", context.Canceled.Error()},
|
||||||
|
wantErrIs: []error{context.Canceled},
|
||||||
|
wantCommitCalls: 0,
|
||||||
|
wantRollbackCalls: 1,
|
||||||
|
wantDBCloseCalls: 1,
|
||||||
|
wantRowsClosed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "flush failure after successful work is returned",
|
||||||
|
rows: []fakeRow{{id: 1, payload: "ok"}},
|
||||||
|
openReadOnly: true,
|
||||||
|
wantErr: true,
|
||||||
|
wantErrContains: []string{"flush writer"},
|
||||||
|
wantCommitCalls: 1,
|
||||||
|
wantRollbackCalls: 0,
|
||||||
|
wantDBCloseCalls: 1,
|
||||||
|
wantRowsClosed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "write failure in loop is returned",
|
||||||
|
rows: []fakeRow{{id: 1, payload: "ok"}},
|
||||||
|
openReadOnly: true,
|
||||||
|
largePayloadRow: true,
|
||||||
|
wantErr: true,
|
||||||
|
wantErrContains: []string{"write backup row"},
|
||||||
|
wantCommitCalls: 0,
|
||||||
|
wantRollbackCalls: 1,
|
||||||
|
wantDBCloseCalls: 1,
|
||||||
|
wantRowsClosed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "open file failure short-circuits immediately",
|
||||||
|
openErr: errOpenBoom,
|
||||||
|
wantErr: true,
|
||||||
|
wantErrContains: []string{"open output file"},
|
||||||
|
wantErrIs: []error{errOpenBoom},
|
||||||
|
wantCommitCalls: 0,
|
||||||
|
wantRollbackCalls: 0,
|
||||||
|
wantDBCloseCalls: 0,
|
||||||
|
wantRowsClosed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rows close failure is preserved",
|
||||||
|
rows: []fakeRow{{id: 1, payload: "ok"}},
|
||||||
|
rowsCloseErr: errors.New("rows close boom"),
|
||||||
|
wantErr: true,
|
||||||
|
wantErrContains: []string{"close rows"},
|
||||||
|
wantCommitCalls: 1,
|
||||||
|
wantRollbackCalls: 0,
|
||||||
|
wantDBCloseCalls: 1,
|
||||||
|
wantRowsClosed: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
filename := filepath.Join(dir, "backup.csv")
|
||||||
|
|
||||||
|
if tt.openReadOnly {
|
||||||
|
require.NoError(t, os.WriteFile(filename, nil, 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsData := tt.rows
|
||||||
|
if tt.largePayloadRow {
|
||||||
|
rowsData = []fakeRow{{id: 1, payload: strings.Repeat("x", 9000)}}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx := &fakeTx{commitErr: tt.commitErr, rollbackErr: tt.rollbackErr}
|
||||||
|
rows := &fakeRows{data: rowsData, iterErr: tt.iterErr, closeErr: tt.rowsCloseErr}
|
||||||
|
db := &fakeDB{
|
||||||
|
tx: tx,
|
||||||
|
rows: rows,
|
||||||
|
beginErr: tt.beginErr,
|
||||||
|
queryErr: tt.queryErr,
|
||||||
|
closeErr: tt.dbCloseErr,
|
||||||
|
}
|
||||||
|
|
||||||
|
connect := func(context.Context, string) (DB, error) {
|
||||||
|
if tt.connectErr != nil {
|
||||||
|
return nil, tt.connectErr
|
||||||
|
}
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
openFile := os.Create
|
||||||
|
if tt.openErr != nil {
|
||||||
|
openFile = func(string) (*os.File, error) {
|
||||||
|
return nil, tt.openErr
|
||||||
|
}
|
||||||
|
} else if tt.openReadOnly {
|
||||||
|
openFile = func(name string) (*os.File, error) {
|
||||||
|
return os.Open(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if tt.cancelCtx {
|
||||||
|
ctxWithCancel, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
ctx = ctxWithCancel
|
||||||
|
}
|
||||||
|
|
||||||
|
err := backupDatabase(ctx, "db://test", filename, connect, openFile)
|
||||||
|
|
||||||
|
if tt.wantErr {
|
||||||
|
require.Error(t, err)
|
||||||
|
for _, substr := range tt.wantErrContains {
|
||||||
|
assert.ErrorContains(t, err, substr)
|
||||||
|
}
|
||||||
|
for _, targetErr := range tt.wantErrIs {
|
||||||
|
assert.ErrorIs(t, err, targetErr)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, tt.wantCommitCalls, tx.commitCalled)
|
||||||
|
assert.Equal(t, tt.wantRollbackCalls, tx.rollbackCalled)
|
||||||
|
assert.Equal(t, tt.wantDBCloseCalls, db.closeCalled)
|
||||||
|
assert.Equal(t, tt.wantRowsClosed, rows.closed)
|
||||||
|
|
||||||
|
if tt.wantOutput != "" {
|
||||||
|
got, readErr := os.ReadFile(filename)
|
||||||
|
require.NoError(t, readErr)
|
||||||
|
assert.Equal(t, tt.wantOutput, string(got))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackupDatabase_SelfCorrection_BeginFails_ClosesFileAndDB(t *testing.T) {
|
||||||
|
errBegin := errors.New("begin failed")
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
filename := filepath.Join(dir, "backup.csv")
|
||||||
|
|
||||||
|
var openedFile *os.File
|
||||||
|
openFile := func(name string) (*os.File, error) {
|
||||||
|
f, err := os.Create(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
openedFile = f
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
db := &fakeDB{beginErr: errBegin}
|
||||||
|
connect := func(context.Context, string) (DB, error) {
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err := backupDatabase(context.Background(), "db://test", filename, connect, openFile)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorContains(t, err, "begin transaction")
|
||||||
|
assert.ErrorIs(t, err, errBegin)
|
||||||
|
assert.Equal(t, 1, db.closeCalled, "db close must run even when Begin fails")
|
||||||
|
require.NotNil(t, openedFile)
|
||||||
|
assert.ErrorIs(t, openedFile.Close(), os.ErrClosed, "file must already be closed by defer")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackupDatabase_SelfCorrection_CommitFailsAndFileCloseFails_BothErrorsReturned(t *testing.T) {
|
||||||
|
errCommit := errors.New("commit failed")
|
||||||
|
|
||||||
|
badFile := os.NewFile(^uintptr(0), "invalid-fd")
|
||||||
|
openFile := func(string) (*os.File, error) {
|
||||||
|
return badFile, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := &fakeRows{}
|
||||||
|
tx := &fakeTx{commitErr: errCommit}
|
||||||
|
db := &fakeDB{tx: tx, rows: rows}
|
||||||
|
connect := func(context.Context, string) (DB, error) {
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err := backupDatabase(context.Background(), "db://test", "ignored.csv", connect, openFile)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorContains(t, err, "commit transaction")
|
||||||
|
assert.ErrorContains(t, err, "close output file")
|
||||||
|
assert.ErrorIs(t, err, errCommit)
|
||||||
|
assert.Equal(t, 1, tx.commitCalled)
|
||||||
|
assert.Equal(t, 1, tx.rollbackCalled, "rollback must run after failed commit")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackupDatabase_SelfCorrection_NoFDLeak_1000Runs(t *testing.T) {
|
||||||
|
before, err := countOpenFDs()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
for i := 0; i < 1000; i++ {
|
||||||
|
dir := t.TempDir()
|
||||||
|
filename := filepath.Join(dir, "backup.csv")
|
||||||
|
|
||||||
|
rows := &fakeRows{}
|
||||||
|
tx := &fakeTx{}
|
||||||
|
db := &fakeDB{tx: tx, rows: rows}
|
||||||
|
connect := func(context.Context, string) (DB, error) {
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err := backupDatabase(context.Background(), "db://test", filename, connect, os.Create)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.GC()
|
||||||
|
after, err := countOpenFDs()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Allow a tiny delta for runtime/testing internals while asserting no growth trend.
|
||||||
|
assert.LessOrEqual(t, after, before+3, "open file descriptors should not grow after 1000 runs")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackupDatabase_PublicWrapper_Success(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
filename := filepath.Join(dir, "backup.csv")
|
||||||
|
|
||||||
|
oldConnector := defaultConnector
|
||||||
|
t.Cleanup(func() {
|
||||||
|
defaultConnector = oldConnector
|
||||||
|
})
|
||||||
|
|
||||||
|
tx := &fakeTx{}
|
||||||
|
rows := &fakeRows{data: []fakeRow{{id: 7, payload: "public"}}}
|
||||||
|
db := &fakeDB{tx: tx, rows: rows}
|
||||||
|
|
||||||
|
defaultConnector = func(context.Context, string) (DB, error) {
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err := BackupDatabase(context.Background(), "db://public", filename)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, tx.commitCalled)
|
||||||
|
assert.Equal(t, 0, tx.rollbackCalled)
|
||||||
|
assert.Equal(t, 1, db.closeCalled)
|
||||||
|
assert.True(t, rows.closed)
|
||||||
|
|
||||||
|
got, readErr := os.ReadFile(filename)
|
||||||
|
require.NoError(t, readErr)
|
||||||
|
assert.Equal(t, "7,public\n", string(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackupDatabase_PublicWrapper_ConnectorError(t *testing.T) {
|
||||||
|
errConnect := errors.New("public connector fail")
|
||||||
|
|
||||||
|
oldConnector := defaultConnector
|
||||||
|
t.Cleanup(func() {
|
||||||
|
defaultConnector = oldConnector
|
||||||
|
})
|
||||||
|
|
||||||
|
defaultConnector = func(context.Context, string) (DB, error) {
|
||||||
|
return nil, errConnect
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := filepath.Join(t.TempDir(), "backup.csv")
|
||||||
|
err := BackupDatabase(context.Background(), "db://public", filename)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorContains(t, err, "connect DB")
|
||||||
|
assert.ErrorIs(t, err, errConnect)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackupDatabase_PublicWrapper_DefaultConnectorUnconfigured(t *testing.T) {
|
||||||
|
oldConnector := defaultConnector
|
||||||
|
t.Cleanup(func() {
|
||||||
|
defaultConnector = oldConnector
|
||||||
|
})
|
||||||
|
|
||||||
|
defaultConnector = oldConnector
|
||||||
|
|
||||||
|
filename := filepath.Join(t.TempDir(), "backup.csv")
|
||||||
|
err := BackupDatabase(context.Background(), "db://public", filename)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorContains(t, err, "connect DB")
|
||||||
|
assert.ErrorContains(t, err, "no database connector configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
func countOpenFDs() (int, error) {
|
||||||
|
var lastErr error
|
||||||
|
|
||||||
|
for _, path := range []string{"/proc/self/fd", "/dev/fd"} {
|
||||||
|
count, err := countNumericDirEntries(path)
|
||||||
|
if err == nil {
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
lastErr = err
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastErr == nil {
|
||||||
|
lastErr = errors.New("no fd directory available")
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func countNumericDirEntries(path string) (int, error) {
|
||||||
|
dir, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer dir.Close()
|
||||||
|
|
||||||
|
names, err := dir.Readdirnames(-1)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for _, name := range names {
|
||||||
|
if _, convErr := strconv.Atoi(name); convErr == nil {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user