262 lines
5.1 KiB
Go
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
|
|
}
|