Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/releases/v26.09.5.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
arcli 26.09.5 shows what a backup lacks, now that Arc 26.09.3 reports it on every backup endpoint.

### Changed

- `arcli backup list` gains an `INCOMPLETE` column: `-` when the server reports nothing missing, otherwise the counts the server keeps apart, such as `3 skipped, 1 metadata, 2 unaddressable` (skipped: data files inventoried but not stored; metadata: Iceberg metadata or compaction recovery state that was skipped; unaddressable: files whose key no listing can return, so they were never inventoried). The csv output appends three columns after `total_size_bytes`: `skipped_files`, `skipped_metadata_files`, `unaddressable_files`; scripts that take the last column by position should be updated. `-o json` is unchanged.
- `arcli backup show` names the files: its `INCOMPLETE` line now says how many files of the total and how many metadata files were skipped, how many of those were skipped for a key too long to store, how many files could not be listed, and how many files of an outside-root Iceberg warehouse were skipped, followed by one `skipped:` line per file the manifest names (up to 32) and one `unaddressable:` line per file in that sample.
- `arcli backup status`, and the final status that `arcli backup create --wait` and `arcli backup restore --wait` print, list the skipped files the server names and report files a backup could not list (their names are in the manifest once the backup has completed, otherwise in the server log). For a restore, failed or completed, they say what the restored backup already lacked when it was taken and how many Iceberg warehouse files were not restored because the node has no outside-root warehouse. `create --wait` ends its success line with every gap the server reports, prints the names, and points at `arcli backup show` for the full breakdown.
- `arcli backup restore` warns before restoring a backup that lacks files for any reason the manifest records (skipped data or metadata files, files that could not be listed, outside-root Iceberg warehouse files), not only skipped data files, and says how many of the skipped files had keys too long to store.

### Notes

- Against an Arc older than 26.09.3 every one of these reads as "none reported": `-` in the listing, no extra lines elsewhere. That is not a guarantee of completeness; it is what the server knew.
74 changes: 56 additions & 18 deletions internal/client/backup.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,23 @@ func (e *BackupBusyError) Error() string {
}

// BackupSummary is one entry of GET /api/v1/backup/.
//
// The three incompleteness counts arrive from Arc 26.09.3 on and are absent
// (zero) from older servers; each is its own population on the server:
// SkippedFiles are data files inside TotalFiles that were not stored,
// SkippedMetadataFiles are Iceberg metadata or compaction recovery state
// (not inside TotalFiles), UnaddressableFiles were never inventoried because
// no listing can return their key.
type BackupSummary struct {
BackupID string `json:"backup_id"`
CreatedAt time.Time `json:"created_at"`
BackupType string `json:"backup_type"`
TotalFiles int64 `json:"total_files"`
TotalBytes int64 `json:"total_size_bytes"`
DatabaseCount int `json:"database_count"`
BackupID string `json:"backup_id"`
CreatedAt time.Time `json:"created_at"`
BackupType string `json:"backup_type"`
TotalFiles int64 `json:"total_files"`
TotalBytes int64 `json:"total_size_bytes"`
DatabaseCount int `json:"database_count"`
SkippedFiles int64 `json:"skipped_files,omitempty"`
SkippedMetadataFiles int64 `json:"skipped_metadata_files,omitempty"`
UnaddressableFiles int64 `json:"unaddressable_files,omitempty"`
}

// BackupMeasurement / BackupDatabase / BackupManifest mirror
Expand All @@ -61,19 +71,34 @@ type BackupDatabase struct {
SizeBytes int64 `json:"size_bytes"`
}

// BackupIcebergWarehouse mirrors the manifest's iceberg_warehouse object: an
// Iceberg warehouse outside the storage root, copied separately.
type BackupIcebergWarehouse struct {
Path string `json:"path"`
FileCount int64 `json:"file_count"`
SizeBytes int64 `json:"size_bytes"`
SkippedFiles int64 `json:"skipped_files,omitempty"`
}

