Skip to content

Commit 25f21df

Browse files
committed
Add history export to afd
1 parent ff203b7 commit 25f21df

11 files changed

Lines changed: 416 additions & 1 deletion

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,9 @@ venv.bak/
114114
# Azure Functions E2E test host logs (written by the test harness)
115115
_func_host.log
116116

117+
# Azure Functions E2E history-export output (written by the sample app writer)
118+
_export_output/
119+
117120
# Spyder project settings
118121
.spyderproject
119122
.spyproject

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,25 @@ ADDED
1313
- Added `OrchestrationContext.parent_instance_id`, which returns the instance
1414
ID of the parent orchestration for a sub-orchestration, or `None` for a
1515
top-level orchestration.
16+
- Exported `bind_context` and `clear_context` from
17+
`durabletask.extensions.history_export` so hosts that register the export
18+
functions themselves (rather than via `ExportHistoryClient.register_worker`)
19+
can supply the activities' runtime dependencies.
1620

1721
CHANGED
1822

1923
- Changed the default large-payload externalization threshold (`LargePayloadStorageOptions.threshold_bytes`) from 900,000 bytes to 262,144 bytes (256 KiB), matching the .NET SDK default. Behavioral change (not source/binary breaking): payloads larger than 256 KiB are now externalized by default.
2024

2125
FIXED
2226

27+
- Fixed durabletask scheduled tasks (`durabletask.scheduled`) failing under
28+
data converters that reconstruct nested custom-object envelopes bottom-up
29+
(such as the Azure Functions Durable `df` codec). `ScheduleState.from_json`
30+
now tolerates an already-reconstructed nested `ScheduleConfiguration` (and
31+
accepts the active `DataConverter` for nested reconstruction), and
32+
`ScheduleOperationRequest` gained `to_json`/`from_json` hooks so it can be
33+
serialized by converters that require them. The default JSON converter's
34+
behavior is unchanged.
2335
- Fixed `OrchestrationContext.lock_entities` failing when used over the legacy
2436
entity protocol (used by the Azure Functions Durable extension). Acquiring an
2537
entity lock raised a `JSONDecodeError` because the worker tried to deserialize

azure-functions-durable/CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- `DFApp.configure_scheduled_tasks()` opts an app in to durabletask scheduled
13+
tasks by registering the schedule entity and operation orchestrator. Once
14+
enabled, schedules are managed from a client via
15+
`durabletask.scheduled.ScheduledTaskClient`. Scheduled tasks are not
16+
registered unless this method is called.
17+
- `DFApp.configure_history_export()` opts an app in to durabletask history
18+
export by registering the export-job entity, driving orchestrator, and
19+
activities. Once enabled, export jobs are driven from a client via
20+
`durabletask.extensions.history_export.ExportHistoryClient`; supply the
21+
activities' runtime dependencies with `history_export.bind_context(...)`.
22+
The instance-enumeration activity uses a Functions-specific implementation
23+
based on `QueryInstances` because the Durable Functions host extension does
24+
not implement the `ListInstanceIds` gRPC call the core activity relies on.
1225
- `DurableOrchestrationContext.call_http(...)` for making durable HTTP calls
1326
from orchestrators, restoring the v1 API. The request is executed by a
1427
built-in activity and, when the endpoint responds with `202 Accepted` and a

azure-functions-durable/azure/durable_functions/decorators/durable_app.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,69 @@ def configure_scheduled_tasks(self) -> None:
9090
self.orchestration_trigger(
9191
context_name="context")(execute_schedule_operation_orchestrator)
9292

