|
1 | 1 | # Copyright (c) Microsoft Corporation. |
2 | 2 | # Licensed under the MIT License. |
3 | 3 |
|
4 | | -from typing import Iterable, Optional, Sequence |
| 4 | +from dataclasses import dataclass, field |
| 5 | +from typing import Callable, Iterable, Optional, Sequence |
5 | 6 |
|
6 | 7 | import grpc |
7 | 8 | from azure.core.credentials import TokenCredential |
8 | 9 |
|
| 10 | +from durabletask import task |
9 | 11 | from durabletask.azuremanaged.internal.durabletask_grpc_interceptor import ( |
10 | 12 | DTSDefaultClientInterceptorImpl, |
11 | 13 | ) |
|
21 | 23 | DEFAULT_MAX_CONCURRENT_ACTIVITIES = 100 |
22 | 24 |
|
23 | 25 |
|
| 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 | + |
24 | 85 | def resolve_activity_names(activity_names: str | Iterable[str]) -> list[str]: |
25 | 86 | resolved: list[str] = [] |
26 | 87 | seen: set[str] = set() |
@@ -114,6 +175,41 @@ def build_serverless_activity_declaration( |
114 | 175 | return declaration |
115 | 176 |
|
116 | 177 |
|
| 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 | + |
117 | 213 | def build_serverless_worker_start( |
118 | 214 | *, |
119 | 215 | taskhub: str, |
@@ -188,37 +284,14 @@ def close(self) -> None: |
188 | 284 | if self._owns_channel: |
189 | 285 | self._channel.close() |
190 | 286 |
|
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) |
222 | 295 |
|
223 | 296 | def remove_serverless_activity_declaration(self, worker_profile_id: str) -> None: |
224 | 297 | worker_profile_id = _normalize_required(worker_profile_id, "Worker profile ID is required.") |
|
0 commit comments