feat: add activity health and inventory contracts
This commit is contained in:
parent
c8e64758da
commit
3da836aeff
@ -153,6 +153,7 @@ func TestServiceIdempotentReplayKeepsOriginalExtractionTime(t *testing.T) {
|
|||||||
upserted, err := store.UpsertFetched(context.Background(), "provider-a", activitypool.FetchedBatch{
|
upserted, err := store.UpsertFetched(context.Background(), "provider-a", activitypool.FetchedBatch{
|
||||||
ObservedAt: firstTime,
|
ObservedAt: firstTime,
|
||||||
ConfiguredTTL: 2 * time.Minute,
|
ConfiguredTTL: 2 * time.Minute,
|
||||||
|
MaxSize: 100,
|
||||||
Proxies: []proxyDomain.Proxy{{
|
Proxies: []proxyDomain.Proxy{{
|
||||||
ID: "p1", Scheme: proxyDomain.SchemeHTTP, Host: "192.0.2.10", Port: 8080,
|
ID: "p1", Scheme: proxyDomain.SchemeHTTP, Host: "192.0.2.10", Port: 8080,
|
||||||
State: proxyDomain.StateAvailable, LastCheckedAt: &checkedAt,
|
State: proxyDomain.StateAvailable, LastCheckedAt: &checkedAt,
|
||||||
|
|||||||
@ -301,7 +301,7 @@ func newTestActivityPool(t *testing.T, now time.Time, proxyIDs ...string) *activ
|
|||||||
}
|
}
|
||||||
store := activitypool.NewMemoryPool()
|
store := activitypool.NewMemoryPool()
|
||||||
result, err := store.UpsertFetched(context.Background(), "provider-a", activitypool.FetchedBatch{
|
result, err := store.UpsertFetched(context.Background(), "provider-a", activitypool.FetchedBatch{
|
||||||
ObservedAt: now, ConfiguredTTL: 10 * time.Minute, Proxies: proxies,
|
ObservedAt: now, ConfiguredTTL: 10 * time.Minute, MaxSize: 100, Proxies: proxies,
|
||||||
})
|
})
|
||||||
if err != nil || result.Inserted != len(proxyIDs) {
|
if err != nil || result.Inserted != len(proxyIDs) {
|
||||||
t.Fatalf("UpsertFetched() = %+v, %v", result, err)
|
t.Fatalf("UpsertFetched() = %+v, %v", result, err)
|
||||||
|
|||||||
@ -19,6 +19,7 @@ type Config struct {
|
|||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
MaxAttempts int
|
MaxAttempts int
|
||||||
MaxInFlight int
|
MaxInFlight int
|
||||||
|
MaxSize int
|
||||||
TTL time.Duration
|
TTL time.Duration
|
||||||
AllocationSafetyMargin time.Duration
|
AllocationSafetyMargin time.Duration
|
||||||
Retry RetryConfig
|
Retry RetryConfig
|
||||||
@ -62,7 +63,8 @@ func NewReconciler(config Config, ports Ports, runtimes ...Runtime) (*Reconciler
|
|||||||
if config.UpstreamID == "" {
|
if config.UpstreamID == "" {
|
||||||
return nil, fmt.Errorf("new provider reconciler: upstream ID is required")
|
return nil, fmt.Errorf("new provider reconciler: upstream ID is required")
|
||||||
}
|
}
|
||||||
if config.RequestInterval < 0 || config.Timeout <= 0 || config.MaxAttempts <= 0 || config.MaxInFlight <= 0 {
|
if config.RequestInterval < 0 || config.Timeout <= 0 || config.MaxAttempts <= 0 ||
|
||||||
|
config.MaxInFlight <= 0 || config.MaxSize <= 0 {
|
||||||
return nil, fmt.Errorf("new provider reconciler: fetch limits must be positive")
|
return nil, fmt.Errorf("new provider reconciler: fetch limits must be positive")
|
||||||
}
|
}
|
||||||
if config.TTL < 0 || config.AllocationSafetyMargin < 0 ||
|
if config.TTL < 0 || config.AllocationSafetyMargin < 0 ||
|
||||||
@ -213,6 +215,7 @@ func (r *Reconciler) fetchAttempt(ctx context.Context, attempt int) (FetchRespon
|
|||||||
ObservedAt: r.runtime.Clock.Now().UTC(),
|
ObservedAt: r.runtime.Clock.Now().UTC(),
|
||||||
ConfiguredTTL: r.config.TTL,
|
ConfiguredTTL: r.config.TTL,
|
||||||
AllocationSafetyMargin: r.config.AllocationSafetyMargin,
|
AllocationSafetyMargin: r.config.AllocationSafetyMargin,
|
||||||
|
MaxSize: r.config.MaxSize,
|
||||||
Proxies: retained,
|
Proxies: retained,
|
||||||
})
|
})
|
||||||
candidateErr = err
|
candidateErr = err
|
||||||
|
|||||||
@ -31,6 +31,7 @@ func TestReconcilerWritesProviderTTLPolicyToEphemeralPool(t *testing.T) {
|
|||||||
Timeout: time.Second,
|
Timeout: time.Second,
|
||||||
MaxAttempts: 1,
|
MaxAttempts: 1,
|
||||||
MaxInFlight: 1,
|
MaxInFlight: 1,
|
||||||
|
MaxSize: 321,
|
||||||
TTL: 30 * time.Second,
|
TTL: 30 * time.Second,
|
||||||
AllocationSafetyMargin: 3 * time.Second,
|
AllocationSafetyMargin: 3 * time.Second,
|
||||||
}, ports, Runtime{Clock: clock})
|
}, ports, Runtime{Clock: clock})
|
||||||
@ -44,7 +45,7 @@ func TestReconcilerWritesProviderTTLPolicyToEphemeralPool(t *testing.T) {
|
|||||||
}
|
}
|
||||||
batch := <-batches
|
batch := <-batches
|
||||||
if !batch.ObservedAt.Equal(now) || batch.ConfiguredTTL != 30*time.Second ||
|
if !batch.ObservedAt.Equal(now) || batch.ConfiguredTTL != 30*time.Second ||
|
||||||
batch.AllocationSafetyMargin != 3*time.Second {
|
batch.AllocationSafetyMargin != 3*time.Second || batch.MaxSize != 321 {
|
||||||
t.Fatalf("activity batch = %+v", batch)
|
t.Fatalf("activity batch = %+v", batch)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -57,6 +58,7 @@ func TestReconcilerCoalescesConcurrentNotifications(t *testing.T) {
|
|||||||
Timeout: time.Second,
|
Timeout: time.Second,
|
||||||
MaxAttempts: 1,
|
MaxAttempts: 1,
|
||||||
MaxInFlight: 1,
|
MaxInFlight: 1,
|
||||||
|
MaxSize: 100,
|
||||||
}, Ports{
|
}, Ports{
|
||||||
Adapter: adapterFunc(func(context.Context) (FetchResponse, error) {
|
Adapter: adapterFunc(func(context.Context) (FetchResponse, error) {
|
||||||
calls.Add(1)
|
calls.Add(1)
|
||||||
@ -117,6 +119,7 @@ func TestReconcilerEnforcesRequestInterval(t *testing.T) {
|
|||||||
Timeout: time.Second,
|
Timeout: time.Second,
|
||||||
MaxAttempts: 1,
|
MaxAttempts: 1,
|
||||||
MaxInFlight: 1,
|
MaxInFlight: 1,
|
||||||
|
MaxSize: 100,
|
||||||
}, successfulPorts(func() { calledAt <- clock.Now() }, results), Runtime{
|
}, successfulPorts(func() { calledAt <- clock.Now() }, results), Runtime{
|
||||||
Clock: clock,
|
Clock: clock,
|
||||||
Sleeper: sleeper,
|
Sleeper: sleeper,
|
||||||
@ -167,6 +170,7 @@ func TestReconcilerRetriesErrorsWithExponentialBackoffAndJitter(t *testing.T) {
|
|||||||
Timeout: time.Second,
|
Timeout: time.Second,
|
||||||
MaxAttempts: 3,
|
MaxAttempts: 3,
|
||||||
MaxInFlight: 1,
|
MaxInFlight: 1,
|
||||||
|
MaxSize: 100,
|
||||||
Retry: RetryConfig{
|
Retry: RetryConfig{
|
||||||
Initial: 100 * time.Millisecond,
|
Initial: 100 * time.Millisecond,
|
||||||
Max: time.Second,
|
Max: time.Second,
|
||||||
@ -244,6 +248,7 @@ func TestReconcilerClassifiesFetchResults(t *testing.T) {
|
|||||||
Timeout: time.Second,
|
Timeout: time.Second,
|
||||||
MaxAttempts: 1,
|
MaxAttempts: 1,
|
||||||
MaxInFlight: 1,
|
MaxInFlight: 1,
|
||||||
|
MaxSize: 100,
|
||||||
}, ports)
|
}, ports)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewReconciler(): %v", err)
|
t.Fatalf("NewReconciler(): %v", err)
|
||||||
@ -277,6 +282,7 @@ func TestReconcilerHonorsRetryAfterBeforeBackoff(t *testing.T) {
|
|||||||
Timeout: time.Second,
|
Timeout: time.Second,
|
||||||
MaxAttempts: 2,
|
MaxAttempts: 2,
|
||||||
MaxInFlight: 1,
|
MaxInFlight: 1,
|
||||||
|
MaxSize: 100,
|
||||||
Retry: RetryConfig{Initial: 50 * time.Millisecond, Max: time.Second, Jitter: 20},
|
Retry: RetryConfig{Initial: 50 * time.Millisecond, Max: time.Second, Jitter: 20},
|
||||||
}, ports, Runtime{Clock: clock, Sleeper: sleeper, Random: fixedRandom(1)})
|
}, ports, Runtime{Clock: clock, Sleeper: sleeper, Random: fixedRandom(1)})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -317,6 +323,7 @@ func TestReconcilerCapsRetryAfter(t *testing.T) {
|
|||||||
Timeout: time.Second,
|
Timeout: time.Second,
|
||||||
MaxAttempts: 2,
|
MaxAttempts: 2,
|
||||||
MaxInFlight: 1,
|
MaxInFlight: 1,
|
||||||
|
MaxSize: 100,
|
||||||
Retry: RetryConfig{Initial: 50 * time.Millisecond, Max: 2 * time.Second},
|
Retry: RetryConfig{Initial: 50 * time.Millisecond, Max: 2 * time.Second},
|
||||||
}, ports, Runtime{Clock: clock, Sleeper: sleeper})
|
}, ports, Runtime{Clock: clock, Sleeper: sleeper})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -371,6 +378,7 @@ func TestReconcilerAppliesAttemptTimeoutToEveryPort(t *testing.T) {
|
|||||||
Timeout: 250 * time.Millisecond,
|
Timeout: 250 * time.Millisecond,
|
||||||
MaxAttempts: 1,
|
MaxAttempts: 1,
|
||||||
MaxInFlight: 1,
|
MaxInFlight: 1,
|
||||||
|
MaxSize: 100,
|
||||||
}, ports)
|
}, ports)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewReconciler(): %v", err)
|
t.Fatalf("NewReconciler(): %v", err)
|
||||||
@ -401,6 +409,7 @@ func TestReconcilerDropsNotificationFanoutWhileFetchIsInFlight(t *testing.T) {
|
|||||||
Timeout: time.Second,
|
Timeout: time.Second,
|
||||||
MaxAttempts: 1,
|
MaxAttempts: 1,
|
||||||
MaxInFlight: 1,
|
MaxInFlight: 1,
|
||||||
|
MaxSize: 100,
|
||||||
}, ports)
|
}, ports)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewReconciler(): %v", err)
|
t.Fatalf("NewReconciler(): %v", err)
|
||||||
@ -456,6 +465,7 @@ func TestReconcilerEnforcesMaxInFlightAcrossRunConsumers(t *testing.T) {
|
|||||||
Timeout: time.Second,
|
Timeout: time.Second,
|
||||||
MaxAttempts: 1,
|
MaxAttempts: 1,
|
||||||
MaxInFlight: 1,
|
MaxInFlight: 1,
|
||||||
|
MaxSize: 100,
|
||||||
}, ports)
|
}, ports)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewReconciler(): %v", err)
|
t.Fatalf("NewReconciler(): %v", err)
|
||||||
@ -501,6 +511,7 @@ func TestReconcilerUsesConfiguredMaxInFlight(t *testing.T) {
|
|||||||
Timeout: time.Second,
|
Timeout: time.Second,
|
||||||
MaxAttempts: 1,
|
MaxAttempts: 1,
|
||||||
MaxInFlight: 2,
|
MaxInFlight: 2,
|
||||||
|
MaxSize: 100,
|
||||||
}, ports)
|
}, ports)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewReconciler(): %v", err)
|
t.Fatalf("NewReconciler(): %v", err)
|
||||||
@ -544,6 +555,7 @@ func TestReconcilerDoesNotRefetchWhenActivitySinkFails(t *testing.T) {
|
|||||||
Timeout: time.Second,
|
Timeout: time.Second,
|
||||||
MaxAttempts: 3,
|
MaxAttempts: 3,
|
||||||
MaxInFlight: 1,
|
MaxInFlight: 1,
|
||||||
|
MaxSize: 100,
|
||||||
Retry: RetryConfig{Initial: 100 * time.Millisecond, Max: time.Second},
|
Retry: RetryConfig{Initial: 100 * time.Millisecond, Max: time.Second},
|
||||||
}, ports, Runtime{Clock: clock, Sleeper: sleeper})
|
}, ports, Runtime{Clock: clock, Sleeper: sleeper})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -576,6 +588,7 @@ func TestReconcilerDoesNotRetryPermanentAdapterError(t *testing.T) {
|
|||||||
Timeout: time.Second,
|
Timeout: time.Second,
|
||||||
MaxAttempts: 3,
|
MaxAttempts: 3,
|
||||||
MaxInFlight: 1,
|
MaxInFlight: 1,
|
||||||
|
MaxSize: 100,
|
||||||
Retry: RetryConfig{Initial: time.Second, Max: time.Second},
|
Retry: RetryConfig{Initial: time.Second, Max: time.Second},
|
||||||
}, ports, Runtime{Sleeper: sleeper})
|
}, ports, Runtime{Sleeper: sleeper})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -602,7 +615,7 @@ func TestReconcilerDoesNotCallProviderWithoutFetchCapacity(t *testing.T) {
|
|||||||
return nil, false, nil
|
return nil, false, nil
|
||||||
})
|
})
|
||||||
reconciler, err := NewReconciler(Config{
|
reconciler, err := NewReconciler(Config{
|
||||||
UpstreamID: "provider-a", Timeout: time.Second, MaxAttempts: 1, MaxInFlight: 1,
|
UpstreamID: "provider-a", Timeout: time.Second, MaxAttempts: 1, MaxInFlight: 1, MaxSize: 100,
|
||||||
}, ports)
|
}, ports)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewReconciler(): %v", err)
|
t.Fatalf("NewReconciler(): %v", err)
|
||||||
@ -644,7 +657,7 @@ func TestReconcilerCompletesFetchPermitWithFetchedAndRetainedCounts(t *testing.T
|
|||||||
return &recordingFetchPermit{expected: 2, completed: completed}, true, nil
|
return &recordingFetchPermit{expected: 2, completed: completed}, true, nil
|
||||||
})
|
})
|
||||||
reconciler, err := NewReconciler(Config{
|
reconciler, err := NewReconciler(Config{
|
||||||
UpstreamID: "provider-a", Timeout: time.Second, MaxAttempts: 1, MaxInFlight: 1,
|
UpstreamID: "provider-a", Timeout: time.Second, MaxAttempts: 1, MaxInFlight: 1, MaxSize: 100,
|
||||||
}, ports)
|
}, ports)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewReconciler(): %v", err)
|
t.Fatalf("NewReconciler(): %v", err)
|
||||||
@ -677,6 +690,7 @@ func TestReconcilerRetriesParserErrorsAsFetchErrors(t *testing.T) {
|
|||||||
Timeout: time.Second,
|
Timeout: time.Second,
|
||||||
MaxAttempts: 2,
|
MaxAttempts: 2,
|
||||||
MaxInFlight: 1,
|
MaxInFlight: 1,
|
||||||
|
MaxSize: 100,
|
||||||
Retry: RetryConfig{Initial: 100 * time.Millisecond, Max: time.Second},
|
Retry: RetryConfig{Initial: 100 * time.Millisecond, Max: time.Second},
|
||||||
}, ports, Runtime{Clock: clock, Sleeper: sleeper})
|
}, ports, Runtime{Clock: clock, Sleeper: sleeper})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -710,14 +724,15 @@ func TestNewReconcilerRejectsInvalidSchedulingConfiguration(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
config Config
|
config Config
|
||||||
}{
|
}{
|
||||||
{name: "missing upstream", config: Config{Timeout: time.Second, MaxAttempts: 1, MaxInFlight: 1}},
|
{name: "missing upstream", config: Config{Timeout: time.Second, MaxAttempts: 1, MaxInFlight: 1, MaxSize: 100}},
|
||||||
{name: "negative interval", config: Config{UpstreamID: "a", RequestInterval: -1, Timeout: time.Second, MaxAttempts: 1, MaxInFlight: 1}},
|
{name: "negative interval", config: Config{UpstreamID: "a", RequestInterval: -1, Timeout: time.Second, MaxAttempts: 1, MaxInFlight: 1, MaxSize: 100}},
|
||||||
{name: "missing timeout", config: Config{UpstreamID: "a", MaxAttempts: 1, MaxInFlight: 1}},
|
{name: "missing timeout", config: Config{UpstreamID: "a", MaxAttempts: 1, MaxInFlight: 1, MaxSize: 100}},
|
||||||
{name: "missing attempts", config: Config{UpstreamID: "a", Timeout: time.Second, MaxInFlight: 1}},
|
{name: "missing attempts", config: Config{UpstreamID: "a", Timeout: time.Second, MaxInFlight: 1, MaxSize: 100}},
|
||||||
{name: "missing in flight", config: Config{UpstreamID: "a", Timeout: time.Second, MaxAttempts: 1}},
|
{name: "missing in flight", config: Config{UpstreamID: "a", Timeout: time.Second, MaxAttempts: 1, MaxSize: 100}},
|
||||||
{name: "invalid jitter", config: Config{UpstreamID: "a", Timeout: time.Second, MaxAttempts: 1, MaxInFlight: 1, Retry: RetryConfig{Jitter: 101}}},
|
{name: "missing max size", config: Config{UpstreamID: "a", Timeout: time.Second, MaxAttempts: 1, MaxInFlight: 1}},
|
||||||
{name: "initial exceeds max", config: Config{UpstreamID: "a", Timeout: time.Second, MaxAttempts: 1, MaxInFlight: 1, Retry: RetryConfig{Initial: time.Second, Max: time.Millisecond}}},
|
{name: "invalid jitter", config: Config{UpstreamID: "a", Timeout: time.Second, MaxAttempts: 1, MaxInFlight: 1, MaxSize: 100, Retry: RetryConfig{Jitter: 101}}},
|
||||||
{name: "incomplete retry pair", config: Config{UpstreamID: "a", Timeout: time.Second, MaxAttempts: 1, MaxInFlight: 1, Retry: RetryConfig{Max: time.Second}}},
|
{name: "initial exceeds max", config: Config{UpstreamID: "a", Timeout: time.Second, MaxAttempts: 1, MaxInFlight: 1, MaxSize: 100, Retry: RetryConfig{Initial: time.Second, Max: time.Millisecond}}},
|
||||||
|
{name: "incomplete retry pair", config: Config{UpstreamID: "a", Timeout: time.Second, MaxAttempts: 1, MaxInFlight: 1, MaxSize: 100, Retry: RetryConfig{Max: time.Second}}},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
170
internal/domain/activitypool/activity_contracts_test.go
Normal file
170
internal/domain/activitypool/activity_contracts_test.go
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
package activitypool
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
proxyDomain "proxy-pool/internal/domain/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMemoryPoolAppliesHealthTransitionsAndRejectsStaleObservation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
now := time.Date(2026, 7, 29, 15, 0, 0, 0, time.UTC)
|
||||||
|
pool := NewMemoryPool()
|
||||||
|
inserted, err := pool.UpsertFetched(context.Background(), "provider-a", FetchedBatch{
|
||||||
|
ObservedAt: now, ConfiguredTTL: time.Minute, MaxSize: 10,
|
||||||
|
Proxies: []proxyDomain.Proxy{{
|
||||||
|
ID: "proxy-a", Scheme: proxyDomain.SchemeHTTP, Host: "192.0.2.10", Port: 8080,
|
||||||
|
State: proxyDomain.StateFetched,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil || inserted.Inserted != 1 {
|
||||||
|
t.Fatalf("UpsertFetched() = %+v, %v", inserted, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
checking, err := pool.ApplyHealth(context.Background(), HealthUpdate{
|
||||||
|
ProxyID: "proxy-a", CheckedAt: now.Add(time.Second), NextState: proxyDomain.StateChecking,
|
||||||
|
})
|
||||||
|
if err != nil || checking.State != proxyDomain.StateChecking {
|
||||||
|
t.Fatalf("ApplyHealth(checking) = %+v, %v", checking, err)
|
||||||
|
}
|
||||||
|
available, err := pool.ApplyHealth(context.Background(), HealthUpdate{
|
||||||
|
ProxyID: "proxy-a", CheckedAt: now.Add(2 * time.Second),
|
||||||
|
NextState: proxyDomain.StateAvailable, Latency: 25 * time.Millisecond,
|
||||||
|
})
|
||||||
|
if err != nil || available.State != proxyDomain.StateAvailable ||
|
||||||
|
available.Proxy.LastCheckedAt == nil || !available.Proxy.LastCheckedAt.Equal(now.Add(2*time.Second)) ||
|
||||||
|
available.Proxy.LastSuccessAt == nil || !available.Proxy.LastSuccessAt.Equal(now.Add(2*time.Second)) ||
|
||||||
|
available.Proxy.Latency != 25*time.Millisecond {
|
||||||
|
t.Fatalf("ApplyHealth(available) = %+v, %v", available, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = pool.ApplyHealth(context.Background(), HealthUpdate{
|
||||||
|
ProxyID: "proxy-a", CheckedAt: now.Add(time.Second), NextState: proxyDomain.StateSuspect,
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrStaleHealthUpdate) {
|
||||||
|
t.Fatalf("ApplyHealth(stale) error = %v, want ErrStaleHealthUpdate", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
replayed, err := pool.ApplyHealth(context.Background(), HealthUpdate{
|
||||||
|
ProxyID: "proxy-a", CheckedAt: now.Add(2 * time.Second),
|
||||||
|
NextState: proxyDomain.StateAvailable, Latency: time.Second,
|
||||||
|
})
|
||||||
|
if err != nil || replayed.Proxy.Latency != 25*time.Millisecond {
|
||||||
|
t.Fatalf("ApplyHealth(idempotent replay) = %+v, %v", replayed, err)
|
||||||
|
}
|
||||||
|
_, err = pool.ApplyHealth(context.Background(), HealthUpdate{
|
||||||
|
ProxyID: "proxy-a", CheckedAt: now.Add(2 * time.Second), NextState: proxyDomain.StateSuspect,
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrStaleHealthUpdate) {
|
||||||
|
t.Fatalf("ApplyHealth(conflicting replay) error = %v, want ErrStaleHealthUpdate", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryPoolRejectsInvalidHealthUpdates(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
now := time.Date(2026, 7, 29, 15, 0, 0, 0, time.UTC)
|
||||||
|
pool := NewMemoryPool()
|
||||||
|
if _, err := pool.UpsertFetched(context.Background(), "provider-a", FetchedBatch{
|
||||||
|
ObservedAt: now, ConfiguredTTL: time.Minute, MaxSize: 10,
|
||||||
|
Proxies: []proxyDomain.Proxy{{
|
||||||
|
ID: "proxy-a", Scheme: proxyDomain.SchemeHTTP, Host: "192.0.2.10", Port: 8080,
|
||||||
|
State: proxyDomain.StateFetched,
|
||||||
|
}},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("UpsertFetched(): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
update HealthUpdate
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{name: "missing proxy ID", update: HealthUpdate{CheckedAt: now, NextState: proxyDomain.StateChecking}, want: ErrInvalidHealthUpdate},
|
||||||
|
{name: "zero observation time", update: HealthUpdate{ProxyID: "proxy-a", NextState: proxyDomain.StateChecking}, want: ErrInvalidHealthUpdate},
|
||||||
|
{name: "negative latency", update: HealthUpdate{ProxyID: "proxy-a", CheckedAt: now, NextState: proxyDomain.StateChecking, Latency: -1}, want: ErrInvalidHealthUpdate},
|
||||||
|
{name: "missing entry", update: HealthUpdate{ProxyID: "missing", CheckedAt: now, NextState: proxyDomain.StateChecking}, want: ErrActivityNotFound},
|
||||||
|
{name: "invalid transition", update: HealthUpdate{ProxyID: "proxy-a", CheckedAt: now, NextState: proxyDomain.StateAvailable}, want: ErrInvalidHealthUpdate},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if _, err := pool.ApplyHealth(context.Background(), tt.update); !errors.Is(err, tt.want) {
|
||||||
|
t.Fatalf("ApplyHealth() error = %v, want %v", err, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryPoolRejectsNonPositiveMaxSize(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
_, err := NewMemoryPool().UpsertFetched(context.Background(), "provider-a", FetchedBatch{
|
||||||
|
ObservedAt: time.Date(2026, 7, 29, 15, 0, 0, 0, time.UTC),
|
||||||
|
ConfiguredTTL: time.Minute,
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrInvalidBatch) {
|
||||||
|
t.Fatalf("UpsertFetched() error = %v, want ErrInvalidBatch", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryPoolEnforcesMaxSizePerIncumbentUpstream(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
now := time.Date(2026, 7, 29, 15, 0, 0, 0, time.UTC)
|
||||||
|
pool := NewMemoryPool()
|
||||||
|
result, err := pool.UpsertFetched(context.Background(), "provider-a", FetchedBatch{
|
||||||
|
ObservedAt: now, ConfiguredTTL: time.Minute, MaxSize: 1,
|
||||||
|
Proxies: []proxyDomain.Proxy{
|
||||||
|
{ID: "proxy-a", Scheme: proxyDomain.SchemeHTTP, Host: "192.0.2.10", Port: 8080},
|
||||||
|
{ID: "proxy-b", Scheme: proxyDomain.SchemeHTTP, Host: "192.0.2.11", Port: 8080},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertFetched(): %v", err)
|
||||||
|
}
|
||||||
|
if result.Accepted != 2 || result.Inserted != 1 || result.Dropped != 1 {
|
||||||
|
t.Fatalf("UpsertFetched() = %+v", result)
|
||||||
|
}
|
||||||
|
retry, err := pool.UpsertFetched(context.Background(), "provider-a", FetchedBatch{
|
||||||
|
ObservedAt: now.Add(time.Second), ConfiguredTTL: time.Minute, MaxSize: 2,
|
||||||
|
Proxies: []proxyDomain.Proxy{
|
||||||
|
{ID: "proxy-b", Scheme: proxyDomain.SchemeHTTP, Host: "192.0.2.11", Port: 8080},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil || retry.Inserted != 1 || retry.Refreshed != 0 {
|
||||||
|
t.Fatalf("UpsertFetched(capacity retry) = %+v, %v", retry, err)
|
||||||
|
}
|
||||||
|
inventory, err := pool.Inventory(context.Background(), "provider-a", now)
|
||||||
|
if err != nil || inventory.Managed != 2 {
|
||||||
|
t.Fatalf("Inventory() = %+v, %v", inventory, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryPoolInventoryAndSweepExpiredAreBounded(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
now := time.Date(2026, 7, 29, 15, 0, 0, 0, time.UTC)
|
||||||
|
pool := NewMemoryPool()
|
||||||
|
result, err := pool.UpsertFetched(context.Background(), "provider-a", FetchedBatch{
|
||||||
|
ObservedAt: now, ConfiguredTTL: time.Second, MaxSize: 2,
|
||||||
|
Proxies: []proxyDomain.Proxy{
|
||||||
|
{ID: "proxy-a", Scheme: proxyDomain.SchemeHTTP, Host: "192.0.2.10", Port: 8080},
|
||||||
|
{ID: "proxy-b", Scheme: proxyDomain.SchemeHTTP, Host: "192.0.2.11", Port: 8080},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil || result.Inserted != 2 {
|
||||||
|
t.Fatalf("UpsertFetched() = %+v, %v", result, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
inventory, err := pool.Inventory(context.Background(), "provider-a", now.Add(2*time.Second))
|
||||||
|
if err != nil || inventory.Managed != 0 {
|
||||||
|
t.Fatalf("Inventory(expired) = %+v, %v", inventory, err)
|
||||||
|
}
|
||||||
|
first, err := pool.SweepExpired(context.Background(), now.Add(2*time.Second), 1)
|
||||||
|
if err != nil || first != 1 {
|
||||||
|
t.Fatalf("SweepExpired(first) = %d, %v", first, err)
|
||||||
|
}
|
||||||
|
second, err := pool.SweepExpired(context.Background(), now.Add(2*time.Second), 1)
|
||||||
|
if err != nil || second != 1 {
|
||||||
|
t.Fatalf("SweepExpired(second) = %d, %v", second, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -16,7 +16,14 @@ import (
|
|||||||
|
|
||||||
const defaultIdempotencyTTL = 5 * time.Minute
|
const defaultIdempotencyTTL = 5 * time.Minute
|
||||||
|
|
||||||
var ErrInvalidBatch = errors.New("invalid activity pool batch")
|
var (
|
||||||
|
ErrInvalidBatch = errors.New("invalid activity pool batch")
|
||||||
|
ErrInvalidHealthUpdate = errors.New("invalid activity pool health update")
|
||||||
|
ErrActivityNotFound = errors.New("activity pool proxy not found")
|
||||||
|
ErrStaleHealthUpdate = errors.New("stale activity pool health update")
|
||||||
|
ErrInvalidInventory = errors.New("invalid activity pool inventory query")
|
||||||
|
ErrInvalidMaintenance = errors.New("invalid activity pool maintenance request")
|
||||||
|
)
|
||||||
|
|
||||||
// FetchedBatch describes one ephemeral provider response. Proxies without a
|
// FetchedBatch describes one ephemeral provider response. Proxies without a
|
||||||
// usable expiry are dropped because this pool is intentionally rebuildable.
|
// usable expiry are dropped because this pool is intentionally rebuildable.
|
||||||
@ -24,6 +31,7 @@ type FetchedBatch struct {
|
|||||||
ObservedAt time.Time
|
ObservedAt time.Time
|
||||||
ConfiguredTTL time.Duration
|
ConfiguredTTL time.Duration
|
||||||
AllocationSafetyMargin time.Duration
|
AllocationSafetyMargin time.Duration
|
||||||
|
MaxSize int
|
||||||
Proxies []proxyDomain.Proxy
|
Proxies []proxyDomain.Proxy
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -40,6 +48,30 @@ type Upserter interface {
|
|||||||
UpsertFetched(context.Context, string, FetchedBatch) (UpsertResult, error)
|
UpsertFetched(context.Context, string, FetchedBatch) (UpsertResult, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HealthUpdate struct {
|
||||||
|
ProxyID string
|
||||||
|
CheckedAt time.Time
|
||||||
|
NextState proxyDomain.State
|
||||||
|
Latency time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type Inventory struct {
|
||||||
|
UpstreamID string
|
||||||
|
Managed int
|
||||||
|
}
|
||||||
|
|
||||||
|
type HealthStore interface {
|
||||||
|
ApplyHealth(context.Context, HealthUpdate) (Entry, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type InventoryReader interface {
|
||||||
|
Inventory(context.Context, string, time.Time) (Inventory, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Maintainer interface {
|
||||||
|
SweepExpired(context.Context, time.Time, int) (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
type Entry struct {
|
type Entry struct {
|
||||||
Proxy proxyDomain.Proxy
|
Proxy proxyDomain.Proxy
|
||||||
UsableUntil time.Time
|
UsableUntil time.Time
|
||||||
@ -65,6 +97,9 @@ type idempotencyEntry struct {
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
_ Upserter = (*MemoryPool)(nil)
|
_ Upserter = (*MemoryPool)(nil)
|
||||||
|
_ HealthStore = (*MemoryPool)(nil)
|
||||||
|
_ InventoryReader = (*MemoryPool)(nil)
|
||||||
|
_ Maintainer = (*MemoryPool)(nil)
|
||||||
_ extractionDomain.Store = (*MemoryPool)(nil)
|
_ extractionDomain.Store = (*MemoryPool)(nil)
|
||||||
_ ownershipDomain.Repository = (*MemoryPool)(nil)
|
_ ownershipDomain.Repository = (*MemoryPool)(nil)
|
||||||
)
|
)
|
||||||
@ -83,7 +118,7 @@ func (p *MemoryPool) UpsertFetched(ctx context.Context, upstreamID string, batch
|
|||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
if p == nil || upstreamID == "" || batch.ObservedAt.IsZero() || batch.ConfiguredTTL < 0 ||
|
if p == nil || upstreamID == "" || batch.ObservedAt.IsZero() || batch.ConfiguredTTL < 0 || batch.MaxSize <= 0 ||
|
||||||
batch.AllocationSafetyMargin < 0 ||
|
batch.AllocationSafetyMargin < 0 ||
|
||||||
(batch.ConfiguredTTL > 0 && batch.AllocationSafetyMargin >= batch.ConfiguredTTL) {
|
(batch.ConfiguredTTL > 0 && batch.AllocationSafetyMargin >= batch.ConfiguredTTL) {
|
||||||
return result, ErrInvalidBatch
|
return result, ErrInvalidBatch
|
||||||
@ -100,6 +135,12 @@ func (p *MemoryPool) UpsertFetched(ctx context.Context, upstreamID string, batch
|
|||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
p.purgeExpiredLocked(batch.ObservedAt)
|
p.purgeExpiredLocked(batch.ObservedAt)
|
||||||
|
managedByUpstream := make(map[string]int)
|
||||||
|
for _, entry := range p.entries {
|
||||||
|
if managedActivityState(entry.State) {
|
||||||
|
managedByUpstream[entry.Proxy.SourceUpstream]++
|
||||||
|
}
|
||||||
|
}
|
||||||
seenIDs := make(map[string]string, len(batch.Proxies))
|
seenIDs := make(map[string]string, len(batch.Proxies))
|
||||||
for _, candidate := range batch.Proxies {
|
for _, candidate := range batch.Proxies {
|
||||||
if !validProxyIdentity(candidate) {
|
if !validProxyIdentity(candidate) {
|
||||||
@ -165,6 +206,10 @@ func (p *MemoryPool) UpsertFetched(ctx context.Context, upstreamID string, batch
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if managedByUpstream[upstreamID] >= batch.MaxSize {
|
||||||
|
result.Dropped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if candidate.ID == "" {
|
if candidate.ID == "" {
|
||||||
candidate.ID = stableProxyID(key)
|
candidate.ID = stableProxyID(key)
|
||||||
@ -175,11 +220,119 @@ func (p *MemoryPool) UpsertFetched(ctx context.Context, upstreamID string, batch
|
|||||||
State: candidate.State,
|
State: candidate.State,
|
||||||
}
|
}
|
||||||
p.keyByID[candidate.ID] = key
|
p.keyByID[candidate.ID] = key
|
||||||
|
if managedActivityState(candidate.State) {
|
||||||
|
managedByUpstream[upstreamID]++
|
||||||
|
}
|
||||||
result.Inserted++
|
result.Inserted++
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *MemoryPool) ApplyHealth(ctx context.Context, update HealthUpdate) (Entry, error) {
|
||||||
|
if ctx == nil {
|
||||||
|
return Entry{}, ErrInvalidHealthUpdate
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return Entry{}, err
|
||||||
|
}
|
||||||
|
if p == nil || update.ProxyID == "" || update.CheckedAt.IsZero() || update.NextState == "" || update.Latency < 0 {
|
||||||
|
return Entry{}, ErrInvalidHealthUpdate
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return Entry{}, err
|
||||||
|
}
|
||||||
|
p.purgeExpiredLocked(update.CheckedAt)
|
||||||
|
entry, ok := p.entryByIDLocked(update.ProxyID)
|
||||||
|
if !ok {
|
||||||
|
return Entry{}, ErrActivityNotFound
|
||||||
|
}
|
||||||
|
if entry.Proxy.LastCheckedAt != nil {
|
||||||
|
if update.CheckedAt.Before(*entry.Proxy.LastCheckedAt) {
|
||||||
|
return Entry{}, ErrStaleHealthUpdate
|
||||||
|
}
|
||||||
|
if update.CheckedAt.Equal(*entry.Proxy.LastCheckedAt) {
|
||||||
|
if entry.State != update.NextState {
|
||||||
|
return Entry{}, ErrStaleHealthUpdate
|
||||||
|
}
|
||||||
|
entry.Proxy = cloneProxy(entry.Proxy)
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if entry.State != update.NextState && !proxyDomain.CanTransition(entry.State, update.NextState) {
|
||||||
|
return Entry{}, ErrInvalidHealthUpdate
|
||||||
|
}
|
||||||
|
checkedAt := update.CheckedAt.UTC()
|
||||||
|
entry.State = update.NextState
|
||||||
|
entry.Proxy.State = update.NextState
|
||||||
|
entry.Proxy.LastCheckedAt = &checkedAt
|
||||||
|
entry.Proxy.Latency = update.Latency
|
||||||
|
if update.NextState == proxyDomain.StateAvailable {
|
||||||
|
lastSuccessAt := checkedAt
|
||||||
|
entry.Proxy.LastSuccessAt = &lastSuccessAt
|
||||||
|
}
|
||||||
|
p.setEntryByIDLocked(update.ProxyID, entry)
|
||||||
|
entry.Proxy = cloneProxy(entry.Proxy)
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MemoryPool) Inventory(ctx context.Context, upstreamID string, now time.Time) (Inventory, error) {
|
||||||
|
result := Inventory{UpstreamID: upstreamID}
|
||||||
|
if ctx == nil {
|
||||||
|
return result, ErrInvalidInventory
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
if p == nil || upstreamID == "" || now.IsZero() {
|
||||||
|
return result, ErrInvalidInventory
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
for _, entry := range p.entries {
|
||||||
|
if entry.Proxy.SourceUpstream == upstreamID && managedActivityState(entry.State) &&
|
||||||
|
entry.Proxy.ExpiresAt != nil && entry.Proxy.ExpiresAt.After(now) {
|
||||||
|
result.Managed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MemoryPool) SweepExpired(ctx context.Context, now time.Time, limit int) (int, error) {
|
||||||
|
if ctx == nil {
|
||||||
|
return 0, ErrInvalidMaintenance
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if p == nil || now.IsZero() || limit <= 0 {
|
||||||
|
return 0, ErrInvalidMaintenance
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
expiredIDs := make([]string, 0)
|
||||||
|
for _, entry := range p.entries {
|
||||||
|
if entry.Proxy.ExpiresAt != nil && !entry.Proxy.ExpiresAt.After(now) {
|
||||||
|
expiredIDs = append(expiredIDs, entry.Proxy.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(expiredIDs)
|
||||||
|
if len(expiredIDs) > limit {
|
||||||
|
expiredIDs = expiredIDs[:limit]
|
||||||
|
}
|
||||||
|
for _, proxyID := range expiredIDs {
|
||||||
|
p.removeEntryByIDLocked(proxyID)
|
||||||
|
}
|
||||||
|
return len(expiredIDs), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (p *MemoryPool) Snapshot(now time.Time) []Entry {
|
func (p *MemoryPool) Snapshot(now time.Time) []Entry {
|
||||||
if p == nil {
|
if p == nil {
|
||||||
return nil
|
return nil
|
||||||
@ -473,9 +626,7 @@ func (p *MemoryPool) purgeExpiredLocked(now time.Time) int {
|
|||||||
if entry.Proxy.ExpiresAt == nil || entry.Proxy.ExpiresAt.After(now) {
|
if entry.Proxy.ExpiresAt == nil || entry.Proxy.ExpiresAt.After(now) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
delete(p.ownership, entry.Proxy.ID)
|
p.removeEntryLocked(key, entry)
|
||||||
delete(p.keyByID, entry.Proxy.ID)
|
|
||||||
delete(p.entries, key)
|
|
||||||
removed++
|
removed++
|
||||||
}
|
}
|
||||||
for key, entry := range p.idempotent {
|
for key, entry := range p.idempotent {
|
||||||
@ -486,6 +637,25 @@ func (p *MemoryPool) purgeExpiredLocked(now time.Time) int {
|
|||||||
return removed
|
return removed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *MemoryPool) removeEntryByIDLocked(proxyID string) {
|
||||||
|
key, ok := p.keyByID[proxyID]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entry, ok := p.entries[key]
|
||||||
|
if !ok {
|
||||||
|
delete(p.keyByID, proxyID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.removeEntryLocked(key, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MemoryPool) removeEntryLocked(key string, entry Entry) {
|
||||||
|
delete(p.ownership, entry.Proxy.ID)
|
||||||
|
delete(p.keyByID, entry.Proxy.ID)
|
||||||
|
delete(p.entries, key)
|
||||||
|
}
|
||||||
|
|
||||||
func (p *MemoryPool) entryByIDLocked(proxyID string) (Entry, bool) {
|
func (p *MemoryPool) entryByIDLocked(proxyID string) (Entry, bool) {
|
||||||
key, ok := p.keyByID[proxyID]
|
key, ok := p.keyByID[proxyID]
|
||||||
if !ok {
|
if !ok {
|
||||||
@ -554,6 +724,16 @@ func validProxyIdentity(candidate proxyDomain.Proxy) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func managedActivityState(state proxyDomain.State) bool {
|
||||||
|
switch state {
|
||||||
|
case proxyDomain.StateFetched, proxyDomain.StateChecking, proxyDomain.StateAvailable,
|
||||||
|
proxyDomain.StateSuspect, proxyDomain.StateDraining:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func cloneProxy(candidate proxyDomain.Proxy) proxyDomain.Proxy {
|
func cloneProxy(candidate proxyDomain.Proxy) proxyDomain.Proxy {
|
||||||
if candidate.ExpiresAt != nil {
|
if candidate.ExpiresAt != nil {
|
||||||
value := *candidate.ExpiresAt
|
value := *candidate.ExpiresAt
|
||||||
|
|||||||
@ -19,6 +19,7 @@ func TestMemoryPoolUpsertAppliesProviderTTLAndRefreshesWithoutGrowth(t *testing.
|
|||||||
ObservedAt: now,
|
ObservedAt: now,
|
||||||
ConfiguredTTL: 30 * time.Second,
|
ConfiguredTTL: 30 * time.Second,
|
||||||
AllocationSafetyMargin: 3 * time.Second,
|
AllocationSafetyMargin: 3 * time.Second,
|
||||||
|
MaxSize: 100,
|
||||||
Proxies: []proxyDomain.Proxy{{
|
Proxies: []proxyDomain.Proxy{{
|
||||||
Scheme: proxyDomain.SchemeHTTP,
|
Scheme: proxyDomain.SchemeHTTP,
|
||||||
Host: "192.0.2.10",
|
Host: "192.0.2.10",
|
||||||
@ -70,7 +71,7 @@ func TestMemoryPoolCrossProviderDuplicateDoesNotReplaceSourceLifecycle(t *testin
|
|||||||
State: proxyDomain.StateAvailable,
|
State: proxyDomain.StateAvailable,
|
||||||
}
|
}
|
||||||
first, err := pool.UpsertFetched(context.Background(), "provider-a", FetchedBatch{
|
first, err := pool.UpsertFetched(context.Background(), "provider-a", FetchedBatch{
|
||||||
ObservedAt: now, ConfiguredTTL: 30 * time.Second,
|
ObservedAt: now, ConfiguredTTL: 30 * time.Second, MaxSize: 100,
|
||||||
AllocationSafetyMargin: 3 * time.Second, Proxies: []proxyDomain.Proxy{proxy},
|
AllocationSafetyMargin: 3 * time.Second, Proxies: []proxyDomain.Proxy{proxy},
|
||||||
})
|
})
|
||||||
if err != nil || first.Inserted != 1 {
|
if err != nil || first.Inserted != 1 {
|
||||||
@ -78,7 +79,7 @@ func TestMemoryPoolCrossProviderDuplicateDoesNotReplaceSourceLifecycle(t *testin
|
|||||||
}
|
}
|
||||||
|
|
||||||
duplicate, err := pool.UpsertFetched(context.Background(), "provider-b", FetchedBatch{
|
duplicate, err := pool.UpsertFetched(context.Background(), "provider-b", FetchedBatch{
|
||||||
ObservedAt: now.Add(time.Second), ConfiguredTTL: 5 * time.Minute,
|
ObservedAt: now.Add(time.Second), ConfiguredTTL: 5 * time.Minute, MaxSize: 100,
|
||||||
AllocationSafetyMargin: 10 * time.Second, Proxies: []proxyDomain.Proxy{proxy},
|
AllocationSafetyMargin: 10 * time.Second, Proxies: []proxyDomain.Proxy{proxy},
|
||||||
})
|
})
|
||||||
if err != nil || duplicate.Inserted != 0 || duplicate.Refreshed != 1 {
|
if err != nil || duplicate.Inserted != 0 || duplicate.Refreshed != 1 {
|
||||||
@ -99,7 +100,7 @@ func TestMemoryPoolCrossProviderDuplicateDoesNotReplaceSourceLifecycle(t *testin
|
|||||||
}
|
}
|
||||||
|
|
||||||
afterExpiry, err := pool.UpsertFetched(context.Background(), "provider-b", FetchedBatch{
|
afterExpiry, err := pool.UpsertFetched(context.Background(), "provider-b", FetchedBatch{
|
||||||
ObservedAt: now.Add(31 * time.Second), ConfiguredTTL: 5 * time.Minute,
|
ObservedAt: now.Add(31 * time.Second), ConfiguredTTL: 5 * time.Minute, MaxSize: 100,
|
||||||
AllocationSafetyMargin: 10 * time.Second, Proxies: []proxyDomain.Proxy{proxy},
|
AllocationSafetyMargin: 10 * time.Second, Proxies: []proxyDomain.Proxy{proxy},
|
||||||
})
|
})
|
||||||
if err != nil || afterExpiry.Inserted != 1 {
|
if err != nil || afterExpiry.Inserted != 1 {
|
||||||
@ -116,7 +117,7 @@ func TestMemoryPoolRefreshPreservesRuntimeStateAndHealth(t *testing.T) {
|
|||||||
checkedAt := now.Add(-time.Second)
|
checkedAt := now.Add(-time.Second)
|
||||||
pool := NewMemoryPool()
|
pool := NewMemoryPool()
|
||||||
first, err := pool.UpsertFetched(context.Background(), "provider-a", FetchedBatch{
|
first, err := pool.UpsertFetched(context.Background(), "provider-a", FetchedBatch{
|
||||||
ObservedAt: now, ConfiguredTTL: 30 * time.Second,
|
ObservedAt: now, ConfiguredTTL: 30 * time.Second, MaxSize: 100,
|
||||||
Proxies: []proxyDomain.Proxy{{
|
Proxies: []proxyDomain.Proxy{{
|
||||||
Scheme: proxyDomain.SchemeHTTP, Host: "192.0.2.10", Port: 8080,
|
Scheme: proxyDomain.SchemeHTTP, Host: "192.0.2.10", Port: 8080,
|
||||||
State: proxyDomain.StateAvailable, LastCheckedAt: &checkedAt,
|
State: proxyDomain.StateAvailable, LastCheckedAt: &checkedAt,
|
||||||
@ -128,7 +129,7 @@ func TestMemoryPoolRefreshPreservesRuntimeStateAndHealth(t *testing.T) {
|
|||||||
initial := pool.Snapshot(now)[0]
|
initial := pool.Snapshot(now)[0]
|
||||||
|
|
||||||
second, err := pool.UpsertFetched(context.Background(), "provider-a", FetchedBatch{
|
second, err := pool.UpsertFetched(context.Background(), "provider-a", FetchedBatch{
|
||||||
ObservedAt: now.Add(10 * time.Second), ConfiguredTTL: 30 * time.Second,
|
ObservedAt: now.Add(10 * time.Second), ConfiguredTTL: 30 * time.Second, MaxSize: 100,
|
||||||
Proxies: []proxyDomain.Proxy{{
|
Proxies: []proxyDomain.Proxy{{
|
||||||
Scheme: proxyDomain.SchemeHTTP, Host: "192.0.2.10", Port: 8080,
|
Scheme: proxyDomain.SchemeHTTP, Host: "192.0.2.10", Port: 8080,
|
||||||
State: proxyDomain.StateFetched,
|
State: proxyDomain.StateFetched,
|
||||||
@ -154,6 +155,7 @@ func TestMemoryPoolDropsCandidatesWithoutUsableTTL(t *testing.T) {
|
|||||||
result, err := pool.UpsertFetched(context.Background(), "provider-a", FetchedBatch{
|
result, err := pool.UpsertFetched(context.Background(), "provider-a", FetchedBatch{
|
||||||
ObservedAt: now,
|
ObservedAt: now,
|
||||||
AllocationSafetyMargin: 3 * time.Second,
|
AllocationSafetyMargin: 3 * time.Second,
|
||||||
|
MaxSize: 100,
|
||||||
Proxies: []proxyDomain.Proxy{
|
Proxies: []proxyDomain.Proxy{
|
||||||
{Scheme: proxyDomain.SchemeHTTP, Host: "192.0.2.10", Port: 8001},
|
{Scheme: proxyDomain.SchemeHTTP, Host: "192.0.2.10", Port: 8001},
|
||||||
{Scheme: proxyDomain.SchemeHTTP, Host: "192.0.2.11", Port: 8002, ExpiresAt: &expired},
|
{Scheme: proxyDomain.SchemeHTTP, Host: "192.0.2.11", Port: 8002, ExpiresAt: &expired},
|
||||||
@ -174,6 +176,7 @@ func TestMemoryPoolRejectsInvalidBatchAtomically(t *testing.T) {
|
|||||||
result, err := pool.UpsertFetched(context.Background(), "provider-a", FetchedBatch{
|
result, err := pool.UpsertFetched(context.Background(), "provider-a", FetchedBatch{
|
||||||
ObservedAt: now,
|
ObservedAt: now,
|
||||||
ConfiguredTTL: time.Minute,
|
ConfiguredTTL: time.Minute,
|
||||||
|
MaxSize: 100,
|
||||||
Proxies: []proxyDomain.Proxy{
|
Proxies: []proxyDomain.Proxy{
|
||||||
{Scheme: proxyDomain.SchemeHTTP, Host: "192.0.2.10", Port: 8001},
|
{Scheme: proxyDomain.SchemeHTTP, Host: "192.0.2.10", Port: 8001},
|
||||||
{Scheme: proxyDomain.SchemeHTTP, Host: "192.0.2.11", Port: 8002, SourceUpstream: "provider-b"},
|
{Scheme: proxyDomain.SchemeHTTP, Host: "192.0.2.11", Port: 8002, SourceUpstream: "provider-b"},
|
||||||
@ -397,7 +400,7 @@ func TestMemoryPoolExpireUsesStableProxyIDOrderAndLimit(t *testing.T) {
|
|||||||
{ID: "proxy-b", Scheme: proxyDomain.SchemeHTTP, Host: "192.0.2.2", Port: 8002, State: proxyDomain.StateAvailable},
|
{ID: "proxy-b", Scheme: proxyDomain.SchemeHTTP, Host: "192.0.2.2", Port: 8002, State: proxyDomain.StateAvailable},
|
||||||
}
|
}
|
||||||
if _, err := pool.UpsertFetched(context.Background(), "provider-a", FetchedBatch{
|
if _, err := pool.UpsertFetched(context.Background(), "provider-a", FetchedBatch{
|
||||||
ObservedAt: now, ConfiguredTTL: 10 * time.Minute, Proxies: proxies,
|
ObservedAt: now, ConfiguredTTL: 10 * time.Minute, MaxSize: 100, Proxies: proxies,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("UpsertFetched(): %v", err)
|
t.Fatalf("UpsertFetched(): %v", err)
|
||||||
}
|
}
|
||||||
@ -439,6 +442,7 @@ func poolWithOneProxy(t *testing.T, now time.Time) *MemoryPool {
|
|||||||
ObservedAt: now,
|
ObservedAt: now,
|
||||||
ConfiguredTTL: 30 * time.Second,
|
ConfiguredTTL: 30 * time.Second,
|
||||||
AllocationSafetyMargin: 3 * time.Second,
|
AllocationSafetyMargin: 3 * time.Second,
|
||||||
|
MaxSize: 100,
|
||||||
Proxies: []proxyDomain.Proxy{{
|
Proxies: []proxyDomain.Proxy{{
|
||||||
Scheme: proxyDomain.SchemeHTTP,
|
Scheme: proxyDomain.SchemeHTTP,
|
||||||
Host: "192.0.2.10",
|
Host: "192.0.2.10",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user