Skip to content

Commit f38971e

Browse files
authored
perf(queue): reuse workers and consolidate task results (#180)
* perf(queue): reuse dispatch and worker goroutines Preserve timeout and callback independence while reducing per-task scheduling overhead. Add lifecycle regressions and end-to-end benchmarks. * perf(queue): consolidate task result channels - Return errors and panics through one buffered result channel - Preserve late completion and shutdown behavior without changing retry timers - Add result lifecycle regressions and focused allocation benchmarks
1 parent eaf4947 commit f38971e

6 files changed

Lines changed: 588 additions & 79 deletions

File tree

‎handle_performance_test.go‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package queue
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"testing"
8+
"time"
9+
10+
"github.com/golang-queue/queue/job"
11+
)
12+
13+
// Isolate handle overhead with fresh retry state for each completed invocation.
14+
// A 1ns retry delay emphasizes timer bookkeeping rather than real backoff waits.
15+
func BenchmarkHandleRetry(b *testing.B) {
16+
for _, retries := range []int64{0, 1, 3, 10} {
17+
b.Run(fmt.Sprintf("retries=%d", retries), func(b *testing.B) {
18+
q, err := NewQueue(WithWorker(NewRing()), WithLogger(NewEmptyLogger()))
19+
if err != nil {
20+
b.Fatal(err)
21+
}
22+
b.Cleanup(q.Release)
23+
retryErr := errors.New("retry")
24+
var attempts int64
25+
m := job.Message{
26+
Timeout: time.Minute, RetryDelay: time.Nanosecond,
27+
Task: func(context.Context) error {
28+
attempts++
29+
if attempts <= retries {
30+
return retryErr
31+
}
32+
return nil
33+
},
34+
}
35+
b.ReportAllocs()
36+
b.ResetTimer()
37+
for range b.N {
38+
attempts = 0
39+
m.RetryCount = retries
40+
if err := q.handle(&m); err != nil {
41+
b.Fatal(err)
42+
}
43+
if attempts != retries+1 {
44+
b.Fatalf("got %d attempts, want %d", attempts, retries+1)
45+
}
46+
}
47+
})
48+
}
49+
}

‎handle_result_test.go‎

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package queue
2+
3+
import (
4+
"context"
5+
"errors"
6+
"testing"
7+
"time"
8+
9+
"github.com/golang-queue/queue/job"
10+
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func TestResultProtocolShutdown(t *testing.T) {
15+
for _, kind := range []string{"success", "error", "panic"} {
16+
t.Run(kind, func(t *testing.T) {
17+
q, err := NewQueue(WithWorker(NewRing()), WithLogger(NewEmptyLogger()))
18+
require.NoError(t, err)
19+
t.Cleanup(q.Release)
20+
entered := make(chan struct{})
21+
type outcome struct {
22+
err error
23+
p any
24+
}
25+
finished := make(chan outcome, 1)
26+
taskErr := errors.New("task failed")
27+
m := job.NewTask(func(ctx context.Context) error {
28+
close(entered)
29+
<-ctx.Done()
30+
if kind == "panic" {
31+
panic("shutdown panic")
32+
}
33+
if kind == "error" {
34+
return taskErr
35+
}
36+
return nil
37+
}, job.AllowOption{Timeout: job.Time(5 * time.Second)})
38+
go func() {
39+
var result outcome
40+
defer func() { result.p = recover(); finished <- result }()
41+
result.err = q.handle(&m)
42+
}()
43+
receiveWithin(t, entered)
44+
q.Shutdown()
45+
result := receiveWithin(t, finished)
46+
if kind == "panic" {
47+
require.Equal(t, "shutdown panic", result.p)
48+
} else {
49+
require.Nil(t, result.p)
50+
if kind == "error" {
51+
require.ErrorIs(t, result.err, taskErr)
52+
} else {
53+
require.NoError(t, result.err)
54+
}
55+
}
56+
})
57+
}
58+
}
59+
60+
// TestMain's goleak check detects a handler blocked on a late result send.
61+
func TestResultProtocolLateCompletion(t *testing.T) {
62+
for _, kind := range []string{"success", "error", "panic"} {
63+
t.Run(kind, func(t *testing.T) {
64+
q, err := NewQueue(WithWorker(NewRing()), WithLogger(NewEmptyLogger()))
65+
require.NoError(t, err)
66+
t.Cleanup(q.Release)
67+
release := make(chan struct{})
68+
defer close(release)
69+
entered := make(chan struct{})
70+
m := job.NewTask(func(context.Context) error {
71+
close(entered)
72+
<-release
73+
if kind == "panic" {
74+
panic("late panic")
75+
}
76+
if kind == "error" {
77+
return errors.New("late failure")
78+
}
79+
return nil
80+
}, job.AllowOption{Timeout: job.Time(5 * time.Millisecond)})
81+
require.ErrorIs(t, q.handle(&m), context.DeadlineExceeded)
82+
receiveWithin(t, entered)
83+
})
84+
}
85+
}
86+
87+
func TestResultProtocolRetryCancellation(t *testing.T) {
88+
q, err := NewQueue(WithWorker(NewRing()), WithLogger(NewEmptyLogger()))
89+
require.NoError(t, err)
90+
t.Cleanup(q.Release)
91+
taskErr := errors.New("retry")
92+
task := func(context.Context) error { return taskErr }
93+
m := job.NewTask(task, job.AllowOption{
94+
RetryCount: job.Int64(10),
95+
RetryDelay: job.Time(time.Hour),
96+
Timeout: job.Time(10 * time.Millisecond),
97+
})
98+
require.ErrorIs(t, q.handle(&m), context.DeadlineExceeded)
99+
}
100+
101+
func TestResultProtocolRepeatedRetries(t *testing.T) {
102+
for _, succeed := range []bool{false, true} {
103+
q, err := NewQueue(WithWorker(NewRing()), WithLogger(NewEmptyLogger()))
104+
require.NoError(t, err)
105+
t.Cleanup(q.Release)
106+
var attempts int
107+
taskErr := errors.New("retry")
108+
m := job.NewTask(func(context.Context) error {
109+
attempts++
110+
if succeed && attempts == 11 {
111+
return nil
112+
}
113+
return taskErr
114+
}, job.AllowOption{RetryCount: job.Int64(10), RetryDelay: job.Time(time.Nanosecond)})
115+
err = q.handle(&m)
116+
if succeed {
117+
require.NoError(t, err)
118+
} else {
119+
require.ErrorIs(t, err, taskErr)
120+
}
121+
require.Equal(t, 11, attempts)
122+
}
123+
}

‎options.go‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,8 @@ func WithFn(fn func(context.Context, core.TaskMessage) error) Option {
123123

124124
// WithAfterFn sets a callback function that will be executed after each job completes.
125125
// This callback runs regardless of whether the job succeeded or failed.
126-
// It executes after metrics are updated but before the worker picks up the next task.
126+
// It executes after metrics are updated without occupying a worker slot.
127+
// Callbacks may overlap; Release waits for them to finish.
127128
// Useful for cleanup, logging, or triggering post-processing workflows.
128129
//
129130
// Example:

0 commit comments

Comments
 (0)