diff --git a/cmd/litestream/main.go b/cmd/litestream/main.go index ecb383e92..25f68d163 100644 --- a/cmd/litestream/main.go +++ b/cmd/litestream/main.go @@ -50,17 +50,18 @@ var errStop = errors.New("stop") // Sentinel errors for configuration validation var ( - ErrInvalidSnapshotInterval = errors.New("snapshot interval must be greater than 0") - ErrInvalidSnapshotRetention = errors.New("snapshot retention must be greater than 0") - ErrInvalidCompactionInterval = errors.New("compaction interval must be greater than 0") - ErrInvalidSyncInterval = errors.New("sync interval must be greater than 0") - ErrInvalidL0Retention = errors.New("l0 retention must not be negative") - ErrInvalidL0RetentionCheckInterval = errors.New("l0 retention check interval must be greater than 0") - ErrInvalidShutdownSyncTimeout = errors.New("shutdown-sync-timeout must be >= 0") - ErrInvalidShutdownSyncInterval = errors.New("shutdown sync interval must be greater than 0") - ErrInvalidHeartbeatURL = errors.New("heartbeat URL must be a valid HTTP or HTTPS URL") - ErrInvalidHeartbeatInterval = errors.New("heartbeat interval must be at least 1 minute") - ErrConfigFileNotFound = errors.New("config file not found") + ErrInvalidSnapshotInterval = errors.New("snapshot interval must be greater than 0") + ErrInvalidSnapshotRetention = errors.New("snapshot retention must be greater than 0") + ErrInvalidSnapshotRetentionInterval = errors.New("snapshot retention must not be shorter than snapshot interval") + ErrInvalidCompactionInterval = errors.New("compaction interval must be greater than 0") + ErrInvalidSyncInterval = errors.New("sync interval must be greater than 0") + ErrInvalidL0Retention = errors.New("l0 retention must not be negative") + ErrInvalidL0RetentionCheckInterval = errors.New("l0 retention check interval must be greater than 0") + ErrInvalidShutdownSyncTimeout = errors.New("shutdown-sync-timeout must be >= 0") + ErrInvalidShutdownSyncInterval = errors.New("shutdown sync interval must be greater than 0") + ErrInvalidHeartbeatURL = errors.New("heartbeat URL must be a valid HTTP or HTTPS URL") + ErrInvalidHeartbeatInterval = errors.New("heartbeat interval must be at least 1 minute") + ErrConfigFileNotFound = errors.New("config file not found") ) // ConfigValidationError wraps a validation error with additional context @@ -432,6 +433,13 @@ func (c *Config) Validate() error { Value: *c.Snapshot.Retention, } } + if c.Snapshot.Interval != nil && c.Snapshot.Retention != nil && *c.Snapshot.Retention < *c.Snapshot.Interval { + return &ConfigValidationError{ + Err: ErrInvalidSnapshotRetentionInterval, + Field: "snapshot.retention", + Value: *c.Snapshot.Retention, + } + } if c.L0Retention != nil && *c.L0Retention < 0 { return &ConfigValidationError{ Err: ErrInvalidL0Retention, @@ -532,6 +540,13 @@ func (c *Config) Validate() error { Value: *db.Snapshot.Retention, } } + if db.Snapshot.Interval != nil && db.Snapshot.Retention != nil && *db.Snapshot.Retention < *db.Snapshot.Interval { + return &ConfigValidationError{ + Err: ErrInvalidSnapshotRetentionInterval, + Field: fmt.Sprintf("dbs[%s].snapshot.retention", dbIdentifier), + Value: *db.Snapshot.Retention, + } + } // Validate sync intervals for replicas if db.Replica != nil && db.Replica.SyncInterval != nil && *db.Replica.SyncInterval <= 0 { diff --git a/cmd/litestream/main_test.go b/cmd/litestream/main_test.go index d90fb0cec..99debbf24 100644 --- a/cmd/litestream/main_test.go +++ b/cmd/litestream/main_test.go @@ -711,6 +711,38 @@ snapshot: } }) + t.Run("RetentionShorterThanInterval", func(t *testing.T) { + yaml := ` +snapshot: + interval: 1h + retention: 30m +` + _, err := main.ParseConfig(strings.NewReader(yaml), false) + if err == nil { + t.Fatal("expected error for retention shorter than snapshot interval") + } + if !errors.Is(err, main.ErrInvalidSnapshotRetentionInterval) { + t.Errorf("expected ErrInvalidSnapshotRetentionInterval, got %v", err) + } + }) + + t.Run("DBRetentionShorterThanInterval", func(t *testing.T) { + yaml := ` + dbs: + - path: /tmp/test.db + snapshot: + interval: 1h + retention: 30m +` + _, err := main.ParseConfig(strings.NewReader(yaml), false) + if err == nil { + t.Fatal("expected error for database retention shorter than snapshot interval") + } + if !errors.Is(err, main.ErrInvalidSnapshotRetentionInterval) { + t.Errorf("expected ErrInvalidSnapshotRetentionInterval, got %v", err) + } + }) + t.Run("NegativeInterval", func(t *testing.T) { yaml := ` snapshot: