Space keeps its scans and refreshes them: zfs diff, folder mtimes, resume after cancel

A scan is saved beside the config and read back after a restart. The next scan starts
from it: on a pool with snapshots zfs diff names what changed and only those folders are
read; elsewhere a folder whose mtime is unchanged keeps its list and only its kept files
are stat'ed again. A cancelled scan keeps what it walked and Resume reads the rest.
Forget drops the saved result. The treemap is every file a tile, grouped by folder, with
no nested boxes: the folder chains of a Linux system made a staircase of empty headers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-23 00:43:30 -07:00
co-authored by Claude Fable 5.1
parent c96d6facad
commit 53d1da08a5
11 changed files with 869 additions and 145 deletions
+87
View File
@@ -0,0 +1,87 @@
package space
import (
"context"
"os"
"path/filepath"
"testing"
"time"
)
func mk(t *testing.T, dir string, files map[string]int) {
for p, n := range files {
full := filepath.Join(dir, p)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, make([]byte, n), 0o644); err != nil {
t.Fatal(err)
}
}
}
func TestRefreshAndResume(t *testing.T) {
dir := t.TempDir()
mk(t, dir, map[string]int{"a/big1": 300 << 10, "a/small": 100, "b/c/big2": 500 << 10, "b/d/big3": 200 << 10})
res, err := Walk(context.Background(), []string{dir}, Options{}, nil)
if err != nil {
t.Fatal(err)
}
root := Find(res.Root, dir)
if root == nil || root.Files != 4 {
t.Fatalf("full walk: %+v", root)
}
want := root.Size
// grow a file in place (its directory's mtime does not change) and add a file elsewhere
time.Sleep(20 * time.Millisecond)
if err := os.WriteFile(filepath.Join(dir, "a/big1"), make([]byte, 600<<10), 0o644); err != nil {
t.Fatal(err)
}
mk(t, dir, map[string]int{"b/d/big4": 400 << 10})
res2, err := Walk(context.Background(), []string{dir}, Options{Prev: res.Root, Modes: map[string]Mode{dir: ModeMTime}}, nil)
if err != nil {
t.Fatal(err)
}
r2 := Find(res2.Root, dir)
if r2.Files != 5 || r2.Size != want+(300<<10)+(400<<10) {
t.Fatalf("mtime refresh: files %d size %d want %d (reused %d read %d)", r2.Files, r2.Size, want+(300<<10)+(400<<10), res2.Reused, res2.Read)
}
if res2.Reused == 0 || res2.Read == 0 {
t.Fatalf("mtime refresh should reuse some and read some: reused %d read %d", res2.Reused, res2.Read)
}
// diff mode: only b/d named dirty; a's growth must NOT be seen (that is the contract), b/d's new file must
dirty := map[string]bool{filepath.Join(dir, "b/d"): true}
touched := map[string]bool{dir: true, filepath.Join(dir, "b"): true}
res3, err := Walk(context.Background(), []string{dir}, Options{Prev: res.Root, Modes: map[string]Mode{dir: ModeDiff}, Dirty: dirty, Touched: touched}, nil)
if err != nil {
t.Fatal(err)
}
r3 := Find(res3.Root, dir)
if r3.Files != 5 || r3.Size != want+(400<<10) || res3.Read != 1 {
t.Fatalf("diff refresh: files %d size %d want %d read %d", r3.Files, r3.Size, want+(400<<10), res3.Read)
}
// a cancelled walk marks what it did not read; resuming reads those
ctx, cancel := context.WithCancel(context.Background())
cancel()
res4, err := Walk(ctx, []string{dir}, Options{}, nil)
if err == nil || res4.Complete {
t.Fatal("cancelled walk should say so")
}
if len(partialPaths(res4.Root, "/")) == 0 {
t.Fatal("cancelled walk should leave partial directories")
}
res5, err := Walk(context.Background(), []string{dir}, Options{Prev: res4.Root, Modes: map[string]Mode{dir: ModeMTime}}, nil)
if err != nil {
t.Fatal(err)
}
if r5 := Find(res5.Root, dir); r5.Files != 5 || len(partialPaths(res5.Root, "/")) != 0 {
t.Fatalf("resume: files %d partial %v", r5.Files, partialPaths(res5.Root, "/"))
}
// the codec round-trips the partial flag
pr, pw, _ := os.Pipe()
go func() { _ = Encode(pw, res4.Root); pw.Close() }()
back, err := Decode(pr)
if err != nil || len(partialPaths(back, "/")) != len(partialPaths(res4.Root, "/")) {
t.Fatalf("codec: %v", err)
}
}