Skip to content

Commit 5f2c22f

Browse files
Bind schedule ticks through a scheduler role seam
1 parent b2c0a9a commit 5f2c22f

8 files changed

Lines changed: 165 additions & 5 deletions

File tree

docs/architecture/control-plane-split.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,15 @@ Authority:
242242
`Workflow\V2\Support\ScheduleManager` when invoked by an
243243
authenticated operator.
244244

245+
Canonical implementation surface:
246+
247+
- `Workflow\V2\Contracts\SchedulerRole` and
248+
`Workflow\V2\Support\DefaultSchedulerRole` own the scheduler-role
249+
tick entrypoint exposed by `workflow:v2:schedule-tick`.
250+
- `Workflow\V2\Support\ScheduleManager` and
251+
`Workflow\V2\Contracts\ScheduleWorkflowStarter` remain the
252+
schedule-lifecycle and scheduled-start boundary inside that role.
253+
245254
Guarantees:
246255

247256
- The scheduler is the **only** role authorised to fire scheduled
@@ -467,16 +476,20 @@ each step independently.
467476
an out-of-process adapter can replace the binding without
468477
patching the package. Today's bindings are
469478
`WorkflowControlPlane`, `OperatorObservabilityRepository`,
470-
`MatchingRole`, `HistoryProjectionRole`, `WorkflowTaskBridge`,
471-
`ActivityTaskBridge`, `LongPollWakeStore`, and the scheduler's
479+
`MatchingRole`, `HistoryProjectionRole`, `SchedulerRole`,
480+
`WorkflowTaskBridge`, `ActivityTaskBridge`,
481+
`LongPollWakeStore`, and the scheduler's
472482
`ScheduleWorkflowStarter`. The matching role now crosses the
473483
queue-loop wake and dedicated daemon entrypoints through
474484
`DefaultMatchingRole`, so a future out-of-process adapter can
475485
replace that binding without patching `Looping` listeners or
476486
`workflow:v2:repair-pass`. The history/projection role now
477487
crosses the matching seam through `DefaultHistoryProjectionRole`,
478488
so a future out-of-process adapter can replace that binding
479-
without patching the claim paths.
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.
480493
3. **Introduce the dedicated matching shape.** The Phase 3
481494
contract already allows a dedicated matching role; Phase 4
482495
provides the deployment guidance for running it as a separate

src/Commands/V2ScheduleTickCommand.php

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
use Illuminate\Console\Command;
88
use JsonException;
99
use Symfony\Component\Console\Attribute\AsCommand;
10-
use Workflow\V2\Support\ScheduleManager;
10+
use Workflow\V2\Contracts\SchedulerRole;
1111

1212
#[AsCommand(name: 'workflow:v2:schedule-tick')]
1313
class V2ScheduleTickCommand extends Command
@@ -17,9 +17,15 @@ class V2ScheduleTickCommand extends Command
1717

1818
protected $description = 'Evaluate all due workflow v2 schedules and trigger matching workflows';
1919

