durable-workflow 2.9.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.9.0/src/durable_workflow.egg-info → durable_workflow-2.9.1}/PKG-INFO +2 -1
  2. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/pyproject.toml +4 -3
  3. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow/workflow.py +7 -0
  4. {durable_workflow-2.9.0 → durable_workflow-2.9.1/src/durable_workflow.egg-info}/PKG-INFO +2 -1
  5. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow.egg-info/SOURCES.txt +1 -0
  6. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow.egg-info/requires.txt +1 -0
  7. durable_workflow-2.9.1/tests/test_threaded_heartbeat_example.py +125 -0
  8. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/LICENSE +0 -0
  9. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/README.md +0 -0
  10. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/setup.cfg +0 -0
  11. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow/__init__.py +0 -0
  12. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow/_activity_lease.py +0 -0
  13. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow/_activity_process.py +0 -0
  14. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow/_avro.py +0 -0
  15. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow/_cancellation_scope.py +0 -0
  16. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow/_cancellation_scope_history.py +0 -0
  17. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow/_cooperative_cancellation.py +0 -0
  18. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow/_prepared_local_activity.py +0 -0
  19. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow/_query_task_authority.py +0 -0
  20. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
  21. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow/activity.py +0 -0
  22. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow/auth_composition.py +0 -0
  23. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow/cancellation.py +0 -0
  24. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow/client.py +0 -0
  25. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow/errors.py +0 -0
  26. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow/external_storage.py +0 -0
  27. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow/external_task_input.py +0 -0
  28. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow/external_task_result.py +0 -0
  29. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow/history_bundle_verify.py +0 -0
  30. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow/interceptors.py +0 -0
  31. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow/invocable.py +0 -0
  32. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow/metrics.py +0 -0
  33. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow/nexus.py +0 -0
  34. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow/py.typed +0 -0
  35. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow/python_conformance.py +0 -0
  36. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow/replay_conformance.py +0 -0
  37. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow/replay_verify.py +0 -0
  38. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow/retry_policy.py +0 -0
  39. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow/serializer.py +0 -0
  40. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow/sync.py +0 -0
  41. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow/telemetry.py +0 -0
  42. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow/telemetry_carrier.py +0 -0
  43. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow/testing.py +0 -0
  44. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow/worker.py +0 -0
  45. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow/worker_session.py +0 -0
  46. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  47. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  48. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  49. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/src/durable_workflow.egg-info/top_level.txt +0 -0
  50. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_activity_cancellation_policies.py +0 -0
  51. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_activity_context.py +0 -0
  52. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_activity_heartbeat_authority.py +0 -0
  53. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_activity_lease_authority.py +0 -0
  54. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_activity_lease_renewal.py +0 -0
  55. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_activity_lease_worker.py +0 -0
  56. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_activity_process.py +0 -0
  57. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_auth_composition.py +0 -0
  58. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_avro_value_benchmark.py +0 -0
  59. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_avro_value_protocol.py +0 -0
  60. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_cancellation_context.py +0 -0
  61. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_cancellation_remaining_time.py +0 -0
  62. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_cancellation_scope_admission.py +0 -0
  63. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_cancellation_scope_authoring.py +0 -0
  64. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_cancellation_scope_boundary.py +0 -0
  65. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_cancellation_scope_opening.py +0 -0
  66. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_child_workflow_policies.py +0 -0
  67. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_ci_checkout.py +0 -0
  68. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_ci_integration_endpoint.py +0 -0
  69. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_client.py +0 -0
  70. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_client_telemetry.py +0 -0
  71. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_client_timeouts.py +0 -0
  72. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_codec_regression_corpus.py +0 -0
  73. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_committed_cancellation_scope_descendants.py +0 -0
  74. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_committed_cancellation_scope_groups.py +0 -0
  75. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_committed_cancellation_scope_history.py +0 -0
  76. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_committed_cancellation_scope_replay.py +0 -0
  77. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_condition_replay_drift.py +0 -0
  78. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_control_plane_parity_fixtures.py +0 -0
  79. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_cooperative_cancellation.py +0 -0
  80. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_cooperative_cancellation_client.py +0 -0
  81. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_cooperative_cancellation_worker.py +0 -0
  82. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_cooperative_remote_worker.py +0 -0
  83. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_docstring_cross_references.py +0 -0
  84. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_durable_selection.py +0 -0
  85. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_errors.py +0 -0
  86. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_external_storage.py +0 -0
  87. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_external_task_input.py +0 -0
  88. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_external_task_result.py +0 -0
  89. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_golden_history_replay.py +0 -0
  90. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_history_bundle_verify.py +0 -0
  91. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_history_event_contract.py +0 -0
  92. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_invocable.py +0 -0
  93. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_message_streams.py +0 -0
  94. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_metrics.py +0 -0
  95. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_nexus_durable_wait.py +0 -0
  96. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_nexus_namespace.py +0 -0
  97. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_nexus_wire.py +0 -0
  98. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_order_processing_example.py +0 -0
  99. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_parallel_completion_order_replay.py +0 -0
  100. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_parallel_saga_authoring.py +0 -0
  101. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_patch_history.py +0 -0
  102. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_pending_operation_replay.py +0 -0
  103. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_prepared_local_activity_client.py +0 -0
  104. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_prepared_local_activity_groups.py +0 -0
  105. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_prepared_local_activity_policies.py +0 -0
  106. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_prepared_local_activity_worker.py +0 -0
  107. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_public_boundary_scanner.py +0 -0
  108. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_pypi_project_surface.py +0 -0
  109. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_python_conformance.py +0 -0
  110. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_queries.py +0 -0
  111. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_query_task_authority.py +0 -0
  112. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_query_timeout_retry.py +0 -0
  113. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_readme_quickstart.py +0 -0
  114. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_redrive_replay.py +0 -0
  115. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_registration_ambiguity.py +0 -0
  116. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_release_metadata.py +0 -0
  117. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_replay.py +0 -0
  118. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_replay_clock.py +0 -0
  119. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_replay_conformance.py +0 -0
  120. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_replay_regression_corpus.py +0 -0
  121. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_replay_verify.py +0 -0
  122. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_retry_policy.py +0 -0
  123. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_run_cancellation_scope_replay.py +0 -0
  124. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_runtime_external_payload_transport.py +0 -0
  125. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_schedules.py +0 -0
  126. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_scoped_run_cancellation_context.py +0 -0
  127. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_serializer.py +0 -0
  128. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_signals.py +0 -0
  129. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_sleep.py +0 -0
  130. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_standalone_activity_client.py +0 -0
  131. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_sticky_execution.py +0 -0
  132. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_storage_admission.py +0 -0
  133. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_sync.py +0 -0
  134. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_sync_activities.py +0 -0
  135. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_telemetry.py +0 -0
  136. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_telemetry_carrier.py +0 -0
  137. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_testing_harness.py +0 -0
  138. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_update_signal_condition_replay.py +0 -0
  139. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_updates.py +0 -0
  140. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_wait_condition.py +0 -0
  141. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_worker.py +0 -0
  142. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_worker_observation.py +0 -0
  143. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_worker_session.py +0 -0
  144. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_worker_telemetry_wire.py +0 -0
  145. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_workflow_entry_arguments.py +0 -0
  146. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_workflow_entrypoint_validation.py +0 -0
  147. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_workflow_handler_validation.py +0 -0
  148. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_workflow_identity.py +0 -0
  149. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_workflow_result_timeout.py +0 -0
  150. {durable_workflow-2.9.0 → durable_workflow-2.9.1}/tests/test_workflow_streams.py +0 -0
  151. {durable_workflow-2.9.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.9.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.9.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.9.0"
79
- registry-version = "2.9.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.9.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
@@ -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]
@@ -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"))