durable-workflow 2.13.0__tar.gz → 2.13.2__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 (177) hide show
  1. {durable_workflow-2.13.0/src/durable_workflow.egg-info → durable_workflow-2.13.2}/PKG-INFO +1 -1
  2. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/pyproject.toml +3 -3
  3. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/_cancellation_scope_history.py +6 -1
  4. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/_cooperative_cancellation.py +3 -1
  5. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/testing.py +12 -1
  6. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/workflow.py +395 -4
  7. {durable_workflow-2.13.0 → durable_workflow-2.13.2/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  8. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow.egg-info/SOURCES.txt +3 -0
  9. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_cancellation_remaining_time.py +51 -1
  10. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_committed_cancellation_scope_replay.py +96 -0
  11. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_history_event_contract.py +39 -1
  12. durable_workflow-2.13.2/tests/test_publish_workflow_isolation.py +149 -0
  13. durable_workflow-2.13.2/tests/test_redrive_successor_evidence.py +613 -0
  14. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_replay.py +6 -3
  15. durable_workflow-2.13.2/tests/test_replay_clock.py +998 -0
  16. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_replay_regression_corpus.py +21 -2
  17. durable_workflow-2.13.2/tests/test_search_attribute_replay_identity.py +675 -0
  18. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_wait_condition.py +99 -0
  19. durable_workflow-2.13.0/tests/test_replay_clock.py +0 -93
  20. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/LICENSE +0 -0
  21. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/README.md +0 -0
  22. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/setup.cfg +0 -0
  23. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/__init__.py +0 -0
  24. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/_activity_lease.py +0 -0
  25. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/_activity_process.py +0 -0
  26. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/_avro.py +0 -0
  27. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/_cancellation_scope.py +0 -0
  28. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/_failure_reporting.py +0 -0
  29. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/_metric_routes.py +0 -0
  30. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/_poll_transport.py +0 -0
  31. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/_prepared_local_activity.py +0 -0
  32. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/_query_task_authority.py +0 -0
  33. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
  34. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/activity.py +0 -0
  35. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/auth_composition.py +0 -0
  36. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/cancellation.py +0 -0
  37. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/client.py +0 -0
  38. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/errors.py +0 -0
  39. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/external_storage.py +0 -0
  40. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/external_task_input.py +0 -0
  41. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/external_task_result.py +0 -0
  42. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/history_bundle_verify.py +0 -0
  43. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/interceptors.py +0 -0
  44. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/invocable.py +0 -0
  45. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/metrics.py +0 -0
  46. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/nexus.py +0 -0
  47. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/py.typed +0 -0
  48. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/python_conformance.py +0 -0
  49. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/replay_conformance.py +0 -0
  50. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/replay_verify.py +0 -0
  51. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/retry_policy.py +0 -0
  52. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/serializer.py +0 -0
  53. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/server_compatibility.json +0 -0
  54. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/sync.py +0 -0
  55. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/telemetry.py +0 -0
  56. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/telemetry_carrier.py +0 -0
  57. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/worker.py +0 -0
  58. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/worker_session.py +0 -0
  59. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/worker_status.py +0 -0
  60. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  61. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  62. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  63. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow.egg-info/requires.txt +0 -0
  64. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/src/durable_workflow.egg-info/top_level.txt +0 -0
  65. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_activity_cancellation_policies.py +0 -0
  66. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_activity_context.py +0 -0
  67. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_activity_heartbeat_authority.py +0 -0
  68. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_activity_lease_authority.py +0 -0
  69. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_activity_lease_renewal.py +0 -0
  70. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_activity_lease_worker.py +0 -0
  71. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_activity_process.py +0 -0
  72. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_activity_retry_info.py +0 -0
  73. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_auth_composition.py +0 -0
  74. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_avro_value_benchmark.py +0 -0
  75. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_avro_value_protocol.py +0 -0
  76. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_cancellation_context.py +0 -0
  77. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_cancellation_scope_admission.py +0 -0
  78. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_cancellation_scope_authoring.py +0 -0
  79. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_cancellation_scope_boundary.py +0 -0
  80. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_cancellation_scope_opening.py +0 -0
  81. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_child_workflow_policies.py +0 -0
  82. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_ci_checkout.py +0 -0
  83. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_ci_integration_endpoint.py +0 -0
  84. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_client.py +0 -0
  85. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_client_telemetry.py +0 -0
  86. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_client_timeouts.py +0 -0
  87. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_codec_regression_corpus.py +0 -0
  88. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_committed_cancellation_scope_descendants.py +0 -0
  89. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_committed_cancellation_scope_groups.py +0 -0
  90. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_committed_cancellation_scope_history.py +0 -0
  91. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_condition_replay_drift.py +0 -0
  92. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_control_plane_parity_fixtures.py +0 -0
  93. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_cooperative_cancellation.py +0 -0
  94. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_cooperative_cancellation_client.py +0 -0
  95. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_cooperative_cancellation_worker.py +0 -0
  96. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_cooperative_remote_worker.py +0 -0
  97. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_docstring_cross_references.py +0 -0
  98. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_durable_selection.py +0 -0
  99. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_empty_callback_failure.py +0 -0
  100. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_errors.py +0 -0
  101. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_external_storage.py +0 -0
  102. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_external_task_input.py +0 -0
  103. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_external_task_result.py +0 -0
  104. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_failure_reporting.py +0 -0
  105. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_generator_lifecycle.py +0 -0
  106. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_golden_history_replay.py +0 -0
  107. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_history_bundle_pruned.py +0 -0
  108. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_history_bundle_schema2.py +0 -0
  109. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_history_bundle_verify.py +0 -0
  110. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_invocable.py +0 -0
  111. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_local_failure_reporting.py +0 -0
  112. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_message_streams.py +0 -0
  113. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_metric_routes.py +0 -0
  114. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_metrics.py +0 -0
  115. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_nexus_durable_wait.py +0 -0
  116. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_nexus_namespace.py +0 -0
  117. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_nexus_wire.py +0 -0
  118. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_order_processing_example.py +0 -0
  119. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_parallel_completion_order_replay.py +0 -0
  120. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_parallel_saga_authoring.py +0 -0
  121. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_patch_history.py +0 -0
  122. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_pending_operation_replay.py +0 -0
  123. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_poll_failure_backoff.py +0 -0
  124. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_poll_transport.py +0 -0
  125. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_prepared_local_activity_client.py +0 -0
  126. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_prepared_local_activity_groups.py +0 -0
  127. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_prepared_local_activity_policies.py +0 -0
  128. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_prepared_local_activity_worker.py +0 -0
  129. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_public_boundary_scanner.py +0 -0
  130. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_pypi_project_surface.py +0 -0
  131. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_python_conformance.py +0 -0
  132. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_queries.py +0 -0
  133. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_query_task_authority.py +0 -0
  134. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_query_timeout_retry.py +0 -0
  135. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_readme_quickstart.py +0 -0
  136. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_redrive_replay.py +0 -0
  137. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_registration_ambiguity.py +0 -0
  138. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_release_metadata.py +0 -0
  139. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_replay_conformance.py +0 -0
  140. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_replay_verify.py +0 -0
  141. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_retry_policy.py +0 -0
  142. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_run_cancellation_scope_replay.py +0 -0
  143. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_runtime_external_payload_transport.py +0 -0
  144. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_schedules.py +0 -0
  145. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_scoped_run_cancellation_context.py +0 -0
  146. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_sdk_metrics_contract.py +0 -0
  147. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_serializer.py +0 -0
  148. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_signals.py +0 -0
  149. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_sleep.py +0 -0
  150. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_standalone_activity_client.py +0 -0
  151. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_sticky_execution.py +0 -0
  152. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_storage_admission.py +0 -0
  153. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_sync.py +0 -0
  154. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_sync_activities.py +0 -0
  155. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_telemetry.py +0 -0
  156. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_telemetry_carrier.py +0 -0
  157. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_testing_failures.py +0 -0
  158. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_testing_harness.py +0 -0
  159. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_testing_nexus.py +0 -0
  160. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_threaded_heartbeat_example.py +0 -0
  161. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_tls_trust.py +0 -0
  162. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_transport_diagnostics.py +0 -0
  163. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_update_signal_condition_replay.py +0 -0
  164. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_updates.py +0 -0
  165. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_worker.py +0 -0
  166. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_worker_deregistration.py +0 -0
  167. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_worker_observation.py +0 -0
  168. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_worker_session.py +0 -0
  169. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_worker_status.py +0 -0
  170. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_worker_telemetry_wire.py +0 -0
  171. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_workflow_entry_arguments.py +0 -0
  172. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_workflow_entrypoint_validation.py +0 -0
  173. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_workflow_handler_validation.py +0 -0
  174. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_workflow_identity.py +0 -0
  175. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_workflow_result_timeout.py +0 -0
  176. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/tests/test_workflow_streams.py +0 -0
  177. {durable_workflow-2.13.0 → durable_workflow-2.13.2}/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.13.0
3
+ Version: 2.13.2
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.13.0"
7
+ version = "2.13.2"
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"
@@ -79,8 +79,8 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main"
79
79
  durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main"
80
80
 
81
81
  [tool.durable-workflow]
82
- product-train = "2.13.0"
83
- registry-version = "2.13.0"
82
+ product-train = "2.13.2"
83
+ registry-version = "2.13.2"
84
84
  supported-server-versions = "2.8.0"
85
85
  supported-server-release-baselines = { "2" = "2.8.0", "3" = "3.0.0" }
86
86
  worker-protocol-version = "1.20"
@@ -122,10 +122,15 @@ def _cleanup_operation(event: dict[str, Any], prefix: Sequence[dict[str, Any]],
122
122
  "authority_deadline_at": _canonical_time(authority),
123
123
  }
124
124
  sequence = payload.get("sequence")
125
+ # Only an explicit null reads as one call, as in Core; any other value
126
+ # that is not a positive integer is refused, never narrowed.
127
+ span = 1 if recorded.get("sequence_span") is None else recorded["sequence_span"]
128
+ if not _positive(span) or not _positive(recorded.get("sequence")):
129
+ raise _invalid(operation + " follows a delivery without a canonical sequence span")
125
130
  deadline = scope_timestamp(expected["authority_deadline_at"])
126
131
  timestamp = scope_timestamp(event.get("timestamp", event.get("recorded_at")))
127
132
  if (snapshot != expected or scope_id != context.scope_id
128
- or not _positive(sequence) or sequence < recorded["sequence"] + recorded["sequence_span"]
133
+ or not _positive(sequence) or sequence < recorded["sequence"] + span
129
134
  or not _positive(event.get("sequence")) or delivery["sequence"] >= event["sequence"]
130
135
  or timestamp < scope_timestamp(delivery.get("timestamp", delivery.get("recorded_at")))
131
136
  or timestamp >= deadline or not local and (
@@ -112,7 +112,9 @@ class CancellationDelivery:
112
112
  request_id = _text(payload.get("workflow_command_id"), "workflow_command_id")
113
113
  sequence = _positive(payload.get("sequence"), "sequence")
114
114
  kind = _text(payload.get("call_kind"), "call_kind")
115
- span = _positive(payload.get("sequence_span", 1), "sequence_span", maximum=1000)
115
+ # Absent and null both mean one call, as Core's WorkflowStepHistory reads it.
116
+ recorded_span = payload.get("sequence_span")
117
+ span = _positive(1 if recorded_span is None else recorded_span, "sequence_span", maximum=1000)
116
118
  operation = payload.get("operation_sequence")
117
119
  operation_span = _positive(payload.get("operation_sequence_span", 1), "operation_sequence_span", maximum=1000)
118
120
  if kind not in _CALL_KINDS or (kind != "parallel" and span != 1):
@@ -400,8 +400,19 @@ class WorkflowEnvironment:
400
400
  }
401
401
  )
402
402
  elif isinstance(cmd, UpsertSearchAttributes):
403
+ # Record the JSON wire form, as the Server does; replay
404
+ # checks the workflow's update against it.
403
405
  history.append(
404
- {"event_type": "SearchAttributesUpserted", "payload": {}}
406
+ {
407
+ "event_type": "SearchAttributesUpserted",
408
+ "payload": {
409
+ # Every recorded event but a signal is a step.
410
+ "sequence": 1 + sum(
411
+ event["event_type"] != "SignalReceived" for event in history
412
+ ),
413
+ "attributes": json.loads(json.dumps(cmd.attributes)),
414
+ },
415
+ }
405
416
  )
406
417
  elif isinstance(cmd, RecordVersionMarker):
407
418
  history.append(
@@ -1393,6 +1393,130 @@ def _memo_replay_identity(value: Mapping[str, Any]) -> str:
1393
1393
  return serializer.encode(_canonical_memo_entries(value), size_warning=None)
1394
1394
 
1395
1395
 
1396
+ # Core's UpsertSearchAttributesCall, which the Server applies before storing
1397
+ # SearchAttributesUpserted. Replay compares the canonical forms exactly and
1398
+ # refuses anything outside this portable value domain.
1399
+ _SEARCH_ATTRIBUTE_KEY_PATTERN = re.compile(r"[A-Za-z0-9_.:-]{1,64}")
1400
+ _SEARCH_ATTRIBUTE_TRIM = " \t\n\r\0\x0b"
1401
+ _SEARCH_ATTRIBUTE_MAX_STRING_LENGTH = 2048
1402
+ _SEARCH_ATTRIBUTE_MAX_KEYWORD_LENGTH = 255
1403
+ _SEARCH_ATTRIBUTE_TYPES = frozenset({"string", "keyword", "keyword_list", "int", "float", "bool", "datetime"})
1404
+ _INT64_MIN, _INT64_MAX = -(2**63), 2**63 - 1
1405
+
1406
+
1407
+ def _json_type_name(value: Any) -> str:
1408
+ """A diagnostic-safe name for a value's JSON type; never the value itself."""
1409
+ if value is None:
1410
+ return "null"
1411
+ if isinstance(value, bool):
1412
+ return "boolean"
1413
+ if isinstance(value, int):
1414
+ return "integer"
1415
+ if isinstance(value, float):
1416
+ return "number"
1417
+ if isinstance(value, str):
1418
+ return "string"
1419
+ if isinstance(value, list | tuple):
1420
+ return "array"
1421
+ if isinstance(value, Mapping):
1422
+ return "object"
1423
+ return "unsupported value"
1424
+
1425
+
1426
+ def _canonical_search_attributes(attributes: Any, *, authored: bool = False) -> dict[str, tuple[str, Any]]:
1427
+ """Return the type-tagged canonical form of a search-attribute update.
1428
+
1429
+ Raises ValueError for any value Core's UpsertSearchAttributesCall rejects.
1430
+ The type tag keeps distinct values distinct (1, 1.0, True and "1"). An
1431
+ authored tuple is sent as the same JSON list, so it is checked as a list.
1432
+ """
1433
+ if not isinstance(attributes, Mapping) or not attributes:
1434
+ raise ValueError("search attributes must be a non-empty object")
1435
+ canonical: dict[str, tuple[str, Any]] = {}
1436
+ for key, value in attributes.items():
1437
+ if not isinstance(key, str) or _SEARCH_ATTRIBUTE_KEY_PATTERN.fullmatch(key) is None:
1438
+ # Name only the type: an invalid key may carry arbitrary data.
1439
+ raise ValueError(f"a search attribute key ({_json_type_name(key)}) is not 1-64 URL-safe characters")
1440
+ if authored and type(value) is tuple:
1441
+ value = list(value)
1442
+ if value is None:
1443
+ canonical[key] = ("null", None)
1444
+ elif isinstance(value, bool):
1445
+ canonical[key] = ("bool", value)
1446
+ elif isinstance(value, int):
1447
+ if not _INT64_MIN <= value <= _INT64_MAX:
1448
+ raise ValueError(f"search attribute [{key}] integer is outside the 64-bit range")
1449
+ canonical[key] = ("int", value)
1450
+ elif isinstance(value, float):
1451
+ if not math.isfinite(value):
1452
+ raise ValueError(f"search attribute [{key}] must be a finite number")
1453
+ canonical[key] = ("float", value)
1454
+ elif isinstance(value, str):
1455
+ trimmed = value.strip(_SEARCH_ATTRIBUTE_TRIM)
1456
+ if len(trimmed) > _SEARCH_ATTRIBUTE_MAX_STRING_LENGTH:
1457
+ raise ValueError(f"search attribute [{key}] exceeds {_SEARCH_ATTRIBUTE_MAX_STRING_LENGTH} characters")
1458
+ canonical[key] = ("string", trimmed) if trimmed else ("null", None)
1459
+ elif type(value) is list or isinstance(value, Mapping) and not value:
1460
+ # PHP decodes an empty JSON object as an empty list.
1461
+ items: list[str] = []
1462
+ for item in value:
1463
+ if not isinstance(item, str):
1464
+ raise ValueError(f"search attribute [{key}] list values must contain only strings")
1465
+ trimmed = item.strip(_SEARCH_ATTRIBUTE_TRIM)
1466
+ if len(trimmed) > _SEARCH_ATTRIBUTE_MAX_KEYWORD_LENGTH:
1467
+ raise ValueError(
1468
+ f"search attribute [{key}] list values exceed {_SEARCH_ATTRIBUTE_MAX_KEYWORD_LENGTH} characters"
1469
+ )
1470
+ items.append(trimmed)
1471
+ canonical[key] = ("list", tuple(items))
1472
+ else:
1473
+ raise ValueError(f"search attribute [{key}] must be a string, number, boolean, string list or null")
1474
+ return canonical
1475
+
1476
+
1477
+ def _search_attribute_type_choices(value: Any) -> frozenset[str]:
1478
+ """Every type a Server can record for one value of this SDK's untyped upsert.
1479
+
1480
+ The SDK declares no types, but native Server 3.0.x resolves each key as
1481
+ registered, then declared, then inferred from the raw value
1482
+ (rust/src/runtime/search_attribute_upserts.rs:254-289, recorded at :856),
1483
+ and the PHP Server fills registered types into the command the same way
1484
+ (SearchAttributeValueValidator.php:93, WorkerController.php:2734). The SDK
1485
+ cannot see the namespace registry, so this returns the union of the
1486
+ inferred type and every registered type the Server accepts for the value.
1487
+ Any string is treated as possibly registered datetime. The check refuses
1488
+ only a recorded type that cannot hold the value at all.
1489
+ """
1490
+ if isinstance(value, bool):
1491
+ return frozenset({"bool"})
1492
+ if isinstance(value, int):
1493
+ return frozenset({"int", "float"})
1494
+ if isinstance(value, float):
1495
+ return frozenset({"float"})
1496
+ if isinstance(value, str):
1497
+ return frozenset({"keyword", "string", "datetime"})
1498
+ return frozenset({"keyword_list"})
1499
+
1500
+
1501
+ def _search_attribute_types_mismatch(recorded: Any, authored: Mapping[str, Any]) -> str | None:
1502
+ if isinstance(recorded, list) and not recorded:
1503
+ recorded = {} # PHP serializes an empty map as a JSON list.
1504
+ if not isinstance(recorded, Mapping):
1505
+ return "recorded attribute_types is not an object"
1506
+ # A null value has no type. Both Servers type every other raw value,
1507
+ # including a blank string that is stored as null.
1508
+ typed = {key for key, value in authored.items() if value is not None}
1509
+ if not all(isinstance(key, str) for key in recorded) or set(recorded) != typed:
1510
+ return "recorded attribute_types names different keys from the current update"
1511
+ for key in sorted(recorded):
1512
+ kind = recorded[key]
1513
+ if not isinstance(kind, str) or kind not in _SEARCH_ATTRIBUTE_TYPES:
1514
+ return f"recorded attribute type for [{key}] is not a search attribute type"
1515
+ if kind not in _search_attribute_type_choices(authored[key]):
1516
+ return f"recorded attribute type {kind!r} for [{key}] cannot hold the current value"
1517
+ return None
1518
+
1519
+
1396
1520
  def _canonical_memo_entries(entries: Mapping[str, Any], *, require_entries: bool = True) -> dict[str, Any]:
1397
1521
  if require_entries and not entries:
1398
1522
  raise ValueError("workflow memo updates require at least one entry")
@@ -2205,9 +2329,15 @@ class WorkflowContext:
2205
2329
  return self._cancellation_context
2206
2330
 
2207
2331
  def _observe_cancellation_replay_time(self, event: Mapping[str, Any] | None) -> None:
2208
- timestamp = event.get("timestamp") if event is not None else None
2209
- if timestamp is None and event is not None:
2210
- timestamp = event.get("recorded_at")
2332
+ payload = event.get("payload") if event is not None else None
2333
+ # A step reused by redrive keeps the time it was first recorded, as in
2334
+ # Core's RedriveReplayClock::recordedAt; it overrides the event's own time.
2335
+ if isinstance(payload, Mapping) and isinstance(payload.get("reused_recorded_at"), str):
2336
+ timestamp: Any = payload["reused_recorded_at"]
2337
+ else:
2338
+ timestamp = event.get("timestamp") if event is not None else None
2339
+ if timestamp is None and event is not None:
2340
+ timestamp = event.get("recorded_at")
2211
2341
  self._cancellation_replay_time_available = False
2212
2342
  if not isinstance(timestamp, str):
2213
2343
  return
@@ -2333,6 +2463,10 @@ class WorkflowContext:
2333
2463
  ``False`` (otherwise ``True``). The fractional ``timeout`` is rounded
2334
2464
  up to the next whole second to match the server's integer-second
2335
2465
  timer resolution.
2466
+
2467
+ The deadline is fixed when the wait first opens. A signal that leaves
2468
+ the predicate false re-drives the workflow, and the wait reopens with
2469
+ only the time left before that deadline, so signals never extend it.
2336
2470
  """
2337
2471
  timeout_seconds: int | None = None
2338
2472
  if timeout is not None:
@@ -2952,6 +3086,20 @@ def _validated_replay_payload_codec(
2952
3086
  return codec
2953
3087
 
2954
3088
 
3089
+ def _history_event_time(event: Mapping[str, Any]) -> datetime | None:
3090
+ """The recorded time of a history event, or None if it has none."""
3091
+ value = event.get("timestamp")
3092
+ if value is None:
3093
+ value = event.get("recorded_at")
3094
+ if not isinstance(value, str):
3095
+ return None
3096
+ try:
3097
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
3098
+ except ValueError:
3099
+ return None
3100
+ return parsed if parsed.tzinfo is not None and parsed.utcoffset() is not None else None
3101
+
3102
+
2955
3103
  def _decode_history_result(
2956
3104
  payload: dict[str, Any],
2957
3105
  fallback_codec: str | None,
@@ -3713,6 +3861,16 @@ def _workflow_sequence(payload: Mapping[str, Any]) -> int | None:
3713
3861
  return None
3714
3862
 
3715
3863
 
3864
+ def _durable_command_sequence_problem(payload: Mapping[str, Any]) -> str | None:
3865
+ """The sdk-rust reason when a durable command record has no usable sequence."""
3866
+ value = payload.get("sequence")
3867
+ if value is None:
3868
+ return "durable_command_sequence_missing"
3869
+ if type(value) is not int or value <= 0:
3870
+ return "durable_command_sequence_invalid"
3871
+ return None
3872
+
3873
+
3716
3874
  def _parallel_group_integer(payload: Mapping[str, Any], key: str) -> int | None:
3717
3875
  value = payload.get(key)
3718
3876
  if isinstance(value, bool):
@@ -3867,6 +4025,93 @@ def _reorder_completed_parallel_groups(
3867
4025
  return ordered_results, ordered_steps
3868
4026
 
3869
4027
 
4028
+ # The events that claim a workflow sequence, by durable command kind: Core's
4029
+ # WorkflowStepHistory step events and cancellation deliveries, plus each
4030
+ # command's other lifecycle rows, which carry the same sequence. Events that
4031
+ # only point at a step's sequence claim nothing and are not listed: signal
4032
+ # receipts, update records, failure handling, selection records, and
4033
+ # cooperative or scope cancellation requests and preparations. As in sdk-php,
4034
+ # a collision is refused when it involves an upsert: an upsert is the only
4035
+ # command at its sequence.
4036
+ _DURABLE_COMMAND_KINDS: dict[str, str] = {
4037
+ **dict.fromkeys(("ActivityScheduled", "ActivityStarted", "ActivityHeartbeatRecorded", "ActivityRetryScheduled",
4038
+ "ActivityCompleted", "ActivityFailed", "ActivityTimedOut", "ActivityCancelled",
4039
+ "ActivityCancellationAcknowledged"), "activity"),
4040
+ **dict.fromkeys(("TimerScheduled", "TimerFired", "TimerCancelled"), "timer"),
4041
+ **dict.fromkeys(("ChildWorkflowScheduled", "ChildRunStarted", "ChildRunCompleted", "ChildRunFailed",
4042
+ "ChildRunCancelled", "ChildRunTerminated", "ChildCancellationRequested",
4043
+ "ChildCancellationResolved"), "child"),
4044
+ **dict.fromkeys(("ConditionWaitOpened", "ConditionWaitSatisfied", "ConditionWaitTimedOut",
4045
+ "ConditionWaitCancelled"), "condition"),
4046
+ **dict.fromkeys(("SignalWaitOpened", "SignalApplied", "SignalWaitReceived", "SignalWaitTimedOut",
4047
+ "SignalWaitCancelled"), "signal"),
4048
+ **dict.fromkeys(("ServiceOperationScheduled", "ServiceCallStarted", "ServiceCallCompleted", "ServiceCallFailed",
4049
+ "ServiceCallCancelled"), "service_call"),
4050
+ "CancellationScopeOpened": "cancellation_scope",
4051
+ # A delivered cancellation consumes the call it interrupts, scheduled or
4052
+ # not, and its sequence_span reserves the whole interrupted range.
4053
+ **dict.fromkeys(("CooperativeCancellationDelivered", "CancellationScopeDelivered"), "cancellation_delivery"),
4054
+ "WorkflowContinuedAsNew": "continue_as_new",
4055
+ "SideEffectRecorded": "side_effect",
4056
+ "VersionMarkerRecorded": "version_marker",
4057
+ "MemoUpserted": "memo",
4058
+ "SearchAttributesUpserted": "search_attributes",
4059
+ }
4060
+ _UPSERT_COMMAND_KINDS = frozenset({"memo", "search_attributes"})
4061
+
4062
+
4063
+ def _delivery_sequence_span(payload: Mapping[str, Any]) -> int:
4064
+ """A cancellation delivery's sequence_span under the producer contract.
4065
+
4066
+ Absent or null means 1, as Core reads it
4067
+ (WorkflowStepHistory.php:270, `intValue($payload['sequence_span'] ?? null) ?? 1`).
4068
+ Otherwise it must be a positive JSON integer; zero, negative, bool,
4069
+ float, string and every other value are malformed and raise ValueError,
4070
+ so a malformed span never shrinks the claimed range.
4071
+ """
4072
+ span = payload.get("sequence_span")
4073
+ if span is None:
4074
+ return 1
4075
+ if type(span) is not int or span <= 0:
4076
+ # Name only the JSON type: the span may carry arbitrary history data.
4077
+ raise ValueError(f"sequence_span must be a positive integer (got {_json_type_name(span)})")
4078
+ return span
4079
+
4080
+
4081
+ def _assert_delivery_spans(events: Iterable[Mapping[str, Any]]) -> None:
4082
+ for event in events:
4083
+ if _DURABLE_COMMAND_KINDS.get(_history_event_type(event) or "") != "cancellation_delivery":
4084
+ continue
4085
+ raw = event.get("payload")
4086
+ payload: Mapping[str, Any] = raw if isinstance(raw, Mapping) else {}
4087
+ try:
4088
+ _delivery_sequence_span(payload)
4089
+ except ValueError as error:
4090
+ raise NonDeterministicReplayError(
4091
+ _workflow_sequence(payload) or 0,
4092
+ "cancellation delivery with a canonical sequence span",
4093
+ [str(_history_event_type(event))],
4094
+ detail=f"durable_command_sequence_invalid: {error}.",
4095
+ ) from error
4096
+
4097
+
4098
+ def _durable_command_claimed_range(event_type: str | None, payload: Mapping[str, Any], sequence: int) -> range:
4099
+ """The workflow sequences one recorded event claims.
4100
+
4101
+ A cancellation delivery claims sequence through sequence + sequence_span
4102
+ - 1, inclusive, as Core's WorkflowStepHistory::nextSequenceFromEvents
4103
+ extends it. Its span follows the producer contract
4104
+ (_delivery_sequence_span), which replay checks before any parsing.
4105
+ """
4106
+ if _DURABLE_COMMAND_KINDS.get(event_type or "") != "cancellation_delivery":
4107
+ return range(sequence, sequence + 1)
4108
+ return range(sequence, sequence + _delivery_sequence_span(payload))
4109
+
4110
+
4111
+ def _durable_command_kinds_compatible(kinds: frozenset[str]) -> bool:
4112
+ return len(kinds) == 1 or not kinds & _UPSERT_COMMAND_KINDS
4113
+
4114
+
3870
4115
  def _internal_timeout_timer_kind(
3871
4116
  payload: Mapping[str, Any],
3872
4117
  condition_wait_ids_by_sequence: Mapping[int, str] | None = None,
@@ -4303,6 +4548,28 @@ def _recorded_detail_mismatch(command: Any, step: _RecordedStep) -> str | None:
4303
4548
  recorded = step.details.get("entries_identity")
4304
4549
  if isinstance(recorded, str) and recorded != _memo_replay_identity(command.entries):
4305
4550
  return "Recorded memo entries differ from the current workflow memo update."
4551
+ elif isinstance(command, UpsertSearchAttributes):
4552
+ if "search_attributes" not in step.details:
4553
+ return "search_attribute_update_missing: SearchAttributesUpserted history is missing its attributes."
4554
+ try:
4555
+ authored_attributes = _canonical_search_attributes(command.attributes, authored=True)
4556
+ except ValueError as exc:
4557
+ return f"search_attribute_value_mismatch: the current search attribute update is invalid: {exc}"
4558
+ try:
4559
+ recorded_attributes = _canonical_search_attributes(step.details["search_attributes"])
4560
+ except ValueError as exc:
4561
+ return f"search_attribute_value_mismatch: SearchAttributesUpserted history has invalid attributes: {exc}"
4562
+ if recorded_attributes != authored_attributes:
4563
+ return (
4564
+ "search_attribute_value_mismatch: Recorded search attributes differ from the current "
4565
+ "workflow search attribute update."
4566
+ )
4567
+ # Events recorded before attribute_types existed keep value-only
4568
+ # compatibility, as in Core's SearchAttributeReplayIdentity.
4569
+ if "search_attribute_types" in step.details:
4570
+ problem = _search_attribute_types_mismatch(step.details["search_attribute_types"], command.attributes)
4571
+ if problem is not None:
4572
+ return f"search_attribute_type_mismatch: {problem}."
4306
4573
  return None
4307
4574
 
4308
4575
 
@@ -4495,6 +4762,9 @@ def _replay_state(
4495
4762
  ) from exception
4496
4763
 
4497
4764
  events = list(history_events)
4765
+ # A malformed delivery span refuses replay before any history parser can
4766
+ # read it more permissively.
4767
+ _assert_delivery_spans(events)
4498
4768
  _assert_cancellation_scope_replay_supported(
4499
4769
  events, allow_authoring=allow_cancellation_scope_authoring, allow_delivery=allow_cancellation_scope_delivery,
4500
4770
  )
@@ -4577,6 +4847,10 @@ def _replay_state(
4577
4847
  resolved_sequences: set[int] = set()
4578
4848
  condition_wait_ids_by_sequence: dict[int, str] = {}
4579
4849
  selected_condition_wait_ids_by_sequence: dict[int, str] = {}
4850
+ command_kinds_by_sequence: dict[int, frozenset[str]] = {}
4851
+ search_attribute_sequences: set[int] = set()
4852
+ upsert_sequences: set[int] = set()
4853
+ delivery_ranges: list[range] = []
4580
4854
 
4581
4855
  for event in events:
4582
4856
  event_type = _history_event_type(event)
@@ -4584,8 +4858,62 @@ def _replay_state(
4584
4858
  if not isinstance(payload, Mapping):
4585
4859
  payload = {}
4586
4860
  sequence = _workflow_sequence(payload)
4861
+ if event_type == "SearchAttributesUpserted":
4862
+ # Never bind an upsert to a guessed step.
4863
+ sequence_problem = _durable_command_sequence_problem(payload)
4864
+ if sequence_problem is not None:
4865
+ raise NonDeterministicReplayError(
4866
+ len(event_types_by_sequence) + 1,
4867
+ "search attributes upsert with its workflow sequence",
4868
+ ["SearchAttributesUpserted"],
4869
+ detail=f"{sequence_problem}: SearchAttributesUpserted history needs a positive integer "
4870
+ "workflow sequence.",
4871
+ )
4587
4872
  if sequence is None:
4588
4873
  continue
4874
+ if event_type == "SearchAttributesUpserted":
4875
+ if sequence in search_attribute_sequences:
4876
+ raise NonDeterministicReplayError(
4877
+ sequence,
4878
+ "one SearchAttributesUpserted event",
4879
+ ["SearchAttributesUpserted", "SearchAttributesUpserted"],
4880
+ detail="duplicate_search_attribute_update: a workflow sequence records two search "
4881
+ "attribute updates.",
4882
+ )
4883
+ search_attribute_sequences.add(sequence)
4884
+ command_kind = _DURABLE_COMMAND_KINDS.get(event_type or "")
4885
+ if command_kind is not None:
4886
+ claimed = _durable_command_claimed_range(event_type, payload, sequence)
4887
+ # A delivery span claims its whole range; an upsert anywhere inside
4888
+ # it collides, whichever of the two was recorded first.
4889
+ if command_kind in _UPSERT_COMMAND_KINDS:
4890
+ inside = next((sequence for delivery in delivery_ranges if sequence in delivery), None)
4891
+ elif command_kind == "cancellation_delivery":
4892
+ inside = next((upsert for upsert in sorted(upsert_sequences) if upsert in claimed), None)
4893
+ else:
4894
+ inside = None
4895
+ if inside is not None:
4896
+ raise NonDeterministicReplayError(
4897
+ inside,
4898
+ "one durable command per workflow sequence",
4899
+ [*event_types_by_sequence.get(inside, []), event_type or "<unknown>"],
4900
+ detail=f"durable_command_sequence_collision: {event_type} and an upsert claim workflow "
4901
+ f"sequence {inside} inside a cancellation delivery span.",
4902
+ )
4903
+ kinds = command_kinds_by_sequence.get(sequence, frozenset()) | {command_kind}
4904
+ if not _durable_command_kinds_compatible(kinds):
4905
+ raise NonDeterministicReplayError(
4906
+ sequence,
4907
+ "one durable command per workflow sequence",
4908
+ [*event_types_by_sequence.get(sequence, []), event_type or "<unknown>"],
4909
+ detail=f"durable_command_sequence_collision: {event_type} claims a workflow sequence "
4910
+ f"already recorded by a {', '.join(sorted(kinds - {command_kind}))} command.",
4911
+ )
4912
+ command_kinds_by_sequence[sequence] = kinds
4913
+ if command_kind in _UPSERT_COMMAND_KINDS:
4914
+ upsert_sequences.add(sequence)
4915
+ elif command_kind == "cancellation_delivery":
4916
+ delivery_ranges.append(claimed)
4589
4917
  if event_type is not None:
4590
4918
  event_types_by_sequence.setdefault(sequence, []).append(event_type)
4591
4919
  try:
@@ -4669,6 +4997,10 @@ def _replay_state(
4669
4997
  ):
4670
4998
  resolved_sequences.add(sequence)
4671
4999
 
5000
+ # The latest recorded time in this history: when a re-driven wait reopens.
5001
+ history_time = max(
5002
+ (time for time in (_history_event_time(ev) for ev in events) if time is not None), default=None,
5003
+ )
4672
5004
  workflow_start_time: datetime | None = None
4673
5005
  replay_clock_error = "Workflow replay clock unavailable: missing WorkflowStarted persisted start time"
4674
5006
  for ev in events:
@@ -4680,6 +5012,10 @@ def _replay_state(
4680
5012
  explicit_time = "timestamp" in start_payload
4681
5013
  ts = start_payload.get("timestamp") if explicit_time else ev.get("recorded_at")
4682
5014
  clock_source = "payload.timestamp" if explicit_time else "recorded_at"
5015
+ # A redriven run replays from the failed run's start time, as Core does.
5016
+ if start_payload.get("recovery_kind") == "redrive" and "replayed_started_at" in start_payload:
5017
+ ts = start_payload.get("replayed_started_at")
5018
+ clock_source = "payload.replayed_started_at"
4683
5019
  replay_clock_error = f"Workflow replay clock unavailable: invalid or missing WorkflowStarted.{clock_source}"
4684
5020
  if isinstance(ts, str):
4685
5021
  with contextlib.suppress(ValueError):
@@ -4787,6 +5123,9 @@ def _replay_state(
4787
5123
  # to match against their corresponding opened wait in history
4788
5124
  # (Nth yield ↔ Nth opened).
4789
5125
  wait_opened: list[dict[str, Any]] = []
5126
+ # The recorded time of each ``wait_opened`` row, or None when history
5127
+ # carries no parseable time. A reopened wait keeps its first open's deadline.
5128
+ wait_opened_times: list[datetime | None] = []
4790
5129
  # Map condition_wait_id → resolution: 'satisfied' (from ConditionWaitSatisfied
4791
5130
  # in history, future server-recorded) or 'timed_out' (from a matching
4792
5131
  # condition_timeout TimerFired event).
@@ -5334,6 +5673,7 @@ def _replay_state(
5334
5673
  else:
5335
5674
  recorded_wait_steps.append(_recorded_step("condition wait", ev))
5336
5675
  wait_opened.append(dict(payload))
5676
+ wait_opened_times.append(_history_event_time(ev))
5337
5677
  elif etype == "ConditionWaitSatisfied":
5338
5678
  wait_id = payload.get("condition_wait_id")
5339
5679
  if isinstance(wait_id, str) and wait_id:
@@ -5450,6 +5790,32 @@ def _replay_state(
5450
5790
  memo_sequences_added.add(sequence)
5451
5791
  _append_resolved_result(None, "memo upsert", ev)
5452
5792
  elif etype == "SearchAttributesUpserted":
5793
+ # The recorded values are this step's replay identity; without
5794
+ # them the step cannot be checked, so replay is refused.
5795
+ # The first pass refused an upsert without a positive integer sequence.
5796
+ upsert_sequence = cast(int, _workflow_sequence(payload))
5797
+ if "attributes" not in payload:
5798
+ raise NonDeterministicReplayError(
5799
+ upsert_sequence,
5800
+ "search attributes upsert with recorded attributes",
5801
+ ["SearchAttributesUpserted"],
5802
+ detail="search_attribute_update_missing: SearchAttributesUpserted history is missing its "
5803
+ "attributes.",
5804
+ )
5805
+ try:
5806
+ _canonical_search_attributes(payload["attributes"])
5807
+ except ValueError as exc:
5808
+ raise NonDeterministicReplayError(
5809
+ upsert_sequence,
5810
+ "search attributes upsert with canonical attributes",
5811
+ ["SearchAttributesUpserted"],
5812
+ detail=f"search_attribute_value_mismatch: SearchAttributesUpserted history has invalid "
5813
+ f"attributes: {exc}",
5814
+ ) from exc
5815
+ upsert_details = details_by_sequence.setdefault(upsert_sequence, {})
5816
+ upsert_details["search_attributes"] = payload["attributes"]
5817
+ if "attribute_types" in payload:
5818
+ upsert_details["search_attribute_types"] = payload["attribute_types"]
5453
5819
  _append_resolved_result(None, "search attributes upsert", ev)
5454
5820
  elif etype == "SignalReceived":
5455
5821
  signal_name = payload.get("signal_name")
@@ -6619,6 +6985,27 @@ def _replay_state(
6619
6985
  except StopIteration as stop:
6620
6986
  return _terminal_state(stop.value, include_pending=True)
6621
6987
 
6988
+ def _reopened_with_remaining_timeout(command: WaitCondition, first_index: int) -> WaitCondition:
6989
+ """Reopen a logical wait with the time left before its first deadline.
6990
+
6991
+ The deadline is fixed when the wait first opens, as in embedded
6992
+ Workflow: the first ConditionWaitOpened time plus the authored
6993
+ timeout. A non-satisfying signal re-drives the wait, and the reopen
6994
+ carries the remaining whole seconds measured at the latest recorded
6995
+ history time, rounded up and clamped to 0..authored (the sdk-php#269
6996
+ rule). Without recorded times the authored timeout is kept.
6997
+ """
6998
+ authored = command.timeout_seconds
6999
+ if authored is None or first_index >= len(wait_opened):
7000
+ return command
7001
+ opened_at = wait_opened_times[first_index]
7002
+ if opened_at is None or history_time is None:
7003
+ return command
7004
+ remaining = math.ceil(authored - (history_time - opened_at).total_seconds())
7005
+ reopened = copy(command)
7006
+ reopened.timeout_seconds = min(authored, max(0, remaining))
7007
+ return reopened
7008
+
6622
7009
  def _advance_cancellation_clock(indexes: Iterable[int | None]) -> None:
6623
7010
  if ctx._cancellation_context is None and not consumed_scope_deliveries:
6624
7011
  return
@@ -7201,6 +7588,7 @@ def _replay_state(
7201
7588
  # Terminal cleanup may only skip a later open after this
7202
7589
  # yielded wait has already replayed at least one false reopen.
7203
7590
  consumed_reopen_for_current_wait = False
7591
+ first_open_index = wait_yield_count
7204
7592
  while True:
7205
7593
  resolution: str | None = None
7206
7594
  opened: dict[str, Any] | None = None
@@ -7303,7 +7691,10 @@ def _replay_state(
7303
7691
  continue
7304
7692
 
7305
7693
  _set_replaying(False)
7306
- pending.append(cmd)
7694
+ pending.append(
7695
+ _reopened_with_remaining_timeout(cmd, first_open_index)
7696
+ if opened is not None else cmd
7697
+ )
7307
7698
  wait_yield_count = next_wait_index
7308
7699
  return _state(pending)
7309
7700
  continue
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.13.0
3
+ Version: 2.13.2
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
@@ -117,6 +117,7 @@ tests/test_prepared_local_activity_groups.py
117
117
  tests/test_prepared_local_activity_policies.py
118
118
  tests/test_prepared_local_activity_worker.py
119
119
  tests/test_public_boundary_scanner.py
120
+ tests/test_publish_workflow_isolation.py
120
121
  tests/test_pypi_project_surface.py
121
122
  tests/test_python_conformance.py
122
123
  tests/test_queries.py
@@ -124,6 +125,7 @@ tests/test_query_task_authority.py
124
125
  tests/test_query_timeout_retry.py
125
126
  tests/test_readme_quickstart.py
126
127
  tests/test_redrive_replay.py
128
+ tests/test_redrive_successor_evidence.py
127
129
  tests/test_registration_ambiguity.py
128
130
  tests/test_release_metadata.py
129
131
  tests/test_replay.py
@@ -137,6 +139,7 @@ tests/test_runtime_external_payload_transport.py
137
139
  tests/test_schedules.py
138
140
  tests/test_scoped_run_cancellation_context.py
139
141
  tests/test_sdk_metrics_contract.py
142
+ tests/test_search_attribute_replay_identity.py
140
143
  tests/test_serializer.py
141
144
  tests/test_signals.py
142
145
  tests/test_sleep.py