Skip to content
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,21 @@ project follows [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Added

- Optional, bounded metadata-only activity lease renewal against runtimes that
explicitly advertise it, without a new worker protocol floor.
- Automatic lease and Worker-presence maintenance in an independent PHP CLI
helper while ordinary activity callbacks retain their inline execution and
application-owned service identities. Custom HTTP/authentication setups can
provide an explicit fresh-process Client bootstrap.

### Fixed

- Fence late activity outcomes after original lease, business, progress or
required session authority is lost. Maintenance cannot fabricate application
heartbeats, restore an expired attempt or reset an original session TTL.

## [3.1.0] - 2026-10-21

### Added
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,24 @@ worker configuration, Message Streams, framework setup, and testing examples.
The generated [API reference](https://php.durable-workflow.com/api/) documents
every public class and method.

## Activity runtime leases

When the runtime advertises `activity_lease_renewal`, the managed Worker keeps
an activity's runtime lease and Worker presence alive independently of a blocking
callback. Ordinary callbacks keep running inline with their original injected
services and captured memory. This requires PHP CLI with `proc_open` enabled.

Runtime maintenance does not record application progress or extend an activity,
heartbeat, workflow or worker-session TTL deadline. Continue calling
`ActivityContext::heartbeat()` when an activity has a heartbeat timeout.
After authority is lost, the Worker discards late outcomes. It cannot undo
external side effects or forcibly stop ordinary inline application code.

The default HTTP transport and static worker credentials work automatically.
Custom HTTP clients or dynamic authentication require
[`withActivityLeaseBootstrap()`](docs/sdk-reference.md#activity-runtime-lease-maintenance)
so the independent process recreates their actual TLS and authentication settings.

## Cooperative cancellation release candidate

Cooperative requests let workflow code perform bounded, replayable cleanup.
Expand Down
52 changes: 52 additions & 0 deletions docs/sdk-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,58 @@ task kind, consecutive attempt, selected delay, and typed server exception.
Authentication failures, malformed responses, and generic server errors remain
fatal.

## Activity runtime lease maintenance

The managed Worker starts a separate PHP CLI maintenance process when the
runtime registration advertises `activity_lease_renewal`. It renews only the
original activity attempt and reports the Worker slots as busy. Ordinary
activity code still runs in the original Worker process, including captured
memory, dependency-injected services and application-owned connections.

Maintenance carries the original namespace, queue, task, run, execution,
attempt and owner. It cannot extend recorded activity/workflow deadlines,
change an attempt or reset the original TTL of a required worker session.
Actual application heartbeats can advance only their recorded progress
interval. Runtime pings do not record progress or add heartbeat history events.
An activity with a heartbeat timeout must still call `$context->heartbeat()`.

The helper exits when its owning process dies or its private input closes.
Server recovery then uses the last acknowledged lease and recorded retry
policy. Ordinary inline PHP callbacks cannot be forcibly stopped safely by
this helper. If authority is lost, the managed Worker and its captured Client
reject late completion/failure publication even when application code catches
an earlier refusal. External side effects still need application idempotency.

PHP CLI must permit `proc_open`. The default HTTP transport and static worker
credentials need no extra setup. Custom PSR-18 clients, middleware, TLS/CA
configuration or dynamic authentication require a trusted bootstrap file that
returns a fresh Client for the same runtime URI, namespace and protocol:

```php
// lease-client.php: recreate the same HTTP/auth settings used by the Worker.
return new DurableWorkflow\Client(
getenv('DURABLE_WORKFLOW_SERVER'),
namespace: 'orders',
workerToken: getenv('DURABLE_WORKFLOW_WORKER_TOKEN'),
transport: new DurableWorkflow\Transport\Psr18Transport(
new GuzzleHttp\Client(['verify' => getenv('COMPANY_CA_BUNDLE')]),
),
);
```

Configure the original Client before constructing the Worker:

```php
$client = $client->withActivityLeaseBootstrap(__DIR__.'/lease-client.php');
```

The file runs in the helper, so create new transport/authentication objects
there. Do not return inherited database or HTTP handles. Worker credentials
and claim metadata travel over the private input pipe, not command arguments.
The SDK refuses custom configurations without this bootstrap rather than
silently replacing their TLS, proxy or authentication behavior. Runtimes that
do not advertise the capability retain their existing lease behavior.

## Receive repeated input with Message Streams

Inbound Message Streams deliver repeated, ordered application input to a stable
Expand Down
151 changes: 139 additions & 12 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
use DurableWorkflow\Worker\CancellationScopeDeliveryReceipt;
use DurableWorkflow\Worker\WorkflowClaimAborted;
use DurableWorkflow\Worker\WorkerSessionOptions;
use DurableWorkflow\Worker\ActivityLeaseBinding;
use DurableWorkflow\Worker\ActivityLeaseProcess;
use InvalidArgumentException;

/** Synchronous control-plane and worker-plane client for the standalone server. */
Expand All @@ -64,13 +66,17 @@ final class Client implements WorkflowClientInterface
private const EXTERNAL_PAYLOAD_REFERENCE_SCHEMA = 'durable-workflow.v2.external-payload-reference.v1';
private const WORKFLOW_TASK_WAITING_FOR_HISTORY_MESSAGE = 'Workflow task waiting for scheduled history.';
private const WORKFLOW_TASK_WAITING_FOR_HISTORY_TYPE = 'WorkflowTaskWaitingForHistory';
private const ACTIVITY_TASK_PATH = '/worker/activity-tasks/';
private const CLUSTER_INFO_PATH = '/cluster/info';

private readonly string $baseUri;
private readonly ?Authentication $authentication;
private readonly Transport $transport;
private readonly PayloadCodec $codec;
private readonly RuntimePayloadUploads $payloadUploads;
private bool $boundedWorkerRequests = false;
private ActivityLeaseBinding $activityLeaseBinding;
private ?string $activityLeaseBootstrap = null;
private ?ExecutionTelemetry $executionTelemetry = null;
private bool $observabilityContextSupported = false;
/** @var (\Closure(string, array<string, mixed>|null, array<string, mixed>, RequestBudget|null): (?\Closure))|null */
Expand Down Expand Up @@ -124,13 +130,62 @@ public function __construct(
$this->transport = $transport ?? new Psr18Transport();
$this->codec = $codec ?? new AvroPayloadCodec();
$this->payloadUploads = new RuntimePayloadUploads($this->transport, $this->baseUri);
$this->activityLeaseBinding = new ActivityLeaseBinding();
}

public function payloadCodec(): PayloadCodec
{
return $this->codec;
}

/**
* Recreate custom authentication and HTTP configuration in the independent
* lease-maintenance process. The trusted PHP file must return a Client with
* this same runtime URI, namespace and protocol. It never runs user activity code.
*/
public function withActivityLeaseBootstrap(string $path): self
{
$resolved = realpath($path);
if ($resolved === false || !is_file($resolved) || !is_readable($resolved)) {
throw new InvalidArgumentException('Activity lease bootstrap must be a readable PHP file.');
}
$copy = clone $this;
$copy->activityLeaseBootstrap = $resolved;

return $copy;
}

/** @internal Credentials travel only over the private helper input pipe.
* @return array<string, mixed>
*/
public function activityLeaseProcessConfiguration(): array
{
$configuration = ['base_uri' => $this->baseUri, 'namespace' => $this->namespace,
'worker_protocol' => $this->workerProtocolVersion, 'bootstrap' => $this->activityLeaseBootstrap];
if ($this->activityLeaseBootstrap !== null) {
return $configuration;
}
if (!$this->transport instanceof Psr18Transport || !$this->transport->supportsDefaultActivityLeaseProcess()
|| ($this->authentication !== null && !$this->authentication instanceof TokenAuthentication
&& !$this->authentication instanceof \DurableWorkflow\Auth\StaticHeadersAuthentication)) {
throw new InvalidArgumentException('Independent activity maintenance requires withActivityLeaseBootstrap() for custom authentication or HTTP clients; their TLS and transport settings cannot be discarded.');
}

return $configuration + ['worker_headers' => $this->authentication?->headers(true) ?? []];
}

/** @internal Bootstrap validation never makes a control-plane request. */
public function activityLeaseEndpoint(): string
{
return $this->baseUri;
}

/** @internal Bind only the current execution context, including cloned Clients. */
public function bindActivityLease(ActivityLeaseProcess $process): \Closure
{
return $this->activityLeaseBinding->enter($process);
}

/** Configure tracing before explicit cluster discovery and business I/O. */
public function withTelemetry(ExecutionTelemetry $telemetry): self
{
Expand Down Expand Up @@ -193,14 +248,21 @@ public function supportsSupervisedQueryRequests(): bool
/** Return a new client with the same transport, authentication, and codec for another namespace. */
public function withNamespace(string $namespace): self
{
return new self(
$copy = new self(
$this->baseUri,
$this->authentication,
$namespace,
$this->transport,
$this->codec,
maxExternalPayloadBytes: $this->maxExternalPayloadBytes,
workerProtocolVersion: $this->workerProtocolVersion,
);
$copy->activityLeaseBootstrap = $this->activityLeaseBootstrap;
if ($namespace === $this->namespace) {
$copy->activityLeaseBinding = $this->activityLeaseBinding;
}

return $copy;
}

/** @return array<string, mixed> */
Expand All @@ -212,7 +274,7 @@ public function health(): array
public function clusterInfo(bool $includeDiagnostics = false): ClusterInfo
{
$this->observabilityContextSupported = false;
$path = $includeDiagnostics ? '/cluster/info?include=diagnostics' : '/cluster/info';
$path = $includeDiagnostics ? self::CLUSTER_INFO_PATH.'?include=diagnostics' : self::CLUSTER_INFO_PATH;

$info = $this->control('GET', $path);
$this->observabilityContextSupported = TraceCarrier::supported($info);
Expand Down Expand Up @@ -326,7 +388,7 @@ private function serviceCatalogRead(string $path, array $filters = [], ?string $
public function workerClusterInfo(): ClusterInfo
{
$this->observabilityContextSupported = false;
$info = $this->worker('GET', '/cluster/info');
$info = $this->worker('GET', self::CLUSTER_INFO_PATH);
$this->observabilityContextSupported = TraceCarrier::supported($info);

return ClusterInfo::fromArray($info);
Expand Down Expand Up @@ -1565,7 +1627,8 @@ public function closeWorkerSession(string $workerId, string $sessionId, string $
* @param array<string, int> $taskSlots
* @return array<string, mixed>
*/
public function heartbeatWorker(string $workerId, array $taskSlots = [], ?RequestBudget $budget = null): array
public function heartbeatWorker(string $workerId, array $taskSlots = [], ?RequestBudget $budget = null,
?int $processId = null): array
{
if ($budget !== null && !$this->boundedWorkerRequests) {
throw new \LogicException('A worker authority budget requires bounded worker requests.');
Expand All @@ -1574,7 +1637,7 @@ public function heartbeatWorker(string $workerId, array $taskSlots = [], ?Reques
'worker_id' => $workerId,
'task_slots' => $taskSlots ?: null,
'process_metrics' => [
'process_id' => getmypid(),
'process_id' => $processId ?? getmypid(),
'process_uptime_seconds' => 0,
],
]), budget: $budget);
Expand Down Expand Up @@ -2120,7 +2183,7 @@ public function completeActivityTask(
string $leaseOwner,
mixed $result,
): array {
return $this->worker('POST', '/worker/activity-tasks/'.$this->segment($taskId).'/complete', [
return $this->worker('POST', self::ACTIVITY_TASK_PATH.$this->segment($taskId).'/complete', [
'activity_attempt_id' => $activityAttemptId,
'lease_owner' => $leaseOwner,
'result' => $this->codec->envelope($result),
Expand All @@ -2140,7 +2203,7 @@ public function failActivityTask(
bool $nonRetryable = false,
?array $details = null,
): array {
return $this->worker('POST', '/worker/activity-tasks/'.$this->segment($taskId).'/fail', [
return $this->worker('POST', self::ACTIVITY_TASK_PATH.$this->segment($taskId).'/fail', [
'activity_attempt_id' => $activityAttemptId,
'lease_owner' => $leaseOwner,
'failure' => $this->withoutNulls([
Expand All @@ -2162,13 +2225,48 @@ public function heartbeatActivityTask(
string $leaseOwner,
array $details = [],
): array {
return $this->worker('POST', '/worker/activity-tasks/'.$this->segment($taskId).'/heartbeat', [
return $this->worker('POST', self::ACTIVITY_TASK_PATH.$this->segment($taskId).'/heartbeat', [
'activity_attempt_id' => $activityAttemptId,
'lease_owner' => $leaseOwner,
'details' => $details,
]);
}

/**
* Renew only the original runtime lease, without recording application progress.
* Discovery and transport share at most five seconds and any earlier caller ceiling.
* The caller must validate the returned original identity and actual grant before use.
*
* @return array<string, mixed>
*/
public function renewActivityTaskLease(
string $taskId,
string $activityAttemptId,
string $leaseOwner,
?RequestBudget $budget = null,
): array {
foreach ([$taskId, $activityAttemptId, $leaseOwner] as $identity) {
if (trim($identity) === '' || strlen($identity) > 255) {
throw new InvalidArgumentException('Activity lease renewal requires bounded, nonempty original claim identities.');
}
}
$budget ??= new RequestBudget(5);
$budget->restrictRequestDuration(5);
$budget->remainingSeconds();
$client = $this->withBoundedWorkerRequests();
$info = $client->worker('GET', self::CLUSTER_INFO_PATH, budget: $budget);
$protocol = $info['worker_protocol'] ?? null;
$capabilities = is_array($protocol) ? ($protocol['server_capabilities'] ?? null) : null;
if (!is_array($capabilities) || ($capabilities['activity_lease_renewal'] ?? null) !== true) {
throw new \LogicException('Runtime must explicitly discover activity_lease_renewal before activity lease maintenance.');
}

return $client->worker('POST', self::ACTIVITY_TASK_PATH.$this->segment($taskId).'/renew-lease', [
'activity_attempt_id' => $activityAttemptId,
'lease_owner' => $leaseOwner,
], $budget);
}

/** Observe ownership without renewing a lease or recording user progress.
* @return array<string, mixed>
*/
Expand All @@ -2178,7 +2276,7 @@ public function activityTaskStatus(string $taskId, string $activityAttemptId, st
throw new InvalidArgumentException('Activity attempt observation requires worker protocol 1.20.');
}

return $this->worker('POST', '/worker/activity-tasks/'.$this->segment($taskId).'/status', [
return $this->worker('POST', self::ACTIVITY_TASK_PATH.$this->segment($taskId).'/status', [
'activity_attempt_id' => $activityAttemptId,
'lease_owner' => $leaseOwner,
], $budget);
Expand All @@ -2203,7 +2301,7 @@ public function acknowledgeActivityCancellation(
}
}

return $this->worker('POST', '/worker/activity-tasks/'.$this->segment($taskId).'/acknowledge-cancellation', [
return $this->worker('POST', self::ACTIVITY_TASK_PATH.$this->segment($taskId).'/acknowledge-cancellation', [
'activity_attempt_id' => $activityAttemptId,
'lease_owner' => $leaseOwner,
'request_id' => $requestId,
Expand Down Expand Up @@ -2299,7 +2397,13 @@ private function control(
*/
private function worker(string $method, string $path, ?array $body = null, ?RequestBudget $budget = null): array
{
if ($budget !== null && !$this->boundedWorkerRequests) {
$lease = $this->activityLeaseBinding->current();
$governed = $lease !== null && $lease->governs($path);
if ($governed) {
$lease->checkRequest($body);
$budget ??= $lease->budget();
}
if ($budget !== null && !$this->boundedWorkerRequests && !$governed) {
throw new \LogicException('An activity receipt budget requires bounded worker requests.');
}
if ($method === 'POST'
Expand All @@ -2312,7 +2416,22 @@ private function worker(string $method, string $path, ?array $body = null, ?Requ
$this->assertWorkflowCommandPayloads($commands);
}

return $this->request($method, $path, true, $body, budget: $budget);
$started = hrtime(true) / 1e9;
$requestClient = $governed ? $this->withBoundedWorkerRequests() : $this;
try {
$response = $requestClient->request($method, $path, true, $body, budget: $budget);
} catch (ServerException $error) {
if ($governed && str_ends_with($path, '/heartbeat')
&& ($error->details['can_continue'] ?? null) === false) {
$lease->progress($error->details, $started, hrtime(true) / 1e9);
}
throw $error;
}
if ($governed && str_ends_with($path, '/heartbeat')) {
$lease->progress($response, $started, hrtime(true) / 1e9);
}

return $response;
}

/** @param list<mixed> $commands */
Expand Down Expand Up @@ -2497,6 +2616,14 @@ private function request(
if ($body !== null) {
$body = $this->payloadUploads->request($body, $method, $path, $worker, $headers, $budget);
}
$activityLease = $worker ? $this->activityLeaseBinding->current() : null;
if ($activityLease !== null && $activityLease->governs($path)) {
// Uploading an external result is not publication authority.
// Check again before its final Worker POST and bound the send
// by the latest acknowledged grant, not an old cached lease.
$activityLease->checkRequest($body);
$budget?->restrictAuthorityDeadline($activityLease->publicationDeadline());
}
if ($worker && $this->boundedWorkerRequests) {
if (!$this->transport instanceof BoundedTransport || !$this->transport->supportsBoundedRequests()) {
throw new InvalidArgumentException('The cooperative worker transport no longer supports bounded requests.');
Expand Down
Loading
Loading