Skip to content

Commit 1898412

Browse files
durable-workflow.github.io: update v2 changes
1 parent daf3e4f commit 1898412

File tree

2 files changed

+136
-12
lines changed

2 files changed

+136
-12
lines changed

docs/features/sagas.md

Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,28 +12,91 @@ Sagas are an established design pattern for managing complex, long-running opera
1212
- The saga pattern assures that all operations are either completed successfully or the corresponding compensation activities are run to undo any completed work.
1313

1414
```php
15-
use function Workflow\activity;
16-
use Workflow\Workflow;
15+
use function Workflow\V2\activity;
16+
use Workflow\V2\Attributes\Type;
17+
use Workflow\V2\Workflow;
1718

19+
#[Type('booking-saga')]
1820
class BookingSagaWorkflow extends Workflow
1921
{
20-
public function execute()
22+
public function handle(): array
2123
{
2224
try {
23-
$flightId = yield activity(BookFlightActivity::class);
25+
$flightId = activity(BookFlightActivity::class);
2426
$this->addCompensation(fn () => activity(CancelFlightActivity::class, $flightId));
2527

26-
$hotelId = yield activity(BookHotelActivity::class);
28+
$hotelId = activity(BookHotelActivity::class);
2729
$this->addCompensation(fn () => activity(CancelHotelActivity::class, $hotelId));
2830

29-
$carId = yield activity(BookRentalCarActivity::class);
31+
$carId = activity(BookRentalCarActivity::class);
3032
$this->addCompensation(fn () => activity(CancelRentalCarActivity::class, $carId));
31-
} catch (Throwable $th) {
32-
yield from $this->compensate();
33-
throw $th;
33+
34+
return compact('flightId', 'hotelId', 'carId');
35+
} catch (\Throwable $e) {
36+
$this->compensate();
37+
38+
throw $e;
3439
}
3540
}
3641
}
3742
```
3843

39-
By default, compensations execute sequentially in the reverse order they were added. To run them in parallel, use `$this->setParallelCompensation(true)`. To ignore exceptions that occur inside compensation activities while still running them sequentially, use `$this->setContinueWithError(true)` instead.
44+
When the workflow catches an exception, `$this->compensate()` runs every registered compensation in **reverse order**. In the example above, if `BookRentalCarActivity` fails, the engine cancels the hotel first and then the flight — unwinding the saga from the most recent step backward.
45+
46+
## Compensation ordering
47+
48+
By default, compensations execute **sequentially in reverse registration order**. This is the safest default because later steps may depend on earlier ones.
49+
50+
## Parallel compensation
51+
52+
To run compensations in parallel, use `setParallelCompensation(true)`. When parallel compensation is enabled, each compensation closure should return a started (but not awaited) activity call so the engine can execute them concurrently:
53+
54+
```php
55+
use function Workflow\V2\activity;
56+
use function Workflow\V2\startActivity;
57+
use Workflow\V2\Attributes\Type;
58+
use Workflow\V2\Workflow;
59+
60+
#[Type('parallel-saga')]
61+
class ParallelSagaWorkflow extends Workflow
62+
{
63+
public function handle(): void
64+
{
65+
$this->setParallelCompensation(true);
66+
67+
try {
68+
$flightId = activity(BookFlightActivity::class);
69+
$this->addCompensation(fn () => startActivity(CancelFlightActivity::class, $flightId));
70+
71+
$hotelId = activity(BookHotelActivity::class);
72+
$this->addCompensation(fn () => startActivity(CancelHotelActivity::class, $hotelId));
73+
74+
activity(ChargePaymentActivity::class);
75+
} catch (\Throwable $e) {
76+
$this->compensate();
77+
78+
throw $e;
79+
}
80+
}
81+
}
82+
```
83+
84+
Use `startActivity()` (not `activity()`) inside parallel compensation closures. `startActivity()` returns a pending call that the engine collects and runs through `all()`, while `activity()` would block inline and defeat the purpose of parallelism.
85+
86+
## Continue with error
87+
88+
By default, if a compensation activity throws an exception, the remaining compensations are skipped and the error propagates. To run all compensations regardless of individual failures, use `setContinueWithError(true)`:
89+
90+
```php
91+
$this->setContinueWithError(true);
92+
```
93+
94+
When enabled, the engine catches and discards exceptions from each compensation closure and continues to the next one. This is useful when compensations are independent and you want a best-effort cleanup even if some steps fail.
95+
96+
## How it works
97+
98+
- `addCompensation()` registers a callable that will be invoked during `compensate()`
99+
- `compensate()` iterates the registered compensations in reverse order
100+
- each compensation closure is a normal V2 workflow step — the activities it calls produce durable history events just like any other activity
101+
- compensation activities are visible in Waterline's timeline and history export
102+
- if the workflow succeeds, compensation closures are never invoked and produce no history

