Skip to content

Commit 9ea795b

Browse files
FEATURE (backups): Add backups cancelling
1 parent a809dc8 commit 9ea795b

File tree

14 files changed

+333
-10
lines changed

14 files changed

+333
-10
lines changed
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package backups
2+
3+
import (
4+
"context"
5+
"errors"
6+
"sync"
7+
8+
"github.com/google/uuid"
9+
)
10+
11+
type BackupContextManager struct {
12+
mu sync.RWMutex
13+
cancelFuncs map[uuid.UUID]context.CancelFunc
14+
}
15+
16+
func NewBackupContextManager() *BackupContextManager {
17+
return &BackupContextManager{
18+
cancelFuncs: make(map[uuid.UUID]context.CancelFunc),
19+
}
20+
}
21+
22+
func (m *BackupContextManager) RegisterBackup(backupID uuid.UUID, cancelFunc context.CancelFunc) {
23+
m.mu.Lock()
24+
defer m.mu.Unlock()
25+
m.cancelFuncs[backupID] = cancelFunc
26+
}
27+
28+
func (m *BackupContextManager) CancelBackup(backupID uuid.UUID) error {
29+
m.mu.Lock()
30+
defer m.mu.Unlock()
31+
32+
cancelFunc, exists := m.cancelFuncs[backupID]
33+
if !exists {
34+
return errors.New("backup is not in progress or already completed")
35+
}
36+
37+
cancelFunc()
38+
delete(m.cancelFuncs, backupID)
39+
40+
return nil
41+
}
42+
43+
func (m *BackupContextManager) UnregisterBackup(backupID uuid.UUID) {
44+
m.mu.Lock()
45+
defer m.mu.Unlock()
46+
delete(m.cancelFuncs, backupID)
47+
}

backend/internal/features/backups/backups/controller.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ func (c *BackupController) RegisterRoutes(router *gin.RouterGroup) {
1919
router.POST("/backups", c.MakeBackup)
2020
router.GET("/backups/:id/file", c.GetFile)
2121
router.DELETE("/backups/:id", c.DeleteBackup)
22+
router.POST("/backups/:id/cancel", c.CancelBackup)
2223
}
2324

2425
// GetBackups
@@ -126,6 +127,37 @@ func (c *BackupController) DeleteBackup(ctx *gin.Context) {
126127
ctx.Status(http.StatusNoContent)
127128
}
128129

130+
// CancelBackup
131+
// @Summary Cancel an in-progress backup
132+
// @Description Cancel a backup that is currently in progress
133+
// @Tags backups
134+
// @Param id path string true "Backup ID"
135+
// @Success 204
136+
// @Failure 400
137+
// @Failure 401
138+
// @Failure 500
139+
// @Router /backups/{id}/cancel [post]
140+
func (c *BackupController) CancelBackup(ctx *gin.Context) {
141+
user, ok := users_middleware.GetUserFromContext(ctx)
142+
if !ok {
143+
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
144+
return
145+
}
146+
147+
id, err := uuid.Parse(ctx.Param("id"))
148+
if err != nil {
149+
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid backup ID"})
150+
return
151+
}
152+
153+
if err := c.backupService.CancelBackup(user, id); err != nil {
154+
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
155+
return
156+
}
157+
158+
ctx.Status(http.StatusNoContent)
159+
}
160+
129161
// GetFile
130162
// @Summary Download a backup file
131163
// @Description Download the backup file for the specified backup

backend/internal/features/backups/backups/controller_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,77 @@ func Test_DownloadBackup_AuditLogWritten(t *testing.T) {
492492
assert.True(t, found, "Audit log for backup download not found")
493493
}
494494

