durable-workflow 2.11.1__tar.gz → 2.12.0__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.1/src/durable_workflow.egg-info → durable_workflow-2.12.0}/PKG-INFO +1 -1
  2. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/pyproject.toml +3 -3
  3. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/__init__.py +2 -1
  4. durable_workflow-2.12.0/src/durable_workflow/_poll_transport.py +55 -0
  5. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/activity.py +61 -2
  6. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/client.py +49 -7
  7. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/sync.py +2 -1
  8. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/worker.py +3 -0
  9. {durable_workflow-2.11.1 → durable_workflow-2.12.0/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  10. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow.egg-info/SOURCES.txt +3 -0
  11. durable_workflow-2.12.0/tests/test_activity_retry_info.py +82 -0
  12. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_client.py +35 -0
  13. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_cooperative_remote_worker.py +20 -0
  14. durable_workflow-2.12.0/tests/test_poll_transport.py +255 -0
  15. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_prepared_local_activity_worker.py +21 -0
  16. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_sync.py +8 -0
  17. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_worker.py +39 -0
  18. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/LICENSE +0 -0
  19. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/README.md +0 -0
  20. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/setup.cfg +0 -0
  21. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/_activity_lease.py +0 -0
  22. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/_activity_process.py +0 -0
  23. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/_avro.py +0 -0
  24. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/_cancellation_scope.py +0 -0
  25. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/_cancellation_scope_history.py +0 -0
  26. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/_cooperative_cancellation.py +0 -0
  27. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/_failure_reporting.py +0 -0
  28. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/_metric_routes.py +0 -0
  29. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/_prepared_local_activity.py +0 -0
  30. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/_query_task_authority.py +0 -0
  31. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
  32. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/auth_composition.py +0 -0
  33. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/cancellation.py +0 -0
  34. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/errors.py +0 -0
  35. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/external_storage.py +0 -0
  36. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/external_task_input.py +0 -0
  37. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/external_task_result.py +0 -0
  38. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/history_bundle_verify.py +0 -0
  39. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/interceptors.py +0 -0
  40. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/invocable.py +0 -0
  41. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/metrics.py +0 -0
  42. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/nexus.py +0 -0
  43. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/py.typed +0 -0
  44. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/python_conformance.py +0 -0
  45. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/replay_conformance.py +0 -0
  46. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/replay_verify.py +0 -0
  47. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/retry_policy.py +0 -0
  48. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/serializer.py +0 -0
  49. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/telemetry.py +0 -0
  50. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/telemetry_carrier.py +0 -0
  51. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/testing.py +0 -0
  52. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/worker_session.py +0 -0
  53. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/workflow.py +0 -0
  54. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  55. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  56. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  57. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow.egg-info/requires.txt +0 -0
  58. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/src/durable_workflow.egg-info/top_level.txt +0 -0
  59. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_activity_cancellation_policies.py +0 -0
  60. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_activity_context.py +0 -0
  61. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_activity_heartbeat_authority.py +0 -0
  62. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_activity_lease_authority.py +0 -0
  63. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_activity_lease_renewal.py +0 -0
  64. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_activity_lease_worker.py +0 -0
  65. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_activity_process.py +0 -0
  66. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_auth_composition.py +0 -0
  67. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_avro_value_benchmark.py +0 -0
  68. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_avro_value_protocol.py +0 -0
  69. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_cancellation_context.py +0 -0
  70. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_cancellation_remaining_time.py +0 -0
  71. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_cancellation_scope_admission.py +0 -0
  72. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_cancellation_scope_authoring.py +0 -0
  73. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_cancellation_scope_boundary.py +0 -0
  74. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_cancellation_scope_opening.py +0 -0
  75. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_child_workflow_policies.py +0 -0
  76. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_ci_checkout.py +0 -0
  77. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_ci_integration_endpoint.py +0 -0
  78. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_client_telemetry.py +0 -0
  79. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_client_timeouts.py +0 -0
  80. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_codec_regression_corpus.py +0 -0
  81. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_committed_cancellation_scope_descendants.py +0 -0
  82. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_committed_cancellation_scope_groups.py +0 -0
  83. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_committed_cancellation_scope_history.py +0 -0
  84. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_committed_cancellation_scope_replay.py +0 -0
  85. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_condition_replay_drift.py +0 -0
  86. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_control_plane_parity_fixtures.py +0 -0
  87. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_cooperative_cancellation.py +0 -0
  88. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_cooperative_cancellation_client.py +0 -0
  89. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_cooperative_cancellation_worker.py +0 -0
  90. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_docstring_cross_references.py +0 -0
  91. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_durable_selection.py +0 -0
  92. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_errors.py +0 -0
  93. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_external_storage.py +0 -0
  94. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_external_task_input.py +0 -0
  95. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_external_task_result.py +0 -0
  96. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_failure_reporting.py +0 -0
  97. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_golden_history_replay.py +0 -0
  98. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_history_bundle_schema2.py +0 -0
  99. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_history_bundle_verify.py +0 -0
  100. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_history_event_contract.py +0 -0
  101. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_invocable.py +0 -0
  102. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_local_failure_reporting.py +0 -0
  103. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_message_streams.py +0 -0
  104. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_metric_routes.py +0 -0
  105. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_metrics.py +0 -0
  106. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_nexus_durable_wait.py +0 -0
  107. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_nexus_namespace.py +0 -0
  108. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_nexus_wire.py +0 -0
  109. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_order_processing_example.py +0 -0
  110. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_parallel_completion_order_replay.py +0 -0
  111. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_parallel_saga_authoring.py +0 -0
  112. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_patch_history.py +0 -0
  113. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_pending_operation_replay.py +0 -0
  114. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_poll_failure_backoff.py +0 -0
  115. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_prepared_local_activity_client.py +0 -0
  116. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_prepared_local_activity_groups.py +0 -0
  117. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_prepared_local_activity_policies.py +0 -0
  118. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_public_boundary_scanner.py +0 -0
  119. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_pypi_project_surface.py +0 -0
  120. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_python_conformance.py +0 -0
  121. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_queries.py +0 -0
  122. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_query_task_authority.py +0 -0
  123. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_query_timeout_retry.py +0 -0
  124. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_readme_quickstart.py +0 -0
  125. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_redrive_replay.py +0 -0
  126. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_registration_ambiguity.py +0 -0
  127. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_release_metadata.py +0 -0
  128. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_replay.py +0 -0
  129. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_replay_clock.py +0 -0
  130. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_replay_conformance.py +0 -0
  131. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_replay_regression_corpus.py +0 -0
  132. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_replay_verify.py +0 -0
  133. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_retry_policy.py +0 -0
  134. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_run_cancellation_scope_replay.py +0 -0
  135. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_runtime_external_payload_transport.py +0 -0
  136. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_schedules.py +0 -0
  137. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_scoped_run_cancellation_context.py +0 -0
  138. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_sdk_metrics_contract.py +0 -0
  139. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_serializer.py +0 -0
  140. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_signals.py +0 -0
  141. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_sleep.py +0 -0
  142. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_standalone_activity_client.py +0 -0
  143. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_sticky_execution.py +0 -0
  144. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_storage_admission.py +0 -0
  145. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_sync_activities.py +0 -0
  146. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_telemetry.py +0 -0
  147. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_telemetry_carrier.py +0 -0
  148. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_testing_failures.py +0 -0
  149. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_testing_harness.py +0 -0
  150. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_testing_nexus.py +0 -0
  151. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_threaded_heartbeat_example.py +0 -0
  152. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_tls_trust.py +0 -0
  153. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_transport_diagnostics.py +0 -0
  154. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_update_signal_condition_replay.py +0 -0
  155. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_updates.py +0 -0
  156. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_wait_condition.py +0 -0
  157. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_worker_deregistration.py +0 -0
  158. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_worker_observation.py +0 -0
  159. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_worker_session.py +0 -0
  160. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_worker_telemetry_wire.py +0 -0
  161. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_workflow_entry_arguments.py +0 -0
  162. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_workflow_entrypoint_validation.py +0 -0
  163. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_workflow_handler_validation.py +0 -0
  164. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_workflow_identity.py +0 -0
  165. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_workflow_result_timeout.py +0 -0
  166. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/tests/test_workflow_streams.py +0 -0
  167. {durable_workflow-2.11.1 → durable_workflow-2.12.0}/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.1
3
+ Version: 2.12.0
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.1"
7
+ version = "2.12.0"
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.1"
80
- registry-version = "2.11.1"
79
+ product-train = "2.12.0"
80
+ registry-version = "2.12.0"
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",
@@ -0,0 +1,55 @@
1
+ """Negotiated long-poll responses and early transport failure diagnostics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ from collections.abc import Mapping
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ MEDIA_TYPE = "application/vnd.durable-workflow.poll.v1+json"
13
+ ACCEPT = f"{MEDIA_TYPE}, application/json;q=0.9"
14
+ _SCHEMA = "durable-workflow.poll-transport.v1"
15
+ POLL_PATHS = frozenset(
16
+ f"/worker/{kind}-tasks/poll" for kind in ("workflow", "activity", "query", "update-validation")
17
+ )
18
+ _HEADERS = frozenset(("retry-after", "x-durable-workflow-protocol-version"))
19
+ log = logging.getLogger(__name__)
20
+
21
+
22
+ def unwrap_response(response: httpx.Response) -> httpx.Response:
23
+ if response.headers.get("content-type", "").split(";", 1)[0].strip().lower() != MEDIA_TYPE:
24
+ return response
25
+
26
+ try:
27
+ envelope = response.json()
28
+ if not isinstance(envelope, dict) or envelope.get("schema") != _SCHEMA:
29
+ raise ValueError("unrecognized poll transport")
30
+ status, headers, body = envelope.get("status"), envelope.get("headers"), envelope.get("body")
31
+ if (type(status) is not int or not 200 <= status < 600
32
+ or response.status_code not in (200, status)
33
+ or not isinstance(body, dict) or not isinstance(headers, dict)
34
+ or any(key not in _HEADERS or not isinstance(value, str)
35
+ or "\r" in value or "\n" in value for key, value in headers.items())):
36
+ raise ValueError("invalid poll transport result")
37
+ # Apply the original status and retry headers before the normal client
38
+ # error handling. Outer HTTP 200 may have been committed by progress.
39
+ return httpx.Response(status, headers=headers, json=body, request=response.request)
40
+ except (ValueError, TypeError) as error:
41
+ raise httpx.RemoteProtocolError("Invalid worker poll transport response.", request=response.request) from error
42
+
43
+
44
+ def warn_early_cutoff(path: str, body: Any, started: float, error: Exception) -> None:
45
+ wait = body.get("timeout_seconds") if isinstance(body, Mapping) else None
46
+ observed = time.monotonic() - started
47
+ if isinstance(wait, bool) or not isinstance(wait, int | float) or wait <= 0 or observed >= wait:
48
+ return
49
+ if isinstance(error, httpx.HTTPStatusError) and error.response.status_code not in (502, 504):
50
+ return
51
+ log.warning(
52
+ "Worker long poll %s ended after %.3fs before its configured wait of %.3fs (%s). "
53
+ "Check proxy and HTTP timeout budgets.",
54
+ path, observed, wait, type(error).__name__,
55
+ )
@@ -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:
@@ -57,6 +57,10 @@ from ._failure_reporting import (
57
57
  prepare_failure_request,
58
58
  )
59
59
  from ._metric_routes import route_for_metrics as _route_for_metrics
60
+ from ._poll_transport import ACCEPT as _POLL_ACCEPT
61
+ from ._poll_transport import POLL_PATHS as _POLL_PATHS
62
+ from ._poll_transport import unwrap_response as _unwrap_poll_response
63
+ from ._poll_transport import warn_early_cutoff as _warn_poll_cutoff
60
64
  from ._query_task_authority import QueryTaskAuthority, current_query_authority
61
65
  from .errors import (
62
66
  REQUEST_ID_CONFLICT,
@@ -1382,6 +1386,10 @@ class WorkflowHandle:
1382
1386
  request_id: str | None = None,
1383
1387
  ) -> dict[str, Any]:
1384
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
+ )
1385
1393
  return await self._client.signal_workflow(
1386
1394
  self.workflow_id, signal_name, args=args, request_id=request_id,
1387
1395
  )
@@ -1882,6 +1890,7 @@ class Client:
1882
1890
 
1883
1891
  query_poll = worker and path == "/worker/query-tasks/poll"
1884
1892
  activity_poll = worker and path == "/worker/activity-tasks/poll"
1893
+ progress_poll = worker and method.upper() == "POST" and path in _POLL_PATHS
1885
1894
  if query_poll:
1886
1895
  current_query_authority.set(None)
1887
1896
  if activity_poll:
@@ -1931,15 +1940,34 @@ class Client:
1931
1940
  request_timeout = self.timeout if timeout is None else timeout
1932
1941
  if worker and activity_authority is not None and activity_authority.governs(self, self.namespace, path):
1933
1942
  request_timeout = min(request_timeout, activity_authority.remaining())
1934
- resp = await self._http.request(
1943
+ headers = self._discovery_headers() if discovery else self._headers(worker=worker)
1944
+ if progress_poll:
1945
+ headers["Accept"] = _POLL_ACCEPT
1946
+ pending = self._http.request(
1935
1947
  method,
1936
1948
  f"/api{path}",
1937
- headers=self._discovery_headers() if discovery else self._headers(worker=worker),
1949
+ headers=headers,
1938
1950
  json=request_json,
1939
1951
  timeout=request_timeout,
1940
1952
  )
1941
- # Raise HTTPStatusError for 4xx/5xx so retry policy can catch it
1942
- resp.raise_for_status()
1953
+ try:
1954
+ if progress_poll:
1955
+ try:
1956
+ resp = await asyncio.wait_for(pending, timeout=request_timeout)
1957
+ except asyncio.TimeoutError as error:
1958
+ raise httpx.ReadTimeout(
1959
+ "Worker poll exceeded its absolute HTTP attempt deadline.",
1960
+ request=self._http.build_request(method, f"/api{path}"),
1961
+ ) from error
1962
+ resp = _unwrap_poll_response(resp)
1963
+ else:
1964
+ resp = await pending
1965
+ # Interpret negotiated late errors before the usual retry policy.
1966
+ resp.raise_for_status()
1967
+ except (httpx.TransportError, httpx.HTTPStatusError) as error:
1968
+ if progress_poll:
1969
+ _warn_poll_cutoff(path, request_json, request_started, error)
1970
+ raise
1943
1971
  return resp
1944
1972
 
1945
1973
  try:
@@ -4437,6 +4465,7 @@ class Client:
4437
4465
  *,
4438
4466
  args: list[Any] | None = None,
4439
4467
  request_id: str | None = None,
4468
+ run_id: str | None = None,
4440
4469
  ) -> dict[str, Any]:
4441
4470
  """Deliver an external signal to a running workflow.
4442
4471
 
@@ -4445,12 +4474,18 @@ class Client:
4445
4474
  observe the signal. See the main docs for how to declare the allowed
4446
4475
  signal names on a workflow type.
4447
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
+
4448
4481
  ``request_id`` is the caller command key (1-255 opaque, case-sensitive
4449
- bytes) that makes a retry of this signal safe. When omitted the client
4482
+ bytes). When omitted the client
4450
4483
  generates ``sdk-python-signal-<uuid>`` once per call, so the transport
