feat: add automatic Worker runtime with lease recovery and bounded shutdown

This commit is contained in:
2026-09-17 14:29:28 +08:00
parent 1753fcbccb
commit da123bba69
22 changed files with 1784 additions and 17 deletions

View File

@@ -1,4 +1,5 @@
// Package orbit 是 Orbit 的独立 Go SDK,提供健康、任务、队列与 Worker 底层协议。
// WorkerConnection 不自动重连、续租或运行处理器;自动运行器与流程 SDK 仍在开发。
// WorkerConnection 是底层接口;NewWorker/Run 自动管理处理器、续租、恢复与排空。
// 流程 SDK 等后续能力按阶段继续开发。
// SDK 不导入服务端模型、迁移或数据库依赖。
package orbit

View File

@@ -1,5 +1,5 @@
// worker-protocol 演示一次文本规范化任务的真实执行与原消息重报。
// 这是底层协议示例;持续运行、自动续租与优雅退出由后续 Worker 运行器提供。
// 这是底层协议示例;持续运行、自动续租与优雅退出见 examples/worker 的运行器示例。
package main
import (

View File

@@ -0,0 +1,133 @@
// worker 演示自动 Worker:声明处理器,Run 管理连接/心跳/续租/恢复,取消后排空。
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"time"
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run() error {
token, queue := os.Getenv("ORBIT_TOKEN"), os.Getenv("ORBIT_QUEUE_ID")
if path := os.Getenv("ORBIT_CREDENTIALS_FILE"); path != "" {
raw, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read private credentials: %w", err)
}
var credentials struct {
Token string `json:"token"`
QueueID string `json:"queue_id"`
}
if json.Unmarshal(raw, &credentials) != nil {
return errors.New("invalid credentials file")
}
if token == "" {
token = credentials.Token
}
if queue == "" {
queue = credentials.QueueID
}
}
client, err := orbit.NewClient(os.Getenv("ORBIT_URL"), orbit.Options{Token: token})
if err != nil {
return err
}
operation, err := orbit.NewWorkerMessageID()
if err != nil {
return err
}
handler := "text.normalize.runtime." + operation
w, err := orbit.NewWorker(client, orbit.WorkerOptions{QueueID: queue, Capacity: 2, ShutdownTimeout: 5 * time.Second, Handlers: []orbit.WorkerHandler{{Capability: orbit.WorkerCapability{Handler: handler, HandlerVersion: "v1", PayloadVersion: "v1"}, Handle: normalize}}})
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
// 示例先受理,再启动消费者;生产通常是独立的长驻 Worker 进程。
var tasks []orbit.Task
for i, text := range []string{" hello orbit ", " reusable worker ", " durable result "} {
payload, _ := json.Marshal(struct {
Text string `json:"text"`
}{text})
accepted, err := client.SubmitTask(ctx, fmt.Sprintf("runtime-%s-%d", operation, i), orbit.SubmitTaskRequest{QueueID: queue, Handler: handler, HandlerVersion: "v1", PayloadVersion: "v1", Payload: payload, TaskTimeoutMS: 60000, Retry: orbit.RetryPolicy{MaxAttempts: 1, AttemptTimeoutMS: 10000}, RecoverySafety: orbit.RecoveryIdempotent})
if err != nil {
return err
}
tasks = append(tasks, accepted.Task)
}
workerCtx, stop := context.WithCancel(ctx)
done := make(chan error, 1)
go func() { done <- w.Run(workerCtx) }()
// 任一调用失败时也等待有界排空,不在 main 返回时静默丢掉执行中的 Worker。
waited := false
defer func() {
stop()
if !waited {
<-done
}
}()
for _, task := range tasks {
for {
current, err := client.GetTask(ctx, task.ID)
if err != nil {
return err
}
if current.State == orbit.TaskSucceeded {
fmt.Printf("task=%s state=%s result=%s\n", current.ID, current.State, current.Result)
break
}
if current.State == orbit.TaskFailed || current.State == orbit.TaskNeedsReview {
return fmt.Errorf("task %s requires attention: %s", current.ID, current.State)
}
select {
case err := <-done:
waited = true
return fmt.Errorf("Worker stopped before completion: %v", err)
case <-ctx.Done():
return ctx.Err()
case <-time.After(100 * time.Millisecond):
}
}
}
stop()
err = <-done
waited = true
if err != nil {
return err
}
stats := w.Stats()
if stats.Started != 3 || stats.ReportsConfirmed != 3 || stats.Running != 0 {
return errors.New("unexpected Worker completion counts")
}
fmt.Printf("worker_drained=true started=%d confirmed=%d running=%d\n", stats.Started, stats.ReportsConfirmed, stats.Running)
return nil
}
func normalize(ctx context.Context, execution orbit.WorkerExecution) (json.RawMessage, error) {
var in struct {
Text string `json:"text"`
}
if err := json.Unmarshal(execution.Task.Payload, &in); err != nil {
return nil, &orbit.WorkerFailure{Code: "INVALID_TEXT"}
}
// 模拟可取消的短工作;真实 I/O 也必须使用传入的 ctx。
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(200 * time.Millisecond):
}
return json.Marshal(struct {
Text string `json:"text"`
}{Text: strings.ToUpper(strings.TrimSpace(in.Text))})
}

130
sdk/go/worker_handler.go Normal file
View File

