feat: serialize keyed tasks across queues with durable fencing tokens

This commit is contained in:
2026-09-17 18:25:00 +08:00
parent 993786476e
commit 958b4a8cea
43 changed files with 1319 additions and 82 deletions

View File

@@ -0,0 +1,217 @@
package orbit
import (
"context"
"encoding/json"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestIntegrationWorkerRuntimeKeyedConcurrency(t *testing.T) {
api, _, token := integrationWorkerClient(t)
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
name := "keyed." + workerTestID(t)
gateway := newWorkerFaultGateway(t, api.baseURL)
client, err := NewClient(gateway.server.URL, Options{Token: token, HTTPClient: gateway.server.Client()})
if err != nil {
t.Fatal(err)
}
var active, calls atomic.Int32
var mu sync.Mutex
tokens := map[string]uint64{}
var last uint64
handler := runtimeHandler(name, func(ctx context.Context, e WorkerExecution) (json.RawMessage, error) {
calls.Add(1)
if active.Add(1) != 1 {
t.Error("same resource handlers overlapped")
}
defer active.Add(-1)
mu.Lock()
if e.Task.ConcurrencyKey != name || e.ConcurrencyToken <= last {
t.Error("key token was lost, duplicated or reset")
}
last = e.ConcurrencyToken
tokens[e.Task.ID] = e.ConcurrencyToken
mu.Unlock()
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(100 * time.Millisecond):
}
return e.Task.Payload, nil
})
var tasks []Task
var workers []*Worker
for i := 0; i < 2; i++ {
q, err := api.CreateQueue(ctx, workerTestID(t), CreateQueueRequest{Name: name + workerTestID(t), MaxConcurrency: 4, RatePerSecond: 100})
if err != nil {
t.Fatal(err)
}
for j := 0; j < 2; j++ {
out, err := api.SubmitTask(ctx, workerTestID(t), SubmitTaskRequest{QueueID: q.Queue.ID, Handler: name, HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{"n":9007199254740993}`), ConcurrencyKey: name})
if err != nil {
t.Fatal(err)
}
tasks = append(tasks, out.Task)
}
w, err := NewWorker(client, WorkerOptions{QueueID: q.Queue.ID, Capacity: 4, Handlers: []WorkerHandler{handler}})
if err != nil {
t.Fatal(err)
}
workers = append(workers, w)
}
// 在真实 Start 提交后丢 ACK;恢复必须沿用原 key/token,不能重跑处理器。
gateway.drop.Store("task.start")
run, stop := context.WithCancel(ctx)
done := make(chan error, 2)
for _, w := range workers {
go func() { done <- w.Run(run) }()
}
defer func() {
stop()
for range workers {
if err := <-done; err != nil {
t.Error(err)
}
}
}()
for _, task := range tasks {
finished := waitRuntimeState(t, ctx, api, task.ID, TaskSucceeded)
if finished.AttemptCount != 1 || finished.ConcurrencyKey != name {
t.Fatal("keyed task lost identity")
}
history, err := api.ListTaskAttempts(ctx, task.ID, HistoryPageRequest{})
if err != nil || len(history.Items) != 1 {
t.Fatal("missing keyed history", err)
}
mu.Lock()
want := tokens[task.ID]
mu.Unlock()
if want == 0 || history.Items[0].ConcurrencyToken != want {
t.Fatal("history token differs from handler")
}
detail, err := api.GetTaskAttempt(ctx, task.ID, history.Items[0].ID)
if err != nil || detail.ConcurrencyToken != want {
t.Fatal("detail token differs from handler", err)
}
}
gateway.expectDrop(t, "task.start")
if calls.Load() != 4 || active.Load() != 0 {
t.Fatal("incorrect handler count")
}
}
func TestIntegrationWorkerProcessKeyedConcurrency(t *testing.T) {
a := startOrbitProcess(t)
_, _, token := integrationWorkerClient(t)
api := processClient(t, a, token)
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
name := "key-crash." + workerTestID(t)
var registrations []WorkerRegistration
var tasks []Task
for i := 0; i < 2; i++ {
q, err := api.CreateQueue(ctx, workerTestID(t), CreateQueueRequest{Name: name + workerTestID(t), MaxConcurrency: 2, RatePerSecond: 100})
if err != nil {
t.Fatal(err)
}
r, err := NewWorkerRegistration(q.Queue.ID, 1, []WorkerCapability{{Handler: name, HandlerVersion: "v1", PayloadVersion: "v1"}})
if err != nil {
t.Fatal(err)
}
r.ConcurrencyKeys = true
registrations = append(registrations, r)
out, err := api.SubmitTask(ctx, workerTestID(t), SubmitTaskRequest{QueueID: q.Queue.ID, Handler: name, HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{}`), ConcurrencyKey: name})
if err != nil {
t.Fatal(err)
}
tasks = append(tasks, out.Task)
}
dial := func(api *Client, r WorkerRegistration) *WorkerConnection {
t.Helper()
w, err := api.DialWorker(ctx, r)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { w.Close() })
return w
}
capacity := func(w *WorkerConnection) {
t.Helper()
if _, err := w.Capacity(ctx, workerTestID(t), WorkerCapacity{Sequence: 1, Capacity: 1}); err != nil {
t.Fatal(err)
}
}
w1 := dial(api, registrations[0])
capacity(w1)
first, err := w1.NextAssignment(ctx)
if err != nil || first.ConcurrencyToken == 0 {
t.Fatal("first key not granted", err)
}
started, err := w1.Start(ctx, workerTestID(t), first.Attempt())
if err != nil {
t.Fatal(err)
}
if _, active := started.Lease.ExecutionDeadline(); !active {
t.Fatal("no current execution authorization")
}
a.kill(t)
b := startOrbitProcess(t)
api = processClient(t, b, token)
if a.nodeID == b.nodeID {
t.Fatal("node id reused")
}
w1 = dial(api, registrations[0])
lease, err := w1.Resume(ctx, workerTestID(t), first.Attempt())
if err != nil {
t.Fatal(err)
}
if _, active := lease.ExecutionDeadline(); !active {
t.Fatal("original attempt not resumed")
}
w2 := dial(api, registrations[1])
capacity(w2)
brief, stop := context.WithTimeout(ctx, 300*time.Millisecond)
_, err = w2.NextAssignment(brief)
stop()
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatal("restart lost key exclusion", err)
}
waiting, err := api.GetTask(ctx, tasks[1].ID)
if err != nil || waiting.AttemptCount != 0 {
t.Fatal("blocked key consumed attempt", err)
}
history, err := api.GetTaskAttempt(ctx, tasks[0].ID, first.AttemptID)
if err != nil || history.ConcurrencyToken != first.ConcurrencyToken {
t.Fatal("restart changed key token", err)
}
finishID := workerTestID(t)
finish := WorkerFinish{Ref: first.Attempt(), Outcome: WorkerSucceeded, Result: json.RawMessage(`{}`)}
if _, err = w1.Finish(ctx, finishID, finish); err != nil {
t.Fatal(err)
}
second, err := w2.NextAssignment(ctx)
if err != nil || second.ConcurrencyToken <= first.ConcurrencyToken {
t.Fatal("restart reset sequence", err)
}
if _, err = w2.Start(ctx, workerTestID(t), second.Attempt()); err != nil {
t.Fatal(err)
}
// 第二次许可已经开始,旧完成回执的重放不能释放它。
if out, err := w1.Finish(ctx, finishID, finish); err != nil || !out.Deduplicated {
t.Fatal("duplicate finish changed ownership", err)
}
if _, err = w2.Renew(ctx, workerTestID(t), second.Attempt()); err != nil {
t.Fatal(err)
}
if _, err = w2.Finish(ctx, workerTestID(t), WorkerFinish{Ref: second.Attempt(), Outcome: WorkerSucceeded, Result: json.RawMessage(`{}`)}); err != nil {
t.Fatal(err)
}
for _, task := range tasks {
waitRuntimeState(t, ctx, api, task.ID, TaskSucceeded)
}
}