Skip to content

Commit 92c3d1e

Browse files
Preserve external activity failure payload details
1 parent 326081d commit 92c3d1e

6 files changed

Lines changed: 147 additions & 10 deletions

File tree

src/V2/ActivityTaskBridge.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,9 @@ public static function complete(string $attemptId, mixed $result, ?string $codec
115115
* @param Throwable|array<string, mixed>|string $failure
116116
* @return array{recorded: bool, task_id: string, reason: string|null, next_task_id: string|null}
117117
*/
118-
public static function fail(string $attemptId, Throwable|array|string $failure): array
118+
public static function fail(string $attemptId, Throwable|array|string $failure, ?string $codec = null): array
119119
{
120-
return self::resolve()->fail($attemptId, $failure);
120+
return self::resolve()->fail($attemptId, $failure, $codec);
121121
}
122122

123123
/**

src/V2/Support/ActivityOutcomeRecorder.php

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
use Illuminate\Support\Facades\DB;
88
use Throwable;
9-
use Workflow\Exceptions\NonRetryableExceptionContract;
109
use Workflow\Serializers\CodecRegistry;
1110
use Workflow\Serializers\Serializer;
1211
use Workflow\V2\Enums\ActivityAttemptStatus;
@@ -119,7 +118,7 @@ public static function record(
119118
: $lockedExecution->result,
120119
'exception' => $throwable === null
121120
? $lockedExecution->exception
122-
: self::serializeWithCodec(FailureFactory::payload($throwable), null, $runCodec),
121+
: self::serializeWithCodec(self::failurePayload($throwable, $codec), null, $runCodec),
123122
'closed_at' => $lockedExecution->closed_at ?? now(),
124123
])->save();
125124

@@ -178,7 +177,7 @@ public static function record(
178177

179178
self::closeAttempt($attemptId, ActivityAttemptStatus::Completed);
180179
} elseif (self::shouldRetry($throwable, $attemptCount, $maxAttempts)) {
181-
$exceptionPayload = FailureFactory::payload($throwable);
180+
$exceptionPayload = self::failurePayload($throwable, $codec);
182181
$retryAvailableAt = now()
183182
->addSeconds($backoffSeconds);
184183

@@ -243,7 +242,7 @@ public static function record(
243242

244243
return self::recorded($retryTask);
245244
} else {
246-
$exceptionPayload = FailureFactory::payload($throwable);
245+
$exceptionPayload = self::failurePayload($throwable, $codec);
247246
$activityFailureCategory = FailureFactory::classify('activity', 'activity_execution', $throwable);
248247
$activityNonRetryable = FailureFactory::isNonRetryable($throwable);
249248

@@ -439,7 +438,7 @@ private static function closeAttemptIfStale(WorkflowRun $run, string $attemptId)
439438

440439
private static function shouldRetry(Throwable $throwable, int $attemptCount, int $maxAttempts): bool
441440
{
442-
return ! $throwable instanceof NonRetryableExceptionContract
441+
return ! FailureFactory::isNonRetryable($throwable)
443442
&& $attemptCount < $maxAttempts;
444443
}
445444

@@ -466,6 +465,25 @@ private static function serializeWithCodec(mixed $value, ?string $workerCodec, ?
466465
return Serializer::serialize($value);
467466
}
468467

468+
/**
469+
* @return array<string, mixed>
470+
*/
471+
private static function failurePayload(Throwable $throwable, ?string $workerCodec): array
472+
{
473+
$payload = FailureFactory::payload($throwable);
474+
475+
if (
476+
is_string($workerCodec)
477+
&& $workerCodec !== ''
478+
&& array_key_exists('details', $payload)
479+
&& ! is_string($payload['details_payload_codec'] ?? null)
480+
) {
481+
$payload['details_payload_codec'] = $workerCodec;
482+
}
483+
484+
return $payload;
485+
}
486+
469487
private static function payloadCodec(?string $workerCodec, ?string $runCodec): string
470488
{
471489
if (is_string($workerCodec) && $workerCodec !== '') {

src/V2/Support/FailureFactory.php

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ public static function classifyFromStrings(string $propagationKind, string $sour
9696
*/
9797
public static function isNonRetryable(?Throwable $throwable): bool
9898
{
99+
if ($throwable instanceof RestoredWorkflowException) {
100+
return (bool) ($throwable->failurePayload()['non_retryable'] ?? false);
101+
}
102+
99103
return $throwable instanceof NonRetryableExceptionContract;
100104
}
101105

@@ -314,6 +318,10 @@ public static function restore(
314318
$normalized = self::normalizePayload($payload, $fallbackClass, $fallbackMessage, $fallbackCode);
315319
$class = $normalized['class'];
316320

321+
if (array_key_exists('details', $normalized) || is_string($normalized['details_payload_codec'] ?? null)) {
322+
return new RestoredWorkflowException($normalized);
323+
}
324+
317325
try {
318326
$resolvedClass = is_string($class)
319327
? TypeRegistry::resolveThrowableClass($class, $normalized['type'])
@@ -527,7 +535,7 @@ private static function normalizePayload(
527535
$payload = [];
528536
}
529537

530-
return [
538+
$normalized = [
531539
'class' => is_string($payload['class'] ?? null)
532540
? $payload['class']
533541
: ($fallbackClass ?? RestoredWorkflowException::class),
@@ -549,6 +557,20 @@ private static function normalizePayload(
549557
'trace' => self::traceFrames($payload),
550558
'properties' => self::propertyFrames($payload),
551559
];
560+
561+
if (array_key_exists('details', $payload)) {
562+
$normalized['details'] = $payload['details'];
563+
}
564+
565+
if (is_bool($payload['non_retryable'] ?? null)) {
566+
$normalized['non_retryable'] = $payload['non_retryable'];
567+
}
568+
569+
if (is_string($payload['details_payload_codec'] ?? null) && $payload['details_payload_codec'] !== '') {
570+
$normalized['details_payload_codec'] = $payload['details_payload_codec'];
571+
}
572+
573+
return $normalized;
552574
}
553575

554576
/**

src/V2/Support/FailureSnapshots.php

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,7 @@ private static function exceptionPayload(
274274
? array_values(array_filter($payload['properties'], static fn (mixed $frame): bool => is_array($frame)))
275275
: [];
276276

277-
return [
277+
$normalized = [
278278
'__constructor' => self::stringValue($payload['class'] ?? null)
279279
?? $fallbackClass
280280
?? $failure?->exception_class,
@@ -290,6 +290,20 @@ private static function exceptionPayload(
290290
'trace' => $trace,
291291
'properties' => $properties,
292292
];
293+
294+
if (array_key_exists('details', $payload)) {
295+
$normalized['details'] = $payload['details'];
296+
}
297+
298+
if (is_bool($payload['non_retryable'] ?? null)) {
299+
$normalized['non_retryable'] = $payload['non_retryable'];
300+
}
301+
302+
if (is_string($payload['details_payload_codec'] ?? null) && $payload['details_payload_codec'] !== '') {
303+
$normalized['details_payload_codec'] = $payload['details_payload_codec'];
304+
}
305+
306+
return $normalized;
293307
}
294308

295309
private static function tracePreviewFromPayload(array $payload): string

src/V2/Support/RunDetailView.php

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -702,7 +702,7 @@ private static function exceptionPayload(array $failure): array
702702
)
703703
: [];
704704

705-
return [
705+
$normalized = [
706706
'__constructor' => is_string($payload['__constructor'] ?? null)
707707
? $payload['__constructor']
708708
: ($failure['exception_class'] ?? null),
@@ -724,5 +724,19 @@ private static function exceptionPayload(array $failure): array
724724
'trace' => $trace,
725725
'properties' => $properties,
726726
];
727+
728+
if (array_key_exists('details', $payload)) {
729+
$normalized['details'] = $payload['details'];
730+
}
731+
732+
if (is_bool($payload['non_retryable'] ?? null)) {
733+
$normalized['non_retryable'] = $payload['non_retryable'];
734+
}
735+
736+
if (is_string($payload['details_payload_codec'] ?? null) && $payload['details_payload_codec'] !== '') {
737+
$normalized['details_payload_codec'] = $payload['details_payload_codec'];
738+
}
739+
740+
return $normalized;
727741
}
728742
}

tests/Feature/V2/V2ActivityExceptionCodecTest.php

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
use Workflow\V2\Models\WorkflowRun;
2424
use Workflow\V2\Models\WorkflowTask;
2525
use Workflow\V2\Support\ActivityOutcomeRecorder;
26+
use Workflow\V2\Support\FailureFactory;
27+
use Workflow\V2\Support\FailureSnapshots;
2628

2729
/**
2830
* TD-089 regression: activity exception rows must be encoded with the
@@ -186,6 +188,73 @@ public function testCompletionHistoryStampsWorkerResultCodec(): void
186188
);
187189
}
188190

191+
public function testFailureDetailsEnvelopePreservesWorkerCodecInTypedHistory(): void
192+
{
193+
config()->set('workflows.serializer', 'avro');
194+
195+
[$run, $execution, $task, $attempt] = $this->scaffoldLeasedAttempt(
196+
pinnedCodec: 'json',
197+
maxAttempts: 2,
198+
instanceId: 'td090-failure-details-json',
199+
);
200+
201+
$detailsBlob = Serializer::serializeWithCodec('json', ['retry_after' => 30]);
202+
203+
$outcome = ActivityOutcomeRecorder::record(
204+
taskId: $task->id,
205+
attemptId: $attempt->id,
206+
attemptCount: 1,
207+
result: null,
208+
throwable: FailureFactory::restore([
209+
'class' => RuntimeException::class,
210+
'type' => 'TimeoutException',
211+
'message' => 'external timeout',
212+
'non_retryable' => true,
213+
'details' => $detailsBlob,
214+
]),
215+
maxAttempts: 2,
216+
backoffSeconds: 0,
217+
codec: 'json',
218+
);
219+
220+
$this->assertTrue($outcome['recorded']);
221+
222+
$execution->refresh();
223+
$decodedException = Serializer::unserializeWithCodec('json', (string) $execution->exception);
224+
225+
$this->assertSame($detailsBlob, $decodedException['details'] ?? null);
226+
$this->assertSame('json', $decodedException['details_payload_codec'] ?? null);
227+
$this->assertTrue($decodedException['non_retryable'] ?? false);
228+
229+
/** @var WorkflowHistoryEvent $failed */
230+
$failed = WorkflowHistoryEvent::query()
231+
->where('workflow_run_id', $run->id)
232+
->where('event_type', HistoryEventType::ActivityFailed->value)
233+
->firstOrFail();
234+
235+
$this->assertSame(RuntimeException::class, $failed->payload['exception_class'] ?? null);
236+
$this->assertSame('TimeoutException', $failed->payload['exception_type'] ?? null);
237+
$this->assertTrue($failed->payload['non_retryable'] ?? false);
238+
$this->assertSame($detailsBlob, $failed->payload['exception']['details'] ?? null);
239+
$this->assertSame('json', $failed->payload['exception']['details_payload_codec'] ?? null);
240+
$this->assertTrue($failed->payload['exception']['non_retryable'] ?? false);
241+
242+
$this->assertFalse(
243+
WorkflowHistoryEvent::query()
244+
->where('workflow_run_id', $run->id)
245+
->where('event_type', HistoryEventType::ActivityRetryScheduled->value)
246+
->exists(),
247+
);
248+
249+
$snapshot = FailureSnapshots::forRun($run->fresh(['historyEvents', 'failures']))[0] ?? null;
250+
251+
$this->assertIsArray($snapshot);
252+
$this->assertTrue($snapshot['non_retryable'] ?? false);
253+
$this->assertSame($detailsBlob, $snapshot['exception_payload']['details'] ?? null);
254+
$this->assertSame('json', $snapshot['exception_payload']['details_payload_codec'] ?? null);
255+
$this->assertTrue($snapshot['exception_payload']['non_retryable'] ?? false);
256+
}
257+
189258
/**
190259
* @return array{0: WorkflowRun, 1: ActivityExecution, 2: WorkflowTask, 3: ActivityAttempt}
191260
*/

0 commit comments

Comments
 (0)