@@ -0,0 +1,130 @@
package orbit
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"strconv"
"strings"
)
type handlerCompletion struct {
attemptID string
result WorkerFinish
}
func (w *Worker) invoke(ctx context.Context, handler WorkerHandler, execution WorkerExecution, ref WorkerAttempt, results chan<- handlerCompletion, runDone <-chan struct{}) {
out := WorkerFinish{Ref: ref, Outcome: WorkerUnknown, ErrorCode: "HANDLER_ABORTED", Result: json.RawMessage(`{}`)}
completed := false
defer func() {
if recover() != nil {
out = unknownHandlerResult(ref, "HANDLER_PANIC")
} else if !completed {
out = unknownHandlerResult(ref, "HANDLER_ABORTED")
}
w.running.Add(-1)
select {
case results <- handlerCompletion{attemptID: ref.AttemptID, result: out}:
case <-runDone:
}
}()
if ctx.Err() != nil {
out = unknownHandlerResult(ref, "LEASE_LOST")
completed = true
return
}
w.started.Add(1)
result, err := handler.Handle(ctx, execution)
if err == nil {
result = append(json.RawMessage(nil), result...)
if validHandlerResult(result) {
out = WorkerFinish{Ref: ref, Outcome: WorkerSucceeded, Result: result}
} else {
out = unknownHandlerResult(ref, "INVALID_HANDLER_RESULT")
}
} else {
var known *WorkerFailure
if errors.As(err, &known) && known != nil && workerName(known.Code, 64) {
details := append(json.RawMessage(nil), known.Details...)
if len(details) == 0 {
details = json.RawMessage(`{}`)
}
if validHandlerResult(details) {
out = WorkerFinish{Ref: ref, Outcome: WorkerFailed, ErrorCode: known.Code, Result: details}
} else {
out = unknownHandlerResult(ref, "INVALID_HANDLER_RESULT")
}
} else if errors.Is(context.Cause(ctx), ErrExecutionLeaseLost) {
out = unknownHandlerResult(ref, "LEASE_LOST")
} else {
out = unknownHandlerResult(ref, "HANDLER_ERROR")
}
}
completed = true
}
func unknownHandlerResult(ref WorkerAttempt, code string) WorkerFinish {
return WorkerFinish{Ref: ref, Outcome: WorkerUnknown, ErrorCode: code, Result: json.RawMessage(`{}`)}
}
// validHandlerResult 提前拒绝服务端无法持久化的结果,避免失效报告无限重报。
// 只检验数值范围;不把精确业务数字改写成 float64。
func validHandlerResult(raw json.RawMessage) bool {
if len(raw) == 0 || len(raw) > 256<<10 || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return false
}
// 预留 task.finish 的信封/body 嵌套,避免合法结果单独校验后在帧校验阶段超深。
frame := append([]byte(`{"body":{"result":`), raw...)
frame = append(frame, []byte(`}}`)...)
if validateWorkerJSON(frame) != nil {
return false
}
d := json.NewDecoder(bytes.NewReader(raw))
d.UseNumber()
for {
value, err := d.Token()
if err == io.EOF {
return true
}
if err != nil {
return false
}
number, ok := value.(json.Number)
if !ok {
continue
}
s := string(number)
if len(s) > 256 {
return false
}
if _, err := strconv.ParseFloat(s, 64); err != nil {
return false
}
s = strings.TrimPrefix(s, "-")
exponent := 0
if i := strings.IndexAny(s, "eE"); i >= 0 {
var err error
exponent, err = strconv.Atoi(s[i+1:])
if err != nil || exponent < -308 || exponent > 308 {
return false
}
s = s[:i]
}
if i := strings.IndexByte(s, '.'); i >= 0 {
exponent -= len(s) - i - 1
s = s[:i] + s[i+1:]
}
s = strings.TrimLeft(s, "0")
if s == "" {
continue
}
for strings.HasSuffix(s, "0") {
s = s[:len(s)-1]
exponent++
}
if exponent < -308 || exponent > 308 || exponent+len(s)-1 > 308 {
return false
}
}
}

View File

