Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/copilot-instructions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -224,3 +224,17 @@ works correctly:
- If a new public API is added to the core SDK (e.g. a method on
`OrchestrationContext`), confirm it is accessible through the
azuremanaged package and add a test or example if appropriate.

## Release Coordination

- Package versions and inter-package minimum dependencies record the latest
released compatibility contract. They are not an instruction to bump versions
in a feature PR.
- When a core `durabletask` API is consumed by `durabletask.azuremanaged` or
`azure-functions-durable`, do not update package versions or dependency
minimums in the feature PR. Document the affected package changelogs and
release coordination instead.
- Create a dedicated release PR after the core package has been released. The
pipeline will publish `durabletask` first, so bump all dependency minimums,
package versions, and release notes together so resolvers cannot select a
provider release with an incompatible older core package.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

ADDED

- Added optional `reason` parameters to `TaskHubGrpcClient` and

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These new parameters are also inherited by DurableTaskSchedulerClient and AsyncDurableTaskSchedulerClient, but durabletask-azuremanaged/CHANGELOG.md still has an empty Unreleased section. Repository policy requires each affected package changelog, and the prior inherited rewind API was documented there. Please add an ADDED entry for suspend/resume reasons.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the inherited suspend/resume reason API entry under durabletask-azuremanaged/CHANGELOG.md Unreleased. Fixed in 63db777.

`AsyncTaskHubGrpcClient` suspend and resume operations. The reason is now sent
to the backend with the lifecycle request.
- Added the optional `new_version` argument to
`OrchestrationContext.continue_as_new()` so continued orchestrations can
switch to a new version.
Expand Down
11 changes: 11 additions & 0 deletions azure-functions-durable/CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

> [!NOTE]
> Release this change only in coordination with a new `durabletask` release
> that contains the suspend/resume reason APIs. Publish `durabletask` first,
> then update this package's minimum dependency in its dedicated release PR.

FIXED

- Fixed deprecated `DurableFunctionsClient.suspend()` and `resume()` methods
discarding their `reason` arguments. Reasons are now forwarded to the Durable
Task backend.

## v2.0.0b2

ADDED
Expand Down
16 changes: 4 additions & 12 deletions azure-functions-durable/azure/durable_functions/client.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -431,21 +431,13 @@ async def purge_instance_history(self, instance_id: str) -> PurgeHistoryResult:

@deprecated("suspend is deprecated; use suspend_orchestration instead.")
async def suspend(self, instance_id: str, reason: Optional[str] = None) -> None:
"""Deprecated alias for :meth:`suspend_orchestration`.

The v1 ``reason`` argument has no equivalent in durabletask and is
ignored.
"""
await self.suspend_orchestration(instance_id)
"""Deprecated alias for :meth:`suspend_orchestration`."""
await self.suspend_orchestration(instance_id, reason=reason)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This now always passes reason=, but azure-functions-durable/pyproject.toml still permits durabletask>=1.9.0, whose suspend/resume methods do not accept this keyword. A valid dependency resolution can therefore make every deprecated suspend()/resume() call fail with TypeError, even when no reason is supplied. Please raise the minimum core version to the first release containing these signatures and coordinate the package release order.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented the required coordinated release in the Azure Functions changelog and repository agent guidance. Per release policy, package versions and dependency minimums remain unchanged in this feature PR; a dedicated release PR must publish the core package first, then update provider minimums and versions.


@deprecated("resume is deprecated; use resume_orchestration instead.")
async def resume(self, instance_id: str, reason: Optional[str] = None) -> None:
"""Deprecated alias for :meth:`resume_orchestration`.

The v1 ``reason`` argument has no equivalent in durabletask and is
ignored.
"""
await self.resume_orchestration(instance_id)
"""Deprecated alias for :meth:`resume_orchestration`."""
await self.resume_orchestration(instance_id, reason=reason)

