github.com/dolthub/dolt/go@v0.40.5-0.20240520175717-68db7794bea6/performance/sysbench/testdef.go (about)

     1  // Copyright 2022 Dolthub, Inc.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package sysbench
    16  
    17  import (
    18  	"bytes"
    19  	"database/sql"
    20  	"fmt"
    21  	"log"
    22  	"math"
    23  	"os"
    24  	"os/exec"
    25  	"path"
    26  	"regexp"
    27  	"strconv"
    28  	"strings"
    29  	"testing"
    30  
    31  	"github.com/creasty/defaults"
    32  	"github.com/stretchr/testify/require"
    33  	yaml "gopkg.in/yaml.v3"
    34  
    35  	driver "github.com/dolthub/dolt/go/libraries/doltcore/dtestutils/sql_server_driver"
    36  )
    37  
    38  // TestDef is the top-level definition of tests to run.
    39  type TestDef struct {
    40  	Tests []Script `yaml:"tests"`
    41  }
    42  
    43  // Config specifies default sysbench script arguments
    44  type Config struct {
    45  	DbDriver  string `yaml:"dbDriver"`
    46  	Host      string `yaml:"host"`
    47  	Port      string `yaml:"port"`
    48  	User      string `yaml:"user"`
    49  	Password  string `yaml:"password"`
    50  	RandType  string `yaml:"randType"`
    51  	EventCnt  int    `yaml:"eventCnt"`
    52  	Time      int    `yaml:"time"`
    53  	Seed      int    `yaml:"seed"`
    54  	TableSize int    `yaml:"tableSize"`
    55  	Histogram bool   `yaml:"histogram"`
    56  	ScriptDir string `yaml:"scriptDir"`
    57  	Verbose   bool   `yaml:"verbose"`
    58  	Prepared  bool   `yaml:"prepared"`
    59  }
    60  
    61  func (c Config) WithScriptDir(dir string) Config {
    62  	c.ScriptDir = dir
    63  	return c
    64  }
    65  
    66  func (c Config) WithVerbose(v bool) Config {
    67  	c.Verbose = v
    68  	return c
    69  }
    70  
    71  func (c Config) WithPrepared(v bool) Config {
    72  	c.Prepared = v
    73  	return c
    74  }
    75  
    76  func (c Config) AsOpts() []string {
    77  	var ret []string
    78  	if c.DbDriver != "" {
    79  		ret = append(ret, fmt.Sprintf("--db-driver=%s", c.DbDriver))
    80  	}
    81  	if c.Host != "" {
    82  		ret = append(ret, fmt.Sprintf("--mysql-host=%s", c.Host))
    83  	}
    84  	if c.User != "" {
    85  		ret = append(ret, fmt.Sprintf("--mysql-user=%s", c.User))
    86  	}
    87  	if c.Port != "" {
    88  		ret = append(ret, fmt.Sprintf("--mysql-port=%s", c.Port))
    89  	}
    90  	if c.Password != "" {
    91  		ret = append(ret, fmt.Sprintf("--mysql-password=%s", c.Password))
    92  	}
    93  	if c.RandType != "" {
    94  		ret = append(ret, fmt.Sprintf("--rand-type=%s", c.RandType))
    95  	}
    96  	if c.EventCnt > 0 {
    97  		ret = append(ret, fmt.Sprintf("--events=%s", strconv.Itoa(c.EventCnt)))
    98  	}
    99  	if c.Time > 0 {
   100  		ret = append(ret, fmt.Sprintf("--time=%s", strconv.Itoa(c.Time)))
   101  	}
   102  	if c.Prepared {
   103  		ret = append(ret, fmt.Sprint("--db-ps-mode=auto"))
   104  	}
   105  	ret = append(ret,
   106  		fmt.Sprintf("--rand-seed=%s", strconv.Itoa(c.Seed)),
   107  		fmt.Sprintf("--table-size=%s", strconv.Itoa(c.TableSize)),
   108  		fmt.Sprintf("--histogram=%s", strconv.FormatBool(c.Histogram)))
   109  
   110  	return ret
   111  }
   112  
   113  // Script is a single test to run. The Repos and MultiRepos will be created, and
   114  // any Servers defined within them will be started. The interactions and
   115  // assertions defined in Conns will be run.
   116  type Script struct {
   117  	Name    string            `yaml:"name"`
   118  	Repos   []driver.TestRepo `yaml:"repos"`
   119  	Scripts []string          `yaml:"scripts"`
   120  
   121  	// Skip the entire test with this reason.
   122  	Skip string `yaml:"skip"`
   123  
   124  	Results   *Results
   125  	tmpdir    string
   126  	scriptDir string
   127  }
   128  
   129  func (s *Script) UnmarshalYAML(unmarshal func(interface{}) error) error {
   130  	defaults.Set(s)
   131  
   132  	type plain Script
   133  	if err := unmarshal((*plain)(s)); err != nil {
   134  		return err
   135  	}
   136  
   137  	return nil
   138  }
   139  
   140  func ParseTestsFile(path string) (TestDef, error) {
   141  	contents, err := os.ReadFile(path)
   142  	if err != nil {
   143  		return TestDef{}, err
   144  	}
   145  	dec := yaml.NewDecoder(bytes.NewReader(contents))
   146  	dec.KnownFields(true)
   147  	var res TestDef
   148  	err = dec.Decode(&res)
   149  	return res, err
   150  }
   151  
   152  func ParseConfig(path string) (Config, error) {
   153  	contents, err := os.ReadFile(path)
   154  	if err != nil {
   155  		return Config{}, err
   156  	}
   157  	dec := yaml.NewDecoder(bytes.NewReader(contents))
   158  	dec.KnownFields(true)
   159  	var res Config
   160  	err = dec.Decode(&res)
   161  	return res, err
   162  }
   163  
   164  func MakeRepo(rs driver.RepoStore, r driver.TestRepo) (driver.Repo, error) {
   165  	repo, err := rs.MakeRepo(r.Name)
   166  	if err != nil {
   167  		return driver.Repo{}, err
   168  	}
   169  	return repo, nil
   170  }
   171  
   172  func MakeServer(dc driver.DoltCmdable, s *driver.Server) (*driver.SqlServer, error) {
   173  	if s == nil {
   174  		return nil, nil
   175  	}
   176  	opts := []driver.SqlServerOpt{driver.WithArgs(s.Args...)}
   177  	if s.Port != 0 {
   178  		opts = append(opts, driver.WithPort(s.Port))
   179  	}
   180  	server, err := driver.StartSqlServer(dc, opts...)
   181  	if err != nil {
   182  		return nil, err
   183  	}
   184  
   185  	return server, nil
   186  }
   187  
   188  type Hist struct {
   189  	bins []float64
   190  	cnts []int
   191  	sum  float64
   192  	cnt  int
   193  	mn   float64
   194  	md   float64
   195  	v    float64
   196  }
   197  
   198  func newHist() *Hist {
   199  	return &Hist{bins: make([]float64, 0), cnts: make([]int, 0)}
   200  }
   201  
   202  // add assumes bins are passed in increasing order before
   203  // any statistics are evaluated
   204  func (h *Hist) add(bin float64, cnt int) {
   205  	if h.mn != 0 || h.v != 0 || h.md != 0 {
   206  		panic("tried to edit finished histogram")
   207  	}
   208  	h.sum += bin * float64(cnt)
   209  	h.cnt += cnt
   210  	h.bins = append(h.bins, bin)
   211  	h.cnts = append(h.cnts, cnt)
   212  }
   213  
   214  // mean is a lossy mean based on histogram bucket sizes
   215  func (h *Hist) mean() float64 {
   216  	if h.mn == 0 {
   217  		h.mn = math.Round(h.sum*1e3/(float64(h.cnt))) / 1e3
   218  	}
   219  	return h.mn
   220  
   221  }
   222  
   223  // median is a lossy median based on histogram bucket sizes
   224  func (h *Hist) median() float64 {
   225  	if h.md == 0 {
   226  		mid := h.cnt / 2
   227  		var i int
   228  		var cnt int
   229  		for cnt < mid {
   230  			cnt += h.cnts[i]
   231  			i++
   232  		}
   233  		h.md = h.bins[i]
   234  	}
   235  	return h.md
   236  
   237  }
   238  
   239  // variance is a lossy sum of least squares
   240  func (h *Hist) variance() float64 {
   241  	if h.v == 0 {
   242  		ss := 0.0
   243  		for i := range h.cnts {
   244  			ss += float64(h.cnts[i]) * math.Pow(h.bins[i]-h.mean(), 2)
   245  		}
   246  		h.v = math.Round(ss*1e3/(float64(h.cnt-1))) / 1e3
   247  	}
   248  	return h.v
   249  }
   250  
   251  var histRe = regexp.MustCompile(`([0-9]+\.+[0-9]+)\s+.+\s+([1-9][0-9]*)\n`)
   252  
   253  func (h *Hist) populate(buf []byte) error {
   254  	res := histRe.FindAllSubmatch(buf, -1)
   255  	var bin float64
   256  	var cnt int
   257  	var err error
   258  	if len(res) == 0 {
   259  		return fmt.Errorf("histogram not found")
   260  	}
   261  	for _, r := range res {
   262  		bin, err = strconv.ParseFloat(string(r[1]), 0)
   263  		if err != nil {
   264  			return fmt.Errorf("failed to parse bin: %s -> '%s' - '%s'; %s", r[0], r[1], r[2], err)
   265  		}
   266  		cnt, err = strconv.Atoi(string(r[2]))
   267  		if err != nil {
   268  			return fmt.Errorf("failed to parse cnt: %s -> '%s' - '%s'; %s", r[0], r[1], r[2], err)
   269  		}
   270  		h.add(bin, cnt)
   271  	}
   272  	return nil
   273  }
   274  
   275  func (r *Result) populateHistogram(buf []byte) error {
   276  	r.hist = newHist()
   277  	r.hist.populate(buf)
   278  
   279  	r.stddev = math.Sqrt(r.hist.variance())
   280  	r.median = r.hist.median()
   281  
   282  	var err error
   283  	{
   284  		timeRe := regexp.MustCompile(`total time:\s+([0-9][0-9]*\.[0-9]+)s\n`)
   285  		res := timeRe.FindSubmatch(buf)
   286  		if len(res) == 0 {
   287  			return fmt.Errorf("time not found")
   288  		}
   289  		time, err := strconv.ParseFloat(string(res[1]), 0)
   290  		if err != nil {
   291  			return fmt.Errorf("failed to parse time: %s -> '%s' ; %s", res[0], res[1], err)
   292  		}
   293  		r.time = math.Round(time*1e3) / 1e3
   294  	}
   295  	{
   296  		itersRe := regexp.MustCompile(`total number of events:\s+([1-9][0-9]*)\n`)
   297  		res := itersRe.FindSubmatch(buf)
   298  		if len(res) == 0 {
   299  			return fmt.Errorf("time not found")
   300  		}
   301  		r.iters, err = strconv.Atoi(string(res[1]))
   302  		if err != nil {
   303  			return fmt.Errorf("failed to parse bin: %s -> '%s'; %s", res[0], res[1], err)
   304  		}
   305  	}
   306  	{
   307  		avgRe := regexp.MustCompile(`avg:\s+([0-9][0-9]*\.[0-9]+)\n`)
   308  		res := avgRe.FindSubmatch(buf)
   309  		if len(res) == 0 {
   310  			return fmt.Errorf("avg not found")
   311  		}
   312  		r.avg, err = strconv.ParseFloat(string(res[1]), 0)
   313  		if err != nil {
   314  			return fmt.Errorf("failed to parse avg: %s -> '%s'; %s", res[0], res[1], err)
   315  		}
   316  	}
   317  	return nil
   318  }
   319  
   320  func (r *Result) populateAvg(buf []byte) error {
   321  	panic("TODO")
   322  }
   323  
   324  type Result struct {
   325  	server string
   326  	detail string
   327  	test   string
   328  
   329  	time  float64
   330  	iters int
   331  
   332  	hist   *Hist
   333  	avg    float64
   334  	median float64
   335  	stddev float64
   336  }
   337  
   338  func newResult(server, test, detail string) *Result {
   339  	return &Result{
   340  		server: server,
   341  		detail: detail,
   342  		test:   test,
   343  	}
   344  }
   345  
   346  func (r *Result) String() string {
   347  	b := &strings.Builder{}
   348  	fmt.Fprintf(b, "result:\n")
   349  	b.WriteString("result:\n")
   350  	if r.test != "" {
   351  		fmt.Fprintf(b, "- test: '%s'\n", r.test)
   352  	}
   353  	if r.detail != "" {
   354  		fmt.Fprintf(b, "- detail: '%s'\n", r.detail)
   355  	}
   356  	if r.server != "" {
   357  		fmt.Fprintf(b, "- server: '%s'\n", r.server)
   358  	}
   359  	fmt.Fprintf(b, "- time: %.3f\n", r.time)
   360  	fmt.Fprintf(b, "- iters: %d\n", r.iters)
   361  	fmt.Fprintf(b, "- mean: %.3f\n", r.hist.mean())
   362  	fmt.Fprintf(b, "- median: %.3f\n", r.median)
   363  	fmt.Fprintf(b, "- stddev: %.3f\n", r.stddev)
   364  	return b.String()
   365  }
   366  
   367  type Results struct {
   368  	Res []*Result
   369  }
   370  
   371  func (r *Results) Append(ir ...*Result) {
   372  	r.Res = append(r.Res, ir...)
   373  }
   374  
   375  func (r *Results) String() string {
   376  	b := strings.Builder{}
   377  	b.WriteString("Results:\n")
   378  	for _, x := range r.Res {
   379  		b.WriteString(x.String())
   380  	}
   381  	return b.String()
   382  }
   383  
   384  func (r *Results) SqlDump() string {
   385  	b := strings.Builder{}
   386  	b.WriteString(`CREATE TABLE IF NOT EXISTS sysbench_results (
   387    test_name varchar(64),
   388    detail varchar(64),
   389    server varchar(64),
   390    time double,
   391    iters int,
   392    avg double,
   393    median double,
   394    stdd double,
   395    primary key (test_name, detail, server)
   396  );
   397  `)
   398  
   399  	b.WriteString("insert into sysbench_results values\n")
   400  	for i, r := range r.Res {
   401  		if i > 0 {
   402  			b.WriteString(",\n  ")
   403  		}
   404  		b.WriteString(fmt.Sprintf(
   405  			"('%s', '%s', '%s', %.3f, %d, %.3f, %.3f, %.3f)",
   406  			r.test, r.detail, r.server, r.time, r.iters, r.avg, r.median, r.stddev))
   407  	}
   408  	b.WriteString(";\n")
   409  
   410  	return b.String()
   411  }
   412  
   413  func (test *Script) InitWithTmpDir(s string) {
   414  	test.tmpdir = s
   415  	test.Results = new(Results)
   416  
   417  }
   418  
   419  // Run executes an import configuration. Test parallelism makes
   420  // runtimes resulting from this method unsuitable for reporting.
   421  func (test *Script) Run(t *testing.T) {
   422  	if test.Skip != "" {
   423  		t.Skip(test.Skip)
   424  	}
   425  
   426  	conf, err := ParseConfig("testdata/default-config.yaml")
   427  	if err != nil {
   428  		require.NoError(t, err)
   429  	}
   430  
   431  	if _, err = os.Stat(test.scriptDir); err != nil {
   432  		require.NoError(t, err)
   433  	}
   434  
   435  	conf = conf.WithScriptDir(test.scriptDir)
   436  
   437  	tmpdir, err := os.MkdirTemp("", "repo-store-")
   438  	if err != nil {
   439  		require.NoError(t, err)
   440  	}
   441  
   442  	results := new(Results)
   443  	u, err := driver.NewDoltUser()
   444  	test.Results = results
   445  	test.InitWithTmpDir(tmpdir)
   446  	for _, r := range test.Repos {
   447  		var err error
   448  		switch {
   449  		case r.ExternalServer != nil:
   450  			panic("unsupported")
   451  		case r.Server != nil:
   452  			err = test.RunSqlServerTests(r, u, conf)
   453  		default:
   454  			panic("unsupported")
   455  		}
   456  		if err != nil {
   457  			require.NoError(t, err)
   458  		}
   459  		results.Append(test.Results.Res...)
   460  	}
   461  
   462  	fmt.Println(test.Results)
   463  }
   464  
   465  func modifyServerForImport(db *sql.DB) error {
   466  	_, err := db.Exec("CREATE DATABASE sbtest")
   467  	if err != nil {
   468  		return err
   469  	}
   470  	return nil
   471  }
   472  
   473  // RunExternalServerTests connects to a single externally provided server to run every test
   474  func (test *Script) RunExternalServerTests(repoName string, s *driver.ExternalServer, conf Config) error {
   475  	conf.Port = strconv.Itoa(s.Port)
   476  	conf.Password = s.Password
   477  	return test.IterSysbenchScripts(conf, test.Scripts, func(script string, prep, run, clean *exec.Cmd) error {
   478  		log.Printf("starting scipt: %s", script)
   479  
   480  		db, err := driver.ConnectDB(s.User, s.Password, s.Name, s.Host, s.Port, nil)
   481  		if err != nil {
   482  			return err
   483  		}
   484  		defer db.Close()
   485  		defer clean.Run()
   486  
   487  		buf := new(bytes.Buffer)
   488  		prep.Stdout = buf
   489  		if err := prep.Run(); err != nil {
   490  			log.Println(buf)
   491  			return err
   492  		}
   493  
   494  		buf = new(bytes.Buffer)
   495  		run.Stdout = buf
   496  		err = run.Run()
   497  		if err != nil {
   498  			log.Println(buf)
   499  			return err
   500  		}
   501  
   502  		// TODO scrape histogram data
   503  		r := newResult(repoName, script, test.Name)
   504  		if conf.Histogram {
   505  			r.populateHistogram(buf.Bytes())
   506  		} else {
   507  			r.populateAvg(buf.Bytes())
   508  		}
   509  		test.Results.Append(r)
   510  
   511  		return clean.Run()
   512  	})
   513  }
   514  
   515  // RunSqlServerTests creates a new repo and server for every import test.
   516  func (test *Script) RunSqlServerTests(repo driver.TestRepo, user driver.DoltUser, conf Config) error {
   517  	return test.IterSysbenchScripts(conf, test.Scripts, func(script string, prep, run, clean *exec.Cmd) error {
   518  		log.Printf("starting scipt: %s", script)
   519  		//make a new server for every test
   520  		server, err := newServer(user, repo, conf)
   521  		if err != nil {
   522  			return err
   523  		}
   524  		defer server.GracefulStop()
   525  
   526  		db, err := server.DB(driver.Connection{User: "root", Pass: ""})
   527  		if err != nil {
   528  			return err
   529  		}
   530  
   531  		err = modifyServerForImport(db)
   532  		if err != nil {
   533  			return err
   534  		}
   535  
   536  		if err := prep.Run(); err != nil {
   537  			return err
   538  		}
   539  
   540  		buf := new(bytes.Buffer)
   541  		run.Stdout = buf
   542  		err = run.Run()
   543  		if err != nil {
   544  			log.Println(buf)
   545  			return err
   546  		}
   547  
   548  		// TODO scrape histogram data
   549  		r := newResult(repo.Name, script, test.Name)
   550  		if conf.Histogram {
   551  			r.populateHistogram(buf.Bytes())
   552  		} else {
   553  			r.populateAvg(buf.Bytes())
   554  		}
   555  		test.Results.Append(r)
   556  
   557  		//if conf.Verbose {
   558  		//	return nil
   559  		//}
   560  		return clean.Run()
   561  	})
   562  }
   563  
   564  func newServer(u driver.DoltUser, r driver.TestRepo, conf Config) (*driver.SqlServer, error) {
   565  	rs, err := u.MakeRepoStore()
   566  	if err != nil {
   567  		return nil, err
   568  	}
   569  	// start dolt server
   570  	repo, err := MakeRepo(rs, r)
   571  	if err != nil {
   572  		return nil, err
   573  	}
   574  	if conf.Verbose {
   575  		log.Printf("database at: '%s'", repo.Dir)
   576  	}
   577  	server, err := MakeServer(repo, r.Server)
   578  	if err != nil {
   579  		return nil, err
   580  	}
   581  	if server != nil {
   582  		server.DBName = r.Name
   583  	}
   584  	return server, nil
   585  }
   586  
   587  const luaExt = ".lua"
   588  
   589  // IterSysbenchScripts returns 3 executable commands for the given script path: prepare, run, cleanup
   590  func (test *Script) IterSysbenchScripts(conf Config, scripts []string, cb func(name string, prep, run, clean *exec.Cmd) error) error {
   591  	newCmd := func(command, script string) *exec.Cmd {
   592  		cmd := exec.Command("sysbench")
   593  		cmd.Args = append(cmd.Args, conf.AsOpts()...)
   594  		cmd.Args = append(cmd.Args, script, command)
   595  		cmd.Stdout = os.Stdout
   596  		cmd.Stderr = os.Stderr
   597  		return cmd
   598  	}
   599  
   600  	for _, script := range scripts {
   601  		p := script
   602  		if strings.HasSuffix(script, luaExt) {
   603  			p = path.Join(conf.ScriptDir, script)
   604  			if _, err := os.Stat(p); err != nil {
   605  				return fmt.Errorf("failed to run script: '%s'", err)
   606  			}
   607  		}
   608  		prep := newCmd("prepare", p)
   609  		run := newCmd("run", p)
   610  		clean := newCmd("cleanup", p)
   611  		if err := cb(script, prep, run, clean); err != nil {
   612  			return err
   613  		}
   614  	}
   615  	return nil
   616  }
   617  
   618  func RunTestsFile(t *testing.T, path, scriptDir string) {
   619  	def, err := ParseTestsFile(path)
   620  	require.NoError(t, err)
   621  	for _, test := range def.Tests {
   622  		test.scriptDir = scriptDir
   623  		t.Run(test.Name, test.Run)
   624  	}
   625  }