github.com/cockroachdb/pebble@v0.0.0-20231214172447-ab4952c5f87b/metamorphic/meta.go (about) 1 // Copyright 2023 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 metamorphic provides a testing framework for running randomized tests 6 // over multiple Pebble databases with varying configurations. Logically 7 // equivalent operations should result in equivalent output across all 8 // configurations. 9 package metamorphic 10 11 import ( 12 "context" 13 "fmt" 14 "io" 15 "os" 16 "os/exec" 17 "path" 18 "path/filepath" 19 "regexp" 20 "sort" 21 "strconv" 22 "testing" 23 "time" 24 25 "github.com/cockroachdb/pebble/internal/base" 26 "github.com/cockroachdb/pebble/internal/dsl" 27 "github.com/cockroachdb/pebble/internal/randvar" 28 "github.com/cockroachdb/pebble/internal/testkeys" 29 "github.com/cockroachdb/pebble/vfs" 30 "github.com/cockroachdb/pebble/vfs/errorfs" 31 "github.com/pmezard/go-difflib/difflib" 32 "github.com/stretchr/testify/require" 33 "golang.org/x/exp/rand" 34 "golang.org/x/sync/errgroup" 35 ) 36 37 type runAndCompareOptions struct { 38 seed uint64 39 ops randvar.Static 40 previousOpsPath string 41 initialStatePath string 42 initialStateDesc string 43 traceFile string 44 innerBinary string 45 mutateTestOptions []func(*TestOptions) 46 customRuns map[string]string 47 numInstances int 48 runOnceOptions 49 } 50 51 // A RunOption configures the behavior of RunAndCompare. 52 type RunOption interface { 53 apply(*runAndCompareOptions) 54 } 55 56 // Seed configures generation to use the provided seed. Seed may be used to 57 // deterministically reproduce the same run. 58 type Seed uint64 59 60 func (s Seed) apply(ro *runAndCompareOptions) { ro.seed = uint64(s) } 61 62 // ExtendPreviousRun configures RunAndCompare to use the output of a previous 63 // metamorphic test run to seed the this run. It's used in the crossversion 64 // metamorphic tests, in which a data directory is upgraded through multiple 65 // versions of Pebble, exercising upgrade code paths and cross-version 66 // compatibility. 67 // 68 // The opsPath should be the filesystem path to the ops file containing the 69 // operations run within the previous iteration of the metamorphic test. It's 70 // used to inform operation generation to prefer using keys used in the previous 71 // run, which are therefore more likely to be "interesting." 72 // 73 // The initialStatePath argument should be the filesystem path to the data 74 // directory containing the database where the previous run of the metamorphic 75 // test left off. 76 // 77 // The initialStateDesc argument is presentational and should hold a 78 // human-readable description of the initial state. 79 func ExtendPreviousRun(opsPath, initialStatePath, initialStateDesc string) RunOption { 80 return closureOpt(func(ro *runAndCompareOptions) { 81 ro.previousOpsPath = opsPath 82 ro.initialStatePath = initialStatePath 83 ro.initialStateDesc = initialStateDesc 84 }) 85 } 86 87 var ( 88 // UseDisk configures RunAndCompare to use the physical filesystem for all 89 // generated runs. 90 UseDisk = closureOpt(func(ro *runAndCompareOptions) { 91 ro.mutateTestOptions = append(ro.mutateTestOptions, func(to *TestOptions) { to.useDisk = true }) 92 }) 93 // UseInMemory configures RunAndCompare to use an in-memory virtual 94 // filesystem for all generated runs. 95 UseInMemory = closureOpt(func(ro *runAndCompareOptions) { 96 ro.mutateTestOptions = append(ro.mutateTestOptions, func(to *TestOptions) { to.useDisk = false }) 97 }) 98 ) 99 100 // OpCount configures the random variable for the number of operations to 101 // generate. 102 func OpCount(rv randvar.Static) RunOption { 103 return closureOpt(func(ro *runAndCompareOptions) { ro.ops = rv }) 104 } 105 106 // RuntimeTrace configures each test run to collect a runtime trace and output 107 // it with the provided filename. 108 func RuntimeTrace(name string) RunOption { 109 return closureOpt(func(ro *runAndCompareOptions) { ro.traceFile = name }) 110 } 111 112 // InnerBinary configures the binary that is called for each run. If not 113 // specified, this binary (os.Args[0]) is called. 114 func InnerBinary(path string) RunOption { 115 return closureOpt(func(ro *runAndCompareOptions) { ro.innerBinary = path }) 116 } 117 118 // ParseCustomTestOption adds support for parsing the provided CustomOption from 119 // OPTIONS files serialized by the metamorphic tests. This RunOption alone does 120 // not cause the metamorphic tests to run with any variant of the provided 121 // CustomOption set. 122 func ParseCustomTestOption(name string, parseFn func(value string) (CustomOption, bool)) RunOption { 123 return closureOpt(func(ro *runAndCompareOptions) { ro.customOptionParsers[name] = parseFn }) 124 } 125 126 // AddCustomRun adds an additional run of the metamorphic tests, using the 127 // provided OPTIONS file contents. The default options will be used, except 128 // those options that are overriden by the provided OPTIONS string. 129 func AddCustomRun(name string, serializedOptions string) RunOption { 130 return closureOpt(func(ro *runAndCompareOptions) { ro.customRuns[name] = serializedOptions }) 131 } 132 133 type closureOpt func(*runAndCompareOptions) 134 135 func (f closureOpt) apply(ro *runAndCompareOptions) { f(ro) } 136 137 // RunAndCompare runs the metamorphic tests, using the provided root directory 138 // to hold test data. 139 func RunAndCompare(t *testing.T, rootDir string, rOpts ...RunOption) { 140 runOpts := runAndCompareOptions{ 141 ops: randvar.NewUniform(1000, 10000), 142 customRuns: map[string]string{}, 143 runOnceOptions: runOnceOptions{ 144 customOptionParsers: map[string]func(string) (CustomOption, bool){}, 145 }, 146 } 147 for _, o := range rOpts { 148 o.apply(&runOpts) 149 } 150 if runOpts.seed == 0 { 151 runOpts.seed = uint64(time.Now().UnixNano()) 152 } 153 154 require.NoError(t, os.MkdirAll(rootDir, 0755)) 155 metaDir, err := os.MkdirTemp(rootDir, time.Now().Format("060102-150405.000")) 156 require.NoError(t, err) 157 require.NoError(t, os.MkdirAll(metaDir, 0755)) 158 defer func() { 159 if !t.Failed() && !runOpts.keep { 160 _ = os.RemoveAll(metaDir) 161 } 162 }() 163 164 rng := rand.New(rand.NewSource(runOpts.seed)) 165 opCount := runOpts.ops.Uint64(rng) 166 167 // Generate a new set of random ops, writing them to <dir>/ops. These will be 168 // read by the child processes when performing a test run. 169 km := newKeyManager(runOpts.numInstances) 170 cfg := presetConfigs[rng.Intn(len(presetConfigs))] 171 if runOpts.previousOpsPath != "" { 172 // During cross-version testing, we load keys from an `ops` file 173 // produced by a metamorphic test run of an earlier Pebble version. 174 // Seeding the keys ensure we generate interesting operations, including 175 // ones with key shadowing, merging, etc. 176 opsPath := filepath.Join(filepath.Dir(filepath.Clean(runOpts.previousOpsPath)), "ops") 177 opsData, err := os.ReadFile(opsPath) 178 require.NoError(t, err) 179 ops, err := parse(opsData, parserOpts{}) 180 require.NoError(t, err) 181 loadPrecedingKeys(t, ops, &cfg, km) 182 } 183 if runOpts.numInstances > 1 { 184 // The multi-instance variant does not support all operations yet. 185 // 186 // TODO(bilal): Address this and use the default configs. 187 cfg = multiInstancePresetConfig 188 cfg.numInstances = runOpts.numInstances 189 } 190 ops := generate(rng, opCount, cfg, km) 191 opsPath := filepath.Join(metaDir, "ops") 192 formattedOps := formatOps(ops) 193 require.NoError(t, os.WriteFile(opsPath, []byte(formattedOps), 0644)) 194 195 // runOptions performs a particular test run with the specified options. The 196 // options are written to <run-dir>/OPTIONS and a child process is created to 197 // actually execute the test. 198 runOptions := func(t *testing.T, opts *TestOptions) { 199 if opts.Opts.Cache != nil { 200 defer opts.Opts.Cache.Unref() 201 } 202 for _, fn := range runOpts.mutateTestOptions { 203 fn(opts) 204 } 205 runDir := filepath.Join(metaDir, path.Base(t.Name())) 206 require.NoError(t, os.MkdirAll(runDir, 0755)) 207 208 optionsPath := filepath.Join(runDir, "OPTIONS") 209 optionsStr := optionsToString(opts) 210 require.NoError(t, os.WriteFile(optionsPath, []byte(optionsStr), 0644)) 211 212 args := []string{ 213 "-keep=" + fmt.Sprint(runOpts.keep), 214 "-run-dir=" + runDir, 215 "-test.run=" + t.Name() + "$", 216 } 217 if runOpts.numInstances > 1 { 218 args = append(args, "--num-instances="+strconv.Itoa(runOpts.numInstances)) 219 } 220 if runOpts.traceFile != "" { 221 args = append(args, "-test.trace="+filepath.Join(runDir, runOpts.traceFile)) 222 } 223 224 binary := os.Args[0] 225 if runOpts.innerBinary != "" { 226 binary = runOpts.innerBinary 227 } 228 cmd := exec.Command(binary, args...) 229 out, err := cmd.CombinedOutput() 230 if err != nil { 231 t.Fatalf(` 232 ===== SEED ===== 233 %d 234 ===== ERR ===== 235 %v 236 ===== OUT ===== 237 %s 238 ===== OPTIONS ===== 239 %s 240 ===== OPS ===== 241 %s 242 ===== HISTORY ===== 243 %s`, runOpts.seed, err, out, optionsStr, formattedOps, readFile(filepath.Join(runDir, "history"))) 244 } 245 } 246 247 var names []string 248 options := map[string]*TestOptions{} 249 250 // Create the standard options. 251 for i, opts := range standardOptions() { 252 name := fmt.Sprintf("standard-%03d", i) 253 names = append(names, name) 254 options[name] = opts 255 } 256 257 // Create the custom option runs, if any. 258 for name, customOptsStr := range runOpts.customRuns { 259 options[name] = defaultTestOptions() 260 if err := parseOptions(options[name], customOptsStr, runOpts.customOptionParsers); err != nil { 261 t.Fatalf("custom opts %q: %s", name, err) 262 } 263 } 264 // Sort the custom options names for determinism (they're currently in 265 // random order from map iteration). 266 sort.Strings(names[len(names)-len(runOpts.customRuns):]) 267 268 // Create random options. We make an arbitrary choice to run with as many 269 // random options as we have standard options. 270 nOpts := len(options) 271 for i := 0; i < nOpts; i++ { 272 name := fmt.Sprintf("random-%03d", i) 273 names = append(names, name) 274 opts := randomOptions(rng, runOpts.customOptionParsers) 275 options[name] = opts 276 } 277 278 // If the user provided the path to an initial database state to use, update 279 // all the options to pull from it. 280 if runOpts.initialStatePath != "" { 281 for _, o := range options { 282 var err error 283 o.initialStatePath, err = filepath.Abs(runOpts.initialStatePath) 284 require.NoError(t, err) 285 o.initialStateDesc = runOpts.initialStateDesc 286 } 287 } 288 289 // Run the options. 290 t.Run("execution", func(t *testing.T) { 291 for _, name := range names { 292 name := name 293 t.Run(name, func(t *testing.T) { 294 t.Parallel() 295 runOptions(t, options[name]) 296 }) 297 } 298 }) 299 // NB: The above 'execution' subtest will not complete until all of the 300 // individual execution/ subtests have completed. The grouping within the 301 // `execution` subtest ensures all the histories are available when we 302 // proceed to comparing against the base history. 303 304 // Don't bother comparing output if we've already failed. 305 if t.Failed() { 306 return 307 } 308 309 t.Run("compare", func(t *testing.T) { 310 getHistoryPath := func(name string) string { 311 return filepath.Join(metaDir, name, "history") 312 } 313 314 base := readHistory(t, getHistoryPath(names[0])) 315 base = reorderHistory(base) 316 for i := 1; i < len(names); i++ { 317 t.Run(names[i], func(t *testing.T) { 318 lines := readHistory(t, getHistoryPath(names[i])) 319 lines = reorderHistory(lines) 320 diff := difflib.UnifiedDiff{ 321 A: base, 322 B: lines, 323 Context: 5, 324 } 325 text, err := difflib.GetUnifiedDiffString(diff) 326 require.NoError(t, err) 327 if text != "" { 328 // NB: We force an exit rather than using t.Fatal because the latter 329 // will run another instance of the test if -count is specified, while 330 // we're happy to exit on the first failure. 331 optionsStrA := optionsToString(options[names[0]]) 332 optionsStrB := optionsToString(options[names[i]]) 333 334 fmt.Printf(` 335 ===== SEED ===== 336 %d 337 ===== DIFF ===== 338 %s/{%s,%s} 339 %s 340 ===== OPTIONS %s ===== 341 %s 342 ===== OPTIONS %s ===== 343 %s 344 ===== OPS ===== 345 %s 346 `, runOpts.seed, metaDir, names[0], names[i], text, names[0], optionsStrA, names[i], optionsStrB, formattedOps) 347 os.Exit(1) 348 } 349 }) 350 } 351 }) 352 } 353 354 type runOnceOptions struct { 355 keep bool 356 maxThreads int 357 errorRate float64 358 failRegexp *regexp.Regexp 359 numInstances int 360 customOptionParsers map[string]func(string) (CustomOption, bool) 361 } 362 363 // A RunOnceOption configures the behavior of a single run of the metamorphic 364 // tests. 365 type RunOnceOption interface { 366 applyOnce(*runOnceOptions) 367 } 368 369 // KeepData keeps the database directory, even on successful runs. If the test 370 // used an in-memory filesystem, the in-memory filesystem will be persisted to 371 // the run directory. 372 type KeepData struct{} 373 374 func (KeepData) apply(ro *runAndCompareOptions) { ro.keep = true } 375 func (KeepData) applyOnce(ro *runOnceOptions) { ro.keep = true } 376 377 // InjectErrorsRate configures the run to inject errors into read-only 378 // filesystem operations and retry injected errors. 379 type InjectErrorsRate float64 380 381 func (r InjectErrorsRate) apply(ro *runAndCompareOptions) { ro.errorRate = float64(r) } 382 func (r InjectErrorsRate) applyOnce(ro *runOnceOptions) { ro.errorRate = float64(r) } 383 384 // MaxThreads sets an upper bound on the number of parallel execution threads 385 // during replay. 386 type MaxThreads int 387 388 func (m MaxThreads) apply(ro *runAndCompareOptions) { ro.maxThreads = int(m) } 389 func (m MaxThreads) applyOnce(ro *runOnceOptions) { ro.maxThreads = int(m) } 390 391 // FailOnMatch configures the run to fail immediately if the history matches the 392 // provided regular expression. 393 type FailOnMatch struct { 394 *regexp.Regexp 395 } 396 397 func (f FailOnMatch) apply(ro *runAndCompareOptions) { ro.failRegexp = f.Regexp } 398 func (f FailOnMatch) applyOnce(ro *runOnceOptions) { ro.failRegexp = f.Regexp } 399 400 // MultiInstance configures the number of pebble instances to create. 401 type MultiInstance int 402 403 func (m MultiInstance) apply(ro *runAndCompareOptions) { ro.numInstances = int(m) } 404 func (m MultiInstance) applyOnce(ro *runOnceOptions) { ro.numInstances = int(m) } 405 406 // RunOnce performs one run of the metamorphic tests. RunOnce expects the 407 // directory named by `runDir` to already exist and contain an `OPTIONS` file 408 // containing the test run's configuration. The history of the run is persisted 409 // to a file at the path `historyPath`. 410 // 411 // The `seed` parameter is not functional; it's used for context in logging. 412 func RunOnce(t TestingT, runDir string, seed uint64, historyPath string, rOpts ...RunOnceOption) { 413 runOpts := runOnceOptions{ 414 customOptionParsers: map[string]func(string) (CustomOption, bool){}, 415 } 416 for _, o := range rOpts { 417 o.applyOnce(&runOpts) 418 } 419 420 opsPath := filepath.Join(filepath.Dir(filepath.Clean(runDir)), "ops") 421 opsData, err := os.ReadFile(opsPath) 422 require.NoError(t, err) 423 424 ops, err := parse(opsData, parserOpts{}) 425 require.NoError(t, err) 426 _ = ops 427 428 optionsPath := filepath.Join(runDir, "OPTIONS") 429 optionsData, err := os.ReadFile(optionsPath) 430 require.NoError(t, err) 431 432 // NB: It's important to use defaultTestOptions() here as the base into 433 // which we parse the serialized options. It contains the relevant defaults, 434 // like the appropriate block-property collectors. 435 testOpts := defaultTestOptions() 436 opts := testOpts.Opts 437 require.NoError(t, parseOptions(testOpts, string(optionsData), runOpts.customOptionParsers)) 438 439 // Always use our custom comparer which provides a Split method, splitting 440 // keys at the trailing '@'. 441 opts.Comparer = testkeys.Comparer 442 // Use an archive cleaner to ease post-mortem debugging. 443 opts.Cleaner = base.ArchiveCleaner{} 444 445 // Set up the filesystem to use for the test. Note that by default we use an 446 // in-memory FS. 447 if testOpts.useDisk { 448 opts.FS = vfs.Default 449 require.NoError(t, os.RemoveAll(opts.FS.PathJoin(runDir, "data"))) 450 } else { 451 opts.Cleaner = base.ArchiveCleaner{} 452 if testOpts.strictFS { 453 opts.FS = vfs.NewStrictMem() 454 } else { 455 opts.FS = vfs.NewMem() 456 } 457 } 458 opts.WithFSDefaults() 459 460 threads := testOpts.threads 461 if runOpts.maxThreads < threads { 462 threads = runOpts.maxThreads 463 } 464 465 dir := opts.FS.PathJoin(runDir, "data") 466 // Set up the initial database state if configured to start from a non-empty 467 // database. By default tests start from an empty database, but split 468 // version testing may configure a previous metamorphic tests's database 469 // state as the initial state. 470 if testOpts.initialStatePath != "" { 471 require.NoError(t, setupInitialState(dir, testOpts)) 472 } 473 474 // Wrap the filesystem with one that will inject errors into read 475 // operations with *errorRate probability. 476 opts.FS = errorfs.Wrap(opts.FS, errorfs.ErrInjected.If( 477 dsl.And[errorfs.Op](errorfs.Reads, errorfs.Randomly(runOpts.errorRate, int64(seed))), 478 )) 479 480 if opts.WALDir != "" { 481 if runOpts.numInstances > 1 { 482 // TODO(bilal): Allow opts to diverge on a per-instance basis, and use 483 // that to set unique WAL dirs for all instances in multi-instance mode. 484 opts.WALDir = "" 485 } else { 486 opts.WALDir = opts.FS.PathJoin(runDir, opts.WALDir) 487 } 488 } 489 490 historyFile, err := os.Create(historyPath) 491 require.NoError(t, err) 492 defer historyFile.Close() 493 writers := []io.Writer{historyFile} 494 495 if testing.Verbose() { 496 writers = append(writers, os.Stdout) 497 } 498 h := newHistory(runOpts.failRegexp, writers...) 499 500 m := newTest(ops) 501 require.NoError(t, m.init(h, dir, testOpts, runOpts.numInstances)) 502 503 if threads <= 1 { 504 for m.step(h) { 505 if err := h.Error(); err != nil { 506 fmt.Fprintf(os.Stderr, "Seed: %d\n", seed) 507 fmt.Fprintln(os.Stderr, err) 508 m.maybeSaveData() 509 os.Exit(1) 510 } 511 } 512 } else { 513 eg, ctx := errgroup.WithContext(context.Background()) 514 for t := 0; t < threads; t++ { 515 t := t // bind loop var to scope 516 eg.Go(func() error { 517 for idx := 0; idx < len(m.ops); idx++ { 518 // Skip any operations whose receiver object hashes to a 519 // different thread. All operations with the same receiver 520 // are performed from the same thread. This goroutine is 521 // only responsible for executing operations that hash to 522 // `t`. 523 if hashThread(m.ops[idx].receiver(), threads) != t { 524 continue 525 } 526 527 // Some operations have additional synchronization 528 // dependencies. If this operation has any, wait for its 529 // dependencies to complete before executing. 530 for _, waitOnIdx := range m.opsWaitOn[idx] { 531 select { 532 case <-ctx.Done(): 533 // Exit if some other thread already errored out. 534 return ctx.Err() 535 case <-m.opsDone[waitOnIdx]: 536 } 537 } 538 539 m.ops[idx].run(m, h.recorder(t, idx)) 540 541 // If this operation has a done channel, close it so that 542 // other operations that synchronize on this operation know 543 // that it's been completed. 544 if ch := m.opsDone[idx]; ch != nil { 545 close(ch) 546 } 547 548 if err := h.Error(); err != nil { 549 return err 550 } 551 } 552 return nil 553 }) 554 } 555 if err := eg.Wait(); err != nil { 556 fmt.Fprintf(os.Stderr, "Seed: %d\n", seed) 557 fmt.Fprintln(os.Stderr, err) 558 m.maybeSaveData() 559 os.Exit(1) 560 } 561 } 562 563 if runOpts.keep && !testOpts.useDisk { 564 m.maybeSaveData() 565 } 566 } 567 568 func hashThread(objID objID, numThreads int) int { 569 // Fibonacci hash https://probablydance.com/2018/06/16/fibonacci-hashing-the-optimization-that-the-world-forgot-or-a-better-alternative-to-integer-modulo/ 570 return int((11400714819323198485 * uint64(objID)) % uint64(numThreads)) 571 } 572 573 // Compare runs the metamorphic tests in the provided runDirs and compares their 574 // histories. 575 func Compare(t TestingT, rootDir string, seed uint64, runDirs []string, rOpts ...RunOnceOption) { 576 historyPaths := make([]string, len(runDirs)) 577 for i := 0; i < len(runDirs); i++ { 578 historyPath := filepath.Join(rootDir, runDirs[i]+"-"+time.Now().Format("060102-150405.000")) 579 runDirs[i] = filepath.Join(rootDir, runDirs[i]) 580 _ = os.Remove(historyPath) 581 historyPaths[i] = historyPath 582 } 583 defer func() { 584 for _, path := range historyPaths { 585 _ = os.Remove(path) 586 } 587 }() 588 589 for i, runDir := range runDirs { 590 RunOnce(t, runDir, seed, historyPaths[i], rOpts...) 591 } 592 593 if t.Failed() { 594 return 595 } 596 597 i, diff := CompareHistories(t, historyPaths) 598 if i != 0 { 599 fmt.Printf(` 600 ===== DIFF ===== 601 %s/{%s,%s} 602 %s 603 `, rootDir, runDirs[0], runDirs[i], diff) 604 os.Exit(1) 605 } 606 } 607 608 // TestingT is an interface wrapper around *testing.T 609 type TestingT interface { 610 require.TestingT 611 Failed() bool 612 } 613 614 func readFile(path string) string { 615 history, err := os.ReadFile(path) 616 if err != nil { 617 return fmt.Sprintf("err: %v", err) 618 } 619 620 return string(history) 621 }