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 .github/workflows/php.yml
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,20 @@ jobs:
tests/Unit/V2/ProjectionSqliteContentionTest.php \
--fail-on-warning --log-junit build/test-results/pr-database-queue-recovery.xml

- name: Run legacy overlap releases on a real database queue
env:
DB_CONNECTION: sqlite
DB_DATABASE: ${{ runner.temp }}/workflow-overlap-release.sqlite
CACHE_DRIVER: array
CACHE_STORE: array
QUEUE_CONNECTION: sync
REDIS_HOST: ''
run: |
touch "$DB_DATABASE"
vendor/bin/phpunit tests/Unit/WorkflowOverlapReleaseTest.php \
tests/Unit/WorkflowOptionsTest.php \
tests/Unit/V2/LegacyOverlapOptionsBoundaryTest.php --testdox --fail-on-warning

- name: Run retained execution authority and process-loss controls
run: >-
vendor/bin/phpunit tests/Unit/V2/EmbeddedContinuationTest.php
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## 2.11.0 - 2026-10-22

- Add the opt-in `WorkflowOptions::overlapReleaseAfter` delay for legacy
workflow, activity, signal and child-completion jobs contending on overlap
locks. Preserve the zero default, stored options, lock ownership and expiry.
- Preserve the delay through child inheritance and continue-as-new. Queue
releases still count toward Laravel attempt limits.

## 2.10.2 - 2026-10-22

- Preserve recorded activity lease expiry and worker deregistration in canonical
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@
"dev-main": "2.0.x-dev"
},
"durable-workflow": {
"product-train": "2.10.2",
"product-train": "2.11.0",
"laravel-embedded-upgrade-contract": "resources/laravel-embedded-upgrade-contract.json",
"laravel-dependency-security-policy": "resources/laravel-dependency-security-policy.json"
},
Expand Down
9 changes: 5 additions & 4 deletions docs/architecture/routing-precedence.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,11 @@ The contract authorities are:
`RoutingResolver::activityConnection`, and
`RoutingResolver::activityQueue`. No other class is allowed to
compute `(connection, queue)` from the same inputs.
- **`WorkflowOptions`** — the durable wire value for workflow start
routing overrides. The type carries only `?string $connection` and
`?string $queue`. Any additional fields on `WorkflowOptions` are a
protocol change.
- **`WorkflowOptions`** — workflow start routing overrides are
`?string $connection` and `?string $queue`. The optional nonnegative
`int $overlapReleaseAfter` controls only the legacy `Workflow\\Workflow`
queue middleware. It defaults to zero, is omitted from stored metadata at
that default, and does not change V2 routing or the portable service protocol.
- **`ActivityOptions`** — the durable wire value for activity
scheduling overrides. Routing fields on `ActivityOptions` are
`?string $connection` and `?string $queue`; retry and timeout
Expand Down
32 changes: 32 additions & 0 deletions docs/legacy-overlap-release.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Legacy overlap release delay

Applications using `Workflow\Workflow` can back off queue jobs that cannot
acquire an overlap lock:

```php
use Workflow\WorkflowOptions;
use Workflow\WorkflowStub;

$workflow = WorkflowStub::make(MyWorkflow::class);
$workflow->start('argument', new WorkflowOptions(overlapReleaseAfter: 5));
```

`WorkflowOptions::set(['overlapReleaseAfter' => 5])` accepts the same setting.
The delay is a nonnegative integer in seconds. Its default is zero, preserving
existing behavior and stored options. It applies to contended workflow,
activity, signal and child-completion queue jobs. It does not change lock
expiry, semaphore ownership, or activity exception backoff.

The setting is saved with the run. Children and continue-as-new inherit it
when no explicit `WorkflowOptions` is supplied. An explicit options object
replaces inherited options, including resetting this delay to zero if omitted.

Laravel counts queue reservations as attempts even when middleware releases
the job before its handler executes. A positive delay reduces rapid retries;
it does not make a low `$tries`, `--tries`, or database queue attempt limit
safe for arbitrary contention. Choose the delay and retry budget for the
expected lock duration. Keep queue visibility and worker timeout settings
consistent with activity duration.

