Skip to content

fix(platform): size the gateway's provider worker pools by record kind - #4574

Merged
yannickmonney merged 11 commits into
mainfrom
fix/sandbox-gateway-provider-concurrency
Oct 9, 2026
Merged

yannickmonney merged 11 commits into
mainfrom
fix/sandbox-gateway-provider-concurrency

Conversation

@yannickmonney

@yannickmonney yannickmonney commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Every provider record the platform writes into the sandbox model gateway (Bifrost) asked for 1,000 request workers and a 5,000-request queue. Bifrost starts a record's workers as goroutines as soon as it loads the record, at boot and on every write, and keeps them parked whether or not anything calls it. Custom providers get one record per organization, connector and model, plus an Anthropic-lane sibling. So the gateway's idle memory grew with organizations times custom models, against its 512 MiB limit. A gateway over that limit crash-loops at boot.

Changes

test(platform): pin the legacy flow's gateway helper, not its whole module

The released legacy agent flow reuses one function of llm_gateway_admin.ts,
hashVirtualKey, and its provenance pinned the whole file's hash, so any
change elsewhere in the module (here the provider records' worker pools)
failed the guard although the helper the flow runs is unchanged. It is now
pinned by that declaration's hash, the way the guard already pins the
sections it reuses from external_turn_shared.ts and others. The fixture is
byte-identical to the same switch on the egress-supervision branch, so the
two merge cleanly.

fix(platform): size the gateway's provider worker pools by record kind

Every provider record the platform puts into the sandbox model gateway
asked for 1,000 request workers and a 5,000-deep queue. The gateway
(Bifrost core v1.11.0 in the pinned v2.2.4 image) starts a record's
workers as goroutines eagerly: for every stored record at boot (Init ->
prepareProvider) and again on every config or key write (UpdateProvider),
and they stay parked whether or not anything calls the record. A custom
connector gets one record per (organization, connector, model) plus its
Anthropic-lane sibling, so parked workers grow with organizations times
custom models. At 2-8 KiB of stack per goroutine a record costs about
2-8 MiB, so 64 to 256 records fill the gateway's 512 MiB limit on stacks
alone, and a gateway over its limit is killed again at every boot.

A shared standard record now gets 512 workers and an org-scoped custom
record 64, each with a queue 16 deep per worker (the gateway refuses a
queue shorter than the pool): a hundred custom records park 6,400
workers instead of 100,000. A stream holds its worker only until the
upstream's response headers arrive, and a sandbox host sizes itself to
at most 256 sessions, so 64 covers one organization's burst on one
model; a request past the pool waits in the queue.

SANDBOX_LLM_GATEWAY_PROVIDER_CONCURRENCY and
SANDBOX_LLM_GATEWAY_CUSTOM_PROVIDER_CONCURRENCY override the two sizes.
A value that is not a positive whole number keeps the default with one
warning, since the gateway would refuse every provision with it; one
above 5,000, the gateway's default connections per upstream host, is
held there. The backend reads both from its .env like the other
gateway settings, so no compose or CLI change is needed. The pool is
part of the provision fingerprint, so a resized record is rewritten at
its next provision.

fix(platform): keep the gateway queueing a burst instead of dropping it

The gateway's provider records now carry smaller worker pools and
queues (64 workers and 1,024 queue slots on a custom record). A request
that finds a record's queue full is held until there is room, as long
as the gateway's client setting drop_excess_requests is off, which is
its default (Bifrost core v1.11.0, tryRequest and tryStreamRequest). With the setting on, the
same request is answered 503 "request dropped: queue is full" at once.
The platform merged whatever the gateway's store held and never looked
at the flag, so a store restored with it on, or an operator's edit,
would turn a burst past 1,088 requests on one record into failed
agent calls.

The posture check run on every sandbox create now treats the flag as
drift too, and the config write keeps it off, the same way it keeps
inference behind virtual keys and content out of the request log. A
store that already has it off (every gateway the platform set up) is
left alone, with no extra write.

Review follow-ups

An adversarial review found that only a record a later provision names gets resized. Records nothing provisions again would keep 1,000 workers forever, and a gateway already crash-looping at boot had no recovery path. A second review pass then hardened the resize pass. The fixes, one commit each:

fix(platform): resize gateway provider records no provision rewrites

Only a provision that names a record writes its worker pool, and a
session provisions just the records of its own allowed models. A record
nothing provisions again (a model an organization removed or renamed, an
__anthropic sibling of a lane no session rides, an inactive
organization's records, a rarely used model) kept the 1,000 workers and
5,000-deep queue it was stored with, and the gateway restarts those
workers at every boot. A hundred such records still park 100,000
goroutines, so the smaller pools alone did not bound the gateway's
memory.

Each backend process now runs one shrink pass in the background once its
first provision has applied the gateway's auth posture: it lists every
record (GET /api/providers) and writes each one with more workers than
its kind's pool back with that pool. PUT /api/providers/:name replaces
network_config whole and refuses a custom record without its
custom_provider_config (Bifrost transports v2.2.4, updateProvider), so
the pass sends the record's own network block, custom upstream and
raw-payload switches back with it, and leaves out the blocks the PUT
keeps when absent, whose secrets the listing masks.

The pass must never write an old config over a newer one. A record this
process has provisioned is skipped, a provision of a record waits for
the pass's write to it, and each record is read again right before its
write, which narrows a race with another backend process to the moment
between that read and the write. A failed listing is tried again at the
next provision; a record the gateway refuses keeps its pool, with a
warning, until the next process.

docs(sandbox): recover a model gateway killed while it boots

The gateway (Bifrost core v1.11.0) starts every stored provider
record's workers inside Init, before it serves HTTP. A store whose
records still carry the gateway's default 1,000 workers needs about
2-8 MiB of goroutine stacks per record, so 64 to 256 such records
exceed the 512 MiB limit that tale deploy (mem_limit) and the
Kubernetes manifest (limits.memory) set. The gateway is then killed at
every boot, and the backend, which resizes records only through the
management API, never gets to run its shrink pass. Nothing told an
operator how to get out of that state, and the compose comment warns
against wiping the gateway volume, which holds the admin account and
the minted keys with their spend.

The gateway README and the environment reference (EN, DE, FR) now give
the path: raise the container's memory limit until the gateway starts
(docker update on a tale deploy host, which keeps the restart policy
and needs no compose override, or the Deployment's limit on
Kubernetes), start one sandbox session or model-endpoint request so the
backend resizes the records and logs it, then restore the limit the
deployment sets (512m memory, 1g memory plus swap) and restart.

docs(sandbox): size the Anthropic lane of a built-in provider as custom

The environment reference and .env.example said
SANDBOX_LLM_GATEWAY_PROVIDER_CONCURRENCY sizes the built-in providers
(OpenAI, Anthropic, OpenRouter). That holds for their shared records,
but Claude Code through OpenRouter's Anthropic endpoint rides an
org-scoped record of its own (resolveGatewayRouting names it
openrouter__anthropic), and the gateway sizes that
record with SANDBOX_LLM_GATEWAY_CUSTOM_PROVIDER_CONCURRENCY: 64
workers, not 512, as the pool test for that lane already pins. An
operator raising the built-in value for Claude Code load on OpenRouter
would see no effect.

The CUSTOM row (EN, DE, FR) and the .env.example block now say that
lane gets an organization-and-model provider of its own and follows
the custom value.

fix(platform): confirm a gateway resize whose answer came too late

The shrink pass gave each record's PUT the 15 s every management call
gets and counted a record only on a success answer. The gateway (Bifrost
transports v2.2.4, updateProvider) resolves a custom record's base URL
(up to 5 s), stores the record and restarts its workers, and only then
asks the upstream for its models (up to 15 s) before it answers. A
record whose upstream is unreachable was therefore resized in the
gateway yet logged as failed and left out of the count, and the pass
printed its "resized" line only when that count was above zero: the
line the recovery steps tell an operator to wait for might never come.

A resize write now gets 30 s (5 s + 15 s, plus margin). One the gateway
fails with a 5xx or does not answer is read back, and counts as resized
when the record holds the new pool, else as unconfirmed. The pass
always ends with one line, "[llm-gateway] provider worker resize
finished: resized, refused, unconfirmed", zeros included;
the gateway README, the environment reference (EN, DE, FR) and the
automation register name it. The pass also checks again after its read,
right before the write, whether a provision of this process took the
record, so a provision waits only for a write already sent, never for
the read before it.

fix(platform): never let a gateway resize recreate a deleted record

The gateway's PUT /api/providers/:name is an upsert: for a record it
does not hold it calls AddProvider with the payload (Bifrost transports
v2.2.4, updateProvider). The shrink pass sent its write through the
management client, which sends a write the gateway's store turned away
with a 5xx again after 250, 750 and 2,000 ms, blind. A record deleted in
those seconds (a base-type recreate by another process, an operator's
cleanup, an organization teardown) came back without keys, with 64 or
512 workers and its old base URL, and nothing deleted it again.

The resize write now takes the first answer and decides itself. After a
5xx it waits the same 250, 750 and 2,000 ms, reads the record, and
sends the write again only while the record is still there and still
over its pool, with the config it reads then; a record gone or resized
meanwhile settles the outcome. A write the gateway did not answer may
still be under way there, so it is read back and never sent again.

fix(platform): start the gateway shrink pass after the startup burst

The gateway's provider write copies the record's keys when the request
arrives and stores that copy (Bifrost transports v2.2.4,
updateProvider), and it resolves a custom record's base URL (up to 5 s)
before it stores. A key or config another backend process writes to the
same record in that window is undone by the shrink pass's write, and
that process's fingerprint memo then keeps it from writing the record
again until it restarts. Every fresh process rewrites each record and
key it provisions once, which is also when keys rotated through the
environment land, and backend-api runs two replicas on Kubernetes, so a
pass started at the first provision ran inside exactly that burst, in
both replicas at once.

The first provision now schedules the pass two minutes plus up to three
more at random later, once per process, with an unref'd timer; a pass
whose listing failed is scheduled again by the next provision. The
window remains for a write that lands while the gateway handles the
pass's write to that record, now outside the deploy burst; the
shrinkProviderPools comment and the gateway README describe it. The
environment reference (EN, DE, FR), README, .env.example and the
automation register say the resize comes two to five minutes after the
first session or model-endpoint request.

fix(platform): say how to clear a gateway record no resize can reach

The gateway resolves the base URL every provider write carries (Bifrost
transports v2.2.4, updateProvider: ValidateExternalURL, a DNS lookup
bounded at 5 s) and answers 400 "Invalid base URL: failed to resolve
hostname" when the host is gone. The records whose upstream disappeared
(a removed connector's, a decommissioned LAN host's) are exactly the
orphans no provision rewrites, so the shrink pass of every backend
process was refused on them and they kept the pool they were stored
with, up to the gateway's 1,000 workers (2-8 MiB of parked stacks each).
The environment reference said a lower count reaches every other
provider, and the recovery steps had no remedy when the gateway still
did not start within 512 MiB.

A refused resize of a record that carries a base URL now logs that the
record keeps its workers and names DELETE /api/providers/ as the
remedy, since a provision sets up a record still in use again. A
built-in provider's record carries no base URL and holds every
organization's key, so its refusal says only that it keeps its workers.
The environment reference (EN, DE, FR), the gateway README's recovery
steps, .env.example and the automation register document the exception
and the delete, sent from a backend container with curl and the admin
password it already holds.

Verification

  • Checked against Bifrost's source at the pinned maximhq/bifrost:v2.2.4 (core v1.11.0): workers start up front at Init and on UpdateProvider; the PUT refuses concurrency of 0 or above buffer_size; when the queue is full and drop_excess_requests is off, a request waits instead of getting a 503.
  • services/platform: llm_gateway_admin.test.ts 102 pass. That includes the defaults, both overrides, invalid values warning once, the 5,000 cap, a resized record being rewritten through the fingerprint, and a store with dropping turned on being rewritten. The posture tests fail with that fix removed.
  • Guards pass: the legacy agent-flow provenance guard, domain specs and env-example scope. Docs locale and structure checks pass, and so does lint:manual.
  • @tale/platform typecheck and lint are clean, and commitlint passes.
  • The provenance fixture change matches fix(sandbox): supervise egress DNS and clear deleted orgs' gateway keys #4545's exactly, so the two merge cleanly.
  • Not run against a live gateway. The per-record memory figure (2–8 KiB per parked goroutine) is derived from Go stack sizes, not measured.

Risks

  • The once-per-process resize pass runs 2–5 minutes after the first provision. It can race with a key or config write from another backend replica to the same record. That replica's memo then keeps the record as the resize wrote it until it next restarts. A lost key comes back at the next provision.

  • When fix(sandbox): supervise egress DNS and clear deleted orgs' gateway keys #4545 lands, its organization teardown and this resize pass need a fence, so a resize can't recreate a record the teardown just deleted. That will be wired in at rebase time.

  • A record is only rewritten when an organization that uses it next provisions (a sandbox session or a model-endpoint call). A gateway that is already crash-looping from memory needs its memory limit raised once so those rewrites can land.

  • Shrinking a live record goes through Bifrost's UpdateProvider, which cancels any queued requests beyond the new queue length (1,024 on a custom record). This happens once, and only under very heavy load.

Part of the sandbox footprint and robustness pass (#4533, #4538 and siblings).

After the follow-ups: llm_gateway_admin.test.ts 120 pass, gateway_provisioning.test.ts 36 pass, the legacy-flow guard passes, @tale/platform typecheck passes, oxlint on the touched files is clean, the docs locale/structure checks pass and lint:manual is ok.

@yannickmonney

Copy link
Copy Markdown
Contributor Author

REQUEST CHANGES — independent API-only review of head ad3e572e4c84e4fef166023c56d941bd78608a9f (base ba3f848a535d928637161be4053f150bd4d15f4c). I did not write this implementation. Part B was reviewed before Part A.

  1. [P1] Supply gateway resource and saturation proof before adopting the new defaults — services/platform/backend/core/node_only/sandbox/llm_gateway_admin.ts:165, :180, :1672; services/platform/backend/core/node_only/sandbox/llm_gateway_admin.test.ts:888. The custom pool falls from 1,000 workers to 64 and its queue from 5,000 to 1,024, while the backend now overrides a stored overload-dropping policy to wait. The new tests inspect mocked management PUTs; they cannot establish memory at boot, live stream/connection counts, saturated queue cancellation, or whether another organization's shared-provider calls make progress. The PR explicitly says no live gateway was exercised and memory is an estimate. Checks run 37817626604's performance artifact 11569466588 explicitly marks @tale/sandbox-llm-gateway not-measured. Add source-bound baseline/candidate evidence using the pinned gateway and a controlled mock upstream: many stored records under the 512 MiB budget, slow-header streams and whole answers, bursts beyond pool/queue, caller cancellation, shared-record competing organizations, and resize under load. Record latency/throughput, peak/steady RSS, goroutines, connections, cleanup and acceptance thresholds. This is missing required validation, not an observed runtime failure; no live vendor spending is needed.

  2. [P2] Describe worker concurrency accurately for streaming callers — docs/en/self-hosted/configuration/environment-reference.md:428 (and DE/FR), .env.example:371. These describe the setting as how many requests the gateway sends at once. The implementation's own lifecycle description at llm_gateway_admin.ts:170–172 says a stream releases its worker once response headers arrive. Under that contract, 64 workers can leave more than 64 streams/connections active; neither the worker count nor its queue bounds all in-flight streaming work. Describe this as the request-worker/startup pool, explain streaming versus whole-answer occupancy, and avoid presenting 5,000 as a deployment-wide connection bound. Keep all three locale references equivalent.

  3. [P2] Put the rollout/recovery caveats in operator documentation — services/sandbox-llm-gateway/README.md:26, docs/en/self-hosted/configuration/environment-reference.md:433 (and DE/FR). The docs tell operators that restarting the backend and next provision apply the size, but omit the PR's acknowledged cases: a gateway already OOM-looping cannot accept those rewrites, and shrinking a live record can cancel queued calls beyond the new queue length. Document an owned, capacity-checked recovery/rollout procedure that preserves the gateway volume and secrets, verifies stored pool readback and healthy restart, handles/drains affected traffic, and explains rollback through overrides plus reprovisioning. A backend restart alone does not resize records in an unavailable gateway.

Per-topic dispositions:

Topic Disposition
CODE PASS for source review: shared classification is reused; positive safe integers/defaults/cap are coherent; pool is in the fingerprint. No additional source defect identified.
RESOURCES BLOCKED by finding 1; per-record worker/channel bounds are visible, aggregate memory/stream/connection safety is unmeasured.
PERFORMANCE BLOCKED by finding 1; no comparable gateway baseline/candidate or tail/fairness evidence.
PROVIDERS PASS for routing/config source review, including the org-scoped Anthropic sibling; real pinned-gateway saturation/cancel/update compatibility NOT_RUN, finding 1.
DEPLOYMENT BLOCKED by findings 1 and 3. Compose and CLI both retain 512 MiB; backend roles load .env; adoption is lazy per next provision. No Ops access or deployment performed.
DOCS FAIL for findings 2 and 3. EN/DE/FR additions otherwise preserve defaults and ranges; warnings name invalid values and fallback. Rendered docs NOT_RUN.
CICD BLOCKED/pending, not failed: final bounded snapshot has CI ready (Commitlint) success; SAST and Security queued; Checks, CLI, E2E and Build terminals not yet materialized. Backend integration and several builds remain queued. Candidate/path skips are acceptable. No reruns/cancellations requested.

Both platform unit shards actually executed (Turbo MISS, exit 0) on merge 4340ca0b380179d2a9e35226736ecb6cb7ae09a4, whose checkout logs name this exact head and base; their logs contain no ##[error]/FAIL lines. The successful Commitlint terminal log also contains none. The new mocked regressions would fail without the sizing, env handling, fingerprint and posture changes, but do not prove real queue behavior. The legacy helper declaration is byte-identical at base/head and hashes to the new fixture's c7a4526599f59ab0cdd4c578cbaa7d5b207d4f33ea6c3269f573e7a70e8701db; narrowing its provenance pin is justified.

Limits: no checkout, local tests, install, live gateway/provider, runtime benchmark, browser render, Ops read, push or merge. PR remains behind main. Merge readiness needs settled source-bound CI and independent verification of the requested proof/docs. Manager/implementation owner should route these findings; check queued CI on the next bounded pass rather than rerunning unchanged work.

@yannickmonney
yannickmonney force-pushed the fix/sandbox-gateway-provider-concurrency branch from ad3e572 to 2fc235c Compare October 8, 2026 21:32
@yannickmonney
yannickmonney added this pull request to the merge queue Oct 9, 2026
@yannickmonney
yannickmonney removed this pull request from the merge queue due to a manual request Oct 9, 2026
Every provider record the platform puts into the sandbox model gateway
asked for 1,000 request workers and a 5,000-deep queue. The gateway
(Bifrost core v1.11.0 in the pinned v2.2.4 image) starts a record's
workers as goroutines eagerly: for every stored record at boot (Init ->
prepareProvider) and again on every config or key write (UpdateProvider),
and they stay parked whether or not anything calls the record. A custom
connector gets one record per (organization, connector, model) plus its
Anthropic-lane sibling, so parked workers grow with organizations times
custom models. At 2-8 KiB of stack per goroutine a record costs about
2-8 MiB, so 64 to 256 records fill the gateway's 512 MiB limit on stacks
alone, and a gateway over its limit is killed again at every boot.

A shared standard record now gets 512 workers and an org-scoped custom
record 64, each with a queue 16 deep per worker (the gateway refuses a
queue shorter than the pool): a hundred custom records park 6,400
workers instead of 100,000. A stream holds its worker only until the
upstream's response headers arrive, and a sandbox host sizes itself to
at most 256 sessions, so 64 covers one organization's burst on one
model; a request past the pool waits in the queue.

SANDBOX_LLM_GATEWAY_PROVIDER_CONCURRENCY and
SANDBOX_LLM_GATEWAY_CUSTOM_PROVIDER_CONCURRENCY override the two sizes.
A value that is not a positive whole number keeps the default with one
warning, since the gateway would refuse every provision with it; one
above 5,000, the gateway's default connections per upstream host, is
held there. The backend reads both from its .env like the other
gateway settings, so no compose or CLI change is needed. The pool is
part of the provision fingerprint, so a resized record is rewritten at
its next provision.
The gateway's provider records now carry smaller worker pools and
queues (64 workers and 1,024 queue slots on a custom record). A request
that finds a record's queue full is held until there is room, as long
as the gateway's client setting drop_excess_requests is off, which is
its default (Bifrost core v1.11.0, tryRequest and tryStreamRequest). With the setting on, the
same request is answered 503 "request dropped: queue is full" at once.
The platform merged whatever the gateway's store held and never looked
at the flag, so a store restored with it on, or an operator's edit,
would turn a burst past 1,088 requests on one record into failed
agent calls.

The posture check run on every sandbox create now treats the flag as
drift too, and the config write keeps it off, the same way it keeps
inference behind virtual keys and content out of the request log. A
store that already has it off (every gateway the platform set up) is
left alone, with no extra write.
Only a provision that names a record writes its worker pool, and a
session provisions just the records of its own allowed models. A record
nothing provisions again (a model an organization removed or renamed, an
__anthropic sibling of a lane no session rides, an inactive
organization's records, a rarely used model) kept the 1,000 workers and
5,000-deep queue it was stored with, and the gateway restarts those
workers at every boot. A hundred such records still park 100,000
goroutines, so the smaller pools alone did not bound the gateway's
memory.

Each backend process now runs one shrink pass in the background once its
first provision has applied the gateway's auth posture: it lists every
record (GET /api/providers) and writes each one with more workers than
its kind's pool back with that pool. PUT /api/providers/:name replaces
network_config whole and refuses a custom record without its
custom_provider_config (Bifrost transports v2.2.4, updateProvider), so
the pass sends the record's own network block, custom upstream and
raw-payload switches back with it, and leaves out the blocks the PUT
keeps when absent, whose secrets the listing masks.

The pass must never write an old config over a newer one. A record this
process has provisioned is skipped, a provision of a record waits for
the pass's write to it, and each record is read again right before its
write, which narrows a race with another backend process to the moment
between that read and the write. A failed listing is tried again at the
next provision; a record the gateway refuses keeps its pool, with a
warning, until the next process.
The gateway (Bifrost core v1.11.0) starts every stored provider
record's workers inside Init, before it serves HTTP. A store whose
records still carry the gateway's default 1,000 workers needs about
2-8 MiB of goroutine stacks per record, so 64 to 256 such records
exceed the 512 MiB limit that tale deploy (mem_limit) and the
Kubernetes manifest (limits.memory) set. The gateway is then killed at
every boot, and the backend, which resizes records only through the
management API, never gets to run its shrink pass. Nothing told an
operator how to get out of that state, and the compose comment warns
against wiping the gateway volume, which holds the admin account and
the minted keys with their spend.

The gateway README and the environment reference (EN, DE, FR) now give
the path: raise the container's memory limit until the gateway starts
(docker update on a tale deploy host, which keeps the restart policy
and needs no compose override, or the Deployment's limit on
Kubernetes), start one sandbox session or model-endpoint request so the
backend resizes the records and logs it, then restore the limit the
deployment sets (512m memory, 1g memory plus swap) and restart.
The environment reference and .env.example said
SANDBOX_LLM_GATEWAY_PROVIDER_CONCURRENCY sizes the built-in providers
(OpenAI, Anthropic, OpenRouter). That holds for their shared records,
but Claude Code through OpenRouter's Anthropic endpoint rides an
org-scoped record of its own (resolveGatewayRouting names it
<org>__openrouter__<model>__anthropic), and the gateway sizes that
record with SANDBOX_LLM_GATEWAY_CUSTOM_PROVIDER_CONCURRENCY: 64
workers, not 512, as the pool test for that lane already pins. An
operator raising the built-in value for Claude Code load on OpenRouter
would see no effect.

The CUSTOM row (EN, DE, FR) and the .env.example block now say that
lane gets an organization-and-model provider of its own and follows
the custom value.
The shrink pass gave each record's PUT the 15 s every management call
gets and counted a record only on a success answer. The gateway (Bifrost
transports v2.2.4, updateProvider) resolves a custom record's base URL
(up to 5 s), stores the record and restarts its workers, and only then
asks the upstream for its models (up to 15 s) before it answers. A
record whose upstream is unreachable was therefore resized in the
gateway yet logged as failed and left out of the count, and the pass
printed its "resized" line only when that count was above zero: the
line the recovery steps tell an operator to wait for might never come.

A resize write now gets 30 s (5 s + 15 s, plus margin). One the gateway
fails with a 5xx or does not answer is read back, and counts as resized
when the record holds the new pool, else as unconfirmed. The pass
always ends with one line, "[llm-gateway] provider worker resize
finished: <n> resized, <n> refused, <n> unconfirmed", zeros included;
the gateway README, the environment reference (EN, DE, FR) and the
automation register name it. The pass also checks again after its read,
right before the write, whether a provision of this process took the
record, so a provision waits only for a write already sent, never for
the read before it.
The gateway's PUT /api/providers/:name is an upsert: for a record it
does not hold it calls AddProvider with the payload (Bifrost transports
v2.2.4, updateProvider). The shrink pass sent its write through the
management client, which sends a write the gateway's store turned away
with a 5xx again after 250, 750 and 2,000 ms, blind. A record deleted in
those seconds (a base-type recreate by another process, an operator's
cleanup, an organization teardown) came back without keys, with 64 or
512 workers and its old base URL, and nothing deleted it again.

The resize write now takes the first answer and decides itself. After a
5xx it waits the same 250, 750 and 2,000 ms, reads the record, and
sends the write again only while the record is still there and still
over its pool, with the config it reads then; a record gone or resized
meanwhile settles the outcome. A write the gateway did not answer may
still be under way there, so it is read back and never sent again.
The gateway's provider write copies the record's keys when the request
arrives and stores that copy (Bifrost transports v2.2.4,
updateProvider), and it resolves a custom record's base URL (up to 5 s)
before it stores. A key or config another backend process writes to the
same record in that window is undone by the shrink pass's write, and
that process's fingerprint memo then keeps it from writing the record
again until it restarts. Every fresh process rewrites each record and
key it provisions once, which is also when keys rotated through the
environment land, and backend-api runs two replicas on Kubernetes, so a
pass started at the first provision ran inside exactly that burst, in
both replicas at once.

The first provision now schedules the pass two minutes plus up to three
more at random later, once per process, with an unref'd timer; a pass
whose listing failed is scheduled again by the next provision. The
window remains for a write that lands while the gateway handles the
pass's write to that record, now outside the deploy burst; the
shrinkProviderPools comment and the gateway README describe it. The
environment reference (EN, DE, FR), README, .env.example and the
automation register say the resize comes two to five minutes after the
first session or model-endpoint request.
The gateway resolves the base URL every provider write carries (Bifrost
transports v2.2.4, updateProvider: ValidateExternalURL, a DNS lookup
bounded at 5 s) and answers 400 "Invalid base URL: failed to resolve
hostname" when the host is gone. The records whose upstream disappeared
(a removed connector's, a decommissioned LAN host's) are exactly the
orphans no provision rewrites, so the shrink pass of every backend
process was refused on them and they kept the pool they were stored
with, up to the gateway's 1,000 workers (2-8 MiB of parked stacks each).
The environment reference said a lower count reaches every other
provider, and the recovery steps had no remedy when the gateway still
did not start within 512 MiB.

A refused resize of a record that carries a base URL now logs that the
record keeps its workers and names DELETE /api/providers/<name> as the
remedy, since a provision sets up a record still in use again. A
built-in provider's record carries no base URL and holds every
organization's key, so its refusal says only that it keeps its workers.
The environment reference (EN, DE, FR), the gateway README's recovery
steps, .env.example and the automation register document the exception
and the delete, sent from a backend container with curl and the admin
password it already holds.
The automation coverage map now keeps one file per suite; this change's row moves there.
The organization teardown deletes a deleted organization's own provider
records, and the shrink pass resizes oversized records by writing them
back with a PUT, which the gateway answers by creating a record it does
not hold. A resize whose read came before the teardown's delete and whose
write came after it brought the deleted organization's record back, with
no keys, after the teardown had reported success.

Within a process the two are now fenced: a teardown marks each record it
deletes and waits for a resize of it already in flight before deleting,
and the pass never writes a marked record; a provision takes the mark off
again. A teardown in another backend process is not seen; the comment on
the mark says what that race leaves behind and how it goes.
@yannickmonney
yannickmonney force-pushed the fix/sandbox-gateway-provider-concurrency branch from 2fc235c to 9a765b4 Compare October 9, 2026 03:08
@yannickmonney
yannickmonney merged commit 0c975a7 into main Oct 9, 2026
12 of 26 checks passed
@yannickmonney
yannickmonney deleted the fix/sandbox-gateway-provider-concurrency branch October 9, 2026 03:09
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.

1 participant