Skip to content

Commit e882381

Browse files
andystaplesCopilot
andcommitted
Document gRPC history compatibility boundary
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a28a3fa8-303a-4a74-8fbc-8e062e39d97f
1 parent 387cd1e commit e882381

6 files changed

Lines changed: 21 additions & 69 deletions

File tree

CHANGELOG.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,6 @@ import paths, `__all__`, `dir()`, and star-imports behave exactly as before.
4141

4242
FIXED
4343

44-
- Failed task and sub-orchestration history events now retain the host-provided
45-
failure `reason` and redacted `details` alongside structured failure details.
4644
- Fixed `AsyncTaskHubGrpcClient` failing during construction when no current
4745
event loop was set. SDK-owned async gRPC channels are now created on first use,
4846
binding them to the event loop that performs the RPC.

azure-functions-durable/CHANGELOG.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,11 @@ avoiding repeated allocation of unused worker resources.
3333
FIXED
3434

3535
- The v1-compatible `get_status()` API now supports `show_history` and
36-
`show_history_output`, including compacted `historyEvents` output without an
37-
additional history request when history is not requested.
36+
`show_history_output`, including compacted `historyEvents` output without an
37+
additional history request when history is not requested. Failed activity and
38+
sub-orchestration events expose the structured `FailureDetails` supplied by
39+
the gRPC host, but not the v1-only top-level `Reason` and `Details` fields,
40+
which are not represented by the shared gRPC history protocol.
3841
- Check-status responses now include the standard `Retry-After: 10` polling
3942
header. Failed orchestrations return HTTP 200 from the wait helper by default;
4043
callers can request HTTP 500 responses through

azure-functions-durable/MIGRATION_GUIDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,10 @@ instead of calling `context.set_result`.
269269

270270
- `DurableOrchestrationContext.histories` is unavailable. Retrieve history with
271271
`client.get_orchestration_history(instance_id)`.
272+
- `get_status(show_history=True)` cannot reproduce the v1 top-level `Reason`
273+
and `Details` fields for failed activities or sub-orchestrations because the
274+
shared gRPC history protocol represents only structured `FailureDetails`.
275+
Read `FailureDetails` when present.
272276
- Distributed tracing is not yet wired through the Python provider.
273277
- Continuous history export is not supported by Azure Functions.
274278
- Unusual callable signatures can be misclassified by the compatibility layer.

durabletask/history.py

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,6 @@ class TaskCompletedEvent(HistoryEvent):
9494
class TaskFailedEvent(HistoryEvent):
9595
task_scheduled_id: int
9696
failure_details: task.FailureDetails | None = None
97-
reason: str | None = None
98-
details: str | None = None
9997

10098

10199
@dataclass(slots=True)
@@ -118,8 +116,6 @@ class SubOrchestrationInstanceCompletedEvent(HistoryEvent):
118116
class SubOrchestrationInstanceFailedEvent(HistoryEvent):
119117
task_scheduled_id: int
120118
failure_details: task.FailureDetails | None = None
121-
reason: str | None = None
122-
details: str | None = None
123119

124120

125121
@dataclass(slots=True)
@@ -290,15 +286,6 @@ def _failure_details(msg: Message, field_name: str) -> task.FailureDetails | Non
290286
)
291287

292288