93+
def configure_history_export(self) -> None:
94+
"""Opt in to durabletask history export by registering its built-ins.
95+
96+
Like scheduled tasks, history export is opt-in: its export-job entity,
97+
driving orchestrator, and two activities are only registered when this
98+
method is called. After calling it, drive export jobs from the client
99+
with :class:`durabletask.extensions.history_export.ExportHistoryClient`.
100+
101+
The runtime dependencies the activities need (a durabletask client and a
102+
:class:`~durabletask.extensions.history_export.writer.HistoryWriter`) are
103+
supplied separately via
104+
:func:`durabletask.extensions.history_export.bind_context`, since the
105+
client is only available at request time in the host-driven Functions
106+
model.
107+
108+
The durabletask export activities take a ``(context, input)`` signature;
109+
they ignore the context, so they are wrapped as single-input Functions
110+
activities to match the host's activity calling convention.
111+
112+
The enumeration activity uses a Functions-specific implementation
113+
(:mod:`azure.durable_functions.internal.history_export_compat`) that
114+
queries terminal instances via ``QueryInstances`` instead of the core
115+
``ListInstanceIds`` call, which the Durable Functions host extension
116+
does not implement.
117+
118+
NOTE: We need to consider whether this will perform well on Azure
119+
Functions' distributed architecture - later.
120+
"""
121+
from durabletask.extensions.history_export._constants import (
122+
ENTITY_NAME as EXPORT_ENTITY_NAME,
123+
)
124+
from durabletask.extensions.history_export.activities import (
125+
EXPORT_INSTANCE_HISTORY_ACTIVITY,
126+
LIST_TERMINAL_INSTANCES_ACTIVITY,
127+
export_instance_history,
128+
)
129+
from durabletask.extensions.history_export.entity import ExportJobEntity
130+
from durabletask.extensions.history_export.orchestrator import (
131+
export_job_orchestrator,
132+
)
133+
from ..internal.history_export_compat import (
134+
list_terminal_instances,
135+
)
136+
137+
self.entity_trigger(
138+
context_name="context", entity_name=EXPORT_ENTITY_NAME)(ExportJobEntity)
139+
self.orchestration_trigger(context_name="context")(export_job_orchestrator)
140+
141+
def _list_terminal_instances(input: dict) -> dict:
142+
return list_terminal_instances(None, input) # type: ignore[arg-type]
143+
144+
def _export_instance_history(input: dict) -> dict:
145+
return export_instance_history(None, input) # type: ignore[arg-type]
146+
147+
_list_terminal_instances.__name__ = LIST_TERMINAL_INSTANCES_ACTIVITY
148+
_export_instance_history.__name__ = EXPORT_INSTANCE_HISTORY_ACTIVITY
149+
self.activity_trigger(
150+
input_name="input",
151+
activity=LIST_TERMINAL_INSTANCES_ACTIVITY)(_list_terminal_instances)
152+
self.activity_trigger(
153+
input_name="input",
154+
activity=EXPORT_INSTANCE_HISTORY_ACTIVITY)(_export_instance_history)
155+
93156
def _configure_orchestrator_callable(
94157
self,
95158
wrap: Callable[[Callable[..., Any]], FunctionBuilder],
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
"""Compatibility override for the history-export enumeration activity.
5+
6+
The core durabletask ``list_terminal_instances`` activity enumerates terminal
7+
instances via the ``ListInstanceIds`` gRPC call. The Azure Functions Durable
8+
extension's gRPC endpoint does not implement that method (it returns
9+
``UNIMPLEMENTED``), so this module provides a drop-in replacement that
10+
enumerates via ``QueryInstances`` (:meth:`get_all_orchestration_states`)
11+
instead -- a method the extension does implement.
12+
13+
> [!NOTE]
14+
> This shim exists only because the Durable Functions host extension does not
15+
> yet implement ``ListInstanceIds``. Once it does, delete this module and have
16+
> :meth:`DFApp.configure_history_export` register the core
17+
> ``durabletask.extensions.history_export.activities.list_terminal_instances``
18+
> activity directly.
19+
"""
20+
21+
from __future__ import annotations
22+
23+
from collections.abc import Mapping
24+
from typing import Any, Optional
25+
26+
from durabletask import task
27+
from durabletask.client import OrchestrationQuery, OrchestrationStatus
28+
from durabletask.internal.helpers import ensure_aware
29+
30+
from durabletask.extensions.history_export._internal import dt_from_iso
31+
from durabletask.extensions.history_export.activities import _require_context
32+
33+
# The activity registers under the same name the export orchestrator calls, so
34+
# it transparently replaces the core activity.
35+
LIST_TERMINAL_INSTANCES_ACTIVITY = "list_terminal_instances"
36+
37+
38+
def list_terminal_instances(
39+
_: task.ActivityContext, input: Mapping[str, Any]) -> dict[str, Any]:
40+
"""Enumerate terminal instances via ``QueryInstances``.
41+
42+
Drop-in replacement for the core ``list_terminal_instances`` activity that
43+
avoids the unimplemented ``ListInstanceIds`` call. ``QueryInstances`` filters
44+
by *created* time whereas the export filters by *completed* time, so the
45+
completed-time window is applied client-side against each instance's last
46+
update time. Every match is returned in a single page
47+
(``continuation_token`` is always ``None``) because
48+
``get_all_orchestration_states`` paginates internally.
49+
"""
50+
ctx = _require_context()
51+
52+
# A continuation token means the first call already returned everything;
53+
# there is no second page under this enumeration strategy.
54+
if input.get("continuation_token"):
55+
return {"instance_ids": [], "continuation_token": None}
56+
57+
raw_statuses = input.get("runtime_status")
58+
runtime_status_names: Optional[list[str]] = (
59+
list(raw_statuses) if raw_statuses is not None else None
60+
)
61+
completed_time_from = dt_from_iso(input.get("completed_time_from"))
62+
completed_time_to = dt_from_iso(input.get("completed_time_to"))
63+
if completed_time_from is None:
64+
raise ValueError("list_terminal_instances requires 'completed_time_from'")
65+
66+
runtime_status: Optional[list[OrchestrationStatus]] = None
67+
if runtime_status_names is not None:
68+
runtime_status = [OrchestrationStatus[name] for name in runtime_status_names]
69+
70+
states = ctx.client.get_all_orchestration_states(
71+
OrchestrationQuery(runtime_status=runtime_status))
72+
73+
instance_ids: list[str] = []
74+
for state in states:
75+
completed_at = ensure_aware(state.last_updated_at)
76+
if completed_at is not None:
77+
if completed_at < completed_time_from:
78+
continue
79+
if completed_time_to is not None and completed_at > completed_time_to:
80+
continue
81+
instance_ids.append(state.instance_id)
82+
83+
return {"instance_ids": instance_ids, "continuation_token": None}

durabletask/extensions/history_export/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
)
2323
from durabletask.extensions.history_export.activities import (
2424
HistoryExportContext,
25+
bind_context,
26+
clear_context,
2527
)
2628
from durabletask.extensions.history_export.client import (
2729
ExportHistoryClient,
@@ -76,5 +78,7 @@
7678
"ExportMode",
7779
"HistoryExportContext",
7880
"HistoryWriter",
81+
"bind_context",
82+
"clear_context",
7983
"orchestrator_instance_id_for",
8084
]

