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
1 change: 1 addition & 0 deletions docs/portal/build/failures-retries.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ next:
| `ServerException` | The runtime rejected or failed an operation. | Inspect status, reason, and details. |
| `ActivityFailed` | A recorded activity reached a failed terminal attempt. | Catch in workflow code only when compensation is deterministic. |
| `NonDeterministicWorkflow` | Code no longer matches committed history. | Restore compatible code; do not blindly retry. |
| `WorkflowEntryArgumentsMismatch` | Recorded start input cannot bind to the entry method, before its body runs. The workflow task is refused and the run remains recoverable. | Restore a compatible entry method, inspect the task and use run-scoped repair. |
| `WorkflowFailed` | The execution closed as failed. | Surface the recorded failure to the caller. |
| `WorkflowTimedOut` | A Server deadline closed the run. | Start a new workflow only if business policy permits. |
| `WorkflowCancelled` | The run closed as Cancelled, after terminal cancellation or successful cooperative cleanup. | Inspect the cancellation lifecycle and cleanup outcome. |
Expand Down
15 changes: 15 additions & 0 deletions docs/portal/operate/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,21 @@ Do not use the SDK repository's `main` branch as a production Composer source. I

Start new workers before draining the old pool. Keep old and new builds registered only when both can replay the histories routed to that queue. If a change is not replay-compatible, use a new task queue or a worker build-routing plan rather than hoping restarts choose the right code.

## Preserve workflow entry arguments

Recorded start input must still bind to the deployed workflow entry method.
Adding a required parameter or changing its type can prevent the method from
starting, before a version branch inside it can run. The worker reports
`WorkflowEntryArgumentsMismatch` as a nonterminal workflow-task replay refusal,
with reason `workflow_entry_arguments_mismatch`. It leaves the original run,
input, history and deadlines intact.

Restore a compatible entry method and use the runtime's run-scoped repair
operation to resume the same run. Inspect the refused task before repairing it;
repeated incompatible deployments do not fix its input. A compatible optional
parameter is allowed. A `TypeError` thrown after valid entry binding remains an
application failure, rather than being relabelled as a deployment mismatch.

## Readiness and shutdown

