|
10 | 10 | use Tests\Fixtures\V2\TestGreetingWorkflow; |
11 | 11 | use Tests\Fixtures\V2\TestParentChildWorkflow; |
12 | 12 | use Tests\TestCase; |
| 13 | +use Workflow\V2\Contracts\WorkflowTaskBridge; |
13 | 14 | use Workflow\V2\Enums\RunStatus; |
14 | 15 | use Workflow\V2\Enums\TaskStatus; |
15 | 16 | use Workflow\V2\Enums\TaskType; |
@@ -1067,6 +1068,173 @@ public function testIncompatibleExpiredLeaseDoesNotSurfaceAsRepairNeeded(): void |
1067 | 1068 | ); |
1068 | 1069 | } |
1069 | 1070 |
|
| 1071 | + public function testRolloutLifecyclePreservesLongRunningRunsAcrossAdditiveRolloutDrainAndRollback(): void |
| 1072 | + { |
| 1073 | + config()->set('workflows.v2.compatibility.namespace', 'sample-app'); |
| 1074 | + |
| 1075 | + // Phase 0 — steady state. Fleet advertises only build-old. |
| 1076 | + config() |
| 1077 | + ->set('workflows.v2.compatibility.current', 'build-old'); |
| 1078 | + config() |
| 1079 | + ->set('workflows.v2.compatibility.supported', ['build-old']); |
| 1080 | + |
| 1081 | + Queue::fake(); |
| 1082 | + |
| 1083 | + $oldWorkflow = WorkflowStub::make(TestGreetingWorkflow::class, 'rollout-old-run'); |
| 1084 | + $oldWorkflow->start('Old'); |
| 1085 | + |
| 1086 | + /** @var WorkflowRun $oldRun */ |
| 1087 | + $oldRun = WorkflowRun::query() |
| 1088 | + ->where('workflow_instance_id', 'rollout-old-run') |
| 1089 | + ->sole(); |
| 1090 | + /** @var WorkflowTask $oldTask */ |
| 1091 | + $oldTask = WorkflowTask::query() |
| 1092 | + ->where('workflow_run_id', $oldRun->id) |
| 1093 | + ->where('task_type', TaskType::Workflow->value) |
| 1094 | + ->sole(); |
| 1095 | + |
| 1096 | + $this->assertSame('build-old', $oldRun->compatibility); |
| 1097 | + $this->assertSame('build-old', $oldTask->compatibility); |
| 1098 | + |
| 1099 | + // Phase 1 — additive rollout. Starter flips to build-new; fleet |
| 1100 | + // advertises both markers. The in-flight build-old run is unchanged |
| 1101 | + // and still routable; newly-started runs stamp build-new. |
| 1102 | + config() |
| 1103 | + ->set('workflows.v2.compatibility.current', 'build-new'); |
| 1104 | + config() |
| 1105 | + ->set('workflows.v2.compatibility.supported', ['build-old', 'build-new']); |
| 1106 | + |
| 1107 | + WorkerCompatibilityFleet::record(['build-old', 'build-new'], 'redis', 'default', 'worker-rollout'); |
| 1108 | + |
| 1109 | + $newWorkflow = WorkflowStub::make(TestGreetingWorkflow::class, 'rollout-new-run'); |
| 1110 | + $newWorkflow->start('New'); |
| 1111 | + |
| 1112 | + /** @var WorkflowRun $newRun */ |
| 1113 | + $newRun = WorkflowRun::query() |
| 1114 | + ->where('workflow_instance_id', 'rollout-new-run') |
| 1115 | + ->sole(); |
| 1116 | + |
| 1117 | + $this->assertSame('build-new', $newRun->compatibility); |
| 1118 | + $this->assertSame('build-old', $oldRun->fresh()->compatibility); |
| 1119 | + |
| 1120 | + $oldDetails = WorkerCompatibilityFleet::detailsForNamespace( |
| 1121 | + 'sample-app', |
| 1122 | + 'build-old', |
| 1123 | + 'redis', |
| 1124 | + 'default', |
| 1125 | + ); |
| 1126 | + $this->assertCount(1, $oldDetails); |
| 1127 | + $this->assertTrue($oldDetails[0]['supports_required']); |
| 1128 | + |
| 1129 | + $newDetails = WorkerCompatibilityFleet::detailsForNamespace( |
| 1130 | + 'sample-app', |
| 1131 | + 'build-new', |
| 1132 | + 'redis', |
| 1133 | + 'default', |
| 1134 | + ); |
| 1135 | + $this->assertCount(1, $newDetails); |
| 1136 | + $this->assertTrue($newDetails[0]['supports_required']); |
| 1137 | + |
| 1138 | + $this->assertSame(TaskStatus::Ready, $oldTask->fresh()?->status); |
| 1139 | + $bridge = app(WorkflowTaskBridge::class); |
| 1140 | + $rolloutClaim = $bridge->claimStatus($oldTask->id, 'worker-rollout'); |
| 1141 | + $this->assertTrue( |
| 1142 | + $rolloutClaim['claimed'], |
| 1143 | + 'Mixed-fleet worker advertising both markers must claim the old-marker task.', |
| 1144 | + ); |
| 1145 | + $this->assertSame('worker-rollout', $rolloutClaim['lease_owner']); |
| 1146 | + $this->assertSame('build-old', $rolloutClaim['compatibility']); |
| 1147 | + |
| 1148 | + $oldTask->refresh(); |
| 1149 | + $oldTask->forceFill([ |
| 1150 | + 'status' => TaskStatus::Ready, |
| 1151 | + 'leased_at' => null, |
| 1152 | + 'lease_owner' => null, |
| 1153 | + 'lease_expires_at' => null, |
| 1154 | + 'attempt_count' => 0, |
| 1155 | + 'last_claim_failed_at' => null, |
| 1156 | + 'last_claim_error' => null, |
| 1157 | + ])->save(); |
| 1158 | + |
| 1159 | + // Phase 2 — drain complete. The old fleet has been retired; only |
| 1160 | + // build-new workers heartbeat and only build-new is supported. The |
| 1161 | + // still-pinned build-old run is observable as "no compatible worker" |
| 1162 | + // via supports_required=false, and a claim attempt from a build-new-only |
| 1163 | + // worker leaves the task Ready without burning an attempt. |
| 1164 | + WorkerCompatibilityFleet::clear(); |
| 1165 | + config() |
| 1166 | + ->set('workflows.v2.compatibility.supported', ['build-new']); |
| 1167 | + WorkerCompatibilityFleet::record(['build-new'], 'redis', 'default', 'worker-new-only'); |
| 1168 | + |
| 1169 | + $drainedOldDetails = WorkerCompatibilityFleet::detailsForNamespace( |
| 1170 | + 'sample-app', |
| 1171 | + 'build-old', |
| 1172 | + 'redis', |
| 1173 | + 'default', |
| 1174 | + ); |
| 1175 | + $this->assertCount(1, $drainedOldDetails); |
| 1176 | + $this->assertFalse( |
| 1177 | + $drainedOldDetails[0]['supports_required'], |
| 1178 | + 'Removing the old marker from every live heartbeat must surface as supports_required=false.', |
| 1179 | + ); |
| 1180 | + |
| 1181 | + $drainClaim = $bridge->claimStatus($oldTask->id, 'worker-new-only'); |
| 1182 | + $this->assertFalse( |
| 1183 | + $drainClaim['claimed'], |
| 1184 | + 'A build-new-only worker must reject the build-old task at claim.', |
| 1185 | + ); |
| 1186 | + $this->assertSame('compatibility_blocked', $drainClaim['reason']); |
| 1187 | + $drainedTask = $oldTask->fresh(); |
| 1188 | + $this->assertSame(TaskStatus::Ready, $drainedTask?->status); |
| 1189 | + $this->assertNull($drainedTask?->leased_at); |
| 1190 | + |
| 1191 | + // Phase 3 — rollback. Re-advertise build-old on the live fleet and |
| 1192 | + // flip the starter back to build-old. The in-flight build-old task is |
| 1193 | + // routable again, and newly-started runs stamp build-old (the |
| 1194 | + // inverse of the rollout). No in-flight run is silently retargeted. |
| 1195 | + WorkerCompatibilityFleet::record(['build-old', 'build-new'], 'redis', 'default', 'worker-rolled-back'); |
| 1196 | + config() |
| 1197 | + ->set('workflows.v2.compatibility.current', 'build-old'); |
| 1198 | + config() |
| 1199 | + ->set('workflows.v2.compatibility.supported', ['build-old', 'build-new']); |
| 1200 | + |
| 1201 | + $rolledBackOldDetails = WorkerCompatibilityFleet::detailsForNamespace( |
| 1202 | + 'sample-app', |
| 1203 | + 'build-old', |
| 1204 | + 'redis', |
| 1205 | + 'default', |
| 1206 | + ); |
| 1207 | + $this->assertNotEmpty($rolledBackOldDetails); |
| 1208 | + $this->assertTrue( |
| 1209 | + collect($rolledBackOldDetails) |
| 1210 | + ->contains(static fn (array $snapshot): bool => $snapshot['supports_required'] === true), |
| 1211 | + 'A rollback that re-advertises the old marker must restore supports_required=true on the pinned run.', |
| 1212 | + ); |
| 1213 | + |
| 1214 | + $rollbackClaim = $bridge->claimStatus($oldTask->id, 'worker-rolled-back'); |
| 1215 | + $this->assertTrue( |
| 1216 | + $rollbackClaim['claimed'], |
| 1217 | + 'A rollback that re-advertises the old marker must let the pinned task be claimed again.', |
| 1218 | + ); |
| 1219 | + $this->assertSame('worker-rolled-back', $rollbackClaim['lease_owner']); |
| 1220 | + $rolledBackTask = $oldTask->fresh(); |
| 1221 | + $this->assertSame(TaskStatus::Leased, $rolledBackTask?->status); |
| 1222 | + |
| 1223 | + $postRollbackWorkflow = WorkflowStub::make(TestGreetingWorkflow::class, 'rollout-post-rollback-run'); |
| 1224 | + $postRollbackWorkflow->start('Rollback'); |
| 1225 | + |
| 1226 | + /** @var WorkflowRun $postRollbackRun */ |
| 1227 | + $postRollbackRun = WorkflowRun::query() |
| 1228 | + ->where('workflow_instance_id', 'rollout-post-rollback-run') |
| 1229 | + ->sole(); |
| 1230 | + $this->assertSame('build-old', $postRollbackRun->compatibility); |
| 1231 | + |
| 1232 | + // The build-new run started before the rollback keeps its original |
| 1233 | + // marker — compatibility stamping is once-per-run at Start and is |
| 1234 | + // not retroactively rewritten by a starter flip. |
| 1235 | + $this->assertSame('build-new', $newRun->fresh()->compatibility); |
| 1236 | + } |
| 1237 | + |
1070 | 1238 | private function drainReadyTasks(): void |
1071 | 1239 | { |
1072 | 1240 | $deadline = microtime(true) + 10; |
|
0 commit comments