github.com/cockroachdb/pebble@v1.1.1-0.20240513155919-3622ade60459/internal/metamorphic/crossversion/crossversion_test.go (about)

     1  // Copyright 2022 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 crossversion builds on the metamorphic testing implemented in
     6  // internal/metamorphic, performing metamorphic testing across versions of
     7  // Pebble. This improves test coverage of upgrade and migration code paths.
     8  package crossversion
     9  
    10  import (
    11  	"bytes"
    12  	"context"
    13  	"flag"
    14  	"fmt"
    15  	"io"
    16  	"math/rand"
    17  	"os"
    18  	"os/exec"
    19  	"path/filepath"
    20  	"strconv"
    21  	"strings"
    22  	"sync"
    23  	"testing"
    24  	"time"
    25  	"unicode"
    26  
    27  	"github.com/cockroachdb/errors"
    28  	"github.com/cockroachdb/pebble/metamorphic"
    29  	"github.com/cockroachdb/pebble/vfs"
    30  	"github.com/stretchr/testify/require"
    31  )
    32  
    33  var (
    34  	factor       int
    35  	seed         int64
    36  	versions     pebbleVersions
    37  	artifactsDir string
    38  	streamOutput bool
    39  )
    40  
    41  func init() {
    42  	// NB: If you add new command-line flags, you should update the
    43  	// reproductionCommand function.
    44  	flag.Int64Var(&seed, "seed", 0,
    45  		`a pseudorandom number generator seed`)
    46  	flag.IntVar(&factor, "factor", 10,
    47  		`the number of data directories to carry forward
    48  from one version's run to the subsequent version's runs.`)
    49  	flag.Var(&versions, "version",
    50  		`a comma-separated 3-tuple defining a Pebble version to test.
    51  The expected format is <label>,<SHA>,<test-binary-path>.
    52  The label should be a human-readable label describing the
    53  version, for example, 'CRDB-22.1'. The SHA indicates the
    54  exact commit sha of the version, and may be abbreviated.
    55  The test binary path must point to a test binary of the
    56  internal/metamorphic package built on the indicated SHA.
    57  A test binary may be built with 'go test -c'.
    58  
    59  This flag should be provided multiple times to indicate
    60  the set of versions to test. The order of the versions
    61  is significant and database states generated from earlier
    62  versions will be used to initialize runs of subsequent
    63  versions.`)
    64  	flag.StringVar(&artifactsDir, "artifacts", "",
    65  		`the path to a directory where test artifacts should be
    66  moved on failure. Defaults to the current working directory.`)
    67  	flag.BoolVar(&streamOutput, "stream-output", false,
    68  		`stream TestMeta output to standard output`)
    69  }
    70  
    71  func reproductionCommand() string {
    72  	return fmt.Sprintf(
    73  		"SEED=%d FACTOR=%d ./scripts/run-crossversion-meta.sh %s\n",
    74  		seed, factor, versions.String(),
    75  	)
    76  }
    77  
    78  // TestMetaCrossVersion performs cross-version metamorphic testing.
    79  //
    80  // It runs tests against the internal/metamorphic test binaries specified with
    81  // multiple instances of the -version flag, exercising upgrade and migration
    82  // code paths.
    83  //
    84  // More specifically, assume we are passed the following versions:
    85  //
    86  //	--version 22.2,<sha>,meta-22-2.test --version 23.1,<sha>,meta-23-1.test
    87  //
    88  // TestMetaCrossVersion will:
    89  //   - run TestMeta on meta-22-2.test;
    90  //   - retain a random subset of the resulting directories (each directory is a
    91  //     store after a sequence of operations);
    92  //   - run TestMeta on meta-23.1.test once for every retained directory from the
    93  //     previous version (using it as initial state).
    94  func TestMetaCrossVersion(t *testing.T) {
    95  	if seed == 0 {
    96  		seed = time.Now().UnixNano()
    97  	}
    98  	tempDir := t.TempDir()
    99  	t.Logf("Test directory: %s\n", tempDir)
   100  	t.Logf("Reproduction:\n  %s\n", reproductionCommand())
   101  
   102  	// Print all the versions supplied and ensure all the test binaries
   103  	// actually exist before proceeding.
   104  	for i, v := range versions {
   105  		if len(v.SHA) > 8 {
   106  			// Use shortened SHAs for readability.
   107  			versions[i].SHA = versions[i].SHA[:8]
   108  		}
   109  		absPath, err := filepath.Abs(v.TestBinaryPath)
   110  		if err != nil {
   111  			t.Fatal(err)
   112  		}
   113  		fi, err := os.Stat(absPath)
   114  		if err != nil {
   115  			t.Fatal(err)
   116  		}
   117  		versions[i].TestBinaryPath = absPath
   118  		t.Logf("%d: %s (Mode = %s)", i, v.String(), fi.Mode())
   119  	}
   120  
   121  	// All randomness should be derived from `seed`. This makes reproducing a
   122  	// failure locally easier.
   123  	ctx := context.Background()
   124  	require.NoError(t, runCrossVersion(ctx, t, tempDir, versions, seed, factor))
   125  }
   126  
   127  type pebbleVersion struct {
   128  	Label          string
   129  	SHA            string
   130  	TestBinaryPath string
   131  }
   132  
   133  type initialState struct {
   134  	desc string
   135  	path string
   136  }
   137  
   138  func (s initialState) String() string {
   139  	if s.desc == "" {
   140  		return "<empty>"
   141  	}
   142  	return s.desc
   143  }
   144  
   145  func runCrossVersion(
   146  	ctx context.Context,
   147  	t *testing.T,
   148  	tempDir string,
   149  	versions pebbleVersions,
   150  	seed int64,
   151  	factor int,
   152  ) error {
   153  	prng := rand.New(rand.NewSource(seed))
   154  	// Use prng to derive deterministic seeds to provide to the child
   155  	// metamorphic runs. The same seed is used for all runs on a particular
   156  	// Pebble version.
   157  	versionSeeds := make([]uint64, len(versions))
   158  	for i := range versions {
   159  		versionSeeds[i] = prng.Uint64()
   160  	}
   161  
   162  	rootDir := filepath.Join(tempDir, strconv.FormatInt(seed, 10))
   163  	if err := os.MkdirAll(rootDir, os.ModePerm); err != nil {
   164  		return err
   165  	}
   166  
   167  	// When run with test parallelism, multiple tests may fail concurrently.
   168  	// Only one should actually run the test failure logic which copies the root
   169  	// dir into the artifacts directory.
   170  	var fatalOnce sync.Once
   171  
   172  	// The outer for loop executes once per version being tested. It takes a
   173  	// list of initial states, populated by the previous version. The inner loop
   174  	// executes once per initial state, running the metamorphic test against the
   175  	// initial state.
   176  	//
   177  	// The number of states that are carried forward from one version to the
   178  	// next is fixed by `factor`.
   179  	initialStates := []initialState{{}}
   180  	for i := range versions {
   181  		t.Logf("Running tests with version %s with %d initial state(s).", versions[i].SHA, len(initialStates))
   182  		histories, nextInitialStates, err := runVersion(ctx, t, &fatalOnce, rootDir, versions[i], versionSeeds[i], initialStates)
   183  		if err != nil {
   184  			return err
   185  		}
   186  
   187  		// All the initial states described the same state and all of this
   188  		// version's metamorphic runs used the same seed, so all of the
   189  		// resulting histories should be identical.
   190  		if h, diff := metamorphic.CompareHistories(t, histories); h > 0 {
   191  			fatalf(t, &fatalOnce, rootDir, "Metamorphic test divergence between %q and %q:\nDiff:\n%s",
   192  				nextInitialStates[0].desc, nextInitialStates[h].desc, diff)
   193  		}
   194  
   195  		// Prune the set of initial states we collected for this version, using
   196  		// the deterministic randomness of prng to pick which states we keep.
   197  		if len(nextInitialStates) > factor {
   198  			prng.Shuffle(len(nextInitialStates), func(i, j int) {
   199  				nextInitialStates[i], nextInitialStates[j] = nextInitialStates[j], nextInitialStates[i]
   200  			})
   201  			// Delete the states that we're not going to use.
   202  			for _, s := range nextInitialStates[factor:] {
   203  				require.NoError(t, os.RemoveAll(s.path))
   204  			}
   205  			nextInitialStates = nextInitialStates[:factor]
   206  		}
   207  		initialStates = nextInitialStates
   208  	}
   209  	return nil
   210  }
   211  
   212  func runVersion(
   213  	ctx context.Context,
   214  	t *testing.T,
   215  	fatalOnce *sync.Once,
   216  	rootDir string,
   217  	vers pebbleVersion,
   218  	seed uint64,
   219  	initialStates []initialState,
   220  ) (histories []string, nextInitialStates []initialState, err error) {
   221  	// mu guards histories and nextInitialStates. The subtests may be run in
   222  	// parallel (via t.Parallel()).
   223  	var mu sync.Mutex
   224  
   225  	// The outer 'execution-<label>' subtest will block until all of the
   226  	// individual subtests have completed.
   227  	t.Run(fmt.Sprintf("execution-%s", vers.Label), func(t *testing.T) {
   228  		for j, s := range initialStates {
   229  			j, s := j, s // re-bind loop vars to scope
   230  
   231  			runID := fmt.Sprintf("%s_%s_%d_%03d", vers.Label, vers.SHA, seed, j)
   232  			r := metamorphicTestRun{
   233  				seed:           seed,
   234  				dir:            filepath.Join(rootDir, runID),
   235  				vers:           vers,
   236  				initialState:   s,
   237  				testBinaryPath: vers.TestBinaryPath,
   238  			}
   239  			t.Run(s.desc, func(t *testing.T) {
   240  				t.Parallel()
   241  				require.NoError(t, os.MkdirAll(r.dir, os.ModePerm))
   242  
   243  				var buf bytes.Buffer
   244  				var out io.Writer = &buf
   245  				if streamOutput {
   246  					out = io.MultiWriter(out, os.Stderr)
   247  				}
   248  				t.Logf("  Running test with version %s with initial state %s.",
   249  					vers.SHA, s)
   250  				if err := r.run(ctx, out); err != nil {
   251  					fatalf(t, fatalOnce, rootDir, "Metamorphic test failed: %s\nOutput:%s\n", err, buf.String())
   252  				}
   253  
   254  				// dir is a directory containing the ops file and subdirectories for
   255  				// each run with a particular set of OPTIONS. For example:
   256  				//
   257  				// dir/
   258  				//   ops
   259  				//   random-000/
   260  				//   random-001/
   261  				//   ...
   262  				//   standard-000/
   263  				//   standard-001/
   264  				//   ...
   265  				dir := getRunDir(t, r.dir)
   266  				// subrunDirs contains the names of all dir's subdirectories.
   267  				subrunDirs := getDirs(t, dir)
   268  
   269  				mu.Lock()
   270  				defer mu.Unlock()
   271  				for _, subrunDir := range subrunDirs {
   272  					// Record the subrun as an initial state for the next version.
   273  					nextInitialStates = append(nextInitialStates, initialState{
   274  						path: filepath.Join(dir, subrunDir),
   275  						desc: fmt.Sprintf("sha=%s-seed=%d-opts=%s(%s)", vers.SHA, seed, subrunDir, s.String()),
   276  					})
   277  					histories = append(histories, filepath.Join(dir, subrunDir, "history"))
   278  				}
   279  			})
   280  		}
   281  	})
   282  	return histories, nextInitialStates, err
   283  }
   284  
   285  func fatalf(t testing.TB, fatalOnce *sync.Once, dir string, msg string, args ...interface{}) {
   286  	fatalOnce.Do(func() {
   287  		if artifactsDir == "" {
   288  			var err error
   289  			artifactsDir, err = os.Getwd()
   290  			require.NoError(t, err)
   291  		}
   292  		// When run with test parallelism, other subtests may still be running
   293  		// within subdirectories of `dir`. We copy instead of rename so that those
   294  		// substests don't also fail when we remove their files out from under them.
   295  		// Those additional failures would confuse the test output.
   296  		dst := filepath.Join(artifactsDir, filepath.Base(dir))
   297  		t.Logf("Copying test dir %q to %q.", dir, dst)
   298  		_, err := vfs.Clone(vfs.Default, vfs.Default, dir, dst, vfs.CloneTryLink)
   299  		if err != nil {
   300  			t.Error(err)
   301  		}
   302  		t.Fatalf(msg, args...)
   303  	})
   304  }
   305  
   306  type metamorphicTestRun struct {
   307  	seed           uint64
   308  	dir            string
   309  	vers           pebbleVersion
   310  	initialState   initialState
   311  	testBinaryPath string
   312  }
   313  
   314  func (r *metamorphicTestRun) run(ctx context.Context, output io.Writer) error {
   315  	args := []string{
   316  		"-test.run", "TestMeta$",
   317  		"-seed", strconv.FormatUint(r.seed, 10),
   318  		"-keep",
   319  		// Use an op-count distribution that includes a low lower bound, so that
   320  		// some intermediary versions do very little work besides opening the
   321  		// database. This helps exercise state from version n that survives to
   322  		// versions ≥ n+2.
   323  		"-ops", "uniform:1-10000",
   324  		// Explicitly specify the location of the _meta directory. In Cockroach
   325  		// CI when built using bazel, the subprocesses may be given a different
   326  		// current working directory than the one provided below. To ensure we
   327  		// can find this run's artifacts, explicitly pass the intended dir.
   328  		"-dir", filepath.Join(r.dir, "_meta"),
   329  	}
   330  	// Propagate the verbose flag, if necessary.
   331  	if testing.Verbose() {
   332  		args = append(args, "-test.v")
   333  	}
   334  	if r.initialState.path != "" {
   335  		args = append(args,
   336  			"--initial-state", r.initialState.path,
   337  			"--initial-state-desc", r.initialState.desc)
   338  	}
   339  	cmd := exec.CommandContext(ctx, r.testBinaryPath, args...)
   340  	cmd.Dir = r.dir
   341  	cmd.Stderr = output
   342  	cmd.Stdout = output
   343  
   344  	// Print the command itself before executing it.
   345  	if testing.Verbose() {
   346  		fmt.Fprintln(output, cmd)
   347  	}
   348  
   349  	return cmd.Run()
   350  }
   351  
   352  func (v pebbleVersion) String() string {
   353  	return fmt.Sprintf("%s,%s,%s", v.Label, v.SHA, v.TestBinaryPath)
   354  }
   355  
   356  // pebbleVersions implements flag.Value for the -version flag.
   357  type pebbleVersions []pebbleVersion
   358  
   359  var _ flag.Value = (*pebbleVersions)(nil)
   360  
   361  // String returns the SHAs of the versions.
   362  func (f *pebbleVersions) String() string {
   363  	var buf bytes.Buffer
   364  	for i, v := range *f {
   365  		if i > 0 {
   366  			fmt.Fprint(&buf, " ")
   367  		}
   368  		fmt.Fprintf(&buf, v.SHA)
   369  	}
   370  	return buf.String()
   371  }
   372  
   373  // Set is part of the flag.Value interface; it is called once for every
   374  // occurrence of the version flag.
   375  func (f *pebbleVersions) Set(value string) error {
   376  	// Expected format is `<label>,<sha>,<path>`.
   377  	fields := strings.FieldsFunc(value, func(r rune) bool { return r == ',' || unicode.IsSpace(r) })
   378  	if len(fields) != 3 {
   379  		return errors.Newf("unable to parse version %q", value)
   380  	}
   381  	*f = append(*f, pebbleVersion{
   382  		Label:          fields[0],
   383  		SHA:            fields[1],
   384  		TestBinaryPath: fields[2],
   385  	})
   386  	return nil
   387  }
   388  
   389  func getDirs(t testing.TB, dir string) (names []string) {
   390  	dirents, err := os.ReadDir(dir)
   391  	if err != nil {
   392  		t.Fatal(err)
   393  	}
   394  	for _, dirent := range dirents {
   395  		if dirent.IsDir() {
   396  			names = append(names, dirent.Name())
   397  		}
   398  	}
   399  	return names
   400  }
   401  
   402  func getRunDir(t testing.TB, dir string) string {
   403  	metaDir := filepath.Join(dir, "_meta")
   404  	dirs := getDirs(t, metaDir)
   405  	if len(dirs) != 1 {
   406  		t.Fatalf("expected 1 directory, found %d", len(dirs))
   407  	}
   408  	return filepath.Join(metaDir, dirs[0])
   409  }