Keep unreferenced blobs newer than prune_grace_period (closes #387)
check / check (push) Waiting to run

A backup uploads its blobs before the manifest that lists them, and the
process lock covers one user on one machine, so a prune from another host
or user sharing the destination deleted a running backup's blobs.

Prune now deletes an unreferenced blob only once its listed time is older
than the new config key prune_grace_period (default 24h; a negative value
is refused). A newer blob, or one listed with no time, is kept and counted
(blobs_too_new). The rclone backend takes each object's time from the
listing (use_server_modtime).

Decision: a blob a backup reuses from an earlier run that stopped before its manifest keeps its old time and is not protected; the docs say so.

Model: opus-5-5
This commit is contained in:
2026-10-14 09:38:23 +00:00
parent 2590f49583
commit d3acfc5ca1
21 changed files with 518 additions and 52 deletions
+17
View File
@@ -11,6 +11,7 @@ import (
"path/filepath"
"sort"
"strings"
"time"
"filippo.io/age"
"git.eeqj.de/sneak/smartconfig"
@@ -38,6 +39,7 @@ const (
defaultChunkSize = Size(10 * 1000 * 1000) // 10MB
defaultS3PartSize = Size(5 * 1024 * 1024) // 5MiB
defaultCompressionLevel = 3
defaultPruneGracePeriod = 24 * time.Hour
minChunkSize = 1000 * 1000 // 1MB
minCompressionLevel = 1
maxCompressionLevel = 19
@@ -67,6 +69,8 @@ var (
errFileURLNotAbsolute = errors.New(
"a file:// storage_url needs an absolute path, " +
"as in file:///path/to/backup")
errNegativePruneGracePeriod = errors.New(
"prune_grace_period must not be negative")
errStorageNotConfigured = errors.New(
"storage not configured; set storage_url or provide s3.endpoint + " +
"s3.bucket + credentials")
@@ -187,6 +191,13 @@ type Config struct {
Snapshots map[string]SnapshotConfig `yaml:"snapshots"`
CompressionLevel int `yaml:"compression_level"`
// PruneGracePeriod is how old a blob no manifest lists must be before
// a prune deletes it. A backup lists its blobs in a manifest only
// once it has uploaded them all, so a younger one may belong to a
// backup still running, on this host or another sharing the
// destination store.
PruneGracePeriod time.Duration `yaml:"prune_grace_period"`
// StorageURL specifies the storage backend using a URL format.
// Takes precedence over S3Config if set.
// Supported formats:
@@ -288,6 +299,7 @@ func Parse(data []byte) (*Config, error) {
ChunkSize: defaultChunkSize,
IndexPath: filepath.Join(xdg.DataHome, appName, "index.sqlite"),
CompressionLevel: defaultCompressionLevel,
PruneGracePeriod: defaultPruneGracePeriod,
S3: S3Config{PartSize: defaultS3PartSize},
}
@@ -356,6 +368,7 @@ func Parse(data []byte) (*Config, error) {
// exceeds the configured limit
// - Compression level must be between 1 and 19
// - S3 part size must be between 5MiB and 5GiB, the part sizes S3 accepts
// - Prune grace period must not be negative
//
// Returns an error describing the first validation failure encountered.
func (c *Config) Validate() error {
@@ -405,6 +418,10 @@ func (c *Config) Validate() error {
return errBadS3PartSize
}
if c.PruneGracePeriod < 0 {
return errNegativePruneGracePeriod
}
return nil
}