durable-workflow 2.5.0__tar.gz → 2.5.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. {durable_workflow-2.5.0/src/durable_workflow.egg-info → durable_workflow-2.5.1}/PKG-INFO +1 -1
  2. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/pyproject.toml +4 -4
  3. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/src/durable_workflow/workflow.py +94 -12
  4. {durable_workflow-2.5.0 → durable_workflow-2.5.1/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  5. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/src/durable_workflow.egg-info/SOURCES.txt +1 -0
  6. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_cooperative_remote_worker.py +10 -3
  7. durable_workflow-2.5.1/tests/test_patch_history.py +145 -0
  8. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_replay_regression_corpus.py +4 -0
  9. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/LICENSE +0 -0
  10. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/README.md +0 -0
  11. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/setup.cfg +0 -0
  12. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/src/durable_workflow/__init__.py +0 -0
  13. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/src/durable_workflow/_activity_process.py +0 -0
  14. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/src/durable_workflow/_avro.py +0 -0
  15. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/src/durable_workflow/_cancellation_scope.py +0 -0
  16. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/src/durable_workflow/_cancellation_scope_history.py +0 -0
  17. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/src/durable_workflow/_cooperative_cancellation.py +0 -0
  18. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/src/durable_workflow/_prepared_local_activity.py +0 -0
  19. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
  20. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/src/durable_workflow/activity.py +0 -0
  21. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/src/durable_workflow/auth_composition.py +0 -0
  22. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/src/durable_workflow/cancellation.py +0 -0
  23. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/src/durable_workflow/client.py +0 -0
  24. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/src/durable_workflow/errors.py +0 -0
  25. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/src/durable_workflow/external_storage.py +0 -0
  26. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/src/durable_workflow/external_task_input.py +0 -0
  27. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/src/durable_workflow/external_task_result.py +0 -0
  28. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/src/durable_workflow/history_bundle_verify.py +0 -0
  29. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/src/durable_workflow/interceptors.py +0 -0
  30. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/src/durable_workflow/invocable.py +0 -0
  31. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/src/durable_workflow/metrics.py +0 -0
  32. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/src/durable_workflow/nexus.py +0 -0
  33. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/src/durable_workflow/py.typed +0 -0
  34. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/src/durable_workflow/python_conformance.py +0 -0
  35. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/src/durable_workflow/replay_conformance.py +0 -0
  36. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/src/durable_workflow/replay_verify.py +0 -0
  37. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/src/durable_workflow/retry_policy.py +0 -0
  38. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/src/durable_workflow/serializer.py +0 -0
  39. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/src/durable_workflow/sync.py +0 -0
  40. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/src/durable_workflow/testing.py +0 -0
  41. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/src/durable_workflow/worker.py +0 -0
  42. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/src/durable_workflow/worker_session.py +0 -0
  43. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  44. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  45. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  46. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/src/durable_workflow.egg-info/requires.txt +0 -0
  47. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/src/durable_workflow.egg-info/top_level.txt +0 -0
  48. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_activity_cancellation_policies.py +0 -0
  49. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_activity_context.py +0 -0
  50. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_activity_process.py +0 -0
  51. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_auth_composition.py +0 -0
  52. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_avro_value_benchmark.py +0 -0
  53. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_avro_value_protocol.py +0 -0
  54. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_cancellation_context.py +0 -0
  55. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_cancellation_remaining_time.py +0 -0
  56. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_cancellation_scope_admission.py +0 -0
  57. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_cancellation_scope_authoring.py +0 -0
  58. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_cancellation_scope_boundary.py +0 -0
  59. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_cancellation_scope_opening.py +0 -0
  60. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_child_workflow_policies.py +0 -0
  61. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_ci_checkout.py +0 -0
  62. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_ci_integration_endpoint.py +0 -0
  63. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_client.py +0 -0
  64. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_client_timeouts.py +0 -0
  65. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_codec_regression_corpus.py +0 -0
  66. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_committed_cancellation_scope_descendants.py +0 -0
  67. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_committed_cancellation_scope_groups.py +0 -0
  68. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_committed_cancellation_scope_history.py +0 -0
  69. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_committed_cancellation_scope_replay.py +0 -0
  70. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_control_plane_parity_fixtures.py +0 -0
  71. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_cooperative_cancellation.py +0 -0
  72. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_cooperative_cancellation_client.py +0 -0
  73. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_cooperative_cancellation_worker.py +0 -0
  74. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_docstring_cross_references.py +0 -0
  75. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_durable_selection.py +0 -0
  76. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_errors.py +0 -0
  77. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_external_storage.py +0 -0
  78. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_external_task_input.py +0 -0
  79. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_external_task_result.py +0 -0
  80. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_golden_history_replay.py +0 -0
  81. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_history_bundle_verify.py +0 -0
  82. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_history_event_contract.py +0 -0
  83. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_invocable.py +0 -0
  84. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_message_streams.py +0 -0
  85. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_metrics.py +0 -0
  86. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_order_processing_example.py +0 -0
  87. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_parallel_completion_order_replay.py +0 -0
  88. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_parallel_saga_authoring.py +0 -0
  89. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_pending_operation_replay.py +0 -0
  90. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_prepared_local_activity_client.py +0 -0
  91. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_prepared_local_activity_groups.py +0 -0
  92. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_prepared_local_activity_policies.py +0 -0
  93. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_prepared_local_activity_worker.py +0 -0
  94. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_public_boundary_scanner.py +0 -0
  95. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_pypi_project_surface.py +0 -0
  96. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_python_conformance.py +0 -0
  97. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_queries.py +0 -0
  98. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_readme_quickstart.py +0 -0
  99. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_redrive_replay.py +0 -0
  100. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_release_metadata.py +0 -0
  101. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_replay.py +0 -0
  102. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_replay_conformance.py +0 -0
  103. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_replay_verify.py +0 -0
  104. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_retry_policy.py +0 -0
  105. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_run_cancellation_scope_replay.py +0 -0
  106. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_runtime_external_payload_transport.py +0 -0
  107. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_schedules.py +0 -0
  108. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_scoped_run_cancellation_context.py +0 -0
  109. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_serializer.py +0 -0
  110. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_signals.py +0 -0
  111. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_sleep.py +0 -0
  112. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_standalone_activity_client.py +0 -0
  113. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_sticky_execution.py +0 -0
  114. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_storage_admission.py +0 -0
  115. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_sync.py +0 -0
  116. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_testing_harness.py +0 -0
  117. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_update_signal_condition_replay.py +0 -0
  118. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_updates.py +0 -0
  119. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_wait_condition.py +0 -0
  120. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_worker.py +0 -0
  121. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_worker_session.py +0 -0
  122. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_workflow_result_timeout.py +0 -0
  123. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_workflow_streams.py +0 -0
  124. {durable_workflow-2.5.0 → durable_workflow-2.5.1}/tests/test_workflow_updates_conformance.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.5.0
3
+ Version: 2.5.1
4
4
  Summary: Python client and worker SDK for Durable Workflow Cloud and self-hosted Server
5
5
  Author: Durable Workflow Contributors
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "durable-workflow"
7
- version = "2.5.0"
7
+ version = "2.5.1"
8
8
  description = "Python client and worker SDK for Durable Workflow Cloud and self-hosted Server"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -71,9 +71,9 @@ 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.0"
75
- registry-version = "2.5.0"
76
- supported-server-versions = "2.5.0"
74
+ product-train = "2.5.1"
75
+ registry-version = "2.5.1"
76
+ supported-server-versions = "2.5.16"
77
77
  worker-protocol-version = "1.19"
78
78
  control-plane-version = "2"
79
79
  durable-selection = true
@@ -5185,6 +5185,96 @@ def _replay_state(
5185
5185
  pending: list[Command] = []
5186
5186
  advanced_cmd: Any = None
5187
5187
  terminal_condition_reopen_cmd: WaitCondition | None = None
5188
+ version_decisions: dict[str, tuple[int, str]] = {}
5189
+
5190
+ def _version_error(detail: str, step: _RecordedStep | None = None) -> NonDeterministicReplayError:
5191
+ return NonDeterministicReplayError(
5192
+ step.workflow_sequence if step is not None else authored_sequence,
5193
+ "compatible version marker",
5194
+ step.event_types if step is not None else [],
5195
+ detail=detail,
5196
+ )
5197
+
5198
+ def _recorded_version(step: _RecordedStep) -> int:
5199
+ if step.history_index is None:
5200
+ raise _version_error("Version marker is missing its history position.", step)
5201
+ payload = events[step.history_index].get("payload") or {}
5202
+ version = payload.get("version")
5203
+ if type(version) is not int:
5204
+ raise _version_error("Version marker must contain an integer version.", step)
5205
+ change_id = payload.get("change_id")
5206
+ if "change_id" in payload and (not isinstance(change_id, str) or not change_id.strip()):
5207
+ raise _version_error("Version marker must retain a non-empty change ID.", step)
5208
+ if "min_supported" in payload or "max_supported" in payload:
5209
+ minimum, maximum = payload.get("min_supported"), payload.get("max_supported")
5210
+ if (type(minimum) is not int or type(maximum) is not int
5211
+ or not minimum <= version <= maximum):
5212
+ raise _version_error("Recorded version marker has an invalid supported range.", step)
5213
+ return version
5214
+
5215
+ def _consume_duplicate_version_markers() -> None:
5216
+ nonlocal result_cursor, authored_sequence
5217
+ if not version_decisions:
5218
+ return
5219
+ while result_cursor < len(recorded_steps):
5220
+ step = recorded_steps[result_cursor]
5221
+ change_id = step.details.get("change_id")
5222
+ if step.shape != "version marker" or change_id not in version_decisions:
5223
+ return
5224
+ if _next_unconsumed_recorded_step() is not step:
5225
+ return
5226
+ version = _recorded_version(step)
5227
+ if version != version_decisions[change_id][0]:
5228
+ raise _version_error(f"Conflicting recorded decisions for change ID {change_id!r}.", step)
5229
+ # Earlier SDKs emitted a marker for every invocation. Consume these
5230
+ # aliases while retaining their physical positions for cancellation
5231
+ # and prepared operations. The stored history itself is unchanged.
5232
+ result_cursor += 1
5233
+ authored_sequence = max(authored_sequence, step.workflow_sequence + 1)
5234
+
5235
+ def _resolve_version(command: RecordVersionMarker) -> Any:
5236
+ nonlocal result_cursor, authored_sequence
5237
+ change_id = command.change_id
5238
+ if not isinstance(change_id, str) or not change_id.strip():
5239
+ raise _version_error("Version markers require a stable non-empty change ID.")
5240
+ if (type(command.min_supported) is not int or type(command.max_supported) is not int
5241
+ or command.min_supported > command.max_supported):
5242
+ raise _version_error(f"Invalid supported range for change ID {change_id!r}.")
5243
+ family = "version" if command.result_kind == "version" else "patch"
5244
+ decision = version_decisions.get(change_id)
5245
+ step = _next_unconsumed_recorded_step()
5246
+ if decision is not None:
5247
+ version, original_family = decision
5248
+ if original_family != family:
5249
+ raise _version_error(f"Change ID {change_id!r} cannot switch between version and patch helpers.", step)
5250
+ elif step is not None and step.shape == "version marker":
5251
+ _assert_step_matches(command, step)
5252
+ version = _recorded_version(step)
5253
+ result_cursor += 1
5254
+ authored_sequence = max(authored_sequence, step.workflow_sequence + 1)
5255
+ elif step is not None or (cancellation.delivery is not None and not cancellation_consumed) or any(
5256
+ sequence not in consumed_scope_deliveries for sequence in committed_scopes.deliveries
5257
+ ) or any(_history_event_type(event) in {
5258
+ "WorkflowCompleted", "WorkflowFailed", "WorkflowCancelled", "WorkflowTerminated",
5259
+ "WorkflowContinuedAsNew", "WorkflowTimedOut",
5260
+ } for event in events):
5261
+ # An unmarked recorded command proves this history passed the new
5262
+ # patch site. Leave that command available for its original call.
5263
+ version = -1
5264
+ else:
5265
+ version = command.version
5266
+ if type(version) is not int:
5267
+ raise _version_error(f"Invalid version for change ID {change_id!r}.")
5268
+ ctx.logger._set_replaying(False)
5269
+ pending.append(command)
5270
+ authored_sequence += 1
5271
+ if not command.min_supported <= version <= command.max_supported:
5272
+ raise _version_error(
5273
+ f"Recorded version {version} for change ID {change_id!r} is outside "
5274
+ f"the supported range {command.min_supported}..{command.max_supported}.", step,
5275
+ )
5276
+ version_decisions.setdefault(change_id, (version, family))
5277
+ return _version_marker_result(command, version)
5188
5278
 
5189
5279
  def _parallel_leaf_kind(command: Any) -> str:
5190
5280
  if isinstance(command, ScheduleActivity | RecordLocalActivity):
@@ -6064,6 +6154,7 @@ def _replay_state(
6064
6154
  replay_token = _ACTIVE_WORKFLOW_REPLAY.set(ctx)
6065
6155
  try:
6066
6156
  while True:
6157
+ _consume_duplicate_version_markers()
6067
6158
  # Cursor-0 receivers are start-boundary events. Enter run() once
6068
6159
  # before applying them so workflow initialization observes the
6069
6160
  # same WorkflowStarted-before-Signal/Update ordering the server
@@ -6081,6 +6172,9 @@ def _replay_state(
6081
6172
  first = False
6082
6173
  _apply_due_receivers()
6083
6174
  _validate_local_activity_policies(cmd)
6175
+ if isinstance(cmd, RecordVersionMarker):
6176
+ next_value = _resolve_version(cmd)
6177
+ continue
6084
6178
  if prepare_local_activities and isinstance(cmd, list | SelectGroup) and _contains_local_activity(cmd) and (
6085
6179
  not prepare_local_activity_groups or isinstance(cmd, SelectGroup)
6086
6180
  ):
@@ -6497,18 +6591,6 @@ def _replay_state(
6497
6591
  pending.append(cmd)
6498
6592
  next_value = None
6499
6593
  continue
6500
- if isinstance(cmd, RecordVersionMarker):
6501
- if result_cursor < len(resolved_results):
6502
- _assert_next_step_matches(cmd)
6503
- val = resolved_results[result_cursor]
6504
- result_cursor += 1
6505
- next_value = _version_marker_result(cmd, val)
6506
- continue
6507
- ctx.logger._set_replaying(False)
6508
- _assert_pending_step_matches(cmd)
6509
- pending.append(cmd)
6510
- next_value = _version_marker_result(cmd, cmd.version)
6511
- continue
6512
6594
  if isinstance(cmd, WaitCondition):
6513
6595
  # A condition that is already true is not persisted. Apply
6514
6596
  # receivers at the yield boundary before deciding whether the
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.5.0
3
+ Version: 2.5.1
4
4
  Summary: Python client and worker SDK for Durable Workflow Cloud and self-hosted Server
5
5
  Author: Durable Workflow Contributors
6
6
  License-Expression: MIT
@@ -82,6 +82,7 @@ tests/test_metrics.py
82
82
  tests/test_order_processing_example.py
83
83
  tests/test_parallel_completion_order_replay.py
84
84
  tests/test_parallel_saga_authoring.py
85
+ tests/test_patch_history.py
85
86
  tests/test_pending_operation_replay.py
86
87
  tests/test_prepared_local_activity_client.py
87
88
  tests/test_prepared_local_activity_groups.py
@@ -47,22 +47,29 @@ def stop_receipt() -> dict[str, Any]:
47
47
  "heartbeat_recorded": False, "history_event_id": "receipt-event"}
48
48
 
49
49
 
50
+ def write_pid_marker(marker: str) -> None:
51
+ target = Path(marker)
52
+ pending = target.with_name(target.name + ".pending")
53
+ pending.write_text(str(os.getpid()))
54
+ pending.replace(target)
55
+
56
+
50
57
  async def blocked_async(marker: str, heartbeat: bool = False) -> None:
51
58
  if heartbeat:
52
59
  await activity.context().heartbeat({"authored": True})
53
- Path(marker).write_text(str(os.getpid()))
60
+ write_pid_marker(marker)
54
61
  await asyncio.Event().wait()
55
62
 
56
63
 
57
64
  def blocked_sync(marker: str, heartbeat: bool = False) -> None:
58
65
  if heartbeat:
59
66
  asyncio.run(activity.context().heartbeat({"authored": True}))
60
- Path(marker).write_text(str(os.getpid()))
67
+ write_pid_marker(marker)
61
68
  time.sleep(60)
62
69
 
63
70
 
64
71
  def released_sync(marker: str) -> bytes:
65
- Path(marker).write_text(str(os.getpid()))
72
+ write_pid_marker(marker)
66
73
  while not Path(marker + ".release").exists():
67
74
  time.sleep(0.02)
68
75
  return b"late result"
@@ -0,0 +1,145 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import pytest
6
+
7
+ from durable_workflow import serializer, workflow
8
+ from durable_workflow.errors import NonDeterministicReplayError, WorkflowCancelled
9
+ from durable_workflow.workflow import CompleteWorkflow, RecordVersionMarker, ScheduleActivity, replay
10
+ from tests.test_cooperative_cancellation import marker as cancellation_marker
11
+ from tests.test_cooperative_cancellation import request
12
+
13
+
14
+ def version_marker(sequence: int, change_id: str = "same", version: int = 1) -> dict[str, Any]:
15
+ return {"event_type": "VersionMarkerRecorded", "payload": {
16
+ "sequence": sequence, "change_id": change_id, "version": version,
17
+ "min_supported": -1, "max_supported": 1,
18
+ }}
19
+
20
+
21
+ def old_activity(sequence: int, completed: bool = True) -> list[dict[str, Any]]:
22
+ history = [{"event_type": "ActivityScheduled", "payload": {
23
+ "sequence": sequence, "activity_type": "old",
24
+ }}]
25
+ if completed:
26
+ history.append({"event_type": "ActivityCompleted", "payload": {
27
+ "sequence": sequence, "activity_type": "old", "result": serializer.encode("recorded"),
28
+ "payload_codec": "avro",
29
+ }})
30
+ return history
31
+
32
+
33
+ @workflow.defn(name="tests.patch.insertion")
34
+ class PatchInsertionWorkflow:
35
+ def run(self, ctx): # type: ignore[no-untyped-def]
36
+ if (yield ctx.patched("same")):
37
+ yield ctx.schedule_activity("new", [])
38
+ return (yield ctx.schedule_activity("old", []))
39
+
40
+
41
+ @workflow.defn(name="tests.patch.repeated")
42
+ class RepeatedPatchWorkflow:
43
+ def run(self, ctx, repeat=True): # type: ignore[no-untyped-def]
44
+ first = yield ctx.patched("same")
45
+ second = (yield ctx.patched("same")) if repeat else first
46
+ result = yield ctx.schedule_activity("old", [])
47
+ return [first, second, result]
48
+
49
+
50
+ @workflow.defn(name="tests.patch.interleaved")
51
+ class InterleavedPatchWorkflow:
52
+ def run(self, ctx, repeat=True): # type: ignore[no-untyped-def]
53
+ first = yield ctx.patched("same")
54
+ result = yield ctx.schedule_activity("old", [])
55
+ second = (yield ctx.patched("same")) if repeat else first
56
+ return [first, second, result]
57
+
58
+
59
+ @workflow.defn(name="tests.patch.ranges")
60
+ class VersionRangeWorkflow:
61
+ def run(self, ctx, minimum=1, mixed=False): # type: ignore[no-untyped-def]
62
+ first = yield ctx.get_version("same", 1, 2)
63
+ second = (yield ctx.patched("same")) if mixed else (yield ctx.get_version("same", minimum, 3))
64
+ return [first, second]
65
+
66
+
67
+ @workflow.defn(name="tests.patch.cancellation")
68
+ class PatchCancellationWorkflow:
69
+ def run(self, ctx): # type: ignore[no-untyped-def]
70
+ decisions = [(yield ctx.patched("same")), (yield ctx.patched("same"))]
71
+ try:
72
+ yield ctx.schedule_activity("old", [])
73
+ except WorkflowCancelled:
74
+ return [decisions, "cancelled"]
75
+
76
+
77
+ @pytest.mark.parametrize("completed", [False, True])
78
+ def test_inserted_patch_preserves_unmarked_activity(completed: bool) -> None:
79
+ outcome = replay(PatchInsertionWorkflow, old_activity(1, completed), [])
80
+ if completed:
81
+ assert len(outcome.commands) == 1
82
+ command = outcome.commands[0]
83
+ assert isinstance(command, CompleteWorkflow)
84
+ assert command.result == "recorded"
85
+ else:
86
+ # The old activity is already scheduled. Await it without duplicating
87
+ # that command or introducing the new patched activity.
88
+ assert outcome.commands == []
89
+
90
+
91
+ def test_repeated_patch_emits_one_marker_on_new_history() -> None:
92
+ commands = replay(RepeatedPatchWorkflow, [], []).commands
93
+ assert [type(command) for command in commands] == [RecordVersionMarker, ScheduleActivity]
94
+
95
+
96
+ @pytest.mark.parametrize("repeat", [False, True])
97
+ @pytest.mark.parametrize("duplicates", [False, True])
98
+ def test_old_consistent_duplicate_markers_remain_replayable(repeat: bool, duplicates: bool) -> None:
99
+ history = [version_marker(1)]
100
+ if duplicates:
101
+ history.append(version_marker(2))
102
+ history += old_activity(3 if duplicates else 2)
103
+ commands = replay(RepeatedPatchWorkflow, history, [repeat]).commands
104
+ assert len(commands) == 1
105
+ assert isinstance(commands[0], CompleteWorkflow)
106
+ assert commands[0].result == [True, True, "recorded"]
107
+
108
+
109
+ @pytest.mark.parametrize("repeat", [False, True])
110
+ def test_duplicate_markers_after_an_activity_remain_replayable(repeat: bool) -> None:
111
+ history = [version_marker(1)] + old_activity(2) + [version_marker(3)]
112
+ commands = replay(InterleavedPatchWorkflow, history, [repeat]).commands
113
+ assert len(commands) == 1
114
+ assert isinstance(commands[0], CompleteWorkflow)
115
+ assert commands[0].result == [True, True, "recorded"]
116
+
117
+
118
+ def test_repeated_version_reuses_first_decision() -> None:
119
+ commands = replay(VersionRangeWorkflow, [], []).commands
120
+ assert [type(command) for command in commands] == [RecordVersionMarker, CompleteWorkflow]
121
+ assert commands[-1].result == [2, 2]
122
+
123
+
124
+ @pytest.mark.parametrize("arguments", [[3], [1, True]])
125
+ def test_repeated_version_rejects_incompatible_range_or_family(arguments: list[Any]) -> None:
126
+ with pytest.raises(NonDeterministicReplayError):
127
+ replay(VersionRangeWorkflow, [], arguments)
128
+
129
+
130
+ @pytest.mark.parametrize("version", [-1, 2, True])
131
+ def test_conflicting_duplicate_marker_is_rejected(version: Any) -> None:
132
+ history = [version_marker(1), version_marker(2, version=version)] + old_activity(3)
133
+ with pytest.raises(NonDeterministicReplayError):
134
+ replay(RepeatedPatchWorkflow, history, [])
135
+
136
+
137
+ @pytest.mark.parametrize("marker_count", [0, 1, 2])
138
+ def test_patch_decisions_preserve_committed_cancellation_sequence(marker_count: int) -> None:
139
+ history = [version_marker(sequence) for sequence in range(1, marker_count + 1)]
140
+ history += old_activity(marker_count + 1, False)
141
+ history += [request(), cancellation_marker(marker_count + 1)]
142
+ commands = replay(PatchCancellationWorkflow, history, [], run_id="run-1").commands
143
+ assert len(commands) == 1
144
+ assert isinstance(commands[0], CompleteWorkflow)
145
+ assert commands[0].result == [[marker_count > 0, marker_count > 0], "cancelled"]
@@ -24,6 +24,7 @@ from tests.test_golden_history_replay import (
24
24
  GoldenTimeoutWaitWorkflow,
25
25
  GoldenVersionMarkerWorkflow,
26
26
  )
27
+ from tests.test_patch_history import PatchCancellationWorkflow, PatchInsertionWorkflow, RepeatedPatchWorkflow
27
28
  from tests.test_pending_operation_replay import PendingOperation
28
29
  from tests.test_update_signal_condition_replay import (
29
30
  PostConditionReceiversWorkflow,
@@ -266,6 +267,9 @@ WORKFLOWS = [
266
267
  NestedParallelPathWorkflow,
267
268
  ParallelMetadataProducerWorkflow,
268
269
  ParallelResultBindingWorkflow,
270
+ PatchCancellationWorkflow,
271
+ PatchInsertionWorkflow,
272
+ RepeatedPatchWorkflow,
269
273
  PendingOperation,
270
274
  PagedRecordedSideEffectsWorkflow,
271
275
  PostConditionReceiversWorkflow,