Files
fm_be/internal/realtime/helicopterfile/hub.go
2026-07-16 22:16:45 +07:00

262 lines
5.1 KiB
Go

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
}