type BackupManifest struct {
Version string `json:"version"`
BackupID string `json:"backup_id"`
CreatedAt time.Time `json:"created_at"`
BackupType string `json:"backup_type"`
Databases []BackupDatabase `json:"databases"`
TotalFiles int64 `json:"total_files"`
TotalSizeBytes int64 `json:"total_size_bytes"`
SkippedFiles int64 `json:"skipped_files,omitempty"`
HasMetadata bool `json:"has_metadata"`
HasIcebergCatalog bool `json:"has_iceberg_catalog,omitempty"`
HasConfig bool `json:"has_config"`
Raw json.RawMessage `json:"-"`
Version string `json:"version"`
BackupID string `json:"backup_id"`
CreatedAt time.Time `json:"created_at"`
BackupType string `json:"backup_type"`
Databases []BackupDatabase `json:"databases"`
TotalFiles int64 `json:"total_files"`
TotalSizeBytes int64 `json:"total_size_bytes"`
SkippedFiles int64 `json:"skipped_files,omitempty"`
SkippedMetadataFiles int64 `json:"skipped_metadata_files,omitempty"`
SkippedSample []string `json:"skipped_sample,omitempty"`
SkippedOverlongKeys int64 `json:"skipped_overlong_keys,omitempty"`
UnaddressableFiles int64 `json:"unaddressable_files,omitempty"`
UnaddressableSample []string `json:"unaddressable_sample,omitempty"`
IcebergWarehouse *BackupIcebergWarehouse `json:"iceberg_warehouse,omitempty"`
HasMetadata bool `json:"has_metadata"`
HasIcebergCatalog bool `json:"has_iceberg_catalog,omitempty"`
HasConfig bool `json:"has_config"`
Raw json.RawMessage `json:"-"`
}

