backup_pg_dumper.go 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. package repository
  2. import (
  3. "context"
  4. "database/sql"
  5. "database/sql/driver"
  6. "errors"
  7. "fmt"
  8. "io"
  9. "os/exec"
  10. "sync"
  11. "time"
  12. "github.com/Wei-Shaw/sub2api/internal/config"
  13. "github.com/Wei-Shaw/sub2api/internal/service"
  14. )
  15. // PgDumper implements service.DBDumper using pg_dump/psql
  16. type PgDumper struct {
  17. cfg *config.DatabaseConfig
  18. db *sql.DB
  19. commandContext func(context.Context, string, ...string) *exec.Cmd
  20. }
  21. // NewPgDumper creates a new PgDumper
  22. func NewPgDumper(cfg *config.Config, db *sql.DB) service.DBDumper {
  23. return &PgDumper{
  24. cfg: &cfg.Database,
  25. db: db,
  26. commandContext: exec.CommandContext,
  27. }
  28. }
  29. // Dump executes pg_dump and returns a streaming reader of the output
  30. func (d *PgDumper) Dump(ctx context.Context) (io.ReadCloser, error) {
  31. if d.db == nil {
  32. return nil, errors.New("acquire backup migration lock: nil sql db")
  33. }
  34. lockConn, err := d.db.Conn(ctx)
  35. if err != nil {
  36. return nil, fmt.Errorf("acquire backup migration lock connection: %w", err)
  37. }
  38. if err := pgAdvisoryLock(ctx, lockConn); err != nil {
  39. // Lock acquisition can fail after PostgreSQL accepted the request. Discard
  40. // the session because ownership is ambiguous and advisory locks are session-scoped.
  41. discardSQLConnection(lockConn)
  42. return nil, fmt.Errorf("acquire backup migration lock: %w", err)
  43. }
  44. releaseLock := func() error {
  45. return releaseBackupMigrationLock(lockConn)
  46. }
  47. args := []string{
  48. "-h", d.cfg.Host,
  49. "-p", fmt.Sprintf("%d", d.cfg.Port),
  50. "-U", d.cfg.User,
  51. "-d", d.cfg.DBName,
  52. "--no-owner",
  53. "--no-acl",
  54. "--clean",
  55. "--if-exists",
  56. }
  57. commandContext := d.commandContext
  58. if commandContext == nil {
  59. commandContext = exec.CommandContext
  60. }
  61. cmd := commandContext(ctx, "pg_dump", args...)
  62. if d.cfg.Password != "" {
  63. cmd.Env = append(cmd.Environ(), "PGPASSWORD="+d.cfg.Password)
  64. }
  65. if d.cfg.SSLMode != "" {
  66. cmd.Env = append(cmd.Environ(), "PGSSLMODE="+d.cfg.SSLMode)
  67. }
  68. stdout, err := cmd.StdoutPipe()
  69. if err != nil {
  70. return nil, errors.Join(fmt.Errorf("create stdout pipe: %w", err), releaseLock())
  71. }
  72. if err := cmd.Start(); err != nil {
  73. _ = stdout.Close()
  74. return nil, errors.Join(fmt.Errorf("start pg_dump: %w", err), releaseLock())
  75. }
  76. return &cmdReadCloser{ReadCloser: stdout, cmd: cmd, release: releaseLock}, nil
  77. }
  78. func releaseBackupMigrationLock(conn *sql.Conn) error {
  79. unlockCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  80. defer cancel()
  81. if err := pgAdvisoryUnlock(unlockCtx, conn); err != nil {
  82. // A failed unlock must not return a possibly lock-owning session to the pool.
  83. discardSQLConnection(conn)
  84. return fmt.Errorf("release backup migration lock: %w", err)
  85. }
  86. if err := conn.Close(); err != nil {
  87. return fmt.Errorf("close backup migration lock connection: %w", err)
  88. }
  89. return nil
  90. }
  91. func discardSQLConnection(conn *sql.Conn) {
  92. _ = conn.Raw(func(any) error { return driver.ErrBadConn })
  93. _ = conn.Close()
  94. }
  95. // Restore executes psql to restore from a streaming reader
  96. func (d *PgDumper) Restore(ctx context.Context, data io.Reader) error {
  97. args := []string{
  98. "-h", d.cfg.Host,
  99. "-p", fmt.Sprintf("%d", d.cfg.Port),
  100. "-U", d.cfg.User,
  101. "-d", d.cfg.DBName,
  102. "--single-transaction",
  103. }
  104. cmd := exec.CommandContext(ctx, "psql", args...)
  105. if d.cfg.Password != "" {
  106. cmd.Env = append(cmd.Environ(), "PGPASSWORD="+d.cfg.Password)
  107. }
  108. if d.cfg.SSLMode != "" {
  109. cmd.Env = append(cmd.Environ(), "PGSSLMODE="+d.cfg.SSLMode)
  110. }
  111. cmd.Stdin = data
  112. output, err := cmd.CombinedOutput()
  113. if err != nil {
  114. return fmt.Errorf("%v: %s", err, string(output))
  115. }
  116. return nil
  117. }
  118. // cmdReadCloser wraps a command stdout pipe and waits for the process on Close
  119. type cmdReadCloser struct {
  120. io.ReadCloser
  121. cmd *exec.Cmd
  122. release func() error
  123. closeOnce sync.Once
  124. closeErr error
  125. }
  126. func (c *cmdReadCloser) Close() error {
  127. c.closeOnce.Do(func() {
  128. var closeErrs []error
  129. if err := c.ReadCloser.Close(); err != nil {
  130. closeErrs = append(closeErrs, fmt.Errorf("close pg_dump stdout: %w", err))
  131. }
  132. if err := c.cmd.Wait(); err != nil {
  133. closeErrs = append(closeErrs, fmt.Errorf("pg_dump exited with error: %w", err))
  134. }
  135. if c.release != nil {
  136. if err := c.release(); err != nil {
  137. closeErrs = append(closeErrs, err)
  138. }
  139. }
  140. c.closeErr = errors.Join(closeErrs...)
  141. })
  142. return c.closeErr
  143. }