e0e362d700
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
Deploy Docs Pages / build (push) Has been cancelled
Deploy Docs Pages / deploy (push) Has been cancelled
Real E2E Tests / JavaScript E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Python E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Java E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / C# E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Go E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Real E2E CI (push) Has been cancelled
SDK Tests / SDK CI (push) Has been cancelled
SDK Tests / CLI Tests (push) Has been cancelled
SDK Tests / Python SDK Quality (code-interpreter) (push) Has been cancelled
SDK Tests / Python SDK Quality (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (sandbox) (push) Has been cancelled
SDK Tests / CLI Quality (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Go SDK Quality And Tests (push) Has been cancelled
925 lines
30 KiB
Go
925 lines
30 KiB
Go
// Copyright 2026 Alibaba Group Holding Ltd.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package e2e
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/alibaba/OpenSandbox/sdks/sandbox/go"
|
|
"github.com/alibaba/OpenSandbox/sdks/sandbox/go/poolredis"
|
|
"github.com/redis/go-redis/v9"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// ==========================================================================
|
|
// Single-node InMemory pool E2E tests
|
|
// ==========================================================================
|
|
|
|
func TestPool_WarmupAcquireFailFastAndCommand(t *testing.T) {
|
|
tag := poolTag("go-pool")
|
|
store := opensandbox.NewInMemoryPoolStateStore()
|
|
pool := createTestPool(t, "pool-"+tag, "owner-"+tag, store, tag, poolMaxIdle, nil)
|
|
|
|
require.NoError(t, pool.Start(context.Background()))
|
|
t.Cleanup(func() { cleanupPool(pool); cleanupTaggedSandboxes(t, tag) })
|
|
|
|
eventually(t, "pool becomes healthy with warm idle",
|
|
func() bool { return snapshotState(pool) == "HEALTHY" && snapshotIdle(pool) >= 1 }, 0, 0)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
|
defer cancel()
|
|
|
|
sb, err := pool.Acquire(ctx, opensandbox.AcquireOptions{
|
|
SandboxTimeout: 5 * time.Minute,
|
|
Policy: policyPtr(opensandbox.AcquirePolicyFailFast),
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = sb.Kill(context.Background()); _ = sb.Close() })
|
|
|
|
require.True(t, sb.IsHealthy(ctx))
|
|
|
|
exec, err := sb.RunCommand(ctx, "echo go-pool-basic-ok", nil)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, exec.ExitCode)
|
|
require.Equal(t, 0, *exec.ExitCode)
|
|
require.Contains(t, exec.Text(), "go-pool-basic-ok")
|
|
}
|
|
|
|
func TestPool_ResizeReleaseFailFastAndDirectCreate(t *testing.T) {
|
|
tag := poolTag("go-pool")
|
|
store := opensandbox.NewInMemoryPoolStateStore()
|
|
pool := createTestPool(t, "pool-"+tag, "owner-"+tag, store, tag, poolMaxIdle, nil)
|
|
|
|
require.NoError(t, pool.Start(context.Background()))
|
|
t.Cleanup(func() { cleanupPool(pool); cleanupTaggedSandboxes(t, tag) })
|
|
|
|
eventually(t, "pool has warm idle", func() bool { return snapshotIdle(pool) >= 1 }, 0, 0)
|
|
|
|
require.NoError(t, pool.Resize(context.Background(), 0))
|
|
released, err := pool.ReleaseAllIdle(context.Background())
|
|
require.NoError(t, err)
|
|
assert.GreaterOrEqual(t, released, 0)
|
|
eventually(t, "idle drains after resize zero", func() bool { return snapshotIdle(pool) == 0 }, 0, 0)
|
|
|
|
ctx := context.Background()
|
|
_, err = pool.Acquire(ctx, opensandbox.AcquireOptions{
|
|
SandboxTimeout: 5 * time.Minute,
|
|
Policy: policyPtr(opensandbox.AcquirePolicyFailFast),
|
|
})
|
|
var emptyErr *opensandbox.PoolEmptyError
|
|
require.True(t, errors.As(err, &emptyErr), "expected PoolEmptyError, got %T: %v", err, err)
|
|
|
|
direct, err := pool.Acquire(ctx, opensandbox.AcquireOptions{
|
|
SandboxTimeout: 5 * time.Minute,
|
|
Policy: policyPtr(opensandbox.AcquirePolicyDirectCreate),
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = direct.Kill(context.Background()); _ = direct.Close() })
|
|
require.True(t, direct.IsHealthy(ctx))
|
|
}
|
|
|
|
func TestPool_StaleIdleFallbackShutdownRestartSnapshot(t *testing.T) {
|
|
tag := poolTag("go-pool")
|
|
store := opensandbox.NewInMemoryPoolStateStore()
|
|
poolName := "pool-" + tag
|
|
pool := createTestPool(t, poolName, "owner-"+tag, store, tag, poolMaxIdle, nil)
|
|
|
|
require.NoError(t, pool.Start(context.Background()))
|
|
t.Cleanup(func() { cleanupPool(pool); cleanupTaggedSandboxes(t, tag) })
|
|
|
|
// Inject a stale (nonexistent) sandbox ID.
|
|
require.NoError(t, store.PutIdle(context.Background(), poolName, fmt.Sprintf("missing-%d", time.Now().UnixNano())))
|
|
|
|
ctx := context.Background()
|
|
fallback, err := pool.Acquire(ctx, opensandbox.AcquireOptions{
|
|
SandboxTimeout: 5 * time.Minute,
|
|
Policy: policyPtr(opensandbox.AcquirePolicyDirectCreate),
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = fallback.Kill(context.Background()); _ = fallback.Close() })
|
|
require.True(t, fallback.IsHealthy(ctx))
|
|
|
|
// Shutdown then verify PoolNotRunningError.
|
|
require.NoError(t, pool.Shutdown(ctx, true))
|
|
_, err = pool.Acquire(ctx, opensandbox.AcquireOptions{
|
|
SandboxTimeout: 5 * time.Minute,
|
|
Policy: policyPtr(opensandbox.AcquirePolicyDirectCreate),
|
|
})
|
|
var notRunErr *opensandbox.PoolNotRunningError
|
|
require.True(t, errors.As(err, ¬RunErr), "expected PoolNotRunningError, got %T: %v", err, err)
|
|
|
|
snap, err := pool.Snapshot(ctx)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "STOPPED", snap.LifecycleState.String())
|
|
|
|
// Restart and verify snapshot_idle_entries have ExpiresAt.
|
|
require.NoError(t, pool.Start(ctx))
|
|
eventually(t, "pool restarts and warms idle",
|
|
func() bool { return snapshotState(pool) == "HEALTHY" && snapshotIdle(pool) >= 1 }, 0, 0)
|
|
|
|
entries, err := pool.SnapshotIdleEntries(ctx)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, entries)
|
|
for _, entry := range entries {
|
|
assert.NotEmpty(t, entry.SandboxID)
|
|
assert.True(t, entry.ExpiresAt.After(time.Now()), "entry ExpiresAt should be in the future")
|
|
}
|
|
}
|
|
|
|
func TestPool_LifecycleIdempotencyResizeRewarm(t *testing.T) {
|
|
tag := poolTag("go-pool")
|
|
store := opensandbox.NewInMemoryPoolStateStore()
|
|
poolName := "pool-" + tag
|
|
pool := createTestPool(t, poolName, "owner-"+tag, store, tag, poolMaxIdle, nil)
|
|
|
|
require.NoError(t, pool.Start(context.Background()))
|
|
t.Cleanup(func() { cleanupPool(pool); cleanupTaggedSandboxes(t, tag) })
|
|
eventually(t, "pool warms before lifecycle checks", func() bool { return snapshotIdle(pool) >= 1 }, 0, 0)
|
|
|
|
ctx := context.Background()
|
|
|
|
// Idempotent shutdown.
|
|
require.NoError(t, pool.Shutdown(ctx, false))
|
|
require.NoError(t, pool.Shutdown(ctx, false))
|
|
assert.Equal(t, "STOPPED", snapshotLifecycle(pool))
|
|
|
|
_, err := pool.Acquire(ctx, opensandbox.AcquireOptions{
|
|
SandboxTimeout: 5 * time.Minute,
|
|
Policy: policyPtr(opensandbox.AcquirePolicyDirectCreate),
|
|
})
|
|
var notRunErr *opensandbox.PoolNotRunningError
|
|
require.True(t, errors.As(err, ¬RunErr))
|
|
|
|
// Release after stop.
|
|
_, err = pool.ReleaseAllIdle(ctx)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 0, snapshotIdle(pool))
|
|
|
|
// Inject fake IDs and release.
|
|
require.NoError(t, store.PutIdle(ctx, poolName, "injected-a"))
|
|
require.NoError(t, store.PutIdle(ctx, poolName, "injected-b"))
|
|
released, err := pool.ReleaseAllIdle(ctx)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 2, released)
|
|
assert.Equal(t, 0, snapshotIdle(pool))
|
|
|
|
// Restart, resize to 0 then back.
|
|
require.NoError(t, pool.Start(ctx))
|
|
eventually(t, "pool rewarms after restart", func() bool { return snapshotIdle(pool) >= 1 }, 0, 0)
|
|
|
|
require.NoError(t, pool.Resize(ctx, 0))
|
|
_, _ = pool.ReleaseAllIdle(ctx)
|
|
eventually(t, "remote sandboxes cleaned up",
|
|
func() bool { return snapshotIdle(pool) == 0 && countTaggedSandboxes(t, tag) == 0 },
|
|
60*time.Second, 0)
|
|
|
|
require.NoError(t, pool.Resize(ctx, 1))
|
|
eventually(t, "resize from zero to positive rewarms idle",
|
|
func() bool { return snapshotState(pool) == "HEALTHY" && snapshotIdle(pool) >= 1 }, 0, 0)
|
|
}
|
|
|
|
func TestPool_ConcurrentAcquireAndShutdown(t *testing.T) {
|
|
tag := poolTag("go-pool")
|
|
store := opensandbox.NewInMemoryPoolStateStore()
|
|
pool := createTestPool(t, "pool-"+tag, "owner-"+tag, store, tag, poolMaxIdle, nil)
|
|
|
|
require.NoError(t, pool.Start(context.Background()))
|
|
t.Cleanup(func() { cleanupPool(pool); cleanupTaggedSandboxes(t, tag) })
|
|
eventually(t, "pool reaches target idle", func() bool { return snapshotIdle(pool) >= poolMaxIdle }, 0, 0)
|
|
|
|
var (
|
|
mu sync.Mutex
|
|
acquiredIDs = make(map[string]bool)
|
|
borrowed []*opensandbox.Sandbox
|
|
errs []error
|
|
start = make(chan struct{})
|
|
wg sync.WaitGroup
|
|
)
|
|
t.Cleanup(func() { cleanupBorrowed(borrowed) })
|
|
|
|
for i := 0; i < 4; i++ {
|
|
wg.Add(1)
|
|
go func(idx int) {
|
|
defer wg.Done()
|
|
<-start
|
|
sb, err := pool.Acquire(context.Background(), opensandbox.AcquireOptions{
|
|
SandboxTimeout: 5 * time.Minute,
|
|
Policy: policyPtr(opensandbox.AcquirePolicyDirectCreate),
|
|
})
|
|
if err != nil {
|
|
mu.Lock()
|
|
errs = append(errs, err)
|
|
mu.Unlock()
|
|
return
|
|
}
|
|
mu.Lock()
|
|
assert.False(t, acquiredIDs[sb.ID()], "duplicate sandbox ID: %s", sb.ID())
|
|
acquiredIDs[sb.ID()] = true
|
|
borrowed = append(borrowed, sb)
|
|
mu.Unlock()
|
|
}(i)
|
|
}
|
|
close(start)
|
|
wg.Wait()
|
|
|
|
require.Empty(t, errs, "unexpected errors during concurrent acquire: %v", errs)
|
|
assert.Len(t, acquiredIDs, 4)
|
|
|
|
// Race acquire vs shutdown.
|
|
require.NoError(t, pool.Resize(context.Background(), 1))
|
|
require.NoError(t, pool.Start(context.Background()))
|
|
eventually(t, "pool rewarmed before shutdown race", func() bool { return snapshotIdle(pool) >= 1 }, 0, 0)
|
|
|
|
start2 := make(chan struct{})
|
|
var wg2 sync.WaitGroup
|
|
var raceErrs []error
|
|
|
|
for i := 0; i < 4; i++ {
|
|
wg2.Add(1)
|
|
go func() {
|
|
defer wg2.Done()
|
|
<-start2
|
|
sb, err := pool.Acquire(context.Background(), opensandbox.AcquireOptions{
|
|
SandboxTimeout: 5 * time.Minute,
|
|
Policy: policyPtr(opensandbox.AcquirePolicyDirectCreate),
|
|
})
|
|
if err != nil {
|
|
var notRunErr *opensandbox.PoolNotRunningError
|
|
if !errors.As(err, ¬RunErr) {
|
|
mu.Lock()
|
|
raceErrs = append(raceErrs, err)
|
|
mu.Unlock()
|
|
}
|
|
return
|
|
}
|
|
mu.Lock()
|
|
borrowed = append(borrowed, sb)
|
|
mu.Unlock()
|
|
}()
|
|
}
|
|
wg2.Add(1)
|
|
go func() {
|
|
defer wg2.Done()
|
|
<-start2
|
|
_ = pool.Shutdown(context.Background(), true)
|
|
}()
|
|
close(start2)
|
|
wg2.Wait()
|
|
|
|
assert.Empty(t, raceErrs, "unexpected errors during shutdown race: %v", raceErrs)
|
|
}
|
|
|
|
func TestPool_ConcurrentStartShutdownStress(t *testing.T) {
|
|
tag := poolTag("go-pool")
|
|
store := opensandbox.NewInMemoryPoolStateStore()
|
|
pool := createTestPool(t, "pool-"+tag, "owner-"+tag, store, tag, poolMaxIdle, nil)
|
|
|
|
require.NoError(t, pool.Start(context.Background()))
|
|
t.Cleanup(func() { cleanupPool(pool); cleanupTaggedSandboxes(t, tag) })
|
|
|
|
var (
|
|
mu sync.Mutex
|
|
errs []error
|
|
)
|
|
start := make(chan struct{})
|
|
var wg sync.WaitGroup
|
|
|
|
for i := 0; i < 4; i++ {
|
|
wg.Add(1)
|
|
go func(idx int) {
|
|
defer wg.Done()
|
|
<-start
|
|
for j := 0; j < 3; j++ {
|
|
var err error
|
|
if idx%2 == 0 {
|
|
err = pool.Start(context.Background())
|
|
} else {
|
|
err = pool.Shutdown(context.Background(), idx%3 == 0)
|
|
}
|
|
if err != nil {
|
|
// PoolNotRunningError is expected when Start races with Shutdown.
|
|
var notRunning *opensandbox.PoolNotRunningError
|
|
if !errors.As(err, ¬Running) {
|
|
mu.Lock()
|
|
errs = append(errs, err)
|
|
mu.Unlock()
|
|
}
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
}(i)
|
|
}
|
|
close(start)
|
|
wg.Wait()
|
|
|
|
assert.Empty(t, errs, "unexpected errors during lifecycle stress: %v", errs)
|
|
|
|
require.NoError(t, pool.Start(context.Background()))
|
|
eventually(t, "pool remains usable after lifecycle stress",
|
|
func() bool { return snapshotIdle(pool) >= 1 }, 0, 0)
|
|
}
|
|
|
|
func TestPool_WarmupPreparerAndIsolation(t *testing.T) {
|
|
tag := poolTag("go-pool")
|
|
markerPath := "/tmp/" + tag + "-prepared.txt"
|
|
|
|
preparer := func(ctx context.Context, sb *opensandbox.Sandbox) error {
|
|
_, err := sb.RunCommand(ctx, fmt.Sprintf("printf prepared > %s", markerPath), nil)
|
|
return err
|
|
}
|
|
|
|
preparedPool := createTestPool(t, "prepared-pool-"+tag, "prepared-owner-"+tag,
|
|
opensandbox.NewInMemoryPoolStateStore(), tag, 1,
|
|
&poolCreateOpts{warmupSandboxPreparer: preparer})
|
|
|
|
require.NoError(t, preparedPool.Start(context.Background()))
|
|
t.Cleanup(func() { cleanupPool(preparedPool) })
|
|
|
|
eventually(t, "prepared pool warms", func() bool { return snapshotIdle(preparedPool) >= 1 }, 0, 0)
|
|
|
|
ctx := context.Background()
|
|
sb, err := preparedPool.Acquire(ctx, opensandbox.AcquireOptions{
|
|
SandboxTimeout: 5 * time.Minute,
|
|
Policy: policyPtr(opensandbox.AcquirePolicyFailFast),
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = sb.Kill(context.Background()); _ = sb.Close() })
|
|
|
|
exec, err := sb.RunCommand(ctx, "cat "+markerPath, nil)
|
|
require.NoError(t, err)
|
|
require.Contains(t, exec.Text(), "prepared")
|
|
|
|
// Isolation: second pool with different tag.
|
|
otherTag := poolTag("go-pool-other")
|
|
otherPool := createTestPool(t, "pool-"+otherTag, "owner-"+otherTag,
|
|
opensandbox.NewInMemoryPoolStateStore(), otherTag, 1, nil)
|
|
require.NoError(t, otherPool.Start(ctx))
|
|
t.Cleanup(func() { cleanupPool(otherPool); cleanupTaggedSandboxes(t, otherTag) })
|
|
|
|
eventually(t, "other pool warms", func() bool { return snapshotIdle(otherPool) >= 1 }, 0, 0)
|
|
|
|
// Draining one pool should not affect the other.
|
|
require.NoError(t, preparedPool.Resize(ctx, 0))
|
|
_, _ = preparedPool.ReleaseAllIdle(ctx)
|
|
eventually(t, "prepared pool drains", func() bool { return snapshotIdle(preparedPool) == 0 }, 0, 0)
|
|
assert.GreaterOrEqual(t, snapshotIdle(otherPool), 1)
|
|
|
|
cleanupTaggedSandboxes(t, tag)
|
|
}
|
|
|
|
func TestPool_WarmupConcurrencyReachesTargetAndStaysBounded(t *testing.T) {
|
|
tag := poolTag("go-pool")
|
|
pool := createTestPool(t, "pool-"+tag, "owner-"+tag,
|
|
opensandbox.NewInMemoryPoolStateStore(), tag, 3,
|
|
&poolCreateOpts{warmupConcurrency: 2})
|
|
|
|
require.NoError(t, pool.Start(context.Background()))
|
|
t.Cleanup(func() { cleanupPool(pool); cleanupTaggedSandboxes(t, tag) })
|
|
|
|
eventually(t, "concurrent warmup fills configured idle target",
|
|
func() bool { return snapshotIdle(pool) >= 3 && countTaggedSandboxes(t, tag) <= 3 },
|
|
90*time.Second, 0)
|
|
}
|
|
|
|
func TestPool_BrokenConnectionDegrades(t *testing.T) {
|
|
tag := poolTag("go-pool")
|
|
badTag := poolTag("go-pool-bad")
|
|
badCfg := brokenConnectionConfig()
|
|
|
|
badPool := createTestPool(t, "bad-pool-"+tag, "bad-owner-"+tag,
|
|
opensandbox.NewInMemoryPoolStateStore(), badTag, 1,
|
|
&poolCreateOpts{
|
|
connectionConfig: &badCfg,
|
|
degradedThreshold: 1,
|
|
warmupReadyTimeout: 1 * time.Second,
|
|
acquireReadyTimeout: 1 * time.Second,
|
|
})
|
|
|
|
require.NoError(t, badPool.Start(context.Background()))
|
|
t.Cleanup(func() { cleanupPool(badPool); cleanupTaggedSandboxes(t, badTag) })
|
|
|
|
eventually(t, "bad pool enters degraded state",
|
|
func() bool { return snapshotState(badPool) == "DEGRADED" },
|
|
60*time.Second, 0)
|
|
|
|
snap, err := badPool.Snapshot(context.Background())
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, snap.LastError)
|
|
assert.Equal(t, 0, snap.IdleCount)
|
|
|
|
ctx := context.Background()
|
|
_, err = badPool.Acquire(ctx, opensandbox.AcquireOptions{
|
|
SandboxTimeout: 1 * time.Minute,
|
|
Policy: policyPtr(opensandbox.AcquirePolicyFailFast),
|
|
})
|
|
var emptyErr *opensandbox.PoolEmptyError
|
|
require.True(t, errors.As(err, &emptyErr))
|
|
|
|
_, err = badPool.Acquire(ctx, opensandbox.AcquireOptions{
|
|
SandboxTimeout: 1 * time.Minute,
|
|
Policy: policyPtr(opensandbox.AcquirePolicyDirectCreate),
|
|
})
|
|
require.Error(t, err)
|
|
|
|
// Healthy pool works independently.
|
|
healthyTag := poolTag("go-pool-good")
|
|
healthyPool := createTestPool(t, "healthy-pool-"+tag, "healthy-owner-"+tag,
|
|
opensandbox.NewInMemoryPoolStateStore(), healthyTag, 1, nil)
|
|
require.NoError(t, healthyPool.Start(ctx))
|
|
t.Cleanup(func() { cleanupPool(healthyPool); cleanupTaggedSandboxes(t, healthyTag) })
|
|
|
|
eventually(t, "healthy pool still warms after broken pool path",
|
|
func() bool { return snapshotIdle(healthyPool) >= 1 }, 0, 0)
|
|
|
|
sb, err := healthyPool.Acquire(ctx, opensandbox.AcquireOptions{
|
|
SandboxTimeout: 5 * time.Minute,
|
|
Policy: policyPtr(opensandbox.AcquirePolicyFailFast),
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = sb.Kill(context.Background()); _ = sb.Close() })
|
|
require.True(t, sb.IsHealthy(ctx))
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Redis distributed pool E2E tests
|
|
// ==========================================================================
|
|
|
|
func skipWithoutRedis(t *testing.T) *redis.Client {
|
|
t.Helper()
|
|
redisURL := os.Getenv("OPENSANDBOX_TEST_REDIS_URL")
|
|
if redisURL == "" {
|
|
t.Skip("Set OPENSANDBOX_TEST_REDIS_URL to run Redis-backed pool E2E tests")
|
|
}
|
|
opts, err := redis.ParseURL(redisURL)
|
|
require.NoError(t, err)
|
|
client := redis.NewClient(opts)
|
|
require.NoError(t, client.Ping(context.Background()).Err(), "Redis not reachable")
|
|
t.Cleanup(func() { client.Close() })
|
|
return client
|
|
}
|
|
|
|
func newRedisE2EStore(t *testing.T, client *redis.Client, prefix string) *poolredis.RedisPoolStateStore {
|
|
t.Helper()
|
|
store, err := poolredis.NewRedisPoolStateStore(poolredis.RedisPoolStateStoreConfig{
|
|
Client: client,
|
|
KeyPrefix: prefix,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewRedisPoolStateStore: %v", err)
|
|
}
|
|
return store
|
|
}
|
|
|
|
func cleanupRedisKeys(t *testing.T, client *redis.Client, prefix string) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
var cursor uint64
|
|
for {
|
|
keys, next, err := client.Scan(ctx, cursor, prefix+":*", 100).Result()
|
|
if err != nil {
|
|
return
|
|
}
|
|
if len(keys) > 0 {
|
|
client.Del(ctx, keys...)
|
|
}
|
|
cursor = next
|
|
if cursor == 0 {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRedisPool_CrossNodeAcquireResizeDirectCreate(t *testing.T) {
|
|
rdb := skipWithoutRedis(t)
|
|
tag := poolTag("go-redis")
|
|
prefix := "opensandbox:e2e:" + tag
|
|
poolName := "redis-pool-" + tag
|
|
|
|
storeA := newRedisE2EStore(t, rdb, prefix)
|
|
storeB := newRedisE2EStore(t, rdb, prefix)
|
|
poolA := createTestPool(t, poolName, "owner-a-"+tag, storeA, tag, poolMaxIdle, nil)
|
|
poolB := createTestPool(t, poolName, "owner-b-"+tag, storeB, tag, poolMaxIdle, nil)
|
|
t.Cleanup(func() {
|
|
cleanupPool(poolA); cleanupPool(poolB)
|
|
cleanupTaggedSandboxes(t, tag); cleanupRedisKeys(t, rdb, prefix)
|
|
})
|
|
|
|
require.NoError(t, poolA.Start(context.Background()))
|
|
require.NoError(t, poolB.Start(context.Background()))
|
|
eventually(t, "Redis pool warms", func() bool { return snapshotIdle(poolA) >= 1 }, 0, 0)
|
|
|
|
ctx := context.Background()
|
|
sb, err := poolB.Acquire(ctx, opensandbox.AcquireOptions{
|
|
SandboxTimeout: 5 * time.Minute,
|
|
Policy: policyPtr(opensandbox.AcquirePolicyFailFast),
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = sb.Kill(context.Background()); _ = sb.Close() })
|
|
require.True(t, sb.IsHealthy(ctx))
|
|
|
|
exec, err := sb.RunCommand(ctx, "echo go-redis-dist-ok", nil)
|
|
require.NoError(t, err)
|
|
require.Contains(t, exec.Text(), "go-redis-dist-ok")
|
|
|
|
// Shared resize to 0.
|
|
require.NoError(t, poolB.Resize(ctx, 0))
|
|
eventually(t, "Redis idle drains after shared resize", func() bool { return snapshotIdle(poolA) == 0 }, 0, 0)
|
|
time.Sleep(poolReconcileInterval * 2)
|
|
assert.Equal(t, 0, snapshotIdle(poolA))
|
|
|
|
_, err = poolA.Acquire(ctx, opensandbox.AcquireOptions{
|
|
SandboxTimeout: 2 * time.Minute,
|
|
Policy: policyPtr(opensandbox.AcquirePolicyFailFast),
|
|
})
|
|
var emptyErr *opensandbox.PoolEmptyError
|
|
require.True(t, errors.As(err, &emptyErr))
|
|
|
|
// Direct create fallback.
|
|
direct, err := poolA.Acquire(ctx, opensandbox.AcquireOptions{
|
|
SandboxTimeout: 5 * time.Minute,
|
|
Policy: policyPtr(opensandbox.AcquirePolicyDirectCreate),
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = direct.Kill(context.Background()); _ = direct.Close() })
|
|
require.True(t, direct.IsHealthy(ctx))
|
|
assert.Equal(t, 0, snapshotIdle(poolA))
|
|
}
|
|
|
|
func TestRedisPool_PrimaryFailoverRestart(t *testing.T) {
|
|
rdb := skipWithoutRedis(t)
|
|
tag := poolTag("go-redis")
|
|
prefix := "opensandbox:e2e:" + tag
|
|
poolName := "redis-failover-" + tag
|
|
ownerA := "owner-a-" + tag
|
|
ownerB := "owner-b-" + tag
|
|
|
|
storeA := newRedisE2EStore(t, rdb, prefix)
|
|
storeB := newRedisE2EStore(t, rdb, prefix)
|
|
poolA := createTestPool(t, poolName, ownerA, storeA, tag, 1, nil)
|
|
poolB := createTestPool(t, poolName, ownerB, storeB, tag, 1, nil)
|
|
lockKey := storeA.PrimaryLockKey(poolName)
|
|
t.Cleanup(func() {
|
|
cleanupPool(poolA); cleanupPool(poolB)
|
|
cleanupTaggedSandboxes(t, tag); cleanupRedisKeys(t, rdb, prefix)
|
|
})
|
|
|
|
ctx := context.Background()
|
|
require.NoError(t, poolA.Start(ctx))
|
|
eventually(t, "first Redis node owns primary lock and warms",
|
|
func() bool {
|
|
v, _ := rdb.Get(ctx, lockKey).Result()
|
|
return v == ownerA && snapshotIdle(poolA) >= 1
|
|
}, 0, 0)
|
|
|
|
require.NoError(t, poolB.Start(ctx))
|
|
require.NoError(t, poolA.Shutdown(ctx, false))
|
|
require.NoError(t, poolB.Resize(ctx, 1))
|
|
|
|
eventually(t, "primary lock fails over to remaining Redis node",
|
|
func() bool {
|
|
v, _ := rdb.Get(ctx, lockKey).Result()
|
|
return v == ownerB && snapshotIdle(poolB) >= 1
|
|
}, 60*time.Second, 0)
|
|
|
|
// Restart node A and apply resize jitter.
|
|
require.NoError(t, poolA.Start(ctx))
|
|
for i := 0; i < 6; i++ {
|
|
p := poolA
|
|
if i%2 != 0 {
|
|
p = poolB
|
|
}
|
|
_ = p.Resize(ctx, i%3)
|
|
time.Sleep(200 * time.Millisecond)
|
|
}
|
|
require.NoError(t, poolB.Resize(ctx, 1))
|
|
eventually(t, "Redis restart and resize jitter converge",
|
|
func() bool { return snapshotIdle(poolA) <= 1 && countTaggedSandboxes(t, tag) <= 2 },
|
|
60*time.Second, 0)
|
|
}
|
|
|
|
func TestRedisPool_RestartOverwritesStaleMaxIdle(t *testing.T) {
|
|
rdb := skipWithoutRedis(t)
|
|
tag := poolTag("go-redis")
|
|
prefix := "opensandbox:e2e:" + tag
|
|
poolName := "redis-restart-config-" + tag
|
|
|
|
storeA := newRedisE2EStore(t, rdb, prefix)
|
|
poolA := createTestPool(t, poolName, "owner-a-"+tag, storeA, tag, 1, nil)
|
|
t.Cleanup(func() {
|
|
cleanupPool(poolA); cleanupTaggedSandboxes(t, tag); cleanupRedisKeys(t, rdb, prefix)
|
|
})
|
|
|
|
ctx := context.Background()
|
|
require.NoError(t, poolA.Start(ctx))
|
|
eventually(t, "initial Redis pool warms", func() bool { return snapshotIdle(poolA) >= 1 }, 0, 0)
|
|
|
|
require.NoError(t, poolA.Resize(ctx, 0))
|
|
eventually(t, "initial Redis pool drains to zero", func() bool { return snapshotIdle(poolA) == 0 }, 0, 0)
|
|
require.NoError(t, poolA.Shutdown(ctx, false))
|
|
|
|
storeB := newRedisE2EStore(t, rdb, prefix)
|
|
poolB := createTestPool(t, poolName, "owner-b-"+tag, storeB, tag, 2, nil)
|
|
t.Cleanup(func() { cleanupPool(poolB) })
|
|
|
|
require.NoError(t, poolB.Start(ctx))
|
|
eventually(t, "restart with same Redis namespace uses new configured max_idle",
|
|
func() bool {
|
|
snap, err := poolB.Snapshot(ctx)
|
|
return err == nil && snap.MaxIdle == 2 && snap.IdleCount >= 2
|
|
}, 0, 0)
|
|
}
|
|
|
|
func TestRedisPool_SecondaryResizeAppliedByPrimary(t *testing.T) {
|
|
rdb := skipWithoutRedis(t)
|
|
tag := poolTag("go-redis")
|
|
prefix := "opensandbox:e2e:" + tag
|
|
poolName := "redis-secondary-resize-" + tag
|
|
ownerA := "owner-a-" + tag
|
|
|
|
storeA := newRedisE2EStore(t, rdb, prefix)
|
|
storeB := newRedisE2EStore(t, rdb, prefix)
|
|
poolA := createTestPool(t, poolName, ownerA, storeA, tag, 2, nil)
|
|
poolB := createTestPool(t, poolName, "owner-b-"+tag, storeB, tag, 2, nil)
|
|
lockKey := storeA.PrimaryLockKey(poolName)
|
|
t.Cleanup(func() {
|
|
cleanupPool(poolA); cleanupPool(poolB)
|
|
cleanupTaggedSandboxes(t, tag); cleanupRedisKeys(t, rdb, prefix)
|
|
})
|
|
|
|
ctx := context.Background()
|
|
require.NoError(t, poolA.Start(ctx))
|
|
eventually(t, "primary Redis node owns lock and warms",
|
|
func() bool {
|
|
v, _ := rdb.Get(ctx, lockKey).Result()
|
|
return v == ownerA && snapshotIdle(poolA) >= 2
|
|
}, 0, 0)
|
|
require.NoError(t, poolB.Start(ctx))
|
|
|
|
require.NoError(t, poolB.Resize(ctx, 0))
|
|
eventually(t, "secondary resize to zero is applied by primary",
|
|
func() bool {
|
|
v, _ := rdb.Get(ctx, lockKey).Result()
|
|
return v == ownerA && snapshotIdle(poolA) == 0
|
|
}, 0, 0)
|
|
|
|
require.NoError(t, poolB.Resize(ctx, 2))
|
|
eventually(t, "secondary resize up is applied by primary",
|
|
func() bool {
|
|
v, _ := rdb.Get(ctx, lockKey).Result()
|
|
return v == ownerA && snapshotIdle(poolA) >= 2
|
|
}, 0, 0)
|
|
}
|
|
|
|
func TestRedisPool_ConcurrentCrossNodeAcquireAtomicTake(t *testing.T) {
|
|
rdb := skipWithoutRedis(t)
|
|
tag := poolTag("go-redis")
|
|
prefix := "opensandbox:e2e:" + tag
|
|
poolName := "redis-concurrent-" + tag
|
|
|
|
storeA := newRedisE2EStore(t, rdb, prefix)
|
|
storeB := newRedisE2EStore(t, rdb, prefix)
|
|
poolA := createTestPool(t, poolName, "owner-a-"+tag, storeA, tag, poolMaxIdle, nil)
|
|
poolB := createTestPool(t, poolName, "owner-b-"+tag, storeB, tag, poolMaxIdle, nil)
|
|
t.Cleanup(func() {
|
|
cleanupPool(poolA); cleanupPool(poolB)
|
|
cleanupTaggedSandboxes(t, tag); cleanupRedisKeys(t, rdb, prefix)
|
|
})
|
|
|
|
ctx := context.Background()
|
|
require.NoError(t, poolA.Start(ctx))
|
|
require.NoError(t, poolB.Start(ctx))
|
|
eventually(t, "Redis pool warms two idle", func() bool { return snapshotIdle(poolA) >= 2 }, 0, 0)
|
|
|
|
var (
|
|
mu sync.Mutex
|
|
borrowed []*opensandbox.Sandbox
|
|
ids = make(map[string]bool)
|
|
start = make(chan struct{})
|
|
wg sync.WaitGroup
|
|
)
|
|
t.Cleanup(func() { cleanupBorrowed(borrowed) })
|
|
|
|
for i, p := range []*opensandbox.DefaultSandboxPool{poolA, poolB} {
|
|
wg.Add(1)
|
|
go func(pool *opensandbox.DefaultSandboxPool, idx int) {
|
|
defer wg.Done()
|
|
<-start
|
|
sb, err := pool.Acquire(ctx, opensandbox.AcquireOptions{
|
|
SandboxTimeout: 5 * time.Minute,
|
|
Policy: policyPtr(opensandbox.AcquirePolicyFailFast),
|
|
})
|
|
require.NoError(t, err)
|
|
mu.Lock()
|
|
assert.False(t, ids[sb.ID()], "duplicate sandbox ID")
|
|
ids[sb.ID()] = true
|
|
borrowed = append(borrowed, sb)
|
|
mu.Unlock()
|
|
}(p, i)
|
|
}
|
|
close(start)
|
|
wg.Wait()
|
|
assert.Len(t, ids, 2)
|
|
|
|
// Pure-store contention test: 50 IDs, 16 goroutines.
|
|
contentionStore := newRedisE2EStore(t, rdb, prefix)
|
|
contentionPool := "redis-store-contention-" + tag
|
|
for i := 0; i < 50; i++ {
|
|
require.NoError(t, contentionStore.PutIdle(ctx, contentionPool, fmt.Sprintf("id-%d", i)))
|
|
}
|
|
|
|
var (
|
|
takenMu sync.Mutex
|
|
taken = make(map[string]bool)
|
|
wg2 sync.WaitGroup
|
|
)
|
|
for g := 0; g < 16; g++ {
|
|
wg2.Add(1)
|
|
go func() {
|
|
defer wg2.Done()
|
|
for {
|
|
id, err := contentionStore.TryTakeIdle(ctx, contentionPool)
|
|
if err != nil || id == "" {
|
|
return
|
|
}
|
|
takenMu.Lock()
|
|
assert.False(t, taken[id], "duplicate take: %s", id)
|
|
taken[id] = true
|
|
takenMu.Unlock()
|
|
}
|
|
}()
|
|
}
|
|
wg2.Wait()
|
|
assert.Len(t, taken, 50)
|
|
|
|
counters, err := contentionStore.SnapshotCounters(ctx, contentionPool)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 0, counters.IdleCount)
|
|
}
|
|
|
|
func TestRedisPool_ExpiredIdleReapedByTakeNotSnapshot(t *testing.T) {
|
|
rdb := skipWithoutRedis(t)
|
|
tag := poolTag("go-redis")
|
|
prefix := "opensandbox:e2e:" + tag
|
|
poolName := "redis-expired-idle-" + tag
|
|
t.Cleanup(func() { cleanupRedisKeys(t, rdb, prefix) })
|
|
|
|
store := newRedisE2EStore(t, rdb, prefix)
|
|
ctx := context.Background()
|
|
|
|
require.NoError(t, store.SetIdleEntryTTL(ctx, poolName, 50*time.Millisecond))
|
|
require.NoError(t, store.PutIdle(ctx, poolName, fmt.Sprintf("expired-%d", time.Now().UnixNano())))
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
// SnapshotCounters filters expired entries, so it should already report 0.
|
|
counters, err := store.SnapshotCounters(ctx, poolName)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 0, counters.IdleCount)
|
|
|
|
// TryTakeIdle also returns empty for expired entries.
|
|
id, err := store.TryTakeIdle(ctx, poolName)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, id)
|
|
}
|
|
|
|
func TestRedisPool_ConcurrentAcquireResizeJitter(t *testing.T) {
|
|
rdb := skipWithoutRedis(t)
|
|
tag := poolTag("go-redis")
|
|
prefix := "opensandbox:e2e:" + tag
|
|
poolName := "redis-acquire-resize-jitter-" + tag
|
|
|
|
storeA := newRedisE2EStore(t, rdb, prefix)
|
|
storeB := newRedisE2EStore(t, rdb, prefix)
|
|
poolA := createTestPool(t, poolName, "owner-a-"+tag, storeA, tag, poolMaxIdle, nil)
|
|
poolB := createTestPool(t, poolName, "owner-b-"+tag, storeB, tag, poolMaxIdle, nil)
|
|
t.Cleanup(func() {
|
|
cleanupPool(poolA); cleanupPool(poolB)
|
|
cleanupTaggedSandboxes(t, tag); cleanupRedisKeys(t, rdb, prefix)
|
|
})
|
|
|
|
ctx := context.Background()
|
|
require.NoError(t, poolA.Start(ctx))
|
|
require.NoError(t, poolB.Start(ctx))
|
|
eventually(t, "Redis jitter pool warms two idle", func() bool { return snapshotIdle(poolA) >= 2 }, 0, 0)
|
|
|
|
var (
|
|
mu sync.Mutex
|
|
acquiredIDs = make(map[string]bool)
|
|
borrowed []*opensandbox.Sandbox
|
|
wg sync.WaitGroup
|
|
)
|
|
t.Cleanup(func() { cleanupBorrowed(borrowed) })
|
|
|
|
for i := 0; i < 4; i++ {
|
|
wg.Add(1)
|
|
go func(idx int) {
|
|
defer wg.Done()
|
|
p := poolA
|
|
if idx%2 != 0 {
|
|
p = poolB
|
|
}
|
|
sb, err := p.Acquire(ctx, opensandbox.AcquireOptions{
|
|
SandboxTimeout: 5 * time.Minute,
|
|
Policy: policyPtr(opensandbox.AcquirePolicyDirectCreate),
|
|
})
|
|
require.NoError(t, err)
|
|
mu.Lock()
|
|
assert.False(t, acquiredIDs[sb.ID()], "duplicate sandbox ID")
|
|
acquiredIDs[sb.ID()] = true
|
|
borrowed = append(borrowed, sb)
|
|
mu.Unlock()
|
|
|
|
exec, err := sb.RunCommand(ctx, fmt.Sprintf("echo go-redis-jitter-%d", idx), nil)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, exec.ExitCode)
|
|
assert.Equal(t, 0, *exec.ExitCode)
|
|
}(i)
|
|
}
|
|
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for i := 0; i < 8; i++ {
|
|
p := poolA
|
|
if i%2 != 0 {
|
|
p = poolB
|
|
}
|
|
_ = p.Resize(ctx, i%3)
|
|
time.Sleep(200 * time.Millisecond)
|
|
}
|
|
_ = poolB.Resize(ctx, 2)
|
|
}()
|
|
wg.Wait()
|
|
|
|
assert.Len(t, acquiredIDs, 4)
|
|
eventually(t, "Redis acquire plus resize jitter converges and stays bounded",
|
|
func() bool { return snapshotIdle(poolA) <= 2 && countTaggedSandboxes(t, tag) <= 8 },
|
|
90*time.Second, 0)
|
|
}
|
|
|
|
func TestRedisPool_StaleIdleRemovedAndDirectCreateFallback(t *testing.T) {
|
|
rdb := skipWithoutRedis(t)
|
|
tag := poolTag("go-redis")
|
|
prefix := "opensandbox:e2e:" + tag
|
|
poolName := "redis-stale-" + tag
|
|
|
|
storeA := newRedisE2EStore(t, rdb, prefix)
|
|
storeB := newRedisE2EStore(t, rdb, prefix)
|
|
poolA := createTestPool(t, poolName, "owner-a-"+tag, storeA, tag, 0, nil)
|
|
poolB := createTestPool(t, poolName, "owner-b-"+tag, storeB, tag, 0, nil)
|
|
t.Cleanup(func() {
|
|
cleanupPool(poolA); cleanupPool(poolB)
|
|
cleanupTaggedSandboxes(t, tag); cleanupRedisKeys(t, rdb, prefix)
|
|
})
|
|
|
|
ctx := context.Background()
|
|
require.NoError(t, poolA.Start(ctx))
|
|
require.NoError(t, poolB.Start(ctx))
|
|
|
|
// Inject a nonexistent sandbox ID.
|
|
require.NoError(t, storeA.PutIdle(ctx, poolName, fmt.Sprintf("missing-%d", time.Now().UnixNano())))
|
|
|
|
_, err := poolB.Acquire(ctx, opensandbox.AcquireOptions{
|
|
SandboxTimeout: 2 * time.Second,
|
|
Policy: policyPtr(opensandbox.AcquirePolicyFailFast),
|
|
})
|
|
var failedErr *opensandbox.PoolAcquireFailedError
|
|
require.True(t, errors.As(err, &failedErr), "expected PoolAcquireFailedError, got %T: %v", err, err)
|
|
|
|
counters, err := storeA.SnapshotCounters(ctx, poolName)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 0, counters.IdleCount)
|
|
|
|
sb, err := poolB.Acquire(ctx, opensandbox.AcquireOptions{
|
|
SandboxTimeout: 5 * time.Minute,
|
|
Policy: policyPtr(opensandbox.AcquirePolicyDirectCreate),
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = sb.Kill(context.Background()); _ = sb.Close() })
|
|
require.True(t, sb.IsHealthy(ctx))
|
|
}
|
|
|
|
// ---------- Helpers ----------
|
|
|
|
func policyPtr(p opensandbox.AcquirePolicy) *opensandbox.AcquirePolicy {
|
|
return &p
|
|
}
|