Switch activitylog servie to a native nats store

Also split the list of activities in different chunks, one per buffered
write. That allows for scaling the service horizontally and also helps
reduce the load caused by the write operations.
This commit is contained in:
André Duffeck
2025-06-05 15:20:20 +02:00
parent 54f934cbb0
commit f9d4a038b3
3 changed files with 352 additions and 95 deletions
@@ -31,6 +31,7 @@ type Options struct {
HistoryClient ehsvc.EventHistoryService HistoryClient ehsvc.EventHistoryService
ValueClient settingssvc.ValueService ValueClient settingssvc.ValueService
WriteBufferDuration time.Duration WriteBufferDuration time.Duration
MaxActivities int
} }
// Logger configures a logger for the activitylog service // Logger configures a logger for the activitylog service
@@ -109,3 +110,10 @@ func WriteBufferDuration(d time.Duration) Option {
o.WriteBufferDuration = d o.WriteBufferDuration = d
} }
} }
// MaxActivities sets the maximum number of activities to store
func MaxActivities(max int) Option {
return func(o *Options) {
o.MaxActivities = max
}
}
+184 -64
View File
@@ -2,11 +2,14 @@ package service
import ( import (
"context" "context"
"encoding/base32"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"path/filepath" "path/filepath"
"reflect" "reflect"
"sort"
"strconv"
"strings"
"sync" "sync"
"time" "time"
@@ -14,12 +17,13 @@ import (
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/jellydator/ttlcache/v2" "github.com/jellydator/ttlcache/v2"
"github.com/nats-io/nats.go"
"github.com/opencloud-eu/reva/v2/pkg/events" "github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool" "github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/opencloud-eu/reva/v2/pkg/storagespace" "github.com/opencloud-eu/reva/v2/pkg/storagespace"
"github.com/opencloud-eu/reva/v2/pkg/utils" "github.com/opencloud-eu/reva/v2/pkg/utils"
"github.com/pkg/errors"
"github.com/vmihailenco/msgpack/v5" "github.com/vmihailenco/msgpack/v5"
microstore "go-micro.dev/v4/store"
"go.opentelemetry.io/otel/trace" "go.opentelemetry.io/otel/trace"
"github.com/opencloud-eu/opencloud/pkg/log" "github.com/opencloud-eu/opencloud/pkg/log"
@@ -29,7 +33,7 @@ import (
) )
// Nats runs into max payload exceeded errors at around 7k activities. Let's keep a buffer. // Nats runs into max payload exceeded errors at around 7k activities. Let's keep a buffer.
var _maxActivities = 6000 var _maxActivitiesDefault = 6000
// RawActivity represents an activity as it is stored in the activitylog store // RawActivity represents an activity as it is stored in the activitylog store
type RawActivity struct { type RawActivity struct {
@@ -43,7 +47,6 @@ type ActivitylogService struct {
cfg *config.Config cfg *config.Config
log log.Logger log log.Logger
events <-chan events.Event events <-chan events.Event
store microstore.Store
gws pool.Selectable[gateway.GatewayAPIClient] gws pool.Selectable[gateway.GatewayAPIClient]
mux *chi.Mux mux *chi.Mux
evHistory ehsvc.EventHistoryService evHistory ehsvc.EventHistoryService
@@ -53,6 +56,9 @@ type ActivitylogService struct {
tracer trace.Tracer tracer trace.Tracer
debouncer *Debouncer debouncer *Debouncer
parentIdCache *ttlcache.Cache parentIdCache *ttlcache.Cache
natskv nats.KeyValue
maxActivities int
registeredEvents map[string]events.Unmarshaller registeredEvents map[string]events.Unmarshaller
} }
@@ -71,6 +77,12 @@ type queueItem struct {
timer *time.Timer timer *time.Timer
} }
type batchInfo struct {
key string
count int
timestamp time.Time
}
// NewDebouncer returns a new Debouncer instance // NewDebouncer returns a new Debouncer instance
func NewDebouncer(d time.Duration, f func(id string, ra []RawActivity) error) *Debouncer { func NewDebouncer(d time.Duration, f func(id string, ra []RawActivity) error) *Debouncer {
return &Debouncer{ return &Debouncer{
@@ -128,7 +140,9 @@ func (d *Debouncer) Debounce(id string, ra RawActivity) {
// New creates a new ActivitylogService // New creates a new ActivitylogService
func New(opts ...Option) (*ActivitylogService, error) { func New(opts ...Option) (*ActivitylogService, error) {
o := &Options{} o := &Options{
MaxActivities: _maxActivitiesDefault,
}
for _, opt := range opts { for _, opt := range opts {
opt(o) opt(o)
} }
@@ -137,10 +151,6 @@ func New(opts ...Option) (*ActivitylogService, error) {
return nil, errors.New("stream is required") return nil, errors.New("stream is required")
} }
if o.Store == nil {
return nil, errors.New("store is required")
}
ch, err := events.Consume(o.Stream, o.Config.Service.Name, o.RegisteredEvents...) ch, err := events.Consume(o.Stream, o.Config.Service.Name, o.RegisteredEvents...)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -152,11 +162,41 @@ func New(opts ...Option) (*ActivitylogService, error) {
return nil, err return nil, err
} }
// Connect to NATS servers
natsOptions := nats.Options{
Servers: o.Config.Store.Nodes,
}
conn, err := natsOptions.Connect()
if err != nil {
return nil, err
}
js, err := conn.JetStream()
if err != nil {
return nil, err
}
kv, err := js.KeyValue(o.Config.Store.Database)
if err != nil {
if !errors.Is(err, nats.ErrBucketNotFound) {
return nil, errors.Wrapf(err, "Failed to get bucket (%s)", o.Config.Store.Database)
}
kv, err = js.CreateKeyValue(&nats.KeyValueConfig{
Bucket: o.Config.Store.Database,
})
if err != nil {
return nil, errors.Wrapf(err, "Failed to create bucket (%s)", o.Config.Store.Database)
}
}
if err != nil {
return nil, err
}
s := &ActivitylogService{ s := &ActivitylogService{
log: o.Logger, log: o.Logger,
cfg: o.Config, cfg: o.Config,
events: ch, events: ch,
store: o.Store,
gws: o.GatewaySelector, gws: o.GatewaySelector,
mux: o.Mux, mux: o.Mux,
evHistory: o.HistoryClient, evHistory: o.HistoryClient,
@@ -166,6 +206,8 @@ func New(opts ...Option) (*ActivitylogService, error) {
tp: o.TraceProvider, tp: o.TraceProvider,
tracer: o.TraceProvider.Tracer("github.com/opencloud-eu/opencloud/services/activitylog/pkg/service"), tracer: o.TraceProvider.Tracer("github.com/opencloud-eu/opencloud/services/activitylog/pkg/service"),
parentIdCache: cache, parentIdCache: cache,
maxActivities: o.MaxActivities,
natskv: kv,
} }
s.debouncer = NewDebouncer(o.WriteBufferDuration, s.storeActivity) s.debouncer = NewDebouncer(o.WriteBufferDuration, s.storeActivity)
@@ -250,7 +292,7 @@ func (a *ActivitylogService) AddActivity(initRef *provider.Reference, parentId *
ctx, span = a.tracer.Start(ctx, "AddActivity") ctx, span = a.tracer.Start(ctx, "AddActivity")
defer span.End() defer span.End()
return a.addActivity(ctx, initRef, parentId, eventID, timestamp, func(ref *provider.Reference) (*provider.ResourceInfo, error) { return a.addActivity(ctx, initRef, parentId, eventID, timestamp, func(ctx context.Context, ref *provider.Reference) (*provider.ResourceInfo, error) {
return utils.GetResource(ctx, ref, gwc) return utils.GetResource(ctx, ref, gwc)
}) })
} }
@@ -285,10 +327,10 @@ func (a *ActivitylogService) AddActivityTrashed(resourceID *provider.ResourceId,
} }
var span trace.Span var span trace.Span
ctx, span = a.tracer.Start(ctx, "AddActivity") ctx, span = a.tracer.Start(ctx, "AddActivityTrashed")
defer span.End() defer span.End()
return a.addActivity(ctx, ref, parentId, eventID, timestamp, func(ref *provider.Reference) (*provider.ResourceInfo, error) { return a.addActivity(ctx, ref, parentId, eventID, timestamp, func(ctx context.Context, ref *provider.Reference) (*provider.ResourceInfo, error) {
return utils.GetResource(ctx, ref, gwc) return utils.GetResource(ctx, ref, gwc)
}) })
} }
@@ -343,10 +385,8 @@ func (a *ActivitylogService) RemoveActivities(rid *provider.ResourceId, toDelete
return err return err
} }
return a.store.Write(&microstore.Record{ _, err = a.natskv.Put(storagespace.FormatResourceID(rid), b)
Key: storagespace.FormatResourceID(rid), return err
Value: b,
})
} }
// RemoveResource removes the resource from the store // RemoveResource removes the resource from the store
@@ -358,45 +398,52 @@ func (a *ActivitylogService) RemoveResource(rid *provider.ResourceId) error {
a.lock.Lock() a.lock.Lock()
defer a.lock.Unlock() defer a.lock.Unlock()
return a.store.Delete(storagespace.FormatResourceID(rid)) return a.natskv.Delete(storagespace.FormatResourceID(rid))
} }
func (a *ActivitylogService) activities(rid *provider.ResourceId) ([]RawActivity, error) { func (a *ActivitylogService) activities(rid *provider.ResourceId) ([]RawActivity, error) {
resourceID := storagespace.FormatResourceID(rid) resourceID := storagespace.FormatResourceID(rid)
records, err := a.store.Read(resourceID) glob := fmt.Sprintf("%s.>", base32.StdEncoding.EncodeToString([]byte(resourceID)))
if err != nil && err != microstore.ErrNotFound {
return nil, fmt.Errorf("could not read activities: %w", err)
}
if len(records) == 0 { watcher, err := a.natskv.Watch(glob, nats.IgnoreDeletes())
return []RawActivity{}, nil if err != nil {
return nil, err
} }
var activities []RawActivity var activities []RawActivity
if err := msgpack.Unmarshal(records[0].Value, &activities); err != nil { for update := range watcher.Updates() {
a.log.Debug().Err(err).Str("resourceID", resourceID).Msg("could not unmarshal messagepack, trying json") if update == nil {
if err := json.Unmarshal(records[0].Value, &activities); err != nil { break
return nil, fmt.Errorf("could not unmarshal activities: %w", err)
} }
var batchActivities []RawActivity
if err := msgpack.Unmarshal(update.Value(), &batchActivities); err != nil {
a.log.Debug().Err(err).Str("resourceID", resourceID).Msg("could not unmarshal messagepack, trying json")
}
activities = append(activities, batchActivities...)
} }
return activities, nil return activities, nil
} }
// note: getResource is abstracted to allow unit testing, in general this will just be utils.GetResource // note: getResource is abstracted to allow unit testing, in general this will just be utils.GetResource
func (a *ActivitylogService) addActivity(ctx context.Context, initRef *provider.Reference, parentId *provider.ResourceId, eventID string, timestamp time.Time, getResource func(*provider.Reference) (*provider.ResourceInfo, error)) error { func (a *ActivitylogService) addActivity(ctx context.Context, initRef *provider.Reference, parentId *provider.ResourceId, eventID string, timestamp time.Time, getResource func(context.Context, *provider.Reference) (*provider.ResourceInfo, error)) error {
var ( var (
err error err error
depth int depth int
ref = initRef ref = initRef
) )
ctx, span := a.tracer.Start(ctx, "addActivity")
defer span.End()
for { for {
var info *provider.ResourceInfo var info *provider.ResourceInfo
id := ref.GetResourceId() id := ref.GetResourceId()
if ref.Path != "" { if ref.Path != "" {
// Path based reference, we need to resolve the resource id // Path based reference, we need to resolve the resource id
info, err = getResource(ref) ctx, span = a.tracer.Start(ctx, "addActivity.getResource")
info, err = getResource(ctx, ref)
span.End()
if err != nil { if err != nil {
return fmt.Errorf("could not get resource info: %w", err) return fmt.Errorf("could not get resource info: %w", err)
} }
@@ -407,17 +454,15 @@ func (a *ActivitylogService) addActivity(ctx context.Context, initRef *provider.
} }
key := storagespace.FormatResourceID(id) key := storagespace.FormatResourceID(id)
_, span := a.tracer.Start(ctx, "queueStoreActivity")
a.debouncer.Debounce(key, RawActivity{ a.debouncer.Debounce(key, RawActivity{
EventID: eventID, EventID: eventID,
Depth: depth, Depth: depth,
Timestamp: timestamp, Timestamp: timestamp,
}) })
span.End()
if id.OpaqueId == id.SpaceId { if id.OpaqueId == id.SpaceId {
// we are at the root of the space, no need to go further // we are at the root of the space, no need to go further
return nil break
} }
// check if parent id is cached // check if parent id is cached
@@ -426,8 +471,8 @@ func (a *ActivitylogService) addActivity(ctx context.Context, initRef *provider.
if parentId == nil { if parentId == nil {
if v, err := a.parentIdCache.Get(key); err != nil { if v, err := a.parentIdCache.Get(key); err != nil {
if info == nil { if info == nil {
_, span = a.tracer.Start(ctx, "getResource") ctx, span := a.tracer.Start(ctx, "addActivity.getResource parent")
info, err = getResource(ref) info, err = getResource(ctx, ref)
span.End() span.End()
if err != nil || info.GetParentId() == nil || info.GetParentId().GetOpaqueId() == "" { if err != nil || info.GetParentId() == nil || info.GetParentId().GetOpaqueId() == "" {
return fmt.Errorf("could not get parent id: %w", err) return fmt.Errorf("could not get parent id: %w", err)
@@ -446,6 +491,8 @@ func (a *ActivitylogService) addActivity(ctx context.Context, initRef *provider.
ref = &provider.Reference{ResourceId: parentId} ref = &provider.Reference{ResourceId: parentId}
parentId = nil // reset parent id so it's not reused in the next iteration parentId = nil // reset parent id so it's not reused in the next iteration
} }
return nil
} }
func (a *ActivitylogService) storeActivity(resourceID string, activities []RawActivity) error { func (a *ActivitylogService) storeActivity(resourceID string, activities []RawActivity) error {
@@ -454,43 +501,116 @@ func (a *ActivitylogService) storeActivity(resourceID string, activities []RawAc
ctx, span := a.tracer.Start(context.Background(), "storeActivity") ctx, span := a.tracer.Start(context.Background(), "storeActivity")
defer span.End() defer span.End()
_, subspan := a.tracer.Start(ctx, "store.Read")
records, err := a.store.Read(resourceID)
if err != nil && err != microstore.ErrNotFound {
return err
}
subspan.End()
_, subspan = a.tracer.Start(ctx, "Unmarshal") _, subspan := a.tracer.Start(ctx, "storeActivity.Marshal")
var existingActivities []RawActivity
if len(records) > 0 {
if err := msgpack.Unmarshal(records[0].Value, &existingActivities); err != nil {
a.log.Debug().Err(err).Str("resourceID", resourceID).Msg("could not unmarshal messagepack, trying json")
if err := json.Unmarshal(records[0].Value, &existingActivities); err != nil {
return err
}
}
}
subspan.End()
if l := len(existingActivities) + len(activities); l >= _maxActivities {
start := min(len(existingActivities), l-_maxActivities+1)
existingActivities = existingActivities[start:]
}
activities = append(existingActivities, activities...)
_, subspan = a.tracer.Start(ctx, "Unmarshal")
b, err := msgpack.Marshal(activities) b, err := msgpack.Marshal(activities)
if err != nil { if err != nil {
return err return err
} }
subspan.End() subspan.End()
return a.store.Write(&microstore.Record{ _, subspan = a.tracer.Start(ctx, "storeActivity.natskv.Put")
Key: resourceID, key := a.natsKey(resourceID, len(activities))
Value: b, _, err = a.natskv.Put(key, b)
if err != nil {
return err
}
subspan.End()
ctx, subspan = a.tracer.Start(ctx, "storeActivity.enforceMaxActivities")
a.enforceMaxActivities(ctx, resourceID)
subspan.End()
return nil
}
func (a *ActivitylogService) natsKey(resourceID string, activitiesCount int) string {
return fmt.Sprintf("%s.%d.%d",
base32.StdEncoding.EncodeToString([]byte(resourceID)),
activitiesCount,
time.Now().UnixNano())
}
func (a *ActivitylogService) enforceMaxActivities(ctx context.Context, resourceID string) {
if a.maxActivities <= 0 {
return
}
key := fmt.Sprintf("%s.>", base32.StdEncoding.EncodeToString([]byte(resourceID)))
_, subspan := a.tracer.Start(ctx, "enforceMaxActivities.watch")
watcher, err := a.natskv.Watch(key, nats.IgnoreDeletes())
if err != nil {
a.log.Error().Err(err).Str("resourceID", resourceID).Msg("could not watch")
return
}
var keys []string
for update := range watcher.Updates() {
if update == nil {
break
}
var batchActivities []RawActivity
if err := msgpack.Unmarshal(update.Value(), &batchActivities); err != nil {
a.log.Debug().Err(err).Str("resourceID", resourceID).Msg("could not unmarshal messagepack, trying json")
}
keys = append(keys, update.Key())
}
subspan.End()
_, subspan = a.tracer.Start(ctx, "enforceMaxActivities.compile")
// Parse keys into batches
batches := make([]batchInfo, 0)
var activitiesCount int
for _, k := range keys {
parts := strings.SplitN(k, ".", 3)
if len(parts) < 3 {
a.log.Warn().Str("key", k).Msg("skipping key, not enough parts")
continue
}
c, err := strconv.Atoi(parts[1])
if err != nil {
a.log.Warn().Str("key", k).Msg("skipping key, can not parse count")
continue
}
// parse timestamp
nano, err := strconv.ParseInt(parts[2], 10, 64)
if err != nil {
a.log.Warn().Str("key", k).Msg("skipping key, can not parse timestamp")
continue
}
batches = append(batches, batchInfo{
key: k,
count: c,
timestamp: time.Unix(0, nano),
})
activitiesCount += c
}
// sort batches by timestamp
sort.Slice(batches, func(i, j int) bool {
return batches[i].timestamp.Before(batches[j].timestamp)
}) })
subspan.End()
_, subspan = a.tracer.Start(ctx, "enforceMaxActivities.delete")
// remove oldest keys until we are at max activities
for _, b := range batches {
if activitiesCount-b.count < a.maxActivities {
break
}
activitiesCount -= b.count
err = a.natskv.Delete(b.key)
if err != nil {
a.log.Error().Err(err).Str("key", b.key).Msg("could not delete key")
break
}
}
subspan.End()
} }
func toRef(r *provider.ResourceId) *provider.Reference { func toRef(r *provider.ResourceId) *provider.Reference {
+160 -31
View File
@@ -2,30 +2,101 @@ package service
import ( import (
"context" "context"
"net"
"os"
"path/filepath"
"time" "time"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/jellydator/ttlcache/v2" "github.com/go-chi/chi/v5"
"github.com/google/uuid"
nserver "github.com/nats-io/nats-server/v2/server"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
"github.com/opencloud-eu/reva/v2/pkg/store" "github.com/opencloud-eu/opencloud/services/activitylog/pkg/config"
eventsmocks "github.com/opencloud-eu/reva/v2/pkg/events/mocks"
"github.com/test-go/testify/mock"
"go.opentelemetry.io/otel/trace/noop" "go.opentelemetry.io/otel/trace/noop"
) )
var (
server *nserver.Server
tmpdir string
)
func getFreeLocalhostPort() (int, error) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return -1, err
}
port := l.Addr().(*net.TCPAddr).Port
_ = l.Close() // Close the listener immediately to free the port
return port, nil
}
// Spawn a nats server and a JetStream instance for the duration of the test suite.
// The different tests need to make sure to use different databases to avoid conflicts.
var _ = SynchronizedBeforeSuite(func() {
port, err := getFreeLocalhostPort()
server, err = nserver.NewServer(&nserver.Options{
Port: port,
})
Expect(err).ToNot(HaveOccurred())
tmpdir, err = os.MkdirTemp("", "activitylog-test")
natsdir := filepath.Join(tmpdir, "nats-js")
jsConf := &nserver.JetStreamConfig{
StoreDir: natsdir,
}
// first start NATS
go server.Start()
time.Sleep(time.Second)
// second start JetStream
err = server.EnableJetStream(jsConf)
Expect(err).ToNot(HaveOccurred())
}, func() {})
var _ = SynchronizedAfterSuite(func() {
server.Shutdown()
_ = os.RemoveAll(tmpdir)
}, func() {})
var _ = Describe("ActivitylogService", func() { var _ = Describe("ActivitylogService", func() {
var ( var (
alog *ActivitylogService alog *ActivitylogService
getResource func(ref *provider.Reference) (*provider.ResourceInfo, error) getResource func(ref *provider.Reference) (*provider.ResourceInfo, error)
writebufferduration = 100 * time.Millisecond
) )
JustBeforeEach(func() {
var err error
stream := &eventsmocks.Stream{}
stream.EXPECT().Consume(mock.Anything, mock.Anything).Return(nil, nil)
alog, err = New(
Config(&config.Config{
Service: config.Service{
Name: "activitylog-test",
},
Store: config.Store{
Store: "nats-js-kv",
Nodes: []string{server.Addr().String()},
Database: "activitylog-test-" + uuid.New().String(),
},
}),
MaxActivities(4),
WriteBufferDuration(writebufferduration),
Stream(stream),
TraceProvider(noop.NewTracerProvider()),
Mux(chi.NewMux()),
)
Expect(err).ToNot(HaveOccurred())
})
Context("with a noop debouncer", func() { Context("with a noop debouncer", func() {
BeforeEach(func() { BeforeEach(func() {
alog = &ActivitylogService{ writebufferduration = 0
store: store.Create(),
tracer: noop.NewTracerProvider().Tracer("test"),
parentIdCache: ttlcache.NewCache(),
}
alog.debouncer = NewDebouncer(0, alog.storeActivity)
}) })
Describe("AddActivity", func() { Describe("AddActivity", func() {
@@ -76,7 +147,7 @@ var _ = Describe("ActivitylogService", func() {
for _, tc := range testCases { for _, tc := range testCases {
tc := tc // capture range variable tc := tc // capture range variable
Context(tc.Name, func() { Context(tc.Name, func() {
BeforeEach(func() { JustBeforeEach(func() {
getResource = func(ref *provider.Reference) (*provider.ResourceInfo, error) { getResource = func(ref *provider.Reference) (*provider.ResourceInfo, error) {
return tc.Tree[ref.GetResourceId().GetOpaqueId()], nil return tc.Tree[ref.GetResourceId().GetOpaqueId()], nil
} }
@@ -107,30 +178,88 @@ var _ = Describe("ActivitylogService", func() {
"spaceid": resourceInfo("spaceid", "spaceid"), "spaceid": resourceInfo("spaceid", "spaceid"),
} }
) )
BeforeEach(func() {
alog = &ActivitylogService{ Describe("addActivity", func() {
store: store.Create(), var (
tracer: noop.NewTracerProvider().Tracer("test"), getResource = func(ref *provider.Reference) (*provider.ResourceInfo, error) {
parentIdCache: ttlcache.NewCache(), return tree[ref.GetResourceId().GetOpaqueId()], nil
} }
alog.debouncer = NewDebouncer(100*time.Millisecond, alog.storeActivity) )
It("debounces activities", func() {
err := alog.addActivity(context.Background(), reference("base"), nil, "activity1", time.Time{}, getResource)
Expect(err).NotTo(HaveOccurred())
err = alog.addActivity(context.Background(), reference("base"), nil, "activity2", time.Time{}, getResource)
Expect(err).NotTo(HaveOccurred())
Eventually(func(g Gomega) {
activities, err := alog.Activities(resourceID("base"))
g.Expect(err).NotTo(HaveOccurred())
g.Expect(activities).To(ConsistOf(activitites("activity1", 0, "activity2", 0)))
}).Should(Succeed())
})
It("adheres to the MaxActivities setting", func() {
err := alog.addActivity(context.Background(), reference("base"), nil, "activity1", time.Time{}, getResource)
Expect(err).NotTo(HaveOccurred())
Eventually(func(g Gomega) {
activities, err := alog.Activities(resourceID("base"))
g.Expect(err).NotTo(HaveOccurred())
g.Expect(len(activities)).To(Equal(1))
}).Should(Succeed())
err = alog.addActivity(context.Background(), reference("base"), nil, "activity2", time.Time{}, getResource)
Expect(err).NotTo(HaveOccurred())
Eventually(func(g Gomega) {
activities, err := alog.Activities(resourceID("base"))
g.Expect(err).NotTo(HaveOccurred())
g.Expect(len(activities)).To(Equal(2))
}).Should(Succeed())
err = alog.addActivity(context.Background(), reference("base"), nil, "activity3", time.Time{}, getResource)
Expect(err).NotTo(HaveOccurred())
err = alog.addActivity(context.Background(), reference("base"), nil, "activity4", time.Time{}, getResource)
Expect(err).NotTo(HaveOccurred())
err = alog.addActivity(context.Background(), reference("base"), nil, "activity5", time.Time{}, getResource)
Expect(err).NotTo(HaveOccurred())
Eventually(func(g Gomega) {
activities, err := alog.Activities(resourceID("base"))
g.Expect(err).NotTo(HaveOccurred())
g.Expect(activities).To(ConsistOf(activitites("activity2", 0, "activity3", 0, "activity4", 0, "activity5", 0)))
}).Should(Succeed())
})
}) })
It("should debounce activities", func() { Describe("Activities", func() {
getResource = func(ref *provider.Reference) (*provider.ResourceInfo, error) { It("combines multiple batches", func() {
return tree[ref.GetResourceId().GetOpaqueId()], nil getResource = func(ref *provider.Reference) (*provider.ResourceInfo, error) {
} return tree[ref.GetResourceId().GetOpaqueId()], nil
}
err := alog.addActivity(context.Background(), reference("base"), nil, "activity1", time.Time{}, getResource) err := alog.addActivity(context.Background(), reference("base"), nil, "activity1", time.Time{}, getResource)
Expect(err).NotTo(HaveOccurred()) Expect(err).NotTo(HaveOccurred())
err = alog.addActivity(context.Background(), reference("base"), nil, "activity2", time.Time{}, getResource) err = alog.addActivity(context.Background(), reference("base"), nil, "activity2", time.Time{}, getResource)
Expect(err).NotTo(HaveOccurred()) Expect(err).NotTo(HaveOccurred())
Eventually(func(g Gomega) { Eventually(func(g Gomega) {
activities, err := alog.Activities(resourceID("base")) activities, err := alog.Activities(resourceID("base"))
g.Expect(err).NotTo(HaveOccurred()) g.Expect(err).NotTo(HaveOccurred())
g.Expect(activities).To(ConsistOf(activitites("activity1", 0, "activity2", 0))) g.Expect(activities).To(ConsistOf(activitites("activity1", 0, "activity2", 0)))
}).Should(Succeed()) }).Should(Succeed())
err = alog.addActivity(context.Background(), reference("base"), nil, "activity3", time.Time{}, getResource)
Expect(err).NotTo(HaveOccurred())
err = alog.addActivity(context.Background(), reference("base"), nil, "activity4", time.Time{}, getResource)
Expect(err).NotTo(HaveOccurred())
Eventually(func(g Gomega) {
activities, err := alog.Activities(resourceID("base"))
g.Expect(err).NotTo(HaveOccurred())
g.Expect(activities).To(ConsistOf(activitites("activity1", 0, "activity2", 0, "activity3", 0, "activity4", 0)))
}).Should(Succeed())
})
}) })
}) })
}) })