// Copyright 2024 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 harness import ( "context" "database/sql" "fmt" "io" "log" "os" "os/exec" "os/signal" "path/filepath" "sync" "syscall" "time" "golang.org/x/sync/errgroup" "github.com/dolthub/sqllogictest/go/logictest" _ "github.com/jackc/pgx/v4/stdlib" ) const ( doltgresDBDir = "doltgresDatabases" serverLogFile = "server.log" harnessLogFile = "harness.log" DefaultPort = 5432 defaultDbName = "sqllogictest" ) func noDatabaseDSN(port int) string { return fmt.Sprintf("postgresql://postgres:password@127.0.0.1:%d/?sslmode=disable", port) } func withDatabaseDSN(port int, dbName string) string { return fmt.Sprintf("postgresql://postgres:password@127.0.0.1:%d/%s?sslmode=disable", port, dbName) } var _ logictest.Harness = &DoltgresHarness{} // DoltgresHarness is a sqllogictest harness for doltgres databases. type DoltgresHarness struct { db *sql.DB doltgresExec string server *DoltgresServer serverDir string timeout int64 port int dbName string harnessLog *os.File stashedLogOutput io.Writer } // NewDoltgresHarness returns a harness that manages its own server lifecycle. The server is // started once at construction; Init resets the database without restarting the process. func NewDoltgresHarness(doltgresExec string, t int64) *DoltgresHarness { cwd, err := os.Getwd() if err != nil { logErr(err, "getting cwd") } serverDir := filepath.Join(cwd, doltgresDBDir) err = os.RemoveAll(serverDir) if err != nil { logErr(err, fmt.Sprintf("running `RemoveAll` for '%s'", serverDir)) } err = os.MkdirAll(serverDir, os.ModePerm) if err != nil { logErr(err, fmt.Sprintf("running `MkdirAll` for '%s'", serverDir)) } hl, err := os.OpenFile(harnessLogFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) if err != nil { log.Fatal(err) } stashLogOutput := log.Writer() log.SetOutput(hl) logMsg("creating a new DoltgresHarness") h := &DoltgresHarness{ doltgresExec: doltgresExec, serverDir: serverDir, timeout: t, port: DefaultPort, dbName: defaultDbName, harnessLog: hl, stashedLogOutput: stashLogOutput, } h.server = startServerProcess(doltgresExec, serverDir) return h } // NewDoltgresWorkerHarness returns a harness for use in a concurrent worker. It connects to an // already-running server on the given port using dbName as its isolated database. The caller is // responsible for starting and stopping the server (see StartSharedServer). func NewDoltgresWorkerHarness(port int, dbName string, t int64) *DoltgresHarness { return &DoltgresHarness{ timeout: t, port: port, dbName: dbName, } } // StartSharedServer starts a single doltgres server process for use by multiple concurrent // workers. The returned *DoltgresServer must be closed when testing is complete. func StartSharedServer(doltgresExec string) *DoltgresServer { cwd, err := os.Getwd() if err != nil { logErr(err, "getting cwd") } serverDir := filepath.Join(cwd, doltgresDBDir) err = os.RemoveAll(serverDir) if err != nil { logErr(err, fmt.Sprintf("running `RemoveAll` for '%s'", serverDir)) } err = os.MkdirAll(serverDir, os.ModePerm) if err != nil { logErr(err, fmt.Sprintf("running `MkdirAll` for '%s'", serverDir)) } return startServerProcess(doltgresExec, serverDir) } // startServerProcess starts a doltgres subprocess in serverDir and waits for it to be ready. func startServerProcess(doltgresExec string, serverDir string) *DoltgresServer { withKeyCtx, cancel := context.WithCancel(context.Background()) gServer, serverCtx := errgroup.WithContext(withKeyCtx) server := exec.CommandContext(serverCtx, doltgresExec, "--data-dir=.") server.Dir = serverDir l, err := os.OpenFile(serverLogFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) if err != nil { logErr(err, fmt.Sprintf("opening %s file", serverLogFile)) } server.Stdout = l server.Stderr = l quit := make(chan os.Signal, 1) signal.Notify(quit, os.Interrupt, syscall.SIGTERM) var wg sync.WaitGroup wg.Add(1) go func() { <-quit defer wg.Done() signal.Stop(quit) cancel() }() ds := &DoltgresServer{ dir: serverDir, quit: quit, wg: &wg, gServer: gServer, server: server, serverLog: l, } ds.Start() return ds } func (h *DoltgresHarness) EngineStr() string { return "postgresql" } // Init resets the harness to a clean state for the next test file. // For worker harnesses (no server process), it keeps a persistent connection and resets the // public schema via DROP+CREATE — avoiding database-level connection races entirely. // For server-owning harnesses, it drops and recreates the database (safe because only one worker // uses each database in the non-concurrent path). func (h *DoltgresHarness) Init() error { if h.server == nil { return h.initWorker() } return h.initServer() } // initWorker resets a worker harness between test files. // // When useDropSchema is true (PostgreSQL), it wipes and recreates the public schema on the // persistent connection — no reconnect needed, no races. func (h *DoltgresHarness) initWorker() error { ctx := context.Background() if h.db != nil { h.db.Close() h.db = nil } noDB, err := sql.Open("pgx", noDatabaseDSN(h.port)) if err != nil { return err } _, err = noDB.ExecContext(ctx, "DROP DATABASE IF EXISTS "+h.dbName) if err == nil { _, err = noDB.ExecContext(ctx, "CREATE DATABASE "+h.dbName) } noDB.Close() if err != nil { return fmt.Errorf("resetting database %s: %w", h.dbName, err) } return h.openWorkerDB(ctx) } // openWorkerDB establishes a connection to the worker's database, creating it if necessary. func (h *DoltgresHarness) openWorkerDB(ctx context.Context) error { db, err := sql.Open("pgx", withDatabaseDSN(h.port, h.dbName)) if err != nil { return err } if pingErr := db.Ping(); pingErr == nil { h.db = db return nil } db.Close() // Database does not exist yet; create it. noDB, err := sql.Open("pgx", noDatabaseDSN(h.port)) if err != nil { return err } _, createErr := noDB.ExecContext(ctx, "CREATE DATABASE "+h.dbName) noDB.Close() if createErr != nil { logErr(createErr, "creating database "+h.dbName) return createErr } db, err = sql.Open("pgx", withDatabaseDSN(h.port, h.dbName)) if err != nil { return err } if err = db.Ping(); err != nil { db.Close() return err } h.db = db return nil } // initServer resets the server-owning harness by dropping and recreating the database. // This is only used in the non-concurrent (single-worker) path. func (h *DoltgresHarness) initServer() error { ctx := context.Background() if h.db != nil { h.db.Close() h.db = nil } noDB, openErr := sql.Open("pgx", noDatabaseDSN(h.port)) if openErr != nil { logErr(openErr, "opening no-db connection") return openErr } _, err := noDB.ExecContext(ctx, "DROP DATABASE IF EXISTS "+h.dbName) if err == nil { _, err = noDB.ExecContext(ctx, "CREATE DATABASE "+h.dbName) } noDB.Close() if err != nil { logErr(err, "resetting database "+h.dbName) return err } db, err := sql.Open("pgx", withDatabaseDSN(h.port, h.dbName)) if err != nil { logErr(err, "opening connection to "+h.dbName) return err } if err = db.Ping(); err != nil { db.Close() return err } h.db = db return nil } func (h *DoltgresHarness) Close() error { if h.db != nil { h.db.Close() h.db = nil } if h.server != nil { h.server.Close() h.server = nil } if h.harnessLog != nil { h.harnessLog.Close() log.SetOutput(h.stashedLogOutput) } return os.RemoveAll(h.serverDir) } func (h *DoltgresHarness) ExecuteStatement(statement string) error { _, err := h.db.Exec(statement) return err } func (h *DoltgresHarness) ExecuteQuery(statement string) (schema string, results []string, err error) { rows, err := h.db.Query(statement) if rows != nil { defer rows.Close() } if err != nil { return "", nil, err } return h.getSchemaAndResults(rows) } func (h *DoltgresHarness) ExecuteStatementContext(ctx context.Context, statement string) error { _, err := h.db.ExecContext(ctx, statement) return err } func (h *DoltgresHarness) ExecuteQueryContext(ctx context.Context, statement string) (schema string, results []string, err error) { rows, err := h.db.QueryContext(ctx, statement) if rows != nil { defer rows.Close() } if err != nil { return "", nil, err } return h.getSchemaAndResults(rows) } func (h *DoltgresHarness) GetTimeout() int64 { return h.timeout } func (h *DoltgresHarness) getSchemaAndResults(rows *sql.Rows) (schema string, results []string, err error) { schema, cols, err := columns(rows) if err != nil { return "", nil, err } for rows.Next() { if err = rows.Scan(cols...); err != nil { return "", nil, err } for _, col := range cols { results = append(results, stringVal(col)) } } if rows.Err() != nil { return "", nil, rows.Err() } return schema, results, nil } type DoltgresServer struct { dir string quit chan os.Signal wg *sync.WaitGroup gServer *errgroup.Group server *exec.Cmd serverLog *os.File } func (s *DoltgresServer) Start() { logMsg("starting doltgres server") var err error s.gServer.Go(func() error { err = s.server.Run() return err }) // Allow the server time to start accepting connections. time.Sleep(3 * time.Second) if err != nil { logErr(err, "from server.Start()") } } func (s *DoltgresServer) Stop() { select { case <-s.quit: return default: } s.quit <- syscall.SIGTERM err := s.gServer.Wait() if err != nil { if err.Error() == "signal: killed" { logMsg("doltgres server stopped successfully") } else { logErr(err, "from server.Stop()") } } close(s.quit) s.wg.Wait() } func (s *DoltgresServer) Close() { s.Stop() if err := s.serverLog.Close(); err != nil { logErr(err, "closing server.log") } } func logErr(err error, cause string) { log.Printf("ERROR: %s received from %s", err.Error(), cause) } func logMsg(msg string) { log.Println(msg) }