Skip to content

Commit c953e8d

Browse files
Route rebuild projections through the history role binding
Route rebuild projections through the history role binding
1 parent 9a2cfb0 commit c953e8d

4 files changed

Lines changed: 124 additions & 7 deletions

File tree

docs/architecture/control-plane-split.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -486,10 +486,11 @@ each step independently.
486486
`workflow:v2:repair-pass`. The history/projection role now
487487
crosses the matching seam through `DefaultHistoryProjectionRole`,
488488
so a future out-of-process adapter can replace that binding
489-
without patching the claim paths. The scheduler role now crosses
490-
`workflow:v2:schedule-tick` through `DefaultSchedulerRole`, so a
491-
future out-of-process adapter can replace that binding without
492-
patching the command entrypoint.
489+
without patching the claim paths or the
490+
`workflow:v2:rebuild-projections` maintenance command. The
491+
scheduler role now crosses `workflow:v2:schedule-tick` through
492+
`DefaultSchedulerRole`, so a future out-of-process adapter can
493+
replace that binding without patching the command entrypoint.
493494
3. **Introduce the dedicated matching shape.** The Phase 3
494495
contract already allows a dedicated matching role; Phase 4
495496
provides the deployment guidance for running it as a separate

src/Commands/V2RebuildProjectionsCommand.php

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use JsonException;
99
use Symfony\Component\Console\Attribute\AsCommand;
1010
use Throwable;
11+
use Workflow\V2\Contracts\HistoryProjectionRole;
1112
use Workflow\V2\Models\WorkflowHistoryEvent;
1213
use Workflow\V2\Models\WorkflowRun;
1314
use Workflow\V2\Models\WorkflowRunLineageEntry;
@@ -16,7 +17,6 @@
1617
use Workflow\V2\Models\WorkflowRunWait;
1718
use Workflow\V2\Models\WorkflowTimelineEntry;
1819
use Workflow\V2\Support\RunSummaryProjectionDrift;
19-
use Workflow\V2\Support\RunSummaryProjector;
2020
use Workflow\V2\Support\SelectedRunProjectionDrift;
2121

2222
#[AsCommand(name: 'workflow:v2:rebuild-projections')]
@@ -33,6 +33,12 @@ class V2RebuildProjectionsCommand extends Command
3333

3434
protected $description = 'Rebuild Workflow v2 projection rows from durable runtime state';
3535

36+
public function __construct(
37+
private readonly HistoryProjectionRole $historyProjectionRole,
38+
) {
39+
parent::__construct();
40+
}
41+
3642
public function handle(): int
3743
{
3844
$runIds = $this->runIds();
@@ -63,14 +69,16 @@ public function handle(): int
6369
'failures' => [],
6470
];
6571

