Compare commits
1
Commits
next
..
e80c6541d2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e80c6541d2 |
@@ -33,14 +33,14 @@ export SFDUPES_DATABASE="$HOME/.local/share/sfdupes/db.sqlite"
|
|||||||
```
|
```
|
||||||
|
|
||||||
`scan` walks one or more filesystem trees and maintains one database record per
|
`scan` walks one or more filesystem trees and maintains one database record per
|
||||||
regular file (path, size, size on disk, mtime, head hash, tail hash, content
|
regular file (path, size, mtime, head hash, tail hash, content hash). The
|
||||||
hash). The database persists between runs. A rescan hashes only the files that
|
database persists between runs. A rescan hashes only the files that are new or
|
||||||
are new or changed, and removes the records of files that no longer exist.
|
changed, and removes the records of files that no longer exist. `report` reads
|
||||||
`report` reads the database and prints the file-level duplicates report. `trees`
|
the database and prints the file-level duplicates report. `trees` reads the same
|
||||||
reads the same database and prints the duplicate-tree report. `info` prints the
|
database and prints the duplicate-tree report. `info` prints the database's
|
||||||
database's details: its path, the sizes of its files and the number of its
|
details: its path, the sizes of its files and the number of its records. A
|
||||||
records. A missing/invalid subcommand — or a `scan` invocation with no `PATH`
|
missing/invalid subcommand — or a `scan` invocation with no `PATH` operand —
|
||||||
operand — prints a usage message and exits 2.
|
prints a usage message and exits 2.
|
||||||
|
|
||||||
The database defaults to `/var/lib/sfdupes/db.sqlite` and can be placed anywhere
|
The database defaults to `/var/lib/sfdupes/db.sqlite` and can be placed anywhere
|
||||||
by setting `SFDUPES_DATABASE`. The intended deployment is a daily `sfdupes scan`
|
by setting `SFDUPES_DATABASE`. The intended deployment is a daily `sfdupes scan`
|
||||||
@@ -226,12 +226,12 @@ Goals, in order:
|
|||||||
its ends and at 1 MiB per gigabyte (see "Duplicate detection" below).
|
its ends and at 1 MiB per gigabyte (see "Duplicate detection" below).
|
||||||
Therefore no file of 100 MiB or more is read in full. Scale target: tens of
|
Therefore no file of 100 MiB or more is read in full. Scale target: tens of
|
||||||
millions of files, ~150 TB filesystem, possibly slow or busy disks (ZFS pool
|
millions of files, ~150 TB filesystem, possibly slow or busy disks (ZFS pool
|
||||||
under resilver). Holding one small record (path, size, size on disk, mtime)
|
under resilver). Holding one small record (path, size, mtime) per file in
|
||||||
per file in memory during a scan is acceptable; holding every file's hashes
|
memory during a scan is acceptable; holding every file's hashes is not (they
|
||||||
is not (they stay in the database). The reporting commands do not hold every
|
stay in the database). The reporting commands do not hold every file's
|
||||||
file's hashes either: `report` reads the records in signature order and
|
hashes either: `report` reads the records in signature order and holds the
|
||||||
holds the duplicate paths of one size at a time, so its memory grows with
|
duplicate paths of one size at a time, so its memory grows with the
|
||||||
the duplicates of the size that has the most of them, and `trees` reads the
|
duplicates of the size that has the most of them, and `trees` reads the
|
||||||
records in path order and keeps each directory's path, digest and totals,
|
records in path order and keeps each directory's path, digest and totals,
|
||||||
plus the hashes of only the files in the directories holding the record
|
plus the hashes of only the files in the directories holding the record
|
||||||
being read, so its memory grows with the number of directories and with the
|
being read, so its memory grows with the number of directories and with the
|
||||||
@@ -368,8 +368,7 @@ All four subcommands operate on a single SQLite database file:
|
|||||||
- While it loads the existing records (`load`) and walks: no records. A
|
- While it loads the existing records (`load`) and walks: no records. A
|
||||||
first scan's database holds none, however long its walk has run.
|
first scan's database holds none, however long its walk has run.
|
||||||
- During the hash phase: the first records, committed 10,000 at a time.
|
- During the hash phase: the first records, committed 10,000 at a time.
|
||||||
- In the update phase: the rest of the hashed records, the new `disk_size`
|
- In the update phase: the rest of the hashed records, and the deletions.
|
||||||
of unchanged files whose size on disk changed, and the deletions.
|
|
||||||
- At the end, as it closes the database: the `-wal` and `-shm` files go
|
- At the end, as it closes the database: the `-wal` and `-shm` files go
|
||||||
away, unless a report still has the database open (see above).
|
away, unless a report still has the database open (see above).
|
||||||
- `report`, `trees` and `info` open the database read-only and need only read
|
- `report`, `trees` and `info` open the database read-only and need only read
|
||||||
@@ -387,7 +386,6 @@ All four subcommands operate on a single SQLite database file:
|
|||||||
CREATE TABLE files (
|
CREATE TABLE files (
|
||||||
path BLOB PRIMARY KEY, -- absolute path, raw bytes
|
path BLOB PRIMARY KEY, -- absolute path, raw bytes
|
||||||
size INTEGER NOT NULL, -- bytes, from lstat
|
size INTEGER NOT NULL, -- bytes, from lstat
|
||||||
disk_size INTEGER NOT NULL, -- bytes allocated on disk (st_blocks × 512), from lstat
|
|
||||||
mtime INTEGER NOT NULL, -- whole Unix seconds of the mtime, from lstat
|
mtime INTEGER NOT NULL, -- whole Unix seconds of the mtime, from lstat
|
||||||
mtime_nsec INTEGER NOT NULL, -- nanoseconds within that second, 0 to 999999999
|
mtime_nsec INTEGER NOT NULL, -- nanoseconds within that second, 0 to 999999999
|
||||||
head BLOB NOT NULL, -- raw 32-byte SHA-256 of the first 64 KiB
|
head BLOB NOT NULL, -- raw 32-byte SHA-256 of the first 64 KiB
|
||||||
@@ -395,20 +393,6 @@ 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
|
||||||
@@ -422,17 +406,6 @@ 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. Three triggers on `files` 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` reads both numbers from this row. `scan`,
|
|
||||||
`report` and `trees` read the number of records from it as the total of
|
|
||||||
their progress displays. Counting the records in `files` would read every
|
|
||||||
record, which takes minutes on a database of tens of millions. The triggers
|
|
||||||
make each write to `files` slower, so a first scan of many small files takes
|
|
||||||
about two fifths longer. On large files, where reading them takes most of
|
|
||||||
the time, the difference is small.
|
|
||||||
|
|
||||||
### 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.
|
||||||
@@ -488,26 +461,17 @@ 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
|
`-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.
|
walk does not enter such a directory and so does not reach it.
|
||||||
|
|
||||||
An operand is not scanned when it is:
|
An operand that is a symlink (never followed, not even as an operand), socket,
|
||||||
|
FIFO, or device node, or a directory named `.zfs`, is not scanned. `scan` prints
|
||||||
- a symlink, which `scan` never follows, not even as an operand;
|
a one-line warning naming the path and what it is, counts it as skipped, and
|
||||||
- a socket, FIFO or device node;
|
drops it from the scanned operands before reading the database. Another operand
|
||||||
- at or inside a directory named `.zfs`.
|
beneath it is still scanned. The records stored beneath it are not deleted: they
|
||||||
|
are treated like any other record outside the scanned operands. If it lies under
|
||||||
An operand reached through a symlink into a `.zfs` directory is not scanned
|
another operand, they are under that operand instead, and are treated like any
|
||||||
either.
|
other record there. This is not an error: a scan whose every operand is dropped
|
||||||
|
walks nothing and exits 0. An operand that is not on a persistent filesystem
|
||||||
This holds even when the operand lies under another operand. `scan` prints a
|
(see "Rules for the walk") is dropped the same way. The line `scan` prints for
|
||||||
one-line warning naming the path and what it is, counts it as skipped, and drops
|
it is not a warning, though, and it is not counted as skipped.
|
||||||
it from the scanned operands before reading the database. An operand beneath a
|
|
||||||
symlink operand is still scanned. The records stored beneath the dropped operand
|
|
||||||
are not deleted: they are treated like any other record outside the scanned
|
|
||||||
operands. If it lies under another operand, they are under that operand instead,
|
|
||||||
and are treated like any other record there. This is not an error: a scan whose
|
|
||||||
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:
|
||||||
@@ -516,11 +480,6 @@ operands:
|
|||||||
- A file already in the database is **skipped without reading its contents**
|
- A file already in the database is **skipped without reading its contents**
|
||||||
when its lstat size equals the recorded size and its lstat mtime is not newer
|
when its lstat size equals the recorded size and its lstat mtime is not newer
|
||||||
than the recorded mtime. This is what makes a daily rescan cheap.
|
than the recorded mtime. This is what makes a daily rescan cheap.
|
||||||
- When such a file's size on disk differs from the recorded `disk_size`, the
|
|
||||||
scan updates `disk_size` alone. It still does not read the file, and counts it
|
|
||||||
as unchanged. ZFS, for example, counts a new file's blocks only seconds after
|
|
||||||
the write. A file scanned within those seconds gets its real `disk_size` at
|
|
||||||
the next scan.
|
|
||||||
- A file whose mtime is newer than recorded, or whose size differs, is hashed
|
- A file whose mtime is newer than recorded, or whose size differs, is hashed
|
||||||
again. Change detection compares the mtime to the nanosecond, as finely as the
|
again. Change detection compares the mtime to the nanosecond, as finely as the
|
||||||
filesystem records it, so a same-size rewrite counts as a change whenever the
|
filesystem records it, so a same-size rewrite counts as a change whenever the
|
||||||
@@ -548,8 +507,8 @@ inside the walk and hash phases, and batched database writes begin during the
|
|||||||
hash phase:
|
hash phase:
|
||||||
|
|
||||||
1. **load** — read the existing records. Each record under the operands is kept
|
1. **load** — read the existing records. Each record under the operands is kept
|
||||||
in memory, by path, with its size, its size on disk and its mtime, for the
|
in memory, by path, with its size and its mtime, for the walk to compare
|
||||||
walk to compare each file with.
|
each file with.
|
||||||
2. **walk + stat** — enumerate the trees under all `PATH` operands concurrently
|
2. **walk + stat** — enumerate the trees under all `PATH` operands concurrently
|
||||||
with the walk worker pool: every operand seeds the shared queue, and each
|
with the walk worker pool: every operand seeds the shared queue, and each
|
||||||
worker reads one directory at a time, handing discovered subdirectories back
|
worker reads one directory at a time, handing discovered subdirectories back
|
||||||
@@ -557,10 +516,9 @@ hash phase:
|
|||||||
(while the directory's metadata is still hot). Sequential directory
|
(while the directory's metadata is still hot). Sequential directory
|
||||||
enumeration is metadata-latency-bound and takes hours at tens of millions of
|
enumeration is metadata-latency-bound and takes hours at tens of millions of
|
||||||
files; per-directory parallelism is what makes the walk tractable on large
|
files; per-directory parallelism is what makes the walk tractable on large
|
||||||
or busy pools. The walk compares each file with its record as it goes. An
|
or busy pools. The walk compares each file with its record as it goes, and
|
||||||
unchanged file is not read again. When its size on disk has changed, the
|
an unchanged file needs nothing more. Every new or changed file is carried
|
||||||
update phase writes its new `disk_size`. Every new or changed file is
|
to the hash phase as a (path, size, mtime) record.
|
||||||
carried to the hash phase as a (path, size, size on disk, mtime) record.
|
|
||||||
3. **hash** — the worker pool hashes every carried file as described in
|
3. **hash** — the worker pool hashes every carried file as described in
|
||||||
"Duplicate detection" above, which gives the file its `head`, `tail` and
|
"Duplicate detection" above, which gives the file its `head`, `tail` and
|
||||||
`content`. Zero-length files have constant hashes and are never opened.
|
`content`. Zero-length files have constant hashes and are never opened.
|
||||||
@@ -572,8 +530,7 @@ hash phase:
|
|||||||
committed in batched transactions **while hashing runs**. A scan interrupted
|
committed in batched transactions **while hashing runs**. A scan interrupted
|
||||||
after hours therefore keeps everything hashed so far, and the next scan
|
after hours therefore keeps everything hashed so far, and the next scan
|
||||||
resumes cheaply, skipping records already written.
|
resumes cheaply, skipping records already written.
|
||||||
4. **update** — commit the final partial batch of hashed records, and the new
|
4. **update** — commit the final partial batch of hashed records. Then delete
|
||||||
`disk_size` of each unchanged file whose size on disk changed. Then delete
|
|
||||||
the records the scan did not verify: those of vanished files, and of paths
|
the records the scan did not verify: those of vanished files, and of paths
|
||||||
that failed to stat or hash.
|
that failed to stat or hash.
|
||||||
|
|
||||||
@@ -722,18 +679,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"):
|
"Progress"): `open` opens the database; `read` counts the records, then reads
|
||||||
|
them in signature order, largest size first. That is the order of the
|
||||||
- `open` opens the database.
|
`files_signature` index read backwards, so SQLite sorts nothing and returns the
|
||||||
- `read` gets the number of records from `totals`, then reads the records in
|
first record at once. The records sharing a signature come together, and so do
|
||||||
signature order, largest size first.
|
the records of one size, so `report` holds only the duplicate groups of the size
|
||||||
|
it is reading, and writes them, ordered by `first` path, once it reaches a
|
||||||
Signature order, largest size first, is the `files_signature` index read
|
smaller size. Its memory holds at most the duplicate paths of one size at a
|
||||||
backwards, so SQLite sorts nothing and returns the first record at once. The
|
time, never every record.
|
||||||
records sharing a signature come together, and so do the records of one size, so
|
|
||||||
`report` holds only the duplicate groups of the size it is reading, and writes
|
|
||||||
them, ordered by `first` path, once it reaches a smaller size. Its memory holds
|
|
||||||
at most the duplicate paths of one size at a time, never every record.
|
|
||||||
|
|
||||||
#### Report output format
|
#### Report output format
|
||||||
|
|
||||||
@@ -807,37 +760,22 @@ 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"):
|
"Progress"): `open` opens the database; `read` counts the records, then reads
|
||||||
|
them in path order, building the hierarchy and computing the digests as it goes;
|
||||||
- `open` opens the database.
|
`group` groups the directories by digest and picks the maximal groups; `write`
|
||||||
- `read` gets the number of records from `totals`, then reads the records in
|
writes the groups to stdout.
|
||||||
path order, building the hierarchy and computing the digests as it goes.
|
|
||||||
- `group` groups the directories by digest and picks the maximal groups.
|
|
||||||
- `write` writes the groups to stdout.
|
|
||||||
|
|
||||||
#### Trees output format
|
#### Trees output format
|
||||||
|
|
||||||
TSV on stdout: a header line, then one row per duplicate tree (N-1 rows for a
|
TSV on stdout: a header line, then one row per duplicate tree (N-1 rows for a
|
||||||
group of N). After the `first` and `dupe` paths come three numbers:
|
group of N). `files` is the recursive regular-file count of one copy of the
|
||||||
|
tree; `size` is the recursive total byte size of one copy:
|
||||||
- `files`, the number of regular files in one copy of the tree, counted
|
|
||||||
recursively;
|
|
||||||
- `size`, the bytes of one copy, its files' sizes added up;
|
|
||||||
- `disk_size`, the bytes the `dupe` copy takes on disk, its files' `disk_size`
|
|
||||||
added up.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
first dupe files size disk_size
|
first dupe files size
|
||||||
/srv/a/project /srv/backup/project 3417 104857600 61865984
|
/srv/a/project /srv/backup/project 3417 104857600
|
||||||
```
|
```
|
||||||
|
|
||||||
A file's `disk_size` is the space its blocks take on disk. It can be more than
|
|
||||||
the file's size, because space is allocated in whole blocks. It is less than the
|
|
||||||
file's size on a compressed or sparse file, so two copies of one tree can differ
|
|
||||||
in `disk_size`. When a file in one copy is a hard link to one in the other, both
|
|
||||||
copies count its blocks in full. Removing it from one copy then frees nothing,
|
|
||||||
as the known limitation under "`trees` mode" says.
|
|
||||||
|
|
||||||
The `first` and `dupe` paths are escaped as described under "Report output
|
The `first` and `dupe` paths are escaped as described under "Report output
|
||||||
format". The root directory's path is `/`.
|
format". The root directory's path is `/`.
|
||||||
|
|
||||||
@@ -872,19 +810,15 @@ size of recorded files 150000000000000 (136.4 TiB)
|
|||||||
database is in WAL mode, the newest changes may be in the `-wal` file alone.
|
database is in WAL mode, the newest changes may be in the `-wal` file alone.
|
||||||
- `schema version` is the database's `PRAGMA user_version`. A database with
|
- `schema version` is the database's `PRAGMA user_version`. A database with
|
||||||
another version than this build's is refused, as by `report`.
|
another version than this build's is refused, as by `report`.
|
||||||
- `records` is the number of records. `size of recorded files` is the sum of the
|
- `records` is the number of records. `size of recorded files` adds up the sizes
|
||||||
sizes 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"):
|
"Progress"): `open` opens the database; `count` reads the schema version and
|
||||||
|
counts the records with one query, a single pass over the table, both in one
|
||||||
- `open` opens the database.
|
read-only transaction, so the numbers agree with each other while a scan is
|
||||||
- `count` reads the schema version and the row of `totals` (see "Database").
|
committing. `info` writes its lines once the `count` display has ended, so on a
|
||||||
Both reads run in one read-only transaction, so the numbers agree with each
|
terminal they come after the displays.
|
||||||
other while a scan is committing.
|
|
||||||
|
|
||||||
`info` writes its lines once the `count` display has ended, so on a terminal
|
|
||||||
they come after the displays.
|
|
||||||
|
|
||||||
### Progress
|
### Progress
|
||||||
|
|
||||||
@@ -900,13 +834,12 @@ 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` gets the number of records from `totals` first, then reads the records
|
- `load` counts the records first, then reads them against that count. While the
|
||||||
against that number. Until it has the number, the display says it is counting
|
count runs, the display says it is counting the records.
|
||||||
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 `~`. Once
|
||||||
time remaining is unknown once the walk passes the estimate, and when there
|
the walk passes the estimate, or when there are no such records, as on a first
|
||||||
are no such records, as on a first scan.
|
scan, the time remaining is unknown.
|
||||||
- `hash` and `update` have exact totals. Only files that actually need hashing
|
- `hash` and `update` have exact totals. Only files that actually need hashing
|
||||||
appear in the hash total, so its time remaining is meaningful.
|
appear in the hash total, so its time remaining is meaningful.
|
||||||
|
|
||||||
@@ -915,9 +848,8 @@ 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` gets the number of records from `totals` first, then reads the records
|
- `read` counts the records first, then reads them against that count. While the
|
||||||
against that number. Until it has the number, the display says it is counting
|
count runs, the display says it is counting the records.
|
||||||
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.
|
||||||
|
|
||||||
@@ -925,16 +857,17 @@ 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` gets the number of records from `totals` first, then reads the records
|
- `read` counts the records first, then reads them against that count, writing
|
||||||
against that number, writing the rows to stdout as it goes. Until it has the
|
the rows to stdout as it goes. While the count runs, the display says it is
|
||||||
number, the display says it is counting the records.
|
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 it reads one row: the display says it is
|
- `count` has no total either, because one query counts everything: the display
|
||||||
counting the records until it has the row, and its time remaining is unknown.
|
says it is counting the records until the query returns, and its time
|
||||||
|
remaining is unknown.
|
||||||
|
|
||||||
Each display is one line holding:
|
Each display is one line holding:
|
||||||
|
|
||||||
@@ -980,8 +913,8 @@ Additional requirements:
|
|||||||
- On a terminal, a phase with an exact total ends its line with its percent and
|
- On a terminal, a phase with an exact total ends its line with its percent and
|
||||||
a bar 10 columns wide. This percent takes the place of the line's own. The bar
|
a bar 10 columns wide. This percent takes the place of the line's own. The bar
|
||||||
stays at the terminal's right edge, so it does not move as the line grows or
|
stays at the terminal's right edge, so it does not move as the line grows or
|
||||||
shrinks. A phase with nothing to do shows no bar. Every phase has an exact
|
shrinks. Every phase has an exact total once it has counted what it will do,
|
||||||
total once it has counted what it will do, except these:
|
except these:
|
||||||
- `scan`'s `walk`;
|
- `scan`'s `walk`;
|
||||||
- the `open` phase of `report` and `trees`;
|
- the `open` phase of `report` and `trees`;
|
||||||
- both phases of `info`.
|
- both phases of `info`.
|
||||||
@@ -993,22 +926,21 @@ Additional requirements:
|
|||||||
```
|
```
|
||||||
|
|
||||||
- On a terminal no line reaches the terminal's last column, however narrow the
|
- On a terminal no line reaches the terminal's last column, however narrow the
|
||||||
terminal is. A line too wide for the terminal first loses whichever of these
|
terminal is. A line too wide for the terminal first loses its bar. Next it
|
||||||
it shows: its rate, the words of a stalled phase, or what the phase is waiting
|
loses whichever of these it shows: its rate, the words of a stalled phase, or
|
||||||
for. The other four parts are kept whole:
|
what the phase is waiting for. The other four parts are kept whole:
|
||||||
- the phase and its place;
|
- the phase and its place;
|
||||||
- the progress;
|
- the progress;
|
||||||
- the elapsed time;
|
- the elapsed time;
|
||||||
- the time remaining.
|
- the time remaining.
|
||||||
|
|
||||||
The line loses its bar only when those four and the bar do not fit. It is
|
The line is cut short at the terminal's edge only when those four alone are
|
||||||
cut short at the terminal's edge only when those four alone are too wide,
|
too wide, and then the end of the time remaining is lost first. That happens
|
||||||
and then the end of the time remaining is lost first. That happens hours
|
hours into a scan of millions of files, on a terminal narrower than about 75
|
||||||
into a scan of millions of files, on a terminal narrower than about 75
|
columns. On an 80-column terminal, the stalled line of the example above
|
||||||
columns. On a terminal 100 columns wide, the stalled line of the example
|
keeps its words. The next draw fits the line to a terminal made narrower
|
||||||
above keeps its words and its bar. The next draw fits the line to a terminal
|
while a phase runs. It erases the old line within the new width, and the
|
||||||
made narrower while a phase runs. It erases the old line within the new
|
display goes on redrawing in place.
|
||||||
width, and the display goes on redrawing in place.
|
|
||||||
|
|
||||||
- When stderr is not a terminal (a pipe, a file, `/dev/null`), do not emit ANSI
|
- When stderr is not a terminal (a pipe, a file, `/dev/null`), do not emit ANSI
|
||||||
redraws: print the line the moment each phase starts, every 5 seconds while it
|
redraws: print the line the moment each phase starts, every 5 seconds while it
|
||||||
@@ -1221,9 +1153,9 @@ All of the following, run in this directory, must pass:
|
|||||||
ordered by size descending.
|
ordered by size descending.
|
||||||
|
|
||||||
Expected from `trees`: exactly one row — `first` `$d/t1`, `dupe` `$d/t2`, 2
|
Expected from `trees`: exactly one row — `first` `$d/t1`, `dupe` `$d/t2`, 2
|
||||||
files, 3100 bytes, and a `disk_size` that depends on the filesystem.
|
files, 3100 bytes. `$d/t1/sub` vs `$d/t2/sub` is suppressed as non-maximal
|
||||||
`$d/t1/sub` vs `$d/t2/sub` is suppressed as non-maximal (implied by the
|
(implied by the `t1`/`t2` group), and `t3` appears nowhere (its file set
|
||||||
`t1`/`t2` group), and `t3` appears nowhere (its file set differs by name).
|
differs by name).
|
||||||
|
|
||||||
Expected from the second `report` (after the modify/delete rescan):
|
Expected from the second `report` (after the modify/delete rescan):
|
||||||
`one.bin` has left its group (its content changed), so
|
`one.bin` has left its group (its content changed), so
|
||||||
|
|||||||
@@ -29,35 +29,15 @@
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- each record holds `disk_size`, the bytes the file takes on disk: its allocated
|
|
||||||
blocks, `st_blocks` × 512, from the lstat the walk already does. `trees`
|
|
||||||
prints a `disk_size` column after `size`, the sum over the `dupe` copy's
|
|
||||||
files; on a compressed or sparse file it differs from `size`. A rescan updates
|
|
||||||
the `disk_size` of an unchanged file whose size on disk changed, without
|
|
||||||
reading the file (2026-10-27, https://git.eeqj.de/sneak/sfdupes/issues/147)
|
|
||||||
|
|
||||||
- the database keeps the number of records and the sum of their sizes in the one
|
|
||||||
row of a `totals` table, which three triggers on `files` keep current. `info`
|
|
||||||
reads that row, and so do `scan`'s `load` phase and the `read` phases of
|
|
||||||
`report` and `trees` for their totals; no count reads every record any more.
|
|
||||||
The triggers make each write to `files` slower. `scan` keeps SQLite's
|
|
||||||
temporary data in memory, which wins back part of that time (2026-10-27,
|
|
||||||
https://git.eeqj.de/sneak/sfdupes/issues/146)
|
|
||||||
|
|
||||||
- an operand at or inside a directory named `.zfs` is never scanned, whether
|
|
||||||
given alone or under another operand. `scan` warns about it, counts it as
|
|
||||||
skipped and does not walk it (2026-10-26,
|
|
||||||
https://git.eeqj.de/sneak/sfdupes/issues/142)
|
|
||||||
|
|
||||||
- a file or directory removed while the scan runs is not an error, in the walk
|
- a file or directory removed while the scan runs is not an error, in the walk
|
||||||
or in the hash phase. The scan prints no line for it and does not count it as
|
or in the hash phase. The scan prints no line for it and does not count it as
|
||||||
skipped. When the first of several hard links to a file is gone, the hash
|
skipped. When the first of several hard links to a file is gone, the hash
|
||||||
phase reads the next one instead. On a terminal every progress display starts
|
phase reads the next one instead. On a terminal every progress display starts
|
||||||
with the spinner, the hash phase's included. A phase with an exact total ends
|
with the spinner, the hash phase's included. A phase with an exact total ends
|
||||||
its line with its percent and a 10-column bar at the terminal's right edge,
|
its line with its percent and a 10-column bar at the terminal's right edge,
|
||||||
which no longer moves as the line's length changes. A line too wide for the
|
which no longer moves as the line's length changes. The time remaining is
|
||||||
terminal now loses its rate before its bar. The time remaining is labelled
|
labelled `remain`, not `eta` (2026-10-26,
|
||||||
`remain`, not `eta` (2026-10-26, https://git.eeqj.de/sneak/sfdupes/issues/133)
|
https://git.eeqj.de/sneak/sfdupes/issues/133)
|
||||||
|
|
||||||
- the `head`, `tail` and `content` columns are BLOBs, each holding the raw 32
|
- the `head`, `tail` and `content` columns are BLOBs, each holding the raw 32
|
||||||
bytes of a SHA-256, half the size of the hex text they held. The hashes stay
|
bytes of a SHA-256, half the size of the hex text they held. The hashes stay
|
||||||
|
|||||||
@@ -47,15 +47,14 @@ const dbFilePerm = 0o644
|
|||||||
const lockFilePerm = 0o600
|
const lockFilePerm = 0o600
|
||||||
|
|
||||||
// createTableSQL is the schema applied to a fresh database. Paths are
|
// createTableSQL is the schema applied to a fresh database. Paths are
|
||||||
// BLOBs because Unix paths are raw bytes, not guaranteed UTF-8.
|
// BLOBs because Unix paths are raw bytes, not guaranteed UTF-8. mtime
|
||||||
// disk_size holds the bytes the file takes on disk. mtime holds whole
|
// holds whole Unix seconds and mtime_nsec the nanoseconds within that
|
||||||
// Unix seconds and mtime_nsec the nanoseconds within that second. head,
|
// second. head, tail and content each hold the raw 32 bytes of a
|
||||||
// tail and content each hold the raw 32 bytes of a SHA-256.
|
// SHA-256.
|
||||||
const createTableSQL = `
|
const createTableSQL = `
|
||||||
CREATE TABLE files (
|
CREATE TABLE files (
|
||||||
path BLOB PRIMARY KEY,
|
path BLOB PRIMARY KEY,
|
||||||
size INTEGER NOT NULL,
|
size INTEGER NOT NULL,
|
||||||
disk_size INTEGER NOT NULL,
|
|
||||||
mtime INTEGER NOT NULL,
|
mtime INTEGER NOT NULL,
|
||||||
mtime_nsec INTEGER NOT NULL,
|
mtime_nsec INTEGER NOT NULL,
|
||||||
head BLOB NOT NULL,
|
head BLOB NOT NULL,
|
||||||
@@ -70,59 +69,14 @@ const createIndexSQL = `
|
|||||||
CREATE INDEX files_signature ON files (size, head, tail, content)
|
CREATE INDEX files_signature ON files (size, head, tail, content)
|
||||||
`
|
`
|
||||||
|
|
||||||
// createTotalsSQL creates the totals table, which holds one row: the
|
|
||||||
// number of records and the sizes of all records added up. Counting
|
|
||||||
// them over files would read every record. The three triggers below
|
|
||||||
// keep the row current, in the transaction of each change to files.
|
|
||||||
const createTotalsSQL = `
|
|
||||||
CREATE TABLE totals (
|
|
||||||
records INTEGER NOT NULL,
|
|
||||||
size INTEGER NOT NULL
|
|
||||||
)
|
|
||||||
`
|
|
||||||
|
|
||||||
// insertTotalsSQL adds the one row of totals, for an empty files table.
|
|
||||||
const insertTotalsSQL = `
|
|
||||||
INSERT INTO totals (records, size) VALUES (0, 0)
|
|
||||||
`
|
|
||||||
|
|
||||||
// totalsAfterInsertSQL counts each inserted record into totals.
|
|
||||||
const totalsAfterInsertSQL = `
|
|
||||||
CREATE TRIGGER totals_after_insert AFTER INSERT ON files BEGIN
|
|
||||||
UPDATE totals SET records = records + 1, size = size + NEW.size;
|
|
||||||
END
|
|
||||||
`
|
|
||||||
|
|
||||||
// totalsAfterDeleteSQL takes each deleted record out of totals.
|
|
||||||
const totalsAfterDeleteSQL = `
|
|
||||||
CREATE TRIGGER totals_after_delete AFTER DELETE ON files BEGIN
|
|
||||||
UPDATE totals SET records = records - 1, size = size - OLD.size;
|
|
||||||
END
|
|
||||||
`
|
|
||||||
|
|
||||||
// totalsAfterUpdateSQL replaces a record's old size in totals with its
|
|
||||||
// new one. scan's upsert of an existing record is such an update.
|
|
||||||
const totalsAfterUpdateSQL = `
|
|
||||||
CREATE TRIGGER totals_after_update AFTER UPDATE OF size ON files BEGIN
|
|
||||||
UPDATE totals SET size = size + NEW.size - OLD.size;
|
|
||||||
END
|
|
||||||
`
|
|
||||||
|
|
||||||
// recordCountSQL reads the number of records from totals.
|
|
||||||
const recordCountSQL = `
|
|
||||||
SELECT records FROM totals
|
|
||||||
`
|
|
||||||
|
|
||||||
// upsertSQL inserts one file record, replacing any existing record for
|
// upsertSQL inserts one file record, replacing any existing record for
|
||||||
// the same path.
|
// the same path.
|
||||||
const upsertSQL = `
|
const upsertSQL = `
|
||||||
INSERT INTO files (
|
INSERT INTO files (path, size, mtime, mtime_nsec, head, tail, content)
|
||||||
path, size, disk_size, mtime, mtime_nsec, head, tail, content
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT (path) DO UPDATE SET
|
ON CONFLICT (path) DO UPDATE SET
|
||||||
size = excluded.size, disk_size = excluded.disk_size,
|
size = excluded.size, mtime = excluded.mtime,
|
||||||
mtime = excluded.mtime, mtime_nsec = excluded.mtime_nsec,
|
mtime_nsec = excluded.mtime_nsec,
|
||||||
head = excluded.head, tail = excluded.tail,
|
head = excluded.head, tail = excluded.tail,
|
||||||
content = excluded.content
|
content = excluded.content
|
||||||
`
|
`
|
||||||
@@ -196,14 +150,9 @@ func createDatabaseDirectory(path string) error {
|
|||||||
// scanParams are the connection parameters for scan: read-write, with
|
// scanParams are the connection parameters for scan: read-write, with
|
||||||
// WAL journaling and a busy timeout, so a report can run while a cron
|
// WAL journaling and a busy timeout, so a report can run while a cron
|
||||||
// scan is in progress. closeScanDatabase leaves WAL mode again.
|
// scan is in progress. closeScanDatabase leaves WAL mode again.
|
||||||
//
|
|
||||||
// Each write to files fires a trigger, so SQLite keeps a statement
|
|
||||||
// journal for it. temp_store(MEMORY) keeps that journal in memory,
|
|
||||||
// which costs far less than a temporary file.
|
|
||||||
const scanParams = "_pragma=busy_timeout(10000)" +
|
const scanParams = "_pragma=busy_timeout(10000)" +
|
||||||
"&_pragma=journal_mode(WAL)" +
|
"&_pragma=journal_mode(WAL)" +
|
||||||
"&_pragma=synchronous(NORMAL)" +
|
"&_pragma=synchronous(NORMAL)"
|
||||||
"&_pragma=temp_store(MEMORY)"
|
|
||||||
|
|
||||||
// reportParams are the connection parameters for report, trees and
|
// reportParams are the connection parameters for report, trees and
|
||||||
// info: read-only, with the same busy timeout. They set no journal
|
// info: read-only, with the same busy timeout. They set no journal
|
||||||
@@ -477,14 +426,14 @@ func createSchema(ctx context.Context, db *sql.DB) error {
|
|||||||
|
|
||||||
defer func() { _ = tx.Rollback() }()
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
for _, stmt := range []string{
|
_, err = tx.ExecContext(ctx, createTableSQL)
|
||||||
createTableSQL, createIndexSQL, createTotalsSQL, insertTotalsSQL,
|
if err != nil {
|
||||||
totalsAfterInsertSQL, totalsAfterDeleteSQL, totalsAfterUpdateSQL,
|
return fmt.Errorf("create schema: %w", err)
|
||||||
} {
|
}
|
||||||
_, err = tx.ExecContext(ctx, stmt)
|
|
||||||
if err != nil {
|
_, err = tx.ExecContext(ctx, createIndexSQL)
|
||||||
return fmt.Errorf("create schema: %w", err)
|
if err != nil {
|
||||||
}
|
return fmt.Errorf("create schema: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx,
|
_, err = tx.ExecContext(ctx,
|
||||||
@@ -531,7 +480,7 @@ func loadFileRows(ctx context.Context, db *sql.DB, total func(n int64),
|
|||||||
|
|
||||||
var n int64
|
var n int64
|
||||||
|
|
||||||
err = tx.QueryRowContext(ctx, recordCountSQL).Scan(&n)
|
err = tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM files").Scan(&n)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("count records: %w", err)
|
return fmt.Errorf("count records: %w", err)
|
||||||
}
|
}
|
||||||
@@ -539,8 +488,8 @@ func loadFileRows(ctx context.Context, db *sql.DB, total func(n int64),
|
|||||||
total(n)
|
total(n)
|
||||||
|
|
||||||
rows, err := tx.QueryContext(ctx,
|
rows, err := tx.QueryContext(ctx,
|
||||||
"SELECT path, size, disk_size, mtime, mtime_nsec, head, tail, "+
|
"SELECT path, size, mtime, mtime_nsec, head, tail, content "+
|
||||||
"content FROM files ORDER BY path")
|
"FROM files ORDER BY path")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read records: %w", err)
|
return fmt.Errorf("read records: %w", err)
|
||||||
}
|
}
|
||||||
@@ -554,8 +503,7 @@ func loadFileRows(ctx context.Context, db *sql.DB, total func(n int64),
|
|||||||
r scanRec
|
r scanRec
|
||||||
)
|
)
|
||||||
|
|
||||||
err = rows.Scan(&path, &r.size, &r.diskSize, &sec, &nsec,
|
err = rows.Scan(&path, &r.size, &sec, &nsec, &head, &tail, &content)
|
||||||
&head, &tail, &content)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read record: %w", err)
|
return fmt.Errorf("read record: %w", err)
|
||||||
}
|
}
|
||||||
@@ -590,11 +538,10 @@ ORDER BY size DESC, head DESC, tail DESC, content DESC
|
|||||||
`
|
`
|
||||||
|
|
||||||
// loadSignatureRows gives total the number of records, then streams
|
// loadSignatureRows gives total the number of records, then streams
|
||||||
// every record's signature and path to fn, without its mtime or its
|
// every record's signature and path to fn, without its mtime, in the
|
||||||
// disk_size, in the order of signatureRowsSQL. The count and the
|
// order of signatureRowsSQL. The count and the records are read in one
|
||||||
// records are read in one transaction, so they agree while a scan is
|
// transaction, so they agree while a scan is committing. An error from
|
||||||
// committing. An error from fn stops the reading and is returned as it
|
// fn stops the reading and is returned as it is.
|
||||||
// is.
|
|
||||||
func loadSignatureRows(ctx context.Context, db *sql.DB,
|
func loadSignatureRows(ctx context.Context, db *sql.DB,
|
||||||
total func(n int64), fn func(r scanRec) error,
|
total func(n int64), fn func(r scanRec) error,
|
||||||
) error {
|
) error {
|
||||||
@@ -609,7 +556,7 @@ func loadSignatureRows(ctx context.Context, db *sql.DB,
|
|||||||
|
|
||||||
var n int64
|
var n int64
|
||||||
|
|
||||||
err = tx.QueryRowContext(ctx, recordCountSQL).Scan(&n)
|
err = tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM files").Scan(&n)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("count records: %w", err)
|
return fmt.Errorf("count records: %w", err)
|
||||||
}
|
}
|
||||||
@@ -675,7 +622,7 @@ func (r *scanRec) setHashes(head, tail, content []byte) error {
|
|||||||
func countRecords(ctx context.Context, db *sql.DB) (int64, error) {
|
func countRecords(ctx context.Context, db *sql.DB) (int64, error) {
|
||||||
var n int64
|
var n int64
|
||||||
|
|
||||||
err := db.QueryRowContext(ctx, recordCountSQL).Scan(&n)
|
err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM files").Scan(&n)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("count records: %w", err)
|
return 0, fmt.Errorf("count records: %w", err)
|
||||||
}
|
}
|
||||||
@@ -683,10 +630,12 @@ func countRecords(ctx context.Context, db *sql.DB) (int64, error) {
|
|||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// databaseCountsSQL reads the number of records and the sizes of all
|
// databaseCountsSQL counts the records and adds up their sizes, in one
|
||||||
// records added up from totals.
|
// pass over the table. A SUM over no rows is NULL, so on an empty table
|
||||||
|
// the SUM falls back to 0.
|
||||||
const databaseCountsSQL = `
|
const databaseCountsSQL = `
|
||||||
SELECT records, size FROM totals
|
SELECT COUNT(*), COALESCE(SUM(size), 0)
|
||||||
|
FROM files
|
||||||
`
|
`
|
||||||
|
|
||||||
// databaseCounts is what info reads from the database: its schema
|
// databaseCounts is what info reads from the database: its schema
|
||||||
@@ -729,15 +678,15 @@ func readDatabaseCounts(ctx context.Context,
|
|||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadFileMeta streams every record's path, size, disk_size and mtime
|
// loadFileMeta streams every record's path, size and mtime to fn. Scan
|
||||||
// to fn. Scan change detection needs no hash values, and skipping the
|
// change detection needs no hash values, and skipping the hash columns
|
||||||
// hash columns keeps the scan's in-memory index small on
|
// keeps the scan's in-memory index small on multi-million-file
|
||||||
// multi-million-file databases.
|
// databases.
|
||||||
func loadFileMeta(ctx context.Context, db *sql.DB,
|
func loadFileMeta(ctx context.Context, db *sql.DB,
|
||||||
fn func(path string, m fileMeta),
|
fn func(path string, size int64, mtime time.Time),
|
||||||
) error {
|
) error {
|
||||||
rows, err := db.QueryContext(ctx,
|
rows, err := db.QueryContext(ctx,
|
||||||
"SELECT path, size, disk_size, mtime, mtime_nsec FROM files")
|
"SELECT path, size, mtime, mtime_nsec FROM files")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read records: %w", err)
|
return fmt.Errorf("read records: %w", err)
|
||||||
}
|
}
|
||||||
@@ -746,19 +695,16 @@ func loadFileMeta(ctx context.Context, db *sql.DB,
|
|||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var (
|
var (
|
||||||
path []byte
|
path []byte
|
||||||
m fileMeta
|
size, sec, nsec int64
|
||||||
sec, nsec int64
|
|
||||||
)
|
)
|
||||||
|
|
||||||
err = rows.Scan(&path, &m.size, &m.diskSize, &sec, &nsec)
|
err = rows.Scan(&path, &size, &sec, &nsec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read record: %w", err)
|
return fmt.Errorf("read record: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.mtime = time.Unix(sec, nsec)
|
fn(string(path), size, time.Unix(sec, nsec))
|
||||||
|
|
||||||
fn(string(path), m)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err = rows.Err()
|
err = rows.Err()
|
||||||
@@ -776,21 +722,21 @@ func loadFileMeta(ctx context.Context, db *sql.DB,
|
|||||||
// WAL small and let concurrent reports observe progress.
|
// WAL small and let concurrent reports observe progress.
|
||||||
const updateBatchSize = 10000
|
const updateBatchSize = 10000
|
||||||
|
|
||||||
// applyChanges writes upserts for new and changed files and deletes for
|
// applyChanges writes one scan's database changes — upserts for new and
|
||||||
// vanished ones, in batched transactions. Progress is rendered on prog
|
// changed files, deletes for vanished ones — in batched transactions.
|
||||||
// (one increment per change).
|
// Progress is rendered on prog (one increment per change).
|
||||||
func applyChanges(ctx context.Context, db *sql.DB, upserts []scanRec,
|
func applyChanges(ctx context.Context, db *sql.DB, upserts []scanRec,
|
||||||
deletes []string, prog *progress,
|
deletes []string, prog *progress,
|
||||||
) error {
|
) error {
|
||||||
for batch := range slices.Chunk(upserts, updateBatchSize) {
|
for batch := range slices.Chunk(upserts, updateBatchSize) {
|
||||||
err := applyBatch(ctx, db, batch, nil, nil, prog)
|
err := applyBatch(ctx, db, batch, nil, prog)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for batch := range slices.Chunk(deletes, updateBatchSize) {
|
for batch := range slices.Chunk(deletes, updateBatchSize) {
|
||||||
err := applyBatch(ctx, db, nil, nil, batch, prog)
|
err := applyBatch(ctx, db, nil, batch, prog)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -799,26 +745,10 @@ func applyChanges(ctx context.Context, db *sql.DB, upserts []scanRec,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyDiskSizes writes the disk_size of each of recs into its record,
|
// applyBatch commits one batch of upserts and deletes in a single
|
||||||
// in batched transactions, and leaves the rest of the record alone.
|
// transaction.
|
||||||
// Progress is rendered on prog (one increment per record).
|
|
||||||
func applyDiskSizes(ctx context.Context, db *sql.DB, recs []fileRec,
|
|
||||||
prog *progress,
|
|
||||||
) error {
|
|
||||||
for batch := range slices.Chunk(recs, updateBatchSize) {
|
|
||||||
err := applyBatch(ctx, db, nil, batch, nil, prog)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyBatch commits one batch of upserts, disk_size updates and
|
|
||||||
// deletes in a single transaction.
|
|
||||||
func applyBatch(ctx context.Context, db *sql.DB, upserts []scanRec,
|
func applyBatch(ctx context.Context, db *sql.DB, upserts []scanRec,
|
||||||
diskSizes []fileRec, deletes []string, prog *progress,
|
deletes []string, prog *progress,
|
||||||
) error {
|
) error {
|
||||||
tx, err := db.BeginTx(ctx, nil)
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -832,11 +762,6 @@ func applyBatch(ctx context.Context, db *sql.DB, upserts []scanRec,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = execDiskSizes(ctx, tx, diskSizes, prog)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = execDeletes(ctx, tx, deletes, prog)
|
err = execDeletes(ctx, tx, deletes, prog)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -862,7 +787,7 @@ func execUpserts(ctx context.Context, tx *sql.Tx, upserts []scanRec,
|
|||||||
defer func() { _ = st.Close() }()
|
defer func() { _ = st.Close() }()
|
||||||
|
|
||||||
for _, r := range upserts {
|
for _, r := range upserts {
|
||||||
_, err = st.ExecContext(ctx, []byte(r.path), r.size, r.diskSize,
|
_, err = st.ExecContext(ctx, []byte(r.path), r.size,
|
||||||
r.mtime.Unix(), r.mtime.Nanosecond(),
|
r.mtime.Unix(), r.mtime.Nanosecond(),
|
||||||
r.head[:], r.tail[:], r.content[:])
|
r.head[:], r.tail[:], r.content[:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -875,31 +800,6 @@ func execUpserts(ctx context.Context, tx *sql.Tx, upserts []scanRec,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// execDiskSizes sets the disk_size of the record of each of recs. It
|
|
||||||
// does not touch size, so the totals triggers do not fire.
|
|
||||||
func execDiskSizes(ctx context.Context, tx *sql.Tx, recs []fileRec,
|
|
||||||
prog *progress,
|
|
||||||
) error {
|
|
||||||
st, err := tx.PrepareContext(ctx,
|
|
||||||
"UPDATE files SET disk_size = ? WHERE path = ?")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("prepare disk_size update: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = st.Close() }()
|
|
||||||
|
|
||||||
for _, r := range recs {
|
|
||||||
_, err = st.ExecContext(ctx, r.diskSize, []byte(r.path))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("update disk_size of %s: %w", r.path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
prog.increment()
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// execDeletes removes the records for paths no longer present.
|
// execDeletes removes the records for paths no longer present.
|
||||||
func execDeletes(ctx context.Context, tx *sql.Tx, deletes []string,
|
func execDeletes(ctx context.Context, tx *sql.Tx, deletes []string,
|
||||||
prog *progress,
|
prog *progress,
|
||||||
|
|||||||
+10
-157
@@ -535,12 +535,17 @@ func TestLoadRowsRefuseWrongSizeHash(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryPlan returns the detail of each line of SQLite's EXPLAIN QUERY
|
// TestSignatureRowsNeedNoSort checks that SQLite reads signatureRowsSQL
|
||||||
// PLAN for query.
|
// from the files_signature index alone, with nothing to sort: the
|
||||||
func queryPlan(t *testing.T, db *sql.DB, query string) []string {
|
// first record comes at once, so report's read phase counts each
|
||||||
t.Helper()
|
// record as it comes rather than waiting on SQLite for all of them.
|
||||||
|
func TestSignatureRowsNeedNoSort(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
rows, err := db.QueryContext(t.Context(), "EXPLAIN QUERY PLAN "+query)
|
db := openTestDB(t)
|
||||||
|
|
||||||
|
rows, err := db.QueryContext(t.Context(),
|
||||||
|
"EXPLAIN QUERY PLAN "+signatureRowsSQL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -568,164 +573,12 @@ func queryPlan(t *testing.T, db *sql.DB, query string) []string {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return plan
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSignatureRowsNeedNoSort checks that SQLite reads signatureRowsSQL
|
|
||||||
// from the files_signature index alone, with nothing to sort: the
|
|
||||||
// first record comes at once, so report's read phase counts each
|
|
||||||
// record as it comes rather than waiting on SQLite for all of them.
|
|
||||||
func TestSignatureRowsNeedNoSort(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
db := openTestDB(t)
|
|
||||||
|
|
||||||
plan := queryPlan(t, db, signatureRowsSQL)
|
|
||||||
|
|
||||||
want := []string{"SCAN files USING COVERING INDEX files_signature"}
|
want := []string{"SCAN files USING COVERING INDEX files_signature"}
|
||||||
if !slices.Equal(plan, want) {
|
if !slices.Equal(plan, want) {
|
||||||
t.Errorf("query plan = %q, want %q", plan, want)
|
t.Errorf("query plan = %q, want %q", plan, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCountsReadOnlyTotals checks that SQLite answers the queries for
|
|
||||||
// the number of records and their total size from the one row of
|
|
||||||
// totals. A plan that reads files or its index reads every record.
|
|
||||||
func TestCountsReadOnlyTotals(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
db := openTestDB(t)
|
|
||||||
|
|
||||||
for _, query := range []string{recordCountSQL, databaseCountsSQL} {
|
|
||||||
plan := queryPlan(t, db, query)
|
|
||||||
|
|
||||||
want := []string{"SCAN totals"}
|
|
||||||
if !slices.Equal(plan, want) {
|
|
||||||
t.Errorf("query plan of %q = %q, want %q", query, plan, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCountsComeFromTotals sets the row of totals to numbers that files
|
|
||||||
// does not hold, then checks that scan's load phase, the read phases of
|
|
||||||
// trees and report, and info each report the numbers from totals.
|
|
||||||
func TestCountsComeFromTotals(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// files is empty, so a count over files gives 0 records of 0 bytes.
|
|
||||||
const records, size = 7, 9000
|
|
||||||
|
|
||||||
db := openTestDB(t)
|
|
||||||
|
|
||||||
_, err := db.ExecContext(t.Context(),
|
|
||||||
"UPDATE totals SET records = ?, size = ?", records, size)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
scanTotal, err := countRecords(t.Context(), db)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var treesTotal, reportTotal int64
|
|
||||||
|
|
||||||
err = loadFileRows(t.Context(), db,
|
|
||||||
func(n int64) { treesTotal = n }, func(scanRec) {})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = loadSignatureRows(t.Context(), db,
|
|
||||||
func(n int64) { reportTotal = n },
|
|
||||||
func(scanRec) error { return nil })
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := readDatabaseCounts(t.Context(), db)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for name, got := range map[string]int64{
|
|
||||||
"scan's load": scanTotal,
|
|
||||||
"trees' read": treesTotal,
|
|
||||||
"report's read": reportTotal,
|
|
||||||
"info": info.records,
|
|
||||||
} {
|
|
||||||
if got != records {
|
|
||||||
t.Errorf("%s: %d records, want %d from totals",
|
|
||||||
name, got, records)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if info.size != size {
|
|
||||||
t.Errorf("info: size %d, want %d from totals", info.size, size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertTotalsMatchFiles fails unless the row of totals holds the
|
|
||||||
// number of records and the sizes of all records added up, as counted
|
|
||||||
// over files.
|
|
||||||
func assertTotalsMatchFiles(t *testing.T, db *sql.DB) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
got, err := readDatabaseCounts(t.Context(), db)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var records, size int64
|
|
||||||
|
|
||||||
err = db.QueryRowContext(t.Context(),
|
|
||||||
"SELECT COUNT(*), COALESCE(SUM(size), 0) FROM files").
|
|
||||||
Scan(&records, &size)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got.records != records || got.size != size {
|
|
||||||
t.Errorf("totals = %d records of %d bytes, files holds %d of %d",
|
|
||||||
got.records, got.size, records, size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTotalsMatchFiles scans the smoke tree, rescans it after two files
|
|
||||||
// change size, and rescans it after two files are removed. After each
|
|
||||||
// scan the row of totals must agree with files.
|
|
||||||
func TestTotalsMatchFiles(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
dir := buildSmokeTree(t)
|
|
||||||
db := openTestDB(t)
|
|
||||||
|
|
||||||
syncTree(t, db, dir)
|
|
||||||
assertTotalsMatchFiles(t, db)
|
|
||||||
|
|
||||||
writeFile(t, dir, "a/one.bin", pattern(10, 2500))
|
|
||||||
writeFile(t, dir, "t1/f1", pattern(30, 1000))
|
|
||||||
|
|
||||||
if st := syncTree(t, db, dir); st.updated != 2 {
|
|
||||||
t.Fatalf("rescan stats = %+v, want 2 updated", st)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertTotalsMatchFiles(t, db)
|
|
||||||
|
|
||||||
for _, name := range []string{"a/unique.bin", "t2/sub/f2"} {
|
|
||||||
err := os.Remove(filepath.Join(dir, name))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if st := syncTree(t, db, dir); st.removed != 2 {
|
|
||||||
t.Fatalf("rescan stats = %+v, want 2 removed", st)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertTotalsMatchFiles(t, db)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyChangesRoundTrip(t *testing.T) {
|
func TestApplyChangesRoundTrip(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+22
-157
@@ -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())
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-40
@@ -60,7 +60,7 @@ const fallbackWidth = 80
|
|||||||
const spinnerMargin = 5
|
const spinnerMargin = 5
|
||||||
|
|
||||||
// countingRecords is what the display of a phase that reads the records
|
// countingRecords is what the display of a phase that reads the records
|
||||||
// says until it has the number of records, its total.
|
// says while it counts them, before it has its total.
|
||||||
const countingRecords = "counting the records"
|
const countingRecords = "counting the records"
|
||||||
|
|
||||||
// openPhase is the name of the phase in which report, trees and info
|
// openPhase is the name of the phase in which report, trees and info
|
||||||
@@ -217,13 +217,27 @@ 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)
|
room := width - spinnerMargin
|
||||||
|
text := p.line(now, room, true)
|
||||||
|
|
||||||
|
// A phase with an exact total ends its text with its percent and bar
|
||||||
|
// when its whole line, without the percent the bar shows, fits beside
|
||||||
|
// them: the bar gives way before any part of the line. The line is
|
||||||
|
// padded to the room the bar leaves it, so the bar stays at the
|
||||||
|
// terminal's right edge however long the line is. A total of 0 leaves
|
||||||
|
// the bar nothing to fill.
|
||||||
|
if p.total > 0 && !p.estimate {
|
||||||
|
line := p.line(now, math.MaxInt, false)
|
||||||
|
if len(line) <= room-barColumns {
|
||||||
|
text = fmt.Sprintf("%-*s%s", room-barColumns, line, p.percentBar())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Before each draw the library erases its line with as many spaces as
|
// Before each draw the library erases its line with as many spaces as
|
||||||
// the widest line it has drawn. A display gets a new one when the
|
// the widest line it has drawn. A display gets a new one when the
|
||||||
@@ -428,24 +442,20 @@ func (p *progress) finish() {
|
|||||||
//
|
//
|
||||||
// [3/4] hash: [1234/5000] 25%, 300 files/s, elapsed 4s, remain 13s
|
// [3/4] hash: [1234/5000] 25%, 300 files/s, elapsed 4s, remain 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 bar, which shows its own. A phase with no
|
||||||
// first draw in drawn, and the time remaining comes from the rate since
|
// total shows the items done instead, or what it waits for. The rate is
|
||||||
// the count started. Once no item has been completed for stallTime, the
|
// that since the first draw in drawn, and the time remaining 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 line with its percent and bar, in place of the percent after its
|
|
||||||
// count. A total of 0 leaves the bar nothing to fill.
|
|
||||||
//
|
//
|
||||||
// The phase and its place, the progress, the elapsed time and the time
|
// The phase and its place, the progress, the elapsed time and the time
|
||||||
// remaining are kept whole. A line wider than room first leaves out the
|
// remaining are kept whole. A line wider than room leaves out the rate,
|
||||||
// rate, the words of a stalled phase, or what the phase waits for. Next
|
// the words of a stalled phase, or what the phase waits for; only if it
|
||||||
// it leaves out the bar. Only if it is still wider is it cut at room.
|
// is still wider is it cut at room. Every part is ASCII, so its length
|
||||||
// Every part but the bar is ASCII, so its length is its width in
|
// is its width in columns. p.mu must be held.
|
||||||
// columns. p.mu must be held.
|
func (p *progress) line(now time.Time, room int, percent bool) string {
|
||||||
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
|
||||||
@@ -460,7 +470,7 @@ func (p *progress) line(now time.Time, room int, bar bool) string {
|
|||||||
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, remain %s", elapsed, p.remain(average))
|
||||||
|
|
||||||
@@ -469,26 +479,6 @@ func (p *progress) line(now time.Time, room int, bar bool) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
|
|||||||
+49
-140
@@ -163,7 +163,7 @@ func TestProgressLine(t *testing.T) {
|
|||||||
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,40 +198,39 @@ 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: 60, 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, remain 40s",
|
||||||
}, {
|
}, {
|
||||||
p: hash, room: 59,
|
p: hash, room: 59, percent: true,
|
||||||
want: "[3/4] hash: [20/100] 20%, elapsed 10s, remain 40s",
|
want: "[3/4] hash: [20/100] 20%, elapsed 10s, remain 40s",
|
||||||
}, {
|
}, {
|
||||||
p: hash, room: 48,
|
p: hash, room: 48, percent: true,
|
||||||
want: "[3/4] hash: [20/100] 20%, elapsed 10s, remain 40",
|
want: "[3/4] hash: [20/100] 20%, elapsed 10s, remain 40",
|
||||||
}, {
|
}, {
|
||||||
// The bar shows the percent.
|
// Before the bar, which shows the percent.
|
||||||
p: hash, room: 74, bar: true,
|
p: hash, room: 60, 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, remain 40s",
|
||||||
" 20% |██ |",
|
|
||||||
}, {
|
}, {
|
||||||
p: load, room: 43,
|
p: load, room: 43, percent: true,
|
||||||
want: "[1/4] load: elapsed 10s, remain unknown",
|
want: "[1/4] load: elapsed 10s, remain unknown",
|
||||||
}, {
|
}, {
|
||||||
p: stalled, room: 68,
|
p: stalled, room: 68, 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",
|
"remain 3m20s",
|
||||||
}, {
|
}, {
|
||||||
p: stalled, room: 67,
|
p: stalled, room: 67, percent: true,
|
||||||
want: "[3/4] hash: [20/100] 20%, elapsed 50s, remain 3m20s",
|
want: "[3/4] hash: [20/100] 20%, elapsed 50s, remain 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -257,14 +256,14 @@ 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, remain 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, remain 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,7 +273,7 @@ func TestSetTotalStartsRate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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, remain 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -596,32 +595,31 @@ 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 time remaining.
|
||||||
// remaining. At a large scan's, 1234567 of 9876543 two and a half hours
|
// At a large scan's, 1234567 of 9876543 two and a half hours in, those
|
||||||
// in, on an 80-column terminal, those four still show whole, as the
|
// four still show whole, as the words give way first where they do not
|
||||||
// words give way first where they do not fit.
|
// 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,19 +651,16 @@ 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, remain 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, remain 17h30m0s",
|
||||||
@@ -743,12 +738,9 @@ func TestTerminalLinesStartWithSpinner(t *testing.T) {
|
|||||||
|
|
||||||
// TestTerminalBarStaysAtRightEdge draws the hash phase's display two
|
// TestTerminalBarStaysAtRightEdge draws the hash phase's display two
|
||||||
// and a half hours in, at two counts whose lines differ in length, on
|
// 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
|
// terminals 110 and 120 columns wide. Its bar must start the same number
|
||||||
// first count and full at the second. It must start the same number of
|
// of columns from the terminal's right edge each time: a line that grows
|
||||||
// columns from the terminal's right edge each time, and end the same
|
// or shrinks, as its time remaining does, never moves the bar.
|
||||||
// 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
|
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
||||||
func TestTerminalBarStaysAtRightEdge(t *testing.T) {
|
func TestTerminalBarStaysAtRightEdge(t *testing.T) {
|
||||||
@@ -759,8 +751,7 @@ func TestTerminalBarStaysAtRightEdge(t *testing.T) {
|
|||||||
|
|
||||||
widths := []int{110, 120}
|
widths := []int{110, 120}
|
||||||
counts := []int64{1234567, 9876543}
|
counts := []int64{1234567, 9876543}
|
||||||
startFromEdge := make([]int, 0, len(widths)*len(counts))
|
fromEdge := make([]int, 0, len(widths)*len(counts))
|
||||||
endFromEdge := make([]int, 0, len(widths)*len(counts))
|
|
||||||
|
|
||||||
for _, width := range widths {
|
for _, width := range widths {
|
||||||
for _, count := range counts {
|
for _, count := range counts {
|
||||||
@@ -771,96 +762,14 @@ func TestTerminalBarStaysAtRightEdge(t *testing.T) {
|
|||||||
t.Fatalf("terminal %d wide shows %q, want a bar", width, shown)
|
t.Fatalf("terminal %d wide shows %q, want a bar", width, shown)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The bar ends with the last "|".
|
fromEdge = append(fromEdge,
|
||||||
throughBar := shown[:strings.LastIndex(shown, "|")+1]
|
|
||||||
|
|
||||||
startFromEdge = append(startFromEdge,
|
|
||||||
width-utf8.RuneCountInString(beforeBar))
|
width-utf8.RuneCountInString(beforeBar))
|
||||||
endFromEdge = append(endFromEdge,
|
|
||||||
width-utf8.RuneCountInString(throughBar))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if slices.Min(startFromEdge) != slices.Max(startFromEdge) {
|
if slices.Min(fromEdge) != slices.Max(fromEdge) {
|
||||||
t.Errorf("bar starts %v columns from the right edge, want the same "+
|
t.Errorf("bar starts %v columns from the right edge, want the same "+
|
||||||
"every time", startFromEdge)
|
"every time", fromEdge)
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -888,7 +797,7 @@ func TestTerminalNarrowedRedrawsInPlace(t *testing.T) {
|
|||||||
progressParams{
|
progressParams{
|
||||||
label: scanPhase("hash"), unit: scanUnit, total: 9876543,
|
label: scanPhase("hash"), unit: scanUnit, total: 9876543,
|
||||||
},
|
},
|
||||||
80,
|
90,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,16 +21,14 @@ const minGroupSize = 2
|
|||||||
|
|
||||||
// scanRec is one file record from the database. The signature (size,
|
// scanRec is one file record from the database. The signature (size,
|
||||||
// head, tail, content) is the duplicate key; mtime is informational
|
// head, tail, content) is the duplicate key; mtime is informational
|
||||||
// only and used by scan for change detection. diskSize is the bytes the
|
// only and used by scan for change detection.
|
||||||
// file takes on disk, which trees adds up.
|
|
||||||
type scanRec struct {
|
type scanRec struct {
|
||||||
size int64
|
size int64
|
||||||
diskSize int64
|
mtime time.Time
|
||||||
mtime time.Time
|
head [sha256.Size]byte
|
||||||
head [sha256.Size]byte
|
tail [sha256.Size]byte
|
||||||
tail [sha256.Size]byte
|
content [sha256.Size]byte
|
||||||
content [sha256.Size]byte
|
path string
|
||||||
path string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// reportPhase names one phase of report with its place among all of
|
// reportPhase names one phase of report with its place among all of
|
||||||
@@ -47,11 +45,11 @@ func reportPhase(name string) string {
|
|||||||
// problem, including a missing database, is fatal.
|
// problem, including a missing database, is fatal.
|
||||||
//
|
//
|
||||||
// The work runs in two phases, each under a progress display of its
|
// The work runs in two phases, each under a progress display of its
|
||||||
// own on stderr: open (open the database) and read (read the number of
|
// own on stderr: open (open the database) and read (count the records,
|
||||||
// records from the totals table, then read the records in signature
|
// then read them in signature order, writing each size's duplicate
|
||||||
// order, writing each size's duplicate groups to stdout once that size
|
// groups to stdout once that size is read; see readReport). Each
|
||||||
// is read; see readReport). Each display ends before the next phase
|
// display ends before the next phase starts, and before an error is
|
||||||
// starts, and before an error is returned to be printed.
|
// returned to be printed.
|
||||||
func runReport(ctx context.Context, stdout io.Writer) error {
|
func runReport(ctx context.Context, stdout io.Writer) error {
|
||||||
dbPath, source, err := databasePath()
|
dbPath, source, err := databasePath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+2
-2
@@ -38,8 +38,8 @@ func awkwardPairRecs() []scanRec {
|
|||||||
h, tl, c := testHash("h"), testHash("t"), testHash("c")
|
h, tl, c := testHash("h"), testHash("t"), testHash("c")
|
||||||
|
|
||||||
return []scanRec{
|
return []scanRec{
|
||||||
{size: 5, diskSize: 4096, head: h, tail: tl, content: c, path: "/d/A/f"},
|
{size: 5, head: h, tail: tl, content: c, path: "/d/A/f"},
|
||||||
{size: 5, diskSize: 4096, head: h, tail: tl, content: c, path: awkwardDir + "/f"},
|
{size: 5, head: h, tail: tl, content: c, path: awkwardDir + "/f"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,26 +52,23 @@ const workQueueDepth = 1024
|
|||||||
// has already printed its line, so run prints nothing more.
|
// has already printed its line, so run prints nothing more.
|
||||||
var errInterrupted = errors.New("scan interrupted")
|
var errInterrupted = errors.New("scan interrupted")
|
||||||
|
|
||||||
// fileRec carries one statted file between the scan phases. diskSize is
|
// fileRec carries one statted file between the scan phases. dev and
|
||||||
// the bytes the file takes on disk. dev and ino identify the underlying
|
// ino identify the underlying inode so hard-linked paths can share
|
||||||
// inode so hard-linked paths can share one read; both are zero when the
|
// one read; both are zero when the platform exposes no inode.
|
||||||
// platform exposes no inode.
|
|
||||||
type fileRec struct {
|
type fileRec struct {
|
||||||
path string
|
path string
|
||||||
size int64
|
size int64
|
||||||
diskSize int64
|
mtime time.Time
|
||||||
mtime time.Time
|
dev uint64
|
||||||
dev uint64
|
ino uint64
|
||||||
ino uint64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// fileMeta is the in-memory index entry for one existing database
|
// fileMeta is the in-memory index entry for one existing database
|
||||||
// record: what the walk compares the file with. Hashes stay on disk; at
|
// record: just enough for change detection. Hashes stay on disk; at
|
||||||
// tens of millions of records they would dominate the scan's memory.
|
// tens of millions of records they would dominate the scan's memory.
|
||||||
type fileMeta struct {
|
type fileMeta struct {
|
||||||
size int64
|
size int64
|
||||||
diskSize int64
|
mtime time.Time
|
||||||
mtime time.Time
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// scanUnit is what every scan phase counts, as its progress display
|
// scanUnit is what every scan phase counts, as its progress display
|
||||||
@@ -342,12 +339,11 @@ type scanStats struct {
|
|||||||
// whatever remains after the walk and hash phases is deleted by the
|
// whatever remains after the walk and hash phases is deleted by the
|
||||||
// update phase.
|
// update phase.
|
||||||
type scanState struct {
|
type scanState struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
existing map[string]fileMeta
|
existing map[string]fileMeta
|
||||||
toHash []fileRec // new and changed files: must be read
|
toHash []fileRec // new and changed files: must be read
|
||||||
diskSizes []fileRec // unchanged files whose disk_size changed
|
batch []scanRec
|
||||||
batch []scanRec
|
st scanStats
|
||||||
st scanStats
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// syncScan synchronizes the database with the filesystem under roots;
|
// syncScan synchronizes the database with the filesystem under roots;
|
||||||
@@ -387,8 +383,7 @@ func (s *scanState) runPhases(ctx context.Context, roots []string,
|
|||||||
) error {
|
) error {
|
||||||
// Types and filesystems are checked before pruning so that an
|
// Types and filesystems are checked before pruning so that an
|
||||||
// operand under a dropped one is still scanned, not dropped as lying
|
// operand under a dropped one is still scanned, not dropped as lying
|
||||||
// under it, and so that an operand inside a .zfs directory gets its
|
// under it.
|
||||||
// warning even when it lies under another operand.
|
|
||||||
roots = pruneRoots(s.walkableRoots(roots, rules), oneFS, rules,
|
roots = pruneRoots(s.walkableRoots(roots, rules), oneFS, rules,
|
||||||
deviceOfPath)
|
deviceOfPath)
|
||||||
|
|
||||||
@@ -416,15 +411,15 @@ func (s *scanState) runPhases(ctx context.Context, roots []string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// walkableRoots returns the operands the walk can start from: regular
|
// walkableRoots returns the operands the walk can start from: regular
|
||||||
// files, and directories not named .zfs, none of them inside a
|
// files, and directories not named .zfs. Every other operand is warned
|
||||||
// directory named .zfs. Every other operand is warned about, counted as
|
// about, counted as skipped, and dropped. A dropped operand is no
|
||||||
// skipped, and dropped. A dropped operand is no longer a root, so the
|
// longer a root, so the records stored beneath it are treated as if it
|
||||||
// records stored beneath it are treated as if it had not been given:
|
// had not been given: those under no other root count as outside the
|
||||||
// those under no other root count as outside the roots and are not
|
// roots and are not deleted as unverified. An operand on a filesystem
|
||||||
// deleted as unverified. An operand on a filesystem in rules that is not
|
// in rules that is not persistent is dropped the same way, with a line
|
||||||
// persistent is dropped the same way, with a line saying so that is not
|
// saying so that is not a warning, and is not counted as skipped. An
|
||||||
// a warning, and is not counted as skipped. An operand that fails lstat
|
// operand that fails lstat here is kept, and the walk warns about it
|
||||||
// here is kept, and the walk warns about it unless it no longer exists.
|
// unless it no longer exists.
|
||||||
func (s *scanState) walkableRoots(roots []string, rules walkRules) []string {
|
func (s *scanState) walkableRoots(roots []string, rules walkRules) []string {
|
||||||
kept := make([]string, 0, len(roots))
|
kept := make([]string, 0, len(roots))
|
||||||
|
|
||||||
@@ -459,8 +454,8 @@ func (s *scanState) walkableRoots(roots []string, rules walkRules) []string {
|
|||||||
// included, so its display counts against the number of records.
|
// included, so its display counts against the number of records.
|
||||||
func (s *scanState) loadIndex(ctx context.Context, roots []string) error {
|
func (s *scanState) loadIndex(ctx context.Context, roots []string) error {
|
||||||
// Indexing tens of millions of records takes real time; without a
|
// Indexing tens of millions of records takes real time; without a
|
||||||
// display the scan looks hung before the walk begins. The number of
|
// display the scan looks hung before the walk begins. Counting them
|
||||||
// records, read first, gives the display its total.
|
// first gives the display its total.
|
||||||
prog := newProgress(progressParams{
|
prog := newProgress(progressParams{
|
||||||
label: scanPhase("load"), unit: scanUnit, total: -1,
|
label: scanPhase("load"), unit: scanUnit, total: -1,
|
||||||
waitingFor: countingRecords, stalledOn: "db",
|
waitingFor: countingRecords, stalledOn: "db",
|
||||||
@@ -476,24 +471,24 @@ func (s *scanState) loadIndex(ctx context.Context, roots []string) error {
|
|||||||
|
|
||||||
s.existing = make(map[string]fileMeta)
|
s.existing = make(map[string]fileMeta)
|
||||||
|
|
||||||
return loadFileMeta(ctx, s.db, func(path string, m fileMeta) {
|
return loadFileMeta(ctx, s.db,
|
||||||
prog.increment()
|
func(path string, size int64, mtime time.Time) {
|
||||||
|
prog.increment()
|
||||||
|
|
||||||
if underAnyRoot(path, roots) {
|
if underAnyRoot(path, roots) {
|
||||||
s.existing[path] = m
|
s.existing[path] = fileMeta{size: size, mtime: mtime}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// walkPhase drains the walk and queues every new or changed file for
|
// walkPhase drains the walk and queues every new or changed file for
|
||||||
// the hash phase. An unchanged file is not read, and its record counts
|
// the hash phase. An unchanged file needs nothing more, and its record
|
||||||
// as verified. When its disk_size differs from the record's, it is
|
// counts as verified. A record at or below a path the walk left out,
|
||||||
// queued for the update phase, which writes the new disk_size. A record
|
// because -x stopped at it, it is a default exclude, or it is not on a
|
||||||
// at or below a path the walk left out, because -x stopped at it, it is
|
// persistent filesystem, counts as outside the roots: it is not
|
||||||
// a default exclude, or it is not on a persistent filesystem, counts as
|
// deleted. That does not hold below a root at or inside that path,
|
||||||
// outside the roots: it is not deleted. That does not hold below a root
|
// since that root's own walk verifies the records below it; nor is a
|
||||||
// at or inside that path, since that root's own walk verifies the
|
// line printed for such a root.
|
||||||
// records below it; nor is a line printed for such a root.
|
|
||||||
func (s *scanState) walkPhase(events <-chan walkEvent, roots []string) {
|
func (s *scanState) walkPhase(events <-chan walkEvent, roots []string) {
|
||||||
// The records the last scan left under the roots estimate how many
|
// The records the last scan left under the roots estimate how many
|
||||||
// files the walk will find. A first scan has none, and no estimate.
|
// files the walk will find. A first scan has none, and no estimate.
|
||||||
@@ -551,13 +546,6 @@ func (s *scanState) walkPhase(events <-chan walkEvent, roots []string) {
|
|||||||
delete(s.existing, ev.rec.path)
|
delete(s.existing, ev.rec.path)
|
||||||
|
|
||||||
s.st.unchanged++
|
s.st.unchanged++
|
||||||
|
|
||||||
// The space a file takes on disk can change while its size and
|
|
||||||
// mtime stay the same. ZFS, for one, counts a new file's blocks
|
|
||||||
// only seconds after the write.
|
|
||||||
if old.diskSize != ev.rec.diskSize {
|
|
||||||
s.diskSizes = append(s.diskSizes, ev.rec)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
prog.finish()
|
prog.finish()
|
||||||
@@ -706,13 +694,12 @@ func (s *scanState) recordRun(ctx context.Context, r hashResult) error {
|
|||||||
s.resolve(rec.path)
|
s.resolve(rec.path)
|
||||||
|
|
||||||
s.batch = append(s.batch, scanRec{
|
s.batch = append(s.batch, scanRec{
|
||||||
size: rec.size,
|
size: rec.size,
|
||||||
diskSize: rec.diskSize,
|
mtime: rec.mtime,
|
||||||
mtime: rec.mtime,
|
head: r.head,
|
||||||
head: r.head,
|
tail: r.tail,
|
||||||
tail: r.tail,
|
content: r.content,
|
||||||
content: r.content,
|
path: rec.path,
|
||||||
path: rec.path,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -728,7 +715,7 @@ func (s *scanState) commitFullBatch(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
err := applyBatch(ctx, s.db, s.batch, nil, nil, nil)
|
err := applyBatch(ctx, s.db, s.batch, nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -739,10 +726,9 @@ func (s *scanState) commitFullBatch(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// updatePhase writes the scan's tail under one progress display: the
|
// updatePhase writes the scan's tail under one progress display: the
|
||||||
// final partial batch of hashed records, the new disk_size of every
|
// final partial batch of hashed records, and deletions for every record
|
||||||
// unchanged file whose disk_size changed, and deletions for every
|
// the scan did not verify (vanished files, plus paths that failed to
|
||||||
// record the scan did not verify (vanished files, plus paths that
|
// stat or hash).
|
||||||
// failed to stat or hash).
|
|
||||||
func (s *scanState) updatePhase(ctx context.Context) error {
|
func (s *scanState) updatePhase(ctx context.Context) error {
|
||||||
deletes := make([]string, 0, len(s.existing))
|
deletes := make([]string, 0, len(s.existing))
|
||||||
for path := range s.existing {
|
for path := range s.existing {
|
||||||
@@ -754,7 +740,7 @@ func (s *scanState) updatePhase(ctx context.Context) error {
|
|||||||
|
|
||||||
s.st.removed = len(deletes)
|
s.st.removed = len(deletes)
|
||||||
|
|
||||||
total := len(s.batch) + len(s.diskSizes) + len(deletes)
|
total := len(s.batch) + len(deletes)
|
||||||
prog := newProgress(progressParams{
|
prog := newProgress(progressParams{
|
||||||
label: scanPhase("update"), unit: scanUnit, total: int64(total),
|
label: scanPhase("update"), unit: scanUnit, total: int64(total),
|
||||||
stalledOn: "db",
|
stalledOn: "db",
|
||||||
@@ -769,11 +755,6 @@ func (s *scanState) updatePhase(ctx context.Context) error {
|
|||||||
|
|
||||||
s.batch = nil
|
s.batch = nil
|
||||||
|
|
||||||
err = applyDiskSizes(ctx, s.db, s.diskSizes, prog)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return applyChanges(ctx, s.db, nil, deletes, prog)
|
return applyChanges(ctx, s.db, nil, deletes, prog)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -893,24 +874,9 @@ func sendEvent(ctx context.Context, events chan<- walkEvent,
|
|||||||
|
|
||||||
// operandWarning returns the one-line warning for an operand the walk
|
// operandWarning returns the one-line warning for an operand the walk
|
||||||
// does not start from, naming the path and what it is, or "" for one it
|
// does not start from, naming the path and what it is, or "" for one it
|
||||||
// does: a regular file, or a directory not named .zfs, that is not
|
// does: a regular file, or a directory not named .zfs. Symlinks are
|
||||||
// inside a directory named .zfs, neither as given nor with its symlinks
|
// never followed, including as operands.
|
||||||
// resolved. Symlinks are never followed, including as operands.
|
|
||||||
func operandWarning(root string, fi fs.FileInfo) string {
|
func operandWarning(root string, fi fs.FileInfo) string {
|
||||||
// The walk never enters a .zfs directory, so it starts from nothing
|
|
||||||
// inside one either, not even through a symlink. An operand whose
|
|
||||||
// symlinks cannot be resolved is checked as given.
|
|
||||||
resolved, err := filepath.EvalSymlinks(root)
|
|
||||||
if err != nil {
|
|
||||||
resolved = root
|
|
||||||
}
|
|
||||||
|
|
||||||
if slices.Contains(strings.Split(filepath.Dir(root), "/"), ".zfs") ||
|
|
||||||
slices.Contains(strings.Split(filepath.Dir(resolved), "/"), ".zfs") {
|
|
||||||
return fmt.Sprintf("walk %s: skipping operand inside a .zfs directory",
|
|
||||||
root)
|
|
||||||
}
|
|
||||||
|
|
||||||
var kind string
|
var kind string
|
||||||
|
|
||||||
switch mode := fi.Mode(); {
|
switch mode := fi.Mode(); {
|
||||||
@@ -979,12 +945,11 @@ func seedRoot(ctx context.Context, root string, rules walkRules,
|
|||||||
|
|
||||||
dev, ino := inodeOfInfo(fi)
|
dev, ino := inodeOfInfo(fi)
|
||||||
rec := fileRec{
|
rec := fileRec{
|
||||||
path: root,
|
path: root,
|
||||||
size: fi.Size(),
|
size: fi.Size(),
|
||||||
diskSize: diskSizeOfInfo(fi),
|
mtime: fi.ModTime(),
|
||||||
mtime: fi.ModTime(),
|
dev: dev,
|
||||||
dev: dev,
|
ino: ino,
|
||||||
ino: ino,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isDatabaseFile(rec, rules) {
|
if !isDatabaseFile(rec, rules) {
|
||||||
@@ -1164,12 +1129,11 @@ func emitFile(ctx context.Context, p string, e fs.DirEntry,
|
|||||||
|
|
||||||
dev, ino := inodeOfInfo(info)
|
dev, ino := inodeOfInfo(info)
|
||||||
rec := fileRec{
|
rec := fileRec{
|
||||||
path: p,
|
path: p,
|
||||||
size: info.Size(),
|
size: info.Size(),
|
||||||
diskSize: diskSizeOfInfo(info),
|
mtime: info.ModTime(),
|
||||||
mtime: info.ModTime(),
|
dev: dev,
|
||||||
dev: dev,
|
ino: ino,
|
||||||
ino: ino,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isDatabaseFile(rec, rules) {
|
if !isDatabaseFile(rec, rules) {
|
||||||
@@ -1261,23 +1225,6 @@ func inodeOfInfo(fi fs.FileInfo) (uint64, uint64) {
|
|||||||
return statDev(st), st.Ino
|
return statDev(st), st.Ino
|
||||||
}
|
}
|
||||||
|
|
||||||
// statBlockSize is the unit of st_blocks in an lstat result on Linux and
|
|
||||||
// macOS, whatever the filesystem's own block size.
|
|
||||||
const statBlockSize = 512
|
|
||||||
|
|
||||||
// diskSizeOfInfo returns the bytes a file takes on disk: its allocated
|
|
||||||
// blocks, st_blocks × 512. On a compressed or sparse file it is less
|
|
||||||
// than the file's size. It is 0 when the platform exposes no block
|
|
||||||
// count.
|
|
||||||
func diskSizeOfInfo(fi fs.FileInfo) int64 {
|
|
||||||
st, ok := fi.Sys().(*syscall.Stat_t)
|
|
||||||
if !ok {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return st.Blocks * statBlockSize
|
|
||||||
}
|
|
||||||
|
|
||||||
// hashResult carries the hashes computed for one inode run (or the
|
// hashResult carries the hashes computed for one inode run (or the
|
||||||
// error that prevented computing them) from the pool's workers to the
|
// error that prevented computing them) from the pool's workers to the
|
||||||
// hash phase. Its run leaves out the paths found removed.
|
// hash phase. Its run leaves out the paths found removed.
|
||||||
|
|||||||
+3
-131
@@ -42,24 +42,6 @@ func writeFile(t *testing.T, dir, name string, data []byte) string {
|
|||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
// diskSizeOf returns the bytes the file at path takes on disk, from its
|
|
||||||
// own lstat: st_blocks counts 512-byte units. ZFS counts a new file's
|
|
||||||
// blocks only seconds after the write, and fsync does not hasten that.
|
|
||||||
// A test therefore calls unix.Sync, which waits for it, before the scan
|
|
||||||
// whose disk_size it compares with this.
|
|
||||||
func diskSizeOf(t *testing.T, path string) int64 {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var st syscall.Stat_t
|
|
||||||
|
|
||||||
err := syscall.Lstat(path, &st)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return st.Blocks * 512
|
|
||||||
}
|
|
||||||
|
|
||||||
// pattern returns n bytes of deterministic content seeded by tag.
|
// pattern returns n bytes of deterministic content seeded by tag.
|
||||||
func pattern(tag byte, n int) []byte {
|
func pattern(tag byte, n int) []byte {
|
||||||
data := make([]byte, n)
|
data := make([]byte, n)
|
||||||
@@ -1246,9 +1228,9 @@ func TestScanPipeline(t *testing.T) {
|
|||||||
// TestScanProgressNamesEachPhase checks the plain progress lines of a
|
// TestScanProgressNamesEachPhase checks the plain progress lines of a
|
||||||
// first scan, and of a rescan of one operand holding two of the
|
// first scan, and of a rescan of one operand holding two of the
|
||||||
// database's records. Each phase's first line names it with its place
|
// database's records. Each phase's first line names it with its place
|
||||||
// among the four: load gets the number of records before reading them,
|
// among the four: load counts the records before reading them, and the
|
||||||
// and the walk estimates its total from the records under its operand,
|
// walk estimates its total from the records under its operand, or says
|
||||||
// or says its time remaining is unknown when there are none.
|
// its time remaining is unknown when there are none.
|
||||||
//
|
//
|
||||||
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
||||||
func TestScanProgressNamesEachPhase(t *testing.T) {
|
func TestScanProgressNamesEachPhase(t *testing.T) {
|
||||||
@@ -1434,116 +1416,6 @@ func TestScanStoresRawHashes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestScanStoresDiskSize scans a sparse file, which takes less space on
|
|
||||||
// disk than its length, once through its directory and once as the
|
|
||||||
// operand itself. Each scan stores the disk_size of the file's lstat.
|
|
||||||
func TestScanStoresDiskSize(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const length = 1024 * 1024
|
|
||||||
|
|
||||||
p := sparseFile(t, t.TempDir(), "sparse", length)
|
|
||||||
pokeAt(t, p, 0, []byte("x"))
|
|
||||||
|
|
||||||
unix.Sync() // see diskSizeOf
|
|
||||||
|
|
||||||
want := diskSizeOf(t, p)
|
|
||||||
if want == 0 || want >= length {
|
|
||||||
t.Fatalf("the file takes %d bytes on disk, want more than none "+
|
|
||||||
"and fewer than its length %d", want, length)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, operand := range []string{filepath.Dir(p), p} {
|
|
||||||
db := openTestDB(t)
|
|
||||||
syncTree(t, db, operand)
|
|
||||||
|
|
||||||
got := recordByPath(t, dbRecords(t, db), p).diskSize
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("scan of %s: disk_size = %d, want %d",
|
|
||||||
operand, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSyncScanRewriteUpdatesDiskSize writes into the hole of a scanned
|
|
||||||
// sparse file and moves its mtime on. The rescan hashes the file again
|
|
||||||
// and rewrites its record with the new disk_size.
|
|
||||||
func TestSyncScanRewriteUpdatesDiskSize(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
db := openTestDB(t)
|
|
||||||
p := sparseFile(t, dir, "sparse", 1024*1024)
|
|
||||||
pokeAt(t, p, 0, []byte("x"))
|
|
||||||
|
|
||||||
syncTree(t, db, dir)
|
|
||||||
|
|
||||||
before := recordByPath(t, dbRecords(t, db), p).diskSize
|
|
||||||
|
|
||||||
pokeAt(t, p, 512*1024, pattern(7, 64*1024))
|
|
||||||
|
|
||||||
// The write may leave the mtime the scan recorded; a later one makes
|
|
||||||
// the rescan read the file again.
|
|
||||||
later := time.Now().Add(time.Hour)
|
|
||||||
|
|
||||||
err := os.Chtimes(p, later, later)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
unix.Sync() // see diskSizeOf
|
|
||||||
|
|
||||||
want := diskSizeOf(t, p)
|
|
||||||
if want == before {
|
|
||||||
t.Fatalf("the file still takes %d bytes on disk after the write "+
|
|
||||||
"into its hole", want)
|
|
||||||
}
|
|
||||||
|
|
||||||
st := syncTree(t, db, dir)
|
|
||||||
if st != (scanStats{walked: 1, updated: 1}) {
|
|
||||||
t.Fatalf("rescan stats = %+v, want 1 updated", st)
|
|
||||||
}
|
|
||||||
|
|
||||||
got := recordByPath(t, dbRecords(t, db), p).diskSize
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("disk_size = %d, want %d", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSyncScanUnchangedUpdatesDiskSize gives a file's record a
|
|
||||||
// disk_size the file does not have, as when ZFS had not yet counted the
|
|
||||||
// file's blocks at the last scan. The rescan writes the file's own
|
|
||||||
// disk_size into the record without reading the file again.
|
|
||||||
func TestSyncScanUnchangedUpdatesDiskSize(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
db := openTestDB(t)
|
|
||||||
p := writeFile(t, dir, "a.bin", pattern(1, 500))
|
|
||||||
|
|
||||||
syncTree(t, db, dir)
|
|
||||||
|
|
||||||
// lstat counts 512-byte units, so no file takes 1 byte on disk.
|
|
||||||
_, err := db.ExecContext(t.Context(),
|
|
||||||
"UPDATE files SET disk_size = 1 WHERE path = ?", []byte(p))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
unix.Sync() // see diskSizeOf
|
|
||||||
|
|
||||||
// A file read again would count as updated.
|
|
||||||
st := syncTree(t, db, dir)
|
|
||||||
if st != (scanStats{walked: 1, unchanged: 1}) {
|
|
||||||
t.Fatalf("rescan stats = %+v, want 1 unchanged", st)
|
|
||||||
}
|
|
||||||
|
|
||||||
got := recordByPath(t, dbRecords(t, db), p).diskSize
|
|
||||||
if want := diskSizeOf(t, p); got != want {
|
|
||||||
t.Errorf("disk_size = %d, want %d", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSyncScanSameSecondRewrite rewrites a file in place at the same
|
// TestSyncScanSameSecondRewrite rewrites a file in place at the same
|
||||||
// size with an mtime later in the same second as the recorded one: the
|
// size with an mtime later in the same second as the recorded one: the
|
||||||
// next scan must notice the change and re-hash the file. The operand is
|
// next scan must notice the change and re-hash the file. The operand is
|
||||||
|
|||||||
@@ -22,10 +22,6 @@ type treeNode struct {
|
|||||||
digest [sha256.Size]byte
|
digest [sha256.Size]byte
|
||||||
fileCount int64
|
fileCount int64
|
||||||
totalSize int64
|
totalSize int64
|
||||||
// diskSize is the bytes the directory's files take on disk. Two
|
|
||||||
// copies of one tree have the same totalSize, but their diskSize
|
|
||||||
// can differ.
|
|
||||||
diskSize int64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// treesPhase names one phase of trees with its place among all of them,
|
// treesPhase names one phase of trees with its place among all of them,
|
||||||
@@ -44,11 +40,11 @@ func treesPhase(name string) string {
|
|||||||
// database problem, including a missing database, is fatal.
|
// database problem, including a missing database, is fatal.
|
||||||
//
|
//
|
||||||
// The work runs in four phases, each under a progress display of its
|
// The work runs in four phases, each under a progress display of its
|
||||||
// own on stderr: open (open the database), read (read the number of
|
// own on stderr: open (open the database), read (count the records,
|
||||||
// records from the totals table, then read the records into the
|
// then read them into the hierarchy), group (group the directories by
|
||||||
// hierarchy), group (group the directories by digest) and write (write
|
// digest) and write (write the groups to stdout). Each display ends
|
||||||
// the groups to stdout). Each display ends before the next phase
|
// before the next phase starts, and before an error is returned to be
|
||||||
// starts, and before an error is returned to be printed.
|
// printed.
|
||||||
func runTrees(ctx context.Context, stdout io.Writer) error {
|
func runTrees(ctx context.Context, stdout io.Writer) error {
|
||||||
dbPath, source, err := databasePath()
|
dbPath, source, err := databasePath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -143,16 +139,15 @@ func readTree(ctx context.Context, db *sql.DB,
|
|||||||
|
|
||||||
// writeTreeGroups writes dupes to stdout as TSV, a header line and then
|
// writeTreeGroups writes dupes to stdout as TSV, a header line and then
|
||||||
// a row for every member of each group but its first, counting each
|
// a row for every member of each group but its first, counting each
|
||||||
// group on prog once its rows are written. The disk_size column is the
|
// group on prog once its rows are written. stdout may be the terminal
|
||||||
// dupe's own. stdout may be the terminal prog is drawn on, so the lines
|
// prog is drawn on, so the lines go through prog's writer. It returns
|
||||||
// go through prog's writer. It returns the number of rows (dupe trees)
|
// the number of rows (dupe trees) and the bytes they could reclaim.
|
||||||
// and the bytes they could reclaim.
|
|
||||||
func writeTreeGroups(stdout io.Writer, dupes [][]*treeNode,
|
func writeTreeGroups(stdout io.Writer, dupes [][]*treeNode,
|
||||||
prog *progress,
|
prog *progress,
|
||||||
) (int, int64, error) {
|
) (int, int64, error) {
|
||||||
out := prog.writer(stdout)
|
out := prog.writer(stdout)
|
||||||
|
|
||||||
_, err := fmt.Fprintln(out, "first\tdupe\tfiles\tsize\tdisk_size")
|
_, err := fmt.Fprintln(out, "first\tdupe\tfiles\tsize")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, fmt.Errorf("write stdout: %w", err)
|
return 0, 0, fmt.Errorf("write stdout: %w", err)
|
||||||
}
|
}
|
||||||
@@ -164,9 +159,9 @@ func writeTreeGroups(stdout io.Writer, dupes [][]*treeNode,
|
|||||||
for _, g := range dupes {
|
for _, g := range dupes {
|
||||||
first := g[0]
|
first := g[0]
|
||||||
for _, n := range g[1:] {
|
for _, n := range g[1:] {
|
||||||
_, err = fmt.Fprintf(out, "%s\t%s\t%d\t%d\t%d\n",
|
_, err = fmt.Fprintf(out, "%s\t%s\t%d\t%d\n",
|
||||||
escapePath(first.path), escapePath(n.path),
|
escapePath(first.path), escapePath(n.path),
|
||||||
first.fileCount, first.totalSize, n.diskSize)
|
first.fileCount, first.totalSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, fmt.Errorf("write stdout: %w", err)
|
return 0, 0, fmt.Errorf("write stdout: %w", err)
|
||||||
}
|
}
|
||||||
@@ -237,7 +232,6 @@ func (b *treeBuilder) add(r scanRec) {
|
|||||||
dir.entries = append(dir.entries, fileEntry(name, r))
|
dir.entries = append(dir.entries, fileEntry(name, r))
|
||||||
dir.fileCount++
|
dir.fileCount++
|
||||||
dir.totalSize += r.size
|
dir.totalSize += r.size
|
||||||
dir.diskSize += r.diskSize
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// openDir starts the directory called name inside the innermost open
|
// openDir starts the directory called name inside the innermost open
|
||||||
@@ -276,7 +270,6 @@ func (b *treeBuilder) closeTo(n int) {
|
|||||||
"d\x00"+name+"\x00"+string(dir.digest[:]))
|
"d\x00"+name+"\x00"+string(dir.digest[:]))
|
||||||
dir.parent.fileCount += dir.fileCount
|
dir.parent.fileCount += dir.fileCount
|
||||||
dir.parent.totalSize += dir.totalSize
|
dir.parent.totalSize += dir.totalSize
|
||||||
dir.parent.diskSize += dir.diskSize
|
|
||||||
b.dirs = append(b.dirs, dir)
|
b.dirs = append(b.dirs, dir)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-40
@@ -17,24 +17,18 @@ const treesOpenLine = "[1/4] open: opening the database, elapsed 0s, " +
|
|||||||
|
|
||||||
// smokeTreeRecs mirrors the README smoke-test tree layout: /d/t1 and
|
// smokeTreeRecs mirrors the README smoke-test tree layout: /d/t1 and
|
||||||
// /d/t2 are identical, /d/t3 differs from them only by one filename.
|
// /d/t2 are identical, /d/t3 differs from them only by one filename.
|
||||||
// Each file takes one 4 KiB block on disk.
|
|
||||||
func smokeTreeRecs() []scanRec {
|
func smokeTreeRecs() []scanRec {
|
||||||
f1Head, f1Tail, f1Content := testHash("f1h"), testHash("f1t"), testHash("f1c")
|
f1Head, f1Tail, f1Content := testHash("f1h"), testHash("f1t"), testHash("f1c")
|
||||||
f2Head, f2Tail, f2Content := testHash("f2h"), testHash("f2t"), testHash("f2c")
|
f2Head, f2Tail, f2Content := testHash("f2h"), testHash("f2t"), testHash("f2c")
|
||||||
|
|
||||||
return []scanRec{
|
return []scanRec{
|
||||||
{size: 3000, diskSize: 4096, head: f1Head, tail: f1Tail,
|
{size: 3000, head: f1Head, tail: f1Tail, content: f1Content, path: "/d/t1/f1"},
|
||||||
content: f1Content, path: "/d/t1/f1"},
|
{size: 100, head: f2Head, tail: f2Tail, content: f2Content, path: "/d/t1/sub/f2"},
|
||||||
{size: 100, diskSize: 4096, head: f2Head, tail: f2Tail,
|
{size: 3000, head: f1Head, tail: f1Tail, content: f1Content, path: "/d/t2/f1"},
|
||||||
content: f2Content, path: "/d/t1/sub/f2"},
|
{size: 100, head: f2Head, tail: f2Tail, content: f2Content, path: "/d/t2/sub/f2"},
|
||||||
{size: 3000, diskSize: 4096, head: f1Head, tail: f1Tail,
|
{size: 3000, head: f1Head, tail: f1Tail, content: f1Content, path: "/d/t3/f1"},
|
||||||
content: f1Content, path: "/d/t2/f1"},
|
{size: 100, head: f2Head, tail: f2Tail, content: f2Content,
|
||||||
{size: 100, diskSize: 4096, head: f2Head, tail: f2Tail,
|
path: "/d/t3/sub/f2renamed"},
|
||||||
content: f2Content, path: "/d/t2/sub/f2"},
|
|
||||||
{size: 3000, diskSize: 4096, head: f1Head, tail: f1Tail,
|
|
||||||
content: f1Content, path: "/d/t3/f1"},
|
|
||||||
{size: 100, diskSize: 4096, head: f2Head, tail: f2Tail,
|
|
||||||
content: f2Content, path: "/d/t3/sub/f2renamed"},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,33 +198,13 @@ func TestRunTreesEscapesPaths(t *testing.T) {
|
|||||||
code, exitOK, stderr.String())
|
code, exitOK, stderr.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
want := "first\tdupe\tfiles\tsize\tdisk_size\n" +
|
want := "first\tdupe\tfiles\tsize\n" +
|
||||||
`/d/\tone\ntwo\rthree\\four` + "\t/d/A\t1\t5\t4096\n"
|
`/d/\tone\ntwo\rthree\\four` + "\t/d/A\t1\t5\n"
|
||||||
if got := stdout.String(); got != want {
|
if got := stdout.String(); got != want {
|
||||||
t.Errorf("stdout = %q, want %q", got, want)
|
t.Errorf("stdout = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRunTreesDiskSize runs trees on two copies of a tree whose files
|
|
||||||
// take different space on disk. The row's disk_size is the sum over the
|
|
||||||
// dupe copy's files, those in its subdirectory included.
|
|
||||||
func TestRunTreesDiskSize(t *testing.T) {
|
|
||||||
f, g := testHash("f"), testHash("g")
|
|
||||||
recs := []scanRec{
|
|
||||||
{size: 10, diskSize: 4096, head: f, tail: f, content: f, path: "/a/f"},
|
|
||||||
{size: 20, diskSize: 4096, head: g, tail: g, content: g, path: "/a/sub/g"},
|
|
||||||
{size: 10, diskSize: 512, head: f, tail: f, content: f, path: "/b/f"},
|
|
||||||
{size: 20, diskSize: 1024, head: g, tail: g, content: g, path: "/b/sub/g"},
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Setenv(databaseEnv, seedDatabase(t, recs))
|
|
||||||
|
|
||||||
want := "first\tdupe\tfiles\tsize\tdisk_size\n/a\t/b\t2\t30\t1536\n"
|
|
||||||
if got := runStdout(t, cmdTrees); got != want {
|
|
||||||
t.Errorf("stdout = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRunTreesProgressNamesEachPhase runs trees on the smoke-test
|
// TestRunTreesProgressNamesEachPhase runs trees on the smoke-test
|
||||||
// records with stderr not a terminal, and checks every line it prints
|
// records with stderr not a terminal, and checks every line it prints
|
||||||
// there. After the line naming the database, each phase prints a line
|
// there. After the line naming the database, each phase prints a line
|
||||||
@@ -252,8 +226,7 @@ func TestRunTreesProgressNamesEachPhase(t *testing.T) {
|
|||||||
code, exitOK, stderr())
|
code, exitOK, stderr())
|
||||||
}
|
}
|
||||||
|
|
||||||
report := "first\tdupe\tfiles\tsize\tdisk_size\n" +
|
report := "first\tdupe\tfiles\tsize\n/d/t1\t/d/t2\t2\t3100\n"
|
||||||
"/d/t1\t/d/t2\t2\t3100\t8192\n"
|
|
||||||
if got := stdout.String(); got != report {
|
if got := stdout.String(); got != report {
|
||||||
t.Errorf("stdout = %q, want %q", got, report)
|
t.Errorf("stdout = %q, want %q", got, report)
|
||||||
}
|
}
|
||||||
@@ -318,9 +291,7 @@ func TestWriteTreeGroupsOnTerminal(t *testing.T) {
|
|||||||
shown = append(shown, line[strings.LastIndex(line, "\r")+1:])
|
shown = append(shown, line[strings.LastIndex(line, "\r")+1:])
|
||||||
}
|
}
|
||||||
|
|
||||||
want := []string{
|
want := []string{"first\tdupe\tfiles\tsize", "/d/t1\t/d/t2\t2\t3100"}
|
||||||
"first\tdupe\tfiles\tsize\tdisk_size", "/d/t1\t/d/t2\t2\t3100\t8192",
|
|
||||||
}
|
|
||||||
if len(shown) < len(want) || !slices.Equal(shown[:len(want)], want) {
|
if len(shown) < len(want) || !slices.Equal(shown[:len(want)], want) {
|
||||||
t.Errorf("terminal shows lines %q, want %q first", shown, want)
|
t.Errorf("terminal shows lines %q, want %q first", shown, want)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user