4451
4484
  retry policy resends the same key. Resending the same key with the same
4452
4485
  signal name, arguments and run target returns the original receipt
4453
- 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.
4454
4489
 
4455
4490
  Returns the Server's admission receipt. ``request_id`` is always
4456
4491
  present (the key that was sent); a Server that retains command keys
@@ -4462,6 +4497,12 @@ class Client:
4462
4497
  in its ``details``. Every exception raised after the key was chosen,
4463
4498
  including transport errors, carries it as ``request_id``.
4464
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='')}"
4465
4506
  key = _command_request_id(request_id, "signal")
4466
4507
  body: dict[str, Any] = {}
4467
4508
  if args:
@@ -4469,12 +4510,13 @@ class Client:
4469
4510
  args,
4470
4511
  kind="signal",
4471
4512
  workflow_id=workflow_id,
4513
+ run_id=run_id,
4472
4514
  signal_name=signal_name,
4473
4515
  )
4474
4516
  body["request_id"] = key
4475
4517
  try:
4476
4518
  data = await self._request(
4477
- "POST", f"/workflows/{workflow_id}/signal/{signal_name}",
4519
+ "POST", path,
4478
4520
  json=self._with_observability_context(body), context=workflow_id,
4479
4521
  )
4480
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
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.11.1
3
+ Version: 2.12.0
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
@@ -10,6 +10,7 @@ src/durable_workflow/_cancellation_scope_history.py
10
10
  src/durable_workflow/_cooperative_cancellation.py
11
11
  src/durable_workflow/_failure_reporting.py
12
12
  src/durable_workflow/_metric_routes.py
13
+ src/durable_workflow/_poll_transport.py
13
14
  src/durable_workflow/_prepared_local_activity.py
14
15
  src/durable_workflow/_query_task_authority.py
15
16
  src/durable_workflow/_sticky_workflow_cache.py
@@ -53,6 +54,7 @@ tests/test_activity_lease_authority.py
53
54
  tests/test_activity_lease_renewal.py
54
55
  tests/test_activity_lease_worker.py
55
56
  tests/test_activity_process.py
57
+ tests/test_activity_retry_info.py
56
58
  tests/test_auth_composition.py
57
59
  tests/test_avro_value_benchmark.py
58
60
  tests/test_avro_value_protocol.py
@@ -104,6 +106,7 @@ tests/test_parallel_saga_authoring.py
104
106
  tests/test_patch_history.py
105
107
  tests/test_pending_operation_replay.py
106
108
  tests/test_poll_failure_backoff.py
109
+ tests/test_poll_transport.py
107
110
  tests/test_prepared_local_activity_client.py
108
111
  tests/test_prepared_local_activity_groups.py
109
112
  tests/test_prepared_local_activity_policies.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