@deprecated("restart is deprecated; use restart_orchestration instead.")
async def restart(
Expand Down
5 changes: 5 additions & 0 deletions durabletask-azuremanaged/CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,11 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

ADDED

- Added optional `reason` parameters to suspend and resume operations inherited
from `TaskHubGrpcClient` and `AsyncTaskHubGrpcClient`.

## v1.9.0

CHANGED
Expand Down
32 changes: 24 additions & 8 deletions durabletask/client.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -786,13 +786,21 @@ def terminate_orchestration(self, instance_id: str, *,
)
self._stub.TerminateInstance(req)

def suspend_orchestration(self, instance_id: str) -> None:
req = pb.SuspendRequest(instanceId=instance_id)
def suspend_orchestration(self, instance_id: str, *,
reason: str | None = None) -> None:
req = pb.SuspendRequest(
instanceId=instance_id,
reason=helpers.get_string_value(reason),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please carry this reason through the public in-memory testing backend too. InMemoryOrchestrationBackend.SuspendInstance/ResumeInstance still call new_suspend_event()/new_resume_event() without request.reason, and those helpers create history events with empty input; tests using that backend therefore lose the reason while a real sidecar preserves it. Pass the optional reason through the helpers and add an in-memory history regression test.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passed the optional request reason into the in-memory suspend/resume history events and added an in-memory E2E regression that verifies both persisted history inputs. Fixed in 63db777.

)
self._logger.info(f"Suspending instance '{instance_id}'.")
self._stub.SuspendInstance(req)

def resume_orchestration(self, instance_id: str) -> None:
req = pb.ResumeRequest(instanceId=instance_id)
def resume_orchestration(self, instance_id: str, *,
reason: str | None = None) -> None:
req = pb.ResumeRequest(
instanceId=instance_id,
reason=helpers.get_string_value(reason),
)
self._logger.info(f"Resuming instance '{instance_id}'.")
self._stub.ResumeInstance(req)

Expand DownExpand Up@@ -1320,13 +1328,21 @@ async def terminate_orchestration(self, instance_id: str, *,
)
await self._get_stub().TerminateInstance(req)

async def suspend_orchestration(self, instance_id: str) -> None:
req = pb.SuspendRequest(instanceId=instance_id)
async def suspend_orchestration(self, instance_id: str, *,
reason: str | None = None) -> None:
req = pb.SuspendRequest(
instanceId=instance_id,
reason=helpers.get_string_value(reason),
)
self._logger.info(f"Suspending instance '{instance_id}'.")
await self._get_stub().SuspendInstance(req)

async def resume_orchestration(self, instance_id: str) -> None:
req = pb.ResumeRequest(instanceId=instance_id)
async def resume_orchestration(self, instance_id: str, *,
reason: str | None = None) -> None:
req = pb.ResumeRequest(
instanceId=instance_id,
reason=helpers.get_string_value(reason),
)
self._logger.info(f"Resuming instance '{instance_id}'.")
await self._get_stub().ResumeInstance(req)

Expand Down
12 changes: 8 additions & 4 deletions durabletask/internal/helpers.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -234,19 +234,23 @@ def new_event_raised_event(name: str, encoded_input: str | None = None) -> pb.Hi
)


def new_suspend_event() -> pb.HistoryEvent:
def new_suspend_event(*, encoded_input: str | None = None) -> pb.HistoryEvent:
return pb.HistoryEvent(
eventId=-1,
timestamp=timestamp_pb2.Timestamp(),
executionSuspended=pb.ExecutionSuspendedEvent()
executionSuspended=pb.ExecutionSuspendedEvent(
input=get_string_value(encoded_input)
)
)


def new_resume_event() -> pb.HistoryEvent:
def new_resume_event(*, encoded_input: str | None = None) -> pb.HistoryEvent:
return pb.HistoryEvent(
eventId=-1,
timestamp=timestamp_pb2.Timestamp(),
executionResumed=pb.ExecutionResumedEvent()
executionResumed=pb.ExecutionResumedEvent(
input=get_string_value(encoded_input)
)
)


Expand Down
8 changes: 6 additions & 2 deletions durabletask/testing/in_memory_backend.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -386,7 +386,9 @@ def SuspendInstance(self, request: pb.SuspendRequest, context: grpc.ServicerCont
if instance.status == pb.ORCHESTRATION_STATUS_SUSPENDED:
return pb.SuspendResponse()

event = helpers.new_suspend_event()
event = helpers.new_suspend_event(
encoded_input=request.reason.value if request.HasField("reason") else None
)
instance.pending_events.append(event)
instance.last_updated_at = datetime.now(timezone.utc)
self._enqueue_orchestration(instance.instance_id)
Expand All@@ -403,7 +405,9 @@ def ResumeInstance(self, request: pb.ResumeRequest, context: grpc.ServicerContex
f"Orchestration instance '{request.instanceId}' not found")
return pb.ResumeResponse()

event = helpers.new_resume_event()
event = helpers.new_resume_event(
encoded_input=request.reason.value if request.HasField("reason") else None
)
instance.pending_events.append(event)
instance.last_updated_at = datetime.now(timezone.utc)
self._enqueue_orchestration(instance.instance_id)
Expand Down
4 changes: 2 additions & 2 deletions tests/azure-functions-durable/test_client_compat.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -472,13 +472,13 @@ async def test_suspend_resume_delegate():
new=AsyncMock()) as suspend_mock:
with pytest.warns(DeprecationWarning):
await client.suspend("abc", "reason")
suspend_mock.assert_awaited_once_with("abc")
suspend_mock.assert_awaited_once_with("abc", reason="reason")

