storage: write index information to database
Change-Id: Icef3215b69d220620c9bdc44150699abbce1c57e
Reviewed-on: https://go-review.googlesource.com/34834
Reviewed-by: Russ Cox <rsc@golang.org>
diff --git a/storage/app/upload.go b/storage/app/upload.go
index b9c56be..8ecdad1 100644
--- a/storage/app/upload.go
+++ b/storage/app/upload.go
@@ -14,6 +14,8 @@
"sort"
"golang.org/x/net/context"
+ "golang.org/x/perf/storage/benchfmt"
+ "golang.org/x/perf/storage/db"
)
// upload is the handler for the /upload endpoint. It serves a form on
@@ -67,7 +69,7 @@
// processUpload takes one or more files from a multipart.Reader,
// writes them to the filesystem, and indexes their content.
func (a *App) processUpload(ctx context.Context, mr *multipart.Reader) (*uploadStatus, error) {
- var uploadid string
+ var upload *db.Upload
var fileids []string
for i := 0; ; i++ {
@@ -81,9 +83,9 @@
return nil, fmt.Errorf("unexpected field %q", name)
}
- if uploadid == "" {
+ if upload == nil {
var err error
- uploadid, err = a.DB.ReserveUploadID(ctx)
+ upload, err = a.DB.NewUpload(ctx)
if err != nil {
return nil, err
}
@@ -94,7 +96,7 @@
// is invalid (contains no valid records) it needs to
// be rejected and the Cloud Storage upload aborted.
- meta := fileMetadata(ctx, uploadid, i)
+ meta := fileMetadata(ctx, upload.ID, i)
// We need to do two things with the incoming data:
// - Write it to permanent storage via a.FS
@@ -102,17 +104,17 @@
// AND if anything fails, attempt to clean up both the
// FS and the index records.
- if err := a.indexFile(ctx, p, meta); err != nil {
+ if err := a.indexFile(ctx, upload, p, meta); err != nil {
return nil, err
}
fileids = append(fileids, meta["fileid"])
}
- return &uploadStatus{uploadid, fileids}, nil
+ return &uploadStatus{upload.ID, fileids}, nil
}
-func (a *App) indexFile(ctx context.Context, p io.Reader, meta map[string]string) (err error) {
+func (a *App) indexFile(ctx context.Context, upload *db.Upload, p io.Reader, meta map[string]string) (err error) {
fw, err := a.FS.NewWriter(ctx, fmt.Sprintf("uploads/%s.txt", meta["fileid"]), meta)
if err != nil {
return err
@@ -137,7 +139,7 @@
// TODO(quentin): Add a separate goroutine and buffer for writes to fw?
tr := io.TeeReader(p, fw)
- br := NewBenchmarkReader(tr)
+ br := benchfmt.NewReader(tr)
br.AddLabels(meta)
i := 0
for {
@@ -152,8 +154,9 @@
return nil
}
i++
- // TODO(quentin): Write records to database
- _ = result
+ if err := upload.InsertRecord(result); err != nil {
+ return err
+ }
}
}
diff --git a/storage/app/upload_test.go b/storage/app/upload_test.go
index 972ebdc..b2bc143 100644
--- a/storage/app/upload_test.go
+++ b/storage/app/upload_test.go
@@ -13,8 +13,8 @@
"net/http/httptest"
"testing"
- _ "github.com/mattn/go-sqlite3"
"golang.org/x/perf/storage/db"
+ _ "golang.org/x/perf/storage/db/sqlite3"
"golang.org/x/perf/storage/fs"
)
@@ -41,7 +41,7 @@
if err != nil {
t.Errorf("CreateFormFile: %v", err)
}
- fmt.Fprintf(w, "key: value\nBenchmarkOne 5 ns/op\n")
+ fmt.Fprintf(w, "key: value\nBenchmarkOne 5 ns/op\nkey:value2\nBenchmarkTwo 10 ns/op\n")
}()
resp, err := http.Post(srv.URL, mpw.FormDataContentType(), pr)
if err != nil {
diff --git a/storage/app/benchfmt.go b/storage/benchfmt/benchfmt.go
similarity index 70%
rename from storage/app/benchfmt.go
rename to storage/benchfmt/benchfmt.go
index b115950..18bf9e1 100644
--- a/storage/app/benchfmt.go
+++ b/storage/benchfmt/benchfmt.go
@@ -2,7 +2,10 @@
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
-package app
+// Package benchfmt provides readers and writers for the Go benchmark format.
+//
+// The format is documented at https://golang.org/design/14313-benchmark-format
+package benchfmt
import (
"bufio"
@@ -14,97 +17,111 @@
"unicode"
)
-// BenchmarkReader reads benchmark results from an io.Reader.
-type BenchmarkReader struct {
+// Reader reads benchmark results from an io.Reader.
+type Reader struct {
s *bufio.Scanner
- labels map[string]string
+ labels Labels
lineNum int
}
-// NewBenchmarkReader creates a BenchmarkReader that reads from r.
-func NewBenchmarkReader(r io.Reader) *BenchmarkReader {
- return &BenchmarkReader{
+// TODO(quentin): Make Reader have a Scanner-style interface instead, to match db.Query.
+
+// NewReader creates a BenchmarkReader that reads from r.
+func NewReader(r io.Reader) *Reader {
+ return &Reader{
s: bufio.NewScanner(r),
- labels: make(map[string]string),
+ labels: make(Labels),
}
}
// AddLabels adds additional labels as if they had been read from the file.
// It must be called before the first call to r.Next.
-func (r *BenchmarkReader) AddLabels(labels map[string]string) {
+func (r *Reader) AddLabels(labels Labels) {
for k, v := range labels {
r.labels[k] = v
}
}
-// TODO: It would probably be helpful to add a named type for
-// map[string]string with String(), Keys(), and Equal() methods.
-
// Result represents a single line from a benchmark file.
// All information about that line is self-contained in the Result.
type Result struct {
// Labels is the set of persistent labels that apply to the result.
// Labels must not be modified.
- Labels map[string]string
+ Labels Labels
// NameLabels is the set of ephemeral labels that were parsed
// from the benchmark name/line.
// NameLabels must not be modified.
- NameLabels map[string]string
+ NameLabels Labels
// LineNum is the line number on which the result was found
LineNum int
// Content is the verbatim input line of the benchmark file, beginning with the string "Benchmark".
Content string
}
-// A BenchmarkPrinter prints a sequence of benchmark results.
-type BenchmarkPrinter struct {
- w io.Writer
- labels map[string]string
+// Labels is a set of key-value strings.
+type Labels map[string]string
+
+// TODO(quentin): Add String and Equal methods to Labels?
+
+// Keys returns a sorted list of the keys in l.
+func (l Labels) Keys() []string {
+ var out []string
+ for k := range l {
+ out = append(out, k)
+ }
+ sort.Strings(out)
+ return out
}
-// NewBenchmarkPrinter constructs a BenchmarkPrinter writing to w.
-func NewBenchmarkPrinter(w io.Writer) *BenchmarkPrinter {
- return &BenchmarkPrinter{w: w}
+// A Printer prints a sequence of benchmark results.
+type Printer struct {
+ w io.Writer
+ labels Labels
+}
+
+// NewPrinter constructs a BenchmarkPrinter writing to w.
+func NewPrinter(w io.Writer) *Printer {
+ return &Printer{w: w}
}
// Print writes the lines necessary to recreate r.
-func (bp *BenchmarkPrinter) Print(r *Result) error {
+func (p *Printer) Print(r *Result) error {
var keys []string
// Print removed keys first.
- for k := range bp.labels {
+ for k := range p.labels {
if r.Labels[k] == "" {
keys = append(keys, k)
}
}
sort.Strings(keys)
for _, k := range keys {
- if _, err := fmt.Fprintf(bp.w, "%s:\n", k); err != nil {
+ if _, err := fmt.Fprintf(p.w, "%s:\n", k); err != nil {
return err
}
}
// Then print new or changed keys.
keys = keys[:0]
for k, v := range r.Labels {
- if v != "" && bp.labels[k] != v {
+ if v != "" && p.labels[k] != v {
keys = append(keys, k)
}
}
sort.Strings(keys)
for _, k := range keys {
- if _, err := fmt.Fprintf(bp.w, "%s: %s\n", k, r.Labels[k]); err != nil {
+ if _, err := fmt.Fprintf(p.w, "%s: %s\n", k, r.Labels[k]); err != nil {
return err
}
}
// Finally print the actual line itself.
- if _, err := fmt.Fprintf(bp.w, "%s\n", r.Content); err != nil {
+ if _, err := fmt.Fprintf(p.w, "%s\n", r.Content); err != nil {
return err
}
- bp.labels = r.Labels
+ p.labels = r.Labels
return nil
}
// parseNameLabels extracts extra labels from a benchmark name and sets them in labels.
-func parseNameLabels(name string, labels map[string]string) {
+func parseNameLabels(name string, labels Labels) {
dash := strings.LastIndex(name, "-")
if dash >= 0 {
// Accept -N as an alias for /GOMAXPROCS=N
@@ -129,10 +146,10 @@
}
// newResult parses a line and returns a Result object for the line.
-func newResult(labels map[string]string, lineNum int, name, content string) *Result {
+func newResult(labels Labels, lineNum int, name, content string) *Result {
r := &Result{
Labels: labels,
- NameLabels: make(map[string]string),
+ NameLabels: make(Labels),
LineNum: lineNum,
Content: content,
}
@@ -140,11 +157,11 @@
return r
}
-// copyLabels makes a new copy of the labels map, to protect against
+// copy returns a new copy of the labels map, to protect against
// future modifications to labels.
-func copyLabels(labels map[string]string) map[string]string {
- new := make(map[string]string)
- for k, v := range labels {
+func (l Labels) copy() Labels {
+ new := make(Labels)
+ for k, v := range l {
new[k] = v
}
return new
@@ -154,7 +171,7 @@
// Next returns the next benchmark result from the file. If there are
// no further results, it returns nil, io.EOF.
-func (r *BenchmarkReader) Next() (*Result, error) {
+func (r *Reader) Next() (*Result, error) {
copied := false
for r.s.Scan() {
r.lineNum++
@@ -162,7 +179,7 @@
if key, value, ok := parseKeyValueLine(line); ok {
if !copied {
copied = true
- r.labels = copyLabels(r.labels)
+ r.labels = r.labels.copy()
}
// TODO(quentin): Spec says empty value is valid, but
// we need a way to cancel previous labels, so we'll
diff --git a/storage/app/benchfmt_test.go b/storage/benchfmt/benchfmt_test.go
similarity index 87%
rename from storage/app/benchfmt_test.go
rename to storage/benchfmt/benchfmt_test.go
index 0e9d763..b32be50 100644
--- a/storage/app/benchfmt_test.go
+++ b/storage/benchfmt/benchfmt_test.go
@@ -2,7 +2,7 @@
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
-package app
+package benchfmt
import (
"bytes"
@@ -16,7 +16,7 @@
"testing"
)
-func readAllResults(t *testing.T, r *BenchmarkReader) []*Result {
+func readAllResults(t *testing.T, r *Reader) []*Result {
var out []*Result
for {
result, err := r.Next()
@@ -32,7 +32,6 @@
}
func TestBenchmarkReader(t *testing.T) {
- type kv map[string]string
tests := []struct {
name, input string
want []*Result
@@ -43,8 +42,8 @@
BenchmarkOne 1 ns/sec
`,
[]*Result{{
- kv{"key": "value"},
- kv{"name": "One"},
+ Labels{"key": "value"},
+ Labels{"name": "One"},
2,
"BenchmarkOne 1 ns/sec",
}},
@@ -57,14 +56,14 @@
`,
[]*Result{
{
- kv{"key": "value"},
- kv{"name": "One", "sub1": "foo", "bar": "1", "GOMAXPROCS": "2"},
+ Labels{"key": "value"},
+ Labels{"name": "One", "sub1": "foo", "bar": "1", "GOMAXPROCS": "2"},
2,
"BenchmarkOne/foo/bar=1-2 1 ns/sec",
},
{
- kv{"key": "value"},
- kv{"name": "Two"},
+ Labels{"key": "value"},
+ Labels{"name": "Two"},
3,
"BenchmarkTwo 2 ns/sec",
},
@@ -78,8 +77,8 @@
`,
[]*Result{
{
- kv{},
- kv{"name": "One"},
+ Labels{},
+ Labels{"name": "One"},
3,
"BenchmarkOne 1 ns/sec",
},
@@ -88,7 +87,7 @@
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
- r := NewBenchmarkReader(strings.NewReader(test.input))
+ r := NewReader(strings.NewReader(test.input))
have := readAllResults(t, r)
want := test.want
diff := ""
@@ -157,10 +156,10 @@
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
- r := NewBenchmarkReader(strings.NewReader(test.input))
+ r := NewReader(strings.NewReader(test.input))
results := readAllResults(t, r)
var have bytes.Buffer
- bp := NewBenchmarkPrinter(&have)
+ bp := NewPrinter(&have)
for _, result := range results {
if err := bp.Print(result); err != nil {
t.Errorf("Print returned %v", err)
diff --git a/storage/db/db.go b/storage/db/db.go
index 4c84d14..5ab74cd 100644
--- a/storage/db/db.go
+++ b/storage/db/db.go
@@ -7,18 +7,23 @@
package db
import (
+ "bytes"
"database/sql"
"fmt"
"strings"
+ "text/template"
"golang.org/x/net/context"
+ "golang.org/x/perf/storage/benchfmt"
)
// DB is a high-level interface to a database for the storage
// app. It's safe for concurrent use by multiple goroutines.
type DB struct {
- sql *sql.DB
+ sql *sql.DB // underlying database connection
+ // prepared statements
insertUpload *sql.Stmt
+ insertRecord *sql.Stmt
}
// OpenSQL creates a DB backed by a SQL database. The parameters are
@@ -30,6 +35,11 @@
if err != nil {
return nil, err
}
+ if hook := openHooks[driverName]; hook != nil {
+ if err := hook(db); err != nil {
+ return nil, err
+ }
+ }
d := &DB{sql: db}
if err := d.createTables(driverName); err != nil {
return nil, err
@@ -40,25 +50,53 @@
return d, nil
}
+var openHooks = make(map[string]func(*sql.DB) error)
+
+// RegisterOpenHook registers a hook to be called after opening a connection to driverName.
+// This is used by the sqlite3 package to register a ConnectHook.
+// It must be called from an init function.
+func RegisterOpenHook(driverName string, hook func(*sql.DB) error) {
+ openHooks[driverName] = hook
+}
+
+// createTmpl is the template used to prepare the CREATE statements
+// for the database. It is evaluated with . as a map containing one
+// entry whose key is the driver name.
+var createTmpl = template.Must(template.New("create").Parse(`
+CREATE TABLE IF NOT EXISTS Uploads (
+ UploadID {{if .sqlite3}}INTEGER PRIMARY KEY AUTOINCREMENT{{else}}SERIAL PRIMARY KEY AUTO_INCREMENT{{end}}
+);
+CREATE TABLE IF NOT EXISTS Records (
+ UploadID BIGINT UNSIGNED,
+ RecordID BIGINT UNSIGNED,
+ Content BLOB,
+ PRIMARY KEY (UploadID, RecordID),
+ FOREIGN KEY (UploadID) REFERENCES Uploads(UploadID) ON UPDATE CASCADE ON DELETE CASCADE
+);
+CREATE TABLE IF NOT EXISTS RecordLabels (
+ UploadID BIGINT UNSIGNED,
+ RecordID BIGINT UNSIGNED,
+ Name VARCHAR(255),
+ Value VARCHAR(8192),
+{{if not .sqlite3}}
+ Index (Name(100), Value(100)),
+{{end}}
+ FOREIGN KEY (UploadID, RecordID) REFERENCES Records(UploadID, RecordID) ON UPDATE CASCADE ON DELETE CASCADE
+);
+{{if .sqlite3}}
+CREATE INDEX IF NOT EXISTS RecordLabelsNameValue ON RecordLabels(Name, Value);
+{{end}}
+`))
+
// createTables creates any missing tables on the connection in
// db.sql. driverName is the same driver name passed to sql.Open and
// is used to select the correct syntax.
func (db *DB) createTables(driverName string) error {
- var schema string
- switch driverName {
- case "sqlite3":
- schema = `
-CREATE TABLE IF NOT EXISTS Uploads (
- UploadId INTEGER PRIMARY KEY AUTOINCREMENT
-);
-`
- default: // MySQL syntax
- schema = `
-CREATE TABLE IF NOT EXISTS Uploads (
- UploadId SERIAL PRIMARY KEY AUTO_INCREMENT
-);`
+ var buf bytes.Buffer
+ if err := createTmpl.Execute(&buf, map[string]bool{driverName: true}); err != nil {
+ return err
}
- for _, q := range strings.Split(schema, ";") {
+ for _, q := range strings.Split(buf.String(), ";") {
if strings.TrimSpace(q) == "" {
continue
}
@@ -80,28 +118,97 @@
if err != nil {
return err
}
+ db.insertRecord, err = db.sql.Prepare("INSERT INTO Records(UploadID, RecordID, Content) VALUES (?, ?, ?)")
+ if err != nil {
+ return err
+ }
return nil
}
-// ReserveUploadID returns an upload ID which can be used for storing new files.
-func (db *DB) ReserveUploadID(ctx context.Context) (string, error) {
+// An Upload is a collection of files that share an upload ID.
+type Upload struct {
+ // ID is the value of the "uploadid" key that should be
+ // associated with every record in this upload.
+ ID string
+
+ // id is the numeric value used as the primary key. ID is a
+ // string for the public API; the underlying table actually
+ // uses an integer key. To avoid repeated calls to
+ // strconv.Atoi, the int64 is cached here.
+ id int64
+ // recordid is the index of the next record to insert.
+ recordid int64
+ // db is the underlying database that this upload is going to.
+ db *DB
+}
+
+// NewUpload returns an upload for storing new files.
+// All records written to the Upload will have the same upload ID.
+func (db *DB) NewUpload(ctx context.Context) (*Upload, error) {
// TODO(quentin): Use a transaction?
res, err := db.insertUpload.Exec()
if err != nil {
- return "", err
+ return nil, err
}
// TODO(quentin): Use a date-based upload ID (YYYYMMDDnnn)
i, err := res.LastInsertId()
if err != nil {
- return "", err
+ return nil, err
}
- return fmt.Sprint(i), nil
+ return &Upload{
+ ID: fmt.Sprint(i),
+ id: i,
+ db: db,
+ }, nil
}
-// TODO(quentin): Implement
-// func (db *DB) InsertRecord(uploadid string, fields map[string]string, lines map[int]string) error
+// InsertRecord inserts a single record in an existing upload.
+func (u *Upload) InsertRecord(r *benchfmt.Result) (err error) {
+ // TODO(quentin): Use a single transaction for the whole upload?
+ tx, err := u.db.sql.Begin()
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ } else {
+ err = tx.Commit()
+ }
+ }()
+ // TODO(quentin): Support multiple lines (slice of results?)
+ var buf bytes.Buffer
+ if err := benchfmt.NewPrinter(&buf).Print(r); err != nil {
+ return err
+ }
+ if _, err = tx.Stmt(u.db.insertRecord).Exec(u.id, u.recordid, buf.Bytes()); err != nil {
+ return err
+ }
+ var args []interface{}
+ for _, k := range r.Labels.Keys() {
+ args = append(args, u.id, u.recordid, k, r.Labels[k])
+ }
+ for _, k := range r.NameLabels.Keys() {
+ args = append(args, u.id, u.recordid, k, r.NameLabels[k])
+ }
+ if len(args) > 0 {
+ query := "INSERT INTO RecordLabels VALUES " + strings.Repeat("(?, ?, ?, ?), ", len(args)/4)
+ query = strings.TrimSuffix(query, ", ")
+ if _, err := tx.Exec(query, args...); err != nil {
+ return err
+ }
+ }
+ u.recordid++
+ return nil
+}
// Close closes the database connections, releasing any open resources.
func (db *DB) Close() error {
+ if err := db.insertUpload.Close(); err != nil {
+ return err
+ }
+ if err := db.insertRecord.Close(); err != nil {
+ return err
+ }
return db.sql.Close()
}
diff --git a/storage/db/db_test.go b/storage/db/db_test.go
new file mode 100644
index 0000000..6c7b681
--- /dev/null
+++ b/storage/db/db_test.go
@@ -0,0 +1,84 @@
+// Copyright 2017 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package db_test
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "golang.org/x/perf/storage/benchfmt"
+ . "golang.org/x/perf/storage/db"
+ _ "golang.org/x/perf/storage/db/sqlite3"
+)
+
+// Most of the db package is tested via the end-to-end-tests in perf/storage/app.
+
+// TestNewUpload verifies that NewUpload and InsertRecord wrote the correct rows to the database.
+func TestNewUpload(t *testing.T) {
+ db, err := OpenSQL("sqlite3", ":memory:")
+ if err != nil {
+ t.Fatalf("open database: %v", err)
+ }
+ defer db.Close()
+
+ u, err := db.NewUpload(context.Background())
+ if err != nil {
+ t.Fatalf("NewUpload: %v", err)
+ }
+
+ br := benchfmt.NewReader(strings.NewReader(`
+key: value
+BenchmarkName 1 ns/op
+`))
+
+ r, err := br.Next()
+ if err != nil {
+ t.Fatalf("BenchmarkReader.Next: %v", err)
+ }
+
+ if err := u.InsertRecord(r); err != nil {
+ t.Fatalf("InsertRecord: %v", err)
+ }
+
+ rows, err := DBSQL(db).Query("SELECT UploadId, RecordId, Name, Value FROM RecordLabels")
+ if err != nil {
+ t.Fatalf("sql.Query: %v", err)
+ }
+ defer rows.Close()
+
+ want := map[string]string{
+ "key": "value",
+ "name": "Name",
+ }
+
+ i := 0
+
+ for rows.Next() {
+ var uploadid, recordid int64
+ var name, value string
+
+ if err := rows.Scan(&uploadid, &recordid, &name, &value); err != nil {
+ t.Fatalf("rows.Scan: %v")
+ }
+ if uploadid != 1 {
+ t.Errorf("uploadid = %d, want 1", uploadid)
+ }
+ if recordid != 0 {
+ t.Errorf("recordid = %d, want 0", recordid)
+ }
+ if want[name] != value {
+ t.Errorf("%s = %q, want %q", name, value, want[name])
+ }
+ i++
+ }
+ if i != len(want) {
+ t.Errorf("have %d labels, want %d", i, len(want))
+ }
+
+ if err := rows.Err(); err != nil {
+ t.Errorf("rows.Err: %v", err)
+ }
+}
diff --git a/storage/db/export_test.go b/storage/db/export_test.go
new file mode 100644
index 0000000..49e54b8
--- /dev/null
+++ b/storage/db/export_test.go
@@ -0,0 +1,13 @@
+// Copyright 2017 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package db
+
+import "database/sql"
+
+var SplitQueryWords = splitQueryWords
+
+func DBSQL(db *DB) *sql.DB {
+ return db.sql
+}
diff --git a/storage/db/sqlite3/sqlite3.go b/storage/db/sqlite3/sqlite3.go
new file mode 100644
index 0000000..9fb50c5
--- /dev/null
+++ b/storage/db/sqlite3/sqlite3.go
@@ -0,0 +1,25 @@
+// Copyright 2017 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Package sqlite3 provides the sqlite3 driver for
+// x/perf/storage/db. It must be imported instead of go-sqlite3 to
+// ensure foreign keys are properly honored.
+package sqlite3
+
+import (
+ "database/sql"
+
+ sqlite3 "github.com/mattn/go-sqlite3"
+ "golang.org/x/perf/storage/db"
+)
+
+func init() {
+ db.RegisterOpenHook("sqlite3", func(db *sql.DB) error {
+ db.Driver().(*sqlite3.SQLiteDriver).ConnectHook = func(c *sqlite3.SQLiteConn) error {
+ _, err := c.Exec("PRAGMA foreign_keys = ON;", nil)
+ return err
+ }
+ return nil
+ })
+}
diff --git a/storage/localserver/app.go b/storage/localserver/app.go
index e832470..4cfb9db 100644
--- a/storage/localserver/app.go
+++ b/storage/localserver/app.go
@@ -9,9 +9,9 @@
"log"
"net/http"
- _ "github.com/mattn/go-sqlite3"
"golang.org/x/perf/storage/app"
"golang.org/x/perf/storage/db"
+ _ "golang.org/x/perf/storage/db/sqlite3"
"golang.org/x/perf/storage/fs"
)