Repository: iter8-tools/iter8
Pull request #1486 (2023-05-30 14:24:14)

Add methods to storageclient

Alan-Cha: Questions to be answered:

* How to store data?
  * Serialize the application name/application version/user name/metric name?
    * Use [prefix](https://dgraph.io/docs/badger/get-started/#prefix-scans) scan to get a particular application/application version?
  * Multiple databases for each application? For each application + application version?
  * Use one byte [metadata](https://dgraph.io/docs/badger/get-started/#setting-time-to-live-ttl-and-user-metadata-on-keys)?
* How to delete data?
  * Set TTL?
    * How long should the TTL be?
    * Should this be user configurable?
Alan-Cha: @sriumcp Commented it out.

Patch:
diff --git a/controllers/allcontrollers.go b/controllers/allcontrollers.go
index e7550f2f..ed52242b 100644
--- a/controllers/allcontrollers.go
+++ b/controllers/allcontrollers.go
@@ -238,7 +238,7 @@ func Start(stopCh <-chan struct{}, client k8sclient.Interface) error {
 
 		// TODO: expose badgerDB options in config?
 		var dbClient storageclient.Interface
-		dbClient, err = badgerdb.GetClient(badger.DefaultOptions(MetricsPath))
+		dbClient, err = badgerdb.GetClient(badger.DefaultOptions(MetricsPath), badgerdb.AdditionalOptions{})
 		fmt.Println(dbClient, err)
 	}
 
diff --git a/controllers/storageclient/badgerdb/simple.go b/controllers/storageclient/badgerdb/simple.go
index d508fffd..4e3e9541 100644
--- a/controllers/storageclient/badgerdb/simple.go
+++ b/controllers/storageclient/badgerdb/simple.go
@@ -3,19 +3,30 @@ package badgerdb
 
 import (
 	"errors"
+	"fmt"
 	"os"
+	"strings"
+	"time"
 
 	"github.com/dgraph-io/badger/v4"
 	"github.com/imdario/mergo"
+	"github.com/iter8-tools/iter8/controllers/storageclient"
 )
 
 // Client is a client for the BadgerDB
 type Client struct {
 	db *badger.DB
+
+	additionalOptions AdditionalOptions
+}
+
+// AdditionalOptions are additional options for setting up BadgerDB
+type AdditionalOptions struct {
+	TTL time.Duration
 }
 
 // GetClient gets a client for the BadgerDB
-func GetClient(opts badger.Options) (*Client, error) {
+func GetClient(opts badger.Options, additionalOptions AdditionalOptions) (*Client, error) {
 	// check if Dir and ValueDir are set and are equal
 	dir := opts.Dir           // Dir is the path of the directory where key data will be stored in.
 	valueDir := opts.ValueDir // ValueDir is the path of the directory where value data will be stored in.
@@ -48,5 +59,90 @@ func GetClient(opts badger.Options) (*Client, error) {
 	}
 	client.db = db
 
+	// add additionalOptions
+	a := getDefaultAdditionalOptions()
+	err = mergo.Merge(&a, additionalOptions)
+	if err != nil {
+		return nil, fmt.Errorf("cannot merge additionalOptions with defaultOptions for BadgerDB: %e", err)
+	}
+	client.additionalOptions = a
+
 	return &client, nil
 }
+
+func getDefaultAdditionalOptions() AdditionalOptions {
+	return AdditionalOptions{
+		TTL: time.Hour * 24,
+	}
+}
+
+func validateKeyToken(s string) error {
+	if strings.Contains(s, ":") {
+		return errors.New("key token contains \":\"")
+	}
+
+	return nil
+}
+
+func getMetricKey(applicationName string, version int, signature, metric, user, transaction string) (string, error) {
+	if err := validateKeyToken(applicationName); err != nil {
+		return "", errors.New("application name cannot have \":\"")
+	}
+	if err := validateKeyToken(signature); err != nil {
+		return "", errors.New("signature cannot have \":\"")
+	}
+	if err := validateKeyToken(metric); err != nil {
+		return "", errors.New("metric name cannot have \":\"")
+	}
+	if err := validateKeyToken(user); err != nil {
+		return "", errors.New("user name cannot have \":\"")
+	}
+	if err := validateKeyToken(transaction); err != nil {
+		return "", errors.New("transaction ID cannot have \":\"")
+	}
+
+	return fmt.Sprintf("kt-metric::%s::%d::%s::%s::%s::%s", applicationName, version, signature, metric, user, transaction), nil
+}
+
+// SetMetric sets a metric based on the app name, version, signature, metric type, user name, transaction ID, and metric value with BadgerDB
+func (cl Client) SetMetric(applicationName string, version int, signature, metric, user, transaction string, metricValue float64) error {
+	key, err := getMetricKey(applicationName, version, signature, metric, user, transaction)
+	if err != nil {
+		return err
+	}
+
+	err = cl.db.Update(func(txn *badger.Txn) error {
+		e := badger.NewEntry([]byte(key), []byte(fmt.Sprintf("%f", metricValue))).WithTTL(cl.additionalOptions.TTL)
+		err := txn.SetEntry(e)
+		return err
+	})
+	if err != nil {
+		return fmt.Errorf("cannot set metric with key \"%s\": %w", key, err)
+	}
+
+	// update user
+	err = cl.SetUser(applicationName, version, signature, user)
+
+	return err
+}
+
+func getUserKey(applicationName string, version int, signature, user string) string {
+	return fmt.Sprintf("kt-metric::%s::%d::%s::%s", applicationName, version, signature, user)
+}
+
+// SetUser sets a user based on the app name, version, signature, and user name with BadgerDB
+// Example key/value: kt-users::my-app::0::my-signature::my-user -> true
+func (cl Client) SetUser(applicationName string, version int, signature, user string) error {
+	key := getUserKey(applicationName, version, signature, user)
+
+	return cl.db.Update(func(txn *badger.Txn) error {
+		e := badger.NewEntry([]byte(key), []byte("true")).WithTTL(cl.additionalOptions.TTL)
+		err := txn.SetEntry(e)
+		return err
+	})
+}
+
+// GetSummaryMetrics gets a summary of all the metrics from all versions of an application
+func (cl Client) GetSummaryMetrics(applicationName string) (*map[int]storageclient.VersionMetricSummary, error) {
+	return nil, nil
+}
diff --git a/controllers/storageclient/interface.go b/controllers/storageclient/interface.go
index c865dfbc..91fdc4f6 100644
--- a/controllers/storageclient/interface.go
+++ b/controllers/storageclient/interface.go
@@ -1,10 +1,22 @@
 // Package storageclient provides the storage client for the controllers package
 package storageclient
 
+import "github.com/iter8-tools/iter8/base/summarymetrics"
+
+// VersionMetricSummary is a map of metric names to summary metric values
+type VersionMetricSummary map[string]summarymetrics.SummaryMetric
+
 // Interface enables interaction with a storage entity
 // Can be mocked in unit tests with fake implementation
 type Interface interface {
-	// CreateMetric(name string, value interface{}) error
-	// GetMetric(name string) (interface{}, error)
-	// DeleteMetric(name string) (interface{}, error)
+	// GetSummaryMetrics returns versionMetricSummary for each version of the application
+	// called by Iter8 experiments
+	GetSummaryMetrics(applicationName string) (*map[int]VersionMetricSummary, error)
+
+	// called by the A/B/n SDK gRPC API implementation (SDK for application clients)
+	// Example key: kt-metric::my-app::0::my-signature::my-metric::my-user::my-transaction-id -> my-metric-value (get the metric value with all the provided information)
+	SetMetric(applicationName string, version int, signature, metric, user, transaction string, metricValue float64) error
+
+	// Example key: kt-users::my-app::0::my-signature::my-user -> true
+	SetUser(applicationName string, version int, signature, user string) error
 }


Test patch:
diff --git a/controllers/storageclient/badgerdb/simple_test.go b/controllers/storageclient/badgerdb/simple_test.go
index 8ff8ba87..434c2ac2 100644
--- a/controllers/storageclient/badgerdb/simple_test.go
+++ b/controllers/storageclient/badgerdb/simple_test.go
@@ -1,6 +1,7 @@
 package badgerdb
 
 import (
+	"strconv"
 	"testing"
 
 	"github.com/dgraph-io/badger/v4"
@@ -10,7 +11,7 @@ import (
 func TestGetClient(t *testing.T) {
 	tempDirPath := t.TempDir()
 
-	client, err := GetClient(badger.DefaultOptions(tempDirPath))
+	client, err := GetClient(badger.DefaultOptions(tempDirPath), AdditionalOptions{})
 	assert.NoError(t, err)
 
 	assert.NotNil(t, client)
@@ -19,3 +20,116 @@ func TestGetClient(t *testing.T) {
 	err = client.db.Close()
 	assert.NoError(t, err)
 }
+
+func TestSetMetric(t *testing.T) {
+	tempDirPath := t.TempDir()
+
+	client, err := GetClient(badger.DefaultOptions(tempDirPath), AdditionalOptions{})
+	assert.NoError(t, err)
+
+	app := "my-application"
+	version := 0
+	signature := "my-signature"
+	metric := "my-metric"
+	user := "my-user"
+	transaction := "my-transaction"
+	value := 50.0
+
+	err = client.SetMetric(app, version, signature, metric, user, transaction, value)
+	assert.NoError(t, err)
+
+	// get metric
+	err = client.db.View(func(txn *badger.Txn) error {
+		key, err := getMetricKey(app, version, signature, metric, user, transaction)
+		assert.NoError(t, err)
+
+		item, err := txn.Get([]byte(key))
+		assert.NoError(t, err)
+		assert.NotNil(t, item)
+
+		err = item.Value(func(val []byte) error {
+			// parse val into float64
+			fval, err := strconv.ParseFloat(string(val), 64)
+			assert.NoError(t, err)
+
+			// assert metric value is the same as the provided one
+			assert.Equal(t, value, fval)
+			return nil
+		})
+		assert.NoError(t, err)
+
+		return nil
+	})
+	assert.NoError(t, err)
+
+	// SetMetric() should also add a user
+	err = client.db.View(func(txn *badger.Txn) error {
+		key := getUserKey(app, version, signature, user)
+		item, err := txn.Get([]byte(key))
+		assert.NoError(t, err)
+		assert.NotNil(t, item)
+
+		err = item.Value(func(val []byte) error {
+			// user should be set to "true"
+			assert.Equal(t, "true", string(val))
+			return nil
+		})
+		assert.NoError(t, err)
+
+		return nil
+	})
+	assert.NoError(t, err)
+}
+
+func TestSetUser(t *testing.T) {
+	tempDirPath := t.TempDir()
+
+	client, err := GetClient(badger.DefaultOptions(tempDirPath), AdditionalOptions{})
+	assert.NoError(t, err)
+
+	app := "my-application"
+	version := 0
+	signature := "my-signature"
+	user := "my-user"
+
+	err = client.SetUser(app, version, signature, user)
+	assert.NoError(t, err)
+
+	// get user
+	err = client.db.View(func(txn *badger.Txn) error {
+		key := getUserKey(app, version, signature, user)
+		item, err := txn.Get([]byte(key))
+		assert.NoError(t, err)
+		assert.NotNil(t, item)
+
+		err = item.Value(func(val []byte) error {
+			// metric type should be set to "true"
+			assert.Equal(t, "true", string(val))
+			return nil
+		})
+		assert.NoError(t, err)
+
+		return nil
+	})
+	assert.NoError(t, err)
+}
+
+func TestValidateKeyToken(t *testing.T) {
+	err := validateKeyToken("hello")
+	assert.NoError(t, err)
+
+	err = validateKeyToken("::")
+	assert.Error(t, err)
+
+	err = validateKeyToken("hello::world")
+	assert.Error(t, err)
+
+	err = validateKeyToken("hello :: world")
+	assert.Error(t, err)
+
+	err = validateKeyToken("hello:world")
+	assert.Error(t, err)
+
+	err = validateKeyToken("hello : world")
+	assert.Error(t, err)
+}