Skip to content

Commit db352b1

Browse files
feat(lifecycle): add support for profile-specific backup retention policies
Summary of changes: - Extended the communication layer to allow PBM configuration profiles to carry nested 'lifecycle' configuration blocks. - Fixed a bug in the lifecycle evaluation engine where the 'minKeep' safety threshold was not correctly rescuing the last successful backups from the purge list. - Updated the engine to respect 'Enabled: false' states instantly, preventing accidental purges when the policy is disabled. Key Technical Modifications: - pbm/ctrl/cmd.go: Added Lifecycle struct to ProfileCmd payload. - pbm/ctrl/send.go: Updated SendAddConfigProfile to accept lifecycle data. - sdk/impl.go: Modified AddConfigProfile to pass lifecycle pointers from the parsed YAML configuration. - pbm-agent/profile.go: Updated handleAddConfigProfile to map received lifecycle metadata back into the MongoDB profile documents. - pbm/lifecycle/manager.go: Re-engineered Evaluate() to correctly enforce MinKeep logic and handle disabled policies via early exit. - pbm/lifecycle/manager_test.go: Updated unit tests to reflect and verify the corrected safety behavior for in-progress and last-base backups. Validated in a sharded lab environment with physical and logical storage profiles, verifying that retention rules are applied correctly per-profile.
1 parent 6adf620 commit db352b1

7 files changed

Lines changed: 74 additions & 18 deletions

File tree

cmd/pbm-agent/profile.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,9 @@ func (a *Agent) handleAddConfigProfile(
111111
IsProfile: true,
112112
Storage: cmd.Storage,
113113
}
114+
if cmd.Lifecycle != nil {
115+
profile.Lifecycle = *cmd.Lifecycle
116+
}
114117
err = config.AddProfile(ctx, a.leadConn, profile)
115118
if err != nil {
116119
err = errors.Wrap(err, "add profile config")

pbm/config/util.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ func GetProfiledConfig(ctx context.Context, conn connect.Client, profile string)
3535
cfg.Storage = custom.Storage
3636
cfg.Name = custom.Name
3737
cfg.IsProfile = true
38+
cfg.Lifecycle = custom.Lifecycle
3839
}
3940