docs/features/side-effects.md

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,72 @@ class MyWorkflow extends Workflow
2727

2828
The workflow will only call `random_int()` once and save the result, even if the workflow later fails and is retried.
2929

30-
**Important:** The code inside a side effect should never fail because it will not be retried. Code that can possibly fail and therefore need to be retried should be moved to an activity instead.
30+
## When to use side effects
3131

32-
## How It Works
32+
Use `sideEffect()` when you need a non-deterministic value that:
33+
34+
- is computed locally without external I/O (random numbers, UUIDs, timestamps)
35+
- should never change once recorded, even across replays
36+
- does not need retry semantics — the closure runs exactly once
37+
38+
```php
39+
// Generate a correlation token for downstream systems.
40+
$correlationId = sideEffect(fn () => (string) Str::uuid());
41+
42+
// Snapshot the current time for a business rule.
43+
$decidedAt = sideEffect(fn () => now()->toIso8601String());
44+
```
45+
46+
## When to use an activity instead
47+
48+
If the code can fail, talks to an external service, or needs retry/timeout semantics, use an [activity](../defining-workflows/activities.md) instead of a side effect:
49+
50+
| Scenario | Use |
51+
|---|---|
52+
| Generate a random token | `sideEffect()` |
53+
| Read a config value at decision time | `sideEffect()` |
54+
| Call an external API | `activity()` |
55+
| Write to a database | `activity()` |
56+
| Send an email or notification | `activity()` |
57+
| Compute an expensive value that can throw | `activity()` |
58+
59+
The rule of thumb: if the closure can throw an exception that you would want to retry, it belongs in an activity.
60+
61+
## How it works
3362

3463
- each `sideEffect()` call appends a typed `SideEffectRecorded` history event with the workflow step sequence
3564
- workflow replay and query replay both reuse that committed value instead of re-running the closure
3665
- Waterline surfaces the side-effect snapshot as a typed history entry in the selected run timeline
3766
- side effects are still for replay-safe snapshots only, not for work that can fail or that needs retry semantics
67+
68+
## Anti-patterns
69+
70+
**Do not call external services inside a side effect.** If the service call fails, the side effect will not be retried and the workflow will fail permanently:
71+
72+
```php
73+
// BAD: HTTP calls can fail and side effects do not retry.
74+
$price = sideEffect(fn () => Http::get('/api/price')->json('amount'));
75+
76+
// GOOD: Use an activity for external calls.
77+
$price = activity(FetchPriceActivity::class);
78+
```
79+
80+
**Do not put slow or blocking operations inside a side effect.** The closure runs on the workflow task thread. Long-running work delays the entire workflow task:
81+
82+
```php
83+
// BAD: Expensive computation blocks the workflow task.
84+
$hash = sideEffect(fn () => bcrypt($largePayload));
85+
86+
// GOOD: Offload heavy work to an activity.
87+
$hash = activity(ComputeHashActivity::class, $largePayload);
88+
```
89+
90+
**Do not rely on mutable external state.** The closure is executed exactly once. If you read a value that changes over time, the snapshot is frozen at the moment of first execution — not at replay time:
91+
92+
```php
93+
// The cached value is whatever it was during the first execution.
94+
// If the cache changes later, this workflow still sees the old value.
95+
$setting = sideEffect(fn () => cache('feature.flag'));
96+
```
97+
98+
This is by design — the snapshot is intentionally frozen for determinism. If you need a value that updates over the lifetime of the workflow, use a signal or an activity.

0 commit comments

Comments
 (0)