180 lines
5.3 KiB
Go
180 lines
5.3 KiB
Go
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)
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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()
|
||
|
|
}
|