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

293 lines
6.9 KiB
Go

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