4041
if storage.ParseType(string(cfg.Storage.Type)) == storage.Undefined {

pbm/ctrl/cmd.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,10 @@ func (c Cmd) String() string {
115115
}
116116

117117
type ProfileCmd struct {
118-
Name string `bson:"name"`
119-
IsProfile bool `bson:"profile"`
120-
Storage config.StorageConf `bson:"storage"`
118+
Name string `bson:"name"`
119+
IsProfile bool `bson:"profile"`
120+
Storage config.StorageConf `bson:"storage"`
121+
Lifecycle *config.LifecycleConf `bson:"lifecycle,omitempty"`
121122
}
122123

123124
type ResyncCmd struct {

pbm/ctrl/send.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,15 @@ func SendAddConfigProfile(
7575
m connect.Client,
7676
name string,
7777
storage config.StorageConf,
78+
lifecycle *config.LifecycleConf,
7879
) (OPID, error) {
7980
cmd := Cmd{
8081
Cmd: CmdAddConfigProfile,
8182
Profile: &ProfileCmd{
8283
Name: name,
8384
IsProfile: true,
8485
Storage: storage,
86+
Lifecycle: lifecycle,
8587
},
8688
}
8789
return sendCommand(ctx, m, cmd)

pbm/lifecycle/manager.go

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package lifecycle
33
import (
44
"fmt"
55
"math"
6+
"slices"
67
"strings"
78
"time"
89

@@ -61,7 +62,7 @@ func (r *Report) String() string {
6162
return res
6263
}
6364

64-
// Evaluate determines which backups to keep and which to purge based on the config.
65+
// Replace the Evaluate function (~ line 50) with this:
6566
func Evaluate(cfg config.LifecycleConf, backups []backup.BackupMeta, dryRun bool, now time.Time) *Report {
6667
report := &Report{
6768
DryRun: dryRun,
@@ -70,12 +71,20 @@ func Evaluate(cfg config.LifecycleConf, backups []backup.BackupMeta, dryRun bool
7071
BackupTypes: make(map[string]string),
7172
}
7273

73-
if !cfg.Enabled && !dryRun {
74+
// BUG FIX: If Disabled, go to sleep. Keep everything.
75+
if !cfg.Enabled {
76+
for _, bcp := range backups {
77+
if bcp.Status.IsRunning() {
78+
continue
79+
}
80+
report.BackupTypes[bcp.Name] = string(bcp.Type)
81+
report.BackupsKept = append(report.BackupsKept, bcp.Name)
82+
report.KeepReasons[bcp.Name] = []string{"Lifecycle Disabled"}
83+
}
7484
return report
7585
}
7686

7787
isCalendar := strings.ToLower(cfg.Strategy) == "calendar"
78-
7988
keepMap := make(map[string][]string)
8089

8190
addReason := func(name, reason string) {
@@ -161,10 +170,9 @@ func Evaluate(cfg config.LifecycleConf, backups []backup.BackupMeta, dryRun bool
161170
// 3. Finalize Lists
162171
for _, bcp := range backups {
163172
if bcp.Status.IsRunning() {
164-
continue // Hide in-progress backups from the Keep/Purge report
173+
continue
165174
}
166175

167-
// Capture the type for every completed backup evaluated
168176
report.BackupTypes[bcp.Name] = string(bcp.Type)
169177

170178
if len(keepMap[bcp.Name]) > 0 {
@@ -175,6 +183,45 @@ func Evaluate(cfg config.LifecycleConf, backups []backup.BackupMeta, dryRun bool
175183
}
176184
}
177185

186+
// BUG FIX: 4. Enforce Min Keep (Rescue backups from PURGE)
187+
minKeep := 1
188+
if cfg.MinKeep != nil {
189+
minKeep = *cfg.MinKeep
190+
}
191+
192+
if minKeep > 0 && len(report.BackupsKept) < minKeep {
193+
var rescue []backup.BackupMeta
194+
for _, bcp := range backups {
195+
if bcp.Status == defs.StatusDone && len(keepMap[bcp.Name]) == 0 {
196+
rescue = append(rescue, bcp)
197+
}
198+
}
199+
200+
// Sort newest first to rescue the most recent ones
201+
slices.SortFunc(rescue, func(a, b backup.BackupMeta) int {
202+
if a.StartTS > b.StartTS {
203+
return -1
204+
}
205+
if a.StartTS < b.StartTS {
206+
return 1
207+
}
208+
return 0
209+
})
210+
211+
for _, bcp := range rescue {
212+
if len(report.BackupsKept) >= minKeep {
213+
break
214+
}
215+
report.BackupsKept = append(report.BackupsKept, bcp.Name)
216+
report.KeepReasons[bcp.Name] = []string{"Min Keep"}
217+
218+
// Remove from Purged list safely
219+
report.BackupsPurged = slices.DeleteFunc(report.BackupsPurged, func(name string) bool {
220+
return name == bcp.Name
221+
})
222+
}
223+
}
224+
178225
return report
179226
}
180227

pbm/lifecycle/manager_test.go

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ func TestEvaluate(t *testing.T) {
3939
}{
4040
// --- STANDARD DATE SCENARIOS ---
4141
{
42-
name: "Feature Disabled BUT Dry Run is True (Bypass enabled flag)",
42+
name: "Feature Disabled (Dry run or not, it sleeps)",
4343
cfg: config.LifecycleConf{
4444
Enabled: false,
4545
DailyRetention: 1,
@@ -50,8 +50,8 @@ func TestEvaluate(t *testing.T) {
5050
mockBcp("bcp-old", 10, standardDate, defs.StatusDone),
5151
},
5252
dryRun: true,
53-
expectedKept: []string{"bcp-today"},
54-
expectedPurged: []string{"bcp-old"},
53+
expectedKept: []string{"bcp-today", "bcp-old"},
54+
expectedPurged: []string{},
5555
},
5656
{
5757
name: "Rolling Strategy - Basic GFS (7 Daily, 4 Weekly)",
@@ -133,19 +133,21 @@ func TestEvaluate(t *testing.T) {
133133

134134
// --- STATE HANDLING SCENARIOS ---
135135
{
136-
name: "In-Progress Backups are ALWAYS protected",
136+
name: "In-Progress Backups are ALWAYS protected (and MinKeep rescues the last safe base)",
137137
cfg: config.LifecycleConf{
138138
Enabled: true,
139139
DailyRetention: 1,
140140
},
141141
mockNow: standardDate,
142142
backups: []backup.BackupMeta{
143-
mockBcp("bcp-running", 50, standardDate, defs.StatusRunning),
144-
mockBcp("bcp-done-old", 50, standardDate, defs.StatusDone),
143+
mockBcp("bcp-running", 0, standardDate, defs.StatusRunning), // In-progress today
144+
mockBcp("bcp-done-old", 50, standardDate, defs.StatusDone), // 50 days old, normally purged
145145
},
146-
dryRun: false,
147-
expectedKept: nil,
148-
expectedPurged: []string{"bcp-done-old"},
146+
dryRun: false,
147+
// bcp-running is implicitly protected (hidden from purge).
148+
// bcp-done-old is expired, but rescued because MinKeep defaults to 1 and the running backup doesn't count yet!
149+
expectedKept: []string{"bcp-done-old"},
150+
expectedPurged: []string{},
149151
},
150152
{
151153
name: "Failed Backups - PurgeFailed is TRUE",

sdk/impl.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ func (c *Client) GetConfigProfile(ctx context.Context, name string) (*config.Con
9191
}
9292

9393
func (c *Client) AddConfigProfile(ctx context.Context, name string, cfg *Config) (CommandID, error) {
94-
opid, err := ctrl.SendAddConfigProfile(ctx, c.conn, name, cfg.Storage)
94+
opid, err := ctrl.SendAddConfigProfile(ctx, c.conn, name, cfg.Storage, &cfg.Lifecycle)
9595
return CommandID(opid.String()), err
9696
}
9797

0 commit comments

Comments
 (0)