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