feat(Production): Complete production deployment infrastructure

- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
This commit is contained in:
2025-10-25 19:18:37 +02:00
parent caa85db796
commit fc3d7e6357
83016 changed files with 378904 additions and 20919 deletions

View File

@@ -17,7 +17,8 @@ final readonly class DeadLetterQueueCommands
{
public function __construct(
private DeadLetterManager $deadLetterManager
) {}
) {
}
#[ConsoleCommand(name: 'queue:failed', description: 'List failed jobs in dead letter queues')]
public function listFailedJobs(ConsoleInput $input, ConsoleOutput $output): int
@@ -35,6 +36,7 @@ final readonly class DeadLetterQueueCommands
if (empty($failedJobs)) {
$output->writeLine("✅ No failed jobs found");
return 0;
}
@@ -60,9 +62,10 @@ final readonly class DeadLetterQueueCommands
public function retryJob(ConsoleInput $input, ConsoleOutput $output): int
{
$jobId = $input->getArgument('job_id');
if (!$jobId) {
if (! $jobId) {
$output->writeLine("❌ Job ID is required");
$output->writeLine("Usage: queue:retry <job_id>");
return 1;
}
@@ -72,6 +75,7 @@ final readonly class DeadLetterQueueCommands
$output->writeLine("✅ Job {$jobId} successfully retried");
} else {
$output->writeLine("❌ Failed to retry job {$jobId}");
return 1;
}
@@ -82,9 +86,10 @@ final readonly class DeadLetterQueueCommands
public function retryAllJobs(ConsoleInput $input, ConsoleOutput $output): int
{
$queueName = $input->getArgument('queue_name');
if (!$queueName) {
if (! $queueName) {
$output->writeLine("❌ Queue name is required");
$output->writeLine("Usage: queue:retry-all <queue_name>");
return 1;
}
@@ -100,16 +105,18 @@ final readonly class DeadLetterQueueCommands
public function clearDeadLetterQueue(ConsoleInput $input, ConsoleOutput $output): int
{
$queueName = $input->getArgument('queue_name');
if (!$queueName) {
if (! $queueName) {
$output->writeLine("❌ Queue name is required");
$output->writeLine("Usage: queue:clear-failed <queue_name>");
return 1;
}
$confirm = $input->getOption('force', false);
if (!$confirm) {
if (! $confirm) {
$output->writeLine("⚠️ This will permanently delete all failed jobs in queue '{$queueName}'");
$output->writeLine("Use --force to confirm");
return 1;
}
@@ -131,6 +138,7 @@ final readonly class DeadLetterQueueCommands
if (empty($stats)) {
$output->writeLine("✅ No dead letter queues found");
return 0;
}
@@ -153,16 +161,18 @@ final readonly class DeadLetterQueueCommands
public function deleteJob(ConsoleInput $input, ConsoleOutput $output): int
{
$jobId = $input->getArgument('job_id');
if (!$jobId) {
if (! $jobId) {
$output->writeLine("❌ Job ID is required");
$output->writeLine("Usage: queue:delete-failed <job_id>");
return 1;
}
$confirm = $input->getOption('force', false);
if (!$confirm) {
if (! $confirm) {
$output->writeLine("⚠️ This will permanently delete the failed job {$jobId}");
$output->writeLine("Use --force to confirm");
return 1;
}
@@ -172,9 +182,10 @@ final readonly class DeadLetterQueueCommands
$output->writeLine("✅ Job {$jobId} successfully deleted");
} else {
$output->writeLine("❌ Failed to delete job {$jobId} (job not found)");
return 1;
}
return 0;
}
}
}

View File

@@ -4,10 +4,9 @@ declare(strict_types=1);
namespace App\Framework\Queue\Commands;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Attributes\ConsoleCommand as ConsoleCommandAttribute;
use App\Framework\Queue\Contracts\JobBatchManagerInterface;
use App\Framework\Queue\ValueObjects\JobBatchStatus;
use App\Framework\Attributes\ConsoleCommand as ConsoleCommandAttribute;
/**
* Console commands for job batch management
@@ -16,7 +15,8 @@ final readonly class JobBatchCommands
{
public function __construct(
private JobBatchManagerInterface $batchManager
) {}
) {
}
#[ConsoleCommandAttribute(name: 'batch:list', description: 'List job batches')]
public function listBatches(string $status = 'all', int $limit = 20): void
@@ -30,7 +30,7 @@ final readonly class JobBatchCommands
}
// Sort by created_at desc
usort($batches, fn($a, $b) => $b->createdAt?->getTimestamp() <=> $a->createdAt?->getTimestamp());
usort($batches, fn ($a, $b) => $b->createdAt?->getTimestamp() <=> $a->createdAt?->getTimestamp());
$batches = array_slice($batches, 0, $limit);
} else {
try {
@@ -38,13 +38,15 @@ final readonly class JobBatchCommands
$batches = $this->batchManager->getBatchesByStatus($batchStatus, $limit);
} catch (\ValueError) {
echo "❌ Invalid status: {$status}. Valid statuses: " .
implode(', ', array_map(fn($s) => $s->value, JobBatchStatus::cases())) . "\n";
implode(', ', array_map(fn ($s) => $s->value, JobBatchStatus::cases())) . "\n";
return;
}
}
if (empty($batches)) {
echo "No batches found" . ($status !== 'all' ? " with status: {$status}" : '') . "\n";
return;
}
@@ -82,8 +84,9 @@ final readonly class JobBatchCommands
{
$batch = $this->batchManager->getBatch($batchId);
if (!$batch) {
if (! $batch) {
echo "❌ Batch not found: {$batchId}\n";
return;
}
@@ -116,7 +119,7 @@ final readonly class JobBatchCommands
}
echo "\n";
if (!empty($batch->options)) {
if (! empty($batch->options)) {
echo "Options:\n";
foreach ($batch->options as $key => $value) {
echo " {$key}: " . (is_array($value) ? json_encode($value) : $value) . "\n";
@@ -135,13 +138,15 @@ final readonly class JobBatchCommands
{
$batch = $this->batchManager->getBatch($batchId);
if (!$batch) {
if (! $batch) {
echo "❌ Batch not found: {$batchId}\n";
return;
}
if ($batch->isFinished()) {
echo "❌ Cannot cancel batch: already finished with status {$batch->status->getDisplayName()}\n";
return;
}
@@ -161,6 +166,7 @@ final readonly class JobBatchCommands
if (empty($stats)) {
echo "No batch statistics available\n";
return;
}
@@ -207,6 +213,7 @@ final readonly class JobBatchCommands
{
if ($olderThanDays < 1) {
echo "❌ Days must be at least 1\n";
return;
}
@@ -228,6 +235,7 @@ final readonly class JobBatchCommands
if (empty($progress)) {
echo "❌ Batch not found: {$batchId}\n";
return;
}
@@ -256,4 +264,4 @@ final readonly class JobBatchCommands
echo "✅ Batch completed\n";
}
}
}
}

View File

@@ -7,9 +7,9 @@ namespace App\Framework\Queue\Commands;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ExitCode;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Performance\MemoryMonitor;
use App\Framework\Queue\Services\JobCleanupService;
use App\Framework\Queue\Services\JobMemoryManager;
use App\Framework\Performance\MemoryMonitor;
final readonly class JobCleanupCommands
{
@@ -17,7 +17,8 @@ final readonly class JobCleanupCommands
private JobCleanupService $cleanupService,
private JobMemoryManager $memoryManager,
private MemoryMonitor $memoryMonitor
) {}
) {
}
#[ConsoleCommand(name: 'queue:cleanup:all', description: 'Run comprehensive queue cleanup with default retention periods')]
public function runComprehensiveCleanup(): ExitCode
@@ -46,6 +47,7 @@ final readonly class JobCleanupCommands
if ($stats['total_eligible'] === 0) {
echo "✅ No data eligible for cleanup\n";
return ExitCode::SUCCESS;
}
@@ -67,19 +69,22 @@ final readonly class JobCleanupCommands
echo " • Memory used: {$results['memory_used']}\n";
echo " • Peak memory: {$results['memory_peak']}\n\n";
if (!empty($results['errors'])) {
if (! empty($results['errors'])) {
echo "⚠️ Errors occurred:\n";
foreach ($results['errors'] as $error) {
echo "{$error}\n";
}
return ExitCode::GENERAL_ERROR;
}
echo "✅ Cleanup completed successfully!\n";
return ExitCode::SUCCESS;
} catch (\Exception $e) {
echo "❌ Error: {$e->getMessage()}\n";
return ExitCode::GENERAL_ERROR;
}
}
@@ -92,6 +97,7 @@ final readonly class JobCleanupCommands
try {
if ($days < 1) {
echo "❌ Days must be at least 1\n";
return ExitCode::INVALID_ARGUMENT;
}
@@ -108,6 +114,7 @@ final readonly class JobCleanupCommands
} catch (\Exception $e) {
echo "❌ Error: {$e->getMessage()}\n";
return ExitCode::GENERAL_ERROR;
}
}
@@ -120,6 +127,7 @@ final readonly class JobCleanupCommands
try {
if ($days < 1) {
echo "❌ Days must be at least 1\n";
return ExitCode::INVALID_ARGUMENT;
}
@@ -136,6 +144,7 @@ final readonly class JobCleanupCommands
} catch (\Exception $e) {
echo "❌ Error: {$e->getMessage()}\n";
return ExitCode::GENERAL_ERROR;
}
}
@@ -148,6 +157,7 @@ final readonly class JobCleanupCommands
try {
if ($days < 1) {
echo "❌ Days must be at least 1\n";
return ExitCode::INVALID_ARGUMENT;
}
@@ -164,6 +174,7 @@ final readonly class JobCleanupCommands
} catch (\Exception $e) {
echo "❌ Error: {$e->getMessage()}\n";
return ExitCode::GENERAL_ERROR;
}
}
@@ -176,6 +187,7 @@ final readonly class JobCleanupCommands
try {
if ($days < 1) {
echo "❌ Days must be at least 1\n";
return ExitCode::INVALID_ARGUMENT;
}
@@ -192,6 +204,7 @@ final readonly class JobCleanupCommands
} catch (\Exception $e) {
echo "❌ Error: {$e->getMessage()}\n";
return ExitCode::GENERAL_ERROR;
}
}
@@ -229,7 +242,7 @@ final readonly class JobCleanupCommands
foreach ($recommendations['recommendations'] as $rec) {
echo "\n 📌 {$rec['type']}:\n";
echo " {$rec['message']}\n";
if (!empty($rec['actions'])) {
if (! empty($rec['actions'])) {
echo " Actions:\n";
foreach ($rec['actions'] as $action) {
echo "{$action}\n";
@@ -241,6 +254,7 @@ final readonly class JobCleanupCommands
} catch (\Exception $e) {
echo "❌ Error: {$e->getMessage()}\n";
return ExitCode::GENERAL_ERROR;
}
}
@@ -277,7 +291,7 @@ final readonly class JobCleanupCommands
foreach ($recommendations['recommendations'] as $rec) {
echo "\n {$rec['type']}:\n";
echo " {$rec['message']}\n";
if (!empty($rec['actions'])) {
if (! empty($rec['actions'])) {
echo " Suggested actions:\n";
foreach ($rec['actions'] as $action) {
echo "{$action}\n";
@@ -289,6 +303,7 @@ final readonly class JobCleanupCommands
} catch (\Exception $e) {
echo "❌ Error: {$e->getMessage()}\n";
return ExitCode::GENERAL_ERROR;
}
}
@@ -322,7 +337,8 @@ final readonly class JobCleanupCommands
} catch (\Exception $e) {
echo "❌ Error: {$e->getMessage()}\n";
return ExitCode::GENERAL_ERROR;
}
}
}
}

View File

@@ -12,7 +12,8 @@ final readonly class JobMetricsCommands
{
public function __construct(
private JobMetricsManagerInterface $metricsManager
) {}
) {
}
#[ConsoleCommand(name: 'queue:metrics:overview', description: 'Show system-wide queue metrics overview')]
public function systemOverview(): ExitCode
@@ -47,7 +48,7 @@ final readonly class JobMetricsCommands
foreach ($overview['queue_metrics'] as $queueName => $metrics) {
$healthIcon = $metrics->isHealthy() ? '✅' : '⚠️';
$bottlenecks = $metrics->getBottleneckIndicators();
$bottleneckText = !empty($bottlenecks) ? ' (' . implode(', ', $bottlenecks) . ')' : '';
$bottleneckText = ! empty($bottlenecks) ? ' (' . implode(', ', $bottlenecks) . ')' : '';
echo " {$healthIcon} {$queueName}: {$metrics->totalJobs} jobs, {$metrics->successRate->getValue()}% success{$bottleneckText}\n";
}
@@ -55,6 +56,7 @@ final readonly class JobMetricsCommands
return ExitCode::SUCCESS;
} catch (\Exception $e) {
echo "❌ Error: {$e->getMessage()}\n";
return ExitCode::GENERAL_ERROR;
}
}
@@ -93,7 +95,7 @@ final readonly class JobMetricsCommands
// Bottlenecks
$bottlenecks = $metrics->getBottleneckIndicators();
if (!empty($bottlenecks)) {
if (! empty($bottlenecks)) {
echo "⚠️ Bottleneck Indicators:\n";
foreach ($bottlenecks as $bottleneck) {
echo " - " . str_replace('_', ' ', ucfirst($bottleneck)) . "\n";
@@ -106,6 +108,7 @@ final readonly class JobMetricsCommands
return ExitCode::SUCCESS;
} catch (\Exception $e) {
echo "❌ Error: {$e->getMessage()}\n";
return ExitCode::GENERAL_ERROR;
}
}
@@ -116,8 +119,9 @@ final readonly class JobMetricsCommands
try {
$metrics = $this->metricsManager->getJobMetrics($jobId);
if (!$metrics) {
if (! $metrics) {
echo "❌ No metrics found for job: {$jobId}\n";
return ExitCode::GENERAL_ERROR;
}
@@ -160,7 +164,7 @@ final readonly class JobMetricsCommands
}
// Metadata
if (!empty($metrics->metadata)) {
if (! empty($metrics->metadata)) {
echo "\n📝 Metadata:\n";
foreach ($metrics->metadata as $key => $value) {
echo " {$key}: " . json_encode($value) . "\n";
@@ -170,6 +174,7 @@ final readonly class JobMetricsCommands
return ExitCode::SUCCESS;
} catch (\Exception $e) {
echo "❌ Error: {$e->getMessage()}\n";
return ExitCode::GENERAL_ERROR;
}
}
@@ -185,6 +190,7 @@ final readonly class JobMetricsCommands
if (empty($slowJobs)) {
echo " No jobs found\n";
return ExitCode::SUCCESS;
}
@@ -202,6 +208,7 @@ final readonly class JobMetricsCommands
return ExitCode::SUCCESS;
} catch (\Exception $e) {
echo "❌ Error: {$e->getMessage()}\n";
return ExitCode::GENERAL_ERROR;
}
}
@@ -217,6 +224,7 @@ final readonly class JobMetricsCommands
if (empty($memoryJobs)) {
echo " No jobs found\n";
return ExitCode::SUCCESS;
}
@@ -234,6 +242,7 @@ final readonly class JobMetricsCommands
return ExitCode::SUCCESS;
} catch (\Exception $e) {
echo "❌ Error: {$e->getMessage()}\n";
return ExitCode::GENERAL_ERROR;
}
}
@@ -249,6 +258,7 @@ final readonly class JobMetricsCommands
if (empty($failedJobs)) {
echo " No failed jobs found\n";
return ExitCode::SUCCESS;
}
@@ -265,6 +275,7 @@ final readonly class JobMetricsCommands
return ExitCode::SUCCESS;
} catch (\Exception $e) {
echo "❌ Error: {$e->getMessage()}\n";
return ExitCode::GENERAL_ERROR;
}
}
@@ -301,6 +312,7 @@ final readonly class JobMetricsCommands
return ExitCode::SUCCESS;
} catch (\Exception $e) {
echo "❌ Error: {$e->getMessage()}\n";
return ExitCode::GENERAL_ERROR;
}
}
@@ -333,6 +345,7 @@ final readonly class JobMetricsCommands
return ExitCode::SUCCESS;
} catch (\Exception $e) {
echo "❌ Error: {$e->getMessage()}\n";
return ExitCode::GENERAL_ERROR;
}
}
@@ -355,6 +368,7 @@ final readonly class JobMetricsCommands
return ExitCode::SUCCESS;
} catch (\Exception $e) {
echo "❌ Error: {$e->getMessage()}\n";
return ExitCode::GENERAL_ERROR;
}
}
@@ -370,4 +384,4 @@ final readonly class JobMetricsCommands
default => '❓'
};
}
}
}

View File

@@ -6,8 +6,8 @@ namespace App\Framework\Queue\Commands;
use App\Framework\Attributes\ConsoleCommand;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Queue\Services\ProgressManager;
use App\Framework\Queue\Entities\JobProgressEntry;
use App\Framework\Queue\Services\ProgressManager;
/**
* Console commands for managing job progress
@@ -16,15 +16,17 @@ final readonly class ProgressCommands
{
public function __construct(
private ProgressManager $progressManager
) {}
) {
}
#[ConsoleCommand(name: 'queue:progress:show', description: 'Show current progress for a job')]
public function showProgress(string $jobId, ConsoleOutput $output): void
{
$progress = $this->progressManager->getJobProgress($jobId);
if (!$progress) {
if (! $progress) {
$output->writeln("<error>No progress found for job: {$jobId}</error>");
return;
}
@@ -50,6 +52,7 @@ final readonly class ProgressCommands
if (empty($history)) {
$output->writeln("<error>No progress history found for job: {$jobId}</error>");
return;
}
@@ -67,9 +70,13 @@ final readonly class ProgressCommands
}
$status = [];
if ($entry->isCompleted) $status[] = 'Completed';
if ($entry->isFailed) $status[] = 'Failed';
if (!empty($status)) {
if ($entry->isCompleted) {
$status[] = 'Completed';
}
if ($entry->isFailed) {
$status[] = 'Failed';
}
if (! empty($status)) {
$output->writeln("Status: " . implode(', ', $status));
}
@@ -84,6 +91,7 @@ final readonly class ProgressCommands
if (empty($recentJobs)) {
$output->writeln("<info>No recent job progress updates found</info>");
return;
}
@@ -102,9 +110,13 @@ final readonly class ProgressCommands
}
$status = [];
if ($entry->isCompleted) $status[] = 'Completed';
if ($entry->isFailed) $status[] = 'Failed';
if (!empty($status)) {
if ($entry->isCompleted) {
$status[] = 'Completed';
}
if ($entry->isFailed) {
$status[] = 'Failed';
}
if (! empty($status)) {
$output->writeln("Status: " . implode(', ', $status));
}
@@ -117,6 +129,7 @@ final readonly class ProgressCommands
{
if ($percentage < 0 || $percentage > 100) {
$output->writeln("<error>Percentage must be between 0 and 100</error>");
return;
}
@@ -124,6 +137,7 @@ final readonly class ProgressCommands
if (empty($jobs)) {
$output->writeln("<info>No jobs found above {$percentage}% progress</info>");
return;
}
@@ -138,9 +152,13 @@ final readonly class ProgressCommands
$output->writeln("Updated: {$entry->updatedAt}");
$status = [];
if ($entry->isCompleted) $status[] = 'Completed';
if ($entry->isFailed) $status[] = 'Failed';
if (!empty($status)) {
if ($entry->isCompleted) {
$status[] = 'Completed';
}
if ($entry->isFailed) {
$status[] = 'Failed';
}
if (! empty($status)) {
$output->writeln("Status: " . implode(', ', $status));
}
@@ -163,6 +181,7 @@ final readonly class ProgressCommands
if (empty($idArray)) {
$output->writeln("<error>No job IDs provided</error>");
return;
}
@@ -170,6 +189,7 @@ final readonly class ProgressCommands
if (empty($progressData)) {
$output->writeln("<error>No progress found for any of the provided job IDs</error>");
return;
}
@@ -191,4 +211,4 @@ final readonly class ProgressCommands
$output->writeln(str_repeat('-', 40));
}
}
}
}

View File

@@ -6,13 +6,13 @@ namespace App\Framework\Queue\Commands;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ExitCode;
use App\Framework\Queue\Services\WorkerRegistry;
use App\Framework\Queue\Services\WorkerHealthCheckService;
use App\Framework\Queue\Services\JobDistributionService;
use App\Framework\Queue\Services\FailoverRecoveryService;
use App\Framework\Queue\Interfaces\DistributedLockInterface;
use App\Framework\Queue\ValueObjects\WorkerId;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Queue\Interfaces\DistributedLockInterface;
use App\Framework\Queue\Services\FailoverRecoveryService;
use App\Framework\Queue\Services\JobDistributionService;
use App\Framework\Queue\Services\WorkerHealthCheckService;
use App\Framework\Queue\Services\WorkerRegistry;
use App\Framework\Queue\ValueObjects\WorkerId;
/**
* Console Commands für Worker Management
@@ -25,7 +25,8 @@ final readonly class WorkerCommands
private JobDistributionService $jobDistributionService,
private FailoverRecoveryService $failoverRecoveryService,
private DistributedLockInterface $distributedLock
) {}
) {
}
#[ConsoleCommand(name: 'worker:list', description: 'List all registered workers with their status')]
public function listWorkers(bool $active = true, bool $detailed = false): ExitCode
@@ -39,6 +40,7 @@ final readonly class WorkerCommands
if (empty($workers)) {
echo " No workers found\n";
return ExitCode::SUCCESS;
}
@@ -51,7 +53,7 @@ final readonly class WorkerCommands
echo "{$status} {$worker->hostname}:{$worker->processId}\n";
echo " ID: {$worker->id->toString()}\n";
echo " Load: {$worker->currentJobs}/{$worker->maxJobs} ({$load}%)\n";
echo " Queues: " . implode(', ', array_map(fn($q) => $q->toString(), $worker->queues)) . "\n";
echo " Queues: " . implode(', ', array_map(fn ($q) => $q->toString(), $worker->queues)) . "\n";
if ($detailed) {
echo " CPU: {$worker->cpuUsage->getValue()}%\n";
@@ -67,6 +69,7 @@ final readonly class WorkerCommands
} catch (\Exception $e) {
echo "❌ Error: {$e->getMessage()}\n";
return ExitCode::GENERAL_ERROR;
}
}
@@ -103,14 +106,14 @@ final readonly class WorkerCommands
echo "{$statusIcon} {$workerHealth['hostname']}:{$workerHealth['process_id']}\n";
echo " Status: {$workerHealth['status']} (Score: {$workerHealth['score']})\n";
if (!empty($workerHealth['issues'])) {
if (! empty($workerHealth['issues'])) {
echo " Issues:\n";
foreach ($workerHealth['issues'] as $issue) {
echo "{$issue}\n";
}
}
if (!empty($workerHealth['warnings'])) {
if (! empty($workerHealth['warnings'])) {
echo " Warnings:\n";
foreach ($workerHealth['warnings'] as $warning) {
echo " ⚠️ {$warning}\n";
@@ -133,6 +136,7 @@ final readonly class WorkerCommands
} catch (\Exception $e) {
echo "❌ Error: {$e->getMessage()}\n";
return ExitCode::GENERAL_ERROR;
}
}
@@ -190,7 +194,7 @@ final readonly class WorkerCommands
echo " Load Variance: " . ($health['load_variance'] ?? 0) . "%\n";
echo " Availability: " . ($health['availability_ratio'] ?? 0) . "%\n";
if (!empty($health['issues'])) {
if (! empty($health['issues'])) {
echo " Issues:\n";
foreach ($health['issues'] as $issue) {
echo "{$issue}\n";
@@ -201,6 +205,7 @@ final readonly class WorkerCommands
} catch (\Exception $e) {
echo "❌ Error: {$e->getMessage()}\n";
return ExitCode::GENERAL_ERROR;
}
}
@@ -248,7 +253,7 @@ final readonly class WorkerCommands
echo " Duration: {$results['duration_seconds']}s\n";
if (!empty($results['errors'])) {
if (! empty($results['errors'])) {
echo "\n❌ Errors:\n";
foreach ($results['errors'] as $error) {
echo "{$error}\n";
@@ -262,6 +267,7 @@ final readonly class WorkerCommands
} catch (\Exception $e) {
echo "❌ Error: {$e->getMessage()}\n";
return ExitCode::GENERAL_ERROR;
}
}
@@ -283,7 +289,7 @@ final readonly class WorkerCommands
echo " Oldest Lock: " . ($lockStats['oldest_lock'] ?? 'None') . "\n";
echo " Newest Lock: " . ($lockStats['newest_lock'] ?? 'None') . "\n\n";
if ($detailed && !empty($lockStats['top_lock_keys'])) {
if ($detailed && ! empty($lockStats['top_lock_keys'])) {
echo "🔑 Top Lock Keys:\n";
foreach ($lockStats['top_lock_keys'] as $keyInfo) {
echo " {$keyInfo['lock_key']}: {$keyInfo['count']} locks\n";
@@ -300,6 +306,7 @@ final readonly class WorkerCommands
} catch (\Exception $e) {
echo "❌ Error: {$e->getMessage()}\n";
return ExitCode::GENERAL_ERROR;
}
}
@@ -332,7 +339,7 @@ final readonly class WorkerCommands
echo " Expired Locks: {$metrics['expired_locks']}\n";
}
if (!empty($resilience['factors'])) {
if (! empty($resilience['factors'])) {
echo "\n⚠️ Resilience Factors:\n";
foreach ($resilience['factors'] as $factor) {
echo "{$factor}\n";
@@ -352,6 +359,7 @@ final readonly class WorkerCommands
} catch (\Exception $e) {
echo "❌ Error: {$e->getMessage()}\n";
return ExitCode::GENERAL_ERROR;
}
}
@@ -385,6 +393,7 @@ final readonly class WorkerCommands
} catch (\Exception $e) {
echo "❌ Error: {$e->getMessage()}\n";
return ExitCode::GENERAL_ERROR;
}
}
@@ -397,8 +406,9 @@ final readonly class WorkerCommands
try {
$worker = $this->workerRegistry->findById(WorkerId::fromString($workerId));
if (!$worker) {
if (! $worker) {
echo "❌ Worker not found: {$workerId}\n";
return ExitCode::INVALID_ARGUMENT;
}
@@ -422,7 +432,8 @@ final readonly class WorkerCommands
} catch (\Exception $e) {
echo "❌ Error: {$e->getMessage()}\n";
return ExitCode::GENERAL_ERROR;
}
}
}
}

View File

@@ -66,4 +66,4 @@ interface DeadLetterQueueInterface
* @return DeadLetterQueueName[]
*/
public function getAvailableQueues(): array;
}
}

View File

@@ -56,4 +56,4 @@ interface JobBatchManagerInterface
* Get batch progress
*/
public function getBatchProgress(string $batchId): array;
}
}

View File

@@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Framework\Queue\Contracts;
use App\Framework\Queue\ValueObjects\JobChain;
use App\Framework\Queue\Entities\JobChainEntry;
use App\Framework\Queue\ValueObjects\JobChain;
/**
* Interface for managing job chains
@@ -86,4 +86,4 @@ interface JobChainManagerInterface
* Clean up old completed chains
*/
public function cleanupOldChains(int $olderThanDays = 30): int;
}
}

View File

@@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Framework\Queue\Contracts;
use App\Framework\Queue\ValueObjects\JobDependency;
use App\Framework\Queue\Entities\JobDependencyEntry;
use App\Framework\Queue\ValueObjects\JobDependency;
/**
* Interface for managing job dependencies
@@ -78,4 +78,4 @@ interface JobDependencyManagerInterface
* Clean up old satisfied dependencies
*/
public function cleanupOldDependencies(int $olderThanDays = 30): int;
}
}

View File

@@ -77,4 +77,4 @@ interface JobProgressTrackerInterface
* @return JobProgressEntry[]
*/
public function getRecentlyUpdatedJobs(int $limitMinutes = 60, int $limit = 100): array;
}
}

View File

@@ -0,0 +1,227 @@
<?php
declare(strict_types=1);
namespace App\Framework\Queue;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\DateTime\Clock;
use App\Framework\Redis\RedisConnectionInterface;
/**
* Distributed Job Coordinator für Multi-Server Job Processing
*
* Verhindert dass derselbe Job auf mehreren Servern gleichzeitig
* ausgeführt wird mittels Redis-basierter Distributed Locks.
*/
final readonly class DistributedJobCoordinator
{
private const LOCK_PREFIX = 'job:lock:';
private const HEARTBEAT_PREFIX = 'job:heartbeat:';
public function __construct(
private RedisConnectionInterface $redis,
private Clock $clock,
private string $serverId = 'unknown'
) {
}
/**
* Versucht einen Lock für einen Job zu akquirieren
*
* @return DistributedLock|null Lock bei Erfolg, null wenn bereits gelockt
*/
public function acquireLock(string $jobId, Duration $ttl): ?DistributedLock
{
$lockKey = self::LOCK_PREFIX . $jobId;
$ownerId = $this->generateOwnerId();
$now = $this->clock->time();
$expiresAt = Timestamp::fromFloat($now->toFloat() + $ttl->toSeconds());
$lockData = json_encode([
'owner_id' => $ownerId,
'acquired_at' => $now->toFloat(),
'expires_at' => $expiresAt->toFloat(),
'server_id' => $this->serverId,
]);
// Check if lock already exists (NX semantics)
if ($this->redis->exists($lockKey) > 0) {
return null; // Lock bereits von anderem Process gehalten
}
// SET with Expiration
$success = $this->redis->set(
$lockKey,
$lockData,
(int) $ttl->toSeconds()
);
if (!$success) {
return null; // Fehler beim Setzen
}
$lockId = uniqid('lock_', true);
return new DistributedLock(
lockId: $lockId,
jobId: $jobId,
ownerId: $ownerId,
acquiredAt: $now,
expiresAt: $expiresAt,
ttl: $ttl
);
}
/**
* Gibt einen Lock frei
*/
public function releaseLock(DistributedLock $lock): bool
{
$lockKey = self::LOCK_PREFIX . $lock->jobId;
// Lösche Lock nur wenn wir der Owner sind
$lockData = $this->redis->get($lockKey);
if ($lockData === false) {
return false; // Lock existiert nicht mehr
}
$data = json_decode($lockData, true);
if ($data['owner_id'] !== $lock->ownerId) {
return false; // Lock gehört jemand anderem
}
$this->redis->delete($lockKey);
$this->redis->delete(self::HEARTBEAT_PREFIX . $lock->jobId);
return true;
}
/**
* Erneuert einen Lock (Heartbeat)
*
* Verhindert dass Lock während langer Job-Ausführung expired
*/
public function heartbeat(DistributedLock $lock): bool
{
$lockKey = self::LOCK_PREFIX . $lock->jobId;
$heartbeatKey = self::HEARTBEAT_PREFIX . $lock->jobId;
// Prüfe ob wir noch Lock-Owner sind
$lockData = $this->redis->get($lockKey);
if ($lockData === false) {
return false; // Lock expired
}
$data = json_decode($lockData, true);
if ($data['owner_id'] !== $lock->ownerId) {
return false; // Lock von jemand anderem übernommen
}
// Erneuere TTL
$ttlSeconds = $lock->ttl?->toSeconds() ?? 300;
$this->redis->expire($lockKey, (int) $ttlSeconds);
// Setze Heartbeat Timestamp
$this->redis->set(
$heartbeatKey,
(string) $this->clock->time()->toFloat(),
(int) $ttlSeconds
);
return true;
}
/**
* Prüft ob ein Job aktuell gelockt ist
*/
public function isLocked(string $jobId): bool
{
$lockKey = self::LOCK_PREFIX . $jobId;
return $this->redis->exists($lockKey) > 0;
}
/**
* Holt Lock-Informationen für einen Job
*/
public function getLockInfo(string $jobId): ?array
{
$lockKey = self::LOCK_PREFIX . $jobId;
$lockData = $this->redis->get($lockKey);
if ($lockData === false) {
return null;
}
$data = json_decode($lockData, true);
$ttl = $this->redis->ttl($lockKey);
return [
'job_id' => $jobId,
'owner_id' => $data['owner_id'] ?? 'unknown',
'server_id' => $data['server_id'] ?? 'unknown',
'acquired_at' => $data['acquired_at'] ?? null,
'expires_at' => $data['expires_at'] ?? null,
'ttl_remaining' => $ttl > 0 ? $ttl : 0,
];
}
/**
* Holt letzten Heartbeat-Timestamp für einen Job
*/
public function getLastHeartbeat(string $jobId): ?Timestamp
{
$heartbeatKey = self::HEARTBEAT_PREFIX . $jobId;
$timestamp = $this->redis->get($heartbeatKey);
if ($timestamp === false) {
return null;
}
return Timestamp::fromFloat((float) $timestamp);
}
/**
* Forciert Lock-Release (z.B. bei Dead Lock Detection)
*/
public function forceRelease(string $jobId): bool
{
$lockKey = self::LOCK_PREFIX . $jobId;
$heartbeatKey = self::HEARTBEAT_PREFIX . $jobId;
$deleted = $this->redis->delete($lockKey, $heartbeatKey);
return $deleted > 0;
}
/**
* Gibt alle aktiven Locks zurück
*/
public function getActiveLocks(): array
{
$pattern = self::LOCK_PREFIX . '*';
$keys = $this->redis->command('KEYS', $pattern);
$locks = [];
foreach ($keys as $key) {
$jobId = str_replace(self::LOCK_PREFIX, '', $key);
$lockInfo = $this->getLockInfo($jobId);
if ($lockInfo !== null) {
$locks[] = $lockInfo;
}
}
return $locks;
}
private function generateOwnerId(): string
{
return sprintf(
'%s:%s:%d',
$this->serverId,
gethostname(),
getmypid()
);
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Framework\Queue;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Distributed Lock Value Object
*
* Repräsentiert einen verteilten Lock für Job-Koordination
*/
final readonly class DistributedLock
{
public function __construct(
public string $lockId,
public string $jobId,
public string $ownerId,
public Timestamp $acquiredAt,
public Timestamp $expiresAt,
public ?Duration $ttl = null
) {
}
public function isExpired(Timestamp $now): bool
{
return $now->isAfter($this->expiresAt);
}
public function remainingTime(Timestamp $now): Duration
{
if ($this->isExpired($now)) {
return Duration::fromSeconds(0);
}
return $this->acquiredAt->diff($now);
}
public function toArray(): array
{
return [
'lock_id' => $this->lockId,
'job_id' => $this->jobId,
'owner_id' => $this->ownerId,
'acquired_at' => $this->acquiredAt->format('Y-m-d H:i:s'),
'expires_at' => $this->expiresAt->format('Y-m-d H:i:s'),
'ttl_seconds' => $this->ttl?->toSeconds(),
];
}
}

View File

@@ -4,9 +4,9 @@ declare(strict_types=1);
namespace App\Framework\Queue\Entities;
use App\Framework\Database\Attributes\Column;
use App\Framework\Database\Attributes\Entity;
use App\Framework\Database\Attributes\Id;
use App\Framework\Database\Attributes\Column;
use App\Framework\Queue\ValueObjects\DeadLetterQueueName;
use App\Framework\Queue\ValueObjects\FailureReason;
use App\Framework\Queue\ValueObjects\JobPayload;
@@ -23,43 +23,32 @@ final readonly class DeadLetterJob
#[Id]
#[Column(name: 'id', type: 'string', length: 26)]
public string $id,
#[Column(name: 'original_job_id', type: 'string', length: 26)]
public string $originalJobId,
#[Column(name: 'dead_letter_queue', type: 'string', length: 100)]
public string $deadLetterQueue,
#[Column(name: 'original_queue', type: 'string', length: 50)]
public string $originalQueue,
#[Column(name: 'job_payload', type: 'text')]
public string $jobPayload,
#[Column(name: 'failure_reason', type: 'text')]
public string $failureReason,
#[Column(name: 'exception_type', type: 'string', length: 255, nullable: true)]
public ?string $exceptionType,
#[Column(name: 'stack_trace', type: 'longtext', nullable: true)]
public ?string $stackTrace,
#[Column(name: 'failed_attempts', type: 'integer')]
public int $failedAttempts,
#[Column(name: 'failed_at', type: 'timestamp')]
public string $failedAt,
#[Column(name: 'moved_to_dlq_at', type: 'timestamp')]
public string $movedToDlqAt,
#[Column(name: 'retry_count', type: 'integer', default: 0)]
public int $retryCount = 0,
#[Column(name: 'last_retry_at', type: 'timestamp', nullable: true)]
public ?string $lastRetryAt = null
) {}
) {
}
public static function fromFailedJob(
JobIndexEntry $failedJob,
@@ -139,7 +128,7 @@ final readonly class DeadLetterJob
'failed_at' => $this->failedAt,
'moved_to_dlq_at' => $this->movedToDlqAt,
'retry_count' => $this->retryCount,
'last_retry_at' => $this->lastRetryAt
'last_retry_at' => $this->lastRetryAt,
];
}
}
}

