Initial QSfera import
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
)
|
||||
|
||||
// SpaceDebouncer debounces operations on spaces for a configurable amount of time
|
||||
type SpaceDebouncer struct {
|
||||
after time.Duration
|
||||
timeout time.Duration
|
||||
f func(id *provider.StorageSpaceId)
|
||||
pending map[string]*workItem
|
||||
inProgress sync.Map
|
||||
|
||||
mutex sync.Mutex
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
type workItem struct {
|
||||
t *time.Timer
|
||||
timeout *time.Timer
|
||||
|
||||
work func()
|
||||
}
|
||||
|
||||
type AckFunc func() error
|
||||
|
||||
// NewSpaceDebouncer returns a new SpaceDebouncer instance
|
||||
func NewSpaceDebouncer(d time.Duration, timeout time.Duration, f func(id *provider.StorageSpaceId), logger log.Logger) *SpaceDebouncer {
|
||||
return &SpaceDebouncer{
|
||||
after: d,
|
||||
timeout: timeout,
|
||||
f: f,
|
||||
pending: map[string]*workItem{},
|
||||
inProgress: sync.Map{},
|
||||
log: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Debounce restars the debounce timer for the given space
|
||||
func (d *SpaceDebouncer) Debounce(id *provider.StorageSpaceId, ack AckFunc) {
|
||||
d.mutex.Lock()
|
||||
defer d.mutex.Unlock()
|
||||
|
||||
if wi := d.pending[id.OpaqueId]; wi != nil {
|
||||
if ack != nil {
|
||||
go ack() // Acknowledge the event immediately, the according space is already scheduled for indexing
|
||||
}
|
||||
wi.t.Reset(d.after)
|
||||
return
|
||||
}
|
||||
|
||||
wi := &workItem{}
|
||||
wi.work = func() {
|
||||
if _, ok := d.inProgress.Load(id.OpaqueId); ok {
|
||||
// Reschedule this run for when the previous run has finished
|
||||
d.mutex.Lock()
|
||||
if wi := d.pending[id.OpaqueId]; wi != nil {
|
||||
wi.t.Reset(d.after)
|
||||
}
|
||||
d.mutex.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
d.mutex.Lock()
|
||||
wi.timeout.Stop() // stop the timeout timer if it is running
|
||||
delete(d.pending, id.OpaqueId)
|
||||
d.inProgress.Store(id.OpaqueId, true)
|
||||
defer func() {
|
||||
d.inProgress.Delete(id.OpaqueId)
|
||||
}()
|
||||
d.mutex.Unlock() // release the lock early to allow other goroutines to debounce
|
||||
|
||||
d.f(id)
|
||||
go func() {
|
||||
if ack != nil {
|
||||
if err := ack(); err != nil {
|
||||
d.log.Error().Err(err).Msg("error while acknowledging event")
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wi.t = time.AfterFunc(d.after, wi.work)
|
||||
wi.timeout = time.AfterFunc(d.timeout, func() {
|
||||
d.log.Debug().Msg("timeout while waiting for space debouncer to finish")
|
||||
wi.t.Stop()
|
||||
wi.work()
|
||||
})
|
||||
|
||||
d.pending[id.OpaqueId] = wi
|
||||
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package event_test
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
sprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/search/pkg/service/event"
|
||||
)
|
||||
|
||||
var _ = Describe("SpaceDebouncer", func() {
|
||||
var (
|
||||
debouncer *event.SpaceDebouncer
|
||||
|
||||
callCount atomic.Int32
|
||||
|
||||
spaceid = &sprovider.StorageSpaceId{
|
||||
OpaqueId: "spaceid",
|
||||
}
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
callCount = atomic.Int32{}
|
||||
debouncer = event.NewSpaceDebouncer(50*time.Millisecond, 10*time.Second, func(id *sprovider.StorageSpaceId) {
|
||||
if id.OpaqueId == "spaceid" {
|
||||
callCount.Add(1)
|
||||
}
|
||||
}, log.NewLogger())
|
||||
})
|
||||
|
||||
It("debounces", func() {
|
||||
debouncer.Debounce(spaceid, nil)
|
||||
debouncer.Debounce(spaceid, nil)
|
||||
debouncer.Debounce(spaceid, nil)
|
||||
Eventually(func() int {
|
||||
return int(callCount.Load())
|
||||
}, "200ms").Should(Equal(1))
|
||||
})
|
||||
|
||||
It("works multiple times", func() {
|
||||
debouncer.Debounce(spaceid, nil)
|
||||
debouncer.Debounce(spaceid, nil)
|
||||
debouncer.Debounce(spaceid, nil)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
debouncer.Debounce(spaceid, nil)
|
||||
debouncer.Debounce(spaceid, nil)
|
||||
|
||||
Eventually(func() int {
|
||||
return int(callCount.Load())
|
||||
}, "200ms").Should(Equal(2))
|
||||
})
|
||||
|
||||
It("doesn't trigger twice simultaneously", func() {
|
||||
debouncer = event.NewSpaceDebouncer(50*time.Millisecond, 5*time.Second, func(id *sprovider.StorageSpaceId) {
|
||||
if id.OpaqueId == "spaceid" {
|
||||
callCount.Add(1)
|
||||
}
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
}, log.NewLogger())
|
||||
debouncer.Debounce(spaceid, nil)
|
||||
time.Sleep(100 * time.Millisecond) // Let it trigger once
|
||||
|
||||
debouncer.Debounce(spaceid, nil)
|
||||
time.Sleep(100 * time.Millisecond) // shouldn't trigger as the other run is still in progress
|
||||
Expect(int(callCount.Load())).To(Equal(1))
|
||||
|
||||
Eventually(func() int {
|
||||
return int(callCount.Load())
|
||||
}, "2000ms").Should(Equal(2))
|
||||
})
|
||||
|
||||
It("fires at the timeout even when continuously debounced", func() {
|
||||
debouncer = event.NewSpaceDebouncer(100*time.Millisecond, 250*time.Millisecond, func(id *sprovider.StorageSpaceId) {
|
||||
if id.OpaqueId == "spaceid" {
|
||||
callCount.Add(1)
|
||||
}
|
||||
}, log.NewLogger())
|
||||
|
||||
// Reset the debounce timer every 50ms (shorter than the 100ms debounce
|
||||
// duration) but stop before the 250ms timeout fires. Continuing past the
|
||||
// timeout would race with the work function's cleanup of pending state:
|
||||
// a Debounce call arriving right after the timeout fires would find
|
||||
// pending empty and schedule a second workItem, breaking the assertion
|
||||
// below that the work function is invoked exactly once.
|
||||
debouncer.Debounce(spaceid, nil)
|
||||
for i := 0; i < 4 && callCount.Load() == 0; i++ {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if callCount.Load() == 0 {
|
||||
debouncer.Debounce(spaceid, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// The debounce timer (100ms) was reset roughly every 50ms and should
|
||||
// not have fired. The timeout timer (250ms) should fire regardless.
|
||||
Eventually(func() int {
|
||||
return int(callCount.Load())
|
||||
}, "300ms").Should(Equal(1))
|
||||
|
||||
// And it should not fire again
|
||||
Consistently(func() int {
|
||||
return int(callCount.Load())
|
||||
}, "300ms").Should(Equal(1))
|
||||
})
|
||||
|
||||
It("doesn't run the timeout function if the work function has been called", func() {
|
||||
debouncer = event.NewSpaceDebouncer(100*time.Millisecond, 250*time.Millisecond, func(id *sprovider.StorageSpaceId) {
|
||||
if id.OpaqueId == "spaceid" {
|
||||
callCount.Add(1)
|
||||
}
|
||||
}, log.NewLogger())
|
||||
|
||||
// Initial call to start the timers
|
||||
debouncer.Debounce(spaceid, nil)
|
||||
|
||||
// Wait for the debounce timer to fire
|
||||
Eventually(func() int {
|
||||
return int(callCount.Load())
|
||||
}, "200ms").Should(Equal(1))
|
||||
|
||||
// The timeout function should not be called
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
Expect(int(callCount.Load())).To(Equal(1))
|
||||
})
|
||||
|
||||
It("calls the ack function when the debounce fires", func() {
|
||||
var ackCalled atomic.Bool
|
||||
ackFunc := func() error {
|
||||
ackCalled.Store(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
debouncer.Debounce(spaceid, ackFunc)
|
||||
|
||||
Eventually(func() int {
|
||||
return int(callCount.Load())
|
||||
}, "200ms").Should(Equal(1))
|
||||
Eventually(func() bool {
|
||||
return ackCalled.Load()
|
||||
}, "200ms").Should(BeTrue())
|
||||
})
|
||||
|
||||
It("calls the ack function immediately for subsequent calls", func() {
|
||||
var firstAckCalled atomic.Bool
|
||||
firstAckFunc := func() error {
|
||||
firstAckCalled.Store(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
var secondAckCalled atomic.Bool
|
||||
secondAckFunc := func() error {
|
||||
secondAckCalled.Store(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
// First call, sets up the trigger
|
||||
debouncer.Debounce(spaceid, firstAckFunc)
|
||||
Expect(firstAckCalled.Load()).To(BeFalse())
|
||||
Expect(secondAckCalled.Load()).To(BeFalse())
|
||||
|
||||
// Second call, should call its ack immediately
|
||||
debouncer.Debounce(spaceid, secondAckFunc)
|
||||
Eventually(func() bool {
|
||||
return secondAckCalled.Load()
|
||||
}, "50ms").Should(BeTrue())
|
||||
// The first ack is not yet called.
|
||||
Expect(firstAckCalled.Load()).To(BeFalse())
|
||||
|
||||
// After the debounce period, the trigger fires, calling the main function and the first ack.
|
||||
Eventually(func() int {
|
||||
return int(callCount.Load())
|
||||
}, "200ms").Should(Equal(1))
|
||||
Eventually(func() bool {
|
||||
return firstAckCalled.Load()
|
||||
}, "200ms").Should(BeTrue())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
package event_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/qsfera/server/pkg/registry"
|
||||
mRegistry "go-micro.dev/v4/registry"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func init() {
|
||||
r := registry.GetRegistry(registry.Inmemory())
|
||||
service := registry.BuildGRPCService("qsfera.api.gateway", "", "", "")
|
||||
service.Nodes = []*mRegistry.Node{{
|
||||
Address: "any",
|
||||
}}
|
||||
|
||||
_ = r.Register(service)
|
||||
}
|
||||
func TestEvent(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Event Suite")
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/search/pkg/metrics"
|
||||
"github.com/qsfera/server/services/search/pkg/search"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events/raw"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
var tracer trace.Tracer
|
||||
|
||||
func init() {
|
||||
tracer = otel.Tracer("github.com/qsfera/server/services/search/pkg/service/event")
|
||||
}
|
||||
|
||||
// Service defines the service handlers.
|
||||
type Service struct {
|
||||
ctx context.Context
|
||||
log log.Logger
|
||||
tp trace.TracerProvider
|
||||
m *metrics.Metrics
|
||||
index search.Searcher
|
||||
events []events.Unmarshaller
|
||||
stream raw.Stream
|
||||
indexSpaceDebouncer *SpaceDebouncer
|
||||
numConsumers int
|
||||
stopCh chan struct{}
|
||||
stopped *atomic.Bool
|
||||
}
|
||||
|
||||
// New returns a service implementation for Service.
|
||||
func New(ctx context.Context, stream raw.Stream, logger log.Logger, tp trace.TracerProvider, m *metrics.Metrics, index search.Searcher, debounceDuration int, numConsumers int, asyncUploads bool) (Service, error) {
|
||||
svc := Service{
|
||||
ctx: ctx,
|
||||
log: logger,
|
||||
tp: tp,
|
||||
m: m,
|
||||
index: index,
|
||||
stream: stream,
|
||||
stopCh: make(chan struct{}, 1),
|
||||
stopped: new(atomic.Bool),
|
||||
events: []events.Unmarshaller{
|
||||
events.ItemTrashed{},
|
||||
events.ItemPurged{},
|
||||
events.ItemRestored{},
|
||||
events.ItemMoved{},
|
||||
events.TrashbinPurged{},
|
||||
events.ContainerCreated{},
|
||||
events.FileTouched{},
|
||||
events.FileVersionRestored{},
|
||||
events.TagsAdded{},
|
||||
events.TagsRemoved{},
|
||||
events.SpaceRenamed{},
|
||||
events.LabelAdded{},
|
||||
events.LabelRemoved{},
|
||||
},
|
||||
numConsumers: numConsumers,
|
||||
}
|
||||
|
||||
if asyncUploads {
|
||||
svc.events = append(svc.events, events.UploadReady{})
|
||||
} else {
|
||||
svc.events = append(svc.events, events.FileUploaded{})
|
||||
}
|
||||
|
||||
svc.indexSpaceDebouncer = NewSpaceDebouncer(time.Duration(debounceDuration)*time.Millisecond, 30*time.Second, func(id *provider.StorageSpaceId) {
|
||||
if err := svc.index.IndexSpace(id, false); err != nil {
|
||||
svc.log.Error().Err(err).Interface("spaceID", id).Msg("error while indexing a space")
|
||||
}
|
||||
}, svc.log)
|
||||
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
// Run to fulfil Runner interface
|
||||
func (s Service) Run() error {
|
||||
ch, err := s.stream.Consume("search-pull", s.events...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if s.m != nil {
|
||||
monitorMetrics(s.ctx, s.stream, "search-pull", s.m, s.log)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
ctx, cancel := context.WithCancel(s.ctx)
|
||||
defer cancel()
|
||||
|
||||
s.log.Debug().Int("worker.count", s.numConsumers).
|
||||
Str("messaging.consumer.group.name", "search-pull").
|
||||
Str("messaging.system", "nats").
|
||||
Str("messaging.operation.name", "receive").
|
||||
Msg("starting event processing workers")
|
||||
|
||||
// start workers
|
||||
for i := 0; i < s.numConsumers; i++ {
|
||||
wg.Add(1)
|
||||
go func(workerID int) {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case e, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.processEvent(e); err != nil {
|
||||
s.log.Error().Err(err).
|
||||
Int("worker", workerID).
|
||||
Interface("event", e).
|
||||
Msg("failed to process event")
|
||||
}
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// wait for stop signal
|
||||
<-s.stopCh
|
||||
cancel() // signal workers to stop
|
||||
wg.Wait()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close will make the service to stop processing, so the `Run`
|
||||
// method can finish.
|
||||
// TODO: Underlying services can't be stopped. This means that some goroutines
|
||||
// will get stuck trying to push events through a channel nobody is reading
|
||||
// from, so resources won't be freed and there will be memory leaks. For now,
|
||||
// if the service is stopped, you should close the app soon after.
|
||||
func (s Service) Close() {
|
||||
if s.stopped.CompareAndSwap(false, true) {
|
||||
close(s.stopCh)
|
||||
}
|
||||
}
|
||||
|
||||
func getSpaceID(ref *provider.Reference) *provider.StorageSpaceId {
|
||||
return &provider.StorageSpaceId{
|
||||
OpaqueId: storagespace.FormatResourceID(
|
||||
&provider.ResourceId{
|
||||
StorageId: ref.GetResourceId().GetStorageId(),
|
||||
SpaceId: ref.GetResourceId().GetSpaceId(),
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func (s Service) processEvent(e raw.Event) error {
|
||||
ctx := e.GetTraceContext(s.ctx)
|
||||
_, span := tracer.Start(ctx, "processEvent")
|
||||
defer span.End()
|
||||
|
||||
e.InProgress() // let nats know that we are processing this event
|
||||
s.log.Debug().Interface("event", e).Msg("updating index")
|
||||
|
||||
switch ev := e.Event.Event.(type) {
|
||||
case events.ItemTrashed:
|
||||
s.index.TrashItem(ev.ID)
|
||||
s.indexSpaceDebouncer.Debounce(getSpaceID(ev.Ref), e.Ack)
|
||||
case events.ItemPurged:
|
||||
s.index.PurgeItem(ev.Ref)
|
||||
e.Ack()
|
||||
case events.TrashbinPurged:
|
||||
s.index.PurgeDeleted(getSpaceID(ev.Ref))
|
||||
e.Ack()
|
||||
case events.ItemMoved:
|
||||
s.index.MoveItem(ev.Ref)
|
||||
s.indexSpaceDebouncer.Debounce(getSpaceID(ev.Ref), e.Ack)
|
||||
case events.ItemRestored:
|
||||
s.index.RestoreItem(ev.Ref)
|
||||
s.indexSpaceDebouncer.Debounce(getSpaceID(ev.Ref), e.Ack)
|
||||
case events.ContainerCreated:
|
||||
s.indexSpaceDebouncer.Debounce(getSpaceID(ev.Ref), e.Ack)
|
||||
case events.FileTouched:
|
||||
s.indexSpaceDebouncer.Debounce(getSpaceID(ev.Ref), e.Ack)
|
||||
case events.FileVersionRestored:
|
||||
s.indexSpaceDebouncer.Debounce(getSpaceID(ev.Ref), e.Ack)
|
||||
case events.TagsAdded:
|
||||
s.index.UpsertItem(ev.Ref)
|
||||
s.indexSpaceDebouncer.Debounce(getSpaceID(ev.Ref), e.Ack)
|
||||
case events.TagsRemoved:
|
||||
s.index.UpsertItem(ev.Ref)
|
||||
s.indexSpaceDebouncer.Debounce(getSpaceID(ev.Ref), e.Ack)
|
||||
case events.FileUploaded:
|
||||
s.indexSpaceDebouncer.Debounce(getSpaceID(ev.Ref), e.Ack)
|
||||
case events.UploadReady:
|
||||
s.indexSpaceDebouncer.Debounce(getSpaceID(ev.FileRef), e.Ack)
|
||||
case events.SpaceRenamed:
|
||||
s.indexSpaceDebouncer.Debounce(ev.ID, e.Ack)
|
||||
case events.LabelAdded:
|
||||
s.index.UpsertItem(ev.Ref)
|
||||
case events.LabelRemoved:
|
||||
s.index.UpsertItem(ev.Ref)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func monitorMetrics(ctx context.Context, stream raw.Stream, name string, m *metrics.Metrics, logger log.Logger) {
|
||||
consumer, err := stream.JetStream().Consumer(ctx, name)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("failed to get consumer")
|
||||
}
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
go func() {
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
info, err := consumer.Info(ctx)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("failed to get consumer")
|
||||
continue
|
||||
}
|
||||
|
||||
m.EventsOutstandingAcks.Set(float64(info.NumAckPending))
|
||||
m.EventsUnprocessed.Set(float64(info.NumPending))
|
||||
m.EventsRedelivered.Set(float64(info.NumRedelivered))
|
||||
logger.Trace().Msg("updated search event metrics")
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package event_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
|
||||
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
searchMocks "github.com/qsfera/server/services/search/pkg/search/mocks"
|
||||
"github.com/qsfera/server/services/search/pkg/service/event"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events/raw"
|
||||
rawMocks "github.com/opencloud-eu/reva/v2/pkg/events/raw/mocks"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
var _ = DescribeTable("event",
|
||||
func(mcks []string, e any, asyncUploads bool) {
|
||||
var (
|
||||
s = &searchMocks.Searcher{}
|
||||
calls atomic.Int32
|
||||
)
|
||||
|
||||
stream := rawMocks.NewStream(GinkgoT())
|
||||
ch := make(chan raw.Event, 1)
|
||||
stream.EXPECT().Consume(mock.Anything, mock.Anything).Return((<-chan raw.Event)(ch), nil)
|
||||
|
||||
event, err := event.New(context.Background(), stream, log.NewLogger(), nil, nil, s, 50, 1, asyncUploads)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
go func() {
|
||||
err := event.Run()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
}()
|
||||
defer event.Close()
|
||||
|
||||
for _, mck := range mcks {
|
||||
s.On(mck, mock.Anything, mock.Anything).Return(nil).Run(func(args mock.Arguments) {
|
||||
calls.Add(1)
|
||||
})
|
||||
}
|
||||
|
||||
ch <- raw.Event{
|
||||
Event: events.Event{Event: e},
|
||||
}
|
||||
|
||||
Eventually(func() int {
|
||||
return int(calls.Load())
|
||||
}, "2s").Should(Equal(len(mcks)))
|
||||
},
|
||||
Entry("ItemTrashed", []string{"TrashItem", "IndexSpace"}, events.ItemTrashed{}, false),
|
||||
Entry("ItemMoved", []string{"MoveItem", "IndexSpace"}, events.ItemMoved{}, false),
|
||||
Entry("ItemRestored", []string{"RestoreItem", "IndexSpace"}, events.ItemRestored{}, false),
|
||||
Entry("ContainerCreated", []string{"IndexSpace"}, events.ContainerCreated{}, false),
|
||||
Entry("FileTouched", []string{"IndexSpace"}, events.FileTouched{}, false),
|
||||
Entry("FileVersionRestored", []string{"IndexSpace"}, events.FileVersionRestored{}, false),
|
||||
Entry("TagsAdded", []string{"UpsertItem", "IndexSpace"}, events.TagsAdded{}, false),
|
||||
Entry("TagsRemoved", []string{"UpsertItem", "IndexSpace"}, events.TagsRemoved{}, false),
|
||||
Entry("FileUploaded", []string{"IndexSpace"}, events.FileUploaded{}, false),
|
||||
Entry("UploadReady", []string{"IndexSpace"}, events.UploadReady{ExecutingUser: &userv1beta1.User{}}, true),
|
||||
)
|
||||
@@ -0,0 +1,87 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/search/pkg/config"
|
||||
"github.com/qsfera/server/services/search/pkg/metrics"
|
||||
"github.com/qsfera/server/services/search/pkg/search"
|
||||
)
|
||||
|
||||
// Option defines a single option function.
|
||||
type Option func(o *Options)
|
||||
|
||||
// Options defines the available options for this package.
|
||||
type Options struct {
|
||||
Logger log.Logger
|
||||
Config *config.Config
|
||||
JWTSecret string
|
||||
TracerProvider trace.TracerProvider
|
||||
Metrics *metrics.Metrics
|
||||
GatewaySelector *pool.Selector[gateway.GatewayAPIClient]
|
||||
Searcher search.Searcher
|
||||
}
|
||||
|
||||
func newOptions(opts ...Option) Options {
|
||||
opt := Options{}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
return opt
|
||||
}
|
||||
|
||||
// Logger provides a function to set the Logger option.
|
||||
func Logger(val log.Logger) Option {
|
||||
return func(o *Options) {
|
||||
o.Logger = val
|
||||
}
|
||||
}
|
||||
|
||||
// Config provides a function to set the Config option.
|
||||
func Config(val *config.Config) Option {
|
||||
return func(o *Options) {
|
||||
o.Config = val
|
||||
}
|
||||
}
|
||||
|
||||
// JWTSecret provides a function to set the Config option.
|
||||
func JWTSecret(val string) Option {
|
||||
return func(o *Options) {
|
||||
o.JWTSecret = val
|
||||
}
|
||||
}
|
||||
|
||||
// TracerProvider provides a function to set the TracerProvider option
|
||||
func TracerProvider(val trace.TracerProvider) Option {
|
||||
return func(o *Options) {
|
||||
o.TracerProvider = val
|
||||
}
|
||||
}
|
||||
|
||||
// Metrics provides a function to set the Metrics option.
|
||||
func Metrics(val *metrics.Metrics) Option {
|
||||
return func(o *Options) {
|
||||
if val != nil {
|
||||
o.Metrics = val
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GatewaySelector provides a function to set the GatewaySelector option.
|
||||
func GatewaySelector(val *pool.Selector[gateway.GatewayAPIClient]) Option {
|
||||
return func(o *Options) {
|
||||
o.GatewaySelector = val
|
||||
}
|
||||
}
|
||||
|
||||
// Searcher provides a function to set the Searcher option.
|
||||
func Searcher(val search.Searcher) Option {
|
||||
return func(o *Options) {
|
||||
o.Searcher = val
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/jellydator/ttlcache/v2"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/errtypes"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/token"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/token/manager/jwt"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
merrors "go-micro.dev/v4/errors"
|
||||
"go-micro.dev/v4/metadata"
|
||||
grpcmetadata "google.golang.org/grpc/metadata"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
v0 "github.com/qsfera/server/protogen/gen/qsfera/messages/search/v0"
|
||||
searchsvc "github.com/qsfera/server/protogen/gen/qsfera/services/search/v0"
|
||||
"github.com/qsfera/server/services/search/pkg/config"
|
||||
"github.com/qsfera/server/services/search/pkg/search"
|
||||
)
|
||||
|
||||
// NewHandler returns a service implementation for Service.
|
||||
func NewHandler(opts ...Option) (searchsvc.SearchProviderHandler, error) {
|
||||
options := newOptions(opts...)
|
||||
cfg := options.Config
|
||||
if options.GatewaySelector == nil {
|
||||
return nil, errors.New("no GatewaySelector provided")
|
||||
}
|
||||
if options.Searcher == nil {
|
||||
return nil, errors.New("no Searcher provided")
|
||||
}
|
||||
|
||||
cache := ttlcache.NewCache()
|
||||
if err := cache.SetTTL(time.Second); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tokenManager, err := jwt.New(map[string]any{
|
||||
"secret": options.JWTSecret,
|
||||
"expires": int64(24 * 60 * 60),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Service{
|
||||
id: cfg.GRPC.Namespace + "." + cfg.Service.Name,
|
||||
log: &options.Logger,
|
||||
searcher: options.Searcher,
|
||||
cache: cache,
|
||||
tokenManager: tokenManager,
|
||||
gws: options.GatewaySelector,
|
||||
cfg: cfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Service implements the searchServiceHandler interface
|
||||
type Service struct {
|
||||
id string
|
||||
log *log.Logger
|
||||
searcher search.Searcher
|
||||
cache *ttlcache.Cache
|
||||
tokenManager token.Manager
|
||||
gws *pool.Selector[gateway.GatewayAPIClient]
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
// Search handles the search
|
||||
func (s Service) Search(ctx context.Context, in *searchsvc.SearchRequest, out *searchsvc.SearchResponse) error {
|
||||
// Get token from the context (go-micro) and make it known to the reva client too (grpc)
|
||||
t, ok := metadata.Get(ctx, revactx.TokenHeader)
|
||||
if !ok {
|
||||
s.log.Error().Msg("Could not get token from context")
|
||||
return errors.New("could not get token from context")
|
||||
}
|
||||
ctx = grpcmetadata.AppendToOutgoingContext(ctx, revactx.TokenHeader, t)
|
||||
|
||||
// unpack user
|
||||
u, _, err := s.tokenManager.DismantleToken(ctx, t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx = revactx.ContextSetUser(ctx, u)
|
||||
|
||||
key := cacheKey(in.Query, in.PageSize, in.Ref, u)
|
||||
res, ok := s.FromCache(key)
|
||||
if !ok {
|
||||
var err error
|
||||
res, err = s.searcher.Search(ctx, &searchsvc.SearchRequest{
|
||||
Query: in.Query,
|
||||
PageSize: in.PageSize,
|
||||
Ref: in.Ref,
|
||||
})
|
||||
if err != nil {
|
||||
switch err.(type) {
|
||||
case errtypes.BadRequest:
|
||||
return merrors.BadRequest(s.id, "%s", err.Error())
|
||||
default:
|
||||
return merrors.InternalServerError(s.id, "%s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
s.Cache(key, res)
|
||||
}
|
||||
|
||||
out.Matches = res.Matches
|
||||
out.TotalMatches = res.TotalMatches
|
||||
out.NextPageToken = res.NextPageToken
|
||||
return nil
|
||||
}
|
||||
|
||||
// IndexSpace (re)indexes all resources of a given space.
|
||||
func (s Service) IndexSpace(_ context.Context, in *searchsvc.IndexSpaceRequest, _ *searchsvc.IndexSpaceResponse) error {
|
||||
if in.GetSpaceId() != "" {
|
||||
return s.searcher.IndexSpace(&provider.StorageSpaceId{OpaqueId: in.GetSpaceId()}, in.GetForceReindex())
|
||||
}
|
||||
|
||||
// index all spaces instead
|
||||
gwc, err := s.gws.Next()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, err := utils.GetServiceUserContext(s.cfg.ServiceAccount.ServiceAccountID, gwc, s.cfg.ServiceAccount.ServiceAccountSecret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := gwc.ListStorageSpaces(ctx, &provider.ListStorageSpacesRequest{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.GetStatus().GetCode() != rpc.Code_CODE_OK {
|
||||
return errors.New(resp.GetStatus().GetMessage())
|
||||
}
|
||||
|
||||
for _, space := range resp.GetStorageSpaces() {
|
||||
if err := s.searcher.IndexSpace(space.GetId(), in.GetForceReindex()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FromCache pulls a search result from cache
|
||||
func (s Service) FromCache(key string) (*searchsvc.SearchResponse, bool) {
|
||||
v, err := s.cache.Get(key)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
sr, ok := v.(*searchsvc.SearchResponse)
|
||||
return sr, ok
|
||||
}
|
||||
|
||||
// Cache caches the search result
|
||||
func (s Service) Cache(key string, res *searchsvc.SearchResponse) {
|
||||
// lets ignore the error
|
||||
_ = s.cache.Set(key, res)
|
||||
}
|
||||
|
||||
func cacheKey(query string, pagesize int32, ref *v0.Reference, user *user.User) string {
|
||||
return fmt.Sprintf("%s|%d|%s$%s!%s/%s|%s", query, pagesize, ref.GetResourceId().GetStorageId(), ref.GetResourceId().GetSpaceId(), ref.GetResourceId().GetOpaqueId(), ref.GetPath(), user.GetId().GetOpaqueId())
|
||||
}
|
||||
Reference in New Issue
Block a user