Skip to content

feat(sdks/pool): add RETRY_NEXT_IDLE acquire policies - #1347

Merged
ninan-nn merged 9 commits into
mainfrom
feat/pool-retry-next-idle-policy
Jul 24, 2026
Merged

feat(sdks/pool): add RETRY_NEXT_IDLE acquire policies#1347
ninan-nn merged 9 commits into
mainfrom
feat/pool-retry-next-idle-policy

Conversation

@Pangjiping

@Pangjiping Pangjiping commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds two new `AcquirePolicy` values across Kotlin, Go, and Python SDKs so `acquire` can skip a stale idle candidate and retry the next one. This addresses the case where the pool contains a mix of healthy and unreachable idles — custom templates with long cold-start, or leftover unhealthy idles after a network flap — where a single bad idle used to force either `FAIL_FAST` or a slow `DIRECT_CREATE`.

New policies:

  • `RETRY_NEXT_IDLE`: try up to `maxAcquireRetries` idle candidates; if all fail, raise `PoolAcquireFailedException` (fail-fast semantics on exhaustion).
  • `RETRY_NEXT_IDLE_THEN_CREATE`: same retry loop, but fall through to direct-create instead of raising.

`FAIL_FAST` / `DIRECT_CREATE` keep single-shot semantics unchanged.

Policy Retry across idles Fallback on exhaustion
`FAIL_FAST` no throw `PoolEmptyException` / `PoolAcquireFailedException`
`DIRECT_CREATE` (default) no lifecycle create
`RETRY_NEXT_IDLE` up to `maxAcquireRetries` throw
`RETRY_NEXT_IDLE_THEN_CREATE` up to `maxAcquireRetries` lifecycle create

New config field: `maxAcquireRetries` / `MaxAcquireRetries` / `max_acquire_retries` (default 3, must be `>= 1`). Only takes effect under the retry policies. Bound is required because each failed candidate still pays up to `acquireReadyTimeout`; without a cap, mass-stale idle would freeze all acquires.

Implementation notes

  • Pool store contract is unchanged. `tryTakeIdle` is atomic pop, so the loop advances via repeated take calls without a new store API.
  • Discarded-alive sandbox ids are accumulated across retry iterations and cleaned up asynchronously in a single batch after acquire returns/throws — same async cleanup path as before, just fed once instead of once per attempt.
  • Loop re-checks pool lifecycle between iterations so an in-flight shutdown / namespace destroy short-circuits the retry loop instead of paying additional ready-timeouts.
  • Go `Acquire` additionally honours `ctx` cancellation between iterations.
  • Enum ordering: Go uses `iota`, new values are appended so `AcquirePolicyDirectCreate == 0` still holds.

Design decisions

  • Fallback via enum combinations (not an orthogonal `fallbackToDirectCreate: bool`). Keeps the acquire signature unchanged; users get four discrete, self-describing choices.
  • Retry bound is a count (not a wall-clock deadline). Simpler to reason about; a deadline can be layered on later if operators need it.
  • OSEP-0005 not updated — the enum extension is additive and the retry loop is a client-side implementation detail; can follow up in a separate PR if the OSEP maintainer wants it in the spec.

Not in scope (possible follow-ups)

  • No `PoolSnapshot` counter for evicted stale idles. The retry policies will amplify silent stale churn; `bad_idle_evicted_total` on the snapshot would help operators distinguish "pool healthy" from "pool churning" but is additive and can ship separately.
  • JS / C# SDKs have no pool today, so no changes there.

Testing

  • Unit tests
    • Kotlin: 6 new tests (`SandboxPoolTest`) — empty idle raise, all-stale bounded retries, drained-mid-loop, `_THEN_CREATE` fallthrough (with exhaustion / with empty), config validation. Full suite: 33 passed (`./gradlew :sandbox:test`).
    • Go: 9 new tests in a new `pool_retry_test.go` — includes a `staleAwareLifecycleServer` mock, `ctx` cancellation, and helper-function unit tests. Full suite: PASS (`go test -count=1 ./...`).
    • Python: 14 new tests (7 sync + 7 async) mirroring Kotlin coverage. Full suite: 315 passed (`uv run pytest tests/`). `ruff` clean; `pyright` clean on source, no new errors on tests (8 pre-existing `FakeSandbox` type-cast warnings unchanged).
  • Integration tests
  • e2e / manual verification

