1 Commits
Author SHA1 Message Date
sneak e7538ef7bd Rewrite README.md sentence by sentence for a newcomer
check / check (push) Canceled after 0s
The old README was a specification written as one dense document:
sentences past 30 words, lists chained with commas, exceptions ahead
of the rules, and release history in the user text. This rewrite
leads with what the program is, what it is for and how to use it,
then gives a reference for the settings, each subcommand, the
database, the output formats, the progress display and the exit
codes. Non-goals come before the reference; the constraints, the
entrypoints and the build come last. Every statement was checked
against the code on next and against what the built binary prints on
a small tree. The section names the code comments cite are kept, and
five comments in main_test.go now cite Settings. TODO.md records the
step.

Model: fable-5-1
2026-10-26 11:37:20 +00:00
13 changed files with 1182 additions and 1748 deletions
+975 -927
View File
File diff suppressed because it is too large Load Diff
+6 -19
View File
@@ -29,25 +29,12 @@
# Completed Steps # Completed Steps
- each record holds `disk_size`, the bytes the file takes on disk: its allocated - `README.md` rewritten from scratch, sentence by sentence, as a newcomer reads
blocks, `st_blocks` × 512, from the lstat the walk already does. `trees` it. It leads with what the program is and how to use it. A reference section
prints a `disk_size` column after `size`, the sum over the `dupe` copy's covers every setting and every subcommand. Development and testing come last.
files; on a compressed or sparse file it differs from `size`. A rescan updates Every statement was checked against the code on `next`. Five comments in
the `disk_size` of an unchanged file whose size on disk changed, without `main_test.go` cite "Settings", where their rules now live (2026-10-26,
reading the file (2026-10-27, https://git.eeqj.de/sneak/sfdupes/issues/147) https://git.eeqj.de/sneak/sfdupes/issues/135)
- the database keeps the number of records and the sum of their sizes in the one
row of a `totals` table, which three triggers on `files` keep current. `info`
reads that row, and so do `scan`'s `load` phase and the `read` phases of
`report` and `trees` for their totals; no count reads every record any more.
The triggers make each write to `files` slower. `scan` keeps SQLite's
temporary data in memory, which wins back part of that time (2026-10-27,
https://git.eeqj.de/sneak/sfdupes/issues/146)
- an operand at or inside a directory named `.zfs` is never scanned, whether
given alone or under another operand. `scan` warns about it, counts it as
skipped and does not walk it (2026-10-26,
https://git.eeqj.de/sneak/sfdupes/issues/142)
- a file or directory removed while the scan runs is not an error, in the walk - a file or directory removed while the scan runs is not an error, in the walk
or in the hash phase. The scan prints no line for it and does not count it as or in the hash phase. The scan prints no line for it and does not count it as
+51 -151
View File
@@ -47,15 +47,14 @@ const dbFilePerm = 0o644
const lockFilePerm = 0o600 const lockFilePerm = 0o600
// createTableSQL is the schema applied to a fresh database. Paths are // createTableSQL is the schema applied to a fresh database. Paths are
// BLOBs because Unix paths are raw bytes, not guaranteed UTF-8. // BLOBs because Unix paths are raw bytes, not guaranteed UTF-8. mtime
// disk_size holds the bytes the file takes on disk. mtime holds whole // holds whole Unix seconds and mtime_nsec the nanoseconds within that
// Unix seconds and mtime_nsec the nanoseconds within that second. head, // second. head, tail and content each hold the raw 32 bytes of a
// tail and content each hold the raw 32 bytes of a SHA-256. // SHA-256.
const createTableSQL = ` const createTableSQL = `
CREATE TABLE files ( CREATE TABLE files (
path BLOB PRIMARY KEY, path BLOB PRIMARY KEY,
size INTEGER NOT NULL, size INTEGER NOT NULL,
disk_size INTEGER NOT NULL,
mtime INTEGER NOT NULL, mtime INTEGER NOT NULL,
mtime_nsec INTEGER NOT NULL, mtime_nsec INTEGER NOT NULL,
head BLOB NOT NULL, head BLOB NOT NULL,
@@ -70,59 +69,14 @@ const createIndexSQL = `
CREATE INDEX files_signature ON files (size, head, tail, content) CREATE INDEX files_signature ON files (size, head, tail, content)
` `
// createTotalsSQL creates the totals table, which holds one row: the
// number of records and the sizes of all records added up. Counting
// them over files would read every record. The three triggers below
// keep the row current, in the transaction of each change to files.
const createTotalsSQL = `
CREATE TABLE totals (
records INTEGER NOT NULL,
size INTEGER NOT NULL
)
`
// insertTotalsSQL adds the one row of totals, for an empty files table.
const insertTotalsSQL = `
INSERT INTO totals (records, size) VALUES (0, 0)
`
// totalsAfterInsertSQL counts each inserted record into totals.
const totalsAfterInsertSQL = `
CREATE TRIGGER totals_after_insert AFTER INSERT ON files BEGIN
UPDATE totals SET records = records + 1, size = size + NEW.size;
END
`
// totalsAfterDeleteSQL takes each deleted record out of totals.
const totalsAfterDeleteSQL = `
CREATE TRIGGER totals_after_delete AFTER DELETE ON files BEGIN
UPDATE totals SET records = records - 1, size = size - OLD.size;
END
`
// totalsAfterUpdateSQL replaces a record's old size in totals with its
// new one. scan's upsert of an existing record is such an update.
const totalsAfterUpdateSQL = `
CREATE TRIGGER totals_after_update AFTER UPDATE OF size ON files BEGIN
UPDATE totals SET size = size + NEW.size - OLD.size;
END
`
// recordCountSQL reads the number of records from totals.
const recordCountSQL = `
SELECT records FROM totals
`
// upsertSQL inserts one file record, replacing any existing record for // upsertSQL inserts one file record, replacing any existing record for
// the same path. // the same path.
const upsertSQL = ` const upsertSQL = `
INSERT INTO files ( INSERT INTO files (path, size, mtime, mtime_nsec, head, tail, content)
path, size, disk_size, mtime, mtime_nsec, head, tail, content VALUES (?, ?, ?, ?, ?, ?, ?)
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (path) DO UPDATE SET ON CONFLICT (path) DO UPDATE SET
size = excluded.size, disk_size = excluded.disk_size, size = excluded.size, mtime = excluded.mtime,
mtime = excluded.mtime, mtime_nsec = excluded.mtime_nsec, mtime_nsec = excluded.mtime_nsec,
head = excluded.head, tail = excluded.tail, head = excluded.head, tail = excluded.tail,
content = excluded.content content = excluded.content
` `
@@ -196,14 +150,9 @@ func createDatabaseDirectory(path string) error {
// scanParams are the connection parameters for scan: read-write, with // scanParams are the connection parameters for scan: read-write, with
// WAL journaling and a busy timeout, so a report can run while a cron // WAL journaling and a busy timeout, so a report can run while a cron
// scan is in progress. closeScanDatabase leaves WAL mode again. // scan is in progress. closeScanDatabase leaves WAL mode again.
//
// Each write to files fires a trigger, so SQLite keeps a statement
// journal for it. temp_store(MEMORY) keeps that journal in memory,
// which costs far less than a temporary file.
const scanParams = "_pragma=busy_timeout(10000)" + const scanParams = "_pragma=busy_timeout(10000)" +
"&_pragma=journal_mode(WAL)" + "&_pragma=journal_mode(WAL)" +
"&_pragma=synchronous(NORMAL)" + "&_pragma=synchronous(NORMAL)"
"&_pragma=temp_store(MEMORY)"
// reportParams are the connection parameters for report, trees and // reportParams are the connection parameters for report, trees and
// info: read-only, with the same busy timeout. They set no journal // info: read-only, with the same busy timeout. They set no journal
@@ -477,14 +426,14 @@ func createSchema(ctx context.Context, db *sql.DB) error {
defer func() { _ = tx.Rollback() }() defer func() { _ = tx.Rollback() }()
for _, stmt := range []string{ _, err = tx.ExecContext(ctx, createTableSQL)
createTableSQL, createIndexSQL, createTotalsSQL, insertTotalsSQL, if err != nil {
totalsAfterInsertSQL, totalsAfterDeleteSQL, totalsAfterUpdateSQL, return fmt.Errorf("create schema: %w", err)
} { }
_, err = tx.ExecContext(ctx, stmt)
if err != nil { _, err = tx.ExecContext(ctx, createIndexSQL)
return fmt.Errorf("create schema: %w", err) if err != nil {
} return fmt.Errorf("create schema: %w", err)
} }
_, err = tx.ExecContext(ctx, _, err = tx.ExecContext(ctx,
@@ -531,7 +480,7 @@ func loadFileRows(ctx context.Context, db *sql.DB, total func(n int64),
var n int64 var n int64
err = tx.QueryRowContext(ctx, recordCountSQL).Scan(&n) err = tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM files").Scan(&n)
if err != nil { if err != nil {
return fmt.Errorf("count records: %w", err) return fmt.Errorf("count records: %w", err)
} }
@@ -539,8 +488,8 @@ func loadFileRows(ctx context.Context, db *sql.DB, total func(n int64),
total(n) total(n)
rows, err := tx.QueryContext(ctx, rows, err := tx.QueryContext(ctx,
"SELECT path, size, disk_size, mtime, mtime_nsec, head, tail, "+ "SELECT path, size, mtime, mtime_nsec, head, tail, content "+
"content FROM files ORDER BY path") "FROM files ORDER BY path")
if err != nil { if err != nil {
return fmt.Errorf("read records: %w", err) return fmt.Errorf("read records: %w", err)
} }
@@ -554,8 +503,7 @@ func loadFileRows(ctx context.Context, db *sql.DB, total func(n int64),
r scanRec r scanRec
) )
err = rows.Scan(&path, &r.size, &r.diskSize, &sec, &nsec, err = rows.Scan(&path, &r.size, &sec, &nsec, &head, &tail, &content)
&head, &tail, &content)
if err != nil { if err != nil {
return fmt.Errorf("read record: %w", err) return fmt.Errorf("read record: %w", err)
} }
@@ -590,11 +538,10 @@ ORDER BY size DESC, head DESC, tail DESC, content DESC
` `
// loadSignatureRows gives total the number of records, then streams // loadSignatureRows gives total the number of records, then streams
// every record's signature and path to fn, without its mtime or its // every record's signature and path to fn, without its mtime, in the
// disk_size, in the order of signatureRowsSQL. The count and the // order of signatureRowsSQL. The count and the records are read in one
// records are read in one transaction, so they agree while a scan is // transaction, so they agree while a scan is committing. An error from
// committing. An error from fn stops the reading and is returned as it // fn stops the reading and is returned as it is.
// is.
func loadSignatureRows(ctx context.Context, db *sql.DB, func loadSignatureRows(ctx context.Context, db *sql.DB,
total func(n int64), fn func(r scanRec) error, total func(n int64), fn func(r scanRec) error,
) error { ) error {
@@ -609,7 +556,7 @@ func loadSignatureRows(ctx context.Context, db *sql.DB,
var n int64 var n int64
err = tx.QueryRowContext(ctx, recordCountSQL).Scan(&n) err = tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM files").Scan(&n)
if err != nil { if err != nil {
return fmt.Errorf("count records: %w", err) return fmt.Errorf("count records: %w", err)
} }
@@ -675,7 +622,7 @@ func (r *scanRec) setHashes(head, tail, content []byte) error {
func countRecords(ctx context.Context, db *sql.DB) (int64, error) { func countRecords(ctx context.Context, db *sql.DB) (int64, error) {
var n int64 var n int64
err := db.QueryRowContext(ctx, recordCountSQL).Scan(&n) err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM files").Scan(&n)
if err != nil { if err != nil {
return 0, fmt.Errorf("count records: %w", err) return 0, fmt.Errorf("count records: %w", err)
} }
@@ -683,10 +630,12 @@ func countRecords(ctx context.Context, db *sql.DB) (int64, error) {
return n, nil return n, nil
} }
// databaseCountsSQL reads the number of records and the sizes of all // databaseCountsSQL counts the records and adds up their sizes, in one
// records added up from totals. // pass over the table. A SUM over no rows is NULL, so on an empty table
// the SUM falls back to 0.
const databaseCountsSQL = ` const databaseCountsSQL = `
SELECT records, size FROM totals SELECT COUNT(*), COALESCE(SUM(size), 0)
FROM files
` `
// databaseCounts is what info reads from the database: its schema // databaseCounts is what info reads from the database: its schema
@@ -729,15 +678,15 @@ func readDatabaseCounts(ctx context.Context,
return c, nil return c, nil
} }
// loadFileMeta streams every record's path, size, disk_size and mtime // loadFileMeta streams every record's path, size and mtime to fn. Scan
// to fn. Scan change detection needs no hash values, and skipping the // change detection needs no hash values, and skipping the hash columns
// hash columns keeps the scan's in-memory index small on // keeps the scan's in-memory index small on multi-million-file
// multi-million-file databases. // databases.
func loadFileMeta(ctx context.Context, db *sql.DB, func loadFileMeta(ctx context.Context, db *sql.DB,
fn func(path string, m fileMeta), fn func(path string, size int64, mtime time.Time),
) error { ) error {
rows, err := db.QueryContext(ctx, rows, err := db.QueryContext(ctx,
"SELECT path, size, disk_size, mtime, mtime_nsec FROM files") "SELECT path, size, mtime, mtime_nsec FROM files")
if err != nil { if err != nil {
return fmt.Errorf("read records: %w", err) return fmt.Errorf("read records: %w", err)
} }
@@ -746,19 +695,16 @@ func loadFileMeta(ctx context.Context, db *sql.DB,
for rows.Next() { for rows.Next() {
var ( var (
path []byte path []byte
m fileMeta size, sec, nsec int64
sec, nsec int64
) )
err = rows.Scan(&path, &m.size, &m.diskSize, &sec, &nsec) err = rows.Scan(&path, &size, &sec, &nsec)
if err != nil { if err != nil {
return fmt.Errorf("read record: %w", err) return fmt.Errorf("read record: %w", err)
} }
m.mtime = time.Unix(sec, nsec) fn(string(path), size, time.Unix(sec, nsec))
fn(string(path), m)
} }
err = rows.Err() err = rows.Err()
@@ -776,21 +722,21 @@ func loadFileMeta(ctx context.Context, db *sql.DB,
// WAL small and let concurrent reports observe progress. // WAL small and let concurrent reports observe progress.
const updateBatchSize = 10000 const updateBatchSize = 10000
// applyChanges writes upserts for new and changed files and deletes for // applyChanges writes one scan's database changes — upserts for new and
// vanished ones, in batched transactions. Progress is rendered on prog // changed files, deletes for vanished ones — in batched transactions.
// (one increment per change). // Progress is rendered on prog (one increment per change).
func applyChanges(ctx context.Context, db *sql.DB, upserts []scanRec, func applyChanges(ctx context.Context, db *sql.DB, upserts []scanRec,
deletes []string, prog *progress, deletes []string, prog *progress,
) error { ) error {
for batch := range slices.Chunk(upserts, updateBatchSize) { for batch := range slices.Chunk(upserts, updateBatchSize) {
err := applyBatch(ctx, db, batch, nil, nil, prog) err := applyBatch(ctx, db, batch, nil, prog)
if err != nil { if err != nil {
return err return err
} }
} }
for batch := range slices.Chunk(deletes, updateBatchSize) { for batch := range slices.Chunk(deletes, updateBatchSize) {
err := applyBatch(ctx, db, nil, nil, batch, prog) err := applyBatch(ctx, db, nil, batch, prog)
if err != nil { if err != nil {
return err return err
} }
@@ -799,26 +745,10 @@ func applyChanges(ctx context.Context, db *sql.DB, upserts []scanRec,
return nil return nil
} }
// applyDiskSizes writes the disk_size of each of recs into its record, // applyBatch commits one batch of upserts and deletes in a single
// in batched transactions, and leaves the rest of the record alone. // transaction.
// Progress is rendered on prog (one increment per record).
func applyDiskSizes(ctx context.Context, db *sql.DB, recs []fileRec,
prog *progress,
) error {
for batch := range slices.Chunk(recs, updateBatchSize) {
err := applyBatch(ctx, db, nil, batch, nil, prog)
if err != nil {
return err
}
}
return nil
}
// applyBatch commits one batch of upserts, disk_size updates and
// deletes in a single transaction.
func applyBatch(ctx context.Context, db *sql.DB, upserts []scanRec, func applyBatch(ctx context.Context, db *sql.DB, upserts []scanRec,
diskSizes []fileRec, deletes []string, prog *progress, deletes []string, prog *progress,
) error { ) error {
tx, err := db.BeginTx(ctx, nil) tx, err := db.BeginTx(ctx, nil)
if err != nil { if err != nil {
@@ -832,11 +762,6 @@ func applyBatch(ctx context.Context, db *sql.DB, upserts []scanRec,
return err return err
} }
err = execDiskSizes(ctx, tx, diskSizes, prog)
if err != nil {
return err
}
err = execDeletes(ctx, tx, deletes, prog) err = execDeletes(ctx, tx, deletes, prog)
if err != nil { if err != nil {
return err return err
@@ -862,7 +787,7 @@ func execUpserts(ctx context.Context, tx *sql.Tx, upserts []scanRec,
defer func() { _ = st.Close() }() defer func() { _ = st.Close() }()
for _, r := range upserts { for _, r := range upserts {
_, err = st.ExecContext(ctx, []byte(r.path), r.size, r.diskSize, _, err = st.ExecContext(ctx, []byte(r.path), r.size,
r.mtime.Unix(), r.mtime.Nanosecond(), r.mtime.Unix(), r.mtime.Nanosecond(),
r.head[:], r.tail[:], r.content[:]) r.head[:], r.tail[:], r.content[:])
if err != nil { if err != nil {
@@ -875,31 +800,6 @@ func execUpserts(ctx context.Context, tx *sql.Tx, upserts []scanRec,
return nil return nil
} }
// execDiskSizes sets the disk_size of the record of each of recs. It
// does not touch size, so the totals triggers do not fire.
func execDiskSizes(ctx context.Context, tx *sql.Tx, recs []fileRec,
prog *progress,
) error {
st, err := tx.PrepareContext(ctx,
"UPDATE files SET disk_size = ? WHERE path = ?")
if err != nil {
return fmt.Errorf("prepare disk_size update: %w", err)
}
defer func() { _ = st.Close() }()
for _, r := range recs {
_, err = st.ExecContext(ctx, r.diskSize, []byte(r.path))
if err != nil {
return fmt.Errorf("update disk_size of %s: %w", r.path, err)
}
prog.increment()
}
return nil
}
// execDeletes removes the records for paths no longer present. // execDeletes removes the records for paths no longer present.
func execDeletes(ctx context.Context, tx *sql.Tx, deletes []string, func execDeletes(ctx context.Context, tx *sql.Tx, deletes []string,
prog *progress, prog *progress,
+10 -157
View File
@@ -535,12 +535,17 @@ func TestLoadRowsRefuseWrongSizeHash(t *testing.T) {
} }
} }
// queryPlan returns the detail of each line of SQLite's EXPLAIN QUERY // TestSignatureRowsNeedNoSort checks that SQLite reads signatureRowsSQL
// PLAN for query. // from the files_signature index alone, with nothing to sort: the
func queryPlan(t *testing.T, db *sql.DB, query string) []string { // first record comes at once, so report's read phase counts each
t.Helper() // record as it comes rather than waiting on SQLite for all of them.
func TestSignatureRowsNeedNoSort(t *testing.T) {
t.Parallel()
rows, err := db.QueryContext(t.Context(), "EXPLAIN QUERY PLAN "+query) db := openTestDB(t)
rows, err := db.QueryContext(t.Context(),
"EXPLAIN QUERY PLAN "+signatureRowsSQL)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -568,164 +573,12 @@ func queryPlan(t *testing.T, db *sql.DB, query string) []string {
t.Fatal(err) t.Fatal(err)
} }
return plan
}
// TestSignatureRowsNeedNoSort checks that SQLite reads signatureRowsSQL
// from the files_signature index alone, with nothing to sort: the
// first record comes at once, so report's read phase counts each
// record as it comes rather than waiting on SQLite for all of them.
func TestSignatureRowsNeedNoSort(t *testing.T) {
t.Parallel()
db := openTestDB(t)
plan := queryPlan(t, db, signatureRowsSQL)
want := []string{"SCAN files USING COVERING INDEX files_signature"} want := []string{"SCAN files USING COVERING INDEX files_signature"}
if !slices.Equal(plan, want) { if !slices.Equal(plan, want) {
t.Errorf("query plan = %q, want %q", plan, want) t.Errorf("query plan = %q, want %q", plan, want)
} }
} }
// TestCountsReadOnlyTotals checks that SQLite answers the queries for
// the number of records and their total size from the one row of
// totals. A plan that reads files or its index reads every record.
func TestCountsReadOnlyTotals(t *testing.T) {
t.Parallel()
db := openTestDB(t)
for _, query := range []string{recordCountSQL, databaseCountsSQL} {
plan := queryPlan(t, db, query)
want := []string{"SCAN totals"}
if !slices.Equal(plan, want) {
t.Errorf("query plan of %q = %q, want %q", query, plan, want)
}
}
}
// TestCountsComeFromTotals sets the row of totals to numbers that files
// does not hold, then checks that scan's load phase, the read phases of
// trees and report, and info each report the numbers from totals.
func TestCountsComeFromTotals(t *testing.T) {
t.Parallel()
// files is empty, so a count over files gives 0 records of 0 bytes.
const records, size = 7, 9000
db := openTestDB(t)
_, err := db.ExecContext(t.Context(),
"UPDATE totals SET records = ?, size = ?", records, size)
if err != nil {
t.Fatal(err)
}
scanTotal, err := countRecords(t.Context(), db)
if err != nil {
t.Fatal(err)
}
var treesTotal, reportTotal int64
err = loadFileRows(t.Context(), db,
func(n int64) { treesTotal = n }, func(scanRec) {})
if err != nil {
t.Fatal(err)
}
err = loadSignatureRows(t.Context(), db,
func(n int64) { reportTotal = n },
func(scanRec) error { return nil })
if err != nil {
t.Fatal(err)
}
info, err := readDatabaseCounts(t.Context(), db)
if err != nil {
t.Fatal(err)
}
for name, got := range map[string]int64{
"scan's load": scanTotal,
"trees' read": treesTotal,
"report's read": reportTotal,
"info": info.records,
} {
if got != records {
t.Errorf("%s: %d records, want %d from totals",
name, got, records)
}
}
if info.size != size {
t.Errorf("info: size %d, want %d from totals", info.size, size)
}
}
// assertTotalsMatchFiles fails unless the row of totals holds the
// number of records and the sizes of all records added up, as counted
// over files.
func assertTotalsMatchFiles(t *testing.T, db *sql.DB) {
t.Helper()
got, err := readDatabaseCounts(t.Context(), db)
if err != nil {
t.Fatal(err)
}
var records, size int64
err = db.QueryRowContext(t.Context(),
"SELECT COUNT(*), COALESCE(SUM(size), 0) FROM files").
Scan(&records, &size)
if err != nil {
t.Fatal(err)
}
if got.records != records || got.size != size {
t.Errorf("totals = %d records of %d bytes, files holds %d of %d",
got.records, got.size, records, size)
}
}
// TestTotalsMatchFiles scans the smoke tree, rescans it after two files
// change size, and rescans it after two files are removed. After each
// scan the row of totals must agree with files.
func TestTotalsMatchFiles(t *testing.T) {
t.Parallel()
dir := buildSmokeTree(t)
db := openTestDB(t)
syncTree(t, db, dir)
assertTotalsMatchFiles(t, db)
writeFile(t, dir, "a/one.bin", pattern(10, 2500))
writeFile(t, dir, "t1/f1", pattern(30, 1000))
if st := syncTree(t, db, dir); st.updated != 2 {
t.Fatalf("rescan stats = %+v, want 2 updated", st)
}
assertTotalsMatchFiles(t, db)
for _, name := range []string{"a/unique.bin", "t2/sub/f2"} {
err := os.Remove(filepath.Join(dir, name))
if err != nil {
t.Fatal(err)
}
}
if st := syncTree(t, db, dir); st.removed != 2 {
t.Fatalf("rescan stats = %+v, want 2 removed", st)
}
assertTotalsMatchFiles(t, db)
}
func TestApplyChangesRoundTrip(t *testing.T) { func TestApplyChangesRoundTrip(t *testing.T) {
t.Parallel() t.Parallel()
+6 -6
View File
@@ -26,11 +26,11 @@ func infoPhase(name string) string {
// it. Any database problem, including a missing database, is fatal. // it. Any database problem, including a missing database, is fatal.
// //
// The work runs in two phases, each under a progress display of its // The work runs in two phases, each under a progress display of its
// own on stderr: open (open the database) and count (read the number of // own on stderr: open (open the database) and count (count the records,
// records and their total size from the totals table). Each display // with one query). Each display ends before the next phase starts, and
// ends before the next phase starts, and before an error is returned // before an error is returned to be printed. The details are written
// to be printed. The details are written once the last display has // once the last display has ended, so no display is drawn while they
// ended, so no display is drawn while they are written. // are written.
func runInfo(ctx context.Context, stdout io.Writer) error { func runInfo(ctx context.Context, stdout io.Writer) error {
dbPath, source, err := databasePath() dbPath, source, err := databasePath()
if err != nil { if err != nil {
@@ -56,7 +56,7 @@ func runInfo(ctx context.Context, stdout io.Writer) error {
defer func() { _ = db.Close() }() defer func() { _ = db.Close() }()
// One row holds both counts, so the phase never has a total. // One query counts everything, so the phase never has a total.
prog = newProgress(progressParams{ prog = newProgress(progressParams{
label: infoPhase("count"), total: -1, label: infoPhase("count"), total: -1,
waitingFor: countingRecords, waitingFor: countingRecords,
+29 -164
View File
@@ -180,9 +180,9 @@ func capture(t *testing.T, std **os.File) func() string {
} }
// brokenDatabase writes a database that opens cleanly and passes the // brokenDatabase writes a database that opens cleanly and passes the
// schema-version check but has no tables, so the first query fails // schema-version check but has no files table, so the first query
// with the database already open: a fatal error on a path that owns an // fails with the database already open: a fatal error on a path that
// open database. It closes the database the way scan does. // owns an open database. It closes the database the way scan does.
func brokenDatabase(t *testing.T) string { func brokenDatabase(t *testing.T) string {
t.Helper() t.Helper()
@@ -265,9 +265,8 @@ func TestRunFatalAfterOpenClosesDatabase(t *testing.T) {
assertFatalOutput(t, stderr.String(), stdout()) assertFatalOutput(t, stderr.String(), stdout())
// Proof that the failure happened after the open: only a // Proof that the failure happened after the open: only a
// query against the opened database can report this. Each // query against the opened database can report this.
// subcommand's first query reads totals. if !strings.Contains(stderr.String(), "no such table: files") {
if !strings.Contains(stderr.String(), "no such table: totals") {
t.Errorf("stderr = %q, want the failure to come from a "+ t.Errorf("stderr = %q, want the failure to come from a "+
"query on the open database", stderr.String()) "query on the open database", stderr.String())
} }
@@ -394,7 +393,7 @@ func TestRunNamesRelativeDatabase(t *testing.T) {
} }
func TestRunUsesDatabaseThroughSymlinkedDirectory(t *testing.T) { func TestRunUsesDatabaseThroughSymlinkedDirectory(t *testing.T) {
// README §Database: the path names the file exactly. With link a // README §Settings: the path names the file exactly. With link a
// symlink to the directory real/sub, link/../db.sqlite names // symlink to the directory real/sub, link/../db.sqlite names
// real/db.sqlite, not db.sqlite beside link. Every subcommand must // real/db.sqlite, not db.sqlite beside link. Every subcommand must
// use that file, relative or absolute, and its first line must name // use that file, relative or absolute, and its first line must name
@@ -649,7 +648,7 @@ func TestRunUsageErrors(t *testing.T) {
} }
func TestRunScanRejectsWorkersBelowOne(t *testing.T) { func TestRunScanRejectsWorkersBelowOne(t *testing.T) {
// README §scan mode: --workers below 1 is a usage error reported in // README §Settings: --workers below 1 is a usage error reported in
// one line on stderr, before the scan opens the database. // one line on stderr, before the scan opens the database.
for _, workers := range []string{"0", "-1"} { for _, workers := range []string{"0", "-1"} {
t.Run(workers, func(t *testing.T) { t.Run(workers, func(t *testing.T) {
@@ -687,8 +686,8 @@ func TestRunScanRejectsWorkersBelowOne(t *testing.T) {
func TestRunHelp(t *testing.T) { func TestRunHelp(t *testing.T) {
t.Parallel() t.Parallel()
// README §Subcommands: help goes to stderr, exits 0, and leaves // README §Settings: help goes to stderr, exits 0, and leaves stdout
// stdout empty. // empty.
cases := [][]string{{"--help"}, {"-h"}, {cmdScan, "--help"}} cases := [][]string{{"--help"}, {"-h"}, {cmdScan, "--help"}}
for _, args := range cases { for _, args := range cases {
@@ -714,8 +713,8 @@ func TestRunHelp(t *testing.T) {
func TestRunVersion(t *testing.T) { func TestRunVersion(t *testing.T) {
t.Parallel() t.Parallel()
// README §Subcommands: the version is one line on stdout, with // README §Settings: the version is one line on stdout, with nothing
// nothing on stderr, and exits 0. // on stderr, and exits 0.
info, _ := debug.ReadBuildInfo() info, _ := debug.ReadBuildInfo()
for _, arg := range []string{"--version", "-v"} { for _, arg := range []string{"--version", "-v"} {
@@ -818,23 +817,6 @@ func scanFixture(t *testing.T) []string {
return dupes return dupes
} }
// storedDiskSize returns the disk_size of the record for path in the
// database at dbPath. A test of a trees run compares with this rather
// than with a new lstat, which on ZFS can differ from the scan's (see
// diskSizeOf).
func storedDiskSize(t *testing.T, dbPath, path string) int64 {
t.Helper()
db, err := openReportDatabase(t.Context(), dbPath)
if err != nil {
t.Fatal(err)
}
defer func() { _ = db.Close() }()
return recordByPath(t, dbRecords(t, db), path).diskSize
}
// scanOK runs scan over operands, fails the test unless it exits 0 with // scanOK runs scan over operands, fails the test unless it exits 0 with
// nothing on stdout, and returns everything it printed to stderr. // nothing on stdout, and returns everything it printed to stderr.
func scanOK(t *testing.T, operands ...string) string { func scanOK(t *testing.T, operands ...string) string {
@@ -946,21 +928,24 @@ func TestRunScanWalksOperandUnderSymlinkOperand(t *testing.T) {
} }
func TestRunScanSkipsZFSOperand(t *testing.T) { func TestRunScanSkipsZFSOperand(t *testing.T) {
zfs := filepath.Join(t.TempDir(), ".zfs") path := testDBPath(t)
f := writeFile(t, zfs, "snapshot/hourly/f", pattern(1, 10))
// No scan records a file inside a .zfs directory, so the record
// beneath the operand is written straight into the database.
path := seedDatabase(t, []scanRec{{size: 10, path: f}})
t.Setenv(databaseEnv, path) t.Setenv(databaseEnv, path)
zfs := filepath.Join(t.TempDir(), ".zfs")
snapshot := filepath.Join(zfs, "snapshot", "hourly")
f := writeFile(t, snapshot, "f", pattern(1, 10))
// An operand beneath a .zfs directory is walked, because it is not
// itself named .zfs.
scanOK(t, snapshot)
assertOperandSkipped(t, path, zfs, ".zfs directory", f) assertOperandSkipped(t, path, zfs, ".zfs directory", f)
} }
// assertOperandSkipped scans operand alone and checks that it is skipped // assertOperandSkipped scans operand alone and checks that it is skipped
// as kind: a warning naming it, one skip in the summary, exit 0, and the // as kind: a warning naming it, one skip in the summary, exit 0, and the
// record for kept, stored beneath operand before this scan, still in the // record for kept, which an earlier scan stored beneath operand, still
// database at dbPath. // in the database at dbPath.
func assertOperandSkipped(t *testing.T, dbPath, operand, kind, func assertOperandSkipped(t *testing.T, dbPath, operand, kind,
kept string, kept string,
) { ) {
@@ -989,121 +974,6 @@ func assertOperandSkipped(t *testing.T, dbPath, operand, kind,
recordByPath(t, dbRecords(t, db), kept) recordByPath(t, dbRecords(t, db), kept)
} }
// TestRunScanSkipsOperandInsideZFS scans a directory inside a .zfs
// directory, first alone and then under another operand. Each time it
// is warned about and counted as skipped, and nothing in it is walked.
// The other operand is scanned as usual.
func TestRunScanSkipsOperandInsideZFS(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
dir := t.TempDir()
hourly := filepath.Join(dir, ".zfs", "snapshot", "hourly")
writeFile(t, hourly, "f", pattern(1, 10))
f := writeFile(t, dir, "f", pattern(2, 10))
warning := "walk " + hourly + ": skipping operand inside a .zfs directory\n"
for _, tc := range []struct {
operands []string
summary string
}{
{
[]string{hourly},
"scan: 0 files seen (0 added, 0 updated, 0 removed, " +
"0 unchanged), 1 skipped\n",
},
{
[]string{dir, hourly},
"scan: 1 files seen (1 added, 0 updated, 0 removed, " +
"0 unchanged), 1 skipped\n",
},
} {
stderr := scanOK(t, tc.operands...)
if !strings.Contains(stderr, warning) ||
!strings.Contains(stderr, tc.summary) {
t.Errorf("scan %q: stderr = %q, want %q and %q", tc.operands,
stderr, warning, tc.summary)
}
}
db, err := openDB(path, reportParams)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
if got := recordPaths(dbRecords(t, db)); !slices.Equal(got, []string{f}) {
t.Errorf("records = %q, want %q", got, []string{f})
}
}
// TestRunScanSkipsOperandThroughSymlinkIntoZFS scans snaps/daily, where
// snaps is a symlink to pool/.zfs/snapshot. The operand is warned about
// by the path given, counted as skipped, and not walked.
func TestRunScanSkipsOperandThroughSymlinkIntoZFS(t *testing.T) {
t.Setenv(databaseEnv, testDBPath(t))
dir := t.TempDir()
writeFile(t, dir, "pool/.zfs/snapshot/daily/f", pattern(1, 10))
snaps := filepath.Join(dir, "snaps")
err := os.Symlink(filepath.Join(dir, "pool", ".zfs", "snapshot"), snaps)
if err != nil {
t.Fatal(err)
}
daily := filepath.Join(snaps, "daily")
stderr := scanOK(t, daily)
warning := "walk " + daily + ": skipping operand inside a .zfs directory\n"
summary := "scan: 0 files seen (0 added, 0 updated, 0 removed, " +
"0 unchanged), 1 skipped\n"
if !strings.Contains(stderr, warning) ||
!strings.Contains(stderr, summary) {
t.Errorf("stderr = %q, want %q and %q", stderr, warning, summary)
}
}
// TestRunScanWalksOperandThroughSymlink scans data/daily, where data is
// a symlink to a directory outside any .zfs directory. The operand is
// scanned as usual, and its file is recorded by the path given.
func TestRunScanWalksOperandThroughSymlink(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
dir := t.TempDir()
writeFile(t, dir, "pool/data/daily/f", pattern(1, 10))
data := filepath.Join(dir, "data")
err := os.Symlink(filepath.Join(dir, "pool", "data"), data)
if err != nil {
t.Fatal(err)
}
daily := filepath.Join(data, "daily")
stderr := scanOK(t, daily)
summary := "scan: 1 files seen (1 added, 0 updated, 0 removed, " +
"0 unchanged), 0 skipped\n"
if !strings.Contains(stderr, summary) {
t.Errorf("stderr = %q, want %q", stderr, summary)
}
db, err := openDB(path, reportParams)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
recordByPath(t, dbRecords(t, db), filepath.Join(daily, "f"))
}
func TestRunReportSucceeds(t *testing.T) { func TestRunReportSucceeds(t *testing.T) {
path := testDBPath(t) path := testDBPath(t)
t.Setenv(databaseEnv, path) t.Setenv(databaseEnv, path)
@@ -1158,9 +1028,8 @@ func TestRunTreesSucceeds(t *testing.T) {
// The two directories holding the duplicate pair are duplicate // The two directories holding the duplicate pair are duplicate
// trees of each other. // trees of each other.
want := "first\tdupe\tfiles\tsize\tdisk_size\n" + want := "first\tdupe\tfiles\tsize\n" +
filepath.Dir(dupes[0]) + "\t" + filepath.Dir(dupes[1]) + "\t1\t300\t" + filepath.Dir(dupes[0]) + "\t" + filepath.Dir(dupes[1]) + "\t1\t300\n"
strconv.FormatInt(storedDiskSize(t, path, dupes[1]), 10) + "\n"
if got := stdout.String(); got != want { if got := stdout.String(); got != want {
t.Errorf("stdout = %q, want %q", got, want) t.Errorf("stdout = %q, want %q", got, want)
} }
@@ -1194,10 +1063,9 @@ func TestRunReportsNeedOnlyReadAccess(t *testing.T) {
cases := map[string]string{ cases := map[string]string{
cmdReport: "first\tdupe\tsize\n" + cmdReport: "first\tdupe\tsize\n" +
dupes[0] + "\t" + dupes[1] + "\t300\n", dupes[0] + "\t" + dupes[1] + "\t300\n",
cmdTrees: "first\tdupe\tfiles\tsize\tdisk_size\n" + cmdTrees: "first\tdupe\tfiles\tsize\n" +
filepath.Dir(dupes[0]) + "\t" + filepath.Dir(dupes[1]) + filepath.Dir(dupes[0]) + "\t" + filepath.Dir(dupes[1]) +
"\t1\t300\t" + "\t1\t300\n",
strconv.FormatInt(storedDiskSize(t, path, dupes[1]), 10) + "\n",
} }
for name, want := range cases { for name, want := range cases {
@@ -1232,11 +1100,8 @@ func assertRunsUseDatabase(t *testing.T, dir, name string) {
t.Errorf("report stdout = %q, want %q", got, want) t.Errorf("report stdout = %q, want %q", got, want)
} }
diskSize := storedDiskSize(t, filepath.Join(dir, name), dupes[1]) want = "first\tdupe\tfiles\tsize\n" +
filepath.Dir(dupes[0]) + "\t" + filepath.Dir(dupes[1]) + "\t1\t300\n"
want = "first\tdupe\tfiles\tsize\tdisk_size\n" +
filepath.Dir(dupes[0]) + "\t" + filepath.Dir(dupes[1]) + "\t1\t300\t" +
strconv.FormatInt(diskSize, 10) + "\n"
if got := runStdout(t, cmdTrees); got != want { if got := runStdout(t, cmdTrees); got != want {
t.Errorf("trees stdout = %q, want %q", got, want) t.Errorf("trees stdout = %q, want %q", got, want)
} }
@@ -1257,7 +1122,7 @@ func assertRunsUseDatabase(t *testing.T, dir, name string) {
} }
func TestRunDatabasePathUsedAsGiven(t *testing.T) { func TestRunDatabasePathUsedAsGiven(t *testing.T) {
// README §Database: the path names the database file exactly. In // README §Settings: the path names the database file exactly. In
// SQLite's connection string an unescaped ? or # would end the file // SQLite's connection string an unescaped ? or # would end the file
// name and % would start an escape, and a path starting with // // name and % would start an escape, and a path starting with //
// could be read as a host name. %25 is a valid escape, so unescaped // could be read as a host name. %25 is a valid escape, so unescaped
+1 -1
View File
@@ -60,7 +60,7 @@ const fallbackWidth = 80
const spinnerMargin = 5 const spinnerMargin = 5
// countingRecords is what the display of a phase that reads the records // countingRecords is what the display of a phase that reads the records
// says until it has the number of records, its total. // says while it counts them, before it has its total.
const countingRecords = "counting the records" const countingRecords = "counting the records"
// openPhase is the name of the phase in which report, trees and info // openPhase is the name of the phase in which report, trees and info
+12 -14
View File
@@ -21,16 +21,14 @@ const minGroupSize = 2
// scanRec is one file record from the database. The signature (size, // scanRec is one file record from the database. The signature (size,
// head, tail, content) is the duplicate key; mtime is informational // head, tail, content) is the duplicate key; mtime is informational
// only and used by scan for change detection. diskSize is the bytes the // only and used by scan for change detection.
// file takes on disk, which trees adds up.
type scanRec struct { type scanRec struct {
size int64 size int64
diskSize int64 mtime time.Time
mtime time.Time head [sha256.Size]byte
head [sha256.Size]byte tail [sha256.Size]byte
tail [sha256.Size]byte content [sha256.Size]byte
content [sha256.Size]byte path string
path string
} }
// reportPhase names one phase of report with its place among all of // reportPhase names one phase of report with its place among all of
@@ -47,11 +45,11 @@ func reportPhase(name string) string {
// problem, including a missing database, is fatal. // problem, including a missing database, is fatal.
// //
// The work runs in two phases, each under a progress display of its // The work runs in two phases, each under a progress display of its
// own on stderr: open (open the database) and read (read the number of // own on stderr: open (open the database) and read (count the records,
// records from the totals table, then read the records in signature // then read them in signature order, writing each size's duplicate
// order, writing each size's duplicate groups to stdout once that size // groups to stdout once that size is read; see readReport). Each
// is read; see readReport). Each display ends before the next phase // display ends before the next phase starts, and before an error is
// starts, and before an error is returned to be printed. // returned to be printed.
func runReport(ctx context.Context, stdout io.Writer) error { func runReport(ctx context.Context, stdout io.Writer) error {
dbPath, source, err := databasePath() dbPath, source, err := databasePath()
if err != nil { if err != nil {
+2 -2
View File
@@ -38,8 +38,8 @@ func awkwardPairRecs() []scanRec {
h, tl, c := testHash("h"), testHash("t"), testHash("c") h, tl, c := testHash("h"), testHash("t"), testHash("c")
return []scanRec{ return []scanRec{
{size: 5, diskSize: 4096, head: h, tail: tl, content: c, path: "/d/A/f"}, {size: 5, head: h, tail: tl, content: c, path: "/d/A/f"},
{size: 5, diskSize: 4096, head: h, tail: tl, content: c, path: awkwardDir + "/f"}, {size: 5, head: h, tail: tl, content: c, path: awkwardDir + "/f"},
} }
} }
+65 -118
View File
@@ -52,26 +52,23 @@ const workQueueDepth = 1024
// has already printed its line, so run prints nothing more. // has already printed its line, so run prints nothing more.
var errInterrupted = errors.New("scan interrupted") var errInterrupted = errors.New("scan interrupted")
// fileRec carries one statted file between the scan phases. diskSize is // fileRec carries one statted file between the scan phases. dev and
// the bytes the file takes on disk. dev and ino identify the underlying // ino identify the underlying inode so hard-linked paths can share
// inode so hard-linked paths can share one read; both are zero when the // one read; both are zero when the platform exposes no inode.
// platform exposes no inode.
type fileRec struct { type fileRec struct {
path string path string
size int64 size int64
diskSize int64 mtime time.Time
mtime time.Time dev uint64
dev uint64 ino uint64
ino uint64
} }
// fileMeta is the in-memory index entry for one existing database // fileMeta is the in-memory index entry for one existing database
// record: what the walk compares the file with. Hashes stay on disk; at // record: just enough for change detection. Hashes stay on disk; at
// tens of millions of records they would dominate the scan's memory. // tens of millions of records they would dominate the scan's memory.
type fileMeta struct { type fileMeta struct {
size int64 size int64
diskSize int64 mtime time.Time
mtime time.Time
} }
// scanUnit is what every scan phase counts, as its progress display // scanUnit is what every scan phase counts, as its progress display
@@ -342,12 +339,11 @@ type scanStats struct {
// whatever remains after the walk and hash phases is deleted by the // whatever remains after the walk and hash phases is deleted by the
// update phase. // update phase.
type scanState struct { type scanState struct {
db *sql.DB db *sql.DB
existing map[string]fileMeta existing map[string]fileMeta
toHash []fileRec // new and changed files: must be read toHash []fileRec // new and changed files: must be read
diskSizes []fileRec // unchanged files whose disk_size changed batch []scanRec
batch []scanRec st scanStats
st scanStats
} }
// syncScan synchronizes the database with the filesystem under roots; // syncScan synchronizes the database with the filesystem under roots;
@@ -387,8 +383,7 @@ func (s *scanState) runPhases(ctx context.Context, roots []string,
) error { ) error {
// Types and filesystems are checked before pruning so that an // Types and filesystems are checked before pruning so that an
// operand under a dropped one is still scanned, not dropped as lying // operand under a dropped one is still scanned, not dropped as lying
// under it, and so that an operand inside a .zfs directory gets its // under it.
// warning even when it lies under another operand.
roots = pruneRoots(s.walkableRoots(roots, rules), oneFS, rules, roots = pruneRoots(s.walkableRoots(roots, rules), oneFS, rules,
deviceOfPath) deviceOfPath)
@@ -416,15 +411,15 @@ func (s *scanState) runPhases(ctx context.Context, roots []string,
} }
// walkableRoots returns the operands the walk can start from: regular // walkableRoots returns the operands the walk can start from: regular
// files, and directories not named .zfs, none of them inside a // files, and directories not named .zfs. Every other operand is warned
// directory named .zfs. Every other operand is warned about, counted as // about, counted as skipped, and dropped. A dropped operand is no
// skipped, and dropped. A dropped operand is no longer a root, so the // longer a root, so the records stored beneath it are treated as if it
// records stored beneath it are treated as if it had not been given: // had not been given: those under no other root count as outside the
// those under no other root count as outside the roots and are not // roots and are not deleted as unverified. An operand on a filesystem
// deleted as unverified. An operand on a filesystem in rules that is not // in rules that is not persistent is dropped the same way, with a line
// persistent is dropped the same way, with a line saying so that is not // saying so that is not a warning, and is not counted as skipped. An
// a warning, and is not counted as skipped. An operand that fails lstat // operand that fails lstat here is kept, and the walk warns about it
// here is kept, and the walk warns about it unless it no longer exists. // unless it no longer exists.
func (s *scanState) walkableRoots(roots []string, rules walkRules) []string { func (s *scanState) walkableRoots(roots []string, rules walkRules) []string {
kept := make([]string, 0, len(roots)) kept := make([]string, 0, len(roots))
@@ -459,8 +454,8 @@ func (s *scanState) walkableRoots(roots []string, rules walkRules) []string {
// included, so its display counts against the number of records. // included, so its display counts against the number of records.
func (s *scanState) loadIndex(ctx context.Context, roots []string) error { func (s *scanState) loadIndex(ctx context.Context, roots []string) error {
// Indexing tens of millions of records takes real time; without a // Indexing tens of millions of records takes real time; without a
// display the scan looks hung before the walk begins. The number of // display the scan looks hung before the walk begins. Counting them
// records, read first, gives the display its total. // first gives the display its total.
prog := newProgress(progressParams{ prog := newProgress(progressParams{
label: scanPhase("load"), unit: scanUnit, total: -1, label: scanPhase("load"), unit: scanUnit, total: -1,
waitingFor: countingRecords, stalledOn: "db", waitingFor: countingRecords, stalledOn: "db",
@@ -476,24 +471,24 @@ func (s *scanState) loadIndex(ctx context.Context, roots []string) error {
s.existing = make(map[string]fileMeta) s.existing = make(map[string]fileMeta)
return loadFileMeta(ctx, s.db, func(path string, m fileMeta) { return loadFileMeta(ctx, s.db,
prog.increment() func(path string, size int64, mtime time.Time) {
prog.increment()
if underAnyRoot(path, roots) { if underAnyRoot(path, roots) {
s.existing[path] = m s.existing[path] = fileMeta{size: size, mtime: mtime}
} }
}) })
} }
// walkPhase drains the walk and queues every new or changed file for // walkPhase drains the walk and queues every new or changed file for
// the hash phase. An unchanged file is not read, and its record counts // the hash phase. An unchanged file needs nothing more, and its record
// as verified. When its disk_size differs from the record's, it is // counts as verified. A record at or below a path the walk left out,
// queued for the update phase, which writes the new disk_size. A record // because -x stopped at it, it is a default exclude, or it is not on a
// at or below a path the walk left out, because -x stopped at it, it is // persistent filesystem, counts as outside the roots: it is not
// a default exclude, or it is not on a persistent filesystem, counts as // deleted. That does not hold below a root at or inside that path,
// outside the roots: it is not deleted. That does not hold below a root // since that root's own walk verifies the records below it; nor is a
// at or inside that path, since that root's own walk verifies the // line printed for such a root.
// records below it; nor is a line printed for such a root.
func (s *scanState) walkPhase(events <-chan walkEvent, roots []string) { func (s *scanState) walkPhase(events <-chan walkEvent, roots []string) {
// The records the last scan left under the roots estimate how many // 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. // files the walk will find. A first scan has none, and no estimate.
@@ -551,13 +546,6 @@ func (s *scanState) walkPhase(events <-chan walkEvent, roots []string) {
delete(s.existing, ev.rec.path) delete(s.existing, ev.rec.path)
s.st.unchanged++ 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() prog.finish()
@@ -706,13 +694,12 @@ func (s *scanState) recordRun(ctx context.Context, r hashResult) error {
s.resolve(rec.path) s.resolve(rec.path)
s.batch = append(s.batch, scanRec{ s.batch = append(s.batch, scanRec{
size: rec.size, size: rec.size,
diskSize: rec.diskSize, mtime: rec.mtime,
mtime: rec.mtime, head: r.head,
head: r.head, tail: r.tail,
tail: r.tail, content: r.content,
content: r.content, path: rec.path,
path: rec.path,
}) })
} }
@@ -728,7 +715,7 @@ func (s *scanState) commitFullBatch(ctx context.Context) error {
return nil return nil
} }
err := applyBatch(ctx, s.db, s.batch, nil, nil, nil) err := applyBatch(ctx, s.db, s.batch, nil, nil)
if err != nil { if err != nil {
return err return err
} }
@@ -739,10 +726,9 @@ func (s *scanState) commitFullBatch(ctx context.Context) error {
} }
// updatePhase writes the scan's tail under one progress display: the // updatePhase writes the scan's tail under one progress display: the
// final partial batch of hashed records, the new disk_size of every // final partial batch of hashed records, and deletions for every record
// unchanged file whose disk_size changed, and deletions for every // the scan did not verify (vanished files, plus paths that failed to
// record the scan did not verify (vanished files, plus paths that // stat or hash).
// failed to stat or hash).
func (s *scanState) updatePhase(ctx context.Context) error { func (s *scanState) updatePhase(ctx context.Context) error {
deletes := make([]string, 0, len(s.existing)) deletes := make([]string, 0, len(s.existing))
for path := range s.existing { for path := range s.existing {
@@ -754,7 +740,7 @@ func (s *scanState) updatePhase(ctx context.Context) error {
s.st.removed = len(deletes) s.st.removed = len(deletes)
total := len(s.batch) + len(s.diskSizes) + len(deletes) total := len(s.batch) + len(deletes)
prog := newProgress(progressParams{ prog := newProgress(progressParams{
label: scanPhase("update"), unit: scanUnit, total: int64(total), label: scanPhase("update"), unit: scanUnit, total: int64(total),
stalledOn: "db", stalledOn: "db",
@@ -769,11 +755,6 @@ func (s *scanState) updatePhase(ctx context.Context) error {
s.batch = nil s.batch = nil
err = applyDiskSizes(ctx, s.db, s.diskSizes, prog)
if err != nil {
return err
}
return applyChanges(ctx, s.db, nil, deletes, prog) return applyChanges(ctx, s.db, nil, deletes, prog)
} }
@@ -893,24 +874,9 @@ func sendEvent(ctx context.Context, events chan<- walkEvent,
// operandWarning returns the one-line warning for an operand the walk // operandWarning returns the one-line warning for an operand the walk
// does not start from, naming the path and what it is, or "" for one it // does not start from, naming the path and what it is, or "" for one it
// does: a regular file, or a directory not named .zfs, that is not // does: a regular file, or a directory not named .zfs. Symlinks are
// inside a directory named .zfs, neither as given nor with its symlinks // never followed, including as operands.
// resolved. Symlinks are never followed, including as operands.
func operandWarning(root string, fi fs.FileInfo) string { func operandWarning(root string, fi fs.FileInfo) string {
// The walk never enters a .zfs directory, so it starts from nothing
// inside one either, not even through a symlink. An operand whose
// symlinks cannot be resolved is checked as given.
resolved, err := filepath.EvalSymlinks(root)
if err != nil {
resolved = root
}
if slices.Contains(strings.Split(filepath.Dir(root), "/"), ".zfs") ||
slices.Contains(strings.Split(filepath.Dir(resolved), "/"), ".zfs") {
return fmt.Sprintf("walk %s: skipping operand inside a .zfs directory",
root)
}
var kind string var kind string
switch mode := fi.Mode(); { switch mode := fi.Mode(); {
@@ -979,12 +945,11 @@ func seedRoot(ctx context.Context, root string, rules walkRules,
dev, ino := inodeOfInfo(fi) dev, ino := inodeOfInfo(fi)
rec := fileRec{ rec := fileRec{
path: root, path: root,
size: fi.Size(), size: fi.Size(),
diskSize: diskSizeOfInfo(fi), mtime: fi.ModTime(),
mtime: fi.ModTime(), dev: dev,
dev: dev, ino: ino,
ino: ino,
} }
if !isDatabaseFile(rec, rules) { if !isDatabaseFile(rec, rules) {
@@ -1164,12 +1129,11 @@ func emitFile(ctx context.Context, p string, e fs.DirEntry,
dev, ino := inodeOfInfo(info) dev, ino := inodeOfInfo(info)
rec := fileRec{ rec := fileRec{
path: p, path: p,
size: info.Size(), size: info.Size(),
diskSize: diskSizeOfInfo(info), mtime: info.ModTime(),
mtime: info.ModTime(), dev: dev,
dev: dev, ino: ino,
ino: ino,
} }
if !isDatabaseFile(rec, rules) { if !isDatabaseFile(rec, rules) {
@@ -1261,23 +1225,6 @@ func inodeOfInfo(fi fs.FileInfo) (uint64, uint64) {
return statDev(st), st.Ino 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 // hashResult carries the hashes computed for one inode run (or the
// error that prevented computing them) from the pool's workers to the // error that prevented computing them) from the pool's workers to the
// hash phase. Its run leaves out the paths found removed. // hash phase. Its run leaves out the paths found removed.
+3 -131
View File
@@ -42,24 +42,6 @@ func writeFile(t *testing.T, dir, name string, data []byte) string {
return p return p
} }
// diskSizeOf returns the bytes the file at path takes on disk, from its
// own lstat: st_blocks counts 512-byte units. ZFS counts a new file's
// blocks only seconds after the write, and fsync does not hasten that.
// A test therefore calls unix.Sync, which waits for it, before the scan
// whose disk_size it compares with this.
func diskSizeOf(t *testing.T, path string) int64 {
t.Helper()
var st syscall.Stat_t
err := syscall.Lstat(path, &st)
if err != nil {
t.Fatal(err)
}
return st.Blocks * 512
}
// pattern returns n bytes of deterministic content seeded by tag. // pattern returns n bytes of deterministic content seeded by tag.
func pattern(tag byte, n int) []byte { func pattern(tag byte, n int) []byte {
data := make([]byte, n) data := make([]byte, n)
@@ -1246,9 +1228,9 @@ func TestScanPipeline(t *testing.T) {
// TestScanProgressNamesEachPhase checks the plain progress lines of a // TestScanProgressNamesEachPhase checks the plain progress lines of a
// first scan, and of a rescan of one operand holding two of the // first scan, and of a rescan of one operand holding two of the
// database's records. Each phase's first line names it with its place // database's records. Each phase's first line names it with its place
// among the four: load gets the number of records before reading them, // among the four: load counts the records before reading them, and the
// and the walk estimates its total from the records under its operand, // walk estimates its total from the records under its operand, or says
// or says its time remaining is unknown when there are none. // its time remaining is unknown when there are none.
// //
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestScanProgressNamesEachPhase(t *testing.T) { func TestScanProgressNamesEachPhase(t *testing.T) {
@@ -1434,116 +1416,6 @@ func TestScanStoresRawHashes(t *testing.T) {
} }
} }
// TestScanStoresDiskSize scans a sparse file, which takes less space on
// disk than its length, once through its directory and once as the
// operand itself. Each scan stores the disk_size of the file's lstat.
func TestScanStoresDiskSize(t *testing.T) {
t.Parallel()
const length = 1024 * 1024
p := sparseFile(t, t.TempDir(), "sparse", length)
pokeAt(t, p, 0, []byte("x"))
unix.Sync() // see diskSizeOf
want := diskSizeOf(t, p)
if want == 0 || want >= length {
t.Fatalf("the file takes %d bytes on disk, want more than none "+
"and fewer than its length %d", want, length)
}
for _, operand := range []string{filepath.Dir(p), p} {
db := openTestDB(t)
syncTree(t, db, operand)
got := recordByPath(t, dbRecords(t, db), p).diskSize
if got != want {
t.Errorf("scan of %s: disk_size = %d, want %d",
operand, got, want)
}
}
}
// TestSyncScanRewriteUpdatesDiskSize writes into the hole of a scanned
// sparse file and moves its mtime on. The rescan hashes the file again
// and rewrites its record with the new disk_size.
func TestSyncScanRewriteUpdatesDiskSize(t *testing.T) {
t.Parallel()
dir := t.TempDir()
db := openTestDB(t)
p := sparseFile(t, dir, "sparse", 1024*1024)
pokeAt(t, p, 0, []byte("x"))
syncTree(t, db, dir)
before := recordByPath(t, dbRecords(t, db), p).diskSize
pokeAt(t, p, 512*1024, pattern(7, 64*1024))
// The write may leave the mtime the scan recorded; a later one makes
// the rescan read the file again.
later := time.Now().Add(time.Hour)
err := os.Chtimes(p, later, later)
if err != nil {
t.Fatal(err)
}
unix.Sync() // see diskSizeOf
want := diskSizeOf(t, p)
if want == before {
t.Fatalf("the file still takes %d bytes on disk after the write "+
"into its hole", want)
}
st := syncTree(t, db, dir)
if st != (scanStats{walked: 1, updated: 1}) {
t.Fatalf("rescan stats = %+v, want 1 updated", st)
}
got := recordByPath(t, dbRecords(t, db), p).diskSize
if got != want {
t.Errorf("disk_size = %d, want %d", got, want)
}
}
// TestSyncScanUnchangedUpdatesDiskSize gives a file's record a
// disk_size the file does not have, as when ZFS had not yet counted the
// file's blocks at the last scan. The rescan writes the file's own
// disk_size into the record without reading the file again.
func TestSyncScanUnchangedUpdatesDiskSize(t *testing.T) {
t.Parallel()
dir := t.TempDir()
db := openTestDB(t)
p := writeFile(t, dir, "a.bin", pattern(1, 500))
syncTree(t, db, dir)
// lstat counts 512-byte units, so no file takes 1 byte on disk.
_, err := db.ExecContext(t.Context(),
"UPDATE files SET disk_size = 1 WHERE path = ?", []byte(p))
if err != nil {
t.Fatal(err)
}
unix.Sync() // see diskSizeOf
// A file read again would count as updated.
st := syncTree(t, db, dir)
if st != (scanStats{walked: 1, unchanged: 1}) {
t.Fatalf("rescan stats = %+v, want 1 unchanged", st)
}
got := recordByPath(t, dbRecords(t, db), p).diskSize
if want := diskSizeOf(t, p); got != want {
t.Errorf("disk_size = %d, want %d", got, want)
}
}
// TestSyncScanSameSecondRewrite rewrites a file in place at the same // TestSyncScanSameSecondRewrite rewrites a file in place at the same
// size with an mtime later in the same second as the recorded one: the // size with an mtime later in the same second as the recorded one: the
// next scan must notice the change and re-hash the file. The operand is // next scan must notice the change and re-hash the file. The operand is
+11 -18
View File
@@ -22,10 +22,6 @@ type treeNode struct {
digest [sha256.Size]byte digest [sha256.Size]byte
fileCount int64 fileCount int64
totalSize int64 totalSize int64
// diskSize is the bytes the directory's files take on disk. Two
// copies of one tree have the same totalSize, but their diskSize
// can differ.
diskSize int64
} }
// treesPhase names one phase of trees with its place among all of them, // treesPhase names one phase of trees with its place among all of them,
@@ -44,11 +40,11 @@ func treesPhase(name string) string {
// database problem, including a missing database, is fatal. // database problem, including a missing database, is fatal.
// //
// The work runs in four phases, each under a progress display of its // The work runs in four phases, each under a progress display of its
// own on stderr: open (open the database), read (read the number of // own on stderr: open (open the database), read (count the records,
// records from the totals table, then read the records into the // then read them into the hierarchy), group (group the directories by
// hierarchy), group (group the directories by digest) and write (write // digest) and write (write the groups to stdout). Each display ends
// the groups to stdout). Each display ends before the next phase // before the next phase starts, and before an error is returned to be
// starts, and before an error is returned to be printed. // printed.
func runTrees(ctx context.Context, stdout io.Writer) error { func runTrees(ctx context.Context, stdout io.Writer) error {
dbPath, source, err := databasePath() dbPath, source, err := databasePath()
if err != nil { if err != nil {
@@ -143,16 +139,15 @@ func readTree(ctx context.Context, db *sql.DB,
// writeTreeGroups writes dupes to stdout as TSV, a header line and then // writeTreeGroups writes dupes to stdout as TSV, a header line and then
// a row for every member of each group but its first, counting each // a row for every member of each group but its first, counting each
// group on prog once its rows are written. The disk_size column is the // group on prog once its rows are written. stdout may be the terminal
// dupe's own. stdout may be the terminal prog is drawn on, so the lines // prog is drawn on, so the lines go through prog's writer. It returns
// go through prog's writer. It returns the number of rows (dupe trees) // the number of rows (dupe trees) and the bytes they could reclaim.
// and the bytes they could reclaim.
func writeTreeGroups(stdout io.Writer, dupes [][]*treeNode, func writeTreeGroups(stdout io.Writer, dupes [][]*treeNode,
prog *progress, prog *progress,
) (int, int64, error) { ) (int, int64, error) {
out := prog.writer(stdout) out := prog.writer(stdout)
_, err := fmt.Fprintln(out, "first\tdupe\tfiles\tsize\tdisk_size") _, err := fmt.Fprintln(out, "first\tdupe\tfiles\tsize")
if err != nil { if err != nil {
return 0, 0, fmt.Errorf("write stdout: %w", err) return 0, 0, fmt.Errorf("write stdout: %w", err)
} }
@@ -164,9 +159,9 @@ func writeTreeGroups(stdout io.Writer, dupes [][]*treeNode,
for _, g := range dupes { for _, g := range dupes {
first := g[0] first := g[0]
for _, n := range g[1:] { for _, n := range g[1:] {
_, err = fmt.Fprintf(out, "%s\t%s\t%d\t%d\t%d\n", _, err = fmt.Fprintf(out, "%s\t%s\t%d\t%d\n",
escapePath(first.path), escapePath(n.path), escapePath(first.path), escapePath(n.path),
first.fileCount, first.totalSize, n.diskSize) first.fileCount, first.totalSize)
if err != nil { if err != nil {
return 0, 0, fmt.Errorf("write stdout: %w", err) return 0, 0, fmt.Errorf("write stdout: %w", err)
} }
@@ -237,7 +232,6 @@ func (b *treeBuilder) add(r scanRec) {
dir.entries = append(dir.entries, fileEntry(name, r)) dir.entries = append(dir.entries, fileEntry(name, r))
dir.fileCount++ dir.fileCount++
dir.totalSize += r.size dir.totalSize += r.size
dir.diskSize += r.diskSize
} }
// openDir starts the directory called name inside the innermost open // openDir starts the directory called name inside the innermost open
@@ -276,7 +270,6 @@ func (b *treeBuilder) closeTo(n int) {
"d\x00"+name+"\x00"+string(dir.digest[:])) "d\x00"+name+"\x00"+string(dir.digest[:]))
dir.parent.fileCount += dir.fileCount dir.parent.fileCount += dir.fileCount
dir.parent.totalSize += dir.totalSize dir.parent.totalSize += dir.totalSize
dir.parent.diskSize += dir.diskSize
b.dirs = append(b.dirs, dir) b.dirs = append(b.dirs, dir)
} }
} }
+11 -40
View File
@@ -17,24 +17,18 @@ const treesOpenLine = "[1/4] open: opening the database, elapsed 0s, " +
// smokeTreeRecs mirrors the README smoke-test tree layout: /d/t1 and // smokeTreeRecs mirrors the README smoke-test tree layout: /d/t1 and
// /d/t2 are identical, /d/t3 differs from them only by one filename. // /d/t2 are identical, /d/t3 differs from them only by one filename.
// Each file takes one 4 KiB block on disk.
func smokeTreeRecs() []scanRec { func smokeTreeRecs() []scanRec {
f1Head, f1Tail, f1Content := testHash("f1h"), testHash("f1t"), testHash("f1c") f1Head, f1Tail, f1Content := testHash("f1h"), testHash("f1t"), testHash("f1c")
f2Head, f2Tail, f2Content := testHash("f2h"), testHash("f2t"), testHash("f2c") f2Head, f2Tail, f2Content := testHash("f2h"), testHash("f2t"), testHash("f2c")
return []scanRec{ return []scanRec{
{size: 3000, diskSize: 4096, head: f1Head, tail: f1Tail, {size: 3000, head: f1Head, tail: f1Tail, content: f1Content, path: "/d/t1/f1"},
content: f1Content, path: "/d/t1/f1"}, {size: 100, head: f2Head, tail: f2Tail, content: f2Content, path: "/d/t1/sub/f2"},
{size: 100, diskSize: 4096, head: f2Head, tail: f2Tail, {size: 3000, head: f1Head, tail: f1Tail, content: f1Content, path: "/d/t2/f1"},
content: f2Content, path: "/d/t1/sub/f2"}, {size: 100, head: f2Head, tail: f2Tail, content: f2Content, path: "/d/t2/sub/f2"},
{size: 3000, diskSize: 4096, head: f1Head, tail: f1Tail, {size: 3000, head: f1Head, tail: f1Tail, content: f1Content, path: "/d/t3/f1"},
content: f1Content, path: "/d/t2/f1"}, {size: 100, head: f2Head, tail: f2Tail, content: f2Content,
{size: 100, diskSize: 4096, head: f2Head, tail: f2Tail, path: "/d/t3/sub/f2renamed"},
content: f2Content, path: "/d/t2/sub/f2"},
{size: 3000, diskSize: 4096, head: f1Head, tail: f1Tail,
content: f1Content, path: "/d/t3/f1"},
{size: 100, diskSize: 4096, head: f2Head, tail: f2Tail,
content: f2Content, path: "/d/t3/sub/f2renamed"},
} }
} }
@@ -204,33 +198,13 @@ func TestRunTreesEscapesPaths(t *testing.T) {
code, exitOK, stderr.String()) code, exitOK, stderr.String())
} }
want := "first\tdupe\tfiles\tsize\tdisk_size\n" + want := "first\tdupe\tfiles\tsize\n" +
`/d/\tone\ntwo\rthree\\four` + "\t/d/A\t1\t5\t4096\n" `/d/\tone\ntwo\rthree\\four` + "\t/d/A\t1\t5\n"
if got := stdout.String(); got != want { if got := stdout.String(); got != want {
t.Errorf("stdout = %q, want %q", got, want) t.Errorf("stdout = %q, want %q", got, want)
} }
} }
// TestRunTreesDiskSize runs trees on two copies of a tree whose files
// take different space on disk. The row's disk_size is the sum over the
// dupe copy's files, those in its subdirectory included.
func TestRunTreesDiskSize(t *testing.T) {
f, g := testHash("f"), testHash("g")
recs := []scanRec{
{size: 10, diskSize: 4096, head: f, tail: f, content: f, path: "/a/f"},
{size: 20, diskSize: 4096, head: g, tail: g, content: g, path: "/a/sub/g"},
{size: 10, diskSize: 512, head: f, tail: f, content: f, path: "/b/f"},
{size: 20, diskSize: 1024, head: g, tail: g, content: g, path: "/b/sub/g"},
}
t.Setenv(databaseEnv, seedDatabase(t, recs))
want := "first\tdupe\tfiles\tsize\tdisk_size\n/a\t/b\t2\t30\t1536\n"
if got := runStdout(t, cmdTrees); got != want {
t.Errorf("stdout = %q, want %q", got, want)
}
}
// TestRunTreesProgressNamesEachPhase runs trees on the smoke-test // TestRunTreesProgressNamesEachPhase runs trees on the smoke-test
// records with stderr not a terminal, and checks every line it prints // records with stderr not a terminal, and checks every line it prints
// there. After the line naming the database, each phase prints a line // there. After the line naming the database, each phase prints a line
@@ -252,8 +226,7 @@ func TestRunTreesProgressNamesEachPhase(t *testing.T) {
code, exitOK, stderr()) code, exitOK, stderr())
} }
report := "first\tdupe\tfiles\tsize\tdisk_size\n" + report := "first\tdupe\tfiles\tsize\n/d/t1\t/d/t2\t2\t3100\n"
"/d/t1\t/d/t2\t2\t3100\t8192\n"
if got := stdout.String(); got != report { if got := stdout.String(); got != report {
t.Errorf("stdout = %q, want %q", got, report) t.Errorf("stdout = %q, want %q", got, report)
} }
@@ -318,9 +291,7 @@ func TestWriteTreeGroupsOnTerminal(t *testing.T) {
shown = append(shown, line[strings.LastIndex(line, "\r")+1:]) shown = append(shown, line[strings.LastIndex(line, "\r")+1:])
} }
want := []string{ want := []string{"first\tdupe\tfiles\tsize", "/d/t1\t/d/t2\t2\t3100"}
"first\tdupe\tfiles\tsize\tdisk_size", "/d/t1\t/d/t2\t2\t3100\t8192",
}
if len(shown) < len(want) || !slices.Equal(shown[:len(want)], want) { if len(shown) < len(want) || !slices.Equal(shown[:len(want)], want) {
t.Errorf("terminal shows lines %q, want %q first", shown, want) t.Errorf("terminal shows lines %q, want %q first", shown, want)
} }