Skip to content

Commit f0fdf05

Browse files
committed
Add afd rewind
1 parent 914a083 commit f0fdf05

9 files changed

Lines changed: 69 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ ADDED
1818

1919
FIXED
2020

21+
- Fixed orchestrations failing with `OrchestrationStateError: Don't know how to
22+
handle event of type 'genericEvent'` after being rewound over the Azure
23+
Functions Durable extension. The `genericEvent` history event (an
24+
informational marker with no execution semantics) is now ignored during
25+
replay, matching the .NET worker, so `rewind_orchestration` completes the
26+
replay instead of re-failing.
2127
- Fixed durabletask scheduled tasks (`durabletask.scheduled`) failing under
2228
data converters that reconstruct nested custom-object envelopes bottom-up
2329
(such as the Azure Functions Durable `df` codec). `ScheduleState.from_json`

azure-functions-durable/CHANGELOG.md

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

1010
### Added
1111

12+
- `DurableFunctionsClient.rewind_orchestration(...)` (inherited from durabletask)
13+
rewinds a failed orchestration to its last known good state. The deprecated v1
14+
`rewind(...)` method now delegates to it instead of raising
15+
`NotImplementedError`.
16+
1217
- `DFApp.configure_scheduled_tasks()` opts an app in to durabletask scheduled
1318
tasks by registering the schedule entity and operation orchestrator. Once
1419
enabled, schedules are managed from a client via

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

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -393,18 +393,23 @@ async def wait_for_completion_or_create_check_status_response(
393393
return self._create_http_response(500, state.serialized_output)
394394
return self.create_check_status_response(request, instance_id)
395395

396-
@deprecated(
397-
"rewind is not yet supported in durabletask; this shim raises "
398-
"NotImplementedError.")
396+
@deprecated("rewind is deprecated; use rewind_orchestration instead.")
399397
async def rewind(
400398
self,
401399
instance_id: str,
402400
reason: str,
403401
task_hub_name: Optional[str] = None,
404402
connection_name: Optional[str] = None) -> None:
405-
"""Not implemented: durabletask has no rewind equivalent yet."""
406-
raise NotImplementedError(
407-
"rewind is not yet supported by durabletask.")
403+
"""Deprecated alias for :meth:`rewind_orchestration`.
404+
405+
Rewinds a failed orchestration instance to its last known good state,
406+
removing failed task and sub-orchestration results from the history and
407+
replaying from the last successful checkpoint.
408+
409+
The ``task_hub_name`` and ``connection_name`` arguments have no
410+
equivalent in durabletask and are ignored.
411+
"""
412+
await self.rewind_orchestration(instance_id, reason=reason)
408413

409414
@staticmethod
410415
def _create_http_response(status_code: int, body: Union[str, Any]) -> func.HttpResponse:

durabletask/worker.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2917,6 +2917,12 @@ def _cancel_timer() -> None:
29172917
elif event.HasField("executionRewound"):
29182918
# Informational event added when an orchestration is rewound. No action needed.
29192919
pass
2920+
elif event.HasField("genericEvent"):
2921+
# Informational history event with no execution semantics (for
2922+
# example, the marker the Durable Functions extension appends
2923+
# when rewinding an orchestration). Ignored during replay,
2924+
# matching the .NET worker.
2925+
pass
29202926
elif event.HasField("eventSent"):
29212927
# Check if this eventSent corresponds to an entity operation call after being translated to the old
29222928
# entity protocol by the Durable WebJobs extension. If so, treat this message similarly to

tests/azure-functions-durable/e2e/apps/v1_style/activities.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,18 @@ def flaky(payload: dict) -> dict:
4848
if attempts < threshold:
4949
raise ValueError(f"flaky failure {attempts}/{threshold}")
5050
return {"attempts": attempts}
51+
52+
53+
@bp.activity_trigger(input_name="token")
54+
def fail_once(token: str) -> str:
55+
"""Fail on the first invocation for ``token``, then succeed.
56+
57+
Used to exercise rewind: the first attempt fails the orchestration, and a
58+
rewind replays the failed activity -- which now succeeds because the
59+
in-process attempt counter has advanced.
60+
"""
61+
_ATTEMPTS[token] = _ATTEMPTS.get(token, 0) + 1
62+
attempts = _ATTEMPTS[token]
63+
if attempts == 1:
64+
raise ValueError("failing on first attempt (rewind to retry)")
65+
return f"succeeded on attempt {attempts}"

tests/azure-functions-durable/e2e/apps/v1_style/client_routes.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -183,10 +183,7 @@ async def wait_or_check(
183183
@bp.durable_client_input(client_name="client")
184184
async def rewind_orchestration(
185185
req: func.HttpRequest, client: df.DurableFunctionsClient) -> func.HttpResponse:
186-
try:
187-
await client.rewind(req.route_params["id"], reason="e2e-rewind")
188-
except NotImplementedError as exc:
189-
return func.HttpResponse(str(exc), status_code=501)
186+
await client.rewind(req.route_params["id"], reason="e2e-rewind")
190187
return func.HttpResponse(status_code=202)
191188

192189

tests/azure-functions-durable/e2e/apps/v1_style/orchestrators.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,14 @@ def sub_orch_fails(context: df.DurableOrchestrationContext):
206206
return result
207207

208208

209+
@bp.orchestration_trigger(context_name="context")
210+
def rewind_target(context: df.DurableOrchestrationContext):
211+
# Calls an activity that fails on its first attempt. The orchestration
212+
# fails; a client rewind replays the failed activity, which then succeeds.
213+
result = yield context.call_activity("fail_once", context.instance_id)
214+
return result
215+
216+
209217
@bp.orchestration_trigger(context_name="context")
210218
def access_histories(context: df.DurableOrchestrationContext):
211219
# histories is intentionally unsupported and raises NotImplementedError,

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

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,16 @@ def test_wait_for_completion_or_check_status(v1_app):
104104
assert result.json() == ["Hello Tokyo!", "Hello Seattle!", "Hello London!"]
105105

106106

107-
def test_rewind_not_implemented(v1_app):
108-
instance_id = v1_app.start_orchestration("activity_chain")
109-
v1_app.wait_for_completion(instance_id)
107+
def test_rewind(v1_app):
108+
# The orchestration's activity fails on its first attempt, so it lands in a
109+
# Failed state; rewinding replays the failed activity, which now succeeds.
110+
instance_id = v1_app.start_orchestration("rewind_target")
111+
failed = v1_app.wait_for_completion(instance_id)
112+
assert failed["runtimeStatus"] == "Failed"
110113

111114
result = http_request("POST", f"{v1_app.base_url}/api/rewind/{instance_id}")
112-
# rewind is a deprecated stub that raises NotImplementedError.
113-
assert result.status == 501
115+
assert result.status == 202
116+
117+
status = v1_app.wait_for_status(instance_id, "Completed")
118+
assert status["runtimeStatus"] == "Completed"
119+
assert status["output"] == "succeeded on attempt 2"

tests/azure-functions-durable/test_client_compat.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -332,15 +332,17 @@ async def test_wait_for_completion_returns_check_status_on_timeout():
332332

333333

334334
# ---------------------------------------------------------------------------
335-
# rewind (not implemented)
335+
# rewind
336336
# ---------------------------------------------------------------------------
337337

338-
async def test_rewind_raises_not_implemented():
338+
async def test_rewind_delegates_to_rewind_orchestration():
339339
client = _make_client()
340340
try:
341-
with pytest.warns(DeprecationWarning):
342-
with pytest.raises(NotImplementedError):
341+
with patch.object(client, "rewind_orchestration",
342+
new=AsyncMock()) as mock:
343+
with pytest.warns(DeprecationWarning):
343344
await client.rewind("abc", "reason")
345+
mock.assert_awaited_once_with("abc", reason="reason")
344346
finally:
345347
await client.close()
346348

0 commit comments

Comments
 (0)