Skip to content

Commit e7b5f15

Browse files
authored
Add rewind support (#121)
* Initial implementation * Fix in-memory-backend history ordering, rewind check * Match other rewind implementations better * Lint + pylance * Lint * PR Feedback * Add DTS tests * Skip DTS E2E rewind tests until available * Add warning * Add more tests, fix in-memory backend bug * Restore PROTO_SOURCE_COMMIT_HASH * Remove duplicate test * Add RewindInstance to _SyncTaskHubSidecarServiceStub protocol for pyright * Address PR review feedback; enable DTS rewind e2e tests - Add async rewind_orchestration parity on AsyncTaskHubGrpcClient - in-memory backend: stamp ExecutionRewound with current time; avoid duplicate executionRewound on post-rewind replay - worker: fix rewind comments, guard parentInstance, drop dead sub-orchestration branch - tests: free-port fixture, clarify docstrings, un-skip DTS rewind e2e (emulator now supports RewindInstance) - azuremanaged CHANGELOG entry * Clarify rewind jump-start comment (PR review nit)
1 parent 7e95535 commit e7b5f15

10 files changed

Lines changed: 1803 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88
## Unreleased
99

10+
ADDED
11+
12+
- Added `TaskHubGrpcClient.rewind_orchestration()` to rewind a failed orchestration instance to its last known good state. Failed activity and sub-orchestration results are removed from the history and the orchestration replays from the last successful checkpoint, retrying only the failed work. The in-memory testing backend supports rewind as well.
13+
1014
## v1.7.2
1115

1216
FIXED

durabletask-azuremanaged/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88
## Unreleased
99

10+
- Added `rewind_orchestration()` to `DurableTaskSchedulerClient` and `AsyncDurableTaskSchedulerClient` (inherited from the base clients) to rewind a failed orchestration instance to its last known good state.
11+
1012
## v1.7.2
1113

1214
- Updates base dependency to durabletask v1.7.2.

durabletask/client.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,9 @@ def SuspendInstance(self, request: pb.SuspendRequest) -> pb.SuspendResponse:
320320
def ResumeInstance(self, request: pb.ResumeRequest) -> pb.ResumeResponse:
321321
...
322322

323+
def RewindInstance(self, request: pb.RewindInstanceRequest) -> pb.RewindInstanceResponse:
324+
...
325+
323326
def RestartInstance(self, request: pb.RestartInstanceRequest) -> pb.RestartInstanceResponse:
324327
...
325328

@@ -381,6 +384,9 @@ async def SuspendInstance(self, request: pb.SuspendRequest) -> pb.SuspendRespons
381384
async def ResumeInstance(self, request: pb.ResumeRequest) -> pb.ResumeResponse:
382385
...
383386

387+
async def RewindInstance(self, request: pb.RewindInstanceRequest) -> pb.RewindInstanceResponse:
388+
...
389+
384390
async def RestartInstance(self, request: pb.RestartInstanceRequest) -> pb.RestartInstanceResponse:
385391
...
386392

@@ -782,6 +788,26 @@ def resume_orchestration(self, instance_id: str) -> None:
782788
self._logger.info(f"Resuming instance '{instance_id}'.")
783789
self._stub.ResumeInstance(req)
784790

791+
def rewind_orchestration(self, instance_id: str, *,
792+
reason: str | None = None) -> None:
793+
"""Rewinds a failed orchestration instance to its last known good state.
794+
795+
Rewind removes failed task and sub-orchestration results from the
796+
orchestration history and replays the orchestration from the last
797+
successful checkpoint. Activities that previously succeeded are
798+
not re-executed; only failed work is retried.
799+
800+
Args:
801+
instance_id: The ID of the orchestration instance to rewind.
802+
reason: An optional reason string describing why the orchestration is being rewound.
803+
"""
804+
req = pb.RewindInstanceRequest(
805+
instanceId=instance_id,
806+
reason=helpers.get_string_value(reason))
807+
808+
self._logger.info(f"Rewinding instance '{instance_id}'.")
809+
self._stub.RewindInstance(req)
810+
785811
def restart_orchestration(self, instance_id: str, *,
786812
restart_with_new_instance_id: bool = False) -> str:
787813
"""Restarts an existing orchestration instance.
@@ -1267,6 +1293,26 @@ async def resume_orchestration(self, instance_id: str) -> None:
12671293
self._logger.info(f"Resuming instance '{instance_id}'.")
12681294
await self._stub.ResumeInstance(req)
12691295

1296+
async def rewind_orchestration(self, instance_id: str, *,
1297+
reason: str | None = None) -> None:
1298+
"""Rewinds a failed orchestration instance to its last known good state.
1299+
1300+
Rewind removes failed task and sub-orchestration results from the
1301+
orchestration history and replays the orchestration from the last
1302+
successful checkpoint. Activities that previously succeeded are
1303+
not re-executed; only failed work is retried.
1304+
1305+
Args:
1306+
instance_id: The ID of the orchestration instance to rewind.
1307+
reason: An optional reason string describing why the orchestration is being rewound.
1308+
"""
1309+
req = pb.RewindInstanceRequest(
1310+
instanceId=instance_id,
1311+
reason=helpers.get_string_value(reason))
1312+
1313+
self._logger.info(f"Rewinding instance '{instance_id}'.")
1314+
await self._stub.RewindInstance(req)
1315+
12701316
async def restart_orchestration(self, instance_id: str, *,
12711317
restart_with_new_instance_id: bool = False) -> str:
12721318
"""Restarts an existing orchestration instance.

durabletask/internal/helpers.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,21 @@ def new_terminated_event(*, encoded_output: str | None = None) -> pb.HistoryEven
236236
)
237237

