From d1d35227dfe730e9d2763ac95a03259ca4436822 Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Mon, 17 Aug 2026 13:25:04 +0400 Subject: [PATCH 1/2] feat(data-converter): serialization context for converters and codecs Add SerializationContext support so a payload converter or failure converter can know which namespace, workflow, or activity a payload belongs to: - SerializationContext marker with WorkflowSerializationContext and ActivitySerializationContext, plus SerializationContextAwareInterface and SerializationContextApplier for opt-in context binding. - Outbound workflow context is resolved at a single choke point in WorkflowContext::request per command type (activity, local activity, child workflow, external signal, complete, side effect, continue-as-new); stubs create plain EncodedValues and carry no context. - Inbound routers, client API (start, signal-with-start, update-with-start, query, update, result), start memo, schedule mapping, pending activity info, heartbeat details, and failure chains are converted with the proper context. - ActivityCompletionClient stays context-free by default with an explicit withContext escape hatch for out-of-band completion. Covered by unit tests and an acceptance suite that signs every payload with its serialization context and rejects any mismatch. --- psalm-baseline.xml | 120 ++-- .../ActivityCompletionClientInterface.php | 4 + src/Client/Schedule/ScheduleHandle.php | 17 +- src/Client/ScheduleClient.php | 8 +- src/Client/Update/UpdateHandle.php | 10 +- src/Client/WorkflowOptions.php | 13 +- .../ActivitySerializationContext.php | 57 ++ src/DataConverter/DataConverter.php | 27 +- src/DataConverter/DataConverterAwareTrait.php | 68 +++ src/DataConverter/EncodedCollection.php | 28 +- src/DataConverter/EncodedValues.php | 31 +- .../HasWorkflowSerializationContext.php | 19 + src/DataConverter/SerializationContext.php | 14 + .../SerializationContextAwareInterface.php | 19 + src/DataConverter/ValuesInterface.php | 6 + .../WorkflowSerializationContext.php | 37 ++ src/Exception/Failure/ApplicationFailure.php | 9 + src/Exception/Failure/CanceledFailure.php | 9 + src/Exception/Failure/FailureConverter.php | 25 +- src/Exception/Failure/TemporalFailure.php | 32 +- src/Exception/Failure/TimeoutFailure.php | 9 + src/Internal/Activity/ActivityContext.php | 36 +- .../Client/ActivityCompletionClient.php | 41 +- .../Client/ResponseToResultMapper.php | 16 +- src/Internal/Client/WorkflowStarter.php | 19 +- src/Internal/Client/WorkflowStub.php | 75 ++- .../Mapper/PendingActivityInfoMapper.php | 39 +- src/Internal/Mapper/ScheduleMapper.php | 22 +- .../Mapper/WorkflowExecutionInfoMapper.php | 14 +- src/Internal/Transport/Client.php | 29 +- .../Transport/Request/ExecuteActivity.php | 17 +- .../Request/ExecuteChildWorkflow.php | 25 +- .../Request/ExecuteLocalActivity.php | 17 +- .../Request/SignalExternalWorkflow.php | 15 + .../Transport/Router/CancelWorkflow.php | 5 +- .../Transport/Router/DestroyWorkflow.php | 5 +- .../Transport/Router/GetWorkerInfo.php | 5 +- .../Transport/Router/InvokeActivity.php | 19 +- .../Transport/Router/InvokeLocalActivity.php | 8 +- src/Internal/Transport/Router/InvokeQuery.php | 18 +- .../Transport/Router/InvokeSignal.php | 9 +- .../Transport/Router/InvokeUpdate.php | 19 +- .../Transport/Router/StartWorkflow.php | 25 +- src/Internal/Workflow/ActivityStub.php | 15 +- src/Internal/Workflow/ChildWorkflowStub.php | 44 +- .../Workflow/ExternalWorkflowStub.php | 7 +- src/Internal/Workflow/ScopeContext.php | 1 + src/Internal/Workflow/WorkflowContext.php | 103 +++- .../Transport/Codec/JsonCodec/Encoder.php | 9 +- .../Transport/Codec/ProtoCodec/Encoder.php | 18 +- .../SerializationContextTest.php | 558 ++++++++++++++++++ .../data/Test_ExecuteChildStubWorkflow.log | 2 +- .../data/Test_ExecuteChildStubWorkflow_02.log | 4 +- .../data/Test_ExecuteChildWorkflow.log | 2 +- .../Test_ExecuteChildWorkflowNamespaced.log | 2 +- .../Test_ExecuteChildWorkflowTaskQueue.log | 2 +- .../data/Test_SignalChildViaStubWorkflow.log | 2 +- .../PendingActivityInfoMapperTestCase.php | 157 ++++- ...aConverterSerializationContextTestCase.php | 145 +++++ .../SerializationContextSigningTestCase.php | 164 +++++ .../SerializationContextValuesTestCase.php | 107 ++++ .../Stub/ContextStampingConverter.php | 84 +++ ...ralFailureSerializationContextTestCase.php | 91 +++ .../SerializationContextClientTestCase.php | 150 +++++ tests/Unit/Router/GetWorkerInfoTestCase.php | 86 +++ 65 files changed, 2544 insertions(+), 249 deletions(-) create mode 100644 src/DataConverter/ActivitySerializationContext.php create mode 100644 src/DataConverter/DataConverterAwareTrait.php create mode 100644 src/DataConverter/HasWorkflowSerializationContext.php create mode 100644 src/DataConverter/SerializationContext.php create mode 100644 src/DataConverter/SerializationContextAwareInterface.php create mode 100644 src/DataConverter/WorkflowSerializationContext.php create mode 100644 tests/Acceptance/Extra/DataConverter/SerializationContextTest.php create mode 100644 tests/Unit/DataConverter/DataConverterSerializationContextTestCase.php create mode 100644 tests/Unit/DataConverter/SerializationContextSigningTestCase.php create mode 100644 tests/Unit/DataConverter/SerializationContextValuesTestCase.php create mode 100644 tests/Unit/DataConverter/Stub/ContextStampingConverter.php create mode 100644 tests/Unit/Exception/Failure/TemporalFailureSerializationContextTestCase.php create mode 100644 tests/Unit/Internal/Client/SerializationContextClientTestCase.php create mode 100644 tests/Unit/Router/GetWorkerInfoTestCase.php 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..8ec3abef5 100644 --- a/src/Exception/Failure/FailureConverter.php +++ b/src/Exception/Failure/FailureConverter.php @@ -24,13 +24,17 @@ use Temporal\Api\Failure\V1\TimeoutFailureInfo; use Temporal\DataConverter\DataConverterInterface; use Temporal\DataConverter\EncodedValues; +use Temporal\DataConverter\SerializationContext; use Temporal\Exception\Client\ActivityCanceledException; use Temporal\Internal\Support\DateInterval; final class FailureConverter { - public static function mapFailureToException(Failure $failure, DataConverterInterface $converter): TemporalFailure - { + public static function mapFailureToException( + Failure $failure, + DataConverterInterface $converter, + ?SerializationContext $context = null, + ): TemporalFailure { $e = self::createFailureException($failure, $converter); $e->setFailure($failure); @@ -38,14 +42,25 @@ public static function mapFailureToException(Failure $failure, DataConverterInte $e->setOriginalStackTrace($failure->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 +77,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) { 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/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 DEBUG [{"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 DEBUG [{"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 DEBUG [{"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 DEBUG [{"id":9002,"payloads":"CngKFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SXnsiSUQiOiI0NjVjMjIxNy04NDgyLTQwNzktOGQyMi04YzNiODFmODhkNzFfMSIsIlJ1bklEIjoiOTViMGRhYjItYjJlMi00M2U1LWI2OTMtODQ3MGFmMDI2ZjdhIn0="}] {"taskQueue":"default","tickTime":"2021-01-12T15:32:01.3789815Z"} 2021/01/12 15:32:01 DEBUG [{"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 DEBUG [] {"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 DEBUG [{"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 DEBUG [{"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 DEBUG [{"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 DEBUG [{"id":9002,"payloads":"CngKFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SXnsiSUQiOiI0ZmJiMTdiMC1hZGUwLTQ4MzgtYWZlNS0zZjNjNDgyNWRkZTdfMSIsIlJ1bklEIjoiNDA5NGI0OTItNTdiMy00MTdhLWE4ZjAtMzBjMWNmMzQ3MDk4In0="}] {"taskQueue":"default","tickTime":"2021-01-12T15:28:48.0958441Z"} 2021/01/12 15:28:48 DEBUG [{"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 DEBUG [] {"receive": true} @@ -9,7 +9,7 @@ 2021/01/12 15:28:48 DEBUG [{"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 DEBUG [{"payloads":"ChkKFwoIZW5jb2RpbmcSC2JpbmFyeS9udWxs"}] {"receive": true} 2021/01/12 15:28:48 DEBUG [{"id":9001,"payloads":"CicKFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SDSJIRUxMTyBXT1JMRCI="}] {"taskQueue":"default","tickTime":"2021-01-12T15:28:48.1952129Z"} -2021/01/12 15:28:48 DEBUG [{"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 DEBUG [{"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 DEBUG [{"id":9006,"payloads":"CngKFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SXnsiSUQiOiI0ZmJiMTdiMC1hZGUwLTQ4MzgtYWZlNS0zZjNjNDgyNWRkZTdfMiIsIlJ1bklEIjoiZmJiMTNiMDEtOTMxNC00YWJmLWIwZjAtNTJkODcwMzc4MDc4In0="}] {"taskQueue":"default","tickTime":"2021-01-12T15:28:48.2339253Z"} 2021/01/12 15:28:48 DEBUG [{"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 DEBUG [] {"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 DEBUG [{"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 DEBUG [{"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 DEBUG [{"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 DEBUG [{"id":9002,"payloads":"CngKFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SXnsiSUQiOiJlZWRlOGQ3NC1jYWJiLTRjNDktYmQwZC1iODdmMTQzMDZkMzNfMSIsIlJ1bklEIjoiNDMyMDgxZGItZGQyZS00ODY1LTk0MDctZTNjM2Y5NWYyZTc0In0="}] {"taskQueue":"default","tickTime":"2021-01-12T15:29:11.8333619Z"} 2021/01/12 15:29:11 DEBUG [{"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 DEBUG [] {"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 DEBUG [{"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 DEBUG [{"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 DEBUG [{"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 DEBUG [{"id":9002,"payloads":"CngKFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SXnsiSUQiOiJlZWRlOGQ3NC1jYWJiLTRjNDktYmQwZC1iODdmMTQzMDZkMzNfMSIsIlJ1bklEIjoiNDMyMDgxZGItZGQyZS00ODY1LTk0MDctZTNjM2Y5NWYyZTc0In0="}] {"taskQueue":"default","tickTime":"2021-01-12T15:29:11.8333619Z"} 2021/01/12 15:29:11 DEBUG [{"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 DEBUG [] {"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 DEBUG [{"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 DEBUG [{"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 DEBUG [{"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 DEBUG [{"id":9002,"payloads":"CngKFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SXnsiSUQiOiJlZWRlOGQ3NC1jYWJiLTRjNDktYmQwZC1iODdmMTQzMDZkMzNfMSIsIlJ1bklEIjoiNDMyMDgxZGItZGQyZS00ODY1LTk0MDctZTNjM2Y5NWYyZTc0In0="}] {"taskQueue":"default","tickTime":"2021-01-12T15:29:11.8333619Z"} 2021/01/12 15:29:11 DEBUG [{"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 DEBUG [] {"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 DEBUG [{"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 DEBUG [{"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 DEBUG [{"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 DEBUG [{"id":9002,"payloads":"CngKFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SXnsiSUQiOiI1MjUxN2Y0YS00YWE3LTQxMmEtYmIzYy05OWI2NjcyZWViNjJfMSIsIlJ1bklEIjoiYjkyN2FhNzUtYTBiNS00ZTAyLTkyMzctZjk2MWY3M2VkYzMzIn0="}] {"taskQueue":"default","tickTime":"2021-01-12T15:28:19.5368724Z"} 2021/01/12 15:28:19 DEBUG [{"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 DEBUG [{"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/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); + } +} From a65112b1249cad72559182cad0c5ab413d806dce Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Mon, 17 Aug 2026 15:50:22 +0400 Subject: [PATCH 2/2] feat(failure): encode failure message and stack trace as payload attributes Add an opt-in FeatureFlags::$encodeFailureAttributes switch that moves the failure message and stack trace into the encoded_attributes payload, replacing the plain fields with 'Encoded failure' and an empty stack trace. The payload goes through the Data Converter, so a custom converter or codec can encrypt data that would otherwise travel in clear text. Causes are encoded recursively. Encoded attributes are always decoded back, regardless of the flag, so a worker that does not enable it still understands failures produced by one that does. The encoded attributes are converted with the failure serialization context; a failure response carries no payloads, so the context is taken from the exception itself when the caller has none. Decoding is tolerant: the wire decoder maps failures before the context is known, and a context-aware converter may refuse the payload there. --- src/Exception/Failure/FailureConverter.php | 78 +++++++- src/Worker/FeatureFlags.php | 13 ++ .../EncodedFailureAttributesTestCase.php | 188 ++++++++++++++++++ 3 files changed, 276 insertions(+), 3 deletions(-) create mode 100644 tests/Unit/Exception/Failure/EncodedFailureAttributesTestCase.php diff --git a/src/Exception/Failure/FailureConverter.php b/src/Exception/Failure/FailureConverter.php index 8ec3abef5..4687ba579 100644 --- a/src/Exception/Failure/FailureConverter.php +++ b/src/Exception/Failure/FailureConverter.php @@ -22,24 +22,32 @@ 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 { + private const ENCODED_FAILURE_MESSAGE = 'Encoded failure'; + public static function mapFailureToException( Failure $failure, DataConverterInterface $converter, ?SerializationContext $context = null, ): TemporalFailure { - $e = self::createFailureException($failure, $converter); + $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) { @@ -170,9 +178,73 @@ public static function mapExceptionToFailure( $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/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/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'; + } +}