Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
@@ -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),
)