with patch.object(client, "resume_orchestration",
new=AsyncMock()) as resume_mock:
with pytest.warns(DeprecationWarning):
await client.resume("abc", "reason")
resume_mock.assert_awaited_once_with("abc")
resume_mock.assert_awaited_once_with("abc", reason="reason")
finally:
await client.close()

Expand Down
37 changes: 37 additions & 0 deletions tests/durabletask/test_client.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1258,6 +1258,43 @@ def test_worker_stores_resiliency_options():
assert worker._resiliency_options is resiliency


def test_suspend_resume_orchestration_includes_optional_reason():
stub = MagicMock()

with patch('durabletask.client.shared.get_grpc_channel', return_value=MagicMock()), patch(
'durabletask.client.stubs.TaskHubSidecarServiceStub', return_value=stub):
client = TaskHubGrpcClient()
client.suspend_orchestration('suspended', reason='maintenance')
client.resume_orchestration('resumed')

suspend_request = stub.SuspendInstance.call_args.args[0]
assert suspend_request.instanceId == 'suspended'
assert suspend_request.reason.value == 'maintenance'
resume_request = stub.ResumeInstance.call_args.args[0]
assert resume_request.instanceId == 'resumed'
assert not resume_request.HasField('reason')


@pytest.mark.asyncio
async def test_async_suspend_resume_orchestration_includes_optional_reason():
stub = MagicMock()
stub.SuspendInstance = AsyncMock()
stub.ResumeInstance = AsyncMock()

with patch('durabletask.client.shared.get_async_grpc_channel', return_value=MagicMock()), patch(
'durabletask.client.stubs.TaskHubSidecarServiceStub', return_value=stub):
client = AsyncTaskHubGrpcClient()
await client.suspend_orchestration('suspended')
await client.resume_orchestration('resumed', reason='maintenance complete')

suspend_request = stub.SuspendInstance.call_args.args[0]
assert suspend_request.instanceId == 'suspended'
assert not suspend_request.HasField('reason')
resume_request = stub.ResumeInstance.call_args.args[0]
assert resume_request.instanceId == 'resumed'
assert resume_request.reason.value == 'maintenance complete'


def test_get_orchestration_history_aggregates_chunks_and_deexternalizes_payloads():
store = FakePayloadStore()
token = store.upload(b'history payload')
Expand Down
13 changes: 10 additions & 3 deletions tests/durabletask/test_orchestration_e2e.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -275,7 +275,7 @@ def orchestrator(ctx: task.OrchestrationContext, _):
assert state.serialized_output == json.dumps("timed out")


def test_suspend_and_resume():
def test_suspend_and_resume_preserves_reasons_in_history():
def orchestrator(ctx: task.OrchestrationContext, _):
result = yield ctx.wait_for_external_event("my_event")
return result
Expand All@@ -290,7 +290,7 @@ def orchestrator(ctx: task.OrchestrationContext, _):
assert state is not None

# Suspend the orchestration and wait for it to go into the SUSPENDED state
task_hub_client.suspend_orchestration(id)
task_hub_client.suspend_orchestration(id, reason="maintenance")
deadline = time.time() + 10
while state.runtime_status == client.OrchestrationStatus.RUNNING:
assert time.time() < deadline, "Timed out waiting for SUSPENDED status"
Expand All@@ -308,11 +308,18 @@ def orchestrator(ctx: task.OrchestrationContext, _):
pass

# Resume the orchestration and wait for it to complete
task_hub_client.resume_orchestration(id)
task_hub_client.resume_orchestration(id, reason="maintenance complete")
state = task_hub_client.wait_for_orchestration_completion(id, timeout=30)
events = task_hub_client.get_orchestration_history(id)
assert state is not None
assert state.runtime_status == client.OrchestrationStatus.COMPLETED
assert state.serialized_output == json.dumps(42)
suspended_event = next(
event for event in events if isinstance(event, history.ExecutionSuspendedEvent))
resumed_event = next(
event for event in events if isinstance(event, history.ExecutionResumedEvent))
assert suspended_event.input == "maintenance"
assert resumed_event.input == "maintenance complete"


def test_terminate():
Expand Down