init push
This commit is contained in:
292
internal/realtime/filemanager/status_poller.go
Normal file
292
internal/realtime/filemanager/status_poller.go
Normal 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...)
|
||||
}
|
||||
Reference in New Issue
Block a user