durable-workflow 2.11.0__tar.gz → 2.11.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. {durable_workflow-2.11.0/src/durable_workflow.egg-info → durable_workflow-2.11.1}/PKG-INFO +1 -1
  2. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/pyproject.toml +3 -3
  3. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/worker.py +67 -9
  4. {durable_workflow-2.11.0 → durable_workflow-2.11.1/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  5. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_worker.py +228 -0
  6. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/LICENSE +0 -0
  7. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/README.md +0 -0
  8. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/setup.cfg +0 -0
  9. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/__init__.py +0 -0
  10. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/_activity_lease.py +0 -0
  11. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/_activity_process.py +0 -0
  12. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/_avro.py +0 -0
  13. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/_cancellation_scope.py +0 -0
  14. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/_cancellation_scope_history.py +0 -0
  15. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/_cooperative_cancellation.py +0 -0
  16. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/_failure_reporting.py +0 -0
  17. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/_metric_routes.py +0 -0
  18. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/_prepared_local_activity.py +0 -0
  19. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/_query_task_authority.py +0 -0
  20. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
  21. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/activity.py +0 -0
  22. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/auth_composition.py +0 -0
  23. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/cancellation.py +0 -0
  24. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/client.py +0 -0
  25. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/errors.py +0 -0
  26. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/external_storage.py +0 -0
  27. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/external_task_input.py +0 -0
  28. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/external_task_result.py +0 -0
  29. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/history_bundle_verify.py +0 -0
  30. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/interceptors.py +0 -0
  31. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/invocable.py +0 -0
  32. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/metrics.py +0 -0
  33. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/nexus.py +0 -0
  34. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/py.typed +0 -0
  35. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/python_conformance.py +0 -0
  36. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/replay_conformance.py +0 -0
  37. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/replay_verify.py +0 -0
  38. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/retry_policy.py +0 -0
  39. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/serializer.py +0 -0
  40. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/sync.py +0 -0
  41. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/telemetry.py +0 -0
  42. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/telemetry_carrier.py +0 -0
  43. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/testing.py +0 -0
  44. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/worker_session.py +0 -0
  45. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/workflow.py +0 -0
  46. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  47. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow.egg-info/SOURCES.txt +0 -0
  48. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  49. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  50. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow.egg-info/requires.txt +0 -0
  51. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/src/durable_workflow.egg-info/top_level.txt +0 -0
  52. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_activity_cancellation_policies.py +0 -0
  53. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_activity_context.py +0 -0
  54. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_activity_heartbeat_authority.py +0 -0
  55. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_activity_lease_authority.py +0 -0
  56. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_activity_lease_renewal.py +0 -0
  57. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_activity_lease_worker.py +0 -0
  58. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_activity_process.py +0 -0
  59. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_auth_composition.py +0 -0
  60. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_avro_value_benchmark.py +0 -0
  61. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_avro_value_protocol.py +0 -0
  62. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_cancellation_context.py +0 -0
  63. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_cancellation_remaining_time.py +0 -0
  64. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_cancellation_scope_admission.py +0 -0
  65. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_cancellation_scope_authoring.py +0 -0
  66. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_cancellation_scope_boundary.py +0 -0
  67. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_cancellation_scope_opening.py +0 -0
  68. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_child_workflow_policies.py +0 -0
  69. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_ci_checkout.py +0 -0
  70. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_ci_integration_endpoint.py +0 -0
  71. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_client.py +0 -0
  72. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_client_telemetry.py +0 -0
  73. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_client_timeouts.py +0 -0
  74. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_codec_regression_corpus.py +0 -0
  75. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_committed_cancellation_scope_descendants.py +0 -0
  76. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_committed_cancellation_scope_groups.py +0 -0
  77. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_committed_cancellation_scope_history.py +0 -0
  78. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_committed_cancellation_scope_replay.py +0 -0
  79. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_condition_replay_drift.py +0 -0
  80. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_control_plane_parity_fixtures.py +0 -0
  81. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_cooperative_cancellation.py +0 -0
  82. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_cooperative_cancellation_client.py +0 -0
  83. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_cooperative_cancellation_worker.py +0 -0
  84. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_cooperative_remote_worker.py +0 -0
  85. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_docstring_cross_references.py +0 -0
  86. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_durable_selection.py +0 -0
  87. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_errors.py +0 -0
  88. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_external_storage.py +0 -0
  89. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_external_task_input.py +0 -0
  90. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_external_task_result.py +0 -0
  91. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_failure_reporting.py +0 -0
  92. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_golden_history_replay.py +0 -0
  93. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_history_bundle_schema2.py +0 -0
  94. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_history_bundle_verify.py +0 -0
  95. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_history_event_contract.py +0 -0
  96. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_invocable.py +0 -0
  97. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_local_failure_reporting.py +0 -0
  98. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_message_streams.py +0 -0
  99. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_metric_routes.py +0 -0
  100. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_metrics.py +0 -0
  101. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_nexus_durable_wait.py +0 -0
  102. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_nexus_namespace.py +0 -0
  103. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_nexus_wire.py +0 -0
  104. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_order_processing_example.py +0 -0
  105. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_parallel_completion_order_replay.py +0 -0
  106. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_parallel_saga_authoring.py +0 -0
  107. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_patch_history.py +0 -0
  108. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_pending_operation_replay.py +0 -0
  109. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_poll_failure_backoff.py +0 -0
  110. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_prepared_local_activity_client.py +0 -0
  111. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_prepared_local_activity_groups.py +0 -0
  112. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_prepared_local_activity_policies.py +0 -0
  113. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_prepared_local_activity_worker.py +0 -0
  114. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_public_boundary_scanner.py +0 -0
  115. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_pypi_project_surface.py +0 -0
  116. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_python_conformance.py +0 -0
  117. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_queries.py +0 -0
  118. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_query_task_authority.py +0 -0
  119. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_query_timeout_retry.py +0 -0
  120. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_readme_quickstart.py +0 -0
  121. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_redrive_replay.py +0 -0
  122. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_registration_ambiguity.py +0 -0
  123. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_release_metadata.py +0 -0
  124. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_replay.py +0 -0
  125. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_replay_clock.py +0 -0
  126. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_replay_conformance.py +0 -0
  127. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_replay_regression_corpus.py +0 -0
  128. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_replay_verify.py +0 -0
  129. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_retry_policy.py +0 -0
  130. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_run_cancellation_scope_replay.py +0 -0
  131. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_runtime_external_payload_transport.py +0 -0
  132. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_schedules.py +0 -0
  133. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_scoped_run_cancellation_context.py +0 -0
  134. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_sdk_metrics_contract.py +0 -0
  135. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_serializer.py +0 -0
  136. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_signals.py +0 -0
  137. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_sleep.py +0 -0
  138. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_standalone_activity_client.py +0 -0
  139. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_sticky_execution.py +0 -0
  140. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_storage_admission.py +0 -0
  141. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_sync.py +0 -0
  142. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_sync_activities.py +0 -0
  143. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_telemetry.py +0 -0
  144. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_telemetry_carrier.py +0 -0
  145. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_testing_failures.py +0 -0
  146. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_testing_harness.py +0 -0
  147. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_testing_nexus.py +0 -0
  148. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_threaded_heartbeat_example.py +0 -0
  149. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_tls_trust.py +0 -0
  150. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_transport_diagnostics.py +0 -0
  151. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_update_signal_condition_replay.py +0 -0
  152. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_updates.py +0 -0
  153. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_wait_condition.py +0 -0
  154. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_worker_deregistration.py +0 -0
  155. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_worker_observation.py +0 -0
  156. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_worker_session.py +0 -0
  157. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_worker_telemetry_wire.py +0 -0
  158. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_workflow_entry_arguments.py +0 -0
  159. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_workflow_entrypoint_validation.py +0 -0
  160. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_workflow_handler_validation.py +0 -0
  161. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_workflow_identity.py +0 -0
  162. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_workflow_result_timeout.py +0 -0
  163. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_workflow_streams.py +0 -0
  164. {durable_workflow-2.11.0 → durable_workflow-2.11.1}/tests/test_workflow_updates_conformance.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.11.0
3
+ Version: 2.11.1
4
4
  Summary: Python client and worker SDK for Durable Workflow Cloud and self-hosted Server
5
5
  Author: Durable Workflow Contributors
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "durable-workflow"
7
- version = "2.11.0"
7
+ version = "2.11.1"
8
8
  description = "Python client and worker SDK for Durable Workflow Cloud and self-hosted Server"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -76,8 +76,8 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main"
76
76
  durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main"
77
77
 
78
78
  [tool.durable-workflow]
79
- product-train = "2.11.0"
80
- registry-version = "2.11.0"
79
+ product-train = "2.11.1"
80
+ registry-version = "2.11.1"
81
81
  supported-server-versions = "2.8.0"
82
82
  worker-protocol-version = "1.20"
83
83
  control-plane-version = "2"
@@ -259,8 +259,8 @@ class _PollFailureBackoff:
259
259
  self._last_summary: str | None = None
260
260
  self._last_warning_at = 0.0
261
261
 
262
- def retry_delay(self, error: Exception, task_kind: str) -> float:
263
- delay = random.uniform(self._ceiling / 2, self._ceiling)
262
+ def retry_delay(self, error: Exception, task_kind: str, *, minimum_delay: float = 0.0) -> float:
263
+ delay = max(minimum_delay, random.uniform(self._ceiling / 2, self._ceiling))
264
264
  self._ceiling = min(30.0, self._ceiling * 2)
265
265
  summary = _poll_error_summary(error)
266
266
  now = time.monotonic()
@@ -280,6 +280,28 @@ def _poll_capacity_delay(error: BaseException, task_kind: str, task_queue: str)
280
280
  return error.poll_capacity_backpressure_delay(task_kind, task_queue)
281
281
 
282
282
 
283
+ def _backend_lock_poll_delay(
284
+ error: Exception, task_kind: str, task_queue: str, namespace: object,
285
+ ) -> int | None:
286
+ if not isinstance(error, ServerError) or error.status != 503:
287
+ return None
288
+ body = error.body
289
+ if (
290
+ not isinstance(body, dict) or not isinstance(namespace, str) or not namespace
291
+ or body.get("reason") != "backend_lock_pressure"
292
+ or body.get("poll_status") != "backend_lock_pressure"
293
+ or "task" not in body or body["task"] is not None
294
+ or body.get("task_kind") != task_kind + "_task"
295
+ or body.get("task_queue") != task_queue or body.get("namespace") != namespace
296
+ or ("retryable" in body and body["retryable"] is not True)
297
+ or ("request_admitted" in body and body["request_admitted"] is not False)
298
+ or type(body.get("retry_after_seconds")) is not int
299
+ or not 0 < body["retry_after_seconds"] <= 60
300
+ ):
301
+ return None
302
+ return int(body["retry_after_seconds"])
303
+
304
+
283
305
  def _command_payload_codec(codec: object) -> str:
284
306
  if codec is None:
285
307
  return serializer.AVRO_CODEC
@@ -4468,6 +4490,7 @@ class Worker:
4468
4490
  ) -> WorkflowExecution:
