Skip to content

Commit e4cf01e

Browse files
committed
fix: complete permanent payload failures and release entity locks
Fixes #297 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 064f1284-f637-4ba9-8406-8c094ce9f845
1 parent 94085ba commit e4cf01e

8 files changed

Lines changed: 333 additions & 34 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ size before uploading or replacing any protobuf values, including carryover
1414
events and entity payloads, for both synchronous and asynchronous calls.
1515
Oversized messages remain unchanged and cause no uploads; their size error
1616
now takes precedence over hypothetical earlier storage failures.
17+
- Orchestrations that exceed the configured external payload storage limit now
18+
fail without retrying and release their entity locks, allowing other callers to
19+
progress. Transient payload-store failures remain retryable.
1720
- Orchestrator generators are now closed at the end of each dispatch, including
1821
while waiting for durable work, releasing resources held by async bridges.
1922
`finally` blocks and context-manager exits can run before orchestration completion

‎durabletask-azuremanaged/CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ FIXED
1212
- With the corresponding core `durabletask` SDK fix, oversized messages fail
1313
large-payload validation before any uploads or payload replacements. Size
1414
errors now take precedence over hypothetical earlier storage failures.
15+
- With the corresponding core `durabletask` SDK fix, orchestrations that exceed
16+
the configured external payload storage limit fail without retrying and release
17+
their entity locks. Transient payload-store failures remain retryable.
1518
- With the corresponding core `durabletask` SDK fix, orchestrator generators
1619
are closed at the end of each dispatch, including while waiting for durable work.
1720
See [cleanup semantics](../docs/supported-patterns.md#cleanup-in-orchestrator-finally-blocks)

‎durabletask/internal/exceptions.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,7 @@ def __init__(self, error_details: pb.TaskFailureDetails) -> None:
1212

1313
class AbandonOrchestrationError(Exception):
1414
pass
15+
16+
17+
class PayloadSizeExceededError(ValueError):
18+
"""A raw payload exceeds the configured external storage limit."""

‎durabletask/internal/orchestration_entity_context.py‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ def recover_lock_after_call(self, target_instance_id: EntityInstanceId):
6161
if self.is_inside_critical_section:
6262
self.available_locks.append(target_instance_id)
6363

64-
def emit_lock_release_messages(self) -> Generator[pb.SendEntityMessageAction, None, None]:
64+
def get_lock_release_messages(self) -> Generator[pb.SendEntityMessageAction, None, None]:
6565
if self.is_inside_critical_section:
6666
for entity_id in self.critical_section_locks:
6767
unlock_event = pb.SendEntityMessageAction(entityUnlockSent=pb.EntityUnlockSentEvent(
@@ -71,9 +71,11 @@ def emit_lock_release_messages(self) -> Generator[pb.SendEntityMessageAction, No
7171
))
7272
yield unlock_event
7373

74-
self.critical_section_locks = []
75-
self.available_locks = []
76-
self.critical_section_id = None
74+
def emit_lock_release_messages(self) -> Generator[pb.SendEntityMessageAction, None, None]:
75+
yield from self.get_lock_release_messages()
76+
self.critical_section_locks = []
77+
self.available_locks = []
78+
self.critical_section_id = None
7779

7880
def emit_request_message(self, target: Any, operation_name: str, one_way: bool, operation_id: str,
7981
scheduled_time_utc: datetime, input: str | None,
@@ -113,7 +115,7 @@ def emit_acquire_message(
113115
def complete_acquire(self, critical_section_id: str) -> None:
114116
if self.critical_section_id != critical_section_id:
115117
raise RuntimeError(f"Unexpected lock acquire for critical section ID '{critical_section_id}' (expected '{self.critical_section_id}')")
116-
self.available_locks = self.critical_section_locks
118+
self.available_locks = list(self.critical_section_locks)
117119
self.lock_acquisition_pending = False
118120

119121
def adjust_outgoing_message(self, instance_id: str, request_message: Any, capped_time: datetime) -> str:

‎durabletask/payload/helpers.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
from google.protobuf import message as proto_message
1919
from google.protobuf import wrappers_pb2
2020

21+
from durabletask.internal.exceptions import PayloadSizeExceededError
22+
2123
if TYPE_CHECKING:
2224
from durabletask.payload.store import PayloadStore
2325

@@ -150,7 +152,7 @@ def _collect_payloads_to_externalize(
150152
if len(payload_bytes) <= threshold:
151153
continue
152154
if len(payload_bytes) > max_bytes:
153-
raise ValueError(
155+
raise PayloadSizeExceededError(
154156
f"Payload size {len(payload_bytes)} bytes exceeds the maximum "
155157
f"allowed size of {max_bytes} bytes."
156158
)

‎durabletask/worker.py‎

Lines changed: 53 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1179,6 +1179,7 @@ def _execute_orchestrator(
11791179
if (req.HasField("orchestrationTraceContext") and req.orchestrationTraceContext.HasField("spanID") and req.orchestrationTraceContext.spanID.value):
11801180
persisted_orch_span_id = req.orchestrationTraceContext.spanID.value
11811181

1182+
result: ExecutionResults | None = None
11821183
try:
11831184
executor = _OrchestrationExecutor(
11841185
self._registry, self._logger,
@@ -1192,30 +1193,6 @@ def _execute_orchestrator(
11921193
result = executor.execute(
11931194
instance_id, req.pastEvents, req.newEvents)
11941195

1195-
# Determine completion status for span
1196-
is_complete = False
1197-
is_failed = False
1198-
failure_details = None
1199-
for action in result.actions:
1200-
if action.HasField("completeOrchestration"):
1201-
is_complete = True
1202-
orch_status = action.completeOrchestration.orchestrationStatus
1203-
if orch_status == pb.ORCHESTRATION_STATUS_FAILED:
1204-
is_failed = True
1205-
failure_details = action.completeOrchestration.failureDetails
1206-
1207-
if is_complete and self._emit_trace_spans:
1208-
# Orchestration finished — emit a single span covering its lifetime
1209-
tracing.emit_orchestration_span(
1210-
orchestration_name,
1211-
instance_id,
1212-
start_time_ns,
1213-
is_failed,
1214-
failure_details=failure_details,
1215-
parent_trace_context=parent_trace_ctx,
1216-
orchestration_trace_context=result._orchestration_trace_context, # pyright: ignore[reportPrivateUsage]
1217-
)
1218-
12191196
# Include the span ID in the orchestration trace context
12201197
# so it persists across dispatches.
12211198
orch_span_id = None
@@ -1278,10 +1255,54 @@ def _execute_orchestrator(
12781255

12791256
try:
12801257
# Externalize any large payloads in the response
1281-
if self._payload_store is not None:
1282-
payload_helpers.externalize_payloads(
1283-
res, self._payload_store, instance_id=instance_id,
1258+
try:
1259+
if self._payload_store is not None:
1260+
payload_helpers.externalize_payloads(
1261+
res, self._payload_store, instance_id=instance_id,
1262+
)
1263+
except pe.PayloadSizeExceededError as ex:
1264+
self._logger.warning(
1265+
f"Orchestration '{instance_id}' exceeded the payload storage limit: {ex}"
12841266
)
1267+
# Use the original actions, not a partially externalized response.
1268+
releases = [
1269+
action for action in result.actions
1270+
if action.HasField("sendEntityMessage")
1271+
and action.sendEntityMessage.HasField("entityUnlockSent")
1272+
] if result is not None else []
1273+
next_id = max((action.id for action in res.actions), default=0) + 1
1274+
if result is not None:
1275+
for release in result.entity_lock_releases:
1276+
releases.append(pb.OrchestratorAction(id=next_id, sendEntityMessage=release))
1277+
next_id += 1
1278+
releases.append(ph.new_complete_orchestration_action(
1279+
next_id,
1280+
pb.ORCHESTRATION_STATUS_FAILED,
1281+
None,
1282+
pb.TaskFailureDetails(
1283+
errorType=ph.get_qualified_name(type(ex)),
1284+
errorMessage=str(ex)[:1024],
1285+
isNonRetriable=True,
1286+
),
1287+
))
1288+
res.ClearField("actions")
1289+
res.actions.extend(releases)
1290+
res.ClearField("customStatus")
1291+
# Keep this fallback inline: it must not need the failing store.
1292+
1293+
if result is not None and self._emit_trace_spans:
1294+
for action in res.actions:
1295+
if action.HasField("completeOrchestration"):
1296+
completion = action.completeOrchestration
1297+
tracing.emit_orchestration_span(
1298+
orchestration_name,
1299+
instance_id,
1300+
start_time_ns,
1301+
completion.orchestrationStatus == pb.ORCHESTRATION_STATUS_FAILED,
1302+
failure_details=completion.failureDetails if completion.HasField("failureDetails") else None,
1303+
parent_trace_context=parent_trace_ctx,
1304+
orchestration_trace_context=result._orchestration_trace_context, # pyright: ignore[reportPrivateUsage]
1305+
)
12851306
stub.CompleteOrchestratorTask(res)
12861307
except Exception as ex:
12871308
self._logger.exception(
@@ -2194,14 +2215,17 @@ class ExecutionResults:
21942215
actions: list[pb.OrchestratorAction]
21952216
encoded_custom_status: str | None
21962217
_orchestration_trace_context: pb.TraceContext | None
2218+
entity_lock_releases: list[pb.SendEntityMessageAction]
21972219

21982220
def __init__(
21992221
self, actions: list[pb.OrchestratorAction], encoded_custom_status: str | None,
22002222
orchestration_trace_context: pb.TraceContext | None = None,
2223+
entity_lock_releases: list[pb.SendEntityMessageAction] | None = None,
22012224
):
22022225
self.actions = actions
22032226
self.encoded_custom_status = encoded_custom_status
22042227
self._orchestration_trace_context = orchestration_trace_context
2228+
self.entity_lock_releases = entity_lock_releases if entity_lock_releases is not None else []
22052229

22062230

22072231
class _OrchestrationExecutor:
@@ -2347,6 +2371,8 @@ def execute(
23472371
return ExecutionResults(
23482372
actions=actions, encoded_custom_status=ctx._encoded_custom_status, # pyright: ignore[reportPrivateUsage]
23492373
orchestration_trace_context=ctx._orchestration_trace_context, # pyright: ignore[reportPrivateUsage]
2374+
# Snapshot before generator teardown can exit a critical section.
2375+
entity_lock_releases=list(ctx._entity_context.get_lock_release_messages()), # pyright: ignore[reportPrivateUsage]
23502376
)
23512377
finally:
23522378
# Close after building the response, so the orchestrator's finally

‎tests/durabletask/test_large_payload.py‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from google.protobuf import wrappers_pb2
1313

1414
import durabletask.internal.orchestrator_service_pb2 as pb
15+
from durabletask.internal.exceptions import PayloadSizeExceededError
1516
from durabletask.payload.helpers import (
1617
deexternalize_payloads,
1718
deexternalize_payloads_async,
@@ -152,8 +153,9 @@ def test_exceeds_max_raises_error(self):
152153
name="MyOrch",
153154
input=sv(large_data),
154155
)
155-
with pytest.raises(ValueError, match="exceeds the maximum"):
156+
with pytest.raises(PayloadSizeExceededError, match="exceeds the maximum") as failure:
156157
externalize_payloads(req, store, instance_id="test-1")
158+
assert isinstance(failure.value, ValueError)
157159

158160
def test_empty_value_not_externalized(self):
159161
"""Empty StringValue should not be externalized."""
@@ -540,6 +542,40 @@ async def test_async_externalize_and_deexternalize(self):
540542
await deexternalize_payloads_async(req, store)
541543
assert req.input.value == original
542544

545+
@pytest.mark.asyncio
546+
async def test_async_cap_failure_is_a_value_error(self):
547+
store = FakePayloadStore(threshold_bytes=1, max_stored_payload_bytes=10)
548+
req = pb.CreateInstanceRequest(input=sv("x" * 11))
549+
with pytest.raises(PayloadSizeExceededError) as failure:
550+
await externalize_payloads_async(req, store)
551+
assert isinstance(failure.value, ValueError)
552+
assert not store._blobs
553+
554+
@pytest.mark.asyncio
555+
@pytest.mark.parametrize("use_async", [False, True])
556+
async def test_token_larger_than_cap_is_not_reuploaded(self, use_async):
557+
store = FakePayloadStore(threshold_bytes=1, max_stored_payload_bytes=10)
558+
token = f"{FakePayloadStore.TOKEN_PREFIX}existing-blob"
559+
assert len(token.encode("utf-8")) > store.options.max_stored_payload_bytes
560+
req = pb.CreateInstanceRequest(input=sv(token))
561+
if use_async:
562+
await externalize_payloads_async(req, store)
563+
else:
564+
externalize_payloads(req, store)
565+
assert req.input.value == token
566+
assert not store._blobs
567+
568+
@pytest.mark.asyncio
569+
@pytest.mark.parametrize("error", [OSError("unavailable"), ValueError("store error"), asyncio.CancelledError()])
570+
async def test_async_store_error_propagates_unchanged(self, error):
571+
store = FakePayloadStore(threshold_bytes=1)
572+
store.upload_async = AsyncMock(side_effect=error)
573+
req = pb.CreateInstanceRequest(input=sv("large enough"))
574+
with pytest.raises(type(error)) as failure:
575+
await externalize_payloads_async(req, store)
576+
assert failure.value is error
577+
assert req.input.value == "large enough"
578+
543579

544580
# ------------------------------------------------------------------
545581
# Tests: worker capability flag

0 commit comments

Comments
 (0)