diff --git a/composer.json b/composer.json index b715adf6..79eaaf23 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,7 @@ "roadrunner-php/roadrunner-api-dto": "^1.10.0", "roadrunner-php/version-checker": "^1.0.1", "spiral/attributes": "^3.1.8", - "spiral/roadrunner": "^2024.3.2", + "spiral/roadrunner": "^2024.3.3", "spiral/roadrunner-cli": "^2.6", "spiral/roadrunner-kv": "^4.3", "spiral/roadrunner-worker": "^3.6.1", diff --git a/psalm-baseline.xml b/psalm-baseline.xml index ff09e84d..959654b4 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,5 +1,5 @@ - + @@ -984,89 +984,11 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - totalMilliseconds]]> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -1127,6 +1049,7 @@ + @@ -1427,11 +1350,6 @@ failure]]> - - - - - diff --git a/src/Interceptor/WorkflowOutboundCalls/UpsertMemoInput.php b/src/Interceptor/WorkflowOutboundCalls/UpsertMemoInput.php new file mode 100644 index 00000000..ccc78f37 --- /dev/null +++ b/src/Interceptor/WorkflowOutboundCalls/UpsertMemoInput.php @@ -0,0 +1,36 @@ + $memo + * + * @no-named-arguments + * @internal Don't use the constructor. Use {@see self::with()} instead. + */ + public function __construct( + public readonly array $memo, + ) {} + + public function with( + ?array $memo = null, + ): self { + return new self( + $memo ?? $this->memo, + ); + } +} diff --git a/src/Interceptor/WorkflowOutboundCallsInterceptor.php b/src/Interceptor/WorkflowOutboundCallsInterceptor.php index ff448222..76677138 100644 --- a/src/Interceptor/WorkflowOutboundCallsInterceptor.php +++ b/src/Interceptor/WorkflowOutboundCallsInterceptor.php @@ -26,6 +26,7 @@ use Temporal\Interceptor\WorkflowOutboundCalls\SideEffectInput; use Temporal\Interceptor\WorkflowOutboundCalls\SignalExternalWorkflowInput; use Temporal\Interceptor\WorkflowOutboundCalls\TimerInput; +use Temporal\Interceptor\WorkflowOutboundCalls\UpsertMemoInput; use Temporal\Interceptor\WorkflowOutboundCalls\UpsertSearchAttributesInput; use Temporal\Interceptor\WorkflowOutboundCalls\UpsertTypedSearchAttributesInput; use Temporal\Internal\Interceptor\Interceptor; @@ -113,6 +114,11 @@ public function continueAsNew(ContinueAsNewInput $input, callable $next): Promis */ public function getVersion(GetVersionInput $input, callable $next): PromiseInterface; + /** + * @param callable(UpsertMemoInput): PromiseInterface $next + */ + public function upsertMemo(UpsertMemoInput $input, callable $next): PromiseInterface; + /** * @param callable(UpsertSearchAttributesInput): PromiseInterface $next */ diff --git a/src/Internal/Transport/Request/UpsertMemo.php b/src/Internal/Transport/Request/UpsertMemo.php new file mode 100644 index 00000000..9fdc97f7 --- /dev/null +++ b/src/Internal/Transport/Request/UpsertMemo.php @@ -0,0 +1,29 @@ + $memo + */ + public function __construct( + private readonly array $memo, + ) { + parent::__construct(self::NAME, ['memo' => (object) $memo]); + } + + /** + * @return array + */ + public function getMemo(): array + { + return $this->memo; + } +} diff --git a/src/Internal/Transport/Router/StartWorkflow.php b/src/Internal/Transport/Router/StartWorkflow.php index 7c028e42..25a36118 100644 --- a/src/Internal/Transport/Router/StartWorkflow.php +++ b/src/Internal/Transport/Router/StartWorkflow.php @@ -12,6 +12,7 @@ namespace Temporal\Internal\Transport\Router; use React\Promise\Deferred; +use Temporal\Api\Common\V1\Memo; use Temporal\Api\Common\V1\SearchAttributes; use Temporal\Common\TypedSearchAttributes; use Temporal\DataConverter\EncodedCollection; @@ -58,8 +59,10 @@ 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); $options['info']['SearchAttributes'] = $searchAttributes?->getValues(); $options['info']['TypedSearchAttributes'] = $this->prepareTypedSA($options['search_attributes'] ?? null); + $options['info']['Memo'] = $memo?->getValues(); /** @var Input $input */ $input = $this->services->marshaller->unmarshal($options, new Input()); @@ -156,6 +159,32 @@ private function convertSearchAttributes(?array $param): ?EncodedCollection } } + private function convertMemo(?array $param): ?EncodedCollection + { + if (!\is_array($param)) { + return null; + } + + if ($param === []) { + return EncodedCollection::empty(); + } + + try { + $memo = (new Memo()); + $memo->mergeFromJsonString( + \json_encode($param), + true, + ); + + return EncodedCollection::fromPayloadCollection( + $memo->getFields(), + $this->services->dataConverter, + ); + } catch (\Throwable) { + return null; + } + } + private function prepareTypedSA(?array $param): TypedSearchAttributes { return $param === null diff --git a/src/Internal/Workflow/WorkflowContext.php b/src/Internal/Workflow/WorkflowContext.php index 0c260556..c75a23ac 100644 --- a/src/Internal/Workflow/WorkflowContext.php +++ b/src/Internal/Workflow/WorkflowContext.php @@ -35,6 +35,7 @@ use Temporal\Interceptor\WorkflowOutboundCalls\PanicInput; use Temporal\Interceptor\WorkflowOutboundCalls\SideEffectInput; use Temporal\Interceptor\WorkflowOutboundCalls\TimerInput; +use Temporal\Interceptor\WorkflowOutboundCalls\UpsertMemoInput; use Temporal\Interceptor\WorkflowOutboundCalls\UpsertSearchAttributesInput; use Temporal\Interceptor\WorkflowOutboundCalls\UpsertTypedSearchAttributesInput; use Temporal\Interceptor\WorkflowOutboundCallsInterceptor; @@ -55,6 +56,7 @@ use Temporal\Internal\Transport\Request\NewTimer; use Temporal\Internal\Transport\Request\Panic; use Temporal\Internal\Transport\Request\SideEffect; +use Temporal\Internal\Transport\Request\UpsertMemo; use Temporal\Internal\Transport\Request\UpsertSearchAttributes; use Temporal\Internal\Transport\Request\UpsertTypedSearchAttributes; use Temporal\Internal\Workflow\Process\HandlerState; @@ -448,10 +450,43 @@ public function allHandlersFinished(): bool return !$this->handlers->hasRunningHandlers(); } + public function upsertMemo(array $values): void + { + $this->callsInterceptor->with( + function (UpsertMemoInput $input): PromiseInterface { + if ($input->memo === []) { + return resolve(); + } + + $result = $this->request(new UpsertMemo($input->memo), false); + + /** @psalm-suppress UnsupportedPropertyReferenceUsage $memo */ + $memo = &$this->input->info->memo; + $memo ??= []; + foreach ($input->memo as $name => $value) { + if ($value === null) { + unset($memo[$name]); + continue; + } + + $memo[$name] = $value; + } + + return $result; + }, + /** @see WorkflowOutboundCallsInterceptor::upsertMemo() */ + 'upsertMemo', + )(new UpsertMemoInput($values)); + } + public function upsertSearchAttributes(array $searchAttributes): void { $this->callsInterceptor->with( function (UpsertSearchAttributesInput $input): PromiseInterface { + if ($input->searchAttributes === []) { + return resolve(); + } + $result = $this->request(new UpsertSearchAttributes($input->searchAttributes), false); /** @psalm-suppress UnsupportedPropertyReferenceUsage $sa */ @@ -476,6 +511,10 @@ public function upsertTypedSearchAttributes(SearchAttributeUpdate ...$updates): { $this->callsInterceptor->with( function (UpsertTypedSearchAttributesInput $input): PromiseInterface { + if ($input->updates === []) { + return resolve(); + } + $result = $this->request(new UpsertTypedSearchAttributes($input->updates), false); // Merge changes diff --git a/src/Worker/Transport/Command/Common/RequestTrait.php b/src/Worker/Transport/Command/Common/RequestTrait.php index 33815875..5f0d5f26 100644 --- a/src/Worker/Transport/Command/Common/RequestTrait.php +++ b/src/Worker/Transport/Command/Common/RequestTrait.php @@ -30,6 +30,9 @@ public function getHeader(): Header return $this->header; } + /** + * @psalm-external-mutation-free + */ public function withHeader(HeaderInterface $header): self { $clone = clone $this; diff --git a/src/Workflow.php b/src/Workflow.php index 7f200267..d24edc32 100644 --- a/src/Workflow.php +++ b/src/Workflow.php @@ -903,6 +903,49 @@ public static function allHandlersFinished(): bool return $context->allHandlersFinished(); } + /** + * Updates this Workflow's Memos by merging the provided memo with existing Memos. + * + * New Memo is merged by replacing properties of the same name at the first level only. + * Setting a property to {@see null} clears that key from the Memo. + * + * For example: + * + * ```php + * Workflow::upsertMemo([ + * 'key1' => 'value', + * 'key3' => ['subkey1' => 'value'] + * 'key4' => 'value', + * }); + * + * Workflow::upsertMemo([ + * 'key2' => 'value', + * 'key3' => ['subkey2' => 'value'] + * 'key4' => null, + * ]); + * ``` + * + * would result in the Workflow having these Memo: + * + * ```php + * [ + * 'key1' => 'value', + * 'key2' => 'value', + * 'key3' => ['subkey2' => 'value'], // Note this object was completely replaced + * // Note that 'key4' was completely removed + * ] + * ``` + * + * @param array $values + * + * @since SDK 2.13.0 + * @since RoadRunner 2024.3.3 + */ + public static function upsertMemo(array $values): void + { + self::getCurrentContext()->upsertMemo($values); + } + /** * Upsert search attributes * @@ -923,11 +966,13 @@ public static function upsertSearchAttributes(array $searchAttributes): void * ); * ``` * + * @since SDK 2.13.0 + * @since RoadRunner 2024.3.2 * @link https://docs.temporal.io/visibility#search-attribute */ public static function upsertTypedSearchAttributes(SearchAttributeUpdate ...$updates): void { - $updates === [] or self::getCurrentContext()->upsertTypedSearchAttributes(...$updates); + self::getCurrentContext()->upsertTypedSearchAttributes(...$updates); } /** diff --git a/src/Workflow/WorkflowContextInterface.php b/src/Workflow/WorkflowContextInterface.php index 64e01844..4b3ec38a 100644 --- a/src/Workflow/WorkflowContextInterface.php +++ b/src/Workflow/WorkflowContextInterface.php @@ -293,6 +293,47 @@ public function getStackTrace(): string; */ public function allHandlersFinished(): bool; + /** + * Updates this Workflow's Memos by merging the provided memo with existing Memos. + * + * New Memo is merged by replacing properties of the same name at the first level only. + * Setting a property to {@see null} clears that key from the Memo. + * + * For example: + * + * ```php + * Workflow::upsertMemo([ + * 'key1' => 'value', + * 'key3' => ['subkey1' => 'value'] + * 'key4' => 'value', + * }); + * + * Workflow::upsertMemo([ + * 'key2' => 'value', + * 'key3' => ['subkey2' => 'value'] + * 'key4' => null, + * ]); + * ``` + * + * would result in the Workflow having these Memo: + * + * ```php + * [ + * 'key1' => 'value', + * 'key2' => 'value', + * 'key3' => ['subkey2' => 'value'], // Note this object was completely replaced + * // Note that 'key4' was completely removed + * ] + * ``` + * + * @param array $values + * + * @since SDK 2.13.0 + * @since RoadRunner 2024.3.3 + * @link https://docs.temporal.io/glossary#memo + */ + public function upsertMemo(array $values): void; + /** * Upsert search attributes * @@ -310,6 +351,8 @@ public function upsertSearchAttributes(array $searchAttributes): void; * ); * ``` * + * @since SDK 2.13.0 + * @since RoadRunner 2024.3.2 * @link https://docs.temporal.io/visibility#search-attribute */ public function upsertTypedSearchAttributes(SearchAttributeUpdate ...$updates): void; diff --git a/tests/Acceptance/Extra/Workflow/MemoTest.php b/tests/Acceptance/Extra/Workflow/MemoTest.php new file mode 100644 index 00000000..811aef09 --- /dev/null +++ b/tests/Acceptance/Extra/Workflow/MemoTest.php @@ -0,0 +1,128 @@ + 'value1', + 'key2' => 'value2', + 'key3' => ['foo' => 'bar'], + 42 => 'value4', + ], + )] WorkflowStubInterface $stub, + ): void { + try { + $stub->update('setMemo', []); + + // Get Search Attributes using Client API + $clientMemo = $stub->describe()->info->memo->getValues(); + + // Complete workflow + /** @see TestWorkflow::exit */ + $stub->signal('exit'); + } catch (\Throwable $e) { + $stub->terminate('test failed'); + throw $e; + } + + // Get Memo from Workflow + $result = $stub->getResult(); + + $expected = [ + 'key1' => 'value1', + 'key2' => 'value2', + 'key3' => (object) ['foo' => 'bar'], + 42 => 'value4', + ]; + $this->assertEquals($expected, $clientMemo); + $this->assertEquals($expected, (array) $result); + } + + #[Test] + public function overrideAddAndRemove( + #[Stub( + type: 'Extra_Workflow_Memo', + memo: [ + 'key1' => 'value1', + 'key2' => 'value2', + 'key3' => ['foo' => 'bar'], + ], + )] WorkflowStubInterface $stub, + ): void { + try { + $stub->update('setMemo', [ + 'key2' => null, + 'key3' => 42, + 'key4' => 'value4', + ]); + + // Get Search Attributes using Client API + $clientMemo = $stub->describe()->info->memo->getValues(); + + // Complete workflow + /** @see TestWorkflow::exit */ + $stub->signal('exit'); + } catch (\Throwable $e) { + $stub->terminate('test failed'); + throw $e; + } + + // Get Memo from Workflow + $result = $stub->getResult(); + + $expected = [ + 'key1' => 'value1', + 'key3' => 42, + 'key4' => 'value4', + ]; + $this->assertEquals($expected, $clientMemo); + $this->assertEquals($expected, (array) $result); + } +} + +#[WorkflowInterface] +class TestWorkflow +{ + private bool $exit = false; + + #[WorkflowMethod(name: "Extra_Workflow_Memo")] + public function handle() + { + yield Workflow::await( + fn(): bool => $this->exit, + ); + + tr(Workflow::getInfo()->memo); + + return Workflow::getInfo()->memo; + } + + #[Workflow\UpdateMethod] + public function setMemo(array $memo): void + { + Workflow::upsertMemo($memo); + } + + #[Workflow\SignalMethod] + public function exit(): void + { + $this->exit = true; + } +}