358 lines
11 KiB
Go
358 lines
11 KiB
Go
package orbit
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func runtimeTask(t *testing.T, ctx context.Context, c *Client, queue, handler string, timeout uint64) Task {
|
|
t.Helper()
|
|
out, err := c.SubmitTask(ctx, "runtime-"+workerTestID(t), SubmitTaskRequest{QueueID: queue, Handler: handler, HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{"sequence":9007199254740993}`), TaskTimeoutMS: 120000, Retry: RetryPolicy{MaxAttempts: 1, AttemptTimeoutMS: timeout}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return out.Task
|
|
}
|
|
func runtimeHandler(name string, handle func(context.Context, WorkerExecution) (json.RawMessage, error)) WorkerHandler {
|
|
return WorkerHandler{Capability: WorkerCapability{Handler: name, HandlerVersion: "v1", PayloadVersion: "v1"}, Handle: handle}
|
|
}
|
|
func waitRuntimeState(t *testing.T, ctx context.Context, c *Client, id string, state TaskState) Task {
|
|
t.Helper()
|
|
for {
|
|
task, err := c.GetTask(ctx, id)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if task.State == state {
|
|
return task
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
t.Fatalf("task did not reach %s", state)
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIntegrationWorkerRuntimeACKLossRenewAndDrain(t *testing.T) {
|
|
api, queue, token := integrationWorkerClient(t)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 55*time.Second)
|
|
defer cancel()
|
|
g := newWorkerFaultGateway(t, api.baseURL)
|
|
client, err := NewClient(g.server.URL, Options{Token: token, HTTPClient: g.server.Client()})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
name := "runtime.renew." + workerTestID(t)
|
|
entered := make(chan struct{})
|
|
var calls atomic.Int32
|
|
w, err := NewWorker(client, WorkerOptions{QueueID: queue, Capacity: 1, ShutdownTimeout: 45 * time.Second, Handlers: []WorkerHandler{runtimeHandler(name, func(work context.Context, e WorkerExecution) (json.RawMessage, error) {
|
|
calls.Add(1)
|
|
close(entered)
|
|
// 必须跨过服务端最初的 30 秒租约;只有自动续租成功才能获得 APPLIED。
|
|
select {
|
|
case <-work.Done():
|
|
return nil, work.Err()
|
|
case <-time.After(32 * time.Second):
|
|
return json.RawMessage(`{"sequence":9007199254740993,"done":true}`), nil
|
|
}
|
|
})}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
task := runtimeTask(t, ctx, api, queue, name, 90000)
|
|
g.drop.Store("task.start")
|
|
runCtx, stop := context.WithCancel(ctx)
|
|
defer stop()
|
|
done := make(chan error, 1)
|
|
go func() { done <- w.Run(runCtx) }()
|
|
g.expectDrop(t, "task.start")
|
|
select {
|
|
case <-entered:
|
|
case err := <-done:
|
|
t.Fatalf("Worker stopped before handler: %v", err)
|
|
case <-ctx.Done():
|
|
t.Fatal("handler did not start")
|
|
}
|
|
g.drop.Store("task.finish")
|
|
stop() // 取消 Run 只进入排空,不能立即取消正在执行的函数。
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case <-ctx.Done():
|
|
t.Fatal("Worker did not finish within grace")
|
|
}
|
|
g.expectDrop(t, "task.finish")
|
|
current := waitRuntimeState(t, ctx, api, task.ID, TaskSucceeded)
|
|
stats := w.Stats()
|
|
if calls.Load() != 1 || stats.Started != 1 || stats.Running != 0 || stats.ReportsConfirmed != 1 || stats.Reconnects < 2 || stats.RenewalsConfirmed < 2 || current.AttemptCount != 1 || string(current.Result) != `{"sequence":9007199254740993,"done":true}` {
|
|
t.Fatalf("automatic execution invariant failed: calls=%d stats=%+v attempts=%d", calls.Load(), stats, current.AttemptCount)
|
|
}
|
|
// 仍在等待的同类工作不能被已排空的进程领取。
|
|
waiting := runtimeTask(t, ctx, api, queue, name, 10000)
|
|
observed, err := api.GetTask(ctx, waiting.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if observed.State != TaskWaiting {
|
|
t.Fatal("drained Worker accepted another task")
|
|
}
|
|
if _, err := api.CancelTask(ctx, observed.ID, "cleanup-"+workerTestID(t), CancelTaskRequest{ExpectedRevision: observed.Revision, Reason: "finish runtime drain regression"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestIntegrationWorkerRuntimeUnknownAndUncooperativeShutdown(t *testing.T) {
|
|
api, queue, _ := integrationWorkerClient(t)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
|
defer cancel()
|
|
for _, test := range []struct {
|
|
name string
|
|
handle func(context.Context, WorkerExecution) (json.RawMessage, error)
|
|
state TaskState
|
|
}{
|
|
{"known", func(context.Context, WorkerExecution) (json.RawMessage, error) {
|
|
return nil, &WorkerFailure{Code: "INVALID_OBJECT"}
|
|
}, TaskFailed},
|
|
{"unknown", func(context.Context, WorkerExecution) (json.RawMessage, error) {
|
|
return nil, errors.New("external request timed out with private details")
|
|
}, TaskNeedsReview},
|
|
{"panic", func(context.Context, WorkerExecution) (json.RawMessage, error) { panic("private panic") }, TaskNeedsReview},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
name := "runtime." + test.name + "." + workerTestID(t)
|
|
w, err := NewWorker(api, WorkerOptions{QueueID: queue, Capacity: 1, Handlers: []WorkerHandler{runtimeHandler(name, test.handle)}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
task := runtimeTask(t, ctx, api, queue, name, 5000)
|
|
run, stop := context.WithCancel(ctx)
|
|
done := make(chan error, 1)
|
|
go func() { done <- w.Run(run) }()
|
|
defer stop()
|
|
waitRuntimeState(t, ctx, api, task.ID, test.state)
|
|
stop()
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case <-ctx.Done():
|
|
t.Fatal("classification Worker did not stop")
|
|
}
|
|
})
|
|
}
|
|
t.Run("uncooperative", func(t *testing.T) {
|
|
name := "runtime.uncooperative." + workerTestID(t)
|
|
entered, release, returned := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
|
defer close(release)
|
|
w, err := NewWorker(api, WorkerOptions{QueueID: queue, Capacity: 1, ShutdownTimeout: 250 * time.Millisecond, Handlers: []WorkerHandler{runtimeHandler(name, func(context.Context, WorkerExecution) (json.RawMessage, error) {
|
|
close(entered)
|
|
<-release
|
|
close(returned)
|
|
return json.RawMessage(`{}`), nil
|
|
})}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runtimeTask(t, ctx, api, queue, name, 1500)
|
|
run, stop := context.WithCancel(ctx)
|
|
done := make(chan error, 1)
|
|
go func() { done <- w.Run(run) }()
|
|
defer stop()
|
|
select {
|
|
case <-entered:
|
|
case <-ctx.Done():
|
|
t.Fatal("handler did not enter")
|
|
}
|
|
began := time.Now()
|
|
stop()
|
|
select {
|
|
case err := <-done:
|
|
var stopped *WorkerRunError
|
|
if !errors.As(err, &stopped) || !errors.Is(err, ErrWorkerShutdownTimeout) || stopped.Running != 1 || stopped.Unconfirmed != 1 {
|
|
t.Fatalf("uncooperative handler hidden: %v", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("shutdown waited forever for handler")
|
|
}
|
|
if time.Since(began) > time.Second {
|
|
t.Fatal("shutdown exceeded bounded grace")
|
|
}
|
|
select {
|
|
case <-returned:
|
|
t.Fatal("SDK claimed to kill uncooperative handler")
|
|
default:
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestIntegrationWorkerRuntimeLeaseLossCancelsAndDoesNotRevive(t *testing.T) {
|
|
api, queue, _ := integrationWorkerClient(t)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
|
|
defer cancel()
|
|
name := "runtime.expired." + workerTestID(t)
|
|
cause := make(chan error, 1)
|
|
w, err := NewWorker(api, WorkerOptions{QueueID: queue, Capacity: 1, Handlers: []WorkerHandler{runtimeHandler(name, func(work context.Context, _ WorkerExecution) (json.RawMessage, error) {
|
|
<-work.Done()
|
|
cause <- context.Cause(work)
|
|
return nil, work.Err()
|
|
})}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
task := runtimeTask(t, ctx, api, queue, name, 1400)
|
|
run, stop := context.WithCancel(ctx)
|
|
done := make(chan error, 1)
|
|
go func() { done <- w.Run(run) }()
|
|
defer stop()
|
|
select {
|
|
case err := <-cause:
|
|
if !errors.Is(err, ErrExecutionLeaseLost) {
|
|
t.Fatal(err)
|
|
}
|
|
case <-ctx.Done():
|
|
t.Fatal("handler did not observe lease cancellation")
|
|
}
|
|
waitRuntimeState(t, ctx, api, task.ID, TaskNeedsReview)
|
|
stop()
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case <-ctx.Done():
|
|
t.Fatal("Worker did not stop")
|
|
}
|
|
if w.Stats().Started != 1 {
|
|
t.Fatal("expired handler was invoked again")
|
|
}
|
|
}
|
|
|
|
func TestIntegrationWorkerRuntimeOutageCancelsAndReplaysEvidence(t *testing.T) {
|
|
api, queue, token := integrationWorkerClient(t)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 18*time.Second)
|
|
defer cancel()
|
|
g := newWorkerFaultGateway(t, api.baseURL)
|
|
client, err := NewClient(g.server.URL, Options{Token: token, HTTPClient: g.server.Client()})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
name := "runtime.outage." + workerTestID(t)
|
|
entered, cancelled := make(chan struct{}), make(chan error, 1)
|
|
w, err := NewWorker(client, WorkerOptions{QueueID: queue, Capacity: 1, ReconnectMaxDelay: time.Second, Handlers: []WorkerHandler{runtimeHandler(name, func(work context.Context, _ WorkerExecution) (json.RawMessage, error) {
|
|
close(entered)
|
|
<-work.Done()
|
|
cancelled <- context.Cause(work)
|
|
return nil, work.Err()
|
|
})}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
task := runtimeTask(t, ctx, api, queue, name, 2000)
|
|
run, stop := context.WithCancel(ctx)
|
|
done := make(chan error, 1)
|
|
go func() { done <- w.Run(run) }()
|
|
defer stop()
|
|
select {
|
|
case <-entered:
|
|
case <-ctx.Done():
|
|
t.Fatal("handler did not start")
|
|
}
|
|
g.offline.Store(true)
|
|
g.disconnectActive()
|
|
select {
|
|
case cause := <-cancelled:
|
|
if !errors.Is(cause, ErrExecutionLeaseLost) {
|
|
t.Fatal(cause)
|
|
}
|
|
case <-ctx.Done():
|
|
t.Fatal("disconnected handler ignored local expiry")
|
|
}
|
|
waitRuntimeState(t, ctx, api, task.ID, TaskNeedsReview)
|
|
g.offline.Store(false)
|
|
for w.Stats().EvidenceConfirmed != 1 {
|
|
select {
|
|
case err := <-done:
|
|
t.Fatalf("Worker stopped before evidence replay: %v", err)
|
|
case <-ctx.Done():
|
|
t.Fatal("pending result was not replayed")
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
}
|
|
stop()
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case <-ctx.Done():
|
|
t.Fatal("Worker did not stop")
|
|
}
|
|
current, err := api.GetTask(ctx, task.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if w.Stats().Started != 1 || current.AttemptCount != 1 || current.State != TaskNeedsReview || len(current.Result) != 0 {
|
|
t.Fatal("outage revived handler or promoted late evidence")
|
|
}
|
|
}
|
|
|
|
func TestIntegrationWorkerRuntimeConcurrency(t *testing.T) {
|
|
api, queue, _ := integrationWorkerClient(t)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 18*time.Second)
|
|
defer cancel()
|
|
name := "runtime.concurrency." + workerTestID(t)
|
|
var running, peak atomic.Int32
|
|
w, err := NewWorker(api, WorkerOptions{QueueID: queue, Capacity: 2, Handlers: []WorkerHandler{runtimeHandler(name, func(work context.Context, _ WorkerExecution) (json.RawMessage, error) {
|
|
value := running.Add(1)
|
|
defer running.Add(-1)
|
|
for old := peak.Load(); value > old; old = peak.Load() {
|
|
if peak.CompareAndSwap(old, value) {
|
|
break
|
|
}
|
|
}
|
|
select {
|
|
case <-work.Done():
|
|
return nil, work.Err()
|
|
case <-time.After(300 * time.Millisecond):
|
|
return json.RawMessage(`{"done":true}`), nil
|
|
}
|
|
})}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var tasks []Task
|
|
for range 6 {
|
|
tasks = append(tasks, runtimeTask(t, ctx, api, queue, name, 10000))
|
|
}
|
|
run, stop := context.WithCancel(ctx)
|
|
done := make(chan error, 1)
|
|
go func() { done <- w.Run(run) }()
|
|
defer stop()
|
|
for _, task := range tasks {
|
|
current := waitRuntimeState(t, ctx, api, task.ID, TaskSucceeded)
|
|
if current.AttemptCount != 1 {
|
|
t.Fatal("unexpected new attempt")
|
|
}
|
|
}
|
|
stop()
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case <-ctx.Done():
|
|
t.Fatal("Worker did not drain")
|
|
}
|
|
if peak.Load() != 2 || running.Load() != 0 || w.Stats().Started != 6 || w.Stats().ReportsConfirmed != 6 {
|
|
t.Fatalf("concurrency invariant failed: peak=%d stats=%+v", peak.Load(), w.Stats())
|
|
}
|
|
}
|