0.2.68
This commit is contained in:
@@ -8,7 +8,7 @@ is in the library" and answers by reading all of it. This asks "what will somebo
|
||||
on over the next few evenings", which Tracearr already knows, and reads almost nothing:
|
||||
|
||||
```
|
||||
Tracearr demand → priority queue → marker cached? → tiny tail scan → one write → never again
|
||||
Tracearr demand → priority queue → marker/cooldown check → tiny tail scan → history + marker
|
||||
```
|
||||
|
||||
## Why it exists
|
||||
@@ -57,6 +57,19 @@ postgres.go the store adapter
|
||||
load.go whether the server is too busy for speculative work
|
||||
```
|
||||
|
||||
## Operator controls and history
|
||||
|
||||
Admin Console → Credits detection controls three different bounds: the number of candidates
|
||||
allowed to wait, the ordinary and maximum episode look-ahead, and the retry delay after an
|
||||
inconclusive or failed speculative scan. The environment values are first-run defaults; a
|
||||
saved console choice is restored on restart.
|
||||
|
||||
The retry delay is important. A successful marker excludes an episode naturally, but an old
|
||||
worker forgot a no-match result and selected the episode again at the next ten-minute
|
||||
refresh. Completed attempts now have a small durable history and no-match/failed episodes
|
||||
cool down before prediction may select them again. Live playback may still raise an episode
|
||||
immediately because a viewer is waiting.
|
||||
|
||||
## Two detectors, and the cheap one is often better
|
||||
|
||||
**Behavioural** clustering costs nothing: no file is opened, no decoder runs, no new row is
|
||||
@@ -111,7 +124,7 @@ number belonging to neither.
|
||||
| Fine pass | 750ms over ±30s — ~80 frames |
|
||||
| Frame buffer | 14,400 bytes, allocated once and reused for the whole pass |
|
||||
| Writes, cached marker | **0** |
|
||||
| Writes, successful scan | **1** |
|
||||
| Writes, completed scan | **1 history row**, plus **1 marker** only when accepted |
|
||||
|
||||
Against scanning the full library: 20,000 items read end to end versus a queue capped at 20
|
||||
candidates, most of which are rejected by the marker check before any media is touched. Once
|
||||
@@ -135,7 +148,8 @@ in which demand-driven narrowing is doing nothing, and the design would need rev
|
||||
|
||||
- **The queue is deliberately not durable.** Candidate priorities are rebuilt from one
|
||||
Tracearr query on restart, which is cheaper and simpler than a second persistent job
|
||||
scheduler. The database holds markers and nothing else.
|
||||
scheduler. Completed scan history is durable because it is an operator record and the
|
||||
source of retry cooldowns; it does not preserve or resume queue state.
|
||||
- **A single worker, and it is not a placeholder for a pool.** Concurrent scans multiply the
|
||||
two costs this exists to minimise on a machine whose real job is streaming video.
|
||||
- **Live playback does not scan immediately.** `livePlaybackDelay` (45s) is what stops a
|
||||
|
||||
@@ -35,6 +35,10 @@ type Config struct {
|
||||
// QueueLimit bounds the queue. Past it, low-priority speculation is discarded rather than
|
||||
// queued — a backlog of candidates for episodes nobody reached is worse than no backlog.
|
||||
QueueLimit int
|
||||
|
||||
// RetryCooldown keeps an unsuccessful speculative scan from returning on every queue
|
||||
// refresh. Live playback remains immediate; this applies only to predicted work.
|
||||
RetryCooldown time.Duration
|
||||
}
|
||||
|
||||
func DefaultConfig() Config {
|
||||
@@ -45,9 +49,50 @@ func DefaultConfig() Config {
|
||||
UsefulWindow: 3 * 24 * time.Hour,
|
||||
WeakWindow: 7 * 24 * time.Hour,
|
||||
QueueLimit: 20,
|
||||
RetryCooldown: 24 * time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
// NormaliseConfig makes both environment and admin-supplied tuning safe. It is exported
|
||||
// because the gateway restores persisted settings before constructing the service.
|
||||
func NormaliseConfig(cfg Config) Config {
|
||||
defaults := DefaultConfig()
|
||||
if cfg.PrefetchEpisodes <= 0 {
|
||||
cfg.PrefetchEpisodes = defaults.PrefetchEpisodes
|
||||
}
|
||||
if cfg.PrefetchEpisodes > 10 {
|
||||
cfg.PrefetchEpisodes = 10
|
||||
}
|
||||
if cfg.MaxPrefetchEpisodes < cfg.PrefetchEpisodes {
|
||||
cfg.MaxPrefetchEpisodes = cfg.PrefetchEpisodes
|
||||
}
|
||||
if cfg.MaxPrefetchEpisodes > 20 {
|
||||
cfg.MaxPrefetchEpisodes = 20
|
||||
}
|
||||
if cfg.QueueLimit <= 0 {
|
||||
cfg.QueueLimit = defaults.QueueLimit
|
||||
}
|
||||
if cfg.QueueLimit > 100 {
|
||||
cfg.QueueLimit = 100
|
||||
}
|
||||
if cfg.StrongWindow <= 0 {
|
||||
cfg.StrongWindow = defaults.StrongWindow
|
||||
}
|
||||
if cfg.UsefulWindow <= 0 {
|
||||
cfg.UsefulWindow = defaults.UsefulWindow
|
||||
}
|
||||
if cfg.WeakWindow <= 0 {
|
||||
cfg.WeakWindow = defaults.WeakWindow
|
||||
}
|
||||
if cfg.RetryCooldown < 0 {
|
||||
cfg.RetryCooldown = 0
|
||||
}
|
||||
if cfg.RetryCooldown > 30*24*time.Hour {
|
||||
cfg.RetryCooldown = 30 * 24 * time.Hour
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// Watch is one episode one viewer played, reduced to the four things candidate generation
|
||||
// needs. It is what a Tracearr session becomes on the way in, and keeping it this narrow is
|
||||
// what lets every rule below be tested with a literal.
|
||||
|
||||
@@ -174,6 +174,38 @@ type CandidateSource interface {
|
||||
Candidates(ctx context.Context) ([]Candidate, error)
|
||||
}
|
||||
|
||||
// ConfigurableCandidateSource accepts live operator tuning without rebuilding the worker.
|
||||
type ConfigurableCandidateSource interface {
|
||||
Configure(Config)
|
||||
}
|
||||
|
||||
// ScanAttempt is the durable operational account of one claimed candidate. Unlike the
|
||||
// queue, this is history rather than scheduling state, so retaining it does not turn the
|
||||
// worker into a persistent job system.
|
||||
type ScanAttempt struct {
|
||||
ItemID string
|
||||
SeriesID string
|
||||
Season int
|
||||
Episode int
|
||||
Reason string
|
||||
Priority int
|
||||
Outcome string
|
||||
MarkerMs int64
|
||||
Confidence float64
|
||||
Method string
|
||||
Frames int
|
||||
Error string
|
||||
StartedAt time.Time
|
||||
FinishedAt time.Time
|
||||
}
|
||||
|
||||
// HistoryRepository records completed scans and answers which speculative candidates are
|
||||
// still inside their retry cooldown.
|
||||
type HistoryRepository interface {
|
||||
SaveScanAttempt(ctx context.Context, attempt ScanAttempt) error
|
||||
RecentScanTimes(ctx context.Context, itemIDs []string, since time.Time) (map[string]time.Time, error)
|
||||
}
|
||||
|
||||
// Repository is the marker store. Narrow on purpose — the scheduler must not be able to
|
||||
// write anything else, because "one row per successful scan and nothing else" is the
|
||||
// performance claim this subsystem is making.
|
||||
|
||||
@@ -40,6 +40,23 @@ func (p Postgres) SaveMarker(ctx context.Context, marker Marker) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (p Postgres) SaveScanAttempt(ctx context.Context, attempt ScanAttempt) error {
|
||||
return p.Store.SaveCreditsScanAttempt(ctx, store.CreditsScanHistoryRow{
|
||||
ItemID: attempt.ItemID, SeriesID: attempt.SeriesID,
|
||||
Season: attempt.Season, Episode: attempt.Episode,
|
||||
Reason: attempt.Reason, Priority: attempt.Priority, Outcome: attempt.Outcome,
|
||||
MarkerMs: attempt.MarkerMs, Confidence: attempt.Confidence,
|
||||
Method: attempt.Method, Frames: attempt.Frames, Error: attempt.Error,
|
||||
StartedAt: attempt.StartedAt, FinishedAt: attempt.FinishedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (p Postgres) RecentScanTimes(
|
||||
ctx context.Context, itemIDs []string, since time.Time,
|
||||
) (map[string]time.Time, error) {
|
||||
return p.Store.RecentCreditsScanTimes(ctx, itemIDs, since)
|
||||
}
|
||||
|
||||
func (p Postgres) SeasonMarkers(
|
||||
ctx context.Context, seriesID string, season, limit int,
|
||||
) ([]Marker, error) {
|
||||
|
||||
@@ -136,6 +136,25 @@ func (q *Queue) Len() int {
|
||||
return len(q.items)
|
||||
}
|
||||
|
||||
// SetLimit applies an operator change immediately. The strongest candidates survive a
|
||||
// reduction, using the same ordering as Claim, so changing the setting cannot reshuffle
|
||||
// the meaning of priority.
|
||||
func (q *Queue) SetLimit(limit int) {
|
||||
if limit <= 0 {
|
||||
return
|
||||
}
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
q.limit = limit
|
||||
for len(q.items) > q.limit {
|
||||
weakestID, _ := q.weakestLocked()
|
||||
if weakestID == "" {
|
||||
break
|
||||
}
|
||||
delete(q.items, weakestID)
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot is the queue in worker order, for the log line and the admin console.
|
||||
func (q *Queue) Snapshot() []Candidate {
|
||||
q.mu.Lock()
|
||||
|
||||
@@ -141,3 +141,17 @@ func TestQueueOrderIsTotalAndStable(t *testing.T) {
|
||||
t.Fatalf("claimed %q; more recent demand should win a tie", candidate.ItemID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReducingQueueLimitKeepsTheStrongestCandidates(t *testing.T) {
|
||||
queue := NewQueue(5)
|
||||
queue.Push(queued("weak", PrioritySpeculative))
|
||||
queue.Push(queued("ahead", PriorityAhead2))
|
||||
queue.Push(queued("next", PriorityNext))
|
||||
|
||||
queue.SetLimit(2)
|
||||
|
||||
pending := queue.Snapshot()
|
||||
if len(pending) != 2 || pending[0].ItemID != "next" || pending[1].ItemID != "ahead" {
|
||||
t.Fatalf("reduced queue = %+v; want the two strongest candidates", pending)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ type LoadGauge interface {
|
||||
// missing from the image.
|
||||
type Deps struct {
|
||||
Repository Repository
|
||||
History HistoryRepository
|
||||
Resolver MediaResolver
|
||||
Source CandidateSource
|
||||
Detector Detector
|
||||
@@ -87,6 +88,7 @@ type Deps struct {
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
history HistoryRepository
|
||||
resolver MediaResolver
|
||||
source CandidateSource
|
||||
detector Detector
|
||||
@@ -94,6 +96,7 @@ type Service struct {
|
||||
load LoadGauge
|
||||
log *slog.Logger
|
||||
cfg Config
|
||||
cfgMu sync.RWMutex
|
||||
|
||||
queue *Queue
|
||||
flight *flightGroup
|
||||
@@ -107,10 +110,7 @@ type Service struct {
|
||||
}
|
||||
|
||||
func New(deps Deps) *Service {
|
||||
cfg := deps.Config
|
||||
if cfg.QueueLimit <= 0 {
|
||||
cfg = DefaultConfig()
|
||||
}
|
||||
cfg := NormaliseConfig(deps.Config)
|
||||
detector := deps.Detector
|
||||
if detector == nil {
|
||||
detector = noopDetector{}
|
||||
@@ -121,6 +121,7 @@ func New(deps Deps) *Service {
|
||||
}
|
||||
return &Service{
|
||||
repo: deps.Repository,
|
||||
history: deps.History,
|
||||
resolver: deps.Resolver,
|
||||
source: deps.Source,
|
||||
detector: detector,
|
||||
@@ -181,9 +182,37 @@ func (s *Service) Refresh(ctx context.Context) (string, error) {
|
||||
}
|
||||
wanted = append(wanted, candidate)
|
||||
}
|
||||
deferred := 0
|
||||
cfg := s.Configuration()
|
||||
if s.history != nil && cfg.RetryCooldown > 0 && len(wanted) > 0 {
|
||||
ids := make([]string, 0, len(wanted))
|
||||
for _, candidate := range wanted {
|
||||
ids = append(ids, candidate.ItemID)
|
||||
}
|
||||
recent, historyErr := s.history.RecentScanTimes(
|
||||
ctx, ids, time.Now().UTC().Add(-cfg.RetryCooldown),
|
||||
)
|
||||
if historyErr != nil {
|
||||
s.log.Debug("credits scan history unavailable", "error", historyErr)
|
||||
} else if len(recent) > 0 {
|
||||
eligible := wanted[:0]
|
||||
for _, candidate := range wanted {
|
||||
if _, coolingDown := recent[candidate.ItemID]; coolingDown {
|
||||
deferred++
|
||||
continue
|
||||
}
|
||||
eligible = append(eligible, candidate)
|
||||
}
|
||||
wanted = eligible
|
||||
}
|
||||
}
|
||||
s.queue.Replace(wanted)
|
||||
|
||||
if len(wanted) == 0 {
|
||||
if deferred > 0 {
|
||||
return fmt.Sprintf("%d candidate%s cooling down, %d already known",
|
||||
deferred, plural(deferred), skipped), nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
for _, candidate := range wanted {
|
||||
@@ -191,8 +220,8 @@ func (s *Service) Refresh(ctx context.Context) (string, error) {
|
||||
"item", candidate.ItemID, "reason", candidate.Reason,
|
||||
"priority", candidate.Priority, "users", candidate.UserCount)
|
||||
}
|
||||
return fmt.Sprintf("%d candidate%s queued, %d already known",
|
||||
len(wanted), plural(len(wanted)), skipped), nil
|
||||
return fmt.Sprintf("%d candidate%s queued, %d cooling down, %d already known",
|
||||
len(wanted), plural(len(wanted)), deferred, skipped), nil
|
||||
}
|
||||
|
||||
// NotePlayback is the live signal, and the strongest one there is: somebody is watching this
|
||||
@@ -303,10 +332,12 @@ func (s *Service) Run(ctx context.Context) {
|
||||
continue
|
||||
}
|
||||
|
||||
started := time.Now().UTC()
|
||||
scanCtx, cancel := context.WithTimeout(ctx, scanBudget)
|
||||
detection, stored, err := s.Process(scanCtx, candidate.ItemID)
|
||||
cancel()
|
||||
s.queue.Release(candidate.ItemID)
|
||||
s.recordAttempt(ctx, candidate, detection, stored, err, started)
|
||||
|
||||
switch {
|
||||
case err != nil && errors.Is(err, context.Canceled):
|
||||
@@ -496,6 +527,61 @@ func (s *Service) busy(ctx context.Context) bool {
|
||||
return s.load != nil && s.load.Busy(ctx)
|
||||
}
|
||||
|
||||
// Configuration is a snapshot safe to expose through the admin API.
|
||||
func (s *Service) Configuration() Config {
|
||||
s.cfgMu.RLock()
|
||||
defer s.cfgMu.RUnlock()
|
||||
return s.cfg
|
||||
}
|
||||
|
||||
// Configure applies persisted operator tuning to future candidate refreshes and trims an
|
||||
// over-full queue immediately. A scan already in flight is deliberately left alone.
|
||||
func (s *Service) Configure(cfg Config) {
|
||||
cfg = NormaliseConfig(cfg)
|
||||
s.cfgMu.Lock()
|
||||
s.cfg = cfg
|
||||
s.cfgMu.Unlock()
|
||||
if source, ok := s.source.(ConfigurableCandidateSource); ok {
|
||||
source.Configure(cfg)
|
||||
}
|
||||
s.queue.SetLimit(cfg.QueueLimit)
|
||||
}
|
||||
|
||||
func (s *Service) recordAttempt(
|
||||
ctx context.Context, candidate Candidate, detection Detection, stored bool, scanErr error,
|
||||
started time.Time,
|
||||
) {
|
||||
if s.history == nil {
|
||||
return
|
||||
}
|
||||
outcome := "no_match"
|
||||
switch {
|
||||
case scanErr != nil:
|
||||
outcome = "failed"
|
||||
case stored:
|
||||
outcome = "detected"
|
||||
case detection.Found:
|
||||
outcome = "unchanged"
|
||||
}
|
||||
errorText := ""
|
||||
if scanErr != nil {
|
||||
errorText = scanErr.Error()
|
||||
}
|
||||
finished := time.Now().UTC()
|
||||
writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := s.history.SaveScanAttempt(writeCtx, ScanAttempt{
|
||||
ItemID: candidate.ItemID, SeriesID: candidate.SeriesID,
|
||||
Season: candidate.Season, Episode: candidate.Episode,
|
||||
Reason: candidate.Reason, Priority: candidate.Priority, Outcome: outcome,
|
||||
MarkerMs: detection.StartMs, Confidence: detection.Confidence,
|
||||
Method: detection.Method, Frames: detection.FramesSampled, Error: errorText,
|
||||
StartedAt: started, FinishedAt: finished,
|
||||
}); err != nil {
|
||||
s.log.Debug("credits scan history write failed", "item", candidate.ItemID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// QueueDepth is what the console reads.
|
||||
func (s *Service) QueueDepth() int { return s.queue.Len() }
|
||||
|
||||
|
||||
@@ -85,6 +85,26 @@ type fakeBehaviour struct{ stops []StopEvent }
|
||||
|
||||
func (b fakeBehaviour) Stops(context.Context, string) ([]StopEvent, error) { return b.stops, nil }
|
||||
|
||||
type fixedCandidates []Candidate
|
||||
|
||||
func (f fixedCandidates) Candidates(context.Context) ([]Candidate, error) { return f, nil }
|
||||
|
||||
type fakeHistory struct {
|
||||
recent map[string]time.Time
|
||||
attempts []ScanAttempt
|
||||
}
|
||||
|
||||
func (h *fakeHistory) SaveScanAttempt(_ context.Context, attempt ScanAttempt) error {
|
||||
h.attempts = append(h.attempts, attempt)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *fakeHistory) RecentScanTimes(
|
||||
_ context.Context, _ []string, _ time.Time,
|
||||
) (map[string]time.Time, error) {
|
||||
return h.recent, nil
|
||||
}
|
||||
|
||||
func quietLog() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
}
|
||||
@@ -356,3 +376,42 @@ func TestSustainedPlaybackIsQueuedAtLivePriority(t *testing.T) {
|
||||
t.Fatalf("candidate = %+v, want live playback at priority %d", pending[0], PriorityLive)
|
||||
}
|
||||
}
|
||||
|
||||
// An inconclusive scan used to be forgotten, so the ten-minute refresh selected the same
|
||||
// episode for ever. History now makes that episode ineligible until its cooldown expires.
|
||||
func TestRefreshDefersRecentlyScannedCandidate(t *testing.T) {
|
||||
media := testMedia()
|
||||
history := &fakeHistory{recent: map[string]time.Time{media.Version.ItemID: time.Now()}}
|
||||
service := New(Deps{
|
||||
Repository: newFakeRepo(), History: history,
|
||||
Resolver: &fakeResolver{media: map[string]ResolvedMedia{media.Version.ItemID: media}},
|
||||
Source: fixedCandidates{{ItemID: media.Version.ItemID, Priority: PriorityNext}},
|
||||
Log: quietLog(), Config: DefaultConfig(),
|
||||
})
|
||||
|
||||
detail, err := service.Refresh(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if service.QueueDepth() != 0 {
|
||||
t.Fatal("a recently scanned episode returned to the queue")
|
||||
}
|
||||
if detail != "1 candidate cooling down, 0 already known" {
|
||||
t.Fatalf("refresh detail = %q", detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanAttemptHistoryDescribesNoMatch(t *testing.T) {
|
||||
history := &fakeHistory{}
|
||||
service := New(Deps{
|
||||
Repository: newFakeRepo(), History: history,
|
||||
Resolver: &fakeResolver{media: map[string]ResolvedMedia{}},
|
||||
Log: quietLog(), Config: DefaultConfig(),
|
||||
})
|
||||
candidate := Candidate{ItemID: "episode-7", Reason: ReasonNext, Priority: PriorityNext}
|
||||
service.recordAttempt(context.Background(), candidate, Detection{FramesSampled: 42}, false, nil, time.Now().Add(-time.Second))
|
||||
|
||||
if len(history.attempts) != 1 || history.attempts[0].Outcome != "no_match" || history.attempts[0].Frames != 42 {
|
||||
t.Fatalf("history = %+v", history.attempts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package credits
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -39,6 +40,19 @@ const watchLimit = 500
|
||||
type TracearrSource struct {
|
||||
DB Database
|
||||
Cfg Config
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func (s *TracearrSource) Configure(cfg Config) {
|
||||
s.mu.Lock()
|
||||
s.Cfg = NormaliseConfig(cfg)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *TracearrSource) config() Config {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return NormaliseConfig(s.Cfg)
|
||||
}
|
||||
|
||||
// Candidates is the whole predictive pipeline: read recent demand, group it into per-viewer
|
||||
@@ -51,10 +65,7 @@ func (s *TracearrSource) Candidates(ctx context.Context) ([]Candidate, error) {
|
||||
if s == nil || s.DB == nil {
|
||||
return nil, nil
|
||||
}
|
||||
cfg := s.Cfg
|
||||
if cfg.QueueLimit <= 0 {
|
||||
cfg = DefaultConfig()
|
||||
}
|
||||
cfg := s.config()
|
||||
now := time.Now().UTC()
|
||||
|
||||
// The decay window is the query's window too. Anything older cannot survive
|
||||
|
||||
Reference in New Issue
Block a user