diff --git a/CHANGELOG.md b/CHANGELOG.md index 853c4a9ba..40a661d78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2.11.2 - 2026-10-23 + +- Deliver unpinned activity tasks to eligible versioned workers during rolling + deployments. Preserve build pins, namespace and queue isolation, activity-type + routing, readiness and availability filters. + ## 2.11.1 - 2026-10-22 - Preserve microsecond precision when selecting expired task leases, available diff --git a/composer.json b/composer.json index 59792160a..b6d8a10de 100644 --- a/composer.json +++ b/composer.json @@ -87,7 +87,7 @@ "dev-main": "2.0.x-dev" }, "durable-workflow": { - "product-train": "2.11.1", + "product-train": "2.11.2", "laravel-embedded-upgrade-contract": "resources/laravel-embedded-upgrade-contract.json", "laravel-dependency-security-policy": "resources/laravel-dependency-security-policy.json" }, diff --git a/src/V2/Support/DefaultActivityTaskBridge.php b/src/V2/Support/DefaultActivityTaskBridge.php index 1ae3bfc11..21ff39efb 100644 --- a/src/V2/Support/DefaultActivityTaskBridge.php +++ b/src/V2/Support/DefaultActivityTaskBridge.php @@ -73,7 +73,14 @@ public function poll( } if ($compatibility !== null) { - $query->where('compatibility', $compatibility); + // Rolling-deploy starts can be unpinned while multiple builds are + // active. They remain eligible for either build without widening + // the queue, namespace or activity-type predicates. + $query->where(static function ($builder) use ($compatibility): void { + $builder->whereNull('compatibility') + ->orWhere('compatibility', '') + ->orWhere('compatibility', $compatibility); + }); } if ($namespace !== null) { diff --git a/tests/Feature/V2/V2ActivityTaskBridgeTest.php b/tests/Feature/V2/V2ActivityTaskBridgeTest.php index e7a8f9369..e05fa2b85 100644 --- a/tests/Feature/V2/V2ActivityTaskBridgeTest.php +++ b/tests/Feature/V2/V2ActivityTaskBridgeTest.php @@ -647,6 +647,98 @@ public function testPollFiltersByCompatibility(): void $this->assertCount(0, $results); } + /** + * @return array + */ + public static function unpinnedActivityBuilds(): array + { + return [ + 'null pin, first build' => [null, 'build-a'], + 'null pin, second build' => [null, 'build-b'], + 'empty pin, first build' => ['', 'build-a'], + 'empty pin, second build' => ['', 'build-b'], + ]; + } + + #[DataProvider('unpinnedActivityBuilds')] + public function testVersionedWorkerCanPollAndClaimAnUnpinnedActivity(?string $compatibility, string $build): void + { + [$run, $execution, $task] = $this->createActivityTask([ + 'compatibility' => $compatibility, + ]); + $run->forceFill([ + 'compatibility' => null, + ])->save(); + config() + ->set('workflows.v2.compatibility.current', $build); + config() + ->set('workflows.v2.compatibility.supported', [$build]); + + $results = $this->bridge->poll('redis', 'default', 1, $build); + + $this->assertCount(1, $results); + $this->assertSame($task->id, $results[0]['task_id']); + $this->assertSame($run->id, $results[0]['workflow_run_id']); + $this->assertSame($execution->id, $results[0]['activity_execution_id']); + $claim = $this->bridge->claimStatus($results[0]['task_id'], $build . '-worker'); + $this->assertTrue($claim['claimed']); + $this->assertSame($task->id, $claim['task_id']); + $this->assertSame($execution->id, $claim['activity_execution_id']); + $this->assertSame(1, $claim['attempt_number']); + $this->assertSame($build . '-worker', $claim['lease_owner']); + $this->assertSame(TaskStatus::Leased, $task->fresh()->status); + $this->assertNull($run->fresh()->compatibility); + $this->assertSame($compatibility, $task->fresh()->compatibility); + } + + public function testVersionedActivityPollPreservesItsScopeAndBuildFence(): void + { + $scope = [ + 'namespace' => 'tenant-main', + 'queue' => 'work', + ]; + [, , $unpinned] = $this->createActivityTask($scope); + [, , $pinned] = $this->createActivityTask(array_merge($scope, [ + 'compatibility' => 'build-a', + ])); + foreach ([ + [ + 'compatibility' => 'build-b', + ], + [ + 'namespace' => 'tenant-other', + ], + [ + 'queue' => 'other', + ], + [ + 'connection' => 'other', + ], + [ + 'task_type' => TaskType::Workflow->value, + ], + [ + 'status' => TaskStatus::Leased->value, + ], + [ + 'available_at' => now() + ->addMinutes(5), + ], + ] as $excluded) { + $this->createActivityTask(array_merge($scope, $excluded)); + } + [, $otherType] = $this->createActivityTask($scope); + $otherType->forceFill([ + 'activity_type' => 'other-activity', + ])->save(); + + $results = $this->bridge->poll('redis', 'work', 20, 'build-a', 'tenant-main', ['test-greeting-activity']); + + $this->assertEqualsCanonicalizing([$unpinned->id, $pinned->id], array_column($results, 'task_id')); + $this->assertSame(0, $unpinned->fresh()->attempt_count); + $this->assertSame(0, $pinned->fresh()->attempt_count); + } + public function testPollWithNullFiltersReturnsAllReadyTasks(): void { $this->createActivityTask();