Skip to content

ENG-525: provider-aware warm pool#1

Merged
clawhavenapp merged 2 commits into
mainfrom
commonzenpython/eng-525-pool-per-provider-provider-aware-warm-pool
Jul 7, 2026
Merged

ENG-525: provider-aware warm pool#1
clawhavenapp merged 2 commits into
mainfrom
commonzenpython/eng-525-pool-per-provider-provider-aware-warm-pool

Conversation

@czpython

@czpython czpython commented Jul 6, 2026

Copy link
Copy Markdown
Owner

What

Makes the warm-host pool provider-aware. The pool was single-dimensioned (one POOL_SIZE, all hosts warmed with the default provider), so a warm host for one provider could never satisfy a request pinned to another.

  • Config: new POOL_SIZES JSON env ({"exe": 2, "hetzner": 1}) is the primary sizing knob. Effective targets compose in Settings.get_pool_targets(): POOL_SIZE seeds the default provider, POOL_SIZES entries override it, zero targets drop out. Values are validated >= 0. POOL_MAX_CREATES_PER_TICK stays a single global blast cap; POOL_HOST_MAX_AGE_HOURS unchanged.
  • Maintenance (hosts.pool): counts warm members per provider in one GROUP BY query, then tops up / sheds each provider toward its own target. The per-tick create budget is round-robined across providers so one large deficit can't starve the others. The excess sweep covers the union of configured and present providers, so a provider dropped from the targets sheds to zero instead of idling until its max-age TTL. create_host targets the specific provider.
  • Claim path (get_or_create_host): a pool-eligible request (default image, no env) resolves its provider (provider or DEFAULT_HOST_PROVIDER) and claims only warm hosts WHERE provider == P, gated on P having a warm target. Pinned providers now claim from their own pool instead of always provisioning fresh; a miss falls through to fresh provisioning against P with the same response shape.

POOL_SIZE alias decision

Kept POOL_SIZE working as an alias for the default provider's target (the ticket's stated default, per no-silent-removals): existing deployments keep their warm pool without a config migration. Precedence is explicit — a POOL_SIZES entry for the same provider wins, and an explicit {"exe": 0} disables that provider's pool even when the alias would seed it.

Acceptance criteria

  • With targets configured per provider, each provider maintains its own warm count independently
  • A POST /hosts for provider P (default image, no env) claims a warm P host and never a host of another provider
  • Over-provision from overlapping ticks is shed per-provider on the next tick (existing excess path, now provider-scoped)
  • POOL_MAX_CREATES_PER_TICK still bounds total creates across all providers per tick

Codex review

Round 1 flagged one P2: the round-robin batch was built from a sorted provider order, so a per-tick cap smaller than the number of underfilled providers always spent its budget on the same leading provider — permanently starving the rest if the leader's creates kept failing. Fixed by shuffling the underfilled-provider order each tick (there is no cross-tick state to rotate a cursor through); the within-tick round-robin is unchanged, and a regression test pins that a permanently failing provider cannot starve the others.

Verification

uv run ruff check, uv run ruff format --check, uv run pyright, uv run pytest (282 passed). New tests cover target composition/validation, per-provider top-up and shed, dropped-provider shed, cross-provider cap with round-robin, provider-scoped claim, the no-cross-provider-claim guarantee, and no-starvation under a tight cap with a failing provider.

🤖 Generated with Claude Code

The warm pool was single-dimensioned: one POOL_SIZE, all hosts created
with the default provider. With per-request provider selection, a warm
host for one provider cannot satisfy a request for another, so pool
sizing, maintenance, and claiming become provider-aware.

- POOL_SIZES (JSON, provider -> target) replaces the scalar as the
  primary sizing knob; POOL_SIZE stays as the default provider's alias,
  overridden by a POOL_SIZES entry for the same provider. Effective
  targets compose in Settings.get_pool_targets(), dropping zeroes.
- maintain_pool() counts, tops up, and sheds per provider. The global
  POOL_MAX_CREATES_PER_TICK still bounds total creates per tick, with
  the budget round-robined across providers so one large deficit can't
  starve the others. Providers dropped from the targets shed to zero.
- A pool-eligible POST /hosts (default image, no env) claims only warm
  hosts whose provider matches the requested one — pinned providers now
  claim from their own pool instead of always provisioning fresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@czpython czpython mentioned this pull request Jul 6, 2026
4 tasks
…provider

The round-robin batch was built from a sorted provider order, so whenever
POOL_MAX_CREATES_PER_TICK was smaller than the number of underfilled
providers, every tick spent its budget on the same leading provider(s) —
permanently starving the rest if the leader's creates kept failing. There
is no cross-tick state to rotate a cursor through, so shuffle the
underfilled order each tick instead; the within-tick round-robin is
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@clawhavenapp
clawhavenapp merged commit e6a6258 into main Jul 7, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants