Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 354c083e38 | |||
| a211befea8 | |||
| d6fbc77c21 | |||
| e449e2f448 | |||
| dceab64b67 | |||
| a101fb81ab | |||
| 555177f5a7 | |||
| 0d416ecb55 |
38
CHANGELOG.md
38
CHANGELOG.md
@ -5,6 +5,44 @@ All notable changes to dbbackup will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [5.8.26] - 2026-02-05
|
||||
|
||||
### Improved
|
||||
- **Size-Weighted ETA for Cluster Backups**: ETAs now based on database sizes, not count
|
||||
- Query database sizes upfront before starting cluster backup
|
||||
- Progress bar shows bytes completed vs total bytes (e.g., `0B/500.0GB`)
|
||||
- ETA calculated using size-weighted formula: `elapsed * (remaining_bytes / done_bytes)`
|
||||
- Much more accurate for clusters with mixed database sizes (e.g., 8MB postgres + 500GB fakedb)
|
||||
- Falls back to count-based ETA with `~` prefix if sizes unavailable
|
||||
|
||||
## [5.8.25] - 2026-02-05
|
||||
|
||||
### Fixed
|
||||
- **Backup Database Elapsed Time Display**: Fixed bug where per-database elapsed time and ETA showed `0.0s` during cluster backups
|
||||
- Root cause: elapsed time was only updated when `hasUpdate` flag was true, not on every tick
|
||||
- Fix: Store `phase2StartTime` in model and recalculate elapsed time on every UI tick
|
||||
- Now shows accurate real-time elapsed and ETA for database backup phase
|
||||
|
||||
## [5.8.24] - 2026-02-05
|
||||
|
||||
### Added
|
||||
- **Skip Preflight Checks Option**: New TUI setting to disable pre-restore safety checks
|
||||
- Accessible via Settings menu → "Skip Preflight Checks"
|
||||
- Shows warning when enabled: "⚠️ SKIPPED (dangerous)"
|
||||
- Displays prominent warning banner on restore preview screen
|
||||
- Useful for enterprise scenarios where checks are too slow on large databases
|
||||
- Config field: `SkipPreflightChecks` (default: false)
|
||||
- Setting is persisted to config file with warning comment
|
||||
- Added nil-pointer safety checks throughout
|
||||
|
||||
## [5.8.23] - 2026-02-05
|
||||
|
||||
### Added
|
||||
- **Cancellation Tests**: Added Go unit tests for context cancellation verification
|
||||
- `TestParseStatementsContextCancellation` - verifies statement parsing can be cancelled
|
||||
- `TestParseStatementsWithCopyDataCancellation` - verifies COPY data parsing can be cancelled
|
||||
- Tests confirm cancellation responds within 10ms on large (1M+ line) files
|
||||
|
||||
## [5.8.15] - 2026-02-05
|
||||
|
||||
### Fixed
|
||||
|
||||
@ -39,7 +39,8 @@ import (
|
||||
type ProgressCallback func(current, total int64, description string)
|
||||
|
||||
// DatabaseProgressCallback is called with database count progress during cluster backup
|
||||
type DatabaseProgressCallback func(done, total int, dbName string)
|
||||
// bytesDone and bytesTotal enable size-weighted ETA calculations
|
||||
type DatabaseProgressCallback func(done, total int, dbName string, bytesDone, bytesTotal int64)
|
||||
|
||||
// Engine handles backup operations
|
||||
type Engine struct {
|
||||
@ -112,7 +113,8 @@ func (e *Engine) SetDatabaseProgressCallback(cb DatabaseProgressCallback) {
|
||||
}
|
||||
|
||||
// reportDatabaseProgress reports database count progress to the callback if set
|
||||
func (e *Engine) reportDatabaseProgress(done, total int, dbName string) {
|
||||
// bytesDone/bytesTotal enable size-weighted ETA calculations
|
||||
func (e *Engine) reportDatabaseProgress(done, total int, dbName string, bytesDone, bytesTotal int64) {
|
||||
// CRITICAL: Add panic recovery to prevent crashes during TUI shutdown
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
@ -121,7 +123,7 @@ func (e *Engine) reportDatabaseProgress(done, total int, dbName string) {
|
||||
}()
|
||||
|
||||
if e.dbProgressCallback != nil {
|
||||
e.dbProgressCallback(done, total, dbName)
|
||||
e.dbProgressCallback(done, total, dbName, bytesDone, bytesTotal)
|
||||
}
|
||||
}
|
||||
|
||||
@ -461,6 +463,18 @@ func (e *Engine) BackupCluster(ctx context.Context) error {
|
||||
return fmt.Errorf("failed to list databases: %w", err)
|
||||
}
|
||||
|
||||
// Query database sizes upfront for accurate ETA calculation
|
||||
e.printf(" Querying database sizes for ETA estimation...\n")
|
||||
dbSizes := make(map[string]int64)
|
||||
var totalBytes int64
|
||||
for _, dbName := range databases {
|
||||
if size, err := e.db.GetDatabaseSize(ctx, dbName); err == nil {
|
||||
dbSizes[dbName] = size
|
||||
totalBytes += size
|
||||
}
|
||||
}
|
||||
var completedBytes int64 // Track bytes completed (atomic access)
|
||||
|
||||
// Create ETA estimator for database backups
|
||||
estimator := progress.NewETAEstimator("Backing up cluster", len(databases))
|
||||
quietProgress.SetEstimator(estimator)
|
||||
@ -520,25 +534,26 @@ func (e *Engine) BackupCluster(ctx context.Context) error {
|
||||
default:
|
||||
}
|
||||
|
||||
// Get this database's size for progress tracking
|
||||
thisDbSize := dbSizes[name]
|
||||
|
||||
// Update estimator progress (thread-safe)
|
||||
mu.Lock()
|
||||
estimator.UpdateProgress(idx)
|
||||
e.printf(" [%d/%d] Backing up database: %s\n", idx+1, len(databases), name)
|
||||
quietProgress.Update(fmt.Sprintf("Backing up database %d/%d: %s", idx+1, len(databases), name))
|
||||
// Report database progress to TUI callback
|
||||
e.reportDatabaseProgress(idx+1, len(databases), name)
|
||||
// Report database progress to TUI callback with size-weighted info
|
||||
e.reportDatabaseProgress(idx+1, len(databases), name, completedBytes, totalBytes)
|
||||
mu.Unlock()
|
||||
|
||||
// Check database size and warn if very large
|
||||
if size, err := e.db.GetDatabaseSize(ctx, name); err == nil {
|
||||
sizeStr := formatBytes(size)
|
||||
mu.Lock()
|
||||
e.printf(" Database size: %s\n", sizeStr)
|
||||
if size > 10*1024*1024*1024 { // > 10GB
|
||||
e.printf(" [WARN] Large database detected - this may take a while\n")
|
||||
}
|
||||
mu.Unlock()
|
||||
// Use cached size, warn if very large
|
||||
sizeStr := formatBytes(thisDbSize)
|
||||
mu.Lock()
|
||||
e.printf(" Database size: %s\n", sizeStr)
|
||||
if thisDbSize > 10*1024*1024*1024 { // > 10GB
|
||||
e.printf(" [WARN] Large database detected - this may take a while\n")
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
dumpFile := filepath.Join(tempDir, "dumps", name+".dump")
|
||||
|
||||
@ -635,6 +650,8 @@ func (e *Engine) BackupCluster(ctx context.Context) error {
|
||||
}
|
||||
} else {
|
||||
// Native backup succeeded!
|
||||
// Update completed bytes for size-weighted ETA
|
||||
atomic.AddInt64(&completedBytes, thisDbSize)
|
||||
if info, statErr := os.Stat(sqlFile); statErr == nil {
|
||||
mu.Lock()
|
||||
e.printf(" [OK] Completed %s (%s) [native]\n", name, formatBytes(info.Size()))
|
||||
@ -687,6 +704,8 @@ func (e *Engine) BackupCluster(ctx context.Context) error {
|
||||
mu.Unlock()
|
||||
atomic.AddInt32(&failCount, 1)
|
||||
} else {
|
||||
// Update completed bytes for size-weighted ETA
|
||||
atomic.AddInt64(&completedBytes, thisDbSize)
|
||||
compressedCandidate := strings.TrimSuffix(dumpFile, ".dump") + ".sql.gz"
|
||||
mu.Lock()
|
||||
if info, err := os.Stat(compressedCandidate); err == nil {
|
||||
|
||||
@ -131,6 +131,9 @@ type Config struct {
|
||||
TUIVerbose bool // Verbose TUI logging
|
||||
TUILogFile string // TUI event log file path
|
||||
|
||||
// Safety options
|
||||
SkipPreflightChecks bool // Skip pre-restore safety checks (archive integrity, disk space, etc.)
|
||||
|
||||
// Cloud storage options (v2.0)
|
||||
CloudEnabled bool // Enable cloud storage integration
|
||||
CloudProvider string // "s3", "minio", "b2", "azure", "gcs"
|
||||
|
||||
@ -35,6 +35,9 @@ type LocalConfig struct {
|
||||
ResourceProfile string
|
||||
LargeDBMode bool // Enable large database mode (reduces parallelism, increases locks)
|
||||
|
||||
// Safety settings
|
||||
SkipPreflightChecks bool // Skip pre-restore safety checks (dangerous)
|
||||
|
||||
// Security settings
|
||||
RetentionDays int
|
||||
MinBackups int
|
||||
@ -196,6 +199,11 @@ func LoadLocalConfigFromPath(configPath string) (*LocalConfig, error) {
|
||||
cfg.MaxRetries = mr
|
||||
}
|
||||
}
|
||||
case "safety":
|
||||
switch key {
|
||||
case "skip_preflight_checks":
|
||||
cfg.SkipPreflightChecks = value == "true" || value == "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -252,6 +260,14 @@ func SaveLocalConfigToPath(cfg *LocalConfig, configPath string) error {
|
||||
sb.WriteString(fmt.Sprintf("retention_days = %d\n", cfg.RetentionDays))
|
||||
sb.WriteString(fmt.Sprintf("min_backups = %d\n", cfg.MinBackups))
|
||||
sb.WriteString(fmt.Sprintf("max_retries = %d\n", cfg.MaxRetries))
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Safety section - only write if non-default (dangerous setting)
|
||||
if cfg.SkipPreflightChecks {
|
||||
sb.WriteString("[safety]\n")
|
||||
sb.WriteString("# WARNING: Skipping preflight checks can lead to failed restores!\n")
|
||||
sb.WriteString(fmt.Sprintf("skip_preflight_checks = %t\n", cfg.SkipPreflightChecks))
|
||||
}
|
||||
|
||||
// Use 0644 permissions for readability
|
||||
if err := os.WriteFile(configPath, []byte(sb.String()), 0644); err != nil {
|
||||
@ -328,29 +344,36 @@ func ApplyLocalConfig(cfg *Config, local *LocalConfig) {
|
||||
if local.MaxRetries != 0 {
|
||||
cfg.MaxRetries = local.MaxRetries
|
||||
}
|
||||
|
||||
// Safety settings - apply even if false (explicit setting)
|
||||
// This is a dangerous setting, so we always respect what's in the config
|
||||
if local.SkipPreflightChecks {
|
||||
cfg.SkipPreflightChecks = true
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigFromConfig creates a LocalConfig from a Config
|
||||
func ConfigFromConfig(cfg *Config) *LocalConfig {
|
||||
return &LocalConfig{
|
||||
DBType: cfg.DatabaseType,
|
||||
Host: cfg.Host,
|
||||
Port: cfg.Port,
|
||||
User: cfg.User,
|
||||
Database: cfg.Database,
|
||||
SSLMode: cfg.SSLMode,
|
||||
BackupDir: cfg.BackupDir,
|
||||
WorkDir: cfg.WorkDir,
|
||||
Compression: cfg.CompressionLevel,
|
||||
Jobs: cfg.Jobs,
|
||||
DumpJobs: cfg.DumpJobs,
|
||||
CPUWorkload: cfg.CPUWorkloadType,
|
||||
MaxCores: cfg.MaxCores,
|
||||
ClusterTimeout: cfg.ClusterTimeoutMinutes,
|
||||
ResourceProfile: cfg.ResourceProfile,
|
||||
LargeDBMode: cfg.LargeDBMode,
|
||||
RetentionDays: cfg.RetentionDays,
|
||||
MinBackups: cfg.MinBackups,
|
||||
MaxRetries: cfg.MaxRetries,
|
||||
DBType: cfg.DatabaseType,
|
||||
Host: cfg.Host,
|
||||
Port: cfg.Port,
|
||||
User: cfg.User,
|
||||
Database: cfg.Database,
|
||||
SSLMode: cfg.SSLMode,
|
||||
BackupDir: cfg.BackupDir,
|
||||
WorkDir: cfg.WorkDir,
|
||||
Compression: cfg.CompressionLevel,
|
||||
Jobs: cfg.Jobs,
|
||||
DumpJobs: cfg.DumpJobs,
|
||||
CPUWorkload: cfg.CPUWorkloadType,
|
||||
MaxCores: cfg.MaxCores,
|
||||
ClusterTimeout: cfg.ClusterTimeoutMinutes,
|
||||
ResourceProfile: cfg.ResourceProfile,
|
||||
LargeDBMode: cfg.LargeDBMode,
|
||||
SkipPreflightChecks: cfg.SkipPreflightChecks,
|
||||
RetentionDays: cfg.RetentionDays,
|
||||
MinBackups: cfg.MinBackups,
|
||||
MaxRetries: cfg.MaxRetries,
|
||||
}
|
||||
}
|
||||
|
||||
@ -440,6 +440,15 @@ func (e *ParallelRestoreEngine) parseStatementsWithContext(ctx context.Context,
|
||||
currentCopyStmt.CopyData.WriteString(line)
|
||||
currentCopyStmt.CopyData.WriteByte('\n')
|
||||
}
|
||||
// Check for context cancellation during COPY data parsing (large tables)
|
||||
// Check every 10000 lines to avoid overhead
|
||||
if lineCount%10000 == 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return statements, ctx.Err()
|
||||
default:
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
121
internal/engine/native/parallel_restore_cancel_test.go
Normal file
121
internal/engine/native/parallel_restore_cancel_test.go
Normal file
@ -0,0 +1,121 @@
|
||||
package native
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"dbbackup/internal/logger"
|
||||
)
|
||||
|
||||
// mockLogger for tests
|
||||
type mockLogger struct{}
|
||||
|
||||
func (m *mockLogger) Debug(msg string, args ...any) {}
|
||||
func (m *mockLogger) Info(msg string, keysAndValues ...interface{}) {}
|
||||
func (m *mockLogger) Warn(msg string, keysAndValues ...interface{}) {}
|
||||
func (m *mockLogger) Error(msg string, keysAndValues ...interface{}) {}
|
||||
func (m *mockLogger) Time(msg string, args ...any) {}
|
||||
func (m *mockLogger) WithField(key string, value interface{}) logger.Logger { return m }
|
||||
func (m *mockLogger) WithFields(fields map[string]interface{}) logger.Logger { return m }
|
||||
func (m *mockLogger) StartOperation(name string) logger.OperationLogger { return &mockOpLogger{} }
|
||||
|
||||
type mockOpLogger struct{}
|
||||
|
||||
func (m *mockOpLogger) Update(msg string, args ...any) {}
|
||||
func (m *mockOpLogger) Complete(msg string, args ...any) {}
|
||||
func (m *mockOpLogger) Fail(msg string, args ...any) {}
|
||||
|
||||
// createTestEngine creates an engine without database connection for parsing tests
|
||||
func createTestEngine() *ParallelRestoreEngine {
|
||||
return &ParallelRestoreEngine{
|
||||
config: &PostgreSQLNativeConfig{},
|
||||
log: &mockLogger{},
|
||||
parallelWorkers: 4,
|
||||
closeCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseStatementsContextCancellation verifies that parsing can be cancelled
|
||||
// This was a critical fix - parsing large SQL files would hang on Ctrl+C
|
||||
func TestParseStatementsContextCancellation(t *testing.T) {
|
||||
engine := createTestEngine()
|
||||
|
||||
// Create a large SQL content that would take a while to parse
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("-- Test dump\n")
|
||||
buf.WriteString("SET statement_timeout = 0;\n")
|
||||
|
||||
// Add 1,000,000 lines to simulate a large dump
|
||||
for i := 0; i < 1000000; i++ {
|
||||
buf.WriteString("SELECT ")
|
||||
buf.WriteString(string(rune('0' + (i % 10))))
|
||||
buf.WriteString("; -- line padding to make file larger\n")
|
||||
}
|
||||
|
||||
// Create a context that cancels after 10ms
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
reader := strings.NewReader(buf.String())
|
||||
|
||||
start := time.Now()
|
||||
_, err := engine.parseStatementsWithContext(ctx, reader)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// Should return quickly with context error, not hang
|
||||
if elapsed > 500*time.Millisecond {
|
||||
t.Errorf("Parsing took too long after cancellation: %v (expected < 500ms)", elapsed)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
t.Log("Parsing completed before timeout (system is very fast)")
|
||||
} else if err == context.DeadlineExceeded || err == context.Canceled {
|
||||
t.Logf("✓ Context cancellation worked correctly (elapsed: %v)", elapsed)
|
||||
} else {
|
||||
t.Logf("Got error: %v (elapsed: %v)", err, elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseStatementsWithCopyDataCancellation tests cancellation during COPY data parsing
|
||||
// This is where large restores spend most of their time
|
||||
func TestParseStatementsWithCopyDataCancellation(t *testing.T) {
|
||||
engine := createTestEngine()
|
||||
|
||||
// Create SQL with COPY statement and lots of data
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("CREATE TABLE test (id int, data text);\n")
|
||||
buf.WriteString("COPY test (id, data) FROM stdin;\n")
|
||||
|
||||
// Add 500,000 rows of COPY data
|
||||
for i := 0; i < 500000; i++ {
|
||||
buf.WriteString("1\tsome test data for row number padding to make larger\n")
|
||||
}
|
||||
buf.WriteString("\\.\n")
|
||||
buf.WriteString("SELECT 1;\n")
|
||||
|
||||
// Create a context that cancels after 10ms
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
reader := strings.NewReader(buf.String())
|
||||
|
||||
start := time.Now()
|
||||
_, err := engine.parseStatementsWithContext(ctx, reader)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// Should return quickly with context error, not hang
|
||||
if elapsed > 500*time.Millisecond {
|
||||
t.Errorf("COPY parsing took too long after cancellation: %v (expected < 500ms)", elapsed)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
t.Log("Parsing completed before timeout (system is very fast)")
|
||||
} else if err == context.DeadlineExceeded || err == context.Canceled {
|
||||
t.Logf("✓ Context cancellation during COPY worked correctly (elapsed: %v)", elapsed)
|
||||
} else {
|
||||
t.Logf("Got error: %v (elapsed: %v)", err, elapsed)
|
||||
}
|
||||
}
|
||||
@ -2525,7 +2525,14 @@ func (e *Engine) restoreGlobals(ctx context.Context, globalsFile string) error {
|
||||
cmdErr = ctx.Err()
|
||||
}
|
||||
|
||||
<-stderrDone
|
||||
// Wait for stderr reader with timeout to prevent indefinite hang
|
||||
// if the process doesn't fully terminate
|
||||
select {
|
||||
case <-stderrDone:
|
||||
// Normal completion
|
||||
case <-time.After(5 * time.Second):
|
||||
e.log.Warn("Stderr reader timeout - forcefully continuing")
|
||||
}
|
||||
|
||||
// Only fail on actual command errors or FATAL PostgreSQL errors
|
||||
// Regular ERROR messages (like "role already exists") are expected
|
||||
|
||||
@ -168,6 +168,10 @@ func (m ArchiveBrowserModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.InterruptMsg:
|
||||
// Handle Ctrl+C signal (SIGINT) - Bubbletea v1.3+ sends this instead of KeyMsg for ctrl+c
|
||||
return m.parent, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "q", "esc":
|
||||
|
||||
@ -54,13 +54,16 @@ type BackupExecutionModel struct {
|
||||
spinnerFrame int
|
||||
|
||||
// Database count progress (for cluster backup)
|
||||
dbTotal int
|
||||
dbDone int
|
||||
dbName string // Current database being backed up
|
||||
overallPhase int // 1=globals, 2=databases, 3=compressing
|
||||
phaseDesc string // Description of current phase
|
||||
dbPhaseElapsed time.Duration // Elapsed time since database backup phase started
|
||||
dbAvgPerDB time.Duration // Average time per database backup
|
||||
dbTotal int
|
||||
dbDone int
|
||||
dbName string // Current database being backed up
|
||||
overallPhase int // 1=globals, 2=databases, 3=compressing
|
||||
phaseDesc string // Description of current phase
|
||||
dbPhaseElapsed time.Duration // Elapsed time since database backup phase started
|
||||
dbAvgPerDB time.Duration // Average time per database backup
|
||||
phase2StartTime time.Time // When phase 2 started (for realtime elapsed calculation)
|
||||
bytesDone int64 // Size-weighted progress: bytes completed
|
||||
bytesTotal int64 // Size-weighted progress: total bytes
|
||||
}
|
||||
|
||||
// sharedBackupProgressState holds progress state that can be safely accessed from callbacks
|
||||
@ -75,6 +78,8 @@ type sharedBackupProgressState struct {
|
||||
phase2StartTime time.Time // When phase 2 started (for realtime ETA calculation)
|
||||
dbPhaseElapsed time.Duration // Elapsed time since database backup phase started
|
||||
dbAvgPerDB time.Duration // Average time per database backup
|
||||
bytesDone int64 // Size-weighted progress: bytes completed
|
||||
bytesTotal int64 // Size-weighted progress: total bytes
|
||||
}
|
||||
|
||||
// Package-level shared progress state for backup operations
|
||||
@ -95,7 +100,7 @@ func clearCurrentBackupProgress() {
|
||||
currentBackupProgressState = nil
|
||||
}
|
||||
|
||||
func getCurrentBackupProgress() (dbTotal, dbDone int, dbName string, overallPhase int, phaseDesc string, hasUpdate bool, dbPhaseElapsed, dbAvgPerDB time.Duration, phase2StartTime time.Time) {
|
||||
func getCurrentBackupProgress() (dbTotal, dbDone int, dbName string, overallPhase int, phaseDesc string, hasUpdate bool, dbPhaseElapsed, dbAvgPerDB time.Duration, phase2StartTime time.Time, bytesDone, bytesTotal int64) {
|
||||
// CRITICAL: Add panic recovery
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
@ -108,12 +113,12 @@ func getCurrentBackupProgress() (dbTotal, dbDone int, dbName string, overallPhas
|
||||
defer currentBackupProgressMu.Unlock()
|
||||
|
||||
if currentBackupProgressState == nil {
|
||||
return 0, 0, "", 0, "", false, 0, 0, time.Time{}
|
||||
return 0, 0, "", 0, "", false, 0, 0, time.Time{}, 0, 0
|
||||
}
|
||||
|
||||
// Double-check state isn't nil after lock
|
||||
if currentBackupProgressState == nil {
|
||||
return 0, 0, "", 0, "", false, 0, 0, time.Time{}
|
||||
return 0, 0, "", 0, "", false, 0, 0, time.Time{}, 0, 0
|
||||
}
|
||||
|
||||
currentBackupProgressState.mu.Lock()
|
||||
@ -134,7 +139,8 @@ func getCurrentBackupProgress() (dbTotal, dbDone int, dbName string, overallPhas
|
||||
currentBackupProgressState.dbName, currentBackupProgressState.overallPhase,
|
||||
currentBackupProgressState.phaseDesc, hasUpdate,
|
||||
dbPhaseElapsed, currentBackupProgressState.dbAvgPerDB,
|
||||
currentBackupProgressState.phase2StartTime
|
||||
currentBackupProgressState.phase2StartTime,
|
||||
currentBackupProgressState.bytesDone, currentBackupProgressState.bytesTotal
|
||||
}
|
||||
|
||||
func NewBackupExecution(cfg *config.Config, log logger.Logger, parent tea.Model, ctx context.Context, backupType, dbName string, ratio int) BackupExecutionModel {
|
||||
@ -238,8 +244,8 @@ func executeBackupWithTUIProgress(parentCtx context.Context, cfg *config.Config,
|
||||
// Pass nil as indicator - TUI itself handles all display, no stdout printing
|
||||
engine := backup.NewSilent(cfg, log, dbClient, nil)
|
||||
|
||||
// Set database progress callback for cluster backups
|
||||
engine.SetDatabaseProgressCallback(func(done, total int, currentDB string) {
|
||||
// Set database progress callback for cluster backups (with size-weighted progress)
|
||||
engine.SetDatabaseProgressCallback(func(done, total int, currentDB string, bytesDone, bytesTotal int64) {
|
||||
// CRITICAL: Panic recovery to prevent nil pointer crashes
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
@ -256,6 +262,8 @@ func executeBackupWithTUIProgress(parentCtx context.Context, cfg *config.Config,
|
||||
progressState.dbDone = done
|
||||
progressState.dbTotal = total
|
||||
progressState.dbName = currentDB
|
||||
progressState.bytesDone = bytesDone
|
||||
progressState.bytesTotal = bytesTotal
|
||||
progressState.overallPhase = backupPhaseDatabases
|
||||
progressState.phaseDesc = fmt.Sprintf("Phase 2/3: Backing up Databases (%d/%d)", done, total)
|
||||
progressState.hasUpdate = true
|
||||
@ -324,7 +332,7 @@ func (m BackupExecutionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var overallPhase int
|
||||
var phaseDesc string
|
||||
var hasUpdate bool
|
||||
var dbPhaseElapsed, dbAvgPerDB time.Duration
|
||||
var dbAvgPerDB time.Duration
|
||||
|
||||
func() {
|
||||
defer func() {
|
||||
@ -332,7 +340,17 @@ func (m BackupExecutionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.logger.Warn("Backup progress polling panic recovered", "panic", r)
|
||||
}
|
||||
}()
|
||||
dbTotal, dbDone, dbName, overallPhase, phaseDesc, hasUpdate, dbPhaseElapsed, dbAvgPerDB, _ = getCurrentBackupProgress()
|
||||
var phase2Start time.Time
|
||||
var phaseElapsed time.Duration
|
||||
var bytesDone, bytesTotal int64
|
||||
dbTotal, dbDone, dbName, overallPhase, phaseDesc, hasUpdate, phaseElapsed, dbAvgPerDB, phase2Start, bytesDone, bytesTotal = getCurrentBackupProgress()
|
||||
_ = phaseElapsed // We recalculate this below from phase2StartTime
|
||||
if !phase2Start.IsZero() && m.phase2StartTime.IsZero() {
|
||||
m.phase2StartTime = phase2Start
|
||||
}
|
||||
// Always update size info for accurate ETA
|
||||
m.bytesDone = bytesDone
|
||||
m.bytesTotal = bytesTotal
|
||||
}()
|
||||
|
||||
if hasUpdate {
|
||||
@ -341,10 +359,14 @@ func (m BackupExecutionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.dbName = dbName
|
||||
m.overallPhase = overallPhase
|
||||
m.phaseDesc = phaseDesc
|
||||
m.dbPhaseElapsed = dbPhaseElapsed
|
||||
m.dbAvgPerDB = dbAvgPerDB
|
||||
}
|
||||
|
||||
// Always recalculate elapsed time from phase2StartTime for accurate real-time display
|
||||
if !m.phase2StartTime.IsZero() {
|
||||
m.dbPhaseElapsed = time.Since(m.phase2StartTime)
|
||||
}
|
||||
|
||||
// Update status based on progress and elapsed time
|
||||
elapsedSec := int(time.Since(m.startTime).Seconds())
|
||||
|
||||
@ -440,14 +462,19 @@ func (m BackupExecutionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// renderBackupDatabaseProgressBarWithTiming renders database backup progress with ETA
|
||||
func renderBackupDatabaseProgressBarWithTiming(done, total int, dbPhaseElapsed, dbAvgPerDB time.Duration) string {
|
||||
// renderBackupDatabaseProgressBarWithTiming renders database backup progress with size-weighted ETA
|
||||
func renderBackupDatabaseProgressBarWithTiming(done, total int, dbPhaseElapsed time.Duration, bytesDone, bytesTotal int64) string {
|
||||
if total == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Calculate progress percentage
|
||||
percent := float64(done) / float64(total)
|
||||
// Use size-weighted progress if available, otherwise fall back to count-based
|
||||
var percent float64
|
||||
if bytesTotal > 0 {
|
||||
percent = float64(bytesDone) / float64(bytesTotal)
|
||||
} else {
|
||||
percent = float64(done) / float64(total)
|
||||
}
|
||||
if percent > 1.0 {
|
||||
percent = 1.0
|
||||
}
|
||||
@ -460,19 +487,31 @@ func renderBackupDatabaseProgressBarWithTiming(done, total int, dbPhaseElapsed,
|
||||
}
|
||||
bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)
|
||||
|
||||
// Calculate ETA similar to restore
|
||||
// Calculate size-weighted ETA (much more accurate for mixed database sizes)
|
||||
var etaStr string
|
||||
if done > 0 && done < total {
|
||||
if bytesDone > 0 && bytesDone < bytesTotal && bytesTotal > 0 {
|
||||
// Size-weighted: ETA = elapsed * (remaining_bytes / done_bytes)
|
||||
remainingBytes := bytesTotal - bytesDone
|
||||
eta := time.Duration(float64(dbPhaseElapsed) * float64(remainingBytes) / float64(bytesDone))
|
||||
etaStr = fmt.Sprintf(" | ETA: %s", formatDuration(eta))
|
||||
} else if done > 0 && done < total && bytesTotal == 0 {
|
||||
// Fallback to count-based if no size info
|
||||
avgPerDB := dbPhaseElapsed / time.Duration(done)
|
||||
remaining := total - done
|
||||
eta := avgPerDB * time.Duration(remaining)
|
||||
etaStr = fmt.Sprintf(" | ETA: %s", formatDuration(eta))
|
||||
etaStr = fmt.Sprintf(" | ETA: ~%s", formatDuration(eta))
|
||||
} else if done == total {
|
||||
etaStr = " | Complete"
|
||||
}
|
||||
|
||||
return fmt.Sprintf(" Databases: [%s] %d/%d | Elapsed: %s%s\n",
|
||||
bar, done, total, formatDuration(dbPhaseElapsed), etaStr)
|
||||
// Show size progress if available
|
||||
var sizeInfo string
|
||||
if bytesTotal > 0 {
|
||||
sizeInfo = fmt.Sprintf(" (%s/%s)", FormatBytes(bytesDone), FormatBytes(bytesTotal))
|
||||
}
|
||||
|
||||
return fmt.Sprintf(" Databases: [%s] %d/%d%s | Elapsed: %s%s\n",
|
||||
bar, done, total, sizeInfo, formatDuration(dbPhaseElapsed), etaStr)
|
||||
}
|
||||
|
||||
func (m BackupExecutionModel) View() string {
|
||||
@ -561,8 +600,8 @@ func (m BackupExecutionModel) View() string {
|
||||
}
|
||||
s.WriteString("\n")
|
||||
|
||||
// Database progress bar with timing
|
||||
s.WriteString(renderBackupDatabaseProgressBarWithTiming(m.dbDone, m.dbTotal, m.dbPhaseElapsed, m.dbAvgPerDB))
|
||||
// Database progress bar with size-weighted timing
|
||||
s.WriteString(renderBackupDatabaseProgressBarWithTiming(m.dbDone, m.dbTotal, m.dbPhaseElapsed, m.bytesDone, m.bytesTotal))
|
||||
s.WriteString("\n")
|
||||
} else {
|
||||
// Intermediate phase (globals)
|
||||
|
||||
@ -97,13 +97,17 @@ func (m ClusterDatabaseSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.InterruptMsg:
|
||||
// Handle Ctrl+C signal (SIGINT) - Bubbletea v1.3+ sends this instead of KeyMsg for ctrl+c
|
||||
return m.parent, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
if m.loading {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch msg.String() {
|
||||
case "q", "esc":
|
||||
case "ctrl+c", "q", "esc":
|
||||
// Return to parent
|
||||
return m.parent, nil
|
||||
|
||||
|
||||
@ -70,9 +70,18 @@ func (m ConfirmationModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.onConfirm != nil {
|
||||
return m.onConfirm()
|
||||
}
|
||||
executor := NewBackupExecution(m.config, m.logger, m.parent, m.ctx, "cluster", "", 0)
|
||||
// Default fallback (should not be reached if onConfirm is always provided)
|
||||
ctx := m.ctx
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
executor := NewBackupExecution(m.config, m.logger, m.parent, ctx, "cluster", "", 0)
|
||||
return executor, executor.Init()
|
||||
|
||||
case tea.InterruptMsg:
|
||||
// Handle Ctrl+C signal (SIGINT) - Bubbletea v1.3+ sends this instead of KeyMsg for ctrl+c
|
||||
return m.parent, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
// Auto-forward ESC/quit in auto-confirm mode
|
||||
if m.config.TUIAutoConfirm {
|
||||
@ -98,8 +107,12 @@ func (m ConfirmationModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.onConfirm != nil {
|
||||
return m.onConfirm()
|
||||
}
|
||||
// Default: execute cluster backup for backward compatibility
|
||||
executor := NewBackupExecution(m.config, m.logger, m.parent, m.ctx, "cluster", "", 0)
|
||||
// Default fallback (should not be reached if onConfirm is always provided)
|
||||
ctx := m.ctx
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
executor := NewBackupExecution(m.config, m.logger, m, ctx, "cluster", "", 0)
|
||||
return executor, executor.Init()
|
||||
}
|
||||
return m.parent, nil
|
||||
|
||||
@ -126,6 +126,10 @@ func (m DatabaseSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.InterruptMsg:
|
||||
// Handle Ctrl+C signal (SIGINT) - Bubbletea v1.3+ sends this instead of KeyMsg for ctrl+c
|
||||
return m.parent, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
// Auto-forward ESC/quit in auto-confirm mode
|
||||
if m.config.TUIAutoConfirm {
|
||||
|
||||
@ -303,10 +303,10 @@ func (m *MenuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m.handleSchedule()
|
||||
case 9: // View Backup Chain
|
||||
return m.handleChain()
|
||||
case 10: // System Resource Profile
|
||||
return m.handleProfile()
|
||||
case 11: // Separator
|
||||
case 10: // Separator
|
||||
// Do nothing
|
||||
case 11: // System Resource Profile
|
||||
return m.handleProfile()
|
||||
case 12: // Tools
|
||||
return m.handleTools()
|
||||
case 13: // View Active Operations
|
||||
|
||||
@ -181,9 +181,17 @@ func (m *ProfileModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.InterruptMsg:
|
||||
// Handle Ctrl+C signal (SIGINT) - Bubbletea v1.3+ sends this instead of KeyMsg for ctrl+c
|
||||
m.quitting = true
|
||||
if m.parent != nil {
|
||||
return m.parent, nil
|
||||
}
|
||||
return m, tea.Quit
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "q", "esc":
|
||||
case "ctrl+c", "q", "esc":
|
||||
m.quitting = true
|
||||
if m.parent != nil {
|
||||
return m.parent, nil
|
||||
|
||||
@ -245,9 +245,11 @@ func getCurrentRestoreProgress() (bytesTotal, bytesDone int64, description strin
|
||||
speed = calculateRollingSpeed(currentRestoreProgressState.speedSamples)
|
||||
|
||||
// Calculate realtime phase elapsed if we have a phase 3 start time
|
||||
dbPhaseElapsed = currentRestoreProgressState.dbPhaseElapsed
|
||||
// Always recalculate from phase3StartTime for accurate real-time display
|
||||
if !currentRestoreProgressState.phase3StartTime.IsZero() {
|
||||
dbPhaseElapsed = time.Since(currentRestoreProgressState.phase3StartTime)
|
||||
} else {
|
||||
dbPhaseElapsed = currentRestoreProgressState.dbPhaseElapsed
|
||||
}
|
||||
|
||||
return currentRestoreProgressState.bytesTotal, currentRestoreProgressState.bytesDone,
|
||||
@ -412,8 +414,9 @@ func executeRestoreWithTUIProgress(parentCtx context.Context, cfg *config.Config
|
||||
// This matches how cluster restore works - uses CLI tools, not database connections
|
||||
droppedCount := 0
|
||||
for _, dbName := range existingDBs {
|
||||
// Create timeout context for each database drop (5 minutes per DB - large DBs take time)
|
||||
dropCtx, dropCancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
// Create timeout context for each database drop (60 seconds per DB)
|
||||
// Reduced from 5 minutes for better TUI responsiveness
|
||||
dropCtx, dropCancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
if err := dropDatabaseCLI(dropCtx, cfg, dbName); err != nil {
|
||||
log.Warn("Failed to drop database", "name", dbName, "error", err)
|
||||
// Continue with other databases
|
||||
@ -527,6 +530,8 @@ func executeRestoreWithTUIProgress(parentCtx context.Context, cfg *config.Config
|
||||
if progressState.phase3StartTime.IsZero() {
|
||||
progressState.phase3StartTime = time.Now()
|
||||
}
|
||||
// Calculate elapsed time immediately for accurate display
|
||||
progressState.dbPhaseElapsed = time.Since(progressState.phase3StartTime)
|
||||
// Clear byte progress when switching to db progress
|
||||
progressState.bytesTotal = 0
|
||||
progressState.bytesDone = 0
|
||||
@ -568,6 +573,10 @@ func executeRestoreWithTUIProgress(parentCtx context.Context, cfg *config.Config
|
||||
if progressState.phase3StartTime.IsZero() {
|
||||
progressState.phase3StartTime = time.Now()
|
||||
}
|
||||
// Recalculate elapsed for accuracy if phaseElapsed not provided
|
||||
if phaseElapsed == 0 && !progressState.phase3StartTime.IsZero() {
|
||||
progressState.dbPhaseElapsed = time.Since(progressState.phase3StartTime)
|
||||
}
|
||||
// Clear byte progress when switching to db progress
|
||||
progressState.bytesTotal = 0
|
||||
progressState.bytesDone = 0
|
||||
@ -608,6 +617,8 @@ func executeRestoreWithTUIProgress(parentCtx context.Context, cfg *config.Config
|
||||
if progressState.phase3StartTime.IsZero() {
|
||||
progressState.phase3StartTime = time.Now()
|
||||
}
|
||||
// Calculate elapsed time immediately for accurate display
|
||||
progressState.dbPhaseElapsed = time.Since(progressState.phase3StartTime)
|
||||
|
||||
// Update unified progress tracker
|
||||
if progressState.unifiedProgress != nil {
|
||||
|
||||
@ -99,6 +99,22 @@ type safetyCheckCompleteMsg struct {
|
||||
|
||||
func runSafetyChecks(cfg *config.Config, log logger.Logger, archive ArchiveInfo, targetDB string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
// Check if preflight checks should be skipped
|
||||
if cfg != nil && cfg.SkipPreflightChecks {
|
||||
// Return all checks as "skipped" with warning
|
||||
checks := []SafetyCheck{
|
||||
{Name: "Archive integrity", Status: "warning", Message: "⚠️ SKIPPED - preflight checks disabled", Critical: true},
|
||||
{Name: "Dump validity", Status: "warning", Message: "⚠️ SKIPPED - preflight checks disabled", Critical: true},
|
||||
{Name: "Disk space", Status: "warning", Message: "⚠️ SKIPPED - preflight checks disabled", Critical: true},
|
||||
{Name: "Required tools", Status: "warning", Message: "⚠️ SKIPPED - preflight checks disabled", Critical: true},
|
||||
{Name: "Target database", Status: "warning", Message: "⚠️ SKIPPED - preflight checks disabled", Critical: false},
|
||||
}
|
||||
return safetyCheckCompleteMsg{
|
||||
checks: checks,
|
||||
canProceed: true, // Allow proceeding but with warnings
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic timeout based on archive size for large database support
|
||||
// Base: 10 minutes + 1 minute per 5 GB, max 120 minutes
|
||||
timeoutMinutes := 10
|
||||
@ -272,6 +288,10 @@ func (m RestorePreviewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.InterruptMsg:
|
||||
// Handle Ctrl+C signal (SIGINT) - Bubbletea v1.3+ sends this instead of KeyMsg for ctrl+c
|
||||
return m.parent, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "q", "esc":
|
||||
@ -526,6 +546,14 @@ func (m RestorePreviewModel) View() string {
|
||||
s.WriteString(archiveHeaderStyle.Render("[SAFETY] Checks"))
|
||||
s.WriteString("\n")
|
||||
|
||||
// Show warning banner if preflight checks are skipped
|
||||
if m.config != nil && m.config.SkipPreflightChecks {
|
||||
s.WriteString(CheckWarningStyle.Render(" ⚠️ PREFLIGHT CHECKS DISABLED ⚠️"))
|
||||
s.WriteString("\n")
|
||||
s.WriteString(CheckWarningStyle.Render(" Restore may fail unexpectedly. Re-enable in Settings."))
|
||||
s.WriteString("\n\n")
|
||||
}
|
||||
|
||||
if m.checking {
|
||||
s.WriteString(infoStyle.Render(" Running safety checks..."))
|
||||
s.WriteString("\n")
|
||||
|
||||
@ -165,6 +165,22 @@ func NewSettingsModel(cfg *config.Config, log logger.Logger, parent tea.Model) S
|
||||
Type: "selector",
|
||||
Description: "Enable for databases with many tables/LOBs. Reduces parallelism, increases max_locks_per_transaction.",
|
||||
},
|
||||
{
|
||||
Key: "skip_preflight_checks",
|
||||
DisplayName: "Skip Preflight Checks",
|
||||
Value: func(c *config.Config) string {
|
||||
if c.SkipPreflightChecks {
|
||||
return "⚠️ SKIPPED (dangerous)"
|
||||
}
|
||||
return "Enabled (safe)"
|
||||
},
|
||||
Update: func(c *config.Config, v string) error {
|
||||
c.SkipPreflightChecks = !c.SkipPreflightChecks
|
||||
return nil
|
||||
},
|
||||
Type: "selector",
|
||||
Description: "⚠️ WARNING: Skipping checks may result in failed restores or data loss. Only use if checks are too slow.",
|
||||
},
|
||||
{
|
||||
Key: "cluster_parallelism",
|
||||
DisplayName: "Cluster Parallelism",
|
||||
|
||||
2
main.go
2
main.go
@ -16,7 +16,7 @@ import (
|
||||
|
||||
// Build information (set by ldflags)
|
||||
var (
|
||||
version = "5.8.19"
|
||||
version = "5.8.24"
|
||||
buildTime = "unknown"
|
||||
gitCommit = "unknown"
|
||||
)
|
||||
|
||||
@ -1,60 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Test script to verify context cancellation works in TUI restore
|
||||
# Run this BEFORE deploying to enterprise machine
|
||||
|
||||
set -e
|
||||
|
||||
echo "🧪 Testing TUI Cancellation Behavior"
|
||||
echo "====================================="
|
||||
|
||||
# Create a test SQL file that simulates a large dump
|
||||
TEST_SQL="/tmp/test_large_dump.sql"
|
||||
echo "Creating test SQL file with 50000 statements..."
|
||||
|
||||
cat > "$TEST_SQL" << 'EOF'
|
||||
-- Test pg_dumpall format
|
||||
SET statement_timeout = 0;
|
||||
SET lock_timeout = 0;
|
||||
SET idle_in_transaction_session_timeout = 0;
|
||||
SET client_encoding = 'UTF8';
|
||||
SET standard_conforming_strings = on;
|
||||
EOF
|
||||
|
||||
# Add 50000 simple statements to simulate a large dump
|
||||
for i in $(seq 1 50000); do
|
||||
echo "SELECT $i; -- padding line to simulate large file" >> "$TEST_SQL"
|
||||
done
|
||||
|
||||
echo "-- End of test dump" >> "$TEST_SQL"
|
||||
|
||||
echo "✅ Created $TEST_SQL ($(wc -l < "$TEST_SQL") lines)"
|
||||
|
||||
# Test 1: Verify parsing can be cancelled
|
||||
echo ""
|
||||
echo "Test 1: Parsing Cancellation (5 second timeout)"
|
||||
echo "------------------------------------------------"
|
||||
timeout 5 ./bin/dbbackup_linux_amd64 restore single "$TEST_SQL" --target testdb_noexist 2>&1 || {
|
||||
if [ $? -eq 124 ]; then
|
||||
echo "⚠️ TIMEOUT - parsing took longer than 5 seconds (potential hang)"
|
||||
echo " This may indicate the fix is not working"
|
||||
else
|
||||
echo "✅ Command completed or failed normally (no hang)"
|
||||
fi
|
||||
}
|
||||
|
||||
# Test 2: TUI interrupt test (requires manual verification)
|
||||
echo ""
|
||||
echo "Test 2: TUI Interrupt Test (MANUAL)"
|
||||
echo "------------------------------------"
|
||||
echo "Run this command and press Ctrl+C after 2-3 seconds:"
|
||||
echo ""
|
||||
echo " ./bin/dbbackup_linux_amd64 restore single $TEST_SQL --target testdb"
|
||||
echo ""
|
||||
echo "Expected: Should exit cleanly within 1-2 seconds of Ctrl+C"
|
||||
echo "Problem: If it hangs for 30+ seconds, the fix didn't work"
|
||||
|
||||
# Cleanup
|
||||
echo ""
|
||||
echo "Cleanup: rm $TEST_SQL"
|
||||
echo ""
|
||||
echo "🏁 Test complete!"
|
||||
Reference in New Issue
Block a user