durable-workflow 2.11.2__tar.gz → 2.12.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 (167) hide show
  1. {durable_workflow-2.11.2/src/durable_workflow.egg-info → durable_workflow-2.12.1}/PKG-INFO +1 -1
  2. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/pyproject.toml +3 -3
  3. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/__init__.py +2 -1
  4. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/activity.py +61 -2
  5. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/client.py +21 -3
  6. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/sync.py +2 -1
  7. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/worker.py +3 -0
  8. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/workflow.py +15 -1
  9. {durable_workflow-2.11.2 → durable_workflow-2.12.1/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  10. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow.egg-info/SOURCES.txt +1 -0
  11. durable_workflow-2.12.1/tests/test_activity_retry_info.py +82 -0
  12. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_client.py +35 -0
  13. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_cooperative_remote_worker.py +20 -0
  14. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_prepared_local_activity_worker.py +21 -0
  15. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_replay_regression_corpus.py +2 -0
  16. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_sync.py +8 -0
  17. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_wait_condition.py +41 -1
  18. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_worker.py +39 -0
  19. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/LICENSE +0 -0
  20. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/README.md +0 -0
  21. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/setup.cfg +0 -0
  22. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/_activity_lease.py +0 -0
  23. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/_activity_process.py +0 -0
  24. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/_avro.py +0 -0
  25. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/_cancellation_scope.py +0 -0
  26. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/_cancellation_scope_history.py +0 -0
  27. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/_cooperative_cancellation.py +0 -0
  28. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/_failure_reporting.py +0 -0
  29. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/_metric_routes.py +0 -0
  30. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/_poll_transport.py +0 -0
  31. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/_prepared_local_activity.py +0 -0
  32. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/_query_task_authority.py +0 -0
  33. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
  34. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/auth_composition.py +0 -0
  35. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/cancellation.py +0 -0
  36. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/errors.py +0 -0
  37. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/external_storage.py +0 -0
  38. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/external_task_input.py +0 -0
  39. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/external_task_result.py +0 -0
  40. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/history_bundle_verify.py +0 -0
  41. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/interceptors.py +0 -0
  42. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/invocable.py +0 -0
  43. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/metrics.py +0 -0
  44. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/nexus.py +0 -0
  45. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/py.typed +0 -0
  46. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/python_conformance.py +0 -0
  47. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/replay_conformance.py +0 -0
  48. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/replay_verify.py +0 -0
  49. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/retry_policy.py +0 -0
  50. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/serializer.py +0 -0
  51. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/telemetry.py +0 -0
  52. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/telemetry_carrier.py +0 -0
  53. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/testing.py +0 -0
  54. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/worker_session.py +0 -0
  55. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  56. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  57. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  58. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow.egg-info/requires.txt +0 -0
  59. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/src/durable_workflow.egg-info/top_level.txt +0 -0
  60. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_activity_cancellation_policies.py +0 -0
  61. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_activity_context.py +0 -0
  62. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_activity_heartbeat_authority.py +0 -0
  63. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_activity_lease_authority.py +0 -0
  64. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_activity_lease_renewal.py +0 -0
  65. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_activity_lease_worker.py +0 -0
  66. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_activity_process.py +0 -0
  67. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_auth_composition.py +0 -0
  68. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_avro_value_benchmark.py +0 -0
  69. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_avro_value_protocol.py +0 -0
  70. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_cancellation_context.py +0 -0
  71. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_cancellation_remaining_time.py +0 -0
  72. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_cancellation_scope_admission.py +0 -0
  73. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_cancellation_scope_authoring.py +0 -0
  74. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_cancellation_scope_boundary.py +0 -0
  75. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_cancellation_scope_opening.py +0 -0
  76. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_child_workflow_policies.py +0 -0
  77. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_ci_checkout.py +0 -0
  78. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_ci_integration_endpoint.py +0 -0
  79. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_client_telemetry.py +0 -0
  80. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_client_timeouts.py +0 -0
  81. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_codec_regression_corpus.py +0 -0
  82. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_committed_cancellation_scope_descendants.py +0 -0
  83. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_committed_cancellation_scope_groups.py +0 -0
  84. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_committed_cancellation_scope_history.py +0 -0
  85. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_committed_cancellation_scope_replay.py +0 -0
  86. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_condition_replay_drift.py +0 -0
  87. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_control_plane_parity_fixtures.py +0 -0
  88. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_cooperative_cancellation.py +0 -0
  89. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_cooperative_cancellation_client.py +0 -0
  90. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_cooperative_cancellation_worker.py +0 -0
  91. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_docstring_cross_references.py +0 -0
  92. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_durable_selection.py +0 -0
  93. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_errors.py +0 -0
  94. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_external_storage.py +0 -0
  95. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_external_task_input.py +0 -0
  96. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_external_task_result.py +0 -0
  97. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_failure_reporting.py +0 -0
  98. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_golden_history_replay.py +0 -0
  99. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_history_bundle_schema2.py +0 -0
  100. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_history_bundle_verify.py +0 -0
  101. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_history_event_contract.py +0 -0
  102. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_invocable.py +0 -0
  103. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_local_failure_reporting.py +0 -0
  104. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_message_streams.py +0 -0
  105. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_metric_routes.py +0 -0
  106. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_metrics.py +0 -0
  107. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_nexus_durable_wait.py +0 -0
  108. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_nexus_namespace.py +0 -0
  109. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_nexus_wire.py +0 -0
  110. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_order_processing_example.py +0 -0
  111. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_parallel_completion_order_replay.py +0 -0
  112. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_parallel_saga_authoring.py +0 -0
  113. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_patch_history.py +0 -0
  114. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_pending_operation_replay.py +0 -0
  115. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_poll_failure_backoff.py +0 -0
  116. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_poll_transport.py +0 -0
  117. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_prepared_local_activity_client.py +0 -0
  118. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_prepared_local_activity_groups.py +0 -0
  119. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_prepared_local_activity_policies.py +0 -0
  120. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_public_boundary_scanner.py +0 -0
  121. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_pypi_project_surface.py +0 -0
  122. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_python_conformance.py +0 -0
  123. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_queries.py +0 -0
  124. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_query_task_authority.py +0 -0
  125. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_query_timeout_retry.py +0 -0
  126. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_readme_quickstart.py +0 -0
  127. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_redrive_replay.py +0 -0
  128. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_registration_ambiguity.py +0 -0
  129. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_release_metadata.py +0 -0
  130. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_replay.py +0 -0
  131. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_replay_clock.py +0 -0
  132. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_replay_conformance.py +0 -0
  133. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_replay_verify.py +0 -0
  134. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_retry_policy.py +0 -0
  135. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_run_cancellation_scope_replay.py +0 -0
  136. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_runtime_external_payload_transport.py +0 -0
  137. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_schedules.py +0 -0
  138. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_scoped_run_cancellation_context.py +0 -0
  139. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_sdk_metrics_contract.py +0 -0
  140. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_serializer.py +0 -0
  141. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_signals.py +0 -0
  142. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_sleep.py +0 -0
  143. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_standalone_activity_client.py +0 -0
  144. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_sticky_execution.py +0 -0
  145. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_storage_admission.py +0 -0
  146. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_sync_activities.py +0 -0
  147. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_telemetry.py +0 -0
  148. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_telemetry_carrier.py +0 -0
  149. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_testing_failures.py +0 -0
  150. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_testing_harness.py +0 -0
  151. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_testing_nexus.py +0 -0
  152. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_threaded_heartbeat_example.py +0 -0
  153. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_tls_trust.py +0 -0
  154. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_transport_diagnostics.py +0 -0
  155. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_update_signal_condition_replay.py +0 -0
  156. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_updates.py +0 -0
  157. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_worker_deregistration.py +0 -0
  158. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_worker_observation.py +0 -0
  159. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_worker_session.py +0 -0
  160. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_worker_telemetry_wire.py +0 -0
  161. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_workflow_entry_arguments.py +0 -0
  162. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_workflow_entrypoint_validation.py +0 -0
  163. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_workflow_handler_validation.py +0 -0
  164. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_workflow_identity.py +0 -0
  165. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_workflow_result_timeout.py +0 -0
  166. {durable_workflow-2.11.2 → durable_workflow-2.12.1}/tests/test_workflow_streams.py +0 -0
  167. {durable_workflow-2.11.2 → durable_workflow-2.12.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.2
3
+ Version: 2.12.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.2"
7
+ version = "2.12.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.2"
80
- registry-version = "2.11.2"
79
+ product-train = "2.12.1"
80
+ registry-version = "2.12.1"
81
81
  supported-server-versions = "2.8.0"
82
82
  worker-protocol-version = "1.20"
83
83
  control-plane-version = "2"
@@ -7,7 +7,7 @@ except PackageNotFoundError: # source checkout without installed metadata
7
7
  __version__ = "0.0.0+unknown"
8
8
 
9
9
  from . import activity, sync, testing, workflow
10
- from .activity import ActivityContext, ActivityInfo
10
+ from .activity import ActivityContext, ActivityInfo, ActivityRetryInfo
11
11
  from .auth_composition import (
12
12
  AUTH_COMPOSITION_CONTRACT_SCHEMA,
13
13
  AUTH_COMPOSITION_CONTRACT_VERSION,
@@ -248,6 +248,7 @@ __all__ = [
248
248
  "ActivityContext",
249
249
  "ActivityHandler",
250
250
  "ActivityInfo",
251
+ "ActivityRetryInfo",
251
252
  "ActivityInterceptorContext",
252
253
  "ActivityRetryPolicy",
253
254
  "BridgeAdapterOutcome",
@@ -3,8 +3,9 @@ from __future__ import annotations
3
3
 
4
4
  import asyncio
5
5
  import contextvars
6
- from collections.abc import Awaitable, Callable
7
- from dataclasses import dataclass
6
+ import json
7
+ from collections.abc import Awaitable, Callable, Mapping
8
+ from dataclasses import InitVar, dataclass, field
8
9
  from typing import TYPE_CHECKING, Any
9
10
 
10
11
  from ._activity_lease import current_activity_authority
@@ -20,6 +21,53 @@ _current_context: contextvars.ContextVar[ActivityContext | None] = contextvars.C
20
21
  )
21
22
 
22
23
 
24
+ @dataclass(frozen=True, init=False)
25
+ class ActivityRetryInfo:
26
+ """Original attempt and immutable received retry snapshot, without defaults."""
27
+
28
+ attempt_number: int
29
+ policy_provided: bool
30
+ # Immutable JSON also survives the supervised callback's spawn serialization.
31
+ _policy_json: str = field(repr=False)
32
+
33
+ def __init__(self, attempt_number: int, policy_provided: bool = False, policy: Any = None) -> None:
34
+ object.__setattr__(self, "attempt_number", attempt_number)
35
+ object.__setattr__(self, "policy_provided", policy_provided)
36
+ object.__setattr__(self, "_policy_json", json.dumps(policy))
37
+
38
+ @classmethod
39
+ def from_task(cls, attempt_number: int, task: Mapping[str, Any]) -> ActivityRetryInfo:
40
+ return cls(attempt_number, "retry_policy" in task, task.get("retry_policy"))
41
+
42
+ def policy(self) -> Any:
43
+ """Return a fresh copy, including unknown fields and explicit legacy null."""
44
+ return json.loads(self._policy_json)
45
+
46
+ def max_attempts(self) -> int | None:
47
+ """A positive configured limit. An unknown limit does not mean unlimited."""
48
+ policy = self.policy()
49
+ maximum = policy.get("max_attempts") if isinstance(policy, dict) else None
50
+ return maximum if type(maximum) is int and maximum > 0 else None
51
+
52
+ def is_unlimited(self) -> bool:
53
+ """Only an explicit max_attempts:null inside a policy means unlimited."""
54
+ policy = self.policy()
55
+ return isinstance(policy, dict) and "max_attempts" in policy and policy["max_attempts"] is None
56
+
57
+ def is_last_configured_attempt(self) -> bool | None:
58
+ """Check the budget, not whether another execution will actually occur.
59
+
60
+ Cancellation, non-retryable failure, workflow closure and deadlines can
61
+ end execution before the configured budget is exhausted.
62
+ """
63
+ if self.attempt_number < 1:
64
+ return None
65
+ maximum = self.max_attempts()
66
+ if maximum is not None:
67
+ return self.attempt_number >= maximum
68
+ return False if self.is_unlimited() else None
69
+
70
+
23
71
  @dataclass(frozen=True)
24
72
  class ActivityInfo:
25
73
  """Metadata attached to the currently running activity attempt."""
@@ -30,6 +78,17 @@ class ActivityInfo:
30
78
  attempt_number: int
31
79
  task_queue: str
32
80
  worker_id: str
81
+ task_metadata: InitVar[Mapping[str, Any] | None] = None
82
+ retry_info: ActivityRetryInfo = field(init=False)
83
+ workflow_id: str | None = field(init=False)
84
+ run_id: str | None = field(init=False)
85
+
86
+ def __post_init__(self, task_metadata: Mapping[str, Any] | None) -> None:
87
+ task = task_metadata if task_metadata is not None else {}
88
+ object.__setattr__(self, "retry_info", ActivityRetryInfo.from_task(self.attempt_number, task))
89
+ for name in ("workflow_id", "run_id"):
90
+ value = task.get(name)
91
+ object.__setattr__(self, name, value if isinstance(value, str) and value else None)
33
92
 
34
93
 
35
94
  class ActivityContext:
@@ -1386,6 +1386,10 @@ class WorkflowHandle:
1386
1386
  request_id: str | None = None,
1387
1387
  ) -> dict[str, Any]:
1388
1388
  """Deliver an external signal to this workflow. See :meth:`Client.signal_workflow`."""
1389
+ if self.run_id is not None:
1390
+ return await self._client.signal_workflow(
1391
+ self.workflow_id, signal_name, args=args, request_id=request_id, run_id=self.run_id,
1392
+ )
1389
1393
  return await self._client.signal_workflow(
1390
1394
  self.workflow_id, signal_name, args=args, request_id=request_id,
1391
1395
  )
@@ -4461,6 +4465,7 @@ class Client:
4461
4465
  *,
4462
4466
  args: list[Any] | None = None,
4463
4467
  request_id: str | None = None,
4468
+ run_id: str | None = None,
4464
4469
  ) -> dict[str, Any]:
4465
4470
  """Deliver an external signal to a running workflow.
4466
4471
 
@@ -4469,12 +4474,18 @@ class Client:
4469
4474
  observe the signal. See the main docs for how to declare the allowed
4470
4475
  signal names on a workflow type.
4471
4476
 
4477
+ Pass ``run_id`` to target the original run. Omitting it targets the
4478
+ current run when the Server receives the request. A run-pinned
4479
+ :class:`WorkflowHandle` forwards its run identity automatically.
4480
+
4472
4481
  ``request_id`` is the caller command key (1-255 opaque, case-sensitive
4473
- bytes) that makes a retry of this signal safe. When omitted the client
4482
+ bytes). When omitted the client
4474
4483
  generates ``sdk-python-signal-<uuid>`` once per call, so the transport
4475
4484
  retry policy resends the same key. Resending the same key with the same
4476
4485
  signal name, arguments and run target returns the original receipt
4477
- instead of recording a second signal.
4486
+ only when the runtime supports and applies command-key retention.
4487
+ A runtime without that retention can record repeated signals. Keep
4488
+ business-event processing idempotent and reconcile uncertain delivery.
4478
4489
 
4479
4490
  Returns the Server's admission receipt. ``request_id`` is always
4480
4491
  present (the key that was sent); a Server that retains command keys
@@ -4486,6 +4497,12 @@ class Client:
4486
4497
  in its ``details``. Every exception raised after the key was chosen,
4487
4498
  including transport errors, carries it as ``request_id``.
4488
4499
  """
4500
+ if run_id is not None and (not isinstance(run_id, str) or not run_id):
4501
+ raise ValueError("run_id must be a non-empty string when provided")
4502
+ path = f"/workflows/{quote(workflow_id, safe='')}"
4503
+ if run_id is not None:
4504
+ path += f"/runs/{quote(run_id, safe='')}"
4505
+ path += f"/signal/{quote(signal_name, safe='')}"
4489
4506
  key = _command_request_id(request_id, "signal")
4490
4507
  body: dict[str, Any] = {}
4491
4508
  if args:
@@ -4493,12 +4510,13 @@ class Client:
4493
4510
  args,
4494
4511
  kind="signal",
4495
4512
  workflow_id=workflow_id,
4513
+ run_id=run_id,
4496
4514
  signal_name=signal_name,
4497
4515
  )