293-
def _failure_event_kwargs(msg: Message) -> dict[str, Any]:
294-
failure_details = _failure_details(msg, 'failureDetails')
295-
return {
296-
'failure_details': failure_details,
297-
'reason': failure_details.message if failure_details is not None else None,
298-
'details': failure_details.stack_trace if failure_details is not None else None,
299-
}
300-
301-
302289
def _trace_context(msg: Message, field_name: str) -> TraceContext | None:
303290
if not msg.HasField(field_name):
304291
return None
@@ -503,7 +490,7 @@ def _to_serializable(value: Any) -> Any:
503490
'taskFailed': lambda event: TaskFailedEvent(
504491
**_base_kwargs(event),
505492
task_scheduled_id=event.taskFailed.taskScheduledId,
506-
**_failure_event_kwargs(event.taskFailed),
493+
failure_details=_failure_details(event.taskFailed, 'failureDetails'),
507494
),
508495
'subOrchestrationInstanceCreated': lambda event: SubOrchestrationInstanceCreatedEvent(
509496
**_base_kwargs(event),
@@ -522,7 +509,7 @@ def _to_serializable(value: Any) -> Any:
522509
'subOrchestrationInstanceFailed': lambda event: SubOrchestrationInstanceFailedEvent(
523510
**_base_kwargs(event),
524511
task_scheduled_id=event.subOrchestrationInstanceFailed.taskScheduledId,
525-
**_failure_event_kwargs(event.subOrchestrationInstanceFailed),
512+
failure_details=_failure_details(event.subOrchestrationInstanceFailed, 'failureDetails'),
526513
),
527514
'timerCreated': lambda event: TimerCreatedEvent(
528515
**_base_kwargs(event),

tests/azure-functions-durable/test_client_compat.py

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -968,9 +968,7 @@ def _fake_history():
968968
timestamp=started_at + timedelta(seconds=5),
969969
task_scheduled_id=4,
970970
failure_details=dt_task.FailureDetails(
971-
"activity failed", "RuntimeError", "activity details"),
972-
reason="activity failed",
973-
details="activity details"),
971+
"activity failed", "RuntimeError", "activity stack trace")),
974972
dt_history.SubOrchestrationInstanceCreatedEvent(
975973
event_id=6,
976974
timestamp=started_at + timedelta(seconds=6),
@@ -980,11 +978,7 @@ def _fake_history():
980978
dt_history.SubOrchestrationInstanceFailedEvent(
981979
event_id=7,
982980
timestamp=started_at + timedelta(seconds=7),
983-
task_scheduled_id=6,
984-
failure_details=dt_task.FailureDetails(
985-
"child failed", "RuntimeError", "child details"),
986-
reason="child failed",
987-
details="child details"),
981+
task_scheduled_id=6),
988982
dt_history.ExecutionCompletedEvent(
989983
event_id=8,
990984
timestamp=started_at + timedelta(seconds=8),
@@ -994,7 +988,7 @@ def _fake_history():
994988

995989

996990
@pytest.mark.parametrize("show_history_output", [False, True])
997-
async def test_get_status_projects_v1_history(show_history_output):
991+
async def test_get_status_projects_available_v1_history(show_history_output):
998992
client = _make_client()
999993
try:
1000994
history_mock = AsyncMock(return_value=_fake_history())
@@ -1039,14 +1033,16 @@ async def test_get_status_projects_v1_history(show_history_output):
10391033
assert completed["ScheduledTime"] == (
10401034
"2026-01-01T00:00:01.000000Z")
10411035
task_failed = events_by_type["TaskFailed"]
1042-
assert task_failed["Reason"] == "activity failed"
1043-
assert task_failed["Details"] == "activity details"
10441036
assert task_failed["FailureDetails"]["ErrorMessage"] == (
10451037
"activity failed")
1038+
assert task_failed["FailureDetails"]["StackTrace"] == (
1039+
"activity stack trace")
1040+
assert "Reason" not in task_failed
1041+
assert "Details" not in task_failed
10461042
child_failed = events_by_type["SubOrchestrationInstanceFailed"]
1047-
assert child_failed["Reason"] == "child failed"
1048-
assert child_failed["Details"] == "child details"
1049-
assert child_failed["FailureDetails"]["ErrorMessage"] == "child failed"
1043+
assert "Reason" not in child_failed
1044+
assert "Details" not in child_failed
1045+
assert "FailureDetails" not in child_failed
10501046
execution_completed = events_by_type["ExecutionCompleted"]
10511047
assert execution_completed["OrchestrationStatus"] == "Completed"
10521048
if show_history_output:

tests/durabletask/test_history_serialization.py

Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,8 @@
1919
from typing import Any, NamedTuple, cast
2020

2121
import pytest
22-
from google.protobuf import timestamp_pb2, wrappers_pb2
2322

2423
from durabletask import history, task
25-
import durabletask.internal.orchestrator_service_pb2 as pb
2624

2725
_TS = datetime(2025, 1, 2, 3, 4, 5, 678901, tzinfo=timezone.utc)
2826
_NAIVE_TS = datetime(2024, 12, 31, 23, 59, 58)
@@ -52,40 +50,6 @@ def _failure(message: str = 'boom') -> task.FailureDetails:
5250
return task.FailureDetails(message, 'RuntimeError', 'Traceback...')
5351

5452

55-
@pytest.mark.parametrize(
56-
("proto_field", "expected_type"),
57-
[
58-
("taskFailed", history.TaskFailedEvent),
59-
("subOrchestrationInstanceFailed",
60-
history.SubOrchestrationInstanceFailedEvent),
61-
],
62-
)
63-
def test_failed_events_preserve_legacy_failure_fields_from_proto(
64-
proto_field: str,
65-
expected_type: type[history.HistoryEvent]):
66-
timestamp = timestamp_pb2.Timestamp()
67-
timestamp.FromDatetime(_TS)
68-
event = pb.HistoryEvent(eventId=2, timestamp=timestamp)
69-
failed_event = getattr(event, proto_field)
70-
failed_event.taskScheduledId = 1
71-
failed_event.failureDetails.CopyFrom(pb.TaskFailureDetails(
72-
errorMessage="activity failed",
73-
errorType="RuntimeError",
74-
stackTrace=wrappers_pb2.StringValue(value="redacted details")))
75-
76-
converted = history._from_protobuf(event) # pyright: ignore[reportPrivateUsage]
77-
78-
assert type(converted) is expected_type
79-
assert isinstance(
80-
converted,
81-
(history.TaskFailedEvent,
82-
history.SubOrchestrationInstanceFailedEvent))
83-
assert converted.reason == "activity failed"
84-
assert converted.details == "redacted details"
85-
assert converted.failure_details == task.FailureDetails(
86-
"activity failed", "RuntimeError", "redacted details")
87-
88-
8953
@dataclass
9054
class _Inner:
9155
"""A dataclass the walker should convert wherever it is nested."""

0 commit comments

Comments
 (0)