90% Faster Tests With Dolt Stored Procedures

SQL
4 min read

Dolt is a relational database that uses a custom storage layer and SQL engine to version data with Git-semantics. Every layer in the stack relies on thousands of tests to maintain MySQL compatibility and correctness. The surface area of MySQL and Git features is large, and testing is something we pay close attention to.

SQL engine tests are a team favorite. They comprise sets of query strings that validate SQL input/output pairs. They are conceptually easy, convenient to run, coverage-thorough, and provide tight iteration cycles. As a result, we have added new engine tests every day for years. This graph shows the amount of seconds taken to run enginetests over the past 12 months:

test-growth

The uptick has started become a developer bottleneck. A dev waits 4 minutes for enginetests to pass before committing any changes to go-mysql-server (GMS), our open source SQL engine, or Dolt.

Today we will show how we slashed the engine test runtime in the most recent release by 90% using Dolt versioning.

Dolt Stored procedures

Consider TestInsertInto, which adds rows to a table before verifying a new row is added:

{
    WriteQuery:          "INSERT INTO mytable SET s = 'x', i = 999;",
    ExpectedWriteResult: []sql.Row{{sql.NewOkResult(1)}},
    SelectQuery:         "SELECT i FROM mytable WHERE s = 'x';",
    ExpectedSelect:      []sql.Row{{int64(999)}},
},

The profile of the test suite is distressing, but obvious in hindsight:

    .     .    709:func TestInsertInto(t *testing.T, harness Harness) {
    .     .    710:	for _, insertion := range InsertQueries {
    . 3.48s    711:		e := NewEngine(t, harness)
    .  20ms    714:		TestQuery(t, harness, e, insertion.WriteQuery, insertion.ExpectedWriteResult, nil)
    .  20ms    715:		TestQuery(t, harness, e, insertion.SelectQuery, insertion.ExpectedSelect, nil)
    .     .    716:    }
    .     .    716:}

The test creates a SQL engine populated with data, runs WriteQuery, and then runs SelectQuery. Engine setup dwarfs the test runtime. The actual test is fast (40 milliseconds), rebuilding the database every time is slow (3480 milliseconds).

Reduce, Reuse, Recycle

How do Dolt stored procedures help us with enginetests? We can recycle a database with two commands that reset preexisting and new tables instead of running the entire setup steps every time.

First, dolt reset --hard rewinds uncommitted table changes. Below we show the table data alongside the dolt_status describing working set changes:

-- Observe the new changes
> select * from mytable;
+-----+---+
| i   | s |
+-----+---+
| 999 | x |
+-----+---+
> select * from dolt_status;
+------------+--------+----------+
| table_name | staged | status   |
+------------+--------+----------+
| mytable    | false  | modified |
+------------+--------+----------+

-- Rewind changes
> call dolt_reset('--hard');
+--------+
| status |
+--------+
| 0      |
+--------+

-- Database state is equal to the last commit
> select * from mytable;
+---+---+
| i | s |
+---+---+
+---+---+
> select * from dolt_status;
+------------+--------+--------+
| table_name | staged | status |
+------------+--------+--------+
+------------+--------+--------+

Resetting the database does not entirely correct the working set. Reset only affects previously versioned tables.

Consider TestCreateTableSelect, where we add a new table to the database by selecting a fraction of an existing table:

{
    WriteQuery:          "CREATE TABLE newtable AS SELECT * from mytable;",
    ExpectedWriteResult: []sql.Row{{sql.NewOkResult(0)}},
    SelectQuery:         "SELECT i FROM newtable WHERE s = 'first row';",
    ExpectedSelect:      []sql.Row{{int64(1)}},
},

After running dolt reset --hard, the new table remains:

-- Observe new table
> show tables;
+---------------+
| Tables_in_tmp |
+---------------+
| mytable       |
| newtable      |
+---------------+
> select * from dolt_status;
+------------+--------+-----------+
| table_name | staged | status    |
+------------+--------+-----------+
| newtable   | false  | new table |
+------------+--------+-----------+

-- Attempt to remove the new table
> call dolt_reset('--hard');
+--------+
| status |
+--------+
| 0      |
+--------+

-- New table is still there
> show tables;
+---------------+
| Tables_in_tmp |
+---------------+
| mytable       |
| newtable      |
+---------------+
> select * from dolt_status;
+------------+--------+-----------+
| table_name | staged | status    |
+------------+--------+-----------+
| newtable   | false  | new table |
+------------+--------+-----------+

Reset prevents Dolt from deleting unversioned tables. In order to remove untracked changes from the working set, I implemented dolt clean.

-- Delete the new table
> call dolt_clean('newtable');
+--------+
| status |
+--------+
| 0      |
+--------+

-- Database state is equal to the last commit
> select * from dolt_status;
+------------+--------+--------+
| table_name | staged | status |
+------------+--------+--------+
+------------+--------+--------+
> show tables:
+---------------+
| Tables_in_tmp |
+---------------+
| mytable       |
+---------------+

Dolt clean removes new, unstaged, and untracked tables. Or put simply, dolt clean calls drop table for new tables, restoring the second half of the working set that reset preserves.

Results

The reset and clean combo restores modified tables and deletes new tables, respectively, recycling a SQL engine. Here is a TestInsertInto profile with recycling:

   .     .    637:	t.Run(tt.WriteQuery, func(t *testing.T) {
   . 270ms    638:		e := mustNewEngine(t, harness)
   .     .    639:		ctx := NewContext(harness)
   .  20ms    641:		TestQueryWithContext(t, ctx, e, tt.WriteQuery, tt.ExpectedWriteResult, nil, nil)
   .  30ms    642:		TestQueryWithContext(t, ctx, e, tt.SelectQuery, tt.ExpectedSelect, nil, nil)
   .     .    643:	})

A reasonable initial response to this profile might be "This is terrible! 90% of the runtime is still spent on setup." But remember, we went from 3000 milliseconds to 300 milliseconds. Here is the before and after at the scale developers see for all tests:

> go test github.com/dolthub/dolt/go/libraries/doltcore/sqle/enginetest -count=1
ok  	github.com/dolthub/dolt/go/libraries/doltcore/sqle/enginetest	246.408s
ok  	github.com/dolthub/dolt/go/libraries/doltcore/sqle/enginetest	30.254s

There is room for improvement, but overall we achieved a 90% speedup. A developer might run go test ./... anywhere from one to hundreds of times for every PR. 4 minutes * 100 ~= a day, 40 seconds * 100 ~= an hour.

Next time we will talk about our CI shell script tests, bats. Bats are rarely run during active development, but remain a bottleneck for clearing GitHub PRs for merge. Here is a final side-by-side for comparison:

bats tests engine tests
runtime 26 minutes 30 seconds

Summary

We care about shipping software at Dolt, but every PR accumulates tests. Over time, the weight of tests themselves become a drag on development.

This blog shows how we narrowed the test feedback cycle using Dolt stored procedures. Database native Git-style versioning lets us recycle our test setup so we can do more coding and less waiting on tests. If you need to mock a MySQL database for your test suite. Dolt might be able to speed up your tests as well.

If you have any questions about Dolt, databases, or Golang performance reach out to us on Twitter, Discord, and GitHub!

SHARE

JOIN THE DATA EVOLUTION

Get started with Dolt

Or join our mailing list to get product updates.