4498
4516
  body["request_id"] = key
4499
4517
  try:
4500
4518
  data = await self._request(
4501
- "POST", f"/workflows/{workflow_id}/signal/{signal_name}",
4519
+ "POST", path,
4502
4520
  json=self._with_observability_context(body), context=workflow_id,
4503
4521
  )
4504
4522
  except Exception as exc:
@@ -535,9 +535,10 @@ class Client:
535
535
  *,
536
536
  args: list[Any] | None = None,
537
537
  request_id: str | None = None,
538
+ run_id: str | None = None,
538
539
  ) -> dict[str, Any]:
539
540
  result: dict[str, Any] = self._runner.run(
540
- self._async.signal_workflow(workflow_id, signal_name, args=args, request_id=request_id)
541
+ self._async.signal_workflow(workflow_id, signal_name, args=args, request_id=request_id, run_id=run_id)
541
542
  )
542
543
  return result
543
544
 
@@ -2105,6 +2105,7 @@ class Worker:
2105
2105
  handler, tuple(call.command.arguments), ActivityInfo(
2106
2106
  task_id, call.command.activity_type, attempt.attempt_id, attempt.attempt_number,
2107
2107
  self.task_queue, self.worker_id,
2108
+ task_metadata={"workflow_id": task.get("workflow_id"), "run_id": task.get("run_id")},
2108
2109
  ), {**task, "activity_attempt_id": attempt.attempt_id}, self.interceptors,