495+
func Test_CancelBackup_InProgressBackup_SuccessfullyCancelled(t *testing.T) {
496+
router := createTestRouter()
497+
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
498+
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
499+
database := createTestDatabase("Test Database", workspace.ID, owner.Token, router)
500+
storage := createTestStorage(workspace.ID)
501+
502+
configService := backups_config.GetBackupConfigService()
503+
config, err := configService.GetBackupConfigByDbId(database.ID)
504+
assert.NoError(t, err)
505+
506+
config.IsBackupsEnabled = true
507+
config.StorageID = &storage.ID
508+
config.Storage = storage
509+
_, err = configService.SaveBackupConfig(config)
510+
assert.NoError(t, err)
511+
512+
backup := &Backup{
513+
ID: uuid.New(),
514+
DatabaseID: database.ID,
515+
Database: database,
516+
StorageID: storage.ID,
517+
Storage: storage,
518+
Status: BackupStatusInProgress,
519+
BackupSizeMb: 0,
520+
BackupDurationMs: 0,
521+
CreatedAt: time.Now().UTC(),
522+
}
523+
524+
repo := &BackupRepository{}
525+
err = repo.Save(backup)
526+
assert.NoError(t, err)
527+
528+
// Register a cancellable context for the backup
529+
GetBackupService().backupContextMgr.RegisterBackup(backup.ID, func() {})
530+
531+
resp := test_utils.MakePostRequest(
532+
t,
533+
router,
534+
fmt.Sprintf("/api/v1/backups/%s/cancel", backup.ID.String()),
535+
"Bearer "+owner.Token,
536+
nil,
537+
http.StatusNoContent,
538+
)
539+
540+
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
541+
542+
// Verify audit log was created
543+
admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin)
544+
userService := users_services.GetUserService()
545+
adminUser, err := userService.GetUserFromToken(admin.Token)
546+
assert.NoError(t, err)
547+
548+
auditLogService := audit_logs.GetAuditLogService()
549+
auditLogs, err := auditLogService.GetGlobalAuditLogs(
550+
adminUser,
551+
&audit_logs.GetAuditLogsRequest{Limit: 100, Offset: 0},
552+
)
553+
assert.NoError(t, err)
554+
555+
foundCancelLog := false
556+
for _, log := range auditLogs.AuditLogs {
557+
if strings.Contains(log.Message, "Backup cancelled") &&
558+
strings.Contains(log.Message, database.Name) {
559+
foundCancelLog = true
560+
break
561+
}
562+
}
563+
assert.True(t, foundCancelLog, "Cancel audit log should be created")
564+
}
565+
495566
func createTestRouter() *gin.Engine {
496567
return CreateTestRouter()
497568
}

backend/internal/features/backups/backups/di.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import (
1313
)
1414

