├── .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 |
4 |
5 |
--------------------------------------------------------------------------------
/src/State/Attributes/Name.php:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/src/State/Attributes/Entity.php:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
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 |
7 |
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 |
5 |
6 |
7 |
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 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/.idea/jsonSchemas.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
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 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
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 |
4 |
5 |
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 |
--------------------------------------------------------------------------------