mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-02 07:25:26 +08:00
314 lines
8.7 KiB
Go
314 lines
8.7 KiB
Go
package account
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"ds2api/internal/config"
|
|
)
|
|
|
|
func newPoolForTest(t *testing.T, maxInflight string) *Pool {
|
|
t.Helper()
|
|
t.Setenv("DS2API_ACCOUNT_MAX_INFLIGHT", maxInflight)
|
|
t.Setenv("DS2API_ACCOUNT_MAX_QUEUE", "")
|
|
t.Setenv("DS2API_CONFIG_JSON", `{
|
|
"keys":["k1"],
|
|
"accounts":[
|
|
{"email":"acc1@example.com","token":"token1"},
|
|
{"email":"acc2@example.com","token":"token2"}
|
|
]
|
|
}`)
|
|
store := config.LoadStore()
|
|
return NewPool(store)
|
|
}
|
|
|
|
func newSingleAccountPoolForTest(t *testing.T, maxInflight string) *Pool {
|
|
t.Helper()
|
|
t.Setenv("DS2API_ACCOUNT_MAX_INFLIGHT", maxInflight)
|
|
t.Setenv("DS2API_ACCOUNT_MAX_QUEUE", "")
|
|
t.Setenv("DS2API_CONFIG_JSON", `{
|
|
"keys":["k1"],
|
|
"accounts":[{"email":"acc1@example.com","token":"token1"}]
|
|
}`)
|
|
return NewPool(config.LoadStore())
|
|
}
|
|
|
|
func waitForWaitingCount(t *testing.T, pool *Pool, want int) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(800 * time.Millisecond)
|
|
for time.Now().Before(deadline) {
|
|
status := pool.Status()
|
|
if got, ok := status["waiting"].(int); ok && got == want {
|
|
return
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
status := pool.Status()
|
|
t.Fatalf("waiting count did not reach %d, current status=%v", want, status)
|
|
}
|
|
|
|
func TestPoolRoundRobinWithConcurrentSlots(t *testing.T) {
|
|
pool := newPoolForTest(t, "2")
|
|
|
|
order := make([]string, 0, 4)
|
|
for i := 0; i < 4; i++ {
|
|
acc, ok := pool.Acquire("", nil)
|
|
if !ok {
|
|
t.Fatalf("expected acquire success at step %d", i+1)
|
|
}
|
|
order = append(order, acc.Identifier())
|
|
}
|
|
want := []string{"acc1@example.com", "acc2@example.com", "acc1@example.com", "acc2@example.com"}
|
|
for i := range want {
|
|
if order[i] != want[i] {
|
|
t.Fatalf("unexpected order at %d: got %q want %q (full=%v)", i, order[i], want[i], order)
|
|
}
|
|
}
|
|
|
|
if _, ok := pool.Acquire("", nil); ok {
|
|
t.Fatalf("expected acquire to fail when all inflight slots are occupied")
|
|
}
|
|
|
|
pool.Release("acc1@example.com")
|
|
acc, ok := pool.Acquire("", nil)
|
|
if !ok || acc.Identifier() != "acc1@example.com" {
|
|
t.Fatalf("expected reacquire acc1 after releasing one slot, got ok=%v id=%q", ok, acc.Identifier())
|
|
}
|
|
}
|
|
|
|
func TestPoolTargetAccountInflightLimit(t *testing.T) {
|
|
pool := newPoolForTest(t, "2")
|
|
|
|
for i := 0; i < 2; i++ {
|
|
if _, ok := pool.Acquire("acc1@example.com", nil); !ok {
|
|
t.Fatalf("expected target acquire success at step %d", i+1)
|
|
}
|
|
}
|
|
if _, ok := pool.Acquire("acc1@example.com", nil); ok {
|
|
t.Fatalf("expected third acquire on same target to fail due to inflight limit")
|
|
}
|
|
}
|
|
|
|
func TestPoolConcurrentAcquireDistribution(t *testing.T) {
|
|
pool := newPoolForTest(t, "2")
|
|
|
|
start := make(chan struct{})
|
|
results := make(chan string, 6)
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 6; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
<-start
|
|
acc, ok := pool.Acquire("", nil)
|
|
if !ok {
|
|
results <- "FAIL"
|
|
return
|
|
}
|
|
results <- acc.Identifier()
|
|
}()
|
|
}
|
|
|
|
close(start)
|
|
wg.Wait()
|
|
close(results)
|
|
|
|
success := 0
|
|
fail := 0
|
|
perAccount := map[string]int{}
|
|
for id := range results {
|
|
if id == "FAIL" {
|
|
fail++
|
|
continue
|
|
}
|
|
success++
|
|
perAccount[id]++
|
|
}
|
|
if success != 4 || fail != 2 {
|
|
t.Fatalf("unexpected concurrent acquire result: success=%d fail=%d perAccount=%v", success, fail, perAccount)
|
|
}
|
|
for id, n := range perAccount {
|
|
if n > 2 {
|
|
t.Fatalf("account %s exceeded inflight limit: %d", id, n)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPoolStatusRecommendedConcurrencyDefault(t *testing.T) {
|
|
pool := newPoolForTest(t, "")
|
|
status := pool.Status()
|
|
|
|
if got, ok := status["max_inflight_per_account"].(int); !ok || got != 2 {
|
|
t.Fatalf("unexpected max_inflight_per_account: %#v", status["max_inflight_per_account"])
|
|
}
|
|
if got, ok := status["recommended_concurrency"].(int); !ok || got != 4 {
|
|
t.Fatalf("unexpected recommended_concurrency: %#v", status["recommended_concurrency"])
|
|
}
|
|
if got, ok := status["max_queue_size"].(int); !ok || got != 4 {
|
|
t.Fatalf("unexpected max_queue_size: %#v", status["max_queue_size"])
|
|
}
|
|
}
|
|
|
|
func TestPoolStatusRecommendedConcurrencyRespectsOverride(t *testing.T) {
|
|
pool := newPoolForTest(t, "3")
|
|
status := pool.Status()
|
|
|
|
if got, ok := status["max_inflight_per_account"].(int); !ok || got != 3 {
|
|
t.Fatalf("unexpected max_inflight_per_account: %#v", status["max_inflight_per_account"])
|
|
}
|
|
if got, ok := status["recommended_concurrency"].(int); !ok || got != 6 {
|
|
t.Fatalf("unexpected recommended_concurrency: %#v", status["recommended_concurrency"])
|
|
}
|
|
if got, ok := status["max_queue_size"].(int); !ok || got != 6 {
|
|
t.Fatalf("unexpected max_queue_size: %#v", status["max_queue_size"])
|
|
}
|
|
}
|
|
|
|
func TestPoolGlobalMaxInflightEnv(t *testing.T) {
|
|
t.Setenv("DS2API_ACCOUNT_MAX_INFLIGHT", "1")
|
|
t.Setenv("DS2API_GLOBAL_MAX_INFLIGHT", "4")
|
|
t.Setenv("DS2API_CONFIG_JSON", `{
|
|
"keys":["k1"],
|
|
"accounts":[
|
|
{"email":"acc1@example.com","token":"token1"},
|
|
{"email":"acc2@example.com","token":"token2"}
|
|
]
|
|
}`)
|
|
|
|
pool := NewPool(config.LoadStore())
|
|
status := pool.Status()
|
|
if got, ok := status["global_max_inflight"].(int); !ok || got != 4 {
|
|
t.Fatalf("unexpected global_max_inflight: %#v", status["global_max_inflight"])
|
|
}
|
|
if got, ok := status["max_inflight_per_account"].(int); !ok || got != 1 {
|
|
t.Fatalf("unexpected max_inflight_per_account: %#v", status["max_inflight_per_account"])
|
|
}
|
|
if got, ok := status["recommended_concurrency"].(int); !ok || got != 2 {
|
|
t.Fatalf("unexpected recommended_concurrency: %#v", status["recommended_concurrency"])
|
|
}
|
|
}
|
|
|
|
func TestPoolDropsLegacyTokenOnlyAccountOnLoad(t *testing.T) {
|
|
t.Setenv("DS2API_ACCOUNT_MAX_INFLIGHT", "1")
|
|
t.Setenv("DS2API_CONFIG_JSON", `{
|
|
"keys":["k1"],
|
|
"accounts":[{"token":"token-only-account"}]
|
|
}`)
|
|
|
|
pool := NewPool(config.LoadStore())
|
|
status := pool.Status()
|
|
if got, ok := status["total"].(int); !ok || got != 0 {
|
|
t.Fatalf("unexpected total in pool status: %#v", status["total"])
|
|
}
|
|
if got, ok := status["available"].(int); !ok || got != 0 {
|
|
t.Fatalf("unexpected available in pool status: %#v", status["available"])
|
|
}
|
|
|
|
if _, ok := pool.Acquire("", nil); ok {
|
|
t.Fatalf("expected acquire to fail for token-only account")
|
|
}
|
|
}
|
|
|
|
func TestPoolAcquireRotatesIntoTokenlessAccounts(t *testing.T) {
|
|
t.Setenv("DS2API_ACCOUNT_MAX_INFLIGHT", "1")
|
|
t.Setenv("DS2API_ACCOUNT_MAX_QUEUE", "")
|
|
t.Setenv("DS2API_CONFIG_JSON", `{
|
|
"keys":["k1"],
|
|
"accounts":[
|
|
{"email":"acc1@example.com","token":"token1"},
|
|
{"email":"acc2@example.com","token":""},
|
|
{"email":"acc3@example.com","token":""}
|
|
]
|
|
}`)
|
|
|
|
pool := NewPool(config.LoadStore())
|
|
for i, want := range []string{"acc1@example.com", "acc2@example.com", "acc3@example.com"} {
|
|
acc, ok := pool.Acquire("", nil)
|
|
if !ok {
|
|
t.Fatalf("expected acquire success at step %d", i+1)
|
|
}
|
|
if got := acc.Identifier(); got != want {
|
|
t.Fatalf("unexpected account at step %d: got %q want %q", i+1, got, want)
|
|
}
|
|
pool.Release(acc.Identifier())
|
|
}
|
|
}
|
|
|
|
func TestPoolAcquireWaitQueuesAndSucceedsAfterRelease(t *testing.T) {
|
|
pool := newSingleAccountPoolForTest(t, "1")
|
|
first, ok := pool.Acquire("", nil)
|
|
if !ok {
|
|
t.Fatal("expected first acquire to succeed")
|
|
}
|
|
|
|
type result struct {
|
|
id string
|
|
ok bool
|
|
}
|
|
resCh := make(chan result, 1)
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
go func() {
|
|
acc, ok := pool.AcquireWait(ctx, "", nil)
|
|
resCh <- result{id: acc.Identifier(), ok: ok}
|
|
}()
|
|
|
|
waitForWaitingCount(t, pool, 1)
|
|
pool.Release(first.Identifier())
|
|
|
|
select {
|
|
case res := <-resCh:
|
|
if !res.ok {
|
|
t.Fatal("expected queued acquire to succeed after release")
|
|
}
|
|
if res.id != "acc1@example.com" {
|
|
t.Fatalf("unexpected account id from queued acquire: %q", res.id)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for queued acquire result")
|
|
}
|
|
}
|
|
|
|
func TestPoolAcquireWaitQueueLimitReturnsFalse(t *testing.T) {
|
|
pool := newSingleAccountPoolForTest(t, "1")
|
|
first, ok := pool.Acquire("", nil)
|
|
if !ok {
|
|
t.Fatal("expected first acquire to succeed")
|
|
}
|
|
|
|
type result struct {
|
|
id string
|
|
ok bool
|
|
}
|
|
firstWaiter := make(chan result, 1)
|
|
ctx1, cancel1 := context.WithTimeout(context.Background(), 1200*time.Millisecond)
|
|
defer cancel1()
|
|
go func() {
|
|
acc, ok := pool.AcquireWait(ctx1, "", nil)
|
|
firstWaiter <- result{id: acc.Identifier(), ok: ok}
|
|
}()
|
|
waitForWaitingCount(t, pool, 1)
|
|
|
|
ctx2, cancel2 := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
|
defer cancel2()
|
|
start := time.Now()
|
|
if _, ok := pool.AcquireWait(ctx2, "", nil); ok {
|
|
t.Fatal("expected second queued acquire to fail when queue is full")
|
|
}
|
|
if time.Since(start) > 120*time.Millisecond {
|
|
t.Fatalf("queue-full acquire should fail fast, took %s", time.Since(start))
|
|
}
|
|
|
|
pool.Release(first.Identifier())
|
|
select {
|
|
case res := <-firstWaiter:
|
|
if !res.ok {
|
|
t.Fatal("expected first queued acquire to succeed after release")
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for first queued acquire")
|
|
}
|
|
}
|