durable-workflow 2.10.0__tar.gz → 2.11.0__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.10.0/src/durable_workflow.egg-info → durable_workflow-2.11.0}/PKG-INFO +1 -1
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/pyproject.toml +3 -3
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/__init__.py +2 -0
- durable_workflow-2.11.0/src/durable_workflow/_failure_reporting.py +299 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/_metric_routes.py +1 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/client.py +81 -18
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/errors.py +29 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/worker.py +121 -14
- {durable_workflow-2.10.0 → durable_workflow-2.11.0/src/durable_workflow.egg-info}/PKG-INFO +1 -1
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow.egg-info/SOURCES.txt +4 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_cooperative_cancellation_worker.py +8 -3
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_cooperative_remote_worker.py +19 -5
- durable_workflow-2.11.0/tests/test_failure_reporting.py +325 -0
- durable_workflow-2.11.0/tests/test_local_failure_reporting.py +113 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_query_task_authority.py +4 -1
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_replay_regression_corpus.py +17 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_storage_admission.py +17 -3
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_worker.py +11 -8
- durable_workflow-2.11.0/tests/test_worker_deregistration.py +361 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_worker_observation.py +12 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/LICENSE +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/README.md +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/setup.cfg +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/_activity_lease.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/_activity_process.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/_avro.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/_cancellation_scope.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/_cancellation_scope_history.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/_cooperative_cancellation.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/_prepared_local_activity.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/_query_task_authority.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/activity.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/auth_composition.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/cancellation.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/external_storage.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/external_task_input.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/external_task_result.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/history_bundle_verify.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/interceptors.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/invocable.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/metrics.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/nexus.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/py.typed +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/python_conformance.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/replay_conformance.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/replay_verify.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/retry_policy.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/serializer.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/sync.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/telemetry.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/telemetry_carrier.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/testing.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/worker_session.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/workflow.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/workflow_updates_conformance.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow.egg-info/entry_points.txt +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow.egg-info/requires.txt +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow.egg-info/top_level.txt +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_activity_cancellation_policies.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_activity_context.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_activity_heartbeat_authority.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_activity_lease_authority.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_activity_lease_renewal.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_activity_lease_worker.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_activity_process.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_auth_composition.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_avro_value_benchmark.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_avro_value_protocol.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_cancellation_context.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_cancellation_remaining_time.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_cancellation_scope_admission.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_cancellation_scope_authoring.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_cancellation_scope_boundary.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_cancellation_scope_opening.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_child_workflow_policies.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_ci_checkout.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_ci_integration_endpoint.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_client.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_client_telemetry.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_client_timeouts.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_codec_regression_corpus.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_committed_cancellation_scope_descendants.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_committed_cancellation_scope_groups.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_committed_cancellation_scope_history.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_committed_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_condition_replay_drift.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_control_plane_parity_fixtures.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_cooperative_cancellation.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_cooperative_cancellation_client.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_docstring_cross_references.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_durable_selection.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_errors.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_external_storage.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_external_task_input.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_external_task_result.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_golden_history_replay.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_history_bundle_schema2.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_history_bundle_verify.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_history_event_contract.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_invocable.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_message_streams.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_metric_routes.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_metrics.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_nexus_durable_wait.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_nexus_namespace.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_nexus_wire.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_order_processing_example.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_parallel_completion_order_replay.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_parallel_saga_authoring.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_patch_history.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_pending_operation_replay.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_poll_failure_backoff.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_prepared_local_activity_client.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_prepared_local_activity_groups.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_prepared_local_activity_policies.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_prepared_local_activity_worker.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_public_boundary_scanner.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_pypi_project_surface.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_python_conformance.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_queries.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_query_timeout_retry.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_readme_quickstart.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_redrive_replay.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_registration_ambiguity.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_release_metadata.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_replay.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_replay_clock.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_replay_conformance.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_replay_verify.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_retry_policy.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_run_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_runtime_external_payload_transport.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_schedules.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_scoped_run_cancellation_context.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_sdk_metrics_contract.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_serializer.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_signals.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_sleep.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_standalone_activity_client.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_sticky_execution.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_sync.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_sync_activities.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_telemetry.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_telemetry_carrier.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_testing_failures.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_testing_harness.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_testing_nexus.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_threaded_heartbeat_example.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_tls_trust.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_transport_diagnostics.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_update_signal_condition_replay.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_updates.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_wait_condition.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_worker_session.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_worker_telemetry_wire.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_workflow_entry_arguments.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_workflow_entrypoint_validation.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_workflow_handler_validation.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_workflow_identity.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_workflow_result_timeout.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_workflow_streams.py +0 -0
- {durable_workflow-2.10.0 → durable_workflow-2.11.0}/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.
|
|
7
|
+
version = "2.11.0"
|
|
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"
|
|
@@ -76,8 +76,8 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main"
|
|
|
76
76
|
durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main"
|
|
77
77
|
|
|
78
78
|
[tool.durable-workflow]
|
|
79
|
-
product-train = "2.
|
|
80
|
-
registry-version = "2.
|
|
79
|
+
product-train = "2.11.0"
|
|
80
|
+
registry-version = "2.11.0"
|
|
81
81
|
supported-server-versions = "2.8.0"
|
|
82
82
|
worker-protocol-version = "1.20"
|
|
83
83
|
control-plane-version = "2"
|
|
@@ -81,6 +81,7 @@ from .errors import (
|
|
|
81
81
|
ExternalPayloadUnauthorized,
|
|
82
82
|
ExternalPayloadUnavailable,
|
|
83
83
|
ExternalPayloadUnsupported,
|
|
84
|
+
FailureReportTooLarge,
|
|
84
85
|
InvalidArgument,
|
|
85
86
|
InvalidWorkflowDefinition,
|
|
86
87
|
NamespaceNotFound,
|
|
@@ -334,6 +335,7 @@ __all__ = [
|
|
|
334
335
|
"testing",
|
|
335
336
|
"workflow",
|
|
336
337
|
"DurableWorkflowError",
|
|
338
|
+
"FailureReportTooLarge",
|
|
337
339
|
"EXTERNAL_PAYLOAD_REFERENCE_SCHEMA",
|
|
338
340
|
"RUNTIME_EXTERNAL_PAYLOAD_REFERENCE_SCHEMA",
|
|
339
341
|
"AzureBlobExternalStorage",
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""Bound diagnostic text using the same JSON encoder as the HTTP transport.
|
|
2
|
+
|
|
3
|
+
Only failure diagnostics may be reduced. Claim identity, classification and
|
|
4
|
+
retry policy remain exact, even when that makes the complete report unfit.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .errors import FailureReportTooLarge
|
|
14
|
+
|
|
15
|
+
MESSAGE_BYTES = 62 * 1024
|
|
16
|
+
STACK_BYTES = 128 * 1024
|
|
17
|
+
DETAIL_BYTES = 64 * 1024
|
|
18
|
+
REQUEST_BYTES = 1024 * 1024
|
|
19
|
+
UNENCODABLE_DETAILS = object()
|
|
20
|
+
_STACK_MARKER = "\n[truncated]"
|
|
21
|
+
_OPTIONAL = ("details", "diagnostics", "runtime_diagnostics", "validation_errors")
|
|
22
|
+
_EXCEPTION_IDENTITY = frozenset({
|
|
23
|
+
"type", "class", "code", "reason", "non_retryable", "retryable", "retry_after_seconds",
|
|
24
|
+
"next_retry_delay", "activity_type", "activity_attempt_id", "activity_execution_id",
|
|
25
|
+
"failed_step_sequence", "failed_activity_execution_id",
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def encoded_json(value: Any) -> bytes:
|
|
30
|
+
"""Use HTTPX's actual JSON settings, including escaping and NaN refusal."""
|
|
31
|
+
return httpx.Request("POST", "https://failure.invalid", json=value).content
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _size(value: Any) -> int | None:
|
|
35
|
+
try:
|
|
36
|
+
return len(encoded_json(value))
|
|
37
|
+
except (TypeError, ValueError, RecursionError):
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _utf8(value: str) -> bytes:
|
|
42
|
+
return value.encode("utf-8", errors="replace")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _prefix(value: str, size: int) -> str:
|
|
46
|
+
return _utf8(value)[:max(0, size)].decode("utf-8", errors="ignore")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _marker(omitted: dict[str, int | None]) -> str:
|
|
50
|
+
return "\n[truncated " + encoded_json({"omitted_fields": omitted}).decode("utf-8") + "]"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _ObservedLocalFailure(str):
|
|
54
|
+
"""A local message already delivered to workflow code.
|
|
55
|
+
|
|
56
|
+
JSON serializes this as ordinary text. Keep the in-memory distinction until
|
|
57
|
+
encoding so final batch fitting cannot change first-run/replay behavior.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class _Report:
|
|
63
|
+
original: dict[str, Any]
|
|
64
|
+
terminal: bool = False
|
|
65
|
+
structured: bool = False
|
|
66
|
+
|
|
67
|
+
def _copy(self) -> dict[str, Any]:
|
|
68
|
+
result = dict(self.original)
|
|
69
|
+
if self.terminal and isinstance(result.get("exception"), dict):
|
|
70
|
+
result["exception"] = dict(result["exception"])
|
|
71
|
+
return result
|
|
72
|
+
|
|
73
|
+
def prepare(self) -> dict[str, Any]:
|
|
74
|
+
result = self._copy()
|
|
75
|
+
omitted: dict[str, int | None] = {}
|
|
76
|
+
self._bound_metadata(result, omitted, DETAIL_BYTES)
|
|
77
|
+
if omitted:
|
|
78
|
+
self._attach_diagnostics(result, omitted)
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
def render(self, budget: int) -> dict[str, Any]:
|
|
82
|
+
result = self._copy()
|
|
83
|
+
if isinstance(result.get("message"), _ObservedLocalFailure):
|
|
84
|
+
return result
|
|
85
|
+
previous: dict[str, int | None] = {}
|
|
86
|
+
if self.structured:
|
|
87
|
+
self._previous_sizes(previous)
|
|
88
|
+
omitted = dict(previous)
|
|
89
|
+
message = result.get("message", "")
|
|
90
|
+
message_limit = min(MESSAGE_BYTES, budget)
|
|
91
|
+
if len(_utf8(message)) > message_limit or _prefix(message, len(_utf8(message))) != message:
|
|
92
|
+
omitted.setdefault("message", len(_utf8(message)))
|
|
93
|
+
self._bound_stack(result, omitted, min(STACK_BYTES, budget))
|
|
94
|
+
self._bound_metadata(result, omitted, min(DETAIL_BYTES, budget))
|
|
95
|
+
if not omitted:
|
|
96
|
+
return result
|
|
97
|
+
if self._unchanged(previous, omitted, budget):
|
|
98
|
+
return result
|
|
99
|
+
# Inserting the marker can itself shorten an otherwise small message,
|
|
100
|
+
# and inserting diagnostics can require omitting another optional field.
|
|
101
|
+
# Recompute until the finite set of omitted fields is stable.
|
|
102
|
+
while True:
|
|
103
|
+
before = dict(omitted)
|
|
104
|
+
self._attach_diagnostics(result, omitted)
|
|
105
|
+
marker = _marker(omitted)
|
|
106
|
+
if len(_utf8(message)) + len(_utf8(marker)) > message_limit:
|
|
107
|
+
omitted.setdefault("message", len(_utf8(message)))
|
|
108
|
+
if omitted == before:
|
|
109
|
+
break
|
|
110
|
+
retained = max(0, message_limit - len(_utf8(marker)))
|
|
111
|
+
result["message"] = _prefix(message, retained) + marker
|
|
112
|
+
return result
|
|
113
|
+
|
|
114
|
+
@staticmethod
|
|
115
|
+
def _bound_stack(result: dict[str, Any], omitted: dict[str, int | None], stack_limit: int) -> None:
|
|
116
|
+
stack = result.get("stack_trace")
|
|
117
|
+
if isinstance(stack, str) and len(_utf8(stack)) > stack_limit:
|
|
118
|
+
omitted.setdefault("stack_trace", len(_utf8(stack)))
|
|
119
|
+
if stack_limit >= len(_STACK_MARKER):
|
|
120
|
+
result["stack_trace"] = _prefix(stack, stack_limit - len(_STACK_MARKER)) + _STACK_MARKER
|
|
121
|
+
else:
|
|
122
|
+
result.pop("stack_trace", None)
|
|
123
|
+
|
|
124
|
+
def _unchanged(
|
|
125
|
+
self, previous: dict[str, int | None], omitted: dict[str, int | None], budget: int,
|
|
126
|
+
) -> bool:
|
|
127
|
+
message = self.original.get("message", "")
|
|
128
|
+
stack = self.original.get("stack_trace")
|
|
129
|
+
return bool(
|
|
130
|
+
previous and previous == omitted and message.endswith(_marker(previous))
|
|
131
|
+
and len(_utf8(message)) <= min(MESSAGE_BYTES, budget)
|
|
132
|
+
and (not isinstance(stack, str) or len(_utf8(stack)) <= min(STACK_BYTES, budget))
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def _bound_metadata(self, result: dict[str, Any], omitted: dict[str, int | None], budget: int) -> None:
|
|
136
|
+
fields = ("exception",) if self.terminal else _OPTIONAL
|
|
137
|
+
values = {key: result[key] for key in fields if key in result}
|
|
138
|
+
size = _size(values)
|
|
139
|
+
if size is not None and size <= budget:
|
|
140
|
+
return
|
|
141
|
+
for key, value in values.items():
|
|
142
|
+
omitted.setdefault(key, _size(value))
|
|
143
|
+
if key == "exception" and isinstance(value, dict):
|
|
144
|
+
result[key] = {k: v for k, v in value.items() if k in _EXCEPTION_IDENTITY}
|
|
145
|
+
else:
|
|
146
|
+
result.pop(key, None)
|
|
147
|
+
|
|
148
|
+
def _previous_sizes(self, omitted: dict[str, int | None]) -> None:
|
|
149
|
+
original = self.original.get("exception", {}) if self.terminal else self.original
|
|
150
|
+
diagnostics = original.get("diagnostics") if isinstance(original, dict) else None
|
|
151
|
+
previous = diagnostics.get("failure_report") if isinstance(diagnostics, dict) else None
|
|
152
|
+
sizes = previous.get("omitted_fields") if isinstance(previous, dict) else None
|
|
153
|
+
if not isinstance(sizes, dict):
|
|
154
|
+
return
|
|
155
|
+
for key, size in sizes.items():
|
|
156
|
+
if (key in {*_OPTIONAL, "exception", "message", "stack_trace"}
|
|
157
|
+
and (size is None or type(size) is int and size >= 0)):
|
|
158
|
+
omitted[key] = size
|
|
159
|
+
|
|
160
|
+
def _attach_diagnostics(self, result: dict[str, Any], omitted: dict[str, int | None]) -> None:
|
|
161
|
+
if not self.structured:
|
|
162
|
+
return
|
|
163
|
+
container = result.setdefault("exception", {}) if self.terminal else result
|
|
164
|
+
self._previous_sizes(omitted)
|
|
165
|
+
original = self.original.get("exception", {}) if self.terminal else self.original
|
|
166
|
+
existing = original.get("diagnostics") if isinstance(original, dict) else None
|
|
167
|
+
diagnostics = dict(existing) if isinstance(existing, dict) and "diagnostics" not in omitted else {}
|
|
168
|
+
diagnostics["failure_report"] = {"omitted_fields": omitted}
|
|
169
|
+
container["diagnostics"] = diagnostics
|
|
170
|
+
optional = {key: container[key] for key in _OPTIONAL if key in container}
|
|
171
|
+
size = _size(container if self.terminal else optional)
|
|
172
|
+
if size is not None and size <= DETAIL_BYTES:
|
|
173
|
+
return
|
|
174
|
+
self._omit_optional_metadata(container, omitted, existing)
|
|
175
|
+
|
|
176
|
+
def _omit_optional_metadata(
|
|
177
|
+
self, container: dict[str, Any], omitted: dict[str, int | None], existing: Any,
|
|
178
|
+
) -> None:
|
|
179
|
+
# Count inserted diagnostics too. Omit optional fields whole so an
|
|
180
|
+
# Avro envelope is either valid and intact or explicitly absent.
|
|
181
|
+
if existing is not None:
|
|
182
|
+
omitted.setdefault("diagnostics", _size(existing))
|
|
183
|
+
# Snapshot the keys because the loop removes entries from this dictionary.
|
|
184
|
+
for key in list(container):
|
|
185
|
+
removable = key not in _EXCEPTION_IDENTITY if self.terminal else key in _OPTIONAL
|
|
186
|
+
if removable and key != "diagnostics":
|
|
187
|
+
omitted.setdefault(key if key in _OPTIONAL else "exception", _size(container.pop(key)))
|
|
188
|
+
container["diagnostics"] = {"failure_report": {"omitted_fields": omitted}}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _command_reports(command: dict[str, Any]) -> list[tuple[tuple[str | int, ...], _Report]]:
|
|
192
|
+
kind = command.get("type")
|
|
193
|
+
terminal = kind == "fail_workflow"
|
|
194
|
+
result: list[tuple[tuple[str | int, ...], _Report]] = []
|
|
195
|
+
if kind in {"fail_workflow", "fail_update"} or kind == "record_local_activity" and "message" in command:
|
|
196
|
+
result.append(((), _Report(command, terminal=terminal, structured=terminal)))
|
|
197
|
+
if kind == "record_local_activity":
|
|
198
|
+
for index, attempt in enumerate(command.get("attempts", [])):
|
|
199
|
+
if isinstance(attempt, dict) and "message" in attempt:
|
|
200
|
+
result.append((("attempts", index), _Report(attempt)))
|
|
201
|
+
return result
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _reports(path: str, body: dict[str, Any]) -> list[tuple[tuple[str | int, ...], _Report]]:
|
|
205
|
+
failure = body.get("failure")
|
|
206
|
+
if isinstance(failure, dict) and (path.endswith(("/fail", "/reject"))):
|
|
207
|
+
return [(("failure",), _Report(failure, structured="/activity-tasks/" in path))]
|
|
208
|
+
if "/local-activities/" in path and path.endswith("/outcome"):
|
|
209
|
+
report = body.get("report")
|
|
210
|
+
if isinstance(report, dict) and "message" in report:
|
|
211
|
+
return [(("report",), _Report(report))]
|
|
212
|
+
commands = body.get("commands")
|
|
213
|
+
if not isinstance(commands, list) or not path.endswith("/complete"):
|
|
214
|
+
return []
|
|
215
|
+
return [
|
|
216
|
+
(("commands", index, *location), report)
|
|
217
|
+
for index, command in enumerate(commands) if isinstance(command, dict)
|
|
218
|
+
for location, report in _command_reports(command)
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _replace_report(body: Any, location: tuple[str | int, ...], report: dict[str, Any]) -> Any:
|
|
223
|
+
if not location:
|
|
224
|
+
return report
|
|
225
|
+
result: Any = dict(body) if isinstance(body, dict) else list(body)
|
|
226
|
+
key = location[0]
|
|
227
|
+
result[key] = _replace_report(body[key], location[1:], report)
|
|
228
|
+
return result
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def prepare_failure_request(path: str, body: Any) -> Any:
|
|
232
|
+
"""Omit oversized diagnostic envelopes before optional payload uploads.
|
|
233
|
+
|
|
234
|
+
Keep original message/stack text until the final fit so its original sizes
|
|
235
|
+
survive normal externalization of unrelated business payloads in a batch.
|
|
236
|
+
"""
|
|
237
|
+
if not path.startswith("/worker/") or not isinstance(body, dict):
|
|
238
|
+
return body
|
|
239
|
+
result = body
|
|
240
|
+
for location, report in _reports(path, body):
|
|
241
|
+
if report.structured:
|
|
242
|
+
result = _replace_report(result, location, report.prepare())
|
|
243
|
+
return result
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _render_reports(
|
|
247
|
+
body: dict[str, Any], reports: list[tuple[tuple[str | int, ...], _Report]], budget: int, limit: int,
|
|
248
|
+
) -> dict[str, Any]:
|
|
249
|
+
result = body
|
|
250
|
+
for location, report in reports:
|
|
251
|
+
rendered = report.render(budget)
|
|
252
|
+
metadata = rendered.get("exception", {}) if report.terminal else {
|
|
253
|
+
key: rendered[key] for key in _OPTIONAL if key in rendered
|
|
254
|
+
}
|
|
255
|
+
metadata_size = _size(metadata)
|
|
256
|
+
if metadata_size is None or metadata_size > DETAIL_BYTES:
|
|
257
|
+
raise FailureReportTooLarge(limit)
|
|
258
|
+
result = _replace_report(result, location, rendered)
|
|
259
|
+
return result
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def bound_failure_request(path: str, body: Any, limit: int = REQUEST_BYTES) -> Any:
|
|
263
|
+
if not path.startswith("/worker/") or not isinstance(body, dict):
|
|
264
|
+
return body
|
|
265
|
+
reports = _reports(path, body)
|
|
266
|
+
if not reports:
|
|
267
|
+
return body
|
|
268
|
+
limit = min(limit, REQUEST_BYTES)
|
|
269
|
+
budget = STACK_BYTES
|
|
270
|
+
while True:
|
|
271
|
+
result = _render_reports(body, reports, budget, limit)
|
|
272
|
+
size = _size(result)
|
|
273
|
+
if size is not None and size <= limit:
|
|
274
|
+
return result
|
|
275
|
+
if budget == 0:
|
|
276
|
+
raise FailureReportTooLarge(limit)
|
|
277
|
+
budget //= 2
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def normalize_local_failure(
|
|
281
|
+
command: dict[str, Any], *, lease_owner: str, workflow_task_attempt: int, limit: int = REQUEST_BYTES,
|
|
282
|
+
) -> dict[str, Any]:
|
|
283
|
+
"""Fit local diagnostics before the workflow sees its durable failure.
|
|
284
|
+
|
|
285
|
+
Business arguments can still be externalized by Client. Include all claim,
|
|
286
|
+
retry and attempt data here; the final complete request is measured again.
|
|
287
|
+
An already observed message is then immutable, including on a lower limit
|
|
288
|
+
discovered later or when another command makes the batch unfit.
|
|
289
|
+
"""
|
|
290
|
+
frame = {key: value for key, value in command.items() if key != "arguments"}
|
|
291
|
+
body = {"lease_owner": lease_owner, "workflow_task_attempt": workflow_task_attempt, "commands": [frame]}
|
|
292
|
+
normalized = bound_failure_request("/worker/workflow-tasks/local/complete", body, limit)["commands"][0]
|
|
293
|
+
normalized = {**command, **normalized}
|
|
294
|
+
if isinstance(normalized.get("message"), str):
|
|
295
|
+
normalized["message"] = _ObservedLocalFailure(normalized["message"])
|
|
296
|
+
for attempt in normalized.get("attempts", []):
|
|
297
|
+
if isinstance(attempt, dict) and isinstance(attempt.get("message"), str):
|
|
298
|
+
attempt["message"] = _ObservedLocalFailure(attempt["message"])
|
|
299
|
+
return normalized
|
|
@@ -12,6 +12,7 @@ _ROUTES = (
|
|
|
12
12
|
for suffix in ("", "/pass")),
|
|
13
13
|
"/workers", "/workers/{worker_id}",
|
|
14
14
|
"/worker/register", "/worker/heartbeat", "/worker/registrations/{worker_id}",
|
|
15
|
+
"/worker/registrations/{worker_id}/deregister",
|
|
15
16
|
"/worker/sessions", "/worker/sessions/{session_id}", "/worker/sessions/{session_id}/heartbeat",
|
|
16
17
|
*(f"/worker/{kind}-tasks/poll" for kind in ("workflow", "activity", "query", "update-validation")),
|
|
17
18
|
*(f"/worker/{kind}-tasks/{{task_id}}/{action}" for kind, actions in (
|
|
@@ -48,6 +48,14 @@ from ._cancellation_scope_history import (
|
|
|
48
48
|
scope_timestamp,
|
|
49
49
|
)
|
|
50
50
|
from ._cooperative_cancellation import CancellationDelivery, CancellationRequest
|
|
51
|
+
from ._failure_reporting import (
|
|
52
|
+
DETAIL_BYTES,
|
|
53
|
+
REQUEST_BYTES,
|
|
54
|
+
UNENCODABLE_DETAILS,
|
|
55
|
+
bound_failure_request,
|
|
56
|
+
encoded_json,
|
|
57
|
+
prepare_failure_request,
|
|
58
|
+
)
|
|
51
59
|
from ._metric_routes import route_for_metrics as _route_for_metrics
|
|
52
60
|
from ._query_task_authority import QueryTaskAuthority, current_query_authority
|
|
53
61
|
from .errors import (
|
|
@@ -1622,6 +1630,7 @@ class Client:
|
|
|
1622
1630
|
payload_size_limit_bytes: int = serializer.DEFAULT_PAYLOAD_SIZE_BYTES,
|
|
1623
1631
|
payload_size_warning_threshold_percent: int = serializer.DEFAULT_WARNING_THRESHOLD_PERCENT,
|
|
1624
1632
|
payload_size_warnings: bool = True,
|
|
1633
|
+
failure_report_limit_bytes: int = REQUEST_BYTES,
|
|
1625
1634
|
external_storage: ExternalStorageDriver | None = None,
|
|
1626
1635
|
external_storage_threshold_bytes: int | None = None,
|
|
1627
1636
|
external_storage_cache: ExternalPayloadCache | None = None,
|
|
@@ -1643,6 +1652,9 @@ class Client:
|
|
|
1643
1652
|
self._tls_context = tls_context
|
|
1644
1653
|
self.retry_policy = retry_policy or TransportRetryPolicy()
|
|
1645
1654
|
self.metrics = metrics if metrics is not None else NOOP_METRICS
|
|
1655
|
+
if type(failure_report_limit_bytes) is not int or failure_report_limit_bytes < 1:
|
|
1656
|
+
raise ValueError("failure_report_limit_bytes must be a positive integer")
|
|
1657
|
+
self.failure_report_limit_bytes = min(REQUEST_BYTES, failure_report_limit_bytes)
|
|
1646
1658
|
self.payload_size_warning_config = (
|
|
1647
1659
|
serializer.PayloadSizeWarningConfig(
|
|
1648
1660
|
limit_bytes=payload_size_limit_bytes,
|
|
@@ -1843,6 +1855,14 @@ class Client:
|
|
|
1843
1855
|
),
|
|
1844
1856
|
)
|
|
1845
1857
|
|
|
1858
|
+
def _failure_report_limit(self) -> int:
|
|
1859
|
+
limit = self.failure_report_limit_bytes
|
|
1860
|
+
limits = (self._cluster_info or {}).get("limits")
|
|
1861
|
+
advertised = limits.get("max_payload_bytes") if isinstance(limits, dict) else None
|
|
1862
|
+
if type(advertised) is int and advertised > 0:
|
|
1863
|
+
limit = min(limit, advertised)
|
|
1864
|
+
return limit
|
|
1865
|
+
|
|
1846
1866
|
async def _request(
|
|
1847
1867
|
self,
|
|
1848
1868
|
method: str,
|
|
@@ -1853,6 +1873,7 @@ class Client:
|
|
|
1853
1873
|
json: Any = None,
|
|
1854
1874
|
timeout: float | None = None,
|
|
1855
1875
|
context: str = "",
|
|
1876
|
+
retry: bool = True,
|
|
1856
1877
|
) -> Any:
|
|
1857
1878
|
if worker and discovery:
|
|
1858
1879
|
raise ValueError("a request cannot be both worker-plane and cluster discovery")
|
|
@@ -1868,22 +1889,22 @@ class Client:
|
|
|
1868
1889
|
activity_authority = current_activity_authority.get()
|
|
1869
1890
|
if worker and activity_authority is not None and activity_authority.governs(self, self.namespace, path):
|
|
1870
1891
|
activity_authority.remaining()
|
|
1871
|
-
request_json = json
|
|
1892
|
+
request_json = prepare_failure_request(path, json)
|
|
1872
1893
|
if (
|
|
1873
1894
|
not discovery
|
|
1874
1895
|
and json is not None
|
|
1875
|
-
and _contains_direct_external_storage_envelope(
|
|
1896
|
+
and _contains_direct_external_storage_envelope(request_json)
|
|
1876
1897
|
):
|
|
1877
1898
|
await self._require_direct_external_storage_support()
|
|
1878
1899
|
if (
|
|
1879
1900
|
not discovery
|
|
1880
1901
|
and json is not None
|
|
1881
|
-
and _contains_inline_payload_envelope(
|
|
1902
|
+
and _contains_inline_payload_envelope(request_json)
|
|
1882
1903
|
):
|
|
1883
1904
|
transport = await self._runtime_external_payload_transport()
|
|
1884
1905
|
if transport is not None:
|
|
1885
1906
|
request_json = await self._externalize_runtime_payloads(
|
|
1886
|
-
|
|
1907
|
+
request_json,
|
|
1887
1908
|
worker=worker,
|
|
1888
1909
|
transport=transport,
|
|
1889
1910
|
uploaded={},
|
|
@@ -1893,6 +1914,9 @@ class Client:
|
|
|
1893
1914
|
),
|
|
1894
1915
|
)
|
|
1895
1916
|
|
|
1917
|
+
# Discovery/external payload references may change the known limit or
|
|
1918
|
+
# final envelope. Measure again after those transformations.
|
|
1919
|
+
request_json = bound_failure_request(path, request_json, self._failure_report_limit())
|
|
1896
1920
|
start = time.perf_counter()
|
|
1897
1921
|
route = _route_for_metrics(path)
|
|
1898
1922
|
discovery_uses_worker_token = discovery and bool(self.worker_token)
|
|
@@ -1920,7 +1944,7 @@ class Client:
|
|
|
1920
1944
|
|
|
1921
1945
|
try:
|
|
1922
1946
|
try:
|
|
1923
|
-
resp = await self.retry_policy.execute(_do_request)
|
|
1947
|
+
resp = await self.retry_policy.execute(_do_request) if retry else await _do_request()
|
|
1924
1948
|
except httpx.HTTPStatusError as exc:
|
|
1925
1949
|
status_code = str(exc.response.status_code)
|
|
1926
1950
|
outcome = "http_error"
|
|
@@ -5245,39 +5269,69 @@ class Client:
|
|
|
5245
5269
|
json={"worker_id": worker_id, "reason": reason},
|
|
5246
5270
|
))
|
|
5247
5271
|
|
|
5248
|
-
async def deregister_worker_registration(
|
|
5272
|
+
async def deregister_worker_registration(
|
|
5273
|
+
self, worker_id: str, *, registration_token: str | None = None, timeout: float | None = None,
|
|
5274
|
+
expected_namespace: str | None = None,
|
|
5275
|
+
) -> dict[str, Any]:
|
|
5249
5276
|
"""Remove this runtime's successful worker-plane registration.
|
|
5250
5277
|
|
|
5251
5278
|
Called by :class:`~durable_workflow.Worker` after its pollers and
|
|
5252
5279
|
in-flight tasks have drained. This is distinct from the operator-only
|
|
5253
5280
|
:meth:`deregister_worker` control-plane management operation. Returns
|
|
5254
5281
|
the worker deregistration envelope, including recovered workflow-task
|
|
5255
|
-
count.
|
|
5282
|
+
count. With a registration token, use the runtime's generation-fenced
|
|
5283
|
+
receipt endpoint. Each call makes one attempt: Worker owns safe retries
|
|
5284
|
+
and the remaining shutdown budget. An unfenced DELETE cannot safely be
|
|
5285
|
+
repeated after an uncertain response because the worker ID may be reused.
|
|
5286
|
+
``expected_namespace`` is a local registration-scope precondition, not
|
|
5287
|
+
a namespace override.
|
|
5256
5288
|
"""
|
|
5289
|
+
if expected_namespace is not None and self.namespace != expected_namespace:
|
|
5290
|
+
raise RuntimeError("worker registration namespace changed; registration removal is unconfirmed")
|
|
5291
|
+
if registration_token is not None and (
|
|
5292
|
+
not isinstance(registration_token, str) or re.fullmatch(r"[a-f0-9]{32}", registration_token) is None
|
|
5293
|
+
):
|
|
5294
|
+
raise ValueError("registration_token must contain exactly 32 lowercase hexadecimal characters")
|
|
5295
|
+
fenced = registration_token is not None
|
|
5257
5296
|
try:
|
|
5258
5297
|
data = await self._request(
|
|
5259
|
-
"DELETE",
|
|
5260
|
-
f"/worker/registrations/{quote(worker_id, safe='')}",
|
|
5298
|
+
"POST" if fenced else "DELETE",
|
|
5299
|
+
f"/worker/registrations/{quote(worker_id, safe='')}" + ("/deregister" if fenced else ""),
|
|
5261
5300
|
worker=True,
|
|
5262
5301
|
context=worker_id,
|
|
5302
|
+
json={"registration_token": registration_token} if fenced else None,
|
|
5303
|
+
timeout=timeout,
|
|
5304
|
+
retry=False,
|
|
5263
5305
|
)
|
|
5264
5306
|
except ServerError as error:
|
|
5265
|
-
if error.status == 404 and error.reason() == "worker_not_found":
|
|
5307
|
+
if not fenced and error.status == 404 and error.reason() == "worker_not_found":
|
|
5266
5308
|
return {
|
|
5267
5309
|
"worker_id": worker_id,
|
|
5268
5310
|
"outcome": "already_deregistered",
|
|
5269
5311
|
"recovered_workflow_task_count": 0,
|
|
5270
5312
|
}
|
|
5271
5313
|
raise
|
|
5272
|
-
if not
|
|
5314
|
+
if not self._valid_worker_deregistration_response(data, worker_id, registration_token):
|
|
5273
5315
|
raise ServerError(
|
|
5274
5316
|
200,
|
|
5275
5317
|
{
|
|
5276
5318
|
"reason": "invalid_worker_deregistration_response",
|
|
5277
|
-
"message":
|
|
5319
|
+
"message": "expected a deregistration receipt for this worker registration",
|
|
5278
5320
|
},
|
|
5279
5321
|
)
|
|
5280
|
-
return data
|
|
5322
|
+
return cast(dict[str, Any], data)
|
|
5323
|
+
|
|
5324
|
+
@staticmethod
|
|
5325
|
+
def _valid_worker_deregistration_response(data: Any, worker_id: str, token: str | None) -> bool:
|
|
5326
|
+
if not isinstance(data, dict):
|
|
5327
|
+
return False
|
|
5328
|
+
count = data.get("recovered_workflow_task_count")
|
|
5329
|
+
return (
|
|
5330
|
+
data.get("worker_id") == worker_id
|
|
5331
|
+
and data.get("outcome") == "deregistered"
|
|
5332
|
+
and type(count) is int and count >= 0
|
|
5333
|
+
and (token is None or data.get("registration_token") == token)
|
|
5334
|
+
)
|
|
5281
5335
|
|
|
5282
5336
|
async def heartbeat_worker(
|
|
5283
5337
|
self,
|
|
@@ -6177,11 +6231,20 @@ class Client:
|
|
|
6177
6231
|
if non_retryable:
|
|
6178
6232
|
failure["non_retryable"] = True
|
|
6179
6233
|
if details is not None:
|
|
6180
|
-
|
|
6181
|
-
|
|
6182
|
-
|
|
6183
|
-
|
|
6184
|
-
|
|
6234
|
+
# Inspect the official inline encoding before an optional storage
|
|
6235
|
+
# driver uploads diagnostics that the failure budget will omit.
|
|
6236
|
+
try:
|
|
6237
|
+
inline_details: Any = serializer.envelope(details, codec=codec, size_warning=None)
|
|
6238
|
+
except (TypeError, ValueError, RecursionError):
|
|
6239
|
+
if codec != serializer.AVRO_CODEC:
|
|
6240
|
+
raise
|
|
6241
|
+
inline_details = UNENCODABLE_DETAILS
|
|
6242
|
+
failure["details"] = (
|
|
6243
|
+
self._payload_envelope(
|
|
6244
|
+
details, codec=codec, kind="activity_failure_details", activity_name=activity_name,
|
|
6245
|
+
)
|
|
6246
|
+
if isinstance(inline_details, dict) and len(encoded_json(inline_details)) <= DETAIL_BYTES
|
|
6247
|
+
else inline_details
|
|
6185
6248
|
)
|
|
6186
6249
|
body: dict[str, Any] = {
|
|
6187
6250
|
"activity_attempt_id": activity_attempt_id,
|
|
@@ -27,6 +27,20 @@ class DurableWorkflowError(Exception):
|
|
|
27
27
|
"""Base class for every exception raised by the SDK."""
|
|
28
28
|
|
|
29
29
|
|
|
30
|
+
class FailureReportTooLarge(DurableWorkflowError, ValueError):
|
|
31
|
+
"""Required failure identity or semantics cannot fit the reporting budget.
|
|
32
|
+
|
|
33
|
+
This is a local reporting refusal, not the application's failure. No claim
|
|
34
|
+
identity, failure type or retry decision is shortened to make it fit.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, limit_bytes: int) -> None:
|
|
38
|
+
self.limit_bytes = limit_bytes
|
|
39
|
+
super().__init__(
|
|
40
|
+
f"failure report identity or semantics cannot fit the {limit_bytes}-byte encoded request budget"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
30
44
|
class QueryTaskAuthorityLost(DurableWorkflowError):
|
|
31
45
|
"""A query delivery cannot safely load or invoke its handler."""
|
|
32
46
|
|
|
@@ -219,6 +233,21 @@ class ServerError(DurableWorkflowError):
|
|
|
219
233
|
return self.body.get("reason")
|
|
220
234
|
return None
|
|
221
235
|
|
|
236
|
+
def worker_deregistration_retry_delay(self, worker_id: str, registration_token: str) -> int | None:
|
|
237
|
+
"""Return a delay only for an identity-preserving fenced transient."""
|
|
238
|
+
body = self.body
|
|
239
|
+
if (
|
|
240
|
+
self.status != 503 or not isinstance(body, dict)
|
|
241
|
+
or body.get("reason") not in ("backend_lock_pressure", "backend_unavailable")
|
|
242
|
+
or body.get("operation") != "deregister_worker" or body.get("outcome") != "unknown"
|
|
243
|
+
or body.get("worker_id") != worker_id or body.get("registration_token") != registration_token
|
|
244
|
+
or body.get("retryable") is not True
|
|
245
|
+
or type(body.get("retry_after_seconds")) is not int
|
|
246
|
+
or not 0 < body["retry_after_seconds"] <= 60
|
|
247
|
+
):
|
|
248
|
+
return None
|
|
249
|
+
return int(body["retry_after_seconds"])
|
|
250
|
+
|
|
222
251
|
def poll_capacity_backpressure_delay(self, task_kind: str, task_queue: str) -> int | None:
|
|
223
252
|
"""Return the advertised wait only for a complete poll-capacity refusal."""
|
|
224
253
|
body = self.body
|