Files
silo-server/internal/catalog/collection_sync_scheduler.go
T

159 lines
4.2 KiB
Go

package catalog
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"sync"
"time"
"golang.org/x/sync/errgroup"
"github.com/Silo-Server/silo-server/internal/models"
)
// CollectionSyncScheduler finds collections due for automatic sync and
// processes them with bounded concurrency. It is driven by a TaskManager
// task on a short interval (e.g., every 5 minutes).
type CollectionSyncScheduler struct {
repo *LibraryCollectionRepository
service *LibraryCollectionService
logger *slog.Logger
// inFlight tracks collection IDs currently being synced to prevent
// concurrent syncs of the same collection (manual vs scheduled).
inFlight sync.Map
}
// CollectionSyncResult is the JSON summary attached to the task execution.
type CollectionSyncResult struct {
Due int `json:"due"`
Synced int `json:"synced"`
Failed int `json:"failed"`
Skipped int `json:"skipped"`
}
// NewCollectionSyncScheduler creates a new scheduler.
func NewCollectionSyncScheduler(
repo *LibraryCollectionRepository,
service *LibraryCollectionService,
logger *slog.Logger,
) *CollectionSyncScheduler {
return &CollectionSyncScheduler{
repo: repo,
service: service,
logger: logger,
}
}
// RunOnce queries for due collections and syncs them with bounded concurrency.
// It returns a JSON summary suitable for task result data.
func (s *CollectionSyncScheduler) RunOnce(ctx context.Context) (json.RawMessage, error) {
due, err := s.repo.ListDueForSync(ctx)
if err != nil {
return nil, fmt.Errorf("listing due collections: %w", err)
}
if len(due) == 0 {
return marshalResult(CollectionSyncResult{}), nil
}
s.logger.Info("collection sync scheduler: starting",
"due", len(due),
)
var (
mu sync.Mutex
result = CollectionSyncResult{Due: len(due)}
g, gctx = errgroup.WithContext(ctx)
)
g.SetLimit(3)
for _, collection := range due {
collection := collection
g.Go(func() error {
s.syncOne(gctx, collection, &mu, &result)
return nil // never propagate; failures are per-collection
})
}
_ = g.Wait()
s.logger.Info("collection sync scheduler: complete",
"due", result.Due,
"synced", result.Synced,
"failed", result.Failed,
"skipped", result.Skipped,
)
return marshalResult(result), nil
}
// syncOne syncs a single collection and advances its next_sync_at.
func (s *CollectionSyncScheduler) syncOne(ctx context.Context, collection *models.LibraryCollection, mu *sync.Mutex, result *CollectionSyncResult) {
// Guard against concurrent sync of the same collection (e.g., manual trigger).
if _, loaded := s.inFlight.LoadOrStore(collection.ID, struct{}{}); loaded {
s.logger.Info("collection sync scheduler: skipping (already in flight)",
"collection_id", collection.ID,
"title", collection.Title,
)
mu.Lock()
result.Skipped++
mu.Unlock()
return
}
defer s.inFlight.Delete(collection.ID)
startedAt := time.Now()
_, syncErr := s.service.SyncCollection(ctx, collection.ID)
completedAt := time.Now()
// Always advance next_sync_at, even on failure, so we retry on the
// natural cron schedule rather than every poll interval.
if collection.SyncSchedule != nil {
next := ComputeNextSyncAtFrom(*collection.SyncSchedule, completedAt)
if err := s.repo.UpdateNextSyncAt(ctx, collection.ID, next); err != nil {
s.logger.Error("collection sync scheduler: failed to advance schedule",
"collection_id", collection.ID,
"error", err,
)
}
}
mu.Lock()
defer mu.Unlock()
if syncErr != nil {
result.Failed++
s.logger.Error("collection sync scheduler: sync failed",
"collection_id", collection.ID,
"title", collection.Title,
"duration", completedAt.Sub(startedAt).Round(time.Millisecond),
"error", syncErr,
)
} else {
result.Synced++
s.logger.Info("collection sync scheduler: synced",
"collection_id", collection.ID,
"title", collection.Title,
"duration", completedAt.Sub(startedAt).Round(time.Millisecond),
)
}
}
// IsInFlight returns true if the given collection is currently being synced
// by the scheduler. Used by the manual sync handler to avoid overlap.
func (s *CollectionSyncScheduler) IsInFlight(collectionID string) bool {
_, ok := s.inFlight.Load(collectionID)
return ok
}
func marshalResult(r CollectionSyncResult) json.RawMessage {
data, _ := json.Marshal(r)
return data
}