Add Subscription fields to Alerts table.

Modifies Save interface to have a subscription key argument, where one can specify a Subscription. CL also creates granular, isolated tests for each Alert method.

Bug: chromium:327485601
Change-Id: I6a5962aa20420f9cccb917316127b59f6cb16a39
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/844556
Reviewed-by: Ashwin Verleker <ashwinpv@google.com>
Commit-Queue: Eduardo Yap <eduardoyap@google.com>
diff --git a/perf/go/alerts/alertstest/BUILD.bazel b/perf/go/alerts/alertstest/BUILD.bazel
deleted file mode 100644
index 289a438..0000000
--- a/perf/go/alerts/alertstest/BUILD.bazel
+++ /dev/null
@@ -1,13 +0,0 @@
-load("@io_bazel_rules_go//go:def.bzl", "go_library")
-
-go_library(
-    name = "alertstest",
-    srcs = ["alertstest.go"],
-    importpath = "go.skia.org/infra/perf/go/alerts/alertstest",
-    visibility = ["//visibility:public"],
-    deps = [
-        "//perf/go/alerts",
-        "@com_github_stretchr_testify//assert",
-        "@com_github_stretchr_testify//require",
-    ],
-)
diff --git a/perf/go/alerts/alertstest/alertstest.go b/perf/go/alerts/alertstest/alertstest.go
deleted file mode 100644
index f200f47..0000000
--- a/perf/go/alerts/alertstest/alertstest.go
+++ /dev/null
@@ -1,88 +0,0 @@
-// Package alertstest contains test utils for the alerts package.
-package alertstest
-
-import (
-	"context"
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
-	"go.skia.org/infra/perf/go/alerts"
-)
-
-// Store_SaveListDelete tests that an alerts.Store instance operates as expected.
-func Store_SaveListDelete(t *testing.T, a alerts.Store) {
-	ctx := context.Background()
-
-	// TODO(jcgregorio) Break up into finer grained tests.
-	cfg := alerts.NewConfig()
-	cfg.Query = "source_type=svg"
-	cfg.DisplayName = "bar"
-	err := a.Save(ctx, cfg)
-	assert.NoError(t, err)
-	require.NotEqual(t, alerts.BadAlertIDAsAsString, cfg.IDAsString)
-
-	// Confirm it appears in the list.
-	cfgs, err := a.List(ctx, false)
-	require.NoError(t, err)
-	require.Len(t, cfgs, 1)
-
-	// Delete it.
-	err = a.Delete(ctx, int(cfgs[0].IDAsStringToInt()))
-	assert.NoError(t, err)
-
-	// Confirm it is still there if we list deleted configs.
-	cfgs, err = a.List(ctx, true)
-	assert.NoError(t, err)
-	assert.Len(t, cfgs, 1)
-	require.NotEqual(t, alerts.BadAlertIDAsAsString, cfgs[0].IDAsString)
-
-	// Confirm it is not there if we don't list deleted configs.
-	cfgs, err = a.List(ctx, false)
-	assert.NoError(t, err)
-	assert.Len(t, cfgs, 0)
-
-	// Store a second config.
-	cfg = alerts.NewConfig()
-	cfg.Query = "source_type=skp"
-	cfg.DisplayName = "foo"
-	err = a.Save(ctx, cfg)
-	assert.NoError(t, err)
-
-	// Confirm they are both listed when including deleted configs, and they are
-	// ordered by DisplayName.
-	cfgs, err = a.List(ctx, true)
-	assert.NoError(t, err)
-	assert.Len(t, cfgs, 2)
-	assert.Equal(t, "bar", cfgs[0].DisplayName)
-	assert.Equal(t, "foo", cfgs[1].DisplayName)
-}
-
-// Store_SaveWithID tests we can save a new Alert with a given ID.
-func Store_SaveWithID(t *testing.T, a alerts.Store) {
-	ctx := context.Background()
-
-	cfg := alerts.NewConfig()
-	// Add some data to the empty config.
-	cfg.Query = "source_type=svg"
-	cfg.DisplayName = "bar"
-	cfg.IDAsString = "12"
-	err := a.Save(ctx, cfg)
-	require.NoError(t, err)
-
-	// Confirm it appears in the list.
-	cfgs, err := a.List(ctx, false)
-	require.NoError(t, err)
-	assert.Len(t, cfgs, 1)
-	assert.Equal(t, cfg, cfgs[0])
-}
-
-// SubTestFunction is a func we will call to test one aspect of an
-// implementation of regression.Store.
-type SubTestFunction func(t *testing.T, store alerts.Store)
-
-// SubTests are all the subtests we have for regression.Store.
-var SubTests = map[string]SubTestFunction{
-	"Store_SaveListDelete": Store_SaveListDelete,
-	"Store_SaveWithID":     Store_SaveWithID,
-}
diff --git a/perf/go/alerts/configprovider_test.go b/perf/go/alerts/configprovider_test.go
index 429bd4e..b4361ba 100644
--- a/perf/go/alerts/configprovider_test.go
+++ b/perf/go/alerts/configprovider_test.go
@@ -16,7 +16,7 @@
 	mutex     sync.Mutex
 }
 
