Files
dolt/go/performance/sysbench/testdef.go

607 lines
14 KiB
Go

// Copyright 2022 Dolthub, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package sysbench
import (
"bytes"
"database/sql"
"fmt"
"log"
"math"
"os"
"os/exec"
"path"
"regexp"
"strconv"
"strings"
"testing"
"github.com/creasty/defaults"
"github.com/stretchr/testify/require"
yaml "gopkg.in/yaml.v3"
driver "github.com/dolthub/dolt/go/libraries/doltcore/dtestutils/sql_server_driver"
)
// TestDef is the top-level definition of tests to run.
type TestDef struct {
Tests []Script `yaml:"tests"`
}
// Config specifies default sysbench script arguments
type Config struct {
DbDriver string `yaml:"dbDriver"`
Host string `yaml:"host"`
Port string `yaml:"port"`
User string `yaml:"user"`
Password string `yaml:"password"`
RandType string `yaml:"randType"`
EventCnt int `yaml:"eventCnt"`
Time int `yaml:"time"`
Seed int `yaml:"seed"`
TableSize int `yaml:"tableSize"`
Histogram bool `yaml:"histogram"`
ScriptDir string `yaml:"scriptDir"`
Verbose bool `yaml:"verbose"`
}
func (c Config) WithScriptDir(dir string) Config {
c.ScriptDir = dir
return c
}
func (c Config) WithVerbose(v bool) Config {
c.Verbose = v
return c
}
func (c Config) AsOpts() []string {
var ret []string
if c.DbDriver != "" {
ret = append(ret, fmt.Sprintf("--db-driver=%s", c.DbDriver))
}
if c.Host != "" {
ret = append(ret, fmt.Sprintf("--mysql-host=%s", c.Host))
}
if c.User != "" {
ret = append(ret, fmt.Sprintf("--mysql-user=%s", c.User))
}
if c.Port != "" {
ret = append(ret, fmt.Sprintf("--mysql-port=%s", c.Port))
}
if c.Password != "" {
ret = append(ret, fmt.Sprintf("--mysql-password=%s", c.Password))
}
if c.RandType != "" {
ret = append(ret, fmt.Sprintf("--rand-type=%s", c.RandType))
}
if c.EventCnt > 0 {
ret = append(ret, fmt.Sprintf("--events=%s", strconv.Itoa(c.EventCnt)))
}
if c.Time > 0 {
ret = append(ret, fmt.Sprintf("--time=%s", strconv.Itoa(c.Time)))
}
ret = append(ret,
fmt.Sprintf("--rand-seed=%s", strconv.Itoa(c.Seed)),
fmt.Sprintf("--table-size=%s", strconv.Itoa(c.TableSize)),
fmt.Sprintf("--histogram=%s", strconv.FormatBool(c.Histogram)))
return ret
}
// Script is a single test to run. The Repos and MultiRepos will be created, and
// any Servers defined within them will be started. The interactions and
// assertions defined in Conns will be run.
type Script struct {
Name string `yaml:"name"`
Repos []driver.TestRepo `yaml:"repos"`
Scripts []string `yaml:"scripts"`
// Skip the entire test with this reason.
Skip string `yaml:"skip"`
Results *Results
tmpdir string
scriptDir string
}
func (s *Script) UnmarshalYAML(unmarshal func(interface{}) error) error {
defaults.Set(s)
type plain Script
if err := unmarshal((*plain)(s)); err != nil {
return err
}
return nil
}
func ParseTestsFile(path string) (TestDef, error) {
contents, err := os.ReadFile(path)
if err != nil {
return TestDef{}, err
}
dec := yaml.NewDecoder(bytes.NewReader(contents))
dec.KnownFields(true)
var res TestDef
err = dec.Decode(&res)
return res, err
}
func ParseConfig(path string) (Config, error) {
contents, err := os.ReadFile(path)
if err != nil {
return Config{}, err
}
dec := yaml.NewDecoder(bytes.NewReader(contents))
dec.KnownFields(true)
var res Config
err = dec.Decode(&res)
return res, err
}
func MakeRepo(rs driver.RepoStore, r driver.TestRepo) (driver.Repo, error) {
repo, err := rs.MakeRepo(r.Name)
if err != nil {
return driver.Repo{}, err
}
return repo, nil
}
func MakeServer(dc driver.DoltCmdable, s *driver.Server) (*driver.SqlServer, error) {
if s == nil {
return nil, nil
}
opts := []driver.SqlServerOpt{driver.WithArgs(s.Args...)}
if s.Port != 0 {
opts = append(opts, driver.WithPort(s.Port))
}
server, err := driver.StartSqlServer(dc, opts...)
if err != nil {
return nil, err
}
return server, nil
}
type Hist struct {
bins []float64
cnts []int
sum float64
cnt int
mn float64
md float64
v float64
}
func newHist() *Hist {
return &Hist{bins: make([]float64, 0), cnts: make([]int, 0)}
}
// add assumes bins are passed in increasing order before
// any statistics are evaluated
func (h *Hist) add(bin float64, cnt int) {
if h.mn != 0 || h.v != 0 || h.md != 0 {
panic("tried to edit finished histogram")
}
h.sum += bin * float64(cnt)
h.cnt += cnt
h.bins = append(h.bins, bin)
h.cnts = append(h.cnts, cnt)
}
// mean is a lossy mean based on histogram bucket sizes
func (h *Hist) mean() float64 {
if h.mn == 0 {
h.mn = math.Round(h.sum*1e3/(float64(h.cnt))) / 1e3
}
return h.mn
}
// median is a lossy median based on histogram bucket sizes
func (h *Hist) median() float64 {
if h.md == 0 {
mid := h.cnt / 2
var i int
var cnt int
for cnt < mid {
cnt += h.cnts[i]
i++
}
h.md = h.bins[i]
}
return h.md
}
// variance is a lossy sum of least squares
func (h *Hist) variance() float64 {
if h.v == 0 {
ss := 0.0
for i := range h.cnts {
ss += float64(h.cnts[i]) * math.Pow(h.bins[i]-h.mean(), 2)
}
h.v = math.Round(ss*1e3/(float64(h.cnt-1))) / 1e3
}
return h.v
}
var histRe = regexp.MustCompile(`([0-9]+\.+[0-9]+)\s+.+\s+([1-9][0-9]*)\n`)
func (h *Hist) populate(buf []byte) error {
res := histRe.FindAllSubmatch(buf, -1)
var bin float64
var cnt int
var err error
if len(res) == 0 {
return fmt.Errorf("histogram not found")
}
for _, r := range res {
bin, err = strconv.ParseFloat(string(r[1]), 0)
if err != nil {
return fmt.Errorf("failed to parse bin: %s -> '%s' - '%s'; %s", r[0], r[1], r[2], err)
}
cnt, err = strconv.Atoi(string(r[2]))
if err != nil {
return fmt.Errorf("failed to parse cnt: %s -> '%s' - '%s'; %s", r[0], r[1], r[2], err)
}
h.add(bin, cnt)
}
return nil
}
func (r *Result) populateHistogram(buf []byte) error {
r.hist = newHist()
r.hist.populate(buf)
r.stddev = math.Sqrt(r.hist.variance())
r.median = r.hist.median()
var err error
{
timeRe := regexp.MustCompile(`total time:\s+([0-9][0-9]*\.[0-9]+)s\n`)
res := timeRe.FindSubmatch(buf)
if len(res) == 0 {
return fmt.Errorf("time not found")
}
time, err := strconv.ParseFloat(string(res[1]), 0)
if err != nil {
return fmt.Errorf("failed to parse time: %s -> '%s' ; %s", res[0], res[1], err)
}
r.time = math.Round(time*1e3) / 1e3
}
{
itersRe := regexp.MustCompile(`total number of events:\s+([1-9][0-9]*)\n`)
res := itersRe.FindSubmatch(buf)
if len(res) == 0 {
return fmt.Errorf("time not found")
}
r.iters, err = strconv.Atoi(string(res[1]))
if err != nil {
return fmt.Errorf("failed to parse bin: %s -> '%s'; %s", res[0], res[1], err)
}
}
{
avgRe := regexp.MustCompile(`avg:\s+([0-9][0-9]*\.[0-9]+)\n`)
res := avgRe.FindSubmatch(buf)
if len(res) == 0 {
return fmt.Errorf("avg not found")
}
r.avg, err = strconv.ParseFloat(string(res[1]), 0)
if err != nil {
return fmt.Errorf("failed to parse avg: %s -> '%s'; %s", res[0], res[1], err)
}
}
return nil
}
func (r *Result) populateAvg(buf []byte) error {
panic("TODO")
}
type Result struct {
server string
detail string
test string
time float64
iters int
hist *Hist
avg float64
median float64
stddev float64
}
func newResult(server, test, detail string) *Result {
return &Result{
server: server,
detail: detail,
test: test,
}
}
func (r *Result) String() string {
b := &strings.Builder{}
fmt.Fprintf(b, "result:\n")
b.WriteString("result:\n")
if r.test != "" {
fmt.Fprintf(b, "- test: '%s'\n", r.test)
}
if r.detail != "" {
fmt.Fprintf(b, "- detail: '%s'\n", r.detail)
}
if r.server != "" {
fmt.Fprintf(b, "- server: '%s'\n", r.server)
}
fmt.Fprintf(b, "- time: %.3f\n", r.time)
fmt.Fprintf(b, "- iters: %d\n", r.iters)
fmt.Fprintf(b, "- mean: %.3f\n", r.hist.mean())
fmt.Fprintf(b, "- median: %.3f\n", r.median)
fmt.Fprintf(b, "- stddev: %.3f\n", r.stddev)
return b.String()
}
type Results struct {
Res []*Result
}
func (r *Results) Append(ir ...*Result) {
r.Res = append(r.Res, ir...)
}
func (r *Results) String() string {
b := strings.Builder{}
b.WriteString("Results:\n")
for _, x := range r.Res {
b.WriteString(x.String())
}
return b.String()
}
func (r *Results) SqlDump() string {
b := strings.Builder{}
b.WriteString(`CREATE TABLE IF NOT EXISTS sysbench_results (
test_name varchar(64),
detail varchar(64),
server varchar(64),
time double,
iters int,
avg double,
median double,
stdd double,
primary key (test_name, detail, server)
);
`)
b.WriteString("insert into sysbench_results values\n")
for i, r := range r.Res {
if i > 0 {
b.WriteString(",\n ")
}
b.WriteString(fmt.Sprintf(
"('%s', '%s', '%s', %.3f, %d, %.3f, %.3f, %.3f)",
r.test, r.detail, r.server, r.time, r.iters, r.avg, r.median, r.stddev))
}
b.WriteString(";\n")
return b.String()
}
func (test *Script) InitWithTmpDir(s string) {
test.tmpdir = s
test.Results = new(Results)
}
// Run executes an import configuration. Test parallelism makes
// runtimes resulting from this method unsuitable for reporting.
func (test *Script) Run(t *testing.T) {
if test.Skip != "" {
t.Skip(test.Skip)
}
conf, err := ParseConfig("testdata/default-config.yaml")
if err != nil {
require.NoError(t, err)
}
if _, err = os.Stat(test.scriptDir); err != nil {
require.NoError(t, err)
}
conf = conf.WithScriptDir(test.scriptDir)
tmpdir, err := os.MkdirTemp("", "repo-store-")
if err != nil {
require.NoError(t, err)
}
results := new(Results)
u, err := driver.NewDoltUser()
test.Results = results
test.InitWithTmpDir(tmpdir)
for _, r := range test.Repos {
var err error
switch {
case r.ExternalServer != nil:
panic("unsupported")
case r.Server != nil:
err = test.RunSqlServerTests(r, u, conf)
default:
panic("unsupported")
}
if err != nil {
require.NoError(t, err)
}
results.Append(test.Results.Res...)
}
fmt.Println(test.Results)
}
func modifyServerForImport(db *sql.DB) error {
_, err := db.Exec("CREATE DATABASE sbtest")
if err != nil {
return err
}
return nil
}
// RunExternalServerTests connects to a single externally provided server to run every test
func (test *Script) RunExternalServerTests(repoName string, s *driver.ExternalServer, conf Config) error {
return test.IterSysbenchScripts(conf, test.Scripts, func(script string, prep, run, clean *exec.Cmd) error {
db, err := driver.ConnectDB(s.User, s.Password, s.Name, s.Host, s.Port, nil)
if err != nil {
return err
}
defer db.Close()
if err := prep.Run(); err != nil {
return err
}
buf := new(bytes.Buffer)
run.Stdout = buf
err = run.Run()
if err != nil {
return err
}
// TODO scrape histogram data
r := newResult(repoName, script, test.Name)
if conf.Histogram {
r.populateHistogram(buf.Bytes())
} else {
r.populateAvg(buf.Bytes())
}
test.Results.Append(r)
return clean.Run()
})
}
// RunSqlServerTests creates a new repo and server for every import test.
func (test *Script) RunSqlServerTests(repo driver.TestRepo, user driver.DoltUser, conf Config) error {
return test.IterSysbenchScripts(conf, test.Scripts, func(script string, prep, run, clean *exec.Cmd) error {
//make a new server for every test
server, err := newServer(user, repo, conf)
if err != nil {
return err
}
defer server.GracefulStop()
db, err := server.DB(driver.Connection{User: "root", Pass: ""})
if err != nil {
return err
}
err = modifyServerForImport(db)
if err != nil {
return err
}
if err := prep.Run(); err != nil {
return err
}
buf := new(bytes.Buffer)
run.Stdout = buf
err = run.Run()
if err != nil {
fmt.Println(buf)
return err
}
// TODO scrape histogram data
r := newResult(repo.Name, script, test.Name)
if conf.Histogram {
r.populateHistogram(buf.Bytes())
} else {
r.populateAvg(buf.Bytes())
}
test.Results.Append(r)
if conf.Verbose {
return nil
}
return clean.Run()
})
}
func newServer(u driver.DoltUser, r driver.TestRepo, conf Config) (*driver.SqlServer, error) {
rs, err := u.MakeRepoStore()
if err != nil {
return nil, err
}
// start dolt server
repo, err := MakeRepo(rs, r)
if err != nil {
return nil, err
}
if conf.Verbose {
log.Printf("database at: '%s'", repo.Dir)
}
server, err := MakeServer(repo, r.Server)
if err != nil {
return nil, err
}
if server != nil {
server.DBName = r.Name
}
return server, nil
}
const luaExt = ".lua"
// IterSysbenchScripts returns 3 executable commands for the given script path: prepare, run, cleanup
func (test *Script) IterSysbenchScripts(conf Config, scripts []string, cb func(name string, prep, run, clean *exec.Cmd) error) error {
newCmd := func(command, script string) *exec.Cmd {
cmd := exec.Command("sysbench")
cmd.Args = append(cmd.Args, conf.AsOpts()...)
cmd.Args = append(cmd.Args, script, command)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd
}
for _, script := range scripts {
p := script
if strings.HasSuffix(script, luaExt) {
p = path.Join(conf.ScriptDir, script)
if _, err := os.Stat(p); err != nil {
return fmt.Errorf("failed to run script: '%s'", err)
}
}
prep := newCmd("prepare", p)
run := newCmd("run", p)
clean := newCmd("cleanup", p)
if err := cb(script, prep, run, clean); err != nil {
return err
}
}
return nil
}
func RunTestsFile(t *testing.T, path, scriptDir string) {
def, err := ParseTestsFile(path)
require.NoError(t, err)
for _, test := range def.Tests {
test.scriptDir = scriptDir
t.Run(test.Name, test.Run)
}
}