@@ -49,17 +49,24 @@ func integrationWorkerClient(t *testing.T) (*Client, string, string) {
// 只丢响应,不伪造任务状态,测试凭据不进入 URL 或输出。
type workerFaultGateway struct {
drop atomic.Value
offline atomic.Bool
mu sync.Mutex
active map[*websocket.Conn]struct{}
dropped chan string
server *httptest.Server
}
func newWorkerFaultGateway(t *testing.T, upstream string) *workerFaultGateway {
t.Helper()
g := &workerFaultGateway{dropped: make(chan string, 4)}
g := &workerFaultGateway{dropped: make(chan string, 4), active: make(map[*websocket.Conn]struct{})}
g.drop.Store("")
ctx, cancel := context.WithCancel(context.Background())
var handlers sync.WaitGroup
g.server = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if g.offline.Load() {
http.Error(w, "gateway offline", 503)
return
}
handlers.Add(1)
defer handlers.Done()
bridge, stop := context.WithCancel(ctx)
@@ -78,6 +85,13 @@ func newWorkerFaultGateway(t *testing.T, upstream string) *workerFaultGateway {
return
}
defer down.CloseNow()
g.mu.Lock()
g.active[down] = struct{}{}
g.mu.Unlock()
defer func() { g.mu.Lock(); delete(g.active, down); g.mu.Unlock() }()
if g.offline.Load() {
return
}
down.SetReadLimit(512 << 10)
var mu sync.Mutex
var forwarding sync.WaitGroup
@@ -141,6 +155,17 @@ func newWorkerFaultGateway(t *testing.T, upstream string) *workerFaultGateway {
t.Cleanup(func() { cancel(); g.server.Close(); handlers.Wait() })
return g
}
func (g *workerFaultGateway) disconnectActive() {
g.mu.Lock()
active := make([]*websocket.Conn, 0, len(g.active))
for c := range g.active {
active = append(active, c)
}
g.mu.Unlock()
for _, c := range active {
c.CloseNow()
}
}
func (g *workerFaultGateway) expectDrop(t *testing.T, operation string) {
t.Helper()
select {

View File

@@ -0,0 +1,76 @@
package orbit
import (
"context"
"sync"
"time"
)
// executionGuard 独立于网络控制取消处理器,已到期/取消的上下文永不恢复。
type executionGuard struct {
ctx context.Context
cancel context.CancelCauseFunc
mu sync.Mutex
deadline time.Time
wake chan struct{}
done chan struct{}
}
func newExecutionGuard(parent context.Context, lease WorkerLease) (*executionGuard, bool) {
deadline, ok := lease.ExecutionDeadline()
if !ok {
return nil, false
}
ctx, cancel := context.WithCancelCause(parent)
g := &executionGuard{ctx: ctx, cancel: cancel, deadline: deadline, wake: make(chan struct{}, 1), done: make(chan struct{})}
go g.watch()
return g, true
}
func (g *executionGuard) watch() {
defer close(g.done)
timer := time.NewTimer(time.Until(g.until()))
defer timer.Stop()
for {
select {
case <-g.ctx.Done():
return
case <-g.wake:
case <-timer.C:
}
g.mu.Lock()
remaining := time.Until(g.deadline)
if remaining <= 0 {
g.cancel(ErrExecutionLeaseLost)
g.mu.Unlock()
return
}
timer.Reset(remaining)
g.mu.Unlock()
}
}
func (g *executionGuard) update(lease WorkerLease) bool {
g.mu.Lock()
defer g.mu.Unlock()
if g.ctx.Err() != nil || !time.Now().Before(g.deadline) {
g.cancel(ErrExecutionLeaseLost)
return false
}
deadline, ok := lease.ExecutionDeadline()
if !lease.Active || lease.State != "RUNNING" {
g.cancel(ErrExecutionLeaseLost)
return false
}
// Lease 是新鲜核验,不是历史 Receipt。新会话可能缩短有效窗口,必须遵守。
if !ok {
g.cancel(ErrExecutionLeaseLost)
return false
}
g.deadline = deadline
select {
case g.wake <- struct{}{}:
default:
}
return true
}
func (g *executionGuard) until() time.Time { g.mu.Lock(); defer g.mu.Unlock(); return g.deadline }
func (g *executionGuard) stop(cause error) { g.cancel(cause); <-g.done }

543
sdk/go/worker_runtime.go Normal file
View File

@@ -0,0 +1,543 @@
package orbit
import (
"context"
"errors"
"math/rand/v2"
"sync"
"sync/atomic"
"time"
)
type runtimeAttempt struct {
assignment WorkerAssignment
startID, renewID, finishID string
guard *executionGuard
invoked, returned, lost bool
result WorkerFinish
nextTry, nextRenew time.Time
retryDelay time.Duration
}
type runtimeCommand struct {
kind string
id string
job *runtimeAttempt
capacity WorkerCapacity
conn *WorkerConnection
}
type runtimeReply struct {
command *runtimeCommand
result WorkerCommandResult
welcome WorkerWelcome
err error
}
type runtimeConnected struct {
conn *WorkerConnection
leases map[string]WorkerLease
err error
}
type workerRuntime struct {
worker *Worker
ctx context.Context
jobs map[string]*runtimeAttempt
order []string
cursor int
conn *WorkerConnection
command *runtimeCommand
connecting bool
connectedOnce bool
draining, drainConfirmed bool
capacity uint32
sequence uint64
heartbeat, dialAt time.Time
backoff time.Duration
results chan handlerCompletion
replies chan runtimeReply
connections chan runtimeConnected
wg sync.WaitGroup
}
// Run 持续消费直到 ctx 取消或不可恢复的认证/协议错误。取消后先排空并在
// ShutdownTimeout 内收集在途结果;正常收尾返回 nil,超时返回 *WorkerRunError。
// 已到期的处理器上下文不会因重连复活;每个已知尝试在本进程最多调用一次处理器。
func (w *Worker) Run(parent context.Context) error {
if !w.used.CompareAndSwap(false, true) {
return ErrWorkerAlreadyRun
}
defer close(w.events)
if parent.Err() != nil {
return nil
}
ctx, cancel := context.WithCancel(context.WithoutCancel(parent))
var shutdownExpired atomic.Bool
watchDone := make(chan struct{})
go func() {
defer close(watchDone)
select {
case <-ctx.Done():
return
case <-parent.Done():
}
timer := time.NewTimer(w.options.ShutdownTimeout)
defer timer.Stop()
select {
case <-ctx.Done():
case <-timer.C:
shutdownExpired.Store(true)
cancel()
}
}()
r := &workerRuntime{worker: w, ctx: ctx, jobs: make(map[string]*runtimeAttempt), backoff: w.options.ReconnectMinDelay, results: make(chan handlerCompletion, w.options.Capacity), replies: make(chan runtimeReply, 2), connections: make(chan runtimeConnected, 1)}
defer func() {
cancel()
if r.conn != nil {
r.conn.Close()
}
for _, j := range r.jobs {
if j.guard != nil {
j.guard.stop(context.Canceled)
}
}
r.wg.Wait()
// 连接可能在退出与交付结果之间成功,仍需关闭;不留下新会话连接。
select {
case c := <-r.connections:
if c.conn != nil {
c.conn.Close()
}
default:
}
<-watchDone
}()
tick := time.NewTicker(20 * time.Millisecond)
defer tick.Stop()
stop := parent.Done()
for {
if ctx.Err() != nil {
cause := ctx.Err()
if shutdownExpired.Load() {
cause = ErrWorkerShutdownTimeout
}
return r.failure(cause)
}
if parent.Err() != nil && !r.draining {
r.draining = true
stop = nil
w.emit(WorkerEvent{Type: "draining"})
}
r.observeExpired()
if r.conn != nil && r.conn.Err() != nil {
if err := r.disconnect(r.conn.Err()); err != nil {
return r.failure(err)
}
}
// drain ACK 之前的派发已被底层读取器排进队列,收齐后才能宣布排空。
if r.conn != nil {
for len(r.conn.assignments) > 0 {
if err := r.accept(<-r.conn.assignments); err != nil {
return r.failure(err)
}
}
}
if r.draining && len(r.jobs) == 0 && (r.drainConfirmed || !r.connectedOnce && !r.connecting) {
return nil
}
if r.conn == nil && !r.connecting && !time.Now().Before(r.dialAt) {
r.connect()
}
if r.conn != nil && r.command == nil {
if err := r.dispatch(); err != nil {
return r.failure(err)
}
}
var assignments <-chan WorkerAssignment
var closed <-chan struct{}
if r.conn != nil {
assignments = r.conn.assignments
closed = r.conn.Done()
}
select {
case <-ctx.Done():
case <-stop:
stop = nil
case <-tick.C:
case <-closed:
case a := <-assignments:
if r.conn.Err() == nil {
if err := r.accept(a); err != nil {
return r.failure(err)
}
}
case done := <-r.results:
if j := r.jobs[done.attemptID]; j != nil {
j.returned = true
j.result = done.result
j.nextTry = time.Time{}
}
case reply := <-r.replies:
if err := r.receive(reply); err != nil {
return r.failure(err)
}
case connected := <-r.connections:
if err := r.establish(connected); err != nil {
return r.failure(err)
}
}
}
}
func (r *workerRuntime) failure(err error) error {
return &WorkerRunError{Cause: err, Unconfirmed: len(r.jobs), Running: r.worker.running.Load()}
}
func (r *workerRuntime) event(j *runtimeAttempt, kind, code string) {
r.worker.emit(WorkerEvent{Type: kind, TaskID: j.assignment.Task.ID, AttemptID: j.assignment.AttemptID, Code: code})
}
func (r *workerRuntime) accept(a WorkerAssignment) error {
if j := r.jobs[a.AttemptID]; j != nil {
if j.assignment.Attempt() != a.Attempt() {
return ErrWorkerProtocol
}
return nil
}
// 运行中失去授权但不退出的处理器仍占物理槽;允许接收一轮已在网络中的预占,
// 不突破处理器容量,也不让待核对身份无限累积。
if len(r.jobs) >= 2*int(r.worker.options.Capacity) {
return ErrWorkerBackpressure
}
id, err := NewWorkerMessageID()
if err != nil {
return err
}
r.jobs[a.AttemptID] = &runtimeAttempt{assignment: a, startID: id}
r.order = append(r.order, a.AttemptID)
return nil
}
func (r *workerRuntime) remove(j *runtimeAttempt) {
if j.guard != nil {
j.guard.stop(context.Canceled)
}
delete(r.jobs, j.assignment.AttemptID)
for i, id := range r.order {
if id == j.assignment.AttemptID {
r.order = append(r.order[:i], r.order[i+1:]...)
break
}
}
if len(r.order) > 0 {
r.cursor %= len(r.order)
} else {
r.cursor = 0
}
}
func (r *workerRuntime) observeExpired() {
for _, j := range r.jobs {
if j.guard != nil && j.guard.ctx.Err() != nil && !j.lost {
j.lost = true
r.event(j, "lease_lost", "LEASE_LOST")
}
}
}
func (r *workerRuntime) desiredCapacity() uint32 {
if r.draining {
return 0
}
ghosts := uint32(0)
for _, j := range r.jobs {
if j.returned || j.lost {
ghosts++
}
}
if ghosts >= r.worker.options.Capacity {
return 0
}
return r.worker.options.Capacity - ghosts
}
func (r *workerRuntime) connect() {
r.connecting = true
if r.connectedOnce {
r.worker.reconnects.Add(1)
r.worker.emit(WorkerEvent{Type: "reconnecting"})
}
refs := make([]WorkerAttempt, 0, len(r.jobs))
for _, j := range r.jobs {
refs = append(refs, j.assignment.Attempt())
}
r.wg.Add(1)
go func() {
defer r.wg.Done()
ctx, stop := context.WithTimeout(r.ctx, 30*time.Second)
defer stop()
c, err := r.worker.client.DialWorker(ctx, r.worker.registration)
result := runtimeConnected{conn: c, err: err, leases: make(map[string]WorkerLease, len(refs))}
if err == nil {
for _, ref := range refs {
id, e := NewWorkerMessageID()
if e != nil {
result.err = e
break
}
lease, e := c.Resume(ctx, id, ref)
if e != nil {
result.err = e
break
}
result.leases[ref.AttemptID] = lease
}
}
if result.err != nil && c != nil {
c.Close()
result.conn = nil
}
select {
case r.connections <- result:
case <-r.ctx.Done():
if result.conn != nil {
result.conn.Close()
}
}
}()
}
func (r *workerRuntime) establish(c runtimeConnected) error {
r.connecting = false
if c.err != nil {
if !workerRetryable(c.err) {
return c.err
}
r.deferReconnect()
return nil
}
r.conn = c.conn
r.connectedOnce = true
r.sequence = 0
r.heartbeat = time.Time{}
r.capacity = ^uint32(0)
r.drainConfirmed = false
r.draining = r.draining || c.conn.Welcome().Draining
for id, lease := range c.leases {
j := r.jobs[id]
if j == nil {
continue
}
if j.guard != nil {
if !j.guard.update(lease) {
j.lost = true
r.event(j, "lease_lost", "LEASE_LOST")
}
j.nextRenew = time.Time{}
}
if !j.invoked && !lease.Active {
r.event(j, "lease_lost", "NOT_STARTED")
r.remove(j)
}
}
r.worker.emit(WorkerEvent{Type: "connected"})
return nil
}
func (r *workerRuntime) deferReconnect() {
min := r.worker.options.ReconnectMinDelay
r.dialAt = time.Now().Add(min + time.Duration(rand.Int64N(int64(r.backoff-min)+1)))
r.backoff = minDuration(r.backoff*2, r.worker.options.ReconnectMaxDelay)
}
func (r *workerRuntime) disconnect(err error) error {
if r.conn != nil {
r.conn.Close()
r.conn = nil
}
r.command = nil
if !workerRetryable(err) {
return err
}
r.deferReconnect()
return nil
}
func workerRetryable(err error) bool {
if errors.Is(err, ErrWorkerDisconnected) || errors.Is(err, ErrWorkerClosed) || errors.Is(err, ErrWorkerBackpressure) || errors.Is(err, context.DeadlineExceeded) {
return true
}
var control *WorkerError
if errors.As(err, &control) {
return control.Code == "STALE_SESSION" || control.Code == "UNAVAILABLE"
}
var httpErr *HTTPError
return errors.As(err, &httpErr) && (httpErr.StatusCode == 429 || httpErr.StatusCode >= 500)
}
func minDuration(a, b time.Duration) time.Duration {
if a < b {
return a
}
return b
}
func (r *workerRuntime) dispatch() error {
now := time.Now()
capacity := r.desiredCapacity()
if !now.Before(r.heartbeat) || capacity != r.capacity || r.draining && !r.drainConfirmed {
id, err := NewWorkerMessageID()
if err != nil {
return err
}
r.sequence++
r.launch(&runtimeCommand{kind: "capacity", id: id, capacity: WorkerCapacity{Sequence: r.sequence, Capacity: capacity, Drain: r.draining}})
return nil
}
// 结果优先释放占用;其后照顾临近续租,再批准新处理器。每类内部轮转。
for _, kind := range []string{"finish", "renew", "start"} {
for n := 0; n < len(r.order); n++ {
i := (r.cursor + n) % len(r.order)
j := r.jobs[r.order[i]]
if now.Before(j.nextTry) {
continue
}
var id *string
switch kind {
case "finish":
if !j.returned {
continue
}
id = &j.finishID
case "renew":
if !j.invoked || j.lost || j.returned || now.Before(j.nextRenew) {
continue
}
id = &j.renewID
case "start":
if j.invoked || r.worker.running.Load() >= int64(r.worker.options.Capacity) {
continue
}
id = &j.startID
}
if *id == "" {
var err error
*id, err = NewWorkerMessageID()
if err != nil {
return err
}
}
r.cursor = (i + 1) % len(r.order)
r.launch(&runtimeCommand{kind: kind, id: *id, job: j})
return nil
}
}
return nil
}
func (r *workerRuntime) launch(command *runtimeCommand) {
command.conn = r.conn
r.command = command
// 操作所需值在 goroutine 启动前复制;运行器事件循环独占可变尝试状态。
var ref WorkerAttempt
var finish WorkerFinish
if command.job != nil {
ref = command.job.assignment.Attempt()
finish = command.job.result
}
r.wg.Add(1)
go func() {
defer r.wg.Done()
reply := runtimeReply{command: command}
switch command.kind {
case "capacity":
reply.welcome, reply.err = command.conn.Capacity(r.ctx, command.id, command.capacity)
case "start":
reply.result, reply.err = command.conn.Start(r.ctx, command.id, ref)
case "renew":
reply.result, reply.err = command.conn.Renew(r.ctx, command.id, ref)
case "finish":
reply.result, reply.err = command.conn.Finish(r.ctx, command.id, finish)
}
select {
case r.replies <- reply:
case <-r.ctx.Done():
}
}()
}
func (r *workerRuntime) receive(reply runtimeReply) error {
command := reply.command
if r.command != command || command.conn != r.conn {
return nil
}
r.command = nil
j := command.job
if reply.err != nil {
var control *WorkerError
if j != nil && errors.As(reply.err, &control) {
switch control.Code {
case "LEASE_LOST":
if command.kind == "finish" {
return reply.err
}
if j.guard != nil {
j.guard.stop(ErrExecutionLeaseLost)
j.lost = true
r.event(j, "lease_lost", control.Code)
} else {
r.remove(j)
}
return nil
case "EXECUTION_PAUSED", "UNAVAILABLE":
if j.retryDelay == 0 {
j.retryDelay = 200 * time.Millisecond
} else {
j.retryDelay = minDuration(j.retryDelay*2, 5*time.Second)
}
j.nextTry = time.Now().Add(j.retryDelay)
return nil
}
}
if workerRetryable(reply.err) {
return r.disconnect(reply.err)
}
return reply.err
}
r.backoff = r.worker.options.ReconnectMinDelay
if command.kind == "capacity" {
r.capacity = command.capacity.Capacity
r.drainConfirmed = command.capacity.Drain && reply.welcome.Draining
r.heartbeat = time.Now().Add(minDuration(5*time.Second, reply.welcome.ExpiresAt.Sub(reply.welcome.ServerTime)/3))
return nil
}
if command.kind == "finish" {
if reply.result.Receipt.Disposition == "APPLIED" {
r.worker.reports.Add(1)
r.event(j, "result_applied", string(reply.result.Receipt.TaskState))
} else {
r.worker.evidence.Add(1)
r.event(j, "result_evidence", string(reply.result.Receipt.TaskState))
}
r.remove(j)
return nil
}
if command.kind == "start" {
guard, ok := newExecutionGuard(r.ctx, reply.result.Lease)
if !ok {
r.event(j, "lease_lost", "START_NOT_AUTHORIZED")
r.remove(j)
return nil
}
j.guard = guard
j.invoked = true
r.worker.running.Add(1)
task := j.assignment.Task
task.Payload = append([]byte(nil), task.Payload...)
task.Result = append([]byte(nil), task.Result...)
execution := WorkerExecution{Task: task, AttemptID: j.assignment.AttemptID, Epoch: j.assignment.Epoch}
handler := r.worker.handlers[WorkerCapability{Handler: task.Handler, HandlerVersion: task.HandlerVersion, PayloadVersion: task.PayloadVersion}]
r.event(j, "handler_started", "")
go r.worker.invoke(guard.ctx, handler, execution, j.assignment.Attempt(), r.results, r.ctx.Done())
} else {
r.worker.renewals.Add(1)
j.renewID = ""
if !j.guard.update(reply.result.Lease) {
j.lost = true
r.event(j, "lease_lost", "LEASE_LOST")
}
}
j.nextTry = time.Time{}
j.retryDelay = 0
if !j.lost {
j.nextRenew = time.Now().Add(max(20*time.Millisecond, time.Until(j.guard.until())/3))
}
return nil
}

View File

@@ -0,0 +1,357 @@
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())
}
}

View File

@@ -0,0 +1,181 @@
package orbit
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"runtime"
"strings"
"testing"
"time"
)
func guardLease(duration time.Duration) WorkerLease {
return WorkerLease{Active: true, State: "RUNNING", deadline: time.Now().Add(duration)}
}
func TestExecutionGuardRenewsShortensAndNeverRevives(t *testing.T) {
parent, cancel := context.WithCancel(context.Background())
defer cancel()
g, ok := newExecutionGuard(parent, guardLease(80*time.Millisecond))
if !ok {
t.Fatal("missing guard")
}
defer g.stop(context.Canceled)
if !g.update(guardLease(500 * time.Millisecond)) {
t.Fatal("renew rejected")
}
select {
case <-g.ctx.Done():
t.Fatal("old lease cancelled a renewed handler")
case <-time.After(100 * time.Millisecond):
}
if !g.update(guardLease(35 * time.Millisecond)) {
t.Fatal("new short session rejected")
}
select {
case <-g.ctx.Done():
case <-time.After(200 * time.Millisecond):
t.Fatal("shorter session did not cancel handler")
}
if !errors.Is(context.Cause(g.ctx), ErrExecutionLeaseLost) {
t.Fatal(context.Cause(g.ctx))
}
if g.update(guardLease(time.Minute)) {
t.Fatal("expired handler revived")
}
}
func TestHandlerOutcomesAndAbort(t *testing.T) {
ref := WorkerAttempt{TaskID: workerTestTask, AttemptID: workerTestAttempt, Epoch: 1}
for _, test := range []struct {
name string
fn func(context.Context, WorkerExecution) (json.RawMessage, error)
out WorkerOutcome
code string
}{
{"success", func(context.Context, WorkerExecution) (json.RawMessage, error) {
return json.RawMessage(`{"n":9007199254740993}`), nil
}, WorkerSucceeded, ""},
{"known_failure", func(context.Context, WorkerExecution) (json.RawMessage, error) {
return nil, &WorkerFailure{Code: "INPUT_REJECTED", Details: json.RawMessage(`{"field":"name"}`)}
}, WorkerFailed, "INPUT_REJECTED"},
{"ordinary_error", func(context.Context, WorkerExecution) (json.RawMessage, error) {
return nil, errors.New("private error secret")
}, WorkerUnknown, "HANDLER_ERROR"},
{"panic", func(context.Context, WorkerExecution) (json.RawMessage, error) { panic("private panic secret") }, WorkerUnknown, "HANDLER_PANIC"},
{"goexit", func(context.Context, WorkerExecution) (json.RawMessage, error) { runtime.Goexit(); return nil, nil }, WorkerUnknown, "HANDLER_ABORTED"},
{"bad_json", func(context.Context, WorkerExecution) (json.RawMessage, error) {
return json.RawMessage(`{"n":1,"n":2}`), nil
}, WorkerUnknown, "INVALID_HANDLER_RESULT"},
{"number_range", func(context.Context, WorkerExecution) (json.RawMessage, error) { return json.RawMessage(`1e999`), nil }, WorkerUnknown, "INVALID_HANDLER_RESULT"},
} {
t.Run(test.name, func(t *testing.T) {
w := &Worker{}
w.running.Add(1)
done := make(chan handlerCompletion, 1)
go w.invoke(context.Background(), WorkerHandler{Handle: test.fn}, WorkerExecution{}, ref, done, make(chan struct{}))
select {
case got := <-done:
if got.result.Outcome != test.out || got.result.ErrorCode != test.code || strings.Contains(string(got.result.Result), "secret") {
t.Fatalf("unexpected classification: %s/%s", got.result.Outcome, got.result.ErrorCode)
}
case <-time.After(time.Second):
t.Fatal("handler lost result")
}
if w.running.Load() != 0 || w.started.Load() != 1 {
t.Fatal("handler counters leaked")
}
})
}
for _, raw := range []string{`null`, `0.1e-308`, strings.Repeat("[", 31) + "1" + strings.Repeat("]", 31), strings.Repeat("9", 257)} {
if validHandlerResult(json.RawMessage(raw)) {
t.Fatal("unsupported result passed runtime validation")
}
}
}
func TestWorkerOptionsAndSingleRun(t *testing.T) {
c, _ := NewClient("http://127.0.0.1:1", Options{Token: "test-token"})
opts := WorkerOptions{QueueID: workerTestQueue, Handlers: []WorkerHandler{{Capability: WorkerCapability{Handler: "test", HandlerVersion: "v1", PayloadVersion: "v1"}, Handle: func(context.Context, WorkerExecution) (json.RawMessage, error) { return json.RawMessage(`{}`), nil }}}}
w, err := NewWorker(c, opts)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := w.Run(ctx); err != nil {
t.Fatal(err)
}
if err := w.Run(ctx); !errors.Is(err, ErrWorkerAlreadyRun) {
t.Fatal("Worker identity reused after Run")
}
if _, open := <-w.Events(); open {
t.Fatal("event channel not closed")
}
for _, invalid := range []WorkerOptions{{QueueID: workerTestQueue}, {QueueID: workerTestQueue, Handlers: append(opts.Handlers, opts.Handlers...)}, {QueueID: workerTestQueue, Handlers: opts.Handlers, ShutdownTimeout: -1}} {
if _, err := NewWorker(c, invalid); err == nil {
t.Fatal("invalid Worker options accepted")
}
}
}
func TestWorkerShutdownBoundsPendingHandshake(t *testing.T) {
entered := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { close(entered); <-r.Context().Done() }))
defer server.Close()
c, _ := NewClient(server.URL, Options{Token: "test-token"})
w, err := NewWorker(c, WorkerOptions{QueueID: workerTestQueue, ShutdownTimeout: 50 * time.Millisecond, Handlers: []WorkerHandler{runtimeHandler("test", func(context.Context, WorkerExecution) (json.RawMessage, error) {
t.Error("unexpected handler")
return nil, nil
})}})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() { done <- w.Run(ctx) }()
select {
case <-entered:
case <-time.After(time.Second):
t.Fatal("handshake did not start")
}
began := time.Now()
cancel()
select {
case err := <-done:
if !errors.Is(err, ErrWorkerShutdownTimeout) {
t.Fatal(err)
}
case <-time.After(time.Second):
t.Fatal("shutdown blocked on pending handshake")
}
if time.Since(began) > 500*time.Millisecond {
t.Fatal("handshake exceeded shutdown bound")
}
}
func TestWorkerPermanentAuthenticationFailureStops(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "private upstream response", http.StatusUnauthorized)
}))
defer server.Close()
c, _ := NewClient(server.URL, Options{Token: "test-token"})
w, err := NewWorker(c, WorkerOptions{QueueID: workerTestQueue, Handlers: []WorkerHandler{runtimeHandler("test", func(context.Context, WorkerExecution) (json.RawMessage, error) {
t.Error("unauthorized handler invoked")
return nil, nil
})}})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
err = w.Run(ctx)
var stopped *WorkerRunError
var status *HTTPError
if !errors.As(err, &stopped) || !errors.As(err, &status) || status.StatusCode != 401 || stopped.Unconfirmed != 0 || strings.Contains(err.Error(), "private") {
t.Fatalf("unexpected authentication failure: %v", err)
}
}

View File

@@ -0,0 +1,152 @@
package orbit
import (
"context"
"encoding/json"
"errors"
"fmt"
"sync/atomic"
"time"
)
var ErrExecutionLeaseLost = errors.New("Worker execution lease expired or lost")
var ErrWorkerShutdownTimeout = errors.New("Worker shutdown grace period expired")
var ErrWorkerAlreadyRun = errors.New("Worker instance may only Run once")
// WorkerExecution 是交给业务处理器的输入,不包含实例或尝试秘密。
type WorkerExecution struct {
Task Task // Task 固定任务输入;业务副作用应使用稳定业务身份去重。
AttemptID string // AttemptID 当前尝试,用于核查与追踪,不代替业务幂等键。
Epoch uint64 // Epoch 执行代次;接入方资源可用它拒绝旧尝试写入。
}
func (e WorkerExecution) String() string {
return fmt.Sprintf("WorkerExecution{TaskID:%s AttemptID:%s Epoch:%d data:redacted}", e.Task.ID, e.AttemptID, e.Epoch)
}
func (e WorkerExecution) GoString() string { return e.String() }
// WorkerHandler 注册精确版本的业务函数;函数必须遵守 ctx 的取消。
// 普通 error/panic 表示效果未知;明确的业务失败返回 *WorkerFailure。
type WorkerHandler struct {
Capability WorkerCapability // Capability 处理器和输入版本,整个 Worker 生命周期固定。
Handle func(context.Context, WorkerExecution) (json.RawMessage, error) // Handle 返回非 null JSON;不得忽略租约取消继续发起副作用。
}
// WorkerFailure 仅用于接入方能够确认的业务失败,不用于网络超时或未知外部效果。
type WorkerFailure struct {
Code string // Code 1–64 位稳定 ASCII 分类,禁止放原始异常正文。
Details json.RawMessage // Details 可选非 null JSON,最多 256 KiB;空值使用空对象。
}
func (e *WorkerFailure) Error() string { return "Worker handler reported a known failure" }
// WorkerOptions 配置自动执行器。一个实例绑定一个队列,只能 Run 一次。
type WorkerOptions struct {
QueueID string // QueueID 凭据有消费权限的队列。
Capacity uint32 // Capacity 最大并发处理器数,默认 4,范围 1–256。
Handlers []WorkerHandler // Handlers 1–32 种精确能力,不可重复,函数不得为空。
ShutdownTimeout time.Duration // ShutdownTimeout 收到 Run context 取消后的收尾总时限,默认 30 秒,最多 5 分钟。
ReconnectMinDelay time.Duration // ReconnectMinDelay 重连退避起点,默认 200 毫秒,至少 10 毫秒。
ReconnectMaxDelay time.Duration // ReconnectMaxDelay 重连退避上限,默认 5 秒,最多 30 秒;采用区间内抖动。
}
// WorkerEvent 是有界、可能丢弃的本地观察通知,不是持久化审计或结果订阅。
type WorkerEvent struct {
Type string // Type connected、reconnecting、handler_started、lease_lost、result_applied、result_evidence、draining。
TaskID string // TaskID 涉及的任务;连接通知为空。
AttemptID string // AttemptID 涉及的尝试;连接通知为空。
Code string // Code 稳定错误/结果分类,不包含输入、结果或秘密。
}
// WorkerStats 是当前进程的观测值,重启清零,不作为持久化成功率。
type WorkerStats struct {
Running int64 // Running 尚未返回的处理器数,包含不遵守取消的函数。
Started uint64 // Started 本进程实际调用处理器的次数。
RenewalsConfirmed uint64 // RenewalsConfirmed 收到当前续租确认的次数,不代表任务成功。
ReportsConfirmed uint64 // ReportsConfirmed 收到 APPLIED 的结果报告数,包含 UNKNOWN。
EvidenceConfirmed uint64 // EvidenceConfirmed 收到 EVIDENCE_ONLY 的报告数。
Reconnects uint64 // Reconnects 首次连接之后发起重连的次数。
DroppedEvents uint64 // DroppedEvents 本地通知缓冲满时丢弃的条数。
}
// WorkerRunError 表示执行器未能正常收尾;不能据此认为外部效果已撤销。
type WorkerRunError struct {
Cause error // Cause 连接/权限/协议错误或收尾超时,支持 errors.Is/As。
Unconfirmed int // Unconfirmed 尚未完成控制核对的已知尝试数。
Running int64 // Running 返回时仍未退出的处理器数;Go 无法强制杀死 goroutine。
}
func (e *WorkerRunError) Error() string {
return fmt.Sprintf("Worker stopped with %d unconfirmed attempts and %d running handlers: %v", e.Unconfirmed, e.Running, e.Cause)
}
func (e *WorkerRunError) Unwrap() error { return e.Cause }
// Worker 自动管理注册、心跳、续租、重连和有界排空;任务权威状态仍在服务端。
type Worker struct {
client *Client
options WorkerOptions
registration WorkerRegistration
handlers map[WorkerCapability]WorkerHandler
used atomic.Bool
events chan WorkerEvent
running atomic.Int64
started atomic.Uint64
renewals atomic.Uint64
reports atomic.Uint64
evidence atomic.Uint64
reconnects atomic.Uint64
dropped atomic.Uint64
}
func NewWorker(client *Client, options WorkerOptions) (*Worker, error) {
if client == nil || client.token == "" {
return nil, errors.New("authenticated Orbit client is required")
}
if options.Capacity == 0 {
options.Capacity = 4
}
if options.ShutdownTimeout == 0 {
options.ShutdownTimeout = 30 * time.Second
}
if options.ReconnectMinDelay == 0 {
options.ReconnectMinDelay = 200 * time.Millisecond
}
if options.ReconnectMaxDelay == 0 {
options.ReconnectMaxDelay = 5 * time.Second
}
if options.ShutdownTimeout < time.Millisecond || options.ShutdownTimeout > 5*time.Minute || options.ReconnectMinDelay < 10*time.Millisecond || options.ReconnectMaxDelay < options.ReconnectMinDelay || options.ReconnectMaxDelay > 30*time.Second {
return nil, errors.New("invalid Worker shutdown or reconnect duration")
}
capabilities := make([]WorkerCapability, 0, len(options.Handlers))
handlers := make(map[WorkerCapability]WorkerHandler, len(options.Handlers))
for _, h := range options.Handlers {
if h.Handle == nil {
return nil, errors.New("Worker handler function is required")
}
capabilities = append(capabilities, h.Capability)
handlers[h.Capability] = h
}
r, err := NewWorkerRegistration(options.QueueID, options.Capacity, capabilities)
if err != nil {
return nil, err
}
options.Handlers = append([]WorkerHandler(nil), options.Handlers...)
return &Worker{client: client, options: options, registration: r, handlers: handlers, events: make(chan WorkerEvent, 128)}, nil
}
// Events 返回只读通知流;无需读取也不会阻塞执行,Run 返回时关闭。
func (w *Worker) Events() <-chan WorkerEvent { return w.events }
func (w *Worker) Stats() WorkerStats {
return WorkerStats{Running: w.running.Load(), Started: w.started.Load(), RenewalsConfirmed: w.renewals.Load(), ReportsConfirmed: w.reports.Load(), EvidenceConfirmed: w.evidence.Load(), Reconnects: w.reconnects.Load(), DroppedEvents: w.dropped.Load()}
}
func (w *Worker) emit(event WorkerEvent) {
select {
case w.events <- event:
default:
w.dropped.Add(1)
}
}
func (w *Worker) String() string {
return fmt.Sprintf("Worker{QueueID:%s Capacity:%d secrets:redacted}", w.options.QueueID, w.options.Capacity)
}
func (w *Worker) GoString() string { return w.String() }