diff --git a/CHANGELOG.md b/CHANGELOG.md
index f61ff2e8..92f3ea66 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,34 @@ All notable changes to LaraFly are documented here. This project uses CalVer (`Y
### Added
+- **Orchestration (`firefly/orchestration`):** sagas with five compensation policies, compensation error
+ handlers, a builder DSL and saga composition; TCC with ordered participants, per-phase timeouts and
+ retries and TCC composition; and durable workflows with dependencies, conditions, signals, timers,
+ wait-for-all/any, child workflows, continue-as-new, queries, suspend/resume, search attributes and queued
+ execution. Definitions are attribute-discovered, compiled by `firefly:cache` and validated at boot.
+- **Orchestration persistence and recovery:** checkpoints at every step through a `memory`, `cache` or
+ `database` store (proven on PostgreSQL, MySQL, MariaDB, SQL Server and SQLite by the CI integration lanes),
+ a per-execution distributed lock, a scheduled recovery sweep that reports, resumes or compensates stale
+ executions, retention cleanup, and a dead-letter queue with retry.
+- **Orchestration operations:** lifecycle and step events through `firefly/eda`, event-type triggers, cron and
+ fixed-rate schedules, the `orchestration` actuator endpoint and health component,
+ `php artisan firefly:orchestration:{list,show,signal,dlq,dlq:retry,tick}` and an opt-in REST surface.
+- **Orchestration queued workflows:** ASYNC workflows are now tested end to end through a real Laravel queue.
+ A capstone checks that the `RunWorkflowJob` is pushed on `firefly.orchestration.workflow.queue.connection`
+ and `.name`, that `queue:work` runs it to `COMPLETED`, that a redelivered job is a no-op, and that the
+ `sync` connection runs the job inside the start. An integration test runs the same path on the `database`
+ queue against PostgreSQL, MySQL, MariaDB, SQL Server and a SQLite file, one CI integration lane each.
+- **Orchestration workflow compensation steps:** the separate-method `#[CompensationStep(compensates: …)]`
+ form is now tested and documented. The scanner tests check that the method becomes the step's compensation
+ with the step result bound implicitly, and that a duplicate, a step with both forms and an unknown step are
+ refused. A capstone and an integration test on PostgreSQL, MySQL, MariaDB, SQL Server and a SQLite file
+ check that a failing discovered workflow runs it.
+- **Orchestration failure parking:** a saga whose compensation stops part-way ends `COMPENSATION_FAILED` and
+ is dead-lettered, and a due workflow the tick fails to drive `firefly.orchestration.workflow.max-resume-attempts`
+ times in a row (default `5`) is parked `FAILED` and dead-lettered.
+- **Observability:** `orchestration_executions_seconds`, `orchestration_steps_seconds`,
+ `orchestration_compensations_total` and `orchestration_dead_letters_total`, and INTERNAL spans per execution
+ and step behind `firefly.observability.tracing.orchestration.enabled`.
- **`firefly/eda-database` — the same-transaction outbox on every database.** `firefly.eda.provider=database`
writes the outbox row inside the aggregate's transaction on PostgreSQL, MySQL, MariaDB, SQL Server or SQLite, and
claims committed rows by leases through an `OutboxClaimStrategy` (SKIP LOCKED on PostgreSQL, MySQL 8.0.1+ and
diff --git a/README.md b/README.md
index dde5dc58..a57926b2 100644
--- a/README.md
+++ b/README.md
@@ -1072,7 +1072,7 @@ Full flag reference and generated-file contents: [CLI](docs/cli.md).
One published library, `fireflyframework/larafly`, contains the components under `packages/*` and the bundled
application skeleton. Its `replace` entries satisfy component requirements at the framework's version;
-component names remain useful module boundaries, with their own test suites. The 35
+component names remain useful module boundaries, with their own test suites. The 36
[module guides](docs/modules/) below group them by concern:
| Group | Module | Package(s) |
@@ -1097,6 +1097,7 @@ component names remain useful module boundaries, with their own test suites. The
| Eventing & Messaging | [EDA Brokers](docs/modules/eda-brokers.md) — RabbitMQ/Postgres/Kafka adapters; Postgres adds `LISTEN`/`NOTIFY` to the database outbox | `firefly/eda-rabbitmq`, `firefly/eda-postgres`, `firefly/eda-kafka` |
| Eventing & Messaging | [EDA Database Outbox](docs/modules/eda-database.md) — the portable same-transaction outbox on every database, concurrent consumers | `firefly/eda-database` |
| Eventing & Messaging | [Messaging](docs/modules/messaging.md) — the lower-level raw-bytes broker layer | `firefly/messaging` |
+| Eventing & Messaging | [Orchestration](docs/modules/orchestration.md) — sagas, TCC, durable workflows, recovery, DLQ | `firefly/orchestration` |
| CQRS | [CQRS](docs/modules/cqrs.md) — `CommandBus`/`QueryBus`, the domain→integration-event bridge | `firefly/cqrs` |
| Security | [Security](docs/modules/security.md) — principal model, `HttpSecurity`, `#[PreAuthorize]`, JWT/OAuth2 | `firefly/security` |
| Security | [OAuth2 Client](docs/modules/security-oauth2-client.md) — OpenID Connect login with presets and discovery, PKCE, id-token validation, `OidcUser`, RP-initiated logout, client credentials, `Http::oauth2Client()` | `firefly/security-oauth2-client` |
@@ -1149,10 +1150,10 @@ genuine same-transaction outbox (RabbitMQ, Postgres, Kafka, and the portable dat
server, with integration lanes per server in CI) and the global `firefly new` installer. What's still ahead,
accurately:
-- **More actuator endpoints.** Sixteen ship today — `/health`, `/info`, `/env`, `/beans`, `/conditions`,
+- **More actuator endpoints.** Seventeen ship today — `/health`, `/info`, `/env`, `/beans`, `/conditions`,
`/mappings`, `/loggers`, `/scheduledtasks`, `/caches`, `/configprops`, `/metrics`, `/prometheus`,
- `/process`, `/httpexchanges`, `/oauth2clients` when the authorization server is installed, and `/flags`
- when feature flags are enabled — and
+ `/process`, `/httpexchanges`, `/oauth2clients` when the authorization server is installed, `/flags`
+ when feature flags are enabled, and `/orchestration` for sagas, TCCs and workflows — and
`firefly.management.server.port` moves the whole surface onto a second listener, with `ManagementPortGuard`
making the actuator refuse the application port and `php artisan firefly:management:serve` running that
second listener in development. Spring Boot's `/refresh`, `/threaddump` and `/shutdown` are still not
diff --git a/book/src-es/02-dependency-injection.md b/book/src-es/02-dependency-injection.md
index 0ef00a1a..173b98c3 100644
--- a/book/src-es/02-dependency-injection.md
+++ b/book/src-es/02-dependency-injection.md
@@ -367,7 +367,7 @@ php artisan firefly:cache
```
```
-firefly:cache — wrote 14 manifest(s) + 0 proxy(ies) to /path/to/my-app/bootstrap/cache/firefly
+firefly:cache — wrote 15 manifest(s) + 0 proxy(ies) to /path/to/my-app/bootstrap/cache/firefly
```
Ya ejecutaste esto una vez, de forma indirecta — `firefly new` lo llama por ti en su script `post-create-project-cmd`, que es la razón por la que la aplicación del Inicio rápido arrancó correctamente sin que ejecutaras el comando a mano ni una sola vez. De aquí en adelante, cada vez que añadas o cambies un `#[Service]`, `#[Repository]`, `#[Configuration]` o cualquier otro atributo de Firefly, vuelve a ejecutar `firefly:cache` para recompilar el manifiesto; `php artisan firefly:clear` elimina la caché compilada y vuelve a la ruta de escaneo de desarrollo (más lenta, basada en reflexión).
diff --git a/book/src-es/03-configuration.md b/book/src-es/03-configuration.md
index 2f35577f..a9c8b3e1 100644
--- a/book/src-es/03-configuration.md
+++ b/book/src-es/03-configuration.md
@@ -231,7 +231,7 @@ foreach ($this->classesIn($prefix, $dir) as $class) {
}
```
-`ConfigManifestCompiler` escribe esa lista en `bootstrap/cache/firefly/config-properties.php` con la misma técnica basada en `var_export()`, sin reflexión, que usó el manifiesto de componentes. `php artisan firefly:cache` ejecuta este escaneo como uno de los doce pares escáner/compilador que orquesta — nunca invocas `ConfigPropertiesScanner` tú mismo.
+`ConfigManifestCompiler` escribe esa lista en `bootstrap/cache/firefly/config-properties.php` con la misma técnica basada en `var_export()`, sin reflexión, que usó el manifiesto de componentes. `php artisan firefly:cache` ejecuta este escaneo como uno de los trece pares escáner/compilador que orquesta — nunca invocas `ConfigPropertiesScanner` tú mismo.
En el arranque, `Firefly\Cli\Boot\FireflyCacheServiceProvider` carga ese manifiesto compilado y se lo entrega directamente a `Firefly\Config\Registrar\ConfigRegistrar::register()`, que vincula el DTO de cada descriptor como singleton del contenedor en un solo paso:
diff --git a/book/src-es/07-cqrs.md b/book/src-es/07-cqrs.md
index 119f2f9e..95af9d88 100644
--- a/book/src-es/07-cqrs.md
+++ b/book/src-es/07-cqrs.md
@@ -456,11 +456,11 @@ El tipo exterior te dice *qué operación del bus* falló; el tipo interior —
`HandlerScanner` es el **único** punto de reflexión en `packages/cqrs/src` (la misma invariante forzada por grep que el Capítulo 6 describió para `firefly/domain`). Recorre una vez las raíces PSR-4 de la aplicación, resuelve cada `#[CommandHandler]`/`#[QueryHandler]` (ejecutando exactamente la inferencia de `messageType()` que acabas de leer) en un `HandlerDescriptor`, y entrega la lista completa a `HandlerManifestCompiler`, que la `var_export()`a en un archivo PHP de array plano — la misma técnica sin reflexión que el Capítulo 2 introdujo para el manifiesto de componentes y el Capítulo 3 para `#[ConfigProperties]`.
-Nunca ejecutas `HandlerScanner` tú mismo. Es uno de los **doce** pares escáner/compilador que `php artisan firefly:cache` orquesta en un solo paso, junto al de componentes/contexto, rutas, propiedades de configuración y (del Capítulo 9) el transaccional:
+Nunca ejecutas `HandlerScanner` tú mismo. Es uno de los **trece** pares escáner/compilador que `php artisan firefly:cache` orquesta en un solo paso, junto al de componentes/contexto, rutas, propiedades de configuración y (del Capítulo 9) el transaccional:
```bash
$ php artisan firefly:cache
-firefly:cache — wrote 14 manifest(s) + 1 proxy(ies) to /path/to/my-app/bootstrap/cache/firefly
+firefly:cache — wrote 15 manifest(s) + 1 proxy(ies) to /path/to/my-app/bootstrap/cache/firefly
```
El archivo compilado aterriza en `bootstrap/cache/firefly/handlers.php`; `FireflyCacheServiceProvider` lo vincula directamente sobre la entrada del contenedor `HandlerManifest` — sobrescribiendo incondicionalmente el `HandlerManifest` **vacío** por defecto que `CqrsWiringProvider` vincula cuando todavía no existe ninguna caché (el mismo patrón de retirada al estilo `#[ConditionalOnMissingBean]` que el Capítulo 2 te mostró para los beans, aplicado aquí a todo un manifiesto compilado). `CqrsHandlerWiringPass` recorre entonces ese manifiesto en el arranque y llena el `HandlerRegistry` que conociste arriba con un invocador por descriptor — cada invocador resuelve su bean manejador **de nuevo desde el contenedor en cada despacho**, nunca en caché en tiempo de cableado, así que un manejador `#[Transactional]` como `OpenWalletHandler` siempre se observa a través de su proxy generado (Capítulo 9), nunca a través de la clase desnuda, sin proxy.
@@ -479,7 +479,7 @@ Equivócate en esto en una aplicación — añade un manejador, olvida volver a
| La tubería acotada | correlacionar → validar → autorizar → (obtener de caché, solo consultas) → resolver e invocar → (guardar en caché, solo consultas) → métricas |
| `CommandProcessingException` / `QueryProcessingException` | Envoltorios que preservan la categoría; el fallo original siempre es alcanzable vía `getPrevious()` |
| `HandlerRegistry` | Una clase de comando/consulta se mapea a exactamente un manejador — un registro duplicado falla ruidosamente en tiempo de cableado |
-| `HandlerScanner` → `HandlerManifest` | El único punto de reflexión; compilado por `firefly:cache` (uno de doce pares escáner/compilador) en un manifiesto sin reflexión |
+| `HandlerScanner` → `HandlerManifest` | El único punto de reflexión; compilado por `firefly:cache` (uno de trece pares escáner/compilador) en un manifiesto sin reflexión |
---
diff --git a/book/src-es/08-eda-outbox.md b/book/src-es/08-eda-outbox.md
index e026b7c2..a641e2bc 100644
--- a/book/src-es/08-eda-outbox.md
+++ b/book/src-es/08-eda-outbox.md
@@ -183,7 +183,7 @@ final class OrderService
Aquí el destino (`'orders'`) y el tipo de evento (`'order.placed'`) son cadenas deliberadamente distintas, y el patrón `'order.*'` coincide con la **segunda** — precisamente para que este ejemplo no pueda confundirse con evidencia de que los patrones alguna vez miran el destino.
-`EventListenerScanner` (`packages/eda/src/Scanner/EventListenerScanner.php`) es el único sitio de reflexión en `firefly/eda`, recorre una vez las raíces PSR-4 de la aplicación y compila cada método `#[EventListener]` en un `EventListenerDescriptor`; `EventListenerManifest` carga el resultado sin reflexión alguna, exactamente el mismo idioma de `HandlerManifest` que te mostró el Capítulo 7. `firefly:cache` ejecuta este escáner como uno de sus doce pares, escribiendo `bootstrap/cache/firefly/event-listeners.php`; `FireflyCacheServiceProvider` lo vincula, sustituyendo el valor por defecto vacío con el que arrancaría una aplicación sin caché. `EventListenerWiringPass` recorre después ese manifiesto al arrancar — en *todo* proceso, tanto en la petición web como en el worker de cola — envuelve cada invocación de destino en el decorador de reintento/DLQ de más abajo, y llama a `$bus->subscribe($pattern, $wrapped)` por cada patrón, resolviendo el bean de destino **fresco desde el contenedor en cada despacho**.
+`EventListenerScanner` (`packages/eda/src/Scanner/EventListenerScanner.php`) es el único sitio de reflexión en `firefly/eda`, recorre una vez las raíces PSR-4 de la aplicación y compila cada método `#[EventListener]` en un `EventListenerDescriptor`; `EventListenerManifest` carga el resultado sin reflexión alguna, exactamente el mismo idioma de `HandlerManifest` que te mostró el Capítulo 7. `firefly:cache` ejecuta este escáner como uno de sus trece pares, escribiendo `bootstrap/cache/firefly/event-listeners.php`; `FireflyCacheServiceProvider` lo vincula, sustituyendo el valor por defecto vacío con el que arrancaría una aplicación sin caché. `EventListenerWiringPass` recorre después ese manifiesto al arrancar — en *todo* proceso, tanto en la petición web como en el worker de cola — envuelve cada invocación de destino en el decorador de reintento/DLQ de más abajo, y llama a `$bus->subscribe($pattern, $wrapped)` por cada patrón, resolviendo el bean de destino **fresco desde el contenedor en cada despacho**.
---
diff --git a/book/src-es/09-transactions.md b/book/src-es/09-transactions.md
index b18aff6d..bc965632 100644
--- a/book/src-es/09-transactions.md
+++ b/book/src-es/09-transactions.md
@@ -404,7 +404,7 @@ Cuatro detalles, cada uno de los cuales acabarás necesitando:
El Capítulo 2 te enseñó la forma de una clase `#[Configuration]` y sus métodos fábrica `#[Bean]`. Durante una versión de este framework, toda aplicación LaraFly tuvo que escribir una de ellas a mano — un `#[Configuration]` cuyo único `#[Bean]` cargaba el `TransactionalManifest` cacheado — o `#[Transactional]` no hacía absolutamente nada. **Ya no necesitas esa clase**, y merece una página: el fallo que aquella clase sorteaba fue de los más afilados que ha tenido este framework, y su arreglo es una pequeña lección sobre cómo se resuelve cada artefacto compilado.
-`php artisan firefly:cache` emite **tres** artefactos separados que lee la maquinaria de proxies. Ejecuta `TransactionalScanner`/`TransactionalManifestCompiler` — uno de los doce pares scanner/compiler — para escribir los datos del manifiesto en `bootstrap/cache/firefly/transactional.php`. Ejecuta `ProxyPlanCompiler` sobre el `ProxyPlan` fusionado para escribir `bootstrap/cache/firefly/proxy-plan.php` — el artefacto que decide qué beans reciben siquiera un proxy y qué advice ejecuta cada uno de sus métodos. Y ejecuta `ProxyClassGenerator` para emitir un archivo fuente `{Target}__FireflyTransactionalProxy` **por cada clase que el plan nombra** — cada clase que reclama algún `AdviceSource`, así que un bean que solo lleva reglas de seguridad de método también recibe uno — en un directorio `proxies/`, más un classmap `proxies.php`.
+`php artisan firefly:cache` emite **tres** artefactos separados que lee la maquinaria de proxies. Ejecuta `TransactionalScanner`/`TransactionalManifestCompiler` — uno de los trece pares scanner/compiler — para escribir los datos del manifiesto en `bootstrap/cache/firefly/transactional.php`. Ejecuta `ProxyPlanCompiler` sobre el `ProxyPlan` fusionado para escribir `bootstrap/cache/firefly/proxy-plan.php` — el artefacto que decide qué beans reciben siquiera un proxy y qué advice ejecuta cada uno de sus métodos. Y ejecuta `ProxyClassGenerator` para emitir un archivo fuente `{Target}__FireflyTransactionalProxy` **por cada clase que el plan nombra** — cada clase que reclama algún `AdviceSource`, así que un bean que solo lleva reglas de seguridad de método también recibe uno — en un directorio `proxies/`, más un classmap `proxies.php`.
Durante mucho tiempo, nada cargaba el primero de esos tres. `DataAutoConfiguration` vinculaba un `TransactionalManifest` *vacío* incondicionalmente, el `transactional.php` compilado solo lo referenciaba su propia declaración, y por eso `hasProxyFor()` era siempre falso y `TransactionalBeanPostProcessor` devolvía cada bean sin envolver. `#[Transactional]` era un **no-op silencioso** en cualquier aplicación que no escribiera a mano su propia configuración de manifiesto — que es exactamente lo que aquel `#[Configuration]` manuscrito existía para hacer, y exactamente el modo de fallo del que avisaba la sección anterior, llegando desde el propio lado del framework.
diff --git a/book/src-es/13-cli-cache.md b/book/src-es/13-cli-cache.md
index 535dfceb..d99c7a08 100644
--- a/book/src-es/13-cli-cache.md
+++ b/book/src-es/13-cli-cache.md
@@ -2,7 +2,7 @@
# La CLI y la Caché Sin Reflexión {.chtitle}
-Al terminar este capítulo sabrás exactamente qué hace `php artisan firefly:cache` — los trece pasos de escáner-y-compilador que ejecuta, en orden, sobre las propias raíces PSR-4 de tu aplicación, y los manifiestos de `bootstrap/cache/firefly/` y las clases proxy generadas (una por cada clase que nombra el plan de proxies) que escribe — cómo `FireflyCacheServiceProvider` vuelve a cargar esa caché en el arranque con cero reflexión, el inverso exacto de `firefly:clear`, los comandos de actuator-sobre-CLI (`firefly:about`/`:routes`/`:health`/`:metrics`) que reutilizan los endpoints del Capítulo 11 en-proceso sin ida y vuelta HTTP, los ocho generadores `make:firefly-*`, y cómo el `firefly new` de `firefly/installer` te lleva de la nada a una aplicación cacheada y en funcionamiento en un solo comando.
+Al terminar este capítulo sabrás exactamente qué hace `php artisan firefly:cache` — los catorce pasos de escáner-y-compilador que ejecuta, en orden, sobre las propias raíces PSR-4 de tu aplicación, y los manifiestos de `bootstrap/cache/firefly/` y las clases proxy generadas (una por cada clase que nombra el plan de proxies) que escribe — cómo `FireflyCacheServiceProvider` vuelve a cargar esa caché en el arranque con cero reflexión, el inverso exacto de `firefly:clear`, los comandos de actuator-sobre-CLI (`firefly:about`/`:routes`/`:health`/`:metrics`) que reutilizan los endpoints del Capítulo 11 en-proceso sin ida y vuelta HTTP, los ocho generadores `make:firefly-*`, y cómo el `firefly new` de `firefly/installer` te lleva de la nada a una aplicación cacheada y en funcionamiento en un solo comando.
!!! note "Término nuevo: arranque sin reflexión"
Cada capítulo anterior a este ha mencionado, de pasada, que un manifiesto compilado reemplaza a "escanea el sistema de archivos con reflexión de PHP en cada arranque". Un **arranque sin reflexión** es lo que obtienes una vez que `firefly:cache` ha ejecutado realmente: `FireflyCacheServiceProvider` carga manifiestos precomputados directamente desde archivos PHP bajo `bootstrap/cache/firefly/`, de modo que ningún atributo, ninguna clase y ningún método se somete jamás a reflexión en tiempo de petición — el escaneo ocurrió una vez, en tiempo de despliegue, no en cada petición.
@@ -125,6 +125,12 @@ final class ManifestCacheWriter
$files[] = $dir.'/'.FireflyCachePaths::SCHEDULED,
);
+ // orchestration — #[Saga] / #[Tcc] / #[Workflow] definitions and their schedules.
+ (new OrchestrationManifestCompiler)->write(
+ (new OrchestrationScanner)->scan($psr4),
+ $files[] = $dir.'/'.FireflyCachePaths::ORCHESTRATION,
+ );
+
// security
(new SecurityMethodManifestCompiler)->write(
(new MethodSecurityScanner)->scan($psr4),
@@ -146,7 +152,7 @@ final class ManifestCacheWriter
}
```
-Eso son doce llamadas `scan()`-luego-`write()` — cada capacidad desde el escaneo de componentes del Capítulo 2 hasta el manifiesto de seguridad de método del Capítulo 10, cada una ya presentada en un capítulo anterior como un escaneo en-proceso — más un paso más, `writeProxies()`, que genera las clases proxy que te enseñó el Capítulo 9, usando el mismísimo `ProxyClassGenerator` sin reflexión que usa el propio runtime cuando aún no existe caché:
+Eso son trece llamadas `scan()`-luego-`write()` — cada capacidad desde el escaneo de componentes del Capítulo 2 hasta el manifiesto de seguridad de método del Capítulo 10, más el manifiesto de orquestación con las definiciones `#[Saga]`, `#[Tcc]` y `#[Workflow]`, cada una ya presentada en un capítulo anterior como un escaneo en-proceso — más un paso más, `writeProxies()`, que genera las clases proxy que te enseñó el Capítulo 9, usando el mismísimo `ProxyClassGenerator` sin reflexión que usa el propio runtime cuando aún no existe caché:
```php
@@ -195,7 +201,7 @@ final class ManifestCacheWriter
}
```
-Trece pasos en total dejan catorce artefactos bajo `bootstrap/cache/firefly/`:
+Catorce pasos en total dejan quince artefactos bajo `bootstrap/cache/firefly/`:
```
bootstrap/cache/firefly/
@@ -209,6 +215,7 @@ bootstrap/cache/firefly/
├── event-listeners.php # #[EventListener] manifest
├── message-listeners.php # #[MessageListener] manifest
├── scheduled.php # #[Scheduled] manifest
+├── orchestration.php # #[Saga]/#[Tcc]/#[Workflow] definitions and their schedules
├── security-methods.php # #[PreAuthorize]/#[Secured]/#[RolesAllowed] manifest
├── transactional.php # #[Transactional] method manifest
├── proxy-plan.php # qué beans reciben un proxy, y qué advice ejecuta cada método
@@ -313,7 +320,7 @@ php artisan firefly:cache
```
```
-firefly:cache — wrote 14 manifest(s) + 3 proxy(ies) to bootstrap/cache/firefly
+firefly:cache — wrote 15 manifest(s) + 3 proxy(ies) to bootstrap/cache/firefly
firefly:cache — skipped 1 stale manifest entry naming a class that no longer exists: App\Security\ControlPlaneJwksProvider
```
@@ -521,7 +528,7 @@ El Capítulo 13A presenta `firefly:flags` para inspeccionar y cambiar banderas e
| Concepto | Qué hace |
|---|---|
-| `firefly:cache` | Ejecuta el par escáner→compilador propio de cada capacidad (13 pasos, 14 artefactos) sobre `firefly.scan.paths`; escribe manifiestos + un archivo de clase proxy generado por cada clase que nombra el plan de proxies a `bootstrap/cache/firefly/` |
+| `firefly:cache` | Ejecuta el par escáner→compilador propio de cada capacidad (14 pasos, 15 artefactos) sobre `firefly.scan.paths`; escribe manifiestos + un archivo de clase proxy generado por cada clase que nombra el plan de proxies a `bootstrap/cache/firefly/` |
| `FireflyCacheServiceProvider` | Enlaza cada manifiesto cacheado incondicionalmente (gana sobre todo valor por defecto vacío) + instala el autoloader de classmap de proxies — antes de que se ejecute cualquier resolución de bean |
| `firefly:clear` | Borra recursivamente solo el directorio de caché; el siguiente arranque recurre al escaneo en-proceso |
| `firefly:about` / `:routes` / `:health` / `:metrics` | Renderiza los endpoints de actuator del Capítulo 11 en el terminal, en-proceso, sin ida y vuelta HTTP |
diff --git a/book/src/02-dependency-injection.md b/book/src/02-dependency-injection.md
index 8f0103ba..8dfc6d89 100644
--- a/book/src/02-dependency-injection.md
+++ b/book/src/02-dependency-injection.md
@@ -367,7 +367,7 @@ php artisan firefly:cache
```
```
-firefly:cache — wrote 14 manifest(s) + 0 proxy(ies) to /path/to/my-app/bootstrap/cache/firefly
+firefly:cache — wrote 15 manifest(s) + 0 proxy(ies) to /path/to/my-app/bootstrap/cache/firefly
```
You already ran this once, indirectly — `firefly new` calls it for you in its `post-create-project-cmd` script, which is why the Quick Start's application booted correctly without you ever running the command by hand. From here on, any time you add or change a `#[Service]`, `#[Repository]`, `#[Configuration]`, or any other Firefly attribute, re-run `firefly:cache` to recompile the manifest; `php artisan firefly:clear` deletes the compiled cache and falls back to the slower, reflection-based development scan.
diff --git a/book/src/03-configuration.md b/book/src/03-configuration.md
index e57e002b..47502f11 100644
--- a/book/src/03-configuration.md
+++ b/book/src/03-configuration.md
@@ -231,7 +231,7 @@ foreach ($this->classesIn($prefix, $dir) as $class) {
}
```
-`ConfigManifestCompiler` writes that list to `bootstrap/cache/firefly/config-properties.php` with the same `var_export()`-based, zero-reflection technique the component manifest used. `php artisan firefly:cache` runs this scan as one step among the twelve scanner/compiler pairs it orchestrates — you never invoke `ConfigPropertiesScanner` yourself.
+`ConfigManifestCompiler` writes that list to `bootstrap/cache/firefly/config-properties.php` with the same `var_export()`-based, zero-reflection technique the component manifest used. `php artisan firefly:cache` runs this scan as one step among the thirteen scanner/compiler pairs it orchestrates — you never invoke `ConfigPropertiesScanner` yourself.
At boot, `Firefly\Cli\Boot\FireflyCacheServiceProvider` loads that compiled manifest and hands it straight to `Firefly\Config\Registrar\ConfigRegistrar::register()`, which binds every descriptor's DTO as a container singleton in one pass:
diff --git a/book/src/07-cqrs.md b/book/src/07-cqrs.md
index 957f321d..da940320 100644
--- a/book/src/07-cqrs.md
+++ b/book/src/07-cqrs.md
@@ -456,11 +456,11 @@ The outer type tells you *which bus operation* failed; the inner type — reache
`HandlerScanner` is the **sole** reflection site in `packages/cqrs/src` (the same grep-enforced invariant Chapter 6 described for `firefly/domain`). It walks the app's PSR-4 roots once, resolves every `#[CommandHandler]`/`#[QueryHandler]` (running exactly the `messageType()` inference you just read) into a `HandlerDescriptor`, and hands the whole list to `HandlerManifestCompiler`, which `var_export()`s it to a plain PHP array file — the same zero-reflection technique Chapter 2 introduced for the component manifest and Chapter 3 for `#[ConfigProperties]`.
-You never run `HandlerScanner` yourself. It is one of the **twelve** scanner/compiler pairs `php artisan firefly:cache` orchestrates in one pass, alongside the component/context, route, config-properties, and (Chapter 9's) transactional scanners:
+You never run `HandlerScanner` yourself. It is one of the **thirteen** scanner/compiler pairs `php artisan firefly:cache` orchestrates in one pass, alongside the component/context, route, config-properties, and (Chapter 9's) transactional scanners:
```bash
$ php artisan firefly:cache
-firefly:cache — wrote 14 manifest(s) + 1 proxy(ies) to /path/to/my-app/bootstrap/cache/firefly
+firefly:cache — wrote 15 manifest(s) + 1 proxy(ies) to /path/to/my-app/bootstrap/cache/firefly
```
The compiled file lands at `bootstrap/cache/firefly/handlers.php`; `FireflyCacheServiceProvider` binds it straight onto the `HandlerManifest` container entry — unconditionally overriding the **empty** default `CqrsWiringProvider` binds when no cache exists yet (the same `#[ConditionalOnMissingBean]`-style backing-off pattern Chapter 2 showed you for beans, applied here to a whole compiled manifest). `CqrsHandlerWiringPass` then walks that manifest at boot and populates the `HandlerRegistry` you met above with one invoker per descriptor — each invoker resolving its handler bean **fresh from the container on every dispatch**, never cached at wiring time, so a `#[Transactional]` handler like `OpenWalletHandler` is always observed through its generated proxy (Chapter 9), never through the bare, unproxied class.
@@ -479,7 +479,7 @@ Get this wrong in an application — add a handler, forget to re-run `firefly:ca
| The bounded pipeline | correlate → validate → authorize → (cache-get, query only) → resolve + invoke → (cache-put, query only) → metrics |
| `CommandProcessingException` / `QueryProcessingException` | Category-preserving wrappers; the original fault is always reachable via `getPrevious()` |
| `HandlerRegistry` | One command/query class maps to exactly one handler — a duplicate registration fails loud at wiring time |
-| `HandlerScanner` → `HandlerManifest` | The sole reflection site; compiled by `firefly:cache` (one of twelve scanner/compiler pairs) into a zero-reflection manifest |
+| `HandlerScanner` → `HandlerManifest` | The sole reflection site; compiled by `firefly:cache` (one of thirteen scanner/compiler pairs) into a zero-reflection manifest |
---
diff --git a/book/src/08-eda-outbox.md b/book/src/08-eda-outbox.md
index 493c5cff..f36649b1 100644
--- a/book/src/08-eda-outbox.md
+++ b/book/src/08-eda-outbox.md
@@ -183,7 +183,7 @@ final class OrderService
Here the destination (`'orders'`) and the event type (`'order.placed'`) are deliberately different strings, and the `'order.*'` pattern matches the **second** one — precisely so this example cannot be mistaken for evidence that patterns ever look at the destination.
-`EventListenerScanner` (`packages/eda/src/Scanner/EventListenerScanner.php`) is the sole reflection site in `firefly/eda`, walking the app's PSR-4 roots once and compiling every `#[EventListener]` method into an `EventListenerDescriptor`; `EventListenerManifest` loads the result with zero reflection, exactly the `HandlerManifest` idiom Chapter 7 just showed you. `firefly:cache` runs this scanner as one of its twelve pairs, writing `bootstrap/cache/firefly/event-listeners.php`; `FireflyCacheServiceProvider` binds it, overriding the empty default an uncached app would otherwise boot with. `EventListenerWiringPass` then walks that manifest at boot — in *every* process, web request and queue worker alike — wraps each target invocation in the retry/DLQ decorator below, and calls `$bus->subscribe($pattern, $wrapped)` per pattern, resolving the target bean **fresh from the container on every dispatch**.
+`EventListenerScanner` (`packages/eda/src/Scanner/EventListenerScanner.php`) is the sole reflection site in `firefly/eda`, walking the app's PSR-4 roots once and compiling every `#[EventListener]` method into an `EventListenerDescriptor`; `EventListenerManifest` loads the result with zero reflection, exactly the `HandlerManifest` idiom Chapter 7 just showed you. `firefly:cache` runs this scanner as one of its thirteen pairs, writing `bootstrap/cache/firefly/event-listeners.php`; `FireflyCacheServiceProvider` binds it, overriding the empty default an uncached app would otherwise boot with. `EventListenerWiringPass` then walks that manifest at boot — in *every* process, web request and queue worker alike — wraps each target invocation in the retry/DLQ decorator below, and calls `$bus->subscribe($pattern, $wrapped)` per pattern, resolving the target bean **fresh from the container on every dispatch**.
---
diff --git a/book/src/09-transactions.md b/book/src/09-transactions.md
index 2c62741f..1abce006 100644
--- a/book/src/09-transactions.md
+++ b/book/src/09-transactions.md
@@ -404,7 +404,7 @@ Four details, each of which you will eventually need:
Chapter 2 taught you the shape of a `#[Configuration]` class and its `#[Bean]` factory methods. For one release of this framework, every LaraFly application had to write one of them by hand — a `#[Configuration]` whose single `#[Bean]` loaded the cached `TransactionalManifest` — or `#[Transactional]` did nothing at all. **You no longer need that class**, and it is worth a page: the bug it worked around was one of the sharpest this framework has had, and the fix is a small lesson in how every compiled artifact is resolved.
-`php artisan firefly:cache` emits **three** separate artifacts the proxy machinery reads. It runs `TransactionalScanner`/`TransactionalManifestCompiler` — one of the twelve scanner/compiler pairs — to write the manifest data to `bootstrap/cache/firefly/transactional.php`. It runs `ProxyPlanCompiler` over the merged `ProxyPlan` to write `bootstrap/cache/firefly/proxy-plan.php` — the artifact that decides which beans get a proxy at all and which advice each of their methods runs. And it runs `ProxyClassGenerator` to emit one `{Target}__FireflyTransactionalProxy` source file **per class the plan names** — every class any `AdviceSource` claims, so a bean carrying only method-security rules gets one too — into a `proxies/` directory, plus a `proxies.php` classmap.
+`php artisan firefly:cache` emits **three** separate artifacts the proxy machinery reads. It runs `TransactionalScanner`/`TransactionalManifestCompiler` — one of the thirteen scanner/compiler pairs — to write the manifest data to `bootstrap/cache/firefly/transactional.php`. It runs `ProxyPlanCompiler` over the merged `ProxyPlan` to write `bootstrap/cache/firefly/proxy-plan.php` — the artifact that decides which beans get a proxy at all and which advice each of their methods runs. And it runs `ProxyClassGenerator` to emit one `{Target}__FireflyTransactionalProxy` source file **per class the plan names** — every class any `AdviceSource` claims, so a bean carrying only method-security rules gets one too — into a `proxies/` directory, plus a `proxies.php` classmap.
For a long time, nothing loaded the first of those three. `DataAutoConfiguration` bound an unconditional *empty* `TransactionalManifest`, the compiled `transactional.php` was referenced only by its own declaration, and so `hasProxyFor()` was always false and `TransactionalBeanPostProcessor` handed back every bean unwrapped. `#[Transactional]` was a **silent no-op** in any application that did not hand-write its own manifest configuration — which is exactly what that hand-written `#[Configuration]` existed to do, and exactly the failure mode the previous section warned about, arriving from the framework's own side.
diff --git a/book/src/13-cli-cache.md b/book/src/13-cli-cache.md
index 6a625374..d4c29581 100644
--- a/book/src/13-cli-cache.md
+++ b/book/src/13-cli-cache.md
@@ -2,7 +2,7 @@
# The CLI and the Zero-Reflection Cache {.chtitle}
-By the end of this chapter you will know exactly what `php artisan firefly:cache` does — the thirteen scanner-and-compiler steps it runs, in order, over your application's own PSR-4 roots, and the `bootstrap/cache/firefly/` manifests and generated proxy classes (one per class the proxy plan names) it writes — how `FireflyCacheServiceProvider` loads that cache back in at boot with zero reflection, `firefly:clear`'s exact inverse, the actuator-over-CLI commands (`firefly:about`/`:routes`/`:health`/`:metrics`) that reuse Chapter 11's endpoints in-process with no HTTP round trip, the eight `make:firefly-*` generators, and how `firefly/installer`'s `firefly new` gets you from nothing to a cached, running application in one command.
+By the end of this chapter you will know exactly what `php artisan firefly:cache` does — the fourteen scanner-and-compiler steps it runs, in order, over your application's own PSR-4 roots, and the `bootstrap/cache/firefly/` manifests and generated proxy classes (one per class the proxy plan names) it writes — how `FireflyCacheServiceProvider` loads that cache back in at boot with zero reflection, `firefly:clear`'s exact inverse, the actuator-over-CLI commands (`firefly:about`/`:routes`/`:health`/`:metrics`) that reuse Chapter 11's endpoints in-process with no HTTP round trip, the eight `make:firefly-*` generators, and how `firefly/installer`'s `firefly new` gets you from nothing to a cached, running application in one command.
!!! note "New term: zero-reflection boot"
Every chapter before this one has mentioned, in passing, that a compiled manifest replaces "scan the filesystem with PHP reflection at every boot." A **zero-reflection boot** is what you get once `firefly:cache` has actually run: `FireflyCacheServiceProvider` loads pre-computed manifests straight from PHP files under `bootstrap/cache/firefly/`, so no attribute, no class, and no method is ever reflected on at request time — the scan happened once, at deploy time, not on every request.
@@ -125,6 +125,12 @@ final class ManifestCacheWriter
$files[] = $dir.'/'.FireflyCachePaths::SCHEDULED,
);
+ // orchestration — #[Saga] / #[Tcc] / #[Workflow] definitions and their schedules.
+ (new OrchestrationManifestCompiler)->write(
+ (new OrchestrationScanner)->scan($psr4),
+ $files[] = $dir.'/'.FireflyCachePaths::ORCHESTRATION,
+ );
+
// security
(new SecurityMethodManifestCompiler)->write(
(new MethodSecurityScanner)->scan($psr4),
@@ -146,7 +152,7 @@ final class ManifestCacheWriter
}
```
-That is twelve `scan()`-then-`write()` calls — every capability from Chapter 2's component scan through Chapter 10's method-security manifest, each already introduced in an earlier chapter as an in-process scan — plus one more step, `writeProxies()`, that generates the proxy classes Chapter 9 taught you about, using the exact same reflection-free `ProxyClassGenerator` the runtime itself uses when no cache exists yet:
+That is thirteen `scan()`-then-`write()` calls — every capability from Chapter 2's component scan through Chapter 10's method-security manifest, plus the orchestration manifest of `#[Saga]`, `#[Tcc]` and `#[Workflow]` definitions, each already introduced in an earlier chapter as an in-process scan — plus one more step, `writeProxies()`, that generates the proxy classes Chapter 9 taught you about, using the exact same reflection-free `ProxyClassGenerator` the runtime itself uses when no cache exists yet:
```php
@@ -195,7 +201,7 @@ final class ManifestCacheWriter
}
```
-Thirteen steps in total land fourteen artifacts under `bootstrap/cache/firefly/`:
+Fourteen steps in total land fifteen artifacts under `bootstrap/cache/firefly/`:
```
bootstrap/cache/firefly/
@@ -209,6 +215,7 @@ bootstrap/cache/firefly/
├── event-listeners.php # #[EventListener] manifest
├── message-listeners.php # #[MessageListener] manifest
├── scheduled.php # #[Scheduled] manifest
+├── orchestration.php # #[Saga]/#[Tcc]/#[Workflow] definitions and their schedules
├── security-methods.php # #[PreAuthorize]/#[Secured]/#[RolesAllowed] manifest
├── transactional.php # #[Transactional] method manifest
├── proxy-plan.php # which beans get a proxy, and which advice each method runs
@@ -313,7 +320,7 @@ php artisan firefly:cache
```
```
-firefly:cache — wrote 14 manifest(s) + 3 proxy(ies) to bootstrap/cache/firefly
+firefly:cache — wrote 15 manifest(s) + 3 proxy(ies) to bootstrap/cache/firefly
firefly:cache — skipped 1 stale manifest entry naming a class that no longer exists: App\Security\ControlPlaneJwksProvider
```
@@ -521,7 +528,7 @@ Chapter 13A introduces `firefly:flags` for inspecting and changing runtime flags
| Concept | What it does |
|---|---|
-| `firefly:cache` | Runs every capability's own scanner→compiler pair (13 steps, 14 artifacts) over `firefly.scan.paths`; writes manifests + one generated proxy class file per class the proxy plan names to `bootstrap/cache/firefly/` |
+| `firefly:cache` | Runs every capability's own scanner→compiler pair (14 steps, 15 artifacts) over `firefly.scan.paths`; writes manifests + one generated proxy class file per class the proxy plan names to `bootstrap/cache/firefly/` |
| `FireflyCacheServiceProvider` | Binds each cached manifest unconditionally (wins over every empty default) + installs the proxy classmap autoloader — before any bean resolution runs |
| `firefly:clear` | Recursively deletes only the cache dir; the next boot falls back to in-process scanning |
| `firefly:about` / `:routes` / `:health` / `:metrics` | Renders Chapter 11's actuator endpoints at the terminal, in-process, no HTTP round trip |
diff --git a/composer.json b/composer.json
index ab8b13af..55632865 100644
--- a/composer.json
+++ b/composer.json
@@ -76,6 +76,7 @@
"Firefly\\Messaging\\Tests\\": "packages/messaging/tests/",
"Firefly\\Observability\\Tests\\": "packages/observability/tests/",
"Firefly\\OpenApi\\Tests\\": "packages/openapi/tests/",
+ "Firefly\\Orchestration\\Tests\\": "packages/orchestration/tests/",
"Firefly\\Resilience\\Tests\\": "packages/resilience/tests/",
"Firefly\\Scheduling\\Database\\Tests\\": "packages/scheduling-database/tests/",
"Firefly\\Scheduling\\Postgres\\Tests\\": "packages/scheduling-postgres/tests/",
@@ -145,6 +146,7 @@
"firefly/messaging": "self.version",
"firefly/observability": "self.version",
"firefly/openapi": "self.version",
+ "firefly/orchestration": "self.version",
"firefly/resilience": "self.version",
"firefly/scheduling": "self.version",
"firefly/scheduling-database": "self.version",
@@ -178,6 +180,7 @@
"Firefly\\Kernel\\": "packages/kernel/src/",
"Firefly\\Messaging\\": "packages/messaging/src/",
"Firefly\\Observability\\": "packages/observability/src/",
+ "Firefly\\Orchestration\\": "packages/orchestration/src/",
"Firefly\\OpenApi\\": "packages/openapi/src/",
"Firefly\\Resilience\\": "packages/resilience/src/",
"Firefly\\Scheduling\\": "packages/scheduling/src/",
@@ -272,6 +275,8 @@
"Firefly\\Security\\OAuth2\\Client\\SecurityOAuth2ClientWiringProvider",
"Firefly\\FeatureFlags\\FeatureFlagsServiceProvider",
"Firefly\\FeatureFlags\\FeatureFlagsWiringProvider",
+ "Firefly\\Orchestration\\OrchestrationServiceProvider",
+ "Firefly\\Orchestration\\OrchestrationWiringProvider",
"Firefly\\Cli\\Boot\\FireflyCacheServiceProvider"
]
}
diff --git a/deptrac.yaml b/deptrac.yaml
index fc71d056..1983ef1c 100644
--- a/deptrac.yaml
+++ b/deptrac.yaml
@@ -110,6 +110,10 @@ deptrac:
collectors:
- type: directory
value: packages/feature-flags/src/.*
+ - name: Orchestration
+ collectors:
+ - type: directory
+ value: packages/orchestration/src/.*
- name: OpenApi
collectors:
- type: directory
@@ -476,6 +480,9 @@ deptrac:
#
# The => FeatureFlags edge is one class (src/FeatureFlags): MeterRegistryFeatureFlagMetrics backs the
# FeatureFlagMetrics port with feature_flag_evaluations_total{flag, variant, reason} — the CqrsMetrics seam.
+ #
+ # The => Orchestration edge is one folder (src/Orchestration): the meter-registry OrchestrationMetrics and
+ # the Tracer-backed OrchestrationTracing — the same seam direction as Cqrs, Eda and FeatureFlags.
Observability:
- Kernel
- Container
@@ -489,6 +496,7 @@ deptrac:
- Resilience
- Data
- FeatureFlags
+ - Orchestration
# Admin is the browser front-end for Actuator: it renders the SAME ActuatorEndpoint beans the JSON surface
# serves, resolved in-process from Actuator's registry rather than fetched over HTTP (=> Actuator), mounts
@@ -535,6 +543,26 @@ deptrac:
- Actuator
- Resilience
+ # Orchestration is the saga / TCC / durable-workflow capability. It reads Config, carries Container/Context
+ # attributes and boot passes, extends AutoConfigure's base, reuses Kernel exceptions, builds its step
+ # retries, timeouts and the CIRCUIT_BREAKER compensation policy on Resilience (Retry, TimeLimiter,
+ # CircuitBreaker, Duration), serializes writers through Scheduling's DistributedLock and contributes its tick
+ # to Scheduling's manifest (Cadence, ScheduledDescriptor), publishes lifecycle and step events and listens
+ # for trigger events through Eda's EventPublisher, and supplies an Actuator endpoint and HealthIndicator.
+ # NO Web edge (its REST routes mount on the Illuminate router, the FlagdSyncRouteRegistrar precedent) and NO
+ # Data edge (state is written through illuminate/database). Cli (firefly:cache compiles its manifest) and
+ # Observability (the metrics and tracing adapters) depend on it; it depends on neither.
+ Orchestration:
+ - Kernel
+ - Container
+ - Config
+ - Context
+ - AutoConfigure
+ - Resilience
+ - Scheduling
+ - Eda
+ - Actuator
+
# OpenApi is a top-of-stack capability (like Web/Cqrs/Security/Actuator): it generates an OpenAPI 3.1
# document from manifests that already exist, so it is nearly all READS. It reads Web's RouteManifest/
# RouteDescriptor (paths, verbs, statuses, binding plans) and Validation's ConstraintManifest + Rule
@@ -585,7 +613,7 @@ deptrac:
- Observability
- FeatureFlags
- # Cli is the top-of-stack developer-experience package: firefly:cache drives each of the 12
+ # Cli is the top-of-stack developer-experience package: firefly:cache drives each of the 13
# compile-producing packages' scanner/compiler pair directly (D4 DIRECT DEPENDENCY). UNLIKE Testing
# (whose composer.json genuinely requires the full runtime family, so it legitimately lists all 18
# layers), firefly/cli's composer.json requires exactly the packages ManifestCacheWriter/CacheCommand/
@@ -598,6 +626,8 @@ deptrac:
# FeatureFlags is listed for the same reason: ManifestCacheWriter's planner names FeatureFlagAdviceSource, so
# firefly:cache compiles #[FeatureFlag] into the same proxy plan; packages/cli/composer.json requires it
# accordingly.
+ # Orchestration is listed for the same reason: ManifestCacheWriter compiles #[Saga]/#[Tcc]/#[Workflow] through
+ # OrchestrationScanner into orchestration.php, and packages/cli/composer.json requires it accordingly.
# It is depended on by NONE. It does NOT depend on Testing (dev-only).
Cli:
- Kernel
@@ -618,6 +648,7 @@ deptrac:
- Observability
- Resilience
- FeatureFlags
+ - Orchestration
# The bundled installer reads the framework version for its local skeleton repository.
Installer:
diff --git a/docs/README.md b/docs/README.md
index e495c1ac..2931547a 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -28,11 +28,11 @@
## Module Guides
Every module guide lives under [`modules/`](modules/), in the ten groups `mkdocs.yml`'s navigation and the
-site's own [Modules](modules.md) landing page use. All 35 are listed here, and neither the list nor the
+site's own [Modules](modules.md) landing page use. All 36 are listed here, and neither the list nor the
number is maintained on trust: `tests/ModuleDocumentationTest.php` fails the build if a guide is missing
from the navigation, from the table below, from the root `README.md`'s table, from `docs/index.md` or from
the landing page, and `tests/SiteNavigationTest.php` reads that number off `docs/modules/*.md` itself, so a
-thirty-fourth guide turns this page red rather than quietly making it wrong.
+thirty-seventh guide turns this page red rather than quietly making it wrong.
### Foundation
@@ -78,6 +78,7 @@ thirty-fourth guide turns this page red rather than quietly making it wrong.
| [EDA Brokers](modules/eda-brokers.md) | Real broker adapters behind the `EventPublisher` port — RabbitMQ, Postgres LISTEN/NOTIFY, Kafka |
| [EDA Database Outbox](modules/eda-database.md) | `firefly/eda-database` — the portable same-transaction outbox, claimed by leases on every database |
| [Messaging](modules/messaging.md) | `firefly/messaging` — the lower-level raw-bytes `MessageBrokerPort`, `#[MessageListener]` |
+| [Orchestration](modules/orchestration.md) | `firefly/orchestration` — sagas, TCC and durable workflows with checkpoints, recovery and a dead-letter queue |
### CQRS
@@ -125,8 +126,8 @@ thirty-fourth guide turns this page red rather than quietly making it wrong.
| Document | Description |
|----------|-------------|
-| [Modules](modules.md) | The grouped index of all 35 module guides, and how a package wires itself |
-| [CLI Reference](cli.md) | Every `firefly:*` command and `make:firefly-*` generator, including the seven contributed by capability packages |
+| [Modules](modules.md) | The grouped index of all 36 module guides, and how a package wires itself |
+| [CLI Reference](cli.md) | Every `firefly:*` command and `make:firefly-*` generator, including the thirteen contributed by capability packages |
| [Database Support Matrix](database-support.md) | What each database-backed capability does on PostgreSQL, MySQL, MariaDB, SQL Server and SQLite, and which CI lane proves it |
| [Laravel Comparison](laravel-comparison.md) | Side-by-side concept mapping for developers coming from plain Laravel |
| [Versioning](versioning.md) | CalVer (`YY.MM.Patch`), no `version` field, how Packagist derives releases from tags |
diff --git a/docs/architecture.md b/docs/architecture.md
index 62a0132c..ff285d67 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -269,10 +269,10 @@ onto every record beside the correlation and request ids, and `firefly.logging.s
`json`, `ecs` or `logstash`, or empty for plain text — decides how a line is rendered, attached per channel
at resolution time.
-Metrics and introspection are the actuator's. Sixteen `ActuatorEndpoint` implementations ship, discovered
+Metrics and introspection are the actuator's. Seventeen `ActuatorEndpoint` implementations ship, discovered
into the `ActuatorRegistry` — health, info, env, beans, conditions, mappings, loggers, scheduledtasks, caches,
configprops, metrics, prometheus, process, httpexchanges, oauth2clients when the authorization server is
-installed, and flags when feature flags are enabled — and the sensitive ones answer **404** until named in
+installed, flags when feature flags are enabled, and orchestration for sagas, TCCs and workflows — and the sensitive ones answer **404** until named in
`firefly.management.endpoints.web.exposure.include`. `firefly/admin` renders those same beans server-side by
resolving them **in-process from that registry**, never by fetching its own HTTP surface: one data path, and
pages the JSON surface deliberately keeps unexposed. That is also why the dashboard's own URL is its entire
diff --git a/docs/book.md b/docs/book.md
index 3dd211ff..35606797 100644
--- a/docs/book.md
+++ b/docs/book.md
@@ -27,7 +27,7 @@ composer global require fireflyframework/larafly
firefly new my-app
```
-All 32 components are included in that package. An existing application starts with
+All 33 components are included in that package. An existing application starts with
`composer require fireflyframework/larafly`; compatible legacy `firefly/*` requirements are then
satisfied by the framework's `replace` declarations. See [Installation](installation.md) for details.
diff --git a/docs/cli.md b/docs/cli.md
index f9f54fe2..a430f505 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -21,10 +21,10 @@ php artisan firefly:cache
```
The zero-reflection payoff: it runs every settled package's existing scanner → compiler pair over
-`config('firefly.scan.paths')` (a PSR-4 map, e.g. `'App\\' => app_path()`) and writes fourteen compiled
+`config('firefly.scan.paths')` (a PSR-4 map, e.g. `'App\\' => app_path()`) and writes fifteen compiled
artifacts — every manifest below, unconditionally, whether or not the application has anything to put in it —
plus one generated proxy class per planned bean, into `bootstrap/cache/firefly/`. It prints what it wrote:
-`firefly:cache — wrote 14 manifest(s) + N proxy(ies) to
`.
+`firefly:cache — wrote 15 manifest(s) + N proxy(ies) to `.
```
bootstrap/cache/firefly/
@@ -38,6 +38,7 @@ bootstrap/cache/firefly/
├── event-listeners.php # #[EventListener] manifest
├── message-listeners.php # #[MessageListener] manifest
├── scheduled.php # #[Scheduled] manifest
+├── orchestration.php # #[Saga]/#[Tcc]/#[Workflow] definitions and their schedules
├── security-methods.php # #[PreAuthorize]/#[Secured]/#[RolesAllowed] manifest
├── transactional.php # #[Transactional] method manifest
├── proxy-plan.php # the compiled ProxyPlan: which beans get a proxy, and the advice each method runs
@@ -111,7 +112,7 @@ php artisan firefly:cache
```
```
-firefly:cache — wrote 14 manifest(s) + 3 proxy(ies) to bootstrap/cache/firefly
+firefly:cache — wrote 15 manifest(s) + 3 proxy(ies) to bootstrap/cache/firefly
firefly:cache — skipped 1 stale manifest entry naming a class that no longer exists: App\Security\ControlPlaneJwksProvider
```
@@ -332,4 +333,10 @@ command is part of that capability rather than of the console.
| `firefly:eda:dead-letters:replay` | `firefly/eda` | Republishes dead-lettered envelopes (`firefly.eda.dead-letter.store=database`) through the bound `EventPublisher`, oldest first, to their original destination, removing each one that republished. `--id=*`, `--destination=`, `--event-type=`, `--limit=100`, `--dry-run`, `--keep`; exit 1 when any entry failed. Registered only under that `database` store. |
| `firefly:messaging:dead-letters:replay` | `firefly/messaging` | Republishes dead-lettered messages (`firefly.messaging.dead-letter.store=database`) through the bound `MessageBrokerPort` to their original topic. `--id=*`, `--topic=`, `--limit=100`, `--dry-run`, `--keep`; exit 1 when any entry failed. Registered only under that `database` store. |
| `firefly:outbox:relay` | `firefly/eda-database` | **Optional.** Forwards committed `firefly_eda_outbox` rows to the downstream broker named by `firefly.eda.database.relay.downstream-provider` (alias `firefly.eda.postgres.relay.downstream_provider`) (claim → publish → mark `PUBLISHED`/`FAILED`); any number of relay processes may run — each batch is leased. `--max-messages=`, `--time-limit=`, `--sleep=1` (seconds between empty batches), `--batch-size=50`. In-process delivery is `firefly:eda:consume`'s job, not this command's, so an app that only needs `#[EventListener]` handlers never runs it; an unset or misconfigured `downstream-provider` fails loudly before a single row is claimed. See [EDA Database Outbox](modules/eda-database.md). |
+| `firefly:orchestration:list` | `firefly/orchestration` | Lists persisted saga, TCC and workflow executions. `--pattern=saga\|tcc\|workflow`, `--status=` (e.g. `failed`, `waiting`), `--name=` (the saga/TCC name or workflow id), `--limit=50`. See [Orchestration](modules/orchestration.md). |
+| `firefly:orchestration:show` | `firefly/orchestration` | Shows one persisted execution by correlation id: its status, steps and report. |
+| `firefly:orchestration:signal` | `firefly/orchestration` | Delivers a signal to a durable workflow: `{id} {signal}`, with `--payload=` as JSON. |
+| `firefly:orchestration:dlq` | `firefly/orchestration` | Lists orchestration dead letters. `--name=`, `--limit=50`. |
+| `firefly:orchestration:dlq:retry` | `firefly/orchestration` | Retries one orchestration dead letter by id; the execution restarts from scratch. |
+| `firefly:orchestration:tick` | `firefly/orchestration` | Resumes due workflows (signals, timers) and runs the recovery sweep; `--no-recovery` only resumes. The same work runs as a `#[Scheduled]` task under the `DistributedLock` port. |
| `firefly:flags` | `firefly/feature-flags` | Runs the `flags` management operations in-process: `list`, `show {key}`, `evaluate {key}` (`--context=`, `--targeting-key=`), and the writes `enable`, `disable`, `default-variant {key} {variant}`, `put {key} --file=`, `delete {key}` (each with `--expected-version=`). `--json` prints the management JSON, including `{error,message}` on a refusal. Writes need `firefly.feature-flags.management.writes` and a flag store; the actor is the principal, else `cli:`. An accepted write with pending local refresh exits successfully and asks the operator to check visibility again. |
diff --git a/docs/contributing.md b/docs/contributing.md
index cf230ec6..dd1a0f58 100644
--- a/docs/contributing.md
+++ b/docs/contributing.md
@@ -245,7 +245,7 @@ required to land on a document that exists; `navigation.indexes` is required to
section really has an `index.md`/`README.md` for it to bind, because the feature is inert otherwise and the
first explanation of the tab row was wrong in precisely that way; the package and guide counts quoted in
prose — on `README.md`, `docs/README.md`, `docs/index.md` and `docs/modules.md` — are compared against
-`glob()` over `packages/*` and `docs/modules/*.md` rather than proof-read, so a thirty-third guide fails the
+`glob()` over `packages/*` and `docs/modules/*.md` rather than proof-read, so a thirty-seventh guide fails the
build instead of turning four sentences into lies no diff touched; and the two wiring idioms
`docs/modules.md` names are held to the split the packages really have, exemplar files included.
diff --git a/docs/database-support.md b/docs/database-support.md
index c218cca9..a3c79638 100644
--- a/docs/database-support.md
+++ b/docs/database-support.md
@@ -20,6 +20,8 @@ on a temporary file.
| `#[Transactional(timeout:)]` at the server | `SET LOCAL statement_timeout` | `max_execution_time` (reads) + `innodb_lock_wait_timeout` | `max_statement_time` + `innodb_lock_wait_timeout` | `SET LOCK_TIMEOUT` (lock waits) | busy timeout |
| `#[Lock(PESSIMISTIC_WRITE / READ)]` | ✓ `FOR UPDATE` / `FOR SHARE` | ✓ `FOR UPDATE` / `LOCK IN SHARE MODE` | ✓ | ✓ `UPDLOCK` / `HOLDLOCK` hints | — (compiles to a plain read) |
| Optimistic locking (`HasOptimisticLock`) | ✓ | ✓ | ✓ | ✓ | ✓ |
+| Orchestration checkpoints, search attributes, recovery and dead letters (`firefly.orchestration.persistence.provider=database`) | ✓ | ✓ | ✓ | ✓ | ✓ |
+| ASYNC workflows on the `database` queue | ✓ | ✓ | ✓ | ✓ | ✓ |
How the outbox claim is chosen, and why every strategy is safe with any number of workers, is explained in
[EDA Database Outbox](modules/eda-database.md#claim-strategies). Servers older than the versions above fall back to
diff --git a/docs/index.md b/docs/index.md
index 58d81f66..2e9c5b66 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -8,7 +8,7 @@ CQRS, event-driven architecture, first-party security including both halves of O
directly onto Laravel's own runtime. Nothing is forked or wrapped: a LaraFly app is, in every respect a
Laravel developer would recognize, still a Laravel app — it just boots like a Spring Boot one.
-The whole framework ships as one Composer library, `fireflyframework/larafly`, containing all 32 components,
+The whole framework ships as one Composer library, `fireflyframework/larafly`, containing all 33 components,
wired together by a single zero-reflection boot pipeline: a component scan compiles to a cached manifest once, and every
request after that runs against plain PHP arrays — no runtime reflection on the hot path.
@@ -103,7 +103,7 @@ See [Installation](installation.md) for requirements and manual setup, and
## The modules
-Every capability above ships in the root library; configuration selects optional features. The [module index](modules.md) lays all 35
+Every capability above ships in the root library; configuration selects optional features. The [module index](modules.md) lays all 36
guides out by concern, with a line on each saying what it is for, and the same grouping is the site's
**Modules** tab.
@@ -113,7 +113,7 @@ guides out by concern, with a line on each saying what it is for, and the same g
| **Web & API** | [Web Layer](modules/web.md) · [Web Filters](modules/web-filters.md) · [OpenAPI](modules/openapi.md) |
| **Resilience & Scheduling** | [Resilience](modules/resilience.md) · [Scheduling](modules/scheduling.md) · [Scheduling Database Locks](modules/scheduling-database.md) |
| **Data & Domain** | [Domain (DDD)](modules/domain.md) · [Data & Repositories](modules/data.md) · [Relational Data](modules/data-relational.md) · [Transactions](modules/transactional.md) |
-| **Eventing & Messaging** | [EDA](modules/eda.md) · [EDA Brokers](modules/eda-brokers.md) · [EDA Database Outbox](modules/eda-database.md) · [Messaging](modules/messaging.md) |
+| **Eventing & Messaging** | [EDA](modules/eda.md) · [EDA Brokers](modules/eda-brokers.md) · [EDA Database Outbox](modules/eda-database.md) · [Messaging](modules/messaging.md) · [Orchestration](modules/orchestration.md) |
| **CQRS** | [Command/Query](modules/cqrs.md) |
| **Security** | [Security](modules/security.md) · [OAuth2 Client](modules/security-oauth2-client.md) · [OAuth2 Authorization Server](modules/security-oauth2-server.md) |
| **Operations** | [Actuator](modules/actuator.md) · [Observability](modules/observability.md) · [Tracing](modules/tracing.md) · [Logging](modules/logging.md) · [Admin Dashboard](modules/admin.md) · [Bean Graph](modules/bean-graph.md) · [Data Browser](modules/data-browser.md) · [Feature Flags](modules/feature-flags.md) |
diff --git a/docs/modules.md b/docs/modules.md
index 1ab6c164..a1ccc7d4 100644
--- a/docs/modules.md
+++ b/docs/modules.md
@@ -1,12 +1,12 @@
# Modules
-LaraFly publishes one `fireflyframework/larafly` library containing the **32 component directories** under
-`packages/*`, each with its own tests or metadata. The **35 guides** below explain their public surfaces.
+LaraFly publishes one `fireflyframework/larafly` library containing the **33 component directories** under
+`packages/*`, each with its own tests or metadata. The **36 guides** below explain their public surfaces.
Several components carry more than one guide: `firefly/data` answers for *Data & Repositories*,
*Relational Data* and *Transactions*. Laravel discovers the root library's providers, and configuration
selects optional features. A bean you declare wins over the framework default.
Two idioms deliver that outcome, and
-which one you are looking at matters the moment you go reading the source. **20 of the 32 packages** carry a
+which one you are looking at matters the moment you go reading the source. **21 of the 33 packages** carry a
`#[Configuration]` class whose `#[Bean]` methods sit behind `#[ConditionalOnMissingBean]`, so a bean of yours
makes the framework's back off silently — `packages/data/src/DataAutoConfiguration.php` is the model. The
**other 12** have no such class to go looking for; `firefly/web`, the one nearly every reader installs, guards
@@ -43,7 +43,7 @@ each binding in `WebServiceProvider::register()` with a `bound()` check instead,
Eventing & Messaging
-[EDA](modules/eda.md) · [EDA Brokers](modules/eda-brokers.md) · [EDA Database Outbox](modules/eda-database.md) · [Messaging](modules/messaging.md)
+[EDA](modules/eda.md) · [EDA Brokers](modules/eda-brokers.md) · [EDA Database Outbox](modules/eda-database.md) · [Messaging](modules/messaging.md) · [Orchestration](modules/orchestration.md)
One `EventPublisher` port with in-memory, queue, RabbitMQ, database (any SQL server), Postgres and Kafka adapters, a genuine same-transaction outbox safe with concurrent consumers on every database, and the lower-level raw-bytes broker layer beneath it.
diff --git a/docs/modules/observability.md b/docs/modules/observability.md
index 8bb38636..caa99fc3 100644
--- a/docs/modules/observability.md
+++ b/docs/modules/observability.md
@@ -242,6 +242,14 @@ survivors before the next config is evaluated, so `cqrsMetrics()` registers **fi
`#[ConditionalOnMissingBean(CqrsMetrics::class)]` then sees a bean already bound and backs off. No code in
`firefly/cqrs` changes; the win is pure auto-configuration ordering.
+`MeterRegistryOrchestrationMetrics` is the same drop-in for `firefly/orchestration`'s `OrchestrationMetrics` port,
+behind the same `#[Order(500)]` precedence over `OrchestrationAutoConfiguration`'s `#[Order(1000)]` no-op:
+
+ orchestration_executions_seconds{name, pattern, outcome}
+ orchestration_steps_seconds{name, step, outcome}
+ orchestration_compensations_total{name, step, outcome}
+ orchestration_dead_letters_total{name}
+
## Gating — property, not bean presence
Every observability bean/endpoint/filter gates on the **same** config property,
diff --git a/docs/modules/orchestration.md b/docs/modules/orchestration.md
new file mode 100644
index 00000000..75d9c20e
--- /dev/null
+++ b/docs/modules/orchestration.md
@@ -0,0 +1,404 @@
+# Orchestration
+
+`firefly/orchestration` coordinates work that spans more than one transaction. It ships three patterns, shaped
+after the Java `fireflyframework-orchestration` engine:
+
+- **Sagas** run forward steps in dependency order and, when one fails, undo the completed ones with their
+ compensations under one of five compensation policies.
+- **TCC** (try/confirm/cancel) reserves with every participant first, then confirms all of them or cancels
+ the ones that tried.
+- **Durable workflows** run a step graph that can wait for signals and timers, start child workflows, answer
+ queries, suspend and resume, and survive a process that dies between two steps.
+
+Every step boundary writes an `ExecutionState` checkpoint through the `ExecutionPersistenceProvider` port, so
+a recovery sweep can resume or compensate whatever a crashed process left behind. Failures land in a
+dead-letter queue. Lifecycle events publish through `firefly/eda`, and an EDA event can start a definition.
+
+## Declaring a saga
+
+A saga is a bean with `#[Saga]` and one `#[SagaStep]` per forward step. `compensate` names the method that
+undoes the step; `dependsOn` orders the steps into topology layers. Parameters are injected by attribute:
+`#[Input]` reads the execution input, `#[FromStep]` the result of an earlier step, and an `ExecutionContext`
+parameter receives the live context.
+
+
+```php
+use Firefly\Container\Attributes\Service;
+use Firefly\Orchestration\Core\Argument\FromStep;
+use Firefly\Orchestration\Core\Argument\Input;
+use Firefly\Orchestration\Saga\Attributes\OnSagaError;
+use Firefly\Orchestration\Saga\Attributes\Saga;
+use Firefly\Orchestration\Saga\Attributes\SagaStep;
+use Firefly\Orchestration\Saga\Attributes\StepEvent;
+
+#[Service]
+#[Saga(name: 'checkout', triggerEventType: 'orders.placed')]
+final class CheckoutSaga
+{
+ #[SagaStep(id: 'reserve', compensate: 'release')]
+ public function reserve(#[Input('orderId')] int $orderId): string
+ {
+ return "reservation-{$orderId}";
+ }
+
+ public function release(string $reservation): void {}
+
+ #[SagaStep(id: 'charge', dependsOn: ['reserve'], compensate: 'refund', retry: 2, backoffMs: 200, timeoutMs: 5000)]
+ #[StepEvent(topic: 'payments', type: 'payment.captured', key: 'orderId')]
+ public function charge(#[Input('amount')] int $amount, #[FromStep('reserve')] string $reservation): string
+ {
+ return "receipt-for-{$reservation}";
+ }
+
+ public function refund(string $receipt): void {}
+
+ #[OnSagaError]
+ public function failed(\Throwable $error): void {}
+}
+```
+
+A compensation receives the forward step's result. Start the saga through the `Orchestrator` (or `SagaEngine`
+directly):
+
+
+```php
+use Firefly\Orchestration\Core\ExecutionPattern;
+use Firefly\Orchestration\Core\Orchestrator;
+
+final class PlaceOrder
+{
+ public function __construct(private readonly Orchestrator $orchestrator) {}
+
+ /** @return array */
+ public function __invoke(int $orderId, int $amount): array
+ {
+ $outcome = $this->orchestrator->start(ExecutionPattern::Saga, 'checkout', ['orderId' => $orderId, 'amount' => $amount]);
+
+ return ['id' => $outcome->correlationId, 'status' => $outcome->status->value];
+ }
+}
+```
+
+Steps can also be declared without a class through `SagaBuilder::saga('checkout')->step('reserve')->handler(…)
+->compensation(…)->add()->build()`, and registered on the `SagaRegistry`.
+
+### Compensation policies
+
+`firefly.orchestration.saga.compensation-policy` decides how completed steps are undone:
+
+| Policy | Behavior |
+|---|---|
+| `strict-sequential` (default) | Undo in reverse completion order, one at a time. |
+| `grouped-parallel` | Undo layer by layer, in reverse layer order. |
+| `retry-with-backoff` | Like `strict-sequential`, retrying each compensation with its `compensationRetry` and `compensationBackoffMs`. |
+| `circuit-breaker` | Stop compensating once a `compensationCritical` step fails or the breaker opens; the saga ends `COMPENSATION_FAILED`, is dead-lettered, and lists the steps it left in `SagaResult::$uncompensatedSteps`. |
+| `best-effort` | Run every compensation and record each failure without stopping. |
+
+`firefly.orchestration.saga.compensation-error-handler` decides what one failed compensation does to the run:
+`default`, `fail-fast`, `log-and-continue`, `retry`, `robust`, or the class name of your own
+`CompensationErrorHandler` bean.
+
+## Declaring a TCC
+
+A TCC names its participants by `#[TccParticipant]` on separate beans. Each participant has a `#[TryMethod]`, a
+`#[ConfirmMethod]` and a `#[CancelMethod]`; confirm and cancel receive the try result through `#[FromTry]`.
+Participants try in `order`. If any try fails, every participant that tried is cancelled.
+
+
+```php
+use Firefly\Container\Attributes\Service;
+use Firefly\Orchestration\Core\Argument\Input;
+use Firefly\Orchestration\Tcc\Attributes\CancelMethod;
+use Firefly\Orchestration\Tcc\Attributes\ConfirmMethod;
+use Firefly\Orchestration\Tcc\Attributes\FromTry;
+use Firefly\Orchestration\Tcc\Attributes\Tcc;
+use Firefly\Orchestration\Tcc\Attributes\TccParticipant;
+use Firefly\Orchestration\Tcc\Attributes\TryMethod;
+
+#[Tcc(name: 'transfer', timeoutMs: 10000)]
+final class TransferTcc {}
+
+#[Service]
+#[TccParticipant(tcc: 'transfer', id: 'debit', order: 1)]
+final class DebitAccount
+{
+ #[TryMethod]
+ public function hold(#[Input('amount')] int $amount): string
+ {
+ return "hold-{$amount}";
+ }
+
+ #[ConfirmMethod]
+ public function commit(#[FromTry] string $hold): void {}
+
+ #[CancelMethod]
+ public function release(#[FromTry] string $hold): void {}
+}
+```
+
+A confirmed TCC ends `CONFIRMED`, a cancelled one `CANCELED`. With `recovery.action` set to `resume`, a
+process that died in `CONFIRMING` is driven on to `CONFIRMED`, confirming only the participants that had not
+confirmed yet; `compensate` cancels every participant instead.
+
+## Durable workflows
+
+A workflow is a step graph whose steps may wait. `#[WaitForSignal]` parks the execution `WAITING` until
+`WorkflowEngine::signal()` delivers the signal, whose payload the step reads as the variable
+`signals.`. `#[WaitForTimer]` parks it until the timer is due; the scheduled tick
+(`firefly.orchestration.workflow.tick.*`) or `php artisan firefly:orchestration:tick` advances due workflows.
+A due workflow the tick cannot drive (its definition is not deployed on that node, a handler throws outside a
+step) is backed off, starting at one minute and doubling, so it never holds back the healthy rows behind it.
+After `firefly.orchestration.workflow.max-resume-attempts` failed ticks in a row (default `5`, about fifteen
+minutes) it is parked `FAILED` without compensation and dead-lettered. A database fault is not counted: it
+escapes the tick, so the scheduler reports the outage.
+`#[WaitForAll]` and `#[WaitForAny]` combine signals and timers, `#[ChildWorkflow]` starts another workflow and
+waits for it, `condition` skips a step when an ExpressionLanguage expression over `input`, `results`,
+`variables` and `headers` is false, and `#[WorkflowQuery]` answers a read-only question about a live execution.
+
+
+```php
+use Firefly\Container\Attributes\Service;
+use Firefly\Orchestration\Core\Argument\Input;
+use Firefly\Orchestration\Core\Argument\Variable;
+use Firefly\Orchestration\Core\ExecutionContext;
+use Firefly\Orchestration\Workflow\Attributes\WaitForSignal;
+use Firefly\Orchestration\Workflow\Attributes\WaitForTimer;
+use Firefly\Orchestration\Workflow\Attributes\Workflow;
+use Firefly\Orchestration\Workflow\Attributes\WorkflowQuery;
+use Firefly\Orchestration\Workflow\Attributes\WorkflowStep;
+
+#[Service]
+#[Workflow(id: 'onboarding')]
+final class OnboardingWorkflow
+{
+ #[WorkflowStep(id: 'register')]
+ public function register(#[Input('email')] string $email, ExecutionContext $ctx): string
+ {
+ $ctx->setSearchAttribute('email', $email);
+
+ return "user-{$email}";
+ }
+
+ #[WorkflowStep(id: 'verify', dependsOn: ['register'])]
+ #[WaitForSignal('email-verified', timeoutMs: 86400000)]
+ public function verify(#[Variable('signals.email-verified')] mixed $proof): string
+ {
+ return 'verified';
+ }
+
+ #[WorkflowStep(id: 'welcome', dependsOn: ['verify'])]
+ #[WaitForTimer(delayMs: 3600000, timerId: 'welcome')]
+ public function welcome(): string
+ {
+ return 'welcomed';
+ }
+
+ #[WorkflowQuery('progress')]
+ public function progress(ExecutionContext $ctx): int
+ {
+ return count($ctx->results());
+ }
+}
+```
+
+
+```php
+use Firefly\Orchestration\Workflow\Engine\WorkflowEngine;
+
+final class Onboarding
+{
+ public function __construct(private readonly WorkflowEngine $workflows) {}
+
+ public function begin(string $email): string
+ {
+ return $this->workflows->start('onboarding', ['email' => $email])->correlationId;
+ }
+
+ public function verified(string $correlationId, string $token): void
+ {
+ $this->workflows->signal($correlationId, 'email-verified', ['token' => $token]);
+ }
+
+ public function progress(string $correlationId): mixed
+ {
+ return $this->workflows->query($correlationId, 'progress');
+ }
+}
+```
+
+`suspend()`, `resume()`, `cancel()` and `compensate()` manage a live execution;
+`upsertSearchAttributes()` and `search()` find executions by attribute. A step that returns `ContinueAsNew`
+ends the run and starts a fresh one with new input, linked through the `x-firefly-continued-from` header.
+
+### Compensating a workflow
+
+When a workflow step fails (after its retries), the engine undoes every completed step that has a
+compensation, newest first, dead-letters the failure and ends the execution `FAILED`. A step declares its
+compensation inline, with `compensatable: true, compensationMethod: 'name'` on its `#[WorkflowStep]`, or as a
+separate method marked `#[CompensationStep(compensates: 'step-id')]`, which makes the step compensatable on
+its own. Either way the method receives the step's result as its implicit argument (and may take the usual
+`#[Input]`, `#[Variable]` or `ExecutionContext` parameters); what it returns is recorded under
+`compensationResults`. A step has one compensation: two `#[CompensationStep]` methods for one step, both forms
+on one step, or a `#[CompensationStep]` naming a step the workflow does not declare are refused with a
+`TopologyValidationException` when the definitions are scanned.
+
+
+```php
+use Firefly\Container\Attributes\Service;
+use Firefly\Orchestration\Core\Argument\Input;
+use Firefly\Orchestration\Workflow\Attributes\CompensationStep;
+use Firefly\Orchestration\Workflow\Attributes\Workflow;
+use Firefly\Orchestration\Workflow\Attributes\WorkflowStep;
+
+#[Service]
+#[Workflow(id: 'shipment')]
+final class ShipmentWorkflow
+{
+ #[WorkflowStep(id: 'book')]
+ public function book(#[Input('parcel')] string $parcel): string
+ {
+ return "label-{$parcel}";
+ }
+
+ #[CompensationStep(compensates: 'book')]
+ public function voidLabel(string $label): string
+ {
+ return "voided {$label}";
+ }
+
+ #[WorkflowStep(id: 'dispatch', dependsOn: ['book'])]
+ public function dispatch(#[Input('carrier')] string $carrier): string
+ {
+ return "dispatched via {$carrier}";
+ }
+}
+```
+
+`WorkflowEngine::compensate()` runs the same compensations on request for a live execution and ends it
+`CANCELLED`.
+
+### Queued (ASYNC) workflows
+
+A workflow declared with `triggerMode: TriggerMode::Async` never runs inside `start()`. The start persists
+the execution `PENDING` and dispatches a `RunWorkflowJob` that carries only the correlation id. A queue
+worker then loads the execution from the persistence provider and runs it:
+
+
+```php
+#[Service]
+#[Workflow(id: 'statement', triggerMode: TriggerMode::Async)]
+final class StatementWorkflow
+{
+ public function __construct(private readonly Ledger $ledger) {}
+
+ #[WorkflowStep(id: 'render')]
+ public function render(#[Input('account')] string $account): string
+ {
+ $this->ledger->record("render {$account}");
+
+ return "statement-{$account}";
+ }
+ // …
+}
+```
+
+The job is dispatched on `firefly.orchestration.workflow.queue.connection` and `.name`. When either is unset,
+the application's default connection or queue is used:
+
+
+```php
+'queue' => [
+ 'connection' => env('FIREFLY_ORCHESTRATION_WORKFLOW_QUEUE_CONNECTION'),
+ 'name' => env('FIREFLY_ORCHESTRATION_WORKFLOW_QUEUE'),
+],
+```
+
+A worker on that connection and queue runs the job:
+
+```bash
+php artisan queue:work database --queue=workflows
+```
+
+The worker is a different process, so ASYNC workflows need `cache` or `database` persistence. The `sync`
+connection is the one exception: it runs the job inside `start()`, and the start returns the finished
+execution. Redelivery is safe. A job delivered a second time, for example by a worker that died before it
+acknowledged the job, finds the execution no longer `PENDING` and returns it unchanged, so no step runs twice.
+The capstone in `packages/orchestration/tests/Capstone/CapstoneQueuedWorkflowTest.php` covers this path on
+the `database` queue. Its integration twin covers it on PostgreSQL, MySQL, MariaDB, SQL Server and a SQLite
+file, one CI integration lane each.
+
+## Persistence, recovery and the dead-letter queue
+
+`firefly.orchestration.persistence.provider` selects the store:
+
+| Provider | Use |
+|---|---|
+| `memory` (default) | Tests and single-request flows. Nothing survives the process. |
+| `cache` | Any Laravel cache store (`persistence.cache-store`), keys under `persistence.key-prefix`. |
+| `database` | The `firefly_orchestration_*` tables on `persistence.connection`. Proven on PostgreSQL, MySQL, MariaDB, SQL Server and SQLite by the CI integration lanes (see the [database support matrix](../database-support.md)). |
+
+Publish the migration with `php artisan vendor:publish --tag=firefly-orchestration-migrations`; the package also
+loads it while `persistence.provider` or `dlq.store` is `database`.
+
+Writers serialize per execution through the `firefly/scheduling` `DistributedLock` (`firefly.orchestration.lock.*`).
+With `firefly.orchestration.recovery.enabled`, a scheduled sweep finds executions not updated for
+`recovery.stale-threshold` and, by `recovery.action`, reports them (`report`), runs them on (`resume`) or undoes
+them (`compensate`). The same sweep deletes terminal executions older than `persistence.retention-period`.
+
+A failed execution becomes a dead letter (`firefly.orchestration.dlq.*`). Retrying one starts its definition
+again with the original input and counts the retry on the entry.
+
+## Events, triggers and schedules
+
+With `firefly.orchestration.events.enabled`, every execution publishes `EXECUTION_STARTED`,
+`EXECUTION_COMPLETED`, `STEP_COMPLETED` and `STEP_FAILED` through the `EventPublisher` to
+`events.default-topic` (or `orchestration`); a workflow publishes its `STEP_COMPLETED` events only with
+`publishEvents: true`. A `#[StepEvent]`, a `#[TccEvent]` or a workflow step's `outputEventType` publishes that
+step's completion (for a TCC participant, its confirm) to the topic and type it names, whether or not
+`events.enabled` is on. A definition's `triggerEventType` registers it on the event gateway, so an EDA event of that type starts it
+with the event payload as input. `#[ScheduledSaga]`, `#[ScheduledTcc]` and `#[ScheduledWorkflow]` start a
+definition on a cron, fixed-rate or fixed-delay cadence through `firefly/scheduling`, guarded by its distributed lock.
+Lifecycle hooks are beans implementing `OrchestrationEvents` (extend `OrchestrationEventsAdapter` to override a
+few of its 26 methods).
+
+## Operating
+
+- `GET /actuator/orchestration` (expose `orchestration` in `firefly.management.endpoints.web.exposure.include`)
+ lists definitions with their topology layers, execution counts, dead letters and triggers;
+ `…/executions`, `…/executions/{id}` and `…/dlq` drill down.
+- The `orchestration` health component reports the store, open and stale executions and dead letters
+ (`firefly.orchestration.health.enabled`).
+- `php artisan firefly:orchestration:list`, `:show`, `:signal`, `:dlq`, `:dlq:retry` and `:tick`.
+- With `firefly.orchestration.rest.enabled`, a REST surface under `rest.path` starts sagas, TCCs and
+ workflows, signals, suspends, resumes and cancels workflows, runs queries and retries or deletes dead
+ letters. It is an operator surface: protect it with your own middleware or network policy.
+- With `firefly/observability`, executions and steps are timed (`orchestration_executions_seconds`,
+ `orchestration_steps_seconds`), compensations and dead letters counted
+ (`orchestration_compensations_total`, `orchestration_dead_letters_total`), and traced when
+ `firefly.observability.tracing.orchestration.enabled` is on.
+
+## Configuration
+
+Every key lives under `firefly.orchestration` in `config/firefly.php`; the skeleton documents each with its
+default.
+
+| Keys | Purpose |
+|---|---|
+| `saga.enabled`, `saga.compensation-policy`, `saga.default-timeout`, `saga.compensation-error-handler` | Saga engine |
+| `tcc.enabled`, `tcc.default-timeout`, `tcc.composition.enabled`, `tcc.composition.compensation-policy` | TCC engine |
+| `workflow.enabled`, `workflow.queue.connection`, `workflow.queue.name`, `workflow.tick.enabled`, `workflow.tick.interval`, `workflow.max-resume-attempts` | Workflow engine and its tick |
+| `persistence.provider`, `persistence.connection`, `persistence.cache-store`, `persistence.key-prefix`, `persistence.key-ttl`, `persistence.retention-period`, `persistence.cleanup-interval` | Execution store |
+| `recovery.enabled`, `recovery.stale-threshold`, `recovery.action` | Recovery sweep |
+| `dlq.enabled`, `dlq.store` | Dead-letter queue |
+| `events.enabled`, `events.default-topic`, `event-gateway.enabled` | EDA events and triggers |
+| `rest.enabled`, `rest.path` | REST surface |
+| `health.enabled`, `metrics.enabled`, `tracing.enabled` | Health component, meters, spans |
+| `validation.enabled`, `validation.fail-on-warning` | Boot-time topology validation |
+| `lock.ttl`, `lock.wait` | Per-execution lock |
+| `backpressure.strategy`, `backpressure.batch-size`, `backpressure.max-in-flight`, `backpressure.circuit-breaker.*` | Admission control |
+
+## Limits
+
+Steps of one topology layer run one after another. `layerConcurrency`, a step's `cpuBound` and a workflow
+step's `async` are accepted so definitions port from the Java engine unchanged, and have no effect; the
+actuator endpoint says so in its `notes`.
diff --git a/docs/modules/tracing.md b/docs/modules/tracing.md
index bdf34e76..bda7ff38 100644
--- a/docs/modules/tracing.md
+++ b/docs/modules/tracing.md
@@ -73,6 +73,7 @@ case-insensitively, list or string values) and `inject(SpanContext): array` / `CONSUMER` `process `; `messaging.system=firefly-eda`, `messaging.destination.name`, `messaging.operation.type`, `messaging.message.id`, `firefly.eda.event_type` | `traceparent`/`tracestate` in the envelope headers, beside `x-correlation-id`; `firefly.eda.tracing.brokers.enabled=false` keeps the in-process spans while the three broker publishers stamp nothing on the transport (see [EDA](eda.md#config-keys)) |
+| Orchestration | `OrchestrationTracing` seam in `firefly/orchestration` (`NoOpOrchestrationTracing` default), filled by `TracerOrchestrationTracing` | `INTERNAL` ` ` per execution and `.` per step; `firefly.orchestration.pattern`, `firefly.orchestration.name`, `firefly.orchestration.step`, `firefly.orchestration.correlation_id` | nothing to carry — in-process; also needs `firefly.orchestration.tracing.enabled` |
**The id a person quotes is the trace id.** `firefly/web` reads the ids `TracingFilter` publishes above
(`Firefly\Web\Trace\TraceContext`, which owns the two attribute/`Context` keys the filter's own constants
@@ -144,6 +145,7 @@ and the NoOp stays: every instrumentation site costs a few method calls and publ
| `http-client.enabled` | `true` | CLIENT spans + `traceparent` on the `Http` client. |
| `cqrs.enabled` | `true` | INTERNAL spans on the command/query buses. |
| `eda.enabled` | `true` | PRODUCER/CONSUMER spans and `traceparent` in envelopes. |
+| `orchestration.enabled` | `true` | INTERNAL spans around saga, TCC and workflow executions and their steps. |
## Testing
diff --git a/docs/tutorial.es.md b/docs/tutorial.es.md
index a9e7619a..7bce932f 100644
--- a/docs/tutorial.es.md
+++ b/docs/tutorial.es.md
@@ -1093,17 +1093,18 @@ php artisan firefly:cache
```
```
-firefly:cache — wrote 14 manifest(s) + 1 proxy(ies) to /path/to/my-app/bootstrap/cache/firefly
+firefly:cache — wrote 15 manifest(s) + 1 proxy(ies) to /path/to/my-app/bootstrap/cache/firefly
```
-El recuento de manifiestos es fijo, catorce, porque `ManifestCacheWriter` escribe cada artefacto sin
+El recuento de manifiestos es fijo, quince, porque `ManifestCacheWriter` escribe cada artefacto sin
condiciones — una aplicación sin ningún método `#[Scheduled]` obtiene igualmente un `scheduled.php` vacío,
que es lo que permite a la ruta de arranque leer "el fichero no está" como "nadie ha compilado todavía" y no
como "no hay nada que cargar". Este único comando ejecuta *cada* par escáner → compilador ya asentado de
cada paquete sobre `config('firefly.scan.paths')` y escribe, en `bootstrap/cache/firefly/`: los manifiestos
de DI/context/config-properties, la tabla de rutas compilada, el manifiesto de handlers
`#[ControllerAdvice]`, el manifiesto de restricciones de validación, el manifiesto de handlers de CQRS, los
-manifiestos de listeners de evento/mensaje, el manifiesto de tareas programadas, el manifiesto de métodos de
+manifiestos de listeners de evento/mensaje, el manifiesto de tareas programadas, el manifiesto de orquestación
+(`#[Saga]`/`#[Tcc]`/`#[Workflow]`), el manifiesto de métodos de
seguridad, el manifiesto de `#[Transactional]`, el **plan de proxies** (`proxy-plan.php` — qué beans reciben
un proxy y qué advice ejecuta cada uno de sus métodos) y el classmap de proxies, **y** un fichero de clase
`RenameGreetingHandler__FireflyTransactionalProxy` generado (el "1 proxy" de arriba). Se emite un proxy por
diff --git a/docs/tutorial.md b/docs/tutorial.md
index 681f77f2..e7184f57 100644
--- a/docs/tutorial.md
+++ b/docs/tutorial.md
@@ -1074,17 +1074,17 @@ php artisan firefly:cache
```
```
-firefly:cache — wrote 14 manifest(s) + 1 proxy(ies) to /path/to/my-app/bootstrap/cache/firefly
+firefly:cache — wrote 15 manifest(s) + 1 proxy(ies) to /path/to/my-app/bootstrap/cache/firefly
```
-The manifest count is fixed at fourteen, because `ManifestCacheWriter` writes every artifact unconditionally
+The manifest count is fixed at fifteen, because `ManifestCacheWriter` writes every artifact unconditionally
— an application with no `#[Scheduled]` method still gets an empty `scheduled.php`, which is what lets the
boot path treat "the file is missing" as "nobody has compiled yet" rather than as "there is nothing to
load". This single command drives *every* settled package's existing scanner → compiler pair over
`config('firefly.scan.paths')` and writes, into `bootstrap/cache/firefly/`: the DI/context/config-properties
manifests, the compiled route table, the `#[ControllerAdvice]` handler manifest, the validation constraint
manifest, the CQRS handler manifest, the event/message-listener manifests, the scheduled-task manifest, the
-security-method manifest, the `#[Transactional]` manifest, the **proxy plan** (`proxy-plan.php` — which
+orchestration manifest (`#[Saga]`/`#[Tcc]`/`#[Workflow]`), the security-method manifest, the `#[Transactional]` manifest, the **proxy plan** (`proxy-plan.php` — which
beans get a proxy and which advice each of their methods runs) and the proxy classmap, **and** a generated
`RenameGreetingHandler__FireflyTransactionalProxy` class file (the "1 proxy" above). A proxy is emitted per
class the *plan* claims, which is every class any advice source names — so a `#[Service]` carrying only
diff --git a/mkdocs.yml b/mkdocs.yml
index f1d4de6e..2e07d190 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -143,6 +143,7 @@ nav:
- EDA Brokers: modules/eda-brokers.md
- EDA Database Outbox: modules/eda-database.md
- Messaging: modules/messaging.md
+ - Orchestration: modules/orchestration.md
- CQRS:
- CQRS (Command/Query): modules/cqrs.md
- Security:
diff --git a/packages/cli/composer.json b/packages/cli/composer.json
index 82f1e05b..3455988b 100644
--- a/packages/cli/composer.json
+++ b/packages/cli/composer.json
@@ -33,6 +33,7 @@
"firefly/kernel": "*@dev",
"firefly/messaging": "*@dev",
"firefly/observability": "*@dev",
+ "firefly/orchestration": "*@dev",
"firefly/resilience": "*@dev",
"firefly/scheduling": "*@dev",
"firefly/security": "*@dev",
diff --git a/packages/cli/src/Boot/FireflyCacheServiceProvider.php b/packages/cli/src/Boot/FireflyCacheServiceProvider.php
index bae0b395..28f84d83 100644
--- a/packages/cli/src/Boot/FireflyCacheServiceProvider.php
+++ b/packages/cli/src/Boot/FireflyCacheServiceProvider.php
@@ -11,6 +11,7 @@
use Firefly\Cqrs\Handler\HandlerManifest;
use Firefly\Eda\Listener\EventListenerManifest;
use Firefly\Messaging\Listener\MessageListenerManifest;
+use Firefly\Orchestration\Manifest\OrchestrationManifest;
use Firefly\Scheduling\Schedule\ScheduledManifest;
use Firefly\Security\Access\Method\SecurityMethodManifest;
use Firefly\Validation\Constraint\ConstraintManifest;
@@ -55,6 +56,9 @@ public function register(): void
if (is_file($path = $dir.'/'.FireflyCachePaths::SCHEDULED)) {
$this->app->instance(ScheduledManifest::class, ScheduledManifest::load($path));
}
+ if (is_file($path = $dir.'/'.FireflyCachePaths::ORCHESTRATION)) {
+ $this->app->instance(OrchestrationManifest::class, OrchestrationManifest::load($path));
+ }
if (is_file($path = $dir.'/'.FireflyCachePaths::SECURITY_METHODS)) {
$this->app->instance(SecurityMethodManifest::class, SecurityMethodManifest::load($path));
}
diff --git a/packages/cli/src/Cache/FireflyCachePaths.php b/packages/cli/src/Cache/FireflyCachePaths.php
index efae4b3d..afee3ff5 100644
--- a/packages/cli/src/Cache/FireflyCachePaths.php
+++ b/packages/cli/src/Cache/FireflyCachePaths.php
@@ -29,6 +29,8 @@ final class FireflyCachePaths
public const string SCHEDULED = 'scheduled.php';
+ public const string ORCHESTRATION = 'orchestration.php';
+
public const string SECURITY_METHODS = 'security-methods.php';
public const string TRANSACTIONAL = 'transactional.php';
diff --git a/packages/cli/src/Cache/ManifestCacheWriter.php b/packages/cli/src/Cache/ManifestCacheWriter.php
index 99d5c6fd..5c1b67d2 100644
--- a/packages/cli/src/Cache/ManifestCacheWriter.php
+++ b/packages/cli/src/Cache/ManifestCacheWriter.php
@@ -21,6 +21,8 @@
use Firefly\Messaging\Listener\MessageListenerManifestCompiler;
use Firefly\Messaging\Scanner\MessageListenerScanner;
use Firefly\Observability\Method\ObservabilityAdviceSource;
+use Firefly\Orchestration\Manifest\OrchestrationManifestCompiler;
+use Firefly\Orchestration\Scanner\OrchestrationScanner;
use Firefly\Resilience\Method\ResilienceAdviceSource;
use Firefly\Scheduling\Scanner\ScheduledScanner;
use Firefly\Scheduling\Schedule\ScheduledManifestCompiler;
@@ -187,6 +189,12 @@ public function writeManifests(array $psr4, string $dir): CacheReport
$files[] = $dir.'/'.FireflyCachePaths::SCHEDULED,
);
+ // orchestration — #[Saga] / #[Tcc] / #[Workflow] definitions and their schedules.
+ (new OrchestrationManifestCompiler)->write(
+ (new OrchestrationScanner)->scan($psr4),
+ $files[] = $dir.'/'.FireflyCachePaths::ORCHESTRATION,
+ );
+
// security
(new SecurityMethodManifestCompiler)->write(
(new MethodSecurityScanner)->scan($psr4),
diff --git a/packages/cli/tests/Cache/ManifestCacheWriterTest.php b/packages/cli/tests/Cache/ManifestCacheWriterTest.php
index bcd8a598..2eb28424 100644
--- a/packages/cli/tests/Cache/ManifestCacheWriterTest.php
+++ b/packages/cli/tests/Cache/ManifestCacheWriterTest.php
@@ -22,7 +22,7 @@ function fixturesPsr4(): array
foreach ([
FireflyCachePaths::COMPONENT, FireflyCachePaths::CONTEXT, FireflyCachePaths::CONFIG_PROPERTIES,
FireflyCachePaths::ROUTES, FireflyCachePaths::CONSTRAINTS, FireflyCachePaths::HANDLERS,
- FireflyCachePaths::EVENT_LISTENERS, FireflyCachePaths::MESSAGE_LISTENERS, FireflyCachePaths::SCHEDULED,
+ FireflyCachePaths::EVENT_LISTENERS, FireflyCachePaths::MESSAGE_LISTENERS, FireflyCachePaths::SCHEDULED, FireflyCachePaths::ORCHESTRATION,
FireflyCachePaths::SECURITY_METHODS, FireflyCachePaths::TRANSACTIONAL, FireflyCachePaths::PROXY_PLAN,
] as $basename) {
$path = $dir.'/'.$basename;
diff --git a/packages/context/src/Scan/AppScan.php b/packages/context/src/Scan/AppScan.php
index 747a774d..d433b38a 100644
--- a/packages/context/src/Scan/AppScan.php
+++ b/packages/context/src/Scan/AppScan.php
@@ -62,6 +62,8 @@ final class AppScan
public const string SCHEDULED = 'scheduled.php';
+ public const string ORCHESTRATION = 'orchestration.php';
+
public const string SECURITY_METHODS = 'security-methods.php';
public const string TRANSACTIONAL = 'transactional.php';
diff --git a/packages/context/tests/Scan/AppScanTest.php b/packages/context/tests/Scan/AppScanTest.php
index 8b6c7197..10ff4c87 100644
--- a/packages/context/tests/Scan/AppScanTest.php
+++ b/packages/context/tests/Scan/AppScanTest.php
@@ -74,7 +74,7 @@ function appScanContainer(array $firefly = []): Container
foreach ([
AppScan::COMPONENT, AppScan::CONTEXT, AppScan::CONFIG_PROPERTIES, AppScan::ROUTES,
AppScan::EXCEPTION_HANDLERS, AppScan::CONSTRAINTS, AppScan::HANDLERS, AppScan::EVENT_LISTENERS,
- AppScan::MESSAGE_LISTENERS, AppScan::SCHEDULED, AppScan::SECURITY_METHODS, AppScan::TRANSACTIONAL,
+ AppScan::MESSAGE_LISTENERS, AppScan::SCHEDULED, AppScan::ORCHESTRATION, AppScan::SECURITY_METHODS, AppScan::TRANSACTIONAL,
AppScan::PROXY_MAP, AppScan::PROXY_PLAN,
] as $basename) {
expect($source)->toContain("'{$basename}'");
diff --git a/packages/data/tests/Integration/PersistenceExceptionTranslationIntegrationTest.php b/packages/data/tests/Integration/PersistenceExceptionTranslationIntegrationTest.php
index cef091b4..672cdb74 100644
--- a/packages/data/tests/Integration/PersistenceExceptionTranslationIntegrationTest.php
+++ b/packages/data/tests/Integration/PersistenceExceptionTranslationIntegrationTest.php
@@ -61,6 +61,18 @@ function translationShortLockWait(Connection $connection, string $lane): void
};
}
+/**
+ * Put the server's own lock wait back. The ODBC driver manager on the CI runner pools SQL Server sessions, so a
+ * session-level SET outlives disconnect() and would decide a later test's outcome (the deadlock test below saw a
+ * 300 ms lock timeout and lost by timing out instead of by being chosen as the deadlock victim).
+ */
+function translationDefaultLockWait(Connection $connection, string $lane): void
+{
+ if ($lane === 'sqlsrv') {
+ $connection->unprepared('set lock_timeout -1');
+ }
+}
+
foreach (IntegrationDsn::lanes() as $lane) {
it("translates integrity, grammar and lock-wait failures on {$lane}", function () use ($lane): void {
$overrides = $lane === 'sqlite' ? ['database' => IntegrationDsn::sqlitePath('translation'), 'busy_timeout' => 300] : [];
@@ -83,6 +95,7 @@ function translationShortLockWait(Connection $connection, string $lane): void
translationShortLockWait($b, $lane);
$waited = translationOf($b, static fn () => $b->table('firefly_translation_parents')->where('id', 1)->update(['name' => 'waiter']));
$a->rollBack();
+ translationDefaultLockWait($b, $lane);
expect($waited)->toBeInstanceOf(CannotAcquireLockException::class);
} finally {
@@ -98,6 +111,7 @@ function translationShortLockWait(Connection $connection, string $lane): void
foreach (['pgsql', 'mysql', 'mariadb', 'sqlsrv'] as $lane) {
it("translates the deadlock victim on {$lane}, whichever process the server picks", function () use ($lane): void {
$connection = IntegrationDsn::connect($lane, 'deadlock-parent');
+ translationDefaultLockWait($connection, $lane); // a pooled session may carry another test's lock timeout
$schema = $connection->getSchemaBuilder();
$schema->dropIfExists('firefly_deadlock_probe');
$schema->create('firefly_deadlock_probe', static function (Blueprint $table): void {
@@ -128,7 +142,7 @@ function translationShortLockWait(Connection $connection, string $lane): void
$racerSaw = trim($racer->getOutput());
// Exactly one side lost, and it was told so in the kernel's own vocabulary.
- expect($parent instanceof DeadlockLoserDataAccessException xor str_ends_with($racerSaw, 'deadlock'))->toBeTrue('parent: '.($parent === null ? 'committed' : $parent::class)."; racer: {$racerSaw}")
+ expect($parent instanceof DeadlockLoserDataAccessException xor str_ends_with($racerSaw, 'deadlock'))->toBeTrue('parent: '.($parent === null ? 'committed' : $parent::class.' — '.$parent->getMessage().' — previous: '.($parent->getPrevious()?->getMessage() ?? 'none')).($lane === 'sqlsrv' ? '; parent lock_timeout: '.json_encode($connection->selectOne('select @@lock_timeout as value')) : '').'; racer: '.$racerSaw)
->and($parent === null || $parent instanceof DeadlockLoserDataAccessException)->toBeTrue();
} finally {
$racer->stop();
diff --git a/packages/data/tests/Integration/RepositoryLockingIntegrationTest.php b/packages/data/tests/Integration/RepositoryLockingIntegrationTest.php
index 616aa271..0199e6d4 100644
--- a/packages/data/tests/Integration/RepositoryLockingIntegrationTest.php
+++ b/packages/data/tests/Integration/RepositoryLockingIntegrationTest.php
@@ -70,6 +70,10 @@ function lockingWriteFromOther(Connection $other, string $lane): ?Throwable
$other->table('records')->where('id', 1)->update(['status' => 'stolen']);
} catch (Throwable $e) {
return (new PersistenceExceptionTranslator)->translate($e, $other->getDriverName());
+ } finally {
+ if ($lane === 'sqlsrv') {
+ $other->unprepared('set lock_timeout -1'); // pooled SQL Server sessions keep session SETs past disconnect()
+ }
}
return null;
diff --git a/packages/data/tests/Support/deadlock-racer.php b/packages/data/tests/Support/deadlock-racer.php
index c7338c5f..d5b0cfee 100644
--- a/packages/data/tests/Support/deadlock-racer.php
+++ b/packages/data/tests/Support/deadlock-racer.php
@@ -16,6 +16,9 @@
$lane = $argv[1] ?? '';
$go = $argv[2] ?? '';
$connection = IntegrationDsn::connect($lane, 'deadlock-racer');
+if ($lane === 'sqlsrv') {
+ $connection->unprepared('set lock_timeout -1'); // a pooled session may carry another test's lock timeout
+}
$connection->beginTransaction();
$connection->table('firefly_deadlock_probe')->where('id', 2)->update(['name' => 'racer']);
diff --git a/packages/firefly/composer.json b/packages/firefly/composer.json
index 5eb1f6c3..9053863c 100644
--- a/packages/firefly/composer.json
+++ b/packages/firefly/composer.json
@@ -39,6 +39,7 @@
"firefly/messaging": "*@dev",
"firefly/observability": "*@dev",
"firefly/openapi": "*@dev",
+ "firefly/orchestration": "*@dev",
"firefly/resilience": "*@dev",
"firefly/scheduling": "*@dev",
"firefly/scheduling-database": "*@dev",
diff --git a/packages/installer/src/CapabilityCatalog.php b/packages/installer/src/CapabilityCatalog.php
index bb0c0e9b..f77a3b27 100644
--- a/packages/installer/src/CapabilityCatalog.php
+++ b/packages/installer/src/CapabilityCatalog.php
@@ -44,6 +44,7 @@ public static function all(): array
new Capability('admin', 'firefly/admin', 'Server-rendered dashboard over the actuator (a Spring Boot Admin analog)'),
new Capability('openapi', 'firefly/openapi', 'OpenAPI 3.1 document generated from the route and constraint manifests, plus a viewer'),
new Capability('feature-flags', 'firefly/feature-flags', 'Feature flags on OpenFeature: flagd documents, targeting and percentage rollouts, a writable store, admin and actuator controls'),
+ new Capability('orchestration', 'firefly/orchestration', 'Sagas, TCC and durable workflows: compensation policies, signals and timers, recovery, a dead-letter queue, EDA triggers', requires: ['eda', 'scheduling', 'resilience', 'actuator']),
new Capability('eda-kafka', 'firefly/eda-kafka', 'Kafka publisher/consumer over ext-rdkafka', requires: ['eda'], adapter: true),
new Capability('eda-rabbitmq', 'firefly/eda-rabbitmq', 'RabbitMQ publisher/consumer over php-amqplib', requires: ['eda'], adapter: true),
diff --git a/packages/observability/cache/firefly-observability-components.php b/packages/observability/cache/firefly-observability-components.php
index 445874de..abf9a4a7 100644
--- a/packages/observability/cache/firefly-observability-components.php
+++ b/packages/observability/cache/firefly-observability-components.php
@@ -230,6 +230,30 @@
0 => 'Firefly\\Observability\\Tracing\\Tracer',
],
],
+ 10 => [
+ 'method' => 'orchestrationMetrics',
+ 'returns' => 'Firefly\\Orchestration\\Event\\OrchestrationMetrics',
+ 'name' => null,
+ 'scope' => 'Singleton',
+ 'primary' => false,
+ 'order' => 0,
+ 'lazy' => false,
+ 'dependencies' => [
+ 0 => 'Firefly\\Observability\\Metrics\\MetricsRecorder',
+ ],
+ ],
+ 11 => [
+ 'method' => 'orchestrationTracing',
+ 'returns' => 'Firefly\\Orchestration\\Event\\OrchestrationTracing',
+ 'name' => null,
+ 'scope' => 'Singleton',
+ 'primary' => false,
+ 'order' => 0,
+ 'lazy' => false,
+ 'dependencies' => [
+ 0 => 'Firefly\\Observability\\Tracing\\Tracer',
+ ],
+ ],
],
'lazy' => false,
'dependencies' => [
diff --git a/packages/observability/cache/firefly-observability-context.php b/packages/observability/cache/firefly-observability-context.php
index a5a3a133..bd593692 100644
--- a/packages/observability/cache/firefly-observability-context.php
+++ b/packages/observability/cache/firefly-observability-context.php
@@ -248,6 +248,52 @@
],
],
],
+ 10 => [
+ 'method' => 'orchestrationMetrics',
+ 'conditions' => [
+ 0 => [
+ 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean',
+ 'args' => [
+ 0 => 'Firefly\\Orchestration\\Event\\OrchestrationMetrics',
+ ],
+ ],
+ 1 => [
+ 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty',
+ 'args' => [
+ 0 => 'firefly.observability.metrics.enabled',
+ 1 => 'true',
+ 2 => true,
+ ],
+ ],
+ ],
+ ],
+ 11 => [
+ 'method' => 'orchestrationTracing',
+ 'conditions' => [
+ 0 => [
+ 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean',
+ 'args' => [
+ 0 => 'Firefly\\Orchestration\\Event\\OrchestrationTracing',
+ ],
+ ],
+ 1 => [
+ 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty',
+ 'args' => [
+ 0 => 'firefly.observability.tracing.enabled',
+ 1 => 'true',
+ 2 => false,
+ ],
+ ],
+ 2 => [
+ 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty',
+ 'args' => [
+ 0 => 'firefly.observability.tracing.orchestration.enabled',
+ 1 => 'true',
+ 2 => true,
+ ],
+ ],
+ ],
+ ],
],
],
3 => [
diff --git a/packages/observability/composer.json b/packages/observability/composer.json
index 7eb26aeb..33e3612e 100644
--- a/packages/observability/composer.json
+++ b/packages/observability/composer.json
@@ -1,6 +1,6 @@
{
"name": "firefly/observability",
- "description": "LaraFly observability: a first-party pure-PHP MeterRegistry (counter/gauge/timer) with Prometheus 0.0.4 text exposition and a Micrometer-JSON /metrics endpoint, HTTP request auto-instrumentation, the real MeterRegistry-backed CqrsMetrics recorder, a resilience circuit-breaker gauge, process metrics, a Spring/OTel-shaped Tracer port with an OpenTelemetry adapter and W3C traceparent propagation over HTTP, CQRS and EDA, trace-aware structured logging, histogram buckets. Top-of-stack, gated on a MeterRegistry bean.",
+ "description": "LaraFly observability: a first-party pure-PHP MeterRegistry (counter/gauge/timer) with Prometheus 0.0.4 text exposition and a Micrometer-JSON /metrics endpoint, HTTP request auto-instrumentation, the real MeterRegistry-backed CqrsMetrics recorder, a resilience circuit-breaker gauge, process metrics, a Spring/OTel-shaped Tracer port with an OpenTelemetry adapter and W3C traceparent propagation over HTTP, CQRS, EDA and orchestration, trace-aware structured logging, histogram buckets. Top-of-stack, gated on a MeterRegistry bean.",
"type": "library",
"license": "Apache-2.0",
"homepage": "https://github.com/fireflyframework/fireflyframework-php",
@@ -32,6 +32,7 @@
"firefly/eda": "*@dev",
"firefly/feature-flags": "*@dev",
"firefly/kernel": "*@dev",
+ "firefly/orchestration": "*@dev",
"firefly/resilience": "*@dev",
"firefly/web": "*@dev",
"guzzlehttp/promises": "^2.0 || ^3.0",
diff --git a/packages/observability/src/ObservabilityAutoConfiguration.php b/packages/observability/src/ObservabilityAutoConfiguration.php
index 2be57f9c..6d113025 100644
--- a/packages/observability/src/ObservabilityAutoConfiguration.php
+++ b/packages/observability/src/ObservabilityAutoConfiguration.php
@@ -29,10 +29,14 @@
use Firefly\Observability\Metrics\MetricsRecorder;
use Firefly\Observability\Metrics\NoOpMetricsRecorder;
use Firefly\Observability\Metrics\SimpleMeterRegistry;
+use Firefly\Observability\Orchestration\MeterRegistryOrchestrationMetrics;
+use Firefly\Observability\Orchestration\TracerOrchestrationTracing;
use Firefly\Observability\Prometheus\PrometheusTextFormat;
use Firefly\Observability\Tracing\NoOpTracer;
use Firefly\Observability\Tracing\Tracer;
use Firefly\Observability\Tracing\W3CTraceContextPropagator;
+use Firefly\Orchestration\Event\OrchestrationMetrics;
+use Firefly\Orchestration\Event\OrchestrationTracing;
use Illuminate\Container\Container;
use Illuminate\Contracts\Cache\Factory;
@@ -233,4 +237,27 @@ public function edaTracing(Tracer $tracer): EdaTracing
{
return new TracerEdaTracing($tracer, new W3CTraceContextPropagator);
}
+
+ /**
+ * Backs firefly/orchestration's OrchestrationMetrics port — the cqrsMetrics() precedence over
+ * OrchestrationAutoConfiguration's #[Order(1000)] NoOp. firefly.orchestration.metrics.enabled still decides,
+ * on the orchestration side, whether the engines report to it at all.
+ */
+ #[Bean]
+ #[ConditionalOnMissingBean(OrchestrationMetrics::class)]
+ #[ConditionalOnProperty(name: 'firefly.observability.metrics.enabled', havingValue: 'true', matchIfMissing: true)]
+ public function orchestrationMetrics(MetricsRecorder $recorder): OrchestrationMetrics
+ {
+ return new MeterRegistryOrchestrationMetrics($recorder);
+ }
+
+ /** The OrchestrationTracing drop-in — the cqrsTracing() gates, with the orchestration instrumentation switch. */
+ #[Bean]
+ #[ConditionalOnMissingBean(OrchestrationTracing::class)]
+ #[ConditionalOnProperty(name: 'firefly.observability.tracing.enabled', havingValue: 'true')]
+ #[ConditionalOnProperty(name: 'firefly.observability.tracing.orchestration.enabled', havingValue: 'true', matchIfMissing: true)]
+ public function orchestrationTracing(Tracer $tracer): OrchestrationTracing
+ {
+ return new TracerOrchestrationTracing($tracer);
+ }
}
diff --git a/packages/observability/src/Orchestration/MeterRegistryOrchestrationMetrics.php b/packages/observability/src/Orchestration/MeterRegistryOrchestrationMetrics.php
new file mode 100644
index 00000000..5db56f7e
--- /dev/null
+++ b/packages/observability/src/Orchestration/MeterRegistryOrchestrationMetrics.php
@@ -0,0 +1,40 @@
+recorder->record('orchestration_executions_seconds', ['name' => $name, 'pattern' => strtolower($pattern->value), 'outcome' => $outcome], $seconds);
+ }
+
+ public function recordStep(string $name, string $stepId, string $outcome, int $attempts, float $seconds): void
+ {
+ $this->recorder->record('orchestration_steps_seconds', ['name' => $name, 'step' => $stepId, 'outcome' => $outcome], $seconds);
+ }
+
+ public function recordCompensation(string $name, string $stepId, string $outcome): void
+ {
+ $this->recorder->increment('orchestration_compensations_total', ['name' => $name, 'step' => $stepId, 'outcome' => $outcome]);
+ }
+
+ public function recordDeadLetter(string $name): void
+ {
+ $this->recorder->increment('orchestration_dead_letters_total', ['name' => $name]);
+ }
+}
diff --git a/packages/observability/src/Orchestration/TracerOrchestrationTracing.php b/packages/observability/src/Orchestration/TracerOrchestrationTracing.php
new file mode 100644
index 00000000..837e66e5
--- /dev/null
+++ b/packages/observability/src/Orchestration/TracerOrchestrationTracing.php
@@ -0,0 +1,50 @@
+tracer->trace(strtolower($pattern->value).' '.$name, $execution, SpanKind::Internal, [
+ 'firefly.orchestration.pattern' => $pattern->value,
+ 'firefly.orchestration.name' => $name,
+ 'firefly.orchestration.correlation_id' => $correlationId,
+ ]);
+ }
+
+ /**
+ * @template T
+ *
+ * @param callable(): T $step
+ * @return T
+ */
+ public function traceStep(string $name, string $stepId, string $correlationId, callable $step): mixed
+ {
+ return $this->tracer->trace("{$name}.{$stepId}", $step, SpanKind::Internal, [
+ 'firefly.orchestration.name' => $name,
+ 'firefly.orchestration.step' => $stepId,
+ 'firefly.orchestration.correlation_id' => $correlationId,
+ ]);
+ }
+}
diff --git a/packages/observability/tests/Orchestration/MeterRegistryOrchestrationMetricsTest.php b/packages/observability/tests/Orchestration/MeterRegistryOrchestrationMetricsTest.php
new file mode 100644
index 00000000..b913b990
--- /dev/null
+++ b/packages/observability/tests/Orchestration/MeterRegistryOrchestrationMetricsTest.php
@@ -0,0 +1,25 @@
+recordExecution('checkout', ExecutionPattern::Saga, 'success', 0.4);
+ $metrics->recordExecution('checkout', ExecutionPattern::Saga, 'failure', 0.2);
+ $metrics->recordStep('checkout', 'charge', 'failure', 3, 0.1);
+ $metrics->recordCompensation('checkout', 'reserve', 'success');
+ $metrics->recordDeadLetter('checkout');
+ $metrics->recordDeadLetter('checkout');
+
+ expect($registry->timer('orchestration_executions_seconds', ['name' => 'checkout', 'pattern' => 'saga', 'outcome' => 'success'])->count())->toBe(1)
+ ->and($registry->timer('orchestration_executions_seconds', ['name' => 'checkout', 'pattern' => 'saga', 'outcome' => 'failure'])->totalTimeSeconds())->toBe(0.2)
+ ->and($registry->timer('orchestration_steps_seconds', ['name' => 'checkout', 'step' => 'charge', 'outcome' => 'failure'])->count())->toBe(1)
+ ->and($registry->counter('orchestration_compensations_total', ['name' => 'checkout', 'step' => 'reserve', 'outcome' => 'success'])->count())->toBe(1.0)
+ ->and($registry->counter('orchestration_dead_letters_total', ['name' => 'checkout'])->count())->toBe(2.0);
+});
diff --git a/packages/observability/tests/Orchestration/OrchestrationAdaptersBootTest.php b/packages/observability/tests/Orchestration/OrchestrationAdaptersBootTest.php
new file mode 100644
index 00000000..8f0c45ae
--- /dev/null
+++ b/packages/observability/tests/Orchestration/OrchestrationAdaptersBootTest.php
@@ -0,0 +1,49 @@
+ $observability */
+function bootOrchestrationObservability(array $observability): ApplicationContext
+{
+ return bootFireflyApp(
+ [
+ 'firefly' => ['observability' => $observability],
+ 'logging' => ['default' => 'test', 'channels' => ['test' => ['driver' => 'errorlog']]],
+ ],
+ [
+ ResilienceServiceProvider::class, SchedulingServiceProvider::class, SchedulingWiringProvider::class,
+ EdaServiceProvider::class, EdaWiringProvider::class,
+ ObservabilityServiceProvider::class, ObservabilityWiringProvider::class,
+ OrchestrationServiceProvider::class, OrchestrationWiringProvider::class,
+ ],
+ needs: ['cache'],
+ );
+}
+
+it('backs the orchestration metrics port with the meter registry unless metrics are off', function (): void {
+ expect(bootOrchestrationObservability([])->get(OrchestrationMetrics::class))->toBeInstanceOf(MeterRegistryOrchestrationMetrics::class)
+ ->and(bootOrchestrationObservability(['metrics' => ['enabled' => false]])->get(OrchestrationMetrics::class))->toBeInstanceOf(NoOpOrchestrationMetrics::class);
+});
+
+it('traces orchestrations only under the tracing master gate and the orchestration switch', function (): void {
+ expect(bootOrchestrationObservability(['tracing' => ['enabled' => true]])->get(OrchestrationTracing::class))->toBeInstanceOf(TracerOrchestrationTracing::class)
+ ->and(bootOrchestrationObservability([])->get(OrchestrationTracing::class))->toBeInstanceOf(NoOpOrchestrationTracing::class)
+ ->and(bootOrchestrationObservability(['tracing' => ['enabled' => true, 'orchestration' => ['enabled' => false]]])->get(OrchestrationTracing::class))->toBeInstanceOf(NoOpOrchestrationTracing::class);
+});
diff --git a/packages/observability/tests/Orchestration/TracerOrchestrationTracingTest.php b/packages/observability/tests/Orchestration/TracerOrchestrationTracingTest.php
new file mode 100644
index 00000000..8c74ade0
--- /dev/null
+++ b/packages/observability/tests/Orchestration/TracerOrchestrationTracingTest.php
@@ -0,0 +1,35 @@
+traceExecution('checkout', ExecutionPattern::Saga, 'c-1', fn (): string => $tracing->traceStep('checkout', 'charge', 'c-1', fn (): string => 'charged'));
+
+ expect($result)->toBe('charged');
+
+ $execution = $tracer->find('saga checkout');
+ $step = $tracer->find('checkout.charge');
+ expect($execution?->kind)->toBe(SpanKind::Internal)
+ ->and($execution?->attributes)->toBe(['firefly.orchestration.pattern' => 'SAGA', 'firefly.orchestration.name' => 'checkout', 'firefly.orchestration.correlation_id' => 'c-1'])
+ ->and($step?->attributes)->toBe(['firefly.orchestration.name' => 'checkout', 'firefly.orchestration.step' => 'charge', 'firefly.orchestration.correlation_id' => 'c-1'])
+ ->and($execution?->ended)->toBeTrue();
+});
+
+it('records a failing step as ERROR and rethrows', function (): void {
+ $tracer = new RecordingTracer;
+
+ expect(fn () => (new TracerOrchestrationTracing($tracer))->traceStep('checkout', 'charge', 'c-2', function (): never {
+ throw new RuntimeException('card declined');
+ }))->toThrow(RuntimeException::class, 'card declined');
+
+ expect($tracer->recorded()[0]->status)->toBe(SpanStatus::Error);
+});
diff --git a/packages/orchestration/.gitattributes b/packages/orchestration/.gitattributes
new file mode 100644
index 00000000..538b69a4
--- /dev/null
+++ b/packages/orchestration/.gitattributes
@@ -0,0 +1,2 @@
+/tests export-ignore
+/.gitattributes export-ignore
diff --git a/packages/orchestration/LICENSE b/packages/orchestration/LICENSE
new file mode 100644
index 00000000..2240005d
--- /dev/null
+++ b/packages/orchestration/LICENSE
@@ -0,0 +1,204 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+Copyright 2026 Firefly Software Solutions Inc.
diff --git a/packages/orchestration/README.md b/packages/orchestration/README.md
new file mode 100644
index 00000000..dee56db1
--- /dev/null
+++ b/packages/orchestration/README.md
@@ -0,0 +1,20 @@
+# firefly/orchestration
+
+LaraFly's distributed-transaction patterns, shaped after the Java `fireflyframework-orchestration` engine:
+**sagas** (forward steps with compensations, five compensation policies, error handlers, a builder DSL and
+saga composition), **TCC** (ordered try/confirm/cancel participants with per-phase timeouts and retries) and a
+**durable workflow** engine (dependencies, ExpressionLanguage conditions, signals, timers, wait-for-all/any,
+child workflows, continue-as-new, queries, suspend/resume and search attributes, triggered synchronously or
+through the Laravel queue).
+
+Definitions are discovered from attributes (`#[Saga]`, `#[Tcc]`, `#[Workflow]`, …) and compiled by
+`php artisan firefly:cache`; their topology is validated at boot. Execution state is checkpointed at every
+step through the `ExecutionPersistenceProvider` port — `memory` (default), `cache` or `database`
+(`firefly.orchestration.persistence.provider`) — so a recovery sweep can resume or compensate an execution a
+crashed process left behind. Failed executions are parked in a dead-letter queue (`memory` or `database`),
+lifecycle events publish through `firefly/eda`, the `orchestration` actuator endpoint and health component
+report what is running, and `php artisan firefly:orchestration:*` lists, shows, signals, ticks and retries.
+
+The guide is [docs/modules/orchestration.md](../../docs/modules/orchestration.md).
+
+Apache-2.0 © Firefly Software Solutions Inc.
diff --git a/packages/orchestration/cache/firefly-orchestration-components.php b/packages/orchestration/cache/firefly-orchestration-components.php
new file mode 100644
index 00000000..f3fe8be3
--- /dev/null
+++ b/packages/orchestration/cache/firefly-orchestration-components.php
@@ -0,0 +1,485 @@
+ [
+ 'class' => 'Firefly\\Orchestration\\Actuator\\OrchestrationEndpoint',
+ 'stereotype' => 'component',
+ 'name' => null,
+ 'scope' => 'Singleton',
+ 'primary' => false,
+ 'order' => 0,
+ 'qualifier' => null,
+ 'interfaces' => [
+ 0 => 'Firefly\\Actuator\\Endpoint\\ActuatorEndpoint',
+ ],
+ 'beans' => [
+ ],
+ 'lazy' => false,
+ 'dependencies' => [
+ 0 => 'Firefly\\Container\\Container',
+ ],
+ ],
+ 1 => [
+ 'class' => 'Firefly\\Orchestration\\Actuator\\OrchestrationHealthIndicator',
+ 'stereotype' => 'component',
+ 'name' => null,
+ 'scope' => 'Singleton',
+ 'primary' => false,
+ 'order' => 0,
+ 'qualifier' => null,
+ 'interfaces' => [
+ 0 => 'Firefly\\Actuator\\Health\\ConditionalHealthIndicator',
+ 1 => 'Firefly\\Actuator\\Health\\HealthIndicator',
+ ],
+ 'beans' => [
+ ],
+ 'lazy' => false,
+ 'dependencies' => [
+ 0 => 'Firefly\\Container\\Container',
+ ],
+ ],
+ 2 => [
+ 'class' => 'Firefly\\Orchestration\\OrchestrationAutoConfiguration',
+ 'stereotype' => 'configuration',
+ 'name' => null,
+ 'scope' => 'Singleton',
+ 'primary' => false,
+ 'order' => 1000,
+ 'qualifier' => null,
+ 'interfaces' => [
+ ],
+ 'beans' => [
+ 0 => [
+ 'method' => 'orchestrationSettings',
+ 'returns' => 'Firefly\\Orchestration\\OrchestrationSettings',
+ 'name' => null,
+ 'scope' => 'Singleton',
+ 'primary' => false,
+ 'order' => 0,
+ 'lazy' => false,
+ 'dependencies' => [
+ 0 => 'Firefly\\Config\\Config',
+ ],
+ ],
+ 1 => [
+ 'method' => 'orchestrationClock',
+ 'returns' => 'Firefly\\Orchestration\\Core\\Clock',
+ 'name' => null,
+ 'scope' => 'Singleton',
+ 'primary' => false,
+ 'order' => 0,
+ 'lazy' => true,
+ 'dependencies' => [
+ ],
+ ],
+ 2 => [
+ 'method' => 'executionPersistenceProvider',
+ 'returns' => 'Firefly\\Orchestration\\Persistence\\ExecutionPersistenceProvider',
+ 'name' => null,
+ 'scope' => 'Singleton',
+ 'primary' => false,
+ 'order' => 0,
+ 'lazy' => true,
+ 'dependencies' => [
+ 0 => 'Firefly\\Orchestration\\OrchestrationSettings',
+ 1 => 'Illuminate\\Container\\Container',
+ ],
+ ],
+ 3 => [
+ 'method' => 'orchestrationDeadLetterStore',
+ 'returns' => 'Firefly\\Orchestration\\Dlq\\DeadLetterStore',
+ 'name' => null,
+ 'scope' => 'Singleton',
+ 'primary' => false,
+ 'order' => 0,
+ 'lazy' => true,
+ 'dependencies' => [
+ 0 => 'Firefly\\Orchestration\\OrchestrationSettings',
+ 1 => 'Illuminate\\Container\\Container',
+ ],
+ ],
+ 4 => [
+ 'method' => 'orchestrationMetrics',
+ 'returns' => 'Firefly\\Orchestration\\Event\\OrchestrationMetrics',
+ 'name' => null,
+ 'scope' => 'Singleton',
+ 'primary' => false,
+ 'order' => 0,
+ 'lazy' => true,
+ 'dependencies' => [
+ ],
+ ],
+ 5 => [
+ 'method' => 'orchestrationTracing',
+ 'returns' => 'Firefly\\Orchestration\\Event\\OrchestrationTracing',
+ 'name' => null,
+ 'scope' => 'Singleton',
+ 'primary' => false,
+ 'order' => 0,
+ 'lazy' => true,
+ 'dependencies' => [
+ ],
+ ],
+ 6 => [
+ 'method' => 'orchestrationEvents',
+ 'returns' => 'Firefly\\Orchestration\\Event\\CompositeOrchestrationEvents',
+ 'name' => null,
+ 'scope' => 'Singleton',
+ 'primary' => false,
+ 'order' => 0,
+ 'lazy' => true,
+ 'dependencies' => [
+ 0 => 'Firefly\\Orchestration\\OrchestrationSettings',
+ 1 => 'Firefly\\Orchestration\\Event\\OrchestrationMetrics',
+ 2 => 'Firefly\\Container\\Container',
+ 3 => 'Illuminate\\Container\\Container',
+ ],
+ ],
+ 7 => [
+ 'method' => 'orchestrationEventPublisher',
+ 'returns' => 'Firefly\\Orchestration\\Event\\OrchestrationEventPublisher',
+ 'name' => null,
+ 'scope' => 'Singleton',
+ 'primary' => false,
+ 'order' => 0,
+ 'lazy' => true,
+ 'dependencies' => [
+ 0 => 'Firefly\\Orchestration\\OrchestrationSettings',
+ 1 => 'Firefly\\Eda\\EventPublisher',
+ ],
+ ],
+ 8 => [
+ 'method' => 'executionLock',
+ 'returns' => 'Firefly\\Orchestration\\Core\\ExecutionLock',
+ 'name' => null,
+ 'scope' => 'Singleton',
+ 'primary' => false,
+ 'order' => 0,
+ 'lazy' => true,
+ 'dependencies' => [
+ 0 => 'Firefly\\Orchestration\\OrchestrationSettings',
+ 1 => 'Firefly\\Scheduling\\Lock\\DistributedLock',
+ ],
+ ],
+ 9 => [
+ 'method' => 'executionAdmission',
+ 'returns' => 'Firefly\\Orchestration\\Core\\ExecutionAdmission',
+ 'name' => null,
+ 'scope' => 'Singleton',
+ 'primary' => false,
+ 'order' => 0,
+ 'lazy' => true,
+ 'dependencies' => [
+ 0 => 'Firefly\\Orchestration\\OrchestrationSettings',
+ 1 => 'Firefly\\Orchestration\\Persistence\\ExecutionPersistenceProvider',
+ 2 => 'Firefly\\Resilience\\Store\\ResilienceStore',
+ ],
+ ],
+ 10 => [
+ 'method' => 'deadLetterService',
+ 'returns' => 'Firefly\\Orchestration\\Dlq\\DeadLetterService',
+ 'name' => null,
+ 'scope' => 'Singleton',
+ 'primary' => false,
+ 'order' => 0,
+ 'lazy' => true,
+ 'dependencies' => [
+ 0 => 'Firefly\\Orchestration\\OrchestrationSettings',
+ 1 => 'Firefly\\Orchestration\\Dlq\\DeadLetterStore',
+ 2 => 'Firefly\\Orchestration\\Event\\CompositeOrchestrationEvents',
+ ],
+ ],
+ 11 => [
+ 'method' => 'callbackInvoker',
+ 'returns' => 'Firefly\\Orchestration\\Core\\Callback\\CallbackInvoker',
+ 'name' => null,
+ 'scope' => 'Singleton',
+ 'primary' => false,
+ 'order' => 0,
+ 'lazy' => true,
+ 'dependencies' => [
+ 0 => 'Illuminate\\Container\\Container',
+ ],
+ ],
+ 12 => [
+ 'method' => 'engineSupport',
+ 'returns' => 'Firefly\\Orchestration\\Core\\EngineSupport',
+ 'name' => null,
+ 'scope' => 'Singleton',
+ 'primary' => false,
+ 'order' => 0,
+ 'lazy' => true,
+ 'dependencies' => [
+ 0 => 'Firefly\\Orchestration\\OrchestrationSettings',
+ 1 => 'Firefly\\Orchestration\\Persistence\\ExecutionPersistenceProvider',
+ 2 => 'Firefly\\Orchestration\\Dlq\\DeadLetterService',
+ 3 => 'Firefly\\Orchestration\\Event\\CompositeOrchestrationEvents',
+ 4 => 'Firefly\\Orchestration\\Event\\OrchestrationEventPublisher',
+ 5 => 'Firefly\\Eda\\EventPublisher',
+ 6 => 'Firefly\\Orchestration\\Event\\OrchestrationTracing',
+ 7 => 'Firefly\\Orchestration\\Core\\Callback\\CallbackInvoker',
+ 8 => 'Firefly\\Orchestration\\Core\\Clock',
+ 9 => 'Firefly\\Orchestration\\Core\\ExecutionLock',
+ 10 => 'Firefly\\Orchestration\\Core\\ExecutionAdmission',
+ 11 => 'Illuminate\\Container\\Container',
+ ],
+ ],
+ 13 => [
+ 'method' => 'conditionEvaluator',
+ 'returns' => 'Firefly\\Orchestration\\Core\\Condition\\ConditionEvaluator',
+ 'name' => null,
+ 'scope' => 'Singleton',
+ 'primary' => false,
+ 'order' => 0,
+ 'lazy' => true,
+ 'dependencies' => [
+ ],
+ ],
+ 14 => [
+ 'method' => 'invocationRunner',
+ 'returns' => 'Firefly\\Orchestration\\Core\\InvocationRunner',
+ 'name' => null,
+ 'scope' => 'Singleton',
+ 'primary' => false,
+ 'order' => 0,
+ 'lazy' => true,
+ 'dependencies' => [
+ 0 => 'Illuminate\\Container\\Container',
+ ],
+ ],
+ 15 => [
+ 'method' => 'stepInvoker',
+ 'returns' => 'Firefly\\Orchestration\\Core\\StepInvoker',
+ 'name' => null,
+ 'scope' => 'Singleton',
+ 'primary' => false,
+ 'order' => 0,
+ 'lazy' => true,
+ 'dependencies' => [
+ 0 => 'Firefly\\Orchestration\\Core\\InvocationRunner',
+ 1 => 'Firefly\\Orchestration\\Core\\EngineSupport',
+ ],
+ ],
+ 16 => [
+ 'method' => 'sagaRegistry',
+ 'returns' => 'Firefly\\Orchestration\\Saga\\Registry\\SagaRegistry',
+ 'name' => null,
+ 'scope' => 'Singleton',
+ 'primary' => false,
+ 'order' => 0,
+ 'lazy' => true,
+ 'dependencies' => [
+ ],
+ ],
+ 17 => [
+ 'method' => 'tccRegistry',
+ 'returns' => 'Firefly\\Orchestration\\Tcc\\Registry\\TccRegistry',
+ 'name' => null,
+ 'scope' => 'Singleton',
+ 'primary' => false,
+ 'order' => 0,
+ 'lazy' => true,
+ 'dependencies' => [
+ ],
+ ],
+ 18 => [
+ 'method' => 'workflowRegistry',
+ 'returns' => 'Firefly\\Orchestration\\Workflow\\Registry\\WorkflowRegistry',
+ 'name' => null,
+ 'scope' => 'Singleton',
+ 'primary' => false,
+ 'order' => 0,
+ 'lazy' => true,
+ 'dependencies' => [
+ ],
+ ],
+ 19 => [
+ 'method' => 'eventGateway',
+ 'returns' => 'Firefly\\Orchestration\\Event\\EventGateway',
+ 'name' => null,
+ 'scope' => 'Singleton',
+ 'primary' => false,
+ 'order' => 0,
+ 'lazy' => true,
+ 'dependencies' => [
+ ],
+ ],
+ 20 => [
+ 'method' => 'compensationErrorHandlerFactory',
+ 'returns' => 'Firefly\\Orchestration\\Saga\\Compensation\\CompensationErrorHandlerFactory',
+ 'name' => null,
+ 'scope' => 'Singleton',
+ 'primary' => false,
+ 'order' => 0,
+ 'lazy' => true,
+ 'dependencies' => [
+ 0 => 'Illuminate\\Container\\Container',
+ ],
+ ],
+ 21 => [
+ 'method' => 'sagaCompensator',
+ 'returns' => 'Firefly\\Orchestration\\Saga\\Compensation\\SagaCompensator',
+ 'name' => null,
+ 'scope' => 'Singleton',
+ 'primary' => false,
+ 'order' => 0,
+ 'lazy' => true,
+ 'dependencies' => [
+ 0 => 'Firefly\\Orchestration\\OrchestrationSettings',
+ 1 => 'Firefly\\Orchestration\\Core\\StepInvoker',
+ 2 => 'Firefly\\Orchestration\\Core\\EngineSupport',
+ 3 => 'Firefly\\Orchestration\\Saga\\Compensation\\CompensationErrorHandlerFactory',
+ 4 => 'Firefly\\Resilience\\ResilienceRegistry',
+ 5 => 'Firefly\\Resilience\\Store\\ResilienceStore',
+ ],
+ ],
+ 22 => [
+ 'method' => 'sagaEngine',
+ 'returns' => 'Firefly\\Orchestration\\Saga\\Engine\\SagaEngine',
+ 'name' => null,
+ 'scope' => 'Singleton',
+ 'primary' => false,
+ 'order' => 0,
+ 'lazy' => true,
+ 'dependencies' => [
+ 0 => 'Firefly\\Orchestration\\OrchestrationSettings',
+ 1 => 'Firefly\\Orchestration\\Saga\\Registry\\SagaRegistry',
+ 2 => 'Firefly\\Orchestration\\Core\\StepInvoker',
+ 3 => 'Firefly\\Orchestration\\Saga\\Compensation\\SagaCompensator',
+ 4 => 'Firefly\\Orchestration\\Core\\EngineSupport',
+ ],
+ ],
+ 23 => [
+ 'method' => 'sagaCompositor',
+ 'returns' => 'Firefly\\Orchestration\\Saga\\Composition\\SagaCompositor',
+ 'name' => null,
+ 'scope' => 'Singleton',
+ 'primary' => false,
+ 'order' => 0,
+ 'lazy' => true,
+ 'dependencies' => [
+ 0 => 'Firefly\\Orchestration\\Saga\\Engine\\SagaEngine',
+ 1 => 'Firefly\\Orchestration\\Core\\EngineSupport',
+ 2 => 'Firefly\\Orchestration\\Core\\Condition\\ConditionEvaluator',
+ ],
+ ],
+ 24 => [
+ 'method' => 'tccEngine',
+ 'returns' => 'Firefly\\Orchestration\\Tcc\\Engine\\TccEngine',
+ 'name' => null,
+ 'scope' => 'Singleton',
+ 'primary' => false,
+ 'order' => 0,
+ 'lazy' => true,
+ 'dependencies' => [
+ 0 => 'Firefly\\Orchestration\\OrchestrationSettings',
+ 1 => 'Firefly\\Orchestration\\Tcc\\Registry\\TccRegistry',
+ 2 => 'Firefly\\Orchestration\\Core\\StepInvoker',
+ 3 => 'Firefly\\Orchestration\\Core\\EngineSupport',
+ ],
+ ],
+ 25 => [
+ 'method' => 'tccCompositor',
+ 'returns' => 'Firefly\\Orchestration\\Tcc\\Composition\\TccCompositor',
+ 'name' => null,
+ 'scope' => 'Singleton',
+ 'primary' => false,
+ 'order' => 0,
+ 'lazy' => true,
+ 'dependencies' => [
+ 0 => 'Firefly\\Orchestration\\OrchestrationSettings',
+ 1 => 'Firefly\\Orchestration\\Tcc\\Engine\\TccEngine',
+ 2 => 'Firefly\\Orchestration\\Core\\EngineSupport',
+ 3 => 'Firefly\\Orchestration\\Core\\Condition\\ConditionEvaluator',
+ 4 => 'Firefly\\Resilience\\ResilienceRegistry',
+ 5 => 'Firefly\\Resilience\\Store\\ResilienceStore',
+ ],
+ ],
+ 26 => [
+ 'method' => 'workflowDispatcher',
+ 'returns' => 'Firefly\\Orchestration\\Workflow\\Queue\\WorkflowDispatcher',
+ 'name' => null,
+ 'scope' => 'Singleton',
+ 'primary' => false,
+ 'order' => 0,
+ 'lazy' => true,
+ 'dependencies' => [
+ 0 => 'Firefly\\Orchestration\\OrchestrationSettings',
+ 1 => 'Illuminate\\Contracts\\Bus\\Dispatcher',
+ ],
+ ],
+ 27 => [
+ 'method' => 'workflowEngine',
+ 'returns' => 'Firefly\\Orchestration\\Workflow\\Engine\\WorkflowEngine',
+ 'name' => null,
+ 'scope' => 'Singleton',
+ 'primary' => false,
+ 'order' => 0,
+ 'lazy' => true,
+ 'dependencies' => [
+ 0 => 'Firefly\\Orchestration\\OrchestrationSettings',
+ 1 => 'Firefly\\Orchestration\\Workflow\\Registry\\WorkflowRegistry',
+ 2 => 'Firefly\\Orchestration\\Core\\StepInvoker',
+ 3 => 'Firefly\\Orchestration\\Core\\EngineSupport',
+ 4 => 'Firefly\\Orchestration\\Core\\Condition\\ConditionEvaluator',
+ 5 => 'Firefly\\Orchestration\\Workflow\\Queue\\WorkflowDispatcher',
+ 6 => 'Illuminate\\Container\\Container',
+ ],
+ ],
+ 28 => [
+ 'method' => 'orchestrator',
+ 'returns' => 'Firefly\\Orchestration\\Core\\Orchestrator',
+ 'name' => null,
+ 'scope' => 'Singleton',
+ 'primary' => false,
+ 'order' => 0,
+ 'lazy' => true,
+ 'dependencies' => [
+ 0 => 'Firefly\\Orchestration\\Saga\\Engine\\SagaEngine',
+ 1 => 'Firefly\\Orchestration\\Tcc\\Engine\\TccEngine',
+ 2 => 'Firefly\\Orchestration\\Workflow\\Engine\\WorkflowEngine',
+ 3 => 'Firefly\\Orchestration\\Persistence\\ExecutionPersistenceProvider',
+ ],
+ ],
+ 29 => [
+ 'method' => 'deadLetterRetrier',
+ 'returns' => 'Firefly\\Orchestration\\Dlq\\DeadLetterRetrier',
+ 'name' => null,
+ 'scope' => 'Singleton',
+ 'primary' => false,
+ 'order' => 0,
+ 'lazy' => true,
+ 'dependencies' => [
+ 0 => 'Firefly\\Orchestration\\Dlq\\DeadLetterService',
+ 1 => 'Firefly\\Orchestration\\Core\\Orchestrator',
+ ],
+ ],
+ 30 => [
+ 'method' => 'recoveryService',
+ 'returns' => 'Firefly\\Orchestration\\Recovery\\RecoveryService',
+ 'name' => null,
+ 'scope' => 'Singleton',
+ 'primary' => false,
+ 'order' => 0,
+ 'lazy' => true,
+ 'dependencies' => [
+ 0 => 'Firefly\\Orchestration\\OrchestrationSettings',
+ 1 => 'Firefly\\Orchestration\\Persistence\\ExecutionPersistenceProvider',
+ 2 => 'Firefly\\Orchestration\\Saga\\Engine\\SagaEngine',
+ 3 => 'Firefly\\Orchestration\\Tcc\\Engine\\TccEngine',
+ 4 => 'Firefly\\Orchestration\\Workflow\\Engine\\WorkflowEngine',
+ 5 => 'Firefly\\Orchestration\\Core\\ExecutionLock',
+ 6 => 'Firefly\\Orchestration\\Core\\Clock',
+ 7 => 'Illuminate\\Container\\Container',
+ ],
+ ],
+ ],
+ 'lazy' => false,
+ 'dependencies' => [
+ ],
+ ],
+];
diff --git a/packages/orchestration/cache/firefly-orchestration-context.php b/packages/orchestration/cache/firefly-orchestration-context.php
new file mode 100644
index 00000000..2d2e08a1
--- /dev/null
+++ b/packages/orchestration/cache/firefly-orchestration-context.php
@@ -0,0 +1,362 @@
+ [
+ 'class' => 'Firefly\\Orchestration\\OrchestrationAutoConfiguration',
+ 'postConstruct' => [
+ ],
+ 'preDestroy' => [
+ ],
+ 'listeners' => [
+ ],
+ 'conditions' => [
+ ],
+ 'beanConditions' => [
+ 0 => [
+ 'method' => 'orchestrationSettings',
+ 'conditions' => [
+ 0 => [
+ 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean',
+ 'args' => [
+ 0 => 'Firefly\\Orchestration\\OrchestrationSettings',
+ ],
+ ],
+ ],
+ ],
+ 1 => [
+ 'method' => 'orchestrationClock',
+ 'conditions' => [
+ 0 => [
+ 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean',
+ 'args' => [
+ 0 => 'Firefly\\Orchestration\\Core\\Clock',
+ ],
+ ],
+ ],
+ ],
+ 2 => [
+ 'method' => 'executionPersistenceProvider',
+ 'conditions' => [
+ 0 => [
+ 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean',
+ 'args' => [
+ 0 => 'Firefly\\Orchestration\\Persistence\\ExecutionPersistenceProvider',
+ ],
+ ],
+ ],
+ ],
+ 3 => [
+ 'method' => 'orchestrationDeadLetterStore',
+ 'conditions' => [
+ 0 => [
+ 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean',
+ 'args' => [
+ 0 => 'Firefly\\Orchestration\\Dlq\\DeadLetterStore',
+ ],
+ ],
+ ],
+ ],
+ 4 => [
+ 'method' => 'orchestrationMetrics',
+ 'conditions' => [
+ 0 => [
+ 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean',
+ 'args' => [
+ 0 => 'Firefly\\Orchestration\\Event\\OrchestrationMetrics',
+ ],
+ ],
+ ],
+ ],
+ 5 => [
+ 'method' => 'orchestrationTracing',
+ 'conditions' => [
+ 0 => [
+ 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean',
+ 'args' => [
+ 0 => 'Firefly\\Orchestration\\Event\\OrchestrationTracing',
+ ],
+ ],
+ ],
+ ],
+ 6 => [
+ 'method' => 'orchestrationEvents',
+ 'conditions' => [
+ 0 => [
+ 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean',
+ 'args' => [
+ 0 => 'Firefly\\Orchestration\\Event\\CompositeOrchestrationEvents',
+ ],
+ ],
+ ],
+ ],
+ 7 => [
+ 'method' => 'orchestrationEventPublisher',
+ 'conditions' => [
+ 0 => [
+ 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean',
+ 'args' => [
+ 0 => 'Firefly\\Orchestration\\Event\\OrchestrationEventPublisher',
+ ],
+ ],
+ ],
+ ],
+ 8 => [
+ 'method' => 'executionLock',
+ 'conditions' => [
+ 0 => [
+ 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean',
+ 'args' => [
+ 0 => 'Firefly\\Orchestration\\Core\\ExecutionLock',
+ ],
+ ],
+ ],
+ ],
+ 9 => [
+ 'method' => 'executionAdmission',
+ 'conditions' => [
+ 0 => [
+ 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean',
+ 'args' => [
+ 0 => 'Firefly\\Orchestration\\Core\\ExecutionAdmission',
+ ],
+ ],
+ ],
+ ],
+ 10 => [
+ 'method' => 'deadLetterService',
+ 'conditions' => [
+ 0 => [
+ 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean',
+ 'args' => [
+ 0 => 'Firefly\\Orchestration\\Dlq\\DeadLetterService',
+ ],
+ ],
+ ],
+ ],
+ 11 => [
+ 'method' => 'callbackInvoker',
+ 'conditions' => [
+ 0 => [
+ 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean',
+ 'args' => [
+ 0 => 'Firefly\\Orchestration\\Core\\Callback\\CallbackInvoker',
+ ],
+ ],
+ ],
+ ],
+ 12 => [
+ 'method' => 'engineSupport',
+ 'conditions' => [
+ 0 => [
+ 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean',
+ 'args' => [
+ 0 => 'Firefly\\Orchestration\\Core\\EngineSupport',
+ ],
+ ],
+ ],
+ ],
+ 13 => [
+ 'method' => 'conditionEvaluator',
+ 'conditions' => [
+ 0 => [
+ 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean',
+ 'args' => [
+ 0 => 'Firefly\\Orchestration\\Core\\Condition\\ConditionEvaluator',
+ ],
+ ],
+ ],
+ ],
+ 14 => [
+ 'method' => 'invocationRunner',
+ 'conditions' => [
+ 0 => [
+ 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean',
+ 'args' => [
+ 0 => 'Firefly\\Orchestration\\Core\\InvocationRunner',
+ ],
+ ],
+ ],
+ ],
+ 15 => [
+ 'method' => 'stepInvoker',
+ 'conditions' => [
+ 0 => [
+ 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean',
+ 'args' => [
+ 0 => 'Firefly\\Orchestration\\Core\\StepInvoker',
+ ],
+ ],
+ ],
+ ],
+ 16 => [
+ 'method' => 'sagaRegistry',
+ 'conditions' => [
+ 0 => [
+ 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean',
+ 'args' => [
+ 0 => 'Firefly\\Orchestration\\Saga\\Registry\\SagaRegistry',
+ ],
+ ],
+ ],
+ ],
+ 17 => [
+ 'method' => 'tccRegistry',
+ 'conditions' => [
+ 0 => [
+ 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean',
+ 'args' => [
+ 0 => 'Firefly\\Orchestration\\Tcc\\Registry\\TccRegistry',
+ ],
+ ],
+ ],
+ ],
+ 18 => [
+ 'method' => 'workflowRegistry',
+ 'conditions' => [
+ 0 => [
+ 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean',
+ 'args' => [
+ 0 => 'Firefly\\Orchestration\\Workflow\\Registry\\WorkflowRegistry',
+ ],
+ ],
+ ],
+ ],
+ 19 => [
+ 'method' => 'eventGateway',
+ 'conditions' => [
+ 0 => [
+ 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean',
+ 'args' => [
+ 0 => 'Firefly\\Orchestration\\Event\\EventGateway',
+ ],
+ ],
+ ],
+ ],
+ 20 => [
+ 'method' => 'compensationErrorHandlerFactory',
+ 'conditions' => [
+ 0 => [
+ 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean',
+ 'args' => [
+ 0 => 'Firefly\\Orchestration\\Saga\\Compensation\\CompensationErrorHandlerFactory',
+ ],
+ ],
+ ],
+ ],
+ 21 => [
+ 'method' => 'sagaCompensator',
+ 'conditions' => [
+ 0 => [
+ 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean',
+ 'args' => [
+ 0 => 'Firefly\\Orchestration\\Saga\\Compensation\\SagaCompensator',
+ ],
+ ],
+ ],
+ ],
+ 22 => [
+ 'method' => 'sagaEngine',
+ 'conditions' => [
+ 0 => [
+ 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean',
+ 'args' => [
+ 0 => 'Firefly\\Orchestration\\Saga\\Engine\\SagaEngine',
+ ],
+ ],
+ ],
+ ],
+ 23 => [
+ 'method' => 'sagaCompositor',
+ 'conditions' => [
+ 0 => [
+ 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean',
+ 'args' => [
+ 0 => 'Firefly\\Orchestration\\Saga\\Composition\\SagaCompositor',
+ ],
+ ],
+ ],
+ ],
+ 24 => [
+ 'method' => 'tccEngine',
+ 'conditions' => [
+ 0 => [
+ 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean',
+ 'args' => [
+ 0 => 'Firefly\\Orchestration\\Tcc\\Engine\\TccEngine',
+ ],
+ ],
+ ],
+ ],
+ 25 => [
+ 'method' => 'tccCompositor',
+ 'conditions' => [
+ 0 => [
+ 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean',
+ 'args' => [
+ 0 => 'Firefly\\Orchestration\\Tcc\\Composition\\TccCompositor',
+ ],
+ ],
+ ],
+ ],
+ 26 => [
+ 'method' => 'workflowDispatcher',
+ 'conditions' => [
+ 0 => [
+ 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean',
+ 'args' => [
+ 0 => 'Firefly\\Orchestration\\Workflow\\Queue\\WorkflowDispatcher',
+ ],
+ ],
+ ],
+ ],
+ 27 => [
+ 'method' => 'workflowEngine',
+ 'conditions' => [
+ 0 => [
+ 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean',
+ 'args' => [
+ 0 => 'Firefly\\Orchestration\\Workflow\\Engine\\WorkflowEngine',
+ ],
+ ],
+ ],
+ ],
+ 28 => [
+ 'method' => 'orchestrator',
+ 'conditions' => [
+ 0 => [
+ 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean',
+ 'args' => [
+ 0 => 'Firefly\\Orchestration\\Core\\Orchestrator',
+ ],
+ ],
+ ],
+ ],
+ 29 => [
+ 'method' => 'deadLetterRetrier',
+ 'conditions' => [
+ 0 => [
+ 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean',
+ 'args' => [
+ 0 => 'Firefly\\Orchestration\\Dlq\\DeadLetterRetrier',
+ ],
+ ],
+ ],
+ ],
+ 30 => [
+ 'method' => 'recoveryService',
+ 'conditions' => [
+ 0 => [
+ 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean',
+ 'args' => [
+ 0 => 'Firefly\\Orchestration\\Recovery\\RecoveryService',
+ ],
+ ],
+ ],
+ ],
+ ],
+ ],
+];
diff --git a/packages/orchestration/composer.json b/packages/orchestration/composer.json
new file mode 100644
index 00000000..8c08bbc3
--- /dev/null
+++ b/packages/orchestration/composer.json
@@ -0,0 +1,74 @@
+{
+ "name": "firefly/orchestration",
+ "description": "LaraFly orchestration: sagas, TCC transactions and durable workflows discovered from attributes into a compiled manifest, with compensation policies, memory/cache/database execution persistence, recovery, a dead-letter queue, EDA events and triggers, an actuator endpoint and an opt-in REST surface.",
+ "type": "library",
+ "license": "Apache-2.0",
+ "homepage": "https://github.com/fireflyframework/fireflyframework-php",
+ "authors": [
+ {
+ "name": "Firefly Software Solutions Inc.",
+ "homepage": "https://github.com/fireflyframework"
+ }
+ ],
+ "keywords": [
+ "firefly",
+ "laravel",
+ "saga",
+ "tcc",
+ "workflow",
+ "orchestration"
+ ],
+ "support": {
+ "issues": "https://github.com/fireflyframework/fireflyframework-php/issues",
+ "source": "https://github.com/fireflyframework/fireflyframework-php/tree/main/packages/orchestration"
+ },
+ "require": {
+ "php": "^8.3",
+ "firefly/actuator": "*@dev",
+ "firefly/autoconfigure": "*@dev",
+ "firefly/config": "*@dev",
+ "firefly/container": "*@dev",
+ "firefly/context": "*@dev",
+ "firefly/eda": "*@dev",
+ "firefly/kernel": "*@dev",
+ "firefly/resilience": "*@dev",
+ "firefly/scheduling": "*@dev",
+ "illuminate/bus": "^13.0",
+ "illuminate/cache": "^13.0",
+ "illuminate/console": "^13.0",
+ "illuminate/container": "^13.0",
+ "illuminate/contracts": "^13.0",
+ "illuminate/database": "^13.0",
+ "illuminate/http": "^13.0",
+ "illuminate/queue": "^13.0",
+ "illuminate/routing": "^13.0",
+ "illuminate/support": "^13.0",
+ "psr/log": "^3.0",
+ "symfony/expression-language": "^7.4|^8.0"
+ },
+ "extra": {
+ "laravel": {
+ "providers": [
+ "Firefly\\Orchestration\\OrchestrationServiceProvider",
+ "Firefly\\Orchestration\\OrchestrationWiringProvider"
+ ]
+ },
+ "branch-alias": {
+ "dev-main": "26.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Firefly\\Orchestration\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Firefly\\Orchestration\\Tests\\": "tests/"
+ }
+ },
+ "minimum-stability": "stable",
+ "config": {
+ "sort-packages": true
+ }
+}
diff --git a/packages/orchestration/database/migrations/2026_10_12_000000_create_firefly_orchestration_tables.php b/packages/orchestration/database/migrations/2026_10_12_000000_create_firefly_orchestration_tables.php
new file mode 100644
index 00000000..b51bb380
--- /dev/null
+++ b/packages/orchestration/database/migrations/2026_10_12_000000_create_firefly_orchestration_tables.php
@@ -0,0 +1,31 @@
+getConnection()));
+ }
+
+ public function down(): void
+ {
+ OrchestrationSchema::drop(Schema::connection($this->getConnection()));
+ }
+};
diff --git a/packages/orchestration/src/Actuator/OrchestrationEndpoint.php b/packages/orchestration/src/Actuator/OrchestrationEndpoint.php
new file mode 100644
index 00000000..0d0fb1f9
--- /dev/null
+++ b/packages/orchestration/src/Actuator/OrchestrationEndpoint.php
@@ -0,0 +1,150 @@
+beans->has(OrchestrationSettings::class);
+ }
+
+ public function handle(EndpointRequest $request): ?EndpointResponse
+ {
+ if ($request->method !== 'GET' || ! $this->enabled()) {
+ return null;
+ }
+
+ return match ($request->subPath[0] ?? null) {
+ null => EndpointResponse::json($this->overview()),
+ 'executions' => $this->executions($request),
+ 'dlq' => EndpointResponse::json(['deadLetters' => array_map(static fn (DeadLetterEntry $entry): array => $entry->toArray(), $this->deadLetters()->all(self::limit($request)))]),
+ default => null,
+ };
+ }
+
+ /** @return array */
+ private function overview(): array
+ {
+ $settings = $this->get(OrchestrationSettings::class);
+
+ $sagas = [];
+ foreach ($this->get(SagaRegistry::class)->all() as $saga) {
+ $sagas[$saga->name] = ['steps' => $saga->stepIds(), 'layers' => $saga->layers, 'triggerEventType' => $saga->triggerEventType, 'layerConcurrency' => $saga->layerConcurrency];
+ }
+ $tccs = [];
+ foreach ($this->get(TccRegistry::class)->all() as $tcc) {
+ $tccs[$tcc->name] = ['participants' => $tcc->participantIds(), 'triggerEventType' => $tcc->triggerEventType];
+ }
+ $workflows = [];
+ foreach ($this->get(WorkflowRegistry::class)->all() as $workflow) {
+ $workflows[$workflow->id] = [
+ 'steps' => $workflow->stepIds(), 'layers' => $workflow->layers, 'triggerMode' => $workflow->triggerMode->value,
+ 'triggerEventType' => $workflow->triggerEventType, 'queries' => array_map('strval', array_keys($workflow->queries)),
+ ];
+ }
+
+ return [
+ 'persistence' => $settings->persistence->provider,
+ 'compensationPolicy' => $settings->saga->compensationPolicy->value,
+ 'executions' => $this->get(ExecutionPersistenceProvider::class)->countByStatus(),
+ 'deadLetters' => $this->deadLetters()->count(),
+ 'sagas' => $sagas,
+ 'tccs' => $tccs,
+ 'workflows' => $workflows,
+ 'triggers' => $this->get(EventGateway::class)->describe(),
+ 'notes' => ['Steps of one topology layer run one after another; layerConcurrency, cpuBound and a workflow step\'s async are accepted for parity and have no effect.'],
+ ];
+ }
+
+ private function executions(EndpointRequest $request): EndpointResponse
+ {
+ $persistence = $this->get(ExecutionPersistenceProvider::class);
+ $id = $request->subPath[1] ?? null;
+
+ if ($id !== null) {
+ $state = $persistence->findById($id);
+
+ return $state === null
+ ? EndpointResponse::json(['error' => "No orchestration execution [{$id}] is persisted."], 404)
+ : EndpointResponse::json($state->toArray());
+ }
+
+ $query = $request->query;
+ $criteria = new ExecutionCriteria(
+ pattern: is_string($query['pattern'] ?? null) ? ExecutionPattern::tryFrom(strtoupper($query['pattern'])) : null,
+ status: is_string($query['status'] ?? null) ? ExecutionStatus::tryFrom(strtoupper($query['status'])) : null,
+ name: is_string($query['name'] ?? null) && $query['name'] !== '' ? $query['name'] : null,
+ limit: self::limit($request),
+ );
+
+ return EndpointResponse::json(['executions' => array_map(static fn (ExecutionState $state): array => [
+ 'correlationId' => $state->correlationId,
+ 'name' => $state->executionName,
+ 'pattern' => $state->pattern->value,
+ 'status' => $state->status->value,
+ 'updatedAt' => ExecutionState::instant($state->updatedAt),
+ 'waitingOn' => $state->waitingOn,
+ 'failureReason' => $state->failureReason,
+ ], $persistence->find($criteria))]);
+ }
+
+ private function deadLetters(): DeadLetterService
+ {
+ return $this->get(DeadLetterService::class);
+ }
+
+ private static function limit(EndpointRequest $request): int
+ {
+ $limit = $request->query['limit'] ?? 50;
+
+ return is_numeric($limit) ? max(1, min(500, (int) $limit)) : 50;
+ }
+
+ /**
+ * @template T of object
+ *
+ * @param class-string $class
+ * @return T
+ */
+ private function get(string $class): object
+ {
+ /** @var T */
+ return $this->beans->get($class);
+ }
+}
diff --git a/packages/orchestration/src/Actuator/OrchestrationHealthIndicator.php b/packages/orchestration/src/Actuator/OrchestrationHealthIndicator.php
new file mode 100644
index 00000000..7a9fa75c
--- /dev/null
+++ b/packages/orchestration/src/Actuator/OrchestrationHealthIndicator.php
@@ -0,0 +1,66 @@
+beans->has(OrchestrationSettings::class)) {
+ return false;
+ }
+
+ $settings = $this->beans->get(OrchestrationSettings::class);
+
+ return $settings instanceof OrchestrationSettings && $settings->healthEnabled;
+ }
+
+ public function health(): Health
+ {
+ /** @var OrchestrationSettings $settings */
+ $settings = $this->beans->get(OrchestrationSettings::class);
+ /** @var ExecutionPersistenceProvider $persistence */
+ $persistence = $this->beans->get(ExecutionPersistenceProvider::class);
+
+ if (! $persistence->isHealthy()) {
+ return Health::down(['provider' => $settings->persistence->provider, 'error' => 'the execution store does not answer']);
+ }
+
+ try {
+ /** @var RecoveryService $recovery */
+ $recovery = $this->beans->get(RecoveryService::class);
+ /** @var DeadLetterService $deadLetters */
+ $deadLetters = $this->beans->get(DeadLetterService::class);
+
+ return Health::up([
+ 'provider' => $settings->persistence->provider,
+ 'inFlight' => $persistence->countInFlight(),
+ 'stale' => $settings->recovery->enabled ? count($recovery->findStale()) : 0,
+ 'deadLetters' => $deadLetters->count(),
+ ]);
+ } catch (Throwable $error) {
+ return Health::down(['provider' => $settings->persistence->provider, 'error' => $error->getMessage()]);
+ }
+ }
+}
diff --git a/packages/orchestration/src/Boot/EventGatewayWiringPass.php b/packages/orchestration/src/Boot/EventGatewayWiringPass.php
new file mode 100644
index 00000000..d9d1c735
--- /dev/null
+++ b/packages/orchestration/src/Boot/EventGatewayWiringPass.php
@@ -0,0 +1,62 @@
+container;
+ if (! $container->bound(EventGateway::class) || ! $container->bound(EventPublisher::class)) {
+ return;
+ }
+
+ /** @var EventGateway $gateway */
+ $gateway = $container->make(EventGateway::class);
+ if ($gateway->count() === 0) {
+ return;
+ }
+
+ /** @var EventPublisher $publisher */
+ $publisher = $container->make(EventPublisher::class);
+ foreach ($gateway->eventTypes() as $eventType) {
+ $publisher->subscribe($eventType, static function (EventEnvelope $envelope) use ($gateway, $eventType): void {
+ $payload = [];
+ foreach ($envelope->payload as $key => $value) {
+ $payload[(string) $key] = $value;
+ }
+ $headers = $envelope->headers;
+ $headers['eventId'] = $envelope->eventId;
+ $headers['eventType'] = $envelope->eventType;
+ $gateway->route($eventType, $payload, $headers, EventGateway::correlationIdFor($envelope->eventType, $envelope->eventId));
+ });
+ }
+ }
+}
diff --git a/packages/orchestration/src/Boot/OrchestrationRegistryPass.php b/packages/orchestration/src/Boot/OrchestrationRegistryPass.php
new file mode 100644
index 00000000..e850b7a4
--- /dev/null
+++ b/packages/orchestration/src/Boot/OrchestrationRegistryPass.php
@@ -0,0 +1,156 @@
+container;
+ if (! $container->bound(OrchestrationManifest::class) || ! $container->bound(OrchestrationSettings::class)) {
+ return;
+ }
+
+ /** @var OrchestrationManifest $manifest */
+ $manifest = $container->make(OrchestrationManifest::class);
+ /** @var OrchestrationSettings $settings */
+ $settings = $container->make(OrchestrationSettings::class);
+ /** @var SagaRegistry $sagas */
+ $sagas = $container->make(SagaRegistry::class);
+ /** @var TccRegistry $tccs */
+ $tccs = $container->make(TccRegistry::class);
+ /** @var WorkflowRegistry $workflows */
+ $workflows = $container->make(WorkflowRegistry::class);
+
+ if ($settings->saga->enabled) {
+ foreach ($manifest->sagas() as $saga) {
+ $sagas->register($saga);
+ }
+ }
+ if ($settings->tcc->enabled) {
+ foreach ($manifest->tccs() as $tcc) {
+ $tccs->register($tcc);
+ }
+ }
+ if ($settings->workflow->enabled) {
+ foreach ($manifest->workflows() as $workflow) {
+ $workflows->register($workflow);
+ }
+ }
+
+ if ($settings->validationEnabled) {
+ /** @var ConditionEvaluator $conditions */
+ $conditions = $container->make(ConditionEvaluator::class);
+ $logger = $container->bound(LoggerInterface::class) ? $container->make(LoggerInterface::class) : new NullLogger;
+ $validator = new OrchestrationValidator($conditions);
+ $validator->enforce($validator->validate($sagas, $tccs, $workflows), $settings->failOnWarning, $logger);
+ }
+
+ if (! $settings->events->gatewayEnabled) {
+ return;
+ }
+
+ /** @var EventGateway $gateway */
+ $gateway = $container->make(EventGateway::class);
+ $start = static fn (ExecutionPattern $pattern, string $name): Closure => self::starter($container, $pattern, $name);
+
+ foreach ($sagas->all() as $saga) {
+ if ($saga->triggerEventType !== '') {
+ $gateway->register($saga->triggerEventType, $saga->name, ExecutionPattern::Saga, $start(ExecutionPattern::Saga, $saga->name));
+ }
+ }
+ foreach ($tccs->all() as $tcc) {
+ if ($tcc->triggerEventType !== '') {
+ $gateway->register($tcc->triggerEventType, $tcc->name, ExecutionPattern::Tcc, $start(ExecutionPattern::Tcc, $tcc->name));
+ }
+ }
+ foreach ($workflows->all() as $workflow) {
+ if ($workflow->triggerEventType !== '') {
+ $gateway->register($workflow->triggerEventType, $workflow->id, ExecutionPattern::Workflow, $start(ExecutionPattern::Workflow, $workflow->id));
+ }
+ }
+ }
+
+ /**
+ * The executor the gateway runs for one trigger: it resolves the Orchestrator only when the first event
+ * arrives, so registering triggers at boot builds no engine.
+ *
+ * @return Closure(array): mixed
+ */
+ /**
+ * The executor the gateway calls for one delivered trigger. The gateway hands it a correlation id derived
+ * from the triggering event, so a redelivery of that event (every EDA transport delivers at least once)
+ * names an execution that already exists: the start is skipped before it runs, or, when two deliveries
+ * race, the engine refuses the loser under the execution lock. Either way the redelivery is acknowledged as
+ * a no-op (null) instead of starting the definition again or failing the delivery into a retry loop.
+ */
+ private static function starter(Container $container, ExecutionPattern $pattern, string $name): Closure
+ {
+ return static function (array $payload, array $headers = [], ?string $correlationId = null) use ($container, $pattern, $name): ?ExecutionOutcome {
+ $input = [];
+ foreach ($payload as $key => $value) {
+ $input[(string) $key] = $value;
+ }
+ $carried = [];
+ foreach ($headers as $key => $value) {
+ if (is_string($value)) {
+ $carried[(string) $key] = $value;
+ }
+ }
+ /** @var Orchestrator $orchestrator */
+ $orchestrator = $container->make(Orchestrator::class);
+ if ($correlationId !== null && $orchestrator->find($correlationId) !== null) {
+ return null;
+ }
+
+ try {
+ return $orchestrator->start($pattern, $name, $input, $carried, $correlationId);
+ } catch (InvalidExecutionStateException $refused) {
+ if ($correlationId !== null && $orchestrator->find($correlationId) !== null) {
+ return null;
+ }
+
+ throw $refused;
+ }
+ };
+ }
+}
diff --git a/packages/orchestration/src/Boot/OrchestrationRouteRegistrar.php b/packages/orchestration/src/Boot/OrchestrationRouteRegistrar.php
new file mode 100644
index 00000000..3252dd7c
--- /dev/null
+++ b/packages/orchestration/src/Boot/OrchestrationRouteRegistrar.php
@@ -0,0 +1,74 @@
+get(` argument
+ * as a Config key spelled out of sight.
+ */
+final class OrchestrationRouteRegistrar implements BootPass
+{
+ public const string ROUTE_PREFIX = 'firefly.orchestration.';
+
+ public function phase(): BootPhase
+ {
+ return BootPhase::WiringPasses;
+ }
+
+ public function order(): int
+ {
+ return 65;
+ }
+
+ public function run(BootContext $context): void
+ {
+ $container = $context->container;
+ if (! $container->bound(OrchestrationSettings::class)) {
+ return;
+ }
+
+ /** @var OrchestrationSettings $settings */
+ $settings = $container->make(OrchestrationSettings::class);
+ if (! $settings->rest->enabled) {
+ return;
+ }
+
+ /** @var Router $router */
+ $router = $container->make('router');
+ $base = $settings->rest->path;
+ $controller = static function () use ($container): OrchestrationController {
+ /** @var OrchestrationController */
+ return $container->make(OrchestrationController::class);
+ };
+
+ $router->post($base.'/sagas/{name}', static fn (Request $request, string $name): Response => $controller()->startSaga($request, $name))->name(self::ROUTE_PREFIX.'start-saga');
+ $router->post($base.'/tcc/{name}', static fn (Request $request, string $name): Response => $controller()->startTcc($request, $name))->name(self::ROUTE_PREFIX.'start-tcc');
+ $router->post($base.'/workflows/{id}', static fn (Request $request, string $id): Response => $controller()->startWorkflow($request, $id))->name(self::ROUTE_PREFIX.'start-workflow');
+ $router->get($base.'/executions', static fn (Request $request): Response => $controller()->executions($request))->name(self::ROUTE_PREFIX.'executions');
+ $router->get($base.'/executions/{id}', static fn (string $id): Response => $controller()->execution($id))->name(self::ROUTE_PREFIX.'execution');
+ $router->post($base.'/workflows/{id}/signals/{signal}', static fn (Request $request, string $id, string $signal): Response => $controller()->signal($request, $id, $signal))->name(self::ROUTE_PREFIX.'signal');
+ $router->post($base.'/workflows/{id}/suspend', static fn (Request $request, string $id): Response => $controller()->suspend($request, $id))->name(self::ROUTE_PREFIX.'suspend');
+ $router->post($base.'/workflows/{id}/resume', static fn (string $id): Response => $controller()->resume($id))->name(self::ROUTE_PREFIX.'resume');
+ $router->post($base.'/workflows/{id}/cancel', static fn (string $id): Response => $controller()->cancel($id))->name(self::ROUTE_PREFIX.'cancel');
+ $router->get($base.'/workflows/{id}/queries/{query}', static fn (string $id, string $query): Response => $controller()->query($id, $query))->name(self::ROUTE_PREFIX.'query');
+ $router->get($base.'/dlq', static fn (Request $request): Response => $controller()->deadLetters($request))->name(self::ROUTE_PREFIX.'dlq');
+ $router->post($base.'/dlq/{id}/retry', static fn (string $id): Response => $controller()->retryDeadLetter($id))->name(self::ROUTE_PREFIX.'dlq-retry');
+ $router->delete($base.'/dlq/{id}', static fn (string $id): Response => $controller()->deleteDeadLetter($id))->name(self::ROUTE_PREFIX.'dlq-delete');
+ }
+}
diff --git a/packages/orchestration/src/Boot/OrchestrationSchedulePass.php b/packages/orchestration/src/Boot/OrchestrationSchedulePass.php
new file mode 100644
index 00000000..5ba67ded
--- /dev/null
+++ b/packages/orchestration/src/Boot/OrchestrationSchedulePass.php
@@ -0,0 +1,84 @@
+container;
+ if (! class_exists(ScheduledManifest::class) || ! $container->bound(ScheduledManifest::class) || ! $container->bound(OrchestrationSettings::class)) {
+ return;
+ }
+
+ /** @var OrchestrationSettings $settings */
+ $settings = $container->make(OrchestrationSettings::class);
+ $durable = $settings->persistence->provider !== 'memory';
+ $declaresWorkflows = $container->bound(OrchestrationManifest::class) && $this->declaresWorkflows($container->make(OrchestrationManifest::class));
+
+ $descriptors = [];
+ if ($settings->workflow->enabled && $settings->workflow->tickEnabled && ($durable || $declaresWorkflows)) {
+ $descriptors[] = new ScheduledDescriptor(
+ class: OrchestrationTickTask::class,
+ method: 'tick',
+ fixedRate: OrchestrationSettings::interval($settings->workflow->tickInterval),
+ lockName: OrchestrationTickTask::LOCK,
+ lockTtl: '300s',
+ );
+ }
+ if ($settings->recovery->enabled && $durable) {
+ $descriptors[] = new ScheduledDescriptor(
+ class: OrchestrationRecoveryTask::class,
+ method: 'recover',
+ fixedRate: OrchestrationSettings::interval($settings->persistence->cleanupInterval),
+ lockName: OrchestrationRecoveryTask::LOCK,
+ lockTtl: '600s',
+ );
+ }
+ if ($descriptors === []) {
+ return;
+ }
+
+ /** @var ScheduledManifest $manifest */
+ $manifest = $container->make(ScheduledManifest::class);
+ $container->instance(ScheduledManifest::class, new ScheduledManifest([...$manifest->all(), ...$descriptors]));
+ }
+
+ private function declaresWorkflows(mixed $manifest): bool
+ {
+ return $manifest instanceof OrchestrationManifest && $manifest->toArray()['workflows'] !== [];
+ }
+}
diff --git a/packages/orchestration/src/Boot/ScheduledExecutionsPass.php b/packages/orchestration/src/Boot/ScheduledExecutionsPass.php
new file mode 100644
index 00000000..18c3a366
--- /dev/null
+++ b/packages/orchestration/src/Boot/ScheduledExecutionsPass.php
@@ -0,0 +1,106 @@
+container;
+ if (! $container->bound(OrchestrationManifest::class)) {
+ return;
+ }
+
+ /** @var OrchestrationManifest $manifest */
+ $manifest = $container->make(OrchestrationManifest::class);
+ $schedules = $manifest->schedules();
+ if ($schedules === []) {
+ return;
+ }
+
+ $container->afterResolving(Schedule::class, static function (Schedule $schedule) use ($container, $schedules): void {
+ foreach ($schedules as $index => $scheduled) {
+ $event = $schedule->call(static function () use ($container, $scheduled, $index): void {
+ self::start($container, $scheduled, $index);
+ });
+
+ Cadence::of(new ScheduledDescriptor(
+ class: Orchestrator::class,
+ method: 'start',
+ cron: $scheduled->cron,
+ fixedRate: $scheduled->fixedRate,
+ fixedDelay: $scheduled->fixedDelay,
+ ))->apply($event);
+
+ if ($scheduled->zone !== null) {
+ $event->timezone($scheduled->zone);
+ }
+ $event->description($scheduled->description !== '' ? $scheduled->description : "orchestration {$scheduled->pattern->value} {$scheduled->name}");
+ }
+ });
+ }
+
+ private static function start(Container $container, ScheduledExecution $scheduled, int $index): void
+ {
+ $name = $scheduled->lockName($index);
+ $lock = null;
+
+ try {
+ /** @var DistributedLock $lock */
+ $lock = $container->make(DistributedLock::class);
+ if (! $lock->tryAcquire($name, 300.0)) {
+ $lock = null;
+
+ return; // another node starts it this tick
+ }
+
+ /** @var Orchestrator $orchestrator */
+ $orchestrator = $container->make(Orchestrator::class);
+ $orchestrator->start($scheduled->pattern, $scheduled->name, $scheduled->input, ['x-firefly-scheduled' => 'true']);
+ } catch (Throwable $error) {
+ $logger = $container->bound(LoggerInterface::class) ? $container->make(LoggerInterface::class) : null;
+ $message = "Scheduled orchestration {$scheduled->pattern->value} [{$scheduled->name}] failed to start: {$error->getMessage()}";
+ if ($logger instanceof LoggerInterface) {
+ $logger->error($message, ['exception' => $error]);
+ } else {
+ error_log($message);
+ }
+ } finally {
+ $lock?->release($name);
+ }
+ }
+}
diff --git a/packages/orchestration/src/Console/DeadLetterCommand.php b/packages/orchestration/src/Console/DeadLetterCommand.php
new file mode 100644
index 00000000..5cab436b
--- /dev/null
+++ b/packages/orchestration/src/Console/DeadLetterCommand.php
@@ -0,0 +1,37 @@
+stringOption('limit');
+ $limit = is_numeric($limit) ? max(1, (int) $limit) : 50;
+ $name = $this->stringOption('name');
+ $entries = $name === null ? $deadLetters->all($limit) : $deadLetters->byExecutionName($name, $limit);
+
+ $this->table(['Id', 'Execution', 'Correlation id', 'Step', 'Error', 'Retries', 'Created'], array_map(static fn (DeadLetterEntry $entry): array => [
+ $entry->id, $entry->pattern->value.' '.$entry->executionName, $entry->correlationId, $entry->stepId ?? '',
+ $entry->errorMessage, (string) $entry->retryCount, ExecutionState::instant($entry->createdAt),
+ ], $entries));
+
+ return self::SUCCESS;
+ }
+}
diff --git a/packages/orchestration/src/Console/ListExecutionsCommand.php b/packages/orchestration/src/Console/ListExecutionsCommand.php
new file mode 100644
index 00000000..12007c93
--- /dev/null
+++ b/packages/orchestration/src/Console/ListExecutionsCommand.php
@@ -0,0 +1,55 @@
+stringOption('pattern');
+ $status = $this->stringOption('status');
+ $parsedPattern = $pattern === null ? null : ExecutionPattern::tryFrom(strtoupper($pattern));
+ $parsedStatus = $status === null ? null : ExecutionStatus::tryFrom(strtoupper($status));
+
+ if ($pattern !== null && $parsedPattern === null) {
+ $this->components->error("Unknown pattern [{$pattern}]: use saga, tcc or workflow.");
+
+ return self::FAILURE;
+ }
+ if ($status !== null && $parsedStatus === null) {
+ $this->components->error("Unknown status [{$status}].");
+
+ return self::FAILURE;
+ }
+
+ $limit = $this->stringOption('limit');
+ $states = $persistence->find(new ExecutionCriteria($parsedPattern, $parsedStatus, $this->stringOption('name'), is_numeric($limit) ? max(1, (int) $limit) : 50));
+
+ $this->table(['Correlation id', 'Pattern', 'Name', 'Status', 'Updated', 'Waiting on'], array_map(static fn (ExecutionState $state): array => [
+ $state->correlationId, $state->pattern->value, $state->executionName, $state->status->value,
+ ExecutionState::instant($state->updatedAt), $state->waitingOn ?? '',
+ ], $states));
+
+ return self::SUCCESS;
+ }
+}
diff --git a/packages/orchestration/src/Console/OptionReader.php b/packages/orchestration/src/Console/OptionReader.php
new file mode 100644
index 00000000..d46d6966
--- /dev/null
+++ b/packages/orchestration/src/Console/OptionReader.php
@@ -0,0 +1,27 @@
+option($name);
+
+ return is_string($value) && $value !== '' ? $value : null;
+ }
+
+ private function stringArgument(string $name): ?string
+ {
+ $value = $this->argument($name);
+
+ return is_string($value) && $value !== '' ? $value : null;
+ }
+}
diff --git a/packages/orchestration/src/Console/RetryDeadLetterCommand.php b/packages/orchestration/src/Console/RetryDeadLetterCommand.php
new file mode 100644
index 00000000..bbabd663
--- /dev/null
+++ b/packages/orchestration/src/Console/RetryDeadLetterCommand.php
@@ -0,0 +1,41 @@
+` — start the dead letter's definition again with its input. */
+final class RetryDeadLetterCommand extends Command
+{
+ use OptionReader;
+
+ protected $signature = 'firefly:orchestration:dlq:retry {id : the dead-letter id}';
+
+ protected $description = 'Retry an orchestration dead letter.';
+
+ public function handle(DeadLetterRetrier $retrier): int
+ {
+ try {
+ $outcome = $retrier->retry($this->stringArgument('id') ?? '');
+ } catch (OrchestrationException $refused) {
+ $this->components->error($refused->getMessage());
+
+ return self::FAILURE;
+ }
+
+ $line = "{$outcome->pattern->value} [{$outcome->name}] restarted as {$outcome->correlationId}: {$outcome->status->value}.";
+ if ($outcome->success) {
+ $this->components->info($line);
+
+ return self::SUCCESS;
+ }
+
+ $this->components->error($line);
+
+ return self::FAILURE;
+ }
+}
diff --git a/packages/orchestration/src/Console/ShowExecutionCommand.php b/packages/orchestration/src/Console/ShowExecutionCommand.php
new file mode 100644
index 00000000..d42c6684
--- /dev/null
+++ b/packages/orchestration/src/Console/ShowExecutionCommand.php
@@ -0,0 +1,33 @@
+` — one execution's full checkpoint as JSON. */
+final class ShowExecutionCommand extends Command
+{
+ use OptionReader;
+
+ protected $signature = 'firefly:orchestration:show {id : the correlation id}';
+
+ protected $description = 'Show one persisted orchestration execution.';
+
+ public function handle(ExecutionPersistenceProvider $persistence): int
+ {
+ $id = $this->stringArgument('id') ?? '';
+ $state = $persistence->findById($id);
+ if ($state === null) {
+ $this->components->error("No orchestration execution [{$id}] is persisted.");
+
+ return self::FAILURE;
+ }
+
+ $this->line((string) json_encode($state->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION));
+
+ return self::SUCCESS;
+ }
+}
diff --git a/packages/orchestration/src/Console/SignalWorkflowCommand.php b/packages/orchestration/src/Console/SignalWorkflowCommand.php
new file mode 100644
index 00000000..cb9d4282
--- /dev/null
+++ b/packages/orchestration/src/Console/SignalWorkflowCommand.php
@@ -0,0 +1,50 @@
+ [--payload=JSON]` — deliver a signal to a workflow. */
+final class SignalWorkflowCommand extends Command
+{
+ use OptionReader;
+
+ protected $signature = 'firefly:orchestration:signal
+ {id : the workflow correlation id}
+ {signal : the signal name}
+ {--payload= : the payload, as JSON}';
+
+ protected $description = 'Deliver a signal to a durable workflow.';
+
+ public function handle(WorkflowEngine $workflows): int
+ {
+ $payload = null;
+ $raw = $this->stringOption('payload');
+ if ($raw !== null) {
+ try {
+ $payload = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
+ } catch (JsonException $error) {
+ $this->components->error("--payload is not JSON: {$error->getMessage()}");
+
+ return self::FAILURE;
+ }
+ }
+
+ try {
+ $state = $workflows->signal($this->stringArgument('id') ?? '', $this->stringArgument('signal') ?? '', $payload);
+ } catch (OrchestrationException $refused) {
+ $this->components->error($refused->getMessage());
+
+ return self::FAILURE;
+ }
+
+ $this->components->info("Execution {$state->correlationId} is now {$state->status->value}".($state->waitingOn === null ? '.' : " (waiting on {$state->waitingOn})."));
+
+ return self::SUCCESS;
+ }
+}
diff --git a/packages/orchestration/src/Console/TickCommand.php b/packages/orchestration/src/Console/TickCommand.php
new file mode 100644
index 00000000..c255e9e9
--- /dev/null
+++ b/packages/orchestration/src/Console/TickCommand.php
@@ -0,0 +1,43 @@
+resumeDue());
+ $this->components->info("Advanced {$advanced} waiting workflow(s).");
+
+ if ($this->option('no-recovery') === true || ! $settings->recovery->enabled) {
+ return self::SUCCESS;
+ }
+
+ $report = $recovery->recover();
+ $this->components->info(sprintf(
+ 'Recovery: %d stale, %d resumed, %d compensated, %d failed, %d cleaned up.',
+ count($report->stale), count($report->resumed), count($report->compensated), count($report->errors), $report->cleaned,
+ ));
+ foreach ($report->errors as $id => $error) {
+ $this->components->warn("{$id}: {$error}");
+ }
+
+ return $report->errors === [] ? self::SUCCESS : self::FAILURE;
+ }
+}
diff --git a/packages/orchestration/src/Core/Argument/ArgumentResolver.php b/packages/orchestration/src/Core/Argument/ArgumentResolver.php
new file mode 100644
index 00000000..0840e984
--- /dev/null
+++ b/packages/orchestration/src/Core/Argument/ArgumentResolver.php
@@ -0,0 +1,58 @@
+
+ */
+ public function resolve(MethodInvocation $invocation, ExecutionContext $context, mixed $input, mixed $implicit): array
+ {
+ $arguments = [];
+ foreach ($invocation->parameters as $binding) {
+ if ($binding->kind === ParameterBinding::DEFAULT) {
+ continue;
+ }
+
+ $value = $this->value($binding, $context, $input, $implicit);
+ if ($value === null && $binding->required) {
+ throw new OrchestrationException("Required parameter \${$binding->name} of {$invocation->describe()} resolved to null.", 'ORCHESTRATION_REQUIRED_ARGUMENT', 422);
+ }
+
+ $arguments[$binding->name] = $value;
+ }
+
+ return $arguments;
+ }
+
+ private function value(ParameterBinding $binding, ExecutionContext $context, mixed $input, mixed $implicit): mixed
+ {
+ return match ($binding->kind) {
+ ParameterBinding::IMPLICIT => $implicit,
+ ParameterBinding::CONTEXT => $context,
+ ParameterBinding::INPUT => $input,
+ ParameterBinding::INPUT_KEY => is_array($input) ? ($input[$binding->ref] ?? null) : null,
+ ParameterBinding::FROM_STEP => $context->result($binding->ref),
+ ParameterBinding::FROM_COMPENSATION_RESULT => $context->compensationResult($binding->ref),
+ ParameterBinding::COMPENSATION_ERROR => $context->compensationError($binding->ref),
+ ParameterBinding::FROM_TRY => $binding->ref === '' ? $implicit : $context->tryResult($binding->ref),
+ ParameterBinding::HEADER => $context->header($binding->ref),
+ ParameterBinding::HEADERS => $context->headers(),
+ ParameterBinding::VARIABLE => $context->variable($binding->ref),
+ ParameterBinding::VARIABLES => $context->variables(),
+ ParameterBinding::CORRELATION_ID => $context->correlationId,
+ default => throw new OrchestrationException("Unknown parameter binding [{$binding->kind}] for \${$binding->name}; recompile with php artisan firefly:cache.", 'ORCHESTRATION_UNKNOWN_BINDING'),
+ };
+ }
+}
diff --git a/packages/orchestration/src/Core/Argument/CompensationError.php b/packages/orchestration/src/Core/Argument/CompensationError.php
new file mode 100644
index 00000000..cf13aecd
--- /dev/null
+++ b/packages/orchestration/src/Core/Argument/CompensationError.php
@@ -0,0 +1,14 @@
+. */
+#[Attribute(Attribute::TARGET_PARAMETER)]
+final readonly class Headers {}
diff --git a/packages/orchestration/src/Core/Argument/Input.php b/packages/orchestration/src/Core/Argument/Input.php
new file mode 100644
index 00000000..44677f59
--- /dev/null
+++ b/packages/orchestration/src/Core/Argument/Input.php
@@ -0,0 +1,14 @@
+ $this->name, 'kind' => $this->kind, 'ref' => $this->ref, 'required' => $this->required];
+ }
+
+ /** @param array $row */
+ public static function fromArray(array $row): self
+ {
+ return new self(Rows::string($row, 'name'), Rows::string($row, 'kind', self::IMPLICIT), Rows::string($row, 'ref'), Rows::bool($row, 'required'));
+ }
+}
diff --git a/packages/orchestration/src/Core/Argument/Required.php b/packages/orchestration/src/Core/Argument/Required.php
new file mode 100644
index 00000000..30a7885d
--- /dev/null
+++ b/packages/orchestration/src/Core/Argument/Required.php
@@ -0,0 +1,11 @@
+`. */
+#[Attribute(Attribute::TARGET_PARAMETER)]
+final readonly class Variable
+{
+ public function __construct(public string $name) {}
+}
diff --git a/packages/orchestration/src/Core/Argument/Variables.php b/packages/orchestration/src/Core/Argument/Variables.php
new file mode 100644
index 00000000..6e386752
--- /dev/null
+++ b/packages/orchestration/src/Core/Argument/Variables.php
@@ -0,0 +1,11 @@
+. */
+#[Attribute(Attribute::TARGET_PARAMETER)]
+final readonly class Variables {}
diff --git a/packages/orchestration/src/Core/Callback/CallbackDefinition.php b/packages/orchestration/src/Core/Callback/CallbackDefinition.php
new file mode 100644
index 00000000..5e4b2fd7
--- /dev/null
+++ b/packages/orchestration/src/Core/Callback/CallbackDefinition.php
@@ -0,0 +1,106 @@
+ $parameters
+ * @param list $errorTypes class names; empty matches every error
+ * @param list $stepIds step ids; empty matches every step
+ */
+ public function __construct(
+ public ?string $class = null,
+ public ?string $method = null,
+ public array $parameters = [],
+ public bool $async = false,
+ public int $priority = 0,
+ public array $errorTypes = [],
+ public bool $suppressError = false,
+ public array $stepIds = [],
+ public ?Closure $closure = null,
+ ) {}
+
+ /**
+ * @param list $errorTypes
+ * @param list $stepIds
+ */
+ public static function closure(Closure $callback, bool $async = false, int $priority = 0, array $errorTypes = [], bool $suppressError = false, array $stepIds = []): self
+ {
+ return new self(async: $async, priority: $priority, errorTypes: $errorTypes, suppressError: $suppressError, stepIds: $stepIds, closure: $callback);
+ }
+
+ public function matchesError(?Throwable $error): bool
+ {
+ if ($this->errorTypes === [] || $error === null) {
+ return true;
+ }
+
+ foreach ($this->errorTypes as $type) {
+ if (is_a($error, $type)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public function matchesStep(?string $stepId): bool
+ {
+ return $this->stepIds === [] || $stepId === null || in_array($stepId, $this->stepIds, true);
+ }
+
+ public function describe(): string
+ {
+ return $this->closure !== null ? 'closure' : $this->class.'::'.$this->method;
+ }
+
+ /** @return array */
+ public function toArray(): array
+ {
+ if ($this->closure !== null) {
+ throw new LogicException('A closure callback cannot be compiled into a manifest.');
+ }
+
+ return [
+ 'class' => $this->class, 'method' => $this->method, 'parameters' => $this->parameters, 'async' => $this->async,
+ 'priority' => $this->priority, 'errorTypes' => $this->errorTypes, 'suppressError' => $this->suppressError,
+ 'stepIds' => $this->stepIds,
+ ];
+ }
+
+ /** @param array $row */
+ public static function fromArray(array $row): self
+ {
+ return new self(
+ Rows::nullableString($row, 'class'),
+ Rows::nullableString($row, 'method'),
+ Rows::strings($row, 'parameters'),
+ Rows::bool($row, 'async'),
+ Rows::int($row, 'priority'),
+ Rows::strings($row, 'errorTypes'),
+ Rows::bool($row, 'suppressError'),
+ Rows::strings($row, 'stepIds'),
+ );
+ }
+}
diff --git a/packages/orchestration/src/Core/Callback/CallbackInvoker.php b/packages/orchestration/src/Core/Callback/CallbackInvoker.php
new file mode 100644
index 00000000..0fa108f3
--- /dev/null
+++ b/packages/orchestration/src/Core/Callback/CallbackInvoker.php
@@ -0,0 +1,81 @@
+ $callbacks
+ */
+ public function invoke(array $callbacks, ExecutionContext $context, mixed $result = null, ?Throwable $error = null, ?string $stepId = null): void
+ {
+ $eligible = array_values(array_filter(
+ $callbacks,
+ static fn (CallbackDefinition $callback): bool => $callback->matchesError($error) && $callback->matchesStep($stepId),
+ ));
+ usort($eligible, static fn (CallbackDefinition $a, CallbackDefinition $b): int => [$a->async, -$a->priority] <=> [$b->async, -$b->priority]);
+
+ foreach ($eligible as $callback) {
+ try {
+ $this->call($callback, $context, $result, $error);
+ } catch (Throwable $failure) {
+ $this->logger->warning("Orchestration callback {$callback->describe()} for [{$context->executionName}] failed: {$failure->getMessage()}", [
+ 'exception' => $failure,
+ 'correlationId' => $context->correlationId,
+ ]);
+ }
+ }
+ }
+
+ /**
+ * @param list $callbacks
+ */
+ public function suppresses(array $callbacks, Throwable $error): bool
+ {
+ foreach ($callbacks as $callback) {
+ if ($callback->suppressError && $callback->matchesError($error)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private function call(CallbackDefinition $callback, ExecutionContext $context, mixed $result, ?Throwable $error): void
+ {
+ if ($callback->closure !== null) {
+ ($callback->closure)($context, $error ?? $result);
+
+ return;
+ }
+
+ $bean = $this->container->make((string) $callback->class);
+ $method = (string) $callback->method;
+ $arguments = array_map(static fn (string $kind): mixed => match ($kind) {
+ CallbackDefinition::CONTEXT => $context,
+ CallbackDefinition::ERROR => $error,
+ default => $result,
+ }, $callback->parameters);
+
+ if (is_object($bean)) {
+ $bean->{$method}(...$arguments);
+ }
+ }
+}
diff --git a/packages/orchestration/src/Core/Clock.php b/packages/orchestration/src/Core/Clock.php
new file mode 100644
index 00000000..a6cf4c30
--- /dev/null
+++ b/packages/orchestration/src/Core/Clock.php
@@ -0,0 +1,16 @@
+ …`. It lives only in the process that
+ * built it, so it can never be compiled into a manifest — and a builder-defined saga can be resumed after a
+ * crash only by a process that registered the same definition again.
+ */
+final readonly class ClosureInvocation implements Invocation
+{
+ public function __construct(
+ public Closure $closure,
+ public string $label = 'closure',
+ public ?string $variable = null,
+ ) {}
+
+ public function describe(): string
+ {
+ return $this->label;
+ }
+
+ public function setVariable(): ?string
+ {
+ return $this->variable;
+ }
+
+ public function toArray(): array
+ {
+ throw new LogicException("Invocation [{$this->label}] was built from a closure and cannot be compiled into a manifest.");
+ }
+}
diff --git a/packages/orchestration/src/Core/CompensationPolicy.php b/packages/orchestration/src/Core/CompensationPolicy.php
new file mode 100644
index 00000000..d0c88c8d
--- /dev/null
+++ b/packages/orchestration/src/Core/CompensationPolicy.php
@@ -0,0 +1,38 @@
+ 100 and results["check"]["ok"]`.
+ *
+ * lint() runs at boot over every declared condition, with the four names, so a typo or an unknown variable
+ * refuses the application instead of failing the first execution that reaches the step.
+ */
+final class ConditionEvaluator
+{
+ /** @var list */
+ public const array VARIABLES = ['input', 'results', 'variables', 'headers'];
+
+ private readonly ExpressionLanguage $language;
+
+ public function __construct(?ExpressionLanguage $language = null)
+ {
+ $this->language = $language ?? new ExpressionLanguage;
+ }
+
+ public function lint(string $expression, string $where): void
+ {
+ try {
+ $this->language->lint($expression, self::VARIABLES);
+ } catch (SyntaxError $error) {
+ throw new TopologyValidationException("{$where}: condition [{$expression}] does not parse: {$error->getMessage()}", $error);
+ }
+ }
+
+ /**
+ * @param array $values
+ */
+ public function evaluate(string $expression, array $values): bool
+ {
+ try {
+ return (bool) $this->language->evaluate($expression, $values);
+ } catch (Throwable $error) {
+ throw new OrchestrationException("Condition [{$expression}] could not be evaluated: {$error->getMessage()}", 'ORCHESTRATION_CONDITION_FAILED', 500, $error);
+ }
+ }
+}
diff --git a/packages/orchestration/src/Core/EngineSupport.php b/packages/orchestration/src/Core/EngineSupport.php
new file mode 100644
index 00000000..f2bee5d1
--- /dev/null
+++ b/packages/orchestration/src/Core/EngineSupport.php
@@ -0,0 +1,107 @@
+clock->now();
+ }
+
+ public function checkpoint(ExecutionContext $context, ExecutionStatus $status, ?string $failureReason = null, ?ExecutionReport $report = null): ExecutionState
+ {
+ $state = $context->toState($status, $this->now(), $failureReason, $report?->toArray());
+
+ try {
+ $this->persistence->save($state);
+ } catch (Throwable $error) {
+ $this->logger->warning("Orchestration [{$context->executionName}] could not be checkpointed as {$status->value}: {$error->getMessage()}", [
+ 'exception' => $error,
+ 'correlationId' => $context->correlationId,
+ ]);
+ }
+
+ return $state;
+ }
+
+ public function deadLetter(ExecutionContext $context, ?string $stepId, ExecutionStatus $status, Throwable $error): void
+ {
+ $headers = $context->headers();
+ $retries = isset($headers[DeadLetterEntry::RETRY_COUNT_HEADER]) ? (int) $headers[DeadLetterEntry::RETRY_COUNT_HEADER] + 1 : 0;
+ unset($headers[DeadLetterEntry::RETRY_COUNT_HEADER], $headers[DeadLetterEntry::RETRY_OF_HEADER]);
+
+ try {
+ $this->deadLetters->deadLetter(DeadLetterEntry::create(
+ $context->executionName, $context->correlationId, $context->pattern, $stepId, $status, $error,
+ $context->input(), $headers, $this->now(), $retries,
+ ));
+ } catch (Throwable $failure) {
+ $this->logger->warning("Orchestration [{$context->executionName}] could not be dead-lettered: {$failure->getMessage()}", [
+ 'exception' => $failure,
+ 'correlationId' => $context->correlationId,
+ ]);
+ }
+ }
+
+ /**
+ * Publishes an event without letting the transport fail the execution it describes: a broker that is down
+ * costs a log line, not a compensated saga.
+ */
+ public function publish(OrchestrationEventPublisher $publisher, OrchestrationEvent $event): void
+ {
+ try {
+ $publisher->publish($event);
+ } catch (Throwable $error) {
+ $this->logger->warning("Orchestration event {$event->eventType} for [{$event->executionName}] could not be published: {$error->getMessage()}", [
+ 'exception' => $error,
+ 'correlationId' => $event->correlationId,
+ ]);
+ }
+ }
+
+ public function elapsedMs(ExecutionContext $context): int
+ {
+ return max(0, (int) round(((float) $this->now()->format('U.u') - (float) $context->startedAt()->format('U.u')) * 1000));
+ }
+}
diff --git a/packages/orchestration/src/Core/Exception/BackpressureException.php b/packages/orchestration/src/Core/Exception/BackpressureException.php
new file mode 100644
index 00000000..98568a29
--- /dev/null
+++ b/packages/orchestration/src/Core/Exception/BackpressureException.php
@@ -0,0 +1,14 @@
+ $uncompensatedSteps
+ */
+ public function __construct(
+ string $message,
+ ?Throwable $previous = null,
+ public readonly ?string $stoppedAt = null,
+ public readonly array $uncompensatedSteps = [],
+ ) {
+ parent::__construct($message, 'ORCHESTRATION_COMPENSATION_FAILED', 500, $previous);
+ }
+}
diff --git a/packages/orchestration/src/Core/Exception/DeadLetterNotFoundException.php b/packages/orchestration/src/Core/Exception/DeadLetterNotFoundException.php
new file mode 100644
index 00000000..775dccd8
--- /dev/null
+++ b/packages/orchestration/src/Core/Exception/DeadLetterNotFoundException.php
@@ -0,0 +1,14 @@
+value} executions are disabled ({$key} is false).", 'ORCHESTRATION_DISABLED', 503);
+ }
+}
diff --git a/packages/orchestration/src/Core/Exception/SignalTimeoutException.php b/packages/orchestration/src/Core/Exception/SignalTimeoutException.php
new file mode 100644
index 00000000..9231fa30
--- /dev/null
+++ b/packages/orchestration/src/Core/Exception/SignalTimeoutException.php
@@ -0,0 +1,8 @@
+settings->maxInFlight > 0 && $this->persistence->countInFlight() >= $this->settings->maxInFlight) {
+ throw new BackpressureException("Orchestration [{$name}] refused: {$this->settings->maxInFlight} executions are already in flight (firefly.orchestration.backpressure.max-in-flight).");
+ }
+
+ if ($this->breaker === null) {
+ return $execution();
+ }
+
+ try {
+ return $this->breaker->call(static function () use ($execution, $succeeded): object {
+ $result = $execution();
+ if (! $succeeded($result)) {
+ throw new FailedExecution($result);
+ }
+
+ return $result;
+ });
+ } catch (FailedExecution $failed) {
+ /** @var T */
+ return $failed->result;
+ }
+ }
+}
diff --git a/packages/orchestration/src/Core/ExecutionContext.php b/packages/orchestration/src/Core/ExecutionContext.php
new file mode 100644
index 00000000..3ce48706
--- /dev/null
+++ b/packages/orchestration/src/Core/ExecutionContext.php
@@ -0,0 +1,589 @@
+ */
+ private array $variables = [];
+
+ /** @var array */
+ private array $stepResults = [];
+
+ /** @var array */
+ private array $stepStatuses = [];
+
+ /** @var array */
+ private array $stepAttempts = [];
+
+ /** @var array */
+ private array $stepLatenciesMs = [];
+
+ /** @var array */
+ private array $stepStartedAt = [];
+
+ /** @var array */
+ private array $stepInputs = [];
+
+ /** @var list */
+ private array $completionOrder = [];
+
+ /** @var array */
+ private array $compensationResults = [];
+
+ /** @var array */
+ private array $compensationErrors = [];
+
+ /** @var array */
+ private array $stepErrors = [];
+
+ /** @var array */
+ private array $tryResults = [];
+
+ private ?TccPhase $currentPhase = null;
+
+ /** @var array */
+ private array $idempotencyKeys = [];
+
+ /** @var list> */
+ private array $topologyLayers = [];
+
+ /** @var array */
+ private array $searchAttributes = [];
+
+ /** @var array */
+ private array $signals = [];
+
+ /** @var array */
+ private array $timers = [];
+
+ /**
+ * The instant each timer was last checked (timerFired()) in THIS run. Not persisted: it only tells
+ * wakeAt() which due timers a check actually saw fire, and every run checks its own waits again.
+ *
+ * @var array
+ */
+ private array $timerChecks = [];
+
+ private ?string $waitingOn = null;
+
+ private ?string $parentCorrelationId = null;
+
+ /** @var array */
+ private array $children = [];
+
+ private ?DateTimeImmutable $deadline = null;
+
+ /**
+ * @param array $input
+ * @param array $headers
+ */
+ private function __construct(
+ public readonly string $correlationId,
+ public readonly string $executionName,
+ public readonly ExecutionPattern $pattern,
+ private readonly DateTimeImmutable $startedAt,
+ private readonly array $input,
+ private array $headers,
+ ) {}
+
+ /**
+ * @param array $input
+ * @param array $headers
+ */
+ public static function start(ExecutionPattern $pattern, string $name, array $input, array $headers, ?string $correlationId, DateTimeImmutable $now): self
+ {
+ return new self($correlationId ?? Str::uuid()->toString(), $name, $pattern, $now, $input, $headers);
+ }
+
+ public static function fromState(ExecutionState $state): self
+ {
+ $context = new self($state->correlationId, $state->executionName, $state->pattern, $state->startedAt, $state->input, $state->headers);
+ $context->variables = $state->variables;
+ $context->stepResults = $state->stepResults;
+ $context->stepStatuses = $state->stepStatuses;
+ $context->stepAttempts = $state->stepAttempts;
+ $context->stepLatenciesMs = $state->stepLatenciesMs;
+ $context->stepInputs = $state->stepInputs;
+ $context->completionOrder = $state->completionOrder;
+ $context->compensationResults = $state->compensationResults;
+ $context->compensationErrors = array_map(static fn (string $message): Throwable => new RuntimeException($message), $state->compensationErrors);
+ $context->stepErrors = array_map(static fn (string $message): Throwable => new RuntimeException($message), $state->stepErrors);
+ $context->tryResults = $state->tryResults;
+ $context->currentPhase = $state->currentPhase;
+ $context->idempotencyKeys = array_fill_keys($state->idempotencyKeys, true);
+ $context->topologyLayers = $state->topologyLayers;
+ $context->searchAttributes = $state->searchAttributes;
+ $context->signals = $state->signals;
+ $context->timers = array_map(static fn (string $at): DateTimeImmutable => ExecutionState::parseInstant($at), $state->timers);
+ $context->waitingOn = $state->waitingOn;
+ $context->parentCorrelationId = $state->parentCorrelationId;
+ $context->children = $state->children;
+
+ return $context;
+ }
+
+ /**
+ * @param array|null $report
+ */
+ public function toState(ExecutionStatus $status, DateTimeImmutable $now, ?string $failureReason = null, ?array $report = null): ExecutionState
+ {
+ /** @var array $input */
+ $input = StateSerializer::normalize($this->input, 'input');
+ /** @var array $variables */
+ $variables = StateSerializer::normalize($this->variables, 'variables');
+ /** @var array $results */
+ $results = StateSerializer::normalize($this->stepResults, 'stepResults');
+ /** @var array $stepInputs */
+ $stepInputs = StateSerializer::normalize($this->stepInputs, 'stepInputs');
+ /** @var array $compensationResults */
+ $compensationResults = StateSerializer::normalize($this->compensationResults, 'compensationResults');
+ /** @var array $tryResults */
+ $tryResults = StateSerializer::normalize($this->tryResults, 'tryResults');
+ /** @var array $signals */
+ $signals = StateSerializer::normalize($this->signals, 'signals');
+ /** @var array|null $normalizedReport */
+ $normalizedReport = $report === null ? null : StateSerializer::normalize($report, 'report');
+
+ return new ExecutionState(
+ correlationId: $this->correlationId,
+ executionName: $this->executionName,
+ pattern: $this->pattern,
+ status: $status,
+ startedAt: $this->startedAt,
+ updatedAt: $now,
+ input: $input,
+ headers: $this->headers,
+ variables: $variables,
+ stepResults: $results,
+ stepStatuses: $this->stepStatuses,
+ stepAttempts: $this->stepAttempts,
+ stepLatenciesMs: $this->stepLatenciesMs,
+ stepInputs: $stepInputs,
+ completionOrder: $this->completionOrder,
+ compensationResults: $compensationResults,
+ compensationErrors: array_map(static fn (Throwable $error): string => $error->getMessage(), $this->compensationErrors),
+ stepErrors: array_map(static fn (Throwable $error): string => $error->getMessage(), $this->stepErrors),
+ tryResults: $tryResults,
+ currentPhase: $this->currentPhase,
+ idempotencyKeys: array_map('strval', array_keys($this->idempotencyKeys)),
+ topologyLayers: $this->topologyLayers,
+ failureReason: $failureReason,
+ completedAt: $status->isTerminal() ? $now : null,
+ wakeAt: $status === ExecutionStatus::Waiting ? $this->wakeAt($now) : null,
+ searchAttributes: $this->searchAttributes,
+ signals: $signals,
+ timers: array_map(static fn (DateTimeImmutable $at): string => ExecutionState::instant($at), $this->timers),
+ waitingOn: $this->waitingOn,
+ parentCorrelationId: $this->parentCorrelationId,
+ children: $this->children,
+ report: $normalizedReport,
+ );
+ }
+
+ public function startedAt(): DateTimeImmutable
+ {
+ return $this->startedAt;
+ }
+
+ /** @return array */
+ public function input(): array
+ {
+ return $this->input;
+ }
+
+ public function stepInput(string $stepId): mixed
+ {
+ return $this->stepInputs[$stepId] ?? null;
+ }
+
+ public function putStepInput(string $stepId, mixed $input): void
+ {
+ $this->stepInputs[$stepId] = $input;
+ }
+
+ /** @return array */
+ public function headers(): array
+ {
+ return $this->headers;
+ }
+
+ public function header(string $name): ?string
+ {
+ return $this->headers[$name] ?? null;
+ }
+
+ public function setHeader(string $name, string $value): void
+ {
+ $this->headers[$name] = $value;
+ }
+
+ /** @return array */
+ public function variables(): array
+ {
+ return $this->variables;
+ }
+
+ public function variable(string $name): mixed
+ {
+ return $this->variables[$name] ?? null;
+ }
+
+ public function hasVariable(string $name): bool
+ {
+ return array_key_exists($name, $this->variables);
+ }
+
+ public function setVariable(string $name, mixed $value): void
+ {
+ $this->variables[$name] = $value;
+ }
+
+ public function removeVariable(string $name): void
+ {
+ unset($this->variables[$name]);
+ }
+
+ /** @return array */
+ public function results(): array
+ {
+ return $this->stepResults;
+ }
+
+ public function result(string $stepId): mixed
+ {
+ return $this->stepResults[$stepId] ?? null;
+ }
+
+ public function hasResult(string $stepId): bool
+ {
+ return array_key_exists($stepId, $this->stepResults);
+ }
+
+ public function putResult(string $stepId, mixed $result): void
+ {
+ $this->stepResults[$stepId] = $result;
+ }
+
+ public function stepStatus(string $stepId): StepStatus
+ {
+ return $this->stepStatuses[$stepId] ?? StepStatus::Pending;
+ }
+
+ public function setStepStatus(string $stepId, StepStatus $status): void
+ {
+ $this->stepStatuses[$stepId] = $status;
+ }
+
+ /** @return array */
+ public function stepStatuses(): array
+ {
+ return $this->stepStatuses;
+ }
+
+ public function attempts(string $stepId): int
+ {
+ return $this->stepAttempts[$stepId] ?? 0;
+ }
+
+ public function recordAttempt(string $stepId): int
+ {
+ return $this->stepAttempts[$stepId] = $this->attempts($stepId) + 1;
+ }
+
+ public function latencyMs(string $stepId): int
+ {
+ return $this->stepLatenciesMs[$stepId] ?? 0;
+ }
+
+ public function setLatencyMs(string $stepId, int $milliseconds): void
+ {
+ $this->stepLatenciesMs[$stepId] = $milliseconds;
+ }
+
+ public function markStepStarted(string $stepId, DateTimeImmutable $at): void
+ {
+ $this->stepStartedAt[$stepId] = $at;
+ }
+
+ public function stepStartedAt(string $stepId): ?DateTimeImmutable
+ {
+ return $this->stepStartedAt[$stepId] ?? null;
+ }
+
+ /** @return list */
+ public function completionOrder(): array
+ {
+ return $this->completionOrder;
+ }
+
+ public function markCompleted(string $stepId): void
+ {
+ if (! in_array($stepId, $this->completionOrder, true)) {
+ $this->completionOrder[] = $stepId;
+ }
+ }
+
+ public function compensationResult(string $stepId): mixed
+ {
+ return $this->compensationResults[$stepId] ?? null;
+ }
+
+ /** @return array */
+ public function compensationResults(): array
+ {
+ return $this->compensationResults;
+ }
+
+ public function putCompensationResult(string $stepId, mixed $result): void
+ {
+ $this->compensationResults[$stepId] = $result;
+ }
+
+ public function compensationError(string $stepId): ?Throwable
+ {
+ return $this->compensationErrors[$stepId] ?? null;
+ }
+
+ /** @return array */
+ public function compensationErrors(): array
+ {
+ return $this->compensationErrors;
+ }
+
+ public function putCompensationError(string $stepId, Throwable $error): void
+ {
+ $this->compensationErrors[$stepId] = $error;
+ }
+
+ public function stepError(string $stepId): ?Throwable
+ {
+ return $this->stepErrors[$stepId] ?? null;
+ }
+
+ /** @return array */
+ public function stepErrors(): array
+ {
+ return $this->stepErrors;
+ }
+
+ public function putStepError(string $stepId, Throwable $error): void
+ {
+ $this->stepErrors[$stepId] = $error;
+ }
+
+ public function clearStepError(string $stepId): void
+ {
+ unset($this->stepErrors[$stepId]);
+ }
+
+ public function tryResult(string $participantId): mixed
+ {
+ return $this->tryResults[$participantId] ?? null;
+ }
+
+ /** @return array */
+ public function tryResults(): array
+ {
+ return $this->tryResults;
+ }
+
+ public function putTryResult(string $participantId, mixed $result): void
+ {
+ $this->tryResults[$participantId] = $result;
+ }
+
+ public function currentPhase(): ?TccPhase
+ {
+ return $this->currentPhase;
+ }
+
+ public function setCurrentPhase(?TccPhase $phase): void
+ {
+ $this->currentPhase = $phase;
+ }
+
+ public function hasIdempotencyKey(string $key): bool
+ {
+ return isset($this->idempotencyKeys[$key]);
+ }
+
+ public function addIdempotencyKey(string $key): void
+ {
+ $this->idempotencyKeys[$key] = true;
+ }
+
+ /** @return list> */
+ public function topologyLayers(): array
+ {
+ return $this->topologyLayers;
+ }
+
+ /** @param list> $layers */
+ public function setTopologyLayers(array $layers): void
+ {
+ $this->topologyLayers = $layers;
+ }
+
+ /** @return array */
+ public function searchAttributes(): array
+ {
+ return $this->searchAttributes;
+ }
+
+ public function setSearchAttribute(string $key, string|int|float|bool $value): void
+ {
+ $this->searchAttributes[$key] = $value;
+ }
+
+ /** @return array */
+ public function signals(): array
+ {
+ return $this->signals;
+ }
+
+ public function hasSignal(string $name): bool
+ {
+ return array_key_exists($name, $this->signals);
+ }
+
+ public function receiveSignal(string $name, mixed $payload): void
+ {
+ $this->signals[$name] = $payload;
+ }
+
+ public function consumeSignal(string $name): mixed
+ {
+ $payload = $this->signals[$name] ?? null;
+ unset($this->signals[$name]);
+
+ return $payload;
+ }
+
+ /** @return array */
+ public function timers(): array
+ {
+ return $this->timers;
+ }
+
+ public function hasTimer(string $timerId): bool
+ {
+ return isset($this->timers[$timerId]);
+ }
+
+ public function scheduleTimer(string $timerId, DateTimeImmutable $fireAt): void
+ {
+ $this->timers[$timerId] = $fireAt;
+ unset($this->timerChecks[$timerId]);
+ }
+
+ public function timerFired(string $timerId, DateTimeImmutable $now): bool
+ {
+ if (! isset($this->timers[$timerId])) {
+ return false;
+ }
+ $this->timerChecks[$timerId] = $now;
+
+ return $this->timers[$timerId] <= $now;
+ }
+
+ public function removeTimer(string $timerId): void
+ {
+ unset($this->timers[$timerId], $this->timerChecks[$timerId]);
+ }
+
+ public function waitingOn(): ?string
+ {
+ return $this->waitingOn;
+ }
+
+ public function setWaitingOn(?string $stepId): void
+ {
+ $this->waitingOn = $stepId;
+ }
+
+ public function parentCorrelationId(): ?string
+ {
+ return $this->parentCorrelationId;
+ }
+
+ public function setParentCorrelationId(?string $correlationId): void
+ {
+ $this->parentCorrelationId = $correlationId;
+ }
+
+ /** @return array */
+ public function children(): array
+ {
+ return $this->children;
+ }
+
+ public function child(string $stepId): ?string
+ {
+ return $this->children[$stepId] ?? null;
+ }
+
+ public function setChild(string $stepId, string $childCorrelationId): void
+ {
+ $this->children[$stepId] = $childCorrelationId;
+ }
+
+ public function deadline(): ?DateTimeImmutable
+ {
+ return $this->deadline;
+ }
+
+ public function setDeadline(?DateTimeImmutable $deadline): void
+ {
+ $this->deadline = $deadline;
+ }
+
+ /**
+ * The earliest PENDING instant. A timer a check of this run saw fire is being handled by the run that
+ * checkpoints now (the step is still waiting on something else — a signal of a WaitForAll), and a due timer
+ * nothing in this run checked belongs to a run that did not get that far (a tick back-off), so counting
+ * either would make the tick wake this execution on every pass for nothing. A timer is judged against the
+ * instant it was CHECKED, not the checkpoint instant: a wait is evaluated a moment before the checkpoint is
+ * written, and a timer that came due in between was never seen firing — dropping it would leave a WAITING
+ * execution with no wake-up, which no tick ever looks at again. Its wakeAt is then already past, and the
+ * next tick drives it.
+ */
+ private function wakeAt(DateTimeImmutable $now): ?DateTimeImmutable
+ {
+ $candidates = [];
+ foreach ($this->timers as $timerId => $at) {
+ if ($at > ($this->timerChecks[$timerId] ?? $now)) {
+ $candidates[] = $at;
+ }
+ }
+ if ($this->deadline !== null) {
+ $candidates[] = $this->deadline;
+ }
+
+ return $candidates === [] ? null : min($candidates);
+ }
+}
diff --git a/packages/orchestration/src/Core/ExecutionLock.php b/packages/orchestration/src/Core/ExecutionLock.php
new file mode 100644
index 00000000..a468e84b
--- /dev/null
+++ b/packages/orchestration/src/Core/ExecutionLock.php
@@ -0,0 +1,73 @@
+ */
+ private array $held = [];
+
+ public function __construct(
+ private readonly DistributedLock $lock,
+ private readonly float $ttlSeconds = 300.0,
+ private readonly float $waitSeconds = 5.0,
+ ) {}
+
+ public static function name(string $correlationId): string
+ {
+ return 'firefly:orchestration:execution:'.$correlationId;
+ }
+
+ /**
+ * @template T
+ *
+ * @param Closure(): T $callback
+ * @return T
+ */
+ public function synchronized(string $correlationId, Closure $callback): mixed
+ {
+ $name = self::name($correlationId);
+
+ if (isset($this->held[$name])) {
+ $this->held[$name]++;
+ try {
+ return $callback();
+ } finally {
+ $this->held[$name]--;
+ }
+ }
+
+ $deadline = microtime(true) + $this->waitSeconds;
+ while (! $this->lock->tryAcquire($name, $this->ttlSeconds)) {
+ if (microtime(true) >= $deadline) {
+ throw new ExecutionLockedException($correlationId, $this->waitSeconds);
+ }
+ usleep(50_000);
+ }
+
+ $this->held[$name] = 1;
+ try {
+ return $callback();
+ } finally {
+ unset($this->held[$name]);
+ $this->lock->release($name);
+ }
+ }
+}
diff --git a/packages/orchestration/src/Core/ExecutionOutcome.php b/packages/orchestration/src/Core/ExecutionOutcome.php
new file mode 100644
index 00000000..ac8b4feb
--- /dev/null
+++ b/packages/orchestration/src/Core/ExecutionOutcome.php
@@ -0,0 +1,58 @@
+ $payload
+ */
+ public function __construct(
+ public string $correlationId,
+ public ExecutionPattern $pattern,
+ public string $name,
+ public ExecutionStatus $status,
+ public bool $success,
+ public array $payload,
+ ) {}
+
+ public static function fromSaga(SagaResult $result): self
+ {
+ return new self($result->correlationId, ExecutionPattern::Saga, $result->sagaName, $result->status, $result->success, $result->toArray());
+ }
+
+ public static function fromTcc(TccResult $result): self
+ {
+ return new self($result->correlationId, ExecutionPattern::Tcc, $result->tccName, $result->status, $result->success, $result->toArray());
+ }
+
+ public static function fromWorkflow(ExecutionState $state): self
+ {
+ $success = ! in_array($state->status, [ExecutionStatus::Failed, ExecutionStatus::TimedOut, ExecutionStatus::Cancelled, ExecutionStatus::CompensationFailed], true);
+
+ return new self($state->correlationId, ExecutionPattern::Workflow, $state->executionName, $state->status, $success, $state->toArray());
+ }
+
+ /** @return array */
+ public function toArray(): array
+ {
+ return [
+ 'correlationId' => $this->correlationId,
+ 'pattern' => $this->pattern->value,
+ 'name' => $this->name,
+ 'status' => $this->status->value,
+ 'success' => $this->success,
+ 'result' => $this->payload,
+ ];
+ }
+}
diff --git a/packages/orchestration/src/Core/ExecutionPattern.php b/packages/orchestration/src/Core/ExecutionPattern.php
new file mode 100644
index 00000000..912d718d
--- /dev/null
+++ b/packages/orchestration/src/Core/ExecutionPattern.php
@@ -0,0 +1,13 @@
+ */
+ public static function terminal(): array
+ {
+ return [self::Completed, self::Failed, self::Cancelled, self::TimedOut, self::Confirmed, self::Canceled, self::CompensationFailed];
+ }
+
+ /** @return list */
+ public static function recoverable(): array
+ {
+ return [self::Pending, self::Running, self::Compensating, self::Trying, self::Confirming, self::Canceling];
+ }
+}
diff --git a/packages/orchestration/src/Core/FailedExecution.php b/packages/orchestration/src/Core/FailedExecution.php
new file mode 100644
index 00000000..ebd33335
--- /dev/null
+++ b/packages/orchestration/src/Core/FailedExecution.php
@@ -0,0 +1,21 @@
+ */
+ public function toArray(): array;
+}
diff --git a/packages/orchestration/src/Core/InvocationRunner.php b/packages/orchestration/src/Core/InvocationRunner.php
new file mode 100644
index 00000000..dbc1a0bf
--- /dev/null
+++ b/packages/orchestration/src/Core/InvocationRunner.php
@@ -0,0 +1,47 @@
+ ($invocation->closure)($implicit, $context),
+ $invocation instanceof MethodInvocation => $this->call($invocation, $context, $input, $implicit),
+ default => throw new LogicException('Unsupported orchestration invocation '.$invocation::class.'.'),
+ };
+
+ $variable = $invocation->setVariable();
+ if ($variable !== null) {
+ $context->setVariable($variable, $result);
+ }
+
+ return $result;
+ }
+
+ private function call(MethodInvocation $invocation, ExecutionContext $context, mixed $input, mixed $implicit): mixed
+ {
+ $bean = $this->container->make($invocation->class);
+ if (! is_object($bean) || ! method_exists($bean, $invocation->method)) {
+ throw new OrchestrationException("{$invocation->describe()} does not exist; recompile with php artisan firefly:cache.", 'ORCHESTRATION_UNKNOWN_METHOD');
+ }
+
+ $method = $invocation->method;
+
+ return $bean->{$method}(...$this->arguments->resolve($invocation, $context, $input, $implicit));
+ }
+}
diff --git a/packages/orchestration/src/Core/MethodInvocation.php b/packages/orchestration/src/Core/MethodInvocation.php
new file mode 100644
index 00000000..ea233168
--- /dev/null
+++ b/packages/orchestration/src/Core/MethodInvocation.php
@@ -0,0 +1,58 @@
+ $parameters
+ */
+ public function __construct(
+ public string $class,
+ public string $method,
+ public array $parameters = [],
+ public ?string $variable = null,
+ ) {}
+
+ public function describe(): string
+ {
+ return $this->class.'::'.$this->method;
+ }
+
+ public function setVariable(): ?string
+ {
+ return $this->variable;
+ }
+
+ /** @return array */
+ public function toArray(): array
+ {
+ return [
+ 'class' => $this->class,
+ 'method' => $this->method,
+ 'parameters' => array_map(static fn (ParameterBinding $binding): array => $binding->toArray(), $this->parameters),
+ 'setVariable' => $this->variable,
+ ];
+ }
+
+ /** @param array $row */
+ public static function fromArray(array $row): self
+ {
+ return new self(
+ Rows::string($row, 'class'),
+ Rows::string($row, 'method'),
+ array_map(static fn (array $binding): ParameterBinding => ParameterBinding::fromArray($binding), Rows::rows($row, 'parameters')),
+ Rows::nullableString($row, 'setVariable'),
+ );
+ }
+}
diff --git a/packages/orchestration/src/Core/Orchestrator.php b/packages/orchestration/src/Core/Orchestrator.php
new file mode 100644
index 00000000..77130731
--- /dev/null
+++ b/packages/orchestration/src/Core/Orchestrator.php
@@ -0,0 +1,59 @@
+ $input
+ * @param array $headers
+ */
+ public function start(ExecutionPattern $pattern, string $name, array $input = [], array $headers = [], ?string $correlationId = null): ExecutionOutcome
+ {
+ return match ($pattern) {
+ ExecutionPattern::Saga => ExecutionOutcome::fromSaga($this->sagas->execute($name, $input, $headers, $correlationId)),
+ ExecutionPattern::Tcc => ExecutionOutcome::fromTcc($this->tccs->execute($name, $input, $headers, $correlationId)),
+ ExecutionPattern::Workflow => ExecutionOutcome::fromWorkflow($this->workflows->start($name, $input, $headers, $correlationId)),
+ };
+ }
+
+ public function find(string $correlationId): ?ExecutionState
+ {
+ return $this->persistence->findById($correlationId);
+ }
+
+ public function sagas(): SagaEngine
+ {
+ return $this->sagas;
+ }
+
+ public function tccs(): TccEngine
+ {
+ return $this->tccs;
+ }
+
+ public function workflows(): WorkflowEngine
+ {
+ return $this->workflows;
+ }
+}
diff --git a/packages/orchestration/src/Core/Rows.php b/packages/orchestration/src/Core/Rows.php
new file mode 100644
index 00000000..955dcdb1
--- /dev/null
+++ b/packages/orchestration/src/Core/Rows.php
@@ -0,0 +1,169 @@
+ $row */
+ public static function string(array $row, string $key, string $default = ''): string
+ {
+ $value = $row[$key] ?? null;
+
+ return match (true) {
+ is_string($value) => $value,
+ is_int($value), is_float($value) => (string) $value,
+ default => $default,
+ };
+ }
+
+ /** @param array $row */
+ public static function nullableString(array $row, string $key): ?string
+ {
+ $value = $row[$key] ?? null;
+
+ return is_string($value) ? $value : null;
+ }
+
+ /** @param array $row */
+ public static function int(array $row, string $key, int $default = 0): int
+ {
+ $value = $row[$key] ?? null;
+
+ return match (true) {
+ is_int($value) => $value,
+ is_numeric($value) => (int) $value,
+ default => $default,
+ };
+ }
+
+ /** @param array $row */
+ public static function float(array $row, string $key, float $default = 0.0): float
+ {
+ $value = $row[$key] ?? null;
+
+ return is_numeric($value) ? (float) $value : $default;
+ }
+
+ /** @param array $row */
+ public static function bool(array $row, string $key, bool $default = false): bool
+ {
+ $value = $row[$key] ?? null;
+
+ return is_bool($value) ? $value : $default;
+ }
+
+ /**
+ * @param array $row
+ * @return list
+ */
+ public static function list(array $row, string $key): array
+ {
+ $value = $row[$key] ?? null;
+
+ return is_array($value) ? array_values($value) : [];
+ }
+
+ /**
+ * @param array $row
+ * @return list
+ */
+ public static function strings(array $row, string $key): array
+ {
+ $strings = [];
+ foreach (self::list($row, $key) as $value) {
+ if (is_string($value) || is_int($value)) {
+ $strings[] = (string) $value;
+ }
+ }
+
+ return $strings;
+ }
+
+ /**
+ * @param array $row
+ * @return array
+ */
+ public static function map(array $row, string $key): array
+ {
+ $value = $row[$key] ?? null;
+ if (! is_array($value)) {
+ return [];
+ }
+
+ $map = [];
+ foreach ($value as $k => $v) {
+ $map[(string) $k] = $v;
+ }
+
+ return $map;
+ }
+
+ /**
+ * @param array $row
+ * @return array|null
+ */
+ public static function nullableMap(array $row, string $key): ?array
+ {
+ return is_array($row[$key] ?? null) ? self::map($row, $key) : null;
+ }
+
+ /**
+ * @param array $row
+ * @return array
+ */
+ public static function stringMap(array $row, string $key): array
+ {
+ $map = [];
+ foreach (self::map($row, $key) as $k => $v) {
+ if (is_bool($v)) {
+ $map[$k] = $v ? 'true' : 'false';
+ } elseif (is_string($v) || is_int($v) || is_float($v)) {
+ $map[$k] = (string) $v;
+ }
+ }
+
+ return $map;
+ }
+
+ /**
+ * @param array $row
+ * @return array
+ */
+ public static function intMap(array $row, string $key): array
+ {
+ $map = [];
+ foreach (self::map($row, $key) as $k => $v) {
+ if (is_numeric($v)) {
+ $map[$k] = (int) $v;
+ }
+ }
+
+ return $map;
+ }
+
+ /**
+ * @param array $row
+ * @return list>
+ */
+ public static function rows(array $row, string $key): array
+ {
+ $rows = [];
+ foreach (self::list($row, $key) as $value) {
+ if (is_array($value)) {
+ $rows[] = self::map(['row' => $value], 'row');
+ }
+ }
+
+ return $rows;
+ }
+}
diff --git a/packages/orchestration/src/Core/StepInvoker.php b/packages/orchestration/src/Core/StepInvoker.php
new file mode 100644
index 00000000..9314c1b5
--- /dev/null
+++ b/packages/orchestration/src/Core/StepInvoker.php
@@ -0,0 +1,49 @@
+recordAttempt($stepId);
+
+ return $this->tracing->traceStep($executionName, $stepId, $context->correlationId, function () use ($invocation, $options, $context, $input, $implicit): mixed {
+ $call = fn (): mixed => $this->runner->run($invocation, $context, $input, $implicit);
+
+ return $options->timeoutMs > 0 ? (new TimeLimiter($options->timeoutMs / 1000))->call($call) : $call();
+ });
+ };
+
+ if ($options->retry <= 0) {
+ return $attempt();
+ }
+
+ $wait = $options->backoffMs / 1000;
+
+ return (new Retry(
+ maxAttempts: $options->retry + 1,
+ waitDuration: $wait,
+ jitter: $options->jitter ? $wait * $options->jitterFactor : 0.0,
+ ))->call($attempt);
+ }
+}
diff --git a/packages/orchestration/src/Core/StepOptions.php b/packages/orchestration/src/Core/StepOptions.php
new file mode 100644
index 00000000..7df3c704
--- /dev/null
+++ b/packages/orchestration/src/Core/StepOptions.php
@@ -0,0 +1,54 @@
+retry, $this->backoffMs, $this->timeoutMs, $this->jitter, $this->jitterFactor);
+ }
+
+ /** @return array{retry: int, backoffMs: int, timeoutMs: int, jitter: bool, jitterFactor: float, idempotencyKey: string} */
+ public function toArray(): array
+ {
+ return [
+ 'retry' => $this->retry, 'backoffMs' => $this->backoffMs, 'timeoutMs' => $this->timeoutMs,
+ 'jitter' => $this->jitter, 'jitterFactor' => $this->jitterFactor, 'idempotencyKey' => $this->idempotencyKey,
+ ];
+ }
+
+ /** @param array $row */
+ public static function fromArray(array $row): self
+ {
+ return new self(
+ Rows::int($row, 'retry'),
+ Rows::int($row, 'backoffMs'),
+ Rows::int($row, 'timeoutMs'),
+ Rows::bool($row, 'jitter'),
+ Rows::float($row, 'jitterFactor', 0.5),
+ Rows::string($row, 'idempotencyKey'),
+ );
+ }
+}
diff --git a/packages/orchestration/src/Core/StepStatus.php b/packages/orchestration/src/Core/StepStatus.php
new file mode 100644
index 00000000..de16146c
--- /dev/null
+++ b/packages/orchestration/src/Core/StepStatus.php
@@ -0,0 +1,36 @@
+> $dependencies step id => the ids it depends on, in declaration order;
+ * a numeric id arrives as an int key
+ * @return list>
+ */
+ public static function layers(string $owner, array $dependencies): array
+ {
+ self::validate($owner, $dependencies);
+
+ $remaining = $dependencies;
+ $placed = [];
+ $layers = [];
+ while ($remaining !== []) {
+ $layer = [];
+ foreach ($remaining as $id => $dependsOn) {
+ if (array_diff($dependsOn, array_map('strval', array_keys($placed))) === []) {
+ $layer[] = (string) $id;
+ }
+ }
+ foreach ($layer as $id) {
+ $placed[$id] = true;
+ unset($remaining[$id]);
+ }
+ $layers[] = $layer;
+ }
+
+ return $layers;
+ }
+
+ /**
+ * @param array> $dependencies
+ */
+ public static function validate(string $owner, array $dependencies): void
+ {
+ if ($dependencies === []) {
+ throw new TopologyValidationException("{$owner} declares no steps.");
+ }
+
+ foreach ($dependencies as $id => $dependsOn) {
+ $id = (string) $id;
+ foreach ($dependsOn as $dependency) {
+ if ($dependency === $id) {
+ throw new TopologyValidationException("{$owner}: step [{$id}] depends on itself.");
+ }
+ if (! array_key_exists($dependency, $dependencies)) {
+ throw new TopologyValidationException("{$owner}: step [{$id}] depends on [{$dependency}], which is not a step of it.");
+ }
+ }
+ }
+
+ $state = [];
+ foreach (array_keys($dependencies) as $id) {
+ self::visit($owner, (string) $id, $dependencies, $state, []);
+ }
+ }
+
+ /**
+ * @param array> $dependencies
+ * @param array $state 1 = on the current path, 2 = finished
+ * @param list $path
+ */
+ private static function visit(string $owner, string $id, array $dependencies, array &$state, array $path): void
+ {
+ $mark = $state[$id] ?? 0;
+ if ($mark === 2) {
+ return;
+ }
+ if ($mark === 1) {
+ $start = array_search($id, $path, true);
+ $cycle = [...array_slice($path, $start === false ? 0 : $start), $id];
+
+ throw new TopologyValidationException("{$owner}: the steps form a cycle: ".implode(' -> ', $cycle).'.');
+ }
+
+ $state[$id] = 1;
+ foreach ($dependencies[$id] ?? [] as $dependency) {
+ self::visit($owner, $dependency, $dependencies, $state, [...$path, $id]);
+ }
+ $state[$id] = 2;
+ }
+}
diff --git a/packages/orchestration/src/Core/TriggerMode.php b/packages/orchestration/src/Core/TriggerMode.php
new file mode 100644
index 00000000..e705372f
--- /dev/null
+++ b/packages/orchestration/src/Core/TriggerMode.php
@@ -0,0 +1,15 @@
+id, OrchestrationSchema::ID_LENGTH);
+ OrchestrationSchema::fit('executionName', $entry->executionName, OrchestrationSchema::NAME_LENGTH);
+ OrchestrationSchema::fit('correlationId', $entry->correlationId, OrchestrationSchema::ID_LENGTH);
+ OrchestrationSchema::fit('stepId', $entry->stepId, OrchestrationSchema::NAME_LENGTH);
+ OrchestrationSchema::fit('errorType', $entry->errorType, OrchestrationSchema::TYPE_LENGTH);
+
+ $this->table()->upsert([[
+ 'id' => $entry->id,
+ 'execution_name' => $entry->executionName,
+ 'correlation_id' => $entry->correlationId,
+ 'pattern' => $entry->pattern->value,
+ 'step_id' => $entry->stepId,
+ 'status_at_failure' => $entry->statusAtFailure->value,
+ 'error_message' => $entry->errorMessage,
+ 'error_type' => $entry->errorType,
+ 'input' => StateSerializer::encodeArray($entry->input),
+ 'headers' => StateSerializer::encodeArray($entry->headers),
+ 'retry_count' => $entry->retryCount,
+ 'created_at' => OrchestrationSchema::instant($entry->createdAt),
+ 'last_retried_at' => $entry->lastRetriedAt === null ? null : OrchestrationSchema::instant($entry->lastRetriedAt),
+ ]], ['id'], ['retry_count', 'last_retried_at', 'error_message', 'error_type']);
+ }
+
+ public function findById(string $id): ?DeadLetterEntry
+ {
+ return $this->entries($this->table()->where('id', $id)->limit(1))[0] ?? null;
+ }
+
+ public function findAll(int $limit = 100): array
+ {
+ return $this->entries($this->table()->orderByDesc('created_at')->orderBy('id')->limit(max(0, $limit)));
+ }
+
+ public function findByExecutionName(string $executionName, int $limit = 100): array
+ {
+ return $this->entries($this->table()->where('execution_name', $executionName)->orderByDesc('created_at')->orderBy('id')->limit(max(0, $limit)));
+ }
+
+ public function findByCorrelationId(string $correlationId): array
+ {
+ return $this->entries($this->table()->where('correlation_id', $correlationId)->orderByDesc('created_at')->orderBy('id'));
+ }
+
+ public function delete(string $id): bool
+ {
+ return $this->table()->where('id', $id)->delete() > 0;
+ }
+
+ public function count(): int
+ {
+ return $this->table()->count();
+ }
+
+ private function table(): Builder
+ {
+ return $this->connection->table(OrchestrationSchema::DEAD_LETTERS);
+ }
+
+ /** @return list */
+ private function entries(Builder $query): array
+ {
+ $entries = [];
+ foreach ($query->get() as $row) {
+ $data = (array) $row;
+ $entries[] = DeadLetterEntry::fromArray([
+ 'id' => $data['id'] ?? '',
+ 'executionName' => $data['execution_name'] ?? '',
+ 'correlationId' => $data['correlation_id'] ?? '',
+ 'pattern' => $data['pattern'] ?? '',
+ 'stepId' => $data['step_id'] ?? null,
+ 'statusAtFailure' => $data['status_at_failure'] ?? '',
+ 'errorMessage' => $data['error_message'] ?? '',
+ 'errorType' => $data['error_type'] ?? '',
+ 'input' => is_string($data['input'] ?? null) ? StateSerializer::decodeArray($data['input']) : [],
+ 'headers' => is_string($data['headers'] ?? null) ? StateSerializer::decodeArray($data['headers']) : [],
+ 'retryCount' => $data['retry_count'] ?? 0,
+ 'createdAt' => is_string($data['created_at'] ?? null) ? ExecutionState::instant(new DateTimeImmutable($data['created_at'], new DateTimeZone('UTC'))) : 'now',
+ 'lastRetriedAt' => is_string($data['last_retried_at'] ?? null) ? ExecutionState::instant(new DateTimeImmutable($data['last_retried_at'], new DateTimeZone('UTC'))) : null,
+ ]);
+ }
+
+ return $entries;
+ }
+}
diff --git a/packages/orchestration/src/Dlq/DeadLetterEntry.php b/packages/orchestration/src/Dlq/DeadLetterEntry.php
new file mode 100644
index 00000000..000a4d9d
--- /dev/null
+++ b/packages/orchestration/src/Dlq/DeadLetterEntry.php
@@ -0,0 +1,105 @@
+ $input
+ * @param array $headers
+ */
+ public function __construct(
+ public string $id,
+ public string $executionName,
+ public string $correlationId,
+ public ExecutionPattern $pattern,
+ public ?string $stepId,
+ public ExecutionStatus $statusAtFailure,
+ public string $errorMessage,
+ public string $errorType,
+ public array $input,
+ public array $headers,
+ public int $retryCount,
+ public DateTimeImmutable $createdAt,
+ public ?DateTimeImmutable $lastRetriedAt = null,
+ ) {}
+
+ /**
+ * @param array $input
+ * @param array $headers
+ */
+ public static function create(string $executionName, string $correlationId, ExecutionPattern $pattern, ?string $stepId, ExecutionStatus $statusAtFailure, Throwable $error, array $input, array $headers, DateTimeImmutable $now, int $retryCount = 0): self
+ {
+ /** @var array $normalized */
+ $normalized = StateSerializer::normalize($input, 'input');
+
+ return new self(Str::uuid()->toString(), $executionName, $correlationId, $pattern, $stepId, $statusAtFailure, $error->getMessage(), $error::class, $normalized, $headers, $retryCount, $now);
+ }
+
+ public function withRetry(DateTimeImmutable $now): self
+ {
+ return new self($this->id, $this->executionName, $this->correlationId, $this->pattern, $this->stepId, $this->statusAtFailure, $this->errorMessage, $this->errorType, $this->input, $this->headers, $this->retryCount + 1, $this->createdAt, $now);
+ }
+
+ /** @return array */
+ public function toArray(): array
+ {
+ return [
+ 'id' => $this->id,
+ 'executionName' => $this->executionName,
+ 'correlationId' => $this->correlationId,
+ 'pattern' => $this->pattern->value,
+ 'stepId' => $this->stepId,
+ 'statusAtFailure' => $this->statusAtFailure->value,
+ 'errorMessage' => $this->errorMessage,
+ 'errorType' => $this->errorType,
+ 'input' => $this->input,
+ 'headers' => $this->headers,
+ 'retryCount' => $this->retryCount,
+ 'createdAt' => ExecutionState::instant($this->createdAt),
+ 'lastRetriedAt' => $this->lastRetriedAt === null ? null : ExecutionState::instant($this->lastRetriedAt),
+ ];
+ }
+
+ /** @param array $row */
+ public static function fromArray(array $row): self
+ {
+ $retried = Rows::nullableString($row, 'lastRetriedAt');
+
+ return new self(
+ id: Rows::string($row, 'id'),
+ executionName: Rows::string($row, 'executionName'),
+ correlationId: Rows::string($row, 'correlationId'),
+ pattern: ExecutionPattern::tryFrom(Rows::string($row, 'pattern')) ?? ExecutionPattern::Saga,
+ stepId: Rows::nullableString($row, 'stepId'),
+ statusAtFailure: ExecutionStatus::tryFrom(Rows::string($row, 'statusAtFailure')) ?? ExecutionStatus::Failed,
+ errorMessage: Rows::string($row, 'errorMessage'),
+ errorType: Rows::string($row, 'errorType'),
+ input: Rows::map($row, 'input'),
+ headers: Rows::stringMap($row, 'headers'),
+ retryCount: Rows::int($row, 'retryCount'),
+ createdAt: ExecutionState::parseInstant(Rows::string($row, 'createdAt', 'now')),
+ lastRetriedAt: $retried === null ? null : ExecutionState::parseInstant($retried),
+ );
+ }
+}
diff --git a/packages/orchestration/src/Dlq/DeadLetterRetrier.php b/packages/orchestration/src/Dlq/DeadLetterRetrier.php
new file mode 100644
index 00000000..167a1669
--- /dev/null
+++ b/packages/orchestration/src/Dlq/DeadLetterRetrier.php
@@ -0,0 +1,37 @@
+deadLetters->get($id);
+ $headers = $entry->headers + [
+ DeadLetterEntry::RETRY_OF_HEADER => $entry->id,
+ DeadLetterEntry::RETRY_COUNT_HEADER => (string) $entry->retryCount,
+ ];
+
+ $outcome = $this->orchestrator->start($entry->pattern, $entry->executionName, $entry->input, $headers);
+ $this->deadLetters->delete($id);
+
+ return $outcome;
+ }
+}
diff --git a/packages/orchestration/src/Dlq/DeadLetterService.php b/packages/orchestration/src/Dlq/DeadLetterService.php
new file mode 100644
index 00000000..3232eb04
--- /dev/null
+++ b/packages/orchestration/src/Dlq/DeadLetterService.php
@@ -0,0 +1,84 @@
+enabled;
+ }
+
+ public function deadLetter(DeadLetterEntry $entry): void
+ {
+ if (! $this->enabled) {
+ return;
+ }
+
+ $this->store->save($entry);
+ $this->events->onDeadLettered($entry->executionName, $entry->correlationId, $entry->stepId, $entry->errorMessage);
+ }
+
+ /** @return list */
+ public function all(int $limit = 100): array
+ {
+ return $this->store->findAll($limit);
+ }
+
+ public function find(string $id): ?DeadLetterEntry
+ {
+ return $this->store->findById($id);
+ }
+
+ public function get(string $id): DeadLetterEntry
+ {
+ return $this->store->findById($id) ?? throw new DeadLetterNotFoundException($id);
+ }
+
+ /** @return list */
+ public function byExecutionName(string $executionName, int $limit = 100): array
+ {
+ return $this->store->findByExecutionName($executionName, $limit);
+ }
+
+ /** @return list */
+ public function byCorrelationId(string $correlationId): array
+ {
+ return $this->store->findByCorrelationId($correlationId);
+ }
+
+ public function delete(string $id): bool
+ {
+ return $this->store->delete($id);
+ }
+
+ public function count(): int
+ {
+ return $this->store->count();
+ }
+
+ public function markRetried(string $id, DateTimeImmutable $now): DeadLetterEntry
+ {
+ $retried = $this->get($id)->withRetry($now);
+ $this->store->save($retried);
+
+ return $retried;
+ }
+}
diff --git a/packages/orchestration/src/Dlq/DeadLetterStore.php b/packages/orchestration/src/Dlq/DeadLetterStore.php
new file mode 100644
index 00000000..246af18f
--- /dev/null
+++ b/packages/orchestration/src/Dlq/DeadLetterStore.php
@@ -0,0 +1,29 @@
+ */
+ public function findAll(int $limit = 100): array;
+
+ /** @return list */
+ public function findByExecutionName(string $executionName, int $limit = 100): array;
+
+ /** @return list */
+ public function findByCorrelationId(string $correlationId): array;
+
+ public function delete(string $id): bool;
+
+ public function count(): int;
+}
diff --git a/packages/orchestration/src/Dlq/MemoryDeadLetterStore.php b/packages/orchestration/src/Dlq/MemoryDeadLetterStore.php
new file mode 100644
index 00000000..23e72fcd
--- /dev/null
+++ b/packages/orchestration/src/Dlq/MemoryDeadLetterStore.php
@@ -0,0 +1,65 @@
+ */
+ private array $entries = [];
+
+ public function save(DeadLetterEntry $entry): void
+ {
+ $this->entries[$entry->id] = $entry;
+ }
+
+ public function findById(string $id): ?DeadLetterEntry
+ {
+ return $this->entries[$id] ?? null;
+ }
+
+ public function findAll(int $limit = 100): array
+ {
+ return self::newestFirst($this->entries, $limit);
+ }
+
+ public function findByExecutionName(string $executionName, int $limit = 100): array
+ {
+ return self::newestFirst(array_filter($this->entries, static fn (DeadLetterEntry $entry): bool => $entry->executionName === $executionName), $limit);
+ }
+
+ public function findByCorrelationId(string $correlationId): array
+ {
+ return self::newestFirst(array_filter($this->entries, static fn (DeadLetterEntry $entry): bool => $entry->correlationId === $correlationId), PHP_INT_MAX);
+ }
+
+ public function delete(string $id): bool
+ {
+ if (! isset($this->entries[$id])) {
+ return false;
+ }
+
+ unset($this->entries[$id]);
+
+ return true;
+ }
+
+ public function count(): int
+ {
+ return count($this->entries);
+ }
+
+ /**
+ * @param array $entries
+ * @return list
+ */
+ private static function newestFirst(array $entries, int $limit): array
+ {
+ $entries = array_values($entries);
+ usort($entries, static fn (DeadLetterEntry $a, DeadLetterEntry $b): int => [$b->createdAt, $a->id] <=> [$a->createdAt, $b->id]);
+
+ return array_slice($entries, 0, $limit);
+ }
+}
diff --git a/packages/orchestration/src/Event/CompositeOrchestrationEvents.php b/packages/orchestration/src/Event/CompositeOrchestrationEvents.php
new file mode 100644
index 00000000..44951ae8
--- /dev/null
+++ b/packages/orchestration/src/Event/CompositeOrchestrationEvents.php
@@ -0,0 +1,251 @@
+|null */
+ private ?array $resolved = null;
+
+ private readonly LoggerInterface $logger;
+
+ /**
+ * @param list $listeners
+ * @param (Closure(): iterable)|null $discover
+ */
+ public function __construct(
+ private readonly array $listeners = [],
+ private readonly ?Closure $discover = null,
+ ?LoggerInterface $logger = null,
+ ) {
+ $this->logger = $logger ?? new NullLogger;
+ }
+
+ /** @return list */
+ public function listeners(): array
+ {
+ if ($this->resolved === null) {
+ $found = $this->listeners;
+ foreach ($this->discover === null ? [] : ($this->discover)() as $listener) {
+ if ($listener instanceof OrchestrationEvents && ! $listener instanceof self && ! in_array($listener, $found, true)) {
+ $found[] = $listener;
+ }
+ }
+ $this->resolved = $found;
+ }
+
+ return $this->resolved;
+ }
+
+ /**
+ * @param Closure(OrchestrationEvents): void $call
+ */
+ private function each(string $hook, Closure $call): void
+ {
+ foreach ($this->listeners() as $listener) {
+ try {
+ $call($listener);
+ } catch (Throwable $error) {
+ $this->logger->warning("Orchestration hook {$hook} on ".$listener::class." failed: {$error->getMessage()}", ['exception' => $error]);
+ }
+ }
+ }
+
+ public function onStart(string $name, string $correlationId, ExecutionPattern $pattern): void
+ {
+ $this->each(__FUNCTION__, static function (OrchestrationEvents $listener) use ($name, $correlationId, $pattern): void {
+ $listener->onStart($name, $correlationId, $pattern);
+ });
+ }
+
+ public function onStepStarted(string $name, string $correlationId, string $stepId): void
+ {
+ $this->each(__FUNCTION__, static function (OrchestrationEvents $listener) use ($name, $correlationId, $stepId): void {
+ $listener->onStepStarted($name, $correlationId, $stepId);
+ });
+ }
+
+ public function onStepSuccess(string $name, string $correlationId, string $stepId, int $attempts, int $latencyMs): void
+ {
+ $this->each(__FUNCTION__, static function (OrchestrationEvents $listener) use ($name, $correlationId, $stepId, $attempts, $latencyMs): void {
+ $listener->onStepSuccess($name, $correlationId, $stepId, $attempts, $latencyMs);
+ });
+ }
+
+ public function onStepFailed(string $name, string $correlationId, string $stepId, Throwable $error, int $attempts): void
+ {
+ $this->each(__FUNCTION__, static function (OrchestrationEvents $listener) use ($name, $correlationId, $stepId, $error, $attempts): void {
+ $listener->onStepFailed($name, $correlationId, $stepId, $error, $attempts);
+ });
+ }
+
+ public function onStepSkipped(string $name, string $correlationId, string $stepId): void
+ {
+ $this->each(__FUNCTION__, static function (OrchestrationEvents $listener) use ($name, $correlationId, $stepId): void {
+ $listener->onStepSkipped($name, $correlationId, $stepId);
+ });
+ }
+
+ public function onStepSkippedIdempotent(string $name, string $correlationId, string $stepId): void
+ {
+ $this->each(__FUNCTION__, static function (OrchestrationEvents $listener) use ($name, $correlationId, $stepId): void {
+ $listener->onStepSkippedIdempotent($name, $correlationId, $stepId);
+ });
+ }
+
+ public function onCompleted(string $name, string $correlationId, ExecutionPattern $pattern, bool $success, int $durationMs): void
+ {
+ $this->each(__FUNCTION__, static function (OrchestrationEvents $listener) use ($name, $correlationId, $pattern, $success, $durationMs): void {
+ $listener->onCompleted($name, $correlationId, $pattern, $success, $durationMs);
+ });
+ }
+
+ public function onCompensationStarted(string $name, string $correlationId): void
+ {
+ $this->each(__FUNCTION__, static function (OrchestrationEvents $listener) use ($name, $correlationId): void {
+ $listener->onCompensationStarted($name, $correlationId);
+ });
+ }
+
+ public function onStepCompensated(string $name, string $correlationId, string $stepId): void
+ {
+ $this->each(__FUNCTION__, static function (OrchestrationEvents $listener) use ($name, $correlationId, $stepId): void {
+ $listener->onStepCompensated($name, $correlationId, $stepId);
+ });
+ }
+
+ public function onStepCompensationFailed(string $name, string $correlationId, string $stepId, Throwable $error): void
+ {
+ $this->each(__FUNCTION__, static function (OrchestrationEvents $listener) use ($name, $correlationId, $stepId, $error): void {
+ $listener->onStepCompensationFailed($name, $correlationId, $stepId, $error);
+ });
+ }
+
+ public function onPhaseStarted(string $name, string $correlationId, TccPhase $phase): void
+ {
+ $this->each(__FUNCTION__, static function (OrchestrationEvents $listener) use ($name, $correlationId, $phase): void {
+ $listener->onPhaseStarted($name, $correlationId, $phase);
+ });
+ }
+
+ public function onPhaseCompleted(string $name, string $correlationId, TccPhase $phase, int $durationMs): void
+ {
+ $this->each(__FUNCTION__, static function (OrchestrationEvents $listener) use ($name, $correlationId, $phase, $durationMs): void {
+ $listener->onPhaseCompleted($name, $correlationId, $phase, $durationMs);
+ });
+ }
+
+ public function onPhaseFailed(string $name, string $correlationId, TccPhase $phase, ?Throwable $error): void
+ {
+ $this->each(__FUNCTION__, static function (OrchestrationEvents $listener) use ($name, $correlationId, $phase, $error): void {
+ $listener->onPhaseFailed($name, $correlationId, $phase, $error);
+ });
+ }
+
+ public function onParticipantStarted(string $name, string $correlationId, string $participantId, TccPhase $phase): void
+ {
+ $this->each(__FUNCTION__, static function (OrchestrationEvents $listener) use ($name, $correlationId, $participantId, $phase): void {
+ $listener->onParticipantStarted($name, $correlationId, $participantId, $phase);
+ });
+ }
+
+ public function onParticipantSuccess(string $name, string $correlationId, string $participantId, TccPhase $phase): void
+ {
+ $this->each(__FUNCTION__, static function (OrchestrationEvents $listener) use ($name, $correlationId, $participantId, $phase): void {
+ $listener->onParticipantSuccess($name, $correlationId, $participantId, $phase);
+ });
+ }
+
+ public function onParticipantFailed(string $name, string $correlationId, string $participantId, TccPhase $phase, Throwable $error): void
+ {
+ $this->each(__FUNCTION__, static function (OrchestrationEvents $listener) use ($name, $correlationId, $participantId, $phase, $error): void {
+ $listener->onParticipantFailed($name, $correlationId, $participantId, $phase, $error);
+ });
+ }
+
+ public function onWorkflowSuspended(string $name, string $correlationId, string $reason): void
+ {
+ $this->each(__FUNCTION__, static function (OrchestrationEvents $listener) use ($name, $correlationId, $reason): void {
+ $listener->onWorkflowSuspended($name, $correlationId, $reason);
+ });
+ }
+
+ public function onWorkflowResumed(string $name, string $correlationId): void
+ {
+ $this->each(__FUNCTION__, static function (OrchestrationEvents $listener) use ($name, $correlationId): void {
+ $listener->onWorkflowResumed($name, $correlationId);
+ });
+ }
+
+ public function onCompositionStarted(string $compositionName, string $correlationId): void
+ {
+ $this->each(__FUNCTION__, static function (OrchestrationEvents $listener) use ($compositionName, $correlationId): void {
+ $listener->onCompositionStarted($compositionName, $correlationId);
+ });
+ }
+
+ public function onCompositionCompleted(string $compositionName, string $correlationId, bool $success): void
+ {
+ $this->each(__FUNCTION__, static function (OrchestrationEvents $listener) use ($compositionName, $correlationId, $success): void {
+ $listener->onCompositionCompleted($compositionName, $correlationId, $success);
+ });
+ }
+
+ public function onSignalDelivered(string $name, string $correlationId, string $signalName): void
+ {
+ $this->each(__FUNCTION__, static function (OrchestrationEvents $listener) use ($name, $correlationId, $signalName): void {
+ $listener->onSignalDelivered($name, $correlationId, $signalName);
+ });
+ }
+
+ public function onTimerFired(string $name, string $correlationId, string $timerId): void
+ {
+ $this->each(__FUNCTION__, static function (OrchestrationEvents $listener) use ($name, $correlationId, $timerId): void {
+ $listener->onTimerFired($name, $correlationId, $timerId);
+ });
+ }
+
+ public function onChildWorkflowStarted(string $parentName, string $parentCorrelationId, string $childWorkflowId, string $childCorrelationId): void
+ {
+ $this->each(__FUNCTION__, static function (OrchestrationEvents $listener) use ($parentName, $parentCorrelationId, $childWorkflowId, $childCorrelationId): void {
+ $listener->onChildWorkflowStarted($parentName, $parentCorrelationId, $childWorkflowId, $childCorrelationId);
+ });
+ }
+
+ public function onChildWorkflowCompleted(string $parentName, string $parentCorrelationId, string $childCorrelationId, bool $success): void
+ {
+ $this->each(__FUNCTION__, static function (OrchestrationEvents $listener) use ($parentName, $parentCorrelationId, $childCorrelationId, $success): void {
+ $listener->onChildWorkflowCompleted($parentName, $parentCorrelationId, $childCorrelationId, $success);
+ });
+ }
+
+ public function onContinueAsNew(string $name, string $oldCorrelationId, string $newCorrelationId): void
+ {
+ $this->each(__FUNCTION__, static function (OrchestrationEvents $listener) use ($name, $oldCorrelationId, $newCorrelationId): void {
+ $listener->onContinueAsNew($name, $oldCorrelationId, $newCorrelationId);
+ });
+ }
+
+ public function onDeadLettered(string $name, string $correlationId, ?string $stepId, string $error): void
+ {
+ $this->each(__FUNCTION__, static function (OrchestrationEvents $listener) use ($name, $correlationId, $stepId, $error): void {
+ $listener->onDeadLettered($name, $correlationId, $stepId, $error);
+ });
+ }
+}
diff --git a/packages/orchestration/src/Event/EdaOrchestrationEventPublisher.php b/packages/orchestration/src/Event/EdaOrchestrationEventPublisher.php
new file mode 100644
index 00000000..5855a33a
--- /dev/null
+++ b/packages/orchestration/src/Event/EdaOrchestrationEventPublisher.php
@@ -0,0 +1,47 @@
+` (`orchestration.execution_completed`).
+ *
+ * Headers follow the framework's EDA convention, the one EdaCommandEventPublisher stamps and the transports
+ * read: `x-correlation-id` carries the correlation id (the Postgres outbox takes its transaction id from it,
+ * and Kafka partitions by it when no key is set), and a keyed step event adds `partition_key`, which Kafka
+ * routes by first, so every event for one key lands on one partition in order. The `correlationId`, `key`
+ * and `pattern` headers ride alongside (jafly parity).
+ */
+final class EdaOrchestrationEventPublisher implements OrchestrationEventPublisher
+{
+ public function __construct(
+ private readonly EventPublisher $publisher,
+ private readonly string $defaultTopic = '',
+ ) {}
+
+ public function publish(OrchestrationEvent $event): void
+ {
+ $headers = [
+ 'x-correlation-id' => $event->correlationId,
+ 'correlationId' => $event->correlationId,
+ 'pattern' => $event->pattern->value,
+ ];
+ if ($event->key !== null) {
+ $headers['partition_key'] = $event->key;
+ $headers['key'] = $event->key;
+ }
+
+ $this->publisher->publish(
+ $event->topic ?? ($this->defaultTopic !== '' ? $this->defaultTopic : 'orchestration'),
+ $event->type ?? 'orchestration.'.strtolower($event->eventType),
+ $event->toArray(),
+ $headers,
+ );
+ }
+}
diff --git a/packages/orchestration/src/Event/EventGateway.php b/packages/orchestration/src/Event/EventGateway.php
new file mode 100644
index 00000000..ccf24653
--- /dev/null
+++ b/packages/orchestration/src/Event/EventGateway.php
@@ -0,0 +1,93 @@
+, array, ?string): mixed}> */
+ private array $triggers = [];
+
+ /**
+ * The executor is called with the payload, the headers and the correlation id the execution must use.
+ *
+ * @param Closure(array, array, ?string): mixed $executor
+ */
+ public function register(string $eventType, string $target, ExecutionPattern $pattern, Closure $executor): void
+ {
+ if (isset($this->triggers[$eventType])) {
+ $existing = $this->triggers[$eventType];
+
+ throw new ConfigurationException("Trigger event type [{$eventType}] is declared by {$existing['pattern']->value} [{$existing['target']}] and by {$pattern->value} [{$target}]; one event type may start one definition.");
+ }
+
+ $this->triggers[$eventType] = ['target' => $target, 'pattern' => $pattern, 'executor' => $executor];
+ }
+
+ /**
+ * @param array $payload
+ * @param array $headers
+ */
+ public function route(string $eventType, array $payload, array $headers = [], ?string $correlationId = null): mixed
+ {
+ $trigger = $this->triggers[$eventType] ?? null;
+
+ return $trigger === null ? null : ($trigger['executor'])($payload, $headers, $correlationId);
+ }
+
+ /**
+ * The correlation id of the execution an event starts: a name-based (version 5 layout) UUID over the event
+ * type and the event id, so it is stable across redeliveries, fits the ledger's id column whatever the
+ * producer's id looks like, and never collides with the random ids code-started executions get.
+ */
+ public static function correlationIdFor(string $eventType, string $eventId): string
+ {
+ $hash = sha1("firefly.orchestration.trigger\0{$eventType}\0{$eventId}");
+ $variant = dechex((hexdec($hash[16]) & 0x3) | 0x8);
+
+ return substr($hash, 0, 8).'-'.substr($hash, 8, 4).'-5'.substr($hash, 13, 3).'-'.$variant.substr($hash, 17, 3).'-'.substr($hash, 20, 12);
+ }
+
+ public function has(string $eventType): bool
+ {
+ return isset($this->triggers[$eventType]);
+ }
+
+ public function count(): int
+ {
+ return count($this->triggers);
+ }
+
+ /** @return list */
+ public function eventTypes(): array
+ {
+ return array_map('strval', array_keys($this->triggers));
+ }
+
+ /** @return list */
+ public function describe(): array
+ {
+ $rows = [];
+ foreach ($this->triggers as $eventType => $trigger) {
+ $rows[] = ['eventType' => (string) $eventType, 'target' => $trigger['target'], 'pattern' => $trigger['pattern']->value];
+ }
+
+ return $rows;
+ }
+}
diff --git a/packages/orchestration/src/Event/LoggerOrchestrationEvents.php b/packages/orchestration/src/Event/LoggerOrchestrationEvents.php
new file mode 100644
index 00000000..321571bd
--- /dev/null
+++ b/packages/orchestration/src/Event/LoggerOrchestrationEvents.php
@@ -0,0 +1,80 @@
+logger->info("Orchestration {$pattern->value} [{$name}] started.", ['correlationId' => $correlationId]);
+ }
+
+ public function onStepFailed(string $name, string $correlationId, string $stepId, Throwable $error, int $attempts): void
+ {
+ $this->logger->warning("Orchestration [{$name}] step [{$stepId}] failed after {$attempts} attempt(s): {$error->getMessage()}", ['correlationId' => $correlationId, 'exception' => $error]);
+ }
+
+ public function onCompleted(string $name, string $correlationId, ExecutionPattern $pattern, bool $success, int $durationMs): void
+ {
+ $message = "Orchestration {$pattern->value} [{$name}] ".($success ? 'completed' : 'failed')." in {$durationMs} ms.";
+ if ($success) {
+ $this->logger->info($message, ['correlationId' => $correlationId]);
+ } else {
+ $this->logger->warning($message, ['correlationId' => $correlationId]);
+ }
+ }
+
+ public function onCompensationStarted(string $name, string $correlationId): void
+ {
+ $this->logger->info("Orchestration [{$name}] is compensating.", ['correlationId' => $correlationId]);
+ }
+
+ public function onStepCompensationFailed(string $name, string $correlationId, string $stepId, Throwable $error): void
+ {
+ $this->logger->error("Orchestration [{$name}] could not compensate step [{$stepId}]: {$error->getMessage()}", ['correlationId' => $correlationId, 'exception' => $error]);
+ }
+
+ public function onPhaseFailed(string $name, string $correlationId, TccPhase $phase, ?Throwable $error): void
+ {
+ $this->logger->warning("Orchestration TCC [{$name}] phase {$phase->value} failed".($error === null ? '.' : ": {$error->getMessage()}"), ['correlationId' => $correlationId]);
+ }
+
+ public function onWorkflowSuspended(string $name, string $correlationId, string $reason): void
+ {
+ $this->logger->info("Orchestration workflow [{$name}] suspended".($reason === '' ? '.' : ": {$reason}"), ['correlationId' => $correlationId]);
+ }
+
+ public function onWorkflowResumed(string $name, string $correlationId): void
+ {
+ $this->logger->info("Orchestration workflow [{$name}] resumed.", ['correlationId' => $correlationId]);
+ }
+
+ public function onSignalDelivered(string $name, string $correlationId, string $signalName): void
+ {
+ $this->logger->info("Orchestration workflow [{$name}] received signal [{$signalName}].", ['correlationId' => $correlationId]);
+ }
+
+ public function onContinueAsNew(string $name, string $oldCorrelationId, string $newCorrelationId): void
+ {
+ $this->logger->info("Orchestration workflow [{$name}] continued as new execution [{$newCorrelationId}].", ['correlationId' => $oldCorrelationId]);
+ }
+
+ public function onDeadLettered(string $name, string $correlationId, ?string $stepId, string $error): void
+ {
+ $this->logger->warning("Orchestration [{$name}]".($stepId === null ? '' : " step [{$stepId}]")." was dead-lettered: {$error}", ['correlationId' => $correlationId]);
+ }
+}
diff --git a/packages/orchestration/src/Event/MetricsOrchestrationEvents.php b/packages/orchestration/src/Event/MetricsOrchestrationEvents.php
new file mode 100644
index 00000000..54b8b013
--- /dev/null
+++ b/packages/orchestration/src/Event/MetricsOrchestrationEvents.php
@@ -0,0 +1,44 @@
+metrics->recordStep($name, $stepId, 'success', $attempts, $latencyMs / 1000);
+ }
+
+ public function onStepFailed(string $name, string $correlationId, string $stepId, Throwable $error, int $attempts): void
+ {
+ $this->metrics->recordStep($name, $stepId, 'failure', $attempts, 0.0);
+ }
+
+ public function onStepCompensated(string $name, string $correlationId, string $stepId): void
+ {
+ $this->metrics->recordCompensation($name, $stepId, 'compensated');
+ }
+
+ public function onStepCompensationFailed(string $name, string $correlationId, string $stepId, Throwable $error): void
+ {
+ $this->metrics->recordCompensation($name, $stepId, 'failed');
+ }
+
+ public function onCompleted(string $name, string $correlationId, ExecutionPattern $pattern, bool $success, int $durationMs): void
+ {
+ $this->metrics->recordExecution($name, $pattern, $success ? 'success' : 'failure', $durationMs / 1000);
+ }
+
+ public function onDeadLettered(string $name, string $correlationId, ?string $stepId, string $error): void
+ {
+ $this->metrics->recordDeadLetter($name);
+ }
+}
diff --git a/packages/orchestration/src/Event/NoOpOrchestrationEventPublisher.php b/packages/orchestration/src/Event/NoOpOrchestrationEventPublisher.php
new file mode 100644
index 00000000..8a51afb0
--- /dev/null
+++ b/packages/orchestration/src/Event/NoOpOrchestrationEventPublisher.php
@@ -0,0 +1,11 @@
+ $payload
+ */
+ public function __construct(
+ public string $eventType,
+ public string $executionName,
+ public string $correlationId,
+ public ExecutionPattern $pattern,
+ public ?string $stepId,
+ public ?ExecutionStatus $status,
+ public array $payload,
+ public DateTimeImmutable $timestamp,
+ public ?string $topic = null,
+ public ?string $type = null,
+ public ?string $key = null,
+ ) {}
+
+ public static function executionStarted(string $name, string $correlationId, ExecutionPattern $pattern, DateTimeImmutable $now): self
+ {
+ return new self(self::EXECUTION_STARTED, $name, $correlationId, $pattern, null, ExecutionStatus::Running, [], $now);
+ }
+
+ public static function executionCompleted(string $name, string $correlationId, ExecutionPattern $pattern, ExecutionStatus $status, DateTimeImmutable $now): self
+ {
+ return new self(self::EXECUTION_COMPLETED, $name, $correlationId, $pattern, null, $status, [], $now);
+ }
+
+ public static function stepCompleted(string $name, string $correlationId, ExecutionPattern $pattern, string $stepId, mixed $result, DateTimeImmutable $now): self
+ {
+ return new self(self::STEP_COMPLETED, $name, $correlationId, $pattern, $stepId, null, $result === null ? [] : ['result' => StateSerializer::normalize($result, "step {$stepId} result")], $now);
+ }
+
+ public static function stepFailed(string $name, string $correlationId, ExecutionPattern $pattern, string $stepId, Throwable $error, DateTimeImmutable $now): self
+ {
+ return new self(self::STEP_FAILED, $name, $correlationId, $pattern, $stepId, null, ['error' => $error->getMessage(), 'errorType' => $error::class], $now);
+ }
+
+ public function withTopic(string $topic): self
+ {
+ return new self($this->eventType, $this->executionName, $this->correlationId, $this->pattern, $this->stepId, $this->status, $this->payload, $this->timestamp, $topic === '' ? null : $topic, $this->type, $this->key);
+ }
+
+ public function withType(string $type): self
+ {
+ return new self($this->eventType, $this->executionName, $this->correlationId, $this->pattern, $this->stepId, $this->status, $this->payload, $this->timestamp, $this->topic, $type === '' ? null : $type, $this->key);
+ }
+
+ public function withKey(string $key): self
+ {
+ return new self($this->eventType, $this->executionName, $this->correlationId, $this->pattern, $this->stepId, $this->status, $this->payload, $this->timestamp, $this->topic, $this->type, $key === '' ? null : $key);
+ }
+
+ /** @return array */
+ public function toArray(): array
+ {
+ return [
+ 'eventType' => $this->eventType,
+ 'executionName' => $this->executionName,
+ 'correlationId' => $this->correlationId,
+ 'pattern' => $this->pattern->value,
+ 'stepId' => $this->stepId,
+ 'status' => $this->status?->value,
+ 'payload' => $this->payload,
+ 'timestamp' => ExecutionState::instant($this->timestamp),
+ 'key' => $this->key,
+ ];
+ }
+}
diff --git a/packages/orchestration/src/Event/OrchestrationEventPublisher.php b/packages/orchestration/src/Event/OrchestrationEventPublisher.php
new file mode 100644
index 00000000..430a0be7
--- /dev/null
+++ b/packages/orchestration/src/Event/OrchestrationEventPublisher.php
@@ -0,0 +1,14 @@
+guard(fn (): HttpResponse => $this->started($this->orchestrator->start(ExecutionPattern::Saga, $name, self::input($request), self::headers($request))));
+ }
+
+ public function startTcc(Request $request, string $name): HttpResponse
+ {
+ return $this->guard(fn (): HttpResponse => $this->started($this->orchestrator->start(ExecutionPattern::Tcc, $name, self::input($request), self::headers($request))));
+ }
+
+ public function startWorkflow(Request $request, string $id): HttpResponse
+ {
+ return $this->guard(function () use ($request, $id): HttpResponse {
+ $correlationId = self::body($request)['correlationId'] ?? null;
+ $state = $this->orchestrator->workflows()->start($id, self::input($request), self::headers($request), is_string($correlationId) && $correlationId !== '' ? $correlationId : null);
+
+ return $this->started(ExecutionOutcome::fromWorkflow($state));
+ });
+ }
+
+ public function executions(Request $request): HttpResponse
+ {
+ return $this->guard(function () use ($request): HttpResponse {
+ $pattern = $request->query('pattern');
+ $status = $request->query('status');
+ $name = $request->query('name');
+ $criteria = new ExecutionCriteria(
+ pattern: is_string($pattern) ? ExecutionPattern::tryFrom(strtoupper($pattern)) : null,
+ status: is_string($status) ? ExecutionStatus::tryFrom(strtoupper($status)) : null,
+ name: is_string($name) && $name !== '' ? $name : null,
+ limit: self::limit($request),
+ );
+
+ return new JsonResponse(['executions' => array_map(static fn (ExecutionState $state): array => [
+ 'correlationId' => $state->correlationId,
+ 'name' => $state->executionName,
+ 'pattern' => $state->pattern->value,
+ 'status' => $state->status->value,
+ 'updatedAt' => ExecutionState::instant($state->updatedAt),
+ 'waitingOn' => $state->waitingOn,
+ 'failureReason' => $state->failureReason,
+ ], $this->persistence->find($criteria))]);
+ });
+ }
+
+ public function execution(string $id): HttpResponse
+ {
+ return $this->guard(fn (): HttpResponse => new JsonResponse(($this->persistence->findById($id) ?? throw new ExecutionNotFoundException($id))->toArray()));
+ }
+
+ public function signal(Request $request, string $id, string $signal): HttpResponse
+ {
+ return $this->guard(fn (): HttpResponse => new JsonResponse($this->orchestrator->workflows()->signal($id, $signal, self::body($request)['payload'] ?? null)->toArray()));
+ }
+
+ public function suspend(Request $request, string $id): HttpResponse
+ {
+ return $this->guard(function () use ($request, $id): HttpResponse {
+ $reason = self::body($request)['reason'] ?? '';
+
+ return new JsonResponse($this->orchestrator->workflows()->suspend($id, is_string($reason) ? $reason : '')->toArray());
+ });
+ }
+
+ public function resume(string $id): HttpResponse
+ {
+ return $this->guard(fn (): HttpResponse => new JsonResponse($this->orchestrator->workflows()->resume($id)->toArray()));
+ }
+
+ public function cancel(string $id): HttpResponse
+ {
+ return $this->guard(fn (): HttpResponse => new JsonResponse($this->orchestrator->workflows()->cancel($id)->toArray()));
+ }
+
+ public function query(string $id, string $query): HttpResponse
+ {
+ return $this->guard(fn (): HttpResponse => new JsonResponse(['result' => $this->orchestrator->workflows()->query($id, $query)]));
+ }
+
+ public function deadLetters(Request $request): HttpResponse
+ {
+ return $this->guard(fn (): HttpResponse => new JsonResponse(['deadLetters' => array_map(
+ static fn (DeadLetterEntry $entry): array => $entry->toArray(),
+ $this->deadLetters->all(self::limit($request)),
+ )]));
+ }
+
+ public function retryDeadLetter(string $id): HttpResponse
+ {
+ return $this->guard(fn (): HttpResponse => $this->started($this->retrier->retry($id)));
+ }
+
+ public function deleteDeadLetter(string $id): HttpResponse
+ {
+ return $this->guard(function () use ($id): HttpResponse {
+ if (! $this->deadLetters->delete($id)) {
+ throw new DeadLetterNotFoundException($id);
+ }
+
+ return new Response('', 204);
+ });
+ }
+
+ private function started(ExecutionOutcome $outcome): HttpResponse
+ {
+ if (! $outcome->success) {
+ return new JsonResponse($outcome->toArray(), 422);
+ }
+
+ return new JsonResponse($outcome->toArray(), 201, ['Location' => url($this->settings->rest->path.'/executions/'.rawurlencode($outcome->correlationId))]);
+ }
+
+ /** @param Closure(): HttpResponse $action */
+ private function guard(Closure $action): HttpResponse
+ {
+ try {
+ return $action();
+ } catch (FireflyException $refused) {
+ return new JsonResponse(['error' => $refused->getMessage(), 'code' => $refused->errorCode()], $refused->httpStatus());
+ }
+ }
+
+ /** @return array */
+ private static function body(Request $request): array
+ {
+ if ($request->getContent() === '') {
+ return [];
+ }
+
+ $body = json_decode($request->getContent(), true);
+ if (! is_array($body) || array_is_list($body) && $body !== []) {
+ throw new OrchestrationException('The request body must be a JSON object.', 'ORCHESTRATION_BAD_REQUEST', 400);
+ }
+
+ /** @var array $body */
+ return $body;
+ }
+
+ /** @return array */
+ private static function input(Request $request): array
+ {
+ $input = self::body($request)['input'] ?? [];
+ if (! is_array($input) || array_is_list($input) && $input !== []) {
+ throw new OrchestrationException('"input" must be a JSON object.', 'ORCHESTRATION_BAD_REQUEST', 400);
+ }
+
+ /** @var array $input */
+ return $input;
+ }
+
+ /** @return array */
+ private static function headers(Request $request): array
+ {
+ $headers = self::body($request)['headers'] ?? [];
+ if (! is_array($headers) || array_is_list($headers) && $headers !== []) {
+ throw new OrchestrationException('"headers" must be a JSON object of strings.', 'ORCHESTRATION_BAD_REQUEST', 400);
+ }
+
+ $strings = [];
+ foreach ($headers as $name => $value) {
+ if (! is_scalar($value)) {
+ throw new OrchestrationException('"headers" must be a JSON object of strings.', 'ORCHESTRATION_BAD_REQUEST', 400);
+ }
+ $strings[(string) $name] = (string) $value;
+ }
+
+ return $strings;
+ }
+
+ private static function limit(Request $request): int
+ {
+ $limit = $request->query('limit', '50');
+
+ return is_numeric($limit) ? max(1, min(500, (int) $limit)) : 50;
+ }
+}
diff --git a/packages/orchestration/src/Manifest/OrchestrationManifest.php b/packages/orchestration/src/Manifest/OrchestrationManifest.php
new file mode 100644
index 00000000..d3fcec6a
--- /dev/null
+++ b/packages/orchestration/src/Manifest/OrchestrationManifest.php
@@ -0,0 +1,110 @@
+> $sagas
+ * @param list> $tccs
+ * @param list> $workflows
+ * @param list> $schedules
+ */
+ public function __construct(
+ private readonly array $sagas = [],
+ private readonly array $tccs = [],
+ private readonly array $workflows = [],
+ private readonly array $schedules = [],
+ ) {}
+
+ public static function empty(): self
+ {
+ return new self;
+ }
+
+ /**
+ * @param list $sagas
+ * @param list $tccs
+ * @param list $workflows
+ * @param list $schedules
+ */
+ public static function fromDefinitions(array $sagas, array $tccs, array $workflows, array $schedules): self
+ {
+ return new self(
+ array_map(static fn (SagaDefinition $saga): array => $saga->toArray(), $sagas),
+ array_map(static fn (TccDefinition $tcc): array => $tcc->toArray(), $tccs),
+ array_map(static fn (WorkflowDefinition $workflow): array => $workflow->toArray(), $workflows),
+ array_map(static fn (ScheduledExecution $schedule): array => $schedule->toArray(), $schedules),
+ );
+ }
+
+ /** @return list */
+ public function sagas(): array
+ {
+ return array_map(static fn (array $row): SagaDefinition => SagaDefinition::fromArray($row), $this->sagas);
+ }
+
+ /** @return list */
+ public function tccs(): array
+ {
+ return array_map(static fn (array $row): TccDefinition => TccDefinition::fromArray($row), $this->tccs);
+ }
+
+ /** @return list */
+ public function workflows(): array
+ {
+ return array_map(static fn (array $row): WorkflowDefinition => WorkflowDefinition::fromArray($row), $this->workflows);
+ }
+
+ /** @return list */
+ public function schedules(): array
+ {
+ return array_map(static fn (array $row): ScheduledExecution => ScheduledExecution::fromArray($row), $this->schedules);
+ }
+
+ public function isEmpty(): bool
+ {
+ return $this->sagas === [] && $this->tccs === [] && $this->workflows === [] && $this->schedules === [];
+ }
+
+ /** @return array{sagas: list>, tccs: list>, workflows: list>, schedules: list>} */
+ public function toArray(): array
+ {
+ return ['sagas' => $this->sagas, 'tccs' => $this->tccs, 'workflows' => $this->workflows, 'schedules' => $this->schedules];
+ }
+
+ /** @param array $data */
+ public static function fromArray(array $data): self
+ {
+ return new self(Rows::rows($data, 'sagas'), Rows::rows($data, 'tccs'), Rows::rows($data, 'workflows'), Rows::rows($data, 'schedules'));
+ }
+
+ public static function load(string $path): self
+ {
+ if (! is_file($path)) {
+ throw new ConfigurationException("Orchestration manifest not found at {$path}. Run php artisan firefly:cache.");
+ }
+
+ /** @var mixed $data */
+ $data = require $path;
+ if (! is_array($data)) {
+ throw new ConfigurationException("Orchestration manifest at {$path} did not return an array.");
+ }
+
+ return self::fromArray($data);
+ }
+}
diff --git a/packages/orchestration/src/Manifest/OrchestrationManifestCompiler.php b/packages/orchestration/src/Manifest/OrchestrationManifestCompiler.php
new file mode 100644
index 00000000..23ebacc2
--- /dev/null
+++ b/packages/orchestration/src/Manifest/OrchestrationManifestCompiler.php
@@ -0,0 +1,30 @@
+toArray(), true)
+ .";\n";
+ }
+
+ public function write(OrchestrationManifest $manifest, string $path): void
+ {
+ $dir = dirname($path);
+ if (! is_dir($dir) && ! mkdir($dir, 0o775, true) && ! is_dir($dir)) {
+ throw new ConfigurationException("Could not create manifest directory {$dir}.");
+ }
+
+ if (file_put_contents($path, $this->compile($manifest)) === false) {
+ throw new ConfigurationException("Could not write the orchestration manifest to {$path}.");
+ }
+ }
+}
diff --git a/packages/orchestration/src/Manifest/ScheduledExecution.php b/packages/orchestration/src/Manifest/ScheduledExecution.php
new file mode 100644
index 00000000..6b6f777d
--- /dev/null
+++ b/packages/orchestration/src/Manifest/ScheduledExecution.php
@@ -0,0 +1,58 @@
+ $input
+ */
+ public function __construct(
+ public ExecutionPattern $pattern,
+ public string $name,
+ public ?string $cron = null,
+ public ?string $zone = null,
+ public ?string $fixedRate = null,
+ public ?string $fixedDelay = null,
+ public array $input = [],
+ public string $description = '',
+ ) {}
+
+ public function lockName(int $index): string
+ {
+ return "firefly:orchestration:scheduled:{$this->pattern->value}:{$this->name}:{$index}";
+ }
+
+ /** @return array */
+ public function toArray(): array
+ {
+ return [
+ 'pattern' => $this->pattern->value, 'name' => $this->name, 'cron' => $this->cron, 'zone' => $this->zone,
+ 'fixedRate' => $this->fixedRate, 'fixedDelay' => $this->fixedDelay, 'input' => $this->input, 'description' => $this->description,
+ ];
+ }
+
+ /** @param array $row */
+ public static function fromArray(array $row): self
+ {
+ return new self(
+ ExecutionPattern::tryFrom(Rows::string($row, 'pattern')) ?? ExecutionPattern::Saga,
+ Rows::string($row, 'name'),
+ Rows::nullableString($row, 'cron'),
+ Rows::nullableString($row, 'zone'),
+ Rows::nullableString($row, 'fixedRate'),
+ Rows::nullableString($row, 'fixedDelay'),
+ Rows::map($row, 'input'),
+ Rows::string($row, 'description'),
+ );
+ }
+}
diff --git a/packages/orchestration/src/OrchestrationAutoConfiguration.php b/packages/orchestration/src/OrchestrationAutoConfiguration.php
new file mode 100644
index 00000000..5b4653ce
--- /dev/null
+++ b/packages/orchestration/src/OrchestrationAutoConfiguration.php
@@ -0,0 +1,427 @@
+persistence;
+
+ return match ($persistence->provider) {
+ 'cache' => new CacheExecutionPersistenceProvider(self::cacheStore($app, $persistence->cacheStore), $persistence->keyPrefix, $persistence->keyTtl),
+ 'database' => new DatabaseExecutionPersistenceProvider(self::connection($app, $persistence->connection, 'firefly.orchestration.persistence.provider')),
+ 'event-sourced' => throw new ConfigurationException('Configuration key [firefly.orchestration.persistence.provider] is event-sourced, which firefly/eventsourcing supplies; install it, or choose memory, cache or database.'),
+ default => new MemoryExecutionPersistenceProvider,
+ };
+ }
+
+ #[Bean]
+ #[Lazy]
+ #[ConditionalOnMissingBean(DeadLetterStore::class)]
+ public function orchestrationDeadLetterStore(OrchestrationSettings $settings, Container $app): DeadLetterStore
+ {
+ return $settings->dlq->store === 'database'
+ ? new DatabaseDeadLetterStore(self::connection($app, $settings->persistence->connection, 'firefly.orchestration.dlq.store'))
+ : new MemoryDeadLetterStore;
+ }
+
+ #[Bean]
+ #[Lazy]
+ #[ConditionalOnMissingBean(OrchestrationMetrics::class)]
+ public function orchestrationMetrics(): OrchestrationMetrics
+ {
+ return new NoOpOrchestrationMetrics;
+ }
+
+ #[Bean]
+ #[Lazy]
+ #[ConditionalOnMissingBean(OrchestrationTracing::class)]
+ public function orchestrationTracing(): OrchestrationTracing
+ {
+ return new NoOpOrchestrationTracing;
+ }
+
+ #[Bean]
+ #[Lazy]
+ #[ConditionalOnMissingBean(CompositeOrchestrationEvents::class)]
+ public function orchestrationEvents(OrchestrationSettings $settings, OrchestrationMetrics $metrics, FireflyContainer $beans, Container $app): CompositeOrchestrationEvents
+ {
+ $logger = self::logger($app);
+ $listeners = [new LoggerOrchestrationEvents($logger)];
+ if ($settings->metricsEnabled) {
+ $listeners[] = new MetricsOrchestrationEvents($metrics);
+ }
+
+ return new CompositeOrchestrationEvents($listeners, static fn (): array => $beans->getAll(OrchestrationEvents::class), $logger);
+ }
+
+ #[Bean]
+ #[Lazy]
+ #[ConditionalOnMissingBean(OrchestrationEventPublisher::class)]
+ public function orchestrationEventPublisher(OrchestrationSettings $settings, EventPublisher $publisher): OrchestrationEventPublisher
+ {
+ return $settings->events->enabled ? new EdaOrchestrationEventPublisher($publisher, $settings->events->defaultTopic) : new NoOpOrchestrationEventPublisher;
+ }
+
+ #[Bean]
+ #[Lazy]
+ #[ConditionalOnMissingBean(ExecutionLock::class)]
+ public function executionLock(OrchestrationSettings $settings, DistributedLock $lock): ExecutionLock
+ {
+ return new ExecutionLock($lock, $settings->lock->ttl, $settings->lock->wait);
+ }
+
+ #[Bean]
+ #[Lazy]
+ #[ConditionalOnMissingBean(ExecutionAdmission::class)]
+ public function executionAdmission(OrchestrationSettings $settings, ExecutionPersistenceProvider $persistence, ResilienceStore $store): ExecutionAdmission
+ {
+ $backpressure = $settings->backpressure;
+ $breaker = $backpressure->strategy === 'circuit-breaker'
+ ? new CircuitBreaker(
+ key: 'firefly:orchestration:backpressure',
+ store: $store,
+ failureThreshold: $backpressure->failureThreshold,
+ waitDurationInOpen: $backpressure->recoveryTimeout,
+ halfOpenMaxCalls: $backpressure->halfOpenMaxCalls,
+ )
+ : null;
+
+ return new ExecutionAdmission($persistence, $backpressure, $breaker);
+ }
+
+ #[Bean]
+ #[Lazy]
+ #[ConditionalOnMissingBean(DeadLetterService::class)]
+ public function deadLetterService(OrchestrationSettings $settings, DeadLetterStore $store, CompositeOrchestrationEvents $events): DeadLetterService
+ {
+ return new DeadLetterService($store, $events, $settings->dlq->enabled);
+ }
+
+ #[Bean]
+ #[Lazy]
+ #[ConditionalOnMissingBean(CallbackInvoker::class)]
+ public function callbackInvoker(Container $app): CallbackInvoker
+ {
+ return new CallbackInvoker($app, self::logger($app));
+ }
+
+ #[Bean]
+ #[Lazy]
+ #[ConditionalOnMissingBean(EngineSupport::class)]
+ public function engineSupport(
+ OrchestrationSettings $settings,
+ ExecutionPersistenceProvider $persistence,
+ DeadLetterService $deadLetters,
+ CompositeOrchestrationEvents $events,
+ OrchestrationEventPublisher $lifecycle,
+ EventPublisher $eda,
+ OrchestrationTracing $tracing,
+ CallbackInvoker $callbacks,
+ Clock $clock,
+ ExecutionLock $lock,
+ ExecutionAdmission $admission,
+ Container $app,
+ ): EngineSupport {
+ return new EngineSupport(
+ persistence: $persistence,
+ deadLetters: $deadLetters,
+ events: $events,
+ lifecycle: $lifecycle,
+ stepEvents: new EdaOrchestrationEventPublisher($eda, $settings->events->defaultTopic),
+ tracing: $settings->tracingEnabled ? $tracing : new NoOpOrchestrationTracing,
+ callbacks: $callbacks,
+ clock: $clock,
+ lock: $lock,
+ admission: $admission,
+ logger: self::logger($app),
+ );
+ }
+
+ #[Bean]
+ #[Lazy]
+ #[ConditionalOnMissingBean(ConditionEvaluator::class)]
+ public function conditionEvaluator(): ConditionEvaluator
+ {
+ return new ConditionEvaluator;
+ }
+
+ #[Bean]
+ #[Lazy]
+ #[ConditionalOnMissingBean(InvocationRunner::class)]
+ public function invocationRunner(Container $app): InvocationRunner
+ {
+ return new InvocationRunner($app, new ArgumentResolver);
+ }
+
+ #[Bean]
+ #[Lazy]
+ #[ConditionalOnMissingBean(StepInvoker::class)]
+ public function stepInvoker(InvocationRunner $runner, EngineSupport $support): StepInvoker
+ {
+ return new StepInvoker($runner, $support->tracing);
+ }
+
+ #[Bean]
+ #[Lazy]
+ #[ConditionalOnMissingBean(SagaRegistry::class)]
+ public function sagaRegistry(): SagaRegistry
+ {
+ return new SagaRegistry;
+ }
+
+ #[Bean]
+ #[Lazy]
+ #[ConditionalOnMissingBean(TccRegistry::class)]
+ public function tccRegistry(): TccRegistry
+ {
+ return new TccRegistry;
+ }
+
+ #[Bean]
+ #[Lazy]
+ #[ConditionalOnMissingBean(WorkflowRegistry::class)]
+ public function workflowRegistry(): WorkflowRegistry
+ {
+ return new WorkflowRegistry;
+ }
+
+ #[Bean]
+ #[Lazy]
+ #[ConditionalOnMissingBean(EventGateway::class)]
+ public function eventGateway(): EventGateway
+ {
+ return new EventGateway;
+ }
+
+ #[Bean]
+ #[Lazy]
+ #[ConditionalOnMissingBean(CompensationErrorHandlerFactory::class)]
+ public function compensationErrorHandlerFactory(Container $app): CompensationErrorHandlerFactory
+ {
+ return new CompensationErrorHandlerFactory($app, self::logger($app));
+ }
+
+ #[Bean]
+ #[Lazy]
+ #[ConditionalOnMissingBean(SagaCompensator::class)]
+ public function sagaCompensator(OrchestrationSettings $settings, StepInvoker $invoker, EngineSupport $support, CompensationErrorHandlerFactory $handlers, ResilienceRegistry $resilience, ResilienceStore $store): SagaCompensator
+ {
+ return new SagaCompensator($invoker, $support, $settings->saga->compensationPolicy, $handlers->get($settings->saga->compensationErrorHandler), $resilience, $store);
+ }
+
+ #[Bean]
+ #[Lazy]
+ #[ConditionalOnMissingBean(SagaEngine::class)]
+ public function sagaEngine(OrchestrationSettings $settings, SagaRegistry $registry, StepInvoker $invoker, SagaCompensator $compensator, EngineSupport $support): SagaEngine
+ {
+ return new SagaEngine($registry, new SagaExecutionOrchestrator($invoker, $support), $compensator, $support, $settings->saga);
+ }
+
+ #[Bean]
+ #[Lazy]
+ #[ConditionalOnMissingBean(SagaCompositor::class)]
+ public function sagaCompositor(SagaEngine $engine, EngineSupport $support, ConditionEvaluator $conditions): SagaCompositor
+ {
+ return new SagaCompositor($engine, $support->events, $conditions, new CompositionDataFlowManager);
+ }
+
+ #[Bean]
+ #[Lazy]
+ #[ConditionalOnMissingBean(TccEngine::class)]
+ public function tccEngine(OrchestrationSettings $settings, TccRegistry $registry, StepInvoker $invoker, EngineSupport $support): TccEngine
+ {
+ return new TccEngine($registry, new TccExecutionOrchestrator($invoker, $support, $settings->tcc), $support, $settings->tcc);
+ }
+
+ #[Bean]
+ #[Lazy]
+ #[ConditionalOnMissingBean(TccCompositor::class)]
+ public function tccCompositor(OrchestrationSettings $settings, TccEngine $engine, EngineSupport $support, ConditionEvaluator $conditions, ResilienceRegistry $resilience, ResilienceStore $store): TccCompositor
+ {
+ // Task 12 fix: the compositor honours tcc.composition.compensation-policy and needs the resilience
+ // registry and store for retry-with-backoff / circuit-breaker, exactly as SagaCompensator does.
+ return new TccCompositor($engine, $support->events, $conditions, new CompositionDataFlowManager, $settings->tcc, $resilience, $store);
+ }
+
+ #[Bean]
+ #[Lazy]
+ #[ConditionalOnMissingBean(WorkflowDispatcher::class)]
+ public function workflowDispatcher(OrchestrationSettings $settings, Dispatcher $bus): WorkflowDispatcher
+ {
+ return new BusWorkflowDispatcher($bus, $settings->workflow->queueConnection, $settings->workflow->queueName);
+ }
+
+ #[Bean]
+ #[Lazy]
+ #[ConditionalOnMissingBean(WorkflowEngine::class)]
+ public function workflowEngine(OrchestrationSettings $settings, WorkflowRegistry $registry, StepInvoker $invoker, EngineSupport $support, ConditionEvaluator $conditions, WorkflowDispatcher $dispatcher, Container $app): WorkflowEngine
+ {
+ return new WorkflowEngine(
+ $registry,
+ new WorkflowExecutor($invoker, $support, $conditions, new WaitEvaluator($support->events)),
+ $support,
+ $settings->workflow,
+ $dispatcher,
+ $app,
+ $settings->backpressure->batchSize,
+ );
+ }
+
+ #[Bean]
+ #[Lazy]
+ #[ConditionalOnMissingBean(Orchestrator::class)]
+ public function orchestrator(SagaEngine $sagas, TccEngine $tccs, WorkflowEngine $workflows, ExecutionPersistenceProvider $persistence): Orchestrator
+ {
+ return new Orchestrator($sagas, $tccs, $workflows, $persistence);
+ }
+
+ #[Bean]
+ #[Lazy]
+ #[ConditionalOnMissingBean(DeadLetterRetrier::class)]
+ public function deadLetterRetrier(DeadLetterService $deadLetters, Orchestrator $orchestrator): DeadLetterRetrier
+ {
+ return new DeadLetterRetrier($deadLetters, $orchestrator);
+ }
+
+ #[Bean]
+ #[Lazy]
+ #[ConditionalOnMissingBean(RecoveryService::class)]
+ public function recoveryService(OrchestrationSettings $settings, ExecutionPersistenceProvider $persistence, SagaEngine $sagas, TccEngine $tccs, WorkflowEngine $workflows, ExecutionLock $lock, Clock $clock, Container $app): RecoveryService
+ {
+ return new RecoveryService($persistence, $sagas, $tccs, $workflows, $lock, $settings->recovery, $settings->persistence, $clock, self::logger($app), $settings->backpressure->batchSize);
+ }
+
+ private static function cacheStore(Container $app, ?string $store): CacheRepository
+ {
+ if (! $app->bound(CacheFactory::class)) {
+ throw new ConfigurationException('Configuration key [firefly.orchestration.persistence.provider] is cache but no cache manager is bound.');
+ }
+
+ /** @var CacheFactory $cache */
+ $cache = $app->make(CacheFactory::class);
+
+ return $cache->store($store);
+ }
+
+ private static function connection(Container $app, ?string $connection, string $key): ConnectionInterface
+ {
+ $databases = $app->bound('db') ? $app->make('db') : null;
+ if (! $databases instanceof ConnectionResolverInterface) {
+ throw new ConfigurationException("Configuration key [{$key}] is database but no database connection resolver (db) is bound.");
+ }
+
+ return $databases->connection($connection);
+ }
+
+ private static function logger(Container $app): LoggerInterface
+ {
+ $logger = $app->bound(LoggerInterface::class) ? $app->make(LoggerInterface::class) : null;
+
+ return $logger instanceof LoggerInterface ? $logger : new NullLogger;
+ }
+}
diff --git a/packages/orchestration/src/OrchestrationServiceProvider.php b/packages/orchestration/src/OrchestrationServiceProvider.php
new file mode 100644
index 00000000..1f0b06e7
--- /dev/null
+++ b/packages/orchestration/src/OrchestrationServiceProvider.php
@@ -0,0 +1,27 @@
+ */
+ public const array PERSISTENCE_PROVIDERS = ['memory', 'cache', 'database', 'event-sourced'];
+
+ /** @var list */
+ public const array DLQ_STORES = ['memory', 'database'];
+
+ /** @var list */
+ public const array RECOVERY_ACTIONS = [RecoverySettings::REPORT, RecoverySettings::RESUME, RecoverySettings::COMPENSATE];
+
+ /** @var list */
+ public const array BACKPRESSURE_STRATEGIES = ['none', 'circuit-breaker'];
+
+ public function __construct(
+ public SagaSettings $saga,
+ public TccSettings $tcc,
+ public WorkflowSettings $workflow,
+ public PersistenceSettings $persistence,
+ public RecoverySettings $recovery,
+ public DlqSettings $dlq,
+ public EventsSettings $events,
+ public RestSettings $rest,
+ public BackpressureSettings $backpressure,
+ public LockSettings $lock,
+ public bool $healthEnabled = true,
+ public bool $metricsEnabled = true,
+ public bool $tracingEnabled = true,
+ public bool $validationEnabled = true,
+ public bool $failOnWarning = false,
+ ) {}
+
+ public static function defaults(): self
+ {
+ return self::fromConfig(new Config(new Repository([])));
+ }
+
+ /** A duration in seconds as the string a ScheduledDescriptor takes: 3600.0 → '3600s', 0.5 → '0.5s'. */
+ public static function interval(float $seconds): string
+ {
+ return $seconds.'s';
+ }
+
+ public static function fromConfig(Config $config): self
+ {
+ $durations = new DurationReader($config);
+
+ $saga = new SagaSettings(
+ enabled: $config->bool('firefly.orchestration.saga.enabled', true),
+ compensationPolicy: CompensationPolicy::fromConfig($config->string('firefly.orchestration.saga.compensation-policy', 'strict-sequential'), 'firefly.orchestration.saga.compensation-policy'),
+ defaultTimeout: $durations->get('firefly.orchestration.saga.default-timeout', 300.0),
+ compensationErrorHandler: self::nonEmpty($config->string('firefly.orchestration.saga.compensation-error-handler', 'default'), 'firefly.orchestration.saga.compensation-error-handler'),
+ );
+
+ $tcc = new TccSettings(
+ enabled: $config->bool('firefly.orchestration.tcc.enabled', true),
+ defaultTimeout: $durations->get('firefly.orchestration.tcc.default-timeout', 30.0),
+ compositionEnabled: $config->bool('firefly.orchestration.tcc.composition.enabled', true),
+ compositionCompensationPolicy: CompensationPolicy::fromConfig($config->string('firefly.orchestration.tcc.composition.compensation-policy', 'strict-sequential'), 'firefly.orchestration.tcc.composition.compensation-policy'),
+ );
+
+ $workflow = new WorkflowSettings(
+ enabled: $config->bool('firefly.orchestration.workflow.enabled', true),
+ queueConnection: self::nullableName($config->get('firefly.orchestration.workflow.queue.connection'), 'firefly.orchestration.workflow.queue.connection'),
+ queueName: self::nullableName($config->get('firefly.orchestration.workflow.queue.name'), 'firefly.orchestration.workflow.queue.name'),
+ tickEnabled: $config->bool('firefly.orchestration.workflow.tick.enabled', true),
+ tickInterval: $durations->get('firefly.orchestration.workflow.tick.interval', 60.0, 'a zero tick interval would schedule the timer sweep continuously'),
+ maxResumeAttempts: self::atLeast($config->int('firefly.orchestration.workflow.max-resume-attempts', 5), 1, 'firefly.orchestration.workflow.max-resume-attempts'),
+ );
+
+ $provider = $config->string('firefly.orchestration.persistence.provider', 'memory');
+ if (! in_array($provider, self::PERSISTENCE_PROVIDERS, true)) {
+ throw new ConfigurationException("Configuration key [firefly.orchestration.persistence.provider] must be one of memory, cache, database or event-sourced, got [{$provider}].");
+ }
+ $keyTtl = $durations->get('firefly.orchestration.persistence.key-ttl', 0.0);
+ $persistence = new PersistenceSettings(
+ provider: $provider,
+ connection: self::nullableName($config->get('firefly.orchestration.persistence.connection'), 'firefly.orchestration.persistence.connection'),
+ cacheStore: self::nullableName($config->get('firefly.orchestration.persistence.cache-store'), 'firefly.orchestration.persistence.cache-store'),
+ keyPrefix: $config->string('firefly.orchestration.persistence.key-prefix', 'orchestration:'),
+ keyTtl: $keyTtl > 0.0 ? (int) ceil($keyTtl) : null,
+ retentionPeriod: $durations->get('firefly.orchestration.persistence.retention-period', 604800.0, 'a zero retention period would delete every execution the moment it ends'),
+ cleanupInterval: $durations->get('firefly.orchestration.persistence.cleanup-interval', 3600.0, 'a zero cleanup interval would run the recovery sweep continuously'),
+ );
+
+ $action = $config->string('firefly.orchestration.recovery.action', RecoverySettings::REPORT);
+ if (! in_array($action, self::RECOVERY_ACTIONS, true)) {
+ throw new ConfigurationException("Configuration key [firefly.orchestration.recovery.action] must be report, resume or compensate, got [{$action}].");
+ }
+ $recovery = new RecoverySettings(
+ enabled: $config->bool('firefly.orchestration.recovery.enabled', true),
+ staleThreshold: $durations->get('firefly.orchestration.recovery.stale-threshold', 3600.0),
+ action: $action,
+ );
+
+ $store = $config->string('firefly.orchestration.dlq.store', 'memory');
+ if (! in_array($store, self::DLQ_STORES, true)) {
+ throw new ConfigurationException("Configuration key [firefly.orchestration.dlq.store] must be memory or database, got [{$store}].");
+ }
+
+ $path = '/'.trim($config->string('firefly.orchestration.rest.path', '/orchestration'), '/');
+ if ($path === '/') {
+ throw new ConfigurationException('Configuration key [firefly.orchestration.rest.path] must name a path such as /orchestration, not the application root.');
+ }
+
+ $strategy = $config->string('firefly.orchestration.backpressure.strategy', 'none');
+ if (! in_array($strategy, self::BACKPRESSURE_STRATEGIES, true)) {
+ throw new ConfigurationException("Configuration key [firefly.orchestration.backpressure.strategy] must be none or circuit-breaker, got [{$strategy}]. jafly's adaptive and batched strategies schedule reactive streams and have no meaning in a synchronous PHP process; use max-in-flight and batch-size instead.");
+ }
+
+ return new self(
+ saga: $saga,
+ tcc: $tcc,
+ workflow: $workflow,
+ persistence: $persistence,
+ recovery: $recovery,
+ dlq: new DlqSettings($config->bool('firefly.orchestration.dlq.enabled', true), $store),
+ events: new EventsSettings(
+ enabled: $config->bool('firefly.orchestration.events.enabled', false),
+ defaultTopic: $config->string('firefly.orchestration.events.default-topic', ''),
+ gatewayEnabled: $config->bool('firefly.orchestration.event-gateway.enabled', true),
+ ),
+ rest: new RestSettings($config->bool('firefly.orchestration.rest.enabled', false), $path),
+ backpressure: new BackpressureSettings(
+ strategy: $strategy,
+ batchSize: self::atLeast($config->int('firefly.orchestration.backpressure.batch-size', 10), 1, 'firefly.orchestration.backpressure.batch-size'),
+ maxInFlight: self::atLeast($config->int('firefly.orchestration.backpressure.max-in-flight', 0), 0, 'firefly.orchestration.backpressure.max-in-flight'),
+ failureThreshold: self::atLeast($config->int('firefly.orchestration.backpressure.circuit-breaker.failure-threshold', 5), 1, 'firefly.orchestration.backpressure.circuit-breaker.failure-threshold'),
+ recoveryTimeout: $durations->get('firefly.orchestration.backpressure.circuit-breaker.recovery-timeout', 30.0),
+ halfOpenMaxCalls: self::atLeast($config->int('firefly.orchestration.backpressure.circuit-breaker.half-open-max-calls', 3), 1, 'firefly.orchestration.backpressure.circuit-breaker.half-open-max-calls'),
+ ),
+ lock: new LockSettings(
+ ttl: $durations->get('firefly.orchestration.lock.ttl', 300.0, 'a zero lock lifetime would let a second process in immediately'),
+ wait: $durations->get('firefly.orchestration.lock.wait', 5.0),
+ ),
+ healthEnabled: $config->bool('firefly.orchestration.health.enabled', true),
+ metricsEnabled: $config->bool('firefly.orchestration.metrics.enabled', true),
+ tracingEnabled: $config->bool('firefly.orchestration.tracing.enabled', true),
+ validationEnabled: $config->bool('firefly.orchestration.validation.enabled', true),
+ failOnWarning: $config->bool('firefly.orchestration.validation.fail-on-warning', false),
+ );
+ }
+
+ private static function nonEmpty(string $value, string $key): string
+ {
+ if (trim($value) === '') {
+ throw new ConfigurationException("Configuration key [{$key}] must not be empty.");
+ }
+
+ return $value;
+ }
+
+ private static function nullableName(mixed $value, string $key): ?string
+ {
+ if ($value === null || $value === '') {
+ return null;
+ }
+ if (! is_string($value)) {
+ throw new ConfigurationException("Configuration key [{$key}] must be a name (or null for the default), got ".get_debug_type($value).'.');
+ }
+
+ return $value;
+ }
+
+ private static function atLeast(int $value, int $minimum, string $key): int
+ {
+ if ($value < $minimum) {
+ throw new ConfigurationException("Configuration key [{$key}] must be at least {$minimum}, got [{$value}].");
+ }
+
+ return $value;
+ }
+}
diff --git a/packages/orchestration/src/OrchestrationWiringProvider.php b/packages/orchestration/src/OrchestrationWiringProvider.php
new file mode 100644
index 00000000..c6187ee5
--- /dev/null
+++ b/packages/orchestration/src/OrchestrationWiringProvider.php
@@ -0,0 +1,92 @@
+app->bound(OrchestrationManifest::class)) {
+ $this->app->singleton(OrchestrationManifest::class, static function (Container $app): OrchestrationManifest {
+ if (($file = AppScan::cachedFile($app, AppScan::ORCHESTRATION)) !== null) {
+ return OrchestrationManifest::load($file);
+ }
+
+ $paths = AppScan::paths($app);
+
+ return $paths === [] ? OrchestrationManifest::empty() : (new OrchestrationScanner)->scan($paths);
+ });
+ }
+
+ parent::register();
+ }
+
+ /**
+ * @return list
+ */
+ public function passes(): array
+ {
+ return [new OrchestrationRegistryPass, new EventGatewayWiringPass, new OrchestrationSchedulePass, new ScheduledExecutionsPass, new OrchestrationRouteRegistrar];
+ }
+
+ public function boot(): void
+ {
+ if ($this->app->runningInConsole()) {
+ $this->commands([
+ ListExecutionsCommand::class,
+ ShowExecutionCommand::class,
+ SignalWorkflowCommand::class,
+ DeadLetterCommand::class,
+ RetryDeadLetterCommand::class,
+ TickCommand::class,
+ ]);
+ }
+
+ $migrations = dirname(__DIR__).'/database/migrations';
+ $this->publishes([$migrations.'/'.self::MIGRATION => $this->app->databasePath('migrations/'.self::MIGRATION)], 'firefly-orchestration-migrations');
+
+ $config = new Config($this->app->make('config'));
+ if ($config->string('firefly.orchestration.persistence.provider', 'memory') === 'database'
+ || $config->string('firefly.orchestration.dlq.store', 'memory') === 'database') {
+ $this->loadMigrationsFrom($migrations);
+ }
+ }
+}
diff --git a/packages/orchestration/src/Persistence/CacheExecutionPersistenceProvider.php b/packages/orchestration/src/Persistence/CacheExecutionPersistenceProvider.php
new file mode 100644
index 00000000..c9bfc763
--- /dev/null
+++ b/packages/orchestration/src/Persistence/CacheExecutionPersistenceProvider.php
@@ -0,0 +1,196 @@
+key($state->correlationId);
+ if ($this->ttlSeconds === null) {
+ $this->cache->forever($key, $state->toArray());
+ } else {
+ $this->cache->put($key, $state->toArray(), $this->ttlSeconds);
+ }
+
+ $id = $state->correlationId;
+ $this->rewriteIndex(static fn (array $ids): array => in_array($id, $ids, true) ? $ids : [...$ids, $id]);
+ }
+
+ public function findById(string $correlationId): ?ExecutionState
+ {
+ $row = $this->cache->get($this->key($correlationId));
+
+ return is_array($row) ? ExecutionState::fromArray($row) : null;
+ }
+
+ public function find(ExecutionCriteria $criteria): array
+ {
+ return MemoryExecutionPersistenceProvider::newestFirst(array_filter($this->all(), static fn (ExecutionState $state): bool => $criteria->matches($state)), $criteria->limit);
+ }
+
+ public function findInFlight(int $limit = 100): array
+ {
+ return MemoryExecutionPersistenceProvider::newestFirst(array_filter($this->all(), static fn (ExecutionState $state): bool => ! $state->isTerminal()), $limit);
+ }
+
+ public function findStale(DateTimeImmutable $before, int $limit = 100): array
+ {
+ return MemoryExecutionPersistenceProvider::newestFirst(array_filter(
+ $this->all(),
+ static fn (ExecutionState $state): bool => $state->status->isRecoverable() && $state->updatedAt < $before,
+ ), $limit);
+ }
+
+ public function findDue(DateTimeImmutable $now, int $limit = 100): array
+ {
+ $memory = new MemoryExecutionPersistenceProvider;
+ foreach ($this->all() as $state) {
+ $memory->save($state);
+ }
+
+ return $memory->findDue($now, $limit);
+ }
+
+ public function findBySearchAttributes(array $criteria, int $limit = 100): array
+ {
+ return MemoryExecutionPersistenceProvider::newestFirst(array_filter(
+ $this->all(),
+ static fn (ExecutionState $state): bool => MemoryExecutionPersistenceProvider::hasAttributes($state, $criteria),
+ ), $limit);
+ }
+
+ public function updateStatus(string $correlationId, ExecutionStatus $status, DateTimeImmutable $now): void
+ {
+ $state = $this->findById($correlationId);
+ if ($state !== null) {
+ $this->save($state->withStatus($status, $now));
+ }
+ }
+
+ public function delete(string $correlationId): bool
+ {
+ $existed = $this->cache->has($this->key($correlationId));
+ $this->cache->forget($this->key($correlationId));
+ $this->rewriteIndex(static fn (array $ids): array => array_values(array_filter($ids, static fn (string $id): bool => $id !== $correlationId)));
+
+ return $existed;
+ }
+
+ public function cleanup(DateTimeImmutable $before): int
+ {
+ $removed = 0;
+ foreach ($this->ids() as $id) {
+ $state = $this->findById($id);
+ if ($state === null) {
+ $this->delete($id);
+
+ continue;
+ }
+ if ($state->isTerminal() && $state->updatedAt < $before) {
+ $this->delete($id);
+ $removed++;
+ }
+ }
+
+ return $removed;
+ }
+
+ public function countInFlight(): int
+ {
+ return count(array_filter($this->all(), static fn (ExecutionState $state): bool => ! $state->isTerminal()));
+ }
+
+ public function countByStatus(): array
+ {
+ $counts = [];
+ foreach ($this->all() as $state) {
+ $counts[$state->status->value] = ($counts[$state->status->value] ?? 0) + 1;
+ }
+
+ return $counts;
+ }
+
+ public function isHealthy(): bool
+ {
+ try {
+ $probe = $this->prefix.'health';
+ $this->cache->put($probe, 'ok', 10);
+
+ return $this->cache->get($probe) === 'ok';
+ } catch (Throwable) {
+ return false;
+ }
+ }
+
+ private function key(string $correlationId): string
+ {
+ return $this->prefix.'execution:'.$correlationId;
+ }
+
+ /** @return list */
+ private function ids(): array
+ {
+ $ids = $this->cache->get($this->prefix.'index', []);
+
+ return is_array($ids) ? array_values(array_filter($ids, 'is_string')) : [];
+ }
+
+ /** @return list */
+ private function all(): array
+ {
+ $states = [];
+ foreach ($this->ids() as $id) {
+ $state = $this->findById($id);
+ if ($state !== null) {
+ $states[] = $state;
+ }
+ }
+
+ return $states;
+ }
+
+ /**
+ * @param Closure(list): list $change
+ */
+ private function rewriteIndex(Closure $change): void
+ {
+ $apply = function () use ($change): void {
+ $this->cache->forever($this->prefix.'index', $change($this->ids()));
+ };
+
+ $store = $this->cache->getStore();
+ if ($store instanceof LockProvider) {
+ $store->lock($this->prefix.'index-lock', 10)->block(5, $apply);
+
+ return;
+ }
+
+ $apply();
+ }
+}
diff --git a/packages/orchestration/src/Persistence/DatabaseExecutionPersistenceProvider.php b/packages/orchestration/src/Persistence/DatabaseExecutionPersistenceProvider.php
new file mode 100644
index 00000000..0a33c862
--- /dev/null
+++ b/packages/orchestration/src/Persistence/DatabaseExecutionPersistenceProvider.php
@@ -0,0 +1,282 @@
+correlationId, OrchestrationSchema::ID_LENGTH);
+ OrchestrationSchema::fit('parentCorrelationId', $state->parentCorrelationId, OrchestrationSchema::ID_LENGTH);
+ OrchestrationSchema::fit('executionName', $state->executionName, OrchestrationSchema::NAME_LENGTH);
+
+ $attributes = [];
+ foreach ($state->searchAttributes as $key => $value) {
+ $key = (string) $key;
+ $value = ExecutionState::attributeValue($value);
+ OrchestrationSchema::fit('searchAttributes key', $key, OrchestrationSchema::ATTRIBUTE_KEY_LENGTH);
+ OrchestrationSchema::fit("searchAttributes.{$key}", $value, OrchestrationSchema::ATTRIBUTE_VALUE_LENGTH);
+ $attributes[] = ['correlation_id' => $state->correlationId, 'attribute_key' => $key, 'attribute_value' => $value];
+ }
+
+ $row = [
+ 'correlation_id' => $state->correlationId,
+ 'execution_name' => $state->executionName,
+ 'pattern' => $state->pattern->value,
+ 'status' => $state->status->value,
+ 'parent_correlation_id' => $state->parentCorrelationId,
+ 'started_at' => OrchestrationSchema::instant($state->startedAt),
+ 'updated_at' => OrchestrationSchema::instant($state->updatedAt),
+ 'completed_at' => $state->completedAt === null ? null : OrchestrationSchema::instant($state->completedAt),
+ 'wake_at' => $state->wakeAt === null ? null : OrchestrationSchema::instant($state->wakeAt),
+ 'payload' => StateSerializer::encode($state),
+ ];
+
+ $this->connection->transaction(function () use ($row, $state, $attributes): void {
+ $this->connection->table(OrchestrationSchema::EXECUTIONS)->upsert([$row], ['correlation_id'], self::UPDATED);
+
+ $stored = $this->attributeKeys($state->correlationId);
+ $obsolete = array_values(array_diff($stored, array_column($attributes, 'attribute_key')));
+ if ($obsolete !== []) {
+ $this->connection->table(OrchestrationSchema::SEARCH_ATTRIBUTES)
+ ->where('correlation_id', $state->correlationId)
+ ->whereIn('attribute_key', $obsolete)
+ ->delete();
+ }
+
+ $new = [];
+ foreach ($attributes as $attribute) {
+ if (! in_array($attribute['attribute_key'], $stored, true)) {
+ $new[] = $attribute;
+
+ continue;
+ }
+ $this->connection->table(OrchestrationSchema::SEARCH_ATTRIBUTES)
+ ->where('correlation_id', $attribute['correlation_id'])
+ ->where('attribute_key', $attribute['attribute_key'])
+ ->update(['attribute_value' => $attribute['attribute_value']]);
+ }
+
+ if ($new !== []) {
+ $this->connection->table(OrchestrationSchema::SEARCH_ATTRIBUTES)->insert($new);
+ }
+ }, self::ATTEMPTS);
+ }
+
+ /**
+ * The search-attribute keys stored for one execution, read with a plain (non-locking) select. The
+ * execution row's upsert above already holds that row's lock, so no other save of the same execution
+ * can change these keys before this transaction ends; the engine's execution lock already keeps two
+ * saves of one execution apart, so the read never needs to lock the attribute rows themselves.
+ *
+ * @return list
+ */
+ private function attributeKeys(string $correlationId): array
+ {
+ return array_values(array_filter($this->connection->table(OrchestrationSchema::SEARCH_ATTRIBUTES)
+ ->where('correlation_id', $correlationId)
+ ->pluck('attribute_key')
+ ->all(), 'is_string'));
+ }
+
+ public function findById(string $correlationId): ?ExecutionState
+ {
+ $payload = $this->connection->table(OrchestrationSchema::EXECUTIONS)->where('correlation_id', $correlationId)->value('payload');
+
+ return is_string($payload) ? StateSerializer::decode($payload) : null;
+ }
+
+ public function find(ExecutionCriteria $criteria): array
+ {
+ $query = $this->executions();
+ if ($criteria->pattern !== null) {
+ $query->where('pattern', $criteria->pattern->value);
+ }
+ if ($criteria->status !== null) {
+ $query->where('status', $criteria->status->value);
+ }
+ if ($criteria->name !== null) {
+ $query->where('execution_name', $criteria->name);
+ }
+
+ return $this->newestFirst($query, $criteria->limit);
+ }
+
+ public function findInFlight(int $limit = 100): array
+ {
+ return $this->newestFirst($this->executions()->whereNotIn('status', self::values(ExecutionStatus::terminal())), $limit);
+ }
+
+ public function findStale(DateTimeImmutable $before, int $limit = 100): array
+ {
+ return $this->newestFirst(
+ $this->executions()->whereIn('status', self::values(ExecutionStatus::recoverable()))->where('updated_at', '<', OrchestrationSchema::instant($before)),
+ $limit,
+ );
+ }
+
+ public function findDue(DateTimeImmutable $now, int $limit = 100): array
+ {
+ return $this->decode($this->executions()
+ ->where('status', ExecutionStatus::Waiting->value)
+ ->whereNotNull('wake_at')
+ ->where('wake_at', '<=', OrchestrationSchema::instant($now))
+ ->orderBy('wake_at')
+ ->orderBy('correlation_id')
+ ->limit(max(0, $limit))
+ ->pluck('payload')
+ ->all());
+ }
+
+ public function findBySearchAttributes(array $criteria, int $limit = 100): array
+ {
+ $query = $this->connection->table(OrchestrationSchema::EXECUTIONS.' as e');
+ foreach ($criteria as $key => $value) {
+ $query->whereExists(static function (Builder $attribute) use ($key, $value): void {
+ $attribute->selectRaw('1')
+ ->from(OrchestrationSchema::SEARCH_ATTRIBUTES.' as a')
+ ->whereColumn('a.correlation_id', 'e.correlation_id')
+ ->where('a.attribute_key', $key)
+ ->where('a.attribute_value', ExecutionState::attributeValue($value));
+ });
+ }
+
+ return $this->decode($query->orderByDesc('e.updated_at')->orderBy('e.correlation_id')->limit(max(0, $limit))->pluck('e.payload')->all());
+ }
+
+ public function updateStatus(string $correlationId, ExecutionStatus $status, DateTimeImmutable $now): void
+ {
+ $state = $this->findById($correlationId);
+ if ($state !== null) {
+ $this->save($state->withStatus($status, $now));
+ }
+ }
+
+ public function delete(string $correlationId): bool
+ {
+ return $this->connection->transaction(function () use ($correlationId): bool {
+ $this->connection->table(OrchestrationSchema::SEARCH_ATTRIBUTES)->where('correlation_id', $correlationId)->delete();
+
+ return $this->connection->table(OrchestrationSchema::EXECUTIONS)->where('correlation_id', $correlationId)->delete() > 0;
+ });
+ }
+
+ public function cleanup(DateTimeImmutable $before): int
+ {
+ $ids = array_values(array_filter($this->executions()
+ ->whereIn('status', self::values(ExecutionStatus::terminal()))
+ ->where('updated_at', '<', OrchestrationSchema::instant($before))
+ ->pluck('correlation_id')
+ ->all(), 'is_string'));
+
+ foreach (array_chunk($ids, 500) as $chunk) {
+ $this->connection->transaction(function () use ($chunk): void {
+ $this->connection->table(OrchestrationSchema::SEARCH_ATTRIBUTES)->whereIn('correlation_id', $chunk)->delete();
+ $this->connection->table(OrchestrationSchema::EXECUTIONS)->whereIn('correlation_id', $chunk)->delete();
+ });
+ }
+
+ return count($ids);
+ }
+
+ public function countInFlight(): int
+ {
+ return $this->executions()->whereNotIn('status', self::values(ExecutionStatus::terminal()))->count();
+ }
+
+ public function countByStatus(): array
+ {
+ $counts = [];
+ foreach ($this->executions()->select('status')->selectRaw('count(*) as total')->groupBy('status')->get() as $row) {
+ $status = isset($row->status) && is_string($row->status) ? $row->status : null;
+ $total = isset($row->total) && is_numeric($row->total) ? (int) $row->total : 0;
+ if ($status !== null) {
+ $counts[$status] = $total;
+ }
+ }
+
+ return $counts;
+ }
+
+ public function isHealthy(): bool
+ {
+ try {
+ $this->executions()->limit(1)->get(['correlation_id']);
+
+ return true;
+ } catch (Throwable) {
+ return false;
+ }
+ }
+
+ private function executions(): Builder
+ {
+ return $this->connection->table(OrchestrationSchema::EXECUTIONS);
+ }
+
+ /** @return list */
+ private function newestFirst(Builder $query, int $limit): array
+ {
+ return $this->decode($query->orderByDesc('updated_at')->orderBy('correlation_id')->limit(max(0, $limit))->pluck('payload')->all());
+ }
+
+ /**
+ * @param array $payloads
+ * @return list
+ */
+ private function decode(array $payloads): array
+ {
+ $states = [];
+ foreach ($payloads as $payload) {
+ if (is_string($payload)) {
+ $states[] = StateSerializer::decode($payload);
+ }
+ }
+
+ return $states;
+ }
+
+ /**
+ * @param list $statuses
+ * @return list
+ */
+ private static function values(array $statuses): array
+ {
+ return array_map(static fn (ExecutionStatus $status): string => $status->value, $statuses);
+ }
+}
diff --git a/packages/orchestration/src/Persistence/ExecutionCriteria.php b/packages/orchestration/src/Persistence/ExecutionCriteria.php
new file mode 100644
index 00000000..d95187c4
--- /dev/null
+++ b/packages/orchestration/src/Persistence/ExecutionCriteria.php
@@ -0,0 +1,26 @@
+pattern === null || $state->pattern === $this->pattern)
+ && ($this->status === null || $state->status === $this->status)
+ && ($this->name === null || $state->executionName === $this->name);
+ }
+}
diff --git a/packages/orchestration/src/Persistence/ExecutionPersistenceProvider.php b/packages/orchestration/src/Persistence/ExecutionPersistenceProvider.php
new file mode 100644
index 00000000..165683d3
--- /dev/null
+++ b/packages/orchestration/src/Persistence/ExecutionPersistenceProvider.php
@@ -0,0 +1,56 @@
+ newest update first, then by correlation id */
+ public function find(ExecutionCriteria $criteria): array;
+
+ /** @return list every execution whose status is not terminal */
+ public function findInFlight(int $limit = 100): array;
+
+ /** @return list recoverable-status executions last updated before $before */
+ public function findStale(DateTimeImmutable $before, int $limit = 100): array;
+
+ /** @return list WAITING executions whose wake-up instant is at or before $now, earliest first */
+ public function findDue(DateTimeImmutable $now, int $limit = 100): array;
+
+ /**
+ * @param array $criteria every attribute must equal (ExecutionState::attributeValue form)
+ * @return list
+ */
+ public function findBySearchAttributes(array $criteria, int $limit = 100): array;
+
+ public function updateStatus(string $correlationId, ExecutionStatus $status, DateTimeImmutable $now): void;
+
+ public function delete(string $correlationId): bool;
+
+ /** Deletes terminal executions last updated before $before; returns how many. */
+ public function cleanup(DateTimeImmutable $before): int;
+
+ public function countInFlight(): int;
+
+ /** @return array status value => executions */
+ public function countByStatus(): array;
+
+ public function isHealthy(): bool;
+}
diff --git a/packages/orchestration/src/Persistence/ExecutionState.php b/packages/orchestration/src/Persistence/ExecutionState.php
new file mode 100644
index 00000000..906dffa0
--- /dev/null
+++ b/packages/orchestration/src/Persistence/ExecutionState.php
@@ -0,0 +1,230 @@
+ $input
+ * @param array $headers
+ * @param array $variables
+ * @param array $stepResults
+ * @param array $stepStatuses
+ * @param array $stepAttempts
+ * @param array $stepLatenciesMs
+ * @param array $stepInputs
+ * @param list $completionOrder
+ * @param array $compensationResults
+ * @param array $compensationErrors
+ * @param array $stepErrors
+ * @param array $tryResults
+ * @param list $idempotencyKeys
+ * @param list> $topologyLayers
+ * @param array $searchAttributes
+ * @param array $signals
+ * @param array $timers timer id => fire-at instant (UTC, see instant())
+ * @param array $children step id => child correlation id
+ * @param array|null $report
+ */
+ public function __construct(
+ public string $correlationId,
+ public string $executionName,
+ public ExecutionPattern $pattern,
+ public ExecutionStatus $status,
+ public DateTimeImmutable $startedAt,
+ public DateTimeImmutable $updatedAt,
+ public array $input = [],
+ public array $headers = [],
+ public array $variables = [],
+ public array $stepResults = [],
+ public array $stepStatuses = [],
+ public array $stepAttempts = [],
+ public array $stepLatenciesMs = [],
+ public array $stepInputs = [],
+ public array $completionOrder = [],
+ public array $compensationResults = [],
+ public array $compensationErrors = [],
+ public array $stepErrors = [],
+ public array $tryResults = [],
+ public ?TccPhase $currentPhase = null,
+ public array $idempotencyKeys = [],
+ public array $topologyLayers = [],
+ public ?string $failureReason = null,
+ public ?DateTimeImmutable $completedAt = null,
+ public ?DateTimeImmutable $wakeAt = null,
+ public array $searchAttributes = [],
+ public array $signals = [],
+ public array $timers = [],
+ public ?string $waitingOn = null,
+ public ?string $parentCorrelationId = null,
+ public array $children = [],
+ public ?array $report = null,
+ ) {}
+
+ public function isTerminal(): bool
+ {
+ return $this->status->isTerminal();
+ }
+
+ public function stepStatus(string $stepId): StepStatus
+ {
+ return $this->stepStatuses[$stepId] ?? StepStatus::Pending;
+ }
+
+ public function withStatus(ExecutionStatus $status, DateTimeImmutable $now): self
+ {
+ $data = $this->toArray();
+ $data['status'] = $status->value;
+ $data['updatedAt'] = self::instant($now);
+ $data['completedAt'] = $status->isTerminal() ? self::instant($now) : null;
+ if ($status !== ExecutionStatus::Waiting) {
+ $data['wakeAt'] = null;
+ }
+
+ return self::fromArray($data);
+ }
+
+ /**
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return [
+ 'correlationId' => $this->correlationId,
+ 'executionName' => $this->executionName,
+ 'pattern' => $this->pattern->value,
+ 'status' => $this->status->value,
+ 'startedAt' => self::instant($this->startedAt),
+ 'updatedAt' => self::instant($this->updatedAt),
+ 'completedAt' => $this->completedAt === null ? null : self::instant($this->completedAt),
+ 'wakeAt' => $this->wakeAt === null ? null : self::instant($this->wakeAt),
+ 'input' => $this->input,
+ 'headers' => $this->headers,
+ 'variables' => $this->variables,
+ 'stepResults' => $this->stepResults,
+ 'stepStatuses' => array_map(static fn (StepStatus $status): string => $status->value, $this->stepStatuses),
+ 'stepAttempts' => $this->stepAttempts,
+ 'stepLatenciesMs' => $this->stepLatenciesMs,
+ 'stepInputs' => $this->stepInputs,
+ 'completionOrder' => $this->completionOrder,
+ 'compensationResults' => $this->compensationResults,
+ 'compensationErrors' => $this->compensationErrors,
+ 'stepErrors' => $this->stepErrors,
+ 'tryResults' => $this->tryResults,
+ 'currentPhase' => $this->currentPhase?->value,
+ 'idempotencyKeys' => $this->idempotencyKeys,
+ 'topologyLayers' => $this->topologyLayers,
+ 'failureReason' => $this->failureReason,
+ 'searchAttributes' => $this->searchAttributes,
+ 'signals' => $this->signals,
+ 'timers' => $this->timers,
+ 'waitingOn' => $this->waitingOn,
+ 'parentCorrelationId' => $this->parentCorrelationId,
+ 'children' => $this->children,
+ 'report' => $this->report,
+ ];
+ }
+
+ /**
+ * @param array $data
+ */
+ public static function fromArray(array $data): self
+ {
+ $statuses = [];
+ foreach (Rows::stringMap($data, 'stepStatuses') as $step => $status) {
+ $statuses[$step] = StepStatus::tryFrom($status) ?? StepStatus::Pending;
+ }
+
+ $layers = [];
+ foreach (Rows::list($data, 'topologyLayers') as $layer) {
+ if (is_array($layer)) {
+ $layers[] = Rows::strings(['layer' => $layer], 'layer');
+ }
+ }
+
+ $attributes = [];
+ foreach (Rows::map($data, 'searchAttributes') as $key => $value) {
+ if (is_string($value) || is_int($value) || is_float($value) || is_bool($value)) {
+ $attributes[$key] = $value;
+ }
+ }
+
+ $completedAt = Rows::nullableString($data, 'completedAt');
+ $wakeAt = Rows::nullableString($data, 'wakeAt');
+ $phase = Rows::nullableString($data, 'currentPhase');
+
+ return new self(
+ correlationId: Rows::string($data, 'correlationId'),
+ executionName: Rows::string($data, 'executionName'),
+ pattern: ExecutionPattern::tryFrom(Rows::string($data, 'pattern')) ?? ExecutionPattern::Saga,
+ status: ExecutionStatus::tryFrom(Rows::string($data, 'status')) ?? ExecutionStatus::Pending,
+ startedAt: self::parseInstant(Rows::string($data, 'startedAt', 'now')),
+ updatedAt: self::parseInstant(Rows::string($data, 'updatedAt', 'now')),
+ input: Rows::map($data, 'input'),
+ headers: Rows::stringMap($data, 'headers'),
+ variables: Rows::map($data, 'variables'),
+ stepResults: Rows::map($data, 'stepResults'),
+ stepStatuses: $statuses,
+ stepAttempts: Rows::intMap($data, 'stepAttempts'),
+ stepLatenciesMs: Rows::intMap($data, 'stepLatenciesMs'),
+ stepInputs: Rows::map($data, 'stepInputs'),
+ completionOrder: Rows::strings($data, 'completionOrder'),
+ compensationResults: Rows::map($data, 'compensationResults'),
+ compensationErrors: Rows::stringMap($data, 'compensationErrors'),
+ stepErrors: Rows::stringMap($data, 'stepErrors'),
+ tryResults: Rows::map($data, 'tryResults'),
+ currentPhase: $phase === null ? null : TccPhase::tryFrom($phase),
+ idempotencyKeys: Rows::strings($data, 'idempotencyKeys'),
+ topologyLayers: $layers,
+ failureReason: Rows::nullableString($data, 'failureReason'),
+ completedAt: $completedAt === null ? null : self::parseInstant($completedAt),
+ wakeAt: $wakeAt === null ? null : self::parseInstant($wakeAt),
+ searchAttributes: $attributes,
+ signals: Rows::map($data, 'signals'),
+ timers: Rows::stringMap($data, 'timers'),
+ waitingOn: Rows::nullableString($data, 'waitingOn'),
+ parentCorrelationId: Rows::nullableString($data, 'parentCorrelationId'),
+ children: Rows::stringMap($data, 'children'),
+ report: Rows::nullableMap($data, 'report'),
+ );
+ }
+
+ public static function instant(DateTimeImmutable $at): string
+ {
+ return $at->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d\TH:i:s.uP');
+ }
+
+ public static function parseInstant(string $value): DateTimeImmutable
+ {
+ return (new DateTimeImmutable($value))->setTimezone(new DateTimeZone('UTC'));
+ }
+
+ /**
+ * The one string form a search attribute is compared in, by every provider: a database column holds a
+ * string, so the in-memory and cache providers compare the same string rather than PHP's loose ==.
+ */
+ public static function attributeValue(string|int|float|bool $value): string
+ {
+ return is_bool($value) ? ($value ? 'true' : 'false') : (string) $value;
+ }
+}
diff --git a/packages/orchestration/src/Persistence/MemoryExecutionPersistenceProvider.php b/packages/orchestration/src/Persistence/MemoryExecutionPersistenceProvider.php
new file mode 100644
index 00000000..f31d581d
--- /dev/null
+++ b/packages/orchestration/src/Persistence/MemoryExecutionPersistenceProvider.php
@@ -0,0 +1,142 @@
+ */
+ private array $states = [];
+
+ public function save(ExecutionState $state): void
+ {
+ $this->states[$state->correlationId] = $state;
+ }
+
+ public function findById(string $correlationId): ?ExecutionState
+ {
+ return $this->states[$correlationId] ?? null;
+ }
+
+ public function find(ExecutionCriteria $criteria): array
+ {
+ return self::newestFirst(array_filter($this->states, static fn (ExecutionState $state): bool => $criteria->matches($state)), $criteria->limit);
+ }
+
+ public function findInFlight(int $limit = 100): array
+ {
+ return self::newestFirst(array_filter($this->states, static fn (ExecutionState $state): bool => ! $state->isTerminal()), $limit);
+ }
+
+ public function findStale(DateTimeImmutable $before, int $limit = 100): array
+ {
+ return self::newestFirst(array_filter(
+ $this->states,
+ static fn (ExecutionState $state): bool => $state->status->isRecoverable() && $state->updatedAt < $before,
+ ), $limit);
+ }
+
+ public function findDue(DateTimeImmutable $now, int $limit = 100): array
+ {
+ $due = array_values(array_filter(
+ $this->states,
+ static fn (ExecutionState $state): bool => $state->status === ExecutionStatus::Waiting && $state->wakeAt !== null && $state->wakeAt <= $now,
+ ));
+ usort($due, static fn (ExecutionState $a, ExecutionState $b): int => [$a->wakeAt, $a->correlationId] <=> [$b->wakeAt, $b->correlationId]);
+
+ return array_slice($due, 0, $limit);
+ }
+
+ public function findBySearchAttributes(array $criteria, int $limit = 100): array
+ {
+ return self::newestFirst(array_filter($this->states, static fn (ExecutionState $state): bool => self::hasAttributes($state, $criteria)), $limit);
+ }
+
+ public function updateStatus(string $correlationId, ExecutionStatus $status, DateTimeImmutable $now): void
+ {
+ $state = $this->states[$correlationId] ?? null;
+ if ($state !== null) {
+ $this->states[$correlationId] = $state->withStatus($status, $now);
+ }
+ }
+
+ public function delete(string $correlationId): bool
+ {
+ if (! isset($this->states[$correlationId])) {
+ return false;
+ }
+
+ unset($this->states[$correlationId]);
+
+ return true;
+ }
+
+ public function cleanup(DateTimeImmutable $before): int
+ {
+ $removed = 0;
+ foreach ($this->states as $id => $state) {
+ if ($state->isTerminal() && $state->updatedAt < $before) {
+ unset($this->states[$id]);
+ $removed++;
+ }
+ }
+
+ return $removed;
+ }
+
+ public function countInFlight(): int
+ {
+ return count(array_filter($this->states, static fn (ExecutionState $state): bool => ! $state->isTerminal()));
+ }
+
+ public function countByStatus(): array
+ {
+ $counts = [];
+ foreach ($this->states as $state) {
+ $counts[$state->status->value] = ($counts[$state->status->value] ?? 0) + 1;
+ }
+
+ return $counts;
+ }
+
+ public function isHealthy(): bool
+ {
+ return true;
+ }
+
+ /**
+ * @param array $criteria
+ */
+ public static function hasAttributes(ExecutionState $state, array $criteria): bool
+ {
+ foreach ($criteria as $key => $value) {
+ $actual = $state->searchAttributes[$key] ?? null;
+ if ($actual === null || ExecutionState::attributeValue($actual) !== ExecutionState::attributeValue($value)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * @param array $states
+ * @return list
+ */
+ public static function newestFirst(array $states, int $limit): array
+ {
+ $states = array_values($states);
+ usort($states, static fn (ExecutionState $a, ExecutionState $b): int => [$b->updatedAt, $a->correlationId] <=> [$a->updatedAt, $b->correlationId]);
+
+ return array_slice($states, 0, max(0, $limit));
+ }
+}
diff --git a/packages/orchestration/src/Persistence/OrchestrationSchema.php b/packages/orchestration/src/Persistence/OrchestrationSchema.php
new file mode 100644
index 00000000..1e50d2c0
--- /dev/null
+++ b/packages/orchestration/src/Persistence/OrchestrationSchema.php
@@ -0,0 +1,152 @@
+ $length) {
+ throw new OrchestrationException("{$field} is ".mb_strlen($value, 'UTF-8')." characters long; the orchestration database tables hold at most {$length}.", 'ORCHESTRATION_UNSERIALIZABLE');
+ }
+ }
+
+ public static function instant(DateTimeImmutable $at): string
+ {
+ return $at->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s.u');
+ }
+
+ public static function create(Builder $schema): void
+ {
+ $collation = self::binaryCollation($schema);
+ $binary = static function (ColumnDefinition $column) use ($collation): void {
+ if ($collation === self::SQL_SERVER_BINARY) {
+ $column->collation($collation);
+ } elseif ($collation !== null) {
+ $column->charset('utf8mb4')->collation($collation);
+ }
+ };
+
+ if (! $schema->hasTable(self::EXECUTIONS)) {
+ $schema->create(self::EXECUTIONS, static function (Blueprint $table) use ($binary): void {
+ $binary($table->string('correlation_id', self::ID_LENGTH)->primary());
+ $binary($table->string('execution_name', self::NAME_LENGTH));
+ $table->string('pattern', 16);
+ $table->string('status', 24);
+ $binary($table->string('parent_correlation_id', self::ID_LENGTH)->nullable());
+ $table->dateTime('started_at', 6);
+ $table->dateTime('updated_at', 6);
+ $table->dateTime('completed_at', 6)->nullable();
+ $table->dateTime('wake_at', 6)->nullable();
+ $table->longText('payload');
+ $table->index(['status', 'updated_at'], 'firefly_orchestration_executions_status_updated');
+ $table->index(['pattern', 'status'], 'firefly_orchestration_executions_pattern_status');
+ $table->index(['wake_at'], 'firefly_orchestration_executions_wake');
+ $table->index(['execution_name'], 'firefly_orchestration_executions_name');
+ });
+ }
+
+ if (! $schema->hasTable(self::SEARCH_ATTRIBUTES)) {
+ $schema->create(self::SEARCH_ATTRIBUTES, static function (Blueprint $table) use ($binary): void {
+ $binary($table->string('correlation_id', self::ID_LENGTH));
+ $binary($table->string('attribute_key', self::ATTRIBUTE_KEY_LENGTH));
+ $binary($table->string('attribute_value', self::ATTRIBUTE_VALUE_LENGTH));
+ $table->primary(['correlation_id', 'attribute_key'], 'firefly_orchestration_search_attributes_pk');
+ $table->index(['attribute_key', 'attribute_value'], 'firefly_orchestration_search_attributes_lookup');
+ });
+ }
+
+ if (! $schema->hasTable(self::DEAD_LETTERS)) {
+ $schema->create(self::DEAD_LETTERS, static function (Blueprint $table) use ($binary): void {
+ $binary($table->string('id', self::ID_LENGTH)->primary());
+ $binary($table->string('execution_name', self::NAME_LENGTH));
+ $binary($table->string('correlation_id', self::ID_LENGTH));
+ $table->string('pattern', 16);
+ $table->string('step_id', self::NAME_LENGTH)->nullable();
+ $table->string('status_at_failure', 24);
+ $table->longText('error_message');
+ $table->string('error_type', self::TYPE_LENGTH);
+ $table->longText('input');
+ $table->longText('headers');
+ $table->unsignedInteger('retry_count')->default(0);
+ $table->dateTime('created_at', 6);
+ $table->dateTime('last_retried_at', 6)->nullable();
+ $table->index(['execution_name'], 'firefly_orchestration_dead_letters_name');
+ $table->index(['correlation_id'], 'firefly_orchestration_dead_letters_correlation');
+ $table->index(['created_at'], 'firefly_orchestration_dead_letters_created');
+ });
+ }
+ }
+
+ public static function drop(Builder $schema): void
+ {
+ $schema->dropIfExists(self::DEAD_LETTERS);
+ $schema->dropIfExists(self::SEARCH_ATTRIBUTES);
+ $schema->dropIfExists(self::EXECUTIONS);
+ }
+
+ private const string SQL_SERVER_BINARY = 'Latin1_General_100_BIN2';
+
+ private static function binaryCollation(Builder $schema): ?string
+ {
+ $connection = $schema->getConnection();
+ if ($connection instanceof SqlServerConnection) {
+ return self::SQL_SERVER_BINARY;
+ }
+ if (! $connection instanceof MySqlConnection) {
+ return null;
+ }
+
+ $modern = version_compare($connection->getServerVersion(), $connection->isMaria() ? '10.2.2' : '8.0.1', '>=');
+
+ return $modern ? ($connection->isMaria() ? 'utf8mb4_nopad_bin' : 'utf8mb4_0900_bin') : 'utf8mb4_bin';
+ }
+}
diff --git a/packages/orchestration/src/Persistence/StateSerializer.php b/packages/orchestration/src/Persistence/StateSerializer.php
new file mode 100644
index 00000000..41e4502f
--- /dev/null
+++ b/packages/orchestration/src/Persistence/StateSerializer.php
@@ -0,0 +1,169 @@
+ $ancestors
+ */
+ private static function normalizeAt(mixed $value, string $path, array $ancestors, int $depth): mixed
+ {
+ if (is_float($value) && ! is_finite($value)) {
+ $kind = is_nan($value) ? 'NAN' : ($value > 0 ? 'INF' : '-INF');
+
+ throw new OrchestrationException("{$path} holds a non-finite float ({$kind}), which JSON cannot represent and an orchestration checkpoint cannot persist.", 'ORCHESTRATION_UNSERIALIZABLE');
+ }
+
+ if (is_string($value) && ! mb_check_encoding($value, 'UTF-8')) {
+ throw new OrchestrationException("{$path} holds a string that is not valid UTF-8, which an orchestration checkpoint cannot persist. Encode binary data (base64) before returning it from the step.", 'ORCHESTRATION_UNSERIALIZABLE');
+ }
+
+ if ($value === null || is_scalar($value)) {
+ return $value;
+ }
+
+ if ($depth >= self::MAX_DEPTH) {
+ throw new OrchestrationException("{$path} nests deeper than ".self::MAX_DEPTH.' levels, which an orchestration checkpoint cannot persist.', 'ORCHESTRATION_UNSERIALIZABLE');
+ }
+
+ if (is_array($value)) {
+ $normalized = [];
+ foreach ($value as $key => $item) {
+ if (is_string($key) && ! mb_check_encoding($key, 'UTF-8')) {
+ throw new OrchestrationException("{$path} holds a key that is not valid UTF-8, which an orchestration checkpoint cannot persist.", 'ORCHESTRATION_UNSERIALIZABLE');
+ }
+ $normalized[$key] = self::normalizeAt($item, $path.'.'.$key, $ancestors, $depth + 1);
+ }
+
+ return $normalized;
+ }
+
+ if (is_object($value) && ! $value instanceof UnitEnum && ! $value instanceof DateTimeInterface && ! $value instanceof Closure) {
+ $id = spl_object_id($value);
+ if (isset($ancestors[$id])) {
+ throw new OrchestrationException("{$path} holds a cyclic reference to a ".get_debug_type($value).' already on its own path, which an orchestration checkpoint cannot persist. Return acyclic data from the step.', 'ORCHESTRATION_UNSERIALIZABLE');
+ }
+ $ancestors[$id] = true;
+ }
+
+ return match (true) {
+ $value instanceof JsonSerializable => self::normalizeAt($value->jsonSerialize(), $path, $ancestors, $depth + 1),
+ $value instanceof BackedEnum => $value->value,
+ $value instanceof UnitEnum => $value->name,
+ $value instanceof DateTimeInterface => DateTimeImmutable::createFromInterface($value)->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d\TH:i:s.uP'),
+ $value instanceof Traversable => self::normalizeAt(iterator_to_array($value), $path, $ancestors, $depth + 1),
+ $value instanceof Closure => throw new OrchestrationException("{$path} holds a closure, which an orchestration checkpoint cannot persist. Return data (an array, a scalar, a JsonSerializable) from the step.", 'ORCHESTRATION_UNSERIALIZABLE'),
+ is_object($value) => self::normalizeAt(get_object_vars($value), $path, $ancestors, $depth + 1),
+ default => throw new OrchestrationException("{$path} holds a ".get_debug_type($value).', which an orchestration checkpoint cannot persist.', 'ORCHESTRATION_UNSERIALIZABLE'),
+ };
+ }
+
+ public static function encode(ExecutionState $state): string
+ {
+ return self::encodeArray($state->toArray());
+ }
+
+ public static function decode(string $payload): ExecutionState
+ {
+ return ExecutionState::fromArray(self::decodeArray($payload));
+ }
+
+ /**
+ * Encodes a checkpoint. A value normalize() let through always encodes; a field that never went through
+ * it (a header, a search attribute) and still holds what JSON cannot represent is refused with the same
+ * code rather than escaping as a bare JsonException.
+ *
+ * @param array $data
+ */
+ public static function encodeArray(array $data): string
+ {
+ try {
+ return json_encode($data, self::FLAGS, self::JSON_DEPTH);
+ } catch (JsonException $error) {
+ throw new OrchestrationException("An orchestration checkpoint cannot be encoded as JSON: {$error->getMessage()}", 'ORCHESTRATION_UNSERIALIZABLE', 500, $error);
+ }
+ }
+
+ /**
+ * @return array
+ */
+ public static function decodeArray(string $payload): array
+ {
+ try {
+ $data = json_decode($payload, true, self::JSON_DEPTH, JSON_THROW_ON_ERROR);
+ } catch (JsonException $error) {
+ throw new OrchestrationException("A persisted orchestration payload is not valid JSON: {$error->getMessage()}", 'ORCHESTRATION_CORRUPT_STATE', 500, $error);
+ }
+
+ if (! is_array($data)) {
+ throw new OrchestrationException('A persisted orchestration payload is not a JSON object.', 'ORCHESTRATION_CORRUPT_STATE');
+ }
+
+ $map = [];
+ foreach ($data as $key => $value) {
+ $map[(string) $key] = $value;
+ }
+
+ return $map;
+ }
+}
diff --git a/packages/orchestration/src/Recovery/OrchestrationRecoveryTask.php b/packages/orchestration/src/Recovery/OrchestrationRecoveryTask.php
new file mode 100644
index 00000000..179e0c82
--- /dev/null
+++ b/packages/orchestration/src/Recovery/OrchestrationRecoveryTask.php
@@ -0,0 +1,18 @@
+recovery->recover();
+ }
+}
diff --git a/packages/orchestration/src/Recovery/OrchestrationTickTask.php b/packages/orchestration/src/Recovery/OrchestrationTickTask.php
new file mode 100644
index 00000000..6f153aaf
--- /dev/null
+++ b/packages/orchestration/src/Recovery/OrchestrationTickTask.php
@@ -0,0 +1,24 @@
+workflows->resumeDue());
+ }
+}
diff --git a/packages/orchestration/src/Recovery/RecoveryReport.php b/packages/orchestration/src/Recovery/RecoveryReport.php
new file mode 100644
index 00000000..6bc916b2
--- /dev/null
+++ b/packages/orchestration/src/Recovery/RecoveryReport.php
@@ -0,0 +1,35 @@
+ $stale
+ * @param list $resumed
+ * @param list $compensated
+ * @param array $errors
+ * @param list $skipped
+ */
+ public function __construct(
+ public array $stale = [],
+ public array $resumed = [],
+ public array $compensated = [],
+ public array $errors = [],
+ public int $cleaned = 0,
+ public array $skipped = [],
+ ) {}
+
+ /** @return array{stale: list, resumed: list, compensated: list, errors: array, cleaned: int, skipped: list} */
+ public function toArray(): array
+ {
+ return ['stale' => $this->stale, 'resumed' => $this->resumed, 'compensated' => $this->compensated, 'errors' => $this->errors, 'cleaned' => $this->cleaned, 'skipped' => $this->skipped];
+ }
+}
diff --git a/packages/orchestration/src/Recovery/RecoveryService.php b/packages/orchestration/src/Recovery/RecoveryService.php
new file mode 100644
index 00000000..c4e86d54
--- /dev/null
+++ b/packages/orchestration/src/Recovery/RecoveryService.php
@@ -0,0 +1,193 @@
+ at most $limit (the batch size by default), newest update first */
+ public function findStale(?int $limit = null): array
+ {
+ return $this->persistence->findStale($this->staleBefore(), $limit ?? $this->batchSize);
+ }
+
+ public function cleanup(): int
+ {
+ return $this->persistence->cleanup($this->clock->now()->modify(sprintf('-%d seconds', (int) round($this->persistenceSettings->retentionPeriod))));
+ }
+
+ public function recover(): RecoveryReport
+ {
+ if (! $this->settings->enabled) {
+ return new RecoveryReport;
+ }
+
+ $stale = [];
+ $resumed = [];
+ $compensated = [];
+ $errors = [];
+ $skipped = [];
+
+ $before = $this->staleBefore();
+ $seen = [];
+ do {
+ $fresh = 0;
+ $failed = false;
+ // Wide enough to reach past every row this sweep already saw, so a row that failed is not fetched
+ // in place of the next one.
+ foreach ($this->persistence->findStale($before, $this->batchSize + count($seen)) as $state) {
+ $id = $state->correlationId;
+ if (isset($seen[$id])) {
+ continue;
+ }
+ $seen[$id] = true;
+ $fresh++;
+
+ try {
+ if ($this->settings->action === RecoverySettings::REPORT) {
+ $stale[] = $id;
+ $this->logger->warning("Orchestration recovery found {$state->pattern->value} [{$state->executionName}] ({$id}) stale in {$state->status->value} since ".ExecutionState::instant($state->updatedAt).'; firefly.orchestration.recovery.action is report, so it is left as it is.', ['correlationId' => $id]);
+ } elseif ($this->recoverIfUnchanged($state)) {
+ $stale[] = $id;
+ if ($this->settings->action === RecoverySettings::RESUME) {
+ $resumed[] = $id;
+ } else {
+ $compensated[] = $id;
+ }
+ } else {
+ // Not an error and not stale: its process is alive and moved it on. It is not counted
+ // against the batch, and findStale() no longer returns it.
+ $skipped[] = $id;
+ $this->logger->info("Orchestration recovery left [{$id}] alone: it changed after the sweep selected it as stale, so the process running it is still alive.", ['correlationId' => $id]);
+
+ continue;
+ }
+ } catch (Throwable $error) {
+ $stale[] = $id;
+ $failed = true;
+ $errors[$id] = $error->getMessage();
+ $this->logger->error("Orchestration recovery of [{$id}] failed: {$error->getMessage()}", ['correlationId' => $id, 'exception' => $error]);
+ }
+
+ if (count($stale) - count($errors) >= $this->batchSize) {
+ break 2;
+ }
+ }
+ } while ($failed && $fresh > 0 && count($seen) < $this->batchSize * self::MAX_BATCHES);
+
+ return new RecoveryReport($stale, $resumed, $compensated, $errors, $this->cleanup(), $skipped);
+ }
+
+ private function staleBefore(): DateTimeImmutable
+ {
+ return $this->clock->now()->modify(sprintf('-%d milliseconds', (int) round($this->settings->staleThreshold * 1000)));
+ }
+
+ /**
+ * Recovers $selected under its execution lock, unless the row changed since the sweep selected it.
+ *
+ * @return bool false when the row is no longer the stale one the sweep saw (it was left alone)
+ */
+ private function recoverIfUnchanged(ExecutionState $selected): bool
+ {
+ return $this->lock->synchronized($selected->correlationId, function () use ($selected): bool {
+ $current = $this->persistence->findById($selected->correlationId);
+ if ($current === null
+ || $current->pattern !== $selected->pattern
+ || $current->status !== $selected->status
+ || ! $current->status->isRecoverable()
+ || ExecutionState::instant($current->updatedAt) !== ExecutionState::instant($selected->updatedAt)) {
+ return false;
+ }
+
+ if ($this->settings->action === RecoverySettings::RESUME) {
+ $this->resume($current);
+ } else {
+ $this->compensate($current);
+ }
+
+ return true;
+ });
+ }
+
+ private function resume(ExecutionState $state): void
+ {
+ if ($state->pattern === ExecutionPattern::Saga) {
+ $this->sagas->resume($state->correlationId);
+ } elseif ($state->pattern === ExecutionPattern::Tcc) {
+ $this->tccs->resume($state->correlationId);
+ } else {
+ $this->workflows->recover($state->correlationId);
+ }
+ }
+
+ private function compensate(ExecutionState $state): void
+ {
+ if ($state->pattern === ExecutionPattern::Saga) {
+ $this->sagas->compensate($state->correlationId);
+ } elseif ($state->pattern === ExecutionPattern::Tcc) {
+ $this->tccs->compensate($state->correlationId);
+ } else {
+ $this->workflows->compensate($state->correlationId);
+ }
+ }
+}
diff --git a/packages/orchestration/src/Report/ExecutionReport.php b/packages/orchestration/src/Report/ExecutionReport.php
new file mode 100644
index 00000000..4d324e41
--- /dev/null
+++ b/packages/orchestration/src/Report/ExecutionReport.php
@@ -0,0 +1,66 @@
+ $steps
+ * @param list> $layers
+ */
+ public function __construct(
+ public string $name,
+ public string $correlationId,
+ public ExecutionPattern $pattern,
+ public ExecutionStatus $status,
+ public DateTimeImmutable $startedAt,
+ public DateTimeImmutable $completedAt,
+ public int $durationMs,
+ public array $steps,
+ public ?string $failureReason = null,
+ public array $layers = [],
+ ) {}
+
+ /** @return list */
+ public function failedSteps(): array
+ {
+ return array_map('strval', array_keys(array_filter($this->steps, static fn (StepReport $step): bool => $step->status->isFailure())));
+ }
+
+ /** @return list */
+ public function compensatedSteps(): array
+ {
+ return array_map('strval', array_keys(array_filter($this->steps, static fn (StepReport $step): bool => $step->compensated || $step->status === StepStatus::Compensated)));
+ }
+
+ /**
+ * @return array{name: string, correlationId: string, pattern: string, status: string, startedAt: string, completedAt: string, durationMs: int, failureReason: ?string, layers: list>, steps: array}
+ */
+ public function toArray(): array
+ {
+ return [
+ 'name' => $this->name,
+ 'correlationId' => $this->correlationId,
+ 'pattern' => $this->pattern->value,
+ 'status' => $this->status->value,
+ 'startedAt' => ExecutionState::instant($this->startedAt),
+ 'completedAt' => ExecutionState::instant($this->completedAt),
+ 'durationMs' => $this->durationMs,
+ 'failureReason' => $this->failureReason,
+ 'layers' => $this->layers,
+ 'steps' => array_map(static fn (StepReport $step): array => $step->toArray(), $this->steps),
+ ];
+ }
+}
diff --git a/packages/orchestration/src/Report/ExecutionReportBuilder.php b/packages/orchestration/src/Report/ExecutionReportBuilder.php
new file mode 100644
index 00000000..a6b0fd60
--- /dev/null
+++ b/packages/orchestration/src/Report/ExecutionReportBuilder.php
@@ -0,0 +1,49 @@
+ $stepIds
+ */
+ public static function fromContext(ExecutionContext $context, ExecutionStatus $status, ?string $failureReason, DateTimeImmutable $now, array $stepIds): ExecutionReport
+ {
+ $steps = [];
+ foreach ($stepIds as $stepId) {
+ $stepStatus = $context->stepStatus($stepId);
+ $steps[$stepId] = new StepReport(
+ stepId: $stepId,
+ status: $stepStatus,
+ attempts: $context->attempts($stepId),
+ latencyMs: $context->latencyMs($stepId),
+ error: $context->stepError($stepId)?->getMessage(),
+ compensated: $stepStatus === StepStatus::Compensated,
+ compensationError: $context->compensationError($stepId)?->getMessage(),
+ );
+ }
+
+ $duration = (int) round(((float) $now->format('U.u') - (float) $context->startedAt()->format('U.u')) * 1000);
+
+ return new ExecutionReport(
+ name: $context->executionName,
+ correlationId: $context->correlationId,
+ pattern: $context->pattern,
+ status: $status,
+ startedAt: $context->startedAt(),
+ completedAt: $now,
+ durationMs: max(0, $duration),
+ steps: $steps,
+ failureReason: $failureReason,
+ layers: $context->topologyLayers(),
+ );
+ }
+}
diff --git a/packages/orchestration/src/Report/StepReport.php b/packages/orchestration/src/Report/StepReport.php
new file mode 100644
index 00000000..ac53b2d2
--- /dev/null
+++ b/packages/orchestration/src/Report/StepReport.php
@@ -0,0 +1,31 @@
+ $this->stepId, 'status' => $this->status->value, 'attempts' => $this->attempts,
+ 'latencyMs' => $this->latencyMs, 'error' => $this->error, 'compensated' => $this->compensated,
+ 'compensationError' => $this->compensationError,
+ ];
+ }
+}
diff --git a/packages/orchestration/src/Saga/Attributes/CompensationSagaStep.php b/packages/orchestration/src/Saga/Attributes/CompensationSagaStep.php
new file mode 100644
index 00000000..75a14b53
--- /dev/null
+++ b/packages/orchestration/src/Saga/Attributes/CompensationSagaStep.php
@@ -0,0 +1,20 @@
+ $dependsOn
+ */
+ public function __construct(
+ public string $saga,
+ public string $id,
+ public string $compensate = '',
+ public array $dependsOn = [],
+ public int $retry = 0,
+ public int $backoffMs = -1,
+ public int $timeoutMs = -1,
+ public bool $jitter = false,
+ public float $jitterFactor = 0.5,
+ public string $idempotencyKey = '',
+ public bool $cpuBound = false,
+ public int $compensationRetry = -1,
+ public int $compensationTimeoutMs = -1,
+ public int $compensationBackoffMs = -1,
+ public bool $compensationCritical = false,
+ ) {}
+}
diff --git a/packages/orchestration/src/Saga/Attributes/OnSagaComplete.php b/packages/orchestration/src/Saga/Attributes/OnSagaComplete.php
new file mode 100644
index 00000000..8e79b960
--- /dev/null
+++ b/packages/orchestration/src/Saga/Attributes/OnSagaComplete.php
@@ -0,0 +1,21 @@
+ $errorTypes exception class names; empty matches every error
+ */
+ public function __construct(
+ public array $errorTypes = [],
+ public bool $suppressError = false,
+ public bool $async = false,
+ public int $priority = 0,
+ ) {}
+}
diff --git a/packages/orchestration/src/Saga/Attributes/Saga.php b/packages/orchestration/src/Saga/Attributes/Saga.php
new file mode 100644
index 00000000..a5b11764
--- /dev/null
+++ b/packages/orchestration/src/Saga/Attributes/Saga.php
@@ -0,0 +1,23 @@
+}, {header.}) is
+ * recorded once the step succeeds, and a step whose key is already recorded is skipped. The `compensation*`
+ * members override retry/backoff/timeout for the compensation; `compensationCritical` makes a failed
+ * compensation stop the CIRCUIT_BREAKER policy. `cpuBound` is accepted for parity and has no effect. -1 means
+ * "not set".
+ */
+#[Attribute(Attribute::TARGET_METHOD)]
+final readonly class SagaStep
+{
+ /**
+ * @param list $dependsOn
+ */
+ public function __construct(
+ public string $id,
+ public string $compensate = '',
+ public array $dependsOn = [],
+ public int $retry = 0,
+ public int $backoffMs = -1,
+ public int $timeoutMs = -1,
+ public bool $jitter = false,
+ public float $jitterFactor = 0.5,
+ public string $idempotencyKey = '',
+ public bool $cpuBound = false,
+ public int $compensationRetry = -1,
+ public int $compensationTimeoutMs = -1,
+ public int $compensationBackoffMs = -1,
+ public bool $compensationCritical = false,
+ ) {}
+}
diff --git a/packages/orchestration/src/Saga/Attributes/ScheduledSaga.php b/packages/orchestration/src/Saga/Attributes/ScheduledSaga.php
new file mode 100644
index 00000000..895b2a1c
--- /dev/null
+++ b/packages/orchestration/src/Saga/Attributes/ScheduledSaga.php
@@ -0,0 +1,28 @@
+step('debit')->handler(fn (array $in) => $ledger->debit($in))->compensation(fn ($ref) => $ledger->credit($ref))->add()
+ * ->step('credit')->dependsOn('debit')->handler(fn (array $in) => $ledger->credit($in))->add()
+ * ->build();
+ *
+ * A built saga runs with SagaEngine::execute($definition, …); register it in SagaRegistry (in every process)
+ * for it to be startable by name, triggerable from EDA and recoverable after a crash.
+ */
+final class SagaBuilder
+{
+ /** @var array */
+ private array $steps = [];
+
+ private string $triggerEventType = '';
+
+ /** @var list */
+ private array $onComplete = [];
+
+ /** @var list */
+ private array $onError = [];
+
+ private function __construct(
+ private readonly string $name,
+ private readonly int $layerConcurrency,
+ ) {}
+
+ public static function saga(string $name, int $layerConcurrency = 0): self
+ {
+ return new self($name, $layerConcurrency);
+ }
+
+ public function triggerEventType(string $eventType): self
+ {
+ $this->triggerEventType = $eventType;
+
+ return $this;
+ }
+
+ public function step(string $id): SagaStepBuilder
+ {
+ return new SagaStepBuilder($this, $this->name, $id);
+ }
+
+ /**
+ * @param Closure(ExecutionContext, mixed): void $callback
+ */
+ public function onComplete(Closure $callback, int $priority = 0, bool $async = false): self
+ {
+ $this->onComplete[] = CallbackDefinition::closure($callback, $async, $priority);
+
+ return $this;
+ }
+
+ /**
+ * @param Closure(ExecutionContext, mixed): void $callback
+ * @param list $errorTypes
+ */
+ public function onError(Closure $callback, bool $suppressError = false, array $errorTypes = [], int $priority = 0, bool $async = false): self
+ {
+ $this->onError[] = CallbackDefinition::closure($callback, $async, $priority, $errorTypes, $suppressError);
+
+ return $this;
+ }
+
+ /**
+ * A second step with an id already added refuses, rather than silently replacing the first.
+ *
+ * @internal called by SagaStepBuilder::add()
+ */
+ public function addStep(SagaStepDefinition $step): self
+ {
+ if (isset($this->steps[$step->id])) {
+ throw new TopologyValidationException("Saga [{$this->name}] declares step [{$step->id}] twice.");
+ }
+
+ $this->steps[$step->id] = $step;
+
+ return $this;
+ }
+
+ public function build(): SagaDefinition
+ {
+ return new SagaDefinition($this->name, $this->steps, $this->layerConcurrency, $this->triggerEventType, $this->onComplete, $this->onError);
+ }
+}
diff --git a/packages/orchestration/src/Saga/Builder/SagaStepBuilder.php b/packages/orchestration/src/Saga/Builder/SagaStepBuilder.php
new file mode 100644
index 00000000..04ade0ad
--- /dev/null
+++ b/packages/orchestration/src/Saga/Builder/SagaStepBuilder.php
@@ -0,0 +1,186 @@
+stepInput($id)`.
+ */
+final class SagaStepBuilder
+{
+ /** @var list */
+ private array $dependsOn = [];
+
+ private int $retry = 0;
+
+ private int $backoffMs = 0;
+
+ private int $timeoutMs = 0;
+
+ private bool $jitter = false;
+
+ private float $jitterFactor = 0.5;
+
+ private string $idempotencyKey = '';
+
+ private int $compensationRetry = -1;
+
+ private int $compensationBackoffMs = -1;
+
+ private int $compensationTimeoutMs = -1;
+
+ private bool $compensationCritical = false;
+
+ private bool $cpuBound = false;
+
+ private ?StepEventDefinition $event = null;
+
+ private ?string $variable = null;
+
+ private ?Closure $handler = null;
+
+ private ?Closure $compensation = null;
+
+ public function __construct(
+ private readonly SagaBuilder $saga,
+ private readonly string $sagaName,
+ private readonly string $id,
+ ) {}
+
+ public function dependsOn(string ...$ids): self
+ {
+ $this->dependsOn = array_values([...$this->dependsOn, ...$ids]);
+
+ return $this;
+ }
+
+ public function retry(int $retry): self
+ {
+ $this->retry = $retry;
+
+ return $this;
+ }
+
+ public function backoffMs(int $milliseconds): self
+ {
+ $this->backoffMs = $milliseconds;
+
+ return $this;
+ }
+
+ public function timeoutMs(int $milliseconds): self
+ {
+ $this->timeoutMs = $milliseconds;
+
+ return $this;
+ }
+
+ public function jitter(bool $enabled = true, float $factor = 0.5): self
+ {
+ $this->jitter = $enabled;
+ $this->jitterFactor = $factor;
+
+ return $this;
+ }
+
+ public function idempotencyKey(string $key): self
+ {
+ $this->idempotencyKey = $key;
+
+ return $this;
+ }
+
+ public function compensationRetry(int $retry): self
+ {
+ $this->compensationRetry = $retry;
+
+ return $this;
+ }
+
+ public function compensationBackoffMs(int $milliseconds): self
+ {
+ $this->compensationBackoffMs = $milliseconds;
+
+ return $this;
+ }
+
+ public function compensationTimeoutMs(int $milliseconds): self
+ {
+ $this->compensationTimeoutMs = $milliseconds;
+
+ return $this;
+ }
+
+ public function compensationCritical(bool $critical = true): self
+ {
+ $this->compensationCritical = $critical;
+
+ return $this;
+ }
+
+ public function cpuBound(bool $cpuBound = true): self
+ {
+ $this->cpuBound = $cpuBound;
+
+ return $this;
+ }
+
+ public function stepEvent(string $topic, string $type = '', string $key = ''): self
+ {
+ $this->event = new StepEventDefinition($topic, $type, $key);
+
+ return $this;
+ }
+
+ public function setVariable(string $name): self
+ {
+ $this->variable = $name;
+
+ return $this;
+ }
+
+ public function handler(Closure $handler): self
+ {
+ $this->handler = $handler;
+
+ return $this;
+ }
+
+ public function compensation(Closure $compensation): self
+ {
+ $this->compensation = $compensation;
+
+ return $this;
+ }
+
+ public function add(): SagaBuilder
+ {
+ if ($this->handler === null) {
+ throw new TopologyValidationException("Saga [{$this->sagaName}] step [{$this->id}] has no handler.");
+ }
+
+ return $this->saga->addStep(new SagaStepDefinition(
+ id: $this->id,
+ invocation: new ClosureInvocation($this->handler, "{$this->sagaName}.{$this->id}", $this->variable),
+ dependsOn: $this->dependsOn,
+ options: StepOptions::of($this->retry, $this->backoffMs, $this->timeoutMs, $this->jitter, $this->jitterFactor, $this->idempotencyKey),
+ compensation: $this->compensation === null ? null : new ClosureInvocation($this->compensation, "{$this->sagaName}.{$this->id}:compensate"),
+ compensationRetry: $this->compensationRetry,
+ compensationBackoffMs: $this->compensationBackoffMs,
+ compensationTimeoutMs: $this->compensationTimeoutMs,
+ compensationCritical: $this->compensationCritical,
+ cpuBound: $this->cpuBound,
+ event: $this->event,
+ ));
+ }
+}
diff --git a/packages/orchestration/src/Saga/Compensation/CompensationErrorHandler.php b/packages/orchestration/src/Saga/Compensation/CompensationErrorHandler.php
new file mode 100644
index 00000000..e13c28c9
--- /dev/null
+++ b/packages/orchestration/src/Saga/Compensation/CompensationErrorHandler.php
@@ -0,0 +1,17 @@
+ */
+ private array $factories;
+
+ public function __construct(
+ private readonly Container $container,
+ LoggerInterface $logger,
+ ) {
+ $this->factories = [
+ 'default' => static fn (): CompensationErrorHandler => new DefaultCompensationErrorHandler,
+ 'fail-fast' => static fn (): CompensationErrorHandler => new FailFastCompensationErrorHandler,
+ 'log-and-continue' => static fn (): CompensationErrorHandler => new LogAndContinueCompensationErrorHandler($logger),
+ 'retry' => static fn (): CompensationErrorHandler => new RetryWithBackoffCompensationErrorHandler(3),
+ 'robust' => static fn (): CompensationErrorHandler => new RobustCompensationErrorHandler($logger, 3),
+ ];
+ }
+
+ /**
+ * @param Closure(): CompensationErrorHandler $factory
+ */
+ public function register(string $name, Closure $factory): void
+ {
+ $this->factories[$name] = $factory;
+ }
+
+ /** @return list */
+ public function names(): array
+ {
+ return array_map('strval', array_keys($this->factories));
+ }
+
+ public function get(string $name): CompensationErrorHandler
+ {
+ if (isset($this->factories[$name])) {
+ return ($this->factories[$name])();
+ }
+
+ if (class_exists($name) && is_a($name, CompensationErrorHandler::class, true)) {
+ $handler = $this->container->make($name);
+ if ($handler instanceof CompensationErrorHandler) {
+ return $handler;
+ }
+ }
+
+ throw new ConfigurationException("Configuration key [firefly.orchestration.saga.compensation-error-handler] names [{$name}], which is neither one of ".implode(', ', $this->names()).' nor a class implementing '.CompensationErrorHandler::class.'.');
+ }
+}
diff --git a/packages/orchestration/src/Saga/Compensation/CompensationErrorResult.php b/packages/orchestration/src/Saga/Compensation/CompensationErrorResult.php
new file mode 100644
index 00000000..fd23f594
--- /dev/null
+++ b/packages/orchestration/src/Saga/Compensation/CompensationErrorResult.php
@@ -0,0 +1,19 @@
+ $handlers
+ */
+ public function __construct(private readonly array $handlers) {}
+
+ public function handle(string $sagaName, string $stepId, Throwable $error, int $attempt): CompensationErrorResult
+ {
+ foreach ($this->handlers as $handler) {
+ $result = $handler->handle($sagaName, $stepId, $error, $attempt);
+ if ($result !== CompensationErrorResult::Continue) {
+ return $result;
+ }
+ }
+
+ return CompensationErrorResult::Continue;
+ }
+}
diff --git a/packages/orchestration/src/Saga/Compensation/DefaultCompensationErrorHandler.php b/packages/orchestration/src/Saga/Compensation/DefaultCompensationErrorHandler.php
new file mode 100644
index 00000000..8d01f594
--- /dev/null
+++ b/packages/orchestration/src/Saga/Compensation/DefaultCompensationErrorHandler.php
@@ -0,0 +1,16 @@
+logger->warning("Saga [{$sagaName}] could not compensate step [{$stepId}] (attempt {$attempt}); continuing: {$error->getMessage()}");
+
+ return CompensationErrorResult::Continue;
+ }
+}
diff --git a/packages/orchestration/src/Saga/Compensation/RetryWithBackoffCompensationErrorHandler.php b/packages/orchestration/src/Saga/Compensation/RetryWithBackoffCompensationErrorHandler.php
new file mode 100644
index 00000000..56c326b8
--- /dev/null
+++ b/packages/orchestration/src/Saga/Compensation/RetryWithBackoffCompensationErrorHandler.php
@@ -0,0 +1,46 @@
+ $retryable
+ */
+ public function __construct(
+ private readonly int $maxRetries = 3,
+ private readonly array $retryable = [],
+ ) {}
+
+ public function handle(string $sagaName, string $stepId, Throwable $error, int $attempt): CompensationErrorResult
+ {
+ if (! $this->isRetryable($error)) {
+ return CompensationErrorResult::FailSaga;
+ }
+
+ return $attempt < $this->maxRetries ? CompensationErrorResult::Retry : CompensationErrorResult::FailSaga;
+ }
+
+ private function isRetryable(Throwable $error): bool
+ {
+ if ($this->retryable === []) {
+ return true;
+ }
+
+ foreach ($this->retryable as $type) {
+ if (is_a($error, $type)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/packages/orchestration/src/Saga/Compensation/RobustCompensationErrorHandler.php b/packages/orchestration/src/Saga/Compensation/RobustCompensationErrorHandler.php
new file mode 100644
index 00000000..6a7240ae
--- /dev/null
+++ b/packages/orchestration/src/Saga/Compensation/RobustCompensationErrorHandler.php
@@ -0,0 +1,28 @@
+maxRetries) {
+ return CompensationErrorResult::Retry;
+ }
+
+ $this->logger->warning("Saga [{$sagaName}] gave up compensating step [{$stepId}] after {$attempt} retries; continuing: {$error->getMessage()}");
+
+ return CompensationErrorResult::Continue;
+ }
+}
diff --git a/packages/orchestration/src/Saga/Compensation/SagaCompensator.php b/packages/orchestration/src/Saga/Compensation/SagaCompensator.php
new file mode 100644
index 00000000..37eb81f8
--- /dev/null
+++ b/packages/orchestration/src/Saga/Compensation/SagaCompensator.php
@@ -0,0 +1,335 @@
+policy;
+ }
+
+ public function compensate(SagaDefinition $saga, ExecutionContext $context): void
+ {
+ $completed = array_values(array_filter(
+ $context->completionOrder(),
+ static fn (string $stepId): bool => isset($saga->steps[$stepId]) && $context->stepStatus($stepId) === StepStatus::Done,
+ ));
+ if ($completed === []) {
+ return;
+ }
+
+ $this->support->events->onCompensationStarted($saga->name, $context->correlationId);
+ $reversed = array_reverse($completed);
+
+ match ($this->policy) {
+ CompensationPolicy::StrictSequential => $this->sequentially($saga, $context, $reversed, withRetry: false, honourFailSaga: true),
+ CompensationPolicy::RetryWithBackoff => $this->sequentially($saga, $context, $reversed, withRetry: true, honourFailSaga: true),
+ CompensationPolicy::BestEffort => $this->sequentially($saga, $context, $reversed, withRetry: false, honourFailSaga: false),
+ CompensationPolicy::GroupedParallel => $this->sequentially($saga, $context, $this->byLayer($saga, $completed), withRetry: false, honourFailSaga: true),
+ CompensationPolicy::CircuitBreaker => $this->throughBreaker($saga, $context, $reversed),
+ };
+ }
+
+ /**
+ * @param list $order
+ */
+ private function sequentially(SagaDefinition $saga, ExecutionContext $context, array $order, bool $withRetry, bool $honourFailSaga): void
+ {
+ foreach ($order as $stepId) {
+ $step = $saga->steps[$stepId];
+ if ($step->compensation === null) {
+ continue;
+ }
+
+ $options = $withRetry ? $step->compensationOptions() : new StepOptions(timeoutMs: $step->compensationOptions()->timeoutMs);
+ $error = $this->attempt($saga, $context, $stepId, $options, null);
+ if ($error !== null) {
+ $this->onFailure($saga, $context, $stepId, $error, $honourFailSaga);
+ }
+ }
+ }
+
+ /**
+ * Compensates in reverse completion order through the saga's breaker. When the breaker refuses a step (or
+ * opens during the sweep), or a `compensationCritical` step fails, the sweep stops and a
+ * CompensationException names the step that stopped it and the steps left DONE. The engine records that
+ * message as the execution's reason and ends it COMPENSATION_FAILED, so a saga never claims a compensation
+ * it did not finish.
+ *
+ * @param list $reversed
+ */
+ private function throughBreaker(SagaDefinition $saga, ExecutionContext $context, array $reversed): void
+ {
+ $breaker = $this->breaker($saga->name);
+ $left = [];
+ $stoppedAt = null;
+ $critical = false;
+ $lastError = null;
+
+ foreach ($reversed as $stepId) {
+ $step = $saga->steps[$stepId];
+ if ($step->compensation === null) {
+ continue;
+ }
+ if ($stoppedAt !== null) {
+ $left[] = $stepId;
+
+ continue;
+ }
+
+ try {
+ $error = $this->attempt($saga, $context, $stepId, new StepOptions(timeoutMs: $step->compensationOptions()->timeoutMs), $breaker);
+ } catch (CircuitBreakerOpenException $refused) {
+ $stoppedAt = $stepId;
+ $lastError ??= $refused;
+ $left[] = $stepId;
+
+ continue;
+ }
+
+ if ($error === null) {
+ continue;
+ }
+ $lastError = $error;
+ if ($step->compensationCritical) {
+ $stoppedAt = $stepId;
+ $critical = true;
+
+ continue;
+ }
+ if ($breaker !== null && $breaker->state() === 'open') {
+ $stoppedAt = $stepId;
+
+ continue;
+ }
+
+ // The handler's retries go through the same breaker: they count towards its failure threshold, and a
+ // retry it refuses — or one that opens it — stops the sweep like any other open circuit.
+ if ($this->onFailure($saga, $context, $stepId, $error, honourFailSaga: true, breaker: $breaker)) {
+ $stoppedAt = $stepId;
+ $lastError = $context->compensationError($stepId) ?? $error;
+ }
+ }
+
+ if ($stoppedAt === null) {
+ return;
+ }
+
+ $message = $critical
+ ? "Saga [{$saga->name}] stopped compensating at critical step [{$stoppedAt}]"
+ : "Saga [{$saga->name}] opened its compensation circuit at step [{$stoppedAt}]";
+ if ($left !== []) {
+ $message .= '; ['.implode(', ', $left).'] left uncompensated';
+ }
+
+ // Every compensated step was already checkpointed under COMPENSATING; the steps left stay DONE, and the
+ // exception ends the execution with this message as its reason, so an operator sees what to undo.
+ $this->support->logger->warning($message.'.', ['correlationId' => $context->correlationId]);
+ $this->support->checkpoint($context, ExecutionStatus::Compensating);
+
+ throw new CompensationException($message.($lastError === null ? '.' : ": {$lastError->getMessage()}"), $lastError, $stoppedAt, $this->uncompensated($saga, $context));
+ }
+
+ /**
+ * The completed steps with a compensation that is not undone (still DONE, or COMPENSATION_FAILED), in
+ * reverse completion order.
+ *
+ * @return list
+ */
+ private function uncompensated(SagaDefinition $saga, ExecutionContext $context): array
+ {
+ return array_values(array_filter(
+ array_reverse($context->completionOrder()),
+ static fn (string $stepId): bool => ($saga->steps[$stepId] ?? null)?->compensation !== null
+ && in_array($context->stepStatus($stepId), [StepStatus::Done, StepStatus::CompensationFailed], true),
+ ));
+ }
+
+ /** Runs one compensation; returns its error, or null when it succeeded. Throws only when `$breaker` refused admission. */
+ private function attempt(SagaDefinition $saga, ExecutionContext $context, string $stepId, StepOptions $options, ?CircuitBreaker $breaker): ?Throwable
+ {
+ $step = $saga->steps[$stepId];
+ $compensation = $step->compensation;
+ if ($compensation === null) {
+ return null;
+ }
+
+ // Only OUR breaker refusing admission stops the sweep: a compensation that itself calls a downstream
+ // service behind an open breaker failed like any other compensation and goes to the error handler.
+ $ran = false;
+ $run = function () use ($saga, $stepId, $compensation, $options, $context, &$ran): mixed {
+ $ran = true;
+
+ return $this->invoker->invoke($saga->name, $stepId.':compensate', $compensation, $options, $context, $context->stepInput($stepId), $context->result($stepId));
+ };
+
+ try {
+ $result = $breaker === null ? $run() : $breaker->call($run);
+ } catch (Throwable $error) {
+ if ($error instanceof CircuitBreakerOpenException && $breaker !== null && ! $ran) {
+ throw $error;
+ }
+
+ $context->putCompensationError($stepId, $error);
+ $context->setStepStatus($stepId, StepStatus::CompensationFailed);
+ $this->support->events->onStepCompensationFailed($saga->name, $context->correlationId, $stepId, $error);
+ $this->support->checkpoint($context, ExecutionStatus::Compensating);
+
+ return $error;
+ }
+
+ $context->putCompensationResult($stepId, $result);
+ $this->markCompensated($saga, $context, $stepId);
+
+ return null;
+ }
+
+ /**
+ * Asks the error handler what to do with a failed compensation and carries out its answer. Under
+ * CIRCUIT_BREAKER `$breaker` is the saga's breaker and every RETRY runs through it; the method then
+ * returns true when that breaker refused a retry or opened after one, so the caller stops the sweep.
+ * Without a breaker it always returns false.
+ */
+ private function onFailure(SagaDefinition $saga, ExecutionContext $context, string $stepId, Throwable $error, bool $honourFailSaga, ?CircuitBreaker $breaker = null): bool
+ {
+ $step = $saga->steps[$stepId];
+
+ for ($attempt = 0; $attempt < self::MAX_HANDLER_RETRIES; $attempt++) {
+ $decision = $this->handler->handle($saga->name, $stepId, $error, $attempt);
+
+ if ($decision === CompensationErrorResult::Continue || $decision === CompensationErrorResult::SkipStep) {
+ return false;
+ }
+
+ if ($decision === CompensationErrorResult::MarkCompensated) {
+ $this->markCompensated($saga, $context, $stepId);
+
+ return false;
+ }
+
+ if ($decision === CompensationErrorResult::FailSaga) {
+ if ($honourFailSaga) {
+ throw new CompensationException("Saga [{$saga->name}] stopped compensating at step [{$stepId}]: {$error->getMessage()}", $error, $stepId, $this->uncompensated($saga, $context));
+ }
+
+ return false;
+ }
+
+ $backoff = $step->compensationOptions()->backoffMs;
+ if ($backoff > 0) {
+ usleep($backoff * 1000);
+ }
+
+ try {
+ $retryError = $this->attempt($saga, $context, $stepId, new StepOptions(timeoutMs: $step->compensationOptions()->timeoutMs), $breaker);
+ } catch (CircuitBreakerOpenException) {
+ return true;
+ }
+ if ($retryError === null) {
+ return false;
+ }
+ if ($breaker !== null && $breaker->state() === 'open') {
+ return true;
+ }
+ $error = $retryError;
+ }
+
+ $this->support->logger->warning("Saga [{$saga->name}] stopped retrying the compensation of step [{$stepId}] after ".self::MAX_HANDLER_RETRIES.' handler retries.', [
+ 'correlationId' => $context->correlationId,
+ ]);
+
+ return false;
+ }
+
+ private function markCompensated(SagaDefinition $saga, ExecutionContext $context, string $stepId): void
+ {
+ $context->setStepStatus($stepId, StepStatus::Compensated);
+ $this->support->events->onStepCompensated($saga->name, $context->correlationId, $stepId);
+ $this->support->checkpoint($context, ExecutionStatus::Compensating);
+ }
+
+ /**
+ * @param list $completed
+ * @return list
+ */
+ private function byLayer(SagaDefinition $saga, array $completed): array
+ {
+ $order = [];
+ foreach (array_reverse($saga->layers) as $layer) {
+ foreach ($layer as $stepId) {
+ if (in_array($stepId, $completed, true)) {
+ $order[] = $stepId;
+ }
+ }
+ }
+
+ return $order;
+ }
+
+ private function breaker(string $sagaName): ?CircuitBreaker
+ {
+ // Fall back to the default breaker only when firefly.resilience.circuit-breaker has no instance for this
+ // saga. A configured instance with an invalid value throws its ConfigurationException here instead of
+ // silently running on thresholds the operator never set.
+ $name = 'orchestration-compensation-'.$sagaName;
+ if ($this->resilience !== null && $this->resilience->hasCircuitBreaker($name)) {
+ return $this->resilience->circuitBreaker($name);
+ }
+
+ return $this->store === null ? null : new CircuitBreaker(
+ key: 'firefly:orchestration:compensation:'.$sagaName,
+ store: $this->store,
+ failureThreshold: 5,
+ waitDurationInOpen: 30.0,
+ );
+ }
+}
diff --git a/packages/orchestration/src/Saga/Composition/CompositionDataFlowManager.php b/packages/orchestration/src/Saga/Composition/CompositionDataFlowManager.php
new file mode 100644
index 00000000..290c61e5
--- /dev/null
+++ b/packages/orchestration/src/Saga/Composition/CompositionDataFlowManager.php
@@ -0,0 +1,53 @@
+ $global
+ * @param array $staticInput
+ * @param list $mappings
+ * @param array> $resultsByAlias alias => step id => step result
+ * @return array
+ */
+ public function resolveInputs(array $global, array $staticInput, array $mappings, string $alias, array $resultsByAlias): array
+ {
+ $input = array_merge($global, $staticInput);
+
+ foreach ($mappings as $mapping) {
+ if ($mapping->targetSaga !== $alias || ! isset($resultsByAlias[$mapping->sourceSaga])) {
+ continue;
+ }
+
+ $value = $this->extract($resultsByAlias[$mapping->sourceSaga], $mapping->sourceField);
+ if ($value !== null) {
+ $input[$mapping->targetField] = $value;
+ }
+ }
+
+ return $input;
+ }
+
+ /**
+ * @param array $stepResults
+ */
+ private function extract(array $stepResults, string $field): mixed
+ {
+ foreach ($stepResults as $result) {
+ if (is_array($result) && array_key_exists($field, $result)) {
+ return $result[$field];
+ }
+ }
+
+ return $stepResults[$field] ?? null;
+ }
+}
diff --git a/packages/orchestration/src/Saga/Composition/CompositionResult.php b/packages/orchestration/src/Saga/Composition/CompositionResult.php
new file mode 100644
index 00000000..102b681b
--- /dev/null
+++ b/packages/orchestration/src/Saga/Composition/CompositionResult.php
@@ -0,0 +1,57 @@
+ $sagaResults
+ * @param list $compensated
+ * @param list $skipped
+ * @param array $compensationFailures alias => why that member is not fully compensated
+ * @param array $optionalFailures alias => why that optional member failed or did not start
+ */
+ public function __construct(
+ public string $name,
+ public string $correlationId,
+ public bool $success,
+ public array $sagaResults,
+ public ?Throwable $error = null,
+ public array $compensated = [],
+ public array $skipped = [],
+ public array $compensationFailures = [],
+ public array $optionalFailures = [],
+ ) {}
+
+ /** @return array */
+ public function toArray(): array
+ {
+ $messages = static fn (Throwable $error): string => $error->getMessage();
+
+ return [
+ 'name' => $this->name,
+ 'correlationId' => $this->correlationId,
+ 'success' => $this->success,
+ 'error' => $this->error?->getMessage(),
+ 'compensated' => $this->compensated,
+ 'compensationFailures' => array_map($messages, $this->compensationFailures),
+ 'skipped' => $this->skipped,
+ 'optionalFailures' => array_map($messages, $this->optionalFailures),
+ 'sagas' => array_map(static fn (SagaResult $result): array => $result->toArray(), $this->sagaResults),
+ ];
+ }
+}
diff --git a/packages/orchestration/src/Saga/Composition/CompositionSaga.php b/packages/orchestration/src/Saga/Composition/CompositionSaga.php
new file mode 100644
index 00000000..4d7517e1
--- /dev/null
+++ b/packages/orchestration/src/Saga/Composition/CompositionSaga.php
@@ -0,0 +1,28 @@
+ $dependsOn
+ * @param array $staticInput
+ */
+ public function __construct(
+ public string $alias,
+ public string $sagaName,
+ public array $dependsOn = [],
+ public array $staticInput = [],
+ public bool $optional = false,
+ public string $condition = '',
+ ) {}
+}
diff --git a/packages/orchestration/src/Saga/Composition/CompositionSagaBuilder.php b/packages/orchestration/src/Saga/Composition/CompositionSagaBuilder.php
new file mode 100644
index 00000000..6a5f05a5
--- /dev/null
+++ b/packages/orchestration/src/Saga/Composition/CompositionSagaBuilder.php
@@ -0,0 +1,68 @@
+ */
+ private array $dependsOn = [];
+
+ /** @var array */
+ private array $input = [];
+
+ private bool $optional = false;
+
+ private string $condition = '';
+
+ public function __construct(
+ private readonly SagaCompositionBuilder $composition,
+ private readonly string $alias,
+ ) {
+ $this->sagaName = $alias;
+ }
+
+ public function sagaName(string $sagaName): self
+ {
+ $this->sagaName = $sagaName;
+
+ return $this;
+ }
+
+ public function dependsOn(string ...$aliases): self
+ {
+ $this->dependsOn = array_values([...$this->dependsOn, ...$aliases]);
+
+ return $this;
+ }
+
+ public function input(string $key, mixed $value): self
+ {
+ $this->input[$key] = $value;
+
+ return $this;
+ }
+
+ public function optional(bool $optional = true): self
+ {
+ $this->optional = $optional;
+
+ return $this;
+ }
+
+ public function condition(string $expression): self
+ {
+ $this->condition = $expression;
+
+ return $this;
+ }
+
+ public function add(): SagaCompositionBuilder
+ {
+ return $this->composition->addSaga(new CompositionSaga($this->alias, $this->sagaName, $this->dependsOn, $this->input, $this->optional, $this->condition));
+ }
+}
diff --git a/packages/orchestration/src/Saga/Composition/DataMapping.php b/packages/orchestration/src/Saga/Composition/DataMapping.php
new file mode 100644
index 00000000..1484537d
--- /dev/null
+++ b/packages/orchestration/src/Saga/Composition/DataMapping.php
@@ -0,0 +1,16 @@
+> */
+ public array $layers;
+
+ /**
+ * @param array $sagas
+ * @param list $dataMappings
+ */
+ public function __construct(
+ public string $name,
+ public array $sagas,
+ public array $dataMappings = [],
+ ) {
+ $dependencies = [];
+ foreach ($sagas as $alias => $member) {
+ $dependencies[(string) $alias] = $member->dependsOn;
+ }
+ $this->layers = TopologyBuilder::layers("Saga composition [{$name}]", $dependencies);
+
+ foreach ($dataMappings as $mapping) {
+ foreach ([$mapping->sourceSaga, $mapping->targetSaga] as $alias) {
+ if (! isset($sagas[$alias])) {
+ throw new TopologyValidationException("Saga composition [{$name}] maps data ".($alias === $mapping->sourceSaga ? 'from' : 'to')." [{$alias}], which is not a member of it.");
+ }
+ }
+ }
+ }
+}
diff --git a/packages/orchestration/src/Saga/Composition/SagaCompositionBuilder.php b/packages/orchestration/src/Saga/Composition/SagaCompositionBuilder.php
new file mode 100644
index 00000000..38472e26
--- /dev/null
+++ b/packages/orchestration/src/Saga/Composition/SagaCompositionBuilder.php
@@ -0,0 +1,55 @@
+saga('stock')->sagaName('reserve-stock')->add()
+ * ->saga('charge')->sagaName('charge-card')->dependsOn('stock')->add()
+ * ->dataFlow('stock', 'reservationId', 'charge', 'reservationId')
+ * ->build();
+ */
+final class SagaCompositionBuilder
+{
+ /** @var array */
+ private array $sagas = [];
+
+ /** @var list */
+ private array $dataMappings = [];
+
+ private function __construct(private readonly string $name) {}
+
+ public static function composition(string $name): self
+ {
+ return new self($name);
+ }
+
+ public function saga(string $alias): CompositionSagaBuilder
+ {
+ return new CompositionSagaBuilder($this, $alias);
+ }
+
+ public function dataFlow(string $sourceSaga, string $sourceField, string $targetSaga, string $targetField): self
+ {
+ $this->dataMappings[] = new DataMapping($sourceSaga, $sourceField, $targetSaga, $targetField);
+
+ return $this;
+ }
+
+ /** @internal called by CompositionSagaBuilder::add() */
+ public function addSaga(CompositionSaga $saga): self
+ {
+ $this->sagas[$saga->alias] = $saga;
+
+ return $this;
+ }
+
+ public function build(): SagaComposition
+ {
+ return new SagaComposition($this->name, $this->sagas, $this->dataMappings);
+ }
+}
diff --git a/packages/orchestration/src/Saga/Composition/SagaCompositor.php b/packages/orchestration/src/Saga/Composition/SagaCompositor.php
new file mode 100644
index 00000000..35daadc2
--- /dev/null
+++ b/packages/orchestration/src/Saga/Composition/SagaCompositor.php
@@ -0,0 +1,202 @@
+ $input
+ * @param array $headers
+ */
+ public function compose(SagaComposition $composition, array $input = [], array $headers = []): CompositionResult
+ {
+ foreach ($composition->sagas as $member) {
+ if ($member->condition !== '') {
+ $this->conditions->lint($member->condition, "Saga composition [{$composition->name}] member [{$member->alias}]");
+ }
+ }
+
+ $correlationId = Str::uuid()->toString();
+ $this->events->onCompositionStarted($composition->name, $correlationId);
+
+ /** @var array $results */
+ $results = [];
+ $completed = [];
+ $skipped = [];
+ $unavailable = [];
+ /** @var array $optionalFailures */
+ $optionalFailures = [];
+
+ foreach ($composition->layers as $layer) {
+ foreach ($layer as $alias) {
+ $member = $composition->sagas[$alias];
+ $resultsByAlias = array_map(static fn (SagaResult $result): array => $result->stepResults(), $results);
+
+ try {
+ if (array_intersect($member->dependsOn, $unavailable) !== []
+ || ($member->condition !== '' && ! $this->conditions->evaluate($member->condition, ['input' => $input, 'results' => $resultsByAlias, 'variables' => [], 'headers' => $headers]))) {
+ $skipped[] = $alias;
+ $unavailable[] = $alias;
+
+ continue;
+ }
+
+ // The composition's own headers win over a caller's header of the same name: a member must
+ // never claim to belong to a composition it is not part of.
+ $result = $this->engine->execute(
+ $member->sagaName,
+ $this->dataFlow->resolveInputs($input, $member->staticInput, $composition->dataMappings, $alias, $resultsByAlias),
+ array_merge($headers, [self::COMPOSITION_HEADER => $correlationId, self::ALIAS_HEADER => $alias]),
+ );
+ } catch (Throwable $error) {
+ if ($member->optional) {
+ $optionalFailures[$alias] = $error;
+ $unavailable[] = $alias;
+
+ continue;
+ }
+
+ return $this->abort($composition, $correlationId, $results, $completed, $skipped, $optionalFailures, $error);
+ }
+
+ $results[$alias] = $result;
+
+ if ($result->isSuccess()) {
+ $completed[] = $alias;
+
+ continue;
+ }
+
+ $error = $result->error ?? new OrchestrationException("Composition member [{$alias}] failed.");
+ if ($member->optional) {
+ $optionalFailures[$alias] = $error;
+ $unavailable[] = $alias;
+
+ continue;
+ }
+
+ return $this->abort($composition, $correlationId, $results, $completed, $skipped, $optionalFailures, $error);
+ }
+ }
+
+ $this->events->onCompositionCompleted($composition->name, $correlationId, true);
+
+ return new CompositionResult($composition->name, $correlationId, true, $results, null, [], $skipped, [], $optionalFailures);
+ }
+
+ /**
+ * Compensates every completed member in reverse completion order and reports the composition as failed.
+ * A member that cannot be fully undone is recorded and reported, and the sweep moves on to the next one.
+ *
+ * @param array $results
+ * @param list $completed
+ * @param list $skipped
+ * @param array $optionalFailures
+ */
+ private function abort(SagaComposition $composition, string $correlationId, array $results, array $completed, array $skipped, array $optionalFailures, Throwable $error): CompositionResult
+ {
+ $compensated = [];
+ $failures = [];
+ foreach (array_reverse($completed) as $done) {
+ try {
+ $failure = $this->undo($done, $results[$done]);
+ } catch (Throwable $refused) {
+ $failure = $refused;
+ }
+
+ if ($failure === null) {
+ $compensated[] = $done;
+
+ continue;
+ }
+
+ $failures[$done] = $failure;
+ $this->events->onStepCompensationFailed($composition->name, $correlationId, $done, $failure);
+ }
+ $this->events->onCompositionCompleted($composition->name, $correlationId, false);
+
+ return new CompositionResult($composition->name, $correlationId, false, $results, $error, $compensated, $skipped, $failures, $optionalFailures);
+ }
+
+ /**
+ * Compensates one completed member. Returns null when every step it completed that has a compensation was
+ * undone, and otherwise the reason it was not: SagaEngine::compensate() handles a failed compensation
+ * itself and still returns a CANCELLED result, so the outcome of each step is what tells the two apart.
+ */
+ private function undo(string $alias, SagaResult $completed): ?Throwable
+ {
+ $undone = $this->engine->compensate($completed->correlationId);
+ $definition = $this->engine->registry()->get($completed->sagaName);
+
+ $left = [];
+ $cause = null;
+ foreach ($completed->steps as $stepId => $outcome) {
+ if ($outcome->status !== StepStatus::Done || ($definition->steps[$stepId] ?? null)?->compensation === null) {
+ continue;
+ }
+
+ $after = $undone->steps[$stepId] ?? null;
+ if ($after?->compensated !== true) {
+ $left[] = $stepId;
+ $cause ??= $after?->compensationError;
+ }
+ }
+
+ if ($left === []) {
+ return null;
+ }
+
+ $reason = $cause?->getMessage() ?? $undone->report->failureReason ?? 'not compensated';
+
+ return new CompensationException("Composition member [{$alias}] left [".implode(', ', $left)."] uncompensated: {$reason}", $cause);
+ }
+}
diff --git a/packages/orchestration/src/Saga/Engine/SagaEngine.php b/packages/orchestration/src/Saga/Engine/SagaEngine.php
new file mode 100644
index 00000000..c1a40caf
--- /dev/null
+++ b/packages/orchestration/src/Saga/Engine/SagaEngine.php
@@ -0,0 +1,248 @@
+registry;
+ }
+
+ /**
+ * @param array|StepInputs $input
+ * @param array $headers
+ */
+ public function execute(string|SagaDefinition $saga, array|StepInputs $input = [], array $headers = [], ?string $correlationId = null): SagaResult
+ {
+ $this->guard();
+ $definition = is_string($saga) ? $this->registry->get($saga) : $saga;
+ $inputs = $input instanceof StepInputs ? $input : StepInputs::of($input);
+ $context = ExecutionContext::start(ExecutionPattern::Saga, $definition->name, $inputs->global(), $headers, $correlationId, $this->support->now());
+
+ // Checked twice: up front, so a retried call is refused before admission, whose circuit breaker would
+ // count the refusal as a failed execution; and again under the lock, so two concurrent calls with one id
+ // cannot both pass.
+ $this->refuseExisting($context->correlationId);
+
+ return $this->support->admission->admit(
+ $definition->name,
+ fn (): SagaResult => $this->support->lock->synchronized($context->correlationId, function () use ($definition, $inputs, $context): SagaResult {
+ $this->refuseExisting($context->correlationId);
+
+ return $this->support->tracing->traceExecution(
+ $definition->name,
+ ExecutionPattern::Saga,
+ $context->correlationId,
+ fn (): SagaResult => $this->run($definition, $inputs, $context, fresh: true),
+ );
+ }),
+ static fn (SagaResult $result): bool => $result->success,
+ );
+ }
+
+ public function resume(string $correlationId): SagaResult
+ {
+ return $this->support->lock->synchronized($correlationId, function () use ($correlationId): SagaResult {
+ $state = $this->load($correlationId);
+ if ($state->isTerminal()) {
+ throw new InvalidExecutionStateException("Saga execution [{$correlationId}] is already {$state->status->value} and cannot be resumed.");
+ }
+
+ $definition = $this->registry->get($state->executionName);
+ $context = ExecutionContext::fromState($state);
+ $inputs = StepInputs::of($state->input);
+ foreach ($state->stepInputs as $stepId => $value) {
+ $inputs = $inputs->forStep($stepId, $value);
+ }
+
+ if ($state->status === ExecutionStatus::Compensating) {
+ return $this->fail($definition, $context, $this->firstError($context));
+ }
+
+ return $this->support->tracing->traceExecution($definition->name, ExecutionPattern::Saga, $correlationId, fn (): SagaResult => $this->run($definition, $inputs, $context, fresh: false));
+ });
+ }
+
+ public function compensate(string $correlationId): SagaResult
+ {
+ return $this->support->lock->synchronized($correlationId, function () use ($correlationId): SagaResult {
+ $state = $this->load($correlationId);
+ if ($state->isTerminal() && $state->status !== ExecutionStatus::Completed) {
+ throw new InvalidExecutionStateException("Saga execution [{$correlationId}] is already {$state->status->value} and has nothing left to compensate.");
+ }
+
+ $definition = $this->registry->get($state->executionName);
+ $context = ExecutionContext::fromState($state);
+ $this->support->checkpoint($context, ExecutionStatus::Compensating);
+
+ try {
+ $this->compensator->compensate($definition, $context);
+ } catch (CompensationException $failure) {
+ return $this->compensationFailed($definition, $context, $failure, $failure);
+ }
+
+ return $this->finish($definition, $context, ExecutionStatus::Cancelled, null, 'compensated on request');
+ });
+ }
+
+ private function run(SagaDefinition $saga, StepInputs $inputs, ExecutionContext $context, bool $fresh): SagaResult
+ {
+ if ($fresh) {
+ foreach ($inputs->explicit() as $stepId => $value) {
+ $context->putStepInput($stepId, $value);
+ }
+ $this->support->checkpoint($context, ExecutionStatus::Running);
+ $this->support->publish($this->support->lifecycle, OrchestrationEvent::executionStarted($saga->name, $context->correlationId, ExecutionPattern::Saga, $this->support->now()));
+ $this->support->events->onStart($saga->name, $context->correlationId, ExecutionPattern::Saga);
+ }
+
+ // A resumed execution gets a fresh budget from the moment it is resumed: measured from the original
+ // start, a saga recovery picks up after the stale threshold would always be past its deadline, and
+ // `recovery.action=resume` could only ever compensate it.
+ $deadline = $this->settings->defaultTimeout > 0
+ ? ($fresh ? $context->startedAt() : $this->support->now())->modify(sprintf('+%d milliseconds', (int) round($this->settings->defaultTimeout * 1000)))
+ : null;
+
+ if ($this->orchestrator->orchestrate($saga, $inputs, $context, $deadline)) {
+ $result = $this->finish($saga, $context, ExecutionStatus::Completed, null, null);
+ $this->support->callbacks->invoke($saga->onComplete, $context, $result);
+
+ return $result;
+ }
+
+ return $this->fail($saga, $context, $this->firstError($context));
+ }
+
+ private function fail(SagaDefinition $saga, ExecutionContext $context, Throwable $error): SagaResult
+ {
+ $this->support->checkpoint($context, ExecutionStatus::Compensating, $error->getMessage());
+
+ $compensationFailure = null;
+ try {
+ $this->compensator->compensate($saga, $context);
+ } catch (CompensationException $failure) {
+ $compensationFailure = $failure;
+ }
+
+ foreach ($context->stepErrors() as $stepId => $stepError) {
+ $this->support->deadLetter($context, $stepId, ExecutionStatus::Failed, $stepError);
+ }
+
+ if ($compensationFailure !== null) {
+ $result = $this->compensationFailed($saga, $context, $compensationFailure, $error);
+ } elseif ($this->support->callbacks->suppresses($saga->onError, $error)) {
+ $result = $this->finish($saga, $context, ExecutionStatus::Completed, null, null);
+ } else {
+ $result = $this->finish($saga, $context, ExecutionStatus::Failed, $error, $error->getMessage());
+ }
+
+ $this->support->callbacks->invoke($saga->onError, $context, $result, $error);
+
+ return $result;
+ }
+
+ /**
+ * A compensation that stopped before every completed step was undone (CompensationException) ends the saga
+ * COMPENSATION_FAILED, never FAILED or CANCELLED as if it had been rolled back: the steps it left are named
+ * in the reason and in SagaResult::$uncompensatedSteps, and the execution is dead-lettered for an operator.
+ */
+ private function compensationFailed(SagaDefinition $saga, ExecutionContext $context, CompensationException $failure, Throwable $error): SagaResult
+ {
+ $this->support->deadLetter($context, $failure->stoppedAt, ExecutionStatus::CompensationFailed, $failure);
+
+ return $this->finish($saga, $context, ExecutionStatus::CompensationFailed, $error, $failure->getMessage(), $failure->uncompensatedSteps);
+ }
+
+ /**
+ * @param list $uncompensated
+ */
+ private function finish(SagaDefinition $saga, ExecutionContext $context, ExecutionStatus $status, ?Throwable $error, ?string $reason, array $uncompensated = []): SagaResult
+ {
+ $now = $this->support->now();
+ $report = ExecutionReportBuilder::fromContext($context, $status, $reason, $now, $saga->stepIds());
+ $result = SagaResult::from($saga, $context, $status, $error, $report, $now, $uncompensated);
+
+ $this->support->checkpoint($context, $status, $reason, $report);
+ $this->support->publish($this->support->lifecycle, OrchestrationEvent::executionCompleted($saga->name, $context->correlationId, ExecutionPattern::Saga, $status, $now));
+ $this->support->events->onCompleted($saga->name, $context->correlationId, ExecutionPattern::Saga, $status->isSuccess(), $this->support->elapsedMs($context));
+
+ return $result;
+ }
+
+ private function firstError(ExecutionContext $context): Throwable
+ {
+ foreach ($context->stepErrors() as $error) {
+ return $error;
+ }
+
+ return new OrchestrationException("Saga [{$context->executionName}] failed.");
+ }
+
+ /**
+ * A caller-supplied correlation id that is already persisted belongs to an execution that exists: starting
+ * it again would overwrite its row (the first checkpoint is an upsert), run every step a second time with
+ * none of its idempotency keys, and lose its outcome or its compensation progress.
+ */
+ private function refuseExisting(string $correlationId): void
+ {
+ $state = $this->support->persistence->findById($correlationId);
+ if ($state !== null) {
+ throw new InvalidExecutionStateException("Execution [{$correlationId}] already exists ({$state->pattern->value} {$state->executionName}, {$state->status->value}); use resume() or compensate() instead of starting it again.");
+ }
+ }
+
+ private function load(string $correlationId): ExecutionState
+ {
+ $state = $this->support->persistence->findById($correlationId);
+ if ($state === null || $state->pattern !== ExecutionPattern::Saga) {
+ throw new ExecutionNotFoundException($correlationId);
+ }
+
+ return $state;
+ }
+
+ private function guard(): void
+ {
+ if (! $this->settings->enabled) {
+ throw new PatternDisabledException(ExecutionPattern::Saga, 'firefly.orchestration.saga.enabled');
+ }
+ }
+}
diff --git a/packages/orchestration/src/Saga/Engine/SagaExecutionOrchestrator.php b/packages/orchestration/src/Saga/Engine/SagaExecutionOrchestrator.php
new file mode 100644
index 00000000..9cbd720b
--- /dev/null
+++ b/packages/orchestration/src/Saga/Engine/SagaExecutionOrchestrator.php
@@ -0,0 +1,151 @@
+setTopologyLayers($saga->layers);
+
+ foreach ($saga->layers as $layer) {
+ foreach ($layer as $stepId) {
+ if ($context->stepStatus($stepId)->isSettled()) {
+ continue;
+ }
+ if (! $this->runStep($saga, $saga->steps[$stepId], $inputs, $context, $deadline)) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * {correlationId}, {input.} and {header.} are replaced; anything else is kept literally.
+ */
+ public static function interpolate(string $template, ExecutionContext $context, mixed $input): string
+ {
+ return (string) preg_replace_callback('/\{(correlationId|input\.[^}]+|header\.[^}]+)\}/', static function (array $match) use ($context, $input): string {
+ $token = $match[1];
+ if ($token === 'correlationId') {
+ return $context->correlationId;
+ }
+ if (str_starts_with($token, 'input.')) {
+ $value = is_array($input) ? ($input[substr($token, 6)] ?? '') : '';
+
+ return is_scalar($value) ? (string) $value : '';
+ }
+
+ return $context->header(substr($token, 7)) ?? '';
+ }, $template);
+ }
+
+ /** A step-event key names an input key whose value is the event key; without such a key it is used literally. */
+ public static function eventKey(string $key, mixed $input): string
+ {
+ if ($key === '') {
+ return '';
+ }
+ $value = is_array($input) ? ($input[$key] ?? null) : null;
+
+ return is_scalar($value) ? (string) $value : $key;
+ }
+
+ private function runStep(SagaDefinition $saga, SagaStepDefinition $step, StepInputs $inputs, ExecutionContext $context, ?DateTimeImmutable $deadline): bool
+ {
+ $stepId = $step->id;
+ $now = $this->support->now();
+
+ if ($deadline !== null && $now > $deadline) {
+ return $this->failed($saga, $context, $stepId, new StepTimeoutException("Saga [{$saga->name}] passed its deadline (firefly.orchestration.saga.default-timeout) before step [{$stepId}]."));
+ }
+
+ $input = $inputs->resolveFor($stepId, $context);
+ $key = $step->options->idempotencyKey === '' ? '' : self::interpolate($step->options->idempotencyKey, $context, $input);
+ if ($key !== '' && $context->hasIdempotencyKey($key)) {
+ $context->setStepStatus($stepId, StepStatus::Skipped);
+ $this->support->events->onStepSkippedIdempotent($saga->name, $context->correlationId, $stepId);
+
+ return true;
+ }
+
+ $context->putStepInput($stepId, $input);
+ $context->setStepStatus($stepId, StepStatus::Running);
+ $context->markStepStarted($stepId, $now);
+ $context->clearStepError($stepId);
+ $this->support->events->onStepStarted($saga->name, $context->correlationId, $stepId);
+ $this->support->checkpoint($context, ExecutionStatus::Running);
+
+ $started = hrtime(true);
+ try {
+ $result = $this->invoker->invoke($saga->name, $stepId, $step->invocation, $step->options, $context, $input, $input);
+ } catch (Throwable $error) {
+ $context->setLatencyMs($stepId, self::since($started));
+
+ return $this->failed($saga, $context, $stepId, $error);
+ }
+
+ $latency = self::since($started);
+ $context->putResult($stepId, $result);
+ $context->setLatencyMs($stepId, $latency);
+ $context->setStepStatus($stepId, StepStatus::Done);
+ $context->markCompleted($stepId);
+ if ($key !== '') {
+ $context->addIdempotencyKey($key);
+ }
+ $this->support->events->onStepSuccess($saga->name, $context->correlationId, $stepId, $context->attempts($stepId), $latency);
+
+ $completed = OrchestrationEvent::stepCompleted($saga->name, $context->correlationId, ExecutionPattern::Saga, $stepId, $result, $this->support->now());
+ if ($step->event !== null) {
+ $this->support->publish($this->support->stepEvents, $completed->withTopic($step->event->topic)->withType($step->event->type)->withKey(self::eventKey($step->event->key, $input)));
+ }
+ $this->support->publish($this->support->lifecycle, $completed);
+ $this->support->checkpoint($context, ExecutionStatus::Running);
+
+ return true;
+ }
+
+ private function failed(SagaDefinition $saga, ExecutionContext $context, string $stepId, Throwable $error): bool
+ {
+ $context->setStepStatus($stepId, $error instanceof TimeoutException ? StepStatus::TimedOut : StepStatus::Failed);
+ $context->putStepError($stepId, $error);
+ $this->support->events->onStepFailed($saga->name, $context->correlationId, $stepId, $error, $context->attempts($stepId));
+ $this->support->publish($this->support->lifecycle, OrchestrationEvent::stepFailed($saga->name, $context->correlationId, ExecutionPattern::Saga, $stepId, $error, $this->support->now()));
+
+ return false;
+ }
+
+ private static function since(int|float $started): int
+ {
+ return (int) round((hrtime(true) - $started) / 1_000_000);
+ }
+}
diff --git a/packages/orchestration/src/Saga/Engine/SagaResult.php b/packages/orchestration/src/Saga/Engine/SagaResult.php
new file mode 100644
index 00000000..9aa30675
--- /dev/null
+++ b/packages/orchestration/src/Saga/Engine/SagaResult.php
@@ -0,0 +1,128 @@
+ $steps
+ * @param array $headers
+ * @param list