init push

This commit is contained in:
2026-07-16 22:16:45 +07:00
commit 8b068bdb10
1021 changed files with 332816 additions and 0 deletions

View 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
}

View 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):
}
}

View 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...)
}

View 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)
}
}

View 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
}