Docs: VitePress build passes (`pnpm docs:build`).

Breaking Changes

  • None. Enum values are appended (Go iota ordering preserved), default behavior of existing policies is unchanged, new `maxAcquireRetries` field has a default. Public interfaces stay backward-compatible.

Checklist

  • Linked Issue or clearly described motivation — see Summary
  • Added/updated docs (`docs/sdks/{kotlin,go,python}.md` — AcquirePolicy comparison table)
  • Added/updated tests (see Testing)
  • Security impact considered — no new surface; policy is client-side only
  • Backward compatibility considered — see Breaking Changes

Adds two new AcquirePolicy values across Kotlin, Go, and Python SDKs so
acquire can skip a stale idle candidate and retry the next one, addressing
the case where the pool contains a mix of healthy and unreachable idles
(custom templates with long cold-start; leftover unhealthy idles after a
network flap):

- RETRY_NEXT_IDLE: try up to maxAcquireRetries idle candidates; if all
  fail, raise PoolAcquireFailedException (fail-fast semantics).
- RETRY_NEXT_IDLE_THEN_CREATE: same retry loop, but fall through to
  direct-create instead of raising.

FAIL_FAST and DIRECT_CREATE keep their single-shot semantics unchanged.
Retry bound is a new maxAcquireRetries config field (default 3, must be
>= 1); each attempt still pays up to acquireReadyTimeout so the bound is
required to cap worst-case latency.

Implementation notes:
- Pool store contract unchanged. tryTakeIdle is atomic pop, so the loop
  advances via repeated take calls; no new store API required.
- Discarded-alive sandbox ids are accumulated across retry iterations and
  cleaned up asynchronously in a single batch after acquire returns/raises.
- Loop re-checks pool lifecycle between iterations so an in-flight
  shutdown / namespace destroy short-circuits instead of paying extra
  ready-timeouts.
- Go acquire additionally honours ctx cancellation between iterations.

Tests: 6 Kotlin + 9 Go + 14 Python (sync 7 / async 7) new tests covering
empty idle, all-stale, drained-mid-loop, then_create fallthrough, first-
healthy short-circuit, config validation. Existing pool tests unchanged.

