新增基于任务状态、执行租约和 Worker 心跳的孤儿 River Job 精确识别,每 15 秒将确认失活的 Job 恢复为可重试,避免等待 River 一小时通用救援窗口。运行时恢复同步清除中断及终态任务的 admission、执行令牌和并发租约,防止状态残留污染队列指标。正常运行中的长任务仍由 running 状态和活跃心跳保护。验证通过完整 Go 测试、go vet、竞态检查、迁移安全检查和 PostgreSQL 18 集成测试。
182 lines
6.2 KiB
Go
182 lines
6.2 KiB
Go
package runner
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
|
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
|
)
|
|
|
|
func TestAsyncQueueClientEnqueuesWithoutExecutionWorker(t *testing.T) {
|
|
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
|
|
if databaseURL == "" {
|
|
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run the queue client integration test")
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
db, err := store.Connect(ctx, databaseURL)
|
|
if err != nil {
|
|
t.Fatalf("connect store: %v", err)
|
|
}
|
|
t.Cleanup(db.Close)
|
|
|
|
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
|
|
task, err := db.CreateTask(ctx, store.CreateTaskInput{
|
|
Kind: "images.edits",
|
|
Model: "queue-client-" + suffix,
|
|
Request: map[string]any{"prompt": "enqueue without execution worker"},
|
|
Async: true,
|
|
RunMode: "simulation",
|
|
}, &auth.User{ID: "queue-client-" + suffix, Source: "gateway"})
|
|
if err != nil {
|
|
t.Fatalf("create task: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = db.Pool().Exec(context.Background(), `DELETE FROM gateway_tasks WHERE id = $1::uuid`, task.ID)
|
|
})
|
|
|
|
service := New(config.Config{AppEnv: "test"}, db, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
service.StartAsyncQueueClient(ctx)
|
|
if err := service.EnqueueAsyncTask(ctx, task); err != nil {
|
|
t.Fatalf("enqueue task without execution worker: %v", err)
|
|
}
|
|
|
|
queued, err := db.GetTask(ctx, task.ID)
|
|
if err != nil {
|
|
t.Fatalf("load queued task: %v", err)
|
|
}
|
|
if queued.RiverJobID <= 0 {
|
|
t.Fatal("queue client did not persist a River job ID")
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = db.Pool().Exec(context.Background(), `DELETE FROM river_job WHERE id = $1`, queued.RiverJobID)
|
|
})
|
|
|
|
var attemptedByCount int
|
|
if err := db.Pool().QueryRow(ctx, `
|
|
SELECT COALESCE(cardinality(attempted_by), 0)
|
|
FROM river_job
|
|
WHERE id = $1`, queued.RiverJobID).Scan(&attemptedByCount); err != nil {
|
|
t.Fatalf("load River job: %v", err)
|
|
}
|
|
if attemptedByCount != 0 {
|
|
t.Fatalf("control-only queue client executed the job: attempted_by=%d", attemptedByCount)
|
|
}
|
|
}
|
|
|
|
func TestRecoverOrphanedAsyncRiverJobRequiresInactiveWorker(t *testing.T) {
|
|
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
|
|
if databaseURL == "" {
|
|
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run the orphaned River job integration test")
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
db, err := store.Connect(ctx, databaseURL)
|
|
if err != nil {
|
|
t.Fatalf("connect store: %v", err)
|
|
}
|
|
t.Cleanup(db.Close)
|
|
service := New(config.Config{AppEnv: "test"}, db, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
service.StartAsyncQueueClient(ctx)
|
|
|
|
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
|
|
createQueuedTask := func(label string) store.GatewayTask {
|
|
t.Helper()
|
|
task, createErr := db.CreateTask(ctx, store.CreateTaskInput{
|
|
Kind: "images.edits",
|
|
Model: "orphan-recovery-" + label + "-" + suffix,
|
|
Request: map[string]any{"prompt": "recover orphaned River job"},
|
|
Async: true,
|
|
RunMode: "simulation",
|
|
}, &auth.User{ID: "orphan-recovery-" + suffix, Source: "gateway"})
|
|
if createErr != nil {
|
|
t.Fatalf("create %s task: %v", label, createErr)
|
|
}
|
|
if enqueueErr := service.EnqueueAsyncTask(ctx, task); enqueueErr != nil {
|
|
t.Fatalf("enqueue %s task: %v", label, enqueueErr)
|
|
}
|
|
queued, getErr := db.GetTask(ctx, task.ID)
|
|
if getErr != nil {
|
|
t.Fatalf("load %s task: %v", label, getErr)
|
|
}
|
|
return queued
|
|
}
|
|
|
|
orphaned := createQueuedTask("orphaned")
|
|
protected := createQueuedTask("protected")
|
|
staleWorkerID := "orphan-recovery-stale-" + suffix
|
|
activeWorkerID := "orphan-recovery-active-" + suffix
|
|
t.Cleanup(func() {
|
|
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cleanupCancel()
|
|
_, _ = db.Pool().Exec(cleanupCtx, `DELETE FROM gateway_worker_instances WHERE instance_id = ANY($1::text[])`, []string{staleWorkerID, activeWorkerID})
|
|
_, _ = db.Pool().Exec(cleanupCtx, `DELETE FROM river_job WHERE id = ANY($1::bigint[])`, []int64{orphaned.RiverJobID, protected.RiverJobID})
|
|
_, _ = db.Pool().Exec(cleanupCtx, `DELETE FROM gateway_tasks WHERE id = ANY($1::uuid[])`, []string{orphaned.ID, protected.ID})
|
|
})
|
|
|
|
if _, err := db.Pool().Exec(ctx, `
|
|
INSERT INTO gateway_worker_instances (
|
|
instance_id, status, desired_capacity, allocated_capacity, heartbeat_at, updated_at
|
|
)
|
|
VALUES
|
|
($1, 'active', 1, 0, now() - interval '2 minutes', now() - interval '2 minutes'),
|
|
($2, 'active', 1, 1, now(), now())`,
|
|
staleWorkerID,
|
|
activeWorkerID,
|
|
); err != nil {
|
|
t.Fatalf("seed worker heartbeats: %v", err)
|
|
}
|
|
if _, err := db.Pool().Exec(ctx, `
|
|
UPDATE river_job
|
|
SET state = 'running',
|
|
attempt = 1,
|
|
attempted_at = now() - interval '1 minute',
|
|
attempted_by = ARRAY[
|
|
CASE id
|
|
WHEN $1::bigint THEN $3
|
|
WHEN $2::bigint THEN $4
|
|
END
|
|
]::text[]
|
|
WHERE id = ANY($5::bigint[])`,
|
|
orphaned.RiverJobID,
|
|
protected.RiverJobID,
|
|
staleWorkerID+"-exec-1-test",
|
|
activeWorkerID+"-exec-1-test",
|
|
[]int64{orphaned.RiverJobID, protected.RiverJobID},
|
|
); err != nil {
|
|
t.Fatalf("mark River jobs running: %v", err)
|
|
}
|
|
|
|
recovered, err := db.RecoverOrphanedAsyncRiverJobs(ctx, 30*time.Second, 10)
|
|
if err != nil {
|
|
t.Fatalf("recover orphaned River jobs: %v", err)
|
|
}
|
|
if recovered != 1 {
|
|
t.Fatalf("recovered jobs=%d, want 1", recovered)
|
|
}
|
|
var orphanedState, protectedState string
|
|
if err := db.Pool().QueryRow(ctx, `SELECT state::text FROM river_job WHERE id = $1`, orphaned.RiverJobID).Scan(&orphanedState); err != nil {
|
|
t.Fatalf("read orphaned River job state: %v", err)
|
|
}
|
|
if err := db.Pool().QueryRow(ctx, `SELECT state::text FROM river_job WHERE id = $1`, protected.RiverJobID).Scan(&protectedState); err != nil {
|
|
t.Fatalf("read protected River job state: %v", err)
|
|
}
|
|
if orphanedState != "retryable" || protectedState != "running" {
|
|
t.Fatalf("River states orphaned=%s protected=%s, want retryable/running", orphanedState, protectedState)
|
|
}
|
|
recovered, err = db.RecoverOrphanedAsyncRiverJobs(ctx, 30*time.Second, 10)
|
|
if err != nil || recovered != 0 {
|
|
t.Fatalf("second orphan recovery count=%d err=%v, want idempotent zero", recovered, err)
|
|
}
|
|
}
|