projection: begin the exact-path public epoch
The public tree and its history contain only the listed paths. Earlier projection history remains preserved internally. Source-Sha: 913fde029b935671833254797f0f20f1eb9fabba Policy-Sha: 913fde029b935671833254797f0f20f1eb9fabba Tree-Digest: 180ae530c1058a2a5c89837bdce2d323ae83e669e38590ca72e75b8d92b7262f
This commit is contained in:
commit
67e26be2d9
838 changed files with 157302 additions and 0 deletions
286
server/internal/scheduler/queue.go
Normal file
286
server/internal/scheduler/queue.go
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
package scheduler
|
||||
|
||||
import (
|
||||
"container/heap"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/uuid/v5"
|
||||
)
|
||||
|
||||
// SubsystemJob represents a scheduled subsystem scan
|
||||
type SubsystemJob struct {
|
||||
AgentID uuid.UUID
|
||||
AgentHostname string // For logging/debugging
|
||||
Subsystem string
|
||||
IntervalMinutes int
|
||||
NextRunAt time.Time
|
||||
Enabled bool
|
||||
index int // Heap index (managed by heap.Interface)
|
||||
}
|
||||
|
||||
// String returns a human-readable representation of the job
|
||||
func (j *SubsystemJob) String() string {
|
||||
return fmt.Sprintf("[%s/%s] next_run=%s interval=%dm",
|
||||
j.AgentHostname, j.Subsystem,
|
||||
j.NextRunAt.Format("15:04:05"), j.IntervalMinutes)
|
||||
}
|
||||
|
||||
// jobHeap implements heap.Interface for SubsystemJob priority queue
|
||||
// Jobs are ordered by NextRunAt (earliest first)
|
||||
type jobHeap []*SubsystemJob
|
||||
|
||||
func (h jobHeap) Len() int { return len(h) }
|
||||
|
||||
func (h jobHeap) Less(i, j int) bool {
|
||||
return h[i].NextRunAt.Before(h[j].NextRunAt)
|
||||
}
|
||||
|
||||
func (h jobHeap) Swap(i, j int) {
|
||||
h[i], h[j] = h[j], h[i]
|
||||
h[i].index = i
|
||||
h[j].index = j
|
||||
}
|
||||
|
||||
func (h *jobHeap) Push(x interface{}) {
|
||||
n := len(*h)
|
||||
job := x.(*SubsystemJob)
|
||||
job.index = n
|
||||
*h = append(*h, job)
|
||||
}
|
||||
|
||||
func (h *jobHeap) Pop() interface{} {
|
||||
old := *h
|
||||
n := len(old)
|
||||
job := old[n-1]
|
||||
old[n-1] = nil // Avoid memory leak
|
||||
job.index = -1 // Mark as removed
|
||||
*h = old[0 : n-1]
|
||||
return job
|
||||
}
|
||||
|
||||
// PriorityQueue is a thread-safe priority queue for subsystem jobs
|
||||
// Jobs are ordered by their NextRunAt timestamp (earliest first)
|
||||
type PriorityQueue struct {
|
||||
heap jobHeap
|
||||
mu sync.RWMutex
|
||||
|
||||
// Index for fast lookups by agent_id + subsystem
|
||||
index map[string]*SubsystemJob // key: "agent_id:subsystem"
|
||||
}
|
||||
|
||||
// NewPriorityQueue creates a new empty priority queue
|
||||
func NewPriorityQueue() *PriorityQueue {
|
||||
pq := &PriorityQueue{
|
||||
heap: make(jobHeap, 0),
|
||||
index: make(map[string]*SubsystemJob),
|
||||
}
|
||||
heap.Init(&pq.heap)
|
||||
return pq
|
||||
}
|
||||
|
||||
// Push adds a job to the queue
|
||||
// If a job with the same agent_id + subsystem already exists, it's updated
|
||||
func (pq *PriorityQueue) Push(job *SubsystemJob) {
|
||||
pq.mu.Lock()
|
||||
defer pq.mu.Unlock()
|
||||
|
||||
key := makeKey(job.AgentID, job.Subsystem)
|
||||
|
||||
// Check if job already exists
|
||||
if existing, exists := pq.index[key]; exists {
|
||||
// Update existing job
|
||||
existing.NextRunAt = job.NextRunAt
|
||||
existing.IntervalMinutes = job.IntervalMinutes
|
||||
existing.Enabled = job.Enabled
|
||||
existing.AgentHostname = job.AgentHostname
|
||||
heap.Fix(&pq.heap, existing.index)
|
||||
return
|
||||
}
|
||||
|
||||
// Add new job
|
||||
heap.Push(&pq.heap, job)
|
||||
pq.index[key] = job
|
||||
}
|
||||
|
||||
// Pop removes and returns the job with the earliest NextRunAt
|
||||
// Returns nil if queue is empty
|
||||
func (pq *PriorityQueue) Pop() *SubsystemJob {
|
||||
pq.mu.Lock()
|
||||
defer pq.mu.Unlock()
|
||||
|
||||
if pq.heap.Len() == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
job := heap.Pop(&pq.heap).(*SubsystemJob)
|
||||
key := makeKey(job.AgentID, job.Subsystem)
|
||||
delete(pq.index, key)
|
||||
|
||||
return job
|
||||
}
|
||||
|
||||
// Peek returns the job with the earliest NextRunAt without removing it
|
||||
// Returns nil if queue is empty
|
||||
func (pq *PriorityQueue) Peek() *SubsystemJob {
|
||||
pq.mu.RLock()
|
||||
defer pq.mu.RUnlock()
|
||||
|
||||
if pq.heap.Len() == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return pq.heap[0]
|
||||
}
|
||||
|
||||
// Remove removes a specific job from the queue
|
||||
// Returns true if job was found and removed, false otherwise
|
||||
func (pq *PriorityQueue) Remove(agentID uuid.UUID, subsystem string) bool {
|
||||
pq.mu.Lock()
|
||||
defer pq.mu.Unlock()
|
||||
|
||||
key := makeKey(agentID, subsystem)
|
||||
job, exists := pq.index[key]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
heap.Remove(&pq.heap, job.index)
|
||||
delete(pq.index, key)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Get retrieves a specific job without removing it
|
||||
// Returns nil if not found
|
||||
func (pq *PriorityQueue) Get(agentID uuid.UUID, subsystem string) *SubsystemJob {
|
||||
pq.mu.RLock()
|
||||
defer pq.mu.RUnlock()
|
||||
|
||||
key := makeKey(agentID, subsystem)
|
||||
return pq.index[key]
|
||||
}
|
||||
|
||||
// PopBefore returns all jobs with NextRunAt <= before, up to limit
|
||||
// Jobs are removed from the queue
|
||||
// If limit <= 0, all matching jobs are returned
|
||||
func (pq *PriorityQueue) PopBefore(before time.Time, limit int) []*SubsystemJob {
|
||||
pq.mu.Lock()
|
||||
defer pq.mu.Unlock()
|
||||
|
||||
var jobs []*SubsystemJob
|
||||
|
||||
for pq.heap.Len() > 0 {
|
||||
// Peek at next job
|
||||
next := pq.heap[0]
|
||||
|
||||
// Stop if next job is after our cutoff
|
||||
if next.NextRunAt.After(before) {
|
||||
break
|
||||
}
|
||||
|
||||
// Stop if we've hit the limit
|
||||
if limit > 0 && len(jobs) >= limit {
|
||||
break
|
||||
}
|
||||
|
||||
// Pop and collect the job
|
||||
job := heap.Pop(&pq.heap).(*SubsystemJob)
|
||||
key := makeKey(job.AgentID, job.Subsystem)
|
||||
delete(pq.index, key)
|
||||
|
||||
jobs = append(jobs, job)
|
||||
}
|
||||
|
||||
return jobs
|
||||
}
|
||||
|
||||
// PeekBefore returns all jobs with NextRunAt <= before without removing them
|
||||
// If limit <= 0, all matching jobs are returned
|
||||
func (pq *PriorityQueue) PeekBefore(before time.Time, limit int) []*SubsystemJob {
|
||||
pq.mu.RLock()
|
||||
defer pq.mu.RUnlock()
|
||||
|
||||
var jobs []*SubsystemJob
|
||||
|
||||
for i := 0; i < pq.heap.Len(); i++ {
|
||||
job := pq.heap[i]
|
||||
|
||||
if job.NextRunAt.After(before) {
|
||||
// Since heap is sorted by NextRunAt, we can break early
|
||||
// Note: This is only valid because we peek in order
|
||||
break
|
||||
}
|
||||
|
||||
if limit > 0 && len(jobs) >= limit {
|
||||
break
|
||||
}
|
||||
|
||||
jobs = append(jobs, job)
|
||||
}
|
||||
|
||||
return jobs
|
||||
}
|
||||
|
||||
// Len returns the number of jobs in the queue
|
||||
func (pq *PriorityQueue) Len() int {
|
||||
pq.mu.RLock()
|
||||
defer pq.mu.RUnlock()
|
||||
return pq.heap.Len()
|
||||
}
|
||||
|
||||
// Clear removes all jobs from the queue
|
||||
func (pq *PriorityQueue) Clear() {
|
||||
pq.mu.Lock()
|
||||
defer pq.mu.Unlock()
|
||||
|
||||
pq.heap = make(jobHeap, 0)
|
||||
pq.index = make(map[string]*SubsystemJob)
|
||||
heap.Init(&pq.heap)
|
||||
}
|
||||
|
||||
// GetStats returns statistics about the queue
|
||||
func (pq *PriorityQueue) GetStats() QueueStats {
|
||||
pq.mu.RLock()
|
||||
defer pq.mu.RUnlock()
|
||||
|
||||
stats := QueueStats{
|
||||
Size: pq.heap.Len(),
|
||||
}
|
||||
|
||||
if pq.heap.Len() > 0 {
|
||||
stats.NextRunAt = &pq.heap[0].NextRunAt
|
||||
stats.OldestJob = pq.heap[0].String()
|
||||
}
|
||||
|
||||
// Count jobs by subsystem
|
||||
stats.JobsBySubsystem = make(map[string]int)
|
||||
for _, job := range pq.heap {
|
||||
stats.JobsBySubsystem[job.Subsystem]++
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// QueueStats holds statistics about the priority queue
|
||||
type QueueStats struct {
|
||||
Size int
|
||||
NextRunAt *time.Time
|
||||
OldestJob string
|
||||
JobsBySubsystem map[string]int
|
||||
}
|
||||
|
||||
// String returns a human-readable representation of stats
|
||||
func (s QueueStats) String() string {
|
||||
nextRun := "empty"
|
||||
if s.NextRunAt != nil {
|
||||
nextRun = s.NextRunAt.Format("15:04:05")
|
||||
}
|
||||
return fmt.Sprintf("size=%d next=%s oldest=%s", s.Size, nextRun, s.OldestJob)
|
||||
}
|
||||
|
||||
// makeKey creates a unique key for agent_id + subsystem
|
||||
func makeKey(agentID uuid.UUID, subsystem string) string {
|
||||
return agentID.String() + ":" + subsystem
|
||||
}
|
||||
539
server/internal/scheduler/queue_test.go
Normal file
539
server/internal/scheduler/queue_test.go
Normal file
|
|
@ -0,0 +1,539 @@
|
|||
package scheduler
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/uuid/v5"
|
||||
)
|
||||
|
||||
func TestPriorityQueue_BasicOperations(t *testing.T) {
|
||||
pq := NewPriorityQueue()
|
||||
|
||||
// Test empty queue
|
||||
if pq.Len() != 0 {
|
||||
t.Fatalf("expected empty queue, got len=%d", pq.Len())
|
||||
}
|
||||
|
||||
if pq.Peek() != nil {
|
||||
t.Fatal("Peek on empty queue should return nil")
|
||||
}
|
||||
|
||||
if pq.Pop() != nil {
|
||||
t.Fatal("Pop on empty queue should return nil")
|
||||
}
|
||||
|
||||
// Push a job
|
||||
agent1 := uuid.Must(uuid.NewV4())
|
||||
job1 := &SubsystemJob{
|
||||
AgentID: agent1,
|
||||
AgentHostname: "agent-01",
|
||||
Subsystem: "updates",
|
||||
IntervalMinutes: 15,
|
||||
NextRunAt: time.Now().Add(10 * time.Minute),
|
||||
}
|
||||
pq.Push(job1)
|
||||
|
||||
if pq.Len() != 1 {
|
||||
t.Fatalf("expected len=1 after push, got %d", pq.Len())
|
||||
}
|
||||
|
||||
// Peek should return the job without removing it
|
||||
peeked := pq.Peek()
|
||||
if peeked == nil {
|
||||
t.Fatal("Peek should return job")
|
||||
}
|
||||
if peeked.AgentID != agent1 {
|
||||
t.Fatal("Peek returned wrong job")
|
||||
}
|
||||
if pq.Len() != 1 {
|
||||
t.Fatal("Peek should not remove job")
|
||||
}
|
||||
|
||||
// Pop should return and remove the job
|
||||
popped := pq.Pop()
|
||||
if popped == nil {
|
||||
t.Fatal("Pop should return job")
|
||||
}
|
||||
if popped.AgentID != agent1 {
|
||||
t.Fatal("Pop returned wrong job")
|
||||
}
|
||||
if pq.Len() != 0 {
|
||||
t.Fatal("Pop should remove job")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriorityQueue_Ordering(t *testing.T) {
|
||||
pq := NewPriorityQueue()
|
||||
now := time.Now()
|
||||
|
||||
// Push jobs in random order
|
||||
jobs := []*SubsystemJob{
|
||||
{
|
||||
AgentID: uuid.Must(uuid.NewV4()),
|
||||
Subsystem: "updates",
|
||||
NextRunAt: now.Add(30 * time.Minute), // Third
|
||||
},
|
||||
{
|
||||
AgentID: uuid.Must(uuid.NewV4()),
|
||||
Subsystem: "storage",
|
||||
NextRunAt: now.Add(5 * time.Minute), // First
|
||||
},
|
||||
{
|
||||
AgentID: uuid.Must(uuid.NewV4()),
|
||||
Subsystem: "docker",
|
||||
NextRunAt: now.Add(15 * time.Minute), // Second
|
||||
},
|
||||
}
|
||||
|
||||
for _, job := range jobs {
|
||||
pq.Push(job)
|
||||
}
|
||||
|
||||
// Pop should return jobs in NextRunAt order
|
||||
first := pq.Pop()
|
||||
if first.Subsystem != "storage" {
|
||||
t.Fatalf("expected 'storage' first, got '%s'", first.Subsystem)
|
||||
}
|
||||
|
||||
second := pq.Pop()
|
||||
if second.Subsystem != "docker" {
|
||||
t.Fatalf("expected 'docker' second, got '%s'", second.Subsystem)
|
||||
}
|
||||
|
||||
third := pq.Pop()
|
||||
if third.Subsystem != "updates" {
|
||||
t.Fatalf("expected 'updates' third, got '%s'", third.Subsystem)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriorityQueue_UpdateExisting(t *testing.T) {
|
||||
pq := NewPriorityQueue()
|
||||
agentID := uuid.Must(uuid.NewV4())
|
||||
now := time.Now()
|
||||
|
||||
// Push initial job
|
||||
job1 := &SubsystemJob{
|
||||
AgentID: agentID,
|
||||
Subsystem: "updates",
|
||||
IntervalMinutes: 15,
|
||||
NextRunAt: now.Add(15 * time.Minute),
|
||||
}
|
||||
pq.Push(job1)
|
||||
|
||||
if pq.Len() != 1 {
|
||||
t.Fatalf("expected len=1, got %d", pq.Len())
|
||||
}
|
||||
|
||||
// Push same agent+subsystem with different NextRunAt
|
||||
job2 := &SubsystemJob{
|
||||
AgentID: agentID,
|
||||
Subsystem: "updates",
|
||||
IntervalMinutes: 30,
|
||||
NextRunAt: now.Add(30 * time.Minute),
|
||||
}
|
||||
pq.Push(job2)
|
||||
|
||||
// Should still be 1 job (updated, not added)
|
||||
if pq.Len() != 1 {
|
||||
t.Fatalf("expected len=1 after update, got %d", pq.Len())
|
||||
}
|
||||
|
||||
// Verify the job was updated
|
||||
job := pq.Pop()
|
||||
if job.IntervalMinutes != 30 {
|
||||
t.Fatalf("expected interval=30, got %d", job.IntervalMinutes)
|
||||
}
|
||||
if !job.NextRunAt.Equal(now.Add(30 * time.Minute)) {
|
||||
t.Fatal("NextRunAt was not updated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriorityQueue_Remove(t *testing.T) {
|
||||
pq := NewPriorityQueue()
|
||||
|
||||
agent1 := uuid.Must(uuid.NewV4())
|
||||
agent2 := uuid.Must(uuid.NewV4())
|
||||
|
||||
pq.Push(&SubsystemJob{
|
||||
AgentID: agent1,
|
||||
Subsystem: "updates",
|
||||
NextRunAt: time.Now(),
|
||||
})
|
||||
pq.Push(&SubsystemJob{
|
||||
AgentID: agent2,
|
||||
Subsystem: "storage",
|
||||
NextRunAt: time.Now(),
|
||||
})
|
||||
|
||||
if pq.Len() != 2 {
|
||||
t.Fatalf("expected len=2, got %d", pq.Len())
|
||||
}
|
||||
|
||||
// Remove existing job
|
||||
removed := pq.Remove(agent1, "updates")
|
||||
if !removed {
|
||||
t.Fatal("Remove should return true for existing job")
|
||||
}
|
||||
if pq.Len() != 1 {
|
||||
t.Fatalf("expected len=1 after remove, got %d", pq.Len())
|
||||
}
|
||||
|
||||
// Remove non-existent job
|
||||
removed = pq.Remove(agent1, "updates")
|
||||
if removed {
|
||||
t.Fatal("Remove should return false for non-existent job")
|
||||
}
|
||||
if pq.Len() != 1 {
|
||||
t.Fatal("Remove of non-existent job should not affect queue")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriorityQueue_Get(t *testing.T) {
|
||||
pq := NewPriorityQueue()
|
||||
|
||||
agentID := uuid.Must(uuid.NewV4())
|
||||
job := &SubsystemJob{
|
||||
AgentID: agentID,
|
||||
Subsystem: "updates",
|
||||
NextRunAt: time.Now(),
|
||||
}
|
||||
pq.Push(job)
|
||||
|
||||
// Get existing job
|
||||
retrieved := pq.Get(agentID, "updates")
|
||||
if retrieved == nil {
|
||||
t.Fatal("Get should return job")
|
||||
}
|
||||
if retrieved.AgentID != agentID {
|
||||
t.Fatal("Get returned wrong job")
|
||||
}
|
||||
if pq.Len() != 1 {
|
||||
t.Fatal("Get should not remove job")
|
||||
}
|
||||
|
||||
// Get non-existent job
|
||||
retrieved = pq.Get(uuid.Must(uuid.NewV4()), "storage")
|
||||
if retrieved != nil {
|
||||
t.Fatal("Get should return nil for non-existent job")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriorityQueue_PopBefore(t *testing.T) {
|
||||
pq := NewPriorityQueue()
|
||||
now := time.Now()
|
||||
|
||||
// Add jobs with different NextRunAt times
|
||||
for i := 0; i < 5; i++ {
|
||||
pq.Push(&SubsystemJob{
|
||||
AgentID: uuid.Must(uuid.NewV4()),
|
||||
Subsystem: "updates",
|
||||
NextRunAt: now.Add(time.Duration(i*10) * time.Minute),
|
||||
})
|
||||
}
|
||||
|
||||
if pq.Len() != 5 {
|
||||
t.Fatalf("expected len=5, got %d", pq.Len())
|
||||
}
|
||||
|
||||
// Pop jobs before now+25min (should get 3 jobs: 0, 10, 20 minutes)
|
||||
cutoff := now.Add(25 * time.Minute)
|
||||
jobs := pq.PopBefore(cutoff, 0) // no limit
|
||||
|
||||
if len(jobs) != 3 {
|
||||
t.Fatalf("expected 3 jobs, got %d", len(jobs))
|
||||
}
|
||||
|
||||
// Verify all returned jobs are before cutoff
|
||||
for _, job := range jobs {
|
||||
if job.NextRunAt.After(cutoff) {
|
||||
t.Fatalf("job NextRunAt %v is after cutoff %v", job.NextRunAt, cutoff)
|
||||
}
|
||||
}
|
||||
|
||||
// Queue should have 2 jobs left
|
||||
if pq.Len() != 2 {
|
||||
t.Fatalf("expected len=2 after PopBefore, got %d", pq.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriorityQueue_PopBeforeWithLimit(t *testing.T) {
|
||||
pq := NewPriorityQueue()
|
||||
now := time.Now()
|
||||
|
||||
// Add 5 jobs all due now
|
||||
for i := 0; i < 5; i++ {
|
||||
pq.Push(&SubsystemJob{
|
||||
AgentID: uuid.Must(uuid.NewV4()),
|
||||
Subsystem: "updates",
|
||||
NextRunAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
// Pop with limit of 3
|
||||
jobs := pq.PopBefore(now.Add(1*time.Hour), 3)
|
||||
|
||||
if len(jobs) != 3 {
|
||||
t.Fatalf("expected 3 jobs (limit), got %d", len(jobs))
|
||||
}
|
||||
|
||||
if pq.Len() != 2 {
|
||||
t.Fatalf("expected 2 jobs remaining, got %d", pq.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriorityQueue_PeekBefore(t *testing.T) {
|
||||
pq := NewPriorityQueue()
|
||||
now := time.Now()
|
||||
|
||||
pq.Push(&SubsystemJob{
|
||||
AgentID: uuid.Must(uuid.NewV4()),
|
||||
Subsystem: "updates",
|
||||
NextRunAt: now.Add(5 * time.Minute),
|
||||
})
|
||||
pq.Push(&SubsystemJob{
|
||||
AgentID: uuid.Must(uuid.NewV4()),
|
||||
Subsystem: "storage",
|
||||
NextRunAt: now.Add(15 * time.Minute),
|
||||
})
|
||||
|
||||
// Peek before 10 minutes (should see 1 job)
|
||||
jobs := pq.PeekBefore(now.Add(10*time.Minute), 0)
|
||||
|
||||
if len(jobs) != 1 {
|
||||
t.Fatalf("expected 1 job, got %d", len(jobs))
|
||||
}
|
||||
|
||||
// Queue should still have both jobs
|
||||
if pq.Len() != 2 {
|
||||
t.Fatalf("expected len=2 after PeekBefore, got %d", pq.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriorityQueue_Clear(t *testing.T) {
|
||||
pq := NewPriorityQueue()
|
||||
|
||||
// Add some jobs
|
||||
for i := 0; i < 10; i++ {
|
||||
pq.Push(&SubsystemJob{
|
||||
AgentID: uuid.Must(uuid.NewV4()),
|
||||
Subsystem: "updates",
|
||||
NextRunAt: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
if pq.Len() != 10 {
|
||||
t.Fatalf("expected len=10, got %d", pq.Len())
|
||||
}
|
||||
|
||||
// Clear the queue
|
||||
pq.Clear()
|
||||
|
||||
if pq.Len() != 0 {
|
||||
t.Fatalf("expected len=0 after clear, got %d", pq.Len())
|
||||
}
|
||||
|
||||
if pq.Peek() != nil {
|
||||
t.Fatal("Peek should return nil after clear")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriorityQueue_GetStats(t *testing.T) {
|
||||
pq := NewPriorityQueue()
|
||||
now := time.Now()
|
||||
|
||||
// Empty queue stats
|
||||
stats := pq.GetStats()
|
||||
if stats.Size != 0 {
|
||||
t.Fatalf("expected size=0, got %d", stats.Size)
|
||||
}
|
||||
if stats.NextRunAt != nil {
|
||||
t.Fatal("empty queue should have nil NextRunAt")
|
||||
}
|
||||
|
||||
// Add jobs
|
||||
pq.Push(&SubsystemJob{
|
||||
AgentID: uuid.Must(uuid.NewV4()),
|
||||
AgentHostname: "agent-01",
|
||||
Subsystem: "updates",
|
||||
NextRunAt: now.Add(5 * time.Minute),
|
||||
IntervalMinutes: 15,
|
||||
})
|
||||
pq.Push(&SubsystemJob{
|
||||
AgentID: uuid.Must(uuid.NewV4()),
|
||||
Subsystem: "storage",
|
||||
NextRunAt: now.Add(10 * time.Minute),
|
||||
})
|
||||
pq.Push(&SubsystemJob{
|
||||
AgentID: uuid.Must(uuid.NewV4()),
|
||||
Subsystem: "updates",
|
||||
NextRunAt: now.Add(15 * time.Minute),
|
||||
})
|
||||
|
||||
stats = pq.GetStats()
|
||||
|
||||
if stats.Size != 3 {
|
||||
t.Fatalf("expected size=3, got %d", stats.Size)
|
||||
}
|
||||
|
||||
if stats.NextRunAt == nil {
|
||||
t.Fatal("NextRunAt should not be nil")
|
||||
}
|
||||
|
||||
// Should be the earliest job (5 minutes)
|
||||
expectedNext := now.Add(5 * time.Minute)
|
||||
if !stats.NextRunAt.Equal(expectedNext) {
|
||||
t.Fatalf("expected NextRunAt=%v, got %v", expectedNext, stats.NextRunAt)
|
||||
}
|
||||
|
||||
// Check subsystem counts
|
||||
if stats.JobsBySubsystem["updates"] != 2 {
|
||||
t.Fatalf("expected 2 updates jobs, got %d", stats.JobsBySubsystem["updates"])
|
||||
}
|
||||
if stats.JobsBySubsystem["storage"] != 1 {
|
||||
t.Fatalf("expected 1 storage job, got %d", stats.JobsBySubsystem["storage"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriorityQueue_Concurrency(t *testing.T) {
|
||||
pq := NewPriorityQueue()
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Concurrent pushes
|
||||
numGoroutines := 100
|
||||
wg.Add(numGoroutines)
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
pq.Push(&SubsystemJob{
|
||||
AgentID: uuid.Must(uuid.NewV4()),
|
||||
Subsystem: "updates",
|
||||
NextRunAt: time.Now().Add(time.Duration(idx) * time.Second),
|
||||
})
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if pq.Len() != numGoroutines {
|
||||
t.Fatalf("expected len=%d after concurrent pushes, got %d", numGoroutines, pq.Len())
|
||||
}
|
||||
|
||||
// Concurrent pops
|
||||
wg.Add(numGoroutines)
|
||||
popped := make(chan *SubsystemJob, numGoroutines)
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if job := pq.Pop(); job != nil {
|
||||
popped <- job
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(popped)
|
||||
|
||||
// Count popped jobs
|
||||
count := 0
|
||||
for range popped {
|
||||
count++
|
||||
}
|
||||
|
||||
if count != numGoroutines {
|
||||
t.Fatalf("expected %d popped jobs, got %d", numGoroutines, count)
|
||||
}
|
||||
|
||||
if pq.Len() != 0 {
|
||||
t.Fatalf("expected empty queue after concurrent pops, got len=%d", pq.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriorityQueue_ConcurrentReadWrite(t *testing.T) {
|
||||
pq := NewPriorityQueue()
|
||||
done := make(chan bool)
|
||||
|
||||
// Writer goroutine
|
||||
go func() {
|
||||
for i := 0; i < 1000; i++ {
|
||||
pq.Push(&SubsystemJob{
|
||||
AgentID: uuid.Must(uuid.NewV4()),
|
||||
Subsystem: "updates",
|
||||
NextRunAt: time.Now(),
|
||||
})
|
||||
time.Sleep(1 * time.Microsecond)
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
|
||||
// Reader goroutine
|
||||
go func() {
|
||||
for i := 0; i < 1000; i++ {
|
||||
pq.Peek()
|
||||
pq.GetStats()
|
||||
time.Sleep(1 * time.Microsecond)
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
|
||||
// Wait for both to complete
|
||||
<-done
|
||||
<-done
|
||||
|
||||
// Should not panic and queue should be consistent
|
||||
if pq.Len() < 0 {
|
||||
t.Fatal("queue length became negative (race condition)")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkPriorityQueue_Push(b *testing.B) {
|
||||
pq := NewPriorityQueue()
|
||||
b.ResetTimer()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
pq.Push(&SubsystemJob{
|
||||
AgentID: uuid.Must(uuid.NewV4()),
|
||||
Subsystem: "updates",
|
||||
NextRunAt: time.Now().Add(time.Duration(i) * time.Second),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkPriorityQueue_Pop(b *testing.B) {
|
||||
pq := NewPriorityQueue()
|
||||
|
||||
// Pre-fill the queue
|
||||
for i := 0; i < b.N; i++ {
|
||||
pq.Push(&SubsystemJob{
|
||||
AgentID: uuid.Must(uuid.NewV4()),
|
||||
Subsystem: "updates",
|
||||
NextRunAt: time.Now().Add(time.Duration(i) * time.Second),
|
||||
})
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
pq.Pop()
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkPriorityQueue_Peek(b *testing.B) {
|
||||
pq := NewPriorityQueue()
|
||||
|
||||
// Pre-fill with 10000 jobs
|
||||
for i := 0; i < 10000; i++ {
|
||||
pq.Push(&SubsystemJob{
|
||||
AgentID: uuid.Must(uuid.NewV4()),
|
||||
Subsystem: "updates",
|
||||
NextRunAt: time.Now().Add(time.Duration(i) * time.Second),
|
||||
})
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
pq.Peek()
|
||||
}
|
||||
}
|
||||
649
server/internal/scheduler/scheduler.go
Normal file
649
server/internal/scheduler/scheduler.go
Normal file
|
|
@ -0,0 +1,649 @@
|
|||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/server/internal/database/queries"
|
||||
"github.com/Fimeg/RedFlag/server/internal/models"
|
||||
"github.com/Fimeg/RedFlag/server/internal/services"
|
||||
"github.com/gofrs/uuid/v5"
|
||||
)
|
||||
|
||||
// Config holds scheduler configuration
|
||||
type Config struct {
|
||||
// CheckInterval is how often to check the queue for due jobs
|
||||
CheckInterval time.Duration
|
||||
|
||||
// LookaheadWindow is how far ahead to look for jobs
|
||||
// Jobs due within this window will be batched and jittered
|
||||
LookaheadWindow time.Duration
|
||||
|
||||
// MaxJitter is the maximum random delay added to job execution
|
||||
MaxJitter time.Duration
|
||||
|
||||
// NumWorkers is the number of parallel workers for command creation
|
||||
NumWorkers int
|
||||
|
||||
// BackpressureThreshold is max pending commands per agent before skipping
|
||||
BackpressureThreshold int
|
||||
|
||||
// RateLimitPerSecond is max commands created per second (0 = unlimited)
|
||||
RateLimitPerSecond int
|
||||
|
||||
// MaxDispatchPerTick caps how many due jobs are popped and dispatched in a
|
||||
// single tick (SCALE-001 S4). Without a cap, a fleet whose subsystem jobs
|
||||
// phase-align can dump thousands of jobs into one tick, a thundering herd
|
||||
// against the DB pool. The remainder stays queued for the next tick. 0 =
|
||||
// unlimited (legacy behavior).
|
||||
MaxDispatchPerTick int
|
||||
}
|
||||
|
||||
// DefaultConfig returns default configuration values
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
CheckInterval: 10 * time.Second,
|
||||
LookaheadWindow: 60 * time.Second,
|
||||
MaxJitter: 30 * time.Second,
|
||||
NumWorkers: 10,
|
||||
BackpressureThreshold: 5,
|
||||
RateLimitPerSecond: 100,
|
||||
MaxDispatchPerTick: 200,
|
||||
}
|
||||
}
|
||||
|
||||
// Scheduler manages subsystem job scheduling with priority queue and worker pool
|
||||
type Scheduler struct {
|
||||
config Config
|
||||
queue *PriorityQueue
|
||||
|
||||
// Database queries
|
||||
agentQueries *queries.AgentQueries
|
||||
commandQueries *queries.CommandQueries
|
||||
subsystemQueries *queries.SubsystemQueries
|
||||
|
||||
// Signing
|
||||
signingService *services.SigningService
|
||||
|
||||
// Worker pool
|
||||
jobChan chan *SubsystemJob
|
||||
workers []*worker
|
||||
|
||||
// Rate limiting
|
||||
rateLimiter chan struct{}
|
||||
|
||||
// Lifecycle management
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
shutdown chan struct{}
|
||||
|
||||
// Metrics
|
||||
mu sync.RWMutex
|
||||
stats Stats
|
||||
}
|
||||
|
||||
// Stats holds scheduler statistics
|
||||
type Stats struct {
|
||||
JobsProcessed int64
|
||||
JobsSkipped int64
|
||||
CommandsCreated int64
|
||||
CommandsFailed int64
|
||||
BackpressureSkips int64
|
||||
LastProcessedAt time.Time
|
||||
QueueSize int
|
||||
WorkerPoolUtilized int
|
||||
AverageProcessingMS int64
|
||||
}
|
||||
|
||||
// NewScheduler creates a new scheduler instance
|
||||
func NewScheduler(config Config, agentQueries *queries.AgentQueries, commandQueries *queries.CommandQueries, subsystemQueries *queries.SubsystemQueries, signingService *services.SigningService) *Scheduler {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
s := &Scheduler{
|
||||
config: config,
|
||||
queue: NewPriorityQueue(),
|
||||
agentQueries: agentQueries,
|
||||
commandQueries: commandQueries,
|
||||
subsystemQueries: subsystemQueries,
|
||||
signingService: signingService,
|
||||
jobChan: make(chan *SubsystemJob, 1000), // Buffer 1000 jobs
|
||||
workers: make([]*worker, config.NumWorkers),
|
||||
shutdown: make(chan struct{}),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
// Initialize rate limiter if configured
|
||||
if config.RateLimitPerSecond > 0 {
|
||||
s.rateLimiter = make(chan struct{}, config.RateLimitPerSecond)
|
||||
go s.refillRateLimiter()
|
||||
}
|
||||
|
||||
// Initialize workers
|
||||
for i := 0; i < config.NumWorkers; i++ {
|
||||
s.workers[i] = &worker{
|
||||
id: i,
|
||||
scheduler: s,
|
||||
}
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// LoadSubsystems loads all enabled auto-run subsystems from database into queue
|
||||
func (s *Scheduler) LoadSubsystems(ctx context.Context) error {
|
||||
log.Println("[Scheduler] Loading subsystems from database...")
|
||||
|
||||
// Get all agents (pass empty strings to get all agents regardless of status/os)
|
||||
agents, err := s.agentQueries.ListAgents("", "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get agents: %w", err)
|
||||
}
|
||||
|
||||
// Build the online-agent ID slice first so we only fetch rows we need.
|
||||
// Agents that haven't checked in for 10+ minutes are considered offline
|
||||
// and contribute no jobs, so there is no point fetching their subsystems.
|
||||
onlineAgentIDs := make([]uuid.UUID, 0, len(agents))
|
||||
onlineAgentByID := make(map[uuid.UUID]struct{ Hostname string }, len(agents))
|
||||
for _, agent := range agents {
|
||||
if time.Since(agent.LastSeen) > 10*time.Minute {
|
||||
continue
|
||||
}
|
||||
onlineAgentIDs = append(onlineAgentIDs, agent.ID)
|
||||
onlineAgentByID[agent.ID] = struct{ Hostname string }{agent.Hostname}
|
||||
}
|
||||
|
||||
if len(onlineAgentIDs) == 0 {
|
||||
log.Printf("[Scheduler] Loaded 0 subsystem jobs for 0 agents (respecting database settings)\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Single query for all subsystem rows across all online agents (SCALE-001 S10).
|
||||
allSubsystems, err := s.subsystemQueries.GetSubsystemsForAgents(onlineAgentIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get subsystems for agents: %w", err)
|
||||
}
|
||||
|
||||
// Group subsystem rows by agent ID for O(1) lookup below.
|
||||
byAgent := groupSubsystemsByAgent(allSubsystems)
|
||||
|
||||
loaded := 0
|
||||
for _, agentID := range onlineAgentIDs {
|
||||
agentInfo := onlineAgentByID[agentID]
|
||||
for _, dbSub := range byAgent[agentID] {
|
||||
// ARC-004: legacy "updates" virtual subsystem is deprecated.
|
||||
// It pre-dates per-scanner subsystems (apt/dnf/winget/windows)
|
||||
// and fans out to every package scanner regardless of what's
|
||||
// actually present on the host. With ARC-001 capability
|
||||
// advertisement and ARC-002 scheduler filter, the per-scanner
|
||||
// rows now do this correctly. Skip "updates" rows so we stop
|
||||
// generating fan-out commands without requiring a DB migration
|
||||
// to delete them. Migration to remove these rows is a follow-up.
|
||||
if dbSub.Subsystem == "updates" {
|
||||
continue
|
||||
}
|
||||
if dbSub.Enabled && dbSub.AutoRun {
|
||||
// Use database interval, fallback to default
|
||||
intervalMinutes := dbSub.IntervalMinutes
|
||||
if intervalMinutes <= 0 {
|
||||
intervalMinutes = s.getDefaultInterval(dbSub.Subsystem)
|
||||
}
|
||||
|
||||
var nextRun time.Time
|
||||
if dbSub.NextRunAt != nil {
|
||||
nextRun = *dbSub.NextRunAt
|
||||
} else {
|
||||
// If no next run is set, schedule it with default interval
|
||||
nextRun = time.Now().UTC().Add(time.Duration(intervalMinutes) * time.Minute)
|
||||
}
|
||||
|
||||
job := &SubsystemJob{
|
||||
AgentID: agentID,
|
||||
AgentHostname: agentInfo.Hostname,
|
||||
Subsystem: dbSub.Subsystem,
|
||||
IntervalMinutes: intervalMinutes,
|
||||
NextRunAt: nextRun,
|
||||
Enabled: dbSub.Enabled,
|
||||
}
|
||||
|
||||
s.queue.Push(job)
|
||||
loaded++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[Scheduler] Loaded %d subsystem jobs for %d agents (respecting database settings)\n", loaded, len(onlineAgentIDs))
|
||||
return nil
|
||||
}
|
||||
|
||||
// groupSubsystemsByAgent groups a flat slice of AgentSubsystem rows into a map
|
||||
// keyed by agent ID. This is a pure helper so it can be tested without a DB.
|
||||
func groupSubsystemsByAgent(rows []models.AgentSubsystem) map[uuid.UUID][]models.AgentSubsystem {
|
||||
out := make(map[uuid.UUID][]models.AgentSubsystem, len(rows))
|
||||
for _, row := range rows {
|
||||
out[row.AgentID] = append(out[row.AgentID], row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// getDefaultInterval returns default interval minutes for a subsystem
|
||||
// TODO: These intervals need to correlate with agent health scanning settings
|
||||
// Each subsystem should be variable based on user-configurable agent health policies
|
||||
func (s *Scheduler) getDefaultInterval(subsystem string) int {
|
||||
// ARC-004: "updates" intentionally absent — it's the deprecated virtual
|
||||
// subsystem that fans out to all package scanners. Per-scanner rows
|
||||
// (apt/dnf/winget/windows) replaced it via ARC-001 capability advertisement.
|
||||
defaults := map[string]int{
|
||||
"apt": 30, // 30 minutes
|
||||
"dnf": 240, // 4 hours
|
||||
"docker": 120, // 2 hours
|
||||
"storage": 360, // 6 hours
|
||||
"windows": 480, // 8 hours
|
||||
"winget": 360, // 6 hours
|
||||
"system": 30, // 30 minutes
|
||||
}
|
||||
|
||||
if interval, exists := defaults[subsystem]; exists {
|
||||
return interval
|
||||
}
|
||||
return 30 // Default fallback
|
||||
}
|
||||
|
||||
// Start begins the scheduler main loop and workers
|
||||
func (s *Scheduler) Start() error {
|
||||
log.Printf("[Scheduler] Starting with %d workers, check interval %v\n",
|
||||
s.config.NumWorkers, s.config.CheckInterval)
|
||||
|
||||
// Start workers
|
||||
for _, w := range s.workers {
|
||||
s.wg.Add(1)
|
||||
go w.run()
|
||||
}
|
||||
|
||||
// Start main loop
|
||||
s.wg.Add(1)
|
||||
go s.mainLoop()
|
||||
|
||||
log.Println("[Scheduler] Started successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully shuts down the scheduler
|
||||
func (s *Scheduler) Stop() error {
|
||||
log.Println("[Scheduler] Shutting down...")
|
||||
|
||||
// Signal shutdown
|
||||
s.cancel()
|
||||
close(s.shutdown)
|
||||
|
||||
// Close job channel (workers will drain and exit)
|
||||
close(s.jobChan)
|
||||
|
||||
// Wait for all goroutines with timeout
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
s.wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
log.Println("[Scheduler] Shutdown complete")
|
||||
return nil
|
||||
case <-time.After(30 * time.Second):
|
||||
log.Println("[Scheduler] Shutdown timeout - forcing exit")
|
||||
return fmt.Errorf("shutdown timeout")
|
||||
}
|
||||
}
|
||||
|
||||
// mainLoop is the scheduler's main processing loop
|
||||
func (s *Scheduler) mainLoop() {
|
||||
defer s.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(s.config.CheckInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
log.Printf("[Scheduler] Main loop started (check every %v)\n", s.config.CheckInterval)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.shutdown:
|
||||
log.Println("[Scheduler] Main loop shutting down")
|
||||
return
|
||||
|
||||
case <-ticker.C:
|
||||
s.processQueue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// processQueue checks for due jobs and dispatches them to workers
|
||||
func (s *Scheduler) processQueue() {
|
||||
start := time.Now()
|
||||
|
||||
// Get jobs due within the lookahead window, capped per tick (SCALE-001 S4)
|
||||
// so an aligned fleet can't dump the whole queue into one tick. Overflow
|
||||
// stays queued and is picked up next tick.
|
||||
cutoff := time.Now().UTC().Add(s.config.LookaheadWindow)
|
||||
dueJobs := s.queue.PopBefore(cutoff, s.config.MaxDispatchPerTick)
|
||||
if s.config.MaxDispatchPerTick > 0 && len(dueJobs) == s.config.MaxDispatchPerTick && s.queue.Len() > 0 {
|
||||
log.Printf("[Scheduler] Dispatch cap hit: %d jobs this tick, %d still queued",
|
||||
len(dueJobs), s.queue.Len())
|
||||
}
|
||||
|
||||
if len(dueJobs) == 0 {
|
||||
// No jobs due, just update stats
|
||||
s.mu.Lock()
|
||||
s.stats.QueueSize = s.queue.Len()
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[Scheduler] Processing %d jobs due before %s\n",
|
||||
len(dueJobs), cutoff.Format("15:04:05"))
|
||||
|
||||
// Add jitter to each job and dispatch to workers
|
||||
dispatched := 0
|
||||
for _, job := range dueJobs {
|
||||
// Add random jitter (0 to MaxJitter)
|
||||
jitter := time.Duration(rand.Intn(int(s.config.MaxJitter.Seconds()))) * time.Second
|
||||
job.NextRunAt = job.NextRunAt.Add(jitter)
|
||||
|
||||
// Dispatch to worker pool (non-blocking)
|
||||
select {
|
||||
case s.jobChan <- job:
|
||||
dispatched++
|
||||
default:
|
||||
// Worker pool full, re-queue job
|
||||
log.Printf("[Scheduler] Worker pool full, re-queueing %s\n", job.String())
|
||||
s.queue.Push(job)
|
||||
|
||||
s.mu.Lock()
|
||||
s.stats.JobsSkipped++
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Update stats
|
||||
duration := time.Since(start)
|
||||
s.mu.Lock()
|
||||
s.stats.JobsProcessed += int64(dispatched)
|
||||
s.stats.LastProcessedAt = time.Now().UTC()
|
||||
s.stats.QueueSize = s.queue.Len()
|
||||
s.stats.WorkerPoolUtilized = len(s.jobChan)
|
||||
s.stats.AverageProcessingMS = duration.Milliseconds()
|
||||
s.mu.Unlock()
|
||||
|
||||
log.Printf("[Scheduler] Dispatched %d jobs in %v (queue: %d remaining)\n",
|
||||
dispatched, duration, s.queue.Len())
|
||||
}
|
||||
|
||||
// refillRateLimiter continuously refills the rate limiter token bucket
|
||||
func (s *Scheduler) refillRateLimiter() {
|
||||
ticker := time.NewTicker(time.Second / time.Duration(s.config.RateLimitPerSecond))
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.shutdown:
|
||||
return
|
||||
case <-ticker.C:
|
||||
// Try to add token (non-blocking)
|
||||
select {
|
||||
case s.rateLimiter <- struct{}{}:
|
||||
default:
|
||||
// Bucket full, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveSubsystemJob removes a scheduled job for a given agent + subsystem from
|
||||
// the priority queue. Used by DisableSubsystem so disabling a subsystem stops
|
||||
// further commands immediately, without waiting for a scheduler reload.
|
||||
func (s *Scheduler) RemoveSubsystemJob(agentID uuid.UUID, subsystem string) bool {
|
||||
return s.queue.Remove(agentID, subsystem)
|
||||
}
|
||||
|
||||
// GetStats returns current scheduler statistics (thread-safe)
|
||||
func (s *Scheduler) GetStats() Stats {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.stats
|
||||
}
|
||||
|
||||
// GetQueueStats returns current queue statistics
|
||||
func (s *Scheduler) GetQueueStats() QueueStats {
|
||||
return s.queue.GetStats()
|
||||
}
|
||||
|
||||
// worker processes jobs from the job channel
|
||||
type worker struct {
|
||||
id int
|
||||
scheduler *Scheduler
|
||||
}
|
||||
|
||||
func (w *worker) run() {
|
||||
defer w.scheduler.wg.Done()
|
||||
|
||||
log.Printf("[Worker %d] Started\n", w.id)
|
||||
|
||||
for job := range w.scheduler.jobChan {
|
||||
if err := w.processJob(job); err != nil {
|
||||
log.Printf("[Worker %d] Failed to process %s: %v\n", w.id, job.String(), err)
|
||||
|
||||
w.scheduler.mu.Lock()
|
||||
w.scheduler.stats.CommandsFailed++
|
||||
w.scheduler.mu.Unlock()
|
||||
} else {
|
||||
w.scheduler.mu.Lock()
|
||||
w.scheduler.stats.CommandsCreated++
|
||||
w.scheduler.mu.Unlock()
|
||||
}
|
||||
|
||||
// Re-queue job for next execution
|
||||
job.NextRunAt = time.Now().UTC().Add(time.Duration(job.IntervalMinutes) * time.Minute)
|
||||
w.scheduler.queue.Push(job)
|
||||
|
||||
// Persist last_run_at / next_run_at so the UI shows a current "Next Run" time
|
||||
if dbErr := w.scheduler.subsystemQueries.UpdateLastRun(job.AgentID, job.Subsystem); dbErr != nil {
|
||||
log.Printf("[WARN] [Worker %d] [scheduler] update_last_run_failed agent=%s subsystem=%s err=%v",
|
||||
w.id, job.AgentHostname, job.Subsystem, dbErr)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[Worker %d] Stopped\n", w.id)
|
||||
}
|
||||
|
||||
func (w *worker) processJob(job *SubsystemJob) error {
|
||||
// Apply rate limiting if configured
|
||||
if w.scheduler.rateLimiter != nil {
|
||||
select {
|
||||
case <-w.scheduler.rateLimiter:
|
||||
// Token acquired
|
||||
case <-w.scheduler.shutdown:
|
||||
return fmt.Errorf("shutdown during rate limit wait")
|
||||
}
|
||||
}
|
||||
|
||||
// Check backpressure: skip if agent has too many pending commands
|
||||
pendingCount, err := w.scheduler.commandQueries.CountPendingCommandsForAgent(job.AgentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check pending commands: %w", err)
|
||||
}
|
||||
|
||||
if pendingCount >= w.scheduler.config.BackpressureThreshold {
|
||||
log.Printf("[Worker %d] Backpressure: agent %s has %d pending commands, skipping %s\n",
|
||||
w.id, job.AgentHostname, pendingCount, job.Subsystem)
|
||||
|
||||
w.scheduler.mu.Lock()
|
||||
w.scheduler.stats.BackpressureSkips++
|
||||
w.scheduler.mu.Unlock()
|
||||
|
||||
return nil // Not an error, just skipped
|
||||
}
|
||||
|
||||
// BUG-015 FIX: Get platform-specific scan command type
|
||||
commandType, err := w.getScanCommandType(job)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to determine scan command type: %w", err)
|
||||
}
|
||||
|
||||
// ARC-002: skip platform-specific scan commands when the agent has
|
||||
// advertised its available scanners (via ARC-001) and this scanner
|
||||
// isn't in that set. Safe-by-default: agents that haven't reported
|
||||
// scanners yet (legacy / pre-upgrade) fall through and behave as before.
|
||||
if scanner := scannerForCommandType(commandType); scanner != "" {
|
||||
if w.shouldSkipForUnavailableScanner(job.AgentID, scanner) {
|
||||
log.Printf("[INFO] [Worker %d] [scheduler] skip_unavailable_scanner agent=%s scanner=%s type=%s",
|
||||
w.id, job.AgentHostname, scanner, commandType)
|
||||
w.scheduler.mu.Lock()
|
||||
w.scheduler.stats.JobsSkipped++
|
||||
w.scheduler.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// FIX: Check for duplicate pending command before creating
|
||||
existingCmd, err := w.scheduler.commandQueries.GetPendingCommandByType(job.AgentID, commandType)
|
||||
if err == nil && existingCmd != nil {
|
||||
// Duplicate pending command exists, skip creating new one
|
||||
log.Printf("[INFO] [Worker %d] [scheduler] duplicate_command_skip agent=%s type=%s existing_id=%s",
|
||||
w.id, job.AgentHostname, commandType, existingCmd.ID)
|
||||
return nil
|
||||
}
|
||||
cmd := &models.AgentCommand{
|
||||
ID: uuid.Must(uuid.NewV4()),
|
||||
AgentID: job.AgentID,
|
||||
CommandType: commandType,
|
||||
Params: models.JSONB{},
|
||||
Status: models.CommandStatusPending,
|
||||
Source: models.CommandSourceSystem,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
if w.scheduler.signingService == nil || !w.scheduler.signingService.IsEnabled() {
|
||||
return fmt.Errorf("signing service not available - command rejected")
|
||||
}
|
||||
|
||||
signature, err := w.scheduler.signingService.SignCommand(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sign command: %w", err)
|
||||
}
|
||||
cmd.Signature = signature
|
||||
|
||||
if err := w.scheduler.commandQueries.CreateCommand(cmd); err != nil {
|
||||
return fmt.Errorf("failed to create command: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [Worker %d] [scheduler] command_created agent=%s type=%s id=%s",
|
||||
w.id, job.AgentHostname, commandType, cmd.ID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getScanCommandType returns platform-specific scan command type based on agent OS
|
||||
func (w *worker) getScanCommandType(job *SubsystemJob) (string, error) {
|
||||
// Get agent details to determine platform
|
||||
agent, err := w.scheduler.agentQueries.GetAgentByID(job.AgentID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get agent %s: %w", job.AgentID, err)
|
||||
}
|
||||
|
||||
// Map subsystem to platform-specific command type
|
||||
switch job.Subsystem {
|
||||
case "updates":
|
||||
return w.getUpdateScanCommandType(agent.OSType)
|
||||
case "apt":
|
||||
return "scan_apt", nil
|
||||
case "dnf":
|
||||
return "scan_dnf", nil
|
||||
case "docker":
|
||||
return "scan_docker", nil
|
||||
case "storage":
|
||||
return "scan_storage", nil
|
||||
case "system":
|
||||
return "scan_system", nil
|
||||
case "windows":
|
||||
return "scan_windows", nil
|
||||
case "winget":
|
||||
return "scan_winget", nil
|
||||
default:
|
||||
// Default to generic scan command
|
||||
return fmt.Sprintf("scan_%s", job.Subsystem), nil
|
||||
}
|
||||
}
|
||||
|
||||
// scannerForCommandType maps a platform-specific scan_* command type to the
|
||||
// scanner identifier the agent reports via ARC-001 capability advertisement.
|
||||
// Returns "" for command types that aren't tied to a specific scanner
|
||||
// (storage, system, the legacy "updates" virtual subsystem, unknown distros).
|
||||
func scannerForCommandType(commandType string) string {
|
||||
switch commandType {
|
||||
case "scan_apt":
|
||||
return "apt"
|
||||
case "scan_dnf":
|
||||
return "dnf"
|
||||
case "scan_winget":
|
||||
return "winget"
|
||||
case "scan_windows":
|
||||
return "windows"
|
||||
case "scan_docker":
|
||||
return "docker"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// shouldSkipForUnavailableScanner returns true when the agent has reported a
|
||||
// concrete list of available scanners (ARC-001 metadata) and the requested
|
||||
// scanner isn't in that list. Returns false when the metadata is missing or
|
||||
// malformed — that means we don't yet have authoritative info, so we fall
|
||||
// through to the legacy behavior rather than silently dropping commands.
|
||||
func (w *worker) shouldSkipForUnavailableScanner(agentID uuid.UUID, scanner string) bool {
|
||||
agent, err := w.scheduler.agentQueries.GetAgentByID(agentID)
|
||||
if err != nil || agent == nil || agent.Metadata == nil {
|
||||
return false
|
||||
}
|
||||
raw, ok := agent.Metadata["available_scanners"]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
list, ok := raw.([]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, item := range list {
|
||||
if s, ok := item.(string); ok && s == scanner {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// getUpdateScanCommandType returns the appropriate update scan command for the OS
|
||||
func (w *worker) getUpdateScanCommandType(osType string) (string, error) {
|
||||
switch osType {
|
||||
case "debian", "ubuntu", "linuxmint", "pop", "elementary":
|
||||
return "scan_apt", nil
|
||||
case "fedora", "rhel", "centos", "rocky", "almalinux", "oracle":
|
||||
return "scan_dnf", nil
|
||||
case "arch", "manjaro", "endeavouros":
|
||||
return "scan_pacman", nil
|
||||
case "opensuse", "suse":
|
||||
return "scan_zypper", nil
|
||||
case "windows":
|
||||
return "scan_windows", nil
|
||||
default:
|
||||
// Fallback to generic scan_updates for unknown Linux distros
|
||||
if osType == "linux" {
|
||||
return "scan_updates", nil
|
||||
}
|
||||
return "scan_updates", nil
|
||||
}
|
||||
}
|
||||
375
server/internal/scheduler/scheduler_test.go
Normal file
375
server/internal/scheduler/scheduler_test.go
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
package scheduler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/server/internal/models"
|
||||
"github.com/gofrs/uuid/v5"
|
||||
)
|
||||
|
||||
func TestScheduler_NewScheduler(t *testing.T) {
|
||||
config := DefaultConfig()
|
||||
s := NewScheduler(config, nil, nil, nil, nil)
|
||||
|
||||
if s == nil {
|
||||
t.Fatal("NewScheduler returned nil")
|
||||
}
|
||||
|
||||
if s.config.NumWorkers != 10 {
|
||||
t.Fatalf("expected 10 workers, got %d", s.config.NumWorkers)
|
||||
}
|
||||
|
||||
if s.queue == nil {
|
||||
t.Fatal("queue not initialized")
|
||||
}
|
||||
|
||||
if len(s.workers) != config.NumWorkers {
|
||||
t.Fatalf("expected %d workers, got %d", config.NumWorkers, len(s.workers))
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduler_DefaultConfig(t *testing.T) {
|
||||
config := DefaultConfig()
|
||||
|
||||
if config.CheckInterval != 10*time.Second {
|
||||
t.Fatalf("expected check interval 10s, got %v", config.CheckInterval)
|
||||
}
|
||||
|
||||
if config.LookaheadWindow != 60*time.Second {
|
||||
t.Fatalf("expected lookahead 60s, got %v", config.LookaheadWindow)
|
||||
}
|
||||
|
||||
if config.MaxJitter != 30*time.Second {
|
||||
t.Fatalf("expected max jitter 30s, got %v", config.MaxJitter)
|
||||
}
|
||||
|
||||
if config.NumWorkers != 10 {
|
||||
t.Fatalf("expected 10 workers, got %d", config.NumWorkers)
|
||||
}
|
||||
|
||||
if config.BackpressureThreshold != 5 {
|
||||
t.Fatalf("expected backpressure threshold 5, got %d", config.BackpressureThreshold)
|
||||
}
|
||||
|
||||
if config.RateLimitPerSecond != 100 {
|
||||
t.Fatalf("expected rate limit 100/s, got %d", config.RateLimitPerSecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduler_QueueIntegration(t *testing.T) {
|
||||
config := DefaultConfig()
|
||||
s := NewScheduler(config, nil, nil, nil, nil)
|
||||
|
||||
// Add jobs to queue
|
||||
agent1 := uuid.Must(uuid.NewV4())
|
||||
agent2 := uuid.Must(uuid.NewV4())
|
||||
|
||||
job1 := &SubsystemJob{
|
||||
AgentID: agent1,
|
||||
AgentHostname: "agent-01",
|
||||
Subsystem: "updates",
|
||||
IntervalMinutes: 15,
|
||||
NextRunAt: time.Now().Add(5 * time.Minute),
|
||||
}
|
||||
|
||||
job2 := &SubsystemJob{
|
||||
AgentID: agent2,
|
||||
AgentHostname: "agent-02",
|
||||
Subsystem: "storage",
|
||||
IntervalMinutes: 15,
|
||||
NextRunAt: time.Now().Add(10 * time.Minute),
|
||||
}
|
||||
|
||||
s.queue.Push(job1)
|
||||
s.queue.Push(job2)
|
||||
|
||||
if s.queue.Len() != 2 {
|
||||
t.Fatalf("expected queue len 2, got %d", s.queue.Len())
|
||||
}
|
||||
|
||||
// Get stats
|
||||
stats := s.GetQueueStats()
|
||||
if stats.Size != 2 {
|
||||
t.Fatalf("expected stats size 2, got %d", stats.Size)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduler_GetStats(t *testing.T) {
|
||||
config := DefaultConfig()
|
||||
s := NewScheduler(config, nil, nil, nil, nil)
|
||||
|
||||
// Initial stats should be zero
|
||||
stats := s.GetStats()
|
||||
|
||||
if stats.JobsProcessed != 0 {
|
||||
t.Fatalf("expected 0 jobs processed, got %d", stats.JobsProcessed)
|
||||
}
|
||||
|
||||
if stats.CommandsCreated != 0 {
|
||||
t.Fatalf("expected 0 commands created, got %d", stats.CommandsCreated)
|
||||
}
|
||||
|
||||
if stats.BackpressureSkips != 0 {
|
||||
t.Fatalf("expected 0 backpressure skips, got %d", stats.BackpressureSkips)
|
||||
}
|
||||
|
||||
// Manually update stats (simulating processing)
|
||||
s.mu.Lock()
|
||||
s.stats.JobsProcessed = 100
|
||||
s.stats.CommandsCreated = 95
|
||||
s.stats.BackpressureSkips = 5
|
||||
s.mu.Unlock()
|
||||
|
||||
stats = s.GetStats()
|
||||
|
||||
if stats.JobsProcessed != 100 {
|
||||
t.Fatalf("expected 100 jobs processed, got %d", stats.JobsProcessed)
|
||||
}
|
||||
|
||||
if stats.CommandsCreated != 95 {
|
||||
t.Fatalf("expected 95 commands created, got %d", stats.CommandsCreated)
|
||||
}
|
||||
|
||||
if stats.BackpressureSkips != 5 {
|
||||
t.Fatalf("expected 5 backpressure skips, got %d", stats.BackpressureSkips)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduler_StartStop(t *testing.T) {
|
||||
config := Config{
|
||||
CheckInterval: 100 * time.Millisecond, // Fast for testing
|
||||
LookaheadWindow: 60 * time.Second,
|
||||
MaxJitter: 1 * time.Second,
|
||||
NumWorkers: 2,
|
||||
BackpressureThreshold: 5,
|
||||
RateLimitPerSecond: 0, // Disable rate limiting for test
|
||||
}
|
||||
|
||||
s := NewScheduler(config, nil, nil, nil, nil)
|
||||
|
||||
// Start scheduler
|
||||
err := s.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to start scheduler: %v", err)
|
||||
}
|
||||
|
||||
// Let it run for a bit
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Stop scheduler
|
||||
err = s.Stop()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to stop scheduler: %v", err)
|
||||
}
|
||||
|
||||
// Should stop cleanly
|
||||
}
|
||||
|
||||
func TestScheduler_ProcessQueueEmpty(t *testing.T) {
|
||||
config := DefaultConfig()
|
||||
s := NewScheduler(config, nil, nil, nil, nil)
|
||||
|
||||
// Process empty queue should not panic
|
||||
s.processQueue()
|
||||
|
||||
stats := s.GetStats()
|
||||
if stats.JobsProcessed != 0 {
|
||||
t.Fatalf("expected 0 jobs processed on empty queue, got %d", stats.JobsProcessed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduler_ProcessQueueWithJobs(t *testing.T) {
|
||||
config := Config{
|
||||
CheckInterval: 1 * time.Second,
|
||||
LookaheadWindow: 60 * time.Second,
|
||||
MaxJitter: 5 * time.Second,
|
||||
NumWorkers: 2,
|
||||
BackpressureThreshold: 5,
|
||||
RateLimitPerSecond: 0, // Disable for test
|
||||
}
|
||||
|
||||
s := NewScheduler(config, nil, nil, nil, nil)
|
||||
|
||||
// Add jobs that are due now
|
||||
for i := 0; i < 5; i++ {
|
||||
job := &SubsystemJob{
|
||||
AgentID: uuid.Must(uuid.NewV4()),
|
||||
AgentHostname: "test-agent",
|
||||
Subsystem: "updates",
|
||||
IntervalMinutes: 15,
|
||||
NextRunAt: time.Now(), // Due now
|
||||
}
|
||||
s.queue.Push(job)
|
||||
}
|
||||
|
||||
if s.queue.Len() != 5 {
|
||||
t.Fatalf("expected 5 jobs in queue, got %d", s.queue.Len())
|
||||
}
|
||||
|
||||
// Process the queue
|
||||
s.processQueue()
|
||||
|
||||
// Jobs should be dispatched to job channel
|
||||
// Note: Without database, workers can't actually process them
|
||||
// But we can verify they were dispatched
|
||||
|
||||
stats := s.GetStats()
|
||||
if stats.JobsProcessed == 0 {
|
||||
t.Fatal("expected some jobs to be processed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduler_RateLimiterRefill(t *testing.T) {
|
||||
config := Config{
|
||||
CheckInterval: 1 * time.Second,
|
||||
LookaheadWindow: 60 * time.Second,
|
||||
MaxJitter: 1 * time.Second,
|
||||
NumWorkers: 2,
|
||||
BackpressureThreshold: 5,
|
||||
RateLimitPerSecond: 10, // 10 tokens per second
|
||||
}
|
||||
|
||||
s := NewScheduler(config, nil, nil, nil, nil)
|
||||
|
||||
if s.rateLimiter == nil {
|
||||
t.Fatal("rate limiter not initialized")
|
||||
}
|
||||
|
||||
// Start refill goroutine
|
||||
go s.refillRateLimiter()
|
||||
|
||||
// Wait for some tokens to be added
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Should have some tokens available
|
||||
tokensAvailable := 0
|
||||
for i := 0; i < 15; i++ {
|
||||
select {
|
||||
case <-s.rateLimiter:
|
||||
tokensAvailable++
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if tokensAvailable == 0 {
|
||||
t.Fatal("expected some tokens to be available after refill")
|
||||
}
|
||||
|
||||
// Should not exceed buffer size (10)
|
||||
if tokensAvailable > 10 {
|
||||
t.Fatalf("token bucket overflowed: got %d tokens, max is 10", tokensAvailable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduler_ConcurrentQueueAccess(t *testing.T) {
|
||||
config := DefaultConfig()
|
||||
s := NewScheduler(config, nil, nil, nil, nil)
|
||||
|
||||
done := make(chan bool)
|
||||
|
||||
// Concurrent pushes
|
||||
go func() {
|
||||
for i := 0; i < 100; i++ {
|
||||
job := &SubsystemJob{
|
||||
AgentID: uuid.Must(uuid.NewV4()),
|
||||
Subsystem: "updates",
|
||||
IntervalMinutes: 15,
|
||||
NextRunAt: time.Now(),
|
||||
}
|
||||
s.queue.Push(job)
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
|
||||
// Concurrent stats reads
|
||||
go func() {
|
||||
for i := 0; i < 100; i++ {
|
||||
s.GetStats()
|
||||
s.GetQueueStats()
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
|
||||
// Wait for both
|
||||
<-done
|
||||
<-done
|
||||
|
||||
// Should not panic and should have queued jobs
|
||||
if s.queue.Len() <= 0 {
|
||||
t.Fatal("expected jobs in queue after concurrent pushes")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGroupSubsystemsByAgent exercises the pure grouping helper that
|
||||
// LoadSubsystems uses after the batch query (SCALE-001 S10). No DB needed.
|
||||
func TestGroupSubsystemsByAgent(t *testing.T) {
|
||||
agentA := uuid.Must(uuid.NewV4())
|
||||
agentB := uuid.Must(uuid.NewV4())
|
||||
agentC := uuid.Must(uuid.NewV4()) // agent with no rows
|
||||
|
||||
rows := []models.AgentSubsystem{
|
||||
{AgentID: agentA, Subsystem: "apt"},
|
||||
{AgentID: agentA, Subsystem: "storage"},
|
||||
{AgentID: agentB, Subsystem: "dnf"},
|
||||
}
|
||||
|
||||
got := groupSubsystemsByAgent(rows)
|
||||
|
||||
// agentA should have exactly 2 rows
|
||||
if len(got[agentA]) != 2 {
|
||||
t.Fatalf("agentA: expected 2 subsystems, got %d", len(got[agentA]))
|
||||
}
|
||||
|
||||
// agentB should have exactly 1 row
|
||||
if len(got[agentB]) != 1 {
|
||||
t.Fatalf("agentB: expected 1 subsystem, got %d", len(got[agentB]))
|
||||
}
|
||||
if got[agentB][0].Subsystem != "dnf" {
|
||||
t.Fatalf("agentB: expected subsystem=dnf, got %s", got[agentB][0].Subsystem)
|
||||
}
|
||||
|
||||
// agentC has no rows — map lookup should return nil slice (zero jobs)
|
||||
if len(got[agentC]) != 0 {
|
||||
t.Fatalf("agentC: expected 0 subsystems, got %d", len(got[agentC]))
|
||||
}
|
||||
|
||||
// empty input should return empty map
|
||||
empty := groupSubsystemsByAgent(nil)
|
||||
if len(empty) != 0 {
|
||||
t.Fatalf("empty input: expected empty map, got len=%d", len(empty))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGroupSubsystemsByAgent_EmptyInput confirms nil/empty slice input is safe.
|
||||
func TestGroupSubsystemsByAgent_EmptyInput(t *testing.T) {
|
||||
got := groupSubsystemsByAgent([]models.AgentSubsystem{})
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil map for empty input")
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("expected empty map, got len=%d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkScheduler_ProcessQueue(b *testing.B) {
|
||||
config := DefaultConfig()
|
||||
s := NewScheduler(config, nil, nil, nil, nil)
|
||||
|
||||
// Pre-fill queue with jobs
|
||||
for i := 0; i < 1000; i++ {
|
||||
job := &SubsystemJob{
|
||||
AgentID: uuid.Must(uuid.NewV4()),
|
||||
Subsystem: "updates",
|
||||
IntervalMinutes: 15,
|
||||
NextRunAt: time.Now(),
|
||||
}
|
||||
s.queue.Push(job)
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
s.processQueue()
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue