├── .nvmrc ├── tests ├── paratest.php ├── PerformanceTests │ ├── src │ │ └── Benchmarks │ │ │ └── Bank │ │ │ ├── public │ │ │ └── bank.php │ │ │ ├── AccountInterface.php │ │ │ ├── Account.php │ │ │ └── BankTransaction.php │ ├── ExampleTest.php │ ├── Dockerfile │ ├── HelloCities │ │ ├── Client.php │ │ └── HelloSequence.php │ ├── bootstrap.php │ ├── Bank │ │ ├── AccountInterface.php │ │ ├── BankClient.php │ │ └── Account.php │ ├── report.php │ ├── Sequence.php │ ├── CounterEntity.php │ ├── SequenceClient.php │ ├── FanOutFanInClient.php │ ├── FanOutFanIn.php │ └── docker-compose.yaml ├── Feature │ └── ExampleTest.php ├── TestCase.php ├── Unit │ ├── final-event.json │ ├── RecordTest.php │ └── TypeTests.php ├── SignalEntity.http ├── .pest │ └── snapshots │ │ └── Feature │ │ ├── ClientProxyTest │ │ └── it_generates_a_client_proxy_correctly.snap │ │ └── OrchestratorProxyTest │ │ └── it_generates_a_proxy_correctly.snap ├── Common │ ├── SayHello.php │ └── LauncherEntity.php ├── StopWatch.php └── ClientTestCli.php ├── .gitattributes ├── cli ├── .gitignore ├── init │ └── template │ │ ├── .gitignore │ │ ├── pint.json │ │ ├── dphp.json │ │ ├── src │ │ ├── Activities │ │ │ └── AddOne.php │ │ ├── bootstrap.php │ │ ├── Entities │ │ │ ├── CountInterface.php │ │ │ └── CountState.php │ │ └── Orchestrations │ │ │ ├── Password.php │ │ │ └── Counter.php │ │ ├── composer.json │ │ ├── readme.md │ │ └── tests │ │ └── integrationTest.php ├── appcontext │ └── auth.go ├── readme.md ├── message.json ├── glue │ ├── state.go │ └── messages.go ├── Makefile ├── auth │ ├── createPermissions_test.go │ └── createPermissions.go ├── config │ └── defaultConfiguration.json ├── lib │ └── index.go └── go.mod ├── .husky └── pre-commit ├── src ├── Proxy │ ├── SpyException.php │ ├── Pure.php │ └── ImpureException.php ├── State │ ├── Attributes │ │ ├── AccessControl.php │ │ ├── Name.php │ │ ├── Entity.php │ │ ├── Activity.php │ │ ├── Orchestration.php │ │ ├── Tag.php │ │ ├── AllowAnyOperation.php │ │ ├── AllowCreateFrom.php │ │ ├── EntryPoint.php │ │ ├── Operation.php │ │ ├── AllowCreateAll.php │ │ ├── AllowCreateForUser.php │ │ ├── AllowCreateForRole.php │ │ ├── AllowCreateForAuth.php │ │ ├── TimeToLive.php │ │ └── DenyAnyOperation.php │ ├── ParentInstance.php │ ├── Session.php │ ├── ReceivedSet.php │ ├── EntityState.php │ ├── Exporter.php │ ├── ResultSet.php │ ├── Status.php │ ├── EntityLock.php │ ├── OrchestrationInstance.php │ ├── EntityId.php │ ├── EntrypointLocatorTrait.php │ ├── LockStateEnum.php │ └── StateInterface.php ├── Contexts │ ├── AuthContext │ │ ├── SecurityException.php │ │ ├── Share │ │ │ ├── Role.php │ │ │ ├── User.php │ │ │ └── Owner.php │ │ ├── Share.php │ │ └── functions.php │ ├── AuthContext.php │ └── LoggingContextFactory.php ├── Gateway │ └── Graph │ │ ├── NoopRenderer.php │ │ ├── Union.php │ │ ├── AstAttribute.php │ │ ├── AstProperty.php │ │ ├── SchemaRendererInterface.php │ │ ├── UnionRenderer.php │ │ ├── AstMethod.php │ │ ├── AstType.php │ │ ├── ScalarRenderer.php │ │ ├── generic-schema.graphql │ │ └── Mode.php ├── functions.php ├── Events │ ├── WithFrom.php │ ├── External.php │ ├── TargetType.php │ ├── HasInnerEventInterface.php │ ├── Shares │ │ ├── Mode.php │ │ ├── NeedsSource.php │ │ ├── NeedsTarget.php │ │ └── Operation.php │ ├── StateTargetInterface.php │ ├── PoisonPill.php │ ├── ReplyToInterface.php │ ├── GiveOwnership.php │ ├── With.php │ ├── ExecutionTerminated.php │ ├── ShareOwnership.php │ ├── RevokeRole.php │ ├── RevokeUser.php │ ├── ShareWithRole.php │ ├── StartOrchestration.php │ ├── ShareWithUser.php │ └── WithDelay.php ├── Exceptions │ ├── Unwind.php │ ├── LockException.php │ ├── FireEvent.php │ ├── ActivityFailedException.php │ └── ExternalException.php ├── DurableClientInterface.php ├── Testing │ ├── Exceptions │ │ └── ContinuedAsNew.php │ ├── EntityMock.php │ └── ActivityMock.php ├── Search │ └── EntityFilter.php ├── Glue │ ├── Provenance.php │ └── autoload.php ├── ActivityInfo.php ├── Transmutation │ └── Router.php └── RetryOptions.php ├── .idea ├── codeStyles │ └── codeStyleConfig.xml ├── vcs.xml ├── misc.xml ├── .gitignore ├── copyright │ ├── profiles_settings.xml │ └── MIT.xml ├── scopes │ └── Source.xml ├── modules.xml ├── phpspec.xml ├── remote-mappings.xml ├── prettier.xml ├── phpunit.xml ├── deployment.xml ├── php-test-framework.xml ├── runConfigurations │ ├── Workers.xml │ └── Client.xml ├── php-docker-settings.xml ├── jsonSchemas.xml └── watcherTasks.xml ├── package.json ├── phpunit.xml ├── docs ├── Components │ ├── Rethinkdb.md │ └── Beanstalkd.md ├── timers.md ├── activities.md ├── external-events.md ├── monitoring.md ├── fan-in-out.md ├── human-interaction.md ├── chaining.md └── readme.md ├── .github ├── workflows │ ├── cleanup.yaml │ └── Test.yaml └── dependabot.yml ├── LICENSE ├── pint.json ├── composer.json └── bootstrap.php /.nvmrc: -------------------------------------------------------------------------------- 1 | v20.13.1 2 | -------------------------------------------------------------------------------- /tests/paratest.php: -------------------------------------------------------------------------------- 1 | toBeTrue(); 5 | }); 6 | -------------------------------------------------------------------------------- /tests/PerformanceTests/ExampleTest.php: -------------------------------------------------------------------------------- 1 | toBeTrue(); 5 | }); 6 | -------------------------------------------------------------------------------- /src/Proxy/SpyException.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /src/State/Attributes/Name.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/State/Attributes/Entity.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/Contexts/AuthContext/Share/Role.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /.idea/scopes/Source.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/State/Attributes/Orchestration.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /cli/init/template/src/Activities/AddOne.php: -------------------------------------------------------------------------------- 1 | \DI\autowire(\{{.Name}}\Entities\CountState::class) 9 | ]; 10 | })(); 11 | -------------------------------------------------------------------------------- /.idea/phpspec.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /tests/PerformanceTests/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:8.4-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/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 | -------------------------------------------------------------------------------- /.idea/remote-mappings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /cli/init/template/src/Entities/CountInterface.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | -------------------------------------------------------------------------------- /src/State/ParentInstance.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | -------------------------------------------------------------------------------- /tests/PerformanceTests/src/Benchmarks/Bank/AccountInterface.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/AstAttribute.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 | -------------------------------------------------------------------------------- /.idea/deployment.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/State/Attributes/Tag.php: -------------------------------------------------------------------------------- 1 | name = mb_trim($name); 15 | 16 | if (empty($this->name)) { 17 | throw new LogicException('Orchestration name must not be empty'); 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /cli/glue/messages.go: -------------------------------------------------------------------------------- 1 | package glue 2 | 3 | import "time" 4 | 5 | type EventMessage struct { 6 | Destination string `json:"destination"` 7 | ReplyTo string `json:"replyTo"` 8 | ScheduleAt time.Time `json:"scheduleAt"` 9 | EventId string `json:"eventId"` 10 | Event string `json:"event"` 11 | EventType string `json:"eventType"` 12 | TargetType string `json:"targetType"` 13 | SourceOps string `json:"sourceOps"` 14 | Meta string `json:"meta"` 15 | TargetOps string `json:"targetOps"` 16 | } 17 | -------------------------------------------------------------------------------- /.idea/php-test-framework.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./tests 6 | 7 | 8 | 9 | 10 | 11 | ./app 12 | ./src 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/Gateway/Graph/SchemaRendererInterface.php: -------------------------------------------------------------------------------- 1 | $typeManager->lookupType($type)?->getGraphQlType(), $this->unions); 16 | return "union {$this->graphQlType} = " . implode(' | ', $unions); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/State/Attributes/AllowAnyOperation.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /.idea/runConfigurations/Client.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/Gateway/Graph/AstMethod.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/functions.php: -------------------------------------------------------------------------------- 1 | $name 12 | */ 13 | function EntityId(string $name, string $id): EntityId 14 | { 15 | return EntityId::from($name, $id); 16 | } 17 | 18 | /** 19 | * @template T 20 | * @param class-string $instanceId 21 | */ 22 | function OrchestrationInstance(string $instanceId, string $executionId): OrchestrationInstance 23 | { 24 | return OrchestrationInstance::from($instanceId, $executionId); 25 | } 26 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /tests/.pest/snapshots/Feature/ClientProxyTest/it_generates_a_client_proxy_correctly.snap: -------------------------------------------------------------------------------- 1 | 2 | 3 | class __ClientProxy_orchProxy implements orchProxy { 4 | public function __construct(private mixed $source) {} 5 | public string $prop { 6 | get { 7 | throw new Bottledcode\DurablePhp\Proxy\ImpureException(); 8 | } 9 | set { 10 | $this->source->__setProp($value); 11 | } 12 | } 13 | public function callExample(): string { 14 | throw new Bottledcode\DurablePhp\Proxy\ImpureException(); 15 | } 16 | public function signalExample(int $a): void { 17 | throw new Bottledcode\DurablePhp\Proxy\ImpureException(); 18 | } 19 | public function pureExample(int|float $number): string { 20 | return $this->source->pureExample(...func_get_args()); 21 | } 22 | } -------------------------------------------------------------------------------- /tests/PerformanceTests/src/Benchmarks/Bank/Account.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 | -------------------------------------------------------------------------------- /cli/Makefile: -------------------------------------------------------------------------------- 1 | TARGET := dphp-linux-* 2 | BIN_PATH := ../bin 3 | DOCKER_IMAGE := builder 4 | DOCKER_TARGET := cli-base-alpine 5 | BUILD_PATH := /go/src/app/cli/dist 6 | 7 | ${BIN_PATH}/${TARGET}: cli.go */* go.mod build.sh build-php.sh ../Dockerfile 8 | mkdir -p ${BIN_PATH} 9 | cd .. && docker buildx build --pull --load --target ${DOCKER_TARGET} -t ${DOCKER_IMAGE} . 10 | docker create --name ${DOCKER_IMAGE} ${DOCKER_IMAGE} || ( docker rm -f ${DOCKER_IMAGE} && false ) 11 | docker cp ${DOCKER_IMAGE}:${BUILD_PATH}/dphp ${BIN_PATH}/ || ( docker rm -f ${DOCKER_IMAGE} && false ) 12 | docker rm -f ${DOCKER_IMAGE} 13 | upx -9 --force-pie ../bin/dphp-* 14 | 15 | ../dist: ${BIN_PATH}/${TARGET} 16 | docker create --name builder builder 17 | docker cp ${DOCKER_IMAGE}:${BUILD_PATH} ../dist 18 | -------------------------------------------------------------------------------- /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/Entities/CountState.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 | -------------------------------------------------------------------------------- /src/Events/WithFrom.php: -------------------------------------------------------------------------------- 1 | eventId, $from, $innerEvent); 17 | } 18 | 19 | public function __toString(): string 20 | { 21 | return sprintf('WithFrom(%s, %s)', $this->from, $this->innerEvent); 22 | } 23 | 24 | public function getInnerEvent(): Event 25 | { 26 | return $this->innerEvent; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/Contexts/AuthContext/Share.php: -------------------------------------------------------------------------------- 1 | Owner::class, 15 | 'role' => Role::class, 16 | 'user' => User::class, 17 | ])] 18 | abstract readonly class Share extends Record 19 | { 20 | public string $subject; 21 | 22 | /** 23 | * @var array 24 | */ 25 | #[SequenceField(arrayType: Operation::class)] 26 | public array $allowed; 27 | } 28 | -------------------------------------------------------------------------------- /.idea/php-docker-settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /.idea/jsonSchemas.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/State/Attributes/AllowCreateFrom.php: -------------------------------------------------------------------------------- 1 | $type 18 | */ 19 | public function __construct(public ?string $type = null, public EntityId|OrchestrationInstance|null $id = null) 20 | { 21 | if ($type === null && $id === null) { 22 | throw new LogicException('At least one of type or id must be provided to AllowCreateFrom'); 23 | } 24 | if ($type !== null && $id !== null) { 25 | throw new LogicException('Only one of type or id can be provided to AllowCreateFrom'); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/.pest/snapshots/Feature/OrchestratorProxyTest/it_generates_a_proxy_correctly.snap: -------------------------------------------------------------------------------- 1 | 2 | 3 | class __OrchestratorProxy_orchProxy implements orchProxy { 4 | public function __construct(private \Bottledcode\DurablePhp\OrchestrationContextInterface $context, private \Bottledcode\DurablePhp\State\EntityId $id) {} 5 | public string $prop { 6 | get { 7 | return $this->context->waitOne($this->context->callEntity($this->id, "\$prop::get", [])); 8 | } 9 | set { 10 | $this->context->waitOne($this->context->callEntity($this->id, "\$prop::set", [$value])); 11 | } 12 | } 13 | public function callExample(): string { 14 | return $this->context->waitOne($this->context->callEntity($this->id, "callExample", func_get_args())); 15 | } 16 | public function signalExample(int $a): void { 17 | $this->context->signalEntity($this->id, "signalExample", func_get_args()); 18 | } 19 | public function pureExample(int|float $number): string { 20 | return $this->context->waitOne($this->context->callEntity($this->id, "pureExample", func_get_args())); 21 | } 22 | } -------------------------------------------------------------------------------- /src/Gateway/Graph/AstType.php: -------------------------------------------------------------------------------- 1 | $s !== 'null'), $isNullable); 13 | } 14 | 15 | public function getUnionOrType(): string|Union 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.idea/watcherTasks.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 16 | 24 | 25 | -------------------------------------------------------------------------------- /src/Gateway/Graph/ScalarRenderer.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 | -------------------------------------------------------------------------------- /tests/PerformanceTests/src/Benchmarks/Bank/BankTransaction.php: -------------------------------------------------------------------------------- 1 | getInput(); 13 | 14 | $sourceId = sprintf('src%d-!-%d', $pair, ($pair + 1) % 32); 15 | $sourceEntity = EntityId(AccountInterface::class, $sourceId); 16 | 17 | $destinationId = sprintf('dst%d-!%d', $pair, ($pair + 2) % 32); 18 | $destinationEntity = EntityId(AccountInterface::class, $destinationId); 19 | 20 | $transferAmount = 1000; 21 | $sourceProxy = $context->createProxy(AccountInterface::class, $sourceEntity); 22 | $destinationProxy = $context->createProxy(AccountInterface::class, $destinationEntity); 23 | 24 | $context->lock($sourceEntity, $destinationEntity); 25 | 26 | $sourceBalance = $context->await($sourceProxy->get()); 27 | $context->await($sourceProxy->add(-$transferAmount)); 28 | $context->await($destinationProxy->add($transferAmount)); 29 | 30 | return true; 31 | } 32 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "per", 3 | "exclude": [ 4 | "cli" 5 | ], 6 | "rules": { 7 | "array_push": true, 8 | "attribute_empty_parentheses": { 9 | "use_parentheses": false 10 | }, 11 | "backtick_to_shell_exec": true, 12 | "combine_consecutive_issets": true, 13 | "combine_consecutive_unsets": true, 14 | "combine_nested_dirname": true, 15 | "date_time_immutable": true, 16 | "explicit_indirect_variable": true, 17 | "explicit_string_variable": true, 18 | "function_to_constant": true, 19 | "heredoc_to_nowdoc": true, 20 | "indentation_type": true, 21 | "is_null": true, 22 | "lambda_not_used_import": true, 23 | "mb_str_functions": true, 24 | "modernize_strpos": true, 25 | "multiline_comment_opening_closing": true, 26 | "no_superfluous_elseif": true, 27 | "non_printable_character": true, 28 | "single_quote": true, 29 | "strict_param": true, 30 | "use_arrow_functions": true, 31 | "void_return": true, 32 | "no_unused_imports": true, 33 | "ordered_interfaces": true, 34 | "ordered_types": { 35 | "null_adjustment": "always_last" 36 | }, 37 | "simplified_if_return": true 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /cli/auth/createPermissions_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestCreatePermissions_Validate(t *testing.T) { 10 | testCases := []struct { 11 | name string 12 | input CreatePermissions 13 | wantError bool 14 | }{ 15 | { 16 | name: "InvalidTimeToLive", 17 | input: CreatePermissions{ 18 | TimeToLive: 0, 19 | }, 20 | wantError: true, 21 | }, 22 | { 23 | name: "InvalidMode", 24 | input: CreatePermissions{ 25 | Mode: "InvalidMode", 26 | }, 27 | wantError: true, 28 | }, 29 | { 30 | name: "ValidPermissions", 31 | input: CreatePermissions{ 32 | Mode: AnonymousMode, 33 | Limits: Limits{}, // assume a valid value 34 | Users: []UserId{}, // assume a valid value 35 | Roles: []Role{}, // assume a valid value 36 | TimeToLive: 10, 37 | }, 38 | wantError: false, 39 | }, 40 | } 41 | 42 | for _, tc := range testCases { 43 | t.Run(tc.name, func(t *testing.T) { 44 | err := tc.input.Validate() 45 | if tc.wantError { 46 | assert.Error(t, err) 47 | } else { 48 | assert.NoError(t, err) 49 | } 50 | }) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/PerformanceTests/HelloCities/Client.php: -------------------------------------------------------------------------------- 1 | getMethod('fromArgs')->invoke(null, subject: $subject, allowed: [Operation::Owner]); 17 | } 18 | 19 | function Role(string $subject, Operation ...$allowed): Role 20 | { 21 | $ref = new ReflectionClass(Role::class); 22 | 23 | return $ref->getMethod('fromArgs')->invoke(null, subject: $subject, allowed: $allowed); 24 | } 25 | 26 | function User(string $subject, Operation ...$allowed): User 27 | { 28 | $ref = new ReflectionClass(User::class); 29 | 30 | return $ref->getMethod('fromArgs')->invoke(null, subject: $subject, allowed: $allowed); 31 | } 32 | 33 | define('ApiSource', StateId::fromString('--api--:--api--')); 34 | define('SystemSource', StateId::fromString('--system--:--system--')); 35 | -------------------------------------------------------------------------------- /src/Events/External.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | #[SequenceField(arrayType: Owner::class)] 20 | public array $owners; 21 | 22 | /** 23 | * @var array 24 | */ 25 | #[SequenceField(arrayType: Share::class)] 26 | public array $shares; 27 | 28 | #[SequenceField(arrayType: 'string')] 29 | public array $fromTypes; 30 | 31 | #[SequenceField(arrayType: StateId::class)] 32 | public array $fromIds; 33 | 34 | public static function fromCurrentContext(): ?AuthContext 35 | { 36 | if (isset($_SERVER['HTTP_DPHP_AUTH_CONTEXT'])) { 37 | $json = json_decode($_SERVER['HTTP_DPHP_AUTH_CONTEXT'], true, flags: JSON_THROW_ON_ERROR); 38 | 39 | return Serializer::deserialize($json, self::class); 40 | } 41 | 42 | return null; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/PerformanceTests/bootstrap.php: -------------------------------------------------------------------------------- 1 | 2.0", 37 | "nikic/php-parser": "^5.6", 38 | "php": ">=8.4", 39 | "php-di/php-di": "^7.0.11", 40 | "ramsey/uuid": "^4.9.0", 41 | "webonyx/graphql-php": "^15.22.0", 42 | "withinboredom/records": "^0.1.3", 43 | "withinboredom/time": "^6.0.0" 44 | }, 45 | "require-dev": { 46 | "laravel/pint": "^1.24.0", 47 | "mockery/mockery": "^1.6.12", 48 | "pestphp/pest": "^2.35.1 || ^3.8.2" 49 | }, 50 | "scripts": { 51 | "test": "pest" 52 | }, 53 | "type": "library" 54 | } 55 | -------------------------------------------------------------------------------- /src/Events/TargetType.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /src/Contexts/AuthContext/Share/Owner.php: -------------------------------------------------------------------------------- 1 | toBe($record); 18 | }); 19 | 20 | it('can serialize an orchestration id', function (): void { 21 | $record = OrchestrationInstance('name', 'id'); 22 | $result = Serializer::serialize($record); 23 | $result = Serializer::deserialize($result, OrchestrationInstance::class); 24 | expect($result)->toBe($record); 25 | }); 26 | 27 | it('can serialize an event', function (): void { 28 | $entity = EntityId('name', 'id'); 29 | $event = WithEntity::forInstance(StateId::fromEntityId($entity), RaiseEvent::forOperation('get', ['test' => 'test'])); 30 | $result = Serializer::serialize($event); 31 | $result = Serializer::deserialize($result, WithEntity::class); 32 | expect($result->target)->toBe($event->target); 33 | }); 34 | -------------------------------------------------------------------------------- /src/Events/HasInnerEventInterface.php: -------------------------------------------------------------------------------- 1 | > $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 | -------------------------------------------------------------------------------- /src/State/ReceivedSet.php: -------------------------------------------------------------------------------- 1 | getMethod('getIdentity')->invoke($value); 22 | 23 | return parent::exportValue($serializer, $field, $id, $runningValue); 24 | } 25 | 26 | public function canExport(Field $field, mixed $value, string $format): bool 27 | { 28 | return $value instanceof Record; 29 | } 30 | 31 | public function importValue(Deserializer $deserializer, Field $field, mixed $source): mixed 32 | { 33 | $reflectedRecord = new ReflectionClass($field->phpType); 34 | if ($source === null || $source[$field->phpName] === null) { 35 | return null; 36 | } 37 | 38 | $record = $reflectedRecord->getMethod('fromArgs')->invoke(null, ...($source[$field->phpName])); 39 | 40 | return $record; 41 | } 42 | 43 | public function canImport(Field $field, string $format): bool 44 | { 45 | return is_a($field->phpType, Record::class, true); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/State/Attributes/AllowCreateForAuth.php: -------------------------------------------------------------------------------- 1 | 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 | -------------------------------------------------------------------------------- /src/Exceptions/ActivityFailedException.php: -------------------------------------------------------------------------------- 1 | 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 | -------------------------------------------------------------------------------- /src/Events/Shares/Operation.php: -------------------------------------------------------------------------------- 1 | unit->multiply($this->amount); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/ActivityInfo.php: -------------------------------------------------------------------------------- 1 | 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 | -------------------------------------------------------------------------------- /src/Transmutation/Router.php: -------------------------------------------------------------------------------- 1 | {'apply' . $objectClass}($event, $original); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Exceptions/ExternalException.php: -------------------------------------------------------------------------------- 1 | getMessage(), $e->getTraceAsString(), $e::class, $e->getFile(), $e->getLine()); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /cli/config/defaultConfiguration.json: -------------------------------------------------------------------------------- 1 | { 2 | "project": "test", 3 | "bootstrap": "src/bootstrap.php", 4 | "nats": { 5 | "url": "nats://127.0.0.1:4222", 6 | "embeddedServer": true, 7 | "jwt": null, 8 | "nkey": null, 9 | "tls": { 10 | "ca": null, 11 | "clientCert": null, 12 | "keyFile": null 13 | } 14 | }, 15 | "historyRetentionDays": 7, 16 | "extensions": { 17 | "billing": { 18 | "enabled": true, 19 | "listen": true, 20 | "costs": { 21 | "orchestrations": { 22 | "freeLimit": 150, 23 | "maxFree": 1000, 24 | "cost": 10000, 25 | "limit": 0 26 | }, 27 | "activities": { 28 | "freeLimit": 500, 29 | "maxFree": 2000, 30 | "cost": 10, 31 | "limit": 0 32 | }, 33 | "entities": { 34 | "freeLimit": 5000, 35 | "maxFree": 5000, 36 | "cost": 10000, 37 | "limit": 0 38 | }, 39 | "objectStorage": { 40 | "freeLimit": 1000000000, 41 | "maxFree": 1000000000, 42 | "cost": 0, 43 | "limit": 0 44 | }, 45 | "fileStorage": { 46 | "freeLimit": 1000000000, 47 | "maxFree": 1000000000, 48 | "cost": 0, 49 | "limit": 0 50 | } 51 | } 52 | }, 53 | "search": { 54 | "url": "http://localhost:8108", 55 | "key": "abc123", 56 | "collections": [ 57 | "entities", 58 | "orchestrations" 59 | ] 60 | }, 61 | "authz": { 62 | "enabled": true, 63 | "secrets": [ 64 | "8ZCnKRIIh5uDM4MeSEFWCdxBEKxeHzRN+KMsywW2QRA=" 65 | ] 66 | } 67 | } 68 | } -------------------------------------------------------------------------------- /src/Events/GiveOwnership.php: -------------------------------------------------------------------------------- 1 | userId); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/RetryOptions.php: -------------------------------------------------------------------------------- 1 | maxRetryInterval = Hours(1); 41 | $this->retryTimeout = Hours(1); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/Unit/TypeTests.php: -------------------------------------------------------------------------------- 1 | $id 42 | */ 43 | $id = new \Bottledcode\DurablePhp\State\EntityId(ITest::class, 'hello'); 44 | 45 | $result = $entityClient->getEntitySnapshot($id); 46 | -------------------------------------------------------------------------------- /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/State/Attributes/DenyAnyOperation.php: -------------------------------------------------------------------------------- 1 | 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 | -------------------------------------------------------------------------------- /src/State/Status.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/OrchestrationInstance.php: -------------------------------------------------------------------------------- 1 | instanceId}:{$this->executionId}"; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/PerformanceTests/report.php: -------------------------------------------------------------------------------- 1 | eventId, 45 | $this->input, 46 | ); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tests/PerformanceTests/Sequence.php: -------------------------------------------------------------------------------- 1 | getInput(); 37 | $results = []; 38 | foreach ($sequence as $value) { 39 | $results[] = $context->waitOne($context->callActivity(SayHello::class, null, null, $value)); 40 | } 41 | 42 | return $results; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Events/ShareOwnership.php: -------------------------------------------------------------------------------- 1 | userId); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/Events/RevokeRole.php: -------------------------------------------------------------------------------- 1 | role); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tests/PerformanceTests/Bank/BankClient.php: -------------------------------------------------------------------------------- 1 | start(); 34 | $id = random_int(1, 1000000); 35 | //$id = 1; 36 | $instance = $client->startNew(BankTransaction::class, [$id]); 37 | $instance2 = $client->startNew(BankTransaction::class, [$id]); 38 | $client->waitForCompletion($instance); 39 | $client->waitForCompletion($instance2); 40 | $watch->stop(); 41 | 42 | var_dump($client->getStatus($instance)); 43 | var_dump($client->getStatus($instance2)); 44 | -------------------------------------------------------------------------------- /src/State/EntityId.php: -------------------------------------------------------------------------------- 1 | 37 | */ 38 | public protected(set) string $name; 39 | 40 | public protected(set) string $id; 41 | 42 | /** 43 | * @param class-string $name 44 | */ 45 | public static function from(string $name, string $id): static 46 | { 47 | return self::fromArgs(name: $name, id: $id); 48 | } 49 | 50 | public function __toString(): string 51 | { 52 | return $this->name . ':' . $this->id; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Events/RevokeUser.php: -------------------------------------------------------------------------------- 1 | userId); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/State/EntrypointLocatorTrait.php: -------------------------------------------------------------------------------- 1 | getMethods() as $method) { 37 | foreach ($method->getAttributes(EntryPoint::class) as $attribute) { 38 | return $method; 39 | } 40 | } 41 | 42 | try { 43 | return $class->getMethod('__invoke'); 44 | } catch (ReflectionException) { 45 | return null; 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Testing/ActivityMock.php: -------------------------------------------------------------------------------- 1 | result instanceof Throwable; 37 | } 38 | 39 | public function getResult(array $args): array 40 | { 41 | if ($this->callback) { 42 | try { 43 | $this->result = ($this->callback)(...$args); 44 | } catch (Throwable $e) { 45 | $this->result = $e; 46 | } 47 | } 48 | 49 | return [$this->result]; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tests/StopWatch.php: -------------------------------------------------------------------------------- 1 | startTime = hrtime(true); 37 | $this->mag = microtime(true); 38 | } 39 | 40 | public function stop(): void 41 | { 42 | $this->time = hrtime(true) - $this->startTime; 43 | $this->mag = microtime(true) - $this->mag; 44 | } 45 | 46 | public function getSeconds(): float 47 | { 48 | return $this->time / 1_000_000_000; 49 | } 50 | 51 | public function getTestSecs(): float 52 | { 53 | return $this->mag; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/Common/LauncherEntity.php: -------------------------------------------------------------------------------- 1 | startNewOrchestration($orchestration, [], $offset + $i); 43 | } 44 | $this->launched = true; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/PerformanceTests/Bank/Account.php: -------------------------------------------------------------------------------- 1 | balance += $amount; 39 | } 40 | 41 | public function reset(): void 42 | { 43 | $this->balance = 0; 44 | } 45 | 46 | public function get(): int 47 | { 48 | return $this->balance; 49 | } 50 | 51 | public function delete(): void 52 | { 53 | $this->context->delete(); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /cli/init/template/tests/integrationTest.php: -------------------------------------------------------------------------------- 1 | signal($entity, fn(CountInterface $state) => $state->countTo(100)); 22 | $client->waitForCompletion(OrchestrationInstance(\{{.Name}}\Orchestrations\Counter::class, $entity->id)); 23 | $time = number_format(microtime(true) - $start, 2); 24 | echo "Cool! That took $time seconds\n"; 25 | echo "Here's the state:\n" . json_encode($client->getEntitySnapshot($entity, CountInterface::class), JSON_PRETTY_PRINT) . "\n"; 26 | 27 | echo "Directly start an orchestration and wait for its completion\n"; 28 | $start = microtime(true); 29 | $orch = $client->startNew(\{{.Name}}\Orchestrations\Counter::class, ['count' => 100]); 30 | $client->waitForCompletion($orch); 31 | $time = number_format(microtime(true) - $start, 2); 32 | echo "Neat! That took $time seconds\nHere's the status:\n"; 33 | $status = $client->getStatus($orch); 34 | echo json_encode(Serializer::serialize($status), JSON_PRETTY_PRINT) . "\n"; 35 | 36 | echo "Now we will send a password: "; 37 | $start = microtime(true); 38 | $orch = $client->startNew(Password::class, ['password' => 'test']); 39 | $client->raiseEvent($orch, 'password', ['password' => 'test']); 40 | $client->waitForCompletion($orch); 41 | $status = $client->getStatus($orch); 42 | echo $status->output->toArray() === [true] ? 'success' : 'fail'; 43 | echo "\n"; 44 | -------------------------------------------------------------------------------- /tests/PerformanceTests/CounterEntity.php: -------------------------------------------------------------------------------- 1 | lastUpdated = new \DateTimeImmutable(); 38 | $this->currentValue += $amount; 39 | } 40 | 41 | public function reset(): void 42 | { 43 | $this->lastUpdated = new \DateTimeImmutable(); 44 | $this->currentValue = 0; 45 | } 46 | 47 | public function get(): array 48 | { 49 | return ['currentValue' => $this->currentValue, 'lastUpdated' => $this->lastUpdated]; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /cli/go.mod: -------------------------------------------------------------------------------- 1 | module durable_php 2 | 3 | go 1.24.5 4 | 5 | require github.com/dunglas/frankenphp v1.9.0 6 | 7 | require github.com/nats-io/nats.go v1.44.0 8 | 9 | require github.com/nats-io/nats-server/v2 v2.11.7 10 | 11 | require github.com/teris-io/cli v1.0.1 12 | 13 | require go.uber.org/zap v1.27.0 14 | 15 | require github.com/gorilla/mux v1.8.1 16 | 17 | require github.com/typesense/typesense-go v1.1.0 18 | 19 | require github.com/golang-jwt/jwt/v4 v4.5.2 20 | 21 | require ( 22 | github.com/google/uuid v1.6.0 23 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd 24 | github.com/stretchr/testify v1.10.0 25 | ) 26 | 27 | require ( 28 | github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect 29 | github.com/beorn7/perks v1.0.1 // indirect 30 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 31 | github.com/davecgh/go-spew v1.1.1 // indirect 32 | github.com/dolthub/maphash v0.1.0 // indirect 33 | github.com/gammazero/deque v1.1.0 // indirect 34 | github.com/google/go-tpm v0.9.5 // indirect 35 | github.com/klauspost/compress v1.18.0 // indirect 36 | github.com/maypok86/otter v1.2.4 // indirect 37 | github.com/minio/highwayhash v1.0.3 // indirect 38 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 39 | github.com/nats-io/jwt/v2 v2.7.4 // indirect 40 | github.com/nats-io/nkeys v0.4.11 // indirect 41 | github.com/nats-io/nuid v1.0.1 // indirect 42 | github.com/oapi-codegen/runtime v1.1.1 // indirect 43 | github.com/pmezard/go-difflib v1.0.0 // indirect 44 | github.com/prometheus/client_golang v1.22.0 // indirect 45 | github.com/prometheus/client_model v0.6.2 // indirect 46 | github.com/prometheus/common v0.65.0 // indirect 47 | github.com/prometheus/procfs v0.17.0 // indirect 48 | github.com/sony/gobreaker v1.0.0 // indirect 49 | go.uber.org/multierr v1.11.0 // indirect 50 | golang.org/x/crypto v0.40.0 // indirect 51 | golang.org/x/sys v0.34.0 // indirect 52 | golang.org/x/time v0.12.0 // indirect 53 | google.golang.org/protobuf v1.36.6 // indirect 54 | gopkg.in/yaml.v3 v3.0.1 // indirect 55 | ) 56 | -------------------------------------------------------------------------------- /src/State/LockStateEnum.php: -------------------------------------------------------------------------------- 1 | role, implode(', ', $this->allowedOperations)); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Events/StartOrchestration.php: -------------------------------------------------------------------------------- 1 | eventId); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/PerformanceTests/SequenceClient.php: -------------------------------------------------------------------------------- 1 | start(); 43 | $instance = $client->startNew(Sequence::class, $sequence); 44 | $client->waitForCompletion($instance); 45 | $watch->stop(); 46 | 47 | $logger->alert(sprintf('Completed in %s seconds', number_format($watch->getSeconds(), 2))); 48 | export_report('seq', $watch->getSeconds()); 49 | -------------------------------------------------------------------------------- /tests/PerformanceTests/FanOutFanInClient.php: -------------------------------------------------------------------------------- 1 | start(); 39 | $instance = $client->startNew(FanOutFanIn::class, ['count' => getenv('ACTIVITY_COUNT') ?: 5000]); 40 | $client->waitForCompletion($instance); 41 | $watch->stop(); 42 | 43 | var_dump($client->getStatus($instance)); 44 | var_dump($instance); 45 | 46 | $logger->alert(sprintf('Completed in %s seconds', number_format($watch->getSeconds(), 2))); 47 | export_report('Fan out/in', $watch->getSeconds()); 48 | -------------------------------------------------------------------------------- /src/Events/ShareWithUser.php: -------------------------------------------------------------------------------- 1 | userId, implode(', ', $this->allowedOperations)); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tests/ClientTestCli.php: -------------------------------------------------------------------------------- 1 | startNew( 41 | HelloSequence::class, 42 | ['name' => 'World'], 43 | Uuid::uuid7()->toString(), 44 | ); 45 | $client->raiseEvent($orchestrationInstance, 'event', ['data']); 46 | $client->waitForCompletion($orchestrationInstance, new TimeoutCancellation(hours(2)->as(Unit::Seconds))); 47 | var_dump($client->getStatus($orchestrationInstance)); 48 | -------------------------------------------------------------------------------- /tests/PerformanceTests/FanOutFanIn.php: -------------------------------------------------------------------------------- 1 | getInput()['count']; 37 | $tasks = []; 38 | for ($i = 0; $i < $count; $i++) { 39 | $tasks[] = $context->callActivity(SayHello::class, null, null, mb_str_pad((string) $i, 4, '0', STR_PAD_LEFT)); 40 | } 41 | $context->waitAll(...$tasks); 42 | foreach ($tasks as $i => $task) { 43 | $context->setCustomStatus($i); 44 | $task->getResult(); 45 | } 46 | $context->getReplayAwareLogger()->alert($i + 1); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tests/PerformanceTests/HelloCities/HelloSequence.php: -------------------------------------------------------------------------------- 1 | callActivity(SayHello::class, null, null, 'Tokyo'), 38 | $context->callActivity(SayHello::class, null, null, 'Seattle'), 39 | $context->callActivity(SayHello::class, null, null, 'London'), 40 | $context->callActivity(SayHello::class, null, null, 'Amsterdam'), 41 | $context->callActivity(SayHello::class, null, null, 'Seoul'), 42 | ]; 43 | 44 | return $context->waitAll(...$outputs); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/PerformanceTests/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | services: 3 | beanstalkd: 4 | image: ghcr.io/beanstalkd/beanstalkd 5 | ports: 6 | - "11300:11300" 7 | - "8080:8080" # rethinkdb 8 | - "28015:28015" # rethinkdb 9 | rethinkdb: 10 | image: rethinkdb 11 | network_mode: service:beanstalkd 12 | activities: 13 | image: workers 14 | restart: on-failure 15 | depends_on: 16 | beanstalkd: 17 | condition: service_started 18 | network_mode: service:beanstalkd 19 | build: 20 | context: ../../ 21 | dockerfile: tests/PerformanceTests/Dockerfile 22 | volumes: 23 | - ../..:/app 24 | working_dir: /app 25 | command: 26 | - /app/bin/dphp 27 | - run 28 | - --bootstrap 29 | - tests/PerformanceTests/bootstrap.php 30 | - --monitor 31 | - activities 32 | - --migrate 33 | orchestrations: 34 | image: workers 35 | restart: on-failure 36 | depends_on: 37 | beanstalkd: 38 | condition: service_started 39 | activities: 40 | condition: service_started 41 | network_mode: service:beanstalkd 42 | build: 43 | context: ../../ 44 | dockerfile: tests/PerformanceTests/Dockerfile 45 | volumes: 46 | - ../..:/app 47 | working_dir: /app 48 | command: 49 | - /app/bin/dphp 50 | - run 51 | - --bootstrap 52 | - tests/PerformanceTests/bootstrap.php 53 | - --monitor 54 | - orchestrations 55 | entities: 56 | image: workers 57 | restart: on-failure 58 | depends_on: 59 | beanstalkd: 60 | condition: service_started 61 | orchestrations: 62 | condition: service_started 63 | network_mode: service:beanstalkd 64 | build: 65 | context: ../../ 66 | dockerfile: tests/PerformanceTests/Dockerfile 67 | volumes: 68 | - ../..:/app 69 | working_dir: /app 70 | command: 71 | - /app/bin/dphp 72 | - run 73 | - --bootstrap 74 | - tests/PerformanceTests/bootstrap.php 75 | - --monitor 76 | - entities 77 | -------------------------------------------------------------------------------- /src/Events/WithDelay.php: -------------------------------------------------------------------------------- 1 | innerEvent->eventId ?: Uuid::uuid7()); 35 | } 36 | 37 | public static function forEvent(DateTimeImmutable $fireAt, Event $innerEvent): static 38 | { 39 | return new static( 40 | $innerEvent->eventId, 41 | $fireAt, 42 | $innerEvent, 43 | ); 44 | } 45 | 46 | public function getInnerEvent(): Event 47 | { 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/Contexts/LoggingContextFactory.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/Glue/autoload.php: -------------------------------------------------------------------------------- 1 | Level::Debug, 47 | 'INFO' => Level::Info, 48 | default => Level::Error, 49 | }); 50 | 51 | if (($_SERVER['SERVER_PROTOCOL'] ?? null) !== 'DPHP/1.0') { 52 | http_response_code(400); 53 | $logger->critical('Invalid request protocol', [$_SERVER['SERVER_PROTOCOL'] ?? null]); 54 | die(); 55 | } 56 | --------------------------------------------------------------------------------