Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dceab64b67 | |||
| a101fb81ab | |||
| 555177f5a7 | |||
| 0d416ecb55 | |||
| 1fe16ef89b | |||
| 4507ec682f |
@ -5,6 +5,14 @@ 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.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
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -123,9 +123,11 @@ func getCurrentBackupProgress() (dbTotal, dbDone int, dbName string, overallPhas
|
||||
currentBackupProgressState.hasUpdate = false
|
||||
|
||||
// Calculate realtime phase elapsed if we have a phase 2 start time
|
||||
dbPhaseElapsed = currentBackupProgressState.dbPhaseElapsed
|
||||
// Always recalculate from phase2StartTime for accurate real-time display
|
||||
if !currentBackupProgressState.phase2StartTime.IsZero() {
|
||||
dbPhaseElapsed = time.Since(currentBackupProgressState.phase2StartTime)
|
||||
} else {
|
||||
dbPhaseElapsed = currentBackupProgressState.dbPhaseElapsed
|
||||
}
|
||||
|
||||
return currentBackupProgressState.dbTotal, currentBackupProgressState.dbDone,
|
||||
@ -260,7 +262,10 @@ func executeBackupWithTUIProgress(parentCtx context.Context, cfg *config.Config,
|
||||
// Set phase 2 start time on first callback (for realtime ETA calculation)
|
||||
if progressState.phase2StartTime.IsZero() {
|
||||
progressState.phase2StartTime = time.Now()
|
||||
log.Info("Phase 2 started", "time", progressState.phase2StartTime)
|
||||
}
|
||||
// Calculate elapsed time immediately
|
||||
progressState.dbPhaseElapsed = time.Since(progressState.phase2StartTime)
|
||||
progressState.mu.Unlock()
|
||||
})
|
||||
|
||||
|
||||
@ -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,
|
||||
@ -311,6 +313,35 @@ func executeRestoreWithTUIProgress(parentCtx context.Context, cfg *config.Config
|
||||
return func() (returnMsg tea.Msg) {
|
||||
start := time.Now()
|
||||
|
||||
// TUI Debug Log: Always write to file when debug is enabled (even on success/hang)
|
||||
var tuiDebugFile *os.File
|
||||
if saveDebugLog {
|
||||
workDir := cfg.GetEffectiveWorkDir()
|
||||
tuiLogPath := filepath.Join(workDir, fmt.Sprintf("dbbackup-tui-debug-%s.log", time.Now().Format("20060102-150405")))
|
||||
var err error
|
||||
tuiDebugFile, err = os.Create(tuiLogPath)
|
||||
if err == nil {
|
||||
defer tuiDebugFile.Close()
|
||||
fmt.Fprintf(tuiDebugFile, "=== TUI Restore Debug Log ===\n")
|
||||
fmt.Fprintf(tuiDebugFile, "Started: %s\n", time.Now().Format(time.RFC3339))
|
||||
fmt.Fprintf(tuiDebugFile, "Archive: %s\n", archive.Path)
|
||||
fmt.Fprintf(tuiDebugFile, "RestoreType: %s\n", restoreType)
|
||||
fmt.Fprintf(tuiDebugFile, "TargetDB: %s\n", targetDB)
|
||||
fmt.Fprintf(tuiDebugFile, "CleanCluster: %v\n", cleanClusterFirst)
|
||||
fmt.Fprintf(tuiDebugFile, "ExistingDBs: %v\n\n", existingDBs)
|
||||
log.Info("TUI debug log enabled", "path", tuiLogPath)
|
||||
}
|
||||
}
|
||||
tuiLog := func(msg string, args ...interface{}) {
|
||||
if tuiDebugFile != nil {
|
||||
fmt.Fprintf(tuiDebugFile, "[%s] %s", time.Now().Format("15:04:05.000"), fmt.Sprintf(msg, args...))
|
||||
fmt.Fprintln(tuiDebugFile)
|
||||
tuiDebugFile.Sync() // Flush immediately so we capture hangs
|
||||
}
|
||||
}
|
||||
|
||||
tuiLog("Starting restore execution")
|
||||
|
||||
// CRITICAL: Add panic recovery that RETURNS a proper message to BubbleTea.
|
||||
// Without this, if a panic occurs the command function returns nil,
|
||||
// causing BubbleTea's execBatchMsg WaitGroup to hang forever waiting
|
||||
@ -333,8 +364,11 @@ func executeRestoreWithTUIProgress(parentCtx context.Context, cfg *config.Config
|
||||
// DO NOT create a new context here as it breaks Ctrl+C cancellation
|
||||
ctx := parentCtx
|
||||
|
||||
tuiLog("Checking context state")
|
||||
|
||||
// Check if context is already cancelled
|
||||
if ctx.Err() != nil {
|
||||
tuiLog("Context already cancelled: %v", ctx.Err())
|
||||
return restoreCompleteMsg{
|
||||
result: "",
|
||||
err: fmt.Errorf("operation cancelled: %w", ctx.Err()),
|
||||
@ -342,9 +376,12 @@ func executeRestoreWithTUIProgress(parentCtx context.Context, cfg *config.Config
|
||||
}
|
||||
}
|
||||
|
||||
tuiLog("Creating database client")
|
||||
|
||||
// Create database instance
|
||||
dbClient, err := database.New(cfg, log)
|
||||
if err != nil {
|
||||
tuiLog("Database client creation failed: %v", err)
|
||||
return restoreCompleteMsg{
|
||||
result: "",
|
||||
err: fmt.Errorf("failed to create database client: %w", err),
|
||||
@ -353,8 +390,11 @@ func executeRestoreWithTUIProgress(parentCtx context.Context, cfg *config.Config
|
||||
}
|
||||
defer dbClient.Close()
|
||||
|
||||
tuiLog("Database client created successfully")
|
||||
|
||||
// STEP 1: Clean cluster if requested (drop all existing user databases)
|
||||
if restoreType == "restore-cluster" && cleanClusterFirst {
|
||||
tuiLog("STEP 1: Cleaning cluster (dropping existing DBs)")
|
||||
// Re-detect databases at execution time to get current state
|
||||
// The preview list may be stale or detection may have failed earlier
|
||||
safety := restore.NewSafety(cfg, log)
|
||||
@ -374,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
|
||||
@ -489,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
|
||||
@ -530,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
|
||||
@ -570,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 {
|
||||
@ -594,29 +643,39 @@ func executeRestoreWithTUIProgress(parentCtx context.Context, cfg *config.Config
|
||||
log.Info("Debug logging enabled", "path", debugLogPath)
|
||||
}
|
||||
|
||||
tuiLog("STEP 3: Executing restore (type=%s)", restoreType)
|
||||
|
||||
// STEP 3: Execute restore based on type
|
||||
var restoreErr error
|
||||
if restoreType == "restore-cluster" {
|
||||
// Use pre-extracted directory if available (optimization)
|
||||
if archive.ExtractedDir != "" {
|
||||
tuiLog("Using pre-extracted cluster directory: %s", archive.ExtractedDir)
|
||||
log.Info("Using pre-extracted cluster directory", "path", archive.ExtractedDir)
|
||||
defer os.RemoveAll(archive.ExtractedDir) // Cleanup after restore completes
|
||||
restoreErr = engine.RestoreCluster(ctx, archive.Path, archive.ExtractedDir)
|
||||
} else {
|
||||
tuiLog("Calling engine.RestoreCluster for: %s", archive.Path)
|
||||
restoreErr = engine.RestoreCluster(ctx, archive.Path)
|
||||
}
|
||||
tuiLog("RestoreCluster returned: err=%v", restoreErr)
|
||||
} else if restoreType == "restore-cluster-single" {
|
||||
tuiLog("Calling RestoreSingleFromCluster: %s -> %s", archive.Path, targetDB)
|
||||
// Restore single database from cluster backup
|
||||
// Also cleanup pre-extracted dir if present
|
||||
if archive.ExtractedDir != "" {
|
||||
defer os.RemoveAll(archive.ExtractedDir)
|
||||
}
|
||||
restoreErr = engine.RestoreSingleFromCluster(ctx, archive.Path, targetDB, targetDB, cleanFirst, createIfMissing)
|
||||
tuiLog("RestoreSingleFromCluster returned: err=%v", restoreErr)
|
||||
} else {
|
||||
tuiLog("Calling RestoreSingle: %s -> %s", archive.Path, targetDB)
|
||||
restoreErr = engine.RestoreSingle(ctx, archive.Path, targetDB, cleanFirst, createIfMissing)
|
||||
tuiLog("RestoreSingle returned: err=%v", restoreErr)
|
||||
}
|
||||
|
||||
if restoreErr != nil {
|
||||
tuiLog("Restore failed: %v", restoreErr)
|
||||
return restoreCompleteMsg{
|
||||
result: "",
|
||||
err: restoreErr,
|
||||
@ -633,6 +692,8 @@ func executeRestoreWithTUIProgress(parentCtx context.Context, cfg *config.Config
|
||||
result = fmt.Sprintf("Successfully restored cluster from %s (cleaned %d existing database(s) first)", archive.Name, len(existingDBs))
|
||||
}
|
||||
|
||||
tuiLog("Restore completed successfully: %s", result)
|
||||
|
||||
return restoreCompleteMsg{
|
||||
result: result,
|
||||
err: nil,
|
||||
|
||||
@ -272,6 +272,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":
|
||||
|
||||
2
main.go
2
main.go
@ -16,7 +16,7 @@ import (
|
||||
|
||||
// Build information (set by ldflags)
|
||||
var (
|
||||
version = "5.8.17"
|
||||
version = "5.8.23"
|
||||
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