2109
2110
  namespace=getattr(self.client, "namespace", None),
2110
2111
  execution_context=telemetry.capture() if telemetry is not None else None,
@@ -2462,6 +2463,7 @@ class Worker:
2462
2463
  attempt_number=attempt_number,
2463
2464
  task_queue=self.task_queue,
2464
2465
  worker_id=self.worker_id,
2466
+ task_metadata={"workflow_id": task.get("workflow_id"), "run_id": task.get("run_id")},
2465
2467
  )
2466
2468
  _set_context(ActivityContext(info=info, client=self.client, heartbeat_callback=heartbeat))
2467
2469
  try:
@@ -3196,6 +3198,7 @@ class Worker:
3196
3198
  attempt_number=attempt_number,
3197
3199
  task_queue=self.task_queue,
3198
3200
  worker_id=self.worker_id,
3201
+ task_metadata=task,
3199
3202
  ),
3200
3203
  client=self.client,
3201
3204
  )
@@ -4661,6 +4661,7 @@ def _replay_state(
4661
4661
  # Receiver replay uses this exact boundary so interleaved input is applied
4662
4662
  # in durable order without leaking input recorded after the terminal row.
4663
4663
  condition_wait_terminal_indexes: dict[str, int] = {}
4664
+ condition_wait_opened_indexes: dict[str, int] = {}
4664
4665
  # Selection members are bound by their durable sequence instead of being
4665
4666
  # inserted into the ordinary completion-order cursor. This is what lets a
4666
4667
  # loser complete before or after unrelated successor work without changing
@@ -5006,6 +5007,7 @@ def _replay_state(
5006
5007
  wait_id = payload.get("condition_wait_id")
5007
5008
  if not isinstance(wait_id, str) or not wait_id:
5008
5009
  continue
5010
+ condition_wait_opened_indexes.setdefault(wait_id, index)
5009
5011
 
5010
5012
  if current_wait_id is None:
5011
5013
  for receiver_index in prefix_receivers:
@@ -5070,7 +5072,14 @@ def _replay_state(
5070
5072
 
5071
5073
  for receiver_index in receivers_since_wait:
5072
5074
  if receiver_index is not None and receiver_index not in bindings:
5073
- bindings[receiver_index] = current_wait_id
5075
+ if boundary_kind == "condition":
5076
+ # A terminal row can precede the next physical reopen.
5077
+ # Later inputs admitted during the first receiver's
5078
+ # leased replay belong to that reopen, not the wait
5079
+ # already resolved by the first receiver.
5080
+ prefix_receivers.append(receiver_index)
5081
+ else:
5082
+ bindings[receiver_index] = current_wait_id
5074
5083
  current_wait_id = None
5075
5084
  receivers_since_wait = []
5076
5085
  prefix_can_bind_to_first_wait = boundary_kind == "condition"
@@ -5431,6 +5440,11 @@ def _replay_state(
5431
5440
  terminal_index = condition_wait_terminal_indexes.get(condition_wait_id)
5432
5441
  if terminal_index is not None:
5433
5442
  while pending_receivers and pending_receivers[0].history_index < terminal_index:
5443
+ receiver_wait_id = pending_receivers[0].condition_wait_id
5444
+ receiver_wait_index = (condition_wait_opened_indexes.get(receiver_wait_id)
5445
+ if receiver_wait_id is not None else None)
5446
+ if receiver_wait_index is not None and receiver_wait_index > terminal_index:
5447
+ break
5434
5448
  _apply_receiver(pending_receivers.pop(0))
5435
5449
  return
5436
5450
  target_index = next(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.11.2
3
+ Version: 2.12.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
@@ -54,6 +54,7 @@ tests/test_activity_lease_authority.py
54
54
  tests/test_activity_lease_renewal.py
55
55
  tests/test_activity_lease_worker.py
56
56
  tests/test_activity_process.py
57
+ tests/test_activity_retry_info.py
57
58
  tests/test_auth_composition.py
58
59
  tests/test_avro_value_benchmark.py
59
60
  tests/test_avro_value_protocol.py
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ import pickle
4
+ from dataclasses import FrozenInstanceError
5
+ from typing import Any
6
+
7
+ import pytest
8
+
9
+ from durable_workflow import ActivityInfo, ActivityRetryInfo, activity
10
+
11
+
12
+ @activity.defn(name="context.metadata")
13
+ def inspect_activity_context(_marker: str | None = None) -> dict[str, Any]:
14
+ info = activity.context().info
15
+ retry = info.retry_info
16
+ return {"workflow_id": info.workflow_id, "run_id": info.run_id, "attempt": retry.attempt_number,
17
+ "policy_provided": retry.policy_provided, "policy": retry.policy(), "maximum": retry.max_attempts(),
18
+ "unlimited": retry.is_unlimited(), "last": retry.is_last_configured_attempt()}
19
+
20
+
21
+ @pytest.mark.parametrize(("attempt", "task", "maximum", "unlimited", "last"), [
22
+ (1, {"retry_policy": {"max_attempts": 3}}, 3, False, False),
23
+ (3, {"retry_policy": {"max_attempts": 3}}, 3, False, True),
24
+ (4, {"retry_policy": {"max_attempts": 3}}, 3, False, True),
25
+ (1, {"retry_policy": {"max_attempts": 1}}, 1, False, True),
26
+ (1, {}, None, False, None),
27
+ (1, {"retry_policy": None}, None, False, None),
28
+ (99, {"retry_policy": {"max_attempts": None}}, None, True, False),
29
+ (1, {"retry_policy": {"future": [True, 2.0]}}, None, False, None),
30
+ (1, {"retry_policy": {"max_attempts": "3"}}, None, False, None),
31
+ (1, {"retry_policy": {"max_attempts": True}}, None, False, None),
32
+ (1, {"retry_policy": {"max_attempts": 3.0}}, None, False, None),
33
+ (1, {"retry_policy": {"max_attempts": 0}}, None, False, None),
34
+ (1, {"retry_policy": "legacy"}, None, False, None),
35
+ (0, {"retry_policy": {"max_attempts": 1}}, 1, False, None),
36
+ ])
37
+ def test_received_budget_preserves_unknown_and_unlimited(
38
+ attempt: int, task: dict[str, Any], maximum: int | None, unlimited: bool, last: bool | None,
39
+ ) -> None:
40
+ retry = ActivityRetryInfo.from_task(attempt, task)
41
+ assert retry.attempt_number == attempt
42
+ assert retry.policy_provided is ("retry_policy" in task)
43
+ assert retry.policy() == task.get("retry_policy")
44
+ assert retry.max_attempts() == maximum
45
+ assert retry.is_unlimited() is unlimited
46
+ assert retry.is_last_configured_attempt() is last
47
+
48
+
49
+ def test_snapshot_copies_and_original_identity_survive_spawn_serialization() -> None:
50
+ task = {"workflow_id": "0", "run_id": "017", "retry_policy": {"max_attempts": 3, "future": [True, 2.0, None]}}
51
+ info = ActivityInfo("task", "activity", "attempt", 3, "queue", "owner", task_metadata=task)
52
+ task["retry_policy"]["max_attempts"] = 10
53
+ task["workflow_id"] = "replacement"
54
+ copy = info.retry_info.policy()
55
+ copy["future"][0] = False
56
+ assert info.retry_info.policy() == {"max_attempts": 3, "future": [True, 2.0, None]}
57
+ assert info.workflow_id == "0" and info.run_id == "017"
58
+ assert info.retry_info.is_last_configured_attempt() is True
59
+ restored = pickle.loads(pickle.dumps(info))
60
+ assert restored == info
61
+ assert restored.retry_info.policy() == info.retry_info.policy()
62
+ with pytest.raises(FrozenInstanceError):
63
+ info.workflow_id = "replacement" # type: ignore[misc]
64
+ with pytest.raises(FrozenInstanceError):
65
+ info.retry_info.attempt_number = 100 # type: ignore[misc]
66
+
67
+
68
+ def test_legacy_constructor_has_no_invented_received_policy_or_identity() -> None:
69
+ info = ActivityInfo("task", "activity", "attempt", 2, "queue", "owner")
70
+ assert info.retry_info.attempt_number == 2
71
+ assert info.retry_info.policy_provided is False
72
+ assert info.retry_info.is_last_configured_attempt() is None
73
+ assert info.workflow_id is None and info.run_id is None
74
+
75
+
76
+ @pytest.mark.parametrize("task", [
77
+ {}, {"workflow_id": None, "run_id": None}, {"workflow_id": "", "run_id": 42},
78
+ {"workflow_instance_id": "internal", "workflow_run_id": "internal-run"},
79
+ ])
80
+ def test_public_claim_identity_stays_unknown_when_missing_or_invalid(task: dict[str, Any]) -> None:
81
+ info = ActivityInfo("task", "activity", "attempt", 1, "queue", "owner", task_metadata=task)
82
+ assert info.workflow_id is None and info.run_id is None
@@ -869,6 +869,41 @@ class TestWorkflowHandleControlPlane:
869
869
 
870
870
 
871
871
  class TestSignalWorkflow:
872
+ @pytest.mark.asyncio
873
+ @pytest.mark.parametrize("use_handle", [False, True])
874
+ async def test_signal_keeps_original_run_and_business_key(self, client: Client, use_handle: bool) -> None:
875
+ seen = []
876
+
877
+ def receive(request: httpx.Request) -> httpx.Response:
878
+ seen.append(request)
879
+ return httpx.Response(202, json={"outcome": "received"})
880
+
881
+ await client._http.aclose()
882
+ client._http = httpx.AsyncClient(base_url=client.base_url, transport=httpx.MockTransport(receive))
883
+ async with client:
884
+ if use_handle:
885
+ handle = client.get_workflow_handle("invoice-42", run_id="original-run")
886
+ await handle.signal("payment_recorded", ["payment-17"], request_id="payment:17")
887
+ else:
888
+ await client.signal_workflow("invoice-42", "payment_recorded", args=["payment-17"],
889
+ run_id="original-run", request_id="payment:17")
890
+ assert len(seen) == 1
891
+ assert seen[0].url.path == "/api/workflows/invoice-42/runs/original-run/signal/payment_recorded"
892
+ body = json.loads(seen[0].content)
893
+ assert body["request_id"] == "payment:17"
894
+ assert serializer.decode_envelope(body["input"]) == ["payment-17"]
895
+
896
+ @pytest.mark.asyncio
897
+ @pytest.mark.parametrize("run_id", ["", False, 1])
898
+ async def test_invalid_run_pin_never_falls_back_to_current(self, client: Client, run_id: Any) -> None:
899
+ with (
900
+ patch.object(client._http, "request", new_callable=AsyncMock) as request,
901
+ pytest.raises(ValueError, match="run_id"),
902
+ ):
903
+ await client.signal_workflow("invoice-42", "payment_recorded", run_id=run_id)
904
+ request.assert_not_awaited()
905
+ await client.aclose()
906
+
872
907
  @pytest.mark.asyncio
873
908
  async def test_signal(self, client: Client) -> None:
874
909
  resp = _mock_response(200, {"ok": True})
@@ -19,6 +19,7 @@ from durable_workflow.errors import ActivityCancelled, NonRetryableError, Server
19
19
  from durable_workflow.retry_policy import TransportRetryPolicy
20
20
  from durable_workflow.worker import Worker
21
21
  from tests.test_activity_process import wait_for_exit, wait_for_file
22
+ from tests.test_activity_retry_info import inspect_activity_context
22
23
  from tests.test_worker import compatible_cluster_info
23
24
 
24
25
 
@@ -205,6 +206,25 @@ async def test_authored_heartbeat_stays_on_owner_loop_and_preserves_typed_result
205
206
  client.fail_activity_task.assert_not_awaited()
206
207
 
207
208
 
209
+ @pytest.mark.parametrize("configured", [False, True])
210
+ async def test_supervised_worker_preserves_received_context(owner: Any, configured: bool) -> None:
211
+ worker, client = owner
212
+ worker.activities["remote"] = inspect_activity_context
213
+ work = task()
214
+ if configured:
215
+ work.update(attempt_number=3, workflow_id="0", run_id="017",
216
+ retry_policy={"max_attempts": 3, "future": [True, None]})
217
+ assert await worker._run_activity_task(work) == "completed"
218
+ client.fail_activity_task.assert_not_awaited()
219
+ assert client.complete_activity_task.await_args.kwargs["result"] == {
220
+ "workflow_id": "0" if configured else None, "run_id": "017" if configured else None,
221
+ "attempt": 3 if configured else 1, "policy_provided": configured,
222
+ "policy": {"max_attempts": 3, "future": [True, None]} if configured else None,
223
+ "maximum": 3 if configured else None, "unlimited": False, "last": True if configured else None,
224
+ }
225
+ assert not worker._remote_activity_processes
226
+
227
+
208
228
  @pytest.mark.parametrize("field", ["lease", "heartbeat", "start_to_close", "schedule_to_close", "session", "malformed"])
209
229
  async def test_invalid_or_elapsed_observed_bounds_prevent_callback_start(owner: Any, field: str) -> None:
210
230
  worker, client = owner
@@ -20,6 +20,7 @@ from durable_workflow.errors import WorkflowCancelled
20
20
  from durable_workflow.worker import Worker
21
21
  from durable_workflow.workflow import LocalActivityExecutionAborted, replay
22
22
  from tests.test_activity_process import blocked_without_python_progress, wait_for_exit, wait_for_file, wait_for_release
23
+ from tests.test_activity_retry_info import inspect_activity_context
23
24
  from tests.test_cancellation_context import delivery, request, snapshot
24
25
  from tests.test_replay_regression_corpus import PreparedLocalColdResultsWorkflow
25
26
  from tests.test_worker import compatible_cluster_info
@@ -415,6 +416,26 @@ async def test_worker_runs_only_an_admitted_process_then_replays_canonical_resul
415
416
  await wait_for_exit(int((tmp_path / "callback").read_text()))
416
417
 
417
418
 
419
+ async def test_prepared_local_activity_preserves_parent_context_only(
420
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
421
+ ) -> None:
422
+ server = PreparedServer()
423
+ worker = await server.worker(monkeypatch, handler=inspect_activity_context)
424
+ task = server.task(tmp_path / "unused")
425
+ task["retry_policy"] = {"max_attempts": 9}
426
+ try:
427
+ commands = await worker._run_workflow_task(task)
428
+ assert commands is not None and [command["type"] for command in commands] == ["complete_workflow"]
429
+ assert serializer.decode_envelope(commands[0]["result"]) == {
430
+ "workflow_id": "workflow-1", "run_id": "run-1", "attempt": 1, "policy_provided": False,
431
+ "policy": None, "maximum": None, "unlimited": False, "last": None,
432
+ }
433
+ server.client.fail_workflow_task.assert_not_awaited()
434
+ assert not worker._prepared_local_activity_processes
435
+ finally:
436
+ await worker.stop()
437
+
438
+
418
439
  async def test_bad_admission_never_spawns_and_never_completes_or_fails_a_claim(
419
440
  monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
420
441
  ) -> None:
@@ -47,6 +47,7 @@ from tests.test_update_signal_condition_replay import (
47
47
  PostConditionReceiversWorkflow,
48
48
  UpdateSignalConditionTimerWorkflow,
49
49
  )
50
+ from tests.test_wait_condition import SignalCounterUntilFinished
50
51
  from tests.test_worker import compatible_cluster_info
51
52
  from tests.test_workflow_identity import IdentityAcrossReplay, IdentityContinueAsNew
52
53
 
@@ -375,6 +376,7 @@ WORKFLOWS = [
375
376
  FencedRecoveryWorkflow,
376
377
  PagedRecordedSideEffectsWorkflow,
377
378
  PostConditionReceiversWorkflow,
379
+ SignalCounterUntilFinished,
378
380
  RecordedSideEffectWorkflow,
379
381
  SelectionAwaitMarkerWorkflow,
380
382
  SelectionCancellationQueryWorkflow,
@@ -221,6 +221,14 @@ class TestSyncClientDescribe:
221
221
 
222
222
 
223
223
  class TestSyncClientSignal:
224
+ def test_signal_pins_the_original_run(self) -> None:
225
+ with Client("http://localhost:8080") as client:
226
+ with patch.object(client._async._http, "request", new_callable=AsyncMock,
227
+ return_value=_mock_response(202, {"outcome": "received"})) as request:
228
+ client.signal_workflow("invoice-42", "payment_recorded", run_id="original-run", request_id="payment:17")
229
+ assert request.call_args.args[1] == "/api/workflows/invoice-42/runs/original-run/signal/payment_recorded"
230
+ assert request.call_args.kwargs["json"]["request_id"] == "payment:17"
231
+
224
232
  def test_signal(self) -> None:
225
233
  client = Client("http://localhost:8080")
226
234
  resp = _mock_response(202, {"outcome": "received"})
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  import pytest
4
4
 
5
5
  from durable_workflow import serializer, workflow
6
- from durable_workflow.errors import NonDeterministicReplayError
6
+ from durable_workflow.errors import NonDeterministicReplayError, QueryFailed
7
7
  from durable_workflow.workflow import (
8
8
  CompleteWorkflow,
9
9
  WaitCondition,
@@ -567,6 +567,46 @@ class TestReplayWaitCondition:
567
567
  assert outcome.commands[0].result == expected
568
568
  assert query_state(SignalCounterUntilFinished, history, [], "current") == expected
569
569
 
570
+ @pytest.mark.parametrize("receiver_sequence", [None, 1])
571
+ @pytest.mark.parametrize("changed_identity", [None, "condition_key", "condition_definition_fingerprint"])
572
+ def test_signal_during_condition_completion_keeps_its_physical_reopen(
573
+ self, receiver_sequence: int | None, changed_identity: str | None,
574
+ ) -> None:
575
+ history = [
576
+ {"event_type": "ConditionWaitOpened", "payload": {
577
+ "condition_wait_id": "wait-first", "condition_key": "done", "sequence": 1,
578
+ }},
579
+ _signal_received_event("increment", [3], workflow_sequence=receiver_sequence),
580
+ _signal_received_event("finish", [], workflow_sequence=receiver_sequence),
581
+ {"event_type": "SignalApplied", "payload": {"signal_name": "increment"}},
582
+ {"event_type": "ConditionWaitSatisfied", "payload": {
583
+ "condition_wait_id": "wait-first", "condition_key": "done", "sequence": 1,
584
+ }},
585
+ {"event_type": "ConditionWaitOpened", "payload": {
586
+ "condition_wait_id": "wait-reopened", "condition_key": "done", "sequence": 2,
587
+ }},
588
+ ]
589
+
590
+ if changed_identity is not None:
591
+ history[-1]["payload"][changed_identity] = "changed-definition"
592
+ with pytest.raises(NonDeterministicReplayError, match="ConditionWaitOpened") as captured:
593
+ replay(SignalCounterUntilFinished, history, [])
594
+ assert captured.value.workflow_sequence == 2
595
+ with pytest.raises(QueryFailed, match="ConditionWaitOpened") as query_error:
596
+ query_state(SignalCounterUntilFinished, history, [], "current")
597
+ assert isinstance(query_error.value.__cause__, NonDeterministicReplayError)
598
+ assert query_error.value.__cause__.workflow_sequence == 2
599
+ return
600
+
601
+ expected = {"count": 3, "done": True, "events": [
602
+ {"signal": "increment", "amount": 3, "count": 3}, {"signal": "finish", "count": 3},
603
+ ]}
604
+ outcome = replay(SignalCounterUntilFinished, history, [])
605
+ assert len(outcome.commands) == 1
606
+ assert isinstance(outcome.commands[0], CompleteWorkflow)
607
+ assert outcome.commands[0].result == expected
608
+ assert query_state(SignalCounterUntilFinished, history, [], "current") == expected
609
+
570
610
  def test_signal_received_before_reopened_wait_uses_next_wait_when_sequence_absent(self) -> None:
571
611
  history = [
572
612
  {
@@ -48,6 +48,7 @@ from durable_workflow.worker import (
48
48
  _should_fail_workflow_task_after_completion_error,
49
49
  )
50
50
  from tests.query_task_helpers import leased_query_task
51
+ from tests.test_activity_retry_info import inspect_activity_context
51
52
 
52
53
 
53
54
  class TypedCancelFlightError(Exception):
@@ -1341,6 +1342,27 @@ class TestWorkflowTaskExecution:
1341
1342
  assert [command["type"] for command in replayed] == ["complete_workflow"]
1342
1343
  mock_client.heartbeat_workflow_task.assert_not_awaited()
1343
1344
 
1345
+ @pytest.mark.asyncio
1346
+ async def test_inline_local_activity_preserves_parent_context_only(self, mock_client: AsyncMock) -> None:
1347
+ mock_client.heartbeat_workflow_task.return_value = {
1348
+ "task_id": "local-task", "lease_owner": "local-worker", "workflow_task_attempt": 1, "renewed": True,
1349
+ }
1350
+ worker = Worker(mock_client, task_queue="q1", worker_id="local-worker",
1351
+ workflows=[LocalWorkerWorkflow], activities=[local_echo])
1352
+ worker.activities["local.echo"] = inspect_activity_context
1353
+ commands = await worker._run_workflow_task({
1354
+ "task_id": "local-task", "workflow_type": "local-worker-wf", "workflow_task_attempt": 1,
1355
+ "workflow_id": "0", "run_id": "017", "retry_policy": {"max_attempts": 9},
1356
+ "history_events": [], "arguments": serializer.envelope(["ignored"]), "payload_codec": "avro",
1357
+ })
1358
+ assert commands is not None
1359
+ assert [command["type"] for command in commands] == ["record_local_activity", "complete_workflow"]
1360
+ assert serializer.decode_envelope(commands[-1]["result"]) == {
1361
+ "workflow_id": "0", "run_id": "017", "attempt": 1, "policy_provided": False,
1362
+ "policy": None, "maximum": None, "unlimited": False, "last": None,
1363
+ }
1364
+ mock_client.fail_workflow_task.assert_not_awaited()
1365
+
1344
1366
  @pytest.mark.asyncio
1345
1367
  async def test_local_activity_lease_refusal_never_completes_task(self, mock_client: AsyncMock) -> None:
1346
1368
  mock_client.heartbeat_workflow_task.return_value = {
@@ -3316,6 +3338,23 @@ class TestUpdateValidationTaskExecution:
3316
3338
 
3317
3339
 
3318
3340
  class TestActivityTaskExecution:
3341
+ @pytest.mark.parametrize("configured", [False, True])
3342
+ async def test_ordinary_worker_preserves_received_context(self, mock_client: AsyncMock, configured: bool) -> None:
3343
+ worker = Worker(mock_client, task_queue="q1", activities=[inspect_activity_context])
3344
+ metadata = {"attempt_number": 3, "workflow_id": "0", "run_id": "017",
3345
+ "retry_policy": {"max_attempts": 3, "future": [True, None]}} if configured else {}
3346
+ task = {"task_id": "metadata-task", "activity_attempt_id": "metadata-attempt",
3347
+ "activity_type": "context.metadata", "arguments": serializer.envelope([]), "payload_codec": "avro",
3348
+ **metadata}
3349
+ assert await worker._run_activity_task(task) == "completed"
3350
+ mock_client.fail_activity_task.assert_not_awaited()
3351
+ assert mock_client.complete_activity_task.await_args.kwargs["result"] == {
3352
+ "workflow_id": "0" if configured else None, "run_id": "017" if configured else None,
3353
+ "attempt": 3 if configured else 1, "policy_provided": configured,
3354
+ "policy": {"max_attempts": 3, "future": [True, None]} if configured else None,
3355
+ "maximum": 3 if configured else None, "unlimited": False, "last": True if configured else None,
3356
+ }
3357
+
3319
3358
  @pytest.mark.asyncio
3320
3359
  async def test_sync_activity(self, mock_client: AsyncMock) -> None:
3321
3360
  worker = Worker(mock_client, task_queue="q1", workflows=[], activities=[echo_activity])