Author SHA1 Message Date
clawbot 8d2ea50674 Keep the record count and total size in a totals table (closes #146)
check / check (push) Canceled after 0s
info counted the records and added up their sizes with a query that
reads every record, which takes minutes on a large database. The
schema now has a totals table of one row, kept current by three
triggers on files, in the transaction of each insert, delete and size
update. info, scan's load phase and the read phases of report and
trees read that row instead of counting over files. scan writes files
as before.

Model: opus-5-5
2026-10-27 16:32:38 +00:00
clawbot ee09cda355 Never scan an operand inside a .zfs directory (closes #142)
check / check (push) Canceled after 0s
An operand inside a directory named .zfs was walked, because the
operand check looked only at the operand's own name. The check now
also looks at every directory above the operand, both as given and
with its symlinks resolved. Such an operand gets a warning, counts as
skipped and is not walked, whether given alone or under another
operand. The check runs before overlapping operands are pruned, so an
operand under another one still gets its warning.

The test for an operand named .zfs now writes its record beneath .zfs
straight into the database, since no scan stores one there any more.

Judgement call: a path component named .zfs counts whatever it is, a
symlink included.

Model: opus-5-5
2026-10-26 15:15:37 +01:00
8 changed files with 404 additions and 217 deletions
+71 -47
View File
@@ -393,6 +393,20 @@ All four subcommands operate on a single SQLite database file:
content BLOB NOT NULL -- raw 32-byte SHA-256; whole file under 100 MiB, else samples content BLOB NOT NULL -- raw 32-byte SHA-256; whole file under 100 MiB, else samples
) WITHOUT ROWID; ) WITHOUT ROWID;
CREATE INDEX files_signature ON files (size, head, tail, content); CREATE INDEX files_signature ON files (size, head, tail, content);
CREATE TABLE totals (
records INTEGER NOT NULL, -- number of records in files
size INTEGER NOT NULL -- sizes of all records in files added up
);
INSERT INTO totals (records, size) VALUES (0, 0);
CREATE TRIGGER totals_after_insert AFTER INSERT ON files BEGIN
UPDATE totals SET records = records + 1, size = size + NEW.size;
END;
CREATE TRIGGER totals_after_delete AFTER DELETE ON files BEGIN
UPDATE totals SET records = records - 1, size = size - OLD.size;
END;
CREATE TRIGGER totals_after_update AFTER UPDATE OF size ON files BEGIN
UPDATE totals SET size = size + NEW.size - OLD.size;
END;
``` ```
Paths are stored as BLOBs because Unix paths are raw bytes, not guaranteed Paths are stored as BLOBs because Unix paths are raw bytes, not guaranteed
@@ -406,6 +420,13 @@ All four subcommands operate on a single SQLite database file:
`files_signature` index lets `report` read the records in signature order `files_signature` index lets `report` read the records in signature order
without sorting the table. without sorting the table.
`totals` holds one row: the number of records in `files` and the sum of
their sizes. Its three triggers keep that row current. Each runs in the
transaction of the change to `files` that fires it, so the row and the
records always agree. `info` and the progress displays read the number of
records from this row. Counting them in `files` would read every record,
which takes minutes on a database of tens of millions.
### Duplicate detection ### Duplicate detection
Two files are duplicates when they have the same size and the same three hashes. Two files are duplicates when they have the same size and the same three hashes.
@@ -455,29 +476,32 @@ scan and are enumerated concurrently: every operand seeds the shared walk worker
pool. Overlapping operands are harmless — an operand that duplicates another or pool. Overlapping operands are harmless — an operand that duplicates another or
lies under another is dropped before walking, so every file is reached exactly lies under another is dropped before walking, so every file is reached exactly
once and produces one database record. An operand under another one is kept, once and produces one database record. An operand under another one is kept,
though, when the walk of the outer operand does not reach it. It is then walked though, and walked on its own when any directory from it (from its directory,
on its own. The walk of the outer operand does not reach the inner one when it, for a regular file) up to but not including the operand covering it is a default
or any directory between the two, is: exclude, is not on a persistent filesystem (see "Rules for the walk"), or, with
`-x`, is on a different filesystem than that operand, because that operand's
walk does not enter such a directory and so does not reach it.
- named `.zfs`; An operand is not scanned when it is:
- a symlink;
- a default exclude;
- not on a persistent filesystem (see "Rules for the walk");
- with `-x`, on a different filesystem than the outer operand.
For a regular-file operand, only the directories between the two count. - a symlink, which `scan` never follows, not even as an operand;
- a socket, FIFO or device node;
- at or inside a directory named `.zfs`.
An operand that is a symlink (never followed, not even as an operand), socket, An operand reached through a symlink into a `.zfs` directory is not scanned
FIFO, or device node, or a directory named `.zfs`, is not scanned. `scan` prints either.
a one-line warning naming the path and what it is, counts it as skipped, and
drops it from the scanned operands before reading the database. Another operand This holds even when the operand lies under another operand. `scan` prints a
beneath it is still scanned. The records stored beneath it are not deleted: they one-line warning naming the path and what it is, counts it as skipped, and drops
are treated like any other record outside the scanned operands. If it lies under it from the scanned operands before reading the database. An operand beneath a
another operand, they are under that operand instead, and are treated like any symlink operand is still scanned. The records stored beneath the dropped operand
other record there. This is not an error: a scan whose every operand is dropped are not deleted: they are treated like any other record outside the scanned
walks nothing and exits 0. An operand that is not on a persistent filesystem operands. If it lies under another operand, they are under that operand instead,
(see "Rules for the walk") is dropped the same way. The line `scan` prints for and are treated like any other record there. This is not an error: a scan whose
it is not a warning, though, and it is not counted as skipped. every operand is dropped walks nothing and exits 0. An operand that is not on a
persistent filesystem (see "Rules for the walk") is dropped the same way. The
line `scan` prints for it is not a warning, though, and it is not counted as
skipped.
`scan` synchronizes the database with the filesystem state under the scanned `scan` synchronizes the database with the filesystem state under the scanned
operands: operands:
@@ -685,14 +709,14 @@ Processing:
database state. database state.
`report` runs in two phases, each with its own progress display on stderr (see `report` runs in two phases, each with its own progress display on stderr (see
"Progress"): `open` opens the database; `read` counts the records, then reads "Progress"): `open` opens the database; `read` gets the number of records from
them in signature order, largest size first. That is the order of the `totals`, then reads the records in signature order, largest size first. That is
`files_signature` index read backwards, so SQLite sorts nothing and returns the the order of the `files_signature` index read backwards, so SQLite sorts nothing
first record at once. The records sharing a signature come together, and so do and returns the first record at once. The records sharing a signature come
the records of one size, so `report` holds only the duplicate groups of the size together, and so do the records of one size, so `report` holds only the
it is reading, and writes them, ordered by `first` path, once it reaches a duplicate groups of the size it is reading, and writes them, ordered by `first`
smaller size. Its memory holds at most the duplicate paths of one size at a path, once it reaches a smaller size. Its memory holds at most the duplicate
time, never every record. paths of one size at a time, never every record.
#### Report output format #### Report output format
@@ -766,10 +790,10 @@ Processing:
ascending. Output must be fully deterministic for a given input. ascending. Output must be fully deterministic for a given input.
`trees` runs in four phases, each with its own progress display on stderr (see `trees` runs in four phases, each with its own progress display on stderr (see
"Progress"): `open` opens the database; `read` counts the records, then reads "Progress"): `open` opens the database; `read` gets the number of records from
them in path order, building the hierarchy and computing the digests as it goes; `totals`, then reads the records in path order, building the hierarchy and
`group` groups the directories by digest and picks the maximal groups; `write` computing the digests as it goes; `group` groups the directories by digest and
writes the groups to stdout. picks the maximal groups; `write` writes the groups to stdout.
#### Trees output format #### Trees output format
@@ -820,11 +844,10 @@ size of recorded files 150000000000000 (136.4 TiB)
of all records, in bytes, then in human units. of all records, in bytes, then in human units.
`info` runs in two phases, each with its own progress display on stderr (see `info` runs in two phases, each with its own progress display on stderr (see
"Progress"): `open` opens the database; `count` reads the schema version and "Progress"): `open` opens the database; `count` reads the schema version and the
counts the records with one query, a single pass over the table, both in one row of `totals` (see "Database") in one read-only transaction, so the numbers
read-only transaction, so the numbers agree with each other while a scan is agree with each other while a scan is committing. `info` writes its lines once
committing. `info` writes its lines once the `count` display has ended, so on a the `count` display has ended, so on a terminal they come after the displays.
terminal they come after the displays.
### Progress ### Progress
@@ -840,8 +863,9 @@ progress, its elapsed time and its time remaining.
A scan has four phases (`[1/4] load`, `[2/4] walk`, `[3/4] hash`, A scan has four phases (`[1/4] load`, `[2/4] walk`, `[3/4] hash`,
`[4/4] update`): `[4/4] update`):
- `load` counts the records first, then reads them against that count. While the - `load` gets the number of records from `totals` first, then reads the records
count runs, the display says it is counting the records. against that number. Until it has the number, the display says it is counting
the records.
- `walk` has no exact total. Its estimate is the number of records the last scan - `walk` has no exact total. Its estimate is the number of records the last scan
left under the operands, known once `load` is done and shown with a `~`. The left under the operands, known once `load` is done and shown with a `~`. The
time remaining is unknown once the walk passes the estimate, and when there time remaining is unknown once the walk passes the estimate, and when there
@@ -854,8 +878,9 @@ A scan has four phases (`[1/4] load`, `[2/4] walk`, `[3/4] hash`,
- `open` has no total. While it opens the database, waiting on a busy one - `open` has no total. While it opens the database, waiting on a busy one
included, the display says it is opening the database. included, the display says it is opening the database.
- `read` counts the records first, then reads them against that count. While the - `read` gets the number of records from `totals` first, then reads the records
count runs, the display says it is counting the records. against that number. Until it has the number, the display says it is counting
the records.
- `group` has the directories `read` found as its total, and `write` the - `group` has the directories `read` found as its total, and `write` the
duplicate-tree groups. duplicate-tree groups.
@@ -863,17 +888,16 @@ A scan has four phases (`[1/4] load`, `[2/4] walk`, `[3/4] hash`,
- `open` has no total. While it opens the database, waiting on a busy one - `open` has no total. While it opens the database, waiting on a busy one
included, the display says it is opening the database. included, the display says it is opening the database.
- `read` counts the records first, then reads them against that count, writing - `read` gets the number of records from `totals` first, then reads the records
the rows to stdout as it goes. While the count runs, the display says it is against that number, writing the rows to stdout as it goes. Until it has the
counting the records. number, the display says it is counting the records.
`info` has two phases (`[1/2] open`, `[2/2] count`, see "`info` mode"): `info` has two phases (`[1/2] open`, `[2/2] count`, see "`info` mode"):
- `open` has no total. While it opens the database, waiting on a busy one - `open` has no total. While it opens the database, waiting on a busy one
included, the display says it is opening the database. included, the display says it is opening the database.
- `count` has no total either, because one query counts everything: the display - `count` has no total either, because it reads one row: the display says it is
says it is counting the records until the query returns, and its time counting the records until it has the row, and its time remaining is unknown.
remaining is unknown.
Each display is one line holding: Each display is one line holding:
+9 -4
View File
@@ -29,10 +29,15 @@
# Completed Steps # Completed Steps
- an operand under another is walked on its own when a directory between them, - the database keeps the number of records and the sum of their sizes in the one
the inner operand included, is named `.zfs` or is a symlink. The walk of the row of a `totals` table, which three triggers on `files` keep current. `info`
outer operand does not reach it then. `scan` now records the files below it reads that row, and so do `scan`'s `load` phase and the `read` phases of
and no longer deletes their records (2026-10-26, `report` and `trees` for their totals; no count reads every record any more
(2026-10-27, https://git.eeqj.de/sneak/sfdupes/issues/146)
- an operand at or inside a directory named `.zfs` is never scanned, whether
given alone or under another operand. `scan` warns about it, counts it as
skipped and does not walk it (2026-10-26,
https://git.eeqj.de/sneak/sfdupes/issues/142) https://git.eeqj.de/sneak/sfdupes/issues/142)
- a file or directory removed while the scan runs is not an error, in the walk - a file or directory removed while the scan runs is not an error, in the walk
+57 -16
View File
@@ -69,6 +69,49 @@ 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 = `
@@ -426,14 +469,14 @@ func createSchema(ctx context.Context, db *sql.DB) error {
defer func() { _ = tx.Rollback() }() defer func() { _ = tx.Rollback() }()
_, err = tx.ExecContext(ctx, createTableSQL) for _, stmt := range []string{
if err != nil { createTableSQL, createIndexSQL, createTotalsSQL, insertTotalsSQL,
return fmt.Errorf("create schema: %w", err) totalsAfterInsertSQL, totalsAfterDeleteSQL, totalsAfterUpdateSQL,
} } {
_, err = tx.ExecContext(ctx, stmt)
_, err = tx.ExecContext(ctx, createIndexSQL) if err != nil {
if err != nil { return fmt.Errorf("create schema: %w", err)
return fmt.Errorf("create schema: %w", err) }
} }
_, err = tx.ExecContext(ctx, _, err = tx.ExecContext(ctx,
@@ -480,7 +523,7 @@ func loadFileRows(ctx context.Context, db *sql.DB, total func(n int64),
var n int64 var n int64
err = tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM files").Scan(&n) err = tx.QueryRowContext(ctx, recordCountSQL).Scan(&n)
if err != nil { if err != nil {
return fmt.Errorf("count records: %w", err) return fmt.Errorf("count records: %w", err)
} }
@@ -556,7 +599,7 @@ func loadSignatureRows(ctx context.Context, db *sql.DB,
var n int64 var n int64
err = tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM files").Scan(&n) err = tx.QueryRowContext(ctx, recordCountSQL).Scan(&n)
if err != nil { if err != nil {
return fmt.Errorf("count records: %w", err) return fmt.Errorf("count records: %w", err)
} }
@@ -622,7 +665,7 @@ func (r *scanRec) setHashes(head, tail, content []byte) error {
func countRecords(ctx context.Context, db *sql.DB) (int64, error) { func countRecords(ctx context.Context, db *sql.DB) (int64, error) {
var n int64 var n int64
err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM files").Scan(&n) err := db.QueryRowContext(ctx, recordCountSQL).Scan(&n)
if err != nil { if err != nil {
return 0, fmt.Errorf("count records: %w", err) return 0, fmt.Errorf("count records: %w", err)
} }
@@ -630,12 +673,10 @@ func countRecords(ctx context.Context, db *sql.DB) (int64, error) {
return n, nil return n, nil
} }
// databaseCountsSQL counts the records and adds up their sizes, in one // databaseCountsSQL reads the number of records and the sizes of all
// pass over the table. A SUM over no rows is NULL, so on an empty table // records added up from totals.
// the SUM falls back to 0.
const databaseCountsSQL = ` const databaseCountsSQL = `
SELECT COUNT(*), COALESCE(SUM(size), 0) SELECT records, size FROM totals
FROM files
` `
// databaseCounts is what info reads from the database: its schema // databaseCounts is what info reads from the database: its schema
+98 -10
View File
@@ -535,17 +535,12 @@ func TestLoadRowsRefuseWrongSizeHash(t *testing.T) {
} }
} }
// TestSignatureRowsNeedNoSort checks that SQLite reads signatureRowsSQL // queryPlan returns the detail of each line of SQLite's EXPLAIN QUERY
// from the files_signature index alone, with nothing to sort: the // PLAN for query.
// first record comes at once, so report's read phase counts each func queryPlan(t *testing.T, db *sql.DB, query string) []string {
// record as it comes rather than waiting on SQLite for all of them. t.Helper()
func TestSignatureRowsNeedNoSort(t *testing.T) {
t.Parallel()
db := openTestDB(t) rows, err := db.QueryContext(t.Context(), "EXPLAIN QUERY PLAN "+query)
rows, err := db.QueryContext(t.Context(),
"EXPLAIN QUERY PLAN "+signatureRowsSQL)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -573,12 +568,105 @@ func TestSignatureRowsNeedNoSort(t *testing.T) {
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 each query that
// counts the records 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)
}
}
}
// assertTotalsMatchFiles fails unless the row of totals holds the
// number of records and the sizes of all records added up, as counted
// over files.
func assertTotalsMatchFiles(t *testing.T, db *sql.DB) {
t.Helper()
got, err := readDatabaseCounts(t.Context(), db)
if err != nil {
t.Fatal(err)
}
var records, size int64
err = db.QueryRowContext(t.Context(),
"SELECT COUNT(*), COALESCE(SUM(size), 0) FROM files").
Scan(&records, &size)
if err != nil {
t.Fatal(err)
}
if got.records != records || got.size != size {
t.Errorf("totals = %d records of %d bytes, files holds %d of %d",
got.records, got.size, records, size)
}
}
// TestTotalsMatchFiles scans the smoke tree, rescans it after two files
// change size, and rescans it after two files are removed. After each
// scan the row of totals must agree with files.
func TestTotalsMatchFiles(t *testing.T) {
t.Parallel()
dir := buildSmokeTree(t)
db := openTestDB(t)
syncTree(t, db, dir)
assertTotalsMatchFiles(t, db)
writeFile(t, dir, "a/one.bin", pattern(10, 2500))
writeFile(t, dir, "t1/f1", pattern(30, 1000))
if st := syncTree(t, db, dir); st.updated != 2 {
t.Fatalf("rescan stats = %+v, want 2 updated", st)
}
assertTotalsMatchFiles(t, db)
for _, name := range []string{"a/unique.bin", "t2/sub/f2"} {
err := os.Remove(filepath.Join(dir, name))
if err != nil {
t.Fatal(err)
}
}
if st := syncTree(t, db, dir); st.removed != 2 {
t.Fatalf("rescan stats = %+v, want 2 removed", st)
}
assertTotalsMatchFiles(t, db)
}
func TestApplyChangesRoundTrip(t *testing.T) { func TestApplyChangesRoundTrip(t *testing.T) {
t.Parallel() t.Parallel()
+6 -6
View File
@@ -26,11 +26,11 @@ func infoPhase(name string) string {
// it. Any database problem, including a missing database, is fatal. // it. Any database problem, including a missing database, is fatal.
// //
// The work runs in two phases, each under a progress display of its // The work runs in two phases, each under a progress display of its
// own on stderr: open (open the database) and count (count the records, // own on stderr: open (open the database) and count (read the number of
// with one query). Each display ends before the next phase starts, and // records and their total size from the totals table). Each display
// before an error is returned to be printed. The details are written // ends before the next phase starts, and before an error is returned
// once the last display has ended, so no display is drawn while they // to be printed. The details are written once the last display has
// are written. // ended, so no display is drawn while they 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 query counts everything, so the phase never has a total. // One row holds both counts, 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,
+128 -15
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 files table, so the first query // schema-version check but has no tables, so the first query fails
// fails with the database already open: a fatal error on a path that // with the database already open: a fatal error on a path that owns an
// owns an open database. It closes the database the way scan does. // 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,8 +265,9 @@ 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. // query against the opened database can report this. Each
if !strings.Contains(stderr.String(), "no such table: files") { // subcommand's first query reads totals.
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())
} }
@@ -928,24 +929,21 @@ func TestRunScanWalksOperandUnderSymlinkOperand(t *testing.T) {
} }
func TestRunScanSkipsZFSOperand(t *testing.T) { func TestRunScanSkipsZFSOperand(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
zfs := filepath.Join(t.TempDir(), ".zfs") zfs := filepath.Join(t.TempDir(), ".zfs")
snapshot := filepath.Join(zfs, "snapshot", "hourly") f := writeFile(t, zfs, "snapshot/hourly/f", pattern(1, 10))
f := writeFile(t, snapshot, "f", pattern(1, 10))
// An operand beneath a .zfs directory is walked, because it is not // No scan records a file inside a .zfs directory, so the record
// itself named .zfs. // beneath the operand is written straight into the database.
scanOK(t, snapshot) path := seedDatabase(t, []scanRec{{size: 10, path: f}})
t.Setenv(databaseEnv, path)
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, which an earlier scan stored beneath operand, still // record for kept, stored beneath operand before this scan, still in the
// in the database at dbPath. // database at dbPath.
func assertOperandSkipped(t *testing.T, dbPath, operand, kind, func assertOperandSkipped(t *testing.T, dbPath, operand, kind,
kept string, kept string,
) { ) {
@@ -974,6 +972,121 @@ 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)
+35 -34
View File
@@ -48,11 +48,6 @@ const sampleWindow = 1024 * 1024
// and hash worker pools. // and hash worker pools.
const workQueueDepth = 1024 const workQueueDepth = 1024
// zfsDirName is the name of the directory in which ZFS shows a
// filesystem's snapshots. The walk never enters one, since it would
// list every file once per snapshot.
const zfsDirName = ".zfs"
// errInterrupted reports a scan stopped by SIGINT or SIGTERM. runScan // errInterrupted reports a scan stopped by SIGINT or SIGTERM. runScan
// 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")
@@ -277,10 +272,9 @@ func pruneRoots(roots []string, oneFS bool, rules walkRules,
} }
// walkReaches reports whether the walk of operand above reaches r, an // walkReaches reports whether the walk of operand above reaches r, an
// operand under it. That walk never follows a symlink. It does not // operand under it. That walk does not enter a default exclude in
// enter a directory named .zfs, nor a default exclude in rules, nor a // rules, nor a directory that device puts on a filesystem in rules that
// directory that device puts on a filesystem in rules that is not // is not persistent, nor, with -x, one that device puts on a different
// persistent, nor, with -x, one that device puts on a different
// filesystem than above, when device knows above's. It reaches every // filesystem than above, when device knows above's. It reaches every
// regular file in a directory it enters, so for a regular-file operand // regular file in a directory it enters, so for a regular-file operand
// the directories checked start at its directory; they end below above. // the directories checked start at its directory; they end below above.
@@ -301,15 +295,6 @@ func walkReaches(above, r string, oneFS bool, rules walkRules,
return false return false
} }
if filepath.Base(dir) == zfsDirName {
return false
}
fi, err = os.Lstat(dir)
if err == nil && fi.Mode()&fs.ModeSymlink != 0 {
return false
}
dev, ok := device(dir) dev, ok := device(dir)
if !ok { if !ok {
continue continue
@@ -398,7 +383,8 @@ 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. // under it, and so that an operand inside a .zfs directory gets its
// 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)
@@ -426,15 +412,15 @@ func (s *scanState) runPhases(ctx context.Context, roots []string,
} }
// walkableRoots returns the operands the walk can start from: regular // walkableRoots returns the operands the walk can start from: regular
// files, and directories not named .zfs. Every other operand is warned // files, and directories not named .zfs, none of them inside a
// about, counted as skipped, and dropped. A dropped operand is no // directory named .zfs. Every other operand is warned about, counted as
// longer a root, so the records stored beneath it are treated as if it // skipped, and dropped. A dropped operand is no longer a root, so the
// had not been given: those under no other root count as outside the // records stored beneath it are treated as if it had not been given:
// roots and are not deleted as unverified. An operand on a filesystem // those under no other root count as outside the roots and are not
// in rules that is not persistent is dropped the same way, with a line // deleted as unverified. An operand on a filesystem in rules that is not
// saying so that is not a warning, and is not counted as skipped. An // persistent is dropped the same way, with a line saying so that is not
// operand that fails lstat here is kept, and the walk warns about it // a warning, and is not counted as skipped. An operand that fails lstat
// unless it no longer exists. // 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))
@@ -469,8 +455,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. Counting them // display the scan looks hung before the walk begins. The number of
// first gives the display its total. // records, read 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",
@@ -889,16 +875,31 @@ 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. Symlinks are // does: a regular file, or a directory not named .zfs, that is not
// never followed, including as operands. // inside a directory named .zfs, neither as given nor with its symlinks
// 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(); {
case mode.IsRegular(): case mode.IsRegular():
return "" return ""
case mode.IsDir(): case mode.IsDir():
if filepath.Base(root) != zfsDirName { if filepath.Base(root) != ".zfs" {
return "" return ""
} }
@@ -1168,7 +1169,7 @@ func subdirJob(ctx context.Context, p string, e fs.DirEntry,
parent dirJob, oneFS bool, rules walkRules, parent dirJob, oneFS bool, rules walkRules,
events chan<- walkEvent, events chan<- walkEvent,
) (dirJob, bool) { ) (dirJob, bool) {
if e.Name() == zfsDirName { if e.Name() == ".zfs" {
return dirJob{}, false return dirJob{}, false
} }
-85
View File
@@ -1672,81 +1672,6 @@ func TestSyncScanOverlappingRoots(t *testing.T) {
} }
} }
// TestScanWalksOperandInZFSDirectory checks that an operand inside a
// directory named .zfs below another operand is walked on its own,
// since the walk of the other operand does not enter .zfs. The file
// under it is recorded, and a second scan keeps the record.
func TestScanWalksOperandInZFSDirectory(t *testing.T) {
t.Parallel()
dir := t.TempDir()
db := openTestDB(t)
f := writeFile(t, dir, "pool/.zfs/snapshot/daily/f", pattern(1, 10))
daily := filepath.Dir(f)
syncTree(t, db, dir, daily)
recordByPath(t, dbRecords(t, db), f)
syncTree(t, db, dir, daily)
recordByPath(t, dbRecords(t, db), f)
}
// TestScanWalksOperandBehindSymlink checks that an operand reached
// through a symlinked directory below another operand is walked on its
// own, since the walk of the other operand does not follow the symlink.
// The file under it is recorded, and a second scan keeps the record.
func TestScanWalksOperandBehindSymlink(t *testing.T) {
t.Parallel()
dir := t.TempDir()
target := t.TempDir()
db := openTestDB(t)
writeFile(t, target, "sub/f", pattern(1, 10))
link := filepath.Join(dir, "link")
err := os.Symlink(target, link)
if err != nil {
t.Fatal(err)
}
sub := filepath.Join(link, "sub")
f := filepath.Join(sub, "f")
syncTree(t, db, dir, sub)
recordByPath(t, dbRecords(t, db), f)
syncTree(t, db, dir, sub)
recordByPath(t, dbRecords(t, db), f)
}
// TestScanDropsOperandReachedBehindSymlink checks that an operand under
// another one is still dropped when both lie behind the same symlinked
// directory: the walk of the other operand starts past the symlink and
// reaches it, so the file under it is walked once.
func TestScanDropsOperandReachedBehindSymlink(t *testing.T) {
t.Parallel()
dir := t.TempDir()
target := t.TempDir()
db := openTestDB(t)
writeFile(t, target, "sub/deeper/f", pattern(1, 10))
link := filepath.Join(dir, "link")
err := os.Symlink(target, link)
if err != nil {
t.Fatal(err)
}
sub := filepath.Join(link, "sub")
st := syncTree(t, db, sub, filepath.Join(sub, "deeper"))
if st != (scanStats{walked: 1, added: 1}) {
t.Fatalf("stats = %+v, want 1 walked and added", st)
}
}
// TestScanHashesUniqueSize checks that a file whose size no other file // TestScanHashesUniqueSize checks that a file whose size no other file
// shares is hashed like every other file. // shares is hashed like every other file.
func TestScanHashesUniqueSize(t *testing.T) { func TestScanHashesUniqueSize(t *testing.T) {
@@ -2160,16 +2085,6 @@ func TestPruneRoots(t *testing.T) {
t.Fatalf("pruneRoots with %s not persistent = %q, want %q", t.Fatalf("pruneRoots with %s not persistent = %q, want %q",
pqRoot, got, want) pqRoot, got, want)
} }
// Nor does it enter a directory named .zfs, so /p/.zfs/s is kept. The
// walk of /p/.zfs/s reaches /p/.zfs/s/t, which is dropped.
got = pruneRoots([]string{"/p", "/p/.zfs/s", "/p/.zfs/s/t"}, false,
walkRules{}, device)
want = []string{"/p", "/p/.zfs/s"}
if !slices.Equal(got, want) {
t.Fatalf("pruneRoots with .zfs = %q, want %q", got, want)
}
} }
func TestReportsNeverTouchFilesystem(t *testing.T) { func TestReportsNeverTouchFilesystem(t *testing.T) {