blob: 85ee1de95b5691bd3110df6b558974e529a8c2cd [file]
package backup
/*
* This file contains structs and functions related to backing up data on the segments.
*/
import (
"errors"
"fmt"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/apache/cloudberry-backup/options"
"github.com/apache/cloudberry-backup/utils"
"github.com/apache/cloudberry-go-libs/dbconn"
"github.com/apache/cloudberry-go-libs/gplog"
"github.com/jackc/pgconn"
"gopkg.in/cheggaaa/pb.v1"
)
var (
tableDelim = ","
)
func ConstructTableAttributesList(columnDefs []ColumnDefinition) string {
// this attribute list used ONLY by CopyTableIn on the restore side
// columns where data should not be copied out and back in are excluded from this string.
names := make([]string, 0)
for _, col := range columnDefs {
// data in generated columns should not be backed up or restored.
if col.AttGenerated == "" {
names = append(names, col.Name)
}
}
if len(names) > 0 {
return fmt.Sprintf("(%s)", strings.Join(names, ","))
}
return ""
}
func AddTableDataEntriesToTOC(tables []Table, rowsCopiedMaps []map[uint32]int64) {
for _, table := range tables {
if !table.SkipDataBackup() {
var rowsCopied int64
for _, rowsCopiedMap := range rowsCopiedMaps {
if val, ok := rowsCopiedMap[table.Oid]; ok {
rowsCopied = val
break
}
}
attributes := ConstructTableAttributesList(table.ColumnDefs)
globalTOC.AddCoordinatorDataEntry(table.Schema, table.Name, table.Oid, attributes, rowsCopied, table.PartitionLevelInfo.RootName, table.DistPolicy.Policy, table.DistPolicy.DistByEnum)
}
}
}
type BackupProgressCounters struct {
NumRegTables int64
TotalRegTables int64
ProgressBar utils.ProgressBar
}
func CopyTableOut(connectionPool *dbconn.DBConn, table Table, destinationToWrite string, connNum int) (int64, error) {
if wasTerminated {
return -1, nil
}
checkPipeExistsCommand := ""
customPipeThroughCommand := utils.GetPipeThroughProgram().OutputCommand
sendToDestinationCommand := ">"
if MustGetFlagBool(options.SINGLE_DATA_FILE) {
/*
* The segment TOC files are always written to the segment data directory for
* performance reasons, in case the user-specified directory is on a mounted
* drive. It will be copied to a user-specified directory, if any, once all
* of the data is backed up.
*/
checkPipeExistsCommand = fmt.Sprintf("(test -p \"%s\" || (echo \"Pipe not found %s\">&2; exit 1)) && ", destinationToWrite, destinationToWrite)
customPipeThroughCommand = utils.DefaultPipeThroughProgram
} else if MustGetFlagString(options.PLUGIN_CONFIG) != "" {
sendToDestinationCommand = fmt.Sprintf("| %s backup_data %s", pluginConfig.ExecutablePath, pluginConfig.ConfigPath)
}
copyCommand := fmt.Sprintf("PROGRAM '%s%s %s %s'", checkPipeExistsCommand, customPipeThroughCommand, sendToDestinationCommand, destinationToWrite)
columnNames := ""
if (connectionPool.Version.IsGPDB() && connectionPool.Version.AtLeast("7")) || connectionPool.Version.IsCBDB() {
// process column names to exclude generated columns from data copy out
columnNames = ConstructTableAttributesList(table.ColumnDefs)
}
workerInfo := ""
if gplog.GetVerbosity() >= gplog.LOGVERBOSE {
workerInfo = fmt.Sprintf("Worker %d: ", connNum)
}
query := fmt.Sprintf("COPY %s%s TO %s WITH CSV DELIMITER '%s' ON SEGMENT IGNORE EXTERNAL PARTITIONS;", table.FQN(), columnNames, copyCommand, tableDelim)
if (connectionPool.Version.IsGPDB() && connectionPool.Version.AtLeast("7")) || connectionPool.Version.IsCBDB() {
utils.LogProgress(`%sExecuting "%s" on coordinator`, workerInfo, query)
} else {
utils.LogProgress(`%sExecuting "%s" on master`, workerInfo, query)
}
result, err := connectionPool.Exec(query, connNum)
if err != nil {
return 0, err
}
numRows, _ := result.RowsAffected()
return numRows, nil
}
func BackupSingleTableData(table Table, rowsCopiedMap map[uint32]int64, counters *BackupProgressCounters, whichConn int) error {
workerInfo := ""
if gplog.GetVerbosity() >= gplog.LOGVERBOSE {
workerInfo = fmt.Sprintf("Worker %d: ", whichConn)
}
logMessage := fmt.Sprintf("%sWriting data for table %s to file", workerInfo, table.FQN())
// Avoid race condition by incrementing counters in call to sprintf
tableCount := fmt.Sprintf(" (table %d of %d)", atomic.AddInt64(&counters.NumRegTables, 1), counters.TotalRegTables)
utils.LogProgress(logMessage + tableCount)
destinationToWrite := ""
if MustGetFlagBool(options.SINGLE_DATA_FILE) {
destinationToWrite = fmt.Sprintf("%s_%d", globalFPInfo.GetSegmentPipePathForCopyCommand(), table.Oid)
} else {
destinationToWrite = globalFPInfo.GetTableBackupFilePathForCopyCommand(table.Oid, utils.GetPipeThroughProgram().Extension, false)
}
rowsCopied, err := CopyTableOut(connectionPool, table, destinationToWrite, whichConn)
if err != nil {
return err
}
rowsCopiedMap[table.Oid] = rowsCopied
counters.ProgressBar.Increment()
return nil
}
/*
* Iterate through tables, backup data from each table in set.
* If supported by the database, a synchronized database snapshot is used to sync
* all parallel workers' view of the database and ensure data consistency across workers.
* In each worker, we try to acquire a ACCESS SHARE lock on each table in NOWAIT mode,
* to avoid potential deadlocks with queued DDL operations that requested ACCESS EXCLUSIVE
* locks on the same tables (for eg concurrent ALTER TABLE operations). If worker is unable to
* acquire a lock, defer table to worker 0, which holds all ACCESS SHARE locks for the backup set.
* If synchronized snapshot is not supported and worker is unable to acquire a lock, the
* worker must be terminated because the session no longer has a valid distributed snapshot
*
* FIXME: Simplify BackupDataForAllTables by having one function for snapshot workflow and
* another without, then extract common portions into their own functions.
*/
func BackupDataForAllTables(tables []Table) []map[uint32]int64 {
counters := BackupProgressCounters{NumRegTables: 0, TotalRegTables: int64(len(tables))}
counters.ProgressBar = utils.NewProgressBar(int(counters.TotalRegTables), "Tables backed up: ", utils.PB_INFO)
counters.ProgressBar.Start()
rowsCopiedMaps := make([]map[uint32]int64, connectionPool.NumConns)
/*
* We break when an interrupt is received and rely on
* TerminateHangingCopySessions to halt any COPY statements
* in progress if they don't finish on their own.
*/
tasks := make(chan Table, len(tables))
var oidMap sync.Map
var isErroredBackup atomic.Bool
var workerPool sync.WaitGroup
// Record and track tables in a hashmap of oids and table states (preloaded with value Unknown).
// The tables are loaded into the tasks channel for the subsequent goroutines to work on.
for _, table := range tables {
oidMap.Store(table.Oid, Unknown)
tasks <- table
}
/*
* Worker 0 is a special database connection that
* 1) Exports the database snapshot if the feature is supported
* 2) Does not have tables pre-assigned to it.
* 3) Processes tables only in the event that the other workers encounter locking issues.
* Worker 0 already has all locks on the tables so it will not run into locking issues.
*/
panicChan := make(chan error)
rowsCopiedMaps[0] = make(map[uint32]int64)
for connNum := 1; connNum < connectionPool.NumConns; connNum++ {
rowsCopiedMaps[connNum] = make(map[uint32]int64)
workerPool.Add(1)
go func(whichConn int) {
defer func() {
if panicErr := recover(); panicErr != nil {
panicChan <- fmt.Errorf("%v", panicErr)
}
}()
defer workerPool.Done()
/* If the --leaf-partition-data flag is not set, the parent and all leaf
* partition data are treated as a single table and will be assigned to a single worker.
* Large partition hierarchies result in a large number of locks being held until the
* transaction commits and the locks are released.
*/
for table := range tasks {
if wasTerminated || isErroredBackup.Load() {
counters.ProgressBar.(*pb.ProgressBar).NotPrint = true
return
}
if backupSnapshot != "" && connectionPool.Tx[whichConn] == nil {
err := SetSynchronizedSnapshot(connectionPool, whichConn, backupSnapshot)
gplog.FatalOnError(err)
}
// If a random external SQL command had queued an AccessExclusiveLock acquisition
// request against this next table, the --job worker thread would deadlock on the
// COPY attempt. To prevent gpbackup from hanging, we attempt to acquire an
// AccessShareLock on the relation with the NOWAIT option before we run COPY. If
// the LOCK TABLE NOWAIT call fails, we catch the error and defer the table to the
// main worker thread, worker 0. Afterwards, if we are in a local transaction
// instead of a distributed snapshot, we break early and terminate the worker since
// its transaction is now in an aborted state. We do not need to do this with the
// main worker thread because it has already acquired AccessShareLocks on all
// tables before the metadata dumping part.
err := LockTableNoWait(table, whichConn)
if err != nil {
if pgErr, ok := err.(*pgconn.PgError); ok && pgErr.Code != PG_LOCK_NOT_AVAILABLE {
isErroredBackup.Store(true)
err = connectionPool.Rollback(whichConn)
if err != nil {
gplog.Warn("Worker %d: %s", whichConn, err)
}
gplog.Fatal(fmt.Errorf("Unexpectedly unable to take lock on table %s, %s", table.FQN(), pgErr.Error()), "")
}
if gplog.GetVerbosity() < gplog.LOGVERBOSE {
// Add a newline to interrupt the progress bar so that
// the following WARN message is nicely outputted.
fmt.Printf("\n")
}
gplog.Warn("Worker %d could not acquire AccessShareLock for table %s.", whichConn, table.FQN())
logTableLocks(table, whichConn)
// rollback transaction and defer table
err = connectionPool.Rollback(whichConn)
if err != nil {
gplog.Warn("Worker %d: %s", whichConn, err)
}
oidMap.Store(table.Oid, Deferred)
// If have backup snapshot, continue to next table, else terminate worker.
if backupSnapshot != "" {
continue
} else {
gplog.Warn("Terminating worker %d and deferring table %s to main worker thread.", whichConn, table.FQN())
break
}
}
err = BackupSingleTableData(table, rowsCopiedMaps[whichConn], &counters, whichConn)
if err != nil {
// if copy isn't working, skip remaining backups, and let downstream panic
// handling deal with it
counters.ProgressBar.(*pb.ProgressBar).NotPrint = true
isErroredBackup.Store(true)
gplog.Fatal(err, "")
} else {
oidMap.Store(table.Oid, Complete)
}
if backupSnapshot != "" {
err = connectionPool.Commit(whichConn)
if err != nil {
gplog.Warn("Worker %d: %s", whichConn, err)
}
}
}
}(connNum)
}
// Special goroutine to handle deferred tables
// Handle all tables deferred by the deadlock detection. This can only be
// done with the main worker thread, worker 0, because it has
// AccessShareLocks on all the tables already.
deferredWorkerDone := make(chan bool)
go func() {
defer func() {
if panicErr := recover(); panicErr != nil {
panicChan <- fmt.Errorf("%v", panicErr)
}
}()
for _, table := range tables {
for {
if wasTerminated || isErroredBackup.Load() {
return
}
state, _ := oidMap.Load(table.Oid)
if state.(int) == Unknown {
time.Sleep(time.Millisecond * 50)
} else if state.(int) == Deferred {
err := BackupSingleTableData(table, rowsCopiedMaps[0], &counters, 0)
if err != nil {
isErroredBackup.Store(true)
gplog.Fatal(err, "")
}
oidMap.Store(table.Oid, Complete)
break
} else if state.(int) == Complete {
break
} else {
gplog.Fatal(errors.New("Encountered unknown table state"), "")
}
}
}
deferredWorkerDone <- true
}()
close(tasks)
workerPool.Wait()
// Allow panics to crash from the main process, invoking DoCleanup
select {
case err := <-panicChan:
gplog.Fatal(err, "")
default:
// no panic, nothing to do
}
// If not using synchronized snapshots,
// check if all workers were terminated due to lock issues.
if backupSnapshot == "" {
allWorkersTerminatedLogged := false
for _, table := range tables {
if wasTerminated || isErroredBackup.Load() {
counters.ProgressBar.(*pb.ProgressBar).NotPrint = true
break
}
state, _ := oidMap.Load(table.Oid)
if state == Unknown {
if !allWorkersTerminatedLogged {
gplog.Warn("All workers terminated due to lock issues. Falling back to single main worker.")
allWorkersTerminatedLogged = true
}
oidMap.Store(table.Oid, Deferred)
}
}
}
// Main goroutine waits for deferred worker 0 by waiting on this channel
<-deferredWorkerDone
agentErr := utils.CheckAgentErrorsOnSegments(globalCluster, globalFPInfo)
if agentErr != nil {
gplog.Fatal(agentErr, "")
}
counters.ProgressBar.Finish()
return rowsCopiedMaps
}
func printDataBackupWarnings(numExtTables int64) {
if numExtTables > 0 {
gplog.Info("Skipped data backup of %d external/foreign table(s).", numExtTables)
gplog.Info("See %s for a complete list of skipped tables.", gplog.GetLogFilePath())
}
}
// Remove external/foreign tables from the data backup set
func GetBackupDataSet(tables []Table) ([]Table, int64) {
var backupDataSet []Table
var numExtOrForeignTables int64
if !backupReport.MetadataOnly {
for _, table := range tables {
if !table.SkipDataBackup() {
backupDataSet = append(backupDataSet, table)
} else {
gplog.Verbose("Skipping data backup of table %s because it is either an external or foreign table.", table.FQN())
numExtOrForeignTables++
}
}
}
return backupDataSet, numExtOrForeignTables
}
// Acquire AccessShareLock on a table with NOWAIT option. If we are unable to acquire
// the lock, the call will fail instead of block. Return the failure for handling.
func LockTableNoWait(dataTable Table, connNum int) error {
var lockMode string
// TODO: This is a temporary workaround. Cloudberry should support the
// COORDINATOR ONLY syntax to align with GPDB 7+ for performance optimization
// during the table locking phase. Revert this to the combined GPDB 7+ condition
// once Cloudberry adds this feature.
if connectionPool.Version.IsCBDB() {
lockMode = `IN ACCESS SHARE MODE NOWAIT`
} else if connectionPool.Version.IsGPDB() && connectionPool.Version.AtLeast("7") {
lockMode = `IN ACCESS SHARE MODE NOWAIT COORDINATOR ONLY`
} else if connectionPool.Version.AtLeast("6.21.0") {
lockMode = `IN ACCESS SHARE MODE NOWAIT MASTER ONLY`
} else {
lockMode = `IN ACCESS SHARE MODE NOWAIT`
}
query := fmt.Sprintf("LOCK TABLE %s %s;", dataTable.FQN(), lockMode)
gplog.Verbose("Worker %d: %s", connNum, query)
_, err := connectionPool.Exec(query, connNum)
if err != nil {
return err
}
return nil
}