Skip to content

Commit ea10305

Browse files
committed
Align serverless declarations with dotnet
1 parent 31218f3 commit ea10305

8 files changed

Lines changed: 254 additions & 54 deletions

File tree

durabletask-azuremanaged/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
99

1010
- Added preview `durabletask.azuremanaged.extensions.serverless` APIs for
1111
declaring DTS serverless activities and running a sandbox activity worker.
12+
- Added profile-based DTS serverless declarations with
13+
`@serverless_worker_profile`, `ServerlessWorkerProfile.configure()`,
14+
`ServerlessWorkerProfileOptions.add_activity(...)`, and
15+
`ServerlessActivitiesClient.enable_serverless_activities()`.
16+
- Changed serverless worker profile declarations so the decorator names the
17+
profile and the profile class configures image, resources, concurrency, and
18+
customer environment variables explicitly.
1219
- Changed serverless activity worker configuration to require explicit SDK
1320
parameters instead of reading DTS environment variables inside the SDK.
1421
- Removed the serverless worker wakeup HTTP listener because ADC sandbox

durabletask-azuremanaged/durabletask/azuremanaged/extensions/serverless/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,15 @@
1616
"""
1717

1818
from durabletask.azuremanaged.extensions.serverless.client import ServerlessActivitiesClient
19+
from durabletask.azuremanaged.extensions.serverless.client import ServerlessWorkerProfile
20+
from durabletask.azuremanaged.extensions.serverless.client import ServerlessWorkerProfileOptions
21+
from durabletask.azuremanaged.extensions.serverless.client import serverless_worker_profile
1922
from durabletask.azuremanaged.extensions.serverless.worker import ServerlessWorker
2023

2124
__all__ = [
2225
"ServerlessWorker",
26+
"ServerlessWorkerProfile",
27+
"ServerlessWorkerProfileOptions",
2328
"ServerlessActivitiesClient",
29+
"serverless_worker_profile",
2430
]

durabletask-azuremanaged/durabletask/azuremanaged/extensions/serverless/client.py

Lines changed: 105 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
# Copyright (c) Microsoft Corporation.
22
# Licensed under the MIT License.
33

4-
from typing import Iterable, Optional, Sequence
4+
from dataclasses import dataclass, field
5+
from typing import Callable, Iterable, Optional, Sequence
56

67
import grpc
78
from azure.core.credentials import TokenCredential
89

10+
from durabletask import task
911
from durabletask.azuremanaged.internal.durabletask_grpc_interceptor import (
1012
DTSDefaultClientInterceptorImpl,
1113
)
@@ -21,6 +23,65 @@
2123
DEFAULT_MAX_CONCURRENT_ACTIVITIES = 100
2224

2325

26+
@dataclass
27+
class ServerlessWorkerProfileOptions:
28+
"""Options for a decorated serverless worker profile."""
29+
30+
worker_profile_id: str
31+
container_image: Optional[str] = None
32+
registry_server: Optional[str] = None
33+
repository: Optional[str] = None
34+
tag: Optional[str] = None
35+
image_digest: Optional[str] = None
36+
cpu: str = DEFAULT_CPU
37+
memory: str = DEFAULT_MEMORY
38+
environment_variables: dict[str, str] = field(default_factory=dict)
39+
max_concurrent_activities: int = DEFAULT_MAX_CONCURRENT_ACTIVITIES
40+
entrypoint: list[str] = field(default_factory=list)
41+
cmd: list[str] = field(default_factory=list)
42+
activity_names: list[str] = field(default_factory=list)
43+
44+
def add_activity(self, activity: str | Callable) -> None:
45+
"""Add an activity to the serverless worker profile declaration."""
46+
activity_name = task.get_name(activity) if callable(activity) else activity
47+
self.activity_names.append(
48+
_normalize_required(activity_name, "Serverless activity name is required."))
49+
50+
51+
class ServerlessWorkerProfile:
52+
"""Base class for configuring a decorated serverless worker profile."""
53+
54+
def configure(self, options: ServerlessWorkerProfileOptions) -> None:
55+
"""Configure the serverless worker profile declaration options."""
56+
57+
58+
_worker_profiles: dict[str, ServerlessWorkerProfileOptions] = {}
59+
60+
61+
def serverless_worker_profile(worker_profile_id: str) -> Callable[[type], type]:
62+
"""Declare a serverless worker profile using a decorated marker class."""
63+
normalized_profile = _normalize_required(worker_profile_id, "Serverless worker profile ID is required.")
64+
65+
def decorator(cls: type) -> type:
66+
if normalized_profile in _worker_profiles:
67+
raise ValueError(f"Serverless worker profile '{normalized_profile}' is declared more than once.")
68+
69+
options = ServerlessWorkerProfileOptions(worker_profile_id=normalized_profile)
70+
try:
71+
profile = cls()
72+
except TypeError as ex:
73+
raise TypeError("Serverless worker profile classes must have a parameterless constructor.") from ex
74+
75+
configure = getattr(profile, "configure", None)
76+
if callable(configure):
77+
configure(options)
78+
79+
_worker_profiles[normalized_profile] = options
80+
return cls
81+
82+
return decorator
83+
84+
2485
def resolve_activity_names(activity_names: str | Iterable[str]) -> list[str]:
2586
resolved: list[str] = []
2687
seen: set[str] = set()
@@ -114,6 +175,41 @@ def build_serverless_activity_declaration(
114175
return declaration
115176

116177

178+
def build_profile_serverless_activity_declarations() -> list[pb.ServerlessActivityDeclaration]:
179+
"""Build serverless declarations from worker profile configuration."""
180+
declarations: list[pb.ServerlessActivityDeclaration] = []
181+
activity_owners: dict[str, str] = {}
182+
for profile in _worker_profiles.values():
183+
activity_names = resolve_activity_names(profile.activity_names)
184+
if not activity_names:
185+
continue
186+
187+
for activity_name in activity_names:
188+
existing_profile = activity_owners.get(activity_name)
189+
if existing_profile and existing_profile != profile.worker_profile_id:
190+
raise ValueError(
191+
f"Serverless activity '{activity_name}' is assigned to both worker profile "
192+
f"'{existing_profile}' and '{profile.worker_profile_id}'.")
193+
activity_owners[activity_name] = profile.worker_profile_id
194+
195+
declarations.append(build_serverless_activity_declaration(
196+
activity_names=activity_names,
197+
worker_profile_id=profile.worker_profile_id,
198+
container_image=profile.container_image,
199+
registry_server=profile.registry_server,
200+
repository=profile.repository,
201+
tag=profile.tag,
202+
image_digest=profile.image_digest,
203+
cpu=profile.cpu,
204+
memory=profile.memory,
205+
environment_variables=profile.environment_variables,
206+
max_concurrent_activities=profile.max_concurrent_activities,
207+
entrypoint=profile.entrypoint,
208+
cmd=profile.cmd))
209+
210+
return declarations
211+
212+
117213
def build_serverless_worker_start(
118214
*,
119215
taskhub: str,
@@ -188,37 +284,14 @@ def close(self) -> None:
188284
if self._owns_channel:
189285
self._channel.close()
190286

191-
def declare_serverless_activities(
192-
self,
193-
*,
194-
activity_names: str | Iterable[str],
195-
worker_profile_id: str = DEFAULT_WORKER_PROFILE_ID,
196-
container_image: Optional[str] = None,
197-
registry_server: Optional[str] = None,
198-
repository: Optional[str] = None,
199-
tag: Optional[str] = None,
200-
image_digest: Optional[str] = None,
201-
cpu: str = DEFAULT_CPU,
202-
memory: str = DEFAULT_MEMORY,
203-
environment_variables: Optional[dict[str, str]] = None,
204-
max_concurrent_activities: int = DEFAULT_MAX_CONCURRENT_ACTIVITIES,
205-
entrypoint: Optional[Iterable[str]] = None,
206-
cmd: Optional[Iterable[str]] = None) -> None:
207-
declaration = build_serverless_activity_declaration(
208-
activity_names=activity_names,
209-
worker_profile_id=worker_profile_id,
210-
container_image=container_image,
211-
registry_server=registry_server,
212-
repository=repository,
213-
tag=tag,
214-
image_digest=image_digest,
215-
cpu=cpu,
216-
memory=memory,
217-
environment_variables=environment_variables,
218-
max_concurrent_activities=max_concurrent_activities,
219-
entrypoint=entrypoint,
220-
cmd=cmd)
221-
self._stub.DeclareServerlessActivities(declaration)
287+
def enable_serverless_activities(self) -> None:
288+
"""Declare all configured serverless worker profiles with DTS."""
289+
declarations = build_profile_serverless_activity_declarations()
290+
if not declarations:
291+
raise ValueError("No configured serverless activities were found.")
292+
293+
for declaration in declarations:
294+
self._stub.DeclareServerlessActivities(declaration)
222295

223296
def remove_serverless_activity_declaration(self, worker_profile_id: str) -> None:
224297
worker_profile_id = _normalize_required(worker_profile_id, "Worker profile ID is required.")

examples/serverless/README.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ This sample mirrors the .NET serverless sample with two customer-owned pieces:
66
serverlessly, starts the orchestration, and waits for the result.
77
2. A **remote worker image** (`remote_worker.py` plus `Containerfile`) that
88
DTS starts in a sandbox to execute the declared activity.
9+
3. A tiny shared module (`activity_names.py`) that keeps the declarer and remote
10+
worker on the same activity name constants.
911

1012
Reference .NET template:
1113
<https://github.com/microsoft/durabletask-dotnet/compare/wangbill/serverless-private-preview>
@@ -24,12 +26,14 @@ Set these before running the declarer app:
2426
$env:DTS_ENDPOINT = "<scheduler endpoint>"
2527
$env:DTS_TASK_HUB = "<task hub name>"
2628
$env:DTS_WORKER_PROFILE_ID = "default"
27-
$env:DTS_SERVERLESS_ACTIVITY_IMAGE = "<public container image reference>"
28-
$env:DTS_SERVERLESS_CPU = "1000m"
29-
$env:DTS_SERVERLESS_MEMORY = "2048Mi"
30-
$env:DTS_SERVERLESS_MAX_ACTIVITIES = "1"
3129
```
3230

31+
After pushing the remote worker image, set `options.container_image` in
32+
`RemoteWorkerProfile.configure()` to the pushed image reference. That method is
33+
also where the sample declares CPU, memory, max concurrency, customer
34+
environment variables, and serverless activity names with `options.add_activity(...)`.
35+
The declarer and remote worker both use `activity_names.py` so they stay in sync.
36+
3337
The remote worker code cannot pass DTS runtime settings to the SDK. In a
3438
sandbox, `ServerlessWorker()` reads `DTS_ENDPOINT`,
3539
`DTS_TASK_HUB`, `DTS_WORKER_PROFILE_ID`, `DTS_SERVERLESS_MAX_ACTIVITIES`,
@@ -67,3 +71,6 @@ python examples\serverless\main_app.py
6771

6872
The declarer app registers the serverless activity metadata, starts
6973
`hello_orchestrator`, and the remote worker sandbox executes `remote_hello`.
74+
The result includes `SERVERLESS_SAMPLE_MARKER=serverless-python-sample-marker`,
75+
proving the customer environment variable declared on the worker profile reached
76+
the sandbox.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
REMOTE_HELLO = "remote_hello"

examples/serverless/main_app.py

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,29 +7,40 @@
77
from durabletask import client, task
88
from durabletask.azuremanaged.client import DurableTaskSchedulerClient
99
from durabletask.azuremanaged.extensions.serverless import ServerlessActivitiesClient
10+
from durabletask.azuremanaged.extensions.serverless import ServerlessWorkerProfile
11+
from durabletask.azuremanaged.extensions.serverless import serverless_worker_profile
1012
from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker
1113

12-
13-
REMOTE_ACTIVITY_NAME = "remote_hello"
14+
from activity_names import REMOTE_HELLO
1415

1516

1617
def hello_orchestrator(ctx: task.OrchestrationContext, name: str):
1718
"""Orchestrator that calls an activity executed by the remote worker image."""
18-
return (yield ctx.call_activity(REMOTE_ACTIVITY_NAME, input=name))
19+
return (yield ctx.call_activity(REMOTE_HELLO, input=name))
1920

2021

2122
taskhub_name = os.getenv("DTS_TASK_HUB") or "ServerlessPocHub"
2223
endpoint = os.getenv("DTS_ENDPOINT", "http://localhost:8080")
2324
worker_profile_id = os.getenv("DTS_WORKER_PROFILE_ID", "default")
24-
serverless_image = os.getenv("DTS_SERVERLESS_ACTIVITY_IMAGE", "serverless-remote-worker:local")
25-
serverless_cpu = os.getenv("DTS_SERVERLESS_CPU", "1000m")
26-
serverless_memory = os.getenv("DTS_SERVERLESS_MEMORY", "2048Mi")
27-
serverless_max_activities = int(os.getenv("DTS_SERVERLESS_MAX_ACTIVITIES", "1"))
2825
sample_input = os.getenv("DTS_SAMPLE_HELLO_INPUT", "serverless Python")
2926

27+
28+
@serverless_worker_profile(worker_profile_id)
29+
class RemoteWorkerProfile(ServerlessWorkerProfile):
30+
"""Serverless worker profile used by the sample remote activity."""
31+
32+
def configure(self, options) -> None:
33+
options.container_image = "serverless-remote-worker:local"
34+
options.cpu = "1000m"
35+
options.memory = "2048Mi"
36+
options.max_concurrent_activities = 1
37+
options.environment_variables["SERVERLESS_SAMPLE_MARKER"] = "serverless-python-sample-marker"
38+
options.add_activity(REMOTE_HELLO)
39+
40+
3041
print(f"Using taskhub: {taskhub_name}")
3142
print(f"Using endpoint: {endpoint}")
32-
print(f"Declaring serverless activity image: {serverless_image}")
43+
print("Declaring serverless activity image: serverless-remote-worker:local")
3344

3445
secure_channel = endpoint.startswith("https://") or endpoint.startswith("grpcs://")
3546
credential = DefaultAzureCredential() if secure_channel else None
@@ -39,13 +50,7 @@ def hello_orchestrator(ctx: task.OrchestrationContext, name: str):
3950
secure_channel=secure_channel,
4051
taskhub=taskhub_name,
4152
token_credential=credential)
42-
serverless_client.declare_serverless_activities(
43-
activity_names=REMOTE_ACTIVITY_NAME,
44-
worker_profile_id=worker_profile_id,
45-
container_image=serverless_image,
46-
cpu=serverless_cpu,
47-
memory=serverless_memory,
48-
max_concurrent_activities=serverless_max_activities)
53+
serverless_client.enable_serverless_activities()
4954

5055
with DurableTaskSchedulerWorker(
5156
host_address=endpoint,

examples/serverless/remote_worker.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,21 @@
66
from durabletask import task
77
from durabletask.azuremanaged.extensions.serverless import ServerlessWorker
88

9+
from activity_names import REMOTE_HELLO
910

10-
def remote_hello(ctx: task.ActivityContext, name: str) -> str:
11+
12+
def _remote_hello(ctx: task.ActivityContext, name: str) -> str:
1113
"""Activity function that runs inside the serverless worker container."""
1214
sandbox_id = os.getenv("DTS_SANDBOX_ID", "unknown-sandbox")
13-
return f"Hello {name} from Python serverless worker {sandbox_id}!"
15+
marker = os.getenv("SERVERLESS_SAMPLE_MARKER", "<missing>")
16+
return f"Hello {name} from Python serverless worker {sandbox_id}! SERVERLESS_SAMPLE_MARKER={marker}"
17+
18+
19+
_remote_hello.__name__ = REMOTE_HELLO
1420

1521

1622
with ServerlessWorker() as worker:
17-
worker.add_activity(remote_hello)
23+
worker.add_activity(_remote_hello)
1824
worker.start()
1925
print("Python serverless remote worker is running. Press Ctrl+C to stop.")
2026
try:

0 commit comments

Comments
 (0)