1515
var backupRepository = &BackupRepository{}
16+
17+
var backupContextManager = NewBackupContextManager()
18+
1619
var backupService = &BackupService{
1720
databases.GetDatabaseService(),
1821
storages.GetStorageService(),
@@ -25,6 +28,7 @@ var backupService = &BackupService{
2528
[]BackupRemoveListener{},
2629
workspaces_services.GetWorkspaceService(),
2730
audit_logs.GetAuditLogService(),
31+
backupContextManager,
2832
}
2933

3034
var backupBackgroundService = &BackupBackgroundService{

backend/internal/features/backups/backups/enums.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ const (
66
BackupStatusInProgress BackupStatus = "IN_PROGRESS"
77
BackupStatusCompleted BackupStatus = "COMPLETED"
88
BackupStatusFailed BackupStatus = "FAILED"
9+
BackupStatusCanceled BackupStatus = "CANCELED"
910
)

backend/internal/features/backups/backups/interfaces.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package backups
22

33
import (
4+
"context"
5+
46
backups_config "postgresus-backend/internal/features/backups/config"
57
"postgresus-backend/internal/features/databases"
68
"postgresus-backend/internal/features/notifiers"
@@ -19,6 +21,7 @@ type NotificationSender interface {
1921

2022
type CreateBackupUsecase interface {
2123
Execute(
24+
ctx context.Context,
2225
backupID uuid.UUID,
2326
backupConfig *backups_config.BackupConfig,
2427
database *databases.Database,

backend/internal/features/backups/backups/service.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package backups
22

33
import (
4+
"context"
45
"errors"
56
"fmt"
67
"io"
@@ -13,6 +14,7 @@ import (
1314
users_models "postgresus-backend/internal/features/users/models"
1415
workspaces_services "postgresus-backend/internal/features/workspaces/services"
1516
"slices"
17+
"strings"
1618
"time"
1719

1820
"github.com/google/uuid"
@@ -34,6 +36,7 @@ type BackupService struct {
3436

3537
workspaceService *workspaces_services.WorkspaceService
3638
auditLogService *audit_logs.AuditLogService
39+
backupContextMgr *BackupContextManager
3740
}
3841

3942
func (s *BackupService) AddBackupRemoveListener(listener BackupRemoveListener) {
@@ -247,7 +250,12 @@ func (s *BackupService) MakeBackup(databaseID uuid.UUID, isLastTry bool) {
247250
}
248251
}
249252

253+
ctx, cancel := context.WithCancel(context.Background())
254+
s.backupContextMgr.RegisterBackup(backup.ID, cancel)
255+
defer s.backupContextMgr.UnregisterBackup(backup.ID)
256+
250257
err = s.createBackupUseCase.Execute(
258+
ctx,
251259
backup.ID,
252260
backupConfig,
253261
database,
@@ -256,6 +264,34 @@ func (s *BackupService) MakeBackup(databaseID uuid.UUID, isLastTry bool) {
256264
)
257265
if err != nil {
258266
errMsg := err.Error()
267+
268+
// Check if backup was cancelled (not due to shutdown)
269+
if strings.Contains(errMsg, "backup cancelled") && !strings.Contains(errMsg, "shutdown") {
270+
backup.Status = BackupStatusCanceled
271+
backup.BackupDurationMs = time.Since(start).Milliseconds()
272+
backup.BackupSizeMb = 0
273+
274+
if err := s.backupRepository.Save(backup); err != nil {
275+
s.logger.Error("Failed to save cancelled backup", "error", err)
276+
}
277+
278+
// Delete partial backup from storage
279+
storage, storageErr := s.storageService.GetStorageByID(backup.StorageID)
280+
if storageErr == nil {
281+
if deleteErr := storage.DeleteFile(backup.ID); deleteErr != nil {
282+
s.logger.Error(
283+
"Failed to delete partial backup file",
284+
"backupId",
285+
backup.ID,
286+
"error",
287+
deleteErr,
288+
)
289+
}
290+
}
291+
292+
return
293+
}
294+
259295
backup.FailMessage = &errMsg
260296
backup.Status = BackupStatusFailed
261297
backup.BackupDurationMs = time.Since(start).Milliseconds()
@@ -382,6 +418,48 @@ func (s *BackupService) GetBackup(backupID uuid.UUID) (*Backup, error) {
382418
return s.backupRepository.FindByID(backupID)
383419
}
384420

421+
func (s *BackupService) CancelBackup(
422+
user *users_models.User,
423+
backupID uuid.UUID,
424+
) error {
425+
backup, err := s.backupRepository.FindByID(backupID)
426+
if err != nil {
427+
return err
428+
}
429+
430+
if backup.Database.WorkspaceID == nil {
431+
return errors.New("cannot cancel backup for database without workspace")
432+
}
433+
434+
canManage, err := s.workspaceService.CanUserManageDBs(*backup.Database.WorkspaceID, user)
435+
if err != nil {
436+
return err
437+
}
438+
if !canManage {
439+
return errors.New("insufficient permissions to cancel backup for this database")
440+
}
441+
442+
if backup.Status != BackupStatusInProgress {
443+
return errors.New("backup is not in progress")
444+
}
445+
446+
if err := s.backupContextMgr.CancelBackup(backupID); err != nil {
447+
return err
448+
}
449+
450+
s.auditLogService.WriteAuditLog(
451+
fmt.Sprintf(
452+
"Backup cancelled for database: %s (ID: %s)",
453+
backup.Database.Name,
454+
backupID.String(),
455+
),
456+
&user.ID,
457+
backup.Database.WorkspaceID,
458+
)
459+
460+
return nil
461+
}
462+
385463
func (s *BackupService) GetBackupFile(
386464
user *users_models.User,
387465
backupID uuid.UUID,

backend/internal/features/backups/backups/service_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package backups
22

33
import (
4+
"context"
45
"errors"
56
backups_config "postgresus-backend/internal/features/backups/config"
67
"postgresus-backend/internal/features/databases"
@@ -56,6 +57,7 @@ func Test_BackupExecuted_NotificationSent(t *testing.T) {
5657
[]BackupRemoveListener{},
5758
nil, // workspaceService
5859
nil, // auditLogService
60+
NewBackupContextManager(),
5961
}
6062

6163
// Set up expectations
@@ -101,6 +103,7 @@ func Test_BackupExecuted_NotificationSent(t *testing.T) {
101103
[]BackupRemoveListener{},
102104
nil, // workspaceService
103105
nil, // auditLogService
106+
NewBackupContextManager(),
104107
}
105108

106109
backupService.MakeBackup(database.ID, true)
@@ -123,6 +126,7 @@ func Test_BackupExecuted_NotificationSent(t *testing.T) {
123126
[]BackupRemoveListener{},
124127
nil, // workspaceService
125128
nil, // auditLogService
129+
NewBackupContextManager(),
126130
}
127131

128132
// capture arguments
@@ -158,6 +162,7 @@ type CreateFailedBackupUsecase struct {
158162
}
159163

160164
func (uc *CreateFailedBackupUsecase) Execute(
165+
ctx context.Context,
161166
backupID uuid.UUID,
162167
backupConfig *backups_config.BackupConfig,
163168
database *databases.Database,
@@ -174,6 +179,7 @@ type CreateSuccessBackupUsecase struct {
174179
}
175180

176181
func (uc *CreateSuccessBackupUsecase) Execute(
182+
ctx context.Context,
177183
backupID uuid.UUID,
178184
backupConfig *backups_config.BackupConfig,
179185
database *databases.Database,

0 commit comments

Comments
 (0)