View File

@@ -4,11 +4,11 @@ declare(strict_types=1);
namespace App\Framework\Queue\Entities;
use App\Framework\Database\Attributes\Column;
use App\Framework\Database\Attributes\Entity;
use App\Framework\Database\Attributes\Id;
use App\Framework\Database\Attributes\Column;
use App\Framework\Queue\ValueObjects\JobChain;
use App\Framework\Queue\ValueObjects\ChainExecutionMode;
use App\Framework\Queue\ValueObjects\JobChain;
use App\Framework\Ulid\Ulid;
/**
@@ -21,40 +21,30 @@ final readonly class JobChainEntry
#[Id]
#[Column(name: 'id', type: 'string', length: 26)]
public string $id,
#[Column(name: 'chain_id', type: 'string', length: 26)]
public string $chainId,
#[Column(name: 'name', type: 'string', length: 255)]
public string $name,
#[Column(name: 'job_ids', type: 'json')]
public string $jobIds,
#[Column(name: 'execution_mode', type: 'string', length: 20)]
public string $executionMode,
#[Column(name: 'created_at', type: 'timestamp')]
public string $createdAt,
#[Column(name: 'updated_at', type: 'timestamp')]
public string $updatedAt,
#[Column(name: 'stop_on_failure', type: 'boolean', default: true)]
public bool $stopOnFailure = true,
#[Column(name: 'metadata', type: 'json', nullable: true)]
public ?string $metadata = null,
#[Column(name: 'status', type: 'string', length: 20, default: 'pending')]
public string $status = 'pending',
#[Column(name: 'started_at', type: 'timestamp', nullable: true)]
public ?string $startedAt = null,
#[Column(name: 'completed_at', type: 'timestamp', nullable: true)]
public ?string $completedAt = null
) {}
) {
}
public static function fromJobChain(JobChain $jobChain): self
{
@@ -194,7 +184,7 @@ final readonly class JobChainEntry
'started_at' => $this->startedAt,
'completed_at' => $this->completedAt,
'created_at' => $this->createdAt,
'updated_at' => $this->updatedAt
'updated_at' => $this->updatedAt,
];
}
}
}

View File

@@ -4,11 +4,11 @@ declare(strict_types=1);
namespace App\Framework\Queue\Entities;
use App\Framework\Database\Attributes\Column;
use App\Framework\Database\Attributes\Entity;
use App\Framework\Database\Attributes\Id;
use App\Framework\Database\Attributes\Column;
use App\Framework\Queue\ValueObjects\JobDependency;
use App\Framework\Queue\ValueObjects\DependencyType;
use App\Framework\Queue\ValueObjects\JobDependency;
use App\Framework\Ulid\Ulid;
/**
@@ -21,31 +21,24 @@ final readonly class JobDependencyEntry
#[Id]
#[Column(name: 'id', type: 'string', length: 26)]
public string $id,
#[Column(name: 'dependent_job_id', type: 'string', length: 26)]
public string $dependentJobId,
#[Column(name: 'depends_on_job_id', type: 'string', length: 26)]
public string $dependsOnJobId,
#[Column(name: 'dependency_type', type: 'string', length: 20)]
public string $dependencyType,
#[Column(name: 'created_at', type: 'timestamp')]
public string $createdAt,
#[Column(name: 'updated_at', type: 'timestamp')]
public string $updatedAt,
#[Column(name: 'condition_expression', type: 'text', nullable: true)]
public ?string $conditionExpression = null,
#[Column(name: 'is_satisfied', type: 'boolean', default: false)]
public bool $isSatisfied = false,
#[Column(name: 'satisfied_at', type: 'timestamp', nullable: true)]
public ?string $satisfiedAt = null
) {}
) {
}
public static function fromJobDependency(JobDependency $dependency): self
{
@@ -115,7 +108,7 @@ final readonly class JobDependencyEntry
'is_satisfied' => $this->isSatisfied,
'satisfied_at' => $this->satisfiedAt,
'created_at' => $this->createdAt,
'updated_at' => $this->updatedAt
'updated_at' => $this->updatedAt,
];
}
}
}

View File

@@ -4,11 +4,11 @@ declare(strict_types=1);
namespace App\Framework\Queue\Entities;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Database\Attributes\Column;
use App\Framework\Database\Attributes\Entity;
use App\Framework\Queue\ValueObjects\JobId;
use App\Framework\Queue\ValueObjects\JobStatus;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Job History Entry Entity
@@ -21,25 +21,20 @@ final readonly class JobHistoryEntry
public function __construct(
#[Column(name: 'id', primary: true, autoIncrement: true)]
public ?int $id,
#[Column(name: 'job_id')]
public JobId $jobId,
#[Column(name: 'old_status')]
public ?JobStatus $oldStatus,
#[Column(name: 'new_status')]
public JobStatus $newStatus,
#[Column(name: 'error_message')]
public ?string $errorMessage,
#[Column(name: 'changed_at')]
public Timestamp $changedAt,
#[Column(name: 'metadata')]
public ?string $metadata = null
) {}
) {
}
/**
* Create status change entry
@@ -58,7 +53,7 @@ final readonly class JobHistoryEntry
newStatus: $newStatus,
errorMessage: $errorMessage,
changedAt: Timestamp::now(),
metadata: !empty($metadata) ? json_encode($metadata) : null
metadata: ! empty($metadata) ? json_encode($metadata) : null
);
}
@@ -72,6 +67,7 @@ final readonly class JobHistoryEntry
}
$decoded = json_decode($this->metadata, true);
return is_array($decoded) ? $decoded : [];
}
@@ -87,7 +83,7 @@ final readonly class JobHistoryEntry
'new_status' => $this->newStatus->value,
'error_message' => $this->errorMessage,
'changed_at' => $this->changedAt->toFloat(),
'metadata' => $this->metadata
'metadata' => $this->metadata,
];
}
}
}

View File

@@ -4,14 +4,14 @@ declare(strict_types=1);
namespace App\Framework\Queue\Entities;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Database\Attributes\Column;
use App\Framework\Database\Attributes\Entity;
use App\Framework\Queue\ValueObjects\JobId;
use App\Framework\Queue\ValueObjects\JobStatus;
use App\Framework\Queue\ValueObjects\JobState;
use App\Framework\Queue\ValueObjects\QueueType;
use App\Framework\Queue\ValueObjects\JobStatus;
use App\Framework\Queue\ValueObjects\QueuePriority;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Queue\ValueObjects\QueueType;
/**
* Job Index Entry Entity
@@ -24,40 +24,30 @@ final readonly class JobIndexEntry
public function __construct(
#[Column(name: 'job_id', primary: true)]
public JobId $jobId,
#[Column(name: 'status')]
public JobStatus $status,
#[Column(name: 'queue_type')]
public QueueType $queueType,
#[Column(name: 'priority')]
public QueuePriority $priority,
#[Column(name: 'attempts')]
public int $attempts,
#[Column(name: 'max_attempts')]
public int $maxAttempts,
#[Column(name: 'created_at')]
public Timestamp $createdAt,
#[Column(name: 'updated_at')]
public Timestamp $updatedAt,
#[Column(name: 'started_at')]
public ?Timestamp $startedAt = null,
#[Column(name: 'completed_at')]
public ?Timestamp $completedAt = null,
#[Column(name: 'scheduled_for')]
public ?Timestamp $scheduledFor = null,
#[Column(name: 'error_message')]
public ?string $errorMessage = null
) {}
) {
}
/**
* Create from JobState
@@ -139,7 +129,7 @@ final readonly class JobIndexEntry
*/
public function needsRetry(): bool
{
if (!$this->canRetry()) {
if (! $this->canRetry()) {
return false;
}
@@ -167,7 +157,7 @@ final readonly class JobIndexEntry
'started_at' => $this->startedAt?->toFloat(),
'completed_at' => $this->completedAt?->toFloat(),
'scheduled_for' => $this->scheduledFor?->toFloat(),
'error_message' => $this->errorMessage
'error_message' => $this->errorMessage,
];
}
}
}

View File

@@ -4,9 +4,9 @@ declare(strict_types=1);
namespace App\Framework\Queue\Entities;
use App\Framework\Database\Attributes\Column;
use App\Framework\Database\Attributes\Entity;
use App\Framework\Database\Attributes\Id;
use App\Framework\Database\Attributes\Column;
use App\Framework\Queue\ValueObjects\JobMetrics;
use App\Framework\Ulid\Ulid;
@@ -17,49 +17,36 @@ final readonly class JobMetricsEntry
#[Id]
#[Column(name: 'id', type: 'string', length: 26)]
public string $id,
#[Column(name: 'job_id', type: 'string', length: 26)]
public string $jobId,
#[Column(name: 'queue_name', type: 'string', length: 100)]
public string $queueName,
#[Column(name: 'status', type: 'string', length: 20)]
public string $status,
#[Column(name: 'attempts', type: 'integer')]
public int $attempts,
#[Column(name: 'max_attempts', type: 'integer')]
public int $maxAttempts,
#[Column(name: 'execution_time_ms', type: 'float')]
public float $executionTimeMs,
#[Column(name: 'memory_usage_bytes', type: 'integer')]
public int $memoryUsageBytes,
#[Column(name: 'created_at', type: 'timestamp')]
public string $createdAt,
#[Column(name: 'updated_at', type: 'timestamp')]
public string $updatedAt,
#[Column(name: 'error_message', type: 'text', nullable: true)]
public ?string $errorMessage = null,
#[Column(name: 'started_at', type: 'timestamp', nullable: true)]
public ?string $startedAt = null,
#[Column(name: 'completed_at', type: 'timestamp', nullable: true)]
public ?string $completedAt = null,
#[Column(name: 'failed_at', type: 'timestamp', nullable: true)]
public ?string $failedAt = null,
#[Column(name: 'metadata', type: 'json', nullable: true)]
public ?string $metadata = null
) {}
) {
}
public static function fromJobMetrics(JobMetrics $metrics): self
{
@@ -80,7 +67,7 @@ final readonly class JobMetricsEntry
startedAt: $metrics->startedAt,
completedAt: $metrics->completedAt,
failedAt: $metrics->failedAt,
metadata: !empty($metrics->metadata) ? json_encode($metrics->metadata) : null
metadata: ! empty($metrics->metadata) ? json_encode($metrics->metadata) : null
);
}
@@ -120,7 +107,7 @@ final readonly class JobMetricsEntry
startedAt: $metrics->startedAt,
completedAt: $metrics->completedAt,
failedAt: $metrics->failedAt,
metadata: !empty($metrics->metadata) ? json_encode($metrics->metadata) : $this->metadata
metadata: ! empty($metrics->metadata) ? json_encode($metrics->metadata) : $this->metadata
);
}
@@ -178,7 +165,7 @@ final readonly class JobMetricsEntry
'started_at' => $this->startedAt,
'completed_at' => $this->completedAt,
'failed_at' => $this->failedAt,
'metadata' => $this->getMetadataArray()
'metadata' => $this->getMetadataArray(),
];
}
}
}

View File

@@ -4,11 +4,11 @@ declare(strict_types=1);
namespace App\Framework\Queue\Entities;
use App\Framework\Core\ValueObjects\Percentage;
use App\Framework\Database\Attributes\Column;
use App\Framework\Database\Attributes\Entity;
use App\Framework\Database\Attributes\Id;
use App\Framework\Database\Attributes\Column;
use App\Framework\Queue\ValueObjects\JobProgress;
use App\Framework\Core\ValueObjects\Percentage;
use App\Framework\Ulid\Ulid;
/**
@@ -21,31 +21,24 @@ final readonly class JobProgressEntry
#[Id]
#[Column(name: 'id', type: 'string', length: 26)]
public string $id,
#[Column(name: 'job_id', type: 'string', length: 26)]
public string $jobId,
#[Column(name: 'percentage', type: 'decimal', precision: 5, scale: 2)]
public float $percentage,
#[Column(name: 'message', type: 'text')]
public string $message,
#[Column(name: 'step_name', type: 'string', length: 100, nullable: true)]
public ?string $stepName,
#[Column(name: 'metadata', type: 'json', nullable: true)]
public ?string $metadata,
#[Column(name: 'updated_at', type: 'timestamp')]
public string $updatedAt,
#[Column(name: 'is_completed', type: 'boolean', default: false)]
public bool $isCompleted = false,
#[Column(name: 'is_failed', type: 'boolean', default: false)]
public bool $isFailed = false
) {}
) {
}
public static function fromJobProgress(string $jobId, JobProgress $progress, ?string $stepName = null): self
{
@@ -100,7 +93,7 @@ final readonly class JobProgressEntry
'metadata' => $this->getMetadataArray(),
'updated_at' => $this->updatedAt,
'is_completed' => $this->isCompleted,
'is_failed' => $this->isFailed
'is_failed' => $this->isFailed,
];
}
}
}

View File

@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace App\Framework\Queue\Entities;
use App\Framework\Queue\ValueObjects\WorkerId;
use App\Framework\Queue\ValueObjects\QueueName;
use App\Framework\Core\ValueObjects\Percentage;
use App\Framework\Core\ValueObjects\Byte;
use App\Framework\Core\ValueObjects\Percentage;
use App\Framework\Queue\ValueObjects\QueueName;
use App\Framework\Queue\ValueObjects\WorkerId;
/**
* Worker Entity für Distributed Job Processing
@@ -136,6 +136,7 @@ final readonly class Worker
return true;
}
}
return false;
}
@@ -144,7 +145,7 @@ final readonly class Worker
*/
public function isHealthy(): bool
{
if (!$this->isActive) {
if (! $this->isActive) {
return false;
}
@@ -204,7 +205,7 @@ final readonly class Worker
'id' => $this->id->toString(),
'hostname' => $this->hostname,
'process_id' => $this->processId,
'queues' => array_map(fn(QueueName $queue) => $queue->toString(), $this->queues),
'queues' => array_map(fn (QueueName $queue) => $queue->toString(), $this->queues),
'max_jobs' => $this->maxJobs,
'current_jobs' => $this->currentJobs,
'is_active' => $this->isActive,
@@ -216,7 +217,7 @@ final readonly class Worker
'registered_at' => $this->registeredAt->format('Y-m-d H:i:s'),
'last_heartbeat' => $this->lastHeartbeat?->format('Y-m-d H:i:s'),
'capabilities' => $this->capabilities,
'version' => $this->version
'version' => $this->version,
];
}
@@ -229,7 +230,7 @@ final readonly class Worker
'id' => $this->id->toString(),
'hostname' => $this->hostname,
'process_id' => $this->processId,
'queues' => json_encode(array_map(fn(QueueName $queue) => $queue->toString(), $this->queues)),
'queues' => json_encode(array_map(fn (QueueName $queue) => $queue->toString(), $this->queues)),
'max_jobs' => $this->maxJobs,
'current_jobs' => $this->currentJobs,
'is_active' => $this->isActive,
@@ -238,7 +239,7 @@ final readonly class Worker
'registered_at' => $this->registeredAt->format('Y-m-d H:i:s'),
'last_heartbeat' => $this->lastHeartbeat?->format('Y-m-d H:i:s'),
'capabilities' => json_encode($this->capabilities),
'version' => $this->version
'version' => $this->version,
];
}
@@ -248,13 +249,14 @@ final readonly class Worker
public static function fromArray(array $data): self
{
$queueStrings = json_decode($data['queues'], true);
$queues = array_map(function(string $queueString) {
$queues = array_map(function (string $queueString) {
// Parse queue string zurück zu QueueName
// Annahme: Format ist "type.name" oder "tenant.type.name"
$parts = explode('.', $queueString);
if (count($parts) >= 2) {
return QueueName::default(); // Vereinfacht - könnte erweitert werden
}
return QueueName::default();
}, $queueStrings);
@@ -274,4 +276,4 @@ final readonly class Worker
version: $data['version'] ?? '1.0.0'
);
}
}
}

View File

@@ -26,7 +26,7 @@ final readonly class JobChainCompletedEvent
'chain_id' => $this->chainId,
'name' => $this->name,
'completed_at' => $this->completedAt,
'timestamp' => $this->timestamp->toFloat()
'timestamp' => $this->timestamp->toFloat(),
];
}
}
}

View File

@@ -28,7 +28,7 @@ final readonly class JobChainFailedEvent
'name' => $this->name,
'failed_job_id' => $this->failedJobId,
'failed_at' => $this->failedAt,
'timestamp' => $this->timestamp->toFloat()
'timestamp' => $this->timestamp->toFloat(),
];
}
}
}

View File

