package repository import ( "context" "database/sql" "database/sql/driver" "errors" "fmt" "io" "os/exec" "sync" "time" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/service" ) // PgDumper implements service.DBDumper using pg_dump/psql type PgDumper struct { cfg *config.DatabaseConfig db *sql.DB commandContext func(context.Context, string, ...string) *exec.Cmd } // NewPgDumper creates a new PgDumper func NewPgDumper(cfg *config.Config, db *sql.DB) service.DBDumper { return &PgDumper{ cfg: &cfg.Database, db: db, commandContext: exec.CommandContext, } } // Dump executes pg_dump and returns a streaming reader of the output func (d *PgDumper) Dump(ctx context.Context) (io.ReadCloser, error) { if d.db == nil { return nil, errors.New("acquire backup migration lock: nil sql db") } lockConn, err := d.db.Conn(ctx) if err != nil { return nil, fmt.Errorf("acquire backup migration lock connection: %w", err) } if err := pgAdvisoryLock(ctx, lockConn); err != nil { // Lock acquisition can fail after PostgreSQL accepted the request. Discard // the session because ownership is ambiguous and advisory locks are session-scoped. discardSQLConnection(lockConn) return nil, fmt.Errorf("acquire backup migration lock: %w", err) } releaseLock := func() error { return releaseBackupMigrationLock(lockConn) } args := []string{ "-h", d.cfg.Host, "-p", fmt.Sprintf("%d", d.cfg.Port), "-U", d.cfg.User, "-d", d.cfg.DBName, "--no-owner", "--no-acl", "--clean", "--if-exists", } commandContext := d.commandContext if commandContext == nil { commandContext = exec.CommandContext } cmd := commandContext(ctx, "pg_dump", args...) if d.cfg.Password != "" { cmd.Env = append(cmd.Environ(), "PGPASSWORD="+d.cfg.Password) } if d.cfg.SSLMode != "" { cmd.Env = append(cmd.Environ(), "PGSSLMODE="+d.cfg.SSLMode) } stdout, err := cmd.StdoutPipe() if err != nil { return nil, errors.Join(fmt.Errorf("create stdout pipe: %w", err), releaseLock()) } if err := cmd.Start(); err != nil { _ = stdout.Close() return nil, errors.Join(fmt.Errorf("start pg_dump: %w", err), releaseLock()) } return &cmdReadCloser{ReadCloser: stdout, cmd: cmd, release: releaseLock}, nil } func releaseBackupMigrationLock(conn *sql.Conn) error { unlockCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := pgAdvisoryUnlock(unlockCtx, conn); err != nil { // A failed unlock must not return a possibly lock-owning session to the pool. discardSQLConnection(conn) return fmt.Errorf("release backup migration lock: %w", err) } if err := conn.Close(); err != nil { return fmt.Errorf("close backup migration lock connection: %w", err) } return nil } func discardSQLConnection(conn *sql.Conn) { _ = conn.Raw(func(any) error { return driver.ErrBadConn }) _ = conn.Close() } // Restore executes psql to restore from a streaming reader func (d *PgDumper) Restore(ctx context.Context, data io.Reader) error { args := []string{ "-h", d.cfg.Host, "-p", fmt.Sprintf("%d", d.cfg.Port), "-U", d.cfg.User, "-d", d.cfg.DBName, "--single-transaction", } cmd := exec.CommandContext(ctx, "psql", args...) if d.cfg.Password != "" { cmd.Env = append(cmd.Environ(), "PGPASSWORD="+d.cfg.Password) } if d.cfg.SSLMode != "" { cmd.Env = append(cmd.Environ(), "PGSSLMODE="+d.cfg.SSLMode) } cmd.Stdin = data output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("%v: %s", err, string(output)) } return nil } // cmdReadCloser wraps a command stdout pipe and waits for the process on Close type cmdReadCloser struct { io.ReadCloser cmd *exec.Cmd release func() error closeOnce sync.Once closeErr error } func (c *cmdReadCloser) Close() error { c.closeOnce.Do(func() { var closeErrs []error if err := c.ReadCloser.Close(); err != nil { closeErrs = append(closeErrs, fmt.Errorf("close pg_dump stdout: %w", err)) } if err := c.cmd.Wait(); err != nil { closeErrs = append(closeErrs, fmt.Errorf("pg_dump exited with error: %w", err)) } if c.release != nil { if err := c.release(); err != nil { closeErrs = append(closeErrs, err) } } c.closeErr = errors.Join(closeErrs...) }) return c.closeErr }