You've already forked agentic-coding-workflow
200 lines
6.9 KiB
Go
200 lines
6.9 KiB
Go
//go:build integration
|
|
|
|
package jobs_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/yan1h/agent-coding-workflow/internal/infra/clock"
|
|
"github.com/yan1h/agent-coding-workflow/internal/jobs"
|
|
)
|
|
|
|
type recordingHandler struct {
|
|
typ jobs.JobType
|
|
calls atomic.Int32
|
|
wantErr error
|
|
beforeRet func()
|
|
}
|
|
|
|
func (h *recordingHandler) Type() jobs.JobType { return h.typ }
|
|
func (h *recordingHandler) Handle(ctx context.Context, job jobs.Job) error {
|
|
h.calls.Add(1)
|
|
if h.beforeRet != nil {
|
|
h.beforeRet()
|
|
}
|
|
return h.wantErr
|
|
}
|
|
|
|
func TestWorker_HandlesAndCompletes(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
repo, _ := setupRepo(t)
|
|
_, err := repo.Enqueue(ctx, jobs.TypeWebhookDeliver, json.RawMessage(`{}`), time.Now(), 5)
|
|
require.NoError(t, err)
|
|
|
|
h := &recordingHandler{typ: jobs.TypeWebhookDeliver}
|
|
w := jobs.NewWorker(repo, map[jobs.JobType]jobs.Handler{jobs.TypeWebhookDeliver: h},
|
|
jobs.WorkerOptions{
|
|
ID: "test#1",
|
|
PollInterval: 5 * time.Millisecond,
|
|
Backoff: []time.Duration{30 * time.Second},
|
|
Clock: clock.Real(),
|
|
Log: discardLogger(),
|
|
})
|
|
cctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
|
|
defer cancel()
|
|
go w.Run(cctx)
|
|
require.Eventually(t, func() bool { return h.calls.Load() >= 1 }, 200*time.Millisecond, 5*time.Millisecond)
|
|
cancel()
|
|
w.Wait()
|
|
}
|
|
|
|
func TestWorker_RetryableErrorReschedules(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
repo, _ := setupRepo(t)
|
|
enqueued, err := repo.Enqueue(ctx, jobs.TypeWebhookDeliver, json.RawMessage(`{}`), time.Now(), 5)
|
|
require.NoError(t, err)
|
|
|
|
h := &recordingHandler{typ: jobs.TypeWebhookDeliver, wantErr: errors.New("transient")}
|
|
w := jobs.NewWorker(repo, map[jobs.JobType]jobs.Handler{jobs.TypeWebhookDeliver: h},
|
|
jobs.WorkerOptions{
|
|
ID: "t#1",
|
|
PollInterval: 5 * time.Millisecond,
|
|
Backoff: []time.Duration{30 * time.Second, 2 * time.Minute},
|
|
Clock: clock.Real(),
|
|
Log: discardLogger(),
|
|
})
|
|
cctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
|
|
defer cancel()
|
|
go w.Run(cctx)
|
|
require.Eventually(t, func() bool { return h.calls.Load() >= 1 }, 200*time.Millisecond, 5*time.Millisecond)
|
|
cancel()
|
|
w.Wait()
|
|
|
|
got, err := repo.Get(ctx, enqueued.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, jobs.StatusPending, got.Status)
|
|
assert.Equal(t, int32(1), got.Attempts)
|
|
require.NotNil(t, got.LastError)
|
|
assert.Equal(t, "transient", *got.LastError)
|
|
}
|
|
|
|
func TestWorker_PermanentErrorDeadLetters(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
repo, _ := setupRepo(t)
|
|
enqueued, err := repo.Enqueue(ctx, jobs.TypeWebhookDeliver, json.RawMessage(`{}`), time.Now(), 5)
|
|
require.NoError(t, err)
|
|
h := &recordingHandler{typ: jobs.TypeWebhookDeliver, wantErr: jobs.Permanent(errors.New("4xx config error"))}
|
|
w := jobs.NewWorker(repo, map[jobs.JobType]jobs.Handler{jobs.TypeWebhookDeliver: h},
|
|
jobs.WorkerOptions{ID: "t", PollInterval: 5 * time.Millisecond, Backoff: []time.Duration{time.Second}, Clock: clock.Real(), Log: discardLogger()})
|
|
cctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
|
|
defer cancel()
|
|
go w.Run(cctx)
|
|
require.Eventually(t, func() bool {
|
|
got, _ := repo.Get(ctx, enqueued.ID)
|
|
return got != nil && got.Status == jobs.StatusDead
|
|
}, 300*time.Millisecond, 10*time.Millisecond)
|
|
cancel()
|
|
w.Wait()
|
|
}
|
|
|
|
func TestWorker_MaxAttemptsExhaustedDeadLetters(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
repo, _ := setupRepo(t)
|
|
enqueued, err := repo.Enqueue(ctx, jobs.TypeWebhookDeliver, json.RawMessage(`{}`), time.Now(), 2) // max=2
|
|
require.NoError(t, err)
|
|
h := &recordingHandler{typ: jobs.TypeWebhookDeliver, wantErr: errors.New("transient")}
|
|
w := jobs.NewWorker(repo, map[jobs.JobType]jobs.Handler{jobs.TypeWebhookDeliver: h},
|
|
jobs.WorkerOptions{ID: "t", PollInterval: 2 * time.Millisecond, Backoff: []time.Duration{time.Millisecond}, Clock: clock.Real(), Log: discardLogger()})
|
|
cctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
|
|
defer cancel()
|
|
go w.Run(cctx)
|
|
require.Eventually(t, func() bool {
|
|
got, _ := repo.Get(ctx, enqueued.ID)
|
|
return got != nil && got.Status == jobs.StatusDead
|
|
}, 500*time.Millisecond, 10*time.Millisecond)
|
|
cancel()
|
|
w.Wait()
|
|
}
|
|
|
|
func TestWorker_HandlerPanicTreatedAsRetry(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
repo, _ := setupRepo(t)
|
|
enqueued, err := repo.Enqueue(ctx, jobs.TypeWebhookDeliver, json.RawMessage(`{}`), time.Now(), 5)
|
|
require.NoError(t, err)
|
|
h := &recordingHandler{typ: jobs.TypeWebhookDeliver, beforeRet: func() { panic("kaboom") }}
|
|
w := jobs.NewWorker(repo, map[jobs.JobType]jobs.Handler{jobs.TypeWebhookDeliver: h},
|
|
jobs.WorkerOptions{ID: "t", PollInterval: 5 * time.Millisecond, Backoff: []time.Duration{time.Second}, Clock: clock.Real(), Log: discardLogger()})
|
|
cctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
|
|
defer cancel()
|
|
go w.Run(cctx)
|
|
require.Eventually(t, func() bool {
|
|
got, _ := repo.Get(ctx, enqueued.ID)
|
|
return got != nil && got.Status == jobs.StatusPending && got.Attempts >= 1
|
|
}, 300*time.Millisecond, 10*time.Millisecond)
|
|
cancel()
|
|
w.Wait()
|
|
}
|
|
|
|
func TestWorker_UnknownTypeDeadLetters(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
repo, _ := setupRepo(t)
|
|
enqueued, err := repo.Enqueue(ctx, "unknown.type", json.RawMessage(`{}`), time.Now(), 5)
|
|
require.NoError(t, err)
|
|
w := jobs.NewWorker(repo, map[jobs.JobType]jobs.Handler{},
|
|
jobs.WorkerOptions{ID: "t", PollInterval: 5 * time.Millisecond, Backoff: []time.Duration{time.Second}, Clock: clock.Real(), Log: discardLogger()})
|
|
cctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
|
|
defer cancel()
|
|
go w.Run(cctx)
|
|
require.Eventually(t, func() bool {
|
|
got, _ := repo.Get(ctx, enqueued.ID)
|
|
return got != nil && got.Status == jobs.StatusDead
|
|
}, 200*time.Millisecond, 10*time.Millisecond)
|
|
cancel()
|
|
w.Wait()
|
|
}
|
|
|
|
func TestWorker_DeadLetterHookCalled(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
repo, _ := setupRepo(t)
|
|
enqueued, err := repo.Enqueue(ctx, jobs.TypeWebhookDeliver, json.RawMessage(`{}`), time.Now(), 5)
|
|
require.NoError(t, err)
|
|
|
|
h := &recordingHandler{typ: jobs.TypeWebhookDeliver, wantErr: jobs.Permanent(errors.New("4xx"))}
|
|
var called atomic.Int32
|
|
var seenID atomic.Value // uuid.UUID
|
|
w := jobs.NewWorker(repo, map[jobs.JobType]jobs.Handler{jobs.TypeWebhookDeliver: h},
|
|
jobs.WorkerOptions{
|
|
ID: "t", PollInterval: 5 * time.Millisecond, Backoff: []time.Duration{time.Second},
|
|
Clock: clock.Real(), Log: discardLogger(),
|
|
OnDeadLetter: func(_ context.Context, j jobs.Job, _ error) {
|
|
seenID.Store(j.ID)
|
|
called.Add(1)
|
|
},
|
|
})
|
|
cctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
|
|
defer cancel()
|
|
go w.Run(cctx)
|
|
require.Eventually(t, func() bool { return called.Load() >= 1 }, 500*time.Millisecond, 10*time.Millisecond)
|
|
cancel()
|
|
w.Wait()
|
|
if id, ok := seenID.Load().(uuid.UUID); ok {
|
|
assert.Equal(t, enqueued.ID, id)
|
|
}
|
|
}
|