durable-workflow 2.5.2__tar.gz → 2.5.3__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 (126) hide show
  1. {durable_workflow-2.5.2/src/durable_workflow.egg-info → durable_workflow-2.5.3}/PKG-INFO +1 -1
  2. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/pyproject.toml +3 -3
  3. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/src/durable_workflow/worker.py +30 -14
  4. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/src/durable_workflow/workflow.py +17 -10
  5. {durable_workflow-2.5.2 → durable_workflow-2.5.3/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  6. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/src/durable_workflow.egg-info/SOURCES.txt +1 -0
  7. durable_workflow-2.5.3/tests/test_condition_replay_drift.py +132 -0
  8. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_replay_regression_corpus.py +2 -0
  9. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_wait_condition.py +10 -15
  10. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/LICENSE +0 -0
  11. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/README.md +0 -0
  12. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/setup.cfg +0 -0
  13. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/src/durable_workflow/__init__.py +0 -0
  14. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/src/durable_workflow/_activity_process.py +0 -0
  15. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/src/durable_workflow/_avro.py +0 -0
  16. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/src/durable_workflow/_cancellation_scope.py +0 -0
  17. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/src/durable_workflow/_cancellation_scope_history.py +0 -0
  18. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/src/durable_workflow/_cooperative_cancellation.py +0 -0
  19. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/src/durable_workflow/_prepared_local_activity.py +0 -0
  20. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
  21. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/src/durable_workflow/activity.py +0 -0
  22. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/src/durable_workflow/auth_composition.py +0 -0
  23. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/src/durable_workflow/cancellation.py +0 -0
  24. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/src/durable_workflow/client.py +0 -0
  25. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/src/durable_workflow/errors.py +0 -0
  26. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/src/durable_workflow/external_storage.py +0 -0
  27. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/src/durable_workflow/external_task_input.py +0 -0
  28. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/src/durable_workflow/external_task_result.py +0 -0
  29. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/src/durable_workflow/history_bundle_verify.py +0 -0
  30. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/src/durable_workflow/interceptors.py +0 -0
  31. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/src/durable_workflow/invocable.py +0 -0
  32. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/src/durable_workflow/metrics.py +0 -0
  33. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/src/durable_workflow/nexus.py +0 -0
  34. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/src/durable_workflow/py.typed +0 -0
  35. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/src/durable_workflow/python_conformance.py +0 -0
  36. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/src/durable_workflow/replay_conformance.py +0 -0
  37. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/src/durable_workflow/replay_verify.py +0 -0
  38. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/src/durable_workflow/retry_policy.py +0 -0
  39. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/src/durable_workflow/serializer.py +0 -0
  40. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/src/durable_workflow/sync.py +0 -0
  41. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/src/durable_workflow/testing.py +0 -0
  42. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/src/durable_workflow/worker_session.py +0 -0
  43. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  44. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  45. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  46. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/src/durable_workflow.egg-info/requires.txt +0 -0
  47. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/src/durable_workflow.egg-info/top_level.txt +0 -0
  48. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_activity_cancellation_policies.py +0 -0
  49. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_activity_context.py +0 -0
  50. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_activity_process.py +0 -0
  51. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_auth_composition.py +0 -0
  52. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_avro_value_benchmark.py +0 -0
  53. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_avro_value_protocol.py +0 -0
  54. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_cancellation_context.py +0 -0
  55. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_cancellation_remaining_time.py +0 -0
  56. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_cancellation_scope_admission.py +0 -0
  57. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_cancellation_scope_authoring.py +0 -0
  58. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_cancellation_scope_boundary.py +0 -0
  59. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_cancellation_scope_opening.py +0 -0
  60. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_child_workflow_policies.py +0 -0
  61. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_ci_checkout.py +0 -0
  62. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_ci_integration_endpoint.py +0 -0
  63. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_client.py +0 -0
  64. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_client_timeouts.py +0 -0
  65. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_codec_regression_corpus.py +0 -0
  66. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_committed_cancellation_scope_descendants.py +0 -0
  67. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_committed_cancellation_scope_groups.py +0 -0
  68. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_committed_cancellation_scope_history.py +0 -0
  69. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_committed_cancellation_scope_replay.py +0 -0
  70. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_control_plane_parity_fixtures.py +0 -0
  71. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_cooperative_cancellation.py +0 -0
  72. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_cooperative_cancellation_client.py +0 -0
  73. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_cooperative_cancellation_worker.py +0 -0
  74. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_cooperative_remote_worker.py +0 -0
  75. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_docstring_cross_references.py +0 -0
  76. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_durable_selection.py +0 -0
  77. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_errors.py +0 -0
  78. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_external_storage.py +0 -0
  79. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_external_task_input.py +0 -0
  80. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_external_task_result.py +0 -0
  81. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_golden_history_replay.py +0 -0
  82. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_history_bundle_verify.py +0 -0
  83. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_history_event_contract.py +0 -0
  84. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_invocable.py +0 -0
  85. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_message_streams.py +0 -0
  86. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_metrics.py +0 -0
  87. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_order_processing_example.py +0 -0
  88. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_parallel_completion_order_replay.py +0 -0
  89. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_parallel_saga_authoring.py +0 -0
  90. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_patch_history.py +0 -0
  91. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_pending_operation_replay.py +0 -0
  92. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_prepared_local_activity_client.py +0 -0
  93. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_prepared_local_activity_groups.py +0 -0
  94. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_prepared_local_activity_policies.py +0 -0
  95. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_prepared_local_activity_worker.py +0 -0
  96. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_public_boundary_scanner.py +0 -0
  97. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_pypi_project_surface.py +0 -0
  98. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_python_conformance.py +0 -0
  99. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_queries.py +0 -0
  100. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_readme_quickstart.py +0 -0
  101. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_redrive_replay.py +0 -0
  102. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_release_metadata.py +0 -0
  103. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_replay.py +0 -0
  104. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_replay_conformance.py +0 -0
  105. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_replay_verify.py +0 -0
  106. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_retry_policy.py +0 -0
  107. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_run_cancellation_scope_replay.py +0 -0
  108. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_runtime_external_payload_transport.py +0 -0
  109. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_schedules.py +0 -0
  110. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_scoped_run_cancellation_context.py +0 -0
  111. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_serializer.py +0 -0
  112. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_signals.py +0 -0
  113. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_sleep.py +0 -0
  114. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_standalone_activity_client.py +0 -0
  115. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_sticky_execution.py +0 -0
  116. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_storage_admission.py +0 -0
  117. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_sync.py +0 -0
  118. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_testing_harness.py +0 -0
  119. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_update_signal_condition_replay.py +0 -0
  120. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_updates.py +0 -0
  121. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_worker.py +0 -0
  122. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_worker_session.py +0 -0
  123. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_workflow_entrypoint_validation.py +0 -0
  124. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_workflow_result_timeout.py +0 -0
  125. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/tests/test_workflow_streams.py +0 -0
  126. {durable_workflow-2.5.2 → durable_workflow-2.5.3}/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.5.2
3
+ Version: 2.5.3
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.5.2"
7
+ version = "2.5.3"
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"
@@ -71,8 +71,8 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main"
71
71
  durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main"
72
72
 
73
73
  [tool.durable-workflow]
74
- product-train = "2.5.2"
75
- registry-version = "2.5.2"
74
+ product-train = "2.5.3"
75
+ registry-version = "2.5.3"
76
76
  supported-server-versions = "2.5.16"
77
77
  worker-protocol-version = "1.19"
78
78
  control-plane-version = "2"
@@ -69,6 +69,7 @@ from .errors import (
69
69
  DurableWorkflowError,
70
70
  ExternalPayloadError,
71
71
  InvalidArgument,
72
+ NonDeterministicReplayError,
72
73
  NonRetryableError,
73
74
  QueryFailed,
74
75
  ServerError,
@@ -2502,20 +2503,35 @@ class Worker:
2502
2503
 
2503
2504
  update_id = task.get("workflow_update_id")
2504
2505
  if isinstance(update_id, str) and update_id:
2505
- update_command = apply_update(
2506
- cls,
2507
- history,
2508
- start_input,
2509
- update_id,
2510
- workflow_id=task.get("workflow_id"),
2511
- run_id=run_id,
2512
- payload_codec=codec,
2513
- external_storage=self.external_storage,
2514
- external_storage_cache=self.external_storage_cache,
2515
- prepare_local_activities=self._prepared_local_activities_supported,
2516
- prepare_local_activity_groups=self._prepared_local_activity_groups_supported,
2517
- local_activity_cancellation_policies=self._local_activity_cancellation_policies,
2518
- )
2506
+ try:
2507
+ update_command = apply_update(
2508
+ cls,
2509
+ history,
2510
+ start_input,
2511
+ update_id,
2512
+ workflow_id=task.get("workflow_id"),
2513
+ run_id=run_id,
2514
+ payload_codec=codec,
2515
+ external_storage=self.external_storage,
2516
+ external_storage_cache=self.external_storage_cache,
2517
+ prepare_local_activities=self._prepared_local_activities_supported,
2518
+ prepare_local_activity_groups=self._prepared_local_activity_groups_supported,
2519
+ local_activity_cancellation_policies=self._local_activity_cancellation_policies,
2520
+ )
2521
+ except NonDeterministicReplayError as exc:
2522
+ log.exception("workflow update task %s replay failed for run %s", task_id, run_id)
2523
+ try:
2524
+ await self.client.fail_workflow_task(
2525
+ task_id=task_id,
2526
+ lease_owner=self.worker_id,
2527
+ workflow_task_attempt=attempt,
2528
+ message=f"workflow replay failed before update {update_id!r}: {exc}",
2529
+ failure_type=type(exc).__name__,
2530
+ stack_trace=traceback.format_exc(),
2531
+ )
2532
+ except Exception as failure_error:
2533
+ log.warning("failed to report update replay failure: %s", failure_error)
2534
+ return None
2519
2535
  command = update_command.to_server_command(
2520
2536
  self.task_queue,
2521
2537
  payload_codec=command_codec,
@@ -3067,6 +3067,8 @@ def apply_update(
3067
3067
  prepare_local_activity_groups=prepare_local_activity_groups,
3068
3068
  local_activity_cancellation_policies=local_activity_cancellation_policies,
3069
3069
  )
3070
+ except NonDeterministicReplayError:
3071
+ raise
3070
3072
  except Exception as exc:
3071
3073
  return _fail_update_from_exception(
3072
3074
  update_id,
@@ -5144,16 +5146,20 @@ def _replay_state(
5144
5146
  for _ in range(boundary):
5145
5147
  _apply_receiver(pending_receivers.pop(0))
5146
5148
 
5147
- def _condition_wait_mismatch(opened: Mapping[str, Any], cmd: WaitCondition) -> FailWorkflow | None:
5149
+ def _condition_wait_mismatch(
5150
+ opened: Mapping[str, Any], cmd: WaitCondition,
5151
+ ) -> NonDeterministicReplayError | None:
5152
+ step = recorded_wait_steps[wait_yield_count]
5153
+ identity = f"condition wait {opened.get('condition_wait_id')!r}"
5148
5154
  opened_key = opened.get("condition_key")
5149
5155
  if isinstance(opened_key, str) and opened_key != (cmd.condition_key or ""):
5150
- return FailWorkflow(
5151
- message=(
5152
- "wait_condition key changed during replay: "
5156
+ return NonDeterministicReplayError(
5157
+ step.workflow_sequence, "condition wait", step.event_types,
5158
+ detail=(
5159
+ f"condition_key_changed: {identity} wait_condition key changed during replay: "
5153
5160
  f"history has {opened_key!r}, workflow yielded "
5154
5161
  f"{cmd.condition_key!r}"
5155
5162
  ),
5156
- exception_type="NonDeterministicWaitCondition",
5157
5163
  )
5158
5164
 
5159
5165
  opened_fingerprint = opened.get("condition_definition_fingerprint")
@@ -5161,13 +5167,14 @@ def _replay_state(
5161
5167
  isinstance(opened_fingerprint, str)
5162
5168
  and cmd.condition_definition_fingerprint != opened_fingerprint
5163
5169
  ):
5164
- return FailWorkflow(
5165
- message=(
5170
+ return NonDeterministicReplayError(
5171
+ step.workflow_sequence, "condition wait", step.event_types,
5172
+ detail=(
5173
+ f"condition_fingerprint_changed: {identity} "
5166
5174
  "wait_condition predicate fingerprint changed during replay: "
5167
5175
  f"history has {opened_fingerprint!r}, workflow yielded "
5168
5176
  f"{cmd.condition_definition_fingerprint!r}"
5169
5177
  ),
5170
- exception_type="NonDeterministicWaitCondition",
5171
5178
  )
5172
5179
 
5173
5180
  return None
@@ -6655,7 +6662,7 @@ def _replay_state(
6655
6662
  next_value = True
6656
6663
  continue
6657
6664
  if next_opened_mismatch is not None:
6658
- return _state([next_opened_mismatch])
6665
+ raise next_opened_mismatch
6659
6666
  step = _next_unconsumed_recorded_step()
6660
6667
  if step is not None:
6661
6668
  _assert_step_matches(cmd, step)
@@ -6707,7 +6714,7 @@ def _replay_state(
6707
6714
  opened = wait_opened[wait_yield_count]
6708
6715
  mismatch = _condition_wait_mismatch(opened, cmd)
6709
6716
  if mismatch is not None:
6710
- return _state([mismatch])
6717
+ raise mismatch
6711
6718
  opened_id = opened.get("condition_wait_id")
6712
6719
  if isinstance(opened_id, str):
6713
6720
  resolution = wait_resolutions.get(opened_id)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.5.2
3
+ Version: 2.5.3
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
@@ -62,6 +62,7 @@ tests/test_committed_cancellation_scope_descendants.py
62
62
  tests/test_committed_cancellation_scope_groups.py
63
63
  tests/test_committed_cancellation_scope_history.py
64
64
  tests/test_committed_cancellation_scope_replay.py
65
+ tests/test_condition_replay_drift.py
65
66
  tests/test_control_plane_parity_fixtures.py
66
67
  tests/test_cooperative_cancellation.py
67
68
  tests/test_cooperative_cancellation_client.py
@@ -0,0 +1,132 @@
1
+ from __future__ import annotations
2
+
3
+ from unittest.mock import AsyncMock
4
+
5
+ import pytest
6
+
7
+ from durable_workflow import Worker, serializer, workflow
8
+ from durable_workflow.client import Client
9
+ from durable_workflow.errors import NonDeterministicReplayError, QueryFailed
10
+ from durable_workflow.workflow import WorkflowContext, apply_update, query_state, replay
11
+
12
+
13
+ @workflow.defn(name="tests.replay.condition-identity")
14
+ class ConditionIdentityWorkflow:
15
+ @workflow.query("state")
16
+ def state(self) -> str:
17
+ return "waiting"
18
+
19
+ @workflow.update("approve")
20
+ def approve(self) -> str:
21
+ return "approved"
22
+
23
+ def run(self, ctx: WorkflowContext): # type: ignore[no-untyped-def]
24
+ yield ctx.wait_condition(lambda: False, key="approved")
25
+ return "done"
26
+
27
+
28
+ def condition_history(field: str) -> list[dict]:
29
+ return [{
30
+ "event_type": "ConditionWaitOpened",
31
+ "payload": {
32
+ "sequence": 7,
33
+ "condition_wait_id": "wait-original",
34
+ "condition_key": "approved",
35
+ field: "old-key" if field == "condition_key" else "sha256:different",
36
+ },
37
+ }]
38
+
39
+
40
+ @pytest.mark.parametrize("field", ["condition_key", "condition_definition_fingerprint"])
41
+ @pytest.mark.parametrize("consumer", ["replay", "query", "update"])
42
+ def test_condition_drift_is_not_a_business_failure(field: str, consumer: str) -> None:
43
+ history = condition_history(field)
44
+ error = QueryFailed if consumer == "query" else NonDeterministicReplayError
45
+ with pytest.raises(error) as captured:
46
+ if consumer == "query":
47
+ query_state(ConditionIdentityWorkflow, history, [], "state")
48
+ elif consumer == "update":
49
+ apply_update(ConditionIdentityWorkflow, history, [], "update-original")
50
+ else:
51
+ replay(ConditionIdentityWorkflow, history, [])
52
+
53
+ mismatch = captured.value.__cause__ if consumer == "query" else captured.value
54
+ assert isinstance(mismatch, NonDeterministicReplayError)
55
+ assert mismatch.workflow_sequence == 7
56
+ assert "wait-original" in str(mismatch)
57
+ assert "changed" in str(mismatch)
58
+
59
+
60
+ @pytest.mark.asyncio
61
+ @pytest.mark.parametrize("field", ["condition_key", "condition_definition_fingerprint"])
62
+ @pytest.mark.parametrize("consumer", ["workflow", "update"])
63
+ async def test_worker_reports_drift_once_without_a_completion(field: str, consumer: str) -> None:
64
+ client = AsyncMock(spec=Client)
65
+ worker = Worker(client, task_queue="condition-drift", workflows=[ConditionIdentityWorkflow])
66
+ task = {
67
+ "task_id": "task-original",
68
+ "workflow_id": "workflow-original",
69
+ "run_id": "run-original",
70
+ "workflow_type": "tests.replay.condition-identity",
71
+ "workflow_task_attempt": 2,
72
+ "history_events": condition_history(field),
73
+ "arguments": serializer.envelope([], codec="avro"),
74
+ "payload_codec": "avro",
75
+ }
76
+ if consumer == "update":
77
+ task["workflow_update_id"] = "update-original"
78
+
79
+ assert await worker._run_workflow_task_core(task) is None
80
+
81
+ client.complete_workflow_task.assert_not_awaited()
82
+ client.fail_workflow_task.assert_awaited_once()
83
+ report = client.fail_workflow_task.await_args.kwargs
84
+ assert report["task_id"] == "task-original"
85
+ assert report["workflow_task_attempt"] == 2
86
+ assert report["lease_owner"] == worker.worker_id
87
+ assert report["failure_type"] == "NonDeterministicReplayError"
88
+ assert "wait-original" in report["message"]
89
+
90
+
91
+ def test_application_predicate_failure_remains_terminal() -> None:
92
+ @workflow.defn(name="tests.condition-predicate-failure")
93
+ class PredicateFailure:
94
+ def run(self, ctx: WorkflowContext): # type: ignore[no-untyped-def]
95
+ def predicate() -> bool:
96
+ raise ValueError("application failed")
97
+
98
+ yield ctx.wait_condition(predicate, key="approved")
99
+
100
+ outcome = replay(PredicateFailure, [], [])
101
+ assert len(outcome.commands) == 1
102
+ command = outcome.commands[0].to_server_command("condition-drift")
103
+ assert command["type"] == "fail_workflow"
104
+ assert command["exception_type"] == "ValueError"
105
+
106
+
107
+ @pytest.mark.asyncio
108
+ @pytest.mark.parametrize("field", ["condition_key", "condition_definition_fingerprint"])
109
+ async def test_query_worker_reports_unavailable_state_without_changing_the_run(field: str) -> None:
110
+ client = AsyncMock(spec=Client)
111
+ worker = Worker(client, task_queue="condition-drift", workflows=[ConditionIdentityWorkflow])
112
+ outcome = await worker._run_query_task({
113
+ "query_task_id": "query-original",
114
+ "query_task_attempt": 2,
115
+ "workflow_id": "workflow-original",
116
+ "run_id": "run-original",
117
+ "workflow_type": "tests.replay.condition-identity",
118
+ "query_name": "state",
119
+ "history_events": condition_history(field),
120
+ "workflow_arguments": serializer.envelope([], codec="avro"),
121
+ "query_arguments": serializer.envelope([], codec="avro"),
122
+ "payload_codec": "avro",
123
+ })
124
+
125
+ assert outcome == "failed"
126
+ client.complete_query_task.assert_not_awaited()
127
+ client.complete_workflow_task.assert_not_awaited()
128
+ client.fail_workflow_task.assert_not_awaited()
129
+ client.fail_query_task.assert_awaited_once()
130
+ report = client.fail_query_task.await_args.kwargs
131
+ assert report["reason"] == "query_workflow_state_unavailable"
132
+ assert "wait-original" in report["message"]
@@ -17,6 +17,7 @@ from durable_workflow.workflow import (
17
17
  commands_to_server_commands,
18
18
  query_state,
19
19
  )
20
+ from tests.test_condition_replay_drift import ConditionIdentityWorkflow
20
21
  from tests.test_golden_history_replay import (
21
22
  GoldenSagaCompensationWorkflow,
22
23
  GoldenSignalWaitWorkflow,
@@ -291,6 +292,7 @@ WORKFLOWS = [
291
292
  YieldedContinueAfterMetadataWorkflow,
292
293
  ]
293
294
  WORKFLOW_TYPES = {str(getattr(workflow, "__workflow_name__", workflow.__name__)): workflow for workflow in WORKFLOWS}
295
+ WORKFLOW_TYPES["tests.replay.condition-identity"] = ConditionIdentityWorkflow
294
296
 
295
297
 
296
298
  def _fixture_paths() -> list[Path]:
@@ -6,7 +6,6 @@ from durable_workflow import serializer, workflow
6
6
  from durable_workflow.errors import NonDeterministicReplayError
7
7
  from durable_workflow.workflow import (
8
8
  CompleteWorkflow,
9
- FailWorkflow,
10
9
  WaitCondition,
11
10
  WorkflowContext,
12
11
  query_state,
@@ -1052,14 +1051,12 @@ class TestReplayWaitCondition:
1052
1051
  },
1053
1052
  ]
1054
1053
 
1055
- outcome = replay(WaitUntilApproved, history, [])
1054
+ with pytest.raises(NonDeterministicReplayError, match="key changed") as captured:
1055
+ replay(WaitUntilApproved, history, [])
1056
1056
 
1057
- assert len(outcome.commands) == 1
1058
- assert not isinstance(outcome.commands[0], CompleteWorkflow)
1059
- cmd = outcome.commands[0]
1060
- assert isinstance(cmd, FailWorkflow)
1061
- assert cmd.exception_type == "NonDeterministicWaitCondition"
1062
- assert "key changed" in cmd.message
1057
+ assert captured.value.workflow_sequence == 1
1058
+ assert "old-key" in str(captured.value)
1059
+ assert "approved" in str(captured.value)
1063
1060
 
1064
1061
  def test_replay_rejects_changed_condition_fingerprint(self) -> None:
1065
1062
  initial = replay(WaitUntilApproved, [], [])
@@ -1077,14 +1074,12 @@ class TestReplayWaitCondition:
1077
1074
  },
1078
1075
  ]
1079
1076
 
1080
- outcome = replay(WaitUntilApproved, history, [])
1077
+ with pytest.raises(NonDeterministicReplayError, match="predicate fingerprint changed") as captured:
1078
+ replay(WaitUntilApproved, history, [])
1081
1079
 
1082
- assert len(outcome.commands) == 1
1083
- cmd = outcome.commands[0]
1084
- assert isinstance(cmd, FailWorkflow)
1085
- assert cmd.exception_type == "NonDeterministicWaitCondition"
1086
- assert "predicate fingerprint changed" in cmd.message
1087
- assert fingerprint in cmd.message
1080
+ assert captured.value.workflow_sequence == 1
1081
+ assert "sha256:different" in str(captured.value)
1082
+ assert fingerprint in str(captured.value)
1088
1083
 
1089
1084
  def test_replay_accepts_matching_condition_fingerprint(self) -> None:
1090
1085
  initial = replay(WaitUntilApproved, [], [])