Очередь job получила lease и fencing, телеметрия и прогресс deploy сохраняются, ревизия пишет manifest входов. Обновление префиксов не переписывает неизменённый снимок, большие наборы идут через COPY, а устаревшая ревизия не попадает в deploy. Бандл ноды кэшируется, prune и постановка job больше не держат горячий путь. Co-authored-by: Cursor <[email protected]>
144 lines
3.9 KiB
Go
144 lines
3.9 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"evobgp/internal/store"
|
|
)
|
|
|
|
func TestOperationLimitsSharedCapacityAndWindows(t *testing.T) {
|
|
l := newOperationLimiter()
|
|
l.maxActive = 2
|
|
l.maxTenantActive = 1
|
|
l.rates = [3]int{1, 2, 2}
|
|
now := time.Now()
|
|
a, _ := l.acquire("a", lookupOperation, now)
|
|
if a == nil {
|
|
t.Fatal("first denied")
|
|
}
|
|
if r, _ := l.acquire("a", cdnPreviewOperation, now); r != nil {
|
|
t.Fatal("tenant concurrency bypass")
|
|
}
|
|
b, _ := l.acquire("b", revisionPreviewOperation, now)
|
|
if b == nil {
|
|
t.Fatal("other tenant denied")
|
|
}
|
|
if r, _ := l.acquire("c", lookupOperation, now); r != nil {
|
|
t.Fatal("global concurrency bypass")
|
|
}
|
|
a()
|
|
a()
|
|
b()
|
|
if l.active != 0 {
|
|
t.Fatal("release not idempotent")
|
|
}
|
|
if r, retry := l.acquire("a", lookupOperation, now.Add(time.Second)); r != nil || retry != 59 {
|
|
t.Fatalf("rate bypass/retry admitted=%t seconds=%d", r != nil, retry)
|
|
}
|
|
c, _ := l.acquire("a", cdnPreviewOperation, now.Add(time.Second))
|
|
if c == nil {
|
|
t.Fatal("independent operation denied")
|
|
}
|
|
c()
|
|
d, _ := l.acquire("a", lookupOperation, now.Add(time.Minute))
|
|
if d == nil {
|
|
t.Fatal("expired window not reset")
|
|
}
|
|
d()
|
|
}
|
|
|
|
func TestOperationTenantMapBoundWithoutActiveEviction(t *testing.T) {
|
|
l := newOperationLimiter()
|
|
l.maxTenants = 1
|
|
now := time.Now()
|
|
a, _ := l.acquire("a", lookupOperation, now)
|
|
if r, _ := l.acquire("b", lookupOperation, now.Add(2*time.Minute)); r != nil {
|
|
t.Fatal("active entry evicted")
|
|
}
|
|
a()
|
|
b, _ := l.acquire("b", lookupOperation, now.Add(3*time.Minute))
|
|
if b == nil || len(l.tenants) != 1 {
|
|
t.Fatal("idle expiry did not free bounded state")
|
|
}
|
|
b()
|
|
}
|
|
|
|
func TestOperationConcurrentAdmissionBound(t *testing.T) {
|
|
l := newOperationLimiter()
|
|
l.maxActive = 4
|
|
l.maxTenantActive = 4
|
|
l.rates = [3]int{100, 100, 100}
|
|
results := make(chan func(), 32)
|
|
var wg sync.WaitGroup
|
|
for range 32 {
|
|
wg.Add(1)
|
|
go func() { defer wg.Done(); r, _ := l.acquire("tenant", lookupOperation, time.Now()); results <- r }()
|
|
}
|
|
wg.Wait()
|
|
close(results)
|
|
accepted := 0
|
|
for r := range results {
|
|
if r != nil {
|
|
accepted++
|
|
r()
|
|
}
|
|
}
|
|
if accepted != 4 || l.active != 0 {
|
|
t.Fatalf("accepted=%d active=%d", accepted, l.active)
|
|
}
|
|
}
|
|
|
|
type waitingRevisionStore struct {
|
|
store.Backend
|
|
entered chan struct{}
|
|
unblock chan struct{}
|
|
}
|
|
|
|
func (s waitingRevisionStore) GetRevision(tenant, id string) (*store.Revision, error) {
|
|
s.entered <- struct{}{}
|
|
<-s.unblock
|
|
return s.Backend.GetRevision(tenant, id)
|
|
}
|
|
|
|
func TestRevisionQuotaHeldUntilWorkReturns(t *testing.T) {
|
|
t.Setenv("EVOBGP_API_EXPENSIVE_TENANT_CONCURRENT", "1")
|
|
t.Setenv("EVOBGP_API_REVISION_PREVIEW_PER_MINUTE", "30")
|
|
m := store.NewMemory()
|
|
m.SeedDemo()
|
|
tenant, _, _, revision, _ := m.DemoIDs()
|
|
entered := make(chan struct{}, 2)
|
|
unblock := make(chan struct{})
|
|
s := &Server{store: waitingRevisionStore{Backend: m, entered: entered, unblock: unblock}}
|
|
request := func() *http.Request {
|
|
r := httptest.NewRequest(http.MethodGet, "/v1/revisions/"+revision+"/preview", nil)
|
|
r.SetPathValue("revision_id", revision)
|
|
return r.WithContext(context.WithValue(r.Context(), authCtxKey, Auth{TenantID: tenant, Role: "operator"}))
|
|
}
|
|
ctx, cancel := context.WithCancel(request().Context())
|
|
defer cancel()
|
|
first := httptest.NewRecorder()
|
|
done := make(chan struct{})
|
|
go func() { defer close(done); s.handleRevisionPreview(first, request().WithContext(ctx)) }()
|
|
<-entered
|
|
cancel() // Backend deliberately ignores cancellation: capacity stays reserved.
|
|
second := httptest.NewRecorder()
|
|
s.handleRevisionPreview(second, request())
|
|
if second.Code != http.StatusTooManyRequests || second.Header().Get("Retry-After") == "" {
|
|
close(unblock)
|
|
<-done
|
|
t.Fatalf("quota: %d %s", second.Code, second.Body.String())
|
|
}
|
|
close(unblock)
|
|
<-done
|
|
third := httptest.NewRecorder()
|
|
s.handleRevisionPreview(third, request())
|
|
if third.Code != http.StatusOK {
|
|
t.Fatalf("release failed: %d %s", third.Code, third.Body.String())
|
|
}
|
|
}
|