Skip to content

Commit ce19f8a

Browse files
authored
Merge branch 'main' into wangbill/serverless-op1
2 parents eb1d6c3 + f17ee6f commit ce19f8a

57 files changed

Lines changed: 5020 additions & 1743 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/typecheck.yml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
name: Type Check (pyright)
2+
3+
on:
4+
push:
5+
branches:
6+
- "main"
7+
tags:
8+
- "v*"
9+
- "azuremanaged-v*"
10+
pull_request:
11+
branches:
12+
- "main"
13+
14+
permissions:
15+
contents: read
16+
17+
jobs:
18+
pyright:
19+
runs-on: ubuntu-latest
20+
steps:
21+
- name: Checkout repository
22+
uses: actions/checkout@v4
23+
24+
- name: Set up Python 3.10 (lowest supported)
25+
uses: actions/setup-python@v5
26+
with:
27+
python-version: "3.10"
28+
29+
- name: Install packages and dependencies
30+
run: |
31+
python -m pip install --upgrade pip
32+
pip install -r requirements.txt
33+
pip install -e ".[azure-blob-payloads,opentelemetry]"
34+
pip install -e ./durabletask-azuremanaged
35+
pip install pyright
36+
37+
- name: Run pyright (strict, Python 3.10)
38+
run: pyright

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,5 +131,7 @@ dmypy.json
131131

132132
# IDEs
133133
.idea
134+
.worktrees/
135+
docs/superpowers/
134136

135-
coverage.lcov
137+
coverage.lcov

CHANGELOG.md

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,47 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project
66
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8-
## Unreleased
9-
108
## v1.5.0
119

