1 Commits
Author SHA1 Message Date
clawbot 62b9cfc7ae Hash every file's head, tail and content in the hash phase (closes #132)
check / check (push) Canceled after 0s
Every file the walk records now gets three hashes in the hash phase:
head and tail, of its first and last 64 KiB, and content. The content
hash covers the whole file under 100 MiB, and 1 MiB at each gigabyte
from 100 MiB up, so a large file is never read in full. One 100 MiB
constant replaces the 10 MiB and 50 MiB thresholds. The size census,
the records stored without hashes and the content phase are gone, so a
scan has four phases and a rescan reads only new and changed files.
report and trees no longer special-case a missing content hash. README
"Duplicate detection" and "Rationale" are rewritten.

Judgement call: info drops its two hash-count lines, which would now
always equal the record count.

Model: opus-5-5
2026-10-25 23:37:09 +00:00
17 changed files with 1857 additions and 3491 deletions
+866 -1099
View File
File diff suppressed because it is too large Load Diff
+418 -633
View File
File diff suppressed because it is too large Load Diff
+2 -7
View File
@@ -2,7 +2,6 @@ package main
import ( import (
"context" "context"
"crypto/sha256"
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
@@ -763,9 +762,7 @@ func TestHashWorkerDropsQueuedRuns(t *testing.T) {
var hashed atomic.Bool var hashed atomic.Bool
hash := func(path string, size int64) ( hash := func(path string, size int64) (string, string, string, error) {
[sha256.Size]byte, [sha256.Size]byte, [sha256.Size]byte, error,
) {
hashed.Store(true) hashed.Store(true)
return hashSignature(path, size) return hashSignature(path, size)
@@ -805,9 +802,7 @@ func TestHashWorkerAbandonsBlockedSend(t *testing.T) {
// The scan is cancelled while the worker hashes, so the worker has // The scan is cancelled while the worker hashes, so the worker has
// already passed the check that drops queued runs. // already passed the check that drops queued runs.
hash := func(path string, size int64) ( hash := func(path string, size int64) (string, string, string, error) {
[sha256.Size]byte, [sha256.Size]byte, [sha256.Size]byte, error,
) {
cancel() cancel()
return hashSignature(path, size) return hashSignature(path, size)
+61 -194
View File
@@ -2,7 +2,6 @@ package main
import ( import (
"context" "context"
"crypto/sha256"
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
@@ -47,20 +46,18 @@ 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.
// tail and content each hold the raw 32 bytes of a 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 TEXT NOT NULL,
tail BLOB NOT NULL, tail TEXT NOT NULL,
content BLOB NOT NULL content TEXT NOT NULL
) WITHOUT ROWID ) WITHOUT ROWID
` `
@@ -70,59 +67,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
` `
@@ -140,10 +92,6 @@ var errSchemaVersion = errors.New("unsupported database schema version")
// database. // database.
var errScanRunning = errors.New("another scan is running") var errScanRunning = errors.New("another scan is running")
// errHashSize reports a record whose head, tail or content is not the
// 32 bytes of a SHA-256.
var errHashSize = errors.New("hash is not 32 bytes")
// databasePath resolves the database location: SFDUPES_DATABASE when // databasePath resolves the database location: SFDUPES_DATABASE when
// set and non-empty, the compiled-in default otherwise. It returns the // set and non-empty, the compiled-in default otherwise. It returns the
// path, with the working directory, its symlinks resolved, put in front // path, with the working directory, its symlinks resolved, put in front
@@ -196,14 +144,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 +420,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 +474,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 +482,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)
} }
@@ -549,25 +492,19 @@ func loadFileRows(ctx context.Context, db *sql.DB, total func(n int64),
for rows.Next() { for rows.Next() {
var ( var (
path, head, tail, content []byte path []byte
sec, nsec int64 sec, nsec int64
r scanRec r scanRec
) )
err = rows.Scan(&path, &r.size, &r.diskSize, &sec, &nsec, err = rows.Scan(&path, &r.size, &sec, &nsec, &r.head, &r.tail,
&head, &tail, &content) &r.content)
if err != nil { if err != nil {
return fmt.Errorf("read record: %w", err) return fmt.Errorf("read record: %w", err)
} }
r.path = string(path) r.path = string(path)
r.mtime = time.Unix(sec, nsec) r.mtime = time.Unix(sec, nsec)
err = r.setHashes(head, tail, content)
if err != nil {
return fmt.Errorf("read record %s: %w", escapePath(r.path), err)
}
fn(r) fn(r)
} }
@@ -590,11 +527,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 +545,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)
} }
@@ -625,22 +561,17 @@ func loadSignatureRows(ctx context.Context, db *sql.DB,
for rows.Next() { for rows.Next() {
var ( var (
path, head, tail, content []byte path []byte
r scanRec r scanRec
) )
err = rows.Scan(&r.size, &head, &tail, &content, &path) err = rows.Scan(&r.size, &r.head, &r.tail, &r.content, &path)
if err != nil { if err != nil {
return fmt.Errorf("read record: %w", err) return fmt.Errorf("read record: %w", err)
} }
r.path = string(path) r.path = string(path)
err = r.setHashes(head, tail, content)
if err != nil {
return fmt.Errorf("read record %s: %w", escapePath(r.path), err)
}
err = fn(r) err = fn(r)
if err != nil { if err != nil {
return err return err
@@ -655,27 +586,11 @@ func loadSignatureRows(ctx context.Context, db *sql.DB,
return nil return nil
} }
// setHashes sets r's head, tail and content to the values read from
// those columns. Each must be the raw 32 bytes of a SHA-256.
func (r *scanRec) setHashes(head, tail, content []byte) error {
for _, b := range [][]byte{head, tail, content} {
if len(b) != sha256.Size {
return fmt.Errorf("%w: found %d bytes", errHashSize, len(b))
}
}
r.head = [sha256.Size]byte(head)
r.tail = [sha256.Size]byte(tail)
r.content = [sha256.Size]byte(content)
return nil
}
// countRecords returns the number of records in the database. // countRecords returns the number of records in the database.
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 +598,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 +646,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 +663,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 +690,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 +713,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 +730,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,9 +755,8 @@ 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 {
return fmt.Errorf("upsert %s: %w", r.path, err) return fmt.Errorf("upsert %s: %w", r.path, err)
} }
@@ -875,31 +767,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,
+16 -226
View File
@@ -2,7 +2,6 @@ package main
import ( import (
"context" "context"
"crypto/sha256"
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
@@ -474,73 +473,17 @@ func TestLoadRowsCountAgreeDuringScan(t *testing.T) {
} }
} }
// TestLoadRowsRefuseWrongSizeHash stores a record whose head, tail or // TestSignatureRowsNeedNoSort checks that SQLite reads signatureRowsSQL
// content is not the 32 bytes of a SHA-256: either one byte, or the 64 // from the files_signature index alone, with nothing to sort: the
// bytes of a SHA-256 written as hex text. loadFileRows and // first record comes at once, so report's read phase counts each
// loadSignatureRows must each fail on it with an error naming its path. // record as it comes rather than waiting on SQLite for all of them.
func TestLoadRowsRefuseWrongSizeHash(t *testing.T) { func TestSignatureRowsNeedNoSort(t *testing.T) {
t.Parallel() t.Parallel()
hexText := strings.Repeat("ab", sha256.Size) db := openTestDB(t)
cases := []struct { rows, err := db.QueryContext(t.Context(),
name string "EXPLAIN QUERY PLAN "+signatureRowsSQL)
update string
value any
}{
{"one-byte head", "UPDATE files SET head = ?", []byte{0}},
{"hex-text head", "UPDATE files SET head = ?", hexText},
{"one-byte tail", "UPDATE files SET tail = ?", []byte{0}},
{"hex-text tail", "UPDATE files SET tail = ?", hexText},
{"one-byte content", "UPDATE files SET content = ?", []byte{0}},
{"hex-text content", "UPDATE files SET content = ?", hexText},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
db := openTestDB(t)
err := applyChanges(t.Context(), db,
[]scanRec{{path: "/a/f"}}, nil, nil)
if err != nil {
t.Fatal(err)
}
_, err = db.ExecContext(t.Context(), c.update, c.value)
if err != nil {
t.Fatal(err)
}
loads := map[string]func() error{
"loadFileRows": func() error {
return loadFileRows(t.Context(), db, func(int64) {},
func(scanRec) {})
},
"loadSignatureRows": func() error {
return loadSignatureRows(t.Context(), db, func(int64) {},
func(scanRec) error { return nil })
},
}
for name, load := range loads {
err := load()
if !errors.Is(err, errHashSize) ||
!strings.Contains(err.Error(), "/a/f") {
t.Errorf("%s: err = %v, want %v naming /a/f",
name, err, errHashSize)
}
}
})
}
}
// queryPlan returns the detail of each line of SQLite's EXPLAIN QUERY
// PLAN for query.
func queryPlan(t *testing.T, db *sql.DB, query string) []string {
t.Helper()
rows, err := db.QueryContext(t.Context(), "EXPLAIN QUERY PLAN "+query)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -568,164 +511,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()
@@ -736,13 +527,12 @@ func TestApplyChangesRoundTrip(t *testing.T) {
// written. // written.
recs := []scanRec{ recs := []scanRec{
{ {
size: 2, mtime: time.Unix(20, 999_999_999), head: testHash("h2"), size: 2, mtime: time.Unix(20, 999_999_999), head: "h2", tail: "t2",
tail: testHash("t2"), content: testHash("c2"), content: "c2", path: "/a/tab\tnew\nline",
path: "/a/tab\tnew\nline",
}, },
{ {
size: 1, mtime: time.Unix(10, 0), head: testHash("h1"), size: 1, mtime: time.Unix(10, 0), head: "h1", tail: "t1",
tail: testHash("t1"), content: testHash("c1"), path: "/a/x", content: "c1", path: "/a/x",
}, },
} }
@@ -763,8 +553,8 @@ func TestApplyChangesRoundTrip(t *testing.T) {
// An upsert for an existing path updates in place; a delete // An upsert for an existing path updates in place; a delete
// removes exactly its path. // removes exactly its path.
upd := scanRec{ upd := scanRec{
size: 3, mtime: time.Unix(30, 0), head: testHash("h3"), size: 3, mtime: time.Unix(30, 0), head: "h3", tail: "t3", content: "c3",
tail: testHash("t3"), content: testHash("c3"), path: "/a/x", path: "/a/x",
} }
err = applyChanges(t.Context(), db, []scanRec{upd}, err = applyChanges(t.Context(), db, []scanRec{upd},
@@ -791,8 +581,8 @@ func TestApplyChangesBatching(t *testing.T) {
recs := make([]scanRec, 0, n) recs := make([]scanRec, 0, n)
for i := range n { for i := range n {
recs = append(recs, scanRec{ recs = append(recs, scanRec{
size: int64(i), mtime: time.Unix(1, 0), head: testHash("h"), size: int64(i), mtime: time.Unix(1, 0), head: "h", tail: "t",
tail: testHash("t"), path: fmt.Sprintf("/batch/%07d", i), path: fmt.Sprintf("/batch/%07d", i),
}) })
} }
+1 -3
View File
@@ -17,9 +17,7 @@ import (
func TestDefaultExcludes(t *testing.T) { func TestDefaultExcludes(t *testing.T) {
t.Parallel() t.Parallel()
// README "Rules for the walk": the Linux and macOS lists, and none // README "Rules for the walk": each system's list.
// on any other system. The Windows list exists in the code only: the
// binary does not build for Windows, so the README leaves it out.
cases := map[string][]string{ cases := map[string][]string{
goosLinux: {"/proc", "/sys", "/dev", "/run", "/tmp", "/var/tmp"}, goosLinux: {"/proc", "/sys", "/dev", "/run", "/tmp", "/var/tmp"},
goosDarwin: { goosDarwin: {
+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,
+6 -9
View File
@@ -12,25 +12,22 @@ import (
// infoOpenLine is the plain line of info's open phase when it opens the // infoOpenLine is the plain line of info's open phase when it opens the
// database at once; it prints it as the phase starts and as it ends. // database at once; it prints it as the phase starts and as it ends.
const infoOpenLine = "[1/2] open: opening the database, elapsed 0s, " + const infoOpenLine = "[1/2] open: opening the database, elapsed 0s, " +
"remain unknown" "eta unknown"
// infoCountLine is the plain line of info's count phase when the count // infoCountLine is the plain line of info's count phase when the count
// takes under a second; it prints it as the phase starts and as it // takes under a second; it prints it as the phase starts and as it
// ends. // ends.
const infoCountLine = "[2/2] count: counting the records, elapsed 0s, " + const infoCountLine = "[2/2] count: counting the records, elapsed 0s, " +
"remain unknown" "eta unknown"
// infoRecs are four records: a duplicate pair, a file of 20 MiB and // infoRecs are four records: a duplicate pair, a file of 20 MiB and
// another small file. // another small file.
func infoRecs() []scanRec { func infoRecs() []scanRec {
h, tl, c, u := testHash("h"), testHash("t"), testHash("c"), testHash("u")
bigH, bigT, bigC := testHash("bh"), testHash("bt"), testHash("bc")
return []scanRec{ return []scanRec{
{size: 5, head: h, tail: tl, content: c, path: "/d/a"}, {size: 5, head: "h", tail: "t", content: "c", path: "/d/a"},
{size: 5, head: h, tail: tl, content: c, path: "/d/b"}, {size: 5, head: "h", tail: "t", content: "c", path: "/d/b"},
{size: 20 << 20, head: bigH, tail: bigT, content: bigC, path: "/d/big"}, {size: 20 << 20, head: "bh", tail: "bt", content: "bc", path: "/d/big"},
{size: 7, head: u, tail: u, content: u, path: "/d/unique"}, {size: 7, head: "u", tail: "u", content: "u", path: "/d/unique"},
} }
} }
+37 -171
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 §Settings: the path names the file exactly. With link a // README §Database: 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 §Settings: --workers below 1 is a usage error reported in // README §scan mode: --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 §Settings: help goes to stderr, exits 0, and leaves stdout // README §Subcommands: help goes to stderr, exits 0, and leaves
// empty. // stdout 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 §Settings: the version is one line on stdout, with nothing // README §Subcommands: the version is one line on stdout, with
// on stderr, and exits 0. // nothing on stderr, and exits 0.
info, _ := debug.ReadBuildInfo() info, _ := debug.ReadBuildInfo()
for _, arg := range []string{"--version", "-v"} { for _, arg := range []string{"--version", "-v"} {
@@ -774,8 +773,8 @@ func TestBuildVersion(t *testing.T) {
func TestRunVersionWriteFailureIsFatal(t *testing.T) { func TestRunVersionWriteFailureIsFatal(t *testing.T) {
t.Parallel() t.Parallel()
// README §Error handling: a failed write to stdout is a fatal error, // README §Error handling: a stdout write failure exits 1, reported
// exit 1, reported on stderr starting with "sfdupes:". // in one line on stderr.
var stderr bytes.Buffer var stderr bytes.Buffer
code := run([]string{"--version"}, failingWriter{}, &stderr) code := run([]string{"--version"}, failingWriter{}, &stderr)
@@ -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 §Settings: the path names the database file exactly. In // README §Database: 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
@@ -1302,8 +1167,9 @@ func holdScanLock(t *testing.T, path string) {
} }
func TestRunSecondScanFails(t *testing.T) { func TestRunSecondScanFails(t *testing.T) {
// README §Database: a second scan that finds the lock held fails // README §Database: while one scan holds the lock, a second scan
// at once, naming the lock file, without opening the database. // fails at once, naming the lock file, without opening the
// database.
path := testDBPath(t) path := testDBPath(t)
t.Setenv(databaseEnv, path) t.Setenv(databaseEnv, path)
@@ -1362,7 +1228,7 @@ func assertSecondScanFails(t *testing.T, path string) {
} }
func TestRunSecondScanThroughSymlinkFails(t *testing.T) { func TestRunSecondScanThroughSymlinkFails(t *testing.T) {
// README §Database: a path through a symlink names the same lock // README §Database: every path to one database names the same lock
// file, so while a scan holds it through one path, a scan through // file, so while a scan holds it through one path, a scan through
// another fails, naming the lock file beside the database's real // another fails, naming the lock file beside the database's real
// path. // path.
@@ -1467,8 +1333,8 @@ func TestRunReportsDuringScan(t *testing.T) {
} }
func TestRunStdoutClosedIsFatal(t *testing.T) { func TestRunStdoutClosedIsFatal(t *testing.T) {
// README §Error handling: a failed write to stdout is a fatal error, // README §Error handling: a stdout write failure exits 1, reported
// exit 1, reported on stderr starting with "sfdupes:". // in one line on stderr.
for _, name := range []string{cmdReport, cmdTrees, cmdInfo} { for _, name := range []string{cmdReport, cmdTrees, cmdInfo} {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
t.Setenv(databaseEnv, testDBPath(t)) t.Setenv(databaseEnv, testDBPath(t))
+91 -103
View File
@@ -9,7 +9,6 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"unicode/utf8"
"github.com/schollz/progressbar/v3" "github.com/schollz/progressbar/v3"
"golang.org/x/term" "golang.org/x/term"
@@ -35,17 +34,10 @@ const stallTime = 10 * time.Second
// spinnerType selects the progressbar library's spinner style. // spinnerType selects the progressbar library's spinner style.
const spinnerType = 14 const spinnerType = 14
// barWidth is the width of a phase's bar between its two ends, as in // barWidth is the width of the progressbar library's bar between its
// the example line in README "Progress". // two ends, as in the example line in README "Progress".
const barWidth = 10 const barWidth = 10
// barCell is one column of the filled part of a bar.
const barCell = "█"
// barColumns is the width of what percentBar returns: the percent in
// four columns and its sign, a space, and the bar with its two ends.
const barColumns = barWidth + 8
// percentScale converts a fraction to a percentage. // percentScale converts a fraction to a percentage.
const percentScale = 100 const percentScale = 100
@@ -53,14 +45,19 @@ const percentScale = 100
// read, as the progressbar library takes it. // read, as the progressbar library takes it.
const fallbackWidth = 80 const fallbackWidth = 80
// spinnerMargin is the columns of a line besides its text: the spinner // spinnerMargin is the columns of a spinner's line besides its text:
// and the three spaces the library draws around the text, and the // the spinner and the three spaces the library draws around the text,
// terminal's last column, which every line stays clear of so that no // and the terminal's last column, which every line stays clear of so
// terminal wraps it. // that no terminal wraps it.
const spinnerMargin = 5 const spinnerMargin = 5
// barMargin is the columns of a bar's line besides its text: the
// percent and the bar the library draws after the text, the spaces
// around them, and the terminal's last column.
const barMargin = barWidth + 11
// 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
@@ -112,13 +109,13 @@ type drawnCount struct {
} }
// progress renders one phase's progress on stderr. On a TTY the // progress renders one phase's progress on stderr. On a TTY the
// progressbar library draws its spinner and then line, fitted to the // progressbar library draws line, fitted to the terminal's width: after
// terminal's width. A phase with an exact total ends the line with its // the library's spinner, or, for a phase with an exact total, before the
// percent and bar, at the terminal's right edge, when the terminal has // library's bar when the terminal has room for the bar beside it. It is
// room for them. It is drawn again every redrawInterval, so its elapsed // drawn again every redrawInterval, so its elapsed time keeps moving
// time keeps moving while the phase waits for its next item. When // while the phase waits for its next item. When stderr is not a TTY it
// stderr is not a TTY it emits no ANSI redraws: it prints line as the // emits no ANSI redraws: it prints line as the phase starts, every
// phase starts, every plainInterval while it runs, and as it ends. // plainInterval while it runs, and as it ends.
// //
// The draws come from a goroutine of their own, so mu guards the fields // The draws come from a goroutine of their own, so mu guards the fields
// below it. Every method must be called from the main goroutine. A nil // below it. Every method must be called from the main goroutine. A nil
@@ -134,6 +131,7 @@ type progress struct {
mu sync.Mutex mu sync.Mutex
bar *progressbar.ProgressBar // what the library draws; nil until then bar *progressbar.ProgressBar // what the library draws; nil until then
withBar bool // bar is the library's bar, not its spinner
widest int // the widest line bar has drawn, in columns widest int // the widest line bar has drawn, in columns
label string label string
unit string unit string
@@ -143,7 +141,7 @@ type progress struct {
stalledOn string stalledOn string
count int64 count int64
start time.Time // when the phase started, for the elapsed time start time.Time // when the phase started, for the elapsed time
rateStart time.Time // when the count started, for the time remaining rateStart time.Time // when the count started, for the ETA
lastItem time.Time // when the last item was completed, or the count started lastItem time.Time // when the last item was completed, or the count started
// drawn holds the count at each draw over the last rateWindow, oldest // drawn holds the count at each draw over the last rateWindow, oldest
// first; the rate counts from the first. It keeps the draw before the // first; the rate counts from the first. It keeps the draw before the
@@ -217,34 +215,55 @@ func (p *progress) draw() {
} }
if p.width == nil { if p.width == nil {
fmt.Fprintln(os.Stderr, p.line(now, math.MaxInt, false)) fmt.Fprintln(os.Stderr, p.line(now, math.MaxInt, true))
return return
} }
width := p.width() width := p.width()
text := p.line(now, width-spinnerMargin, true)
// Before each draw the library erases its line with as many spaces as // A phase with an exact total gets the library's bar when its whole
// the widest line it has drawn. A display gets a new one when the // line, without the percent the bar shows, fits beside the bar: the
// terminal has been made too narrow for that erase, which would wrap // bar gives way before any part of the line. A total of 0 leaves the
// and leave a line behind at every draw. // bar nothing to fill.
if p.bar == nil || p.widest >= width { text, withBar := "", false
p.newBar(width) if p.total > 0 && !p.estimate {
text = p.line(now, math.MaxInt, false)
withBar = len(text) <= width-barMargin
}
margin := barMargin
if !withBar {
margin = spinnerMargin
text = p.line(now, width-margin, true)
}
// The library makes a bar or a spinner, and before each draw erases
// its line with as many spaces as the widest line it has drawn. A
// display gets a new one when it turns from one to the other, as its
// line grows or the terminal is resized, and when the terminal has
// been made too narrow for that erase, which would wrap and leave a
// line behind at every draw.
if p.bar == nil || p.withBar != withBar || p.widest >= width {
p.newBar(withBar, width)
}
if withBar {
_ = p.bar.Set64(p.count)
} }
p.bar.Describe(text) p.bar.Describe(text)
// The library drew the spinner, the text and the spaces around it, // The library drew the text and its margin, less the terminal's last
// less the terminal's last column, which spinnerMargin keeps clear. A // column, which the margin keeps clear.
// bar's cells take one column each but more than one byte. p.widest = max(p.widest, len(text)+margin-1)
p.widest = max(p.widest, utf8.RuneCountInString(text)+spinnerMargin-1)
} }
// newBar has the library make what draw draws: its spinner, followed by // newBar has the library make what draw draws: a bar for the phase's
// the text draw gives it and nothing of the library's own. p.mu must be // total when withBar is set, and a spinner when not. Either shows the
// held. // text draw gives it, and nothing of its own besides the spinner, or
func (p *progress) newBar(width int) { // the bar and its percent. p.mu must be held.
func (p *progress) newBar(withBar bool, width int) {
if p.bar != nil { if p.bar != nil {
// Erase what was drawn before, short of the last column of a // Erase what was drawn before, short of the last column of a
// terminal that may have been made narrower since: the library's // terminal that may have been made narrower since: the library's
@@ -253,36 +272,29 @@ func (p *progress) newBar(width int) {
fmt.Fprint(os.Stderr, "\r"+blank+"\r") fmt.Fprint(os.Stderr, "\r"+blank+"\r")
} }
// A total of -1 gives the library's spinner. total := int64(-1) // the library's spinner
p.bar = progressbar.NewOptions64(-1, if withBar {
total = p.total
}
p.bar = progressbar.NewOptions64(total,
progressbar.OptionSetWriter(os.Stderr), progressbar.OptionSetWriter(os.Stderr),
progressbar.OptionSetWidth(barWidth),
progressbar.OptionSpinnerType(spinnerType), progressbar.OptionSpinnerType(spinnerType),
// The spinner turns at each draw, not on a timer of the // The spinner turns at each draw, not on a timer of the
// library's own. // library's own.
progressbar.OptionSetSpinnerChangeInterval(0), progressbar.OptionSetSpinnerChangeInterval(0),
// The text carries the elapsed time and the time remaining. // The text carries the elapsed time and the ETA.
progressbar.OptionSetElapsedTime(false), progressbar.OptionSetElapsedTime(false),
progressbar.OptionSetPredictTime(false), progressbar.OptionSetPredictTime(false),
) )
p.widest = 0 p.withBar, p.widest = withBar, 0
}
// percentBar returns the percent and the bar that end the text of a
// phase with an exact total, as " 25% |██ |": the bar's
// barWidth columns fill in proportion to the items done. A count past
// the total, as when a scan adds records while trees reads them, shows
// as 100%. p.mu must be held.
func (p *progress) percentBar() string {
done := min(float64(p.count)/float64(p.total), 1)
filled := strings.Repeat(barCell, int(done*barWidth))
return fmt.Sprintf("%4.0f%% |%-*s|", done*percentScale, barWidth, filled)
} }
// setTotal gives a phase that started without a total its total, once // setTotal gives a phase that started without a total its total, once
// it is known. The rate, the time remaining and the time with no item // it is known. The rate, the ETA and the time with no item completed
// completed count from then on: the time spent waiting for the total // count from then on: the time spent waiting for the total says nothing
// says nothing about how fast items go. // about how fast items go.
func (p *progress) setTotal(total int64) { func (p *progress) setTotal(total int64) {
if p == nil { if p == nil {
return return
@@ -426,26 +438,22 @@ func (p *progress) finish() {
// line formats the phase's progress as of now in at most room columns, // line formats the phase's progress as of now in at most room columns,
// as // as
// //
// [3/4] hash: [1234/5000] 25%, 300 files/s, elapsed 4s, remain 13s // [3/4] hash: [1234/5000] 25%, 300 files/s, elapsed 4s, eta 13s
// //
// where "files" is the phase's unit. A phase with no total shows the // where "files" is the phase's unit, without the percent when percent
// items done instead, or what it waits for. The rate is that since the // is false, as before the library's bar, which shows its own. A phase
// first draw in drawn, and the time remaining comes from the rate since // with no total shows the items done instead, or what it waits for. The
// the count started. Once no item has been completed for stallTime, the // rate is that since the first draw in drawn, and the ETA comes from
// rate, which would read 0, gives way to the word "stalled" and where // the rate since the count started. Once no item has been completed for
// the phase waits, as "stalled disk/db". // stallTime, the rate, which would read 0, gives way to the word
// "stalled" and where the phase waits, as "stalled disk/db".
// //
// When bar is true, as on a terminal, a phase with an exact total ends // The phase and its place, the progress, the elapsed time and the ETA
// the line with its percent and bar, in place of the percent after its // are kept whole. A line wider than room leaves out the rate, the words
// count. A total of 0 leaves the bar nothing to fill. // of a stalled phase, or what the phase waits for; only if it is still
// // wider is it cut at room. Every part is ASCII, so its length is its
// The phase and its place, the progress, the elapsed time and the time // width in columns. p.mu must be held.
// remaining are kept whole. A line wider than room first leaves out the func (p *progress) line(now time.Time, room int, percent bool) string {
// rate, the words of a stalled phase, or what the phase waits for. Next
// it leaves out the bar. Only if it is still wider is it cut at room.
// Every part but the bar is ASCII, so its length is its width in
// columns. p.mu must be held.
func (p *progress) line(now time.Time, room int, bar bool) string {
elapsed := now.Sub(p.start).Round(time.Second) elapsed := now.Sub(p.start).Round(time.Second)
// head and tail hold the parts kept whole; extra, between them, is // head and tail hold the parts kept whole; extra, between them, is
@@ -454,41 +462,21 @@ func (p *progress) line(now time.Time, room int, bar bool) string {
if p.total < 0 && p.waitingFor != "" { if p.total < 0 && p.waitingFor != "" {
head, extra = p.label+": ", p.waitingFor+", " head, extra = p.label+": ", p.waitingFor+", "
tail = fmt.Sprintf("elapsed %s, remain unknown", elapsed) tail = fmt.Sprintf("elapsed %s, eta unknown", elapsed)
} else { } else {
since := p.drawn[0] since := p.drawn[0]
rate := perSecond(p.count-since.count, now.Sub(since.at)) rate := perSecond(p.count-since.count, now.Sub(since.at))
average := perSecond(p.count, now.Sub(p.rateStart)) average := perSecond(p.count, now.Sub(p.rateStart))
head = p.label + ": " + p.itemsDone(true) + ", " head = p.label + ": " + p.itemsDone(percent) + ", "
extra = fmt.Sprintf("%.0f %s/s, ", rate, p.unit) extra = fmt.Sprintf("%.0f %s/s, ", rate, p.unit)
tail = fmt.Sprintf("elapsed %s, remain %s", elapsed, p.remain(average)) tail = fmt.Sprintf("elapsed %s, eta %s", elapsed, p.eta(average))
if now.Sub(p.lastItem) >= stallTime { if now.Sub(p.lastItem) >= stallTime {
extra = "stalled " + p.stalledOn + ", " extra = "stalled " + p.stalledOn + ", "
} }
} }
if bar && p.total > 0 && !p.estimate {
// Beside the bar, which shows the percent, the count shows none.
// The line is padded to the room the bar leaves it, so the bar
// stays at room's right edge however long the line is.
beside := room - barColumns
barHead := p.label + ": " + p.itemsDone(false) + ", "
switch {
case len(barHead+extra+tail) <= beside:
return fmt.Sprintf("%-*s%s", beside, barHead+extra+tail,
p.percentBar())
case len(barHead+tail) <= beside:
return fmt.Sprintf("%-*s%s", beside, barHead+tail, p.percentBar())
}
// The bar gives way. The rate, or the words of a stalled phase,
// gave way before it and stay out.
extra = ""
}
full, kept := head+extra+tail, head+tail full, kept := head+extra+tail, head+tail
switch { switch {
@@ -538,10 +526,10 @@ func (p *progress) itemsDone(percent bool) string {
return fmt.Sprintf("%s %.0f%%", done, pct) return fmt.Sprintf("%s %.0f%%", done, pct)
} }
// remain returns the time the phase has left at rate items a second, or // eta returns the time the phase has left at rate items a second, or
// "unknown": with no total, before the first item, and once the count // "unknown": with no total, before the first item, and once the count
// passes an estimate. p.mu must be held. // passes an estimate. p.mu must be held.
func (p *progress) remain(rate float64) string { func (p *progress) eta(rate float64) string {
switch { switch {
case p.total < 0, p.estimate && p.count > p.total: case p.total < 0, p.estimate && p.count > p.total:
return "unknown" return "unknown"
@@ -551,8 +539,8 @@ func (p *progress) remain(rate float64) string {
return "unknown" return "unknown"
} }
left := time.Duration( remaining := time.Duration(
float64(p.total-p.count) / rate * float64(time.Second)) float64(p.total-p.count) / rate * float64(time.Second))
return left.Round(time.Second).String() return remaining.Round(time.Second).String()
} }
+101 -261
View File
@@ -117,24 +117,24 @@ func TestProgressLine(t *testing.T) {
label: scanPhase("load"), total: -1, label: scanPhase("load"), total: -1,
waitingFor: countingRecords, waitingFor: countingRecords,
}, },
want: "[1/4] load: counting the records, elapsed 10s, remain unknown", want: "[1/4] load: counting the records, elapsed 10s, eta unknown",
}, { }, {
p: &progress{ p: &progress{
label: scanPhase("hash"), unit: scanUnit, total: 100, count: 20, label: scanPhase("hash"), unit: scanUnit, total: 100, count: 20,
}, },
want: "[3/4] hash: [20/100] 20%, 2 files/s, elapsed 10s, remain 40s", want: "[3/4] hash: [20/100] 20%, 2 files/s, elapsed 10s, eta 40s",
}, { }, {
p: &progress{label: scanPhase("hash"), unit: scanUnit, total: 100}, p: &progress{label: scanPhase("hash"), unit: scanUnit, total: 100},
want: "[3/4] hash: [0/100] 0%, 0 files/s, elapsed 10s, remain unknown", want: "[3/4] hash: [0/100] 0%, 0 files/s, elapsed 10s, eta unknown",
}, { }, {
p: &progress{label: scanPhase("update"), unit: scanUnit, total: 0}, p: &progress{label: scanPhase("update"), unit: scanUnit, total: 0},
want: "[4/4] update: [0/0] 100%, 0 files/s, elapsed 10s, remain 0s", want: "[4/4] update: [0/0] 100%, 0 files/s, elapsed 10s, eta 0s",
}, { }, {
p: &progress{ p: &progress{
label: scanPhase("walk"), unit: scanUnit, total: 100, label: scanPhase("walk"), unit: scanUnit, total: 100,
estimate: true, count: 20, estimate: true, count: 20,
}, },
want: "[2/4] walk: [20/~100] 20%, 2 files/s, elapsed 10s, remain 40s", want: "[2/4] walk: [20/~100] 20%, 2 files/s, elapsed 10s, eta 40s",
}, { }, {
// Past the estimate the walk cannot tell how long it has left. // Past the estimate the walk cannot tell how long it has left.
p: &progress{ p: &progress{
@@ -142,13 +142,13 @@ func TestProgressLine(t *testing.T) {
estimate: true, count: 120, estimate: true, count: 120,
}, },
want: "[2/4] walk: [120/~100] 120%, 12 files/s, elapsed 10s, " + want: "[2/4] walk: [120/~100] 120%, 12 files/s, elapsed 10s, " +
"remain unknown", "eta unknown",
}, { }, {
// A first scan's walk has no estimate. // A first scan's walk has no estimate.
p: &progress{ p: &progress{
label: scanPhase("walk"), unit: scanUnit, total: -1, count: 20, label: scanPhase("walk"), unit: scanUnit, total: -1, count: 20,
}, },
want: "[2/4] walk: 20 files, 2 files/s, elapsed 10s, remain unknown", want: "[2/4] walk: 20 files, 2 files/s, elapsed 10s, eta unknown",
}, { }, {
// The rate is in what the phase counts. // The rate is in what the phase counts.
p: &progress{ p: &progress{
@@ -156,14 +156,14 @@ func TestProgressLine(t *testing.T) {
count: 20, count: 20,
}, },
want: "[3/4] group: [20/100] 20%, 2 directories/s, elapsed 10s, " + want: "[3/4] group: [20/100] 20%, 2 directories/s, elapsed 10s, " +
"remain 40s", "eta 40s",
}} }}
for _, tc := range cases { for _, tc := range cases {
tc.p.start, tc.p.rateStart, tc.p.lastItem = ago, ago, now tc.p.start, tc.p.rateStart, tc.p.lastItem = ago, ago, now
tc.p.drawn = []drawnCount{{at: ago}} tc.p.drawn = []drawnCount{{at: ago}}
if got := tc.p.line(now, math.MaxInt, false); got != tc.want { if got := tc.p.line(now, math.MaxInt, true); got != tc.want {
t.Errorf("line = %q, want %q", got, tc.want) t.Errorf("line = %q, want %q", got, tc.want)
} }
} }
@@ -198,49 +198,48 @@ func TestLineFitsRoom(t *testing.T) {
} }
cases := []struct { cases := []struct {
p *progress p *progress
room int room int
bar bool percent bool
want string want string
}{{ }{{
p: hash, room: 60, p: hash, room: 57, percent: true,
want: "[3/4] hash: [20/100] 20%, 2 files/s, elapsed 10s, remain 40s", want: "[3/4] hash: [20/100] 20%, 2 files/s, elapsed 10s, eta 40s",
}, { }, {
p: hash, room: 59, p: hash, room: 56, percent: true,
want: "[3/4] hash: [20/100] 20%, elapsed 10s, remain 40s", want: "[3/4] hash: [20/100] 20%, elapsed 10s, eta 40s",
}, { }, {
p: hash, room: 48, p: hash, room: 45, percent: true,
want: "[3/4] hash: [20/100] 20%, elapsed 10s, remain 40", want: "[3/4] hash: [20/100] 20%, elapsed 10s, eta 40",
}, { }, {
// The bar shows the percent. // Before the library's bar, which shows the percent.
p: hash, room: 74, bar: true, p: hash, room: 57, percent: false,
want: "[3/4] hash: [20/100], 2 files/s, elapsed 10s, remain 40s" + want: "[3/4] hash: [20/100], 2 files/s, elapsed 10s, eta 40s",
" 20% |██ |",
}, { }, {
p: load, room: 43, p: load, room: 40, percent: true,
want: "[1/4] load: elapsed 10s, remain unknown", want: "[1/4] load: elapsed 10s, eta unknown",
}, { }, {
p: stalled, room: 68, p: stalled, room: 65, percent: true,
want: "[3/4] hash: [20/100] 20%, stalled disk/db, elapsed 50s, " + want: "[3/4] hash: [20/100] 20%, stalled disk/db, elapsed 50s, " +
"remain 3m20s", "eta 3m20s",
}, { }, {
p: stalled, room: 67, p: stalled, room: 64, percent: true,
want: "[3/4] hash: [20/100] 20%, elapsed 50s, remain 3m20s", want: "[3/4] hash: [20/100] 20%, elapsed 50s, eta 3m20s",
}} }}
for _, tc := range cases { for _, tc := range cases {
if got := tc.p.line(now, tc.room, tc.bar); got != tc.want { if got := tc.p.line(now, tc.room, tc.percent); got != tc.want {
t.Errorf("line(%d, %t) = %q, want %q", tc.room, tc.bar, got, t.Errorf("line(%d, %t) = %q, want %q",
tc.want) tc.room, tc.percent, got, tc.want)
} }
} }
} }
// TestSetTotalStartsRate checks that setTotal starts a phase's rate, // TestSetTotalStartsRate checks that setTotal starts a phase's rate,
// its time remaining and its time with no record read from when the // its ETA and its time with no record read from when the total becomes
// total becomes known, not from when the phase started waiting for it: // known, not from when the phase started waiting for it: after 20
// after 20 seconds of waiting for the total, the phase has not stalled 5 // seconds of waiting for the total, the phase has not stalled 5 seconds
// seconds after it, and has 10 seconds after it with no record read. // after it, and has 10 seconds after it with no record read.
func TestSetTotalStartsRate(t *testing.T) { func TestSetTotalStartsRate(t *testing.T) {
t.Parallel() t.Parallel()
@@ -256,15 +255,15 @@ func TestSetTotalStartsRate(t *testing.T) {
clock.Add(5 * time.Second) clock.Add(5 * time.Second)
want := "[1/4] load: [0/100] 0%, 0 files/s, elapsed 25s, remain unknown" want := "[1/4] load: [0/100] 0%, 0 files/s, elapsed 25s, eta unknown"
if got := p.line(clock.Now(), math.MaxInt, false); got != want { if got := p.line(clock.Now(), math.MaxInt, true); got != want {
t.Errorf("line = %q, want %q", got, want) t.Errorf("line = %q, want %q", got, want)
} }
clock.Add(5 * time.Second) clock.Add(5 * time.Second)
want = "[1/4] load: [0/100] 0%, stalled db, elapsed 30s, remain unknown" want = "[1/4] load: [0/100] 0%, stalled db, elapsed 30s, eta unknown"
if got := p.line(clock.Now(), math.MaxInt, false); got != want { if got := p.line(clock.Now(), math.MaxInt, true); got != want {
t.Errorf("line = %q, want %q", got, want) t.Errorf("line = %q, want %q", got, want)
} }
@@ -273,8 +272,8 @@ func TestSetTotalStartsRate(t *testing.T) {
p.increment() p.increment()
} }
want = "[1/4] load: [20/100] 20%, 2 files/s, elapsed 30s, remain 40s" want = "[1/4] load: [20/100] 20%, 2 files/s, elapsed 30s, eta 40s"
if got := p.line(clock.Now(), math.MaxInt, false); got != want { if got := p.line(clock.Now(), math.MaxInt, true); got != want {
t.Errorf("line = %q, want %q", got, want) t.Errorf("line = %q, want %q", got, want)
} }
} }
@@ -317,7 +316,7 @@ func TestPlainProgressPrintsAtStartAndEnd(t *testing.T) {
lines := strings.Split(strings.TrimSuffix(stderr(), "\n"), "\n") lines := strings.Split(strings.TrimSuffix(stderr(), "\n"), "\n")
want := "[3/4] hash: [0/1000] 0%, 0 files/s, elapsed 0s, remain unknown" want := "[3/4] hash: [0/1000] 0%, 0 files/s, elapsed 0s, eta unknown"
if len(lines) != 2 || lines[0] != want || if len(lines) != 2 || lines[0] != want ||
!strings.HasPrefix(lines[1], "[3/4] hash: [1000/1000] 100%, ") { !strings.HasPrefix(lines[1], "[3/4] hash: [1000/1000] 100%, ") {
t.Errorf("stderr lines = %q, want %q, then the line at 1000", t.Errorf("stderr lines = %q, want %q, then the line at 1000",
@@ -337,7 +336,7 @@ func TestPlainProgressPrintsWhileWaiting(t *testing.T) {
}, nil, testRedraw, time.Now) }, nil, testRedraw, time.Now)
awaitOutput(t, stderr, awaitOutput(t, stderr,
"[1/4] load: counting the records, elapsed 0s, remain unknown\n", 3) "[1/4] load: counting the records, elapsed 0s, eta unknown\n", 3)
p.finish() p.finish()
} }
@@ -367,23 +366,23 @@ func TestPlainProgressStalledProducer(t *testing.T) {
clock.Add(5 * time.Second) clock.Add(5 * time.Second)
awaitOutput(t, stderr, awaitOutput(t, stderr,
"[3/4] hash: [20/100] 20%, 4 files/s, elapsed 5s, remain 20s\n", 3) "[3/4] hash: [20/100] 20%, 4 files/s, elapsed 5s, eta 20s\n", 3)
clock.Add(time.Second) clock.Add(time.Second)
awaitOutput(t, stderr, awaitOutput(t, stderr,
"[3/4] hash: [20/100] 20%, 0 files/s, elapsed 6s, remain 24s\n", 3) "[3/4] hash: [20/100] 20%, 0 files/s, elapsed 6s, eta 24s\n", 3)
clock.Add(4 * time.Second) clock.Add(4 * time.Second)
awaitOutput(t, stderr, awaitOutput(t, stderr,
"[3/4] hash: [20/100] 20%, stalled disk/db, elapsed 10s, remain 40s\n", "[3/4] hash: [20/100] 20%, stalled disk/db, elapsed 10s, eta 40s\n",
3) 3)
} }
// TestPlainProgressRateAfterStall draws a phase's plain line by hand, a // TestPlainProgressRateAfterStall draws a phase's plain line by hand, a
// little over plainInterval after the line before, as a late tick // little over plainInterval after the line before, as a late tick
// does, through a stall and a burst of files after it: the rate covers // does, through a stall and a burst of files after it: the rate covers
// the files read since the line before, while the time remaining comes // the files read since the line before, while the ETA comes from the
// from the phase's average. // phase's average.
// //
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestPlainProgressRateAfterStall(t *testing.T) { func TestPlainProgressRateAfterStall(t *testing.T) {
@@ -411,10 +410,10 @@ func TestPlainProgressRateAfterStall(t *testing.T) {
p.finish() p.finish()
want := []string{ want := []string{
"[3/4] hash: [0/1000] 0%, 0 files/s, elapsed 0s, remain unknown", "[3/4] hash: [0/1000] 0%, 0 files/s, elapsed 0s, eta unknown",
"[3/4] hash: [0/1000] 0%, 0 files/s, elapsed 5s, remain unknown", "[3/4] hash: [0/1000] 0%, 0 files/s, elapsed 5s, eta unknown",
"[3/4] hash: [0/1000] 0%, stalled disk/db, elapsed 10s, remain unknown", "[3/4] hash: [0/1000] 0%, stalled disk/db, elapsed 10s, eta unknown",
"[3/4] hash: [100/1000] 10%, 20 files/s, elapsed 15s, remain 2m15s", "[3/4] hash: [100/1000] 10%, 20 files/s, elapsed 15s, eta 2m15s",
} }
lines := strings.Split(strings.TrimSuffix(stderr(), "\n"), "\n") lines := strings.Split(strings.TrimSuffix(stderr(), "\n"), "\n")
@@ -427,8 +426,8 @@ func TestPlainProgressRateAfterStall(t *testing.T) {
// redrawInterval, as a terminal does, through a stall of stallTime and // redrawInterval, as a terminal does, through a stall of stallTime and
// a burst of files after it: the rate covers the files read over the // a burst of files after it: the rate covers the files read over the
// last rateWindow, and falls to 0 once the burst is older than that, // last rateWindow, and falls to 0 once the burst is older than that,
// while the time remaining comes from the phase's average. Plain lines // while the ETA comes from the phase's average. Plain lines show each
// show each draw. // draw.
// //
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestProgressRateAfterStallRedrawn(t *testing.T) { func TestProgressRateAfterStallRedrawn(t *testing.T) {
@@ -458,8 +457,8 @@ func TestProgressRateAfterStallRedrawn(t *testing.T) {
redraw(rateWindow + redrawInterval) redraw(rateWindow + redrawInterval)
p.finish() p.finish()
want := "[3/4] hash: [50/1000] 5%, 10 files/s, elapsed 15s, remain 4m45s\n" + want := "[3/4] hash: [50/1000] 5%, 10 files/s, elapsed 15s, eta 4m45s\n" +
"[3/4] hash: [50/1000] 5%, 0 files/s, elapsed 15s, remain 4m47s\n" "[3/4] hash: [50/1000] 5%, 0 files/s, elapsed 15s, eta 4m47s\n"
if !strings.Contains(stderr(), want) { if !strings.Contains(stderr(), want) {
t.Errorf("stderr = %q, want the lines %q", stderr(), want) t.Errorf("stderr = %q, want the lines %q", stderr(), want)
} }
@@ -481,10 +480,10 @@ func lastFrame(out string) string {
} }
// TestTerminalRedrawsWhileWaiting checks that the terminal display of a // TestTerminalRedrawsWhileWaiting checks that the terminal display of a
// phase, with a bar or without, keeps being drawn while the phase waits // phase, spinner or bar, keeps being drawn while the phase waits for its
// for its next item, with the elapsed time as of now, and shows every // next item, with the elapsed time as of now, and shows every item
// item completed before the wait. The terminal is 120 columns wide, so // completed before the wait. stderr is not a terminal here, so the
// the hash phase's display has room for its bar. // displays take it to be fallbackWidth wide.
// //
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestTerminalRedrawsWhileWaiting(t *testing.T) { func TestTerminalRedrawsWhileWaiting(t *testing.T) {
@@ -507,8 +506,7 @@ func TestTerminalRedrawsWhileWaiting(t *testing.T) {
for _, tc := range cases { for _, tc := range cases {
before := len(stderr()) before := len(stderr())
since := func() string { return stderr()[before:] } since := func() string { return stderr()[before:] }
p := startProgress(tc.params, func() int { return 120 }, testRedraw, p := startProgress(tc.params, terminalWidth, testRedraw, time.Now)
time.Now)
for range 50 { for range 50 {
p.increment() p.increment()
@@ -554,13 +552,13 @@ func drawLate(stderr func() string, params progressParams, width int,
return stderr()[before:] return stderr()[before:]
} }
// TestTerminalLinesKeepTimeRemaining checks the line each display shows // TestTerminalLinesKeepETA checks the line each display shows on an
// on an 80-column terminal at a large scan's counts and times, and the // 80-column terminal at a large scan's counts and times, and the line
// line of trees' read phase at the same counts: the rate is left out, // of trees' read phase at the same counts: the rate is left out, so
// so that the elapsed time and the time remaining stay whole. // that the elapsed time and the ETA stay whole.
// //
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestTerminalLinesKeepTimeRemaining(t *testing.T) { func TestTerminalLinesKeepETA(t *testing.T) {
stderr := captureStderr(t) stderr := captureStderr(t)
cases := []struct { cases := []struct {
@@ -573,19 +571,19 @@ func TestTerminalLinesKeepTimeRemaining(t *testing.T) {
estimate: true, estimate: true,
}, },
want: "[2/4] walk: [1234567/~1200000] 103%, elapsed 2h30m0s, " + want: "[2/4] walk: [1234567/~1200000] 103%, elapsed 2h30m0s, " +
"remain unknown", "eta unknown",
}, { }, {
params: progressParams{ params: progressParams{
label: scanPhase("hash"), unit: scanUnit, total: 9876543, label: scanPhase("hash"), unit: scanUnit, total: 9876543,
}, },
want: "[3/4] hash: [1234567/9876543] 12%, elapsed 2h30m0s, " + want: "[3/4] hash: [1234567/9876543] 12%, elapsed 2h30m0s, " +
"remain 17h30m0s", "eta 17h30m0s",
}, { }, {
params: progressParams{ params: progressParams{
label: treesPhase("read"), unit: "records", total: 9876543, label: treesPhase("read"), unit: "records", total: 9876543,
}, },
want: "[2/4] read: [1234567/9876543] 12%, elapsed 2h30m0s, " + want: "[2/4] read: [1234567/9876543] 12%, elapsed 2h30m0s, " +
"remain 17h30m0s", "eta 17h30m0s",
}} }}
for _, tc := range cases { for _, tc := range cases {
@@ -596,32 +594,30 @@ func TestTerminalLinesKeepTimeRemaining(t *testing.T) {
} }
} }
// TestTerminalStalledLines draws every phase's line once the phase has // TestTerminalStalledLines draws every phase's line on an 80-column
// stalled, its last item done 42 seconds before. At an ordinary scan's // terminal once the phase has stalled, its last item done 42 seconds
// counts and times, 12345 of 45678 items done four and a half minutes // before. At an ordinary scan's counts and times, 12345 of 45678 items
// in, on a terminal 100 columns wide, the line shows where the phase // done four and a half minutes in, the line shows where the phase waits
// waits whole, beside the phase, progress, elapsed time and time // whole, beside the phase, progress, elapsed time and ETA. At a large
// remaining. At a large scan's, 1234567 of 9876543 two and a half hours // scan's, 1234567 of 9876543 two and a half hours in, those four still
// in, on an 80-column terminal, those four still show whole, as the // show whole, as the words give way first where they do not fit.
// words give way first where they do not fit.
// //
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestTerminalStalledLines(t *testing.T) { func TestTerminalStalledLines(t *testing.T) {
stderr := captureStderr(t) stderr := captureStderr(t)
// drawStalled draws the display params describes on a terminal width // drawStalled draws the display params describes as it starts and
// columns wide as it starts and once more as it ends, with count of // once more as it ends, with count of total items done elapsed into
// total items done elapsed into the phase, and returns what the // the phase, and returns what the terminal shows.
// terminal shows. drawStalled := func(params progressParams, total, count int64,
drawStalled := func(params progressParams, width int, total, elapsed time.Duration,
count int64, elapsed time.Duration,
) string { ) string {
before := len(stderr()) before := len(stderr())
clock := &testClock{now: time.Now()} clock := &testClock{now: time.Now()}
params.total = total params.total = total
// No timed draws come between the two. // No timed draws come between the two.
p := startProgress(params, func() int { return width }, time.Hour, p := startProgress(params, func() int { return 80 }, time.Hour,
clock.Now) clock.Now)
clock.Add(elapsed - 42*time.Second) clock.Add(elapsed - 42*time.Second)
@@ -653,22 +649,19 @@ func TestTerminalStalledLines(t *testing.T) {
approx = "~" approx = "~"
} }
// The percent is in the line, or beside the bar. shown := drawStalled(params, 45678, 12345,
shown := drawStalled(params, 100, 45678, 12345,
4*time.Minute+30*time.Second) 4*time.Minute+30*time.Second)
for _, want := range []string{ want := params.label + ": [12345/" + approx + "45678] 27%, stalled " +
params.label + ": [12345/" + approx + "45678]", params.stalledOn + ", elapsed 4m30s, eta 12m9s"
", stalled " + params.stalledOn + ", elapsed 4m30s, remain 12m9s",
} { if !strings.Contains(shown, want) {
if !strings.Contains(shown, want) { t.Errorf("terminal shows %q, want %q", shown, want)
t.Errorf("terminal shows %q, want %q", shown, want)
}
} }
shown = drawStalled(params, 80, 9876543, 1234567, 150*time.Minute) shown = drawStalled(params, 9876543, 1234567, 150*time.Minute)
for _, want := range []string{ for _, want := range []string{
params.label + ": [1234567/" + approx + "9876543] 12%, ", params.label + ": [1234567/" + approx + "9876543] 12%, ",
", elapsed 2h30m0s, remain 17h30m0s", ", elapsed 2h30m0s, eta 17h30m0s",
} { } {
if !strings.Contains(shown, want) { if !strings.Contains(shown, want) {
t.Errorf("terminal shows %q, want %q", shown, want) t.Errorf("terminal shows %q, want %q", shown, want)
@@ -713,164 +706,13 @@ func TestTerminalLinesFitWidth(t *testing.T) {
} }
} }
// TestTerminalLinesStartWithSpinner draws a display with no total and
// one with a bar, two and a half hours into the phase, on a terminal 120
// columns wide. Each line must start with the library's spinner in the
// first column, then a space and the phase's label.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestTerminalLinesStartWithSpinner(t *testing.T) {
stderr := captureStderr(t)
for _, params := range []progressParams{
{label: scanPhase("walk"), unit: scanUnit, total: -1},
{label: scanPhase("hash"), unit: scanUnit, total: 9876543},
} {
shown := lastFrame(drawLate(stderr, params, 120, 1234567))
spinner, rest, _ := strings.Cut(shown, " ")
if utf8.RuneCountInString(spinner) != 1 ||
!strings.HasPrefix(rest, params.label+": ") {
t.Errorf("terminal shows %q, want the spinner, a space and %q",
shown, params.label)
}
if params.total > 0 && !strings.Contains(shown, "% |") {
t.Errorf("terminal shows %q, want a bar", shown)
}
}
}
// TestTerminalBarStaysAtRightEdge draws the hash phase's display two
// and a half hours in, at two counts whose lines differ in length, on
// terminals 110 and 120 columns wide. The bar is partly filled at the
// first count and full at the second. It must start the same number of
// columns from the terminal's right edge each time, and end the same
// number of columns from it: a line that grows or shrinks, as its time
// remaining does, never moves the bar, and nor do the bar's filled
// cells.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestTerminalBarStaysAtRightEdge(t *testing.T) {
stderr := captureStderr(t)
params := progressParams{
label: scanPhase("hash"), unit: scanUnit, total: 9876543,
}
widths := []int{110, 120}
counts := []int64{1234567, 9876543}
startFromEdge := make([]int, 0, len(widths)*len(counts))
endFromEdge := make([]int, 0, len(widths)*len(counts))
for _, width := range widths {
for _, count := range counts {
shown := lastFrame(drawLate(stderr, params, width, count))
beforeBar, _, found := strings.Cut(shown, "% |")
if !found {
t.Fatalf("terminal %d wide shows %q, want a bar", width, shown)
}
// The bar ends with the last "|".
throughBar := shown[:strings.LastIndex(shown, "|")+1]
startFromEdge = append(startFromEdge,
width-utf8.RuneCountInString(beforeBar))
endFromEdge = append(endFromEdge,
width-utf8.RuneCountInString(throughBar))
}
}
if slices.Min(startFromEdge) != slices.Max(startFromEdge) {
t.Errorf("bar starts %v columns from the right edge, want the same "+
"every time", startFromEdge)
}
if slices.Min(endFromEdge) != slices.Max(endFromEdge) {
t.Errorf("bar ends %v columns from the right edge, want the same "+
"every time", endFromEdge)
}
}
// TestTerminalRateGivesWayBeforeBar draws the hash phase's display two
// and a half hours in. On a terminal 90 columns wide the line fits
// beside the bar without its rate, but not with it: the bar is drawn
// and the rate is not. On a terminal 85 columns wide the line does not
// fit beside the bar even without its rate: neither is drawn, though
// the rate would fit where the bar was.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestTerminalRateGivesWayBeforeBar(t *testing.T) {
stderr := captureStderr(t)
params := progressParams{
label: scanPhase("hash"), unit: scanUnit, total: 9876543,
}
shown := lastFrame(drawLate(stderr, params, 90, 1234567))
if !strings.Contains(shown, "% |") || strings.Contains(shown, "files/s") {
t.Errorf("terminal 90 wide shows %q, want the bar and no rate", shown)
}
shown = lastFrame(drawLate(stderr, params, 85, 1234567))
if strings.Contains(shown, "% |") || strings.Contains(shown, "files/s") {
t.Errorf("terminal 85 wide shows %q, want neither bar nor rate", shown)
}
}
// TestTerminalSpinnerTurnsBesideBar draws the hash phase's display, its
// bar partly filled, several times on a terminal 120 columns wide. The
// spinner must turn at every draw. A filled cell of the bar takes one
// column but three bytes. A line measured in bytes would seem too wide
// for the terminal, and every draw would start the display over, its
// spinner included.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestTerminalSpinnerTurnsBesideBar(t *testing.T) {
stderr := captureStderr(t)
params := progressParams{
label: scanPhase("hash"), unit: scanUnit, total: 9876543,
}
// No timed draws: the test draws.
p := startProgress(params, func() int { return 120 }, time.Hour,
time.Now)
p.mu.Lock()
p.count = 1234567
p.mu.Unlock()
const draws = 4
spinners := make([]string, 0, draws)
for range draws {
before := len(stderr())
p.draw()
spinner, _, _ := strings.Cut(lastFrame(stderr()[before:]), " ")
spinners = append(spinners, spinner)
}
p.finish()
for i := 1; i < len(spinners); i++ {
if spinners[i] == spinners[i-1] {
t.Errorf("spinners at successive draws are %q, want each to "+
"differ from the one before", spinners)
break
}
}
}
// TestTerminalNarrowedRedrawsInPlace draws a large scan's display, two // TestTerminalNarrowedRedrawsInPlace draws a large scan's display, two
// and a half hours into the phase, on a terminal 120 columns wide, then // and a half hours into the phase, on a terminal 120 columns wide, then
// makes the terminal narrower than that line and draws it again a few // makes the terminal narrower than that line and draws it again a few
// times. Nothing written after that, the erase before each redraw // times. Nothing written after that, the erase before each redraw
// included, may reach the narrower terminal's last column: it would // included, may reach the narrower terminal's last column: it would
// wrap, and every redraw would leave a line behind. At the narrower // wrap, and every redraw would leave a line behind. At these widths the
// width the hash phase's bar gives way. // spinner stays a spinner and the bar gives way to the spinner.
// //
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestTerminalNarrowedRedrawsInPlace(t *testing.T) { func TestTerminalNarrowedRedrawsInPlace(t *testing.T) {
@@ -888,7 +730,7 @@ func TestTerminalNarrowedRedrawsInPlace(t *testing.T) {
progressParams{ progressParams{
label: scanPhase("hash"), unit: scanUnit, total: 9876543, label: scanPhase("hash"), unit: scanUnit, total: 9876543,
}, },
80, 90,
}, },
} }
@@ -928,10 +770,10 @@ func TestTerminalNarrowedRedrawsInPlace(t *testing.T) {
} }
} }
// TestProgressWarningsOnOwnLines drives the terminal displays, with a // TestProgressWarningsOnOwnLines drives the terminal displays, spinner
// bar and without, through a run of warnings with no items between // and bar, through a run of warnings with no items between them, as when
// them, as when the walk meets many unreadable paths, for many redraws: // the walk meets many unreadable paths, for many redraws: every warning
// every warning must land on a line of its own, never inside a redraw. // must land on a line of its own, never inside a redraw.
// //
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestProgressWarningsOnOwnLines(t *testing.T) { func TestProgressWarningsOnOwnLines(t *testing.T) {
@@ -977,12 +819,11 @@ func TestProgressWarningsOnOwnLines(t *testing.T) {
} }
} }
// TestProgressWriterRowsOnOwnLines drives the terminal displays, with a // TestProgressWriterRowsOnOwnLines drives the terminal displays,
// bar and without, through a run of rows written through the display's // spinner and bar, through a run of rows written through the display's
// writer to the same terminal, as stdout is in a plain "sfdupes trees", // writer to the same terminal, as stdout is in a plain "sfdupes trees",
// each written out at once, for many redraws: the terminal must show // each written out at once, for many redraws: the terminal must show
// every row whole, in order, on a line of its own. The terminal is 120 // every row whole, in order, on a line of its own.
// columns wide, so the write phase's display has room for its bar.
// //
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestProgressWriterRowsOnOwnLines(t *testing.T) { func TestProgressWriterRowsOnOwnLines(t *testing.T) {
@@ -999,8 +840,7 @@ func TestProgressWriterRowsOnOwnLines(t *testing.T) {
var want []string var want []string
for _, params := range cases { for _, params := range cases {
p := startProgress(params, func() int { return 120 }, testRedraw, p := startProgress(params, terminalWidth, testRedraw, time.Now)
time.Now)
out := p.writer(os.Stderr) out := p.writer(os.Stderr)
// No pause between rows: one is garbled only if a redraw lands // No pause between rows: one is garbled only if a redraw lands
+12 -15
View File
@@ -2,7 +2,6 @@ package main
import ( import (
"context" "context"
"crypto/sha256"
"database/sql" "database/sql"
"fmt" "fmt"
"io" "io"
@@ -21,16 +20,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 string
head [sha256.Size]byte tail string
tail [sha256.Size]byte content string
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 +44,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 {
+40 -64
View File
@@ -2,7 +2,6 @@ package main
import ( import (
"bytes" "bytes"
"crypto/sha256"
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
@@ -19,27 +18,14 @@ import (
// awkwardDir is a directory name holding every byte the reports escape. // awkwardDir is a directory name holding every byte the reports escape.
const awkwardDir = "/d/\tone\ntwo\rthree\\four" const awkwardDir = "/d/\tone\ntwo\rthree\\four"
// testHash returns a stand-in hash for a test record: the bytes of s,
// then zeros. Stand-ins sort as their strings do, so a test can choose
// the order in which report reads its records.
func testHash(s string) [sha256.Size]byte {
var h [sha256.Size]byte
copy(h[:], s)
return h
}
// awkwardPairRecs is a duplicate pair in sibling directories /d/A and // awkwardPairRecs is a duplicate pair in sibling directories /d/A and
// awkwardDir. A raw tab sorts before "A" but its escaped form `\t` // awkwardDir. A raw tab sorts before "A" but its escaped form `\t`
// sorts after it, so awkwardDir coming first shows that sorting uses // sorts after it, so awkwardDir coming first shows that sorting uses
// the raw path. // the raw path.
func awkwardPairRecs() []scanRec { func awkwardPairRecs() []scanRec {
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: "t", content: "c", path: "/d/A/f"},
{size: 5, diskSize: 4096, head: h, tail: tl, content: c, path: awkwardDir + "/f"}, {size: 5, head: "h", tail: "t", content: "c", path: awkwardDir + "/f"},
} }
} }
@@ -157,8 +143,8 @@ func TestReportStdoutFailsWhileReading(t *testing.T) {
recs := make([]scanRec, 2*ioBufSize/len(dir)) recs := make([]scanRec, 2*ioBufSize/len(dir))
for i := range recs { for i := range recs {
recs[i] = scanRec{ recs[i] = scanRec{
size: int64(i/2) + 1, head: testHash("h"), tail: testHash("t"), size: int64(i/2) + 1, head: "h", tail: "t", content: "c",
content: testHash("c"), path: fmt.Sprintf("%s/%d", dir, i), path: fmt.Sprintf("%s/%d", dir, i),
} }
} }
@@ -187,7 +173,7 @@ func TestReportStdoutFailsWhileReading(t *testing.T) {
// reportOpenLine is the plain line of report's open phase when it opens // reportOpenLine is the plain line of report's open phase when it opens
// the database at once; it prints it as the phase starts and as it ends. // the database at once; it prints it as the phase starts and as it ends.
const reportOpenLine = "[1/2] open: opening the database, elapsed 0s, " + const reportOpenLine = "[1/2] open: opening the database, elapsed 0s, " +
"remain unknown" "eta unknown"
// smokeReport is report's stdout for the smoke-test records. // smokeReport is report's stdout for the smoke-test records.
const smokeReport = "first\tdupe\tsize\n" + const smokeReport = "first\tdupe\tsize\n" +
@@ -225,7 +211,7 @@ func TestRunReportProgressNamesEachPhase(t *testing.T) {
"report: database " + path + " (from " + databaseEnv + ")", "report: database " + path + " (from " + databaseEnv + ")",
reportOpenLine, reportOpenLine,
reportOpenLine, reportOpenLine,
"[2/2] read: counting the records, elapsed 0s, remain unknown", "[2/2] read: counting the records, elapsed 0s, eta unknown",
"[2/2] read: [6/6] 100%, ", "[2/2] read: [6/6] 100%, ",
"report: 6 records read, 2 duplicate groups, 4 dupe files, " + "report: 6 records read, 2 duplicate groups, 4 dupe files, " +
"6.1 KiB reclaimable", "6.1 KiB reclaimable",
@@ -291,14 +277,12 @@ func TestReadReportOnTerminal(t *testing.T) {
func TestRunReportsIgnoreInsertionOrder(t *testing.T) { func TestRunReportsIgnoreInsertionOrder(t *testing.T) {
// README §Constraints: identical database contents give identical // README §Constraints: identical database contents give identical
// output, whatever order the records were inserted in. // output, whatever order the records were inserted in.
a, b := testHash("a"), testHash("b")
recs := append(smokeTreeRecs(), awkwardPairRecs()...) recs := append(smokeTreeRecs(), awkwardPairRecs()...)
recs = append(recs, recs = append(recs,
scanRec{size: 50, head: b, tail: b, content: b, path: "/y/2"}, scanRec{size: 50, head: "b", tail: "b", content: "b", path: "/y/2"},
scanRec{size: 50, head: b, tail: b, content: b, path: "/y/1"}, scanRec{size: 50, head: "b", tail: "b", content: "b", path: "/y/1"},
scanRec{size: 50, head: a, tail: a, content: a, path: "/x/2"}, scanRec{size: 50, head: "a", tail: "a", content: "a", path: "/x/2"},
scanRec{size: 50, head: a, tail: a, content: a, path: "/x/1"}, scanRec{size: 50, head: "a", tail: "a", content: "a", path: "/x/1"},
) )
reversed := slices.Clone(recs) reversed := slices.Clone(recs)
@@ -401,19 +385,16 @@ func TestWarnfEscapes(t *testing.T) {
func TestDupeGroups(t *testing.T) { func TestDupeGroups(t *testing.T) {
t.Parallel() t.Parallel()
h, tl, c := testHash("h"), testHash("t"), testHash("c")
bigH, bigT, bigC := testHash("H"), testHash("T"), testHash("C")
u := testHash("u")
recs := []scanRec{ recs := []scanRec{
{size: 100, head: h, tail: tl, content: c, path: "/z/b"}, {size: 100, head: "h", tail: "t", content: "c", path: "/z/b"},
{size: 100, head: h, tail: tl, content: c, path: "/z/a"}, {size: 100, head: "h", tail: "t", content: "c", path: "/z/a"},
{size: 100, head: h, tail: tl, content: c, path: "/z/c"}, {size: 100, head: "h", tail: "t", content: "c", path: "/z/c"},
{size: 4000, head: bigH, tail: bigT, content: bigC, path: "/big/2"}, {size: 4000, head: "H", tail: "T", content: "C", path: "/big/2"},
{size: 4000, head: bigH, tail: bigT, content: bigC, path: "/big/1"}, {size: 4000, head: "H", tail: "T", content: "C", path: "/big/1"},
// Same size as the /z group but a different head hash. // Same size as the /z group but a different head hash.
{size: 100, head: testHash("other"), tail: tl, content: c, path: "/z/d"}, {size: 100, head: "other", tail: "t", content: "c", path: "/z/d"},
// A singleton signature must not form a group. // A singleton signature must not form a group.
{size: 7, head: u, tail: u, content: u, path: "/lonely"}, {size: 7, head: "u", tail: "u", content: "u", path: "/lonely"},
} }
groups := dupeGroupsOf(t, recs) groups := dupeGroupsOf(t, recs)
@@ -439,11 +420,10 @@ func TestDupeGroupsContentSeparates(t *testing.T) {
// Same size, head, and tail: /b differs from the others only in its // Same size, head, and tail: /b differs from the others only in its
// content hash, so it joins no group, while /a and /c group. // content hash, so it joins no group, while /a and /c group.
h, tl := testHash("h"), testHash("t")
recs := []scanRec{ recs := []scanRec{
{size: 100, head: h, tail: tl, content: testHash("c1"), path: "/a"}, {size: 100, head: "h", tail: "t", content: "c1", path: "/a"},
{size: 100, head: h, tail: tl, content: testHash("c2"), path: "/b"}, {size: 100, head: "h", tail: "t", content: "c2", path: "/b"},
{size: 100, head: h, tail: tl, content: testHash("c1"), path: "/c"}, {size: 100, head: "h", tail: "t", content: "c1", path: "/c"},
} }
groups := dupeGroupsOf(t, recs) groups := dupeGroupsOf(t, recs)
@@ -462,15 +442,14 @@ func TestDupeGroupsMtimeExcluded(t *testing.T) {
// mtime is informational only; records differing only in mtime // mtime is informational only; records differing only in mtime
// still group together. // still group together.
h, tl, c := testHash("h"), testHash("t"), testHash("c")
recs := []scanRec{ recs := []scanRec{
{ {
size: 9, mtime: time.Unix(100, 0), head: h, tail: tl, size: 9, mtime: time.Unix(100, 0), head: "h", tail: "t",
content: c, path: "/m/1", content: "c", path: "/m/1",
}, },
{ {
size: 9, mtime: time.Unix(200, 0), head: h, tail: tl, size: 9, mtime: time.Unix(200, 0), head: "h", tail: "t",
content: c, path: "/m/2", content: "c", path: "/m/2",
}, },
} }
@@ -485,12 +464,11 @@ func TestDupeGroupsTieBreak(t *testing.T) {
// The hashes sort opposite to the first paths, so ordering the // The hashes sort opposite to the first paths, so ordering the
// groups by hash instead of by first path fails this test. // groups by hash instead of by first path fails this test.
a, b := testHash("a"), testHash("b")
recs := []scanRec{ recs := []scanRec{
{size: 50, head: a, tail: a, content: a, path: "/beta/2"}, {size: 50, head: "a", tail: "a", content: "a", path: "/beta/2"},
{size: 50, head: a, tail: a, content: a, path: "/beta/1"}, {size: 50, head: "a", tail: "a", content: "a", path: "/beta/1"},
{size: 50, head: b, tail: b, content: b, path: "/alpha/2"}, {size: 50, head: "b", tail: "b", content: "b", path: "/alpha/2"},
{size: 50, head: b, tail: b, content: b, path: "/alpha/1"}, {size: 50, head: "b", tail: "b", content: "b", path: "/alpha/1"},
} }
groups := dupeGroupsOf(t, recs) groups := dupeGroupsOf(t, recs)
@@ -512,16 +490,15 @@ func TestDupeGroupsOrderedByFirstPath(t *testing.T) {
// /z, /k, which is neither the order of their first paths nor its // /z, /k, which is neither the order of their first paths nor its
// reverse, and must write them ordered by first path. The group of // reverse, and must write them ordered by first path. The group of
// size 7 comes after them all, though its first path sorts first. // size 7 comes after them all, though its first path sorts first.
a, b, c, d := testHash("a"), testHash("b"), testHash("c"), testHash("d")
recs := []scanRec{ recs := []scanRec{
{size: 50, head: a, tail: a, content: a, path: "/k/2"}, {size: 50, head: "a", tail: "a", content: "a", path: "/k/2"},
{size: 50, head: a, tail: a, content: a, path: "/k/1"}, {size: 50, head: "a", tail: "a", content: "a", path: "/k/1"},
{size: 50, head: b, tail: b, content: b, path: "/z/1"}, {size: 50, head: "b", tail: "b", content: "b", path: "/z/1"},
{size: 50, head: b, tail: b, content: b, path: "/z/2"}, {size: 50, head: "b", tail: "b", content: "b", path: "/z/2"},
{size: 50, head: c, tail: c, content: c, path: "/a/2"}, {size: 50, head: "c", tail: "c", content: "c", path: "/a/2"},
{size: 50, head: c, tail: c, content: c, path: "/a/1"}, {size: 50, head: "c", tail: "c", content: "c", path: "/a/1"},
{size: 7, head: d, tail: d, content: d, path: "/0/2"}, {size: 7, head: "d", tail: "d", content: "d", path: "/0/2"},
{size: 7, head: d, tail: d, content: d, path: "/0/1"}, {size: 7, head: "d", tail: "d", content: "d", path: "/0/1"},
} }
groups := dupeGroupsOf(t, recs) groups := dupeGroupsOf(t, recs)
@@ -540,12 +517,11 @@ func TestDupeGroupsOrderedByFirstPath(t *testing.T) {
func TestDupeGroupsDeterministic(t *testing.T) { func TestDupeGroupsDeterministic(t *testing.T) {
t.Parallel() t.Parallel()
a, b := testHash("a"), testHash("b")
recs := []scanRec{ recs := []scanRec{
{size: 1, head: a, tail: a, content: a, path: "/p/1"}, {size: 1, head: "a", tail: "a", content: "a", path: "/p/1"},
{size: 1, head: a, tail: a, content: a, path: "/p/2"}, {size: 1, head: "a", tail: "a", content: "a", path: "/p/2"},
{size: 2, head: b, tail: b, content: b, path: "/q/1"}, {size: 2, head: "b", tail: "b", content: "b", path: "/q/1"},
{size: 2, head: b, tail: b, content: b, path: "/q/2"}, {size: 2, head: "b", tail: "b", content: "b", path: "/q/2"},
} }
forward := dupeGroupsOf(t, recs) forward := dupeGroupsOf(t, recs)
+115 -210
View File
@@ -5,6 +5,7 @@ import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"database/sql" "database/sql"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -52,26 +53,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 +340,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 +384,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 +412,14 @@ 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.
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
// up to seconds after the write.
if old.diskSize != ev.rec.diskSize {
s.diskSizes = append(s.diskSizes, ev.rec)
}
} }
prog.finish() prog.finish()
@@ -642,9 +630,8 @@ func isDatabaseFile(f fileRec, rules walkRules) bool {
// (an interrupted scan resumes cheaply: the next run skips everything // (an interrupted scan resumes cheaply: the next run skips everything
// already recorded). A run that fails to hash is warned about and // already recorded). A run that fails to hash is warned about and
// counted as skipped; stale records for its paths, if any, are deleted // counted as skipped; stale records for its paths, if any, are deleted
// by the update phase. A run whose file no longer exists gets no record // by the update phase. The total counts actual reads, so the display
// and no warning, and is not counted as skipped. The total counts actual // shows a real ETA.
// reads, so the display's time remaining is meaningful.
// //
// Returning early — a failed database write, or a cancelled scan — must // Returning early — a failed database write, or a cancelled scan — must
// not strand the pool: the feeder would park forever on a full jobs // not strand the pool: the feeder would park forever on a full jobs
@@ -676,11 +663,6 @@ func (s *scanState) hashPhase(ctx context.Context, workers int) error {
prog.increment() prog.increment()
// A file removed since the walk is no error: it gets no record.
if errors.Is(r.err, fs.ErrNotExist) {
continue
}
if r.err != nil { if r.err != nil {
s.st.skipped += len(r.run) s.st.skipped += len(r.run)
@@ -706,13 +688,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 +709,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 +720,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 +734,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 +749,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)
} }
@@ -855,12 +830,10 @@ type walkEvent struct {
// startWalk seeds every root into the shared walk worker pool and // startWalk seeds every root into the shared walk worker pool and
// returns the event stream: one record per regular file, one warning // returns the event stream: one record per regular file, one warning
// event per per-path error, and one event per path -x or rules kept the // event per per-path error, and one event per path -x or rules kept the
// walk from entering or recording. A path that no longer exists when // walk from entering or recording. The channel is closed when the walk
// the walk reaches it is no error, and gets no event. The channel is // completes, and also when ctx is cancelled — every goroutine in the
// closed when the walk completes, and also when ctx is cancelled — // pool abandons its blocking send in that case, so the consumer sees a
// every goroutine in the pool abandons its blocking send in that case, // truncated but properly terminated stream instead of a stalled one.
// so the consumer sees a truncated but properly terminated stream
// instead of a stalled one.
func startWalk(ctx context.Context, roots []string, oneFS bool, func startWalk(ctx context.Context, roots []string, oneFS bool,
rules walkRules, workers int, rules walkRules, workers int,
) <-chan walkEvent { ) <-chan walkEvent {
@@ -893,24 +866,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(); {
@@ -942,19 +900,13 @@ func operandWarning(root string, fi fs.FileInfo) string {
// one of the scan's own database files in rules, and a directory // one of the scan's own database files in rules, and a directory
// operand becomes an initial job. walkableRoots has already dropped // operand becomes an initial job. walkableRoots has already dropped
// every other operand. One that has changed into something else since // every other operand. One that has changed into something else since
// is warned about and skipped here, and one removed since is skipped // is warned about and skipped here; it is still a root, so the records
// without a warning. Either is still a root, so the records stored // stored beneath it, other than those under another root, are deleted
// beneath it, other than those under another root, are deleted as // as unverified.
// unverified.
func seedRoot(ctx context.Context, root string, rules walkRules, func seedRoot(ctx context.Context, root string, rules walkRules,
events chan<- walkEvent, events chan<- walkEvent,
) []dirJob { ) []dirJob {
fi, err := os.Lstat(root) fi, err := os.Lstat(root)
if errors.Is(err, fs.ErrNotExist) {
// Removed since the scan started: no error, and no files.
return nil
}
if err != nil { if err != nil {
sendEvent(ctx, events, walkEvent{ sendEvent(ctx, events, walkEvent{
warn: fmt.Sprintf("walk %s: %v", root, err), warn: fmt.Sprintf("walk %s: %v", root, err),
@@ -979,12 +931,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) {
@@ -1073,17 +1024,11 @@ func dispatchDirs(ctx context.Context, initial []dirJob,
// walkOneDir reads one directory, emitting an event per regular-file // walkOneDir reads one directory, emitting an event per regular-file
// entry and a warning event per unreadable one, and returns the // entry and a warning event per unreadable one, and returns the
// subdirectories to descend into. A directory or entry removed since // subdirectories to descend into.
// the walk found it gets no event.
func walkOneDir(ctx context.Context, job dirJob, oneFS bool, func walkOneDir(ctx context.Context, job dirJob, oneFS bool,
rules walkRules, events chan<- walkEvent, rules walkRules, events chan<- walkEvent,
) []dirJob { ) []dirJob {
entries, err := os.ReadDir(job.path) entries, err := os.ReadDir(job.path)
if errors.Is(err, fs.ErrNotExist) {
// Removed since its parent was read: no error, and no files.
return nil
}
if err != nil { if err != nil {
sendEvent(ctx, events, walkEvent{ sendEvent(ctx, events, walkEvent{
warn: fmt.Sprintf("walk %s: %v", job.path, err), warn: fmt.Sprintf("walk %s: %v", job.path, err),
@@ -1126,22 +1071,16 @@ func walkOneDir(ctx context.Context, job dirJob, oneFS bool,
// emitFile stats one regular directory entry and emits its record. // emitFile stats one regular directory entry and emits its record.
// The lstat happens here in the walk worker, while the directory's // The lstat happens here in the walk worker, while the directory's
// metadata is still hot; a path that fails to stat between the // metadata is still hot; a path that fails to stat (or stops being a
// directory read and the lstat is warned about and skipped, unless it // regular file) between the directory read and the lstat is warned
// no longer exists, and one that has stopped being a regular file is // about and skipped. A file on a filesystem in rules that is not
// skipped. A file on a filesystem in rules that is not persistent, as // persistent, as through a bind mount of one file, is not recorded: it
// through a bind mount of one file, is not recorded: it gets an event // gets an event naming it instead. One of the scan's own database files
// naming it instead. One of the scan's own database files in rules gets // in rules gets no event at all.
// no event at all.
func emitFile(ctx context.Context, p string, e fs.DirEntry, func emitFile(ctx context.Context, p string, e fs.DirEntry,
rules walkRules, events chan<- walkEvent, rules walkRules, events chan<- walkEvent,
) { ) {
info, err := e.Info() info, err := e.Info()
if errors.Is(err, fs.ErrNotExist) {
// Removed since its directory was read: no error.
return
}
if err != nil { if err != nil {
sendEvent(ctx, events, walkEvent{ sendEvent(ctx, events, walkEvent{
warn: fmt.Sprintf("stat %s: %v", p, err), warn: fmt.Sprintf("stat %s: %v", p, err),
@@ -1164,12 +1103,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) {
@@ -1194,11 +1132,6 @@ func subdirJob(ctx context.Context, p string, e fs.DirEntry,
} }
info, err := e.Info() info, err := e.Info()
if errors.Is(err, fs.ErrNotExist) {
// Removed since its parent was read: no error, and no files.
return dirJob{}, false
}
if err != nil { if err != nil {
sendEvent(ctx, events, walkEvent{ sendEvent(ctx, events, walkEvent{
warn: fmt.Sprintf("walk %s: %v", p, err), warn: fmt.Sprintf("walk %s: %v", p, err),
@@ -1261,31 +1194,14 @@ 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.
type hashResult struct { type hashResult struct {
run []fileRec run []fileRec
head [sha256.Size]byte head string
tail [sha256.Size]byte tail string
content [sha256.Size]byte content string
err error err error
} }
@@ -1305,8 +1221,7 @@ type hashPool struct {
// hash each run's first path with hash (all paths in a run are hard // hash each run's first path with hash (all paths in a run are hard
// links to the same inode) and write one result per run. // links to the same inode) and write one result per run.
func startHashPool(ctx context.Context, runs [][]fileRec, workers int, func startHashPool(ctx context.Context, runs [][]fileRec, workers int,
hash func(path string, size int64) ( hash func(path string, size int64) (string, string, string, error),
[sha256.Size]byte, [sha256.Size]byte, [sha256.Size]byte, error),
) *hashPool { ) *hashPool {
ctx, cancel := context.WithCancel(ctx) ctx, cancel := context.WithCancel(ctx)
@@ -1371,8 +1286,7 @@ func feedHashJobs(ctx context.Context, runs [][]fileRec,
// the hash of only delays that. // the hash of only delays that.
func hashWorker(ctx context.Context, jobs <-chan []fileRec, func hashWorker(ctx context.Context, jobs <-chan []fileRec,
results chan<- hashResult, results chan<- hashResult,
hash func(path string, size int64) ( hash func(path string, size int64) (string, string, string, error),
[sha256.Size]byte, [sha256.Size]byte, [sha256.Size]byte, error),
) { ) {
for run := range jobs { for run := range jobs {
if ctx.Err() != nil { if ctx.Err() != nil {
@@ -1381,13 +1295,6 @@ func hashWorker(ctx context.Context, jobs <-chan []fileRec,
head, tail, content, err := hash(run[0].path, run[0].size) head, tail, content, err := hash(run[0].path, run[0].size)
// A path removed since the walk is dropped from its run. The
// next path is a hard link to the same file, and is read instead.
for errors.Is(err, fs.ErrNotExist) && len(run) > 1 {
run = run[1:]
head, tail, content, err = hash(run[0].path, run[0].size)
}
select { select {
case results <- hashResult{ case results <- hashResult{
run: run, head: head, tail: tail, content: content, err: err, run: run, head: head, tail: tail, content: content, err: err,
@@ -1430,38 +1337,37 @@ func openRegularFile(path string) (*os.File, error) {
return f, nil return f, nil
} }
// emptyHash is the lowercase-hex SHA-256 of the empty input: the head,
// tail, and content hash of every zero-length file.
const emptyHash = "e3b0c44298fc1c149afbf4c8996fb924" +
"27ae41e4649b934ca495991b7852b855"
// hashSignature computes the three hashes the hash phase records for a // hashSignature computes the three hashes the hash phase records for a
// file: head and tail (see hashEnds) and content (see hashContent). // file: head and tail (see hashEnds) and content (see hashContent).
// With the file size they form its duplicate signature, and two files // With the file size they form its duplicate signature, and two files
// are duplicates only when all four agree. size is the value recorded // are duplicates only when all four agree. size is the value recorded
// when the file was statted; a zero-length file has constant hashes and // when the file was statted; a zero-length file has constant hashes and
// is never opened. On an error every hash is left zero. // is never opened.
func hashSignature(path string, size int64) ( func hashSignature(path string, size int64) (string, string, string, error) {
[sha256.Size]byte, [sha256.Size]byte, [sha256.Size]byte, error,
) {
var zero [sha256.Size]byte
if size == 0 { if size == 0 {
empty := sha256.Sum256(nil) return emptyHash, emptyHash, emptyHash, nil
return empty, empty, empty, nil
} }
f, err := openRegularFile(path) f, err := openRegularFile(path)
if err != nil { if err != nil {
return zero, zero, zero, err return "", "", "", err
} }
defer func() { _ = f.Close() }() defer func() { _ = f.Close() }()
head, tail, err := hashEnds(f, size) head, tail, err := hashEnds(f, size)
if err != nil { if err != nil {
return zero, zero, zero, err return "", "", "", err
} }
content, err := hashContent(f, size) content, err := hashContent(f, size)
if err != nil { if err != nil {
return zero, zero, zero, err return "", "", "", err
} }
return head, tail, content, nil return head, tail, content, nil
@@ -1471,32 +1377,31 @@ func hashSignature(path string, size int64) (
// bytes of f. In a file no larger than headTailWindow, each window is // bytes of f. In a file no larger than headTailWindow, each window is
// the whole file. Reading fewer bytes than asked means the file shrank // the whole file. Reading fewer bytes than asked means the file shrank
// between the stat and the hash, and is an error. // between the stat and the hash, and is an error.
func hashEnds(f *os.File, size int64) ( func hashEnds(f *os.File, size int64) (string, string, error) {
[sha256.Size]byte, [sha256.Size]byte, error,
) {
var zero [sha256.Size]byte
buf := make([]byte, min(int64(headTailWindow), size)) buf := make([]byte, min(int64(headTailWindow), size))
_, err := f.ReadAt(buf, 0) _, err := f.ReadAt(buf, 0)
if err != nil { if err != nil {
return zero, zero, err return "", "", err
} }
head := sha256.Sum256(buf) h := sha256.Sum256(buf)
head := hex.EncodeToString(h[:])
_, err = f.ReadAt(buf, size-int64(len(buf))) _, err = f.ReadAt(buf, size-int64(len(buf)))
if err != nil { if err != nil {
return zero, zero, err return "", "", err
} }
return head, sha256.Sum256(buf), nil t := sha256.Sum256(buf)
return head, hex.EncodeToString(t[:]), nil
} }
// hashContent returns the content hash of f: the SHA-256 of the whole // hashContent returns the content hash of f: the SHA-256 of the whole
// file when it is smaller than wholeFileMax, or of sampled windows when // file when it is smaller than wholeFileMax, or of sampled windows when
// it is that size or larger. // it is that size or larger.
func hashContent(f *os.File, size int64) ([sha256.Size]byte, error) { func hashContent(f *os.File, size int64) (string, error) {
if size >= int64(wholeFileMax) { if size >= int64(wholeFileMax) {
return hashSamples(f, size) return hashSamples(f, size)
} }
@@ -1509,20 +1414,20 @@ func hashContent(f *os.File, size int64) ([sha256.Size]byte, error) {
// reads. Reading fewer than size bytes means the file shrank between // reads. Reading fewer than size bytes means the file shrank between
// the stat and the hash; that is an error rather than a hash of content // the stat and the hash; that is an error rather than a hash of content
// that no longer matches the recorded size. // that no longer matches the recorded size.
func hashWhole(f *os.File, size int64) ([sha256.Size]byte, error) { func hashWhole(f *os.File, size int64) (string, error) {
h := sha256.New() h := sha256.New()
n, err := io.Copy(h, io.NewSectionReader(f, 0, size)) n, err := io.Copy(h, io.NewSectionReader(f, 0, size))
if err != nil { if err != nil {
return [sha256.Size]byte{}, err return "", err
} }
if n != size { if n != size {
return [sha256.Size]byte{}, fmt.Errorf("read %d of %d bytes: %w", return "", fmt.Errorf("read %d of %d bytes: %w", n, size,
n, size, io.ErrUnexpectedEOF) io.ErrUnexpectedEOF)
} }
return [sha256.Size]byte(h.Sum(nil)), nil return hex.EncodeToString(h.Sum(nil)), nil
} }
// hashSamples feeds sampleWindow bytes at each gigabyte-aligned offset // hashSamples feeds sampleWindow bytes at each gigabyte-aligned offset
@@ -1532,7 +1437,7 @@ func hashWhole(f *os.File, size int64) ([sha256.Size]byte, error) {
// without every byte being read. Because size is part of the signature, // without every byte being read. Because size is part of the signature,
// only files of one size are compared, so their sample boundaries // only files of one size are compared, so their sample boundaries
// always align. // always align.
func hashSamples(f *os.File, size int64) ([sha256.Size]byte, error) { func hashSamples(f *os.File, size int64) (string, error) {
h := sha256.New() h := sha256.New()
buf := make([]byte, sampleWindow) buf := make([]byte, sampleWindow)
@@ -1541,11 +1446,11 @@ func hashSamples(f *os.File, size int64) ([sha256.Size]byte, error) {
_, err := f.ReadAt(buf[:n], off) _, err := f.ReadAt(buf[:n], off)
if err != nil { if err != nil {
return [sha256.Size]byte{}, err return "", err
} }
h.Write(buf[:n]) h.Write(buf[:n])
} }
return [sha256.Size]byte(h.Sum(nil)), nil return hex.EncodeToString(h.Sum(nil)), nil
} }
+33 -405
View File
@@ -5,6 +5,7 @@ import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"database/sql" "database/sql"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -42,28 +43,11 @@ 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 // hexSum returns the lowercase-hex SHA-256 of data.
// own lstat: st_blocks counts 512-byte units. ZFS counts a write's func hexSum(data []byte) string {
// blocks only when it commits the write, up to seconds later, and one s := sha256.Sum256(data)
// commit can take the file through several growing sizes. So this value
// can change at any moment. Neither fsync nor unix.Sync hastens that,
// but a unix.Sync call often takes long enough that the blocks are
// counted when it returns. A test that needs the value to change waits
// for the change. A test that compares it with a scan's disk_size reads
// it before and after the scan. The rewrite test rescans until the two
// reads match, then compares exactly. The other two tests fail only on
// a disk_size outside the two reads.
func diskSizeOf(t *testing.T, path string) int64 {
t.Helper()
var st syscall.Stat_t return hex.EncodeToString(s[:])
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.
@@ -78,9 +62,7 @@ func pattern(tag byte, n int) []byte {
// sig returns a file's full signature (head, tail, content), failing the // sig returns a file's full signature (head, tail, content), failing the
// test on any error. // test on any error.
func sig(t *testing.T, path string, size int64) ( func sig(t *testing.T, path string, size int64) (string, string, string) {
[sha256.Size]byte, [sha256.Size]byte, [sha256.Size]byte,
) {
t.Helper() t.Helper()
head, tail, content, err := hashSignature(path, size) head, tail, content, err := hashSignature(path, size)
@@ -118,12 +100,12 @@ func TestHashSignature(t *testing.T) {
head, tail, content := sig(t, p, int64(len(c.data))) head, tail, content := sig(t, p, int64(len(c.data)))
window := min(len(c.data), headTailWindow) window := min(len(c.data), headTailWindow)
wantHead := sha256.Sum256(c.data[:window]) wantHead := hexSum(c.data[:window])
wantTail := sha256.Sum256(c.data[len(c.data)-window:]) wantTail := hexSum(c.data[len(c.data)-window:])
wantContent := sha256.Sum256(c.data) wantContent := hexSum(c.data)
if head != wantHead || tail != wantTail || content != wantContent { if head != wantHead || tail != wantTail || content != wantContent {
t.Errorf("head=%x tail=%x content=%x, want %x %x %x", t.Errorf("head=%s tail=%s content=%s, want %s %s %s",
head, tail, content, wantHead, wantTail, wantContent) head, tail, content, wantHead, wantTail, wantContent)
} }
}) })
@@ -188,25 +170,22 @@ func TestHashSignatureErrors(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
var zero [sha256.Size]byte // A missing file: an error, and every hash left empty.
// A missing file: an error, and every hash left zero.
head, tail, content, err := hashSignature(filepath.Join(dir, "missing"), 1) head, tail, content, err := hashSignature(filepath.Join(dir, "missing"), 1)
if err == nil { if err == nil {
t.Error("no error for a missing file") t.Error("no error for a missing file")
} }
if head != zero || tail != zero || content != zero { if head != "" || tail != "" || content != "" {
t.Errorf("missing file returned hashes: %x %x %x", head, tail, content) t.Errorf("missing file returned hashes: %q %q %q", head, tail, content)
} }
// A zero-length file has constant hashes and is never opened: even // A zero-length file has constant hashes and is never opened: even
// a missing path succeeds. // a missing path succeeds.
empty := sha256.Sum256(nil)
head, tail, content, err = hashSignature(filepath.Join(dir, "missing"), 0) head, tail, content, err = hashSignature(filepath.Join(dir, "missing"), 0)
if err != nil || head != empty || tail != empty || content != empty { if err != nil ||
t.Errorf("empty: head=%x tail=%x content=%x err=%v, "+ head != emptyHash || tail != emptyHash || content != emptyHash {
t.Errorf("empty: head=%q tail=%q content=%q err=%v, "+
"want constant hashes", head, tail, content, err) "want constant hashes", head, tail, content, err)
} }
@@ -219,8 +198,8 @@ func TestHashSignatureErrors(t *testing.T) {
t.Error("no error when the stat size exceeds the file size") t.Error("no error when the stat size exceeds the file size")
} }
if head != zero || tail != zero || content != zero { if head != "" || tail != "" || content != "" {
t.Errorf("shrunk file returned hashes: %x %x %x", head, tail, content) t.Errorf("shrunk file returned hashes: %q %q %q", head, tail, content)
} }
} }
@@ -707,108 +686,6 @@ func TestSubdirJobStatError(t *testing.T) {
} }
} }
// TestWalkPassesOverRemovedPaths removes a file and a directory after
// their parent was read, as when they are deleted while a scan runs.
// The walk then reaches them, the directory also as one to read and as
// an operand. None of that is an error: the walk sends no event.
func TestWalkPassesOverRemovedPaths(t *testing.T) {
t.Parallel()
dir := t.TempDir()
file := writeFile(t, dir, "file", []byte("x"))
writeFile(t, dir, "sub/f", []byte("y"))
sub := filepath.Join(dir, "sub")
fileEntry := dirEntryFor(t, dir, "file")
subEntry := dirEntryFor(t, dir, "sub")
for _, p := range []string{file, sub} {
err := os.RemoveAll(p)
if err != nil {
t.Fatal(err)
}
}
ctx := t.Context()
events := make(chan walkEvent, 4)
emitFile(ctx, file, fileEntry, walkRules{}, events)
_, descend := subdirJob(ctx, sub, subEntry, dirJob{path: dir}, false,
walkRules{}, events)
subs := walkOneDir(ctx, dirJob{path: sub}, false, walkRules{}, events)
jobs := seedRoot(ctx, sub, walkRules{}, events)
close(events)
for ev := range events {
t.Errorf("event %+v for a removed path, want none", ev)
}
if descend || len(subs) != 0 || len(jobs) != 0 {
t.Errorf("descend = %t, subdirectories %+v, jobs %+v, want none",
descend, subs, jobs)
}
}
// TestScanWarnsAboutUnreadablePaths checks that a permission error is
// still warned about and counted as skipped, unlike a removed path. The
// walk reaches an unreadable directory, and a file in a directory that
// can be read but not searched. Another such file is an operand.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestScanWarnsAboutUnreadablePaths(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root ignores file permissions")
}
stderr := captureStderr(t)
dir := t.TempDir()
tree := filepath.Join(dir, "tree")
walked := writeFile(t, tree, "unsearchable/f", []byte("x"))
operand := writeFile(t, dir, "unsearchable/f", []byte("y"))
unreadable := filepath.Join(tree, "unreadable")
err := os.Mkdir(unreadable, 0o750)
if err != nil {
t.Fatal(err)
}
modes := map[string]fs.FileMode{
unreadable: 0,
filepath.Dir(walked): 0o400,
filepath.Dir(operand): 0o400,
}
for p, mode := range modes {
err = os.Chmod(p, mode)
if err != nil {
t.Fatal(err)
}
// Runs before t.TempDir's own cleanup, which must delete the files.
t.Cleanup(func() {
//nolint:gosec // removing the files needs the search bit back
_ = os.Chmod(p, 0o700)
})
}
st := syncTree(t, openTestDB(t), tree, operand)
if st != (scanStats{skipped: 3}) {
t.Errorf("stats = %+v, want 3 skipped and nothing else", st)
}
got := stderr()
for _, warning := range []string{
"walk " + unreadable + ": open " + unreadable + ": permission denied\n",
"stat " + walked + ": lstat " + walked + ": permission denied\n",
"walk " + operand + ": lstat " + operand + ": permission denied\n",
} {
if !strings.Contains(got, warning) {
t.Errorf("stderr = %q, want %q", got, warning)
}
}
}
// syncOneFilesystem scans roots into db with -x and returns the scan // syncOneFilesystem scans roots into db with -x and returns the scan
// stats. Every device pruneRoots looks up, and the device each // stats. Every device pruneRoots looks up, and the device each
// operand's walk compares its subdirectories with, comes from devices // operand's walk compares its subdirectories with, comes from devices
@@ -1088,9 +965,8 @@ func TestScanOneFilesystemWalksNestedFileOperand(t *testing.T) {
} }
} }
// buildSmokeTree recreates the tree of the test under README // buildSmokeTree recreates the README smoke-test filesystem layout
// "Definition of done" with deterministic content and returns the tree // with deterministic content and returns the tree root.
// root.
func buildSmokeTree(t *testing.T) string { func buildSmokeTree(t *testing.T) string {
t.Helper() t.Helper()
@@ -1253,9 +1129,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 ETA 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) {
@@ -1270,14 +1146,14 @@ func TestScanProgressNamesEachPhase(t *testing.T) {
syncTree(t, db, filepath.Join(dir, "a")) syncTree(t, db, filepath.Join(dir, "a"))
rescan := stderr()[len(first):] rescan := stderr()[len(first):]
load := "[1/4] load: counting the records, elapsed 0s, remain unknown" load := "[1/4] load: counting the records, elapsed 0s, eta unknown"
cases := map[string][]string{ cases := map[string][]string{
first: { first: {
load, "[2/4] walk: 0 files, 0 files/s, elapsed 0s, remain unknown", load, "[2/4] walk: 0 files, 0 files/s, elapsed 0s, eta unknown",
"[3/4] hash: [0/", "[4/4] update: [0/", "[3/4] hash: [0/", "[4/4] update: [0/",
}, },
rescan: { rescan: {
load, "[2/4] walk: [0/~2] 0%, 0 files/s, elapsed 0s, remain unknown", load, "[2/4] walk: [0/~2] 0%, 0 files/s, elapsed 0s, eta unknown",
"[3/4] hash: [0/", "[4/4] update: [0/", "[3/4] hash: [0/", "[4/4] update: [0/",
}, },
} }
@@ -1335,10 +1211,8 @@ func TestSyncScanUnchangedReuse(t *testing.T) {
} }
} }
sentinel := sha256.Sum256([]byte("sentinel"))
_, err := db.ExecContext(context.Background(), _, err := db.ExecContext(context.Background(),
"UPDATE files SET head = ? WHERE path = ?", sentinel[:], []byte(a)) "UPDATE files SET head = 'sentinel' WHERE path = ?", []byte(a))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -1349,9 +1223,9 @@ func TestSyncScanUnchangedReuse(t *testing.T) {
st) st)
} }
if r := recordByPath(t, dbRecords(t, db), a); r.head != sentinel { if r := recordByPath(t, dbRecords(t, db), a); r.head != "sentinel" {
t.Fatalf("head = %x, want the sentinel %x (file must not be re-read)", t.Fatalf("head = %q, want sentinel (file must not be re-read)",
r.head, sentinel) r.head)
} }
} }
@@ -1391,209 +1265,13 @@ func assertWholeFileHashed(t *testing.T, db *sql.DB, path string,
t.Helper() t.Helper()
r := recordByPath(t, dbRecords(t, db), path) r := recordByPath(t, dbRecords(t, db), path)
if want := sha256.Sum256(data); r.head != want || r.tail != want || if want := hexSum(data); r.head != want || r.tail != want ||
r.content != want { r.content != want {
t.Fatalf("head, tail, content = %x, %x, %x, want %x for each", t.Fatalf("head, tail, content = %q, %q, %q, want %q for each",
r.head, r.tail, r.content, want) r.head, r.tail, r.content, want)
} }
} }
// TestScanStoresRawHashes checks what the database holds for a file
// larger than both end windows, whose three hashes all differ: head,
// tail and content are each a BLOB of the 32 bytes of the SHA-256 the
// scan computed.
func TestScanStoresRawHashes(t *testing.T) {
t.Parallel()
dir := t.TempDir()
db := openTestDB(t)
data := pattern(3, 3*headTailWindow)
p := writeFile(t, dir, "f.bin", data)
syncTree(t, db, dir)
var (
blobs bool
head, tail, content []byte
)
err := db.QueryRowContext(t.Context(),
"SELECT typeof(head) = 'blob' AND typeof(tail) = 'blob' AND "+
"typeof(content) = 'blob', head, tail, content "+
"FROM files WHERE path = ?", []byte(p)).
Scan(&blobs, &head, &tail, &content)
if err != nil {
t.Fatal(err)
}
if !blobs {
t.Error("head, tail and content are not all BLOBs")
}
wantHead := sha256.Sum256(data[:headTailWindow])
wantTail := sha256.Sum256(data[len(data)-headTailWindow:])
wantContent := sha256.Sum256(data)
if !bytes.Equal(head, wantHead[:]) || !bytes.Equal(tail, wantTail[:]) ||
!bytes.Equal(content, wantContent[:]) {
t.Errorf("head, tail, content = %x, %x, %x, want %x, %x, %x",
head, tail, content, wantHead, wantTail, wantContent)
}
}
// 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
size := diskSizeOf(t, p)
if size == 0 || size >= length {
t.Fatalf("the file takes %d bytes on disk, want more than none "+
"and fewer than its length %d", size, length)
}
for _, operand := range []string{filepath.Dir(p), p} {
db := openTestDB(t)
// ZFS may count the blocks during the scan; see diskSizeOf.
before := diskSizeOf(t, p)
syncTree(t, db, operand)
after := diskSizeOf(t, p)
got := recordByPath(t, dbRecords(t, db), p).diskSize
if got < before || got > after {
t.Errorf("scan of %s: disk_size = %d, want %d to %d",
operand, got, before, after)
}
}
}
// 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)
recorded := 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)
}
// Wait for ZFS to count the write; see diskSizeOf.
deadline := time.Now().Add(30 * time.Second)
for diskSizeOf(t, p) == recorded {
if time.Now().After(deadline) {
t.Fatalf("the file still takes %d bytes on disk 30 seconds "+
"after the write into its hole", recorded)
}
unix.Sync()
}
// ZFS may still be counting, so the size may change during a rescan.
// Only a rescan with the same size on both sides of it can be compared
// exactly. A rescan that keeps the old size still fails, because the
// wait made before differ from it.
for {
before := diskSizeOf(t, p)
st := syncTree(t, db, dir)
after := diskSizeOf(t, p)
if st != (scanStats{walked: 1, updated: 1}) {
t.Fatalf("rescan stats = %+v, want 1 updated", st)
}
if before == after {
got := recordByPath(t, dbRecords(t, db), p).diskSize
if got != after {
t.Errorf("disk_size = %d, want %d", got, after)
}
return
}
if time.Now().After(deadline) {
t.Fatalf("the size on disk was still changing 30 seconds after "+
"the write into its hole: %d, then %d", before, after)
}
// A later mtime makes the next rescan read the file again.
later = later.Add(time.Second)
err = os.Chtimes(p, later, later)
if err != nil {
t.Fatal(err)
}
}
}
// 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
// ZFS may count the blocks during the rescan; see diskSizeOf.
before := diskSizeOf(t, p)
// A file read again would count as updated.
st := syncTree(t, db, dir)
after := diskSizeOf(t, p)
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 got < before || got > after {
t.Errorf("disk_size = %d, want %d to %d", got, before, after)
}
}
// 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
@@ -1889,8 +1567,7 @@ func TestScanHardlinksReadOnce(t *testing.T) {
ra := recordByPath(t, recs, a) ra := recordByPath(t, recs, a)
rb := recordByPath(t, recs, b) rb := recordByPath(t, recs, b)
if ra.head == [sha256.Size]byte{} || ra.head != rb.head || if ra.head == "" || ra.head != rb.head || ra.tail != rb.tail {
ra.tail != rb.tail {
t.Fatalf("hardlink hashes differ: %+v vs %+v", ra, rb) t.Fatalf("hardlink hashes differ: %+v vs %+v", ra, rb)
} }
@@ -1965,55 +1642,6 @@ func TestHashPhaseSkipsFileReplacedByFIFO(t *testing.T) {
} }
} }
// TestHashPhasePassesOverRemovedFiles removes two walked files before
// the hash phase reads them: one with no other path, and the first of
// two hard links to one file. Neither is an error. The phase prints
// nothing but its progress, counts nothing as skipped, and records only
// the hard link still there.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestHashPhasePassesOverRemovedFiles(t *testing.T) {
stderr := captureStderr(t)
dir := t.TempDir()
alone := writeFile(t, dir, "alone.bin", pattern(1, 300))
a := writeFile(t, dir, "a.bin", pattern(2, 300))
b := filepath.Join(dir, "b.bin")
err := os.Link(a, b)
if err != nil {
t.Fatal(err)
}
walked, _ := collectWalk(t, []string{dir}, false, 1)
// a.bin sorts before b.bin, so the run of the two reads it first.
for _, p := range []string{alone, a} {
err = os.Remove(p)
if err != nil {
t.Fatal(err)
}
}
s := &scanState{db: openTestDB(t), toHash: walked}
err = s.hashPhase(t.Context(), 1)
if err != nil {
t.Fatalf("hashPhase = %v, want nil", err)
}
if s.st.skipped != 0 || len(s.batch) != 1 || s.batch[0].path != b {
t.Errorf("skipped = %d, records %+v, want none skipped and a "+
"record for %s alone", s.st.skipped, s.batch, b)
}
for line := range strings.Lines(stderr()) {
if !strings.HasPrefix(line, scanPhase("hash")+": ") {
t.Errorf("stderr holds %q, want the hash phase's progress alone",
line)
}
}
}
// injectedWriteFailure is the message the injected database trigger // injectedWriteFailure is the message the injected database trigger
// aborts with, so the test can recognize its own failure in the error // aborts with, so the test can recognize its own failure in the error
// the scan reports. // the scan reports.
+13 -22
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)
} }
} }
@@ -294,8 +287,7 @@ func (b *treeBuilder) finish() (*treeNode, []*treeNode) {
// excluded. // excluded.
func fileEntry(name string, r scanRec) string { func fileEntry(name string, r scanRec) string {
return "f\x00" + name + "\x00" + strconv.FormatInt(r.size, 10) + return "f\x00" + name + "\x00" + strconv.FormatInt(r.size, 10) +
"\x00" + string(r.head[:]) + "\x00" + string(r.tail[:]) + "\x00" + r.head + "\x00" + r.tail + "\x00" + r.content
"\x00" + string(r.content[:])
} }
// collectTreeGroups groups directories by digest, counting each one on // collectTreeGroups groups directories by digest, counting each one on
@@ -345,8 +337,7 @@ func collectTreeGroups(allDirs []*treeNode, super *treeNode,
// digest is the SHA-256 of its child entries — files serialized with // digest is the SHA-256 of its child entries — files serialized with
// name and signature, subdirectories with name and recursive digest — // name and signature, subdirectories with name and recursive digest —
// sorted byte-lexicographically. Filenames cannot contain NUL or "/", // sorted byte-lexicographically. Filenames cannot contain NUL or "/",
// and every hash and digest is 32 bytes, so NUL delimiters are // so NUL delimiters are unambiguous.
// unambiguous.
func (n *treeNode) computeDigest() { func (n *treeNode) computeDigest() {
slices.Sort(n.entries) slices.Sort(n.entries)
+39 -63
View File
@@ -10,31 +10,32 @@ import (
"time" "time"
) )
// Signature hashes shared by the smoke-test records.
const (
f1Head = "f1h"
f1Tail = "f1t"
f1Content = "f1c"
f2Head = "f2h"
f2Tail = "f2t"
f2Content = "f2c"
)
// treesOpenLine is the plain line of trees' open phase when it opens // treesOpenLine is the plain line of trees' open phase when it opens
// the database at once; it prints it as the phase starts and as it ends. // the database at once; it prints it as the phase starts and as it ends.
const treesOpenLine = "[1/4] open: opening the database, elapsed 0s, " + const treesOpenLine = "[1/4] open: opening the database, elapsed 0s, " +
"remain unknown" "eta unknown"
// smokeTreeRecs mirrors the tree of the test under README "Definition // smokeTreeRecs mirrors the README smoke-test tree layout: /d/t1 and
// of done": /d/t1 and /d/t2 are identical, /d/t3 differs from them only // /d/t2 are identical, /d/t3 differs from them only by one filename.
// 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")
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"},
} }
} }
@@ -152,14 +153,13 @@ func TestTreeNamesSortingBeforeSlash(t *testing.T) {
for _, top := range []string{"/a", "/c"} { for _, top := range []string{"/a", "/c"} {
for _, p := range []string{"/b", "/b-x/f", "/b.txt", "/b/f"} { for _, p := range []string{"/b", "/b-x/f", "/b.txt", "/b/f"} {
content := testHash("c") content := "c"
if p == "/b-x/f" { if p == "/b-x/f" {
content = testHash("other") content = "other"
} }
recs = append(recs, scanRec{ recs = append(recs, scanRec{
size: 1, head: testHash("h"), tail: testHash("t"), size: 1, head: "h", tail: "t", content: content, path: top + p,
content: content, path: top + p,
}) })
} }
} }
@@ -204,33 +204,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 +232,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)
} }
@@ -263,11 +242,11 @@ func TestRunTreesProgressNamesEachPhase(t *testing.T) {
"trees: database " + path + " (from " + databaseEnv + ")", "trees: database " + path + " (from " + databaseEnv + ")",
treesOpenLine, treesOpenLine,
treesOpenLine, treesOpenLine,
"[2/4] read: counting the records, elapsed 0s, remain unknown", "[2/4] read: counting the records, elapsed 0s, eta unknown",
"[2/4] read: [6/6] 100%, ", "[2/4] read: [6/6] 100%, ",
"[3/4] group: [0/8] 0%, 0 directories/s, elapsed 0s, remain unknown", "[3/4] group: [0/8] 0%, 0 directories/s, elapsed 0s, eta unknown",
"[3/4] group: [8/8] 100%, ", "[3/4] group: [8/8] 100%, ",
"[4/4] write: [0/1] 0%, 0 groups/s, elapsed 0s, remain unknown", "[4/4] write: [0/1] 0%, 0 groups/s, elapsed 0s, eta unknown",
"[4/4] write: [1/1] 100%, ", "[4/4] write: [1/1] 100%, ",
"trees: 6 records read, 1 duplicate tree groups, 1 dupe trees, ", "trees: 6 records read, 1 duplicate tree groups, 1 dupe trees, ",
} }
@@ -318,9 +297,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)
} }
@@ -355,10 +332,11 @@ func TestTreeDigests(t *testing.T) {
func TestTreeDigestContentSensitivity(t *testing.T) { func TestTreeDigestContentSensitivity(t *testing.T) {
t.Parallel() t.Parallel()
same, c := testHash("same"), testHash("c") const sharedTail = "same"
recs := []scanRec{ recs := []scanRec{
{size: 10, head: same, tail: same, content: c, path: "/r/a/f"}, {size: 10, head: sharedTail, tail: sharedTail, content: "c", path: "/r/a/f"},
{size: 10, head: testHash("DIFF"), tail: same, content: c, path: "/r/b/f"}, {size: 10, head: "DIFF", tail: sharedTail, content: "c", path: "/r/b/f"},
} }
_, dirs := treeOf(t, recs) _, dirs := treeOf(t, recs)
@@ -419,10 +397,9 @@ func TestCollectTreeGroupsSiblings(t *testing.T) {
// Identical sibling dirs share a parent, so their group cannot be // Identical sibling dirs share a parent, so their group cannot be
// implied by a parent group and must be reported. // implied by a parent group and must be reported.
h, tl, c := testHash("h"), testHash("t"), testHash("c")
recs := []scanRec{ recs := []scanRec{
{size: 10, head: h, tail: tl, content: c, path: "/p/x1/f"}, {size: 10, head: "h", tail: "t", content: "c", path: "/p/x1/f"},
{size: 10, head: h, tail: tl, content: c, path: "/p/x2/f"}, {size: 10, head: "h", tail: "t", content: "c", path: "/p/x2/f"},
} }
super, dirs := treeOf(t, recs) super, dirs := treeOf(t, recs)
@@ -441,11 +418,10 @@ func TestCollectTreeGroupsDifferingParents(t *testing.T) {
// /p/a and /q/b contain identical x subtrees, but /p/a has an // /p/a and /q/b contain identical x subtrees, but /p/a has an
// extra file, so the parents' digests differ and the x group must // extra file, so the parents' digests differ and the x group must
// be reported. // be reported.
h, tl, c, e := testHash("h"), testHash("t"), testHash("c"), testHash("e")
recs := []scanRec{ recs := []scanRec{
{size: 10, head: h, tail: tl, content: c, path: "/p/a/x/f"}, {size: 10, head: "h", tail: "t", content: "c", path: "/p/a/x/f"},
{size: 99, head: e, tail: e, content: e, path: "/p/a/extra"}, {size: 99, head: "e", tail: "e", content: "e", path: "/p/a/extra"},
{size: 10, head: h, tail: tl, content: c, path: "/q/b/x/f"}, {size: 10, head: "h", tail: "t", content: "c", path: "/q/b/x/f"},
} }
super, dirs := treeOf(t, recs) super, dirs := treeOf(t, recs)