Skip to content

Commit e9f64b0

Browse files
committed
Block default-queue starts on drained cohorts
Resolve the effective default queue before the drain gate so implicit starts and webhook bridge starts fail closed while the default cohort is draining. Update the compatibility-blocked assertions to match the queue-qualified rejection detail emitted after routing context resolution.
1 parent e7aedb4 commit e9f64b0

7 files changed

Lines changed: 300 additions & 58 deletions

‎app/Http/Controllers/Api/BridgeAdapterController.php‎

Lines changed: 35 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -113,32 +113,40 @@ private function startWorkflow(
113113
$workflowId = is_string($startTarget['workflow_id'] ?? null)
114114
? $startTarget['workflow_id']
115115
: $this->workflowIdFor($adapter, $idempotencyKey);
116-
$taskQueue = is_string($startTarget['task_queue'] ?? null)
117-
? trim($startTarget['task_queue'])
118-
: null;
119-
120-
if ($taskQueue !== null && $taskQueue !== '') {
121-
$routingBlock = $this->taskQueueRoutingGate->workflowStartBlock((string) $namespace, $taskQueue);
122-
123-
if ($routingBlock !== null) {
124-
return $this->rejected($request, $adapter, 'start_workflow', $idempotencyKey, 'task_queue_draining', array_filter([
125-
'message' => sprintf(
126-
'Task queue [%s] is draining and cannot accept new workflow starts until an active worker cohort is available.',
127-
$taskQueue,
128-
),
129-
'target' => $this->redactedTarget($target + ['workflow_id' => $workflowId]),
130-
'correlation' => $correlation,
131-
'workflow_id' => $workflowId,
132-
'workflow_type' => $startTarget['workflow_type'],
133-
'task_queue' => $taskQueue,
134-
'routing_status' => $routingBlock['routing_status'],
135-
'active_worker_count' => $routingBlock['active_worker_count'],
136-
'draining_worker_count' => $routingBlock['draining_worker_count'],
137-
'stale_worker_count' => $routingBlock['stale_worker_count'],
138-
'draining_build_ids' => $routingBlock['draining_build_ids'],
139-
'drain_intent' => 'draining',
140-
], static fn (mixed $value): bool => $value !== null));
141-
}
116+
117+
try {
118+
$taskQueue = $this->workflowStartService->resolveTaskQueue(
119+
$startTarget['workflow_type'],
120+
$startTarget['task_queue'] ?? null,
121+
);
122+
} catch (LogicException $exception) {
123+
return $this->rejected($request, $adapter, 'start_workflow', $idempotencyKey, 'unknown_target', [
124+
'message' => $exception->getMessage(),
125+
'target' => $this->redactedTarget($target),
126+
'correlation' => $correlation,
127+
]);
128+
}
129+
130+
$routingBlock = $this->taskQueueRoutingGate->workflowStartBlock((string) $namespace, $taskQueue);
131+
132+
if ($routingBlock !== null) {
133+
return $this->rejected($request, $adapter, 'start_workflow', $idempotencyKey, 'task_queue_draining', array_filter([
134+
'message' => sprintf(
135+
'Task queue [%s] is draining and cannot accept new workflow starts until an active worker cohort is available.',
136+
$taskQueue,
137+
),
138+
'target' => $this->redactedTarget($target + ['workflow_id' => $workflowId]),
139+
'correlation' => $correlation,
140+
'workflow_id' => $workflowId,
141+
'workflow_type' => $startTarget['workflow_type'],
142+
'task_queue' => $taskQueue,
143+
'routing_status' => $routingBlock['routing_status'],
144+
'active_worker_count' => $routingBlock['active_worker_count'],
145+
'draining_worker_count' => $routingBlock['draining_worker_count'],
146+
'stale_worker_count' => $routingBlock['stale_worker_count'],
147+
'draining_build_ids' => $routingBlock['draining_build_ids'],
148+
'drain_intent' => 'draining',
149+
], static fn (mixed $value): bool => $value !== null));
142150
}
143151

144152
try {
@@ -160,6 +168,7 @@ private function startWorkflow(
160168
'adapter' => $adapter,
161169
'action' => 'start_workflow',
162170
'idempotency_key' => $idempotencyKey,
171+
'task_queue' => $taskQueue,
163172
],
164173
),
165174
);

‎app/Http/Controllers/Api/WorkflowController.php‎

Lines changed: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -172,31 +172,36 @@ public function start(Request $request): JsonResponse
172172
], 409);
173173
}
174174