66-
$runQuery->chunkById(100, static function ($runs) use (&$report, $dryRun): void {
72+
$historyProjectionRole = $this->historyProjectionRole;
73+
74+
$runQuery->chunkById(100, static function ($runs) use (&$report, $dryRun, $historyProjectionRole): void {
6775
foreach ($runs as $run) {
6876
try {
6977
if ($dryRun) {
7078
continue;
7179
}
7280

73-
RunSummaryProjector::project($run);
81+
$historyProjectionRole->projectRun($run);
7482
$report['run_summaries_rebuilt']++;
7583
} catch (Throwable $exception) {
7684
$report['failures'][] = [

tests/Unit/Commands/V2RebuildProjectionsCommandTest.php

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
use Illuminate\Support\Facades\Schema;
1010
use Illuminate\Support\Str;
1111
use Tests\TestCase;
12+
use Workflow\V2\Contracts\HistoryProjectionRole;
1213
use Workflow\V2\Enums\HistoryEventType;
1314
use Workflow\V2\Enums\RunStatus;
15+
use Workflow\V2\Models\ActivityAttempt;
1416
use Workflow\V2\Models\ActivityExecution;
1517
use Workflow\V2\Models\WorkflowHistoryEvent;
1618
use Workflow\V2\Models\WorkflowInstance;
@@ -19,12 +21,96 @@
1921
use Workflow\V2\Models\WorkflowRunSummary;
2022
use Workflow\V2\Models\WorkflowRunTimerEntry;
2123
use Workflow\V2\Models\WorkflowRunWait;
24+
use Workflow\V2\Models\WorkflowTask;
2225
use Workflow\V2\Models\WorkflowTimelineEntry;
2326
use Workflow\V2\Models\WorkflowTimer;
27+
use Workflow\V2\Support\DefaultHistoryProjectionRole;
2428
use Workflow\V2\Support\RunSummaryProjector;
2529

2630
final class V2RebuildProjectionsCommandTest extends TestCase
2731
{
32+
public function testItUsesTheHistoryProjectionRoleBindingForRebuilds(): void
33+
{
34+
[, $run] = $this->createCompletedRun('projection-command-history-role');
35+
36+
$customRole = new class(new DefaultHistoryProjectionRole()) implements HistoryProjectionRole {
37+
/**
38+
* @var list<string>
39+
*/
40+
public array $projectedRunIds = [];
41+
42+
public function __construct(
43+
private readonly DefaultHistoryProjectionRole $delegate,
44+
) {
45+
}
46+
47+
public function projectRun(WorkflowRun $run): WorkflowRunSummary
48+
{
49+
$this->projectedRunIds[] = $run->id;
50+
51+
return $this->delegate->projectRun($run);
52+
}
53+
54+
public function recordActivityStarted(
55+
WorkflowRun $run,
56+
ActivityExecution $execution,
57+
ActivityAttempt $attempt,
58+
WorkflowTask $task,
59+
): WorkflowRunSummary {
60+
return $this->delegate->recordActivityStarted($run, $execution, $attempt, $task);
61+
}
62+
};
63+
64+
$this->app->instance(HistoryProjectionRole::class, $customRole);
65+
66+
$this->artisan('workflow:v2:rebuild-projections', [
67+
'--run-id' => [$run->id],
68+
])
69+
->expectsOutput('Rebuilt 1 run-summary projection row(s).')
70+
->assertSuccessful();
71+
72+
$this->assertSame([$run->id], $customRole->projectedRunIds);
73+
$this->assertDatabaseHas('workflow_run_summaries', [
74+
'id' => $run->id,
75+
'workflow_instance_id' => $run->workflow_instance_id,
76+
'status' => RunStatus::Completed->value,
77+
]);
78+
}
79+
80+
public function testItReportsHistoryProjectionRoleFailures(): void
81+
{
82+
[, $run] = $this->createCompletedRun('projection-command-history-role-failure');
83+
84+
$failingRole = new class() implements HistoryProjectionRole {
85+
public function projectRun(WorkflowRun $run): WorkflowRunSummary
86+
{
87+
throw new \RuntimeException('projection seam exploded');
88+
}
89+
90+
public function recordActivityStarted(
91+
WorkflowRun $run,
92+
ActivityExecution $execution,
93+
ActivityAttempt $attempt,
94+
WorkflowTask $task,
95+
): WorkflowRunSummary {
96+
throw new \RuntimeException('unused');
97+
}
98+
};
99+
100+
$this->app->instance(HistoryProjectionRole::class, $failingRole);
101+
102+
$this->artisan('workflow:v2:rebuild-projections', [
103+
'--run-id' => [$run->id],
104+
])
105+
->expectsOutput('Rebuilt 0 run-summary projection row(s).')
106+
->expectsOutput(sprintf('Failed to rebuild run [%s]: projection seam exploded', $run->id))
107+
->assertFailed();
108+
109+
$this->assertDatabaseMissing('workflow_run_summaries', [
110+
'id' => $run->id,
111+
]);
112+
}
113+
28114
public function testItRebuildsMissingRunSummariesAndPrunesStaleRows(): void
29115
{
30116
Carbon::setTestNow('2026-04-09 12:00:00');

tests/Unit/V2/ControlPlaneSplitDocumentationTest.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,12 @@ final class ControlPlaneSplitDocumentationTest extends TestCase
136136
'**API ingress down**',
137137
];
138138

139+
private const REQUIRED_ROLE_BOUND_COMMANDS = [
140+
'workflow:v2:repair-pass',
141+
'workflow:v2:rebuild-projections',
142+
'workflow:v2:schedule-tick',
143+
];
144+
139145
public function testContractDocumentExistsAndDeclaresFrozenSections(): void
140146
{
141147
$contents = $this->documentContents();
@@ -210,6 +216,22 @@ public function testContractDocumentNamesServerControllers(): void
210216
}
211217
}
212218

219+
public function testContractDocumentNamesRoleBoundMaintenanceCommands(): void
220+
{
221+
$contents = $this->documentContents();
222+
223+
foreach (self::REQUIRED_ROLE_BOUND_COMMANDS as $command) {
224+
$this->assertStringContainsString(
225+
$command,
226+
$contents,
227+
sprintf(
228+
'Control-plane split contract must name %s so the role-bound maintenance entrypoint stays explicit.',
229+
$command
230+
),
231+
);
232+
}
233+
}
234+
213235
public function testContractDocumentNamesAuthorityBoundarySurfaces(): void
214236
{
215237
$contents = $this->documentContents();

0 commit comments

Comments
 (0)