This option belongs to the legacy API. `Workflow\V2\Workflow` uses durable
task claims and does not use this overlap middleware.
3 changes: 2 additions & 1 deletion src/Activity.php
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,8 @@ public function middleware()
new WithoutOverlappingMiddleware(
$this->storedWorkflow->id,
WithoutOverlappingMiddleware::ACTIVITY,
0,
$this->storedWorkflow->workflowOptions()
->overlapReleaseAfter,
$this->timeout
),
new ActivityMiddleware(),
Expand Down
8 changes: 7 additions & 1 deletion src/ChildWorkflow.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,13 @@ public function handle()
public function middleware()
{
return [
new WithoutOverlappingMiddleware($this->parentWorkflow->id, WithoutOverlappingMiddleware::ACTIVITY, 0, 15),
new WithoutOverlappingMiddleware(
$this->parentWorkflow->id,
WithoutOverlappingMiddleware::ACTIVITY,
$this->parentWorkflow->workflowOptions()
->overlapReleaseAfter,
15
),
];
}
}
2 changes: 1 addition & 1 deletion src/ChildWorkflowStub.php
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ public static function make($workflow, ...$arguments): PromiseInterface
if (! $hasOptions) {
$options = $context->storedWorkflow->workflowOptions();

if ($options->connection !== null || $options->queue !== null) {
if ($options->connection !== null || $options->queue !== null || $options->overlapReleaseAfter !== 0) {
$arguments[] = $options;
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/Signal.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ public function middleware()
new WithoutOverlappingMiddleware(
$this->storedWorkflow->id,
WithoutOverlappingMiddleware::WORKFLOW,
0,
$this->storedWorkflow->workflowOptions()
->overlapReleaseAfter,
15
),
];
Expand Down
2 changes: 1 addition & 1 deletion src/Traits/Continues.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ public static function continueAsNew(...$arguments): PromiseInterface
if (! collect($arguments)->contains(static fn ($argument): bool => $argument instanceof WorkflowOptions)) {
$options = $context->storedWorkflow->workflowOptions();

if ($options->connection !== null || $options->queue !== null) {
if ($options->connection !== null || $options->queue !== null || $options->overlapReleaseAfter !== 0) {
$arguments[] = $options;
}
}
Expand Down
16 changes: 14 additions & 2 deletions src/Workflow.php
Original file line number Diff line number Diff line change
Expand Up @@ -174,18 +174,30 @@ public function children(): array

public function middleware()
{
$releaseAfter = $this->storedWorkflow->workflowOptions()
->overlapReleaseAfter;
$parentWorkflow = $this->storedWorkflow->parents()
->wherePivot('parent_index', '!=', StoredWorkflow::CONTINUE_PARENT_INDEX)
->wherePivot('parent_index', '!=', StoredWorkflow::ACTIVE_WORKFLOW_INDEX)
->first();

if ($parentWorkflow) {
return [
new WithoutOverlappingMiddleware($parentWorkflow->id, WithoutOverlappingMiddleware::ACTIVITY, 0, 15),
new WithoutOverlappingMiddleware(
$parentWorkflow->id,
WithoutOverlappingMiddleware::ACTIVITY,
$releaseAfter,
15
),
];
}
return [
new WithoutOverlappingMiddleware($this->storedWorkflow->id, WithoutOverlappingMiddleware::WORKFLOW, 0, 15),
new WithoutOverlappingMiddleware(
$this->storedWorkflow->id,
WithoutOverlappingMiddleware::WORKFLOW,
$releaseAfter,
15
),
];
}

Expand Down
5 changes: 4 additions & 1 deletion src/WorkflowMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public function __construct(
/**
* @return array{
* arguments: array<int, mixed>,
* options: array{connection: ?string, queue: ?string}
* options: array{connection: ?string, queue: ?string, overlapReleaseAfter?: int}
* }
*/
public function toArray(): array
Expand All @@ -28,6 +28,9 @@ public function toArray(): array
'options' => [
'connection' => $this->options->connection,
'queue' => $this->options->queue,
...($this->options->overlapReleaseAfter === 0 ? [] : [
'overlapReleaseAfter' => $this->options->overlapReleaseAfter,
]),
],
];
}
Expand Down
23 changes: 21 additions & 2 deletions src/WorkflowOptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,38 @@

namespace Workflow;

use InvalidArgumentException;

final class WorkflowOptions
{
public function __construct(
public readonly ?string $connection = null,
public readonly ?string $queue = null,
public readonly int $overlapReleaseAfter = 0,
) {
if ($overlapReleaseAfter < 0) {
throw new InvalidArgumentException('overlapReleaseAfter must be a nonnegative number of seconds.');
}
}

public function __unserialize(array $data): void
{
// Options serialized before this setting was introduced keep their zero delay.
$options = self::set($data);
$this->connection = $options->connection;
$this->queue = $options->queue;
$this->overlapReleaseAfter = $options->overlapReleaseAfter;
}

/**
* @param array{connection?: string|null, queue?: string|null} $options
* @param array{connection?: string|null, queue?: string|null, overlapReleaseAfter?: int} $options
*/
public static function set(array $options): self
{
return new self($options['connection'] ?? null, $options['queue'] ?? null);
return new self(
$options['connection'] ?? null,
$options['queue'] ?? null,
array_key_exists('overlapReleaseAfter', $options) ? $options['overlapReleaseAfter'] : 0,
);
}
}
17 changes: 17 additions & 0 deletions tests/Fixtures/TestOverlapDelayWorkflow.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Tests\Fixtures;

use Workflow\Workflow;

final class TestOverlapDelayWorkflow extends Workflow
{
public function execute()
{
yield from [];

return 'completed';
}
}
24 changes: 19 additions & 5 deletions tests/Unit/ChildWorkflowStubTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Exception;
use Mockery;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\Fixtures\TestChildWorkflow;
use Tests\Fixtures\TestExceptionWorkflow;
use Tests\Fixtures\TestParentWorkflow;
Expand Down Expand Up @@ -347,8 +348,20 @@ public function testDoesNotResumeRunningStartedChildWorkflow(): void
$this->assertSame(1, WorkflowStub::getContext()->index);
}

public function testUsesParentContextForInheritedWorkflowOptions(): void
public static function inheritedWorkflowOptions(): array
{
return [
'routing' => ['sync', 'parent-queue', 0],
'delay only' => [null, null, 7],
];
}

#[DataProvider('inheritedWorkflowOptions')]
public function testUsesParentContextForInheritedWorkflowOptions(
?string $connection,
?string $queue,
int $delay
): void {
$childContextStoredWorkflow = Mockery::mock();
$childContextStoredWorkflow->shouldNotReceive('workflowOptions');

Expand All @@ -359,7 +372,7 @@ public function testUsesParentContextForInheritedWorkflowOptions(): void
->andReturn(null);
$parentStoredWorkflow->shouldReceive('workflowOptions')
->once()
->andReturn(new WorkflowOptions('sync', 'parent-queue'));
->andReturn(new WorkflowOptions($connection, $queue, $delay));

$childWorkflow = Mockery::mock();
$childWorkflow->shouldReceive('running')
Expand All @@ -371,7 +384,7 @@ public function testUsesParentContextForInheritedWorkflowOptions(): void
$childWorkflow->shouldReceive('startAsChild')
->once()
->withArgs(
static function (...$arguments) use ($parentStoredWorkflow): bool {
static function (...$arguments) use ($parentStoredWorkflow, $connection, $queue, $delay): bool {
if (count($arguments) !== 4) {
return false;
}
Expand All @@ -381,8 +394,9 @@ static function (...$arguments) use ($parentStoredWorkflow): bool {
return $parentWorkflow === $parentStoredWorkflow
&& $index === 0
&& $options instanceof WorkflowOptions
&& $options->connection === 'sync'
&& $options->queue === 'parent-queue';
&& $options->connection === $connection
&& $options->queue === $queue
&& $options->overlapReleaseAfter === $delay;
}
);

Expand Down
Loading
Loading