Skip to content

Commit 0654f47

Browse files
Cover v2 compatibility rollout/rollback lifecycle end-to-end
The v2 worker-compatibility contract at docs/architecture/worker-compatibility.md defines a rollout / drain / rollback narrative that composes several existing engine primitives: starter-time stamping, in-flight run immutability, claim-time enforcement, and the supports_required fleet signal. Individual pieces were already pinned; the composite behavior — that a long-running run stays alive across an additive rollout, is observable as "no compatible worker" during a full drain, and becomes routable again after a rollback without retroactively rewriting any in-flight marker — was not. Add Tests\Feature\V2\V2CompatibilityWorkflowTest:: testRolloutLifecyclePreservesLongRunningRunsAcrossAdditiveRolloutDrainAndRollback, which walks the four phases against the real engine. Phase 0 starts a run while the starter process advertises build-old. Phase 1 flips the starter to build-new, records a fleet heartbeat supporting both markers, starts a second run (stamped build-new), confirms that supports_required=true holds for both markers on the fleet detail surface, and confirms that a mixed-fleet worker still claims the build-old task. Phase 2 retires build-old (only build-new heartbeats remain; only build-new is supported), confirms that supports_required=false surfaces for the pinned build-old task and that a claim from a build-new-only worker is rejected with compatibility_blocked without leasing the task. Phase 3 re-advertises build-old on the fleet and flips the starter back, confirms that the pinned task can be claimed again, confirms that a newly-started run stamps build-old, and confirms that the build-new run from Phase 1 is not retroactively rewritten to build-old. The test uses WorkflowTaskBridge::claimStatus() so claim outcomes are asserted without executing the underlying workflow, keeping the test focused on the rollout contract.
1 parent dc60117 commit 0654f47

1 file changed

Lines changed: 168 additions & 0 deletions

File tree

tests/Feature/V2/V2CompatibilityWorkflowTest.php

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use Tests\Fixtures\V2\TestGreetingWorkflow;
1111
use Tests\Fixtures\V2\TestParentChildWorkflow;
1212
use Tests\TestCase;
13+
use Workflow\V2\Contracts\WorkflowTaskBridge;
1314
use Workflow\V2\Enums\RunStatus;
1415
use Workflow\V2\Enums\TaskStatus;
1516
use Workflow\V2\Enums\TaskType;
@@ -1067,6 +1068,173 @@ public function testIncompatibleExpiredLeaseDoesNotSurfaceAsRepairNeeded(): void
10671068
);
10681069
}
10691070

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+
10701238
private function drainReadyTasks(): void
10711239
{
10721240
$deadline = microtime(true) + 10;

0 commit comments

Comments
 (0)