Repository navigation
Add Modal Serverless Worker integration #1970
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
harish-narayanappa-10
wants to merge
6
commits into
main
Choose a base branch
from
modal-sdk-python
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
0c58835
initial changes to support modal
harish-narayanappa-10 d51fe57
python sdk changes to support modal provider
harish-narayanappa-10 ae12140
Merge branch 'main' of ssh://github.com/temporalio/sdk-python into mo…
harish-narayanappa-10 371a18f
Merge branch 'main' into modal-sdk-python
harish-narayanappa-10 64653bb
addressing review comments
harish-narayanappa-10 48b839d
Merge branch 'modal-sdk-python' of ssh://github.com/temporalio/sdk-py…
harish-narayanappa-10 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| Add `temporalio.contrib.modal.worker` for running versioned Temporal Workers within Modal Function invocations with conservative worker defaults and deadline-aware graceful shutdown. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| """Modal integrations for Temporal.""" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,159 @@ | ||
| # Modal worker | ||
|
|
||
| Run a Temporal Worker for the lifetime of one | ||
| [Modal Function](https://modal.com/docs/guide/functions) invocation. The | ||
| integration creates a fresh Temporal client and worker, applies conservative | ||
| serverless defaults, polls for tasks, and shuts the worker down before the | ||
| Function timeout. | ||
|
|
||
| ## Quick start | ||
|
|
||
| Define an Activity in `activities.py`: | ||
|
|
||
| ```python | ||
| from temporalio import activity | ||
|
|
||
|
|
||
| @activity.defn | ||
| async def greet(name: str) -> str: | ||
| return f"Hello, {name}!" | ||
| ``` | ||
|
|
||
| Define a Workflow in `workflows.py`: | ||
|
|
||
| ```python | ||
| from datetime import timedelta | ||
|
|
||
| from temporalio import workflow | ||
|
|
||
| with workflow.unsafe.imports_passed_through(): | ||
| from activities import greet | ||
|
|
||
|
|
||
| @workflow.defn | ||
| class GreetingWorkflow: | ||
| @workflow.run | ||
| async def run(self, name: str) -> str: | ||
| return await workflow.execute_activity( | ||
| greet, | ||
| name, | ||
| start_to_close_timeout=timedelta(seconds=30), | ||
| ) | ||
| ``` | ||
|
|
||
| Create the Modal Function in `worker.py`: | ||
|
|
||
| ```python | ||
| from datetime import timedelta | ||
|
|
||
| import modal | ||
|
|
||
| from temporalio.common import WorkerDeploymentVersion | ||
| from temporalio.contrib.modal.worker import ModalWorkerConfig, run_worker | ||
|
|
||
| from activities import greet | ||
| from workflows import GreetingWorkflow | ||
|
|
||
| FUNCTION_TIMEOUT = timedelta(minutes=5) | ||
| VERSION = WorkerDeploymentVersion( | ||
| deployment_name="my-service", | ||
| build_id="v1.0", | ||
| ) | ||
|
|
||
| app = modal.App("temporal-worker") | ||
|
|
||
|
|
||
| def configure(config: ModalWorkerConfig) -> None: | ||
| config.worker_config["task_queue"] = "my-task-queue" | ||
| config.worker_config["workflows"] = [GreetingWorkflow] | ||
| config.worker_config["activities"] = [greet] | ||
|
|
||
|
|
||
| @app.function(timeout=int(FUNCTION_TIMEOUT.total_seconds())) | ||
| async def temporal_worker() -> None: | ||
| await run_worker( | ||
| VERSION, | ||
| configure, | ||
| modal_function_timeout=FUNCTION_TIMEOUT, | ||
| ) | ||
| ``` | ||
|
|
||
| `run_worker` is async because Modal provides the invocation's event loop. Call | ||
| it once from a top-level Modal Function. Do not enable concurrent inputs for | ||
| that Function: every input owns one Temporal client and worker lifecycle. | ||
| Sequential container reuse is supported because every invocation constructs | ||
| fresh SDK objects. | ||
|
|
||
| ## Timeout and shutdown | ||
|
|
||
| [Modal Functions default to a five-minute timeout](https://modal.com/docs/guide/timeouts). | ||
| Pass the Function's configured timeout to `run_worker` as | ||
| `modal_function_timeout`, as shown above. The two values must match because | ||
| Modal does not expose the remaining invocation time to running Function code. | ||
|
|
||
| The SDK reserves 15 seconds of internal safety headroom. By default, the rest | ||
| is the execution budget, which includes configuration, connection, polling, | ||
| worker shutdown, and shutdown hooks. | ||
|
|
||
| With a five-minute Function timeout and the default five-second graceful | ||
| shutdown timeout, the lifecycle is approximately: | ||
|
|
||
| | Time after `run_worker` starts | Event | | ||
| | --- | --- | | ||
| | 0:00 | Configuration and connection begin. | | ||
| | 4:38 | Polling stops and worker shutdown begins. | | ||
| | 4:43 | The default graceful Activity drain period ends. | | ||
| | 4:45 | The SDK targets completion after reserving two seconds for shutdown hooks. | | ||
| | 5:00 | Modal's Function timeout is reached. | | ||
|
|
||
| Setup and connection time consume the polling budget, so the worker may poll | ||
| for less than 4 minutes 38 seconds. When shutdown begins, the worker stops | ||
| polling for new tasks and lets in-flight Activities finish for up to | ||
| `graceful_shutdown_timeout`. It then runs the configured shutdown hooks. | ||
|
|
||
| The default `shutdown_deadline_buffer` is calculated after `configure` as | ||
| `graceful_shutdown_timeout + 2 seconds`. Increasing | ||
| `graceful_shutdown_timeout` therefore moves the stop-polling time earlier | ||
| without requiring a second setting. Set `shutdown_deadline_buffer` explicitly | ||
| only when shutdown hooks or other drain work require a different reserve. | ||
| Modal's Function timeout remains the final deadline. | ||
|
|
||
| Worker errors and Modal cancellation propagate after shutdown hooks run, so | ||
| Modal can record or retry an interrupted input. | ||
|
|
||
| ## Configuration | ||
|
|
||
| Client settings are loaded using `temporalio.envconfig`. The optional config | ||
| file is resolved in this order: | ||
|
|
||
| 1. `TEMPORAL_CONFIG_FILE`, when set. | ||
| 2. `temporal.toml` in the current working directory. | ||
|
|
||
| `TEMPORAL_TASK_QUEUE` pre-populates the worker task queue. The configure | ||
| callback may override it and any client or worker option. The callback runs once | ||
| per invocation and may be synchronous, async, an async generator, or an async | ||
| context manager. | ||
|
|
||
| `execution_timeout` defaults to the Modal Function timeout minus the SDK's | ||
| internal safety headroom. The configure callback may shorten it when an | ||
| invocation should stop earlier, but cannot extend it into that headroom. | ||
|
|
||
| The integration always enables Worker Deployment Versioning and always disables | ||
| eager activity execution. Its conservative defaults are: | ||
|
|
||
| | Setting | Default | | ||
| | --- | --- | | ||
| | `max_concurrent_activities` | 2 | | ||
| | `max_concurrent_workflow_tasks` | 10 | | ||
| | `max_concurrent_local_activities` | 2 | | ||
| | `max_concurrent_nexus_tasks` | 5 | | ||
| | `workflow_task_poller_behavior` | `SimpleMaximum(2)` | | ||
| | `activity_task_poller_behavior` | `SimpleMaximum(1)` | | ||
| | `nexus_task_poller_behavior` | `SimpleMaximum(1)` | | ||
| | `graceful_shutdown_timeout` | 5 seconds | | ||
| | `max_cached_workflows` | 30 | | ||
| | `disable_eager_activity_execution` | Always `True` | | ||
|
|
||
| The default Temporal worker identity is | ||
| `<Modal Function Call ID>@<Modal container ID>`. A user-supplied client | ||
| `identity` takes precedence. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| """Run Temporal workers inside Modal Functions. | ||
|
|
||
| See the package README for a complete Workflow and Activity example. | ||
| """ | ||
|
|
||
| from temporalio.contrib.modal.worker._configure import ModalWorkerConfig | ||
| from temporalio.contrib.modal.worker._run_worker import run_worker | ||
|
|
||
| __all__ = [ | ||
| "ModalWorkerConfig", | ||
| "run_worker", | ||
| ] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| """Configuration for a Temporal worker running in a Modal Function.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| import logging | ||
| from collections.abc import Awaitable, Callable | ||
| from dataclasses import dataclass, field | ||
| from datetime import timedelta | ||
|
|
||
| from temporalio.client import ClientConnectConfig | ||
| from temporalio.worker import WorkerConfig | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| @dataclass | ||
| class ModalWorkerConfig: | ||
| """Configuration passed to :py:func:`run_worker`'s callback. | ||
|
|
||
| Attributes: | ||
| execution_timeout: Total SDK lifecycle budget for this invocation, | ||
| including configuration and connection setup. It defaults to the | ||
| Modal Function timeout minus the SDK's internal safety margin. The | ||
| callback may shorten it. | ||
| client_connect_config: Keyword arguments passed to | ||
| :py:meth:`temporalio.client.Client.connect`. Values are loaded from | ||
| Temporal environment configuration before the callback runs. | ||
| worker_config: Keyword arguments passed to | ||
| :py:class:`temporalio.worker.Worker`. The ``client`` key is managed | ||
| internally. Conservative concurrency and polling defaults are | ||
| applied, and ``task_queue`` is populated from | ||
| ``TEMPORAL_TASK_QUEUE`` when present. | ||
| shutdown_deadline_buffer: Time reserved at the end of the invocation | ||
| for worker drain and shutdown hooks. When unset, it is derived | ||
| after configuration as ``graceful_shutdown_timeout + 2s``. | ||
| shutdown_hooks: Sync or async functions called in order after the | ||
| worker stops, including when worker execution fails or is cancelled. | ||
| """ | ||
|
|
||
| execution_timeout: timedelta | ||
| client_connect_config: ClientConnectConfig = field( | ||
| default_factory=ClientConnectConfig | ||
| ) | ||
| worker_config: WorkerConfig = field(default_factory=WorkerConfig) | ||
| shutdown_deadline_buffer: timedelta | None = None | ||
| shutdown_hooks: list[Callable[[], Awaitable[None] | None]] = field( | ||
| default_factory=list | ||
| ) | ||
|
|
||
|
|
||
| async def _run_shutdown_hooks( # type: ignore[reportUnusedFunction] | ||
| config: ModalWorkerConfig, | ||
| ) -> None: | ||
| """Run registered shutdown hooks in order and log individual failures.""" | ||
| for fn in config.shutdown_hooks: | ||
| try: | ||
| result = fn() | ||
| if asyncio.iscoroutine(result): | ||
| await result | ||
| except Exception as err: | ||
| logger.error("shutdown hook error: %s", err) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| """Defaults for Temporal workers running in Modal Functions.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import os | ||
| from collections.abc import Callable | ||
| from datetime import timedelta | ||
| from pathlib import Path | ||
|
|
||
| from temporalio.worker import PollerBehaviorSimpleMaximum, WorkerConfig | ||
|
|
||
| DEFAULT_MAX_CONCURRENT_ACTIVITIES: int = 2 | ||
| DEFAULT_MAX_CONCURRENT_WORKFLOW_TASKS: int = 10 | ||
| DEFAULT_MAX_CONCURRENT_LOCAL_ACTIVITIES: int = 2 | ||
| DEFAULT_MAX_CONCURRENT_NEXUS_TASKS: int = 5 | ||
| DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT: timedelta = timedelta(seconds=5) | ||
| DEFAULT_SHUTDOWN_HOOK_BUFFER: timedelta = timedelta(seconds=2) | ||
| DEFAULT_MAX_CACHED_WORKFLOWS: int = 30 | ||
| _FUNCTION_TIMEOUT_HEADROOM: timedelta = timedelta(seconds=15) | ||
|
|
||
| DEFAULT_WORKFLOW_TASK_POLLER_BEHAVIOR = PollerBehaviorSimpleMaximum(maximum=2) | ||
| DEFAULT_ACTIVITY_TASK_POLLER_BEHAVIOR = PollerBehaviorSimpleMaximum(maximum=1) | ||
| DEFAULT_NEXUS_TASK_POLLER_BEHAVIOR = PollerBehaviorSimpleMaximum(maximum=1) | ||
|
|
||
| ENV_TASK_QUEUE = "TEMPORAL_TASK_QUEUE" | ||
| ENV_MODAL_TASK_ID = "MODAL_TASK_ID" | ||
| ENV_CONFIG_FILE = "TEMPORAL_CONFIG_FILE" | ||
| DEFAULT_CONFIG_FILE = "temporal.toml" | ||
|
|
||
|
|
||
| def apply_modal_worker_defaults(config: WorkerConfig) -> None: | ||
| """Apply conservative defaults suitable for a Modal Function. | ||
|
|
||
| Values already present in *config* are preserved, except eager activity | ||
| execution, which is always disabled for an ephemeral worker. | ||
| """ | ||
| config.setdefault("max_concurrent_activities", DEFAULT_MAX_CONCURRENT_ACTIVITIES) | ||
| config.setdefault( | ||
| "max_concurrent_workflow_tasks", DEFAULT_MAX_CONCURRENT_WORKFLOW_TASKS | ||
| ) | ||
| config.setdefault( | ||
| "max_concurrent_local_activities", DEFAULT_MAX_CONCURRENT_LOCAL_ACTIVITIES | ||
| ) | ||
| config.setdefault("max_concurrent_nexus_tasks", DEFAULT_MAX_CONCURRENT_NEXUS_TASKS) | ||
| config.setdefault("graceful_shutdown_timeout", DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT) | ||
| config.setdefault("max_cached_workflows", DEFAULT_MAX_CACHED_WORKFLOWS) | ||
| config.setdefault( | ||
| "workflow_task_poller_behavior", DEFAULT_WORKFLOW_TASK_POLLER_BEHAVIOR | ||
| ) | ||
| config.setdefault( | ||
| "activity_task_poller_behavior", DEFAULT_ACTIVITY_TASK_POLLER_BEHAVIOR | ||
| ) | ||
| config.setdefault("nexus_task_poller_behavior", DEFAULT_NEXUS_TASK_POLLER_BEHAVIOR) | ||
| config["disable_eager_activity_execution"] = True | ||
|
|
||
|
|
||
| def build_modal_identity(function_call_id: str, container_id: str) -> str: | ||
| """Build ``<function call ID>@<container ID>`` for a worker invocation.""" | ||
| return f"{function_call_id or 'unknown'}@{container_id or 'unknown'}" | ||
|
|
||
|
|
||
| def modal_default_config_file_path( | ||
| getenv: Callable[[str], str | None] = os.environ.get, | ||
| ) -> Path: | ||
| """Return the Temporal config path for a Modal Function. | ||
|
|
||
| ``TEMPORAL_CONFIG_FILE`` takes precedence over ``temporal.toml`` in the | ||
| current working directory. | ||
| """ | ||
| config_file = getenv(ENV_CONFIG_FILE) | ||
| return Path(config_file) if config_file else Path(DEFAULT_CONFIG_FILE) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you set this:
@app.function(timeout=900) # 15-minute container
async def temporal_worker():
await run_worker(VERSION, configure,
modal_function_timeout=timedelta(minutes=15))
execution_timeout is still 5 minutes if you don't set it in the config
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed. execution_timeout now defaults to modal_function_timeout minus the internal 15-second headroom