4469
4491
  deadline = time.monotonic() + timeout
4470
4492
  next_task_kind = "workflow"
4493
+ failures = {"workflow": _PollFailureBackoff(), "activity": _PollFailureBackoff()}
4471
4494
 
4472
4495
  while not self._stop.is_set():
4473
4496
  desc = await self.client.describe_workflow(workflow_id)
@@ -4494,9 +4517,14 @@ class Worker:
4494
4517
  task_kinds=self._workflow_poll_task_kinds(),
4495
4518
  history_page_size=WORKFLOW_HISTORY_PAGE_SIZE,
4496
4519
  )
4497
- except BaseException:
4520
+ except BaseException as error:
4498
4521
  self._release_workflow_capacity()
4522
+ if isinstance(error, Exception) and await self._retry_run_until_poll_refusal(
4523
+ error, "workflow", poll_start, deadline, failures["workflow"],
4524
+ ):
4525
+ continue
4499
4526
  raise
4527
+ failures["workflow"].reset()
4500
4528
  if self._stop.is_set():
4501
4529
  self._release_workflow_capacity()
4502
4530
  raise asyncio.CancelledError
@@ -4543,12 +4571,20 @@ class Worker:
4543
4571
  await asyncio.sleep(poll_interval)
4544
4572
  continue
