River 初始化遇到瞬时数据库超时时最多重试三次,并确保启动异常时先取消后台 LISTEN 和 Worker 上下文再关闭连接池,避免不就绪 Pod 卡在退出路径。Worker 维护循环每 15 秒执行幂等运行时恢复,使旧实例中断任务在执行租约到期后自动重排并继续触发孤儿 Job 救援。验证通过完整 Go 测试、go vet、竞态检查和迁移安全检查。
241 lines
7.3 KiB
Go
241 lines
7.3 KiB
Go
package runner
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
|
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
|
)
|
|
|
|
type fakeAsyncExecutionClient struct {
|
|
startErr error
|
|
stopGate <-chan struct{}
|
|
started atomic.Int64
|
|
stopped atomic.Int64
|
|
stopCancelled atomic.Int64
|
|
}
|
|
|
|
func TestRetryAsyncQueueStartupRecoversTransientFailures(t *testing.T) {
|
|
transient := errors.New("transient River startup failure")
|
|
var attempts atomic.Int32
|
|
var retries atomic.Int32
|
|
err := retryAsyncQueueStartup(
|
|
context.Background(),
|
|
3,
|
|
time.Millisecond,
|
|
func() error {
|
|
if attempts.Add(1) < 3 {
|
|
return transient
|
|
}
|
|
return nil
|
|
},
|
|
func(attempt int, err error) {
|
|
if attempt < 1 || !errors.Is(err, transient) {
|
|
t.Fatalf("unexpected retry callback attempt=%d err=%v", attempt, err)
|
|
}
|
|
retries.Add(1)
|
|
},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("retry startup: %v", err)
|
|
}
|
|
if attempts.Load() != 3 || retries.Load() != 2 {
|
|
t.Fatalf("startup attempts=%d retries=%d, want 3/2", attempts.Load(), retries.Load())
|
|
}
|
|
}
|
|
|
|
func TestRetryAsyncQueueStartupStopsWhenContextIsCancelled(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
var attempts atomic.Int32
|
|
err := retryAsyncQueueStartup(
|
|
ctx,
|
|
3,
|
|
time.Hour,
|
|
func() error {
|
|
attempts.Add(1)
|
|
cancel()
|
|
return errors.New("startup failed")
|
|
},
|
|
nil,
|
|
)
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("retry startup error=%v, want context cancelled", err)
|
|
}
|
|
if attempts.Load() != 1 {
|
|
t.Fatalf("startup attempts=%d, want 1", attempts.Load())
|
|
}
|
|
}
|
|
|
|
func (c *fakeAsyncExecutionClient) Start(context.Context) error {
|
|
c.started.Add(1)
|
|
return c.startErr
|
|
}
|
|
|
|
func (c *fakeAsyncExecutionClient) Stop(ctx context.Context) error {
|
|
c.stopped.Add(1)
|
|
if c.stopGate == nil {
|
|
return nil
|
|
}
|
|
select {
|
|
case <-c.stopGate:
|
|
return nil
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
|
|
func (c *fakeAsyncExecutionClient) StopAndCancel(context.Context) error {
|
|
c.stopCancelled.Add(1)
|
|
return nil
|
|
}
|
|
|
|
func TestResizeAsyncWorkerCapacityStartsReplacementBeforeGracefulDrain(t *testing.T) {
|
|
oldClient := &fakeAsyncExecutionClient{}
|
|
drainGate := make(chan struct{})
|
|
oldClient.stopGate = drainGate
|
|
newClient := &fakeAsyncExecutionClient{}
|
|
service := asyncWorkerManagerTestService(1)
|
|
service.riverExecutionClient = oldClient
|
|
service.asyncCapacityLoader = fixedAsyncCapacity(3)
|
|
service.asyncClientFactory = func(capacity int) (asyncExecutionClient, error) {
|
|
if capacity != 3 {
|
|
t.Fatalf("factory capacity=%d, want=3", capacity)
|
|
}
|
|
return newClient, nil
|
|
}
|
|
|
|
service.resizeAsyncWorkerCapacity(context.Background())
|
|
if newClient.started.Load() != 1 {
|
|
t.Fatalf("replacement start count=%d, want=1", newClient.started.Load())
|
|
}
|
|
if service.riverExecutionClient != newClient || service.riverWorkerCapacity != 3 {
|
|
t.Fatalf("replacement was not installed: capacity=%d client=%T", service.riverWorkerCapacity, service.riverExecutionClient)
|
|
}
|
|
waitForAtomicValue(t, &oldClient.stopped, 1)
|
|
if oldClient.stopCancelled.Load() != 0 {
|
|
t.Fatal("graceful drain cancelled an already running old task")
|
|
}
|
|
close(drainGate)
|
|
waitForDrainingClients(t, service, 0)
|
|
}
|
|
|
|
func TestResizeAsyncWorkerCapacityFailuresKeepCurrentClient(t *testing.T) {
|
|
t.Run("refresh failure", func(t *testing.T) {
|
|
oldClient := &fakeAsyncExecutionClient{}
|
|
service := asyncWorkerManagerTestService(4)
|
|
service.riverExecutionClient = oldClient
|
|
service.asyncCapacityLoader = func(context.Context, int) (store.AsyncWorkerCapacitySnapshot, error) {
|
|
return store.AsyncWorkerCapacitySnapshot{}, errors.New("database unavailable")
|
|
}
|
|
service.asyncClientFactory = func(int) (asyncExecutionClient, error) {
|
|
t.Fatal("factory must not run after refresh failure")
|
|
return nil, nil
|
|
}
|
|
service.resizeAsyncWorkerCapacity(context.Background())
|
|
if service.riverExecutionClient != oldClient || service.riverWorkerCapacity != 4 {
|
|
t.Fatal("refresh failure replaced the current client")
|
|
}
|
|
})
|
|
|
|
t.Run("create failure", func(t *testing.T) {
|
|
oldClient := &fakeAsyncExecutionClient{}
|
|
service := asyncWorkerManagerTestService(4)
|
|
service.riverExecutionClient = oldClient
|
|
service.asyncCapacityLoader = fixedAsyncCapacity(8)
|
|
service.asyncClientFactory = func(int) (asyncExecutionClient, error) {
|
|
return nil, errors.New("factory failed")
|
|
}
|
|
service.resizeAsyncWorkerCapacity(context.Background())
|
|
if service.riverExecutionClient != oldClient || service.riverWorkerCapacity != 4 {
|
|
t.Fatal("create failure replaced the current client")
|
|
}
|
|
})
|
|
|
|
t.Run("start failure", func(t *testing.T) {
|
|
oldClient := &fakeAsyncExecutionClient{}
|
|
failedClient := &fakeAsyncExecutionClient{startErr: errors.New("start failed")}
|
|
service := asyncWorkerManagerTestService(4)
|
|
service.riverExecutionClient = oldClient
|
|
service.asyncCapacityLoader = fixedAsyncCapacity(8)
|
|
service.asyncClientFactory = func(int) (asyncExecutionClient, error) {
|
|
return failedClient, nil
|
|
}
|
|
service.resizeAsyncWorkerCapacity(context.Background())
|
|
if service.riverExecutionClient != oldClient || service.riverWorkerCapacity != 4 {
|
|
t.Fatal("start failure replaced the current client")
|
|
}
|
|
if failedClient.stopCancelled.Load() != 1 {
|
|
t.Fatal("failed replacement client was not cleaned up")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestStopAsyncWorkersIncludesCurrentAndDrainingClients(t *testing.T) {
|
|
current := &fakeAsyncExecutionClient{}
|
|
draining := &fakeAsyncExecutionClient{}
|
|
service := asyncWorkerManagerTestService(2)
|
|
service.riverExecutionClient = current
|
|
service.riverDrainingClients[draining] = struct{}{}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
service.stopAsyncWorkersOnShutdown(ctx)
|
|
if current.stopCancelled.Load() != 1 || draining.stopCancelled.Load() != 1 {
|
|
t.Fatalf("shutdown cancellations current=%d draining=%d, want 1/1", current.stopCancelled.Load(), draining.stopCancelled.Load())
|
|
}
|
|
}
|
|
|
|
func asyncWorkerManagerTestService(capacity int) *Service {
|
|
return &Service{
|
|
cfg: config.Config{
|
|
AsyncWorkerHardLimit: 2048,
|
|
AsyncWorkerRefreshIntervalSeconds: 5,
|
|
},
|
|
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
riverDrainingClients: make(map[asyncExecutionClient]struct{}),
|
|
riverWorkerCapacity: capacity,
|
|
riverExecutionClient: nil,
|
|
}
|
|
}
|
|
|
|
func fixedAsyncCapacity(capacity int) func(context.Context, int) (store.AsyncWorkerCapacitySnapshot, error) {
|
|
return func(context.Context, int) (store.AsyncWorkerCapacitySnapshot, error) {
|
|
return store.AsyncWorkerCapacitySnapshot{Capacity: capacity, Desired: capacity, HardLimit: 2048}, nil
|
|
}
|
|
}
|
|
|
|
func waitForAtomicValue(t *testing.T, value *atomic.Int64, want int64) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if value.Load() == want {
|
|
return
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
t.Fatalf("atomic value=%d, want=%d", value.Load(), want)
|
|
}
|
|
|
|
func waitForDrainingClients(t *testing.T, service *Service, want int) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(time.Second)
|
|
for time.Now().Before(deadline) {
|
|
service.riverMu.RLock()
|
|
count := len(service.riverDrainingClients)
|
|
service.riverMu.RUnlock()
|
|
if count == want {
|
|
return
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
service.riverMu.RLock()
|
|
count := len(service.riverDrainingClients)
|
|
service.riverMu.RUnlock()
|
|
t.Fatalf("draining client count=%d, want=%d", count, want)
|
|
}
|