@@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Framework\Queue\Events;
use App\Framework\Queue\ValueObjects\ChainExecutionMode;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Queue\ValueObjects\ChainExecutionMode;
final readonly class JobChainStartedEvent
{
@@ -30,7 +30,7 @@ final readonly class JobChainStartedEvent
'execution_mode' => $this->executionMode->value,
'job_ids' => $this->jobIds,
'job_count' => count($this->jobIds),
'timestamp' => $this->timestamp->toFloat()
'timestamp' => $this->timestamp->toFloat(),
];
}
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Framework\Queue\Exceptions;
use App\Framework\Exception\Core\QueueErrorCode;
use App\Framework\Exception\ExceptionContext;
/**
* Exception thrown when attempting to complete or update steps after all steps are completed
*/
final class AllStepsCompletedException extends QueueException
{
public static function forJob(string $jobId, int $totalSteps): self
{
$context = ExceptionContext::forOperation('step.complete', 'StepProgressTracker')
->withData([
'job_id' => $jobId,
'total_steps' => $totalSteps,
'current_step_index' => $totalSteps,
]);
return self::create(
QueueErrorCode::INVALID_STATE,
"All {$totalSteps} steps have already been completed for job '{$jobId}'",
$context
);
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Framework\Queue\Exceptions;
use App\Framework\Exception\Core\QueueErrorCode;
use App\Framework\Exception\ExceptionContext;
/**
* Exception thrown when a job chain cannot be found
*/
final class ChainNotFoundException extends QueueException
{
public static function byId(string $chainId): self
{
$context = ExceptionContext::forOperation('chain.lookup', 'JobChainManager')
->withData([
'chain_id' => $chainId,
'search_type' => 'by_id',
]);
return self::create(
QueueErrorCode::CHAIN_NOT_FOUND,
"Chain with ID '{$chainId}' not found",
$context
);
}
public static function byName(string $name): self
{
$context = ExceptionContext::forOperation('chain.lookup', 'JobChainManager')
->withData([
'chain_name' => $name,
'search_type' => 'by_name',
]);
return self::create(
QueueErrorCode::CHAIN_NOT_FOUND,
"Chain with name '{$name}' not found",
$context
);
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Framework\Queue\Exceptions;
use App\Framework\Exception\Core\QueueErrorCode;
use App\Framework\Exception\ExceptionContext;
/**
* Exception thrown when circular dependencies are detected in job chains
*/
final class CircularDependencyException extends QueueException
{
public static function inChain(string $chainId, array $circularDependencies = []): self
{
$context = ExceptionContext::forOperation('chain.validate', 'JobChainExecutionCoordinator')
->withData([
'chain_id' => $chainId,
'circular_dependencies' => $circularDependencies,
]);
return self::create(
QueueErrorCode::CIRCULAR_DEPENDENCY,
"Chain '{$chainId}' has circular dependencies",
$context
);
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Framework\Queue\Exceptions;
use App\Framework\Exception\Core\QueueErrorCode;
use App\Framework\Exception\ExceptionContext;
/**
* Exception thrown when a job chain is in an invalid state for the requested operation
*/
final class InvalidChainStateException extends QueueException
{
public static function notPending(string $chainId, string $currentStatus): self
{
$context = ExceptionContext::forOperation('chain.start', 'JobChainManager')
->withData([
'chain_id' => $chainId,
'current_status' => $currentStatus,
'required_status' => 'pending',
]);
return self::create(
QueueErrorCode::INVALID_STATE,
"Chain '{$chainId}' is not in pending status (current: {$currentStatus})",
$context
);
}
public static function alreadyCompleted(string $chainId): self
{
$context = ExceptionContext::forOperation('chain.modify', 'JobChainManager')
->withData([
'chain_id' => $chainId,
'current_status' => 'completed',
]);
return self::create(
QueueErrorCode::INVALID_STATE,
"Chain '{$chainId}' is already completed and cannot be modified",
$context
);
}
public static function alreadyFailed(string $chainId): self
{
$context = ExceptionContext::forOperation('chain.modify', 'JobChainManager')
->withData([
'chain_id' => $chainId,
'current_status' => 'failed',
]);
return self::create(
QueueErrorCode::INVALID_STATE,
"Chain '{$chainId}' has failed and cannot be modified",
$context
);
}
}

View File

@@ -5,42 +5,42 @@ declare(strict_types=1);
namespace App\Framework\Queue\Exceptions;
use App\Framework\Exception\FrameworkException;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ValidationErrorCode;
final class InvalidDeadLetterQueueNameException extends FrameworkException
{
public static function tooShort(string $name, int $minLength): self
{
return self::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
ValidationErrorCode::BUSINESS_RULE_VIOLATION,
"Dead letter queue name '{$name}' is too short. Minimum length is {$minLength} characters."
)->withData([
'name' => $name,
'actual_length' => strlen($name),
'min_length' => $minLength
'min_length' => $minLength,
]);
}
public static function tooLong(string $name, int $maxLength): self
{
return self::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
ValidationErrorCode::BUSINESS_RULE_VIOLATION,
"Dead letter queue name '{$name}' is too long. Maximum length is {$maxLength} characters."
)->withData([
'name' => $name,
'actual_length' => strlen($name),
'max_length' => $maxLength
'max_length' => $maxLength,
]);
}
public static function invalidFormat(string $name, string $pattern): self
{
return self::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
ValidationErrorCode::BUSINESS_RULE_VIOLATION,
"Dead letter queue name '{$name}' contains invalid characters. Only alphanumeric characters, underscores, hyphens, and dots are allowed."
)->withData([
'name' => $name,
'valid_pattern' => $pattern
'valid_pattern' => $pattern,
]);
}
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Framework\Queue\Exceptions;
use App\Framework\Exception\Core\QueueErrorCode;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Queue\ValueObjects\JobId;
/**
* Exception thrown when a job cannot be found
*/
final class JobNotFoundException extends QueueException
{
public static function byId(JobId $jobId): self
{
$context = ExceptionContext::forOperation('job.lookup', 'JobPersistenceLayer')
->withData([
'job_id' => $jobId->toString(),
'search_type' => 'by_id',
]);
return self::create(
QueueErrorCode::JOB_NOT_FOUND,
"Job with ID '{$jobId->toString()}' not found",
$context
);
}
public static function inQueue(JobId $jobId, string $queueName): self
{
$context = ExceptionContext::forOperation('job.lookup', 'Queue')
->withData([
'job_id' => $jobId->toString(),
'queue_name' => $queueName,
'search_type' => 'in_queue',
]);
return self::create(
QueueErrorCode::JOB_NOT_FOUND,
"Job '{$jobId->toString()}' not found in queue '{$queueName}'",
$context
);
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Framework\Queue\Exceptions;
use App\Framework\Exception\FrameworkException;
/**
* Base exception for Queue module
*
* All Queue-related exceptions should extend this class.
*/
class QueueException extends FrameworkException
{
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Framework\Queue\Exceptions;
use App\Framework\Exception\Core\QueueErrorCode;
use App\Framework\Exception\ExceptionContext;
/**
* Exception thrown when Redis PHP extension is not loaded
*
* This is typically caught and handled with graceful fallback to FileQueue.
*/
final class RedisExtensionNotLoadedException extends QueueException
{
public static function notLoaded(): self
{
$context = ExceptionContext::forOperation('queue.init', 'QueueInitializer')
->withData([
'required_extension' => 'redis',
'loaded_extensions' => get_loaded_extensions(),
'fallback_available' => 'FileQueue',
]);
return self::fromContext(
'Redis PHP extension is not loaded',
$context,
QueueErrorCode::WORKER_UNAVAILABLE
);
}
}

View File

@@ -4,20 +4,17 @@ declare(strict_types=1);
namespace App\Framework\Queue;
use App\Framework\Queue\ValueObjects\JobPayload;
use App\Framework\Queue\ValueObjects\QueuePriority;
use App\Framework\Filesystem\FileStorage;
use App\Framework\Filesystem\FilePath;
use App\Framework\Filesystem\Directory;
use App\Framework\Filesystem\FilesystemFactory;
use App\Framework\Serializer\Php\PhpSerializer;
use App\Framework\Serializer\Php\PhpSerializerConfig;
use App\Framework\Serializer\Serializer;
use App\Framework\Filesystem\FileStorage;
use App\Framework\Logging\DefaultLogger;
use App\Framework\Logging\Logger;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\ProcessorManager;
use App\Framework\Logging\ValueObjects\LogContext;
use App\Framework\Queue\ValueObjects\JobPayload;
use App\Framework\Serializer\Php\PhpSerializer;
use App\Framework\Serializer\Php\PhpSerializerConfig;
use App\Framework\Serializer\Serializer;
/**
* File-based Queue Implementation with Priority Support
@@ -29,10 +26,15 @@ use App\Framework\Logging\ValueObjects\LogContext;
final readonly class FileQueue implements Queue
{
private Directory $queueDirectory;
private Directory $priorityDirectory;
private Directory $delayedDirectory;
private FileStorage $storage;
private Logger $logger;
private Serializer $serializer;
public function __construct(
@@ -124,7 +126,7 @@ final readonly class FileQueue implements Queue
} catch (\Throwable $e) {
$this->logger->warning('Failed to delete priority job file', LogContext::withData([
'file' => $file->filename,
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
}
}
@@ -137,7 +139,7 @@ final readonly class FileQueue implements Queue
} catch (\Throwable $e) {
$this->logger->warning('Failed to delete delayed job file', [
'file' => $file->filename,
'error' => $e->getMessage()
'error' => $e->getMessage(),
]);
}
}
@@ -155,7 +157,7 @@ final readonly class FileQueue implements Queue
'total_size' => count($priorityJobs) + count($delayedJobs),
'priority_queue_size' => count($priorityJobs),
'delayed_queue_size' => count($delayedJobs),
'priority_breakdown' => $priorityBreakdown
'priority_breakdown' => $priorityBreakdown,
];
}
@@ -214,7 +216,7 @@ final readonly class FileQueue implements Queue
} catch (\Throwable $e) {
$this->logger->error('Failed to process delayed job', LogContext::withData([
'file' => $file->filename,
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
}
}
@@ -247,7 +249,7 @@ final readonly class FileQueue implements Queue
} catch (\Throwable $e) {
$this->logger->error('Failed to pop priority job', LogContext::withData([
'file' => $firstFile->filename,
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
// Try to delete corrupted file
@@ -335,6 +337,7 @@ final readonly class FileQueue implements Queue
private function generatePriorityFilename(float $score): string
{
$scoreStr = str_pad((string) (int) ($score * 1000000), 15, '0', STR_PAD_LEFT);
return "job_{$scoreStr}_" . uniqid() . '.json';
}

View File

@@ -21,7 +21,7 @@ final class InMemoryQueue implements Queue
$this->items[] = $payload;
// Sort by priority (higher priority first)
usort($this->items, function(JobPayload $a, JobPayload $b): int {
usort($this->items, function (JobPayload $a, JobPayload $b): int {
return $b->priority->value <=> $a->priority->value;
});
}
@@ -53,6 +53,7 @@ final class InMemoryQueue implements Queue
{
$count = count($this->items);
$this->items = [];
return $count;
}
@@ -68,7 +69,7 @@ final class InMemoryQueue implements Queue
return [
'size' => $this->size(),
'priority_breakdown' => $priorityCounts,
'total_items' => count($this->items)
'total_items' => count($this->items),
];
}
}
}

View File

@@ -4,15 +4,17 @@ declare(strict_types=1);
namespace App\Framework\Queue\Initializers;
use App\Framework\Cache\Cache;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\EntityManager;
use App\Framework\DI\Initializer;
use App\Framework\DI\Container;
use App\Framework\DI\Initializer;
use App\Framework\Queue\Contracts\DeadLetterQueueInterface;
use App\Framework\Queue\Contracts\QueueInterface;
use App\Framework\Queue\ProductionJobPersistenceLayer;
use App\Framework\Queue\Queue;
use App\Framework\Queue\Services\DatabaseDeadLetterQueue;
use App\Framework\Queue\Services\DeadLetterManager;
use App\Framework\Queue\Services\ProductionJobPersistenceLayer;
use App\Framework\StateManagement\StateManager;
/**
* Initializes Dead Letter Queue services
@@ -22,11 +24,21 @@ final readonly class DeadLetterQueueInitializer
#[Initializer]
public function __invoke(Container $container): DeadLetterQueueInterface
{
// Register ProductionJobPersistenceLayer if not already registered
if (!$container->has(ProductionJobPersistenceLayer::class)) {
$persistenceLayer = new ProductionJobPersistenceLayer(
stateManager: $container->get(StateManager::class),
entityManager: $container->get(EntityManager::class),
cache: $container->get(Cache::class)
);
$container->instance(ProductionJobPersistenceLayer::class, $persistenceLayer);
}
// Create Dead Letter Queue implementation
$deadLetterQueue = new DatabaseDeadLetterQueue(
connection: $container->get(ConnectionInterface::class),
entityManager: $container->get(EntityManager::class),
originalQueue: $container->get(QueueInterface::class)
originalQueue: $container->get(Queue::class)
);
// Register Dead Letter Manager
@@ -38,4 +50,4 @@ final readonly class DeadLetterQueueInitializer
return $deadLetterQueue;
}
}
}

View File

@@ -4,12 +4,12 @@ declare(strict_types=1);
namespace App\Framework\Queue\Initializers;
use App\Framework\Database\ConnectionInterface;
use App\Framework\DI\Container;
use App\Framework\DI\Initializer;
use App\Framework\Logging\Logger;
use App\Framework\Queue\Interfaces\DistributedLockInterface;
use App\Framework\Queue\Services\DatabaseDistributedLock;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Logging\Logger;
/**
* Distributed Lock Initializer

View File

@@ -4,15 +4,15 @@ declare(strict_types=1);
namespace App\Framework\Queue\Initializers;
use App\Framework\Database\ConnectionInterface;
use App\Framework\DI\Container;
use App\Framework\DI\Initializer;
use App\Framework\Logging\Logger;
use App\Framework\Queue\Interfaces\DistributedLockInterface;
use App\Framework\Queue\Services\FailoverRecoveryService;
use App\Framework\Queue\Services\WorkerRegistry;
use App\Framework\Queue\Services\JobDistributionService;
use App\Framework\Queue\Services\WorkerHealthCheckService;
use App\Framework\Queue\Interfaces\DistributedLockInterface;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Logging\Logger;
use App\Framework\Queue\Services\WorkerRegistry;
/**
* Failover Recovery Service Initializer

View File

@@ -4,13 +4,13 @@ declare(strict_types=1);
namespace App\Framework\Queue\Initializers;
use App\Framework\DI\Initializer;
use App\Framework\Database\ConnectionInterface;
use App\Framework\DI\Container;
use App\Framework\DI\Initializer;
use App\Framework\Logging\Logger;
use App\Framework\Queue\Interfaces\DistributedLockInterface;
use App\Framework\Queue\Services\JobDistributionService;
use App\Framework\Queue\Services\WorkerRegistry;
use App\Framework\Queue\Interfaces\DistributedLockInterface;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Logging\Logger;
/**
* Job Distribution Service Initializer
@@ -32,4 +32,4 @@ final readonly class JobDistributionInitializer
logger: $logger
);
}
}
}

View File

@@ -4,12 +4,12 @@ declare(strict_types=1);
namespace App\Framework\Queue\Initializers;
use App\Framework\DI\Initializer;
use App\Framework\DI\Container;
use App\Framework\Queue\Services\ProductionJobMetricsManager;
use App\Framework\Queue\Services\JobMetricsManagerInterface;
use App\Framework\Database\ConnectionInterface;
use App\Framework\DI\Container;
use App\Framework\DI\Initializer;
use App\Framework\Logging\Logger;
use App\Framework\Queue\Services\JobMetricsManagerInterface;
use App\Framework\Queue\Services\ProductionJobMetricsManager;
final readonly class JobMetricsInitializer
{
@@ -30,4 +30,4 @@ final readonly class JobMetricsInitializer
return $jobMetricsManager;
}
}
}

View File

@@ -4,13 +4,13 @@ declare(strict_types=1);
namespace App\Framework\Queue\Initializers;
use App\Framework\DI\Initializer;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\EntityManager;
use App\Framework\DI\Container;
use App\Framework\DI\Initializer;
use App\Framework\Queue\Contracts\JobProgressTrackerInterface;
use App\Framework\Queue\Services\DatabaseJobProgressTracker;
use App\Framework\Queue\Services\ProgressManager;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\EntityManager;
/**
* Initialize job progress tracking services
@@ -34,4 +34,4 @@ final readonly class ProgressTrackingInitializer
return $progressTracker;
}
}
}

View File

@@ -4,12 +4,12 @@ declare(strict_types=1);
namespace App\Framework\Queue\Initializers;
use App\Framework\DI\Initializer;
use App\Framework\Database\ConnectionInterface;
use App\Framework\DI\Container;
use App\Framework\DI\Initializer;
use App\Framework\Logging\Logger;
use App\Framework\Queue\Services\WorkerHealthCheckService;
use App\Framework\Queue\Services\WorkerRegistry;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Logging\Logger;
/**
* Worker Health Check Service Initializer
@@ -29,4 +29,4 @@ final readonly class WorkerHealthCheckInitializer
logger: $logger
);
}
}
}

View File

@@ -4,11 +4,11 @@ declare(strict_types=1);
namespace App\Framework\Queue\Initializers;
use App\Framework\DI\Initializer;
use App\Framework\DI\Container;
use App\Framework\Queue\Services\WorkerRegistry;
use App\Framework\Database\ConnectionInterface;
use App\Framework\DI\Container;
use App\Framework\DI\Initializer;
use App\Framework\Logging\Logger;
use App\Framework\Queue\Services\WorkerRegistry;
/**
* Worker Registry Initializer
@@ -26,4 +26,4 @@ final readonly class WorkerRegistryInitializer
logger: $logger
);
}
}
}

View File

@@ -4,9 +4,9 @@ declare(strict_types=1);
namespace App\Framework\Queue\Interfaces;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Queue\ValueObjects\LockKey;
use App\Framework\Queue\ValueObjects\WorkerId;
use App\Framework\Core\ValueObjects\Duration;
/**
* Interface für Distributed Locking System
@@ -57,4 +57,4 @@ interface DistributedLockInterface
* Lock Statistiken
*/
public function getLockStatistics(): array;
}
}

View File

@@ -4,12 +4,13 @@ declare(strict_types=1);
namespace App\Framework\Queue;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Queue\Exceptions\JobNotFoundException;
use App\Framework\Queue\ValueObjects\JobId;
use App\Framework\Queue\ValueObjects\JobState;
use App\Framework\Queue\ValueObjects\JobStatus;
use App\Framework\Queue\ValueObjects\QueueType;
use App\Framework\StateManagement\StateManager;
use App\Framework\Core\ValueObjects\Duration;
/**
* Job Persistence Layer using StateManagement
@@ -20,7 +21,8 @@ final readonly class JobPersistenceLayer
{
public function __construct(
private StateManager $stateManager
) {}
) {
}
/**
* Store a new job
@@ -74,9 +76,9 @@ final readonly class JobPersistenceLayer
*/
public function markAsProcessing(JobId $jobId): JobState
{
return $this->updateJobState($jobId, function (?JobState $state): JobState {
return $this->updateJobState($jobId, function (?JobState $state) use ($jobId): JobState {
if ($state === null) {
throw new \InvalidArgumentException("Job {$jobId->toString()} not found");
throw JobNotFoundException::byId($jobId);
}
return $state->markAsProcessing();
@@ -88,14 +90,14 @@ final readonly class JobPersistenceLayer
*/
public function markAsCompleted(JobId $jobId, array $result = []): JobState
{
return $this->updateJobState($jobId, function (?JobState $state) use ($result): JobState {
return $this->updateJobState($jobId, function (?JobState $state) use ($jobId, $result): JobState {
if ($state === null) {
throw new \InvalidArgumentException("Job {$jobId->toString()} not found");
throw JobNotFoundException::byId($jobId);
}
$completedState = $state->markAsCompleted();
if (!empty($result)) {
if (! empty($result)) {
$completedState = $completedState->withMetadata(['result' => $result]);
}
@@ -108,9 +110,9 @@ final readonly class JobPersistenceLayer
*/
public function markAsFailed(JobId $jobId, string $errorMessage, ?\Throwable $exception = null): JobState
{
return $this->updateJobState($jobId, function (?JobState $state) use ($errorMessage, $exception): JobState {
return $this->updateJobState($jobId, function (?JobState $state) use ($jobId, $errorMessage, $exception): JobState {
if ($state === null) {
throw new \InvalidArgumentException("Job {$jobId->toString()} not found");
throw JobNotFoundException::byId($jobId);
}
$failedState = $state->markAsFailed($errorMessage);
@@ -118,7 +120,7 @@ final readonly class JobPersistenceLayer
if ($exception !== null) {
$failedState = $failedState->withMetadata([
'exception_class' => get_class($exception),
'exception_trace' => $exception->getTraceAsString()
'exception_trace' => $exception->getTraceAsString(),
]);
}
@@ -131,9 +133,9 @@ final readonly class JobPersistenceLayer
*/
public function markForRetry(JobId $jobId, string $errorMessage): JobState
{
return $this->updateJobState($jobId, function (?JobState $state) use ($errorMessage): JobState {
return $this->updateJobState($jobId, function (?JobState $state) use ($jobId, $errorMessage): JobState {
if ($state === null) {
throw new \InvalidArgumentException("Job {$jobId->toString()} not found");
throw JobNotFoundException::byId($jobId);
}
return $state->markForRetry($errorMessage);
@@ -212,7 +214,7 @@ final readonly class JobPersistenceLayer
'failed' => 0,
'retrying' => 0,
'cancelled' => 0,
'expired' => 0
'expired' => 0,
];
}
@@ -239,4 +241,4 @@ final readonly class JobPersistenceLayer
{
return "job:{$jobId->toString()}";
}
}
}

View File

@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace App\Framework\Queue\MachineLearning;
use App\Framework\Config\Environment;
use App\Framework\DI\Container;
use App\Framework\DI\Attributes\Initializer;
use App\Framework\MachineLearning\ModelManagement\ModelPerformanceMonitor;
use App\Framework\MachineLearning\ModelManagement\ModelRegistry;
use App\Framework\Queue\Services\JobMetricsManagerInterface;
use App\Framework\Core\ValueObjects\Score;
use Psr\Log\LoggerInterface;
/**
* Job Anomaly Detection Initializer
*
* Registers ML-based job anomaly detection components in DI container.
*
* Registered Services:
* - JobFeatureExtractor: Extract 8 behavioral features from job sequences
* - JobAnomalyDetector: Statistical + heuristic anomaly detection
* - JobHistoryAnalyzer: Historical job pattern analysis coordinator
*
* Configuration:
* - Uses framework defaults for thresholds (customizable via environment)
* - Integrates with existing JobMetricsManager infrastructure
*/
final readonly class JobAnomalyDetectionInitializer
{
public function __construct(
private Container $container,
private Environment $environment,
private LoggerInterface $logger
) {}
#[Initializer]
public function initialize(Container $container): void
{
// Register JobFeatureExtractor
$container->singleton(JobFeatureExtractor::class, function() {
return new JobFeatureExtractor(
minConfidence: $this->getMinConfidence()
);
});
// Register JobAnomalyDetector
$container->singleton(JobAnomalyDetector::class, function() {
return new JobAnomalyDetector(
anomalyThreshold: $this->getAnomalyThreshold(),
zScoreThreshold: $this->getZScoreThreshold(),
iqrMultiplier: $this->getIqrMultiplier()
);
});
// Register JobHistoryAnalyzer
$container->singleton(JobHistoryAnalyzer::class, function(Container $container) {
return new JobHistoryAnalyzer(
metricsManager: $container->get(JobMetricsManagerInterface::class),
featureExtractor: $container->get(JobFeatureExtractor::class),
anomalyDetector: $container->get(JobAnomalyDetector::class)
);
});
}
/**
* Get minimum confidence threshold for feature extraction
*
* Default: 0.6 (60%)
* Environment: JOB_ANOMALY_MIN_CONFIDENCE
*/
private function getMinConfidence(): float
{
$envValue = getenv('JOB_ANOMALY_MIN_CONFIDENCE');
if ($envValue !== false) {
$value = (float) $envValue;
return max(0.0, min(1.0, $value)); // Clamp to 0.0-1.0
}
return 0.6;
}
/**
* Get anomaly detection threshold
*
* Default: 0.5 (50% - Score of 50)
* Environment: JOB_ANOMALY_THRESHOLD
*/
private function getAnomalyThreshold(): Score
{
$envValue = getenv('JOB_ANOMALY_THRESHOLD');
if ($envValue !== false) {
$value = (int) $envValue;
$value = max(0, min(100, $value)); // Clamp to 0-100
return new Score($value);
}
return new Score(50); // 50% threshold
}
/**
* Get Z-score threshold for statistical outlier detection
*
* Default: 3.0 (3 standard deviations)
* Environment: JOB_ANOMALY_ZSCORE_THRESHOLD
*/
private function getZScoreThreshold(): float
{
$envValue = getenv('JOB_ANOMALY_ZSCORE_THRESHOLD');
if ($envValue !== false) {
$value = (float) $envValue;
return max(1.0, min(5.0, $value)); // Clamp to 1.0-5.0
}
return 3.0;
}
/**
* Get IQR multiplier for outlier detection
*
* Default: 1.5 (standard IQR method)
* Environment: JOB_ANOMALY_IQR_MULTIPLIER
*/
private function getIqrMultiplier(): float
{
$envValue = getenv('JOB_ANOMALY_IQR_MULTIPLIER');
if ($envValue !== false) {
$value = (float) $envValue;
return max(1.0, min(3.0, $value)); // Clamp to 1.0-3.0
}
return 1.5;
}
/**
* Initialize Queue Anomaly Model Adapter for ML Model Management integration
*/
#[Initializer]
public function initializeModelAdapter(): QueueAnomalyModelAdapter
{
$this->logger->info('Initializing Queue Anomaly Model Adapter');
try {
// Get required dependencies from container
$registry = $this->container->get(ModelRegistry::class);
$performanceMonitor = $this->container->get(ModelPerformanceMonitor::class);
$anomalyDetector = $this->container->get(JobAnomalyDetector::class);
$adapter = new QueueAnomalyModelAdapter(
registry: $registry,
performanceMonitor: $performanceMonitor,
detector: $anomalyDetector
);
// Auto-register current model version if enabled
if ($this->environment->getBool('QUEUE_ML_AUTO_REGISTER', true)) {
try {
$metadata = $adapter->registerCurrentModel();
$this->logger->info('Queue anomaly model auto-registered', [
'model_name' => $metadata->modelName,
'version' => $metadata->version->toString(),
'type' => $metadata->modelType->value,
]);
} catch (\Exception $e) {
// Model might already exist, which is fine
$this->logger->debug('Queue anomaly model registration skipped', [
'reason' => $e->getMessage(),
]);
}
}
$this->logger->info('Queue Anomaly Model Adapter initialized successfully');
return $adapter;
} catch (\Throwable $e) {
$this->logger->error('Failed to initialize Queue Anomaly Model Adapter', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
throw $e;
}
}
}

View File

@@ -0,0 +1,392 @@
<?php
declare(strict_types=1);
namespace App\Framework\Queue\MachineLearning;
use App\Framework\Queue\MachineLearning\ValueObjects\JobFeatures;
use App\Framework\Queue\MachineLearning\ValueObjects\JobAnomalyResult;
use App\Framework\Core\ValueObjects\Score;
/**
* Job Anomaly Detector - Statistical and Heuristic Job Behavior Analysis
*
* Detects anomalous job execution patterns using combination of:
* - Statistical outlier detection (Z-Score, IQR methods)
* - Heuristic pattern matching (high failure risk, performance degradation, etc.)
* - Multi-feature analysis with weighted scoring
*
* Detection Methods:
* 1. Z-Score Analysis: Identifies statistical outliers (>3 standard deviations)
* 2. IQR Analysis: Identifies outliers using interquartile range (>1.5 * IQR)
* 3. Heuristic Patterns: Rule-based threat classification
* 4. Weighted Feature Scoring: Combines feature scores with domain weights
*
* All confidence scores use framework's Core Score (0.0-1.0).
*/
final readonly class JobAnomalyDetector
{
/**
* @param Score $anomalyThreshold Minimum score to classify as anomalous (default: 0.5 = 50%)
* @param float $zScoreThreshold Z-score threshold for statistical outliers (default: 3.0)
* @param float $iqrMultiplier IQR multiplier for outlier detection (default: 1.5)
*/
public function __construct(
private Score $anomalyThreshold = new Score(50), // 50% threshold
private float $zScoreThreshold = 3.0,
private float $iqrMultiplier = 1.5
) {}
/**
* Detect anomalies in job execution features
*
* Combines statistical analysis and heuristic pattern matching
* to provide comprehensive anomaly detection.
*/
public function detect(JobFeatures $features): JobAnomalyResult
{
// Step 1: Calculate feature-specific anomaly scores
$featureScores = $this->calculateFeatureScores($features);
// Step 2: Detect heuristic patterns
$detectedPatterns = $this->detectPatterns($features);
// Step 3: Calculate overall anomaly score (weighted average)
$overallScore = $this->calculateOverallScore($featureScores, $detectedPatterns);
// Step 4: Determine if anomalous based on threshold
$isAnomalous = $overallScore->getValue() >= $this->anomalyThreshold->getValue();
// Step 5: Identify primary indicator (highest scoring feature)
$primaryIndicator = $this->identifyPrimaryIndicator($featureScores);
// Step 6: Build result
if (!$isAnomalous) {
if ($overallScore->getValue() > 0) {
return JobAnomalyResult::lowConfidence(
$overallScore,
$featureScores,
'Score below anomaly threshold'
);
}
return JobAnomalyResult::normal('No anomalies detected');
}
return JobAnomalyResult::anomalous(
$overallScore,
$featureScores,
$detectedPatterns,
$primaryIndicator
);
}
/**
* Calculate anomaly score for each feature
*
* Uses statistical thresholds and domain knowledge to score
* individual feature contributions to overall anomaly.
*
* @return array<string, Score>
*/
private function calculateFeatureScores(JobFeatures $features): array
{
$featureArray = $features->toArray();
$scores = [];
foreach ($featureArray as $featureName => $value) {
// Convert feature value (0.0-1.0) to anomaly score
$anomalyScore = $this->featureValueToAnomalyScore($featureName, $value);
$scores[$featureName] = Score::fromDecimal($anomalyScore);
}
return $scores;
}
/**
* Convert feature value to anomaly score using domain-specific thresholds
*
* Different features have different "normal" ranges and criticality.
*/
private function featureValueToAnomalyScore(string $featureName, float $value): float
{
return match ($featureName) {
// Critical features: Lower threshold for anomaly
'failure_rate' => $this->scoreWithThreshold($value, 0.1, 0.3), // >10% concerning, >30% critical
'retry_frequency' => $this->scoreWithThreshold($value, 0.2, 0.5), // >20% concerning, >50% critical
'memory_usage_pattern' => $this->scoreWithThreshold($value, 0.5, 0.7), // >50% concerning, >70% critical
// Important features: Medium threshold
'execution_time_variance' => $this->scoreWithThreshold($value, 0.4, 0.6),
'queue_depth_correlation' => $this->scoreWithThreshold($value, 0.6, 0.8),
'payload_size_anomaly' => $this->scoreWithThreshold($value, 0.6, 0.8),
// Informational features: Higher threshold
'dependency_chain_complexity' => $this->scoreWithThreshold($value, 0.7, 0.9),
'execution_timing_regularity' => $this->scoreWithThreshold($value, 0.8, 0.95), // Very high regularity = bot
default => $value // Fallback: use value directly
};
}
/**
* Score feature value using low/high thresholds
*
* Linear interpolation between thresholds:
* - value <= low: 0.0 (normal)
* - low < value < high: linear scale 0.0-0.5
* - value >= high: value * 1.0 (high anomaly)
*/
private function scoreWithThreshold(float $value, float $lowThreshold, float $highThreshold): float
{
if ($value <= $lowThreshold) {
return 0.0;
}
if ($value >= $highThreshold) {
return $value; // Use value directly for high anomalies
}
// Linear interpolation between low and high threshold
$range = $highThreshold - $lowThreshold;
$position = ($value - $lowThreshold) / $range;
return $position * 0.5; // Scale to 0.0-0.5 for medium concern
}
/**
* Detect heuristic anomaly patterns
*
* Uses JobFeatures built-in indicators for pattern matching.
*
* @return array<array{type: string, confidence: Score, description: string}>
*/
private function detectPatterns(JobFeatures $features): array
{
$patterns = [];
// Pattern 1: High Failure Risk
if ($features->indicatesHighFailureRisk()) {
$confidence = $this->calculatePatternConfidence([
$features->failureRate,
$features->retryFrequency
]);
$patterns[] = [
'type' => 'high_failure_risk',
'confidence' => Score::fromDecimal($confidence),
'description' => sprintf(
'High failure rate (%.1f%%) with excessive retries (%.1f%%)',
$features->failureRate * 100,
$features->retryFrequency * 100
)
];
}
// Pattern 2: Performance Degradation
if ($features->indicatesPerformanceDegradation()) {
$confidence = $this->calculatePatternConfidence([
$features->executionTimeVariance,
$features->memoryUsagePattern
]);
$patterns[] = [
'type' => 'performance_degradation',
'confidence' => Score::fromDecimal($confidence),
'description' => sprintf(
'Unstable execution times (variance: %.1f%%) and memory patterns (%.1f%%)',
$features->executionTimeVariance * 100,
$features->memoryUsagePattern * 100
)
];
}
// Pattern 3: Resource Exhaustion
if ($features->indicatesResourceExhaustion()) {
$confidence = $this->calculatePatternConfidence([
$features->queueDepthCorrelation,
$features->memoryUsagePattern
]);
$patterns[] = [
'type' => 'resource_exhaustion',
'confidence' => Score::fromDecimal($confidence),
'description' => sprintf(
'High queue depth impact (%.1f%%) with memory anomalies (%.1f%%)',
$features->queueDepthCorrelation * 100,
$features->memoryUsagePattern * 100
)
];
}
// Pattern 4: Automated Execution (Bot-like)
if ($features->indicatesAutomatedExecution()) {
$confidence = $this->calculatePatternConfidence([
$features->executionTimingRegularity,
1.0 - $features->executionTimeVariance // Inverted: low variance = higher confidence
]);
$patterns[] = [
'type' => 'automated_execution',
'confidence' => Score::fromDecimal($confidence),
'description' => sprintf(
'Very regular timing (%.1f%%) with low variance (%.1f%%) - possible bot activity',
$features->executionTimingRegularity * 100,
$features->executionTimeVariance * 100
)
];
}
// Pattern 5: Data Processing Anomaly
if ($features->indicatesDataProcessingAnomaly()) {
$confidence = $this->calculatePatternConfidence([
$features->payloadSizeAnomaly,
$features->memoryUsagePattern
]);
$patterns[] = [
'type' => 'data_processing_anomaly',
'confidence' => Score::fromDecimal($confidence),
'description' => sprintf(
'Unusual payload sizes (%.1f%%) with memory pattern anomalies (%.1f%%)',
$features->payloadSizeAnomaly * 100,
$features->memoryUsagePattern * 100
)
];
}
return $patterns;
}
/**
* Calculate pattern confidence from contributing feature values
*
* Uses average of feature values, weighted by their strength.
*/
private function calculatePatternConfidence(array $featureValues): float
{
if (empty($featureValues)) {
return 0.0;
}
// Average of all contributing features
$average = array_sum($featureValues) / count($featureValues);
// Boost confidence if multiple strong indicators
$strongIndicators = count(array_filter($featureValues, fn($v) => $v > 0.7));
$confidenceBoost = min(0.2, $strongIndicators * 0.1);
return min(1.0, $average + $confidenceBoost);
}
/**
* Calculate overall anomaly score
*
* Weighted average of feature scores with pattern-based boosting.
*
* @param array<string, Score> $featureScores
* @param array<array{type: string, confidence: Score}> $detectedPatterns
*/
private function calculateOverallScore(array $featureScores, array $detectedPatterns): Score
{
if (empty($featureScores)) {
return Score::zero();
}
// Feature weights (domain knowledge)
$weights = [
'failure_rate' => 2.0, // Most critical
'retry_frequency' => 1.8, // Very important
'memory_usage_pattern' => 1.5, // Important for resource issues
'execution_time_variance' => 1.3, // Performance indicator
'queue_depth_correlation' => 1.2, // Scalability indicator
'payload_size_anomaly' => 1.0, // Moderate importance
'dependency_chain_complexity' => 0.8, // Less critical
'execution_timing_regularity' => 0.7, // Informational
];
// Calculate weighted feature score
$weightedSum = 0.0;
$totalWeight = 0.0;
foreach ($featureScores as $featureName => $score) {
$weight = $weights[$featureName] ?? 1.0;
$weightedSum += $score->getValue() * $weight;
$totalWeight += $weight;
}
$baseScore = $totalWeight > 0 ? $weightedSum / $totalWeight : 0.0;
// Pattern-based boosting
$patternBoost = $this->calculatePatternBoost($detectedPatterns);
// Combine base score and pattern boost (max 100%)
$finalScore = min(100.0, $baseScore + $patternBoost);
return new Score((int) round($finalScore));
}
/**
* Calculate pattern boost to overall score
*
* Multiple patterns increase confidence in anomaly detection.
*/
private function calculatePatternBoost(array $detectedPatterns): float
{
if (empty($detectedPatterns)) {
return 0.0;
}
// Each high-confidence pattern adds to the boost
$boost = 0.0;
foreach ($detectedPatterns as $pattern) {
$confidence = $pattern['confidence']->getValue();
if ($confidence >= 70) {
$boost += 10.0; // High confidence pattern: +10%
} elseif ($confidence >= 50) {
$boost += 5.0; // Medium confidence: +5%
} else {
$boost += 2.0; // Low confidence: +2%
}
}
// Cap pattern boost at 30%
return min(30.0, $boost);
}
/**
* Identify primary indicator (feature with highest anomaly score)
*/
private function identifyPrimaryIndicator(array $featureScores): string
{
if (empty($featureScores)) {
return 'unknown';
}
$maxScore = 0.0;
$primaryIndicator = 'unknown';
foreach ($featureScores as $featureName => $score) {
if ($score->getValue() > $maxScore) {
$maxScore = $score->getValue();
$primaryIndicator = $featureName;
}
}
return $primaryIndicator;
}
/**
* Get detector configuration
*/
public function getConfiguration(): array
{
return [
'anomaly_threshold' => $this->anomalyThreshold->getValue(),
'z_score_threshold' => $this->zScoreThreshold,
'iqr_multiplier' => $this->iqrMultiplier
];
}
}

View File

@@ -0,0 +1,450 @@
<?php
declare(strict_types=1);
namespace App\Framework\Queue\MachineLearning;
use App\Framework\Queue\MachineLearning\ValueObjects\JobExecutionSequence;
use App\Framework\Queue\MachineLearning\ValueObjects\JobFeatures;
/**
* Job Feature Extractor - Extract 8 Behavioral Features from Job Execution Sequences
*
* Analyzes job execution patterns and extracts normalized features
* for machine learning-based anomaly detection.
*
* Features extracted:
* 1. execution_time_variance - Execution time stability
* 2. memory_usage_pattern - Memory consumption patterns
* 3. retry_frequency - Retry attempt frequency
* 4. failure_rate - Job failure percentage
* 5. queue_depth_correlation - Queue workload impact
* 6. dependency_chain_complexity - Execution dependency complexity
* 7. payload_size_anomaly - Unusual payload sizes
* 8. execution_timing_regularity - Timing consistency
*
* All features normalized to 0.0-1.0 range.
*/
final readonly class JobFeatureExtractor
{
/**
* @param float $minConfidence Minimum confidence threshold for feature extraction (0.0-1.0)
*/
public function __construct(
private float $minConfidence = 0.6
) {
if ($minConfidence < 0.0 || $minConfidence > 1.0) {
throw new \InvalidArgumentException('minConfidence must be between 0.0 and 1.0');
}
}
/**
* Extract features from job execution sequence
*/
public function extract(JobExecutionSequence $sequence): JobFeatures
{
if ($sequence->isEmpty()) {
return JobFeatures::zero();
}
return new JobFeatures(
executionTimeVariance: $this->extractExecutionTimeVariance($sequence),
memoryUsagePattern: $this->extractMemoryUsagePattern($sequence),
retryFrequency: $this->extractRetryFrequency($sequence),
failureRate: $this->extractFailureRate($sequence),
queueDepthCorrelation: $this->extractQueueDepthCorrelation($sequence),
dependencyChainComplexity: $this->extractDependencyChainComplexity($sequence),
payloadSizeAnomaly: $this->extractPayloadSizeAnomaly($sequence),
executionTimingRegularity: $this->extractExecutionTimingRegularity($sequence)
);
}
/**
* Feature 1: Execution Time Variance (0.0-1.0)
*
* Measures stability of execution times.
* Higher values indicate inconsistent execution (possible performance issues).
*
* Calculation:
* - Coefficient of variation (CV) = standard_deviation / mean
* - Normalized using sigmoid: 1 / (1 + exp(-k * (CV - threshold)))
*/
private function extractExecutionTimeVariance(JobExecutionSequence $sequence): float
{
if ($sequence->count() < 2) {
return 0.0;
}
$mean = $sequence->getAverageExecutionTime();
if ($mean === 0.0) {
return 0.0;
}
$variance = $sequence->getExecutionTimeVariance();
$stdDev = sqrt($variance);
// Coefficient of Variation
$cv = $stdDev / $mean;
// Normalize using sigmoid (threshold at CV = 0.5)
return $this->normalize($cv, 0.0, 2.0);
}
/**
* Feature 2: Memory Usage Pattern (0.0-1.0)
*
* Detects anomalous memory consumption patterns.
* Higher values indicate unusual memory behavior.
*
* Calculation:
* - Memory variance relative to average
* - Combined with memory growth trend
*/
private function extractMemoryUsagePattern(JobExecutionSequence $sequence): float
{
if ($sequence->count() < 2) {
return 0.0;
}
$mean = $sequence->getAverageMemoryUsage();
if ($mean === 0.0) {
return 0.0;
}
$variance = $sequence->getMemoryUsageVariance();
$stdDev = sqrt($variance);
// Coefficient of Variation for memory
$cv = $stdDev / $mean;
// Check for memory growth trend (leak indicator)
$growthTrend = $this->calculateMemoryGrowthTrend($sequence);
// Combine CV and growth trend
$memoryScore = ($cv * 0.6) + ($growthTrend * 0.4);
return $this->normalize($memoryScore, 0.0, 1.5);
}
/**
* Feature 3: Retry Frequency (0.0-1.0)
*
* Measures how often jobs are retried.
* Higher values indicate reliability issues.
*/
private function extractRetryFrequency(JobExecutionSequence $sequence): float
{
return $sequence->getAverageRetryRate();
}
/**
* Feature 4: Failure Rate (0.0-1.0)
*
* Percentage of failed job executions.
* Direct indicator of job quality/reliability.
*/
private function extractFailureRate(JobExecutionSequence $sequence): float
{
return $sequence->getFailureRate();
}
/**
* Feature 5: Queue Depth Correlation (0.0-1.0)
*
* Measures correlation between queue depth and execution time.
* Higher values indicate performance degradation under load.
*
* Calculation:
* - Pearson correlation between queue depth and execution time
* - Normalized to 0-1 (abs correlation)
*/
private function extractQueueDepthCorrelation(JobExecutionSequence $sequence): float
{
if ($sequence->count() < 3) {
return 0.0;
}
$executions = $sequence->all();
// Extract queue depths and execution times
$queueDepths = [];
$executionTimes = [];
foreach ($executions as $exec) {
$queueDepths[] = (float) $exec->queueDepth;
$executionTimes[] = $exec->executionTimeMs;
}
// Calculate Pearson correlation
$correlation = $this->calculatePearsonCorrelation($queueDepths, $executionTimes);
// Return absolute correlation (0-1)
return abs($correlation);
}
/**
* Feature 6: Dependency Chain Complexity (0.0-1.0)
*
* Estimates complexity of job dependencies.
* Higher values indicate complex execution patterns.
*
* Heuristic based on:
* - Number of unique queues (more queues = more complex dependencies)
* - Execution frequency variation
*/
private function extractDependencyChainComplexity(JobExecutionSequence $sequence): float
{
if ($sequence->isEmpty()) {
return 0.0;
}
$uniqueQueues = count($sequence->getUniqueQueues());
// More queues = potentially more complex dependencies
$queueComplexity = $this->normalize((float) $uniqueQueues, 1.0, 10.0);
// Execution frequency variation as complexity indicator
$frequencyVariation = $this->calculateExecutionFrequencyVariation($sequence);
// Combine indicators
return ($queueComplexity * 0.5) + ($frequencyVariation * 0.5);
}
/**
* Feature 7: Payload Size Anomaly (0.0-1.0)
*
* Detects unusual payload sizes.
* Higher values indicate data anomalies.
*
* Calculation:
* - Coefficient of variation for payload sizes
* - Outlier detection using IQR method
*/
private function extractPayloadSizeAnomaly(JobExecutionSequence $sequence): float
{
if ($sequence->count() < 3) {
return 0.0;
}
$executions = $sequence->all();
// Extract payload sizes
$payloadSizes = array_map(
fn($exec) => (float) $exec->payloadSizeBytes,
$executions
);
// Filter out zeros
$payloadSizes = array_filter($payloadSizes, fn($size) => $size > 0);
if (empty($payloadSizes)) {
return 0.0;
}
// Calculate CV
$mean = array_sum($payloadSizes) / count($payloadSizes);
if ($mean === 0.0) {
return 0.0;
}
$variance = 0.0;
foreach ($payloadSizes as $size) {
$variance += ($size - $mean) ** 2;
}
$variance /= count($payloadSizes);
$stdDev = sqrt($variance);
$cv = $stdDev / $mean;
// Normalize CV
return $this->normalize($cv, 0.0, 2.0);
}
/**
* Feature 8: Execution Timing Regularity (0.0-1.0)
*
* Measures consistency of execution timing intervals.
* Higher values indicate very regular patterns (possible automated/bot execution).
*
* Calculation:
* - Calculate variance of inter-execution times
* - Lower variance = higher regularity
*/
private function extractExecutionTimingRegularity(JobExecutionSequence $sequence): float
{
if ($sequence->count() < 3) {
return 0.0;
}
$executions = $sequence->all();
// Calculate inter-execution times
$intervals = [];
for ($i = 1; $i < count($executions); $i++) {
$prev = $executions[$i - 1];
$curr = $executions[$i];
$interval = $curr->createdAt->toFloat() - $prev->createdAt->toFloat();
$intervals[] = $interval;
}
if (empty($intervals)) {
return 0.0;
}
// Calculate variance of intervals
$mean = array_sum($intervals) / count($intervals);
if ($mean === 0.0) {
return 0.0;
}
$variance = 0.0;
foreach ($intervals as $interval) {
$variance += ($interval - $mean) ** 2;
}
$variance /= count($intervals);
// Coefficient of variation
$stdDev = sqrt($variance);
$cv = $stdDev / $mean;
// Invert CV: low variance = high regularity
// Perfect regularity (CV = 0) → 1.0
// High variance (CV > 1) → 0.0
return max(0.0, 1.0 - $cv);
}
/**
* Calculate memory growth trend
*/
private function calculateMemoryGrowthTrend(JobExecutionSequence $sequence): float
{
$executions = $sequence->all();
if (count($executions) < 3) {
return 0.0;
}
// Simple linear regression slope for memory usage
$n = count($executions);
$sumX = 0;
$sumY = 0;
$sumXY = 0;
$sumX2 = 0;
foreach ($executions as $i => $exec) {
$x = (float) $i;
$y = (float) $exec->memoryUsageBytes;
$sumX += $x;
$sumY += $y;
$sumXY += $x * $y;
$sumX2 += $x * $x;
}
$denominator = ($n * $sumX2) - ($sumX * $sumX);
if ($denominator === 0.0) {
return 0.0;
}
$slope = (($n * $sumXY) - ($sumX * $sumY)) / $denominator;
// Normalize slope relative to average memory
$avgMemory = $sequence->getAverageMemoryUsage();
if ($avgMemory === 0.0) {
return 0.0;
}
$normalizedSlope = abs($slope) / $avgMemory;
return $this->normalize($normalizedSlope, 0.0, 0.5);
}
/**
* Calculate Pearson correlation coefficient
*/
private function calculatePearsonCorrelation(array $x, array $y): float
{
$n = count($x);
if ($n === 0 || count($y) !== $n) {
return 0.0;
}
$sumX = array_sum($x);
$sumY = array_sum($y);
$sumXY = 0;
$sumX2 = 0;
$sumY2 = 0;
for ($i = 0; $i < $n; $i++) {
$sumXY += $x[$i] * $y[$i];
$sumX2 += $x[$i] * $x[$i];
$sumY2 += $y[$i] * $y[$i];
}
$numerator = ($n * $sumXY) - ($sumX * $sumY);
$denominator = sqrt((($n * $sumX2) - ($sumX * $sumX)) * (($n * $sumY2) - ($sumY * $sumY)));
if ($denominator === 0.0) {
return 0.0;
}
return $numerator / $denominator;
}
/**
* Calculate execution frequency variation
*/
private function calculateExecutionFrequencyVariation(JobExecutionSequence $sequence): float
{
if ($sequence->count() < 3) {
return 0.0;
}
$executions = $sequence->all();
// Calculate inter-execution times
$intervals = [];
for ($i = 1; $i < count($executions); $i++) {
$prev = $executions[$i - 1];
$curr = $executions[$i];
$interval = $curr->createdAt->toFloat() - $prev->createdAt->toFloat();
$intervals[] = $interval;
}
if (empty($intervals)) {
return 0.0;
}
// Calculate CV
$mean = array_sum($intervals) / count($intervals);
if ($mean === 0.0) {
return 0.0;
}
$variance = 0.0;
foreach ($intervals as $interval) {
$variance += ($interval - $mean) ** 2;
}
$variance /= count($intervals);
$stdDev = sqrt($variance);
$cv = $stdDev / $mean;
return $this->normalize($cv, 0.0, 2.0);
}
/**
* Normalize value to 0.0-1.0 range
*/
private function normalize(float $value, float $min, float $max): float
{
if ($max === $min) {
return 0.0;
}
$normalized = ($value - $min) / ($max - $min);
return max(0.0, min(1.0, $normalized));
}
}

View File

@@ -0,0 +1,424 @@
<?php
declare(strict_types=1);
namespace App\Framework\Queue\MachineLearning;
use App\Framework\Queue\MachineLearning\ValueObjects\JobExecutionContext;
use App\Framework\Queue\MachineLearning\ValueObjects\JobExecutionSequence;
use App\Framework\Queue\MachineLearning\ValueObjects\JobFeatures;
use App\Framework\Queue\MachineLearning\ValueObjects\JobAnomalyResult;
use App\Framework\Queue\Services\JobMetricsManagerInterface;
use App\Framework\Queue\ValueObjects\JobMetrics;
use App\Framework\Core\ValueObjects\Duration;
/**
* Job History Analyzer - Historical Job Pattern Analysis for Anomaly Detection
*
* Coordinates the ML-based job anomaly detection pipeline by:
* 1. Fetching job execution history from JobMetricsManager
* 2. Converting to JobExecutionSequence
* 3. Extracting behavioral features
* 4. Detecting anomalies using statistical and heuristic methods
*
* Analysis Capabilities:
* - Single job analysis (job-specific patterns)
* - Queue-wide analysis (queue health monitoring)
* - Time-window analysis (temporal pattern detection)
* - Batch analysis (multiple jobs/queues simultaneously)
*
* Integration:
* - Uses existing JobMetricsManager for data access
* - Leverages JobFeatureExtractor for feature engineering
* - Employs JobAnomalyDetector for anomaly classification
*/
final readonly class JobHistoryAnalyzer
{
public function __construct(
private JobMetricsManagerInterface $metricsManager,
private JobFeatureExtractor $featureExtractor,
private JobAnomalyDetector $anomalyDetector
) {}
/**
* Analyze job execution history for specific job
*
* @param string $jobId Job identifier
* @param Duration $timeWindow Analysis time window (default: last 1 hour)
*/
public function analyzeJob(string $jobId, ?Duration $timeWindow = null): JobAnomalyResult
{
$timeWindow ??= Duration::fromHours(1);
// Fetch job metrics
$metrics = $this->metricsManager->getJobMetrics($jobId);
if ($metrics === null) {
return JobAnomalyResult::normal('Job not found in metrics database');
}
// Create sequence from single job (repeated executions if exists)
$sequence = $this->buildSequenceFromJobMetrics([$metrics]);
if ($sequence->isEmpty()) {
return JobAnomalyResult::lowConfidence(
new \App\Framework\Core\ValueObjects\Score(0),
[],
'Insufficient job execution history'
);
}
// Extract features and detect anomalies
return $this->performAnalysis($sequence);
}
/**
* Analyze all jobs in a queue for anomalies
*
* @param string $queueName Queue name to analyze
* @param Duration $timeWindow Analysis time window (default: last 1 hour)
*/
public function analyzeQueue(string $queueName, ?Duration $timeWindow = null): JobAnomalyResult
{
$timeWindow ??= Duration::fromHours(1);
// Get queue performance stats (includes all jobs in time window)
$stats = $this->metricsManager->getPerformanceStats($queueName, $this->durationToTimeWindow($timeWindow));
if (empty($stats)) {
return JobAnomalyResult::normal('No queue metrics available');
}
// Build execution sequence from queue stats
$sequence = $this->buildSequenceFromQueueStats($queueName, $timeWindow);
if ($sequence->isEmpty()) {
return JobAnomalyResult::lowConfidence(
new \App\Framework\Core\ValueObjects\Score(0),
[],
'Insufficient queue execution history'
);
}
// Extract features and detect anomalies
return $this->performAnalysis($sequence);
}
/**
* Analyze failed jobs in a queue
*
* @param string|null $queueName Queue name (null = all queues)
* @param Duration $timeWindow Analysis time window (default: last 1 hour)
*/
public function analyzeFailedJobs(?string $queueName = null, ?Duration $timeWindow = null): JobAnomalyResult
{
$timeWindow ??= Duration::fromHours(1);
// Get failed jobs
$failedJobs = $this->metricsManager->getFailedJobs($queueName, $this->durationToTimeWindow($timeWindow));
if (empty($failedJobs)) {
return JobAnomalyResult::normal('No failed jobs in time window');
}
// Convert to execution sequence
$sequence = $this->buildSequenceFromJobMetrics($failedJobs);
if ($sequence->isEmpty()) {
return JobAnomalyResult::lowConfidence(
new \App\Framework\Core\ValueObjects\Score(0),
[],
'Insufficient failed job data'
);
}
// Extract features and detect anomalies
return $this->performAnalysis($sequence);
}
/**
* Batch analyze multiple queues
*
* @param array<string> $queueNames Queue names to analyze
* @param Duration $timeWindow Analysis time window
* @return array<string, JobAnomalyResult>
*/
public function analyzeMultipleQueues(array $queueNames, ?Duration $timeWindow = null): array
{
$timeWindow ??= Duration::fromHours(1);
$results = [];
foreach ($queueNames as $queueName) {
$results[$queueName] = $this->analyzeQueue($queueName, $timeWindow);
}
return $results;
}
/**
* Get queue health summary
*
* Analyzes queue and provides actionable health insights.
*
* @return array{
* queue_name: string,
* health_status: string,
* anomaly_result: JobAnomalyResult,
* metrics_summary: array,
* recommendations: array<string>
* }
*/
public function getQueueHealthSummary(string $queueName, ?Duration $timeWindow = null): array
{
$timeWindow ??= Duration::fromHours(1);
// Analyze queue for anomalies
$anomalyResult = $this->analyzeQueue($queueName, $timeWindow);
// Get queue metrics for summary
$queueMetrics = $this->metricsManager->getQueueMetrics($queueName, $this->durationToTimeWindow($timeWindow));
// Determine health status
$healthStatus = $this->determineHealthStatus($anomalyResult);
// Generate recommendations
$recommendations = $this->generateRecommendations($anomalyResult, $queueMetrics);
return [
'queue_name' => $queueName,
'health_status' => $healthStatus,
'anomaly_result' => $anomalyResult,
'metrics_summary' => [
'total_jobs' => $queueMetrics->getTotalJobs(),
'completion_rate' => $queueMetrics->getCompletionRate()->getValue(),
'average_execution_time' => $queueMetrics->getAverageExecutionTime(),
'peak_memory_usage' => $queueMetrics->getPeakMemoryUsage()
],
'recommendations' => $recommendations
];
}
/**
* Core analysis pipeline: Extract features → Detect anomalies
*/
private function performAnalysis(JobExecutionSequence $sequence): JobAnomalyResult
{
// Extract behavioral features
$features = $this->featureExtractor->extract($sequence);
// Detect anomalies
return $this->anomalyDetector->detect($features);
}
/**
* Build JobExecutionSequence from JobMetrics array
*
* @param array<JobMetrics> $jobMetricsList
*/
private function buildSequenceFromJobMetrics(array $jobMetricsList): JobExecutionSequence
{
if (empty($jobMetricsList)) {
return JobExecutionSequence::empty();
}
$executions = [];
foreach ($jobMetricsList as $metrics) {
// Convert JobMetrics to JobExecutionContext
$executions[] = JobExecutionContext::fromJobMetrics($metrics);
}
return JobExecutionSequence::fromExecutions($executions);
}
/**
* Build JobExecutionSequence from queue statistics
*
* Note: This is a heuristic approach when individual job metrics aren't available.
* For detailed analysis, use job-level metrics.
*/
private function buildSequenceFromQueueStats(string $queueName, Duration $timeWindow): JobExecutionSequence
{
// Get queue metrics
$queueMetrics = $this->metricsManager->getQueueMetrics($queueName, $this->durationToTimeWindow($timeWindow));
// For queue-wide analysis, we aggregate all jobs executed in the time window
// This is less granular than job-level analysis but useful for queue health monitoring
// Fetch performance stats which includes job-level details
$performanceStats = $this->metricsManager->getPerformanceStats($queueName, $this->durationToTimeWindow($timeWindow));
if (empty($performanceStats)) {
return JobExecutionSequence::empty();
}
// Extract job executions from performance stats
// Performance stats typically include: execution_times, memory_usage, failure_count, etc.
$executions = [];
// If performance stats contain individual job data, convert to JobExecutionContext
// Otherwise, create synthetic executions from aggregated data
if (isset($performanceStats['individual_jobs'])) {
foreach ($performanceStats['individual_jobs'] as $jobData) {
$executions[] = $this->createExecutionContextFromArray($jobData);
}
} else {
// Fallback: Create synthetic execution context from aggregated stats
$executions[] = $this->createSyntheticExecutionContext($queueName, $queueMetrics, $performanceStats);
}
return JobExecutionSequence::fromExecutions($executions);
}
/**
* Create JobExecutionContext from array data
*/
private function createExecutionContextFromArray(array $data): JobExecutionContext
{
return new JobExecutionContext(
jobId: $data['job_id'] ?? 'unknown',
queueName: $data['queue_name'] ?? 'unknown',
status: $data['status'] ?? 'completed',
attempts: $data['attempts'] ?? 1,
maxAttempts: $data['max_attempts'] ?? 3,
executionTimeMs: $data['execution_time_ms'] ?? 0.0,
memoryUsageBytes: $data['memory_usage_bytes'] ?? 0,
errorMessage: $data['error_message'] ?? null,
createdAt: \App\Framework\Core\ValueObjects\Timestamp::fromString($data['created_at'] ?? 'now'),
startedAt: isset($data['started_at']) ? \App\Framework\Core\ValueObjects\Timestamp::fromString($data['started_at']) : null,
completedAt: isset($data['completed_at']) ? \App\Framework\Core\ValueObjects\Timestamp::fromString($data['completed_at']) : null,
failedAt: isset($data['failed_at']) ? \App\Framework\Core\ValueObjects\Timestamp::fromString($data['failed_at']) : null,
queueDepth: $data['queue_depth'] ?? 0,
payloadSizeBytes: $data['payload_size_bytes'] ?? 0,
metadata: $data['metadata'] ?? []
);
}
/**
* Create synthetic JobExecutionContext from aggregated queue metrics
*
* Used when individual job data is not available.
*/
private function createSyntheticExecutionContext(
string $queueName,
\App\Framework\Queue\ValueObjects\QueueMetrics $queueMetrics,
array $performanceStats
): JobExecutionContext {
$completionRate = $queueMetrics->getCompletionRate()->getValue();
$status = $completionRate > 80 ? 'completed' : 'failed';
return new JobExecutionContext(
jobId: 'aggregated',
queueName: $queueName,
status: $status,
attempts: 1,
maxAttempts: 3,
executionTimeMs: $queueMetrics->getAverageExecutionTime(),
memoryUsageBytes: $queueMetrics->getPeakMemoryUsage(),
errorMessage: $status === 'failed' ? 'Aggregated failure data' : null,
createdAt: \App\Framework\Core\ValueObjects\Timestamp::now(),
startedAt: \App\Framework\Core\ValueObjects\Timestamp::now(),
completedAt: $status === 'completed' ? \App\Framework\Core\ValueObjects\Timestamp::now() : null,
failedAt: $status === 'failed' ? \App\Framework\Core\ValueObjects\Timestamp::now() : null,
queueDepth: $performanceStats['queue_depth'] ?? 0,
payloadSizeBytes: $performanceStats['avg_payload_size'] ?? 0,
metadata: [
'synthetic' => true,
'total_jobs' => $queueMetrics->getTotalJobs(),
'completion_rate' => $completionRate
]
);
}
/**
* Determine health status from anomaly result
*/
private function determineHealthStatus(JobAnomalyResult $result): string
{
if (!$result->isAnomalous) {
return 'healthy';
}
return match ($result->getSeverity()) {
'critical' => 'critical',
'high' => 'degraded',
'medium' => 'warning',
default => 'monitoring'
};
}
/**
* Generate actionable recommendations based on analysis
*
* @return array<string>
*/
private function generateRecommendations(
JobAnomalyResult $result,
\App\Framework\Queue\ValueObjects\QueueMetrics $queueMetrics
): array {
$recommendations = [];
if (!$result->isAnomalous) {
$recommendations[] = 'Queue is healthy - continue monitoring';
return $recommendations;
}
// Pattern-specific recommendations
if ($result->hasPattern('high_failure_risk')) {
$recommendations[] = 'Investigate job logic and error handling';
$recommendations[] = 'Review failed job error messages for patterns';
$recommendations[] = 'Consider implementing circuit breaker pattern';
}
if ($result->hasPattern('performance_degradation')) {
$recommendations[] = 'Analyze job execution bottlenecks';
$recommendations[] = 'Check for N+1 query problems or inefficient algorithms';
$recommendations[] = 'Consider job payload optimization';
}
if ($result->hasPattern('resource_exhaustion')) {
$recommendations[] = 'Scale infrastructure or optimize job resource usage';
$recommendations[] = 'Implement job batching to reduce queue pressure';
$recommendations[] = 'Monitor worker capacity and scaling policies';
}
if ($result->hasPattern('automated_execution')) {
$recommendations[] = 'Verify job submission source and authentication';
$recommendations[] = 'Implement rate limiting for job submission endpoints';
$recommendations[] = 'Review API access logs for suspicious patterns';
}
if ($result->hasPattern('data_processing_anomaly')) {
$recommendations[] = 'Validate job payload structure and size constraints';
$recommendations[] = 'Implement payload sanitization and validation';
$recommendations[] = 'Review data sources for integrity issues';
}
// Queue metrics-based recommendations
$completionRate = $queueMetrics->getCompletionRate()->getValue();
if ($completionRate < 50) {
$recommendations[] = 'Critical: Completion rate below 50% - immediate investigation required';
}
return $recommendations;
}
/**
* Convert Duration to time window string
*/
private function durationToTimeWindow(Duration $duration): string
{
$seconds = $duration->toSeconds();
if ($seconds >= 86400) {
return (int)($seconds / 86400) . 'days';
}
if ($seconds >= 3600) {
return (int)($seconds / 3600) . 'hours';
}
return (int)($seconds / 60) . 'minutes';
}
}

View File

@@ -0,0 +1,215 @@
<?php
declare(strict_types=1);
namespace App\Framework\Queue\MachineLearning;
use App\Framework\MachineLearning\ModelManagement\ModelRegistry;
use App\Framework\MachineLearning\ModelManagement\ModelPerformanceMonitor;
use App\Framework\MachineLearning\ModelManagement\ValueObjects\ModelMetadata;
use App\Framework\Core\ValueObjects\Version;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Queue\MachineLearning\ValueObjects\JobFeatures;
use App\Framework\Queue\MachineLearning\ValueObjects\JobAnomalyResult;
/**
* Queue Job Anomaly Detection Model Management Adapter
*
* Integrates JobAnomalyDetector with the ML Model Management System:
* - Automatic model registration
* - Real-time performance tracking
* - Prediction monitoring
* - Configuration management
*
* Usage:
* ```php
* $adapter = new QueueAnomalyModelAdapter($registry, $performanceMonitor, $detector);
*
* // Register current model version
* $adapter->registerCurrentModel();
*
* // Analyze with tracking
* $result = $adapter->analyzeWithTracking($features, $groundTruth);
* ```
*/
final readonly class QueueAnomalyModelAdapter
{
private const MODEL_NAME = 'queue-anomaly';
private const CURRENT_VERSION = '1.0.0';
public function __construct(
private ModelRegistry $registry,
private ModelPerformanceMonitor $performanceMonitor,
private JobAnomalyDetector $detector
) {}
/**
* Register current queue anomaly model in registry
*/
public function registerCurrentModel(?array $performanceMetrics = null): ModelMetadata
{
$version = Version::fromString(self::CURRENT_VERSION);
// Check if already registered
if ($this->registry->exists(self::MODEL_NAME, $version)) {
return $this->registry->get(self::MODEL_NAME, $version);
}
// Create metadata
$metadata = ModelMetadata::forQueueAnomaly(
version: $version,
configuration: $this->detector->getConfiguration()
);
// Add performance metrics if provided
if ($performanceMetrics !== null) {
$metadata = $metadata->withPerformanceMetrics($performanceMetrics);
}
// Register in registry
$this->registry->register($metadata);
return $metadata;
}
/**
* Analyze job features with automatic performance tracking
*
* @param JobFeatures $features Job execution features
* @param bool|null $groundTruth Ground truth (if known) - true if job is anomalous
*
* @return array Analysis result with tracking info
*/
public function analyzeWithTracking(
JobFeatures $features,
?bool $groundTruth = null
): array {
// Perform ML analysis
$analysisResult = $this->detector->detect($features);
// Determine prediction
$prediction = $analysisResult->isAnomalous;
$confidence = $analysisResult->anomalyScore->getValue() / 100.0; // Convert 0-100 to 0.0-1.0
// Track prediction in performance monitor
$this->performanceMonitor->trackPrediction(
modelName: self::MODEL_NAME,
version: Version::fromString(self::CURRENT_VERSION),
prediction: $prediction,
actual: $groundTruth,
confidence: $confidence,
features: $this->extractFeatureSummary($analysisResult)
);
// Convert result to array format
$resultArray = [
'is_anomalous' => $analysisResult->isAnomalous,
'anomaly_score' => $analysisResult->anomalyScore->getValue(),
'feature_scores' => array_map(
fn($score) => $score->getValue(),
$analysisResult->featureScores
),
'detected_patterns' => $analysisResult->detectedPatterns,
'primary_indicator' => $analysisResult->primaryIndicator,
'success' => true
];
// Add tracking info
$resultArray['tracking'] = [
'model_name' => self::MODEL_NAME,
'model_version' => self::CURRENT_VERSION,
'prediction' => $prediction ? 'anomalous' : 'normal',
'ground_truth' => $groundTruth,
'tracked' => true,
];
return $resultArray;
}
/**
* Get current model performance metrics
*/
public function getCurrentPerformanceMetrics(): array
{
return $this->performanceMonitor->getCurrentMetrics(
self::MODEL_NAME,
Version::fromString(self::CURRENT_VERSION)
);
}
/**
* Check if model performance has degraded
*/
public function checkPerformanceDegradation(float $thresholdPercent = 0.05): array
{
return $this->performanceMonitor->getPerformanceDegradationInfo(
self::MODEL_NAME,
Version::fromString(self::CURRENT_VERSION),
$thresholdPercent
);
}
/**
* Update model configuration in registry
*/
public function updateConfiguration(array $newConfiguration): void
{
$version = Version::fromString(self::CURRENT_VERSION);
$metadata = $this->registry->get(self::MODEL_NAME, $version);
if ($metadata === null) {
throw new \RuntimeException(
'Model not registered. Call registerCurrentModel() first.'
);
}
$updated = $metadata->withConfiguration($newConfiguration);
$this->registry->update($updated);
}
/**
* Deploy current model to production
*/
public function deployToProduction(): void
{
$version = Version::fromString(self::CURRENT_VERSION);
$metadata = $this->registry->get(self::MODEL_NAME, $version);
if ($metadata === null) {
throw new \RuntimeException(
'Model not registered. Call registerCurrentModel() first.'
);
}
$deployed = $metadata->withDeployment(
environment: 'production',
deployedAt: Timestamp::now()
);
$this->registry->update($deployed);
}
/**
* Get model metadata
*/
public function getModelMetadata(): ?ModelMetadata
{
return $this->registry->get(
self::MODEL_NAME,
Version::fromString(self::CURRENT_VERSION)
);
}
/**
* Extract feature summary for tracking
*/
private function extractFeatureSummary(JobAnomalyResult $result): array
{
return [
'feature_count' => count($result->featureScores),
'pattern_count' => count($result->detectedPatterns),
'primary_indicator' => $result->primaryIndicator,
'is_anomalous' => $result->isAnomalous,
];
}
}

View File

@@ -0,0 +1,360 @@
<?php
declare(strict_types=1);
namespace App\Framework\Queue\MachineLearning\ValueObjects;
use App\Framework\Core\ValueObjects\Score;
/**
* Job Anomaly Detection Result - ML-Based Job Behavior Analysis Result
*
* Immutable value object containing job anomaly detection results
* using framework's Core Score for confidence scoring.
*
* Features:
* - Core Score integration for anomaly confidence (0.0-1.0)
* - Pattern-based anomaly classification
* - Feature contribution analysis
* - Severity assessment and recommended actions
* - Detailed metadata for investigation
*
* Used by: JobAnomalyDetector for unified anomaly detection results
*/
final readonly class JobAnomalyResult
{
/**
* @param Score $anomalyScore Framework Core Score (0.0-1.0) representing anomaly confidence
* @param bool $isAnomalous Whether the job execution is classified as anomalous
* @param array<string, Score> $featureScores Individual feature contribution scores
* @param array<array{type: string, confidence: Score, description: string}> $detectedPatterns Detected anomaly patterns
* @param string $primaryIndicator Primary feature indicating anomaly
* @param array<string, mixed> $metadata Additional context and debugging information
*/
public function __construct(
public Score $anomalyScore,
public bool $isAnomalous,
public array $featureScores,
public array $detectedPatterns,
public string $primaryIndicator,
public array $metadata = []
) {}
/**
* Create result for normal (non-anomalous) job execution
*/
public static function normal(string $reason): self
{
return new self(
anomalyScore: Score::zero(),
isAnomalous: false,
featureScores: [],
detectedPatterns: [],
primaryIndicator: 'none',
metadata: ['reason' => $reason]
);
}
/**
* Create result for low confidence detection (inconclusive)
*/
public static function lowConfidence(
Score $score,
array $featureScores,
string $reason = 'Insufficient data or low confidence'
): self {
return new self(
anomalyScore: $score,
isAnomalous: false,
featureScores: $featureScores,
detectedPatterns: [],
primaryIndicator: 'unknown',
metadata: [
'reason' => $reason,
'confidence_level' => 'low'
]
);
}
/**
* Create result for detected anomaly
*
* @param Score $score Overall anomaly confidence score
* @param array<string, Score> $featureScores Individual feature scores
* @param array<array{type: string, confidence: Score, description: string}> $detectedPatterns Detected patterns
* @param string $primaryIndicator Primary anomaly indicator feature
*/
public static function anomalous(
Score $score,
array $featureScores,
array $detectedPatterns,
string $primaryIndicator
): self {
return new self(
anomalyScore: $score,
isAnomalous: true,
featureScores: $featureScores,
detectedPatterns: $detectedPatterns,
primaryIndicator: $primaryIndicator,
metadata: [
'detection_method' => 'statistical_and_heuristic',
'pattern_count' => count($detectedPatterns)
]
);
}
/**
* Get severity level based on anomaly score
*
* - critical: Score >= 0.8 (immediate action required)
* - high: Score >= 0.6 (investigate soon)
* - medium: Score >= 0.4 (monitor closely)
* - low: Score < 0.4 (awareness)
*/
public function getSeverity(): string
{
if (!$this->isAnomalous) {
return 'none';
}
return match (true) {
$this->anomalyScore->getValue() >= 80 => 'critical',
$this->anomalyScore->getValue() >= 60 => 'high',
$this->anomalyScore->getValue() >= 40 => 'medium',
default => 'low'
};
}
/**
* Get recommended action based on severity and patterns
*/
public function getRecommendedAction(): string
{
if (!$this->isAnomalous) {
return 'No action required';
}
$severity = $this->getSeverity();
// Pattern-specific recommendations
foreach ($this->detectedPatterns as $pattern) {
$type = $pattern['type'];
if ($type === 'high_failure_risk') {
return match ($severity) {
'critical' => 'Immediate investigation required - High failure rate with excessive retries',
'high' => 'Investigate job logic and error handling',
default => 'Monitor failure patterns and retry frequency'
};
}
if ($type === 'performance_degradation') {
return match ($severity) {
'critical' => 'Critical performance issue - Check for resource bottlenecks',
'high' => 'Investigate execution time variance and memory patterns',
default => 'Monitor performance metrics'
};
}
if ($type === 'resource_exhaustion') {
return match ($severity) {
'critical' => 'Resource exhaustion detected - Scale infrastructure or optimize jobs',
'high' => 'Review queue depth impact and memory usage',
default => 'Monitor resource utilization trends'
};
}
if ($type === 'automated_execution') {
return match ($severity) {
'critical' => 'Potential bot activity - Verify job submission source',
'high' => 'Investigate automated job submission patterns',
default => 'Monitor timing regularity'
};
}
if ($type === 'data_processing_anomaly') {
return match ($severity) {
'critical' => 'Data anomaly detected - Verify payload integrity and processing logic',
'high' => 'Investigate payload size variations and memory patterns',
default => 'Monitor data processing patterns'
};
}
}
// Generic recommendations by severity
return match ($severity) {
'critical' => 'Immediate investigation required',
'high' => 'Investigate anomaly patterns',
'medium' => 'Monitor closely',
default => 'Review and monitor'
};
}
/**
* Get top contributing features to the anomaly
*
* @param int $limit Maximum number of contributors to return
* @return array<array{feature: string, score: Score, contribution_percentage: float}>
*/
public function getTopContributors(int $limit = 3): array
{
if (empty($this->featureScores)) {
return [];
}
// Calculate total score for percentage calculation
$totalScore = array_reduce(
$this->featureScores,
fn(float $carry, Score $score) => $carry + $score->getValue(),
0.0
);
if ($totalScore === 0.0) {
return [];
}
// Sort features by score descending
$sorted = $this->featureScores;
uasort($sorted, fn(Score $a, Score $b) => $b->getValue() <=> $a->getValue());
// Take top N and calculate contribution percentages
$contributors = [];
$count = 0;
foreach ($sorted as $feature => $score) {
if ($count >= $limit) {
break;
}
$contributors[] = [
'feature' => $feature,
'score' => $score,
'contribution_percentage' => ($score->getValue() / $totalScore) * 100.0
];
$count++;
}
return $contributors;
}
/**
* Check if result has specific pattern type
*/
public function hasPattern(string $patternType): bool
{
foreach ($this->detectedPatterns as $pattern) {
if ($pattern['type'] === $patternType) {
return true;
}
}
return false;
}
/**
* Get pattern by type
*
* @return array{type: string, confidence: Score, description: string}|null
*/
public function getPattern(string $patternType): ?array
{
foreach ($this->detectedPatterns as $pattern) {
if ($pattern['type'] === $patternType) {
return $pattern;
}
}
return null;
}
/**
* Get all pattern types
*
* @return array<string>
*/
public function getPatternTypes(): array
{
return array_map(
fn(array $pattern) => $pattern['type'],
$this->detectedPatterns
);
}
/**
* Check if anomaly requires immediate attention
*/
public function requiresImmediateAttention(): bool
{
return $this->isAnomalous && $this->getSeverity() === 'critical';
}
/**
* Get confidence level as string
*/
public function getConfidenceLevel(): string
{
$value = $this->anomalyScore->getValue();
return match (true) {
$value >= 80 => 'very_high',
$value >= 60 => 'high',
$value >= 40 => 'medium',
$value >= 20 => 'low',
default => 'very_low'
};
}
/**
* Convert to array for serialization
*
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'anomaly_score' => $this->anomalyScore->getValue(),
'is_anomalous' => $this->isAnomalous,
'severity' => $this->getSeverity(),
'confidence_level' => $this->getConfidenceLevel(),
'primary_indicator' => $this->primaryIndicator,
'detected_patterns' => array_map(
fn(array $pattern) => [
'type' => $pattern['type'],
'confidence' => $pattern['confidence']->getValue(),
'description' => $pattern['description']
],
$this->detectedPatterns
),
'feature_scores' => array_map(
fn(Score $score) => $score->getValue(),
$this->featureScores
),
'top_contributors' => $this->getTopContributors(3),
'recommended_action' => $this->getRecommendedAction(),
'requires_immediate_attention' => $this->requiresImmediateAttention(),
'metadata' => $this->metadata
];
}
/**
* Create debugging representation
*/
public function __toString(): string
{
if (!$this->isAnomalous) {
return 'JobAnomalyResult[Normal]';
}
$score = $this->anomalyScore->getValue();
$severity = $this->getSeverity();
$patterns = implode(', ', $this->getPatternTypes());
return sprintf(
'JobAnomalyResult[Anomalous, Score=%.2f, Severity=%s, Patterns=%s]',
$score,
$severity,
$patterns ?: 'none'
);
}
}

View File

@@ -0,0 +1,257 @@
<?php
declare(strict_types=1);
namespace App\Framework\Queue\MachineLearning\ValueObjects;
use App\Framework\Queue\ValueObjects\JobMetrics;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Core\ValueObjects\Duration;
/**
* Job Execution Context - Metadata for Job Analysis
*
* Immutable value object containing execution metadata
* for job anomaly detection analysis.
*
* Features:
* - Execution timing and performance metrics
* - Retry and failure information
* - Queue depth and workload context
* - Memory and resource usage patterns
*
* Used by: JobFeatureExtractor for behavioral analysis
*/
final readonly class JobExecutionContext
{
/**
* @param string $jobId Job identifier
* @param string $queueName Queue name
* @param string $status Current job status (pending, running, completed, failed)
* @param int $attempts Number of execution attempts
* @param int $maxAttempts Maximum allowed attempts
* @param float $executionTimeMs Execution time in milliseconds
* @param int $memoryUsageBytes Memory usage in bytes
* @param ?string $errorMessage Error message if failed
* @param Timestamp $createdAt Job creation timestamp
* @param ?Timestamp $startedAt Job start timestamp
* @param ?Timestamp $completedAt Job completion timestamp
* @param ?Timestamp $failedAt Job failure timestamp
* @param int $queueDepth Current queue depth (jobs waiting)
* @param int $payloadSizeBytes Estimated job payload size
* @param array $metadata Additional metadata
*/
public function __construct(
public string $jobId,
public string $queueName,
public string $status,
public int $attempts,
public int $maxAttempts,
public float $executionTimeMs,
public int $memoryUsageBytes,
public ?string $errorMessage,
public Timestamp $createdAt,
public ?Timestamp $startedAt,
public ?Timestamp $completedAt,
public ?Timestamp $failedAt,
public int $queueDepth = 0,
public int $payloadSizeBytes = 0,
public array $metadata = []
) {}
/**
* Create from JobMetrics value object
*/
public static function fromJobMetrics(
JobMetrics $metrics,
int $queueDepth = 0,
int $payloadSizeBytes = 0
): self {
return new self(
jobId: $metrics->jobId,
queueName: $metrics->queueName,
status: $metrics->status,
attempts: $metrics->attempts,
maxAttempts: $metrics->maxAttempts,
executionTimeMs: $metrics->executionTimeMs,
memoryUsageBytes: $metrics->memoryUsageBytes,
errorMessage: $metrics->errorMessage,
createdAt: Timestamp::fromString($metrics->createdAt),
startedAt: $metrics->startedAt ? Timestamp::fromString($metrics->startedAt) : null,
completedAt: $metrics->completedAt ? Timestamp::fromString($metrics->completedAt) : null,
failedAt: $metrics->failedAt ? Timestamp::fromString($metrics->failedAt) : null,
queueDepth: $queueDepth,
payloadSizeBytes: $payloadSizeBytes,
metadata: $metrics->metadata
);
}
/**
* Check if job completed successfully
*/
public function isCompleted(): bool
{
return $this->status === 'completed';
}
/**
* Check if job failed
*/
public function isFailed(): bool
{
return $this->status === 'failed';
}
/**
* Check if job is currently running
*/
public function isRunning(): bool
{
return $this->status === 'running';
}
/**
* Check if job is pending
*/
public function isPending(): bool
{
return $this->status === 'pending';
}
/**
* Check if job exceeded retry attempts
*/
public function hasExceededRetries(): bool
{
return $this->attempts >= $this->maxAttempts;
}
/**
* Get retry rate (attempts / maxAttempts)
*/
public function getRetryRate(): float
{
if ($this->maxAttempts === 0) {
return 0.0;
}
return min(1.0, $this->attempts / $this->maxAttempts);
}
/**
* Get execution duration
*/
public function getExecutionDuration(): ?Duration
{
if ($this->startedAt === null) {
return null;
}
$endTimestamp = $this->completedAt ?? $this->failedAt ?? Timestamp::now();
$durationSeconds = $endTimestamp->toFloat() - $this->startedAt->toFloat();
return Duration::fromSeconds((int) $durationSeconds);
}
/**
* Get queue time (time from creation to start)
*/
public function getQueueTime(): ?Duration
{
if ($this->startedAt === null) {
return null;
}
$queueTimeSeconds = $this->startedAt->toFloat() - $this->createdAt->toFloat();
return Duration::fromSeconds((int) $queueTimeSeconds);
}
/**
* Get execution time in seconds
*/
public function getExecutionTimeSeconds(): float
{
return $this->executionTimeMs / 1000.0;
}
/**
* Get memory usage in megabytes
*/
public function getMemoryUsageMB(): float
{
return $this->memoryUsageBytes / (1024 * 1024);
}
/**
* Get payload size in kilobytes
*/
public function getPayloadSizeKB(): float
{
return $this->payloadSizeBytes / 1024;
}
/**
* Check if job has error message
*/
public function hasError(): bool
{
return $this->errorMessage !== null;
}
/**
* Get metadata value by key
*/
public function getMetadata(string $key, mixed $default = null): mixed
{
return $this->metadata[$key] ?? $default;
}
/**
* Convert to array for serialization
*/
public function toArray(): array
{
return [
'job_id' => $this->jobId,
'queue_name' => $this->queueName,
'status' => $this->status,
'attempts' => $this->attempts,
'max_attempts' => $this->maxAttempts,
'execution_time_ms' => $this->executionTimeMs,
'execution_time_seconds' => $this->getExecutionTimeSeconds(),
'memory_usage_bytes' => $this->memoryUsageBytes,
'memory_usage_mb' => $this->getMemoryUsageMB(),
'error_message' => $this->errorMessage,
'created_at' => $this->createdAt->format('Y-m-d H:i:s'),
'started_at' => $this->startedAt?->format('Y-m-d H:i:s'),
'completed_at' => $this->completedAt?->format('Y-m-d H:i:s'),
'failed_at' => $this->failedAt?->format('Y-m-d H:i:s'),
'queue_depth' => $this->queueDepth,
'payload_size_bytes' => $this->payloadSizeBytes,
'payload_size_kb' => $this->getPayloadSizeKB(),
'retry_rate' => $this->getRetryRate(),
'execution_duration_seconds' => $this->getExecutionDuration()?->toSeconds(),
'queue_time_seconds' => $this->getQueueTime()?->toSeconds(),
'metadata' => $this->metadata,
];
}
/**
* Create debugging representation
*/
public function __toString(): string
{
return sprintf(
'JobExecution[%s, queue=%s, status=%s, attempts=%d/%d, time=%.2fms, memory=%.2fMB]',
$this->jobId,
$this->queueName,
$this->status,
$this->attempts,
$this->maxAttempts,
$this->executionTimeMs,
$this->getMemoryUsageMB()
);
}
}

View File

@@ -0,0 +1,396 @@
<?php
declare(strict_types=1);
namespace App\Framework\Queue\MachineLearning\ValueObjects;
use App\Framework\Core\ValueObjects\Duration;
/**
* Job Execution Sequence - Collection of Job Executions
*
* Immutable collection of job execution contexts for
* behavioral pattern analysis and anomaly detection.
*
* Features:
* - Time-ordered job execution history
* - Statistical aggregations
* - Performance trend analysis
* - Failure pattern detection
*
* Used by: JobFeatureExtractor for sequence-based analysis
*/
final readonly class JobExecutionSequence
{
/** @param array<JobExecutionContext> $executions */
public function __construct(
private array $executions
) {}
/**
* Create from array of JobExecutionContexts
*
* @param array<JobExecutionContext> $executions
*/
public static function fromExecutions(array $executions): self
{
// Sort by created timestamp (oldest first)
$sorted = $executions;
usort($sorted, function (JobExecutionContext $a, JobExecutionContext $b) {
return $a->createdAt->toFloat() <=> $b->createdAt->toFloat();
});
return new self($sorted);
}
/**
* Create empty sequence
*/
public static function empty(): self
{
return new self([]);
}
/**
* Get number of executions in sequence
*/
public function count(): int
{
return count($this->executions);
}
/**
* Check if sequence is empty
*/
public function isEmpty(): bool
{
return empty($this->executions);
}
/**
* Get all executions
*
* @return array<JobExecutionContext>
*/
public function all(): array
{
return $this->executions;
}
/**
* Get execution by index
*/
public function get(int $index): ?JobExecutionContext
{
return $this->executions[$index] ?? null;
}
/**
* Get first execution
*/
public function first(): ?JobExecutionContext
{
return $this->executions[0] ?? null;
}
/**
* Get last execution
*/
public function last(): ?JobExecutionContext
{
$count = count($this->executions);
return $count > 0 ? $this->executions[$count - 1] : null;
}
/**
* Filter executions by status
*/
public function filterByStatus(string $status): self
{
$filtered = array_filter(
$this->executions,
fn(JobExecutionContext $exec) => $exec->status === $status
);
return new self(array_values($filtered));
}
/**
* Filter failed executions
*/
public function filterFailed(): self
{
return $this->filterByStatus('failed');
}
/**
* Filter completed executions
*/
public function filterCompleted(): self
{
return $this->filterByStatus('completed');
}
/**
* Filter executions within time window
*/
public function filterByTimeWindow(Duration $window): self
{
$now = time();
$windowSeconds = $window->toSeconds();
$filtered = array_filter(
$this->executions,
fn(JobExecutionContext $exec) => ($now - $exec->createdAt->toFloat()) <= $windowSeconds
);
return new self(array_values($filtered));
}
/**
* Filter executions by queue name
*/
public function filterByQueue(string $queueName): self
{
$filtered = array_filter(
$this->executions,
fn(JobExecutionContext $exec) => $exec->queueName === $queueName
);
return new self(array_values($filtered));
}
/**
* Get average execution time (milliseconds)
*/
public function getAverageExecutionTime(): float
{
if ($this->isEmpty()) {
return 0.0;
}
$total = array_sum(array_map(
fn(JobExecutionContext $exec) => $exec->executionTimeMs,
$this->executions
));
return $total / $this->count();
}
/**
* Get average memory usage (bytes)
*/
public function getAverageMemoryUsage(): float
{
if ($this->isEmpty()) {
return 0.0;
}
$total = array_sum(array_map(
fn(JobExecutionContext $exec) => $exec->memoryUsageBytes,
$this->executions
));
return $total / $this->count();
}
/**
* Get failure rate (0.0 - 1.0)
*/
public function getFailureRate(): float
{
if ($this->isEmpty()) {
return 0.0;
}
$failedCount = count($this->filterFailed()->executions);
return $failedCount / $this->count();
}
/**
* Get completion rate (0.0 - 1.0)
*/
public function getCompletionRate(): float
{
if ($this->isEmpty()) {
return 1.0;
}
$completedCount = count($this->filterCompleted()->executions);
return $completedCount / $this->count();
}
/**
* Get average retry rate
*/
public function getAverageRetryRate(): float
{
if ($this->isEmpty()) {
return 0.0;
}
$total = array_sum(array_map(
fn(JobExecutionContext $exec) => $exec->getRetryRate(),
$this->executions
));
return $total / $this->count();
}
/**
* Get execution time variance (for regularity detection)
*/
public function getExecutionTimeVariance(): float
{
if ($this->count() < 2) {
return 0.0;
}
$mean = $this->getAverageExecutionTime();
$squaredDifferences = array_map(
fn(JobExecutionContext $exec) => ($exec->executionTimeMs - $mean) ** 2,
$this->executions
);
return array_sum($squaredDifferences) / $this->count();
}
/**
* Get memory usage variance
*/
public function getMemoryUsageVariance(): float
{
if ($this->count() < 2) {
return 0.0;
}
$mean = $this->getAverageMemoryUsage();
$squaredDifferences = array_map(
fn(JobExecutionContext $exec) => ($exec->memoryUsageBytes - $mean) ** 2,
$this->executions
);
return array_sum($squaredDifferences) / $this->count();
}
/**
* Get unique queue names
*
* @return array<string>
*/
public function getUniqueQueues(): array
{
$queues = array_map(
fn(JobExecutionContext $exec) => $exec->queueName,
$this->executions
);
return array_values(array_unique($queues));
}
/**
* Get time span (duration from first to last execution)
*/
public function getTimeSpan(): ?Duration
{
if ($this->count() < 2) {
return null;
}
$first = $this->first();
$last = $this->last();
if ($first === null || $last === null) {
return null;
}
$spanSeconds = $last->createdAt->toFloat() - $first->createdAt->toFloat();
return Duration::fromSeconds((int) $spanSeconds);
}
/**
* Get execution frequency (executions per hour)
*/
public function getExecutionFrequency(): float
{
if ($this->isEmpty()) {
return 0.0;
}
$timeSpan = $this->getTimeSpan();
if ($timeSpan === null || $timeSpan->toSeconds() === 0) {
return 0.0;
}
$hours = $timeSpan->toSeconds() / 3600.0;
return $this->count() / $hours;
}
/**
* Get statistics summary
*/
public function getStatistics(): array
{
return [
'total_executions' => $this->count(),
'completed' => count($this->filterCompleted()->executions),
'failed' => count($this->filterFailed()->executions),
'completion_rate' => $this->getCompletionRate(),
'failure_rate' => $this->getFailureRate(),
'average_execution_time_ms' => $this->getAverageExecutionTime(),
'average_memory_usage_bytes' => $this->getAverageMemoryUsage(),
'average_memory_usage_mb' => $this->getAverageMemoryUsage() / (1024 * 1024),
'average_retry_rate' => $this->getAverageRetryRate(),
'execution_time_variance' => $this->getExecutionTimeVariance(),
'memory_usage_variance' => $this->getMemoryUsageVariance(),
'unique_queues' => count($this->getUniqueQueues()),
'time_span_seconds' => $this->getTimeSpan()?->toSeconds(),
'execution_frequency_per_hour' => $this->getExecutionFrequency(),
];
}
/**
* Merge with another sequence
*/
public function merge(self $other): self
{
$merged = array_merge($this->executions, $other->executions);
return self::fromExecutions($merged);
}
/**
* Take first N executions
*/
public function take(int $limit): self
{
return new self(array_slice($this->executions, 0, $limit));
}
/**
* Take last N executions
*/
public function takeLast(int $limit): self
{
return new self(array_slice($this->executions, -$limit));
}
/**
* Convert to array
*
* @return array<array>
*/
public function toArray(): array
{
return array_map(
fn(JobExecutionContext $exec) => $exec->toArray(),
$this->executions
);
}
}

View File

@@ -0,0 +1,271 @@
<?php
declare(strict_types=1);
namespace App\Framework\Queue\MachineLearning\ValueObjects;
/**
* Job Features - 8-Dimensional Feature Vector for Job Anomaly Detection
*
* Extracted behavioral features from job execution sequences
* for machine learning-based anomaly detection.
*
* Features (all normalized to 0.0-1.0):
* 1. execution_time_variance - Variance in execution times (stability)
* 2. memory_usage_pattern - Memory consumption pattern (resource efficiency)
* 3. retry_frequency - How often jobs retry (reliability indicator)
* 4. failure_rate - Percentage of failed executions (quality indicator)
* 5. queue_depth_correlation - Job volume impact on performance
* 6. dependency_chain_complexity - Estimated execution dependencies
* 7. payload_size_anomaly - Unusual payload sizes (data anomaly)
* 8. execution_timing_regularity - Execution time consistency (bot detection)
*
* Immutable value object following framework patterns.
*/
final readonly class JobFeatures
{
/**
* @param float $executionTimeVariance Execution time variance (0.0-1.0, higher = more unstable)
* @param float $memoryUsagePattern Memory usage pattern score (0.0-1.0, higher = more anomalous)
* @param float $retryFrequency Retry frequency (0.0-1.0, higher = more retries)
* @param float $failureRate Failure rate (0.0-1.0)
* @param float $queueDepthCorrelation Queue depth impact on performance (0.0-1.0)
* @param float $dependencyChainComplexity Dependency complexity estimate (0.0-1.0)
* @param float $payloadSizeAnomaly Payload size anomaly score (0.0-1.0)
* @param float $executionTimingRegularity Execution timing regularity (0.0-1.0, higher = more regular/bot-like)
*/
public function __construct(
public float $executionTimeVariance,
public float $memoryUsagePattern,
public float $retryFrequency,
public float $failureRate,
public float $queueDepthCorrelation,
public float $dependencyChainComplexity,
public float $payloadSizeAnomaly,
public float $executionTimingRegularity
) {
// Validation: All features must be normalized (0.0-1.0)
foreach ($this->toArray() as $name => $value) {
if ($value < 0.0 || $value > 1.0) {
throw new \InvalidArgumentException(
sprintf('Feature "%s" must be normalized (0.0-1.0), got: %.4f', $name, $value)
);
}
}
}
/**
* Create features with all zeros (baseline)
*/
public static function zero(): self
{
return new self(
executionTimeVariance: 0.0,
memoryUsagePattern: 0.0,
retryFrequency: 0.0,
failureRate: 0.0,
queueDepthCorrelation: 0.0,
dependencyChainComplexity: 0.0,
payloadSizeAnomaly: 0.0,
executionTimingRegularity: 0.0
);
}
/**
* Get feature names in order
*
* @return array<string>
*/
public static function getFeatureNames(): array
{
return [
'execution_time_variance',
'memory_usage_pattern',
'retry_frequency',
'failure_rate',
'queue_depth_correlation',
'dependency_chain_complexity',
'payload_size_anomaly',
'execution_timing_regularity',
];
}
/**
* Convert to feature vector array
*
* @return array<float>
*/
public function toVector(): array
{
return [
$this->executionTimeVariance,
$this->memoryUsagePattern,
$this->retryFrequency,
$this->failureRate,
$this->queueDepthCorrelation,
$this->dependencyChainComplexity,
$this->payloadSizeAnomaly,
$this->executionTimingRegularity,
];
}
/**
* Convert to associative array
*
* @return array<string, float>
*/
public function toArray(): array
{
return [
'execution_time_variance' => $this->executionTimeVariance,
'memory_usage_pattern' => $this->memoryUsagePattern,
'retry_frequency' => $this->retryFrequency,
'failure_rate' => $this->failureRate,
'queue_depth_correlation' => $this->queueDepthCorrelation,
'dependency_chain_complexity' => $this->dependencyChainComplexity,
'payload_size_anomaly' => $this->payloadSizeAnomaly,
'execution_timing_regularity' => $this->executionTimingRegularity,
];
}
/**
* Calculate Euclidean distance to other feature vector
*/
public function distanceTo(self $other): float
{
$sumSquaredDifferences = 0.0;
$thisVector = $this->toVector();
$otherVector = $other->toVector();
for ($i = 0; $i < 8; $i++) {
$diff = $thisVector[$i] - $otherVector[$i];
$sumSquaredDifferences += $diff * $diff;
}
return sqrt($sumSquaredDifferences);
}
/**
* Calculate Manhattan distance to other feature vector
*/
public function manhattanDistanceTo(self $other): float
{
$sumAbsDifferences = 0.0;
$thisVector = $this->toVector();
$otherVector = $other->toVector();
for ($i = 0; $i < 8; $i++) {
$sumAbsDifferences += abs($thisVector[$i] - $otherVector[$i]);
}
return $sumAbsDifferences;
}
/**
* Check if features indicate high failure risk
* (heuristic: high failure rate + high retry frequency)
*/
public function indicatesHighFailureRisk(): bool
{
return $this->failureRate > 0.3 && $this->retryFrequency > 0.5;
}
/**
* Check if features indicate performance degradation
* (heuristic: high execution variance + memory pattern anomaly)
*/
public function indicatesPerformanceDegradation(): bool
{
return $this->executionTimeVariance > 0.6 && $this->memoryUsagePattern > 0.6;
}
/**
* Check if features indicate resource exhaustion
* (heuristic: high queue depth correlation + memory anomaly)
*/
public function indicatesResourceExhaustion(): bool
{
return $this->queueDepthCorrelation > 0.7 && $this->memoryUsagePattern > 0.7;
}
/**
* Check if features indicate automated/bot-like execution
* (heuristic: very high timing regularity + low variance)
*/
public function indicatesAutomatedExecution(): bool
{
return $this->executionTimingRegularity > 0.9 && $this->executionTimeVariance < 0.1;
}
/**
* Check if features indicate data processing anomaly
* (heuristic: high payload size anomaly + high memory pattern)
*/
public function indicatesDataProcessingAnomaly(): bool
{
return $this->payloadSizeAnomaly > 0.7 && $this->memoryUsagePattern > 0.6;
}
/**
* Get anomaly indicators as array
*
* @return array<string, bool>
*/
public function getAnomalyIndicators(): array
{
return [
'high_failure_risk' => $this->indicatesHighFailureRisk(),
'performance_degradation' => $this->indicatesPerformanceDegradation(),
'resource_exhaustion' => $this->indicatesResourceExhaustion(),
'automated_execution' => $this->indicatesAutomatedExecution(),
'data_processing_anomaly' => $this->indicatesDataProcessingAnomaly(),
];
}
/**
* Get highest feature value and name
*
* @return array{name: string, value: float}
*/
public function getHighestFeature(): array
{
$features = $this->toArray();
$maxValue = max($features);
$maxName = array_search($maxValue, $features, true);
return [
'name' => (string) $maxName,
'value' => $maxValue,
];
}
/**
* Get top N features by value
*
* @return array<string, float>
*/
public function getTopFeatures(int $n = 3): array
{
$features = $this->toArray();
arsort($features);
return array_slice($features, 0, $n, true);
}
/**
* Create debugging representation
*/
public function __toString(): string
{
$top = $this->getTopFeatures(3);
$topStr = implode(', ', array_map(
fn($name, $value) => sprintf('%s=%.2f', $name, $value),
array_keys($top),
$top
));
return sprintf('JobFeatures[%s]', $topStr);
}
}

View File

@@ -71,4 +71,4 @@ final readonly class CreateDeadLetterJobsTable implements Migration
{
return "Framework";
}
}
}

View File

@@ -7,12 +7,18 @@ namespace App\Framework\Queue\Migrations;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Migration\Migration;
use App\Framework\Database\Migration\MigrationVersion;
use App\Framework\Database\Migration\SafelyReversible;
use App\Framework\Database\Schema\Schema;
/**
* Migration für Distributed Locks Tabelle
*
* This migration is safely reversible because:
* - Creates a new table (can be dropped without data loss)
* - No existing data is affected
* - No destructive operations
*/
final readonly class CreateDistributedLocksTable implements Migration
final readonly class CreateDistributedLocksTable implements Migration, SafelyReversible
{
public function up(ConnectionInterface $connection): void
{
@@ -59,4 +65,4 @@ final readonly class CreateDistributedLocksTable implements Migration
{
return "Framework";
}
}
}

View File

@@ -34,7 +34,7 @@ final readonly class CreateFailoverEventsTable implements Migration
'worker_failure',
'job_reassignment',
'recovery_completed',
'manual_failover'
'manual_failover',
]);
// Zusätzliche Event-Daten
@@ -76,4 +76,4 @@ final readonly class CreateFailoverEventsTable implements Migration
{
return "Framework";
}
}
}

View File

@@ -62,4 +62,4 @@ final readonly class CreateJobAssignmentsTable implements Migration
{
return "Framework";
}
}
}

View File

@@ -71,4 +71,4 @@ final readonly class CreateJobChainsTable implements Migration
{
return "Framework";
}
}
}

View File

@@ -69,4 +69,4 @@ final readonly class CreateJobDependenciesTable implements Migration
{
return "Framework";
}
}
}

View File

@@ -63,4 +63,4 @@ final readonly class CreateJobHistoryTable implements Migration
{
return "Framework";
}
}
}

View File

@@ -66,4 +66,4 @@ final readonly class CreateJobIndexTable implements Migration
{
return "Framework";
}
}
}

View File

@@ -80,4 +80,4 @@ final readonly class CreateJobMetricsTable implements Migration
{
return "Framework";
}
}
}

View File

@@ -68,4 +68,4 @@ final readonly class CreateJobProgressTable implements Migration
{
return "Framework";
}
}
}

View File

@@ -75,4 +75,4 @@ final readonly class CreateQueueWorkersTable implements Migration
{
return "Framework";
}
}
}

View File

@@ -69,4 +69,4 @@ final readonly class CreateWorkerHealthChecksTable implements Migration
{
return "Framework";
}
}
}

