Skip to content

Commit 9d7f7ac

Browse files
#331: decode replayed signal values with the run's pinned payload codec
WorkflowExecutor::signalValue() read the serialized signal payload from the history event and decoded it with the codec-blind Serializer::unserialize(). For runs pinned to Avro (or any codec other than the deployment default), this either returned a base64 blob where the workflow expected the decoded value, or raised a RuntimeException at resume time. Signal payloads are already written with $run->payload_codec on apply (line 1862), so the asymmetry was purely on read. - signalValue() now accepts an optional $run and delegates to the existing unserializePayloadWithRun() helper, which reads $run->payload_codec and falls back to the codec-blind sniffer when the column is empty (legacy history rows written before payload_codec was populated). - Both replay call sites (applied signal, pending signal command) thread the current $run through so the pinned codec is used. - Regression test exercises the private method via reflection with Avro, JSON, null-codec fallback, and missing-payload paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 325a7e3 commit 9d7f7ac

2 files changed

Lines changed: 96 additions & 4 deletions

File tree

src/V2/Support/WorkflowExecutor.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -621,7 +621,7 @@ public function run(WorkflowRun $run, WorkflowTask $task): ?WorkflowTask
621621
if ($signalEvent !== null) {
622622
try {
623623
$this->syncWorkflowCursor($workflow, $sequence + 1);
624-
$current = $workflowExecution->send($this->signalValue($signalEvent));
624+
$current = $workflowExecution->send($this->signalValue($signalEvent, $run));
625625
} catch (Throwable $throwable) {
626626
$this->failRun($run, $task, $throwable, 'workflow_run', $run->id);
627627

@@ -688,7 +688,7 @@ public function run(WorkflowRun $run, WorkflowTask $task): ?WorkflowTask
688688

689689
try {
690690
$this->syncWorkflowCursor($workflow, $sequence + 1);
691-
$current = $workflowExecution->send($this->signalValue($signalEvent));
691+
$current = $workflowExecution->send($this->signalValue($signalEvent, $run));
692692
} catch (Throwable $throwable) {
693693
$this->failRun($run, $task, $throwable, 'workflow_run', $run->id);
694694

@@ -1943,15 +1943,15 @@ private static function isInternalTimeoutTimerKind(mixed $value): bool
19431943
return in_array($value, ['condition_timeout', 'signal_timeout'], true);
19441944
}
19451945

1946-
private function signalValue(WorkflowHistoryEvent $event): mixed
1946+
private function signalValue(WorkflowHistoryEvent $event, ?WorkflowRun $run = null): mixed
19471947
{
19481948
$serialized = $event->payload['value'] ?? null;
19491949

19501950
if (! is_string($serialized)) {
19511951
return null;
19521952
}
19531953

1954-
return Serializer::unserialize($serialized);
1954+
return $this->unserializePayloadWithRun($serialized, $run);
19551955
}
19561956

19571957
private function signalPayloadValue(WorkflowCommand $command): mixed
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Unit\V2;
6+
7+
use Orchestra\Testbench\TestCase;
8+
use ReflectionMethod;
9+
use Workflow\Serializers\Serializer;
10+
use Workflow\V2\Models\WorkflowHistoryEvent;
11+
use Workflow\V2\Models\WorkflowRun;
12+
use Workflow\V2\Support\WorkflowExecutor;
13+
14+
/**
15+
* #331 regression: signalValue() must decode the serialized signal payload
16+
* using the run's pinned payload_codec. Previously it called the codec-blind
17+
* Serializer::unserialize(), which would silently mis-decode an Avro-encoded
18+
* signal as JSON (yielding a base64 blob or a RuntimeException) on Avro-pinned
19+
* runs. The fix is to thread the run through signalValue() and delegate to
20+
* unserializePayloadWithRun().
21+
*/
22+
final class SignalValueCodecTest extends TestCase
23+
{
24+
public function testSignalValueDecodesAvroEncodedPayloadWithRunCodec(): void
25+
{
26+
$value = ['approved' => true, 'source' => 'waterline'];
27+
28+
$event = new WorkflowHistoryEvent();
29+
$event->payload = [
30+
'value' => Serializer::serializeWithCodec('avro', $value),
31+
];
32+
33+
$run = new WorkflowRun();
34+
$run->payload_codec = 'avro';
35+
36+
$this->assertSame($value, $this->invokeSignalValue($event, $run));
37+
}
38+
39+
public function testSignalValueDecodesJsonEncodedPayloadWithRunCodec(): void
40+
{
41+
$value = ['count' => 3];
42+
43+
$event = new WorkflowHistoryEvent();
44+
$event->payload = [
45+
'value' => Serializer::serializeWithCodec('json', $value),
46+
];
47+
48+
$run = new WorkflowRun();
49+
$run->payload_codec = 'json';
50+
51+
$this->assertSame($value, $this->invokeSignalValue($event, $run));
52+
}
53+
54+
public function testSignalValueFallsBackToCodecBlindWhenRunCodecUnavailable(): void
55+
{
56+
$value = ['legacy' => 'payload'];
57+
58+
$event = new WorkflowHistoryEvent();
59+
$event->payload = [
60+
'value' => Serializer::serializeWithCodec('json', $value),
61+
];
62+
63+
// Legacy rows written before payload_codec was populated must still
64+
// round-trip via the codec-blind sniffer path.
65+
$this->assertSame($value, $this->invokeSignalValue($event, null));
66+
}
67+
68+
public function testSignalValueReturnsNullForMissingSerializedValue(): void
69+
{
70+
$event = new WorkflowHistoryEvent();
71+
$event->payload = [];
72+
73+
$run = new WorkflowRun();
74+
$run->payload_codec = 'avro';
75+
76+
$this->assertNull($this->invokeSignalValue($event, $run));
77+
}
78+
79+
private function invokeSignalValue(WorkflowHistoryEvent $event, ?WorkflowRun $run): mixed
80+
{
81+
$executor = new WorkflowExecutor();
82+
$method = new ReflectionMethod($executor, 'signalValue');
83+
$method->setAccessible(true);
84+
85+
return $method->invoke($executor, $event, $run);
86+
}
87+
88+
protected function getPackageProviders($app)
89+
{
90+
return [\Workflow\Providers\WorkflowServiceProvider::class];
91+
}
92+
}

0 commit comments

Comments
 (0)