Skip to content

Commit 9d409d6

Browse files
committed
Introduce activity hooks; update on-demand worker
Add lifecycle hooks around activity execution and adapt the on-demand sandbox worker to use them. TaskHubGrpcWorker now defines _durabletask_on_activity_execution_started/completed and invokes them around activity execution; _execute_activity was refactored to adjust payload (de/externalization) and error handling. OnDemandSandboxWorker was updated to use new hook semantics, track active activity counts, expose add_activity wrapper for name resolution, use the internal shared logger, and consolidate on-demand-specific host/secure-channel attributes. Tests updated accordingly and a new test file verifies the activity hook behavior and active-activity counting.
1 parent 7a93746 commit 9d409d6

4 files changed

Lines changed: 170 additions & 66 deletions

File tree

durabletask-azuremanaged/durabletask/azuremanaged/preview/on_demand_sandbox/worker.py

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
resolve_activity_names,
1919
)
2020
from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker
21+
import durabletask.internal.shared as shared
2122
from durabletask.worker import (
2223
ActivityWorkItemFilter,
2324
ConcurrencyOptions,
@@ -41,7 +42,10 @@ def __init__(self):
4142
concurrency_options = ConcurrencyOptions(
4243
maximum_concurrent_activity_work_items=resolved_max_concurrent_activities)
4344

45+
self._on_demand_sandbox_host_address = resolved_host_address
46+
self._on_demand_sandbox_secure_channel = resolved_secure_channel
4447
self._on_demand_sandbox_token_credential = resolved_token_credential
48+
self._on_demand_sandbox_logger = shared.get_logger("worker")
4549

4650
super().__init__(
4751
host_address=resolved_host_address,
@@ -62,10 +66,13 @@ def __init__(self):
6266
self._on_demand_sandbox_active_activities = 0
6367
self._on_demand_sandbox_active_activities_lock = threading.Lock()
6468

65-
def start(self) -> None:
66-
if self._is_running:
67-
raise RuntimeError("The worker is already running.")
69+
def add_activity(self, fn):
70+
activity_name = super().add_activity(fn)
71+
self._on_demand_sandbox_activity_names = resolve_activity_names(
72+
[*self._on_demand_sandbox_activity_names, activity_name])
73+
return activity_name
6874

75+
def start(self) -> None:
6976
self._configure_on_demand_sandbox_activity_filters()
7077
super().start()
7178
self._start_on_demand_sandbox_registration()
@@ -74,17 +81,16 @@ def stop(self) -> None:
7481
self._stop_on_demand_sandbox_registration()
7582
super().stop()
7683

77-
def _execute_activity(self, req, stub, completionToken):
84+
def _durabletask_on_activity_execution_started(self, req) -> None:
7885
with self._on_demand_sandbox_active_activities_lock:
7986
self._on_demand_sandbox_active_activities += 1
80-
try:
81-
return super()._execute_activity(req, stub, completionToken)
82-
finally:
83-
with self._on_demand_sandbox_active_activities_lock:
84-
self._on_demand_sandbox_active_activities = max(0, self._on_demand_sandbox_active_activities - 1)
87+
88+
def _durabletask_on_activity_execution_completed(self, req) -> None:
89+
with self._on_demand_sandbox_active_activities_lock:
90+
self._on_demand_sandbox_active_activities = max(0, self._on_demand_sandbox_active_activities - 1)
8591

8692
def _configure_on_demand_sandbox_activity_filters(self) -> None:
87-
activity_names = resolve_activity_names(self._registry.activities.keys())
93+
activity_names = resolve_activity_names(self._on_demand_sandbox_activity_names)
8894
if not activity_names:
8995
raise RuntimeError(
9096
"On-demand sandbox worker requires at least one registered activity before it can register.")
@@ -114,12 +120,10 @@ def _run_on_demand_sandbox_registration_loop(self) -> None:
114120
while not self._on_demand_sandbox_registration_stop.is_set():
115121
try:
116122
client = OnDemandSandboxActivitiesClient(
117-
host_address=self._host_address,
123+
host_address=self._on_demand_sandbox_host_address,
118124
taskhub=self._on_demand_sandbox_taskhub,
119125
token_credential=self._on_demand_sandbox_token_credential,
120-
channel=self._channel,
121-
secure_channel=self._secure_channel,
122-
channel_options=self._channel_options)
126+
secure_channel=self._on_demand_sandbox_secure_channel)
123127
try:
124128
client.connect_on_demand_sandbox_activity_worker(self._registration_messages())
125129
retry_delay = 1.0
@@ -128,7 +132,7 @@ def _run_on_demand_sandbox_registration_loop(self) -> None:
128132
except Exception as ex:
129133
if self._on_demand_sandbox_registration_stop.is_set():
130134
break
131-
self._logger.warning("On-demand sandbox activity worker registration failed: %s", ex)
135+
self._on_demand_sandbox_logger.warning("On-demand sandbox activity worker registration failed: %s", ex)
132136
delay = random.uniform(0, retry_delay)
133137
self._on_demand_sandbox_registration_stop.wait(delay)
134138
retry_delay = min(retry_delay * 2, 30.0)

durabletask/worker.py

Lines changed: 55 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -973,62 +973,71 @@ def _cancel_orchestrator(
973973
)
974974
self._logger.info(f"Cancelled orchestration task for invocation ID: {req.instanceId}")
975975

976+
def _durabletask_on_activity_execution_started(self, req: pb.ActivityRequest) -> None:
977+
pass
978+
979+
def _durabletask_on_activity_execution_completed(self, req: pb.ActivityRequest) -> None:
980+
pass
981+
976982
def _execute_activity(
977983
self,
978984
req: pb.ActivityRequest,
979985
stub: Union[stubs.TaskHubSidecarServiceStub, ProtoTaskHubSidecarServiceStub],
980986
completionToken,
981987
):
982988
instance_id = req.orchestrationInstance.instanceId
983-
984-
# De-externalize any large-payload tokens in the incoming request
985-
if self._payload_store is not None:
986-
payload_helpers.deexternalize_payloads(req, self._payload_store)
987-
try:
988-
executor = _ActivityExecutor(self._registry, self._logger)
989-
with tracing.start_span(
990-
tracing.create_span_name("activity", req.name),
991-
trace_context=req.parentTraceContext,
992-
kind=tracing.SpanKind.SERVER,
993-
attributes={
994-
tracing.ATTR_TASK_TYPE: "activity",
995-
tracing.ATTR_TASK_INSTANCE_ID: instance_id,
996-
tracing.ATTR_TASK_NAME: req.name,
997-
tracing.ATTR_TASK_TASK_ID: str(req.taskId),
998-
},
999-
) as span:
1000-
try:
1001-
result = executor.execute(
1002-
instance_id, req.name, req.taskId, req.input.value
1003-
)
1004-
except Exception as ex:
1005-
tracing.set_span_error(span, ex)
1006-
raise
1007-
res = pb.ActivityResponse(
1008-
instanceId=instance_id,
1009-
taskId=req.taskId,
1010-
result=ph.get_string_value(result),
1011-
completionToken=completionToken,
1012-
)
1013-
except Exception as ex:
1014-
res = pb.ActivityResponse(
1015-
instanceId=instance_id,
1016-
taskId=req.taskId,
1017-
failureDetails=ph.new_failure_details(ex),
1018-
completionToken=completionToken,
1019-
)
1020-
989+
self._durabletask_on_activity_execution_started(req)
1021990
try:
1022-
# Externalize any large payloads in the response
991+
# De-externalize any large-payload tokens in the incoming request
1023992
if self._payload_store is not None:
1024-
payload_helpers.externalize_payloads(
1025-
res, self._payload_store, instance_id=instance_id,
993+
payload_helpers.deexternalize_payloads(req, self._payload_store)
994+
try:
995+
executor = _ActivityExecutor(self._registry, self._logger)
996+
with tracing.start_span(
997+
tracing.create_span_name("activity", req.name),
998+
trace_context=req.parentTraceContext,
999+
kind=tracing.SpanKind.SERVER,
1000+
attributes={
1001+
tracing.ATTR_TASK_TYPE: "activity",
1002+
tracing.ATTR_TASK_INSTANCE_ID: instance_id,
1003+
tracing.ATTR_TASK_NAME: req.name,
1004+
tracing.ATTR_TASK_TASK_ID: str(req.taskId),
1005+
},
1006+
) as span:
1007+
try:
1008+
result = executor.execute(
1009+
instance_id, req.name, req.taskId, req.input.value
1010+
)
1011+
except Exception as ex:
1012+
tracing.set_span_error(span, ex)
1013+
raise
1014+
res = pb.ActivityResponse(
1015+
instanceId=instance_id,
1016+
taskId=req.taskId,
1017+
result=ph.get_string_value(result),
1018+
completionToken=completionToken,
10261019
)
1027-
stub.CompleteActivityTask(res)
1028-
except Exception as ex:
1029-
self._logger.exception(
1030-
f"Failed to deliver activity response for '{req.name}#{req.taskId}' of orchestration ID '{instance_id}' to sidecar: {ex}"
1031-
)
1020+
except Exception as ex:
1021+
res = pb.ActivityResponse(
1022+
instanceId=instance_id,
1023+
taskId=req.taskId,
1024+
failureDetails=ph.new_failure_details(ex),
1025+
completionToken=completionToken,
1026+
)
1027+
1028+
try:
1029+
# Externalize any large payloads in the response
1030+
if self._payload_store is not None:
1031+
payload_helpers.externalize_payloads(
1032+
res, self._payload_store, instance_id=instance_id,
1033+
)
1034+
stub.CompleteActivityTask(res)
1035+
except Exception as ex:
1036+
self._logger.exception(
1037+
f"Failed to deliver activity response for '{req.name}#{req.taskId}' of orchestration ID '{instance_id}' to sidecar: {ex}"
1038+
)
1039+
finally:
1040+
self._durabletask_on_activity_execution_completed(req)
10321041

10331042
def _cancel_activity(
10341043
self,

tests/durabletask-azuremanaged/test_on_demand_sandbox_extension.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,7 @@ def test_generated_stub_uses_on_demand_sandbox_rpc_paths() -> None:
290290

291291
def test_on_demand_sandbox_worker_constructor_does_not_expose_runtime_contract() -> None:
292292
assert list(inspect.signature(OnDemandSandboxWorker).parameters) == []
293+
assert "_execute_activity" not in OnDemandSandboxWorker.__dict__
293294

294295

295296
def test_on_demand_sandbox_worker_does_not_own_legacy_wakeup_server(monkeypatch) -> None:
@@ -311,17 +312,23 @@ def test_on_demand_sandbox_worker_reads_sandbox_environment_and_registered_activ
311312
monkeypatch.setenv("DTS_SUBSTRATE", "AcaSessionPool")
312313
monkeypatch.setenv("DTS_SANDBOX_ID", "env-sandbox")
313314

315+
def EnvActivity(_ctx, value):
316+
return value
317+
318+
def OtherActivity(_ctx, value):
319+
return value
320+
314321
worker = OnDemandSandboxWorker()
315-
worker._registry.add_named_activity("EnvActivity", lambda _ctx, value: value)
316-
worker._registry.add_named_activity("OtherActivity", lambda _ctx, value: value)
322+
worker.add_activity(EnvActivity)
323+
worker.add_activity(OtherActivity)
317324
worker._configure_on_demand_sandbox_activity_filters()
318325
start = next(worker._registration_messages())
319326

320-
assert worker._host_address == "http://localhost:8080"
327+
assert worker._on_demand_sandbox_host_address == "http://localhost:8080"
321328
assert worker._on_demand_sandbox_token_credential is None
322329
assert worker._on_demand_sandbox_taskhub == "env-hub"
323330
assert worker._on_demand_sandbox_worker_profile_id == "env-profile"
324-
assert worker._concurrency_options.maximum_concurrent_activity_work_items == 7
331+
assert worker.concurrency_options.maximum_concurrent_activity_work_items == 7
325332
assert worker._work_item_filters is not None
326333
assert [activity.name for activity in worker._work_item_filters.activities] == [
327334
"EnvActivity",
@@ -353,7 +360,23 @@ def test_on_demand_sandbox_worker_ignores_legacy_max_activities(monkeypatch) ->
353360

354361
worker = OnDemandSandboxWorker()
355362

356-
assert worker._concurrency_options.maximum_concurrent_activity_work_items == 100
363+
assert worker.concurrency_options.maximum_concurrent_activity_work_items == 100
364+
365+
366+
def test_on_demand_sandbox_worker_tracks_active_activity_count_with_hooks(monkeypatch) -> None:
367+
monkeypatch.setenv("DTS_ENDPOINT", "https://example.scheduler")
368+
monkeypatch.setenv("DTS_TASK_HUB", "env-hub")
369+
370+
worker = OnDemandSandboxWorker()
371+
372+
worker._durabletask_on_activity_execution_started(object())
373+
assert worker._on_demand_sandbox_active_activities == 1
374+
375+
worker._durabletask_on_activity_execution_completed(object())
376+
assert worker._on_demand_sandbox_active_activities == 0
377+
378+
worker._durabletask_on_activity_execution_completed(object())
379+
assert worker._on_demand_sandbox_active_activities == 0
357380

358381

359382
def test_on_demand_sandbox_worker_uses_managed_identity_credential_when_injected(monkeypatch) -> None:
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
from google.protobuf import wrappers_pb2
5+
6+
import durabletask.internal.orchestrator_service_pb2 as pb
7+
from durabletask.worker import TaskHubGrpcWorker
8+
9+
10+
class RecordingStub:
11+
def __init__(self):
12+
self.completed = []
13+
14+
def CompleteActivityTask(self, response):
15+
self.completed.append(response)
16+
17+
18+
class HookedWorker(TaskHubGrpcWorker):
19+
def __init__(self):
20+
super().__init__()
21+
self.events = []
22+
23+
def _durabletask_on_activity_execution_started(self, req):
24+
self.events.append(("started", req.name))
25+
26+
def _durabletask_on_activity_execution_completed(self, req):
27+
self.events.append(("completed", req.name))
28+
29+
30+
def test_activity_execution_hooks_run_around_successful_activity() -> None:
31+
def echo(_ctx, value):
32+
return value
33+
34+
worker = HookedWorker()
35+
worker.add_activity(echo)
36+
stub = RecordingStub()
37+
request = _activity_request("echo", "hello")
38+
39+
worker._execute_activity(request, stub, b"token")
40+
41+
assert worker.events == [("started", "echo"), ("completed", "echo")]
42+
assert len(stub.completed) == 1
43+
assert stub.completed[0].result.value == "\"hello\""
44+
45+
46+
def test_activity_execution_completed_hook_runs_after_failed_activity() -> None:
47+
def fails(_ctx, _value):
48+
raise ValueError("boom")
49+
50+
worker = HookedWorker()
51+
worker.add_activity(fails)
52+
stub = RecordingStub()
53+
request = _activity_request("fails", "hello")
54+
55+
worker._execute_activity(request, stub, b"token")
56+
57+
assert worker.events == [("started", "fails"), ("completed", "fails")]
58+
assert len(stub.completed) == 1
59+
assert stub.completed[0].failureDetails.errorType == "ValueError"
60+
61+
62+
def _activity_request(name: str, value: str) -> pb.ActivityRequest:
63+
return pb.ActivityRequest(
64+
orchestrationInstance=pb.OrchestrationInstance(instanceId="instance-1"),
65+
name=name,
66+
taskId=1,
67+
input=wrappers_pb2.StringValue(value=f"\"{value}\""),
68+
)

0 commit comments

Comments
 (0)