|
6 | 6 |
|
7 | 7 | use Illuminate\Support\Carbon; |
8 | 8 | use Mockery; |
| 9 | +use PHPUnit\Framework\Attributes\DataProvider; |
9 | 10 | use RuntimeException; |
10 | 11 | use Tests\TestCase; |
11 | 12 | use Workflow\Serializers\Serializer; |
12 | 13 | use Workflow\V2\Attributes\Type; |
| 14 | +use Workflow\V2\Contracts\ExternalPayloadStorageDriver; |
| 15 | +use Workflow\V2\Contracts\ExternalPayloadStoragePolicy; |
13 | 16 | use Workflow\V2\Contracts\ServiceControlPlane; |
14 | 17 | use Workflow\V2\Contracts\WorkflowControlPlane; |
15 | 18 | use Workflow\V2\Contracts\WorkflowTaskBridge; |
|
29 | 32 | use Workflow\V2\Models\WorkflowServiceOperation; |
30 | 33 | use Workflow\V2\Models\WorkflowTask; |
31 | 34 | use Workflow\V2\Support\DefaultServiceControlPlane; |
| 35 | +use Workflow\V2\Support\ExternalPayloads; |
| 36 | +use Workflow\V2\Support\ExternalPayloadStorage; |
32 | 37 | use Workflow\V2\Support\LinkedServiceCallCloser; |
33 | 38 | use Workflow\V2\Support\ServiceCallDeadline; |
34 | 39 | use Workflow\V2\Support\ServiceCallView; |
| 40 | +use Workflow\V2\Support\ServiceCallWorkflowDelivery; |
35 | 41 | use Workflow\V2\Support\ServiceOperationOptions; |
36 | 42 | use Workflow\V2\Support\ServiceOperationResult; |
37 | 43 | use Workflow\V2\TaskWatchdog; |
@@ -818,6 +824,137 @@ public function testExternalResultKeepsIntegrityMetadataWithoutCopyingProviderUr |
818 | 824 | ], $view['result_envelope']); |
819 | 825 | } |
820 | 826 |
|
| 827 | + #[DataProvider('externalCallerNamespaces')] |
| 828 | + public function testExternalTerminalDeliveryBelongsToTheCallerNamespace(string $namespace): void |
| 829 | + { |
| 830 | + ExternalPayloadStorage::flushVerifiedCache(); |
| 831 | + try { |
| 832 | + [$caller, $call, $task, $targetStorage, $callerStorage, $blob] = $this->externalCaller($namespace); |
| 833 | + $originalCall = $call->getAttributes(); |
| 834 | + $claim = $this->app->make(WorkflowTaskBridge::class)->claimStatus($task->id, 'cold-caller'); |
| 835 | + $this->assertTrue($claim['claimed']); |
| 836 | + $event = WorkflowHistoryEvent::query()->where('workflow_run_id', $caller->id) |
| 837 | + ->where('event_type', HistoryEventType::ServiceCallCompleted->value)->sole(); |
| 838 | + $envelope = $event->payload['response_payload']; |
| 839 | + $this->assertStringStartsWith('memory://' . $namespace . '/', $envelope['external_storage']['uri']); |
| 840 | + $this->assertSame($call->id, $event->payload['service_call_id']); |
| 841 | + $this->assertSame(7, $event->payload['sequence']); |
| 842 | + $this->assertSame($task->id, $event->workflow_task_id); |
| 843 | + $this->assertSame($envelope, $event->payload['service_call']['result_envelope']); |
| 844 | + $this->assertSame($envelope, $event->payload['response_or_failure_surface']['result_envelope']); |
| 845 | + $this->assertSame($originalCall, $call->refresh()->getAttributes()); |
| 846 | + $this->assertSame(strlen($blob), $envelope['external_storage']['size_bytes']); |
| 847 | + $this->assertSame(hash('sha256', $blob), $envelope['external_storage']['sha256']); |
| 848 | + if ($namespace === 'service') { |
| 849 | + $this->assertSame($call->output_payload_reference, $envelope['external_storage']['uri']); |
| 850 | + $this->assertSame(1, $targetStorage->writes); |
| 851 | + } else { |
| 852 | + $this->assertNotSame($call->output_payload_reference, $envelope['external_storage']['uri']); |
| 853 | + $this->assertSame(1, $callerStorage->writes); |
| 854 | + $targetStorage->failReads = true; |
| 855 | + } |
| 856 | + ExternalPayloadStorage::flushVerifiedCache(); |
| 857 | + $this->assertSame($blob, ExternalPayloads::payloadBlob($envelope, 'avro', $namespace)); |
| 858 | + $originalEvent = $event->getAttributes(); |
| 859 | + ServiceCallWorkflowDelivery::recordForClaim($task->fresh(), $caller->fresh()); |
| 860 | + $this->assertSame($originalEvent, $event->refresh()->getAttributes()); |
| 861 | + $this->assertSame(1, $callerStorage->writes); |
| 862 | + $this->assertSame(1, WorkflowHistoryEvent::query()->where('workflow_run_id', $caller->id) |
| 863 | + ->where('event_type', HistoryEventType::ServiceCallCompleted->value)->count()); |
| 864 | + } finally { |
| 865 | + ExternalPayloadStorage::flushVerifiedCache(); |
| 866 | + } |
| 867 | + } |
| 868 | + |
| 869 | + public static function externalCallerNamespaces(): iterable |
| 870 | + { |
| 871 | + yield 'cross namespace' => ['caller']; |
| 872 | + yield 'same namespace' => ['service']; |
| 873 | + } |
| 874 | + |
| 875 | + public function testCallerStorageFailureRollsBackTheOriginalClaimAndCanRecover(): void |
| 876 | + { |
| 877 | + ExternalPayloadStorage::flushVerifiedCache(); |
| 878 | + try { |
| 879 | + [$caller, $call, $task, , $callerStorage] = $this->externalCaller('caller'); |
| 880 | + $originalCall = $call->getAttributes(); |
| 881 | + $callerStorage->failWrites = true; |
| 882 | + $failure = null; |
| 883 | + try { |
| 884 | + $this->app->make(WorkflowTaskBridge::class)->claimStatus($task->id, 'refused-caller'); |
| 885 | + } catch (RuntimeException $error) { |
| 886 | + $failure = $error; |
| 887 | + } |
| 888 | + $this->assertInstanceOf( |
| 889 | + RuntimeException::class, |
| 890 | + $failure, |
| 891 | + 'Caller storage must be available before publishing its result history.' |
| 892 | + ); |
| 893 | + $this->assertSame('caller write refused', $failure->getMessage()); |
| 894 | + $this->assertSame(TaskStatus::Ready, $task->refresh()->status); |
| 895 | + $this->assertSame(0, $task->attempt_count); |
| 896 | + $this->assertNull($task->lease_owner); |
| 897 | + $this->assertTrue($task->payload['service_call_terminal_pending']); |
| 898 | + $this->assertSame(0, WorkflowHistoryEvent::query()->where('workflow_run_id', $caller->id) |
| 899 | + ->where('event_type', HistoryEventType::ServiceCallCompleted->value)->count()); |
| 900 | + $this->assertSame($originalCall, $call->refresh()->getAttributes()); |
| 901 | + $callerStorage->failWrites = false; |
| 902 | + $this->assertTrue( |
| 903 | + $this->app->make(WorkflowTaskBridge::class)->claimStatus($task->id, 'restored-caller')['claimed'] |
| 904 | + ); |
| 905 | + $this->assertSame(1, $task->refresh()->attempt_count); |
| 906 | + $this->assertSame($task->id, WorkflowHistoryEvent::query()->where('workflow_run_id', $caller->id) |
| 907 | + ->where('event_type', HistoryEventType::ServiceCallCompleted->value)->sole()->workflow_task_id); |
| 908 | + } finally { |
| 909 | + ExternalPayloadStorage::flushVerifiedCache(); |
| 910 | + } |
| 911 | + } |
| 912 | + |
| 913 | + /** |
| 914 | + * @return array{WorkflowRun, WorkflowServiceCall, WorkflowTask, NexusNamespaceExternalStorage, NexusNamespaceExternalStorage, string} |
| 915 | + */ |
| 916 | + private function externalCaller(string $namespace): array |
| 917 | + { |
| 918 | + config([ |
| 919 | + 'workflows.v2.task_dispatch_mode' => 'poll', |
| 920 | + ]); |
| 921 | + $targetStorage = new NexusNamespaceExternalStorage('service'); |
| 922 | + $callerStorage = $namespace === 'service' ? $targetStorage : new NexusNamespaceExternalStorage('caller'); |
| 923 | + $drivers = [ |
| 924 | + 'service' => $targetStorage, |
| 925 | + $namespace => $callerStorage, |
| 926 | + ]; |
| 927 | + $policy = Mockery::mock(ExternalPayloadStoragePolicy::class); |
| 928 | + $policy->shouldReceive('driverFor') |
| 929 | + ->andReturnUsing(static fn ($name) => $drivers[$name] ?? null); |
| 930 | + $policy->shouldReceive('thresholdBytesFor') |
| 931 | + ->andReturn(32); |
| 932 | + $this->app->instance(ExternalPayloadStoragePolicy::class, $policy); |
| 933 | + $service = $this->createRun(); |
| 934 | + $caller = $this->createRun($namespace); |
| 935 | + $call = $this->createCall($service, [ |
| 936 | + 'caller_namespace' => $namespace, |
| 937 | + 'caller_workflow_instance_id' => $caller->workflow_instance_id, |
| 938 | + 'caller_workflow_run_id' => $caller->id, |
| 939 | + 'operation_mode' => 'sync', |
| 940 | + ]); |
| 941 | + $this->recordAdmission($caller, $call); |
| 942 | + $blob = Serializer::serializeWithCodec('avro', [ |
| 943 | + 'payload' => str_repeat('value', 50), |
| 944 | + 'bigint' => PHP_INT_MAX, |
| 945 | + 'nullable' => null, |
| 946 | + 'unicode' => '雪☃', |
| 947 | + ]); |
| 948 | + $stored = ExternalPayloads::externalizeForNamespace($blob, 'avro', 'service'); |
| 949 | + WorkflowHistoryEvent::record($service, HistoryEventType::WorkflowCompleted, [ |
| 950 | + 'output' => ExternalPayloads::historyValue($stored, 'avro', 'service'), |
| 951 | + 'payload_codec' => 'avro', |
| 952 | + ]); |
| 953 | + |
| 954 | + return [$caller, $call->refresh(), WorkflowTask::query()->where('workflow_run_id', $caller->id)->sole(), |
| 955 | + $targetStorage, $callerStorage, $blob]; |
| 956 | + } |
| 957 | + |
821 | 958 | /** |
822 | 959 | * @param array<string, mixed> $options |
823 | 960 | */ |
@@ -909,6 +1046,51 @@ private function createCall(WorkflowRun $run, array $overrides = []): WorkflowSe |
909 | 1046 | } |
910 | 1047 | } |
911 | 1048 |
|
| 1049 | +final class NexusNamespaceExternalStorage implements ExternalPayloadStorageDriver |
| 1050 | +{ |
| 1051 | + public bool $failWrites = false; |
| 1052 | + |
| 1053 | + public bool $failReads = false; |
| 1054 | + |
| 1055 | + public int $writes = 0; |
| 1056 | + |
| 1057 | + /** |
| 1058 | + * @var array<string, string> |
| 1059 | + */ |
| 1060 | + private array $objects = []; |
| 1061 | + |
| 1062 | + public function __construct( |
| 1063 | + private readonly string $namespace |
| 1064 | + ) { |
| 1065 | + } |
| 1066 | + |
| 1067 | + public function put(string $data, string $sha256, string $codec): string |
| 1068 | + { |
| 1069 | + if ($this->failWrites) { |
| 1070 | + throw new RuntimeException('caller write refused'); |
| 1071 | + } |
| 1072 | + ++$this->writes; |
| 1073 | + $uri = 'memory://' . $this->namespace . '/' . $sha256; |
| 1074 | + $this->objects[$uri] = $data; |
| 1075 | + |
| 1076 | + return $uri; |
| 1077 | + } |
| 1078 | + |
| 1079 | + public function get(string $uri): string |
| 1080 | + { |
| 1081 | + if ($this->failReads || ! isset($this->objects[$uri])) { |
| 1082 | + throw new RuntimeException('Reference is unavailable in this namespace.'); |
| 1083 | + } |
| 1084 | + |
| 1085 | + return $this->objects[$uri]; |
| 1086 | + } |
| 1087 | + |
| 1088 | + public function delete(string $uri): void |
| 1089 | + { |
| 1090 | + unset($this->objects[$uri]); |
| 1091 | + } |
| 1092 | +} |
| 1093 | + |
912 | 1094 | #[Type('nexus-waiting-caller')] |
913 | 1095 | final class NexusWaitingCallerWorkflow extends Workflow |
914 | 1096 | { |
|
0 commit comments