diff --git a/04-errors-semantics/19-defer-cleanup-chain/backup.go b/04-errors-semantics/19-defer-cleanup-chain/backup.go new file mode 100644 index 0000000..6fe88be --- /dev/null +++ b/04-errors-semantics/19-defer-cleanup-chain/backup.go @@ -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 +}