Skip to content

Commit dd4ccfd

Browse files
Scope Waterline health snapshots to the configured namespace
Keep the Waterline health endpoint compatible while adopting namespace-scoped workflow health snapshots. Add controller-level regression coverage for namespace-scoped health metrics.
1 parent 2b0b297 commit dd4ccfd

2 files changed

Lines changed: 144 additions & 1 deletion

File tree

app/Http/Controllers/V2HealthController.php

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
use Waterline\Support\WorkflowEngineSourceResolver;
66
use Workflow\V2\Support\HealthCheck;
7+
use Workflow\V2\Support\OperatorMetrics;
8+
use Workflow\V2\Support\StructuralLimits;
79

810
class V2HealthController extends Controller
911
{
@@ -35,7 +37,7 @@ public function show()
3537
], 503);
3638
}
3739

38-
$snapshot = HealthCheck::snapshot();
40+
$snapshot = $this->snapshotForConfiguredNamespace();
3941
array_unshift($snapshot['checks'], [
4042
'name' => 'engine_source',
4143
'status' => 'ok',
@@ -53,4 +55,59 @@ public function show()
5355

5456
return response()->json($snapshot, HealthCheck::httpStatus($snapshot));
5557
}
58+
59+
/**
60+
* Keep Waterline compatible with both the released alpha package and the
61+
* newer workflow branch while health snapshots gain namespace scoping.
62+
*
63+
* @return array<string, mixed>
64+
*/
65+
private function snapshotForConfiguredNamespace(): array
66+
{
67+
$namespace = config('waterline.namespace');
68+
$now = now();
69+
70+
if ((new \ReflectionMethod(HealthCheck::class, 'snapshot'))->getNumberOfParameters() >= 2) {
71+
return HealthCheck::snapshot($now, $namespace);
72+
}
73+
74+
$metrics = OperatorMetrics::snapshot($now, $namespace);
75+
$checks = [
76+
self::invokeLegacyHealthCheck('backendCheck', $metrics['backend'] ?? []),
77+
self::invokeLegacyHealthCheck('runSummaryProjectionCheck', $metrics['projections']['run_summaries'] ?? []),
78+
self::invokeLegacyHealthCheck('selectedRunProjectionCheck', $metrics['projections'] ?? []),
79+
self::invokeLegacyHealthCheck('historyRetentionInvariantCheck', $metrics['history'] ?? []),
80+
self::invokeLegacyHealthCheck('commandContractCheck', $metrics['command_contracts'] ?? []),
81+
self::invokeLegacyHealthCheck('taskTransportCheck', $metrics['tasks'] ?? [], $metrics['backlog'] ?? []),
82+
self::invokeLegacyHealthCheck(
83+
'durableResumePathCheck',
84+
$metrics['backlog'] ?? [],
85+
$metrics['repair'] ?? [],
86+
$metrics['runs'] ?? [],
87+
),
88+
self::invokeLegacyHealthCheck('workerCompatibilityCheck', $metrics['workers'] ?? []),
89+
self::invokeLegacyHealthCheck('schedulerRoleCheck', $metrics['schedules'] ?? []),
90+
self::invokeLegacyHealthCheck('longPollWakeAccelerationCheck'),
91+
];
92+
$status = self::invokeLegacyHealthCheck('status', $checks);
93+
94+
return [
95+
'generated_at' => $metrics['generated_at'] ?? $now->toJSON(),
96+
'status' => $status,
97+
'healthy' => $status !== 'error',
98+
'checks' => $checks,
99+
'categories' => self::invokeLegacyHealthCheck('categorySummary', $checks),
100+
'operator_metrics' => $metrics,
101+
'structural_limits' => StructuralLimits::snapshot(),
102+
];
103+
}
104+
105+
private static function invokeLegacyHealthCheck(string $method, mixed ...$args): mixed
106+
{
107+
return \Closure::bind(
108+
static fn (string $method, array $args): mixed => HealthCheck::$method(...$args),
109+
null,
110+
HealthCheck::class,
111+
)($method, $args);
112+
}
56113
}

tests/Feature/V2HealthControllerTest.php

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,19 @@
22

33
namespace Waterline\Tests\Feature;
44

5+
use Illuminate\Support\Carbon;
6+
use Illuminate\Support\Str;
57
use Waterline\Tests\TestCase;
68
use Waterline\Tests\Fixtures\V2\TestCommandContractWorkflow;
79
use Workflow\V2\Enums\HistoryEventType;
10+
use Workflow\V2\Enums\TaskStatus;
11+
use Workflow\V2\Enums\TaskType;
812
use Workflow\V2\Models\WorkflowInstance;
913
use Workflow\V2\Models\WorkflowHistoryEvent;
1014
use Workflow\V2\Models\WorkflowRun;
1115
use Workflow\V2\Models\WorkflowRunSummary;
16+
use Workflow\V2\Models\WorkflowTask;
17+
use Workflow\V2\Support\RunSummaryProjector;
1218
use Workflow\V2\Support\WorkerCompatibilityFleet;
1319

