248 lines
8.0 KiB
Go
248 lines
8.0 KiB
Go
package provider
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"proxy-pool/internal/controller/pool"
|
|
"proxy-pool/internal/domain/activitypool"
|
|
proxyDomain "proxy-pool/internal/domain/proxy"
|
|
)
|
|
|
|
func TestUpstreamRuntimeReadsInventoryAndFetchesOnlyInsideLeaderTerm(t *testing.T) {
|
|
var inventoryReads atomic.Int64
|
|
fetched := make(chan struct{}, 1)
|
|
runtime, err := NewUpstreamRuntime(UpstreamRuntimeConfig{
|
|
Provider: runtimeProviderConfig("provider-a"),
|
|
ReconcilePolicy: runtimeReconcilePolicy(),
|
|
ReconcileInterval: 10 * time.Millisecond,
|
|
}, UpstreamRuntimeDependencies{
|
|
Coordinator: coordinatorFunc(func(
|
|
ctx context.Context,
|
|
upstreamID string,
|
|
limits CoordinationLimits,
|
|
work func(context.Context, LeaderSession) error,
|
|
) error {
|
|
if upstreamID != "provider-a" || limits.MaxTotal != 10 || limits.MaxInFlight != 1 {
|
|
t.Errorf("coordination = (%q, %+v)", upstreamID, limits)
|
|
}
|
|
return work(ctx, unlimitedLeaderSession{})
|
|
}),
|
|
Inventory: inventoryReaderFunc(func(context.Context, string, time.Duration) (pool.InventorySnapshot, error) {
|
|
inventoryReads.Add(1)
|
|
return pool.InventorySnapshot{}, nil
|
|
}),
|
|
Adapter: adapterFunc(func(context.Context) (FetchResponse, error) {
|
|
fetched <- struct{}{}
|
|
return FetchResponse{Body: []byte("fixture")}, nil
|
|
}),
|
|
Parser: parserFunc(func(context.Context, []byte) ([]proxyDomain.Proxy, error) {
|
|
return []proxyDomain.Proxy{{ID: "proxy-1"}}, nil
|
|
}),
|
|
Activity: activitySinkFunc(func(context.Context, string, activitypool.FetchedBatch) (activitypool.UpsertResult, error) {
|
|
return activitypool.UpsertResult{Accepted: 1, Inserted: 1}, nil
|
|
}),
|
|
Results: resultRecorderFunc(func(Result) {}),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewUpstreamRuntime(): %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan error, 1)
|
|
go func() { done <- runtime.Run(ctx) }()
|
|
select {
|
|
case <-fetched:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for leader fetch")
|
|
}
|
|
cancel()
|
|
if err := <-done; !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("Run() error = %v, want context cancellation", err)
|
|
}
|
|
if got := inventoryReads.Load(); got == 0 {
|
|
t.Fatal("inventory was not read inside leader term")
|
|
}
|
|
}
|
|
|
|
func TestUpstreamRuntimeFailsClosedUntilInventoryReadRecovers(t *testing.T) {
|
|
var inventoryReads atomic.Int64
|
|
var providerCalls atomic.Int64
|
|
fetched := make(chan struct{}, 1)
|
|
runtime, err := NewUpstreamRuntime(UpstreamRuntimeConfig{
|
|
Provider: runtimeProviderConfig("provider-a"),
|
|
ReconcilePolicy: runtimeReconcilePolicy(),
|
|
ReconcileInterval: 10 * time.Millisecond,
|
|
}, UpstreamRuntimeDependencies{
|
|
Coordinator: coordinatorFunc(func(
|
|
ctx context.Context,
|
|
_ string,
|
|
_ CoordinationLimits,
|
|
work func(context.Context, LeaderSession) error,
|
|
) error {
|
|
return work(ctx, unlimitedLeaderSession{})
|
|
}),
|
|
Inventory: inventoryReaderFunc(func(context.Context, string, time.Duration) (pool.InventorySnapshot, error) {
|
|
if inventoryReads.Add(1) == 1 {
|
|
return pool.InventorySnapshot{}, errors.New("inventory unavailable")
|
|
}
|
|
return pool.InventorySnapshot{}, nil
|
|
}),
|
|
Adapter: adapterFunc(func(context.Context) (FetchResponse, error) {
|
|
providerCalls.Add(1)
|
|
fetched <- struct{}{}
|
|
return FetchResponse{Body: []byte("fixture")}, nil
|
|
}),
|
|
Parser: parserFunc(func(context.Context, []byte) ([]proxyDomain.Proxy, error) {
|
|
return []proxyDomain.Proxy{{ID: "proxy-1"}}, nil
|
|
}),
|
|
Activity: activitySinkFunc(func(context.Context, string, activitypool.FetchedBatch) (activitypool.UpsertResult, error) {
|
|
return activitypool.UpsertResult{Accepted: 1, Inserted: 1}, nil
|
|
}),
|
|
Results: resultRecorderFunc(func(Result) {}),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewUpstreamRuntime(): %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan error, 1)
|
|
go func() { done <- runtime.Run(ctx) }()
|
|
select {
|
|
case <-fetched:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for recovered inventory fetch")
|
|
}
|
|
cancel()
|
|
<-done
|
|
if got := inventoryReads.Load(); got < 2 {
|
|
t.Fatalf("inventory reads = %d, want recovery retry", got)
|
|
}
|
|
if got := providerCalls.Load(); got != 1 {
|
|
t.Fatalf("provider calls = %d, want one call after recovery", got)
|
|
}
|
|
}
|
|
|
|
func TestUpstreamRuntimeDelegatesRequestIntervalOnlyToCoordinator(t *testing.T) {
|
|
providerConfig := runtimeProviderConfig("provider-a")
|
|
providerConfig.RequestInterval = 500 * time.Millisecond
|
|
fetched := make(chan struct{}, 2)
|
|
runtime, err := NewUpstreamRuntime(UpstreamRuntimeConfig{
|
|
Provider: providerConfig,
|
|
ReconcilePolicy: runtimeReconcilePolicy(),
|
|
ReconcileInterval: 5 * time.Millisecond,
|
|
}, UpstreamRuntimeDependencies{
|
|
Coordinator: coordinatorFunc(func(
|
|
ctx context.Context,
|
|
_ string,
|
|
limits CoordinationLimits,
|
|
work func(context.Context, LeaderSession) error,
|
|
) error {
|
|
if limits.RequestInterval != 500*time.Millisecond {
|
|
t.Errorf("distributed RequestInterval = %s, want 500ms", limits.RequestInterval)
|
|
}
|
|
return work(ctx, unlimitedLeaderSession{})
|
|
}),
|
|
Inventory: inventoryReaderFunc(func(context.Context, string, time.Duration) (pool.InventorySnapshot, error) {
|
|
return pool.InventorySnapshot{}, nil
|
|
}),
|
|
Adapter: adapterFunc(func(context.Context) (FetchResponse, error) {
|
|
fetched <- struct{}{}
|
|
return FetchResponse{Body: []byte("fixture")}, nil
|
|
}),
|
|
Parser: parserFunc(func(context.Context, []byte) ([]proxyDomain.Proxy, error) {
|
|
return []proxyDomain.Proxy{{ID: "proxy-1"}}, nil
|
|
}),
|
|
Activity: activitySinkFunc(func(context.Context, string, activitypool.FetchedBatch) (activitypool.UpsertResult, error) {
|
|
return activitypool.UpsertResult{Accepted: 1, Inserted: 1}, nil
|
|
}),
|
|
Results: resultRecorderFunc(func(Result) {}),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewUpstreamRuntime(): %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan error, 1)
|
|
go func() { done <- runtime.Run(ctx) }()
|
|
for range 2 {
|
|
select {
|
|
case <-fetched:
|
|
case <-time.After(150 * time.Millisecond):
|
|
cancel()
|
|
<-done
|
|
t.Fatal("local Provider reconciler duplicated the distributed request interval")
|
|
}
|
|
}
|
|
cancel()
|
|
<-done
|
|
}
|
|
|
|
func TestNewUpstreamRuntimeRejectsInvalidDependencies(t *testing.T) {
|
|
validConfig := UpstreamRuntimeConfig{
|
|
Provider: runtimeProviderConfig("provider-a"),
|
|
ReconcilePolicy: runtimeReconcilePolicy(),
|
|
ReconcileInterval: time.Second,
|
|
}
|
|
if runtime, err := NewUpstreamRuntime(validConfig, UpstreamRuntimeDependencies{}); err == nil || runtime != nil {
|
|
t.Fatalf("NewUpstreamRuntime() = (%v, %v), want invalid dependencies", runtime, err)
|
|
}
|
|
}
|
|
|
|
func TestInitialReconcileDelayIsStableAndBounded(t *testing.T) {
|
|
const interval = time.Second
|
|
first := initialReconcileDelay("provider-a", interval)
|
|
if first != initialReconcileDelay("provider-a", interval) {
|
|
t.Fatal("initial reconcile delay is not stable")
|
|
}
|
|
if first < 0 || first >= 250*time.Millisecond {
|
|
t.Fatalf("initial reconcile delay = %s, want [0, 250ms)", first)
|
|
}
|
|
if other := initialReconcileDelay("provider-b", interval); other == first {
|
|
t.Fatalf("different upstreams have the same initial delay %s", first)
|
|
}
|
|
}
|
|
|
|
func runtimeProviderConfig(upstreamID string) Config {
|
|
return Config{
|
|
UpstreamID: upstreamID, Timeout: time.Second, MaxAttempts: 1,
|
|
MaxInFlight: 1, MaxTotal: 10, MaxSize: 10,
|
|
}
|
|
}
|
|
|
|
func runtimeReconcilePolicy() pool.ReconcilePolicy {
|
|
return pool.ReconcilePolicy{
|
|
MinimumAvailableSlots: 1, TargetAvailableSlots: 2,
|
|
ExpectedPerFetch: 1, ExpectedSlotsPerFetch: 1,
|
|
}
|
|
}
|
|
|
|
type coordinatorFunc func(
|
|
context.Context,
|
|
string,
|
|
CoordinationLimits,
|
|
func(context.Context, LeaderSession) error,
|
|
) error
|
|
|
|
func (f coordinatorFunc) RunLeader(
|
|
ctx context.Context,
|
|
upstreamID string,
|
|
limits CoordinationLimits,
|
|
work func(context.Context, LeaderSession) error,
|
|
) error {
|
|
return f(ctx, upstreamID, limits, work)
|
|
}
|
|
|
|
type inventoryReaderFunc func(context.Context, string, time.Duration) (pool.InventorySnapshot, error)
|
|
|
|
func (f inventoryReaderFunc) ReadInventory(
|
|
ctx context.Context,
|
|
upstreamID string,
|
|
safetyMargin time.Duration,
|
|
) (pool.InventorySnapshot, error) {
|
|
return f(ctx, upstreamID, safetyMargin)
|
|
}
|