Compare commits

..
1 Commits
Author SHA1 Message Date
sneak 167adf8eb1 Leave remote info orphan figures unknown when a manifest is unreadable (closes #228)
check / check (push) Canceled after 0s
When a manifest could not be read, remote info skipped it, counted that
snapshot's blobs as orphaned and advised running prune. The orphan
figures are now unknown in that case, with no prune advice; --json gives
them as null and lists the unreadable remote keys in
unreadable_manifests.

Names under metadata/ were used unchecked and printed raw. A name that
is not a remote key is now skipped with a warning. Its manifest is then
not read either, so a skipped name also leaves the orphan figures
unknown; --json counts such names in skipped_metadata_names without
printing them.

The closing log line carries the unreadable manifest count in place of
the orphan count.

Model: opus-5-5
2026-10-07 05:46:08 +00:00
4 changed files with 39 additions and 103 deletions
+5 -6
View File
@@ -391,12 +391,11 @@ recipients, and local database statistics.
**`remote info`**: Show storage backend type and location plus detailed **`remote info`**: Show storage backend type and location plus detailed
remote storage inventory: per-snapshot metadata sizes, blob counts, and remote storage inventory: per-snapshot metadata sizes, blob counts, and
orphaned blob detection. A name under `metadata/` that is not a remote orphaned blob detection. A name under `metadata/` that is not a remote
key is skipped with a warning and is not printed. If a listed key is skipped with a warning and is not printed. If a manifest cannot be
`manifest.json.zst` cannot be read, or sits under a skipped name, the read, or a name was skipped, the orphaned blob figures are reported as
orphaned blob figures are reported as unknown; `--json` gives them as unknown; `--json` gives them as `null`, lists the remote key of each
`null`, lists the remote key of each unreadable manifest in unreadable manifest in `unreadable_manifests` and counts the skipped
`unreadable_manifests` and counts the manifests under skipped names in names in `skipped_metadata_names`.
`skipped_manifests`.
* `--json`: Output as JSON * `--json`: Output as JSON
**`remote nuke`**: Delete every snapshot's metadata and every blob from the **`remote nuke`**: Delete every snapshot's metadata and every blob from the
+3 -5
View File
@@ -32,11 +32,9 @@ the tag exists and is exercised; what is left is merging `next` to
advice, and `--json` gives them as `null` with the unreadable remote advice, and `--json` gives them as `null` with the unreadable remote
keys in `unreadable_manifests`. A name under `metadata/` that is not keys in `unreadable_manifests`. A name under `metadata/` that is not
64 lowercase hex characters is now skipped with a warning instead of 64 lowercase hex characters is now skipped with a warning instead of
being printed, control characters included. A manifest under a being printed, control characters included. Its manifest is then not
skipped name is then not read either, so it also leaves the orphan read either, so a skipped name also leaves the orphan figures unknown,
figures unknown, and `--json` counts such manifests in and `--json` counts such names in `skipped_metadata_names`.
`skipped_manifests`. A directory with no manifest in it, as left by an
interrupted backup, leaves the figures known.
- 2026-10-07: Made a backup notice a file rewritten with its size - 2026-10-07: Made a backup notice a file rewritten with its size
unchanged and a new mtime in the same second as the one in the index unchanged and a new mtime in the same second as the one in the index
+24 -41
View File
@@ -183,11 +183,6 @@ type SnapshotMetadataInfo struct {
TotalSize int64 `json:"total_size"` TotalSize int64 `json:"total_size"`
BlobCount int `json:"blob_count"` BlobCount int `json:"blob_count"`
BlobsSize int64 `json:"blobs_size"` BlobsSize int64 `json:"blobs_size"`
// Set when the listing holds this snapshot's manifest.json.zst. A
// backup interrupted before its manifest upload leaves a directory
// without one, which prune does not treat as a snapshot.
hasManifest bool
} }
// RemoteInfoResult contains all remote storage information // RemoteInfoResult contains all remote storage information
@@ -212,19 +207,18 @@ type RemoteInfoResult struct {
ReferencedBlobSize int64 `json:"referenced_blob_size"` ReferencedBlobSize int64 `json:"referenced_blob_size"`
// Orphaned blobs. Both stay nil (null in the JSON) when a manifest // Orphaned blobs. Both stay nil (null in the JSON) when a manifest
// was listed but not read, since that snapshot's blobs would be // could not be read or a name under metadata/ was skipped, since
// counted as orphaned. // that snapshot's blobs would be counted as orphaned.
OrphanedBlobCount *int `json:"orphaned_blob_count"` OrphanedBlobCount *int `json:"orphaned_blob_count"`
OrphanedBlobSize *int64 `json:"orphaned_blob_size"` OrphanedBlobSize *int64 `json:"orphaned_blob_size"`
// Remote key of each snapshot whose manifest could not be read // Remote key of each snapshot whose manifest could not be read
UnreadableManifests []string `json:"unreadable_manifests,omitempty"` UnreadableManifests []string `json:"unreadable_manifests,omitempty"`
// Number of manifests not read because the name above them under // Number of names under metadata/ skipped because they are not
// metadata/ is not a remote key. The names themselves are not // remote keys. The names themselves are not reported: they come
// reported: they come from the destination store and may hold // from the destination store and may hold control characters.
// control characters. SkippedMetadataNames int `json:"skipped_metadata_names,omitempty"`
SkippedManifests int `json:"skipped_manifests,omitempty"`
} }
// RemoteInfo displays information about remote storage // RemoteInfo displays information about remote storage
@@ -250,12 +244,12 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error {
v.stdoutf("Scanning snapshot metadata...\n") v.stdoutf("Scanning snapshot metadata...\n")
} }
snapshotMetadata, snapshotIDs, skippedManifests, err := v.collectSnapshotMetadata() snapshotMetadata, snapshotIDs, skippedNames, err := v.collectSnapshotMetadata()
if err != nil { if err != nil {
return err return err
} }
result.SkippedManifests = skippedManifests result.SkippedMetadataNames = skippedNames
if showText { if showText {
v.stdoutf("Downloading %d manifest(s)...\n", len(snapshotIDs)) v.stdoutf("Downloading %d manifest(s)...\n", len(snapshotIDs))
@@ -293,13 +287,13 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error {
} }
// collectSnapshotMetadata scans remote metadata and returns // collectSnapshotMetadata scans remote metadata and returns
// per-snapshot info, sorted IDs and the number of manifests it skipped // per-snapshot info, sorted IDs and the number of names it skipped
// because the name above them is not a remote key. // because they are not remote keys.
func (v *Vaultik) collectSnapshotMetadata() ( func (v *Vaultik) collectSnapshotMetadata() (
map[string]*SnapshotMetadataInfo, []string, int, error, map[string]*SnapshotMetadataInfo, []string, int, error,
) { ) {
snapshotMetadata := make(map[string]*SnapshotMetadataInfo) snapshotMetadata := make(map[string]*SnapshotMetadataInfo)
skippedManifests := 0 skippedNames := make(map[string]bool)
metadataCh := v.Storage.ListStream(v.ctx, "metadata/") metadataCh := v.Storage.ListStream(v.ctx, "metadata/")
for obj := range metadataCh { for obj := range metadataCh {
@@ -313,8 +307,6 @@ func (v *Vaultik) collectSnapshotMetadata() (
} }
snapshotID := parts[1] snapshotID := parts[1]
filename := parts[2]
isManifest := filename == "manifest.json.zst"
// The name comes from the destination store, which is not // The name comes from the destination store, which is not
// trusted, and is printed in the report. Accept it only in the // trusted, and is printed in the report. Accept it only in the
@@ -323,9 +315,7 @@ func (v *Vaultik) collectSnapshotMetadata() (
log.Warn("Skipping non-conforming key under metadata/", log.Warn("Skipping non-conforming key under metadata/",
"key", obj.Key) "key", obj.Key)
if isManifest { skippedNames[snapshotID] = true
skippedManifests++
}
continue continue
} }
@@ -336,10 +326,7 @@ func (v *Vaultik) collectSnapshotMetadata() (
info := snapshotMetadata[snapshotID] info := snapshotMetadata[snapshotID]
if isManifest { filename := parts[2]
info.hasManifest = true
}
if strings.HasPrefix(filename, "manifest") { if strings.HasPrefix(filename, "manifest") {
info.ManifestSize = obj.Size info.ManifestSize = obj.Size
} else if strings.HasPrefix(filename, "db") { } else if strings.HasPrefix(filename, "db") {
@@ -356,12 +343,12 @@ func (v *Vaultik) collectSnapshotMetadata() (
sort.Strings(snapshotIDs) sort.Strings(snapshotIDs)
return snapshotMetadata, snapshotIDs, skippedManifests, nil return snapshotMetadata, snapshotIDs, len(skippedNames), nil
} }
// collectReferencedBlobsFromManifests downloads the listed manifests // collectReferencedBlobsFromManifests downloads manifests and returns
// and returns referenced blob hashes with sizes, and the remote keys // referenced blob hashes with sizes, and the remote keys of the
// of the manifests it could not read. // manifests it could not read.
func (v *Vaultik) collectReferencedBlobsFromManifests( func (v *Vaultik) collectReferencedBlobsFromManifests(
snapshotIDs []string, snapshotMetadata map[string]*SnapshotMetadataInfo, snapshotIDs []string, snapshotMetadata map[string]*SnapshotMetadataInfo,
) (map[string]int64, []string) { ) (map[string]int64, []string) {
@@ -370,11 +357,6 @@ func (v *Vaultik) collectReferencedBlobsFromManifests(
var unreadable []string var unreadable []string
for _, snapshotID := range snapshotIDs { for _, snapshotID := range snapshotIDs {
info := snapshotMetadata[snapshotID]
if !info.hasManifest {
continue
}
// snapshotIDs here are remote keys, taken straight from the // snapshotIDs here are remote keys, taken straight from the
// metadata/ listing. downloadManifestByKey is the single reader // metadata/ listing. downloadManifestByKey is the single reader
// for remote manifests; see its doc comment. // for remote manifests; see its doc comment.
@@ -387,6 +369,7 @@ func (v *Vaultik) collectReferencedBlobsFromManifests(
continue continue
} }
info := snapshotMetadata[snapshotID]
info.BlobCount = manifest.BlobCount info.BlobCount = manifest.BlobCount
var blobsSize int64 var blobsSize int64
@@ -428,9 +411,9 @@ func (v *Vaultik) populateRemoteInfoResult(
} }
// scanRemoteBlobStorage lists all blobs on remote and computes orphan // scanRemoteBlobStorage lists all blobs on remote and computes orphan
// stats when every listed manifest was read. showText is true only // stats when every manifest was read and no name under metadata/ was
// when the human report is being printed (not --json, not --quiet), // skipped. showText is true only when the human report is being
// gating the progress line. // printed (not --json, not --quiet), gating the progress line.
func (v *Vaultik) scanRemoteBlobStorage( func (v *Vaultik) scanRemoteBlobStorage(
result *RemoteInfoResult, referencedBlobs map[string]int64, showText bool, result *RemoteInfoResult, referencedBlobs map[string]int64, showText bool,
) error { ) error {
@@ -460,7 +443,7 @@ func (v *Vaultik) scanRemoteBlobStorage(
// A blob named only by a manifest that could not be read, or by one // A blob named only by a manifest that could not be read, or by one
// under a skipped name, would be counted as orphaned, so the orphan // under a skipped name, would be counted as orphaned, so the orphan
// figures stay unknown. // figures stay unknown.
if len(result.UnreadableManifests) > 0 || result.SkippedManifests > 0 { if len(result.UnreadableManifests) > 0 || result.SkippedMetadataNames > 0 {
return nil return nil
} }
@@ -535,8 +518,8 @@ func (v *Vaultik) printRemoteInfoTable(result *RemoteInfoResult) {
if result.OrphanedBlobCount == nil { if result.OrphanedBlobCount == nil {
v.stdoutf("Orphaned (unreferenced): unknown "+ v.stdoutf("Orphaned (unreferenced): unknown "+
"(%d manifest(s) could not be read, "+ "(%d manifest(s) could not be read, "+
"%d manifest(s) under a non-conforming name skipped)\n", "%d name(s) under metadata/ skipped)\n",
len(result.UnreadableManifests), result.SkippedManifests) len(result.UnreadableManifests), result.SkippedMetadataNames)
return return
} }
+7 -51
View File
@@ -53,8 +53,7 @@ func TestRemoteInfo_UnreadableManifestLeavesOrphansUnknown(t *testing.T) {
text := env.stdout.String() text := env.stdout.String()
assert.Contains(t, text, "Orphaned (unreferenced): unknown "+ assert.Contains(t, text, "Orphaned (unreferenced): unknown "+
"(1 manifest(s) could not be read, "+ "(1 manifest(s) could not be read, 0 name(s) under metadata/ skipped)")
"0 manifest(s) under a non-conforming name skipped)")
assert.NotContains(t, text, "vaultik prune") assert.NotContains(t, text, "vaultik prune")
env.stdout.Reset() env.stdout.Reset()
@@ -71,11 +70,10 @@ func TestRemoteInfo_UnreadableManifestLeavesOrphansUnknown(t *testing.T) {
// TestRemoteInfo_SkipsNonConformingMetadataName checks that a directory // TestRemoteInfo_SkipsNonConformingMetadataName checks that a directory
// under metadata/ whose name is not a remote key is left out of the // under metadata/ whose name is not a remote key is left out of the
// report, and that the orphan figures are unknown when it holds a // report, and that the orphan figures are then unknown. The name comes
// manifest. The name comes from the destination store; printed raw, its // from the destination store; printed raw, its control characters would
// control characters would reach the terminal. Its manifest is not // reach the terminal. Its manifest is not read, so a blob only it
// read, so a blob only it references would otherwise be counted as // references would otherwise be counted as orphaned.
// orphaned.
func TestRemoteInfo_SkipsNonConformingMetadataName(t *testing.T) { func TestRemoteInfo_SkipsNonConformingMetadataName(t *testing.T) {
log.Initialize(log.Config{}) log.Initialize(log.Config{})
t.Parallel() t.Parallel()
@@ -95,8 +93,7 @@ func TestRemoteInfo_SkipsNonConformingMetadataName(t *testing.T) {
assert.NotContains(t, text, "31mred") assert.NotContains(t, text, "31mred")
assert.Contains(t, text, "Total (1 snapshots)") assert.Contains(t, text, "Total (1 snapshots)")
assert.Contains(t, text, "Orphaned (unreferenced): unknown "+ assert.Contains(t, text, "Orphaned (unreferenced): unknown "+
"(0 manifest(s) could not be read, "+ "(0 manifest(s) could not be read, 1 name(s) under metadata/ skipped)")
"1 manifest(s) under a non-conforming name skipped)")
assert.NotContains(t, text, "vaultik prune") assert.NotContains(t, text, "vaultik prune")
env.stdout.Reset() env.stdout.Reset()
@@ -112,47 +109,6 @@ func TestRemoteInfo_SkipsNonConformingMetadataName(t *testing.T) {
assert.Nil(t, doc["orphaned_blob_count"]) assert.Nil(t, doc["orphaned_blob_count"])
assert.Contains(t, doc, "orphaned_blob_size") assert.Contains(t, doc, "orphaned_blob_size")
assert.Nil(t, doc["orphaned_blob_size"]) assert.Nil(t, doc["orphaned_blob_size"])
assert.InDelta(t, 1, doc["skipped_manifests"], 0) assert.InDelta(t, 1, doc["skipped_metadata_names"], 0)
assert.NotContains(t, doc, "unreadable_manifests") assert.NotContains(t, doc, "unreadable_manifests")
} }
// TestRemoteInfo_DirectoryWithoutManifestLeavesOrphansKnown checks that
// a directory under metadata/ holding no manifest.json.zst, such as one
// left by a backup interrupted before its manifest upload, leaves the
// orphan figures known. prune does not treat such a directory as a
// snapshot and deletes the blobs the report lists as orphaned.
func TestRemoteInfo_DirectoryWithoutManifestLeavesOrphansKnown(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
env := newListEnv(t)
env.addRemote(t, listRemoteID, time.Date(2026, 3, 2, 0, 0, 0, 0, time.UTC))
addBlob(t, env.store.testStorer, testBlobHashA)
addBlob(t, env.store.testStorer, testBlobHashB)
// One directory under a remote key and one under a non-conforming
// name, each holding only a database.
names := []string{snapshot.RemoteSnapshotKey(listLocalID), "\x1b[31mred"}
for _, name := range names {
require.NoError(t, env.store.Put(context.Background(),
"metadata/"+name+"/db.zst.age",
bytes.NewReader([]byte("not a valid database"))))
}
require.NoError(t, env.v.RemoteInfo(false))
text := env.stdout.String()
assert.NotContains(t, text, "\x1b")
assert.Contains(t, text, "Orphaned (unreferenced): 1 (")
assert.Contains(t, text, "Run 'vaultik prune' to remove orphaned blobs.")
env.stdout.Reset()
require.NoError(t, env.v.RemoteInfo(true))
var doc map[string]any
require.NoError(t, json.Unmarshal(env.stdout.Bytes(), &doc))
assert.InDelta(t, 1, doc["orphaned_blob_count"], 0)
assert.NotContains(t, doc, "unreadable_manifests")
assert.NotContains(t, doc, "skipped_manifests")
}