Record each file's size on disk and print it in trees (closes #147)
check / check (push) Waiting to run
check / check (push) Waiting to run
The files table gets a disk_size column: the file's allocated blocks, st_blocks × 512, from the lstat the walk already does. scan writes it with every record it writes. When a rescan finds a file with its size and mtime unchanged but a different disk_size, it updates disk_size alone, without reading the file. trees adds disk_size up per directory and prints it as a column after size. On a compressed or sparse file it is less than size, so two copies of one tree can differ; each row shows the dupe copy's. Judgement calls: a row's disk_size is the dupe copy's, not the first's. A file whose record gets only a new disk_size counts as unchanged. Model: opus-5-5
This commit is contained in:
@@ -52,23 +52,26 @@ const workQueueDepth = 1024
|
||||
// has already printed its line, so run prints nothing more.
|
||||
var errInterrupted = errors.New("scan interrupted")
|
||||
|
||||
// fileRec carries one statted file between the scan phases. dev and
|
||||
// ino identify the underlying inode so hard-linked paths can share
|
||||
// one read; both are zero when the platform exposes no inode.
|
||||
// fileRec carries one statted file between the scan phases. diskSize is
|
||||
// the bytes the file takes on disk. dev and ino identify the underlying
|
||||
// inode so hard-linked paths can share one read; both are zero when the
|
||||
// platform exposes no inode.
|
||||
type fileRec struct {
|
||||
path string
|
||||
size int64
|
||||
mtime time.Time
|
||||
dev uint64
|
||||
ino uint64
|
||||
path string
|
||||
size int64
|
||||
diskSize int64
|
||||
mtime time.Time
|
||||
dev uint64
|
||||
ino uint64
|
||||
}
|
||||
|
||||
// fileMeta is the in-memory index entry for one existing database
|
||||
// record: just enough for change detection. Hashes stay on disk; at
|
||||
// record: what the walk compares the file with. Hashes stay on disk; at
|
||||
// tens of millions of records they would dominate the scan's memory.
|
||||
type fileMeta struct {
|
||||
size int64
|
||||
mtime time.Time
|
||||
size int64
|
||||
diskSize int64
|
||||
mtime time.Time
|
||||
}
|
||||
|
||||
// scanUnit is what every scan phase counts, as its progress display
|
||||
@@ -339,11 +342,12 @@ type scanStats struct {
|
||||
// whatever remains after the walk and hash phases is deleted by the
|
||||
// update phase.
|
||||
type scanState struct {
|
||||
db *sql.DB
|
||||
existing map[string]fileMeta
|
||||
toHash []fileRec // new and changed files: must be read
|
||||
batch []scanRec
|
||||
st scanStats
|
||||
db *sql.DB
|
||||
existing map[string]fileMeta
|
||||
toHash []fileRec // new and changed files: must be read
|
||||
diskSizes []fileRec // unchanged files whose disk_size changed
|
||||
batch []scanRec
|
||||
st scanStats
|
||||
}
|
||||
|
||||
// syncScan synchronizes the database with the filesystem under roots;
|
||||
@@ -472,24 +476,24 @@ func (s *scanState) loadIndex(ctx context.Context, roots []string) error {
|
||||
|
||||
s.existing = make(map[string]fileMeta)
|
||||
|
||||
return loadFileMeta(ctx, s.db,
|
||||
func(path string, size int64, mtime time.Time) {
|
||||
prog.increment()
|
||||
return loadFileMeta(ctx, s.db, func(path string, m fileMeta) {
|
||||
prog.increment()
|
||||
|
||||
if underAnyRoot(path, roots) {
|
||||
s.existing[path] = fileMeta{size: size, mtime: mtime}
|
||||
}
|
||||
})
|
||||
if underAnyRoot(path, roots) {
|
||||
s.existing[path] = m
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// walkPhase drains the walk and queues every new or changed file for
|
||||
// the hash phase. An unchanged file needs nothing more, and its record
|
||||
// counts as verified. A record at or below a path the walk left out,
|
||||
// because -x stopped at it, it is a default exclude, or it is not on a
|
||||
// persistent filesystem, counts as outside the roots: it is not
|
||||
// deleted. That does not hold below a root at or inside that path,
|
||||
// since that root's own walk verifies the records below it; nor is a
|
||||
// line printed for such a root.
|
||||
// the hash phase. An unchanged file is not read, and its record counts
|
||||
// as verified. When its disk_size differs from the record's, it is
|
||||
// queued for the update phase, which writes the new disk_size. A record
|
||||
// at or below a path the walk left out, because -x stopped at it, it is
|
||||
// a default exclude, or it is not on a persistent filesystem, counts as
|
||||
// outside the roots: it is not deleted. That does not hold below a root
|
||||
// at or inside that path, since that root's own walk verifies the
|
||||
// records below it; nor is a line printed for such a root.
|
||||
func (s *scanState) walkPhase(events <-chan walkEvent, roots []string) {
|
||||
// The records the last scan left under the roots estimate how many
|
||||
// files the walk will find. A first scan has none, and no estimate.
|
||||
@@ -547,6 +551,13 @@ func (s *scanState) walkPhase(events <-chan walkEvent, roots []string) {
|
||||
delete(s.existing, ev.rec.path)
|
||||
|
||||
s.st.unchanged++
|
||||
|
||||
// The space a file takes on disk can change while its size and
|
||||
// mtime stay the same. ZFS, for one, counts a new file's blocks
|
||||
// only seconds after the write.
|
||||
if old.diskSize != ev.rec.diskSize {
|
||||
s.diskSizes = append(s.diskSizes, ev.rec)
|
||||
}
|
||||
}
|
||||
|
||||
prog.finish()
|
||||
@@ -695,12 +706,13 @@ func (s *scanState) recordRun(ctx context.Context, r hashResult) error {
|
||||
s.resolve(rec.path)
|
||||
|
||||
s.batch = append(s.batch, scanRec{
|
||||
size: rec.size,
|
||||
mtime: rec.mtime,
|
||||
head: r.head,
|
||||
tail: r.tail,
|
||||
content: r.content,
|
||||
path: rec.path,
|
||||
size: rec.size,
|
||||
diskSize: rec.diskSize,
|
||||
mtime: rec.mtime,
|
||||
head: r.head,
|
||||
tail: r.tail,
|
||||
content: r.content,
|
||||
path: rec.path,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -716,7 +728,7 @@ func (s *scanState) commitFullBatch(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := applyBatch(ctx, s.db, s.batch, nil, nil)
|
||||
err := applyBatch(ctx, s.db, s.batch, nil, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -727,9 +739,10 @@ func (s *scanState) commitFullBatch(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// updatePhase writes the scan's tail under one progress display: the
|
||||
// final partial batch of hashed records, and deletions for every record
|
||||
// the scan did not verify (vanished files, plus paths that failed to
|
||||
// stat or hash).
|
||||
// final partial batch of hashed records, the new disk_size of every
|
||||
// unchanged file whose disk_size changed, and deletions for every
|
||||
// record the scan did not verify (vanished files, plus paths that
|
||||
// failed to stat or hash).
|
||||
func (s *scanState) updatePhase(ctx context.Context) error {
|
||||
deletes := make([]string, 0, len(s.existing))
|
||||
for path := range s.existing {
|
||||
@@ -741,7 +754,7 @@ func (s *scanState) updatePhase(ctx context.Context) error {
|
||||
|
||||
s.st.removed = len(deletes)
|
||||
|
||||
total := len(s.batch) + len(deletes)
|
||||
total := len(s.batch) + len(s.diskSizes) + len(deletes)
|
||||
prog := newProgress(progressParams{
|
||||
label: scanPhase("update"), unit: scanUnit, total: int64(total),
|
||||
stalledOn: "db",
|
||||
@@ -756,6 +769,11 @@ func (s *scanState) updatePhase(ctx context.Context) error {
|
||||
|
||||
s.batch = nil
|
||||
|
||||
err = applyDiskSizes(ctx, s.db, s.diskSizes, prog)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return applyChanges(ctx, s.db, nil, deletes, prog)
|
||||
}
|
||||
|
||||
@@ -961,11 +979,12 @@ func seedRoot(ctx context.Context, root string, rules walkRules,
|
||||
|
||||
dev, ino := inodeOfInfo(fi)
|
||||
rec := fileRec{
|
||||
path: root,
|
||||
size: fi.Size(),
|
||||
mtime: fi.ModTime(),
|
||||
dev: dev,
|
||||
ino: ino,
|
||||
path: root,
|
||||
size: fi.Size(),
|
||||
diskSize: diskSizeOfInfo(fi),
|
||||
mtime: fi.ModTime(),
|
||||
dev: dev,
|
||||
ino: ino,
|
||||
}
|
||||
|
||||
if !isDatabaseFile(rec, rules) {
|
||||
@@ -1145,11 +1164,12 @@ func emitFile(ctx context.Context, p string, e fs.DirEntry,
|
||||
|
||||
dev, ino := inodeOfInfo(info)
|
||||
rec := fileRec{
|
||||
path: p,
|
||||
size: info.Size(),
|
||||
mtime: info.ModTime(),
|
||||
dev: dev,
|
||||
ino: ino,
|
||||
path: p,
|
||||
size: info.Size(),
|
||||
diskSize: diskSizeOfInfo(info),
|
||||
mtime: info.ModTime(),
|
||||
dev: dev,
|
||||
ino: ino,
|
||||
}
|
||||
|
||||
if !isDatabaseFile(rec, rules) {
|
||||
@@ -1241,6 +1261,23 @@ func inodeOfInfo(fi fs.FileInfo) (uint64, uint64) {
|
||||
return statDev(st), st.Ino
|
||||
}
|
||||
|
||||
// statBlockSize is the unit of st_blocks in an lstat result on Linux and
|
||||
// macOS, whatever the filesystem's own block size.
|
||||
const statBlockSize = 512
|
||||
|
||||
// diskSizeOfInfo returns the bytes a file takes on disk: its allocated
|
||||
// blocks, st_blocks × 512. On a compressed or sparse file it is less
|
||||
// than the file's size. It is 0 when the platform exposes no block
|
||||
// count.
|
||||
func diskSizeOfInfo(fi fs.FileInfo) int64 {
|
||||
st, ok := fi.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
|
||||
return st.Blocks * statBlockSize
|
||||
}
|
||||
|
||||
// hashResult carries the hashes computed for one inode run (or the
|
||||
// error that prevented computing them) from the pool's workers to the
|
||||
// hash phase. Its run leaves out the paths found removed.
|
||||
|
||||
Reference in New Issue
Block a user