// BackupProgress is GET /api/v1/backup/status when an operation has run
Expand All @@ -91,6 +116,19 @@ type BackupProgress struct {
StartedAt time.Time `json:"started_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Error string `json:"error,omitempty"`
// SkippedSample names up to 32 of the skipped files: for a backup (Arc
// 26.09.3+) its skipped data and Iceberg metadata files, for a restore the
// backup objects it could not read. UnaddressableFiles is published for
// both operations; its sample only for a restore.
SkippedSample []string `json:"skipped_sample,omitempty"`
UnaddressableFiles int64 `json:"unaddressable_files,omitempty"`
UnaddressableSample []string `json:"unaddressable_sample,omitempty"`
// Restore only: what the restored backup already lacked when it was taken,
// and warehouse files left out because this node has no outside-root
// Iceberg warehouse.
BackupSkippedFiles int64 `json:"backup_skipped_files,omitempty"`
BackupUnaddressableFiles int64 `json:"backup_unaddressable_files,omitempty"`
IcebergWarehouseFilesSkipped int64 `json:"iceberg_warehouse_files_skipped,omitempty"`
}

// BackupStatus is the decoded status endpoint: Idle when the server has
Expand Down
58 changes: 58 additions & 0 deletions internal/client/backup_incomplete_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package client

import (
"context"
"net/http"
"strings"
"testing"
)

// Arc 26.09.3 reports a backup's incompleteness on every endpoint (arc#977);
// the client must decode each count and sample under the server's keys and
// read zero where an older server omits them.
func TestBackup_IncompleteShapes(t *testing.T) {
long := "mydb/cpu/2026/09/17/00/" + strings.Repeat("x", 960) + ".parquet"
cli, _ := newAuthTestClient(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/backup/":
_, _ = w.Write([]byte(`{"backups":[{"backup_id":"backup-20260917-000000-aaaaaaaa","created_at":"2026-09-17T00:00:00Z","backup_type":"full","total_files":21,"total_size_bytes":2086,"database_count":1,"skipped_files":3,"skipped_metadata_files":1,"unaddressable_files":2},{"backup_id":"backup-20260916-000000-bbbbbbbb","created_at":"2026-09-16T00:00:00Z","backup_type":"full","total_files":20,"total_size_bytes":2000,"database_count":1}],"count":2}`))
case "/api/v1/backup/backup-20260917-000000-aaaaaaaa":
_, _ = w.Write([]byte(`{"version":"26.09.3","backup_id":"backup-20260917-000000-aaaaaaaa","created_at":"2026-09-17T00:00:00Z","backup_type":"full","databases":[{"name":"mydb","measurements":[{"name":"cpu","file_count":21,"size_bytes":2086}],"file_count":21,"size_bytes":2086}],"total_files":21,"total_size_bytes":2086,"skipped_files":3,"skipped_metadata_files":1,"skipped_sample":["mydb/cpu/2026/09/17/00/a.parquet","mydb/cpu/2026/09/17/00/b.parquet","` + long + `","mydb/cpu/metadata/00003-5f2c.metadata.json"],"skipped_overlong_keys":1,"unaddressable_files":2,"unaddressable_sample":["mydb/cpu/2026/09/17/00/.hidden.parquet","mydb/cpu/2026/09/17/00/bad key.parquet"],"iceberg_warehouse":{"path":"/srv/wh","file_count":4,"size_bytes":100,"skipped_files":1},"has_metadata":true,"has_config":false}`))
case "/api/v1/backup/status":
_, _ = w.Write([]byte(`{"operation":"restore","backup_id":"backup-20260917-000000-aaaaaaaa","status":"failed","total_files":17,"processed_files":16,"skipped_files":1,"unaddressable_files":1,"unaddressable_sample":["backup-20260917-000000-aaaaaaaa/data/mydb/cpu/2026/09/17/00/.hidden.parquet"],"skipped_sample":["backup-20260917-000000-aaaaaaaa/data/mydb/cpu/2026/09/17/00/c.parquet"],"missing_files":1,"backup_skipped_files":3,"backup_unaddressable_files":2,"iceberg_warehouse_files_skipped":4,"total_bytes":2086,"processed_bytes":1600,"started_at":"2026-09-17T01:00:00Z","completed_at":"2026-09-17T01:00:02Z","error":"restore incomplete: 1 objects could not be read from backup storage (skipped_sample)"}`))
}
})
ctx := context.Background()
list, _, err := cli.ListBackups(ctx)
if err != nil || len(list) != 2 {
t.Fatalf("list=%+v err=%v", list, err)
}
if list[0].SkippedFiles != 3 || list[0].SkippedMetadataFiles != 1 || list[0].UnaddressableFiles != 2 {
t.Errorf("incomplete entry = %+v", list[0])
}
if list[1].SkippedFiles != 0 || list[1].SkippedMetadataFiles != 0 || list[1].UnaddressableFiles != 0 {
t.Errorf("entry without the keys must read zero: %+v", list[1])
}
m, err := cli.GetBackup(ctx, "backup-20260917-000000-aaaaaaaa")
if err != nil {
t.Fatal(err)
}
if m.SkippedFiles != 3 || m.SkippedMetadataFiles != 1 || m.SkippedOverlongKeys != 1 || m.UnaddressableFiles != 2 {
t.Errorf("manifest counts = %+v", m)
}
if len(m.SkippedSample) != 4 || m.SkippedSample[2] != long || len(m.UnaddressableSample) != 2 {
t.Errorf("manifest samples = %v / %v", m.SkippedSample, m.UnaddressableSample)
}
if m.IcebergWarehouse == nil || m.IcebergWarehouse.SkippedFiles != 1 || m.IcebergWarehouse.Path != "/srv/wh" {
t.Errorf("iceberg warehouse = %+v", m.IcebergWarehouse)
}
st, err := cli.BackupStatus(ctx)
if err != nil || st.Progress == nil {
t.Fatalf("status=%+v err=%v", st, err)
}
p := st.Progress
if len(p.SkippedSample) != 1 || len(p.UnaddressableSample) != 1 || p.UnaddressableFiles != 1 ||
p.BackupSkippedFiles != 3 || p.BackupUnaddressableFiles != 2 || p.IcebergWarehouseFilesSkipped != 4 {
t.Errorf("restore progress = %+v", p)
}
}
Loading
Loading