-func (store *MockStore) Save(ctx context.Context, cfg *Alert) error {
+func (store *MockStore) Save(ctx context.Context, req *SaveRequest) error {
 	return nil
 }
 
diff --git a/perf/go/alerts/mock/Store.go b/perf/go/alerts/mock/Store.go
index 34acf6c..5e2ffad 100644
--- a/perf/go/alerts/mock/Store.go
+++ b/perf/go/alerts/mock/Store.go
@@ -63,17 +63,17 @@
 	return r0, r1
 }
 
-// Save provides a mock function with given fields: ctx, cfg
-func (_m *Store) Save(ctx context.Context, cfg *alerts.Alert) error {
-	ret := _m.Called(ctx, cfg)
+// Save provides a mock function with given fields: ctx, req
+func (_m *Store) Save(ctx context.Context, req *alerts.SaveRequest) error {
+	ret := _m.Called(ctx, req)
 
 	if len(ret) == 0 {
 		panic("no return value specified for Save")
 	}
 
 	var r0 error
-	if rf, ok := ret.Get(0).(func(context.Context, *alerts.Alert) error); ok {
-		r0 = rf(ctx, cfg)
+	if rf, ok := ret.Get(0).(func(context.Context, *alerts.SaveRequest) error); ok {
+		r0 = rf(ctx, req)
 	} else {
 		r0 = ret.Error(0)
 	}
diff --git a/perf/go/alerts/sqlalertstore/BUILD.bazel b/perf/go/alerts/sqlalertstore/BUILD.bazel
index 8251361..4dfe6f1 100644
--- a/perf/go/alerts/sqlalertstore/BUILD.bazel
+++ b/perf/go/alerts/sqlalertstore/BUILD.bazel
@@ -28,8 +28,10 @@
     # https://docs.bazel.build/versions/master/be/common-definitions.html#common-attributes-tests
     flaky = True,
     deps = [
-        "//perf/go/alerts/alertstest",
+        "//go/sql/pool",
+        "//perf/go/alerts",
         "//perf/go/sql/sqltest",
+        "@com_github_stretchr_testify//assert",
         "@com_github_stretchr_testify//require",
     ],
 )
diff --git a/perf/go/alerts/sqlalertstore/schema/schema.go b/perf/go/alerts/sqlalertstore/schema/schema.go
index c25ab55..276ef9e 100644
--- a/perf/go/alerts/sqlalertstore/schema/schema.go
+++ b/perf/go/alerts/sqlalertstore/schema/schema.go
@@ -17,4 +17,10 @@
 
 	// Stored as a Unit timestamp.
 	LastModified int `sql:"last_modified INT"`
+
+	// Name of the subscription this alert responds to.
+	SubscriptionName string `sql:"sub_name STRING"`
+
+	// Revision of the associated subscription. Used to query the Subscriptions table.
+	SubscriptionRevision string `sql:"sub_revision STRING"`
 }
diff --git a/perf/go/alerts/sqlalertstore/sqlalertstore.go b/perf/go/alerts/sqlalertstore/sqlalertstore.go
index 1c35dcc..add1480 100644
--- a/perf/go/alerts/sqlalertstore/sqlalertstore.go
+++ b/perf/go/alerts/sqlalertstore/sqlalertstore.go
@@ -5,6 +5,7 @@
 
 import (
 	"context"
+	"database/sql"
 	"encoding/json"
 	"sort"
 	"time"
@@ -38,9 +39,9 @@
 		`,
 	updateAlert: `
 		UPSERT INTO
-			Alerts (id, alert, config_state, last_modified)
+			Alerts (id, alert, config_state, last_modified, sub_name, sub_revision)
 		VALUES
-			($1, $2, $3, $4)
+			($1, $2, $3, $4, $5, $6)
 		`,
 	deleteAlert: `
 		UPDATE
@@ -84,7 +85,9 @@
 }
 
 // Save implements the alerts.Store interface.
-func (s *SQLAlertStore) Save(ctx context.Context, cfg *alerts.Alert) error {
+func (s *SQLAlertStore) Save(ctx context.Context, req *alerts.SaveRequest) error {
+
+	cfg := req.Cfg
 	b, err := json.Marshal(cfg)
 	if err != nil {
 		return skerr.Wrapf(err, "Failed to serialize Alert for saving with ID=%s", cfg.IDAsString)
@@ -99,7 +102,16 @@
 		}
 		cfg.SetIDFromInt64(newID)
 	} else {
-		if _, err := s.db.Exec(ctx, statements[updateAlert], cfg.IDAsStringToInt(), string(b), cfg.StateToInt(), now); err != nil {
+		nameOrNull := sql.NullString{Valid: false}
+		revisionOrNull := sql.NullString{Valid: false}
+
+		if req.SubKey != nil {
+			nameOrNull.String = req.SubKey.SubName
+			nameOrNull.Valid = true
+			revisionOrNull.String = req.SubKey.SubRevision
+			revisionOrNull.Valid = true
+		}
+		if _, err := s.db.Exec(ctx, statements[updateAlert], cfg.IDAsStringToInt(), string(b), cfg.StateToInt(), now, nameOrNull, revisionOrNull); err != nil {
 			return skerr.Wrapf(err, "Failed to update Alert with ID=%s", cfg.IDAsString)
 		}
 	}
diff --git a/perf/go/alerts/sqlalertstore/sqlalertstore_test.go b/perf/go/alerts/sqlalertstore/sqlalertstore_test.go
index f769416..6df01de 100644
--- a/perf/go/alerts/sqlalertstore/sqlalertstore_test.go
+++ b/perf/go/alerts/sqlalertstore/sqlalertstore_test.go
@@ -1,21 +1,286 @@
 package sqlalertstore
 
 import (
+	"context"
+	"database/sql"
+	"encoding/json"
 	"testing"
+	"time"
 
+	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
-	"go.skia.org/infra/perf/go/alerts/alertstest"
+	"go.skia.org/infra/go/sql/pool"
+	"go.skia.org/infra/perf/go/alerts"
 	"go.skia.org/infra/perf/go/sql/sqltest"
 )
 
-func TestSQLAlertStore_CockroachDB(t *testing.T) {
+func setUp(t *testing.T) (alerts.Store, pool.Pool) {
+	db := sqltest.NewCockroachDBForTests(t, "alertstore")
+	store, err := New(db)
+	require.NoError(t, err)
 
-	for name, subTest := range alertstest.SubTests {
-		t.Run(name, func(t *testing.T) {
-			db := sqltest.NewCockroachDBForTests(t, "alertstore")
-			store, err := New(db)
-			require.NoError(t, err)
-			subTest(t, store)
-		})
+	return store, db
+}
+
+// Tests a hypothetical pipeline of Store.
+func TestStore_SaveListDelete(t *testing.T) {
+	ctx := context.Background()
+	store, _ := setUp(t)
+
+	// TODO(jcgregorio) Break up into finer grained tests.
+	cfg := alerts.NewConfig()
+	cfg.Query = "source_type=svg"
+	cfg.DisplayName = "bar"
+	err := store.Save(ctx, &alerts.SaveRequest{
+		Cfg:    cfg,
+		SubKey: nil,
+	})
+	assert.NoError(t, err)
+	require.NotEqual(t, alerts.BadAlertIDAsAsString, cfg.IDAsString)
+
+	// Confirm it appears in the list.
+	cfgs, err := store.List(ctx, false)
+	require.NoError(t, err)
+	require.Len(t, cfgs, 1)
+
+	// Delete it.
+	err = store.Delete(ctx, int(cfgs[0].IDAsStringToInt()))
+	assert.NoError(t, err)
+
+	// Confirm it is still there if we list deleted configs.
+	cfgs, err = store.List(ctx, true)
+	assert.NoError(t, err)
+	assert.Len(t, cfgs, 1)
+	require.NotEqual(t, alerts.BadAlertIDAsAsString, cfgs[0].IDAsString)
+
+	// Confirm it is not there if we don't list deleted configs.
+	cfgs, err = store.List(ctx, false)
+	assert.NoError(t, err)
+	assert.Len(t, cfgs, 0)
+
+	// Store a second config.
+	cfg = alerts.NewConfig()
+	cfg.Query = "source_type=skp"
+	cfg.DisplayName = "foo"
+	err = store.Save(ctx, &alerts.SaveRequest{
+		Cfg:    cfg,
+		SubKey: nil,
+	})
+	assert.NoError(t, err)
+
+	// Confirm they are both listed when including deleted configs, and they are
+	// ordered by DisplayName.
+	cfgs, err = store.List(ctx, true)
+	assert.NoError(t, err)
+	assert.Len(t, cfgs, 2)
+	assert.Equal(t, "bar", cfgs[0].DisplayName)
+	assert.Equal(t, "foo", cfgs[1].DisplayName)
+}
+
+// Tests we can list two active Alerts.
+func TestStoreList_ListActiveAlerts(t *testing.T) {
+	ctx := context.Background()
+	store, db := setUp(t)
+
+	cfg1 := alerts.NewConfig()
+	cfg1.SetIDFromInt64(1)
+	cfg1.Query = "source_type=svg"
+	cfg1.DisplayName = "bar"
+	insertAlertToDb(t, ctx, db, cfg1, nil)
+
+	cfg2 := alerts.NewConfig()
+	cfg2.SetIDFromInt64(2)
+	cfg2.Query = "source_type=skp"
+	cfg2.DisplayName = "foo"
+	insertAlertToDb(t, ctx, db, cfg2, nil)
+
+	cfgs, err := store.List(ctx, true)
+	assert.NoError(t, err)
+	assert.Len(t, cfgs, 2)
+	assert.Equal(t, "bar", cfgs[0].DisplayName)
+	assert.Equal(t, "foo", cfgs[1].DisplayName)
+	assert.Equal(t, "source_type=svg", cfgs[0].Query)
+	assert.Equal(t, "source_type=skp", cfgs[1].Query)
+	assert.Equal(t, "1", cfgs[0].IDAsString)
+	assert.Equal(t, "2", cfgs[1].IDAsString)
+
+	// Confirm all alerts are active.
+	cfgs2, err := store.List(ctx, false)
+	assert.Equal(t, cfgs, cfgs2)
+}
+
+// Tests we can list one deleted Alert.
+func TestStoreList_ListDeletedAlert(t *testing.T) {
+	ctx := context.Background()
+	store, db := setUp(t)
+
+	cfg1 := alerts.NewConfig()
+	cfg1.SetIDFromInt64(1)
+	cfg1.Query = "source_type=svg"
+	cfg1.DisplayName = "bar"
+	insertAlertToDb(t, ctx, db, cfg1, nil)
+
+	cfg2 := alerts.NewConfig()
+	cfg2.SetIDFromInt64(2)
+	cfg2.Query = "source_type=skp"
+	cfg2.DisplayName = "foo"
+	cfg2.StateAsString = alerts.DELETED
+	insertAlertToDb(t, ctx, db, cfg2, nil)
+
+	cfgs, err := store.List(ctx, true)
+	assert.NoError(t, err)
+	assert.Len(t, cfgs, 2)
+	assert.Equal(t, "bar", cfgs[0].DisplayName)
+	assert.Equal(t, "foo", cfgs[1].DisplayName)
+	assert.Equal(t, "source_type=svg", cfgs[0].Query)
+	assert.Equal(t, "source_type=skp", cfgs[1].Query)
+	assert.Equal(t, "1", cfgs[0].IDAsString)
+	assert.Equal(t, "2", cfgs[1].IDAsString)
+
+	// Confirm one alert is active
+	cfgs2, err := store.List(ctx, false)
+	assert.NotEqual(t, cfgs, cfgs2)
+	assert.Len(t, cfgs2, 1)
+	assert.Equal(t, "bar", cfgs2[0].DisplayName)
+	assert.Equal(t, "source_type=svg", cfgs2[0].Query)
+	assert.Equal(t, "1", cfgs2[0].IDAsString)
+}
+
+// Tests we can mark one Alert as deleted using Delete
+func TestStoreDelete_DeleteOneAlert(t *testing.T) {
+	ctx := context.Background()
+	store, db := setUp(t)
+
+	cfg := alerts.NewConfig()
+	cfg.SetIDFromInt64(2)
+	cfg.Query = "source_type=svg"
+	cfg.DisplayName = "bar"
+	insertAlertToDb(t, ctx, db, cfg, nil)
+
+	_, subKey1, configState1 := getAlertFromDb(t, ctx, db, 2)
+	assert.Nil(t, subKey1)
+	assert.Equal(t, alerts.ConfigStateToInt(alerts.ACTIVE), configState1)
+
+	err := store.Delete(ctx, 2)
+	require.NoError(t, err)
+
+	cfg2, subKey2, configState2 := getAlertFromDb(t, ctx, db, 2)
+	assert.Nil(t, subKey2)
+	assert.Equal(t, "bar", cfg2.DisplayName)
+	assert.Equal(t, "source_type=svg", cfg2.Query)
+	assert.Equal(t, "2", cfg2.IDAsString)
+	assert.Equal(t, alerts.ConfigStateToInt(alerts.DELETED), configState2)
+}
+
+// Tests that inserting an ID with -1 ID, generates a new ID for the Alert.
+func TestStoreSave_SaveWithBadID(t *testing.T) {
+	ctx := context.Background()
+	store, db := setUp(t)
+
+	cfg := alerts.NewConfig()
+	cfg.SetIDFromInt64(alerts.BadAlertID)
+	cfg.Query = "source_type=svg"
+	cfg.DisplayName = "bar"
+	err := store.Save(ctx, &alerts.SaveRequest{
+		Cfg:    cfg,
+		SubKey: nil,
+	})
+	require.NoError(t, err)
+
+	_, subKey1, configState1 := getAlertFromDb(t, ctx, db, cfg.IDAsStringToInt())
+	assert.Nil(t, subKey1)
+	assert.Equal(t, alerts.ConfigStateToInt(alerts.ACTIVE), configState1)
+	assert.NotEqual(t, alerts.BadAlertID, cfg.IDAsStringToInt())
+}
+
+// Tests inserting an Alert with valid ID.
+func TestStoreSave_SaveWithValidID(t *testing.T) {
+	ctx := context.Background()
+	store, db := setUp(t)
+
+	cfg := alerts.NewConfig()
+	cfg.SetIDFromInt64(1)
+	cfg.Query = "source_type=svg"
+	cfg.DisplayName = "bar"
+	err := store.Save(ctx, &alerts.SaveRequest{
+		Cfg:    cfg,
+		SubKey: nil,
+	})
+	require.NoError(t, err)
+	_, subKey, configState := getAlertFromDb(t, ctx, db, 1)
+	assert.Nil(t, subKey)
+	assert.Equal(t, alerts.ConfigStateToInt(alerts.ACTIVE), configState)
+}
+
+// Tests inserting an Alert with an associated Subscription
+func TestStoreSave_SaveWithSubscription(t *testing.T) {
+	ctx := context.Background()
+	store, db := setUp(t)
+
+	cfg := alerts.NewConfig()
+	cfg.SetIDFromInt64(1)
+	cfg.Query = "source_type=svg"
+	cfg.DisplayName = "bar"
+	err := store.Save(ctx, &alerts.SaveRequest{
+		Cfg: cfg,
+		SubKey: &alerts.SubKey{
+			SubName:     "Test Subscription",
+			SubRevision: "abcd",
+		},
+	})
+	require.NoError(t, err)
+
+	_, subKey, configState := getAlertFromDb(t, ctx, db, 1)
+	assert.Equal(t, "Test Subscription", subKey.SubName)
+	assert.Equal(t, "abcd", subKey.SubRevision)
+	assert.Equal(t, alerts.ConfigStateToInt(alerts.ACTIVE), configState)
+}
+
+func insertAlertToDb(t *testing.T, ctx context.Context, db pool.Pool, cfg *alerts.Alert, subKey *alerts.SubKey) {
+	b, err := json.Marshal(cfg)
+	require.NoError(t, err)
+
+	nameOrNull := sql.NullString{Valid: false}
+	revisionOrNull := sql.NullString{Valid: false}
+
+	if subKey != nil {
+		nameOrNull.String = subKey.SubName
+		nameOrNull.Valid = true
+		revisionOrNull.String = subKey.SubRevision
+		revisionOrNull.Valid = true
 	}
+	const query = `UPSERT INTO Alerts
+        (id, alert, config_state, last_modified, sub_name, sub_revision)
+        VALUES ($1,$2,$3,$4,$5,$6)`
+	if _, err := db.Exec(ctx, query, cfg.IDAsStringToInt(), string(b), cfg.StateToInt(), time.Now().Unix(), nameOrNull, revisionOrNull); err != nil {
+		require.NoError(t, err)
+	}
+}
+
+func getAlertFromDb(t *testing.T, ctx context.Context, db pool.Pool, id int64) (*alerts.Alert, *alerts.SubKey, int) {
+	alert := &alerts.Alert{}
+	var serializedAlert string
+
+	var nameOrNull sql.NullString
+	var revisionOrNull sql.NullString
+	var configState int
+	err := db.QueryRow(ctx, "SELECT alert, sub_name, sub_revision, config_state FROM Alerts WHERE id = $1", id).Scan(
+		&serializedAlert,
+		&nameOrNull,
+		&revisionOrNull,
+		&configState,
+	)
+	require.NoError(t, err)
+
+	err = json.Unmarshal([]byte(serializedAlert), alert)
+	require.NoError(t, err)
+
+	if !nameOrNull.Valid || !revisionOrNull.Valid {
+		return alert, nil, configState
+	}
+
+	return alert, &alerts.SubKey{
+		SubName:     nameOrNull.String,
+		SubRevision: revisionOrNull.String,
+	}, configState
 }
diff --git a/perf/go/alerts/store.go b/perf/go/alerts/store.go
index 4dd6548..9fb5f29 100644
--- a/perf/go/alerts/store.go
+++ b/perf/go/alerts/store.go
@@ -2,11 +2,21 @@
 
 import "context"
 
+type SubKey struct {
+	SubName     string
+	SubRevision string
+}
+
+type SaveRequest struct {
+	Cfg    *Alert
+	SubKey *SubKey
+}
+
 // Store is the interface used to persist Alerts.
 type Store interface {
 	// Save can write a new, or update an existing, Config. New Configs will
 	// have an ID of -1. On insert the ID of the Alert will be updated.
-	Save(ctx context.Context, cfg *Alert) error
+	Save(ctx context.Context, req *SaveRequest) error
 
 	// Delete removes the Alert with the given id.
 	Delete(ctx context.Context, id int) error
diff --git a/perf/go/builders/BUILD.bazel b/perf/go/builders/BUILD.bazel
index 58554ff..9fdfcf0 100644
--- a/perf/go/builders/BUILD.bazel
+++ b/perf/go/builders/BUILD.bazel
@@ -61,7 +61,6 @@
     deps = [
         "//go/emulators/cockroachdb_instance",
         "//go/paramtools",
-        "//perf/go/alerts/alertstest",
         "//perf/go/config",
         "//perf/go/file/dirsource",
         "//perf/go/git/gittest",
diff --git a/perf/go/builders/builders_test.go b/perf/go/builders/builders_test.go
index b0346a5..21515d3 100644
--- a/perf/go/builders/builders_test.go
+++ b/perf/go/builders/builders_test.go
@@ -11,7 +11,6 @@
 
 	"go.skia.org/infra/go/emulators/cockroachdb_instance"
 	"go.skia.org/infra/go/paramtools"
-	"go.skia.org/infra/perf/go/alerts/alertstest"
 	"go.skia.org/infra/perf/go/config"
 	"go.skia.org/infra/perf/go/file/dirsource"
 	"go.skia.org/infra/perf/go/git/gittest"
@@ -110,15 +109,6 @@
 	assert.Contains(t, err.Error(), invalidDataStoreType)
 }
 
-func TestNewAlertStoreFromConfig_CockroachDB_Success(t *testing.T) {
-	ctx, instanceConfig := newCockroachDBConfigForTest(t)
-
-	store, err := NewAlertStoreFromConfig(ctx, false, instanceConfig)
-	require.NoError(t, err)
-
-	alertstest.Store_SaveListDelete(t, store)
-}
-
 func TestNewAlertStoreFromConfig_InvalidDatastoreTypeIsError(t *testing.T) {
 	ctx, instanceConfig := newCockroachDBConfigForTest(t)
 
diff --git a/perf/go/frontend/frontend.go b/perf/go/frontend/frontend.go
index e35279a..b02c661 100644
--- a/perf/go/frontend/frontend.go
+++ b/perf/go/frontend/frontend.go
@@ -1734,7 +1734,7 @@
 		httputils.ReportError(w, err, "Invalid Alert", http.StatusInternalServerError)
 	}
 
-	if err := f.alertStore.Save(ctx, cfg); err != nil {
+	if err := f.alertStore.Save(ctx, &alerts.SaveRequest{Cfg: cfg}); err != nil {
 		httputils.ReportError(w, err, "Failed to save alerts.Config.", http.StatusInternalServerError)
 	}
 	err := json.NewEncoder(w).Encode(AlertUpdateResponse{
diff --git a/perf/go/perf-tool/application/application.go b/perf/go/perf-tool/application/application.go
index a94c577..f2cb8c8 100644
--- a/perf/go/perf-tool/application/application.go
+++ b/perf/go/perf-tool/application/application.go
@@ -461,7 +461,7 @@
 		if err != nil {
 			return skerr.Wrap(err)
 		}
-		if err := alertStore.Save(ctx, &alert); err != nil {
+		if err := alertStore.Save(ctx, &alerts.SaveRequest{Cfg: &alert}); err != nil {
 			return skerr.Wrap(err)
 		}
 		fmt.Printf("Alerts: %q\n", alert.DisplayName)
diff --git a/perf/go/sql/expectedschema/migrate.go b/perf/go/sql/expectedschema/migrate.go
index c1e42bc..5cd61d1 100644
--- a/perf/go/sql/expectedschema/migrate.go
+++ b/perf/go/sql/expectedschema/migrate.go
@@ -37,17 +37,17 @@
 // DO NOT DROP TABLES IN VAR BELOW.
 // FOR MODIFYING COLUMNS USE ADD/DROP COLUMN INSTEAD.
 var FromLiveToNext = `
-	ALTER TABLE Regressions
-	ADD COLUMN migrated BOOLEAN,
-	ADD COLUMN regression_id TEXT;
+	ALTER TABLE Alerts
+	ADD COLUMN sub_name STRING,
+	ADD COLUMN sub_revision STRING;
 `
 
 // ONLY DROP TABLE IF YOU JUST CREATED A NEW TABLE.
 // FOR MODIFYING COLUMNS USE ADD/DROP COLUMN INSTEAD.
 var FromNextToLive = `
-	ALTER TABLE Regressions
-	DROP COLUMN migrated,
-	DROP COLUMN regression_id;
+	ALTER TABLE Alerts
+	DROP COLUMN sub_name,
+	DROP COLUMN sub_revision;
 `
 
 // This function will check whether there's a new schema checked-in,
diff --git a/perf/go/sql/expectedschema/schema.json b/perf/go/sql/expectedschema/schema.json
index ef59728..bd37323 100644
--- a/perf/go/sql/expectedschema/schema.json
+++ b/perf/go/sql/expectedschema/schema.json
@@ -4,6 +4,8 @@
     "alerts.config_state": "bigint def:0:::INT8 nullable:YES",
     "alerts.id": "bigint def:unique_rowid() nullable:NO",
     "alerts.last_modified": "bigint def: nullable:YES",
+    "alerts.sub_name": "text def: nullable:YES",
+    "alerts.sub_revision": "text def: nullable:YES",
     "anomalygroups.action": "text def: nullable:YES",
     "anomalygroups.action_time": "timestamp with time zone def: nullable:YES",
     "anomalygroups.anomaly_ids": "ARRAY def: nullable:YES",
diff --git a/perf/go/sql/expectedschema/schema_prev.json b/perf/go/sql/expectedschema/schema_prev.json
index d8a50f0..ef59728 100644
--- a/perf/go/sql/expectedschema/schema_prev.json
+++ b/perf/go/sql/expectedschema/schema_prev.json
@@ -39,7 +39,9 @@
     "postings.trace_id": "bytea def: nullable:NO",
     "regressions.alert_id": "bigint def: nullable:NO",
     "regressions.commit_number": "bigint def: nullable:NO",
+    "regressions.migrated": "boolean def: nullable:YES",
     "regressions.regression": "text def: nullable:YES",
+    "regressions.regression_id": "text def: nullable:YES",
     "regressions2.alert_id": "bigint def: nullable:YES",
     "regressions2.cluster_summary": "jsonb def: nullable:YES",
     "regressions2.cluster_type": "text def: nullable:YES",
diff --git a/perf/go/sql/schema.go b/perf/go/sql/schema.go
index 8695561..e6bfb24 100644
--- a/perf/go/sql/schema.go
+++ b/perf/go/sql/schema.go
@@ -7,7 +7,9 @@
   id INT PRIMARY KEY DEFAULT unique_rowid(),
   alert TEXT,
   config_state INT DEFAULT 0,
-  last_modified INT
+  last_modified INT,
+  sub_name STRING,
+  sub_revision STRING
 );
 CREATE TABLE IF NOT EXISTS AnomalyGroups (
   id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -121,6 +123,8 @@
 	"alert",
 	"config_state",
 	"last_modified",
+	"sub_name",
+	"sub_revision",
 }
 
 var AnomalyGroups = []string{
diff --git a/perf/go/sql/sql_test.go b/perf/go/sql/sql_test.go
index e435f9b..8fcd37b 100644
--- a/perf/go/sql/sql_test.go
+++ b/perf/go/sql/sql_test.go
@@ -92,6 +92,8 @@
 	commit_number INT,
 	alert_id INT,
 	regression TEXT,
+	migrated BOOL,
+	regression_id TEXT,
 	PRIMARY KEY (commit_number, alert_id)
   );
   CREATE TABLE IF NOT EXISTS Regressions2 (