From 923f673be9d0ba0c4c3d75b0a2c2442dd8ae42fb Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Wed, 10 Jun 2026 15:19:19 +0300 Subject: [PATCH] feat: add Nexus integration --- composer.json | 1 + phpunit.xml.dist | 1 + psalm-baseline.xml | 148 ++-- psalm.xml | 5 + src/Client/WorkflowOptions.php | 120 +++ src/DataConverter/EncodedValues.php | 18 + .../Client/MultyOperation/OperationStatus.php | 4 +- .../Client/ServiceClientException.php | 2 +- src/Exception/Client/UnpackDetailsTrait.php | 3 +- src/Exception/Failure/FailureConverter.php | 78 ++ src/Exception/Failure/NexusHandlerFailure.php | 62 ++ .../Failure/NexusOperationFailure.php | 70 ++ .../CancelOperationInput.php | 40 + .../StartOperationInput.php | 45 ++ .../NexusOperationInboundCallsInterceptor.php | 53 ++ .../NexusOperationOutbound/GetInfoInput.php | 24 + ...NexusOperationOutboundCallsInterceptor.php | 55 ++ ...sOperationInboundCallsInterceptorTrait.php | 47 ++ ...OperationOutboundCallsInterceptorTrait.php | 36 + .../WorkflowOutboundCallsInterceptorTrait.php | 11 + .../ExecuteNexusOperationInput.php | 69 ++ .../WorkflowOutboundCallsInterceptor.php | 6 + src/Internal/Client/OnConflictOptions.php | 28 + src/Internal/Client/WorkflowStarter.php | 46 +- src/Internal/Declaration/Graph/ClassNode.php | 8 +- .../Instantiator/NexusServiceInstantiator.php | 114 +++ .../Declaration/NexusServiceInstance.php | 30 + .../Prototype/NexusOperationPrototype.php | 36 + .../Prototype/NexusServiceCollection.php | 19 + .../Prototype/NexusServicePrototype.php | 69 ++ .../Declaration/Reader/ActivityReader.php | 2 +- .../Declaration/Reader/NexusServiceReader.php | 299 +++++++ .../Declaration/Reader/WorkflowReader.php | 3 +- src/Internal/Nexus/HandlerErrorMapper.php | 85 ++ src/Internal/Nexus/NexusContext.php | 37 + .../Nexus/NexusInvocationRegistry.php | 39 + src/Internal/Nexus/NexusLinkConverter.php | 321 ++++++++ src/Internal/Nexus/NexusTaskHandler.php | 252 ++++++ src/Internal/ServiceContainer.php | 25 +- src/Internal/Support/DateInterval.php | 1 - src/Internal/Support/SystemClock.php | 25 + .../Request/ExecuteNexusOperation.php | 59 ++ .../Request/GetNexusOperationStarted.php | 29 + .../Request/RejectedOnCancelInterface.php | 19 + .../Transport/Router/CancelNexusOperation.php | 63 ++ .../Router/CancelNexusOperationMethod.php | 44 ++ .../Transport/Router/GetWorkerInfo.php | 11 +- .../Transport/Router/InvokeNexusOperation.php | 149 ++++ .../Transport/Router/InvokeUpdate.php | 50 +- src/Internal/Transport/Server.php | 31 +- src/Internal/Workflow/NexusOperationStub.php | 171 ++++ src/Internal/Workflow/NexusServiceProxy.php | 95 +++ src/Internal/Workflow/NexusStartEnvelope.php | 28 + src/Internal/Workflow/Process/Scope.php | 6 +- src/Internal/Workflow/WorkflowContext.php | 75 ++ src/Nexus/Attribute/AsyncOperation.php | 42 + src/Nexus/Attribute/Operation.php | 36 + src/Nexus/Attribute/Service.php | 42 + src/Nexus/Exception/ErrorType.php | 31 + src/Nexus/Exception/HandlerException.php | 73 ++ .../Exception/InvalidArgumentException.php | 14 + src/Nexus/Exception/NexusException.php | 19 + src/Nexus/Exception/OperationException.php | 50 ++ src/Nexus/Exception/RetryBehavior.php | 22 + .../Handler/AsyncOperationStartResult.php | 39 + .../ClosureMethodCancellationListener.php | 29 + src/Nexus/Handler/HeaderCollection.php | 49 ++ .../Handler/Internal/HandlerInterface.php | 63 ++ .../Internal/MethodOperationHandler.php | 55 ++ src/Nexus/Handler/Internal/ServiceHandler.php | 258 ++++++ .../Handler/Internal/WorkflowRunStarter.php | 114 +++ src/Nexus/Handler/LinkCollection.php | 48 ++ .../MethodCancellationListenerInterface.php | 26 + src/Nexus/Handler/MethodCanceller.php | 94 +++ src/Nexus/Handler/OperationCancelDetails.php | 23 + src/Nexus/Handler/OperationContext.php | 77 ++ .../Handler/OperationHandlerInterface.php | 49 ++ src/Nexus/Handler/OperationStartDetails.php | 36 + src/Nexus/Handler/OperationStartResult.php | 43 + .../Handler/SyncOperationStartResult.php | 31 + src/Nexus/Header.php | 126 +++ .../Failure/NexusFailureConverter.php | 35 + src/Nexus/Internal/Headers.php | 38 + .../Internal/WorkflowRunOperationToken.php | 100 +++ src/Nexus/Link.php | 58 ++ src/Nexus/LinkParser.php | 107 +++ src/Nexus/Nexus.php | 113 +++ src/Nexus/NexusOperationContext.php | 24 + src/Nexus/OperationInfo.php | 30 + src/Nexus/OperationState.php | 22 + .../Validation/OperationNameValidator.php | 33 + .../Validation/OperationTokenValidator.php | 33 + .../Validation/PrintableAsciiValidator.php | 47 ++ src/Nexus/Validation/ServiceNameValidator.php | 35 + src/Nexus/WorkflowHandle.php | 41 + src/Nexus/WorkflowRunOperation.php | 45 ++ .../Transport/Codec/ProtoCodec/Encoder.php | 21 +- ...UpdateResponse.php => CommandResponse.php} | 29 +- .../Command/Client/FailedClientResponse.php | 2 +- src/Worker/Worker.php | 46 ++ src/Worker/WorkerInterface.php | 15 + src/WorkerFactory.php | 3 + src/Workflow.php | 47 ++ src/Workflow/CompletionCallback.php | 65 ++ .../NexusOperationCancellationType.php | 38 + src/Workflow/NexusOperationHandle.php | 54 ++ src/Workflow/NexusOperationOptions.php | 117 +++ src/Workflow/NexusOperationStubInterface.php | 63 ++ src/Workflow/WorkflowContextInterface.php | 42 + src/Workflow/WorkflowExecutionInfo.php | 29 + stubs/CarbonInterval.phpstub | 26 + stubs/GoogleProtobufMapField.phpstub | 42 + stubs/GoogleProtobufRepeatedField.phpstub | 42 + testing/src/WorkerFactory.php | 1 + testing/src/WorkerMock.php | 12 + tests/Acceptance/.rr.yaml | 2 + .../App/Feature/WorkflowStubInjector.php | 2 +- tests/Acceptance/App/Runtime/Feature.php | 3 + tests/Acceptance/App/Runtime/State.php | 20 + .../App/Runtime/TemporalStarter.php | 1 + tests/Acceptance/App/RuntimeBuilder.php | 14 + tests/Acceptance/App/TaskQueueResolver.php | 4 + .../Acceptance/ExecutionStartedSubscriber.php | 15 + .../AsyncCancelTypes/AsyncCancelTypesTest.php | 277 +++++++ .../AsyncCompletion/AsyncCompletionTest.php | 147 ++++ .../Nexus/AsyncFailure/AsyncFailureTest.php | 234 ++++++ .../WorkflowRunOperationTest.php | 166 ++++ .../Nexus/Basic/NexusRegistrationTest.php | 88 +++ .../Extra/Nexus/Cancel/AsyncOperationTest.php | 157 ++++ .../CancelAfterCompleteTest.php | 127 +++ .../Nexus/Coexistence/CoexistenceTest.php | 113 +++ .../Extra/Nexus/Errors/ErrorsTest.php | 334 ++++++++ .../Extra/Nexus/Headers/HeadersTest.php | 79 ++ .../Idempotency/RequestIdIdempotencyTest.php | 174 +++++ .../Extra/Nexus/InputTypes/InputTypesTest.php | 168 ++++ .../Nexus/Interceptor/InterceptorTest.php | 324 ++++++++ .../Extra/Nexus/Links/LinksTest.php | 279 +++++++ .../Nexus/ManualToken/ManualTokenTest.php | 249 ++++++ .../MultiOperation/MultiOperationTest.php | 97 +++ .../Nexus/MultiService/MultiServiceTest.php | 83 ++ .../MultipleCallers/MultipleCallersTest.php | 135 ++++ .../Acceptance/Extra/Nexus/NexusEndpoint.php | 13 + .../Acceptance/Extra/Nexus/NexusEndpoints.php | 49 ++ .../Extra/Nexus/NexusHistoryAssertions.php | 75 ++ .../Extra/Nexus/NexusHttpClient.php | 52 ++ .../Extra/Nexus/NexusWorkerOptions.php | 20 + .../Extra/Nexus/Parallel/ParallelTest.php | 96 +++ .../ParallelCancel/CancelPropagationTest.php | 186 +++++ .../ParallelFailure/PartialFailureTest.php | 144 ++++ .../ParallelMixed/MixedSyncAsyncTest.php | 152 ++++ .../Extra/Nexus/Replay/ReplayTest.php | 400 ++++++++++ .../Nexus/ReverseLinks/ReverseLinkTest.php | 184 +++++ .../Nexus/SyncFailure/SyncFailureTest.php | 413 ++++++++++ .../SyncFromWorkflow/SyncFromWorkflowTest.php | 92 +++ .../Extra/Nexus/Timeout/TimeoutTest.php | 187 +++++ .../Extra/Workflow/MutexRunLockedTest.php | 5 +- tests/Acceptance/worker.php | 34 +- tests/Functional/ReplayerTestCase.php | 31 +- .../Fixtures/Service/FakeGreetingWorkflow.php | 14 + .../Service/GenericServiceInterface.php | 22 + .../Fixtures/Service/GreetingService.php | 57 ++ .../Service/GreetingServiceInterface.php | 27 + .../IntersectionInputServiceInterface.php | 22 + .../Service/NullableInputServiceInterface.php | 22 + .../Service/ThrowingGreetingService.php | 47 ++ .../Service/UnionInputServiceInterface.php | 22 + .../Service/UnionOutputServiceInterface.php | 22 + .../Service/UntypedInputServiceInterface.php | 23 + .../Fixtures/Service/VoidServiceInterface.php | 22 + .../AmbiguousServiceAlpha.php | 22 + .../AmbiguousServiceBeta.php | 22 + .../AmbiguousServiceImpl.php | 19 + .../DiamondCommonInterface.php | 22 + .../DiamondFinalInterface.php | 19 + .../DiamondLeftInterface.php | 19 + .../DiamondRightInterface.php | 19 + .../ServiceDefinition/EmptyService.php | 19 + .../FactoryWithParametersService.php | 23 + .../InvalidAsyncReturnTypeService.php | 22 + .../InvalidServiceDuplicateOperation.php | 25 + .../InvalidServiceNoAnnotation.php | 14 + .../InvalidServiceWithOperations.php | 27 + .../ServiceDefinition/InvalidSubService.php | 17 + .../ServiceDefinition/InvalidSuperService.php | 17 + .../NonPublicOperationService.php | 25 + .../NullableAsyncReturnTypeService.php | 23 + .../OperationOverrideMismatchService.php | 22 + .../ParentWithDifferentOperationName.php | 22 + .../PlainParentInterface.php | 20 + .../ServiceWithPlainParentInterface.php | 22 + .../ValidServiceWithOperations.php | 37 + .../ValidSuperServiceWithOperations.php | 25 + .../ServiceHandler/AuthInterceptor.php | 51 ++ .../ServiceHandler/ExternalJobHandler.php | 40 + .../ServiceHandler/FinishedJobHandler.php | 36 + .../ServiceHandler/LoggingInterceptor.php | 46 ++ .../ServiceHandler/ManualTokenService.php | 44 ++ .../UncancellableJobHandler.php | 40 + .../Fixtures/ServiceHandler/VoidService.php | 19 + .../ChildInheritingHandler.php | 14 + .../NoServiceAnnotation.php | 24 + .../ServiceImplInstance/ParentWithHandler.php | 26 + .../ServiceImplInstance/ServiceAsClass.php | 29 + .../ServiceWithExtraNonOperationMethod.php | 27 + tests/Nexus/Support/BindNexusService.php | 31 + tests/Nexus/Support/EncodesValues.php | 29 + tests/Nexus/Support/ExceptionAssertions.php | 41 + .../Support/MocksAsyncWorkflowClient.php | 34 + tests/Nexus/Unit/Exception/ErrorTypeTest.php | 38 + .../Unit/Handler/CancelOperationTest.php | 143 ++++ .../ClosureMethodCancellationListenerTest.php | 33 + .../Unit/Handler/HandlerExceptionTest.php | 149 ++++ .../Unit/Handler/HeaderCollectionTest.php | 62 ++ .../Nexus/Unit/Handler/LinkCollectionTest.php | 66 ++ .../Unit/Handler/ManualTokenOperationTest.php | 112 +++ .../Unit/Handler/MethodCancellerTest.php | 228 ++++++ .../Handler/NexusServiceInstantiatorTest.php | 81 ++ .../Handler/OperationCancelDetailsTest.php | 39 + .../Unit/Handler/OperationContextTest.php | 168 ++++ .../Handler/OperationStartDetailsTest.php | 61 ++ .../Unit/Handler/OperationStartResultTest.php | 69 ++ .../Handler/ServiceHandlerEdgeCasesTest.php | 48 ++ .../Handler/ServiceHandlerInterceptorTest.php | 244 ++++++ .../Handler/ServiceHandlerSerdeErrorsTest.php | 231 ++++++ .../Nexus/Unit/Handler/ServiceHandlerTest.php | 286 +++++++ tests/Nexus/Unit/HeaderTest.php | 137 ++++ .../Interceptor/CancelOperationInputTest.php | 65 ++ .../Interceptor/StartOperationInputTest.php | 80 ++ .../Failure/NexusFailureConverterTest.php | 46 ++ tests/Nexus/Unit/Internal/HeadersTest.php | 50 ++ tests/Nexus/Unit/LinkParserTest.php | 160 ++++ tests/Nexus/Unit/LinkTest.php | 69 ++ tests/Nexus/Unit/NexusFacadeTest.php | 161 ++++ .../Nexus/Unit/NexusOperationContextTest.php | 36 + tests/Nexus/Unit/NexusOperationTypesTest.php | 101 +++ .../Unit/NexusOutboundInterceptorTest.php | 121 +++ tests/Nexus/Unit/NexusServiceReaderTest.php | 204 +++++ tests/Nexus/Unit/OperationExceptionTest.php | 89 +++ tests/Nexus/Unit/OperationInfoTest.php | 58 ++ tests/Nexus/Unit/OperationStateTest.php | 36 + .../Validation/OperationNameValidatorTest.php | 38 + .../OperationTokenValidatorTest.php | 38 + .../PrintableAsciiValidatorTest.php | 80 ++ .../Validation/ServiceNameValidatorTest.php | 38 + tests/Support/FrozenClock.php | 43 + tests/Unit/Client/WorkflowOptionsTestCase.php | 107 +++ tests/Unit/DTO/CompletionCallbackTestCase.php | 53 ++ tests/Unit/DTO/WorkflowOptionsTestCase.php | 29 +- .../Failure/NexusHandlerFailureTestCase.php | 94 +++ .../Exception/FailureConverterTestCase.php | 319 ++++++++ tests/Unit/Framework/WorkerMock.php | 10 + .../Client/OnConflictOptionsTestCase.php | 51 ++ .../Client/WorkflowStarterTestCase.php | 139 ++++ .../Nexus/NexusLinkConverterTestCase.php | 501 ++++++++++++ .../Internal/Support/DateIntervalTestCase.php | 1 - .../Internal/Support/SystemClockTestCase.php | 35 + .../GetNexusOperationStartedTestCase.php | 29 + .../Router/GetWorkerInfoTestCase.php | 122 +++ .../Workflow/NexusOperationStubTestCase.php | 234 ++++++ .../Workflow/NexusServiceProxyTestCase.php | 166 ++++ .../WorkflowContextNexusOptionsTestCase.php | 61 ++ tests/Unit/Nexus/AwaitsNexusPromise.php | 59 ++ ...ancelNexusOperationMethodRouteTestCase.php | 118 +++ .../CancelNexusOperationRouteTestCase.php | 162 ++++ .../Unit/Nexus/HandlerErrorMapperTestCase.php | 179 +++++ .../Nexus/NexusContextAccessorTestCase.php | 85 ++ .../Nexus/NexusInvocationRegistryTestCase.php | 80 ++ .../Nexus/NexusOperationRoutesTestCase.php | 736 ++++++++++++++++++ .../Nexus/NexusServiceCollectionTestCase.php | 138 ++++ tests/Unit/Nexus/NexusTaskHandlerTestCase.php | 606 ++++++++++++++ .../Nexus/WorkflowRunOperationTestCase.php | 400 ++++++++++ .../WorkflowRunOperationTokenTestCase.php | 129 +++ .../Worker/NexusRegistrationGuardTestCase.php | 127 +++ ...NexusOperationCancellationTypeTestCase.php | 28 + .../Workflow/NexusOperationHandleTestCase.php | 91 +++ .../NexusOperationOptionsTestCase.php | 112 +++ 276 files changed, 21378 insertions(+), 200 deletions(-) create mode 100644 src/Exception/Failure/NexusHandlerFailure.php create mode 100644 src/Exception/Failure/NexusOperationFailure.php create mode 100644 src/Interceptor/NexusOperationInbound/CancelOperationInput.php create mode 100644 src/Interceptor/NexusOperationInbound/StartOperationInput.php create mode 100644 src/Interceptor/NexusOperationInboundCallsInterceptor.php create mode 100644 src/Interceptor/NexusOperationOutbound/GetInfoInput.php create mode 100644 src/Interceptor/NexusOperationOutboundCallsInterceptor.php create mode 100644 src/Interceptor/Trait/NexusOperationInboundCallsInterceptorTrait.php create mode 100644 src/Interceptor/Trait/NexusOperationOutboundCallsInterceptorTrait.php create mode 100644 src/Interceptor/WorkflowOutboundCalls/ExecuteNexusOperationInput.php create mode 100644 src/Internal/Client/OnConflictOptions.php create mode 100644 src/Internal/Declaration/Instantiator/NexusServiceInstantiator.php create mode 100644 src/Internal/Declaration/NexusServiceInstance.php create mode 100644 src/Internal/Declaration/Prototype/NexusOperationPrototype.php create mode 100644 src/Internal/Declaration/Prototype/NexusServiceCollection.php create mode 100644 src/Internal/Declaration/Prototype/NexusServicePrototype.php create mode 100644 src/Internal/Declaration/Reader/NexusServiceReader.php create mode 100644 src/Internal/Nexus/HandlerErrorMapper.php create mode 100644 src/Internal/Nexus/NexusContext.php create mode 100644 src/Internal/Nexus/NexusInvocationRegistry.php create mode 100644 src/Internal/Nexus/NexusLinkConverter.php create mode 100644 src/Internal/Nexus/NexusTaskHandler.php create mode 100644 src/Internal/Support/SystemClock.php create mode 100644 src/Internal/Transport/Request/ExecuteNexusOperation.php create mode 100644 src/Internal/Transport/Request/GetNexusOperationStarted.php create mode 100644 src/Internal/Transport/Request/RejectedOnCancelInterface.php create mode 100644 src/Internal/Transport/Router/CancelNexusOperation.php create mode 100644 src/Internal/Transport/Router/CancelNexusOperationMethod.php create mode 100644 src/Internal/Transport/Router/InvokeNexusOperation.php create mode 100644 src/Internal/Workflow/NexusOperationStub.php create mode 100644 src/Internal/Workflow/NexusServiceProxy.php create mode 100644 src/Internal/Workflow/NexusStartEnvelope.php create mode 100644 src/Nexus/Attribute/AsyncOperation.php create mode 100644 src/Nexus/Attribute/Operation.php create mode 100644 src/Nexus/Attribute/Service.php create mode 100644 src/Nexus/Exception/ErrorType.php create mode 100644 src/Nexus/Exception/HandlerException.php create mode 100644 src/Nexus/Exception/InvalidArgumentException.php create mode 100644 src/Nexus/Exception/NexusException.php create mode 100644 src/Nexus/Exception/OperationException.php create mode 100644 src/Nexus/Exception/RetryBehavior.php create mode 100644 src/Nexus/Handler/AsyncOperationStartResult.php create mode 100644 src/Nexus/Handler/ClosureMethodCancellationListener.php create mode 100644 src/Nexus/Handler/HeaderCollection.php create mode 100644 src/Nexus/Handler/Internal/HandlerInterface.php create mode 100644 src/Nexus/Handler/Internal/MethodOperationHandler.php create mode 100644 src/Nexus/Handler/Internal/ServiceHandler.php create mode 100644 src/Nexus/Handler/Internal/WorkflowRunStarter.php create mode 100644 src/Nexus/Handler/LinkCollection.php create mode 100644 src/Nexus/Handler/MethodCancellationListenerInterface.php create mode 100644 src/Nexus/Handler/MethodCanceller.php create mode 100644 src/Nexus/Handler/OperationCancelDetails.php create mode 100644 src/Nexus/Handler/OperationContext.php create mode 100644 src/Nexus/Handler/OperationHandlerInterface.php create mode 100644 src/Nexus/Handler/OperationStartDetails.php create mode 100644 src/Nexus/Handler/OperationStartResult.php create mode 100644 src/Nexus/Handler/SyncOperationStartResult.php create mode 100644 src/Nexus/Header.php create mode 100644 src/Nexus/Internal/Failure/NexusFailureConverter.php create mode 100644 src/Nexus/Internal/Headers.php create mode 100644 src/Nexus/Internal/WorkflowRunOperationToken.php create mode 100644 src/Nexus/Link.php create mode 100644 src/Nexus/LinkParser.php create mode 100644 src/Nexus/Nexus.php create mode 100644 src/Nexus/NexusOperationContext.php create mode 100644 src/Nexus/OperationInfo.php create mode 100644 src/Nexus/OperationState.php create mode 100644 src/Nexus/Validation/OperationNameValidator.php create mode 100644 src/Nexus/Validation/OperationTokenValidator.php create mode 100644 src/Nexus/Validation/PrintableAsciiValidator.php create mode 100644 src/Nexus/Validation/ServiceNameValidator.php create mode 100644 src/Nexus/WorkflowHandle.php create mode 100644 src/Nexus/WorkflowRunOperation.php rename src/Worker/Transport/Command/Client/{UpdateResponse.php => CommandResponse.php} (67%) create mode 100644 src/Workflow/CompletionCallback.php create mode 100644 src/Workflow/NexusOperationCancellationType.php create mode 100644 src/Workflow/NexusOperationHandle.php create mode 100644 src/Workflow/NexusOperationOptions.php create mode 100644 src/Workflow/NexusOperationStubInterface.php create mode 100644 stubs/CarbonInterval.phpstub create mode 100644 stubs/GoogleProtobufMapField.phpstub create mode 100644 stubs/GoogleProtobufRepeatedField.phpstub create mode 100644 tests/Acceptance/Extra/Nexus/AsyncCancelTypes/AsyncCancelTypesTest.php create mode 100644 tests/Acceptance/Extra/Nexus/AsyncCompletion/AsyncCompletionTest.php create mode 100644 tests/Acceptance/Extra/Nexus/AsyncFailure/AsyncFailureTest.php create mode 100644 tests/Acceptance/Extra/Nexus/AsyncWorkflow/WorkflowRunOperationTest.php create mode 100644 tests/Acceptance/Extra/Nexus/Basic/NexusRegistrationTest.php create mode 100644 tests/Acceptance/Extra/Nexus/Cancel/AsyncOperationTest.php create mode 100644 tests/Acceptance/Extra/Nexus/CancelAfterComplete/CancelAfterCompleteTest.php create mode 100644 tests/Acceptance/Extra/Nexus/Coexistence/CoexistenceTest.php create mode 100644 tests/Acceptance/Extra/Nexus/Errors/ErrorsTest.php create mode 100644 tests/Acceptance/Extra/Nexus/Headers/HeadersTest.php create mode 100644 tests/Acceptance/Extra/Nexus/Idempotency/RequestIdIdempotencyTest.php create mode 100644 tests/Acceptance/Extra/Nexus/InputTypes/InputTypesTest.php create mode 100644 tests/Acceptance/Extra/Nexus/Interceptor/InterceptorTest.php create mode 100644 tests/Acceptance/Extra/Nexus/Links/LinksTest.php create mode 100644 tests/Acceptance/Extra/Nexus/ManualToken/ManualTokenTest.php create mode 100644 tests/Acceptance/Extra/Nexus/MultiOperation/MultiOperationTest.php create mode 100644 tests/Acceptance/Extra/Nexus/MultiService/MultiServiceTest.php create mode 100644 tests/Acceptance/Extra/Nexus/MultipleCallers/MultipleCallersTest.php create mode 100644 tests/Acceptance/Extra/Nexus/NexusEndpoint.php create mode 100644 tests/Acceptance/Extra/Nexus/NexusEndpoints.php create mode 100644 tests/Acceptance/Extra/Nexus/NexusHistoryAssertions.php create mode 100644 tests/Acceptance/Extra/Nexus/NexusHttpClient.php create mode 100644 tests/Acceptance/Extra/Nexus/NexusWorkerOptions.php create mode 100644 tests/Acceptance/Extra/Nexus/Parallel/ParallelTest.php create mode 100644 tests/Acceptance/Extra/Nexus/ParallelCancel/CancelPropagationTest.php create mode 100644 tests/Acceptance/Extra/Nexus/ParallelFailure/PartialFailureTest.php create mode 100644 tests/Acceptance/Extra/Nexus/ParallelMixed/MixedSyncAsyncTest.php create mode 100644 tests/Acceptance/Extra/Nexus/Replay/ReplayTest.php create mode 100644 tests/Acceptance/Extra/Nexus/ReverseLinks/ReverseLinkTest.php create mode 100644 tests/Acceptance/Extra/Nexus/SyncFailure/SyncFailureTest.php create mode 100644 tests/Acceptance/Extra/Nexus/SyncFromWorkflow/SyncFromWorkflowTest.php create mode 100644 tests/Acceptance/Extra/Nexus/Timeout/TimeoutTest.php create mode 100644 tests/Nexus/Fixtures/Service/FakeGreetingWorkflow.php create mode 100644 tests/Nexus/Fixtures/Service/GenericServiceInterface.php create mode 100644 tests/Nexus/Fixtures/Service/GreetingService.php create mode 100644 tests/Nexus/Fixtures/Service/GreetingServiceInterface.php create mode 100644 tests/Nexus/Fixtures/Service/IntersectionInputServiceInterface.php create mode 100644 tests/Nexus/Fixtures/Service/NullableInputServiceInterface.php create mode 100644 tests/Nexus/Fixtures/Service/ThrowingGreetingService.php create mode 100644 tests/Nexus/Fixtures/Service/UnionInputServiceInterface.php create mode 100644 tests/Nexus/Fixtures/Service/UnionOutputServiceInterface.php create mode 100644 tests/Nexus/Fixtures/Service/UntypedInputServiceInterface.php create mode 100644 tests/Nexus/Fixtures/Service/VoidServiceInterface.php create mode 100644 tests/Nexus/Fixtures/ServiceDefinition/AmbiguousServiceAlpha.php create mode 100644 tests/Nexus/Fixtures/ServiceDefinition/AmbiguousServiceBeta.php create mode 100644 tests/Nexus/Fixtures/ServiceDefinition/AmbiguousServiceImpl.php create mode 100644 tests/Nexus/Fixtures/ServiceDefinition/DiamondCommonInterface.php create mode 100644 tests/Nexus/Fixtures/ServiceDefinition/DiamondFinalInterface.php create mode 100644 tests/Nexus/Fixtures/ServiceDefinition/DiamondLeftInterface.php create mode 100644 tests/Nexus/Fixtures/ServiceDefinition/DiamondRightInterface.php create mode 100644 tests/Nexus/Fixtures/ServiceDefinition/EmptyService.php create mode 100644 tests/Nexus/Fixtures/ServiceDefinition/FactoryWithParametersService.php create mode 100644 tests/Nexus/Fixtures/ServiceDefinition/InvalidAsyncReturnTypeService.php create mode 100644 tests/Nexus/Fixtures/ServiceDefinition/InvalidServiceDuplicateOperation.php create mode 100644 tests/Nexus/Fixtures/ServiceDefinition/InvalidServiceNoAnnotation.php create mode 100644 tests/Nexus/Fixtures/ServiceDefinition/InvalidServiceWithOperations.php create mode 100644 tests/Nexus/Fixtures/ServiceDefinition/InvalidSubService.php create mode 100644 tests/Nexus/Fixtures/ServiceDefinition/InvalidSuperService.php create mode 100644 tests/Nexus/Fixtures/ServiceDefinition/NonPublicOperationService.php create mode 100644 tests/Nexus/Fixtures/ServiceDefinition/NullableAsyncReturnTypeService.php create mode 100644 tests/Nexus/Fixtures/ServiceDefinition/OperationOverrideMismatchService.php create mode 100644 tests/Nexus/Fixtures/ServiceDefinition/ParentWithDifferentOperationName.php create mode 100644 tests/Nexus/Fixtures/ServiceDefinition/PlainParentInterface.php create mode 100644 tests/Nexus/Fixtures/ServiceDefinition/ServiceWithPlainParentInterface.php create mode 100644 tests/Nexus/Fixtures/ServiceDefinition/ValidServiceWithOperations.php create mode 100644 tests/Nexus/Fixtures/ServiceDefinition/ValidSuperServiceWithOperations.php create mode 100644 tests/Nexus/Fixtures/ServiceHandler/AuthInterceptor.php create mode 100644 tests/Nexus/Fixtures/ServiceHandler/ExternalJobHandler.php create mode 100644 tests/Nexus/Fixtures/ServiceHandler/FinishedJobHandler.php create mode 100644 tests/Nexus/Fixtures/ServiceHandler/LoggingInterceptor.php create mode 100644 tests/Nexus/Fixtures/ServiceHandler/ManualTokenService.php create mode 100644 tests/Nexus/Fixtures/ServiceHandler/UncancellableJobHandler.php create mode 100644 tests/Nexus/Fixtures/ServiceHandler/VoidService.php create mode 100644 tests/Nexus/Fixtures/ServiceImplInstance/ChildInheritingHandler.php create mode 100644 tests/Nexus/Fixtures/ServiceImplInstance/NoServiceAnnotation.php create mode 100644 tests/Nexus/Fixtures/ServiceImplInstance/ParentWithHandler.php create mode 100644 tests/Nexus/Fixtures/ServiceImplInstance/ServiceAsClass.php create mode 100644 tests/Nexus/Fixtures/ServiceImplInstance/ServiceWithExtraNonOperationMethod.php create mode 100644 tests/Nexus/Support/BindNexusService.php create mode 100644 tests/Nexus/Support/EncodesValues.php create mode 100644 tests/Nexus/Support/ExceptionAssertions.php create mode 100644 tests/Nexus/Support/MocksAsyncWorkflowClient.php create mode 100644 tests/Nexus/Unit/Exception/ErrorTypeTest.php create mode 100644 tests/Nexus/Unit/Handler/CancelOperationTest.php create mode 100644 tests/Nexus/Unit/Handler/ClosureMethodCancellationListenerTest.php create mode 100644 tests/Nexus/Unit/Handler/HandlerExceptionTest.php create mode 100644 tests/Nexus/Unit/Handler/HeaderCollectionTest.php create mode 100644 tests/Nexus/Unit/Handler/LinkCollectionTest.php create mode 100644 tests/Nexus/Unit/Handler/ManualTokenOperationTest.php create mode 100644 tests/Nexus/Unit/Handler/MethodCancellerTest.php create mode 100644 tests/Nexus/Unit/Handler/NexusServiceInstantiatorTest.php create mode 100644 tests/Nexus/Unit/Handler/OperationCancelDetailsTest.php create mode 100644 tests/Nexus/Unit/Handler/OperationContextTest.php create mode 100644 tests/Nexus/Unit/Handler/OperationStartDetailsTest.php create mode 100644 tests/Nexus/Unit/Handler/OperationStartResultTest.php create mode 100644 tests/Nexus/Unit/Handler/ServiceHandlerEdgeCasesTest.php create mode 100644 tests/Nexus/Unit/Handler/ServiceHandlerInterceptorTest.php create mode 100644 tests/Nexus/Unit/Handler/ServiceHandlerSerdeErrorsTest.php create mode 100644 tests/Nexus/Unit/Handler/ServiceHandlerTest.php create mode 100644 tests/Nexus/Unit/HeaderTest.php create mode 100644 tests/Nexus/Unit/Interceptor/CancelOperationInputTest.php create mode 100644 tests/Nexus/Unit/Interceptor/StartOperationInputTest.php create mode 100644 tests/Nexus/Unit/Internal/Failure/NexusFailureConverterTest.php create mode 100644 tests/Nexus/Unit/Internal/HeadersTest.php create mode 100644 tests/Nexus/Unit/LinkParserTest.php create mode 100644 tests/Nexus/Unit/LinkTest.php create mode 100644 tests/Nexus/Unit/NexusFacadeTest.php create mode 100644 tests/Nexus/Unit/NexusOperationContextTest.php create mode 100644 tests/Nexus/Unit/NexusOperationTypesTest.php create mode 100644 tests/Nexus/Unit/NexusOutboundInterceptorTest.php create mode 100644 tests/Nexus/Unit/NexusServiceReaderTest.php create mode 100644 tests/Nexus/Unit/OperationExceptionTest.php create mode 100644 tests/Nexus/Unit/OperationInfoTest.php create mode 100644 tests/Nexus/Unit/OperationStateTest.php create mode 100644 tests/Nexus/Unit/Validation/OperationNameValidatorTest.php create mode 100644 tests/Nexus/Unit/Validation/OperationTokenValidatorTest.php create mode 100644 tests/Nexus/Unit/Validation/PrintableAsciiValidatorTest.php create mode 100644 tests/Nexus/Unit/Validation/ServiceNameValidatorTest.php create mode 100644 tests/Support/FrozenClock.php create mode 100644 tests/Unit/Client/WorkflowOptionsTestCase.php create mode 100644 tests/Unit/DTO/CompletionCallbackTestCase.php create mode 100644 tests/Unit/Exception/Failure/NexusHandlerFailureTestCase.php create mode 100644 tests/Unit/Internal/Client/OnConflictOptionsTestCase.php create mode 100644 tests/Unit/Internal/Nexus/NexusLinkConverterTestCase.php create mode 100644 tests/Unit/Internal/Support/SystemClockTestCase.php create mode 100644 tests/Unit/Internal/Transport/Request/GetNexusOperationStartedTestCase.php create mode 100644 tests/Unit/Internal/Transport/Router/GetWorkerInfoTestCase.php create mode 100644 tests/Unit/Internal/Workflow/NexusOperationStubTestCase.php create mode 100644 tests/Unit/Internal/Workflow/NexusServiceProxyTestCase.php create mode 100644 tests/Unit/Internal/Workflow/WorkflowContextNexusOptionsTestCase.php create mode 100644 tests/Unit/Nexus/AwaitsNexusPromise.php create mode 100644 tests/Unit/Nexus/CancelNexusOperationMethodRouteTestCase.php create mode 100644 tests/Unit/Nexus/CancelNexusOperationRouteTestCase.php create mode 100644 tests/Unit/Nexus/HandlerErrorMapperTestCase.php create mode 100644 tests/Unit/Nexus/NexusContextAccessorTestCase.php create mode 100644 tests/Unit/Nexus/NexusInvocationRegistryTestCase.php create mode 100644 tests/Unit/Nexus/NexusOperationRoutesTestCase.php create mode 100644 tests/Unit/Nexus/NexusServiceCollectionTestCase.php create mode 100644 tests/Unit/Nexus/NexusTaskHandlerTestCase.php create mode 100644 tests/Unit/Nexus/WorkflowRunOperationTestCase.php create mode 100644 tests/Unit/Nexus/WorkflowRunOperationTokenTestCase.php create mode 100644 tests/Unit/Worker/NexusRegistrationGuardTestCase.php create mode 100644 tests/Unit/Workflow/NexusOperationCancellationTypeTestCase.php create mode 100644 tests/Unit/Workflow/NexusOperationHandleTestCase.php create mode 100644 tests/Unit/Workflow/NexusOperationOptionsTestCase.php diff --git a/composer.json b/composer.json index d3ea5fb04..4bc6543b5 100644 --- a/composer.json +++ b/composer.json @@ -30,6 +30,7 @@ "internal/destroy": "^1.0", "internal/promise": "^3.4", "nesbot/carbon": "^2.72.6 || ^3.8.4", + "psr/clock": "^1.0", "psr/log": "^2.0 || ^3.0.2", "ramsey/uuid": "^4.7.6", "roadrunner-php/roadrunner-api-dto": "^1.14.0", diff --git a/phpunit.xml.dist b/phpunit.xml.dist index f1ee28874..8d55fca81 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -75,6 +75,7 @@ tests/Unit + tests/Nexus/Unit tests/Functional diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 36b6ddf16..a8e764d1f 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -31,11 +31,6 @@ - - - - - name]]> @@ -99,9 +94,6 @@ excludeCalendarList]]> excludeCalendarList]]> - - - @@ -258,11 +250,6 @@ - - - - - @@ -447,9 +434,6 @@ - - ]]> - ]]> @@ -515,6 +499,12 @@ + ]]> + + + + + ]]> @@ -533,7 +523,8 @@ - ]]> + ]]> + ]]> @@ -585,18 +576,6 @@ - - - - - - - - - - - - getRunId()]]> @@ -609,16 +588,6 @@ - - - - - - - - - - @@ -817,17 +786,7 @@ - - - - - - - - - - @@ -924,10 +883,6 @@ - - - - @@ -936,13 +891,6 @@ getMessage()]]> - - - - - - - @@ -1003,6 +951,17 @@ + + + + + + + + + operation]]> + + @@ -1060,6 +1019,7 @@ maxSupported]]> minSupported]]> + operation]]> type]]> @@ -1102,6 +1062,37 @@ trace = &$this->trace]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + workflowId]]> + + @@ -1182,12 +1173,6 @@ - - serializeToString()]]> - - - - getCode()]]> getCode()]]> @@ -1210,22 +1195,10 @@ - - getOptions(), JSON_INVALID_UTF8_IGNORE | JSON_UNESCAPED_UNICODE)]]> - - getFailure()]]> - - - - - - failure]]> - - @@ -1360,6 +1333,12 @@ + + + + + + @@ -1492,19 +1471,6 @@ getHeader()]]> getPayloads()]]> - - - - - - - - getSeconds() + \round($eventTime->getNanos() / 1_000_000_000, 6)]]> - - - - getSeconds()]]> - diff --git a/psalm.xml b/psalm.xml index ef14c51ce..9c160f6d4 100644 --- a/psalm.xml +++ b/psalm.xml @@ -22,6 +22,11 @@ + + + + + diff --git a/src/Client/WorkflowOptions.php b/src/Client/WorkflowOptions.php index 027f2c522..a3fdd7b55 100644 --- a/src/Client/WorkflowOptions.php +++ b/src/Client/WorkflowOptions.php @@ -31,10 +31,14 @@ use Temporal\Internal\Marshaller\Type\CronType; use Temporal\Internal\Marshaller\Type\DateIntervalType; use Temporal\Internal\Marshaller\Type\NullableType; +use Temporal\Internal\Nexus\NexusLinkConverter; +use Temporal\Internal\Client\OnConflictOptions; use Temporal\Internal\Support\DateInterval; use Temporal\Internal\Support\Options; +use Temporal\Nexus\Link as NexusLink; use Temporal\Worker\Worker; use Temporal\Worker\WorkerFactoryInterface; +use Temporal\Workflow\CompletionCallback; /** * WorkflowOptions configuration parameters for starting a workflow execution. @@ -193,6 +197,17 @@ final class WorkflowOptions extends Options #[Marshal(name: 'VersioningOverride')] public ?VersioningOverride $versioningOverride = null; + public ?string $requestId = null; + + /** @var list */ + public array $completionCallbacks = []; + + /** @internal */ + public ?OnConflictOptions $onConflictOptions = null; + + /** @var list<\Temporal\Api\Common\V1\Link> */ + public array $links = []; + /** * @throws \Exception */ @@ -612,4 +627,109 @@ public function withPriority(Priority $priority): self $self->priority = $priority; return $self; } + + /** + * Pin gRPC `request_id`. `null` = fresh UUID per call. + * + * @return $this + */ + #[Pure] + public function withRequestId(?string $requestId): self + { + $self = clone $this; + $self->requestId = $requestId; + return $self; + } + + /** + * Append a Nexus completion callback. Use {@see self::withCompletionCallbacks()} + * with an empty argument list to clear. + * + * @param non-empty-string $url + * @param array $headers + * @return $this + */ + #[Pure] + public function withNexusCompletionCallback(string $url, array $headers = []): self + { + $self = clone $this; + $self->completionCallbacks = [...$this->completionCallbacks, new CompletionCallback($url, $headers)]; + return $self; + } + + /** + * Replace the full list of completion callbacks. + * + * @return $this + */ + #[Pure] + public function withCompletionCallbacks(CompletionCallback ...$callbacks): self + { + $self = clone $this; + $self->completionCallbacks = \array_values($callbacks); + return $self; + } + + /** + * @internal + * @return $this + */ + #[Pure] + public function withOnConflictOptionsInternal(?OnConflictOptions $options): self + { + $self = clone $this; + $self->onConflictOptions = $options; + return $self; + } + + /** + * Replace the link list with proto Link[] derived from Nexus-level URIs. + * + * @psalm-suppress ImpureMethodCall + * + * @param iterable $nexusLinks + * @return $this + */ + #[Pure] + public function withLinks(iterable $nexusLinks): self + { + $self = clone $this; + $self->links = NexusLinkConverter::toProtoLinks($nexusLinks); + return $self; + } + + /** + * Snapshot of the DTO for `var_dump`/`print_r`/Xdebug pretty-printing. + * + * Renders {@see \DateInterval} fields as ISO 8601 spec strings (e.g. + * `PT60S`); the default dump expands them into nine zero components and + * buries the actual values. + */ + public function __debugInfo(): array + { + return [ + 'workflowId' => $this->workflowId, + 'taskQueue' => $this->taskQueue, + 'eagerStart' => $this->eagerStart, + 'workflowExecutionTimeout' => CarbonInterval::instance($this->workflowExecutionTimeout)->spec(), + 'workflowRunTimeout' => CarbonInterval::instance($this->workflowRunTimeout)->spec(), + 'workflowStartDelay' => CarbonInterval::instance($this->workflowStartDelay)->spec(), + 'workflowTaskTimeout' => CarbonInterval::instance($this->workflowTaskTimeout)->spec(), + 'workflowIdReusePolicy' => $this->workflowIdReusePolicy, + 'workflowIdConflictPolicy' => $this->workflowIdConflictPolicy, + 'retryOptions' => $this->retryOptions, + 'cronSchedule' => $this->cronSchedule, + 'memo' => $this->memo, + 'searchAttributes' => $this->searchAttributes, + 'typedSearchAttributes' => $this->typedSearchAttributes, + 'staticDetails' => $this->staticDetails, + 'staticSummary' => $this->staticSummary, + 'priority' => $this->priority, + 'versioningOverride' => $this->versioningOverride, + 'requestId' => $this->requestId, + 'completionCallbacks' => $this->completionCallbacks, + 'onConflictOptions' => $this->onConflictOptions, + 'links' => $this->links, + ]; + } } diff --git a/src/DataConverter/EncodedValues.php b/src/DataConverter/EncodedValues.php index c94ca3219..0d32ebfe6 100644 --- a/src/DataConverter/EncodedValues.php +++ b/src/DataConverter/EncodedValues.php @@ -60,6 +60,24 @@ public static function fromPayloads(Payloads $payloads, DataConverterInterface $ return static::fromPayloadCollection($payloads->getPayloads(), $dataConverter); } + public static function fromPayload(?Payload $payload, DataConverterInterface $dataConverter): EncodedValues + { + $payloads = new Payloads(); + if ($payload !== null) { + $payloads->setPayloads([$payload]); + } + return static::fromPayloads($payloads, $dataConverter); + } + + public static function firstPayload(ValuesInterface $values): ?Payload + { + $payloads = $values->toPayloads()->getPayloads(); + if ($payloads->count() === 0) { + return null; + } + return $payloads[0]; + } + public static function sliceValues( DataConverterInterface $converter, ValuesInterface $values, diff --git a/src/Exception/Client/MultyOperation/OperationStatus.php b/src/Exception/Client/MultyOperation/OperationStatus.php index fea2e023e..8b191ccd1 100644 --- a/src/Exception/Client/MultyOperation/OperationStatus.php +++ b/src/Exception/Client/MultyOperation/OperationStatus.php @@ -17,7 +17,7 @@ final class OperationStatus use UnpackDetailsTrait; /** - * @param \ArrayAccess&RepeatedField $details + * @param RepeatedField $details */ private function __construct( private readonly \Traversable $details, @@ -35,7 +35,7 @@ public function getMessage(): string } /** - * @return \ArrayAccess&RepeatedField + * @return RepeatedField */ private function getDetails(): \Traversable { diff --git a/src/Exception/Client/ServiceClientException.php b/src/Exception/Client/ServiceClientException.php index d92d81a10..fe54cd108 100644 --- a/src/Exception/Client/ServiceClientException.php +++ b/src/Exception/Client/ServiceClientException.php @@ -44,7 +44,7 @@ public function getStatus(): Status } /** - * @return RepeatedField + * @return RepeatedField<\Google\Protobuf\Any> */ public function getDetails(): \ArrayAccess&\Countable&\IteratorAggregate { diff --git a/src/Exception/Client/UnpackDetailsTrait.php b/src/Exception/Client/UnpackDetailsTrait.php index 82449f4bb..ce79059b4 100644 --- a/src/Exception/Client/UnpackDetailsTrait.php +++ b/src/Exception/Client/UnpackDetailsTrait.php @@ -31,7 +31,6 @@ public function getFailure(string $class): ?object // ensures that message descriptor was added to the pool Message::initOnce(); - /** @var Any $detail */ foreach ($details as $detail) { if ($detail->is($class)) { return $detail->unpack(); @@ -42,7 +41,7 @@ public function getFailure(string $class): ?object } /** - * @return \ArrayAccess&RepeatedField + * @return RepeatedField */ abstract private function getDetails(): iterable; } diff --git a/src/Exception/Failure/FailureConverter.php b/src/Exception/Failure/FailureConverter.php index 646cef035..04b67f7cf 100644 --- a/src/Exception/Failure/FailureConverter.php +++ b/src/Exception/Failure/FailureConverter.php @@ -11,6 +11,9 @@ namespace Temporal\Exception\Failure; +use Temporal\Nexus\Exception\HandlerException as NexusHandlerException; +use Temporal\Nexus\Exception\OperationException as NexusOperationException; +use Temporal\Nexus\Internal\Failure\NexusFailureConverter; use Temporal\Api\Common\V1\ActivityType; use Temporal\Api\Common\V1\WorkflowExecution; use Temporal\Api\Common\V1\WorkflowType; @@ -19,6 +22,8 @@ use Temporal\Api\Failure\V1\CanceledFailureInfo; use Temporal\Api\Failure\V1\ChildWorkflowExecutionFailureInfo; use Temporal\Api\Failure\V1\Failure; +use Temporal\Api\Failure\V1\NexusHandlerFailureInfo; +use Temporal\Api\Failure\V1\NexusOperationFailureInfo; use Temporal\Api\Failure\V1\ServerFailureInfo; use Temporal\Api\Failure\V1\TerminatedFailureInfo; use Temporal\Api\Failure\V1\TimeoutFailureInfo; @@ -29,6 +34,8 @@ final class FailureConverter { + public const NEXUS_OPERATION_ERROR_TYPE_PREFIX = 'nexus.OperationError.'; + public static function mapFailureToException(Failure $failure, DataConverterInterface $converter): TemporalFailure { $e = self::createFailureException($failure, $converter); @@ -148,6 +155,45 @@ public static function mapExceptionToFailure(\Throwable $e, DataConverterInterfa $failure->setCanceledFailureInfo(new CanceledFailureInfo()); break; + case $e instanceof NexusHandlerException: + $info = new NexusHandlerFailureInfo(); + $info->setType($e->errorType->value); + $info->setRetryBehavior(NexusFailureConverter::mapRetryBehavior($e->retryBehavior)); + + $failure->setNexusHandlerFailureInfo($info); + break; + + case $e instanceof NexusHandlerFailure: + $info = new NexusHandlerFailureInfo(); + $info->setType($e->getType()); + $info->setRetryBehavior($e->getRetryBehavior()); + + $failure->setNexusHandlerFailureInfo($info); + break; + + case $e instanceof NexusOperationException: + // Encode state in tagged ApplicationFailureInfo (no dedicated proto yet). + $info = new ApplicationFailureInfo(); + $info->setType(self::NEXUS_OPERATION_ERROR_TYPE_PREFIX . $e->state->value); + $info->setNonRetryable(true); + + $failure->setApplicationFailureInfo($info); + break; + + case $e instanceof NexusOperationFailure: + $info = new NexusOperationFailureInfo(); + /** @psalm-suppress DeprecatedMethod */ + $info + ->setScheduledEventId($e->getScheduledEventId()) + ->setEndpoint($e->getEndpoint()) + ->setService($e->getService()) + ->setOperation($e->getOperation()) + ->setOperationId($e->getOperationToken()) + ->setOperationToken($e->getOperationToken()); + + $failure->setNexusOperationExecutionFailureInfo($info); + break; + default: $info = new ApplicationFailureInfo(); $info->setType($e::class); @@ -261,6 +307,38 @@ private static function createFailureException(Failure $failure, DataConverterIn $previous, ); + case $failure->hasNexusHandlerFailureInfo(): + $info = $failure->getNexusHandlerFailureInfo(); + \assert($info instanceof NexusHandlerFailureInfo); + + return new NexusHandlerFailure( + $failure->getMessage(), + $info->getType(), + $info->getRetryBehavior(), + $previous, + ); + + case $failure->hasNexusOperationExecutionFailureInfo(): + $info = $failure->getNexusOperationExecutionFailureInfo(); + \assert($info instanceof NexusOperationFailureInfo); + + // Fall back to deprecated operation_id for older servers. + $token = $info->getOperationToken(); + if ($token === '') { + /** @psalm-suppress DeprecatedMethod */ + $token = $info->getOperationId(); + } + + return new NexusOperationFailure( + $failure->getMessage(), + (int) $info->getScheduledEventId(), + $info->getEndpoint(), + $info->getService(), + $info->getOperation(), + $token, + $previous, + ); + default: throw new \InvalidArgumentException('Failure info not set'); } diff --git a/src/Exception/Failure/NexusHandlerFailure.php b/src/Exception/Failure/NexusHandlerFailure.php new file mode 100644 index 000000000..485d27929 --- /dev/null +++ b/src/Exception/Failure/NexusHandlerFailure.php @@ -0,0 +1,62 @@ +type; + } + + /** + * {@see \Temporal\Api\Enums\V1\NexusHandlerErrorRetryBehavior} value. + */ + public function getRetryBehavior(): int + { + return $this->retryBehavior; + } + + public function getErrorType(): ErrorType + { + return ErrorType::tryFrom($this->type) ?? ErrorType::Unknown; + } + + public function getRetryBehaviorEnum(): RetryBehavior + { + return match ($this->retryBehavior) { + NexusHandlerErrorRetryBehavior::NEXUS_HANDLER_ERROR_RETRY_BEHAVIOR_RETRYABLE => RetryBehavior::Retryable, + NexusHandlerErrorRetryBehavior::NEXUS_HANDLER_ERROR_RETRY_BEHAVIOR_NON_RETRYABLE => RetryBehavior::NonRetryable, + default => RetryBehavior::Unspecified, + }; + } +} diff --git a/src/Exception/Failure/NexusOperationFailure.php b/src/Exception/Failure/NexusOperationFailure.php new file mode 100644 index 000000000..afac4faea --- /dev/null +++ b/src/Exception/Failure/NexusOperationFailure.php @@ -0,0 +1,70 @@ + $endpoint, + 'service' => $service, + 'operation' => $operation, + 'operationToken' => $operationToken, + 'scheduledEventId' => $scheduledEventId, + ]), + $message, + $previous, + ); + } + + public function getScheduledEventId(): int + { + return $this->scheduledEventId; + } + + public function getEndpoint(): string + { + return $this->endpoint; + } + + public function getService(): string + { + return $this->service; + } + + public function getOperation(): string + { + return $this->operation; + } + + /** + * Async operation token issued by the handler — empty for sync operations. + */ + public function getOperationToken(): string + { + return $this->operationToken; + } +} diff --git a/src/Interceptor/NexusOperationInbound/CancelOperationInput.php b/src/Interceptor/NexusOperationInbound/CancelOperationInput.php new file mode 100644 index 000000000..6b08d9a10 --- /dev/null +++ b/src/Interceptor/NexusOperationInbound/CancelOperationInput.php @@ -0,0 +1,40 @@ +operationContext, + $cancelDetails ?? $this->cancelDetails, + ); + } +} diff --git a/src/Interceptor/NexusOperationInbound/StartOperationInput.php b/src/Interceptor/NexusOperationInbound/StartOperationInput.php new file mode 100644 index 000000000..0100cf202 --- /dev/null +++ b/src/Interceptor/NexusOperationInbound/StartOperationInput.php @@ -0,0 +1,45 @@ +operationContext, + $startDetails ?? $this->startDetails, + $input === self::UNSET ? $this->input : $input, + ); + } +} diff --git a/src/Interceptor/NexusOperationInboundCallsInterceptor.php b/src/Interceptor/NexusOperationInboundCallsInterceptor.php new file mode 100644 index 000000000..b75509030 --- /dev/null +++ b/src/Interceptor/NexusOperationInboundCallsInterceptor.php @@ -0,0 +1,53 @@ +operationContext->headers->get('authorization') !== 'expected-token') { + * throw HandlerException::create(ErrorType::Unauthorized, 'Unauthorized'); + * } + * + * return $next($input); + * } + * } + * ``` + * + * @see NexusOperationInboundCallsInterceptorTrait + */ +interface NexusOperationInboundCallsInterceptor extends Interceptor +{ + /** + * @param callable(StartOperationInput): OperationStartResult $next + */ + public function startOperation(StartOperationInput $input, callable $next): OperationStartResult; + + /** + * @param callable(CancelOperationInput): void $next + */ + public function cancelOperation(CancelOperationInput $input, callable $next): void; +} diff --git a/src/Interceptor/NexusOperationOutbound/GetInfoInput.php b/src/Interceptor/NexusOperationOutbound/GetInfoInput.php new file mode 100644 index 000000000..2337371d6 --- /dev/null +++ b/src/Interceptor/NexusOperationOutbound/GetInfoInput.php @@ -0,0 +1,24 @@ + $nexusHeaders raw-string headers forwarded + * on the Nexus operation wire (separate from the Temporal `Header` + * that carries payload-typed values). + * + * @no-named-arguments + * @internal Don't use the constructor. Use {@see self::with()} instead. + */ + public function __construct( + public readonly string $endpoint, + public readonly string $service, + public readonly string $operation, + public readonly array $args, + public readonly NexusOperationOptions $options, + public readonly null|Type|string|\ReflectionClass|\ReflectionType $returnType, + public readonly array $nexusHeaders = [], + ) { + if ($endpoint === '') { + throw new \InvalidArgumentException('$endpoint must be a non-empty string.'); + } + if ($service === '') { + throw new \InvalidArgumentException('$service must be a non-empty string.'); + } + if ($operation === '') { + throw new \InvalidArgumentException('$operation must be a non-empty string.'); + } + } + + public function with( + ?string $endpoint = null, + ?string $service = null, + ?string $operation = null, + ?array $args = null, + ?NexusOperationOptions $options = null, + null|Type|string|\ReflectionClass|\ReflectionType $returnType = null, + ?array $nexusHeaders = null, + ): self { + return new self( + $endpoint ?? $this->endpoint, + $service ?? $this->service, + $operation ?? $this->operation, + $args ?? $this->args, + $options ?? $this->options, + $returnType ?? $this->returnType, + $nexusHeaders ?? $this->nexusHeaders, + ); + } +} diff --git a/src/Interceptor/WorkflowOutboundCallsInterceptor.php b/src/Interceptor/WorkflowOutboundCallsInterceptor.php index 620f1a4f8..8c72edecc 100644 --- a/src/Interceptor/WorkflowOutboundCallsInterceptor.php +++ b/src/Interceptor/WorkflowOutboundCallsInterceptor.php @@ -21,6 +21,7 @@ use Temporal\Interceptor\WorkflowOutboundCalls\ExecuteActivityInput; use Temporal\Interceptor\WorkflowOutboundCalls\ExecuteChildWorkflowInput; use Temporal\Interceptor\WorkflowOutboundCalls\ExecuteLocalActivityInput; +use Temporal\Interceptor\WorkflowOutboundCalls\ExecuteNexusOperationInput; use Temporal\Interceptor\WorkflowOutboundCalls\GetVersionInput; use Temporal\Interceptor\WorkflowOutboundCalls\PanicInput; use Temporal\Interceptor\WorkflowOutboundCalls\SideEffectInput; @@ -141,4 +142,9 @@ public function await(AwaitInput $input, callable $next): PromiseInterface; * @param callable(AwaitWithTimeoutInput): PromiseInterface $next */ public function awaitWithTimeout(AwaitWithTimeoutInput $input, callable $next): PromiseInterface; + + /** + * @param callable(ExecuteNexusOperationInput): PromiseInterface $next + */ + public function executeNexusOperation(ExecuteNexusOperationInput $input, callable $next): PromiseInterface; } diff --git a/src/Internal/Client/OnConflictOptions.php b/src/Internal/Client/OnConflictOptions.php new file mode 100644 index 000000000..25ec82d5a --- /dev/null +++ b/src/Internal/Client/OnConflictOptions.php @@ -0,0 +1,28 @@ +setUrl($callback->url); + if ($callback->headers !== []) { + $nexus->setHeader($callback->headers); + } + + $proto = new Callback(); + $proto->setNexus($nexus); + if ($callback->links !== []) { + $proto->setLinks($callback->links); + } + return $proto; + } + + private static function onConflictOptionsToProto(OnConflictOptions $options): OnConflictOptionsProto + { + $proto = new OnConflictOptionsProto(); + $proto->setAttachRequestId($options->attachRequestId); + $proto->setAttachCompletionCallbacks($options->attachCompletionCallbacks); + $proto->setAttachLinks($options->attachLinks); + return $proto; + } + /** * @param StartWorkflowExecutionRequest|SignalWithStartWorkflowExecutionRequest $request * use {@see configureExecutionRequest()} to prepare request @@ -324,7 +353,7 @@ private function configureExecutionRequest( ): StartWorkflowExecutionRequest|SignalWithStartWorkflowExecutionRequest { $options = $input->options; - $req->setRequestId(Uuid::v4()) + $req->setRequestId($options->requestId ?? Uuid::v4()) ->setIdentity($this->clientOptions->identity) ->setNamespace($this->clientOptions->namespace) ->setTaskQueue(new TaskQueue(['name' => $options->taskQueue])) @@ -395,6 +424,21 @@ private function configureExecutionRequest( if ($req instanceof StartWorkflowExecutionRequest) { $req->setRequestEagerExecution($options->eagerStart); + + // SignalWithStart's proto has no completion_callbacks / on_conflict_options field — start path only. + if ($options->completionCallbacks !== []) { + $req->setCompletionCallbacks( + \array_map(self::completionCallbackToProto(...), $options->completionCallbacks), + ); + } + + if ($options->onConflictOptions !== null) { + $req->setOnConflictOptions(self::onConflictOptionsToProto($options->onConflictOptions)); + } + + if ($options->links !== []) { + $req->setLinks($options->links); + } } if (!$input->arguments->isEmpty()) { diff --git a/src/Internal/Declaration/Graph/ClassNode.php b/src/Internal/Declaration/Graph/ClassNode.php index 996b894bc..2d59fe5a8 100644 --- a/src/Internal/Declaration/Graph/ClassNode.php +++ b/src/Internal/Declaration/Graph/ClassNode.php @@ -58,7 +58,7 @@ public function count(): int * Get a method with all the declared classes. * * @param non-empty-string $name - * @return \Traversable> + * @return \Traversable> * @throws \ReflectionException */ public function getMethods(string $name, bool $reverse = true): \Traversable @@ -190,7 +190,7 @@ private function getInheritance(): array /** * @param iterable $classes - * @return list + * @return list * @throws \ReflectionException */ private function boxMethods(iterable $classes, string $name): array @@ -207,8 +207,8 @@ private function boxMethods(iterable $classes, string $name): array } /** - * @param array $boxed - * @return \Traversable + * @param array $boxed + * @return \Traversable */ private function unboxMethods(array $boxed): \Traversable { diff --git a/src/Internal/Declaration/Instantiator/NexusServiceInstantiator.php b/src/Internal/Declaration/Instantiator/NexusServiceInstantiator.php new file mode 100644 index 000000000..a801dd75d --- /dev/null +++ b/src/Internal/Declaration/Instantiator/NexusServiceInstantiator.php @@ -0,0 +1,114 @@ +resolveInstance($prototype); + $handlers = $this->buildOperationHandlers($prototype, $instance); + + return new NexusServiceInstance($prototype, $handlers); + } + + private function resolveInstance(NexusServicePrototype $prototype): object + { + $factory = $prototype->getFactory(); + if ($factory !== null) { + $instance = $factory(); + if (!\is_object($instance)) { + throw new InvalidArgumentException(\sprintf( + 'Nexus service factory for "%s" must return an object, got %s', + $prototype->getID(), + \get_debug_type($instance), + )); + } + return $instance; + } + + $reflection = $prototype->getClass(); + if (!$reflection->isInstantiable() || $reflection->getConstructor()?->getNumberOfRequiredParameters() > 0) { + throw new NexusException(\sprintf( + 'Service implementation for "%s" cannot be instantiated without arguments — bind via withInstance() or withFactory()', + $prototype->getID(), + )); + } + + return $reflection->newInstance(); + } + + /** + * @return array + */ + private function buildOperationHandlers(NexusServicePrototype $prototype, object $instance): array + { + $handlers = []; + $reflection = new \ReflectionClass($instance); + + foreach ($prototype->getOperations() as $operation) { + try { + $startMethod = $reflection->getMethod($operation->methodName); + } catch (\ReflectionException $e) { + throw new NexusException(\sprintf( + 'Service implementation %s is missing method %s() for operation "%s"', + $reflection->getName(), + $operation->methodName, + $operation->name, + ), 0, $e); + } + + if (!$startMethod->isPublic() || $startMethod->isStatic() || $startMethod->isAbstract()) { + throw new NexusException(\sprintf( + 'Operation method %s::%s() must be public and non-static', + $reflection->getName(), + $operation->methodName, + )); + } + + if (NexusServiceReader::returnsOperationHandler($startMethod)) { + $handler = $startMethod->invoke($instance); + if (!$handler instanceof OperationHandlerInterface) { + throw new NexusException(\sprintf( + 'Operation handler factory %s::%s() must return an %s instance, got %s', + $reflection->getName(), + $operation->methodName, + OperationHandlerInterface::class, + \get_debug_type($handler), + )); + } + $handlers[$operation->name] = $handler; + continue; + } + + $handlers[$operation->name] = new MethodOperationHandler( + instance: $instance, + startMethod: $startMethod, + operation: $operation, + ); + } + + return $handlers; + } +} diff --git a/src/Internal/Declaration/NexusServiceInstance.php b/src/Internal/Declaration/NexusServiceInstance.php new file mode 100644 index 000000000..532cfc9ec --- /dev/null +++ b/src/Internal/Declaration/NexusServiceInstance.php @@ -0,0 +1,30 @@ + $operationHandlers Keyed by wire operation name. + */ + public function __construct( + public readonly NexusServicePrototype $prototype, + public readonly array $operationHandlers, + ) {} +} diff --git a/src/Internal/Declaration/Prototype/NexusOperationPrototype.php b/src/Internal/Declaration/Prototype/NexusOperationPrototype.php new file mode 100644 index 000000000..e30aca3eb --- /dev/null +++ b/src/Internal/Declaration/Prototype/NexusOperationPrototype.php @@ -0,0 +1,36 @@ + + */ +final class NexusServiceCollection extends ArrayRepository {} diff --git a/src/Internal/Declaration/Prototype/NexusServicePrototype.php b/src/Internal/Declaration/Prototype/NexusServicePrototype.php new file mode 100644 index 000000000..458d95627 --- /dev/null +++ b/src/Internal/Declaration/Prototype/NexusServicePrototype.php @@ -0,0 +1,69 @@ + Keyed by wire operation name. */ + private readonly array $operations; + + private ?\Closure $factory = null; + + /** + * @param non-empty-string $name Wire-level service name. + * @param array $operations + */ + public function __construct( + string $name, + array $operations, + \ReflectionClass $class, + ) { + ServiceNameValidator::assert($name); + parent::__construct($name, null, $class); + $this->operations = $operations; + } + + /** + * @return array + */ + public function getOperations(): array + { + return $this->operations; + } + + public function getFactory(): ?\Closure + { + return $this->factory; + } + + /** + * Bind the given object as the service implementation. Equivalent to + * `withFactory(static fn() => $instance)` — same shape as + * {@see ActivityPrototype::withInstance()}. + */ + public function withInstance(object $instance): self + { + return $this->withFactory(static fn(): object => $instance); + } + + public function withFactory(\Closure $factory): self + { + $proto = clone $this; + $proto->factory = $factory; + return $proto; + } +} diff --git a/src/Internal/Declaration/Reader/ActivityReader.php b/src/Internal/Declaration/Reader/ActivityReader.php index c97c22a4c..5b00fed67 100644 --- a/src/Internal/Declaration/Reader/ActivityReader.php +++ b/src/Internal/Declaration/Reader/ActivityReader.php @@ -96,7 +96,7 @@ private function getMethodGroups(ClassNode $graph, \ReflectionMethod $root): arr // Each group of methods means one level of hierarchy in the // inheritance graph. // - /** @var ClassNode $ctx */ + /** @var \Traversable $group */ foreach ($group as $ctx => $method) { /** @var MethodRetry $retry */ $retry = $this->reader->firstFunctionMetadata($method, MethodRetry::class); diff --git a/src/Internal/Declaration/Reader/NexusServiceReader.php b/src/Internal/Declaration/Reader/NexusServiceReader.php new file mode 100644 index 000000000..9aee1474a --- /dev/null +++ b/src/Internal/Declaration/Reader/NexusServiceReader.php @@ -0,0 +1,299 @@ + + */ +class NexusServiceReader extends Reader +{ + /** + * @internal + */ + public static function returnsOperationHandler(\ReflectionMethod $method): bool + { + $returnType = $method->getReturnType(); + + return $returnType instanceof \ReflectionNamedType + && !$returnType->allowsNull() + && !$returnType->isBuiltin() + && \is_a($returnType->getName(), OperationHandlerInterface::class, true); + } + + /** + * @param class-string $class + */ + public function fromClass(string $class): NexusServicePrototype + { + $reflection = new \ReflectionClass($class); + $graph = new ClassNode($reflection); + + $serviceNodes = $this->serviceNodes($graph); + $contract = $this->resolveContract($reflection, $serviceNodes); + + $service = $this->reader->firstClassMetadata($contract, Service::class); + // resolveContract guarantees a Service attribute is present. + \assert($service !== null); + $name = $service->name !== '' ? $service->name : $contract->getShortName(); + + $this->assertServiceNamesMatch($serviceNodes, $contract, $name); + + $operations = $this->collectOperations($graph); + + return new NexusServicePrototype($name, $operations, $reflection); + } + + /** + * Locate the single `#[Service]`-bearing type for the hierarchy; the root wins if annotated. + * + * @param \ReflectionClass $root + * @param array> $matches + * @return \ReflectionClass + */ + private function resolveContract(\ReflectionClass $root, array $matches): \ReflectionClass + { + if ($this->reader->firstClassMetadata($root, Service::class) !== null) { + return $root; + } + + if ($matches === []) { + throw new InvalidArgumentException(\sprintf( + 'Missing #[Service] attribute on %s or any implemented interface', + $root->getName(), + )); + } + + if (\count($matches) > 1) { + throw new InvalidArgumentException(\sprintf( + '%s implements multiple #[Service] types (%s); ambiguous', + $root->getName(), + \implode(', ', \array_keys($matches)), + )); + } + + return \reset($matches); + } + + /** + * Reject hierarchies whose other `#[Service]` types declare a different service name. + * + * @param array> $serviceNodes + * @param \ReflectionClass $contract + */ + private function assertServiceNamesMatch(array $serviceNodes, \ReflectionClass $contract, string $name): void + { + foreach ($serviceNodes as $node) { + if ($node->getName() === $contract->getName()) { + continue; + } + + $service = $this->reader->firstClassMetadata($node, Service::class); + \assert($service !== null); + $nodeName = $service->name !== '' ? $service->name : $node->getShortName(); + if ($nodeName !== $name) { + throw new InvalidArgumentException( + "Interface {$node->getName()} has a service attribute whose name ({$nodeName}) " + . "does not match the expected name on the contract ({$name})", + ); + } + } + } + + /** + * Collect every `#[Service]`-annotated type across the hierarchy, keyed by class name. + * + * @return array> + */ + private function serviceNodes(ClassNode $graph): array + { + $matches = []; + foreach ($graph as $edge) { + foreach ($edge as $node) { + $reflection = $node->getReflection(); + if ($this->reader->firstClassMetadata($reflection, Service::class) !== null) { + $matches[$reflection->getName()] = $reflection; + } + } + } + + return $matches; + } + + /** + * @return array + */ + private function collectOperations(ClassNode $graph): array + { + $operationFailures = []; + $operations = []; + + foreach (\array_keys($graph->getAllMethods()) as $name) { + $first = null; + /** @var \Traversable $group */ + foreach ($graph->getMethods($name) as $group) { + foreach ($group as $method) { + try { + $attribute = $this->operationAttribute($method); + if ($attribute === null) { + continue; + } + + $current = $this->operationFromMethod($method, $attribute); + if ($first === null) { + $first = $current; + if (isset($operations[$first->name])) { + $operationFailures[] = "Multiple operations named '{$first->name}'"; + break 2; + } + $operations[$first->name] = $first; + } elseif ( + $first->name !== $current->name + || $first->inputType != $current->inputType + || $first->outputType != $current->outputType + ) { + $operationFailures[] = "{$method->getName()} on {$method->getDeclaringClass()->getName()} " + . 'mismatches against another operation of the same name/signature'; + break 2; + } + } catch (\Exception $exception) { + $operationFailures[] = "{$method->getName()} on {$method->getDeclaringClass()->getName()} " + . "is invalid: {$exception->getMessage()}"; + break 2; + } + } + } + } + + if (\count($operationFailures) > 0) { + throw new InvalidArgumentException( + \count($operationFailures) . ' operation(s) were invalid, reasons: ' + . \implode(', ', $operationFailures), + ); + } + + if (\count($operations) === 0) { + throw new InvalidArgumentException('No operations defined'); + } + + return $operations; + } + + /** + * Resolve the operation attribute for a method; `null` means the method is not an operation. + */ + private function operationAttribute(\ReflectionMethod $method): Operation|AsyncOperation|null + { + $sync = $this->reader->firstFunctionMetadata($method, Operation::class); + $async = $this->reader->firstFunctionMetadata($method, AsyncOperation::class); + + if ($sync !== null && $async !== null) { + throw new InvalidArgumentException('declares both #[Operation] and #[AsyncOperation]'); + } + + return $sync ?? $async; + } + + /** + * Build a {@see NexusOperationPrototype} from a resolved `#[Operation]` / `#[AsyncOperation]` attribute. + */ + private function operationFromMethod( + \ReflectionMethod $method, + Operation|AsyncOperation $attribute, + ): NexusOperationPrototype { + if ($method->getNumberOfParameters() > 1) { + throw new InvalidArgumentException('Can have no more than one parameter'); + } + if ($method->isStatic()) { + throw new InvalidArgumentException('Cannot be static'); + } + if (!$this->isValidMethod($method)) { + throw new InvalidArgumentException('Must be public'); + } + + $inputType = new Type(Type::TYPE_VOID); + if ($method->getNumberOfParameters() === 1) { + $inputType = Type::create($method->getParameters()[0]->getType()); + } + + $async = $attribute instanceof AsyncOperation ? $attribute : null; + + if ($async !== null) { + $this->assertAsyncReturnType($method); + $operationName = $async->name !== '' ? $async->name : $method->getName(); + $outputType = Type::create($async->output !== '' ? $async->output : Type::TYPE_VOID); + + if (self::returnsOperationHandler($method)) { + if ($method->getNumberOfParameters() !== 0) { + throw new InvalidArgumentException( + 'Operation handler factory must declare no parameters; ' + . 'the input arrives in OperationHandlerInterface::start()', + ); + } + $inputType = Type::create($async->input !== '' ? $async->input : Type::TYPE_ANY); + } + } else { + $operationName = $attribute->name !== '' ? $attribute->name : $method->getName(); + $outputType = Type::create($method->getReturnType()); + } + + return new NexusOperationPrototype( + name: $operationName, + methodName: $method->getName(), + inputType: $inputType, + outputType: $outputType, + async: $async !== null, + handler: $method, + ); + } + + /** + * An `#[AsyncOperation]` method must return a non-nullable `WorkflowHandle` + * (SDK-managed workflow run) or an `OperationHandlerInterface` implementation + * (manual operation owning both start and cancel). + */ + private function assertAsyncReturnType(\ReflectionMethod $method): void + { + $returnType = $method->getReturnType(); + if ( + $returnType instanceof \ReflectionNamedType + && !$returnType->allowsNull() + && $returnType->getName() === WorkflowHandle::class + ) { + return; + } + if (self::returnsOperationHandler($method)) { + return; + } + + throw new InvalidArgumentException(\sprintf( + '#[%s] method %s::%s() must declare a `%s` return type or return an `%s` implementation', + AsyncOperation::class, + $method->getDeclaringClass()->getName(), + $method->getName(), + WorkflowHandle::class, + OperationHandlerInterface::class, + )); + } +} diff --git a/src/Internal/Declaration/Reader/WorkflowReader.php b/src/Internal/Declaration/Reader/WorkflowReader.php index d97877a22..a90b78078 100644 --- a/src/Internal/Declaration/Reader/WorkflowReader.php +++ b/src/Internal/Declaration/Reader/WorkflowReader.php @@ -279,6 +279,7 @@ private function assertWorkflowInterface(ClassNode $graph): void */ private function getAttributedMethod(ClassNode $graph, \ReflectionMethod $handler, string $name): ?object { + /** @var \Traversable $group */ foreach ($graph->getMethods($handler->getName()) as $group) { foreach ($group as $method) { $attribute = $this->reader->firstFunctionMetadata($method, $name); @@ -301,7 +302,7 @@ private function getPrototype(ClassNode $graph, \ReflectionMethod $handler): ?Wo { $cronSchedule = $previousRetry = $prototype = $returnType = $versionBehavior = null; - /** @var \Traversable $group */ + /** @var \Traversable $group */ foreach ($graph->getMethods($handler->getName()) as $group) { $contextualRetry = $previousRetry; diff --git a/src/Internal/Nexus/HandlerErrorMapper.php b/src/Internal/Nexus/HandlerErrorMapper.php new file mode 100644 index 000000000..cf86dbdd1 --- /dev/null +++ b/src/Internal/Nexus/HandlerErrorMapper.php @@ -0,0 +1,85 @@ +isNonRetryable()) { + return HandlerException::fromCause(ErrorType::Internal, $e, RetryBehavior::NonRetryable); + } + + if ($e instanceof WorkflowNotFoundException) { + return HandlerException::fromCause(ErrorType::NotFound, $e); + } + + if ($e instanceof WorkflowExecutionAlreadyStartedException) { + return HandlerException::fromCause(ErrorType::Internal, $e, RetryBehavior::NonRetryable); + } + + if ($e instanceof WorkflowException) { + $previous = $e->getPrevious(); + if ($previous instanceof ServiceClientException) { + return self::fromGrpcCode($previous); + } + } + + if ($e instanceof ServiceClientException) { + return self::fromGrpcCode($e); + } + + return null; + } + + private static function fromGrpcCode(ServiceClientException $e): HandlerException + { + return match ($e->getCode()) { + Code::INVALID_ARGUMENT => HandlerException::fromCause(ErrorType::BadRequest, $e), + Code::ALREADY_EXISTS, + Code::FAILED_PRECONDITION, + Code::OUT_OF_RANGE => HandlerException::fromCause(ErrorType::Internal, $e, RetryBehavior::NonRetryable), + Code::ABORTED, + Code::UNAVAILABLE => HandlerException::fromCause(ErrorType::Unavailable, $e), + // Unauthenticated/PermissionDenied collapse to Internal: a handler-side auth failure against Temporal, not a Nexus-caller auth error. + Code::CANCELLED, + Code::DATA_LOSS, + Code::INTERNAL, + Code::UNKNOWN, + Code::UNAUTHENTICATED, + Code::PERMISSION_DENIED => HandlerException::fromCause(ErrorType::Internal, $e), + Code::NOT_FOUND => HandlerException::fromCause(ErrorType::NotFound, $e), + Code::RESOURCE_EXHAUSTED => HandlerException::fromCause(ErrorType::ResourceExhausted, $e), + Code::UNIMPLEMENTED => HandlerException::fromCause(ErrorType::NotImplemented, $e), + Code::DEADLINE_EXCEEDED => HandlerException::fromCause(ErrorType::UpstreamTimeout, $e), + default => HandlerException::fromCause(ErrorType::Internal, $e), + }; + } +} diff --git a/src/Internal/Nexus/NexusContext.php b/src/Internal/Nexus/NexusContext.php new file mode 100644 index 000000000..7aba8047b --- /dev/null +++ b/src/Internal/Nexus/NexusContext.php @@ -0,0 +1,37 @@ + $outboundPipeline + */ + public function __construct( + public readonly OperationContext $current, + public readonly ?NexusOperationContext $operation = null, + public readonly ?WorkflowClientInterface $workflowClient = null, + public readonly ?OperationStartDetails $startDetails = null, + public readonly ?OperationCancelDetails $cancelDetails = null, + public readonly ?Pipeline $outboundPipeline = null, + ) {} +} diff --git a/src/Internal/Nexus/NexusInvocationRegistry.php b/src/Internal/Nexus/NexusInvocationRegistry.php new file mode 100644 index 000000000..1fd6aec7a --- /dev/null +++ b/src/Internal/Nexus/NexusInvocationRegistry.php @@ -0,0 +1,39 @@ + */ + private array $cancellers = []; + + public function register(int $invocationId, MethodCanceller $canceller): void + { + $this->cancellers[$invocationId] = $canceller; + } + + public function unregister(int $invocationId): void + { + unset($this->cancellers[$invocationId]); + } + + public function get(int $invocationId): ?MethodCanceller + { + return $this->cancellers[$invocationId] ?? null; + } +} diff --git a/src/Internal/Nexus/NexusLinkConverter.php b/src/Internal/Nexus/NexusLinkConverter.php new file mode 100644 index 000000000..366818409 --- /dev/null +++ b/src/Internal/Nexus/NexusLinkConverter.php @@ -0,0 +1,321 @@ + $links + * @return list + * @throws InvalidArgumentException on malformed WorkflowEvent URI. + */ + public static function toProtoLinks(iterable $links): array + { + $out = []; + foreach ($links as $link) { + if ($link->type !== self::TYPE_WORKFLOW_EVENT) { + continue; + } + $out[] = self::convertOne($link); + } + return $out; + } + + /** + * Bare wire form: only `url` + `type`, no parsed `WorkflowEvent`; non-WorkflowEvent types pass through. + * + * @param iterable $links + * @return list + */ + public static function toNexusProtoLinks(iterable $links): array + { + $out = []; + foreach ($links as $link) { + $proto = new NexusProtoLink(); + $proto->setUrl($link->uri); + $proto->setType($link->type); + $out[] = $proto; + } + return $out; + } + + /** + * Wire `eventType` is written in PascalCase (`WorkflowExecutionStarted`); decoder accepts both forms. + * + * @throws InvalidArgumentException when WorkflowEvent has neither + * event_ref nor request_id_ref set, or carries an unknown + * EventType enum value. + */ + public static function workflowEventToNexusLink(WorkflowEvent $event): NexusLink + { + $path = \sprintf( + '/namespaces/%s/workflows/%s/%s/history', + \rawurlencode($event->getNamespace()), + \rawurlencode($event->getWorkflowId()), + \rawurlencode($event->getRunId()), + ); + + $query = self::buildEventQuery($event); + $uri = 'temporal://' . $path . '?' . self::encodeQuery($query); + + return new NexusLink($uri, self::TYPE_WORKFLOW_EVENT); + } + + private static function convertOne(NexusLink $link): Link + { + // Manual scheme/path/query split — parse_url rejects the `scheme:///path` (empty authority) form. + if (!\preg_match('~^([a-zA-Z][a-zA-Z0-9+.\-]*):(?://[^/?\#]*)?(/[^?\#]*)(?:\?([^\#]*))?(?:\#.*)?$~', $link->uri, $u)) { + throw new InvalidArgumentException(\sprintf( + 'malformed Nexus link URI: "%s"', + $link->uri, + )); + } + $scheme = $u[1]; + $path = $u[2]; + $queryString = $u[3] ?? ''; + + if ($scheme !== self::SCHEME) { + throw new InvalidArgumentException(\sprintf( + 'Nexus link URI scheme must be "temporal", got "%s" in "%s"', + $scheme, + $link->uri, + )); + } + if (!\preg_match(self::PATH_REGEX, $path, $m)) { + throw new InvalidArgumentException(\sprintf( + 'Nexus link URI path does not match /namespaces/{ns}/workflows/{wf}/{run}/history: "%s"', + $path, + )); + } + $namespace = \rawurldecode($m[1]); + $workflowId = \rawurldecode($m[2]); + $runId = \rawurldecode($m[3]); + + $query = []; + if ($queryString !== '') { + \parse_str($queryString, $query); + } + + $event = (new WorkflowEvent()) + ->setNamespace($namespace) + ->setWorkflowId($workflowId) + ->setRunId($runId); + + $referenceType = (string) ($query[self::QUERY_REFERENCE_TYPE] ?? ''); + $eventTypeName = (string) ($query[self::QUERY_EVENT_TYPE] ?? ''); + $eventTypeValue = self::resolveEventType($eventTypeName); + if ($eventTypeValue === null) { + throw new InvalidArgumentException(\sprintf( + 'unknown EventType "%s" in Nexus link URI "%s"', + $eventTypeName, + $link->uri, + )); + } + + match ($referenceType) { + self::REF_TYPE_EVENT => $event->setEventRef(self::buildEventRef($query, $eventTypeValue, $link->uri)), + self::REF_TYPE_REQUEST_ID => $event->setRequestIdRef(self::buildRequestIdRef($query, $eventTypeValue)), + default => throw new InvalidArgumentException(\sprintf( + 'unknown referenceType "%s" in Nexus link URI "%s"', + $referenceType, + $link->uri, + )), + }; + + $proto = new Link(); + $proto->setWorkflowEvent($event); + return $proto; + } + + /** + * Accepts both legacy `EVENT_TYPE_*` and modern PascalCase wire forms. + */ + private static function resolveEventType(string $name): ?int + { + if ($name === '') { + return null; + } + + if (\str_starts_with($name, 'EVENT_TYPE_')) { + return self::lookupEventTypeValue($name); + } + + if (!\preg_match('/^[A-Z]/', $name)) { + return null; + } + + return self::lookupEventTypeValue('EVENT_TYPE_' . self::pascalCaseToConstantCase($name)); + } + + private static function lookupEventTypeValue(string $screamingName): ?int + { + try { + return EventType::value($screamingName); + } catch (\UnexpectedValueException) { + return null; + } + } + + /** + * "WorkflowExecutionStarted" → "WORKFLOW_EXECUTION_STARTED". + */ + private static function pascalCaseToConstantCase(string $pascal): string + { + if ($pascal === '') { + return ''; + } + $withUnderscores = \preg_replace('/(? $query + */ + private static function buildEventRef(array $query, int $eventType, string $uri): EventReference + { + $eventReference = new EventReference(); + $eventReference->setEventType($eventType); + if (isset($query[self::QUERY_EVENT_ID]) && $query[self::QUERY_EVENT_ID] !== '') { + if (!\preg_match('/^\\d+$/', (string) $query[self::QUERY_EVENT_ID])) { + throw new InvalidArgumentException(\sprintf( + 'eventID is not an integer in Nexus link URI "%s"', + $uri, + )); + } + $eventReference->setEventId((int) $query[self::QUERY_EVENT_ID]); + } + return $eventReference; + } + + /** + * @param array $query + */ + private static function buildRequestIdRef(array $query, int $eventType): RequestIdReference + { + $requestId = $query[self::QUERY_REQUEST_ID] ?? ''; + if (!\is_string($requestId)) { + $requestId = ''; + } + $requestIdReference = new RequestIdReference(); + $requestIdReference->setRequestId($requestId); + $requestIdReference->setEventType($eventType); + return $requestIdReference; + } + + /** + * @return array + */ + private static function buildEventQuery(WorkflowEvent $event): array + { + if ($event->hasEventRef()) { + $eventRef = $event->getEventRef(); + \assert($eventRef !== null); + $query = [self::QUERY_REFERENCE_TYPE => self::REF_TYPE_EVENT]; + $eventId = (int) $eventRef->getEventId(); + if ($eventId > 0) { + $query[self::QUERY_EVENT_ID] = (string) $eventId; + } + $query[self::QUERY_EVENT_TYPE] = self::encodeEventTypeName($eventRef->getEventType(), $event); + return $query; + } + if ($event->hasRequestIdRef()) { + $requestRef = $event->getRequestIdRef(); + \assert($requestRef !== null); + $query = [self::QUERY_REFERENCE_TYPE => self::REF_TYPE_REQUEST_ID]; + $requestId = $requestRef->getRequestId(); + if ($requestId !== '') { + $query[self::QUERY_REQUEST_ID] = $requestId; + } + $query[self::QUERY_EVENT_TYPE] = self::encodeEventTypeName($requestRef->getEventType(), $event); + return $query; + } + throw new InvalidArgumentException( + 'WorkflowEvent must have either event_ref or request_id_ref set', + ); + } + + /** + * @param array $query + */ + private static function encodeQuery(array $query): string + { + $parts = []; + foreach ($query as $key => $value) { + $parts[] = \rawurlencode($key) . '=' . \rawurlencode($value); + } + return \implode('&', $parts); + } + + /** + * Convert protobuf int → PascalCase wire form (`WorkflowExecutionStarted`). + * + * @throws InvalidArgumentException when $value is not a known EventType. + */ + private static function encodeEventTypeName(int $value, WorkflowEvent $event): string + { + try { + $screaming = EventType::name($value); + } catch (\UnexpectedValueException) { + $screaming = null; + } + if ($screaming === null) { + throw new InvalidArgumentException(\sprintf( + 'unknown EventType enum value %d in WorkflowEvent (workflow_id="%s")', + $value, + $event->getWorkflowId(), + )); + } + if (!\str_starts_with($screaming, 'EVENT_TYPE_')) { + return $screaming; + } + return self::constantCaseToPascalCase(\substr($screaming, \strlen('EVENT_TYPE_'))); + } + + /** + * "WORKFLOW_EXECUTION_STARTED" → "WorkflowExecutionStarted". + */ + private static function constantCaseToPascalCase(string $screaming): string + { + if ($screaming === '') { + return ''; + } + $segments = \explode('_', \strtolower($screaming)); + return \implode('', \array_map(\ucfirst(...), $segments)); + } +} diff --git a/src/Internal/Nexus/NexusTaskHandler.php b/src/Internal/Nexus/NexusTaskHandler.php new file mode 100644 index 000000000..a4a098c37 --- /dev/null +++ b/src/Internal/Nexus/NexusTaskHandler.php @@ -0,0 +1,252 @@ + $headers + */ + public static function deadlineFromHeaders(array $headers): ?\DateTimeImmutable + { + $lowerHeaders = \array_change_key_case($headers, \CASE_LOWER); + + $value = NexusHeader::get($lowerHeaders, NexusHeader::OPERATION_TIMEOUT) + ?? NexusHeader::get($lowerHeaders, NexusHeader::REQUEST_TIMEOUT); + + if ($value === null || $value === '') { + return null; + } + + try { + return NexusHeader::deadlineFromTimeout($value); + } catch (InvalidArgumentException) { + return null; + } + } + + public function handleStartOperation( + Request $request, + NexusOperationContext $operationContext, + ?MethodCanceller $methodCanceller = null, + ): Response { + $startRequest = $request->getStartOperation(); + \assert($startRequest instanceof StartOperationRequest); + + $headers = []; + foreach ($request->getHeader() as $key => $value) { + $headers[(string) $key] = (string) $value; + } + + // Strict link parsing: malformed → BadRequest. + $links = LinkParser::fromProto($startRequest->getLinks()); + + $callbackHeaders = []; + foreach ($startRequest->getCallbackHeader() as $key => $value) { + $callbackHeaders[(string) $key] = (string) $value; + } + + $context = new OperationContext( + service: $startRequest->getService(), + operation: $startRequest->getOperation(), + headers: $headers, + deadline: self::deadlineFromHeaders($headers), + methodCanceller: $methodCanceller, + env: $this->env, + ); + + $input = EncodedValues::fromPayload($startRequest->getPayload(), $this->dataConverter); + + try { + $details = new OperationStartDetails( + requestId: $startRequest->getRequestId(), + callbackUrl: $startRequest->getCallback() ?: null, + callbackHeaders: $callbackHeaders, + links: $links, + ); + + $result = $this->getServiceHandler()->startOperation( + $context, + $details, + $input, + $this->workflowClient, + $operationContext, + ); + + $startResponse = new StartOperationResponse(); + + if ($result instanceof SyncOperationStartResult) { + $syncResponse = new StartOperationResponse\Sync(); + + if (!$result->value instanceof ValuesInterface) { + throw new \LogicException('sync start result must be ValuesInterface'); + } + $resultPayload = EncodedValues::firstPayload($result->value); + if ($resultPayload !== null) { + $syncResponse->setPayload($resultPayload); + } + + $contextLinks = $context->links->all(); + if ($contextLinks !== []) { + $syncResponse->setLinks(NexusLinkConverter::toNexusProtoLinks($contextLinks)); + } + + $startResponse->setSyncSuccess($syncResponse); + } else { + \assert($result instanceof AsyncOperationStartResult); + $asyncResponse = new StartOperationResponse\Async(); + /** @psalm-suppress DeprecatedMethod */ + $asyncResponse->setOperationId($result->info->token); + $asyncResponse->setOperationToken($result->info->token); + + $contextLinks = $context->links->all(); + if ($contextLinks !== []) { + $asyncResponse->setLinks(NexusLinkConverter::toNexusProtoLinks($contextLinks)); + } + + $startResponse->setAsyncSuccess($asyncResponse); + } + + $response = new Response(); + $response->setStartOperation($startResponse); + return $response; + } catch (OperationException $e) { + throw $e; + } catch (\Throwable $e) { + throw $this->toHandlerException($e); + } + } + + public function handleCancelOperation( + Request $request, + NexusOperationContext $operationContext, + ): Response { + $cancelRequest = $request->getCancelOperation(); + \assert($cancelRequest instanceof CancelOperationRequest); + + $headers = []; + foreach ($request->getHeader() as $key => $value) { + $headers[(string) $key] = (string) $value; + } + + $context = new OperationContext( + service: $cancelRequest->getService(), + operation: $cancelRequest->getOperation(), + headers: $headers, + env: $this->env, + ); + + $token = $cancelRequest->getOperationToken(); + if ($token === '') { + /** @psalm-suppress DeprecatedMethod — back-compat fallback */ + $token = $cancelRequest->getOperationId(); + } + + try { + $details = new OperationCancelDetails(operationToken: $token); + + $this->getServiceHandler()->cancelOperation( + $context, + $details, + $this->workflowClient, + $operationContext, + ); + + $response = new Response(); + $response->setCancelOperation(new CancelOperationResponse()); + return $response; + } catch (\Throwable $e) { + throw $this->toHandlerException($e); + } + } + + private function getServiceHandler(): ServiceHandler + { + if ($this->serviceHandler === null) { + $instantiator = new NexusServiceInstantiator(); + $instances = []; + foreach ($this->repository as $prototype) { + $instances[] = $instantiator->instantiate($prototype); + } + if ($instances === []) { + throw new \RuntimeException('No Nexus service implementations registered'); + } + + $this->serviceHandler = ServiceHandler::create( + dataConverter: $this->dataConverter, + instances: $instances, + interceptorProvider: $this->interceptorProvider, + ); + } + + return $this->serviceHandler; + } + + private function toHandlerException(\Throwable $e): HandlerException + { + if ($e instanceof HandlerException) { + return $e; + } + + if ($e instanceof InvalidArgumentException) { + return HandlerException::fromCause(ErrorType::BadRequest, $e, RetryBehavior::NonRetryable); + } + + return HandlerErrorMapper::mapToHandlerException($e) ?? HandlerException::fromCause(ErrorType::Internal, $e); + } +} diff --git a/src/Internal/ServiceContainer.php b/src/Internal/ServiceContainer.php index 5e6e1e9ac..72b62a8f7 100644 --- a/src/Internal/ServiceContainer.php +++ b/src/Internal/ServiceContainer.php @@ -13,14 +13,19 @@ use Psr\Log\LoggerInterface; use Spiral\Attributes\ReaderInterface; +use Temporal\Client\WorkflowClientInterface; use Temporal\DataConverter\DataConverterInterface; use Temporal\Exception\ExceptionInterceptorInterface; use Temporal\Interceptor\PipelineProvider; use Temporal\Internal\Declaration\Prototype\ActivityCollection; +use Temporal\Internal\Declaration\Prototype\NexusServiceCollection; use Temporal\Internal\Declaration\Prototype\WorkflowCollection; use Temporal\Internal\Declaration\Prototype\WorkflowPrototype; use Temporal\Internal\Declaration\Reader\ActivityReader; +use Temporal\Internal\Declaration\Reader\NexusServiceReader; use Temporal\Internal\Declaration\Reader\WorkflowReader; +use Temporal\Internal\Nexus\NexusInvocationRegistry; +use Temporal\Internal\Nexus\NexusTaskHandler; use Temporal\Internal\Marshaller\MarshallerInterface; use Temporal\Internal\Queue\QueueInterface; use Temporal\Internal\Repository\RepositoryInterface; @@ -39,6 +44,10 @@ final class ServiceContainer public readonly ActivityCollection $activities; public readonly WorkflowReader $workflowsReader; public readonly ActivityReader $activitiesReader; + public readonly NexusServiceCollection $nexusServices; + public readonly NexusInvocationRegistry $nexusInvocations; + public readonly NexusTaskHandler $nexusTaskHandler; + public readonly NexusServiceReader $nexusServicesReader; /** * @param MarshallerInterface $marshaller @@ -54,12 +63,24 @@ public function __construct( public readonly ExceptionInterceptorInterface $exceptionInterceptor, public readonly PipelineProvider $interceptorProvider, public readonly LoggerInterface $logger, + public readonly ?WorkflowClientInterface $workflowClient = null, ) { $this->workflows = new WorkflowCollection(); - $this->activities = new ActivityCollection(); $this->running = new ProcessCollection(); + $this->activities = new ActivityCollection(); $this->workflowsReader = new WorkflowReader($this->reader); $this->activitiesReader = new ActivityReader($this->reader); + + $this->nexusServices = new NexusServiceCollection(); + $this->nexusInvocations = new NexusInvocationRegistry(); + $this->nexusTaskHandler = new NexusTaskHandler( + $this->nexusServices, + $this->dataConverter, + interceptorProvider: $this->interceptorProvider, + workflowClient: $workflowClient, + env: $this->env, + ); + $this->nexusServicesReader = new NexusServiceReader($this->reader); } public static function fromWorkerFactory( @@ -67,6 +88,7 @@ public static function fromWorkerFactory( ExceptionInterceptorInterface $exceptionInterceptor, PipelineProvider $interceptorProvider, LoggerInterface $logger, + ?WorkflowClientInterface $workflowClient = null, ): self { return new self( $worker, @@ -79,6 +101,7 @@ public static function fromWorkerFactory( $exceptionInterceptor, $interceptorProvider, $logger, + $workflowClient, ); } } diff --git a/src/Internal/Support/DateInterval.php b/src/Internal/Support/DateInterval.php index 2863aded0..a6a36614b 100644 --- a/src/Internal/Support/DateInterval.php +++ b/src/Internal/Support/DateInterval.php @@ -53,7 +53,6 @@ final class DateInterval /** * @psalm-param DateIntervalFormat $format * - * @psalm-assert DateIntervalValue|null $interval * @psalm-suppress InvalidOperand */ public static function parse(mixed $interval, string $format = self::FORMAT_MILLISECONDS): CarbonInterval diff --git a/src/Internal/Support/SystemClock.php b/src/Internal/Support/SystemClock.php new file mode 100644 index 000000000..95e49076d --- /dev/null +++ b/src/Internal/Support/SystemClock.php @@ -0,0 +1,25 @@ + $nexusHeaders Raw-string Nexus headers + * forwarded to the handler via the wire — distinct from `$header` + * (Temporal interceptor header, payload-typed values). + */ + public function __construct( + string $endpoint, + string $service, + string $operation, + ValuesInterface $args, + array $options, + HeaderInterface $header, + array $nexusHeaders = [], + ) { + parent::__construct( + self::NAME, + [ + 'endpoint' => $endpoint, + 'service' => $service, + 'operation' => $operation, + 'options' => $options, + // Force `{}` over `[]` on the wire — Go side decodes as map[string]string. + 'nexusHeaders' => $nexusHeaders === [] ? new \stdClass() : $nexusHeaders, + ], + $args, + header: $header, + ); + } +} diff --git a/src/Internal/Transport/Request/GetNexusOperationStarted.php b/src/Internal/Transport/Request/GetNexusOperationStarted.php new file mode 100644 index 000000000..6833e52a4 --- /dev/null +++ b/src/Internal/Transport/Request/GetNexusOperationStarted.php @@ -0,0 +1,29 @@ + $id]); + } +} diff --git a/src/Internal/Transport/Request/RejectedOnCancelInterface.php b/src/Internal/Transport/Request/RejectedOnCancelInterface.php new file mode 100644 index 000000000..658defef5 --- /dev/null +++ b/src/Internal/Transport/Request/RejectedOnCancelInterface.php @@ -0,0 +1,19 @@ +getOptions(); + $operationContext = $this->marshaller->unmarshal($options, new NexusOperationContext()); + $protoRequest = self::buildProtoRequest($options); + $this->taskHandler->handleCancelOperation( + $protoRequest, + $operationContext, + ); + $resolver->resolve(EncodedValues::fromValues([])); + } catch (\Throwable $e) { + $resolver->reject($e); + } + } + + /** + * @param array $options + */ + private static function buildProtoRequest(array $options): Request + { + $cancelRequest = (new CancelOperationRequest()) + ->setService((string) ($options['service'] ?? '')) + ->setOperation((string) ($options['operation'] ?? '')) + ->setOperationToken((string) ($options['operationToken'] ?? '')); + + return (new Request()) + ->setHeader((array) ($options['headers'] ?? [])) + ->setCancelOperation($cancelRequest); + } +} diff --git a/src/Internal/Transport/Router/CancelNexusOperationMethod.php b/src/Internal/Transport/Router/CancelNexusOperationMethod.php new file mode 100644 index 000000000..8dbbe0350 --- /dev/null +++ b/src/Internal/Transport/Router/CancelNexusOperationMethod.php @@ -0,0 +1,44 @@ +getOptions(); + $invocationId = (int) ($options['invocationId'] ?? 0); + $reason = (string) ($options['reason'] ?? ''); + + if ($invocationId !== 0) { + $this->invocations->get($invocationId)?->cancel($reason); + } + + $resolver->resolve(EncodedValues::fromValues([])); + } +} diff --git a/src/Internal/Transport/Router/GetWorkerInfo.php b/src/Internal/Transport/Router/GetWorkerInfo.php index 596c214d4..e0710a879 100644 --- a/src/Internal/Transport/Router/GetWorkerInfo.php +++ b/src/Internal/Transport/Router/GetWorkerInfo.php @@ -15,6 +15,7 @@ use Temporal\Common\SdkVersion; use Temporal\DataConverter\EncodedValues; use Temporal\Internal\Declaration\Prototype\ActivityPrototype; +use Temporal\Internal\Declaration\Prototype\NexusServicePrototype; use Temporal\Internal\Declaration\Prototype\WorkflowPrototype; use Temporal\Internal\Marshaller\MarshallerInterface; use Temporal\Internal\Repository\RepositoryInterface; @@ -57,7 +58,12 @@ private function workerToArray(WorkerInterface $worker): array 'Name' => $activity->getID(), ]; - $map = $this->map($this->pluginRegistry->getPlugins(PluginInterface::class), static fn(PluginInterface $plugin): array => [ + $nexusServiceMap = static fn(NexusServicePrototype $service): array => [ + 'name' => $service->getID(), + 'operations' => \array_keys($service->getOperations()), + ]; + + $plugins = $this->map($this->pluginRegistry->getPlugins(PluginInterface::class), static fn(PluginInterface $plugin): array => [ 'Name' => $plugin->getName(), 'Version' => null, ]); @@ -69,8 +75,9 @@ private function workerToArray(WorkerInterface $worker): array // ActivityInfo[] 'Activities' => $this->map($worker->getActivities(), $activityMap), 'PhpSdkVersion' => SdkVersion::getSdkVersion(), - 'Plugins' => $map, + 'Plugins' => $plugins, 'Flags' => (object) $this->prepareFlags(), + 'NexusServices' => $this->map($worker->getNexusServices(), $nexusServiceMap), ]; } diff --git a/src/Internal/Transport/Router/InvokeNexusOperation.php b/src/Internal/Transport/Router/InvokeNexusOperation.php new file mode 100644 index 000000000..adf5215ae --- /dev/null +++ b/src/Internal/Transport/Router/InvokeNexusOperation.php @@ -0,0 +1,149 @@ +getOptions(); + $invocationId = (int) ($options['invocationId'] ?? 0); + $operationContext = $this->marshaller->unmarshal($options, new NexusOperationContext()); + + $canceller = null; + if ($invocationId !== 0) { + $canceller = new MethodCanceller( + $this->env, + NexusTaskHandler::deadlineFromHeaders((array) ($options['headers'] ?? [])), + ); + $this->invocations->register($invocationId, $canceller); + } + + try { + $protoRequest = self::buildProtoRequest($options, $request->getPayloads()); + $response = $this->taskHandler->handleStartOperation( + $protoRequest, + $operationContext, + $canceller, + ); + + $startResponse = $response->getStartOperation(); + \assert($startResponse !== null); + + if ($startResponse->hasSyncSuccess()) { + $sync = $startResponse->getSyncSuccess(); + \assert($sync !== null); + $responseOptions = ['async' => false]; + $protoLinks = $sync->getLinks(); + $payloads = EncodedValues::fromPayload($sync->getPayload(), $this->dataConverter); + } elseif ($startResponse->hasAsyncSuccess()) { + $async = $startResponse->getAsyncSuccess(); + \assert($async !== null); + $responseOptions = ['async' => true]; + $token = $async->getOperationToken(); + if ($token !== '') { + $responseOptions['token'] = $token; + } + $protoLinks = $async->getLinks(); + $payloads = null; + } else { + $resolver->reject(new \LogicException('NexusTaskHandler returned a response with no success variant')); + return; + } + + $links = self::linksToWire($protoLinks); + if ($links !== []) { + $responseOptions['links'] = $links; + } + + $resolver->resolve(new CommandResponse( + command: self::COMMAND, + options: $responseOptions, + payloads: $payloads, + )); + } catch (\Throwable $e) { + $resolver->reject($e); + } finally { + if ($invocationId !== 0) { + $this->invocations->unregister($invocationId); + } + } + } + + /** + * @param array $options + */ + private static function buildProtoRequest(array $options, ValuesInterface $payloads): Request + { + $requestId = ((string) ($options['requestId'] ?? '')) ?: Uuid::v4(); + + $startRequest = (new StartOperationRequest()) + ->setService((string) ($options['service'] ?? '')) + ->setOperation((string) ($options['operation'] ?? '')) + ->setRequestId($requestId) + ->setCallback((string) ($options['callback'] ?? '')) + ->setCallbackHeader((array) ($options['callbackHeaders'] ?? [])) + ->setLinks(NexusLinkConverter::toNexusProtoLinks( + LinkParser::fromRaw($options['links'] ?? null), + )); + + $inputPayload = EncodedValues::firstPayload($payloads); + if ($inputPayload !== null) { + $startRequest->setPayload($inputPayload); + } + + return (new Request()) + ->setHeader((array) ($options['headers'] ?? [])) + ->setStartOperation($startRequest); + } + + /** + * @param iterable<\Temporal\Api\Nexus\V1\Link> $links + * @return list + */ + private static function linksToWire(iterable $links): array + { + $out = []; + foreach ($links as $link) { + $out[] = ['url' => $link->getUrl(), 'type' => $link->getType()]; + } + return $out; + } +} diff --git a/src/Internal/Transport/Router/InvokeUpdate.php b/src/Internal/Transport/Router/InvokeUpdate.php index 5f228d324..107c4d077 100644 --- a/src/Internal/Transport/Router/InvokeUpdate.php +++ b/src/Internal/Transport/Router/InvokeUpdate.php @@ -16,12 +16,14 @@ use Temporal\DataConverter\EncodedValues; use Temporal\Interceptor\WorkflowInbound\UpdateInput; use Temporal\Internal\Declaration\WorkflowInstance\UpdateDispatcher; -use Temporal\Worker\Transport\Command\Client\UpdateResponse; +use Temporal\Worker\Transport\Command\Client\CommandResponse; use Temporal\Worker\Transport\Command\ServerRequestInterface; final class InvokeUpdate extends WorkflowProcessAwareRoute { private const ERROR_HANDLER_NOT_FOUND = 'unknown update method %s. KnownUpdateNames=[%s]'; + private const COMMAND_VALIDATED = 'UpdateValidated'; + private const COMMAND_COMPLETED = 'UpdateCompleted'; public function handle(ServerRequestInterface $request, array $headers, Deferred $resolver): void { @@ -54,32 +56,34 @@ public function handle(ServerRequestInterface $request, array $headers, Deferred $isReplay = (bool) ($request->getOptions()['replay'] ?? false); if ($isReplay) { // On replay, we don't need to execute validation handlers - $context->getClient()->send(new UpdateResponse( - command: UpdateResponse::COMMAND_VALIDATED, - values: null, + $context->getClient()->send(new CommandResponse( + command: self::COMMAND_VALIDATED, + options: ['id' => $updateId], + payloads: null, failure: null, - updateId: $updateId, )); } else { $validator = $updateDispatcher->findValidateUpdateHandler($name); // Validation will be passed if no validation handler is found - $validator === null or $validator($input); - - $context->getClient()->send(new UpdateResponse( - command: UpdateResponse::COMMAND_VALIDATED, - values: null, + if ($validator !== null) { + $validator($input); + } + + $context->getClient()->send(new CommandResponse( + command: self::COMMAND_VALIDATED, + options: ['id' => $updateId], + payloads: null, failure: null, - updateId: $updateId, )); } } catch (\Throwable $e) { $context->getClient()->send( - new UpdateResponse( - command: UpdateResponse::COMMAND_VALIDATED, - values: null, + new CommandResponse( + command: self::COMMAND_VALIDATED, + options: ['id' => $updateId], + payloads: null, failure: $e, - updateId: $updateId, ), ); return; @@ -90,19 +94,19 @@ public function handle(ServerRequestInterface $request, array $headers, Deferred $deferred = new Deferred(); $deferred->promise()->then( static function (mixed $value) use ($updateId, $context): void { - $context->getClient()->send(new UpdateResponse( - command: UpdateResponse::COMMAND_COMPLETED, - values: EncodedValues::fromValues([$value]), + $context->getClient()->send(new CommandResponse( + command: self::COMMAND_COMPLETED, + options: ['id' => $updateId], + payloads: EncodedValues::fromValues([$value]), failure: null, - updateId: $updateId, )); }, static function (\Throwable $err) use ($updateId, $context): void { - $context->getClient()->send(new UpdateResponse( - command: UpdateResponse::COMMAND_COMPLETED, - values: null, + $context->getClient()->send(new CommandResponse( + command: self::COMMAND_COMPLETED, + options: ['id' => $updateId], + payloads: null, failure: $err, - updateId: $updateId, )); }, ); diff --git a/src/Internal/Transport/Server.php b/src/Internal/Transport/Server.php index c902232a0..08af54368 100644 --- a/src/Internal/Transport/Server.php +++ b/src/Internal/Transport/Server.php @@ -20,10 +20,10 @@ use Temporal\Plugin\WorkerPluginInterface; use Temporal\Worker\Transport\Command\Client\FailedClientResponse; use Temporal\Worker\Transport\Command\Client\SuccessClientResponse; +use Temporal\Worker\Transport\Command\CommandInterface; use Temporal\Worker\Transport\Command\FailureResponseInterface; -use Temporal\Worker\Transport\Command\RequestInterface; +use Temporal\Worker\Transport\Command\ResponseInterface; use Temporal\Worker\Transport\Command\ServerRequestInterface; -use Temporal\Worker\Transport\Command\SuccessResponseInterface; /** * @psalm-import-type OnMessageHandler from ServerInterface @@ -57,9 +57,6 @@ public function onMessage(callable $then): void $this->onMessage = $then(...); } - /** - * @param RequestInterface $request - */ public function dispatch(ServerRequestInterface $request, array $headers): void { try { @@ -74,22 +71,30 @@ public function dispatch(ServerRequestInterface $request, array $headers): void return; } - $result instanceof PromiseInterface or throw new \BadMethodCallException(\sprintf( - self::ERROR_INVALID_RETURN_TYPE, - PromiseInterface::class, - \get_debug_type($result), - )); + if (!$result instanceof PromiseInterface) { + throw new \BadMethodCallException(\sprintf( + self::ERROR_INVALID_RETURN_TYPE, + PromiseInterface::class, + \get_debug_type($result), + )); + } $result->then($this->onFulfilled($request), $this->onRejected($request)); } /** - * @return \Closure(mixed): SuccessResponseInterface + * Routes that need a typed reply command (e.g. {@see \Temporal\Worker\Transport\Command\Client\CommandResponse}) + * resolve directly with a {@see ResponseInterface} instance and it is + * pushed verbatim. Anything else is wrapped as a {@see SuccessClientResponse}. + * + * @return \Closure(mixed): CommandInterface */ private function onFulfilled(ServerRequestInterface $request): \Closure { - return function ($result) use ($request) { - $response = new SuccessClientResponse($request->getID(), $result); + return function ($result) use ($request): CommandInterface { + $response = $result instanceof ResponseInterface + ? $result + : new SuccessClientResponse($request->getID(), $result); $this->queue->push($response); return $response; diff --git a/src/Internal/Workflow/NexusOperationStub.php b/src/Internal/Workflow/NexusOperationStub.php new file mode 100644 index 000000000..9ef7fda53 --- /dev/null +++ b/src/Internal/Workflow/NexusOperationStub.php @@ -0,0 +1,171 @@ + $marshaller + */ + public function __construct( + private readonly MarshallerInterface $marshaller, + private readonly NexusOperationOptions $options, + private readonly HeaderInterface $header, + ) {} + + public function getOptions(): NexusOperationOptions + { + return $this->options; + } + + public function execute( + string $operation, + array $args = [], + Type|string|\ReflectionClass|\ReflectionType|null $returnType = null, + array $nexusHeaders = [], + ): PromiseInterface { + return $this + ->start($operation, $args, $returnType, $nexusHeaders) + ->then(static fn(NexusOperationHandle $handle): PromiseInterface => $handle->getResult()); + } + + public function start( + string $operation, + array $args = [], + Type|string|\ReflectionClass|\ReflectionType|null $returnType = null, + array $nexusHeaders = [], + ): PromiseInterface { + // Programming errors throw synchronously; runtime errors reject the promise. + $endpoint = $this->options->endpoint; + $service = $this->options->service; + $this->assertOperationParams($endpoint, $service, $operation); + + $startRequest = new ExecuteNexusOperation( + endpoint: $endpoint, + service: $service, + operation: $operation, + args: EncodedValues::fromValues($args), + options: $this->marshaller->marshal($this->options), + header: $this->header, + nexusHeaders: Headers::normalize($nexusHeaders), + ); + + $startId = $startRequest->getID(); + + $cancellable = $this->options->cancellationType !== NexusOperationCancellationType::Abandon; + + $operationToken = ''; + $resultPromise = $this->normalizeFailure( + $this->request($startRequest, cancellable: $cancellable), + $endpoint, + $service, + $operation, + $operationToken, + ); + $startedPromise = $this->normalizeFailure( + $this->request(new GetNexusOperationStarted($startId)), + $endpoint, + $service, + $operation, + $operationToken, + ); + + return $startedPromise->then( + static function (ValuesInterface $values) use ( + &$operationToken, + $resultPromise, + $returnType + ): NexusOperationHandle { + $envelope = $values->getValue(0, NexusStartEnvelope::class); + if ($envelope->async) { + $operationToken = $envelope->token; + } + + return new NexusOperationHandle( + operationToken: $envelope->async ? $envelope->token : null, + rawResult: $resultPromise, + returnType: $returnType, + ); + }, + ); + } + + protected function request(RequestInterface $request, bool $cancellable = true): PromiseInterface + { + return Workflow::getCurrentContext()->request($request, $cancellable); + } + + private function assertOperationParams(string $endpoint, string $service, string $operation): void + { + if ($endpoint === '') { + throw new \InvalidArgumentException(\sprintf( + "Nexus stub for %s has no endpoint set. Call NexusOperationOptions::withEndpoint('your-endpoint') before passing options to newNexusServiceStub() or newUntypedNexusOperationStub().", + $service !== '' ? "service '{$service}'" : 'this operation', + )); + } + if ($service === '') { + throw new \InvalidArgumentException( + 'Nexus service is empty; call NexusOperationOptions::withService() or pass a #[Service]-annotated interface to newNexusServiceStub()', + ); + } + if ($operation === '') { + throw new \InvalidArgumentException('Nexus operation name must be a non-empty string'); + } + } + + private function normalizeFailure( + PromiseInterface $promise, + string $endpoint, + string $service, + string $operation, + string &$operationToken, + ): PromiseInterface { + return $promise->then( + null, + static function (\Throwable $e) use ($endpoint, $service, $operation, &$operationToken): never { + if ($e instanceof NexusOperationFailure) { + throw $e; + } + $message = $e instanceof CanceledFailure + ? 'nexus operation cancelled' + : 'nexus operation completed unsuccessfully'; + throw new NexusOperationFailure( + message: $message, + scheduledEventId: 0, + endpoint: $endpoint, + service: $service, + operation: $operation, + operationToken: $operationToken, + previous: $e, + ); + }, + ); + } +} diff --git a/src/Internal/Workflow/NexusServiceProxy.php b/src/Internal/Workflow/NexusServiceProxy.php new file mode 100644 index 000000000..12077f8ec --- /dev/null +++ b/src/Internal/Workflow/NexusServiceProxy.php @@ -0,0 +1,95 @@ + Keyed by PHP method name. */ + private readonly array $operationsByMethod; + + /** + * @param class-string $class + * @param Pipeline $callsInterceptor + */ + public function __construct( + private readonly string $class, + NexusServicePrototype $prototype, + private readonly NexusOperationOptions $options, + private readonly WorkflowContextInterface $ctx, + private readonly Pipeline $callsInterceptor, + ) { + $byMethod = []; + foreach ($prototype->getOperations() as $operation) { + $byMethod[$operation->methodName] = $operation; + } + $this->operationsByMethod = $byMethod; + } + + public function __call(string $method, array $args = []): PromiseInterface + { + $operation = $this->operationsByMethod[$method] ?? null; + + if ($operation === null) { + throw new \BadMethodCallException(\sprintf( + 'Nexus service "%s" has no operation method "%s". ' + . 'Did you forget the #[Operation] attribute on the method?', + $this->class, + $method, + )); + } + + \assert($this->options->service !== ''); + + return $this->callsInterceptor->with( + fn(ExecuteNexusOperationInput $input): PromiseInterface => $this->ctx + ->newUntypedNexusOperationStub(self::effectiveOptions($input)) + ->execute($input->operation, $input->args, $input->returnType, $input->nexusHeaders), + /** @see WorkflowOutboundCallsInterceptor::executeNexusOperation() */ + 'executeNexusOperation', + )( + new ExecuteNexusOperationInput( + $this->options->endpoint, + $this->options->service, + $operation->name, + $args, + $this->options, + $operation->outputType, + ), + ); + } + + private static function effectiveOptions(ExecuteNexusOperationInput $input): NexusOperationOptions + { + $options = $input->options; + if ($input->endpoint !== '' && $input->endpoint !== $options->endpoint) { + $options = $options->withEndpoint($input->endpoint); + } + if ($input->service !== '' && $input->service !== $options->service) { + $options = $options->withService($input->service); + } + return $options; + } +} diff --git a/src/Internal/Workflow/NexusStartEnvelope.php b/src/Internal/Workflow/NexusStartEnvelope.php new file mode 100644 index 000000000..b4f8bead6 --- /dev/null +++ b/src/Internal/Workflow/NexusStartEnvelope.php @@ -0,0 +1,28 @@ +reject($request, new CanceledFailure('nexus operation cancelled')); + } + return; } diff --git a/src/Internal/Workflow/WorkflowContext.php b/src/Internal/Workflow/WorkflowContext.php index b32c0c9b2..8c805f4c4 100644 --- a/src/Internal/Workflow/WorkflowContext.php +++ b/src/Internal/Workflow/WorkflowContext.php @@ -35,6 +35,7 @@ use Temporal\Interceptor\WorkflowOutboundCalls\ExecuteActivityInput; use Temporal\Interceptor\WorkflowOutboundCalls\ExecuteChildWorkflowInput; use Temporal\Interceptor\WorkflowOutboundCalls\ExecuteLocalActivityInput; +use Temporal\Interceptor\WorkflowOutboundCalls\ExecuteNexusOperationInput; use Temporal\Interceptor\WorkflowOutboundCalls\GetVersionInput; use Temporal\Interceptor\WorkflowOutboundCalls\PanicInput; use Temporal\Interceptor\WorkflowOutboundCalls\SideEffectInput; @@ -75,6 +76,8 @@ use Temporal\Workflow\ContinueAsNewOptions; use Temporal\Workflow\ExternalWorkflowStubInterface; use Temporal\Workflow\Mutex; +use Temporal\Workflow\NexusOperationOptions; +use Temporal\Workflow\NexusOperationStubInterface; use Temporal\Workflow\TimerOptions; use Temporal\Workflow\WorkflowContextInterface; use Temporal\Workflow\WorkflowExecution; @@ -471,6 +474,66 @@ public function newActivityStub( ); } + /** + * @template T of object + * @param class-string $class + * @return NexusServiceProxy + */ + public function newNexusServiceStub( + string $class, + NexusOperationOptions $options, + ): object { + $prototype = $this->services->nexusServicesReader->fromClass($class); + + if ($options->service === '') { + $options = $options->withService($prototype->getID()); + } + + return new NexusServiceProxy( + $class, + $prototype, + $options, + $this, + $this->callsInterceptor, + ); + } + + public function newUntypedNexusOperationStub( + NexusOperationOptions $options, + ): NexusOperationStubInterface { + return new NexusOperationStub( + $this->services->marshaller, + $options, + $this->getHeader(), + ); + } + + public function executeNexusOperation( + string $operation, + array $args = [], + ?NexusOperationOptions $options = null, + Type|string|\ReflectionClass|\ReflectionType|null $returnType = null, + array $nexusHeaders = [], + ): PromiseInterface { + $options ??= NexusOperationOptions::new(); + + return $this->callsInterceptor->with( + fn(ExecuteNexusOperationInput $input): PromiseInterface => $this + ->newUntypedNexusOperationStub(self::effectiveNexusOptions($input)) + ->execute($input->operation, $input->args, $input->returnType, $input->nexusHeaders), + /** @see WorkflowOutboundCallsInterceptor::executeNexusOperation() */ + 'executeNexusOperation', + )(new ExecuteNexusOperationInput( + $options->endpoint, + $options->service, + $operation, + $args, + $options, + $returnType, + $nexusHeaders, + )); + } + public function timer($interval, ?TimerOptions $options = null): PromiseInterface { $dateInterval = DateInterval::parse($interval, DateInterval::FORMAT_SECONDS); @@ -816,4 +879,16 @@ protected function recordTrace(): void { $this->readonly or $this->trace = \debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS); } + + private static function effectiveNexusOptions(ExecuteNexusOperationInput $input): NexusOperationOptions + { + $options = $input->options; + if ($input->endpoint !== '' && $input->endpoint !== $options->endpoint) { + $options = $options->withEndpoint($input->endpoint); + } + if ($input->service !== '' && $input->service !== $options->service) { + $options = $options->withService($input->service); + } + return $options; + } } diff --git a/src/Nexus/Attribute/AsyncOperation.php b/src/Nexus/Attribute/AsyncOperation.php new file mode 100644 index 000000000..f6e4d205e --- /dev/null +++ b/src/Nexus/Attribute/AsyncOperation.php @@ -0,0 +1,42 @@ +errorType = $errorType; + $this->retryBehavior = $retryBehavior; + + parent::__construct($message, 0, $cause); + } + + public static function create( + ErrorType $errorType, + string $message, + ?\Throwable $cause = null, + RetryBehavior $retryBehavior = RetryBehavior::Unspecified, + ): self { + return new self($errorType, $message, $cause, $retryBehavior); + } + + /** + * Message is derived from the cause's own message. + */ + public static function fromCause( + ErrorType $errorType, + \Throwable $cause, + RetryBehavior $retryBehavior = RetryBehavior::Unspecified, + ): self { + $message = $cause->getMessage() !== '' + ? "handler error: {$cause->getMessage()}" + : 'handler error'; + return new self($errorType, $message, $cause, $retryBehavior); + } + + public function isRetryable(): bool + { + if ($this->retryBehavior !== RetryBehavior::Unspecified) { + return $this->retryBehavior === RetryBehavior::Retryable; + } + + return match ($this->errorType) { + ErrorType::BadRequest, + ErrorType::Unauthenticated, + ErrorType::Unauthorized, + ErrorType::NotFound, + ErrorType::Conflict, + ErrorType::NotImplemented => false, + default => true, + }; + } +} diff --git a/src/Nexus/Exception/InvalidArgumentException.php b/src/Nexus/Exception/InvalidArgumentException.php new file mode 100644 index 000000000..07feb0753 --- /dev/null +++ b/src/Nexus/Exception/InvalidArgumentException.php @@ -0,0 +1,14 @@ +getMessage() !== '' ? $cause->getMessage() : $fallback; + } +} diff --git a/src/Nexus/Exception/RetryBehavior.php b/src/Nexus/Exception/RetryBehavior.php new file mode 100644 index 000000000..cfd30ee63 --- /dev/null +++ b/src/Nexus/Exception/RetryBehavior.php @@ -0,0 +1,22 @@ + + */ +final readonly class AsyncOperationStartResult extends OperationStartResult +{ + /** + * @internal + */ + public function __construct(public OperationInfo $info) + { + if ($info->state !== OperationState::Running) { + throw new InvalidArgumentException(\sprintf( + 'Async operation start must report a running operation, got state "%s".', + $info->state->value, + )); + } + + parent::__construct(); + } +} diff --git a/src/Nexus/Handler/ClosureMethodCancellationListener.php b/src/Nexus/Handler/ClosureMethodCancellationListener.php new file mode 100644 index 000000000..903ec33fb --- /dev/null +++ b/src/Nexus/Handler/ClosureMethodCancellationListener.php @@ -0,0 +1,29 @@ +closure)(); + } +} diff --git a/src/Nexus/Handler/HeaderCollection.php b/src/Nexus/Handler/HeaderCollection.php new file mode 100644 index 000000000..6a1e9fcee --- /dev/null +++ b/src/Nexus/Handler/HeaderCollection.php @@ -0,0 +1,49 @@ + Lowercased keys. */ + private array $headers; + + /** + * @param array $initial + */ + public function __construct(array $initial = []) + { + $this->headers = Headers::normalize($initial); + } + + public function get(string $name): ?string + { + return $this->headers[\strtolower($name)] ?? null; + } + + public function has(string $name): bool + { + return \array_key_exists(\strtolower($name), $this->headers); + } + + /** + * @return array + */ + public function all(): array + { + return $this->headers; + } +} diff --git a/src/Nexus/Handler/Internal/HandlerInterface.php b/src/Nexus/Handler/Internal/HandlerInterface.php new file mode 100644 index 000000000..fd9654736 --- /dev/null +++ b/src/Nexus/Handler/Internal/HandlerInterface.php @@ -0,0 +1,63 @@ + + * + * @throws OperationException + * @throws HandlerException + */ + public function startOperation( + OperationContext $context, + OperationStartDetails $details, + ValuesInterface $input, + ?WorkflowClientInterface $workflowClient, + NexusOperationContext $operationContext, + ): OperationStartResult; + + /** + * Cancel the asynchronously started operation. + * + * Per Nexus spec, cancellation is **idempotent**: implementations must + * ignore repeat cancel requests for the same operation token (including + * cancels for an operation that has already reached a terminal state) and + * return successfully. Throw {@see HandlerException} only for genuine + * routing/permission/transport errors, never for "already cancelled" or + * "already completed". + * + * @throws HandlerException + */ + public function cancelOperation( + OperationContext $context, + OperationCancelDetails $details, + ?WorkflowClientInterface $workflowClient, + NexusOperationContext $operationContext, + ): void; +} diff --git a/src/Nexus/Handler/Internal/MethodOperationHandler.php b/src/Nexus/Handler/Internal/MethodOperationHandler.php new file mode 100644 index 000000000..be3c80107 --- /dev/null +++ b/src/Nexus/Handler/Internal/MethodOperationHandler.php @@ -0,0 +1,55 @@ + + */ +final class MethodOperationHandler implements OperationHandlerInterface +{ + public function __construct( + private readonly object $instance, + private readonly \ReflectionMethod $startMethod, + private readonly NexusOperationPrototype $operation, + ) {} + + public function start( + OperationContext $context, + OperationStartDetails $details, + mixed $param, + ): OperationStartResult { + $args = $this->startMethod->getNumberOfParameters() === 0 ? [] : [$param]; + $result = $this->startMethod->invoke($this->instance, ...$args); + + if ($this->operation->async) { + return OperationStartResult::async(WorkflowRunStarter::start($result, $details)); + } + + return OperationStartResult::sync($result); + } + + public function cancel( + OperationContext $context, + OperationCancelDetails $details, + ): void { + WorkflowRunOperation::cancel($details->operationToken); + } +} diff --git a/src/Nexus/Handler/Internal/ServiceHandler.php b/src/Nexus/Handler/Internal/ServiceHandler.php new file mode 100644 index 000000000..6304aefe6 --- /dev/null +++ b/src/Nexus/Handler/Internal/ServiceHandler.php @@ -0,0 +1,258 @@ + $instances + */ + private function __construct( + private readonly array $instances, + private readonly DataConverterInterface $dataConverter, + private readonly PipelineProvider $interceptorProvider = new SimplePipelineProvider(), + ) {} + + /** + * @param NexusServiceInstance[] $instances + */ + public static function create( + DataConverterInterface $dataConverter, + array $instances, + PipelineProvider $interceptorProvider = new SimplePipelineProvider(), + ): self { + if (\count($instances) === 0) { + throw new InvalidArgumentException('No service instances defined'); + } + + $instancesByName = []; + foreach ($instances as $instance) { + $name = $instance->prototype->getID(); + if (isset($instancesByName[$name])) { + throw new InvalidArgumentException( + "Multiple instances registered for service name '{$name}'", + ); + } + $instancesByName[$name] = $instance; + } + + return new self($instancesByName, $dataConverter, $interceptorProvider); + } + + public function startOperation( + OperationContext $context, + OperationStartDetails $details, + ValuesInterface $input, + ?WorkflowClientInterface $workflowClient, + NexusOperationContext $operationContext, + ): OperationStartResult { + [$instance, $handler] = $this->resolveHandler($context); + + $operations = $instance->prototype->getOperations(); + $definition = $operations[$context->operation]; + + $result = $this->dispatch( + new NexusContext( + operation: self::publicOperationContext($operationContext), + workflowClient: $workflowClient, + current: $context, + startDetails: $details, + outboundPipeline: $this->interceptorProvider + ->getPipeline(NexusOperationOutboundCallsInterceptor::class), + ), + static fn(StartOperationInput $input): OperationStartResult => $handler->start( + $input->operationContext, + $input->startDetails, + $input->input instanceof ValuesInterface + ? self::decodeInput($input->input, $input->operationContext, $definition->inputType) + : $input->input, + ), + 'startOperation', + new StartOperationInput($context, $details, $input), + ); + + \assert($result instanceof OperationStartResult); + + if (!$result instanceof SyncOperationStartResult) { + return $result; + } + + return OperationStartResult::sync( + $this->encodeResult( + $result->value, + $context, + $definition->outputType, + ), + ); + } + + public function cancelOperation( + OperationContext $context, + OperationCancelDetails $details, + ?WorkflowClientInterface $workflowClient, + NexusOperationContext $operationContext, + ): void { + [$instance, $handler] = $this->resolveHandler($context); + + $definition = $instance->prototype->getOperations()[$context->operation]; + if (!$definition->async) { + throw HandlerException::create( + ErrorType::NotImplemented, + \sprintf( + 'Operation %s/%s is synchronous and cannot be cancelled', + $context->service, + $context->operation, + ), + ); + } + + $this->dispatch( + new NexusContext( + operation: self::publicOperationContext($operationContext), + workflowClient: $workflowClient, + current: $context, + cancelDetails: $details, + outboundPipeline: $this->interceptorProvider + ->getPipeline(NexusOperationOutboundCallsInterceptor::class), + ), + static function (CancelOperationInput $input) use ($handler): void { + $handler->cancel($input->operationContext, $input->cancelDetails); + }, + 'cancelOperation', + new CancelOperationInput($context, $details), + ); + } + + private static function publicOperationContext(NexusOperationContext $operationContext): ?NexusOperationContext + { + if ($operationContext->namespace === '' || $operationContext->taskQueue === '') { + return null; + } + return $operationContext; + } + + private static function decodeInput( + ValuesInterface $input, + OperationContext $context, + Type $inputType, + ): mixed { + try { + return $inputType->getName() === Type::TYPE_VOID + ? null + : $input->getValue(0, $inputType); + } catch (\Throwable $e) { + throw HandlerException::create( + ErrorType::BadRequest, + \sprintf( + 'Failed deserializing input for %s/%s as %s: %s', + $context->service, + $context->operation, + $inputType->getName(), + $e->getMessage(), + ), + $e, + ); + } + } + + /** + * @param non-empty-string $method + */ + private function dispatch(NexusContext $dispatchContext, \Closure $terminal, string $method, object $input): mixed + { + Nexus::setCurrentContext($dispatchContext); + try { + return $this->interceptorProvider + ->getPipeline(NexusOperationInboundCallsInterceptor::class) + ->with($terminal, $method)($input); + } finally { + Nexus::setCurrentContext(null); + } + } + + /** + * @return array{NexusServiceInstance, OperationHandlerInterface} + */ + private function resolveHandler(OperationContext $context): array + { + $instance = $this->instances[$context->service] ?? null; + if ($instance === null) { + throw HandlerException::create( + ErrorType::NotFound, + "Unrecognized service '{$context->service}'", + ); + } + + $handler = $instance->operationHandlers[$context->operation] ?? null; + if ($handler === null) { + throw HandlerException::create( + ErrorType::NotFound, + "Service '{$context->service}' has no operation '{$context->operation}'", + ); + } + + return [$instance, $handler]; + } + + private function encodeResult( + mixed $result, + OperationContext $context, + Type $outputType, + ): ValuesInterface { + try { + $payload = $this->dataConverter->toPayload($result); + $payloads = new Payloads(['payloads' => [$payload]]); + return EncodedValues::fromPayloads($payloads, $this->dataConverter); + } catch (\Throwable $e) { + throw HandlerException::create( + ErrorType::Internal, + \sprintf( + 'Failed serializing result for %s/%s as %s: %s', + $context->service, + $context->operation, + $outputType->getName(), + $e->getMessage(), + ), + $e, + ); + } + } +} diff --git a/src/Nexus/Handler/Internal/WorkflowRunStarter.php b/src/Nexus/Handler/Internal/WorkflowRunStarter.php new file mode 100644 index 000000000..20649c23f --- /dev/null +++ b/src/Nexus/Handler/Internal/WorkflowRunStarter.php @@ -0,0 +1,114 @@ +options; + if ($options->workflowId === '') { + throw new \LogicException(\sprintf( + 'Async Nexus operation: workflow ID is required for %s — ' + . 'set it via WorkflowOptions::withWorkflowId($details->requestId) inside your handler.', + $handle->workflowClass, + )); + } + + // Default task queue to the handler's queue; avoids silent hang on `default`. + if ($options->taskQueue === WorkerFactoryInterface::DEFAULT_TASK_QUEUE) { + $options = $options->withTaskQueue($info->taskQueue); + } + + $token = WorkflowRunOperationToken::generate( + $info->namespace, + $options->workflowId, + ); + + if ($details->callbackUrl !== null && $details->callbackUrl !== '') { + $headers = $details->callbackHeaders; + $present = Headers::normalize($headers); + // Send both header names for pre/post-1.27 server compatibility. + if (!\array_key_exists(\strtolower(Header::OPERATION_TOKEN), $present)) { + $headers[Header::OPERATION_TOKEN] = $token; + } + if (!\array_key_exists(\strtolower(Header::OPERATION_ID), $present)) { + $headers[Header::OPERATION_ID] = $token; + } + + $callback = CompletionCallback::fromNexusLinks($details->callbackUrl, $headers, $details->links); + $options = $options->withCompletionCallbacks($callback); + } + + $options = $options + ->withLinks($details->links) + ->withOnConflictOptionsInternal(new OnConflictOptions()) + // Pin requestId so retried Nexus starts dedupe server-side. + ->withRequestId($details->requestId); + + $stub = $client->newWorkflowStub($handle->workflowClass, $options); + $run = $client->start($stub, ...$handle->args); + + Nexus::getCurrentOperationContext() + ->links + ->add(self::buildStartedEventSelfLink($info->namespace, $run->getExecution())); + + return new OperationInfo($token, OperationState::Running); + } + + private static function buildStartedEventSelfLink(string $namespace, WorkflowExecution $execution): Link + { + $event = (new WorkflowEvent()) + ->setNamespace($namespace) + ->setWorkflowId($execution->getID()) + ->setRunId($execution->getRunID() ?? '') + ->setEventRef( + (new EventReference()) + ->setEventType(EventType::EVENT_TYPE_WORKFLOW_EXECUTION_STARTED), + ); + return NexusLinkConverter::workflowEventToNexusLink($event); + } +} diff --git a/src/Nexus/Handler/LinkCollection.php b/src/Nexus/Handler/LinkCollection.php new file mode 100644 index 000000000..35c4367ce --- /dev/null +++ b/src/Nexus/Handler/LinkCollection.php @@ -0,0 +1,48 @@ + */ + private array $links; + + /** + * @param list $initial + */ + public function __construct(array $initial = []) + { + Link::assertAll($initial, 'LinkCollection: initial'); + $this->links = \array_values($initial); + } + + public function add(Link ...$links): void + { + foreach ($links as $link) { + $this->links[] = $link; + } + } + + /** + * @return list + */ + public function all(): array + { + return $this->links; + } +} diff --git a/src/Nexus/Handler/MethodCancellationListenerInterface.php b/src/Nexus/Handler/MethodCancellationListenerInterface.php new file mode 100644 index 000000000..a86270fa3 --- /dev/null +++ b/src/Nexus/Handler/MethodCancellationListenerInterface.php @@ -0,0 +1,26 @@ + */ + private readonly \SplObjectStorage $listeners; + + public function __construct( + private readonly EnvironmentInterface $env, + private readonly ?\DateTimeImmutable $deadline = null, + ) { + $this->listeners = new \SplObjectStorage(); + } + + public function isCancelled(): bool + { + $this->checkDeadline(); + return $this->reason !== null; + } + + public function getReason(): ?string + { + $this->checkDeadline(); + return $this->reason; + } + + /** + * Idempotent. Listeners run in registration order. Not reentrant. + */ + public function cancel(string $reason): void + { + if ($this->reason !== null) { + return; + } + $this->reason = $reason; + foreach ($this->listeners as $listener) { + $listener->cancelled(); + } + } + + /** + * If already cancelled, the listener is invoked synchronously and not stored. + */ + public function addListener(MethodCancellationListenerInterface $listener): void + { + $this->checkDeadline(); + if ($this->reason !== null) { + $listener->cancelled(); + return; + } + $this->listeners->offsetSet($listener); + } + + public function removeListener(MethodCancellationListenerInterface $listener): void + { + $this->listeners->offsetUnset($listener); + } + + private static function formatDeadlineReason(\DateTimeImmutable $deadline): string + { + return \sprintf('deadline exceeded (%s)', $deadline->format(\DATE_ATOM)); + } + + private function checkDeadline(): void + { + if ($this->reason !== null || $this->deadline === null) { + return; + } + if ($this->deadline > $this->env->now()) { + return; + } + $this->cancel(self::formatDeadlineReason($this->deadline)); + } +} diff --git a/src/Nexus/Handler/OperationCancelDetails.php b/src/Nexus/Handler/OperationCancelDetails.php new file mode 100644 index 000000000..0c55efbb7 --- /dev/null +++ b/src/Nexus/Handler/OperationCancelDetails.php @@ -0,0 +1,23 @@ +|HeaderCollection $headers Reused if a collection, wrapped if an array (keys lowercased). + * @param list|LinkCollection $links Reused if a collection, wrapped if a list. + */ + public function __construct( + public readonly string $service, + public readonly string $operation, + EnvironmentInterface $env, + array|HeaderCollection $headers = [], + public readonly ?\DateTimeImmutable $deadline = null, + array|LinkCollection $links = [], + ?MethodCanceller $methodCanceller = null, + ) { + $this->headers = $headers instanceof HeaderCollection ? $headers : new HeaderCollection($headers); + $this->links = $links instanceof LinkCollection ? $links : new LinkCollection($links); + $this->methodCanceller = $methodCanceller + ?? ($deadline !== null ? new MethodCanceller($env, $deadline) : null); + } + + /** + * True if the canceller fired or the deadline has passed. Not the same as + * Nexus operation cancellation. + */ + public function isMethodCancelled(): bool + { + return $this->methodCanceller?->isCancelled() === true; + } + + /** + * Reason from {@see MethodCanceller::cancel()} or `"deadline exceeded (...)"`. + */ + public function getMethodCancellationReason(): ?string + { + return $this->methodCanceller?->getReason(); + } + + /** + * No-op when no canceller (and no deadline) attached. If already + * cancelled, the listener runs synchronously here. + */ + public function addMethodCancellationListener(MethodCancellationListenerInterface $listener): self + { + $this->methodCanceller?->addListener($listener); + return $this; + } + + public function removeMethodCancellationListener(MethodCancellationListenerInterface $listener): self + { + $this->methodCanceller?->removeListener($listener); + return $this; + } +} diff --git a/src/Nexus/Handler/OperationHandlerInterface.php b/src/Nexus/Handler/OperationHandlerInterface.php new file mode 100644 index 000000000..0ba267cc0 --- /dev/null +++ b/src/Nexus/Handler/OperationHandlerInterface.php @@ -0,0 +1,49 @@ + + * + * @throws OperationException + * @throws HandlerException + */ + public function start( + OperationContext $context, + OperationStartDetails $details, + mixed $param, + ): OperationStartResult; + + /** + * @throws HandlerException + */ + public function cancel( + OperationContext $context, + OperationCancelDetails $details, + ): void; +} diff --git a/src/Nexus/Handler/OperationStartDetails.php b/src/Nexus/Handler/OperationStartDetails.php new file mode 100644 index 000000000..b6358ccb2 --- /dev/null +++ b/src/Nexus/Handler/OperationStartDetails.php @@ -0,0 +1,36 @@ + $callbackHeaders Headers to attach as-is on + * the async callback POST. Transport must strip the inbound + * `Nexus-Callback-` prefix first (see {@see \Temporal\Nexus\Header::CALLBACK_PREFIX}). + * @param Link[] $links + */ + public function __construct( + public readonly string $requestId, + public readonly ?string $callbackUrl = null, + public readonly array $callbackHeaders = [], + public readonly array $links = [], + ) { + if ($requestId === '') { + throw new InvalidArgumentException('OperationStartDetails requires a non-empty requestId'); + } + Link::assertAll($links, 'OperationStartDetails: links'); + } +} diff --git a/src/Nexus/Handler/OperationStartResult.php b/src/Nexus/Handler/OperationStartResult.php new file mode 100644 index 000000000..7a81c7208 --- /dev/null +++ b/src/Nexus/Handler/OperationStartResult.php @@ -0,0 +1,43 @@ + + */ + public static function sync(mixed $value = null): SyncOperationStartResult + { + return new SyncOperationStartResult($value); + } + + public static function async(OperationInfo $info): AsyncOperationStartResult + { + return new AsyncOperationStartResult($info); + } +} diff --git a/src/Nexus/Handler/SyncOperationStartResult.php b/src/Nexus/Handler/SyncOperationStartResult.php new file mode 100644 index 000000000..ec42d389c --- /dev/null +++ b/src/Nexus/Handler/SyncOperationStartResult.php @@ -0,0 +1,31 @@ + + */ +final readonly class SyncOperationStartResult extends OperationStartResult +{ + /** + * @internal + * @param R|null $value + */ + public function __construct( + public mixed $value, + ) { + parent::__construct(); + } +} diff --git a/src/Nexus/Header.php b/src/Nexus/Header.php new file mode 100644 index 000000000..6ed2cf339 --- /dev/null +++ b/src/Nexus/Header.php @@ -0,0 +1,126 @@ +`. */ + public const REQUEST_TIMEOUT = 'Request-Timeout'; + + /** Total time per Nexus operation (may span callbacks). Same format as {@see self::REQUEST_TIMEOUT}. */ + public const OPERATION_TIMEOUT = 'Operation-Timeout'; + + /** @deprecated Use {@see self::OPERATION_TOKEN}. */ + public const OPERATION_ID = 'Nexus-Operation-Id'; + + /** Async operation token returned from StartOperation. */ + public const OPERATION_TOKEN = 'Nexus-Operation-Token'; + + /** IMF-fixdate (RFC 9110 §5.6.7) timestamp; optional on async callback POST (defaults to reception time). */ + public const OPERATION_START_TIME = 'Nexus-Operation-Start-Time'; + + /** RFC 3339 ms-precision timestamp; required on async callback POST. */ + public const OPERATION_CLOSE_TIME = 'Nexus-Operation-Close-Time'; + + /** + * Terminal state on callback POST. One of {@see OperationState} values. + * Deprecated on `424 Failed Dependency` — modern callers read the body. + */ + public const OPERATION_STATE = 'Nexus-Operation-State'; + + /** Caller-provided opaque non-empty id for retry de-duplication. */ + public const REQUEST_ID = 'Nexus-Request-Id'; + + /** RFC 8288 link header; `; type="..."`. May repeat. */ + public const LINK = 'Nexus-Link'; + + /** + * Prefix on caller headers that the handler must forward (with prefix + * stripped) on the async callback POST. Compare case-insensitively. + */ + public const CALLBACK_PREFIX = 'Nexus-Callback-'; + + /** URL the handler POSTs to on async-operation terminal state. */ + public const CALLBACK_URL = self::CALLBACK_PREFIX . 'Url'; + + /** Inbound name of the opaque auth token; outbound it is sent as `Token: ...` (secret). */ + public const CALLBACK_TOKEN = self::CALLBACK_PREFIX . 'Token'; + + /** Standard HTTP `Content-Type` header. */ + public const CONTENT_TYPE = 'Content-Type'; + + /** Required `Content-Type` for spec JSON envelopes (see {@see \Temporal\Nexus\OperationInfo} and the Nexus `Failure` shape). */ + public const CONTENT_TYPE_JSON = 'application/json'; + + /** + * @codeCoverageIgnore + */ + private function __construct() {} + + /** + * Case-insensitive lookup over a lowercase-normalized header map. + * + * @param array $headers Must be lowercase-normalized. + */ + public static function get(array $headers, string $name): ?string + { + return $headers[\strtolower($name)] ?? null; + } + + /** + * Parse `"30s"` / `"250ms"` / `"2m"`. Empty → null. + * + * @throws InvalidArgumentException + */ + public static function parseTimeout(string $value): ?\DateInterval + { + $trimmed = \trim($value); + if ($trimmed === '') { + return null; + } + + if (!\preg_match('/^[+-]?\d/', $trimmed)) { + throw new InvalidArgumentException("Invalid Nexus timeout '{$value}'"); + } + + try { + return DateInterval::parse($trimmed, DateInterval::FORMAT_SECONDS); + } catch (\Throwable $e) { + throw new InvalidArgumentException("Invalid Nexus timeout '{$value}'", 0, $e); + } + } + + /** + * `$now + parseTimeout($value)`. Empty → null. `$now` defaults to UTC now. + * + * @throws InvalidArgumentException + */ + public static function deadlineFromTimeout( + string $value, + ?\DateTimeImmutable $now = null, + ): ?\DateTimeImmutable { + $interval = self::parseTimeout($value); + if ($interval === null) { + return null; + } + return ($now ?? new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->add($interval); + } +} diff --git a/src/Nexus/Internal/Failure/NexusFailureConverter.php b/src/Nexus/Internal/Failure/NexusFailureConverter.php new file mode 100644 index 000000000..e6a056ac6 --- /dev/null +++ b/src/Nexus/Internal/Failure/NexusFailureConverter.php @@ -0,0 +1,35 @@ + NexusHandlerErrorRetryBehavior::NEXUS_HANDLER_ERROR_RETRY_BEHAVIOR_RETRYABLE, + RetryBehavior::NonRetryable => NexusHandlerErrorRetryBehavior::NEXUS_HANDLER_ERROR_RETRY_BEHAVIOR_NON_RETRYABLE, + RetryBehavior::Unspecified => NexusHandlerErrorRetryBehavior::NEXUS_HANDLER_ERROR_RETRY_BEHAVIOR_UNSPECIFIED, + }; + } +} diff --git a/src/Nexus/Internal/Headers.php b/src/Nexus/Internal/Headers.php new file mode 100644 index 000000000..6893fb31e --- /dev/null +++ b/src/Nexus/Internal/Headers.php @@ -0,0 +1,38 @@ + $headers + * @return array + */ + public static function normalize(array $headers): array + { + $normalized = []; + foreach ($headers as $key => $value) { + $normalized[\strtolower($key)] = $value; + } + return $normalized; + } +} diff --git a/src/Nexus/Internal/WorkflowRunOperationToken.php b/src/Nexus/Internal/WorkflowRunOperationToken.php new file mode 100644 index 000000000..10bfb4c0e --- /dev/null +++ b/src/Nexus/Internal/WorkflowRunOperationToken.php @@ -0,0 +1,100 @@ + self::TYPE_WORKFLOW_RUN, + 'ns' => $namespace, + 'wid' => $workflowId, + ]; + + $json = \json_encode($payload, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR); + + return \rtrim(\strtr(\base64_encode($json), '+/', '-_'), '='); + } + + /** + * @throws \InvalidArgumentException on malformed input. + */ + public static function load(string $token): self + { + if ($token === '') { + throw new \InvalidArgumentException('invalid workflow run token: token is empty'); + } + + $decoded = \base64_decode(\strtr($token, '-_', '+/'), true); + if ($decoded === false) { + throw new \InvalidArgumentException('failed to decode token'); + } + + try { + $parsed = \json_decode($decoded, true, 16, \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new \InvalidArgumentException( + 'failed to unmarshal workflow run operation token: ' . $e->getMessage(), + previous: $e, + ); + } + + if (!\is_array($parsed)) { + throw new \InvalidArgumentException('failed to unmarshal workflow run operation token: not an object'); + } + + $type = $parsed['t'] ?? null; + if ($type !== self::TYPE_WORKFLOW_RUN) { + throw new \InvalidArgumentException( + \sprintf( + 'invalid workflow token type: %s, expected: %d', + \var_export($type, true), + self::TYPE_WORKFLOW_RUN, + ), + ); + } + + // Non-zero version = newer producer. + if (\array_key_exists('v', $parsed) && $parsed['v'] !== 0) { + throw new \InvalidArgumentException(\sprintf( + 'invalid workflow run token: unsupported version %s', + \var_export($parsed['v'], true), + )); + } + + $workflowId = $parsed['wid'] ?? ''; + if (!\is_string($workflowId) || $workflowId === '') { + throw new \InvalidArgumentException('invalid workflow run token: missing workflow ID (wid)'); + } + + $namespace = $parsed['ns'] ?? ''; + if (!\is_string($namespace)) { + throw new \InvalidArgumentException('invalid workflow run token: namespace must be a string'); + } + + return new self($namespace, $workflowId); + } +} diff --git a/src/Nexus/Link.php b/src/Nexus/Link.php new file mode 100644 index 000000000..616a3bacc --- /dev/null +++ b/src/Nexus/Link.php @@ -0,0 +1,58 @@ + $links + * @param non-empty-string $where Label used in the error message, e.g. `Foo: links`. + */ + public static function assertAll(iterable $links, string $where): void + { + foreach ($links as $i => $link) { + if (!$link instanceof self) { + throw new InvalidArgumentException(\sprintf( + '%s[%s] must be a %s, got %s', + $where, + \is_int($i) ? (string) $i : \var_export($i, true), + self::class, + \get_debug_type($link), + )); + } + } + } + + public function __toString(): string + { + return "Link{uri='{$this->uri}', type='{$this->type}'}"; + } +} diff --git a/src/Nexus/LinkParser.php b/src/Nexus/LinkParser.php new file mode 100644 index 000000000..38f9ca0ce --- /dev/null +++ b/src/Nexus/LinkParser.php @@ -0,0 +1,107 @@ + $entry) { + if (!\is_array($entry)) { + throw HandlerException::create( + ErrorType::BadRequest, + \sprintf( + 'Nexus link at index %s is not an object (got %s)', + self::formatIndex($index), + \get_debug_type($entry), + ), + ); + } + $links[] = self::buildLink( + url: $entry['url'] ?? null, + type: $entry['type'] ?? null, + context: 'index ' . self::formatIndex($index), + ); + } + return $links; + } + + /** + * @param iterable<\Temporal\Api\Nexus\V1\Link> $protoLinks + * @return Link[] + * @throws HandlerException + */ + public static function fromProto(iterable $protoLinks): array + { + $links = []; + $index = 0; + foreach ($protoLinks as $protoLink) { + $links[] = self::buildLink( + url: $protoLink->getUrl(), + type: $protoLink->getType(), + context: 'proto index ' . $index, + ); + $index++; + } + return $links; + } + + private static function buildLink(mixed $url, mixed $type, string $context): Link + { + if (!\is_string($url) || $url === '') { + throw HandlerException::create( + ErrorType::BadRequest, + \sprintf('Nexus link at %s has missing or empty "url"', $context), + ); + } + if (!\is_string($type) || $type === '') { + throw HandlerException::create( + ErrorType::BadRequest, + \sprintf('Nexus link at %s has missing or empty "type"', $context), + ); + } + return new Link($url, $type); + } + + private static function formatIndex(mixed $index): string + { + return \is_int($index) ? (string) $index : \var_export($index, true); + } +} diff --git a/src/Nexus/Nexus.php b/src/Nexus/Nexus.php new file mode 100644 index 000000000..e44941c17 --- /dev/null +++ b/src/Nexus/Nexus.php @@ -0,0 +1,113 @@ +current; + } + + /** + * Temporal-side context (namespace, taskQueue, workflowClient). + * + * @throws \LogicException when called outside a Nexus operation dispatch. + */ + public static function getOperationContext(): NexusOperationContext + { + $dispatch = self::getCurrentContext(); + $info = $dispatch->operation ?? throw new \LogicException( + 'Temporal operation context is not available for this dispatch.', + ); + + $pipeline = $dispatch->outboundPipeline; + if ($pipeline === null) { + return $info; + } + + return $pipeline->with( + /** + * @psalm-suppress UnusedClosureParam The terminal call ignores the empty input DTO. + * @see \Temporal\Interceptor\NexusOperationOutboundCallsInterceptor::getInfo() + */ + static fn(GetInfoInput $input): NexusOperationContext => $info, + 'getInfo', + )(new GetInfoInput()); + } + + /** + * Per-start details (requestId, callbackUrl, callbackHeaders, caller links). + * + * @throws \LogicException when called outside a start-operation dispatch. + */ + public static function getStartDetails(): OperationStartDetails + { + return self::getCurrentContext()->startDetails ?? throw new \LogicException( + 'Nexus::getStartDetails() called outside a start-operation dispatch.', + ); + } + + /** + * @throws \LogicException when called outside a cancel-operation dispatch. + */ + public static function getCancelDetails(): OperationCancelDetails + { + return self::getCurrentContext()->cancelDetails ?? throw new \LogicException( + 'Nexus::getCancelDetails() called outside a cancel-operation dispatch.', + ); + } + + /** + * @internal Plumbing for {@see \Temporal\Nexus\WorkflowRunOperation}; user code should drive + * backing workflows through the helper API rather than reaching for the client. + * @throws \LogicException when no WorkflowClient is available (async Nexus needs cluster access). + */ + public static function getWorkflowClient(): WorkflowClientInterface + { + return self::getCurrentContext()->workflowClient ?? throw new \LogicException( + 'Nexus::getWorkflowClient() requires a WorkflowClient. Async Nexus operations (WorkflowRunOperation) need cluster access; provide a WorkflowClient to the WorkerFactory.', + ); + } +} diff --git a/src/Nexus/NexusOperationContext.php b/src/Nexus/NexusOperationContext.php new file mode 100644 index 000000000..8aeeac5ff --- /dev/null +++ b/src/Nexus/NexusOperationContext.php @@ -0,0 +1,24 @@ + $args Arguments forwarded to the workflow method. + */ + private function __construct( + public readonly string $workflowClass, + public readonly WorkflowOptions $options, + public readonly array $args, + ) {} + + /** + * @param class-string $workflowClass + */ + public static function fromWorkflowMethod( + string $workflowClass, + WorkflowOptions $options, + mixed ...$args, + ): self { + return new self($workflowClass, $options, \array_values($args)); + } +} diff --git a/src/Nexus/WorkflowRunOperation.php b/src/Nexus/WorkflowRunOperation.php new file mode 100644 index 000000000..11dddbc4a --- /dev/null +++ b/src/Nexus/WorkflowRunOperation.php @@ -0,0 +1,45 @@ +newUntypedRunningWorkflowStub($decoded->workflowId)->cancel(); + } +} diff --git a/src/Worker/Transport/Codec/ProtoCodec/Encoder.php b/src/Worker/Transport/Codec/ProtoCodec/Encoder.php index f059074a8..90b3024e6 100644 --- a/src/Worker/Transport/Codec/ProtoCodec/Encoder.php +++ b/src/Worker/Transport/Codec/ProtoCodec/Encoder.php @@ -15,7 +15,7 @@ use Temporal\DataConverter\DataConverterInterface; use Temporal\Exception\Failure\FailureConverter; use Temporal\Interceptor\Header; -use Temporal\Worker\Transport\Command\Client\UpdateResponse; +use Temporal\Worker\Transport\Command\Client\CommandResponse; use Temporal\Worker\Transport\Command\CommandInterface; use Temporal\Worker\Transport\Command\FailureResponseInterface; use Temporal\Worker\Transport\Command\RequestInterface; @@ -51,7 +51,7 @@ public function encode(CommandInterface $cmd): Message } $msg->setCommand($cmd->getName()); - $msg->setOptions(\json_encode($options)); + $msg->setOptions(\json_encode($options, \JSON_THROW_ON_ERROR)); $msg->setPayloads($cmd->getPayloads()->toPayloads()); $msg->setHeader($header->toHeader()); @@ -62,21 +62,30 @@ public function encode(CommandInterface $cmd): Message return $msg; case $cmd instanceof FailureResponseInterface: - \is_int($cmd->getID()) and $msg->setId($cmd->getID()); + if (\is_int($cmd->getID())) { + $msg->setId($cmd->getID()); + } $msg->setFailure(FailureConverter::mapExceptionToFailure($cmd->getFailure(), $this->converter)); return $msg; case $cmd instanceof SuccessResponseInterface: - \is_int($cmd->getID()) and $msg->setId($cmd->getID()); + if (\is_int($cmd->getID())) { + $msg->setId($cmd->getID()); + } $cmd->getPayloads()->setDataConverter($this->converter); $msg->setPayloads($cmd->getPayloads()->toPayloads()); return $msg; - case $cmd instanceof UpdateResponse: + case $cmd instanceof CommandResponse: + $options = $cmd->getOptions(); + if ($options === []) { + $options = new \stdClass(); + } + $msg->setCommand($cmd->getCommand()); - $msg->setOptions(\json_encode($cmd->getOptions(), JSON_INVALID_UTF8_IGNORE | JSON_UNESCAPED_UNICODE)); + $msg->setOptions(\json_encode($options, \JSON_THROW_ON_ERROR)); if ($cmd->getFailure() !== null) { $msg->setFailure(FailureConverter::mapExceptionToFailure($cmd->getFailure(), $this->converter)); diff --git a/src/Worker/Transport/Command/Client/UpdateResponse.php b/src/Worker/Transport/Command/Client/CommandResponse.php similarity index 67% rename from src/Worker/Transport/Command/Client/UpdateResponse.php rename to src/Worker/Transport/Command/Client/CommandResponse.php index 1b10adcaa..6889c62e8 100644 --- a/src/Worker/Transport/Command/Client/UpdateResponse.php +++ b/src/Worker/Transport/Command/Client/CommandResponse.php @@ -14,16 +14,16 @@ use Temporal\DataConverter\ValuesInterface; use Temporal\Worker\Transport\Command\ResponseInterface; -final class UpdateResponse implements ResponseInterface +final class CommandResponse implements ResponseInterface { - public const COMMAND_VALIDATED = 'UpdateValidated'; - public const COMMAND_COMPLETED = 'UpdateCompleted'; - + /** + * @param array $options + */ public function __construct( private readonly string $command, - private ?ValuesInterface $values, - private readonly ?\Throwable $failure, - private string|int $updateId, + private readonly array $options = [], + private readonly ?ValuesInterface $payloads = null, + private readonly ?\Throwable $failure = null, ) {} public function getID(): int @@ -36,18 +36,21 @@ public function getCommand(): string return $this->command; } - public function getPayloads(): ?ValuesInterface + /** + * @return array + */ + public function getOptions(): array { - return $this->values; + return $this->options; } - public function getFailure(): ?\Throwable + public function getPayloads(): ?ValuesInterface { - return $this->failure; + return $this->payloads; } - public function getOptions(): array + public function getFailure(): ?\Throwable { - return ['id' => $this->updateId]; + return $this->failure; } } diff --git a/src/Worker/Transport/Command/Client/FailedClientResponse.php b/src/Worker/Transport/Command/Client/FailedClientResponse.php index a834f2b4f..4ec0c0c2b 100644 --- a/src/Worker/Transport/Command/Client/FailedClientResponse.php +++ b/src/Worker/Transport/Command/Client/FailedClientResponse.php @@ -17,7 +17,7 @@ final class FailedClientResponse implements FailureResponseInterface { public function __construct( private readonly int|string $id, - private readonly ?\Throwable $failure = null, + private readonly \Throwable $failure, ) {} public function getID(): string|int diff --git a/src/Worker/Worker.php b/src/Worker/Worker.php index 391fa02c0..3b4d4a787 100644 --- a/src/Worker/Worker.php +++ b/src/Worker/Worker.php @@ -13,6 +13,7 @@ use React\Promise\PromiseInterface; use Temporal\Internal\Declaration\EntityNameValidator; +use Temporal\Internal\Declaration\Reader\NexusServiceReader; use Temporal\Internal\Events\EventEmitterTrait; use Temporal\Internal\Events\EventListenerInterface; use Temporal\Internal\Repository\RepositoryInterface; @@ -115,6 +116,37 @@ public function getActivities(): RepositoryInterface return $this->services->activities; } + public function registerNexusServiceImplementation(object ...$services): WorkerInterface + { + $hasClient = $this->services->workflowClient !== null; + + foreach ($services as $service) { + $prototype = $this->services->nexusServicesReader->fromClass($service::class); + + if (!$hasClient) { + foreach ($prototype->getOperations() as $operation) { + if ($operation->async && !NexusServiceReader::returnsOperationHandler($operation->handler)) { + throw new \LogicException(\sprintf( + 'Nexus service %s declares async operation "%s", which needs cluster access. ' + . 'Pass a WorkflowClient to the worker: WorkerFactory::create(client: $workflowClient).', + $service::class, + $operation->name, + )); + } + } + } + + $this->services->nexusServices->add($prototype->withInstance($service), false); + } + + return $this; + } + + public function getNexusServices(): RepositoryInterface + { + return $this->services->nexusServices; + } + protected function createRouter(): RouterInterface { $router = new Router(); @@ -132,6 +164,20 @@ protected function createRouter(): RouterInterface $router->add(new Router\DestroyWorkflow($this->services->running, $this->services->loop)); $router->add(new Router\StackTrace($this->services->running)); + // Nexus routes + $router->add(new Router\InvokeNexusOperation( + $this->services->nexusTaskHandler, + $this->services->nexusInvocations, + $this->services->dataConverter, + $this->services->marshaller, + $this->services->env, + )); + $router->add(new Router\CancelNexusOperation( + $this->services->nexusTaskHandler, + $this->services->marshaller, + )); + $router->add(new Router\CancelNexusOperationMethod($this->services->nexusInvocations)); + return $router; } } diff --git a/src/Worker/WorkerInterface.php b/src/Worker/WorkerInterface.php index 893cff752..5a37e0a3e 100644 --- a/src/Worker/WorkerInterface.php +++ b/src/Worker/WorkerInterface.php @@ -12,6 +12,7 @@ namespace Temporal\Worker; use Temporal\Internal\Declaration\Prototype\ActivityPrototype; +use Temporal\Internal\Declaration\Prototype\NexusServicePrototype; use Temporal\Internal\Declaration\Prototype\WorkflowPrototype; use Temporal\Internal\Repository\Identifiable; @@ -79,4 +80,18 @@ public function registerActivity(string $type, ?callable $factory = null): self; * @return iterable */ public function getActivities(): iterable; + + /** + * Register one or multiple Nexus service implementations to be served by this worker. + * + * @return $this + */ + public function registerNexusServiceImplementation(object ...$services): self; + + /** + * Returns list of registered Nexus service prototypes. + * + * @return iterable + */ + public function getNexusServices(): iterable; } diff --git a/src/WorkerFactory.php b/src/WorkerFactory.php index c64245815..98ad3e165 100644 --- a/src/WorkerFactory.php +++ b/src/WorkerFactory.php @@ -113,6 +113,7 @@ class WorkerFactory implements WorkerFactoryInterface, LoopInterface protected EnvironmentInterface $env; protected PluginRegistry $pluginRegistry; + protected ?WorkflowClient $workflowClient = null; public function __construct( DataConverterInterface $dataConverter, @@ -121,6 +122,7 @@ public function __construct( ?PluginRegistry $pluginRegistry = null, ?WorkflowClient $client = null, ) { + $this->workflowClient = $client; $this->pluginRegistry = new PluginRegistry(); // Propagate worker plugins from the client first if ($client !== null) { @@ -202,6 +204,7 @@ public function newWorker( $options->enableLoggingInReplay, $taskQueue, ), + $this->workflowClient, ), $this->rpc, ); diff --git a/src/Workflow.php b/src/Workflow.php index 0938d890a..bda5052ee 100644 --- a/src/Workflow.php +++ b/src/Workflow.php @@ -28,6 +28,7 @@ use Temporal\Internal\Workflow\ChildWorkflowProxy; use Temporal\Internal\Workflow\ContinueAsNewProxy; use Temporal\Internal\Workflow\ExternalWorkflowProxy; +use Temporal\Internal\Workflow\NexusServiceProxy; use Temporal\Workflow\ActivityStubInterface; use Temporal\Workflow\CancellationScopeInterface; use Temporal\Workflow\ChildWorkflowOptions; @@ -1013,6 +1014,52 @@ public static function newUntypedActivityStub( return self::getCurrentContext()->newUntypedActivityStub($options); } + /** + * Returns a typed proxy for a Nexus service interface. + * Method calls on the returned object will execute Nexus operations. + * + * @template T of object + * + * @param class-string $class Nexus service interface annotated with #[Service] + * + * @return NexusServiceProxy + * @throws OutOfContextException in the absence of the workflow execution context. + */ + public static function newNexusServiceStub( + string $class, + Workflow\NexusOperationOptions $options, + ): object { + return self::getCurrentContext()->newNexusServiceStub($class, $options); + } + + /** + * Returns an untyped Nexus operation stub. + * + * @throws OutOfContextException in the absence of the workflow execution context. + */ + public static function newUntypedNexusOperationStub( + Workflow\NexusOperationOptions $options, + ): Workflow\NexusOperationStubInterface { + return self::getCurrentContext()->newUntypedNexusOperationStub($options); + } + + /** + * Execute a Nexus operation directly without a typed stub. + * + * @param array $nexusHeaders Raw-string headers carried on + * the Nexus wire and surfaced to the handler via OperationContext. + * @throws OutOfContextException in the absence of the workflow execution context. + */ + public static function executeNexusOperation( + string $operation, + array $args = [], + ?Workflow\NexusOperationOptions $options = null, + Type|string|\ReflectionClass|\ReflectionType|null $returnType = null, + array $nexusHeaders = [], + ): PromiseInterface { + return self::getCurrentContext()->executeNexusOperation($operation, $args, $options, $returnType, $nexusHeaders); + } + /** * Returns a complete trace of the last calls (for debugging). * diff --git a/src/Workflow/CompletionCallback.php b/src/Workflow/CompletionCallback.php new file mode 100644 index 000000000..daa1f639f --- /dev/null +++ b/src/Workflow/CompletionCallback.php @@ -0,0 +1,65 @@ + + */ + public readonly array $links; + + /** + * @param non-empty-string $url Callback URL the server invokes when the + * workflow reaches a terminal state. + * @param array $headers Optional headers sent verbatim. + * @param list $links Already-converted proto Links. + * @throws \InvalidArgumentException when $url is empty. + */ + public function __construct( + public readonly string $url, + public readonly array $headers = [], + array $links = [], + ) { + /** @psalm-suppress TypeDoesNotContainType — defensive runtime check */ + if ($url === '') { + throw new \InvalidArgumentException('CompletionCallback: url must be a non-empty string'); + } + $this->links = $links; + } + + /** + * Build a callback with high-level Nexus links converted to proto Links. + * + * @param non-empty-string $url + * @param array $headers + * @param iterable $nexusLinks + */ + public static function fromNexusLinks(string $url, array $headers, iterable $nexusLinks): self + { + return new self($url, $headers, NexusLinkConverter::toProtoLinks($nexusLinks)); + } +} diff --git a/src/Workflow/NexusOperationCancellationType.php b/src/Workflow/NexusOperationCancellationType.php new file mode 100644 index 000000000..dcd08bd08 --- /dev/null +++ b/src/Workflow/NexusOperationCancellationType.php @@ -0,0 +1,38 @@ +resultPromise = EncodedValues::decodePromise($rawResult, $returnType); + } + + /** + * @return PromiseInterface + */ + public function getResult(): PromiseInterface + { + return $this->resultPromise; + } + + public function getOperationToken(): ?string + { + return $this->operationToken; + } +} diff --git a/src/Workflow/NexusOperationOptions.php b/src/Workflow/NexusOperationOptions.php new file mode 100644 index 000000000..810e05042 --- /dev/null +++ b/src/Workflow/NexusOperationOptions.php @@ -0,0 +1,117 @@ +scheduleToCloseTimeout = \Carbon\CarbonInterval::seconds(0); + $this->cancellationType = NexusOperationCancellationType::Unspecified; + parent::__construct(); + } + + /** + * @param non-empty-string $endpoint + */ + #[Pure] + public function withEndpoint(string $endpoint): self + { + PrintableAsciiValidator::assert($endpoint, 'Nexus Endpoint'); + $self = clone $this; + $self->endpoint = $endpoint; + return $self; + } + + /** + * @param non-empty-string $service + */ + #[Pure] + public function withService(string $service): self + { + ServiceNameValidator::assert($service); + $self = clone $this; + $self->service = $service; + return $self; + } + + /** + * @param DateIntervalValue $timeout + */ + #[Pure] + public function withScheduleToCloseTimeout($timeout): self + { + \assert(DateInterval::assert($timeout)); + $timeout = DateInterval::parse($timeout, DateInterval::FORMAT_SECONDS); + \assert($timeout->totalMicroseconds >= 0); + + $self = clone $this; + $self->scheduleToCloseTimeout = $timeout; + return $self; + } + + #[Pure] + public function withCancellationType(NexusOperationCancellationType|int $type): self + { + if (\is_int($type)) { + $type = NexusOperationCancellationType::from($type); + } + + $self = clone $this; + $self->cancellationType = $type; + return $self; + } +} diff --git a/src/Workflow/NexusOperationStubInterface.php b/src/Workflow/NexusOperationStubInterface.php new file mode 100644 index 000000000..37e6c8977 --- /dev/null +++ b/src/Workflow/NexusOperationStubInterface.php @@ -0,0 +1,63 @@ +getResult(). + * + * @param non-empty-string $operation + * @param array $nexusHeaders Raw-string headers carried on the Nexus wire. + */ + public function execute( + string $operation, + array $args = [], + Type|string|\ReflectionClass|\ReflectionType|null $returnType = null, + array $nexusHeaders = [], + ): PromiseInterface; + + /** + * Start a Nexus operation. The returned promise resolves with a + * {@see NexusOperationHandle} once the start response arrives — by that + * point the discriminator is known, so the handle's `operationToken` is + * fully populated (string for async, null for sync) and its result-promise + * is wired (already-resolved for sync, pending-poll for async). + * + * Workflow code yields the returned promise: + * + * ```php + * $handle = yield $stub->start('order.place', [$order]); + * $token = $handle->getOperationToken(); + * $result = yield $handle->getResult(); + * ``` + * + * @param non-empty-string $operation + * @param array $nexusHeaders + * @return PromiseInterface + */ + public function start( + string $operation, + array $args = [], + Type|string|\ReflectionClass|\ReflectionType|null $returnType = null, + array $nexusHeaders = [], + ): PromiseInterface; +} diff --git a/src/Workflow/WorkflowContextInterface.php b/src/Workflow/WorkflowContextInterface.php index 0711d6701..2a4994a32 100644 --- a/src/Workflow/WorkflowContextInterface.php +++ b/src/Workflow/WorkflowContextInterface.php @@ -25,6 +25,7 @@ use Temporal\Internal\Workflow\ChildWorkflowProxy; use Temporal\Internal\Workflow\ContinueAsNewProxy; use Temporal\Internal\Workflow\ExternalWorkflowProxy; +use Temporal\Internal\Workflow\NexusServiceProxy; use Temporal\Worker\Transport\Command\RequestInterface; use Temporal\Worker\Environment\EnvironmentInterface; use Temporal\Workflow; @@ -303,6 +304,47 @@ public function newUntypedActivityStub( ?ActivityOptionsInterface $options = null, ): ActivityStubInterface; + /** + * Returns a typed proxy for a Nexus service interface. + * Method calls on the returned object will execute Nexus operations. + * + * @see Workflow::newNexusServiceStub() + * + * @template T of object + * @param class-string $class Nexus service interface annotated with #[Service] + * + * @return NexusServiceProxy + */ + public function newNexusServiceStub( + string $class, + NexusOperationOptions $options, + ): object; + + /** + * Returns an untyped Nexus operation stub. + * + * @see Workflow::newUntypedNexusOperationStub() + */ + public function newUntypedNexusOperationStub( + NexusOperationOptions $options, + ): NexusOperationStubInterface; + + /** + * Execute a Nexus operation directly without a typed stub. + * + * @see Workflow::executeNexusOperation() + * + * @param array $nexusHeaders Raw-string headers carried on + * the Nexus wire and surfaced to the handler via OperationContext. + */ + public function executeNexusOperation( + string $operation, + array $args = [], + ?NexusOperationOptions $options = null, + Type|string|\ReflectionClass|\ReflectionType|null $returnType = null, + array $nexusHeaders = [], + ): PromiseInterface; + /** * Moves to the next step if the expression evaluates to `true`. * diff --git a/src/Workflow/WorkflowExecutionInfo.php b/src/Workflow/WorkflowExecutionInfo.php index d214bd081..d621f77e8 100644 --- a/src/Workflow/WorkflowExecutionInfo.php +++ b/src/Workflow/WorkflowExecutionInfo.php @@ -4,6 +4,7 @@ namespace Temporal\Workflow; +use Carbon\CarbonInterval; use JetBrains\PhpStorm\Immutable; use Temporal\Common\WorkerVersionStamp; use Temporal\DataConverter\EncodedCollection; @@ -96,4 +97,32 @@ public function __construct( */ public readonly string $firstRunId, ) {} + + /** + * Drops {@see self::$memo} and {@see self::$searchAttributes} — their lazily-decoded payloads dump as dozens of nested protobuf objects. + */ + public function __debugInfo(): array + { + return [ + 'execution' => $this->execution, + 'type' => $this->type, + 'startTime' => $this->startTime?->format(\DateTimeInterface::ATOM), + 'closeTime' => $this->closeTime?->format(\DateTimeInterface::ATOM), + 'status' => $this->status, + 'historyLength' => $this->historyLength, + 'parentNamespaceId' => $this->parentNamespaceId, + 'parentExecution' => $this->parentExecution, + 'executionTime' => $this->executionTime?->format(\DateTimeInterface::ATOM), + 'autoResetPoints' => $this->autoResetPoints, + 'taskQueue' => $this->taskQueue, + 'stateTransitionCount' => $this->stateTransitionCount, + 'historySizeBytes' => $this->historySizeBytes, + 'mostRecentWorkerVersionStamp' => $this->mostRecentWorkerVersionStamp, + 'executionDuration' => $this->executionDuration === null + ? null + : CarbonInterval::instance($this->executionDuration)->spec(), + 'rootExecution' => $this->rootExecution, + 'firstRunId' => $this->firstRunId, + ]; + } } diff --git a/stubs/CarbonInterval.phpstub b/stubs/CarbonInterval.phpstub new file mode 100644 index 000000000..5550de321 --- /dev/null +++ b/stubs/CarbonInterval.phpstub @@ -0,0 +1,26 @@ + + * @implements \IteratorAggregate + */ +class MapField implements \ArrayAccess, \IteratorAggregate, \Countable +{ + /** + * @param TKey $offset + * @return bool + */ + public function offsetExists($offset): bool {} + + /** + * @param TKey $offset + * @return TValue + */ + public function offsetGet($offset) {} + + /** + * @param TKey $offset + * @param TValue $value + */ + public function offsetSet($offset, $value): void {} + + /** + * @param TKey $offset + */ + public function offsetUnset($offset): void {} + + /** + * @return \Traversable + */ + public function getIterator(): \Traversable {} + + public function count(): int {} +} diff --git a/stubs/GoogleProtobufRepeatedField.phpstub b/stubs/GoogleProtobufRepeatedField.phpstub new file mode 100644 index 000000000..84a446ae1 --- /dev/null +++ b/stubs/GoogleProtobufRepeatedField.phpstub @@ -0,0 +1,42 @@ + + * @implements \IteratorAggregate + */ +class RepeatedField implements \ArrayAccess, \IteratorAggregate, \Countable +{ + /** + * @param int $offset + * @return bool + */ + public function offsetExists($offset): bool {} + + /** + * @param int $offset + * @return T + */ + public function offsetGet($offset) {} + + /** + * @param int|null $offset + * @param T $value + */ + public function offsetSet($offset, $value): void {} + + /** + * @param int $offset + */ + public function offsetUnset($offset): void {} + + /** + * @return \Traversable + */ + public function getIterator(): \Traversable {} + + public function count(): int {} +} + diff --git a/testing/src/WorkerFactory.php b/testing/src/WorkerFactory.php index cb68951fc..d5d86c861 100644 --- a/testing/src/WorkerFactory.php +++ b/testing/src/WorkerFactory.php @@ -107,6 +107,7 @@ public function newWorker( $options->enableLoggingInReplay, $taskQueue, ), + $this->workflowClient, ), $this->rpc, ), diff --git a/testing/src/WorkerMock.php b/testing/src/WorkerMock.php index 6e9ec2885..48c3ffd9e 100644 --- a/testing/src/WorkerMock.php +++ b/testing/src/WorkerMock.php @@ -82,4 +82,16 @@ public function getActivities(): iterable { return $this->wrapped->getActivities(); } + + public function registerNexusServiceImplementation(object ...$services): WorkerInterface + { + $this->wrapped->registerNexusServiceImplementation(...$services); + + return $this; + } + + public function getNexusServices(): iterable + { + return $this->wrapped->getNexusServices(); + } } diff --git a/tests/Acceptance/.rr.yaml b/tests/Acceptance/.rr.yaml index 07c49f144..2903e3712 100644 --- a/tests/Acceptance/.rr.yaml +++ b/tests/Acceptance/.rr.yaml @@ -21,3 +21,5 @@ kv: logs: mode: none #info + output: /Users/xepozz/IdeaProjects/temporalio/sdk-php/runtime/rr.log + err_output: /Users/xepozz/IdeaProjects/temporalio/sdk-php/runtime/rr.err.log diff --git a/tests/Acceptance/App/Feature/WorkflowStubInjector.php b/tests/Acceptance/App/Feature/WorkflowStubInjector.php index cb29c4283..84023e4ef 100644 --- a/tests/Acceptance/App/Feature/WorkflowStubInjector.php +++ b/tests/Acceptance/App/Feature/WorkflowStubInjector.php @@ -66,7 +66,7 @@ public function createInjection( \sprintf( 'Workflow %s did not start. WorkflowOptions: %s. WorkflowInfo: %s', $attribute->type, - \json_encode($options, JSON_PRETTY_PRINT), + \print_r($options, true), \print_r($description->info, true), ), ); diff --git a/tests/Acceptance/App/Runtime/Feature.php b/tests/Acceptance/App/Runtime/Feature.php index 7d01b48f2..736612950 100644 --- a/tests/Acceptance/App/Runtime/Feature.php +++ b/tests/Acceptance/App/Runtime/Feature.php @@ -20,6 +20,9 @@ final class Feature /** @var list> Lazy callables */ public array $converters = []; + /** @var list Nexus service implementation classes */ + public array $nexusServices = []; + /** * @param non-empty-string $taskQueue */ diff --git a/tests/Acceptance/App/Runtime/State.php b/tests/Acceptance/App/Runtime/State.php index 9eea9d8aa..827d9fe5d 100644 --- a/tests/Acceptance/App/Runtime/State.php +++ b/tests/Acceptance/App/Runtime/State.php @@ -130,6 +130,26 @@ public function addActivity(\Temporal\Tests\Acceptance\App\Input\Feature $inputF $this->getFeature($inputFeature)->activities[] = $class; } + /** + * @param class-string $class + */ + public function addNexusService(\Temporal\Tests\Acceptance\App\Input\Feature $inputFeature, string $class): void + { + $this->getFeature($inputFeature)->nexusServices[] = $class; + } + + /** + * @return \Traversable + */ + public function nexusServices(): \Traversable + { + foreach ($this->features as $feature) { + foreach ($feature->nexusServices as $service) { + yield $feature => $service; + } + } + } + private function getFeature(\Temporal\Tests\Acceptance\App\Input\Feature $feature): Feature { return $this->features[$feature->testClass] ??= new Feature($feature->taskQueue); diff --git a/tests/Acceptance/App/Runtime/TemporalStarter.php b/tests/Acceptance/App/Runtime/TemporalStarter.php index 5a9aab1bf..f5e644895 100644 --- a/tests/Acceptance/App/Runtime/TemporalStarter.php +++ b/tests/Acceptance/App/Runtime/TemporalStarter.php @@ -25,6 +25,7 @@ public function start(): void $this->environment->startTemporalServer( parameters: [ + '--http-port', '7243', '--dynamic-config-value', 'frontend.enableUpdateWorkflowExecution=true', '--dynamic-config-value', 'frontend.enableUpdateWorkflowExecutionAsyncAccepted=true', '--dynamic-config-value', 'frontend.enableExecuteMultiOperation=true', diff --git a/tests/Acceptance/App/RuntimeBuilder.php b/tests/Acceptance/App/RuntimeBuilder.php index 64086f205..6ceb21059 100644 --- a/tests/Acceptance/App/RuntimeBuilder.php +++ b/tests/Acceptance/App/RuntimeBuilder.php @@ -4,6 +4,7 @@ namespace Temporal\Tests\Acceptance\App; +use Temporal\Nexus\Attribute\Service as NexusService; use PHPUnit\Framework\Attributes\Test; use Temporal\Activity\ActivityInterface; use Temporal\DataConverter\PayloadConverterInterface; @@ -37,6 +38,19 @@ public static function hydrateClasses(State $runtime, array $allowedTestClasses $runtime->addActivity($feature, $classString); } + if (!$class->isInterface() && !$class->isAbstract()) { + if ($class->getAttributes(NexusService::class) !== []) { + $runtime->addNexusService($feature, $classString); + } else { + foreach ($class->getInterfaces() as $interface) { + if ($interface->getAttributes(NexusService::class) !== []) { + $runtime->addNexusService($feature, $classString); + break; + } + } + } + } + if ($class->implementsInterface(PayloadConverterInterface::class)) { $runtime->addConverter($feature, $classString); } diff --git a/tests/Acceptance/App/TaskQueueResolver.php b/tests/Acceptance/App/TaskQueueResolver.php index 399ab18fc..6dcd60e91 100644 --- a/tests/Acceptance/App/TaskQueueResolver.php +++ b/tests/Acceptance/App/TaskQueueResolver.php @@ -36,6 +36,10 @@ public static function resolve(string $class, string $namespace): string return $namespace; } + if (\str_starts_with($namespace, 'Temporal\\Tests\\Acceptance\\Extra\\Nexus\\')) { + return $namespace; + } + $reflection = new \ReflectionClass($class); foreach ($reflection->getAttributes(Worker::class) as $attribute) { $worker = $attribute->newInstance(); diff --git a/tests/Acceptance/ExecutionStartedSubscriber.php b/tests/Acceptance/ExecutionStartedSubscriber.php index 56940a800..e32da7c9f 100644 --- a/tests/Acceptance/ExecutionStartedSubscriber.php +++ b/tests/Acceptance/ExecutionStartedSubscriber.php @@ -15,6 +15,8 @@ use Spiral\Goridge\RPC\RPCInterface; use Spiral\RoadRunner\KeyValue\Factory; use Spiral\RoadRunner\KeyValue\StorageInterface; +use Symfony\Component\HttpClient\HttpClient; +use Temporal\Api\Operatorservice\V1\OperatorServiceClient; use Temporal\Client\ClientOptions; use Temporal\Client\GRPC\ServiceClient; use Temporal\Client\GRPC\ServiceClientInterface; @@ -35,6 +37,8 @@ use Temporal\Tests\Acceptance\App\Runtime\TemporalStarter; use Temporal\Tests\Acceptance\App\RuntimeBuilder; use Temporal\Tests\Acceptance\App\Support; +use Temporal\Tests\Acceptance\Extra\Nexus\NexusEndpoints; +use Temporal\Tests\Acceptance\Extra\Nexus\NexusHttpClient; use Temporal\Worker\Logger\StderrLogger; final class ExecutionStartedSubscriber implements ExecutionStartedSubscriberInterface @@ -141,11 +145,22 @@ private function boot(ExecutionStarted $event): void converter: $converter, )->withTimeout(5); + $nexusHost = \parse_url("http://{$state->address}", PHP_URL_HOST) ?: '127.0.0.1'; + $nexusEndpoints = new NexusEndpoints( + new OperatorServiceClient( + $state->address, + ['credentials' => \Grpc\ChannelCredentials::createInsecure()], + ), + ); + $nexusHttp = new NexusHttpClient(HttpClient::createForBaseUri("http://{$nexusHost}:7243")); + $container->bindSingleton(RRStarter::class, $rrRunner); $container->bindSingleton(TemporalStarter::class, $temporalRunner); $container->bindSingleton(ServiceClientInterface::class, $serviceClient); $container->bindSingleton(WorkflowClientInterface::class, $workflowClient); $container->bindSingleton(ScheduleClientInterface::class, $scheduleClient); + $container->bindSingleton(NexusEndpoints::class, $nexusEndpoints); + $container->bindSingleton(NexusHttpClient::class, $nexusHttp); $container->bindInjector(WorkflowStubInterface::class, WorkflowStubInjector::class); $container->bindSingleton(DataConverterInterface::class, $converter); $container->bind(RPCInterface::class, static fn() => RPC::create(\getenv('RR_RPC_ADDRESS') ?: 'tcp://127.0.0.1:6001')); diff --git a/tests/Acceptance/Extra/Nexus/AsyncCancelTypes/AsyncCancelTypesTest.php b/tests/Acceptance/Extra/Nexus/AsyncCancelTypes/AsyncCancelTypesTest.php new file mode 100644 index 000000000..f7a32a368 --- /dev/null +++ b/tests/Acceptance/Extra/Nexus/AsyncCancelTypes/AsyncCancelTypesTest.php @@ -0,0 +1,277 @@ +runCancelScenario($state, $client, $endpoints, 'try-cancel'); + self::assertSame('ok', $stub->getResult('string')); + } + + #[Test] + public function waitCompleted(State $state, WorkflowClientInterface $client, NexusEndpoints $endpoints): void + { + $stub = $this->runCancelScenario($state, $client, $endpoints, 'wait-completed'); + self::assertSame('cancelled:payload', $stub->getResult('string')); + } + + #[Test] + public function unspecifiedDefaultsToWaitCompleted( + State $state, + WorkflowClientInterface $client, + NexusEndpoints $endpoints, + ): void { + $stub = $this->runCancelScenario($state, $client, $endpoints, 'unspecified'); + + // Unspecified (value 0, dropped on the wire) must behave like WaitCompleted. + self::assertSame( + 'cancelled:payload', + $stub->getResult('string'), + 'Unspecified must default to WaitCompleted behaviour.', + ); + } + + #[Test] + public function abandonResumesCallerImmediatelyWithoutWireCancel( + State $state, + WorkflowClientInterface $client, + NexusEndpoints $endpoints, + ): void { + $stub = $this->runCancelScenario($state, $client, $endpoints, 'abandon'); + + self::assertSame( + 'ok', + $stub->getResult('string'), + 'Abandon must resolve the caller future immediately with a CanceledFailure.', + ); + + $history = $client->getWorkflowHistory($stub->getExecution())->getHistory(); + self::assertSame( + 0, + self::countEvents($history, EventType::EVENT_TYPE_NEXUS_OPERATION_CANCEL_REQUESTED), + 'Abandon must NOT send a RequestCancelNexusOperation to the server.', + ); + } + + #[Test] + public function cancelBeforeSentResumesCallerWithoutScheduling( + State $state, + WorkflowClientInterface $client, + NexusEndpoints $endpoints, + ): void { + $stub = $this->runCancelScenario($state, $client, $endpoints, 'cancel-before-sent'); + + self::assertSame( + 'ok', + $stub->getResult('string'), + 'Cancel before the schedule command is flushed must resolve the caller with a CanceledFailure.', + ); + + $history = $client->getWorkflowHistory($stub->getExecution())->getHistory(); + self::assertSame( + 0, + self::countEvents($history, EventType::EVENT_TYPE_NEXUS_OPERATION_SCHEDULED), + 'Nothing must be scheduled on the server when the operation is cancelled before being sent.', + ); + } + + private function runCancelScenario( + State $state, + WorkflowClientInterface $client, + NexusEndpoints $endpoints, + string $scenario, + ): WorkflowStubInterface { + $endpoint = $endpoints->register($state->namespace, __NAMESPACE__, 'nexus-cancel-' . $scenario); + + $stub = $client->newUntypedWorkflowStub( + 'Extra_Nexus_AsyncCancelTypes_Caller', + WorkflowOptions::new() + ->withTaskQueue(__NAMESPACE__) + ->withWorkflowExecutionTimeout(CarbonInterval::seconds(20)), + ); + + $client->start($stub, $endpoint->name, $scenario); + + return $stub; + } +} + +// ── Service A: long-running handler that catches cancel ──────────── + +#[Service(name: 'CancelTypesService')] +class CancelTypesService +{ + #[AsyncOperation(output: 'string')] + public function longRunning(string $input): WorkflowHandle + { + return WorkflowHandle::fromWorkflowMethod( + LongRunningHandlerWorkflow::class, + WorkflowOptions::new()->withWorkflowId(Nexus::getStartDetails()->requestId), + $input, + ); + } +} + +#[WorkflowInterface] +class LongRunningHandlerWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_AsyncCancelTypes_LongHandler')] + public function handle(string $input) + { + try { + yield Workflow::timer(CarbonInterval::seconds(30)); + return "completed:{$input}"; + } catch (CanceledFailure) { + return "cancelled:{$input}"; + } + } +} + +// ── Service B: Abandon scenario — handler keeps running server-side ─ + +#[Service(name: 'AbandonService')] +class AbandonService +{ + #[AsyncOperation(output: 'string')] + public function run(string $input): WorkflowHandle + { + return WorkflowHandle::fromWorkflowMethod( + AbandonHandlerWorkflow::class, + WorkflowOptions::new()->withWorkflowId(Nexus::getStartDetails()->requestId), + $input, + ); + } +} + +#[WorkflowInterface] +class AbandonHandlerWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_AsyncCancelTypes_AbandonHandler')] + public function handle(string $input) + { + yield Workflow::timer(CarbonInterval::seconds(5)); + return "completed:{$input}"; + } +} + +// ── Caller workflow ──────────────────────────────────────────────── + +#[WorkflowInterface] +class CancelTypesCallerWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_AsyncCancelTypes_Caller')] + public function run(string $endpoint, string $scenario) + { + [$cancelType, $serviceClass, $opName, $waitBeforeCancel] = $this->resolveScenario($scenario); + + $stub = Workflow::newNexusServiceStub( + $serviceClass, + NexusOperationOptions::new() + ->withEndpoint($endpoint) + ->withScheduleToCloseTimeout(CarbonInterval::seconds(15)) + ->withCancellationType($cancelType), + ); + + $promise = null; + $scope = Workflow::async(static function () use ($stub, $opName, &$promise): void { + $promise = $stub->{$opName}('payload'); + }); + + if ($waitBeforeCancel > 0) { + yield Workflow::timer(CarbonInterval::milliseconds($waitBeforeCancel)); + } + $scope->cancel(); + + try { + $result = yield $promise; + + return $result; + } catch (NexusOperationFailure $e) { + $cause = $e->getPrevious(); + if (!$cause instanceof CanceledFailure) { + $causeName = $cause === null ? 'null' : $cause::class; + return "wrong-cause:{$causeName}"; + } + return 'ok'; + } + } + + /** + * @return array{int, class-string, non-empty-string, int} {cancellationType, service-FQCN, op-method, ms-to-wait} + */ + private function resolveScenario(string $scenario): array + { + return match ($scenario) { + 'try-cancel' => [ + NexusOperationCancellationType::TryCancel->value, + CancelTypesService::class, + 'longRunning', + 500, + ], + 'wait-completed' => [ + NexusOperationCancellationType::WaitCompleted->value, + CancelTypesService::class, + 'longRunning', + 500, + ], + 'unspecified' => [ + NexusOperationCancellationType::Unspecified->value, + CancelTypesService::class, + 'longRunning', + 500, + ], + 'abandon' => [ + NexusOperationCancellationType::Abandon->value, + AbandonService::class, + 'run', + 300, + ], + 'cancel-before-sent' => [ + NexusOperationCancellationType::TryCancel->value, + CancelTypesService::class, + 'longRunning', + 0, + ], + default => throw new \InvalidArgumentException("unknown scenario: {$scenario}"), + }; + } +} diff --git a/tests/Acceptance/Extra/Nexus/AsyncCompletion/AsyncCompletionTest.php b/tests/Acceptance/Extra/Nexus/AsyncCompletion/AsyncCompletionTest.php new file mode 100644 index 000000000..987985bc4 --- /dev/null +++ b/tests/Acceptance/Extra/Nexus/AsyncCompletion/AsyncCompletionTest.php @@ -0,0 +1,147 @@ +register($state->namespace, __NAMESPACE__, 'nexus-async-completion-cancel'); + + $caller = $client->newUntypedWorkflowStub( + 'Extra_Nexus_AsyncCompletion_CancelCaller', + WorkflowOptions::new() + ->withTaskQueue(__NAMESPACE__) + ->withWorkflowExecutionTimeout(CarbonInterval::seconds(60)), + ); + + $client->start($caller, $endpoint->name, 'payload'); + + self::assertSame('cancelled', $caller->getResult('string', timeout: 30)); + + $cancelRequested = false; + foreach ($client->getWorkflowHistory($caller->getExecution()) as $event) { + if ($event->getEventType() === EventType::EVENT_TYPE_NEXUS_OPERATION_CANCEL_REQUESTED) { + $cancelRequested = true; + break; + } + } + + self::assertTrue( + $cancelRequested, + 'Expected EVENT_TYPE_NEXUS_OPERATION_CANCEL_REQUESTED in caller workflow history; none found.', + ); + } +} + +// ── Nexus service ────────────────────────────────────────────────────── + +#[Service(name: 'AsyncCompletionService')] +class AsyncCompletionService +{ + #[AsyncOperation(output: 'string')] + public function longRunning(string $input): WorkflowHandle + { + return WorkflowHandle::fromWorkflowMethod( + LongRunningHandlerWorkflow::class, + WorkflowOptions::new()->withWorkflowId(Nexus::getStartDetails()->requestId), + $input, + ); + } +} + +// ── Handler workflow ──────────────────────────────────────────────────── + +#[WorkflowInterface] +class LongRunningHandlerWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_AsyncCompletion_Handler')] + public function handle(string $input) + { + try { + yield Workflow::timer(CarbonInterval::seconds(AsyncCompletionTest::HANDLER_DURATION_SECONDS)); + return "ok:{$input}"; + } catch (CanceledFailure) { + return "cancelled:{$input}"; + } + } +} + +// ── Caller workflow: start, then cancel after one task boundary ───────── + +#[WorkflowInterface] +class CancelCallerWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_AsyncCompletion_CancelCaller')] + public function run(string $endpoint, string $input) + { + $stub = Workflow::newNexusServiceStub( + AsyncCompletionService::class, + NexusOperationOptions::new() + ->withEndpoint($endpoint) + ->withScheduleToCloseTimeout( + CarbonInterval::seconds(AsyncCompletionTest::HANDLER_DURATION_SECONDS + 30), + ) + ->withCancellationType(NexusOperationCancellationType::WaitRequested), + ); + + $promise = null; + $scope = Workflow::async(static function () use ($stub, $input, &$promise): void { + $promise = $stub->longRunning($input); + }); + + // One task boundary so the schedule command is flushed before the cancel. + yield Workflow::timer(CarbonInterval::seconds(NexusWorkerOptions::PRE_CANCEL_TIMER_SECONDS)); + $scope->cancel(); + + try { + yield $promise; + } catch (NexusOperationFailure $e) { + if ($e->getPrevious() instanceof CanceledFailure) { + return 'cancelled'; + } + throw $e; + } + + return 'unexpected-completion'; + } +} diff --git a/tests/Acceptance/Extra/Nexus/AsyncFailure/AsyncFailureTest.php b/tests/Acceptance/Extra/Nexus/AsyncFailure/AsyncFailureTest.php new file mode 100644 index 000000000..f83c58fbf --- /dev/null +++ b/tests/Acceptance/Extra/Nexus/AsyncFailure/AsyncFailureTest.php @@ -0,0 +1,234 @@ +register($state->namespace, __NAMESPACE__, 'nexus-async-handler-fail'); + + $stub = $client->newUntypedWorkflowStub( + 'Extra_Nexus_AsyncFailure_HandlerFailsCaller', + WorkflowOptions::new() + ->withTaskQueue(__NAMESPACE__) + ->withWorkflowExecutionTimeout(CarbonInterval::seconds(15)), + ); + + $client->start($stub, $endpoint->name); + + self::assertSame('ok', $stub->getResult('string')); + } + + #[Test] + public function callerReceivesFailureWhenHandlerWorkflowTerminated( + State $state, + WorkflowClientInterface $client, + NexusEndpoints $endpoints, + ): void { + $endpoint = $endpoints->register($state->namespace, __NAMESPACE__, 'nexus-async-handler-term'); + + $callerStub = $client->newUntypedWorkflowStub( + 'Extra_Nexus_AsyncFailure_TerminateCaller', + WorkflowOptions::new() + ->withTaskQueue(__NAMESPACE__) + ->withWorkflowExecutionTimeout(CarbonInterval::seconds(15)), + ); + + $client->start($callerStub, $endpoint->name); + + if (!self::historyContains($client, $callerStub, EventType::EVENT_TYPE_NEXUS_OPERATION_STARTED, 5.0)) { + self::fail('Handler workflow never reached NEXUS_OPERATION_STARTED within 5s; nothing to terminate.'); + } + + $handlerStub = $client->newUntypedRunningWorkflowStub(HandlerWorkflowToTerminate::ID); + $handlerStub->terminate('test-terminate'); + + self::assertSame('ok', $callerStub->getResult('string')); + } +} + +// ── Service A: handler workflow throws ApplicationFailure ────────── + +#[Service(name: 'AsyncFailingService')] +class AsyncFailingService +{ + #[AsyncOperation(output: 'string')] + public function run(string $input): WorkflowHandle + { + return WorkflowHandle::fromWorkflowMethod( + FailingHandlerWorkflow::class, + WorkflowOptions::new()->withWorkflowId(Nexus::getStartDetails()->requestId), + $input, + ); + } +} + +#[WorkflowInterface] +class FailingHandlerWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_AsyncFailure_FailingHandler')] + public function handle(string $input) + { + // Yield once so the workflow takes a real task before failing. + yield Workflow::timer(CarbonInterval::milliseconds(50)); + throw new ApplicationFailure( + 'handler-workflow-failed', + 'BusinessError', + true, + ); + } +} + +#[WorkflowInterface] +class HandlerFailsCallerWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_AsyncFailure_HandlerFailsCaller')] + public function run(string $endpoint) + { + $stub = Workflow::newNexusServiceStub( + AsyncFailingService::class, + NexusOperationOptions::new() + ->withEndpoint($endpoint) + ->withScheduleToCloseTimeout(CarbonInterval::seconds(10)), + ); + + try { + yield $stub->run('payload'); + } catch (NexusOperationFailure $e) { + $cause = $e->getPrevious(); + if ($cause === null) { + return 'no-cause'; + } + + $haystack = $cause->getMessage(); + if ($cause instanceof ApplicationFailure) { + $haystack .= '|' . $cause->getOriginalMessage(); + } + + if (!\str_contains($haystack, 'handler-workflow-failed')) { + return 'missing-message:' . $cause::class . ':' . $haystack; + } + + return 'ok'; + } + + return 'unexpected:no-exception'; + } +} + +// ── Service B: handler workflow with fixed ID, terminated externally ── + +#[Service(name: 'AsyncTerminateService')] +class AsyncTerminateService +{ + #[AsyncOperation(output: 'string')] + public function run(string $input): WorkflowHandle + { + return WorkflowHandle::fromWorkflowMethod( + HandlerWorkflowToTerminate::class, + // Fixed workflow ID so the test can locate and terminate it. + WorkflowOptions::new()->withWorkflowId(HandlerWorkflowToTerminate::ID), + $input, + ); + } +} + +#[WorkflowInterface] +class HandlerWorkflowToTerminate +{ + public const ID = 'extra-nexus-asyncfailure-handler-to-terminate'; + + #[WorkflowMethod(name: 'Extra_Nexus_AsyncFailure_HandlerToTerminate')] + public function handle(string $input) + { + yield Workflow::timer(CarbonInterval::seconds(30)); + return "should-never-reach:{$input}"; + } +} + +#[WorkflowInterface] +class TerminateCallerWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_AsyncFailure_TerminateCaller')] + public function run(string $endpoint) + { + $stub = Workflow::newNexusServiceStub( + AsyncTerminateService::class, + NexusOperationOptions::new() + ->withEndpoint($endpoint) + ->withScheduleToCloseTimeout(CarbonInterval::seconds(12)), + ); + + try { + yield $stub->run('payload'); + } catch (NexusOperationFailure $e) { + $cause = $e->getPrevious(); + if ($cause === null) { + return 'no-cause'; + } + + if ($cause instanceof TerminatedFailure) { + return 'ok'; + } + + $haystack = \strtolower($cause->getMessage()); + if (\str_contains($haystack, 'terminat')) { + return 'ok'; + } + + return 'unexpected-cause:' . $cause::class . ':' . $cause->getMessage(); + } + + return 'unexpected:no-exception'; + } +} diff --git a/tests/Acceptance/Extra/Nexus/AsyncWorkflow/WorkflowRunOperationTest.php b/tests/Acceptance/Extra/Nexus/AsyncWorkflow/WorkflowRunOperationTest.php new file mode 100644 index 000000000..3291efcf9 --- /dev/null +++ b/tests/Acceptance/Extra/Nexus/AsyncWorkflow/WorkflowRunOperationTest.php @@ -0,0 +1,166 @@ +register($state->namespace, __NAMESPACE__, 'nexus-async-wf'); + + $stub = $client->newUntypedWorkflowStub( + 'Extra_Nexus_AsyncWorkflow_Caller', + WorkflowOptions::new() + ->withTaskQueue(__NAMESPACE__) + ->withWorkflowExecutionTimeout(CarbonInterval::seconds(20)), + ); + + $client->start($stub, $endpoint->name, 'world'); + + self::assertSame('HELLO, WORLD!', $stub->getResult('string')); + } + + /** Proves the requestId really becomes the handler workflow id (file marker carries it across processes). */ + #[Test] + public function requestIdIsPropagatedToHandlerWorkflowId( + State $state, + WorkflowClientInterface $client, + NexusEndpoints $endpoints, + ): void { + RequestIdMarker::clear(); + + $endpoint = $endpoints->register($state->namespace, __NAMESPACE__, 'nexus-async-wf-rid'); + + $stub = $client->newUntypedWorkflowStub( + 'Extra_Nexus_AsyncWorkflow_Caller', + WorkflowOptions::new() + ->withTaskQueue(__NAMESPACE__) + ->withWorkflowExecutionTimeout(CarbonInterval::seconds(20)), + ); + + $client->start($stub, $endpoint->name, 'idempotent'); + + self::assertSame('HELLO, IDEMPOTENT!', $stub->getResult('string')); + + $requestId = RequestIdMarker::read(); + self::assertNotNull($requestId, 'Handler never recorded the Nexus start requestId.'); + self::assertNotSame('', $requestId, 'Recorded Nexus requestId must be non-empty.'); + + $handlerStub = $client->newUntypedRunningWorkflowStub( + $requestId, + workflowType: 'Extra_Nexus_AsyncWorkflow_Handler', + ); + self::assertSame('HELLO, IDEMPOTENT!', $handlerStub->getResult('string', timeout: 10)); + + RequestIdMarker::clear(); + } +} + +/** File-backed marker: handler runs in a RR worker process separate from PHPUnit. */ +final class RequestIdMarker +{ + public const FILE = '/tmp/nexus-async-wf-request-id-marker-nexus-async-wf-rid'; + + public static function record(string $requestId): void + { + \file_put_contents(self::FILE, $requestId); + } + + public static function read(): ?string + { + if (!\is_file(self::FILE)) { + return null; + } + $contents = \file_get_contents(self::FILE); + return $contents === false ? null : $contents; + } + + public static function clear(): void + { + if (\is_file(self::FILE)) { + \unlink(self::FILE); + } + } +} + +// ── Nexus service ────────────────────────────────────────────────── + +#[Service(name: 'AsyncWorkflowService')] +class AsyncWorkflowService +{ + #[AsyncOperation(output: 'string')] + public function hello(string $input): WorkflowHandle + { + $details = Nexus::getStartDetails(); + RequestIdMarker::record($details->requestId); + return WorkflowHandle::fromWorkflowMethod( + AsyncHandlerWorkflow::class, + WorkflowOptions::new()->withWorkflowId($details->requestId), + $input, + ); + } +} + +// ── Handler workflow ─────────────────────────────────────────────── + +#[WorkflowInterface] +class AsyncHandlerWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_AsyncWorkflow_Handler')] + public function handle(string $input) + { + // Yield once so the operation goes async instead of collapsing into a sync result. + yield Workflow::timer(CarbonInterval::milliseconds(50)); + return 'HELLO, ' . \strtoupper($input) . '!'; + } +} + +// ── Caller workflow ──────────────────────────────────────────────── + +#[WorkflowInterface] +class AsyncCallerWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_AsyncWorkflow_Caller')] + public function run(string $endpoint, string $input) + { + $stub = Workflow::newNexusServiceStub( + AsyncWorkflowService::class, + NexusOperationOptions::new() + ->withEndpoint($endpoint) + ->withScheduleToCloseTimeout(CarbonInterval::seconds(30)), + ); + return yield $stub->hello($input); + } +} diff --git a/tests/Acceptance/Extra/Nexus/Basic/NexusRegistrationTest.php b/tests/Acceptance/Extra/Nexus/Basic/NexusRegistrationTest.php new file mode 100644 index 000000000..45830b9a0 --- /dev/null +++ b/tests/Acceptance/Extra/Nexus/Basic/NexusRegistrationTest.php @@ -0,0 +1,88 @@ +getResult('string'); + + self::assertSame('nexus-service-registered', $result); + } + + #[Test] + public function nexusHandlerProcessesRequest( + State $state, + NexusEndpoints $endpoints, + NexusHttpClient $http, + ): void { + $endpoint = $endpoints->register($state->namespace, __NAMESPACE__); + + [$code, $resp, ] = $http->post($endpoint, 'GreetingService', 'greet', 'World'); + + self::assertSame(200, $code, "Expected HTTP 200, got {$code}. Response: " . \substr($resp, 0, 500)); + self::assertStringContainsString('Hello, World!', $resp); + } +} + +// ── Nexus service (handler side) ───────────────────────────────── + +// Interface + impl shape kept here on purpose: this is the smoke test for the +// "contract is an interface, impl class implements it" registration path. The +// rest of the Nexus acceptance suite covers the class-only `#[Service]` shape. +#[Service(name: 'GreetingService')] +interface GreetingNexusServiceInterface +{ + #[Operation] + public function greet(string $name): string; +} + +final class GreetingNexusServiceImpl implements GreetingNexusServiceInterface +{ + public function greet(string $name): string + { + return "Hello, {$name}!"; + } +} + +// ── Workflow (needed for the test framework) ───────────────────── + +#[WorkflowInterface] +class NexusBasicWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_Basic')] + public function run(): string + { + return 'nexus-service-registered'; + } +} diff --git a/tests/Acceptance/Extra/Nexus/Cancel/AsyncOperationTest.php b/tests/Acceptance/Extra/Nexus/Cancel/AsyncOperationTest.php new file mode 100644 index 000000000..18c125ae0 --- /dev/null +++ b/tests/Acceptance/Extra/Nexus/Cancel/AsyncOperationTest.php @@ -0,0 +1,157 @@ +getResult('string'); + + $endpoint = $endpoints->register($state->namespace, __NAMESPACE__, 'nexus-cancel'); + + [$code, $resp, ] = $http->post( + $endpoint, + 'AsyncJobService', + 'startJob', + 'payload', + ['Nexus-Callback-Url' => 'http://callback.example.local/done'], + ); + + // Nexus spec: async StartOperation → 201 Created with a JSON OperationInfo body. + self::assertSame(201, $code, "Expected 201 Created for async start, got {$code}. Body: {$resp}"); + + $decoded = \json_decode($resp, true); + self::assertIsArray($decoded, "Async start body must be JSON OperationInfo. Body: {$resp}"); + // Spec field is `token`; tolerate `operationToken` for forward-compat. + $token = $decoded['token'] ?? $decoded['operationToken'] ?? null; + self::assertIsString($token, "Async start body must carry a token field. Body: {$resp}"); + self::assertNotSame('', $token, 'Operation token must be non-empty.'); + } + + /** Async start without `Nexus-Callback-Url`: server policy decides 201 vs 4xx; never a 5xx. */ + #[Test] + public function asyncOperationWithoutCallbackStillStarts( + State $state, + NexusEndpoints $endpoints, + NexusHttpClient $http, + #[Stub('Extra_Nexus_Cancel_Bootstrap2')] + WorkflowStubInterface $stub, + ): void { + $stub->getResult('string'); + + $endpoint = $endpoints->register($state->namespace, __NAMESPACE__, 'nexus-cancel-nocb'); + + [$code, $resp, ] = $http->post( + $endpoint, + 'AsyncJobService', + 'startJob', + 'payload-no-cb', + ); + + self::assertLessThan(500, $code, "Handler must not crash with 5xx. Body: {$resp}"); + + if ($code >= 200 && $code < 300) { + $decoded = \json_decode($resp, true); + self::assertIsArray($decoded, "2xx async start must carry JSON OperationInfo. Body: {$resp}"); + $token = $decoded['token'] ?? $decoded['operationToken'] ?? null; + self::assertIsString($token, "2xx async start must carry a token field. Body: {$resp}"); + self::assertNotSame('', $token, 'Operation token must be non-empty on a 2xx response.'); + } else { + self::assertStringContainsStringIgnoringCase( + 'callback', + $resp, + "A 4xx refusal must be about the missing callback, not some other failure. Code {$code}. Body: {$resp}", + ); + } + } +} + +// ── Nexus service ──────────────────────────────────────────────────── + +#[Service(name: 'AsyncJobService')] +class AsyncJobService +{ + #[AsyncOperation(output: 'string', input: 'string')] + public function startJob(): AsyncJobHandler + { + return new AsyncJobHandler(); + } +} + +final class AsyncJobHandler implements OperationHandlerInterface +{ + public function start( + OperationContext $context, + OperationStartDetails $details, + mixed $param, + ): OperationStartResult { + // Deterministic-ish token derived from requestId so the caller can correlate. + $token = 'job-' . \substr(\hash('sha1', $details->requestId . ':' . $param), 0, 12); + return OperationStartResult::async(new OperationInfo($token, OperationState::Running)); + } + + public function cancel( + OperationContext $context, + OperationCancelDetails $details, + ): void {} +} + +// ── Bootstrap workflows ────────────────────────────────────────────── + +#[WorkflowInterface] +class CancelBootstrapWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_Cancel_Bootstrap')] + public function run(): string + { + return 'ready'; + } +} + +#[WorkflowInterface] +class CancelBootstrapWorkflow2 +{ + #[WorkflowMethod(name: 'Extra_Nexus_Cancel_Bootstrap2')] + public function run(): string + { + return 'ready'; + } +} diff --git a/tests/Acceptance/Extra/Nexus/CancelAfterComplete/CancelAfterCompleteTest.php b/tests/Acceptance/Extra/Nexus/CancelAfterComplete/CancelAfterCompleteTest.php new file mode 100644 index 000000000..764eea3dd --- /dev/null +++ b/tests/Acceptance/Extra/Nexus/CancelAfterComplete/CancelAfterCompleteTest.php @@ -0,0 +1,127 @@ +register($state->namespace, __NAMESPACE__, 'nexus-cancel-after-complete'); + + $stub = $client->newUntypedWorkflowStub( + 'Extra_Nexus_Cancel_AfterComplete_Caller', + WorkflowOptions::new() + ->withTaskQueue(__NAMESPACE__) + ->withWorkflowExecutionTimeout(CarbonInterval::seconds(60)), + ); + + $client->start($stub, $endpoint->name, 'payload'); + + self::assertSame('completed:payload', $stub->getResult('string', timeout: 30)); + } +} + +// ── Nexus service: handler completes almost immediately ───────────────── + +#[Service(name: 'CancelAfterCompleteService')] +class CancelAfterCompleteService +{ + #[AsyncOperation(output: 'string')] + public function quick(string $input): WorkflowHandle + { + return WorkflowHandle::fromWorkflowMethod( + CancelAfterCompleteHandlerWorkflow::class, + WorkflowOptions::new()->withWorkflowId(Nexus::getStartDetails()->requestId), + $input, + ); + } +} + +#[WorkflowInterface] +class CancelAfterCompleteHandlerWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_Cancel_AfterComplete_Handler')] + public function handle(string $input) + { + yield Workflow::timer(CarbonInterval::milliseconds(50)); + return "completed:{$input}"; + } +} + +/** + * Awaits the operation result first, then issues a cancel on the (now resolved) + * scope. The cancel must be swallowed silently and the result preserved. + */ +#[WorkflowInterface] +class CancelAfterCompleteCallerWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_Cancel_AfterComplete_Caller')] + public function run(string $endpoint, string $input) + { + $stub = Workflow::newNexusServiceStub( + CancelAfterCompleteService::class, + NexusOperationOptions::new() + ->withEndpoint($endpoint) + ->withScheduleToCloseTimeout(CarbonInterval::seconds(30)) + ->withCancellationType(NexusOperationCancellationType::WaitRequested), + ); + + $promise = null; + $scope = Workflow::async(static function () use ($stub, $input, &$promise): void { + $promise = $stub->quick($input); + }); + + try { + $result = yield $promise; + } catch (CanceledFailure) { + return 'unexpected-cancel-before-completion'; + } + + $scope->cancel(); + + yield Workflow::timer(CarbonInterval::milliseconds(50)); + + return $result; + } +} diff --git a/tests/Acceptance/Extra/Nexus/Coexistence/CoexistenceTest.php b/tests/Acceptance/Extra/Nexus/Coexistence/CoexistenceTest.php new file mode 100644 index 000000000..f6969f975 --- /dev/null +++ b/tests/Acceptance/Extra/Nexus/Coexistence/CoexistenceTest.php @@ -0,0 +1,113 @@ +getResult('string'); + self::assertSame('activity-result:hello', $result); + } + + #[Test] + public function nexusOperationStillWorksAfterActivityRegistered( + State $state, + NexusEndpoints $endpoints, + NexusHttpClient $http, + #[Stub('Extra_Nexus_Coexistence_Wf2')] + WorkflowStubInterface $stub, + ): void { + $stub->getResult('string'); + + $endpoint = $endpoints->register($state->namespace, __NAMESPACE__, 'test-nexus-coexist'); + + [$code, $resp, ] = $http->post($endpoint, 'CoexistService', 'ping', 'pong'); + self::assertSame(200, $code, "Expected 200, got {$code}. Response: {$resp}"); + self::assertStringContainsString('pong-pong', $resp); + } +} + +// ── Activity ───────────────────────────────────────────────────── + +#[ActivityInterface(prefix: 'Extra_Nexus_Coexistence_')] +class CoexistenceActivity +{ + #[ActivityMethod] + public function process(string $input): string + { + return "activity-result:{$input}"; + } +} + +// ── Workflows ──────────────────────────────────────────────────── + +#[WorkflowInterface] +class CoexistenceWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_Coexistence_Wf')] + public function run() + { + $activity = Workflow::newActivityStub( + CoexistenceActivity::class, + ActivityOptions::new()->withStartToCloseTimeout(10), + ); + return yield $activity->process('hello'); + } +} + +#[WorkflowInterface] +class CoexistenceBootstrapWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_Coexistence_Wf2')] + public function run(): string + { + return 'ready'; + } +} + +// ── Nexus service (coexists with workflow + activity) ───────────── + +#[Service(name: 'CoexistService')] +class CoexistService +{ + #[Operation] + public function ping(string $word): string + { + return "pong-{$word}"; + } +} diff --git a/tests/Acceptance/Extra/Nexus/Errors/ErrorsTest.php b/tests/Acceptance/Extra/Nexus/Errors/ErrorsTest.php new file mode 100644 index 000000000..16cdc015f --- /dev/null +++ b/tests/Acceptance/Extra/Nexus/Errors/ErrorsTest.php @@ -0,0 +1,334 @@ +getResult('string'); + + $endpoint = $this->endpoint($endpoints, $state->namespace); + + [$code, $resp, ] = $http->post($endpoint, 'ErrorService', 'failOp', 'business-error'); + + self::assertSame(424, $code, "OperationException must map to 424, got {$code}. Body: {$resp}"); + self::assertStringContainsString('business-error', $resp, 'Failure message should be propagated'); + } + + #[Test] + public function handlerInternalErrorReturns500( + State $state, + NexusEndpoints $endpoints, + NexusHttpClient $http, + #[Stub('Extra_Nexus_Errors_Bootstrap2')] + WorkflowStubInterface $stub, + ): void { + $stub->getResult('string'); + + $endpoint = $this->endpoint($endpoints, $state->namespace); + + [$code, $resp, ] = $http->post($endpoint, 'ErrorService', 'handlerErrorOp', 'infra'); + + self::assertSame(500, $code, "Internal handler error must map to 500, got {$code}. Body: {$resp}"); + } + + #[Test] + public function handlerBadRequestReturns400( + State $state, + NexusEndpoints $endpoints, + NexusHttpClient $http, + #[Stub('Extra_Nexus_Errors_Bootstrap3')] + WorkflowStubInterface $stub, + ): void { + $stub->getResult('string'); + + $endpoint = $this->endpoint($endpoints, $state->namespace); + + [$code, $resp, ] = $http->post($endpoint, 'ErrorService', 'badRequestOp', 'bad-input'); + + self::assertSame(400, $code, "BadRequest handler error must map to 400, got {$code}. Body: {$resp}"); + } + + #[Test] + public function handlerUnauthorizedReturns403( + State $state, + NexusEndpoints $endpoints, + NexusHttpClient $http, + #[Stub('Extra_Nexus_Errors_Bootstrap4')] + WorkflowStubInterface $stub, + ): void { + $stub->getResult('string'); + + $endpoint = $this->endpoint($endpoints, $state->namespace); + + [$code, $resp, ] = $http->post($endpoint, 'ErrorService', 'unauthorizedOp', 'deny'); + + self::assertSame(403, $code, "Unauthorized handler error must map to 403, got {$code}. Body: {$resp}"); + } + + #[Test] + public function handlerNotFoundReturns404( + State $state, + NexusEndpoints $endpoints, + NexusHttpClient $http, + #[Stub('Extra_Nexus_Errors_Bootstrap5')] + WorkflowStubInterface $stub, + ): void { + $stub->getResult('string'); + + $endpoint = $this->endpoint($endpoints, $state->namespace); + + [$code, $resp, ] = $http->post($endpoint, 'ErrorService', 'notFoundOp', 'missing'); + + self::assertSame(404, $code, "Handler NotFound error must map to 404, got {$code}. Body: {$resp}"); + self::assertStringContainsString( + 'missing: missing', + $resp, + 'Body must carry the handler\'s NotFound message — otherwise this 404 may be an endpoint-cache miss.', + ); + } + + #[Test] + public function unknownOperationReturns404( + State $state, + NexusEndpoints $endpoints, + NexusHttpClient $http, + #[Stub('Extra_Nexus_Errors_Bootstrap6')] + WorkflowStubInterface $stub, + ): void { + $stub->getResult('string'); + + $endpoint = $this->endpoint($endpoints, $state->namespace); + + // Prove the endpoint is routable first, so the 404 below cannot be an endpoint-cache miss. + [$probeCode, $probeResp, ] = $http->post($endpoint, 'ErrorService', 'echoOp', 'probe'); + self::assertSame(200, $probeCode, "Routability probe failed: {$probeCode}. Body: {$probeResp}"); + + [$code, $resp, ] = $http->post($endpoint, 'ErrorService', 'nonExistentOp', 'whatever'); + + self::assertSame(404, $code, "Unknown op → NOT_FOUND → 404, got {$code}. Body: {$resp}"); + } + + #[Test] + public function operationCanceledFromHandlerPropagates( + State $state, + NexusEndpoints $endpoints, + NexusHttpClient $http, + #[Stub('Extra_Nexus_Errors_Bootstrap7')] + WorkflowStubInterface $stub, + ): void { + $stub->getResult('string'); + + $endpoint = $this->endpoint($endpoints, $state->namespace); + + [$code, $resp, ] = $http->post($endpoint, 'ErrorService', 'cancelOp', 'bye'); + + self::assertSame(424, $code, "Canceled → 424, got {$code}. Body: {$resp}"); + self::assertStringContainsString('bye', $resp, 'Failure message should be propagated'); + } + + /** + * Verifies the failure cause/stack-trace forwarding (Fix 3). + */ + #[Test] + public function handlerExceptionForwardsCauseChainInResponse( + State $state, + NexusEndpoints $endpoints, + NexusHttpClient $http, + #[Stub('Extra_Nexus_Errors_Bootstrap8')] + WorkflowStubInterface $stub, + ): void { + $stub->getResult('string'); + + $endpoint = $this->endpoint($endpoints, $state->namespace); + + [$code, $resp, ] = $http->post($endpoint, 'ErrorService', 'failWithCause', 'outer-fail'); + + self::assertSame(500, $code, "Body: {$resp}"); + self::assertStringContainsString('outer-fail', $resp); + self::assertStringContainsString('CAUSE_CHAIN_MARKER', $resp); + } + + private function endpoint(NexusEndpoints $endpoints, string $namespace): NexusEndpoint + { + return $endpoints->register($namespace, __NAMESPACE__, 'test-nexus-err'); + } +} + +#[Service(name: 'ErrorService')] +class ErrorService +{ + #[Operation] + public function echoOp(string $input): string + { + return $input; + } + + #[Operation] + public function failOp(string $reason): string + { + throw OperationException::failed($reason ?: 'unknown'); + } + + #[Operation] + public function handlerErrorOp(string $reason): string + { + throw HandlerException::create(ErrorType::Internal, "infra: {$reason}"); + } + + #[Operation] + public function badRequestOp(string $reason): string + { + throw HandlerException::create(ErrorType::BadRequest, "bad: {$reason}"); + } + + #[Operation] + public function unauthorizedOp(string $reason): string + { + throw HandlerException::create(ErrorType::Unauthorized, "deny: {$reason}"); + } + + #[Operation] + public function notFoundOp(string $reason): string + { + throw HandlerException::create(ErrorType::NotFound, "missing: {$reason}"); + } + + #[Operation] + public function cancelOp(string $reason): string + { + throw OperationException::canceled($reason ?: 'canceled'); + } + + #[Operation] + public function failWithCause(string $reason): string + { + // Two-level cause chain. The marker in the inner cause's + // message is what the acceptance test asserts to prove the + // chain reached the caller. + $inner = new \RuntimeException('CAUSE_CHAIN_MARKER: db unavailable'); + $middle = new \LogicException("middle of {$reason}", 0, $inner); + throw HandlerException::create( + ErrorType::Internal, + $reason ?: 'unknown', + cause: $middle, + ); + } +} + +#[WorkflowInterface] +class ErrorsBootstrapWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_Errors_Bootstrap')] + public function run(): string + { + return 'ready'; + } +} + +#[WorkflowInterface] +class ErrorsBootstrapWorkflow2 +{ + #[WorkflowMethod(name: 'Extra_Nexus_Errors_Bootstrap2')] + public function run(): string + { + return 'ready'; + } +} + +#[WorkflowInterface] +class ErrorsBootstrapWorkflow3 +{ + #[WorkflowMethod(name: 'Extra_Nexus_Errors_Bootstrap3')] + public function run(): string + { + return 'ready'; + } +} + +#[WorkflowInterface] +class ErrorsBootstrapWorkflow4 +{ + #[WorkflowMethod(name: 'Extra_Nexus_Errors_Bootstrap4')] + public function run(): string + { + return 'ready'; + } +} + +#[WorkflowInterface] +class ErrorsBootstrapWorkflow5 +{ + #[WorkflowMethod(name: 'Extra_Nexus_Errors_Bootstrap5')] + public function run(): string + { + return 'ready'; + } +} + +#[WorkflowInterface] +class ErrorsBootstrapWorkflow6 +{ + #[WorkflowMethod(name: 'Extra_Nexus_Errors_Bootstrap6')] + public function run(): string + { + return 'ready'; + } +} + +#[WorkflowInterface] +class ErrorsBootstrapWorkflow7 +{ + #[WorkflowMethod(name: 'Extra_Nexus_Errors_Bootstrap7')] + public function run(): string + { + return 'ready'; + } +} + +#[WorkflowInterface] +class ErrorsBootstrapWorkflow8 +{ + #[WorkflowMethod(name: 'Extra_Nexus_Errors_Bootstrap8')] + public function run(): string + { + return 'ready'; + } +} diff --git a/tests/Acceptance/Extra/Nexus/Headers/HeadersTest.php b/tests/Acceptance/Extra/Nexus/Headers/HeadersTest.php new file mode 100644 index 000000000..c65e0dd15 --- /dev/null +++ b/tests/Acceptance/Extra/Nexus/Headers/HeadersTest.php @@ -0,0 +1,79 @@ +getResult('string'); + + $endpoint = $endpoints->register($state->namespace, __NAMESPACE__, 'test-nexus-hdr'); + + [$code, $resp, ] = $http->post( + $endpoint, + 'HeaderEchoService', + 'echoHeader', + 'X-Custom-Header', + ['X-Custom-Header' => 'my-test-value'], + ); + + self::assertSame(200, $code, "Expected 200, got {$code}. Response: {$resp}"); + self::assertStringContainsString('my-test-value', $resp, 'Expected header value in response'); + } +} + +#[Service(name: 'HeaderEchoService')] +class HeaderEchoService +{ + #[Operation] + public function echoHeader(string $headerName): string + { + // Headers in OperationContext are case-insensitive (lowercased) + $context = Nexus::getCurrentOperationContext(); + return $context->headers->get($headerName) ?? "missing:{$headerName}"; + } +} + +#[WorkflowInterface] +class HeadersBootstrapWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_Headers_Bootstrap')] + public function run(): string + { + return 'ready'; + } +} diff --git a/tests/Acceptance/Extra/Nexus/Idempotency/RequestIdIdempotencyTest.php b/tests/Acceptance/Extra/Nexus/Idempotency/RequestIdIdempotencyTest.php new file mode 100644 index 000000000..0c40c811f --- /dev/null +++ b/tests/Acceptance/Extra/Nexus/Idempotency/RequestIdIdempotencyTest.php @@ -0,0 +1,174 @@ +getResult('string'); + + $endpoint = $endpoints->register($state->namespace, __NAMESPACE__, 'nexus-idempotent'); + + $sharedRequestId = 'idempotency-test-' . \bin2hex(\random_bytes(4)); + + $extraHeaders = [ + 'Nexus-Callback-Url' => 'http://callback.example.local/done', + 'Nexus-Request-Id' => $sharedRequestId, + ]; + + [$code1, $body1, ] = $http->post( + $endpoint, + 'IdempotentWorkflowService', + 'longRun', + 'payload-1', + $extraHeaders, + ); + self::assertSame(201, $code1, "First async start expected 201, got {$code1}. Body: {$body1}"); + $token1 = self::extractOperationToken($body1); + + [$code2, $body2, ] = $http->post( + $endpoint, + 'IdempotentWorkflowService', + 'longRun', + 'payload-2', + $extraHeaders, + ); + self::assertSame(201, $code2, "Second async start expected 201, got {$code2}. Body: {$body2}"); + $token2 = self::extractOperationToken($body2); + + self::assertSame( + $token1, + $token2, + "Same Nexus-Request-Id must yield identical operation tokens. token1={$token1} token2={$token2}", + ); + } + + #[Test] + public function differentNexusRequestIdsYieldDifferentOperationTokens( + State $state, + NexusEndpoints $endpoints, + NexusHttpClient $http, + #[Stub('Extra_Nexus_Idempotency_Bootstrap')] + WorkflowStubInterface $bootstrapStub, + ): void { + $bootstrapStub->getResult('string'); + + $endpoint = $endpoints->register($state->namespace, __NAMESPACE__, 'nexus-idempotent-distinct'); + + $requestIdA = 'idempotency-test-A-' . \bin2hex(\random_bytes(4)); + $requestIdB = 'idempotency-test-B-' . \bin2hex(\random_bytes(4)); + + [$code1, $body1, ] = $http->post( + $endpoint, + 'IdempotentWorkflowService', + 'longRun', + 'payload-1', + [ + 'Nexus-Callback-Url' => 'http://callback.example.local/done', + 'Nexus-Request-Id' => $requestIdA, + ], + ); + self::assertSame(201, $code1); + $tokenA = self::extractOperationToken($body1); + + [$code2, $body2, ] = $http->post( + $endpoint, + 'IdempotentWorkflowService', + 'longRun', + 'payload-1', + [ + 'Nexus-Callback-Url' => 'http://callback.example.local/done', + 'Nexus-Request-Id' => $requestIdB, + ], + ); + self::assertSame(201, $code2); + $tokenB = self::extractOperationToken($body2); + + self::assertNotSame( + $tokenA, + $tokenB, + 'Different Nexus-Request-Ids must yield distinct operation tokens (no accidental cross-request dedup).', + ); + } + + private static function extractOperationToken(string $responseBody): string + { + $decoded = \json_decode($responseBody, true); + self::assertIsArray($decoded, "Async start body must be JSON OperationInfo. Body: {$responseBody}"); + $token = $decoded['token'] ?? $decoded['operationToken'] ?? null; + self::assertIsString($token, "Async start body must carry a token field. Body: {$responseBody}"); + self::assertNotSame('', $token, 'Operation token must be non-empty.'); + return $token; + } +} + +#[Service(name: 'IdempotentWorkflowService')] +class IdempotentWorkflowService +{ + #[AsyncOperation(output: 'string')] + public function longRun(string $input): WorkflowHandle + { + return WorkflowHandle::fromWorkflowMethod( + IdempotentHandlerWorkflow::class, + WorkflowOptions::new()->withWorkflowId(Nexus::getStartDetails()->requestId), + $input, + ); + } +} + +#[WorkflowInterface] +class IdempotentHandlerWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_Idempotency_Handler')] + public function handle(string $input) + { + yield Workflow::timer(CarbonInterval::seconds(3)); + return 'done:' . $input; + } +} + +#[WorkflowInterface] +class IdempotencyBootstrapWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_Idempotency_Bootstrap')] + public function run(): string + { + return 'ready'; + } +} diff --git a/tests/Acceptance/Extra/Nexus/InputTypes/InputTypesTest.php b/tests/Acceptance/Extra/Nexus/InputTypes/InputTypesTest.php new file mode 100644 index 000000000..b24d64b7d --- /dev/null +++ b/tests/Acceptance/Extra/Nexus/InputTypes/InputTypesTest.php @@ -0,0 +1,168 @@ +getResult('string'); + + [$code, $resp] = $this->invoke($state, $endpoints, $http, 'pingNoInput', null); + self::assertSame(200, $code, "Body: {$resp}"); + self::assertStringContainsString('pong', $resp); + } + + #[Test] + public function scalarIntRoundtrip( + State $state, + NexusEndpoints $endpoints, + NexusHttpClient $http, + #[Stub('Extra_Nexus_InputTypes_Bootstrap2')] + WorkflowStubInterface $stub, + ): void { + $stub->getResult('string'); + + [$code, $resp] = $this->invoke($state, $endpoints, $http, 'doubleInt', 21); + self::assertSame(200, $code, "Body: {$resp}"); + self::assertStringContainsString('42', $resp); + } + + #[Test] + public function dtoInputDtoOutput( + State $state, + NexusEndpoints $endpoints, + NexusHttpClient $http, + #[Stub('Extra_Nexus_InputTypes_Bootstrap3')] + WorkflowStubInterface $stub, + ): void { + $stub->getResult('string'); + + [$code, $resp] = $this->invoke($state, $endpoints, $http, 'echoDto', [ + 'name' => 'Ada', + 'value' => 99, + ]); + + self::assertSame(200, $code, "Body: {$resp}"); + self::assertStringContainsString('Ada', $resp); + self::assertStringContainsString('99', $resp); + } + + /** + * @return array{int, string} + */ + private function invoke( + State $state, + NexusEndpoints $endpoints, + NexusHttpClient $http, + string $operation, + mixed $input, + ): array { + $endpoint = $endpoints->register($state->namespace, __NAMESPACE__, 'nexus-input-types'); + + [$code, $body, ] = $http->post($endpoint, 'ShapeService', $operation, $input); + return [$code, $body]; + } +} + +// ── DTOs ───────────────────────────────────────────────────────────── + +final class Item +{ + public function __construct( + public string $name = '', + public int $value = 0, + ) {} +} + +// ── Nexus service ──────────────────────────────────────────────────── + +#[Service(name: 'ShapeService')] +class ShapeService +{ + /** Operation with no input parameter — handler receives `null`. */ + #[Operation] + public function pingNoInput(): string + { + return 'pong'; + } + + #[Operation] + public function doubleInt(int $x): int + { + return $x * 2; + } + + #[Operation] + public function echoDto(Item $item): Item + { + // Echo with a trivial transform to prove deserialization actually happened. + return new Item($item->name, $item->value); + } +} + +// ── Bootstrap workflows ────────────────────────────────────────────── + +#[WorkflowInterface] +class InputTypesBootstrapWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_InputTypes_Bootstrap')] + public function run(): string + { + return 'ready'; + } +} + +#[WorkflowInterface] +class InputTypesBootstrapWorkflow2 +{ + #[WorkflowMethod(name: 'Extra_Nexus_InputTypes_Bootstrap2')] + public function run(): string + { + return 'ready'; + } +} + +#[WorkflowInterface] +class InputTypesBootstrapWorkflow3 +{ + #[WorkflowMethod(name: 'Extra_Nexus_InputTypes_Bootstrap3')] + public function run(): string + { + return 'ready'; + } +} diff --git a/tests/Acceptance/Extra/Nexus/Interceptor/InterceptorTest.php b/tests/Acceptance/Extra/Nexus/Interceptor/InterceptorTest.php new file mode 100644 index 000000000..9a9dbe471 --- /dev/null +++ b/tests/Acceptance/Extra/Nexus/Interceptor/InterceptorTest.php @@ -0,0 +1,324 @@ +getResult('string'); + + $endpoint = $endpoints->register($state->namespace, __NAMESPACE__, 'test-nexus-interceptor'); + + [$code, $body, ] = $http->post( + $endpoint, + 'GreetingService', + 'sayHello', + 'World', + [AuthInterceptor::AUTH_HEADER => self::AUTH_TOKEN], + ); + + self::assertSame(200, $code, "Expected 200, got {$code}. Response: {$body}"); + // Greeting proves the pipeline reached the handler. + self::assertStringContainsString('Hello, World!', $body); + // Marker proves the LoggingInterceptor ran before the handler. + self::assertStringContainsString('seen-by-interceptor', $body); + } + + #[Test] + public function startIsRejectedWithoutAuthToken( + State $state, + NexusEndpoints $endpoints, + NexusHttpClient $http, + #[Stub('Extra_Nexus_Interceptor_Bootstrap')] WorkflowStubInterface $stub, + ): void { + $stub->getResult('string'); + + $endpoint = $endpoints->register($state->namespace, __NAMESPACE__, 'test-nexus-interceptor'); + + [$code, $body, ] = $http->post( + $endpoint, + 'GreetingService', + 'sayHello', + 'World', + ); + + // Nexus maps `ErrorType::Unauthorized` to HTTP 403. + self::assertSame(403, $code, "Expected 403, got {$code}. Response: {$body}"); + // Auth interceptor short-circuited the pipeline — no handler greeting in the body. + self::assertStringNotContainsString('Hello, World!', $body); + } + + /** Caller cancels an async op mid-flight; the cancel-side interceptor must leave its marker. */ + #[Test] + public function cancelInvokesInterceptorAndHandlerSeesMarker( + State $state, + WorkflowClientInterface $client, + NexusEndpoints $endpoints, + ): void { + WorkerLocalMarker::clearCancel(); + + $endpoint = $endpoints->register($state->namespace, __NAMESPACE__, 'test-nexus-interceptor-cancel'); + + // Deterministic handler workflow id — we want to fetch its result later. + $handlerWorkflowId = 'cancel-marker-handler-' . \bin2hex(\random_bytes(4)); + + $callerStub = $client->newUntypedWorkflowStub( + 'Extra_Nexus_Interceptor_CancelCaller', + WorkflowOptions::new() + ->withTaskQueue(__NAMESPACE__) + ->withWorkflowExecutionTimeout(CarbonInterval::seconds(15)), + ); + + $client->start($callerStub, $endpoint->name, $handlerWorkflowId); + self::assertSame('cancelled', $callerStub->getResult('string')); + + // Handler workflow caught CanceledFailure and returned 'cancelled:...'. + $handlerStub = $client->newUntypedRunningWorkflowStub($handlerWorkflowId); + $handlerResult = $handlerStub->getResult('string', timeout: 10); + self::assertStringStartsWith('cancelled:', $handlerResult); + + // File-backed marker survives cross-process dispatch between RR workers. + $marker = WorkerLocalMarker::readCancel(); + self::assertNotNull($marker, 'Cancel interceptor never wrote its marker.'); + self::assertStringContainsString('cancel-seen-by-interceptor', $marker); + WorkerLocalMarker::clearCancel(); + } +} + +/** + * Markers shared between interceptors (writers) and downstream readers: + * $lastSeen is process-local (sync case); the cancel marker is file-backed (cross-process). + */ +final class WorkerLocalMarker +{ + public const CANCEL_MARKER_FILE = '/tmp/nexus-interceptor-cancel-marker-test-nexus-interceptor-cancel'; + + public static ?string $lastSeen = null; + + public static function recordCancel(string $value): void + { + \file_put_contents(self::CANCEL_MARKER_FILE, $value); + } + + public static function readCancel(): ?string + { + return \is_file(self::CANCEL_MARKER_FILE) + ? \file_get_contents(self::CANCEL_MARKER_FILE) + : null; + } + + public static function clearCancel(): void + { + if (\is_file(self::CANCEL_MARKER_FILE)) { + \unlink(self::CANCEL_MARKER_FILE); + } + } +} + +final class AuthInterceptor implements NexusOperationInboundCallsInterceptor +{ + public const AUTH_HEADER = 'authorization'; + + public function __construct(private readonly string $authToken) {} + + public function startOperation(StartOperationInput $input, callable $next): OperationStartResult + { + // Auth is scoped to GreetingService; workflow-to-workflow stubs carry no auth header. + if ($input->operationContext->service === 'GreetingService') { + $this->assertAuthorized($input->operationContext->headers->get(self::AUTH_HEADER)); + } + return $next($input); + } + + public function cancelOperation(CancelOperationInput $input, callable $next): void + { + if ($input->operationContext->service === 'GreetingService') { + $this->assertAuthorized($input->operationContext->headers->get(self::AUTH_HEADER)); + } + $next($input); + } + + private function assertAuthorized(?string $token): void + { + if ($token !== $this->authToken) { + throw HandlerException::create(ErrorType::Unauthorized, 'Unauthorized'); + } + } +} + +final class LoggingInterceptor implements NexusOperationInboundCallsInterceptor +{ + use NexusOperationInboundCallsInterceptorTrait; + + public function startOperation(StartOperationInput $input, callable $next): OperationStartResult + { + WorkerLocalMarker::$lastSeen = "seen-by-interceptor:{$input->operationContext->operation}"; + return $next($input); + } + + public function cancelOperation(CancelOperationInput $input, callable $next): void + { + WorkerLocalMarker::recordCancel("cancel-seen-by-interceptor:{$input->operationContext->operation}"); + $next($input); + } +} + +class InterceptorTestServices +{ + public static function interceptors(): PipelineProvider + { + return new SimplePipelineProvider([ + new AuthInterceptor(InterceptorTest::AUTH_TOKEN), + new LoggingInterceptor(), + ]); + } +} + +// ── Sync greeting service (used by the first two tests) ──────────────── + +#[Service(name: 'GreetingService')] +class GreetingService +{ + #[Operation] + public function sayHello(string $name): string + { + $marker = WorkerLocalMarker::$lastSeen ?? 'no-marker'; + return "Hello, {$name}! [{$marker}]"; + } +} + +// ── Async cancel service (used by the cancel-marker test) ────────────── + +#[Service(name: 'InterceptorCancelService')] +class InterceptorCancelService +{ + #[AsyncOperation(output: 'string')] + public function longRunning(string $handlerWorkflowId): WorkflowHandle + { + return WorkflowHandle::fromWorkflowMethod( + CancelHandlerWorkflow::class, + WorkflowOptions::new()->withWorkflowId($handlerWorkflowId), + $handlerWorkflowId, + ); + } +} + +#[WorkflowInterface] +class CancelHandlerWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_Interceptor_CancelHandler')] + public function handle(string $input) + { + try { + // Long enough that the caller can request cancel before we finish. + yield Workflow::timer(CarbonInterval::seconds(30)); + return "completed:{$input}"; + } catch (CanceledFailure) { + // Marker isn't read here: handler may run in a different RR worker process. + return "cancelled:{$input}"; + } + } +} + +#[WorkflowInterface] +class CancelCallerWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_Interceptor_CancelCaller')] + public function run(string $endpoint, string $handlerWorkflowId) + { + $stub = Workflow::newNexusServiceStub( + InterceptorCancelService::class, + NexusOperationOptions::new() + ->withEndpoint($endpoint) + ->withScheduleToCloseTimeout(CarbonInterval::seconds(60)) + ->withCancellationType(NexusOperationCancellationType::WaitRequested), + ); + + $promise = null; + $scope = Workflow::async(static function () use ($stub, $handlerWorkflowId, &$promise): void { + $promise = $stub->longRunning($handlerWorkflowId); + }); + + // Give the handler workflow a chance to actually start before cancelling. + yield Workflow::timer(CarbonInterval::seconds(NexusWorkerOptions::PRE_CANCEL_TIMER_SECONDS)); + $scope->cancel(); + + try { + yield $promise; + } catch (NexusOperationFailure $e) { + if ($e->getPrevious() instanceof CanceledFailure) { + return 'cancelled'; + } + throw $e; + } + + return 'unexpected-completion'; + } +} + +#[WorkflowInterface] +class InterceptorBootstrapWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_Interceptor_Bootstrap')] + public function run(): string + { + return 'ready'; + } +} diff --git a/tests/Acceptance/Extra/Nexus/Links/LinksTest.php b/tests/Acceptance/Extra/Nexus/Links/LinksTest.php new file mode 100644 index 000000000..7d1f77226 --- /dev/null +++ b/tests/Acceptance/Extra/Nexus/Links/LinksTest.php @@ -0,0 +1,279 @@ +getResult('string'); + + [$code, $resp] = $this->invoke($state, $endpoints, $http, 'attachAndReportSingle', 'session-7'); + + self::assertSame(200, $code, "Expected 200, got {$code}. Body: {$resp}"); + self::assertStringContainsString('count=1', $resp); + self::assertStringContainsString('session-7', $resp); + self::assertStringContainsString('example.session', $resp); + } + + #[Test] + public function handlerCanAttachMultipleLinksAndSeeThemBack( + State $state, + NexusEndpoints $endpoints, + NexusHttpClient $http, + #[Stub('Extra_Nexus_Links_Bootstrap2')] + WorkflowStubInterface $stub, + ): void { + $stub->getResult('string'); + + [$code, $resp] = $this->invoke($state, $endpoints, $http, 'attachAndReportMany', 'job-99'); + + self::assertSame(200, $code, "Expected 200, got {$code}. Body: {$resp}"); + self::assertStringContainsString('count=2', $resp); + self::assertStringContainsString('primary-job-99', $resp); + self::assertStringContainsString('audit-job-99', $resp); + } + + #[Test] + public function handlerStartsWithNoLinks( + State $state, + NexusEndpoints $endpoints, + NexusHttpClient $http, + #[Stub('Extra_Nexus_Links_Bootstrap3')] + WorkflowStubInterface $stub, + ): void { + $stub->getResult('string'); + + [$code, $resp] = $this->invoke($state, $endpoints, $http, 'reportNoLinks', 'x'); + + self::assertSame(200, $code, "Body: {$resp}"); + self::assertStringContainsString('count=0', $resp); + } + + /** + * End-to-end verification of the handler-response-Link wire: + * handler calls $context->links->add(), sdk-php packs them into + * `_rr_nexus_links` payload metadata, RoadRunner extracts them and + * calls nexus.AddHandlerLinks which puts them into the handler ctx; + * Temporal Go SDK reads them via nexus.HandlerLinks(ctx) and emits + * `Nexus-Link` response headers back to the caller. + */ + #[Test] + public function handlerLinksAppearInNexusLinkResponseHeader( + State $state, + NexusEndpoints $endpoints, + NexusHttpClient $http, + #[Stub('Extra_Nexus_Links_Bootstrap4')] + WorkflowStubInterface $stub, + ): void { + $stub->getResult('string'); + + $endpoint = $endpoints->register($state->namespace, __NAMESPACE__, 'nexus-links-hdr'); + + [$code, $body, $headers] = $http->post( + $endpoint, + 'LinkService', + 'attachAndReportMany', + 'order-42', + ); + + self::assertSame(200, $code, "Body: {$body}"); + self::assertArrayHasKey('nexus-link', $headers, \sprintf( + 'Nexus-Link response header missing; got: [%s]', + \implode(', ', \array_keys($headers)), + )); + + // RFC 5988 Link headers — value format `; type="T"`. The two + // links set by attachAndReportMany must both be present (some + // clients concatenate with comma, some emit separate values). + $linkValues = \implode("\n", (array) $headers['nexus-link']); + self::assertStringContainsString('primary-order-42', $linkValues); + self::assertStringContainsString('audit-order-42', $linkValues); + self::assertStringContainsString('example.primary', $linkValues); + self::assertStringContainsString('example.audit', $linkValues); + } + + /** + * Strict parsing (LinkParser): a caller-supplied `Nexus-Link` with a + * missing required field must be rejected with HTTP 400, matching the + * Java reference SDK. Previously the sdk-php route silently dropped + * malformed entries. + */ + #[Test] + public function malformedCallerNexusLinkReturns400( + State $state, + NexusEndpoints $endpoints, + NexusHttpClient $http, + #[Stub('Extra_Nexus_Links_Bootstrap5')] + WorkflowStubInterface $stub, + ): void { + $stub->getResult('string'); + + $endpoint = $endpoints->register($state->namespace, __NAMESPACE__, 'nexus-links-bad'); + + // A Link header value without the mandatory `type` parameter. + // RoadRunner parses the raw `Nexus-Link` header, builds options.links, + // sdk-php's LinkParser then rejects with HandlerException(BadRequest). + [$code, $body, ] = $http->post( + $endpoint, + 'LinkService', + 'reportNoLinks', + 'x', + ['Nexus-Link' => ''], + ); + + self::assertSame(400, $code, "Expected 400 BadRequest, got {$code}. Body: {$body}"); + } + + /** + * @return array{int, string} + */ + private function invoke( + State $state, + NexusEndpoints $endpoints, + NexusHttpClient $http, + string $op, + string $body, + ): array { + $endpoint = $endpoints->register($state->namespace, __NAMESPACE__, 'nexus-links'); + + [$code, $resp, ] = $http->post($endpoint, 'LinkService', $op, $body); + return [$code, $resp]; + } +} + +// ── Nexus service ──────────────────────────────────────────────────── + +#[Service(name: 'LinkService')] +class LinkService +{ + #[Operation] + public function attachAndReportSingle(string $suffix): string + { + $context = Nexus::getCurrentOperationContext(); + $context->links->add(new Link( + "https://example.test/session/{$suffix}", + 'example.session', + )); + return self::reportLinks($context); + } + + #[Operation] + public function attachAndReportMany(string $suffix): string + { + $context = Nexus::getCurrentOperationContext(); + $context->links->add( + new Link("https://example.test/primary/primary-{$suffix}", 'example.primary'), + new Link("https://example.test/audit/audit-{$suffix}", 'example.audit'), + ); + return self::reportLinks($context); + } + + #[Operation] + public function reportNoLinks(string $_ignored): string + { + return self::reportLinks(Nexus::getCurrentOperationContext()); + } + + /** + * Serializes the links currently attached to the given OperationContext into + * a compact string the test can assert on. + */ + private static function reportLinks(OperationContext $context): string + { + $links = $context->links->all(); + $parts = []; + foreach ($links as $link) { + $parts[] = "{$link->uri}|{$link->type}"; + } + return \sprintf('count=%d;links=[%s]', \count($links), \implode(';', $parts)); + } +} + +// ── Bootstrap workflows ────────────────────────────────────────────── + +#[WorkflowInterface] +class LinksBootstrapWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_Links_Bootstrap')] + public function run(): string + { + return 'ready'; + } +} + +#[WorkflowInterface] +class LinksBootstrapWorkflow2 +{ + #[WorkflowMethod(name: 'Extra_Nexus_Links_Bootstrap2')] + public function run(): string + { + return 'ready'; + } +} + +#[WorkflowInterface] +class LinksBootstrapWorkflow3 +{ + #[WorkflowMethod(name: 'Extra_Nexus_Links_Bootstrap3')] + public function run(): string + { + return 'ready'; + } +} + +#[WorkflowInterface] +class LinksBootstrapWorkflow4 +{ + #[WorkflowMethod(name: 'Extra_Nexus_Links_Bootstrap4')] + public function run(): string + { + return 'ready'; + } +} + +#[WorkflowInterface] +class LinksBootstrapWorkflow5 +{ + #[WorkflowMethod(name: 'Extra_Nexus_Links_Bootstrap5')] + public function run(): string + { + return 'ready'; + } +} diff --git a/tests/Acceptance/Extra/Nexus/ManualToken/ManualTokenTest.php b/tests/Acceptance/Extra/Nexus/ManualToken/ManualTokenTest.php new file mode 100644 index 000000000..b7eb75006 --- /dev/null +++ b/tests/Acceptance/Extra/Nexus/ManualToken/ManualTokenTest.php @@ -0,0 +1,249 @@ +register($state->namespace, __NAMESPACE__, 'nexus-manual-token'); + + $caller = $client->newUntypedWorkflowStub( + 'Extra_Nexus_ManualToken_TokenCaller', + WorkflowOptions::new() + ->withTaskQueue(__NAMESPACE__) + ->withWorkflowExecutionTimeout(CarbonInterval::seconds(30)), + ); + + $client->start($caller, $endpoint->name, 'job-1'); + + self::assertSame('external-job-1', $caller->getResult('string', timeout: 30)); + } + + #[Test] + public function cancelRoutesToOperationCancelRoutine( + State $state, + WorkflowClientInterface $client, + NexusEndpoints $endpoints, + ): void { + $endpoint = $endpoints->register($state->namespace, __NAMESPACE__, 'nexus-manual-cancel-ok'); + + $caller = $client->newUntypedWorkflowStub( + 'Extra_Nexus_ManualToken_CancelCaller', + WorkflowOptions::new() + ->withTaskQueue(__NAMESPACE__) + ->withWorkflowExecutionTimeout(CarbonInterval::seconds(30)), + ); + + $client->start($caller, $endpoint->name, 'startCancellable', 'job-2'); + + self::assertTrue( + self::historyContains($client, $caller, EventType::EVENT_TYPE_NEXUS_OPERATION_CANCEL_REQUEST_COMPLETED), + 'Expected NEXUS_OPERATION_CANCEL_REQUEST_COMPLETED in caller history. Seen events: ' + . \implode(', ', self::historyEventNames($client, $caller)), + ); + + $caller->signal('finish'); + self::assertSame('done', $caller->getResult('string', timeout: 40)); + } + + #[Test] + public function cancelWithoutRoutineFailsWithNotImplemented( + State $state, + WorkflowClientInterface $client, + NexusEndpoints $endpoints, + ): void { + $endpoint = $endpoints->register($state->namespace, __NAMESPACE__, 'nexus-manual-cancel-ni'); + + $caller = $client->newUntypedWorkflowStub( + 'Extra_Nexus_ManualToken_CancelCaller', + WorkflowOptions::new() + ->withTaskQueue(__NAMESPACE__) + ->withWorkflowExecutionTimeout(CarbonInterval::seconds(30)), + ); + + $client->start($caller, $endpoint->name, 'startUncancellable', 'job-3'); + + self::assertTrue( + self::historyContains($client, $caller, EventType::EVENT_TYPE_NEXUS_OPERATION_CANCEL_REQUEST_FAILED), + 'Expected NEXUS_OPERATION_CANCEL_REQUEST_FAILED in caller history. Seen events: ' + . \implode(', ', self::historyEventNames($client, $caller)), + ); + + $caller->signal('finish'); + self::assertSame('done', $caller->getResult('string', timeout: 40)); + } +} + +// ── Nexus service: manual tokens, no backing workflow ─────────────────── + +#[Service(name: 'ManualTokenAcceptanceService')] +class ManualTokenAcceptanceService +{ + #[AsyncOperation(output: 'string', input: 'string')] + public function startCancellable(): CancellableExternalHandler + { + return new CancellableExternalHandler(); + } + + #[AsyncOperation(output: 'string', input: 'string')] + public function startUncancellable(): UncancellableExternalHandler + { + return new UncancellableExternalHandler(); + } +} + +final class CancellableExternalHandler implements OperationHandlerInterface +{ + public function start( + OperationContext $context, + OperationStartDetails $details, + mixed $param, + ): OperationStartResult { + return OperationStartResult::async(new OperationInfo("external-{$param}", OperationState::Running)); + } + + public function cancel( + OperationContext $context, + OperationCancelDetails $details, + ): void {} +} + +final class UncancellableExternalHandler implements OperationHandlerInterface +{ + public function start( + OperationContext $context, + OperationStartDetails $details, + mixed $param, + ): OperationStartResult { + return OperationStartResult::async(new OperationInfo("external-{$param}", OperationState::Running)); + } + + public function cancel( + OperationContext $context, + OperationCancelDetails $details, + ): void { + throw HandlerException::create(ErrorType::NotImplemented, 'manual operation does not support cancellation'); + } +} + +// ── Caller: starts the op, returns the operation token, abandons the op ── + +#[WorkflowInterface] +class TokenCallerWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_ManualToken_TokenCaller')] + public function run(string $endpoint, string $input) + { + $stub = Workflow::newUntypedNexusOperationStub( + NexusOperationOptions::new() + ->withEndpoint($endpoint) + ->withService('ManualTokenAcceptanceService') + ->withScheduleToCloseTimeout(CarbonInterval::seconds(20)) + ->withCancellationType(NexusOperationCancellationType::Abandon), + ); + + /** @var NexusOperationHandle $handle */ + $handle = yield $stub->start('startCancellable', [$input], 'string'); + + return $handle->getOperationToken(); + } +} + +// ── Caller: starts the named op, cancels it, then waits for the `finish` signal ── + +#[WorkflowInterface] +class CancelCallerWorkflow +{ + private bool $finished = false; + + #[WorkflowMethod(name: 'Extra_Nexus_ManualToken_CancelCaller')] + public function run(string $endpoint, string $operation, string $input) + { + $stub = Workflow::newUntypedNexusOperationStub( + NexusOperationOptions::new() + ->withEndpoint($endpoint) + ->withService('ManualTokenAcceptanceService') + ->withScheduleToCloseTimeout(CarbonInterval::seconds(20)) + ->withCancellationType(NexusOperationCancellationType::TryCancel), + ); + + $handle = null; + $scope = Workflow::async(static function () use ($stub, $operation, $input, &$handle) { + $handle = yield $stub->start($operation, [$input], 'string'); + yield $handle->getResult(); + }); + + yield Workflow::await(static function () use (&$handle): bool { + return $handle !== null; + }); + yield Workflow::timer(CarbonInterval::seconds(NexusWorkerOptions::PRE_CANCEL_TIMER_SECONDS)); + $scope->cancel(); + + try { + yield $scope; + } catch (\Throwable) { + } + + yield Workflow::await(fn(): bool => $this->finished); + + return 'done'; + } + + #[SignalMethod] + public function finish(): void + { + $this->finished = true; + } +} diff --git a/tests/Acceptance/Extra/Nexus/MultiOperation/MultiOperationTest.php b/tests/Acceptance/Extra/Nexus/MultiOperation/MultiOperationTest.php new file mode 100644 index 000000000..afeef3518 --- /dev/null +++ b/tests/Acceptance/Extra/Nexus/MultiOperation/MultiOperationTest.php @@ -0,0 +1,97 @@ +getResult('string'); + + $endpoint = $endpoints->register($state->namespace, __NAMESPACE__); + + $cases = [ + ['add', [3, 5], '8'], + ['multiply', [4, 7], '28'], + ['echo', ['hello'], '"hello"'], + ['constant', [null], '42'], + ]; + + foreach ($cases as [$op, $body, $expectedFragment]) { + $payload = \count($body) === 1 ? $body[0] : $body; + [$code, $resp, ] = $http->post($endpoint, 'MathService', $op, $payload); + self::assertSame(200, $code, "Operation {$op}: expected 200, got {$code}. Response: {$resp}"); + self::assertStringContainsString($expectedFragment, $resp, "Operation {$op}: missing expected fragment"); + } + } +} + +#[Service(name: 'MathService')] +class MathService +{ + #[Operation] + public function add(array $args): int + { + return (int) ($args[0] ?? 0) + (int) ($args[1] ?? 0); + } + + #[Operation] + public function multiply(array $args): int + { + return (int) ($args[0] ?? 1) * (int) ($args[1] ?? 1); + } + + #[Operation] + public function echo(string $value): string + { + return $value; + } + + #[Operation] + public function constant(): int + { + return 42; + } +} + +#[WorkflowInterface] +class MultiOpBootstrapWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_MultiOp_Bootstrap')] + public function run(): string + { + return 'ready'; + } +} diff --git a/tests/Acceptance/Extra/Nexus/MultiService/MultiServiceTest.php b/tests/Acceptance/Extra/Nexus/MultiService/MultiServiceTest.php new file mode 100644 index 000000000..e5a11e7c3 --- /dev/null +++ b/tests/Acceptance/Extra/Nexus/MultiService/MultiServiceTest.php @@ -0,0 +1,83 @@ +getResult('string'); + + $endpoint = $endpoints->register($state->namespace, __NAMESPACE__, 'test-nexus-multi'); + + [$codeA, $respA, ] = $http->post($endpoint, 'ServiceA', 'opA', 'foo'); + self::assertSame(200, $codeA, "ServiceA: expected 200, got {$codeA}. Response: {$respA}"); + self::assertStringContainsString('A:foo', $respA); + + [$codeB, $respB, ] = $http->post($endpoint, 'ServiceB', 'opB', 'bar'); + self::assertSame(200, $codeB, "ServiceB: expected 200, got {$codeB}. Response: {$respB}"); + self::assertStringContainsString('B:bar', $respB); + } +} + +#[Service(name: 'ServiceA')] +class ServiceA +{ + #[Operation] + public function opA(string $input): string + { + return "A:{$input}"; + } +} + +#[Service(name: 'ServiceB')] +class ServiceB +{ + #[Operation] + public function opB(string $input): string + { + return "B:{$input}"; + } +} + +#[WorkflowInterface] +class MultiServiceBootstrapWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_MultiService_Bootstrap')] + public function run(): string + { + return 'ready'; + } +} diff --git a/tests/Acceptance/Extra/Nexus/MultipleCallers/MultipleCallersTest.php b/tests/Acceptance/Extra/Nexus/MultipleCallers/MultipleCallersTest.php new file mode 100644 index 000000000..b82eeeded --- /dev/null +++ b/tests/Acceptance/Extra/Nexus/MultipleCallers/MultipleCallersTest.php @@ -0,0 +1,135 @@ +register($state->namespace, __NAMESPACE__, 'nexus-shared-async'); + + $handlerWorkflowId = 'shared-handler-' . \bin2hex(\random_bytes(8)); + + $callerA = $client->newUntypedWorkflowStub( + 'Extra_Nexus_MultipleCallers_Caller', + WorkflowOptions::new() + ->withTaskQueue(__NAMESPACE__) + ->withWorkflowExecutionTimeout(CarbonInterval::seconds(20)), + ); + + $callerB = $client->newUntypedWorkflowStub( + 'Extra_Nexus_MultipleCallers_Caller', + WorkflowOptions::new() + ->withTaskQueue(__NAMESPACE__) + ->withWorkflowExecutionTimeout(CarbonInterval::seconds(20)), + ); + + $client->start($callerA, $endpoint->name, $handlerWorkflowId); + $client->start($callerB, $endpoint->name, $handlerWorkflowId); + + $handlerStub = $client->newUntypedRunningWorkflowStub($handlerWorkflowId); + $signaled = false; + $deadline = \microtime(true) + 5.0; + do { + try { + $handlerStub->signal('unblock'); + $signaled = true; + break; + } catch (WorkflowNotFoundException) { + \usleep(50_000); + } + } while (\microtime(true) < $deadline); + + if (!$signaled) { + self::fail('handler never became signalable within 5s'); + } + + self::assertSame('shared-handler-result', $callerA->getResult('string')); + self::assertSame('shared-handler-result', $callerB->getResult('string')); + } +} + +#[Service(name: 'SharedAsyncService')] +class SharedAsyncService +{ + #[AsyncOperation(output: 'string')] + public function run(string $handlerWorkflowId): WorkflowHandle + { + return WorkflowHandle::fromWorkflowMethod( + SharedHandlerWorkflow::class, + WorkflowOptions::new() + ->withWorkflowId($handlerWorkflowId) + ->withWorkflowIdConflictPolicy(WorkflowIdConflictPolicy::UseExisting), + $handlerWorkflowId, + ); + } +} + +#[WorkflowInterface] +class SharedHandlerWorkflow +{ + private bool $unblocked = false; + + #[WorkflowMethod(name: 'Extra_Nexus_MultipleCallers_SharedHandler')] + public function handle(string $input) + { + yield Workflow::await(fn() => $this->unblocked); + return 'shared-handler-result'; + } + + #[SignalMethod(name: 'unblock')] + public function unblock(): void + { + $this->unblocked = true; + } +} + +#[WorkflowInterface] +class MultipleCallersCallerWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_MultipleCallers_Caller')] + public function run(string $endpoint, string $handlerWorkflowId) + { + $stub = Workflow::newNexusServiceStub( + SharedAsyncService::class, + NexusOperationOptions::new() + ->withEndpoint($endpoint) + ->withScheduleToCloseTimeout(CarbonInterval::seconds(15)), + ); + + return yield $stub->run($handlerWorkflowId); + } +} diff --git a/tests/Acceptance/Extra/Nexus/NexusEndpoint.php b/tests/Acceptance/Extra/Nexus/NexusEndpoint.php new file mode 100644 index 000000000..8659c8820 --- /dev/null +++ b/tests/Acceptance/Extra/Nexus/NexusEndpoint.php @@ -0,0 +1,13 @@ +setSpec( + (new EndpointSpec()) + ->setName($name) + ->setTarget( + (new EndpointTarget())->setWorker( + (new WorkerTarget()) + ->setNamespace($namespace) + ->setTaskQueue($taskQueue), + ), + ), + ); + + [$response, $status] = $this->operator->CreateNexusEndpoint($request)->wait(); + + if ($status->code !== \Grpc\STATUS_OK) { + throw new \RuntimeException( + "CreateNexusEndpoint failed (gRPC code {$status->code}): {$status->details}", + ); + } + + return new NexusEndpoint(id: $response->getEndpoint()->getId(), name: $name); + } +} diff --git a/tests/Acceptance/Extra/Nexus/NexusHistoryAssertions.php b/tests/Acceptance/Extra/Nexus/NexusHistoryAssertions.php new file mode 100644 index 000000000..5e73dc9b1 --- /dev/null +++ b/tests/Acceptance/Extra/Nexus/NexusHistoryAssertions.php @@ -0,0 +1,75 @@ +getEvents() as $event) { + if ($event->getEventType() === $type) { + $count++; + } + } + return $count; + } + + /** + * @param list $expectedTypes + */ + protected static function assertContainsEvents(History $history, array $expectedTypes, string $message): void + { + $present = []; + foreach ($history->getEvents() as $event) { + $present[$event->getEventType()] = true; + } + foreach ($expectedTypes as $type) { + self::assertArrayHasKey( + $type, + $present, + $message . ' — missing event type ' . EventType::name($type), + ); + } + } + + protected static function historyContains( + WorkflowClientInterface $client, + WorkflowStubInterface $stub, + int $eventType, + float $timeout = 15.0, + ): bool { + $deadline = \microtime(true) + $timeout; + do { + foreach ($client->getWorkflowHistory($stub->getExecution()) as $event) { + if ($event->getEventType() === $eventType) { + return true; + } + } + \usleep(500_000); + } while (\microtime(true) < $deadline); + + return false; + } + + /** + * @return list + */ + protected static function historyEventNames( + WorkflowClientInterface $client, + WorkflowStubInterface $stub, + ): array { + $names = []; + foreach ($client->getWorkflowHistory($stub->getExecution()) as $event) { + $names[] = EventType::name($event->getEventType()); + } + return $names; + } +} diff --git a/tests/Acceptance/Extra/Nexus/NexusHttpClient.php b/tests/Acceptance/Extra/Nexus/NexusHttpClient.php new file mode 100644 index 000000000..3451e9b43 --- /dev/null +++ b/tests/Acceptance/Extra/Nexus/NexusHttpClient.php @@ -0,0 +1,52 @@ + $headers + * @return array{int, string, array>} + */ + public function post( + NexusEndpoint $endpoint, + string $service, + string $operation, + mixed $body, + array $headers = [], + ): array { + $attempts = 10; + for ($attempt = 1; $attempt <= $attempts; $attempt++) { + $response = $this->http->request( + 'POST', + "/nexus/endpoints/{$endpoint->id}/services/{$service}/{$operation}", + [ + 'headers' => $headers, + 'json' => $body, + 'max_duration' => 30, + ], + ); + $code = $response->getStatusCode(); + if ($code !== 404 || $attempt === $attempts) { + return [$code, $response->getContent(false), $response->getHeaders(false)]; + } + \usleep(100_000); + } + throw new \LogicException('Unreachable'); + } +} diff --git a/tests/Acceptance/Extra/Nexus/NexusWorkerOptions.php b/tests/Acceptance/Extra/Nexus/NexusWorkerOptions.php new file mode 100644 index 000000000..2a2ae0831 --- /dev/null +++ b/tests/Acceptance/Extra/Nexus/NexusWorkerOptions.php @@ -0,0 +1,20 @@ +withMaxConcurrentActivityExecutionSize(10) + ->withMaxConcurrentNexusTaskExecutionSize(10) + ->withMaxConcurrentNexusTaskPollers(2); + } +} diff --git a/tests/Acceptance/Extra/Nexus/Parallel/ParallelTest.php b/tests/Acceptance/Extra/Nexus/Parallel/ParallelTest.php new file mode 100644 index 000000000..5b48c6e3d --- /dev/null +++ b/tests/Acceptance/Extra/Nexus/Parallel/ParallelTest.php @@ -0,0 +1,96 @@ +register($state->namespace, __NAMESPACE__, 'nexus-parallel'); + + $stub = $client->newUntypedWorkflowStub( + 'Extra_Nexus_Parallel_Caller', + WorkflowOptions::new() + ->withTaskQueue(__NAMESPACE__) + ->withWorkflowExecutionTimeout(CarbonInterval::seconds(20)), + ); + + $client->start($stub, $endpoint->name); + + // double(1..5) → 2+4+6+8+10 = 30. + self::assertSame('sum=30', $stub->getResult('string')); + } +} + +#[Service(name: 'ParallelService')] +class ParallelService +{ + #[Operation] + public function double(int $input): int + { + return $input * 2; + } +} + +#[WorkflowInterface] +class ParallelCallerWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_Parallel_Caller')] + public function run(string $endpoint) + { + $stub = Workflow::newNexusServiceStub( + ParallelService::class, + NexusOperationOptions::new() + ->withEndpoint($endpoint) + ->withScheduleToCloseTimeout(CarbonInterval::seconds(15)), + ); + + $promises = []; + for ($i = 1; $i <= 5; $i++) { + $promises[] = $stub->double($i); + } + + $results = yield Promise::all($promises); + + return 'sum=' . \array_sum($results); + } +} diff --git a/tests/Acceptance/Extra/Nexus/ParallelCancel/CancelPropagationTest.php b/tests/Acceptance/Extra/Nexus/ParallelCancel/CancelPropagationTest.php new file mode 100644 index 000000000..ec370ae69 --- /dev/null +++ b/tests/Acceptance/Extra/Nexus/ParallelCancel/CancelPropagationTest.php @@ -0,0 +1,186 @@ +getResult('string'); + + $endpoint = $endpoints->register($state->namespace, __NAMESPACE__, 'nexus-cancel-prop'); + + $stub = $client->newUntypedWorkflowStub( + 'Extra_Nexus_ParallelCancel_Caller', + WorkflowOptions::new() + ->withTaskQueue(__NAMESPACE__) + ->withWorkflowExecutionTimeout(CarbonInterval::seconds(60)), + ); + + $client->start($stub, $endpoint->name); + + self::assertSame('cancelled', $stub->getResult('string')); + + $history = $client->getWorkflowHistory($stub->getExecution())->getHistory(); + $scheduled = self::countEvents($history, EventType::EVENT_TYPE_NEXUS_OPERATION_SCHEDULED); + $cancelRequested = self::countEvents($history, EventType::EVENT_TYPE_NEXUS_OPERATION_CANCEL_REQUESTED); + + self::assertSame( + 3, + $scheduled, + 'All three siblings must reach the schedule event before cancel.', + ); + self::assertSame( + 3, + $cancelRequested, + 'Cancel must fan out to every sibling — proves Scope::onRequest registers per-promise onCancel.', + ); + + $canceled = self::countEvents($history, EventType::EVENT_TYPE_NEXUS_OPERATION_CANCELED); + self::assertSame( + 3, + $canceled, + 'Every sibling must reach the terminal CANCELED event — the handler re-raises the ' + . 'CanceledFailure so the operation closes as CANCELED rather than COMPLETED.', + ); + + $completed = self::countEvents($history, EventType::EVENT_TYPE_NEXUS_OPERATION_COMPLETED); + self::assertSame( + 0, + $completed, + 'No sibling may close as COMPLETED once cancellation propagated.', + ); + } +} + +#[Service(name: 'CancelPropagationService')] +class CancelPropagationService +{ + #[AsyncOperation(output: 'string')] + public function longRunning(string $input): WorkflowHandle + { + return WorkflowHandle::fromWorkflowMethod( + CancelPropagationHandlerWorkflow::class, + WorkflowOptions::new()->withWorkflowId(Nexus::getStartDetails()->requestId), + $input, + ); + } +} + +#[WorkflowInterface] +class CancelPropagationHandlerWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_ParallelCancel_Handler')] + public function handle(string $input) + { + yield Workflow::timer(CarbonInterval::seconds(45)); + return "completed:{$input}"; + } +} + +#[WorkflowInterface] +class CancelPropagationCallerWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_ParallelCancel_Caller')] + public function run(string $endpoint) + { + $stub = Workflow::newNexusServiceStub( + CancelPropagationService::class, + NexusOperationOptions::new() + ->withEndpoint($endpoint) + ->withScheduleToCloseTimeout(CarbonInterval::seconds(60)) + ->withCancellationType(NexusOperationCancellationType::WaitCompleted), + ); + + $promises = []; + $combined = null; + $scope = Workflow::async(static function () use ($stub, &$combined, &$promises): void { + $promises = [ + $stub->longRunning('a'), + $stub->longRunning('b'), + $stub->longRunning('c'), + ]; + $combined = Promise::all($promises); + }); + + yield Workflow::timer(CarbonInterval::seconds(NexusWorkerOptions::PRE_CANCEL_TIMER_SECONDS)); + $scope->cancel(); + + $outcome = 'unexpected-no-failure'; + try { + yield $combined; + } catch (NexusOperationFailure $e) { + $outcome = $e->getPrevious() instanceof CanceledFailure + ? 'cancelled' + : 'unexpected-cause:' . ($e->getPrevious()?->getMessage() ?? 'null'); + } catch (CanceledFailure) { + $outcome = 'cancelled'; + } + + // Drain every sibling so all terminal events land before the caller closes. + foreach ($promises as $promise) { + try { + yield $promise; + } catch (\Throwable) { + } + } + + return $outcome; + } +} + +#[WorkflowInterface] +class ParallelCancelBootstrapWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_ParallelCancel_Bootstrap')] + public function run(): string + { + return 'ready'; + } +} diff --git a/tests/Acceptance/Extra/Nexus/ParallelFailure/PartialFailureTest.php b/tests/Acceptance/Extra/Nexus/ParallelFailure/PartialFailureTest.php new file mode 100644 index 000000000..c53c6242f --- /dev/null +++ b/tests/Acceptance/Extra/Nexus/ParallelFailure/PartialFailureTest.php @@ -0,0 +1,144 @@ +getResult('string'); + + $endpoint = $endpoints->register($state->namespace, __NAMESPACE__, 'nexus-partial-fail'); + + $stub = $client->newUntypedWorkflowStub( + 'Extra_Nexus_ParallelFailure_PartialCaller', + WorkflowOptions::new() + ->withTaskQueue(__NAMESPACE__) + ->withWorkflowExecutionTimeout(CarbonInterval::seconds(45)), + ); + + $client->start($stub, $endpoint->name); + + $callerFailed = false; + try { + $stub->getResult('string'); + } catch (WorkflowFailedException) { + $callerFailed = true; + } + + self::assertTrue( + $callerFailed, + 'Caller workflow must fail when a sibling Nexus operation fails inside Promise::all.', + ); + + $history = $client->getWorkflowHistory($stub->getExecution())->getHistory(); + $scheduled = self::countEvents($history, EventType::EVENT_TYPE_NEXUS_OPERATION_SCHEDULED); + $failed = self::countEvents($history, EventType::EVENT_TYPE_NEXUS_OPERATION_FAILED); + $timedOut = self::countEvents($history, EventType::EVENT_TYPE_NEXUS_OPERATION_TIMED_OUT); + + self::assertSame( + 3, + $scheduled, + 'All three Promise::all siblings must be scheduled before the workflow fails.', + ); + self::assertGreaterThanOrEqual( + 1, + $failed + $timedOut, + 'At least one Nexus operation must terminate (Failed or TimedOut) so Promise::all can settle.', + ); + } +} + +#[Service(name: 'PartialFailureService')] +class PartialFailureService +{ + #[Operation] + public function succeed(string $tag): string + { + return 'ok-' . $tag; + } + + #[Operation] + public function fail(string $tag): string + { + throw OperationException::failed('partial-failure-' . $tag); + } +} + +#[WorkflowInterface] +class PartialFailureCallerWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_ParallelFailure_PartialCaller')] + public function run(string $endpoint) + { + $stub = Workflow::newNexusServiceStub( + PartialFailureService::class, + NexusOperationOptions::new() + ->withEndpoint($endpoint) + ->withScheduleToCloseTimeout(CarbonInterval::seconds(5)), + ); + + $promises = [ + $stub->succeed('a'), + $stub->succeed('b'), + $stub->fail('c'), + ]; + + return yield Promise::all($promises); + } +} + +#[WorkflowInterface] +class ParallelFailureBootstrapWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_ParallelFailure_Bootstrap')] + public function run(): string + { + return 'ready'; + } +} diff --git a/tests/Acceptance/Extra/Nexus/ParallelMixed/MixedSyncAsyncTest.php b/tests/Acceptance/Extra/Nexus/ParallelMixed/MixedSyncAsyncTest.php new file mode 100644 index 000000000..25cda95d9 --- /dev/null +++ b/tests/Acceptance/Extra/Nexus/ParallelMixed/MixedSyncAsyncTest.php @@ -0,0 +1,152 @@ +getResult('string'); + + $endpoint = $endpoints->register($state->namespace, __NAMESPACE__, 'nexus-mixed'); + + $stub = $client->newUntypedWorkflowStub( + 'Extra_Nexus_ParallelMixed_Caller', + WorkflowOptions::new() + ->withTaskQueue(__NAMESPACE__) + ->withWorkflowExecutionTimeout(CarbonInterval::seconds(45)), + ); + + $client->start($stub, $endpoint->name); + + self::assertSame('sync=ok-x|async=done-y', $stub->getResult('string')); + + $history = $client->getWorkflowHistory($stub->getExecution())->getHistory(); + $scheduled = self::countEvents($history, EventType::EVENT_TYPE_NEXUS_OPERATION_SCHEDULED); + $started = self::countEvents($history, EventType::EVENT_TYPE_NEXUS_OPERATION_STARTED); + $completed = self::countEvents($history, EventType::EVENT_TYPE_NEXUS_OPERATION_COMPLETED); + + self::assertSame( + 2, + $scheduled, + 'Both sync and async siblings must emit NEXUS_OPERATION_SCHEDULED.', + ); + self::assertSame( + 1, + $started, + 'Only the async sibling emits NEXUS_OPERATION_STARTED — discriminator vs sync path.', + ); + self::assertSame( + 2, + $completed, + 'Both siblings must terminate via NEXUS_OPERATION_COMPLETED.', + ); + } +} + +#[Service(name: 'MixedSyncAsyncService')] +class MixedSyncAsyncService +{ + #[Operation] + public function syncOp(string $tag): string + { + return 'ok-' . $tag; + } + + #[AsyncOperation(output: 'string')] + public function asyncOp(string $tag): WorkflowHandle + { + return WorkflowHandle::fromWorkflowMethod( + MixedAsyncHandlerWorkflow::class, + WorkflowOptions::new()->withWorkflowId(Nexus::getStartDetails()->requestId), + $tag, + ); + } +} + +#[WorkflowInterface] +class MixedAsyncHandlerWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_ParallelMixed_AsyncHandler')] + public function handle(string $tag) + { + // Force a workflow-task transition so the operation actually goes async + // (without a yield it collapses to the sync-async path, skipping NEXUS_OPERATION_STARTED). + yield Workflow::timer(CarbonInterval::milliseconds(100)); + return 'done-' . $tag; + } +} + +#[WorkflowInterface] +class MixedSyncAsyncCallerWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_ParallelMixed_Caller')] + public function run(string $endpoint) + { + $stub = Workflow::newNexusServiceStub( + MixedSyncAsyncService::class, + NexusOperationOptions::new() + ->withEndpoint($endpoint) + ->withScheduleToCloseTimeout(CarbonInterval::seconds(30)), + ); + + [$syncResult, $asyncResult] = yield Promise::all([ + $stub->syncOp('x'), + $stub->asyncOp('y'), + ]); + + return "sync={$syncResult}|async={$asyncResult}"; + } +} + +#[WorkflowInterface] +class ParallelMixedBootstrapWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_ParallelMixed_Bootstrap')] + public function run(): string + { + return 'ready'; + } +} diff --git a/tests/Acceptance/Extra/Nexus/Replay/ReplayTest.php b/tests/Acceptance/Extra/Nexus/Replay/ReplayTest.php new file mode 100644 index 000000000..113ed81e7 --- /dev/null +++ b/tests/Acceptance/Extra/Nexus/Replay/ReplayTest.php @@ -0,0 +1,400 @@ +register($state->namespace, __NAMESPACE__, 'nexus-replay-async'); + + $stub = $client->newUntypedWorkflowStub( + 'Extra_Nexus_Replay_AsyncCaller', + WorkflowOptions::new() + ->withTaskQueue(__NAMESPACE__) + ->withWorkflowExecutionTimeout(CarbonInterval::seconds(30)), + ); + + $client->start($stub, $endpoint->name, 'world'); + self::assertSame('HELLO, WORLD!', $stub->getResult('string')); + + $history = $client->getWorkflowHistory($stub->getExecution())->getHistory(); + + // Started discriminates async from sync; without it the test name lies. + self::assertContainsEvents( + $history, + [ + EventType::EVENT_TYPE_NEXUS_OPERATION_SCHEDULED, + EventType::EVENT_TYPE_NEXUS_OPERATION_STARTED, + EventType::EVENT_TYPE_NEXUS_OPERATION_COMPLETED, + ], + 'async caller history must include Scheduled+Started+Completed Nexus events', + ); + + (new WorkflowReplayer())->replayHistory($history); + } + + #[Test] + public function timerBeforeNexusOperationReplaysCleanly( + State $state, + WorkflowClientInterface $client, + NexusEndpoints $endpoints, + ): void { + $endpoint = $endpoints->register($state->namespace, __NAMESPACE__, 'nexus-replay-timer'); + + $stub = $client->newUntypedWorkflowStub( + 'Extra_Nexus_Replay_TimerThenSyncCaller', + WorkflowOptions::new() + ->withTaskQueue(__NAMESPACE__) + ->withWorkflowExecutionTimeout(CarbonInterval::seconds(30)), + ); + + $client->start($stub, $endpoint->name, 'world'); + self::assertSame('Hello, world!', $stub->getResult('string')); + + $history = $client->getWorkflowHistory($stub->getExecution())->getHistory(); + + // Timer + Nexus exercises Request::$lastID ordering across both command kinds. + self::assertContainsEvents( + $history, + [ + EventType::EVENT_TYPE_TIMER_STARTED, + EventType::EVENT_TYPE_TIMER_FIRED, + EventType::EVENT_TYPE_NEXUS_OPERATION_SCHEDULED, + EventType::EVENT_TYPE_NEXUS_OPERATION_COMPLETED, + ], + 'timer-then-nexus history must include both timer and Nexus events', + ); + + (new WorkflowReplayer())->replayHistory($history); + } + + #[Test] + public function syncNexusOperationReplaysFromDumpedJsonFile( + State $state, + WorkflowClientInterface $client, + NexusEndpoints $endpoints, + ): void { + $endpoint = $endpoints->register($state->namespace, __NAMESPACE__, 'nexus-replay-dump'); + + $stub = $client->newUntypedWorkflowStub( + 'Extra_Nexus_Replay_SyncCaller', + WorkflowOptions::new() + ->withTaskQueue(__NAMESPACE__) + ->withWorkflowExecutionTimeout(CarbonInterval::seconds(30)), + ); + + $client->start($stub, $endpoint->name, 'world'); + self::assertSame('Hello, world!', $stub->getResult('string')); + + $file = \dirname(__DIR__, 4) . '/runtime/tests/nexus-sync-history.json'; + \is_dir(\dirname($file)) or \mkdir(\dirname($file), recursive: true); + \is_file($file) and \unlink($file); + + try { + $replayer = new WorkflowReplayer(); + $replayer->downloadHistory( + 'Extra_Nexus_Replay_SyncCaller', + $stub->getExecution(), + $file, + ); + self::assertFileExists($file); + // The file must really contain Nexus events before the JSON-replay step gets credit. + $contents = (string) \file_get_contents($file); + self::assertStringContainsString( + 'EVENT_TYPE_NEXUS_OPERATION_SCHEDULED', + $contents, + 'Dumped history JSON must contain a Nexus scheduled event.', + ); + self::assertStringContainsString( + 'EVENT_TYPE_NEXUS_OPERATION_COMPLETED', + $contents, + 'Dumped history JSON must contain a Nexus completed event.', + ); + + // Round-trip exercises the JSON deserialiser path independent of proto-from-server. + $replayer->replayFromJSON('Extra_Nexus_Replay_SyncCaller', $file); + } finally { + \is_file($file) and \unlink($file); + } + } + + #[Test] + public function mutatedNexusScheduledEventIsRejectedByReplayer( + State $state, + WorkflowClientInterface $client, + NexusEndpoints $endpoints, + ): void { + $endpoint = $endpoints->register($state->namespace, __NAMESPACE__, 'nexus-replay-mutate'); + + $stub = $client->newUntypedWorkflowStub( + 'Extra_Nexus_Replay_SyncCaller', + WorkflowOptions::new() + ->withTaskQueue(__NAMESPACE__) + ->withWorkflowExecutionTimeout(CarbonInterval::seconds(30)), + ); + + $client->start($stub, $endpoint->name, 'world'); + self::assertSame('Hello, world!', $stub->getResult('string')); + + $history = $client->getWorkflowHistory($stub->getExecution())->getHistory(); + + // Clean replay first — otherwise the negative assertion below is vacuous. + (new WorkflowReplayer())->replayHistory($history); + + // Mutate the recorded operation name; replay must detect the command mismatch. + $mutated = false; + foreach ($history->getEvents() as $event) { + if ($event->getEventType() !== EventType::EVENT_TYPE_NEXUS_OPERATION_SCHEDULED) { + continue; + } + $attrs = $event->getNexusOperationScheduledEventAttributes(); + self::assertNotNull($attrs); + self::assertSame('greet', $attrs->getOperation()); + $attrs->setOperation('mutatedOperationName'); + $mutated = true; + break; + } + self::assertTrue( + $mutated, + 'Test setup expected at least one NEXUS_OPERATION_SCHEDULED in history.', + ); + + $this->expectException(ReplayerException::class); + (new WorkflowReplayer())->replayHistory($history); + } + + #[Test] + public function cancelledNexusOperationReplaysCleanly( + State $state, + WorkflowClientInterface $client, + NexusEndpoints $endpoints, + ): void { + $endpoint = $endpoints->register($state->namespace, __NAMESPACE__, 'nexus-replay-cancel'); + + $stub = $client->newUntypedWorkflowStub( + 'Extra_Nexus_Replay_CancelCaller', + WorkflowOptions::new() + ->withTaskQueue(__NAMESPACE__) + ->withWorkflowExecutionTimeout(CarbonInterval::seconds(30)), + ); + + $client->start($stub, $endpoint->name, 'world'); + self::assertSame('cancelled', $stub->getResult('string')); + + $history = $client->getWorkflowHistory($stub->getExecution())->getHistory(); + + // Cancel must leave both the request marker and the terminal CANCELED event. + self::assertContainsEvents( + $history, + [ + EventType::EVENT_TYPE_NEXUS_OPERATION_SCHEDULED, + EventType::EVENT_TYPE_NEXUS_OPERATION_CANCEL_REQUESTED, + EventType::EVENT_TYPE_NEXUS_OPERATION_CANCELED, + ], + 'cancelled caller history must include Scheduled+CancelRequested+Canceled Nexus events', + ); + + (new WorkflowReplayer())->replayHistory($history); + } +} + +// ── Sync Nexus service ───────────────────────────────────────────────── + +#[Service(name: 'ReplaySyncService')] +class ReplaySyncService +{ + #[Operation] + public function greet(string $name): string + { + return "Hello, {$name}!"; + } +} + +// ── Async Nexus service (WorkflowRunOperation) ───────────────────────── + +#[Service(name: 'ReplayAsyncService')] +class ReplayAsyncService +{ + #[AsyncOperation(output: 'string')] + public function shout(string $input): WorkflowHandle + { + return WorkflowHandle::fromWorkflowMethod( + ReplayAsyncHandlerWorkflow::class, + WorkflowOptions::new()->withWorkflowId(Nexus::getStartDetails()->requestId), + $input, + ); + } +} + +// ── Async handler workflow ───────────────────────────────────────────── + +#[WorkflowInterface] +class ReplayAsyncHandlerWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_Replay_AsyncHandler')] + public function handle(string $input) + { + // One task transition so the operation goes async (Started event). + yield Workflow::timer(CarbonInterval::milliseconds(50)); + return 'HELLO, ' . \strtoupper($input) . '!'; + } +} + +// ── Caller workflows ─────────────────────────────────────────────────── + +#[WorkflowInterface] +class ReplaySyncCallerWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_Replay_SyncCaller')] + public function run(string $endpoint, string $name) + { + $stub = Workflow::newNexusServiceStub( + ReplaySyncService::class, + NexusOperationOptions::new() + ->withEndpoint($endpoint) + ->withScheduleToCloseTimeout(CarbonInterval::seconds(20)), + ); + return yield $stub->greet($name); + } +} + +#[WorkflowInterface] +class ReplayAsyncCallerWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_Replay_AsyncCaller')] + public function run(string $endpoint, string $input) + { + $stub = Workflow::newNexusServiceStub( + ReplayAsyncService::class, + NexusOperationOptions::new() + ->withEndpoint($endpoint) + ->withScheduleToCloseTimeout(CarbonInterval::seconds(20)), + ); + return yield $stub->shout($input); + } +} + +#[WorkflowInterface] +class ReplayTimerThenSyncCallerWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_Replay_TimerThenSyncCaller')] + public function run(string $endpoint, string $name) + { + // Timer first: minimal interleaving that catches per-task command-id drift. + yield Workflow::timer(CarbonInterval::milliseconds(50)); + + $stub = Workflow::newNexusServiceStub( + ReplaySyncService::class, + NexusOperationOptions::new() + ->withEndpoint($endpoint) + ->withScheduleToCloseTimeout(CarbonInterval::seconds(20)), + ); + return yield $stub->greet($name); + } +} + +// ── Cancel-path Nexus service (long handler that does not recover) ────── + +#[Service(name: 'ReplayCancelService')] +class ReplayCancelService +{ + #[AsyncOperation(output: 'string')] + public function longRunning(string $input): WorkflowHandle + { + return WorkflowHandle::fromWorkflowMethod( + ReplayCancelHandlerWorkflow::class, + WorkflowOptions::new()->withWorkflowId(Nexus::getStartDetails()->requestId), + $input, + ); + } +} + +#[WorkflowInterface] +class ReplayCancelHandlerWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_Replay_CancelHandler')] + public function handle(string $input) + { + yield Workflow::timer(CarbonInterval::seconds(30)); + return "completed:{$input}"; + } +} + +#[WorkflowInterface] +class ReplayCancelCallerWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_Replay_CancelCaller')] + public function run(string $endpoint, string $input) + { + // WaitCompleted: with WaitRequested the caller closes before the terminal CANCELED lands. + $stub = Workflow::newNexusServiceStub( + ReplayCancelService::class, + NexusOperationOptions::new() + ->withEndpoint($endpoint) + ->withScheduleToCloseTimeout(CarbonInterval::seconds(60)) + ->withCancellationType(NexusOperationCancellationType::WaitCompleted), + ); + + $promise = null; + $scope = Workflow::async(static function () use ($stub, $input, &$promise): void { + $promise = $stub->longRunning($input); + }); + + yield Workflow::timer(CarbonInterval::seconds(NexusWorkerOptions::PRE_CANCEL_TIMER_SECONDS)); + $scope->cancel(); + + try { + yield $promise; + } catch (NexusOperationFailure $e) { + if ($e->getPrevious() instanceof CanceledFailure) { + return 'cancelled'; + } + throw $e; + } + + return 'unexpected-completion'; + } +} diff --git a/tests/Acceptance/Extra/Nexus/ReverseLinks/ReverseLinkTest.php b/tests/Acceptance/Extra/Nexus/ReverseLinks/ReverseLinkTest.php new file mode 100644 index 000000000..42cd05208 --- /dev/null +++ b/tests/Acceptance/Extra/Nexus/ReverseLinks/ReverseLinkTest.php @@ -0,0 +1,184 @@ +getResult('string'); + + $endpoint = $endpoints->register($state->namespace, __NAMESPACE__, 'nexus-reverse-link'); + + $callerStub = $client->newUntypedWorkflowStub( + 'Extra_Nexus_ReverseLinks_Caller', + WorkflowOptions::new() + ->withTaskQueue(__NAMESPACE__) + ->withWorkflowExecutionTimeout(CarbonInterval::seconds(30)), + ); + + $client->start($callerStub, $endpoint->name); + + self::assertSame('done-reverse-link', $callerStub->getResult('string')); + + $callerExecution = $callerStub->getExecution(); + $handlerExecution = self::lookupHandlerExecution($client, $callerExecution); + + $handlerHistory = $client->getWorkflowHistory($handlerExecution)->getHistory(); + $startedAttributes = null; + foreach ($handlerHistory->getEvents() as $event) { + if ($event->getEventType() === EventType::EVENT_TYPE_WORKFLOW_EXECUTION_STARTED) { + $startedAttributes = $event->getWorkflowExecutionStartedEventAttributes(); + break; + } + } + self::assertNotNull($startedAttributes, 'Handler workflow must have a WORKFLOW_EXECUTION_STARTED event.'); + + $callbacks = $startedAttributes->getCompletionCallbacks(); + self::assertGreaterThan( + 0, + $callbacks->count(), + 'Handler workflow must have at least one completion callback on its started event.', + ); + + $matched = false; + foreach ($callbacks as $callback) { + foreach ($callback->getLinks() as $link) { + $workflowEvent = $link->getWorkflowEvent(); + if ($workflowEvent === null) { + continue; + } + if ( + $workflowEvent->getNamespace() === $state->namespace + && $workflowEvent->getWorkflowId() === $callerExecution->getID() + ) { + $matched = true; + break 2; + } + } + } + self::assertTrue( + $matched, + 'Handler started event must carry a CompletionCallback link with a workflow_event ' + . 'pointing to the caller workflow ' . $callerExecution->getID() . '.', + ); + } + + private static function lookupHandlerExecution( + WorkflowClientInterface $client, + WorkflowExecution $callerExecution, + ): WorkflowExecution { + $callerHistory = $client->getWorkflowHistory($callerExecution)->getHistory(); + foreach ($callerHistory->getEvents() as $event) { + if ($event->getEventType() !== EventType::EVENT_TYPE_NEXUS_OPERATION_STARTED) { + continue; + } + $startedEventAttributes = $event->getNexusOperationStartedEventAttributes(); + $token = $startedEventAttributes?->getOperationToken() ?? ''; + self::assertNotSame('', $token, 'Caller history must carry a non-empty operation token.'); + $decoded = \json_decode(\base64_decode(\strtr($token, '-_', '+/')), true); + self::assertIsArray($decoded, "Operation token did not decode as JSON: {$token}"); + $workflowId = $decoded['wid'] ?? null; + self::assertIsString($workflowId, "Operation token JSON missing 'wid': " . \var_export($decoded, true)); + + // The token only carries the workflow id; describe the workflow to learn its run id, + // which getWorkflowHistory requires (see WorkflowClient::getWorkflowHistory). + $handlerStub = $client->newUntypedRunningWorkflowStub($workflowId); + return $handlerStub->describe()->info->execution; + } + self::fail('Caller history did not contain a NEXUS_OPERATION_STARTED event; cannot derive handler workflow id.'); + } +} + +#[Service(name: 'ReverseLinkService')] +class ReverseLinkService +{ + #[AsyncOperation(output: 'string')] + public function backedByWorkflow(string $input): WorkflowHandle + { + return WorkflowHandle::fromWorkflowMethod( + ReverseLinkHandlerWorkflow::class, + WorkflowOptions::new()->withWorkflowId(Nexus::getStartDetails()->requestId), + $input, + ); + } +} + +#[WorkflowInterface] +class ReverseLinkHandlerWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_ReverseLinks_Handler')] + public function handle(string $input) + { + yield Workflow::timer(CarbonInterval::milliseconds(50)); + return 'done-' . $input; + } +} + +#[WorkflowInterface] +class ReverseLinkCallerWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_ReverseLinks_Caller')] + public function run(string $endpoint) + { + $stub = Workflow::newNexusServiceStub( + ReverseLinkService::class, + NexusOperationOptions::new() + ->withEndpoint($endpoint) + ->withScheduleToCloseTimeout(CarbonInterval::seconds(20)), + ); + + return yield $stub->backedByWorkflow('reverse-link'); + } +} + +#[WorkflowInterface] +class ReverseLinksBootstrapWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_ReverseLinks_Bootstrap')] + public function run(): string + { + return 'ready'; + } +} diff --git a/tests/Acceptance/Extra/Nexus/SyncFailure/SyncFailureTest.php b/tests/Acceptance/Extra/Nexus/SyncFailure/SyncFailureTest.php new file mode 100644 index 000000000..381f474d3 --- /dev/null +++ b/tests/Acceptance/Extra/Nexus/SyncFailure/SyncFailureTest.php @@ -0,0 +1,413 @@ +register($state->namespace, __NAMESPACE__, 'nexus-sync-fail-app'); + + $stub = $client->newUntypedWorkflowStub( + 'Extra_Nexus_SyncFailure_AppFailureCaller', + WorkflowOptions::new() + ->withTaskQueue(__NAMESPACE__) + ->withWorkflowExecutionTimeout(CarbonInterval::seconds(15)), + ); + + $client->start($stub, $endpoint->name); + + self::assertSame('ok', $stub->getResult('string')); + } + + #[Test] + public function handlerErrorBadRequest(State $state, WorkflowClientInterface $client, NexusEndpoints $endpoints): void + { + self::assertSame('ok', $this->runHandlerErrorScenario($state, $client, $endpoints, 'badRequest', 'BAD_REQUEST')); + } + + #[Test] + public function handlerErrorInternal(State $state, WorkflowClientInterface $client, NexusEndpoints $endpoints): void + { + self::assertSame('ok', $this->runHandlerErrorScenario($state, $client, $endpoints, 'internal', 'INTERNAL')); + } + + #[Test] + public function handlerErrorNotFound(State $state, WorkflowClientInterface $client, NexusEndpoints $endpoints): void + { + self::assertSame('ok', $this->runHandlerErrorScenario($state, $client, $endpoints, 'notFound', 'NOT_FOUND')); + } + + #[Test] + public function handlerErrorUnauthorized(State $state, WorkflowClientInterface $client, NexusEndpoints $endpoints): void + { + self::assertSame('ok', $this->runHandlerErrorScenario($state, $client, $endpoints, 'unauthorized', 'UNAUTHORIZED')); + } + + #[Test] + public function applicationFailureCausePreservesTypeMessageAndDetails( + State $state, + WorkflowClientInterface $client, + NexusEndpoints $endpoints, + ): void { + $endpoint = $endpoints->register($state->namespace, __NAMESPACE__, 'nexus-sync-fail-rich-cause'); + + $stub = $client->newUntypedWorkflowStub( + 'Extra_Nexus_SyncFailure_RichCauseCaller', + WorkflowOptions::new() + ->withTaskQueue(__NAMESPACE__) + ->withWorkflowExecutionTimeout(CarbonInterval::seconds(15)), + ); + + $client->start($stub, $endpoint->name); + + self::assertSame('ok', $stub->getResult('string')); + } + + #[Test] + public function callerReceivesNotFoundForUnknownOperation( + State $state, + WorkflowClientInterface $client, + NexusEndpoints $endpoints, + ): void { + $endpoint = $endpoints->register($state->namespace, __NAMESPACE__, 'nexus-sync-unknown-op'); + + $stub = $client->newUntypedWorkflowStub( + 'Extra_Nexus_SyncFailure_UnknownOperationCaller', + WorkflowOptions::new() + ->withTaskQueue(__NAMESPACE__) + ->withWorkflowExecutionTimeout(CarbonInterval::seconds(15)), + ); + + $client->start($stub, $endpoint->name); + + self::assertSame('ok', $stub->getResult('string')); + } + + private function runHandlerErrorScenario( + State $state, + WorkflowClientInterface $client, + NexusEndpoints $endpoints, + string $opName, + string $expectedType, + ): string { + $endpoint = $endpoints->register( + $state->namespace, + __NAMESPACE__, + 'nexus-sync-handler-err-' . \strtolower($opName), + ); + + $stub = $client->newUntypedWorkflowStub( + 'Extra_Nexus_SyncFailure_HandlerErrorCaller', + WorkflowOptions::new() + ->withTaskQueue(__NAMESPACE__) + ->withWorkflowExecutionTimeout(CarbonInterval::seconds(15)), + ); + + $client->start($stub, $endpoint->name, $opName, $expectedType); + + return $stub->getResult('string'); + } +} + +// ── Service A: throws OperationException::failed() ───────────────── + +#[Service(name: 'SyncFailureAppService')] +class SyncFailureAppService +{ + #[Operation] + public function failAlways(string $input): string + { + throw OperationException::failed('business-error'); + } +} + +#[WorkflowInterface] +class AppFailureCallerWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_SyncFailure_AppFailureCaller')] + public function run(string $endpoint) + { + $stub = Workflow::newNexusServiceStub( + SyncFailureAppService::class, + NexusOperationOptions::new() + ->withEndpoint($endpoint) + ->withScheduleToCloseTimeout(CarbonInterval::seconds(10)), + ); + + try { + yield $stub->failAlways('ignored'); + } catch (NexusOperationFailure $e) { + $cause = $e->getPrevious(); + if (!$cause instanceof ApplicationFailure) { + return 'wrong-cause-type:' . \get_debug_type($cause); + } + if (!\str_starts_with($cause->getType(), 'nexus.OperationError.')) { + return "missing-type-marker:{$cause->getType()}"; + } + if (!\str_contains($cause->getOriginalMessage(), 'business-error')) { + return "missing-message:{$cause->getOriginalMessage()}"; + } + return 'ok'; + } + + return 'unexpected:no-exception'; + } +} + +// ── Service B: throws HandlerException with various ErrorTypes ───── + +// Interface + impl shape kept here on purpose: pairs with the untyped-stub +// caller workflow below to cover the "service contract on an interface, called +// via newUntypedNexusOperationStub" path end-to-end. +#[Service(name: 'SyncHandlerErrorService')] +interface SyncHandlerErrorService +{ + #[Operation] + public function badRequest(string $input): string; + + #[Operation] + public function internal(string $input): string; + + #[Operation] + public function notFound(string $input): string; + + #[Operation] + public function unauthorized(string $input): string; +} + +final class SyncHandlerErrorServiceImpl implements SyncHandlerErrorService +{ + public function badRequest(string $input): string + { + throw HandlerException::create(ErrorType::BadRequest, 'bad-request-msg'); + } + + public function internal(string $input): string + { + // `Internal` is retryable by default — force NonRetryable so the caller + // sees the handler error directly instead of a TimeoutFailure after + // retries exhaust the schedule-to-close window. + throw HandlerException::create(ErrorType::Internal, 'internal-msg', null, RetryBehavior::NonRetryable); + } + + public function notFound(string $input): string + { + throw HandlerException::create(ErrorType::NotFound, 'not-found-msg'); + } + + public function unauthorized(string $input): string + { + throw HandlerException::create(ErrorType::Unauthorized, 'unauthorized-msg'); + } +} + +#[WorkflowInterface] +class HandlerErrorCallerWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_SyncFailure_HandlerErrorCaller')] + public function run(string $endpoint, string $opName, string $expectedErrorType) + { + $stub = Workflow::newUntypedNexusOperationStub( + NexusOperationOptions::new() + ->withEndpoint($endpoint) + ->withService('SyncHandlerErrorService') + ->withScheduleToCloseTimeout(CarbonInterval::seconds(10)), + ); + + try { + yield $stub->execute($opName, ['ignored']); + } catch (NexusOperationFailure $e) { + $cause = $e->getPrevious(); + if (!$cause instanceof NexusHandlerFailure) { + $causeName = $cause === null ? 'null' : $cause::class; + return "wrong-cause-type:{$causeName}"; + } + + if ($cause->getType() !== $expectedErrorType) { + return "wrong-error-type:got={$cause->getType()}:want={$expectedErrorType}"; + } + + return 'ok'; + } + + return 'unexpected:no-exception'; + } +} + +// ── Service D: throws OperationException::failed with a rich ApplicationFailure cause ── + +#[Service(name: 'SyncFailureRichCauseService')] +class SyncFailureRichCauseService +{ + #[Operation] + public function failWithRichCause(string $input): string + { + throw OperationException::failed( + 'outer-business-error', + new ApplicationFailure( + 'inner-business-message', + 'CustomBusinessType', + false, + EncodedValues::fromValues(['detail-payload-marker']), + ), + ); + } +} + +#[WorkflowInterface] +class RichCauseCallerWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_SyncFailure_RichCauseCaller')] + public function run(string $endpoint) + { + $stub = Workflow::newNexusServiceStub( + SyncFailureRichCauseService::class, + NexusOperationOptions::new() + ->withEndpoint($endpoint) + ->withScheduleToCloseTimeout(CarbonInterval::seconds(10)), + ); + + try { + yield $stub->failWithRichCause('ignored'); + } catch (NexusOperationFailure $e) { + $innerType = self::findApplicationFailureType($e, 'CustomBusinessType'); + if ($innerType === null) { + return self::dumpChain($e, 'inner-CustomBusinessType-not-found-in-chain'); + } + + $messageHaystack = $innerType->getOriginalMessage() . '|' . $innerType->getMessage(); + if (!\str_contains($messageHaystack, 'inner-business-message')) { + return "missing-inner-message:haystack={$messageHaystack}"; + } + + $details = $innerType->getDetails(); + if ($details->count() === 0) { + return 'missing-details:zero-values'; + } + + $detailValue = $details->getValue(0, 'string'); + if ($detailValue !== 'detail-payload-marker') { + return "wrong-detail-payload:{$detailValue}"; + } + + return 'ok'; + } + + return 'unexpected:no-exception'; + } + + private static function findApplicationFailureType(\Throwable $e, string $type): ?ApplicationFailure + { + $current = $e; + while ($current !== null) { + if ($current instanceof ApplicationFailure && $current->getType() === $type) { + return $current; + } + $current = $current->getPrevious(); + } + return null; + } + + private static function dumpChain(\Throwable $e, string $reason): string + { + $entries = []; + $current = $e; + while ($current !== null) { + $entry = $current::class; + if ($current instanceof ApplicationFailure) { + $entry .= '(type=' . $current->getType() + . ',msg=' . $current->getOriginalMessage() + . ',details=' . $current->getDetails()->count() . ')'; + } else { + $entry .= '(msg=' . $current->getMessage() . ')'; + } + $entries[] = $entry; + $current = $current->getPrevious(); + } + return $reason . '|chain=' . \implode('->', $entries); + } +} + +// ── Service C: known service for "unknown operation" scenario ────── + +#[Service(name: 'KnownService')] +class KnownService +{ + #[Operation] + public function knownOp(string $input): string + { + return "known:{$input}"; + } +} + +#[WorkflowInterface] +class UnknownOperationCallerWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_SyncFailure_UnknownOperationCaller')] + public function run(string $endpoint) + { + $stub = Workflow::newUntypedNexusOperationStub( + NexusOperationOptions::new() + ->withEndpoint($endpoint) + ->withService('KnownService') + ->withScheduleToCloseTimeout(CarbonInterval::seconds(10)), + ); + + try { + yield $stub->execute('definitelyNotRegistered', ['x']); + } catch (NexusOperationFailure $e) { + $cause = $e->getPrevious(); + if (!$cause instanceof NexusHandlerFailure) { + $causeName = $cause === null ? 'null' : $cause::class; + return "wrong-cause-type:{$causeName}"; + } + + if ($cause->getType() !== 'NOT_FOUND') { + return "wrong-error-type:{$cause->getType()}"; + } + + return 'ok'; + } + + return 'unexpected:no-exception'; + } +} diff --git a/tests/Acceptance/Extra/Nexus/SyncFromWorkflow/SyncFromWorkflowTest.php b/tests/Acceptance/Extra/Nexus/SyncFromWorkflow/SyncFromWorkflowTest.php new file mode 100644 index 000000000..de77be9f7 --- /dev/null +++ b/tests/Acceptance/Extra/Nexus/SyncFromWorkflow/SyncFromWorkflowTest.php @@ -0,0 +1,92 @@ +register($state->namespace, __NAMESPACE__, 'nexus-sync-from-wf'); + + $stub = $client->newUntypedWorkflowStub( + 'Extra_Nexus_SyncFromWorkflow_Caller', + WorkflowOptions::new() + ->withTaskQueue(__NAMESPACE__) + ->withWorkflowExecutionTimeout(CarbonInterval::seconds(30)), + ); + + $client->start($stub, $endpoint->name, 'world'); + + self::assertSame('Hello, world!', $stub->getResult('string')); + } +} + +// This test deliberately keeps the interface + impl shape so we cover the +// "service contract on an interface, impl on a separate class" path end-to-end +// — the rest of the Nexus acceptance suite runs the class-only shape. +#[Service(name: 'SyncFromWorkflowService')] +interface SyncFromWorkflowService +{ + #[Operation] + public function greet(string $name): string; +} + +final class SyncFromWorkflowServiceImpl implements SyncFromWorkflowService +{ + public function greet(string $name): string + { + return "Hello, {$name}!"; + } +} + +#[WorkflowInterface] +class SyncFromWorkflowCaller +{ + #[WorkflowMethod(name: 'Extra_Nexus_SyncFromWorkflow_Caller')] + public function run(string $endpoint, string $name) + { + $stub = Workflow::newNexusServiceStub( + SyncFromWorkflowService::class, + NexusOperationOptions::new() + ->withEndpoint($endpoint) + ->withScheduleToCloseTimeout(CarbonInterval::seconds(20)), + ); + return yield $stub->greet($name); + } +} diff --git a/tests/Acceptance/Extra/Nexus/Timeout/TimeoutTest.php b/tests/Acceptance/Extra/Nexus/Timeout/TimeoutTest.php new file mode 100644 index 000000000..1f6d57975 --- /dev/null +++ b/tests/Acceptance/Extra/Nexus/Timeout/TimeoutTest.php @@ -0,0 +1,187 @@ +register($state->namespace, __NAMESPACE__, 'nexus-timeout-sync'); + + $stub = $client->newUntypedWorkflowStub( + 'Extra_Nexus_Timeout_SyncCaller', + WorkflowOptions::new() + ->withTaskQueue(__NAMESPACE__) + ->withWorkflowExecutionTimeout(CarbonInterval::seconds(20)), + ); + + $client->start($stub, $endpoint->name); + + self::assertSame('ok', $stub->getResult('string')); + } + + #[Test] + public function asyncOperationTimesOutOnCaller( + State $state, + WorkflowClientInterface $client, + NexusEndpoints $endpoints, + ): void { + $endpoint = $endpoints->register($state->namespace, __NAMESPACE__, 'nexus-timeout-async'); + + $stub = $client->newUntypedWorkflowStub( + 'Extra_Nexus_Timeout_AsyncCaller', + WorkflowOptions::new() + ->withTaskQueue(__NAMESPACE__) + ->withWorkflowExecutionTimeout(CarbonInterval::seconds(20)), + ); + + $client->start($stub, $endpoint->name); + + self::assertSame('ok', $stub->getResult('string')); + } +} + +// ── Sync service: handler sleeps past the caller's timeout ───────── + +#[Service(name: 'TimeoutSyncService')] +class TimeoutSyncService +{ + #[Operation] + public function slowSync(string $input): string + { + // PHP-side handler blocks the worker thread; sleep just past the + // caller's 2s scheduleToCloseTimeout. + \sleep(5); + return "should-not-reach:{$input}"; + } +} + +#[WorkflowInterface] +class TimeoutSyncCallerWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_Timeout_SyncCaller')] + public function run(string $endpoint) + { + $stub = Workflow::newNexusServiceStub( + TimeoutSyncService::class, + NexusOperationOptions::new() + ->withEndpoint($endpoint) + ->withScheduleToCloseTimeout(CarbonInterval::seconds(2)), + ); + + try { + yield $stub->slowSync('payload'); + } catch (NexusOperationFailure $e) { + $cause = $e->getPrevious(); + if (!$cause instanceof TimeoutFailure) { + $causeName = $cause === null ? 'null' : $cause::class; + return "wrong-cause:{$causeName}"; + } + return 'ok'; + } + + return 'unexpected:no-exception'; + } +} + +// ── Async service: handler workflow sleeps past the caller's timeout ─ + +#[Service(name: 'TimeoutAsyncService')] +class TimeoutAsyncService +{ + #[AsyncOperation(output: 'string')] + public function slowAsync(string $input): WorkflowHandle + { + return WorkflowHandle::fromWorkflowMethod( + SlowHandlerWorkflow::class, + WorkflowOptions::new()->withWorkflowId(Nexus::getStartDetails()->requestId), + $input, + ); + } +} + +#[WorkflowInterface] +class SlowHandlerWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_Timeout_SlowHandler')] + public function handle(string $input) + { + // Sleep well past the caller's 3s timeout. + yield Workflow::timer(CarbonInterval::seconds(15)); + return "should-not-reach:{$input}"; + } +} + +#[WorkflowInterface] +class TimeoutAsyncCallerWorkflow +{ + #[WorkflowMethod(name: 'Extra_Nexus_Timeout_AsyncCaller')] + public function run(string $endpoint) + { + $stub = Workflow::newNexusServiceStub( + TimeoutAsyncService::class, + NexusOperationOptions::new() + ->withEndpoint($endpoint) + ->withScheduleToCloseTimeout(CarbonInterval::seconds(3)), + ); + + try { + yield $stub->slowAsync('payload'); + } catch (NexusOperationFailure $e) { + $cause = $e->getPrevious(); + if (!$cause instanceof TimeoutFailure) { + $causeName = $cause === null ? 'null' : $cause::class; + return "wrong-cause:{$causeName}"; + } + return 'ok'; + } + + return 'unexpected:no-exception'; + } +} diff --git a/tests/Acceptance/Extra/Workflow/MutexRunLockedTest.php b/tests/Acceptance/Extra/Workflow/MutexRunLockedTest.php index 576259480..db5fa5bad 100644 --- a/tests/Acceptance/Extra/Workflow/MutexRunLockedTest.php +++ b/tests/Acceptance/Extra/Workflow/MutexRunLockedTest.php @@ -67,6 +67,7 @@ public function __construct() #[Workflow\ReturnType(Type::TYPE_ARRAY)] public function handle(): \Generator { + $result = null; $exception = null; try { $result = yield $this->promise = Workflow::runLocked($this->mutex, $this->runLocked(...)); @@ -77,7 +78,7 @@ public function handle(): \Generator $trailed = false; yield Workflow::await( fn() => $this->exit, - Workflow::runLocked($this->mutex, static function () use (&$trailed) { + Workflow::runLocked($this->mutex, static function () use (&$trailed): void { $trailed = true; }), ); @@ -112,7 +113,7 @@ private function runLocked(): \Generator // Permanently lock mutex Workflow::runLocked($this->mutex, function () { $this->unlocked = true; - yield Workflow::await(fn() => false); + yield Workflow::await(static fn() => false); }); yield Workflow::await(fn() => $this->unblock); diff --git a/tests/Acceptance/worker.php b/tests/Acceptance/worker.php index 08da5fa6d..486962652 100644 --- a/tests/Acceptance/worker.php +++ b/tests/Acceptance/worker.php @@ -97,20 +97,6 @@ $converter = new DataConverter(...$converters); $container->bindSingleton(DataConverter::class, $converter); - $plugins = [new TranscriptPlugin($workerTranscript)]; - $container->bindSingleton( - WorkerFactoryInterface::class, - WorkerFactory::create( - converter: $converter, - pluginRegistry: new PluginRegistry($plugins), - ) - ); - - $workerFactory = $container->get(\Temporal\Tests\Acceptance\App\Feature\WorkerFactory::class); - $getWorker = static function (Feature $feature) use (&$workers, $workerFactory): WorkerInterface { - return $workers[$feature->taskQueue] ??= $workerFactory->createWorker($feature); - }; - $serviceClient = $runtime->command->tlsKey === null && $runtime->command->tlsCert === null ? ServiceClient::create($runtime->address) : ServiceClient::createSSL( @@ -122,6 +108,21 @@ $workflowClient = WorkflowClient::create(serviceClient: $serviceClient, options: $options, converter: $converter); $scheduleClient = ScheduleClient::create(serviceClient: $serviceClient, options: $options, converter: $converter); + $container->bindSingleton( + WorkerFactoryInterface::class, + WorkerFactory::create( + converter: $converter, + pluginRegistry: new PluginRegistry([new TranscriptPlugin($workerTranscript)]), + client: $workflowClient, + ), + ); + + $workerFactory = $container->get(\Temporal\Tests\Acceptance\App\Feature\WorkerFactory::class); + $getWorker = static function (Feature $feature) use (&$workers, $workerFactory): WorkerInterface { + return $workers[$feature->taskQueue] ??= $workerFactory->createWorker($feature); + }; + + // Bind services $container->bindSingleton(State::class, $runtime); $container->bindSingleton(LoggerInterface::class, $logger); $container->bindSingleton(ServiceClientInterface::class, $serviceClient); @@ -141,6 +142,11 @@ $getWorker($feature)->registerActivityImplementations($container->make($activity)); } + // Register Nexus Services + foreach ($runtime->nexusServices() as $feature => $nexusService) { + $getWorker($feature)->registerNexusServiceImplementation($container->make($nexusService)); + } + $host = new RecordingHost(RoadRunner::create(), $workerTranscript); $container->get(WorkerFactoryInterface::class)->run($host); } catch (\Throwable $e) { diff --git a/tests/Functional/ReplayerTestCase.php b/tests/Functional/ReplayerTestCase.php index cf3eec6fe..273902c4e 100644 --- a/tests/Functional/ReplayerTestCase.php +++ b/tests/Functional/ReplayerTestCase.php @@ -24,20 +24,6 @@ final class ReplayerTestCase extends TestCase { private WorkflowClient $workflowClient; - protected function setUp(): void - { - $this->workflowClient = new WorkflowClient( - ServiceClient::create('127.0.0.1:7233') - ); - - parent::setUp(); - } - - protected function tearDown(): void - { - parent::tearDown(); - } - public function testReplayWorkflowFromServer(): void { $workflow = $this->workflowClient->newWorkflowStub(WorkflowWithSequence::class); @@ -253,7 +239,7 @@ public function testFilterHistory(): void $history = $this->workflowClient->getWorkflowHistory( execution: $run->getExecution(), historyEventFilterType: HistoryEventFilterType::HISTORY_EVENT_FILTER_TYPE_CLOSE_EVENT, - skipArchival: true + skipArchival: true, )->getHistory(); $this->assertCount(1, \iterator_to_array($history->getEvents(), false)); @@ -327,10 +313,21 @@ public function testReplayNotExistingWorkflowHistory(): void (new WorkflowReplayer())->replayHistory($history); } + protected function setUp(): void + { + $this->workflowClient = new WorkflowClient( + ServiceClient::create('127.0.0.1:7233'), + ); + + parent::setUp(); + } + private function createAwaitsUpdateUntypedStub(WorkflowClient $client): WorkflowStubInterface { - return $client->newWorkflowStub(AwaitsUpdateWorkflow::class, WorkflowOptions::new() - ->withWorkflowRunTimeout('10 seconds') + return $client->newWorkflowStub( + AwaitsUpdateWorkflow::class, + WorkflowOptions::new() + ->withWorkflowRunTimeout('10 seconds'), )->__getUntypedStub(); } } diff --git a/tests/Nexus/Fixtures/Service/FakeGreetingWorkflow.php b/tests/Nexus/Fixtures/Service/FakeGreetingWorkflow.php new file mode 100644 index 000000000..cd57a8ad5 --- /dev/null +++ b/tests/Nexus/Fixtures/Service/FakeGreetingWorkflow.php @@ -0,0 +1,14 @@ +apiClient = $apiClient; + } + + public function sayHello1(string $name): string + { + return "Hello, {$name}!"; + } + + public function sayHello2(string $name): WorkflowHandle + { + $details = Nexus::getStartDetails(); + if ($details->callbackUrl !== null) { + throw new \InvalidArgumentException('This service does not support callbacks'); + } + + ($this->apiClient)($name); + + if (\str_ends_with($name, 'link')) { + Nexus::getCurrentOperationContext()->links->add( + new Link('http://somepath?k=v', 'com.example.MyResource'), + ); + } + + return WorkflowHandle::fromWorkflowMethod( + FakeGreetingWorkflow::class, + WorkflowOptions::new()->withWorkflowId(self::WORKFLOW_ID), + ); + } + +} diff --git a/tests/Nexus/Fixtures/Service/GreetingServiceInterface.php b/tests/Nexus/Fixtures/Service/GreetingServiceInterface.php new file mode 100644 index 000000000..800db54d2 --- /dev/null +++ b/tests/Nexus/Fixtures/Service/GreetingServiceInterface.php @@ -0,0 +1,27 @@ +hello1Throw !== null) { + throw $this->hello1Throw; + } + return 'ok'; + } + + public function sayHello2(string $name): WorkflowHandle + { + if ($this->hello2Throw !== null) { + throw $this->hello2Throw; + } + return WorkflowHandle::fromWorkflowMethod( + FakeGreetingWorkflow::class, + WorkflowOptions::new()->withWorkflowId('throwing-workflow'), + ); + } + +} diff --git a/tests/Nexus/Fixtures/Service/UnionInputServiceInterface.php b/tests/Nexus/Fixtures/Service/UnionInputServiceInterface.php new file mode 100644 index 000000000..6c137755d --- /dev/null +++ b/tests/Nexus/Fixtures/Service/UnionInputServiceInterface.php @@ -0,0 +1,22 @@ +assertAuthorized($input->operationContext->headers->get(self::AUTH_HEADER)); + return $next($input); + } + + public function cancelOperation(CancelOperationInput $input, callable $next): void + { + $this->assertAuthorized($input->operationContext->headers->get(self::AUTH_HEADER)); + $next($input); + } + + private function assertAuthorized(?string $token): void + { + if ($token !== $this->authToken) { + throw HandlerException::create(ErrorType::Unauthorized, 'Unauthorized'); + } + } +} diff --git a/tests/Nexus/Fixtures/ServiceHandler/ExternalJobHandler.php b/tests/Nexus/Fixtures/ServiceHandler/ExternalJobHandler.php new file mode 100644 index 000000000..ae256d38a --- /dev/null +++ b/tests/Nexus/Fixtures/ServiceHandler/ExternalJobHandler.php @@ -0,0 +1,40 @@ +cancelledToken = $details->operationToken; + } +} diff --git a/tests/Nexus/Fixtures/ServiceHandler/FinishedJobHandler.php b/tests/Nexus/Fixtures/ServiceHandler/FinishedJobHandler.php new file mode 100644 index 000000000..5d915f3f7 --- /dev/null +++ b/tests/Nexus/Fixtures/ServiceHandler/FinishedJobHandler.php @@ -0,0 +1,36 @@ + */ + private array $operations = []; + + /** + * @return list + */ + public function getOperations(): array + { + return $this->operations; + } + + public function startOperation(StartOperationInput $input, callable $next): OperationStartResult + { + $this->operations[] = $input->operationContext->operation; + return $next($input); + } + + public function cancelOperation(CancelOperationInput $input, callable $next): void + { + $this->operations[] = $input->operationContext->operation; + $next($input); + } +} diff --git a/tests/Nexus/Fixtures/ServiceHandler/ManualTokenService.php b/tests/Nexus/Fixtures/ServiceHandler/ManualTokenService.php new file mode 100644 index 000000000..bcf3aa0ce --- /dev/null +++ b/tests/Nexus/Fixtures/ServiceHandler/ManualTokenService.php @@ -0,0 +1,44 @@ +externalJobHandler = new ExternalJobHandler(); + } + + #[AsyncOperation(output: 'string', input: 'string')] + public function startExternal(): ExternalJobHandler + { + return $this->externalJobHandler; + } + + #[AsyncOperation(output: 'string', input: 'string')] + public function startUncancellable(): UncancellableJobHandler + { + return new UncancellableJobHandler(); + } + + #[AsyncOperation(output: 'string', input: 'string')] + public function startAlreadyFinished(): FinishedJobHandler + { + return new FinishedJobHandler(); + } +} diff --git a/tests/Nexus/Fixtures/ServiceHandler/UncancellableJobHandler.php b/tests/Nexus/Fixtures/ServiceHandler/UncancellableJobHandler.php new file mode 100644 index 000000000..87955696a --- /dev/null +++ b/tests/Nexus/Fixtures/ServiceHandler/UncancellableJobHandler.php @@ -0,0 +1,40 @@ +fromClass(\get_class($instance))->withInstance($instance); + return (new NexusServiceInstantiator())->instantiate($prototype); + } +} diff --git a/tests/Nexus/Support/EncodesValues.php b/tests/Nexus/Support/EncodesValues.php new file mode 100644 index 000000000..e1c67f62b --- /dev/null +++ b/tests/Nexus/Support/EncodesValues.php @@ -0,0 +1,29 @@ + $expected + * @return T + */ + protected static function assertThrown(string $expected, callable $action): \Throwable + { + try { + $action(); + } catch (\Throwable $e) { + Assert::assertInstanceOf($expected, $e); + return $e; + } + + Assert::fail("Expected {$expected} but nothing was thrown"); + } +} diff --git a/tests/Nexus/Support/MocksAsyncWorkflowClient.php b/tests/Nexus/Support/MocksAsyncWorkflowClient.php new file mode 100644 index 000000000..7bb80ec18 --- /dev/null +++ b/tests/Nexus/Support/MocksAsyncWorkflowClient.php @@ -0,0 +1,34 @@ +createMock(WorkflowClientInterface::class); + $client->method('newWorkflowStub')->willReturn($this->createMock(WorkflowStubInterface::class)); + $client->method('newUntypedRunningWorkflowStub')->willReturn($this->createMock(WorkflowStubInterface::class)); + + $run = $this->createMock(WorkflowRunInterface::class); + $run->method('getExecution')->willReturn(new WorkflowExecution(GreetingService::WORKFLOW_ID, 'run-1')); + $client->method('start')->willReturn($run); + + return $client; + } +} diff --git a/tests/Nexus/Unit/Exception/ErrorTypeTest.php b/tests/Nexus/Unit/Exception/ErrorTypeTest.php new file mode 100644 index 000000000..c97fd1e63 --- /dev/null +++ b/tests/Nexus/Unit/Exception/ErrorTypeTest.php @@ -0,0 +1,38 @@ +value); + self::assertSame('UNAUTHENTICATED', ErrorType::Unauthenticated->value); + self::assertSame('UNAUTHORIZED', ErrorType::Unauthorized->value); + self::assertSame('NOT_FOUND', ErrorType::NotFound->value); + self::assertSame('REQUEST_TIMEOUT', ErrorType::RequestTimeout->value); + self::assertSame('CONFLICT', ErrorType::Conflict->value); + self::assertSame('RESOURCE_EXHAUSTED', ErrorType::ResourceExhausted->value); + self::assertSame('INTERNAL', ErrorType::Internal->value); + self::assertSame('NOT_IMPLEMENTED', ErrorType::NotImplemented->value); + self::assertSame('UNAVAILABLE', ErrorType::Unavailable->value); + self::assertSame('UPSTREAM_TIMEOUT', ErrorType::UpstreamTimeout->value); + } +} diff --git a/tests/Nexus/Unit/Handler/CancelOperationTest.php b/tests/Nexus/Unit/Handler/CancelOperationTest.php new file mode 100644 index 000000000..825ab9457 --- /dev/null +++ b/tests/Nexus/Unit/Handler/CancelOperationTest.php @@ -0,0 +1,143 @@ +env = new Environment(); + } + + public function testCancelUnrecognizedService(): void + { + $handler = ServiceHandler::create( + dataConverter: self::dataConverter(), + instances: [ + self::bindNexusService(new VoidService()), + ], + ); + + $e = self::assertThrown(HandlerException::class, fn() => $handler->cancelOperation( + new OperationContext(service: 'NonExistent', operation: 'op', env: $this->env), + new OperationCancelDetails(operationToken: 'token'), + null, + new NexusOperationContext(), + )); + + self::assertSame(ErrorType::NotFound, $e->errorType); + self::assertStringContainsString("Unrecognized service 'NonExistent'", $e->getMessage()); + } + + public function testCancelUnrecognizedOperation(): void + { + $handler = ServiceHandler::create( + dataConverter: self::dataConverter(), + instances: [ + self::bindNexusService(new VoidService()), + ], + ); + + $e = self::assertThrown(HandlerException::class, fn() => $handler->cancelOperation( + new OperationContext(service: 'VoidServiceInterface', operation: 'nonExistent', env: $this->env), + new OperationCancelDetails(operationToken: 'token'), + null, + new NexusOperationContext(), + )); + + self::assertSame(ErrorType::NotFound, $e->errorType); + self::assertStringContainsString("has no operation 'nonExistent'", $e->getMessage()); + } + + public function testCancelWithInterceptor(): void + { + $apiClient = static fn(string $name): string => "greeting-{$name}"; + $authToken = 'auth-token'; + $loggingInterceptor = new LoggingInterceptor(); + + $handler = ServiceHandler::create( + dataConverter: self::dataConverter(), + instances: [ + self::bindNexusService(new GreetingService($apiClient)), + ], + interceptorProvider: new SimplePipelineProvider([ + new AuthInterceptor($authToken), + $loggingInterceptor, + ]), + ); + + // Start an async operation first. + $result = $handler->startOperation( + new OperationContext( + service: 'GreetingServiceInterface', + operation: 'sayHello2', + env: $this->env, + headers: [AuthInterceptor::AUTH_HEADER => $authToken], + ), + new OperationStartDetails(requestId: 'r1'), + EncodedValues::fromValues(['SomeUser'], self::dataConverter()), + $this->asyncClient(), + new NexusOperationContext('test-ns', 'test-tq'), + ); + + $token = $result->info->token; + self::assertNotNull($token); + + // Cancel it. + $handler->cancelOperation( + new OperationContext( + service: 'GreetingServiceInterface', + operation: 'sayHello2', + env: $this->env, + headers: [AuthInterceptor::AUTH_HEADER => $authToken], + ), + new OperationCancelDetails(operationToken: $token), + $this->asyncClient(), + new NexusOperationContext('test-ns', 'test-tq'), + ); + + // Logging interceptor saw both start and cancel. + self::assertSame(['sayHello2', 'sayHello2'], $loggingInterceptor->getOperations()); + } +} diff --git a/tests/Nexus/Unit/Handler/ClosureMethodCancellationListenerTest.php b/tests/Nexus/Unit/Handler/ClosureMethodCancellationListenerTest.php new file mode 100644 index 000000000..0e68de97f --- /dev/null +++ b/tests/Nexus/Unit/Handler/ClosureMethodCancellationListenerTest.php @@ -0,0 +1,33 @@ +cancelled(); + $listener->cancelled(); + + self::assertSame(2, $calls); + } +} diff --git a/tests/Nexus/Unit/Handler/HandlerExceptionTest.php b/tests/Nexus/Unit/Handler/HandlerExceptionTest.php new file mode 100644 index 000000000..2c8870f94 --- /dev/null +++ b/tests/Nexus/Unit/Handler/HandlerExceptionTest.php @@ -0,0 +1,149 @@ +getMessage()); + self::assertSame(ErrorType::BadRequest, $ex->errorType); + self::assertSame('BAD_REQUEST', $ex->errorType->value); + self::assertNull($ex->getPrevious()); + self::assertSame(RetryBehavior::Unspecified, $ex->retryBehavior); + } + + public function testCreateWithCause(): void + { + $cause = new \RuntimeException('Root cause'); + $ex = HandlerException::create(ErrorType::Internal, 'Handler error', $cause); + + self::assertSame('Handler error', $ex->getMessage()); + self::assertSame($cause, $ex->getPrevious()); + self::assertSame(ErrorType::Internal, $ex->errorType); + } + + public function testCreateWithRetryBehavior(): void + { + $ex = HandlerException::create( + ErrorType::Internal, + 'Server error', + retryBehavior: RetryBehavior::NonRetryable, + ); + + self::assertSame(RetryBehavior::NonRetryable, $ex->retryBehavior); + self::assertFalse($ex->isRetryable()); + } + + public function testFromCauseBuildsMessage(): void + { + $ex = HandlerException::fromCause(ErrorType::Internal, new \RuntimeException('boom')); + + self::assertSame('handler error: boom', $ex->getMessage()); + self::assertSame(ErrorType::Internal, $ex->errorType); + self::assertInstanceOf(\RuntimeException::class, $ex->getPrevious()); + self::assertSame('boom', $ex->getPrevious()->getMessage()); + } + + public function testFromCauseWithEmptyMessageDefaultsToHandlerError(): void + { + $ex = HandlerException::fromCause(ErrorType::Internal, new \RuntimeException('')); + + self::assertSame('handler error', $ex->getMessage()); + } + + public function testFromCauseWithRetryBehavior(): void + { + $cause = new \RuntimeException('Cause'); + $ex = HandlerException::fromCause( + ErrorType::BadRequest, + $cause, + RetryBehavior::Retryable, + ); + + self::assertSame($cause, $ex->getPrevious()); + self::assertSame(RetryBehavior::Retryable, $ex->retryBehavior); + self::assertTrue($ex->isRetryable()); + } + + public function testIsRetryableFromErrorType(): void + { + $retryable = [ + ErrorType::RequestTimeout, + ErrorType::ResourceExhausted, + ErrorType::Internal, + ErrorType::Unavailable, + ErrorType::UpstreamTimeout, + ErrorType::Unknown, + ]; + foreach ($retryable as $type) { + $ex = HandlerException::create($type, 'x'); + self::assertTrue($ex->isRetryable(), "{$type->value} should be retryable"); + } + + $nonRetryable = [ + ErrorType::BadRequest, + ErrorType::Unauthenticated, + ErrorType::Unauthorized, + ErrorType::NotFound, + ErrorType::Conflict, + ErrorType::NotImplemented, + ]; + foreach ($nonRetryable as $type) { + $ex = HandlerException::create($type, 'x'); + self::assertFalse($ex->isRetryable(), "{$type->value} should be non-retryable"); + } + + // Completeness guard — new ErrorType cases must be classified above. + $covered = \count($retryable) + \count($nonRetryable); + self::assertSame(\count(ErrorType::cases()), $covered); + } + + public function testIsRetryableOverriddenByRetryable(): void + { + // BAD_REQUEST is non-retryable by default, but explicit Retryable flips it. + $ex = HandlerException::create( + ErrorType::BadRequest, + 'x', + retryBehavior: RetryBehavior::Retryable, + ); + self::assertTrue($ex->isRetryable()); + } + + public function testIsRetryableOverriddenByNonRetryable(): void + { + // INTERNAL is retryable by default, but explicit NonRetryable flips it. + $ex = HandlerException::create( + ErrorType::Internal, + 'x', + retryBehavior: RetryBehavior::NonRetryable, + ); + self::assertFalse($ex->isRetryable()); + } + + public function testIsInstanceOfNexusException(): void + { + $ex = HandlerException::create(ErrorType::Internal, 'x'); + self::assertInstanceOf(NexusException::class, $ex); + self::assertInstanceOf(\RuntimeException::class, $ex); + } +} diff --git a/tests/Nexus/Unit/Handler/HeaderCollectionTest.php b/tests/Nexus/Unit/Handler/HeaderCollectionTest.php new file mode 100644 index 000000000..cc829d0ad --- /dev/null +++ b/tests/Nexus/Unit/Handler/HeaderCollectionTest.php @@ -0,0 +1,62 @@ + 'text/plain']); + + self::assertSame('text/plain', $headers->get('content-type')); + self::assertSame('text/plain', $headers->get('Content-Type')); + self::assertSame('text/plain', $headers->get('CONTENT-TYPE')); + } + + public function testGetReturnsNullForUnknownHeader(): void + { + $headers = new HeaderCollection(['x-known' => 'v']); + + self::assertNull($headers->get('x-unknown')); + } + + public function testHasIsCaseInsensitive(): void + { + $headers = new HeaderCollection(['X-Trace-Id' => 'abc']); + + self::assertTrue($headers->has('x-trace-id')); + self::assertTrue($headers->has('X-Trace-Id')); + self::assertTrue($headers->has('X-TRACE-ID')); + self::assertFalse($headers->has('x-other')); + } + + public function testAllReturnsNormalizedMap(): void + { + $headers = new HeaderCollection(['UPPER' => 'A', 'lower' => 'b']); + + self::assertSame(['upper' => 'A', 'lower' => 'b'], $headers->all()); + } + + public function testEmptyCollection(): void + { + $headers = new HeaderCollection(); + + self::assertSame([], $headers->all()); + self::assertFalse($headers->has('anything')); + self::assertNull($headers->get('anything')); + } +} diff --git a/tests/Nexus/Unit/Handler/LinkCollectionTest.php b/tests/Nexus/Unit/Handler/LinkCollectionTest.php new file mode 100644 index 000000000..1bbcadaae --- /dev/null +++ b/tests/Nexus/Unit/Handler/LinkCollectionTest.php @@ -0,0 +1,66 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('initial[1] must be'); + /** @phpstan-ignore-next-line — intentionally wrong type */ + new LinkCollection([new Link('a', 't'), 'not-a-link']); + } + + public function testRejectsNonLinkInInitialWithStringKey(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("initial['k']"); + /** @phpstan-ignore-next-line — intentionally wrong type */ + new LinkCollection(['k' => 'nope']); + } + + + public function testEmptyByDefault(): void + { + $c = new LinkCollection(); + + self::assertSame([], $c->all()); + } + + public function testSeedListIsCopied(): void + { + $seed = [new Link('u', 't')]; + $c = new LinkCollection($seed); + + $seed[] = new Link('u2', 't2'); + + self::assertCount(1, $c->all()); + } + + public function testAddAppendsInOrder(): void + { + $c = new LinkCollection(); + $c->add(new Link('a', 'x')); + $c->add(new Link('b', 'y'), new Link('c', 'z')); + + $uris = \array_map(static fn(Link $l) => $l->uri, $c->all()); + self::assertSame(['a', 'b', 'c'], $uris); + } +} diff --git a/tests/Nexus/Unit/Handler/ManualTokenOperationTest.php b/tests/Nexus/Unit/Handler/ManualTokenOperationTest.php new file mode 100644 index 000000000..42c699dab --- /dev/null +++ b/tests/Nexus/Unit/Handler/ManualTokenOperationTest.php @@ -0,0 +1,112 @@ +env = new Environment(); + } + + public function testStartReturnsManualTokenWithoutWorkflowClient(): void + { + $result = $this->handler()->startOperation( + $this->context('startExternal'), + new OperationStartDetails(requestId: 'r1'), + self::encode('job-42'), + null, + new NexusOperationContext(), + ); + + self::assertSame('ext-job-42', $result->info->token); + } + + public function testCancelRoutesToHandlerCancelMethod(): void + { + $service = new ManualTokenService(); + $handler = $this->handler($service); + + $handler->cancelOperation( + $this->context('startExternal'), + new OperationCancelDetails(operationToken: 'ext-job-42'), + null, + new NexusOperationContext(), + ); + + self::assertSame('ext-job-42', $service->externalJobHandler->cancelledToken); + } + + public function testCancelThrowingNotImplementedSurfacesErrorType(): void + { + $e = self::assertThrown(HandlerException::class, fn() => $this->handler()->cancelOperation( + $this->context('startUncancellable'), + new OperationCancelDetails(operationToken: 'ext-fixed-1'), + null, + new NexusOperationContext(), + )); + + self::assertSame(ErrorType::NotImplemented, $e->errorType); + } + + public function testStartRejectsNonRunningOperationInfo(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('must report a running operation'); + + $this->handler()->startOperation( + $this->context('startAlreadyFinished'), + new OperationStartDetails(requestId: 'r2'), + self::encode('job-43'), + null, + new NexusOperationContext(), + ); + } + + private function handler(?ManualTokenService $service = null): ServiceHandler + { + return ServiceHandler::create( + dataConverter: self::dataConverter(), + instances: [self::bindNexusService($service ?? new ManualTokenService())], + ); + } + + private function context(string $operation): OperationContext + { + return new OperationContext(service: 'ManualTokenService', operation: $operation, env: $this->env); + } +} diff --git a/tests/Nexus/Unit/Handler/MethodCancellerTest.php b/tests/Nexus/Unit/Handler/MethodCancellerTest.php new file mode 100644 index 000000000..59f11c376 --- /dev/null +++ b/tests/Nexus/Unit/Handler/MethodCancellerTest.php @@ -0,0 +1,228 @@ +env = new Environment(); + } + + public function testNotCancelledByDefault(): void + { + $canceller = new MethodCanceller($this->env); + + self::assertFalse($canceller->isCancelled()); + self::assertNull($canceller->getReason()); + } + + public function testCancelSetsReason(): void + { + $canceller = new MethodCanceller($this->env); + $canceller->cancel('deadline exceeded'); + + self::assertTrue($canceller->isCancelled()); + self::assertSame('deadline exceeded', $canceller->getReason()); + } + + public function testCancelIsIdempotent(): void + { + $canceller = new MethodCanceller($this->env); + $canceller->cancel('first'); + $canceller->cancel('second'); + + self::assertSame('first', $canceller->getReason(), 'second cancel must be a no-op'); + } + + public function testListenerInvokedOnCancel(): void + { + $canceller = new MethodCanceller($this->env); + $hits = 0; + $canceller->addListener(ClosureMethodCancellationListener::fromCallable( + static function () use (&$hits): void { + $hits++; + }, + )); + + $canceller->cancel('shutdown'); + + self::assertSame(1, $hits); + // Listeners must read the reason from the canceller if they need it. + self::assertSame('shutdown', $canceller->getReason()); + } + + public function testListenerInvokedOnlyOnceAcrossDuplicateCancels(): void + { + $canceller = new MethodCanceller($this->env); + $count = 0; + $canceller->addListener(ClosureMethodCancellationListener::fromCallable( + static function () use (&$count): void { + $count++; + }, + )); + + $canceller->cancel('first'); + $canceller->cancel('second'); + + self::assertSame(1, $count); + } + + public function testListenerAddedAfterCancelInvokedImmediately(): void + { + $canceller = new MethodCanceller($this->env); + $canceller->cancel('gone'); + + $fired = false; + $canceller->addListener(ClosureMethodCancellationListener::fromCallable( + static function () use (&$fired): void { + $fired = true; + }, + )); + + self::assertTrue($fired); + } + + public function testRemovedListenerNotInvoked(): void + { + $canceller = new MethodCanceller($this->env); + $invoked = false; + + $listener = new class($invoked) implements MethodCancellationListenerInterface { + public function __construct(private bool &$invoked) {} + + public function cancelled(): void + { + $this->invoked = true; + } + }; + + $canceller->addListener($listener); + $canceller->removeListener($listener); + $canceller->cancel('irrelevant'); + + self::assertFalse($invoked); + } + + public function testDeadlineNotExpiredYet(): void + { + $canceller = new MethodCanceller($this->env, new \DateTimeImmutable('+1 hour')); + + self::assertFalse($canceller->isCancelled()); + self::assertNull($canceller->getReason()); + } + + public function testExpiredDeadlineAutoCancelsOnIsCancelled(): void + { + $canceller = new MethodCanceller($this->env, new \DateTimeImmutable('-1 second')); + + self::assertTrue($canceller->isCancelled()); + self::assertStringContainsString('deadline exceeded', (string) $canceller->getReason()); + } + + public function testExpiredDeadlineAutoCancelsOnGetReason(): void + { + $canceller = new MethodCanceller($this->env, new \DateTimeImmutable('-1 second')); + + // getReason() must trip the cancellation even if isCancelled() wasn't called first. + self::assertNotNull($canceller->getReason()); + self::assertTrue($canceller->isCancelled()); + } + + public function testListenerFiresOnDeadlineTrip(): void + { + $this->env->update(new TickInfo(time: new \DateTimeImmutable('2026-01-01T00:00:00Z'))); + $deadline = new \DateTimeImmutable('2026-01-01T00:00:00.100Z'); + $canceller = new MethodCanceller($this->env, $deadline); + + $fired = false; + $canceller->addListener(ClosureMethodCancellationListener::fromCallable( + static function () use (&$fired): void { + $fired = true; + }, + )); + self::assertFalse($canceller->isCancelled()); + self::assertFalse($fired); + + $this->env->update(new TickInfo(time: new \DateTimeImmutable('2026-01-01T00:00:01Z'))); + self::assertTrue($canceller->isCancelled()); + + self::assertTrue($fired); + self::assertStringContainsString('deadline exceeded', (string) $canceller->getReason()); + } + + public function testExplicitCancelWinsOverDeadline(): void + { + $canceller = new MethodCanceller($this->env, new \DateTimeImmutable('-1 second')); + + $canceller->cancel('shutdown'); + + // Explicit cancel() before any deadline inspection wins; lazy deadline check is a no-op afterwards. + self::assertSame('shutdown', $canceller->getReason()); + } + + public function testAddListenerOnAlreadyExpiredDeadlineInvokesImmediately(): void + { + $canceller = new MethodCanceller($this->env, new \DateTimeImmutable('-1 second')); + + $fired = false; + $canceller->addListener(ClosureMethodCancellationListener::fromCallable( + static function () use (&$fired): void { + $fired = true; + }, + )); + + self::assertTrue($fired, 'listener must fire synchronously when deadline already passed'); + self::assertStringContainsString('deadline exceeded', (string) $canceller->getReason()); + } + + public function testNoDeadlineNeverAutoCancels(): void + { + $canceller = new MethodCanceller($this->env); + + self::assertFalse($canceller->isCancelled()); + } + + public function testListenersInvokedInRegistrationOrder(): void + { + $canceller = new MethodCanceller($this->env); + $order = []; + $canceller->addListener(ClosureMethodCancellationListener::fromCallable( + static function () use (&$order): void { + $order[] = 'a'; + }, + )); + $canceller->addListener(ClosureMethodCancellationListener::fromCallable( + static function () use (&$order): void { + $order[] = 'b'; + }, + )); + + $canceller->cancel('x'); + + self::assertSame(['a', 'b'], $order); + } + +} diff --git a/tests/Nexus/Unit/Handler/NexusServiceInstantiatorTest.php b/tests/Nexus/Unit/Handler/NexusServiceInstantiatorTest.php new file mode 100644 index 000000000..1d5ebcd17 --- /dev/null +++ b/tests/Nexus/Unit/Handler/NexusServiceInstantiatorTest.php @@ -0,0 +1,81 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/Missing #\[Service\] attribute/'); + self::bind(new NoServiceAnnotation()); + } + + public function testServiceAttributeOnClassIsAccepted(): void + { + $instance = self::bind(new ServiceAsClass()); + + self::assertSame('ServiceAsClass', $instance->prototype->getID()); + self::assertCount(1, $instance->operationHandlers); + self::assertArrayHasKey('classOperation', $instance->operationHandlers); + } + + public function testInheritedHandlerIsDiscovered(): void + { + $instance = self::bind(new ChildInheritingHandler()); + self::assertArrayHasKey('operation', $instance->operationHandlers); + } + + public function testServiceWithExtraNonOperationMethodIsAccepted(): void + { + $instance = self::bind(new ServiceWithExtraNonOperationMethod()); + + self::assertCount(1, $instance->operationHandlers); + self::assertArrayHasKey('operation', $instance->operationHandlers); + } + + /** + * Helper that wires Reader+Instantiator together — the same flow the + * Worker uses at registration time. + */ + private static function bind(object $instance): NexusServiceInstance + { + $reader = new NexusServiceReader(new AttributeReader()); + $prototype = $reader->fromClass(\get_class($instance))->withInstance($instance); + return (new NexusServiceInstantiator())->instantiate($prototype); + } +} diff --git a/tests/Nexus/Unit/Handler/OperationCancelDetailsTest.php b/tests/Nexus/Unit/Handler/OperationCancelDetailsTest.php new file mode 100644 index 000000000..4ebd368e0 --- /dev/null +++ b/tests/Nexus/Unit/Handler/OperationCancelDetailsTest.php @@ -0,0 +1,39 @@ +operationToken); + } + + public function testRejectsEmptyToken(): void + { + $this->expectException(InvalidArgumentException::class); + new OperationCancelDetails(''); + } + + public function testRejectsTokenWithWhitespace(): void + { + $this->expectException(InvalidArgumentException::class); + new OperationCancelDetails("bad tok"); + } +} diff --git a/tests/Nexus/Unit/Handler/OperationContextTest.php b/tests/Nexus/Unit/Handler/OperationContextTest.php new file mode 100644 index 000000000..a9872f5fd --- /dev/null +++ b/tests/Nexus/Unit/Handler/OperationContextTest.php @@ -0,0 +1,168 @@ +env = new Environment(); + } + + public function testLinksAddAndSet(): void + { + $ctx = new OperationContext(service: 'service', operation: 'operation', env: $this->env); + $ctx->links->add(new Link('http://somepath?k=v', 'com.example.MyResource')); + + self::assertEquals( + [new Link('http://somepath?k=v', 'com.example.MyResource')], + $ctx->links->all(), + ); + } + + public function testDeadline(): void + { + $deadline = new \DateTimeImmutable('+1 second'); + $ctx = new OperationContext( + service: 'service', + operation: 'operation', + env: $this->env, + deadline: $deadline, + ); + + self::assertSame($deadline, $ctx->deadline); + } + + public function testConstructorNormalizesHeaders(): void + { + $ctx = new OperationContext( + service: 's', + operation: 'o', + env: $this->env, + headers: [ + 'Content-Type' => 'text/plain', + 'UPPER-CASE-HEADER' => 'UPPER-VALUE', + 'lower-case-header' => 'lower-value', + ], + ); + + self::assertSame( + [ + 'content-type' => 'text/plain', + 'upper-case-header' => 'UPPER-VALUE', + 'lower-case-header' => 'lower-value', + ], + $ctx->headers->all(), + ); + } + + public function testLinksPassedAsExistingCollectionAreUsedAsIs(): void + { + $collection = new LinkCollection([new Link('shared', 't')]); + $ctx = new OperationContext(service: 's', operation: 'o', env: $this->env, links: $collection); + + $collection->add(new Link('added-outside', 't')); + // The same collection was injected, so external additions are visible. + self::assertSame($collection, $ctx->links); + self::assertCount(2, $ctx->links->all()); + } + + public function testMethodCancellationDegradesWithoutCanceller(): void + { + $ctx = new OperationContext(service: 's', operation: 'o', env: $this->env); + + self::assertFalse($ctx->isMethodCancelled()); + self::assertNull($ctx->getMethodCancellationReason()); + + // Listener registration without a canceller is a silent no-op. + $called = false; + $listener = ClosureMethodCancellationListener::fromCallable( + static function () use (&$called): void { + $called = true; + }, + ); + $ctx->addMethodCancellationListener($listener); + $ctx->removeMethodCancellationListener($listener); + self::assertFalse($called); + } + + public function testMethodCancellationPropagatesFromCanceller(): void + { + $canceller = new MethodCanceller($this->env); + $ctx = new OperationContext( + service: 's', + operation: 'o', + env: $this->env, + methodCanceller: $canceller, + ); + + self::assertFalse($ctx->isMethodCancelled()); + + $fired = false; + $ctx->addMethodCancellationListener(ClosureMethodCancellationListener::fromCallable( + static function () use (&$fired): void { + $fired = true; + }, + )); + + $canceller->cancel('shutdown'); + + self::assertTrue($ctx->isMethodCancelled()); + self::assertSame('shutdown', $ctx->getMethodCancellationReason()); + self::assertTrue($fired); + } + + public function testIsMethodCancelledTripsOnDeadlineEvenWithoutCanceller(): void + { + // No canceller attached — but deadline-based trip must still be observable so + // long-running handlers that only poll isMethodCancelled() degrade correctly on + // transports without method-cancel support. + $ctx = new OperationContext( + service: 's', + operation: 'o', + env: $this->env, + deadline: new \DateTimeImmutable('-1 second'), + ); + + self::assertTrue($ctx->isMethodCancelled()); + self::assertStringContainsString('deadline exceeded', (string) $ctx->getMethodCancellationReason()); + } + + public function testExplicitCancellerReasonBeatsDeadline(): void + { + $canceller = new MethodCanceller($this->env); + $canceller->cancel('shutdown'); + $ctx = new OperationContext( + service: 's', + operation: 'o', + env: $this->env, + deadline: new \DateTimeImmutable('-1 second'), + methodCanceller: $canceller, + ); + + self::assertSame('shutdown', $ctx->getMethodCancellationReason()); + } +} diff --git a/tests/Nexus/Unit/Handler/OperationStartDetailsTest.php b/tests/Nexus/Unit/Handler/OperationStartDetailsTest.php new file mode 100644 index 000000000..f2574d487 --- /dev/null +++ b/tests/Nexus/Unit/Handler/OperationStartDetailsTest.php @@ -0,0 +1,61 @@ + 'secret'], + links: $links, + ); + + self::assertSame('req-1', $d->requestId); + self::assertSame('https://cb', $d->callbackUrl); + self::assertSame(['Token' => 'secret'], $d->callbackHeaders); + self::assertSame($links, $d->links); + } + + public function testRejectsEmptyRequestId(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('OperationStartDetails requires a non-empty requestId'); + new OperationStartDetails(requestId: ''); + } + + public function testRejectsNonLinkInLinksList(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('links[1] must be'); + /** @phpstan-ignore-next-line — intentionally wrong type */ + new OperationStartDetails(requestId: 'r', links: [new Link('a', 't'), 'not-a-link']); + } + + public function testRejectsNonLinkInLinksMapWithStringKey(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("links['k']"); + /** @phpstan-ignore-next-line — intentionally wrong type */ + new OperationStartDetails(requestId: 'r', links: ['k' => 'not-a-link']); + } +} diff --git a/tests/Nexus/Unit/Handler/OperationStartResultTest.php b/tests/Nexus/Unit/Handler/OperationStartResultTest.php new file mode 100644 index 000000000..526001877 --- /dev/null +++ b/tests/Nexus/Unit/Handler/OperationStartResultTest.php @@ -0,0 +1,69 @@ +value); + } + + public function testSyncCarriesValue(): void + { + $result = OperationStartResult::sync('hello'); + + self::assertInstanceOf(SyncOperationStartResult::class, $result); + self::assertSame('hello', $result->value); + } + + public function testAsyncFactoryReturnsAsyncSubclass(): void + { + $info = new OperationInfo('token-123', OperationState::Running); + $result = OperationStartResult::async($info); + + self::assertInstanceOf(AsyncOperationStartResult::class, $result); + self::assertSame($info, $result->info); + } + + public function testPatternMatchWithInstanceOf(): void + { + $results = [ + OperationStartResult::sync('v'), + OperationStartResult::async(new OperationInfo('tok', OperationState::Running)), + ]; + + $summary = \array_map( + static fn(OperationStartResult $r): string => match (true) { + $r instanceof SyncOperationStartResult => "sync:{$r->value}", + $r instanceof AsyncOperationStartResult => "async:{$r->info->token}", + }, + $results, + ); + + self::assertSame(['sync:v', 'async:tok'], $summary); + } +} diff --git a/tests/Nexus/Unit/Handler/ServiceHandlerEdgeCasesTest.php b/tests/Nexus/Unit/Handler/ServiceHandlerEdgeCasesTest.php new file mode 100644 index 000000000..127c39a3d --- /dev/null +++ b/tests/Nexus/Unit/Handler/ServiceHandlerEdgeCasesTest.php @@ -0,0 +1,48 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('No service instances defined'); + ServiceHandler::create( + dataConverter: DataConverter::createDefault(), + instances: [], + ); + } + + public function testDuplicateServiceNames(): void + { + $instance = self::bindNexusService(new VoidService()); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Multiple instances registered for service name"); + ServiceHandler::create( + dataConverter: DataConverter::createDefault(), + instances: [$instance, $instance], + ); + } +} diff --git a/tests/Nexus/Unit/Handler/ServiceHandlerInterceptorTest.php b/tests/Nexus/Unit/Handler/ServiceHandlerInterceptorTest.php new file mode 100644 index 000000000..861936acc --- /dev/null +++ b/tests/Nexus/Unit/Handler/ServiceHandlerInterceptorTest.php @@ -0,0 +1,244 @@ +env = new Environment(); + } + + public function testMultipleInterceptorsAreAppliedInRegistrationOrder(): void + { + $log = []; + $record = static function (string $name) use (&$log): NexusOperationInboundCallsInterceptor { + return new class($name, $log) implements NexusOperationInboundCallsInterceptor { + use NexusOperationInboundCallsInterceptorTrait; + + /** + * @param list $log + */ + public function __construct( + private readonly string $name, + private array &$log, + ) {} + + public function startOperation( + StartOperationInput $input, + callable $next, + ): OperationStartResult { + $this->log[] = "enter:{$this->name}"; + $result = $next($input); + $this->log[] = "exit:{$this->name}"; + return $result; + } + }; + }; + + $handler = ServiceHandler::create( + dataConverter: self::dataConverter(), + instances: [self::bindNexusService(new GreetingService(static fn($n) => "g-{$n}"))], + interceptorProvider: new SimplePipelineProvider([$record('A'), $record('B'), $record('C')]), + ); + + $handler->startOperation( + new OperationContext(service: 'GreetingServiceInterface', operation: 'sayHello1', env: $this->env), + new OperationStartDetails(requestId: 'r1'), + self::encode('User'), + null, + new NexusOperationContext(), + ); + + // First registered interceptor is the outermost. + self::assertSame( + ['enter:A', 'enter:B', 'enter:C', 'exit:C', 'exit:B', 'exit:A'], + $log, + ); + } + + public function testInterceptorCanOverrideHandlerResult(): void + { + $overriding = new class implements NexusOperationInboundCallsInterceptor { + use NexusOperationInboundCallsInterceptorTrait; + + public function startOperation( + StartOperationInput $input, + callable $next, + ): OperationStartResult { + \assert($input->input instanceof \Temporal\DataConverter\ValuesInterface); + return OperationStartResult::sync('rewritten-' . $input->input->getValue(0, 'string')); + } + }; + + $handler = ServiceHandler::create( + dataConverter: self::dataConverter(), + instances: [self::bindNexusService(new GreetingService(static fn($n) => "g-{$n}"))], + interceptorProvider: new SimplePipelineProvider([$overriding]), + ); + + $result = $handler->startOperation( + new OperationContext(service: 'GreetingServiceInterface', operation: 'sayHello1', env: $this->env), + new OperationStartDetails(requestId: 'r1'), + self::encode('Alice'), + null, + new NexusOperationContext(), + ); + + self::assertSame('rewritten-Alice', $result->value->getValue(0, 'string')); + } + + public function testInterceptorExceptionPropagates(): void + { + $exploding = new class implements NexusOperationInboundCallsInterceptor { + use NexusOperationInboundCallsInterceptorTrait; + + public function startOperation( + StartOperationInput $input, + callable $next, + ): OperationStartResult { + throw HandlerException::create(ErrorType::Unauthorized, 'blocked'); + } + }; + + $handler = ServiceHandler::create( + dataConverter: self::dataConverter(), + instances: [self::bindNexusService(new GreetingService(static fn($n) => "g-{$n}"))], + interceptorProvider: new SimplePipelineProvider([$exploding]), + ); + + $this->expectException(HandlerException::class); + $this->expectExceptionMessage('blocked'); + $handler->startOperation( + new OperationContext(service: 'GreetingServiceInterface', operation: 'sayHello1', env: $this->env), + new OperationStartDetails(requestId: 'r1'), + self::encode('X'), + null, + new NexusOperationContext(), + ); + } + + public function testInterceptorCanSwallowHandlerException(): void + { + $swallowing = new class implements NexusOperationInboundCallsInterceptor { + use NexusOperationInboundCallsInterceptorTrait; + + public function startOperation( + StartOperationInput $input, + callable $next, + ): OperationStartResult { + try { + return $next($input); + } catch (HandlerException) { + return OperationStartResult::sync('fallback'); + } + } + }; + + // The injected apiClient is invoked by the async sayHello2 path; we make it throw. + $handler = ServiceHandler::create( + dataConverter: self::dataConverter(), + instances: [self::bindNexusService(new GreetingService( + static fn(string $n): string => throw HandlerException::create(ErrorType::Internal, 'boom'), + ))], + interceptorProvider: new SimplePipelineProvider([$swallowing]), + ); + + $result = $handler->startOperation( + new OperationContext(service: 'GreetingServiceInterface', operation: 'sayHello2', env: $this->env), + new OperationStartDetails(requestId: 'r1'), + self::encode('X'), + null, + new NexusOperationContext(), + ); + + self::assertSame('fallback', $result->value->getValue(0, 'string')); + } + + public function testCancelInterceptorReceivesContext(): void + { + $seen = []; + $observer = new class($seen) implements NexusOperationInboundCallsInterceptor { + use NexusOperationInboundCallsInterceptorTrait; + + /** + * @param list $seen + */ + public function __construct(private array &$seen) {} + + public function cancelOperation(CancelOperationInput $input, callable $next): void + { + $this->seen[] = "{$input->operationContext->operation}:{$input->cancelDetails->operationToken}"; + $next($input); + } + }; + + $handler = ServiceHandler::create( + dataConverter: self::dataConverter(), + instances: [self::bindNexusService(new GreetingService(static fn($n) => "g-{$n}"))], + interceptorProvider: new SimplePipelineProvider([$observer]), + ); + + $started = $handler->startOperation( + new OperationContext(service: 'GreetingServiceInterface', operation: 'sayHello2', env: $this->env), + new OperationStartDetails(requestId: 'r1'), + self::encode('User'), + $this->asyncClient(), + new NexusOperationContext(self::NS, self::TQ), + ); + + $token = $started->info->token; + self::assertNotNull($token); + + $handler->cancelOperation( + new OperationContext(service: 'GreetingServiceInterface', operation: 'sayHello2', env: $this->env), + new \Temporal\Nexus\Handler\OperationCancelDetails(operationToken: $token), + $this->asyncClient(), + new NexusOperationContext('test-ns', 'test-tq'), + ); + + self::assertSame(["sayHello2:{$token}"], $seen); + } +} diff --git a/tests/Nexus/Unit/Handler/ServiceHandlerSerdeErrorsTest.php b/tests/Nexus/Unit/Handler/ServiceHandlerSerdeErrorsTest.php new file mode 100644 index 000000000..62998e6ec --- /dev/null +++ b/tests/Nexus/Unit/Handler/ServiceHandlerSerdeErrorsTest.php @@ -0,0 +1,231 @@ + $handler->startOperation( + self::newContext('sayHello1'), + new OperationStartDetails(requestId: 'r1'), + self::input($converter), + null, + new NexusOperationContext(), + )); + + self::assertStringContainsString( + 'Failed deserializing input for GreetingServiceInterface/sayHello1 as string: Bad JSON', + $e->getMessage(), + ); + self::assertSame(ErrorType::BadRequest, $e->errorType); + self::assertInstanceOf(\JsonException::class, $e->getPrevious()); + } + + public function testSerializeFailureWrapsAsInternalHandlerException(): void + { + $converter = self::failingSerializeConverter(); + $handler = self::newHandler($converter); + + $e = self::assertThrown(HandlerException::class, static fn() => $handler->startOperation( + self::newContext('sayHello1'), + new OperationStartDetails(requestId: 'r1'), + self::input($converter), + null, + new NexusOperationContext(), + )); + + self::assertStringContainsString( + 'Failed serializing result for GreetingServiceInterface/sayHello1 as string: cannot serialize', + $e->getMessage(), + ); + self::assertSame(ErrorType::Internal, $e->errorType); + self::assertInstanceOf(\RuntimeException::class, $e->getPrevious()); + } + + public function testOperationExceptionFromHandlerIsNotWrapped(): void + { + $converter = DataConverter::createDefault(); + $handler = self::newHandler( + $converter, + new ThrowingGreetingService(hello1Throw: OperationException::failed('intentional business failure')), + ); + + $this->expectException(OperationException::class); + $this->expectExceptionMessage('intentional business failure'); + $handler->startOperation( + self::newContext('sayHello1'), + new OperationStartDetails(requestId: 'r1'), + EncodedValues::fromValues(['anything'], $converter), + null, + new NexusOperationContext(), + ); + } + + public function testHandlerExceptionFromHandlerIsNotDoubleWrapped(): void + { + $converter = DataConverter::createDefault(); + $handler = self::newHandler( + $converter, + new ThrowingGreetingService(hello1Throw: HandlerException::create( + ErrorType::Unauthorized, + 'no auth', + retryBehavior: RetryBehavior::NonRetryable, + )), + ); + + $e = self::assertThrown(HandlerException::class, static fn() => $handler->startOperation( + self::newContext('sayHello1'), + new OperationStartDetails(requestId: 'r1'), + EncodedValues::fromValues(['anything'], $converter), + null, + new NexusOperationContext(), + )); + + self::assertSame(ErrorType::Unauthorized, $e->errorType); + self::assertSame('no auth', $e->getMessage()); + self::assertSame(RetryBehavior::NonRetryable, $e->retryBehavior); + } + + public function testDeserializeErrorMessageContainsServiceOperationAndType(): void + { + $converter = self::failingDeserializeConverter(); + $handler = self::newHandler($converter); + + $e = self::assertThrown(HandlerException::class, static fn() => $handler->startOperation( + self::newContext('sayHello2'), + new OperationStartDetails(requestId: 'r1'), + self::input($converter), + null, + new NexusOperationContext(), + )); + + self::assertStringContainsString('GreetingServiceInterface', $e->getMessage()); + self::assertStringContainsString('sayHello2', $e->getMessage()); + self::assertStringContainsString('string', $e->getMessage()); + } + + public function testSerializeErrorMessageContainsOutputType(): void + { + $converter = self::failingSerializeConverter(); + $handler = self::newHandler($converter); + + $e = self::assertThrown(HandlerException::class, static fn() => $handler->startOperation( + self::newContext('sayHello1'), + new OperationStartDetails(requestId: 'r1'), + self::input($converter), + null, + new NexusOperationContext(), + )); + + self::assertStringContainsString('as string', $e->getMessage()); + } + + private static function newHandler( + DataConverterInterface $dataConverter, + ?ThrowingGreetingService $instance = null, + ): ServiceHandler { + return ServiceHandler::create( + dataConverter: $dataConverter, + instances: [self::bindNexusService($instance ?? new ThrowingGreetingService())], + ); + } + + private static function newContext(string $operation): OperationContext + { + return new OperationContext( + service: 'GreetingServiceInterface', + operation: $operation, + env: self::$env, + ); + } + + /** + * Wrap a single dummy proto Payload so ServiceHandler routes through + * `DataConverterInterface::fromPayload()` (the path that may raise). + */ + private static function input(DataConverterInterface $dataConverter): EncodedValues + { + $payload = new Payload(); + $payload->setData('garbage'); + $payloads = new \Temporal\Api\Common\V1\Payloads(['payloads' => [$payload]]); + return EncodedValues::fromPayloads($payloads, $dataConverter); + } + + private static function failingDeserializeConverter(): DataConverterInterface + { + return new class implements DataConverterInterface { + public function fromPayload(Payload $payload, mixed $type): mixed + { + throw new \JsonException('Bad JSON'); + } + + public function toPayload(mixed $value): Payload + { + $p = new Payload(); + $p->setData((string) $value); + return $p; + } + }; + } + + private static function failingSerializeConverter(): DataConverterInterface + { + return new class implements DataConverterInterface { + public function fromPayload(Payload $payload, mixed $type): mixed + { + return $payload->getData(); + } + + public function toPayload(mixed $value): Payload + { + throw new \RuntimeException('cannot serialize'); + } + }; + } +} diff --git a/tests/Nexus/Unit/Handler/ServiceHandlerTest.php b/tests/Nexus/Unit/Handler/ServiceHandlerTest.php new file mode 100644 index 000000000..8b19cc4cc --- /dev/null +++ b/tests/Nexus/Unit/Handler/ServiceHandlerTest.php @@ -0,0 +1,286 @@ +env = new Environment(); + } + + public function testVoidService(): void + { + $serviceInstance = self::bindNexusService(new VoidService()); + self::assertCount(1, $serviceInstance->operationHandlers); + } + + public function testSyncHandlerReturnsSyncResult(): void + { + $handler = self::newGreetingHandler(); + + $result = $handler->startOperation( + $this->newGreetingContext('sayHello1'), + new OperationStartDetails(requestId: 'r1'), + self::encode('SomeUser'), + null, + new NexusOperationContext(), + ); + + self::assertSame('Hello, SomeUser!', $result->value->getValue(0, 'string')); + } + + public function testAsyncHandlerReturnsToken(): void + { + $handler = self::newGreetingHandler(); + + $result = $handler->startOperation( + $this->newGreetingContext('sayHello2'), + new OperationStartDetails(requestId: 'r3'), + self::encode('SomeUser'), + $this->asyncClient(), + $this->asyncOperationContext(), + ); + + $expectedToken = WorkflowRunOperationToken::generate(self::NS, GreetingService::WORKFLOW_ID); + self::assertSame($expectedToken, $result->info->token); + } + + public function testAsyncHandlerWithoutWorkflowClientSurfacesLogicException(): void + { + $handler = self::newGreetingHandler(); + + $e = self::assertThrown(\LogicException::class, fn() => $handler->startOperation( + $this->newGreetingContext('sayHello2'), + new OperationStartDetails(requestId: 'r3'), + self::encode('SomeUser'), + null, + $this->asyncOperationContext(), + )); + + self::assertSame( + 'Nexus::getWorkflowClient() requires a WorkflowClient. Async Nexus operations (WorkflowRunOperation)' + . ' need cluster access; provide a WorkflowClient to the WorkerFactory.', + $e->getMessage(), + ); + } + + public function testAsyncHandlerCollectsLinksOnLinkSuffixedInput(): void + { + $handler = self::newGreetingHandler(); + $context = $this->newGreetingContext('sayHello2'); + + $result = $handler->startOperation( + $context, + new OperationStartDetails(requestId: 'r4'), + self::encode('SomeUser-link'), + $this->asyncClient(), + $this->asyncOperationContext(), + ); + + self::assertNotNull($result->info->token); + $links = $context->links->all(); + self::assertCount(2, $links); + self::assertSame('http://somepath?k=v', $links[0]->uri); + self::assertSame('com.example.MyResource', $links[0]->type); + self::assertSame( + 'temporal:///namespaces/sample-ns/workflows/greeting-workflow/run-1/history' + . '?referenceType=EventReference&eventType=WorkflowExecutionStarted', + $links[1]->uri, + ); + self::assertSame('temporal.api.common.v1.Link.WorkflowEvent', $links[1]->type); + } + + public function testAuthInterceptorAllowsCallWithValidToken(): void + { + $token = 'auth-token'; + $handler = self::newGreetingHandler(new SimplePipelineProvider([ + new AuthInterceptor($token), + new LoggingInterceptor(), + ])); + + $result = $handler->startOperation( + new OperationContext( + service: 'GreetingServiceInterface', + operation: 'sayHello1', + env: $this->env, + headers: [AuthInterceptor::AUTH_HEADER => $token], + ), + new OperationStartDetails(requestId: 'r1'), + self::encode('SomeUser'), + null, + new NexusOperationContext(), + ); + + self::assertSame('Hello, SomeUser!', $result->value->getValue(0, 'string')); + } + + public function testAuthInterceptorRejectsCallWithMissingToken(): void + { + $handler = self::newGreetingHandler(new SimplePipelineProvider([ + new AuthInterceptor('auth-token'), + new LoggingInterceptor(), + ])); + + $this->expectException(HandlerException::class); + $handler->startOperation( + new OperationContext( + service: 'GreetingServiceInterface', + operation: 'sayHello1', + env: $this->env, + ), + new OperationStartDetails(requestId: 'r2'), + self::encode('SomeUser'), + null, + new NexusOperationContext(), + ); + } + + public function testLoggingInterceptorRecordsAuthorizedCallsOnly(): void + { + $token = 'auth-token'; + $logger = new LoggingInterceptor(); + $handler = self::newGreetingHandler(new SimplePipelineProvider([ + new AuthInterceptor($token), + $logger, + ])); + + $handler->startOperation( + new OperationContext( + service: 'GreetingServiceInterface', + operation: 'sayHello1', + env: $this->env, + headers: [AuthInterceptor::AUTH_HEADER => $token], + ), + new OperationStartDetails(requestId: 'r1'), + self::encode('SomeUser'), + null, + new NexusOperationContext(), + ); + + // Auth is before logging, so an unauthorized call never reaches the logger. + self::assertSame(['sayHello1'], $logger->getOperations()); + } + + public function testUnrecognizedService(): void + { + $handler = ServiceHandler::create( + dataConverter: self::dataConverter(), + instances: [self::bindNexusService(new VoidService())], + ); + + $e = self::assertThrown(HandlerException::class, fn() => $handler->startOperation( + new OperationContext(service: 'NonExistent', operation: 'op', env: $this->env), + new OperationStartDetails(requestId: 'r1'), + EncodedValues::empty(), + null, + new NexusOperationContext(), + )); + + self::assertSame(ErrorType::NotFound, $e->errorType); + self::assertStringContainsString("Unrecognized service 'NonExistent'", $e->getMessage()); + } + + public function testUnrecognizedOperation(): void + { + $handler = ServiceHandler::create( + dataConverter: self::dataConverter(), + instances: [self::bindNexusService(new VoidService())], + ); + + $e = self::assertThrown(HandlerException::class, fn() => $handler->startOperation( + new OperationContext(service: 'VoidServiceInterface', operation: 'nonExistent', env: $this->env), + new OperationStartDetails(requestId: 'r1'), + EncodedValues::empty(), + null, + new NexusOperationContext(), + )); + + self::assertSame(ErrorType::NotFound, $e->errorType); + self::assertStringContainsString("has no operation 'nonExistent'", $e->getMessage()); + } + + public function testCancelOnSyncHandlerThrowsNotImplemented(): void + { + $handler = ServiceHandler::create( + dataConverter: self::dataConverter(), + instances: [self::bindNexusService(new VoidService())], + ); + + $this->expectException(HandlerException::class); + $this->expectExceptionMessage('synchronous and cannot be cancelled'); + $handler->cancelOperation( + new OperationContext(service: 'VoidServiceInterface', operation: 'operation', env: $this->env), + new OperationCancelDetails(operationToken: 'some-token'), + null, + new NexusOperationContext(), + ); + } + + private static function newGreetingHandler(?PipelineProvider $interceptorProvider = null): ServiceHandler + { + $apiClient = static fn(string $name): string => "greeting-{$name}"; + + return ServiceHandler::create( + dataConverter: self::dataConverter(), + instances: [self::bindNexusService(new GreetingService($apiClient))], + interceptorProvider: $interceptorProvider ?? new SimplePipelineProvider(), + ); + } + + private function newGreetingContext(string $operation): OperationContext + { + return new OperationContext(service: 'GreetingServiceInterface', operation: $operation, env: $this->env); + } + + private function asyncOperationContext(): NexusOperationContext + { + return new NexusOperationContext(self::NS, self::TQ); + } +} diff --git a/tests/Nexus/Unit/HeaderTest.php b/tests/Nexus/Unit/HeaderTest.php new file mode 100644 index 000000000..02e20b4aa --- /dev/null +++ b/tests/Nexus/Unit/HeaderTest.php @@ -0,0 +1,137 @@ + 'abc123']; // already lowercase + + self::assertSame('abc123', Header::get($headers, Header::OPERATION_TOKEN)); + self::assertSame('abc123', Header::get($headers, 'NEXUS-OPERATION-TOKEN')); + self::assertSame('abc123', Header::get($headers, 'nexus-operation-token')); + self::assertNull(Header::get($headers, 'x-unknown')); + } + + public function testGetReturnsNullOnEmptyBag(): void + { + self::assertNull(Header::get([], Header::REQUEST_ID)); + } + + // ── parseTimeout() ────────────────────────────────────────────── + + public function testParseTimeoutMilliseconds(): void + { + $i = Header::parseTimeout('250ms'); + + self::assertNotNull($i); + // DateInterval from 'milliseconds' carries the fraction in ->f. + self::assertEqualsWithDelta(0.25, $i->s + $i->f, 0.001); + } + + public function testParseTimeoutSeconds(): void + { + $i = Header::parseTimeout('30s'); + + self::assertNotNull($i); + self::assertSame(30, $i->s); + } + + public function testParseTimeoutMinutes(): void + { + $i = Header::parseTimeout('2m'); + + self::assertNotNull($i); + self::assertSame(2, $i->i); + } + + public function testParseTimeoutReturnsNullForEmpty(): void + { + self::assertNull(Header::parseTimeout('')); + self::assertNull(Header::parseTimeout(' ')); + } + + /** + * @return iterable + */ + public static function malformedTimeoutProvider(): iterable + { + yield 'unknown unit us' => ['30us']; + yield 'no unit' => ['30']; + yield 'bare number' => ['5']; + yield 'only unit' => ['ms']; + yield 'pure garbage' => ['abc']; + yield 'trailing junk' => ['12x']; + } + + #[DataProvider('malformedTimeoutProvider')] + public function testParseTimeoutRejectsMalformed(string $input): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/Invalid Nexus timeout/'); + + Header::parseTimeout($input); + } + + // ── deadlineFromTimeout() ─────────────────────────────────────── + + public function testDeadlineFromTimeoutAddsIntervalToNow(): void + { + $now = new \DateTimeImmutable('2026-01-01T00:00:00Z'); + + $deadline = Header::deadlineFromTimeout('30s', $now); + + self::assertNotNull($deadline); + self::assertSame('2026-01-01T00:00:30+00:00', $deadline->format('c')); + } + + public function testDeadlineFromTimeoutReturnsNullForEmpty(): void + { + self::assertNull(Header::deadlineFromTimeout('')); + } + + public function testDeadlineFromTimeoutThrowsOnMalformed(): void + { + $this->expectException(InvalidArgumentException::class); + Header::deadlineFromTimeout('garbage'); + } + +} diff --git a/tests/Nexus/Unit/Interceptor/CancelOperationInputTest.php b/tests/Nexus/Unit/Interceptor/CancelOperationInputTest.php new file mode 100644 index 000000000..0b3bd9054 --- /dev/null +++ b/tests/Nexus/Unit/Interceptor/CancelOperationInputTest.php @@ -0,0 +1,65 @@ +env = new Environment(); + } + + public function testWithReturnsNewInstanceKeepingValues(): void + { + $input = $this->makeInput(); + $copy = $input->with(); + + self::assertNotSame($input, $copy); + self::assertSame($input->operationContext, $copy->operationContext); + self::assertSame($input->cancelDetails, $copy->cancelDetails); + } + + public function testWithOverridesOperationContextOnly(): void + { + $input = $this->makeInput(); + $newContext = new OperationContext('other-service', 'other-operation', $this->env); + + $copy = $input->with(operationContext: $newContext); + + self::assertSame($newContext, $copy->operationContext); + self::assertSame($input->cancelDetails, $copy->cancelDetails); + } + + public function testWithOverridesCancelDetailsOnly(): void + { + $input = $this->makeInput(); + $newDetails = new OperationCancelDetails('other-token'); + + $copy = $input->with(cancelDetails: $newDetails); + + self::assertSame($input->operationContext, $copy->operationContext); + self::assertSame($newDetails, $copy->cancelDetails); + } + + private function makeInput(): CancelOperationInput + { + return new CancelOperationInput( + new OperationContext('service', 'operation', $this->env), + new OperationCancelDetails('operation-token'), + ); + } +} diff --git a/tests/Nexus/Unit/Interceptor/StartOperationInputTest.php b/tests/Nexus/Unit/Interceptor/StartOperationInputTest.php new file mode 100644 index 000000000..311d64d3b --- /dev/null +++ b/tests/Nexus/Unit/Interceptor/StartOperationInputTest.php @@ -0,0 +1,80 @@ +env = new Environment(); + } + + public function testWithReturnsNewInstanceKeepingValues(): void + { + $input = $this->makeInput(); + $copy = $input->with(); + + self::assertNotSame($input, $copy); + self::assertSame($input->operationContext, $copy->operationContext); + self::assertSame($input->startDetails, $copy->startDetails); + self::assertSame($input->input, $copy->input); + } + + public function testWithOverridesOperationContextOnly(): void + { + $input = $this->makeInput(); + $newContext = new OperationContext('other-service', 'other-operation', $this->env); + + $copy = $input->with(operationContext: $newContext); + + self::assertSame($newContext, $copy->operationContext); + self::assertSame($input->startDetails, $copy->startDetails); + self::assertSame($input->input, $copy->input); + } + + public function testWithOverridesStartDetailsOnly(): void + { + $input = $this->makeInput(); + $newDetails = new OperationStartDetails('other-request-id'); + + $copy = $input->with(startDetails: $newDetails); + + self::assertSame($input->operationContext, $copy->operationContext); + self::assertSame($newDetails, $copy->startDetails); + self::assertSame($input->input, $copy->input); + } + + public function testWithOverridesInputOnly(): void + { + $input = $this->makeInput(); + + $copy = $input->with(input: 'changed'); + + self::assertSame($input->operationContext, $copy->operationContext); + self::assertSame($input->startDetails, $copy->startDetails); + self::assertSame('changed', $copy->input); + } + + private function makeInput(): StartOperationInput + { + return new StartOperationInput( + new OperationContext('service', 'operation', $this->env), + new OperationStartDetails('request-id'), + 'original-input', + ); + } +} diff --git a/tests/Nexus/Unit/Internal/Failure/NexusFailureConverterTest.php b/tests/Nexus/Unit/Internal/Failure/NexusFailureConverterTest.php new file mode 100644 index 000000000..a587e0e51 --- /dev/null +++ b/tests/Nexus/Unit/Internal/Failure/NexusFailureConverterTest.php @@ -0,0 +1,46 @@ + */ + public static function retryBehaviorMatrix(): iterable + { + yield 'Unspecified' => [ + RetryBehavior::Unspecified, + NexusHandlerErrorRetryBehavior::NEXUS_HANDLER_ERROR_RETRY_BEHAVIOR_UNSPECIFIED, + ]; + yield 'Retryable' => [ + RetryBehavior::Retryable, + NexusHandlerErrorRetryBehavior::NEXUS_HANDLER_ERROR_RETRY_BEHAVIOR_RETRYABLE, + ]; + yield 'NonRetryable' => [ + RetryBehavior::NonRetryable, + NexusHandlerErrorRetryBehavior::NEXUS_HANDLER_ERROR_RETRY_BEHAVIOR_NON_RETRYABLE, + ]; + } + + #[DataProvider('retryBehaviorMatrix')] + public function testMapRetryBehavior(RetryBehavior $behavior, int $expectedProto): void + { + self::assertSame($expectedProto, NexusFailureConverter::mapRetryBehavior($behavior)); + } +} diff --git a/tests/Nexus/Unit/Internal/HeadersTest.php b/tests/Nexus/Unit/Internal/HeadersTest.php new file mode 100644 index 000000000..1d0139393 --- /dev/null +++ b/tests/Nexus/Unit/Internal/HeadersTest.php @@ -0,0 +1,50 @@ + 'bar', 'baz' => 'qux'], + Headers::normalize(['FOO' => 'bar', 'Baz' => 'qux']), + ); + } + + public function testNormalizePreservesValueCase(): void + { + self::assertSame( + ['content-type' => 'Application/JSON'], + Headers::normalize(['Content-Type' => 'Application/JSON']), + ); + } + + public function testNormalizeEmpty(): void + { + self::assertSame([], Headers::normalize([])); + } + + public function testNormalizeLastValueWinsOnCaseCollision(): void + { + // Same key with different casing — last one wins after normalization. + self::assertSame( + ['x-custom' => 'second'], + Headers::normalize(['X-Custom' => 'first', 'x-custom' => 'second']), + ); + } +} diff --git a/tests/Nexus/Unit/LinkParserTest.php b/tests/Nexus/Unit/LinkParserTest.php new file mode 100644 index 000000000..1987368c3 --- /dev/null +++ b/tests/Nexus/Unit/LinkParserTest.php @@ -0,0 +1,160 @@ + 'https://a/1', 'type' => 'com.example.A'], + ['url' => 'https://a/2', 'type' => 'com.example.B'], + ]); + + self::assertCount(2, $links); + self::assertContainsOnlyInstancesOf(Link::class, $links); + self::assertSame('https://a/1', $links[0]->uri); + self::assertSame('com.example.A', $links[0]->type); + self::assertSame('https://a/2', $links[1]->uri); + self::assertSame('com.example.B', $links[1]->type); + } + + public function testFromRawRejectsNonArrayPayload(): void + { + $e = self::assertThrown(HandlerException::class, static fn() => LinkParser::fromRaw('not-an-array')); + + self::assertSame(ErrorType::BadRequest, $e->errorType); + self::assertStringContainsString('must be an array', $e->getMessage()); + } + + public function testFromRawRejectsNonObjectEntry(): void + { + $e = self::assertThrown( + HandlerException::class, + static fn() => LinkParser::fromRaw([['url' => 'https://a', 'type' => 't'], 'bad']), + ); + + self::assertSame(ErrorType::BadRequest, $e->errorType); + self::assertStringContainsString('index 1 is not an object', $e->getMessage()); + } + + public function testFromRawRejectsMissingUrl(): void + { + $e = self::assertThrown(HandlerException::class, static fn() => LinkParser::fromRaw([['type' => 't']])); + + self::assertSame(ErrorType::BadRequest, $e->errorType); + self::assertStringContainsString('"url"', $e->getMessage()); + } + + public function testFromRawRejectsMissingType(): void + { + $e = self::assertThrown(HandlerException::class, static fn() => LinkParser::fromRaw([['url' => 'https://a']])); + + self::assertSame(ErrorType::BadRequest, $e->errorType); + self::assertStringContainsString('"type"', $e->getMessage()); + } + + public function testFromRawRejectsEmptyUrl(): void + { + $this->expectException(HandlerException::class); + $this->expectExceptionMessage('Nexus link at index 0 has missing or empty "url"'); + LinkParser::fromRaw([['url' => '', 'type' => 't']]); + } + + public function testFromRawRejectsNonStringUrl(): void + { + $this->expectException(HandlerException::class); + $this->expectExceptionMessage('Nexus link at index 0 has missing or empty "url"'); + LinkParser::fromRaw([['url' => 42, 'type' => 't']]); + } + + public function testFromRawRejectsEmptyType(): void + { + $this->expectException(HandlerException::class); + $this->expectExceptionMessage('Nexus link at index 0 has missing or empty "type"'); + LinkParser::fromRaw([['url' => 'https://a', 'type' => '']]); + } + + public function testFromRawStringIndexInError(): void + { + $this->expectException(HandlerException::class); + $this->expectExceptionMessage("Nexus link at index 'key1' is not an object (got string)"); + LinkParser::fromRaw(['key1' => 'bad']); + } + + public function testFromProtoEmpty(): void + { + self::assertSame([], LinkParser::fromProto([])); + } + + public function testFromProtoHappyPath(): void + { + $proto = (new \Temporal\Api\Nexus\V1\Link()) + ->setUrl('https://p/1') + ->setType('com.example.P'); + + $links = LinkParser::fromProto([$proto, $proto]); + + self::assertCount(2, $links); + self::assertSame('https://p/1', $links[0]->uri); + self::assertSame('com.example.P', $links[0]->type); + } + + public function testFromProtoRejectsEmptyUrl(): void + { + $proto = (new \Temporal\Api\Nexus\V1\Link())->setType('com.example.P'); + + $this->expectException(HandlerException::class); + $this->expectExceptionMessage('Nexus link at proto index 0 has missing or empty "url"'); + LinkParser::fromProto([$proto]); + } + + public function testFromProtoIndexAdvances(): void + { + $good = (new \Temporal\Api\Nexus\V1\Link()) + ->setUrl('https://ok') + ->setType('t'); + $bad = (new \Temporal\Api\Nexus\V1\Link()) + ->setUrl('https://ok'); + + $this->expectException(HandlerException::class); + $this->expectExceptionMessage('Nexus link at proto index 2 has missing or empty "type"'); + LinkParser::fromProto([$good, $good, $bad]); + } +} diff --git a/tests/Nexus/Unit/LinkTest.php b/tests/Nexus/Unit/LinkTest.php new file mode 100644 index 000000000..7d8bf080b --- /dev/null +++ b/tests/Nexus/Unit/LinkTest.php @@ -0,0 +1,69 @@ +uri); + self::assertSame('com.example.MyResource', $link->type); + } + + public function testToString(): void + { + $link = new Link('http://example.com', 'MyType'); + $str = (string) $link; + self::assertStringContainsString('http://example.com', $str); + self::assertStringContainsString('MyType', $str); + } + + public function testEquality(): void + { + $link1 = new Link('http://a.com', 'TypeA'); + $link2 = new Link('http://a.com', 'TypeA'); + $link3 = new Link('http://b.com', 'TypeB'); + + self::assertEquals($link1, $link2); + self::assertNotEquals($link1, $link3); + } + + // ── Constructor validation ────────────────────────────────────── + + public function testEmptyUriRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Link URI must not be empty'); + new Link('', 'some-type'); + } + + public function testEmptyTypeRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Link type must not be empty'); + new Link('http://example.com', ''); + } + + public function testConstructorAcceptsRelativeUri(): void + { + // The plain constructor is intentionally permissive — only non-emptiness is enforced. + $link = new Link('/just-a-path', 'some-type'); + self::assertSame('/just-a-path', $link->uri); + } +} diff --git a/tests/Nexus/Unit/NexusFacadeTest.php b/tests/Nexus/Unit/NexusFacadeTest.php new file mode 100644 index 000000000..bf4df67ac --- /dev/null +++ b/tests/Nexus/Unit/NexusFacadeTest.php @@ -0,0 +1,161 @@ +service->capturedCancelDetails = Nexus::getCancelDetails(); + } +} + +#[CoversClass(Nexus::class)] +final class NexusFacadeTest extends TestCase +{ + use BindNexusService; + use EncodesValues; + use ExceptionAssertions; + + private EnvironmentInterface $env; + + protected function setUp(): void + { + parent::setUp(); + $this->env = new Environment(); + } + + public function testGetCancelDetailsOutsideAnyDispatchThrows(): void + { + $e = self::assertThrown(\LogicException::class, static fn() => Nexus::getCancelDetails()); + + self::assertStringContainsString('only inside a Nexus operation handler', $e->getMessage()); + } + + public function testGetCancelDetailsDuringStartDispatchThrows(): void + { + $handler = $this->handler(); + + $e = self::assertThrown(\LogicException::class, fn() => $handler->startOperation( + $this->context('probeCancelDetails'), + new OperationStartDetails(requestId: 'r1'), + self::encode('x'), + null, + new NexusOperationContext(), + )); + + self::assertStringContainsString('outside a cancel-operation dispatch', $e->getMessage()); + } + + public function testGetCancelDetailsAvailableInsideCancelDispatch(): void + { + $service = new FacadeProbeService(); + $this->handler($service)->cancelOperation( + $this->context('startJob'), + new OperationCancelDetails(operationToken: 'job-42'), + null, + new NexusOperationContext(), + ); + + self::assertNotNull($service->capturedCancelDetails); + self::assertSame('job-42', $service->capturedCancelDetails->operationToken); + } + + public function testGetWorkflowClientWithoutClientThrows(): void + { + $handler = $this->handler(); + + $e = self::assertThrown(\LogicException::class, fn() => $handler->startOperation( + $this->context('probeWorkflowClient'), + new OperationStartDetails(requestId: 'r1'), + self::encode('x'), + null, + new NexusOperationContext(), + )); + + self::assertStringContainsString('Nexus::getWorkflowClient() requires a WorkflowClient', $e->getMessage()); + } + + private function handler(?FacadeProbeService $service = null): ServiceHandler + { + return ServiceHandler::create( + dataConverter: self::dataConverter(), + instances: [self::bindNexusService($service ?? new FacadeProbeService())], + ); + } + + private function context(string $operation): OperationContext + { + return new OperationContext(service: 'FacadeProbeService', operation: $operation, env: $this->env); + } +} diff --git a/tests/Nexus/Unit/NexusOperationContextTest.php b/tests/Nexus/Unit/NexusOperationContextTest.php new file mode 100644 index 000000000..ed0c1e7eb --- /dev/null +++ b/tests/Nexus/Unit/NexusOperationContextTest.php @@ -0,0 +1,36 @@ +namespace); + self::assertSame('tq', $ctx->taskQueue); + } + + public function testDefaultsAreEmpty(): void + { + $ctx = new NexusOperationContext(); + + self::assertSame('', $ctx->namespace); + self::assertSame('', $ctx->taskQueue); + } +} diff --git a/tests/Nexus/Unit/NexusOperationTypesTest.php b/tests/Nexus/Unit/NexusOperationTypesTest.php new file mode 100644 index 000000000..2984664ac --- /dev/null +++ b/tests/Nexus/Unit/NexusOperationTypesTest.php @@ -0,0 +1,101 @@ +fromClass(NullableInputServiceInterface::class); + + self::assertArrayHasKey('operation', $proto->getOperations()); + $op = $proto->getOperations()['operation']; + self::assertSame('string', $op->inputType->getName()); + self::assertTrue($op->inputType->allowsNull()); + self::assertSame('string', $op->outputType->getName()); + self::assertTrue($op->outputType->allowsNull()); + } + + public function testUnionInputFallsBackToMixed(): void + { + $proto = self::reader()->fromClass(UnionInputServiceInterface::class); + + self::assertSame('mixed', $proto->getOperations()['operation']->inputType->getName()); + } + + public function testUnionOutputFallsBackToMixed(): void + { + $proto = self::reader()->fromClass(UnionOutputServiceInterface::class); + + self::assertSame('mixed', $proto->getOperations()['operation']->outputType->getName()); + } + + public function testIntersectionInputFallsBackToMixed(): void + { + $proto = self::reader()->fromClass(IntersectionInputServiceInterface::class); + + self::assertSame('mixed', $proto->getOperations()['operation']->inputType->getName()); + } + + public function testUntypedParameterFallsBackToMixed(): void + { + $proto = self::reader()->fromClass(UntypedInputServiceInterface::class); + + $op = $proto->getOperations()['operation']; + self::assertSame('mixed', $op->inputType->getName()); + self::assertSame('string', $op->outputType->getName()); + } + + public function testRejectsTooManyParameters(): void + { + $iface = new #[\Temporal\Nexus\Attribute\Service] class { + #[\Temporal\Nexus\Attribute\Operation] + public function bad(string $a, string $b): void {} + }; + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Can have no more than one parameter'); + self::reader()->fromClass($iface::class); + } + + public function testRejectsStaticOperation(): void + { + $iface = new #[\Temporal\Nexus\Attribute\Service] class { + #[\Temporal\Nexus\Attribute\Operation] + public static function staticOp(): void {} + }; + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot be static'); + self::reader()->fromClass($iface::class); + } + + private static function reader(): NexusServiceReader + { + return new NexusServiceReader(new AttributeReader()); + } +} diff --git a/tests/Nexus/Unit/NexusOutboundInterceptorTest.php b/tests/Nexus/Unit/NexusOutboundInterceptorTest.php new file mode 100644 index 000000000..5cb062cab --- /dev/null +++ b/tests/Nexus/Unit/NexusOutboundInterceptorTest.php @@ -0,0 +1,121 @@ +env = new Environment(); + } + + protected function tearDown(): void + { + Nexus::setCurrentContext(null); + parent::tearDown(); + } + + public function testGetInfoPassesThroughInterceptorChain(): void + { + $log = []; + $record = static function (string $name) use (&$log): NexusOperationOutboundCallsInterceptor { + return new class($name, $log) implements NexusOperationOutboundCallsInterceptor { + use NexusOperationOutboundCallsInterceptorTrait; + + /** @param list $log */ + public function __construct( + private readonly string $name, + private array &$log, + ) {} + + public function getInfo(GetInfoInput $input, callable $next): NexusOperationContext + { + $this->log[] = "enter:{$this->name}"; + $info = $next($input); + $this->log[] = "exit:{$this->name}"; + return $info; + } + }; + }; + + $info = new NexusOperationContext('ns', 'tq'); + Nexus::setCurrentContext(new NexusContext( + current: new OperationContext(service: 'svc', operation: 'op', env: $this->env), + operation: $info, + outboundPipeline: Pipeline::prepare([$record('A'), $record('B')]), + )); + + $result = Nexus::getOperationContext(); + + self::assertSame($info, $result); + self::assertSame(['enter:A', 'enter:B', 'exit:B', 'exit:A'], $log); + } + + public function testInterceptorCanRewriteReturnedInfo(): void + { + $rewriting = new class implements NexusOperationOutboundCallsInterceptor { + use NexusOperationOutboundCallsInterceptorTrait; + + public function getInfo(GetInfoInput $input, callable $next): NexusOperationContext + { + $next($input); + return new NexusOperationContext('rewritten-ns', 'rewritten-tq'); + } + }; + + Nexus::setCurrentContext(new NexusContext( + current: new OperationContext(service: 'svc', operation: 'op', env: $this->env), + operation: new NexusOperationContext('ns', 'tq'), + outboundPipeline: Pipeline::prepare([$rewriting]), + )); + + $result = Nexus::getOperationContext(); + + self::assertSame('rewritten-ns', $result->namespace); + self::assertSame('rewritten-tq', $result->taskQueue); + } + + public function testGetInfoReturnsInfoDirectlyWithoutPipeline(): void + { + $info = new NexusOperationContext('ns', 'tq'); + Nexus::setCurrentContext(new NexusContext( + current: new OperationContext(service: 'svc', operation: 'op', env: $this->env), + operation: $info, + )); + + self::assertSame($info, Nexus::getOperationContext()); + } + + public function testGetInfoThrowsOutsideDispatch(): void + { + $this->expectException(\LogicException::class); + Nexus::getOperationContext(); + } +} diff --git a/tests/Nexus/Unit/NexusServiceReaderTest.php b/tests/Nexus/Unit/NexusServiceReaderTest.php new file mode 100644 index 000000000..9a7a6b65d --- /dev/null +++ b/tests/Nexus/Unit/NexusServiceReaderTest.php @@ -0,0 +1,204 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing #[Service] attribute'); + self::reader()->fromClass(InvalidServiceNoAnnotation::class); + } + + public function testServiceAttributeOnClassIsAccepted(): void + { + $proto = self::reader()->fromClass(ServiceAsClass::class); + + self::assertSame('ServiceAsClass', $proto->getID()); + self::assertCount(1, $proto->getOperations()); + self::assertArrayHasKey('classOperation', $proto->getOperations()); + self::assertSame('string', $proto->getOperations()['classOperation']->inputType->getName()); + self::assertSame('string', $proto->getOperations()['classOperation']->outputType->getName()); + } + + public function testMultipleServiceInterfacesAreAmbiguous(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('implements multiple #[Service] types'); + self::reader()->fromClass(AmbiguousServiceImpl::class); + } + + public function testInvalidSubServiceNameMismatch(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('does not match the expected name on the contract'); + self::reader()->fromClass(InvalidSubService::class); + } + + public function testInvalidServiceWithOperations(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('operation(s) were invalid'); + self::reader()->fromClass(InvalidServiceWithOperations::class); + } + + public function testNonPublicOperationMethodIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Must be public'); + self::reader()->fromClass(NonPublicOperationService::class); + } + + public function testInvalidServiceDuplicateOperation(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Multiple operations named 'duplicateWhenNameOverridden1'"); + self::reader()->fromClass(InvalidServiceDuplicateOperation::class); + } + + public function testAsyncOperationWithInvalidReturnTypeIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('must declare a `Temporal\Nexus\WorkflowHandle` return type or return an'); + self::reader()->fromClass(InvalidAsyncReturnTypeService::class); + } + + public function testAsyncOperationWithNullableReturnTypeIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('must declare a `Temporal\Nexus\WorkflowHandle` return type or return an'); + self::reader()->fromClass(NullableAsyncReturnTypeService::class); + } + + public function testAsyncOperationWithHandlerFactoryReturnTypeIsAccepted(): void + { + $proto = self::reader()->fromClass(ManualTokenService::class); + + $operations = $proto->getOperations(); + self::assertTrue($operations['startExternal']->async); + self::assertTrue($operations['startUncancellable']->async); + } + + public function testFactoryInputTypeComesFromAttribute(): void + { + $proto = self::reader()->fromClass(ManualTokenService::class); + + self::assertSame('string', $proto->getOperations()['startExternal']->inputType->getName()); + } + + public function testFactoryWithParametersIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('must declare no parameters'); + self::reader()->fromClass(FactoryWithParametersService::class); + } + + public function testValidService(): void + { + $proto = self::reader()->fromClass(ValidServiceWithOperations::class); + + self::assertSame('ValidServiceWithOperations', $proto->getID()); + + self::assertOperation($proto, 'superMethod', input: 'void', output: 'void'); + self::assertOperation($proto, 'superInterfaceOnly', input: 'void', output: 'void'); + self::assertOperation($proto, 'noParamNoReturn', input: 'void', output: 'void'); + self::assertOperation($proto, 'noParamSingleReturn', input: 'void', output: 'string'); + self::assertOperation($proto, 'singleParamNoReturn', input: 'string', output: 'void'); + self::assertOperation($proto, 'singleParamSingleReturn', input: 'string', output: 'string'); + self::assertOperation($proto, 'custom-name', input: 'void', output: 'void'); + } + + public function testEmptyServiceRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('No operations defined'); + self::reader()->fromClass(EmptyService::class); + } + + public function testParentInterfaceWithoutServiceAttributeIsSkipped(): void + { + $proto = self::reader()->fromClass(ServiceWithPlainParentInterface::class); + self::assertSame('ServiceWithPlainParentInterface', $proto->getID()); + self::assertArrayHasKey('ownOperation', $proto->getOperations()); + self::assertArrayHasKey('inheritedOperation', $proto->getOperations()); + } + + public function testOperationOverrideWithMismatchingNameIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('mismatches against another operation'); + self::reader()->fromClass(OperationOverrideMismatchService::class); + } + + public function testDiamondInheritanceIsHandled(): void + { + $proto = self::reader()->fromClass(DiamondFinalInterface::class); + self::assertSame('Diamond', $proto->getID()); + self::assertCount(1, $proto->getOperations()); + self::assertArrayHasKey('commonOp', $proto->getOperations()); + } + + private static function reader(): NexusServiceReader + { + return new NexusServiceReader(new AttributeReader()); + } + + private static function assertOperation( + NexusServicePrototype $proto, + string $name, + string $input, + string $output, + ): void { + $operations = $proto->getOperations(); + self::assertArrayHasKey($name, $operations); + $op = $operations[$name]; + self::assertSame($name, $op->name); + self::assertSame($input, self::typeName($op->inputType), "inputType for {$name}"); + self::assertSame($output, self::typeName($op->outputType), "outputType for {$name}"); + } + + private static function typeName(\Temporal\DataConverter\Type $type): string + { + $name = $type->getName(); + return $type->allowsNull() && !\in_array($name, ['mixed', 'void', 'null'], true) + ? '?' . $name + : $name; + } +} diff --git a/tests/Nexus/Unit/OperationExceptionTest.php b/tests/Nexus/Unit/OperationExceptionTest.php new file mode 100644 index 000000000..b2b88f721 --- /dev/null +++ b/tests/Nexus/Unit/OperationExceptionTest.php @@ -0,0 +1,89 @@ +getMessage()); + self::assertNull($ex->getPrevious()); + self::assertSame(OperationState::Failed, $ex->state); + } + + public function testFailureWithCause(): void + { + $cause = new \RuntimeException('Root cause'); + $ex = OperationException::failedFromCause($cause); + + self::assertSame($cause, $ex->getPrevious()); + self::assertSame(OperationState::Failed, $ex->state); + } + + public function testFailureWithMessageAndCause(): void + { + $cause = new \RuntimeException('Root cause'); + $ex = OperationException::failed('Custom message', $cause); + + self::assertSame('Custom message', $ex->getMessage()); + self::assertSame($cause, $ex->getPrevious()); + self::assertSame(OperationState::Failed, $ex->state); + } + + public function testCanceledWithMessage(): void + { + $ex = OperationException::canceled('Test cancellation'); + + self::assertSame('Test cancellation', $ex->getMessage()); + self::assertNull($ex->getPrevious()); + self::assertSame(OperationState::Canceled, $ex->state); + } + + public function testCanceledWithCause(): void + { + $cause = new \RuntimeException('Cancellation reason'); + $ex = OperationException::canceledFromCause($cause); + + self::assertSame($cause, $ex->getPrevious()); + self::assertSame(OperationState::Canceled, $ex->state); + } + + public function testCanceledWithMessageAndCause(): void + { + $cause = new \RuntimeException('Cancellation reason'); + $ex = OperationException::canceled('Custom cancellation message', $cause); + + self::assertSame('Custom cancellation message', $ex->getMessage()); + self::assertSame($cause, $ex->getPrevious()); + self::assertSame(OperationState::Canceled, $ex->state); + } + + public function testExceptionChaining(): void + { + $rootCause = new \Exception('Root cause'); + $intermediateCause = new \RuntimeException('Intermediate', 0, $rootCause); + $ex = OperationException::failed('Operation failed', $intermediateCause); + + self::assertSame('Operation failed', $ex->getMessage()); + self::assertSame($intermediateCause, $ex->getPrevious()); + self::assertSame($rootCause, $ex->getPrevious()->getPrevious()); + self::assertSame(OperationState::Failed, $ex->state); + } +} diff --git a/tests/Nexus/Unit/OperationInfoTest.php b/tests/Nexus/Unit/OperationInfoTest.php new file mode 100644 index 000000000..1ff7b8c9b --- /dev/null +++ b/tests/Nexus/Unit/OperationInfoTest.php @@ -0,0 +1,58 @@ +token); + self::assertSame(OperationState::Running, $info->state); + } + + public function testCarriesEachState(): void + { + foreach (OperationState::cases() as $state) { + $info = new OperationInfo('t', $state); + self::assertSame($state, $info->state); + } + } + + public function testRejectsEmptyToken(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Operation Token must not be empty'); + new OperationInfo('', OperationState::Succeeded); + } + + public function testRejectsInvalidTokenBytes(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/printable non-whitespace ASCII/'); + new OperationInfo("bad\ntoken", OperationState::Failed); + } +} diff --git a/tests/Nexus/Unit/OperationStateTest.php b/tests/Nexus/Unit/OperationStateTest.php new file mode 100644 index 000000000..4e253dd78 --- /dev/null +++ b/tests/Nexus/Unit/OperationStateTest.php @@ -0,0 +1,36 @@ +value); + self::assertSame('succeeded', OperationState::Succeeded->value); + self::assertSame('failed', OperationState::Failed->value); + self::assertSame('canceled', OperationState::Canceled->value); + } + + public function testFromString(): void + { + self::assertSame(OperationState::Running, OperationState::from('running')); + self::assertSame(OperationState::Failed, OperationState::from('failed')); + } +} diff --git a/tests/Nexus/Unit/Validation/OperationNameValidatorTest.php b/tests/Nexus/Unit/Validation/OperationNameValidatorTest.php new file mode 100644 index 000000000..1f623aa42 --- /dev/null +++ b/tests/Nexus/Unit/Validation/OperationNameValidatorTest.php @@ -0,0 +1,38 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Operation Name must not be empty'); + OperationNameValidator::assert(''); + } + + public function testDelegatesToPrintableAsciiValidator(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/Operation Name.+printable non-whitespace ASCII/'); + OperationNameValidator::assert("bad\nname"); + } +} diff --git a/tests/Nexus/Unit/Validation/OperationTokenValidatorTest.php b/tests/Nexus/Unit/Validation/OperationTokenValidatorTest.php new file mode 100644 index 000000000..3da7a0bac --- /dev/null +++ b/tests/Nexus/Unit/Validation/OperationTokenValidatorTest.php @@ -0,0 +1,38 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Operation Token must not be empty'); + OperationTokenValidator::assert(''); + } + + public function testDelegatesToPrintableAsciiValidator(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/Operation Token.+printable non-whitespace ASCII/'); + OperationTokenValidator::assert("bad\ntoken"); + } +} diff --git a/tests/Nexus/Unit/Validation/PrintableAsciiValidatorTest.php b/tests/Nexus/Unit/Validation/PrintableAsciiValidatorTest.php new file mode 100644 index 000000000..4a6474efc --- /dev/null +++ b/tests/Nexus/Unit/Validation/PrintableAsciiValidatorTest.php @@ -0,0 +1,80 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Thing must not be empty'); + PrintableAsciiValidator::assert('', 'Thing'); + } + + public function testAcceptsPrintableAscii(): void + { + PrintableAsciiValidator::assert('hello-world.42_%', 'Thing'); + PrintableAsciiValidator::assert('!~', 'Thing'); + self::assertTrue(true); + } + + public function testAcceptsFullPrintableRange(): void + { + $value = ''; + for ($c = 0x21; $c <= 0x7E; $c++) { + $value .= \chr($c); + } + + PrintableAsciiValidator::assert($value, 'Thing'); + + self::assertTrue(true); + } + + public function testErrorMessageIncludesByteLengthAndOffset(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('got 6 bytes, first bad char at offset 2'); + PrintableAsciiValidator::assert("ok\nbad", 'Thing'); + } + + /** + * @return iterable + */ + public static function badCharProvider(): iterable + { + yield 'leading space' => [' a', 0]; + yield 'embedded space' => ['a b', 1]; + yield 'tab' => ["a\tb", 1]; + yield 'newline' => ["a\nb", 1]; + yield 'carriage return' => ["a\rb", 1]; + yield 'null byte' => ["a\0b", 1]; + yield 'control 0x01' => ["a\x01b", 1]; + yield 'del' => ["a\x7Fb", 1]; + yield 'high byte' => ["a\xFFb", 1]; + yield 'utf-8 multibyte' => ['имя', 0]; + } + + #[DataProvider('badCharProvider')] + public function testRejectsNonPrintable(string $value, int $offset): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("first bad char at offset {$offset}"); + PrintableAsciiValidator::assert($value, 'Thing'); + } +} diff --git a/tests/Nexus/Unit/Validation/ServiceNameValidatorTest.php b/tests/Nexus/Unit/Validation/ServiceNameValidatorTest.php new file mode 100644 index 000000000..ebe34d336 --- /dev/null +++ b/tests/Nexus/Unit/Validation/ServiceNameValidatorTest.php @@ -0,0 +1,38 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Service Name must not be empty'); + ServiceNameValidator::assert(''); + } + + public function testDelegatesToPrintableAsciiValidator(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/Service Name.+printable non-whitespace ASCII/'); + ServiceNameValidator::assert("bad\nname"); + } +} diff --git a/tests/Support/FrozenClock.php b/tests/Support/FrozenClock.php new file mode 100644 index 000000000..1944f32f1 --- /dev/null +++ b/tests/Support/FrozenClock.php @@ -0,0 +1,43 @@ +now; + } + + public function advance(\DateInterval $interval): void + { + $this->now = $this->now->add($interval); + } + + public function set(\DateTimeImmutable $moment): void + { + $this->now = $moment; + } +} diff --git a/tests/Unit/Client/WorkflowOptionsTestCase.php b/tests/Unit/Client/WorkflowOptionsTestCase.php new file mode 100644 index 000000000..229cc8041 --- /dev/null +++ b/tests/Unit/Client/WorkflowOptionsTestCase.php @@ -0,0 +1,107 @@ +requestId); + } + + public function testWithRequestIdReturnsClone(): void + { + $original = new WorkflowOptions(); + $updated = $original->withRequestId('req-1'); + + self::assertNull($original->requestId, 'original must not be mutated'); + self::assertSame('req-1', $updated->requestId); + self::assertNotSame($original, $updated); + } + + public function testWithRequestIdNullClearsOverride(): void + { + $options = (new WorkflowOptions())->withRequestId('req-1')->withRequestId(null); + + self::assertNull($options->requestId); + } + + public function testCompletionCallbacksDefaultEmpty(): void + { + $options = new WorkflowOptions(); + + self::assertSame([], $options->completionCallbacks); + } + + public function testWithNexusCompletionCallbackAccumulates(): void + { + $options = (new WorkflowOptions()) + ->withNexusCompletionCallback('https://callback.example/done', ['Nexus-Operation-Token' => 'tok-1']) + ->withNexusCompletionCallback('https://callback.example/other', []); + + self::assertCount(2, $options->completionCallbacks); + foreach ($options->completionCallbacks as $cb) { + self::assertInstanceOf(CompletionCallback::class, $cb); + } + + self::assertSame('https://callback.example/done', $options->completionCallbacks[0]->url); + self::assertSame(['Nexus-Operation-Token' => 'tok-1'], $options->completionCallbacks[0]->headers); + + self::assertSame('https://callback.example/other', $options->completionCallbacks[1]->url); + self::assertSame([], $options->completionCallbacks[1]->headers); + } + + public function testWithNexusCompletionCallbackKeepsOriginalUntouched(): void + { + $original = new WorkflowOptions(); + $updated = $original->withNexusCompletionCallback('https://x', []); + + self::assertSame([], $original->completionCallbacks); + self::assertCount(1, $updated->completionCallbacks); + } + + public function testWithCompletionCallbacksReplacesList(): void + { + $options = (new WorkflowOptions()) + ->withNexusCompletionCallback('https://a', []) + ->withCompletionCallbacks(); + + self::assertSame([], $options->completionCallbacks); + } + + public function testWithCompletionCallbacksAcceptsValueObjectsDirectly(): void + { + $cb1 = new CompletionCallback('https://one.example', ['X-Token' => 't1']); + $cb2 = new CompletionCallback('https://two.example'); + + $options = (new WorkflowOptions())->withCompletionCallbacks($cb1, $cb2); + + self::assertCount(2, $options->completionCallbacks); + self::assertSame($cb1, $options->completionCallbacks[0]); + self::assertSame($cb2, $options->completionCallbacks[1]); + } + + public function testCompletionCallbackRejectsEmptyUrl(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('url must be a non-empty string'); + + new CompletionCallback(''); + } +} diff --git a/tests/Unit/DTO/CompletionCallbackTestCase.php b/tests/Unit/DTO/CompletionCallbackTestCase.php new file mode 100644 index 000000000..7df9a82bb --- /dev/null +++ b/tests/Unit/DTO/CompletionCallbackTestCase.php @@ -0,0 +1,53 @@ +links); + } + + public function testEmptyUrlRejected(): void + { + $this->expectException(\InvalidArgumentException::class); + new CompletionCallback(''); + } + + public function testWithNexusLinksConvertsAndKeepsOnlyWorkflowEvent(): void + { + $uri = 'temporal:///namespaces/n/workflows/w/r/history' + . '?referenceType=EventReference&eventID=1&eventType=EVENT_TYPE_WORKFLOW_EXECUTION_STARTED'; + $cb = CompletionCallback::fromNexusLinks( + 'http://cb', + ['k' => 'v'], + [ + new NexusLink($uri, NexusLinkConverter::TYPE_WORKFLOW_EVENT), + new NexusLink('https://custom/abc', 'custom.type'), + ], + ); + + self::assertSame('http://cb', $cb->url); + self::assertSame(['k' => 'v'], $cb->headers); + self::assertCount(1, $cb->links); + } + + public function testWithNexusLinksThrowsOnMalformedUri(): void + { + $bad = 'https:///namespaces/n/workflows/w/r/history?referenceType=EventReference&eventType=EVENT_TYPE_WORKFLOW_EXECUTION_STARTED'; + $this->expectException(InvalidArgumentException::class); + CompletionCallback::fromNexusLinks('http://cb', [], [new NexusLink($bad, NexusLinkConverter::TYPE_WORKFLOW_EVENT)]); + } +} diff --git a/tests/Unit/DTO/WorkflowOptionsTestCase.php b/tests/Unit/DTO/WorkflowOptionsTestCase.php index 2d6bde9b2..466d380da 100644 --- a/tests/Unit/DTO/WorkflowOptionsTestCase.php +++ b/tests/Unit/DTO/WorkflowOptionsTestCase.php @@ -20,6 +20,8 @@ use Temporal\Common\Uuid; use Temporal\Common\WorkflowIdConflictPolicy; use Temporal\DataConverter\DataConverter; +use Temporal\Internal\Nexus\NexusLinkConverter; +use Temporal\Nexus\Link as NexusLink; class WorkflowOptionsTestCase extends AbstractDTOMarshalling { @@ -57,7 +59,14 @@ public function testMarshalling(): void ]; $result = $this->marshal($dto); - unset($result['typedSearchAttributes']); + unset( + $result['typedSearchAttributes'], + // Nexus-specific overrides; not marshalled to Go. + $result['requestId'], + $result['completionCallbacks'], + $result['onConflictOptions'], + $result['links'], + ); $this->assertSame($expected, $result); } @@ -242,4 +251,22 @@ public function testSetTypedSearchAttributesCasting(): void $this->assertInstanceOf(SearchAttributes::class, $result); $this->assertCount(1, $result->getIndexedFields()); } + + public function testLinksDefaultEmpty(): void + { + $dto = new WorkflowOptions(); + $this->assertSame([], $dto->links); + } + + public function testWithLinksImmutability(): void + { + $uri = 'temporal:///namespaces/n/workflows/w/r/history' + . '?referenceType=EventReference&eventID=1&eventType=EVENT_TYPE_WORKFLOW_EXECUTION_STARTED'; + $dto = new WorkflowOptions(); + $newDto = $dto->withLinks([new NexusLink($uri, NexusLinkConverter::TYPE_WORKFLOW_EVENT)]); + + $this->assertNotSame($dto, $newDto); + $this->assertSame([], $dto->links); + $this->assertCount(1, $newDto->links); + } } diff --git a/tests/Unit/Exception/Failure/NexusHandlerFailureTestCase.php b/tests/Unit/Exception/Failure/NexusHandlerFailureTestCase.php new file mode 100644 index 000000000..d61aa527f --- /dev/null +++ b/tests/Unit/Exception/Failure/NexusHandlerFailureTestCase.php @@ -0,0 +1,94 @@ + */ + public static function knownErrorTypes(): iterable + { + foreach (ErrorType::cases() as $case) { + yield "{$case->value}" => [$case->value, $case]; + } + } + + #[DataProvider('knownErrorTypes')] + public function testGetErrorTypeReturnsTypedEnumForKnownWireValue( + string $rawType, + ErrorType $expected, + ): void { + $failure = new NexusHandlerFailure('m', $rawType, 0); + + self::assertSame($expected, $failure->getErrorType()); + self::assertSame($rawType, $failure->getType(), 'Raw string preserved alongside typed view'); + } + + public function testGetErrorTypeFallsBackToUnknownForUnrecognizedWireValue(): void + { + $failure = new NexusHandlerFailure('m', 'FUTURE_TYPE_X', 0); + + self::assertSame(ErrorType::Unknown, $failure->getErrorType()); + self::assertSame('FUTURE_TYPE_X', $failure->getType(), 'Raw string preserved'); + } + + /** @return iterable */ + public static function retryBehaviors(): iterable + { + yield 'Unspecified' => [ + NexusHandlerErrorRetryBehavior::NEXUS_HANDLER_ERROR_RETRY_BEHAVIOR_UNSPECIFIED, + RetryBehavior::Unspecified, + ]; + yield 'Retryable' => [ + NexusHandlerErrorRetryBehavior::NEXUS_HANDLER_ERROR_RETRY_BEHAVIOR_RETRYABLE, + RetryBehavior::Retryable, + ]; + yield 'NonRetryable' => [ + NexusHandlerErrorRetryBehavior::NEXUS_HANDLER_ERROR_RETRY_BEHAVIOR_NON_RETRYABLE, + RetryBehavior::NonRetryable, + ]; + } + + #[DataProvider('retryBehaviors')] + public function testGetRetryBehaviorEnumMapsAllProtoValues(int $proto, RetryBehavior $expected): void + { + $failure = new NexusHandlerFailure('m', 'BAD_REQUEST', $proto); + + self::assertSame($expected, $failure->getRetryBehaviorEnum()); + self::assertSame($proto, $failure->getRetryBehavior(), 'Raw int preserved alongside typed view'); + } + + public function testTypedAccessorsOnConsumePathFromProto(): void + { + $info = new NexusHandlerFailureInfo(); + $info->setType('NOT_FOUND'); + $info->setRetryBehavior(NexusHandlerErrorRetryBehavior::NEXUS_HANDLER_ERROR_RETRY_BEHAVIOR_NON_RETRYABLE); + + $failure = new Failure(); + $failure->setMessage('vanished'); + $failure->setNexusHandlerFailureInfo($info); + + $exception = FailureConverter::mapFailureToException($failure, DataConverter::createDefault()); + + self::assertInstanceOf(NexusHandlerFailure::class, $exception); + self::assertSame(ErrorType::NotFound, $exception->getErrorType()); + self::assertSame(RetryBehavior::NonRetryable, $exception->getRetryBehaviorEnum()); + } +} diff --git a/tests/Unit/Exception/FailureConverterTestCase.php b/tests/Unit/Exception/FailureConverterTestCase.php index 5da23292f..957eec8f6 100644 --- a/tests/Unit/Exception/FailureConverterTestCase.php +++ b/tests/Unit/Exception/FailureConverterTestCase.php @@ -7,13 +7,23 @@ use Carbon\CarbonInterval; use Exception; use Google\Protobuf\Duration; +use Temporal\Nexus\Exception\ErrorType as NexusErrorType; +use Temporal\Nexus\Exception\HandlerException as NexusHandlerException; +use Temporal\Nexus\Exception\OperationException as NexusOperationException; +use Temporal\Nexus\Exception\RetryBehavior as NexusRetryBehavior; +use Temporal\Api\Enums\V1\NexusHandlerErrorRetryBehavior; use Temporal\Api\Failure\V1\Failure; +use Temporal\Api\Failure\V1\NexusHandlerFailureInfo; +use Temporal\Api\Failure\V1\NexusOperationFailureInfo; use Temporal\DataConverter\DataConverter; use Temporal\DataConverter\EncodedValues; use Temporal\Exception\Failure\ApplicationErrorCategory; use Temporal\Exception\Failure\ApplicationFailure; use Temporal\Exception\Failure\FailureConverter; +use Temporal\Exception\Failure\NexusHandlerFailure; +use Temporal\Exception\Failure\NexusOperationFailure; use Temporal\Tests\Unit\AbstractUnit; +use PHPUnit\Framework\Attributes\DataProvider; final class FailureConverterTestCase extends AbstractUnit { @@ -198,4 +208,313 @@ public function testMapAppFailureWithCategory(): void self::assertInstanceOf(ApplicationFailure::class, $newException); $this->assertSame(ApplicationErrorCategory::Benign, $newException->getApplicationErrorCategory()); } + + // ── Nexus: HandlerException → NexusHandlerFailureInfo ─────────────── + + public function testNexusHandlerExceptionProducesNexusHandlerFailureInfo(): void + { + $e = NexusHandlerException::create( + NexusErrorType::BadRequest, + 'invalid payload', + null, + NexusRetryBehavior::NonRetryable, + ); + + $failure = FailureConverter::mapExceptionToFailure($e, DataConverter::createDefault()); + + self::assertTrue($failure->hasNexusHandlerFailureInfo(), 'Nexus handler info must be set'); + $info = $failure->getNexusHandlerFailureInfo(); + self::assertSame('BAD_REQUEST', $info->getType(), 'Spec-level error type wire value'); + self::assertSame( + NexusHandlerErrorRetryBehavior::NEXUS_HANDLER_ERROR_RETRY_BEHAVIOR_NON_RETRYABLE, + $info->getRetryBehavior(), + ); + self::assertSame('invalid payload', $failure->getMessage()); + } + + public function testNexusHandlerExceptionWithRetryableBehavior(): void + { + $e = NexusHandlerException::create( + NexusErrorType::Internal, + 'transient storage error', + null, + NexusRetryBehavior::Retryable, + ); + + $failure = FailureConverter::mapExceptionToFailure($e, DataConverter::createDefault()); + + self::assertSame( + NexusHandlerErrorRetryBehavior::NEXUS_HANDLER_ERROR_RETRY_BEHAVIOR_RETRYABLE, + $failure->getNexusHandlerFailureInfo()->getRetryBehavior(), + ); + } + + public function testNexusHandlerExceptionUnspecifiedRetryBehaviorIsZero(): void + { + $e = NexusHandlerException::create(NexusErrorType::NotFound, 'missing'); + + $failure = FailureConverter::mapExceptionToFailure($e, DataConverter::createDefault()); + + self::assertSame( + NexusHandlerErrorRetryBehavior::NEXUS_HANDLER_ERROR_RETRY_BEHAVIOR_UNSPECIFIED, + $failure->getNexusHandlerFailureInfo()->getRetryBehavior(), + ); + } + + // ── Nexus: OperationException → tagged ApplicationFailureInfo ────── + + public function testNexusOperationExceptionFailedProducesTaggedApplicationFailure(): void + { + $e = NexusOperationException::failed('user rejected the request'); + + $failure = FailureConverter::mapExceptionToFailure($e, DataConverter::createDefault()); + + self::assertTrue($failure->hasApplicationFailureInfo(), 'Must use ApplicationFailureInfo for operation errors'); + self::assertFalse($failure->hasNexusHandlerFailureInfo(), 'Must NOT emit NexusHandlerFailureInfo for operation errors'); + + $info = $failure->getApplicationFailureInfo(); + self::assertSame( + FailureConverter::NEXUS_OPERATION_ERROR_TYPE_PREFIX . 'failed', + $info->getType(), + 'RR distinguishes business errors by this exact prefix', + ); + self::assertTrue($info->getNonRetryable(), 'Operation errors are terminal states'); + self::assertSame('user rejected the request', $failure->getMessage()); + } + + public function testNexusOperationExceptionCanceledProducesTaggedApplicationFailure(): void + { + $e = NexusOperationException::canceled('user canceled'); + + $failure = FailureConverter::mapExceptionToFailure($e, DataConverter::createDefault()); + + $info = $failure->getApplicationFailureInfo(); + self::assertSame( + FailureConverter::NEXUS_OPERATION_ERROR_TYPE_PREFIX . 'canceled', + $info->getType(), + ); + } + + public function testNexusOperationErrorPrefixMatchesWireContract(): void + { + // Keep the prefix stable — changing it breaks wire compat with + // roadrunner-temporal/aggregatedpool/nexus.go (see nexusOperationErrorTypePrefix). + self::assertSame('nexus.OperationError.', FailureConverter::NEXUS_OPERATION_ERROR_TYPE_PREFIX); + } + + // ── Nexus: inverse mapping (wire → typed exception) ──────────────── + + public function testNexusHandlerFailureInfoMapsToNexusHandlerFailure(): void + { + $info = new NexusHandlerFailureInfo(); + $info->setType('BAD_REQUEST'); + $info->setRetryBehavior(NexusHandlerErrorRetryBehavior::NEXUS_HANDLER_ERROR_RETRY_BEHAVIOR_NON_RETRYABLE); + + $failure = new Failure(); + $failure->setMessage('bad payload'); + $failure->setNexusHandlerFailureInfo($info); + + $exception = FailureConverter::mapFailureToException($failure, DataConverter::createDefault()); + + self::assertInstanceOf(NexusHandlerFailure::class, $exception); + self::assertSame('bad payload', $exception->getMessage()); + self::assertSame('BAD_REQUEST', $exception->getType()); + self::assertSame( + NexusHandlerErrorRetryBehavior::NEXUS_HANDLER_ERROR_RETRY_BEHAVIOR_NON_RETRYABLE, + $exception->getRetryBehavior(), + ); + } + + public function testNexusOperationFailureInfoMapsToNexusOperationFailure(): void + { + $info = new NexusOperationFailureInfo(); + $info->setScheduledEventId(42); + $info->setEndpoint('my-endpoint'); + $info->setService('MyService'); + $info->setOperation('doThing'); + $info->setOperationToken('tok-xyz'); + + $failure = new Failure(); + $failure->setMessage('operation failed'); + $failure->setNexusOperationExecutionFailureInfo($info); + + $exception = FailureConverter::mapFailureToException($failure, DataConverter::createDefault()); + + self::assertInstanceOf(NexusOperationFailure::class, $exception); + self::assertSame(42, $exception->getScheduledEventId()); + self::assertSame('my-endpoint', $exception->getEndpoint()); + self::assertSame('MyService', $exception->getService()); + self::assertSame('doThing', $exception->getOperation()); + self::assertSame('tok-xyz', $exception->getOperationToken()); + } + + public function testNexusOperationFailureProducesNexusOperationFailureInfo(): void + { + $e = new NexusOperationFailure( + 'operation failed', + 42, + 'my-endpoint', + 'MyService', + 'doThing', + 'tok-xyz', + ); + + $failure = FailureConverter::mapExceptionToFailure($e, DataConverter::createDefault()); + + self::assertTrue($failure->hasNexusOperationExecutionFailureInfo()); + $info = $failure->getNexusOperationExecutionFailureInfo(); + self::assertSame(42, $info->getScheduledEventId()); + self::assertSame('my-endpoint', $info->getEndpoint()); + self::assertSame('MyService', $info->getService()); + self::assertSame('doThing', $info->getOperation()); + self::assertSame('tok-xyz', $info->getOperationToken()); + } + + public function testNexusHandlerFailureProducesNexusHandlerFailureInfo(): void + { + $e = new NexusHandlerFailure( + 'handler exploded', + 'BAD_REQUEST', + NexusHandlerErrorRetryBehavior::NEXUS_HANDLER_ERROR_RETRY_BEHAVIOR_NON_RETRYABLE, + ); + + $failure = FailureConverter::mapExceptionToFailure($e, DataConverter::createDefault()); + + self::assertTrue($failure->hasNexusHandlerFailureInfo()); + $info = $failure->getNexusHandlerFailureInfo(); + self::assertSame('BAD_REQUEST', $info->getType()); + self::assertSame( + NexusHandlerErrorRetryBehavior::NEXUS_HANDLER_ERROR_RETRY_BEHAVIOR_NON_RETRYABLE, + $info->getRetryBehavior(), + ); + } + + public function testNexusHandlerFailureRoundTrip(): void + { + $converter = DataConverter::createDefault(); + $original = new NexusHandlerFailure( + 'handler exploded', + 'INTERNAL', + NexusHandlerErrorRetryBehavior::NEXUS_HANDLER_ERROR_RETRY_BEHAVIOR_RETRYABLE, + ); + + $failure = FailureConverter::mapExceptionToFailure($original, $converter); + $restored = FailureConverter::mapFailureToException($failure, $converter); + + self::assertInstanceOf(NexusHandlerFailure::class, $restored); + self::assertSame('INTERNAL', $restored->getType()); + self::assertSame( + NexusHandlerErrorRetryBehavior::NEXUS_HANDLER_ERROR_RETRY_BEHAVIOR_RETRYABLE, + $restored->getRetryBehavior(), + ); + } + + public function testNexusOperationFailureRoundTrip(): void + { + $converter = DataConverter::createDefault(); + $original = new NexusOperationFailure( + 'operation failed', + 7, + 'endpoint-1', + 'SvcA', + 'op-b', + 'token-c', + ); + + $failure = FailureConverter::mapExceptionToFailure($original, $converter); + $restored = FailureConverter::mapFailureToException($failure, $converter); + + self::assertInstanceOf(NexusOperationFailure::class, $restored); + self::assertSame(7, $restored->getScheduledEventId()); + self::assertSame('endpoint-1', $restored->getEndpoint()); + self::assertSame('SvcA', $restored->getService()); + self::assertSame('op-b', $restored->getOperation()); + self::assertSame('token-c', $restored->getOperationToken()); + } + + public function testNexusOperationFailureInfoFallsBackToDeprecatedOperationId(): void + { + // Older servers populate only the deprecated `operation_id` field. + // The converter must fall back so callers always get a non-empty + // token for async operations. + $info = new NexusOperationFailureInfo(); + $info->setOperationId('legacy-id'); + // operation_token left empty + + $failure = new Failure(); + $failure->setMessage('legacy'); + $failure->setNexusOperationExecutionFailureInfo($info); + + $exception = FailureConverter::mapFailureToException($failure, DataConverter::createDefault()); + + self::assertInstanceOf(NexusOperationFailure::class, $exception); + self::assertSame('legacy-id', $exception->getOperationToken()); + } + + /** @return iterable */ + public static function nexusHandlerRoundTripMatrix(): iterable + { + $protoMap = [ + NexusRetryBehavior::Unspecified->value => NexusHandlerErrorRetryBehavior::NEXUS_HANDLER_ERROR_RETRY_BEHAVIOR_UNSPECIFIED, + NexusRetryBehavior::Retryable->value => NexusHandlerErrorRetryBehavior::NEXUS_HANDLER_ERROR_RETRY_BEHAVIOR_RETRYABLE, + NexusRetryBehavior::NonRetryable->value => NexusHandlerErrorRetryBehavior::NEXUS_HANDLER_ERROR_RETRY_BEHAVIOR_NON_RETRYABLE, + ]; + + foreach (NexusErrorType::cases() as $type) { + foreach (NexusRetryBehavior::cases() as $retry) { + yield "{$type->value} + {$retry->value}" => [$type, $retry, $protoMap[$retry->value]]; + } + } + } + + #[DataProvider('nexusHandlerRoundTripMatrix')] + public function testNexusHandlerRoundTripParametric( + NexusErrorType $type, + NexusRetryBehavior $retry, + int $expectedProtoRetry, + ): void { + $original = NexusHandlerException::create($type, 'wire round-trip', null, $retry); + $converter = DataConverter::createDefault(); + + $failure = FailureConverter::mapExceptionToFailure($original, $converter); + $restored = FailureConverter::mapFailureToException($failure, $converter); + + self::assertInstanceOf(NexusHandlerFailure::class, $restored); + self::assertSame($type->value, $restored->getType()); + self::assertSame($expectedProtoRetry, $restored->getRetryBehavior()); + // Stack trace is appended to the message on round-trip; only the leading text is contractual. + self::assertStringStartsWith('wire round-trip', $restored->getMessage()); + } + + public function testNexusOperationFailurePreservesCauseChain(): void + { + // A NexusOperationFailureInfo typically wraps a cause describing the + // underlying handler error. The cause must propagate through the + // inverse mapping. + $cause = new Failure(); + $cause->setMessage('handler said no'); + $causeInfo = new \Temporal\Api\Failure\V1\ApplicationFailureInfo(); + $causeInfo->setType(FailureConverter::NEXUS_OPERATION_ERROR_TYPE_PREFIX . 'failed'); + $causeInfo->setNonRetryable(true); + $cause->setApplicationFailureInfo($causeInfo); + + $info = new NexusOperationFailureInfo(); + $info->setEndpoint('ep'); + $info->setService('svc'); + $info->setOperation('op'); + + $failure = new Failure(); + $failure->setMessage('nexus op failure'); + $failure->setNexusOperationExecutionFailureInfo($info); + $failure->setCause($cause); + + $exception = FailureConverter::mapFailureToException($failure, DataConverter::createDefault()); + + self::assertInstanceOf(NexusOperationFailure::class, $exception); + self::assertInstanceOf(ApplicationFailure::class, $exception->getPrevious()); + self::assertSame( + FailureConverter::NEXUS_OPERATION_ERROR_TYPE_PREFIX . 'failed', + $exception->getPrevious()->getType(), + ); + } } diff --git a/tests/Unit/Framework/WorkerMock.php b/tests/Unit/Framework/WorkerMock.php index d09ff2976..e44b12cac 100644 --- a/tests/Unit/Framework/WorkerMock.php +++ b/tests/Unit/Framework/WorkerMock.php @@ -190,6 +190,16 @@ public function complete(): void $this->execution = []; } + public function registerNexusServiceImplementation(object ...$services): WorkerInterface + { + return $this; + } + + public function getNexusServices(): array + { + return []; + } + public function registerActivityFinalizer(\Closure $finalizer): WorkerInterface { $this->services->activities->addFinalizer($finalizer); diff --git a/tests/Unit/Internal/Client/OnConflictOptionsTestCase.php b/tests/Unit/Internal/Client/OnConflictOptionsTestCase.php new file mode 100644 index 000000000..a743cd34f --- /dev/null +++ b/tests/Unit/Internal/Client/OnConflictOptionsTestCase.php @@ -0,0 +1,51 @@ +attachRequestId); + self::assertTrue($options->attachCompletionCallbacks); + self::assertTrue($options->attachLinks); + } + + public function testAcceptsExplicitFlags(): void + { + $options = new OnConflictOptions( + attachRequestId: false, + attachCompletionCallbacks: true, + attachLinks: false, + ); + + self::assertFalse($options->attachRequestId); + self::assertTrue($options->attachCompletionCallbacks); + self::assertFalse($options->attachLinks); + } + + public function testForNexusCompletionCallbackHardcodesAllTrue(): void + { + $options = new OnConflictOptions(); + + self::assertTrue($options->attachRequestId); + self::assertTrue($options->attachCompletionCallbacks); + self::assertTrue($options->attachLinks); + } +} diff --git a/tests/Unit/Internal/Client/WorkflowStarterTestCase.php b/tests/Unit/Internal/Client/WorkflowStarterTestCase.php index a5f689cf1..932448ba6 100644 --- a/tests/Unit/Internal/Client/WorkflowStarterTestCase.php +++ b/tests/Unit/Internal/Client/WorkflowStarterTestCase.php @@ -4,16 +4,27 @@ namespace Temporal\Tests\Unit\Internal\Client; +use Google\Protobuf\Any; +use Google\Rpc\Status as RpcStatus; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use Temporal\Api\Errordetails\V1\WorkflowExecutionAlreadyStartedFailure; use Temporal\Api\Workflowservice\V1\StartWorkflowExecutionRequest; use Temporal\Api\Workflowservice\V1\StartWorkflowExecutionResponse; use Temporal\Client\GRPC\ServiceClientInterface; +use Temporal\Client\GRPC\StatusCode; use Temporal\Client\WorkflowOptions; +use Temporal\Common\WorkflowIdConflictPolicy; use Temporal\DataConverter\DataConverter; +use Temporal\Exception\Client\ServiceClientException; +use Temporal\Exception\Client\WorkflowExecutionAlreadyStartedException; use Temporal\Internal\Client\WorkflowStarter; use Temporal\Internal\Interceptor\Pipeline; +use Temporal\Internal\Nexus\NexusLinkConverter; +use Temporal\Internal\Client\OnConflictOptions; use Temporal\Internal\Support\DateInterval; +use Temporal\Nexus\Link as NexusLink; +use Temporal\Workflow\CompletionCallback; /** * @internal @@ -70,6 +81,134 @@ public function testDelayIfSpecifiedNanos(): void self::assertSame(42000, $request->getWorkflowStartDelay()->getNanos()); } + public function testOnConflictOptionsAbsentByDefault(): void + { + $request = $this->startRequest('test-workflow', new WorkflowOptions()); + + self::assertNull($request->getOnConflictOptions()); + } + + public function testOnConflictOptionsSerializedToProtoWithAllFlags(): void + { + $options = (new WorkflowOptions()) + ->withOnConflictOptionsInternal(new OnConflictOptions()); + + $request = $this->startRequest('test-workflow', $options); + + $proto = $request->getOnConflictOptions(); + self::assertNotNull($proto); + self::assertTrue($proto->getAttachRequestId()); + self::assertTrue($proto->getAttachCompletionCallbacks()); + self::assertTrue($proto->getAttachLinks()); + } + + public function testOnConflictOptionsSerializedToProtoWithMixedFlags(): void + { + $options = (new WorkflowOptions()) + ->withOnConflictOptionsInternal(new OnConflictOptions( + attachRequestId: false, + attachCompletionCallbacks: true, + attachLinks: false, + )); + + $request = $this->startRequest('test-workflow', $options); + + $proto = $request->getOnConflictOptions(); + self::assertNotNull($proto); + self::assertFalse($proto->getAttachRequestId()); + self::assertTrue($proto->getAttachCompletionCallbacks()); + self::assertFalse($proto->getAttachLinks()); + } + + public function testWorkflowIdConflictPolicyUseExistingFlowsToProto(): void + { + $options = (new WorkflowOptions()) + ->withWorkflowIdConflictPolicy(WorkflowIdConflictPolicy::UseExisting); + + $request = $this->startRequest('test-workflow', $options); + + self::assertSame(WorkflowIdConflictPolicy::UseExisting->value, $request->getWorkflowIdConflictPolicy()); + } + + public function testAlreadyStartedThrowsEvenWithUseExisting(): void + { + $exception = $this->alreadyStartedException('existing-run-id'); + + $clientOptions = (new \Temporal\Client\ClientOptions()) + ->withNamespace(self::NAMESPACE) + ->withIdentity(self::IDENTITY); + + $clientMock = $this->createMock(ServiceClientInterface::class); + $clientMock + ->expects($this->once()) + ->method('StartWorkflowExecution') + ->willThrowException($exception); + + $starter = new WorkflowStarter( + serviceClient: $clientMock, + converter: DataConverter::createDefault(), + clientOptions: $clientOptions, + interceptors: Pipeline::prepare([]), + ); + + $options = (new WorkflowOptions()) + ->withWorkflowId('my-wf-id') + ->withWorkflowIdConflictPolicy(WorkflowIdConflictPolicy::UseExisting); + + $this->expectException(WorkflowExecutionAlreadyStartedException::class); + $starter->start('test-workflow', $options, []); + } + + private function alreadyStartedException(string $runId): ServiceClientException + { + $any = new Any(); + $any->pack((new WorkflowExecutionAlreadyStartedFailure())->setRunId($runId)); + + $rpcStatus = (new RpcStatus())->setCode(StatusCode::ALREADY_EXISTS); + $rpcStatus->setDetails([$any]); + + $status = new \stdClass(); + $status->code = StatusCode::ALREADY_EXISTS; + $status->details = 'workflow execution already started'; + $status->metadata = ['grpc-status-details-bin' => [$rpcStatus->serializeToString()]]; + + return new ServiceClientException($status); + } + + public function testStartRequestCarriesTopLevelLinks(): void + { + $uri = 'temporal:///namespaces/n/workflows/w/r/history' + . '?referenceType=EventReference&eventID=1&eventType=EVENT_TYPE_WORKFLOW_EXECUTION_STARTED'; + $options = (new WorkflowOptions()) + ->withLinks([new NexusLink($uri, NexusLinkConverter::TYPE_WORKFLOW_EVENT)]); + + $request = $this->startRequest('test-workflow', $options); + + $links = \iterator_to_array($request->getLinks()); + self::assertCount(1, $links); + self::assertNotNull($links[0]->getWorkflowEvent()); + } + + public function testCompletionCallbackProtoIncludesLinks(): void + { + $uri = 'temporal:///namespaces/n/workflows/w/r/history' + . '?referenceType=EventReference&eventID=1&eventType=EVENT_TYPE_WORKFLOW_EXECUTION_STARTED'; + $callback = CompletionCallback::fromNexusLinks( + 'http://cb', + [], + [new NexusLink($uri, NexusLinkConverter::TYPE_WORKFLOW_EVENT)], + ); + $options = (new WorkflowOptions())->withCompletionCallbacks($callback); + + $request = $this->startRequest('test-workflow', $options); + + $callbacks = \iterator_to_array($request->getCompletionCallbacks()); + self::assertCount(1, $callbacks); + $links = \iterator_to_array($callbacks[0]->getLinks()); + self::assertCount(1, $links); + self::assertNotNull($links[0]->getWorkflowEvent()); + } + private function startRequest( string $workflowType, WorkflowOptions $options, diff --git a/tests/Unit/Internal/Nexus/NexusLinkConverterTestCase.php b/tests/Unit/Internal/Nexus/NexusLinkConverterTestCase.php new file mode 100644 index 000000000..1880cd502 --- /dev/null +++ b/tests/Unit/Internal/Nexus/NexusLinkConverterTestCase.php @@ -0,0 +1,501 @@ +getWorkflowEvent(); + self::assertNotNull($event); + self::assertSame('default', $event->getNamespace()); + self::assertSame('wf-123', $event->getWorkflowId()); + self::assertSame('run-456', $event->getRunId()); + + $eventRef = $event->getEventRef(); + self::assertNotNull($eventRef); + self::assertSame(42, (int) $eventRef->getEventId()); + self::assertSame(EventType::EVENT_TYPE_NEXUS_OPERATION_STARTED, $eventRef->getEventType()); + } + + public function testRequestIdReferenceHappyPath(): void + { + $uri = 'temporal:///namespaces/default/workflows/wf-1/run-1/history' + . '?referenceType=RequestIdReference&requestID=req-abc&eventType=EVENT_TYPE_WORKFLOW_EXECUTION_STARTED'; + $result = NexusLinkConverter::toProtoLinks([new NexusLink($uri, self::TYPE)]); + + self::assertCount(1, $result); + $event = $result[0]->getWorkflowEvent(); + self::assertNotNull($event); + $ref = $event->getRequestIdRef(); + self::assertNotNull($ref); + self::assertSame('req-abc', $ref->getRequestId()); + self::assertSame(EventType::EVENT_TYPE_WORKFLOW_EXECUTION_STARTED, $ref->getEventType()); + } + + public function testEventReferenceWithoutEventId(): void + { + $uri = 'temporal:///namespaces/default/workflows/wf/run/history' + . '?referenceType=EventReference&eventType=EVENT_TYPE_NEXUS_OPERATION_STARTED'; + $result = NexusLinkConverter::toProtoLinks([new NexusLink($uri, self::TYPE)]); + + self::assertCount(1, $result); + $eventRef = $result[0]->getWorkflowEvent()->getEventRef(); + self::assertNotNull($eventRef); + self::assertSame(0, (int) $eventRef->getEventId()); + } + + public function testSkipsNonWorkflowEventLinks(): void + { + $result = NexusLinkConverter::toProtoLinks([ + new NexusLink('https://custom-tracker/123', 'custom.tracking.event'), + ]); + self::assertSame([], $result); + } + + public function testMixedListKeepsWorkflowEventOnly(): void + { + $good1 = 'temporal:///namespaces/n/workflows/w/r/history?referenceType=EventReference&eventID=1&eventType=EVENT_TYPE_WORKFLOW_EXECUTION_STARTED'; + $good2 = 'temporal:///namespaces/n/workflows/w2/r2/history?referenceType=EventReference&eventID=2&eventType=EVENT_TYPE_WORKFLOW_EXECUTION_STARTED'; + $result = NexusLinkConverter::toProtoLinks([ + new NexusLink($good1, self::TYPE), + new NexusLink('https://custom/abc', 'custom.tracking'), + new NexusLink($good2, self::TYPE), + ]); + + self::assertCount(2, $result); + } + + public function testPathDecodesEscapedSegments(): void + { + $uri = 'temporal:///namespaces/ns%20with%20space/workflows/wf%2Fslash/run-1/history' + . '?referenceType=EventReference&eventID=1&eventType=EVENT_TYPE_WORKFLOW_EXECUTION_STARTED'; + $result = NexusLinkConverter::toProtoLinks([new NexusLink($uri, self::TYPE)]); + + $event = $result[0]->getWorkflowEvent(); + self::assertSame('ns with space', $event->getNamespace()); + self::assertSame('wf/slash', $event->getWorkflowId()); + } + + public function testRejectsBadScheme(): void + { + $uri = 'https:///namespaces/n/workflows/w/r/history?referenceType=EventReference&eventType=EVENT_TYPE_WORKFLOW_EXECUTION_STARTED'; + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/scheme/'); + NexusLinkConverter::toProtoLinks([new NexusLink($uri, self::TYPE)]); + } + + public function testRejectsMalformedPath(): void + { + $uri = 'temporal:///workflows/w/r/history?referenceType=EventReference&eventType=EVENT_TYPE_WORKFLOW_EXECUTION_STARTED'; + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/path/'); + NexusLinkConverter::toProtoLinks([new NexusLink($uri, self::TYPE)]); + } + + public function testRejectsUnknownEventType(): void + { + $uri = 'temporal:///namespaces/n/workflows/w/r/history?referenceType=EventReference&eventType=EVENT_TYPE_DOES_NOT_EXIST'; + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/EventType/'); + NexusLinkConverter::toProtoLinks([new NexusLink($uri, self::TYPE)]); + } + + public function testRejectsUnknownReferenceType(): void + { + $uri = 'temporal:///namespaces/n/workflows/w/r/history?referenceType=NotARealType&eventType=EVENT_TYPE_WORKFLOW_EXECUTION_STARTED'; + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/referenceType/'); + NexusLinkConverter::toProtoLinks([new NexusLink($uri, self::TYPE)]); + } + + public function testRejectsNonNumericEventId(): void + { + $uri = 'temporal:///namespaces/n/workflows/w/r/history?referenceType=EventReference&eventID=abc&eventType=EVENT_TYPE_WORKFLOW_EXECUTION_STARTED'; + $this->expectException(InvalidArgumentException::class); + NexusLinkConverter::toProtoLinks([new NexusLink($uri, self::TYPE)]); + } + + public function testAcceptsMissingRequestIdAsEmpty(): void + { + $uri = 'temporal:///namespaces/n/workflows/w/r/history?referenceType=RequestIdReference&eventType=EVENT_TYPE_WORKFLOW_EXECUTION_STARTED'; + $result = NexusLinkConverter::toProtoLinks([new NexusLink($uri, self::TYPE)]); + + self::assertCount(1, $result); + $ref = $result[0]->getWorkflowEvent()->getRequestIdRef(); + self::assertNotNull($ref); + self::assertSame('', $ref->getRequestId()); + } + + public function testParsesPascalCaseEventReference(): void + { + $uri = 'temporal:///namespaces/default/workflows/wf/run/history' + . '?referenceType=EventReference&eventID=7&eventType=WorkflowExecutionStarted'; + $result = NexusLinkConverter::toProtoLinks([new NexusLink($uri, self::TYPE)]); + + self::assertCount(1, $result); + $eventRef = $result[0]->getWorkflowEvent()->getEventRef(); + self::assertNotNull($eventRef); + self::assertSame(7, (int) $eventRef->getEventId()); + self::assertSame(EventType::EVENT_TYPE_WORKFLOW_EXECUTION_STARTED, $eventRef->getEventType()); + } + + public function testParsesPascalCaseRequestIdReference(): void + { + $uri = 'temporal:///namespaces/default/workflows/wf/run/history' + . '?referenceType=RequestIdReference&requestID=req-1&eventType=WorkflowExecutionOptionsUpdated'; + $result = NexusLinkConverter::toProtoLinks([new NexusLink($uri, self::TYPE)]); + + self::assertCount(1, $result); + $ref = $result[0]->getWorkflowEvent()->getRequestIdRef(); + self::assertNotNull($ref); + self::assertSame('req-1', $ref->getRequestId()); + self::assertSame(EventType::EVENT_TYPE_WORKFLOW_EXECUTION_OPTIONS_UPDATED, $ref->getEventType()); + } + + public function testParsesNexusOperationScheduledPascalCase(): void + { + $uri = 'temporal:///namespaces/n/workflows/w/r/history' + . '?referenceType=EventReference&eventType=NexusOperationScheduled'; + $result = NexusLinkConverter::toProtoLinks([new NexusLink($uri, self::TYPE)]); + + $eventRef = $result[0]->getWorkflowEvent()->getEventRef(); + self::assertSame(EventType::EVENT_TYPE_NEXUS_OPERATION_SCHEDULED, $eventRef->getEventType()); + } + + public function testRejectsUnknownPascalCase(): void + { + $uri = 'temporal:///namespaces/n/workflows/w/r/history' + . '?referenceType=EventReference&eventType=WorkflowDoesNotExist'; + $this->expectException(InvalidArgumentException::class); + NexusLinkConverter::toProtoLinks([new NexusLink($uri, self::TYPE)]); + } + + public function testRejectsLowerCaseEventType(): void + { + $uri = 'temporal:///namespaces/n/workflows/w/r/history' + . '?referenceType=EventReference&eventType=workflowExecutionStarted'; + $this->expectException(InvalidArgumentException::class); + NexusLinkConverter::toProtoLinks([new NexusLink($uri, self::TYPE)]); + } + + public function testRejectsEmptyEventType(): void + { + $uri = 'temporal:///namespaces/n/workflows/w/r/history?referenceType=EventReference&eventType='; + $this->expectException(InvalidArgumentException::class); + NexusLinkConverter::toProtoLinks([new NexusLink($uri, self::TYPE)]); + } + + public function testParsesJavaWireFormatVerbatim(): void + { + // Fixture from sdk-java LinkConverterTest::testConvertWorkflowEventToNexus_Valid + $uri = 'temporal:///namespaces/ns/workflows/wf-id/run-id/history' + . '?referenceType=EventReference&eventID=1&eventType=WorkflowExecutionStarted'; + $result = NexusLinkConverter::toProtoLinks([new NexusLink($uri, self::TYPE)]); + + $event = $result[0]->getWorkflowEvent(); + self::assertSame('ns', $event->getNamespace()); + self::assertSame('wf-id', $event->getWorkflowId()); + self::assertSame('run-id', $event->getRunId()); + self::assertSame(1, (int) $event->getEventRef()->getEventId()); + self::assertSame(EventType::EVENT_TYPE_WORKFLOW_EXECUTION_STARTED, $event->getEventRef()->getEventType()); + } + + public function testParsesGoWireFormatVerbatim(): void + { + $uri = 'temporal:///namespaces/ns/workflows/wf-id/run-id/history' + . '?referenceType=EventReference&eventID=1&eventType=EVENT_TYPE_WORKFLOW_EXECUTION_STARTED'; + $result = NexusLinkConverter::toProtoLinks([new NexusLink($uri, self::TYPE)]); + + self::assertSame(EventType::EVENT_TYPE_WORKFLOW_EXECUTION_STARTED, $result[0]->getWorkflowEvent()->getEventRef()->getEventType()); + } + + // ---- Encoder (Part B) ---- + + public function testEncodesToJavaWireFormat(): void + { + $event = (new WorkflowEvent()) + ->setNamespace('ns') + ->setWorkflowId('wf-id') + ->setRunId('run-id'); + $event->setEventRef( + (new EventReference()) + ->setEventId(1) + ->setEventType(EventType::EVENT_TYPE_WORKFLOW_EXECUTION_STARTED), + ); + + $link = NexusLinkConverter::workflowEventToNexusLink($event); + + self::assertSame(self::TYPE, $link->type); + self::assertSame( + 'temporal:///namespaces/ns/workflows/wf-id/run-id/history' + . '?referenceType=EventReference&eventID=1&eventType=WorkflowExecutionStarted', + $link->uri, + ); + } + + public function testEncodesRequestIdReferenceToJavaWireFormat(): void + { + $event = (new WorkflowEvent()) + ->setNamespace('ns') + ->setWorkflowId('wf-id') + ->setRunId('run-id'); + $event->setRequestIdRef( + (new RequestIdReference()) + ->setRequestId('random-request-id') + ->setEventType(EventType::EVENT_TYPE_WORKFLOW_EXECUTION_OPTIONS_UPDATED), + ); + + $link = NexusLinkConverter::workflowEventToNexusLink($event); + + self::assertSame( + 'temporal:///namespaces/ns/workflows/wf-id/run-id/history' + . '?referenceType=RequestIdReference&requestID=random-request-id&eventType=WorkflowExecutionOptionsUpdated', + $link->uri, + ); + } + + public function testRoundTripEventReferenceWithEventId(): void + { + $event = (new WorkflowEvent()) + ->setNamespace('ns') + ->setWorkflowId('wf') + ->setRunId('run'); + $event->setEventRef( + (new EventReference()) + ->setEventId(42) + ->setEventType(EventType::EVENT_TYPE_NEXUS_OPERATION_STARTED), + ); + + $link = NexusLinkConverter::workflowEventToNexusLink($event); + $decoded = NexusLinkConverter::toProtoLinks([$link])[0]->getWorkflowEvent(); + + self::assertSame('ns', $decoded->getNamespace()); + self::assertSame('wf', $decoded->getWorkflowId()); + self::assertSame('run', $decoded->getRunId()); + self::assertSame(42, (int) $decoded->getEventRef()->getEventId()); + self::assertSame( + EventType::EVENT_TYPE_NEXUS_OPERATION_STARTED, + $decoded->getEventRef()->getEventType(), + ); + } + + public function testRoundTripRequestIdReference(): void + { + $event = (new WorkflowEvent()) + ->setNamespace('default') + ->setWorkflowId('wf') + ->setRunId('run'); + $event->setRequestIdRef( + (new RequestIdReference()) + ->setRequestId('req-abc') + ->setEventType(EventType::EVENT_TYPE_WORKFLOW_EXECUTION_OPTIONS_UPDATED), + ); + + $link = NexusLinkConverter::workflowEventToNexusLink($event); + $decoded = NexusLinkConverter::toProtoLinks([$link])[0]->getWorkflowEvent(); + + self::assertSame('req-abc', $decoded->getRequestIdRef()->getRequestId()); + self::assertSame( + EventType::EVENT_TYPE_WORKFLOW_EXECUTION_OPTIONS_UPDATED, + $decoded->getRequestIdRef()->getEventType(), + ); + } + + public function testRoundTripEventReferenceWithoutEventId(): void + { + $event = (new WorkflowEvent()) + ->setNamespace('ns') + ->setWorkflowId('wf') + ->setRunId('run'); + $event->setEventRef( + (new EventReference()) + ->setEventType(EventType::EVENT_TYPE_WORKFLOW_EXECUTION_STARTED), + ); + + $link = NexusLinkConverter::workflowEventToNexusLink($event); + self::assertStringNotContainsString('eventID=', $link->uri); + + $decoded = NexusLinkConverter::toProtoLinks([$link])[0]->getWorkflowEvent(); + self::assertSame(0, (int) $decoded->getEventRef()->getEventId()); + } + + public function testEncodesSlashInWorkflowId(): void + { + $event = (new WorkflowEvent()) + ->setNamespace('ns') + ->setWorkflowId('wf-id/') + ->setRunId('run-id'); + $event->setEventRef( + (new EventReference()) + ->setEventId(1) + ->setEventType(EventType::EVENT_TYPE_WORKFLOW_EXECUTION_STARTED), + ); + + $link = NexusLinkConverter::workflowEventToNexusLink($event); + + self::assertStringContainsString('/workflows/wf-id%2F/', $link->uri); + } + + public function testEncodesAngleInWorkflowId(): void + { + $event = (new WorkflowEvent()) + ->setNamespace('ns') + ->setWorkflowId('wf-id>') + ->setRunId('run-id'); + $event->setEventRef( + (new EventReference()) + ->setEventId(1) + ->setEventType(EventType::EVENT_TYPE_WORKFLOW_EXECUTION_STARTED), + ); + + $link = NexusLinkConverter::workflowEventToNexusLink($event); + + self::assertStringContainsString('/workflows/wf-id%3E/', $link->uri); + } + + public function testEncoderRejectsEmptyWorkflowEvent(): void + { + $event = (new WorkflowEvent()) + ->setNamespace('ns') + ->setWorkflowId('wf') + ->setRunId('run'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/event_ref or request_id_ref/'); + NexusLinkConverter::workflowEventToNexusLink($event); + } + + public function testEncoderRejectsUnknownEventTypeEnumValue(): void + { + $event = (new WorkflowEvent()) + ->setNamespace('ns') + ->setWorkflowId('wf') + ->setRunId('run'); + $event->setEventRef( + (new EventReference())->setEventType(99999), + ); + + $this->expectException(InvalidArgumentException::class); + NexusLinkConverter::workflowEventToNexusLink($event); + } + + public function testHardcodedTypeWorkflowEventConstant(): void + { + // Tripwire. The proto-php stubs we depend on don't expose a stable + // descriptor.full_name accessor on generated classes today, so we + // hardcode the type string in NexusLinkConverter::TYPE_WORKFLOW_EVENT. + // If proto-php gains such an accessor (e.g. via descriptor pool), + // replace the hardcode and update this test. + self::assertSame( + 'temporal.api.common.v1.Link.WorkflowEvent', + NexusLinkConverter::TYPE_WORKFLOW_EVENT, + ); + self::assertTrue(\class_exists(WorkflowEvent::class)); + } + + public function testEncoderOutputIsAcceptedByOurDecoder(): void + { + $cases = [ + // (namespace, workflowId, runId, fn(): WorkflowEvent) + [ + 'ns', 'wf', 'run', + static fn(): EventReference => (new EventReference()) + ->setEventId(1) + ->setEventType(EventType::EVENT_TYPE_WORKFLOW_EXECUTION_STARTED), + 'event', + ], + [ + 'default', 'wf-x', 'run-y', + static fn(): EventReference => (new EventReference()) + ->setEventType(EventType::EVENT_TYPE_NEXUS_OPERATION_SCHEDULED), + 'event', + ], + [ + 'ns space', 'wf/with/slash', 'run-1', + static fn(): RequestIdReference => (new RequestIdReference()) + ->setRequestId('rid-1') + ->setEventType(EventType::EVENT_TYPE_WORKFLOW_EXECUTION_STARTED), + 'request', + ], + [ + 'ns', 'wf', 'run', + static fn(): EventReference => (new EventReference()) + ->setEventId(0) + ->setEventType(EventType::EVENT_TYPE_WORKFLOW_EXECUTION_STARTED), + 'event', + ], + ]; + + foreach ($cases as $i => [$ns, $wfId, $runId, $refFactory, $kind]) { + $event = (new WorkflowEvent()) + ->setNamespace($ns) + ->setWorkflowId($wfId) + ->setRunId($runId); + $ref = $refFactory(); + if ($kind === 'event') { + $event->setEventRef($ref); + } else { + $event->setRequestIdRef($ref); + } + + $link = NexusLinkConverter::workflowEventToNexusLink($event); + $decoded = NexusLinkConverter::toProtoLinks([$link])[0]->getWorkflowEvent(); + + self::assertSame($ns, $decoded->getNamespace(), "case #$i: namespace"); + self::assertSame($wfId, $decoded->getWorkflowId(), "case #$i: workflowId"); + self::assertSame($runId, $decoded->getRunId(), "case #$i: runId"); + } + } + + public function testToNexusProtoLinksPassesUrlAndTypeThrough(): void + { + $uri = 'temporal:///namespaces/default/workflows/wf/run/history' + . '?referenceType=EventReference&eventType=WorkflowExecutionStarted'; + $result = NexusLinkConverter::toNexusProtoLinks([new NexusLink($uri, self::TYPE)]); + + self::assertCount(1, $result); + self::assertSame($uri, $result[0]->getUrl()); + self::assertSame(self::TYPE, $result[0]->getType()); + } + + public function testToNexusProtoLinksKeepsCustomLinkTypes(): void + { + $custom = new NexusLink('https://example.com/x', 'custom.user.type'); + $event = new NexusLink( + 'temporal:///namespaces/n/workflows/w/r/history?referenceType=EventReference&eventType=WorkflowExecutionStarted', + self::TYPE, + ); + + $result = NexusLinkConverter::toNexusProtoLinks([$custom, $event]); + + self::assertCount(2, $result); + self::assertSame('custom.user.type', $result[0]->getType()); + self::assertSame(self::TYPE, $result[1]->getType()); + } + + public function testToNexusProtoLinksReturnsEmptyArrayForEmptyInput(): void + { + self::assertSame([], NexusLinkConverter::toNexusProtoLinks([])); + } +} diff --git a/tests/Unit/Internal/Support/DateIntervalTestCase.php b/tests/Unit/Internal/Support/DateIntervalTestCase.php index dd203729e..9056b1808 100644 --- a/tests/Unit/Internal/Support/DateIntervalTestCase.php +++ b/tests/Unit/Internal/Support/DateIntervalTestCase.php @@ -201,7 +201,6 @@ public function testParseDetectsIso8601FormatCorrectly(string $interval, bool $s // Arrange $reflection = new \ReflectionClass(DateInterval::class); $method = $reflection->getMethod('isIso8601DurationFormat'); - $method->setAccessible(true); // Act $result = $method->invoke(null, $interval); diff --git a/tests/Unit/Internal/Support/SystemClockTestCase.php b/tests/Unit/Internal/Support/SystemClockTestCase.php new file mode 100644 index 000000000..f875d4e20 --- /dev/null +++ b/tests/Unit/Internal/Support/SystemClockTestCase.php @@ -0,0 +1,35 @@ +now(); + self::assertSame('UTC', $now->getTimezone()->getName()); + } + + public function testTwoCallsAreMonotonic(): void + { + $clock = new SystemClock(); + $a = $clock->now(); + $b = $clock->now(); + self::assertGreaterThanOrEqual($a, $b); + } +} diff --git a/tests/Unit/Internal/Transport/Request/GetNexusOperationStartedTestCase.php b/tests/Unit/Internal/Transport/Request/GetNexusOperationStartedTestCase.php new file mode 100644 index 000000000..0309b39f8 --- /dev/null +++ b/tests/Unit/Internal/Transport/Request/GetNexusOperationStartedTestCase.php @@ -0,0 +1,29 @@ +getName()); + self::assertSame(['id' => 123], $request->getOptions()); + } +} diff --git a/tests/Unit/Internal/Transport/Router/GetWorkerInfoTestCase.php b/tests/Unit/Internal/Transport/Router/GetWorkerInfoTestCase.php new file mode 100644 index 000000000..0d02698df --- /dev/null +++ b/tests/Unit/Internal/Transport/Router/GetWorkerInfoTestCase.php @@ -0,0 +1,122 @@ +dispatch(['greet']); + + // Wire contract with rrtemporal: WorkerInfo.NexusServices uses the same + // PascalCase convention as every other top-level field. Go's JSON + // unmarshal is case-insensitive, so old camelCase `nexusServices` + // would also parse — but emitting PascalCase keeps the handshake + // self-consistent and matches the Go struct tag. + self::assertArrayHasKey('NexusServices', $payload); + self::assertArrayNotHasKey('nexusServices', $payload); + } + + public function testNexusServicesShape(): void + { + $payload = $this->dispatch(['greet', 'farewell']); + + self::assertSame( + [ + ['name' => 'GreetingService', 'operations' => ['greet', 'farewell']], + ], + $payload['NexusServices'], + ); + } + + public function testFlagsDoNotAdvertiseMethodCancel(): void + { + $payload = $this->dispatch([]); + + // `nexus_method_cancel` was a redundant capability signal — removed. + // rrtemporal now infers method-cancel support from NexusServices presence. + $flags = (array) $payload['Flags']; + self::assertArrayNotHasKey('nexus_method_cancel', $flags); + } + + public function testFlagsRetainApiKey(): void + { + $payload = $this->dispatch([]); + + $flags = (array) $payload['Flags']; + self::assertArrayHasKey('ApiKey', $flags); + self::assertSame('', $flags['ApiKey']); + } + + /** + * @param list $operationNames Wire keys for the single registered Nexus service. + * @return array The first worker entry from the resolved payload. + */ + private function dispatch(array $operationNames): array + { + $worker = $this->createMock(WorkerInterface::class); + $worker->method('getID')->willReturn('test-queue'); + $worker->method('getOptions')->willReturn(WorkerOptions::new()); + $worker->method('getWorkflows')->willReturn([]); + $worker->method('getActivities')->willReturn([]); + $worker->method('getNexusServices')->willReturn([ + new NexusServicePrototype( + 'GreetingService', + \array_fill_keys($operationNames, $this->stubOperationPrototype()), + new \ReflectionClass(\stdClass::class), + ), + ]); + + $marshaller = $this->createMock(MarshallerInterface::class); + $marshaller->method('marshal')->willReturn([]); + + $route = new GetWorkerInfoRoute( + new ArrayRepository([$worker]), + $marshaller, + ServiceCredentials::create(), + new PluginRegistry(), + ); + + $deferred = new Deferred(); + $request = new ServerRequest('GetWorkerInfo', new TickInfo(new DateTimeImmutable())); + $route->handle($request, [], $deferred); + + $resolved = null; + $deferred->promise()->then(static function (mixed $value) use (&$resolved): void { + $resolved = $value; + }); + + self::assertInstanceOf(ValuesInterface::class, $resolved); + $values = $resolved->getValues(); + self::assertCount(1, $values, 'one worker entry expected'); + return $values[0]; + } + + /** + * The route only reads operation keys via `array_keys()`, so the value + * type is irrelevant for the wire-shape assertion. + */ + private function stubOperationPrototype(): mixed + { + return null; + } +} diff --git a/tests/Unit/Internal/Workflow/NexusOperationStubTestCase.php b/tests/Unit/Internal/Workflow/NexusOperationStubTestCase.php new file mode 100644 index 000000000..bca91821a --- /dev/null +++ b/tests/Unit/Internal/Workflow/NexusOperationStubTestCase.php @@ -0,0 +1,234 @@ +makeStub(NexusOperationOptions::new()); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + "Nexus stub for this operation has no endpoint set. " + . "Call NexusOperationOptions::withEndpoint('your-endpoint') " + . "before passing options to newNexusServiceStub() or newUntypedNexusOperationStub().", + ); + + $stub->start('someOp'); + } + + public function testStartRejectsEmptyEndpointMentionsServiceWhenKnown(): void + { + $stub = $this->makeStub( + NexusOperationOptions::new()->withService('PaymentService'), + ); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + "Nexus stub for service 'PaymentService' has no endpoint set. " + . "Call NexusOperationOptions::withEndpoint('your-endpoint') " + . "before passing options to newNexusServiceStub() or newUntypedNexusOperationStub().", + ); + + $stub->start('someOp'); + } + + public function testStartRejectsEmptyService(): void + { + $stub = $this->makeStub( + NexusOperationOptions::new()->withEndpoint('ep'), + ); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Nexus service is empty'); + + $stub->start('someOp'); + } + + public function testStartRejectsEmptyOperationName(): void + { + $stub = $this->makeStub( + NexusOperationOptions::new() + ->withEndpoint('ep') + ->withService('svc'), + ); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Nexus operation name must be a non-empty string'); + + $stub->start(''); + } + + public function testNormalizeFailureWrapsCanceledFailure(): void + { + $deferred = new Deferred(); + $normalized = $this->invokeNormalizeFailure($deferred->promise()); + + $captured = null; + $normalized->then( + null, + static function (\Throwable $e) use (&$captured): void { + $captured = $e; + }, + ); + + $deferred->reject(new CanceledFailure('cancelled by caller')); + + self::assertInstanceOf(NexusOperationFailure::class, $captured); + self::assertSame('nexus operation cancelled', $captured->getOriginalMessage()); + self::assertSame('ep', $captured->getEndpoint()); + self::assertSame('svc', $captured->getService()); + self::assertSame('place-order', $captured->getOperation()); + self::assertSame('', $captured->getOperationToken()); + self::assertSame(0, $captured->getScheduledEventId()); + self::assertInstanceOf(CanceledFailure::class, $captured->getPrevious()); + } + + public function testNormalizeFailureWrapsGenericThrowable(): void + { + $deferred = new Deferred(); + $normalized = $this->invokeNormalizeFailure($deferred->promise()); + + $captured = null; + $normalized->then( + null, + static function (\Throwable $e) use (&$captured): void { + $captured = $e; + }, + ); + + $original = new ApplicationFailure('boom', 'BoomType', false); + $deferred->reject($original); + + self::assertInstanceOf(NexusOperationFailure::class, $captured); + self::assertSame('nexus operation completed unsuccessfully', $captured->getOriginalMessage()); + self::assertSame($original, $captured->getPrevious()); + } + + public function testNormalizeFailurePassesThroughExistingNexusOperationFailure(): void + { + $deferred = new Deferred(); + $normalized = $this->invokeNormalizeFailure($deferred->promise()); + + $captured = null; + $normalized->then( + null, + static function (\Throwable $e) use (&$captured): void { + $captured = $e; + }, + ); + + // Simulates a server-originated failure that already arrived wrapped + // — must not be re-wrapped (would lose the real scheduledEventId / + // operationToken from the wire). + $original = new NexusOperationFailure( + message: 'handler returned error', + scheduledEventId: 42, + endpoint: 'wire-ep', + service: 'wire-svc', + operation: 'wire-op', + operationToken: 'tok-123', + ); + $deferred->reject($original); + + self::assertSame($original, $captured); + self::assertSame(42, $captured->getScheduledEventId()); + self::assertSame('tok-123', $captured->getOperationToken()); + self::assertSame('wire-ep', $captured->getEndpoint()); + } + + public function testNormalizeFailureCarriesKnownOperationToken(): void + { + $deferred = new Deferred(); + $token = ''; + $normalized = $this->invokeNormalizeFailure($deferred->promise(), $token); + + $captured = null; + $normalized->then( + null, + static function (\Throwable $e) use (&$captured): void { + $captured = $e; + }, + ); + + $token = 'tok-async-1'; + $deferred->reject(new CanceledFailure('cancelled after start')); + + self::assertInstanceOf(NexusOperationFailure::class, $captured); + self::assertSame('tok-async-1', $captured->getOperationToken()); + self::assertSame(0, $captured->getScheduledEventId()); + } + + public function testNormalizeFailurePassesThroughResolvedValue(): void + { + $deferred = new Deferred(); + $normalized = $this->invokeNormalizeFailure($deferred->promise()); + + $received = null; + $normalized->then( + static function ($value) use (&$received): void { + $received = $value; + }, + ); + + // Verifies the success path is not broken by the new rejection-only + // handler — values flow through unchanged. + $deferred->resolve('payload'); + + self::assertSame('payload', $received); + } + + public function testGetOptionsReturnsTheStubOptions(): void + { + $options = NexusOperationOptions::new()->withEndpoint('ep')->withService('svc'); + $stub = $this->makeStub($options); + + self::assertSame($options, $stub->getOptions()); + } + + private function makeStub(NexusOperationOptions $options): NexusOperationStub + { + /** @var MarshallerInterface $marshaller */ + $marshaller = $this->createStub(MarshallerInterface::class); + return new NexusOperationStub( + $marshaller, + $options, + Header::empty(), + ); + } + + /** + * Reach into the private normalizeFailure() — the class is final so we + * cannot subclass to override request(), and start() requires a real + * Workflow context. Reflection keeps the unit boundary at the function + * under test without dragging in the workflow runtime. + */ + private function invokeNormalizeFailure(PromiseInterface $promise, string &$operationToken = ''): PromiseInterface + { + $stub = $this->makeStub( + NexusOperationOptions::new()->withEndpoint('ep')->withService('svc'), + ); + + $method = new \ReflectionMethod(NexusOperationStub::class, 'normalizeFailure'); + + return $method->invokeArgs($stub, [$promise, 'ep', 'svc', 'place-order', &$operationToken]); + } +} diff --git a/tests/Unit/Internal/Workflow/NexusServiceProxyTestCase.php b/tests/Unit/Internal/Workflow/NexusServiceProxyTestCase.php new file mode 100644 index 000000000..1a37e7f31 --- /dev/null +++ b/tests/Unit/Internal/Workflow/NexusServiceProxyTestCase.php @@ -0,0 +1,166 @@ +makeProxy( + $this->makeContext($captured), + new class implements WorkflowOutboundCallsInterceptor { + use WorkflowOutboundCallsInterceptorTrait; + + public function executeNexusOperation( + ExecuteNexusOperationInput $input, + callable $next, + ): PromiseInterface { + return $next($input->with(endpoint: 'rewritten-ep')); + } + }, + ); + + $proxy->placeOrder('order-1'); + + self::assertInstanceOf(NexusOperationOptions::class, $captured); + self::assertSame('rewritten-ep', $captured->endpoint); + self::assertSame('OrderService', $captured->service); + } + + public function testInterceptorServiceRewriteChangesOutgoingOptions(): void + { + $captured = null; + $proxy = $this->makeProxy( + $this->makeContext($captured), + new class implements WorkflowOutboundCallsInterceptor { + use WorkflowOutboundCallsInterceptorTrait; + + public function executeNexusOperation( + ExecuteNexusOperationInput $input, + callable $next, + ): PromiseInterface { + return $next($input->with(service: 'RewrittenService')); + } + }, + ); + + $proxy->placeOrder('order-1'); + + self::assertInstanceOf(NexusOperationOptions::class, $captured); + self::assertSame('orig-ep', $captured->endpoint); + self::assertSame('RewrittenService', $captured->service); + } + + public function testWithoutInterceptorsOptionsPassThroughUnchanged(): void + { + $captured = null; + $proxy = $this->makeProxy($this->makeContext($captured)); + + $proxy->placeOrder('order-1'); + + self::assertInstanceOf(NexusOperationOptions::class, $captured); + self::assertSame('orig-ep', $captured->endpoint); + self::assertSame('OrderService', $captured->service); + } + + public function testUnknownMethodThrowsBadMethodCall(): void + { + $captured = null; + $proxy = $this->makeProxy($this->makeContext($captured)); + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('has no operation method "unknownMethod"'); + + $proxy->unknownMethod(); + } + + private function makeProxy( + WorkflowContextInterface $ctx, + WorkflowOutboundCallsInterceptor ...$interceptors, + ): NexusServiceProxy { + $reflection = new \ReflectionClass(NexusProxyTestService::class); + $operation = new NexusOperationPrototype( + name: 'place-order', + methodName: 'placeOrder', + inputType: Type::create(Type::TYPE_STRING), + outputType: Type::create(Type::TYPE_STRING), + async: false, + handler: $reflection->getMethod('placeOrder'), + ); + + return new NexusServiceProxy( + NexusProxyTestService::class, + new NexusServicePrototype('OrderService', ['place-order' => $operation], $reflection), + NexusOperationOptions::new()->withEndpoint('orig-ep')->withService('OrderService'), + $ctx, + Pipeline::prepare($interceptors), + ); + } + + private function makeContext(?NexusOperationOptions &$captured): WorkflowContextInterface + { + $ctx = $this->createMock(WorkflowContextInterface::class); + $ctx->method('newUntypedNexusOperationStub') + ->willReturnCallback(static function (NexusOperationOptions $options) use (&$captured) { + $captured = $options; + $stub = new class implements NexusOperationStubInterface { + public NexusOperationOptions $options; + + public function getOptions(): NexusOperationOptions + { + return $this->options; + } + + public function execute( + string $operation, + array $args = [], + Type|string|\ReflectionClass|\ReflectionType|null $returnType = null, + array $nexusHeaders = [], + ): PromiseInterface { + return resolve(null); + } + + public function start( + string $operation, + array $args = [], + Type|string|\ReflectionClass|\ReflectionType|null $returnType = null, + array $nexusHeaders = [], + ): PromiseInterface { + return resolve(null); + } + }; + $stub->options = $options; + return $stub; + }); + + return $ctx; + } +} + +interface NexusProxyTestService +{ + public function placeOrder(string $order): string; +} diff --git a/tests/Unit/Internal/Workflow/WorkflowContextNexusOptionsTestCase.php b/tests/Unit/Internal/Workflow/WorkflowContextNexusOptionsTestCase.php new file mode 100644 index 000000000..d35064ec1 --- /dev/null +++ b/tests/Unit/Internal/Workflow/WorkflowContextNexusOptionsTestCase.php @@ -0,0 +1,61 @@ +withEndpoint('orig-ep') + ->withService('OrderService') + ->withCancellationType(NexusOperationCancellationType::Abandon); + $input = $this->makeInput($options)->with(endpoint: 'new-ep', service: 'NewService'); + + $effective = $this->deriveOptions($input); + + self::assertSame('new-ep', $effective->endpoint); + self::assertSame('NewService', $effective->service); + self::assertSame(NexusOperationCancellationType::Abandon, $effective->cancellationType); + } + + public function testUntouchedInputReturnsOriginalOptionsInstance(): void + { + $options = NexusOperationOptions::new() + ->withEndpoint('orig-ep') + ->withService('OrderService'); + + self::assertSame($options, $this->deriveOptions($this->makeInput($options))); + } + + private function makeInput(NexusOperationOptions $options): ExecuteNexusOperationInput + { + return new ExecuteNexusOperationInput( + $options->endpoint, + $options->service, + 'place-order', + [], + $options, + null, + ); + } + + private function deriveOptions(ExecuteNexusOperationInput $input): NexusOperationOptions + { + $method = new \ReflectionMethod(WorkflowContext::class, 'effectiveNexusOptions'); + + return $method->invoke(null, $input); + } +} diff --git a/tests/Unit/Nexus/AwaitsNexusPromise.php b/tests/Unit/Nexus/AwaitsNexusPromise.php new file mode 100644 index 000000000..08bc2e7a4 --- /dev/null +++ b/tests/Unit/Nexus/AwaitsNexusPromise.php @@ -0,0 +1,59 @@ +promise()->then( + static function (mixed $value) use (&$result, &$settled): void { + $result = $value; + $settled = true; + }, + static function (\Throwable $e) use (&$error): void { + $error = $e; + }, + ); + + if ($error !== null) { + throw $error; + } + + Assert::assertTrue($settled, 'promise should resolve'); + return $result; + } + + private function assertResolved(Deferred $deferred): void + { + $this->await($deferred); + } + + private function awaitReply(Deferred $deferred): CommandResponse + { + $result = $this->await($deferred); + Assert::assertInstanceOf(CommandResponse::class, $result); + return $result; + } + + private function awaitCancelResult(Deferred $deferred): ValuesInterface + { + $result = $this->await($deferred); + Assert::assertInstanceOf(ValuesInterface::class, $result); + return $result; + } +} diff --git a/tests/Unit/Nexus/CancelNexusOperationMethodRouteTestCase.php b/tests/Unit/Nexus/CancelNexusOperationMethodRouteTestCase.php new file mode 100644 index 000000000..3d76ecc20 --- /dev/null +++ b/tests/Unit/Nexus/CancelNexusOperationMethodRouteTestCase.php @@ -0,0 +1,118 @@ +env = new Environment(); + } + + public function testRouteName(): void + { + $route = new CancelNexusOperationMethod(new NexusInvocationRegistry()); + + self::assertSame('CancelNexusOperationMethod', $route->getName()); + } + + public function testCancelsRegisteredInvocationWithReason(): void + { + $registry = new NexusInvocationRegistry(); + $canceller = new MethodCanceller($this->env); + $registry->register(42, $canceller); + + $route = new CancelNexusOperationMethod($registry); + $request = $this->makeRequest(['invocationId' => 42, 'reason' => 'deadline']); + + $deferred = new Deferred(); + $route->handle($request, [], $deferred); + + $this->assertResolved($deferred); + self::assertTrue($canceller->isCancelled()); + self::assertSame('deadline', $canceller->getReason()); + } + + public function testUnknownInvocationIdIsNoOp(): void + { + $registry = new NexusInvocationRegistry(); + $route = new CancelNexusOperationMethod($registry); + $request = $this->makeRequest(['invocationId' => 999, 'reason' => 'x']); + + $deferred = new Deferred(); + $route->handle($request, [], $deferred); + + // Still resolves cleanly — a late cancel after handler finished is legitimate. + $this->assertResolved($deferred); + } + + public function testZeroInvocationIdSkipsLookup(): void + { + $registry = new NexusInvocationRegistry(); + $canceller = new MethodCanceller($this->env); + // 0 must NEVER be a valid key — RR assigns monotonic non-zero ids. + $registry->register(0, $canceller); + + $route = new CancelNexusOperationMethod($registry); + $request = $this->makeRequest(['invocationId' => 0, 'reason' => 'x']); + + $deferred = new Deferred(); + $route->handle($request, [], $deferred); + + $this->assertResolved($deferred); + self::assertFalse( + $canceller->isCancelled(), + '0 is the sentinel for "no invocation id" and must not touch the registry', + ); + } + + public function testMissingReasonDefaultsToEmptyString(): void + { + $registry = new NexusInvocationRegistry(); + $canceller = new MethodCanceller($this->env); + $registry->register(5, $canceller); + + $route = new CancelNexusOperationMethod($registry); + $request = $this->makeRequest(['invocationId' => 5]); + + $deferred = new Deferred(); + $route->handle($request, [], $deferred); + + $this->assertResolved($deferred); + self::assertSame('', $canceller->getReason()); + } + + private function makeRequest(array $options): ServerRequest + { + return new ServerRequest( + name: 'CancelNexusOperationMethod', + info: new TickInfo(new \DateTimeImmutable()), + options: $options, + ); + } +} diff --git a/tests/Unit/Nexus/CancelNexusOperationRouteTestCase.php b/tests/Unit/Nexus/CancelNexusOperationRouteTestCase.php new file mode 100644 index 000000000..dcc651606 --- /dev/null +++ b/tests/Unit/Nexus/CancelNexusOperationRouteTestCase.php @@ -0,0 +1,162 @@ +headers->all(); + } +} + +class RouteHeaderServiceImpl implements RouteHeaderService +{ + /** @var array */ + public static array $capturedCancelHeaders = []; + + public function op(): RouteHeaderOpHandler + { + return new RouteHeaderOpHandler(); + } +} + +/** + * Unit tests for the `CancelNexusOperation` router — the `options['headers']` + * map (sent by RoadRunner on the cancel command) must surface on the handler's + * OperationContext, symmetric with the start path. + * + * @group unit + * @group nexus + */ +#[CoversClass(CancelNexusOperation::class)] +final class CancelNexusOperationRouteTestCase extends AbstractUnit +{ + use AwaitsNexusPromise; + + private EnvironmentInterface $env; + + protected function setUp(): void + { + parent::setUp(); + $this->env = new Environment(); + RouteHeaderServiceImpl::$capturedCancelHeaders = []; + } + + public function testRouteName(): void + { + $route = new CancelNexusOperation($this->buildHandler(), $this->buildMarshaller()); + + self::assertSame('CancelNexusOperation', $route->getName()); + } + + public function testForwardsOptionHeadersToHandlerContext(): void + { + $route = new CancelNexusOperation($this->buildHandler(), $this->buildMarshaller()); + $request = $this->makeRequest([ + 'service' => 'RouteHeaderService', + 'operation' => 'op', + 'operationToken' => 'tok', + 'headers' => [ + 'X-Nexus-Trace-Id' => 'trace-1', + 'Authorization' => 'Bearer xyz', + ], + ]); + + $deferred = new Deferred(); + $route->handle($request, [], $deferred); + + $this->assertResolved($deferred); + // OperationContext lowercases header keys on construction. + self::assertSame('trace-1', RouteHeaderServiceImpl::$capturedCancelHeaders['x-nexus-trace-id'] ?? null); + self::assertSame('Bearer xyz', RouteHeaderServiceImpl::$capturedCancelHeaders['authorization'] ?? null); + } + + public function testMissingHeadersResolvesWithEmptyContextHeaders(): void + { + $route = new CancelNexusOperation($this->buildHandler(), $this->buildMarshaller()); + $request = $this->makeRequest([ + 'service' => 'RouteHeaderService', + 'operation' => 'op', + 'operationToken' => 'tok', + ]); + + $deferred = new Deferred(); + $route->handle($request, [], $deferred); + + $this->assertResolved($deferred); + self::assertSame([], RouteHeaderServiceImpl::$capturedCancelHeaders); + } + + private function buildHandler(): NexusTaskHandler + { + $reader = new NexusServiceReader(new AttributeReader()); + $collection = new NexusServiceCollection(); + $prototype = $reader->fromClass(RouteHeaderServiceImpl::class)->withInstance(new RouteHeaderServiceImpl()); + $collection->add($prototype, false); + + return new NexusTaskHandler($collection, DataConverter::createDefault(), $this->env); + } + + private function buildMarshaller(): Marshaller + { + return new Marshaller(new AttributeMapperFactory(new AttributeReader())); + } + + /** + * @param array $options + */ + private function makeRequest(array $options): ServerRequest + { + return new ServerRequest( + name: 'CancelNexusOperation', + info: new TickInfo(new \DateTimeImmutable()), + options: $options, + ); + } +} diff --git a/tests/Unit/Nexus/HandlerErrorMapperTestCase.php b/tests/Unit/Nexus/HandlerErrorMapperTestCase.php new file mode 100644 index 000000000..fa63c53cc --- /dev/null +++ b/tests/Unit/Nexus/HandlerErrorMapperTestCase.php @@ -0,0 +1,179 @@ + + */ + public static function grpcCodeMatrix(): iterable + { + // Mirrors Go internal_nexus_task_handler.go:592-647 and Java NexusTaskHandlerImpl.java:236-269. + yield 'INVALID_ARGUMENT → BadRequest' => [Code::INVALID_ARGUMENT, ErrorType::BadRequest, RetryBehavior::Unspecified]; + yield 'ALREADY_EXISTS → Internal/NonRetryable' => [Code::ALREADY_EXISTS, ErrorType::Internal, RetryBehavior::NonRetryable]; + yield 'FAILED_PRECONDITION → Internal/NonRetryable' => [Code::FAILED_PRECONDITION, ErrorType::Internal, RetryBehavior::NonRetryable]; + yield 'OUT_OF_RANGE → Internal/NonRetryable' => [Code::OUT_OF_RANGE, ErrorType::Internal, RetryBehavior::NonRetryable]; + yield 'ABORTED → Unavailable' => [Code::ABORTED, ErrorType::Unavailable, RetryBehavior::Unspecified]; + yield 'UNAVAILABLE → Unavailable' => [Code::UNAVAILABLE, ErrorType::Unavailable, RetryBehavior::Unspecified]; + yield 'CANCELLED → Internal' => [Code::CANCELLED, ErrorType::Internal, RetryBehavior::Unspecified]; + yield 'DATA_LOSS → Internal' => [Code::DATA_LOSS, ErrorType::Internal, RetryBehavior::Unspecified]; + yield 'INTERNAL → Internal' => [Code::INTERNAL, ErrorType::Internal, RetryBehavior::Unspecified]; + yield 'UNKNOWN → Internal' => [Code::UNKNOWN, ErrorType::Internal, RetryBehavior::Unspecified]; + yield 'UNAUTHENTICATED → Internal' => [Code::UNAUTHENTICATED, ErrorType::Internal, RetryBehavior::Unspecified]; + yield 'PERMISSION_DENIED → Internal' => [Code::PERMISSION_DENIED, ErrorType::Internal, RetryBehavior::Unspecified]; + yield 'NOT_FOUND → NotFound' => [Code::NOT_FOUND, ErrorType::NotFound, RetryBehavior::Unspecified]; + yield 'RESOURCE_EXHAUSTED → ResourceExhausted' => [Code::RESOURCE_EXHAUSTED, ErrorType::ResourceExhausted, RetryBehavior::Unspecified]; + yield 'UNIMPLEMENTED → NotImplemented' => [Code::UNIMPLEMENTED, ErrorType::NotImplemented, RetryBehavior::Unspecified]; + yield 'DEADLINE_EXCEEDED → UpstreamTimeout' => [Code::DEADLINE_EXCEEDED, ErrorType::UpstreamTimeout, RetryBehavior::Unspecified]; + } + + #[DataProvider('grpcCodeMatrix')] + public function testMapsGrpcCodeToHandlerException( + int $code, + ErrorType $expectedType, + RetryBehavior $expectedRetry, + ): void { + $exception = self::makeServiceClientException($code, 'wire detail'); + + $mapped = HandlerErrorMapper::mapToHandlerException($exception); + + self::assertInstanceOf(HandlerException::class, $mapped); + self::assertSame($expectedType, $mapped->errorType); + self::assertSame($expectedRetry, $mapped->retryBehavior); + self::assertSame($exception, $mapped->getPrevious(), 'Original gRPC exception preserved as cause'); + } + + public function testUnknownGrpcCodeFallsBackToInternal(): void + { + $exception = self::makeServiceClientException(9999, 'weird'); + + $mapped = HandlerErrorMapper::mapToHandlerException($exception); + + self::assertInstanceOf(HandlerException::class, $mapped); + self::assertSame(ErrorType::Internal, $mapped->errorType); + self::assertSame(RetryBehavior::Unspecified, $mapped->retryBehavior); + } + + public function testNonRetryableApplicationFailureBecomesInternalNonRetryable(): void + { + $cause = new ApplicationFailure( + 'business invariant violated', + 'BusinessError', + true, + EncodedValues::empty(), + ); + + $mapped = HandlerErrorMapper::mapToHandlerException($cause); + + self::assertInstanceOf(HandlerException::class, $mapped); + self::assertSame(ErrorType::Internal, $mapped->errorType); + self::assertSame(RetryBehavior::NonRetryable, $mapped->retryBehavior); + self::assertSame($cause, $mapped->getPrevious()); + } + + public function testRetryableApplicationFailureIsNotMapped(): void + { + $cause = new ApplicationFailure( + 'transient', + 'TransientError', + false, + EncodedValues::empty(), + ); + + self::assertNull(HandlerErrorMapper::mapToHandlerException($cause)); + } + + public function testWorkflowNotFoundExceptionBecomesNotFound(): void + { + $cause = new WorkflowNotFoundException(null, new WorkflowExecution('wf-id', 'run-id')); + + $mapped = HandlerErrorMapper::mapToHandlerException($cause); + + self::assertInstanceOf(HandlerException::class, $mapped); + self::assertSame(ErrorType::NotFound, $mapped->errorType); + self::assertSame(RetryBehavior::Unspecified, $mapped->retryBehavior); + self::assertSame($cause, $mapped->getPrevious()); + } + + public function testWorkflowExceptionWithoutGrpcCauseIsNotMapped(): void + { + $cause = new WorkflowException(null, new WorkflowExecution('wf-id', 'run-id')); + + self::assertNull(HandlerErrorMapper::mapToHandlerException($cause)); + } + + public function testWorkflowFailedExceptionIsNotMapped(): void + { + $cause = new WorkflowFailedException(new WorkflowExecution('wf-id', 'run-id'), 'WorkflowType', 1, 0); + + self::assertNull(HandlerErrorMapper::mapToHandlerException($cause)); + } + + public function testWorkflowServiceExceptionUnwrapsTransientGrpcCause(): void + { + $grpc = self::makeServiceClientException(Code::UNAVAILABLE, 'frontend unavailable'); + $cause = new WorkflowServiceException(null, new WorkflowExecution('wf-id', 'run-id'), null, $grpc); + + $mapped = HandlerErrorMapper::mapToHandlerException($cause); + + self::assertInstanceOf(HandlerException::class, $mapped); + self::assertSame(ErrorType::Unavailable, $mapped->errorType); + self::assertSame(RetryBehavior::Unspecified, $mapped->retryBehavior); + self::assertTrue($mapped->isRetryable()); + self::assertSame($grpc, $mapped->getPrevious()); + } + + public function testWorkflowExecutionAlreadyStartedExceptionBecomesInternalNonRetryable(): void + { + $cause = new WorkflowExecutionAlreadyStartedException(new WorkflowExecution('wf-id', 'run-id'), 'WorkflowType'); + + $mapped = HandlerErrorMapper::mapToHandlerException($cause); + + self::assertInstanceOf(HandlerException::class, $mapped); + self::assertSame(ErrorType::Internal, $mapped->errorType); + self::assertSame(RetryBehavior::NonRetryable, $mapped->retryBehavior); + self::assertSame($cause, $mapped->getPrevious()); + } + + public function testGenericRuntimeExceptionIsNotMapped(): void + { + self::assertNull(HandlerErrorMapper::mapToHandlerException(new \RuntimeException('boom'))); + self::assertNull(HandlerErrorMapper::mapToHandlerException(new \LogicException('boom'))); + } + + private static function makeServiceClientException(int $code, string $detail): ServiceClientException + { + $status = new \stdClass(); + $status->code = $code; + $status->details = $detail; + $status->metadata = []; + + return new ServiceClientException($status); + } +} diff --git a/tests/Unit/Nexus/NexusContextAccessorTestCase.php b/tests/Unit/Nexus/NexusContextAccessorTestCase.php new file mode 100644 index 000000000..0510e74f6 --- /dev/null +++ b/tests/Unit/Nexus/NexusContextAccessorTestCase.php @@ -0,0 +1,85 @@ +env = new Environment(); + } + + protected function tearDown(): void + { + // Always clear — leaking a context would poison sibling tests that + // rely on `getOperationContext()` throwing outside a handler. + Nexus::setCurrentContext(null); + } + + public function testOutsideDispatchThrows(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The Nexus facade can be used only inside a Nexus operation handler.'); + Nexus::getOperationContext(); + } + + public function testReturnsCurrentContext(): void + { + /** @var WorkflowClientInterface&MockObject $client */ + $client = $this->createMock(WorkflowClientInterface::class); + $ctx = new NexusOperationContext('ns', 'tq'); + + Nexus::setCurrentContext(new NexusContext( + current: new OperationContext(service: 'svc', operation: 'op', env: $this->env), + operation: $ctx, + workflowClient: $client, + )); + + self::assertSame($ctx, Nexus::getOperationContext()); + self::assertSame('ns', Nexus::getOperationContext()->namespace); + self::assertSame('tq', Nexus::getOperationContext()->taskQueue); + } + + public function testPublicContextDoesNotExposeWorkflowClient(): void + { + $ctx = new NexusOperationContext('ns', 'tq'); + + self::assertFalse( + \property_exists($ctx, 'workflowClient'), + 'NexusOperationContext must not leak the WorkflowClient into public API surface', + ); + } + + public function testClearingRestoresOutsideBehavior(): void + { + Nexus::setCurrentContext(new NexusContext( + current: new OperationContext(service: 'svc', operation: 'op', env: $this->env), + operation: new NexusOperationContext('ns', 'tq'), + )); + Nexus::setCurrentContext(null); + + $this->expectException(\LogicException::class); + Nexus::getOperationContext(); + } +} diff --git a/tests/Unit/Nexus/NexusInvocationRegistryTestCase.php b/tests/Unit/Nexus/NexusInvocationRegistryTestCase.php new file mode 100644 index 000000000..f196eaae5 --- /dev/null +++ b/tests/Unit/Nexus/NexusInvocationRegistryTestCase.php @@ -0,0 +1,80 @@ +env = new Environment(); + } + + public function testGetReturnsNullForUnknownId(): void + { + $registry = new NexusInvocationRegistry(); + + self::assertNull($registry->get(42)); + } + + public function testUnregisterRemovesEntry(): void + { + $registry = new NexusInvocationRegistry(); + $canceller = new MethodCanceller($this->env); + $registry->register(5, $canceller); + + $registry->unregister(5); + + self::assertNull($registry->get(5)); + } + + public function testUnregisterUnknownIdIsNoOp(): void + { + $registry = new NexusInvocationRegistry(); + + // Must not throw. + $registry->unregister(999); + + self::assertNull($registry->get(999)); + } + + public function testEntriesAreIsolatedByKey(): void + { + $registry = new NexusInvocationRegistry(); + $a = new MethodCanceller($this->env); + $b = new MethodCanceller($this->env); + $registry->register(1, $a); + $registry->register(2, $b); + + self::assertSame($a, $registry->get(1)); + self::assertSame($b, $registry->get(2)); + } + + public function testCancelThroughRegistryAffectsOriginalCanceller(): void + { + $registry = new NexusInvocationRegistry(); + $canceller = new MethodCanceller($this->env); + $registry->register(7, $canceller); + + $registry->get(7)?->cancel('deadline'); + + self::assertTrue($canceller->isCancelled()); + self::assertSame('deadline', $canceller->getReason()); + } +} diff --git a/tests/Unit/Nexus/NexusOperationRoutesTestCase.php b/tests/Unit/Nexus/NexusOperationRoutesTestCase.php new file mode 100644 index 000000000..cbb49e531 --- /dev/null +++ b/tests/Unit/Nexus/NexusOperationRoutesTestCase.php @@ -0,0 +1,736 @@ +links and serialises them into the result. + */ + #[Operation] + public function reportCallerLinks(string $input): string; + + /** + * Reports whether $context->deadline was populated and roughly how far in the future it is. + */ + #[Operation] + public function reportDeadline(string $input): string; + + /** + * Polls cooperative method cancellation and reports the observed state and reason. + */ + #[Operation] + public function pollCancellation(string $input): string; + + /** + * Serialises the request headers visible on the handler context into the result. + */ + #[Operation] + public function reportHeaders(string $input): string; +} + +final class EchoAsyncHandler implements OperationHandlerInterface +{ + public function start( + OperationContext $context, + OperationStartDetails $details, + mixed $param, + ): OperationStartResult { + return OperationStartResult::async(new OperationInfo('async-token-' . $param, OperationState::Running)); + } + + public function cancel( + OperationContext $context, + OperationCancelDetails $details, + ): void {} +} + +final class EchoCancelRecordingHandler implements OperationHandlerInterface +{ + public function __construct( + private readonly EchoServiceImpl $service, + ) {} + + public function start( + OperationContext $context, + OperationStartDetails $details, + mixed $param, + ): OperationStartResult { + return OperationStartResult::async(new OperationInfo('cancel-token-' . $param, OperationState::Running)); + } + + public function cancel( + OperationContext $context, + OperationCancelDetails $details, + ): void { + $this->service->canceledTokens[] = $details->operationToken; + } +} + +final class EchoCancelThrowsHandler implements OperationHandlerInterface +{ + public function start( + OperationContext $context, + OperationStartDetails $details, + mixed $param, + ): OperationStartResult { + return OperationStartResult::async(new OperationInfo('cancel-throws-token-' . $param, OperationState::Running)); + } + + public function cancel( + OperationContext $context, + OperationCancelDetails $details, + ): void { + throw new \RuntimeException("cancel routine blew up for {$details->operationToken}"); + } +} + +class EchoServiceImpl implements EchoServiceInterface +{ + /** @var string[] */ + public array $canceledTokens = []; + + public function echo(string $input): string + { + return "echo:{$input}"; + } + + public function asyncEcho(): EchoAsyncHandler + { + return new EchoAsyncHandler(); + } + + public function failOp(string $input): string + { + throw OperationException::failed("fail:{$input}"); + } + + public function cancelOp(): EchoCancelRecordingHandler + { + return new EchoCancelRecordingHandler($this); + } + + public function cancelThrowsOp(): EchoCancelThrowsHandler + { + return new EchoCancelThrowsHandler(); + } + + public function echoWithLinks(string $input): string + { + Nexus::getCurrentOperationContext()->links->add( + new Link('http://test.local/resource/1', 'test.Resource'), + new Link('http://test.local/resource/2', 'test.Resource'), + ); + return "linked:{$input}"; + } + + public function reportCallerLinks(string $input): string + { + $details = Nexus::getStartDetails(); + $parts = []; + foreach ($details->links as $link) { + $parts[] = "{$link->uri}|{$link->type}"; + } + return \sprintf('caller-links:count=%d;items=[%s]', \count($details->links), \implode(';', $parts)); + } + + public function reportDeadline(string $input): string + { + $context = Nexus::getCurrentOperationContext(); + if ($context->deadline === null) { + return 'deadline:none'; + } + $delta = $context->deadline->getTimestamp() - (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->getTimestamp(); + return "deadline:set;delta_seconds={$delta}"; + } + + public function pollCancellation(string $input): string + { + $context = Nexus::getCurrentOperationContext(); + return \sprintf( + 'cancelled=%s;reason=%s', + $context->isMethodCancelled() ? '1' : '0', + $context->getMethodCancellationReason() ?? '', + ); + } + + public function reportHeaders(string $input): string + { + $parts = []; + foreach (Nexus::getCurrentOperationContext()->headers->all() as $name => $value) { + $parts[] = "{$name}={$value}"; + } + return \implode(';', $parts); + } +} + +// ── Integration tests ───────────────────────────────────────────── + +/** + * @group unit + * @group nexus + */ +#[CoversClass(NexusTaskHandler::class)] +final class NexusOperationRoutesTestCase extends AbstractUnit +{ + use AwaitsNexusPromise; + + private InvokeNexusOperation $invokeRoute; + private CancelNexusOperation $cancelRoute; + private DataConverter $dataConverter; + private EchoServiceImpl $serviceImpl; + private \Temporal\Worker\Environment\Environment $env; + private \Temporal\Internal\Nexus\NexusInvocationRegistry $invocationRegistry; + + // ── Sync operation ─────────────────────────────────────────── + + public function testSyncOperation(): void + { + $reply = $this->invoke('echo', 'hello'); + + self::assertFalse($reply->getOptions()['async']); + self::assertNull($reply->getOptions()['token'] ?? null); + self::assertSame('echo:hello', $this->decodePayload($reply)); + } + + public function testSyncOperationWithDifferentInputs(): void + { + foreach (['simple', 'with spaces', 'unicode: привет'] as $input) { + $reply = $this->invoke('echo', $input); + self::assertFalse($reply->getOptions()['async']); + self::assertSame("echo:{$input}", $this->decodePayload($reply), "Failed for input: {$input}"); + } + } + + // ── Async operation ────────────────────────────────────────── + + public function testAsyncOperationReturnsTokenInReplyOptions(): void + { + $reply = $this->invoke('asyncEcho', 'test'); + + self::assertTrue($reply->getOptions()['async']); + self::assertSame('async-token-test', $reply->getOptions()['token'] ?? null); + self::assertNull($reply->getPayloads(), 'async reply must not carry payloads'); + } + + // ── Operation error ────────────────────────────────────────── + + public function testOperationFailurePropagatesOperationException(): void + { + $request = $this->makeInvokeRequest('EchoService', 'failOp', 'reason'); + $deferred = new Deferred(); + + $this->expectException(OperationException::class); + $this->expectExceptionMessage('fail:reason'); + $this->invokeRoute->handle($request, [], $deferred); + $this->awaitReply($deferred); + } + + // ── Handler error ──────────────────────────────────────────── + + public function testHandlerErrorForUnknownService(): void + { + $request = $this->makeInvokeRequest('UnknownService', 'op', 'input'); + $deferred = new Deferred(); + try { + $this->invokeRoute->handle($request, [], $deferred); + $this->awaitReply($deferred); + self::fail('Expected HandlerException'); + } catch (NexusHandlerException $e) { + self::assertSame(NexusErrorType::NotFound, $e->errorType); + self::assertSame('NOT_FOUND', $e->errorType->value); + } + } + + public function testHandlerErrorForUnknownOperation(): void + { + $request = $this->makeInvokeRequest('EchoService', 'nonExistent', 'input'); + $deferred = new Deferred(); + try { + $this->invokeRoute->handle($request, [], $deferred); + $this->awaitReply($deferred); + self::fail('Expected HandlerException'); + } catch (NexusHandlerException $e) { + self::assertSame(NexusErrorType::NotFound, $e->errorType); + } + } + + // ── Cancel operation ───────────────────────────────────────── + + public function testCancelOperation(): void + { + $request = $this->makeCancelRequest('EchoService', 'cancelOp', 'my-token'); + $deferred = new Deferred(); + $this->cancelRoute->handle($request, [], $deferred); + $this->awaitCancelResult($deferred); + self::assertContains('my-token', $this->serviceImpl->canceledTokens); + } + + public function testCancelUnknownService(): void + { + $request = $this->makeCancelRequest('UnknownService', 'op', 'token'); + $deferred = new Deferred(); + try { + $this->cancelRoute->handle($request, [], $deferred); + $this->awaitCancelResult($deferred); + self::fail('Expected HandlerException'); + } catch (NexusHandlerException $e) { + self::assertSame(NexusErrorType::NotFound, $e->errorType); + } + } + + public function testCancelHandlerThrowingIsConvertedToHandlerError(): void + { + $request = $this->makeCancelRequest('EchoService', 'cancelThrowsOp', 'boom-token'); + $deferred = new Deferred(); + + $this->cancelRoute->handle($request, [], $deferred); + + $error = null; + $deferred->promise()->then( + null, + static function (\Throwable $e) use (&$error): void { + $error = $e; + }, + ); + + self::assertInstanceOf( + NexusHandlerException::class, + $error, + 'A throwing cancel routine must surface as a typed HandlerException rejection, never crash the worker.', + ); + self::assertSame(NexusErrorType::Internal, $error->errorType); + self::assertInstanceOf(\RuntimeException::class, $error->getPrevious()); + self::assertStringContainsString('boom-token', $error->getPrevious()->getMessage()); + } + + // ── Headers ────────────────────────────────────────────────── + + public function testRequestHeadersSurfaceOnHandlerContext(): void + { + $request = $this->makeServerRequest('InvokeNexusOperation', [ + 'service' => 'EchoService', + 'operation' => 'reportHeaders', + 'requestId' => 'req-1', + 'headers' => ['Authorization' => 'Bearer token123'], + ], EncodedValues::fromValues(['test'], $this->dataConverter)); + $deferred = new Deferred(); + $this->invokeRoute->handle($request, [], $deferred); + $reply = $this->awaitReply($deferred); + + // Header keys are normalized to lowercase on the handler side. + self::assertSame('authorization=Bearer token123', $this->decodePayload($reply)); + } + + // ── Method-cancel registry lifecycle ───────────────────────── + + public function testInvocationUnregisteredAfterSuccessfulStart(): void + { + $request = $this->makeServerRequest('InvokeNexusOperation', [ + 'service' => 'EchoService', + 'operation' => 'echo', + 'requestId' => 'reg-1', + 'invocationId' => 7, + ], EncodedValues::fromValues(['x'], $this->dataConverter)); + + $deferred = new Deferred(); + $this->invokeRoute->handle($request, [], $deferred); + $reply = $this->awaitReply($deferred); + + self::assertSame('echo:x', $this->decodePayload($reply)); + self::assertNull($this->invocationRegistry->get(7), 'entry must be unregistered after success'); + } + + public function testInvocationUnregisteredAfterFailedStart(): void + { + $request = $this->makeServerRequest('InvokeNexusOperation', [ + 'service' => 'EchoService', + 'operation' => 'failOp', + 'requestId' => 'reg-2', + 'invocationId' => 7, + ], EncodedValues::fromValues(['x'], $this->dataConverter)); + + $deferred = new Deferred(); + $this->invokeRoute->handle($request, [], $deferred); + + $error = null; + $deferred->promise()->then(null, static function (\Throwable $e) use (&$error): void { + $error = $e; + }); + + self::assertInstanceOf(OperationException::class, $error); + self::assertNull($this->invocationRegistry->get(7), 'entry must be unregistered after failure'); + } + + // ── Caller-side Nexus-Link propagation ─────────────────────── + + public function testCallerLinksPropagateFromOptionsToHandler(): void + { + $request = $this->makeServerRequest('InvokeNexusOperation', [ + 'service' => 'EchoService', + 'operation' => 'reportCallerLinks', + 'requestId' => 'links-1', + 'links' => [ + ['url' => 'https://caller.test/one', 'type' => 'example.one'], + ['url' => 'https://caller.test/two', 'type' => 'example.two'], + ], + ], EncodedValues::fromValues(['ignored'], $this->dataConverter)); + + $deferred = new Deferred(); + $this->invokeRoute->handle($request, [], $deferred); + $reply = $this->awaitReply($deferred); + $result = $this->decodePayload($reply); + + self::assertStringContainsString('count=2', $result); + self::assertStringContainsString('https://caller.test/one|example.one', $result); + self::assertStringContainsString('https://caller.test/two|example.two', $result); + } + + public function testMalformedCallerLinkRejectsRequest(): void + { + $request = $this->makeServerRequest('InvokeNexusOperation', [ + 'service' => 'EchoService', + 'operation' => 'reportCallerLinks', + 'requestId' => 'links-2', + 'links' => [ + ['url' => 'https://ok.test/a', 'type' => 'ok'], + ['url' => 'https://missing-type.test/b'], // no type → reject + ], + ], EncodedValues::fromValues(['x'], $this->dataConverter)); + + $deferred = new Deferred(); + $this->invokeRoute->handle($request, [], $deferred); + + $error = null; + $deferred->promise()->then(null, static function (\Throwable $e) use (&$error): void { + $error = $e; + }); + self::assertInstanceOf(\Temporal\Nexus\Exception\HandlerException::class, $error); + self::assertSame( + \Temporal\Nexus\Exception\ErrorType::BadRequest, + $error->errorType, + ); + self::assertStringContainsString('missing or empty "type"', $error->getMessage()); + } + + public function testLinksPayloadWrongShapeRejectsRequest(): void + { + $request = $this->makeServerRequest('InvokeNexusOperation', [ + 'service' => 'EchoService', + 'operation' => 'reportCallerLinks', + 'requestId' => 'links-3', + 'links' => 'not-a-list', + ], EncodedValues::fromValues(['x'], $this->dataConverter)); + + $deferred = new Deferred(); + $this->invokeRoute->handle($request, [], $deferred); + + $error = null; + $deferred->promise()->then(null, static function (\Throwable $e) use (&$error): void { + $error = $e; + }); + self::assertInstanceOf(\Temporal\Nexus\Exception\HandlerException::class, $error); + self::assertSame( + \Temporal\Nexus\Exception\ErrorType::BadRequest, + $error->errorType, + ); + } + + public function testAbsentLinksMeansEmptyList(): void + { + $reply = $this->invoke('reportCallerLinks', 'x'); + $result = $this->decodePayload($reply); + + self::assertStringContainsString('count=0', $result); + } + + // ── Handler-side links → reply.options.links ──────────── + + public function testHandlerAddedLinksAreSerializedIntoReplyOptions(): void + { + $reply = $this->invoke('echoWithLinks', 'hi'); + + self::assertFalse($reply->getOptions()['async']); + + $links = $reply->getOptions()['links'] ?? []; + self::assertCount(2, $links); + self::assertSame('http://test.local/resource/1', $links[0]['url']); + self::assertSame('test.Resource', $links[0]['type']); + self::assertSame('http://test.local/resource/2', $links[1]['url']); + } + + public function testNoLinksMeansEmptyLinksList(): void + { + $reply = $this->invoke('echo', 'hello'); + + self::assertSame([], $reply->getOptions()['links'] ?? [], 'links field must be empty when handler adds none'); + } + + // ── Deadline from Nexus timeout headers ────────────────────── + + public function testOperationTimeoutHeaderSetsDeadline(): void + { + $request = $this->makeServerRequest('InvokeNexusOperation', [ + 'service' => 'EchoService', + 'operation' => 'reportDeadline', + 'requestId' => 'dl-1', + 'headers' => ['Operation-Timeout' => '30s'], + ], EncodedValues::fromValues(['x'], $this->dataConverter)); + + $deferred = new Deferred(); + $this->invokeRoute->handle($request, [], $deferred); + $reply = $this->awaitReply($deferred); + $result = $this->decodePayload($reply); + + self::assertStringContainsString('deadline:set', $result); + self::assertMatchesRegularExpression('/delta_seconds=(2[5-9]|30|31)/', $result); + } + + public function testRequestTimeoutUsedWhenOperationTimeoutAbsent(): void + { + $request = $this->makeServerRequest('InvokeNexusOperation', [ + 'service' => 'EchoService', + 'operation' => 'reportDeadline', + 'requestId' => 'dl-2', + 'headers' => ['Request-Timeout' => '10s'], + ], EncodedValues::fromValues(['x'], $this->dataConverter)); + + $deferred = new Deferred(); + $this->invokeRoute->handle($request, [], $deferred); + $reply = $this->awaitReply($deferred); + $result = $this->decodePayload($reply); + + self::assertStringContainsString('deadline:set', $result); + self::assertMatchesRegularExpression('/delta_seconds=([5-9]|1[0-1])/', $result); + } + + public function testOperationTimeoutWinsOverRequestTimeout(): void + { + $request = $this->makeServerRequest('InvokeNexusOperation', [ + 'service' => 'EchoService', + 'operation' => 'reportDeadline', + 'requestId' => 'dl-3', + 'headers' => [ + 'Request-Timeout' => '5s', + 'Operation-Timeout' => '120s', + ], + ], EncodedValues::fromValues(['x'], $this->dataConverter)); + + $deferred = new Deferred(); + $this->invokeRoute->handle($request, [], $deferred); + $reply = $this->awaitReply($deferred); + $result = $this->decodePayload($reply); + + self::assertMatchesRegularExpression('/delta_seconds=1(1[5-9]|2[01])/', $result); + } + + public function testMalformedTimeoutHeaderIsSilentlyIgnored(): void + { + $request = $this->makeServerRequest('InvokeNexusOperation', [ + 'service' => 'EchoService', + 'operation' => 'reportDeadline', + 'requestId' => 'dl-4', + 'headers' => ['Operation-Timeout' => 'garbage'], + ], EncodedValues::fromValues(['x'], $this->dataConverter)); + + $deferred = new Deferred(); + $this->invokeRoute->handle($request, [], $deferred); + $reply = $this->awaitReply($deferred); + $result = $this->decodePayload($reply); + + self::assertStringContainsString('deadline:none', $result); + } + + public function testCaseInsensitiveTimeoutHeaderLookup(): void + { + $request = $this->makeServerRequest('InvokeNexusOperation', [ + 'service' => 'EchoService', + 'operation' => 'reportDeadline', + 'requestId' => 'dl-5', + 'headers' => ['operation-timeout' => '15s'], + ], EncodedValues::fromValues(['x'], $this->dataConverter)); + + $deferred = new Deferred(); + $this->invokeRoute->handle($request, [], $deferred); + $reply = $this->awaitReply($deferred); + $result = $this->decodePayload($reply); + + self::assertStringContainsString('deadline:set', $result); + } + + // ── Cooperative deadline-trip via env->now() ───────────────── + + public function testHandlerObservesDeadlineTripViaEnvNow(): void + { + // Advance env time past the operation deadline; a handler polling + // isMethodCancelled() must observe the cooperative deadline trip, + // and the reason must surface through env->now()-based checking. + $this->env->update(new TickInfo(new \DateTimeImmutable('+1 hour'))); + + $request = $this->makeServerRequest('InvokeNexusOperation', [ + 'service' => 'EchoService', + 'operation' => 'pollCancellation', + 'requestId' => 'cancel-trip-1', + 'headers' => ['Operation-Timeout' => '1s'], + ], EncodedValues::fromValues(['x'], $this->dataConverter)); + + $deferred = new Deferred(); + $this->invokeRoute->handle($request, [], $deferred); + $result = $this->decodePayload($this->awaitReply($deferred)); + + self::assertStringStartsWith('cancelled=1', $result); + self::assertStringContainsString('deadline exceeded', $result); + } + + public function testHandlerSeesNoCancellationBeforeDeadline(): void + { + $request = $this->makeServerRequest('InvokeNexusOperation', [ + 'service' => 'EchoService', + 'operation' => 'pollCancellation', + 'requestId' => 'cancel-trip-2', + 'headers' => ['Operation-Timeout' => '3600s'], + ], EncodedValues::fromValues(['x'], $this->dataConverter)); + + $deferred = new Deferred(); + $this->invokeRoute->handle($request, [], $deferred); + $result = $this->decodePayload($this->awaitReply($deferred)); + + self::assertStringStartsWith('cancelled=0', $result); + } + + // ── Route names ────────────────────────────────────────────── + + public function testInvokeNexusOperationRouteName(): void + { + self::assertSame('InvokeNexusOperation', $this->invokeRoute->getName()); + } + + public function testCancelNexusOperationRouteName(): void + { + self::assertSame('CancelNexusOperation', $this->cancelRoute->getName()); + } + + protected function setUp(): void + { + $this->dataConverter = DataConverter::createDefault(); + + $this->serviceImpl = new EchoServiceImpl(); + + $reader = new \Temporal\Internal\Declaration\Reader\NexusServiceReader(new \Spiral\Attributes\AttributeReader()); + $prototype = $reader->fromClass($this->serviceImpl::class)->withInstance($this->serviceImpl); + + $repository = new \Temporal\Internal\Declaration\Prototype\NexusServiceCollection(); + $repository->add($prototype, false); + + $this->env = new \Temporal\Worker\Environment\Environment(); + + $taskHandler = new \Temporal\Internal\Nexus\NexusTaskHandler( + $repository, + $this->dataConverter, + $this->env, + ); + + $marshaller = new \Temporal\Internal\Marshaller\Marshaller( + new \Temporal\Internal\Marshaller\Mapper\AttributeMapperFactory(new \Spiral\Attributes\AttributeReader()), + ); + $this->invocationRegistry = new \Temporal\Internal\Nexus\NexusInvocationRegistry(); + $this->invokeRoute = new InvokeNexusOperation($taskHandler, $this->invocationRegistry, $this->dataConverter, $marshaller, $this->env); + $this->cancelRoute = new CancelNexusOperation($taskHandler, $marshaller); + } + + // ── Helpers ────────────────────────────────────────────────── + + private function invoke(string $operation, string $input): CommandResponse + { + $request = $this->makeInvokeRequest('EchoService', $operation, $input); + $deferred = new Deferred(); + $this->invokeRoute->handle($request, [], $deferred); + return $this->awaitReply($deferred); + } + + private function decodePayload(CommandResponse $reply): string + { + $payloads = $reply->getPayloads(); + self::assertNotNull($payloads, 'sync reply must carry a payload'); + return $payloads->getValue(0, 'string'); + } + + private function makeInvokeRequest(string $service, string $operation, string $input): ServerRequest + { + return $this->makeServerRequest('InvokeNexusOperation', [ + 'service' => $service, + 'operation' => $operation, + 'requestId' => 'test-' . \bin2hex(\random_bytes(4)), + ], EncodedValues::fromValues([$input], $this->dataConverter)); + } + + private function makeCancelRequest(string $service, string $operation, string $token): ServerRequest + { + return $this->makeServerRequest('CancelNexusOperation', [ + 'service' => $service, + 'operation' => $operation, + 'operationToken' => $token, + ]); + } + + private function makeServerRequest(string $name, array $options, ?ValuesInterface $payloads = null): ServerRequest + { + return new ServerRequest( + name: $name, + info: new TickInfo(new \DateTimeImmutable()), + options: $options, + payloads: $payloads, + ); + } +} diff --git a/tests/Unit/Nexus/NexusServiceCollectionTestCase.php b/tests/Unit/Nexus/NexusServiceCollectionTestCase.php new file mode 100644 index 000000000..ac8200de8 --- /dev/null +++ b/tests/Unit/Nexus/NexusServiceCollectionTestCase.php @@ -0,0 +1,138 @@ +add($prototype); + + self::assertCount(1, $repo); + $items = \iterator_to_array($repo); + self::assertSame($prototype, \reset($items)); + } + + public function testRejectsDuplicateServiceName(): void + { + $repo = new NexusServiceCollection(); + $repo->add(self::buildPrototype(new DupAlphaImpl())); + + $this->expectException(\OutOfBoundsException::class); + $this->expectExceptionMessageMatches('/Entry with same identifier "Dup"/'); + $repo->add(self::buildPrototype(new DupBetaImpl())); + } + + public function testGetIteratorReturnsItemsInInsertionOrder(): void + { + $repo = new NexusServiceCollection(); + + $alpha = self::buildPrototype(new DupAlphaImpl()); + $solo = self::buildPrototype(new CollectionSoloImpl()); + + $repo->add($alpha); + $repo->add($solo); + + self::assertCount(2, $repo); + $items = \array_values(\iterator_to_array($repo)); + self::assertSame($alpha, $items[0]); + self::assertSame($solo, $items[1]); + } + + public function testSelfRegistrationRejected(): void + { + // Registering reflection-built prototypes from the same class twice fails: + // both produce the same service name, and the collection guards by ID. + $repo = new NexusServiceCollection(); + $instance = new DupAlphaImpl(); + $repo->add(self::buildPrototype($instance)); + + $this->expectException(\OutOfBoundsException::class); + $repo->add(self::buildPrototype($instance)); + } + + /** + * Reproduces the Worker registration flow: read prototype, bind the + * service instance via factory closure. + */ + private static function buildPrototype(object $instance): NexusServicePrototype + { + $reader = new NexusServiceReader(new AttributeReader()); + return $reader->fromClass($instance::class)->withInstance($instance); + } +} diff --git a/tests/Unit/Nexus/NexusTaskHandlerTestCase.php b/tests/Unit/Nexus/NexusTaskHandlerTestCase.php new file mode 100644 index 000000000..823b77ae1 --- /dev/null +++ b/tests/Unit/Nexus/NexusTaskHandlerTestCase.php @@ -0,0 +1,606 @@ + Headers seen by the most recent start dispatch. */ + public static array $capturedStartHeaders = []; + + /** @var array Headers seen by the most recent cancel dispatch. */ + public static array $capturedCancelHeaders = []; + + /** @var string|null Namespace seen by the most recent start dispatch. */ + public static ?string $capturedStartNamespace = null; + + /** @var string|null Task queue seen by the most recent start dispatch. */ + public static ?string $capturedStartTaskQueue = null; + + public function sayHello(string $name): string + { + self::$capturedStartHeaders = Nexus::getCurrentOperationContext()->headers->all(); + if (Nexus::getCurrentContext()->operation !== null) { + self::$capturedStartNamespace = Nexus::getOperationContext()->namespace; + self::$capturedStartTaskQueue = Nexus::getOperationContext()->taskQueue; + } + return "Hello, {$name}!"; + } + + public function asyncOp(): GreetingAsyncOpHandler + { + return new GreetingAsyncOpHandler(); + } + + public function failingOp(string $input): string + { + throw OperationException::failed('Something went wrong'); + } + + public function richCauseFailingOp(string $input): string + { + throw OperationException::failed( + 'outer-failure', + new ApplicationFailure( + 'inner-detail', + 'CustomBusinessType', + false, + EncodedValues::fromValues(['marker']), + ), + ); + } + + public function cancelableOp(): GreetingCancelableOpHandler + { + return new GreetingCancelableOpHandler(); + } + + public function shout(string $input): string + { + return \strtoupper($input) . '!'; + } + + public function grpcFailingOp(string $input): string + { + $status = new \stdClass(); + $status->code = Code::NOT_FOUND; + $status->details = 'workflow vanished'; + $status->metadata = []; + throw new ServiceClientException($status); + } + + public function appFailureOp(string $input): string + { + throw new ApplicationFailure( + 'business invariant violated', + 'BusinessError', + true, // nonRetryable + EncodedValues::empty(), + ); + } + + public function genericFailingOp(string $input): string + { + throw new \RuntimeException('something blew up'); + } + + public function deadlineEchoOp(string $input): string + { + $deadline = Nexus::getCurrentOperationContext()->deadline; + return $deadline === null ? 'none' : $deadline->format(\DATE_ATOM); + } +} + +final class GreetingAsyncOpHandler implements OperationHandlerInterface +{ + public function start( + OperationContext $context, + OperationStartDetails $details, + mixed $param, + ): OperationStartResult { + Nexus::getCurrentOperationContext()->links->add( + new Link('http://example.com/workflow/123', 'temporal.workflow'), + ); + return OperationStartResult::async(new OperationInfo('op-token-123', OperationState::Running)); + } + + public function cancel( + OperationContext $context, + OperationCancelDetails $details, + ): void {} +} + +final class GreetingCancelableOpHandler implements OperationHandlerInterface +{ + public function start( + OperationContext $context, + OperationStartDetails $details, + mixed $param, + ): OperationStartResult { + return OperationStartResult::async(new OperationInfo('cancel-token-456', OperationState::Running)); + } + + public function cancel( + OperationContext $context, + OperationCancelDetails $details, + ): void { + TestGreetingServiceImpl::$capturedCancelHeaders = Nexus::getCurrentOperationContext()->headers->all(); + if ($details->operationToken === 'unknown') { + throw HandlerException::create(ErrorType::NotFound, 'Not found'); + } + } +} + +/** + * @group unit + * @group nexus + */ +#[CoversClass(NexusTaskHandler::class)] +final class NexusTaskHandlerTestCase extends AbstractUnit +{ + private NexusTaskHandler $handler; + private DataConverterInterface $dataConverter; + private EnvironmentInterface $env; + + public function testStartSyncOperation(): void + { + $request = $this->buildStartRequest('TestGreetingService', 'sayHello', 'World'); + + $response = $this->handler->handleStartOperation($request, new NexusOperationContext()); + + self::assertTrue($response->hasStartOperation()); + $startResp = $response->getStartOperation(); + self::assertTrue($startResp->hasSyncSuccess()); + + $payload = $startResp->getSyncSuccess()->getPayload(); + self::assertNotNull($payload); + + $result = $this->dataConverter->fromPayload($payload, 'string'); + self::assertSame('Hello, World!', $result); + } + + public function testStartAsyncOperation(): void + { + $request = $this->buildStartRequest('TestGreetingService', 'asyncOp', 'input'); + + $response = $this->handler->handleStartOperation($request, new NexusOperationContext()); + + self::assertTrue($response->hasStartOperation()); + $startResp = $response->getStartOperation(); + self::assertTrue($startResp->hasAsyncSuccess()); + + $asyncResp = $startResp->getAsyncSuccess(); + self::assertSame('op-token-123', $asyncResp->getOperationToken()); + + $links = $asyncResp->getLinks(); + self::assertCount(1, $links); + self::assertSame('http://example.com/workflow/123', $links[0]->getUrl()); + self::assertSame('temporal.workflow', $links[0]->getType()); + } + + public function testStartOperationWithOperationException(): void + { + $request = $this->buildStartRequest('TestGreetingService', 'failingOp', 'input'); + + $this->expectException(OperationException::class); + $this->expectExceptionMessage('Something went wrong'); + $this->handler->handleStartOperation($request, new NexusOperationContext()); + } + + public function testStartOperationPropagatesOperationExceptionCauseChain(): void + { + $request = $this->buildStartRequest('TestGreetingService', 'richCauseFailingOp', 'input'); + + try { + $this->handler->handleStartOperation($request, new NexusOperationContext()); + self::fail('Expected OperationException'); + } catch (OperationException $e) { + self::assertSame('outer-failure', $e->getMessage()); + + $cause = $e->getPrevious(); + self::assertInstanceOf(ApplicationFailure::class, $cause); + self::assertSame('CustomBusinessType', $cause->getType()); + self::assertSame('inner-detail', $cause->getOriginalMessage()); + self::assertSame('marker', $cause->getDetails()->getValue(0, 'string')); + } + } + + public function testStartOperationWithUnknownService(): void + { + $request = $this->buildStartRequest('NonExistentService', 'op', 'input'); + + $this->expectException(HandlerException::class); + $this->handler->handleStartOperation($request, new NexusOperationContext()); + } + + public function testCancelOperation(): void + { + $request = $this->buildCancelRequest('TestGreetingService', 'cancelableOp', 'cancel-token-456'); + + $response = $this->handler->handleCancelOperation($request, new NexusOperationContext()); + + self::assertTrue($response->hasCancelOperation()); + } + + public function testCancelOperationWithHandlerException(): void + { + $request = $this->buildCancelRequest('TestGreetingService', 'cancelableOp', 'unknown'); + + $this->expectException(HandlerException::class); + $this->handler->handleCancelOperation($request, new NexusOperationContext()); + } + + public function testCancelOperationWithUnknownService(): void + { + $request = $this->buildCancelRequest('NonExistentService', 'op', 'token'); + + $this->expectException(HandlerException::class); + $this->handler->handleCancelOperation($request, new NexusOperationContext()); + } + + public function testCancelOperationWithEmptyTokenIsBadRequest(): void + { + $request = $this->buildCancelRequest('TestGreetingService', 'cancelableOp', ''); + + try { + $this->handler->handleCancelOperation($request, new NexusOperationContext()); + self::fail('Expected HandlerException'); + } catch (HandlerException $e) { + self::assertSame(ErrorType::BadRequest, $e->errorType); + self::assertSame(RetryBehavior::NonRetryable, $e->retryBehavior); + } + } + + public function testCancelOperationPropagatesHeadersToContext(): void + { + $request = $this->buildCancelRequest('TestGreetingService', 'cancelableOp', 'cancel-token-456', [ + 'X-Nexus-Trace-Id' => 'trace-1', + 'Authorization' => 'Bearer xyz', + ]); + + $this->handler->handleCancelOperation($request, new NexusOperationContext()); + + // OperationContext lowercases header keys on construction. + self::assertSame('trace-1', TestGreetingServiceImpl::$capturedCancelHeaders['x-nexus-trace-id'] ?? null); + self::assertSame('Bearer xyz', TestGreetingServiceImpl::$capturedCancelHeaders['authorization'] ?? null); + } + + public function testStartOperationPropagatesHeadersToContext(): void + { + $startReq = new StartOperationRequest(); + $startReq->setService('TestGreetingService'); + $startReq->setOperation('sayHello'); + $startReq->setRequestId('req-h1'); + $startReq->setPayload($this->dataConverter->toPayload('World')); + + $request = new Request(); + $request->setStartOperation($startReq); + $request->setHeader(['X-Nexus-Trace-Id' => 'trace-2']); + + $this->handler->handleStartOperation($request, new NexusOperationContext()); + + self::assertSame('trace-2', TestGreetingServiceImpl::$capturedStartHeaders['x-nexus-trace-id'] ?? null); + } + + public function testStartOperationUsesWireNamespace(): void + { + $request = $this->buildStartRequest('TestGreetingService', 'sayHello', 'World'); + + $operationContext = new NexusOperationContext(); + $operationContext->namespace = 'wire-ns'; + $operationContext->taskQueue = 'wire-tq'; + $this->handler->handleStartOperation($request, $operationContext); + + self::assertSame('wire-ns', TestGreetingServiceImpl::$capturedStartNamespace); + } + + public function testStartOperationHasNoNamespaceWhenWireAbsent(): void + { + $request = $this->buildStartRequest('TestGreetingService', 'sayHello', 'World'); + + $this->handler->handleStartOperation($request, new NexusOperationContext()); + + self::assertNull(TestGreetingServiceImpl::$capturedStartNamespace); + } + + public function testStartOperationUsesWireTaskQueue(): void + { + $request = $this->buildStartRequest('TestGreetingService', 'sayHello', 'World'); + + $operationContext = new NexusOperationContext(); + $operationContext->namespace = 'wire-ns'; + $operationContext->taskQueue = 'wire-tq'; + $this->handler->handleStartOperation($request, $operationContext); + + self::assertSame('wire-tq', TestGreetingServiceImpl::$capturedStartTaskQueue); + } + + public function testStartOperationHasNoTaskQueueWhenWireAbsent(): void + { + $request = $this->buildStartRequest('TestGreetingService', 'sayHello', 'World'); + + $this->handler->handleStartOperation($request, new NexusOperationContext()); + + self::assertNull(TestGreetingServiceImpl::$capturedStartTaskQueue); + } + + public function testHandlerErrorContainsErrorType(): void + { + $request = $this->buildStartRequest('NonExistentService', 'op', 'input'); + + try { + $this->handler->handleStartOperation($request, new NexusOperationContext()); + self::fail('Expected HandlerException'); + } catch (HandlerException $e) { + self::assertSame(ErrorType::NotFound, $e->errorType); + self::assertNotEmpty($e->getMessage()); + } + } + + public function testGrpcServiceClientExceptionMapsToHandlerError(): void + { + $request = $this->buildStartRequest('TestGreetingService', 'grpcFailingOp', 'input'); + + try { + $this->handler->handleStartOperation($request, new NexusOperationContext()); + self::fail('Expected HandlerException'); + } catch (HandlerException $e) { + self::assertSame(ErrorType::NotFound, $e->errorType); + self::assertInstanceOf(ServiceClientException::class, $e->getPrevious()); + } + } + + public function testNonRetryableApplicationFailureMapsToInternalNonRetryable(): void + { + $request = $this->buildStartRequest('TestGreetingService', 'appFailureOp', 'input'); + + try { + $this->handler->handleStartOperation($request, new NexusOperationContext()); + self::fail('Expected HandlerException'); + } catch (HandlerException $e) { + self::assertSame(ErrorType::Internal, $e->errorType); + self::assertSame(RetryBehavior::NonRetryable, $e->retryBehavior); + } + } + + public function testGenericThrowableNeverEscapesAsRawException(): void + { + $request = $this->buildStartRequest('TestGreetingService', 'genericFailingOp', 'input'); + + try { + $this->handler->handleStartOperation($request, new NexusOperationContext()); + self::fail('Expected HandlerException'); + } catch (HandlerException $e) { + self::assertSame(ErrorType::Internal, $e->errorType); + self::assertInstanceOf(\RuntimeException::class, $e->getPrevious()); + } + } + + public function testStartOperationWithMalformedTimeoutHeaderIsIgnored(): void + { + $request = $this->buildStartRequest('TestGreetingService', 'deadlineEchoOp', 'input'); + $request->setHeader(['Request-Timeout' => 'not-a-duration']); + + $response = $this->handler->handleStartOperation($request, new NexusOperationContext()); + + self::assertTrue($response->getStartOperation()->hasSyncSuccess()); + self::assertSame('none', $this->decodeSyncStringResult($response->getStartOperation())); + } + + public function testStartOperationWithValidTimeoutHeaderSetsDeadline(): void + { + $request = $this->buildStartRequest('TestGreetingService', 'deadlineEchoOp', 'input'); + $request->setHeader(['Request-Timeout' => '30s']); + + $response = $this->handler->handleStartOperation($request, new NexusOperationContext()); + + self::assertTrue($response->getStartOperation()->hasSyncSuccess()); + self::assertNotSame('none', $this->decodeSyncStringResult($response->getStartOperation())); + } + + public function testStartOperationWithAbsentTimeoutHeaderHasNoDeadline(): void + { + $request = $this->buildStartRequest('TestGreetingService', 'deadlineEchoOp', 'input'); + + $response = $this->handler->handleStartOperation($request, new NexusOperationContext()); + + self::assertTrue($response->getStartOperation()->hasSyncSuccess()); + self::assertSame('none', $this->decodeSyncStringResult($response->getStartOperation())); + } + + public function testStartOperationWithCallbackUrl(): void + { + $startReq = new StartOperationRequest(); + $startReq->setService('TestGreetingService'); + $startReq->setOperation('sayHello'); + $startReq->setRequestId('req-123'); + $startReq->setCallback('http://callback.example.com/complete'); + $startReq->setCallbackHeader(['Authorization' => 'Bearer token']); + $startReq->setPayload($this->dataConverter->toPayload('World')); + + $request = new Request(); + $request->setStartOperation($startReq); + $request->setHeader(['content-type' => 'application/json']); + + $response = $this->handler->handleStartOperation($request, new NexusOperationContext()); + + self::assertTrue($response->hasStartOperation()); + self::assertTrue($response->getStartOperation()->hasSyncSuccess()); + } + + public function testShoutSyncOperation(): void + { + $request = $this->buildStartRequest('TestGreetingService', 'shout', 'hello'); + + $response = $this->handler->handleStartOperation($request, new NexusOperationContext()); + + self::assertTrue($response->hasStartOperation()); + $startResp = $response->getStartOperation(); + self::assertTrue($startResp->hasSyncSuccess()); + + self::assertSame('HELLO!', $this->decodeSyncStringResult($startResp)); + } + + public function testStartOperationWithLinks(): void + { + $link = new \Temporal\Api\Nexus\V1\Link(); + $link->setUrl('http://caller.example.com/resource/1'); + $link->setType('caller.resource'); + + $startReq = new StartOperationRequest(); + $startReq->setService('TestGreetingService'); + $startReq->setOperation('sayHello'); + $startReq->setRequestId('req-456'); + $startReq->setLinks([$link]); + $startReq->setPayload($this->dataConverter->toPayload('World')); + + $request = new Request(); + $request->setStartOperation($startReq); + + $response = $this->handler->handleStartOperation($request, new NexusOperationContext()); + self::assertTrue($response->getStartOperation()->hasSyncSuccess()); + } + + protected function setUp(): void + { + $this->dataConverter = DataConverter::createDefault(); + $this->env = new Environment(); + + TestGreetingServiceImpl::$capturedStartHeaders = []; + TestGreetingServiceImpl::$capturedCancelHeaders = []; + TestGreetingServiceImpl::$capturedStartNamespace = null; + TestGreetingServiceImpl::$capturedStartTaskQueue = null; + + $this->handler = new NexusTaskHandler( + self::buildRepository(new TestGreetingServiceImpl()), + $this->dataConverter, + $this->env, + ); + } + + /** + * Build a {@see NexusServiceCollection} populated with the given service instances, + * using the same Reader wiring as the Worker. + */ + private static function buildRepository(object ...$instances): NexusServiceCollection + { + $reader = new NexusServiceReader(new AttributeReader()); + + $collection = new NexusServiceCollection(); + foreach ($instances as $instance) { + $prototype = $reader->fromClass(\get_class($instance))->withInstance($instance); + $collection->add($prototype, false); + } + return $collection; + } + + private function buildStartRequest(string $service, string $operation, string $input): Request + { + $startReq = new StartOperationRequest(); + $startReq->setService($service); + $startReq->setOperation($operation); + $startReq->setRequestId('test-request-id'); + $startReq->setPayload($this->dataConverter->toPayload($input)); + + $request = new Request(); + $request->setStartOperation($startReq); + return $request; + } + + private function decodeSyncStringResult( + \Temporal\Api\Nexus\V1\StartOperationResponse $startResp, + ): string { + $payload = $startResp->getSyncSuccess()->getPayload(); + self::assertNotNull($payload); + + return (string) $this->dataConverter->fromPayload($payload, 'string'); + } + + /** + * @param array $headers + */ + private function buildCancelRequest(string $service, string $operation, string $token, array $headers = []): Request + { + $cancelReq = new CancelOperationRequest(); + $cancelReq->setService($service); + $cancelReq->setOperation($operation); + $cancelReq->setOperationToken($token); + + $request = new Request(); + $request->setCancelOperation($cancelReq); + if ($headers !== []) { + $request->setHeader($headers); + } + return $request; + } +} diff --git a/tests/Unit/Nexus/WorkflowRunOperationTestCase.php b/tests/Unit/Nexus/WorkflowRunOperationTestCase.php new file mode 100644 index 000000000..7c5f96093 --- /dev/null +++ b/tests/Unit/Nexus/WorkflowRunOperationTestCase.php @@ -0,0 +1,400 @@ +createMock(WorkflowStubInterface::class); + + $capturedOptions = null; + $this->client->expects(self::once()) + ->method('newWorkflowStub') + ->willReturnCallback(static function (string $class, ?WorkflowOptions $options = null) use ($stub, &$capturedOptions) { + $capturedOptions = $options; + return $stub; + }); + + $this->client->expects(self::once()) + ->method('start') + ->with($stub, 42) + ->willReturn($this->createMock(\Temporal\Workflow\WorkflowRunInterface::class)); + + $handle = WorkflowHandle::fromWorkflowMethod( + FakeWorkflow::class, + WorkflowOptions::new()->withWorkflowId(self::WID), + 42, + ); + + $info = WorkflowRunStarter::start( + $handle, + new OperationStartDetails( + requestId: 'req-1', + callbackUrl: 'https://callback.example/done', + callbackHeaders: ['X-Caller' => 'demo'], + links: [], + ), + ); + + self::assertInstanceOf(OperationInfo::class, $info); + self::assertSame(OperationState::Running, $info->state); + + $expectedToken = WorkflowRunOperationToken::generate(self::NS, self::WID); + self::assertSame($expectedToken, $info->token); + + self::assertNotNull($capturedOptions); + self::assertSame('req-1', $capturedOptions->requestId); + self::assertCount(1, $capturedOptions->completionCallbacks); + + $callback = $capturedOptions->completionCallbacks[0]; + self::assertSame('https://callback.example/done', $callback->url); + self::assertSame('demo', $callback->headers['X-Caller']); + self::assertSame($expectedToken, $callback->headers['Nexus-Operation-Token']); + self::assertSame($expectedToken, $callback->headers['Nexus-Operation-Id']); + } + + public function testStartPreservesCallerProvidedTokenHeaders(): void + { + $stub = $this->createMock(WorkflowStubInterface::class); + + $captured = null; + $this->client->method('newWorkflowStub')->willReturnCallback( + static function (string $class, ?WorkflowOptions $options = null) use ($stub, &$captured) { + $captured = $options; + return $stub; + }, + ); + $this->client->method('start')->willReturn($this->createMock(\Temporal\Workflow\WorkflowRunInterface::class)); + + WorkflowRunStarter::start( + WorkflowHandle::fromWorkflowMethod( + FakeWorkflow::class, + WorkflowOptions::new()->withWorkflowId(self::WID), + ), + new OperationStartDetails( + requestId: 'req-1', + callbackUrl: 'https://callback.example/done', + callbackHeaders: [ + 'nexus-operation-token' => 'caller-token', + 'Nexus-Operation-Id' => 'caller-id', + ], + links: [], + ), + ); + + $callback = $captured->completionCallbacks[0]; + self::assertSame('caller-token', $callback->headers['nexus-operation-token']); + self::assertSame('caller-id', $callback->headers['Nexus-Operation-Id']); + self::assertArrayNotHasKey('Nexus-Operation-Token', $callback->headers); + self::assertCount(2, $callback->headers); + } + + public function testStartWithoutCallbackOmitsCompletionCallback(): void + { + $stub = $this->createMock(WorkflowStubInterface::class); + + $captured = null; + $this->client->method('newWorkflowStub')->willReturnCallback( + static function (string $class, ?WorkflowOptions $options = null) use ($stub, &$captured) { + $captured = $options; + return $stub; + }, + ); + $this->client->method('start')->willReturn($this->createMock(\Temporal\Workflow\WorkflowRunInterface::class)); + + WorkflowRunStarter::start( + WorkflowHandle::fromWorkflowMethod( + FakeWorkflow::class, + WorkflowOptions::new()->withWorkflowId(self::WID), + ), + new OperationStartDetails(requestId: 'req-no-cb', callbackUrl: null, callbackHeaders: [], links: []), + ); + + self::assertSame([], $captured->completionCallbacks); + self::assertSame('req-no-cb', $captured->requestId); + } + + public function testStartUsesNexusContextTaskQueueByDefault(): void + { + $stub = $this->createMock(WorkflowStubInterface::class); + + $captured = null; + $this->client->method('newWorkflowStub')->willReturnCallback( + static function (string $class, ?WorkflowOptions $options = null) use ($stub, &$captured) { + $captured = $options; + return $stub; + }, + ); + $this->client->method('start')->willReturn($this->createMock(\Temporal\Workflow\WorkflowRunInterface::class)); + + WorkflowRunStarter::start( + WorkflowHandle::fromWorkflowMethod( + FakeWorkflow::class, + WorkflowOptions::new()->withWorkflowId(self::WID), + ), + new OperationStartDetails(requestId: 'req-test', callbackUrl: null, callbackHeaders: [], links: []), + ); + + self::assertSame('tq', $captured->taskQueue, 'task queue should fall back to Nexus context value'); + } + + public function testStartPreservesUserProvidedTaskQueue(): void + { + $stub = $this->createMock(WorkflowStubInterface::class); + + $captured = null; + $this->client->method('newWorkflowStub')->willReturnCallback( + static function (string $class, ?WorkflowOptions $options = null) use ($stub, &$captured) { + $captured = $options; + return $stub; + }, + ); + $this->client->method('start')->willReturn($this->createMock(\Temporal\Workflow\WorkflowRunInterface::class)); + + WorkflowRunStarter::start( + WorkflowHandle::fromWorkflowMethod( + FakeWorkflow::class, + WorkflowOptions::new() + ->withWorkflowId(self::WID) + ->withTaskQueue('explicit-queue'), + ), + new OperationStartDetails(requestId: 'req-test', callbackUrl: null, callbackHeaders: [], links: []), + ); + + self::assertSame('explicit-queue', $captured->taskQueue); + } + + public function testStartAddsSelfLinkToOperationContext(): void + { + $stub = $this->createMock(WorkflowStubInterface::class); + $this->client->method('newWorkflowStub')->willReturn($stub); + + $run = $this->createMock(\Temporal\Workflow\WorkflowRunInterface::class); + $run->method('getExecution')->willReturn( + new \Temporal\Workflow\WorkflowExecution(self::WID, 'run-xyz'), + ); + $this->client->method('start')->willReturn($run); + + WorkflowRunStarter::start( + WorkflowHandle::fromWorkflowMethod( + FakeWorkflow::class, + WorkflowOptions::new()->withWorkflowId(self::WID), + ), + new OperationStartDetails(requestId: 'req-1', callbackUrl: null, callbackHeaders: [], links: []), + ); + + $links = Nexus::getCurrentOperationContext()->links->all(); + self::assertCount(1, $links); + self::assertSame(\Temporal\Internal\Nexus\NexusLinkConverter::TYPE_WORKFLOW_EVENT, $links[0]->type); + self::assertStringContainsString('/namespaces/' . self::NS . '/', $links[0]->uri); + self::assertStringContainsString('/workflows/' . self::WID . '/run-xyz/history', $links[0]->uri); + self::assertStringContainsString('eventType=WorkflowExecutionStarted', $links[0]->uri); + } + + public function testStartDoesNotForceConflictPolicyAndAlwaysAttachesOnConflictOptions(): void + { + $captured = $this->captureStartOptions( + WorkflowOptions::new()->withWorkflowId(self::WID), + ); + + self::assertSame(WorkflowIdConflictPolicy::Unspecified, $captured->workflowIdConflictPolicy); + self::assertNotNull($captured->onConflictOptions); + } + + public function testStartPreservesExplicitFailConflictPolicyAndStillAttachesOnConflictOptions(): void + { + $captured = $this->captureStartOptions( + WorkflowOptions::new() + ->withWorkflowId(self::WID) + ->withWorkflowIdConflictPolicy(WorkflowIdConflictPolicy::Fail), + ); + + self::assertSame(WorkflowIdConflictPolicy::Fail, $captured->workflowIdConflictPolicy); + self::assertNotNull($captured->onConflictOptions); + } + + public function testStartPreservesExplicitUseExistingConflictPolicy(): void + { + $captured = $this->captureStartOptions( + WorkflowOptions::new() + ->withWorkflowId(self::WID) + ->withWorkflowIdConflictPolicy(WorkflowIdConflictPolicy::UseExisting), + ); + + self::assertSame(WorkflowIdConflictPolicy::UseExisting, $captured->workflowIdConflictPolicy); + self::assertNotNull($captured->onConflictOptions); + } + + public function testStartAcceptsExplicitWorkflowId(): void + { + $captured = $this->captureStartOptions( + WorkflowOptions::new()->withWorkflowId(self::WID), + ); + + self::assertSame(self::WID, $captured->workflowId); + } + + public function testCancelDecodesTokenAndCancelsWorkflow(): void + { + $token = WorkflowRunOperationToken::generate(self::NS, self::WID); + $stub = $this->createMock(WorkflowStubInterface::class); + $stub->expects(self::once())->method('cancel'); + + $this->client->expects(self::once()) + ->method('newUntypedRunningWorkflowStub') + ->with(self::WID) + ->willReturn($stub); + + WorkflowRunOperation::cancel($token); + } + + public function testCancelRejectsBadTokenAsBadRequest(): void + { + try { + WorkflowRunOperation::cancel('not-a-real-token'); + self::fail('Expected HandlerException for malformed token'); + } catch (HandlerException $e) { + self::assertSame(ErrorType::BadRequest, $e->errorType); + self::assertFalse($e->isRetryable()); + self::assertStringContainsString('failed to parse operation token', $e->getMessage()); + } + } + + public function testCancelIgnoresTokenNamespaceAndCancelsByWorkflowId(): void + { + $token = WorkflowRunOperationToken::generate('other-ns', self::WID); + + $stub = $this->createMock(WorkflowStubInterface::class); + $stub->expects(self::once())->method('cancel'); + + $this->client->expects(self::once()) + ->method('newUntypedRunningWorkflowStub') + ->with(self::WID) + ->willReturn($stub); + + WorkflowRunOperation::cancel($token); + } + + public function testHandlerWithoutCancelRoutineAutoCancelsWorkflowRun(): void + { + $token = WorkflowRunOperationToken::generate(self::NS, self::WID); + + $stub = $this->createMock(WorkflowStubInterface::class); + $stub->expects(self::once())->method('cancel'); + + $this->client->expects(self::once()) + ->method('newUntypedRunningWorkflowStub') + ->with(self::WID) + ->willReturn($stub); + + $service = new GreetingService(static fn(string $n): string => $n); + $this->handlerFor($service, 'sayHello2')->cancel( + new OperationContext(service: 'svc', operation: 'sayHello2', env: $this->env), + new OperationCancelDetails(operationToken: $token), + ); + } + + protected function setUp(): void + { + $this->env = new Environment(); + $this->client = $this->createMock(WorkflowClientInterface::class); + Nexus::setCurrentContext(new NexusContext( + operation: new NexusOperationContext(self::NS, 'tq'), + workflowClient: $this->client, + current: new OperationContext(service: 'svc', operation: 'op', env: $this->env), + )); + } + + protected function tearDown(): void + { + Nexus::setCurrentContext(null); + } + + private function handlerFor(object $service, string $operation): MethodOperationHandler + { + $prototype = (new NexusServiceReader(new AttributeReader()))->fromClass(\get_class($service)); + $operationPrototype = $prototype->getOperations()[$operation]; + + return new MethodOperationHandler( + instance: $service, + startMethod: new \ReflectionMethod($service, $operationPrototype->methodName), + operation: $operationPrototype, + ); + } + + private function captureStartOptions(WorkflowOptions $handleOptions): WorkflowOptions + { + $stub = $this->createMock(WorkflowStubInterface::class); + + $captured = null; + $this->client->method('newWorkflowStub')->willReturnCallback( + static function (string $class, ?WorkflowOptions $options = null) use ($stub, &$captured) { + $captured = $options; + return $stub; + }, + ); + $this->client->method('start')->willReturn($this->createMock(\Temporal\Workflow\WorkflowRunInterface::class)); + + WorkflowRunStarter::start( + WorkflowHandle::fromWorkflowMethod(FakeWorkflow::class, $handleOptions), + new OperationStartDetails(requestId: 'req-policy', callbackUrl: null, callbackHeaders: [], links: []), + ); + + self::assertNotNull($captured); + return $captured; + } +} + +/** + * @internal Local fixture — needed only as a class-string for WorkflowHandle. + */ +final class FakeWorkflow {} diff --git a/tests/Unit/Nexus/WorkflowRunOperationTokenTestCase.php b/tests/Unit/Nexus/WorkflowRunOperationTokenTestCase.php new file mode 100644 index 000000000..0f52dae65 --- /dev/null +++ b/tests/Unit/Nexus/WorkflowRunOperationTokenTestCase.php @@ -0,0 +1,129 @@ + [ + 'namespace' => 'my-target-namespace', + 'workflowId' => 'my-workflow-id', + 'expected' => 'eyJ0IjoxLCJucyI6Im15LXRhcmdldC1uYW1lc3BhY2UiLCJ3aWQiOiJteS13b3JrZmxvdy1pZCJ9', + ], + 'short' => [ + 'namespace' => 'default', + 'workflowId' => 'abc', + 'expected' => 'eyJ0IjoxLCJucyI6ImRlZmF1bHQiLCJ3aWQiOiJhYmMifQ', + ], + 'special-chars-in-ns' => [ + 'namespace' => 'ns-with/special.chars', + 'workflowId' => 'wid-1234', + 'expected' => 'eyJ0IjoxLCJucyI6Im5zLXdpdGgvc3BlY2lhbC5jaGFycyIsIndpZCI6IndpZC0xMjM0In0', + ], + 'both-empty' => [ + 'namespace' => '', + 'workflowId' => '', + 'expected' => 'eyJ0IjoxLCJucyI6IiIsIndpZCI6IiJ9', + ], + ]; + } + + #[DataProvider('goldenVectors')] + public function testEncodeMatchesGoReference(string $namespace, string $workflowId, string $expected): void + { + self::assertSame($expected, WorkflowRunOperationToken::generate($namespace, $workflowId)); + } + + public function testRoundtripWorkflowIdAndNamespace(): void + { + $token = WorkflowRunOperationToken::generate('ns-A', 'wf-42'); + $decoded = WorkflowRunOperationToken::load($token); + + self::assertSame('ns-A', $decoded->namespace); + self::assertSame('wf-42', $decoded->workflowId); + } + + public function testLoadRejectsEmptyToken(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('token is empty'); + WorkflowRunOperationToken::load(''); + } + + public function testLoadRejectsBadBase64(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('failed to decode token'); + WorkflowRunOperationToken::load('!!!not-base64!!!'); + } + + public function testLoadRejectsNonJson(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('failed to unmarshal'); + // base64 of "not-json" + WorkflowRunOperationToken::load('bm90LWpzb24'); + } + + public function testLoadRejectsWrongType(): void + { + // {"t":2,"ns":"x","wid":"y"} — t=2 is not workflow-run + $bad = \rtrim(\strtr(\base64_encode('{"t":2,"ns":"x","wid":"y"}'), '+/', '-_'), '='); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('invalid workflow token type'); + WorkflowRunOperationToken::load($bad); + } + + public function testLoadRejectsMissingWorkflowId(): void + { + // {"t":1,"ns":"x"} — wid missing + $bad = \rtrim(\strtr(\base64_encode('{"t":1,"ns":"x"}'), '+/', '-_'), '='); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('missing workflow ID'); + WorkflowRunOperationToken::load($bad); + } + + public function testLoadRejectsExplicitVersionField(): void + { + // {"v":2,"t":1,"ns":"x","wid":"y"} — v present and non-zero + $bad = \rtrim(\strtr(\base64_encode('{"v":2,"t":1,"ns":"x","wid":"y"}'), '+/', '-_'), '='); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('unsupported version'); + WorkflowRunOperationToken::load($bad); + } + + public function testLoadAcceptsExplicitVersionZero(): void + { + // {"v":0,"t":1,"ns":"x","wid":"y"} — v=0 is the implicit default, + // accepted by the Go reference even when explicitly written out. + $ok = \rtrim(\strtr(\base64_encode('{"v":0,"t":1,"ns":"x","wid":"y"}'), '+/', '-_'), '='); + + $decoded = WorkflowRunOperationToken::load($ok); + + self::assertSame('x', $decoded->namespace); + self::assertSame('y', $decoded->workflowId); + } +} diff --git a/tests/Unit/Worker/NexusRegistrationGuardTestCase.php b/tests/Unit/Worker/NexusRegistrationGuardTestCase.php new file mode 100644 index 000000000..181e1661b --- /dev/null +++ b/tests/Unit/Worker/NexusRegistrationGuardTestCase.php @@ -0,0 +1,127 @@ +withWorkflowId('guard'), + ); + } +} + +#[Service] +interface GuardManualService +{ + #[AsyncOperation(output: 'string', input: 'string')] + public function manualOp(): GuardManualHandler; +} + +final class GuardManualHandler implements OperationHandlerInterface +{ + public function start( + OperationContext $context, + OperationStartDetails $details, + mixed $param, + ): OperationStartResult { + return OperationStartResult::async(new OperationInfo('guard-token', OperationState::Running)); + } + + public function cancel( + OperationContext $context, + OperationCancelDetails $details, + ): void {} +} + +class GuardManualServiceImpl implements GuardManualService +{ + public function manualOp(): GuardManualHandler + { + return new GuardManualHandler(); + } +} + +/** + * @group unit + * @group worker + * @group nexus + */ +final class NexusRegistrationGuardTestCase extends AbstractUnit +{ + public function testSyncOnlyServiceWithoutClientIsAllowed(): void + { + $worker = WorkerFactory::create()->newWorker(); + + $worker->registerNexusServiceImplementation(new GuardSyncOnlyServiceImpl()); + + self::assertCount(1, $worker->getNexusServices()); + } + + public function testAsyncServiceWithoutClientThrows(): void + { + $worker = WorkerFactory::create()->newWorker(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('declares async operation "runAsync", which needs cluster access'); + + $worker->registerNexusServiceImplementation(new GuardAsyncServiceImpl()); + } + + public function testFactoryBackedAsyncServiceWithoutClientIsAllowed(): void + { + $worker = WorkerFactory::create()->newWorker(); + + $worker->registerNexusServiceImplementation(new GuardManualServiceImpl()); + + self::assertCount(1, $worker->getNexusServices()); + } +} diff --git a/tests/Unit/Workflow/NexusOperationCancellationTypeTestCase.php b/tests/Unit/Workflow/NexusOperationCancellationTypeTestCase.php new file mode 100644 index 000000000..a5ed0fdeb --- /dev/null +++ b/tests/Unit/Workflow/NexusOperationCancellationTypeTestCase.php @@ -0,0 +1,28 @@ +value); + self::assertSame(1, NexusOperationCancellationType::Abandon->value); + self::assertSame(2, NexusOperationCancellationType::TryCancel->value); + self::assertSame(3, NexusOperationCancellationType::WaitRequested->value); + self::assertSame(4, NexusOperationCancellationType::WaitCompleted->value); + } +} diff --git a/tests/Unit/Workflow/NexusOperationHandleTestCase.php b/tests/Unit/Workflow/NexusOperationHandleTestCase.php new file mode 100644 index 000000000..7831892db --- /dev/null +++ b/tests/Unit/Workflow/NexusOperationHandleTestCase.php @@ -0,0 +1,91 @@ +promise(), + ); + + $received = null; + $handle->getResult()->then( + function ($v) use (&$received): void { + $received = $v; + }, + ); + + // A non-Values resolution flows through decodePromise unchanged. + $deferred->resolve('hello'); + self::assertSame('hello', $received); + } + + public function testGetResultIsIdempotent(): void + { + $handle = new NexusOperationHandle( + operationToken: null, + rawResult: (new Deferred())->promise(), + ); + + // Multiple calls must return the same promise — callers may attach + // handlers at different points in the workflow without spawning + // duplicate operations. + self::assertSame($handle->getResult(), $handle->getResult()); + } + + public function testTokenAvailableBeforeResultResolves(): void + { + // The handle is fully populated by the time the caller has it: token + // is observable while the result-promise is still pending. Workflow + // code can capture the token and pass it elsewhere before yielding. + $deferred = new Deferred(); + $handle = new NexusOperationHandle( + operationToken: 'observed-while-pending', + rawResult: $deferred->promise(), + ); + + self::assertSame('observed-while-pending', $handle->getOperationToken()); + + $resolved = false; + $handle->getResult()->then(static function () use (&$resolved): void { + $resolved = true; + }); + self::assertFalse($resolved); + } + + public function testGetOperationTokenReturnsNullForSyncOperation(): void + { + $handle = new NexusOperationHandle( + operationToken: null, + rawResult: (new Deferred())->promise(), + ); + + self::assertNull($handle->getOperationToken()); + } + + public function testGetOperationTokenReturnsTokenForAsyncOperation(): void + { + $handle = new NexusOperationHandle( + operationToken: 'op-token-xyz', + rawResult: (new Deferred())->promise(), + ); + + self::assertSame('op-token-xyz', $handle->getOperationToken()); + } +} diff --git a/tests/Unit/Workflow/NexusOperationOptionsTestCase.php b/tests/Unit/Workflow/NexusOperationOptionsTestCase.php new file mode 100644 index 000000000..c85144ac2 --- /dev/null +++ b/tests/Unit/Workflow/NexusOperationOptionsTestCase.php @@ -0,0 +1,112 @@ +endpoint); + self::assertSame('', $options->service); + self::assertSame(0, $options->scheduleToCloseTimeout->s); + } + + public function testWithEndpointSetsEndpoint(): void + { + $options = NexusOperationOptions::new()->withEndpoint('endpoint-1'); + + self::assertSame('endpoint-1', $options->endpoint); + } + + public function testWithEndpointRejectsEmptyString(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Nexus Endpoint must not be empty'); + + NexusOperationOptions::new()->withEndpoint(''); + } + + public function testWithEndpointIsImmutable(): void + { + $original = NexusOperationOptions::new(); + $updated = $original->withEndpoint('endpoint-2'); + + self::assertNotSame($original, $updated); + self::assertSame('', $original->endpoint, 'Original must stay pristine'); + self::assertSame('endpoint-2', $updated->endpoint); + } + + public function testWithServiceSetsService(): void + { + $options = NexusOperationOptions::new()->withService('MyService'); + + self::assertSame('MyService', $options->service); + } + + public function testWithServiceRejectsEmptyString(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Service Name must not be empty'); + + NexusOperationOptions::new()->withService(''); + } + + public function testWithScheduleToCloseTimeoutAcceptsSeconds(): void + { + $options = NexusOperationOptions::new()->withScheduleToCloseTimeout(30); + + self::assertSame(30, $options->scheduleToCloseTimeout->s); + } + + public function testCancellationTypeDefaultsToUnspecified(): void + { + $options = NexusOperationOptions::new(); + + self::assertSame(NexusOperationCancellationType::Unspecified, $options->cancellationType); + } + + public function testWithCancellationTypeAcceptsEnum(): void + { + $options = NexusOperationOptions::new() + ->withCancellationType(NexusOperationCancellationType::TryCancel); + + self::assertSame(NexusOperationCancellationType::TryCancel, $options->cancellationType); + } + + public function testWithCancellationTypeAcceptsInt(): void + { + $options = NexusOperationOptions::new() + ->withCancellationType(NexusOperationCancellationType::WaitCompleted->value); + + self::assertSame(NexusOperationCancellationType::WaitCompleted, $options->cancellationType); + } + + public function testWithCancellationTypeIsImmutable(): void + { + $original = NexusOperationOptions::new(); + $updated = $original->withCancellationType(NexusOperationCancellationType::Abandon); + + self::assertNotSame($original, $updated); + self::assertSame( + NexusOperationCancellationType::Unspecified, + $original->cancellationType, + 'Original must stay pristine', + ); + self::assertSame(NexusOperationCancellationType::Abandon, $updated->cancellationType); + } +}