Skip to content

Commit fc3b048

Browse files
andystaplesCopilot
andcommitted
Add v1 status history compatibility
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a28a3fa8-303a-4a74-8fbc-8e062e39d97f
1 parent 6d929cd commit fc3b048

7 files changed

Lines changed: 408 additions & 23 deletions

File tree

‎azure-functions-durable/CHANGELOG.md‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ avoiding repeated allocation of unused worker resources.
3232

3333
FIXED
3434

35+
- 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.
3538
- HTTP management payloads now preserve the host-provided management URL
3639
templates and configured HTTP base paths, include `rewindPostUri`, encode
3740
instance IDs, and use forwarded request origins when enabled by the host.
@@ -206,7 +209,3 @@ code:
206209
- Orchestration history is not exposed on the context;
207210
`DurableOrchestrationContext.histories` raises `NotImplementedError`. Use the
208211
client's `get_orchestration_history(...)` instead.
209-
- The client status methods accept the v1 `show_history` /
210-
`show_history_output` flags for signature compatibility but ignore them, so
211-
the returned status has no `historyEvents`. Use
212-
`get_orchestration_history(...)` to retrieve history.

‎azure-functions-durable/MIGRATION_GUIDE.md‎

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

270270
- `DurableOrchestrationContext.histories` is unavailable. Retrieve history with
271271
`client.get_orchestration_history(instance_id)`.
272-
- The compatibility `show_history` and `show_history_output` status flags are
273-
accepted but ignored.
274272
- Distributed tracing is not yet wired through the Python provider.
275273
- Continuous history export is not supported by Azure Functions.
276274
- Unusual callable signatures can be misclassified by the compatibility layer.

‎azure-functions-durable/azure/durable_functions/client.py‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from .http.http_management_payload import HttpManagementPayload, replace_url_origin
2929
from .internal.compat.durable_orchestration_status import DurableOrchestrationStatus
3030
from .internal.compat.entity_state_response import EntityStateResponse
31+
from .internal.compat.history_projection import project_history
3132
from .internal.compat.orchestration_runtime_status import OrchestrationRuntimeStatus, to_durabletask_statuses
3233
from .internal.compat.purge_history_result import PurgeHistoryResult
3334

@@ -339,15 +340,27 @@ async def get_status(
339340
``OrchestrationState`` for v1 back-compat. When the instance does not
340341
exist, a falsy status is returned rather than ``None``.
341342
342-
The ``show_history`` and ``show_history_output`` flags have no
343-
equivalent in durabletask and are ignored. Payloads are fetched to
343+
When ``show_history`` is true, history is fetched and projected into
344+
the v1 status-query shape. ``show_history_output`` controls whether
345+
output-bearing history fields are included. Payloads are fetched to
344346
preserve the v1 output, custom-status, and failure-detail fields;
345347
``show_input`` controls whether the compatibility wrapper exposes the
346-
input.
348+
orchestration and history inputs.
347349
"""
348350
state = await self.get_orchestration_state(instance_id, fetch_payloads=True)
351+
projected_history = None
352+
if (show_history
353+
and state is not None
354+
and state.runtime_status != OrchestrationStatus.PENDING):
355+
history = await self.get_orchestration_history(instance_id)
356+
projected_history = project_history(
357+
history,
358+
show_input=show_input,
359+
show_history_output=show_history_output)
349360
return DurableOrchestrationStatus.from_orchestration_state(
350-
state, include_input=show_input)
361+
state,
362+
include_input=show_input,
363+
history=projected_history)
351364

352365
@deprecated("get_status_all is deprecated; use get_all_orchestration_states instead.")
353366
async def get_status_all(self) -> list[DurableOrchestrationStatus]:

‎azure-functions-durable/azure/durable_functions/internal/compat/durable_orchestration_status.py‎

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,18 +27,23 @@ class DurableOrchestrationStatus:
2727
the v1 behaviour where ``get_status`` never returned ``None``.
2828
"""
2929

30-
def __init__(self, state: Optional[OrchestrationState] = None):
30+
def __init__(
31+
self,
32+
state: Optional[OrchestrationState] = None,
33+
history: Optional[list[Any]] = None):
3134
self._state = state
3235
self._include_input = True
36+
self._history = history
3337

3438
@classmethod
3539
def from_orchestration_state(
3640
cls,
3741
state: Optional[OrchestrationState],
3842
*,
39-
include_input: bool = True) -> "DurableOrchestrationStatus":
43+
include_input: bool = True,
44+
history: Optional[list[Any]] = None) -> "DurableOrchestrationStatus":
4045
"""Wrap a durabletask ``OrchestrationState`` (or ``None``)."""
41-
status = cls(state)
46+
status = cls(state, history)
4247
status._include_input = include_input
4348
return status
4449

