Skip to content

Commit e484f61

Browse files
authored
Merge branch 'main' into style/rm-warning
2 parents effb63e + 71b26be commit e484f61

5 files changed

Lines changed: 449 additions & 162 deletions

File tree

ext/dapr-ext-workflow/dapr/ext/workflow/_durabletask/aio/client.py

Lines changed: 100 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
# See the License for the specific language governing permissions and
1010
# limitations under the License.
1111

12+
import asyncio
1213
import logging
14+
import time
1315
import uuid
1416
from datetime import datetime
1517
from typing import Any, Optional, Sequence, Union
@@ -33,6 +35,7 @@
3335
TOutput,
3436
WorkflowIdReusePolicy,
3537
WorkflowState,
38+
_TransientTimeout,
3639
new_orchestration_state,
3740
)
3841
from google.protobuf import wrappers_pb2
@@ -120,34 +123,33 @@ async def get_orchestration_state(
120123
return new_orchestration_state(req.instanceId, res)
121124

122125
async def wait_for_orchestration_start(
123-
self, instance_id: str, *, fetch_payloads: bool = False, timeout: int = 0
126+
self, instance_id: str, *, fetch_payloads: bool = False, timeout: Optional[int] = 0
124127
) -> Optional[WorkflowState]:
125128
req = pb.GetInstanceRequest(instanceId=instance_id, getInputsAndOutputs=fetch_payloads)
126-
try:
127-
grpc_timeout = None if timeout == 0 else timeout
128-
self._logger.info(
129-
f"Waiting {'indefinitely' if timeout == 0 else f'up to {timeout}s'} for instance '{instance_id}' to start."
130-
)
129+
self._logger.info(
130+
f"Waiting {'indefinitely' if timeout in (0, None) else f'up to {timeout}s'} for instance '{instance_id}' to start."
131+
)
132+
133+
async def _call(grpc_timeout):
131134
res: pb.GetInstanceResponse = await self._stub.WaitForInstanceStart(
132135
req, timeout=grpc_timeout
133136
)
134137
return new_orchestration_state(req.instanceId, res)
135-
except grpc.RpcError as rpc_error:
136-
if rpc_error.code() == grpc.StatusCode.DEADLINE_EXCEEDED: # type: ignore
137-
# Replace gRPC error with the built-in TimeoutError
138-
raise TimeoutError('Timed-out waiting for the orchestration to start')
139-
else:
140-
raise
138+
139+
try:
140+
return await self._call_with_transient_retry(instance_id, timeout, _call)
141+
except _TransientTimeout:
142+
raise TimeoutError('Timed-out waiting for the orchestration to start')
141143

142144
async def wait_for_orchestration_completion(
143-
self, instance_id: str, *, fetch_payloads: bool = True, timeout: int = 0
145+
self, instance_id: str, *, fetch_payloads: bool = True, timeout: Optional[int] = 0
144146
) -> Optional[WorkflowState]:
145147
req = pb.GetInstanceRequest(instanceId=instance_id, getInputsAndOutputs=fetch_payloads)
146-
try:
147-
grpc_timeout = None if timeout == 0 else timeout
148-
self._logger.info(
149-
f"Waiting {'indefinitely' if timeout == 0 else f'up to {timeout}s'} for instance '{instance_id}' to complete."
150-
)
148+
self._logger.info(
149+
f"Waiting {'indefinitely' if timeout in (0, None) else f'up to {timeout}s'} for instance '{instance_id}' to complete."
150+
)
151+
152+
async def _call(grpc_timeout):
151153
res: pb.GetInstanceResponse = await self._stub.WaitForInstanceCompletion(
152154
req, timeout=grpc_timeout
153155
)
@@ -167,14 +169,87 @@ async def wait_for_orchestration_completion(
167169
self._logger.info(f"Instance '{instance_id}' was terminated.")
168170
elif state.runtime_status == OrchestrationStatus.COMPLETED:
169171
self._logger.info(f"Instance '{instance_id}' completed.")
170-
171172
return state
172-
except grpc.RpcError as rpc_error:
173-
if rpc_error.code() == grpc.StatusCode.DEADLINE_EXCEEDED: # type: ignore
174-
# Replace gRPC error with the built-in TimeoutError
175-
raise TimeoutError('Timed-out waiting for the orchestration to complete')
176-
else:
177-
raise
173+
174+
try:
175+
return await self._call_with_transient_retry(instance_id, timeout, _call)
176+
except _TransientTimeout:
177+
raise TimeoutError('Timed-out waiting for the orchestration to complete')
178+
179+
# Transient gRPC codes that indicate the workflow runtime is temporarily
180+
# unable to locate the workflow actor — typically immediately after a Dapr
181+
# sidecar restart (e.g. recovery from chaos). The placement service has the
182+
# actor registration, but local daprd hasn't received the dissemination yet.
183+
# Without retry, every poll fails permanently with FAILED_PRECONDITION even
184+
# though the workflow runtime state is intact.
185+
_TRANSIENT_RPC_CODES = (
186+
grpc.StatusCode.FAILED_PRECONDITION,
187+
grpc.StatusCode.UNAVAILABLE,
188+
)
189+
190+
# See TaskHubGrpcClient._MAX_TRANSIENT_RETRY_SECONDS — same grace window for
191+
# unbounded (timeout=0) callers so a down sidecar surfaces the original
192+
# error instead of retrying forever.
193+
_MAX_TRANSIENT_RETRY_SECONDS = 30.0
194+
195+
async def _call_with_transient_retry(self, instance_id, timeout, call_fn):
196+
"""Async mirror of TaskHubGrpcClient._call_with_transient_retry.
197+
Retries FAILED_PRECONDITION/UNAVAILABLE with capped exponential
198+
backoff while clamping sleep and per-call gRPC timeout to the
199+
remaining budget. The first call uses the caller's timeout unchanged
200+
(``None`` when unbounded) so callers observe identical behavior on a
201+
healthy runtime. In unbounded
202+
mode, continuous transient retries are capped at
203+
``_MAX_TRANSIENT_RETRY_SECONDS`` before the original error propagates.
204+
"""
205+
unbounded = timeout in (0, None)
206+
deadline = None if unbounded else time.monotonic() + timeout
207+
grpc_timeout = None if unbounded else timeout
208+
backoff = 0.5
209+
transient_deadline = None # unbounded mode only; anchored on first transient
210+
while True:
211+
try:
212+
return await call_fn(grpc_timeout)
213+
except grpc.RpcError as rpc_error:
214+
code = rpc_error.code() # type: ignore
215+
if code == grpc.StatusCode.DEADLINE_EXCEEDED:
216+
raise _TransientTimeout()
217+
if code not in self._TRANSIENT_RPC_CODES:
218+
raise
219+
220+
now = time.monotonic()
221+
222+
if unbounded:
223+
if transient_deadline is None:
224+
transient_deadline = now + self._MAX_TRANSIENT_RETRY_SECONDS
225+
elif now >= transient_deadline:
226+
raise
227+
228+
if deadline is None:
229+
remaining = None
230+
else:
231+
remaining = deadline - now
232+
if remaining <= 0:
233+
raise _TransientTimeout()
234+
235+
sleep_for = min(backoff, 5.0)
236+
if remaining is not None:
237+
sleep_for = min(sleep_for, remaining)
238+
if transient_deadline is not None:
239+
sleep_for = min(sleep_for, transient_deadline - now)
240+
self._logger.warning(
241+
f"Transient gRPC error {code.name} waiting on instance '{instance_id}'; "
242+
f'retrying in {sleep_for:.2f}s'
243+
)
244+
await asyncio.sleep(sleep_for)
245+
backoff = min(backoff * 2, 5.0)
246+
247+
if deadline is None:
248+
grpc_timeout = None
249+
else:
250+
grpc_timeout = deadline - time.monotonic()
251+
if grpc_timeout <= 0:
252+
raise _TransientTimeout()
178253

179254
async def raise_orchestration_event(
180255
self, instance_id: str, event_name: str, *, data: Optional[Any] = None

ext/dapr-ext-workflow/dapr/ext/workflow/_durabletask/client.py

Lines changed: 117 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
# limitations under the License.
1111

1212
import logging
13+
import time
1314
import uuid
1415
from dataclasses import dataclass
1516
from datetime import datetime
@@ -25,6 +26,12 @@
2526
from dapr.ext.workflow._durabletask.internal.grpc_interceptor import DefaultClientInterceptorImpl
2627
from google.protobuf import wrappers_pb2
2728

29+
30+
class _TransientTimeout(Exception):
31+
"""Internal sentinel: the retry loop exhausted the user-provided timeout
32+
budget. Callers convert this to a public ``TimeoutError``."""
33+
34+
2835
TInput = TypeVar('TInput')
2936
TOutput = TypeVar('TOutput')
3037

@@ -218,32 +225,31 @@ def get_orchestration_state(
218225
return new_orchestration_state(req.instanceId, res)
219226

220227
def wait_for_orchestration_start(
221-
self, instance_id: str, *, fetch_payloads: bool = False, timeout: int = 0
228+
self, instance_id: str, *, fetch_payloads: bool = False, timeout: Optional[int] = 0
222229
) -> Optional[WorkflowState]:
223230
req = pb.GetInstanceRequest(instanceId=instance_id, getInputsAndOutputs=fetch_payloads)
224-
try:
225-
grpc_timeout = None if timeout == 0 else timeout
226-
self._logger.info(
227-
f"Waiting {'indefinitely' if timeout == 0 else f'up to {timeout}s'} for instance '{instance_id}' to start."
228-
)
231+
self._logger.info(
232+
f"Waiting {'indefinitely' if timeout in (0, None) else f'up to {timeout}s'} for instance '{instance_id}' to start."
233+
)
234+
235+
def _call(grpc_timeout):
229236
res: pb.GetInstanceResponse = self._stub.WaitForInstanceStart(req, timeout=grpc_timeout)
230237
return new_orchestration_state(req.instanceId, res)
231-
except grpc.RpcError as rpc_error:
232-
if rpc_error.code() == grpc.StatusCode.DEADLINE_EXCEEDED: # type: ignore
233-
# Replace gRPC error with the built-in TimeoutError
234-
raise TimeoutError('Timed-out waiting for the orchestration to start')
235-
else:
236-
raise
238+
239+
try:
240+
return self._call_with_transient_retry(instance_id, timeout, _call)
241+
except _TransientTimeout:
242+
raise TimeoutError('Timed-out waiting for the orchestration to start')
237243

238244
def wait_for_orchestration_completion(
239-
self, instance_id: str, *, fetch_payloads: bool = True, timeout: int = 0
245+
self, instance_id: str, *, fetch_payloads: bool = True, timeout: Optional[int] = 0
240246
) -> Optional[WorkflowState]:
241247
req = pb.GetInstanceRequest(instanceId=instance_id, getInputsAndOutputs=fetch_payloads)
242-
try:
243-
grpc_timeout = None if timeout == 0 else timeout
244-
self._logger.info(
245-
f"Waiting {'indefinitely' if timeout == 0 else f'up to {timeout}s'} for instance '{instance_id}' to complete."
246-
)
248+
self._logger.info(
249+
f"Waiting {'indefinitely' if timeout in (0, None) else f'up to {timeout}s'} for instance '{instance_id}' to complete."
250+
)
251+
252+
def _call(grpc_timeout):
247253
res: pb.GetInstanceResponse = self._stub.WaitForInstanceCompletion(
248254
req, timeout=grpc_timeout
249255
)
@@ -263,14 +269,100 @@ def wait_for_orchestration_completion(
263269
self._logger.info(f"Instance '{instance_id}' was terminated.")
264270
elif state.runtime_status == OrchestrationStatus.COMPLETED:
265271
self._logger.info(f"Instance '{instance_id}' completed.")
266-
267272
return state
268-
except grpc.RpcError as rpc_error:
269-
if rpc_error.code() == grpc.StatusCode.DEADLINE_EXCEEDED: # type: ignore
270-
# Replace gRPC error with the built-in TimeoutError
271-
raise TimeoutError('Timed-out waiting for the orchestration to complete')
272-
else:
273-
raise
273+
274+
try:
275+
return self._call_with_transient_retry(instance_id, timeout, _call)
276+
except _TransientTimeout:
277+
raise TimeoutError('Timed-out waiting for the orchestration to complete')
278+
279+
# Transient gRPC codes that indicate the workflow runtime is temporarily
280+
# unable to locate the workflow actor — typically immediately after a Dapr
281+
# sidecar restart (e.g. recovery from chaos). The placement service has the
282+
# actor registration, but local daprd hasn't received the dissemination yet.
283+
# Without retry, every poll fails permanently with FAILED_PRECONDITION even
284+
# though the workflow runtime state is intact.
285+
_TRANSIENT_RPC_CODES = (
286+
grpc.StatusCode.FAILED_PRECONDITION,
287+
grpc.StatusCode.UNAVAILABLE,
288+
)
289+
290+
# When the caller sets no timeout (timeout=0), bound how long we keep
291+
# retrying *consecutive* transient errors so a permanently-unavailable
292+
# sidecar surfaces the original error instead of hanging forever. This
293+
# window comfortably covers placement re-dissemination after a restart;
294+
# a slow-but-healthy workflow never enters this path (it just blocks in
295+
# the long-poll), so its indefinite wait is preserved.
296+
_MAX_TRANSIENT_RETRY_SECONDS = 30.0
297+
298+
def _call_with_transient_retry(self, instance_id, timeout, call_fn):
299+
"""Run a gRPC wait call, retrying transient errors until the user
300+
timeout deadline. Re-raises non-transient errors immediately.
301+
timeout in (0, None) means unbounded; transients are still retried,
302+
but only for up to ``_MAX_TRANSIENT_RETRY_SECONDS`` of continuous
303+
failures, after which the original transient error propagates.
304+
305+
The first call passes the caller's ``grpc_timeout`` (``None`` when
306+
unbounded) to ``call_fn`` so callers observe identical behavior to a
307+
non-retrying client when no transient occurs (preserves prior public
308+
behavior). On a retry, both the sleep
309+
and the per-call gRPC deadline are clamped to the remaining budget so
310+
the helper never sleeps past ``timeout`` or starts a gRPC call with
311+
no time left.
312+
"""
313+
unbounded = timeout in (0, None)
314+
deadline = None if unbounded else time.monotonic() + timeout
315+
grpc_timeout = None if unbounded else timeout
316+
backoff = 0.5
317+
transient_deadline = None # unbounded mode only; anchored on first transient
318+
while True:
319+
try:
320+
return call_fn(grpc_timeout)
321+
except grpc.RpcError as rpc_error:
322+
code = rpc_error.code() # type: ignore
323+
if code == grpc.StatusCode.DEADLINE_EXCEEDED:
324+
raise _TransientTimeout()
325+
if code not in self._TRANSIENT_RPC_CODES:
326+
raise
327+
328+
now = time.monotonic()
329+
330+
# In unbounded mode the user budget can't end the loop, so cap
331+
# continuous transient retries and re-raise the original error
332+
# (matching pre-retry behavior) once the grace window elapses.
333+
if unbounded:
334+
if transient_deadline is None:
335+
transient_deadline = now + self._MAX_TRANSIENT_RETRY_SECONDS
336+
elif now >= transient_deadline:
337+
raise
338+
339+
# Compute remaining budget once and reuse so the sleep and the
340+
# next per-call grpc_timeout agree on "how much time is left".
341+
if deadline is None:
342+
remaining = None
343+
else:
344+
remaining = deadline - now
345+
if remaining <= 0:
346+
raise _TransientTimeout()
347+
348+
sleep_for = min(backoff, 5.0)
349+
if remaining is not None:
350+
sleep_for = min(sleep_for, remaining)
351+
if transient_deadline is not None:
352+
sleep_for = min(sleep_for, transient_deadline - now)
353+
self._logger.warning(
354+
f"Transient gRPC error {code.name} waiting on instance '{instance_id}'; "
355+
f'retrying in {sleep_for:.2f}s'
356+
)
357+
time.sleep(sleep_for)
358+
backoff = min(backoff * 2, 5.0)
359+
360+
if deadline is None:
361+
grpc_timeout = None
362+
else:
363+
grpc_timeout = deadline - time.monotonic()
364+
if grpc_timeout <= 0:
365+
raise _TransientTimeout()
274366

275367
def raise_orchestration_event(
276368
self, instance_id: str, event_name: str, *, data: Optional[Any] = None

0 commit comments

Comments
 (0)