github.com/cockroachdb/pebble@v1.1.1-0.20240513155919-3622ade60459/tool/sstable.go (about)

     1  // Copyright 2019 The LevelDB-Go and Pebble Authors. All rights reserved. Use
     2  // of this source code is governed by a BSD-style license that can be found in
     3  // the LICENSE file.
     4  
     5  package tool
     6  
     7  import (
     8  	"bytes"
     9  	"fmt"
    10  	"io"
    11  	"os"
    12  	"path/filepath"
    13  	"sort"
    14  	"text/tabwriter"
    15  
    16  	"github.com/cockroachdb/pebble"
    17  	"github.com/cockroachdb/pebble/internal/base"
    18  	"github.com/cockroachdb/pebble/internal/humanize"
    19  	"github.com/cockroachdb/pebble/internal/keyspan"
    20  	"github.com/cockroachdb/pebble/internal/private"
    21  	"github.com/cockroachdb/pebble/internal/rangedel"
    22  	"github.com/cockroachdb/pebble/sstable"
    23  	"github.com/cockroachdb/pebble/vfs"
    24  	"github.com/spf13/cobra"
    25  )
    26  
    27  // sstableT implements sstable-level tools, including both configuration state
    28  // and the commands themselves.
    29  type sstableT struct {
    30  	Root       *cobra.Command
    31  	Check      *cobra.Command
    32  	Layout     *cobra.Command
    33  	Properties *cobra.Command
    34  	Scan       *cobra.Command
    35  	Space      *cobra.Command
    36  
    37  	// Configuration and state.
    38  	opts      *pebble.Options
    39  	comparers sstable.Comparers
    40  	mergers   sstable.Mergers
    41  
    42  	// Flags.
    43  	fmtKey   keyFormatter
    44  	fmtValue valueFormatter
    45  	start    key
    46  	end      key
    47  	filter   key
    48  	count    int64
    49  	verbose  bool
    50  }
    51  
    52  func newSSTable(
    53  	opts *pebble.Options, comparers sstable.Comparers, mergers sstable.Mergers,
    54  ) *sstableT {
    55  	s := &sstableT{
    56  		opts:      opts,
    57  		comparers: comparers,
    58  		mergers:   mergers,
    59  	}
    60  	s.fmtKey.mustSet("quoted")
    61  	s.fmtValue.mustSet("[%x]")
    62  
    63  	s.Root = &cobra.Command{
    64  		Use:   "sstable",
    65  		Short: "sstable introspection tools",
    66  	}
    67  	s.Check = &cobra.Command{
    68  		Use:   "check <sstables>",
    69  		Short: "verify checksums and metadata",
    70  		Long:  ``,
    71  		Args:  cobra.MinimumNArgs(1),
    72  		Run:   s.runCheck,
    73  	}
    74  	s.Layout = &cobra.Command{
    75  		Use:   "layout <sstables>",
    76  		Short: "print sstable block and record layout",
    77  		Long: `
    78  Print the layout for the sstables. The -v flag controls whether record layout
    79  is displayed or omitted.
    80  `,
    81  		Args: cobra.MinimumNArgs(1),
    82  		Run:  s.runLayout,
    83  	}
    84  	s.Properties = &cobra.Command{
    85  		Use:   "properties <sstables>",
    86  		Short: "print sstable properties",
    87  		Long: `
    88  Print the properties for the sstables. The -v flag controls whether the
    89  properties are pretty-printed or displayed in a verbose/raw format.
    90  `,
    91  		Args: cobra.MinimumNArgs(1),
    92  		Run:  s.runProperties,
    93  	}
    94  	s.Scan = &cobra.Command{
    95  		Use:   "scan <sstables>",
    96  		Short: "print sstable records",
    97  		Long: `
    98  Print the records in the sstables. The sstables are scanned in command line
    99  order which means the records will be printed in that order. Raw range
   100  tombstones are displayed interleaved with point records.
   101  `,
   102  		Args: cobra.MinimumNArgs(1),
   103  		Run:  s.runScan,
   104  	}
   105  	s.Space = &cobra.Command{
   106  		Use:   "space <sstables>",
   107  		Short: "print filesystem space used",
   108  		Long: `
   109  Print the estimated space usage in the specified files for the
   110  inclusive-inclusive range specified by --start and --end.
   111  `,
   112  		Args: cobra.MinimumNArgs(1),
   113  		Run:  s.runSpace,
   114  	}
   115  
   116  	s.Root.AddCommand(s.Check, s.Layout, s.Properties, s.Scan, s.Space)
   117  	s.Root.PersistentFlags().BoolVarP(&s.verbose, "verbose", "v", false, "verbose output")
   118  
   119  	s.Check.Flags().Var(
   120  		&s.fmtKey, "key", "key formatter")
   121  	s.Layout.Flags().Var(
   122  		&s.fmtKey, "key", "key formatter")
   123  	s.Layout.Flags().Var(
   124  		&s.fmtValue, "value", "value formatter")
   125  	s.Scan.Flags().Var(
   126  		&s.fmtKey, "key", "key formatter")
   127  	s.Scan.Flags().Var(
   128  		&s.fmtValue, "value", "value formatter")
   129  	for _, cmd := range []*cobra.Command{s.Scan, s.Space} {
   130  		cmd.Flags().Var(
   131  			&s.start, "start", "start key for the range")
   132  		cmd.Flags().Var(
   133  			&s.end, "end", "end key for the range")
   134  	}
   135  	s.Scan.Flags().Var(
   136  		&s.filter, "filter", "only output records with matching prefix or overlapping range tombstones")
   137  	s.Scan.Flags().Int64Var(
   138  		&s.count, "count", 0, "key count for scan (0 is unlimited)")
   139  
   140  	return s
   141  }
   142  
   143  func (s *sstableT) newReader(f vfs.File) (*sstable.Reader, error) {
   144  	readable, err := sstable.NewSimpleReadable(f)
   145  	if err != nil {
   146  		return nil, err
   147  	}
   148  	o := sstable.ReaderOptions{
   149  		Cache:    pebble.NewCache(128 << 20 /* 128 MB */),
   150  		Comparer: s.opts.Comparer,
   151  		Filters:  s.opts.Filters,
   152  	}
   153  	defer o.Cache.Unref()
   154  	return sstable.NewReader(readable, o, s.comparers, s.mergers,
   155  		private.SSTableRawTombstonesOpt.(sstable.ReaderOption))
   156  }
   157  
   158  func (s *sstableT) runCheck(cmd *cobra.Command, args []string) {
   159  	stdout, stderr := cmd.OutOrStdout(), cmd.OutOrStderr()
   160  	s.foreachSstable(stderr, args, func(arg string) {
   161  		f, err := s.opts.FS.Open(arg)
   162  		if err != nil {
   163  			fmt.Fprintf(stderr, "%s\n", err)
   164  			return
   165  		}
   166  
   167  		fmt.Fprintf(stdout, "%s\n", arg)
   168  
   169  		r, err := s.newReader(f)
   170  
   171  		if err != nil {
   172  			fmt.Fprintf(stdout, "%s\n", err)
   173  			return
   174  		}
   175  		defer r.Close()
   176  
   177  		// Update the internal formatter if this comparator has one specified.
   178  		s.fmtKey.setForComparer(r.Properties.ComparerName, s.comparers)
   179  		s.fmtValue.setForComparer(r.Properties.ComparerName, s.comparers)
   180  
   181  		iter, err := r.NewIter(nil, nil)
   182  		if err != nil {
   183  			fmt.Fprintf(stderr, "%s\n", err)
   184  			return
   185  		}
   186  
   187  		// If a split function is defined for the comparer, verify that
   188  		// SeekPrefixGE can find every key in the table.
   189  		var prefixIter sstable.Iterator
   190  		if r.Split != nil {
   191  			var err error
   192  			prefixIter, err = r.NewIter(nil, nil)
   193  			if err != nil {
   194  				fmt.Fprintf(stderr, "%s\n", err)
   195  				return
   196  			}
   197  		}
   198  
   199  		var lastKey base.InternalKey
   200  		for key, _ := iter.First(); key != nil; key, _ = iter.Next() {
   201  			if base.InternalCompare(r.Compare, lastKey, *key) >= 0 {
   202  				fmt.Fprintf(stdout, "WARNING: OUT OF ORDER KEYS!\n")
   203  				if s.fmtKey.spec != "null" {
   204  					fmt.Fprintf(stdout, "    %s >= %s\n",
   205  						lastKey.Pretty(s.fmtKey.fn), key.Pretty(s.fmtKey.fn))
   206  				}
   207  			}
   208  			lastKey.Trailer = key.Trailer
   209  			lastKey.UserKey = append(lastKey.UserKey[:0], key.UserKey...)
   210  
   211  			if prefixIter != nil {
   212  				n := r.Split(key.UserKey)
   213  				prefix := key.UserKey[:n]
   214  				key2, _ := prefixIter.SeekPrefixGE(prefix, key.UserKey, base.SeekGEFlagsNone)
   215  				if key2 == nil {
   216  					fmt.Fprintf(stdout, "WARNING: PREFIX ITERATION FAILURE!\n")
   217  					if s.fmtKey.spec != "null" {
   218  						fmt.Fprintf(stdout, "    %s not found\n", key.Pretty(s.fmtKey.fn))
   219  					}
   220  				}
   221  			}
   222  		}
   223  
   224  		if err := iter.Close(); err != nil {
   225  			fmt.Fprintf(stdout, "%s\n", err)
   226  		}
   227  		if prefixIter != nil {
   228  			if err := prefixIter.Close(); err != nil {
   229  				fmt.Fprintf(stdout, "%s\n", err)
   230  			}
   231  		}
   232  	})
   233  }
   234  
   235  func (s *sstableT) runLayout(cmd *cobra.Command, args []string) {
   236  	stdout, stderr := cmd.OutOrStdout(), cmd.OutOrStderr()
   237  	s.foreachSstable(stderr, args, func(arg string) {
   238  		f, err := s.opts.FS.Open(arg)
   239  		if err != nil {
   240  			fmt.Fprintf(stderr, "%s\n", err)
   241  			return
   242  		}
   243  
   244  		fmt.Fprintf(stdout, "%s\n", arg)
   245  
   246  		r, err := s.newReader(f)
   247  		if err != nil {
   248  			fmt.Fprintf(stdout, "%s\n", err)
   249  			return
   250  		}
   251  		defer r.Close()
   252  
   253  		// Update the internal formatter if this comparator has one specified.
   254  		s.fmtKey.setForComparer(r.Properties.ComparerName, s.comparers)
   255  		s.fmtValue.setForComparer(r.Properties.ComparerName, s.comparers)
   256  
   257  		l, err := r.Layout()
   258  		if err != nil {
   259  			fmt.Fprintf(stderr, "%s\n", err)
   260  			return
   261  		}
   262  		fmtRecord := func(key *base.InternalKey, value []byte) {
   263  			formatKeyValue(stdout, s.fmtKey, s.fmtValue, key, value)
   264  		}
   265  		if s.fmtKey.spec == "null" && s.fmtValue.spec == "null" {
   266  			fmtRecord = nil
   267  		}
   268  		l.Describe(stdout, s.verbose, r, fmtRecord)
   269  	})
   270  }
   271  
   272  func (s *sstableT) runProperties(cmd *cobra.Command, args []string) {
   273  	stdout, stderr := cmd.OutOrStdout(), cmd.OutOrStderr()
   274  	s.foreachSstable(stderr, args, func(arg string) {
   275  		f, err := s.opts.FS.Open(arg)
   276  		if err != nil {
   277  			fmt.Fprintf(stderr, "%s\n", err)
   278  			return
   279  		}
   280  
   281  		fmt.Fprintf(stdout, "%s\n", arg)
   282  
   283  		r, err := s.newReader(f)
   284  		if err != nil {
   285  			fmt.Fprintf(stdout, "%s\n", err)
   286  			return
   287  		}
   288  		defer r.Close()
   289  
   290  		if s.verbose {
   291  			fmt.Fprintf(stdout, "%s", r.Properties.String())
   292  			return
   293  		}
   294  
   295  		stat, err := f.Stat()
   296  		if err != nil {
   297  			fmt.Fprintf(stderr, "%s\n", err)
   298  			return
   299  		}
   300  
   301  		formatNull := func(s string) string {
   302  			switch s {
   303  			case "", "nullptr":
   304  				return "-"
   305  			}
   306  			return s
   307  		}
   308  
   309  		tw := tabwriter.NewWriter(stdout, 2, 1, 2, ' ', 0)
   310  		fmt.Fprintf(tw, "size\t\n")
   311  		fmt.Fprintf(tw, "  file\t%s\n", humanize.Bytes.Int64(stat.Size()))
   312  		fmt.Fprintf(tw, "  data\t%s\n", humanize.Bytes.Uint64(r.Properties.DataSize))
   313  		fmt.Fprintf(tw, "    blocks\t%d\n", r.Properties.NumDataBlocks)
   314  		fmt.Fprintf(tw, "  index\t%s\n", humanize.Bytes.Uint64(r.Properties.IndexSize))
   315  		fmt.Fprintf(tw, "    blocks\t%d\n", 1+r.Properties.IndexPartitions)
   316  		fmt.Fprintf(tw, "    top-level\t%s\n", humanize.Bytes.Uint64(r.Properties.TopLevelIndexSize))
   317  		fmt.Fprintf(tw, "  filter\t%s\n", humanize.Bytes.Uint64(r.Properties.FilterSize))
   318  		fmt.Fprintf(tw, "  raw-key\t%s\n", humanize.Bytes.Uint64(r.Properties.RawKeySize))
   319  		fmt.Fprintf(tw, "  raw-value\t%s\n", humanize.Bytes.Uint64(r.Properties.RawValueSize))
   320  		fmt.Fprintf(tw, "  pinned-key\t%d\n", r.Properties.SnapshotPinnedKeySize)
   321  		fmt.Fprintf(tw, "  pinned-val\t%d\n", r.Properties.SnapshotPinnedValueSize)
   322  		fmt.Fprintf(tw, "  point-del-key-size\t%d\n", r.Properties.RawPointTombstoneKeySize)
   323  		fmt.Fprintf(tw, "  point-del-value-size\t%d\n", r.Properties.RawPointTombstoneValueSize)
   324  		fmt.Fprintf(tw, "records\t%d\n", r.Properties.NumEntries)
   325  		fmt.Fprintf(tw, "  set\t%d\n", r.Properties.NumEntries-
   326  			(r.Properties.NumDeletions+r.Properties.NumMergeOperands))
   327  		fmt.Fprintf(tw, "  delete\t%d\n", r.Properties.NumPointDeletions())
   328  		fmt.Fprintf(tw, "  delete-sized\t%d\n", r.Properties.NumSizedDeletions)
   329  		fmt.Fprintf(tw, "  range-delete\t%d\n", r.Properties.NumRangeDeletions)
   330  		fmt.Fprintf(tw, "  range-key-set\t%d\n", r.Properties.NumRangeKeySets)
   331  		fmt.Fprintf(tw, "  range-key-unset\t%d\n", r.Properties.NumRangeKeyUnsets)
   332  		fmt.Fprintf(tw, "  range-key-delete\t%d\n", r.Properties.NumRangeKeyDels)
   333  		fmt.Fprintf(tw, "  merge\t%d\n", r.Properties.NumMergeOperands)
   334  		fmt.Fprintf(tw, "  global-seq-num\t%d\n", r.Properties.GlobalSeqNum)
   335  		fmt.Fprintf(tw, "  pinned\t%d\n", r.Properties.SnapshotPinnedKeys)
   336  		fmt.Fprintf(tw, "index\t\n")
   337  		fmt.Fprintf(tw, "  key\t")
   338  		fmt.Fprintf(tw, "  value\t")
   339  		fmt.Fprintf(tw, "comparer\t%s\n", r.Properties.ComparerName)
   340  		fmt.Fprintf(tw, "merger\t%s\n", formatNull(r.Properties.MergerName))
   341  		fmt.Fprintf(tw, "filter\t%s\n", formatNull(r.Properties.FilterPolicyName))
   342  		fmt.Fprintf(tw, "  prefix\t%t\n", r.Properties.PrefixFiltering)
   343  		fmt.Fprintf(tw, "  whole-key\t%t\n", r.Properties.WholeKeyFiltering)
   344  		fmt.Fprintf(tw, "compression\t%s\n", r.Properties.CompressionName)
   345  		fmt.Fprintf(tw, "  options\t%s\n", r.Properties.CompressionOptions)
   346  		fmt.Fprintf(tw, "user properties\t\n")
   347  		fmt.Fprintf(tw, "  collectors\t%s\n", r.Properties.PropertyCollectorNames)
   348  		keys := make([]string, 0, len(r.Properties.UserProperties))
   349  		for key := range r.Properties.UserProperties {
   350  			keys = append(keys, key)
   351  		}
   352  		sort.Strings(keys)
   353  		for _, key := range keys {
   354  			fmt.Fprintf(tw, "  %s\t%s\n", key, r.Properties.UserProperties[key])
   355  		}
   356  		tw.Flush()
   357  	})
   358  }
   359  
   360  func (s *sstableT) runScan(cmd *cobra.Command, args []string) {
   361  	stdout, stderr := cmd.OutOrStdout(), cmd.OutOrStderr()
   362  	s.foreachSstable(stderr, args, func(arg string) {
   363  		f, err := s.opts.FS.Open(arg)
   364  		if err != nil {
   365  			fmt.Fprintf(stderr, "%s\n", err)
   366  			return
   367  		}
   368  
   369  		// In filter-mode, we prefix ever line that is output with the sstable
   370  		// filename.
   371  		var prefix string
   372  		if s.filter == nil {
   373  			fmt.Fprintf(stdout, "%s\n", arg)
   374  		} else {
   375  			prefix = fmt.Sprintf("%s: ", arg)
   376  		}
   377  
   378  		r, err := s.newReader(f)
   379  		if err != nil {
   380  			fmt.Fprintf(stdout, "%s%s\n", prefix, err)
   381  			return
   382  		}
   383  		defer r.Close()
   384  
   385  		// Update the internal formatter if this comparator has one specified.
   386  		s.fmtKey.setForComparer(r.Properties.ComparerName, s.comparers)
   387  		s.fmtValue.setForComparer(r.Properties.ComparerName, s.comparers)
   388  
   389  		iter, err := r.NewIter(nil, s.end)
   390  		if err != nil {
   391  			fmt.Fprintf(stderr, "%s%s\n", prefix, err)
   392  			return
   393  		}
   394  		defer iter.Close()
   395  		key, value := iter.SeekGE(s.start, base.SeekGEFlagsNone)
   396  
   397  		// We configured sstable.Reader to return raw tombstones which requires a
   398  		// bit more work here to put them in a form that can be iterated in
   399  		// parallel with the point records.
   400  		rangeDelIter, err := func() (keyspan.FragmentIterator, error) {
   401  			iter, err := r.NewRawRangeDelIter()
   402  			if err != nil {
   403  				return nil, err
   404  			}
   405  			if iter == nil {
   406  				return keyspan.NewIter(r.Compare, nil), nil
   407  			}
   408  			defer iter.Close()
   409  
   410  			var tombstones []keyspan.Span
   411  			for t := iter.First(); t != nil; t = iter.Next() {
   412  				if s.end != nil && r.Compare(s.end, t.Start) <= 0 {
   413  					// The range tombstone lies after the scan range.
   414  					continue
   415  				}
   416  				if r.Compare(s.start, t.End) >= 0 {
   417  					// The range tombstone lies before the scan range.
   418  					continue
   419  				}
   420  				tombstones = append(tombstones, t.ShallowClone())
   421  			}
   422  
   423  			sort.Slice(tombstones, func(i, j int) bool {
   424  				return r.Compare(tombstones[i].Start, tombstones[j].Start) < 0
   425  			})
   426  			return keyspan.NewIter(r.Compare, tombstones), nil
   427  		}()
   428  		if err != nil {
   429  			fmt.Fprintf(stdout, "%s%s\n", prefix, err)
   430  			return
   431  		}
   432  
   433  		defer rangeDelIter.Close()
   434  		rangeDel := rangeDelIter.First()
   435  		count := s.count
   436  
   437  		var lastKey base.InternalKey
   438  		for key != nil || rangeDel != nil {
   439  			if key != nil && (rangeDel == nil || r.Compare(key.UserKey, rangeDel.Start) < 0) {
   440  				// The filter specifies a prefix of the key.
   441  				//
   442  				// TODO(peter): Is using prefix comparison like this kosher for all
   443  				// comparers? Probably not, but it is for common ones such as the
   444  				// Pebble default and CockroachDB's comparer.
   445  				if s.filter == nil || bytes.HasPrefix(key.UserKey, s.filter) {
   446  					fmt.Fprint(stdout, prefix)
   447  					v, _, err := value.Value(nil)
   448  					if err != nil {
   449  						fmt.Fprintf(stdout, "%s%s\n", prefix, err)
   450  						return
   451  					}
   452  					formatKeyValue(stdout, s.fmtKey, s.fmtValue, key, v)
   453  
   454  				}
   455  				if base.InternalCompare(r.Compare, lastKey, *key) >= 0 {
   456  					fmt.Fprintf(stdout, "%s    WARNING: OUT OF ORDER KEYS!\n", prefix)
   457  				}
   458  				lastKey.Trailer = key.Trailer
   459  				lastKey.UserKey = append(lastKey.UserKey[:0], key.UserKey...)
   460  				key, value = iter.Next()
   461  			} else {
   462  				// If a filter is specified, we want to output any range tombstone
   463  				// which overlaps the prefix. The comparison on the start key is
   464  				// somewhat complex. Consider the tombstone [aaa,ccc). We want to
   465  				// output this tombstone if filter is "aa", and if it "bbb".
   466  				if s.filter == nil ||
   467  					((r.Compare(s.filter, rangeDel.Start) >= 0 ||
   468  						bytes.HasPrefix(rangeDel.Start, s.filter)) &&
   469  						r.Compare(s.filter, rangeDel.End) < 0) {
   470  					fmt.Fprint(stdout, prefix)
   471  					if err := rangedel.Encode(rangeDel, func(k base.InternalKey, v []byte) error {
   472  						formatKeyValue(stdout, s.fmtKey, s.fmtValue, &k, v)
   473  						return nil
   474  					}); err != nil {
   475  						fmt.Fprintf(stdout, "%s\n", err)
   476  						os.Exit(1)
   477  					}
   478  				}
   479  				rangeDel = rangeDelIter.Next()
   480  			}
   481  
   482  			if count > 0 {
   483  				count--
   484  				if count == 0 {
   485  					break
   486  				}
   487  			}
   488  		}
   489  
   490  		// Handle range keys.
   491  		rkIter, err := r.NewRawRangeKeyIter()
   492  		if err != nil {
   493  			fmt.Fprintf(stdout, "%s\n", err)
   494  			os.Exit(1)
   495  		}
   496  		if rkIter != nil {
   497  			defer rkIter.Close()
   498  			for span := rkIter.SeekGE(s.start); span != nil; span = rkIter.Next() {
   499  				// By default, emit the key, unless there is a filter.
   500  				emit := s.filter == nil
   501  				// Skip spans that start after the end key (if provided). End keys are
   502  				// exclusive, e.g. [a, b), so we consider the interval [b, +inf).
   503  				if s.end != nil && r.Compare(span.Start, s.end) >= 0 {
   504  					emit = false
   505  				}
   506  				// Filters override the provided start / end bounds, if provided.
   507  				if s.filter != nil && bytes.HasPrefix(span.Start, s.filter) {
   508  					// In filter mode, each line is prefixed with the filename.
   509  					fmt.Fprint(stdout, prefix)
   510  					emit = true
   511  				}
   512  				if emit {
   513  					formatSpan(stdout, s.fmtKey, s.fmtValue, span)
   514  				}
   515  			}
   516  		}
   517  
   518  		if err := iter.Close(); err != nil {
   519  			fmt.Fprintf(stdout, "%s\n", err)
   520  		}
   521  	})
   522  }
   523  
   524  func (s *sstableT) runSpace(cmd *cobra.Command, args []string) {
   525  	stdout, stderr := cmd.OutOrStdout(), cmd.OutOrStderr()
   526  	s.foreachSstable(stderr, args, func(arg string) {
   527  		f, err := s.opts.FS.Open(arg)
   528  		if err != nil {
   529  			fmt.Fprintf(stderr, "%s\n", err)
   530  			return
   531  		}
   532  		r, err := s.newReader(f)
   533  		if err != nil {
   534  			fmt.Fprintf(stderr, "%s\n", err)
   535  			return
   536  		}
   537  		defer r.Close()
   538  
   539  		bytes, err := r.EstimateDiskUsage(s.start, s.end)
   540  		if err != nil {
   541  			fmt.Fprintf(stderr, "%s\n", err)
   542  			return
   543  		}
   544  		fmt.Fprintf(stdout, "%s: %d\n", arg, bytes)
   545  	})
   546  }
   547  
   548  func (s *sstableT) foreachSstable(stderr io.Writer, args []string, fn func(arg string)) {
   549  	// Loop over args, invoking fn for each file. Each directory is recursively
   550  	// listed and fn is invoked on any file with an .sst or .ldb suffix.
   551  	for _, arg := range args {
   552  		info, err := s.opts.FS.Stat(arg)
   553  		if err != nil || !info.IsDir() {
   554  			fn(arg)
   555  			continue
   556  		}
   557  		walk(stderr, s.opts.FS, arg, func(path string) {
   558  			switch filepath.Ext(path) {
   559  			case ".sst", ".ldb":
   560  				fn(path)
   561  			}
   562  		})
   563  	}
   564  }