@@ -77,7 +82,8 @@ def _reserialize(value: Any) -> Optional[str]:
7782
serialized_custom_status=_reserialize(data.get("customStatus")),
7883
failure_details=None,
7984
)
80-
return cls(state)
85+
history = data.get("historyEvents", data.get("history"))
86+
return cls(state, history)
8187

8288
def __bool__(self) -> bool:
8389
return self._state is not None
@@ -145,10 +151,10 @@ def custom_status(self) -> Any:
145151
def history(self) -> Optional[list[Any]]:
146152
"""Get the execution history.
147153
148-
History is not available through this compatibility path and is always
149-
``None``; use ``get_orchestration_history`` on the client instead.
154+
The history is populated only when requested by passing
155+
``show_history=True`` to the client's compatibility ``get_status`` API.
150156
"""
151-
return None
157+
return self._history
152158

153159
def to_json(self) -> dict[str, Any]:
154160
"""Convert this status into a v1-compatible JSON dictionary.
@@ -179,6 +185,8 @@ def to_json(self) -> dict[str, Any]:
179185
self._state.serialized_custom_status if self._state is not None else None)
180186
if custom_status is not None:
181187
result["customStatus"] = custom_status
188+
if self.history is not None:
189+
result["historyEvents"] = self.history
182190
return result
183191

184192
@staticmethod
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
import json
5+
from dataclasses import fields, is_dataclass
6+
from datetime import datetime, timezone
7+
from typing import Any, cast
8+
9+
from durabletask import history, task
10+
from durabletask.client import OrchestrationStatus
11+
12+
from .orchestration_runtime_status import from_durabletask_status
13+
14+
15+
def project_history(
16+
events: list[history.HistoryEvent],
17+
*,
18+
show_input: bool,
19+
show_history_output: bool) -> list[dict[str, Any]]:
20+
"""Project Durable Task history events into the v1 status-query shape."""
21+
projected: list[dict[str, Any]] = []
22+
scheduled_tasks: dict[int, tuple[int, history.TaskScheduledEvent]] = {}
23+
scheduled_sub_orchestrations: dict[
24+
int, tuple[int, history.SubOrchestrationInstanceCreatedEvent]] = {}
25+
removed_indexes: set[int] = set()
26+
27+
for event in events:
28+
if isinstance(
29+
event,
30+
(history.OrchestratorStartedEvent,
31+
history.OrchestratorCompletedEvent)):
32+
continue
33+
34+
item = _project_event(
35+
event,
36+
show_input=show_input,
37+
show_history_output=show_history_output)
38+
projected_index = len(projected)
39+
40+
if isinstance(event, history.TaskScheduledEvent):
41+
scheduled_tasks[event.event_id] = (projected_index, event)
42+
elif isinstance(
43+
event,
44+
(history.TaskCompletedEvent, history.TaskFailedEvent)):
45+
_add_scheduled_event_data(
46+
item,
47+
event.task_scheduled_id,
48+
scheduled_tasks,
49+
removed_indexes,
50+
show_input)
51+
elif isinstance(event, history.SubOrchestrationInstanceCreatedEvent):
52+
scheduled_sub_orchestrations[event.event_id] = (
53+
projected_index, event)
54+
elif isinstance(
55+
event,
56+
(history.SubOrchestrationInstanceCompletedEvent,
57+
history.SubOrchestrationInstanceFailedEvent)):
58+
_add_scheduled_event_data(
59+
item,
60+
event.task_scheduled_id,
61+
scheduled_sub_orchestrations,
62+
removed_indexes,
63+
show_input)
64+
65+
projected.append(item)
66+
67+
return [
68+
event for index, event in enumerate(projected)
69+
if index not in removed_indexes
70+
]
71+
72+
73+
def _project_event(
74+
event: history.HistoryEvent,
75+
*,
76+
show_input: bool,
77+
show_history_output: bool) -> dict[str, Any]:
78+
item = {
79+
"EventType": type(event).__name__.removesuffix("Event"),
80+
"Timestamp": _format_datetime(event.timestamp),
81+
}
82+
for field in fields(event):
83+
if field.name in {"event_id", "timestamp"}:
84+
continue
85+
if field.name == "input" and not show_input:
86+
continue
87+
if field.name in {"result", "output"} and not show_history_output:
88+
continue
89+
90+
value = getattr(event, field.name)
91+
if value is None:
92+
continue
93+
if field.name in {"result", "output"}:
94+
value = _raw_payload(value)
95+
item[_field_name(field.name)] = _serialize(value)
96+
97+
if isinstance(
98+
event,
99+
(history.TaskScheduledEvent,
100+
history.SubOrchestrationInstanceCreatedEvent)):
101+
item.pop("Version", None)
102+
elif isinstance(event, history.ExecutionStartedEvent):
103+
item["FunctionName"] = item.pop("Name")
104+
for field_name in (
105+
"OrchestrationInstance",
106+
"ParentInstance",
107+
"Version",
108+
"Tags"):
109+
item.pop(field_name, None)
110+
elif isinstance(
111+
event,
112+
(history.TaskCompletedEvent,
113+
history.TaskFailedEvent,
114+
history.SubOrchestrationInstanceCompletedEvent,
115+
history.SubOrchestrationInstanceFailedEvent)):
116+
item.pop("TaskScheduledId", None)
117+
elif isinstance(event, history.ExecutionCompletedEvent):
118+
try:
119+
status = OrchestrationStatus(event.orchestration_status)
120+
except ValueError:
121+
pass
122+
else:
123+
item["OrchestrationStatus"] = from_durabletask_status(status).name
124+
elif isinstance(event, history.TimerFiredEvent):
125+
item.pop("TimerId", None)
126+
127+
return item
128+
129+
130+
def _add_scheduled_event_data(
131+
item: dict[str, Any],
132+
scheduled_id: int,
133+
scheduled_events: dict[int, tuple[int, Any]],
134+
removed_indexes: set[int],
135+
show_input: bool) -> None:
136+
scheduled = scheduled_events.get(scheduled_id)
137+
if scheduled is None:
138+
return
139+
140+
scheduled_index, scheduled_event = scheduled
141+
item["ScheduledTime"] = _format_datetime(scheduled_event.timestamp)
142+
item["FunctionName"] = scheduled_event.name
143+
if show_input and scheduled_event.input is not None:
144+
item["Input"] = scheduled_event.input
145+
removed_indexes.add(scheduled_index)
146+
147+
148+
def _serialize(value: Any) -> Any:
149+
if isinstance(value, datetime):
150+
return _format_datetime(value)
151+
if isinstance(value, task.FailureDetails):
152+
result = {
153+
"ErrorMessage": value.message,
154+
"ErrorType": value.error_type,
155+
}
156+
if value.stack_trace is not None:
157+
result["StackTrace"] = value.stack_trace
158+
return result
159+
if is_dataclass(value) and not isinstance(value, type):
160+
return {
161+
_field_name(field.name): _serialize(getattr(value, field.name))
162+
for field in fields(value)
163+
if getattr(value, field.name) is not None
164+
}
165+
if isinstance(value, list):
166+
return [_serialize(item) for item in cast(list[Any], value)]
167+
if isinstance(value, dict):
168+
mapping = cast(dict[Any, Any], value)
169+
return {key: _serialize(item) for key, item in mapping.items()}
170+
return value
171+
172+
173+
def _field_name(name: str) -> str:
174+
if name == "scheduled_start_timestamp":
175+
return "ScheduledStartTime"
176+
if name in {"orchestration_span_id", "span_id"}:
177+
return "".join(part.title() for part in name.split("_"))[:-2] + "ID"
178+
return "".join(part.title() for part in name.split("_"))
179+
180+
181+
def _raw_payload(serialized: str) -> Any:
182+
try:
183+
return json.loads(serialized)
184+
except (TypeError, ValueError):
185+
return serialized
186+
187+
188+
def _format_datetime(value: datetime) -> str:
189+
if value.tzinfo is not None:
190+
value = value.astimezone(timezone.utc).replace(tzinfo=None)
191+
return value.strftime("%Y-%m-%dT%H:%M:%S.%f") + "Z"

0 commit comments

Comments
 (0)