durable-workflow 2.8.0__tar.gz → 2.9.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 (151) hide show
  1. {durable_workflow-2.8.0/src/durable_workflow.egg-info → durable_workflow-2.9.1}/PKG-INFO +2 -1
  2. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/pyproject.toml +4 -3
  3. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow/workflow.py +17 -0
  4. {durable_workflow-2.8.0 → durable_workflow-2.9.1/src/durable_workflow.egg-info}/PKG-INFO +2 -1
  5. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow.egg-info/SOURCES.txt +2 -0
  6. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow.egg-info/requires.txt +1 -0
  7. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_replay_regression_corpus.py +5 -0
  8. durable_workflow-2.9.1/tests/test_threaded_heartbeat_example.py +125 -0
  9. durable_workflow-2.9.1/tests/test_workflow_identity.py +72 -0
  10. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/LICENSE +0 -0
  11. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/README.md +0 -0
  12. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/setup.cfg +0 -0
  13. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow/__init__.py +0 -0
  14. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow/_activity_lease.py +0 -0
  15. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow/_activity_process.py +0 -0
  16. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow/_avro.py +0 -0
  17. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow/_cancellation_scope.py +0 -0
  18. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow/_cancellation_scope_history.py +0 -0
  19. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow/_cooperative_cancellation.py +0 -0
  20. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow/_prepared_local_activity.py +0 -0
  21. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow/_query_task_authority.py +0 -0
  22. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
  23. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow/activity.py +0 -0
  24. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow/auth_composition.py +0 -0
  25. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow/cancellation.py +0 -0
  26. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow/client.py +0 -0
  27. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow/errors.py +0 -0
  28. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow/external_storage.py +0 -0
  29. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow/external_task_input.py +0 -0
  30. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow/external_task_result.py +0 -0
  31. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow/history_bundle_verify.py +0 -0
  32. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow/interceptors.py +0 -0
  33. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow/invocable.py +0 -0
  34. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow/metrics.py +0 -0
  35. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow/nexus.py +0 -0
  36. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow/py.typed +0 -0
  37. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow/python_conformance.py +0 -0
  38. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow/replay_conformance.py +0 -0
  39. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow/replay_verify.py +0 -0
  40. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow/retry_policy.py +0 -0
  41. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow/serializer.py +0 -0
  42. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow/sync.py +0 -0
  43. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow/telemetry.py +0 -0
  44. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow/telemetry_carrier.py +0 -0
  45. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow/testing.py +0 -0
  46. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow/worker.py +0 -0
  47. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow/worker_session.py +0 -0
  48. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  49. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  50. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  51. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/src/durable_workflow.egg-info/top_level.txt +0 -0
  52. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_activity_cancellation_policies.py +0 -0
  53. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_activity_context.py +0 -0
  54. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_activity_heartbeat_authority.py +0 -0
  55. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_activity_lease_authority.py +0 -0
  56. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_activity_lease_renewal.py +0 -0
  57. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_activity_lease_worker.py +0 -0
  58. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_activity_process.py +0 -0
  59. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_auth_composition.py +0 -0
  60. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_avro_value_benchmark.py +0 -0
  61. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_avro_value_protocol.py +0 -0
  62. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_cancellation_context.py +0 -0
  63. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_cancellation_remaining_time.py +0 -0
  64. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_cancellation_scope_admission.py +0 -0
  65. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_cancellation_scope_authoring.py +0 -0
  66. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_cancellation_scope_boundary.py +0 -0
  67. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_cancellation_scope_opening.py +0 -0
  68. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_child_workflow_policies.py +0 -0
  69. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_ci_checkout.py +0 -0
  70. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_ci_integration_endpoint.py +0 -0
  71. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_client.py +0 -0
  72. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_client_telemetry.py +0 -0
  73. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_client_timeouts.py +0 -0
  74. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_codec_regression_corpus.py +0 -0
  75. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_committed_cancellation_scope_descendants.py +0 -0
  76. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_committed_cancellation_scope_groups.py +0 -0
  77. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_committed_cancellation_scope_history.py +0 -0
  78. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_committed_cancellation_scope_replay.py +0 -0
  79. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_condition_replay_drift.py +0 -0
  80. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_control_plane_parity_fixtures.py +0 -0
  81. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_cooperative_cancellation.py +0 -0
  82. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_cooperative_cancellation_client.py +0 -0
  83. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_cooperative_cancellation_worker.py +0 -0
  84. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_cooperative_remote_worker.py +0 -0
  85. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_docstring_cross_references.py +0 -0
  86. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_durable_selection.py +0 -0
  87. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_errors.py +0 -0
  88. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_external_storage.py +0 -0
  89. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_external_task_input.py +0 -0
  90. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_external_task_result.py +0 -0
  91. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_golden_history_replay.py +0 -0
  92. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_history_bundle_verify.py +0 -0
  93. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_history_event_contract.py +0 -0
  94. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_invocable.py +0 -0
  95. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_message_streams.py +0 -0
  96. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_metrics.py +0 -0
  97. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_nexus_durable_wait.py +0 -0
  98. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_nexus_namespace.py +0 -0
  99. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_nexus_wire.py +0 -0
  100. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_order_processing_example.py +0 -0
  101. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_parallel_completion_order_replay.py +0 -0
  102. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_parallel_saga_authoring.py +0 -0
  103. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_patch_history.py +0 -0
  104. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_pending_operation_replay.py +0 -0
  105. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_prepared_local_activity_client.py +0 -0
  106. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_prepared_local_activity_groups.py +0 -0
  107. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_prepared_local_activity_policies.py +0 -0
  108. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_prepared_local_activity_worker.py +0 -0
  109. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_public_boundary_scanner.py +0 -0
  110. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_pypi_project_surface.py +0 -0
  111. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_python_conformance.py +0 -0
  112. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_queries.py +0 -0
  113. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_query_task_authority.py +0 -0
  114. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_query_timeout_retry.py +0 -0
  115. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_readme_quickstart.py +0 -0
  116. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_redrive_replay.py +0 -0
  117. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_registration_ambiguity.py +0 -0
  118. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_release_metadata.py +0 -0
  119. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_replay.py +0 -0
  120. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_replay_clock.py +0 -0
  121. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_replay_conformance.py +0 -0
  122. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_replay_verify.py +0 -0
  123. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_retry_policy.py +0 -0
  124. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_run_cancellation_scope_replay.py +0 -0
  125. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_runtime_external_payload_transport.py +0 -0
  126. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_schedules.py +0 -0
  127. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_scoped_run_cancellation_context.py +0 -0
  128. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_serializer.py +0 -0
  129. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_signals.py +0 -0
  130. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_sleep.py +0 -0
  131. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_standalone_activity_client.py +0 -0
  132. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_sticky_execution.py +0 -0
  133. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_storage_admission.py +0 -0
  134. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_sync.py +0 -0
  135. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_sync_activities.py +0 -0
  136. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_telemetry.py +0 -0
  137. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_telemetry_carrier.py +0 -0
  138. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_testing_harness.py +0 -0
  139. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_update_signal_condition_replay.py +0 -0
  140. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_updates.py +0 -0
  141. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_wait_condition.py +0 -0
  142. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_worker.py +0 -0
  143. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_worker_observation.py +0 -0
  144. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_worker_session.py +0 -0
  145. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_worker_telemetry_wire.py +0 -0
  146. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_workflow_entry_arguments.py +0 -0
  147. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_workflow_entrypoint_validation.py +0 -0
  148. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_workflow_handler_validation.py +0 -0
  149. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_workflow_result_timeout.py +0 -0
  150. {durable_workflow-2.8.0 → durable_workflow-2.9.1}/tests/test_workflow_streams.py +0 -0
  151. {durable_workflow-2.8.0 → durable_workflow-2.9.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.8.0
3
+ Version: 2.9.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
@@ -20,6 +20,7 @@ Requires-Python: >=3.10
20
20
  Description-Content-Type: text/markdown
21
21
  License-File: LICENSE
22
22
  Requires-Dist: httpx>=0.27
23
+ Requires-Dist: certifi>=2026.10.19
23
24
  Requires-Dist: fastavro<2,>=1.12.2
24
25
  Provides-Extra: telemetry
25
26
  Requires-Dist: opentelemetry-api<2,>=1.45.1; extra == "telemetry"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "durable-workflow"
7
- version = "2.8.0"
7
+ version = "2.9.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"
@@ -34,6 +34,7 @@ classifiers = [
34
34
  ]
35
35
  dependencies = [
36
36
  "httpx>=0.27",
37
+ "certifi>=2026.10.19",
37
38
  "fastavro>=1.12.2,<2",
38
39
  ]
39
40
 
@@ -75,8 +76,8 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main"
75
76
  durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main"
76
77
 
77
78
  [tool.durable-workflow]
78
- product-train = "2.8.0"
79
- registry-version = "2.8.0"
79
+ product-train = "2.9.1"
80
+ registry-version = "2.9.1"
80
81
  supported-server-versions = "2.8.0"
81
82
  worker-protocol-version = "1.20"
82
83
  control-plane-version = "2"
@@ -316,6 +316,13 @@ class ActivityRetryPolicy:
316
316
  budget for an already-scheduled activity. It is a server-side durable
317
317
  retry policy, not the SDK HTTP transport retry policy.
318
318
 
319
+ ``initial_interval_seconds`` and ``maximum_interval_seconds`` accept
320
+ fractional seconds. Each generated interval is capped first, then rounded
321
+ up to a whole second for the durable command. For example, an initial
322
+ interval of ``0.1`` becomes one second. The explicit ``backoff_seconds``
323
+ list instead takes integer seconds. This resolution does not guarantee
324
+ when a worker will start the next attempt.
325
+
319
326
  ``non_retryable_error_types`` names failure types that should bypass this
320
327
  retry budget. An activity worker can also report ``non_retryable=True`` on
321
328
  a failure to stop retrying that activity execution.
@@ -2056,6 +2063,16 @@ class WorkflowContext:
2056
2063
  self._workflow_stream_command_counter = 0
2057
2064
  self.logger = _ReplayLogger(_REPLAY_LOGGER)
2058
2065
 
2066
+ @property
2067
+ def workflow_id(self) -> str:
2068
+ """Original workflow identity, retained across continue-as-new runs."""
2069
+ return self._workflow_id
2070
+
2071
+ @property
2072
+ def run_id(self) -> str:
2073
+ """Current run identity, stable on replay and new after continue-as-new."""
2074
+ return self._run_id
2075
+
2059
2076
  def cancellation_scope(
2060
2077
  self, body: Callable[[], Any], *, shield_parent: bool = False,
2061
2078
  ) -> Generator[Any, Any, Any]:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.8.0
3
+ Version: 2.9.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
@@ -20,6 +20,7 @@ Requires-Python: >=3.10
20
20
  Description-Content-Type: text/markdown
21
21
  License-File: LICENSE
22
22
  Requires-Dist: httpx>=0.27
23
+ Requires-Dist: certifi>=2026.10.19
23
24
  Requires-Dist: fastavro<2,>=1.12.2
24
25
  Provides-Extra: telemetry
25
26
  Requires-Dist: opentelemetry-api<2,>=1.45.1; extra == "telemetry"
@@ -132,6 +132,7 @@ tests/test_sync_activities.py
132
132
  tests/test_telemetry.py
133
133
  tests/test_telemetry_carrier.py
134
134
  tests/test_testing_harness.py
135
+ tests/test_threaded_heartbeat_example.py
135
136
  tests/test_update_signal_condition_replay.py
136
137
  tests/test_updates.py
137
138
  tests/test_wait_condition.py
@@ -142,6 +143,7 @@ tests/test_worker_telemetry_wire.py
142
143
  tests/test_workflow_entry_arguments.py
143
144
  tests/test_workflow_entrypoint_validation.py
144
145
  tests/test_workflow_handler_validation.py
146
+ tests/test_workflow_identity.py
145
147
  tests/test_workflow_result_timeout.py
146
148
  tests/test_workflow_streams.py
147
149
  tests/test_workflow_updates_conformance.py
@@ -1,4 +1,5 @@
1
1
  httpx>=0.27
2
+ certifi>=2026.10.19
2
3
  fastavro<2,>=1.12.2
3
4
 
4
5
  [dev]
@@ -46,6 +46,7 @@ from tests.test_update_signal_condition_replay import (
46
46
  UpdateSignalConditionTimerWorkflow,
47
47
  )
48
48
  from tests.test_worker import compatible_cluster_info
49
+ from tests.test_workflow_identity import IdentityAcrossReplay, IdentityContinueAsNew
49
50
 
50
51
  FIXTURE_SCHEMA = "durable-workflow.replay-regression/v1"
51
52
  FIXTURE_DIR = Path(__file__).parent / "fixtures" / "replay_regressions"
@@ -340,6 +341,8 @@ class NexusCooperativeDeliveryWorkflow:
340
341
 
341
342
 
342
343
  WORKFLOWS = [
344
+ IdentityAcrossReplay,
345
+ IdentityContinueAsNew,
343
346
  PersistedClockWorkflow,
344
347
  NexusCooperativeDeliveryWorkflow,
345
348
  NexusTerminalDeliveryWorkflow,
@@ -514,6 +517,8 @@ def _execute_fixture(fixture: dict[str, Any]) -> list[dict[str, Any]]:
514
517
  history,
515
518
  start_input,
516
519
  workflow_type=workflow_type,
520
+ workflow_id=workflow.get("workflow_id"),
521
+ run_id=workflow.get("run_id", ""),
517
522
  payload_codec=("json" if payload_codec is None else payload_codec),
518
523
  )
519
524
  commands = _command_documents(outcome.commands)
@@ -0,0 +1,125 @@
1
+ """Exercise the public example with real ActivityContext thread/loop behavior."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import importlib.util
6
+ import unittest
7
+ from pathlib import Path
8
+ from threading import Event, Thread
9
+ from threading import enumerate as threads
10
+ from typing import Any
11
+
12
+ from durable_workflow import activity
13
+ from durable_workflow.activity import ActivityContext, ActivityInfo, _set_context
14
+ from durable_workflow.errors import ActivityCancelled, ActivityLeaseAuthorityLost
15
+
16
+ SPEC = importlib.util.spec_from_file_location(
17
+ "threaded_heartbeats_example",
18
+ Path(__file__).resolve().parents[1] / "examples" / "threaded_heartbeats.py",
19
+ )
20
+ assert SPEC is not None
21
+ assert SPEC.loader is not None
22
+ EXAMPLE = importlib.util.module_from_spec(SPEC)
23
+ SPEC.loader.exec_module(EXAMPLE)
24
+
25
+
26
+ class HeartbeatClient:
27
+ def __init__(self, response: Any = None) -> None:
28
+ self.response = response if response is not None else {"can_continue": True}
29
+ self.calls: list[dict[str, Any]] = []
30
+ self.observed = Event()
31
+
32
+ async def heartbeat_activity_task(self, **request: Any) -> Any:
33
+ self.calls.append(request)
34
+ self.observed.set()
35
+ if isinstance(self.response, Exception):
36
+ raise self.response
37
+ return self.response
38
+
39
+
40
+ class ThreadedHeartbeatExampleTest(unittest.IsolatedAsyncioTestCase):
41
+ async def asyncSetUp(self) -> None:
42
+ self.client = HeartbeatClient()
43
+ self.ctx = ActivityContext(
44
+ info=ActivityInfo(
45
+ "task-original", "export", "attempt-original", 2, "reports", "worker-original",
46
+ ),
47
+ client=self.client, # type: ignore[arg-type]
48
+ )
49
+ _set_context(self.ctx)
50
+
51
+ async def asyncTearDown(self) -> None:
52
+ _set_context(None)
53
+ self.assertFalse([thread for thread in threads() if thread.name == "activity-heartbeat"])
54
+
55
+ async def run_work(self, work: Any) -> Any:
56
+ return await asyncio.wait_for(
57
+ asyncio.to_thread(EXAMPLE.call_with_heartbeats, work, interval=0.01),
58
+ timeout=3,
59
+ )
60
+
61
+ async def test_new_thread_needs_the_captured_context(self) -> None:
62
+ errors: list[BaseException] = []
63
+
64
+ def look_up_context() -> None:
65
+ try:
66
+ activity.context()
67
+ except RuntimeError as error:
68
+ errors.append(error)
69
+
70
+ helper = Thread(target=look_up_context)
71
+ helper.start()
72
+ helper.join()
73
+ self.assertEqual(len(errors), 1)
74
+ self.assertIn("outside of an activity", str(errors[0]))
75
+
76
+ async def test_return_keeps_original_attempt_and_joins(self) -> None:
77
+ def work(cancelled: Event) -> dict[str, int]:
78
+ self.assertTrue(self.client.observed.wait(2))
79
+ self.assertFalse(cancelled.is_set())
80
+ return {"count": 7}
81
+
82
+ self.assertEqual(await self.run_work(work), {"count": 7})
83
+ self.assertTrue(self.client.calls)
84
+ for request in self.client.calls:
85
+ self.assertEqual(request["task_id"], "task-original")
86
+ self.assertEqual(request["activity_attempt_id"], "attempt-original")
87
+ self.assertEqual(request["lease_owner"], "worker-original")
88
+
89
+ async def test_work_error_also_joins(self) -> None:
90
+ original = ValueError("business call failed")
91
+
92
+ def work(cancelled: Event) -> None:
93
+ self.assertTrue(self.client.observed.wait(2))
94
+ raise original
95
+
96
+ with self.assertRaises(ValueError) as caught:
97
+ await self.run_work(work)
98
+ self.assertIs(caught.exception, original)
99
+
100
+ def cancelled_work(self, cancelled: Event) -> str:
101
+ self.assertTrue(cancelled.wait(2))
102
+ return "must not be returned"
103
+
104
+ async def test_cancellation_stops_work_and_preserves_type(self) -> None:
105
+ self.client.response = {"cancel_requested": True}
106
+ with self.assertRaises(ActivityCancelled):
107
+ await self.run_work(self.cancelled_work)
108
+
109
+ async def test_authority_loss_keeps_original_identity_and_reason(self) -> None:
110
+ self.client.response = {"can_continue": False, "reason": "activity_attempt_replaced"}
111
+ with self.assertRaises(ActivityLeaseAuthorityLost) as caught:
112
+ await self.run_work(self.cancelled_work)
113
+ self.assertEqual(caught.exception.task_id, "task-original")
114
+ self.assertEqual(caught.exception.reason, "activity_attempt_replaced")
115
+
116
+ async def test_transport_error_is_not_silently_lost(self) -> None:
117
+ original = OSError("transport unavailable")
118
+ self.client.response = original
119
+ with self.assertRaises(OSError) as caught:
120
+ await self.run_work(self.cancelled_work)
121
+ self.assertIs(caught.exception, original)
122
+
123
+ async def test_event_loop_call_refuses_before_work(self) -> None:
124
+ with self.assertRaisesRegex(RuntimeError, "synchronous activity thread"):
125
+ EXAMPLE.call_with_heartbeats(lambda cancelled: self.fail("must not run"))
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ from durable_workflow import serializer, workflow
6
+ from durable_workflow.workflow import (
7
+ CompleteWorkflow,
8
+ ContinueAsNew,
9
+ ScheduleActivity,
10
+ WorkflowContext,
11
+ replay,
12
+ )
13
+
14
+
15
+ @workflow.defn(name="identity-across-replay")
16
+ class IdentityAcrossReplay:
17
+ def run(self, ctx: WorkflowContext): # type: ignore[no-untyped-def]
18
+ before = [ctx.workflow_id, ctx.run_id]
19
+ value = yield ctx.schedule_activity("identity", before)
20
+ return {"before": before, "after": [ctx.workflow_id, ctx.run_id], "value": value}
21
+
22
+
23
+ @pytest.mark.parametrize("workflow_id", ["workflow-identity", "0", "17", "017"])
24
+ def test_identity_survives_activity_suspension_and_cold_replay(workflow_id: str) -> None:
25
+ run_id = "original-run"
26
+ first = replay(IdentityAcrossReplay, [], [], workflow_id=workflow_id, run_id=run_id)
27
+ assert len(first.commands) == 1
28
+ assert isinstance(first.commands[0], ScheduleActivity)
29
+ assert first.commands[0].arguments == [workflow_id, run_id]
30
+
31
+ history = [{"event_type": "ActivityCompleted", "payload": {
32
+ "result": serializer.envelope("done", codec=serializer.AVRO_CODEC),
33
+ }}]
34
+ resumed = replay(IdentityAcrossReplay, history, [], workflow_id=workflow_id, run_id=run_id)
35
+ assert len(resumed.commands) == 1
36
+ assert isinstance(resumed.commands[0], CompleteWorkflow)
37
+ assert resumed.commands[0].result == {
38
+ "before": [workflow_id, run_id], "after": [workflow_id, run_id], "value": "done",
39
+ }
40
+
41
+
42
+ @workflow.defn(name="identity-continue-as-new")
43
+ class IdentityContinueAsNew:
44
+ def run( # type: ignore[no-untyped-def]
45
+ self, ctx: WorkflowContext, again: bool,
46
+ prior_workflow_id: str | None = None, prior_run_id: str | None = None,
47
+ ):
48
+ if again:
49
+ return ctx.continue_as_new(False, ctx.workflow_id, ctx.run_id)
50
+ return {"current": [ctx.workflow_id, ctx.run_id], "prior": [prior_workflow_id, prior_run_id]}
51
+
52
+
53
+ def test_successor_context_has_same_workflow_and_new_run_identity() -> None:
54
+ original = replay(IdentityContinueAsNew, [], [True], workflow_id="chain", run_id="run-one")
55
+ assert len(original.commands) == 1
56
+ command = original.commands[0]
57
+ assert isinstance(command, ContinueAsNew)
58
+ assert command.arguments == [False, "chain", "run-one"]
59
+
60
+ successor = replay(
61
+ IdentityContinueAsNew, [], command.arguments, workflow_id="chain", run_id="run-two",
62
+ )
63
+ assert len(successor.commands) == 1
64
+ assert isinstance(successor.commands[0], CompleteWorkflow)
65
+ assert successor.commands[0].result == {"current": ["chain", "run-two"], "prior": ["chain", "run-one"]}
66
+
67
+
68
+ @pytest.mark.parametrize("attribute", ["workflow_id", "run_id"])
69
+ def test_workflow_identity_is_read_only(attribute: str) -> None:
70
+ ctx = WorkflowContext(workflow_id="workflow", run_id="run")
71
+ with pytest.raises(AttributeError):
72
+ setattr(ctx, attribute, "replacement")