10+
BREAKING CHANGES (type-level only — no runtime impact for typical users)
11+
12+
These changes do not alter runtime behavior for clients or activity/orchestrator
13+
authors, but because the package ships `py.typed`, consumers running strict type
14+
checkers (pyright/mypy) against their own code — or subclassing the public
15+
abstract types — may see new type-check errors and need to update their
16+
overrides:
17+
18+
- `OrchestrationContext.create_timer` now returns the specific `TimerTask` type
19+
(was `CancellableTask`)
20+
([#93](https://github.com/microsoft/durabletask-python/issues/93)).
21+
- `OrchestrationContext.wait_for_external_event` now returns `CancellableTask[Any]`
22+
(was a bare `CancellableTask`).
23+
- `WhenAnyTask` is now generic; `when_any(tasks: Sequence[Task[T]])` returns
24+
`WhenAnyTask[T]` for better static inference of the completing child task
25+
([#94](https://github.com/microsoft/durabletask-python/issues/94)).
26+
`CompositeTask.on_child_completed` now takes `Task[Any]`.
27+
- `TaskHubGrpcWorker.add_activity` / `add_entity` (and the internal registry
28+
methods) now require `Activity[Any, Any]` / `Entity[Any, Any]` instead of the
29+
bare `Activity` / `Entity` aliases.
30+
- `OrchestrationContext.call_entity` / `signal_entity` `input` parameter widened
31+
from `TInput | None` to `Any` (Liskov-safe for callers; subclass overrides
32+
using the old narrower type will be flagged).
33+
- gRPC client interceptors now use the public `grpc.ClientCallDetails` /
34+
`grpc.aio.ClientCallDetails` types instead of private internal namedtuples;
35+
custom interceptor subclasses should retype their override parameters.
36+
- These changes also broadly improve generic type-safety hints throughout the
37+
SDK ([#92](https://github.com/microsoft/durabletask-python/issues/92)).
38+
1239
ADDED
1340

41+
- Added context-manager support (`__enter__` / `__exit__`) to
42+
`TaskHubGrpcClient` so it can be used with `with` statements, mirroring the
43+
existing `AsyncTaskHubGrpcClient` async-context-manager support and the
44+
`TaskHubGrpcWorker` pattern. `DurableTaskSchedulerClient` inherits this
45+
behavior automatically. `__exit__` delegates to `close()`, so the
46+
resiliency-aware teardown (in-flight recreate thread join, retired-channel
47+
timer cancellation, and SDK-owned channel cleanup) runs unchanged through the
48+
new `with` path.
1449
- Added `ReplaySafeLogger` and `OrchestrationContext.create_replay_safe_logger()`
1550
for suppressing duplicate log messages during orchestrator replay
1651
- Added `GrpcChannelOptions` and `GrpcRetryPolicyOptions` for configuring
@@ -20,8 +55,41 @@ ADDED
2055
`TaskHubGrpcClient`, `AsyncTaskHubGrpcClient`, and `TaskHubGrpcWorker` to
2156
support pre-configured channel passthrough and low-level gRPC channel
2257
customization.
23-
- Added `get_orchestration_history()` and `list_instance_ids()` to the sync and async gRPC clients.
24-
- Added in-memory backend support for `StreamInstanceHistory` and `ListInstanceIds` so local orchestration tests can retrieve history and page terminal instance IDs by completion window.
58+
- Added `GrpcWorkerResiliencyOptions` and `GrpcClientResiliencyOptions`, plus
59+
`resiliency_options` constructor parameters on `TaskHubGrpcClient`,
60+
`AsyncTaskHubGrpcClient`, and `TaskHubGrpcWorker`, to configure hello
61+
deadlines, silent-disconnect detection, reconnect backoff, and channel
62+
recreation thresholds for SDK-managed gRPC connections.
63+
- Added `get_orchestration_history()` and `list_instance_ids()` to the sync
64+
and async gRPC clients.
65+
- Added in-memory backend support for `StreamInstanceHistory` and
66+
`ListInstanceIds` so local orchestration tests can retrieve history and page
67+
terminal instance IDs by completion window.
68+
69+
CHANGED
70+
71+
- `when_any` now copies its input into a new list (`WhenAnyTask(list(tasks))`).
72+
Previously the task aliased the caller's list, so mutating it after
73+
construction was visible inside the task; that side effect no longer occurs.
74+
75+
FIXED
76+
77+
- Fixed `EntityInstanceId.__lt__` infinite recursion when compared against a
78+
non-`EntityInstanceId` operand. It now returns `NotImplemented`, so mixed-type
79+
comparisons raise `TypeError` cleanly instead of recursing.
80+
- Improved `TaskHubGrpcWorker` recovery from stale or disconnected gRPC streams
81+
so configured hello timeouts apply on fresh connections, received work resets
82+
failure tracking, SDK-owned channels are refreshed and cleaned up safely, and
83+
caller-owned channels are never recreated or closed during reconnects.
84+
- Fixed `TaskHubGrpcWorker` so in-flight and queued work item completions keep
85+
draining across graceful gRPC stream resets and worker shutdown before the
86+
worker retires an SDK-owned channel.
87+
- Improved sync and async gRPC clients so repeated transport failures recreate
88+
SDK-owned channels, while long-poll deadlines, successful replies, and
89+
application-level RPC errors do not trigger unnecessary channel replacement.
90+
- Fixed `TaskHubGrpcClient.close()` so explicit sync client shutdown now closes
91+
any previously retired SDK-owned gRPC channels immediately instead of waiting
92+
for the delayed cleanup timer.
2593

2694
## v1.4.0
2795

dev-requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
grpcio-tools
22
pymarkdownlnt
3+
pyright

durabletask-azuremanaged/CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,25 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1414
## v1.5.0
1515

1616
- Updates base dependency to durabletask v1.5.0
17+
- Improved type coverage benefits Azure Managed users: `create_timer` now
18+
returns the specific `TimerTask` type and `when_any` is generic so the
19+
completing child task is type-checked through `DurableTaskSchedulerClient`,
20+
`AsyncDurableTaskSchedulerClient`, and `DurableTaskSchedulerWorker` derived
21+
orchestrations.
22+
- gRPC client interceptors in the core SDK now use the public
23+
`grpc.ClientCallDetails` / `grpc.aio.ClientCallDetails` types instead of
24+
private internal namedtuples. Any custom DTS auth interceptor built on the
25+
same pattern as `DTSDefaultClientInterceptorImpl` should retype its
26+
`_intercept_call` override parameter accordingly. This is a type-level change
27+
only and does not alter runtime behavior.
1728
- Added optional `interceptors`, `channel`, and `channel_options` parameters to
1829
`DurableTaskSchedulerClient`, `AsyncDurableTaskSchedulerClient`, and
1930
`DurableTaskSchedulerWorker` to allow combining custom gRPC interceptors with
2031
DTS defaults and to support pre-configured/customized gRPC channels.
32+
- Added pass-through `resiliency_options` support on
33+
`DurableTaskSchedulerClient`, `AsyncDurableTaskSchedulerClient`, and
34+
`DurableTaskSchedulerWorker` so Azure Managed applications can use the core
35+
SDK's gRPC resiliency option types through their constructors.
2136
- Added `workerid` gRPC metadata on Durable Task Scheduler worker calls for
2237
improved worker identity and observability.
2338
- Improved sync access token refresh concurrency handling to avoid duplicate

durabletask-azuremanaged/durabletask/azuremanaged/client.py

Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22
# Licensed under the MIT License.
33

44
import logging
5-
6-
from typing import Optional, Sequence
5+
from collections.abc import Sequence
76

87
import grpc
98
import grpc.aio
@@ -15,7 +14,10 @@
1514
DTSDefaultClientInterceptorImpl,
1615
)
1716
from durabletask.client import AsyncTaskHubGrpcClient, TaskHubGrpcClient
18-
from durabletask.grpc_options import GrpcChannelOptions
17+
from durabletask.grpc_options import (
18+
GrpcChannelOptions,
19+
GrpcClientResiliencyOptions,
20+
)
1921
import durabletask.internal.shared as shared
2022
from durabletask.payload.store import PayloadStore
2123

@@ -25,15 +27,16 @@ class DurableTaskSchedulerClient(TaskHubGrpcClient):
2527
def __init__(self, *,
2628
host_address: str,
2729
taskhub: str,
28-
token_credential: Optional[TokenCredential],
29-
channel: Optional[grpc.Channel] = None,
30+
token_credential: TokenCredential | None,
31+
channel: grpc.Channel | None = None,
3032
secure_channel: bool = True,
31-
interceptors: Optional[Sequence[shared.ClientInterceptor]] = None,
32-
channel_options: Optional[GrpcChannelOptions] = None,
33-
default_version: Optional[str] = None,
34-
payload_store: Optional[PayloadStore] = None,
35-
log_handler: Optional[logging.Handler] = None,
36-
log_formatter: Optional[logging.Formatter] = None):
33+
interceptors: Sequence[shared.ClientInterceptor] | None = None,
34+
channel_options: GrpcChannelOptions | None = None,
35+
resiliency_options: GrpcClientResiliencyOptions | None = None,
36+
default_version: str | None = None,
37+
payload_store: PayloadStore | None = None,
38+
log_handler: logging.Handler | None = None,
39+
log_formatter: logging.Formatter | None = None):
3740

3841
if not taskhub:
3942
raise ValueError("Taskhub value cannot be empty. Please provide a value for your taskhub")
@@ -54,6 +57,7 @@ def __init__(self, *,
5457
log_formatter=log_formatter,
5558
interceptors=resolved_interceptors,
5659
channel_options=channel_options,
60+
resiliency_options=resiliency_options,
5761
default_version=default_version,
5862
payload_store=payload_store)
5963

@@ -70,15 +74,17 @@ class AsyncDurableTaskSchedulerClient(AsyncTaskHubGrpcClient):
7074
Args:
7175
host_address (str): The gRPC endpoint address of the DTS service.
7276
taskhub (str): The name of the task hub. Cannot be empty.
73-
token_credential (Optional[TokenCredential]): Azure credential for authentication.
77+
token_credential (TokenCredential | None): Azure credential for authentication.
7478
If None, anonymous authentication will be used.
7579
secure_channel (bool, optional): Whether to use a secure gRPC channel (TLS).
7680
Defaults to True.
77-
default_version (Optional[str], optional): Default version string for orchestrations.
78-
payload_store (Optional[PayloadStore], optional): A payload store for
81+
resiliency_options (GrpcClientResiliencyOptions | None, optional): Client-side
82+
gRPC resiliency settings forwarded to the base async client.
83+
default_version (str | None, optional): Default version string for orchestrations.
84+
payload_store (PayloadStore | None, optional): A payload store for
7985
externalizing large payloads. If None, payloads are sent inline.
80-
log_handler (Optional[logging.Handler], optional): Custom logging handler for client logs.
81-
log_formatter (Optional[logging.Formatter], optional): Custom log formatter for client logs.
86+
log_handler (logging.Handler | None, optional): Custom logging handler for client logs.
87+
log_formatter (logging.Formatter | None, optional): Custom log formatter for client logs.
8288
8389
Raises:
8490
ValueError: If taskhub is empty or None.
@@ -99,15 +105,16 @@ class AsyncDurableTaskSchedulerClient(AsyncTaskHubGrpcClient):
99105
def __init__(self, *,
100106
host_address: str,
101107
taskhub: str,
102-
token_credential: Optional[AsyncTokenCredential],
103-
channel: Optional[grpc.aio.Channel] = None,
108+
token_credential: AsyncTokenCredential | None,
109+
channel: grpc.aio.Channel | None = None,
104110
secure_channel: bool = True,
105-
interceptors: Optional[Sequence[shared.AsyncClientInterceptor]] = None,
106-
channel_options: Optional[GrpcChannelOptions] = None,
107-
default_version: Optional[str] = None,
108-
payload_store: Optional[PayloadStore] = None,
109-
log_handler: Optional[logging.Handler] = None,
110-
log_formatter: Optional[logging.Formatter] = None):
111+
interceptors: Sequence[shared.AsyncClientInterceptor] | None = None,
112+
channel_options: GrpcChannelOptions | None = None,
113+
resiliency_options: GrpcClientResiliencyOptions | None = None,
114+
default_version: str | None = None,
115+
payload_store: PayloadStore | None = None,
116+
log_handler: logging.Handler | None = None,
117+
log_formatter: logging.Formatter | None = None):
111118

112119
if not taskhub:
113120
raise ValueError("Taskhub value cannot be empty. Please provide a value for your taskhub")
@@ -128,5 +135,6 @@ def __init__(self, *,
128135
log_formatter=log_formatter,
129136
interceptors=resolved_interceptors,
130137
channel_options=channel_options,
138+
resiliency_options=resiliency_options,
131139
default_version=default_version,
132140
payload_store=payload_store)

durabletask-azuremanaged/durabletask/azuremanaged/internal/access_token_manager.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
# Licensed under the MIT License.
33
from datetime import datetime, timedelta, timezone
44
from threading import Lock
5-
from typing import Optional
65

76
from azure.core.credentials import AccessToken, TokenCredential
87
from azure.core.credentials_async import AsyncTokenCredential
@@ -13,9 +12,9 @@
1312
# By default, when there's 10minutes left before the token expires, refresh the token
1413
class AccessTokenManager:
1514

16-
_token: Optional[AccessToken]
15+
_token: AccessToken | None
1716

18-
def __init__(self, token_credential: Optional[TokenCredential], refresh_interval_seconds: int = 600):
17+
def __init__(self, token_credential: TokenCredential | None, refresh_interval_seconds: int = 600):
1918
self._scope = "https://durabletask.io/.default"
2019
self._refresh_interval_seconds = refresh_interval_seconds
2120
self._logger = shared.get_logger("token_manager")
@@ -30,7 +29,7 @@ def __init__(self, token_credential: Optional[TokenCredential], refresh_interval
3029
self._token = None
3130
self.expiry_time = None
3231

33-
def get_access_token(self) -> Optional[AccessToken]:
32+
def get_access_token(self) -> AccessToken | None:
3433
if self._token is None or self.is_token_expired():
3534
with self._refresh_lock:
3635
if self._token is None or self.is_token_expired():
@@ -59,9 +58,9 @@ class AsyncAccessTokenManager:
5958
6059
This avoids blocking the event loop when acquiring or refreshing tokens."""
6160

62-
_token: Optional[AccessToken]
61+
_token: AccessToken | None
6362

64-
def __init__(self, token_credential: Optional[AsyncTokenCredential],
63+
def __init__(self, token_credential: AsyncTokenCredential | None,
6564
refresh_interval_seconds: int = 600):
6665
self._scope = "https://durabletask.io/.default"
6766
self._refresh_interval_seconds = refresh_interval_seconds
@@ -71,7 +70,7 @@ def __init__(self, token_credential: Optional[AsyncTokenCredential],
7170
self._token = None
7271
self.expiry_time = None
7372

74-
async def get_access_token(self) -> Optional[AccessToken]:
73+
async def get_access_token(self) -> AccessToken | None:
7574
if self._token is None or self.is_token_expired():
7675
await self.refresh_token()
7776
return self._token

durabletask-azuremanaged/durabletask/azuremanaged/internal/durabletask_grpc_interceptor.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
# Licensed under the MIT License.
33

44
from importlib.metadata import version
5-
from typing import Optional
65

76
import grpc
87
from azure.core.credentials import TokenCredential
@@ -15,8 +14,6 @@
1514
from durabletask.internal.grpc_interceptor import (
1615
DefaultAsyncClientInterceptorImpl,
1716
DefaultClientInterceptorImpl,
18-
_AsyncClientCallDetails,
19-
_ClientCallDetails,
2017
)
2118

2219

@@ -27,9 +24,9 @@ class DTSDefaultClientInterceptorImpl (DefaultClientInterceptorImpl):
2724

2825
def __init__(
2926
self,
30-
token_credential: Optional[TokenCredential],
27+
token_credential: TokenCredential | None,
3128
taskhub_name: str,
32-
worker_id: Optional[str] = None):
29+
worker_id: str | None = None):
3330
try:
3431
# Get the version of the azuremanaged package
3532
sdk_version = version('durabletask-azuremanaged')
@@ -63,7 +60,7 @@ def _upsert_authorization_header(self, token: str) -> None:
6360
self._metadata.append(("authorization", f"Bearer {token}"))
6461

6562
def _intercept_call(
66-
self, client_call_details: _ClientCallDetails) -> grpc.ClientCallDetails:
63+
self, client_call_details: grpc.ClientCallDetails) -> grpc.ClientCallDetails:
6764
"""Internal intercept_call implementation which adds metadata to grpc metadata in the RPC
6865
call details."""
6966
# Refresh the auth token if a credential was provided. The call to
@@ -83,7 +80,7 @@ class DTSAsyncDefaultClientInterceptorImpl(DefaultAsyncClientInterceptorImpl):
8380
This class implements async gRPC interceptors to add DTS-specific headers
8481
(task hub name, user agent, and authentication token) to all async calls."""
8582

86-
def __init__(self, token_credential: Optional[AsyncTokenCredential], taskhub_name: str):
83+
def __init__(self, token_credential: AsyncTokenCredential | None, taskhub_name: str):
8784
try:
8885
# Get the version of the azuremanaged package
8986
sdk_version = version('durabletask-azuremanaged')
@@ -115,7 +112,7 @@ def _upsert_authorization_header(self, token: str) -> None:
115112
self._metadata.append(("authorization", f"Bearer {token}"))
116113

117114
async def _intercept_call(
118-
self, client_call_details: _AsyncClientCallDetails) -> grpc.aio.ClientCallDetails:
115+
self, client_call_details: grpc.aio.ClientCallDetails) -> grpc.aio.ClientCallDetails:
119116
"""Internal intercept_call implementation which adds metadata to grpc metadata in the RPC
120117
call details."""
121118
# Refresh the auth token if a credential was provided. The call to

0 commit comments

Comments
 (0)