Docs updated for all three SDKs with an AcquirePolicy comparison table.
@github-actions github-actions Bot added documentation Improvements or additions to documentation sdk/go sdk/java sdk/python sdks labels Jul 20, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4d19e92f38

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread sdks/sandbox/go/pool.go Outdated
Comment thread sdks/sandbox/python/src/opensandbox/sync/pool.py
Comment thread sdks/sandbox/python/src/opensandbox/pool_async.py
Codex review (PR #1347) pointed out that renew failures were being caught
by the same handler as connect/readiness failures. When sandboxTimeout is
set, acquire calls sandbox.renew(...) after a successful connect; if the
lifecycle API rejects the renew (429, 5xx, transient), the RETRY_NEXT_IDLE*
policies were removing the just-connected healthy sandbox, best-effort
killing it, and looping through up to maxAcquireRetries more healthy idles
that could not succeed for the same reason. This drained healthy pool
entries and masked the real (server-side) failure as PoolAcquireFailedError.

Fix: split connect+readiness from renew across all three SDKs.
- Connect / readiness / health-check failure => idle is stale, remove +
  best-effort kill + continue loop (unchanged behavior).
- Renew failure (against an already-connected sandbox) => close the just-
  connected sandbox best-effort and surface the raw renew error. Do NOT
  removeIdle, do NOT kill, do NOT continue.

Also renames the Go internal helper connectAndRenew to connectIdle since
it no longer performs the renew.

Tests: 1 new Go regression test (renewFailingLifecycleServer returns 500
on POST /renew-expiration; asserts single renew attempt, healthy idles
untouched, error not wrapped as PoolAcquireFailedError). 2 new Python
regressions (sync + async) using existing FakeSandbox.fail_renew=True.
Kotlin behavior is verified by inspection since Sandbox.connector() has
no injection point in existing tests; Go + Python cover the invariant.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b967369802

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread sdks/sandbox/go/pool.go
Comment thread sdks/sandbox/python/src/opensandbox/sync/pool.py
Comment thread sdks/sandbox/python/src/opensandbox/pool_async.py
Comment thread sdks/sandbox/go/pool.go
… retry loop

Two follow-ups from Codex review round 2 (PR #1347):

1. Renew-failure path leaked the remote sandbox (all three SDKs).
   tryTakeIdle atomically pops the id out of the idle store; then the
   just-connected sandbox failed renew. The previous fix only called
   Close() / close(), which releases local HTTP resources but leaves the
   remote sandbox alive on the server, untracked, until its TTL expires.
   Fix: kill the remote sandbox best-effort before closing the local
   handle and re-raising. Kotlin / Python call sandbox.kill(); Go schedules
   killSandboxBestEffort (matches how stale-idle failures already kill).

2. Go retry loop did not re-check p.lifecycleState between iterations.
   Kotlin and Python already do this so a Shutdown(graceful=true) in
   another goroutine short-circuits the RETRY_NEXT_IDLE loop instead of
   paying AcquireReadyTimeout per remaining retry (Shutdown uses its own
   context and does not cancel the caller's acquire ctx). Added an
   equivalent lifecycle re-check after each candidate failure.

Tests updated:
- Go regression renamed to RenewFailure_KillsRemoteAndDoesNotRetry;
  renewFailingLifecycleServer now records DELETE calls and the test
  asserts exactly one healthy-* DELETE (via short poll for async kill).
- Python sync + async regressions now assert connected[0].killed == True
  by capturing the connected sandbox via a TrackingSandbox subclass
  (FakeSandbox.connect() had no last_created hook).
Comment thread sdks/sandbox/python/src/opensandbox/sync/pool.py Fixed
Comment thread sdks/sandbox/python/src/opensandbox/pool_async.py Fixed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bc7c3545e9

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread sdks/sandbox/python/src/opensandbox/sync/pool.py Outdated
…_THEN_CREATE

Codex review round 3 pointed out that PoolStateStoreUnavailableException
raised by try_take_idle was propagating unguarded in Python (sync + async)
and Kotlin, aborting acquire before the fallthrough branch could create
a sandbox. OSEP-0005 and the Go implementation already degrade to
direct-create under fallthrough policies during store outages, so the
new RETRY_NEXT_IDLE_THEN_CREATE (and existing DIRECT_CREATE) were less
available than documented in Python/Kotlin.

Fix: wrap the store take in try/except (Python) / try/catch (Kotlin) and:
- Under DIRECT_CREATE / RETRY_NEXT_IDLE_THEN_CREATE: log the outage and
  break out of the retry loop so control falls through to directCreate().
- Under FAIL_FAST / RETRY_NEXT_IDLE: schedule the pending discarded-alive
  cleanup and re-raise so callers observe the outage.

Also extracts a Kotlin policyFallsThroughToDirectCreate() helper so all
three SDKs share one fallthrough classification, and replaces the
Python except Exception: pass at the renew-failure close() site with
a debug-logged handler (github-code-quality[bot] lint feedback).

Tests: 2 Kotlin + 2 Python sync + 2 Python async new regressions using
an OutageStore fake that raises PoolStateStoreUnavailableException from
both try_take_idle variants. Coverage: THEN_CREATE falls through,
non-fallthrough policies re-raise.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 159d2e8882

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread sdks/sandbox/python/src/opensandbox/pool_async.py Outdated
Codex review round 4: the awaited stale-candidate kill was blocking the
RETRY_NEXT_IDLE* retry loop in Python (sync + async) and Kotlin. When a
lifecycle DELETE takes near the default request timeout (30s), a pool
ordered [stale, healthy] still waits on the stale kill before returning
the healthy sandbox, and each retry adds another DELETE timeout. Go
already fires kill in the background (`go p.killSandboxBestEffort`),
so this brings the other three implementations in line.

Fix: route the stale-candidate kill through the existing warmup executor
via _schedule_kill_discarded_alive / scheduleKillDiscardedAlive (which
already falls back to inline execution mid-shutdown so cleanup is never
silently dropped). Uses a 1-tuple / singleton list to reuse the existing
fire-and-forget path.

Tests:
- Two existing stale-kill tests (Python sync and async
  test_*acquire_fail_fast_stale_idle_raises_and_kills_candidate,
  test_*acquire_retry_next_idle_all_stale_bounds_retries_and_raises)
  now use _eventually(...) to poll for the background kill; they used
  to assert synchronously and broke with the fire-and-forget change.
- Two new positive regressions
  (test_*acquire_retry_next_idle_does_not_block_on_slow_stale_kill,
  sync + async) inject a SlowKillManager that blocks kill_sandbox for
  2s and assert acquire returns in <2s with the healthy candidate.
  This directly proves the invariant Codex flagged: retry latency is
  bounded by connect/readiness, not by cleanup.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1652b0a789

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread sdks/sandbox/python/src/opensandbox/sync/pool.py
Comment thread sdks/sandbox/python/src/opensandbox/pool_async.py
@ninan-nn

Copy link
Copy Markdown
Collaborator

Non-blocking suggestion: the new retry policies are covered well by SDK unit tests, but this PR does not appear to add e2e/manual coverage for the new behavior. It would be useful to add or document an e2e/manual scenario with a real pool containing stale idle IDs ahead of a healthy idle, verifying that RETRY_NEXT_IDLE skips stale candidates and returns the healthy sandbox, and that RETRY_NEXT_IDLE_THEN_CREATE falls through to direct create when the retry budget is exhausted. This would give confidence that lifecycle connect/readiness failures and remote cleanup behave correctly outside the fake/mock store setup.

…o/Kotlin

Adds three e2e scenarios exercising the new RETRY_NEXT_IDLE and
RETRY_NEXT_IDLE_THEN_CREATE acquire policies against a real sandbox
backend. All three languages cover the same behaviors:

- Skip a stale idle candidate and return the next healthy warm sandbox
  (mixed idle queue: stale ahead of healthy).
- RETRY_NEXT_IDLE_THEN_CREATE falls through to direct-create after the
  bounded retry budget exhausts on an all-stale idle queue.
- RETRY_NEXT_IDLE respects maxAcquireRetries and raises
  PoolAcquireFailedException without falling through to direct-create,
  so no tagged sandbox is created.

Construction of the "mixed" scenario is the same across languages: put
a nonexistent sandbox id into the state store before pool.start() so
FIFO idle ordering guarantees the stale entry sits ahead of the real
idle the reconciler warms to reach maxIdle. Once idle_count == maxIdle
the reconciler holds steady (no shrink, no rewarm) and acquire under
RETRY_NEXT_IDLE pops the stale, fails its ready-check, and returns the
healthy warm sandbox behind it.

Test-only changes to fixture helpers:
- tests/go/base_e2e_test.go: poolCreateOpts.maxAcquireRetries.
- tests/python/tests/test_sandbox_pool_e2e_async.py: _create_pool
  gains max_acquire_retries kwarg (default 3, matches SDK).
- Java tests inline SandboxPool.builder().maxAcquireRetries(...).

Verified: ruff+pyright (Python), gofmt+go vet+go build (Go),
compileTestJava+spotlessCheck (Java). Not run against a live sandbox
backend; requires an OpenSandbox server to actually exercise.
@github-actions github-actions Bot added the size/XL Denotes a PR that changes 500-999 lines, ignoring generated files. label Jul 22, 2026
…aces

Follow-up on the previous commit. Two silent-false-green risks in the new
e2e tests, spotted during a hindsight review:

1. mixed test (test_async_retry_next_idle_skips_stale_and_returns_healthy_warm
   and its Go/Kotlin counterparts) assumed FIFO ordering put the pre-injected
   stale id ahead of the reconciler-created healthy sandbox. FIFO is in fact
   guaranteed across all three InMemory stores (verified via source), but if a
   future store change ever reordered, the test would silently take the
   "healthy first" happy path and no longer cover RETRY. Added an explicit
   snapshotIdleEntries head-of-queue assertion before acquire.

2. all-stale + THEN_CREATE test used default reconcile_interval (Python/Go 1s;
   Java already 5min). With a 1s tick and per-candidate ready_timeout of 3s,
   the reconciler could shrink stale ids concurrently with the retry loop,
   causing the loop to see an empty queue mid-iteration and take the
   "idle drained" fallthrough branch instead of the "loop_exhausted"
   fallthrough branch. Both branches return a healthy sandbox and pass the
   existing asserts, but only loop_exhausted actually exercises the RETRY
   semantics under test.
   Fixed by:
   - Setting reconcile_interval to 5 minutes on the Python/Go pools (Java
     already had this).
   - Asserting after acquire that all pre-injected stale ids have been
     removed from the store. Under the long reconcile_interval, only the
     acquire retry loop can drain the queue, so this proves the loop
     actually iterated over every stale candidate.

Also verified (no code change, just recorded):
- Go/Kotlin/Python InMemoryPoolStateStore are all FIFO
  (idleQueue[0]/idleQueue[1:], state.queue.poll()/add, popleft/append).
- Reconciler in all three SDKs does not proactively ready-check idle
  entries; it only removes them when idle_count > max_idle (shrink) or
  when the store-side TTL expires (reap). Since our injected stale
  entries live under the default 24h idle TTL, they are stable in the
  queue for the duration of the test.
- Kotlin acquire path raises PoolAcquireFailedException directly (not
  SandboxException with a code), so the Java test's assertThrows type
  is correct.
- SDK unit tests exercising the same RETRY paths pass locally
  (sdks/sandbox/python tests/test_pool_async.py -k retry_next_idle: 10
  passed; sdks/sandbox/go TestPool_Acquire_Retry*: passed).

Still NOT run against a live sandbox backend; requires an OpenSandbox
server. The hardening above is what can be verified statically.
@Pangjiping

Copy link
Copy Markdown
Collaborator Author

@ninan-nn Addressed in 246bf61 + 5682aa6 — added e2e coverage across Python (async), Go, and Kotlin/Java for both new policies:

Python tests/python/tests/test_sandbox_pool_e2e_async.py

  • test_async_retry_next_idle_skips_stale_and_returns_healthy_warm
  • test_async_retry_next_idle_then_create_falls_through_when_all_stale
  • test_async_retry_next_idle_all_stale_raises_after_bounded_retries

Go tests/go/pool_e2e_test.go

  • TestPool_RetryNextIdleSkipsStaleAndReturnsHealthyWarm
  • TestPool_RetryNextIdleThenCreateFallsThroughWhenAllStale
  • TestPool_RetryNextIdleAllStaleRaisesAfterBoundedRetries

Java (Kotlin SDK) tests/java/.../SandboxPoolSingleNodeE2ETest.java

  • testRetryNextIdleSkipsStaleAndReturnsHealthyWarm (@order 19)
  • testRetryNextIdleThenCreateFallsThroughWhenAllStale (@order 20)
  • testRetryNextIdleAllStaleRaisesAfterBoundedRetries (@order 21)

How the mixed [stale, healthy] scenario is constructed (real pool, no mocks):

  1. putIdle(pool_name, stale_uuid) on the state store before pool.start(). The three InMemory stores are all FIFO (verified: Python popleft/append, Go idleQueue[0]/append, Kotlin queue.poll()/queue.add), so the stale id sits at the head.
  2. pool.start() with maxIdle=2. The reconciler warms exactly one real sandbox to reach the target, appending its id behind the stale — queue is now [stale, real] and stays there because idle_count == max_idle (reconciler does not proactively ready-check idle entries, only shrinks when count > target or reaps expired entries).
  3. Each test asserts snapshotIdleEntries()[0].sandbox_id == stale_id before acquire, so if a future store change ever reordered on putIdle, or the reconciler ever reaped the stale before warmup landed, we catch the regression instead of silently taking the "healthy first" happy path.
  4. acquire(RETRY_NEXT_IDLE) then goes through the real code path: sandboxFactory.connect(stale_id) hits the lifecycle API, gets 404, the SDK's stale-idle branch runs (removeIdle + fire-and-forget kill + continue), and the next candidate — the real warm sandbox — connects successfully. This exercises exactly the "lifecycle connect/readiness failures and remote cleanup" surface you called out.

All-stale fallthrough test (test 2) has a subtle timing consideration: with the default 1s reconcile interval, the reconciler can concurrently shrink stale ids during the acquire loop, causing the loop to see "idle drained mid-loop" instead of "loop_exhausted" — both fallthrough to direct-create and pass the naive assertion, but only one actually exercises the retry semantics. Fixed in 5682aa6 by (a) setting reconcile_interval=5min on the isolated test pool so only the acquire loop can pop entries, and (b) asserting all pre-injected stale ids are removed after acquire, which proves the retry loop iterated over every candidate before falling through.

Verified: ruff+pyright (Python), gofmt+go vet+go build (Go), compileTestJava+spotlessCheck (Java) all pass. SDK unit tests for the retry paths still pass. Not run against a live sandbox backend from my dev environment; CI e2e will exercise them.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5682aa6b1b

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread sdks/sandbox/python/src/opensandbox/sync/pool.py
… outage

Codex review round 5 (P2) pointed out that the state-store-outage fallthrough
added in commit 159d2e8 was incomplete: the acquire path has two additional
state-store touchpoints outside `try_take_idle`, and both were unguarded in
Python (sync + async) and Kotlin. When the whole state store (e.g. Redis) is
down — not just `try_take_idle*` — full acquire under RETRY_NEXT_IDLE_THEN_CREATE
still aborted before reaching the fallthrough branch, making the policy
strictly less available than OSEP-0005 documents. The Round 3 outage tests
missed this because their fake store only overrode `try_take_idle*` and left
`get_destroy_state` returning ACTIVE via the default.

The two unguarded touchpoints in acquire were:

1. Pre-loop `_ensure_pool_namespace_active()` call, which reads
   `get_destroy_state` — Redis down here aborts before the retry loop.
2. Post-create `_ensure_pool_namespace_active_after_create()` inside
   `_direct_create()` — Redis down here kills the freshly-created sandbox
   and re-raises, even under a fallthrough policy.

Fix (Python async, Python sync, Kotlin; three languages kept in sync):

- New `_ensure_pool_namespace_active_for_acquire(policy)` /
  `ensurePoolNamespaceActiveForAcquire(policy)` helper. If the state store
  is unavailable (`PoolStateStoreUnavailableException`) and the policy
  falls through to direct-create, we treat destroy-state as *unknown* and
  proceed. Non-fallthrough policies (FAIL_FAST / RETRY_NEXT_IDLE) keep
  fail-closed behavior — the outage is surfaced as-is so callers can react.
  Used at acquire's pre-loop check and inside `_direct_create()`.

- `_ensure_pool_namespace_active_after_create(sandbox, policy=...)` /
  `ensurePoolNamespaceActiveOrDispose(sandbox, policy=...)` now takes an
  optional policy. On store outage under a fallthrough policy, the
  freshly-created sandbox is kept (matches the "at least as available as
  raw SDK usage" guarantee); on `PoolDestroyedException` or under a
  non-fallthrough policy, the original fail-closed cleanup + rethrow path
  runs. Backwards-compatible: existing callers passing no policy default
  to fail-closed.

Go is unaffected: Go's Acquire does not have a pre-loop or post-create
`get_destroy_state` check, so the Round 3 fallthrough already covered it.

Tests: 2 Python async + 2 Python sync + 2 Kotlin new regressions. Each
uses an `OutageStore(WithNamespaceFailure)` fake that starts healthy so
`pool.start()` succeeds (mirroring a Redis that crashes after warmup),
then flips to full outage mode where every store method — including
`get_destroy_state` — raises `PoolStateStoreUnavailableException`.
- Fallthrough coverage: acquire under RETRY_NEXT_IDLE_THEN_CREATE still
  returns a direct-created sandbox (was aborting with the outage exception
  before this fix).
- Fail-closed coverage: acquire under RETRY_NEXT_IDLE still surfaces
  PoolStateStoreUnavailableException (behavior preserved).

All existing pool unit tests continue to pass:
- sdks/sandbox/python: 64 pool tests
- sdks/sandbox/kotlin: SandboxPoolTest full suite
@github-actions github-actions Bot added size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files. and removed size/XL Denotes a PR that changes 500-999 lines, ignoring generated files. labels Jul 22, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0587392d44

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread sdks/sandbox/python/src/opensandbox/sync/pool.py

@ninan-nn ninan-nn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@ninan-nn
ninan-nn merged commit 1178b47 into main Jul 24, 2026
52 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation sdk/go sdk/java sdk/python sdks size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants