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
|
||||
}
|
||||
Reference in New Issue
Block a user