Skip to content

Commit c809917

Browse files
andystaplesCopilot
andcommitted
Expose a typed orchestration ID reuse policy
Add public status-based deduplication policies while retaining protobuf callers and backend defaults. Document inherited provider support and protocol constraints. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 591f2db commit c809917

10 files changed

Lines changed: 284 additions & 6 deletions

File tree

‎CHANGELOG.md‎

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

88
## Unreleased
99

10+
ADDED
11+
12+
- Added public `OrchestrationIdReusePolicy` with typed deduplication statuses for
13+
synchronous and asynchronous orchestration scheduling, including legacy
14+
`OrchestrationStatus.CANCELED`. Omission preserves backend defaults; existing
15+
protobuf policies remain supported. See [ID reuse](docs/features.md#orchestration-id-reuse).
16+
1017
FIXED
1118

1219
- Activity and sub-orchestration `retry_timeout` decisions now use replay-safe

‎README.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@ The Azure Functions Durable provider requires Python 3.13 or later.
3232

3333
## Optional Features
3434

35+
### Orchestration ID Reuse
36+
37+
Use `OrchestrationIdReusePolicy` from `durabletask.client` to select which
38+
existing-instance statuses reject duplicate IDs. Omission preserves backend
39+
defaults; an explicit empty deduplication list permits replacement in every
40+
stable status, subject to backend support. See the
41+
[policy documentation](./docs/features.md#orchestration-id-reuse).
42+
3543
### Task Hub Query Filters
3644

3745
Set `OrchestrationQuery.task_hub_names` to filter orchestration queries by one or

‎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+
- With the corresponding core SDK release, Functions-bound synchronous and
13+
asynchronous clients accept public `OrchestrationIdReusePolicy` values through
14+
`schedule_new_orchestration()`, preserving backend defaults when omitted.
15+
See [ID reuse](../docs/features.md#orchestration-id-reuse). Core release and
16+
dependency minimum updates are coordinated in the dedicated release PR.
1217
- Added opt-in `HttpRetryOptions` on `context.call_http()` for durable initial
1318
request retries with configurable timing and status selection. Empty or omitted
1419
status lists select all non-2xx responses; exhausted retries fail the call.

‎docs/features.md‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,64 @@ fault-tolerant and durable, meaning that they can automatically recover from fai
1111
their local execution state. Orchestrator functions must be deterministic, meaning that they must
1212
always produce the same output given the same input.
1313

14+
### Orchestration ID reuse
15+
16+
Use the public `OrchestrationIdReusePolicy` to control what happens when scheduling
17+
an orchestration whose instance ID already exists:
18+
19+
```python
20+
from durabletask.client import OrchestrationIdReusePolicy, OrchestrationStatus
21+
22+
instance_id = client.schedule_new_orchestration(
23+
"process_order",
24+
instance_id="order-123",
25+
reuse_id_policy=OrchestrationIdReusePolicy(
26+
dedupe_statuses=[
27+
OrchestrationStatus.RUNNING,
28+
OrchestrationStatus.PENDING,
29+
OrchestrationStatus.SUSPENDED,
30+
OrchestrationStatus.TERMINATED,
31+
OrchestrationStatus.CANCELED,
32+
],
33+
),
34+
)
35+
```
36+
37+
In this example, only completed or failed instances are eligible for replacement.
38+
`dedupe_statuses` contains the statuses that **must not** be replaced, matching the
39+
.NET and Go SDKs' deduplication semantics.
40+
41+
- Omitting `reuse_id_policy`, or using `OrchestrationIdReusePolicy()` with
42+
`dedupe_statuses=None`, preserves the backend's default duplicate-ID behavior.
43+
- An explicit `dedupe_statuses=[]` permits replacement in every stable status,
44+
including running instances. Include all stable statuses to reject every
45+
duplicate.
46+
- Stable statuses are `RUNNING`, `PENDING`, `SUSPENDED`, `COMPLETED`, `FAILED`,
47+
`TERMINATED`, and the legacy `CANCELED` status. `CONTINUED_AS_NEW` is an execution
48+
transition, not a configurable instance status. Invalid status values raise
49+
`ValueError` before the scheduling request is sent.
50+
51+
The policy works with synchronous and asynchronous core, Azure Managed, and
52+
Functions-bound clients. Use `await client.schedule_new_orchestration(...)` with
53+
an asynchronous client. The deprecated Functions `start_new()` alias does not
54+
accept policy options.
55+
56+
Existing callers may continue passing a generated protobuf policy through
57+
`reuse_id_policy`. Its `replaceableStatus` list has the **opposite polarity**:
58+
it lists statuses that may be replaced. An explicitly empty protobuf policy still
59+
permits no replacements; it is not equivalent to an empty public deduplication list.
60+
61+
> [!WARNING]
62+
> Replacing an active instance can terminate its execution. Support, replacement
63+
> guarantees, and duplicate rejection are enforced by the backend, not by the SDK.
64+
> The SDK sends one scheduling request; it does not perform a separate
65+
> terminate-then-start sequence. The shared protocol does not provide an atomic
66+
> no-op/`IGNORE` action.
67+
68+
Provider releases exposing this public policy require the corresponding core SDK
69+
release first. Package versions and dependency minimums are coordinated in the
70+
dedicated release PR.
71+
1472
### Orchestration versioning
1573

1674
Orchestrations may be assigned a version when they are first created. If an orchestration is given a

‎durabletask-azuremanaged/CHANGELOG.md‎

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

88
## Unreleased
99

10+
ADDED
11+
12+
- With the corresponding core SDK release, synchronous and asynchronous clients
13+
accept public `OrchestrationIdReusePolicy` values to control duplicate instance
14+
IDs without changing backend defaults. See [ID reuse](../docs/features.md#orchestration-id-reuse).
15+
Core release and dependency minimum updates are coordinated in the dedicated release PR.
16+
1017
FIXED
1118

1219
- With the corresponding core SDK update, activity and sub-orchestration

‎durabletask/client.py‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ class OrchestrationStatus(Enum):
6363
RUNNING = pb.ORCHESTRATION_STATUS_RUNNING
6464
COMPLETED = pb.ORCHESTRATION_STATUS_COMPLETED
6565
FAILED = pb.ORCHESTRATION_STATUS_FAILED
66+
CANCELED = pb.ORCHESTRATION_STATUS_CANCELED
6667
TERMINATED = pb.ORCHESTRATION_STATUS_TERMINATED
6768
CONTINUED_AS_NEW = pb.ORCHESTRATION_STATUS_CONTINUED_AS_NEW
6869
PENDING = pb.ORCHESTRATION_STATUS_PENDING
@@ -72,6 +73,23 @@ def __str__(self) -> str:
7273
return cast(str, helpers.get_orchestration_status_str(self.value))
7374

7475

76+
@dataclass
77+
class OrchestrationIdReusePolicy:
78+
"""Control duplicate instance IDs when scheduling an orchestration.
79+
80+
``dedupe_statuses`` lists existing-instance statuses that must not be
81+
replaced. Other stable statuses permit replacement if supported by the
82+
backend. ``None`` preserves backend defaults; an empty sequence permits
83+
replacement in every stable status. ``CONTINUED_AS_NEW`` is a transition,
84+
not a configurable instance status.
85+
86+
This policy does not provide an atomic no-op/ignore action. Replacement
87+
behavior and support are determined by the backend.
88+
"""
89+
90+
dedupe_statuses: Sequence[OrchestrationStatus] | None = None
91+
92+
7593
@dataclass
7694
class OrchestrationState:
7795
instance_id: str
@@ -631,13 +649,18 @@ def schedule_new_orchestration(self, orchestrator: task.Orchestrator[TInput, TOu
631649
input: TInput | None = None,
632650
instance_id: str | None = None,
633651
start_at: datetime | None = None,
634-
reuse_id_policy: pb.OrchestrationIdReusePolicy | None = None,
652+
reuse_id_policy: OrchestrationIdReusePolicy | pb.OrchestrationIdReusePolicy | None = None,
635653
tags: dict[str, str] | None = None,
636654
version: str | None = None) -> str:
637655
"""Schedule a new orchestration instance for execution.
638656
639657
If instance_id is not specified, a random UUID hex string is generated upfront
640658
and shared across both the OpenTelemetry producer span and the CreateInstanceRequest.
659+
660+
``reuse_id_policy`` controls which existing-instance statuses reject
661+
duplicate IDs. Omission preserves backend defaults. Existing protobuf
662+
policies remain supported and retain their replaceable-status semantics.
663+
Invalid public policy statuses raise ``ValueError`` before sending the request.
641664
"""
642665
name = orchestrator if isinstance(orchestrator, str) else task.get_name(orchestrator)
643666
resolved_instance_id = instance_id if instance_id else uuid.uuid4().hex
@@ -1183,13 +1206,18 @@ async def schedule_new_orchestration(self, orchestrator: task.Orchestrator[TInpu
11831206
input: TInput | None = None,
11841207
instance_id: str | None = None,
11851208
start_at: datetime | None = None,
1186-
reuse_id_policy: pb.OrchestrationIdReusePolicy | None = None,
1209+
reuse_id_policy: OrchestrationIdReusePolicy | pb.OrchestrationIdReusePolicy | None = None,
11871210
tags: dict[str, str] | None = None,
11881211
version: str | None = None) -> str:
11891212
"""Schedule a new orchestration instance for execution asynchronously.
11901213
11911214
If instance_id is not specified, a random UUID hex string is generated upfront
11921215
and shared across both the OpenTelemetry producer span and the CreateInstanceRequest.
1216+
1217+
``reuse_id_policy`` controls which existing-instance statuses reject
1218+
duplicate IDs. Omission preserves backend defaults. Existing protobuf
1219+
policies remain supported and retain their replaceable-status semantics.
1220+
Invalid public policy statuses raise ``ValueError`` before sending the request.
11931221
"""
11941222
name = orchestrator if isinstance(orchestrator, str) else task.get_name(orchestrator)
11951223
resolved_instance_id = instance_id if instance_id else uuid.uuid4().hex

‎durabletask/internal/client_helpers.py‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
if TYPE_CHECKING:
2424
from durabletask.client import (
2525
EntityQuery,
26+
OrchestrationIdReusePolicy,
2627
OrchestrationQuery,
2728
OrchestrationState,
2829
OrchestrationStatus,
@@ -77,11 +78,30 @@ def build_schedule_new_orchestration_req(
7778
input: TInput | None,
7879
instance_id: str | None,
7980
start_at: datetime | None,
80-
reuse_id_policy: pb.OrchestrationIdReusePolicy | None,
81+
reuse_id_policy: OrchestrationIdReusePolicy | pb.OrchestrationIdReusePolicy | None,
8182
tags: dict[str, str] | None,
8283
version: str | None,
8384
data_converter: DataConverter) -> pb.CreateInstanceRequest:
8485
"""Build a CreateInstanceRequest for scheduling a new orchestration."""
86+
if reuse_id_policy is not None and not isinstance(reuse_id_policy, pb.OrchestrationIdReusePolicy):
87+
from durabletask.client import OrchestrationStatus
88+
89+
dedupe_statuses = reuse_id_policy.dedupe_statuses
90+
if dedupe_statuses is None:
91+
reuse_id_policy = None
92+
else:
93+
stable_statuses = tuple(
94+
status for status in OrchestrationStatus
95+
if status is not OrchestrationStatus.CONTINUED_AS_NEW)
96+
for status in dedupe_statuses:
97+
if status not in stable_statuses:
98+
raise ValueError(f"Invalid orchestration dedupe status: {status!r}")
99+
reuse_id_policy = pb.OrchestrationIdReusePolicy(
100+
replaceableStatus=[
101+
status.value for status in stable_statuses
102+
if status not in dedupe_statuses
103+
])
104+
85105
name = orchestrator if isinstance(orchestrator, str) else task.get_name(orchestrator)
86106
return pb.CreateInstanceRequest(
87107
name=name,

‎tests/azure-functions-durable/test_client_transport.py‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,13 @@
1111
from google.protobuf.wrappers_pb2 import StringValue
1212

1313
from azure.durable_functions import DurableFunctionsClient, SyncDurableFunctionsClient
14-
from durabletask.client import AsyncTaskHubGrpcClient, OrchestrationQuery, OrchestrationStatus, TaskHubGrpcClient
14+
from durabletask.client import (
15+
AsyncTaskHubGrpcClient,
16+
OrchestrationIdReusePolicy,
17+
OrchestrationQuery,
18+
OrchestrationStatus,
19+
TaskHubGrpcClient,
20+
)
1521
from durabletask.internal import orchestrator_service_pb2 as pb
1622
from durabletask.internal import orchestrator_service_pb2_grpc as rpc
1723

@@ -26,6 +32,35 @@ def _config(address: str) -> str:
2632
})
2733

2834

35+
@pytest.mark.asyncio
36+
@pytest.mark.parametrize("use_async", [False, True])
37+
async def test_functions_client_accepts_public_reuse_policy(use_async):
38+
stub = Mock()
39+
response = pb.CreateInstanceResponse(instanceId="instance")
40+
policy = OrchestrationIdReusePolicy(dedupe_statuses=[OrchestrationStatus.RUNNING])
41+
if use_async:
42+
stub.StartInstance = AsyncMock(return_value=response)
43+
async with DurableFunctionsClient(_config("localhost:8080")) as client:
44+
with patch.object(client, "_get_stub", return_value=stub):
45+
result = await client.schedule_new_orchestration(
46+
"orchestrator", instance_id="instance", reuse_id_policy=policy)
47+
else:
48+
stub.StartInstance.return_value = response
49+
with SyncDurableFunctionsClient(_config("localhost:8080")) as client:
50+
with patch.object(client, "_stub", stub):
51+
result = client.schedule_new_orchestration(
52+
"orchestrator", instance_id="instance", reuse_id_policy=policy)
53+
54+
assert result == "instance"
55+
request = stub.StartInstance.call_args.args[0]
56+
assert request.HasField("orchestrationIdReusePolicy")
57+
assert list(request.orchestrationIdReusePolicy.replaceableStatus) == [
58+
pb.ORCHESTRATION_STATUS_COMPLETED, pb.ORCHESTRATION_STATUS_FAILED,
59+
pb.ORCHESTRATION_STATUS_CANCELED, pb.ORCHESTRATION_STATUS_TERMINATED,
60+
pb.ORCHESTRATION_STATUS_PENDING, pb.ORCHESTRATION_STATUS_SUSPENDED,
61+
]
62+
63+
2964
@pytest.mark.parametrize("use_async", [False, True])
3065
@pytest.mark.parametrize("address, expected", [
3166
("http://127.0.0.1:8080/durabletask/", "http://127.0.0.1:8080"),

‎tests/durabletask-azuremanaged/test_azuremanaged_grpc_resiliency.py‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
# Copyright (c) Microsoft Corporation.
22
# Licensed under the MIT License.
33

4-
from unittest.mock import Mock, patch
4+
from unittest.mock import AsyncMock, Mock, patch
55
from collections.abc import Mapping
66
from typing import Any
77

88
import pytest
99

1010
from durabletask import task
11+
from durabletask.client import OrchestrationIdReusePolicy, OrchestrationStatus
1112
from durabletask.internal import helpers
1213
from durabletask.internal import orchestrator_service_pb2 as pb
1314
from durabletask.azuremanaged.client import (
@@ -21,6 +22,35 @@
2122
)
2223

2324

25+
@pytest.mark.asyncio
26+
@pytest.mark.parametrize("use_async", [False, True])
27+
async def test_dts_client_accepts_public_reuse_policy(use_async):
28+
stub = Mock()
29+
response = pb.CreateInstanceResponse(instanceId="instance")
30+
stub.StartInstance = AsyncMock(return_value=response) if use_async else Mock(return_value=response)
31+
policy = OrchestrationIdReusePolicy(dedupe_statuses=[OrchestrationStatus.RUNNING])
32+
kwargs = dict(host_address="localhost:4001", taskhub="hub",
33+
token_credential=None, channel=Mock())
34+
35+
with patch("durabletask.client.stubs.TaskHubSidecarServiceStub", return_value=stub):
36+
if use_async:
37+
async with AsyncDurableTaskSchedulerClient(**kwargs) as client:
38+
assert await client.schedule_new_orchestration(
39+
"orchestrator", instance_id="instance", reuse_id_policy=policy) == "instance"
40+
else:
41+
with DurableTaskSchedulerClient(**kwargs) as client:
42+
assert client.schedule_new_orchestration(
43+
"orchestrator", instance_id="instance", reuse_id_policy=policy) == "instance"
44+
45+
request = stub.StartInstance.call_args.args[0]
46+
assert request.HasField("orchestrationIdReusePolicy")
47+
assert list(request.orchestrationIdReusePolicy.replaceableStatus) == [
48+
pb.ORCHESTRATION_STATUS_COMPLETED, pb.ORCHESTRATION_STATUS_FAILED,
49+
pb.ORCHESTRATION_STATUS_CANCELED, pb.ORCHESTRATION_STATUS_TERMINATED,
50+
pb.ORCHESTRATION_STATUS_PENDING, pb.ORCHESTRATION_STATUS_SUSPENDED,
51+
]
52+
53+
2454
def test_dts_client_passes_resiliency_options_to_base_client():
2555
resiliency = GrpcClientResiliencyOptions()
2656
with patch("durabletask.azuremanaged.client.TaskHubGrpcClient.__init__", return_value=None) as mock_init:

0 commit comments

Comments
 (0)