tests/azure-functions-durable/e2e/_harness.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,36 @@ def delete_schedule(self, schedule_id: str) -> None:
419419
"POST", f"{self.base_url}/api/schedule/{schedule_id}/delete", timeout=90)
420420
assert result.status == 202, f"delete schedule failed: {result.status} {result.body}"
421421

422+
def start_export(self, container: str = "exports",
423+
job_id: Optional[str] = None,
424+
completed_from: Optional[str] = None) -> dict[str, Any]:
425+
"""Start a history-export job via the app's ``/api/export/start`` route.
426+
427+
``completed_from`` (an ISO-8601 timestamp) narrows the export window so
428+
it only covers instances completed at/after that time.
429+
"""
430+
data: dict[str, Any] = {"container": container}
431+
if job_id is not None:
432+
data["job_id"] = job_id
433+
if completed_from is not None:
434+
data["completed_from"] = completed_from
435+
result = http_request("POST", f"{self.base_url}/api/export/start", data=data, timeout=90)
436+
assert result.status == 200, f"start export failed: {result.status} {result.body}"
437+
return result.json()
438+
439+
def wait_for_export(self, job_id: str, timeout: float = 90) -> dict[str, Any]:
440+
"""Poll ``/api/export/status/{id}`` until the export job is terminal."""
441+
deadline = time.time() + timeout
442+
payload: dict[str, Any] = {}
443+
while time.time() < deadline:
444+
result = http_request("GET", f"{self.base_url}/api/export/status/{job_id}")
445+
assert result.status == 200, f"export status failed: {result.status} {result.body}"
446+
payload = result.json()
447+
if (payload.get("status") or "") in ("Completed", "Failed"):
448+
return payload
449+
time.sleep(0.5)
450+
raise TimeoutError(f"export job {job_id} did not finish within {timeout}s; last: {payload}")
451+
422452
def signal_entity(self, name: str, key: str, op: str, input: Any = None,
423453
delay_seconds: Optional[float] = None) -> None:
424454
"""Signal an entity via the app's ``/api/signal/{name}/{key}/{op}`` route.

tests/azure-functions-durable/e2e/apps/dtask_style/function_app.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import activities
2222
import client_routes
2323
import entities
24+
import history_export_routes
2425
import orchestrators
2526

2627
app = df.DFApp(http_auth_level=func.AuthLevel.ANONYMOUS)
@@ -29,7 +30,12 @@
2930
app.register_functions(entities.bp)
3031
app.register_functions(orchestrators.bp)
3132
app.register_functions(client_routes.bp)
33+
app.register_functions(history_export_routes.bp)
3234

3335
# Opt in to durabletask scheduled tasks: registers the schedule entity and
3436
# operation orchestrator so schedules can be managed via ScheduledTaskClient.
3537
app.configure_scheduled_tasks()
38+
39+
# Opt in to durabletask history export: registers the export-job entity, driving
40+
# orchestrator, and activities so export jobs can be driven via ExportHistoryClient.
41+
app.configure_history_export()

0 commit comments

Comments
 (0)