init push
This commit is contained in:
272
internal/realtime/filemanager/status_hub.go
Normal file
272
internal/realtime/filemanager/status_hub.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package filemanagerrealtime
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
FileStatusChangedEventType = "file_status_changed"
|
||||
defaultSubscriberBuffer = 32
|
||||
)
|
||||
|
||||
type FileStatusEvent struct {
|
||||
EventType string `json:"event_type"`
|
||||
FileUUID string `json:"file_uuid"`
|
||||
FolderUUID *string `json:"folder_uuid,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Name string `json:"name"`
|
||||
MimeType string `json:"mime_type,omitempty"`
|
||||
FailureReason *string `json:"failure_reason"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Subscription struct {
|
||||
events <-chan FileStatusEvent
|
||||
done <-chan struct{}
|
||||
closeFn func()
|
||||
}
|
||||
|
||||
func (s *Subscription) Events() <-chan FileStatusEvent {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return s.events
|
||||
}
|
||||
|
||||
func (s *Subscription) Done() <-chan struct{} {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return s.done
|
||||
}
|
||||
|
||||
func (s *Subscription) Close() {
|
||||
if s == nil || s.closeFn == nil {
|
||||
return
|
||||
}
|
||||
s.closeFn()
|
||||
}
|
||||
|
||||
type statusSubscriber struct {
|
||||
events chan FileStatusEvent
|
||||
done chan struct{}
|
||||
match func(FileStatusEvent) bool
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
func (s *statusSubscriber) close() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.closeOnce.Do(func() {
|
||||
close(s.done)
|
||||
})
|
||||
}
|
||||
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
subscribers map[string]map[uint64]*statusSubscriber
|
||||
recentByUser map[string][]FileStatusEvent
|
||||
nextID uint64
|
||||
buffer int
|
||||
replayLimit int
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewHub(buffer int, logger *slog.Logger) *Hub {
|
||||
if buffer <= 0 {
|
||||
buffer = defaultSubscriberBuffer
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Hub{
|
||||
subscribers: make(map[string]map[uint64]*statusSubscriber),
|
||||
recentByUser: make(map[string][]FileStatusEvent),
|
||||
buffer: buffer,
|
||||
replayLimit: maxInt(buffer*4, defaultSubscriberBuffer),
|
||||
logger: logger.With("component", "file-status-hub"),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) Subscribe(userUUID string) (*Subscription, error) {
|
||||
return h.SubscribeWithFilter(userUUID, nil)
|
||||
}
|
||||
|
||||
func (h *Hub) SubscribeWithFilter(userUUID string, match func(FileStatusEvent) bool) (*Subscription, error) {
|
||||
if h == nil {
|
||||
return nil, errors.New("status hub is not configured")
|
||||
}
|
||||
userUUID = canonicalUserID(userUUID)
|
||||
if userUUID == "" {
|
||||
return nil, errors.New("user uuid is required")
|
||||
}
|
||||
|
||||
sub := &statusSubscriber{
|
||||
events: make(chan FileStatusEvent, h.buffer),
|
||||
done: make(chan struct{}),
|
||||
match: match,
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
h.nextID++
|
||||
subID := h.nextID
|
||||
if h.subscribers[userUUID] == nil {
|
||||
h.subscribers[userUUID] = make(map[uint64]*statusSubscriber)
|
||||
}
|
||||
h.subscribers[userUUID][subID] = sub
|
||||
backlog := append([]FileStatusEvent(nil), h.recentByUser[userUUID]...)
|
||||
h.mu.Unlock()
|
||||
|
||||
for i := range backlog {
|
||||
if sub.match != nil && !sub.match(backlog[i]) {
|
||||
continue
|
||||
}
|
||||
enqueueSubscriberEvent(sub, backlog[i])
|
||||
}
|
||||
|
||||
var closeOnce sync.Once
|
||||
closeFn := func() {
|
||||
closeOnce.Do(func() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if byUser, ok := h.subscribers[userUUID]; ok {
|
||||
delete(byUser, subID)
|
||||
if len(byUser) == 0 {
|
||||
delete(h.subscribers, userUUID)
|
||||
}
|
||||
}
|
||||
sub.close()
|
||||
})
|
||||
}
|
||||
|
||||
return &Subscription{
|
||||
events: sub.events,
|
||||
done: sub.done,
|
||||
closeFn: closeFn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Hub) PublishToUsers(userUUIDs []string, event FileStatusEvent) int {
|
||||
if h == nil {
|
||||
return 0
|
||||
}
|
||||
if strings.TrimSpace(event.EventType) == "" {
|
||||
event.EventType = FileStatusChangedEventType
|
||||
}
|
||||
if strings.TrimSpace(event.FileUUID) == "" || strings.TrimSpace(event.Status) == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
uniqueUsers := make(map[string]struct{}, len(userUUIDs))
|
||||
for i := range userUUIDs {
|
||||
userID := canonicalUserID(userUUIDs[i])
|
||||
if userID == "" {
|
||||
continue
|
||||
}
|
||||
uniqueUsers[userID] = struct{}{}
|
||||
}
|
||||
if len(uniqueUsers) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
delivered := 0
|
||||
for userID := range uniqueUsers {
|
||||
h.mu.Lock()
|
||||
h.recentByUser[userID] = appendRecentEvents(h.recentByUser[userID], event, h.replayLimit)
|
||||
h.mu.Unlock()
|
||||
|
||||
h.mu.RLock()
|
||||
byUser := h.subscribers[userID]
|
||||
if len(byUser) == 0 {
|
||||
h.mu.RUnlock()
|
||||
continue
|
||||
}
|
||||
|
||||
for _, sub := range byUser {
|
||||
select {
|
||||
case <-sub.done:
|
||||
continue
|
||||
default:
|
||||
}
|
||||
if sub.match != nil && !sub.match(event) {
|
||||
continue
|
||||
}
|
||||
|
||||
if enqueueSubscriberEvent(sub, event) {
|
||||
delivered++
|
||||
}
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
}
|
||||
return delivered
|
||||
}
|
||||
|
||||
func (h *Hub) SubscriberCount(userUUID string) int {
|
||||
if h == nil {
|
||||
return 0
|
||||
}
|
||||
userUUID = canonicalUserID(userUUID)
|
||||
if userUUID == "" {
|
||||
return 0
|
||||
}
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return len(h.subscribers[userUUID])
|
||||
}
|
||||
|
||||
func canonicalUserID(raw string) string {
|
||||
return strings.ToLower(strings.TrimSpace(raw))
|
||||
}
|
||||
|
||||
func enqueueSubscriberEvent(sub *statusSubscriber, event FileStatusEvent) bool {
|
||||
if sub == nil {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case <-sub.done:
|
||||
return false
|
||||
default:
|
||||
}
|
||||
|
||||
select {
|
||||
case sub.events <- event:
|
||||
return true
|
||||
default:
|
||||
// Drop the oldest buffered event to prioritize fresh status changes.
|
||||
select {
|
||||
case <-sub.events:
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case sub.events <- event:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func appendRecentEvents(current []FileStatusEvent, event FileStatusEvent, limit int) []FileStatusEvent {
|
||||
if limit <= 0 {
|
||||
return current[:0]
|
||||
}
|
||||
current = append(current, event)
|
||||
if len(current) <= limit {
|
||||
return current
|
||||
}
|
||||
overflow := len(current) - limit
|
||||
copy(current, current[overflow:])
|
||||
return current[:limit]
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a >= b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
167
internal/realtime/filemanager/status_hub_test.go
Normal file
167
internal/realtime/filemanager/status_hub_test.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package filemanagerrealtime
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHubSubscribePublishAndClose(t *testing.T) {
|
||||
hub := NewHub(4, nil)
|
||||
|
||||
sub, err := hub.Subscribe("01961f5c-6471-7b81-9df5-2e2e1cf7e498")
|
||||
if err != nil {
|
||||
t.Fatalf("subscribe: %v", err)
|
||||
}
|
||||
if got := hub.SubscriberCount("01961f5c-6471-7b81-9df5-2e2e1cf7e498"); got != 1 {
|
||||
t.Fatalf("expected subscriber count 1, got %d", got)
|
||||
}
|
||||
|
||||
delivered := hub.PublishToUsers([]string{"01961f5c-6471-7b81-9df5-2e2e1cf7e498"}, FileStatusEvent{
|
||||
EventType: FileStatusChangedEventType,
|
||||
FileUUID: "01961f5c-6471-7b81-9df5-2e2e1cf7e499",
|
||||
Status: "processing",
|
||||
Name: "report.pdf",
|
||||
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
if delivered != 1 {
|
||||
t.Fatalf("expected one delivered event, got %d", delivered)
|
||||
}
|
||||
|
||||
select {
|
||||
case event := <-sub.Events():
|
||||
if event.Status != "processing" {
|
||||
t.Fatalf("expected processing status, got %q", event.Status)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("timed out waiting for event")
|
||||
}
|
||||
|
||||
sub.Close()
|
||||
if got := hub.SubscriberCount("01961f5c-6471-7b81-9df5-2e2e1cf7e498"); got != 0 {
|
||||
t.Fatalf("expected subscriber count 0 after close, got %d", got)
|
||||
}
|
||||
|
||||
delivered = hub.PublishToUsers([]string{"01961f5c-6471-7b81-9df5-2e2e1cf7e498"}, FileStatusEvent{
|
||||
EventType: FileStatusChangedEventType,
|
||||
FileUUID: "01961f5c-6471-7b81-9df5-2e2e1cf7e499",
|
||||
Status: "validated",
|
||||
Name: "report.pdf",
|
||||
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
if delivered != 0 {
|
||||
t.Fatalf("expected no delivered event after close, got %d", delivered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileStatusEventJSONPayload(t *testing.T) {
|
||||
reason := "file bucket mismatch"
|
||||
event := FileStatusEvent{
|
||||
EventType: FileStatusChangedEventType,
|
||||
FileUUID: "01961f5c-6471-7b81-9df5-2e2e1cf7e499",
|
||||
Status: "failed",
|
||||
Name: "report.pdf",
|
||||
FailureReason: &reason,
|
||||
UpdatedAt: "2026-04-05T07:29:34Z",
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if decoded["event_type"] != FileStatusChangedEventType {
|
||||
t.Fatalf("unexpected event_type: %#v", decoded["event_type"])
|
||||
}
|
||||
if decoded["status"] != "failed" {
|
||||
t.Fatalf("unexpected status: %#v", decoded["status"])
|
||||
}
|
||||
if decoded["failure_reason"] != reason {
|
||||
t.Fatalf("unexpected failure_reason: %#v", decoded["failure_reason"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubReplaysRecentEventsOnSubscribe(t *testing.T) {
|
||||
hub := NewHub(2, nil)
|
||||
userID := "01961f5c-6471-7b81-9df5-2e2e1cf7e498"
|
||||
|
||||
// Publish without active subscribers.
|
||||
if delivered := hub.PublishToUsers([]string{userID}, FileStatusEvent{
|
||||
EventType: FileStatusChangedEventType,
|
||||
FileUUID: "01961f5c-6471-7b81-9df5-2e2e1cf7e499",
|
||||
Status: "processing",
|
||||
Name: "report.pdf",
|
||||
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}); delivered != 0 {
|
||||
t.Fatalf("expected zero delivered before subscription, got %d", delivered)
|
||||
}
|
||||
|
||||
sub, err := hub.Subscribe(userID)
|
||||
if err != nil {
|
||||
t.Fatalf("subscribe: %v", err)
|
||||
}
|
||||
defer sub.Close()
|
||||
|
||||
select {
|
||||
case event := <-sub.Events():
|
||||
if event.Status != "processing" {
|
||||
t.Fatalf("expected replayed processing status, got %q", event.Status)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("timed out waiting for replayed event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubSubscribeWithFilterSkipsUnmatchedEvents(t *testing.T) {
|
||||
hub := NewHub(2, nil)
|
||||
userID := "01961f5c-6471-7b81-9df5-2e2e1cf7e498"
|
||||
keepFileID := "01961f5c-6471-7b81-9df5-2e2e1cf7e499"
|
||||
skipFileID := "01961f5c-6471-7b81-9df5-2e2e1cf7e500"
|
||||
|
||||
sub, err := hub.SubscribeWithFilter(userID, func(event FileStatusEvent) bool {
|
||||
return event.FileUUID == keepFileID
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("subscribe with filter: %v", err)
|
||||
}
|
||||
defer sub.Close()
|
||||
|
||||
if delivered := hub.PublishToUsers([]string{userID}, FileStatusEvent{
|
||||
EventType: FileStatusChangedEventType,
|
||||
FileUUID: skipFileID,
|
||||
Status: "processing",
|
||||
Name: "skip.pdf",
|
||||
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}); delivered != 0 {
|
||||
t.Fatalf("expected unmatched event to be skipped, got %d delivered", delivered)
|
||||
}
|
||||
|
||||
if delivered := hub.PublishToUsers([]string{userID}, FileStatusEvent{
|
||||
EventType: FileStatusChangedEventType,
|
||||
FileUUID: keepFileID,
|
||||
Status: "ready",
|
||||
Name: "keep.pdf",
|
||||
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}); delivered != 1 {
|
||||
t.Fatalf("expected matched event to be delivered, got %d", delivered)
|
||||
}
|
||||
|
||||
select {
|
||||
case event := <-sub.Events():
|
||||
if event.FileUUID != keepFileID {
|
||||
t.Fatalf("expected filtered event, got %q", event.FileUUID)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("timed out waiting for filtered event")
|
||||
}
|
||||
|
||||
select {
|
||||
case event := <-sub.Events():
|
||||
t.Fatalf("unexpected extra event delivered: %#v", event)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
292
internal/realtime/filemanager/status_poller.go
Normal file
292
internal/realtime/filemanager/status_poller.go
Normal file
@@ -0,0 +1,292 @@
|
||||
package filemanagerrealtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
filemanager "wucher/internal/domain/file_manager"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
type StatusPollerConfig struct {
|
||||
PollInterval time.Duration
|
||||
BatchSize int
|
||||
OnPublished func(context.Context, FileStatusEvent) error
|
||||
}
|
||||
|
||||
type StatusPoller struct {
|
||||
db *gorm.DB
|
||||
hub *Hub
|
||||
logger *slog.Logger
|
||||
config StatusPollerConfig
|
||||
}
|
||||
|
||||
func NewStatusPoller(db *gorm.DB, hub *Hub, logger *slog.Logger, cfg StatusPollerConfig) *StatusPoller {
|
||||
if cfg.PollInterval <= 0 {
|
||||
cfg.PollInterval = time.Second
|
||||
}
|
||||
if cfg.BatchSize <= 0 {
|
||||
cfg.BatchSize = 100
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &StatusPoller{
|
||||
db: db,
|
||||
hub: hub,
|
||||
logger: logger.With("component", "file-status-poller"),
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *StatusPoller) Run(ctx context.Context) error {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
if p.db == nil {
|
||||
return errors.New("status poller requires database")
|
||||
}
|
||||
if p.hub == nil {
|
||||
return errors.New("status poller requires hub")
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(p.config.PollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
processed, err := p.PollOnce(ctx)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
p.logger.Error("file status poll failed", slog.Any("error", err))
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
if processed > 0 {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *StatusPoller) PollOnce(ctx context.Context) (int, error) {
|
||||
if p == nil {
|
||||
return 0, nil
|
||||
}
|
||||
if p.db == nil {
|
||||
return 0, errors.New("status poller requires database")
|
||||
}
|
||||
if p.hub == nil {
|
||||
return 0, errors.New("status poller requires hub")
|
||||
}
|
||||
|
||||
totalPublished := 0
|
||||
for {
|
||||
rows, err := p.listPendingEvents(ctx)
|
||||
if err != nil {
|
||||
return totalPublished, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return totalPublished, nil
|
||||
}
|
||||
|
||||
deliveredAt := time.Now().UTC()
|
||||
ids := make([][]byte, 0, len(rows))
|
||||
metadataByFileID, metadataErr := p.loadFileMetadata(ctx, rows)
|
||||
if metadataErr != nil {
|
||||
p.logger.Warn("file status metadata enrich failed", slog.Any("error", metadataErr))
|
||||
}
|
||||
for i := range rows {
|
||||
ids = append(ids, cloneBytes(rows[i].ID))
|
||||
|
||||
meta := metadataByFileID[string(rows[i].FileID)]
|
||||
event, recipient, ok := buildFileStatusEventFromRealtimeEvent(&rows[i], meta)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if p.hub.PublishToUsers([]string{recipient}, event) > 0 {
|
||||
totalPublished++
|
||||
}
|
||||
if p.config.OnPublished != nil {
|
||||
if err := p.config.OnPublished(ctx, event); err != nil {
|
||||
p.logger.Warn("file status post-publish callback failed", slog.Any("error", err), slog.String("file_uuid", event.FileUUID), slog.String("status", event.Status))
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := p.markDelivered(ctx, ids, deliveredAt); err != nil {
|
||||
return totalPublished, err
|
||||
}
|
||||
if len(rows) < p.config.BatchSize {
|
||||
return totalPublished, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type fileStatusEventFileMetadata struct {
|
||||
FolderUUID *string
|
||||
MimeType string
|
||||
}
|
||||
|
||||
func (p *StatusPoller) loadFileMetadata(ctx context.Context, events []filemanager.FileStatusRealtimeEvent) (map[string]fileStatusEventFileMetadata, error) {
|
||||
result := make(map[string]fileStatusEventFileMetadata, len(events))
|
||||
if p == nil || p.db == nil || len(events) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
fileIDs := make([][]byte, 0, len(events))
|
||||
seen := make(map[string]struct{}, len(events))
|
||||
for i := range events {
|
||||
key := string(events[i].FileID)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
fileIDs = append(fileIDs, cloneBytes(events[i].FileID))
|
||||
}
|
||||
if len(fileIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type fileRow struct {
|
||||
ID []byte `gorm:"column:id"`
|
||||
FolderID []byte `gorm:"column:folder_id"`
|
||||
MimeType string `gorm:"column:mime_type"`
|
||||
}
|
||||
rows := make([]fileRow, 0, len(fileIDs))
|
||||
if err := p.db.WithContext(ctx).
|
||||
Model(&filemanager.File{}).
|
||||
Select("id, folder_id, mime_type").
|
||||
Where("id IN ?", fileIDs).
|
||||
Find(&rows).Error; err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
for i := range rows {
|
||||
meta := fileStatusEventFileMetadata{
|
||||
MimeType: strings.TrimSpace(rows[i].MimeType),
|
||||
}
|
||||
if len(rows[i].FolderID) == 16 {
|
||||
if folderUUID, err := uuidv7.BytesToString(rows[i].FolderID); err == nil {
|
||||
folderUUID = strings.ToLower(strings.TrimSpace(folderUUID))
|
||||
if folderUUID != "" {
|
||||
meta.FolderUUID = &folderUUID
|
||||
}
|
||||
}
|
||||
}
|
||||
result[string(rows[i].ID)] = meta
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *StatusPoller) listPendingEvents(ctx context.Context) ([]filemanager.FileStatusRealtimeEvent, error) {
|
||||
rows := make([]filemanager.FileStatusRealtimeEvent, 0, p.config.BatchSize)
|
||||
err := p.db.WithContext(ctx).
|
||||
Model(&filemanager.FileStatusRealtimeEvent{}).
|
||||
Where("delivered_at IS NULL").
|
||||
Order("occurred_at ASC").
|
||||
Order("id ASC").
|
||||
Limit(p.config.BatchSize).
|
||||
Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (p *StatusPoller) markDelivered(ctx context.Context, ids [][]byte, deliveredAt time.Time) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
deliveredAt = deliveredAt.UTC()
|
||||
return p.db.WithContext(ctx).
|
||||
Model(&filemanager.FileStatusRealtimeEvent{}).
|
||||
Where("id IN ?", ids).
|
||||
Where("delivered_at IS NULL").
|
||||
Updates(map[string]any{
|
||||
"delivered_at": deliveredAt,
|
||||
"updated_at": deliveredAt,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func buildFileStatusEventFromRealtimeEvent(row *filemanager.FileStatusRealtimeEvent, meta fileStatusEventFileMetadata) (FileStatusEvent, string, bool) {
|
||||
if row == nil {
|
||||
return FileStatusEvent{}, "", false
|
||||
}
|
||||
|
||||
status := filemanager.NormalizeFileStatus(row.Status)
|
||||
if !isRealtimeStatus(status) {
|
||||
return FileStatusEvent{}, "", false
|
||||
}
|
||||
|
||||
fileUUID, err := uuidv7.BytesToString(row.FileID)
|
||||
if err != nil || strings.TrimSpace(fileUUID) == "" {
|
||||
return FileStatusEvent{}, "", false
|
||||
}
|
||||
|
||||
userUUID, err := uuidv7.BytesToString(row.UserID)
|
||||
if err != nil {
|
||||
return FileStatusEvent{}, "", false
|
||||
}
|
||||
userUUID = strings.ToLower(strings.TrimSpace(userUUID))
|
||||
if userUUID == "" {
|
||||
return FileStatusEvent{}, "", false
|
||||
}
|
||||
|
||||
reason := strings.TrimSpace(row.FailureReason)
|
||||
var failureReason *string
|
||||
if reason != "" {
|
||||
failureReason = &reason
|
||||
}
|
||||
|
||||
updatedAt := row.OccurredAt.UTC()
|
||||
if updatedAt.IsZero() {
|
||||
updatedAt = row.UpdatedAt.UTC()
|
||||
}
|
||||
if updatedAt.IsZero() {
|
||||
updatedAt = time.Now().UTC()
|
||||
}
|
||||
|
||||
return FileStatusEvent{
|
||||
EventType: FileStatusChangedEventType,
|
||||
FileUUID: fileUUID,
|
||||
FolderUUID: meta.FolderUUID,
|
||||
Status: status,
|
||||
Name: row.Name,
|
||||
MimeType: meta.MimeType,
|
||||
FailureReason: failureReason,
|
||||
UpdatedAt: updatedAt.Format(time.RFC3339),
|
||||
}, userUUID, true
|
||||
}
|
||||
|
||||
func isRealtimeStatus(status string) bool {
|
||||
switch filemanager.NormalizeFileStatus(status) {
|
||||
case filemanager.FileStatusUploaded,
|
||||
filemanager.FileStatusProcessing,
|
||||
filemanager.FileStatusValidated,
|
||||
filemanager.FileStatusFailed,
|
||||
filemanager.FileStatusReady,
|
||||
filemanager.FileStatusTrashed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func cloneBytes(raw []byte) []byte {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
return append([]byte(nil), raw...)
|
||||
}
|
||||
321
internal/realtime/filemanager/status_poller_test.go
Normal file
321
internal/realtime/filemanager/status_poller_test.go
Normal file
@@ -0,0 +1,321 @@
|
||||
package filemanagerrealtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
filemanager "wucher/internal/domain/file_manager"
|
||||
"wucher/internal/shared/pkg/uuidv7"
|
||||
)
|
||||
|
||||
func openStatusPollerTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("file:file_status_poller_%d?mode=memory&cache=shared", time.Now().UnixNano())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
||||
NowFunc: func() time.Time { return time.Now().UTC() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&filemanager.FileStatusRealtimeEvent{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&filemanager.File{}); err != nil {
|
||||
t.Fatalf("auto migrate file: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func waitStatusEvent(t *testing.T, sub *Subscription) FileStatusEvent {
|
||||
t.Helper()
|
||||
select {
|
||||
case event := <-sub.Events():
|
||||
return event
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("timed out waiting for status event")
|
||||
}
|
||||
return FileStatusEvent{}
|
||||
}
|
||||
|
||||
func insertRealtimeEventForPollerTest(
|
||||
t *testing.T,
|
||||
db *gorm.DB,
|
||||
fileID []byte,
|
||||
userID []byte,
|
||||
status string,
|
||||
name string,
|
||||
reason string,
|
||||
occurredAt time.Time,
|
||||
) {
|
||||
t.Helper()
|
||||
row := &filemanager.FileStatusRealtimeEvent{
|
||||
ID: uuidv7.MustBytes(),
|
||||
FileID: append([]byte(nil), fileID...),
|
||||
UserID: append([]byte(nil), userID...),
|
||||
Status: status,
|
||||
Name: name,
|
||||
FailureReason: reason,
|
||||
OccurredAt: occurredAt.UTC(),
|
||||
CreatedAt: occurredAt.UTC(),
|
||||
UpdatedAt: occurredAt.UTC(),
|
||||
}
|
||||
if err := db.WithContext(context.Background()).Create(row).Error; err != nil {
|
||||
t.Fatalf("insert realtime event: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func insertFileForPollerTest(t *testing.T, db *gorm.DB, fileID []byte, folderID []byte, mimeType string, name string) {
|
||||
t.Helper()
|
||||
now := time.Now().UTC()
|
||||
row := &filemanager.File{
|
||||
ID: append([]byte(nil), fileID...),
|
||||
FolderID: append([]byte(nil), folderID...),
|
||||
Name: name,
|
||||
NameNormalized: strings.ToLower(strings.TrimSpace(name)),
|
||||
Extension: "pdf",
|
||||
SizeBytes: 1,
|
||||
MimeType: mimeType,
|
||||
Bucket: "bucket",
|
||||
ObjectKey: "obj-" + name,
|
||||
Status: filemanager.FileStatusUploaded,
|
||||
NameSlot: "live",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := db.WithContext(context.Background()).Create(row).Error; err != nil {
|
||||
t.Fatalf("insert file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusPollerEmitsLifecycleEvents(t *testing.T) {
|
||||
db := openStatusPollerTestDB(t)
|
||||
fileID := uuidv7.MustBytes()
|
||||
fileUUID, err := uuidv7.BytesToString(fileID)
|
||||
if err != nil {
|
||||
t.Fatalf("file uuid: %v", err)
|
||||
}
|
||||
ownerID := uuidv7.MustBytes()
|
||||
ownerUUID, err := uuidv7.BytesToString(ownerID)
|
||||
if err != nil {
|
||||
t.Fatalf("owner uuid: %v", err)
|
||||
}
|
||||
folderID := uuidv7.MustBytes()
|
||||
folderUUID, err := uuidv7.BytesToString(folderID)
|
||||
if err != nil {
|
||||
t.Fatalf("folder uuid: %v", err)
|
||||
}
|
||||
insertFileForPollerTest(t, db, fileID, folderID, "application/pdf", "report.pdf")
|
||||
|
||||
hub := NewHub(8, nil)
|
||||
sub, err := hub.Subscribe(ownerUUID)
|
||||
if err != nil {
|
||||
t.Fatalf("subscribe: %v", err)
|
||||
}
|
||||
defer sub.Close()
|
||||
|
||||
poller := NewStatusPoller(db, hub, nil, StatusPollerConfig{
|
||||
PollInterval: time.Second,
|
||||
BatchSize: 50,
|
||||
})
|
||||
|
||||
baseAt := time.Now().UTC().Add(20 * time.Millisecond)
|
||||
insertRealtimeEventForPollerTest(t, db, fileID, ownerID, filemanager.FileStatusUploaded, "report.pdf", "", baseAt)
|
||||
insertRealtimeEventForPollerTest(t, db, fileID, ownerID, filemanager.FileStatusProcessing, "report.pdf", "", baseAt.Add(time.Second))
|
||||
insertRealtimeEventForPollerTest(t, db, fileID, ownerID, filemanager.FileStatusValidated, "report.pdf", "", baseAt.Add(2*time.Second))
|
||||
insertRealtimeEventForPollerTest(t, db, fileID, ownerID, filemanager.FileStatusReady, "report.pdf", "", baseAt.Add(3*time.Second))
|
||||
|
||||
processed, err := poller.PollOnce(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("poll once: %v", err)
|
||||
}
|
||||
if processed != 4 {
|
||||
t.Fatalf("expected 4 published events, got %d", processed)
|
||||
}
|
||||
|
||||
event := waitStatusEvent(t, sub)
|
||||
if event.Status != filemanager.FileStatusUploaded {
|
||||
t.Fatalf("expected uploaded status, got %s", event.Status)
|
||||
}
|
||||
if event.FileUUID != fileUUID {
|
||||
t.Fatalf("expected file uuid %s, got %s", fileUUID, event.FileUUID)
|
||||
}
|
||||
if event.FolderUUID == nil || !strings.EqualFold(*event.FolderUUID, folderUUID) {
|
||||
t.Fatalf("expected folder uuid %s, got %#v", folderUUID, event.FolderUUID)
|
||||
}
|
||||
if event.MimeType != "application/pdf" {
|
||||
t.Fatalf("expected mime type application/pdf, got %q", event.MimeType)
|
||||
}
|
||||
event = waitStatusEvent(t, sub)
|
||||
if event.Status != filemanager.FileStatusProcessing {
|
||||
t.Fatalf("expected processing status, got %s", event.Status)
|
||||
}
|
||||
event = waitStatusEvent(t, sub)
|
||||
if event.Status != filemanager.FileStatusValidated {
|
||||
t.Fatalf("expected validated status, got %s", event.Status)
|
||||
}
|
||||
event = waitStatusEvent(t, sub)
|
||||
if event.Status != filemanager.FileStatusReady {
|
||||
t.Fatalf("expected ready status, got %s", event.Status)
|
||||
}
|
||||
|
||||
var deliveredCount int64
|
||||
if err := db.WithContext(context.Background()).
|
||||
Model(&filemanager.FileStatusRealtimeEvent{}).
|
||||
Where("delivered_at IS NOT NULL").
|
||||
Count(&deliveredCount).Error; err != nil {
|
||||
t.Fatalf("count delivered rows: %v", err)
|
||||
}
|
||||
if deliveredCount != 4 {
|
||||
t.Fatalf("expected 4 delivered rows, got %d", deliveredCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusPollerInvokesOnPublishedCallback(t *testing.T) {
|
||||
db := openStatusPollerTestDB(t)
|
||||
fileID := uuidv7.MustBytes()
|
||||
fileUUID, err := uuidv7.BytesToString(fileID)
|
||||
if err != nil {
|
||||
t.Fatalf("file uuid: %v", err)
|
||||
}
|
||||
userID := uuidv7.MustBytes()
|
||||
userUUID, err := uuidv7.BytesToString(userID)
|
||||
if err != nil {
|
||||
t.Fatalf("user uuid: %v", err)
|
||||
}
|
||||
folderID := uuidv7.MustBytes()
|
||||
insertFileForPollerTest(t, db, fileID, folderID, "application/pdf", "report.pdf")
|
||||
insertRealtimeEventForPollerTest(t, db, fileID, userID, filemanager.FileStatusReady, "report.pdf", "", time.Now().UTC().Add(20*time.Millisecond))
|
||||
|
||||
hub := NewHub(4, nil)
|
||||
sub, err := hub.Subscribe(userUUID)
|
||||
if err != nil {
|
||||
t.Fatalf("subscribe: %v", err)
|
||||
}
|
||||
defer sub.Close()
|
||||
|
||||
var callbackCount int
|
||||
var callbackEvent FileStatusEvent
|
||||
poller := NewStatusPoller(db, hub, nil, StatusPollerConfig{
|
||||
PollInterval: time.Second,
|
||||
BatchSize: 50,
|
||||
OnPublished: func(_ context.Context, event FileStatusEvent) error {
|
||||
callbackCount++
|
||||
callbackEvent = event
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
processed, err := poller.PollOnce(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("poll once failed: %v", err)
|
||||
}
|
||||
if processed != 1 {
|
||||
t.Fatalf("expected one published event, got %d", processed)
|
||||
}
|
||||
if callbackCount != 1 {
|
||||
t.Fatalf("expected callback to run once, got %d", callbackCount)
|
||||
}
|
||||
if callbackEvent.Status != filemanager.FileStatusReady {
|
||||
t.Fatalf("expected ready callback event, got %s", callbackEvent.Status)
|
||||
}
|
||||
if callbackEvent.FileUUID != fileUUID {
|
||||
t.Fatalf("expected callback file uuid %s, got %s", fileUUID, callbackEvent.FileUUID)
|
||||
}
|
||||
_ = waitStatusEvent(t, sub)
|
||||
}
|
||||
|
||||
func TestStatusPollerEmitsFailedReason(t *testing.T) {
|
||||
db := openStatusPollerTestDB(t)
|
||||
fileID := uuidv7.MustBytes()
|
||||
ownerID := uuidv7.MustBytes()
|
||||
ownerUUID, err := uuidv7.BytesToString(ownerID)
|
||||
if err != nil {
|
||||
t.Fatalf("owner uuid: %v", err)
|
||||
}
|
||||
|
||||
hub := NewHub(4, nil)
|
||||
sub, err := hub.Subscribe(ownerUUID)
|
||||
if err != nil {
|
||||
t.Fatalf("subscribe: %v", err)
|
||||
}
|
||||
defer sub.Close()
|
||||
|
||||
poller := NewStatusPoller(db, hub, nil, StatusPollerConfig{
|
||||
PollInterval: time.Second,
|
||||
BatchSize: 50,
|
||||
})
|
||||
|
||||
insertRealtimeEventForPollerTest(
|
||||
t,
|
||||
db,
|
||||
fileID,
|
||||
ownerID,
|
||||
filemanager.FileStatusFailed,
|
||||
"invalid.pdf",
|
||||
"file bucket mismatch",
|
||||
time.Now().UTC().Add(20*time.Millisecond),
|
||||
)
|
||||
|
||||
processed, err := poller.PollOnce(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("poll once failed: %v", err)
|
||||
}
|
||||
if processed != 1 {
|
||||
t.Fatalf("expected one processed failed row, got %d", processed)
|
||||
}
|
||||
event := waitStatusEvent(t, sub)
|
||||
if event.Status != filemanager.FileStatusFailed {
|
||||
t.Fatalf("expected failed status event, got %s", event.Status)
|
||||
}
|
||||
if event.FailureReason == nil || *event.FailureReason == "" {
|
||||
t.Fatalf("expected failure reason on failed event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusPollerSkipsRowsWithoutValidRecipient(t *testing.T) {
|
||||
db := openStatusPollerTestDB(t)
|
||||
fileID := uuidv7.MustBytes()
|
||||
invalidUserID := []byte{1, 2, 3}
|
||||
|
||||
insertRealtimeEventForPollerTest(
|
||||
t,
|
||||
db,
|
||||
fileID,
|
||||
invalidUserID,
|
||||
filemanager.FileStatusUploaded,
|
||||
"nobody.pdf",
|
||||
"",
|
||||
time.Now().UTC().Add(20*time.Millisecond),
|
||||
)
|
||||
|
||||
hub := NewHub(4, nil)
|
||||
poller := NewStatusPoller(db, hub, nil, StatusPollerConfig{
|
||||
PollInterval: time.Second,
|
||||
BatchSize: 50,
|
||||
})
|
||||
|
||||
processed, err := poller.PollOnce(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("poll once: %v", err)
|
||||
}
|
||||
if processed != 0 {
|
||||
t.Fatalf("expected zero emitted events for row without valid recipient, got %d", processed)
|
||||
}
|
||||
|
||||
var deliveredCount int64
|
||||
if err := db.WithContext(context.Background()).
|
||||
Model(&filemanager.FileStatusRealtimeEvent{}).
|
||||
Where("delivered_at IS NOT NULL").
|
||||
Count(&deliveredCount).Error; err != nil {
|
||||
t.Fatalf("count delivered rows: %v", err)
|
||||
}
|
||||
if deliveredCount != 1 {
|
||||
t.Fatalf("expected skipped row to be marked delivered, got %d", deliveredCount)
|
||||
}
|
||||
}
|
||||
261
internal/realtime/helicopterfile/hub.go
Normal file
261
internal/realtime/helicopterfile/hub.go
Normal file
@@ -0,0 +1,261 @@
|
||||
package helicopterfilerealtime
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultEventType = "helicopter_file_changed"
|
||||
DefaultSubscriberSize = 32
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
EventType string `json:"event_type"`
|
||||
HelicopterID string `json:"helicopter_id"`
|
||||
HelicopterFileID string `json:"helicopter_file_id"`
|
||||
FileID string `json:"file_id,omitempty"`
|
||||
Section string `json:"section,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
MimeType string `json:"mime_type,omitempty"`
|
||||
AttachmentPending bool `json:"attachment_pending"`
|
||||
Position int `json:"position,omitempty"`
|
||||
IsMandatory bool `json:"is_mandatory,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type Subscription struct {
|
||||
events <-chan Event
|
||||
done <-chan struct{}
|
||||
closeFn func()
|
||||
}
|
||||
|
||||
func (s *Subscription) Events() <-chan Event {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return s.events
|
||||
}
|
||||
|
||||
func (s *Subscription) Done() <-chan struct{} {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return s.done
|
||||
}
|
||||
|
||||
func (s *Subscription) Close() {
|
||||
if s == nil || s.closeFn == nil {
|
||||
return
|
||||
}
|
||||
s.closeFn()
|
||||
}
|
||||
|
||||
type subscriber struct {
|
||||
events chan Event
|
||||
done chan struct{}
|
||||
match func(Event) bool
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
func (s *subscriber) close() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.closeOnce.Do(func() {
|
||||
close(s.done)
|
||||
})
|
||||
}
|
||||
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
subscribers map[string]map[uint64]*subscriber
|
||||
recentByUser map[string][]Event
|
||||
nextID uint64
|
||||
buffer int
|
||||
replayLimit int
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewHub(buffer int, logger *slog.Logger) *Hub {
|
||||
if buffer <= 0 {
|
||||
buffer = DefaultSubscriberSize
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Hub{
|
||||
subscribers: make(map[string]map[uint64]*subscriber),
|
||||
recentByUser: make(map[string][]Event),
|
||||
buffer: buffer,
|
||||
replayLimit: maxInt(buffer*4, DefaultSubscriberSize),
|
||||
logger: logger.With("component", "helicopter-file-hub"),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) Subscribe(userUUID string) (*Subscription, error) {
|
||||
return h.SubscribeWithFilter(userUUID, nil)
|
||||
}
|
||||
|
||||
func (h *Hub) SubscribeWithFilter(userUUID string, match func(Event) bool) (*Subscription, error) {
|
||||
if h == nil {
|
||||
return nil, errors.New("helicopter file hub is not configured")
|
||||
}
|
||||
userUUID = canonicalUserID(userUUID)
|
||||
if userUUID == "" {
|
||||
return nil, errors.New("user uuid is required")
|
||||
}
|
||||
|
||||
sub := &subscriber{
|
||||
events: make(chan Event, h.buffer),
|
||||
done: make(chan struct{}),
|
||||
match: match,
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
h.nextID++
|
||||
subID := h.nextID
|
||||
if h.subscribers[userUUID] == nil {
|
||||
h.subscribers[userUUID] = make(map[uint64]*subscriber)
|
||||
}
|
||||
h.subscribers[userUUID][subID] = sub
|
||||
backlog := append([]Event(nil), h.recentByUser[userUUID]...)
|
||||
h.mu.Unlock()
|
||||
|
||||
for i := range backlog {
|
||||
if sub.match != nil && !sub.match(backlog[i]) {
|
||||
continue
|
||||
}
|
||||
enqueueSubscriberEvent(sub, backlog[i])
|
||||
}
|
||||
|
||||
var closeOnce sync.Once
|
||||
closeFn := func() {
|
||||
closeOnce.Do(func() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if byUser, ok := h.subscribers[userUUID]; ok {
|
||||
delete(byUser, subID)
|
||||
if len(byUser) == 0 {
|
||||
delete(h.subscribers, userUUID)
|
||||
}
|
||||
}
|
||||
sub.close()
|
||||
})
|
||||
}
|
||||
|
||||
return &Subscription{
|
||||
events: sub.events,
|
||||
done: sub.done,
|
||||
closeFn: closeFn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Hub) PublishToUsers(userUUIDs []string, event Event) int {
|
||||
if h == nil {
|
||||
return 0
|
||||
}
|
||||
if strings.TrimSpace(event.EventType) == "" {
|
||||
event.EventType = DefaultEventType
|
||||
}
|
||||
if strings.TrimSpace(event.HelicopterID) == "" || strings.TrimSpace(event.HelicopterFileID) == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
uniqueUsers := make(map[string]struct{}, len(userUUIDs))
|
||||
for i := range userUUIDs {
|
||||
userID := canonicalUserID(userUUIDs[i])
|
||||
if userID == "" {
|
||||
continue
|
||||
}
|
||||
uniqueUsers[userID] = struct{}{}
|
||||
}
|
||||
if len(uniqueUsers) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
delivered := 0
|
||||
for userID := range uniqueUsers {
|
||||
h.mu.Lock()
|
||||
h.recentByUser[userID] = appendRecentEvents(h.recentByUser[userID], event, h.replayLimit)
|
||||
h.mu.Unlock()
|
||||
|
||||
h.mu.RLock()
|
||||
byUser := h.subscribers[userID]
|
||||
if len(byUser) == 0 {
|
||||
h.mu.RUnlock()
|
||||
continue
|
||||
}
|
||||
|
||||
for _, sub := range byUser {
|
||||
select {
|
||||
case <-sub.done:
|
||||
continue
|
||||
default:
|
||||
}
|
||||
if sub.match != nil && !sub.match(event) {
|
||||
continue
|
||||
}
|
||||
|
||||
if enqueueSubscriberEvent(sub, event) {
|
||||
delivered++
|
||||
}
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
}
|
||||
return delivered
|
||||
}
|
||||
|
||||
func canonicalUserID(raw string) string {
|
||||
return strings.ToLower(strings.TrimSpace(raw))
|
||||
}
|
||||
|
||||
func enqueueSubscriberEvent(sub *subscriber, event Event) bool {
|
||||
if sub == nil {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case <-sub.done:
|
||||
return false
|
||||
default:
|
||||
}
|
||||
|
||||
select {
|
||||
case sub.events <- event:
|
||||
return true
|
||||
default:
|
||||
select {
|
||||
case <-sub.events:
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case sub.events <- event:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func appendRecentEvents(current []Event, event Event, limit int) []Event {
|
||||
if limit <= 0 {
|
||||
return current[:0]
|
||||
}
|
||||
current = append(current, event)
|
||||
if len(current) <= limit {
|
||||
return current
|
||||
}
|
||||
start := len(current) - limit
|
||||
copy(current, current[start:])
|
||||
return current[:limit]
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
Reference in New Issue
Block a user