Repository: kopia/kopia
Pull request #4645 (2025-06-02 22:09:36)

feat(snapshots): Add periodic JSON progress output to snapshot verify
- Add read stats to snapshot verifier output.
- Add periodic JSON progress output.
- Refactor the use of directory summary.
- Use stats mutex for all stats.
- Add processedBytes to the snapshot verify output.
- Output more frequently, when bytes processed changes.

- Change depends on #4644; full diff includes both changes until that PR merges. Please see commit c59be0fe615cda71dab9e0bbb53bcf9f94b47467 for changes introduced by this PR.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/kopia/kopia/pull/4645?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) Report
Attention: Patch coverage is `88.60759%` with `18 lines` in your changes missing coverage. Please review.
> Project coverage is 76.46%. Comparing base [(`cb455c6`)](https://app.codecov.io/gh/kopia/kopia/commit/cb455c699a04e54cb47e25a676d7d65063ce883e?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) to head [(`c59be0f`)](https://app.codecov.io/gh/kopia/kopia/commit/c59be0fe615cda71dab9e0bbb53bcf9f94b47467?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia).
> Report is 558 commits behind head on master.

| [Files with missing lines](https://app.codecov.io/gh/kopia/kopia/pull/4645?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) | Patch % | Lines |
|---|---|---|
| [cli/command\_snapshot\_verify.go](https://app.codecov.io/gh/kopia/kopia/pull/4645?src=pr&el=tree&filepath=cli%2Fcommand_snapshot_verify.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia#diff-Y2xpL2NvbW1hbmRfc25hcHNob3RfdmVyaWZ5Lmdv) | 82.45% | [7 Missing and 3 partials :warning: ](https://app.codecov.io/gh/kopia/kopia/pull/4645?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) |
| [snapshot/snapshotfs/snapshot\_verifier.go](https://app.codecov.io/gh/kopia/kopia/pull/4645?src=pr&el=tree&filepath=snapshot%2Fsnapshotfs%2Fsnapshot_verifier.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia#diff-c25hcHNob3Qvc25hcHNob3Rmcy9zbmFwc2hvdF92ZXJpZmllci5nbw==) | 91.57% | [6 Missing and 2 partials :warning: ](https://app.codecov.io/gh/kopia/kopia/pull/4645?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #4645      +/-   ##
==========================================
+ Coverage   75.86%   76.46%   +0.60%     
==========================================
  Files         470      529      +59     
  Lines       37301    40296    +2995     
==========================================
+ Hits        28299    30814    +2515     
- Misses       7071     7463     +392     
- Partials     1931     2019      +88     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/kopia/kopia/pull/4645?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>

Patch:
diff --git a/cli/command_snapshot_fix_invalid_files.go b/cli/command_snapshot_fix_invalid_files.go
index 27514f0e..9c81ce35 100644
--- a/cli/command_snapshot_fix_invalid_files.go
+++ b/cli/command_snapshot_fix_invalid_files.go
@@ -34,7 +34,7 @@ func (c *commandSnapshotFixInvalidFiles) setup(svc appServices, parent commandPa
 
 func (c *commandSnapshotFixInvalidFiles) rewriteEntry(ctx context.Context, pathFromRoot string, ent *snapshot.DirEntry) (*snapshot.DirEntry, error) {
 	if ent.Type != snapshot.EntryTypeDirectory {
-		if err := c.verifier.VerifyFile(ctx, ent.ObjectID, pathFromRoot); err != nil {
+		if err := c.verifier.VerifyFile(ctx, ent.ObjectID, pathFromRoot, ent.FileSize); err != nil {
 			log(ctx).Warnf("removing invalid file %v due to: %v", pathFromRoot, err)
 
 			return c.failedFileCallback(ctx, pathFromRoot, ent, err)
diff --git a/cli/command_snapshot_verify.go b/cli/command_snapshot_verify.go
index 2fe59aab..b8b3f382 100644
--- a/cli/command_snapshot_verify.go
+++ b/cli/command_snapshot_verify.go
@@ -7,6 +7,7 @@
 
 	"github.com/pkg/errors"
 
+	"github.com/kopia/kopia/fs"
 	"github.com/kopia/kopia/repo"
 	"github.com/kopia/kopia/repo/blob"
 	"github.com/kopia/kopia/repo/manifest"
@@ -66,6 +67,7 @@ func (c *commandSnapshotVerify) run(ctx context.Context, rep repo.Repository) er
 		FileQueueLength:    c.fileQueueLength,
 		Parallelism:        c.fileParallelism,
 		MaxErrors:          c.verifyCommandErrorThreshold,
+		JSONStats:          c.jo.jsonOutput,
 	}
 
 	if dr, ok := rep.(repo.DirectRepository); ok {
@@ -86,7 +88,7 @@ func (c *commandSnapshotVerify) run(ctx context.Context, rep repo.Repository) er
 		}
 	}()
 
-	result, err := v.InParallel(ctx, c.makeVerifyWalkerFunc(ctx, rep))
+	result, err := v.InParallel(ctx, c.makeVerifyWalkerFunc(ctx, rep, v))
 
 	if c.jo.jsonOutput {
 		c.out.printStdout("%s\n", c.jo.jsonIndentedBytes(result, "  "))
@@ -96,7 +98,7 @@ func (c *commandSnapshotVerify) run(ctx context.Context, rep repo.Repository) er
 	return err
 }
 
-func (c *commandSnapshotVerify) makeVerifyWalkerFunc(ctx context.Context, rep repo.Repository) func(tw *snapshotfs.TreeWalker) error {
+func (c *commandSnapshotVerify) makeVerifyWalkerFunc(ctx context.Context, rep repo.Repository, v *snapshotfs.Verifier) func(tw *snapshotfs.TreeWalker) error {
 	return func(tw *snapshotfs.TreeWalker) error {
 		manifests, err := c.loadSourceManifests(ctx, rep)
 		if err != nil {
@@ -110,6 +112,13 @@ func (c *commandSnapshotVerify) makeVerifyWalkerFunc(ctx context.Context, rep re
 
 		manifests = append(manifests, snapIDManifests...)
 
+		type twEntry struct {
+			root     fs.Entry
+			rootPath string
+		}
+
+		var treeWalkerEntries []twEntry
+
 		for _, man := range manifests {
 			rootPath := fmt.Sprintf("%v@%v", man.Source, formatTimestamp(man.StartTime.ToTime()))
 
@@ -122,9 +131,20 @@ func (c *commandSnapshotVerify) makeVerifyWalkerFunc(ctx context.Context, rep re
 				return errors.Wrapf(err, "unable to get snapshot root: %q", rootPath)
 			}
 
+			treeWalkerEntries = append(treeWalkerEntries, twEntry{
+				root:     root,
+				rootPath: rootPath,
+			})
+
+			if err := addExpectedWorkFromDirSummaryToVerifier(ctx, v, root); err != nil {
+				return errors.Wrapf(err, "unable to set stat totals from summary")
+			}
+		}
+
+		for _, twEntry := range treeWalkerEntries {
 			// ignore error now, return aggregate error at a higher level.
 			//nolint:errcheck
-			tw.Process(ctx, root, rootPath)
+			tw.Process(ctx, twEntry.root, twEntry.rootPath)
 		}
 
 		for _, oidStr := range c.verifyCommandDirObjectIDs {
@@ -153,6 +173,38 @@ func (c *commandSnapshotVerify) makeVerifyWalkerFunc(ctx context.Context, rep re
 	}
 }
 
+// addExpectedWorkFromDirSummaryToVerifier initializes the snapshot verifier with an
+// expected amount of work that will take place during the tree walk for this Entry.
+// If the entry is not a DirectoryWithSummary, or the Summary returns nil, no stats
+// will be added to the totals.
+func addExpectedWorkFromDirSummaryToVerifier(ctx context.Context, v *snapshotfs.Verifier, ent fs.Entry) error {
+	dws, ok := ent.(fs.DirectoryWithSummary)
+	if !ok {
+		// Entry is not a directory with summary, no stats to add.
+		return nil
+	}
+
+	s, err := dws.Summary(ctx)
+	if err != nil {
+		return errors.Wrap(err, "unable to get directory summary")
+	}
+
+	if s == nil {
+		// Summary returned nil, no stats to add.
+		return nil
+	}
+
+	// Add the maximum expected work that could be done by this walk.
+	// This can be used to gauge progress.
+	v.AddToExpectedTotals(
+		s.TotalFileCount+s.TotalDirCount+s.TotalSymlinkCount,
+		s.TotalFileCount,
+		s.TotalFileSize,
+	)
+
+	return nil
+}
+
 func (c *commandSnapshotVerify) loadSourceManifests(ctx context.Context, rep repo.Repository) ([]*snapshot.Manifest, error) {
 	var manifestIDs []manifest.ID
 
diff --git a/snapshot/snapshotfs/snapshot_verifier.go b/snapshot/snapshotfs/snapshot_verifier.go
index b6935816..4a6a9e17 100644
--- a/snapshot/snapshotfs/snapshot_verifier.go
+++ b/snapshot/snapshotfs/snapshot_verifier.go
@@ -2,11 +2,11 @@
 
 import (
 	"context"
+	"encoding/json"
 	"io"
 	"math/rand"
 	"runtime"
 	"sync"
-	"sync/atomic"
 	"time"
 
 	"github.com/pkg/errors"
@@ -14,6 +14,7 @@
 	"github.com/kopia/kopia/fs"
 	"github.com/kopia/kopia/internal/iocopy"
 	"github.com/kopia/kopia/internal/timetrack"
+	"github.com/kopia/kopia/internal/units"
 	"github.com/kopia/kopia/repo"
 	"github.com/kopia/kopia/repo/blob"
 	"github.com/kopia/kopia/repo/logging"
@@ -25,14 +26,31 @@
 type verifyFileWorkItem struct {
 	oid       object.ID
 	entryPath string
+	size      int64
 }
 
 // Verifier allows efficient verification of large amounts of filesystem entries in parallel.
 type Verifier struct {
 	throttle timetrack.Throttle
 
-	queued    atomic.Int32
-	processed atomic.Int32
+	statsMu sync.RWMutex
+
+	// +checklocks:statsMu
+	queued int32
+	// +checklocks:statsMu
+	processed int32
+	// +checklocks:statsMu
+	processedBytes int64
+	// +checklocks:statsMu
+	readFiles int64
+	// +checklocks:statsMu
+	readBytes int64
+	// +checklocks:statsMu
+	expectedTotalObjects int64
+	// +checklocks:statsMu
+	expectedTotalFiles int64
+	// +checklocks:statsMu
+	expectedTotalBytes int64
 
 	fileWorkQueue chan verifyFileWorkItem
 	rep           repo.Repository
@@ -42,26 +60,97 @@ type Verifier struct {
 	blobMap map[blob.ID]blob.Metadata // when != nil, will check that each backing blob exists
 }
 
+// AddToExpectedTotals adds the provided values to the corresponding stat
+// total values. If the caller can precompute the expected number
+// of entries that will be iterated over, they can add them to
+// the progress output.
+func (v *Verifier) AddToExpectedTotals(objs, files, bytes int64) {
+	v.statsMu.Lock()
+	defer v.statsMu.Unlock()
+
+	v.expectedTotalObjects += objs
+	v.expectedTotalFiles += files
+	v.expectedTotalBytes += bytes
+}
+
+func (v *Verifier) getStats() VerifierStats {
+	v.statsMu.RLock()
+	defer v.statsMu.RUnlock()
+
+	return VerifierStats{
+		ProcessedObjectCount: int64(v.processed),
+		ProcessedBytes:       v.processedBytes,
+		ReadFileCount:        v.readFiles,
+		ReadBytes:            v.readBytes,
+		ExpectedTotalObjects: v.expectedTotalObjects,
+		ExpectedTotalFiles:   v.expectedTotalFiles,
+		ExpectedTotalBytes:   v.expectedTotalBytes,
+	}
+}
+
+const (
+	inProgressStatsMsg = "Processed"
+	finishedStatsMsg   = "Finished processing"
+)
+
 // ShowStats logs verification statistics.
 func (v *Verifier) ShowStats(ctx context.Context) {
-	processed := v.processed.Load()
-
-	verifierLog(ctx).Infof("Processed %v objects.", processed)
+	v.showStatsf(ctx, inProgressStatsMsg)
 }
 
 // ShowFinalStats logs final verification statistics.
 func (v *Verifier) ShowFinalStats(ctx context.Context) {
-	processed := v.processed.Load()
+	v.showStatsf(ctx, finishedStatsMsg)
+}
 
-	verifierLog(ctx).Infof("Finished processing %v objects.", processed)
+func (v *Verifier) showStatsf(ctx context.Context, msg string) {
+	st := v.getStats()
+
+	if v.opts.JSONStats {
+		v.showStatsJSON(ctx, st)
+		return
+	}
+
+	processed := st.ProcessedObjectCount
+	processedBytes := st.ProcessedBytes
+	readFiles := st.ReadFileCount
+	readBytes := st.ReadBytes
+
+	// "<message> <x> objects. Read <y> files (<z> MB)"
+	verifierLog(ctx).Infof("%v %v objects (%v). Read %v files (%v).", msg, processed, units.BytesString(processedBytes), readFiles, units.BytesString(readBytes))
+}
+
+// VerifierStats contains stats on the amount of work done and current progress.
+type VerifierStats struct {
+	ProcessedObjectCount int64 `json:"processedObjectCount"`
+	ProcessedBytes       int64 `json:"processedBytes"`
+	ReadFileCount        int64 `json:"readFileCount"`
+	ReadBytes            int64 `json:"readBytes"`
+	ExpectedTotalObjects int64 `json:"expectedTotalObjectCount"`
+	ExpectedTotalFiles   int64 `json:"expectedTotalFileCount"`
+	ExpectedTotalBytes   int64 `json:"expectedTotalBytes"`
+}
+
+func (v *Verifier) showStatsJSON(ctx context.Context, st VerifierStats) {
+	b, err := json.Marshal(st)
+	if err != nil {
+		verifierLog(ctx).Errorw("failed to marshal stats", "err", err, "stats", st)
+		return
+	}
+
+	verifierLog(ctx).Infof("%s", b)
 }
 
 // VerifyFile verifies a single file object (using content check, blob map check or full read).
-func (v *Verifier) VerifyFile(ctx context.Context, oid object.ID, entryPath string) error {
+func (v *Verifier) VerifyFile(ctx context.Context, oid object.ID, entryPath string, size int64) error {
 	verifierLog(ctx).Debugf("verifying object %v", oid)
 
 	defer func() {
-		v.processed.Add(1)
+		v.updateProcessedStats(size)
+
+		if v.throttle.ShouldOutput(time.Second) {
+			v.ShowStats(ctx)
+		}
 	}()
 
 	contentIDs, err := v.rep.VerifyObject(ctx, oid)
@@ -92,6 +181,14 @@ func (v *Verifier) VerifyFile(ctx context.Context, oid object.ID, entryPath stri
 	return nil
 }
 
+func (v *Verifier) updateProcessedStats(size int64) {
+	v.statsMu.Lock()
+	defer v.statsMu.Unlock()
+
+	v.processed++
+	v.processedBytes += size
+}
+
 // verifyObject enqueues a single object for verification.
 func (v *Verifier) verifyObject(ctx context.Context, e fs.Entry, oid object.ID, entryPath string) error {
 	if v.throttle.ShouldOutput(time.Second) {
@@ -99,11 +196,18 @@ func (v *Verifier) verifyObject(ctx context.Context, e fs.Entry, oid object.ID,
 	}
 
 	if !e.IsDir() {
-		v.fileWorkQueue <- verifyFileWorkItem{oid, entryPath}
-		v.queued.Add(1)
+		v.fileWorkQueue <- verifyFileWorkItem{oid, entryPath, e.Size()}
+
+		v.statsMu.Lock()
+		defer v.statsMu.Unlock()
+
+		v.queued++
 	} else {
-		v.queued.Add(1)
-		v.processed.Add(1)
+		v.statsMu.Lock()
+		defer v.statsMu.Unlock()
+
+		v.queued++
+		v.processed++
 	}
 
 	return nil
@@ -119,7 +223,18 @@ func (v *Verifier) readEntireObject(ctx context.Context, oid object.ID, path str
 	}
 	defer r.Close() //nolint:errcheck
 
-	return errors.Wrap(iocopy.JustCopy(io.Discard, r), "unable to read data")
+	n, err := iocopy.Copy(io.Discard, r)
+	if err != nil {
+		return errors.Wrap(err, "unable to read data")
+	}
+
+	v.statsMu.Lock()
+	defer v.statsMu.Unlock()
+
+	v.readBytes += n
+	v.readFiles++
+
+	return nil
 }
 
 // VerifierOptions provides options for the verifier.
@@ -129,26 +244,31 @@ type VerifierOptions struct {
 	Parallelism        int
 	MaxErrors          int
 	BlobMap            map[blob.ID]blob.Metadata
+	JSONStats          bool
 }
 
 // VerifierResult returns results from the verifier.
 type VerifierResult struct {
-	ProcessedObjectCount int      `json:"processedObjectCount"`
-	ErrorCount           int      `json:"errorCount"`
-	Errors               []error  `json:"-"`
-	ErrorStrings         []string `json:"errorStrings,omitempty"`
+	Stats        VerifierStats `json:"stats"`
+	ErrorCount   int           `json:"errorCount"`
+	Errors       []error       `json:"-"`
+	ErrorStrings []string      `json:"errorStrings,omitempty"`
 }
 
-// InParallel starts parallel verification and invokes the provided function which can
-// call Process() on in the provided TreeWalker.
-func (v *Verifier) InParallel(ctx context.Context, enqueue func(tw *TreeWalker) error) (*VerifierResult, error) {
+// InParallel starts parallel verification and invokes the provided function
+// which can call Process() on in the provided TreeWalker. Errors and stats
+// are accumulated into a VerifierResult and returned, independent of whether
+// the error return is nil, that is, `VerifierResult` will contain useful,
+// partial stats when an error is returned, including a collection of errors
+// found in the verification process.
+func (v *Verifier) InParallel(ctx context.Context, enqueue func(tw *TreeWalker) error) (VerifierResult, error) {
 	tw, twerr := NewTreeWalker(ctx, TreeWalkerOptions{
 		Parallelism:   v.opts.Parallelism,
 		EntryCallback: v.verifyObject,
 		MaxErrors:     v.opts.MaxErrors,
 	})
 	if twerr != nil {
-		return nil, errors.Wrap(twerr, "tree walker")
+		return VerifierResult{}, errors.Wrap(twerr, "tree walker")
 	}
 	defer tw.Close(ctx)
 
@@ -165,7 +285,7 @@ func (v *Verifier) InParallel(ctx context.Context, enqueue func(tw *TreeWalker)
 					continue
 				}
 
-				if err := v.VerifyFile(ctx, wi.oid, wi.entryPath); err != nil {
+				if err := v.VerifyFile(ctx, wi.oid, wi.entryPath, wi.size); err != nil {
 					tw.ReportError(ctx, wi.entryPath, err)
 				}
 			}
@@ -173,6 +293,10 @@ func (v *Verifier) InParallel(ctx context.Context, enqueue func(tw *TreeWalker)
 	}
 
 	err := enqueue(tw)
+	if err != nil {
+		// Pass the enqueue error to the tree walker for later accumulation.
+		tw.ReportError(ctx, "tree walker enqueue", err)
+	}
 
 	close(v.fileWorkQueue)
 	v.workersWG.Wait()
@@ -185,26 +309,13 @@ func (v *Verifier) InParallel(ctx context.Context, enqueue func(tw *TreeWalker)
 		errStrs = append(errStrs, twErr.Error())
 	}
 
-	result := &VerifierResult{
-		ProcessedObjectCount: int(v.processed.Load()),
-		ErrorCount:           numErrors,
-		Errors:               twErrs,
-		ErrorStrings:         errStrs,
-	}
-
-	if err != nil {
-		// In some circumstances, the enqueue function may return an error itself, for instance
-		// if it failed to resolve the snapshot manifest from the ID.
-		// Append that error to the result output and return.
-		result.Errors = append(result.Errors, err)
-		result.ErrorStrings = append(result.ErrorStrings, err.Error())
-		result.ErrorCount++
-
-		return result, err
-	}
-
-	// Otherwise return the tree walker error output along with result details.
-	return result, tw.Err()
+	// Return the tree walker error output along with result details.
+	return VerifierResult{
+		Stats:        v.getStats(),
+		Errors:       twErrs,
+		ErrorStrings: errStrs,
+		ErrorCount:   numErrors,
+	}, tw.Err()
 }
 
 // NewVerifier creates a verifier.


Test patch:
diff --git a/cli/command_snapshot_verify_test.go b/cli/command_snapshot_verify_test.go
index 6d45814b..b495e7e6 100644
--- a/cli/command_snapshot_verify_test.go
+++ b/cli/command_snapshot_verify_test.go
@@ -85,7 +85,7 @@ func TestSnapshotVerify(t *testing.T) {
 	stdout, _ := env.RunAndExpectFailure(t, "snapshot", "verify", "--json")
 	require.Len(t, stdout, 1)
 	result := unmarshalSnapVerify(t, stdout[0])
-	require.NotZero(t, result.ProcessedObjectCount)
+	require.NotZero(t, result.Stats.ProcessedObjectCount)
 	require.NotZero(t, result.ErrorCount)
 	require.Len(t, result.ErrorStrings, 1)
 
@@ -93,7 +93,7 @@ func TestSnapshotVerify(t *testing.T) {
 	stdout = env.RunAndExpectSuccess(t, "snapshot", "verify", "--json", string(intactMan.ID))
 	require.Len(t, stdout, 1)
 	result = unmarshalSnapVerify(t, stdout[0])
-	require.NotZero(t, result.ProcessedObjectCount)
+	require.NotZero(t, result.Stats.ProcessedObjectCount)
 	require.Zero(t, result.ErrorCount)
 	require.Empty(t, result.ErrorStrings)
 
@@ -101,7 +101,7 @@ func TestSnapshotVerify(t *testing.T) {
 	stdout, _ = env.RunAndExpectFailure(t, "snapshot", "verify", "--json", string(corruptMan1.ID))
 	require.Len(t, stdout, 1)
 	result = unmarshalSnapVerify(t, stdout[0])
-	require.NotZero(t, result.ProcessedObjectCount)
+	require.NotZero(t, result.Stats.ProcessedObjectCount)
 	require.NotZero(t, result.ErrorCount)
 	require.Len(t, result.ErrorStrings, 1)
 
@@ -109,7 +109,7 @@ func TestSnapshotVerify(t *testing.T) {
 	stdout, _ = env.RunAndExpectFailure(t, "snapshot", "verify", "--json", string(corruptMan2.ID))
 	require.Len(t, stdout, 1)
 	result = unmarshalSnapVerify(t, stdout[0])
-	require.NotZero(t, result.ProcessedObjectCount)
+	require.NotZero(t, result.Stats.ProcessedObjectCount)
 	require.NotZero(t, result.ErrorCount)
 	require.Len(t, result.ErrorStrings, 1)
 
@@ -118,7 +118,7 @@ func TestSnapshotVerify(t *testing.T) {
 	require.Error(t, err)
 	assert.Equal(t, 1, strings.Count(strings.Join(stderr, "\n"), "error processing"))
 	result = unmarshalSnapVerify(t, stdout[0])
-	require.NotZero(t, result.ProcessedObjectCount)
+	require.NotZero(t, result.Stats.ProcessedObjectCount)
 	require.Equal(t, 1, result.ErrorCount)
 	require.Len(t, result.ErrorStrings, 1)
 
@@ -128,14 +128,14 @@ func TestSnapshotVerify(t *testing.T) {
 	require.Error(t, err)
 	assert.Equal(t, 2, strings.Count(strings.Join(stderr, "\n"), "error processing"))
 	result = unmarshalSnapVerify(t, stdout[0])
-	require.NotZero(t, result.ProcessedObjectCount)
+	require.NotZero(t, result.Stats.ProcessedObjectCount)
 	require.Equal(t, 2, result.ErrorCount)
 	require.Len(t, result.ErrorStrings, 2)
 
 	// Requesting a snapshot verify of a non-existent manifest ID results in error.
 	stdout, _ = env.RunAndExpectFailure(t, "snapshot", "verify", "--json", "not-a-manifest-id")
 	result = unmarshalSnapVerify(t, stdout[0])
-	require.Zero(t, result.ProcessedObjectCount)
+	require.Zero(t, result.Stats.ProcessedObjectCount)
 	require.Equal(t, 1, result.ErrorCount)
 	require.Len(t, result.ErrorStrings, 1)
 }
diff --git a/snapshot/snapshotfs/snapshot_verifier_test.go b/snapshot/snapshotfs/snapshot_verifier_test.go
index dbae5362..d2e187ae 100644
--- a/snapshot/snapshotfs/snapshot_verifier_test.go
+++ b/snapshot/snapshotfs/snapshot_verifier_test.go
@@ -68,6 +68,9 @@ func TestSnapshotVerifier(t *testing.T) {
 		require.Equal(t, 1, result.ErrorCount)
 		require.Len(t, result.Errors, 1)
 		require.ErrorIs(t, result.Errors[0], someErr)
+		require.Zero(t, result.Stats.ProcessedObjectCount)
+		require.Zero(t, result.Stats.ReadFileCount)
+		require.Zero(t, result.Stats.ReadBytes)
 
 		result, err = v.InParallel(ctx, func(tw *snapshotfs.TreeWalker) error {
 			return someErr
@@ -76,6 +79,9 @@ func TestSnapshotVerifier(t *testing.T) {
 		require.Equal(t, 1, result.ErrorCount)
 		require.Len(t, result.Errors, 1)
 		require.ErrorIs(t, result.Errors[0], someErr)
+		require.Zero(t, result.Stats.ProcessedObjectCount)
+		require.Zero(t, result.Stats.ReadFileCount)
+		require.Zero(t, result.Stats.ReadBytes)
 
 		result, err = v.InParallel(ctx, func(tw *snapshotfs.TreeWalker) error {
 			return nil
@@ -83,6 +89,9 @@ func TestSnapshotVerifier(t *testing.T) {
 		require.NoError(t, err)
 		require.Equal(t, 0, result.ErrorCount)
 		require.Empty(t, result.Errors)
+		require.Zero(t, result.Stats.ProcessedObjectCount)
+		require.Zero(t, result.Stats.ReadFileCount)
+		require.Zero(t, result.Stats.ReadBytes)
 
 		result, err = v.InParallel(ctx, func(tw *snapshotfs.TreeWalker) error {
 			tw.Process(ctx, snapshotfs.DirectoryEntry(te.Repository, obj1, nil), ".")
@@ -91,6 +100,9 @@ func TestSnapshotVerifier(t *testing.T) {
 		require.NoError(t, err)
 		require.Equal(t, 0, result.ErrorCount)
 		require.Empty(t, result.Errors)
+		require.NotZero(t, result.Stats.ProcessedObjectCount)
+		require.Zero(t, result.Stats.ReadFileCount)
+		require.Zero(t, result.Stats.ReadBytes)
 	})
 
 	t.Run("FullFileReadsAndBlobMap", func(t *testing.T) {
@@ -114,6 +126,9 @@ func TestSnapshotVerifier(t *testing.T) {
 		require.NoError(t, err)
 		require.Equal(t, 0, result.ErrorCount)
 		require.Empty(t, result.Errors)
+		require.NotZero(t, result.Stats.ProcessedObjectCount)
+		require.NotZero(t, result.Stats.ReadFileCount)
+		require.NotZero(t, result.Stats.ReadBytes)
 
 		// now remove all 'p' blobs from the blob map
 		for k := range opts.BlobMap {
@@ -133,6 +148,10 @@ func TestSnapshotVerifier(t *testing.T) {
 		for _, err := range result.Errors {
 			require.ErrorContains(t, err, "is backed by missing blob")
 		}
+
+		require.NotZero(t, result.Stats.ProcessedObjectCount)
+		require.NotZero(t, result.Stats.ReadFileCount)
+		require.NotZero(t, result.Stats.ReadBytes)
 	})
 
 	t.Run("MaxErrors", func(t *testing.T) {
@@ -149,7 +168,7 @@ func TestSnapshotVerifier(t *testing.T) {
 
 		v := snapshotfs.NewVerifier(ctx, te2, opts)
 
-		var result *snapshotfs.VerifierResult
+		var result snapshotfs.VerifierResult
 
 		result, err = v.InParallel(ctx, func(tw *snapshotfs.TreeWalker) error {
 			tw.Process(ctx, snapshotfs.DirectoryEntry(te.Repository, obj1, nil), ".")
@@ -158,6 +177,9 @@ func TestSnapshotVerifier(t *testing.T) {
 		require.NoError(t, err)
 		require.Equal(t, 0, result.ErrorCount)
 		require.Empty(t, result.Errors)
+		require.NotZero(t, result.Stats.ProcessedObjectCount)
+		require.Zero(t, result.Stats.ReadFileCount)
+		require.Zero(t, result.Stats.ReadBytes)
 
 		// now remove all 'p' blobs from the blob map
 		for k := range opts.BlobMap {
@@ -175,6 +197,9 @@ func TestSnapshotVerifier(t *testing.T) {
 		require.Equal(t, 1, result.ErrorCount)
 		require.Len(t, result.Errors, 1)
 		require.ErrorContains(t, result.Errors[0], "is backed by missing blob")
+		require.NotZero(t, result.Stats.ProcessedObjectCount)
+		require.Zero(t, result.Stats.ReadFileCount)
+		require.Zero(t, result.Stats.ReadBytes)
 	})
 
 	t.Run("FullFileReadsNoBlobMap", func(t *testing.T) {
@@ -191,6 +216,9 @@ func TestSnapshotVerifier(t *testing.T) {
 		require.NoError(t, err)
 		require.Equal(t, 0, result.ErrorCount)
 		require.Empty(t, result.Errors)
+		require.NotZero(t, result.Stats.ProcessedObjectCount)
+		require.NotZero(t, result.Stats.ReadFileCount)
+		require.NotZero(t, result.Stats.ReadBytes)
 
 		blobs, err := blob.ListAllBlobs(ctx, te.RepositoryWriter.BlobReader(), "p")
 		require.NoError(t, err)
@@ -211,5 +239,9 @@ func TestSnapshotVerifier(t *testing.T) {
 		for _, err := range result.Errors {
 			require.ErrorContains(t, err, "BLOB not found")
 		}
+
+		require.NotZero(t, result.Stats.ProcessedObjectCount)
+		require.NotZero(t, result.Stats.ReadFileCount)
+		require.NotZero(t, result.Stats.ReadBytes)
 	})
 }