Skip to content

Bound Modal dispatch calls with configured deadlines in the Cloud Run gateway - #1634

Open
hua7450 wants to merge 2 commits into
mainfrom
fix/1633-gateway-modal-dispatch-timeout
Open

Bound Modal dispatch calls with configured deadlines in the Cloud Run gateway#1634
hua7450 wants to merge 2 commits into
mainfrom
fix/1633-gateway-modal-dispatch-timeout

Conversation

@hua7450

@hua7450 hua7450 commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Fixes #1633

Summary

During the 2026-07-21 production outage, Modal control-plane dispatches from the gateway black-holed: each hung call blocked its executor thread past the 90s request timeout, future.cancel() could not stop the running thread, and the semaphore permit (released only on completion) never came back. 32 hung dispatches exhausted MODAL_EXECUTOR_MAX_WORKERS and the gateway then 503'd every request instantly (modal_executor_saturated) without attempting Modal. See #1633 for the full incident analysis.

This PR passes the gateway's already-resolved deadlines into the Modal SDK calls themselves, so a hung dispatch frees its thread when the deadline expires instead of leaking it until process restart:

  • call_modal_worker, probe_modal_worker, and _call_modal_worker_dispatch accept timeout_seconds and forward it to call_modal_worker_dispatch, which already supports bounding via spawn(payload).get(timeout=...) (previously the gateway never passed it, so the dispatch used unbounded .remote()).
  • probe_modal_canary accepts timeout_seconds and uses spawn().get(timeout=...) when set.
  • create_gateway_app binds the resolved request_timeout / probe_timeout / canary_timeout into the default callables. Injected fakes (modal_request=, modal_health_probe=, modal_canary_probe=) are unaffected.

All new parameters are keyword-only with None defaults, preserving existing call sites and test fakes.

Scope notes

Test plan

  • Three new unit tests assert the timeout reaches the Modal SDK call for dispatch, worker probe, and canary paths (spawn().get(timeout=...) fakes)
  • make format-check passes (193 files)
  • make test passes: 714 passed, 1 skipped
  • CI passes

🤖 Generated with Claude Code

hua7450 and others added 2 commits July 21, 2026 10:46
The Cloud Run gateway never passed timeout_seconds into
call_modal_worker_dispatch, so a Modal control-plane call that
black-holed blocked its executor thread forever: future.cancel() cannot
stop a running thread, and the semaphore permit is only released on
completion. During the 2026-07-21 incident each hung dispatch
permanently leaked one of the 32 executor slots until the pool
saturated and every request 503'd without Modal being attempted.

Wire the resolved request/probe/canary timeouts into the default
call_modal_worker, probe_modal_worker, and probe_modal_canary paths so
the Modal SDK call itself (spawn + get(timeout)) gives up and frees its
thread instead of waiting indefinitely.

Fixes #1633

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@hua7450
hua7450 marked this pull request as ready for review July 21, 2026 15:47
@hua7450
hua7450 requested a review from anth-volk July 21, 2026 15:47

@anth-volk anth-volk 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.

Requesting changes because the new timeout path still does not provide a hard bound on the executor thread, which is the core guarantee this incident fix needs to establish. I also left inline comments on the spawn() semantic change, production-shaped and route-level coverage gaps, and duplicated test infrastructure.

Please also update the PR description before re-review: it still describes synchronous spawn().get() and three tests, while the current head uses a process-local async runner and adds four tests. The CI checkbox is also stale now that all checks are green.

I ran the focused failover/dispatch tests locally (57 passed), formatting and git diff --check passed, and all current GitHub checks are green.

except BaseException:
coroutine.close()
raise
return future.result()

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.

Blocking: this synchronous wait is still unbounded. asyncio.timeout() is cooperative: it cancels the coroutine and waits for cancellation cleanup. Modal's .aio bridge similarly shields its inner work and waits for underlying cancellation. If the Modal synchronization loop or transport is wedged and cannot finish cancellation, this thread remains here forever and its gateway semaphore permit is never released—the original pool-exhaustion failure mode. Please give this future.result() boundary its own hard deadline (with bounded grace), cancel/abandon the task when that expires, and test it with cancellation-resistant work.

timeout_seconds: float,
) -> Any:
async with asyncio.timeout(timeout_seconds):
function_call = await function.spawn.aio(*args, **kwargs)

@anth-volk anth-volk Jul 22, 2026

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.

This changes normal gateway calls from remote() semantics to spawn()+get(). In the locked Modal 1.4.2 client, remote.aio() can use the function's input-plane route when Modal supplies one, whereas spawn.aio() always uses the control-plane FunctionMap path implicated in this incident; it also changes retry/result-retention semantics. The live handles do not advertise an input-plane URL today, so this is latent rather than an immediate production regression, but Modal can enable that metadata independently. Could we preserve the existing invocation behavior with await function.remote.aio(*args, **kwargs) inside the overall timeout instead?

More broadly, we should strongly prefer Modal's canonical, documented invocation patterns here and avoid composing lower-level Modal primitives into a custom call lifecycle unless Modal explicitly recommends it. AI-generated implementations are particularly prone to combining individually supported APIs in ways that look reasonable but are noncanonical and subtly change transport, retry, cancellation, or result-lifecycle behavior. Please use the simplest canonical Modal operation that satisfies the requirement—ideally remote.aio() under the deadline—and verify any deviation against Modal's documentation or with Modal support.

},
timeout_seconds=0.01,
),
timeout_seconds=0.5,

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.

This test gives the inner call 10 ms but the outer executor 500 ms, while production passes the same resolved timeout to both layers. That extra 490 ms makes the semaphore-release assertion much easier than the real configuration. With equal deadlines, the outer wait usually wins the race and the permit is still held when the 503 is returned (in a local 100-run reproduction, 95 permits were not immediately reacquirable, although cooperative cancellation released them shortly afterward). Please test identical inner/outer deadlines, a cancellation-resistant coroutine, and a hang in get.aio() as well as spawn.aio().

def _probe_canary_with_deadline() -> None:
probe_modal_canary(timeout_seconds=canary_timeout)

call_modal = modal_request or _call_modal_with_deadline

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.

The new tests call call_modal_worker, probe_modal_worker, and probe_modal_canary directly with explicit timeouts, but the test _client() always injects all three Modal callables. As a result, no route-level test exercises these default wrappers or proves that the resolved request/probe/canary configuration reaches the SDK. Please add coverage using the default callables (with the Modal seam mocked) so a wiring or environment-resolution regression here fails a test.

}


def _install_fake_modal_with_spawn(

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.

This introduces a second Modal async-call fake even though this PR also extends tests/fixtures/modal_fakes.py with the same SyncAsyncCallable/spawn/get surface. Per the repository testing guidance, reusable mocks should live in tests/fixtures/ and test files should focus on behavior. Please extend the shared fake with configurable spawn/get hangs and reuse it here rather than maintaining two subtly different Modal models.

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.

Gateway leaks Modal dispatch threads on hung control-plane calls; circuit breaker never opened during 2026-07-21 outage

2 participants