[gold] Add firestore implementation of ignorestore.
Note to reviewer: PS1 adds just Create and List.
Change-Id: Ifd5f0a82bc4299d29ed3f3483624667d7cc7b9e8
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/264318
Reviewed-by: Leandro Lovisolo <lovisolo@google.com>
diff --git a/golden/go/ignore/fs_ignorestore/FIRESTORE.md b/golden/go/ignore/fs_ignorestore/FIRESTORE.md
new file mode 100644
index 0000000..264074b
--- /dev/null
+++ b/golden/go/ignore/fs_ignorestore/FIRESTORE.md
@@ -0,0 +1,32 @@
+Storing Ignore Rules in Firestore
+=================================
+
+We need to store the user-created rules for ignoring certain sets of traces.
+
+Schema
+------
+
+We should have one Firestore Collection (i.e. table) for these rules.
+
+ ruleEntry
+ # ID will be autogenerated
+ CreatedBy string # email address
+ UpdatedBy string # email address
+ Expires time.Time
+ Query string
+ Note string
+
+Indexing
+--------
+
+No indexes required.
+
+Usage
+-----
+
+We just Add, Update, Delete, and List (all).
+
+Growth Opportunities
+-------------------
+
+None projected.
\ No newline at end of file
diff --git a/golden/go/ignore/fs_ignorestore/fs_ignorestore.go b/golden/go/ignore/fs_ignorestore/fs_ignorestore.go
new file mode 100644
index 0000000..71922bc
--- /dev/null
+++ b/golden/go/ignore/fs_ignorestore/fs_ignorestore.go
@@ -0,0 +1,195 @@
+// Package fs_ignorestore hosts a Firestore-based implementation of ignore.Store.
+package fs_ignorestore
+
+import (
+ "context"
+ "math/rand"
+ "sort"
+ "sync"
+ "time"
+
+ "cloud.google.com/go/firestore"
+
+ ifirestore "go.skia.org/infra/go/firestore"
+ "go.skia.org/infra/go/skerr"
+ "go.skia.org/infra/go/sklog"
+ "go.skia.org/infra/golden/go/ignore"
+)
+
+const (
+ // These are the collections in Firestore.
+ rulesCollection = "ignorestore_rules"
+
+ maxReadAttempts = 5
+ maxWriteAttempts = 5
+ maxOperationTime = time.Minute
+
+ // recoverTime is the minimum amount of time to wait before recreating any QuerySnapshotIterator
+ // if it fails. A random amount of time should be added to this, proportional to recoverTime.
+ recoverTime = 30 * time.Second
+)
+
+// StoreImpl is the Firestore-based implementation of ignore.Store. It uses query snapshots to
+// synchronize a local cache with the data in Firestore, thus it does not need to poll. We don't
+// do any searches on our data, so keeping all the ignore rules in RAM (backed by Firestore) is
+// a fine solution for the sake of performance (and avoiding unnecessary Firestore traffic). Ignore
+// Rules generally top out in the 100s, plenty to fit in RAM.
+type StoreImpl struct {
+ client *ifirestore.Client
+
+ cacheMutex sync.RWMutex
+ cache map[string]ignore.Rule
+}
+
+// ruleEntry represents how an ignore.Rule is stored in Firestore.
+type ruleEntry struct {
+ CreatedBy string `firestore:"name"`
+ UpdatedBy string `firestore:"updatedby"`
+ Expires time.Time `firestore:"expires"`
+ Query string `firestore:"query"`
+ Note string `firestore:"note"`
+}
+
+func toRule(id string, r ruleEntry) ignore.Rule {
+ return ignore.Rule{
+ ID: id,
+ CreatedBy: r.CreatedBy,
+ UpdatedBy: r.UpdatedBy,
+ Expires: r.Expires,
+ Query: r.Query,
+ Note: r.Note,
+ }
+}
+
+func toEntry(r ignore.Rule) ruleEntry {
+ return ruleEntry{
+ CreatedBy: r.CreatedBy,
+ UpdatedBy: r.UpdatedBy,
+ Expires: r.Expires,
+ Query: r.Query,
+ Note: r.Note,
+ }
+}
+
+// New returns a new StoreImpl.
+func New(ctx context.Context, client *ifirestore.Client) *StoreImpl {
+ s := &StoreImpl{
+ client: client,
+ cache: map[string]ignore.Rule{},
+ }
+ s.startQueryIterator(ctx)
+ return s
+}
+
+// startQueryIterator sets up the listener to the Query Snapshots which keep the local cache of
+// ignore rules in sync with those in Firestore.
+func (s *StoreImpl) startQueryIterator(ctx context.Context) {
+ go func() {
+ // TODO(kjlubick) deduplicate this logic with fs_expstore maybe? We'd like to be able to
+ // recover, so maybe we need a variant of QuerySnapshotChannel
+ snap := s.client.Collection(rulesCollection).Snapshots(ctx)
+ for {
+ if err := ctx.Err(); err != nil {
+ sklog.Debugf("Stopping query of ignores due to context error: %s", err)
+ snap.Stop()
+ return
+ }
+ qs, err := snap.Next()
+ if err != nil {
+ sklog.Errorf("reading query snapshot: %s", err)
+ snap.Stop()
+ // sleep and rebuild the snapshot query. Once a SnapshotQueryIterator returns
+ // an error, it seems to always return that error.
+ t := recoverTime + time.Duration(float32(recoverTime)*rand.Float32())
+ time.Sleep(t)
+ sklog.Infof("Trying to recreate query snapshot after having slept %s", t)
+ snap = s.client.Collection(rulesCollection).Snapshots(ctx)
+ continue
+ }
+ s.updateCacheWithEntriesFrom(qs)
+ }
+ }()
+}
+
+// updateCacheWithEntriesFrom loops through all the changes in the given snapshot and updates
+// the cache with those new values (or deletes the old ones).
+func (s *StoreImpl) updateCacheWithEntriesFrom(qs *firestore.QuerySnapshot) {
+ s.cacheMutex.Lock()
+ defer s.cacheMutex.Unlock()
+ for _, dc := range qs.Changes {
+ id := dc.Doc.Ref.ID
+ if dc.Kind == firestore.DocumentRemoved {
+ delete(s.cache, id)
+ continue
+ }
+ entry := ruleEntry{}
+ if err := dc.Doc.DataTo(&entry); err != nil {
+ sklog.Errorf("corrupt data in firestore, could not unmarshal ruleEntry with id %s", id)
+ continue
+ }
+ s.cache[id] = toRule(id, entry)
+ }
+}
+
+// Create implements the ignore.Store interface.
+func (s *StoreImpl) Create(ctx context.Context, r ignore.Rule) error {
+ doc := s.client.Collection(rulesCollection).NewDoc()
+ if _, err := s.client.Create(ctx, doc, toEntry(r), maxWriteAttempts, maxOperationTime); err != nil {
+ return skerr.Wrapf(err, "storing new ignore rule to Firestore (%#v)", r)
+ }
+ return nil
+}
+
+// List implements the ignore.Store interface. It returns the local cache of ignore rules, never
+// re-fetching from Firestore because the query snapshots should keep the local cache up to date,
+// give or take a few seconds.
+func (s *StoreImpl) List(_ context.Context) ([]ignore.Rule, error) {
+ s.cacheMutex.RLock()
+ defer s.cacheMutex.RUnlock()
+ rv := make([]ignore.Rule, 0, len(s.cache))
+ for _, r := range s.cache {
+ rv = append(rv, r)
+ }
+ sort.Slice(rv, func(i, j int) bool {
+ return rv[i].Expires.Before(rv[j].Expires)
+ })
+ return rv, nil
+}
+
+// Update implements the ignore.Store interface.
+func (s *StoreImpl) Update(ctx context.Context, rule ignore.Rule) error {
+ if rule.ID == "" {
+ return skerr.Fmt("ID for ignore rule cannot be empty")
+ }
+ doc := s.client.Collection(rulesCollection).Doc(rule.ID)
+ dSnap, err := s.client.Get(ctx, doc, maxReadAttempts, maxOperationTime)
+ if err != nil {
+ return skerr.Wrapf(err, "getting ignore rule %s before updating", rule.ID)
+ }
+ var oldRule ruleEntry
+ if err := dSnap.DataTo(&oldRule); err != nil {
+ return skerr.Wrapf(err, "corrupt data in firestore for id %s", rule.ID)
+ }
+ updatedRule := toEntry(rule)
+ updatedRule.CreatedBy = oldRule.CreatedBy
+ if _, err := s.client.Set(ctx, doc, updatedRule, maxWriteAttempts, maxOperationTime); err != nil {
+ return skerr.Wrapf(err, "updating ignore rule %s", rule.ID)
+ }
+ return nil
+}
+
+// Delete implements the ignore.Store interface.
+func (s *StoreImpl) Delete(ctx context.Context, id string) (bool, error) {
+ if id == "" {
+ return false, skerr.Fmt("ID for ignore rule cannot be empty")
+ }
+ s.client.Collection(rulesCollection).Doc(id)
+ if wr, err := s.client.Collection(rulesCollection).Doc(id).Delete(ctx); err != nil {
+ return false, skerr.Wrapf(err, "deleting ignore rule with id %s", id)
+ } else if wr.UpdateTime.Unix() <= 0 {
+ // UpdateTime is only set if something is modified. If the document didn't exist, UpdateTime
+ // will be set to the epoch.
+ return false, nil
+ }
+ return true, nil
+}
diff --git a/golden/go/ignore/fs_ignorestore/fs_ignorestore_test.go b/golden/go/ignore/fs_ignorestore/fs_ignorestore_test.go
new file mode 100644
index 0000000..3865366
--- /dev/null
+++ b/golden/go/ignore/fs_ignorestore/fs_ignorestore_test.go
@@ -0,0 +1,211 @@
+package fs_ignorestore
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "go.skia.org/infra/go/deepequal"
+ "go.skia.org/infra/go/firestore"
+ "go.skia.org/infra/go/testutils/unittest"
+ "go.skia.org/infra/golden/go/ignore"
+)
+
+func TestCreateListIgnoreRule(t *testing.T) {
+ unittest.LargeTest(t)
+ c, cleanup := firestore.NewClientForTesting(t)
+ defer cleanup()
+
+ ctx := context.Background()
+ f := newEmptyStore(ctx, t, c)
+
+ xir := makeIgnoreRules()
+ // Add them in a not-sorted order to make sure List sorts them.
+ require.NoError(t, f.Create(ctx, xir[2]))
+ require.NoError(t, f.Create(ctx, xir[0]))
+ require.NoError(t, f.Create(ctx, xir[3]))
+ require.NoError(t, f.Create(ctx, xir[1]))
+
+ requireCurrentListMatchesExpected(t, ctx, f)
+}
+
+func TestCreateDelete(t *testing.T) {
+ unittest.LargeTest(t)
+ c, cleanup := firestore.NewClientForTesting(t)
+ defer cleanup()
+
+ ctx := context.Background()
+ f := newEmptyStore(ctx, t, c)
+
+ xir := makeIgnoreRules()
+ // Add 0, 1, 2, 2, 2, 2, 3 (there are 3 extra of index 2)
+ require.NoError(t, f.Create(ctx, xir[0]))
+ require.NoError(t, f.Create(ctx, xir[1]))
+ require.NoError(t, f.Create(ctx, xir[2]))
+ require.NoError(t, f.Create(ctx, xir[2]))
+ require.NoError(t, f.Create(ctx, xir[2]))
+ require.NoError(t, f.Create(ctx, xir[2]))
+ require.NoError(t, f.Create(ctx, xir[3]))
+ // Wait until those 7 rules are in the list
+ require.Eventually(t, func() bool {
+ actualRules, _ := f.List(ctx)
+ return len(actualRules) == 7
+ }, 5*time.Second, 200*time.Millisecond)
+ // Re-query the rules to make sure none got dropped or added unexpectedly.
+ actualRules, err := f.List(ctx)
+ require.NoError(t, err)
+ require.Len(t, actualRules, 7) // should still have 7 elements in the list
+
+ ok, err := f.Delete(ctx, actualRules[3].ID)
+ require.NoError(t, err)
+ assert.True(t, ok)
+ ok, err = f.Delete(ctx, actualRules[4].ID)
+ require.NoError(t, err)
+ assert.True(t, ok)
+ ok, err = f.Delete(ctx, actualRules[5].ID)
+ require.NoError(t, err)
+ assert.True(t, ok)
+
+ requireCurrentListMatchesExpected(t, ctx, f)
+}
+
+func TestDeleteNonExistentRule(t *testing.T) {
+ unittest.LargeTest(t)
+ c, cleanup := firestore.NewClientForTesting(t)
+ defer cleanup()
+
+ ctx := context.Background()
+ f := New(ctx, c)
+ ok, err := f.Delete(ctx, "Not in there")
+ require.NoError(t, err)
+ assert.False(t, ok)
+}
+
+func TestDeleteEmptyRule(t *testing.T) {
+ unittest.LargeTest(t)
+ c, cleanup := firestore.NewClientForTesting(t)
+ defer cleanup()
+
+ ctx := context.Background()
+ f := New(ctx, c)
+ _, err := f.Delete(ctx, "")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "empty")
+}
+
+func TestCreateUpdate(t *testing.T) {
+ unittest.LargeTest(t)
+ c, cleanup := firestore.NewClientForTesting(t)
+ defer cleanup()
+
+ ctx := context.Background()
+ f := newEmptyStore(ctx, t, c)
+
+ xir := makeIgnoreRules()
+ require.NoError(t, f.Create(ctx, xir[1]))
+ // Wait until that rule is in the list
+ require.Eventually(t, func() bool {
+ actualRules, _ := f.List(ctx)
+ return len(actualRules) == 1
+ }, 5*time.Second, 200*time.Millisecond)
+
+ actualRules, err := f.List(ctx)
+ require.NoError(t, err)
+ toUpdateID := actualRules[0].ID
+ newContent := xir[0]
+ newContent.ID = toUpdateID
+
+ require.NoError(t, f.Update(ctx, newContent))
+ require.Eventually(t, func() bool {
+ rules, err := f.List(ctx)
+ assert.NoError(t, err)
+ return compareIgnoreRulesIgnoringIDs(rules, []ignore.Rule{
+ {
+ // Notice the Name is the same as the original rule, but everything else is changed.
+ CreatedBy: "beta@example.com",
+ UpdatedBy: "alpha@example.com",
+ Expires: now.Add(-time.Hour),
+ Query: "config=gpu",
+ Note: "expired",
+ },
+ })
+ }, 5*time.Second, 200*time.Millisecond)
+}
+
+func TestUpdateNonExistentRule(t *testing.T) {
+ unittest.LargeTest(t)
+ c, cleanup := firestore.NewClientForTesting(t)
+ defer cleanup()
+
+ ctx := context.Background()
+ f := New(ctx, c)
+ ir := makeIgnoreRules()[0]
+ ir.ID = "whoops"
+ err := f.Update(ctx, ir)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "before updating")
+}
+
+func TestUpdateEmptyRule(t *testing.T) {
+ unittest.LargeTest(t)
+ c, cleanup := firestore.NewClientForTesting(t)
+ defer cleanup()
+
+ ctx := context.Background()
+ f := New(ctx, c)
+
+ err := f.Update(ctx, ignore.Rule{})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "empty")
+}
+
+var now = time.Date(2020, time.January, 13, 14, 15, 16, 0, time.UTC)
+
+func newEmptyStore(ctx context.Context, t *testing.T, c *firestore.Client) *StoreImpl {
+ f := New(ctx, c)
+ empty, err := f.List(ctx)
+ require.NoError(t, err)
+ require.Empty(t, empty)
+ return f
+}
+
+func makeIgnoreRules() []ignore.Rule {
+ return []ignore.Rule{
+ ignore.NewRule("alpha@example.com", now.Add(-time.Hour), "config=gpu", "expired"),
+ ignore.NewRule("beta@example.com", now.Add(time.Minute*10), "config=8888", "No good reason."),
+ ignore.NewRule("beta@example.com", now.Add(time.Minute*50), "extra=123&extra=abc", "Ignore multiple."),
+ ignore.NewRule("alpha@example.com", now.Add(time.Minute*100), "extra=123&extra=abc&config=8888", "Ignore multiple 2."),
+ }
+}
+
+// compareIgnoreRulesIgnoringIDs returns true if the two lists of rules match (disregarding the ID).
+// ID is ignored because it is nondeterministic.
+func compareIgnoreRulesIgnoringIDs(first []ignore.Rule, second []ignore.Rule) bool {
+ if len(first) != len(second) {
+ return false
+ }
+ for i := range first {
+ r1, r2 := first[i], second[i]
+ r1.ID = ""
+ r2.ID = ""
+ if !deepequal.DeepEqual(r1, r2) {
+ return false
+ }
+ }
+ return true
+}
+
+// requireCurrentListMatchesExpected either returns because the content in the given store
+// matches makeIgnoreRules() or it panics because it does not match.
+func requireCurrentListMatchesExpected(t *testing.T, ctx context.Context, f *StoreImpl) {
+ // List uses a query snapshot, which is not synchronous, so we might have to query a few times
+ // before everything syncs up.
+ require.Eventually(t, func() bool {
+ actualRules, err := f.List(ctx)
+ assert.NoError(t, err)
+ return compareIgnoreRulesIgnoringIDs(actualRules, makeIgnoreRules())
+ }, 5*time.Second, 200*time.Millisecond)
+}
diff --git a/golden/go/ignore/ignore.go b/golden/go/ignore/ignore.go
index 6add555..47e254a 100644
--- a/golden/go/ignore/ignore.go
+++ b/golden/go/ignore/ignore.go
@@ -21,7 +21,8 @@
// List returns all ignore rules in the ignore store.
List(context.Context) ([]Rule, error)
- // Update sets a Rule.
+ // Update sets a Rule. The "Name" field should be ignored, but all other fields should be
+ // applied to the existing data.
Update(ctx context.Context, rule Rule) error
// Delete removes a Rule from the store. The boolean indicates if the deletion was successful