Add filesystem inventory model
Append-only chunked node arena with stable NodeIDs, intrusive child lists, per-directory category totals and batched ancestor propagation.
This commit is contained in:
@@ -0,0 +1,117 @@
|
|||||||
|
package inventory
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// Category is a coarse file classification used for colouring and stats.
|
||||||
|
type Category uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
CatOther Category = iota
|
||||||
|
CatImage
|
||||||
|
CatVideo
|
||||||
|
CatAudio
|
||||||
|
CatArchive
|
||||||
|
CatDiskImage
|
||||||
|
CatDatabase
|
||||||
|
CatCode
|
||||||
|
CatDocument
|
||||||
|
CatExecutable
|
||||||
|
CatLog
|
||||||
|
CatCache
|
||||||
|
NumCategories
|
||||||
|
)
|
||||||
|
|
||||||
|
var categoryNames = [NumCategories]string{
|
||||||
|
"Other", "Image", "Video", "Audio", "Archive", "Disk image", "Database",
|
||||||
|
"Source code", "Document", "Executable", "Log", "Cache",
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Category) String() string {
|
||||||
|
if c < NumCategories {
|
||||||
|
return categoryNames[c]
|
||||||
|
}
|
||||||
|
return "Other"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseCategory resolves a category by (case-insensitive) name or prefix.
|
||||||
|
func ParseCategory(s string) (Category, bool) {
|
||||||
|
s = strings.ToLower(strings.TrimSpace(s))
|
||||||
|
for i, n := range categoryNames {
|
||||||
|
ln := strings.ToLower(n)
|
||||||
|
if ln == s || strings.ReplaceAll(ln, " ", "") == s || strings.HasPrefix(ln, s) && len(s) >= 3 {
|
||||||
|
return Category(i), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch s {
|
||||||
|
case "vm", "virtual machine", "disk":
|
||||||
|
return CatDiskImage, true
|
||||||
|
case "db":
|
||||||
|
return CatDatabase, true
|
||||||
|
case "src", "source":
|
||||||
|
return CatCode, true
|
||||||
|
case "exe", "binary":
|
||||||
|
return CatExecutable, true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
var extCategory = map[string]Category{}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
add := func(c Category, exts string) {
|
||||||
|
for _, e := range strings.Fields(exts) {
|
||||||
|
extCategory[e] = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add(CatImage, "jpg jpeg png gif bmp tif tiff webp heic heif raw cr2 cr3 nef arw dng svg ico psd xcf avif jxl orf rw2")
|
||||||
|
add(CatVideo, "mp4 mkv mov avi wmv flv webm m4v mpg mpeg ts m2ts vob 3gp ogv mts")
|
||||||
|
add(CatAudio, "mp3 flac wav ogg oga opus m4a aac wma aiff aif alac mid midi")
|
||||||
|
add(CatArchive, "zip tar gz tgz bz2 tbz xz txz zst zstd 7z rar lz lz4 lzma cab deb rpm apk jar war cpio pkg snap whl gem")
|
||||||
|
add(CatDiskImage, "iso img qcow qcow2 vmdk vdi vhd vhdx hdd ova ovf dmg raw vmem vmsn nvram sav")
|
||||||
|
add(CatDatabase, "db sqlite sqlite3 db3 mdb accdb frm ibd myd myi ldf mdf ndf dbf wal kdbx realm lmdb rdb aof")
|
||||||
|
add(CatCode, "c h cc cpp cxx hpp hh go rs py pyc js mjs cjs ts tsx jsx java kt kts scala rb php pl pm sh bash zsh fish lua swift m mm cs fs vb r jl hs ml ex exs erl clj dart sql css scss sass less html htm vue svelte json yaml yml toml xml proto gradle cmake mk ipynb o a lib obj class")
|
||||||
|
add(CatDocument, "pdf doc docx odt rtf txt md rst tex xls xlsx ods csv tsv ppt pptx odp epub mobi djvu pages numbers key org adoc")
|
||||||
|
add(CatExecutable, "exe dll so dylib bin msi app elf ko sys efi appimage com")
|
||||||
|
add(CatLog, "log journal")
|
||||||
|
add(CatCache, "cache tmp temp swp bak old part crdownload")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CategoryFor classifies a file by its (lower-case) extension and by
|
||||||
|
// whether it lives beneath a cache or log directory.
|
||||||
|
func CategoryFor(ext string, inCache, inLog bool) Category {
|
||||||
|
c, ok := extCategory[ext]
|
||||||
|
switch {
|
||||||
|
case inCache && (!ok || c == CatOther || c == CatCode):
|
||||||
|
return CatCache
|
||||||
|
case inLog && (!ok || c == CatArchive || c == CatOther):
|
||||||
|
return CatLog
|
||||||
|
case ok:
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
return CatOther
|
||||||
|
}
|
||||||
|
|
||||||
|
// PathHints reports whether a directory path contains a cache or log
|
||||||
|
// component. It is evaluated once per directory, not per file.
|
||||||
|
func PathHints(path string) (inCache, inLog bool) {
|
||||||
|
lp := strings.ToLower(path)
|
||||||
|
for _, part := range strings.FieldsFunc(lp, func(r rune) bool { return r == '/' || r == '\\' }) {
|
||||||
|
switch part {
|
||||||
|
case "cache", ".cache", "caches", "__pycache__", ".npm", ".gradle", "inetcache", "temp", "tmp":
|
||||||
|
inCache = true
|
||||||
|
case "log", "logs", ".logs", "journal":
|
||||||
|
inLog = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ext returns the lower-case extension of name without the dot ("" if none,
|
||||||
|
// or if the name is a dotfile like ".bashrc").
|
||||||
|
func Ext(name string) string {
|
||||||
|
i := strings.LastIndexByte(name, '.')
|
||||||
|
if i <= 0 || i == len(name)-1 || len(name)-i > 16 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.ToLower(name[i+1:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package inventory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"container/heap"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SizeMode selects which size drives layout and sorting.
|
||||||
|
type SizeMode uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
SizeAllocated SizeMode = iota // disk usage (default)
|
||||||
|
SizeLogical // apparent size
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m SizeMode) String() string {
|
||||||
|
if m == SizeLogical {
|
||||||
|
return "logical"
|
||||||
|
}
|
||||||
|
return "allocated"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total returns the node's aggregate size in the given mode.
|
||||||
|
func (n *Node) Total(m SizeMode) int64 {
|
||||||
|
if m == SizeLogical {
|
||||||
|
return n.TotSize
|
||||||
|
}
|
||||||
|
return n.TotAlloc
|
||||||
|
}
|
||||||
|
|
||||||
|
// Own returns the node's own size in the given mode.
|
||||||
|
func (n *Node) Own(m SizeMode) int64 {
|
||||||
|
if m == SizeLogical {
|
||||||
|
return n.Size
|
||||||
|
}
|
||||||
|
return n.Alloc
|
||||||
|
}
|
||||||
|
|
||||||
|
type ranked struct {
|
||||||
|
id NodeID
|
||||||
|
key int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type minHeap []ranked
|
||||||
|
|
||||||
|
func (h minHeap) Len() int { return len(h) }
|
||||||
|
func (h minHeap) Less(i, j int) bool {
|
||||||
|
if h[i].key != h[j].key {
|
||||||
|
return h[i].key < h[j].key
|
||||||
|
}
|
||||||
|
return h[i].id > h[j].id
|
||||||
|
}
|
||||||
|
func (h minHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
|
||||||
|
func (h *minHeap) Push(x any) { *h = append(*h, x.(ranked)) }
|
||||||
|
func (h *minHeap) Pop() any {
|
||||||
|
old := *h
|
||||||
|
x := old[len(old)-1]
|
||||||
|
*h = old[:len(old)-1]
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
// TopK keeps the k entries with the largest keys; O(n log k).
|
||||||
|
type TopK struct {
|
||||||
|
k int
|
||||||
|
h minHeap
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTopK creates a collector for the k largest keys.
|
||||||
|
func NewTopK(k int) *TopK { return &TopK{k: k} }
|
||||||
|
|
||||||
|
// Offer considers one candidate.
|
||||||
|
func (t *TopK) Offer(id NodeID, key int64) {
|
||||||
|
if t.k <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(t.h) < t.k {
|
||||||
|
heap.Push(&t.h, ranked{id, key})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r := t.h[0]; key > r.key || key == r.key && id < r.id {
|
||||||
|
t.h[0] = ranked{id, key}
|
||||||
|
heap.Fix(&t.h, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sorted returns the collected IDs, largest key first (ties by ID).
|
||||||
|
func (t *TopK) Sorted() []NodeID {
|
||||||
|
rs := append([]ranked(nil), t.h...)
|
||||||
|
sort.Slice(rs, func(i, j int) bool {
|
||||||
|
if rs[i].key != rs[j].key {
|
||||||
|
return rs[i].key > rs[j].key
|
||||||
|
}
|
||||||
|
return rs[i].id < rs[j].id
|
||||||
|
})
|
||||||
|
out := make([]NodeID, len(rs))
|
||||||
|
for i, r := range rs {
|
||||||
|
out[i] = r.id
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk visits id and all its descendants depth-first (iteratively).
|
||||||
|
func (t *Tree) Walk(id NodeID, fn func(NodeID, *Node) bool) {
|
||||||
|
stack := []NodeID{id}
|
||||||
|
for len(stack) > 0 {
|
||||||
|
cur := stack[len(stack)-1]
|
||||||
|
stack = stack[:len(stack)-1]
|
||||||
|
n := t.Node(cur)
|
||||||
|
if !fn(cur, n) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for c := n.FirstChild; c != NoNode; c = t.Node(c).NextSibling {
|
||||||
|
stack = append(stack, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top returns the k nodes beneath (and including) root with the largest key,
|
||||||
|
// considering only nodes accepted by keep.
|
||||||
|
func (t *Tree) Top(root NodeID, k int, keep func(*Node) bool, key func(*Node) int64) []NodeID {
|
||||||
|
tk := NewTopK(k)
|
||||||
|
t.Walk(root, func(id NodeID, n *Node) bool {
|
||||||
|
if keep(n) {
|
||||||
|
tk.Offer(id, key(n))
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return tk.Sorted()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SortedChildren returns the children of id ordered by size descending.
|
||||||
|
func (t *Tree) SortedChildren(id NodeID, m SizeMode) []NodeID {
|
||||||
|
var out []NodeID
|
||||||
|
t.Children(id, func(c NodeID, _ *Node) { out = append(out, c) })
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
a, b := t.Node(out[i]).Total(m), t.Node(out[j]).Total(m)
|
||||||
|
if a != b {
|
||||||
|
return a > b
|
||||||
|
}
|
||||||
|
return out[i] < out[j]
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtStat summarises one extension.
|
||||||
|
type ExtStat struct {
|
||||||
|
Ext string
|
||||||
|
Cat Category
|
||||||
|
Count int64
|
||||||
|
Size int64 // logical
|
||||||
|
Alloc int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtStats aggregates file statistics by extension beneath root.
|
||||||
|
func (t *Tree) ExtStats(root NodeID) []ExtStat {
|
||||||
|
stats := make([]ExtStat, t.ExtCount())
|
||||||
|
t.Walk(root, func(_ NodeID, n *Node) bool {
|
||||||
|
if n.Kind == KindFile && n.Flags&FlagHardlinkDup == 0 {
|
||||||
|
s := &stats[n.Ext]
|
||||||
|
s.Count++
|
||||||
|
s.Size += n.Size
|
||||||
|
s.Alloc += n.Alloc
|
||||||
|
s.Cat = n.Cat
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
out := stats[:0]
|
||||||
|
for i, s := range stats {
|
||||||
|
if s.Count > 0 {
|
||||||
|
s.Ext = t.exts[i]
|
||||||
|
s.Cat = CategoryFor(s.Ext, false, false)
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package inventory
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestTopKAndRecompute(t *testing.T) {
|
||||||
|
tr := New("/r", KindDir)
|
||||||
|
d := tr.Add(0, "d", KindDir)
|
||||||
|
for i := range 100 {
|
||||||
|
id := tr.Add(d, "f", KindFile)
|
||||||
|
n := tr.Node(id)
|
||||||
|
n.Size, n.Alloc = int64(i), int64(i)
|
||||||
|
}
|
||||||
|
tr.Recompute()
|
||||||
|
if got := tr.Node(0).TotSize; got != 4950 {
|
||||||
|
t.Fatalf("total %d", got)
|
||||||
|
}
|
||||||
|
if tr.Node(0).Files != 100 || tr.Node(0).Dirs != 1 {
|
||||||
|
t.Fatalf("counts %d %d", tr.Node(0).Files, tr.Node(0).Dirs)
|
||||||
|
}
|
||||||
|
top := tr.Top(0, 3, func(n *Node) bool { return n.Kind == KindFile }, func(n *Node) int64 { return n.Size })
|
||||||
|
if len(top) != 3 || tr.Node(top[0]).Size != 99 || tr.Node(top[2]).Size != 97 {
|
||||||
|
t.Fatalf("top %v", top)
|
||||||
|
}
|
||||||
|
if p := tr.Path(top[0]); p != "/r/d/f" {
|
||||||
|
t.Fatalf("path %q", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtAndCategory(t *testing.T) {
|
||||||
|
cases := map[string]string{"a.QCOW2": "qcow2", ".bashrc": "", "noext": "", "x.tar.gz": "gz", "trail.": ""}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := Ext(in); got != want {
|
||||||
|
t.Errorf("Ext(%q)=%q want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if CategoryFor("qcow2", false, false) != CatDiskImage || CategoryFor("bin", true, false) != CatExecutable {
|
||||||
|
t.Fatal("category mapping")
|
||||||
|
}
|
||||||
|
if CategoryFor("", true, false) != CatCache {
|
||||||
|
t.Fatal("cache hint")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package inventory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrKind classifies scan errors for reporting.
|
||||||
|
type ErrKind uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
ErrPermission ErrKind = iota
|
||||||
|
ErrVanished
|
||||||
|
ErrIO
|
||||||
|
ErrLoop
|
||||||
|
ErrOther
|
||||||
|
NumErrKinds
|
||||||
|
)
|
||||||
|
|
||||||
|
func (k ErrKind) String() string {
|
||||||
|
return [...]string{"Permission denied", "Vanished during scan", "I/O error", "Symlink loop", "Other error"}[k]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classify maps an OS error to an ErrKind.
|
||||||
|
func Classify(err error) ErrKind {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, fs.ErrPermission):
|
||||||
|
return ErrPermission
|
||||||
|
case errors.Is(err, fs.ErrNotExist):
|
||||||
|
return ErrVanished
|
||||||
|
case errors.Is(err, syscall.ELOOP):
|
||||||
|
return ErrLoop
|
||||||
|
case errors.Is(err, syscall.EIO):
|
||||||
|
return ErrIO
|
||||||
|
}
|
||||||
|
return ErrOther
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorRecord is one recorded scan error.
|
||||||
|
type ErrorRecord struct {
|
||||||
|
Node NodeID // node the error is attached to (the directory for listing failures)
|
||||||
|
Name string // entry name when no node was created (e.g. vanished)
|
||||||
|
Kind ErrKind
|
||||||
|
Msg string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaxErrorRecords caps the detailed error list; counts are always exact.
|
||||||
|
const MaxErrorRecords = 100_000
|
||||||
|
|
||||||
|
// Stats describes a scan as a whole.
|
||||||
|
type Stats struct {
|
||||||
|
Root string
|
||||||
|
Start, End time.Time
|
||||||
|
Complete bool // scan finished (possibly with errors)
|
||||||
|
Cancelled bool
|
||||||
|
|
||||||
|
Files, Dirs, Symlinks, Others int64
|
||||||
|
|
||||||
|
ErrCounts [NumErrKinds]int64
|
||||||
|
Errors []ErrorRecord
|
||||||
|
Excluded int64
|
||||||
|
SkippedMounts int64
|
||||||
|
VirtualSkipped int64
|
||||||
|
LoopsSkipped int64
|
||||||
|
BrokenLinks int64
|
||||||
|
HardlinkDups int64
|
||||||
|
Unscanned int64 // directories never read because the scan was cancelled
|
||||||
|
|
||||||
|
Excludes []string
|
||||||
|
OneFileSystem bool
|
||||||
|
Follow string
|
||||||
|
Workers int
|
||||||
|
FromSnapshot string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TotalErrors sums all error kinds.
|
||||||
|
func (s *Stats) TotalErrors() int64 {
|
||||||
|
var t int64
|
||||||
|
for _, c := range s.ErrCounts {
|
||||||
|
t += c
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddError records an error (the detailed list is capped).
|
||||||
|
func (s *Stats) AddError(r ErrorRecord) {
|
||||||
|
s.ErrCounts[r.Kind]++
|
||||||
|
if len(s.Errors) < MaxErrorRecords {
|
||||||
|
s.Errors = append(s.Errors, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Elapsed is the scan duration so far (or total once finished).
|
||||||
|
func (s *Stats) Elapsed() time.Duration {
|
||||||
|
if !s.End.IsZero() {
|
||||||
|
return s.End.Sub(s.Start)
|
||||||
|
}
|
||||||
|
return time.Since(s.Start)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incomplete reports whether totals may under-count real usage.
|
||||||
|
func (s *Stats) Incomplete() bool {
|
||||||
|
return !s.Complete || s.Cancelled || s.TotalErrors() > 0 || s.Unscanned > 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
// Package inventory is the in-memory filesystem model: an append-only arena
|
||||||
|
// of nodes addressed by stable NodeIDs, with incrementally maintained
|
||||||
|
// directory aggregates.
|
||||||
|
//
|
||||||
|
// Concurrency: a Tree has exactly one writer (the scan controller or a
|
||||||
|
// snapshot loader). Other goroutines must hold RLock while reading node data.
|
||||||
|
// Nodes are never removed, so a NodeID stays valid for the Tree's lifetime.
|
||||||
|
package inventory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NodeID addresses a node within one Tree. The root is always 0.
|
||||||
|
type NodeID uint32
|
||||||
|
|
||||||
|
// NoNode is the nil NodeID.
|
||||||
|
const NoNode NodeID = ^NodeID(0)
|
||||||
|
|
||||||
|
// Kind is the node type.
|
||||||
|
type Kind uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
KindFile Kind = iota
|
||||||
|
KindDir
|
||||||
|
KindSymlink
|
||||||
|
KindOther // devices, sockets, fifos, reparse points
|
||||||
|
)
|
||||||
|
|
||||||
|
func (k Kind) String() string {
|
||||||
|
return [...]string{"File", "Directory", "Symlink", "Special"}[k&3]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flags record per-node facts.
|
||||||
|
type Flags uint16
|
||||||
|
|
||||||
|
const (
|
||||||
|
FlagHardlinkDup Flags = 1 << iota // additional link to an inode counted elsewhere
|
||||||
|
FlagMountPoint // directory on a different device than its parent
|
||||||
|
FlagVirtualFS // virtual filesystem, not descended
|
||||||
|
FlagSkippedFS // other filesystem, not descended (--one-file-system)
|
||||||
|
FlagSparse // allocated < logical (sparse or compressed)
|
||||||
|
FlagError // stat or read failed
|
||||||
|
FlagIncomplete // directory contents not (fully) read
|
||||||
|
FlagScanned // directory fully read
|
||||||
|
FlagLoop // directory already visited via another path
|
||||||
|
FlagFollowed // reached through a followed symlink
|
||||||
|
FlagAllocUnknown // platform cannot report allocated size
|
||||||
|
FlagBrokenLink // symlink target does not exist
|
||||||
|
FlagHardlinked // regular file with link count > 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// Node is one filesystem object. Sizes are own sizes; Tot* fields are
|
||||||
|
// aggregates including the node itself and (for directories) everything
|
||||||
|
// beneath it.
|
||||||
|
type Node struct {
|
||||||
|
Parent, FirstChild, NextSibling NodeID
|
||||||
|
dir uint32 // index into dir side table (directories only)
|
||||||
|
|
||||||
|
Name string // single path component; the root holds the full root path
|
||||||
|
|
||||||
|
Size, Alloc int64 // own logical / allocated bytes
|
||||||
|
TotSize, TotAlloc int64 // aggregates (hard-link duplicates contribute 0)
|
||||||
|
MTime int64 // unix nanoseconds
|
||||||
|
|
||||||
|
Files, Dirs uint32 // recursive counts beneath (dirs excludes self)
|
||||||
|
Errors uint32 // recursive error count beneath and including self
|
||||||
|
Mode uint32 // fs.FileMode bits
|
||||||
|
UID, GID uint32
|
||||||
|
Nlink uint32
|
||||||
|
|
||||||
|
Ext uint16
|
||||||
|
Flags Flags
|
||||||
|
Kind Kind
|
||||||
|
Cat Category
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDir reports whether the node is a directory.
|
||||||
|
func (n *Node) IsDir() bool { return n.Kind == KindDir }
|
||||||
|
|
||||||
|
// Delta is an aggregate change applied to a node and all its ancestors.
|
||||||
|
type Delta struct {
|
||||||
|
Size, Alloc int64
|
||||||
|
Files, Dirs, Errors int64
|
||||||
|
Cat [NumCategories]int64
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunkBits = 16
|
||||||
|
const chunkSize = 1 << chunkBits
|
||||||
|
|
||||||
|
// chunked is a growable array that never moves existing elements.
|
||||||
|
type chunked[T any] struct {
|
||||||
|
c [][]T
|
||||||
|
n int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *chunked[T]) add(v T) uint32 {
|
||||||
|
if c.n&(chunkSize-1) == 0 && c.n>>chunkBits == len(c.c) {
|
||||||
|
c.c = append(c.c, make([]T, 0, chunkSize))
|
||||||
|
}
|
||||||
|
ch := &c.c[c.n>>chunkBits]
|
||||||
|
*ch = append(*ch, v)
|
||||||
|
c.n++
|
||||||
|
return uint32(c.n - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *chunked[T]) at(i uint32) *T { return &c.c[i>>chunkBits][i&(chunkSize-1)] }
|
||||||
|
|
||||||
|
// Tree is the inventory for one scan root.
|
||||||
|
type Tree struct {
|
||||||
|
sync.RWMutex
|
||||||
|
|
||||||
|
nodes chunked[Node]
|
||||||
|
dirs chunked[[NumCategories]int64]
|
||||||
|
|
||||||
|
exts []string
|
||||||
|
extIdx map[string]uint16
|
||||||
|
|
||||||
|
Stats Stats
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a tree whose root has the given full path and kind.
|
||||||
|
func New(rootPath string, kind Kind) *Tree {
|
||||||
|
t := &Tree{exts: []string{""}, extIdx: map[string]uint16{"": 0}}
|
||||||
|
t.Stats.Root = rootPath
|
||||||
|
t.Stats.Start = time.Now()
|
||||||
|
t.Add(NoNode, rootPath, kind)
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// Len returns the number of nodes.
|
||||||
|
func (t *Tree) Len() int { return t.nodes.n }
|
||||||
|
|
||||||
|
// Node returns the node for id. The pointer stays valid; reading its fields
|
||||||
|
// concurrently with the writer requires RLock.
|
||||||
|
func (t *Tree) Node(id NodeID) *Node { return t.nodes.at(uint32(id)) }
|
||||||
|
|
||||||
|
// Root returns the root NodeID (always 0).
|
||||||
|
func (t *Tree) Root() NodeID { return 0 }
|
||||||
|
|
||||||
|
// Add appends a child node and links it under parent. Only the writer may
|
||||||
|
// call it. It does not touch aggregates; use Propagate.
|
||||||
|
func (t *Tree) Add(parent NodeID, name string, kind Kind) NodeID {
|
||||||
|
n := Node{Parent: parent, FirstChild: NoNode, NextSibling: NoNode, Name: name, Kind: kind}
|
||||||
|
if kind == KindDir {
|
||||||
|
n.dir = t.dirs.add([NumCategories]int64{})
|
||||||
|
} else {
|
||||||
|
n.Ext = t.internExt(Ext(name))
|
||||||
|
}
|
||||||
|
id := NodeID(t.nodes.add(n))
|
||||||
|
if parent != NoNode {
|
||||||
|
p := t.Node(parent)
|
||||||
|
t.Node(id).NextSibling = p.FirstChild
|
||||||
|
p.FirstChild = id
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tree) internExt(e string) uint16 {
|
||||||
|
if i, ok := t.extIdx[e]; ok {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
if len(t.exts) >= 1<<16-1 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
t.exts = append(t.exts, e)
|
||||||
|
t.extIdx[e] = uint16(len(t.exts) - 1)
|
||||||
|
return uint16(len(t.exts) - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtName returns the extension string for a node ("" if none).
|
||||||
|
func (t *Tree) ExtName(n *Node) string { return t.exts[n.Ext] }
|
||||||
|
|
||||||
|
// ExtCount returns the number of interned extensions (including "").
|
||||||
|
func (t *Tree) ExtCount() int { return len(t.exts) }
|
||||||
|
|
||||||
|
// ExtByIndex returns an interned extension.
|
||||||
|
func (t *Tree) ExtByIndex(i int) string { return t.exts[i] }
|
||||||
|
|
||||||
|
// CatSizes returns the per-category allocated bytes beneath a directory.
|
||||||
|
func (t *Tree) CatSizes(id NodeID) *[NumCategories]int64 {
|
||||||
|
n := t.Node(id)
|
||||||
|
if n.Kind != KindDir {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return t.dirs.at(n.dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Propagate adds d to id and every ancestor. Cost is O(depth); callers batch
|
||||||
|
// many entries into one Delta.
|
||||||
|
func (t *Tree) Propagate(id NodeID, d Delta) {
|
||||||
|
for id != NoNode {
|
||||||
|
n := t.Node(id)
|
||||||
|
n.TotSize += d.Size
|
||||||
|
n.TotAlloc += d.Alloc
|
||||||
|
n.Files = addU32(n.Files, d.Files)
|
||||||
|
n.Dirs = addU32(n.Dirs, d.Dirs)
|
||||||
|
n.Errors = addU32(n.Errors, d.Errors)
|
||||||
|
if n.Kind == KindDir {
|
||||||
|
cs := t.dirs.at(n.dir)
|
||||||
|
for i, v := range d.Cat {
|
||||||
|
cs[i] += v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
id = n.Parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addU32(a uint32, d int64) uint32 {
|
||||||
|
v := int64(a) + d
|
||||||
|
if v < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if v > 1<<32-1 {
|
||||||
|
return 1<<32 - 1
|
||||||
|
}
|
||||||
|
return uint32(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Children calls fn for each child of id (in reverse insertion order).
|
||||||
|
func (t *Tree) Children(id NodeID, fn func(NodeID, *Node)) {
|
||||||
|
for c := t.Node(id).FirstChild; c != NoNode; {
|
||||||
|
n := t.Node(c)
|
||||||
|
fn(c, n)
|
||||||
|
c = n.NextSibling
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChildCount returns the number of direct children.
|
||||||
|
func (t *Tree) ChildCount(id NodeID) int {
|
||||||
|
k := 0
|
||||||
|
for c := t.Node(id).FirstChild; c != NoNode; c = t.Node(c).NextSibling {
|
||||||
|
k++
|
||||||
|
}
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path reconstructs the full path of id.
|
||||||
|
func (t *Tree) Path(id NodeID) string {
|
||||||
|
var parts []string
|
||||||
|
for id != NoNode {
|
||||||
|
n := t.Node(id)
|
||||||
|
parts = append(parts, n.Name)
|
||||||
|
id = n.Parent
|
||||||
|
}
|
||||||
|
slices.Reverse(parts)
|
||||||
|
return filepath.Join(parts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ancestors returns the chain root..id inclusive.
|
||||||
|
func (t *Tree) Ancestors(id NodeID) []NodeID {
|
||||||
|
var out []NodeID
|
||||||
|
for id != NoNode {
|
||||||
|
out = append(out, id)
|
||||||
|
id = t.Node(id).Parent
|
||||||
|
}
|
||||||
|
slices.Reverse(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAncestor reports whether a is id or an ancestor of id.
|
||||||
|
func (t *Tree) IsAncestor(a, id NodeID) bool {
|
||||||
|
for id != NoNode {
|
||||||
|
if id == a {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
id = t.Node(id).Parent
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Depth returns the number of ancestors of id.
|
||||||
|
func (t *Tree) Depth(id NodeID) int {
|
||||||
|
d := 0
|
||||||
|
for id = t.Node(id).Parent; id != NoNode; id = t.Node(id).Parent {
|
||||||
|
d++
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recompute rebuilds all aggregates bottom-up. It relies on the invariant
|
||||||
|
// that a parent's ID is smaller than its children's, which both the scanner
|
||||||
|
// and the snapshot loader guarantee. Used after loading a snapshot.
|
||||||
|
func (t *Tree) Recompute() {
|
||||||
|
for i := 0; i < t.Len(); i++ {
|
||||||
|
n := t.Node(NodeID(i))
|
||||||
|
n.TotSize, n.TotAlloc, n.Files, n.Dirs = 0, 0, 0, 0
|
||||||
|
n.Errors = 0
|
||||||
|
if n.Flags&FlagError != 0 {
|
||||||
|
n.Errors = 1
|
||||||
|
}
|
||||||
|
if n.Flags&FlagHardlinkDup == 0 {
|
||||||
|
n.TotSize, n.TotAlloc = n.Size, n.Alloc
|
||||||
|
}
|
||||||
|
if n.Kind == KindDir {
|
||||||
|
*t.dirs.at(n.dir) = [NumCategories]int64{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := t.Len() - 1; i > 0; i-- {
|
||||||
|
n := t.Node(NodeID(i))
|
||||||
|
p := t.Node(n.Parent)
|
||||||
|
p.TotSize += n.TotSize
|
||||||
|
p.TotAlloc += n.TotAlloc
|
||||||
|
p.Errors += n.Errors
|
||||||
|
cs := t.dirs.at(p.dir)
|
||||||
|
if n.Kind == KindDir {
|
||||||
|
p.Files += n.Files
|
||||||
|
p.Dirs += n.Dirs + 1
|
||||||
|
for c, v := range t.dirs.at(n.dir) {
|
||||||
|
cs[c] += v
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
p.Files++
|
||||||
|
if n.Flags&FlagHardlinkDup == 0 {
|
||||||
|
cs[n.Cat] += n.Alloc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtIndexer returns a function interning extension strings; used by
|
||||||
|
// loaders that rebuild a tree from external data.
|
||||||
|
func (t *Tree) ExtIndexer() func(string) uint16 { return t.internExt }
|
||||||
Reference in New Issue
Block a user