20+
public function __construct(
21+
private readonly SchedulerRole $schedulerRole,
22+
) {
23+
parent::__construct();
24+
}
25+
2026
public function handle(): int
2127
{
22-
$results = ScheduleManager::tick();
28+
$results = $this->schedulerRole->tick();
2329

2430
if ((bool) $this->option('json')) {
2531
try {

src/Providers/WorkflowServiceProvider.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
use Workflow\V2\Contracts\LongPollWakeStore;
2525
use Workflow\V2\Contracts\MatchingRole;
2626
use Workflow\V2\Contracts\OperatorObservabilityRepository;
27+
use Workflow\V2\Contracts\SchedulerRole;
2728
use Workflow\V2\Contracts\ScheduleWorkflowStarter;
2829
use Workflow\V2\Contracts\WorkflowControlPlane;
2930
use Workflow\V2\Contracts\WorkflowTaskBridge;
@@ -41,6 +42,7 @@
4142
use Workflow\V2\Support\DefaultHistoryProjectionRole;
4243
use Workflow\V2\Support\DefaultMatchingRole;
4344
use Workflow\V2\Support\DefaultOperatorObservabilityRepository;
45+
use Workflow\V2\Support\DefaultSchedulerRole;
4446
use Workflow\V2\Support\DefaultWorkflowControlPlane;
4547
use Workflow\V2\Support\DefaultWorkflowTaskBridge;
4648
use Workflow\V2\Support\LongPollCacheValidator;
@@ -70,6 +72,8 @@ public function register(): void
7072

7173
$this->app->singleton(WorkflowControlPlane::class, DefaultWorkflowControlPlane::class);
7274

75+
$this->app->singletonIf(SchedulerRole::class, DefaultSchedulerRole::class);
76+
7377
$this->app->singleton(ScheduleWorkflowStarter::class, PhpClassScheduleStarter::class);
7478

7579
// Register default LongPollWakeStore implementation if not already bound

src/V2/Contracts/SchedulerRole.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Workflow\V2\Contracts;
6+
7+
/**
8+
* Binding seam for the scheduler role's due-schedule tick.
9+
*
10+
* Hosts that want to move schedule evaluation out of process can bind a
11+
* replacement implementation without patching the package command entrypoint.
12+
*/
13+
interface SchedulerRole
14+
{
15+
/**
16+
* @return list<array{
17+
* schedule_id: string,
18+
* instance_id: string|null,
19+
* outcome?: string,
20+
* error?: string
21+
* }>
22+
*/
23+
public function tick(int $limit = 100): array;
24+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Workflow\V2\Support;
6+
7+
use Workflow\V2\Contracts\SchedulerRole;
8+
9+
final class DefaultSchedulerRole implements SchedulerRole
10+
{
11+
public function tick(int $limit = 100): array
12+
{
13+
return ScheduleManager::tick($limit);
14+
}
15+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Unit\Commands;
6+
7+
use Tests\TestCase;
8+
use Workflow\V2\Contracts\SchedulerRole;
9+
10+
final class V2ScheduleTickCommandTest extends TestCase
11+
{
12+
public function testItUsesTheSchedulerRoleBindingForScheduleTicks(): void
13+
{
14+
$fake = new class() implements SchedulerRole {
15+
public ?int $lastTickLimit = null;
16+
17+
public function tick(int $limit = 100): array
18+
{
19+
$this->lastTickLimit = $limit;
20+
21+
return [[
22+
'schedule_id' => 'billing-report',
23+
'instance_id' => 'wf-billing-report',
24+
'outcome' => 'scheduled',
25+
]];
26+
}
27+
};
28+
29+
$this->app->instance(SchedulerRole::class, $fake);
30+
31+
$expected = [[
32+
'schedule_id' => 'billing-report',
33+
'instance_id' => 'wf-billing-report',
34+
'outcome' => 'scheduled',
35+
]];
36+
37+
$this->artisan('workflow:v2:schedule-tick', [
38+
'--json' => true,
39+
])
40+
->expectsOutput(json_encode($expected, JSON_UNESCAPED_SLASHES))
41+
->assertSuccessful();
42+
43+
$this->assertSame(100, $fake->lastTickLimit);
44+
}
45+
46+
public function testItReportsHumanScheduleTickOutputFromTheBoundRole(): void
47+
{
48+
$fake = new class() implements SchedulerRole {
49+
public function tick(int $limit = 100): array
50+
{
51+
return [[
52+
'schedule_id' => 'nightly-rebuild',
53+
'instance_id' => 'wf-nightly-rebuild',
54+
'outcome' => 'scheduled',
55+
]];
56+
}
57+
};
58+
59+
$this->app->instance(SchedulerRole::class, $fake);
60+
61+
$this->artisan('workflow:v2:schedule-tick')
62+
->expectsOutput('[nightly-rebuild] → wf-nightly-rebuild')
63+
->expectsOutput('Processed 1 schedule(s).')
64+
->assertSuccessful();
65+
}
66+
67+
public function testItReportsWhenNoSchedulesAreDue(): void
68+
{
69+
$fake = new class() implements SchedulerRole {
70+
public function tick(int $limit = 100): array
71+
{
72+
return [];
73+
}
74+
};
75+
76+
$this->app->instance(SchedulerRole::class, $fake);
77+
78+
$this->artisan('workflow:v2:schedule-tick')
79+
->expectsOutput('No schedules due.')
80+
->assertSuccessful();
81+
}
82+
}

tests/Unit/Providers/WorkflowServiceProviderTest.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use Workflow\V2\Contracts\HistoryProjectionRole;
2121
use Workflow\V2\Contracts\MatchingRole;
2222
use Workflow\V2\Contracts\OperatorObservabilityRepository;
23+
use Workflow\V2\Contracts\SchedulerRole;
2324
use Workflow\V2\Enums\RunStatus;
2425
use Workflow\V2\Enums\TaskStatus;
2526
use Workflow\V2\Enums\TaskType;
@@ -123,6 +124,18 @@ public function testMatchingRoleBindingDefersToAppBinding(): void
123124
$this->assertSame($custom, $this->app->make(MatchingRole::class));
124125
}
125126

127+
public function testSchedulerRoleBindingDefersToAppBinding(): void
128+
{
129+
$custom = $this->createMock(SchedulerRole::class);
130+
131+
$this->app->offsetUnset(SchedulerRole::class);
132+
$this->app->singleton(SchedulerRole::class, static fn () => $custom);
133+
134+
(new WorkflowServiceProvider($this->app))->register();
135+
136+
$this->assertSame($custom, $this->app->make(SchedulerRole::class));
137+
}
138+
126139
public function testProviderMergesV2DefaultsIntoLegacyPublishedConfig(): void
127140
{
128141
config()->set('workflows', [
@@ -226,6 +239,7 @@ public function testCommandsAreRegistered(): void
226239
'workflow:v2:history-export',
227240
'workflow:v2:repair-pass',
228241
'workflow:v2:rebuild-projections',
242+
'workflow:v2:schedule-tick',
229243
];
230244

231245
foreach ($expectedCommands as $command) {

tests/Unit/V2/ControlPlaneSplitDocumentationTest.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ final class ControlPlaneSplitDocumentationTest extends TestCase
7272
'OperatorObservabilityRepository',
7373
'OperatorMetrics',
7474
'OperatorQueueVisibility',
75+
'SchedulerRole',
76+
'DefaultSchedulerRole',
7577
'ScheduleManager',
7678
'ScheduleTriggerResult',
7779
'ScheduleStartResult',

0 commit comments

Comments
 (0)