Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@ project follows [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Added

- `WorkflowContext::startChildWorkflow()` starts a child and returns a
`ChildWorkflowHandle` once the runtime has committed the start (#185). The
handle's `identity()` gives the child call ID, the child workflow ID and the
initial run ID from canonical history while the child keeps running, and
`await()` returns its result or throws `ChildWorkflowFailed`, following the
same call through child continue-as-new and retries. The start sends
`start_child_workflow` with `await: "started"` and needs a runtime that
advertises `child_workflow_start_turn`; without it the worker abandons the
task before submitting anything. `childWorkflow()`, `deferChildWorkflow()`
and group APIs are unchanged, and `childWorkflow()` refuses an `await`
option.

## [3.9.4] - 2026-11-04

### Fixed
Expand Down
101 changes: 101 additions & 0 deletions docs/portal/build/workflows-activities.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,106 @@ The barrier is fail-fast. The first failure in durable history order is thrown i

On retry or worker restart, replay validates the exact group ID, base sequence, size, leaf index, and nested path. Pending or completed members keep their original sequence and are not scheduled again. Changing a dynamic fan-out count, order, operation kind, or nesting while an open run can reach the group raises `NonDeterministicWorkflow` before any replacement commands leave the worker.

## Start a child and keep its identity

`childWorkflow()` returns the child's result, so the parent waits until the
child finishes. When the parent needs the child's identity first, for example
to record it or notify someone, start the child with `startChildWorkflow()`:

```php
use DurableWorkflow\Worker\ParentClosePolicy;

$child = $context->startChildWorkflow('billing.reconcile', [$accountId], [
'parent_close_policy' => ParentClosePolicy::Abandon,
]);

$identity = $child->identity();
$context->activity('notify.reconcile-started', [$identity->workflowId]);

$summary = $child->await();
```

The turn that calls `startChildWorkflow()` ends after the start command. The
runtime commits the child's start and gives the parent a new turn while the
child keeps running, so the handle returns only for a committed child.
`identity()` reads canonical history:

| Property | Meaning |
| --- | --- |
| `callId` | The parent's call for this child. It stays the same through child continue-as-new and retries. |
| `workflowId` | The child workflow instance ID. |
| `initialRunId` | The first run of the child. A later continued or retried run does not change it. |

`await()` returns the child's result or throws `ChildWorkflowFailed`. It
follows the same child call through continue-as-new and retries, and sends no
command of its own. Replay and a replacement worker rebuild the same handle
from history without starting another child.

Parent-close and cancellation policies, timeouts and retries work as they do
for `childWorkflow()`. The handle supports only `identity()` and `await()`:
it cannot signal, query, update, cancel or terminate the child. Await it in the
workflow execution that started it. `startChildWorkflow()` cannot run inside an
`all()` or `parallel()` group; use `deferChildWorkflow()` there.

This needs a runtime that advertises `child_workflow_start_turn`. Native Server
3.0.x and PHP Server 2.x do not. On those, the worker abandons the task with
`child_workflow_start_turn_unavailable` before it submits anything, so no child
starts. A cancellation-scope checkpoint keeps the parent's claim, so the
runtime refuses a start there with `child_start_turn_not_at_checkpoint`.

### Run the service-mode child example

The complete [worker](https://github.com/durable-workflow/sdk-php/blob/main/examples/child-workflow-worker.php)
and [client](https://github.com/durable-workflow/sdk-php/blob/main/examples/child-workflow-client.php)
use the quickstart's [bootstrap](https://github.com/durable-workflow/sdk-php/blob/main/examples/bootstrap.php).
Copy those three files beside the `vendor/` directory of your Composer project.
Use an SDK release containing `startChildWorkflow()` and a Server advertising
`child_workflow_start_turn`. The same runtime URL and namespace must be available
to both processes. Set `DURABLE_WORKFLOW_RUNTIME_URL`,
`DURABLE_WORKFLOW_NAMESPACE`, and `DURABLE_WORKFLOW_TASK_QUEUE` in both terminals.
Set `DURABLE_WORKFLOW_WORKER_TOKEN` in the worker terminal and
`DURABLE_WORKFLOW_CLIENT_TOKEN` in the client terminal, using their respective
role credentials.

Start the worker, then run a client command in the second terminal:

```bash
php child-workflow-worker.php
```

```bash
php child-workflow-client.php await
php child-workflow-client.php await reject
php child-workflow-client.php abandon
php child-workflow-client.php cancel
php child-workflow-client.php terminate
```

Each parent first demonstrates a sequential result. It then starts a child with a committed handle and records
the identity in an activity. The activity may be retried, so an application
replacing its sample output with an external write should make that write
idempotent by `call_id`.

`await` returns the typed greeting. `await reject` catches `ChildWorkflowFailed`
and returns its recorded failure. The other modes close the parent while its
child remains open: `abandon` allows it to complete, `cancel` applies legacy
terminal cancellation, and `terminate` terminates it. The client prints the
parent result and the child's observed status. Cancellation through the handle
is unsupported. Cooperative cleanup uses the separate opt-in cancellation APIs
and `ParentClosePolicy::RequestCancellation`, as described in the cancellation
guide. Initial run IDs remain snapshots through retry and continue-as-new;
instance-oriented client operations target the child's current run unless the
caller explicitly selects a run.

`deferChildWorkflow()` prepares an operation without starting it or providing a
child identity. Join it at an `all()` or `parallel()` barrier on a runtime that
supports child members in those groups, as shown in the concurrency examples
above. Native Server currently refuses child members with
`development_parallel_group_unqualified`.
[Server #325](https://github.com/durable-workflow/server/issues/325) tracks this
runtime parity gap. The runnable example uses sequential children and committed
start handles; it does not establish deferred-group compatibility on Native.

## Evolve workflow code with version markers

Give each long-lived code change a stable ID and keep the old branch while histories can still reach it. `getVersion()` records the maximum supported version for a new run and returns that same decision after redelivery, worker restart, or cold replay. Including `-1` in the first range lets histories that passed this point before the marker existed stay on the legacy branch:
Expand Down Expand Up @@ -460,6 +560,7 @@ The context provides these replay-aware commands:
| `saga()` | Reverse-order compensation through ordinary activity history. |
| `sleep()` | A durable timer that survives worker downtime. |
| `childWorkflow()` | A separately identified durable execution. |
| `startChildWorkflow()` | A started child whose identity the parent reads before awaiting its result. |
| `all()`, `parallel()` | An ordered, fail-fast join over deferred activities, child workflows, and timers. |
| `sideEffect()` | A small nondeterministic value recorded once in history. |
| `getVersion()`, `patched()`, `deprecatePatch()` | A durable workflow-code evolution decision. |
Expand Down
35 changes: 35 additions & 0 deletions examples/child-workflow-client.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

require_once __DIR__.'/bootstrap.php';

use DurableWorkflow\Client;

$mode = $argv[1] ?? 'await';
if (!in_array($mode, ['await', 'abandon', 'cancel', 'terminate'], true)) {
throw new InvalidArgumentException('Choose await, abandon, cancel or terminate.');
}
$reject = ($argv[2] ?? null) === 'reject';
$client = new Client(
quickstartEnvironment('DURABLE_WORKFLOW_RUNTIME_URL'),
namespace: quickstartEnvironment('DURABLE_WORKFLOW_NAMESPACE'),
controlToken: quickstartEnvironment('DURABLE_WORKFLOW_CLIENT_TOKEN'),
);
$parent = $client->startWorkflow(
'quickstart.php.child-examples',
'php-child-example-'.bin2hex(random_bytes(12)),
quickstartEnvironment('DURABLE_WORKFLOW_TASK_QUEUE'),
['PHP', $mode, $reject],
);
$result = $parent->result(timeoutSeconds: 90, pollIntervalSeconds: 1);
$deadline = microtime(true) + 15;
do {
$child = $client->describeWorkflow($result['identity']['workflow_id']);
if (in_array($child->status, ['completed', 'failed', 'cancelled', 'terminated'], true)) {
break;
}
usleep(200_000);
} while (microtime(true) < $deadline);

echo json_encode(['parent_result' => $result, 'child_status' => $child->status], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT).PHP_EOL;
89 changes: 89 additions & 0 deletions examples/child-workflow-worker.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

declare(strict_types=1);

require_once __DIR__.'/bootstrap.php';

use DurableWorkflow\Attribute\Activity;
use DurableWorkflow\Attribute\Workflow;
use DurableWorkflow\Client;
use DurableWorkflow\Exception\ChildWorkflowFailed;
use DurableWorkflow\Worker;
use DurableWorkflow\Worker\ActivityContext;
use DurableWorkflow\Worker\ParentClosePolicy;
use DurableWorkflow\Worker\WorkflowContext;

final class ChildExampleRejected extends RuntimeException
{
}

final class ChildWorkflowExamples
{
#[Workflow('quickstart.php.child-examples')]
public function run(WorkflowContext $context, string $name, string $mode, bool $reject): array
{
// Sequential calls wait for the child's value.
$sequential = $context->childWorkflow('quickstart.php.child-greeting', [$name, false]);

$policy = match ($mode) {
'await', 'abandon' => ParentClosePolicy::Abandon,
'cancel' => ParentClosePolicy::RequestCancel,
'terminate' => ParentClosePolicy::Terminate,
default => throw new InvalidArgumentException('Unknown child example mode.'),
};
$child = $context->startChildWorkflow('quickstart.php.child-greeting', [$name, $reject], [
'parent_close_policy' => $policy,
]);
$identity = $child->identity();
$snapshot = ['call_id' => $identity->callId, 'workflow_id' => $identity->workflowId, 'initial_run_id' => $identity->initialRunId];
$context->activity('quickstart.php.record-child', [$snapshot]);
$result = ['sequential' => $sequential, 'identity' => $snapshot, 'parent_close_policy' => $policy->value];
if ($mode !== 'await') {
// Closing with an open child applies the selected parent-close policy.
return $result;
}

try {
$result['child_result'] = $child->await();
} catch (ChildWorkflowFailed $failure) {
$result['child_failure'] = ['type' => $failure->failureType, 'message' => $failure->getMessage()];
}

return $result;
}
}

final class ChildGreetingWorkflow
{
#[Workflow('quickstart.php.child-greeting')]
public function greet(WorkflowContext $context, string $name, bool $reject): array
{
$context->sleep(3);
if ($reject) {
throw new ChildExampleRejected('The child declined this greeting.');
}

return ['greeting' => 'Hello, '.$name, 'accepted' => true];
}
}

final class ChildExampleActivities
{
#[Activity('quickstart.php.record-child')]
public function record(ActivityContext $context, array $identity): array

Check warning on line 73 in examples/child-workflow-worker.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the unused function parameter "$context".

See more on https://sonarcloud.io/project/issues?id=durable-workflow_sdk-php&issues=AaGmEnUTd5-zmB2C5B6m&open=AaGmEnUTd5-zmB2C5B6m&pullRequest=280
{
echo json_encode(['child_started' => $identity], JSON_THROW_ON_ERROR).PHP_EOL;

return $identity;
}
}

$client = new Client(
quickstartEnvironment('DURABLE_WORKFLOW_RUNTIME_URL'),
namespace: quickstartEnvironment('DURABLE_WORKFLOW_NAMESPACE'),
workerToken: quickstartEnvironment('DURABLE_WORKFLOW_WORKER_TOKEN'),
);

Worker::create($client, quickstartEnvironment('DURABLE_WORKFLOW_TASK_QUEUE'))
->register(ChildWorkflowExamples::class, ChildGreetingWorkflow::class, ChildExampleActivities::class)
->run();
9 changes: 9 additions & 0 deletions scripts/ci/run-replay-regression-fixture.php
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,15 @@ private static function workflow(string $workflowType): callable

return ['child' => $result];
},
'golden.child-start-handle' => static function (
WorkflowContext $context,
mixed $workflowType,
): array {
$child = $context->startChildWorkflow((string) $workflowType, ['golden-input']);
$identity = $child->identity();

return ['identity' => [$identity->callId, $identity->workflowId, $identity->initialRunId], 'child' => $child->await()];
},
'golden.side-effect' => static function (WorkflowContext $context, mixed $value): array {
$result = $context->sideEffect(static fn (): mixed => $value);

Expand Down
50 changes: 50 additions & 0 deletions scripts/probe-deferred-child-group.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

// Diagnostic for Server #325: records acceptance/refusal of one deferred child.
// Usage: php probe-deferred-child-group.php AUTOLOAD DISPOSABLE_SERVER_URL
// Uses the same disposable role tokens as qualify-child-start-handle.php.

use DurableWorkflow\Client;
use DurableWorkflow\Exception\ServerException;
use DurableWorkflow\Worker\Replayer;
use DurableWorkflow\Worker\WorkflowContext;

[$script, $autoload, $url] = $argv;
require_once $autoload;
$suffix = bin2hex(random_bytes(6));
$namespace = 'php-child-group-'.$suffix;
$queue = 'child-group-'.$suffix;
$admin = new Client($url, controlToken: 'claimed-task-admin');
$admin->createNamespace($namespace);
$control = new Client($url, namespace: $namespace, controlToken: 'claimed-task-client');
$worker = new Client($url, namespace: $namespace, workerToken: 'claimed-task-worker');
$parent = $control->startWorkflow('probe.child-group.parent', 'parent-'.$suffix, $queue);
$workerId = $queue.'-worker';
$registration = $worker->registerWorker($workerId, $queue, ['probe.child-group.parent'], []);
try {
$task = $worker->pollWorkflowTask($workerId, $queue, 1);
if (!is_array($task)) {
throw new LogicException('Probe did not receive its initial parent claim.');
}
$workflow = static fn (WorkflowContext $context): array => $context->all([
$context->deferChildWorkflow('probe.child-group.child', ['child input']),
]);
$commands = (new Replayer($worker->payloadCodec()))->replay($workflow, $task['history_events'] ?? [], [], $queue)->commands;
try {
$response = $worker->completeWorkflowTask($task['task_id'], $task['lease_owner'], $task['workflow_task_attempt'], $commands);
$outcome = ['accepted' => true, 'receipt' => $response];
} catch (ServerException $error) {
$outcome = ['accepted' => false, 'status' => $error->status, 'reason' => $error->reason];
}
$page = $control->workflowHistory($parent->workflowId, (string) $parent->selectedRunId, 200);
echo json_encode([
'observed_at' => gmdate(DATE_ATOM), 'namespace' => $namespace,
'workflow_id' => $parent->workflowId, 'run_id' => $parent->selectedRunId,
'commands' => $commands, 'outcome' => $outcome,
'history' => array_column($page['events'] ?? $page['history_events'] ?? [], 'event_type'),
], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT).PHP_EOL;
} finally {
$worker->deregisterWorkerRegistration($workerId, $registration['registration_token'] ?? null);
}
Loading
Loading