4545
4573
  poll_start = time.perf_counter()
4546
- task = await self.client.poll_activity_task(
4547
- worker_id=self.worker_id,
4548
- task_queue=self.task_queue,
4549
- timeout=self._poll_timeout,
4550
- build_id=self.build_id,
4551
- )
4574
+ try:
4575
+ task = await self.client.poll_activity_task(
4576
+ worker_id=self.worker_id,
4577
+ task_queue=self.task_queue,
4578
+ timeout=self._poll_timeout,
4579
+ build_id=self.build_id,
4580
+ )
4581
+ except Exception as error:
4582
+ if await self._retry_run_until_poll_refusal(
4583
+ error, "activity", poll_start, deadline, failures["activity"],
4584
+ ):
4585
+ continue
4586
+ raise
4587
+ failures["activity"].reset()
4552
4588
  if self._stop.is_set():
4553
4589
  raise asyncio.CancelledError
4554
4590
  self._record_poll_metrics(
@@ -4566,6 +4602,28 @@ class Worker:
4566
4602
 
4567
4603
  raise asyncio.CancelledError
4568
4604
 
4605
+ async def _retry_run_until_poll_refusal(
4606
+ self, error: Exception, kind: str, poll_start: float, deadline: float,
4607
+ failures: _PollFailureBackoff,
4608
+ ) -> bool:
4609
+ delay = _backend_lock_poll_delay(
4610
+ error, kind, self.task_queue, getattr(self.client, "namespace", None),
4611
+ )
4612
+ if delay is None:
4613
+ return False
4614
+ self._record_poll_metrics(kind, "error", time.perf_counter() - poll_start)
4615
+ backoff = failures.retry_delay(error, kind, minimum_delay=delay)
4616
+ remaining = deadline - time.monotonic()
4617
+ if remaining <= 0:
4618
+ raise asyncio.TimeoutError
4619
+ # Both the public caller timer and this local wait retain their original
4620
+ # deadline. Stop interrupts the backoff without another poll or dispatch.
4621
+ with contextlib.suppress(asyncio.TimeoutError):
4622
+ await asyncio.wait_for(self._stop.wait(), timeout=min(backoff, remaining))
4623
+ if time.monotonic() >= deadline:
4624
+ raise asyncio.TimeoutError
4625
+ return True
4626
+
4569
4627
  async def _run_until_activity_task(self, task: dict[str, Any]) -> None:
4570
4628
  task_start = time.perf_counter()
4571
4629
  outcome = "error"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.11.0
3
+ Version: 2.11.1
4
4
  Summary: Python client and worker SDK for Durable Workflow Cloud and self-hosted Server
5
5
  Author: Durable Workflow Contributors
6
6
  License-Expression: MIT
@@ -2,9 +2,11 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import contextlib
5
+ import json
5
6
  import logging
6
7
  import sys
7
8
  import threading
9
+ from pathlib import Path
8
10
  from unittest.mock import AsyncMock, Mock
9
11
 
10
12
  import httpx
@@ -5029,6 +5031,232 @@ class TestWorkerHeartbeats:
5029
5031
 
5030
5032
 
5031
5033
  class TestRunUntil:
5034
+ @pytest.mark.asyncio
5035
+ async def test_lock_refusal_then_cold_history_preserves_original_activity_result(
5036
+ self, mock_client: AsyncMock,
5037
+ ) -> None:
5038
+ from tests.test_poll_failure_backoff import RecoveredWorkflow
5039
+
5040
+ fixture = json.loads((Path(__file__).parent / "fixtures/replay_regressions/"
5041
+ "run-until-lock-refusal-cold-history.json").read_text())
5042
+ mock_client.namespace = "default"
5043
+ mock_client.get_cluster_info.return_value = compatible_cluster_info(worker_protocol={
5044
+ "version": PROTOCOL_VERSION,
5045
+ "server_capabilities": {"query_tasks": False, "long_poll_timeout": 30},
5046
+ })
5047
+ run_id = fixture["workflow"]["run_id"]
5048
+ completed = False
5049
+
5050
+ async def describe(workflow_id: str) -> WorkflowExecution:
5051
+ return WorkflowExecution(
5052
+ workflow_id, run_id, fixture["workflow"]["type"], "completed" if completed else "running",
5053
+ )
5054
+
5055
+ async def complete(**kwargs: object) -> dict[str, str]:
5056
+ nonlocal completed
5057
+ completed = True
5058
+ return {"outcome": "completed"}
5059
+
5060
+ mock_client.describe_workflow.side_effect = describe
5061
+ mock_client.complete_workflow_task.side_effect = complete
5062
+ mock_client.poll_workflow_task.side_effect = [ServerError(503, {
5063
+ "task": None, "poll_status": "backend_lock_pressure", "reason": "backend_lock_pressure",
5064
+ "namespace": "default", "task_queue": "orders", "task_kind": "workflow_task",
5065
+ "retry_after_seconds": 1,
5066
+ }), {
5067
+ "task_id": "original-cold-task", "workflow_task_attempt": 3,
5068
+ "workflow_type": fixture["workflow"]["type"], "workflow_id": "order-0",
5069
+ "run_id": run_id, "payload_codec": "avro",
5070
+ "arguments": serializer.encode(fixture["workflow"]["input"]), "history_events": fixture["history"],
5071
+ }]
5072
+ worker = Worker(
5073
+ mock_client, task_queue="orders", workflows=[RecoveredWorkflow],
5074
+ max_concurrent_workflow_tasks=1, poll_timeout=0.01,
5075
+ )
5076
+ execution = await worker.run_until(workflow_id="order-0", timeout=3.0, poll_interval=0.01)
5077
+ assert execution.run_id == run_id
5078
+ assert execution.status == "completed"
5079
+ mock_client.complete_workflow_task.assert_awaited_once()
5080
+ publication = mock_client.complete_workflow_task.call_args.kwargs
5081
+ assert publication["task_id"] == "original-cold-task"
5082
+ assert publication["workflow_task_attempt"] == 3
5083
+ commands = publication["commands"]
5084
+ assert len(commands) == 1
5085
+ assert commands[0]["type"] == "complete_workflow"
5086
+ assert serializer.decode_envelope(commands[0]["result"], codec="avro") == fixture["expected"]["result"]
5087
+ mock_client.complete_activity_task.assert_not_awaited()
5088
+ mock_client.fail_workflow_task.assert_not_awaited()
5089
+ assert worker._workflow_reserved == 0
5090
+
5091
+ def refusal_worker(
5092
+ self, client: AsyncMock, kind: str,
5093
+ ) -> tuple[Worker, AsyncMock, dict[str, object]]:
5094
+ client.namespace = "default"
5095
+ client.get_cluster_info.return_value = compatible_cluster_info(worker_protocol={
5096
+ "version": PROTOCOL_VERSION,
5097
+ "server_capabilities": {"query_tasks": False, "long_poll_timeout": 30},
5098
+ })
5099
+ client.describe_workflow.return_value = WorkflowExecution(
5100
+ "original", "original-run", "test-wf", "running",
5101
+ )
5102
+ body: dict[str, object] = {
5103
+ "task": None, "poll_status": "backend_lock_pressure", "reason": "backend_lock_pressure",
5104
+ "namespace": "default", "task_queue": "orders", "task_kind": kind + "_task",
5105
+ "retry_after_seconds": 1,
5106
+ }
5107
+ worker = Worker(
5108
+ client, worker_id="w1", task_queue="orders",
5109
+ workflows=[TestWorkflow] if kind == "workflow" else [],
5110
+ activities=[echo_activity] if kind == "activity" else [], poll_timeout=0.01,
5111
+ max_concurrent_workflow_tasks=1,
5112
+ )
5113
+ return worker, getattr(client, "poll_" + kind + "_task"), body
5114
+
5115
+ @pytest.mark.asyncio
5116
+ @pytest.mark.parametrize("kind", ["workflow", "activity"])
5117
+ @pytest.mark.parametrize("registration_delay", [0.0, 0.04])
5118
+ async def test_run_until_lock_pressure_retains_original_deadline(
5119
+ self, mock_client: AsyncMock, kind: str, registration_delay: float,
5120
+ ) -> None:
5121
+ worker, poll, body = self.refusal_worker(mock_client, kind)
5122
+ poll.side_effect = ServerError(503, body)
5123
+
5124
+ async def register(**kwargs: object) -> dict[str, object]:
5125
+ await asyncio.sleep(registration_delay)
5126
+ return {"worker_id": "w1", "registered": True}
5127
+
5128
+ mock_client.register_worker.side_effect = register
5129
+ started = asyncio.get_running_loop().time()
5130
+ with pytest.raises(TimeoutError, match="original"):
5131
+ await worker.run_until(workflow_id="original", timeout=0.08, poll_interval=0.01)
5132
+ elapsed = asyncio.get_running_loop().time() - started
5133
+
5134
+ assert 0.06 <= elapsed < 1.0
5135
+ poll.assert_awaited_once()
5136
+ assert worker._stop.is_set()
5137
+ mock_client.deregister_worker_registration.assert_awaited_once_with("w1", expected_namespace="default")
5138
+
5139
+ @pytest.mark.asyncio
5140
+ @pytest.mark.parametrize("kind", ["workflow", "activity"])
5141
+ async def test_run_until_stop_interrupts_lock_backoff(
5142
+ self, mock_client: AsyncMock, kind: str,
5143
+ ) -> None:
5144
+ worker, poll, body = self.refusal_worker(mock_client, kind)
5145
+ entered = asyncio.Event()
5146
+
5147
+ async def refuse(**kwargs: object) -> None:
5148
+ entered.set()
5149
+ raise ServerError(503, body)
5150
+
5151
+ poll.side_effect = refuse
5152
+ running = asyncio.create_task(worker.run_until(workflow_id="original", timeout=5.0))
5153
+ await asyncio.wait_for(entered.wait(), timeout=1.0)
5154
+ await asyncio.sleep(0)
5155
+ await asyncio.wait_for(worker.stop(), timeout=0.5)
5156
+ with pytest.raises(asyncio.CancelledError):
5157
+ await running
5158
+
5159
+ poll.assert_awaited_once()
5160
+ mock_client.complete_workflow_task.assert_not_awaited()
5161
+ mock_client.complete_activity_task.assert_not_awaited()
5162
+ mock_client.deregister_worker_registration.assert_awaited_once_with("w1", expected_namespace="default")
5163
+
5164
+ @pytest.mark.asyncio
5165
+ @pytest.mark.parametrize("kind", ["workflow", "activity"])
5166
+ @pytest.mark.parametrize("change", [
5167
+ {"task_queue": "foreign"}, {"task_kind": "query_task"}, {"task": {"task_id": "claimed"}},
5168
+ {"poll_status": "empty"}, {"retry_after_seconds": True}, {"retry_after_seconds": 61},
5169
+ {"retryable": False}, {"request_admitted": True},
5170
+ ])
5171
+ async def test_run_until_refuses_malformed_or_admitted_poll_response(
5172
+ self, mock_client: AsyncMock, kind: str, change: dict[str, object],
5173
+ ) -> None:
5174
+ worker, poll, body = self.refusal_worker(mock_client, kind)
5175
+ refusal = ServerError(503, {**body, **change})
5176
+ poll.side_effect = refusal
5177
+ with pytest.raises(ServerError) as observed:
5178
+ await worker.run_until(workflow_id="original", timeout=2.0)
5179
+ assert observed.value is refusal
5180
+ poll.assert_awaited_once()
5181
+ mock_client.complete_workflow_task.assert_not_awaited()
5182
+ mock_client.complete_activity_task.assert_not_awaited()
5183
+
5184
+ @pytest.mark.asyncio
5185
+ @pytest.mark.parametrize("kind", ["workflow", "activity"])
5186
+ async def test_run_until_recovers_typed_lock_refusal(
5187
+ self, mock_client: AsyncMock, kind: str,
5188
+ ) -> None:
5189
+ mock_client.namespace = "default"
5190
+ mock_client.get_cluster_info.return_value = compatible_cluster_info(worker_protocol={
5191
+ "version": PROTOCOL_VERSION,
5192
+ "server_capabilities": {"query_tasks": False, "long_poll_timeout": 30},
5193
+ })
5194
+ mock_client.describe_workflow.side_effect = [
5195
+ WorkflowExecution("original", "original-run", "test-wf", "running"),
5196
+ WorkflowExecution("original", "original-run", "test-wf", "running"),
5197
+ WorkflowExecution("original", "original-run", "test-wf", "completed"),
5198
+ ]
5199
+ refusal = ServerError(503, {
5200
+ "task": None, "poll_status": "backend_lock_pressure", "reason": "backend_lock_pressure",
5201
+ "namespace": "default", "task_queue": "orders", "task_kind": kind + "_task",
5202
+ "retry_after_seconds": 1, "backend": {"driver": "sqlite", "lock_pressure": True},
5203
+ })
5204
+ poll = getattr(mock_client, "poll_" + kind + "_task")
5205
+ poll.side_effect = [refusal, None]
5206
+ worker = Worker(
5207
+ mock_client, worker_id="w1", task_queue="orders",
5208
+ workflows=[TestWorkflow] if kind == "workflow" else [],
5209
+ activities=[echo_activity] if kind == "activity" else [],
5210
+ poll_timeout=0.01,
5211
+ max_concurrent_workflow_tasks=1,
5212
+ )
5213
+
5214
+ execution = await worker.run_until(workflow_id="original", timeout=2.0, poll_interval=0.01)
5215
+
5216
+ assert execution.status == "completed"
5217
+ assert execution.run_id == "original-run"
5218
+ assert poll.await_count == 2
5219
+ assert all(call.kwargs["worker_id"] == "w1" for call in poll.await_args_list)
5220
+ assert all(call.kwargs["task_queue"] == "orders" for call in poll.await_args_list)
5221
+ mock_client.register_worker.assert_awaited_once()
5222
+ mock_client.deregister_worker_registration.assert_awaited_once_with("w1", expected_namespace="default")
5223
+ mock_client.complete_workflow_task.assert_not_awaited()
5224
+ mock_client.complete_activity_task.assert_not_awaited()
5225
+
5226
+ @pytest.mark.asyncio
5227
+ @pytest.mark.parametrize("kind", ["workflow", "activity"])
5228
+ @pytest.mark.parametrize("refusal_kind", ["foreign_namespace", "unauthorized"])
5229
+ async def test_run_until_preserves_fatal_or_foreign_poll_refusal(
5230
+ self, mock_client: AsyncMock, kind: str, refusal_kind: str,
5231
+ ) -> None:
5232
+ mock_client.namespace = "default"
5233
+ mock_client.get_cluster_info.return_value = compatible_cluster_info(worker_protocol={
5234
+ "version": PROTOCOL_VERSION,
5235
+ "server_capabilities": {"query_tasks": False, "long_poll_timeout": 30},
5236
+ })
5237
+ mock_client.describe_workflow.return_value = WorkflowExecution(
5238
+ "original", "original-run", "test-wf", "running",
5239
+ )
5240
+ refusal = Unauthorized() if refusal_kind == "unauthorized" else ServerError(503, {
5241
+ "task": None, "poll_status": "backend_lock_pressure", "reason": "backend_lock_pressure",
5242
+ "namespace": "foreign", "task_queue": "orders", "task_kind": kind + "_task",
5243
+ "retry_after_seconds": 1,
5244
+ })
5245
+ poll = getattr(mock_client, "poll_" + kind + "_task")
5246
+ poll.side_effect = refusal
5247
+ worker = Worker(
5248
+ mock_client, worker_id="w1", task_queue="orders",
5249
+ workflows=[TestWorkflow] if kind == "workflow" else [],
5250
+ activities=[echo_activity] if kind == "activity" else [], poll_timeout=0.01,
5251
+ )
5252
+
5253
+ with pytest.raises(type(refusal)) as observed:
5254
+ await worker.run_until(workflow_id="original", timeout=2.0, poll_interval=0.01)
5255
+
5256
+ assert observed.value is refusal
5257
+ poll.assert_awaited_once()
5258
+ mock_client.deregister_worker_registration.assert_awaited_once_with("w1", expected_namespace="default")
5259
+
5032
5260
  @pytest.mark.asyncio
5033
5261
  @pytest.mark.parametrize("has_workflows,has_activities", [(True, False), (False, True), (False, False)])
5034
5262
  async def test_run_until_polls_only_registered_task_families(