Runtime readiness, worker admission, and process liveness are different signals:
Expand Down
6 changes: 6 additions & 0 deletions regression-corpus-policy.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@
{
"glob": "src/Worker/Replayer.php"
},
{
"glob": "src/Worker/WorkflowEntryArguments.php"
},
{
"glob": "src/Exception/WorkflowEntryArgumentsMismatch.php"
},
{
"glob": "src/Worker/WorkflowCommand.php"
},
Expand Down
13 changes: 13 additions & 0 deletions scripts/ci/run-replay-regression-fixture.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use DurableWorkflow\Exception\NonDeterministicWorkflow;
use DurableWorkflow\Exception\TransportException;
use DurableWorkflow\Exception\WorkflowCancelled;
use DurableWorkflow\Exception\WorkflowEntryArgumentsMismatch;
use DurableWorkflow\Model\WorkflowStreamAppendItem;
use DurableWorkflow\Transport\Transport;
use DurableWorkflow\Worker;
Expand Down Expand Up @@ -209,6 +210,17 @@ public function send(string $method, string $uri, array $headers, ?array $body =

return ['failed' => true];
}
if (is_array($failure) && ($failure['type'] ?? null) === WorkflowEntryArgumentsMismatch::class
&& ($failure['reason'] ?? null) === 'workflow_entry_arguments_mismatch'
&& !array_key_exists('sequence', $failure)) {
$this->completedCommands[] = [[
'type' => 'replay_error',
'reason' => 'workflow_entry_arguments_mismatch',
'sequence' => null,
]];

return ['failed' => true];
}
if (!is_array($failure)
|| ($failure['type'] ?? null) !== NonDeterministicWorkflow::class
|| !is_string($failure['reason'] ?? null)
Expand Down Expand Up @@ -605,6 +617,7 @@ private static function assertExpected(string $identity, array $fixture, array $
private static function workflow(string $workflowType): callable
{
return match ($workflowType) {
'golden.entry-arguments-required' => static fn (WorkflowContext $context, int $amount, int $required): int => $amount + $required,
'golden.finally-disposal' => static function (WorkflowContext $context): void {
try {
$context->sleep(1);
Expand Down
23 changes: 23 additions & 0 deletions src/Exception/WorkflowEntryArgumentsMismatch.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace DurableWorkflow\Exception;

/** Entry input cannot bind, before application workflow code starts. */
final class WorkflowEntryArgumentsMismatch extends DurableWorkflowException
{
public readonly string $reason;

public function __construct(
public readonly string $workflowType,
public readonly string $bindingReason,
public readonly ?string $parameter = null,
public readonly ?string $expectedType = null,
public readonly ?string $actualType = null,
) {
$this->reason = 'workflow_entry_arguments_mismatch';
parent::__construct('replay failed: '.$this->reason.' for '.$workflowType
.' ('.$bindingReason.($parameter === null ? '' : ', parameter '.$parameter).').');
}
}
7 changes: 6 additions & 1 deletion src/Worker.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use DurableWorkflow\Exception\InvalidWorkerDefinition;
use DurableWorkflow\Exception\LocalActivityTimedOut;
use DurableWorkflow\Exception\NonDeterministicWorkflow;
use DurableWorkflow\Exception\WorkflowEntryArgumentsMismatch;
use DurableWorkflow\Exception\SagaCompensationFailed;
use DurableWorkflow\Exception\ServerException;
use DurableWorkflow\Exception\TransportException;
Expand Down Expand Up @@ -40,6 +41,7 @@
use DurableWorkflow\Worker\WorkflowClaimDeferred;
use DurableWorkflow\Worker\WorkflowClaimRevoked;
use DurableWorkflow\Worker\WorkflowContext;
use DurableWorkflow\Worker\WorkflowEntryArguments;
use DurableWorkflow\Worker\StickyWorkflowCache;
use DurableWorkflow\Worker\WorkerSession;
use DurableWorkflow\Worker\WorkerSessionOptions;
Expand Down Expand Up @@ -1294,6 +1296,9 @@ private function executeWorkflowTask(array $task): void
throw new \RuntimeException("No workflow handler is registered for {$workflowType}.");
}
$input = $this->decodeArguments($task['arguments'] ?? $task['input'] ?? null);
// Keep pre-entry refusal outside the application-error boundary.
// A valid-bound body throwing the same exception is still a body failure.
WorkflowEntryArguments::validate($handler, $input, $workflowType);
try {
$replay = $this->replayWorkflowClaim(
$handler,
Expand Down Expand Up @@ -1374,7 +1379,7 @@ function (Throwable $failure) use ($taskId, $leaseOwner, $attempt): void {
$attempt,
'PHP workflow task execution failed: '.$failure->getMessage(),
$failure::class,
$failure instanceof NonDeterministicWorkflow ? $failure->reason : null,
$failure instanceof NonDeterministicWorkflow || $failure instanceof WorkflowEntryArgumentsMismatch ? $failure->reason : null,
$failure instanceof NonDeterministicWorkflow ? $failure->sequence : null,
);
},
Expand Down
1 change: 1 addition & 0 deletions src/Worker/Replayer.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ public function replay(
if ($allowScopedPreparedLocalActivities && (!$prepareLocalActivities || !$prepareCancellationScopeDelivery)) {
throw new LogicException('Scoped local callbacks require prepared admission and canonical scope delivery.');
}
WorkflowEntryArguments::validate($handler, $input, (string) ($task['workflow_type'] ?? ''));
$hasScopes = $this->assertCancellationScopeReplaySupported($history, $allowCancellationScopeAuthoring, $task, $replayCommittedCancellationScopes);
$scopes = new CancellationScopeHistory($hasScopes ? $history : [], (string) ($task['run_id'] ?? ''));
// Unfrozen source profile, enabled only by the Worker's explicit scope opt-in.
Expand Down
127 changes: 127 additions & 0 deletions src/Worker/WorkflowEntryArguments.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<?php

declare(strict_types=1);

namespace DurableWorkflow\Worker;

use Closure;
use DurableWorkflow\Exception\WorkflowEntryArgumentsMismatch;
use ReflectionClass;
use ReflectionFunction;
use ReflectionIntersectionType;
use ReflectionNamedType;
use ReflectionParameter;
use ReflectionType;
use ReflectionUnionType;

/** @internal Match PHP's strict entry invocation without executing the handler. */
final class WorkflowEntryArguments
{
/** @param list<mixed> $input */
public static function validate(callable $handler, array $input, string $workflowType): void
{
$contract = $handler instanceof HandlerDefinition ? $handler->contract() : Closure::fromCallable($handler);
$entry = new ReflectionFunction($contract);
$parameters = $entry->getParameters();
if (($parameters[0] ?? null)?->isVariadic()) {
foreach ($input as $value) {
self::validateType($parameters[0], $value, $entry, $workflowType);
}

return;
}
// The first parameter is the SDK context, validated at registration.
foreach (array_slice($parameters, 1) as $index => $parameter) {
if ($parameter->isVariadic()) {
foreach (array_slice($input, $index) as $value) {
self::validateType($parameter, $value, $entry, $workflowType);
}

return;
}
if (!array_key_exists($index, $input)) {
if (!$parameter->isOptional()) {
throw new WorkflowEntryArgumentsMismatch($workflowType, 'required_argument_missing', $parameter->getName());
}
continue;
}
self::validateType($parameter, $input[$index], $entry, $workflowType);
}
// PHP permits extra trailing positional arguments. Preserve that contract.
}

private static function validateType(ReflectionParameter $parameter, mixed $value, ReflectionFunction $entry, string $workflowType): void
{
$type = $parameter->getType();
if ($type !== null && !self::matches($type, $value, $entry->getClosureScopeClass())) {
throw new WorkflowEntryArgumentsMismatch($workflowType, 'argument_type_mismatch', $parameter->getName(), (string) $type, get_debug_type($value));
}
}

/** @param ReflectionClass<object>|null $scope */
private static function matches(ReflectionType $type, mixed $value, ?ReflectionClass $scope): bool
{
return match (true) {
$value === null && $type->allowsNull() => true,
$type instanceof ReflectionUnionType => self::matchesUnion($type, $value, $scope),
$type instanceof ReflectionIntersectionType => self::matchesIntersection($type, $value, $scope),
$type instanceof ReflectionNamedType => self::matchesNamed($type, $value, $scope),
default => false,
};
}

/** @param ReflectionClass<object>|null $scope */
private static function matchesUnion(ReflectionUnionType $type, mixed $value, ?ReflectionClass $scope): bool
{
foreach ($type->getTypes() as $member) {
if (self::matches($member, $value, $scope)) {
return true;
}
}

return false;
}

/** @param ReflectionClass<object>|null $scope */
private static function matchesIntersection(ReflectionIntersectionType $type, mixed $value, ?ReflectionClass $scope): bool
{
foreach ($type->getTypes() as $member) {
if (!self::matches($member, $value, $scope)) {
return false;
}
}

return true;
}

/** @param ReflectionClass<object>|null $scope */
private static function matchesNamed(ReflectionNamedType $type, mixed $value, ?ReflectionClass $scope): bool
{
if (!$type->isBuiltin()) {
$parent = $scope?->getParentClass();
$name = match ($type->getName()) {
'self', 'static' => $scope?->getName(),
'parent' => $parent === false ? null : $parent?->getName(),
default => $type->getName(),
};

return is_string($name) && $value instanceof $name;
}

return match ($type->getName()) {
'mixed' => true,
'int' => is_int($value),
'float' => is_float($value) || is_int($value),
'string' => is_string($value),
'bool' => is_bool($value),
'true' => $value === true,
'false' => $value === false,
'null' => $value === null,
'array' => is_array($value),
'object' => is_object($value),
'callable' => is_callable($value),
'iterable' => is_iterable($value),
default => false,
};
}
}
6 changes: 4 additions & 2 deletions tests/Support/ReplayRegressionFixture.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use DurableWorkflow\Exception\ChildWorkflowFailed;
use DurableWorkflow\Exception\NonDeterministicWorkflow;
use DurableWorkflow\Exception\WorkflowCancelled;
use DurableWorkflow\Exception\WorkflowEntryArgumentsMismatch;
use DurableWorkflow\Model\WorkflowStreamAppendItem;
use DurableWorkflow\Testing\WorkerTestHarness;
use DurableWorkflow\Worker;
Expand Down Expand Up @@ -175,12 +176,12 @@ public static function execute(array $fixture): array
'message' => $exception->getMessage(),
'exception_type' => $exception::class,
]];
} catch (NonDeterministicWorkflow $exception) {
} catch (NonDeterministicWorkflow|WorkflowEntryArgumentsMismatch $exception) {
$commands = [[
'type' => 'replay_error',
'message' => $exception->getMessage(),
'reason' => $exception->reason,
'sequence' => $exception->sequence,
'sequence' => $exception instanceof NonDeterministicWorkflow ? $exception->sequence : null,
]];
} catch (\Throwable $exception) {
$commands = [[
Expand Down Expand Up @@ -322,6 +323,7 @@ private static function executeWorkflowTasks(
private static function workflow(string $workflowType): callable
{
return match ($workflowType) {
'golden.entry-arguments-required' => static fn (WorkflowContext $context, int $amount, int $required): int => $amount + $required,
'golden.nexus-caller' => static function (WorkflowContext $context): mixed {
return $context->serviceOperation('billing', 'invoices', 'create', ['invoice_id' => 42],
['idempotency_key' => 'call-key'])->responsePayload;
Expand Down
Loading
Loading