175-
$taskQueue = isset($validated['task_queue']) && is_string($validated['task_queue'])
176-
? trim($validated['task_queue'])
177-
: null;
175+
try {
176+
$taskQueue = $this->workflowStartService->resolveTaskQueue(
177+
$validated['workflow_type'],
178+
$validated['task_queue'] ?? null,
179+
);
180+
} catch (LogicException $exception) {
181+
throw ValidationException::withMessages([
182+
'workflow_type' => [$exception->getMessage()],
183+
]);
184+
}
178185

179-
if ($taskQueue !== null && $taskQueue !== '') {
180-
$routingBlock = $this->taskQueueRoutingGate->workflowStartBlock((string) $namespace, $taskQueue);
181-
182-
if ($routingBlock !== null) {
183-
return ControlPlaneProtocol::jsonForRequest($request, array_filter([
184-
'workflow_id' => $workflowId,
185-
'workflow_type' => $validated['workflow_type'],
186-
'task_queue' => $taskQueue,
187-
'message' => sprintf(
188-
'Task queue [%s] is draining and cannot accept new workflow starts until an active worker cohort is available.',
189-
$taskQueue,
190-
),
191-
'reason' => 'task_queue_draining',
192-
'routing_status' => $routingBlock['routing_status'],
193-
'active_worker_count' => $routingBlock['active_worker_count'],
194-
'draining_worker_count' => $routingBlock['draining_worker_count'],
195-
'stale_worker_count' => $routingBlock['stale_worker_count'],
196-
'draining_build_ids' => $routingBlock['draining_build_ids'],
197-
'drain_intent' => 'draining',
198-
], static fn (mixed $value): bool => $value !== null), 409);
199-
}
186+
$routingBlock = $this->taskQueueRoutingGate->workflowStartBlock((string) $namespace, $taskQueue);
187+
188+
if ($routingBlock !== null) {
189+
return ControlPlaneProtocol::jsonForRequest($request, array_filter([
190+
'workflow_id' => $workflowId,
191+
'workflow_type' => $validated['workflow_type'],
192+
'task_queue' => $taskQueue,
193+
'message' => sprintf(
194+
'Task queue [%s] is draining and cannot accept new workflow starts until an active worker cohort is available.',
195+
$taskQueue,
196+
),
197+
'reason' => 'task_queue_draining',
198+
'routing_status' => $routingBlock['routing_status'],
199+
'active_worker_count' => $routingBlock['active_worker_count'],
200+
'draining_worker_count' => $routingBlock['draining_worker_count'],
201+
'stale_worker_count' => $routingBlock['stale_worker_count'],
202+
'draining_build_ids' => $routingBlock['draining_build_ids'],
203+
'drain_intent' => 'draining',
204+
], static fn (mixed $value): bool => $value !== null), 409);
200205
}
201206

202207
try {
@@ -209,7 +214,7 @@ public function start(Request $request): JsonResponse
209214
commandName: 'start',
210215
metadata: array_filter([
211216
'workflow_type' => $validated['workflow_type'],
212-
'task_queue' => $validated['task_queue'] ?? null,
217+
'task_queue' => $taskQueue,
213218
'duplicate_policy' => $validated['duplicate_policy'] ?? null,
214219
], static fn (mixed $value): bool => $value !== null),
215220
),

‎app/Support/ConfiguredWorkflowTypeValidator.php‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,30 @@
77

88
final class ConfiguredWorkflowTypeValidator
99
{
10+
/**
11+
* @return class-string<Workflow>|null
12+
*/
13+
public function resolveWorkflowClass(string $workflowType): ?string
14+
{
15+
if (class_exists($workflowType) && is_subclass_of($workflowType, Workflow::class)) {
16+
return $workflowType;
17+
}
18+
19+
$configured = config('workflows.v2.types.workflows', []);
20+
21+
if (! is_array($configured) || ! array_key_exists($workflowType, $configured)) {
22+
return null;
23+
}
24+
25+
$workflowClass = $configured[$workflowType];
26+
27+
if (! is_string($workflowClass) || ! class_exists($workflowClass) || ! is_subclass_of($workflowClass, Workflow::class)) {
28+
return null;
29+
}
30+
31+
return $workflowClass;
32+
}
33+
1034
/**
1135
* @throws LogicException
1236
*/
@@ -25,7 +49,7 @@ public function validationMessage(string $workflowType): ?string
2549
return null;
2650
}
2751

28-
if (class_exists($workflowType) && is_subclass_of($workflowType, Workflow::class)) {
52+
if ($this->resolveWorkflowClass($workflowType) !== null) {
2953
return null;
3054
}
3155

‎app/Support/WorkflowStartService.php‎

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
use Workflow\V2\CommandContext;
88
use Workflow\V2\Contracts\WorkflowControlPlane;
99
use Workflow\V2\Support\PayloadEnvelopeResolver;
10+
use Workflow\V2\Support\RoutingResolver;
11+
use Workflow\WorkflowMetadata;
1012

1113
class WorkflowStartService
1214
{
@@ -38,9 +40,37 @@ public function start(
3840
$workflowId = isset($validated['workflow_id']) && is_string($validated['workflow_id'])
3941
? $validated['workflow_id']
4042
: null;
43+
$taskQueue = $this->resolveTaskQueue($workflowType, $validated['task_queue'] ?? null);
44+
45+
return $this->startRemoteWorkflow(
46+
$workflowType,
47+
$workflowId,
48+
$taskQueue,
49+
$validated,
50+
$namespace,
51+
$commandContext,
52+
);
53+
}
54+
55+
public function resolveTaskQueue(string $workflowType, mixed $requestedTaskQueue = null): string
56+
{
4157
$this->workflowTypes->assertLoadable($workflowType);
4258

43-
return $this->startRemoteWorkflow($workflowType, $workflowId, $validated, $namespace, $commandContext);
59+
if (is_string($requestedTaskQueue) && trim($requestedTaskQueue) !== '') {
60+
return trim($requestedTaskQueue);
61+
}
62+
63+
$workflowClass = $this->workflowTypes->resolveWorkflowClass($workflowType);
64+
65+
if ($workflowClass !== null) {
66+
$resolvedQueue = RoutingResolver::workflowQueue($workflowClass, new WorkflowMetadata([]));
67+
68+
if (is_string($resolvedQueue) && trim($resolvedQueue) !== '') {
69+
return trim($resolvedQueue);
70+
}
71+
}
72+
73+
return $this->defaultTaskQueue();
4474
}
4575

4676
/**
@@ -72,6 +102,7 @@ public function start(
72102
private function startRemoteWorkflow(
73103
string $workflowType,
74104
?string $workflowId,
105+
string $taskQueue,
75106
array $validated,
76107
?string $namespace = null,
77108
?CommandContext $commandContext = null,
@@ -92,9 +123,7 @@ private function startRemoteWorkflow(
92123
$result = $this->controlPlane->start($workflowType, $workflowId, array_filter([
93124
'arguments' => $arguments,
94125
'payload_codec' => $payloadCodec,
95-
'queue' => isset($validated['task_queue']) && is_string($validated['task_queue'])
96-
? $validated['task_queue']
97-
: null,
126+
'queue' => $taskQueue,
98127
'business_key' => isset($validated['business_key']) && is_string($validated['business_key'])
99128
? $validated['business_key']
100129
: null,
@@ -156,4 +185,19 @@ private function intValue(array $validated, string $key): ?int
156185

157186
return is_int($value) ? $value : null;
158187
}
188+
189+
private function defaultTaskQueue(): string
190+
{
191+
$connection = config('queue.default');
192+
193+
if (! is_string($connection) || trim($connection) === '') {
194+
return 'default';
195+
}
196+
197+
$queue = config('queue.connections.'.trim($connection).'.queue', 'default');
198+
199+
return is_string($queue) && trim($queue) !== ''
200+
? trim($queue)
201+
: 'default';
202+
}
159203
}

‎tests/Feature/BridgeAdapterControllerTest.php‎

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,71 @@ public function test_webhook_bridge_blocks_drained_task_queue_starts_without_an_
304304
$this->assertFalse(WorkflowRun::query()->exists());
305305
}
306306

307+
public function test_webhook_bridge_rejects_start_when_the_implicit_default_queue_is_draining(): void
308+
{
309+
Queue::fake();
310+
311+
config()->set('queue.default', 'redis');
312+
config()->set('queue.connections.redis.driver', 'redis');
313+
config()->set('queue.connections.redis.queue', 'default');
314+
315+
WorkerRegistration::query()->create([
316+
'worker_id' => 'worker-default-draining',
317+
'namespace' => 'default',
318+
'task_queue' => 'default',
319+
'runtime' => 'php',
320+
'sdk_version' => '1.0.0',
321+
'build_id' => 'build-default-draining',
322+
'supported_workflow_types' => [],
323+
'workflow_definition_fingerprints' => [],
324+
'supported_activity_types' => [],
325+
'max_concurrent_workflow_tasks' => 100,
326+
'max_concurrent_activity_tasks' => 100,
327+
'last_heartbeat_at' => now(),
328+
'status' => 'draining',
329+
]);
330+
331+
WorkerBuildIdRollout::query()->create([
332+
'namespace' => 'default',
333+
'task_queue' => 'default',
334+
'build_id' => 'build-default-draining',
335+
'drain_intent' => 'draining',
336+
'drained_at' => now(),
337+
]);
338+
339+
$response = $this->withHeaders($this->apiHeaders())
340+
->postJson('/api/bridge-adapters/webhook/stripe', [
341+
'action' => 'start_workflow',
342+
'idempotency_key' => 'stripe-event-default-drain-1',
343+
'target' => [
344+
'workflow_id' => 'wf-bridge-drained-default-start',
345+
'workflow_type' => 'tests.interactive-command-workflow',
346+
],
347+
]);
348+
349+
$response->assertStatus(422)
350+
->assertJsonPath('adapter', 'stripe')
351+
->assertJsonPath('action', 'start_workflow')
352+
->assertJsonPath('accepted', false)
353+
->assertJsonPath('outcome', 'rejected')
354+
->assertJsonPath('reason', 'task_queue_draining')
355+
->assertJsonPath('workflow_id', 'wf-bridge-drained-default-start')
356+
->assertJsonPath('workflow_type', 'tests.interactive-command-workflow')
357+
->assertJsonPath('task_queue', 'default')
358+
->assertJsonPath('routing_status', 'draining')
359+
->assertJsonPath('active_worker_count', 0)
360+
->assertJsonPath('draining_worker_count', 1)
361+
->assertJsonPath('stale_worker_count', 0)
362+
->assertJsonPath('draining_build_ids.0', 'build-default-draining')
363+
->assertJsonPath('drain_intent', 'draining')
364+
->assertJsonPath(
365+
'message',
366+
'Task queue [default] is draining and cannot accept new workflow starts until an active worker cohort is available.',
367+
);
368+
369+
$this->assertFalse(WorkflowRun::query()->exists());
370+
}
371+
307372
public function test_webhook_bridge_surfaces_fail_closed_start_rejection_detail(): void
308373
{
309374
Queue::fake();
@@ -340,7 +405,7 @@ public function test_webhook_bridge_surfaces_fail_closed_start_rejection_detail(
340405
->assertJsonPath(
341406
'message',
342407
'Workflow instance [wf-bridge-compatibility-blocked] cannot start. Start blocked under fail validation mode. '
343-
.'No active worker heartbeat advertises compatibility [build-a]. '
408+
.'No active worker heartbeat for queue [default] advertises compatibility [build-a]. '
344409
.'Active workers there advertise [build-b].',
345410
);
346411

0 commit comments

Comments
 (0)