Files

199 lines
5.8 KiB
Go
Raw Permalink Normal View History

2026-08-15 09:23:26 +12:00
package credits
import (
"sync"
"time"
)
// The queue, which lives entirely in RAM and is deliberately allowed to be lost.
//
// A durable job queue would need rows written for every state transition, and the whole
// point of this subsystem is that the database sees one write per *scan*, not one per
// candidate. Nothing here is expensive to rebuild: the priorities came from a single indexed
// query against Tracearr sessions, and on restart that query simply runs again. A persistent
// scheduler would cost more to maintain than it could ever save.
// Queue is a bounded priority queue of candidates, deduplicated by item.
//
// Bounded because saturation is a real state and the right response to it is to throw
// speculation away: a queue holding forty episodes nobody has reached is not a queue that
// will eventually catch up, it is one that has stopped describing demand.
type Queue struct {
limit int
mu sync.Mutex
items map[string]Candidate
claimed map[string]bool
}
func NewQueue(limit int) *Queue {
if limit <= 0 {
limit = 20
}
return &Queue{
limit: limit,
items: map[string]Candidate{},
claimed: map[string]bool{},
}
}
// Push offers a candidate. It is merged with anything already queued for the same item — the
// strongest case wins — and dropped if the queue is full and it is not strong enough to
// displace what is already there.
//
// Returns whether the candidate is now queued, which is what lets a caller log a discard
// rather than believe it queued something.
func (q *Queue) Push(candidate Candidate) bool {
if candidate.ItemID == "" {
return false
}
q.mu.Lock()
defer q.mu.Unlock()
// Already being scanned. Single-flight lives in the service, but refusing to re-queue
// what a worker is holding keeps the queue honest about what is outstanding.
if q.claimed[candidate.ItemID] {
return false
}
if existing, found := q.items[candidate.ItemID]; found {
if candidate.Priority > existing.Priority {
existing.Priority = candidate.Priority
existing.Reason = candidate.Reason
}
if candidate.UserCount > existing.UserCount {
existing.UserCount = candidate.UserCount
}
if candidate.LastViewed.After(existing.LastViewed) {
existing.LastViewed = candidate.LastViewed
}
q.items[candidate.ItemID] = existing
return true
}
if len(q.items) >= q.limit {
weakestID, weakest := q.weakestLocked()
// Ties go to what is already queued. A candidate that has been waiting is one the
// worker is closer to reaching, and swapping equals would let a busy refresh cycle
// churn the queue without ever finishing anything.
if weakestID == "" || candidate.Priority <= weakest.Priority {
return false
}
delete(q.items, weakestID)
}
q.items[candidate.ItemID] = candidate
return true
}
// Replace swaps the speculative contents of the queue for a freshly built set, which is what
// a refresh cycle does. Anything a worker has claimed is untouched — cancelling a scan that
// is already reading a file to replace it with a marginally better candidate would waste
// exactly the disk activity this package exists to avoid.
func (q *Queue) Replace(candidates []Candidate) {
q.mu.Lock()
claimed := q.claimed
q.items = map[string]Candidate{}
q.mu.Unlock()
for _, candidate := range candidates {
if claimed[candidate.ItemID] {
continue
}
q.Push(candidate)
}
}
// Claim takes the highest-priority candidate and marks it in flight. Release must follow.
func (q *Queue) Claim() (Candidate, bool) {
q.mu.Lock()
defer q.mu.Unlock()
best, found := Candidate{}, false
for _, candidate := range q.items {
if !found || betterCandidate(candidate, best) {
best, found = candidate, true
}
}
if !found {
return Candidate{}, false
}
delete(q.items, best.ItemID)
q.claimed[best.ItemID] = true
return best, true
}
// Release ends a claim. Called from a defer so a panicking detector cannot wedge an item out
// of the queue for the life of the process.
func (q *Queue) Release(itemID string) {
q.mu.Lock()
defer q.mu.Unlock()
delete(q.claimed, itemID)
}
// Len is the number waiting, not counting anything in flight.
func (q *Queue) Len() int {
q.mu.Lock()
defer q.mu.Unlock()
return len(q.items)
}
2026-08-16 12:13:51 +12:00
// 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)
}
}
2026-08-15 09:23:26 +12:00
// Snapshot is the queue in worker order, for the log line and the admin console.
func (q *Queue) Snapshot() []Candidate {
q.mu.Lock()
out := make([]Candidate, 0, len(q.items))
for _, candidate := range q.items {
out = append(out, candidate)
}
q.mu.Unlock()
sortCandidates(out)
return out
}
// weakestLocked finds the candidate to discard under saturation. Caller holds the lock.
func (q *Queue) weakestLocked() (string, Candidate) {
weakestID, weakest, found := "", Candidate{}, false
for id, candidate := range q.items {
if !found || betterCandidate(weakest, candidate) {
weakestID, weakest, found = id, candidate, true
}
}
return weakestID, weakest
}
// betterCandidate is the one ordering rule, shared by Claim and the saturation discard so
// the thing taken first and the thing thrown away first can never disagree.
func betterCandidate(a, b Candidate) bool {
if a.Priority != b.Priority {
return a.Priority > b.Priority
}
if !a.LastViewed.Equal(b.LastViewed) {
return a.LastViewed.After(b.LastViewed)
}
return a.ItemID < b.ItemID
}
// pending is a live-playback candidate waiting out its settling delay.
type pending struct {
candidate Candidate
due time.Time
cancel func()
}