View File

@@ -67,4 +67,4 @@ final class Migration_2024_10_02_140000_CreateJobBatchesTable implements Migrati
{
return 'Create job_batches table for tracking batch job processing';
}
}
}

View File

@@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Framework\Queue;
use App\Framework\Queue\ValueObjects\JobPayload;
use App\Framework\Queue\ValueObjects\JobId;
use App\Framework\Queue\ValueObjects\JobPayload;
use App\Framework\Queue\ValueObjects\QueueType;
/**
@@ -19,7 +19,8 @@ final readonly class PersistentQueue implements Queue
private Queue $baseQueue,
private JobPersistenceLayer $persistence,
private QueueType $queueType
) {}
) {
}
/**
* {@inheritdoc}
@@ -62,6 +63,7 @@ final readonly class PersistentQueue implements Queue
} catch (\Throwable $e) {
// If we can't mark as processing, put it back in queue
$this->baseQueue->push($payload);
throw $e;
}
}
@@ -108,7 +110,7 @@ final readonly class PersistentQueue implements Queue
return array_merge($baseStats, [
'persistence' => $persistenceStats,
'queue_type' => $this->queueType->value
'queue_type' => $this->queueType->value,
]);
}
@@ -199,4 +201,4 @@ final readonly class PersistentQueue implements Queue
{
return $this->persistence->cleanup($olderThan);
}
}
}

View File

@@ -5,17 +5,18 @@ declare(strict_types=1);
namespace App\Framework\Queue;
use App\Framework\Cache\Cache;
use App\Framework\Cache\CacheKey;
use App\Framework\Cache\CacheItem;
use App\Framework\Cache\CacheKey;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Database\EntityManager;
use App\Framework\Queue\Entities\JobIndexEntry;
use App\Framework\Queue\Entities\JobHistoryEntry;
use App\Framework\Queue\Entities\JobIndexEntry;
use App\Framework\Queue\Exceptions\JobNotFoundException;
use App\Framework\Queue\ValueObjects\JobId;
use App\Framework\Queue\ValueObjects\JobState;
use App\Framework\Queue\ValueObjects\JobStatus;
use App\Framework\Queue\ValueObjects\QueueType;
use App\Framework\StateManagement\StateManager;
use App\Framework\Core\ValueObjects\Duration;
/**
* Production Job Persistence Layer
@@ -28,7 +29,8 @@ final readonly class ProductionJobPersistenceLayer
private StateManager $stateManager, // Hot data (active jobs)
private EntityManager $entityManager, // Persistent storage + queries
private Cache $cache // Query results cache
) {}
) {
}
/**
* Store a new job
@@ -57,6 +59,7 @@ final readonly class ProductionJobPersistenceLayer
// 2. Store in database (persistent indexing)
$this->entityManager->beginTransaction();
try {
$indexEntry = JobIndexEntry::fromJobState($jobState);
$this->entityManager->persist($indexEntry);
@@ -74,6 +77,7 @@ final readonly class ProductionJobPersistenceLayer
$this->entityManager->commit();
} catch (\Exception $e) {
$this->entityManager->rollback();
throw $e;
}
@@ -120,7 +124,7 @@ final readonly class ProductionJobPersistenceLayer
$currentState = $this->getJobState($jobId);
if ($currentState === null) {
throw new \InvalidArgumentException("Job {$jobId->toString()} not found");
throw JobNotFoundException::byId($jobId);
}
$oldStatus = $currentState->status;
@@ -134,6 +138,7 @@ final readonly class ProductionJobPersistenceLayer
);
$this->entityManager->beginTransaction();
try {
// Update index entry
$indexEntry = $this->entityManager->find(JobIndexEntry::class, $jobId->toString());
@@ -157,6 +162,7 @@ final readonly class ProductionJobPersistenceLayer
$this->entityManager->commit();
} catch (\Exception $e) {
$this->entityManager->rollback();
throw $e;
}
@@ -174,9 +180,9 @@ final readonly class ProductionJobPersistenceLayer
*/
public function markAsProcessing(JobId $jobId): JobState
{
return $this->updateJobState($jobId, function (?JobState $state): JobState {
return $this->updateJobState($jobId, function (?JobState $state) use ($jobId): JobState {
if ($state === null) {
throw new \InvalidArgumentException("Job {$jobId->toString()} not found");
throw JobNotFoundException::byId($jobId);
}
return $state->markAsProcessing();
@@ -188,14 +194,14 @@ final readonly class ProductionJobPersistenceLayer
*/
public function markAsCompleted(JobId $jobId, array $result = []): JobState
{
return $this->updateJobState($jobId, function (?JobState $state) use ($result): JobState {
return $this->updateJobState($jobId, function (?JobState $state) use ($jobId, $result): JobState {
if ($state === null) {
throw new \InvalidArgumentException("Job {$jobId->toString()} not found");
throw JobNotFoundException::byId($jobId);
}
$completedState = $state->markAsCompleted();
if (!empty($result)) {
if (! empty($result)) {
$completedState = $completedState->withMetadata(['result' => $result]);
}
@@ -208,9 +214,9 @@ final readonly class ProductionJobPersistenceLayer
*/
public function markAsFailed(JobId $jobId, string $errorMessage, ?\Throwable $exception = null): JobState
{
return $this->updateJobState($jobId, function (?JobState $state) use ($errorMessage, $exception): JobState {
return $this->updateJobState($jobId, function (?JobState $state) use ($jobId, $errorMessage, $exception): JobState {
if ($state === null) {
throw new \InvalidArgumentException("Job {$jobId->toString()} not found");
throw JobNotFoundException::byId($jobId);
}
$failedState = $state->markAsFailed($errorMessage);
@@ -218,7 +224,7 @@ final readonly class ProductionJobPersistenceLayer
if ($exception !== null) {
$failedState = $failedState->withMetadata([
'exception_class' => get_class($exception),
'exception_trace' => $exception->getTraceAsString()
'exception_trace' => $exception->getTraceAsString(),
]);
}
@@ -231,9 +237,9 @@ final readonly class ProductionJobPersistenceLayer
*/
public function markForRetry(JobId $jobId, string $errorMessage): JobState
{
return $this->updateJobState($jobId, function (?JobState $state) use ($errorMessage): JobState {
return $this->updateJobState($jobId, function (?JobState $state) use ($jobId, $errorMessage): JobState {
if ($state === null) {
throw new \InvalidArgumentException("Job {$jobId->toString()} not found");
throw JobNotFoundException::byId($jobId);
}
return $state->markForRetry($errorMessage);
@@ -269,7 +275,7 @@ final readonly class ProductionJobPersistenceLayer
->getQuery()
->getResult();
$jobIds = array_map(fn($entry) => $entry->jobId->toString(), $indexEntries);
$jobIds = array_map(fn ($entry) => $entry->jobId->toString(), $indexEntries);
// Cache for 30 seconds
$cacheItem = CacheItem::forSetting(
@@ -312,7 +318,7 @@ final readonly class ProductionJobPersistenceLayer
->addOrderBy('j.createdAt', 'ASC')
->setParameter('retryable_statuses', [
JobStatus::FAILED->value,
JobStatus::RETRYING->value
JobStatus::RETRYING->value,
])
->setMaxResults($limit)
->getQuery()
@@ -349,7 +355,7 @@ final readonly class ProductionJobPersistenceLayer
->setParameter('final_statuses', [
JobStatus::COMPLETED->value,
JobStatus::CANCELLED->value,
JobStatus::EXPIRED->value
JobStatus::EXPIRED->value,
])
->setParameter('cutoff', $cutoffTimestamp)
->getQuery()
@@ -387,7 +393,7 @@ final readonly class ProductionJobPersistenceLayer
->getArrayResult();
$stats = array_fill_keys([
'pending', 'processing', 'completed', 'failed', 'retrying', 'cancelled', 'expired'
'pending', 'processing', 'completed', 'failed', 'retrying', 'cancelled', 'expired',
], 0);
foreach ($result as $row) {
@@ -420,6 +426,7 @@ final readonly class ProductionJobPersistenceLayer
// Check database
$indexEntry = $this->entityManager->find(JobIndexEntry::class, $jobId->toString());
return $indexEntry !== null;
}
@@ -433,6 +440,7 @@ final readonly class ProductionJobPersistenceLayer
// Remove from database
$this->entityManager->beginTransaction();
try {
$indexEntry = $this->entityManager->find(JobIndexEntry::class, $jobId->toString());
if ($indexEntry !== null) {
@@ -443,6 +451,7 @@ final readonly class ProductionJobPersistenceLayer
$this->entityManager->commit();
} catch (\Exception $e) {
$this->entityManager->rollback();
throw $e;
}
}
@@ -463,9 +472,9 @@ final readonly class ProductionJobPersistenceLayer
$cacheKeys = [
CacheKey::fromString("jobs_by_status:{$status->value}:50"),
CacheKey::fromString("jobs_by_status:{$status->value}:100"),
CacheKey::fromString('job_stats')
CacheKey::fromString('job_stats'),
];
$this->cache->forget(...$cacheKeys);
}
}
}

View File

@@ -4,18 +4,18 @@ declare(strict_types=1);
namespace App\Framework\Queue;
use App\Framework\DI\Initializer;
use App\Framework\Core\Events\EventDispatcherInterface;
use App\Framework\Database\EntityManagerInterface;
use App\Framework\DI\Container;
use App\Framework\Queue\Contracts\JobDependencyManagerInterface;
use App\Framework\DI\Initializer;
use App\Framework\Logging\Logger;
use App\Framework\Queue\Contracts\JobChainManagerInterface;
use App\Framework\Queue\Services\DatabaseJobDependencyManager;
use App\Framework\Queue\Contracts\JobDependencyManagerInterface;
use App\Framework\Queue\Services\DatabaseJobChainManager;
use App\Framework\Queue\Services\DatabaseJobDependencyManager;
use App\Framework\Queue\Services\DependencyResolutionEngine;
use App\Framework\Queue\Services\JobChainExecutionCoordinator;
use App\Framework\Queue\Services\JobMetricsManager;
use App\Framework\Database\EntityManagerInterface;
use App\Framework\Logging\Logger;
use App\Framework\Core\Events\EventDispatcherInterface;
final readonly class QueueDependencyInitializer
{
@@ -38,7 +38,7 @@ final readonly class QueueDependencyInitializer
$container->instance(DatabaseJobChainManager::class, $chainManager);
// Register other services as lazy factories
$container->singleton(DependencyResolutionEngine::class, function(Container $container) {
$container->singleton(DependencyResolutionEngine::class, function (Container $container) {
return new DependencyResolutionEngine(
dependencyManager: $container->get(JobDependencyManagerInterface::class),
chainManager: $container->get(JobChainManagerInterface::class),
@@ -47,7 +47,7 @@ final readonly class QueueDependencyInitializer
);
});
$container->singleton(JobChainExecutionCoordinator::class, function(Container $container) {
$container->singleton(JobChainExecutionCoordinator::class, function (Container $container) {
return new JobChainExecutionCoordinator(
chainManager: $container->get(JobChainManagerInterface::class),
dependencyManager: $container->get(JobDependencyManagerInterface::class),
@@ -58,7 +58,7 @@ final readonly class QueueDependencyInitializer
);
});
$container->singleton(JobMetricsManager::class, function(Container $container) {
$container->singleton(JobMetricsManager::class, function (Container $container) {
return new JobMetricsManager(
entityManager: $container->get(EntityManagerInterface::class),
logger: $container->get(Logger::class)
@@ -67,4 +67,4 @@ final readonly class QueueDependencyInitializer
return $dependencyManager;
}
}
}

View File

@@ -8,6 +8,7 @@ use App\Framework\Core\PathProvider;
use App\Framework\DI\Initializer;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
use App\Framework\Queue\Exceptions\RedisExtensionNotLoadedException;
use App\Framework\Redis\RedisConfig;
use App\Framework\Redis\RedisConnection;
@@ -24,7 +25,7 @@ final readonly class QueueInitializer
try {
// Check if Redis extension is available
if (! extension_loaded('redis')) {
throw new \RuntimeException('Redis extension not loaded');
throw RedisExtensionNotLoadedException::notLoaded();
}
$redisConfig = new RedisConfig(

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Framework\Queue;
use App\Framework\Queue\ValueObjects\JobPayload;
use App\Framework\Queue\ValueObjects\QueuePriority;
use App\Framework\Redis\RedisConnectionInterface;
use App\Framework\Serializer\Php\PhpSerializer;
use App\Framework\Serializer\Serializer;
@@ -19,8 +18,11 @@ use Redis;
final class RedisQueue implements Queue
{
private Redis $redis;
private string $priorityKey;
private string $delayedKey;
private string $statsKey;
public function __construct(
@@ -61,12 +63,13 @@ final class RedisQueue implements Queue
// Pop highest priority job (lowest score in Redis sorted set)
$result = $this->redis->zPopMin($this->priorityKey);
if (!empty($result)) {
if (! empty($result)) {
$serializedPayload = array_key_first($result);
$payload = $this->serializer->unserialize($serializedPayload);
if ($payload instanceof JobPayload) {
$this->updateStats('popped', $payload->priority->toString());
return $payload;
}
}
@@ -82,7 +85,7 @@ final class RedisQueue implements Queue
// Peek at highest priority job
$result = $this->redis->zRange($this->priorityKey, 0, 0);
if (!empty($result)) {
if (! empty($result)) {
$serializedPayload = $result[0];
$payload = $this->serializer->unserialize($serializedPayload);
@@ -134,7 +137,7 @@ final class RedisQueue implements Queue
'priority_queue_size' => $this->redis->zCard($this->priorityKey),
'delayed_queue_size' => $this->redis->zCard($this->delayedKey),
'priority_breakdown' => $priorityBreakdown,
'stats' => $processedStats
'stats' => $processedStats,
];
}
@@ -225,6 +228,7 @@ final class RedisQueue implements Queue
public function getReadyDelayedCount(): int
{
$currentTime = time();
return $this->redis->zCount($this->delayedKey, 0, $currentTime);
}
@@ -234,6 +238,7 @@ final class RedisQueue implements Queue
public function getScheduledCount(): int
{
$currentTime = time();
return $this->redis->zCount($this->delayedKey, $currentTime + 1, '+inf');
}

View File

@@ -4,11 +4,11 @@ declare(strict_types=1);
namespace App\Framework\Queue;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Retry\RetryStrategy;
use App\Framework\Retry\Strategies\ExponentialBackoffStrategy;
use App\Framework\Retry\Strategies\FixedRetryStrategy;
use App\Framework\Retry\Strategies\LinearDelayStrategy;
use App\Framework\Core\ValueObjects\Duration;
/**
* Queue-specific Retry Strategy Helper
@@ -161,4 +161,4 @@ final readonly class RetryStrategyHelper
useJitter: $useJitter
);
}
}
}

View File

@@ -8,10 +8,9 @@ use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\EntityManager;
use App\Framework\Database\ValueObjects\SqlQuery;
use App\Framework\Queue\Contracts\DeadLetterQueueInterface;
use App\Framework\Queue\Contracts\QueueInterface;
use App\Framework\Queue\Queue;
use App\Framework\Queue\Entities\DeadLetterJob;
use App\Framework\Queue\ValueObjects\DeadLetterQueueName;
use App\Framework\Queue\ValueObjects\JobPayload;
use App\Framework\Queue\ValueObjects\QueueName;
use App\Framework\Queue\ValueObjects\QueuePriority;
@@ -23,8 +22,9 @@ final readonly class DatabaseDeadLetterQueue implements DeadLetterQueueInterface
public function __construct(
private ConnectionInterface $connection,
private EntityManager $entityManager,
private QueueInterface $originalQueue
) {}
private Queue $originalQueue
) {
}
public function addFailedJob(DeadLetterJob $deadLetterJob): void
{
@@ -69,7 +69,7 @@ final readonly class DatabaseDeadLetterQueue implements DeadLetterQueueInterface
try {
// Get the dead letter job
$deadLetterJob = $this->findDeadLetterJob($deadLetterJobId);
if (!$deadLetterJob) {
if (! $deadLetterJob) {
return false;
}
@@ -96,6 +96,7 @@ final readonly class DatabaseDeadLetterQueue implements DeadLetterQueueInterface
return true;
} catch (\Throwable $e) {
$this->connection->rollback();
throw $e;
}
}
@@ -122,6 +123,7 @@ final readonly class DatabaseDeadLetterQueue implements DeadLetterQueueInterface
public function clearQueue(DeadLetterQueueName $deadLetterQueueName): int
{
$sql = "DELETE FROM dead_letter_jobs WHERE dead_letter_queue = ?";
return $this->connection->execute(SqlQuery::create($sql, [$deadLetterQueueName->toString()]));
}
@@ -150,7 +152,7 @@ final readonly class DatabaseDeadLetterQueue implements DeadLetterQueueInterface
'avg_retry_count' => round((float) $stats['avg_retry_count'], 2),
'max_retry_count' => (int) $stats['max_retry_count'],
'oldest_job' => $stats['oldest_job'],
'newest_job' => $stats['newest_job']
'newest_job' => $stats['newest_job'],
];
}
@@ -177,6 +179,7 @@ final readonly class DatabaseDeadLetterQueue implements DeadLetterQueueInterface
private function deleteJobById(string $deadLetterJobId): bool
{
$sql = "DELETE FROM dead_letter_jobs WHERE id = ?";
return $this->connection->execute(SqlQuery::create($sql, [$deadLetterJobId])) > 0;
}
@@ -198,4 +201,4 @@ final readonly class DatabaseDeadLetterQueue implements DeadLetterQueueInterface
lastRetryAt: $row['last_retry_at']
);
}
}
}

View File

@@ -4,14 +4,14 @@ declare(strict_types=1);
namespace App\Framework\Queue\Services;
use App\Framework\Queue\Interfaces\DistributedLockInterface;
use App\Framework\Queue\ValueObjects\LockKey;
use App\Framework\Queue\ValueObjects\WorkerId;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\ValueObjects\SqlQuery;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
use App\Framework\Queue\Interfaces\DistributedLockInterface;
use App\Framework\Queue\ValueObjects\LockKey;
use App\Framework\Queue\ValueObjects\WorkerId;
/**
* Database-basierte Distributed Lock Implementation
@@ -25,7 +25,8 @@ final readonly class DatabaseDistributedLock implements DistributedLockInterface
public function __construct(
private ConnectionInterface $connection,
private Logger $logger
) {}
) {
}
/**
* Lock erwerben
@@ -35,7 +36,7 @@ final readonly class DatabaseDistributedLock implements DistributedLockInterface
$this->logger->debug('Attempting to acquire lock', LogContext::withData([
'lock_key' => $key->toString(),
'worker_id' => $workerId->toString(),
'ttl_seconds' => $ttl->toSeconds()
'ttl_seconds' => $ttl->toSeconds(),
]));
try {
@@ -51,14 +52,15 @@ final readonly class DatabaseDistributedLock implements DistributedLockInterface
$affectedRows = $this->connection->execute(SqlQuery::create($sql, [
'lock_key' => $key->toString(),
'worker_id' => $workerId->toString(),
'expires_at' => $expiresAt->format('Y-m-d H:i:s')
'expires_at' => $expiresAt->format('Y-m-d H:i:s'),
]));
if ($affectedRows > 0) {
$this->logger->info('Lock acquired successfully', LogContext::withData([
'lock_key' => $key->toString(),
'worker_id' => $workerId->toString()
'worker_id' => $workerId->toString(),
]));
return true;
}
@@ -69,15 +71,16 @@ final readonly class DatabaseDistributedLock implements DistributedLockInterface
if ($this->isDuplicateKeyError($e)) {
$this->logger->debug('Lock already exists', LogContext::withData([
'lock_key' => $key->toString(),
'worker_id' => $workerId->toString()
'worker_id' => $workerId->toString(),
]));
return false;
}
$this->logger->error('Failed to acquire lock', LogContext::withData([
'lock_key' => $key->toString(),
'worker_id' => $workerId->toString(),
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
throw $e;
@@ -92,7 +95,7 @@ final readonly class DatabaseDistributedLock implements DistributedLockInterface
$this->logger->debug('Attempting to extend lock', LogContext::withData([
'lock_key' => $key->toString(),
'worker_id' => $workerId->toString(),
'ttl_seconds' => $ttl->toSeconds()
'ttl_seconds' => $ttl->toSeconds(),
]));
try {
@@ -107,7 +110,7 @@ final readonly class DatabaseDistributedLock implements DistributedLockInterface
$affectedRows = $this->connection->execute(SqlQuery::create($sql, [
'lock_key' => $key->toString(),
'worker_id' => $workerId->toString(),
'expires_at' => $expiresAt->format('Y-m-d H:i:s')
'expires_at' => $expiresAt->format('Y-m-d H:i:s'),
]));
$success = $affectedRows > 0;
@@ -115,12 +118,12 @@ final readonly class DatabaseDistributedLock implements DistributedLockInterface
if ($success) {
$this->logger->debug('Lock extended successfully', LogContext::withData([
'lock_key' => $key->toString(),
'worker_id' => $workerId->toString()
'worker_id' => $workerId->toString(),
]));
} else {
$this->logger->warning('Failed to extend lock - not owned or expired', LogContext::withData([
'lock_key' => $key->toString(),
'worker_id' => $workerId->toString()
'worker_id' => $workerId->toString(),
]));
}
@@ -130,8 +133,9 @@ final readonly class DatabaseDistributedLock implements DistributedLockInterface
$this->logger->error('Failed to extend lock', LogContext::withData([
'lock_key' => $key->toString(),
'worker_id' => $workerId->toString(),
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
throw $e;
}
}
@@ -143,7 +147,7 @@ final readonly class DatabaseDistributedLock implements DistributedLockInterface
{
$this->logger->debug('Attempting to release lock', LogContext::withData([
'lock_key' => $key->toString(),
'worker_id' => $workerId->toString()
'worker_id' => $workerId->toString(),
]));
try {
@@ -153,7 +157,7 @@ final readonly class DatabaseDistributedLock implements DistributedLockInterface
$affectedRows = $this->connection->execute(SqlQuery::create($sql, [
'lock_key' => $key->toString(),
'worker_id' => $workerId->toString()
'worker_id' => $workerId->toString(),
]));
$success = $affectedRows > 0;
@@ -161,12 +165,12 @@ final readonly class DatabaseDistributedLock implements DistributedLockInterface
if ($success) {
$this->logger->info('Lock released successfully', LogContext::withData([
'lock_key' => $key->toString(),
'worker_id' => $workerId->toString()
'worker_id' => $workerId->toString(),
]));
} else {
$this->logger->warning('Lock not found or not owned by worker', LogContext::withData([
'lock_key' => $key->toString(),
'worker_id' => $workerId->toString()
'worker_id' => $workerId->toString(),
]));
}
@@ -176,8 +180,9 @@ final readonly class DatabaseDistributedLock implements DistributedLockInterface
$this->logger->error('Failed to release lock', LogContext::withData([
'lock_key' => $key->toString(),
'worker_id' => $workerId->toString(),
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
throw $e;
}
}
@@ -193,13 +198,15 @@ final readonly class DatabaseDistributedLock implements DistributedLockInterface
AND expires_at > NOW()";
$result = $this->connection->queryOne(SqlQuery::create($sql, ['lock_key' => $key->toString()]));
return (int) $result['count'] > 0;
} catch (\Exception $e) {
$this->logger->error('Failed to check lock existence', LogContext::withData([
'lock_key' => $key->toString(),
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
throw $e;
}
}
@@ -215,7 +222,7 @@ final readonly class DatabaseDistributedLock implements DistributedLockInterface
AND expires_at > NOW()";
$data = $this->connection->queryOne(SqlQuery::create($sql, ['lock_key' => $key->toString()]));
if (!$data) {
if (! $data) {
return null;
}
@@ -224,14 +231,15 @@ final readonly class DatabaseDistributedLock implements DistributedLockInterface
'worker_id' => $data['worker_id'],
'acquired_at' => $data['acquired_at'],
'expires_at' => $data['expires_at'],
'ttl_seconds' => strtotime($data['expires_at']) - time()
'ttl_seconds' => strtotime($data['expires_at']) - time(),
];
} catch (\Exception $e) {
$this->logger->error('Failed to get lock info', LogContext::withData([
'lock_key' => $key->toString(),
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
throw $e;
}
}
@@ -245,7 +253,7 @@ final readonly class DatabaseDistributedLock implements DistributedLockInterface
'lock_key' => $key->toString(),
'worker_id' => $workerId->toString(),
'ttl_seconds' => $ttl->toSeconds(),
'timeout_seconds' => $timeout->toSeconds()
'timeout_seconds' => $timeout->toSeconds(),
]));
$startTime = microtime(true);
@@ -260,8 +268,9 @@ final readonly class DatabaseDistributedLock implements DistributedLockInterface
'lock_key' => $key->toString(),
'worker_id' => $workerId->toString(),
'attempts' => $attempts,
'elapsed_seconds' => microtime(true) - $startTime
'elapsed_seconds' => microtime(true) - $startTime,
]));
break;
}
@@ -271,8 +280,9 @@ final readonly class DatabaseDistributedLock implements DistributedLockInterface
'lock_key' => $key->toString(),
'worker_id' => $workerId->toString(),
'attempts' => $attempts + 1,
'elapsed_seconds' => microtime(true) - $startTime
'elapsed_seconds' => microtime(true) - $startTime,
]));
return true;
}
@@ -286,7 +296,7 @@ final readonly class DatabaseDistributedLock implements DistributedLockInterface
'lock_key' => $key->toString(),
'worker_id' => $workerId->toString(),
'attempts' => $attempts,
'timeout_seconds' => $timeoutSeconds
'timeout_seconds' => $timeoutSeconds,
]));
return false;
@@ -298,7 +308,7 @@ final readonly class DatabaseDistributedLock implements DistributedLockInterface
public function releaseAllWorkerLocks(WorkerId $workerId): int
{
$this->logger->info('Releasing all locks for worker', LogContext::withData([
'worker_id' => $workerId->toString()
'worker_id' => $workerId->toString(),
]));
try {
@@ -309,7 +319,7 @@ final readonly class DatabaseDistributedLock implements DistributedLockInterface
$this->logger->info('Released all worker locks', LogContext::withData([
'worker_id' => $workerId->toString(),
'released_count' => $count
'released_count' => $count,
]));
return $count;
@@ -317,8 +327,9 @@ final readonly class DatabaseDistributedLock implements DistributedLockInterface
} catch (\Exception $e) {
$this->logger->error('Failed to release worker locks', LogContext::withData([
'worker_id' => $workerId->toString(),
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
throw $e;
}
}
@@ -337,15 +348,16 @@ final readonly class DatabaseDistributedLock implements DistributedLockInterface
$count = $this->connection->execute(SqlQuery::create($sql));
$this->logger->info('Cleaned up expired locks', LogContext::withData([
'cleaned_count' => $count
'cleaned_count' => $count,
]));
return $count;
} catch (\Exception $e) {
$this->logger->error('Failed to cleanup expired locks', LogContext::withData([
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
throw $e;
}
}
@@ -387,13 +399,14 @@ final readonly class DatabaseDistributedLock implements DistributedLockInterface
'avg_ttl_seconds' => round((float) $stats['avg_ttl_seconds'], 2),
'oldest_lock' => $stats['oldest_lock'],
'newest_lock' => $stats['newest_lock'],
'top_lock_keys' => $topKeys
'top_lock_keys' => $topKeys,
];
} catch (\Exception $e) {
$this->logger->error('Failed to get lock statistics', LogContext::withData([
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
throw $e;
}
}
@@ -404,8 +417,9 @@ final readonly class DatabaseDistributedLock implements DistributedLockInterface
private function isDuplicateKeyError(\Exception $e): bool
{
$message = strtolower($e->getMessage());
return strpos($message, 'duplicate') !== false
|| strpos($message, 'unique') !== false
|| $e->getCode() === 23000; // SQLSTATE für Constraint Violation
}
}
}

View File

@@ -4,14 +4,14 @@ declare(strict_types=1);
namespace App\Framework\Queue\Services;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\ValueObjects\SqlQuery;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
use App\Framework\Queue\Contracts\JobBatchManagerInterface;
use App\Framework\Queue\ValueObjects\JobBatch;
use App\Framework\Queue\ValueObjects\JobBatchStatus;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
/**
* Database-based job batch manager
@@ -21,7 +21,8 @@ final readonly class DatabaseJobBatchManager implements JobBatchManagerInterface
public function __construct(
private ConnectionInterface $connection,
private Logger $logger
) {}
) {
}
public function createBatch(string $name, array $jobIds, array $options = []): JobBatch
{
@@ -42,13 +43,13 @@ final readonly class DatabaseJobBatchManager implements JobBatchManagerInterface
$batch->processedJobs,
$batch->failedJobs,
json_encode($batch->options),
$batch->createdAt->toSqlString()
$batch->createdAt->toSqlString(),
]));
$this->logger->info('Job batch created', LogContext::withData([
'batch_id' => $batch->batchId,
'name' => $batch->name,
'total_jobs' => $batch->totalJobs
'total_jobs' => $batch->totalJobs,
]));
return $batch;
@@ -59,7 +60,7 @@ final readonly class DatabaseJobBatchManager implements JobBatchManagerInterface
$sql = "SELECT * FROM job_batches WHERE batch_id = ?";
$row = $this->connection->queryOne(SqlQuery::create($sql, [$batchId]));
if (!$row) {
if (! $row) {
return null;
}
@@ -69,7 +70,7 @@ final readonly class DatabaseJobBatchManager implements JobBatchManagerInterface
public function recordJobCompleted(string $batchId, string $jobId): void
{
$batch = $this->getBatch($batchId);
if (!$batch) {
if (! $batch) {
return;
}
@@ -79,14 +80,14 @@ final readonly class DatabaseJobBatchManager implements JobBatchManagerInterface
$this->logger->debug('Job batch progress updated', LogContext::withData([
'batch_id' => $batchId,
'job_id' => $jobId,
'progress' => $updatedBatch->getProgressPercentage()
'progress' => $updatedBatch->getProgressPercentage(),
]));
}
public function recordJobFailed(string $batchId, string $jobId): void
{
$batch = $this->getBatch($batchId);
if (!$batch) {
if (! $batch) {
return;
}
@@ -96,7 +97,7 @@ final readonly class DatabaseJobBatchManager implements JobBatchManagerInterface
$this->logger->warning('Job batch job failed', LogContext::withData([
'batch_id' => $batchId,
'job_id' => $jobId,
'failed_jobs' => $updatedBatch->failedJobs
'failed_jobs' => $updatedBatch->failedJobs,
]));
}
@@ -106,12 +107,12 @@ final readonly class DatabaseJobBatchManager implements JobBatchManagerInterface
$affected = $this->connection->execute(SqlQuery::create($sql, [
JobBatchStatus::CANCELLED->value,
date('Y-m-d H:i:s'),
$batchId
$batchId,
]));
if ($affected > 0) {
$this->logger->info('Job batch cancelled', LogContext::withData([
'batch_id' => $batchId
'batch_id' => $batchId,
]));
}
@@ -150,7 +151,7 @@ final readonly class DatabaseJobBatchManager implements JobBatchManagerInterface
'count' => (int) $row['count'],
'avg_processed' => (float) $row['avg_processed'],
'total_jobs' => (int) $row['total_jobs'],
'total_failed' => (int) $row['total_failed']
'total_failed' => (int) $row['total_failed'],
];
}
@@ -169,13 +170,13 @@ final readonly class DatabaseJobBatchManager implements JobBatchManagerInterface
$cutoffDate,
JobBatchStatus::COMPLETED->value,
JobBatchStatus::FAILED->value,
JobBatchStatus::CANCELLED->value
JobBatchStatus::CANCELLED->value,
]));
if ($deleted > 0) {
$this->logger->info('Cleaned up old job batches', LogContext::withData([
'deleted_count' => $deleted,
'older_than_days' => $olderThanDays
'older_than_days' => $olderThanDays,
]));
}
@@ -185,7 +186,7 @@ final readonly class DatabaseJobBatchManager implements JobBatchManagerInterface
public function getBatchProgress(string $batchId): array
{
$batch = $this->getBatch($batchId);
if (!$batch) {
if (! $batch) {
return [];
}
@@ -198,7 +199,7 @@ final readonly class DatabaseJobBatchManager implements JobBatchManagerInterface
'processed_jobs' => $batch->processedJobs,
'failed_jobs' => $batch->failedJobs,
'remaining_jobs' => $batch->getRemainingJobs(),
'is_finished' => $batch->isFinished()
'is_finished' => $batch->isFinished(),
];
}
@@ -220,7 +221,7 @@ final readonly class DatabaseJobBatchManager implements JobBatchManagerInterface
$batch->startedAt?->toSqlString(),
$batch->completedAt?->toSqlString(),
$batch->failedAt?->toSqlString(),
$batch->batchId
$batch->batchId,
]));
}
@@ -246,4 +247,4 @@ final readonly class DatabaseJobBatchManager implements JobBatchManagerInterface
{
return 'batch_' . uniqid() . '_' . time();
}
}
}

View File

@@ -4,18 +4,21 @@ declare(strict_types=1);
namespace App\Framework\Queue\Services;
use App\Framework\Queue\Contracts\JobChainManagerInterface;
use App\Framework\Queue\ValueObjects\JobChain;
use App\Framework\Queue\Entities\JobChainEntry;
use App\Framework\Database\EntityManagerInterface;
use App\Framework\Logging\Logger;
use App\Framework\Queue\Contracts\JobChainManagerInterface;
use App\Framework\Queue\Entities\JobChainEntry;
use App\Framework\Queue\Exceptions\ChainNotFoundException;
use App\Framework\Queue\Exceptions\InvalidChainStateException;
use App\Framework\Queue\ValueObjects\JobChain;
final readonly class DatabaseJobChainManager implements JobChainManagerInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private Logger $logger
) {}
) {
}
public function createChain(JobChain $jobChain): void
{
@@ -25,7 +28,7 @@ final readonly class DatabaseJobChainManager implements JobChainManagerInterface
'chain_id' => $jobChain->chainId,
'name' => $jobChain->name,
'job_count' => count($jobChain->jobIds),
'execution_mode' => $jobChain->executionMode->value
'execution_mode' => $jobChain->executionMode->value,
]);
$this->entityManager->save($entry);
@@ -37,7 +40,7 @@ final readonly class DatabaseJobChainManager implements JobChainManagerInterface
$query = "SELECT * FROM job_chains WHERE chain_id = ?";
$result = $this->entityManager->getConnection()->fetchOne($query, [$chainId]);
if (!$result) {
if (! $result) {
return null;
}
@@ -48,8 +51,8 @@ final readonly class DatabaseJobChainManager implements JobChainManagerInterface
{
$existing = $this->getChain($jobChain->chainId);
if (!$existing) {
throw new \RuntimeException("Chain with ID '{$jobChain->chainId}' not found");
if (! $existing) {
throw ChainNotFoundException::byId($jobChain->chainId);
}
$updated = new JobChainEntry(
@@ -69,7 +72,7 @@ final readonly class DatabaseJobChainManager implements JobChainManagerInterface
$this->logger->info('Updating job chain', [
'chain_id' => $jobChain->chainId,
'name' => $jobChain->name
'name' => $jobChain->name,
]);
$this->entityManager->save($updated);
@@ -81,7 +84,7 @@ final readonly class DatabaseJobChainManager implements JobChainManagerInterface
$query = "DELETE FROM job_chains WHERE chain_id = ?";
$this->logger->info('Deleting job chain', [
'chain_id' => $chainId
'chain_id' => $chainId,
]);
$this->entityManager->getConnection()->execute($query, [$chainId]);
@@ -91,19 +94,19 @@ final readonly class DatabaseJobChainManager implements JobChainManagerInterface
{
$chain = $this->getChain($chainId);
if (!$chain) {
throw new \RuntimeException("Chain with ID '{$chainId}' not found");
if (! $chain) {
throw ChainNotFoundException::byId($chainId);
}
if (!$chain->isPending()) {
throw new \RuntimeException("Chain '{$chainId}' is not in pending status");
if (! $chain->isPending()) {
throw InvalidChainStateException::notPending($chainId, $chain->status);
}
$started = $chain->markAsStarted();
$this->logger->info('Starting job chain', [
'chain_id' => $chainId,
'job_count' => count($started->getJobIdsArray())
'job_count' => count($started->getJobIdsArray()),
]);
$this->entityManager->save($started);
@@ -115,7 +118,7 @@ final readonly class DatabaseJobChainManager implements JobChainManagerInterface
$query = "SELECT * FROM job_chains WHERE JSON_CONTAINS(job_ids, ?)";
$results = $this->entityManager->getConnection()->fetchAll($query, [json_encode($jobId)]);
return array_map(function(array $row) {
return array_map(function (array $row) {
return $this->mapRowToEntity($row);
}, $results);
}
@@ -125,7 +128,7 @@ final readonly class DatabaseJobChainManager implements JobChainManagerInterface
$query = "SELECT * FROM job_chains WHERE status = 'running' ORDER BY started_at";
$results = $this->entityManager->getConnection()->fetchAll($query);
return array_map(function(array $row) {
return array_map(function (array $row) {
return $this->mapRowToEntity($row);
}, $results);
}
@@ -135,7 +138,7 @@ final readonly class DatabaseJobChainManager implements JobChainManagerInterface
$query = "SELECT * FROM job_chains WHERE status = 'pending' ORDER BY created_at";
$results = $this->entityManager->getConnection()->fetchAll($query);
return array_map(function(array $row) {
return array_map(function (array $row) {
return $this->mapRowToEntity($row);
}, $results);
}
@@ -144,14 +147,14 @@ final readonly class DatabaseJobChainManager implements JobChainManagerInterface
{
$chain = $this->getChain($chainId);
if (!$chain) {
throw new \RuntimeException("Chain with ID '{$chainId}' not found");
if (! $chain) {
throw ChainNotFoundException::byId($chainId);
}
$completed = $chain->markAsCompleted();
$this->logger->info('Marking chain as completed', [
'chain_id' => $chainId
'chain_id' => $chainId,
]);
$this->entityManager->save($completed);
@@ -162,14 +165,14 @@ final readonly class DatabaseJobChainManager implements JobChainManagerInterface
{
$chain = $this->getChain($chainId);
if (!$chain) {
throw new \RuntimeException("Chain with ID '{$chainId}' not found");
if (! $chain) {
throw ChainNotFoundException::byId($chainId);
}
$failed = $chain->markAsFailed();
$this->logger->info('Marking chain as failed', [
'chain_id' => $chainId
'chain_id' => $chainId,
]);
$this->entityManager->save($failed);
@@ -180,7 +183,7 @@ final readonly class DatabaseJobChainManager implements JobChainManagerInterface
{
$chain = $this->getChain($chainId);
if (!$chain) {
if (! $chain) {
return null;
}
@@ -198,7 +201,7 @@ final readonly class DatabaseJobChainManager implements JobChainManagerInterface
{
$chain = $this->getChain($chainId);
if (!$chain) {
if (! $chain) {
return false;
}
@@ -210,19 +213,20 @@ final readonly class DatabaseJobChainManager implements JobChainManagerInterface
$chains = $this->getChainsForJob($jobId);
foreach ($chains as $chainEntry) {
if (!$chainEntry->isRunning()) {
if (! $chainEntry->isRunning()) {
continue;
}
$jobChain = $chainEntry->getJobChain();
// If chain requires stopping on failure and job failed
if (!$successful && $jobChain->stopOnFailure) {
if (! $successful && $jobChain->stopOnFailure) {
$this->markChainAsFailed($chainEntry->chainId);
$this->logger->warning('Chain failed due to job failure', [
'chain_id' => $chainEntry->chainId,
'failed_job_id' => $jobId
'failed_job_id' => $jobId,
]);
continue;
}
@@ -234,13 +238,13 @@ final readonly class DatabaseJobChainManager implements JobChainManagerInterface
$this->markChainAsCompleted($chainEntry->chainId);
$this->logger->info('Chain completed', [
'chain_id' => $chainEntry->chainId,
'last_job_id' => $jobId
'last_job_id' => $jobId,
]);
} else {
$this->logger->info('Job completed in chain, next job available', [
'chain_id' => $chainEntry->chainId,
'completed_job_id' => $jobId,
'next_job_id' => $nextJobId
'next_job_id' => $nextJobId,
]);
}
}
@@ -250,8 +254,8 @@ final readonly class DatabaseJobChainManager implements JobChainManagerInterface
{
$chain = $this->getChain($chainId);
if (!$chain) {
throw new \RuntimeException("Chain with ID '{$chainId}' not found");
if (! $chain) {
throw ChainNotFoundException::byId($chainId);
}
$jobChain = $chain->getJobChain();
@@ -264,25 +268,25 @@ final readonly class DatabaseJobChainManager implements JobChainManagerInterface
'completed_jobs' => 0,
'total_jobs' => $totalJobs,
'percentage' => 0,
'status' => 'pending'
'status' => 'pending',
],
'running' => [
'completed_jobs' => 0, // Would need actual job status check
'total_jobs' => $totalJobs,
'percentage' => 0, // Would calculate based on completed jobs
'status' => 'running'
'status' => 'running',
],
'completed' => [
'completed_jobs' => $totalJobs,
'total_jobs' => $totalJobs,
'percentage' => 100,
'status' => 'completed'
'status' => 'completed',
],
'failed' => [
'completed_jobs' => 0, // Would need actual job status check
'total_jobs' => $totalJobs,
'percentage' => 0, // Partial progress before failure
'status' => 'failed'
'status' => 'failed',
]
};
@@ -292,7 +296,7 @@ final readonly class DatabaseJobChainManager implements JobChainManagerInterface
'execution_mode' => $chain->executionMode,
'stop_on_failure' => $chain->stopOnFailure,
'started_at' => $chain->startedAt,
'completed_at' => $chain->completedAt
'completed_at' => $chain->completedAt,
]);
}
@@ -305,13 +309,13 @@ final readonly class DatabaseJobChainManager implements JobChainManagerInterface
$this->logger->info('Cleaning up old chains', [
'cutoff_date' => $cutoffDate,
'older_than_days' => $olderThanDays
'older_than_days' => $olderThanDays,
]);
$affectedRows = $this->entityManager->getConnection()->execute($query, [$cutoffDate]);
$this->logger->info('Old chains cleaned up', [
'deleted_count' => $affectedRows
'deleted_count' => $affectedRows,
]);
return $affectedRows;
@@ -334,4 +338,4 @@ final readonly class DatabaseJobChainManager implements JobChainManagerInterface
completedAt: $row['completed_at']
);
}
}
}

View File

@@ -4,19 +4,20 @@ declare(strict_types=1);
namespace App\Framework\Queue\Services;
use App\Framework\Queue\Contracts\JobDependencyManagerInterface;
use App\Framework\Queue\ValueObjects\JobDependency;
use App\Framework\Queue\Entities\JobDependencyEntry;
use App\Framework\Database\EntityManagerInterface;
use App\Framework\Database\ValueObjects\SqlQuery;
use App\Framework\Logging\Logger;
use App\Framework\Queue\Contracts\JobDependencyManagerInterface;
use App\Framework\Queue\Entities\JobDependencyEntry;
use App\Framework\Queue\ValueObjects\JobDependency;
final readonly class DatabaseJobDependencyManager implements JobDependencyManagerInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private Logger $logger
) {}
) {
}
public function addDependency(JobDependency $dependency): void
{
@@ -25,7 +26,7 @@ final readonly class DatabaseJobDependencyManager implements JobDependencyManage
$this->logger->info('Adding job dependency', [
'dependent_job_id' => $dependency->dependentJobId,
'depends_on_job_id' => $dependency->dependsOnJobId,
'type' => $dependency->type->value
'type' => $dependency->type->value,
]);
$this->entityManager->save($entry);
@@ -39,7 +40,7 @@ final readonly class DatabaseJobDependencyManager implements JobDependencyManage
$this->logger->info('Removing job dependency', [
'dependent_job_id' => $dependentJobId,
'depends_on_job_id' => $dependsOnJobId
'depends_on_job_id' => $dependsOnJobId,
]);
$this->entityManager->getConnection()->execute(
@@ -54,7 +55,7 @@ final readonly class DatabaseJobDependencyManager implements JobDependencyManage
SqlQuery::create($query, [$jobId])
)->fetchAll();
return array_map(function(array $row) {
return array_map(function (array $row) {
return $this->mapRowToEntity($row);
}, $results);
}
@@ -66,7 +67,7 @@ final readonly class DatabaseJobDependencyManager implements JobDependencyManage
SqlQuery::create($query, [$jobId])
)->fetchAll();
return array_map(function(array $row) {
return array_map(function (array $row) {
return $this->mapRowToEntity($row);
}, $results);
}
@@ -79,6 +80,7 @@ final readonly class DatabaseJobDependencyManager implements JobDependencyManage
$result = $this->entityManager->getConnection()->queryOne(
SqlQuery::create($query, [$jobId])
);
return $result['count'] > 0;
}
@@ -92,7 +94,7 @@ final readonly class DatabaseJobDependencyManager implements JobDependencyManage
SqlQuery::create($query, [$jobId])
)->fetchAll();
return array_map(function(array $row) {
return array_map(function (array $row) {
return $this->mapRowToEntity($row);
}, $results);
}
@@ -107,7 +109,7 @@ final readonly class DatabaseJobDependencyManager implements JobDependencyManage
$this->logger->info('Marking dependency as satisfied', [
'dependent_job_id' => $dependentJobId,
'depends_on_job_id' => $dependsOnJobId
'depends_on_job_id' => $dependsOnJobId,
]);
$this->entityManager->getConnection()->execute(
@@ -117,7 +119,7 @@ final readonly class DatabaseJobDependencyManager implements JobDependencyManage
public function canJobBeExecuted(string $jobId): bool
{
return !$this->hasUnsatisfiedDependencies($jobId);
return ! $this->hasUnsatisfiedDependencies($jobId);
}
public function getReadyJobs(): array
@@ -142,7 +144,7 @@ final readonly class DatabaseJobDependencyManager implements JobDependencyManage
{
$this->logger->info('Resolving job completion for dependencies', [
'job_id' => $jobId,
'successful' => $successful
'successful' => $successful,
]);
// Get all jobs that depend on this completed job
@@ -159,7 +161,7 @@ final readonly class DatabaseJobDependencyManager implements JobDependencyManage
\App\Framework\Queue\ValueObjects\DependencyType::CONDITIONAL => $this->evaluateCondition($dependency->condition, $successful)
};
if ($shouldSatisfy && !$dependencyEntry->isSatisfied) {
if ($shouldSatisfy && ! $dependencyEntry->isSatisfied) {
$this->markDependencyAsSatisfied($dependency->dependentJobId, $jobId);
// Check if the dependent job is now ready to execute
@@ -172,7 +174,7 @@ final readonly class DatabaseJobDependencyManager implements JobDependencyManage
$this->logger->info('Job completion resolved', [
'job_id' => $jobId,
'resolved_jobs_count' => count($resolvedJobs),
'resolved_jobs' => $resolvedJobs
'resolved_jobs' => $resolvedJobs,
]);
return $resolvedJobs;
@@ -186,7 +188,7 @@ final readonly class DatabaseJobDependencyManager implements JobDependencyManage
$this->buildDependencyChain($jobId, $visited, $chain);
// Remove the starting job from the chain if it's there
return array_filter($chain, fn($id) => $id !== $jobId);
return array_filter($chain, fn ($id) => $id !== $jobId);
}
public function hasCircularDependencies(string $jobId): bool
@@ -206,7 +208,7 @@ final readonly class DatabaseJobDependencyManager implements JobDependencyManage
$this->logger->info('Cleaning up old dependencies', [
'cutoff_date' => $cutoffDate,
'older_than_days' => $olderThanDays
'older_than_days' => $olderThanDays,
]);
$affectedRows = $this->entityManager->getConnection()->execute(
@@ -214,7 +216,7 @@ final readonly class DatabaseJobDependencyManager implements JobDependencyManage
);
$this->logger->info('Old dependencies cleaned up', [
'deleted_count' => $affectedRows
'deleted_count' => $affectedRows,
]);
return $affectedRows;
@@ -247,7 +249,7 @@ final readonly class DatabaseJobDependencyManager implements JobDependencyManage
$dependency = $dependencyEntry->getJobDependency();
$dependsOnJobId = $dependency->dependsOnJobId;
if (!isset($visited[$dependsOnJobId])) {
if (! isset($visited[$dependsOnJobId])) {
if ($this->detectCircularDependency($dependsOnJobId, $visited, $recursionStack)) {
return true;
}
@@ -257,6 +259,7 @@ final readonly class DatabaseJobDependencyManager implements JobDependencyManage
}
unset($recursionStack[$jobId]);
return false;
}
@@ -269,7 +272,7 @@ final readonly class DatabaseJobDependencyManager implements JobDependencyManage
// Simple condition evaluation - can be extended for more complex conditions
return match($condition) {
'on_success' => $jobSuccessful,
'on_failure' => !$jobSuccessful,
'on_failure' => ! $jobSuccessful,
'always' => true,
default => true
};
@@ -289,4 +292,4 @@ final readonly class DatabaseJobDependencyManager implements JobDependencyManage
satisfiedAt: $row['satisfied_at']
);
}
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Framework\Queue\Services;
use App\Framework\Core\ValueObjects\Percentage;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\EntityManager;
use App\Framework\Database\ValueObjects\SqlQuery;
@@ -11,7 +12,6 @@ use App\Framework\Queue\Contracts\JobProgressTrackerInterface;
use App\Framework\Queue\Entities\JobProgressEntry;
use App\Framework\Queue\ValueObjects\JobProgress;
use App\Framework\Queue\ValueObjects\ProgressStep;
use App\Framework\Core\ValueObjects\Percentage;
/**
* Database-backed job progress tracker implementation
@@ -21,7 +21,8 @@ final readonly class DatabaseJobProgressTracker implements JobProgressTrackerInt
public function __construct(
private ConnectionInterface $connection,
private EntityManager $entityManager
) {}
) {
}
public function updateProgress(string $jobId, JobProgress $progress, ?string $stepName = null): void
{
@@ -47,6 +48,7 @@ final readonly class DatabaseJobProgressTracker implements JobProgressTrackerInt
public function getCurrentProgress(string $jobId): ?JobProgress
{
$latestEntry = $this->getLatestProgressEntry($jobId);
return $latestEntry?->getJobProgress();
}
@@ -131,6 +133,7 @@ final readonly class DatabaseJobProgressTracker implements JobProgressTrackerInt
$cutoffDate = date('Y-m-d H:i:s', strtotime("-{$olderThanDays} days"));
$sql = "DELETE FROM job_progress WHERE updated_at < ?";
return $this->connection->execute(SqlQuery::create($sql, [$cutoffDate]));
}
@@ -192,4 +195,4 @@ final readonly class DatabaseJobProgressTracker implements JobProgressTrackerInt
isFailed: (bool) $row['is_failed']
);
}
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Framework\Queue\Services;
use App\Framework\Queue\Contracts\DeadLetterQueueInterface;
use App\Framework\Queue\Entities\DeadLetterJob;
use App\Framework\Queue\Entities\JobIndexEntry;
use App\Framework\Queue\ProductionJobPersistenceLayer;
use App\Framework\Queue\ValueObjects\DeadLetterQueueName;
use App\Framework\Queue\ValueObjects\FailureReason;
use App\Framework\Queue\ValueObjects\QueueName;
@@ -19,7 +20,8 @@ final readonly class DeadLetterManager
public function __construct(
private DeadLetterQueueInterface $deadLetterQueue,
private ProductionJobPersistenceLayer $persistenceLayer
) {}
) {
}
/**
* Move a failed job to the dead letter queue
@@ -57,7 +59,7 @@ final readonly class DeadLetterManager
int $maxAttempts = 3
): bool {
$jobEntry = $this->persistenceLayer->getJobById($jobId);
if (!$jobEntry) {
if (! $jobEntry) {
return false;
}
@@ -156,4 +158,4 @@ final readonly class DeadLetterManager
{
return $this->deadLetterQueue->getAvailableQueues();
}
}
}

View File

@@ -4,11 +4,11 @@ declare(strict_types=1);
namespace App\Framework\Queue\Services;
use App\Framework\Queue\Contracts\JobDependencyManagerInterface;
use App\Framework\Logging\Logger;
use App\Framework\Queue\Contracts\JobChainManagerInterface;
use App\Framework\Queue\Contracts\JobDependencyManagerInterface;
use App\Framework\Queue\Contracts\QueueInterface;
use App\Framework\Queue\ValueObjects\Job;
use App\Framework\Logging\Logger;
final readonly class DependencyResolutionEngine
{
@@ -17,7 +17,8 @@ final readonly class DependencyResolutionEngine
private JobChainManagerInterface $chainManager,
private QueueInterface $queue,
private Logger $logger
) {}
) {
}
/**
* Process job completion and resolve any dependent jobs that can now be executed
@@ -26,7 +27,7 @@ final readonly class DependencyResolutionEngine
{
$this->logger->info('Starting dependency resolution for completed job', [
'job_id' => $jobId,
'successful' => $successful
'successful' => $successful,
]);
$resolvedJobs = [];
@@ -38,7 +39,7 @@ final readonly class DependencyResolutionEngine
$resolvedJobs[] = [
'job_id' => $dependentJobId,
'type' => 'dependency_resolved',
'trigger_job' => $jobId
'trigger_job' => $jobId,
];
}
@@ -59,13 +60,13 @@ final readonly class DependencyResolutionEngine
'job_id' => $nextJobId,
'type' => 'chain_progression',
'chain_id' => $chainEntry->chainId,
'trigger_job' => $jobId
'trigger_job' => $jobId,
];
} else {
$this->logger->info('Next job in chain has unsatisfied dependencies', [
'chain_id' => $chainEntry->chainId,
'next_job_id' => $nextJobId,
'unsatisfied_deps' => count($this->dependencyManager->getUnsatisfiedDependencies($nextJobId))
'unsatisfied_deps' => count($this->dependencyManager->getUnsatisfiedDependencies($nextJobId)),
]);
}
}
@@ -75,7 +76,7 @@ final readonly class DependencyResolutionEngine
$this->logger->info('Dependency resolution completed', [
'trigger_job_id' => $jobId,
'resolved_jobs_count' => count($resolvedJobs),
'resolved_jobs' => array_column($resolvedJobs, 'job_id')
'resolved_jobs' => array_column($resolvedJobs, 'job_id'),
]);
return $resolvedJobs;
@@ -90,7 +91,7 @@ final readonly class DependencyResolutionEngine
$this->logger->debug('Found jobs ready for execution', [
'ready_jobs_count' => count($readyJobs),
'ready_jobs' => $readyJobs
'ready_jobs' => $readyJobs,
]);
return $readyJobs;
@@ -113,17 +114,17 @@ final readonly class DependencyResolutionEngine
'job_id' => $jobId,
'valid' => false,
'error' => 'circular_dependency',
'dependency_chain' => $dependencyChain
'dependency_chain' => $dependencyChain,
];
$this->logger->warning('Circular dependency detected', [
'job_id' => $jobId,
'dependency_chain' => $dependencyChain
'dependency_chain' => $dependencyChain,
]);
} else {
$validationResults[] = [
'job_id' => $jobId,
'valid' => true
'valid' => true,
];
}
}
@@ -145,21 +146,21 @@ final readonly class DependencyResolutionEngine
return [
'job_id' => $jobId,
'can_execute' => $canExecute,
'direct_dependencies' => array_map(fn($dep) => [
'direct_dependencies' => array_map(fn ($dep) => [
'depends_on_job_id' => $dep->dependsOnJobId,
'dependency_type' => $dep->dependencyType,
'is_satisfied' => $dep->isSatisfied,
'condition' => $dep->conditionExpression
'condition' => $dep->conditionExpression,
], $dependencies),
'dependent_jobs' => array_map(fn($dep) => [
'dependent_jobs' => array_map(fn ($dep) => [
'dependent_job_id' => $dep->dependentJobId,
'dependency_type' => $dep->dependencyType,
'is_satisfied' => $dep->isSatisfied
'is_satisfied' => $dep->isSatisfied,
], $dependents),
'unsatisfied_dependencies' => array_map(fn($dep) => [
'unsatisfied_dependencies' => array_map(fn ($dep) => [
'depends_on_job_id' => $dep->dependsOnJobId,
'dependency_type' => $dep->dependencyType,
'condition' => $dep->conditionExpression
'condition' => $dep->conditionExpression,
], $unsatisfiedDeps),
'full_dependency_chain' => $dependencyChain,
'statistics' => [
@@ -167,8 +168,8 @@ final readonly class DependencyResolutionEngine
'satisfied_dependencies' => count($dependencies) - count($unsatisfiedDeps),
'unsatisfied_dependencies' => count($unsatisfiedDeps),
'total_dependents' => count($dependents),
'chain_depth' => count($dependencyChain)
]
'chain_depth' => count($dependencyChain),
],
];
}
@@ -193,14 +194,14 @@ final readonly class DependencyResolutionEngine
'progress' => $progress,
'next_job_after_current' => $nextJob,
'job_position' => $this->getJobPositionInChain($jobId, $chainEntry),
'total_jobs' => count($chainEntry->getJobIdsArray())
'total_jobs' => count($chainEntry->getJobIdsArray()),
];
}
return [
'job_id' => $jobId,
'chains' => $chainAnalysis,
'total_chains' => count($chains)
'total_chains' => count($chains),
];
}
@@ -228,7 +229,7 @@ final readonly class DependencyResolutionEngine
$issues[] = [
'type' => 'stalled_chain',
'chain_id' => $chain->chainId,
'hours_running' => round($hoursRunning, 2)
'hours_running' => round($hoursRunning, 2),
];
}
}
@@ -241,7 +242,7 @@ final readonly class DependencyResolutionEngine
$issues[] = [
'type' => 'many_unsatisfied_dependencies',
'job_id' => $jobId,
'unsatisfied_count' => $analysis['statistics']['unsatisfied_dependencies']
'unsatisfied_count' => $analysis['statistics']['unsatisfied_dependencies'],
];
}
}
@@ -253,14 +254,14 @@ final readonly class DependencyResolutionEngine
'active_chains' => count($activeChains),
'pending_chains' => count($pendingChains),
'ready_jobs' => count($readyJobs),
'detected_issues' => count($issues)
'detected_issues' => count($issues),
],
'issues' => $issues
'issues' => $issues,
];
$this->logger->info('Dependency system health check completed', [
'status' => $healthReport['status'],
'issues_count' => count($issues)
'issues_count' => count($issues),
]);
return $healthReport;
@@ -269,6 +270,7 @@ final readonly class DependencyResolutionEngine
private function getJobPositionInChain(string $jobId, object $chainEntry): ?int
{
$jobIds = $chainEntry->getJobIdsArray();
return array_search($jobId, $jobIds, true) ?: null;
}
}
}

View File

@@ -4,17 +4,17 @@ declare(strict_types=1);
namespace App\Framework\Queue\Services;
use App\Framework\Queue\Entities\Worker;
use App\Framework\Queue\ValueObjects\JobId;
use App\Framework\Queue\ValueObjects\WorkerId;
use App\Framework\Queue\ValueObjects\QueueName;
use App\Framework\Queue\ValueObjects\LockKey;
use App\Framework\Queue\Interfaces\DistributedLockInterface;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\ValueObjects\SqlQuery;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
use App\Framework\Queue\Entities\Worker;
use App\Framework\Queue\Interfaces\DistributedLockInterface;
use App\Framework\Queue\ValueObjects\JobId;
use App\Framework\Queue\ValueObjects\LockKey;
use App\Framework\Queue\ValueObjects\QueueName;
use App\Framework\Queue\ValueObjects\WorkerId;
/**
* Failover und Recovery Service für robustes Distributed Job Processing
@@ -32,7 +32,8 @@ final readonly class FailoverRecoveryService
private DistributedLockInterface $distributedLock,
private ConnectionInterface $connection,
private Logger $logger
) {}
) {
}
/**
* Vollständige Failover und Recovery Prozedur durchführen
@@ -48,7 +49,7 @@ final readonly class FailoverRecoveryService
'jobs_reassigned' => 0,
'workers_cleaned' => 0,
'locks_released' => 0,
'errors' => []
'errors' => [],
];
try {
@@ -56,10 +57,10 @@ final readonly class FailoverRecoveryService
$failedWorkers = $this->detectFailedWorkers();
$results['failed_workers_detected'] = count($failedWorkers);
if (!empty($failedWorkers)) {
if (! empty($failedWorkers)) {
$this->logger->warning('Failed workers detected', LogContext::withData([
'failed_worker_count' => count($failedWorkers),
'worker_ids' => array_map(fn($w) => $w->id->toString(), $failedWorkers)
'worker_ids' => array_map(fn ($w) => $w->id->toString(), $failedWorkers),
]));
// 2. Jobs von fehlgeschlagenen Workern wiederherstellen
@@ -69,7 +70,7 @@ final readonly class FailoverRecoveryService
$results['jobs_reassigned'] += $recovered['jobs_reassigned'];
$results['locks_released'] += $recovered['locks_released'];
if (!empty($recovered['errors'])) {
if (! empty($recovered['errors'])) {
$results['errors'] = array_merge($results['errors'], $recovered['errors']);
}
}
@@ -95,7 +96,7 @@ final readonly class FailoverRecoveryService
$results['errors'][] = 'Recovery process failed: ' . $e->getMessage();
$this->logger->error('Failover and recovery failed', LogContext::withData([
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
'trace' => $e->getTraceAsString(),
]));
}
@@ -125,7 +126,7 @@ final readonly class FailoverRecoveryService
private function isWorkerFailed(Worker $worker): bool
{
// Heartbeat-basierte Überprüfung
if (!$worker->lastHeartbeat) {
if (! $worker->lastHeartbeat) {
return true;
}
@@ -150,14 +151,14 @@ final readonly class FailoverRecoveryService
{
$this->logger->info('Recovering jobs from failed worker', LogContext::withData([
'worker_id' => $worker->id->toString(),
'hostname' => $worker->hostname
'hostname' => $worker->hostname,
]));
$results = [
'jobs_recovered' => 0,
'jobs_reassigned' => 0,
'locks_released' => 0,
'errors' => []
'errors' => [],
];
try {
@@ -165,12 +166,13 @@ final readonly class FailoverRecoveryService
$recoveryLock = LockKey::forWorker($worker->id)->withPrefix('recovery');
$recoveryWorkerId = WorkerId::generate();
if (!$this->distributedLock->acquire(
if (! $this->distributedLock->acquire(
$recoveryLock,
$recoveryWorkerId,
Duration::fromSeconds(self::RECOVERY_LOCK_TTL_SECONDS)
)) {
$results['errors'][] = 'Failed to acquire recovery lock';
return $results;
}
@@ -205,7 +207,7 @@ final readonly class FailoverRecoveryService
$results['errors'][] = 'Worker recovery failed: ' . $e->getMessage();
$this->logger->error('Failed to recover worker jobs', LogContext::withData([
'worker_id' => $worker->id->toString(),
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
}
@@ -223,13 +225,15 @@ final readonly class FailoverRecoveryService
WHERE worker_id = :worker_id";
$result = $this->connection->query(SqlQuery::create($sql, ['worker_id' => $workerId->toString()]));
return $result->fetchAll();
} catch (\Exception $e) {
$this->logger->error('Failed to find worker jobs', LogContext::withData([
'worker_id' => $workerId->toString(),
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
return [];
}
}
@@ -247,7 +251,7 @@ final readonly class FailoverRecoveryService
$this->logger->info('Job successfully reassigned', LogContext::withData([
'job_id' => $jobId->toString(),
'failed_worker_id' => $failedWorkerId->toString(),
'new_worker_id' => $newWorkerId->toString()
'new_worker_id' => $newWorkerId->toString(),
]));
// Failover Event loggen
@@ -262,8 +266,9 @@ final readonly class FailoverRecoveryService
$this->logger->error('Failed to reassign job', LogContext::withData([
'job_id' => $jobId->toString(),
'failed_worker_id' => $failedWorkerId->toString(),
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
return false;
}
}
@@ -282,7 +287,7 @@ final readonly class FailoverRecoveryService
$this->logger->info('Failed worker cleaned up', LogContext::withData([
'worker_id' => $worker->id->toString(),
'hostname' => $worker->hostname
'hostname' => $worker->hostname,
]));
$cleanedCount++;
@@ -290,7 +295,7 @@ final readonly class FailoverRecoveryService
} catch (\Exception $e) {
$this->logger->error('Failed to cleanup worker', LogContext::withData([
'worker_id' => $worker->id->toString(),
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
}
}
@@ -317,7 +322,7 @@ final readonly class FailoverRecoveryService
$result = $this->connection->query(SqlQuery::create($sql, [
'threshold' => self::WORKER_FAILURE_THRESHOLD_SECONDS,
'job_timeout' => self::JOB_TIMEOUT_THRESHOLD_SECONDS
'job_timeout' => self::JOB_TIMEOUT_THRESHOLD_SECONDS,
]));
$orphanedJobs = $result->fetchAll();
@@ -340,22 +345,23 @@ final readonly class FailoverRecoveryService
$this->logger->info('Orphaned job recovered', LogContext::withData([
'job_id' => $jobId->toString(),
'old_worker_id' => $oldWorkerId->toString(),
'new_worker_id' => $newWorkerId->toString()
'new_worker_id' => $newWorkerId->toString(),
]));
}
}
$this->logger->info('Orphaned jobs recovery completed', LogContext::withData([
'total_orphaned' => count($orphanedJobs),
'recovered' => $recoveredCount
'recovered' => $recoveredCount,
]));
return $recoveredCount;
} catch (\Exception $e) {
$this->logger->error('Failed to recover orphaned jobs', LogContext::withData([
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
return 0;
}
}
@@ -375,13 +381,13 @@ final readonly class FailoverRecoveryService
$this->connection->execute(SqlQuery::create($sql, [
'job_id' => $jobId->toString(),
'failed_worker_id' => $failedWorkerId->toString(),
'new_worker_id' => $newWorkerId->toString()
'new_worker_id' => $newWorkerId->toString(),
]));
} catch (\Exception $e) {
$this->logger->warning('Failed to log failover event', LogContext::withData([
'job_id' => $jobId->toString(),
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
}
}
@@ -424,15 +430,16 @@ final readonly class FailoverRecoveryService
'failed_workers' => (int) $stats['failed_workers'],
'recovery_workers' => (int) $stats['recovery_workers'],
'affected_jobs' => (int) $stats['affected_jobs'],
'avg_time_since_failover' => round((float) $stats['avg_time_since_failover'], 2)
'avg_time_since_failover' => round((float) $stats['avg_time_since_failover'], 2),
],
'daily_trends' => $trends
'daily_trends' => $trends,
];
} catch (\Exception $e) {
$this->logger->error('Failed to get failover statistics', LogContext::withData([
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
throw $e;
}
}
@@ -495,22 +502,22 @@ final readonly class FailoverRecoveryService
'worker_availability' => round($activeRatio * 100, 1),
'recent_failovers_24h' => $recentFailovers,
'active_locks' => $lockStats['active_locks'],
'expired_locks' => $lockStats['expired_locks']
'expired_locks' => $lockStats['expired_locks'],
],
'calculated_at' => date('Y-m-d H:i:s')
'calculated_at' => date('Y-m-d H:i:s'),
];
} catch (\Exception $e) {
$this->logger->error('Failed to calculate resilience score', LogContext::withData([
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
return [
'score' => 0,
'status' => 'unknown',
'factors' => ['Calculation failed'],
'error' => $e->getMessage()
'error' => $e->getMessage(),
];
}
}
}
}

View File

@@ -4,17 +4,20 @@ declare(strict_types=1);
namespace App\Framework\Queue\Services;
use App\Framework\Core\Events\EventDispatcherInterface;
use App\Framework\Logging\Logger;
use App\Framework\Queue\Contracts\JobChainManagerInterface;
use App\Framework\Queue\Contracts\JobDependencyManagerInterface;
use App\Framework\Queue\Contracts\QueueInterface;
use App\Framework\Queue\ValueObjects\Job;
use App\Framework\Queue\ValueObjects\JobChain;
use App\Framework\Queue\ValueObjects\ChainExecutionMode;
use App\Framework\Core\Events\EventDispatcherInterface;
use App\Framework\Queue\Events\JobChainStartedEvent;
use App\Framework\Queue\Events\JobChainCompletedEvent;
use App\Framework\Queue\Events\JobChainFailedEvent;
use App\Framework\Logging\Logger;
use App\Framework\Queue\Events\JobChainStartedEvent;
use App\Framework\Queue\Exceptions\ChainNotFoundException;
use App\Framework\Queue\Exceptions\CircularDependencyException;
use App\Framework\Queue\Exceptions\InvalidChainStateException;
use App\Framework\Queue\ValueObjects\ChainExecutionMode;
use App\Framework\Queue\ValueObjects\Job;
use App\Framework\Queue\ValueObjects\JobChain;
final readonly class JobChainExecutionCoordinator
{
@@ -25,7 +28,8 @@ final readonly class JobChainExecutionCoordinator
private EventDispatcherInterface $eventDispatcher,
private DependencyResolutionEngine $resolutionEngine,
private Logger $logger
) {}
) {
}
/**
* Start execution of a job chain
@@ -34,12 +38,12 @@ final readonly class JobChainExecutionCoordinator
{
$chainEntry = $this->chainManager->getChain($chainId);
if (!$chainEntry) {
throw new \RuntimeException("Chain with ID '{$chainId}' not found");
if (! $chainEntry) {
throw ChainNotFoundException::byId($chainId);
}
if (!$chainEntry->isPending()) {
throw new \RuntimeException("Chain '{$chainId}' is not in pending status");
if (! $chainEntry->isPending()) {
throw InvalidChainStateException::notPending($chainId, $chainEntry->status);
}
$jobChain = $chainEntry->getJobChain();
@@ -48,19 +52,20 @@ final readonly class JobChainExecutionCoordinator
'chain_id' => $chainId,
'execution_mode' => $jobChain->executionMode->value,
'job_count' => count($jobChain->jobIds),
'stop_on_failure' => $jobChain->stopOnFailure
'stop_on_failure' => $jobChain->stopOnFailure,
]);
// Validate dependency graph before starting
$validationResults = $this->resolutionEngine->validateDependencyGraph($jobChain->jobIds);
$hasCircularDeps = array_filter($validationResults, fn($result) => !$result['valid']);
$hasCircularDeps = array_filter($validationResults, fn ($result) => ! $result['valid']);
if (!empty($hasCircularDeps)) {
if (! empty($hasCircularDeps)) {
$this->logger->error('Cannot start chain - circular dependencies detected', [
'chain_id' => $chainId,
'circular_dependencies' => $hasCircularDeps
'circular_dependencies' => $hasCircularDeps,
]);
throw new \RuntimeException("Chain '{$chainId}' has circular dependencies");
throw CircularDependencyException::inChain($chainId, $hasCircularDeps);
}
// Mark chain as started
@@ -89,14 +94,14 @@ final readonly class JobChainExecutionCoordinator
{
$this->logger->info('Handling job completion in chain context', [
'job_id' => $jobId,
'successful' => $successful
'successful' => $successful,
]);
// Get all chains this job belongs to
$chains = $this->chainManager->getChainsForJob($jobId);
foreach ($chains as $chainEntry) {
if (!$chainEntry->isRunning()) {
if (! $chainEntry->isRunning()) {
continue;
}
@@ -105,12 +110,13 @@ final readonly class JobChainExecutionCoordinator
$this->logger->info('Processing job completion for chain', [
'chain_id' => $chainEntry->chainId,
'job_id' => $jobId,
'execution_mode' => $jobChain->executionMode->value
'execution_mode' => $jobChain->executionMode->value,
]);
// Handle failure scenarios
if (!$successful && $jobChain->stopOnFailure) {
if (! $successful && $jobChain->stopOnFailure) {
$this->handleChainFailure($chainEntry->chainId, $jobId);
continue;
}
@@ -130,8 +136,8 @@ final readonly class JobChainExecutionCoordinator
{
$chainEntry = $this->chainManager->getChain($chainId);
if (!$chainEntry) {
throw new \RuntimeException("Chain with ID '{$chainId}' not found");
if (! $chainEntry) {
throw ChainNotFoundException::byId($chainId);
}
$progress = $this->chainManager->getChainProgress($chainId);
@@ -148,7 +154,7 @@ final readonly class JobChainExecutionCoordinator
'can_execute' => $dependencyAnalysis['can_execute'],
'dependencies_satisfied' => $dependencyAnalysis['statistics']['satisfied_dependencies'],
'dependencies_total' => $dependencyAnalysis['statistics']['total_dependencies'],
'status' => $this->getJobStatus($jobId) // Would need integration with job status tracking
'status' => $this->getJobStatus($jobId), // Would need integration with job status tracking
];
}
@@ -161,7 +167,7 @@ final readonly class JobChainExecutionCoordinator
'job_statuses' => $jobStatuses,
'started_at' => $chainEntry->startedAt,
'completed_at' => $chainEntry->completedAt,
'metadata' => $chainEntry->getMetadataArray()
'metadata' => $chainEntry->getMetadataArray(),
];
}
@@ -195,13 +201,14 @@ final readonly class JobChainExecutionCoordinator
private function startSequentialExecution(JobChain $jobChain): void
{
$this->logger->info('Starting sequential chain execution', [
'chain_id' => $jobChain->chainId
'chain_id' => $jobChain->chainId,
]);
// Queue the first job that has no unsatisfied dependencies
foreach ($jobChain->jobIds as $jobId) {
if ($this->dependencyManager->canJobBeExecuted($jobId)) {
$this->queueJob($jobId);
break; // Only start first eligible job in sequential mode
}
}
@@ -210,7 +217,7 @@ final readonly class JobChainExecutionCoordinator
private function startParallelExecution(JobChain $jobChain): void
{
$this->logger->info('Starting parallel chain execution', [
'chain_id' => $jobChain->chainId
'chain_id' => $jobChain->chainId,
]);
// Queue all jobs that have no unsatisfied dependencies
@@ -224,7 +231,7 @@ final readonly class JobChainExecutionCoordinator
private function startConditionalExecution(JobChain $jobChain): void
{
$this->logger->info('Starting conditional chain execution', [
'chain_id' => $jobChain->chainId
'chain_id' => $jobChain->chainId,
]);
// Similar to parallel, but with conditional dependency evaluation
@@ -254,14 +261,14 @@ final readonly class JobChainExecutionCoordinator
// Check if any more jobs can be executed
$hasExecutableJobs = false;
foreach ($jobChain->jobIds as $jobId) {
if ($this->dependencyManager->canJobBeExecuted($jobId) && !$this->isJobCompleted($jobId)) {
if ($this->dependencyManager->canJobBeExecuted($jobId) && ! $this->isJobCompleted($jobId)) {
$this->queueJob($jobId);
$hasExecutableJobs = true;
}
}
// Check if chain is complete
if (!$hasExecutableJobs && $this->areAllJobsCompleted($jobChain->jobIds)) {
if (! $hasExecutableJobs && $this->areAllJobsCompleted($jobChain->jobIds)) {
$this->handleChainCompletion($chainEntry->chainId);
}
}
@@ -273,7 +280,7 @@ final readonly class JobChainExecutionCoordinator
// Re-evaluate all dependencies in case conditions have changed
foreach ($jobChain->jobIds as $jobId) {
if ($this->dependencyManager->canJobBeExecuted($jobId) && !$this->isJobCompleted($jobId)) {
if ($this->dependencyManager->canJobBeExecuted($jobId) && ! $this->isJobCompleted($jobId)) {
$this->queueJob($jobId);
}
}
@@ -302,7 +309,7 @@ final readonly class JobChainExecutionCoordinator
{
$this->logger->warning('Chain execution failed', [
'chain_id' => $chainId,
'failed_job_id' => $failedJobId
'failed_job_id' => $failedJobId,
]);
$this->chainManager->markChainAsFailed($chainId);
@@ -344,10 +351,11 @@ final readonly class JobChainExecutionCoordinator
private function areAllJobsCompleted(array $jobIds): bool
{
foreach ($jobIds as $jobId) {
if (!$this->isJobCompleted($jobId)) {
if (! $this->isJobCompleted($jobId)) {
return false;
}
}
return true;
}
}
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Framework\Queue\Services;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Byte;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\ValueObjects\SqlQuery;
use App\Framework\Logging\Logger;
@@ -31,7 +30,8 @@ final readonly class JobCleanupService
private Logger $logger,
private MemoryMonitor $memoryMonitor,
private JobMemoryManager $memoryManager
) {}
) {
}
/**
* Clean up old completed jobs
@@ -44,7 +44,7 @@ final readonly class JobCleanupService
$this->logger->info('Starting completed jobs cleanup', LogContext::withData([
'older_than_days' => $olderThan->toHours() / 24,
'cutoff_date' => $cutoffDate
'cutoff_date' => $cutoffDate,
]));
try {
@@ -61,14 +61,15 @@ final readonly class JobCleanupService
if ($this->memoryManager->shouldAbortJob('cleanup_completed_jobs')) {
$this->logger->warning('Cleanup aborted due to memory constraints', LogContext::withData([
'deleted_so_far' => $totalDeleted,
'batch_count' => $batchCount
'batch_count' => $batchCount,
]));
break;
}
$deleted = $this->connection->execute(SqlQuery::create($sql, [
'cutoff' => $cutoffDate,
'limit' => self::CLEANUP_BATCH_SIZE
'limit' => self::CLEANUP_BATCH_SIZE,
]));
$totalDeleted += $deleted;
$batchCount++;
@@ -77,7 +78,7 @@ final readonly class JobCleanupService
$this->logger->debug('Deleted batch of completed jobs', LogContext::withData([
'batch_size' => $deleted,
'total_deleted' => $totalDeleted,
'batch_number' => $batchCount
'batch_number' => $batchCount,
]));
}
@@ -92,15 +93,16 @@ final readonly class JobCleanupService
$this->logger->info('Completed jobs cleanup finished', LogContext::withData([
'total_deleted' => $totalDeleted,
'batches_processed' => $batchCount
'batches_processed' => $batchCount,
]));
} catch (\Exception $e) {
$this->connection->rollback();
$this->logger->error('Failed to cleanup completed jobs', LogContext::withData([
'error' => $e->getMessage(),
'deleted_before_error' => $totalDeleted
'deleted_before_error' => $totalDeleted,
]));
throw $e;
}
@@ -117,7 +119,7 @@ final readonly class JobCleanupService
$this->logger->info('Starting failed jobs cleanup', LogContext::withData([
'older_than_days' => $olderThan->toHours() / 24,
'cutoff_date' => $cutoffDate
'cutoff_date' => $cutoffDate,
]));
try {
@@ -135,7 +137,7 @@ final readonly class JobCleanupService
$deleted = $this->connection->execute(SqlQuery::create($sql, [
'cutoff' => $cutoffDate,
'limit' => self::CLEANUP_BATCH_SIZE
'limit' => self::CLEANUP_BATCH_SIZE,
]));
$totalDeleted += $deleted;
@@ -148,14 +150,15 @@ final readonly class JobCleanupService
$this->connection->commit();
$this->logger->info('Failed jobs cleanup finished', LogContext::withData([
'total_deleted' => $totalDeleted
'total_deleted' => $totalDeleted,
]));
} catch (\Exception $e) {
$this->connection->rollback();
$this->logger->error('Failed to cleanup failed jobs', LogContext::withData([
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
throw $e;
}
@@ -172,7 +175,7 @@ final readonly class JobCleanupService
$this->logger->info('Starting job metrics cleanup', LogContext::withData([
'older_than_days' => $olderThan->toHours() / 24,
'cutoff_date' => $cutoffDate
'cutoff_date' => $cutoffDate,
]));
try {
@@ -189,7 +192,7 @@ final readonly class JobCleanupService
$deleted = $this->connection->execute(SqlQuery::create($sql, [
'cutoff' => $cutoffDate,
'limit' => self::CLEANUP_BATCH_SIZE
'limit' => self::CLEANUP_BATCH_SIZE,
]));
$totalDeleted += $deleted;
@@ -202,14 +205,15 @@ final readonly class JobCleanupService
$this->connection->commit();
$this->logger->info('Job metrics cleanup finished', LogContext::withData([
'total_deleted' => $totalDeleted
'total_deleted' => $totalDeleted,
]));
} catch (\Exception $e) {
$this->connection->rollback();
$this->logger->error('Failed to cleanup job metrics', LogContext::withData([
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
throw $e;
}
@@ -226,7 +230,7 @@ final readonly class JobCleanupService
$this->logger->info('Starting dead letter jobs cleanup', LogContext::withData([
'older_than_days' => $olderThan->toHours() / 24,
'cutoff_date' => $cutoffDate
'cutoff_date' => $cutoffDate,
]));
try {
@@ -243,7 +247,7 @@ final readonly class JobCleanupService
$deleted = $this->connection->execute(SqlQuery::create($sql, [
'cutoff' => $cutoffDate,
'limit' => self::CLEANUP_BATCH_SIZE
'limit' => self::CLEANUP_BATCH_SIZE,
]));
$totalDeleted += $deleted;
@@ -256,14 +260,15 @@ final readonly class JobCleanupService
$this->connection->commit();
$this->logger->info('Dead letter jobs cleanup finished', LogContext::withData([
'total_deleted' => $totalDeleted
'total_deleted' => $totalDeleted,
]));
} catch (\Exception $e) {
$this->connection->rollback();
$this->logger->error('Failed to cleanup dead letter jobs', LogContext::withData([
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
throw $e;
}
@@ -287,7 +292,7 @@ final readonly class JobCleanupService
'job_metrics' => 0,
'dead_letter_jobs' => 0,
'total_deleted' => 0,
'errors' => []
'errors' => [],
];
// Clean completed jobs
@@ -360,7 +365,7 @@ final readonly class JobCleanupService
$result = $this->connection->queryOne(SqlQuery::create($sql, [
'cutoff' => $this->calculateCutoffDate(
Duration::fromDays(self::DEFAULT_COMPLETED_RETENTION_DAYS)
)
),
]));
$stats['eligible_completed_jobs'] = $result['count'] ?? 0;
@@ -372,7 +377,7 @@ final readonly class JobCleanupService
$result = $this->connection->queryOne(SqlQuery::create($sql, [
'cutoff' => $this->calculateCutoffDate(
Duration::fromDays(self::DEFAULT_FAILED_RETENTION_DAYS)
)
),
]));
$stats['eligible_failed_jobs'] = $result['count'] ?? 0;
@@ -383,7 +388,7 @@ final readonly class JobCleanupService
$result = $this->connection->queryOne(SqlQuery::create($sql, [
'cutoff' => $this->calculateCutoffDate(
Duration::fromDays(self::DEFAULT_METRICS_RETENTION_DAYS)
)
),
]));
$stats['eligible_metrics'] = $result['count'] ?? 0;
@@ -394,7 +399,7 @@ final readonly class JobCleanupService
$result = $this->connection->queryOne(SqlQuery::create($sql, [
'cutoff' => $this->calculateCutoffDate(
Duration::fromDays(self::DEFAULT_DEAD_LETTER_RETENTION_DAYS)
)
),
]));
$stats['eligible_dead_letter_jobs'] = $result['count'] ?? 0;
@@ -411,13 +416,14 @@ final readonly class JobCleanupService
'completed_jobs' => self::DEFAULT_COMPLETED_RETENTION_DAYS,
'failed_jobs' => self::DEFAULT_FAILED_RETENTION_DAYS,
'metrics' => self::DEFAULT_METRICS_RETENTION_DAYS,
'dead_letter_jobs' => self::DEFAULT_DEAD_LETTER_RETENTION_DAYS
'dead_letter_jobs' => self::DEFAULT_DEAD_LETTER_RETENTION_DAYS,
];
} catch (\Exception $e) {
$this->logger->error('Failed to get cleanup statistics', LogContext::withData([
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
throw $e;
}
@@ -431,6 +437,7 @@ final readonly class JobCleanupService
{
$seconds = $olderThan->toSeconds();
$cutoffTimestamp = time() - (int) $seconds;
return date('Y-m-d H:i:s', $cutoffTimestamp);
}
}
}

View File

@@ -4,17 +4,17 @@ declare(strict_types=1);
namespace App\Framework\Queue\Services;
use App\Framework\Queue\Entities\Worker;
use App\Framework\Queue\ValueObjects\JobId;
use App\Framework\Queue\ValueObjects\QueueName;
use App\Framework\Queue\ValueObjects\WorkerId;
use App\Framework\Queue\ValueObjects\LockKey;
use App\Framework\Queue\Interfaces\DistributedLockInterface;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\ValueObjects\SqlQuery;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
use App\Framework\Queue\Entities\Worker;
use App\Framework\Queue\Interfaces\DistributedLockInterface;
use App\Framework\Queue\ValueObjects\JobId;
use App\Framework\Queue\ValueObjects\LockKey;
use App\Framework\Queue\ValueObjects\QueueName;
use App\Framework\Queue\ValueObjects\WorkerId;
/**
* Service für intelligente Job-Verteilung an Worker
@@ -29,7 +29,8 @@ final readonly class JobDistributionService
private DistributedLockInterface $distributedLock,
private ConnectionInterface $connection,
private Logger $logger
) {}
) {
}
/**
* Job an besten verfügbaren Worker verteilen
@@ -38,14 +39,14 @@ final readonly class JobDistributionService
{
$this->logger->info('Starting job distribution', LogContext::withData([
'job_id' => $jobId->toString(),
'queue_name' => $queueName->toString()
'queue_name' => $queueName->toString(),
]));
// Lock für Distribution-Prozess erwerben
$distributionLock = LockKey::forQueue($queueName)->withSuffix('distribution');
$tempWorkerId = WorkerId::generate(); // Temporäre Worker ID für Distribution Lock
if (!$this->distributedLock->acquireWithTimeout(
if (! $this->distributedLock->acquireWithTimeout(
$distributionLock,
$tempWorkerId,
Duration::fromSeconds(self::DISTRIBUTION_LOCK_TTL_SECONDS),
@@ -53,47 +54,51 @@ final readonly class JobDistributionService
)) {
$this->logger->warning('Failed to acquire distribution lock', LogContext::withData([
'job_id' => $jobId->toString(),
'queue_name' => $queueName->toString()
'queue_name' => $queueName->toString(),
]));
return null;
}
try {
// Job Lock erwerben
$jobLock = LockKey::forJob($jobId);
if (!$this->distributedLock->acquire(
if (! $this->distributedLock->acquire(
$jobLock,
$tempWorkerId,
Duration::fromSeconds(self::JOB_LOCK_TTL_SECONDS)
)) {
$this->logger->warning('Job already locked by another process', LogContext::withData([
'job_id' => $jobId->toString()
'job_id' => $jobId->toString(),
]));
return null;
}
// Besten Worker finden
$worker = $this->findBestWorkerForJob($queueName, $jobData);
if (!$worker) {
if (! $worker) {
$this->logger->warning('No suitable worker found', LogContext::withData([
'job_id' => $jobId->toString(),
'queue_name' => $queueName->toString()
'queue_name' => $queueName->toString(),
]));
// Job Lock freigeben
$this->distributedLock->release($jobLock, $tempWorkerId);
return null;
}
// Job Lock an Worker übertragen
if (!$this->transferJobLock($jobLock, $tempWorkerId, $worker->id)) {
if (! $this->transferJobLock($jobLock, $tempWorkerId, $worker->id)) {
$this->logger->error('Failed to transfer job lock to worker', LogContext::withData([
'job_id' => $jobId->toString(),
'worker_id' => $worker->id->toString()
'worker_id' => $worker->id->toString(),
]));
// Job Lock freigeben
$this->distributedLock->release($jobLock, $tempWorkerId);
return null;
}
@@ -104,7 +109,7 @@ final readonly class JobDistributionService
'job_id' => $jobId->toString(),
'worker_id' => $worker->id->toString(),
'queue_name' => $queueName->toString(),
'worker_load' => $worker->getLoadPercentage()->getValue()
'worker_load' => $worker->getLoadPercentage()->getValue(),
]));
return $worker->id;
@@ -131,14 +136,14 @@ final readonly class JobDistributionService
$scoredWorkers = [];
foreach ($workers as $worker) {
if (!$worker->isAvailableForJobs()) {
if (! $worker->isAvailableForJobs()) {
continue;
}
$score = $this->calculateWorkerScore($worker, $jobData);
$scoredWorkers[] = [
'worker' => $worker,
'score' => $score
'score' => $score,
];
}
@@ -147,7 +152,7 @@ final readonly class JobDistributionService
}
// Nach Score sortieren (höchster Score = bester Worker)
usort($scoredWorkers, fn($a, $b) => $b['score'] <=> $a['score']);
usort($scoredWorkers, fn ($a, $b) => $b['score'] <=> $a['score']);
return $scoredWorkers[0]['worker'];
}
@@ -197,7 +202,7 @@ final readonly class JobDistributionService
private function transferJobLock(LockKey $jobLock, WorkerId $fromWorker, WorkerId $toWorker): bool
{
// Neuen Lock für Ziel-Worker erstellen
if (!$this->distributedLock->acquire(
if (! $this->distributedLock->acquire(
$jobLock,
$toWorker,
Duration::fromSeconds(self::JOB_LOCK_TTL_SECONDS)
@@ -228,14 +233,14 @@ final readonly class JobDistributionService
$this->connection->execute(SqlQuery::create($sql, [
'job_id' => $jobId->toString(),
'worker_id' => $workerId->toString(),
'queue_name' => $queueName->toString()
'queue_name' => $queueName->toString(),
]));
} catch (\Exception $e) {
$this->logger->error('Failed to record job assignment', LogContext::withData([
'job_id' => $jobId->toString(),
'worker_id' => $workerId->toString(),
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
// Nicht critical - Job kann trotzdem verarbeitet werden
}
@@ -248,7 +253,7 @@ final readonly class JobDistributionService
{
$this->logger->info('Releasing job from worker', LogContext::withData([
'job_id' => $jobId->toString(),
'worker_id' => $workerId->toString()
'worker_id' => $workerId->toString(),
]));
// Job Lock freigeben
@@ -263,20 +268,20 @@ final readonly class JobDistributionService
$this->connection->execute(SqlQuery::create($sql, [
'job_id' => $jobId->toString(),
'worker_id' => $workerId->toString()
'worker_id' => $workerId->toString(),
]));
} catch (\Exception $e) {
$this->logger->warning('Failed to remove job assignment record', LogContext::withData([
'job_id' => $jobId->toString(),
'worker_id' => $workerId->toString(),
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
}
$this->logger->info('Job released successfully', LogContext::withData([
'job_id' => $jobId->toString(),
'worker_id' => $workerId->toString()
'worker_id' => $workerId->toString(),
]));
}
@@ -291,6 +296,7 @@ final readonly class JobDistributionService
$extension = $extension ?? Duration::fromSeconds(self::JOB_LOCK_TTL_SECONDS);
$jobLock = LockKey::forJob($jobId);
return $this->distributedLock->extend($jobLock, $workerId, $extension);
}
@@ -300,7 +306,7 @@ final readonly class JobDistributionService
public function releaseAllWorkerJobs(WorkerId $workerId): int
{
$this->logger->info('Releasing all jobs for worker', LogContext::withData([
'worker_id' => $workerId->toString()
'worker_id' => $workerId->toString(),
]));
// Alle Locks des Workers freigeben
@@ -310,19 +316,19 @@ final readonly class JobDistributionService
try {
$sql = "DELETE FROM job_assignments WHERE worker_id = :worker_id";
$releasedAssignments = $this->connection->execute(SqlQuery::create($sql, [
'worker_id' => $workerId->toString()
'worker_id' => $workerId->toString(),
]));
$this->logger->info('Released all worker jobs', LogContext::withData([
'worker_id' => $workerId->toString(),
'released_locks' => $releasedLocks,
'released_assignments' => $releasedAssignments
'released_assignments' => $releasedAssignments,
]));
} catch (\Exception $e) {
$this->logger->error('Failed to release worker job assignments', LogContext::withData([
'worker_id' => $workerId->toString(),
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
}
@@ -356,7 +362,7 @@ final readonly class JobDistributionService
'current_jobs' => $worker->currentJobs,
'max_jobs' => $worker->maxJobs,
'load_percentage' => $worker->getLoadPercentage()->getValue(),
'queues' => array_map(fn($q) => $q->toString(), $worker->queues)
'queues' => array_map(fn ($q) => $q->toString(), $worker->queues),
];
}
@@ -368,17 +374,18 @@ final readonly class JobDistributionService
'total' => (int) $assignmentStats['total_assignments'],
'active_workers' => (int) $assignmentStats['active_workers'],
'active_queues' => (int) $assignmentStats['active_queues'],
'avg_assignment_age_seconds' => round((float) $assignmentStats['avg_assignment_age_seconds'], 2)
'avg_assignment_age_seconds' => round((float) $assignmentStats['avg_assignment_age_seconds'], 2),
],
'workers' => $workerStats,
'locks' => $lockStats,
'distribution_health' => $this->calculateDistributionHealth($workerStats)
'distribution_health' => $this->calculateDistributionHealth($workerStats),
];
} catch (\Exception $e) {
$this->logger->error('Failed to get distribution statistics', LogContext::withData([
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
throw $e;
}
}
@@ -392,7 +399,7 @@ final readonly class JobDistributionService
return [
'score' => 0,
'status' => 'critical',
'issues' => ['No active workers']
'issues' => ['No active workers'],
];
}
@@ -421,7 +428,7 @@ final readonly class JobDistributionService
}
// Worker Availability prüfen
$availableWorkers = array_filter($workerStats, fn($w) => $w['load_percentage'] < 80);
$availableWorkers = array_filter($workerStats, fn ($w) => $w['load_percentage'] < 80);
$availabilityRatio = count($availableWorkers) / count($workerStats);
if ($availabilityRatio < 0.3) {
@@ -443,7 +450,7 @@ final readonly class JobDistributionService
'max_load' => $maxLoad,
'load_variance' => $loadVariance,
'availability_ratio' => round($availabilityRatio * 100, 2),
'issues' => $issues
'issues' => $issues,
];
}
}
}

View File

@@ -6,11 +6,11 @@ namespace App\Framework\Queue\Services;
use App\Framework\Core\ValueObjects\Byte;
use App\Framework\Core\ValueObjects\Percentage;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
use App\Framework\Performance\MemoryMonitor;
use App\Framework\Performance\ValueObjects\MemorySummary;
use App\Framework\Queue\ValueObjects\JobMetrics;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
/**
* Manages memory usage and optimization for queue jobs using framework's performance monitoring
@@ -24,7 +24,8 @@ final readonly class JobMemoryManager
public function __construct(
private MemoryMonitor $memoryMonitor,
private Logger $logger
) {}
) {
}
/**
* Get current memory usage for job context
@@ -47,7 +48,7 @@ final readonly class JobMemoryManager
'usage_percentage' => $summary->getUsagePercentageFormatted(),
'is_critical' => $summary->isMemoryCritical(self::CRITICAL_MEMORY_THRESHOLD),
'is_warning' => $summary->isMemoryLow(self::WARNING_MEMORY_THRESHOLD),
'status' => $this->getMemoryStatus($summary)
'status' => $this->getMemoryStatus($summary),
];
}
@@ -65,14 +66,14 @@ final readonly class JobMemoryManager
'phase' => $phase,
'memory' => $summary->getCurrentHumanReadable(),
'usage' => $summary->getUsagePercentageFormatted(),
'available' => $summary->getAvailableMemory()->toHumanReadable()
'available' => $summary->getAvailableMemory()->toHumanReadable(),
]));
} elseif ($summary->isMemoryLow(self::WARNING_MEMORY_THRESHOLD)) {
$this->logger->warning('High memory usage during job execution', LogContext::withData([
'job_id' => $jobId,
'phase' => $phase,
'memory' => $summary->getCurrentHumanReadable(),
'usage' => $summary->getUsagePercentageFormatted()
'usage' => $summary->getUsagePercentageFormatted(),
]));
}
@@ -91,7 +92,7 @@ final readonly class JobMemoryManager
'job_id' => $jobId,
'memory' => $summary->getCurrentHumanReadable(),
'usage' => $summary->getUsagePercentageFormatted(),
'limit' => $summary->getLimitHumanReadable()
'limit' => $summary->getLimitHumanReadable(),
]));
return true;
@@ -123,14 +124,14 @@ final readonly class JobMemoryManager
'freed' => $freedMemory->toHumanReadable(),
'freed_bytes' => $freedMemory->toBytes(),
'usage_before' => $before->getUsagePercentageFormatted(),
'usage_after' => $after->getUsagePercentageFormatted()
'usage_after' => $after->getUsagePercentageFormatted(),
];
$this->logger->debug('Memory optimized for job', LogContext::withData([
'job_id' => $jobId,
'freed' => $freedMemory->toHumanReadable(),
'usage_before' => $before->getUsagePercentageFormatted(),
'usage_after' => $after->getUsagePercentageFormatted()
'usage_after' => $after->getUsagePercentageFormatted(),
]));
return $optimization;
@@ -148,7 +149,7 @@ final readonly class JobMemoryManager
'peak_memory' => Byte::fromBytes(0),
'total_memory' => Byte::fromBytes(0),
'efficiency_score' => Percentage::from(100),
'efficiency_rating' => 'excellent'
'efficiency_rating' => 'excellent',
];
}
@@ -187,7 +188,7 @@ final readonly class JobMemoryManager
'total_memory_human' => $totalMemory->toHumanReadable(),
'efficiency_score' => $efficiencyScore,
'efficiency_score_formatted' => $efficiencyScore->format(2),
'efficiency_rating' => $this->getEfficiencyRating($efficiencyScore)
'efficiency_rating' => $this->getEfficiencyRating($efficiencyScore),
];
}
@@ -209,8 +210,8 @@ final readonly class JobMemoryManager
'Increase memory limit or reduce job complexity',
'Force garbage collection before new jobs',
'Split large jobs into smaller chunks',
'Consider job queue throttling'
]
'Consider job queue throttling',
],
];
} elseif ($summary->isMemoryLow(self::WARNING_MEMORY_THRESHOLD)) {
$priority = 'warning';
@@ -221,8 +222,8 @@ final readonly class JobMemoryManager
'Schedule periodic garbage collection',
'Review job memory patterns',
'Consider optimizing job processing',
'Monitor for memory leaks'
]
'Monitor for memory leaks',
],
];
} else {
$recommendations[] = [
@@ -230,8 +231,8 @@ final readonly class JobMemoryManager
'message' => 'Memory usage is within normal range.',
'actions' => [
'Continue monitoring',
'Maintain current optimization strategies'
]
'Maintain current optimization strategies',
],
];
}
@@ -240,7 +241,7 @@ final readonly class JobMemoryManager
'current_usage' => $summary->getUsagePercentageFormatted(),
'available' => $summary->getAvailableMemory()->toHumanReadable(),
'recommendations' => $recommendations,
'timestamp' => date('Y-m-d H:i:s')
'timestamp' => date('Y-m-d H:i:s'),
];
}
@@ -289,4 +290,4 @@ final readonly class JobMemoryManager
default => 'critical'
};
}
}
}

View File

@@ -4,19 +4,19 @@ declare(strict_types=1);
namespace App\Framework\Queue\Services;
use App\Framework\Queue\ValueObjects\JobMetrics;
use App\Framework\Queue\ValueObjects\QueueMetrics;
use App\Framework\Queue\Entities\JobMetricsEntry;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Logging\Logger;
use App\Framework\Core\ValueObjects\Percentage;
use App\Framework\Queue\Entities\JobMetricsEntry;
use App\Framework\Queue\ValueObjects\JobMetrics;
use App\Framework\Queue\ValueObjects\QueueMetrics;
final readonly class JobMetricsManager
{
public function __construct(
private ConnectionInterface $connection,
private Logger $logger
) {}
) {
}
public function recordJobMetrics(JobMetrics $metrics): void
{
@@ -37,13 +37,14 @@ final readonly class JobMetricsManager
'queue_name' => $metrics->queueName,
'status' => $metrics->status,
'execution_time_ms' => $metrics->executionTimeMs,
'memory_usage_mb' => $metrics->getMemoryUsageMB()
'memory_usage_mb' => $metrics->getMemoryUsageMB(),
]);
}
public function getJobMetrics(string $jobId): ?JobMetrics
{
$entry = $this->findJobMetricsEntry($jobId);
return $entry ? $entry->getJobMetrics() : null;
}
@@ -57,7 +58,7 @@ final readonly class JobMetricsManager
$results = $this->connection->fetchAll($query, [$queueName, $windowStart]);
$jobMetrics = array_map(function(array $row) {
$jobMetrics = array_map(function (array $row) {
return $this->mapRowToJobMetrics($row);
}, $results);
@@ -86,7 +87,7 @@ final readonly class JobMetricsManager
$results = $this->connection->fetchAll($query, [$jobId]);
return array_map(function(array $row) {
return array_map(function (array $row) {
return $this->mapRowToJobMetrics($row);
}, $results);
}
@@ -104,7 +105,7 @@ final readonly class JobMetricsManager
$params[] = $limit;
$results = $this->connection->fetchAll($query, $params);
return array_map(function(array $row) {
return array_map(function (array $row) {
return $this->mapRowToJobMetrics($row);
}, $results);
}
@@ -122,7 +123,7 @@ final readonly class JobMetricsManager
$params[] = $limit;
$results = $this->connection->fetchAll($query, $params);
return array_map(function(array $row) {
return array_map(function (array $row) {
return $this->mapRowToJobMetrics($row);
}, $results);
}
@@ -145,7 +146,7 @@ final readonly class JobMetricsManager
$results = $this->connection->fetchAll($query, $params);
return array_map(function(array $row) {
return array_map(function (array $row) {
return $this->mapRowToJobMetrics($row);
}, $results);
}
@@ -199,7 +200,7 @@ final readonly class JobMetricsManager
'min_memory_usage_bytes' => (int) $result['min_memory_usage_bytes'],
'max_memory_usage_bytes' => (int) $result['max_memory_usage_bytes'],
'average_memory_usage_mb' => (int) $result['avg_memory_usage_bytes'] / (1024 * 1024),
'average_attempts' => (float) $result['avg_attempts']
'average_attempts' => (float) $result['avg_attempts'],
];
}
@@ -235,7 +236,7 @@ final readonly class JobMetricsManager
'queue_name' => $queueName,
'total_completed' => $totalCompleted,
'average_throughput_per_hour' => $avgThroughput,
'hourly_breakdown' => $hourlyStats
'hourly_breakdown' => $hourlyStats,
];
}
@@ -247,13 +248,13 @@ final readonly class JobMetricsManager
$this->logger->info('Cleaning up old job metrics', [
'cutoff_date' => $cutoffDate,
'older_than_days' => $olderThanDays
'older_than_days' => $olderThanDays,
]);
$affectedRows = $this->connection->execute($query, [$cutoffDate]);
$this->logger->info('Old job metrics cleaned up', [
'deleted_count' => $affectedRows
'deleted_count' => $affectedRows,
]);
return $affectedRows;
@@ -301,7 +302,7 @@ final readonly class JobMetricsManager
'overall_success_rate' => $totalJobs > 0 ? ($totalCompleted / $totalJobs) * 100 : 100,
'average_execution_time_ms' => $queueCount > 0 ? $avgExecutionTime / $queueCount : 0,
'average_memory_usage_mb' => $queueCount > 0 ? $avgMemoryUsage / $queueCount : 0,
'queue_metrics' => $allMetrics
'queue_metrics' => $allMetrics,
];
}
@@ -310,7 +311,7 @@ final readonly class JobMetricsManager
$query = "SELECT * FROM job_metrics WHERE job_id = ? ORDER BY updated_at DESC LIMIT 1";
$result = $this->connection->fetchOne($query, [$jobId]);
if (!$result) {
if (! $result) {
return null;
}
@@ -356,4 +357,4 @@ final readonly class JobMetricsManager
metadata: $row['metadata'] ? json_decode($row['metadata'], true) : []
);
}
}
}

View File

@@ -28,4 +28,4 @@ interface JobMetricsManagerInterface
public function getThroughputStats(?string $queueName, string $timeWindow): array;
public function cleanupOldMetrics(int $olderThanDays): int;
}
}

View File

@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace App\Framework\Queue\Services;
use App\Framework\Core\ValueObjects\Percentage;
use App\Framework\Logging\Logger;
use App\Framework\Queue\ValueObjects\JobMetrics;
use App\Framework\Queue\ValueObjects\QueueMetrics;
use App\Framework\Logging\Logger;
use App\Framework\Core\ValueObjects\Percentage;
/**
* Mock implementation of JobMetricsManager for demonstration purposes
@@ -16,7 +16,8 @@ final readonly class MockJobMetricsManager implements JobMetricsManagerInterface
{
public function __construct(
private Logger $logger
) {}
) {
}
public function recordJobMetrics(JobMetrics $metrics): void
{
@@ -45,7 +46,7 @@ final readonly class MockJobMetricsManager implements JobMetricsManagerInterface
'emails' => $this->createMockQueueMetrics('emails', 850, 95.2),
'notifications' => $this->createMockQueueMetrics('notifications', 300, 98.0),
'reports' => $this->createMockQueueMetrics('reports', 100, 85.0),
]
],
];
}
@@ -97,6 +98,7 @@ final readonly class MockJobMetricsManager implements JobMetricsManagerInterface
metadata: []
);
}
return $jobs;
}
@@ -120,6 +122,7 @@ final readonly class MockJobMetricsManager implements JobMetricsManagerInterface
metadata: []
);
}
return $jobs;
}
@@ -143,6 +146,7 @@ final readonly class MockJobMetricsManager implements JobMetricsManagerInterface
metadata: []
);
}
return $jobs;
}
@@ -170,14 +174,14 @@ final readonly class MockJobMetricsManager implements JobMetricsManagerInterface
for ($hour = 0; $hour < 24; $hour++) {
$hourlyData[] = [
'hour' => sprintf('2024-01-15 %02d:00:00', $hour),
'jobs_completed' => rand(10, 50)
'jobs_completed' => rand(10, 50),
];
}
return [
'total_completed' => 800,
'average_throughput_per_hour' => 33.3,
'hourly_breakdown' => $hourlyData
'hourly_breakdown' => $hourlyData,
];
}
@@ -207,4 +211,4 @@ final readonly class MockJobMetricsManager implements JobMetricsManagerInterface
measuredAt: date('Y-m-d H:i:s')
);
}
}
}

View File

@@ -4,13 +4,13 @@ declare(strict_types=1);
namespace App\Framework\Queue\Services;
use App\Framework\Queue\ValueObjects\JobMetrics;
use App\Framework\Queue\ValueObjects\QueueMetrics;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Percentage;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\ValueObjects\SqlQuery;
use App\Framework\Logging\Logger;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Percentage;
use App\Framework\Queue\ValueObjects\JobMetrics;
use App\Framework\Queue\ValueObjects\QueueMetrics;
/**
* Production implementation of JobMetricsManager with full database integration
@@ -20,7 +20,8 @@ final readonly class ProductionJobMetricsManager implements JobMetricsManagerInt
public function __construct(
private ConnectionInterface $connection,
private Logger $logger
) {}
) {
}
public function recordJobMetrics(JobMetrics $metrics): void
{
@@ -49,7 +50,7 @@ final readonly class ProductionJobMetricsManager implements JobMetricsManagerInt
$this->logger->error('Failed to record job metrics', [
'job_id' => $metrics->jobId,
'error' => $e->getMessage()
'error' => $e->getMessage(),
]);
throw $e;
@@ -63,7 +64,7 @@ final readonly class ProductionJobMetricsManager implements JobMetricsManagerInt
SqlQuery::create($query, [$jobId])
);
if (!$result) {
if (! $result) {
return null;
}
@@ -82,13 +83,51 @@ final readonly class ProductionJobMetricsManager implements JobMetricsManagerInt
SqlQuery::create($query, [$queueName, $windowStart])
)->fetchAll();
$jobMetrics = array_map(function(array $row) {
$jobMetrics = array_map(function (array $row) {
return $this->mapRowToJobMetrics($row);
}, $results);
return $this->calculateQueueMetrics($queueName, $jobMetrics, $timeWindow);
}
/**
* Get metrics for all queues in the time window
* Optimized to avoid N+1 query problem
*
* @return array<QueueMetrics>
*/
public function getAllQueueMetrics(string $timeWindow = '1 hour'): array
{
$windowStart = date('Y-m-d H:i:s', strtotime("-{$timeWindow}"));
// Get all metrics in a single query and aggregate in PHP
$query = "SELECT * FROM job_metrics
WHERE created_at >= ?
ORDER BY queue_name, created_at DESC";
$results = $this->connection->query(
SqlQuery::create($query, [$windowStart])
)->fetchAll();
// Group by queue name
$metricsByQueue = [];
foreach ($results as $row) {
$queueName = $row['queue_name'];
if (!isset($metricsByQueue[$queueName])) {
$metricsByQueue[$queueName] = [];
}
$metricsByQueue[$queueName][] = $this->mapRowToJobMetrics($row);
}
// Calculate metrics for each queue
$allMetrics = [];
foreach ($metricsByQueue as $queueName => $jobMetrics) {
$allMetrics[] = $this->calculateQueueMetrics($queueName, $jobMetrics, $timeWindow);
}
return $allMetrics;
}
public function getSystemOverview(): array
{
$windowStart = date('Y-m-d H:i:s', strtotime('-24 hours'));
@@ -149,7 +188,7 @@ final readonly class ProductionJobMetricsManager implements JobMetricsManagerInt
'overall_success_rate' => round($successRate, 1),
'average_execution_time_ms' => round((float) $overallStats['avg_execution_time'], 1),
'average_memory_usage_mb' => round((float) $overallStats['avg_memory_mb'], 1),
'queue_metrics' => $queueMetrics
'queue_metrics' => $queueMetrics,
];
}
@@ -310,7 +349,7 @@ final readonly class ProductionJobMetricsManager implements JobMetricsManagerInt
return [
'total_completed' => $totalCompleted,
'average_throughput_per_hour' => $averageThroughput,
'hourly_breakdown' => $hourlyStats
'hourly_breakdown' => $hourlyStats,
];
}
@@ -325,7 +364,7 @@ final readonly class ProductionJobMetricsManager implements JobMetricsManagerInt
$this->logger->info('Job metrics cleanup completed', [
'cutoff_date' => $cutoffDate,
'deleted_rows' => $affectedRows
'deleted_rows' => $affectedRows,
]);
return $affectedRows;
@@ -354,7 +393,7 @@ final readonly class ProductionJobMetricsManager implements JobMetricsManagerInt
$metrics->completedAt,
$metrics->failedAt,
$metrics->errorMessage,
$metrics->metadata ? json_encode($metrics->metadata) : null
$metrics->metadata ? json_encode($metrics->metadata) : null,
])
);
}
@@ -378,7 +417,7 @@ final readonly class ProductionJobMetricsManager implements JobMetricsManagerInt
$metrics->failedAt,
$metrics->errorMessage,
$metrics->metadata ? json_encode($metrics->metadata) : null,
$metrics->jobId
$metrics->jobId,
])
);
}
@@ -440,18 +479,23 @@ final readonly class ProductionJobMetricsManager implements JobMetricsManagerInt
switch ($metrics->status) {
case 'completed':
$completed++;
break;
case 'failed':
$failed++;
break;
case 'pending':
$pending++;
break;
case 'running':
$running++;
break;
case 'dead_letter':
$deadLetter++;
break;
}
@@ -501,10 +545,11 @@ final readonly class ProductionJobMetricsManager implements JobMetricsManagerInt
try {
// Use framework's Duration class instead of manual parsing
$duration = Duration::parse($timeWindow);
return $duration->toHours();
} catch (\Exception) {
// Fallback to 1 hour if parsing fails
return 1.0;
}
}
}
}

View File

@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace App\Framework\Queue\Services;
use App\Framework\Core\ValueObjects\Percentage;
use App\Framework\Queue\Contracts\JobProgressTrackerInterface;
use App\Framework\Queue\ValueObjects\JobProgress;
use App\Framework\Queue\ValueObjects\ProgressStep;
use App\Framework\Core\ValueObjects\Percentage;
/**
* High-level progress management service for coordinating job progress tracking
@@ -16,7 +16,8 @@ final readonly class ProgressManager
{
public function __construct(
private JobProgressTrackerInterface $progressTracker
) {}
) {
}
public function startJob(string $jobId, string $message = 'Job started'): void
{
@@ -96,4 +97,4 @@ final readonly class ProgressManager
{
return new StepProgressTracker($jobId, $steps, $this);
}
}
}

View File

@@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Framework\Queue\Services;
use App\Framework\Queue\ValueObjects\ProgressStep;
use App\Framework\Core\ValueObjects\Percentage;
use App\Framework\Queue\Exceptions\AllStepsCompletedException;
/**
* Helper class for tracking progress through multiple steps
@@ -13,6 +13,7 @@ use App\Framework\Core\ValueObjects\Percentage;
final class StepProgressTracker
{
private int $currentStepIndex = 0;
private array $completedSteps = [];
public function __construct(
@@ -26,7 +27,7 @@ final class StepProgressTracker
// Validate step structure
foreach ($steps as $index => $step) {
if (!is_array($step) || !isset($step['name'], $step['description'])) {
if (! is_array($step) || ! isset($step['name'], $step['description'])) {
throw new \InvalidArgumentException("Step at index {$index} must have 'name' and 'description' keys");
}
}
@@ -40,7 +41,7 @@ final class StepProgressTracker
public function completeCurrentStep(?array $metadata = null): void
{
if ($this->currentStepIndex >= count($this->steps)) {
throw new \RuntimeException('All steps have already been completed');
throw AllStepsCompletedException::forJob($this->jobId, count($this->steps));
}
$step = $this->steps[$this->currentStepIndex];
@@ -71,7 +72,7 @@ final class StepProgressTracker
[
'current_step' => $this->currentStepIndex,
'total_steps' => count($this->steps),
'completed_steps' => $this->completedSteps
'completed_steps' => $this->completedSteps,
]
);
}
@@ -82,7 +83,7 @@ final class StepProgressTracker
?array $metadata = null
): void {
if ($this->currentStepIndex >= count($this->steps)) {
throw new \RuntimeException('All steps have already been completed');
throw AllStepsCompletedException::forJob($this->jobId, count($this->steps));
}
// Calculate overall percentage: completed steps + current step progress
@@ -104,7 +105,7 @@ final class StepProgressTracker
'total_steps' => count($this->steps),
'step_name' => $step['name'],
'step_percentage' => $stepPercentage,
'completed_steps' => $this->completedSteps
'completed_steps' => $this->completedSteps,
]);
$this->progressManager->updateJobProgress(
@@ -167,4 +168,4 @@ final class StepProgressTracker
{
return ($this->currentStepIndex / count($this->steps)) * 100;
}
}
}

View File

@@ -4,15 +4,14 @@ declare(strict_types=1);
namespace App\Framework\Queue\Services;
use App\Framework\Queue\Entities\Worker;
use App\Framework\Queue\ValueObjects\WorkerId;
use App\Framework\Core\ValueObjects\Percentage;
use App\Framework\Core\ValueObjects\Byte;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Percentage;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\ValueObjects\SqlQuery;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
use App\Framework\Queue\Entities\Worker;
use App\Framework\Queue\ValueObjects\WorkerId;
/**
* Worker Health Check Service für Monitoring und Überwachung
@@ -30,7 +29,8 @@ final readonly class WorkerHealthCheckService
private WorkerRegistry $workerRegistry,
private ConnectionInterface $connection,
private Logger $logger
) {}
) {
}
/**
* Health Check für alle aktiven Worker durchführen
@@ -62,16 +62,16 @@ final readonly class WorkerHealthCheckService
$this->logger->info('Worker health check completed', LogContext::withData([
'total_workers' => count($workers),
'healthy_workers' => count(array_filter($healthResults, fn($h) => $h['status'] === 'healthy')),
'warning_workers' => count(array_filter($healthResults, fn($h) => $h['status'] === 'warning')),
'critical_workers' => count(array_filter($healthResults, fn($h) => $h['status'] === 'critical')),
'overall_status' => $overallHealth['status']
'healthy_workers' => count(array_filter($healthResults, fn ($h) => $h['status'] === 'healthy')),
'warning_workers' => count(array_filter($healthResults, fn ($h) => $h['status'] === 'warning')),
'critical_workers' => count(array_filter($healthResults, fn ($h) => $h['status'] === 'critical')),
'overall_status' => $overallHealth['status'],
]));
return [
'workers' => $healthResults,
'overall' => $overallHealth,
'checked_at' => date('Y-m-d H:i:s')
'checked_at' => date('Y-m-d H:i:s'),
];
}
@@ -128,15 +128,15 @@ final readonly class WorkerHealthCheckService
}
// Aktiv-Status prüfen
if (!$worker->isActive) {
if (! $worker->isActive) {
$issues[] = "Worker marked as inactive";
$score -= 100;
}
// Status basierend auf Score bestimmen
$status = match (true) {
$score <= 30 || !empty($issues) => 'critical',
$score <= 60 || !empty($warnings) => 'warning',
$score <= 30 || ! empty($issues) => 'critical',
$score <= 60 || ! empty($warnings) => 'warning',
default => 'healthy'
};
@@ -153,11 +153,11 @@ final readonly class WorkerHealthCheckService
'job_load_percent' => $loadPercentage,
'current_jobs' => $worker->currentJobs,
'max_jobs' => $worker->maxJobs,
'is_active' => $worker->isActive
'is_active' => $worker->isActive,
],
'issues' => $issues,
'warnings' => $warnings,
'checked_at' => date('Y-m-d H:i:s')
'checked_at' => date('Y-m-d H:i:s'),
];
}
@@ -179,13 +179,13 @@ final readonly class WorkerHealthCheckService
'score' => $healthData['score'],
'metrics' => json_encode($healthData['metrics']),
'issues' => json_encode($healthData['issues']),
'warnings' => json_encode($healthData['warnings'])
'warnings' => json_encode($healthData['warnings']),
]));
} catch (\Exception $e) {
$this->logger->error('Failed to record worker health status', LogContext::withData([
'worker_id' => $workerId->toString(),
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
}
}
@@ -199,7 +199,7 @@ final readonly class WorkerHealthCheckService
'worker_id' => $worker->id->toString(),
'hostname' => $worker->hostname,
'issues' => $healthData['issues'],
'score' => $healthData['score']
'score' => $healthData['score'],
]));
// Worker als inaktiv markieren wenn zu viele kritische Issues
@@ -211,13 +211,13 @@ final readonly class WorkerHealthCheckService
$this->logger->warning('Worker deregistered due to critical issues', LogContext::withData([
'worker_id' => $worker->id->toString(),
'hostname' => $worker->hostname,
'critical_issues' => $criticalIssueCount
'critical_issues' => $criticalIssueCount,
]));
} catch (\Exception $e) {
$this->logger->error('Failed to deregister critical worker', LogContext::withData([
'worker_id' => $worker->id->toString(),
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
}
}
@@ -233,14 +233,14 @@ final readonly class WorkerHealthCheckService
'status' => 'critical',
'score' => 0,
'worker_count' => 0,
'issues' => ['No workers available']
'issues' => ['No workers available'],
];
}
$totalWorkers = count($healthResults);
$healthyWorkers = count(array_filter($healthResults, fn($h) => $h['status'] === 'healthy'));
$warningWorkers = count(array_filter($healthResults, fn($h) => $h['status'] === 'warning'));
$criticalWorkers = count(array_filter($healthResults, fn($h) => $h['status'] === 'critical'));
$healthyWorkers = count(array_filter($healthResults, fn ($h) => $h['status'] === 'healthy'));
$warningWorkers = count(array_filter($healthResults, fn ($h) => $h['status'] === 'warning'));
$criticalWorkers = count(array_filter($healthResults, fn ($h) => $h['status'] === 'critical'));
// Durchschnittlicher Score
$avgScore = array_sum(array_column($healthResults, 'score')) / $totalWorkers;
@@ -274,7 +274,7 @@ final readonly class WorkerHealthCheckService
'warning_workers' => $warningWorkers,
'critical_workers' => $criticalWorkers,
'healthy_percentage' => round($healthyPercentage, 1),
'issues' => $overallIssues
'issues' => $overallIssues,
];
}
@@ -294,7 +294,7 @@ final readonly class WorkerHealthCheckService
$result = $this->connection->query(SqlQuery::create($sql, [
'worker_id' => $workerId->toString(),
'hours' => $period->toHours()
'hours' => $period->toHours(),
]));
$history = [];
@@ -305,7 +305,7 @@ final readonly class WorkerHealthCheckService
'metrics' => json_decode($row['metrics'], true),
'issues' => json_decode($row['issues'], true),
'warnings' => json_decode($row['warnings'], true),
'checked_at' => $row['checked_at']
'checked_at' => $row['checked_at'],
];
}
@@ -314,8 +314,9 @@ final readonly class WorkerHealthCheckService
} catch (\Exception $e) {
$this->logger->error('Failed to get worker health history', LogContext::withData([
'worker_id' => $workerId->toString(),
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
throw $e;
}
}
@@ -365,13 +366,14 @@ final readonly class WorkerHealthCheckService
'current_health' => $currentHealth,
'trends_24h' => $trends,
'top_issues_24h' => $topIssues,
'generated_at' => date('Y-m-d H:i:s')
'generated_at' => date('Y-m-d H:i:s'),
];
} catch (\Exception $e) {
$this->logger->error('Failed to generate system health report', LogContext::withData([
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
throw $e;
}
}
@@ -388,21 +390,22 @@ final readonly class WorkerHealthCheckService
WHERE checked_at < DATE_SUB(NOW(), INTERVAL :hours HOUR)";
$deletedCount = $this->connection->execute(SqlQuery::create($sql, [
'hours' => $retentionPeriod->toHours()
'hours' => $retentionPeriod->toHours(),
]));
$this->logger->info('Health check cleanup completed', LogContext::withData([
'deleted_records' => $deletedCount,
'retention_days' => $retentionPeriod->toHours() / 24
'retention_days' => $retentionPeriod->toHours() / 24,
]));
return $deletedCount;
} catch (\Exception $e) {
$this->logger->error('Failed to cleanup health checks', LogContext::withData([
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
throw $e;
}
}
}
}

View File

@@ -4,15 +4,15 @@ declare(strict_types=1);
namespace App\Framework\Queue\Services;
use App\Framework\Queue\Entities\Worker;
use App\Framework\Queue\ValueObjects\WorkerId;
use App\Framework\Queue\ValueObjects\QueueName;
use App\Framework\Core\ValueObjects\Byte;
use App\Framework\Core\ValueObjects\Percentage;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\ValueObjects\SqlQuery;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
use App\Framework\Core\ValueObjects\Percentage;
use App\Framework\Core\ValueObjects\Byte;
use App\Framework\Queue\Entities\Worker;
use App\Framework\Queue\ValueObjects\QueueName;
use App\Framework\Queue\ValueObjects\WorkerId;
/**
* Worker Registry für Distributed Job Processing
@@ -24,7 +24,8 @@ final readonly class WorkerRegistry
public function __construct(
private ConnectionInterface $connection,
private Logger $logger
) {}
) {
}
/**
* Worker registrieren
@@ -36,7 +37,7 @@ final readonly class WorkerRegistry
'hostname' => $worker->hostname,
'process_id' => $worker->processId,
'queues' => $worker->queues,
'max_jobs' => $worker->maxJobs
'max_jobs' => $worker->maxJobs,
]));
try {
@@ -48,29 +49,31 @@ final readonly class WorkerRegistry
:id, :hostname, :process_id, :queues, :max_jobs, :current_jobs,
:is_active, :cpu_usage, :memory_usage_bytes, :registered_at,
:last_heartbeat, :capabilities, :version
) ON DUPLICATE KEY UPDATE
is_active = VALUES(is_active),
last_heartbeat = VALUES(last_heartbeat),
cpu_usage = VALUES(cpu_usage),
memory_usage_bytes = VALUES(memory_usage_bytes),
current_jobs = VALUES(current_jobs),
capabilities = VALUES(capabilities),
version = VALUES(version)";
) ON CONFLICT (id) DO UPDATE SET
is_active = EXCLUDED.is_active,
last_heartbeat = EXCLUDED.last_heartbeat,
cpu_usage = EXCLUDED.cpu_usage,
memory_usage_bytes = EXCLUDED.memory_usage_bytes,
current_jobs = EXCLUDED.current_jobs,
capabilities = EXCLUDED.capabilities,
version = EXCLUDED.version";
$data = $worker->toArray();
$data['is_active'] = $data['is_active'] ? 1 : 0;
// PostgreSQL expects boolean values directly
$data['is_active'] = $data['is_active'] ? true : false;
$this->connection->execute(SqlQuery::create($sql, $data));
$this->logger->debug('Worker registered successfully', LogContext::withData([
'worker_id' => $worker->id->toString()
'worker_id' => $worker->id->toString(),
]));
} catch (\Exception $e) {
$this->logger->error('Failed to register worker', LogContext::withData([
'worker_id' => $worker->id->toString(),
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
throw $e;
}
}
@@ -81,25 +84,26 @@ final readonly class WorkerRegistry
public function deregister(WorkerId $workerId): void
{
$this->logger->info('Deregistering worker', LogContext::withData([
'worker_id' => $workerId->toString()
'worker_id' => $workerId->toString(),
]));
try {
$sql = "UPDATE " . self::TABLE_NAME . "
SET is_active = 0, last_heartbeat = NOW()
SET is_active = false, last_heartbeat = NOW()
WHERE id = :id";
$this->connection->execute(SqlQuery::create($sql, ['id' => $workerId->toString()]));
$this->logger->debug('Worker deregistered successfully', LogContext::withData([
'worker_id' => $workerId->toString()
'worker_id' => $workerId->toString(),
]));
} catch (\Exception $e) {
$this->logger->error('Failed to deregister worker', LogContext::withData([
'worker_id' => $workerId->toString(),
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
throw $e;
}
}
@@ -119,27 +123,28 @@ final readonly class WorkerRegistry
cpu_usage = :cpu_usage,
memory_usage_bytes = :memory_usage_bytes,
current_jobs = :current_jobs,
is_active = 1
is_active = true
WHERE id = :id";
$affectedRows = $this->connection->execute(SqlQuery::create($sql, [
'id' => $workerId->toString(),
'cpu_usage' => $cpuUsage->getValue(),
'memory_usage_bytes' => $memoryUsage->toBytes(),
'current_jobs' => $currentJobs
'current_jobs' => $currentJobs,
]));
if ($affectedRows === 0) {
$this->logger->warning('Heartbeat update failed - worker not found', LogContext::withData([
'worker_id' => $workerId->toString()
'worker_id' => $workerId->toString(),
]));
}
} catch (\Exception $e) {
$this->logger->error('Failed to update worker heartbeat', LogContext::withData([
'worker_id' => $workerId->toString(),
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
throw $e;
}
}
@@ -152,7 +157,7 @@ final readonly class WorkerRegistry
try {
$sql = "SELECT * FROM " . self::TABLE_NAME . " WHERE id = :id";
$data = $this->connection->queryOne(SqlQuery::create($sql, ['id' => $workerId->toString()]));
if (!$data) {
if (! $data) {
return null;
}
@@ -161,8 +166,9 @@ final readonly class WorkerRegistry
} catch (\Exception $e) {
$this->logger->error('Failed to find worker by ID', LogContext::withData([
'worker_id' => $workerId->toString(),
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
throw $e;
}
}
@@ -174,8 +180,8 @@ final readonly class WorkerRegistry
{
try {
$sql = "SELECT * FROM " . self::TABLE_NAME . "
WHERE is_active = 1
AND last_heartbeat > DATE_SUB(NOW(), INTERVAL 2 MINUTE)
WHERE is_active = true
AND last_heartbeat > NOW() - INTERVAL '2 minutes'
ORDER BY hostname, process_id";
$result = $this->connection->query(SqlQuery::create($sql));
@@ -188,8 +194,9 @@ final readonly class WorkerRegistry
} catch (\Exception $e) {
$this->logger->error('Failed to find active workers', LogContext::withData([
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
throw $e;
}
}
@@ -201,9 +208,9 @@ final readonly class WorkerRegistry
{
try {
$sql = "SELECT * FROM " . self::TABLE_NAME . "
WHERE is_active = 1
AND last_heartbeat > DATE_SUB(NOW(), INTERVAL 2 MINUTE)
AND JSON_CONTAINS(queues, JSON_QUOTE(:queue_name))
WHERE is_active = true
AND last_heartbeat > NOW() - INTERVAL '2 minutes'
AND queues::jsonb @> to_jsonb(:queue_name::text)
ORDER BY current_jobs ASC, cpu_usage ASC";
$result = $this->connection->query(SqlQuery::create($sql, ['queue_name' => $queueName->toString()]));
@@ -220,8 +227,9 @@ final readonly class WorkerRegistry
} catch (\Exception $e) {
$this->logger->error('Failed to find workers for queue', LogContext::withData([
'queue_name' => $queueName->toString(),
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
throw $e;
}
}
@@ -251,28 +259,29 @@ final readonly class WorkerRegistry
public function cleanupInactiveWorkers(int $inactiveMinutes = 5): int
{
$this->logger->info('Starting cleanup of inactive workers', LogContext::withData([
'inactive_minutes' => $inactiveMinutes
'inactive_minutes' => $inactiveMinutes,
]));
try {
$sql = "UPDATE " . self::TABLE_NAME . "
SET is_active = 0
WHERE is_active = 1
AND (last_heartbeat IS NULL OR last_heartbeat < DATE_SUB(NOW(), INTERVAL :minutes MINUTE))";
SET is_active = false
WHERE is_active = true
AND (last_heartbeat IS NULL OR last_heartbeat < NOW() - INTERVAL '1 minute' * :minutes)";
$count = $this->connection->execute(SqlQuery::create($sql, ['minutes' => $inactiveMinutes]));
$this->logger->info('Inactive workers cleanup completed', LogContext::withData([
'deactivated_count' => $count,
'inactive_minutes' => $inactiveMinutes
'inactive_minutes' => $inactiveMinutes,
]));
return $count;
} catch (\Exception $e) {
$this->logger->error('Failed to cleanup inactive workers', LogContext::withData([
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
throw $e;
}
}
@@ -285,8 +294,8 @@ final readonly class WorkerRegistry
try {
$sql = "SELECT
COUNT(*) as total_workers,
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) as active_workers,
SUM(CASE WHEN is_active = 1 AND last_heartbeat > DATE_SUB(NOW(), INTERVAL 2 MINUTE) THEN 1 ELSE 0 END) as healthy_workers,
SUM(CASE WHEN is_active = true THEN 1 ELSE 0 END) as active_workers,
SUM(CASE WHEN is_active = true AND last_heartbeat > NOW() - INTERVAL '2 minutes' THEN 1 ELSE 0 END) as healthy_workers,
SUM(max_jobs) as total_capacity,
SUM(current_jobs) as current_load,
AVG(cpu_usage) as avg_cpu_usage,
@@ -311,13 +320,14 @@ final readonly class WorkerRegistry
: 0,
'avg_cpu_usage' => round((float) $stats['avg_cpu_usage'], 2),
'avg_memory_usage_mb' => round(((float) $stats['avg_memory_usage']) / 1024 / 1024, 2),
'queue_distribution' => $queueStats
'queue_distribution' => $queueStats,
];
} catch (\Exception $e) {
$this->logger->error('Failed to get worker statistics', LogContext::withData([
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
throw $e;
}
}
@@ -328,7 +338,7 @@ final readonly class WorkerRegistry
private function getQueueStatistics(): array
{
try {
$sql = "SELECT queues FROM " . self::TABLE_NAME . " WHERE is_active = 1";
$sql = "SELECT queues FROM " . self::TABLE_NAME . " WHERE is_active = true";
$result = $this->connection->query(SqlQuery::create($sql));
$queueCounts = [];
@@ -340,12 +350,14 @@ final readonly class WorkerRegistry
}
arsort($queueCounts);
return $queueCounts;
} catch (\Exception $e) {
$this->logger->warning('Failed to get queue statistics', LogContext::withData([
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
return [];
}
}
@@ -357,9 +369,9 @@ final readonly class WorkerRegistry
{
try {
$sql = "SELECT * FROM " . self::TABLE_NAME . "
WHERE is_active = 1
AND last_heartbeat > DATE_SUB(NOW(), INTERVAL 2 MINUTE)
AND JSON_CONTAINS(capabilities, JSON_QUOTE(:capability))
WHERE is_active = true
AND last_heartbeat > NOW() - INTERVAL '2 minutes'
AND capabilities::jsonb @> to_jsonb(:capability::text)
ORDER BY current_jobs ASC";
$result = $this->connection->query(SqlQuery::create($sql, ['capability' => $capability]));
@@ -376,9 +388,10 @@ final readonly class WorkerRegistry
} catch (\Exception $e) {
$this->logger->error('Failed to find workers with capability', LogContext::withData([
'capability' => $capability,
'error' => $e->getMessage()
'error' => $e->getMessage(),
]));
throw $e;
}
}
}
}

View File

@@ -36,4 +36,4 @@ enum ChainExecutionMode: string
{
return $this === self::CONDITIONAL;
}
}
}

View File

@@ -56,7 +56,7 @@ final readonly class DeadLetterQueueName
throw InvalidDeadLetterQueueNameException::tooLong($this->name, self::MAX_LENGTH);
}
if (!preg_match(self::VALID_PATTERN, $this->name)) {
if (! preg_match(self::VALID_PATTERN, $this->name)) {
throw InvalidDeadLetterQueueNameException::invalidFormat($this->name, self::VALID_PATTERN);
}
}
@@ -65,4 +65,4 @@ final readonly class DeadLetterQueueName
{
return $this->name;
}
}
}

View File

@@ -36,4 +36,4 @@ enum DependencyType: string
{
return $this === self::CONDITIONAL;
}
}
}

View File

@@ -61,7 +61,7 @@ final readonly class FailureReason
return [
'message' => $this->message,
'exception_type' => $this->exceptionType,
'has_stack_trace' => $this->hasStackTrace()
'has_stack_trace' => $this->hasStackTrace(),
];
}
@@ -71,4 +71,4 @@ final readonly class FailureReason
throw new \InvalidArgumentException('Failure reason message cannot be empty');
}
}
}
}

View File

@@ -150,7 +150,7 @@ final readonly class JobBatch
'started_at' => $this->startedAt?->toRfc3339(),
'completed_at' => $this->completedAt?->toRfc3339(),
'failed_at' => $this->failedAt?->toRfc3339(),
'options' => $this->options
'options' => $this->options,
];
}
@@ -191,4 +191,4 @@ final readonly class JobBatch
throw new \InvalidArgumentException('Processed + failed jobs cannot exceed total jobs');
}
}
}
}

View File

@@ -52,4 +52,4 @@ enum JobBatchStatus: string
self::CANCELLED => '🚫'
};
}
}
}

View File

@@ -82,6 +82,7 @@ final readonly class JobChain
public function getJobPosition(string $jobId): ?int
{
$position = array_search($jobId, $this->jobIds, true);
return $position !== false ? $position : null;
}
@@ -138,7 +139,7 @@ final readonly class JobChain
'execution_mode' => $this->executionMode->value,
'stop_on_failure' => $this->stopOnFailure,
'job_count' => $this->getJobCount(),
'metadata' => $this->metadata
'metadata' => $this->metadata,
];
}
@@ -162,4 +163,4 @@ final readonly class JobChain
}
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More