├── .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 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/codeception.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
11 |
12 |
--------------------------------------------------------------------------------
/.idea/copyright/MIT.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
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 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/modules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.idea/php-docker-settings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
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 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/.idea/prettier.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
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 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
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 |