diff --git a/CHANGELOG.md b/CHANGELOG.md
index 50d4a170..aa9e7b12 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,29 @@ project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
+## [3.5.0] - 2026-10-23
+
+### Added
+
+- `Client::withFailureReportLimit()` narrows the complete encoded failure-report
+ budget. Transports may expose a lower known limit. Activity and workflow-task
+ reporting accept optional stack traces, and activity reporting accepts
+ diagnostics, after all existing arguments.
+
+### Fixed
+
+- Bound failure messages to 62 KiB, stacks to 128 KiB, combined details and
+ diagnostics to 64 KiB, and complete encoded reports to 1 MiB by default.
+ Markers and JSON escaping count toward these limits. Oversized details remain
+ whole valid Avro values or are omitted with original-size diagnostics.
+- Preserve original failure types, retry decisions, authored delays and claim
+ identities. Local durable failures normalize before workflow code observes
+ them, so initial execution, persistence and cold replay agree. Cooperative
+ failure IPC has its own diagnostic budget, separate from business payloads.
+- Keep ordinary successful payload budgets unchanged. Mixed failure/result
+ completions use the existing upload planner without changing or slicing the
+ business payload. Unfit required report content refuses explicitly.
+
## [3.4.1] - 2026-10-23
### Fixed
diff --git a/composer.json b/composer.json
index ab2d0803..1640f09e 100644
--- a/composer.json
+++ b/composer.json
@@ -93,7 +93,7 @@
}
},
"durable-workflow": {
- "product-train": "3.4.1",
+ "product-train": "3.5.0",
"supported-server-versions": "2.5.0",
"worker-protocol-version": "1.20",
"control-plane-version": "2",
diff --git a/docs/portal/build/failures-retries.md b/docs/portal/build/failures-retries.md
index e6801a7e..fdfbda73 100644
--- a/docs/portal/build/failures-retries.md
+++ b/docs/portal/build/failures-retries.md
@@ -107,3 +107,59 @@ A client-side wait timeout is not proof that the workflow failed; describe the h
Do not paper over `NonDeterministicWorkflow` with process retries. Compare the deployed build with the history's worker routing, restore the old command sequence, and roll forward with a replay-compatible change. Keep a replay corpus for long-lived workflow histories in [testing](/build/testing/).
Retry questionBefore adding a retry, identify the durable request ID, the side effect's idempotency boundary, and what evidence proves the prior attempt did not already succeed.
+
+## Bound failure diagnostics
+
+Failure reporting keeps diagnostic content within finite defaults:
+
+| Content | Default budget |
+| --- | --- |
+| UTF-8 message, including its marker | 62 KiB |
+| Stack trace, including diagnostic markers | 128 KiB |
+| Serialized details and diagnostics together | 64 KiB |
+| Complete encoded failure request, including JSON escaping and identity | 1 MiB |
+
+The message budget reserves 2 KiB below the original 64 KiB ceiling for
+published runtime summaries. It does not repair the separate runtime summary
+byte-bound defect tracked in shared issue 225.
+
+Oversized content produces a visible `[truncated failure report; ...]` message
+marker and bounded diagnostics with original sizes and omitted fields. The SDK
+retains a complete valid Avro details envelope or explicitly omits it. It never
+slices Avro or base64 bytes. Small reports keep their message and details.
+
+The original failure type, retry decision, authored retry delays and claimed
+attempt identity remain unchanged. Diagnostic limits are separate from business
+payload and worker request timeout budgets. Cooperative callbacks also bound
+failure diagnostics before sending them to their owning worker.
+
+For a transport with a smaller request budget, narrow the client limit:
+
+```php
+$client = $client->withFailureReportLimit(128 * 1024);
+```
+
+An injected transport can expose a lower known body budget through
+`RequestSizeLimitTransport`. The lower limit applies after markers and JSON
+escaping are included. If required identity or fixed request content cannot fit,
+`FailureReportRefused` reports `failure_report_refused` locally. Treat this as a
+reporting refusal requiring configuration or identity repair, rather than a new
+application failure or permission to publish under a different attempt.
+
+Successful local records keep their normal business-payload budget. When a
+completion includes both failure diagnostics and a large business result, the
+existing runtime upload protocol preserves the complete Avro payload while
+keeping the final failure request bounded. If fixed content cannot fit and the
+namespace cannot store that payload, reporting refuses explicitly. No partial
+business payload is published.
+
+Local callback failures normalize before workflow code branches on them. The
+first execution, persisted local record and fresh replay use the same bounded
+message, type and retry decision.
+
+Activity failures use existing diagnostic fields. Workflow-task failures use
+the existing stack string for structured truncation information. The published
+Server qualification records task failures and grants a legitimate retry turn,
+but that runtime does not append a workflow-task failure event to workflow
+history or persist its stack diagnostics. That visibility work remains tracked
+in [shared issue 225](https://github.com/durable-workflow/.github/issues/225).
diff --git a/docs/quickstart-contract.json b/docs/quickstart-contract.json
index d98883b6..824e1d14 100644
--- a/docs/quickstart-contract.json
+++ b/docs/quickstart-contract.json
@@ -3,9 +3,9 @@
"schema_version": 2,
"package": {
"name": "durable-workflow/sdk",
- "published_version": "3.4.1",
- "composer_requirement": "3.4.1",
- "onboarding_requirement": "^3.4"
+ "published_version": "3.5.0",
+ "composer_requirement": "3.5.0",
+ "onboarding_requirement": "^3.5"
},
"runtime_targets": {
"server": {
diff --git a/scripts/ci/run-replay-regression-fixture.php b/scripts/ci/run-replay-regression-fixture.php
index ebb7c387..1663c4b0 100644
--- a/scripts/ci/run-replay-regression-fixture.php
+++ b/scripts/ci/run-replay-regression-fixture.php
@@ -377,6 +377,7 @@ public function run(WorkflowContext $context): array
final class ReplayRegressionConsumer
{
private const FIXTURE_SCHEMA = 'durable-workflow.replay-regression/v1';
+ private const BOUNDED_LOCAL_FAILURE = 'golden.bounded-local-failure';
public static function executeFile(string $path): void
{
@@ -554,26 +555,7 @@ private static function executeLayout(
);
}
$localActivityInvocations = 0;
- if (in_array($workflowType, ['golden.local-activity-terminal-failure', 'golden.local-activity-recovered'], true)) {
- $worker->registerActivity(
- $workflowType === 'golden.local-activity-recovered' ? 'golden.local-receipt' : 'golden.local-failure',
- static function (ActivityContext $_context) use (&$localActivityInvocations): never {
- ++$localActivityInvocations;
-
- throw new RuntimeException('Cold replay repeated the recorded local activity side effect.');
- },
- );
- }
- if ($workflowType === 'golden.activity-completion-backend-loss') {
- $worker->registerActivity(
- 'golden.backend-recovery',
- static function (ActivityContext $_context) use (&$localActivityInvocations): string {
- ++$localActivityInvocations;
-
- return 'activity-after-backend-recovery';
- },
- );
- }
+ self::registerLocalActivities($worker, $workflowType, $localActivityInvocations);
foreach ($tasks as $_task) {
$worker->tick(0);
gc_collect_cycles();
@@ -581,7 +563,7 @@ static function (ActivityContext $_context) use (&$localActivityInvocations): st
if ($workflowType === 'golden.workflow-lease-recovery') {
$worker->tick(0);
}
- if ($localActivityInvocations !== ($workflowType === 'golden.activity-completion-backend-loss' ? 1 : 0)) {
+ if ($localActivityInvocations !== (in_array($workflowType, ['golden.activity-completion-backend-loss', self::BOUNDED_LOCAL_FAILURE], true) ? 1 : 0)) {
throw new RuntimeException("{$identity} repeated a recorded local activity during cold replay.");
}
@@ -640,6 +622,40 @@ private static function assertExpected(string $identity, array $fixture, array $
self::assertMatches($expected, $observed, "{$identity}.expected");
}
+ private static function registerLocalActivities(Worker $worker, string $workflowType, int &$localActivityInvocations): void
+ {
+ if (in_array($workflowType, ['golden.local-activity-terminal-failure', 'golden.local-activity-recovered'], true)) {
+ $worker->registerActivity(
+ $workflowType === 'golden.local-activity-recovered' ? 'golden.local-receipt' : 'golden.local-failure',
+ static function (ActivityContext $_context) use (&$localActivityInvocations): never {
+ ++$localActivityInvocations;
+
+ throw new RuntimeException('Cold replay repeated the recorded local activity side effect.');
+ },
+ );
+ }
+ if ($workflowType === self::BOUNDED_LOCAL_FAILURE) {
+ $worker->registerActivity(self::BOUNDED_LOCAL_FAILURE,
+ static function (ActivityContext $context) use (&$localActivityInvocations): never {
+ if ($context->activityType !== self::BOUNDED_LOCAL_FAILURE) {
+ throw new UnexpectedValueException('Unexpected bounded-local activity context.');
+ }
+ ++$localActivityInvocations;
+ throw new RuntimeException(str_repeat('雪', 1024 * 1024));
+ });
+ }
+ if ($workflowType === 'golden.activity-completion-backend-loss') {
+ $worker->registerActivity(
+ 'golden.backend-recovery',
+ static function (ActivityContext $_context) use (&$localActivityInvocations): string {
+ ++$localActivityInvocations;
+
+ return 'activity-after-backend-recovery';
+ },
+ );
+ }
+ }
+
/** @return callable(WorkflowContext, mixed ...$input): mixed */
private static function workflow(string $workflowType): callable
{
@@ -872,6 +888,16 @@ private static function workflow(string $workflowType): callable
return ['key' => $selected->key];
},
+ self::BOUNDED_LOCAL_FAILURE => static function (WorkflowContext $context): array {
+ try {
+ $context->localActivity('golden.bounded-local-failure', [], ['retry_policy' => ['max_attempts' => 1]]);
+ return ['unexpected_success' => true];
+ } catch (ActivityFailed $failure) {
+ return ['marked' => str_contains($failure->getMessage(), '[truncated failure report;'),
+ 'message_bytes' => strlen($failure->getMessage()), 'failure_type' => $failure->failureType,
+ 'non_retryable' => $failure->nonRetryable];
+ }
+ },
'golden.local-activity-recovered' => static fn (WorkflowContext $context): mixed =>
$context->localActivity('golden.local-receipt'),
'golden.local-activity-terminal-failure' => static function (WorkflowContext $context): never {
diff --git a/scripts/qualify-failure-report-delivery.php b/scripts/qualify-failure-report-delivery.php
new file mode 100644
index 00000000..b492a2d9
--- /dev/null
+++ b/scripts/qualify-failure-report-delivery.php
@@ -0,0 +1,324 @@
+describeWorkflow($handle->workflowId, $handle->selectedRunId);
+ if ($execution->isTerminal === true || in_array($execution->status,
+ ['completed', 'failed', 'cancelled', 'terminated', 'timed_out'], true)) {
+ return $handle->resultOfSelectedRun(1, 0.05);
+ }
+ $maintain();
+ $worker->tick(0);
+ $activities?->tick(0);
+ usleep(10000);
+ } while (microtime(true) < $deadline);
+ throw new FailureDeliveryQualificationException('The selected run did not finish within its fixture budget.');
+}
+
+$http = new class implements Psr\Http\Client\ClientInterface {
+ public array $reports = [];
+ public array $claims = [];
+ private GuzzleHttp\Client $client;
+ public function __construct() { $this->client = new GuzzleHttp\Client(['http_errors' => false, 'timeout' => 15]); }
+ public function sendRequest(Psr\Http\Message\RequestInterface $request): Psr\Http\Message\ResponseInterface
+ {
+ $response = $this->client->sendRequest($request);
+ $this->observe($request, $response);
+ return $response;
+ }
+ public function observe(Psr\Http\Message\RequestInterface $request, Psr\Http\Message\ResponseInterface $response): void
+ {
+ $wire = (string) $request->getBody();
+ $data = $wire === '' ? [] : json_decode($wire, true, flags: JSON_THROW_ON_ERROR);
+ if ($response->getStatusCode() >= 400) {
+ fwrite(STDERR, json_encode(['path' => $request->getUri()->getPath(), 'status' => $response->getStatusCode(),
+ 'response' => array_diff_key(json_decode((string) $response->getBody(), true), ['server_capabilities' => true])], JSON_THROW_ON_ERROR)."\n");
+ }
+ $path = $request->getUri()->getPath();
+ if (str_ends_with($path, '/fail') || isset($data['commands'][0]['exception_type'])) {
+ $failure = $data['failure'] ?? $data['commands'][0];
+ $this->reports[] = ['path' => $path, 'encoded_bytes' => strlen($wire), 'http_status' => $response->getStatusCode(),
+ 'lease_owner' => $data['lease_owner'] ?? null, 'activity_attempt_id' => $data['activity_attempt_id'] ?? null,
+ 'workflow_task_attempt' => $data['workflow_task_attempt'] ?? null,
+ 'type' => isset($data['failure']) ? ($failure['type'] ?? null) : ($failure['exception_type'] ?? null),
+ 'message_bytes' => strlen($failure['message']),
+ 'marked' => str_contains($failure['message'], FAILURE_DELIVERY_MARKER),
+ 'stack_bytes' => strlen($failure['stack_trace'] ?? $failure['exception']['stack_trace'] ?? ''),
+ 'stack_includes_qualifier' => str_contains($failure['stack_trace'] ?? $failure['exception']['stack_trace'] ?? '', 'qualify-failure-report-delivery.php'),
+ 'diagnostics' => $failure['diagnostics'] ?? $failure['exception']['diagnostics'] ?? null,
+ 'message_sha256' => hash('sha256', $failure['message']), 'non_retryable' => $failure['non_retryable'] ?? null];
+ }
+ if (str_ends_with($path, '/poll')) {
+ $reply = json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR);
+ $task = $reply['task'] ?? null;
+ if (is_array($task)) {
+ $this->claims[] = array_intersect_key($task, array_flip([
+ 'task_id', 'lease_owner', 'workflow_task_attempt', 'activity_attempt_id', 'attempt_id', 'workflow_id', 'run_id',
+ ]));
+ }
+ }
+ }
+};
+$namespace = 'php-failure-'.bin2hex(random_bytes(6));
+$admin = new Client($url, controlToken: 'claimed-task-admin');
+$admin->createNamespace($namespace);
+$control = new Client($url, namespace: $namespace, controlToken: 'claimed-task-client');
+$transport = new DurableWorkflow\Transport\Psr18Transport(client: $http);
+$client = new Client($url, namespace: $namespace, transport: $transport, workerToken: 'claimed-task-worker');
+$queue = $namespace;
+$workerId = $namespace.'-workflow';
+$activityWorkerId = $namespace.'-activity';
+$worker = new Worker($client, $queue, workerId: $workerId, stickyCacheCapacity: 1);
+$workflowHandler = static function (WorkflowContext $context, string $kind): array {
+ if ($kind === 'terminal') {
+ throw new FailureDeliveryQualificationException(str_repeat('t', 3 * 1024 * 1024));
+ }
+ if ($kind === 'retry') {
+ return ['activity_value' => $context->activity('qualification.failure.activity', [],
+ ['retry_policy' => ['max_attempts' => 2, 'backoff_seconds' => [1]]])];
+ }
+ if ($kind === 'workflow-task') {
+ return ['fresh_turn' => true];
+ }
+ try {
+ if ($kind === 'local-failure') {
+ $context->localActivity('qualification.failure.local', [], ['retry_policy' => ['max_attempts' => 1]]);
+ } else {
+ $context->activity($kind === 'cooperative-worker' ? 'qualification.failure.managed' : 'qualification.failure.activity',
+ [], ['retry_policy' => ['max_attempts' => 1]]);
+ }
+ throw new FailureDeliveryQualificationException('The activity unexpectedly succeeded.');
+ } catch (ActivityFailed $failure) {
+ return ['type' => $failure->failureType, 'marked' => str_contains($failure->getMessage(), FAILURE_DELIVERY_MARKER),
+ 'message_bytes' => strlen($failure->getMessage()), 'message_sha256' => hash('sha256', $failure->getMessage()),
+ 'non_retryable' => $failure->nonRetryable];
+ }
+};
+$worker->registerWorkflow('qualification.failure', $workflowHandler);
+$worker->registerActivity('qualification.failure.local', static function (DurableWorkflow\Worker\ActivityContext $_context): never {
+ requireDelivery($_context->activityType === 'qualification.failure.local', 'Wrong local callback context.');
+ throw new FailureDeliveryQualificationException(str_repeat('雪', 1024 * 1024));
+});
+$client->registerWorker($workerId, $queue, ['qualification.failure'], [], ['sticky_execution', 'typed_search_attributes', 'local_activities'],
+ workflowCommandContracts: $worker->contracts()['workflow_commands']);
+$client->registerWorker($activityWorkerId, $queue, [], ['qualification.failure.activity']);
+$registeredWorkers = [$workerId => $client, $activityWorkerId => $client];
+$lastHeartbeats = [];
+$maintenance = [];
+$maintain = static function () use (&$registeredWorkers, &$lastHeartbeats, &$maintenance): void {
+ foreach ($registeredWorkers as $identity => $connection) {
+ $now = hrtime(true) / 1e9;
+ if ($now - ($lastHeartbeats[$identity] ?? 0) < 5) {
+ continue;
+ }
+ // tick() deliberately leaves registration lifecycle to its caller.
+ $reply = $connection->heartbeatWorker($identity);
+ $lastHeartbeats[$identity] = $now;
+ $maintenance[] = ['worker_id' => $identity, 'registration_id' => $reply['registration_id'] ?? null,
+ 'registration_incarnation' => $reply['registration_incarnation'] ?? null];
+ }
+};
+$result = ['sdk_version' => Composer\InstalledVersions::getPrettyVersion('durable-workflow/sdk'),
+ 'sdk_reference' => Composer\InstalledVersions::getReference('durable-workflow/sdk'),
+ 'server_version' => $admin->clusterInfo()->version, 'namespace' => $namespace, 'scenarios' => []];
+register_shutdown_function(static function () use (&$result, $http, &$maintenance): void {
+ if (!isset($result['pass'])) {
+ $result['reports'] = $http->reports;
+ $result['claims'] = $http->claims;
+ $result['maintenance'] = $maintenance;
+ $result['incomplete'] = true;
+ $result['last_error'] = error_get_last();
+ echo json_encode($result, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)."\n";
+ }
+});
+$large = str_repeat('m', 3 * 1024 * 1024);
+foreach (['message', 'stack', 'combined', 'escaped', 'details', 'lower-limit', 'lower-details', 'small'] as $kind) {
+ $handle = $control->startWorkflow('qualification.failure', $namespace.'-'.$kind, $queue, [$kind], runTimeoutSeconds: 120);
+ $maintain();
+ $worker->tick(0);
+ $task = $client->pollActivityTask($activityWorkerId, $queue, 0);
+ requireDelivery(is_array($task), 'The activity was not leased for '.$kind);
+ $message = in_array($kind, ['message', 'combined', 'lower-limit'], true) ? $large : 'original message';
+ $stack = in_array($kind, ['stack', 'combined', 'lower-limit'], true) ? $large : null;
+ if ($kind === 'escaped') {
+ $message = $stack = str_repeat("\0\"\\雪🦀", 300000);
+ }
+ $details = match ($kind) {
+ 'details' => ['nested' => ['cause' => $large]],
+ 'lower-details' => ['text' => str_repeat('d', 45000)],
+ default => ['whole' => 1.0, 'zero' => -0.0],
+ };
+ $type = 'OriginalFailure.'.$kind;
+ $reporter = in_array($kind, ['lower-limit', 'lower-details'], true) ? $client->withFailureReportLimit(4096) : $client;
+ $reporter->failActivityTask($task['task_id'], $task['activity_attempt_id'] ?? $task['attempt_id'], $task['lease_owner'],
+ $message, $type, true, $details, $stack);
+ $maintain();
+ $worker->tick(0);
+ $output = finishFailureWorkflow($control, $worker, $handle, $maintain);
+ requireDelivery($output['type'] === $type && $output['marked'] === ($kind !== 'small'), 'Original workflow failure mismatch.');
+ requireDelivery($output['non_retryable'] === true, 'The non-retryable decision changed.');
+ $history = $control->workflowHistory($handle->workflowId, $handle->selectedRunId, 100);
+ $events = $history['events'] ?? $history['history_events'] ?? [];
+ $counts = array_count_values(array_column($events, 'event_type'));
+ requireDelivery(($counts['ActivityFailed'] ?? 0) === 1 && ($counts['WorkflowCompleted'] ?? 0) === 1, 'Failure history missing.');
+ $result['scenarios'][$kind] = ['workflow_id' => $handle->workflowId, 'run_id' => $handle->selectedRunId,
+ 'task_id' => $task['task_id'], 'attempt_id' => $task['activity_attempt_id'] ?? $task['attempt_id'],
+ 'lease_owner' => $task['lease_owner'], 'output' => $output, 'event_counts' => $counts];
+}
+$handle = $control->startWorkflow('qualification.failure', $namespace.'-local', $queue, ['local-failure'], runTimeoutSeconds: 120);
+$maintain();
+$worker->tick(0);
+$output = finishFailureWorkflow($control, $worker, $handle, $maintain);
+$history = $control->workflowHistory($handle->workflowId, $handle->selectedRunId, 100);
+$events = $history['events'] ?? $history['history_events'] ?? [];
+$failed = array_values(array_filter($events, static fn (array $event): bool => $event['event_type'] === 'ActivityFailed'));
+requireDelivery(count($failed) === 1, 'Local failure was not persisted once.');
+$message = $failed[0]['payload']['message'] ?? '';
+requireDelivery($output['message_sha256'] === hash('sha256', $message), 'Initial local branch saw a different failure from persisted history.');
+requireDelivery($output['type'] === FailureDeliveryQualificationException::class && $output['marked'] === true,
+ 'Original local failure type or marker changed.');
+$cold = (new DurableWorkflow\Worker\Replayer($client->payloadCodec()))->replay($workflowHandler, $events, ['local-failure'], $queue,
+ localActivityExecutor: static function (): never { throw new FailureDeliveryQualificationException('Cold replay repeated a local callback.'); });
+requireDelivery($cold->terminalFailure === null && count($cold->commands) === 1, 'Local cold replay did not complete normally.');
+requireDelivery($client->payloadCodec()->decodeEnvelope($cold->commands[0]['result']) === $output,
+ 'Cold workflow branch saw a different local failure.');
+$result['scenarios']['local-failure'] = ['workflow_id' => $handle->workflowId, 'run_id' => $handle->selectedRunId,
+ 'output' => $output, 'initial_equals_persisted_equals_cold' => true];
+$stack = GuzzleHttp\HandlerStack::create();
+$stack->push(static function (callable $next) use ($http): Closure {
+ return static function (Psr\Http\Message\RequestInterface $request, array $options) use ($next, $http) {
+ return $next($request, $options)->then(static function (Psr\Http\Message\ResponseInterface $response) use ($request, $http) {
+ $http->observe($request, $response);
+ return $response;
+ });
+ };
+});
+$managedId = $namespace.'-cooperative';
+$managedClient = new Client($url, namespace: $namespace, workerToken: 'claimed-task-worker',
+ transport: new DurableWorkflow\Transport\Psr18Transport(client: new GuzzleHttp\Client(['handler' => $stack, 'http_errors' => false])));
+$managedWorker = new Worker($managedClient, $queue, workerId: $managedId, enableCooperativeCancellation: true);
+$managedWorker->registerActivity('qualification.failure.managed', static function (DurableWorkflow\Worker\ActivityContext $_context): never {
+ requireDelivery($_context->activityType === 'qualification.failure.managed', 'Wrong isolated callback context.');
+ throw new FailureDeliveryQualificationException(str_repeat('m', 3 * 1024 * 1024));
+});
+$managedClient->registerWorker($managedId, $queue, [], ['qualification.failure.managed'], ['cooperative_cancellation']);
+$registeredWorkers[$managedId] = $managedClient;
+$handle = $control->startWorkflow('qualification.failure', $namespace.'-cooperative-worker', $queue, ['cooperative-worker'], runTimeoutSeconds: 120);
+$maintain();
+$worker->tick(0);
+$managedWorker->tick(0);
+$maintain();
+$worker->tick(0);
+$output = finishFailureWorkflow($control, $worker, $handle, $maintain, $managedWorker);
+requireDelivery($output['type'] === FailureDeliveryQualificationException::class && $output['marked'] === true,
+ 'The original workflow did not receive its isolated callback failure.');
+requireDelivery($output['non_retryable'] === false, 'The callback retry classification changed.');
+$history = $control->workflowHistory($handle->workflowId, $handle->selectedRunId, 100);
+$events = $history['events'] ?? $history['history_events'] ?? [];
+$failed = array_values(array_filter($events, static fn (array $event): bool => $event['event_type'] === 'ActivityFailed'));
+$failurePayload = $failed[0]['payload'] ?? [];
+requireDelivery(($failurePayload['exception']['diagnostics']['truncation']['fields']['message']['original_bytes'] ?? null) === 3145728,
+ 'Original callback size diagnostics missing from history.');
+$report = $http->reports[array_key_last($http->reports)];
+requireDelivery($report['stack_bytes'] > 0 && $report['stack_includes_qualifier'] === true,
+ 'The SDK did not transmit the original isolated callback trace.');
+$result['scenarios']['cooperative-worker'] = ['workflow_id' => $handle->workflowId, 'run_id' => $handle->selectedRunId,
+ 'output' => $output, 'original_size_in_history' => true, 'transmitted_original_stack_bytes' => $report['stack_bytes'],
+ 'history_stack_present' => isset($failurePayload['exception']['stack_trace']),
+ 'history_stack_visibility' => 'Published runtime omits the transmitted stack from public history; shared225 runtime follow-through remains.'];
+$handle = $control->startWorkflow('qualification.failure', $namespace.'-retry', $queue, ['retry'], runTimeoutSeconds: 120);
+$maintain();
+$worker->tick(0);
+$original = $client->pollActivityTask($activityWorkerId, $queue, 0);
+requireDelivery(is_array($original), 'Retry control original attempt was not leased.');
+$retryStarted = hrtime(true);
+$client->failActivityTask($original['task_id'], $original['activity_attempt_id'] ?? $original['attempt_id'],
+ $original['lease_owner'], $large, 'OriginalRetryableFailure', false);
+$deadline = microtime(true) + 15;
+do {
+ $retry = $client->pollActivityTask($activityWorkerId, $queue, 0);
+ if ($retry !== null) { break; }
+ usleep(50000);
+} while (microtime(true) < $deadline);
+requireDelivery(is_array($retry), 'Authored retry was not made available.');
+$originalAttempt = $original['activity_attempt_id'] ?? $original['attempt_id'];
+$retryAttempt = $retry['activity_attempt_id'] ?? $retry['attempt_id'];
+requireDelivery($retryAttempt !== $originalAttempt, 'The failed attempt was reused as publication authority.');
+$client->completeActivityTask($retry['task_id'], $retryAttempt, $retry['lease_owner'], 42);
+$maintain();
+$worker->tick(0);
+$output = finishFailureWorkflow($control, $worker, $handle, $maintain);
+requireDelivery($output === ['activity_value' => 42], 'The original workflow did not receive the legitimate retry result.');
+$history = $control->workflowHistory($handle->workflowId, $handle->selectedRunId, 100);
+$events = $history['events'] ?? $history['history_events'] ?? [];
+$scheduled = array_values(array_filter($events, static fn (array $event): bool => $event['event_type'] === 'ActivityScheduled'));
+$result['scenarios']['retry'] = ['workflow_id' => $handle->workflowId, 'run_id' => $handle->selectedRunId,
+ 'original_task_id' => $original['task_id'], 'original_attempt_id' => $originalAttempt,
+ 'retry_task_id' => $retry['task_id'], 'retry_attempt_id' => $retryAttempt,
+ 'retry_elapsed_seconds' => (hrtime(true) - $retryStarted) / 1e9,
+ 'scheduled_payload' => $scheduled[0]['payload'] ?? null, 'output' => $output];
+$handle = $control->startWorkflow('qualification.failure', $namespace.'-workflow-task', $queue, ['workflow-task'], runTimeoutSeconds: 120);
+$task = $client->pollWorkflowTask($workerId, $queue, 0);
+requireDelivery(is_array($task), 'Original workflow task not leased.');
+$ack = $client->failWorkflowTask($task['task_id'], $task['lease_owner'], $task['workflow_task_attempt'], $large,
+ 'OriginalWorkflowTaskFailure', stackTrace: $large);
+requireDelivery(($ack['recorded'] ?? null) === true, 'Workflow-task failure not recorded.');
+$maintain();
+$worker->tick(0);
+$output = finishFailureWorkflow($control, $worker, $handle, $maintain);
+requireDelivery($output === ['fresh_turn' => true], 'The workflow did not receive its fresh legitimate turn.');
+$history = $control->workflowHistory($handle->workflowId, $handle->selectedRunId, 100);
+$events = $history['events'] ?? $history['history_events'] ?? [];
+
+$failureEvents = array_values(array_filter($events, static fn (array $event): bool => $event['event_type'] === 'WorkflowTaskFailed'));
+// The published runtime stores task failure separately and emits no history event.
+requireDelivery(($ack['next_task_id'] ?? null) !== $task['task_id'], 'Failed claim identity was reused as retry authority.');
+$result['scenarios']['workflow-task'] = ['workflow_id' => $handle->workflowId, 'run_id' => $handle->selectedRunId,
+ 'original_task_id' => $task['task_id'], 'lease_owner' => $task['lease_owner'], 'attempt' => $task['workflow_task_attempt'],
+ 'ack' => $ack, 'history_failure_events' => count($failureEvents), 'fresh_turn_result' => $output,
+ 'history_visibility' => 'Published runtime emits no WorkflowTaskFailed history event; parent225 runtime follow-through remains.'];
+$handle = $control->startWorkflow('qualification.failure', $namespace.'-terminal', $queue, ['terminal'], runTimeoutSeconds: 120);
+$maintain();
+$worker->tick(0);
+try {
+ finishFailureWorkflow($control, $worker, $handle, $maintain);
+ throw new FailureDeliveryQualificationException('Terminal workflow unexpectedly succeeded.');
+} catch (WorkflowFailed $failure) {
+ requireDelivery($failure->failureType === FailureDeliveryQualificationException::class, 'Workflow failure type changed.');
+ requireDelivery(str_contains($failure->getMessage(), FAILURE_DELIVERY_MARKER), 'Terminal workflow marker missing.');
+ $result['scenarios']['terminal'] = ['workflow_id' => $handle->workflowId, 'run_id' => $handle->selectedRunId,
+ 'failure_type' => $failure->failureType, 'marked_message' => true, 'message_bytes' => strlen($failure->getMessage())];
+}
+foreach ($http->reports as $report) {
+ requireDelivery($report['encoded_bytes'] <= 1048576 && $report['http_status'] === 200, 'Encoded/reporting HTTP boundary failed.');
+}
+$result['reports'] = $http->reports;
+$result['claims'] = $http->claims;
+$result['maintenance'] = $maintenance;
+$result['pass'] = true;
+echo json_encode($result, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)."\n";
diff --git a/scripts/qualify-failure-reports.php b/scripts/qualify-failure-reports.php
new file mode 100644
index 00000000..a83363eb
--- /dev/null
+++ b/scripts/qualify-failure-reports.php
@@ -0,0 +1,46 @@
+requests[] = $request;
+ return new GuzzleHttp\Psr7\Response(200, ['Content-Type' => 'application/json'], '{}');
+ }
+};
+$client = new DurableWorkflow\Client('https://recording.invalid',
+ transport: new DurableWorkflow\Transport\Psr18Transport(client: $http));
+$large = str_repeat('m', 3 * 1024 * 1024);
+$results = [];
+foreach (['activity', 'workflow-task'] as $kind) {
+ $http->requests = [];
+ if ($kind === 'activity') {
+ $client->failActivityTask('original-task', 'original-attempt', 'original-owner', $large, 'OriginalFailure', true);
+ } else {
+ $client->failWorkflowTask('original-task', 'original-owner', 3, $large, 'OriginalFailure');
+ }
+ $request = $http->requests[array_key_last($http->requests)];
+ $wire = (string) $request->getBody();
+ $body = json_decode($wire, true, flags: JSON_THROW_ON_ERROR);
+ $failure = $body['failure'];
+ $results[$kind] = ['original_message_bytes' => strlen($large), 'encoded_request_bytes' => strlen($wire),
+ 'message_bytes' => strlen($failure['message']), 'original_type' => $failure['type'] === 'OriginalFailure',
+ 'exact_identity' => $body['lease_owner'] === 'original-owner'
+ && ($kind === 'activity' ? $body['activity_attempt_id'] === 'original-attempt' : $body['workflow_task_attempt'] === 3),
+ 'marked_message' => str_contains($failure['message'], '[truncated failure report;'),
+ 'non_retryable' => $failure['non_retryable'] ?? null];
+}
+$pass = true;
+foreach ($results as $result) {
+ $pass = $pass && $result['original_type'] && $result['exact_identity']
+ && ($baseline ? $result['encoded_request_bytes'] > 2097152 && !$result['marked_message']
+ : $result['encoded_request_bytes'] <= 1048576 && $result['marked_message']);
+}
+echo json_encode(['baseline' => $baseline, 'sdk_version' => Composer\InstalledVersions::getPrettyVersion('durable-workflow/sdk'),
+ 'pass' => $pass, 'results' => $results], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)."\n";
+exit($pass ? 0 : 1);
diff --git a/src/Client.php b/src/Client.php
index 26d58968..b086be97 100644
--- a/src/Client.php
+++ b/src/Client.php
@@ -9,6 +9,10 @@
use DurableWorkflow\Codec\AvroPayloadCodec;
use DurableWorkflow\Codec\PayloadCodec;
use DurableWorkflow\Exception\CodecException;
+use DurableWorkflow\Exception\FailureReportRefused;
+use DurableWorkflow\Failure\FailureReportLimiter;
+use DurableWorkflow\Failure\FailureReportLimits;
+use DurableWorkflow\Failure\LocalFailureReports;
use DurableWorkflow\Exception\QueryFailed;
use DurableWorkflow\Exception\ServerException;
use DurableWorkflow\Exception\SignalFailed;
@@ -74,6 +78,7 @@ final class Client implements WorkflowClientInterface
private readonly Transport $transport;
private readonly PayloadCodec $codec;
private readonly RuntimePayloadUploads $payloadUploads;
+ private FailureReportLimiter $failureReports;
private bool $boundedWorkerRequests = false;
private ActivityLeaseBinding $activityLeaseBinding;
private ?string $activityLeaseBootstrap = null;
@@ -134,9 +139,29 @@ public function __construct(
$this->transport = $transport ?? new Psr18Transport(requestTimeoutSeconds: $requestTimeoutSeconds);
$this->codec = $codec ?? new AvroPayloadCodec();
$this->payloadUploads = new RuntimePayloadUploads($this->transport, $this->baseUri);
+ $this->failureReports = new FailureReportLimiter(new FailureReportLimits(), $this->transport);
$this->activityLeaseBinding = new ActivityLeaseBinding();
}
+ /** Narrow the encoded failure-report request budget for an application transport. */
+ public function withFailureReportLimit(int $requestBytes): self
+ {
+ $copy = clone $this;
+ $copy->failureReports = new FailureReportLimiter(new FailureReportLimits($requestBytes), $this->transport);
+
+ return $copy;
+ }
+
+ /** @internal Freeze local diagnostic values before workflow code branches on them.
+ * @param array $outcome
+ * @param array{message?: int, attempts?: array} $originalSizes
+ * @return array
+ */
+ public function normalizeLocalFailure(array $outcome, array $originalSizes = []): array
+ {
+ return LocalFailureReports::bound($outcome, $this->failureReports->effectiveLimit(), $originalSizes);
+ }
+
public function payloadCodec(): PayloadCodec
{
return $this->codec;
@@ -2134,6 +2159,7 @@ public function failWorkflowTask(
string $failureType = 'PhpWorkflowTaskFailure',
?string $reason = null,
?int $sequence = null,
+ ?string $stackTrace = null,
): array {
return $this->worker('POST', '/worker/workflow-tasks/'.$this->segment($taskId).'/fail', [
'lease_owner' => $leaseOwner,
@@ -2143,6 +2169,7 @@ public function failWorkflowTask(
'type' => $failureType,
'reason' => $reason,
'sequence' => $sequence,
+ 'stack_trace' => $stackTrace,
], static fn (mixed $value): bool => $value !== null),
]);
}
@@ -2197,6 +2224,7 @@ public function completeActivityTask(
/**
* @param array|null $details
+ * @param array|null $diagnostics
* @return array
*/
public function failActivityTask(
@@ -2207,6 +2235,8 @@ public function failActivityTask(
string $failureType = 'PhpActivityFailure',
bool $nonRetryable = false,
?array $details = null,
+ ?string $stackTrace = null,
+ ?array $diagnostics = null,
): array {
return $this->worker('POST', self::ACTIVITY_TASK_PATH.$this->segment($taskId).'/fail', [
'activity_attempt_id' => $activityAttemptId,
@@ -2216,6 +2246,8 @@ public function failActivityTask(
'type' => $failureType,
'non_retryable' => $nonRetryable,
'details' => $details !== null ? $this->codec->envelope($details) : null,
+ 'stack_trace' => $stackTrace,
+ 'diagnostics' => $diagnostics,
]),
]);
}
@@ -2619,7 +2651,10 @@ private function request(
$pollSeconds = $poll ? max(0, min(60, $body['timeout_seconds'])) : 0;
$budget ??= $worker && $this->boundedWorkerRequests ? new RequestBudget($pollSeconds + 5) : null;
if ($body !== null) {
- $body = $this->payloadUploads->request($body, $method, $path, $worker, $headers, $budget);
+ $body = $this->failureReports->request($path, $body, false);
+ $body = $this->payloadUploads->request($body, $method, $path, $worker, $headers, $budget,
+ $this->failureReports);
+ $body = $this->failureReports->request($path, $body);
}
$activityLease = $worker ? $this->activityLeaseBinding->current() : null;
if ($activityLease !== null && $activityLease->governs($path)) {
@@ -2659,6 +2694,9 @@ private function request(
return $result;
} catch (TransportException $exception) {
+ if ($exception instanceof FailureReportRefused) {
+ throw $exception;
+ }
$details = $exception->response;
$reason = is_array($details) && isset($details['reason']) ? (string) $details['reason'] : null;
$status = $exception->status ?? 0;
diff --git a/src/Exception/FailureReportRefused.php b/src/Exception/FailureReportRefused.php
new file mode 100644
index 00000000..9b97598b
--- /dev/null
+++ b/src/Exception/FailureReportRefused.php
@@ -0,0 +1,15 @@
+ 'failure_report_refused', 'limit_bytes' => $limit], $previous);
+ }
+}
diff --git a/src/Failure/FailureReportLimiter.php b/src/Failure/FailureReportLimiter.php
new file mode 100644
index 00000000..b7d057e2
--- /dev/null
+++ b/src/Failure/FailureReportLimiter.php
@@ -0,0 +1,352 @@
+ $body
+ * @return array
+ */
+ public function request(string $path, array $body, bool $enforceRequestLimit = true): array
+ {
+ $kind = $this->reportKind($path, $body);
+ if ($kind === null) {
+ return $body;
+ }
+ if ($kind === 'prepared') {
+ return $this->preparedOutcome($path, $body);
+ }
+ $carrier = null;
+ if ($kind === 'failure') {
+ $carrier = str_contains($path, '/activity-tasks/') ? 'diagnostics' : 'stack_trace';
+ }
+ return $this->bound($body, $carrier, $kind === 'commands', $this->effectiveLimit(), strlen($path), $enforceRequestLimit);
+ }
+
+ /** @param array $body */
+ public function limitForRequest(string $path, array $body): ?int
+ {
+ return $this->reportKind($path, $body) === null ? null : max(1, $this->effectiveLimit() - strlen($path));
+ }
+
+ /** @param array $body */
+ private function reportKind(string $path, array $body): ?string
+ {
+ return match (true) {
+ preg_match('~^/worker/workflow-tasks/[^/]+/local-activities/[^/]+/outcome$~D', $path) === 1
+ && is_array($body['report'] ?? null) && is_string($body['report']['message'] ?? null) => 'prepared',
+ preg_match('~^/worker/(activity|workflow|query)-tasks/[^/]+/fail$~D', $path) === 1
+ && is_array($body['failure'] ?? null) => 'failure',
+ preg_match('~^/worker/workflow-tasks/[^/]+/complete$~D', $path) === 1
+ && is_array($body['commands'] ?? null) && $this->hasFailureCommand($body['commands']) => 'commands',
+ default => null,
+ };
+ }
+
+ /** @param array $commands */
+ private function hasFailureCommand(array $commands): bool
+ {
+ foreach ($commands as $command) {
+ if (is_array($command) && $this->isFailureCommand($command)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /** @param array $command */
+ private function isFailureCommand(array $command): bool
+ {
+ if (in_array($command['type'] ?? null, ['fail_workflow', 'fail_update'], true)) {
+ return true;
+ }
+ if (($command['type'] ?? null) !== 'record_local_activity') {
+ return false;
+ }
+ $attempts = is_array($command['attempts'] ?? null) ? $command['attempts'] : [];
+ return in_array($command['outcome'] ?? null, ['failed', 'timed_out', 'cancelled'], true)
+ || array_filter($attempts, static fn (mixed $attempt): bool => is_array($attempt)
+ && in_array($attempt['outcome'] ?? null, ['failed', 'timed_out', 'cancelled'], true)) !== [];
+ }
+
+ /** @param array $body
+ * @return array
+ */
+ private function preparedOutcome(string $path, array $body): array
+ {
+ $limit = $this->effectiveLimit();
+ $fixed = $body;
+ $fixed['report'] = [];
+ $available = $limit - JsonDocument::size($fixed) - strlen($path);
+ if ($available < 1) {
+ throw new FailureReportRefused($limit);
+ }
+ $body['report'] = LocalFailureReports::bound($body['report'], $available);
+ return $body;
+ }
+
+ /** @internal Known body budget shared with pre-observation local normalization. */
+ public function effectiveLimit(): int
+ {
+ $limit = $this->limits->requestBytes;
+ if (!$this->transport instanceof RequestSizeLimitTransport) {
+ return $limit;
+ }
+ try {
+ $known = $this->transport->maximumRequestBytes();
+ } catch (\Throwable $error) {
+ throw new FailureReportRefused($limit, $error);
+ }
+ if ($known !== null && $known < 1) {
+ throw new FailureReportRefused($limit);
+ }
+ return $known === null ? $limit : min($limit, $known);
+ }
+
+ /** @internal The callback IPC carries the same bounded report before HTTP admission.
+ * @param array $body
+ * @return array
+ */
+ public function activityEnvelope(array $body): array
+ {
+ return $this->bound($body, 'diagnostics', false, $this->limits->requestBytes, 0);
+ }
+
+ /** @param array $body
+ * @return array
+ */
+ private function bound(array $body, ?string $carrier, bool $commands, int $limit, int $pathBytes, bool $enforceRequestLimit = true): array
+ {
+ $messageLimit = FailureReportLimits::MESSAGE_BYTES;
+ $stackLimit = FailureReportLimits::STACK_BYTES;
+ $dropMetadata = false;
+ for ($pass = 0; $pass < 16; ++$pass) {
+ $candidate = $body;
+ if ($carrier !== null) {
+ $candidate['failure'] = $this->report($body['failure'], $messageLimit, $stackLimit,
+ $dropMetadata, $carrier);
+ }
+ if ($commands) {
+ $candidate['commands'] = $this->commands($body['commands'], $messageLimit, $stackLimit, $dropMetadata);
+ }
+ try {
+ $bytes = JsonDocument::size($candidate);
+ if (!$enforceRequestLimit || $bytes + $pathBytes <= $limit) {
+ return $candidate;
+ }
+ } catch (\JsonException $error) {
+ throw new FailureReportRefused($limit, $error);
+ }
+ if (!$dropMetadata) {
+ $dropMetadata = true;
+ } else {
+ $messageLimit = intdiv($messageLimit, 2);
+ $stackLimit = intdiv($stackLimit, 2);
+ }
+ }
+
+ throw new FailureReportRefused($limit);
+ }
+
+ /** @param array $original
+ * @param array $additionalChanges
+ * @return array
+ */
+ private function report(array $original, int $messageLimit, int $stackLimit, bool $dropMetadata, string $carrier, array $additionalChanges = []): array
+ {
+ $report = $original;
+ $message = $this->text((string) ($original['message'] ?? ''), $messageLimit, 'message');
+ $stack = $this->text((string) ($original['stack_trace'] ?? ''), $stackLimit, 'stack_trace');
+ $changes = $additionalChanges;
+ foreach (['message' => $message, 'stack_trace' => $stack] as $field => $text) {
+ if ($text['changed']) {
+ $changes[$field] = ['original_bytes' => $text['original_bytes']];
+ }
+ }
+ $report['message'] = $message['value'];
+ if (array_key_exists('stack_trace', $original)) {
+ $report['stack_trace'] = $stack['value'];
+ }
+ $metadata = array_intersect_key($original, array_flip(self::METADATA));
+ try {
+ $metadataBytes = JsonDocument::size($metadata);
+ } catch (\JsonException) {
+ $metadataBytes = null;
+ }
+ if ($dropMetadata || $metadataBytes === null || $metadataBytes > FailureReportLimits::METADATA_BYTES) {
+ $this->omitMetadata($report, $metadata, $changes);
+ }
+ if ($changes === []) {
+ return $report;
+ }
+ $changes = $this->priorChanges($original) + $changes;
+ $message = $this->text((string) ($original['message'] ?? ''), $messageLimit, 'message', true);
+ $report['message'] = $message['value'];
+ if ($carrier === 'diagnostics') {
+ $diagnostics = ['truncation' => ['fields' => $changes]];
+ if (isset($report['diagnostics'])) {
+ $diagnostics['original'] = $report['diagnostics'];
+ }
+ $report['diagnostics'] = $diagnostics;
+ if (JsonDocument::size(array_intersect_key($report, array_flip(self::METADATA))) > FailureReportLimits::METADATA_BYTES) {
+ $this->omitMetadata($report, $metadata, $changes);
+ $report['diagnostics'] = ['truncation' => ['fields' => $changes]];
+ }
+ } else {
+ // This endpoint admits a stack string, but no diagnostics field.
+ $suffix = "\n[truncation diagnostics]\n".JsonDocument::encode(['fields' => $changes]);
+ $available = $stackLimit - strlen($suffix);
+ $stack = $this->text((string) ($original['stack_trace'] ?? ''), $available, 'stack_trace');
+ $report['stack_trace'] = $stack['value'].$suffix;
+ }
+
+ return $report;
+ }
+
+ /** @return array{value: string, changed: bool, original_bytes: int, retained_bytes: int} */
+ private function text(string $original, int $limit, string $field, bool $forceMarker = false): array
+ {
+ return FailureText::bound($original, max($limit, FailureText::minimumBytes($original, $field)), $field, $forceMarker);
+ }
+
+ /** Preserve bounded original-size provenance when an IPC report is narrowed again.
+ * @param array $original
+ * @return array
+ */
+ private function priorChanges(array $original): array
+ {
+ $fields = $original['diagnostics']['truncation']['fields'] ?? [];
+ if (!is_array($fields)) {
+ return [];
+ }
+ $result = [];
+ foreach (['message', 'stack_trace', 'exception.message', ...self::METADATA] as $field) {
+ $entry = $fields[$field] ?? null;
+ $retained = is_array($entry) ? $this->priorField($entry) : [];
+ if ($retained !== []) {
+ $result[$field] = $retained;
+ }
+ }
+ return $result;
+ }
+
+ /** @param array $entry
+ * @return array
+ */
+ private function priorField(array $entry): array
+ {
+ $result = [];
+ $bytes = $entry['original_bytes'] ?? null;
+ if (is_int($bytes) && $bytes >= 0) {
+ $result['original_bytes'] = $bytes;
+ }
+ if (($entry['omitted'] ?? null) === true) {
+ $result['omitted'] = true;
+ $serialized = $entry['original_serialized_bytes'] ?? null;
+ $result['original_serialized_bytes'] = is_int($serialized) && $serialized >= 0 ? $serialized : null;
+ }
+ return $result;
+ }
+
+ /** @param array $report
+ * @param array $metadata
+ * @param array $changes
+ */
+ private function omitMetadata(array &$report, array $metadata, array &$changes): void
+ {
+ foreach ($metadata as $field => $value) {
+ try {
+ $size = JsonDocument::size($value);
+ } catch (\JsonException) {
+ $size = null;
+ }
+ $changes[$field] = ['omitted' => true, 'original_serialized_bytes' => $size];
+ unset($report[$field]);
+ }
+ }
+
+ /** @param array $commands
+ * @return array
+ */
+ private function commands(array $commands, int $messageLimit, int $stackLimit, bool $dropMetadata): array
+ {
+ foreach ($commands as $index => $command) {
+ if (is_array($command) && in_array($command['type'] ?? null, ['fail_workflow', 'fail_update'], true)) {
+ $commands[$index] = $this->command($command, $messageLimit, $stackLimit, $dropMetadata);
+ }
+ }
+ return $commands;
+ }
+
+ /** @param array $command
+ * @return array
+ */
+ private function command(array $command, int $messageLimit, int $stackLimit, bool $dropMetadata): array
+ {
+ if (($command['type'] ?? null) === 'fail_update') {
+ return $this->updateCommand($command, $messageLimit);
+ }
+ $exception = is_array($command['exception'] ?? null) ? $command['exception'] : [];
+ $fixed = array_intersect_key($exception, array_flip(['class', 'type', 'code', 'line']));
+ $extra = array_diff_key($exception, $fixed + array_flip(['message', 'stack_trace', 'diagnostics']));
+ $report = ['message' => (string) ($command['message'] ?? '')];
+ foreach (['stack_trace', 'diagnostics'] as $field) {
+ if (array_key_exists($field, $exception)) {
+ $report[$field] = $exception[$field];
+ }
+ }
+ if ($extra !== []) {
+ $report['details'] = $extra;
+ }
+ $innerMessage = array_key_exists('message', $exception)
+ ? $this->text((string) $exception['message'], $messageLimit, 'exception.message') : null;
+ $additionalChanges = $innerMessage !== null && $innerMessage['changed']
+ ? ['exception.message' => ['original_bytes' => $innerMessage['original_bytes']]] : [];
+ $bounded = $this->report($report, $messageLimit, $stackLimit, $dropMetadata, 'diagnostics', $additionalChanges);
+ $command['message'] = $bounded['message'];
+ $result = $fixed + ($bounded['details'] ?? []);
+ if ($innerMessage !== null) {
+ $result['message'] = $innerMessage['value'];
+ }
+ foreach (['stack_trace', 'diagnostics'] as $field) {
+ if (array_key_exists($field, $bounded)) {
+ $result[$field] = $bounded[$field];
+ }
+ }
+ if ($result !== [] || array_key_exists('exception', $command)) {
+ $command['exception'] = $result;
+ }
+
+ return $command;
+ }
+ /** @param array $command
+ * @return array
+ */
+ private function updateCommand(array $command, int $messageLimit): array
+ {
+ $originalMessage = (string) ($command['message'] ?? '');
+ $bounded = $this->text($originalMessage, $messageLimit, 'message');
+ $command['message'] = $bounded['value'];
+ if ($bounded['changed']) {
+ $suffix = "\n[truncation diagnostics]\n".JsonDocument::encode(['original_message_bytes' => strlen($originalMessage)]);
+ $command['message'] = $this->text($originalMessage,
+ $messageLimit - strlen($suffix), 'message', true)['value'].$suffix;
+ }
+ return $command;
+ }
+
+}
diff --git a/src/Failure/FailureReportLimits.php b/src/Failure/FailureReportLimits.php
new file mode 100644
index 00000000..4d0d48e4
--- /dev/null
+++ b/src/Failure/FailureReportLimits.php
@@ -0,0 +1,22 @@
+ self::REQUEST_BYTES) {
+ throw new \InvalidArgumentException('Failure-report request bytes must be between 1 and 1048576.');
+ }
+ }
+}
diff --git a/src/Failure/FailureText.php b/src/Failure/FailureText.php
new file mode 100644
index 00000000..25275dea
--- /dev/null
+++ b/src/Failure/FailureText.php
@@ -0,0 +1,55 @@
+ $original, 'changed' => false, 'original_bytes' => $bytes, 'retained_bytes' => $bytes];
+ }
+ $marker = self::marker($original, $field, $originalBytes);
+ $available = $limit - strlen($marker);
+ if ($available < 0) {
+ throw new FailureReportRefused($limit);
+ }
+ $prefix = substr($original, 0, $available);
+ $candidate = $prefix;
+ // A cut through a valid UTF-8 code point needs at most three bytes of retreat.
+ for ($retreat = 0; $retreat < 3 && preg_match('//u', $candidate) !== 1; ++$retreat) {
+ $candidate = substr($candidate, 0, -1);
+ }
+ if (preg_match('//u', $candidate) !== 1) {
+ // Repair only the bounded prefix, never allocate an escaped copy of
+ // an arbitrarily large original just to retain its first 64 KiB.
+ $candidate = json_decode(json_encode($prefix,
+ JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE | JSON_UNESCAPED_UNICODE), true, flags: JSON_THROW_ON_ERROR);
+ $candidate = substr($candidate, 0, $available);
+ while (preg_match('//u', $candidate) !== 1) {
+ $candidate = substr($candidate, 0, -1);
+ }
+ }
+
+ return ['value' => $candidate.$marker, 'changed' => true,
+ 'original_bytes' => $bytes, 'retained_bytes' => strlen($candidate)];
+ }
+ public static function minimumBytes(string $original, string $field): int
+ {
+ return strlen(self::marker($original, $field));
+ }
+
+ private static function marker(string $original, string $field, ?int $originalBytes = null): string
+ {
+ $bytes = $originalBytes ?? strlen($original);
+ return "\n[truncated failure report; original {$field} {$bytes} bytes]";
+ }
+
+}
diff --git a/src/Failure/LocalFailureReports.php b/src/Failure/LocalFailureReports.php
new file mode 100644
index 00000000..7e45610e
--- /dev/null
+++ b/src/Failure/LocalFailureReports.php
@@ -0,0 +1,65 @@
+ $outcome
+ * @param array{message?: int, attempts?: array} $originalSizes
+ * @return array
+ */
+ public static function bound(array $outcome, int $requestLimit = FailureReportLimits::REQUEST_BYTES, array $originalSizes = []): array
+ {
+ $messageLimit = FailureReportLimits::MESSAGE_BYTES;
+ for ($pass = 0; $pass < 18; ++$pass) {
+ $bounded = self::fragment($outcome, $messageLimit, $originalSizes['message'] ?? null);
+ foreach ($outcome['attempts'] ?? [] as $index => $attempt) {
+ if (is_array($attempt)) {
+ $bounded['attempts'][$index] = self::fragment($attempt, $messageLimit, $originalSizes['attempts'][$index] ?? null);
+ }
+ }
+ // A successful result keeps its independent business-payload policy.
+ $diagnosticContent = $bounded;
+ unset($diagnosticContent['result']);
+ try {
+ if (JsonDocument::size($diagnosticContent) <= $requestLimit) {
+ return $bounded;
+ }
+ } catch (\JsonException $error) {
+ throw new FailureReportRefused($requestLimit, $error);
+ }
+ $messageLimit = intdiv($messageLimit, 2);
+ }
+ throw new FailureReportRefused($requestLimit);
+ }
+
+ /** @param array $fragment
+ * @return array
+ */
+ private static function fragment(array $fragment, int $limit, ?int $sourceBytes = null): array
+ {
+ if (!is_string($fragment['message'] ?? null)) {
+ return $fragment;
+ }
+ $message = trim($fragment['message']);
+ $fragment['message'] = $message;
+ $originalBytes = max(strlen($message), $sourceBytes ?? 0);
+ if (strlen($message) <= $limit && preg_match('//u', $message) === 1 && $originalBytes === strlen($message)) {
+ return $fragment;
+ }
+ $suffix = self::DIAGNOSTICS.JsonDocument::encode(['message' => ['original_bytes' => $originalBytes]]);
+ $minimum = FailureText::minimumBytes($message, 'message') + strlen($suffix) + 24;
+ $budget = max($limit, $minimum);
+ $fragment['message'] = FailureText::bound($message, $budget - strlen($suffix), 'message', true, $originalBytes)['value'].$suffix;
+ return $fragment;
+ }
+
+}
diff --git a/src/Transport/JsonDocument.php b/src/Transport/JsonDocument.php
new file mode 100644
index 00000000..e12bb45d
--- /dev/null
+++ b/src/Transport/JsonDocument.php
@@ -0,0 +1,20 @@
+withHeader($name, $value);
}
if ($body !== null) {
- $json = json_encode($body, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION
- | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+ $json = JsonDocument::encode($body);
$request = $request->withBody($this->streamFactory->createStream($json));
}
diff --git a/src/Transport/RequestSizeLimitTransport.php b/src/Transport/RequestSizeLimitTransport.php
new file mode 100644
index 00000000..3f33bbe2
--- /dev/null
+++ b/src/Transport/RequestSizeLimitTransport.php
@@ -0,0 +1,11 @@
+, blob: string}
+ * @phpstan-type UploadPolicy array{threshold_bytes: int, max_bytes: int, request_bytes: int, timeout_seconds: int, status: string, completion_context?: bool, prepared_completion_context?: bool, scope_completion_context?: bool}
+ * @phpstan-type UploadRequest array{headers: array, path: string, worker: bool, budget: RequestBudget|null}
+ */
final class RuntimePayloadUploads
{
private const COMPLETION_SCHEMA = 'durable-workflow.v2.payload-completion-context.v1';
@@ -40,33 +45,39 @@ public function __construct(private readonly Transport $transport, private reado
* @param array $headers
* @return array
*/
- public function request(array $body, string $method, string $path, bool $worker, array $headers, ?RequestBudget $budget = null): array
+ public function request(array $body, string $method, string $path, bool $worker, array $headers, ?RequestBudget $budget = null, ?FailureReportLimiter $failureReports = null): array
{
if (!$this->transport instanceof PayloadUploadTransport || !in_array(strtoupper($method), ['POST', 'PUT', 'PATCH'], true)) {
return $body;
}
$slots = $this->paths($body, explode('?', $path)[0], $worker);
- $payloads = [];
- foreach ($slots as $segments) {
- $value = $this->get($body, $segments);
- if (is_array($value) && array_key_exists('external_payload', $value)) {
- if (count($value) !== 2 || ($value['codec'] ?? null) !== 'avro') {
- throw new ExternalPayloadException('Invalid runtime payload envelope.', 422, 'external_payload_unsupported');
- }
- RuntimePayloads::validateReference($value['external_payload']);
- }
- $blob = is_string($value) ? $value : (is_array($value) && count($value) === 2
- && ($value['codec'] ?? null) === 'avro' ? ($value['blob'] ?? null) : null);
- if (is_string($blob)) {
- $payloads[] = ['path' => $segments, 'blob' => $blob];
- }
+ $payloads = $this->payloads($body, $slots);
+ $requestBytesLimit = $failureReports?->limitForRequest($path, $body);
+ if ($requestBytesLimit !== null) {
+ $body = $this->failureMetadata($body, $payloads, $path, $failureReports);
+ $payloads = $this->payloads($body, $slots);
}
if ($payloads === []) {
return $body;
}
+ $policy = $this->requestPolicy($body, $path, $worker, $headers, $budget);
+ if ($requestBytesLimit !== null) {
+ $policy['request_bytes'] = min($policy['request_bytes'], $requestBytesLimit);
+ }
+ $selected = $this->plan($body, $payloads, $policy);
+ return $this->uploadSelected($body, $payloads, $selected, $policy,
+ ['headers' => $headers, 'path' => $path, 'worker' => $worker, 'budget' => $budget]);
+ }
+
+ /** @param array $body
+ * @param array $headers
+ * @return UploadPolicy
+ */
+ private function requestPolicy(array $body, string $path, bool $worker, array $headers, ?RequestBudget $budget): array
+ {
try {
- $policy = $this->policy($headers, $worker, $budget);
+ return $this->policy($headers, $worker, $budget);
} catch (TransportException $exception) {
$discovery = $exception->response;
if ($worker
@@ -103,6 +114,15 @@ public function request(array $body, string $method, string $path, bool $worker,
throw $exception;
}
+ }
+
+ /** @param array $body
+ * @param list $payloads
+ * @param UploadPolicy $policy
+ * @return array>
+ */
+ private function plan(array &$body, array &$payloads, array $policy): array
+ {
// Plan the entire request before uploading anything. Several individually
// inline values can still exceed the ordinary JSON request limit.
$selected = [];
@@ -130,98 +150,144 @@ public function request(array $body, string $method, string $path, bool $worker,
throw new ExternalPayloadException('Namespace runtime external payload storage is not available.', 503, 'external_payload_unavailable');
}
- $uploaded = [];
- $upload = function (array $uploadHeaders, string $blob) use ($policy, $budget): array {
- $uri = $this->baseUri.'/api/external-payloads/v1';
- if ($budget !== null && !$this->transport instanceof BoundedPayloadUploadTransport) {
- throw new ExternalPayloadException('The cooperative transport must bound payload uploads.', 415, 'external_payload_unsupported');
- }
- $deadline = hrtime(true) / 1e9 + $policy['timeout_seconds'];
- $timeout = $policy['timeout_seconds'];
- for ($attempt = 0; ; ++$attempt) {
- try {
- if ($budget === null) {
- return $this->transport->uploadPayload($uri, $uploadHeaders, $blob, $timeout);
- }
- $response = $this->transport->uploadPayloadBounded($uri, $uploadHeaders, $blob, min($budget->remainingSeconds(), $timeout));
- $budget->remainingSeconds();
-
- return $response;
- } catch (TransportException $exception) {
- // A reservation or object can already exist after this refusal.
- // Retry only the upload, with identical bytes and completion
- // context, once within the original upload/request budget.
- $timeout = (int) floor($deadline - hrtime(true) / 1e9);
- if ($attempt !== 0 || $exception->status !== 503
- || ($exception->response['schema'] ?? null) !== 'durable-workflow.v2.runtime-external-payload-error.v1'
- || ($exception->response['reason'] ?? null) !== 'external_payload_unavailable'
- || ($exception->response['retryable'] ?? null) !== true
- || $timeout < 1) {
- throw $exception;
- }
- $budget?->remainingSeconds();
+ return $selected;
+ }
+
+ /** @param array $uploadHeaders
+ * @param UploadPolicy $policy
+ * @return array
+ */
+ private function uploadBlob(array $uploadHeaders, string $blob, array $policy, ?RequestBudget $budget): array
+ {
+ $transport = $this->transport;
+ if (!$transport instanceof PayloadUploadTransport) {
+ throw new ExternalPayloadException('The transport must support payload uploads.', 415, 'external_payload_unsupported');
+ }
+ $uri = $this->baseUri.'/api/external-payloads/v1';
+ if ($budget !== null && !$transport instanceof BoundedPayloadUploadTransport) {
+ throw new ExternalPayloadException('The cooperative transport must bound payload uploads.', 415, 'external_payload_unsupported');
+ }
+ $deadline = hrtime(true) / 1e9 + $policy['timeout_seconds'];
+ $timeout = $policy['timeout_seconds'];
+ for ($attempt = 0; ; ++$attempt) {
+ try {
+ if ($budget === null) {
+ return $transport->uploadPayload($uri, $uploadHeaders, $blob, $timeout);
+ }
+ $response = $transport->uploadPayloadBounded($uri, $uploadHeaders, $blob, min($budget->remainingSeconds(), $timeout));
+ $budget->remainingSeconds();
+
+ return $response;
+ } catch (TransportException $exception) {
+ // A reservation or object can already exist after this refusal.
+ // Retry only the upload, with identical bytes and completion
+ // context, once within the original upload/request budget.
+ $timeout = (int) floor($deadline - hrtime(true) / 1e9);
+ if ($attempt !== 0 || $exception->status !== 503
+ || ($exception->response['schema'] ?? null) !== 'durable-workflow.v2.runtime-external-payload-error.v1'
+ || ($exception->response['reason'] ?? null) !== 'external_payload_unavailable'
+ || ($exception->response['retryable'] ?? null) !== true
+ || $timeout < 1) {
+ throw $exception;
}
+ $budget?->remainingSeconds();
}
- };
+ }
+ }
+
+ /** @param array $body
+ * @param list $payloads
+ * @param array> $selected
+ * @param UploadPolicy $policy
+ * @param UploadRequest $request
+ * @return array
+ */
+ private function uploadSelected(array $body, array $payloads, array $selected, array $policy, array $request): array
+ {
+ $uploaded = [];
foreach ($selected as $index => $expected) {
- $blob = $payloads[$index]['blob'];
$key = $expected['sha256'].':'.$expected['size_bytes'];
- if (!isset($uploaded[$key])) {
- try {
- $uploadHeaders = array_replace($headers, [
- 'Content-Type' => 'application/octet-stream',
- 'Accept' => 'application/json',
- 'X-Durable-Workflow-Payload-Codec' => 'avro',
- 'X-Durable-Workflow-Payload-Size' => (string) strlen($blob),
- 'X-Durable-Workflow-Payload-SHA256' => $expected['sha256'],
- ]);
- try {
- $response = $upload($uploadHeaders, $blob);
- } catch (TransportException $refusal) {
- $context = $worker && ($policy['completion_context'] ?? false)
- ? $this->completionContext($body, $path, $payloads[$index]['path'],
- ($policy['prepared_completion_context'] ?? false)
- && ($headers['X-Durable-Workflow-Protocol-Version'] ?? null) === Version::COOPERATIVE_CANCELLATION_MINIMUM_WORKER_PROTOCOL,
- ($policy['scope_completion_context'] ?? false)
- && ($headers['X-Durable-Workflow-Protocol-Version'] ?? null) === Version::COOPERATIVE_CANCELLATION_MINIMUM_WORKER_PROTOCOL) : null;
- if ($context === null || $refusal->status !== 503
- || ($refusal->response['reason'] ?? null) !== 'storage_pressure'
- || ($refusal->response['storage_state'] ?? null) !== 'draining'
- || ($refusal->response['request_admitted'] ?? null) !== false) {
- throw $refusal;
- }
- // One capability-negotiated retry; never turn other pressure
- // errors into retries or repeat application activity code.
- $uploadHeaders[self::COMPLETION_HEADER] = $context;
- $response = $upload($uploadHeaders, $blob);
- }
- } catch (TransportException $exception) {
- $reason = $exception->response['reason'] ?? null;
- $reason = is_string($reason) ? $reason : match ($exception->status) {
- 401, 403 => 'external_payload_unauthorized',
- 413 => 'external_payload_oversized',
- 415, 422 => 'external_payload_unsupported',
- default => 'external_payload_unavailable',
- };
- throw new ExternalPayloadException('Runtime external payload upload failed.', $exception->status ?? 0,
- $reason, $exception->response, $exception);
- }
- if (($response['schema'] ?? null) !== 'durable-workflow.v2.runtime-external-payload-upload.v1'
- || ($response['transport_version'] ?? null) !== 1) {
- throw new ExternalPayloadException('Unsupported runtime upload response.', 422, 'external_payload_unsupported');
- }
- $reference = RuntimePayloads::validateReference($response['reference'] ?? null);
- if ($reference['size_bytes'] !== strlen($blob) || !hash_equals($expected['sha256'], $reference['sha256'])) {
- throw new ExternalPayloadException('Runtime upload reference differs from the submitted bytes.', 422, 'external_payload_integrity_mismatch');
- }
- $uploaded[$key] = $reference;
- }
+ $uploaded[$key] ??= $this->uploadReference($body, $payloads[$index], $expected, $policy, $request);
$this->replace($body, $payloads[$index]['path'], $uploaded[$key]);
}
-
return $body;
}
+ /** @param array $body
+ * @param PayloadSlot $payload
+ * @param array $expected
+ * @param UploadPolicy $policy
+ * @param UploadRequest $request
+ * @return array
+ */
+ private function uploadReference(array $body, array $payload, array $expected, array $policy, array $request): array
+ {
+ $blob = $payload['blob'];
+ $headers = array_replace($request['headers'], [
+ 'Content-Type' => 'application/octet-stream', 'Accept' => 'application/json',
+ 'X-Durable-Workflow-Payload-Codec' => 'avro', 'X-Durable-Workflow-Payload-Size' => (string) strlen($blob),
+ 'X-Durable-Workflow-Payload-SHA256' => $expected['sha256'],
+ ]);
+ try {
+ $response = $this->sendWithCompletionFallback($body, $payload, $headers, $policy, $request);
+ } catch (TransportException $exception) {
+ $reason = $exception->response['reason'] ?? null;
+ $reason = is_string($reason) ? $reason : match ($exception->status) {
+ 401, 403 => 'external_payload_unauthorized', 413 => 'external_payload_oversized',
+ 415, 422 => 'external_payload_unsupported', default => 'external_payload_unavailable',
+ };
+ throw new ExternalPayloadException('Runtime external payload upload failed.', $exception->status ?? 0,
+ $reason, $exception->response, $exception);
+ }
+ return $this->validateUploadReference($response, $blob, $expected);
+ }
+
+ /** @param array $body
+ * @param PayloadSlot $payload
+ * @param array $headers
+ * @param UploadPolicy $policy
+ * @param UploadRequest $request
+ * @return array
+ */
+ private function sendWithCompletionFallback(array $body, array $payload, array $headers, array $policy, array $request): array
+ {
+ try {
+ return $this->uploadBlob($headers, $payload['blob'], $policy, $request['budget']);
+ } catch (TransportException $refusal) {
+ $cooperative = ($headers['X-Durable-Workflow-Protocol-Version'] ?? null) === Version::COOPERATIVE_CANCELLATION_MINIMUM_WORKER_PROTOCOL;
+ $context = $request['worker'] && ($policy['completion_context'] ?? false)
+ ? $this->completionContext($body, $request['path'], $payload['path'],
+ ($policy['prepared_completion_context'] ?? false) && $cooperative,
+ ($policy['scope_completion_context'] ?? false) && $cooperative) : null;
+ if ($context === null || $refusal->status !== 503
+ || ($refusal->response['reason'] ?? null) !== 'storage_pressure'
+ || ($refusal->response['storage_state'] ?? null) !== 'draining'
+ || ($refusal->response['request_admitted'] ?? null) !== false) {
+ throw $refusal;
+ }
+ // Bind only the original completion after this exact refusal.
+ $headers[self::COMPLETION_HEADER] = $context;
+ return $this->uploadBlob($headers, $payload['blob'], $policy, $request['budget']);
+ }
+ }
+
+ /** @param array $response
+ * @param array $expected
+ * @return array
+ */
+ private function validateUploadReference(array $response, string $blob, array $expected): array
+ {
+ if (($response['schema'] ?? null) !== 'durable-workflow.v2.runtime-external-payload-upload.v1'
+ || ($response['transport_version'] ?? null) !== 1) {
+ throw new ExternalPayloadException('Unsupported runtime upload response.', 422, 'external_payload_unsupported');
+ }
+ $reference = RuntimePayloads::validateReference($response['reference'] ?? null);
+ if ($reference['size_bytes'] !== strlen($blob) || !hash_equals($expected['sha256'], $reference['sha256'])) {
+ throw new ExternalPayloadException('Runtime upload reference differs from the submitted bytes.', 422, 'external_payload_integrity_mismatch');
+ }
+ return $reference;
+ }
+
/** @param array $headers
* @return array{threshold_bytes: int, max_bytes: int, request_bytes: int, timeout_seconds: int, status: string, completion_context?: bool, prepared_completion_context?: bool, scope_completion_context?: bool}
*/
@@ -408,6 +474,79 @@ private function placeholder(array &$body, array $payload): array
return $reference;
}
+ /** @param array $body
+ * @param list> $slots
+ * @return list, blob: string}>
+ */
+ private function payloads(array $body, array $slots): array
+ {
+ $payloads = [];
+ foreach ($slots as $segments) {
+ $value = $this->get($body, $segments);
+ $blob = $this->payloadBlob($value);
+ if (is_string($blob)) {
+ $payloads[] = ['path' => $segments, 'blob' => $blob];
+ }
+ }
+ return $payloads;
+ }
+
+ private function payloadBlob(mixed $value): ?string
+ {
+ if (is_array($value) && array_key_exists('external_payload', $value)) {
+ if (count($value) !== 2 || ($value['codec'] ?? null) !== 'avro') {
+ throw new ExternalPayloadException('Invalid runtime payload envelope.', 422, 'external_payload_unsupported');
+ }
+ RuntimePayloads::validateReference($value['external_payload']);
+ return null;
+ }
+ if (is_string($value)) {
+ return $value;
+ }
+ return is_array($value) && count($value) === 2 && ($value['codec'] ?? null) === 'avro'
+ && is_string($value['blob'] ?? null) ? $value['blob'] : null;
+ }
+
+ /** Bound diagnostic text against complete metadata before planning ordinary uploads.
+ * @param array $body
+ * @param list, blob: string}> $payloads
+ * @return array
+ */
+ private function failureMetadata(array $body, array $payloads, string $path, FailureReportLimiter $reports): array
+ {
+ $metadata = $body;
+ $large = [];
+ foreach ($payloads as $payload) {
+ if (strlen($payload['blob']) > 1024 && ($payload['path'][0] ?? null) !== 'failure') {
+ $large[] = $payload['path'];
+ $reference = ['schema' => RuntimePayloads::SCHEMA, 'reference_id' => 'ep_00000000000000000000000000',
+ 'codec' => 'avro', 'size_bytes' => strlen($payload['blob']), 'sha256' => hash('sha256', $payload['blob'])];
+ $this->put($metadata, $payload['path'], ['codec' => 'avro', 'external_payload' => $reference]);
+ }
+ }
+ $metadata = $reports->request($path, $metadata);
+ foreach ($large as $segments) {
+ $value = $this->get($metadata, $segments);
+ if (is_array($value) && isset($value['external_payload'])) {
+ $this->put($metadata, $segments, $this->get($body, $segments));
+ }
+ }
+ return $metadata;
+ }
+
+ /** @param array $body
+ * @param list $segments
+ */
+ private function put(array &$body, array $segments, mixed $value): void
+ {
+ $target = &$body;
+ $last = array_pop($segments);
+ foreach ($segments as $key) {
+ $target = &$target[$key];
+ }
+ $target[$last] = $value;
+ }
+
/** @param array $body
* @param list $segments
* @param array $reference
@@ -446,7 +585,6 @@ private function get(mixed $value, array $segments): mixed
private function jsonSize(mixed $value): int
{
- return strlen(json_encode($value, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION
- | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
+ return JsonDocument::size($value);
}
}
diff --git a/src/Worker.php b/src/Worker.php
index b90932ed..8e597792 100644
--- a/src/Worker.php
+++ b/src/Worker.php
@@ -6,6 +6,7 @@
use DurableWorkflow\Exception\ActivityCancelled;
use DurableWorkflow\Exception\CodecException;
+use DurableWorkflow\Exception\FailureReportRefused;
use DurableWorkflow\Exception\InvalidLocalActivityReport;
use DurableWorkflow\Exception\InvalidWorkerDefinition;
use DurableWorkflow\Exception\LocalActivityTimedOut;
@@ -1293,6 +1294,7 @@ private function rejectPolledTask(string $taskKind, array $task, CodecException
(int) ($task['workflow_task_attempt'] ?? 1),
'PHP workflow task execution failed: '.$failure->getMessage(),
$failure::class,
+ stackTrace: $failure->getTraceAsString(),
);
},
'activity' => function (Throwable $failure) use ($taskId, $leaseOwner, $task): void {
@@ -1303,6 +1305,7 @@ private function rejectPolledTask(string $taskKind, array $task, CodecException
$failure->getMessage(),
$failure::class,
nonRetryable: true,
+ stackTrace: $failure->getTraceAsString(),
);
},
'query' => function (Throwable $failure) use ($taskId, $leaseOwner, $task): void {
@@ -1435,6 +1438,7 @@ function (Throwable $failure) use ($taskId, $leaseOwner, $attempt): void {
$failure::class,
$failure instanceof NonDeterministicWorkflow || $failure instanceof WorkflowEntryArgumentsMismatch ? $failure->reason : null,
$failure instanceof NonDeterministicWorkflow ? $failure->sequence : null,
+ $failure->getTraceAsString(),
);
},
$attempt,
@@ -2536,6 +2540,8 @@ function (Throwable $failure) use ($taskId, $attemptId, $leaseOwner): void {
$failure->getMessage(),
$failure instanceof ActivityExecutionFailure ? $failure->originalType : $failure::class,
$failure instanceof ActivityCancelled || ($failure instanceof ActivityExecutionFailure && $failure->cancelled),
+ stackTrace: $failure instanceof ActivityExecutionFailure ? $failure->originalStackTrace : $failure->getTraceAsString(),
+ diagnostics: $failure instanceof ActivityExecutionFailure ? $failure->originalDiagnostics : null,
);
},
);
@@ -2798,6 +2804,9 @@ private function acknowledgeTaskFailure(
callable $failureAcknowledgement,
?int $workflowTaskAttempt = null,
): void {
+ if ($taskFailure instanceof FailureReportRefused) {
+ throw $taskFailure;
+ }
if ($this->isTaskAuthorityLost($taskKind, $taskId, $taskFailure, $workflowTaskAttempt)) {
if ($taskKind === 'workflow' && $taskFailure instanceof ServerException
&& ($taskFailure->details['task_status'] ?? null) === 'ready') {
@@ -3038,6 +3047,7 @@ private function executeLocalActivity(
$heartbeatTimeout = $options['heartbeat_timeout'] ?? null;
$executionStartedAt = $this->now();
$attempts = [];
+ $localFailureSizes = [];
$totalHeartbeatCount = 0;
for ($attemptNumber = 1; $attemptNumber <= $maxAttempts; ++$attemptNumber) {
@@ -3236,9 +3246,9 @@ function (int $relay, int $callback) use ($task, $attemptId, $activityType): voi
'heartbeats' => $heartbeats,
];
- return ['outcome' => 'completed', 'result' => $result, 'attempts' => $attempts];
+ return $this->client->normalizeLocalFailure(['outcome' => 'completed', 'result' => $result, 'attempts' => $attempts], $localFailureSizes);
} catch (Throwable $exception) {
- if ($exception instanceof WorkflowClaimAborted) {
+ if ($exception instanceof WorkflowClaimAborted || $exception instanceof FailureReportRefused) {
throw $exception;
}
if ($exception instanceof ServerException && $exception->isStorageAdmissionFailure()) {
@@ -3294,6 +3304,10 @@ function (int $relay, int $callback) use ($task, $attemptId, $activityType): voi
if ($message === '') {
$message = "Local activity failed with {$type}.";
}
+ $sourceBytes = $exception instanceof ActivityExecutionFailure
+ ? ($exception->originalDiagnostics['truncation']['fields']['message']['original_bytes'] ?? null) : null;
+ $localFailureSizes['attempts'][$attemptNumber - 1] = is_int($sourceBytes)
+ ? max($sourceBytes, strlen($message)) : strlen($message);
$attempt = [
'attempt_id' => $attemptId,
'attempt_number' => $attemptNumber,
@@ -3327,14 +3341,15 @@ function (int $relay, int $callback) use ($task, $attemptId, $activityType): voi
continue;
}
- return [
+ $localFailureSizes['message'] = $localFailureSizes['attempts'][$attemptNumber - 1];
+ return $this->client->normalizeLocalFailure([
'outcome' => $cancelled ? 'cancelled' : ($timedOut ? 'timed_out' : 'failed'),
'message' => $message,
'exception_type' => $type,
'non_retryable' => $isNonRetryable,
...($timeoutKind === null ? [] : ['timeout_kind' => $timeoutKind]),
'attempts' => $attempts,
- ];
+ ], $localFailureSizes);
}
}
@@ -3941,26 +3956,20 @@ private function workflowFailureCommand(
?string $failedActivityExecutionId = null,
): array
{
+ if ($exception instanceof FailureReportRefused) {
+ throw $exception;
+ }
$command = [
'type' => 'fail_workflow',
'message' => $exception->getMessage(),
'exception_type' => $exception::class,
+ 'exception' => ['stack_trace' => $exception->getTraceAsString()],
];
if ($failedActivitySequence !== null && $failedActivityExecutionId !== null) {
$command['failed_step_sequence'] = $failedActivitySequence;
$command['failed_activity_execution_id'] = $failedActivityExecutionId;
}
- try {
- json_encode($command, JSON_THROW_ON_ERROR);
- } catch (\JsonException) {
- $command = [
- 'type' => 'fail_workflow',
- 'message' => 'Workflow failure metadata could not be encoded for the HTTP JSON wire boundary.',
- 'exception_type' => \RuntimeException::class,
- ];
- }
-
return $command;
}
diff --git a/src/Worker/ActivityExecutionFailure.php b/src/Worker/ActivityExecutionFailure.php
index 9c499138..9f4805c6 100644
--- a/src/Worker/ActivityExecutionFailure.php
+++ b/src/Worker/ActivityExecutionFailure.php
@@ -9,6 +9,7 @@
/** @internal Failure metadata returned by an isolated activity callback. */
final class ActivityExecutionFailure extends RuntimeException
{
+ /** @param array|null $originalDiagnostics */
public function __construct(
string $message,
public readonly string $originalType,
@@ -17,6 +18,8 @@ public function __construct(
public readonly bool $invalidReport = false,
public readonly ?string $timeoutKind = null,
public readonly bool $storageAdmissionFailure = false,
+ public readonly ?string $originalStackTrace = null,
+ public readonly ?array $originalDiagnostics = null,
) {
parent::__construct($message);
}
diff --git a/src/Worker/CooperativeActivityExecutor.php b/src/Worker/CooperativeActivityExecutor.php
index 159cc96f..2eaa8df7 100644
--- a/src/Worker/CooperativeActivityExecutor.php
+++ b/src/Worker/CooperativeActivityExecutor.php
@@ -7,6 +7,10 @@
use Closure;
use DurableWorkflow\Codec\PayloadCodec;
use DurableWorkflow\Exception\ActivityCancelled;
+use DurableWorkflow\Exception\FailureReportRefused;
+use DurableWorkflow\Failure\FailureReportLimiter;
+use DurableWorkflow\Failure\FailureReportLimits;
+use DurableWorkflow\Transport\JsonDocument;
use DurableWorkflow\Exception\InvalidLocalActivityReport;
use DurableWorkflow\Exception\LocalActivityTimedOut;
use DurableWorkflow\Exception\ServerException;
@@ -97,7 +101,7 @@ public function execute(Closure $callback, Closure $heartbeat, Closure $check, ?
throw new WorkflowClaimAborted('Activity IPC closed before a fenced result.');
}
$buffer .= $chunk;
- if (strlen($buffer) > $this->maxFrameBytes + 4) {
+ if (strlen($buffer) > $this->receiveFrameBytes() + 4) {
throw new WorkflowClaimAborted('Activity IPC exceeded its finite frame bound.');
}
continue;
@@ -131,6 +135,9 @@ public function execute(Closure $callback, Closure $heartbeat, Closure $check, ?
$check(true);
return $this->decode($message);
+ case 'report_refused':
+ throw new WorkflowClaimAborted('failure_report_refused: the original callback failure cannot be encoded.',
+ previous: new FailureReportRefused(FailureReportLimits::REQUEST_BYTES));
case 'failure':
$check(true);
if (!is_string($message['message'] ?? null) || !is_string($message['type'] ?? null)
@@ -138,6 +145,8 @@ public function execute(Closure $callback, Closure $heartbeat, Closure $check, ?
|| !is_bool($message['cancelled'] ?? null)
|| !is_bool($message['invalid_report'] ?? null)
|| !is_bool($message['storage_admission_failure'] ?? null)
+ || (isset($message['stack_trace']) && !is_string($message['stack_trace']))
+ || (isset($message['diagnostics']) && !is_array($message['diagnostics']))
|| !array_key_exists('timeout_kind', $message)
|| ($message['timeout_kind'] !== null && !in_array($message['timeout_kind'],
['heartbeat', 'start_to_close', 'schedule_to_close'], true))) {
@@ -145,7 +154,7 @@ public function execute(Closure $callback, Closure $heartbeat, Closure $check, ?
}
throw new ActivityExecutionFailure($message['message'], $message['type'], $message['encoding'],
$message['cancelled'], $message['invalid_report'], $message['timeout_kind'],
- $message['storage_admission_failure']);
+ $message['storage_admission_failure'], $message['stack_trace'] ?? null, $message['diagnostics'] ?? null);
default:
throw new WorkflowClaimAborted('Activity IPC returned an unexpected message.');
}
@@ -257,7 +266,7 @@ public function executeConcurrent(array $operations, Closure $settled): void
throw new WorkflowClaimAborted('Group activity IPC closed before a fenced result.');
}
$process->buffer .= $chunk;
- if (strlen($process->buffer) > $this->maxFrameBytes + 4) {
+ if (strlen($process->buffer) > $this->receiveFrameBytes() + 4) {
throw new WorkflowClaimAborted('Group activity IPC exceeded its finite frame bound.');
}
}
@@ -265,6 +274,10 @@ public function executeConcurrent(array $operations, Closure $settled): void
while (($message = $this->takeFrame($process->buffer)) !== null) {
$operation = $operations[$index];
$kind = $message['kind'];
+ if ($kind === 'report_refused') {
+ throw new WorkflowClaimAborted('failure_report_refused: the original callback failure cannot be encoded.',
+ previous: new FailureReportRefused(FailureReportLimits::REQUEST_BYTES));
+ }
if ($kind === 'started') {
if ($process->callbackPid !== null || !is_int($message['callback_pid'] ?? null) || $message['callback_pid'] < 1) {
throw new WorkflowClaimAborted('Group activity IPC returned an invalid callback PID.');
@@ -365,6 +378,8 @@ private function failure(array $message): ActivityExecutionFailure
if (!is_string($message['message'] ?? null) || !is_string($message['type'] ?? null)
|| !is_bool($message['encoding'] ?? null) || !is_bool($message['cancelled'] ?? null)
|| !is_bool($message['invalid_report'] ?? null) || !is_bool($message['storage_admission_failure'] ?? null)
+ || (isset($message['stack_trace']) && !is_string($message['stack_trace']))
+ || (isset($message['diagnostics']) && !is_array($message['diagnostics']))
|| !array_key_exists('timeout_kind', $message)
|| ($message['timeout_kind'] !== null && !in_array($message['timeout_kind'],
['heartbeat', 'start_to_close', 'schedule_to_close'], true))) {
@@ -372,7 +387,8 @@ private function failure(array $message): ActivityExecutionFailure
}
return new ActivityExecutionFailure($message['message'], $message['type'], $message['encoding'],
- $message['cancelled'], $message['invalid_report'], $message['timeout_kind'], $message['storage_admission_failure']);
+ $message['cancelled'], $message['invalid_report'], $message['timeout_kind'], $message['storage_admission_failure'],
+ $message['stack_trace'] ?? null, $message['diagnostics'] ?? null);
}
/** @param resource $owner
@@ -493,25 +509,25 @@ private function callback($socket, Closure $callback): never
$this->writeFrame($socket, ['kind' => 'result', 'value' => $this->codec->envelope($result)]);
} catch (Throwable $error) {
try {
- $message = $error->getMessage();
- $type = $error::class;
- $invalid = $error instanceof InvalidLocalActivityReport;
+ $invalid = $error instanceof InvalidLocalActivityReport || preg_match('//u', $error->getMessage()) !== 1;
+ $body = (new FailureReportLimiter(new FailureReportLimits()))->activityEnvelope([
+ 'kind' => 'failure',
+ 'failure' => ['message' => $error->getMessage(), 'type' => $error::class,
+ 'stack_trace' => $error->getTraceAsString()],
+ 'encoding' => $encoding, 'cancelled' => !$invalid && $error instanceof ActivityCancelled,
+ 'invalid_report' => $invalid,
+ 'timeout_kind' => !$invalid && $error instanceof LocalActivityTimedOut ? $error->timeoutKind : null,
+ 'storage_admission_failure' => $error instanceof ServerException && $error->isStorageAdmissionFailure(),
+ ]);
+ $failure = $body['failure'];
+ unset($body['failure']);
+ $this->writeFrame($socket, $failure + $body);
+ } catch (FailureReportRefused) {
try {
- json_encode(['message' => $message, 'type' => $type], JSON_THROW_ON_ERROR);
+ $this->writeFrame($socket, ['kind' => 'report_refused']);
} catch (Throwable) {
- $invalid = true;
- $message = 'Activity failure metadata could not be encoded for the HTTP JSON wire boundary.';
+ // The owning worker has abandoned this claim.
}
- if (strlen($type) > 255) {
- $invalid = true;
- $message = 'Activity failure metadata exceeded the published exception type limit.';
- }
- $this->writeFrame($socket, ['kind' => 'failure', 'message' => $message,
- 'type' => $invalid ? InvalidLocalActivityReport::class : $type, 'encoding' => $encoding,
- 'cancelled' => !$invalid && $error instanceof ActivityCancelled,
- 'invalid_report' => $invalid,
- 'timeout_kind' => !$invalid && $error instanceof LocalActivityTimedOut ? $error->timeoutKind : null,
- 'storage_admission_failure' => $error instanceof ServerException && $error->isStorageAdmissionFailure()]);
} catch (Throwable) {
// The owning worker has abandoned this claim or the frame is invalid.
}
@@ -571,8 +587,9 @@ private function socketPair(): array
*/
private function writeFrame($socket, array $message): void
{
- $body = json_encode($message, JSON_THROW_ON_ERROR);
- if (strlen($body) > $this->maxFrameBytes) {
+ $failure = in_array($message['kind'] ?? null, ['failure', 'report_refused'], true);
+ $body = $failure ? JsonDocument::encode($message) : json_encode($message, JSON_THROW_ON_ERROR);
+ if (strlen($body) > ($failure ? FailureReportLimits::REQUEST_BYTES : $this->maxFrameBytes)) {
throw new WorkflowClaimAborted('Activity IPC exceeded its finite frame bound.');
}
$frame = pack('N', strlen($body)).$body;
@@ -611,6 +628,11 @@ private function requireMessage($socket, string $kind): array
return $message;
}
+ private function receiveFrameBytes(): int
+ {
+ return max($this->maxFrameBytes, FailureReportLimits::REQUEST_BYTES);
+ }
+
/** @return array|null */
private function takeFrame(string &$buffer): ?array
{
@@ -619,7 +641,7 @@ private function takeFrame(string &$buffer): ?array
}
$prefix = unpack('Nlength', substr($buffer, 0, 4));
$length = $prefix['length'] ?? 0;
- if (!is_int($length) || $length < 1 || $length > $this->maxFrameBytes) {
+ if (!is_int($length) || $length < 1 || $length > $this->receiveFrameBytes()) {
throw new WorkflowClaimAborted('Activity IPC received an invalid frame length.');
}
if (strlen($buffer) < $length + 4) {
@@ -635,6 +657,11 @@ private function takeFrame(string &$buffer): ?array
throw new WorkflowClaimAborted('Activity IPC received a malformed message.');
}
+ $failure = in_array($message['kind'], ['failure', 'report_refused'], true);
+ if ($length > ($failure ? FailureReportLimits::REQUEST_BYTES : $this->maxFrameBytes)) {
+ throw new WorkflowClaimAborted('Activity IPC exceeded its original payload frame bound.');
+ }
+
return $message;
}
diff --git a/src/Worker/WorkflowCommand.php b/src/Worker/WorkflowCommand.php
index 5fa42564..b83c435d 100644
--- a/src/Worker/WorkflowCommand.php
+++ b/src/Worker/WorkflowCommand.php
@@ -11,6 +11,8 @@
use DurableWorkflow\Codec\PayloadCodec;
use DurableWorkflow\Exception\InvalidLocalActivityReport;
use DurableWorkflow\Exception\ServerException;
+use DurableWorkflow\Exception\FailureReportRefused;
+use DurableWorkflow\Failure\LocalFailureReports;
use InvalidArgumentException;
use LogicException;
use Throwable;
@@ -435,7 +437,7 @@ public function resolveLocalActivity(PayloadCodec $codec): self
try {
$outcome = ($this->localActivity)($activityType, $arguments, $options);
} catch (Throwable $exception) {
- if ($exception instanceof WorkflowClaimAborted) {
+ if ($exception instanceof WorkflowClaimAborted || $exception instanceof FailureReportRefused) {
throw $exception;
}
if ($exception instanceof ServerException && $exception->isStorageAdmissionFailure()) {
@@ -492,6 +494,7 @@ public function resolveLocalActivity(PayloadCodec $codec): self
$resultEnvelope = null;
}
+ $outcome = LocalFailureReports::bound($outcome);
$result = $status === 'completed'
? ($outcome['result'] ?? null)
: new \DurableWorkflow\Exception\ActivityFailed(
diff --git a/tests/CooperativeActivityExecutorTest.php b/tests/CooperativeActivityExecutorTest.php
index aece57f1..b33679b9 100644
--- a/tests/CooperativeActivityExecutorTest.php
+++ b/tests/CooperativeActivityExecutorTest.php
@@ -572,6 +572,34 @@ static function (): void {},
}
}
+ public function testOversizedFailureIsIndependentOfTheBusinessPayloadFrameBudget(): void
+ {
+ try {
+ (new CooperativeActivityExecutor(new AvroPayloadCodec(), maxPayloadBytes: 1024))->execute(
+ static function (): never { throw new RuntimeException(str_repeat('m', 3 * 1024 * 1024)); },
+ static fn (array $details): mixed => null,
+ static function (): void {},
+ );
+ self::fail('Callback failure was accepted as success.');
+ } catch (ActivityExecutionFailure $error) {
+ self::assertSame(RuntimeException::class, $error->originalType);
+ self::assertFalse($error->duringEncoding);
+ self::assertFalse($error->cancelled);
+ self::assertFalse($error->invalidReport);
+ self::assertStringContainsString('[truncated failure report;', $error->getMessage());
+ self::assertLessThanOrEqual(63488, strlen($error->getMessage()));
+ self::assertStringContainsString('CooperativeActivityExecutorTest.php', $error->originalStackTrace);
+ self::assertSame(3145728, $error->originalDiagnostics['truncation']['fields']['message']['original_bytes']);
+ $bounded = (new \DurableWorkflow\Failure\FailureReportLimiter(
+ new \DurableWorkflow\Failure\FailureReportLimits(4096)))->activityEnvelope([
+ 'failure' => ['message' => $error->getMessage(), 'type' => $error->originalType,
+ 'stack_trace' => $error->originalStackTrace, 'diagnostics' => $error->originalDiagnostics],
+ ]);
+ self::assertLessThanOrEqual(4096, \DurableWorkflow\Transport\JsonDocument::size($bounded));
+ self::assertSame(3145728, $bounded['failure']['diagnostics']['truncation']['fields']['message']['original_bytes']);
+ }
+ }
+
public function testResultEncodingFailureIsDistinctFromCallbackFailure(): void
{
try {
diff --git a/tests/FailureReportTest.php b/tests/FailureReportTest.php
new file mode 100644
index 00000000..2ff37094
--- /dev/null
+++ b/tests/FailureReportTest.php
@@ -0,0 +1,231 @@
+requests[] = $request;
+ return new Response(200, ['Content-Type' => 'application/json'], '{}');
+ }
+ };
+ $client = new Client('https://recording.invalid', transport: new Psr18Transport(client: $http));
+ if (in_array($scenario, ['lower-limit', 'lower-details'], true)) {
+ $client = $client->withFailureReportLimit(4096);
+ }
+ $large = str_repeat('m', 3 * 1024 * 1024);
+ $message = in_array($scenario, ['message', 'combined', 'lower-limit'], true) ? $large : 'original message';
+ $stack = in_array($scenario, ['stack', 'combined', 'lower-limit'], true) ? $large : null;
+ if ($scenario === 'escaped') {
+ $message = str_repeat("\0\"\\雪🦀", 300000);
+ $stack = str_repeat("\0\"\\雪🦀", 300000);
+ }
+ $details = match ($scenario) {
+ 'details' => ['nested' => ['cause' => $large]],
+ 'lower-details' => ['text' => str_repeat('d', 45000)],
+ default => ['whole' => 1.0, 'zero' => -0.0],
+ };
+ $client->failActivityTask('original-task', 'original-attempt', 'original-owner', $message,
+ 'OriginalFailure', true, $details, $stack, ['authored_retry_delay' => 12.5]);
+ $reports = array_values(array_filter($http->requests,
+ static fn (RequestInterface $request): bool => str_ends_with($request->getUri()->getPath(), '/fail')));
+ self::assertCount(1, $reports);
+ $request = $reports[0];
+ $wire = (string) $request->getBody();
+ self::assertLessThanOrEqual(in_array($scenario, ['lower-limit', 'lower-details'], true) ? 4096 : 1048576, strlen($wire));
+ self::assertSame('/api/worker/activity-tasks/original-task/fail', $request->getUri()->getPath());
+ $body = json_decode($wire, true, flags: JSON_THROW_ON_ERROR);
+ self::assertSame('original-attempt', $body['activity_attempt_id']);
+ self::assertSame('original-owner', $body['lease_owner']);
+ $failure = $body['failure'];
+ self::assertSame('OriginalFailure', $failure['type']);
+ self::assertTrue($failure['non_retryable']);
+ self::assertLessThanOrEqual(63488, strlen($failure['message']));
+ self::assertLessThanOrEqual(131072, strlen($failure['stack_trace'] ?? ''));
+ self::assertSame(1, preg_match('//u', $failure['message']));
+ $metadata = array_intersect_key($failure, array_flip(['details', 'diagnostics', 'runtime_diagnostics']));
+ self::assertLessThanOrEqual(65536, JsonDocument::size($metadata));
+ if ($scenario === 'small') {
+ self::assertSame($message, $failure['message']);
+ self::assertSame(['authored_retry_delay' => 12.5], $failure['diagnostics']);
+ } else {
+ self::assertStringContainsString('[truncated failure report;', $failure['message']);
+ self::assertNotEmpty($failure['diagnostics']['truncation']['fields']);
+ }
+ if (in_array($scenario, ['details', 'lower-details'], true)) {
+ self::assertArrayNotHasKey('details', $failure);
+ self::assertTrue($failure['diagnostics']['truncation']['fields']['details']['omitted']);
+ } elseif ($scenario !== 'lower-limit') {
+ self::assertSame($client->payloadCodec()->envelope($details), $failure['details']);
+ self::assertSame($details, $client->payloadCodec()->decodeEnvelope($failure['details']));
+ }
+ }
+
+ public static function largeReports(): iterable
+ {
+ foreach (['message', 'stack', 'combined', 'escaped', 'details', 'lower-limit', 'lower-details', 'small'] as $case) {
+ yield $case => [$case];
+ }
+ }
+
+ public function testWorkflowTaskUsesOnlyPublishedStackCarrier(): void
+ {
+ $transport = new FakeTransport([[]]);
+ $client = new Client('http://recording.invalid', transport: $transport);
+ $client->failWorkflowTask('task', 'owner', 7, str_repeat('m', 3 * 1024 * 1024),
+ 'OriginalFailure', 'original-reason', 9, str_repeat('s', 3 * 1024 * 1024));
+ $body = $transport->requests[0]['body'];
+ self::assertSame('owner', $body['lease_owner']);
+ self::assertSame(7, $body['workflow_task_attempt']);
+ $failure = $body['failure'];
+ self::assertSame('OriginalFailure', $failure['type']);
+ self::assertSame('original-reason', $failure['reason']);
+ self::assertSame(9, $failure['sequence']);
+ self::assertArrayNotHasKey('diagnostics', $failure);
+ self::assertStringContainsString('[truncation diagnostics]', $failure['stack_trace']);
+ self::assertStringContainsString('3145728', $failure['stack_trace']);
+ self::assertLessThanOrEqual(131072, strlen($failure['stack_trace']));
+ self::assertLessThanOrEqual(1048576, JsonDocument::size($body));
+ }
+
+ public function testCompletionBoundsNestedExceptionWithoutChangingClassification(): void
+ {
+ $transport = new FakeTransport([[]]);
+ $client = new Client('http://recording.invalid', transport: $transport);
+ $large = str_repeat('x', 3 * 1024 * 1024);
+ $command = ['type' => 'fail_workflow', 'message' => 'outer', 'exception_type' => 'OriginalFailure',
+ 'non_retryable' => false, 'failed_step_sequence' => 8, 'failed_activity_execution_id' => 'activity',
+ 'exception' => ['message' => $large, 'properties' => ['previous' => ['message' => $large]], 'stack_trace' => $large]];
+ $client->completeWorkflowTask('task', 'owner', 4, [$command]);
+ $body = $transport->requests[0]['body'];
+ $bounded = $body['commands'][0];
+ foreach (['type', 'exception_type', 'non_retryable', 'failed_step_sequence', 'failed_activity_execution_id'] as $key) {
+ self::assertSame($command[$key], $bounded[$key]);
+ }
+ self::assertArrayNotHasKey('class', $bounded['exception']);
+ self::assertArrayNotHasKey('type', $bounded['exception']);
+ self::assertArrayNotHasKey('properties', $bounded['exception']);
+ self::assertStringContainsString('[truncated failure report;', $bounded['message']);
+ self::assertStringContainsString('[truncated failure report;', $bounded['exception']['message']);
+ self::assertTrue($bounded['exception']['diagnostics']['truncation']['fields']['details']['omitted']);
+ self::assertSame('owner', $body['lease_owner']);
+ self::assertSame(4, $body['workflow_task_attempt']);
+ }
+
+ public function testLowerConfiguredLimitCountsEscapingAfterAllMarkers(): void
+ {
+ $transport = new FakeTransport([[]]);
+ $client = (new Client('http://recording.invalid', transport: $transport))->withFailureReportLimit(4096);
+ $client->failActivityTask('task', 'attempt', 'owner', str_repeat("\0\"\\雪", 600000), 'OriginalFailure', false,
+ ['oversized' => str_repeat('d', 100000)], str_repeat("\0", 3000000));
+ $body = $transport->requests[0]['body'];
+ self::assertLessThanOrEqual(4096, JsonDocument::size($body));
+ self::assertFalse($body['failure']['non_retryable']);
+ self::assertStringContainsString('[truncated failure report;', $body['failure']['message']);
+ self::assertArrayNotHasKey('details', $body['failure']);
+ self::assertSame(JsonDocument::size($client->payloadCodec()->envelope(['oversized' => str_repeat('d', 100000)])),
+ $body['failure']['diagnostics']['truncation']['fields']['details']['original_serialized_bytes'] ?? null);
+ }
+
+ public function testSmallTransportBudgetRetainsMarkersInsteadOfAnArbitraryContentFloor(): void
+ {
+ foreach (['activity', 'workflow'] as $kind) {
+ $transport = new FakeTransport([[]]);
+ $client = (new Client('http://recording.invalid', transport: $transport))->withFailureReportLimit(512);
+ $large = str_repeat('m', 3 * 1024 * 1024);
+ if ($kind === 'activity') {
+ $client->failActivityTask('task', 'attempt', 'owner', $large, stackTrace: $large);
+ } else {
+ $client->failWorkflowTask('task', 'owner', 1, $large, stackTrace: $large);
+ }
+ self::assertCount(1, $transport->requests);
+ self::assertLessThanOrEqual(512, JsonDocument::size($transport->requests[0]['body']));
+ self::assertStringContainsString('[truncated failure report;', $transport->requests[0]['body']['failure']['message']);
+ }
+ }
+
+ public function testKnownTransportLimitIsRespectedAndImpossibleIdentityRefusesBeforeSend(): void
+ {
+ $transport = new class implements Transport, RequestSizeLimitTransport {
+ public array $requests = [];
+ public function maximumRequestBytes(): ?int { return 4096; }
+ public function send(string $method, string $uri, array $headers, ?array $body = null): ?array
+ {
+ $this->requests[] = $body;
+ return [];
+ }
+ };
+ $client = new Client('http://recording.invalid', transport: $transport);
+ $client->failActivityTask('task', 'attempt', 'owner', str_repeat('x', 3000000));
+ self::assertLessThanOrEqual(4096, JsonDocument::size($transport->requests[0]));
+ try {
+ $client->failActivityTask('task', str_repeat('identity', 1000), 'owner', 'small', 'OriginalFailure');
+ self::fail('The fixed identity cannot fit.');
+ } catch (FailureReportRefused $error) {
+ self::assertSame('failure_report_refused', $error->response['reason']);
+ self::assertFalse($error->transientConnectionFailure);
+ }
+ self::assertCount(1, $transport->requests);
+ }
+
+ public function testAggregateCompletionBudgetIncludesEveryCommandAndFixedBusinessContent(): void
+ {
+ $limiter = new FailureReportLimiter(new FailureReportLimits(), new FakeTransport());
+ $command = ['type' => 'fail_workflow', 'message' => str_repeat("\0", 3000000), 'exception_type' => 'Original'];
+ $body = ['lease_owner' => 'owner', 'workflow_task_attempt' => 3, 'commands' => array_fill(0, 8, $command)];
+ $result = $limiter->request('/worker/workflow-tasks/task/complete', $body);
+ self::assertLessThanOrEqual(1048576, JsonDocument::size($result));
+ self::assertCount(8, $result['commands']);
+ $body['commands'][] = ['type' => 'record_marker', 'name' => 'business', 'details' => str_repeat('x', 1100000)];
+ $this->expectException(FailureReportRefused::class);
+ $limiter->request('/worker/workflow-tasks/task/complete', $body);
+ }
+
+ public function testSuccessfulLocalRecordKeepsItsOrdinaryBusinessPayloadBudget(): void
+ {
+ $transport = new FakeTransport([[]]);
+ $client = new Client('http://recording.invalid', transport: $transport);
+ $result = $client->payloadCodec()->envelope(str_repeat('p', 900000));
+ $command = ['type' => 'record_local_activity', 'activity_type' => 'business', 'outcome' => 'completed',
+ 'result' => $result, 'attempts' => [['attempt_number' => 1, 'outcome' => 'completed', 'heartbeats' => []]]];
+ $client->completeWorkflowTask('task', 'owner', 1, [$command]);
+ self::assertCount(1, $transport->requests);
+ self::assertGreaterThan(1048576, JsonDocument::size($transport->requests[0]['body']));
+ self::assertSame($result, $transport->requests[0]['body']['commands'][0]['result']);
+ }
+
+ public function testInvalidUtf8MessageRepairsBoundedPrefixWithoutChangingType(): void
+ {
+ $transport = new FakeTransport([[]]);
+ $client = new Client('http://recording.invalid', transport: $transport);
+ $client->failActivityTask('task', 'attempt', 'owner', "broken\xff message", 'OriginalType');
+ $failure = $transport->requests[0]['body']['failure'];
+ self::assertSame('OriginalType', $failure['type']);
+ self::assertSame(1, preg_match('//u', $failure['message']));
+ self::assertStringContainsString('[truncated failure report;', $failure['message']);
+ }
+}
diff --git a/tests/LocalFailureReportTest.php b/tests/LocalFailureReportTest.php
new file mode 100644
index 00000000..4f3e6f46
--- /dev/null
+++ b/tests/LocalFailureReportTest.php
@@ -0,0 +1,102 @@
+localActivity('local-failure', [], ['retry_policy' => ['max_attempts' => 1]]);
+ return 'unexpected-success';
+ } catch (ActivityFailed $failure) {
+ $observations[] = [$failure->getMessage(), $failure->failureType, $failure->nonRetryable];
+ return str_contains($failure->getMessage(), '[truncated failure report;') ? 'bounded-branch' : 'unbounded-branch';
+ }
+ };
+ $calls = 0;
+ $original = str_repeat('雪', 1024 * 1024);
+ $local = static function () use (&$calls, $original): array {
+ ++$calls;
+ return ['outcome' => 'failed', 'message' => $original, 'exception_type' => 'OriginalLocalFailure',
+ 'non_retryable' => true, 'attempts' => [['attempt_number' => 1, 'outcome' => 'failed', 'message' => $original,
+ 'exception_type' => 'OriginalLocalFailure', 'non_retryable' => true, 'duration_ms' => 0, 'heartbeats' => []]]];
+ };
+ $codec = new AvroPayloadCodec();
+ $fresh = (new Replayer($codec))->replay($handler, [], [], 'queue', localActivityExecutor: $local);
+ self::assertNull($fresh->terminalFailure);
+ self::assertSame(['record_local_activity', 'complete_workflow'], array_column($fresh->commands, 'type'));
+ $recorded = $fresh->commands[0];
+ self::assertLessThanOrEqual(63488, strlen($recorded['message']));
+ self::assertSame(1, preg_match('//u', $recorded['message']));
+ self::assertStringContainsString('3145728', $recorded['message']);
+ self::assertSame($recorded['message'], $recorded['attempts'][0]['message']);
+ self::assertSame($recorded['message'], $observations[0][0]);
+ $history = [
+ ['event_type' => 'ActivityScheduled', 'payload' => ['sequence' => 1, 'activity_type' => 'local-failure',
+ 'execution_mode' => 'local', 'arguments' => $codec->envelope([])]],
+ ['event_type' => 'ActivityFailed', 'payload' => $recorded + ['sequence' => 1, 'execution_mode' => 'local']],
+ ];
+ $cold = (new Replayer($codec))->replay($handler, $history, [], 'queue', localActivityExecutor: static function (): never {
+ self::fail('Cold replay repeated the local side effect.');
+ });
+ self::assertNull($cold->terminalFailure);
+ self::assertSame(['complete_workflow'], array_column($cold->commands, 'type'));
+ self::assertSame($observations[0], $observations[1]);
+ self::assertSame($fresh->commands[1]['result'], $cold->commands[0]['result']);
+ self::assertSame(1, $calls);
+ }
+
+ public function testCallerTextResemblingADiagnosticMarkerStaysOrdinarySmallText(): void
+ {
+ $message = "caller literal\n[truncated failure report; original message 0 bytes]";
+ $outcome = ['outcome' => 'failed', 'message' => $message, 'exception_type' => 'OriginalFailure', 'non_retryable' => false];
+ self::assertSame($outcome, LocalFailureReports::bound($outcome));
+ $outcome['message'] .= "\n[truncation diagnostics]\n{\"message\":{\"original_bytes\":0}}";
+ self::assertSame($outcome, LocalFailureReports::bound($outcome));
+ }
+
+ public function testOnlyExplicitCallbackProvenanceCarriesAnEarlierOriginalSize(): void
+ {
+ $outcome = ['outcome' => 'failed', 'message' => 'already bounded', 'exception_type' => 'OriginalFailure', 'non_retryable' => false];
+ $bounded = LocalFailureReports::bound($outcome, originalSizes: ['message' => 3145728]);
+ self::assertStringContainsString('3145728', $bounded['message']);
+ self::assertSame($bounded, LocalFailureReports::bound($bounded));
+ }
+
+ public function testAggregateAttemptMessagesFitBeforeObservationWithoutChangingRetries(): void
+ {
+ $message = str_repeat("\0雪", 1000000);
+ $attempts = [];
+ for ($number = 1; $number <= 30; ++$number) {
+ $attempts[] = ['attempt_id' => 'original-attempt-'.$number, 'attempt_number' => $number,
+ 'outcome' => 'failed', 'message' => $message, 'exception_type' => 'OriginalFailure',
+ 'non_retryable' => false, 'retry_reason' => 'failure', 'backoff_seconds' => 3, 'heartbeats' => []];
+ }
+ $outcome = ['outcome' => 'failed', 'message' => $message, 'exception_type' => 'OriginalFailure',
+ 'non_retryable' => false, 'attempts' => $attempts];
+ $bounded = LocalFailureReports::bound($outcome, 128 * 1024);
+ self::assertLessThanOrEqual(128 * 1024, JsonDocument::size($bounded));
+ self::assertCount(30, $bounded['attempts']);
+ foreach ($bounded['attempts'] as $index => $attempt) {
+ unset($attempt['message']);
+ $original = $attempts[$index];
+ unset($original['message']);
+ self::assertSame($original, $attempt);
+ }
+ self::assertSame($bounded['message'], $bounded['attempts'][29]['message']);
+ self::assertSame($bounded, LocalFailureReports::bound($bounded, 128 * 1024));
+ }
+}
diff --git a/tests/PortableWorkerAffinityTest.php b/tests/PortableWorkerAffinityTest.php
index 10bd391c..5f52e3a0 100644
--- a/tests/PortableWorkerAffinityTest.php
+++ b/tests/PortableWorkerAffinityTest.php
@@ -1530,51 +1530,16 @@ public function testCompletedLocalActivityIsRecordedWhenWorkflowThrowsAndColdRep
self::assertSame(\RuntimeException::class, $replayed[0]['exception_type'] ?? null);
}
- public function testJsonUnsafeWorkflowFailureIsNormalizedOnInitialExecutionAndColdReplay(): void
+ public function testJsonUnsafeWorkflowFailureTypeExplicitlyRefusesBeforeChangingIdentity(): void
{
- $outcome = 'completed_then_json_unsafe_workflow_failed';
$calls = 0;
$now = 0.0;
- $initial = $this->runTerminalLocalActivityWorkflow($outcome, [], $calls, $now);
-
- self::assertCount(2, $initial);
- self::assertSame('record_local_activity', $initial[0]['type'] ?? null);
- self::assertSame('completed', $initial[0]['outcome'] ?? null);
- self::assertSame('fail_workflow', $initial[1]['type'] ?? null);
- self::assertSame(
- 'Workflow failure metadata could not be encoded for the HTTP JSON wire boundary.',
- $initial[1]['message'] ?? null,
- );
- self::assertSame(\RuntimeException::class, $initial[1]['exception_type'] ?? null);
- self::assertJson(json_encode($initial, JSON_THROW_ON_ERROR));
- self::assertSame(1, $calls);
-
- $history = [
- [
- 'event_type' => 'ActivityScheduled',
- 'payload' => [
- 'sequence' => 1,
- 'activity_type' => 'terminal-local-activity',
- 'execution_mode' => 'local',
- ],
- ],
- [
- 'event_type' => 'ActivityCompleted',
- 'payload' => [
- 'sequence' => 1,
- 'activity_type' => 'terminal-local-activity',
- 'execution_mode' => 'local',
- 'outcome' => 'completed',
- 'result' => $initial[0]['result'] ?? null,
- 'attempts' => $initial[0]['attempts'] ?? [],
- ],
- ],
- ];
- $replayed = $this->runTerminalLocalActivityWorkflow($outcome, $history, $calls, $now);
-
- self::assertSame(1, $calls, 'Cold replay repeated the completed local activity side effect.');
- self::assertSame([$initial[1]], $replayed);
- self::assertJson(json_encode($replayed, JSON_THROW_ON_ERROR));
+ $this->expectException(\DurableWorkflow\Exception\FailureReportRefused::class);
+ try {
+ $this->runTerminalLocalActivityWorkflow('completed_then_json_unsafe_workflow_failed', [], $calls, $now);
+ } finally {
+ self::assertSame(1, $calls);
+ }
}
public function testTypedWorkerSessionLifecycleIsIdempotentAndSupportsReacquisition(): void
diff --git a/tests/RuntimePayloadUploadTest.php b/tests/RuntimePayloadUploadTest.php
index 2c00b0ce..37f37535 100644
--- a/tests/RuntimePayloadUploadTest.php
+++ b/tests/RuntimePayloadUploadTest.php
@@ -760,6 +760,45 @@ public function uploadPayload(string $uri, array $headers, string $blob, int $ti
}
}
+ public function testMixedLocalFailureUsesTheExistingUploadPlannerWithoutChangingBusinessPayloads(): void
+ {
+ foreach ([false, true] as $hasFailure) {
+ $policy = self::discovery();
+ $policy['limits']['max_payload_bytes'] = 2097152;
+ $policy['namespace']['external_payload_storage']['threshold_bytes'] = 2097152;
+ $policy['namespace']['external_payload_storage']['transport']['limits']['max_payload_bytes'] = 8388608;
+ [$client, $http] = $this->client(discovery: $policy);
+ $value = ['text' => str_repeat('p', 900000), 'whole' => 1.0, 'zero' => -0.0];
+ $envelope = $client->payloadCodec()->envelope($value);
+ $attempts = [];
+ if ($hasFailure) {
+ $attempts[] = ['attempt_number' => 1, 'outcome' => 'failed', 'message' => 'bounded failure',
+ 'exception_type' => 'OriginalFailure', 'non_retryable' => false, 'backoff_seconds' => 3, 'heartbeats' => []];
+ }
+ $attempts[] = ['attempt_number' => count($attempts) + 1, 'outcome' => 'completed', 'heartbeats' => []];
+ $client->completeWorkflowTask('original-task', 'original-owner', 7, [
+ ['type' => 'record_local_activity', 'activity_type' => 'business', 'outcome' => 'completed',
+ 'result' => $envelope, 'attempts' => $attempts],
+ ]);
+ $completion = $http->requests[array_key_last($http->requests)];
+ $body = $this->body($completion);
+ self::assertSame('original-owner', $body['lease_owner']);
+ self::assertSame(7, $body['workflow_task_attempt']);
+ self::assertSame($attempts, $body['commands'][0]['attempts']);
+ if ($hasFailure) {
+ self::assertCount(3, $http->requests);
+ self::assertSame($envelope['blob'], (string) $http->requests[1]->getBody());
+ self::assertLessThanOrEqual(1048576, strlen((string) $completion->getBody()));
+ self::assertArrayHasKey('external_payload', $body['commands'][0]['result']);
+ self::assertSame(self::reference($envelope['blob']), $body['commands'][0]['result']['external_payload']);
+ } else {
+ self::assertCount(2, $http->requests);
+ self::assertGreaterThan(1048576, strlen((string) $completion->getBody()));
+ self::assertSame($envelope, $body['commands'][0]['result']);
+ }
+ }
+ }
+
private function client(?array $discovery = null, bool $workerOnly = false, ?Response $uploadResponse = null, bool $drainUnbound = false, ?Response $retryUploadResponse = null): array
{
$http = new class($discovery ?? self::discovery(), $uploadResponse, $drainUnbound, $retryUploadResponse) {
diff --git a/tests/Support/ReplayRegressionFixture.php b/tests/Support/ReplayRegressionFixture.php
index d72c29cf..77ac55c3 100644
--- a/tests/Support/ReplayRegressionFixture.php
+++ b/tests/Support/ReplayRegressionFixture.php
@@ -25,6 +25,7 @@
final class ReplayRegressionFixture
{
private const FIXTURE_SCHEMA = 'durable-workflow.replay-regression/v1';
+ private const BOUNDED_LOCAL_FAILURE = 'golden.bounded-local-failure';
/** @return list> */
public static function executeFile(string $path): array
@@ -108,11 +109,17 @@ public static function execute(array $fixture): array
}
$localActivityInvocations = 0;
$localActivityExecutor = in_array($workflowType, [
- 'golden.local-activity-terminal-failure', 'golden.local-activity-recovered', 'golden.prepared-local-cleanup',
+ self::BOUNDED_LOCAL_FAILURE, 'golden.local-activity-terminal-failure', 'golden.local-activity-recovered', 'golden.prepared-local-cleanup',
'golden.prepared-local-group', 'golden.prepared-local-scoped-cleanup',
], true)
- ? static function () use (&$localActivityInvocations): array {
+ ? static function () use (&$localActivityInvocations, $workflowType): array {
++$localActivityInvocations;
+ if ($workflowType === self::BOUNDED_LOCAL_FAILURE) {
+ $message = str_repeat('雪', 1024 * 1024);
+ return ['outcome' => 'failed', 'message' => $message, 'exception_type' => RuntimeException::class,
+ 'non_retryable' => false, 'attempts' => [['attempt_number' => 1, 'outcome' => 'failed',
+ 'message' => $message, 'exception_type' => RuntimeException::class, 'non_retryable' => false, 'heartbeats' => []]]];
+ }
return [
'outcome' => 'completed',
@@ -194,7 +201,11 @@ public static function execute(array $fixture): array
'exception_type' => $exception::class,
]];
}
- if ($localActivityInvocations !== 0) {
+ if ($workflowType === self::BOUNDED_LOCAL_FAILURE) {
+ if ($localActivityInvocations !== 1) {
+ throw new RuntimeException("{$identity} must execute its fresh local failure exactly once.");
+ }
+ } elseif ($localActivityInvocations !== 0) {
throw new RuntimeException("{$identity} repeated a recorded local activity during cold replay.");
}
$declaredCommands = $fixture['command_sequence'] ?? null;
@@ -583,6 +594,16 @@ private static function workflow(string $workflowType): callable
return ['key' => $selected->key];
},
+ self::BOUNDED_LOCAL_FAILURE => static function (WorkflowContext $context): array {
+ try {
+ $context->localActivity('golden.bounded-local-failure', [], ['retry_policy' => ['max_attempts' => 1]]);
+ return ['unexpected_success' => true];
+ } catch (ActivityFailed $failure) {
+ return ['marked' => str_contains($failure->getMessage(), '[truncated failure report;'),
+ 'message_bytes' => strlen($failure->getMessage()), 'failure_type' => $failure->failureType,
+ 'non_retryable' => $failure->nonRetryable];
+ }
+ },
'golden.local-activity-recovered' => static fn (WorkflowContext $context): mixed =>
$context->localActivity('golden.local-receipt'),
'golden.local-activity-terminal-failure' => static function (WorkflowContext $context): never {
diff --git a/tests/Support/control-timeout-peer.php b/tests/Support/control-timeout-peer.php
index d425c5e4..8a01c5db 100644
--- a/tests/Support/control-timeout-peer.php
+++ b/tests/Support/control-timeout-peer.php
@@ -10,7 +10,8 @@
if ($server === false) {
exit(1);
}
-file_put_contents($directory.'/address', stream_socket_get_name($server, false));
+file_put_contents($directory.'/address.tmp', stream_socket_get_name($server, false));
+rename($directory.'/address.tmp', $directory.'/address');
$until = microtime(true) + 30;
while (microtime(true) < $until) {
$peer = @stream_socket_accept($server, 1);
diff --git a/tests/fixtures/replay-regressions/bounded-local-failure-before-workflow-branch.json b/tests/fixtures/replay-regressions/bounded-local-failure-before-workflow-branch.json
new file mode 100644
index 00000000..f0b17e18
--- /dev/null
+++ b/tests/fixtures/replay-regressions/bounded-local-failure-before-workflow-branch.json
@@ -0,0 +1,40 @@
+{
+ "$schema": "https://raw.githubusercontent.com/durable-workflow/.github/main/regression-corpus/evidence-schema.json",
+ "fixture_schema": "durable-workflow.replay-regression/v1",
+ "id": "php-bounded-local-failure-before-workflow-branch",
+ "protocol_version": "1.18",
+ "bindings": [
+ "php"
+ ],
+ "workflow": {
+ "type": "golden.bounded-local-failure",
+ "input": []
+ },
+ "history": [
+ {
+ "event_type": "WorkflowStarted",
+ "payload": {
+ "workflow_type": "golden.bounded-local-failure"
+ }
+ }
+ ],
+ "expected": {
+ "command_sequence": [
+ {
+ "type": "record_local_activity",
+ "outcome": "failed",
+ "exception_type": "RuntimeException",
+ "non_retryable": false
+ },
+ {
+ "type": "complete_workflow",
+ "result": {
+ "marked": true,
+ "message_bytes": 63486,
+ "failure_type": "RuntimeException",
+ "non_retryable": false
+ }
+ }
+ ]
+ }
+}