1420
class V2HealthControllerTest extends TestCase
@@ -40,6 +46,29 @@ public function testHealthEndpointReturnsV2HealthSnapshot(): void
4046
->assertJsonPath('operator_metrics.backend.supported', true);
4147
}
4248

49+
public function testHealthEndpointScopesSnapshotToConfiguredNamespace(): void
50+
{
51+
config()->set('queue.default', 'redis');
52+
config()->set('queue.connections.redis.driver', 'redis');
53+
config()->set('cache.default', 'file');
54+
config()->set('waterline.namespace', 'billing');
55+
56+
Carbon::setTestNow('2026-04-09 12:00:00');
57+
$this->beforeApplicationDestroyed(static function (): void {
58+
Carbon::setTestNow();
59+
});
60+
61+
$this->createRunSummaryWithReadyTask(namespace: 'billing', availableSecondsAgo: 1);
62+
$this->createRunSummaryWithReadyTask(namespace: 'shipping', availableSecondsAgo: 10);
63+
64+
$this->get('/waterline/api/v2/health')
65+
->assertStatus(200)
66+
->assertJsonPath('status', 'ok')
67+
->assertJsonPath('operator_metrics.runs.total', 1)
68+
->assertJsonPath('operator_metrics.tasks.ready_due', 1)
69+
->assertJsonPath('operator_metrics.tasks.oldest_ready_due_at', now()->subSecond()->toJSON());
70+
}
71+
4372
public function testHealthEndpointCategorizesEveryCheckAndExposesWakeAcceleration(): void
4473
{
4574
config()->set('queue.default', 'redis');
@@ -306,4 +335,61 @@ public function testHealthEndpointWarnsForCommandContractSnapshotsNeedingBackfil
306335
->assertJsonPath('operator_metrics.command_contracts.backfill_available_runs', 1)
307336
->assertJsonPath('operator_metrics.command_contracts.backfill_unavailable_runs', 1);
308337
}
338+
339+
private function createRunSummaryWithReadyTask(string $namespace, int $availableSecondsAgo): void
340+
{
341+
$instanceId = 'waterline-health-'.Str::lower(Str::random(12));
342+
$runId = (string) Str::ulid();
343+
$workflowType = sprintf('workflow.health.%s', $namespace);
344+
345+
$instance = WorkflowInstance::create([
346+
'id' => $instanceId,
347+
'namespace' => $namespace,
348+
'workflow_class' => 'WorkflowClass',
349+
'workflow_type' => $workflowType,
350+
'run_count' => 1,
351+
]);
352+
353+
$run = WorkflowRun::create([
354+
'id' => $runId,
355+
'workflow_instance_id' => $instance->id,
356+
'run_number' => 1,
357+
'workflow_class' => 'WorkflowClass',
358+
'workflow_type' => $workflowType,
359+
'status' => 'running',
360+
'namespace' => $namespace,
361+
'started_at' => now()->subMinutes(10),
362+
'last_progress_at' => now()->subMinute(),
363+
]);
364+
365+
$instance->update(['current_run_id' => $run->id]);
366+
367+
WorkflowRunSummary::create([
368+
'id' => $run->id,
369+
'workflow_instance_id' => $instance->id,
370+
'run_number' => 1,
371+
'is_current_run' => true,
372+
'engine_source' => 'v2',
373+
'class' => 'WorkflowClass',
374+
'workflow_type' => $workflowType,
375+
'status' => 'running',
376+
'status_bucket' => 'running',
377+
'namespace' => $namespace,
378+
'started_at' => now()->subMinutes(10),
379+
'liveness_state' => 'running',
380+
'projection_schema_version' => RunSummaryProjector::SCHEMA_VERSION,
381+
'created_at' => now()->subMinutes(10),
382+
'updated_at' => now(),
383+
]);
384+
385+
WorkflowTask::create([
386+
'id' => (string) Str::ulid(),
387+
'workflow_run_id' => $run->id,
388+
'namespace' => $namespace,
389+
'task_type' => TaskType::Workflow->value,
390+
'status' => TaskStatus::Ready->value,
391+
'queue' => 'default',
392+
'available_at' => now()->subSeconds($availableSecondsAgo),
393+
]);
394+
}
309395
}

0 commit comments

Comments
 (0)