package router import ( "sync" "testing" "time" "github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle" ) func mkMatch(due time.Time, key string) Match { return Match{ Key: s3lifecycle.ActionKey{Bucket: key}, DueTime: due, ObjectKey: key, } } func TestScheduleEmpty(t *testing.T) { s := NewSchedule() if s.Len() != 0 { t.Fatal("Len != 0") } if _, ok := s.NextDue(); ok { t.Fatal("NextDue ok=true on empty") } if got := s.Drain(time.Now()); got != nil { t.Fatalf("Drain on empty returned %v", got) } } func TestScheduleOrderedByDueTime(t *testing.T) { s := NewSchedule() t0 := time.Now() s.Add(mkMatch(t0.Add(3*time.Second), "c")) s.Add(mkMatch(t0.Add(1*time.Second), "a")) s.Add(mkMatch(t0.Add(2*time.Second), "b")) if s.Len() != 3 { t.Fatalf("Len=%d, want 3", s.Len()) } due, ok := s.NextDue() if !ok || !due.Equal(t0.Add(1*time.Second)) { t.Fatalf("NextDue=%v ok=%v", due, ok) } got := s.Drain(t0.Add(2 * time.Second)) if len(got) != 2 || got[0].ObjectKey != "a" || got[1].ObjectKey != "b" { t.Fatalf("Drain order: %+v", got) } if s.Len() != 1 { t.Fatalf("Len after drain=%d, want 1", s.Len()) } got = s.Drain(t0.Add(5 * time.Second)) if len(got) != 1 || got[0].ObjectKey != "c" { t.Fatalf("Drain rest: %+v", got) } if s.Len() != 0 { t.Fatal("Len != 0 after final drain") } } func TestScheduleDrainBoundaryInclusive(t *testing.T) { // DueTime exactly equal to now is drainable (<=). s := NewSchedule() t0 := time.Now() s.Add(mkMatch(t0, "a")) got := s.Drain(t0) if len(got) != 1 { t.Fatalf("expected boundary-inclusive drain, got %d", len(got)) } } func TestScheduleAllowsDuplicates(t *testing.T) { s := NewSchedule() t0 := time.Now() s.Add(mkMatch(t0, "a")) s.Add(mkMatch(t0, "a")) if s.Len() != 2 { t.Fatalf("dup count=%d, want 2", s.Len()) } got := s.Drain(t0) if len(got) != 2 { t.Fatalf("Drain dup count=%d, want 2", len(got)) } } func TestScheduleDrainBeforeAnyDueReturnsNothing(t *testing.T) { // Drain at a time before the earliest DueTime must return an empty // slice and leave the heap intact. Otherwise the dispatcher would // consume future-due matches early. s := NewSchedule() t0 := time.Now() s.Add(mkMatch(t0.Add(5*time.Second), "a")) s.Add(mkMatch(t0.Add(10*time.Second), "b")) got := s.Drain(t0) if got != nil { t.Fatalf("Drain before any due should be nil, got %+v", got) } if s.Len() != 2 { t.Fatalf("Len after no-op Drain=%d, want 2", s.Len()) } } func TestScheduleNextDueAfterPartialDrain(t *testing.T) { // After draining a prefix, NextDue must point at the earliest // remaining Match — regression catch for a Drain implementation // that forgets to maintain the heap invariant. s := NewSchedule() t0 := time.Now() s.Add(mkMatch(t0.Add(1*time.Second), "a")) s.Add(mkMatch(t0.Add(2*time.Second), "b")) s.Add(mkMatch(t0.Add(3*time.Second), "c")) got := s.Drain(t0.Add(1500 * time.Millisecond)) if len(got) != 1 || got[0].ObjectKey != "a" { t.Fatalf("Drain prefix=%+v, want [a]", got) } due, ok := s.NextDue() if !ok || !due.Equal(t0.Add(2*time.Second)) { t.Fatalf("NextDue after partial Drain=%v ok=%v, want t+2s", due, ok) } } func TestScheduleAddAfterDrainKeepsOrder(t *testing.T) { // Adding to a non-empty schedule mid-stream — after a real Drain // has popped at least one entry, a fresh Match with an earlier // DueTime than the existing minimum must become the next drainable. // Pins that heap.Pop + Push together preserve the heap invariant // across the Drain → Add boundary. s := NewSchedule() t0 := time.Now() s.Add(mkMatch(t0.Add(10*time.Second), "old")) s.Add(mkMatch(t0.Add(6*time.Second), "drain_me")) drained := s.Drain(t0.Add(7 * time.Second)) if len(drained) != 1 || drained[0].ObjectKey != "drain_me" { t.Fatalf("pre-drain=%+v, want [drain_me]", drained) } s.Add(mkMatch(t0.Add(5*time.Second), "new")) // added after Drain, earlier than old due, ok := s.NextDue() if !ok || !due.Equal(t0.Add(5*time.Second)) { t.Fatalf("NextDue=%v ok=%v, want t+5s", due, ok) } got := s.Drain(t0.Add(6 * time.Second)) if len(got) != 1 || got[0].ObjectKey != "new" { t.Fatalf("Drain=%+v, want [new]", got) } } func TestScheduleDrainOrderIsAscendingDueTime(t *testing.T) { // Drain must return Matches in DueTime order regardless of insert // order — explicit pinning of the contract documented on Drain. s := NewSchedule() t0 := time.Now() for _, off := range []time.Duration{ 5 * time.Second, 1 * time.Second, 3 * time.Second, 2 * time.Second, 4 * time.Second, } { s.Add(mkMatch(t0.Add(off), "k")) } got := s.Drain(t0.Add(10 * time.Second)) if len(got) != 5 { t.Fatalf("Drain count=%d, want 5", len(got)) } for i := 1; i < len(got); i++ { if got[i].DueTime.Before(got[i-1].DueTime) { t.Fatalf("Drain[%d]=%v is before Drain[%d]=%v", i, got[i].DueTime, i-1, got[i-1].DueTime) } } } func TestScheduleConcurrentAddDrainNoRace(t *testing.T) { // The dispatcher's Add and Drain run on separate goroutines; the // schedule's mutex must serialize them without deadlock. -race // catches a regression that drops the lock on either path. s := NewSchedule() t0 := time.Now() const N = 64 var wg sync.WaitGroup wg.Add(N * 2) for i := 0; i < N; i++ { i := i go func() { defer wg.Done() s.Add(mkMatch(t0.Add(time.Duration(i)*time.Millisecond), "k")) }() go func() { defer wg.Done() _ = s.Drain(t0.Add(10 * time.Second)) }() } wg.Wait() }