238238

239+
def new_execution_completed_event(
240+
status: pb.OrchestrationStatus,
241+
encoded_result: str | None = None,
242+
failure_details: pb.TaskFailureDetails | None = None) -> pb.HistoryEvent:
243+
return pb.HistoryEvent(
244+
eventId=-1,
245+
timestamp=timestamp_pb2.Timestamp(),
246+
executionCompleted=pb.ExecutionCompletedEvent(
247+
orchestrationStatus=status,
248+
result=get_string_value(encoded_result),
249+
failureDetails=failure_details,
250+
)
251+
)
252+
253+
239254
def get_string_value(val: str | None) -> wrappers_pb2.StringValue | None:
240255
if val is None:
241256
return None

durabletask/testing/README.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,8 +255,7 @@ The in-memory backend is designed for testing and has some limitations compared
255255
1. **No persistence**: All state is lost when the backend is stopped
256256
2. **No distributed execution**: Runs in a single process
257257
3. **No history streaming**: StreamInstanceHistory is not implemented
258-
4. **No rewind**: RewindInstance is not implemented
259-
5. **No recursive termination**: Recursive termination is not supported
258+
4. **No recursive termination**: Recursive termination is not supported
260259

261260
### Best Practices
262261

durabletask/testing/in_memory_backend.py

Lines changed: 190 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -273,14 +273,16 @@ def StartInstance(self, request: pb.CreateInstanceRequest, context: grpc.Service
273273
)
274274
self._next_completion_token += 1
275275

276-
# Add initial events to start the orchestration
277-
orchestrator_started = helpers.new_orchestrator_started_event(start_time)
276+
# Add initial events to start the orchestration.
277+
# orchestratorStarted bookends each replay batch and is
278+
# always the very first event, followed by executionStarted.
278279
execution_started = helpers.new_execution_started_event(
279280
request.name, instance_id,
280281
request.input.value if request.input else None,
281282
dict(request.tags) if request.tags else None,
282283
version=version,
283284
)
285+
orchestrator_started = helpers.new_orchestrator_started_event(start_time)
284286

285287
instance.pending_events.append(orchestrator_started)
286288
instance.pending_events.append(execution_started)
@@ -611,8 +613,12 @@ def GetWorkItems(self, request: pb.GetWorkItemsRequest, context: grpc.ServicerCo
611613
instance.pending_events.clear()
612614

613615
# Add OrchestratorStarted for re-dispatches so that
614-
# ctx.current_utc_datetime advances correctly
615-
if instance.history:
616+
# ctx.current_utc_datetime advances correctly, unless
617+
# the pending batch already begins with one (e.g. a
618+
# post-rewind replay supplies its own).
619+
if instance.history and not (
620+
instance.dispatched_events
621+
and instance.dispatched_events[0].HasField("orchestratorStarted")):
616622
now = datetime.now(timezone.utc)
617623
orch_started = helpers.new_orchestrator_started_event(now)
618624
instance.dispatched_events.insert(0, orch_started)
@@ -802,6 +808,22 @@ def CompleteOrchestratorTask(self, request: pb.OrchestratorResponse, context: gr
802808
instance.completion_token = self._next_completion_token
803809
self._next_completion_token += 1
804810

811+
# Bookend the replay with orchestratorCompleted.
812+
# Skip for continue-as-new (status is PENDING after reset).
813+
if instance.status != pb.ORCHESTRATION_STATUS_PENDING:
814+
instance.history.append(helpers.new_orchestrator_completed_event())
815+
816+
# executionCompleted is the very last event when the
817+
# orchestration reaches a terminal state.
818+
if self._is_terminal_status(instance.status):
819+
instance.history.append(
820+
helpers.new_execution_completed_event(
821+
instance.status,
822+
instance.output,
823+
instance.failure_details,
824+
)
825+
)
826+
805827
# Remove from in-flight before notifying or re-enqueuing
806828
self._orchestration_in_flight.discard(request.instanceId)
807829

@@ -1193,8 +1215,33 @@ def DeleteTaskHub(self, request: pb.DeleteTaskHubRequest, context: grpc.Servicer
11931215
return pb.DeleteTaskHubResponse()
11941216

11951217
def RewindInstance(self, request: pb.RewindInstanceRequest, context: grpc.ServicerContext) -> pb.RewindInstanceResponse:
1196-
"""Rewinds an orchestration instance (not implemented)."""
1197-
context.abort(grpc.StatusCode.UNIMPLEMENTED, "RewindInstance not implemented")
1218+
"""Rewinds a failed orchestration instance.
1219+
1220+
The backend validates the instance is in a failed state, appends
1221+
an ``ExecutionRewoundEvent`` to the pending events, resets the
1222+
instance status to RUNNING, and re-enqueues the orchestration
1223+
so the worker can replay it and produce a
1224+
``RewindOrchestrationAction`` with the corrected history.
1225+
"""
1226+
with self._lock:
1227+
instance = self._instances.get(request.instanceId)
1228+
if not instance:
1229+
context.abort(
1230+
grpc.StatusCode.NOT_FOUND,
1231+
f"Orchestration instance '{request.instanceId}' not found")
1232+
return pb.RewindInstanceResponse()
1233+
1234+
if instance.status != pb.ORCHESTRATION_STATUS_FAILED:
1235+
context.abort(
1236+
grpc.StatusCode.FAILED_PRECONDITION,
1237+
f"Orchestration instance '{request.instanceId}' is not in a failed state")
1238+
return pb.RewindInstanceResponse()
1239+
1240+
reason = request.reason.value if request.HasField("reason") else None
1241+
self._prepare_rewind(instance, reason)
1242+
1243+
self._logger.info(f"Rewound instance '{request.instanceId}'")
1244+
return pb.RewindInstanceResponse()
11981245

11991246
def AbandonTaskActivityWorkItem(self, request: pb.AbandonActivityTaskRequest, context: grpc.ServicerContext) -> pb.AbandonActivityTaskResponse:
12001247
"""Abandons an activity work item."""
@@ -1280,9 +1327,9 @@ def _create_instance_internal(self, instance_id: str, name: str,
12801327
)
12811328
self._next_completion_token += 1
12821329

1283-
orchestrator_started = helpers.new_orchestrator_started_event(now)
12841330
execution_started = helpers.new_execution_started_event(
12851331
name, instance_id, encoded_input, version=version)
1332+
orchestrator_started = helpers.new_orchestrator_started_event(now)
12861333
instance.pending_events.append(orchestrator_started)
12871334
instance.pending_events.append(execution_started)
12881335

@@ -1410,14 +1457,25 @@ def _process_action(self, instance: OrchestrationInstance, action: pb.Orchestrat
14101457
self._process_send_event_action(action.sendEvent)
14111458
elif action.HasField("sendEntityMessage"):
14121459
self._process_send_entity_message_action(instance, action)
1460+
elif action.HasField("rewindOrchestration"):
1461+
self._process_rewind_orchestration_action(instance, action.rewindOrchestration)
14131462

14141463
def _process_complete_orchestration_action(self, instance: OrchestrationInstance,
14151464
complete_action: pb.CompleteOrchestrationAction):
14161465
"""Processes a complete orchestration action."""
14171466
status = complete_action.orchestrationStatus
14181467
instance.status = status
14191468
instance.output = complete_action.result.value if complete_action.result else None
1420-
instance.failure_details = complete_action.failureDetails if complete_action.failureDetails else None
1469+
# NOTE: a protobuf singular message field is always truthy, so
1470+
# ``if complete_action.failureDetails`` would set failure_details
1471+
# even for successful completions. Use HasField for a correct
1472+
# presence check so failure_details is None unless the
1473+
# orchestration actually failed.
1474+
instance.failure_details = (
1475+
complete_action.failureDetails
1476+
if complete_action.HasField("failureDetails")
1477+
else None
1478+
)
14211479
instance.completed_at = datetime.now(timezone.utc) if self._is_terminal_status(status) else None
14221480

14231481
if status == pb.ORCHESTRATION_STATUS_CONTINUED_AS_NEW:
@@ -1446,11 +1504,11 @@ def _process_complete_orchestration_action(self, instance: OrchestrationInstance
14461504
# Build the new pending events in the correct order:
14471505
# OrchestratorStarted, ExecutionStarted, carryover, new arrivals
14481506
now = datetime.now(timezone.utc)
1449-
orchestrator_started = helpers.new_orchestrator_started_event(now)
14501507
execution_started = helpers.new_execution_started_event(
14511508
instance.name, instance.instance_id, new_input,
14521509
version=new_version,
14531510
)
1511+
orchestrator_started = helpers.new_orchestrator_started_event(now)
14541512
instance.pending_events.append(orchestrator_started)
14551513
instance.pending_events.append(execution_started)
14561514
instance.pending_events.extend(carryover_events)
@@ -1822,6 +1880,129 @@ def fire() -> None:
18221880
timer_thread = threading.Thread(target=fire, daemon=True)
18231881
timer_thread.start()
18241882

1883+
def _prepare_rewind(self, instance: OrchestrationInstance,
1884+
reason: str | None = None):
1885+
"""Prepares an orchestration instance for rewind.
1886+
1887+
Appends an ``ExecutionRewoundEvent`` to the pending events, resets
1888+
the instance status to RUNNING, and re-enqueues it so the worker
1889+
can replay it. The actual history rewriting is done by the SDK
1890+
worker when it processes the rewind event.
1891+
1892+
Args:
1893+
instance: The orchestration instance to rewind.
1894+
reason: Optional reason string for the rewind.
1895+
1896+
Note:
1897+
Must be called while holding ``self._lock``.
1898+
"""
1899+
# Reset instance state so it can be re-processed.
1900+
instance.status = pb.ORCHESTRATION_STATUS_RUNNING
1901+
instance.output = None
1902+
instance.failure_details = None
1903+
instance.last_updated_at = datetime.now(timezone.utc)
1904+
1905+
# Clear any stale dispatched events.
1906+
instance.dispatched_events.clear()
1907+
1908+
# Add the ExecutionRewound event as a new pending event. Stamp it
1909+
# with the current time (rather than the default epoch) so the
1910+
# audit event carries a meaningful timestamp, consistent with
1911+
# other backend-generated events.
1912+
rewind_timestamp = timestamp_pb2.Timestamp()
1913+
rewind_timestamp.FromDatetime(datetime.now(timezone.utc))
1914+
rewind_event = pb.HistoryEvent(
1915+
eventId=-1,
1916+
timestamp=rewind_timestamp,
1917+
executionRewound=pb.ExecutionRewoundEvent(
1918+
reason=wrappers_pb2.StringValue(value=reason) if reason else None,
1919+
),
1920+
)
1921+
instance.pending_events.append(rewind_event)
1922+
1923+
# Refresh the completion token and enqueue.
1924+
instance.completion_token = self._next_completion_token
1925+
self._next_completion_token += 1
1926+
self._orchestration_in_flight.discard(instance.instance_id)
1927+
self._enqueue_orchestration(instance.instance_id)
1928+
1929+
def _process_rewind_orchestration_action(
1930+
self, instance: OrchestrationInstance,
1931+
rewind_action: pb.RewindOrchestrationAction):
1932+
"""Processes a RewindOrchestrationAction returned by the SDK.
1933+
1934+
The action contains a ``newHistory`` field with the rewritten
1935+
history computed by the SDK (failed tasks and sub-orchestration
1936+
failures removed). The backend replaces the instance's history
1937+
with this new history, recursively rewinds any failed
1938+
sub-orchestrations, and re-enqueues the orchestration.
1939+
"""
1940+
new_history = list(rewind_action.newHistory)
1941+
1942+
# Replace history with the rewritten version.
1943+
instance.history = new_history
1944+
instance.status = pb.ORCHESTRATION_STATUS_RUNNING
1945+
instance.output = None
1946+
instance.failure_details = None
1947+
instance.last_updated_at = datetime.now(timezone.utc)
1948+
1949+
# Identify sub-orchestrations that were created but did not
1950+
# complete successfully — they need to be recursively rewound.
1951+
completed_sub_orch_task_ids: set[int] = set()
1952+
created_sub_orch_events: dict[int, pb.HistoryEvent] = {}
1953+
for event in new_history:
1954+
if event.HasField("subOrchestrationInstanceCreated"):
1955+
created_sub_orch_events[event.eventId] = event
1956+
elif event.HasField("subOrchestrationInstanceCompleted"):
1957+
completed_sub_orch_task_ids.add(
1958+
event.subOrchestrationInstanceCompleted.taskScheduledId)
1959+
1960+
# Extract the rewind reason from the last ExecutionRewound event.
1961+
reason: str | None = None
1962+
for event in reversed(new_history):
1963+
if event.HasField("executionRewound"):
1964+
if event.executionRewound.HasField("reason"):
1965+
reason = event.executionRewound.reason.value
1966+
break
1967+
1968+
# Recursively rewind failed sub-orchestrations. If the sub was
1969+
# purged (no longer in _instances), re-create it from the
1970+
# subOrchestrationInstanceCreated event so it runs fresh.
1971+
for task_id, event in created_sub_orch_events.items():
1972+
if task_id not in completed_sub_orch_task_ids:
1973+
sub_info = event.subOrchestrationInstanceCreated
1974+
sub_instance_id = sub_info.instanceId
1975+
sub_instance = self._instances.get(sub_instance_id)
1976+
if sub_instance is None:
1977+
# Sub-orchestration was purged — re-create it.
1978+
sub_name = sub_info.name
1979+
sub_input = sub_info.input.value if sub_info.HasField("input") else None
1980+
sub_version = sub_info.version.value if sub_info.HasField("version") else None
1981+
self._create_instance_internal(
1982+
sub_instance_id, sub_name, sub_input, version=sub_version)
1983+
elif sub_instance.status == pb.ORCHESTRATION_STATUS_FAILED:
1984+
self._prepare_rewind(sub_instance, reason)
1985+
self._watch_sub_orchestration(
1986+
instance.instance_id, sub_instance_id, task_id)
1987+
1988+
# Re-enqueue so the orchestration replays with the clean history.
1989+
# The executionRewound event is already present in the clean
1990+
# history (it was kept by the SDK's rewind rewrite), so it must
1991+
# NOT be re-sent as a pending event — doing so would duplicate it
1992+
# in the instance history once the dispatched events are
1993+
# committed. A lone orchestratorStarted is enough to make the
1994+
# instance dispatchable for the post-rewind replay; the worker
1995+
# replays normally because executionCompleted is no longer in the
1996+
# history.
1997+
instance.pending_events.clear()
1998+
instance.dispatched_events.clear()
1999+
instance.pending_events.append(
2000+
helpers.new_orchestrator_started_event(datetime.now(timezone.utc)))
2001+
instance.completion_token = self._next_completion_token
2002+
self._next_completion_token += 1
2003+
self._orchestration_in_flight.discard(instance.instance_id)
2004+
self._enqueue_orchestration(instance.instance_id)
2005+
18252006
def _enqueue_entity(self, entity_id: str):
18262007
"""Enqueues an entity for processing."""
18272008
if entity_id not in self._entity_queue_set:

0 commit comments

Comments
 (0)