├── .dockerignore ├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ ├── Test.yaml │ ├── build-cli.yaml │ └── cleanup.yaml ├── .gitignore ├── .husky └── pre-commit ├── .idea ├── .gitignore ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── codeception.xml ├── copyright │ ├── MIT.xml │ └── profiles_settings.xml ├── deployment.xml ├── durable-php.iml ├── inspectionProfiles │ └── Project_Default.xml ├── jsonSchemas.xml ├── misc.xml ├── modules.xml ├── php-docker-settings.xml ├── php-test-framework.xml ├── php.xml ├── phpspec.xml ├── phpunit.xml ├── prettier.xml ├── remote-mappings.xml ├── runConfigurations │ ├── Client.xml │ └── Workers.xml ├── scopes │ └── Source.xml ├── vcs.xml └── watcherTasks.xml ├── .nvmrc ├── Dockerfile ├── LICENSE ├── api-spec.yaml ├── bin ├── composer.phar └── dphp ├── bootstrap.php ├── cli ├── .gitignore ├── Makefile ├── appcontext │ └── auth.go ├── auth │ ├── createPermissions.go │ ├── createPermissions_test.go │ ├── keys.go │ ├── keys_test.go │ ├── resource.go │ ├── resourceManager.go │ ├── resource_test.go │ └── user.go ├── build-php.sh ├── build.sh ├── cli.go ├── config │ ├── config.go │ └── defaultConfiguration.json ├── glue │ ├── glue.go │ ├── messages.go │ ├── response_writer.go │ ├── sanity.go │ └── state.go ├── go.mod ├── go.sum ├── init │ ├── execute.go │ └── template │ │ ├── .gitignore │ │ ├── composer.json │ │ ├── dphp.json │ │ ├── pint.json │ │ ├── readme.md │ │ ├── src │ │ ├── Activities │ │ │ └── AddOne.php │ │ ├── Entities │ │ │ ├── CountInterface.php │ │ │ └── CountState.php │ │ ├── Orchestrations │ │ │ ├── Counter.php │ │ │ └── Password.php │ │ └── bootstrap.php │ │ └── tests │ │ └── integrationTest.php ├── lib │ ├── api.go │ ├── billing.go │ ├── consumer.go │ ├── index.go │ ├── indexer.go │ └── locks.go ├── message.json └── readme.md ├── composer.json ├── docs ├── Components │ ├── Beanstalkd.md │ └── Rethinkdb.md ├── activities.md ├── aggregation.md ├── chaining.md ├── entities.md ├── external-events.md ├── fan-in-out.md ├── human-interaction.md ├── monitoring.md ├── orchestration-rules.md ├── orchestrations.md ├── readme.md └── timers.md ├── junk.php ├── package.json ├── phpunit.xml ├── pint.json ├── readme.md ├── src ├── ActivityInfo.php ├── Contexts │ └── LoggingContextFactory.php ├── DurableClient.php ├── DurableClientInterface.php ├── DurableFuture.php ├── DurableLogger.php ├── EntityClientInterface.php ├── EntityContext.php ├── EntityContextInterface.php ├── Events │ ├── AwaitResult.php │ ├── Event.php │ ├── EventDescription.php │ ├── EventQueue.php │ ├── ExecutionTerminated.php │ ├── External.php │ ├── GiveOwnership.php │ ├── HasInnerEventInterface.php │ ├── PoisonPill.php │ ├── RaiseEvent.php │ ├── ReplyToInterface.php │ ├── RevokeRole.php │ ├── RevokeUser.php │ ├── ScheduleTask.php │ ├── ShareOwnership.php │ ├── ShareWithRole.php │ ├── ShareWithUser.php │ ├── Shares │ │ ├── Mode.php │ │ ├── NeedsSource.php │ │ ├── NeedsTarget.php │ │ └── Operation.php │ ├── StartExecution.php │ ├── StartOrchestration.php │ ├── StateTargetInterface.php │ ├── TargetType.php │ ├── TaskCompleted.php │ ├── TaskFailed.php │ ├── With.php │ ├── WithActivity.php │ ├── WithDelay.php │ ├── WithEntity.php │ ├── WithLock.php │ ├── WithOrchestration.php │ └── WithPriority.php ├── Exceptions │ ├── ActivityFailedException.php │ ├── ExternalException.php │ ├── FireEvent.php │ ├── LockException.php │ └── Unwind.php ├── Gateway │ └── Graph │ │ ├── AstAttribute.php │ │ ├── AstMethod.php │ │ ├── AstProperty.php │ │ ├── AstType.php │ │ ├── AstVisitor.php │ │ ├── GraphGenerator.php │ │ ├── MetaParser.php │ │ ├── Mode.php │ │ ├── NoopRenderer.php │ │ ├── ScalarRenderer.php │ │ ├── SchemaExtractor.php │ │ ├── SchemaGenerator.php │ │ ├── SchemaRendererInterface.php │ │ ├── TypeManager.php │ │ ├── Union.php │ │ ├── UnionRenderer.php │ │ ├── generic-schema.graphql │ │ └── index.php ├── Glue │ ├── Provenance.php │ ├── autoload.php │ └── glue.php ├── MonotonicClock.php ├── OrchestrationClientInterface.php ├── OrchestrationContext.php ├── OrchestrationContextInterface.php ├── Proxy │ ├── ClientProxy.php │ ├── Generator.php │ ├── ImpureException.php │ ├── OrchestratorProxy.php │ ├── Pure.php │ └── SpyProxy.php ├── RemoteEntityClient.php ├── RemoteOrchestrationClient.php ├── RetryOptions.php ├── Search │ └── EntityFilter.php ├── SerializedArray.php ├── State │ ├── AbstractHistory.php │ ├── ActivityHistory.php │ ├── ApplyStateInterface.php │ ├── Attributes │ │ ├── Activity.php │ │ ├── AllowCreateAll.php │ │ ├── AllowCreateForAuth.php │ │ ├── AllowCreateForRole.php │ │ ├── AllowCreateForUser.php │ │ ├── Entity.php │ │ ├── EntryPoint.php │ │ ├── Name.php │ │ ├── Operation.php │ │ ├── Orchestration.php │ │ ├── Tag.php │ │ └── TimeToLive.php │ ├── EntityHistory.php │ ├── EntityId.php │ ├── EntityLock.php │ ├── EntityState.php │ ├── EntrypointLocatorTrait.php │ ├── HistoricalStateTracker.php │ ├── Ids │ │ └── StateId.php │ ├── LockStateEnum.php │ ├── LockStateMachine.php │ ├── OrchestrationHistory.php │ ├── OrchestrationInstance.php │ ├── ParameterFillerTrait.php │ ├── ParentInstance.php │ ├── ReceivedSet.php │ ├── ResultSet.php │ ├── RuntimeStatus.php │ ├── Serializer.php │ ├── Session.php │ ├── StateInterface.php │ └── Status.php ├── Task.php ├── Testing │ ├── ActivityMock.php │ ├── DummyOrchestrationContext.php │ ├── EntityMock.php │ └── Exceptions │ │ └── ContinuedAsNew.php └── Transmutation │ └── Router.php └── tests ├── ClientTestCli.php ├── Common ├── LauncherEntity.php └── SayHello.php ├── Feature ├── ClientProxyTest.php ├── ExampleTest.php └── OrchestratorProxyTest.php ├── PerformanceTests ├── Bank │ ├── Account.php │ ├── AccountInterface.php │ ├── BankClient.php │ └── BankTransaction.php ├── CounterEntity.php ├── Dockerfile ├── ExampleTest.php ├── FanOutFanIn.php ├── FanOutFanInClient.php ├── HelloCities │ ├── Client.php │ └── HelloSequence.php ├── PerformanceClient.php ├── Sequence.php ├── SequenceClient.php ├── bootstrap.php ├── docker-compose.yaml ├── report.php └── src │ └── Benchmarks │ └── Bank │ ├── Account.php │ ├── AccountInterface.php │ ├── BankTransaction.php │ └── public │ └── bank.php ├── Pest.php ├── SignalEntity.http ├── StopWatch.php ├── TestCase.php ├── Unit ├── ActivityHistoryTest.php ├── EntityHistoryTest.php ├── GraphQlGeneratorTest.php ├── LockIntegrationTest.php ├── OrchestrationHistoryTest.php ├── TypeTests.php ├── final-event.json └── snapshot.json └── paratest.php /.gitattributes: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bottledcode/durable-php/2c33885041a6b6a8e9d12a43020a26630e0ab594/.gitattributes -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "composer" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | - package-ecosystem: "docker" 13 | directory: "/" 14 | schedule: 15 | interval: "daily" 16 | - package-ecosystem: "github-actions" 17 | directory: "/" 18 | schedule: 19 | interval: "daily" 20 | - package-ecosystem: "gomod" 21 | directory: "/cli/" 22 | schedule: 23 | interval: "daily" 24 | - package-ecosystem: "npm" 25 | directory: "/" 26 | schedule: 27 | interval: "daily" 28 | -------------------------------------------------------------------------------- /.github/workflows/Test.yaml: -------------------------------------------------------------------------------- 1 | name: Run Unit Tests 2 | on: 3 | pull_request: 4 | branches: 5 | - v2 6 | push: 7 | branches: 8 | - v2 9 | jobs: 10 | unit-tests-php: 11 | name: Unit Tests - PHP 12 | runs-on: self-hosted 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Setup PHP 16 | uses: shivammathur/setup-php@v2 17 | env: 18 | runner: self-hosted 19 | with: 20 | php-version: '8.3' 21 | extensions: pcntl, xdebug 22 | tools: composer 23 | - name: Get composer cache directory 24 | id: composer-cache 25 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 26 | - name: Cache dependencies 27 | uses: actions/cache@v4 28 | with: 29 | path: ${{ steps.composer-cache.outputs.dir }} 30 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} 31 | restore-keys: ${{ runner.os }}-composer- 32 | - name: Install dependencies 33 | run: composer install --ignore-platform-reqs 34 | - name: Lint 35 | continue-on-error: true 36 | run: vendor/bin/pint --test 37 | - name: Run tests 38 | run: ./vendor/bin/pest --coverage --coverage-clover coverage.xml 39 | - name: Upload coverage reports to Codecov 40 | uses: codecov/codecov-action@v5 41 | env: 42 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 43 | -------------------------------------------------------------------------------- /.github/workflows/cleanup.yaml: -------------------------------------------------------------------------------- 1 | name: Package Cleanup 2 | on: 3 | schedule: 4 | - cron: "0 0 * * *" 5 | jobs: 6 | dangling-images: 7 | name: Clean up dangling images 8 | runs-on: self-hosted 9 | steps: 10 | - uses: actions/delete-package-versions@v5 11 | with: 12 | package-name: runtime 13 | package-type: container 14 | min-versions-to-keep: 5 15 | delete-only-untagged-versions: 'true' 16 | old-test-images: 17 | name: Clean up test images 18 | runs-on: self-hosted 19 | steps: 20 | - uses: actions/delete-package-versions@v5 21 | with: 22 | package-name: 'runtime-tests' 23 | package-type: 'container' 24 | min-versions-to-keep: 10 25 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/codeception.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/copyright/MIT.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/copyright/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /.idea/deployment.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.idea/jsonSchemas.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/php-docker-settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /.idea/php-test-framework.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.idea/phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | -------------------------------------------------------------------------------- /.idea/prettier.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | -------------------------------------------------------------------------------- /.idea/remote-mappings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/runConfigurations/Client.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /.idea/runConfigurations/Workers.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /.idea/scopes/Source.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/watcherTasks.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 16 | 24 | 25 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20.13.1 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright ©2023 Robert Landers 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the “Software”), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, 14 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 15 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 16 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 17 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT 18 | OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE 19 | OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /bin/composer.phar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bottledcode/durable-php/2c33885041a6b6a8e9d12a43020a26630e0ab594/bin/composer.phar -------------------------------------------------------------------------------- /bin/dphp: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Copyright ©2024 Robert Landers 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the “Software”), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in 13 | # all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, 16 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 18 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 19 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT 20 | # OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE 21 | # OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | # 23 | arch="$(uname -m)" 24 | os="$(uname -s | tr '[:upper:]' '[:lower:]')" 25 | 26 | DIR=$(cd -P -- "$(dirname -- "$(command -v -- "$0")")" && pwd -P) 27 | 28 | # shellcheck disable=SC2068 29 | exec "$DIR/dphp-$os-$arch" $@ 30 | -------------------------------------------------------------------------------- /bootstrap.php: -------------------------------------------------------------------------------- 1 | =8.3" 12 | }, 13 | "require-dev": { 14 | "roave/security-advisories": "dev-latest", 15 | "laravel/pint": "^1.14" 16 | }, 17 | "type": "project" 18 | } 19 | -------------------------------------------------------------------------------- /cli/init/template/dphp.json: -------------------------------------------------------------------------------- 1 | { 2 | "project": "{{.Name}}", 3 | "bootstrap": "src/bootstrap.php", 4 | "nats": { 5 | "embeddedServer": true 6 | }, 7 | "historyRetentionDays": 1, 8 | "extensions": { 9 | "search": { 10 | "collections": [] 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /cli/init/template/pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "per" 3 | } 4 | -------------------------------------------------------------------------------- /cli/init/template/readme.md: -------------------------------------------------------------------------------- 1 | # Durable PHP Template 2 | 3 | You are now ready to get started! 4 | 5 | Run `dphp composer install` to install the project dependencies then `dphp` to start the server. In another terminal, 6 | you can run `dphp exec tests/integrationTest.php` to see some example usage from a client. 7 | 8 | Feel free to inspect the sources: 9 | 10 | - Activities/AddOne.php: a dumb activity that emulates a side effect 11 | - Entities/CountInterface.php: an interface for the counter entity 12 | - Entities/CountState.php: a counter entity that uses an orchestration 13 | - Orchestrations/Counter.php: an orchestration that locks the calling entity, adds some numbers, and calls the entity 14 | - Orchestrations/Password.php: an orchestration that uses a DI approach and waits for a password. -------------------------------------------------------------------------------- /cli/init/template/src/Activities/AddOne.php: -------------------------------------------------------------------------------- 1 | startNewOrchestration( 17 | Counter::class, 18 | ['count' => $number, 'replyTo' => EntityContext::current()->getId()], 19 | EntityContext::current()->getId()->id 20 | ); 21 | } 22 | 23 | public function receiveResult(int $result): void 24 | { 25 | $this->count = $result; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /cli/init/template/src/Orchestrations/Counter.php: -------------------------------------------------------------------------------- 1 | getInput(); 16 | 17 | if($input['replyTo'] ?? null) { 18 | // only we will be allowed to communicate with the entity 19 | $lock = $context->lockEntity($input['replyTo']); 20 | } 21 | 22 | for ($i = 0, $sum = 0; $i < $input['count']; $i++) { 23 | $resultFuture = $context->callActivity(AddOne::class, [$sum]); 24 | $sum = $context->waitOne($resultFuture); 25 | } 26 | 27 | if($input['replyTo'] ?? null) { 28 | $context->entityOp($input['replyTo'], fn(CountInterface $entity) => $entity->receiveResult($sum)); 29 | } 30 | $context->setCustomStatus($sum); 31 | if ($lock ?? null) { 32 | $lock->unlock(); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /cli/init/template/src/Orchestrations/Password.php: -------------------------------------------------------------------------------- 1 | context->getCurrentTime()->add($this->context->createInterval(minutes: 5)); 18 | 19 | $timeout = $this->context->createTimer($now); 20 | $entry = $this->context->waitForExternalEvent('password'); 21 | 22 | if($this->context->waitAny($entry, $timeout) === $entry && $entry->getResult() === ['password' => $password]) { 23 | $this->context->setCustomStatus('success!'); 24 | return true; 25 | } 26 | 27 | return false; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /cli/init/template/src/bootstrap.php: -------------------------------------------------------------------------------- 1 | \DI\autowire(\{{.Name}}\Entities\CountState::class) 9 | ]; 10 | })(); 11 | -------------------------------------------------------------------------------- /cli/init/template/tests/integrationTest.php: -------------------------------------------------------------------------------- 1 | signal($entity, fn(CountInterface $state) => $state->countTo(100)); 20 | $client->waitForCompletion(new OrchestrationInstance(\{{.Name}}\Orchestrations\Counter::class, $entity->id)); 21 | $time = number_format(microtime(true) - $start, 2); 22 | echo "Cool! That took $time seconds\n"; 23 | echo "Here's the state:\n" . json_encode($client->getEntitySnapshot($entity, CountInterface::class), JSON_PRETTY_PRINT) . "\n"; 24 | 25 | echo "Directly start an orchestration and wait for its completion\n"; 26 | $start = microtime(true); 27 | $orch = $client->startNew(\{{.Name}}\Orchestrations\Counter::class, ['count' => 100]); 28 | $client->waitForCompletion($orch); 29 | $time = number_format(microtime(true) - $start, 2); 30 | echo "Neat! That took $time seconds\nHere's the status:\n"; 31 | $status = $client->getStatus($orch); 32 | echo json_encode(Serializer::serialize($status), JSON_PRETTY_PRINT) . "\n"; 33 | 34 | echo "Now we will send a password: "; 35 | $start = microtime(true); 36 | $orch = $client->startNew(Password::class, ['password' => 'test']); 37 | $client->raiseEvent($orch, 'password', ['password' => 'test']); 38 | $client->waitForCompletion($orch); 39 | $status = $client->getStatus($orch); 40 | echo $status->output->toArray() === [true] ? 'success' : 'fail'; 41 | echo "\n"; 42 | -------------------------------------------------------------------------------- /cli/lib/index.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "context" 5 | "durable_php/config" 6 | "github.com/typesense/typesense-go/typesense" 7 | "github.com/typesense/typesense-go/typesense/api" 8 | "github.com/typesense/typesense-go/typesense/api/pointer" 9 | ) 10 | 11 | func CreateEntityIndex(ctx context.Context, client *typesense.Client, config *config.Config) error { 12 | _, err := client.Collection(config.Stream + "_entities").Retrieve(ctx) 13 | if err == nil { 14 | return nil 15 | } 16 | 17 | _, err = client.Collections().Create(ctx, &api.CollectionSchema{ 18 | EnableNestedFields: pointer.True(), 19 | Fields: []api.Field{ 20 | { 21 | Name: "name", Type: "string", Facet: pointer.True(), 22 | }, 23 | { 24 | Name: ".*", Type: "auto", 25 | }, 26 | { 27 | Name: ".*_facet", Type: "auto", Facet: pointer.True(), 28 | }, 29 | }, 30 | Name: config.Stream + "_entities", 31 | }) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | return nil 37 | } 38 | 39 | func CreateOrchestrationIndex(ctx context.Context, client *typesense.Client, config *config.Config) error { 40 | _, err := client.Collection(config.Stream + "_orchestrations").Retrieve(ctx) 41 | if err == nil { 42 | return nil 43 | } 44 | 45 | _, err = client.Collections().Create(ctx, &api.CollectionSchema{ 46 | EnableNestedFields: pointer.True(), 47 | Fields: []api.Field{ 48 | { 49 | Name: "execution_id", Type: "string", 50 | }, 51 | { 52 | Name: "instance_id", Type: "string", Facet: pointer.True(), 53 | }, 54 | { 55 | Name: "created_at", Type: "string", 56 | }, 57 | { 58 | Name: "custom_status", Type: "string", 59 | }, 60 | { 61 | Name: "last_updated_at", Type: "string", 62 | }, 63 | { 64 | Name: "runtime_status", Type: "string", 65 | }, 66 | }, 67 | Name: config.Stream + "_orchestrations", 68 | }) 69 | if err != nil { 70 | return err 71 | } 72 | 73 | return nil 74 | } 75 | -------------------------------------------------------------------------------- /cli/message.json: -------------------------------------------------------------------------------- 1 | { 2 | "destination": "activity:1234", 3 | "replyTo": "activity:1234", 4 | "scheduleAt": "2014-01-01T00:00:00Z", 5 | "eventId": "123-12333-2r34", 6 | "priority": 50, 7 | "locks": true, 8 | "isPoisoned": false, 9 | "event": "a long event string" 10 | } -------------------------------------------------------------------------------- /cli/readme.md: -------------------------------------------------------------------------------- 1 | # Durable PHP CLI 2 | 3 | A pretty simple wrapper around FrankenPHP for development. 4 | 5 | ## Building 6 | 7 | To build, simply run `./build-docker.sh` for linux, but run `./build.sh` on osx for osx. -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "authors": [ 3 | { 4 | "name": "Robert Landers", 5 | "email": "landers.robert@gmail.com" 6 | } 7 | ], 8 | "autoload": { 9 | "psr-4": { 10 | "Bottledcode\\DurablePhp\\": "src/" 11 | } 12 | }, 13 | "autoload-dev": { 14 | "psr-4": { 15 | "Bottledcode\\DurablePhp\\Tests\\": "tests/" 16 | } 17 | }, 18 | "bin": [ 19 | "bin/dphp" 20 | ], 21 | "config": { 22 | "allow-plugins": { 23 | "pestphp/pest-plugin": true 24 | } 25 | }, 26 | "license": "MIT", 27 | "name": "bottledcode/durable-php", 28 | "require": { 29 | "adhocore/cli": "^1.7.1", 30 | "amphp/file": "^3.1.1", 31 | "amphp/http-client": "^5.1.0", 32 | "amphp/log": "^v2.0.0", 33 | "amphp/parallel": "^2.2.9", 34 | "crell/serde": "^1.2.0", 35 | "nesbot/carbon": ">2.0", 36 | "php": ">=8.3", 37 | "php-di/php-di": "^7.0.7", 38 | "ramsey/uuid": "^4.7.6", 39 | "webonyx/graphql-php": "^15.12.5", 40 | "withinboredom/time": "^5.0.0", 41 | "nikic/php-parser": "^5.1" 42 | }, 43 | "require-dev": { 44 | "laravel/pint": "^1.17.2", 45 | "mockery/mockery": "^1.6.12", 46 | "pestphp/pest": "^2.35.1 || ^3.0.0" 47 | }, 48 | "scripts": { 49 | "test": "pest" 50 | }, 51 | "type": "library" 52 | } 53 | -------------------------------------------------------------------------------- /docs/Components/Beanstalkd.md: -------------------------------------------------------------------------------- 1 | # Beanstalkd 2 | 3 | Beanstalkd is a highly performant queue, capable of handling insane loads at high speed. 4 | By default, this is the preferred hosting strategy if you are self-hosting. 5 | 6 | ## Configuration options 7 | 8 | There's really not much to configure here, and all options are done through the cli: 9 | 10 | | option | default | description | 11 | | ------------------- | ---------------------------------- | ----------------------------------------------------------- | 12 | | --beanstalk | localhost:11300 | The beanstalk instance to connect | 13 | | --namespace | dphp | prefix queues with this to isolate instances of Durable PHP | 14 | | --execution-timeout | 60 | the amount of time, in seconds, to allow any code to run | 15 | | --monitor | activities,entities,orchestrations | the types of queues to monitor | 16 | -------------------------------------------------------------------------------- /docs/Components/Rethinkdb.md: -------------------------------------------------------------------------------- 1 | # Rethinkdb 2 | 3 | Rethinkdb is used to provide a redundant storage system and simple distributed locks. 4 | 5 | ## Configuration 6 | 7 | Configuration is done via environment variables: 8 | 9 | | variable | default | description | 10 | | ------------------ | ---------- | ---------------------- | 11 | | RETHINKDB_DATABASE | durablephp | The database to use | 12 | | RETHINKDB_HOST | localhost | The host to connect to | 13 | | RETHINKDB_PORT | 28015 | The port to connect to | 14 | | RETHINKDB_USER | admin | The user to connect as | 15 | | RETHINKDB_PASSWORD | | The password to use | 16 | -------------------------------------------------------------------------------- /docs/activities.md: -------------------------------------------------------------------------------- 1 | # Activities 2 | 3 | Activities are executed from orchestrations and can be any callable (including a class that implements `__invoke`). The 4 | result is memoized, therefore making it deterministic. Activities allow you to transform any code into a deterministic 5 | call, even if the code itself isn't deterministic. 6 | 7 | ## Retries 8 | 9 | Once executed, the exact same activity cannot be retried; however, you may call another activity that calls the same 10 | underlying code again. 11 | 12 | ## Storage 13 | 14 | All activity results are stored in the activities table for audit purposes and to ensure at-most-once execution. These 15 | can safely be deleted since the relevant parts are stored as part of the orchestration state. 16 | 17 | ## Guarantees 18 | 19 | Activities are at-most-once; they would only be executed more than once if something 20 | happens between execution and storing the results (which is unlikely). Once the results are persisted into the 21 | activities table, it cannot execute again. 22 | -------------------------------------------------------------------------------- /docs/chaining.md: -------------------------------------------------------------------------------- 1 | # Chaining 2 | 3 | The chaining pattern is when you need to execute some [activities](activities.md) in a specific order. 4 | The output of one activity is fed to the next, and so on, until a desired output is achieved. 5 | 6 | ## In regular php 7 | 8 | In regular PHP, you'd likely write this as a job, or a collection of jobs. A proper example might be an import process: 9 | 10 | ```mermaid 11 | flowchart 12 | Input --> Parse 13 | Parse --> Transform 14 | Transform --> Merge 15 | ``` 16 | 17 | These might be independent jobs that can be highly reused across different supported formats. 18 | 19 | ### Downsides 20 | 21 | The failure case in these types of chaining, is how the chain is constructed. 22 | Usually, once one job finishes, it must trigger the next job. 23 | Ideally, you'd have some way to "orchestrate" a chain, 24 | ensuring each step gets executed in the proper order, exactly once. 25 | 26 | ## In durable php 27 | 28 | In Durable PHP, [orchestrations](orchestrations.md) allow you to write the flow in an imperative style: 29 | 30 | ```php 31 | function chain(\Bottledcode\DurablePhp\OrchestrationContext $context): object { 32 | try { 33 | $input = $context->getInput(); 34 | $result = $context->waitOne($context->callActivity('parse', [$input])); 35 | $result = $context->waitOne($context->callActivity('transform', [$result])); 36 | $result = $context->waitOne($context->callActivity('merge', [$result])); 37 | return $result; 38 | } 39 | catch(Throwable) { 40 | // handle error 41 | } 42 | } 43 | ``` 44 | 45 | You can use the context parameter to invoke other functions/objects by name, pass parameters, and return the output. 46 | Each time you call `->waitOne`, Durable PHP checkpoints your execution and unloads your code until the result is 47 | completed. 48 | -------------------------------------------------------------------------------- /docs/entities.md: -------------------------------------------------------------------------------- 1 | # Entities 2 | 3 | Unlike orchestrations, entities manage a piece of state explicitly instead of through flow control (such as 4 | orchestrations). Entities provide a means for scaling by distributing the workload across many entities. 5 | 6 | ## Concepts 7 | 8 | Entities are very similar to actors or like services that communicate via the sending of messages. Like services, they 9 | perform work when prompted to do so (operations). 10 | 11 | ## Identity 12 | 13 | Entities are accessed by a unique identity. The identity is simply a pair of strings: a name, and id. Traditionally, 14 | the name is the class/interface name, and the id is a unique identifier. 15 | 16 | ## Accessing entities 17 | 18 | Entities may only be signaled, called, or snapshot. 19 | 20 | - Signal: invokes an entity but the return is lost. 21 | - Call: invokes an entity and the result is returned to the orchestration. 22 | - Snapshot: get the current snapshot of the entity. Note that this is not updated, you'll need to take a new snapshot to 23 | get updated information. 24 | 25 | There are specific rules for which type of operation may be performed in various contexts: 26 | 27 | - From clients: signal and snapshot. 28 | - From orchestrations: signal and call. 29 | - From entities: signal 30 | 31 | ## Coordination 32 | 33 | When coordinating between multiple entities, you should use an intermediary orchestration. 34 | 35 | ## Critical sections 36 | 37 | No operations from other clients are allowed on an entity while it's in a locked state. This behavior ensures that only 38 | one orchestration instance can lock an entity at a time. If a caller tries to invoke an operation on an entity while 39 | it's locked by an orchestration, that operation is placed in a pending operation queue. No pending operations are 40 | processed until after the holding orchestration releases its lock. 41 | 42 | Locks are internally persisted as part of an entity's durable state. 43 | 44 | Unlike transactions, critical sections don't automatically roll back changes when errors occur. Instead, any error 45 | handling, such as roll-back or retry, must be explicitly coded, for example by catching errors or exceptions. This 46 | design choice is intentional. 47 | -------------------------------------------------------------------------------- /docs/external-events.md: -------------------------------------------------------------------------------- 1 | # External Events 2 | 3 | External events allow you to influence the execution of an orchestrator from clients: 4 | 5 | ```php 6 | function orchestration(\Bottledcode\DurablePhp\OrchestrationContext $context) { 7 | $approved = $context->waitForExternalEvent('approval') 8 | if($context->waitOne($approved)) { 9 | // handle approval 10 | } else { 11 | // handle rejection 12 | } 13 | } 14 | ``` 15 | 16 | In the above example, we wait for a single event, but we can also wait for the first one: 17 | 18 | ```php 19 | function orchestration(\Bottledcode\DurablePhp\OrchestrationContext $context) { 20 | $approvals = [ 21 | $context->waitForExternalEvent('employee-approval'), 22 | $context->waitForExternalEvent('manager-approval') 23 | ]; 24 | if($context->waitAny($approved)) { 25 | // handle approval 26 | } else { 27 | // handle rejection 28 | } 29 | } 30 | ``` 31 | 32 | Or for all events: 33 | 34 | ```php 35 | function orchestration(\Bottledcode\DurablePhp\OrchestrationContext $context) { 36 | $approvals = [ 37 | $context->waitForExternalEvent('employee-approval'), 38 | $context->waitForExternalEvent('manager-approval') 39 | ]; 40 | if($context->waitAll($approved)) { 41 | // handle approval 42 | } else { 43 | // handle rejection 44 | } 45 | } 46 | ``` 47 | 48 | ## Sending Events 49 | 50 | Clients may send events via the `DurableClient`'s `raiseEvent` method: 51 | 52 | ```php 53 | $client->raiseEvent($instance, 'approved', ['by' => 'me']); 54 | ``` 55 | -------------------------------------------------------------------------------- /docs/fan-in-out.md: -------------------------------------------------------------------------------- 1 | # Fanning out and in 2 | 3 | With the fan-in-out pattern, you typically execute many tasks in parallel, wait for all the results. Typically, you 4 | aggregate the results. 5 | 6 | ```mermaid 7 | flowchart 8 | FanOut --> Child1 9 | FanOut --> Child2 10 | FanOut --> Child3 11 | FanOut --> Childn 12 | Child1 --> FanIn 13 | Child2 --> FanIn 14 | Child3 --> FanIn 15 | Childn --> FanIn 16 | FanIn --> Aggregate 17 | ``` 18 | 19 | ## In regular PHP 20 | 21 | Fanning out in regular PHP is pretty straightforward. You can call another endpoint, or kick off a job. However, fanning 22 | back in is never as straightforward. You must monitor the status of each child, handle errors, etc., and doing so 23 | usually complicates the code, obfuscating the core logic. 24 | 25 | ## In Durable PHP 26 | 27 | Using [orchestrations](orchestrations.md), you can fan-out and in to your heart's content, using relatively simple code: 28 | 29 | ```php 30 | function fanOutIn(\Bottledcode\DurablePhp\OrchestrationContext $context): void { 31 | $tasks = []; 32 | for($n = 0; $n < 10; $n++) { 33 | $tasks[] = $context->callActivity('Child', [$n]); 34 | } 35 | 36 | $results = $context->waitAll($tasks); 37 | 38 | // aggregate results 39 | } 40 | ``` 41 | 42 | In this code, a list of tasks is created to call an [activity](activities.md). The work is started immediately, and 43 | distributed among a cohort of workers. Once we call `->waitAll()`, the orchestration is put to sleep until all tasks are 44 | completed, at which point, execution is resumed. Errors from tasks are also available in the results array. 45 | -------------------------------------------------------------------------------- /docs/human-interaction.md: -------------------------------------------------------------------------------- 1 | # Human Interaction 2 | 3 | Many automated systems require human interaction at some point. Involving humans in an automated process can be tricky, 4 | especially since people aren't always available. An automated process involving humans usually requires some action to 5 | be performed if a human isn't available in time. 6 | 7 | An approval process is an example that involves human interaction. For example, a manager may need to enter their 8 | employee code to disburse funds, and if the manager doesn't respond in time, we would want the approval to be escalated. 9 | 10 | ## In regular PHP 11 | 12 | This is extremely difficult to get right in regular PHP, especially without framework support. It usually requires 13 | carefully orchestrating jobs, events, and other bits of infrastructure. Not to mention that you need to handle failures 14 | along the way. 15 | 16 | ## In Durable PHP 17 | 18 | In Durable PHP, this is extremely simple to implement via an [orchestrator](orchestrations.md). We can create a timer to 19 | handle the approval process and escalate once a timeout occurs: 20 | 21 | ```php 22 | function approvalWorkflow(\Bottledcode\DurablePhp\OrchestrationContext $context): void { 23 | $context->callActivity('requestApproval'); 24 | 25 | $dueAt = $context->getCurrentTime()->add(new DateInterval('PT72H')); 26 | $timeout = $context->createTimer($dueAt); 27 | $approval = $context->waitForExternalEvent('approved'); 28 | 29 | if($approval === $context->waitAny($timeout, $approval)) { 30 | $context->callActivity('processApproval'); 31 | } else { 32 | $context->callActivity('escalate'); 33 | } 34 | } 35 | ``` 36 | -------------------------------------------------------------------------------- /docs/monitoring.md: -------------------------------------------------------------------------------- 1 | # Monitoring Pattern 2 | 3 | This pattern refers to a flexible, recurring process in a workflow, such as monitoring an endpoint's status. You can use 4 | Durable PHP to create flexible recurring intervals, manage task lifetimes, and create multiple monitor processes from a 5 | single orchestration. 6 | 7 | In a few lines of code, you can use Durable PHP to create monitors that observe just about anything. The monitors can 8 | end execution when a condition is met, you can even change the interval based on dynamic conditions (such as an 9 | exponential backoff). 10 | 11 | ## In regular PHP 12 | 13 | There isn't an easy way to implement this in regular PHP without built-in framework support, cron jobs, or 14 | infrastructure support. In many cases, there is no way to dynamically configure the interval. 15 | 16 | ## In Durable PHP 17 | 18 | The following code implements a simple monitor: 19 | 20 | ```php 21 | function monitor(\Bottledcode\DurablePhp\OrchestrationContext $context): void { 22 | $jobId = $context->getInput(); 23 | $pollingInterval = getPollingInterval(); 24 | $expires = getExpiryTime(); 25 | 26 | while($context->getCurrentTime() < $expires) { 27 | $status = $context->callActivity('getJobStatus', [$jobId]); 28 | if($context->waitOne($status) === 'completed') { 29 | $context->callActivity('sendSuccessAlert', [$jobId]); 30 | break; 31 | } 32 | 33 | $next = $context->getCurrentTime()->add($pollingInterval); 34 | $context->waitOne($context->createTimer($next)); 35 | } 36 | } 37 | ``` 38 | -------------------------------------------------------------------------------- /docs/readme.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | Durable PHP is a self-hosted FaaS engine, heavily influenced by Durable Functions in C#. 4 | Durable PHP helps you write stateful workflows by writing [orchestrations](orchestrations.md) 5 | and [entities](entities.md) 6 | (aka actors). 7 | 8 | ## Infrastructure Requirements 9 | 10 | | Requirement | Provides | 11 | | ---------------------------------------- | --------------------------------------------------- | 12 | | PHP 8.3+ | Language | 13 | 14 | (¹) optional, replaces other requirement 15 | (²) not implemented yet 16 | 17 | ## Getting Started 18 | 19 | Download the cli and run `dphp init` to create a simple project. 20 | 21 | ## Patterns 22 | 23 | The primary use-case for Durable PHP is simplifying complex, stateful coordination requirements in any application. 24 | You can view the following sections to view some common patterns: 25 | 26 | - [Chaining](chaining.md) 27 | - [Fan-in/Fan-out](fan-in-out.md) 28 | - [Monitoring](monitoring.md) 29 | - [Human interaction](human-interaction.md) 30 | - [Aggregation](aggregation.md) 31 | - [Idempotent side-effects](activities.md) 32 | 33 | ## Technology 34 | 35 | Durable PHP is built on composable event-sourcing, where every operation/event/signal is composed of multiple events. 36 | This allows for flexible operations, prioritizing, and scaling. Originally, a partitioned approach was taken (similar to 37 | durable functions) to remove the need for distributed locking; however, through testing, it was discovered that a 38 | distributed lock was more scalable, where a partitioned approach was far more complex to scale. 39 | 40 | ## Code constraints 41 | 42 | To provide long-running execution guarantees in orchestrations, orchestrations have a set [of rules](orchestration-rules.md) 43 | that must be adhered to. We recommend PHPStan with the durable-php code rules applied (todo). 44 | -------------------------------------------------------------------------------- /docs/timers.md: -------------------------------------------------------------------------------- 1 | # Timers 2 | 3 | The orchestration context exposes the concept of timers that allow for you to implement delays, deadlines, or timeouts. 4 | Timers should be used in place of `sleep`. 5 | 6 | ## How to delay/sleep 7 | 8 | This example shows how to use a timer to sleep for 5 minutes: 9 | 10 | ```php 11 | $context->waitOne($context->createTimer($context->createInterval(minutes: 5))); 12 | ``` 13 | 14 | ## As a timeout 15 | 16 | This example shows how to use as a timeout: 17 | 18 | ```php 19 | $timeout = $context->createTimer($context->createInterval(days: 2)); 20 | $activity = $context->callActivity('walkTheDog', ['name' => 'Chester']); 21 | $winner = $context->waitAny($timeout, $activity); 22 | 23 | if($winner === $timeout) { 24 | // timed out 25 | return; 26 | } 27 | $result = $activity->getResult(); 28 | ``` 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "husky": "^9.0.10", 4 | "lint-staged": "^15.2.2", 5 | "prettier": "^3.2.5" 6 | }, 7 | "lint-staged": { 8 | "**/*.php": "./vendor/bin/pint --dirty", 9 | "**/*.json": "prettier", 10 | "**/*.md": "prettier" 11 | }, 12 | "scripts": { 13 | "prepare": "husky" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./tests 6 | 7 | 8 | 9 | 10 | 11 | ./app 12 | ./src 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "per", 3 | "exclude": ["cli"], 4 | "rules": { 5 | "array_push": true, 6 | "combine_consecutive_issets": true, 7 | "combine_consecutive_unsets": true, 8 | "combine_nested_dirname": true, 9 | "date_time_immutable": true, 10 | "explicit_indirect_variable": true, 11 | "explicit_string_variable": true, 12 | "function_to_constant": true, 13 | "heredoc_to_nowdoc": true, 14 | "indentation_type": true, 15 | "is_null": true, 16 | "lambda_not_used_import": true, 17 | "mb_str_functions": true, 18 | "modernize_strpos": true, 19 | "multiline_comment_opening_closing": true, 20 | "no_superfluous_elseif": true, 21 | "non_printable_character": true, 22 | "single_quote": true, 23 | "strict_param": true, 24 | "use_arrow_functions": true, 25 | "void_return": true 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/ActivityInfo.php: -------------------------------------------------------------------------------- 1 | other->start($script, $cancellation); 43 | 44 | if ($process instanceof ProcessContext) { 45 | async(pipe(...), $process->getStdout(), getStdout())->ignore(); 46 | async(pipe(...), $process->getStderr(), getStderr())->ignore(); 47 | } 48 | 49 | return $process; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/DurableClientInterface.php: -------------------------------------------------------------------------------- 1 | $future 36 | */ 37 | public function __construct(public readonly DeferredFuture $future) {} 38 | 39 | /** 40 | * @return T 41 | */ 42 | public function getResult(): mixed 43 | { 44 | if ($this->future->isComplete()) { 45 | return $this->future->getFuture()->await(); 46 | } 47 | 48 | throw new \LogicException('Future is not complete'); 49 | } 50 | 51 | public function hasResult(): bool 52 | { 53 | return $this->future->isComplete(); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Events/Event.php: -------------------------------------------------------------------------------- 1 | eventId = $this->eventId ?: Uuid::uuid7(); 42 | $this->timestamp = MonotonicClock::current()->now(); 43 | } 44 | 45 | public function eventType(): string 46 | { 47 | return static::class; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Events/ExecutionTerminated.php: -------------------------------------------------------------------------------- 1 | eventId, 45 | $this->input, 46 | ); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Events/External.php: -------------------------------------------------------------------------------- 1 | userId); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Events/HasInnerEventInterface.php: -------------------------------------------------------------------------------- 1 | role, ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Events/RevokeUser.php: -------------------------------------------------------------------------------- 1 | userId); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Events/ShareOwnership.php: -------------------------------------------------------------------------------- 1 | userId); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Events/ShareWithRole.php: -------------------------------------------------------------------------------- 1 | role, implode(', ', $this->allowedOperations)); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Events/ShareWithUser.php: -------------------------------------------------------------------------------- 1 | userId, implode(', ', $this->allowedOperations)); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Events/Shares/Mode.php: -------------------------------------------------------------------------------- 1 | eventId); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Events/StateTargetInterface.php: -------------------------------------------------------------------------------- 1 | scheduledId, $this->reason, $this->details); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Events/With.php: -------------------------------------------------------------------------------- 1 | isOrchestrationId() => WithOrchestration::forInstance($id, $innerEvent), 36 | $id->isEntityId() => WithEntity::forInstance($id, $innerEvent), 37 | $id->isActivityId() => throw new LogicException('ActivityId is not supported'), 38 | }; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Events/WithActivity.php: -------------------------------------------------------------------------------- 1 | eventId ?: Uuid::uuid7(), StateId::fromActivityId($activityId), $innerEvent); 41 | } 42 | 43 | public function getInnerEvent(): Event 44 | { 45 | return $this->innerEvent; 46 | } 47 | 48 | public function getTarget(): StateId 49 | { 50 | return $this->target; 51 | } 52 | 53 | public function __toString(): string 54 | { 55 | return sprintf('WithActivity(%s, %s)', $this->target, $this->innerEvent); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Events/WithDelay.php: -------------------------------------------------------------------------------- 1 | innerEvent ?: Uuid::uuid7()); 34 | } 35 | 36 | public static function forEvent(\DateTimeImmutable $fireAt, Event $innerEvent): static 37 | { 38 | return new static( 39 | $innerEvent->eventId, 40 | $fireAt, 41 | $innerEvent, 42 | ); 43 | } 44 | 45 | public function getInnerEvent(): Event 46 | { 47 | $this->innerEvent->eventId = $this->eventId; 48 | return $this->innerEvent; 49 | } 50 | 51 | public function __toString(): string 52 | { 53 | return sprintf('WithDelay(%s, %s)', $this->fireAt->format('Y-m-d H:i:s'), $this->innerEvent); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Events/WithEntity.php: -------------------------------------------------------------------------------- 1 | eventId, 40 | $target, 41 | $innerEvent, 42 | ); 43 | } 44 | 45 | public function getInnerEvent(): Event 46 | { 47 | return $this->innerEvent; 48 | } 49 | 50 | public function getTarget(): StateId 51 | { 52 | return $this->target; 53 | } 54 | 55 | public function __toString(): string 56 | { 57 | return sprintf('WithEntity(%s, %s)', $this->target, $this->innerEvent); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Events/WithOrchestration.php: -------------------------------------------------------------------------------- 1 | innerEvent->eventId ?: Uuid::uuid7()); 38 | } 39 | 40 | public static function forInstance(StateId $instance, Event $innerEvent): Event 41 | { 42 | return new WithOrchestration('', $instance, $innerEvent); 43 | } 44 | 45 | public function getInnerEvent(): Event 46 | { 47 | return $this->innerEvent; 48 | } 49 | 50 | public function getTarget(): StateId 51 | { 52 | return $this->target; 53 | } 54 | 55 | public function __toString(): string 56 | { 57 | return sprintf('WithOrchestration(%s)', $this->innerEvent); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Exceptions/ActivityFailedException.php: -------------------------------------------------------------------------------- 1 | getMessage(), $e->getTraceAsString(), $e::class, $e->getFile(), $e->getLine()); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Exceptions/FireEvent.php: -------------------------------------------------------------------------------- 1 | $attributes 14 | */ 15 | public function __construct( 16 | public string $name, 17 | #[SequenceField(arrayType: AstProperty::class)] 18 | public array $arguments, 19 | public AstType $returnType, 20 | #[SequenceField(arrayType: AstAttribute::class)] 21 | public array $attributes, 22 | ) {} 23 | } 24 | -------------------------------------------------------------------------------- /src/Gateway/Graph/AstProperty.php: -------------------------------------------------------------------------------- 1 | $s !== 'null'), $isNullable); 13 | } 14 | 15 | public function getUnionOrType(): Union|string 16 | { 17 | if (count($this->types) > 1) { 18 | return new Union($this->types); 19 | } 20 | 21 | return $this->types[0]; 22 | } 23 | 24 | public function getUnionOrTypeForInput(): string 25 | { 26 | if (count($this->types) > 1) { 27 | // there simply isn't a way to define an input type that is a union, so we must hope there is a scalar. 28 | return (new Union($this->types))->name . 'Input'; 29 | } 30 | 31 | return $this->types[0] . 'Input'; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Gateway/Graph/Mode.php: -------------------------------------------------------------------------------- 1 | graphQlType}"; 16 | } 17 | 18 | public function renderInputType(TypeManager $typeManager): string 19 | { 20 | return ''; 21 | } 22 | 23 | public function renderMutations(TypeManager $typeManager): array 24 | { 25 | return []; 26 | } 27 | 28 | public function renderQueries(TypeManager $typeManager): array 29 | { 30 | return []; 31 | } 32 | 33 | public function getGraphQlType(bool $forInput = false, bool $nullable = false): string 34 | { 35 | return $this->graphQlType . ($this->alwaysNullable || ($this->nullable && $nullable) ? '' : '!'); 36 | } 37 | 38 | public function isHidden(): false 39 | { 40 | return false; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Gateway/Graph/SchemaRendererInterface.php: -------------------------------------------------------------------------------- 1 | types); 12 | 13 | $name = array_map(fn($x) => ucfirst($x), $this->types); 14 | $this->name = implode('Or', $name); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Gateway/Graph/UnionRenderer.php: -------------------------------------------------------------------------------- 1 | $typeManager->lookupType($type)?->getGraphQlType(), $this->unions); 16 | return "union {$this->graphQlType} = " . implode(' | ', $unions); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Gateway/Graph/generic-schema.graphql: -------------------------------------------------------------------------------- 1 | schema { 2 | query: Query 3 | mutation: Mutation 4 | } 5 | 6 | type Query { 7 | entitySnapshot(id: EntityId!): State 8 | orchestration(id: OrchestrationId!, waitForCompletion: Boolean): Status 9 | } 10 | 11 | type Mutation { 12 | SendEntitySignal(id: EntityId!, signal: String!, arguments: [Input!]!): Void 13 | RaiseOrchestrationEvent(id: OrchestrationId!, signal: String!, arguments: [Input!]!): Void 14 | StartOrchestration(name: String!, input: [Input!]!, id: String): Orchestration 15 | } 16 | 17 | input EntityId { 18 | name: ID! 19 | id: ID! 20 | } 21 | 22 | input OrchestrationId { 23 | instance: ID! 24 | execution: ID! 25 | } 26 | 27 | type Orchestration { 28 | instance: ID! 29 | execution: ID! 30 | } 31 | 32 | type Status { 33 | createdAt: Date! 34 | customStatus: String! 35 | input: [OriginalInput!]! 36 | id: ID! 37 | lastUpdated: Date! 38 | output: State 39 | runtimeStatus: RuntimeStatus! 40 | } 41 | 42 | enum RuntimeStatus { 43 | Running 44 | Completed 45 | ContinuedAsNew 46 | Failed 47 | Canceled 48 | Terminated 49 | Pending 50 | Suspended 51 | Unknown 52 | } 53 | 54 | type OriginalInput { 55 | key: String 56 | value: Any! 57 | } 58 | 59 | input Input { 60 | key: String, 61 | value: Any! 62 | } 63 | 64 | scalar Any 65 | scalar Void 66 | scalar State 67 | scalar Date 68 | -------------------------------------------------------------------------------- /src/Glue/Provenance.php: -------------------------------------------------------------------------------- 1 | Level::Debug, 46 | 'INFO' => Level::Info, 47 | default => Level::Error, 48 | }); 49 | 50 | if(($_SERVER['SERVER_PROTOCOL'] ?? null) !== 'DPHP/1.0') { 51 | http_response_code(400); 52 | $logger->critical('Invalid request protocol', [$_SERVER['SERVER_PROTOCOL'] ?? null]); 53 | die(); 54 | } 55 | -------------------------------------------------------------------------------- /src/Proxy/ImpureException.php: -------------------------------------------------------------------------------- 1 | maxRetryInterval = Hours(1); 40 | $this->retryTimeout = Hours(1); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Search/EntityFilter.php: -------------------------------------------------------------------------------- 1 | name = trim($name); 15 | 16 | if (empty($this->name)) { 17 | throw new LogicException('Orchestration name must not be empty'); 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/State/Attributes/TimeToLive.php: -------------------------------------------------------------------------------- 1 | unit->multiply($this->amount); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/State/EntityId.php: -------------------------------------------------------------------------------- 1 | $name 36 | * @param string $id 37 | */ 38 | public function __construct(public string $name, public string $id) {} 39 | 40 | public function __toString(): string 41 | { 42 | return $this->name . ':' . $this->id; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/State/EntityLock.php: -------------------------------------------------------------------------------- 1 | locked) { 36 | ($this->unlocker)(); 37 | $this->locked = false; 38 | } 39 | } 40 | 41 | public function isLocked(): bool 42 | { 43 | return $this->locked; 44 | } 45 | 46 | public function __debugInfo(): ?array 47 | { 48 | return ['locked' => $this->locked]; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/State/EntityState.php: -------------------------------------------------------------------------------- 1 | getMethods() as $method) { 36 | foreach ($method->getAttributes(EntryPoint::class) as $attribute) { 37 | return $method; 38 | } 39 | } 40 | 41 | try { 42 | return $class->getMethod('__invoke'); 43 | } catch (ReflectionException) { 44 | return null; 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/State/LockStateEnum.php: -------------------------------------------------------------------------------- 1 | instanceId}:{$this->executionId}"; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/State/ParentInstance.php: -------------------------------------------------------------------------------- 1 | result instanceof Throwable; 36 | } 37 | 38 | public function getResult(array $args): array 39 | { 40 | if ($this->callback) { 41 | try { 42 | $this->result = ($this->callback)(...$args); 43 | } catch (Throwable $e) { 44 | $this->result = $e; 45 | } 46 | } 47 | 48 | return [$this->result]; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Testing/EntityMock.php: -------------------------------------------------------------------------------- 1 | {'apply' . $objectClass}($event, $original); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests/ClientTestCli.php: -------------------------------------------------------------------------------- 1 | startNew( 40 | HelloSequence::class, 41 | ['name' => 'World'], 42 | Uuid::uuid7()->toString(), 43 | ); 44 | $client->raiseEvent($orchestrationInstance, 'event', ['data']); 45 | $client->waitForCompletion($orchestrationInstance, new TimeoutCancellation(hours(2)->as(TimeUnit::Seconds))); 46 | var_dump($client->getStatus($orchestrationInstance)); 47 | -------------------------------------------------------------------------------- /tests/Common/LauncherEntity.php: -------------------------------------------------------------------------------- 1 | startNewOrchestration($orchestration, [], $offset + $i); 42 | } 43 | $this->launched = true; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/Common/SayHello.php: -------------------------------------------------------------------------------- 1 | toBeTrue(); 5 | }); 6 | -------------------------------------------------------------------------------- /tests/PerformanceTests/Bank/Account.php: -------------------------------------------------------------------------------- 1 | balance += $amount; 38 | } 39 | 40 | public function reset(): void 41 | { 42 | $this->balance = 0; 43 | } 44 | 45 | public function get(): int 46 | { 47 | return $this->balance; 48 | } 49 | 50 | public function delete(): void 51 | { 52 | $this->context->delete(); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tests/PerformanceTests/Bank/AccountInterface.php: -------------------------------------------------------------------------------- 1 | start(); 33 | $id = random_int(1, 1000000); 34 | //$id = 1; 35 | $instance = $client->startNew(BankTransaction::class, [$id]); 36 | $instance2 = $client->startNew(BankTransaction::class, [$id]); 37 | $client->waitForCompletion($instance); 38 | $client->waitForCompletion($instance2); 39 | $watch->stop(); 40 | 41 | var_dump($client->getStatus($instance)); 42 | var_dump($client->getStatus($instance2)); 43 | -------------------------------------------------------------------------------- /tests/PerformanceTests/CounterEntity.php: -------------------------------------------------------------------------------- 1 | lastUpdated = new \DateTimeImmutable(); 37 | $this->currentValue += $amount; 38 | } 39 | 40 | public function reset(): void 41 | { 42 | $this->lastUpdated = new \DateTimeImmutable(); 43 | $this->currentValue = 0; 44 | } 45 | 46 | public function get(): array 47 | { 48 | return ['currentValue' => $this->currentValue, 'lastUpdated' => $this->lastUpdated]; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/PerformanceTests/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:8.3-zts 2 | 3 | COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/ 4 | 5 | RUN install-php-extensions @composer ev sodium zip intl uuid ev pcntl parallel apcu sockets 6 | 7 | COPY . /app 8 | WORKDIR /app 9 | RUN composer install 10 | -------------------------------------------------------------------------------- /tests/PerformanceTests/ExampleTest.php: -------------------------------------------------------------------------------- 1 | toBeTrue(); 5 | }); 6 | -------------------------------------------------------------------------------- /tests/PerformanceTests/FanOutFanIn.php: -------------------------------------------------------------------------------- 1 | getInput()['count']; 36 | $tasks = []; 37 | for ($i = 0; $i < $count; $i++) { 38 | $tasks[] = $context->callActivity(SayHello::class, [mb_str_pad((string) $i, 4, '0', STR_PAD_LEFT)]); 39 | } 40 | $context->waitAll(...$tasks); 41 | foreach ($tasks as $i => $task) { 42 | $context->setCustomStatus($i); 43 | $task->getResult(); 44 | } 45 | $context->getReplayAwareLogger()->alert($i + 1); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/PerformanceTests/FanOutFanInClient.php: -------------------------------------------------------------------------------- 1 | start(); 38 | $instance = $client->startNew(FanOutFanIn::class, ['count' => getenv('ACTIVITY_COUNT') ?: 5000]); 39 | $client->waitForCompletion($instance); 40 | $watch->stop(); 41 | 42 | var_dump($client->getStatus($instance)); 43 | var_dump($instance); 44 | 45 | $logger->alert(sprintf('Completed in %s seconds', number_format($watch->getSeconds(), 2))); 46 | export_report('Fan out/in', $watch->getSeconds()); 47 | -------------------------------------------------------------------------------- /tests/PerformanceTests/HelloCities/Client.php: -------------------------------------------------------------------------------- 1 | callActivity(SayHello::class, ['Tokyo']), 37 | $context->callActivity(SayHello::class, ['Seattle']), 38 | $context->callActivity(SayHello::class, ['London']), 39 | $context->callActivity(SayHello::class, ['Amsterdam']), 40 | $context->callActivity(SayHello::class, ['Seoul']), 41 | ]; 42 | 43 | return $context->waitAll(...$outputs); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/PerformanceTests/Sequence.php: -------------------------------------------------------------------------------- 1 | getInput(); 36 | $results = []; 37 | foreach ($sequence as $value) { 38 | $results[] = $context->waitOne($context->callActivity(SayHello::class, ['name' => $value])); 39 | } 40 | 41 | return $results; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/PerformanceTests/SequenceClient.php: -------------------------------------------------------------------------------- 1 | start(); 42 | $instance = $client->startNew(Sequence::class, $sequence); 43 | $client->waitForCompletion($instance); 44 | $watch->stop(); 45 | 46 | $logger->alert(sprintf('Completed in %s seconds', number_format($watch->getSeconds(), 2))); 47 | export_report('seq', $watch->getSeconds()); 48 | -------------------------------------------------------------------------------- /tests/PerformanceTests/bootstrap.php: -------------------------------------------------------------------------------- 1 | value += $amount; 17 | } 18 | 19 | public function reset(): void 20 | { 21 | $this->value = 0; 22 | } 23 | 24 | public function get(): int 25 | { 26 | return $this->value; 27 | } 28 | 29 | public function delete(): void 30 | { 31 | // TODO: Implement delete() method. 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/PerformanceTests/src/Benchmarks/Bank/AccountInterface.php: -------------------------------------------------------------------------------- 1 | getInput(); 12 | 13 | $sourceId = sprintf('src%d-!-%d', $pair, ($pair + 1) % 32); 14 | $sourceEntity = new EntityId(AccountInterface::class, $sourceId); 15 | 16 | $destinationId = sprintf('dst%d-!%d', $pair, ($pair + 2) % 32); 17 | $destinationEntity = new EntityId(AccountInterface::class, $destinationId); 18 | 19 | $transferAmount = 1000; 20 | $sourceProxy = $context->createProxy(AccountInterface::class, $sourceEntity); 21 | $destinationProxy = $context->createProxy(AccountInterface::class, $destinationEntity); 22 | 23 | $context->lock($sourceEntity, $destinationEntity); 24 | 25 | $sourceBalance = $context->await($sourceProxy->get()); 26 | $context->await($sourceProxy->add(-$transferAmount)); 27 | $context->await($destinationProxy->add($transferAmount)); 28 | 29 | return true; 30 | } 31 | -------------------------------------------------------------------------------- /tests/PerformanceTests/src/Benchmarks/Bank/public/bank.php: -------------------------------------------------------------------------------- 1 | startTime = hrtime(true); 36 | $this->mag = microtime(true); 37 | } 38 | 39 | public function stop(): void 40 | { 41 | $this->time = hrtime(true) - $this->startTime; 42 | $this->mag = microtime(true) - $this->mag; 43 | } 44 | 45 | public function getSeconds(): float 46 | { 47 | return $this->time / 1_000_000_000; 48 | } 49 | 50 | public function getTestSecs(): float 51 | { 52 | return $this->mag; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | $id 41 | */ 42 | $id = new \Bottledcode\DurablePhp\State\EntityId(ITest::class, 'hello'); 43 | 44 | $result = $entityClient->getEntitySnapshot($id); 45 | -------------------------------------------------------------------------------- /tests/Unit/final-event.json: -------------------------------------------------------------------------------- 1 | { 2 | "eventId": "0188f39c-4eb2-73c4-9213-5256c68adb2a", 3 | "eventType": "Bottledcode\\DurablePhp\\Events\\TaskCompleted", 4 | "isPlayed": false, 5 | "result": "Hello, Seoul!", 6 | "scheduledId": "0188f39c-4e7d-738e-bb7a-cf8b1e5b9dda", 7 | "timestamp": "2023-06-25T17:31:27.279+00:00" 8 | } 9 | -------------------------------------------------------------------------------- /tests/paratest.php: -------------------------------------------------------------------------------- 1 |