Files
backend/internal/pkg/hook/hook.go

432 lines
9.5 KiB
Go
Raw Normal View History

package hook
import (
"context"
"sync"
"time"
"go.uber.org/zap"
)
type HookType string
const (
HookPostCreated HookType = "post.created"
HookPostUpdated HookType = "post.updated"
HookPostDeleted HookType = "post.deleted"
HookPostPreModerate HookType = "post.pre_moderate"
HookPostModerated HookType = "post.moderated"
HookCommentCreated HookType = "comment.created"
HookCommentDeleted HookType = "comment.deleted"
HookCommentPreModerate HookType = "comment.pre_moderate"
HookCommentModerated HookType = "comment.moderated"
HookUserCreated HookType = "user.created"
HookUserUpdated HookType = "user.updated"
HookUserDeleted HookType = "user.deleted"
HookUserLogin HookType = "user.login"
HookUserLogout HookType = "user.logout"
HookGroupCreated HookType = "group.created"
HookGroupUpdated HookType = "group.updated"
HookGroupDeleted HookType = "group.deleted"
HookGroupJoined HookType = "group.joined"
HookGroupLeft HookType = "group.left"
HookMessageSent HookType = "message.sent"
HookMessageRead HookType = "message.read"
HookNotificationSent HookType = "notification.sent"
HookVoteCreated HookType = "vote.created"
HookVoteUpdated HookType = "vote.updated"
HookFileUploaded HookType = "file.uploaded"
HookUserAvatarPreModerate HookType = "user.avatar.pre_moderate"
HookUserAvatarModerated HookType = "user.avatar.moderated"
HookUserCoverPreModerate HookType = "user.cover.pre_moderate"
HookUserCoverModerated HookType = "user.cover.moderated"
HookUserBioPreModerate HookType = "user.bio.pre_moderate"
HookUserBioModerated HookType = "user.bio.moderated"
)
type Priority int
const (
PriorityLowest Priority = 0
PriorityLow Priority = 25
PriorityNormal Priority = 50
PriorityHigh Priority = 75
PriorityHighest Priority = 100
)
type HookContext struct {
Ctx context.Context
HookType HookType
UserID uint
Data any
Metadata map[string]any
}
type HookFunc func(ctx *HookContext) error
type Hook struct {
Name string
HookType HookType
Func HookFunc
Priority Priority
Async bool
Timeout time.Duration
Enabled bool
}
type HookStats struct {
TotalCalls int64
FailedCalls int64
TotalTime time.Duration
AverageTime time.Duration
LastCalledAt time.Time
}
type hookEntry struct {
hook *Hook
stats HookStats
mu sync.Mutex
}
type Manager struct {
hooks map[HookType][]*hookEntry
mu sync.RWMutex
asyncPool chan struct{}
enabled bool
metrics *Metrics
}
type ManagerOptions struct {
MaxConcurrentAsync int
Enabled bool
}
func NewManager(opts *ManagerOptions) *Manager {
if opts == nil {
opts = &ManagerOptions{
MaxConcurrentAsync: 100,
Enabled: true,
}
}
if opts.MaxConcurrentAsync <= 0 {
opts.MaxConcurrentAsync = 100
}
return &Manager{
hooks: make(map[HookType][]*hookEntry),
asyncPool: make(chan struct{}, opts.MaxConcurrentAsync),
enabled: opts.Enabled,
metrics: NewMetrics(),
}
}
func (m *Manager) Register(hook *Hook) {
if hook == nil || hook.Func == nil {
return
}
if hook.Priority == 0 {
hook.Priority = PriorityNormal
}
if hook.Timeout == 0 {
hook.Timeout = 30 * time.Second
}
hook.Enabled = true
entry := &hookEntry{
hook: hook,
stats: HookStats{},
}
m.mu.Lock()
defer m.mu.Unlock()
hooks := m.hooks[hook.HookType]
hooks = append(hooks, entry)
for i := len(hooks) - 1; i > 0; i-- {
if hooks[i].hook.Priority > hooks[i-1].hook.Priority {
hooks[i], hooks[i-1] = hooks[i-1], hooks[i]
} else {
break
}
}
m.hooks[hook.HookType] = hooks
zap.L().Debug("Hook registered",
zap.String("name", hook.Name),
zap.String("type", string(hook.HookType)),
zap.Int("priority", int(hook.Priority)),
)
}
func (m *Manager) RegisterFunc(hookType HookType, name string, fn HookFunc, opts ...HookOption) {
hook := &Hook{
Name: name,
HookType: hookType,
Func: fn,
}
for _, opt := range opts {
opt(hook)
}
m.Register(hook)
}
type HookOption func(*Hook)
func WithPriority(p Priority) HookOption {
return func(h *Hook) {
h.Priority = p
}
}
func WithAsync() HookOption {
return func(h *Hook) {
h.Async = true
}
}
func (m *Manager) Trigger(ctx context.Context, hookType HookType, userID uint, data any) error {
return m.TriggerWithMetadata(ctx, hookType, userID, data, nil)
}
func (m *Manager) TriggerWithMetadata(ctx context.Context, hookType HookType, userID uint, data any, metadata map[string]any) error {
if !m.enabled {
return nil
}
m.mu.RLock()
entries := m.hooks[hookType]
m.mu.RUnlock()
if len(entries) == 0 {
return nil
}
hookCtx := &HookContext{
Ctx: ctx,
HookType: hookType,
UserID: userID,
Data: data,
Metadata: metadata,
}
var asyncHooks []*hookEntry
var firstSyncErr error
for _, entry := range entries {
if !entry.hook.Enabled {
continue
}
if entry.hook.Async {
asyncHooks = append(asyncHooks, entry)
continue
}
if err := m.executeHook(entry, hookCtx); err != nil {
if firstSyncErr == nil {
firstSyncErr = err
}
zap.L().Error("Hook execution failed",
zap.String("name", entry.hook.Name),
zap.String("type", string(hookType)),
zap.Error(err),
)
}
}
for _, entry := range asyncHooks {
entry := entry
go func() {
select {
case m.asyncPool <- struct{}{}:
defer func() { <-m.asyncPool }()
if err := m.executeHook(entry, hookCtx); err != nil {
zap.L().Error("Async hook execution failed",
zap.String("name", entry.hook.Name),
zap.String("type", string(hookType)),
zap.Error(err),
)
}
default:
zap.L().Warn("Async hook pool full, skipping hook",
zap.String("name", entry.hook.Name),
zap.String("type", string(hookType)),
)
}
}()
}
return firstSyncErr
}
func (m *Manager) executeHook(entry *hookEntry, ctx *HookContext) error {
start := time.Now()
// Derive a cancellable context bounded by the hook's Timeout so that
// long-running hook work (e.g. HTTP calls to the moderation API) is
// actually cancelled when the timeout fires, instead of leaking and
// racing on shared metadata after executeHook returns.
if entry.hook.Timeout > 0 {
hookCtx, cancel := context.WithTimeout(ctx.Ctx, entry.hook.Timeout)
defer cancel()
ctx = &HookContext{
Ctx: hookCtx,
HookType: ctx.HookType,
UserID: ctx.UserID,
Data: ctx.Data,
Metadata: ctx.Metadata,
}
}
var err error
if entry.hook.Timeout > 0 {
done := make(chan error, 1)
go func() {
defer func() {
if r := recover(); r != nil {
zap.L().Error("Hook panic recovered",
zap.String("name", entry.hook.Name),
zap.Any("panic", r),
)
done <- nil
}
}()
done <- entry.hook.Func(ctx)
}()
select {
case err = <-done:
case <-ctx.Ctx.Done():
err = ctx.Ctx.Err()
zap.L().Warn("Hook timeout",
zap.String("name", entry.hook.Name),
zap.Duration("timeout", entry.hook.Timeout),
zap.Error(err),
)
}
// Wait for the spawned goroutine to observe cancellation and return,
// so it cannot keep writing to ctx.Metadata after executeHook returns.
<-done
} else {
err = entry.hook.Func(ctx)
}
duration := time.Since(start)
entry.mu.Lock()
entry.stats.TotalCalls++
entry.stats.TotalTime += duration
entry.stats.AverageTime = time.Duration(int64(entry.stats.TotalTime) / entry.stats.TotalCalls)
entry.stats.LastCalledAt = time.Now()
if err != nil {
entry.stats.FailedCalls++
}
entry.mu.Unlock()
m.metrics.RecordHookExecution(entry.hook.Name, entry.hook.HookType, duration, err == nil)
return err
}
func (m *Manager) Unregister(hookType HookType, name string) {
m.mu.Lock()
defer m.mu.Unlock()
entries := m.hooks[hookType]
for i, entry := range entries {
if entry.hook.Name == name {
m.hooks[hookType] = append(entries[:i], entries[i+1:]...)
zap.L().Debug("Hook unregistered",
zap.String("name", name),
zap.String("type", string(hookType)),
)
break
}
}
}
func (m *Manager) Enable(hookType HookType, name string) {
m.mu.RLock()
entries := m.hooks[hookType]
m.mu.RUnlock()
for _, entry := range entries {
if entry.hook.Name == name {
entry.hook.Enabled = true
break
}
}
}
func (m *Manager) Disable(hookType HookType, name string) {
m.mu.RLock()
entries := m.hooks[hookType]
m.mu.RUnlock()
for _, entry := range entries {
if entry.hook.Name == name {
entry.hook.Enabled = false
break
}
}
}
func (m *Manager) GetStats(hookType HookType, name string) *HookStats {
m.mu.RLock()
entries := m.hooks[hookType]
m.mu.RUnlock()
for _, entry := range entries {
if entry.hook.Name == name {
entry.mu.Lock()
stats := entry.stats
entry.mu.Unlock()
return &stats
}
}
return nil
}
func (m *Manager) GetAllStats() map[HookType]map[string]HookStats {
m.mu.RLock()
defer m.mu.RUnlock()
result := make(map[HookType]map[string]HookStats)
for hookType, entries := range m.hooks {
result[hookType] = make(map[string]HookStats)
for _, entry := range entries {
entry.mu.Lock()
result[hookType][entry.hook.Name] = entry.stats
entry.mu.Unlock()
}
}
return result
}
func (m *Manager) GetMetrics() *Metrics {
return m.metrics
}
func (m *Manager) Clear(hookType HookType) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.hooks, hookType)
}
func (m *Manager) ClearAll() {
m.mu.Lock()
defer m.mu.Unlock()
m.hooks = make(map[HookType][]*hookEntry)
}
func (m *Manager) SetEnabled(enabled bool) {
m.mu.Lock()
defer m.mu.Unlock()
m.enabled = enabled
}