diff --git a/psalm-baseline.xml b/psalm-baseline.xml
index 511fbe21a..4d1d82d70 100644
--- a/psalm-baseline.xml
+++ b/psalm-baseline.xml
@@ -89,6 +89,11 @@
+
+
+ getStartTime()]]>
+
+
namespace]]>
@@ -106,8 +111,15 @@
namespace]]>
- namespace]]>
+
+ getSchedules()]]>
+
+
+
+
+ getEvents()]]>
+
@@ -141,6 +153,9 @@
+
+ getExecutions()]]>
+
@@ -153,12 +168,14 @@
typedSearchAttributes]]>
+
+
-
+
@@ -245,6 +262,12 @@
+
+
+ getPayloads()]]>
+ getPayloads()]]>
+
+
@@ -255,10 +278,18 @@
+
+
+ getDetails()]]>
+
+
+ getDetails()]]>
+
+
-
-
-
+
+ status->getDetails()]]>
+
@@ -385,6 +416,10 @@
+
+ getStatuses()]]>
+ getResponses()]]>
+
@@ -422,6 +457,9 @@
+
+ getEvents()]]>
+
@@ -566,18 +604,6 @@
action]]>
-
- header?->setDataConverter($this->converter)]]>
- input?->setDataConverter($this->converter)]]>
- memo?->setDataConverter($this->converter)]]>
- searchAttributes?->setDataConverter($this->converter)]]>
-
-
- header]]>
- input]]>
- memo]]>
- searchAttributes]]>
-
@@ -603,7 +629,6 @@
getStateTransitionCount()]]>
-
@@ -616,6 +641,9 @@
+
+ getPoints()]]>
+
@@ -698,6 +726,9 @@
getDetails()]]>
getSummary()]]>
+
+ getPayloads()]]>
+
@@ -921,10 +952,6 @@
-
-
-
-
@@ -971,16 +998,16 @@
execution->promise()->then(
function (WorkflowExecution $execution) use ($name, $args) {
- $request = new SignalExternalWorkflow(
- $this->getOptions()->namespace,
- $execution->getID(),
- null,
- $name,
- EncodedValues::fromValues($args),
- true,
+ return $this->request(
+ new SignalExternalWorkflow(
+ $this->resolveNamespace(),
+ $execution->getID(),
+ null,
+ $name,
+ EncodedValues::fromValues($args),
+ true,
+ ),
);
-
- return $this->request($request);
},
)]]>
start(...$args)->then(fn() => $this->getResult($returnType))]]>
@@ -1049,6 +1076,7 @@
+ childWorkflowSequence = &$context->childWorkflowSequence]]>
currentDetails = &$context->currentDetails]]>
trace = &$context->trace]]>
@@ -1096,6 +1124,7 @@
awaits = &$this->awaits]]>
+ childWorkflowSequence = &$this->childWorkflowSequence]]>
trace = &$this->trace]]>
@@ -1182,12 +1211,6 @@
-
- serializeToString()]]>
-
-
-
-
getCode()]]>
getCode()]]>
@@ -1195,6 +1218,9 @@
+
+ getMessages()]]>
+
@@ -1421,6 +1447,12 @@
+
+
+
+
+ getEvents()]]>
+
@@ -1482,6 +1514,9 @@
+
+
+
getHeader()]]>
getPayloads()]]>
@@ -1489,17 +1524,10 @@
+ getPayloads()]]>
+ getMessages()]]>
-
-
- getSeconds() + \round($eventTime->getNanos() / 1_000_000_000, 6)]]>
-
-
-
- getSeconds()]]>
-
-
diff --git a/src/Client/ActivityCompletionClientInterface.php b/src/Client/ActivityCompletionClientInterface.php
index 2e125db1f..7bdc77b70 100644
--- a/src/Client/ActivityCompletionClientInterface.php
+++ b/src/Client/ActivityCompletionClientInterface.php
@@ -11,6 +11,8 @@
namespace Temporal\Client;
+use Temporal\DataConverter\ActivitySerializationContext;
+
/**
* Used to complete asynchronously activities that called {@link
* ActivityContext->doNotCompleteOnReturn()}.
@@ -19,6 +21,8 @@
*/
interface ActivityCompletionClientInterface
{
+ public function withContext(ActivitySerializationContext $context): self;
+
/**
* @param mixed $result
*/
diff --git a/src/Client/Schedule/ScheduleHandle.php b/src/Client/Schedule/ScheduleHandle.php
index 6f9151b58..87eaf98b4 100644
--- a/src/Client/Schedule/ScheduleHandle.php
+++ b/src/Client/Schedule/ScheduleHandle.php
@@ -17,12 +17,14 @@
use Temporal\Client\ClientOptions;
use Temporal\Client\Common\ClientContextTrait;
use Temporal\Client\GRPC\ServiceClientInterface;
+use Temporal\Client\Schedule\Action\StartWorkflowAction;
use Temporal\Client\Schedule\Info\ScheduleDescription;
use Temporal\Client\Schedule\Policy\ScheduleOverlapPolicy;
use Temporal\Client\Schedule\Update\ScheduleUpdate;
use Temporal\Client\Schedule\Update\ScheduleUpdateInput;
use Temporal\Common\Uuid;
use Temporal\DataConverter\DataConverterInterface;
+use Temporal\DataConverter\WorkflowSerializationContext;
use Temporal\Exception\InvalidArgumentException;
use Temporal\Internal\Mapper\ScheduleMapper;
use Temporal\Internal\Marshaller\MarshallerInterface;
@@ -119,7 +121,7 @@ public function update(
}
$mapper = new ScheduleMapper($this->converter, $this->marshaller);
- $scheduleMessage = $mapper->toMessage($schedule);
+ $scheduleMessage = $mapper->toMessage($schedule, $this->namespace);
$request->setSchedule($scheduleMessage);
@@ -139,7 +141,18 @@ public function describe(): ScheduleDescription
$values = $this->protoConverter->convert($response);
$dto = new ScheduleDescription();
- return $this->marshaller->unmarshal($values, $dto);
+ $description = $this->marshaller->unmarshal($values, $dto);
+
+ $action = $description->schedule->action ?? null;
+ if ($action instanceof StartWorkflowAction && $action->workflowId !== '') {
+ $context = new WorkflowSerializationContext($this->namespace, $action->workflowId);
+ $action->input->setDataConverter($this->converter);
+ $action->input->setSerializationContext($context);
+ $action->memo->setDataConverter($this->converter);
+ $action->memo->setSerializationContext($context);
+ }
+
+ return $description;
}
/**
diff --git a/src/Client/ScheduleClient.php b/src/Client/ScheduleClient.php
index d7a0ca545..218574700 100644
--- a/src/Client/ScheduleClient.php
+++ b/src/Client/ScheduleClient.php
@@ -116,10 +116,12 @@ public function createSchedule(
$options->memo->setDataConverter($this->converter);
$options->searchAttributes->setDataConverter($this->converter);
+ $namespace = $options->namespace ?? $this->clientOptions->namespace;
+
$request = new CreateScheduleRequest();
$request
->setRequestId(Uuid::v4())
- ->setNamespace($options->namespace ?? $this->clientOptions->namespace)
+ ->setNamespace($namespace)
->setScheduleId($scheduleId)
->setIdentity($this->clientOptions->identity);
@@ -144,7 +146,7 @@ public function createSchedule(
}
$mapper = new ScheduleMapper($this->converter, $this->marshaller);
- $scheduleMessage = $mapper->toMessage($schedule);
+ $scheduleMessage = $mapper->toMessage($schedule, $namespace);
$request
->setSchedule($scheduleMessage)
@@ -161,7 +163,7 @@ public function createSchedule(
$this->converter,
$this->marshaller,
$this->protoConverter,
- $options->namespace ?? $this->clientOptions->namespace,
+ $namespace,
$scheduleId,
);
}
diff --git a/src/Client/Update/UpdateHandle.php b/src/Client/Update/UpdateHandle.php
index f8396d5de..0e9e116f2 100644
--- a/src/Client/Update/UpdateHandle.php
+++ b/src/Client/Update/UpdateHandle.php
@@ -10,6 +10,7 @@
use Temporal\DataConverter\DataConverterInterface;
use Temporal\DataConverter\EncodedValues;
use Temporal\DataConverter\ValuesInterface;
+use Temporal\DataConverter\WorkflowSerializationContext;
use Temporal\Exception\Client\CanceledException;
use Temporal\Exception\Client\TimeoutException;
use Temporal\Exception\Client\WorkflowUpdateException;
@@ -144,17 +145,22 @@ private function fetchResult(int|float|null $timeout = null): void
*/
} while ($result === null);
+ $context = new WorkflowSerializationContext(
+ $this->clientOptions->namespace,
+ $this->getExecution()->getID(),
+ );
+
// Accepted with result
$success = $result->getSuccess();
if ($success !== null) {
- $this->result = EncodedValues::fromPayloads($success, $this->converter);
+ $this->result = EncodedValues::fromPayloads($success, $this->converter)->withSerializationContext($context);
return;
}
// Accepted with failure
$failure = $result->getFailure();
\assert($failure !== null);
- $e = FailureConverter::mapFailureToException($failure, $this->converter);
+ $e = FailureConverter::mapFailureToException($failure, $this->converter, $context);
$this->result = new WorkflowUpdateException(
$e->getMessage(),
diff --git a/src/Client/WorkflowOptions.php b/src/Client/WorkflowOptions.php
index 5f483e77e..ce1fe36eb 100644
--- a/src/Client/WorkflowOptions.php
+++ b/src/Client/WorkflowOptions.php
@@ -26,6 +26,8 @@
use Temporal\Common\Versioning\VersioningOverride;
use Temporal\Common\WorkflowIdConflictPolicy;
use Temporal\DataConverter\DataConverterInterface;
+use Temporal\DataConverter\EncodedCollection;
+use Temporal\DataConverter\SerializationContext;
use Temporal\Internal\Marshaller\Meta\Marshal;
use Temporal\Internal\Marshaller\Type\ArrayType;
use Temporal\Internal\Marshaller\Type\CronType;
@@ -543,20 +545,17 @@ public function withVersioningOverride(?VersioningOverride $override): self
/**
* @internal
*/
- public function toMemo(DataConverterInterface $converter): ?Memo
+ public function toMemo(DataConverterInterface $converter, ?SerializationContext $context = null): ?Memo
{
if ($this->memo === null || $this->memo === []) {
return null;
}
- $fields = [];
-
- foreach ($this->memo as $key => $value) {
- $fields[$key] = $converter->toPayload($value);
- }
+ $collection = EncodedCollection::fromValues($this->memo, $converter);
+ $collection->setSerializationContext($context);
$memo = new Memo();
- $memo->setFields($fields);
+ $memo->setFields($collection->toPayloadArray());
return $memo;
}
diff --git a/src/DataConverter/ActivitySerializationContext.php b/src/DataConverter/ActivitySerializationContext.php
new file mode 100644
index 000000000..cab684aa2
--- /dev/null
+++ b/src/DataConverter/ActivitySerializationContext.php
@@ -0,0 +1,57 @@
+workflowExecution;
+ if ($execution === null) {
+ throw new \LogicException('Activity info is missing the workflow execution.');
+ }
+
+ if ($info->workflowType === null) {
+ throw new \LogicException('Activity info is missing the workflow type.');
+ }
+
+ return new self(
+ namespace: $info->workflowNamespace,
+ activityType: $info->type->name,
+ taskQueue: $info->taskQueue,
+ workflowId: $execution->getID(),
+ workflowType: $info->workflowType->name,
+ isLocal: $isLocal,
+ );
+ }
+
+ public function getNamespace(): string
+ {
+ return $this->namespace;
+ }
+
+ public function getWorkflowId(): ?string
+ {
+ return $this->workflowId;
+ }
+}
diff --git a/src/DataConverter/DataConverter.php b/src/DataConverter/DataConverter.php
index 129876c8b..cae574d47 100644
--- a/src/DataConverter/DataConverter.php
+++ b/src/DataConverter/DataConverter.php
@@ -17,13 +17,15 @@
/**
* @psalm-import-type TType from Type
*/
-final class DataConverter implements DataConverterInterface
+final class DataConverter implements DataConverterInterface, SerializationContextAwareInterface
{
/**
* @var array
*/
private array $converters = [];
+ private ?SerializationContext $serializationContext = null;
+
public function __construct(PayloadConverterInterface ...$converter)
{
foreach ($converter as $c) {
@@ -42,6 +44,29 @@ public static function createDefault(): DataConverterInterface
);
}
+ public function getSerializationContext(): ?SerializationContext
+ {
+ return $this->serializationContext;
+ }
+
+ public function withSerializationContext(?SerializationContext $context): static
+ {
+ if ($context === $this->serializationContext) {
+ return $this;
+ }
+
+ $clone = clone $this;
+ $clone->serializationContext = $context;
+
+ foreach ($this->converters as $encoding => $converter) {
+ if ($converter instanceof SerializationContextAwareInterface) {
+ $clone->converters[$encoding] = $converter->withSerializationContext($context);
+ }
+ }
+
+ return $clone;
+ }
+
/**
* @param TType $type
*/
diff --git a/src/DataConverter/DataConverterAwareTrait.php b/src/DataConverter/DataConverterAwareTrait.php
new file mode 100644
index 000000000..323eb4f13
--- /dev/null
+++ b/src/DataConverter/DataConverterAwareTrait.php
@@ -0,0 +1,68 @@
+converter = $converter;
+ $this->effectiveConverter = null;
+ }
+
+ public function getDataConverter(): ?DataConverterInterface
+ {
+ return $this->converter;
+ }
+
+ public function getSerializationContext(): ?SerializationContext
+ {
+ return $this->serializationContext;
+ }
+
+ public function setSerializationContext(?SerializationContext $context): void
+ {
+ $this->serializationContext = $context;
+ $this->effectiveConverter = null;
+ }
+
+ public function withSerializationContext(?SerializationContext $context): static
+ {
+ $clone = clone $this;
+ $clone->serializationContext = $context;
+ $clone->effectiveConverter = null;
+
+ return $clone;
+ }
+
+ private function converter(): DataConverterInterface
+ {
+ if ($this->converter === null) {
+ throw new \LogicException('DataConverter is not set.');
+ }
+
+ if ($this->effectiveConverter !== null) {
+ return $this->effectiveConverter;
+ }
+
+ $converter = $this->converter;
+ if ($this->serializationContext !== null && $converter instanceof SerializationContextAwareInterface) {
+ $converter = $converter->withSerializationContext($this->serializationContext);
+ }
+
+ return $this->effectiveConverter = $converter;
+ }
+}
diff --git a/src/DataConverter/EncodedCollection.php b/src/DataConverter/EncodedCollection.php
index dd6fff975..8f3d86479 100644
--- a/src/DataConverter/EncodedCollection.php
+++ b/src/DataConverter/EncodedCollection.php
@@ -25,7 +25,7 @@
*/
class EncodedCollection implements \IteratorAggregate, \Countable
{
- private ?DataConverterInterface $converter = null;
+ use DataConverterAwareTrait;
/**
* @psalm-var TPayloadsCollection|null
@@ -102,11 +102,7 @@ public function getValue(int|string $name, mixed $type = null): mixed
return null;
}
- if ($this->converter === null) {
- throw new \LogicException('DataConverter is not set.');
- }
-
- return $this->converter->fromPayload($this->payloads[$name], $type);
+ return $this->converter()->fromPayload($this->payloads[$name], $type);
}
public function getValues(): array
@@ -117,10 +113,9 @@ public function getValues(): array
return $result;
}
- $this->converter === null and throw new \LogicException('DataConverter is not set.');
-
+ $converter = $this->converter();
foreach ($this->payloads as $key => $payload) {
- $result[$key] = $this->converter->fromPayload($payload, null);
+ $result[$key] = $converter->fromPayload($payload, null);
}
return $result;
@@ -130,10 +125,9 @@ public function getIterator(): \Traversable
{
yield from $this->values;
if ($this->payloads !== null && $this->payloads->count() > 0) {
- $this->converter === null and throw new \LogicException('DataConverter is not set.');
-
+ $converter = $this->converter();
foreach ($this->payloads as $key => $payload) {
- yield $key => $this->converter->fromPayload($payload, null);
+ yield $key => $converter->fromPayload($payload, null);
}
}
}
@@ -151,10 +145,9 @@ public function toPayloadArray(): array
return $data;
}
- $this->converter === null and throw new \LogicException('DataConverter is not set.');
-
+ $converter = $this->converter();
foreach ($this->values as $key => $value) {
- $data[$key] = $this->converter->toPayload($value);
+ $data[$key] = $converter->toPayload($value);
}
return $data;
@@ -180,11 +173,6 @@ public function withValue(int|string $name, mixed $value): static
return $clone;
}
- public function setDataConverter(DataConverterInterface $converter): void
- {
- $this->converter = $converter;
- }
-
public function __clone()
{
if ($this->payloads !== null) {
diff --git a/src/DataConverter/EncodedValues.php b/src/DataConverter/EncodedValues.php
index c94ca3219..142233bd7 100644
--- a/src/DataConverter/EncodedValues.php
+++ b/src/DataConverter/EncodedValues.php
@@ -30,6 +30,8 @@
*/
class EncodedValues implements ValuesInterface
{
+ use DataConverterAwareTrait;
+
/**
* @var TPayloadsCollection|null
*/
@@ -40,8 +42,6 @@ class EncodedValues implements ValuesInterface
*/
protected ?array $values = null;
- private ?DataConverterInterface $converter = null;
-
/**
* Can not be constructed directly.
*/
@@ -133,11 +133,12 @@ public function getValue(int|string $index, $type = null): mixed
return null;
}
- $count > $index or throw new \OutOfBoundsException("Index {$index} is out of bounds.");
- $this->converter === null and throw new \LogicException('DataConverter is not set.');
+ if ($count <= $index) {
+ throw new \OutOfBoundsException("Index {$index} is out of bounds.");
+ }
\assert($this->payloads !== null);
- return $this->converter->fromPayload(
+ return $this->converter()->fromPayload(
$this->payloads[$index],
$type,
);
@@ -151,20 +152,15 @@ public function getValues(): array
return $result;
}
- $this->converter === null and throw new \LogicException('DataConverter is not set.');
+ $converter = $this->converter();
foreach ($this->payloads as $key => $payload) {
- $result[$key] = $this->converter->fromPayload($payload, null);
+ $result[$key] = $converter->fromPayload($payload, null);
}
return $result;
}
- public function setDataConverter(DataConverterInterface $converter): void
- {
- $this->converter = $converter;
- }
-
/**
* @return int<0, max>
*/
@@ -212,19 +208,12 @@ private function toProtoCollection(): array
}
if ($this->values !== null) {
+ $converter = $this->converter();
foreach ($this->values as $key => $value) {
- $data[$key] = $this->valueToPayload($value);
+ $data[$key] = $converter->toPayload($value);
}
}
return $data;
}
-
- private function valueToPayload(mixed $value): Payload
- {
- if ($this->converter === null) {
- throw new \LogicException('DataConverter is not set');
- }
- return $this->converter->toPayload($value);
- }
}
diff --git a/src/DataConverter/HasWorkflowSerializationContext.php b/src/DataConverter/HasWorkflowSerializationContext.php
new file mode 100644
index 000000000..69305203c
--- /dev/null
+++ b/src/DataConverter/HasWorkflowSerializationContext.php
@@ -0,0 +1,19 @@
+namespace, $info->execution->getID());
+ }
+
+ public function getNamespace(): string
+ {
+ return $this->namespace;
+ }
+
+ public function getWorkflowId(): string
+ {
+ return $this->workflowId;
+ }
+}
diff --git a/src/Exception/Failure/ApplicationFailure.php b/src/Exception/Failure/ApplicationFailure.php
index 7ca0b98bf..242a7f833 100644
--- a/src/Exception/Failure/ApplicationFailure.php
+++ b/src/Exception/Failure/ApplicationFailure.php
@@ -13,6 +13,7 @@
use Temporal\DataConverter\DataConverterInterface;
use Temporal\DataConverter\EncodedValues;
+use Temporal\DataConverter\SerializationContext;
use Temporal\DataConverter\ValuesInterface;
/**
@@ -98,7 +99,15 @@ public function setNonRetryable(bool $nonRetryable): void
public function setDataConverter(DataConverterInterface $converter): void
{
+ parent::setDataConverter($converter);
$this->details->setDataConverter($converter);
+ $this->details->setSerializationContext($this->getSerializationContext());
+ }
+
+ public function setSerializationContext(?SerializationContext $context): void
+ {
+ parent::setSerializationContext($context);
+ $this->details->setSerializationContext($context);
}
public function setNextRetryDelay(?\DateInterval $nextRetryDelay): void
diff --git a/src/Exception/Failure/CanceledFailure.php b/src/Exception/Failure/CanceledFailure.php
index 6a983d940..d42e248da 100644
--- a/src/Exception/Failure/CanceledFailure.php
+++ b/src/Exception/Failure/CanceledFailure.php
@@ -13,6 +13,7 @@
use Temporal\DataConverter\DataConverterInterface;
use Temporal\DataConverter\EncodedValues;
+use Temporal\DataConverter\SerializationContext;
use Temporal\DataConverter\ValuesInterface;
class CanceledFailure extends TemporalFailure
@@ -32,6 +33,14 @@ public function getDetails(): ValuesInterface
public function setDataConverter(DataConverterInterface $converter): void
{
+ parent::setDataConverter($converter);
$this->details->setDataConverter($converter);
+ $this->details->setSerializationContext($this->getSerializationContext());
+ }
+
+ public function setSerializationContext(?SerializationContext $context): void
+ {
+ parent::setSerializationContext($context);
+ $this->details->setSerializationContext($context);
}
}
diff --git a/src/Exception/Failure/FailureConverter.php b/src/Exception/Failure/FailureConverter.php
index 646cef035..4687ba579 100644
--- a/src/Exception/Failure/FailureConverter.php
+++ b/src/Exception/Failure/FailureConverter.php
@@ -22,30 +22,53 @@
use Temporal\Api\Failure\V1\ServerFailureInfo;
use Temporal\Api\Failure\V1\TerminatedFailureInfo;
use Temporal\Api\Failure\V1\TimeoutFailureInfo;
+use Temporal\Api\Common\V1\Payload;
+use Temporal\Api\Common\V1\Payloads;
use Temporal\DataConverter\DataConverterInterface;
use Temporal\DataConverter\EncodedValues;
+use Temporal\DataConverter\SerializationContext;
+use Temporal\DataConverter\Type;
use Temporal\Exception\Client\ActivityCanceledException;
use Temporal\Internal\Support\DateInterval;
+use Temporal\Worker\FeatureFlags;
final class FailureConverter
{
- public static function mapFailureToException(Failure $failure, DataConverterInterface $converter): TemporalFailure
- {
- $e = self::createFailureException($failure, $converter);
+ private const ENCODED_FAILURE_MESSAGE = 'Encoded failure';
+
+ public static function mapFailureToException(
+ Failure $failure,
+ DataConverterInterface $converter,
+ ?SerializationContext $context = null,
+ ): TemporalFailure {
+ $decoded = self::decodeAttributes($failure, $converter, $context);
+
+ $e = self::createFailureException($decoded, $converter);
$e->setFailure($failure);
- if ($failure->getStackTrace() !== '') {
- $e->setOriginalStackTrace($failure->getStackTrace());
+ if ($decoded->getStackTrace() !== '') {
+ $e->setOriginalStackTrace($decoded->getStackTrace());
+ }
+
+ if ($context !== null) {
+ $e->setSerializationContext($context);
}
return $e;
}
- public static function mapExceptionToFailure(\Throwable $e, DataConverterInterface $converter): Failure
- {
+ public static function mapExceptionToFailure(
+ \Throwable $e,
+ DataConverterInterface $converter,
+ ?SerializationContext $context = null,
+ ): Failure {
$failure = new Failure();
if ($e instanceof TemporalFailure) {
+ if ($context !== null && $e->getSerializationContext() === null) {
+ $e->setSerializationContext($context);
+ }
+
$e->setDataConverter($converter);
if ($e->getFailure() !== null) {
@@ -62,7 +85,7 @@ public static function mapExceptionToFailure(\Throwable $e, DataConverterInterfa
$failure->setSource('PHP_SDK')->setStackTrace(self::generateStackTraceString($e));
if ($e->getPrevious() !== null) {
- $failure->setCause(self::mapExceptionToFailure($e->getPrevious(), $converter));
+ $failure->setCause(self::mapExceptionToFailure($e->getPrevious(), $converter, $context));
}
switch (true) {
@@ -155,9 +178,73 @@ public static function mapExceptionToFailure(\Throwable $e, DataConverterInterfa
$failure->setApplicationFailureInfo($info);
}
+ if (FeatureFlags::$encodeFailureAttributes) {
+ self::encodeAttributes(
+ $failure,
+ $converter,
+ $context ?? ($e instanceof TemporalFailure ? $e->getSerializationContext() : null),
+ );
+ }
+
return $failure;
}
+ private static function encodeAttributes(
+ Failure $failure,
+ DataConverterInterface $converter,
+ ?SerializationContext $context,
+ ): void {
+ $values = EncodedValues::fromValues([[
+ 'message' => $failure->getMessage(),
+ 'stack_trace' => $failure->getStackTrace(),
+ ]], $converter);
+ $values->setSerializationContext($context);
+
+ $encoded = null;
+ /** @psalm-suppress TooManyTemplateParams */
+ foreach ($values->toPayloads()->getPayloads() as $payload) {
+ $encoded = $payload;
+ break;
+ }
+
+ \assert($encoded instanceof Payload);
+
+ $failure
+ ->setEncodedAttributes($encoded)
+ ->setMessage(self::ENCODED_FAILURE_MESSAGE)
+ ->setStackTrace('');
+ }
+
+ private static function decodeAttributes(
+ Failure $failure,
+ DataConverterInterface $converter,
+ ?SerializationContext $context,
+ ): Failure {
+ $payload = $failure->getEncodedAttributes();
+ if ($payload === null) {
+ return $failure;
+ }
+
+ $values = EncodedValues::fromPayloads(new Payloads(['payloads' => [$payload]]), $converter);
+ $values->setSerializationContext($context);
+
+ try {
+ $attributes = $values->getValue(0, Type::TYPE_ARRAY);
+ } catch (\Throwable) {
+ return $failure;
+ }
+
+ if (!\is_array($attributes)) {
+ return $failure;
+ }
+
+ $decoded = clone $failure;
+ isset($attributes['message']) and $decoded->setMessage((string) $attributes['message']);
+ isset($attributes['stack_trace']) and $decoded->setStackTrace((string) $attributes['stack_trace']);
+
+ return $decoded;
+ }
+
private static function createFailureException(Failure $failure, DataConverterInterface $converter): TemporalFailure
{
$previous = null;
diff --git a/src/Exception/Failure/TemporalFailure.php b/src/Exception/Failure/TemporalFailure.php
index 8fdee6c77..6999a6112 100644
--- a/src/Exception/Failure/TemporalFailure.php
+++ b/src/Exception/Failure/TemporalFailure.php
@@ -15,6 +15,7 @@
use Temporal\Api\Enums\V1\TimeoutType;
use Temporal\Api\Failure\V1\Failure;
use Temporal\DataConverter\DataConverterInterface;
+use Temporal\DataConverter\SerializationContext;
use Temporal\Exception\TemporalException;
/**
@@ -34,6 +35,7 @@ class TemporalFailure extends TemporalException implements \Stringable
private ?Failure $failure = null;
private string $originalMessage;
private ?string $originalStackTrace = null;
+ private ?SerializationContext $serializationContext = null;
public function __construct(string $message, ?string $originalMessage = null, ?\Throwable $previous = null)
{
@@ -79,7 +81,35 @@ public function getOriginalStackTrace(): ?string
public function setDataConverter(DataConverterInterface $converter): void
{
- // typically handled by children
+ $previous = $this->getPrevious();
+ while ($previous !== null) {
+ if ($previous instanceof self) {
+ $previous->setDataConverter($converter);
+ return;
+ }
+
+ $previous = $previous->getPrevious();
+ }
+ }
+
+ public function getSerializationContext(): ?SerializationContext
+ {
+ return $this->serializationContext;
+ }
+
+ public function setSerializationContext(?SerializationContext $context): void
+ {
+ $this->serializationContext = $context;
+
+ $previous = $this->getPrevious();
+ while ($previous !== null) {
+ if ($previous instanceof self) {
+ $previous->setSerializationContext($context);
+ return;
+ }
+
+ $previous = $previous->getPrevious();
+ }
}
public function __toString(): string
diff --git a/src/Exception/Failure/TimeoutFailure.php b/src/Exception/Failure/TimeoutFailure.php
index 13d24fddc..fac52c1ef 100644
--- a/src/Exception/Failure/TimeoutFailure.php
+++ b/src/Exception/Failure/TimeoutFailure.php
@@ -12,6 +12,7 @@
namespace Temporal\Exception\Failure;
use Temporal\DataConverter\DataConverterInterface;
+use Temporal\DataConverter\SerializationContext;
use Temporal\DataConverter\ValuesInterface;
class TimeoutFailure extends TemporalFailure
@@ -47,6 +48,14 @@ public function getLastHeartbeatDetails(): ValuesInterface
public function setDataConverter(DataConverterInterface $converter): void
{
+ parent::setDataConverter($converter);
$this->lastHeartbeatDetails->setDataConverter($converter);
+ $this->lastHeartbeatDetails->setSerializationContext($this->getSerializationContext());
+ }
+
+ public function setSerializationContext(?SerializationContext $context): void
+ {
+ parent::setSerializationContext($context);
+ $this->lastHeartbeatDetails->setSerializationContext($context);
}
}
diff --git a/src/Internal/Activity/ActivityContext.php b/src/Internal/Activity/ActivityContext.php
index 69b9e61f5..3b26fc0c3 100644
--- a/src/Internal/Activity/ActivityContext.php
+++ b/src/Internal/Activity/ActivityContext.php
@@ -14,6 +14,7 @@
use Temporal\Activity\ActivityCancellationDetails;
use Temporal\Activity\ActivityContextInterface;
use Temporal\Activity\ActivityInfo;
+use Temporal\DataConverter\ActivitySerializationContext;
use Temporal\DataConverter\DataConverterInterface;
use Temporal\DataConverter\EncodedValues;
use Temporal\DataConverter\Type;
@@ -36,13 +37,14 @@ final class ActivityContext implements ActivityContextInterface, HeaderCarrier
private bool $doNotCompleteOnReturn = false;
private ?\WeakReference $instance = null;
private ?ActivityCancellationDetails $cancellationDetails = null;
+ private ?ActivitySerializationContext $serializationContext = null;
public function __construct(
private readonly RPCConnectionInterface $rpc,
- private readonly DataConverterInterface $converter,
+ private DataConverterInterface $converter,
private ValuesInterface $input,
private HeaderInterface $header,
- private readonly ?ValuesInterface $lastHeartbeatDetails = null,
+ private ?ValuesInterface $lastHeartbeatDetails = null,
) {
$this->info = new ActivityInfo();
}
@@ -78,6 +80,30 @@ public function withHeader(HeaderInterface $header): self
return $context;
}
+ public function withLastHeartbeatDetails(?ValuesInterface $lastHeartbeatDetails): self
+ {
+ $context = clone $this;
+ $context->lastHeartbeatDetails = $lastHeartbeatDetails;
+
+ return $context;
+ }
+
+ public function withSerializationContext(?ActivitySerializationContext $context): self
+ {
+ $clone = clone $this;
+ $clone->serializationContext = $context;
+ $clone->input = $this->input->withSerializationContext($context);
+ $clone->lastHeartbeatDetails = $this->lastHeartbeatDetails?->withSerializationContext($context);
+
+ return $clone;
+ }
+
+ public function applySerializationContext(ValuesInterface $values): void
+ {
+ $values->setDataConverter($this->converter);
+ $values->setSerializationContext($this->serializationContext);
+ }
+
public function getDataConverter(): DataConverterInterface
{
return $this->converter;
@@ -115,9 +141,9 @@ public function heartbeat(mixed $details): void
// we use native host process RPC here to avoid excessive GRPC connections and to handle throttling
// on Golang end
- $details = EncodedValues::fromValues([$details], $this->converter)
- ->toPayloads()
- ->serializeToString();
+ $heartbeat = EncodedValues::fromValues([$details], $this->converter);
+ $heartbeat->setSerializationContext($this->serializationContext);
+ $details = $heartbeat->toPayloads()->serializeToString();
try {
$response = $this->rpc->call(
diff --git a/src/Internal/Client/ActivityCompletionClient.php b/src/Internal/Client/ActivityCompletionClient.php
index 55a2cdd35..798c59349 100644
--- a/src/Internal/Client/ActivityCompletionClient.php
+++ b/src/Internal/Client/ActivityCompletionClient.php
@@ -16,6 +16,7 @@
use Temporal\Client\ClientOptions;
use Temporal\Client\GRPC\ServiceClientInterface;
use Temporal\Client\GRPC\StatusCode;
+use Temporal\DataConverter\ActivitySerializationContext;
use Temporal\DataConverter\DataConverterInterface;
use Temporal\DataConverter\EncodedValues;
use Temporal\Exception\Client\ActivityCanceledException;
@@ -29,6 +30,7 @@ final class ActivityCompletionClient implements ActivityCompletionClientInterfac
private ServiceClientInterface $client;
private ClientOptions $clientOptions;
private DataConverterInterface $converter;
+ private ?ActivitySerializationContext $context = null;
public function __construct(
ServiceClientInterface $client,
@@ -40,6 +42,14 @@ public function __construct(
$this->converter = $converter;
}
+ public function withContext(ActivitySerializationContext $context): self
+ {
+ $clone = clone $this;
+ $clone->context = $context;
+
+ return $clone;
+ }
+
public function complete(string $workflowId, ?string $runId, string $activityId, $result = null): void
{
$r = new Proto\RespondActivityTaskCompletedByIdRequest();
@@ -50,7 +60,7 @@ public function complete(string $workflowId, ?string $runId, string $activityId,
->setRunId($runId ?? '')
->setActivityId($activityId);
- $input = EncodedValues::fromValues(\array_slice(\func_get_args(), 3), $this->converter);
+ $input = $this->encode(\array_slice(\func_get_args(), 3));
if (!$input->isEmpty()) {
$r->setResult($input->toPayloads());
}
@@ -75,7 +85,7 @@ public function completeByToken(string $taskToken, $result = null): void
->setNamespace($this->clientOptions->namespace)
->setTaskToken($taskToken);
- $input = EncodedValues::fromValues(\array_slice(\func_get_args(), 1), $this->converter);
+ $input = $this->encode(\array_slice(\func_get_args(), 1));
if (!$input->isEmpty()) {
$r->setResult($input->toPayloads());
}
@@ -103,8 +113,9 @@ public function completeExceptionally(
->setNamespace($this->clientOptions->namespace)
->setWorkflowId($workflowId)
->setRunId($runId ?? '')
- ->setActivityId($activityId)
- ->setFailure(FailureConverter::mapExceptionToFailure($error, $this->converter));
+ ->setActivityId($activityId);
+
+ $r->setFailure(FailureConverter::mapExceptionToFailure($error, $this->converter, $this->context));
try {
$this->client->RespondActivityTaskFailedById($r);
@@ -123,8 +134,9 @@ public function completeExceptionallyByToken(string $taskToken, \Throwable $erro
$r
->setIdentity($this->clientOptions->identity)
->setNamespace($this->clientOptions->namespace)
- ->setTaskToken($taskToken)
- ->setFailure(FailureConverter::mapExceptionToFailure($error, $this->converter));
+ ->setTaskToken($taskToken);
+
+ $r->setFailure(FailureConverter::mapExceptionToFailure($error, $this->converter, $this->context));
try {
$this->client->RespondActivityTaskFailed($r);
@@ -148,7 +160,8 @@ public function reportCancellation(string $workflowId, ?string $runId, string $a
->setActivityId($activityId);
if (\func_num_args() == 4) {
- $r->setDetails(EncodedValues::fromValues([$details], $this->converter)->toPayloads());
+ $input = $this->encode([$details]);
+ $r->setDetails($input->toPayloads());
}
try {
@@ -167,7 +180,8 @@ public function reportCancellationByToken(string $taskToken, $details = null): v
->setTaskToken($taskToken);
if (\func_num_args() == 2) {
- $r->setDetails(EncodedValues::fromValues([$details], $this->converter)->toPayloads());
+ $input = $this->encode([$details]);
+ $r->setDetails($input->toPayloads());
}
try {
@@ -188,7 +202,8 @@ public function recordHeartbeat(string $workflowId, ?string $runId, string $acti
->setActivityId($activityId);
if (\func_num_args() == 4) {
- $r->setDetails(EncodedValues::fromValues([$details], $this->converter)->toPayloads());
+ $input = $this->encode([$details]);
+ $r->setDetails($input->toPayloads());
}
try {
@@ -214,7 +229,8 @@ public function recordHeartbeatByToken(string $taskToken, $details = null): void
->setTaskToken($taskToken);
if (\func_num_args() == 2) {
- $r->setDetails(EncodedValues::fromValues([$details], $this->converter)->toPayloads());
+ $input = $this->encode([$details]);
+ $r->setDetails($input->toPayloads());
}
try {
@@ -230,4 +246,9 @@ public function recordHeartbeatByToken(string $taskToken, $details = null): void
throw ActivityCompletionFailureException::fromPrevious($e);
}
}
+
+ private function encode(array $values): EncodedValues
+ {
+ return EncodedValues::fromValues($values, $this->converter)->withSerializationContext($this->context);
+ }
}
diff --git a/src/Internal/Client/ResponseToResultMapper.php b/src/Internal/Client/ResponseToResultMapper.php
index 1ad891049..67e7cd5ae 100644
--- a/src/Internal/Client/ResponseToResultMapper.php
+++ b/src/Internal/Client/ResponseToResultMapper.php
@@ -7,6 +7,7 @@
use Temporal\Api\Workflowservice\V1\UpdateWorkflowExecutionResponse;
use Temporal\DataConverter\DataConverterInterface;
use Temporal\DataConverter\EncodedValues;
+use Temporal\DataConverter\WorkflowSerializationContext;
use Temporal\Exception\Client\WorkflowUpdateException;
use Temporal\Exception\Failure\FailureConverter;
use Temporal\Interceptor\WorkflowClient\StartUpdateOutput;
@@ -27,6 +28,7 @@ public function mapUpdateWorkflowResponse(
string $updateName,
?string $workflowType,
WorkflowExecution $workflowExecution,
+ string $namespace,
): StartUpdateOutput {
$outcome = $result->getOutcome();
$updateRef = $result->getUpdateRef();
@@ -46,18 +48,18 @@ public function mapUpdateWorkflowResponse(
$failure = $outcome->getFailure();
$success = $outcome->getSuccess();
-
+ $context = new WorkflowSerializationContext($namespace, $workflowExecution->getID());
if ($success !== null) {
- return new StartUpdateOutput(
- $updateRefDto,
- true,
- EncodedValues::fromPayloads($success, $this->converter),
- );
+ $values = EncodedValues::fromPayloads($success, $this->converter)->withSerializationContext($context);
+
+ return new StartUpdateOutput($updateRefDto, true, $values);
}
if ($failure !== null) {
$execution = $updateRef->getWorkflowExecution();
+ $cause = FailureConverter::mapFailureToException($failure, $this->converter, $context);
+
throw new WorkflowUpdateException(
null,
$execution === null
@@ -66,7 +68,7 @@ public function mapUpdateWorkflowResponse(
workflowType: $workflowType,
updateId: $updateRef->getUpdateId(),
updateName: $updateName,
- previous: FailureConverter::mapFailureToException($failure, $this->converter),
+ previous: $cause,
);
}
diff --git a/src/Internal/Client/WorkflowStarter.php b/src/Internal/Client/WorkflowStarter.php
index 10131b91c..7e08e4a3b 100644
--- a/src/Internal/Client/WorkflowStarter.php
+++ b/src/Internal/Client/WorkflowStarter.php
@@ -36,6 +36,7 @@
use Temporal\Common\Versioning\VersioningBehavior;
use Temporal\DataConverter\DataConverterInterface;
use Temporal\DataConverter\EncodedValues;
+use Temporal\DataConverter\WorkflowSerializationContext;
use Temporal\Exception\Client\MultyOperation\OperationStatus;
use Temporal\Exception\Client\ServiceClientException;
use Temporal\Exception\Client\WorkflowExecutionAlreadyStartedException;
@@ -76,7 +77,8 @@ public function start(
array $args = [],
): WorkflowExecution {
$header = Header::empty();
- $arguments = EncodedValues::fromValues($args, $this->converter);
+ $context = new WorkflowSerializationContext($this->clientOptions->namespace, $options->workflowId);
+ $arguments = EncodedValues::fromValues($args, $this->converter)->withSerializationContext($context);
return $this->interceptors->with(
fn(StartInput $input): WorkflowExecution => $this->executeRequest(
@@ -102,8 +104,9 @@ public function signalWithStart(
array $startArgs = [],
): WorkflowExecution {
$header = Header::empty();
- $arguments = EncodedValues::fromValues($startArgs, $this->converter);
- $signalArguments = EncodedValues::fromValues($signalArgs, $this->converter);
+ $context = new WorkflowSerializationContext($this->clientOptions->namespace, $options->workflowId);
+ $arguments = EncodedValues::fromValues($startArgs, $this->converter)->withSerializationContext($context);
+ $signalArguments = EncodedValues::fromValues($signalArgs, $this->converter)->withSerializationContext($context);
return $this->interceptors->with(
function (SignalWithStartInput $input): WorkflowExecution {
@@ -141,8 +144,9 @@ public function updateWithStart(
array $updateArgs = [],
array $startArgs = [],
): UpdateWithStartOutput {
- $arguments = EncodedValues::fromValues($startArgs, $this->converter);
- $updateArguments = EncodedValues::fromValues($updateArgs, $this->converter);
+ $context = new WorkflowSerializationContext($this->clientOptions->namespace, $options->workflowId);
+ $arguments = EncodedValues::fromValues($startArgs, $this->converter)->withSerializationContext($context);
+ $updateArguments = EncodedValues::fromValues($updateArgs, $this->converter)->withSerializationContext($context);
return $this->interceptors->with(
function (UpdateWithStartInput $input): UpdateWithStartOutput {
@@ -170,7 +174,6 @@ function (UpdateWithStartInput $input): UpdateWithStartOutput {
// Configure update Input
$i = new \Temporal\Api\Update\V1\Input();
$i->setName($input->updateInput->updateName);
- $input->updateInput->arguments->setDataConverter($this->converter);
$input->updateInput->arguments->isEmpty() or $i->setArgs($input->updateInput->arguments->toPayloads());
$input->updateInput->header->isEmpty() or $i->setHeader($input->updateInput->header->toHeader());
$r->setInput($i);
@@ -236,6 +239,7 @@ function (UpdateWithStartInput $input): UpdateWithStartOutput {
updateName: $input->updateInput->updateName,
workflowType: $input->workflowStartInput->workflowType,
workflowExecution: $execution,
+ namespace: $this->clientOptions->namespace,
);
} catch (\RuntimeException $e) {
return new UpdateWithStartOutput($execution, $e);
@@ -366,7 +370,8 @@ private function configureExecutionRequest(
$options->retryOptions === null or $req->setRetryPolicy($options->retryOptions->toWorkflowRetryPolicy());
// Memo
- $memo = $options->toMemo($this->converter);
+ $context = new WorkflowSerializationContext($this->clientOptions->namespace, $input->workflowId);
+ $memo = $options->toMemo($this->converter, $context);
$memo === null or $req->setMemo($memo);
// Search Attributes
diff --git a/src/Internal/Client/WorkflowStub.php b/src/Internal/Client/WorkflowStub.php
index 2f257c7d7..fb4d7e259 100644
--- a/src/Internal/Client/WorkflowStub.php
+++ b/src/Internal/Client/WorkflowStub.php
@@ -41,6 +41,7 @@
use Temporal\DataConverter\DataConverterInterface;
use Temporal\DataConverter\EncodedValues;
use Temporal\DataConverter\ValuesInterface;
+use Temporal\DataConverter\WorkflowSerializationContext;
use Temporal\Exception\Client\CanceledException;
use Temporal\Exception\Client\ServiceClientException;
use Temporal\Exception\Client\TimeoutException;
@@ -84,6 +85,7 @@ final class WorkflowStub implements WorkflowStubInterface, HeaderCarrier
private const ERROR_WORKFLOW_NOT_STARTED = 'Method "%s" cannot be called because the workflow has not been started';
private ?WorkflowExecution $execution = null;
+ private ?WorkflowSerializationContext $serializationContext = null;
private HeaderInterface $header;
/**
@@ -129,6 +131,7 @@ public function getExecution(): WorkflowExecution
public function setExecution(WorkflowExecution $execution): void
{
$this->execution = $execution;
+ $this->serializationContext = null;
}
public function hasExecution(): bool
@@ -146,6 +149,9 @@ public function signal(string $name, ...$args): void
$request->setNamespace($this->clientOptions->namespace);
$serviceClient = $this->serviceClient;
+ $signalArguments = EncodedValues::fromValues($args, $this->converter);
+ $signalArguments->setSerializationContext($this->getSerializationContext());
+
$this->interceptors->with(
static function (SignalInput $input) use ($request, $serviceClient): void {
$request->setWorkflowExecution($input->workflowExecution->toProtoWorkflowExecution());
@@ -175,7 +181,7 @@ static function (SignalInput $input) use ($request, $serviceClient): void {
$this->getExecution(),
$this->workflowType,
$name,
- EncodedValues::fromValues($args, $this->converter),
+ $signalArguments,
));
}
@@ -184,11 +190,15 @@ public function query(string $name, ...$args): ?ValuesInterface
$this->assertStarted(__FUNCTION__);
$serviceClient = $this->serviceClient;
- $converter = $this->converter;
$clientOptions = $this->clientOptions;
+ $converter = $this->converter;
+ $context = $this->getSerializationContext();
+
+ $queryArguments = EncodedValues::fromValues($args, $converter);
+ $queryArguments->setSerializationContext($context);
return $this->interceptors->with(
- static function (QueryInput $input) use ($serviceClient, $converter, $clientOptions): ?EncodedValues {
+ static function (QueryInput $input) use ($serviceClient, $converter, $context, $clientOptions): ?EncodedValues {
$request = new QueryWorkflowRequest();
$request->setNamespace($clientOptions->namespace);
$request->setQueryRejectCondition($clientOptions->queryRejectionCondition);
@@ -224,7 +234,10 @@ static function (QueryInput $input) use ($serviceClient, $converter, $clientOpti
return null;
}
- return EncodedValues::fromPayloads($result->getQueryResult(), $converter);
+ $queryResult = EncodedValues::fromPayloads($result->getQueryResult(), $converter);
+ $queryResult->setSerializationContext($context);
+
+ return $queryResult;
}
throw new WorkflowQueryRejectedException(
@@ -241,7 +254,7 @@ static function (QueryInput $input) use ($serviceClient, $converter, $clientOpti
$this->getExecution(),
$this->workflowType,
$name,
- EncodedValues::fromValues($args, $this->converter),
+ $queryArguments,
));
}
@@ -263,8 +276,12 @@ public function startUpdate(string|UpdateOptions $nameOrOptions, ...$args): Upda
$serviceClient = $this->serviceClient;
$converter = $this->converter;
+ $context = $this->getSerializationContext();
$clientOptions = $this->clientOptions;
+ $updateArguments = EncodedValues::fromValues($args, $converter);
+ $updateArguments->setSerializationContext($context);
+
/**
* @var StartUpdateOutput $result
* @var UpdateInput $updateInput
@@ -272,7 +289,7 @@ public function startUpdate(string|UpdateOptions $nameOrOptions, ...$args): Upda
$result = $this->interceptors->with(
static function (
UpdateInput $input,
- ) use (&$updateInput, $serviceClient, $converter, $clientOptions): StartUpdateOutput {
+ ) use (&$updateInput, $serviceClient, $converter, $context, $clientOptions): StartUpdateOutput {
$updateInput = $input;
$request = (new UpdateWorkflowExecutionRequest())
->setNamespace($clientOptions->namespace)
@@ -295,6 +312,7 @@ static function (
$i = new \Temporal\Api\Update\V1\Input();
$i->setName($input->updateName);
$input->arguments->setDataConverter($converter);
+ $input->arguments->setSerializationContext($context);
$input->arguments->isEmpty() or $i->setArgs($input->arguments->toPayloads());
$input->header->isEmpty() or $i->setHeader($input->header->toHeader());
$r->setInput($i);
@@ -319,6 +337,7 @@ static function (
$input->updateName,
$input->workflowType,
$input->workflowExecution,
+ $clientOptions->namespace,
);
},
/** @see WorkflowClientCallsInterceptor::update() */
@@ -327,7 +346,7 @@ static function (
workflowExecution: $this->getExecution(),
workflowType: $this->workflowType,
updateName: $nameOrOptions->updateName,
- arguments: EncodedValues::fromValues($args, $this->converter),
+ arguments: $updateArguments,
header: Header::empty(),
waitPolicy: $nameOrOptions->waitPolicy,
updateId: $nameOrOptions->updateId ?? Uuid::v4(),
@@ -394,9 +413,12 @@ public function terminate(string $reason, array $details = []): void
$serviceClient = $this->serviceClient;
$clientOptions = $this->clientOptions;
$converter = $this->converter;
+ $context = $this->getSerializationContext();
$this->interceptors->with(
- static function (TerminateInput $input) use ($serviceClient, $clientOptions, $details, $converter): void {
+ static function (
+ TerminateInput $input,
+ ) use ($serviceClient, $clientOptions, $details, $converter, $context): void {
$request = new TerminateWorkflowExecutionRequest();
$request->setNamespace($clientOptions->namespace);
$request->setIdentity($clientOptions->identity);
@@ -404,7 +426,9 @@ static function (TerminateInput $input) use ($serviceClient, $clientOptions, $de
$request->setReason($input->reason);
if ($details !== []) {
- $request->setDetails(EncodedValues::fromValues($details, $converter)->toPayloads());
+ $values = EncodedValues::fromValues($details, $converter);
+ $values->setSerializationContext($context);
+ $request->setDetails($values->toPayloads());
}
$serviceClient->TerminateWorkflowExecution($request);
@@ -462,7 +486,12 @@ function (DescribeInput $input): WorkflowExecutionDescription {
$response = $this->serviceClient->DescribeWorkflowExecution($request);
- $activityMapper = new PendingActivityInfoMapper($this->converter);
+ $activityMapper = new PendingActivityInfoMapper(
+ $this->converter,
+ $input->namespace,
+ $this->getExecution()->getID(),
+ $response->getWorkflowExecutionInfo()?->getType()?->getName(),
+ );
$pendingActivities = [];
/** @psalm-suppress TooManyTemplateParams */
foreach ($response->getPendingActivities() as $pendingActivity) {
@@ -473,7 +502,7 @@ function (DescribeInput $input): WorkflowExecutionDescription {
return new WorkflowExecutionDescription(
config: (new WorkflowExecutionConfigMapper($this->converter))
->fromMessage($response->getExecutionConfig()),
- info: (new WorkflowExecutionInfoMapper($this->converter))
+ info: (new WorkflowExecutionInfoMapper($this->converter, $this->getSerializationContext()))
->fromMessage($response->getWorkflowExecutionInfo()),
pendingActivities: $pendingActivities,
);
@@ -486,6 +515,14 @@ function (DescribeInput $input): WorkflowExecutionDescription {
));
}
+ private function getSerializationContext(): WorkflowSerializationContext
+ {
+ return $this->serializationContext ??= new WorkflowSerializationContext(
+ $this->clientOptions->namespace,
+ $this->getExecution()->getID(),
+ );
+ }
+
/**
* @psalm-assert !null $this->execution
*/
@@ -515,7 +552,10 @@ private function fetchResult(?int $timeout = null): ?EncodedValues
return null;
}
- return EncodedValues::fromPayloads($attr->getResult(), $this->converter);
+ $result = EncodedValues::fromPayloads($attr->getResult(), $this->converter);
+ $result->setSerializationContext($this->getSerializationContext());
+
+ return $result;
case EventType::EVENT_TYPE_WORKFLOW_EXECUTION_FAILED:
$attr = $closeEvent->getWorkflowExecutionFailedEventAttributes();
@@ -530,7 +570,8 @@ private function fetchResult(?int $timeout = null): ?EncodedValues
$details = $attr->hasDetails()
? EncodedValues::fromPayloads($attr->getDetails(), $this->converter)
- : EncodedValues::fromValues([]);
+ : EncodedValues::fromValues([], $this->converter);
+ $details->setSerializationContext($this->getSerializationContext());
throw new WorkflowFailedException(
$this->execution,
@@ -638,12 +679,18 @@ private function mapWorkflowFailureToException(\Throwable $failure): \Throwable
{
switch (true) {
case $failure instanceof WorkflowExecutionFailedException:
+ $cause = FailureConverter::mapFailureToException(
+ $failure->getFailure(),
+ $this->converter,
+ $this->getSerializationContext(),
+ );
+
return new WorkflowFailedException(
$this->execution,
$this->workflowType,
$failure->getWorkflowTaskCompletedEventId(),
$failure->getRetryState(),
- FailureConverter::mapFailureToException($failure->getFailure(), $this->converter),
+ $cause,
);
case $failure instanceof ServiceClientException:
diff --git a/src/Internal/Mapper/PendingActivityInfoMapper.php b/src/Internal/Mapper/PendingActivityInfoMapper.php
index cfa5c1e22..3bac1050e 100644
--- a/src/Internal/Mapper/PendingActivityInfoMapper.php
+++ b/src/Internal/Mapper/PendingActivityInfoMapper.php
@@ -14,6 +14,7 @@
use Temporal\Api\Workflow\V1\PendingActivityInfo\PauseInfo;
use Temporal\Common\Priority as PriorityDto;
use Temporal\Common\Versioning\WorkerDeploymentVersion;
+use Temporal\DataConverter\ActivitySerializationContext;
use Temporal\DataConverter\DataConverterInterface;
use Temporal\DataConverter\EncodedValues;
use Temporal\DataConverter\ValuesInterface;
@@ -32,6 +33,9 @@ final class PendingActivityInfoMapper
{
public function __construct(
private readonly DataConverterInterface $converter,
+ private readonly string $namespace,
+ private readonly ?string $workflowId = null,
+ private readonly ?string $workflowType = null,
) {}
/**
@@ -46,18 +50,26 @@ public function fromMessage(PendingActivityInfo $message): PendingActivityInfoDt
$retryInterval = $message->getCurrentRetryInterval();
$retryInterval === null or $retryInterval = DateInterval::parse($retryInterval);
+ $serializationContext = new ActivitySerializationContext(
+ namespace: $this->namespace,
+ workflowId: $this->workflowId,
+ workflowType: $this->workflowType,
+ activityType: $message->getActivityType()?->getName() ?? '',
+ taskQueue: $message->getActivityOptions()?->getTaskQueue()?->getName() ?? '',
+ );
+
return new PendingActivityInfoDto(
activityId: $message->getActivityId(),
activityType: $activityType,
state: PendingActivityState::from($message->getState()),
- heartbeatDetails: $this->prepareHeartbeatDetails($message),
+ heartbeatDetails: $this->prepareHeartbeatDetails($message, $serializationContext),
lastHeartbeatTime: $message->getLastHeartbeatTime()?->toDateTime(),
lastStartedTime: $message->getLastStartedTime()?->toDateTime(),
attempt: $message->getAttempt(),
maximumAttempts: $message->getMaximumAttempts(),
scheduledTime: $message->getScheduledTime()?->toDateTime(),
expirationTime: $message->getExpirationTime()?->toDateTime(),
- lastFailure: $this->prepareFailure($message->getLastFailure()),
+ lastFailure: $this->prepareFailure($message->getLastFailure(), $serializationContext),
lastWorkerIdentity: $message->getLastWorkerIdentity(),
currentRetryInterval: $retryInterval,
lastAttemptCompleteTime: $message->getLastAttemptCompleteTime()?->toDateTime(),
@@ -70,20 +82,25 @@ public function fromMessage(PendingActivityInfo $message): PendingActivityInfoDt
);
}
- private function prepareHeartbeatDetails(PendingActivityInfo $message): ValuesInterface
- {
+ private function prepareHeartbeatDetails(
+ PendingActivityInfo $message,
+ ActivitySerializationContext $context,
+ ): ValuesInterface {
$details = $message->getHeartbeatDetails();
+ if ($details === null) {
+ return EncodedValues::empty();
+ }
- return $details === null
- ? EncodedValues::empty()
- : EncodedValues::fromPayloads($details, $this->converter);
+ return EncodedValues::fromPayloads($details, $this->converter)->withSerializationContext($context);
}
- private function prepareFailure(?Failure $failure): ?TemporalFailure
+ private function prepareFailure(?Failure $failure, ActivitySerializationContext $context): ?TemporalFailure
{
- return $failure === null
- ? null
- : FailureConverter::mapFailureToException($failure, $this->converter);
+ if ($failure === null) {
+ return null;
+ }
+
+ return FailureConverter::mapFailureToException($failure, $this->converter, $context);
}
/**
diff --git a/src/Internal/Mapper/ScheduleMapper.php b/src/Internal/Mapper/ScheduleMapper.php
index 020213642..dde7ef774 100644
--- a/src/Internal/Mapper/ScheduleMapper.php
+++ b/src/Internal/Mapper/ScheduleMapper.php
@@ -4,7 +4,6 @@
namespace Temporal\Internal\Mapper;
-use Temporal\Api\Common\V1\Payloads;
use Temporal\Api\Common\V1\WorkflowType;
use Temporal\Api\Schedule\V1\CalendarSpec;
use Temporal\Api\Schedule\V1\IntervalSpec;
@@ -19,6 +18,7 @@
use Temporal\Client\Schedule\Action\ScheduleAction;
use Temporal\Client\Schedule\Schedule;
use Temporal\DataConverter\DataConverterInterface;
+use Temporal\DataConverter\WorkflowSerializationContext;
use Temporal\Internal\Marshaller\MarshallerInterface;
final class ScheduleMapper
@@ -28,14 +28,21 @@ public function __construct(
private readonly MarshallerInterface $marshaller,
) {}
- public function toMessage(Schedule $dto): \Temporal\Api\Schedule\V1\Schedule
+ public function toMessage(Schedule $dto, ?string $namespace = null): \Temporal\Api\Schedule\V1\Schedule
{
if ($dto->action instanceof StartWorkflowAction) {
$action = $dto->action;
- $action->input?->setDataConverter($this->converter);
- $action->header?->setDataConverter($this->converter);
- $action->memo?->setDataConverter($this->converter);
- $action->searchAttributes?->setDataConverter($this->converter);
+
+ $context = $namespace !== null && $action->workflowId !== ''
+ ? new WorkflowSerializationContext(namespace: $namespace, workflowId: $action->workflowId)
+ : null;
+
+ $action->input->setDataConverter($this->converter);
+ $action->input->setSerializationContext($context);
+ $action->header->setDataConverter($this->converter);
+ $action->memo->setDataConverter($this->converter);
+ $action->memo->setSerializationContext($context);
+ $action->searchAttributes->setDataConverter($this->converter);
}
$array = $this->marshaller->marshal($dto);
@@ -68,8 +75,7 @@ private function prepareAction(ScheduleAction $action, array $array): \Temporal\
/** Because it is mapped with wrong key {@see \Temporal\Workflow\WorkflowType::$name} */
->setName($values['workflow_type']['Name']);
$values['task_queue'] = new TaskQueue($values['task_queue']);
- $action->input?->setDataConverter($this->converter);
- $values['input'] = $action->input?->toPayloads() ?? new Payloads();
+ $values['input'] = $action->input->toPayloads();
$values['workflow_id_reuse_policy'] = $action->workflowIdReusePolicy->value;
$values['retry_policy'] = $action->retryPolicy?->toWorkflowRetryPolicy();
$values['user_metadata'] = (new UserMetadata())
diff --git a/src/Internal/Mapper/WorkflowExecutionInfoMapper.php b/src/Internal/Mapper/WorkflowExecutionInfoMapper.php
index 05458126a..fe6c171be 100644
--- a/src/Internal/Mapper/WorkflowExecutionInfoMapper.php
+++ b/src/Internal/Mapper/WorkflowExecutionInfoMapper.php
@@ -13,6 +13,7 @@
use Temporal\Common\WorkerVersionStamp as WorkerVersionStampDto;
use Temporal\DataConverter\DataConverterInterface;
use Temporal\DataConverter\EncodedCollection;
+use Temporal\DataConverter\SerializationContext;
use Temporal\Internal\Support\DateInterval;
use Temporal\Workflow\ResetPointInfo as ResetPointInfoDto;
use Temporal\Workflow\WorkflowExecution as WorkflowExecutionDto;
@@ -24,6 +25,7 @@ final class WorkflowExecutionInfoMapper
{
public function __construct(
private readonly DataConverterInterface $converter,
+ private readonly ?SerializationContext $context = null,
) {}
public function fromMessage(WorkflowExecutionInfo $message): WorkflowExecutionInfoDto
@@ -74,14 +76,12 @@ public function prepareWorkerVersionStamp(?WorkerVersionStamp $versionStamp): ?W
private function prepareMemo(?Memo $memo): EncodedCollection
{
- if ($memo === null) {
- return EncodedCollection::fromValues([], $this->converter);
- }
+ $collection = $memo === null
+ ? EncodedCollection::fromValues([], $this->converter)
+ : EncodedCollection::fromPayloadCollection($memo->getFields(), $this->converter);
+ $collection->setSerializationContext($this->context);
- return EncodedCollection::fromPayloadCollection(
- $memo->getFields(),
- $this->converter,
- );
+ return $collection;
}
private function prepareSearchAttributes(?SearchAttributes $searchAttributes): EncodedCollection
diff --git a/src/Internal/Transport/Client.php b/src/Internal/Transport/Client.php
index 058cfac65..f381b5002 100644
--- a/src/Internal/Transport/Client.php
+++ b/src/Internal/Transport/Client.php
@@ -13,7 +13,9 @@
use React\Promise\Deferred;
use React\Promise\PromiseInterface;
+use Temporal\DataConverter\SerializationContext;
use Temporal\Exception\Failure\CanceledFailure;
+use Temporal\Exception\Failure\TemporalFailure;
use Temporal\Internal\Queue\QueueInterface;
use Temporal\Internal\Transport\Request\UndefinedResponse;
use Temporal\Worker\Transport\Command\CommandInterface;
@@ -37,7 +39,7 @@ final class Client implements ClientInterface
'a request with that identifier was not sent';
/**
- * @var array
+ * @var array
*/
private array $requests = [];
@@ -55,7 +57,7 @@ public function dispatch(ServerResponseInterface $response): void
return;
}
- [$deferred, $context] = $this->requests[$id];
+ [$deferred, $context, $requestContext] = $this->requests[$id];
unset($this->requests[$id]);
$info = $context->getInfo();
@@ -66,9 +68,18 @@ public function dispatch(ServerResponseInterface $response): void
// Bind workflow context for promise resolution
Workflow::setCurrentContext($context);
if ($response instanceof FailureResponseInterface) {
- $deferred->reject($response->getFailure());
+ $failure = $response->getFailure();
+ if ($requestContext !== null && $failure instanceof TemporalFailure) {
+ $failure->setSerializationContext($requestContext);
+ }
+
+ $deferred->reject($failure);
} else {
- $deferred->resolve($response->getPayloads());
+ $payloads = $response->getPayloads();
+ if ($requestContext !== null && $payloads !== null) {
+ $payloads->setSerializationContext($requestContext);
+ }
+ $deferred->resolve($payloads);
}
}
@@ -78,12 +89,14 @@ public function request(RequestInterface $request, ?WorkflowContextInterface $co
$id = $request->getID();
- \array_key_exists($id, $this->requests) and throw new \OutOfBoundsException(
- \sprintf(self::ERROR_REQUEST_ID_DUPLICATION, $id),
- );
+ if (\array_key_exists($id, $this->requests)) {
+ throw new \OutOfBoundsException(\sprintf(self::ERROR_REQUEST_ID_DUPLICATION, $id));
+ }
+
+ $requestContext = $request->getPayloads()->getSerializationContext();
$deferred = new Deferred();
- $this->requests[$id] = [$deferred, $context];
+ $this->requests[$id] = [$deferred, $context, $requestContext];
return $deferred->promise();
}
diff --git a/src/Internal/Transport/Request/ExecuteActivity.php b/src/Internal/Transport/Request/ExecuteActivity.php
index 3b5fd276a..f63f893d6 100644
--- a/src/Internal/Transport/Request/ExecuteActivity.php
+++ b/src/Internal/Transport/Request/ExecuteActivity.php
@@ -29,13 +29,21 @@ final class ExecuteActivity extends Request
*/
private string $activityName;
+ private ?string $taskQueue;
+
/**
* @param non-empty-string $name Activity name
* @param RequestOptions $options
*/
- public function __construct(string $name, ValuesInterface $args, array $options, HeaderInterface $header)
- {
+ public function __construct(
+ string $name,
+ ValuesInterface $args,
+ array $options,
+ HeaderInterface $header,
+ ?string $taskQueue = null,
+ ) {
$this->activityName = $name;
+ $this->taskQueue = $taskQueue;
parent::__construct(self::NAME, ['name' => $name, 'options' => $options], $args, header: $header);
}
@@ -46,4 +54,9 @@ public function getActivityName(): string
{
return $this->activityName;
}
+
+ public function getTaskQueue(): ?string
+ {
+ return $this->taskQueue;
+ }
}
diff --git a/src/Internal/Transport/Request/ExecuteChildWorkflow.php b/src/Internal/Transport/Request/ExecuteChildWorkflow.php
index b04781c47..085150e9c 100644
--- a/src/Internal/Transport/Request/ExecuteChildWorkflow.php
+++ b/src/Internal/Transport/Request/ExecuteChildWorkflow.php
@@ -27,13 +27,24 @@ final class ExecuteChildWorkflow extends Request
/** @var non-empty-string */
private string $workflowType;
+ private string $namespace;
+ private ?string $workflowId;
+
/**
* @param non-empty-string $name Workflow name
* @param RequestOptions $options
*/
- public function __construct(string $name, ValuesInterface $input, array $options, HeaderInterface $header)
- {
+ public function __construct(
+ string $name,
+ ValuesInterface $input,
+ array $options,
+ HeaderInterface $header,
+ string $namespace = '',
+ ?string $workflowId = null,
+ ) {
$this->workflowType = $name;
+ $this->namespace = $namespace;
+ $this->workflowId = $workflowId;
parent::__construct(self::NAME, ['name' => $name, 'options' => $options], $input, header: $header);
}
@@ -44,4 +55,14 @@ public function getWorkflowType(): string
{
return $this->workflowType;
}
+
+ public function getNamespace(): string
+ {
+ return $this->namespace;
+ }
+
+ public function getWorkflowId(): ?string
+ {
+ return $this->workflowId;
+ }
}
diff --git a/src/Internal/Transport/Request/ExecuteLocalActivity.php b/src/Internal/Transport/Request/ExecuteLocalActivity.php
index 6d906769b..c54f78059 100644
--- a/src/Internal/Transport/Request/ExecuteLocalActivity.php
+++ b/src/Internal/Transport/Request/ExecuteLocalActivity.php
@@ -29,13 +29,21 @@ final class ExecuteLocalActivity extends Request
*/
private string $activityName;
+ private ?string $taskQueue;
+
/**
* @param non-empty-string $name Activity name
* @param RequestOptions $options
*/
- public function __construct(string $name, ValuesInterface $args, array $options, HeaderInterface $header)
- {
+ public function __construct(
+ string $name,
+ ValuesInterface $args,
+ array $options,
+ HeaderInterface $header,
+ ?string $taskQueue = null,
+ ) {
$this->activityName = $name;
+ $this->taskQueue = $taskQueue;
parent::__construct(self::NAME, ['name' => $name, 'options' => $options], $args, header: $header);
}
@@ -46,4 +54,9 @@ public function getActivityName(): string
{
return $this->activityName;
}
+
+ public function getTaskQueue(): ?string
+ {
+ return $this->taskQueue;
+ }
}
diff --git a/src/Internal/Transport/Request/SignalExternalWorkflow.php b/src/Internal/Transport/Request/SignalExternalWorkflow.php
index 234d264e3..b2f191996 100644
--- a/src/Internal/Transport/Request/SignalExternalWorkflow.php
+++ b/src/Internal/Transport/Request/SignalExternalWorkflow.php
@@ -21,6 +21,9 @@ final class SignalExternalWorkflow extends Request
{
public const NAME = 'SignalExternalWorkflow';
+ private string $namespace;
+ private string $workflowId;
+
public function __construct(
string $namespace,
string $workflowId,
@@ -29,6 +32,8 @@ public function __construct(
?ValuesInterface $input = null,
bool $childWorkflowOnly = false,
) {
+ $this->namespace = $namespace;
+ $this->workflowId = $workflowId;
$options = [
'namespace' => $namespace,
'workflowID' => $workflowId,
@@ -39,4 +44,14 @@ public function __construct(
parent::__construct(self::NAME, $options, $input);
}
+
+ public function getNamespace(): string
+ {
+ return $this->namespace;
+ }
+
+ public function getWorkflowId(): string
+ {
+ return $this->workflowId;
+ }
}
diff --git a/src/Internal/Transport/Router/CancelWorkflow.php b/src/Internal/Transport/Router/CancelWorkflow.php
index 9510c00d5..fdecc860b 100644
--- a/src/Internal/Transport/Router/CancelWorkflow.php
+++ b/src/Internal/Transport/Router/CancelWorkflow.php
@@ -22,9 +22,12 @@ class CancelWorkflow extends WorkflowProcessAwareRoute
public function handle(ServerRequestInterface $request, array $headers, Deferred $resolver): void
{
+ $process = $this->running->find($request->getID());
$this->cancel($request->getID());
- $resolver->resolve(EncodedValues::fromValues([null]));
+ $response = EncodedValues::fromValues([null]);
+ $process?->getContext()?->applySerializationContext($response);
+ $resolver->resolve($response);
}
/**
diff --git a/src/Internal/Transport/Router/DestroyWorkflow.php b/src/Internal/Transport/Router/DestroyWorkflow.php
index 2f7031195..53dd6153e 100644
--- a/src/Internal/Transport/Router/DestroyWorkflow.php
+++ b/src/Internal/Transport/Router/DestroyWorkflow.php
@@ -43,9 +43,12 @@ public function __construct(
public function handle(ServerRequestInterface $request, array $headers, Deferred $resolver): void
{
+ $process = $this->running->find($request->getID());
$this->kill($request->getID());
- $resolver->resolve(EncodedValues::fromValues([null]));
+ $response = EncodedValues::fromValues([null]);
+ $process?->getContext()?->applySerializationContext($response);
+ $resolver->resolve($response);
}
public function kill(string $runId): array
diff --git a/src/Internal/Transport/Router/GetWorkerInfo.php b/src/Internal/Transport/Router/GetWorkerInfo.php
index 596c214d4..91afe2b55 100644
--- a/src/Internal/Transport/Router/GetWorkerInfo.php
+++ b/src/Internal/Transport/Router/GetWorkerInfo.php
@@ -13,7 +13,9 @@
use React\Promise\Deferred;
use Temporal\Common\SdkVersion;
+use Temporal\DataConverter\DataConverter;
use Temporal\DataConverter\EncodedValues;
+use Temporal\DataConverter\RawValue;
use Temporal\Internal\Declaration\Prototype\ActivityPrototype;
use Temporal\Internal\Declaration\Prototype\WorkflowPrototype;
use Temporal\Internal\Marshaller\MarshallerInterface;
@@ -35,10 +37,11 @@ public function __construct(
public function handle(ServerRequestInterface $request, array $headers, Deferred $resolver): void
{
+ $converter = DataConverter::createDefault();
$result = [];
foreach ($this->queues as $taskQueue) {
- $result[] = $this->workerToArray($taskQueue);
+ $result[] = new RawValue($converter->toPayload($this->workerToArray($taskQueue)));
}
$resolver->resolve(EncodedValues::fromValues($result));
diff --git a/src/Internal/Transport/Router/InvokeActivity.php b/src/Internal/Transport/Router/InvokeActivity.php
index ffa1f537d..a7feb80f3 100644
--- a/src/Internal/Transport/Router/InvokeActivity.php
+++ b/src/Internal/Transport/Router/InvokeActivity.php
@@ -14,8 +14,10 @@
use React\Promise\Deferred;
use Temporal\Activity;
use Temporal\Activity\ActivityInfo;
+use Temporal\DataConverter\ActivitySerializationContext;
use Temporal\DataConverter\EncodedValues;
use Temporal\Exception\DoNotCompleteOnResultException;
+use Temporal\Exception\Failure\TemporalFailure;
use Temporal\Interceptor\ActivityInbound\ActivityInput;
use Temporal\Interceptor\ActivityInboundInterceptor;
use Temporal\Interceptor\PipelineProvider;
@@ -74,6 +76,10 @@ public function handle(ServerRequestInterface $request, array $headers, Deferred
/** @var ActivityContext $context */
$context = $this->services->marshaller->unmarshal($options, $context);
+ $info = $context->getInfo();
+ $serializationContext = ActivitySerializationContext::fromActivityInfo($info, $this->isLocal());
+ $context = $context->withSerializationContext($serializationContext);
+
$prototype = $this->findDeclarationOrFail($context->getInfo());
try {
@@ -106,9 +112,15 @@ static function (ActivityInput $input) use ($handler, $context): mixed {
if ($context->isDoNotCompleteOnReturn()) {
$resolver->reject(DoNotCompleteOnResultException::create());
} else {
- $resolver->resolve(EncodedValues::fromValues([$result]));
+ $resultPayloads = EncodedValues::fromValues([$result]);
+ $context->applySerializationContext($resultPayloads);
+ $resolver->resolve($resultPayloads);
}
} catch (\Throwable $e) {
+ if ($e instanceof TemporalFailure) {
+ $e->setSerializationContext($serializationContext);
+ }
+
$resolver->reject($e);
} finally {
$finalizer = $this->services->activities->getFinalizer();
@@ -119,6 +131,11 @@ static function (ActivityInput $input) use ($handler, $context): mixed {
}
}
+ protected function isLocal(): bool
+ {
+ return false;
+ }
+
private function findDeclarationOrFail(ActivityInfo $info): ActivityPrototype
{
$activity = $this->services->activities->find($info->type->name);
diff --git a/src/Internal/Transport/Router/InvokeLocalActivity.php b/src/Internal/Transport/Router/InvokeLocalActivity.php
index 0bafd7e6c..9e87dc486 100644
--- a/src/Internal/Transport/Router/InvokeLocalActivity.php
+++ b/src/Internal/Transport/Router/InvokeLocalActivity.php
@@ -14,4 +14,10 @@
/**
* For cases if we would like to have different logic for local activity.
*/
-final class InvokeLocalActivity extends InvokeActivity {}
+final class InvokeLocalActivity extends InvokeActivity
+{
+ protected function isLocal(): bool
+ {
+ return true;
+ }
+}
diff --git a/src/Internal/Transport/Router/InvokeQuery.php b/src/Internal/Transport/Router/InvokeQuery.php
index 081353d8a..bfd3b8f5c 100644
--- a/src/Internal/Transport/Router/InvokeQuery.php
+++ b/src/Internal/Transport/Router/InvokeQuery.php
@@ -16,6 +16,7 @@
use Temporal\Api\Sdk\V1\WorkflowDefinition;
use Temporal\Api\Sdk\V1\WorkflowMetadata;
use Temporal\DataConverter\EncodedValues;
+use Temporal\Exception\Failure\TemporalFailure;
use Temporal\Interceptor\WorkflowInbound\QueryInput;
use Temporal\Internal\Declaration\EntityNameValidator;
use Temporal\Internal\Declaration\WorkflowInstance\QueryDispatcher;
@@ -82,9 +83,19 @@ static function () use ($name, $request, $resolver, $handler, $context, $headers
$info = $context->getInfo();
$request->getTickInfo()->applyTo($info);
- $result = $handler(new QueryInput($name, $request->getPayloads(), $info));
- $resolver->resolve(EncodedValues::fromValues([$result]));
+ $arguments = $request->getPayloads();
+ $context->applySerializationContext($arguments);
+
+ $result = $handler(new QueryInput($name, $arguments, $info));
+
+ $resultValues = EncodedValues::fromValues([$result]);
+ $context->applySerializationContext($resultValues);
+ $resolver->resolve($resultValues);
} catch (\Throwable $e) {
+ if ($e instanceof TemporalFailure) {
+ $e->setSerializationContext($context->getSerializationContext());
+ }
+
$resolver->reject($e);
}
},
@@ -125,6 +136,7 @@ static function () use ($resolver, $context): void {
)
->setCurrentDetails((string) $context->getCurrentDetails()),
]);
+ $context->applySerializationContext($result);
$resolver->resolve($result);
} catch (\Throwable $e) {
@@ -141,6 +153,7 @@ private function stackTrace(Deferred $resolver, WorkflowContext $context): void
static function () use ($resolver, $context): void {
try {
$result = EncodedValues::fromValues([$context->getStackTrace()]);
+ $context->applySerializationContext($result);
$resolver->resolve($result);
} catch (\Throwable $e) {
@@ -159,6 +172,7 @@ static function () use ($resolver, $context): void {
$result = EncodedValues::fromValues([
$context->getEnhancedStackTrace(),
]);
+ $context->applySerializationContext($result);
$resolver->resolve($result);
} catch (\Throwable $e) {
diff --git a/src/Internal/Transport/Router/InvokeSignal.php b/src/Internal/Transport/Router/InvokeSignal.php
index f5ffbd687..e1f40f334 100644
--- a/src/Internal/Transport/Router/InvokeSignal.php
+++ b/src/Internal/Transport/Router/InvokeSignal.php
@@ -37,8 +37,13 @@ public function handle(ServerRequestInterface $request, array $headers, Deferred
$info = $context->getInfo();
$request->getTickInfo()->applyTo($info);
- $handler($request->getPayloads());
+ $payloads = $request->getPayloads();
+ $context->applySerializationContext($payloads);
- $resolver->resolve(EncodedValues::fromValues([null]));
+ $handler($payloads);
+
+ $response = EncodedValues::fromValues([null]);
+ $context->applySerializationContext($response);
+ $resolver->resolve($response);
}
}
diff --git a/src/Internal/Transport/Router/InvokeUpdate.php b/src/Internal/Transport/Router/InvokeUpdate.php
index 5f228d324..08f7f981e 100644
--- a/src/Internal/Transport/Router/InvokeUpdate.php
+++ b/src/Internal/Transport/Router/InvokeUpdate.php
@@ -14,6 +14,7 @@
use React\Promise\Deferred;
use React\Promise\PromiseInterface;
use Temporal\DataConverter\EncodedValues;
+use Temporal\Exception\Failure\TemporalFailure;
use Temporal\Interceptor\WorkflowInbound\UpdateInput;
use Temporal\Internal\Declaration\WorkflowInstance\UpdateDispatcher;
use Temporal\Worker\Transport\Command\Client\UpdateResponse;
@@ -40,11 +41,14 @@ public function handle(ServerRequestInterface $request, array $headers, Deferred
$info = $context->getInfo();
$request->getTickInfo()->applyTo($info);
+ $arguments = $request->getPayloads();
+ $context->applySerializationContext($arguments);
+
$input = new UpdateInput(
updateName: $name,
updateId: $updateId,
info: $context->getInfo(),
- arguments: $request->getPayloads(),
+ arguments: $arguments,
header: $request->getHeader(),
isReplaying: $context->isReplaying(),
);
@@ -74,6 +78,10 @@ public function handle(ServerRequestInterface $request, array $headers, Deferred
));
}
} catch (\Throwable $e) {
+ if ($e instanceof TemporalFailure) {
+ $e->setSerializationContext($context->getSerializationContext());
+ }
+
$context->getClient()->send(
new UpdateResponse(
command: UpdateResponse::COMMAND_VALIDATED,
@@ -90,14 +98,21 @@ public function handle(ServerRequestInterface $request, array $headers, Deferred
$deferred = new Deferred();
$deferred->promise()->then(
static function (mixed $value) use ($updateId, $context): void {
+ $values = EncodedValues::fromValues([$value]);
+ $context->applySerializationContext($values);
+
$context->getClient()->send(new UpdateResponse(
command: UpdateResponse::COMMAND_COMPLETED,
- values: EncodedValues::fromValues([$value]),
+ values: $values,
failure: null,
updateId: $updateId,
));
},
static function (\Throwable $err) use ($updateId, $context): void {
+ if ($err instanceof TemporalFailure) {
+ $err->setSerializationContext($context->getSerializationContext());
+ }
+
$context->getClient()->send(new UpdateResponse(
command: UpdateResponse::COMMAND_COMPLETED,
values: null,
diff --git a/src/Internal/Transport/Router/StartWorkflow.php b/src/Internal/Transport/Router/StartWorkflow.php
index 14e12083e..156208df8 100644
--- a/src/Internal/Transport/Router/StartWorkflow.php
+++ b/src/Internal/Transport/Router/StartWorkflow.php
@@ -15,8 +15,10 @@
use Temporal\Api\Common\V1\Memo;
use Temporal\Api\Common\V1\SearchAttributes;
use Temporal\Common\TypedSearchAttributes;
+use Temporal\DataConverter\DataConverterInterface;
use Temporal\DataConverter\EncodedCollection;
use Temporal\DataConverter\EncodedValues;
+use Temporal\DataConverter\WorkflowSerializationContext;
use Temporal\Internal\Declaration\Instantiator\WorkflowInstantiator;
use Temporal\Internal\Declaration\Prototype\WorkflowPrototype;
use Temporal\Internal\ServiceContainer;
@@ -56,7 +58,12 @@ public function handle(ServerRequestInterface $request, array $headers, Deferred
// Search Attributes and Typed Search Attributes
$searchAttributes = $this->convertSearchAttributes($options['info']['SearchAttributes'] ?? null);
- $memo = $this->convertMemo($options['info']['Memo'] ?? null);
+ $memoContext = new WorkflowSerializationContext(
+ (string) ($options['info']['Namespace'] ?? ''),
+ (string) ($options['info']['WorkflowExecution']['ID'] ?? ''),
+ );
+ $memo = $this->convertMemo($options['info']['Memo'] ?? null, $this->services->dataConverter);
+ $memo?->setSerializationContext($memoContext);
$options['info']['SearchAttributes'] = $searchAttributes?->getValues();
$options['info']['TypedSearchAttributes'] = $this->prepareTypedSA($options['search_attributes'] ?? null);
$options['info']['Memo'] = $memo?->getValues();
@@ -64,14 +71,18 @@ public function handle(ServerRequestInterface $request, array $headers, Deferred
/** @var Input $input */
$input = $this->services->marshaller->unmarshal($options, new Input());
- /** @psalm-suppress InaccessibleProperty */
- $input->input = $payloads;
/** @psalm-suppress InaccessibleProperty */
$input->header = $request->getHeader();
$info = $input->info;
$request->getTickInfo()->applyTo($info);
+ $serializationContext = WorkflowSerializationContext::fromInfo($info);
+ $payloads->setSerializationContext($serializationContext);
+ /** @psalm-suppress InaccessibleProperty */
+ $input->input = $payloads;
+ $lastCompletionResult?->setSerializationContext($serializationContext);
+
$instance = $this->instantiator->instantiate($this->findWorkflowOrFail($input->info));
$context = new WorkflowContext(
@@ -85,7 +96,9 @@ public function handle(ServerRequestInterface $request, array $headers, Deferred
$process = new Process($this->services, $runId, $instance);
$this->services->running->add($process);
- $resolver->resolve(EncodedValues::fromValues([null]));
+ $ack = EncodedValues::fromValues([null], $this->services->dataConverter);
+ $ack->setSerializationContext($serializationContext);
+ $resolver->resolve($ack);
$process->initAndStart($context, $instance, $this->wfStartDeferred);
}
@@ -122,7 +135,7 @@ private function convertSearchAttributes(?array $param): ?EncodedCollection
}
}
- private function convertMemo(?array $param): ?EncodedCollection
+ private function convertMemo(?array $param, DataConverterInterface $converter): ?EncodedCollection
{
if (!\is_array($param)) {
return null;
@@ -141,7 +154,7 @@ private function convertMemo(?array $param): ?EncodedCollection
return EncodedCollection::fromPayloadCollection(
$memo->getFields(),
- $this->services->dataConverter,
+ $converter,
);
} catch (\Throwable) {
return null;
diff --git a/src/Internal/Workflow/ActivityStub.php b/src/Internal/Workflow/ActivityStub.php
index 4ecc930a1..e936e8e46 100644
--- a/src/Internal/Workflow/ActivityStub.php
+++ b/src/Internal/Workflow/ActivityStub.php
@@ -12,8 +12,10 @@
namespace Temporal\Internal\Workflow;
use React\Promise\PromiseInterface;
+use Temporal\Activity\ActivityOptions;
use Temporal\Activity\ActivityOptionsInterface;
use Temporal\DataConverter\EncodedValues;
+use Temporal\DataConverter\Type;
use Temporal\Interceptor\Header;
use Temporal\Interceptor\HeaderInterface;
use Temporal\Internal\Marshaller\MarshallerInterface;
@@ -22,7 +24,6 @@
use Temporal\Worker\Transport\Command\RequestInterface;
use Temporal\Workflow;
use Temporal\Workflow\ActivityStubInterface;
-use Temporal\DataConverter\Type;
final class ActivityStub implements ActivityStubInterface
{
@@ -61,9 +62,15 @@ public function execute(
Type|string|\ReflectionClass|\ReflectionType|null $returnType = null,
bool $isLocalActivity = false,
): PromiseInterface {
- $request = $isLocalActivity ?
- new ExecuteLocalActivity($name, EncodedValues::fromValues($args), $this->getOptionsArray(), $this->header) :
- new ExecuteActivity($name, EncodedValues::fromValues($args), $this->getOptionsArray(), $this->header);
+ $taskQueue = $this->options instanceof ActivityOptions
+ ? $this->options->taskQueue
+ : null;
+
+ $arguments = EncodedValues::fromValues($args);
+
+ $request = $isLocalActivity
+ ? new ExecuteLocalActivity($name, $arguments, $this->getOptionsArray(), $this->header, $taskQueue)
+ : new ExecuteActivity($name, $arguments, $this->getOptionsArray(), $this->header, $taskQueue);
return EncodedValues::decodePromise($this->request($request), $returnType);
}
diff --git a/src/Internal/Workflow/ChildWorkflowStub.php b/src/Internal/Workflow/ChildWorkflowStub.php
index a918581f8..6913339c1 100644
--- a/src/Internal/Workflow/ChildWorkflowStub.php
+++ b/src/Internal/Workflow/ChildWorkflowStub.php
@@ -69,11 +69,19 @@ public function start(... $args): PromiseInterface
throw new \LogicException('Child workflow already has been executed');
}
+ $options = $this->options->workflowId === null
+ ? $this->options->withWorkflowId($this->generateChildWorkflowId())
+ : $this->options;
+
+ $arguments = EncodedValues::fromValues($args);
+
$this->request = new ExecuteChildWorkflow(
$this->workflow,
- EncodedValues::fromValues($args),
- $this->getOptionArray(),
+ $arguments,
+ $this->marshaller->marshal($options),
$this->header,
+ $this->resolveNamespace(),
+ $options->workflowId,
);
$cancellable = FeatureFlags::$cancelAbandonedChildWorkflows
@@ -113,16 +121,16 @@ public function signal(string $name, array $args = []): PromiseInterface
{
return $this->execution->promise()->then(
function (WorkflowExecution $execution) use ($name, $args) {
- $request = new SignalExternalWorkflow(
- $this->getOptions()->namespace,
- $execution->getID(),
- null,
- $name,
- EncodedValues::fromValues($args),
- true,
+ return $this->request(
+ new SignalExternalWorkflow(
+ $this->resolveNamespace(),
+ $execution->getID(),
+ null,
+ $name,
+ EncodedValues::fromValues($args),
+ true,
+ ),
);
-
- return $this->request($request);
},
);
}
@@ -132,8 +140,18 @@ protected function request(RequestInterface $request, bool $cancellable = true):
return Workflow::getCurrentContext()->request($request, cancellable: $cancellable);
}
- private function getOptionArray(): array
+ private function generateChildWorkflowId(): string
+ {
+ $context = Workflow::getCurrentContext();
+ \assert($context instanceof WorkflowContext);
+
+ return $context->generateChildWorkflowId();
+ }
+
+ private function resolveNamespace(): string
{
- return $this->marshaller->marshal($this->getOptions());
+ return $this->options->namespace !== ''
+ ? $this->options->namespace
+ : Workflow::getCurrentContext()->getInfo()->namespace;
}
}
diff --git a/src/Internal/Workflow/ExternalWorkflowStub.php b/src/Internal/Workflow/ExternalWorkflowStub.php
index 75b4ff374..aab4ec87f 100644
--- a/src/Internal/Workflow/ExternalWorkflowStub.php
+++ b/src/Internal/Workflow/ExternalWorkflowStub.php
@@ -42,8 +42,8 @@ public function getExecution(): WorkflowExecution
public function signal(string $name, array $args = []): PromiseInterface
{
return $this->callsInterceptor->with(
- fn(SignalExternalWorkflowInput $input): PromiseInterface => $this
- ->request(
+ function (SignalExternalWorkflowInput $input): PromiseInterface {
+ return $this->request(
new SignalExternalWorkflow(
$input->namespace,
$input->workflowId,
@@ -52,7 +52,8 @@ public function signal(string $name, array $args = []): PromiseInterface
$input->input,
$input->childWorkflowOnly,
),
- ),
+ );
+ },
/** @see WorkflowOutboundCallsInterceptor::signalExternalWorkflow() */
'signalExternalWorkflow',
)(new SignalExternalWorkflowInput(
diff --git a/src/Internal/Workflow/ScopeContext.php b/src/Internal/Workflow/ScopeContext.php
index b9a3ebab4..27ee95df9 100644
--- a/src/Internal/Workflow/ScopeContext.php
+++ b/src/Internal/Workflow/ScopeContext.php
@@ -58,6 +58,7 @@ public static function fromWorkflowContext(
$ctx->continueAsNew = $context->continueAsNew;
$ctx->trace = &$context->trace;
$ctx->currentDetails = &$context->currentDetails;
+ $ctx->childWorkflowSequence = &$context->childWorkflowSequence;
return $ctx;
}
diff --git a/src/Internal/Workflow/WorkflowContext.php b/src/Internal/Workflow/WorkflowContext.php
index b32c0c9b2..519386cd9 100644
--- a/src/Internal/Workflow/WorkflowContext.php
+++ b/src/Internal/Workflow/WorkflowContext.php
@@ -24,9 +24,13 @@
use Temporal\Common\SearchAttributes\SearchAttributeUpdate;
use Temporal\Common\SideEffectOptions;
use Temporal\Common\Uuid;
+use Temporal\DataConverter\ActivitySerializationContext;
+use Temporal\DataConverter\DataConverterInterface;
use Temporal\DataConverter\EncodedValues;
+use Temporal\DataConverter\SerializationContext;
use Temporal\DataConverter\Type;
use Temporal\DataConverter\ValuesInterface;
+use Temporal\DataConverter\WorkflowSerializationContext;
use Temporal\Interceptor\HeaderInterface;
use Temporal\Interceptor\WorkflowOutboundCalls\AwaitInput;
use Temporal\Interceptor\WorkflowOutboundCalls\AwaitWithTimeoutInput;
@@ -59,10 +63,14 @@
use Temporal\Internal\Transport\Request\Cancel;
use Temporal\Internal\Transport\Request\CompleteWorkflow;
use Temporal\Internal\Transport\Request\ContinueAsNew;
+use Temporal\Internal\Transport\Request\ExecuteActivity;
+use Temporal\Internal\Transport\Request\ExecuteChildWorkflow;
+use Temporal\Internal\Transport\Request\ExecuteLocalActivity;
use Temporal\Internal\Transport\Request\GetVersion;
use Temporal\Internal\Transport\Request\NewTimer;
use Temporal\Internal\Transport\Request\Panic;
use Temporal\Internal\Transport\Request\SideEffect;
+use Temporal\Internal\Transport\Request\SignalExternalWorkflow;
use Temporal\Internal\Transport\Request\UpsertMemo;
use Temporal\Internal\Transport\Request\UpsertSearchAttributes;
use Temporal\Internal\Transport\Request\UpsertTypedSearchAttributes;
@@ -101,6 +109,8 @@ class WorkflowContext implements WorkflowContextInterface, HeaderCarrier, Destro
protected bool $continueAsNew = false;
protected bool $readonly = true;
protected ?string $currentDetails = null;
+ protected int $childWorkflowSequence = 0;
+ private ?WorkflowSerializationContext $serializationContext = null;
/** @var Pipeline */
private Pipeline $requestInterceptor;
@@ -180,6 +190,8 @@ public function withInput(Input $input): static
$clone->awaits = &$this->awaits;
$clone->trace = &$this->trace;
$clone->input = $input;
+ $clone->serializationContext = null;
+ $clone->childWorkflowSequence = &$this->childWorkflowSequence;
return $clone;
}
@@ -280,10 +292,12 @@ public function sideEffect(callable $context, ?SideEffectOptions $options = null
}
$last = fn(): PromiseInterface => EncodedValues::decodePromise(
- $this->request(new SideEffect(
- EncodedValues::fromValues([$value]),
- $options === null ? [] : $this->services->marshaller->marshal($options),
- )),
+ $this->request(
+ new SideEffect(
+ EncodedValues::fromValues([$value]),
+ $options === null ? [] : $this->services->marshaller->marshal($options),
+ ),
+ ),
$returnType,
);
return $last();
@@ -306,7 +320,10 @@ function (CompleteInput $input): PromiseInterface {
? EncodedValues::fromValues($input->result)
: EncodedValues::empty();
- return $this->request(new CompleteWorkflow($values, $input->failure), false);
+ return $this->request(
+ new CompleteWorkflow($values, $input->failure),
+ false,
+ );
},
/** @see WorkflowOutboundCallsInterceptor::complete() */
'complete',
@@ -495,6 +512,8 @@ public function request(
// Intercept workflow outbound calls
return $this->requestInterceptor->with(
function (RequestInterface $request) use ($waitResponse): PromiseInterface {
+ $this->bindOutboundSerializationContext($request);
+
if (!$waitResponse) {
$this->client->send($request);
return Promise::resolve();
@@ -749,6 +768,27 @@ public function setCurrentDetails(?string $details): void
$this->currentDetails = $details;
}
+ public function getSerializationContext(): WorkflowSerializationContext
+ {
+ return $this->serializationContext ??= WorkflowSerializationContext::fromInfo($this->getInfo());
+ }
+
+ public function generateChildWorkflowId(): string
+ {
+ return $this->getInfo()->execution->getRunID() . '_' . (++$this->childWorkflowSequence);
+ }
+
+ public function getDataConverter(): DataConverterInterface
+ {
+ return $this->services->dataConverter;
+ }
+
+ public function applySerializationContext(ValuesInterface $values): void
+ {
+ $values->setDataConverter($this->getDataConverter());
+ $values->setSerializationContext($this->getSerializationContext());
+ }
+
protected function awaitRequest(callable|Mutex|PromiseInterface ...$conditions): PromiseInterface
{
$result = [];
@@ -816,4 +856,57 @@ protected function recordTrace(): void
{
$this->readonly or $this->trace = \debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS);
}
+
+ private function bindOutboundSerializationContext(RequestInterface $request): void
+ {
+ $context = $this->resolveOutboundSerializationContext($request);
+ if ($context === null) {
+ return;
+ }
+
+ $request->getPayloads()->setSerializationContext($context);
+ }
+
+ private function resolveOutboundSerializationContext(RequestInterface $request): ?SerializationContext
+ {
+ if ($request instanceof CompleteWorkflow
+ || $request instanceof SideEffect
+ || $request instanceof ContinueAsNew
+ ) {
+ return $this->getSerializationContext();
+ }
+
+ $info = $this->getInfo();
+
+ if ($request instanceof ExecuteActivity || $request instanceof ExecuteLocalActivity) {
+ return new ActivitySerializationContext(
+ namespace: $info->namespace,
+ activityType: $request->getActivityName(),
+ taskQueue: $request->getTaskQueue() ?? $info->taskQueue,
+ workflowId: $info->execution->getID(),
+ workflowType: $info->type->name,
+ isLocal: $request instanceof ExecuteLocalActivity,
+ );
+ }
+
+ if ($request instanceof ExecuteChildWorkflow) {
+ $workflowId = $request->getWorkflowId();
+
+ return $workflowId === null
+ ? null
+ : new WorkflowSerializationContext(
+ $request->getNamespace() !== '' ? $request->getNamespace() : $info->namespace,
+ $workflowId,
+ );
+ }
+
+ if ($request instanceof SignalExternalWorkflow) {
+ return new WorkflowSerializationContext(
+ $request->getNamespace() !== '' ? $request->getNamespace() : $info->namespace,
+ $request->getWorkflowId(),
+ );
+ }
+
+ return null;
+ }
}
diff --git a/src/Worker/FeatureFlags.php b/src/Worker/FeatureFlags.php
index 66557df0b..b614c5cea 100644
--- a/src/Worker/FeatureFlags.php
+++ b/src/Worker/FeatureFlags.php
@@ -78,4 +78,17 @@ final class FeatureFlags
* @link https://github.com/temporalio/sdk-php/issues/769
*/
public static bool $propagateCancellationToNewScopes = false;
+
+ /**
+ * Move the failure message and stack trace into the `encoded_attributes` payload, so that they
+ * pass through the Data Converter and can be encrypted along with the rest of the payloads.
+ * The plain fields are replaced with `Encoded failure` and an empty stack trace.
+ *
+ * Encoded attributes are always decoded back, no matter the value of this flag.
+ *
+ * @experimental
+ * @since SDK 2.18.0
+ * @link https://github.com/temporalio/sdk-php/issues/454
+ */
+ public static bool $encodeFailureAttributes = false;
}
diff --git a/src/Worker/Transport/Codec/JsonCodec/Encoder.php b/src/Worker/Transport/Codec/JsonCodec/Encoder.php
index aeb027254..ea345ba59 100644
--- a/src/Worker/Transport/Codec/JsonCodec/Encoder.php
+++ b/src/Worker/Transport/Codec/JsonCodec/Encoder.php
@@ -32,6 +32,13 @@ public function __construct(DataConverterInterface $dataConverter)
public function encode(CommandInterface $cmd): array
{
+ $payloads = match (true) {
+ $cmd instanceof RequestInterface,
+ $cmd instanceof SuccessResponseInterface => $cmd->getPayloads(),
+ default => null,
+ };
+ $context = $payloads?->getSerializationContext();
+
switch (true) {
case $cmd instanceof RequestInterface:
$cmd->getPayloads()->setDataConverter($this->converter);
@@ -54,7 +61,7 @@ public function encode(CommandInterface $cmd): array
];
if ($cmd->getFailure() !== null) {
- $failure = FailureConverter::mapExceptionToFailure($cmd->getFailure(), $this->converter);
+ $failure = FailureConverter::mapExceptionToFailure($cmd->getFailure(), $this->converter, $context);
$data['failure'] = \base64_encode($failure->serializeToString());
}
diff --git a/src/Worker/Transport/Codec/ProtoCodec/Encoder.php b/src/Worker/Transport/Codec/ProtoCodec/Encoder.php
index f059074a8..355f1edfc 100644
--- a/src/Worker/Transport/Codec/ProtoCodec/Encoder.php
+++ b/src/Worker/Transport/Codec/ProtoCodec/Encoder.php
@@ -35,6 +35,13 @@ public function __construct(
public function encode(CommandInterface $cmd): Message
{
$msg = new Message();
+ $payloads = match (true) {
+ $cmd instanceof RequestInterface,
+ $cmd instanceof SuccessResponseInterface,
+ $cmd instanceof UpdateResponse => $cmd->getPayloads(),
+ default => null,
+ };
+ $context = $payloads?->getSerializationContext();
switch (true) {
case $cmd instanceof RequestInterface:
@@ -56,7 +63,7 @@ public function encode(CommandInterface $cmd): Message
$msg->setHeader($header->toHeader());
if ($cmd->getFailure() !== null) {
- $msg->setFailure(FailureConverter::mapExceptionToFailure($cmd->getFailure(), $this->converter));
+ $msg->setFailure(FailureConverter::mapExceptionToFailure($cmd->getFailure(), $this->converter, $context));
}
return $msg;
@@ -78,13 +85,14 @@ public function encode(CommandInterface $cmd): Message
$msg->setCommand($cmd->getCommand());
$msg->setOptions(\json_encode($cmd->getOptions(), JSON_INVALID_UTF8_IGNORE | JSON_UNESCAPED_UNICODE));
+ $updatePayloads = $cmd->getPayloads();
if ($cmd->getFailure() !== null) {
- $msg->setFailure(FailureConverter::mapExceptionToFailure($cmd->getFailure(), $this->converter));
+ $msg->setFailure(FailureConverter::mapExceptionToFailure($cmd->getFailure(), $this->converter, $context));
}
- if ($cmd->getPayloads() !== null) {
- $cmd->getPayloads()->setDataConverter($this->converter);
- $msg->setPayloads($cmd->getPayloads()->toPayloads());
+ if ($updatePayloads !== null) {
+ $updatePayloads->setDataConverter($this->converter);
+ $msg->setPayloads($updatePayloads->toPayloads());
}
return $msg;
diff --git a/tests/Acceptance/Extra/DataConverter/SerializationContextTest.php b/tests/Acceptance/Extra/DataConverter/SerializationContextTest.php
new file mode 100644
index 000000000..2d5afb4d6
--- /dev/null
+++ b/tests/Acceptance/Extra/DataConverter/SerializationContextTest.php
@@ -0,0 +1,558 @@
+interceptor = new CapturingInterceptor();
+ parent::setUp();
+ }
+
+ public function pipelineProvider(): PipelineProvider
+ {
+ return new SimplePipelineProvider([$this->interceptor]);
+ }
+
+ #[Test]
+ public function everyPayloadIsSignedWithItsWorkflowContext(
+ #[Stub(
+ 'Extra_DataConverter_SerializationContext',
+ args: [new SignedDto('hello')],
+ )]
+ #[Client(
+ pipelineProvider: [self::class, 'pipelineProvider'],
+ payloadConverters: [SignedPayloadConverter::class],
+ )]
+ WorkflowStubInterface $stub,
+ ): void {
+ $result = $stub->getResult(SignedDto::class);
+
+ self::assertInstanceOf(SignedDto::class, $result);
+ self::assertSame('echo:hello', $result->value);
+
+ $workflowId = $stub->getExecution()->getID();
+
+ $start = $this->interceptor->start;
+ $finish = $this->interceptor->result;
+ self::assertNotNull($start);
+ self::assertNotNull($finish);
+
+ $startPayload = $start->toPayloads()->getPayloads()[0];
+ self::assertSame(SIGNED_ENCODING, $startPayload->getMetadata()['encoding']);
+ self::assertSame($workflowId, $startPayload->getMetadata()['signature']);
+
+ $resultPayload = $finish->toPayloads()->getPayloads()[0];
+ self::assertSame(SIGNED_ENCODING, $resultPayload->getMetadata()['encoding']);
+ self::assertSame($workflowId, $resultPayload->getMetadata()['signature']);
+ }
+
+ #[Test]
+ public function workflowFailureDetailsDecodeWithWorkflowContext(
+ #[Stub('Extra_DataConverter_SerializationContext_Failure')]
+ #[Client(payloadConverters: [SignedPayloadConverter::class])]
+ WorkflowStubInterface $stub,
+ ): void {
+ try {
+ $stub->getResult();
+ self::fail('Expected the workflow to fail');
+ } catch (WorkflowFailedException $e) {
+ $cause = $e->getPrevious();
+ self::assertInstanceOf(ApplicationFailure::class, $cause);
+
+ $detail = $cause->getDetails()->getValue(0, SignedDto::class);
+ self::assertInstanceOf(SignedDto::class, $detail);
+ self::assertSame('boom', $detail->value);
+ }
+ }
+
+ #[Test]
+ public function activityFailureDetailsDecodeWithActivityContext(
+ #[Stub('Extra_DataConverter_SerializationContext_ActivityFailure')]
+ #[Client(payloadConverters: [SignedPayloadConverter::class])]
+ WorkflowStubInterface $stub,
+ ): void {
+ self::assertSame('activity-detail', $stub->getResult(Type::TYPE_STRING));
+ }
+
+ #[Test]
+ public function signalQueryUpdateCarryWorkflowContext(
+ #[Stub('Extra_DataConverter_SerializationContext_Interactive')]
+ #[Client(payloadConverters: [SignedPayloadConverter::class])]
+ WorkflowStubInterface $stub,
+ ): void {
+ $stub->signal('store', new SignedDto('signalled'));
+
+ $queried = $stub->query('current')->getValue(0, SignedDto::class);
+ self::assertInstanceOf(SignedDto::class, $queried);
+ self::assertSame('signalled', $queried->value);
+
+ $previous = $stub->update('replace', new SignedDto('updated'))->getValue(0, SignedDto::class);
+ self::assertInstanceOf(SignedDto::class, $previous);
+ self::assertSame('signalled', $previous->value);
+
+ $stub->signal('finish');
+
+ $result = $stub->getResult(SignedDto::class);
+ self::assertInstanceOf(SignedDto::class, $result);
+ self::assertSame('updated', $result->value);
+ }
+
+ #[Test]
+ public function continueAsNewArgumentsCarryWorkflowContext(
+ #[Stub(
+ 'Extra_DataConverter_SerializationContext_ContinueAsNew',
+ args: [new SignedDto('first')],
+ )]
+ #[Client(payloadConverters: [SignedPayloadConverter::class])]
+ WorkflowStubInterface $stub,
+ ): void {
+ $result = $stub->getResult(SignedDto::class);
+
+ self::assertInstanceOf(SignedDto::class, $result);
+ self::assertSame('first-continued', $result->value);
+ }
+
+ #[Test]
+ public function heartbeatDetailsCarryActivityContext(
+ #[Stub('Extra_DataConverter_SerializationContext_Heartbeat')]
+ #[Client(payloadConverters: [SignedPayloadConverter::class])]
+ WorkflowStubInterface $stub,
+ ): void {
+ $result = $stub->getResult(SignedDto::class);
+
+ self::assertInstanceOf(SignedDto::class, $result);
+ self::assertSame('beat', $result->value);
+ }
+
+ #[Test]
+ public function startMemoDecodesWithWorkflowContext(
+ #[Stub(
+ 'Extra_DataConverter_SerializationContext_Memo',
+ memo: ['ser-ctx-memo' => new SignedDto('memo-value')],
+ )]
+ #[Client(payloadConverters: [SignedPayloadConverter::class])]
+ WorkflowStubInterface $stub,
+ ): void {
+ self::assertSame('memo-value', $stub->getResult(Type::TYPE_STRING));
+ }
+
+ #[Test]
+ public function scheduleActionInputAndMemoCarryWorkflowContext(
+ WorkflowClientInterface $client,
+ ): void {
+ $converter = new DataConverter(
+ new NullConverter(),
+ new BinaryConverter(),
+ new ProtoJsonConverter(),
+ new ProtoConverter(),
+ new SignedPayloadConverter(),
+ new JsonConverter(),
+ );
+
+ $scheduleClient = new ScheduleClient($client->getServiceClient(), null, $converter);
+
+ $workflowId = 'Extra_DataConverter_SerializationContext_Schedule-' . \uniqid();
+ $scheduleId = 'sctx-schedule-' . \uniqid();
+
+ $handle = $scheduleClient->createSchedule(
+ Schedule::new()
+ ->withAction(
+ StartWorkflowAction::new('Extra_DataConverter_SerializationContext')
+ ->withWorkflowId($workflowId)
+ ->withInput([new SignedDto('scheduled-input')])
+ ->withMemo(['note' => new SignedDto('scheduled-memo')]),
+ )
+ ->withSpec(ScheduleSpec::new()->withStartTime('+1 hour'))
+ ->withState(ScheduleState::new()->withPaused(true)),
+ ScheduleOptions::new(),
+ $scheduleId,
+ );
+
+ try {
+ $action = $handle->describe()->schedule->action;
+ self::assertInstanceOf(StartWorkflowAction::class, $action);
+
+ $input = $action->input->getValue(0, SignedDto::class);
+ self::assertInstanceOf(SignedDto::class, $input);
+ self::assertSame('scheduled-input', $input->value);
+
+ $memo = $action->memo->getValue('note', SignedDto::class);
+ self::assertInstanceOf(SignedDto::class, $memo);
+ self::assertSame('scheduled-memo', $memo->value);
+ } finally {
+ $handle->delete();
+ }
+ }
+}
+
+final class SignedDto
+{
+ public function __construct(
+ public string $value,
+ ) {}
+}
+
+#[WorkflowInterface]
+class FeatureWorkflow
+{
+ #[WorkflowMethod(name: 'Extra_DataConverter_SerializationContext')]
+ public function handle(SignedDto $input)
+ {
+ yield Workflow::sideEffect(static fn(): SignedDto => new SignedDto($input->value . '-side'));
+
+ $fromActivity = yield Workflow::executeActivity(
+ 'Extra_DataConverter_SerializationContext.echo',
+ [$input],
+ ActivityOptions::new()->withScheduleToCloseTimeout(10),
+ SignedDto::class,
+ );
+
+ yield Workflow::executeActivity(
+ 'Extra_DataConverter_SerializationContext.Local.echo',
+ [$input],
+ LocalActivityOptions::new()->withScheduleToCloseTimeout(10),
+ SignedDto::class,
+ );
+
+ if (Workflow::getInfo()->parentExecution === null) {
+ $child = Workflow::newUntypedChildWorkflowStub(
+ 'Extra_DataConverter_SerializationContext',
+ ChildWorkflowOptions::new()
+ ->withWorkflowId(Workflow::getInfo()->execution->getID() . '-child'),
+ );
+ yield $child->execute([$input], SignedDto::class);
+ }
+
+ return $fromActivity;
+ }
+}
+
+#[ActivityInterface('Extra_DataConverter_SerializationContext.')]
+class FeatureActivity
+{
+ public function echo(SignedDto $input): SignedDto
+ {
+ return new SignedDto('echo:' . $input->value);
+ }
+}
+
+#[ActivityInterface('Extra_DataConverter_SerializationContext.Local.')]
+class FeatureLocalActivity
+{
+ public function echo(SignedDto $input): SignedDto
+ {
+ return new SignedDto('echo:' . $input->value);
+ }
+}
+
+#[WorkflowInterface]
+class FailingWorkflow
+{
+ #[WorkflowMethod(name: 'Extra_DataConverter_SerializationContext_Failure')]
+ public function handle()
+ {
+ yield Workflow::timer(1);
+
+ throw new ApplicationFailure(
+ 'boom',
+ 'BoomType',
+ true,
+ EncodedValues::fromValues([new SignedDto('boom')]),
+ );
+ }
+}
+
+#[WorkflowInterface]
+class ActivityFailureWorkflow
+{
+ #[WorkflowMethod(name: 'Extra_DataConverter_SerializationContext_ActivityFailure')]
+ public function handle()
+ {
+ try {
+ yield Workflow::executeActivity(
+ 'Extra_DataConverter_SerializationContext_Failure.fail',
+ [],
+ ActivityOptions::new()->withScheduleToCloseTimeout(10),
+ SignedDto::class,
+ );
+
+ return 'no-failure';
+ } catch (ActivityFailure $e) {
+ $application = $e->getPrevious();
+ if (!$application instanceof ApplicationFailure) {
+ throw new \RuntimeException('Expected ApplicationFailure cause');
+ }
+
+ return $application->getDetails()->getValue(0, SignedDto::class)->value;
+ }
+ }
+}
+
+#[ActivityInterface('Extra_DataConverter_SerializationContext_Failure.')]
+class FailingActivity
+{
+ public function fail(): SignedDto
+ {
+ throw new ApplicationFailure(
+ 'activity-boom',
+ 'BoomType',
+ true,
+ EncodedValues::fromValues([new SignedDto('activity-detail')]),
+ );
+ }
+}
+
+#[WorkflowInterface]
+class InteractiveWorkflow
+{
+ private ?SignedDto $stored = null;
+ private bool $exit = false;
+
+ #[WorkflowMethod(name: 'Extra_DataConverter_SerializationContext_Interactive')]
+ public function handle()
+ {
+ yield Workflow::await(fn(): bool => $this->exit);
+
+ return $this->stored;
+ }
+
+ #[Workflow\SignalMethod(name: 'store')]
+ public function store(SignedDto $value): void
+ {
+ $this->stored = $value;
+ }
+
+ #[Workflow\QueryMethod(name: 'current')]
+ public function current(): ?SignedDto
+ {
+ return $this->stored;
+ }
+
+ #[Workflow\UpdateMethod(name: 'replace')]
+ public function replace(SignedDto $value): SignedDto
+ {
+ $previous = $this->stored ?? new SignedDto('none');
+ $this->stored = $value;
+
+ return $previous;
+ }
+
+ #[Workflow\SignalMethod(name: 'finish')]
+ public function finish(): void
+ {
+ $this->exit = true;
+ }
+}
+
+#[WorkflowInterface]
+class ContinueAsNewWorkflow
+{
+ #[WorkflowMethod(name: 'Extra_DataConverter_SerializationContext_ContinueAsNew')]
+ public function handle(SignedDto $input)
+ {
+ if (!empty(Workflow::getInfo()->continuedExecutionRunId)) {
+ return $input;
+ }
+
+ return yield Workflow::continueAsNew(
+ 'Extra_DataConverter_SerializationContext_ContinueAsNew',
+ args: [new SignedDto($input->value . '-continued')],
+ );
+ }
+}
+
+#[WorkflowInterface]
+class MemoWorkflow
+{
+ #[WorkflowMethod(name: 'Extra_DataConverter_SerializationContext_Memo')]
+ public function handle()
+ {
+ /** @var SignedDto $memo */
+ $memo = Workflow::getInfo()->memo['ser-ctx-memo'];
+
+ return $memo->value;
+ }
+}
+
+#[WorkflowInterface]
+class HeartbeatWorkflow
+{
+ #[WorkflowMethod(name: 'Extra_DataConverter_SerializationContext_Heartbeat')]
+ public function handle()
+ {
+ return yield Workflow::executeActivity(
+ 'Extra_DataConverter_SerializationContext_Heartbeat.run',
+ [],
+ ActivityOptions::new()
+ ->withScheduleToCloseTimeout(20)
+ ->withHeartbeatTimeout(10)
+ ->withRetryOptions(
+ RetryOptions::new()
+ ->withInitialInterval(1)
+ ->withBackoffCoefficient(1)
+ ->withMaximumAttempts(2),
+ ),
+ SignedDto::class,
+ );
+ }
+}
+
+#[ActivityInterface('Extra_DataConverter_SerializationContext_Heartbeat.')]
+class HeartbeatActivity
+{
+ public function run(): SignedDto
+ {
+ if (Activity::hasHeartbeatDetails()) {
+ return Activity::getHeartbeatDetails(SignedDto::class);
+ }
+
+ Activity::heartbeat(new SignedDto('beat'));
+
+ throw new \RuntimeException('retry to read heartbeat details');
+ }
+}
+
+class CapturingInterceptor implements WorkflowClientCallsInterceptor
+{
+ use WorkflowClientCallsInterceptorTrait;
+
+ public ?EncodedValues $start = null;
+ public ?EncodedValues $result = null;
+
+ public function start(StartInput $input, callable $next): WorkflowExecution
+ {
+ $this->start = $input->arguments;
+ return $next($input);
+ }
+
+ public function getResult(GetResultInput $input, callable $next): ?EncodedValues
+ {
+ return $this->result = $next($input);
+ }
+}
+
+class SignedPayloadConverter implements PayloadConverterInterface, SerializationContextAwareInterface
+{
+ private ?SerializationContext $context = null;
+
+ public function getSerializationContext(): ?SerializationContext
+ {
+ return $this->context;
+ }
+
+ public function withSerializationContext(?SerializationContext $context): static
+ {
+ $clone = clone $this;
+ $clone->context = $context;
+ return $clone;
+ }
+
+ public function getEncodingType(): string
+ {
+ return SIGNED_ENCODING;
+ }
+
+ public function toPayload($value): ?Payload
+ {
+ if (!$value instanceof SignedDto) {
+ return null;
+ }
+
+ if ($this->context === null) {
+ throw new \LogicException('SignedDto serialized without a serialization context');
+ }
+
+ return (new Payload())
+ ->setData(\json_encode($value->value, \JSON_THROW_ON_ERROR))
+ ->setMetadata([
+ 'encoding' => SIGNED_ENCODING,
+ 'signature' => $this->signature($this->context),
+ ]);
+ }
+
+ public function fromPayload(Payload $payload, Type $type): SignedDto
+ {
+ if ($this->context === null) {
+ throw new \LogicException('Signed payload decoded without a serialization context');
+ }
+
+ $metadata = $payload->getMetadata();
+ $actual = isset($metadata['signature']) ? $metadata['signature'] : '';
+ $expected = $this->signature($this->context);
+
+ if ($actual !== $expected) {
+ throw new \RuntimeException(
+ \sprintf('Signature mismatch: expected "%s", got "%s"', $expected, $actual),
+ );
+ }
+
+ return new SignedDto((string) \json_decode($payload->getData(), true, flags: \JSON_THROW_ON_ERROR));
+ }
+
+ private function signature(SerializationContext $context): string
+ {
+ if ($context instanceof ActivitySerializationContext) {
+ return (string) $context->workflowId . ':' . (string) $context->activityType;
+ }
+
+ if ($context instanceof HasWorkflowSerializationContext) {
+ return (string) $context->getWorkflowId();
+ }
+
+ return '';
+ }
+}
diff --git a/tests/Fixtures/data/Test_ExecuteChildStubWorkflow.log b/tests/Fixtures/data/Test_ExecuteChildStubWorkflow.log
index 265dbb1fb..30b34cea7 100644
--- a/tests/Fixtures/data/Test_ExecuteChildStubWorkflow.log
+++ b/tests/Fixtures/data/Test_ExecuteChildStubWorkflow.log
@@ -1,5 +1,5 @@
2021/01/12 15:32:01 [97mDEBUG[0m [{"command":"StartWorkflow","options":{"info":{"WorkflowExecution":{"ID":"dcbf33d0-cef0-427f-9120-e7be3b0b1d85","RunID":"465c2217-8482-4079-8d22-8c3b81f88d71"},"WorkflowType":{"Name":"WithChildStubWorkflow"},"TaskQueueName":"default","WorkflowExecutionTimeout":315360000000000000,"WorkflowRunTimeout":315360000000000000,"WorkflowTaskTimeout":0,"Namespace":"default","Attempt":1,"CronSchedule":"","ContinuedExecutionRunID":"","ParentWorkflowNamespace":"","ParentWorkflowExecution":null,"Memo":null,"SearchAttributes":null,"BinaryChecksum":"e5f098283d9223921afdec88ef72a0b5"}},"payloads":"CicKFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SDSJIZWxsbyBXb3JsZCI="}] {"taskQueue":"default","tickTime":"2021-01-12T15:32:01.297781Z"}
-2021/01/12 15:32:01 [97mDEBUG[0m [{"id":9001,"command":"ExecuteChildWorkflow","options":{"name":"SimpleWorkflow","options":{"Namespace":"default","WorkflowID":null,"TaskQueueName":"default","WorkflowExecutionTimeout":0,"WorkflowRunTimeout":0,"WorkflowTaskTimeout":0,"WaitForCancellation":false,"WorkflowIDReusePolicy":2,"RetryPolicy":null,"CronSchedule":null,"ParentClosePolicy":1,"Memo":null,"SearchAttributes":null,"StaticDetails":"","StaticSummary":"","Priority":{"priority_key":0,"fairness_key":"","fairness_weight":0}}},"payloads":"Ci0KFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SEyJjaGlsZCBIZWxsbyBXb3JsZCI=","header":""},{"id":9002,"command":"GetChildWorkflowExecution","options":{"id":9001},"payloads":"","header":""},{"payloads":"ChkKFwoIZW5jb2RpbmcSC2JpbmFyeS9udWxs"}] {"receive": true}
+2021/01/12 15:32:01 [97mDEBUG[0m [{"id":9001,"command":"ExecuteChildWorkflow","options":{"name":"SimpleWorkflow","options":{"Namespace":"default","WorkflowID":"465c2217-8482-4079-8d22-8c3b81f88d71_1","TaskQueueName":"default","WorkflowExecutionTimeout":0,"WorkflowRunTimeout":0,"WorkflowTaskTimeout":0,"WaitForCancellation":false,"WorkflowIDReusePolicy":2,"RetryPolicy":null,"CronSchedule":null,"ParentClosePolicy":1,"Memo":null,"SearchAttributes":null,"StaticDetails":"","StaticSummary":"","Priority":{"priority_key":0,"fairness_key":"","fairness_weight":0}}},"payloads":"Ci0KFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SEyJjaGlsZCBIZWxsbyBXb3JsZCI=","header":""},{"id":9002,"command":"GetChildWorkflowExecution","options":{"id":9001},"payloads":"","header":""},{"payloads":"ChkKFwoIZW5jb2RpbmcSC2JpbmFyeS9udWxs"}] {"receive": true}
2021/01/12 15:32:01 [97mDEBUG[0m [{"id":9002,"payloads":"CngKFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SXnsiSUQiOiI0NjVjMjIxNy04NDgyLTQwNzktOGQyMi04YzNiODFmODhkNzFfMSIsIlJ1bklEIjoiOTViMGRhYjItYjJlMi00M2U1LWI2OTMtODQ3MGFmMDI2ZjdhIn0="}] {"taskQueue":"default","tickTime":"2021-01-12T15:32:01.3789815Z"}
2021/01/12 15:32:01 [97mDEBUG[0m [{"command":"StartWorkflow","options":{"info":{"WorkflowExecution":{"ID":"465c2217-8482-4079-8d22-8c3b81f88d71_1","RunID":"95b0dab2-b2e2-43e5-b693-8470af026f7a"},"WorkflowType":{"Name":"SimpleWorkflow"},"TaskQueueName":"default","WorkflowExecutionTimeout":0,"WorkflowRunTimeout":0,"WorkflowTaskTimeout":0,"Namespace":"default","Attempt":1,"CronSchedule":"","ContinuedExecutionRunID":"","ParentWorkflowNamespace":"default","ParentWorkflowExecution":{"ID":"dcbf33d0-cef0-427f-9120-e7be3b0b1d85","RunID":"465c2217-8482-4079-8d22-8c3b81f88d71"},"Memo":null,"SearchAttributes":null,"BinaryChecksum":"e5f098283d9223921afdec88ef72a0b5"}},"payloads":"Ci0KFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SEyJjaGlsZCBIZWxsbyBXb3JsZCI=","header":""}] {"taskQueue":"default","tickTime":"2021-01-12T15:32:01.380671Z"}
2021/01/12 15:32:01 [97mDEBUG[0m [] {"receive": true}
diff --git a/tests/Fixtures/data/Test_ExecuteChildStubWorkflow_02.log b/tests/Fixtures/data/Test_ExecuteChildStubWorkflow_02.log
index ee1a283e4..919e28e5f 100644
--- a/tests/Fixtures/data/Test_ExecuteChildStubWorkflow_02.log
+++ b/tests/Fixtures/data/Test_ExecuteChildStubWorkflow_02.log
@@ -1,5 +1,5 @@
2021/01/12 15:28:48 [97mDEBUG[0m [{"command":"StartWorkflow","options":{"info":{"WorkflowExecution":{"ID":"33ca9c17-4933-4b0f-b955-44f2c1c6879f","RunID":"4fbb17b0-ade0-4838-afe5-3f3c4825dde7"},"WorkflowType":{"Name":"ChildStubWorkflow"},"TaskQueueName":"default","WorkflowExecutionTimeout":315360000000000000,"WorkflowRunTimeout":315360000000000000,"WorkflowTaskTimeout":0,"Namespace":"default","Attempt":1,"CronSchedule":"","ContinuedExecutionRunID":"","ParentWorkflowNamespace":"","ParentWorkflowExecution":null,"Memo":null,"SearchAttributes":null,"BinaryChecksum":"024f5bcddf20e7cfe005c52ebf6c3934"}},"payloads":"CicKFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SDSJIZWxsbyBXb3JsZCI="}] {"taskQueue":"default","tickTime":"2021-01-12T15:28:48.0157251Z"}
-2021/01/12 15:28:48 [97mDEBUG[0m [{"id":9001,"command":"ExecuteChildWorkflow","options":{"name":"SimpleWorkflow","options":{"Namespace":"default","WorkflowID":null,"TaskQueueName":"default","WorkflowExecutionTimeout":0,"WorkflowRunTimeout":0,"WorkflowTaskTimeout":0,"WaitForCancellation":false,"WorkflowIDReusePolicy":2,"RetryPolicy":null,"CronSchedule":null,"ParentClosePolicy":1,"Memo":null,"SearchAttributes":null,"StaticDetails":"","StaticSummary":"","Priority":{"priority_key":0,"fairness_key":"","fairness_weight":0}}},"payloads":"CicKFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SDSJIZWxsbyBXb3JsZCI=","header":""},{"id":9002,"command":"GetChildWorkflowExecution","options":{"id":9001},"payloads":"","header":""},{"payloads":"ChkKFwoIZW5jb2RpbmcSC2JpbmFyeS9udWxs"}] {"receive": true}
+2021/01/12 15:28:48 [97mDEBUG[0m [{"id":9001,"command":"ExecuteChildWorkflow","options":{"name":"SimpleWorkflow","options":{"Namespace":"default","WorkflowID":"4fbb17b0-ade0-4838-afe5-3f3c4825dde7_1","TaskQueueName":"default","WorkflowExecutionTimeout":0,"WorkflowRunTimeout":0,"WorkflowTaskTimeout":0,"WaitForCancellation":false,"WorkflowIDReusePolicy":2,"RetryPolicy":null,"CronSchedule":null,"ParentClosePolicy":1,"Memo":null,"SearchAttributes":null,"StaticDetails":"","StaticSummary":"","Priority":{"priority_key":0,"fairness_key":"","fairness_weight":0}}},"payloads":"CicKFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SDSJIZWxsbyBXb3JsZCI=","header":""},{"id":9002,"command":"GetChildWorkflowExecution","options":{"id":9001},"payloads":"","header":""},{"payloads":"ChkKFwoIZW5jb2RpbmcSC2JpbmFyeS9udWxs"}] {"receive": true}
2021/01/12 15:28:48 [97mDEBUG[0m [{"id":9002,"payloads":"CngKFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SXnsiSUQiOiI0ZmJiMTdiMC1hZGUwLTQ4MzgtYWZlNS0zZjNjNDgyNWRkZTdfMSIsIlJ1bklEIjoiNDA5NGI0OTItNTdiMy00MTdhLWE4ZjAtMzBjMWNmMzQ3MDk4In0="}] {"taskQueue":"default","tickTime":"2021-01-12T15:28:48.0958441Z"}
2021/01/12 15:28:48 [97mDEBUG[0m [{"command":"StartWorkflow","options":{"info":{"WorkflowExecution":{"ID":"4fbb17b0-ade0-4838-afe5-3f3c4825dde7_1","RunID":"4094b492-57b3-417a-a8f0-30c1cf347098"},"WorkflowType":{"Name":"SimpleWorkflow"},"TaskQueueName":"default","WorkflowExecutionTimeout":0,"WorkflowRunTimeout":0,"WorkflowTaskTimeout":0,"Namespace":"default","Attempt":1,"CronSchedule":"","ContinuedExecutionRunID":"","ParentWorkflowNamespace":"default","ParentWorkflowExecution":{"ID":"33ca9c17-4933-4b0f-b955-44f2c1c6879f","RunID":"4fbb17b0-ade0-4838-afe5-3f3c4825dde7"},"Memo":null,"SearchAttributes":null,"BinaryChecksum":"024f5bcddf20e7cfe005c52ebf6c3934"}},"payloads":"CicKFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SDSJIZWxsbyBXb3JsZCI="}] {"taskQueue":"default","tickTime":"2021-01-12T15:28:48.0974953Z"}
2021/01/12 15:28:48 [97mDEBUG[0m [] {"receive": true}
@@ -9,7 +9,7 @@
2021/01/12 15:28:48 [97mDEBUG[0m [{"id":9004,"payloads":"CiUKFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SCyJjb21wbGV0ZWQi"},{"command":"DestroyWorkflow","options":{"runId":"4094b492-57b3-417a-a8f0-30c1cf347098"}}] {"taskQueue":"default","tickTime":"2021-01-12T15:28:48.1645485Z","replay":true}
2021/01/12 15:28:48 [97mDEBUG[0m [{"payloads":"ChkKFwoIZW5jb2RpbmcSC2JpbmFyeS9udWxs"}] {"receive": true}
2021/01/12 15:28:48 [97mDEBUG[0m [{"id":9001,"payloads":"CicKFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SDSJIRUxMTyBXT1JMRCI="}] {"taskQueue":"default","tickTime":"2021-01-12T15:28:48.1952129Z"}
-2021/01/12 15:28:48 [97mDEBUG[0m [{"id":9005,"command":"ExecuteChildWorkflow","options":{"name":"SimpleWorkflow","options":{"Namespace":"default","WorkflowID":null,"TaskQueueName":"default","WorkflowExecutionTimeout":0,"WorkflowRunTimeout":0,"WorkflowTaskTimeout":0,"WaitForCancellation":false,"WorkflowIDReusePolicy":2,"RetryPolicy":null,"CronSchedule":null,"ParentClosePolicy":1,"Memo":null,"SearchAttributes":null,"StaticDetails":"","StaticSummary":"","Priority":{"priority_key":0,"fairness_key":"","fairness_weight":0}}},"payloads":"CiMKFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SCSJ1bnR5cGVkIg==","header":""},{"id":9006,"command":"GetChildWorkflowExecution","options":{"id":9005},"payloads":"","header":""}] {"receive": true}
+2021/01/12 15:28:48 [97mDEBUG[0m [{"id":9005,"command":"ExecuteChildWorkflow","options":{"name":"SimpleWorkflow","options":{"Namespace":"default","WorkflowID":"4fbb17b0-ade0-4838-afe5-3f3c4825dde7_2","TaskQueueName":"default","WorkflowExecutionTimeout":0,"WorkflowRunTimeout":0,"WorkflowTaskTimeout":0,"WaitForCancellation":false,"WorkflowIDReusePolicy":2,"RetryPolicy":null,"CronSchedule":null,"ParentClosePolicy":1,"Memo":null,"SearchAttributes":null,"StaticDetails":"","StaticSummary":"","Priority":{"priority_key":0,"fairness_key":"","fairness_weight":0}}},"payloads":"CiMKFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SCSJ1bnR5cGVkIg==","header":""},{"id":9006,"command":"GetChildWorkflowExecution","options":{"id":9005},"payloads":"","header":""}] {"receive": true}
2021/01/12 15:28:48 [97mDEBUG[0m [{"id":9006,"payloads":"CngKFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SXnsiSUQiOiI0ZmJiMTdiMC1hZGUwLTQ4MzgtYWZlNS0zZjNjNDgyNWRkZTdfMiIsIlJ1bklEIjoiZmJiMTNiMDEtOTMxNC00YWJmLWIwZjAtNTJkODcwMzc4MDc4In0="}] {"taskQueue":"default","tickTime":"2021-01-12T15:28:48.2339253Z"}
2021/01/12 15:28:48 [97mDEBUG[0m [{"command":"StartWorkflow","options":{"info":{"WorkflowExecution":{"ID":"4fbb17b0-ade0-4838-afe5-3f3c4825dde7_2","RunID":"fbb13b01-9314-4abf-b0f0-52d870378078"},"WorkflowType":{"Name":"SimpleWorkflow"},"TaskQueueName":"default","WorkflowExecutionTimeout":0,"WorkflowRunTimeout":0,"WorkflowTaskTimeout":0,"Namespace":"default","Attempt":1,"CronSchedule":"","ContinuedExecutionRunID":"","ParentWorkflowNamespace":"default","ParentWorkflowExecution":{"ID":"33ca9c17-4933-4b0f-b955-44f2c1c6879f","RunID":"4fbb17b0-ade0-4838-afe5-3f3c4825dde7"},"Memo":null,"SearchAttributes":null,"BinaryChecksum":"024f5bcddf20e7cfe005c52ebf6c3934"}},"payloads":"CiMKFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SCSJ1bnR5cGVkIg==","header":""}] {"taskQueue":"default","tickTime":"2021-01-12T15:28:48.2351678Z"}
2021/01/12 15:28:48 [97mDEBUG[0m [] {"receive": true}
diff --git a/tests/Fixtures/data/Test_ExecuteChildWorkflow.log b/tests/Fixtures/data/Test_ExecuteChildWorkflow.log
index 3ee7e41bb..b8ceeaa14 100644
--- a/tests/Fixtures/data/Test_ExecuteChildWorkflow.log
+++ b/tests/Fixtures/data/Test_ExecuteChildWorkflow.log
@@ -1,5 +1,5 @@
2021/01/12 15:29:11 [97mDEBUG[0m [{"command":"StartWorkflow","options":{"info":{"WorkflowExecution":{"ID":"ab0dac3f-df7e-44ea-9964-c67aadfffcbf","RunID":"eede8d74-cabb-4c49-bd0d-b87f14306d33"},"WorkflowType":{"Name":"WithChildWorkflow"},"TaskQueueName":"default","WorkflowExecutionTimeout":315360000000000000,"WorkflowRunTimeout":315360000000000000,"WorkflowTaskTimeout":0,"Namespace":"default","Attempt":1,"CronSchedule":"","ContinuedExecutionRunID":"","ParentWorkflowNamespace":"","ParentWorkflowExecution":null,"Memo":null,"SearchAttributes":null,"BinaryChecksum":"0843e92328c0fb62c02212ed103edb4d"}},"payloads":"CicKFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SDSJIZWxsbyBXb3JsZCI="}] {"taskQueue":"default","tickTime":"2021-01-12T15:29:11.7617832Z"}
-2021/01/12 15:29:11 [97mDEBUG[0m [{"id":9001,"command":"ExecuteChildWorkflow","options":{"name":"SimpleWorkflow","options":{"Namespace":"default","WorkflowID":null,"TaskQueueName":"default","WorkflowExecutionTimeout":0,"WorkflowRunTimeout":0,"WorkflowTaskTimeout":0,"WaitForCancellation":false,"WorkflowIDReusePolicy":2,"RetryPolicy":null,"CronSchedule":null,"ParentClosePolicy":1,"Memo":null,"SearchAttributes":null,"StaticDetails":"","StaticSummary":"","Priority":{"priority_key":0,"fairness_key":"","fairness_weight":0}}},"payloads":"Ci0KFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SEyJjaGlsZCBIZWxsbyBXb3JsZCI=","header":""},{"id":9002,"command":"GetChildWorkflowExecution","options":{"id":9001},"payloads":"","header":""},{"payloads":"ChkKFwoIZW5jb2RpbmcSC2JpbmFyeS9udWxs"}] {"receive": true}
+2021/01/12 15:29:11 [97mDEBUG[0m [{"id":9001,"command":"ExecuteChildWorkflow","options":{"name":"SimpleWorkflow","options":{"Namespace":"default","WorkflowID":"eede8d74-cabb-4c49-bd0d-b87f14306d33_1","TaskQueueName":"default","WorkflowExecutionTimeout":0,"WorkflowRunTimeout":0,"WorkflowTaskTimeout":0,"WaitForCancellation":false,"WorkflowIDReusePolicy":2,"RetryPolicy":null,"CronSchedule":null,"ParentClosePolicy":1,"Memo":null,"SearchAttributes":null,"StaticDetails":"","StaticSummary":"","Priority":{"priority_key":0,"fairness_key":"","fairness_weight":0}}},"payloads":"Ci0KFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SEyJjaGlsZCBIZWxsbyBXb3JsZCI=","header":""},{"id":9002,"command":"GetChildWorkflowExecution","options":{"id":9001},"payloads":"","header":""},{"payloads":"ChkKFwoIZW5jb2RpbmcSC2JpbmFyeS9udWxs"}] {"receive": true}
2021/01/12 15:29:11 [97mDEBUG[0m [{"id":9002,"payloads":"CngKFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SXnsiSUQiOiJlZWRlOGQ3NC1jYWJiLTRjNDktYmQwZC1iODdmMTQzMDZkMzNfMSIsIlJ1bklEIjoiNDMyMDgxZGItZGQyZS00ODY1LTk0MDctZTNjM2Y5NWYyZTc0In0="}] {"taskQueue":"default","tickTime":"2021-01-12T15:29:11.8333619Z"}
2021/01/12 15:29:11 [97mDEBUG[0m [{"command":"StartWorkflow","options":{"info":{"WorkflowExecution":{"ID":"eede8d74-cabb-4c49-bd0d-b87f14306d33_1","RunID":"432081db-dd2e-4865-9407-e3c3f95f2e74"},"WorkflowType":{"Name":"SimpleWorkflow"},"TaskQueueName":"default","WorkflowExecutionTimeout":0,"WorkflowRunTimeout":0,"WorkflowTaskTimeout":0,"Namespace":"default","Attempt":1,"CronSchedule":"","ContinuedExecutionRunID":"","ParentWorkflowNamespace":"default","ParentWorkflowExecution":{"ID":"ab0dac3f-df7e-44ea-9964-c67aadfffcbf","RunID":"eede8d74-cabb-4c49-bd0d-b87f14306d33"},"Memo":null,"SearchAttributes":null,"BinaryChecksum":"0843e92328c0fb62c02212ed103edb4d"}},"payloads":"Ci0KFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SEyJjaGlsZCBIZWxsbyBXb3JsZCI=","header":""}] {"taskQueue":"default","tickTime":"2021-01-12T15:29:11.8345687Z"}
2021/01/12 15:29:11 [97mDEBUG[0m [] {"receive": true}
diff --git a/tests/Fixtures/data/Test_ExecuteChildWorkflowNamespaced.log b/tests/Fixtures/data/Test_ExecuteChildWorkflowNamespaced.log
index 9ab5eb81f..51768ab3f 100644
--- a/tests/Fixtures/data/Test_ExecuteChildWorkflowNamespaced.log
+++ b/tests/Fixtures/data/Test_ExecuteChildWorkflowNamespaced.log
@@ -1,5 +1,5 @@
2021/01/12 15:29:11 [97mDEBUG[0m [{"command":"StartWorkflow","options":{"info":{"WorkflowExecution":{"ID":"ab0dac3f-df7e-44ea-9964-c67aadfffcbf","RunID":"eede8d74-cabb-4c49-bd0d-b87f14306d33"},"WorkflowType":{"Name":"WithChildWorkflow"},"TaskQueueName":"default","WorkflowExecutionTimeout":315360000000000000,"WorkflowRunTimeout":315360000000000000,"WorkflowTaskTimeout":0,"Namespace":"foobar","Attempt":1,"CronSchedule":"","ContinuedExecutionRunID":"","ParentWorkflowNamespace":"","ParentWorkflowExecution":null,"Memo":null,"SearchAttributes":null,"BinaryChecksum":"0843e92328c0fb62c02212ed103edb4d"}},"payloads":"CicKFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SDSJIZWxsbyBXb3JsZCI="}] {"taskQueue":"default","tickTime":"2021-01-12T15:29:11.7617832Z"}
-2021/01/12 15:29:11 [97mDEBUG[0m [{"id":9001,"command":"ExecuteChildWorkflow","options":{"name":"SimpleWorkflow","options":{"Namespace":"foobar","WorkflowID":null,"TaskQueueName":"default","WorkflowExecutionTimeout":0,"WorkflowRunTimeout":0,"WorkflowTaskTimeout":0,"WaitForCancellation":false,"WorkflowIDReusePolicy":2,"RetryPolicy":null,"CronSchedule":null,"ParentClosePolicy":1,"Memo":null,"SearchAttributes":null,"StaticDetails":"","StaticSummary":"","Priority":{"priority_key":0,"fairness_key":"","fairness_weight":0}}},"payloads":"Ci0KFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SEyJjaGlsZCBIZWxsbyBXb3JsZCI=","header":""},{"id":9002,"command":"GetChildWorkflowExecution","options":{"id":9001},"payloads":"","header":""},{"payloads":"ChkKFwoIZW5jb2RpbmcSC2JpbmFyeS9udWxs"}] {"receive": true}
+2021/01/12 15:29:11 [97mDEBUG[0m [{"id":9001,"command":"ExecuteChildWorkflow","options":{"name":"SimpleWorkflow","options":{"Namespace":"foobar","WorkflowID":"eede8d74-cabb-4c49-bd0d-b87f14306d33_1","TaskQueueName":"default","WorkflowExecutionTimeout":0,"WorkflowRunTimeout":0,"WorkflowTaskTimeout":0,"WaitForCancellation":false,"WorkflowIDReusePolicy":2,"RetryPolicy":null,"CronSchedule":null,"ParentClosePolicy":1,"Memo":null,"SearchAttributes":null,"StaticDetails":"","StaticSummary":"","Priority":{"priority_key":0,"fairness_key":"","fairness_weight":0}}},"payloads":"Ci0KFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SEyJjaGlsZCBIZWxsbyBXb3JsZCI=","header":""},{"id":9002,"command":"GetChildWorkflowExecution","options":{"id":9001},"payloads":"","header":""},{"payloads":"ChkKFwoIZW5jb2RpbmcSC2JpbmFyeS9udWxs"}] {"receive": true}
2021/01/12 15:29:11 [97mDEBUG[0m [{"id":9002,"payloads":"CngKFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SXnsiSUQiOiJlZWRlOGQ3NC1jYWJiLTRjNDktYmQwZC1iODdmMTQzMDZkMzNfMSIsIlJ1bklEIjoiNDMyMDgxZGItZGQyZS00ODY1LTk0MDctZTNjM2Y5NWYyZTc0In0="}] {"taskQueue":"default","tickTime":"2021-01-12T15:29:11.8333619Z"}
2021/01/12 15:29:11 [97mDEBUG[0m [{"command":"StartWorkflow","options":{"info":{"WorkflowExecution":{"ID":"eede8d74-cabb-4c49-bd0d-b87f14306d33_1","RunID":"432081db-dd2e-4865-9407-e3c3f95f2e74"},"WorkflowType":{"Name":"SimpleWorkflow"},"TaskQueueName":"default","WorkflowExecutionTimeout":0,"WorkflowRunTimeout":0,"WorkflowTaskTimeout":0,"Namespace":"foobar","Attempt":1,"CronSchedule":"","ContinuedExecutionRunID":"","ParentWorkflowNamespace":"default","ParentWorkflowExecution":{"ID":"ab0dac3f-df7e-44ea-9964-c67aadfffcbf","RunID":"eede8d74-cabb-4c49-bd0d-b87f14306d33"},"Memo":null,"SearchAttributes":null,"BinaryChecksum":"0843e92328c0fb62c02212ed103edb4d"}},"payloads":"Ci0KFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SEyJjaGlsZCBIZWxsbyBXb3JsZCI=","header":""}] {"taskQueue":"default","tickTime":"2021-01-12T15:29:11.8345687Z"}
2021/01/12 15:29:11 [97mDEBUG[0m [] {"receive": true}
diff --git a/tests/Fixtures/data/Test_ExecuteChildWorkflowTaskQueue.log b/tests/Fixtures/data/Test_ExecuteChildWorkflowTaskQueue.log
index 7be58f7c0..01c73a6b0 100644
--- a/tests/Fixtures/data/Test_ExecuteChildWorkflowTaskQueue.log
+++ b/tests/Fixtures/data/Test_ExecuteChildWorkflowTaskQueue.log
@@ -1,5 +1,5 @@
2021/01/12 15:29:11 [97mDEBUG[0m [{"command":"StartWorkflow","options":{"info":{"WorkflowExecution":{"ID":"ab0dac3f-df7e-44ea-9964-c67aadfffcbf","RunID":"eede8d74-cabb-4c49-bd0d-b87f14306d33"},"WorkflowType":{"Name":"WithChildWorkflow"},"TaskQueueName":"FooBar","WorkflowExecutionTimeout":315360000000000000,"WorkflowRunTimeout":315360000000000000,"WorkflowTaskTimeout":0,"Namespace":"foobar","Attempt":1,"CronSchedule":"","ContinuedExecutionRunID":"","ParentWorkflowNamespace":"","ParentWorkflowExecution":null,"Memo":null,"SearchAttributes":null,"BinaryChecksum":"0843e92328c0fb62c02212ed103edb4d"}},"payloads":"CicKFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SDSJIZWxsbyBXb3JsZCI="}] {"taskQueue":"default","tickTime":"2021-01-12T15:29:11.7617832Z"}
-2021/01/12 15:29:11 [97mDEBUG[0m [{"id":9001,"command":"ExecuteChildWorkflow","options":{"name":"SimpleWorkflow","options":{"Namespace":"foobar","WorkflowID":null,"TaskQueueName":"FooBar","WorkflowExecutionTimeout":0,"WorkflowRunTimeout":0,"WorkflowTaskTimeout":0,"WaitForCancellation":false,"WorkflowIDReusePolicy":2,"RetryPolicy":null,"CronSchedule":null,"ParentClosePolicy":1,"Memo":null,"SearchAttributes":null,"StaticDetails":"","StaticSummary":"","Priority":{"priority_key":0,"fairness_key":"","fairness_weight":0}}},"payloads":"Ci0KFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SEyJjaGlsZCBIZWxsbyBXb3JsZCI=","header":""},{"id":9002,"command":"GetChildWorkflowExecution","options":{"id":9001},"payloads":"","header":""},{"payloads":"ChkKFwoIZW5jb2RpbmcSC2JpbmFyeS9udWxs"}] {"receive": true}
+2021/01/12 15:29:11 [97mDEBUG[0m [{"id":9001,"command":"ExecuteChildWorkflow","options":{"name":"SimpleWorkflow","options":{"Namespace":"foobar","WorkflowID":"eede8d74-cabb-4c49-bd0d-b87f14306d33_1","TaskQueueName":"FooBar","WorkflowExecutionTimeout":0,"WorkflowRunTimeout":0,"WorkflowTaskTimeout":0,"WaitForCancellation":false,"WorkflowIDReusePolicy":2,"RetryPolicy":null,"CronSchedule":null,"ParentClosePolicy":1,"Memo":null,"SearchAttributes":null,"StaticDetails":"","StaticSummary":"","Priority":{"priority_key":0,"fairness_key":"","fairness_weight":0}}},"payloads":"Ci0KFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SEyJjaGlsZCBIZWxsbyBXb3JsZCI=","header":""},{"id":9002,"command":"GetChildWorkflowExecution","options":{"id":9001},"payloads":"","header":""},{"payloads":"ChkKFwoIZW5jb2RpbmcSC2JpbmFyeS9udWxs"}] {"receive": true}
2021/01/12 15:29:11 [97mDEBUG[0m [{"id":9002,"payloads":"CngKFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SXnsiSUQiOiJlZWRlOGQ3NC1jYWJiLTRjNDktYmQwZC1iODdmMTQzMDZkMzNfMSIsIlJ1bklEIjoiNDMyMDgxZGItZGQyZS00ODY1LTk0MDctZTNjM2Y5NWYyZTc0In0="}] {"taskQueue":"default","tickTime":"2021-01-12T15:29:11.8333619Z"}
2021/01/12 15:29:11 [97mDEBUG[0m [{"command":"StartWorkflow","options":{"info":{"WorkflowExecution":{"ID":"eede8d74-cabb-4c49-bd0d-b87f14306d33_1","RunID":"432081db-dd2e-4865-9407-e3c3f95f2e74"},"WorkflowType":{"Name":"SimpleWorkflow"},"TaskQueueName":"FooBar","WorkflowExecutionTimeout":0,"WorkflowRunTimeout":0,"WorkflowTaskTimeout":0,"Namespace":"foobar","Attempt":1,"CronSchedule":"","ContinuedExecutionRunID":"","ParentWorkflowNamespace":"default","ParentWorkflowExecution":{"ID":"ab0dac3f-df7e-44ea-9964-c67aadfffcbf","RunID":"eede8d74-cabb-4c49-bd0d-b87f14306d33"},"Memo":null,"SearchAttributes":null,"BinaryChecksum":"0843e92328c0fb62c02212ed103edb4d"}},"payloads":"Ci0KFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SEyJjaGlsZCBIZWxsbyBXb3JsZCI=","header":""}] {"taskQueue":"default","tickTime":"2021-01-12T15:29:11.8345687Z"}
2021/01/12 15:29:11 [97mDEBUG[0m [] {"receive": true}
diff --git a/tests/Fixtures/data/Test_SignalChildViaStubWorkflow.log b/tests/Fixtures/data/Test_SignalChildViaStubWorkflow.log
index 898e4d65e..b8b5af2e8 100644
--- a/tests/Fixtures/data/Test_SignalChildViaStubWorkflow.log
+++ b/tests/Fixtures/data/Test_SignalChildViaStubWorkflow.log
@@ -1,5 +1,5 @@
2021/01/12 15:28:19 [97mDEBUG[0m [{"command":"StartWorkflow","options":{"info":{"WorkflowExecution":{"ID":"2445f473-d974-4705-9bba-daac1f557615","RunID":"52517f4a-4aa7-412a-bb3c-99b6672eeb62"},"WorkflowType":{"Name":"SignalChildViaStubWorkflow"},"TaskQueueName":"default","WorkflowExecutionTimeout":315360000000000000,"WorkflowRunTimeout":315360000000000000,"WorkflowTaskTimeout":0,"Namespace":"default","Attempt":1,"CronSchedule":"","ContinuedExecutionRunID":"","ParentWorkflowNamespace":"","ParentWorkflowExecution":null,"Memo":null,"SearchAttributes":null,"BinaryChecksum":"464f326cee4654e3d40d9f42fe0f90f7"}}}] {"taskQueue":"default","tickTime":"2021-01-12T15:28:19.4634306Z"}
-2021/01/12 15:28:19 [97mDEBUG[0m [{"id":9001,"command":"ExecuteChildWorkflow","options":{"name":"SimpleSignalledWorkflow","options":{"Namespace":"default","WorkflowID":null,"TaskQueueName":"default","WorkflowExecutionTimeout":0,"WorkflowRunTimeout":0,"WorkflowTaskTimeout":0,"WaitForCancellation":false,"WorkflowIDReusePolicy":2,"RetryPolicy":null,"CronSchedule":null,"ParentClosePolicy":1,"Memo":null,"SearchAttributes":null,"StaticDetails":"","StaticSummary":"","Priority":{"priority_key":0,"fairness_key":"","fairness_weight":0}}},"payloads":"","header":""},{"id":9002,"command":"GetChildWorkflowExecution","options":{"id":9001},"payloads":"","header":""},{"payloads":"ChkKFwoIZW5jb2RpbmcSC2JpbmFyeS9udWxs"}] {"receive": true}
+2021/01/12 15:28:19 [97mDEBUG[0m [{"id":9001,"command":"ExecuteChildWorkflow","options":{"name":"SimpleSignalledWorkflow","options":{"Namespace":"default","WorkflowID":"52517f4a-4aa7-412a-bb3c-99b6672eeb62_1","TaskQueueName":"default","WorkflowExecutionTimeout":0,"WorkflowRunTimeout":0,"WorkflowTaskTimeout":0,"WaitForCancellation":false,"WorkflowIDReusePolicy":2,"RetryPolicy":null,"CronSchedule":null,"ParentClosePolicy":1,"Memo":null,"SearchAttributes":null,"StaticDetails":"","StaticSummary":"","Priority":{"priority_key":0,"fairness_key":"","fairness_weight":0}}},"payloads":"","header":""},{"id":9002,"command":"GetChildWorkflowExecution","options":{"id":9001},"payloads":"","header":""},{"payloads":"ChkKFwoIZW5jb2RpbmcSC2JpbmFyeS9udWxs"}] {"receive": true}
2021/01/12 15:28:19 [97mDEBUG[0m [{"id":9002,"payloads":"CngKFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SXnsiSUQiOiI1MjUxN2Y0YS00YWE3LTQxMmEtYmIzYy05OWI2NjcyZWViNjJfMSIsIlJ1bklEIjoiYjkyN2FhNzUtYTBiNS00ZTAyLTkyMzctZjk2MWY3M2VkYzMzIn0="}] {"taskQueue":"default","tickTime":"2021-01-12T15:28:19.5368724Z"}
2021/01/12 15:28:19 [97mDEBUG[0m [{"command":"StartWorkflow","options":{"info":{"WorkflowExecution":{"ID":"52517f4a-4aa7-412a-bb3c-99b6672eeb62_1","RunID":"b927aa75-a0b5-4e02-9237-f961f73edc33"},"WorkflowType":{"Name":"SimpleSignalledWorkflow"},"TaskQueueName":"default","WorkflowExecutionTimeout":0,"WorkflowRunTimeout":0,"WorkflowTaskTimeout":0,"Namespace":"default","Attempt":1,"CronSchedule":"","ContinuedExecutionRunID":"","ParentWorkflowNamespace":"default","ParentWorkflowExecution":{"ID":"2445f473-d974-4705-9bba-daac1f557615","RunID":"52517f4a-4aa7-412a-bb3c-99b6672eeb62"},"Memo":null,"SearchAttributes":null,"BinaryChecksum":"464f326cee4654e3d40d9f42fe0f90f7"}}}] {"taskQueue":"default","tickTime":"2021-01-12T15:28:19.538353Z"}
2021/01/12 15:28:19 [97mDEBUG[0m [{"id":9003,"command":"SignalExternalWorkflow","options":{"namespace":"default","workflowID":"52517f4a-4aa7-412a-bb3c-99b6672eeb62_1","runID":null,"signal":"add","childWorkflowOnly":true},"payloads":"ChsKFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SATg=","header":""}] {"receive": true}
diff --git a/tests/Unit/Client/Mapper/PendingActivityInfoMapperTestCase.php b/tests/Unit/Client/Mapper/PendingActivityInfoMapperTestCase.php
index 450d0e393..8a8eb5ee3 100644
--- a/tests/Unit/Client/Mapper/PendingActivityInfoMapperTestCase.php
+++ b/tests/Unit/Client/Mapper/PendingActivityInfoMapperTestCase.php
@@ -20,8 +20,14 @@
use Temporal\Api\Workflow\V1\PendingActivityInfo\PauseInfo;
use Temporal\Api\Workflow\V1\PendingActivityInfo\PauseInfo\Manual;
use Temporal\Api\Workflow\V1\PendingActivityInfo\PauseInfo\Rule;
+use Temporal\Api\Common\V1\Payload;
+use Temporal\DataConverter\ActivitySerializationContext;
use Temporal\DataConverter\DataConverter;
use Temporal\DataConverter\EncodedValues;
+use Temporal\DataConverter\PayloadConverterInterface;
+use Temporal\DataConverter\SerializationContext;
+use Temporal\DataConverter\SerializationContextAwareInterface;
+use Temporal\DataConverter\Type;
use Temporal\Exception\Failure\ApplicationFailure;
use Temporal\Internal\Mapper\PendingActivityInfoMapper;
use Temporal\Workflow\PendingActivityState;
@@ -31,7 +37,7 @@ final class PendingActivityInfoMapperTestCase extends TestCase
public function testFromMessageFullyPopulated(): void
{
$converter = DataConverter::createDefault();
- $mapper = new PendingActivityInfoMapper($converter);
+ $mapper = new PendingActivityInfoMapper($converter, 'default', 'wf-1');
$info = $mapper->fromMessage(
new PendingActivityInfo([
@@ -133,7 +139,7 @@ public function testFromMessageFullyPopulated(): void
public function testFromMessageMinimal(): void
{
- $mapper = new PendingActivityInfoMapper(DataConverter::createDefault());
+ $mapper = new PendingActivityInfoMapper(DataConverter::createDefault(), 'default', 'wf-1');
$info = $mapper->fromMessage(new PendingActivityInfo());
@@ -161,7 +167,7 @@ public function testFromMessageMinimal(): void
public function testPauseInfoByRule(): void
{
- $mapper = new PendingActivityInfoMapper(DataConverter::createDefault());
+ $mapper = new PendingActivityInfoMapper(DataConverter::createDefault(), 'default', 'wf-1');
$info = $mapper->fromMessage(
new PendingActivityInfo([
@@ -183,4 +189,149 @@ public function testPauseInfoByRule(): void
self::assertSame('system', $info->pauseInfo->rule->identity);
self::assertSame('flaky activity', $info->pauseInfo->rule->reason);
}
+
+ public function testHeartbeatDetailsDecodeWithActivityContext(): void
+ {
+ $converter = new DataConverter(new ActivityContextSigningConverter());
+
+ $signingConverter = $converter->withSerializationContext(new ActivitySerializationContext(
+ namespace: 'default',
+ workflowId: 'wf-1',
+ activityType: 'MyActivity',
+ taskQueue: 'my-tq',
+ ));
+ $signed = EncodedValues::fromValues(['hb-progress'], $signingConverter);
+
+ $mapper = new PendingActivityInfoMapper($converter, 'default', 'wf-1');
+ $info = $mapper->fromMessage(new PendingActivityInfo([
+ 'activity_type' => new ActivityType(['name' => 'MyActivity']),
+ 'activity_options' => (new ActivityOptionsMessage())
+ ->setTaskQueue((new TaskQueueMessage())->setName('my-tq')),
+ 'heartbeat_details' => $signed->toPayloads(),
+ ]));
+
+ self::assertSame('hb-progress', $info->heartbeatDetails->getValue(0, Type::TYPE_STRING));
+ }
+
+ /**
+ * The activity worker encodes heartbeat details with the full activity context, which
+ * includes the workflow type; describe() must decode them under the very same context.
+ */
+ public function testHeartbeatDetailsDecodeWithWorkflowTypeInActivityContext(): void
+ {
+ $converter = new DataConverter(new ActivityContextSigningConverter());
+
+ // Exactly what ActivitySerializationContextFactory::fromActivityInfo() builds at runtime.
+ $signingConverter = $converter->withSerializationContext(new ActivitySerializationContext(
+ namespace: 'default',
+ workflowId: 'wf-1',
+ workflowType: 'MyWorkflow',
+ activityType: 'MyActivity',
+ taskQueue: 'my-tq',
+ ));
+ $signed = EncodedValues::fromValues(['hb-progress'], $signingConverter);
+
+ $mapper = new PendingActivityInfoMapper($converter, 'default', 'wf-1', 'MyWorkflow');
+ $info = $mapper->fromMessage(new PendingActivityInfo([
+ 'activity_type' => new ActivityType(['name' => 'MyActivity']),
+ 'activity_options' => (new ActivityOptionsMessage())
+ ->setTaskQueue((new TaskQueueMessage())->setName('my-tq')),
+ 'heartbeat_details' => $signed->toPayloads(),
+ ]));
+
+ self::assertSame('hb-progress', $info->heartbeatDetails->getValue(0, Type::TYPE_STRING));
+ }
+
+ /**
+ * The last activity failure is converted with the same activity context as the heartbeat
+ * details, so its details payloads must decode under the workflow type as well.
+ */
+ public function testLastFailureDetailsDecodeWithWorkflowTypeInActivityContext(): void
+ {
+ $converter = new DataConverter(new ActivityContextSigningConverter());
+
+ $context = new ActivitySerializationContext(
+ namespace: 'default',
+ workflowId: 'wf-1',
+ workflowType: 'MyWorkflow',
+ activityType: 'MyActivity',
+ taskQueue: 'my-tq',
+ );
+ $signed = EncodedValues::fromValues(['failure-detail'], $converter->withSerializationContext($context));
+
+ $mapper = new PendingActivityInfoMapper($converter, 'default', 'wf-1', 'MyWorkflow');
+ $info = $mapper->fromMessage(new PendingActivityInfo([
+ 'activity_type' => new ActivityType(['name' => 'MyActivity']),
+ 'activity_options' => (new ActivityOptionsMessage())
+ ->setTaskQueue((new TaskQueueMessage())->setName('my-tq')),
+ 'last_failure' => (new Failure())
+ ->setMessage('boom')
+ ->setApplicationFailureInfo(
+ (new ApplicationFailureInfo())->setType('MyError')->setDetails($signed->toPayloads()),
+ ),
+ ]));
+
+ self::assertInstanceOf(ApplicationFailure::class, $info->lastFailure);
+ self::assertSame('failure-detail', $info->lastFailure->getDetails()->getValue(0, Type::TYPE_STRING));
+ }
+}
+
+final class ActivityContextSigningConverter implements PayloadConverterInterface, SerializationContextAwareInterface
+{
+ private const ENCODING = 'act-signed';
+
+ private ?SerializationContext $context = null;
+
+ public function withSerializationContext(?SerializationContext $context): static
+ {
+ $clone = clone $this;
+ $clone->context = $context;
+ return $clone;
+ }
+
+ public function getSerializationContext(): ?SerializationContext
+ {
+ return $this->context;
+ }
+
+ public function getEncodingType(): string
+ {
+ return self::ENCODING;
+ }
+
+ public function toPayload($value): ?Payload
+ {
+ if (!\is_string($value)) {
+ return null;
+ }
+
+ return (new Payload())
+ ->setMetadata(['encoding' => self::ENCODING, 'signature' => $this->signature()])
+ ->setData($value);
+ }
+
+ public function fromPayload(Payload $payload, Type $type): mixed
+ {
+ $metadata = $payload->getMetadata();
+ $actual = $metadata['signature'] ?? '';
+ $expected = $this->signature();
+
+ if ($actual !== $expected) {
+ throw new \RuntimeException(
+ \sprintf('Signature mismatch: expected "%s", got "%s"', $expected, $actual),
+ );
+ }
+
+ return $payload->getData();
+ }
+
+ private function signature(): string
+ {
+ return $this->context instanceof ActivitySerializationContext
+ ? (string) $this->context->workflowId
+ . ':' . (string) $this->context->workflowType
+ . ':' . (string) $this->context->activityType
+ . ':' . (string) $this->context->taskQueue
+ : '';
+ }
}
diff --git a/tests/Unit/DataConverter/DataConverterSerializationContextTestCase.php b/tests/Unit/DataConverter/DataConverterSerializationContextTestCase.php
new file mode 100644
index 000000000..7fc61b683
--- /dev/null
+++ b/tests/Unit/DataConverter/DataConverterSerializationContextTestCase.php
@@ -0,0 +1,145 @@
+withSerializationContext($context));
+ }
+
+ public function testWithIdenticalContextReturnsSameInstance(): void
+ {
+ $context = new WorkflowSerializationContext('default', 'wf-1');
+ $converter = (new DataConverter(new NullConverter()))->withSerializationContext($context);
+
+ self::assertSame($converter, $converter->withSerializationContext($context));
+ }
+
+ public function testAwarePayloadConverterIsRewrappedWithContext(): void
+ {
+ $aware = new RecordingAwarePayloadConverter();
+ $converter = new DataConverter($aware);
+ $context = new WorkflowSerializationContext('default', 'wf-1');
+
+ $bound = $converter->withSerializationContext($context);
+ $bound->toPayload('value');
+
+ self::assertNull($aware->lastUsedContext);
+ }
+
+ public function testPlainPayloadConverterIsLeftIntact(): void
+ {
+ $plain = new RecordingPlainPayloadConverter();
+ $aware = new RecordingAwarePayloadConverter();
+ $converter = new DataConverter($plain, $aware);
+ $context = new WorkflowSerializationContext('default', 'wf-1');
+
+ $bound = $converter->withSerializationContext($context);
+ $bound->toPayload('value');
+
+ self::assertSame(1, $plain->callCount);
+ }
+
+ public function testReWrappedConverterReceivesContextAtConversionTime(): void
+ {
+ $aware = new RecordingAwarePayloadConverter();
+ $converter = new DataConverter($aware);
+ $context = new WorkflowSerializationContext('default', 'wf-1');
+
+ $bound = $converter->withSerializationContext($context);
+ $bound->toPayload('value');
+
+ $rewrapped = $aware->lastClone;
+ self::assertNotNull($rewrapped);
+ self::assertSame($context, $rewrapped->boundContext);
+ self::assertSame(1, $rewrapped->callCount);
+ }
+}
+
+final class RecordingAwarePayloadConverter implements PayloadConverterInterface, SerializationContextAwareInterface
+{
+ public ?SerializationContext $boundContext = null;
+ public ?SerializationContext $lastUsedContext = null;
+ public int $callCount = 0;
+ public ?self $lastClone = null;
+
+ public function withSerializationContext(?SerializationContext $context): static
+ {
+ $clone = clone $this;
+ $clone->boundContext = $context;
+ $this->lastClone = $clone;
+ return $clone;
+ }
+
+ public function getSerializationContext(): ?SerializationContext
+ {
+ return $this->boundContext;
+ }
+
+ public function getEncodingType(): string
+ {
+ return EncodingKeys::METADATA_ENCODING_RAW;
+ }
+
+ public function toPayload($value): ?Payload
+ {
+ ++$this->callCount;
+ $this->lastUsedContext = $this->boundContext;
+
+ return new Payload();
+ }
+
+ public function fromPayload(Payload $payload, Type $type)
+ {
+ return null;
+ }
+}
+
+final class RecordingPlainPayloadConverter implements PayloadConverterInterface
+{
+ public int $callCount = 0;
+
+ public function getEncodingType(): string
+ {
+ return EncodingKeys::METADATA_ENCODING_NULL;
+ }
+
+ public function toPayload($value): ?Payload
+ {
+ ++$this->callCount;
+
+ return null;
+ }
+
+ public function fromPayload(Payload $payload, Type $type)
+ {
+ return null;
+ }
+}
diff --git a/tests/Unit/DataConverter/SerializationContextSigningTestCase.php b/tests/Unit/DataConverter/SerializationContextSigningTestCase.php
new file mode 100644
index 000000000..51fd1fdd4
--- /dev/null
+++ b/tests/Unit/DataConverter/SerializationContextSigningTestCase.php
@@ -0,0 +1,164 @@
+toPayloads();
+
+ $decoded = EncodedValues::fromPayloads($payloads, self::signingConverter($context));
+
+ self::assertSame('payload', $decoded->getValue(0, Type::TYPE_STRING));
+ }
+
+ public function testWorkflowContextSignatureMismatchFailsDecode(): void
+ {
+ $encodeContext = new WorkflowSerializationContext('default', 'wf-A');
+ $decodeContext = new WorkflowSerializationContext('default', 'wf-B');
+
+ $payloads = EncodedValues::fromValues(['payload'], self::signingConverter($encodeContext))->toPayloads();
+
+ $decoded = EncodedValues::fromPayloads($payloads, self::signingConverter($decodeContext));
+
+ $this->expectException(\RuntimeException::class);
+ $this->expectExceptionMessage('Signature mismatch: expected "wf-B", got "wf-A"');
+ $decoded->getValue(0, Type::TYPE_STRING);
+ }
+
+ public function testActivityContextSignatureMismatchFailsDecode(): void
+ {
+ $encodeContext = new ActivitySerializationContext(
+ namespace: 'default',
+ workflowId: 'wf-1',
+ activityType: 'Charge',
+ taskQueue: 'tq',
+ );
+ $decodeContext = new ActivitySerializationContext(
+ namespace: 'default',
+ workflowId: 'wf-1',
+ activityType: 'Refund',
+ taskQueue: 'tq',
+ );
+
+ $payloads = EncodedValues::fromValues(['payload'], self::signingConverter($encodeContext))->toPayloads();
+
+ $decoded = EncodedValues::fromPayloads($payloads, self::signingConverter($decodeContext));
+
+ $this->expectException(\RuntimeException::class);
+ $this->expectExceptionMessage('Signature mismatch: expected "wf-1:Refund", got "wf-1:Charge"');
+ $decoded->getValue(0, Type::TYPE_STRING);
+ }
+
+ public function testStandaloneActivityContextAllowsNullWorkflowFields(): void
+ {
+ $context = new ActivitySerializationContext(namespace: 'default', activityType: 'Charge', taskQueue: 'tq');
+
+ self::assertNull($context->getWorkflowId());
+ self::assertNull($context->workflowType);
+
+ $payloads = EncodedValues::fromValues(['payload'], self::signingConverter($context))->toPayloads();
+
+ $decoded = EncodedValues::fromPayloads(
+ $payloads,
+ self::signingConverter(
+ new ActivitySerializationContext(namespace: 'default', activityType: 'Charge', taskQueue: 'tq'),
+ ),
+ );
+
+ self::assertSame('payload', $decoded->getValue(0, Type::TYPE_STRING));
+ }
+
+ private static function signingConverter(SerializationContext $context): DataConverterInterface
+ {
+ return (new DataConverter(new SigningPayloadConverter()))->withSerializationContext($context);
+ }
+}
+
+final class SigningPayloadConverter implements PayloadConverterInterface, SerializationContextAwareInterface
+{
+ private const ENCODING = 'signed-test';
+
+ private ?SerializationContext $context = null;
+
+ public function withSerializationContext(?SerializationContext $context): static
+ {
+ $clone = clone $this;
+ $clone->context = $context;
+ return $clone;
+ }
+
+ public function getSerializationContext(): ?SerializationContext
+ {
+ return $this->context;
+ }
+
+ public function getEncodingType(): string
+ {
+ return self::ENCODING;
+ }
+
+ public function toPayload($value): ?Payload
+ {
+ if (!\is_string($value)) {
+ return null;
+ }
+
+ return (new Payload())
+ ->setMetadata(['encoding' => self::ENCODING, 'signature' => self::signatureOf($this->context)])
+ ->setData($value);
+ }
+
+ public function fromPayload(Payload $payload, Type $type): mixed
+ {
+ $metadata = $payload->getMetadata();
+ $actual = isset($metadata['signature']) ? $metadata['signature'] : '';
+ $expected = self::signatureOf($this->context);
+
+ if ($actual !== $expected) {
+ throw new \RuntimeException(
+ \sprintf('Signature mismatch: expected "%s", got "%s"', $expected, $actual),
+ );
+ }
+
+ return $payload->getData();
+ }
+
+ private static function signatureOf(?SerializationContext $context): string
+ {
+ return match (true) {
+ $context instanceof ActivitySerializationContext =>
+ (string) $context->workflowId . ':' . (string) $context->activityType,
+ $context instanceof HasWorkflowSerializationContext => (string) $context->getWorkflowId(),
+ default => '',
+ };
+ }
+}
diff --git a/tests/Unit/DataConverter/SerializationContextValuesTestCase.php b/tests/Unit/DataConverter/SerializationContextValuesTestCase.php
new file mode 100644
index 000000000..156ee58c0
--- /dev/null
+++ b/tests/Unit/DataConverter/SerializationContextValuesTestCase.php
@@ -0,0 +1,107 @@
+withSerializationContext($context);
+
+ $payload = $values->toPayloads()->getPayloads()[0];
+
+ self::assertSame('wf-1', $payload->getMetadata()['ctx']);
+ }
+
+ public function testContextIsAppliedOnDecode(): void
+ {
+ $context = new WorkflowSerializationContext('default', 'wf-7');
+ $log = new \ArrayObject();
+
+ $values = EncodedValues::fromValues(['payload'], new ContextStampingConverter())
+ ->withSerializationContext($context);
+ $payloads = $values->toPayloads();
+
+ $decoded = EncodedValues::fromPayloads($payloads, new ContextStampingConverter($log))
+ ->withSerializationContext($context);
+ $decoded->getValue(0, Type::TYPE_STRING);
+
+ self::assertContains('from:wf-7', $log->getArrayCopy());
+ }
+
+ public function testWithSerializationContextIsImmutable(): void
+ {
+ $converter = new ContextStampingConverter();
+ $original = EncodedValues::fromValues(['payload'], $converter);
+ $context = new WorkflowSerializationContext('default', 'wf-1');
+
+ $derived = $original->withSerializationContext($context);
+
+ self::assertNotSame($original, $derived);
+ self::assertNull($original->getSerializationContext());
+ self::assertSame($context, $derived->getSerializationContext());
+ }
+
+ public function testSetSerializationContextMutatesInPlace(): void
+ {
+ $context = new WorkflowSerializationContext('default', 'wf-1');
+ $values = EncodedValues::fromValues(['payload'], new ContextStampingConverter());
+
+ $values->setSerializationContext($context);
+
+ self::assertSame($context, $values->getSerializationContext());
+ self::assertSame('wf-1', $values->toPayloads()->getPayloads()[0]->getMetadata()['ctx']);
+ }
+
+ public function testNullContextDoesNotWrapConverter(): void
+ {
+ $log = new \ArrayObject();
+ $values = EncodedValues::fromValues(['payload'], new ContextStampingConverter($log));
+
+ $values->toPayloads();
+
+ self::assertNotContains('wrap:', $log->getArrayCopy());
+ self::assertSame([], \array_filter(
+ $log->getArrayCopy(),
+ static fn(string $entry): bool => \str_starts_with($entry, 'wrap:'),
+ ));
+ }
+
+ public function testSetSerializationContextInvalidatesEffectiveConverter(): void
+ {
+ $values = EncodedValues::fromValues(['payload'], new ContextStampingConverter());
+
+ self::assertSame('', $values->toPayloads()->getPayloads()[0]->getMetadata()['ctx']);
+
+ $values->setSerializationContext(new WorkflowSerializationContext('default', 'wf-2'));
+
+ self::assertSame('wf-2', $values->toPayloads()->getPayloads()[0]->getMetadata()['ctx']);
+ }
+
+ public function testEncodedCollectionCarriesContext(): void
+ {
+ $context = new WorkflowSerializationContext('default', 'wf-9');
+
+ $collection = EncodedCollection::fromValues(['k' => 'payload'], new ContextStampingConverter())
+ ->withSerializationContext($context);
+
+ $payloads = $collection->toPayloadArray();
+
+ self::assertSame('wf-9', $payloads['k']->getMetadata()['ctx']);
+ }
+}
diff --git a/tests/Unit/DataConverter/Stub/ContextStampingConverter.php b/tests/Unit/DataConverter/Stub/ContextStampingConverter.php
new file mode 100644
index 000000000..cf9331c24
--- /dev/null
+++ b/tests/Unit/DataConverter/Stub/ContextStampingConverter.php
@@ -0,0 +1,84 @@
+context;
+ }
+
+ public function withSerializationContext(?SerializationContext $context): static
+ {
+ $this->log->append('wrap:' . self::stampOf($context));
+
+ $clone = clone $this;
+ $clone->context = $context;
+
+ return $clone;
+ }
+
+ public function fromPayload(Payload $payload, mixed $type): mixed
+ {
+ $this->log->append('from:' . self::stampOf($this->context));
+
+ return $payload->getData();
+ }
+
+ public function toPayload(mixed $value): Payload
+ {
+ $this->log->append('to:' . self::stampOf($this->context));
+
+ return (new Payload())
+ ->setMetadata(['encoding' => 'test/ctx', 'ctx' => self::stampOf($this->context)])
+ ->setData((string) $value);
+ }
+
+ /**
+ * @return list
+ */
+ public function wraps(): array
+ {
+ return self::entries($this->log, 'wrap:');
+ }
+
+ /**
+ * @return list
+ */
+ public function reads(): array
+ {
+ return self::entries($this->log, 'from:');
+ }
+
+ private static function stampOf(?SerializationContext $context): string
+ {
+ return $context instanceof HasWorkflowSerializationContext
+ ? (string) $context->getWorkflowId()
+ : '';
+ }
+
+ /**
+ * @return list
+ */
+ private static function entries(\ArrayObject $log, string $prefix): array
+ {
+ return \array_values(\array_filter(
+ $log->getArrayCopy(),
+ static fn(string $entry): bool => \str_starts_with($entry, $prefix),
+ ));
+ }
+}
diff --git a/tests/Unit/Exception/Failure/EncodedFailureAttributesTestCase.php b/tests/Unit/Exception/Failure/EncodedFailureAttributesTestCase.php
new file mode 100644
index 000000000..7a49125e2
--- /dev/null
+++ b/tests/Unit/Exception/Failure/EncodedFailureAttributesTestCase.php
@@ -0,0 +1,188 @@
+flag = FeatureFlags::$encodeFailureAttributes;
+ parent::setUp();
+ }
+
+ protected function tearDown(): void
+ {
+ FeatureFlags::$encodeFailureAttributes = $this->flag;
+ parent::tearDown();
+ }
+
+ public function testMessageAndStackTraceAreMovedIntoEncodedAttributes(): void
+ {
+ $converter = DataConverter::createDefault();
+ $plain = self::mapWithFlag(false, $converter);
+
+ $failure = self::mapWithFlag(true, $converter);
+
+ self::assertSame('Encoded failure', $failure->getMessage());
+ self::assertSame('', $failure->getStackTrace());
+
+ $payload = $failure->getEncodedAttributes();
+ self::assertInstanceOf(Payload::class, $payload);
+
+ $attributes = $converter->fromPayload($payload, Type::TYPE_ARRAY);
+ self::assertSame($plain->getMessage(), $attributes['message']);
+ self::assertArrayHasKey('stack_trace', $attributes);
+ self::assertNotSame('', $attributes['stack_trace']);
+ }
+
+ public function testCauseIsEncodedRecursively(): void
+ {
+ FeatureFlags::$encodeFailureAttributes = true;
+ $converter = DataConverter::createDefault();
+
+ $failure = FailureConverter::mapExceptionToFailure(
+ new ApplicationFailure(
+ 'main error',
+ 'MainError',
+ true,
+ previous: new ApplicationFailure('cause error', 'CauseError', true),
+ ),
+ $converter,
+ );
+
+ $cause = $failure->getCause();
+ self::assertNotNull($cause);
+ self::assertSame('Encoded failure', $cause->getMessage());
+ self::assertSame('', $cause->getStackTrace());
+
+ $attributes = $converter->fromPayload($cause->getEncodedAttributes(), Type::TYPE_ARRAY);
+ self::assertStringContainsString('cause error', $attributes['message']);
+ }
+
+ public function testRoundTripRestoresMessageAndStackTrace(): void
+ {
+ $converter = DataConverter::createDefault();
+ // The same exception instance, so both mappings produce the very same stack trace.
+ $exception = new ApplicationFailure('main error', 'MainError', true);
+
+ FeatureFlags::$encodeFailureAttributes = false;
+ $plainFailure = FailureConverter::mapExceptionToFailure($exception, $converter);
+
+ FeatureFlags::$encodeFailureAttributes = true;
+ $encodedFailure = FailureConverter::mapExceptionToFailure($exception, $converter);
+
+ $plain = FailureConverter::mapFailureToException($plainFailure, $converter);
+ $restored = FailureConverter::mapFailureToException($encodedFailure, $converter);
+
+ self::assertSame($plain->getOriginalMessage(), $restored->getOriginalMessage());
+ self::assertSame($plain->getOriginalStackTrace(), $restored->getOriginalStackTrace());
+ }
+
+ public function testDisabledByDefaultKeepsPlainAttributes(): void
+ {
+ $failure = self::mapWithFlag(false, DataConverter::createDefault());
+
+ self::assertNotSame('Encoded failure', $failure->getMessage());
+ self::assertStringContainsString('main error', $failure->getMessage());
+ self::assertNotSame('', $failure->getStackTrace());
+ self::assertNull($failure->getEncodedAttributes());
+ }
+
+ private static function mapWithFlag(bool $encode, DataConverter $converter): \Temporal\Api\Failure\V1\Failure
+ {
+ FeatureFlags::$encodeFailureAttributes = $encode;
+
+ return FailureConverter::mapExceptionToFailure(
+ new ApplicationFailure('main error', 'MainError', true),
+ $converter,
+ );
+ }
+
+ /**
+ * The encoded attributes are a regular payload, so they must be converted with the
+ * serialization context of the failure they belong to.
+ *
+ * @link https://github.com/temporalio/features/issues/434
+ */
+ public function testEncodedAttributesAreConvertedWithSerializationContext(): void
+ {
+ FeatureFlags::$encodeFailureAttributes = true;
+ $converter = new DataConverter(new FailureAttributesSigningConverter());
+ $context = new WorkflowSerializationContext('test-ns', 'wf-1');
+
+ $failure = FailureConverter::mapExceptionToFailure(
+ new ApplicationFailure('main error', 'MainError', true),
+ $converter,
+ $context,
+ );
+
+ $payload = $failure->getEncodedAttributes();
+ self::assertInstanceOf(Payload::class, $payload);
+ self::assertSame(
+ 'wf:test-ns:wf-1',
+ $payload->getMetadata()[FailureAttributesSigningConverter::KEY] ?? '',
+ );
+ }
+}
+
+final class FailureAttributesSigningConverter implements PayloadConverterInterface, SerializationContextAwareInterface
+{
+ public const KEY = 'ctx-signature';
+
+ private ?SerializationContext $context = null;
+
+ public function getEncodingType(): string
+ {
+ return 'json/plain';
+ }
+
+ public function getSerializationContext(): ?SerializationContext
+ {
+ return $this->context;
+ }
+
+ public function withSerializationContext(?SerializationContext $context): static
+ {
+ $clone = clone $this;
+ $clone->context = $context;
+ return $clone;
+ }
+
+ public function toPayload($value): ?Payload
+ {
+ return (new Payload())
+ ->setData((string) \json_encode($value))
+ ->setMetadata(['encoding' => $this->getEncodingType(), self::KEY => $this->signature()]);
+ }
+
+ public function fromPayload(Payload $payload, Type $type): mixed
+ {
+ return \json_decode($payload->getData(), true, 512, \JSON_THROW_ON_ERROR);
+ }
+
+ private function signature(): string
+ {
+ return $this->context instanceof WorkflowSerializationContext
+ ? 'wf:' . $this->context->namespace . ':' . $this->context->workflowId
+ : 'none';
+ }
+}
diff --git a/tests/Unit/Exception/Failure/TemporalFailureSerializationContextTestCase.php b/tests/Unit/Exception/Failure/TemporalFailureSerializationContextTestCase.php
new file mode 100644
index 000000000..5b83b8bfa
--- /dev/null
+++ b/tests/Unit/Exception/Failure/TemporalFailureSerializationContextTestCase.php
@@ -0,0 +1,91 @@
+setSerializationContext($context);
+
+ self::assertSame($context, $outer->getSerializationContext());
+ self::assertSame($context, $cause->getSerializationContext());
+
+ $converter = DataConverter::createDefault();
+ $outer->setDataConverter($converter);
+ $cause->setDataConverter($converter);
+
+ self::assertSame($context, self::contextOfDetails($outer));
+ self::assertSame($context, self::contextOfDetails($cause));
+ }
+
+ public function testContextWalksPastNonTemporalCause(): void
+ {
+ $cause = new ApplicationFailure('cause', 'T', true, EncodedValues::fromValues(['cause']));
+ $wrapper = new \RuntimeException('wrapper', 0, $cause);
+
+ $outer = new ApplicationFailure('outer', 'T', true, EncodedValues::fromValues(['outer']), previous: $wrapper);
+
+ $context = new WorkflowSerializationContext('default', 'wf-1');
+ $outer->setSerializationContext($context);
+
+ self::assertSame($context, $outer->getSerializationContext());
+ self::assertSame($context, $cause->getSerializationContext());
+
+ $converter = DataConverter::createDefault();
+ $outer->setDataConverter($converter);
+ $cause->setDataConverter($converter);
+
+ self::assertSame($context, self::contextOfDetails($outer));
+ self::assertSame($context, self::contextOfDetails($cause));
+ }
+
+ public function testContextThenConverterLeavesDetailsWithContext(): void
+ {
+ $failure = new ApplicationFailure('e', 'T', true, EncodedValues::fromValues(['x']));
+ $context = new WorkflowSerializationContext('default', 'wf-order');
+
+ $failure->setSerializationContext($context);
+ $failure->setDataConverter(DataConverter::createDefault());
+
+ self::assertSame($context, self::contextOfDetails($failure));
+ }
+
+ public function testConverterThenContextLeavesDetailsWithContext(): void
+ {
+ $failure = new ApplicationFailure('e', 'T', true, EncodedValues::fromValues(['x']));
+ $context = new WorkflowSerializationContext('default', 'wf-order');
+
+ $failure->setDataConverter(DataConverter::createDefault());
+ $failure->setSerializationContext($context);
+
+ self::assertSame($context, self::contextOfDetails($failure));
+ }
+
+ private static function contextOfDetails(ApplicationFailure $failure): ?WorkflowSerializationContext
+ {
+ $context = $failure->getDetails()->getSerializationContext();
+ self::assertInstanceOf(WorkflowSerializationContext::class, $context);
+
+ return $context;
+ }
+}
diff --git a/tests/Unit/Internal/Client/SerializationContextClientTestCase.php b/tests/Unit/Internal/Client/SerializationContextClientTestCase.php
new file mode 100644
index 000000000..91c3f20db
--- /dev/null
+++ b/tests/Unit/Internal/Client/SerializationContextClientTestCase.php
@@ -0,0 +1,150 @@
+signature = match (true) {
+ $context instanceof ActivitySerializationContext => 'act:' . $context->namespace . ':' . $context->activityType,
+ $context instanceof HasWorkflowSerializationContext => 'wf:' . $context->getNamespace() . ':' . $context->getWorkflowId(),
+ default => 'none',
+ };
+
+ return $clone;
+ }
+
+ public function toPayload($value): ?Payload
+ {
+ return (new Payload())
+ ->setData((string) \json_encode($value))
+ ->setMetadata(['encoding' => $this->getEncodingType(), self::KEY => $this->signature]);
+ }
+
+ public function fromPayload(Payload $payload, $type): mixed
+ {
+ return \json_decode($payload->getData(), true);
+ }
+}
+
+final class SerializationContextClientTestCase extends TestCase
+{
+ private const NAMESPACE = 'test-ns';
+
+ public function testStartWorkflowMemoIsEncodedWithWorkflowContext(): void
+ {
+ $captured = null;
+ $service = $this->createMock(ServiceClientInterface::class);
+ $service->method('StartWorkflowExecution')->willReturnCallback(
+ static function (StartWorkflowExecutionRequest $request) use (&$captured): StartWorkflowExecutionResponse {
+ $captured = $request;
+ return (new StartWorkflowExecutionResponse())->setRunId('run-1');
+ },
+ );
+
+ $starter = new WorkflowStarter(
+ serviceClient: $service,
+ converter: new DataConverter(new ContextStampingConverter()),
+ clientOptions: (new ClientOptions())->withNamespace(self::NAMESPACE),
+ interceptors: Pipeline::prepare([]),
+ );
+
+ $options = (new WorkflowOptions())
+ ->withWorkflowId('wf-1')
+ ->withMemo(['note' => 'memo-value']);
+
+ $starter->start('MyWorkflow', $options, ['arg']);
+
+ $expected = 'wf:' . self::NAMESPACE . ':wf-1';
+
+ // Control: the input carries the workflow context.
+ self::assertSame(
+ $expected,
+ $captured->getInput()->getPayloads()[0]->getMetadata()[ContextStampingConverter::KEY],
+ );
+
+ // The memo must carry the same workflow context.
+ self::assertSame(
+ $expected,
+ $captured->getMemo()->getFields()['note']->getMetadata()[ContextStampingConverter::KEY],
+ );
+ }
+
+ /**
+ * The async completion client applies the activity context it is given, so
+ * the out-of-band result carries the activity signature.
+ */
+ public function testAsyncActivityCompletionResultIsEncodedWithActivityContext(): void
+ {
+ $captured = null;
+ $service = $this->createMock(ServiceClientInterface::class);
+ $service->method('RespondActivityTaskCompletedById')->willReturnCallback(
+ static function (RespondActivityTaskCompletedByIdRequest $request) use (&$captured): RespondActivityTaskCompletedByIdResponse {
+ $captured = $request;
+ return new RespondActivityTaskCompletedByIdResponse();
+ },
+ );
+
+ $client = new ActivityCompletionClient(
+ $service,
+ (new ClientOptions())->withNamespace(self::NAMESPACE),
+ new DataConverter(new ContextStampingConverter()),
+ );
+
+ $client
+ ->withContext(new ActivitySerializationContext(
+ namespace: self::NAMESPACE,
+ activityType: 'MyActivity',
+ taskQueue: 'tq',
+ ))
+ ->complete('wf-1', 'run-1', 'act-1', 'the-result');
+
+ self::assertSame(
+ 'act:' . self::NAMESPACE . ':MyActivity',
+ $captured->getResult()->getPayloads()[0]->getMetadata()[ContextStampingConverter::KEY],
+ );
+ }
+}
diff --git a/tests/Unit/Router/GetWorkerInfoTestCase.php b/tests/Unit/Router/GetWorkerInfoTestCase.php
new file mode 100644
index 000000000..047c016b9
--- /dev/null
+++ b/tests/Unit/Router/GetWorkerInfoTestCase.php
@@ -0,0 +1,86 @@
+createMock(WorkerInterface::class);
+ $worker->method('getID')->willReturn('my-tq');
+ $worker->method('getOptions')->willReturn(WorkerOptions::new());
+ $worker->method('getWorkflows')->willReturn([]);
+ $worker->method('getActivities')->willReturn([]);
+
+ $queues = new ArrayRepository();
+ $queues->add($worker);
+
+ $marshaller = $this->createMock(MarshallerInterface::class);
+ $marshaller->method('marshal')->willReturn([]);
+
+ $router = new GetWorkerInfo($queues, $marshaller, ServiceCredentials::create(), new PluginRegistry([]));
+
+ $captured = null;
+ $resolver = new Deferred();
+ $resolver->promise()->then(static function ($value) use (&$captured): void {
+ $captured = $value;
+ });
+ $router->handle(new GetWorkerInfoRequest(), [], $resolver);
+
+ self::assertInstanceOf(EncodedValues::class, $captured);
+
+ $captured->setDataConverter(new DataConverter(
+ new NullConverter(),
+ new BinaryConverter(),
+ new ProtoJsonConverter(),
+ new ProtoConverter(),
+ new EncryptEverythingConverter(),
+ ));
+
+ $payload = $captured->toPayloads()->getPayloads()[0];
+
+ self::assertSame('json/plain', $payload->getMetadata()['encoding']);
+ }
+}
+
+final class EncryptEverythingConverter implements PayloadConverterInterface
+{
+ public function getEncodingType(): string
+ {
+ return 'binary/encrypted';
+ }
+
+ public function toPayload($value): ?Payload
+ {
+ return (new Payload())
+ ->setMetadata(['encoding' => 'binary/encrypted'])
+ ->setData('---' . \json_encode($value) . '---');
+ }
+
+ public function fromPayload(Payload $payload, Type $type): mixed
+ {
+ return \json_decode(\substr($payload->getData(), 3, -3), true);
+ }
+}