durable-workflow 2.13.1__tar.gz → 2.13.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {durable_workflow-2.13.1/src/durable_workflow.egg-info → durable_workflow-2.13.2}/PKG-INFO +1 -1
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/pyproject.toml +3 -3
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/_cancellation_scope_history.py +6 -1
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/_cooperative_cancellation.py +3 -1
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/testing.py +12 -1
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/workflow.py +382 -1
- {durable_workflow-2.13.1 → durable_workflow-2.13.2/src/durable_workflow.egg-info}/PKG-INFO +1 -1
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow.egg-info/SOURCES.txt +3 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_cancellation_remaining_time.py +42 -11
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_committed_cancellation_scope_replay.py +96 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_history_event_contract.py +16 -3
- 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.1 → 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.1 → 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.1 → durable_workflow-2.13.2}/tests/test_wait_condition.py +99 -0
- durable_workflow-2.13.1/tests/test_replay_clock.py +0 -133
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/LICENSE +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/README.md +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/setup.cfg +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/__init__.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/_activity_lease.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/_activity_process.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/_avro.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/_cancellation_scope.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/_failure_reporting.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/_metric_routes.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/_poll_transport.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/_prepared_local_activity.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/_query_task_authority.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/activity.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/auth_composition.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/cancellation.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/client.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/errors.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/external_storage.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/external_task_input.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/external_task_result.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/history_bundle_verify.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/interceptors.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/invocable.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/metrics.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/nexus.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/py.typed +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/python_conformance.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/replay_conformance.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/replay_verify.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/retry_policy.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/serializer.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/server_compatibility.json +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/sync.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/telemetry.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/telemetry_carrier.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/worker.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/worker_session.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/worker_status.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow/workflow_updates_conformance.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow.egg-info/entry_points.txt +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow.egg-info/requires.txt +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/src/durable_workflow.egg-info/top_level.txt +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_activity_cancellation_policies.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_activity_context.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_activity_heartbeat_authority.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_activity_lease_authority.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_activity_lease_renewal.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_activity_lease_worker.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_activity_process.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_activity_retry_info.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_auth_composition.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_avro_value_benchmark.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_avro_value_protocol.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_cancellation_context.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_cancellation_scope_admission.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_cancellation_scope_authoring.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_cancellation_scope_boundary.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_cancellation_scope_opening.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_child_workflow_policies.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_ci_checkout.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_ci_integration_endpoint.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_client.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_client_telemetry.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_client_timeouts.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_codec_regression_corpus.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_committed_cancellation_scope_descendants.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_committed_cancellation_scope_groups.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_committed_cancellation_scope_history.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_condition_replay_drift.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_control_plane_parity_fixtures.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_cooperative_cancellation.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_cooperative_cancellation_client.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_cooperative_cancellation_worker.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_cooperative_remote_worker.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_docstring_cross_references.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_durable_selection.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_empty_callback_failure.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_errors.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_external_storage.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_external_task_input.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_external_task_result.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_failure_reporting.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_generator_lifecycle.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_golden_history_replay.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_history_bundle_pruned.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_history_bundle_schema2.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_history_bundle_verify.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_invocable.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_local_failure_reporting.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_message_streams.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_metric_routes.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_metrics.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_nexus_durable_wait.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_nexus_namespace.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_nexus_wire.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_order_processing_example.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_parallel_completion_order_replay.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_parallel_saga_authoring.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_patch_history.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_pending_operation_replay.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_poll_failure_backoff.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_poll_transport.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_prepared_local_activity_client.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_prepared_local_activity_groups.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_prepared_local_activity_policies.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_prepared_local_activity_worker.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_public_boundary_scanner.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_pypi_project_surface.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_python_conformance.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_queries.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_query_task_authority.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_query_timeout_retry.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_readme_quickstart.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_redrive_replay.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_registration_ambiguity.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_release_metadata.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_replay_conformance.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_replay_verify.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_retry_policy.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_run_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_runtime_external_payload_transport.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_schedules.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_scoped_run_cancellation_context.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_sdk_metrics_contract.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_serializer.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_signals.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_sleep.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_standalone_activity_client.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_sticky_execution.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_storage_admission.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_sync.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_sync_activities.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_telemetry.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_telemetry_carrier.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_testing_failures.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_testing_harness.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_testing_nexus.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_threaded_heartbeat_example.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_tls_trust.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_transport_diagnostics.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_update_signal_condition_replay.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_updates.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_worker.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_worker_deregistration.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_worker_observation.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_worker_session.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_worker_status.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_worker_telemetry_wire.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_workflow_entry_arguments.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_workflow_entrypoint_validation.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_workflow_handler_validation.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_workflow_identity.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_workflow_result_timeout.py +0 -0
- {durable_workflow-2.13.1 → durable_workflow-2.13.2}/tests/test_workflow_streams.py +0 -0
- {durable_workflow-2.13.1 → 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")
|
|
@@ -2339,6 +2463,10 @@ class WorkflowContext:
|
|
|
2339
2463
|
``False`` (otherwise ``True``). The fractional ``timeout`` is rounded
|
|
2340
2464
|
up to the next whole second to match the server's integer-second
|
|
2341
2465
|
timer resolution.
|
|
2466
|
+
|
|
2467
|
+
The deadline is fixed when the wait first opens. A signal that leaves
|
|
2468
|
+
the predicate false re-drives the workflow, and the wait reopens with
|
|
2469
|
+
only the time left before that deadline, so signals never extend it.
|
|
2342
2470
|
"""
|
|
2343
2471
|
timeout_seconds: int | None = None
|
|
2344
2472
|
if timeout is not None:
|
|
@@ -2958,6 +3086,20 @@ def _validated_replay_payload_codec(
|
|
|
2958
3086
|
return codec
|
|
2959
3087
|
|
|
2960
3088
|
|
|
3089
|
+
def _history_event_time(event: Mapping[str, Any]) -> datetime | None:
|
|
3090
|
+
"""The recorded time of a history event, or None if it has none."""
|
|
3091
|
+
value = event.get("timestamp")
|
|
3092
|
+
if value is None:
|
|
3093
|
+
value = event.get("recorded_at")
|
|
3094
|
+
if not isinstance(value, str):
|
|
3095
|
+
return None
|
|
3096
|
+
try:
|
|
3097
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
3098
|
+
except ValueError:
|
|
3099
|
+
return None
|
|
3100
|
+
return parsed if parsed.tzinfo is not None and parsed.utcoffset() is not None else None
|
|
3101
|
+
|
|
3102
|
+
|
|
2961
3103
|
def _decode_history_result(
|
|
2962
3104
|
payload: dict[str, Any],
|
|
2963
3105
|
fallback_codec: str | None,
|
|
@@ -3719,6 +3861,16 @@ def _workflow_sequence(payload: Mapping[str, Any]) -> int | None:
|
|
|
3719
3861
|
return None
|
|
3720
3862
|
|
|
3721
3863
|
|
|
3864
|
+
def _durable_command_sequence_problem(payload: Mapping[str, Any]) -> str | None:
|
|
3865
|
+
"""The sdk-rust reason when a durable command record has no usable sequence."""
|
|
3866
|
+
value = payload.get("sequence")
|
|
3867
|
+
if value is None:
|
|
3868
|
+
return "durable_command_sequence_missing"
|
|
3869
|
+
if type(value) is not int or value <= 0:
|
|
3870
|
+
return "durable_command_sequence_invalid"
|
|
3871
|
+
return None
|
|
3872
|
+
|
|
3873
|
+
|
|
3722
3874
|
def _parallel_group_integer(payload: Mapping[str, Any], key: str) -> int | None:
|
|
3723
3875
|
value = payload.get(key)
|
|
3724
3876
|
if isinstance(value, bool):
|
|
@@ -3873,6 +4025,93 @@ def _reorder_completed_parallel_groups(
|
|
|
3873
4025
|
return ordered_results, ordered_steps
|
|
3874
4026
|
|
|
3875
4027
|
|
|
4028
|
+
# The events that claim a workflow sequence, by durable command kind: Core's
|
|
4029
|
+
# WorkflowStepHistory step events and cancellation deliveries, plus each
|
|
4030
|
+
# command's other lifecycle rows, which carry the same sequence. Events that
|
|
4031
|
+
# only point at a step's sequence claim nothing and are not listed: signal
|
|
4032
|
+
# receipts, update records, failure handling, selection records, and
|
|
4033
|
+
# cooperative or scope cancellation requests and preparations. As in sdk-php,
|
|
4034
|
+
# a collision is refused when it involves an upsert: an upsert is the only
|
|
4035
|
+
# command at its sequence.
|
|
4036
|
+
_DURABLE_COMMAND_KINDS: dict[str, str] = {
|
|
4037
|
+
**dict.fromkeys(("ActivityScheduled", "ActivityStarted", "ActivityHeartbeatRecorded", "ActivityRetryScheduled",
|
|
4038
|
+
"ActivityCompleted", "ActivityFailed", "ActivityTimedOut", "ActivityCancelled",
|
|
4039
|
+
"ActivityCancellationAcknowledged"), "activity"),
|
|
4040
|
+
**dict.fromkeys(("TimerScheduled", "TimerFired", "TimerCancelled"), "timer"),
|
|
4041
|
+
**dict.fromkeys(("ChildWorkflowScheduled", "ChildRunStarted", "ChildRunCompleted", "ChildRunFailed",
|
|
4042
|
+
"ChildRunCancelled", "ChildRunTerminated", "ChildCancellationRequested",
|
|
4043
|
+
"ChildCancellationResolved"), "child"),
|
|
4044
|
+
**dict.fromkeys(("ConditionWaitOpened", "ConditionWaitSatisfied", "ConditionWaitTimedOut",
|
|
4045
|
+
"ConditionWaitCancelled"), "condition"),
|
|
4046
|
+
**dict.fromkeys(("SignalWaitOpened", "SignalApplied", "SignalWaitReceived", "SignalWaitTimedOut",
|
|
4047
|
+
"SignalWaitCancelled"), "signal"),
|
|
4048
|
+
**dict.fromkeys(("ServiceOperationScheduled", "ServiceCallStarted", "ServiceCallCompleted", "ServiceCallFailed",
|
|
4049
|
+
"ServiceCallCancelled"), "service_call"),
|
|
4050
|
+
"CancellationScopeOpened": "cancellation_scope",
|
|
4051
|
+
# A delivered cancellation consumes the call it interrupts, scheduled or
|
|
4052
|
+
# not, and its sequence_span reserves the whole interrupted range.
|
|
4053
|
+
**dict.fromkeys(("CooperativeCancellationDelivered", "CancellationScopeDelivered"), "cancellation_delivery"),
|
|
4054
|
+
"WorkflowContinuedAsNew": "continue_as_new",
|
|
4055
|
+
"SideEffectRecorded": "side_effect",
|
|
4056
|
+
"VersionMarkerRecorded": "version_marker",
|
|
4057
|
+
"MemoUpserted": "memo",
|
|
4058
|
+
"SearchAttributesUpserted": "search_attributes",
|
|
4059
|
+
}
|
|
4060
|
+
_UPSERT_COMMAND_KINDS = frozenset({"memo", "search_attributes"})
|
|
4061
|
+
|
|
4062
|
+
|
|
4063
|
+
def _delivery_sequence_span(payload: Mapping[str, Any]) -> int:
|
|
4064
|
+
"""A cancellation delivery's sequence_span under the producer contract.
|
|
4065
|
+
|
|
4066
|
+
Absent or null means 1, as Core reads it
|
|
4067
|
+
(WorkflowStepHistory.php:270, `intValue($payload['sequence_span'] ?? null) ?? 1`).
|
|
4068
|
+
Otherwise it must be a positive JSON integer; zero, negative, bool,
|
|
4069
|
+
float, string and every other value are malformed and raise ValueError,
|
|
4070
|
+
so a malformed span never shrinks the claimed range.
|
|
4071
|
+
"""
|
|
4072
|
+
span = payload.get("sequence_span")
|
|
4073
|
+
if span is None:
|
|
4074
|
+
return 1
|
|
4075
|
+
if type(span) is not int or span <= 0:
|
|
4076
|
+
# Name only the JSON type: the span may carry arbitrary history data.
|
|
4077
|
+
raise ValueError(f"sequence_span must be a positive integer (got {_json_type_name(span)})")
|
|
4078
|
+
return span
|
|
4079
|
+
|
|
4080
|
+
|
|
4081
|
+
def _assert_delivery_spans(events: Iterable[Mapping[str, Any]]) -> None:
|
|
4082
|
+
for event in events:
|
|
4083
|
+
if _DURABLE_COMMAND_KINDS.get(_history_event_type(event) or "") != "cancellation_delivery":
|
|
4084
|
+
continue
|
|
4085
|
+
raw = event.get("payload")
|
|
4086
|
+
payload: Mapping[str, Any] = raw if isinstance(raw, Mapping) else {}
|
|
4087
|
+
try:
|
|
4088
|
+
_delivery_sequence_span(payload)
|
|
4089
|
+
except ValueError as error:
|
|
4090
|
+
raise NonDeterministicReplayError(
|
|
4091
|
+
_workflow_sequence(payload) or 0,
|
|
4092
|
+
"cancellation delivery with a canonical sequence span",
|
|
4093
|
+
[str(_history_event_type(event))],
|
|
4094
|
+
detail=f"durable_command_sequence_invalid: {error}.",
|
|
4095
|
+
) from error
|
|
4096
|
+
|
|
4097
|
+
|
|
4098
|
+
def _durable_command_claimed_range(event_type: str | None, payload: Mapping[str, Any], sequence: int) -> range:
|
|
4099
|
+
"""The workflow sequences one recorded event claims.
|
|
4100
|
+
|
|
4101
|
+
A cancellation delivery claims sequence through sequence + sequence_span
|
|
4102
|
+
- 1, inclusive, as Core's WorkflowStepHistory::nextSequenceFromEvents
|
|
4103
|
+
extends it. Its span follows the producer contract
|
|
4104
|
+
(_delivery_sequence_span), which replay checks before any parsing.
|
|
4105
|
+
"""
|
|
4106
|
+
if _DURABLE_COMMAND_KINDS.get(event_type or "") != "cancellation_delivery":
|
|
4107
|
+
return range(sequence, sequence + 1)
|
|
4108
|
+
return range(sequence, sequence + _delivery_sequence_span(payload))
|
|
4109
|
+
|
|
4110
|
+
|
|
4111
|
+
def _durable_command_kinds_compatible(kinds: frozenset[str]) -> bool:
|
|
4112
|
+
return len(kinds) == 1 or not kinds & _UPSERT_COMMAND_KINDS
|
|
4113
|
+
|
|
4114
|
+
|
|
3876
4115
|
def _internal_timeout_timer_kind(
|
|
3877
4116
|
payload: Mapping[str, Any],
|
|
3878
4117
|
condition_wait_ids_by_sequence: Mapping[int, str] | None = None,
|
|
@@ -4309,6 +4548,28 @@ def _recorded_detail_mismatch(command: Any, step: _RecordedStep) -> str | None:
|
|
|
4309
4548
|
recorded = step.details.get("entries_identity")
|
|
4310
4549
|
if isinstance(recorded, str) and recorded != _memo_replay_identity(command.entries):
|
|
4311
4550
|
return "Recorded memo entries differ from the current workflow memo update."
|
|
4551
|
+
elif isinstance(command, UpsertSearchAttributes):
|
|
4552
|
+
if "search_attributes" not in step.details:
|
|
4553
|
+
return "search_attribute_update_missing: SearchAttributesUpserted history is missing its attributes."
|
|
4554
|
+
try:
|
|
4555
|
+
authored_attributes = _canonical_search_attributes(command.attributes, authored=True)
|
|
4556
|
+
except ValueError as exc:
|
|
4557
|
+
return f"search_attribute_value_mismatch: the current search attribute update is invalid: {exc}"
|
|
4558
|
+
try:
|
|
4559
|
+
recorded_attributes = _canonical_search_attributes(step.details["search_attributes"])
|
|
4560
|
+
except ValueError as exc:
|
|
4561
|
+
return f"search_attribute_value_mismatch: SearchAttributesUpserted history has invalid attributes: {exc}"
|
|
4562
|
+
if recorded_attributes != authored_attributes:
|
|
4563
|
+
return (
|
|
4564
|
+
"search_attribute_value_mismatch: Recorded search attributes differ from the current "
|
|
4565
|
+
"workflow search attribute update."
|
|
4566
|
+
)
|
|
4567
|
+
# Events recorded before attribute_types existed keep value-only
|
|
4568
|
+
# compatibility, as in Core's SearchAttributeReplayIdentity.
|
|
4569
|
+
if "search_attribute_types" in step.details:
|
|
4570
|
+
problem = _search_attribute_types_mismatch(step.details["search_attribute_types"], command.attributes)
|
|
4571
|
+
if problem is not None:
|
|
4572
|
+
return f"search_attribute_type_mismatch: {problem}."
|
|
4312
4573
|
return None
|
|
4313
4574
|
|
|
4314
4575
|
|
|
@@ -4501,6 +4762,9 @@ def _replay_state(
|
|
|
4501
4762
|
) from exception
|
|
4502
4763
|
|
|
4503
4764
|
events = list(history_events)
|
|
4765
|
+
# A malformed delivery span refuses replay before any history parser can
|
|
4766
|
+
# read it more permissively.
|
|
4767
|
+
_assert_delivery_spans(events)
|
|
4504
4768
|
_assert_cancellation_scope_replay_supported(
|
|
4505
4769
|
events, allow_authoring=allow_cancellation_scope_authoring, allow_delivery=allow_cancellation_scope_delivery,
|
|
4506
4770
|
)
|
|
@@ -4583,6 +4847,10 @@ def _replay_state(
|
|
|
4583
4847
|
resolved_sequences: set[int] = set()
|
|
4584
4848
|
condition_wait_ids_by_sequence: dict[int, str] = {}
|
|
4585
4849
|
selected_condition_wait_ids_by_sequence: dict[int, str] = {}
|
|
4850
|
+
command_kinds_by_sequence: dict[int, frozenset[str]] = {}
|
|
4851
|
+
search_attribute_sequences: set[int] = set()
|
|
4852
|
+
upsert_sequences: set[int] = set()
|
|
4853
|
+
delivery_ranges: list[range] = []
|
|
4586
4854
|
|
|
4587
4855
|
for event in events:
|
|
4588
4856
|
event_type = _history_event_type(event)
|
|
@@ -4590,8 +4858,62 @@ def _replay_state(
|
|
|
4590
4858
|
if not isinstance(payload, Mapping):
|
|
4591
4859
|
payload = {}
|
|
4592
4860
|
sequence = _workflow_sequence(payload)
|
|
4861
|
+
if event_type == "SearchAttributesUpserted":
|
|
4862
|
+
# Never bind an upsert to a guessed step.
|
|
4863
|
+
sequence_problem = _durable_command_sequence_problem(payload)
|
|
4864
|
+
if sequence_problem is not None:
|
|
4865
|
+
raise NonDeterministicReplayError(
|
|
4866
|
+
len(event_types_by_sequence) + 1,
|
|
4867
|
+
"search attributes upsert with its workflow sequence",
|
|
4868
|
+
["SearchAttributesUpserted"],
|
|
4869
|
+
detail=f"{sequence_problem}: SearchAttributesUpserted history needs a positive integer "
|
|
4870
|
+
"workflow sequence.",
|
|
4871
|
+
)
|
|
4593
4872
|
if sequence is None:
|
|
4594
4873
|
continue
|
|
4874
|
+
if event_type == "SearchAttributesUpserted":
|
|
4875
|
+
if sequence in search_attribute_sequences:
|
|
4876
|
+
raise NonDeterministicReplayError(
|
|
4877
|
+
sequence,
|
|
4878
|
+
"one SearchAttributesUpserted event",
|
|
4879
|
+
["SearchAttributesUpserted", "SearchAttributesUpserted"],
|
|
4880
|
+
detail="duplicate_search_attribute_update: a workflow sequence records two search "
|
|
4881
|
+
"attribute updates.",
|
|
4882
|
+
)
|
|
4883
|
+
search_attribute_sequences.add(sequence)
|
|
4884
|
+
command_kind = _DURABLE_COMMAND_KINDS.get(event_type or "")
|
|
4885
|
+
if command_kind is not None:
|
|
4886
|
+
claimed = _durable_command_claimed_range(event_type, payload, sequence)
|
|
4887
|
+
# A delivery span claims its whole range; an upsert anywhere inside
|
|
4888
|
+
# it collides, whichever of the two was recorded first.
|
|
4889
|
+
if command_kind in _UPSERT_COMMAND_KINDS:
|
|
4890
|
+
inside = next((sequence for delivery in delivery_ranges if sequence in delivery), None)
|
|
4891
|
+
elif command_kind == "cancellation_delivery":
|
|
4892
|
+
inside = next((upsert for upsert in sorted(upsert_sequences) if upsert in claimed), None)
|
|
4893
|
+
else:
|
|
4894
|
+
inside = None
|
|
4895
|
+
if inside is not None:
|
|
4896
|
+
raise NonDeterministicReplayError(
|
|
4897
|
+
inside,
|
|
4898
|
+
"one durable command per workflow sequence",
|
|
4899
|
+
[*event_types_by_sequence.get(inside, []), event_type or "<unknown>"],
|
|
4900
|
+
detail=f"durable_command_sequence_collision: {event_type} and an upsert claim workflow "
|
|
4901
|
+
f"sequence {inside} inside a cancellation delivery span.",
|
|
4902
|
+
)
|
|
4903
|
+
kinds = command_kinds_by_sequence.get(sequence, frozenset()) | {command_kind}
|
|
4904
|
+
if not _durable_command_kinds_compatible(kinds):
|
|
4905
|
+
raise NonDeterministicReplayError(
|
|
4906
|
+
sequence,
|
|
4907
|
+
"one durable command per workflow sequence",
|
|
4908
|
+
[*event_types_by_sequence.get(sequence, []), event_type or "<unknown>"],
|
|
4909
|
+
detail=f"durable_command_sequence_collision: {event_type} claims a workflow sequence "
|
|
4910
|
+
f"already recorded by a {', '.join(sorted(kinds - {command_kind}))} command.",
|
|
4911
|
+
)
|
|
4912
|
+
command_kinds_by_sequence[sequence] = kinds
|
|
4913
|
+
if command_kind in _UPSERT_COMMAND_KINDS:
|
|
4914
|
+
upsert_sequences.add(sequence)
|
|
4915
|
+
elif command_kind == "cancellation_delivery":
|
|
4916
|
+
delivery_ranges.append(claimed)
|
|
4595
4917
|
if event_type is not None:
|
|
4596
4918
|
event_types_by_sequence.setdefault(sequence, []).append(event_type)
|
|
4597
4919
|
try:
|
|
@@ -4675,6 +4997,10 @@ def _replay_state(
|
|
|
4675
4997
|
):
|
|
4676
4998
|
resolved_sequences.add(sequence)
|
|
4677
4999
|
|
|
5000
|
+
# The latest recorded time in this history: when a re-driven wait reopens.
|
|
5001
|
+
history_time = max(
|
|
5002
|
+
(time for time in (_history_event_time(ev) for ev in events) if time is not None), default=None,
|
|
5003
|
+
)
|
|
4678
5004
|
workflow_start_time: datetime | None = None
|
|
4679
5005
|
replay_clock_error = "Workflow replay clock unavailable: missing WorkflowStarted persisted start time"
|
|
4680
5006
|
for ev in events:
|
|
@@ -4797,6 +5123,9 @@ def _replay_state(
|
|
|
4797
5123
|
# to match against their corresponding opened wait in history
|
|
4798
5124
|
# (Nth yield ↔ Nth opened).
|
|
4799
5125
|
wait_opened: list[dict[str, Any]] = []
|
|
5126
|
+
# The recorded time of each ``wait_opened`` row, or None when history
|
|
5127
|
+
# carries no parseable time. A reopened wait keeps its first open's deadline.
|
|
5128
|
+
wait_opened_times: list[datetime | None] = []
|
|
4800
5129
|
# Map condition_wait_id → resolution: 'satisfied' (from ConditionWaitSatisfied
|
|
4801
5130
|
# in history, future server-recorded) or 'timed_out' (from a matching
|
|
4802
5131
|
# condition_timeout TimerFired event).
|
|
@@ -5344,6 +5673,7 @@ def _replay_state(
|
|
|
5344
5673
|
else:
|
|
5345
5674
|
recorded_wait_steps.append(_recorded_step("condition wait", ev))
|
|
5346
5675
|
wait_opened.append(dict(payload))
|
|
5676
|
+
wait_opened_times.append(_history_event_time(ev))
|
|
5347
5677
|
elif etype == "ConditionWaitSatisfied":
|
|
5348
5678
|
wait_id = payload.get("condition_wait_id")
|
|
5349
5679
|
if isinstance(wait_id, str) and wait_id:
|
|
@@ -5460,6 +5790,32 @@ def _replay_state(
|
|
|
5460
5790
|
memo_sequences_added.add(sequence)
|
|
5461
5791
|
_append_resolved_result(None, "memo upsert", ev)
|
|
5462
5792
|
elif etype == "SearchAttributesUpserted":
|
|
5793
|
+
# The recorded values are this step's replay identity; without
|
|
5794
|
+
# them the step cannot be checked, so replay is refused.
|
|
5795
|
+
# The first pass refused an upsert without a positive integer sequence.
|
|
5796
|
+
upsert_sequence = cast(int, _workflow_sequence(payload))
|
|
5797
|
+
if "attributes" not in payload:
|
|
5798
|
+
raise NonDeterministicReplayError(
|
|
5799
|
+
upsert_sequence,
|
|
5800
|
+
"search attributes upsert with recorded attributes",
|
|
5801
|
+
["SearchAttributesUpserted"],
|
|
5802
|
+
detail="search_attribute_update_missing: SearchAttributesUpserted history is missing its "
|
|
5803
|
+
"attributes.",
|
|
5804
|
+
)
|
|
5805
|
+
try:
|
|
5806
|
+
_canonical_search_attributes(payload["attributes"])
|
|
5807
|
+
except ValueError as exc:
|
|
5808
|
+
raise NonDeterministicReplayError(
|
|
5809
|
+
upsert_sequence,
|
|
5810
|
+
"search attributes upsert with canonical attributes",
|
|
5811
|
+
["SearchAttributesUpserted"],
|
|
5812
|
+
detail=f"search_attribute_value_mismatch: SearchAttributesUpserted history has invalid "
|
|
5813
|
+
f"attributes: {exc}",
|
|
5814
|
+
) from exc
|
|
5815
|
+
upsert_details = details_by_sequence.setdefault(upsert_sequence, {})
|
|
5816
|
+
upsert_details["search_attributes"] = payload["attributes"]
|
|
5817
|
+
if "attribute_types" in payload:
|
|
5818
|
+
upsert_details["search_attribute_types"] = payload["attribute_types"]
|
|
5463
5819
|
_append_resolved_result(None, "search attributes upsert", ev)
|
|
5464
5820
|
elif etype == "SignalReceived":
|
|
5465
5821
|
signal_name = payload.get("signal_name")
|
|
@@ -6629,6 +6985,27 @@ def _replay_state(
|
|
|
6629
6985
|
except StopIteration as stop:
|
|
6630
6986
|
return _terminal_state(stop.value, include_pending=True)
|
|
6631
6987
|
|
|
6988
|
+
def _reopened_with_remaining_timeout(command: WaitCondition, first_index: int) -> WaitCondition:
|
|
6989
|
+
"""Reopen a logical wait with the time left before its first deadline.
|
|
6990
|
+
|
|
6991
|
+
The deadline is fixed when the wait first opens, as in embedded
|
|
6992
|
+
Workflow: the first ConditionWaitOpened time plus the authored
|
|
6993
|
+
timeout. A non-satisfying signal re-drives the wait, and the reopen
|
|
6994
|
+
carries the remaining whole seconds measured at the latest recorded
|
|
6995
|
+
history time, rounded up and clamped to 0..authored (the sdk-php#269
|
|
6996
|
+
rule). Without recorded times the authored timeout is kept.
|
|
6997
|
+
"""
|
|
6998
|
+
authored = command.timeout_seconds
|
|
6999
|
+
if authored is None or first_index >= len(wait_opened):
|
|
7000
|
+
return command
|
|
7001
|
+
opened_at = wait_opened_times[first_index]
|
|
7002
|
+
if opened_at is None or history_time is None:
|
|
7003
|
+
return command
|
|
7004
|
+
remaining = math.ceil(authored - (history_time - opened_at).total_seconds())
|
|
7005
|
+
reopened = copy(command)
|
|
7006
|
+
reopened.timeout_seconds = min(authored, max(0, remaining))
|
|
7007
|
+
return reopened
|
|
7008
|
+
|
|
6632
7009
|
def _advance_cancellation_clock(indexes: Iterable[int | None]) -> None:
|
|
6633
7010
|
if ctx._cancellation_context is None and not consumed_scope_deliveries:
|
|
6634
7011
|
return
|
|
@@ -7211,6 +7588,7 @@ def _replay_state(
|
|
|
7211
7588
|
# Terminal cleanup may only skip a later open after this
|
|
7212
7589
|
# yielded wait has already replayed at least one false reopen.
|
|
7213
7590
|
consumed_reopen_for_current_wait = False
|
|
7591
|
+
first_open_index = wait_yield_count
|
|
7214
7592
|
while True:
|
|
7215
7593
|
resolution: str | None = None
|
|
7216
7594
|
opened: dict[str, Any] | None = None
|
|
@@ -7313,7 +7691,10 @@ def _replay_state(
|
|
|
7313
7691
|
continue
|
|
7314
7692
|
|
|
7315
7693
|
_set_replaying(False)
|
|
7316
|
-
pending.append(
|
|
7694
|
+
pending.append(
|
|
7695
|
+
_reopened_with_remaining_timeout(cmd, first_open_index)
|
|
7696
|
+
if opened is not None else cmd
|
|
7697
|
+
)
|
|
7317
7698
|
wait_yield_count = next_wait_index
|
|
7318
7699
|
return _state(pending)
|
|
7319
7700
|
continue
|
{durable_workflow-2.13.1 → 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
|