Files

180 lines
5.2 KiB
Go

package counting
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
)
type recorder struct {
mu sync.Mutex
jobs []Job
waits []time.Duration
depths []int
panics int
}
func (r *recorder) handle(ctx context.Context, job Job) {
r.mu.Lock()
r.jobs = append(r.jobs, job)
r.mu.Unlock()
}
func (r *recorder) observer() Observer {
return Observer{
Depth: func(n int) { r.mu.Lock(); r.depths = append(r.depths, n); r.mu.Unlock() },
Wait: func(d time.Duration) { r.mu.Lock(); r.waits = append(r.waits, d); r.mu.Unlock() },
Panic: func(job Job, p any, stack []byte) { r.mu.Lock(); r.panics++; r.mu.Unlock() },
}
}
func (r *recorder) count() int {
r.mu.Lock()
defer r.mu.Unlock()
return len(r.jobs)
}
func settle(t *testing.T, q *Queue) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for q.Pending() > 0 {
if time.Now().After(deadline) {
t.Fatalf("queue did not settle: pending=%d", q.Pending())
}
time.Sleep(time.Millisecond)
}
}
func TestQueue_ProcessesJobs(t *testing.T) {
r := &recorder{}
q := NewQueue(QueueConfig{Size: 10, Workers: 2, Timeout: time.Second}, r.handle, r.observer())
defer q.Drain(context.Background())
for i := 0; i < 5; i++ {
if !q.Enqueue(Job{Route: RouteIn, TraceID: "t", Recipient: "y@example.com", Received: time.Now()}) {
t.Fatalf("enqueue %d refused", i)
}
}
settle(t, q)
if r.count() != 5 {
t.Errorf("handled %d jobs, want 5", r.count())
}
r.mu.Lock()
defer r.mu.Unlock()
if len(r.waits) != 5 || len(r.depths) == 0 {
t.Errorf("observer: waits=%d depths=%d", len(r.waits), len(r.depths))
}
for _, w := range r.waits {
if w < 0 || w > time.Second {
t.Errorf("queue wait %v out of range", w)
}
}
}
// Workers run the handler with their own bounded context, never a request's.
func TestQueue_HandlerContextHasTimeout(t *testing.T) {
var got atomic.Value
q := NewQueue(QueueConfig{Size: 1, Workers: 1, Timeout: 250 * time.Millisecond}, func(ctx context.Context, job Job) {
dl, ok := ctx.Deadline()
got.Store([2]any{ok, time.Until(dl)})
}, Observer{})
defer q.Drain(context.Background())
q.Enqueue(Job{})
settle(t, q)
v := got.Load().([2]any)
if !v[0].(bool) || v[1].(time.Duration) > 250*time.Millisecond || v[1].(time.Duration) < 100*time.Millisecond {
t.Errorf("deadline = %v", v)
}
}
// A full queue refuses the job instead of blocking the HTTP handler.
func TestQueue_FullRefuses(t *testing.T) {
release := make(chan struct{})
q := NewQueue(QueueConfig{Size: 2, Workers: 1, Timeout: time.Second}, func(ctx context.Context, job Job) { <-release }, Observer{})
// One job occupies the worker ...
if !q.Enqueue(Job{}) {
t.Fatal("first enqueue refused")
}
deadline := time.Now().Add(time.Second)
for q.Depth() != 0 && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
// ... two fill the channel.
for i := 0; i < 2; i++ {
if !q.Enqueue(Job{}) {
t.Fatalf("enqueue %d should be accepted", i)
}
}
if q.Enqueue(Job{}) {
t.Error("enqueue on a full queue should be refused")
}
if q.Depth() != 2 || q.Pending() != 3 {
t.Errorf("depth=%d pending=%d, want 2/3", q.Depth(), q.Pending())
}
close(release)
if err := q.Drain(context.Background()); err != nil {
t.Fatal(err)
}
if q.Enqueue(Job{}) {
t.Error("enqueue after Drain must be refused, not panic")
}
}
// Drain waits for queued and in-flight jobs; a context that expires first
// reports what was left behind.
func TestQueue_Drain(t *testing.T) {
r := &recorder{}
slow := func(ctx context.Context, job Job) { time.Sleep(50 * time.Millisecond); r.handle(ctx, job) }
q := NewQueue(QueueConfig{Size: 10, Workers: 1, Timeout: time.Second}, slow, Observer{})
for i := 0; i < 4; i++ {
q.Enqueue(Job{})
}
start := time.Now()
if err := q.Drain(context.Background()); err != nil {
t.Fatal(err)
}
if r.count() != 4 || time.Since(start) < 150*time.Millisecond {
t.Errorf("Drain returned after %v with %d jobs handled, want all 4", time.Since(start), r.count())
}
block := make(chan struct{})
q2 := NewQueue(QueueConfig{Size: 10, Workers: 1, Timeout: time.Second}, func(ctx context.Context, job Job) { <-block }, Observer{})
q2.Enqueue(Job{})
q2.Enqueue(Job{})
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
err := q2.Drain(ctx)
if err == nil || !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("Drain with a stuck worker should report the deadline: %v", err)
}
close(block)
}
// A panicking handler is recovered and reported; the worker keeps serving.
func TestQueue_RecoversPanic(t *testing.T) {
r := &recorder{}
q := NewQueue(QueueConfig{Size: 10, Workers: 1, Timeout: time.Second}, func(ctx context.Context, job Job) {
if job.TraceID == "boom" {
panic("boom")
}
r.handle(ctx, job)
}, r.observer())
defer q.Drain(context.Background())
q.Enqueue(Job{TraceID: "boom"})
q.Enqueue(Job{TraceID: "ok"})
settle(t, q)
if r.count() != 1 || r.panics != 1 {
t.Errorf("handled=%d panics=%d, want 1/1", r.count(), r.panics)
}
}
func TestQueue_Defaults(t *testing.T) {
q := NewQueue(QueueConfig{}, func(context.Context, Job) {}, Observer{})
defer q.Drain(context.Background())
if q.cfg.Size != 10000 || q.cfg.Workers != 8 || q.cfg.Timeout != 4*time.Second {
t.Errorf("defaults = %+v", q.cfg)
}
}