durable-workflow 2.13.1__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.1/src/durable_workflow.egg-info → durable_workflow-2.13.2}/PKG-INFO +1 -1
  2. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/pyproject.toml +3 -3
  3. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/_cancellation_scope_history.py +6 -1
  4. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/_cooperative_cancellation.py +3 -1
  5. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/testing.py +12 -1
  6. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/workflow.py +382 -1
  7. {durable_workflow-2.13.1 → durable_workflow-2.13.2/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  8. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow.egg-info/SOURCES.txt +3 -0
  9. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_cancellation_remaining_time.py +42 -11
  10. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_committed_cancellation_scope_replay.py +96 -0
  11. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_history_event_contract.py +16 -3
  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.1 → 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.1 → 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.1 → durable_workflow-2.13.2}/tests/test_wait_condition.py +99 -0
  19. durable_workflow-2.13.1/tests/test_replay_clock.py +0 -133
  20. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/LICENSE +0 -0
  21. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/README.md +0 -0
  22. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/setup.cfg +0 -0
  23. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/__init__.py +0 -0
  24. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/_activity_lease.py +0 -0
  25. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/_activity_process.py +0 -0
  26. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/_avro.py +0 -0
  27. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/_cancellation_scope.py +0 -0
  28. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/_failure_reporting.py +0 -0
  29. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/_metric_routes.py +0 -0
  30. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/_poll_transport.py +0 -0
  31. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/_prepared_local_activity.py +0 -0
  32. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/_query_task_authority.py +0 -0
  33. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
  34. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/activity.py +0 -0
  35. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/auth_composition.py +0 -0
  36. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/cancellation.py +0 -0
  37. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/client.py +0 -0
  38. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/errors.py +0 -0
  39. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/external_storage.py +0 -0
  40. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/external_task_input.py +0 -0
  41. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/external_task_result.py +0 -0
  42. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/history_bundle_verify.py +0 -0
  43. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/interceptors.py +0 -0
  44. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/invocable.py +0 -0
  45. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/metrics.py +0 -0
  46. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/nexus.py +0 -0
  47. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/py.typed +0 -0
  48. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/python_conformance.py +0 -0
  49. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/replay_conformance.py +0 -0
  50. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/replay_verify.py +0 -0
  51. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/retry_policy.py +0 -0
  52. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/serializer.py +0 -0
  53. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/server_compatibility.json +0 -0
  54. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/sync.py +0 -0
  55. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/telemetry.py +0 -0
  56. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/telemetry_carrier.py +0 -0
  57. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/worker.py +0 -0
  58. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/worker_session.py +0 -0
  59. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/worker_status.py +0 -0
  60. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  61. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  62. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  63. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow.egg-info/requires.txt +0 -0
  64. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow.egg-info/top_level.txt +0 -0
  65. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_activity_cancellation_policies.py +0 -0
  66. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_activity_context.py +0 -0
  67. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_activity_heartbeat_authority.py +0 -0
  68. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_activity_lease_authority.py +0 -0
  69. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_activity_lease_renewal.py +0 -0
  70. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_activity_lease_worker.py +0 -0
  71. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_activity_process.py +0 -0
  72. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_activity_retry_info.py +0 -0
  73. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_auth_composition.py +0 -0
  74. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_avro_value_benchmark.py +0 -0
  75. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_avro_value_protocol.py +0 -0
  76. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_cancellation_context.py +0 -0
  77. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_cancellation_scope_admission.py +0 -0
  78. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_cancellation_scope_authoring.py +0 -0
  79. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_cancellation_scope_boundary.py +0 -0
  80. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_cancellation_scope_opening.py +0 -0
  81. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_child_workflow_policies.py +0 -0
  82. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_ci_checkout.py +0 -0
  83. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_ci_integration_endpoint.py +0 -0
  84. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_client.py +0 -0
  85. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_client_telemetry.py +0 -0
  86. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_client_timeouts.py +0 -0
  87. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_codec_regression_corpus.py +0 -0
  88. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_committed_cancellation_scope_descendants.py +0 -0
  89. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_committed_cancellation_scope_groups.py +0 -0
  90. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_committed_cancellation_scope_history.py +0 -0
  91. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_condition_replay_drift.py +0 -0
  92. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_control_plane_parity_fixtures.py +0 -0
  93. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_cooperative_cancellation.py +0 -0
  94. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_cooperative_cancellation_client.py +0 -0
  95. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_cooperative_cancellation_worker.py +0 -0
  96. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_cooperative_remote_worker.py +0 -0
  97. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_docstring_cross_references.py +0 -0
  98. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_durable_selection.py +0 -0
  99. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_empty_callback_failure.py +0 -0
  100. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_errors.py +0 -0
  101. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_external_storage.py +0 -0
  102. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_external_task_input.py +0 -0
  103. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_external_task_result.py +0 -0
  104. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_failure_reporting.py +0 -0
  105. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_generator_lifecycle.py +0 -0
  106. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_golden_history_replay.py +0 -0
  107. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_history_bundle_pruned.py +0 -0
  108. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_history_bundle_schema2.py +0 -0
  109. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_history_bundle_verify.py +0 -0
  110. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_invocable.py +0 -0
  111. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_local_failure_reporting.py +0 -0
  112. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_message_streams.py +0 -0
  113. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_metric_routes.py +0 -0
  114. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_metrics.py +0 -0
  115. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_nexus_durable_wait.py +0 -0
  116. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_nexus_namespace.py +0 -0
  117. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_nexus_wire.py +0 -0
  118. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_order_processing_example.py +0 -0
  119. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_parallel_completion_order_replay.py +0 -0
  120. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_parallel_saga_authoring.py +0 -0
  121. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_patch_history.py +0 -0
  122. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_pending_operation_replay.py +0 -0
  123. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_poll_failure_backoff.py +0 -0
  124. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_poll_transport.py +0 -0
  125. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_prepared_local_activity_client.py +0 -0
  126. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_prepared_local_activity_groups.py +0 -0
  127. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_prepared_local_activity_policies.py +0 -0
  128. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_prepared_local_activity_worker.py +0 -0
  129. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_public_boundary_scanner.py +0 -0
  130. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_pypi_project_surface.py +0 -0
  131. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_python_conformance.py +0 -0
  132. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_queries.py +0 -0
  133. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_query_task_authority.py +0 -0
  134. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_query_timeout_retry.py +0 -0
  135. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_readme_quickstart.py +0 -0
  136. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_redrive_replay.py +0 -0
  137. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_registration_ambiguity.py +0 -0
  138. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_release_metadata.py +0 -0
  139. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_replay_conformance.py +0 -0
  140. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_replay_verify.py +0 -0
  141. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_retry_policy.py +0 -0
  142. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_run_cancellation_scope_replay.py +0 -0
  143. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_runtime_external_payload_transport.py +0 -0
  144. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_schedules.py +0 -0
  145. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_scoped_run_cancellation_context.py +0 -0
  146. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_sdk_metrics_contract.py +0 -0
  147. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_serializer.py +0 -0
  148. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_signals.py +0 -0
  149. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_sleep.py +0 -0
  150. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_standalone_activity_client.py +0 -0
  151. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_sticky_execution.py +0 -0
  152. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_storage_admission.py +0 -0
  153. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_sync.py +0 -0
  154. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_sync_activities.py +0 -0
  155. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_telemetry.py +0 -0
  156. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_telemetry_carrier.py +0 -0
  157. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_testing_failures.py +0 -0
  158. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_testing_harness.py +0 -0
  159. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_testing_nexus.py +0 -0
  160. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_threaded_heartbeat_example.py +0 -0
  161. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_tls_trust.py +0 -0
  162. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_transport_diagnostics.py +0 -0
  163. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_update_signal_condition_replay.py +0 -0
  164. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_updates.py +0 -0
  165. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_worker.py +0 -0
  166. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_worker_deregistration.py +0 -0
  167. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_worker_observation.py +0 -0
  168. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_worker_session.py +0 -0
  169. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_worker_status.py +0 -0
  170. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_worker_telemetry_wire.py +0 -0
  171. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_workflow_entry_arguments.py +0 -0
  172. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_workflow_entrypoint_validation.py +0 -0
  173. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_workflow_handler_validation.py +0 -0
  174. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_workflow_identity.py +0 -0
  175. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_workflow_result_timeout.py +0 -0
  176. {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_workflow_streams.py +0 -0
  177. {durable_workflow-2.13.1 → 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.1
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.1"
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.1"
83
- registry-version = "2.13.1"
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")
@@ -2339,6 +2463,10 @@ class WorkflowContext:
2339
2463
  ``False`` (otherwise ``True``). The fractional ``timeout`` is rounded
2340
2464
  up to the next whole second to match the server's integer-second
2341
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.
2342
2470
  """
2343
2471
  timeout_seconds: int | None = None
2344
2472
  if timeout is not None:
@@ -2958,6 +3086,20 @@ def _validated_replay_payload_codec(
2958
3086
  return codec
2959
3087
 
2960
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
+
2961
3103
  def _decode_history_result(
2962
3104
  payload: dict[str, Any],
2963
3105
  fallback_codec: str | None,
@@ -3719,6 +3861,16 @@ def _workflow_sequence(payload: Mapping[str, Any]) -> int | None:
3719
3861
  return None
3720
3862
 
3721
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
+
3722
3874
  def _parallel_group_integer(payload: Mapping[str, Any], key: str) -> int | None:
3723
3875
  value = payload.get(key)
3724
3876
  if isinstance(value, bool):
@@ -3873,6 +4025,93 @@ def _reorder_completed_parallel_groups(
3873
4025
  return ordered_results, ordered_steps
3874
4026
 
3875
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
+
3876
4115
  def _internal_timeout_timer_kind(
3877
4116
  payload: Mapping[str, Any],
3878
4117
  condition_wait_ids_by_sequence: Mapping[int, str] | None = None,
@@ -4309,6 +4548,28 @@ def _recorded_detail_mismatch(command: Any, step: _RecordedStep) -> str | None:
4309
4548
  recorded = step.details.get("entries_identity")
4310
4549
  if isinstance(recorded, str) and recorded != _memo_replay_identity(command.entries):
4311
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}."
4312
4573
  return None
4313
4574
 
4314
4575
 
@@ -4501,6 +4762,9 @@ def _replay_state(
4501
4762
  ) from exception
4502
4763
 
4503
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)
4504
4768
  _assert_cancellation_scope_replay_supported(
4505
4769
  events, allow_authoring=allow_cancellation_scope_authoring, allow_delivery=allow_cancellation_scope_delivery,
4506
4770
  )
@@ -4583,6 +4847,10 @@ def _replay_state(
4583
4847
  resolved_sequences: set[int] = set()
4584
4848
  condition_wait_ids_by_sequence: dict[int, str] = {}
4585
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] = []
4586
4854
 
4587
4855
  for event in events:
4588
4856
  event_type = _history_event_type(event)
@@ -4590,8 +4858,62 @@ def _replay_state(
4590
4858
  if not isinstance(payload, Mapping):
4591
4859
  payload = {}
4592
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
+ )
4593
4872
  if sequence is None:
4594
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)
4595
4917
  if event_type is not None:
4596
4918
  event_types_by_sequence.setdefault(sequence, []).append(event_type)
4597
4919
  try:
@@ -4675,6 +4997,10 @@ def _replay_state(
4675
4997
  ):
4676
4998
  resolved_sequences.add(sequence)
4677
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
+ )
4678
5004
  workflow_start_time: datetime | None = None
4679
5005
  replay_clock_error = "Workflow replay clock unavailable: missing WorkflowStarted persisted start time"
4680
5006
  for ev in events:
@@ -4797,6 +5123,9 @@ def _replay_state(
4797
5123
  # to match against their corresponding opened wait in history
4798
5124
  # (Nth yield ↔ Nth opened).
4799
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] = []
4800
5129
  # Map condition_wait_id → resolution: 'satisfied' (from ConditionWaitSatisfied
4801
5130
  # in history, future server-recorded) or 'timed_out' (from a matching
4802
5131
  # condition_timeout TimerFired event).
@@ -5344,6 +5673,7 @@ def _replay_state(
5344
5673
  else:
5345
5674
  recorded_wait_steps.append(_recorded_step("condition wait", ev))
5346
5675
  wait_opened.append(dict(payload))
5676
+ wait_opened_times.append(_history_event_time(ev))
5347
5677
  elif etype == "ConditionWaitSatisfied":
5348
5678
  wait_id = payload.get("condition_wait_id")
5349
5679
  if isinstance(wait_id, str) and wait_id:
@@ -5460,6 +5790,32 @@ def _replay_state(
5460
5790
  memo_sequences_added.add(sequence)
5461
5791
  _append_resolved_result(None, "memo upsert", ev)
5462
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"]
5463
5819
  _append_resolved_result(None, "search attributes upsert", ev)
5464
5820
  elif etype == "SignalReceived":
5465
5821
  signal_name = payload.get("signal_name")
@@ -6629,6 +6985,27 @@ def _replay_state(
6629
6985
  except StopIteration as stop:
6630
6986
  return _terminal_state(stop.value, include_pending=True)
6631
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
+
6632
7009
  def _advance_cancellation_clock(indexes: Iterable[int | None]) -> None:
6633
7010
  if ctx._cancellation_context is None and not consumed_scope_deliveries:
6634
7011
  return
@@ -7211,6 +7588,7 @@ def _replay_state(
7211
7588
  # Terminal cleanup may only skip a later open after this
7212
7589
  # yielded wait has already replayed at least one false reopen.
7213
7590
  consumed_reopen_for_current_wait = False
7591
+ first_open_index = wait_yield_count
7214
7592
  while True:
7215
7593
  resolution: str | None = None
7216
7594
  opened: dict[str, Any] | None = None
@@ -7313,7 +7691,10 @@ def _replay_state(
7313
7691
  continue
7314
7692
 
7315
7693
  _set_replaying(False)
7316
- pending.append(cmd)
7694
+ pending.append(
7695
+ _reopened_with_remaining_timeout(cmd, first_open_index)
7696
+ if opened is not None else cmd
7697
+ )
7317
7698
  wait_yield_count = next_wait_index
7318
7699
  return _state(pending)
7319
7700
  continue
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.13.1
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