vikunja/pkg/events/events.go

265 lines
7.8 KiB
Go

// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package events
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
"github.com/getsentry/sentry-go"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/log"
vmetrics "code.vikunja.io/api/pkg/metrics"
"github.com/ThreeDotsLabs/watermill"
"github.com/ThreeDotsLabs/watermill/components/metrics"
"github.com/ThreeDotsLabs/watermill/message"
"github.com/ThreeDotsLabs/watermill/message/router/middleware"
"github.com/ThreeDotsLabs/watermill/pubsub/gochannel"
)
var pubsub *gochannel.GoChannel
// activeHandlers tracks in-flight event handler goroutines so the test
// endpoint can wait for them to finish before truncating tables.
var activeHandlers sync.WaitGroup
// WaitForPendingHandlers blocks until all currently in-flight event handler
// goroutines have completed (including retries). This is intended for the
// testing endpoint to avoid connection starvation: async handlers from the
// previous test can hold SQLite connections, starving the next test's seed
// request.
func WaitForPendingHandlers() {
activeHandlers.Wait()
}
// Event represents the event interface used by all events
type Event interface {
Name() string
}
type messageHandleFailedError struct {
Metadata message.Metadata
}
func (m *messageHandleFailedError) Error() string {
return fmt.Sprintf("Failed to handle message: %v", m.Metadata)
}
// InitEvents sets up everything needed to work with events
func InitEvents() (err error) {
logger := log.NewWatermillLogger(config.LogEnabled.GetBool(), config.LogEvents.GetString(), config.LogEventsLevel.GetString(), config.LogFormat.GetString())
router, err := message.NewRouter(
message.RouterConfig{},
logger,
)
if err != nil {
return err
}
metricsBuilder := metrics.NewPrometheusMetricsBuilder(vmetrics.GetRegistry(), "", "")
metricsBuilder.AddPrometheusRouterMetrics(router)
pubsub = gochannel.NewGoChannel(
gochannel.Config{
OutputChannelBuffer: 1024,
},
logger,
)
poison, err := middleware.PoisonQueue(pubsub, "poison")
if err != nil {
return err
}
router.AddConsumerHandler("poison.logger", "poison", pubsub, func(msg *message.Message) error {
meta := ""
for s, m := range msg.Metadata {
meta += s + "=" + m + ", "
}
log.Errorf("Error while handling message %s, %s payload=%s", msg.UUID, meta, string(msg.Payload))
if config.SentryEnabled.GetBool() {
sentry.CaptureException(&messageHandleFailedError{
Metadata: msg.Metadata,
})
}
return nil
})
// handlerTracker is a middleware that tracks in-flight handlers via the
// activeHandlers WaitGroup. It wraps the entire processing chain
// (including retries) so WaitForPendingHandlers() can drain all work.
handlerTracker := func(h message.HandlerFunc) message.HandlerFunc {
return func(msg *message.Message) ([]*message.Message, error) {
activeHandlers.Add(1)
defer activeHandlers.Done()
return h(msg)
}
}
router.AddMiddleware(
handlerTracker,
poison,
middleware.Retry{
MaxRetries: 5,
InitialInterval: time.Millisecond * 100,
MaxInterval: time.Hour,
Multiplier: 2,
MaxElapsedTime: 0,
RandomizationFactor: 1,
Logger: logger,
}.Middleware,
middleware.Recoverer,
)
for topic, funcs := range listeners {
for _, handler := range funcs {
router.AddConsumerHandler(topic+"."+handler.Name(), topic, pubsub, handler.Handle)
}
}
return router.Run(context.Background())
}
// InitEventsForTesting sets up the event system like InitEvents but accepts a
// context so the watermill router can be shut down cleanly in tests.
// It starts the router in a background goroutine and returns a channel that is
// closed once the router is ready to accept messages.
func InitEventsForTesting(ctx context.Context) (<-chan struct{}, error) {
logger := log.NewWatermillLogger(config.LogEnabled.GetBool(), config.LogEvents.GetString(), config.LogEventsLevel.GetString(), config.LogFormat.GetString())
router, err := message.NewRouter(
message.RouterConfig{},
logger,
)
if err != nil {
return nil, err
}
pubsub = gochannel.NewGoChannel(
gochannel.Config{
OutputChannelBuffer: 1024,
},
logger,
)
// No prometheus metrics in tests — avoids duplicate registration panics
// No poison queue — keep test output clean, let errors surface directly
handlerTracker := func(h message.HandlerFunc) message.HandlerFunc {
return func(msg *message.Message) ([]*message.Message, error) {
activeHandlers.Add(1)
defer activeHandlers.Done()
return h(msg)
}
}
router.AddMiddleware(
handlerTracker,
middleware.Retry{
MaxRetries: 3,
InitialInterval: time.Millisecond * 50,
MaxInterval: time.Second,
Multiplier: 2,
Logger: logger,
}.Middleware,
middleware.Recoverer,
)
for topic, funcs := range listeners {
for _, handler := range funcs {
router.AddConsumerHandler(topic+"."+handler.Name(), topic, pubsub, handler.Handle)
}
}
ready := router.Running()
go func() {
if err := router.Run(ctx); err != nil {
log.Errorf("Event system error: %s", err)
}
}()
return ready, nil
}
// Dispatch dispatches an event
func Dispatch(event Event) error {
if isUnderTest {
dispatchedTestEvents = append(dispatchedTestEvents, event)
return nil
}
if pubsub == nil {
return fmt.Errorf("event system not initialized: call InitEvents or InitEventsForTesting before dispatching")
}
content, err := json.Marshal(event)
if err != nil {
return err
}
msg := message.NewMessage(watermill.NewUUID(), content)
return pubsub.Publish(event.Name(), msg)
}
// pendingEventQueue holds the pending events and a mutex for thread-safe access
type pendingEventQueue struct {
mu sync.Mutex
events []Event
}
var pendingEvents sync.Map // map[any]*pendingEventQueue
// DispatchOnCommit stores an event to be dispatched later, after a transaction commits.
// The key should be the *xorm.Session pointer associated with the transaction.
// Call DispatchPending(key) after s.Commit() to actually dispatch the events.
// Call CleanupPending(key) on rollback to discard them.
func DispatchOnCommit(key any, event Event) {
val, _ := pendingEvents.LoadOrStore(key, &pendingEventQueue{})
queue := val.(*pendingEventQueue)
queue.mu.Lock()
queue.events = append(queue.events, event)
queue.mu.Unlock()
}
// DispatchPending dispatches all events accumulated for the given key and removes them.
// Call this after s.Commit(). Safe to call even if no events were registered.
// If any event fails to dispatch, the error is logged but remaining events are still dispatched.
func DispatchPending(key any) {
val, ok := pendingEvents.LoadAndDelete(key)
if !ok {
return
}
queue := val.(*pendingEventQueue)
// No need to lock here since we've already removed it from the map
// and this key won't receive new events
for _, event := range queue.events {
if err := Dispatch(event); err != nil {
log.Errorf("Failed to dispatch event %s: %v", event.Name(), err)
}
}
}
// CleanupPending discards all pending events for the given key without dispatching.
// Call this when a transaction is rolled back.
func CleanupPending(key any) {
pendingEvents.Delete(key)
}