Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/added/marching-marmot.md
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.
1 change: 1 addition & 0 deletions temporalio/contrib/modal/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Modal integrations for Temporal."""
159 changes: 159 additions & 0 deletions temporalio/contrib/modal/worker/README.md
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]


Copy link
Copy Markdown
Contributor

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

Copy link
Copy Markdown
Author

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

@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.
12 changes: 12 additions & 0 deletions temporalio/contrib/modal/worker/__init__.py
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",
]
62 changes: 62 additions & 0 deletions temporalio/contrib/modal/worker/_configure.py
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)
71 changes: 71 additions & 0 deletions temporalio/contrib/modal/worker/_defaults.py
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)
Loading
Loading