docs: consolidate documentation into organized structure

- Move 12 markdown files from root to docs/ subdirectories
- Organize documentation by category:
  • docs/troubleshooting/ (1 file)  - Technical troubleshooting guides
  • docs/deployment/      (4 files) - Deployment and security documentation
  • docs/guides/          (3 files) - Feature-specific guides
  • docs/planning/        (4 files) - Planning and improvement proposals

Root directory cleanup:
- Reduced from 16 to 4 markdown files in root
- Only essential project files remain:
  • CLAUDE.md (AI instructions)
  • README.md (Main project readme)
  • CLEANUP_PLAN.md (Current cleanup plan)
  • SRC_STRUCTURE_IMPROVEMENTS.md (Structure improvements)

This improves:
 Documentation discoverability
 Logical organization by purpose
 Clean root directory
 Better maintainability
This commit is contained in:
2025-10-05 11:05:04 +02:00
parent 887847dde6
commit 5050c7d73a
36686 changed files with 196456 additions and 12398919 deletions

View File

@@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
namespace App\Framework\Queue\Services;
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\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;
/**
* Database-backed Dead Letter Queue implementation
*/
final readonly class DatabaseDeadLetterQueue implements DeadLetterQueueInterface
{
public function __construct(
private ConnectionInterface $connection,
private EntityManager $entityManager,
private QueueInterface $originalQueue
) {}
public function addFailedJob(DeadLetterJob $deadLetterJob): void
{
$this->entityManager->persist($deadLetterJob);
$this->entityManager->flush();
}
public function getJobs(DeadLetterQueueName $deadLetterQueueName, int $limit = 100): array
{
$sql = "
SELECT * FROM dead_letter_jobs
WHERE dead_letter_queue = ?
ORDER BY moved_to_dlq_at DESC
LIMIT ?
";
$result = $this->connection->query(SqlQuery::create($sql, [$deadLetterQueueName->toString(), $limit]));
$rows = $result->fetchAll();
return array_map([$this, 'mapRowToDeadLetterJob'], $rows);
}
public function getJobsByOriginalQueue(QueueName $originalQueue, int $limit = 100): array
{
$sql = "
SELECT * FROM dead_letter_jobs
WHERE original_queue = ?
ORDER BY moved_to_dlq_at DESC
LIMIT ?
";
$result = $this->connection->query(SqlQuery::create($sql, [$originalQueue->toString(), $limit]));
$rows = $result->fetchAll();
return array_map([$this, 'mapRowToDeadLetterJob'], $rows);
}
public function retryJob(string $deadLetterJobId): bool
{
$this->connection->beginTransaction();
try {
// Get the dead letter job
$deadLetterJob = $this->findDeadLetterJob($deadLetterJobId);
if (!$deadLetterJob) {
return false;
}
// Add job back to original queue
$jobPayload = $deadLetterJob->getJobPayload();
$originalQueue = $deadLetterJob->getOriginalQueueName();
$this->originalQueue->push(
payload: $jobPayload,
queueName: $originalQueue,
priority: QueuePriority::NORMAL
);
// Update retry count and timestamp
$updatedJob = $deadLetterJob->withRetryAttempt();
$this->entityManager->persist($updatedJob);
// Delete from dead letter queue
$this->deleteJobById($deadLetterJobId);
$this->entityManager->flush();
$this->connection->commit();
return true;
} catch (\Throwable $e) {
$this->connection->rollback();
throw $e;
}
}
public function deleteJob(string $deadLetterJobId): bool
{
return $this->deleteJobById($deadLetterJobId);
}
public function retryAllJobs(DeadLetterQueueName $deadLetterQueueName): int
{
$jobs = $this->getJobs($deadLetterQueueName, 1000); // Get all jobs
$retriedCount = 0;
foreach ($jobs as $job) {
if ($this->retryJob($job->id)) {
$retriedCount++;
}
}
return $retriedCount;
}
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()]));
}
public function getQueueStats(DeadLetterQueueName $deadLetterQueueName): array
{
$sql = "
SELECT
COUNT(*) as total_jobs,
AVG(failed_attempts) as avg_failed_attempts,
MAX(failed_attempts) as max_failed_attempts,
AVG(retry_count) as avg_retry_count,
MAX(retry_count) as max_retry_count,
MIN(moved_to_dlq_at) as oldest_job,
MAX(moved_to_dlq_at) as newest_job
FROM dead_letter_jobs
WHERE dead_letter_queue = ?
";
$stats = $this->connection->queryOne(SqlQuery::create($sql, [$deadLetterQueueName->toString()]));
return [
'queue_name' => $deadLetterQueueName->toString(),
'total_jobs' => (int) $stats['total_jobs'],
'avg_failed_attempts' => round((float) $stats['avg_failed_attempts'], 2),
'max_failed_attempts' => (int) $stats['max_failed_attempts'],
'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']
];
}
public function getAvailableQueues(): array
{
$sql = "SELECT DISTINCT dead_letter_queue FROM dead_letter_jobs ORDER BY dead_letter_queue";
$result = $this->connection->query(SqlQuery::create($sql));
$rows = $result->fetchAll();
return array_map(
fn (array $row) => DeadLetterQueueName::fromString($row['dead_letter_queue']),
$rows
);
}
private function findDeadLetterJob(string $deadLetterJobId): ?DeadLetterJob
{
$sql = "SELECT * FROM dead_letter_jobs WHERE id = ?";
$row = $this->connection->queryOne(SqlQuery::create($sql, [$deadLetterJobId]));
return $row ? $this->mapRowToDeadLetterJob($row) : null;
}
private function deleteJobById(string $deadLetterJobId): bool
{
$sql = "DELETE FROM dead_letter_jobs WHERE id = ?";
return $this->connection->execute(SqlQuery::create($sql, [$deadLetterJobId])) > 0;
}
private function mapRowToDeadLetterJob(array $row): DeadLetterJob
{
return new DeadLetterJob(
id: $row['id'],
originalJobId: $row['original_job_id'],
deadLetterQueue: $row['dead_letter_queue'],
originalQueue: $row['original_queue'],
jobPayload: $row['job_payload'],
failureReason: $row['failure_reason'],
exceptionType: $row['exception_type'],
stackTrace: $row['stack_trace'],
failedAttempts: (int) $row['failed_attempts'],
failedAt: $row['failed_at'],
movedToDlqAt: $row['moved_to_dlq_at'],
retryCount: (int) $row['retry_count'],
lastRetryAt: $row['last_retry_at']
);
}
}

View File

@@ -0,0 +1,411 @@
<?php
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;
/**
* Database-basierte Distributed Lock Implementation
*/
final readonly class DatabaseDistributedLock implements DistributedLockInterface
{
private const TABLE_NAME = 'distributed_locks';
private const MAX_TIMEOUT_ATTEMPTS = 100;
private const TIMEOUT_SLEEP_MS = 100;
public function __construct(
private ConnectionInterface $connection,
private Logger $logger
) {}
/**
* Lock erwerben
*/
public function acquire(LockKey $key, WorkerId $workerId, Duration $ttl): bool
{
$this->logger->debug('Attempting to acquire lock', LogContext::withData([
'lock_key' => $key->toString(),
'worker_id' => $workerId->toString(),
'ttl_seconds' => $ttl->toSeconds()
]));
try {
$expiresAt = new \DateTimeImmutable('+' . $ttl->toSeconds() . ' seconds');
// Versuche Lock zu erstellen
$sql = "INSERT INTO " . self::TABLE_NAME . " (
lock_key, worker_id, acquired_at, expires_at
) VALUES (
:lock_key, :worker_id, NOW(), :expires_at
)";
$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')
]));
if ($affectedRows > 0) {
$this->logger->info('Lock acquired successfully', LogContext::withData([
'lock_key' => $key->toString(),
'worker_id' => $workerId->toString()
]));
return true;
}
return false;
} catch (\Exception $e) {
// Duplicate key error = Lock bereits vorhanden
if ($this->isDuplicateKeyError($e)) {
$this->logger->debug('Lock already exists', LogContext::withData([
'lock_key' => $key->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()
]));
throw $e;
}
}
/**
* Lock verlängern
*/
public function extend(LockKey $key, WorkerId $workerId, Duration $ttl): bool
{
$this->logger->debug('Attempting to extend lock', LogContext::withData([
'lock_key' => $key->toString(),
'worker_id' => $workerId->toString(),
'ttl_seconds' => $ttl->toSeconds()
]));
try {
$expiresAt = new \DateTimeImmutable('+' . $ttl->toSeconds() . ' seconds');
$sql = "UPDATE " . self::TABLE_NAME . "
SET expires_at = :expires_at
WHERE lock_key = :lock_key
AND worker_id = :worker_id
AND expires_at > NOW()";
$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')
]));
$success = $affectedRows > 0;
if ($success) {
$this->logger->debug('Lock extended successfully', LogContext::withData([
'lock_key' => $key->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()
]));
}
return $success;
} catch (\Exception $e) {
$this->logger->error('Failed to extend lock', LogContext::withData([
'lock_key' => $key->toString(),
'worker_id' => $workerId->toString(),
'error' => $e->getMessage()
]));
throw $e;
}
}
/**
* Lock freigeben
*/
public function release(LockKey $key, WorkerId $workerId): bool
{
$this->logger->debug('Attempting to release lock', LogContext::withData([
'lock_key' => $key->toString(),
'worker_id' => $workerId->toString()
]));
try {
$sql = "DELETE FROM " . self::TABLE_NAME . "
WHERE lock_key = :lock_key
AND worker_id = :worker_id";
$affectedRows = $this->connection->execute(SqlQuery::create($sql, [
'lock_key' => $key->toString(),
'worker_id' => $workerId->toString()
]));
$success = $affectedRows > 0;
if ($success) {
$this->logger->info('Lock released successfully', LogContext::withData([
'lock_key' => $key->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()
]));
}
return $success;
} catch (\Exception $e) {
$this->logger->error('Failed to release lock', LogContext::withData([
'lock_key' => $key->toString(),
'worker_id' => $workerId->toString(),
'error' => $e->getMessage()
]));
throw $e;
}
}
/**
* Prüfe ob Lock existiert
*/
public function exists(LockKey $key): bool
{
try {
$sql = "SELECT COUNT(*) as count FROM " . self::TABLE_NAME . "
WHERE lock_key = :lock_key
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()
]));
throw $e;
}
}
/**
* Lock Informationen abrufen
*/
public function getLockInfo(LockKey $key): ?array
{
try {
$sql = "SELECT * FROM " . self::TABLE_NAME . "
WHERE lock_key = :lock_key
AND expires_at > NOW()";
$data = $this->connection->queryOne(SqlQuery::create($sql, ['lock_key' => $key->toString()]));
if (!$data) {
return null;
}
return [
'lock_key' => $data['lock_key'],
'worker_id' => $data['worker_id'],
'acquired_at' => $data['acquired_at'],
'expires_at' => $data['expires_at'],
'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()
]));
throw $e;
}
}
/**
* Lock mit Timeout versuchen
*/
public function acquireWithTimeout(LockKey $key, WorkerId $workerId, Duration $ttl, Duration $timeout): bool
{
$this->logger->debug('Attempting to acquire lock with timeout', LogContext::withData([
'lock_key' => $key->toString(),
'worker_id' => $workerId->toString(),
'ttl_seconds' => $ttl->toSeconds(),
'timeout_seconds' => $timeout->toSeconds()
]));
$startTime = microtime(true);
$timeoutSeconds = $timeout->toSeconds();
$attempts = 0;
$maxAttempts = min(self::MAX_TIMEOUT_ATTEMPTS, (int) ($timeoutSeconds * 1000 / self::TIMEOUT_SLEEP_MS));
while ($attempts < $maxAttempts) {
// Prüfe Timeout
if ((microtime(true) - $startTime) >= $timeoutSeconds) {
$this->logger->debug('Lock acquisition timeout reached', LogContext::withData([
'lock_key' => $key->toString(),
'worker_id' => $workerId->toString(),
'attempts' => $attempts,
'elapsed_seconds' => microtime(true) - $startTime
]));
break;
}
// Versuche Lock zu erwerben
if ($this->acquire($key, $workerId, $ttl)) {
$this->logger->info('Lock acquired with timeout after attempts', LogContext::withData([
'lock_key' => $key->toString(),
'worker_id' => $workerId->toString(),
'attempts' => $attempts + 1,
'elapsed_seconds' => microtime(true) - $startTime
]));
return true;
}
$attempts++;
// Kurz warten vor nächstem Versuch
usleep(self::TIMEOUT_SLEEP_MS * 1000);
}
$this->logger->info('Failed to acquire lock within timeout', LogContext::withData([
'lock_key' => $key->toString(),
'worker_id' => $workerId->toString(),
'attempts' => $attempts,
'timeout_seconds' => $timeoutSeconds
]));
return false;
}
/**
* Alle Locks eines Workers freigeben
*/
public function releaseAllWorkerLocks(WorkerId $workerId): int
{
$this->logger->info('Releasing all locks for worker', LogContext::withData([
'worker_id' => $workerId->toString()
]));
try {
$sql = "DELETE FROM " . self::TABLE_NAME . "
WHERE worker_id = :worker_id";
$count = $this->connection->execute(SqlQuery::create($sql, ['worker_id' => $workerId->toString()]));
$this->logger->info('Released all worker locks', LogContext::withData([
'worker_id' => $workerId->toString(),
'released_count' => $count
]));
return $count;
} catch (\Exception $e) {
$this->logger->error('Failed to release worker locks', LogContext::withData([
'worker_id' => $workerId->toString(),
'error' => $e->getMessage()
]));
throw $e;
}
}
/**
* Abgelaufene Locks aufräumen
*/
public function cleanupExpiredLocks(): int
{
$this->logger->debug('Starting cleanup of expired locks');
try {
$sql = "DELETE FROM " . self::TABLE_NAME . "
WHERE expires_at <= NOW()";
$count = $this->connection->execute(SqlQuery::create($sql));
$this->logger->info('Cleaned up expired locks', LogContext::withData([
'cleaned_count' => $count
]));
return $count;
} catch (\Exception $e) {
$this->logger->error('Failed to cleanup expired locks', LogContext::withData([
'error' => $e->getMessage()
]));
throw $e;
}
}
/**
* Lock Statistiken
*/
public function getLockStatistics(): array
{
try {
$sql = "SELECT
COUNT(*) as total_locks,
COUNT(CASE WHEN expires_at > NOW() THEN 1 END) as active_locks,
COUNT(CASE WHEN expires_at <= NOW() THEN 1 END) as expired_locks,
COUNT(DISTINCT worker_id) as unique_workers,
AVG(TIMESTAMPDIFF(SECOND, acquired_at, expires_at)) as avg_ttl_seconds,
MIN(acquired_at) as oldest_lock,
MAX(acquired_at) as newest_lock
FROM " . self::TABLE_NAME;
$stats = $this->connection->queryOne(SqlQuery::create($sql));
// Top Lock Keys
$topKeysSql = "SELECT lock_key, COUNT(*) as count
FROM " . self::TABLE_NAME . "
WHERE expires_at > NOW()
GROUP BY lock_key
ORDER BY count DESC
LIMIT 10";
$result = $this->connection->query(SqlQuery::create($topKeysSql));
$topKeys = $result->fetchAll();
return [
'total_locks' => (int) $stats['total_locks'],
'active_locks' => (int) $stats['active_locks'],
'expired_locks' => (int) $stats['expired_locks'],
'unique_workers' => (int) $stats['unique_workers'],
'avg_ttl_seconds' => round((float) $stats['avg_ttl_seconds'], 2),
'oldest_lock' => $stats['oldest_lock'],
'newest_lock' => $stats['newest_lock'],
'top_lock_keys' => $topKeys
];
} catch (\Exception $e) {
$this->logger->error('Failed to get lock statistics', LogContext::withData([
'error' => $e->getMessage()
]));
throw $e;
}
}
/**
* Prüfe ob Exception ein Duplicate Key Error ist
*/
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

@@ -0,0 +1,249 @@
<?php
declare(strict_types=1);
namespace App\Framework\Queue\Services;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\ValueObjects\SqlQuery;
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
*/
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
{
$batchId = $this->generateBatchId();
$batch = JobBatch::create($batchId, $name, $jobIds, $options);
$sql = "INSERT INTO job_batches (
batch_id, name, job_ids, status, total_jobs,
processed_jobs, failed_jobs, options, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
$this->connection->execute(SqlQuery::create($sql, [
$batch->batchId,
$batch->name,
json_encode($batch->jobIds),
$batch->status->value,
$batch->totalJobs,
$batch->processedJobs,
$batch->failedJobs,
json_encode($batch->options),
$batch->createdAt->toSqlString()
]));
$this->logger->info('Job batch created', LogContext::withData([
'batch_id' => $batch->batchId,
'name' => $batch->name,
'total_jobs' => $batch->totalJobs
]));
return $batch;
}
public function getBatch(string $batchId): ?JobBatch
{
$sql = "SELECT * FROM job_batches WHERE batch_id = ?";
$row = $this->connection->queryOne(SqlQuery::create($sql, [$batchId]));
if (!$row) {
return null;
}
return $this->hydrateFromRow($row);
}
public function recordJobCompleted(string $batchId, string $jobId): void
{
$batch = $this->getBatch($batchId);
if (!$batch) {
return;
}
$updatedBatch = $batch->incrementProcessed();
$this->updateBatch($updatedBatch);
$this->logger->debug('Job batch progress updated', LogContext::withData([
'batch_id' => $batchId,
'job_id' => $jobId,
'progress' => $updatedBatch->getProgressPercentage()
]));
}
public function recordJobFailed(string $batchId, string $jobId): void
{
$batch = $this->getBatch($batchId);
if (!$batch) {
return;
}
$updatedBatch = $batch->incrementFailed();
$this->updateBatch($updatedBatch);
$this->logger->warning('Job batch job failed', LogContext::withData([
'batch_id' => $batchId,
'job_id' => $jobId,
'failed_jobs' => $updatedBatch->failedJobs
]));
}
public function cancelBatch(string $batchId): bool
{
$sql = "UPDATE job_batches SET status = ?, cancelled_at = ? WHERE batch_id = ?";
$affected = $this->connection->execute(SqlQuery::create($sql, [
JobBatchStatus::CANCELLED->value,
date('Y-m-d H:i:s'),
$batchId
]));
if ($affected > 0) {
$this->logger->info('Job batch cancelled', LogContext::withData([
'batch_id' => $batchId
]));
}
return $affected > 0;
}
public function getBatchesByStatus(JobBatchStatus $status, int $limit = 50): array
{
$sql = "SELECT * FROM job_batches WHERE status = ? ORDER BY created_at DESC LIMIT ?";
$result = $this->connection->query(SqlQuery::create($sql, [$status->value, $limit]));
$batches = [];
foreach ($result->fetchAll() as $row) {
$batches[] = $this->hydrateFromRow($row);
}
return $batches;
}
public function getBatchStats(): array
{
$sql = "SELECT
status,
COUNT(*) as count,
AVG(processed_jobs) as avg_processed,
SUM(total_jobs) as total_jobs,
SUM(failed_jobs) as total_failed
FROM job_batches
GROUP BY status";
$result = $this->connection->query(SqlQuery::create($sql));
$stats = [];
foreach ($result->fetchAll() as $row) {
$stats[$row['status']] = [
'count' => (int) $row['count'],
'avg_processed' => (float) $row['avg_processed'],
'total_jobs' => (int) $row['total_jobs'],
'total_failed' => (int) $row['total_failed']
];
}
return $stats;
}
public function cleanupOldBatches(int $olderThanDays = 30): int
{
$cutoffDate = date('Y-m-d H:i:s', strtotime("-{$olderThanDays} days"));
$sql = "DELETE FROM job_batches
WHERE created_at < ?
AND status IN (?, ?, ?)";
$deleted = $this->connection->execute(SqlQuery::create($sql, [
$cutoffDate,
JobBatchStatus::COMPLETED->value,
JobBatchStatus::FAILED->value,
JobBatchStatus::CANCELLED->value
]));
if ($deleted > 0) {
$this->logger->info('Cleaned up old job batches', LogContext::withData([
'deleted_count' => $deleted,
'older_than_days' => $olderThanDays
]));
}
return $deleted;
}
public function getBatchProgress(string $batchId): array
{
$batch = $this->getBatch($batchId);
if (!$batch) {
return [];
}
return [
'batch_id' => $batch->batchId,
'name' => $batch->name,
'status' => $batch->status->value,
'progress_percentage' => $batch->getProgressPercentage(),
'total_jobs' => $batch->totalJobs,
'processed_jobs' => $batch->processedJobs,
'failed_jobs' => $batch->failedJobs,
'remaining_jobs' => $batch->getRemainingJobs(),
'is_finished' => $batch->isFinished()
];
}
private function updateBatch(JobBatch $batch): void
{
$sql = "UPDATE job_batches SET
status = ?,
processed_jobs = ?,
failed_jobs = ?,
started_at = ?,
completed_at = ?,
failed_at = ?
WHERE batch_id = ?";
$this->connection->execute(SqlQuery::create($sql, [
$batch->status->value,
$batch->processedJobs,
$batch->failedJobs,
$batch->startedAt?->toSqlString(),
$batch->completedAt?->toSqlString(),
$batch->failedAt?->toSqlString(),
$batch->batchId
]));
}
private function hydrateFromRow(array $row): JobBatch
{
return new JobBatch(
batchId: $row['batch_id'],
name: $row['name'],
jobIds: json_decode($row['job_ids'], true),
status: JobBatchStatus::from($row['status']),
totalJobs: (int) $row['total_jobs'],
processedJobs: (int) $row['processed_jobs'],
failedJobs: (int) $row['failed_jobs'],
createdAt: $row['created_at'] ? Timestamp::fromString($row['created_at']) : null,
startedAt: $row['started_at'] ? Timestamp::fromString($row['started_at']) : null,
completedAt: $row['completed_at'] ? Timestamp::fromString($row['completed_at']) : null,
failedAt: $row['failed_at'] ? Timestamp::fromString($row['failed_at']) : null,
options: json_decode($row['options'] ?? '{}', true)
);
}
private function generateBatchId(): string
{
return 'batch_' . uniqid() . '_' . time();
}
}

View File

@@ -0,0 +1,337 @@
<?php
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;
final readonly class DatabaseJobChainManager implements JobChainManagerInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private Logger $logger
) {}
public function createChain(JobChain $jobChain): void
{
$entry = JobChainEntry::fromJobChain($jobChain);
$this->logger->info('Creating job chain', [
'chain_id' => $jobChain->chainId,
'name' => $jobChain->name,
'job_count' => count($jobChain->jobIds),
'execution_mode' => $jobChain->executionMode->value
]);
$this->entityManager->save($entry);
$this->entityManager->flush();
}
public function getChain(string $chainId): ?JobChainEntry
{
$query = "SELECT * FROM job_chains WHERE chain_id = ?";
$result = $this->entityManager->getConnection()->fetchOne($query, [$chainId]);
if (!$result) {
return null;
}
return $this->mapRowToEntity($result);
}
public function updateChain(JobChain $jobChain): void
{
$existing = $this->getChain($jobChain->chainId);
if (!$existing) {
throw new \RuntimeException("Chain with ID '{$jobChain->chainId}' not found");
}
$updated = new JobChainEntry(
id: $existing->id,
chainId: $jobChain->chainId,
name: $jobChain->name,
jobIds: json_encode($jobChain->jobIds),
executionMode: $jobChain->executionMode->value,
createdAt: $existing->createdAt,
updatedAt: date('Y-m-d H:i:s'),
stopOnFailure: $jobChain->stopOnFailure,
metadata: $jobChain->metadata ? json_encode($jobChain->metadata) : null,
status: $existing->status,
startedAt: $existing->startedAt,
completedAt: $existing->completedAt
);
$this->logger->info('Updating job chain', [
'chain_id' => $jobChain->chainId,
'name' => $jobChain->name
]);
$this->entityManager->save($updated);
$this->entityManager->flush();
}
public function deleteChain(string $chainId): void
{
$query = "DELETE FROM job_chains WHERE chain_id = ?";
$this->logger->info('Deleting job chain', [
'chain_id' => $chainId
]);
$this->entityManager->getConnection()->execute($query, [$chainId]);
}
public function startChain(string $chainId): void
{
$chain = $this->getChain($chainId);
if (!$chain) {
throw new \RuntimeException("Chain with ID '{$chainId}' not found");
}
if (!$chain->isPending()) {
throw new \RuntimeException("Chain '{$chainId}' is not in pending status");
}
$started = $chain->markAsStarted();
$this->logger->info('Starting job chain', [
'chain_id' => $chainId,
'job_count' => count($started->getJobIdsArray())
]);
$this->entityManager->save($started);
$this->entityManager->flush();
}
public function getChainsForJob(string $jobId): array
{
$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 $this->mapRowToEntity($row);
}, $results);
}
public function getActiveChains(): array
{
$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 $this->mapRowToEntity($row);
}, $results);
}
public function getPendingChains(): array
{
$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 $this->mapRowToEntity($row);
}, $results);
}
public function markChainAsCompleted(string $chainId): void
{
$chain = $this->getChain($chainId);
if (!$chain) {
throw new \RuntimeException("Chain with ID '{$chainId}' not found");
}
$completed = $chain->markAsCompleted();
$this->logger->info('Marking chain as completed', [
'chain_id' => $chainId
]);
$this->entityManager->save($completed);
$this->entityManager->flush();
}
public function markChainAsFailed(string $chainId): void
{
$chain = $this->getChain($chainId);
if (!$chain) {
throw new \RuntimeException("Chain with ID '{$chainId}' not found");
}
$failed = $chain->markAsFailed();
$this->logger->info('Marking chain as failed', [
'chain_id' => $chainId
]);
$this->entityManager->save($failed);
$this->entityManager->flush();
}
public function getNextJobInChain(string $chainId, ?string $currentJobId = null): ?string
{
$chain = $this->getChain($chainId);
if (!$chain) {
return null;
}
$jobChain = $chain->getJobChain();
if ($currentJobId === null) {
// Return first job in chain
return $jobChain->jobIds[0] ?? null;
}
return $jobChain->getNextJob($currentJobId);
}
public function isChainComplete(string $chainId): bool
{
$chain = $this->getChain($chainId);
if (!$chain) {
return false;
}
return $chain->isCompleted() || $chain->isFailed();
}
public function handleJobCompletion(string $jobId, bool $successful = true): void
{
$chains = $this->getChainsForJob($jobId);
foreach ($chains as $chainEntry) {
if (!$chainEntry->isRunning()) {
continue;
}
$jobChain = $chainEntry->getJobChain();
// If chain requires stopping on failure and job failed
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
]);
continue;
}
// Check if this was the last job in the chain
$nextJobId = $this->getNextJobInChain($chainEntry->chainId, $jobId);
if ($nextJobId === null) {
// Chain is complete
$this->markChainAsCompleted($chainEntry->chainId);
$this->logger->info('Chain completed', [
'chain_id' => $chainEntry->chainId,
'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
]);
}
}
}
public function getChainProgress(string $chainId): array
{
$chain = $this->getChain($chainId);
if (!$chain) {
throw new \RuntimeException("Chain with ID '{$chainId}' not found");
}
$jobChain = $chain->getJobChain();
$totalJobs = count($jobChain->jobIds);
// This would need integration with job status tracking
// For now, return basic progress based on chain status
$progress = match($chain->status) {
'pending' => [
'completed_jobs' => 0,
'total_jobs' => $totalJobs,
'percentage' => 0,
'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'
],
'completed' => [
'completed_jobs' => $totalJobs,
'total_jobs' => $totalJobs,
'percentage' => 100,
'status' => 'completed'
],
'failed' => [
'completed_jobs' => 0, // Would need actual job status check
'total_jobs' => $totalJobs,
'percentage' => 0, // Partial progress before failure
'status' => 'failed'
]
};
return array_merge($progress, [
'chain_id' => $chainId,
'name' => $chain->name,
'execution_mode' => $chain->executionMode,
'stop_on_failure' => $chain->stopOnFailure,
'started_at' => $chain->startedAt,
'completed_at' => $chain->completedAt
]);
}
public function cleanupOldChains(int $olderThanDays = 30): int
{
$cutoffDate = date('Y-m-d H:i:s', strtotime("-{$olderThanDays} days"));
$query = "DELETE FROM job_chains
WHERE status IN ('completed', 'failed') AND completed_at < ?";
$this->logger->info('Cleaning up old chains', [
'cutoff_date' => $cutoffDate,
'older_than_days' => $olderThanDays
]);
$affectedRows = $this->entityManager->getConnection()->execute($query, [$cutoffDate]);
$this->logger->info('Old chains cleaned up', [
'deleted_count' => $affectedRows
]);
return $affectedRows;
}
private function mapRowToEntity(array $row): JobChainEntry
{
return new JobChainEntry(
id: $row['id'],
chainId: $row['chain_id'],
name: $row['name'],
jobIds: $row['job_ids'],
executionMode: $row['execution_mode'],
createdAt: $row['created_at'],
updatedAt: $row['updated_at'],
stopOnFailure: (bool) $row['stop_on_failure'],
metadata: $row['metadata'],
status: $row['status'],
startedAt: $row['started_at'],
completedAt: $row['completed_at']
);
}
}

View File

@@ -0,0 +1,292 @@
<?php
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;
final readonly class DatabaseJobDependencyManager implements JobDependencyManagerInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private Logger $logger
) {}
public function addDependency(JobDependency $dependency): void
{
$entry = JobDependencyEntry::fromJobDependency($dependency);
$this->logger->info('Adding job dependency', [
'dependent_job_id' => $dependency->dependentJobId,
'depends_on_job_id' => $dependency->dependsOnJobId,
'type' => $dependency->type->value
]);
$this->entityManager->save($entry);
$this->entityManager->flush();
}
public function removeDependency(string $dependentJobId, string $dependsOnJobId): void
{
$query = "DELETE FROM job_dependencies
WHERE dependent_job_id = ? AND depends_on_job_id = ?";
$this->logger->info('Removing job dependency', [
'dependent_job_id' => $dependentJobId,
'depends_on_job_id' => $dependsOnJobId
]);
$this->entityManager->getConnection()->execute(
SqlQuery::create($query, [$dependentJobId, $dependsOnJobId])
);
}
public function getDependencies(string $jobId): array
{
$query = "SELECT * FROM job_dependencies WHERE dependent_job_id = ? ORDER BY created_at";
$results = $this->entityManager->getConnection()->query(
SqlQuery::create($query, [$jobId])
)->fetchAll();
return array_map(function(array $row) {
return $this->mapRowToEntity($row);
}, $results);
}
public function getDependents(string $jobId): array
{
$query = "SELECT * FROM job_dependencies WHERE depends_on_job_id = ? ORDER BY created_at";
$results = $this->entityManager->getConnection()->query(
SqlQuery::create($query, [$jobId])
)->fetchAll();
return array_map(function(array $row) {
return $this->mapRowToEntity($row);
}, $results);
}
public function hasUnsatisfiedDependencies(string $jobId): bool
{
$query = "SELECT COUNT(*) as count FROM job_dependencies
WHERE dependent_job_id = ? AND is_satisfied = false";
$result = $this->entityManager->getConnection()->queryOne(
SqlQuery::create($query, [$jobId])
);
return $result['count'] > 0;
}
public function getUnsatisfiedDependencies(string $jobId): array
{
$query = "SELECT * FROM job_dependencies
WHERE dependent_job_id = ? AND is_satisfied = false
ORDER BY created_at";
$results = $this->entityManager->getConnection()->query(
SqlQuery::create($query, [$jobId])
)->fetchAll();
return array_map(function(array $row) {
return $this->mapRowToEntity($row);
}, $results);
}
public function markDependencyAsSatisfied(string $dependentJobId, string $dependsOnJobId): void
{
$query = "UPDATE job_dependencies
SET is_satisfied = true, satisfied_at = ?, updated_at = ?
WHERE dependent_job_id = ? AND depends_on_job_id = ?";
$now = date('Y-m-d H:i:s');
$this->logger->info('Marking dependency as satisfied', [
'dependent_job_id' => $dependentJobId,
'depends_on_job_id' => $dependsOnJobId
]);
$this->entityManager->getConnection()->execute(
SqlQuery::create($query, [$now, $now, $dependentJobId, $dependsOnJobId])
);
}
public function canJobBeExecuted(string $jobId): bool
{
return !$this->hasUnsatisfiedDependencies($jobId);
}
public function getReadyJobs(): array
{
// Get all jobs that have dependencies but no unsatisfied ones
$query = "SELECT DISTINCT jd1.dependent_job_id
FROM job_dependencies jd1
WHERE NOT EXISTS (
SELECT 1 FROM job_dependencies jd2
WHERE jd2.dependent_job_id = jd1.dependent_job_id
AND jd2.is_satisfied = false
)";
$results = $this->entityManager->getConnection()->query(
SqlQuery::create($query)
)->fetchAll();
return array_column($results, 'dependent_job_id');
}
public function resolveJobCompletion(string $jobId, bool $successful = true): array
{
$this->logger->info('Resolving job completion for dependencies', [
'job_id' => $jobId,
'successful' => $successful
]);
// Get all jobs that depend on this completed job
$dependents = $this->getDependents($jobId);
$resolvedJobs = [];
foreach ($dependents as $dependencyEntry) {
$dependency = $dependencyEntry->getJobDependency();
// Check if this dependency should be satisfied based on completion type
$shouldSatisfy = match($dependency->type) {
\App\Framework\Queue\ValueObjects\DependencyType::COMPLETION => true,
\App\Framework\Queue\ValueObjects\DependencyType::SUCCESS => $successful,
\App\Framework\Queue\ValueObjects\DependencyType::CONDITIONAL => $this->evaluateCondition($dependency->condition, $successful)
};
if ($shouldSatisfy && !$dependencyEntry->isSatisfied) {
$this->markDependencyAsSatisfied($dependency->dependentJobId, $jobId);
// Check if the dependent job is now ready to execute
if ($this->canJobBeExecuted($dependency->dependentJobId)) {
$resolvedJobs[] = $dependency->dependentJobId;
}
}
}
$this->logger->info('Job completion resolved', [
'job_id' => $jobId,
'resolved_jobs_count' => count($resolvedJobs),
'resolved_jobs' => $resolvedJobs
]);
return $resolvedJobs;
}
public function getDependencyChain(string $jobId): array
{
$visited = [];
$chain = [];
$this->buildDependencyChain($jobId, $visited, $chain);
// Remove the starting job from the chain if it's there
return array_filter($chain, fn($id) => $id !== $jobId);
}
public function hasCircularDependencies(string $jobId): bool
{
$visited = [];
$recursionStack = [];
return $this->detectCircularDependency($jobId, $visited, $recursionStack);
}
public function cleanupOldDependencies(int $olderThanDays = 30): int
{
$cutoffDate = date('Y-m-d H:i:s', strtotime("-{$olderThanDays} days"));
$query = "DELETE FROM job_dependencies
WHERE is_satisfied = true AND satisfied_at < ?";
$this->logger->info('Cleaning up old dependencies', [
'cutoff_date' => $cutoffDate,
'older_than_days' => $olderThanDays
]);
$affectedRows = $this->entityManager->getConnection()->execute(
SqlQuery::create($query, [$cutoffDate])
);
$this->logger->info('Old dependencies cleaned up', [
'deleted_count' => $affectedRows
]);
return $affectedRows;
}
private function buildDependencyChain(string $jobId, array &$visited, array &$chain): void
{
if (in_array($jobId, $visited)) {
return;
}
$visited[] = $jobId;
$dependencies = $this->getDependencies($jobId);
foreach ($dependencies as $dependencyEntry) {
$dependency = $dependencyEntry->getJobDependency();
$chain[] = $dependency->dependsOnJobId;
$this->buildDependencyChain($dependency->dependsOnJobId, $visited, $chain);
}
}
private function detectCircularDependency(string $jobId, array &$visited, array &$recursionStack): bool
{
$visited[$jobId] = true;
$recursionStack[$jobId] = true;
$dependencies = $this->getDependencies($jobId);
foreach ($dependencies as $dependencyEntry) {
$dependency = $dependencyEntry->getJobDependency();
$dependsOnJobId = $dependency->dependsOnJobId;
if (!isset($visited[$dependsOnJobId])) {
if ($this->detectCircularDependency($dependsOnJobId, $visited, $recursionStack)) {
return true;
}
} elseif (isset($recursionStack[$dependsOnJobId])) {
return true; // Circular dependency detected
}
}
unset($recursionStack[$jobId]);
return false;
}
private function evaluateCondition(?string $condition, bool $jobSuccessful): bool
{
if ($condition === null) {
return true;
}
// Simple condition evaluation - can be extended for more complex conditions
return match($condition) {
'on_success' => $jobSuccessful,
'on_failure' => !$jobSuccessful,
'always' => true,
default => true
};
}
private function mapRowToEntity(array $row): JobDependencyEntry
{
return new JobDependencyEntry(
id: $row['id'],
dependentJobId: $row['dependent_job_id'],
dependsOnJobId: $row['depends_on_job_id'],
dependencyType: $row['dependency_type'],
createdAt: $row['created_at'],
updatedAt: $row['updated_at'],
conditionExpression: $row['condition_expression'],
isSatisfied: (bool) $row['is_satisfied'],
satisfiedAt: $row['satisfied_at']
);
}
}

View File

@@ -0,0 +1,195 @@
<?php
declare(strict_types=1);
namespace App\Framework\Queue\Services;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\EntityManager;
use App\Framework\Database\ValueObjects\SqlQuery;
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
*/
final readonly class DatabaseJobProgressTracker implements JobProgressTrackerInterface
{
public function __construct(
private ConnectionInterface $connection,
private EntityManager $entityManager
) {}
public function updateProgress(string $jobId, JobProgress $progress, ?string $stepName = null): void
{
$progressEntry = JobProgressEntry::fromJobProgress($jobId, $progress, $stepName);
$this->entityManager->persist($progressEntry);
$this->entityManager->flush();
}
public function completeStep(string $jobId, ProgressStep $step): void
{
$completedStep = $step->markAsCompleted();
// Create progress entry for the completed step
$progress = JobProgress::withPercentage(
percentage: Percentage::zero(), // Will be updated based on overall progress
message: "Completed step: {$completedStep->description}",
metadata: $completedStep->metadata
);
$this->updateProgress($jobId, $progress, $completedStep->stepName);
}
public function getCurrentProgress(string $jobId): ?JobProgress
{
$latestEntry = $this->getLatestProgressEntry($jobId);
return $latestEntry?->getJobProgress();
}
public function getProgressHistory(string $jobId): array
{
$sql = "
SELECT * FROM job_progress
WHERE job_id = ?
ORDER BY updated_at ASC
";
$result = $this->connection->query(SqlQuery::create($sql, [$jobId]));
$rows = $result->fetchAll();
return array_map([$this, 'mapRowToProgressEntry'], $rows);
}
public function getLatestProgressEntry(string $jobId): ?JobProgressEntry
{
$sql = "
SELECT * FROM job_progress
WHERE job_id = ?
ORDER BY updated_at DESC
LIMIT 1
";
$row = $this->connection->queryOne(SqlQuery::create($sql, [$jobId]));
return $row ? $this->mapRowToProgressEntry($row) : null;
}
public function markJobCompleted(string $jobId, string $message = 'Job completed successfully'): void
{
$progress = JobProgress::completed($message);
$this->updateProgress($jobId, $progress);
}
public function markJobFailed(string $jobId, string $message = 'Job failed', ?\Throwable $exception = null): void
{
$metadata = [];
if ($exception) {
$metadata['exception_type'] = get_class($exception);
$metadata['exception_message'] = $exception->getMessage();
}
$progress = JobProgress::failed($message)->withMetadata($metadata);
$this->updateProgress($jobId, $progress);
}
public function getProgressForJobs(array $jobIds): array
{
if (empty($jobIds)) {
return [];
}
$placeholders = str_repeat('?,', count($jobIds) - 1) . '?';
$sql = "
SELECT DISTINCT jp1.*
FROM job_progress jp1
INNER JOIN (
SELECT job_id, MAX(updated_at) as max_updated
FROM job_progress
WHERE job_id IN ({$placeholders})
GROUP BY job_id
) jp2 ON jp1.job_id = jp2.job_id AND jp1.updated_at = jp2.max_updated
";
$result = $this->connection->query(SqlQuery::create($sql, $jobIds));
$rows = $result->fetchAll();
$result = [];
foreach ($rows as $row) {
$entry = $this->mapRowToProgressEntry($row);
$result[$entry->jobId] = $entry->getJobProgress();
}
return $result;
}
public function cleanupOldEntries(int $olderThanDays = 30): int
{
$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]));
}
public function getJobsAboveProgress(float $minPercentage): array
{
$sql = "
SELECT DISTINCT jp1.*
FROM job_progress jp1
INNER JOIN (
SELECT job_id, MAX(updated_at) as max_updated
FROM job_progress
WHERE percentage >= ?
GROUP BY job_id
) jp2 ON jp1.job_id = jp2.job_id AND jp1.updated_at = jp2.max_updated
WHERE jp1.percentage >= ?
ORDER BY jp1.percentage DESC, jp1.updated_at DESC
";
$result = $this->connection->query(SqlQuery::create($sql, [$minPercentage, $minPercentage]));
$rows = $result->fetchAll();
return array_map([$this, 'mapRowToProgressEntry'], $rows);
}
public function getRecentlyUpdatedJobs(int $limitMinutes = 60, int $limit = 100): array
{
$cutoffTime = date('Y-m-d H:i:s', strtotime("-{$limitMinutes} minutes"));
$sql = "
SELECT DISTINCT jp1.*
FROM job_progress jp1
INNER JOIN (
SELECT job_id, MAX(updated_at) as max_updated
FROM job_progress
WHERE updated_at >= ?
GROUP BY job_id
) jp2 ON jp1.job_id = jp2.job_id AND jp1.updated_at = jp2.max_updated
ORDER BY jp1.updated_at DESC
LIMIT ?
";
$result = $this->connection->query(SqlQuery::create($sql, [$cutoffTime, $limit]));
$rows = $result->fetchAll();
return array_map([$this, 'mapRowToProgressEntry'], $rows);
}
private function mapRowToProgressEntry(array $row): JobProgressEntry
{
return new JobProgressEntry(
id: $row['id'],
jobId: $row['job_id'],
percentage: (float) $row['percentage'],
message: $row['message'],
stepName: $row['step_name'],
metadata: $row['metadata'],
updatedAt: $row['updated_at'],
isCompleted: (bool) $row['is_completed'],
isFailed: (bool) $row['is_failed']
);
}
}

View File

@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
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\ValueObjects\DeadLetterQueueName;
use App\Framework\Queue\ValueObjects\FailureReason;
use App\Framework\Queue\ValueObjects\QueueName;
/**
* Manages dead letter queue operations and job failure handling
*/
final readonly class DeadLetterManager
{
public function __construct(
private DeadLetterQueueInterface $deadLetterQueue,
private ProductionJobPersistenceLayer $persistenceLayer
) {}
/**
* Move a failed job to the dead letter queue
*/
public function moveJobToDeadLetterQueue(
JobIndexEntry $failedJob,
FailureReason $failureReason,
?DeadLetterQueueName $deadLetterQueueName = null
): void {
// Use default dead letter queue name if not provided
$dlqName = $deadLetterQueueName ?? DeadLetterQueueName::forQueue(
QueueName::fromString($failedJob->queueType)
);
// Create dead letter job entry
$deadLetterJob = DeadLetterJob::fromFailedJob(
failedJob: $failedJob,
deadLetterQueueName: $dlqName,
failureReason: $failureReason
);
// Add to dead letter queue
$this->deadLetterQueue->addFailedJob($deadLetterJob);
// Remove from original job index
$this->persistenceLayer->deleteJob($failedJob->jobId);
}
/**
* Move a job to dead letter queue when max attempts are exceeded
*/
public function handleJobFailure(
string $jobId,
\Throwable $exception,
int $maxAttempts = 3
): bool {
$jobEntry = $this->persistenceLayer->getJobById($jobId);
if (!$jobEntry) {
return false;
}
// Check if max attempts exceeded
if ($jobEntry->attempts >= $maxAttempts) {
$failureReason = FailureReason::fromException($exception);
$this->moveJobToDeadLetterQueue(
failedJob: $jobEntry,
failureReason: $failureReason
);
return true;
}
return false;
}
/**
* Retry a job from dead letter queue
*/
public function retryJob(string $deadLetterJobId): bool
{
return $this->deadLetterQueue->retryJob($deadLetterJobId);
}
/**
* Get failed jobs for monitoring/admin interface
*/
public function getFailedJobs(
?QueueName $originalQueue = null,
int $limit = 100
): array {
if ($originalQueue) {
return $this->deadLetterQueue->getJobsByOriginalQueue($originalQueue, $limit);
}
// Get jobs from all dead letter queues
$allJobs = [];
$queues = $this->deadLetterQueue->getAvailableQueues();
foreach ($queues as $queueName) {
$jobs = $this->deadLetterQueue->getJobs($queueName, $limit);
$allJobs = array_merge($allJobs, $jobs);
}
// Sort by moved_to_dlq_at descending
usort($allJobs, fn ($a, $b) => strcmp($b->movedToDlqAt, $a->movedToDlqAt));
return array_slice($allJobs, 0, $limit);
}
/**
* Get dead letter queue statistics
*/
public function getStatistics(): array
{
$queues = $this->deadLetterQueue->getAvailableQueues();
$stats = [];
foreach ($queues as $queueName) {
$stats[$queueName->toString()] = $this->deadLetterQueue->getQueueStats($queueName);
}
return $stats;
}
/**
* Clear all jobs from a dead letter queue
*/
public function clearDeadLetterQueue(DeadLetterQueueName $queueName): int
{
return $this->deadLetterQueue->clearQueue($queueName);
}
/**
* Retry all jobs in a dead letter queue
*/
public function retryAllJobs(DeadLetterQueueName $queueName): int
{
return $this->deadLetterQueue->retryAllJobs($queueName);
}
/**
* Delete a specific job from dead letter queue
*/
public function deleteJob(string $deadLetterJobId): bool
{
return $this->deadLetterQueue->deleteJob($deadLetterJobId);
}
/**
* Get all available dead letter queues
*/
public function getAvailableQueues(): array
{
return $this->deadLetterQueue->getAvailableQueues();
}
}

View File

@@ -0,0 +1,274 @@
<?php
declare(strict_types=1);
namespace App\Framework\Queue\Services;
use App\Framework\Queue\Contracts\JobDependencyManagerInterface;
use App\Framework\Queue\Contracts\JobChainManagerInterface;
use App\Framework\Queue\Contracts\QueueInterface;
use App\Framework\Queue\ValueObjects\Job;
use App\Framework\Logging\Logger;
final readonly class DependencyResolutionEngine
{
public function __construct(
private JobDependencyManagerInterface $dependencyManager,
private JobChainManagerInterface $chainManager,
private QueueInterface $queue,
private Logger $logger
) {}
/**
* Process job completion and resolve any dependent jobs that can now be executed
*/
public function resolveJobCompletion(string $jobId, bool $successful = true): array
{
$this->logger->info('Starting dependency resolution for completed job', [
'job_id' => $jobId,
'successful' => $successful
]);
$resolvedJobs = [];
// 1. Resolve individual job dependencies
$dependentJobs = $this->dependencyManager->resolveJobCompletion($jobId, $successful);
foreach ($dependentJobs as $dependentJobId) {
$resolvedJobs[] = [
'job_id' => $dependentJobId,
'type' => 'dependency_resolved',
'trigger_job' => $jobId
];
}
// 2. Handle job chain progression
$this->chainManager->handleJobCompletion($jobId, $successful);
// Get next jobs in any chains this job belongs to
$chains = $this->chainManager->getChainsForJob($jobId);
foreach ($chains as $chainEntry) {
if ($chainEntry->isRunning()) {
$nextJobId = $this->chainManager->getNextJobInChain($chainEntry->chainId, $jobId);
if ($nextJobId !== null) {
// Check if the next job has any other unsatisfied dependencies
if ($this->dependencyManager->canJobBeExecuted($nextJobId)) {
$resolvedJobs[] = [
'job_id' => $nextJobId,
'type' => 'chain_progression',
'chain_id' => $chainEntry->chainId,
'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))
]);
}
}
}
}
$this->logger->info('Dependency resolution completed', [
'trigger_job_id' => $jobId,
'resolved_jobs_count' => count($resolvedJobs),
'resolved_jobs' => array_column($resolvedJobs, 'job_id')
]);
return $resolvedJobs;
}
/**
* Get all jobs that are ready to be executed (no unsatisfied dependencies)
*/
public function getExecutableJobs(): array
{
$readyJobs = $this->dependencyManager->getReadyJobs();
$this->logger->debug('Found jobs ready for execution', [
'ready_jobs_count' => count($readyJobs),
'ready_jobs' => $readyJobs
]);
return $readyJobs;
}
/**
* Validate a job dependency graph for circular dependencies
*/
public function validateDependencyGraph(array $jobIds): array
{
$validationResults = [];
foreach ($jobIds as $jobId) {
$hasCircularDeps = $this->dependencyManager->hasCircularDependencies($jobId);
if ($hasCircularDeps) {
$dependencyChain = $this->dependencyManager->getDependencyChain($jobId);
$validationResults[] = [
'job_id' => $jobId,
'valid' => false,
'error' => 'circular_dependency',
'dependency_chain' => $dependencyChain
];
$this->logger->warning('Circular dependency detected', [
'job_id' => $jobId,
'dependency_chain' => $dependencyChain
]);
} else {
$validationResults[] = [
'job_id' => $jobId,
'valid' => true
];
}
}
return $validationResults;
}
/**
* Get dependency analysis for a job
*/
public function analyzeDependencies(string $jobId): array
{
$dependencies = $this->dependencyManager->getDependencies($jobId);
$dependents = $this->dependencyManager->getDependents($jobId);
$unsatisfiedDeps = $this->dependencyManager->getUnsatisfiedDependencies($jobId);
$dependencyChain = $this->dependencyManager->getDependencyChain($jobId);
$canExecute = $this->dependencyManager->canJobBeExecuted($jobId);
return [
'job_id' => $jobId,
'can_execute' => $canExecute,
'direct_dependencies' => array_map(fn($dep) => [
'depends_on_job_id' => $dep->dependsOnJobId,
'dependency_type' => $dep->dependencyType,
'is_satisfied' => $dep->isSatisfied,
'condition' => $dep->conditionExpression
], $dependencies),
'dependent_jobs' => array_map(fn($dep) => [
'dependent_job_id' => $dep->dependentJobId,
'dependency_type' => $dep->dependencyType,
'is_satisfied' => $dep->isSatisfied
], $dependents),
'unsatisfied_dependencies' => array_map(fn($dep) => [
'depends_on_job_id' => $dep->dependsOnJobId,
'dependency_type' => $dep->dependencyType,
'condition' => $dep->conditionExpression
], $unsatisfiedDeps),
'full_dependency_chain' => $dependencyChain,
'statistics' => [
'total_dependencies' => count($dependencies),
'satisfied_dependencies' => count($dependencies) - count($unsatisfiedDeps),
'unsatisfied_dependencies' => count($unsatisfiedDeps),
'total_dependents' => count($dependents),
'chain_depth' => count($dependencyChain)
]
];
}
/**
* Get analysis for job chains
*/
public function analyzeChains(string $jobId): array
{
$chains = $this->chainManager->getChainsForJob($jobId);
$chainAnalysis = [];
foreach ($chains as $chainEntry) {
$progress = $this->chainManager->getChainProgress($chainEntry->chainId);
$nextJob = $this->chainManager->getNextJobInChain($chainEntry->chainId, $jobId);
$chainAnalysis[] = [
'chain_id' => $chainEntry->chainId,
'name' => $chainEntry->name,
'status' => $chainEntry->status,
'execution_mode' => $chainEntry->executionMode,
'stop_on_failure' => $chainEntry->stopOnFailure,
'progress' => $progress,
'next_job_after_current' => $nextJob,
'job_position' => $this->getJobPositionInChain($jobId, $chainEntry),
'total_jobs' => count($chainEntry->getJobIdsArray())
];
}
return [
'job_id' => $jobId,
'chains' => $chainAnalysis,
'total_chains' => count($chains)
];
}
/**
* Comprehensive dependency health check
*/
public function performHealthCheck(): array
{
$this->logger->info('Starting dependency system health check');
$activeChains = $this->chainManager->getActiveChains();
$pendingChains = $this->chainManager->getPendingChains();
$readyJobs = $this->dependencyManager->getReadyJobs();
// Check for potential issues
$issues = [];
// Check for stalled chains (running for too long)
foreach ($activeChains as $chain) {
if ($chain->startedAt) {
$startTime = strtotime($chain->startedAt);
$hoursRunning = (time() - $startTime) / 3600;
if ($hoursRunning > 24) { // Configurable threshold
$issues[] = [
'type' => 'stalled_chain',
'chain_id' => $chain->chainId,
'hours_running' => round($hoursRunning, 2)
];
}
}
}
// Check for jobs with many unsatisfied dependencies (potential deadlocks)
foreach ($readyJobs as $jobId) {
$analysis = $this->analyzeDependencies($jobId);
if ($analysis['statistics']['unsatisfied_dependencies'] > 10) { // Configurable threshold
$issues[] = [
'type' => 'many_unsatisfied_dependencies',
'job_id' => $jobId,
'unsatisfied_count' => $analysis['statistics']['unsatisfied_dependencies']
];
}
}
$healthReport = [
'status' => count($issues) === 0 ? 'healthy' : 'issues_detected',
'timestamp' => date('Y-m-d H:i:s'),
'statistics' => [
'active_chains' => count($activeChains),
'pending_chains' => count($pendingChains),
'ready_jobs' => count($readyJobs),
'detected_issues' => count($issues)
],
'issues' => $issues
];
$this->logger->info('Dependency system health check completed', [
'status' => $healthReport['status'],
'issues_count' => count($issues)
]);
return $healthReport;
}
private function getJobPositionInChain(string $jobId, object $chainEntry): ?int
{
$jobIds = $chainEntry->getJobIdsArray();
return array_search($jobId, $jobIds, true) ?: null;
}
}

View File

@@ -0,0 +1,516 @@
<?php
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;
/**
* Failover und Recovery Service für robustes Distributed Job Processing
*/
final readonly class FailoverRecoveryService
{
private const RECOVERY_LOCK_TTL_SECONDS = 300; // 5 Minuten
private const JOB_TIMEOUT_THRESHOLD_SECONDS = 1800; // 30 Minuten
private const WORKER_FAILURE_THRESHOLD_SECONDS = 300; // 5 Minuten
public function __construct(
private WorkerRegistry $workerRegistry,
private JobDistributionService $jobDistributionService,
private WorkerHealthCheckService $healthCheckService,
private DistributedLockInterface $distributedLock,
private ConnectionInterface $connection,
private Logger $logger
) {}
/**
* Vollständige Failover und Recovery Prozedur durchführen
*/
public function performFailoverRecovery(): array
{
$this->logger->info('Starting failover and recovery process');
$results = [
'started_at' => date('Y-m-d H:i:s'),
'failed_workers_detected' => 0,
'jobs_recovered' => 0,
'jobs_reassigned' => 0,
'workers_cleaned' => 0,
'locks_released' => 0,
'errors' => []
];
try {
// 1. Fehlgeschlagene Worker erkennen
$failedWorkers = $this->detectFailedWorkers();
$results['failed_workers_detected'] = count($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)
]));
// 2. Jobs von fehlgeschlagenen Workern wiederherstellen
foreach ($failedWorkers as $worker) {
$recovered = $this->recoverWorkerJobs($worker);
$results['jobs_recovered'] += $recovered['jobs_recovered'];
$results['jobs_reassigned'] += $recovered['jobs_reassigned'];
$results['locks_released'] += $recovered['locks_released'];
if (!empty($recovered['errors'])) {
$results['errors'] = array_merge($results['errors'], $recovered['errors']);
}
}
// 3. Fehlgeschlagene Worker aufräumen
$results['workers_cleaned'] = $this->cleanupFailedWorkers($failedWorkers);
}
// 4. Verwaiste Jobs wiederherstellen
$orphanedJobs = $this->recoverOrphanedJobs();
$results['orphaned_jobs_recovered'] = $orphanedJobs;
// 5. Abgelaufene Locks aufräumen
$expiredLocks = $this->distributedLock->cleanupExpiredLocks();
$results['expired_locks_cleaned'] = $expiredLocks;
$results['completed_at'] = date('Y-m-d H:i:s');
$results['duration_seconds'] = strtotime($results['completed_at']) - strtotime($results['started_at']);
$this->logger->info('Failover and recovery completed', LogContext::withData($results));
} catch (\Exception $e) {
$results['errors'][] = 'Recovery process failed: ' . $e->getMessage();
$this->logger->error('Failover and recovery failed', LogContext::withData([
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]));
}
return $results;
}
/**
* Fehlgeschlagene Worker erkennen
*/
public function detectFailedWorkers(): array
{
$allWorkers = $this->workerRegistry->findActiveWorkers();
$failedWorkers = [];
foreach ($allWorkers as $worker) {
if ($this->isWorkerFailed($worker)) {
$failedWorkers[] = $worker;
}
}
return $failedWorkers;
}
/**
* Prüfen ob Worker als fehlgeschlagen gilt
*/
private function isWorkerFailed(Worker $worker): bool
{
// Heartbeat-basierte Überprüfung
if (!$worker->lastHeartbeat) {
return true;
}
$heartbeatAge = time() - $worker->lastHeartbeat->getTimestamp();
if ($heartbeatAge > self::WORKER_FAILURE_THRESHOLD_SECONDS) {
return true;
}
// Health-basierte Überprüfung
$health = $this->healthCheckService->checkWorkerHealth($worker);
if ($health['status'] === 'critical' && $health['score'] < 20) {
return true;
}
return false;
}
/**
* Jobs von fehlgeschlagenem Worker wiederherstellen
*/
public function recoverWorkerJobs(Worker $worker): array
{
$this->logger->info('Recovering jobs from failed worker', LogContext::withData([
'worker_id' => $worker->id->toString(),
'hostname' => $worker->hostname
]));
$results = [
'jobs_recovered' => 0,
'jobs_reassigned' => 0,
'locks_released' => 0,
'errors' => []
];
try {
// Recovery Lock erwerben
$recoveryLock = LockKey::forWorker($worker->id)->withPrefix('recovery');
$recoveryWorkerId = WorkerId::generate();
if (!$this->distributedLock->acquire(
$recoveryLock,
$recoveryWorkerId,
Duration::fromSeconds(self::RECOVERY_LOCK_TTL_SECONDS)
)) {
$results['errors'][] = 'Failed to acquire recovery lock';
return $results;
}
try {
// Alle Jobs des Workers finden
$assignedJobs = $this->findWorkerJobs($worker->id);
$results['jobs_recovered'] = count($assignedJobs);
// Alle Locks des Workers freigeben
$results['locks_released'] = $this->jobDistributionService->releaseAllWorkerJobs($worker->id);
// Jobs neu verteilen
foreach ($assignedJobs as $jobAssignment) {
$reassigned = $this->reassignJob(
JobId::fromString($jobAssignment['job_id']),
QueueName::default(), // Vereinfacht - könnte aus Assignment gelesen werden
$worker->id
);
if ($reassigned) {
$results['jobs_reassigned']++;
} else {
$results['errors'][] = "Failed to reassign job: {$jobAssignment['job_id']}";
}
}
} finally {
$this->distributedLock->release($recoveryLock, $recoveryWorkerId);
}
} catch (\Exception $e) {
$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()
]));
}
return $results;
}
/**
* Jobs eines Workers finden
*/
private function findWorkerJobs(WorkerId $workerId): array
{
try {
$sql = "SELECT job_id, queue_name, assigned_at
FROM job_assignments
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()
]));
return [];
}
}
/**
* Job neu zuweisen
*/
private function reassignJob(JobId $jobId, QueueName $queueName, WorkerId $failedWorkerId): bool
{
try {
// Job an neuen Worker verteilen
$newWorkerId = $this->jobDistributionService->distributeJob($jobId, $queueName);
if ($newWorkerId) {
$this->logger->info('Job successfully reassigned', LogContext::withData([
'job_id' => $jobId->toString(),
'failed_worker_id' => $failedWorkerId->toString(),
'new_worker_id' => $newWorkerId->toString()
]));
// Failover Event loggen
$this->logFailoverEvent($jobId, $failedWorkerId, $newWorkerId);
return true;
}
return false;
} catch (\Exception $e) {
$this->logger->error('Failed to reassign job', LogContext::withData([
'job_id' => $jobId->toString(),
'failed_worker_id' => $failedWorkerId->toString(),
'error' => $e->getMessage()
]));
return false;
}
}
/**
* Fehlgeschlagene Worker aufräumen
*/
private function cleanupFailedWorkers(array $failedWorkers): int
{
$cleanedCount = 0;
foreach ($failedWorkers as $worker) {
try {
// Worker deregistrieren
$this->workerRegistry->deregister($worker->id);
$this->logger->info('Failed worker cleaned up', LogContext::withData([
'worker_id' => $worker->id->toString(),
'hostname' => $worker->hostname
]));
$cleanedCount++;
} catch (\Exception $e) {
$this->logger->error('Failed to cleanup worker', LogContext::withData([
'worker_id' => $worker->id->toString(),
'error' => $e->getMessage()
]));
}
}
return $cleanedCount;
}
/**
* Verwaiste Jobs wiederherstellen
*/
public function recoverOrphanedJobs(): int
{
$this->logger->info('Recovering orphaned jobs');
try {
// Jobs finden die länger als Threshold laufen ohne Heartbeat
$sql = "SELECT ja.job_id, ja.queue_name, ja.worker_id, ja.assigned_at
FROM job_assignments ja
LEFT JOIN queue_workers qw ON ja.worker_id = qw.id
WHERE (qw.id IS NULL
OR qw.is_active = 0
OR qw.last_heartbeat < DATE_SUB(NOW(), INTERVAL :threshold SECOND)
OR ja.assigned_at < DATE_SUB(NOW(), INTERVAL :job_timeout SECOND))";
$result = $this->connection->query(SqlQuery::create($sql, [
'threshold' => self::WORKER_FAILURE_THRESHOLD_SECONDS,
'job_timeout' => self::JOB_TIMEOUT_THRESHOLD_SECONDS
]));
$orphanedJobs = $result->fetchAll();
$recoveredCount = 0;
foreach ($orphanedJobs as $job) {
$jobId = JobId::fromString($job['job_id']);
$queueName = QueueName::default(); // Vereinfacht
$oldWorkerId = WorkerId::fromString($job['worker_id']);
// Job Lock freigeben falls vorhanden
$jobLock = LockKey::forJob($jobId);
$this->distributedLock->release($jobLock, $oldWorkerId);
// Job neu verteilen
$newWorkerId = $this->jobDistributionService->distributeJob($jobId, $queueName);
if ($newWorkerId) {
$recoveredCount++;
$this->logger->info('Orphaned job recovered', LogContext::withData([
'job_id' => $jobId->toString(),
'old_worker_id' => $oldWorkerId->toString(),
'new_worker_id' => $newWorkerId->toString()
]));
}
}
$this->logger->info('Orphaned jobs recovery completed', LogContext::withData([
'total_orphaned' => count($orphanedJobs),
'recovered' => $recoveredCount
]));
return $recoveredCount;
} catch (\Exception $e) {
$this->logger->error('Failed to recover orphaned jobs', LogContext::withData([
'error' => $e->getMessage()
]));
return 0;
}
}
/**
* Failover Event loggen
*/
private function logFailoverEvent(JobId $jobId, WorkerId $failedWorkerId, WorkerId $newWorkerId): void
{
try {
$sql = "INSERT INTO failover_events (
job_id, failed_worker_id, new_worker_id, failover_at, event_type
) VALUES (
:job_id, :failed_worker_id, :new_worker_id, NOW(), 'job_reassignment'
)";
$this->connection->execute(SqlQuery::create($sql, [
'job_id' => $jobId->toString(),
'failed_worker_id' => $failedWorkerId->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()
]));
}
}
/**
* Failover Statistiken abrufen
*/
public function getFailoverStatistics(?Duration $period = null): array
{
$period = $period ?? Duration::fromDays(7);
try {
$sql = "SELECT
COUNT(*) as total_failovers,
COUNT(DISTINCT failed_worker_id) as failed_workers,
COUNT(DISTINCT new_worker_id) as recovery_workers,
COUNT(DISTINCT job_id) as affected_jobs,
AVG(TIMESTAMPDIFF(SECOND, failover_at, NOW())) as avg_time_since_failover
FROM failover_events
WHERE failover_at >= DATE_SUB(NOW(), INTERVAL :hours HOUR)";
$stats = $this->connection->queryOne(SqlQuery::create($sql, ['hours' => $period->toHours()]));
// Failover Trends
$trendSql = "SELECT
DATE(failover_at) as failover_date,
COUNT(*) as daily_failovers
FROM failover_events
WHERE failover_at >= DATE_SUB(NOW(), INTERVAL :hours HOUR)
GROUP BY DATE(failover_at)
ORDER BY failover_date DESC";
$result = $this->connection->query(SqlQuery::create($trendSql, ['hours' => $period->toHours()]));
$trends = $result->fetchAll();
return [
'period_hours' => $period->toHours(),
'statistics' => [
'total_failovers' => (int) $stats['total_failovers'],
'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)
],
'daily_trends' => $trends
];
} catch (\Exception $e) {
$this->logger->error('Failed to get failover statistics', LogContext::withData([
'error' => $e->getMessage()
]));
throw $e;
}
}
/**
* System Resilience Score berechnen
*/
public function calculateResilienceScore(): array
{
try {
$score = 100;
$factors = [];
// Worker Availability
$workerStats = $this->workerRegistry->getWorkerStatistics();
$activeRatio = $workerStats['active_workers'] > 0
? $workerStats['healthy_workers'] / $workerStats['active_workers']
: 0;
if ($activeRatio < 0.5) {
$score -= 40;
$factors[] = 'Low worker availability';
} elseif ($activeRatio < 0.8) {
$score -= 20;
$factors[] = 'Reduced worker availability';
}
// Recent Failovers
$failoverStats = $this->getFailoverStatistics(Duration::fromHours(24));
$recentFailovers = $failoverStats['statistics']['total_failovers'];
if ($recentFailovers > 10) {
$score -= 30;
$factors[] = 'High failover rate (24h)';
} elseif ($recentFailovers > 5) {
$score -= 15;
$factors[] = 'Moderate failover rate (24h)';
}
// Lock Contention
$lockStats = $this->distributedLock->getLockStatistics();
if ($lockStats['expired_locks'] > $lockStats['active_locks'] * 0.1) {
$score -= 20;
$factors[] = 'High lock expiration rate';
}
$status = match (true) {
$score >= 80 => 'excellent',
$score >= 60 => 'good',
$score >= 40 => 'fair',
$score >= 20 => 'poor',
default => 'critical'
};
return [
'score' => max(0, $score),
'status' => $status,
'factors' => $factors,
'metrics' => [
'worker_availability' => round($activeRatio * 100, 1),
'recent_failovers_24h' => $recentFailovers,
'active_locks' => $lockStats['active_locks'],
'expired_locks' => $lockStats['expired_locks']
],
'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()
]));
return [
'score' => 0,
'status' => 'unknown',
'factors' => ['Calculation failed'],
'error' => $e->getMessage()
];
}
}
}

View File

@@ -0,0 +1,353 @@
<?php
declare(strict_types=1);
namespace App\Framework\Queue\Services;
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;
final readonly class JobChainExecutionCoordinator
{
public function __construct(
private JobChainManagerInterface $chainManager,
private JobDependencyManagerInterface $dependencyManager,
private QueueInterface $queue,
private EventDispatcherInterface $eventDispatcher,
private DependencyResolutionEngine $resolutionEngine,
private Logger $logger
) {}
/**
* Start execution of a job chain
*/
public function startChainExecution(string $chainId): void
{
$chainEntry = $this->chainManager->getChain($chainId);
if (!$chainEntry) {
throw new \RuntimeException("Chain with ID '{$chainId}' not found");
}
if (!$chainEntry->isPending()) {
throw new \RuntimeException("Chain '{$chainId}' is not in pending status");
}
$jobChain = $chainEntry->getJobChain();
$this->logger->info('Starting chain execution', [
'chain_id' => $chainId,
'execution_mode' => $jobChain->executionMode->value,
'job_count' => count($jobChain->jobIds),
'stop_on_failure' => $jobChain->stopOnFailure
]);
// Validate dependency graph before starting
$validationResults = $this->resolutionEngine->validateDependencyGraph($jobChain->jobIds);
$hasCircularDeps = array_filter($validationResults, fn($result) => !$result['valid']);
if (!empty($hasCircularDeps)) {
$this->logger->error('Cannot start chain - circular dependencies detected', [
'chain_id' => $chainId,
'circular_dependencies' => $hasCircularDeps
]);
throw new \RuntimeException("Chain '{$chainId}' has circular dependencies");
}
// Mark chain as started
$this->chainManager->startChain($chainId);
// Dispatch chain started event
$this->eventDispatcher->dispatch(new JobChainStartedEvent(
chainId: $chainId,
name: $jobChain->name,
executionMode: $jobChain->executionMode,
jobIds: $jobChain->jobIds
));
// Start execution based on execution mode
match($jobChain->executionMode) {
ChainExecutionMode::SEQUENTIAL => $this->startSequentialExecution($jobChain),
ChainExecutionMode::PARALLEL => $this->startParallelExecution($jobChain),
ChainExecutionMode::CONDITIONAL => $this->startConditionalExecution($jobChain)
};
}
/**
* Process job completion within a chain context
*/
public function handleJobCompletionInChain(string $jobId, bool $successful = true, ?array $jobResult = null): void
{
$this->logger->info('Handling job completion in chain context', [
'job_id' => $jobId,
'successful' => $successful
]);
// Get all chains this job belongs to
$chains = $this->chainManager->getChainsForJob($jobId);
foreach ($chains as $chainEntry) {
if (!$chainEntry->isRunning()) {
continue;
}
$jobChain = $chainEntry->getJobChain();
$this->logger->info('Processing job completion for chain', [
'chain_id' => $chainEntry->chainId,
'job_id' => $jobId,
'execution_mode' => $jobChain->executionMode->value
]);
// Handle failure scenarios
if (!$successful && $jobChain->stopOnFailure) {
$this->handleChainFailure($chainEntry->chainId, $jobId);
continue;
}
// Continue chain execution based on mode
match($jobChain->executionMode) {
ChainExecutionMode::SEQUENTIAL => $this->continueSequentialExecution($chainEntry, $jobId, $successful),
ChainExecutionMode::PARALLEL => $this->continueParallelExecution($chainEntry, $jobId, $successful),
ChainExecutionMode::CONDITIONAL => $this->continueConditionalExecution($chainEntry, $jobId, $successful, $jobResult)
};
}
}
/**
* Get chain execution status and progress
*/
public function getChainExecutionStatus(string $chainId): array
{
$chainEntry = $this->chainManager->getChain($chainId);
if (!$chainEntry) {
throw new \RuntimeException("Chain with ID '{$chainId}' not found");
}
$progress = $this->chainManager->getChainProgress($chainId);
$jobChain = $chainEntry->getJobChain();
// Get detailed job status for each job in chain
$jobStatuses = [];
foreach ($jobChain->jobIds as $index => $jobId) {
$dependencyAnalysis = $this->resolutionEngine->analyzeDependencies($jobId);
$jobStatuses[] = [
'job_id' => $jobId,
'position' => $index,
'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
];
}
return [
'chain_id' => $chainId,
'name' => $chainEntry->name,
'status' => $chainEntry->status,
'execution_mode' => $jobChain->executionMode->value,
'progress' => $progress,
'job_statuses' => $jobStatuses,
'started_at' => $chainEntry->startedAt,
'completed_at' => $chainEntry->completedAt,
'metadata' => $chainEntry->getMetadataArray()
];
}
/**
* Pause chain execution
*/
public function pauseChainExecution(string $chainId): void
{
// Implementation would depend on job queue capabilities
$this->logger->info('Pausing chain execution', ['chain_id' => $chainId]);
// This would involve:
// 1. Marking chain as paused
// 2. Preventing new jobs from being queued
// 3. Allowing current jobs to complete
}
/**
* Resume chain execution
*/
public function resumeChainExecution(string $chainId): void
{
$this->logger->info('Resuming chain execution', ['chain_id' => $chainId]);
// This would involve:
// 1. Marking chain as running again
// 2. Re-evaluating which jobs can be executed
// 3. Queuing eligible jobs
}
private function startSequentialExecution(JobChain $jobChain): void
{
$this->logger->info('Starting sequential chain execution', [
'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
}
}
}
private function startParallelExecution(JobChain $jobChain): void
{
$this->logger->info('Starting parallel chain execution', [
'chain_id' => $jobChain->chainId
]);
// Queue all jobs that have no unsatisfied dependencies
foreach ($jobChain->jobIds as $jobId) {
if ($this->dependencyManager->canJobBeExecuted($jobId)) {
$this->queueJob($jobId);
}
}
}
private function startConditionalExecution(JobChain $jobChain): void
{
$this->logger->info('Starting conditional chain execution', [
'chain_id' => $jobChain->chainId
]);
// Similar to parallel, but with conditional dependency evaluation
foreach ($jobChain->jobIds as $jobId) {
if ($this->dependencyManager->canJobBeExecuted($jobId)) {
$this->queueJob($jobId);
}
}
}
private function continueSequentialExecution(object $chainEntry, string $completedJobId, bool $successful): void
{
$nextJobId = $this->chainManager->getNextJobInChain($chainEntry->chainId, $completedJobId);
if ($nextJobId !== null && $this->dependencyManager->canJobBeExecuted($nextJobId)) {
$this->queueJob($nextJobId);
} elseif ($nextJobId === null) {
// Chain completed
$this->handleChainCompletion($chainEntry->chainId);
}
}
private function continueParallelExecution(object $chainEntry, string $completedJobId, bool $successful): void
{
$jobChain = $chainEntry->getJobChain();
// Check if any more jobs can be executed
$hasExecutableJobs = false;
foreach ($jobChain->jobIds as $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)) {
$this->handleChainCompletion($chainEntry->chainId);
}
}
private function continueConditionalExecution(object $chainEntry, string $completedJobId, bool $successful, ?array $jobResult): void
{
// Conditional execution logic would evaluate conditions based on job results
$jobChain = $chainEntry->getJobChain();
// Re-evaluate all dependencies in case conditions have changed
foreach ($jobChain->jobIds as $jobId) {
if ($this->dependencyManager->canJobBeExecuted($jobId) && !$this->isJobCompleted($jobId)) {
$this->queueJob($jobId);
}
}
// Check completion
if ($this->areAllJobsCompleted($jobChain->jobIds)) {
$this->handleChainCompletion($chainEntry->chainId);
}
}
private function handleChainCompletion(string $chainId): void
{
$this->logger->info('Chain execution completed', ['chain_id' => $chainId]);
$this->chainManager->markChainAsCompleted($chainId);
$chainEntry = $this->chainManager->getChain($chainId);
$this->eventDispatcher->dispatch(new JobChainCompletedEvent(
chainId: $chainId,
name: $chainEntry->name,
completedAt: $chainEntry->completedAt
));
}
private function handleChainFailure(string $chainId, string $failedJobId): void
{
$this->logger->warning('Chain execution failed', [
'chain_id' => $chainId,
'failed_job_id' => $failedJobId
]);
$this->chainManager->markChainAsFailed($chainId);
$chainEntry = $this->chainManager->getChain($chainId);
$this->eventDispatcher->dispatch(new JobChainFailedEvent(
chainId: $chainId,
name: $chainEntry->name,
failedJobId: $failedJobId,
failedAt: $chainEntry->completedAt
));
}
private function queueJob(string $jobId): void
{
$this->logger->debug('Queuing job for execution', ['job_id' => $jobId]);
// This would need integration with the actual job creation/queuing system
// For now, we'll assume a job can be queued by ID
// In a real implementation, you'd need to reconstruct the Job object
// $job = $this->jobRepository->find($jobId);
// $this->queue->push($job);
}
private function getJobStatus(string $jobId): string
{
// This would integrate with job status tracking system
// For now, return a placeholder
return 'unknown';
}
private function isJobCompleted(string $jobId): bool
{
// This would check if job is completed in the job tracking system
// For now, return false as placeholder
return false;
}
private function areAllJobsCompleted(array $jobIds): bool
{
foreach ($jobIds as $jobId) {
if (!$this->isJobCompleted($jobId)) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,436 @@
<?php
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;
use App\Framework\Logging\ValueObjects\LogContext;
use App\Framework\Performance\MemoryMonitor;
/**
* Service for cleaning up old job data from the queue system
*/
final readonly class JobCleanupService
{
// Default retention periods
private const DEFAULT_COMPLETED_RETENTION_DAYS = 30;
private const DEFAULT_FAILED_RETENTION_DAYS = 90;
private const DEFAULT_METRICS_RETENTION_DAYS = 180;
private const DEFAULT_DEAD_LETTER_RETENTION_DAYS = 365;
// Batch size for cleanup operations
private const CLEANUP_BATCH_SIZE = 1000;
public function __construct(
private ConnectionInterface $connection,
private Logger $logger,
private MemoryMonitor $memoryMonitor,
private JobMemoryManager $memoryManager
) {}
/**
* Clean up old completed jobs
*/
public function cleanupCompletedJobs(Duration $olderThan): int
{
$cutoffDate = $this->calculateCutoffDate($olderThan);
$totalDeleted = 0;
$batchCount = 0;
$this->logger->info('Starting completed jobs cleanup', LogContext::withData([
'older_than_days' => $olderThan->toHours() / 24,
'cutoff_date' => $cutoffDate
]));
try {
$this->connection->beginTransaction();
// Delete from job_history first
$sql = "DELETE FROM job_history
WHERE new_status = 'completed'
AND changed_at < :cutoff
LIMIT :limit";
do {
// Check memory before each batch
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
]));
break;
}
$deleted = $this->connection->execute(SqlQuery::create($sql, [
'cutoff' => $cutoffDate,
'limit' => self::CLEANUP_BATCH_SIZE
]));
$totalDeleted += $deleted;
$batchCount++;
if ($deleted > 0) {
$this->logger->debug('Deleted batch of completed jobs', LogContext::withData([
'batch_size' => $deleted,
'total_deleted' => $totalDeleted,
'batch_number' => $batchCount
]));
}
// Small delay between batches to prevent overload
if ($deleted === self::CLEANUP_BATCH_SIZE) {
usleep(100000); // 100ms delay
}
} while ($deleted === self::CLEANUP_BATCH_SIZE);
$this->connection->commit();
$this->logger->info('Completed jobs cleanup finished', LogContext::withData([
'total_deleted' => $totalDeleted,
'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
]));
throw $e;
}
return $totalDeleted;
}
/**
* Clean up old failed jobs
*/
public function cleanupFailedJobs(Duration $olderThan): int
{
$cutoffDate = $this->calculateCutoffDate($olderThan);
$totalDeleted = 0;
$this->logger->info('Starting failed jobs cleanup', LogContext::withData([
'older_than_days' => $olderThan->toHours() / 24,
'cutoff_date' => $cutoffDate
]));
try {
$this->connection->beginTransaction();
$sql = "DELETE FROM job_history
WHERE new_status = 'failed'
AND changed_at < :cutoff
LIMIT :limit";
do {
if ($this->memoryManager->shouldAbortJob('cleanup_failed_jobs')) {
break;
}
$deleted = $this->connection->execute(SqlQuery::create($sql, [
'cutoff' => $cutoffDate,
'limit' => self::CLEANUP_BATCH_SIZE
]));
$totalDeleted += $deleted;
if ($deleted === self::CLEANUP_BATCH_SIZE) {
usleep(100000);
}
} while ($deleted === self::CLEANUP_BATCH_SIZE);
$this->connection->commit();
$this->logger->info('Failed jobs cleanup finished', LogContext::withData([
'total_deleted' => $totalDeleted
]));
} catch (\Exception $e) {
$this->connection->rollback();
$this->logger->error('Failed to cleanup failed jobs', LogContext::withData([
'error' => $e->getMessage()
]));
throw $e;
}
return $totalDeleted;
}
/**
* Clean up old job metrics
*/
public function cleanupJobMetrics(Duration $olderThan): int
{
$cutoffDate = $this->calculateCutoffDate($olderThan);
$totalDeleted = 0;
$this->logger->info('Starting job metrics cleanup', LogContext::withData([
'older_than_days' => $olderThan->toHours() / 24,
'cutoff_date' => $cutoffDate
]));
try {
$this->connection->beginTransaction();
$sql = "DELETE FROM job_metrics
WHERE created_at < :cutoff
LIMIT :limit";
do {
if ($this->memoryManager->shouldAbortJob('cleanup_metrics')) {
break;
}
$deleted = $this->connection->execute(SqlQuery::create($sql, [
'cutoff' => $cutoffDate,
'limit' => self::CLEANUP_BATCH_SIZE
]));
$totalDeleted += $deleted;
if ($deleted === self::CLEANUP_BATCH_SIZE) {
usleep(100000);
}
} while ($deleted === self::CLEANUP_BATCH_SIZE);
$this->connection->commit();
$this->logger->info('Job metrics cleanup finished', LogContext::withData([
'total_deleted' => $totalDeleted
]));
} catch (\Exception $e) {
$this->connection->rollback();
$this->logger->error('Failed to cleanup job metrics', LogContext::withData([
'error' => $e->getMessage()
]));
throw $e;
}
return $totalDeleted;
}
/**
* Clean up old dead letter jobs
*/
public function cleanupDeadLetterJobs(Duration $olderThan): int
{
$cutoffDate = $this->calculateCutoffDate($olderThan);
$totalDeleted = 0;
$this->logger->info('Starting dead letter jobs cleanup', LogContext::withData([
'older_than_days' => $olderThan->toHours() / 24,
'cutoff_date' => $cutoffDate
]));
try {
$this->connection->beginTransaction();
$sql = "DELETE FROM dead_letter_jobs
WHERE moved_to_dlq_at < :cutoff
LIMIT :limit";
do {
if ($this->memoryManager->shouldAbortJob('cleanup_dead_letter')) {
break;
}
$deleted = $this->connection->execute(SqlQuery::create($sql, [
'cutoff' => $cutoffDate,
'limit' => self::CLEANUP_BATCH_SIZE
]));
$totalDeleted += $deleted;
if ($deleted === self::CLEANUP_BATCH_SIZE) {
usleep(100000);
}
} while ($deleted === self::CLEANUP_BATCH_SIZE);
$this->connection->commit();
$this->logger->info('Dead letter jobs cleanup finished', LogContext::withData([
'total_deleted' => $totalDeleted
]));
} catch (\Exception $e) {
$this->connection->rollback();
$this->logger->error('Failed to cleanup dead letter jobs', LogContext::withData([
'error' => $e->getMessage()
]));
throw $e;
}
return $totalDeleted;
}
/**
* Run comprehensive cleanup with default retention periods
*/
public function runComprehensiveCleanup(): array
{
$this->logger->info('Starting comprehensive queue cleanup');
$startTime = microtime(true);
$memoryBefore = $this->memoryMonitor->getSummary();
$results = [
'started_at' => date('Y-m-d H:i:s'),
'completed_jobs' => 0,
'failed_jobs' => 0,
'job_metrics' => 0,
'dead_letter_jobs' => 0,
'total_deleted' => 0,
'errors' => []
];
// Clean completed jobs
try {
$results['completed_jobs'] = $this->cleanupCompletedJobs(
Duration::fromDays(self::DEFAULT_COMPLETED_RETENTION_DAYS)
);
} catch (\Exception $e) {
$results['errors'][] = 'Failed to cleanup completed jobs: ' . $e->getMessage();
}
// Clean failed jobs
try {
$results['failed_jobs'] = $this->cleanupFailedJobs(
Duration::fromDays(self::DEFAULT_FAILED_RETENTION_DAYS)
);
} catch (\Exception $e) {
$results['errors'][] = 'Failed to cleanup failed jobs: ' . $e->getMessage();
}
// Clean job metrics
try {
$results['job_metrics'] = $this->cleanupJobMetrics(
Duration::fromDays(self::DEFAULT_METRICS_RETENTION_DAYS)
);
} catch (\Exception $e) {
$results['errors'][] = 'Failed to cleanup job metrics: ' . $e->getMessage();
}
// Clean dead letter jobs
try {
$results['dead_letter_jobs'] = $this->cleanupDeadLetterJobs(
Duration::fromDays(self::DEFAULT_DEAD_LETTER_RETENTION_DAYS)
);
} catch (\Exception $e) {
$results['errors'][] = 'Failed to cleanup dead letter jobs: ' . $e->getMessage();
}
$results['total_deleted'] = $results['completed_jobs']
+ $results['failed_jobs']
+ $results['job_metrics']
+ $results['dead_letter_jobs'];
$memoryAfter = $this->memoryMonitor->getSummary();
$duration = microtime(true) - $startTime;
$results['completed_at'] = date('Y-m-d H:i:s');
$results['duration_seconds'] = round($duration, 2);
$results['memory_used'] = $memoryAfter->current->toHumanReadable();
$results['memory_peak'] = $memoryAfter->peak->toHumanReadable();
$this->logger->info('Comprehensive cleanup completed', $results);
return $results;
}
/**
* Get cleanup statistics
*/
public function getCleanupStatistics(): array
{
$stats = [];
try {
// Get count of completed jobs eligible for cleanup
$sql = "SELECT COUNT(*) as count FROM job_history
WHERE new_status = 'completed'
AND changed_at < :cutoff";
$result = $this->connection->queryOne(SqlQuery::create($sql, [
'cutoff' => $this->calculateCutoffDate(
Duration::fromDays(self::DEFAULT_COMPLETED_RETENTION_DAYS)
)
]));
$stats['eligible_completed_jobs'] = $result['count'] ?? 0;
// Get count of failed jobs eligible for cleanup
$sql = "SELECT COUNT(*) as count FROM job_history
WHERE new_status = 'failed'
AND changed_at < :cutoff";
$result = $this->connection->queryOne(SqlQuery::create($sql, [
'cutoff' => $this->calculateCutoffDate(
Duration::fromDays(self::DEFAULT_FAILED_RETENTION_DAYS)
)
]));
$stats['eligible_failed_jobs'] = $result['count'] ?? 0;
// Get count of metrics eligible for cleanup
$sql = "SELECT COUNT(*) as count FROM job_metrics
WHERE created_at < :cutoff";
$result = $this->connection->queryOne(SqlQuery::create($sql, [
'cutoff' => $this->calculateCutoffDate(
Duration::fromDays(self::DEFAULT_METRICS_RETENTION_DAYS)
)
]));
$stats['eligible_metrics'] = $result['count'] ?? 0;
// Get count of dead letter jobs eligible for cleanup
$sql = "SELECT COUNT(*) as count FROM dead_letter_jobs
WHERE moved_to_dlq_at < :cutoff";
$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;
$stats['total_eligible'] = $stats['eligible_completed_jobs']
+ $stats['eligible_failed_jobs']
+ $stats['eligible_metrics']
+ $stats['eligible_dead_letter_jobs'];
// Calculate estimated cleanup time (rough estimate)
$stats['estimated_cleanup_minutes'] = ceil($stats['total_eligible'] / self::CLEANUP_BATCH_SIZE);
// Add retention settings
$stats['retention_days'] = [
'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
];
} catch (\Exception $e) {
$this->logger->error('Failed to get cleanup statistics', LogContext::withData([
'error' => $e->getMessage()
]));
throw $e;
}
return $stats;
}
/**
* Calculate cutoff date from duration
*/
private function calculateCutoffDate(Duration $olderThan): string
{
$seconds = $olderThan->toSeconds();
$cutoffTimestamp = time() - (int) $seconds;
return date('Y-m-d H:i:s', $cutoffTimestamp);
}
}

View File

@@ -0,0 +1,449 @@
<?php
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;
/**
* Service für intelligente Job-Verteilung an Worker
*/
final readonly class JobDistributionService
{
private const JOB_LOCK_TTL_SECONDS = 300; // 5 Minuten
private const DISTRIBUTION_LOCK_TTL_SECONDS = 30; // 30 Sekunden
public function __construct(
private WorkerRegistry $workerRegistry,
private DistributedLockInterface $distributedLock,
private ConnectionInterface $connection,
private Logger $logger
) {}
/**
* Job an besten verfügbaren Worker verteilen
*/
public function distributeJob(JobId $jobId, QueueName $queueName, array $jobData = []): ?WorkerId
{
$this->logger->info('Starting job distribution', LogContext::withData([
'job_id' => $jobId->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(
$distributionLock,
$tempWorkerId,
Duration::fromSeconds(self::DISTRIBUTION_LOCK_TTL_SECONDS),
Duration::fromSeconds(5)
)) {
$this->logger->warning('Failed to acquire distribution lock', LogContext::withData([
'job_id' => $jobId->toString(),
'queue_name' => $queueName->toString()
]));
return null;
}
try {
// Job Lock erwerben
$jobLock = LockKey::forJob($jobId);
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()
]));
return null;
}
// Besten Worker finden
$worker = $this->findBestWorkerForJob($queueName, $jobData);
if (!$worker) {
$this->logger->warning('No suitable worker found', LogContext::withData([
'job_id' => $jobId->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)) {
$this->logger->error('Failed to transfer job lock to worker', LogContext::withData([
'job_id' => $jobId->toString(),
'worker_id' => $worker->id->toString()
]));
// Job Lock freigeben
$this->distributedLock->release($jobLock, $tempWorkerId);
return null;
}
// Job Assignment in Datenbank speichern
$this->recordJobAssignment($jobId, $worker->id, $queueName);
$this->logger->info('Job successfully distributed', LogContext::withData([
'job_id' => $jobId->toString(),
'worker_id' => $worker->id->toString(),
'queue_name' => $queueName->toString(),
'worker_load' => $worker->getLoadPercentage()->getValue()
]));
return $worker->id;
} finally {
// Distribution Lock freigeben
$this->distributedLock->release($distributionLock, $tempWorkerId);
}
}
/**
* Besten Worker für Job finden
*/
public function findBestWorkerForJob(QueueName $queueName, array $jobData = []): ?Worker
{
// Worker für Queue finden
$workers = $this->workerRegistry->findWorkersForQueue($queueName);
if (empty($workers)) {
return null;
}
// Worker nach verschiedenen Kriterien bewerten
$scoredWorkers = [];
foreach ($workers as $worker) {
if (!$worker->isAvailableForJobs()) {
continue;
}
$score = $this->calculateWorkerScore($worker, $jobData);
$scoredWorkers[] = [
'worker' => $worker,
'score' => $score
];
}
if (empty($scoredWorkers)) {
return null;
}
// Nach Score sortieren (höchster Score = bester Worker)
usort($scoredWorkers, fn($a, $b) => $b['score'] <=> $a['score']);
return $scoredWorkers[0]['worker'];
}
/**
* Worker Score berechnen (höher = besser)
*/
private function calculateWorkerScore(Worker $worker, array $jobData): float
{
$score = 100.0; // Basis Score
// Last-basierte Bewertung (weniger Last = höherer Score)
$loadPenalty = $worker->getLoadPercentage()->getValue();
$score -= $loadPenalty;
// CPU-basierte Bewertung
$cpuPenalty = $worker->cpuUsage->getValue() * 0.5;
$score -= $cpuPenalty;
// Memory-basierte Bewertung (vereinfacht)
$memoryUsageGB = $worker->memoryUsage->toBytes() / (1024 * 1024 * 1024);
if ($memoryUsageGB > 1.5) { // Über 1.5GB = Penalty
$score -= ($memoryUsageGB - 1.5) * 10;
}
// Capability-basierte Bewertung
if (isset($jobData['required_capabilities'])) {
foreach ($jobData['required_capabilities'] as $capability) {
if ($worker->hasCapability($capability)) {
$score += 20; // Bonus für jede erfüllte Capability
} else {
return 0; // Worker kann Job nicht ausführen
}
}
}
// Bonus für Worker mit niedrigerer aktueller Job-Anzahl
$availableSlots = $worker->maxJobs - $worker->currentJobs;
$score += $availableSlots * 2;
return max(0, $score);
}
/**
* Job Lock von einem Worker an anderen übertragen
*/
private function transferJobLock(LockKey $jobLock, WorkerId $fromWorker, WorkerId $toWorker): bool
{
// Neuen Lock für Ziel-Worker erstellen
if (!$this->distributedLock->acquire(
$jobLock,
$toWorker,
Duration::fromSeconds(self::JOB_LOCK_TTL_SECONDS)
)) {
return false;
}
// Alten Lock freigeben
$this->distributedLock->release($jobLock, $fromWorker);
return true;
}
/**
* Job Assignment in Datenbank speichern
*/
private function recordJobAssignment(JobId $jobId, WorkerId $workerId, QueueName $queueName): void
{
try {
$sql = "INSERT INTO job_assignments (
job_id, worker_id, queue_name, assigned_at
) VALUES (
:job_id, :worker_id, :queue_name, NOW()
) ON DUPLICATE KEY UPDATE
worker_id = VALUES(worker_id),
assigned_at = VALUES(assigned_at)";
$this->connection->execute(SqlQuery::create($sql, [
'job_id' => $jobId->toString(),
'worker_id' => $workerId->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()
]));
// Nicht critical - Job kann trotzdem verarbeitet werden
}
}
/**
* Job von Worker freigeben
*/
public function releaseJob(JobId $jobId, WorkerId $workerId): bool
{
$this->logger->info('Releasing job from worker', LogContext::withData([
'job_id' => $jobId->toString(),
'worker_id' => $workerId->toString()
]));
// Job Lock freigeben
$jobLock = LockKey::forJob($jobId);
$released = $this->distributedLock->release($jobLock, $workerId);
if ($released) {
// Job Assignment aus Datenbank entfernen
try {
$sql = "DELETE FROM job_assignments
WHERE job_id = :job_id AND worker_id = :worker_id";
$this->connection->execute(SqlQuery::create($sql, [
'job_id' => $jobId->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()
]));
}
$this->logger->info('Job released successfully', LogContext::withData([
'job_id' => $jobId->toString(),
'worker_id' => $workerId->toString()
]));
}
return $released;
}
/**
* Job Lock verlängern
*/
public function extendJobLock(JobId $jobId, WorkerId $workerId, ?Duration $extension = null): bool
{
$extension = $extension ?? Duration::fromSeconds(self::JOB_LOCK_TTL_SECONDS);
$jobLock = LockKey::forJob($jobId);
return $this->distributedLock->extend($jobLock, $workerId, $extension);
}
/**
* Alle Jobs eines Workers freigeben
*/
public function releaseAllWorkerJobs(WorkerId $workerId): int
{
$this->logger->info('Releasing all jobs for worker', LogContext::withData([
'worker_id' => $workerId->toString()
]));
// Alle Locks des Workers freigeben
$releasedLocks = $this->distributedLock->releaseAllWorkerLocks($workerId);
// Job Assignments aus Datenbank entfernen
try {
$sql = "DELETE FROM job_assignments WHERE worker_id = :worker_id";
$releasedAssignments = $this->connection->execute(SqlQuery::create($sql, [
'worker_id' => $workerId->toString()
]));
$this->logger->info('Released all worker jobs', LogContext::withData([
'worker_id' => $workerId->toString(),
'released_locks' => $releasedLocks,
'released_assignments' => $releasedAssignments
]));
} catch (\Exception $e) {
$this->logger->error('Failed to release worker job assignments', LogContext::withData([
'worker_id' => $workerId->toString(),
'error' => $e->getMessage()
]));
}
return $releasedLocks;
}
/**
* Distribution Statistiken
*/
public function getDistributionStatistics(): array
{
try {
// Job Assignments Statistiken
$sql = "SELECT
COUNT(*) as total_assignments,
COUNT(DISTINCT worker_id) as active_workers,
COUNT(DISTINCT queue_name) as active_queues,
AVG(TIMESTAMPDIFF(SECOND, assigned_at, NOW())) as avg_assignment_age_seconds
FROM job_assignments";
$assignmentStats = $this->connection->queryOne(SqlQuery::create($sql));
// Worker Load Distribution
$workerStats = [];
$activeWorkers = $this->workerRegistry->findActiveWorkers();
foreach ($activeWorkers as $worker) {
$workerStats[] = [
'worker_id' => $worker->id->toString(),
'hostname' => $worker->hostname,
'current_jobs' => $worker->currentJobs,
'max_jobs' => $worker->maxJobs,
'load_percentage' => $worker->getLoadPercentage()->getValue(),
'queues' => array_map(fn($q) => $q->toString(), $worker->queues)
];
}
// Lock Statistiken
$lockStats = $this->distributedLock->getLockStatistics();
return [
'assignments' => [
'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)
],
'workers' => $workerStats,
'locks' => $lockStats,
'distribution_health' => $this->calculateDistributionHealth($workerStats)
];
} catch (\Exception $e) {
$this->logger->error('Failed to get distribution statistics', LogContext::withData([
'error' => $e->getMessage()
]));
throw $e;
}
}
/**
* Distribution Health Score berechnen
*/
private function calculateDistributionHealth(array $workerStats): array
{
if (empty($workerStats)) {
return [
'score' => 0,
'status' => 'critical',
'issues' => ['No active workers']
];
}
$score = 100;
$issues = [];
// Load Balance prüfen
$loads = array_column($workerStats, 'load_percentage');
$avgLoad = array_sum($loads) / count($loads);
$maxLoad = max($loads);
$minLoad = min($loads);
if ($maxLoad > 90) {
$score -= 30;
$issues[] = 'Workers overloaded (>90%)';
} elseif ($maxLoad > 75) {
$score -= 15;
$issues[] = 'High worker load (>75%)';
}
// Load Distribution prüfen
$loadVariance = $maxLoad - $minLoad;
if ($loadVariance > 50) {
$score -= 20;
$issues[] = 'Uneven load distribution';
}
// Worker Availability prüfen
$availableWorkers = array_filter($workerStats, fn($w) => $w['load_percentage'] < 80);
$availabilityRatio = count($availableWorkers) / count($workerStats);
if ($availabilityRatio < 0.3) {
$score -= 25;
$issues[] = 'Low worker availability (<30%)';
}
$status = match(true) {
$score >= 80 => 'healthy',
$score >= 60 => 'warning',
$score >= 40 => 'degraded',
default => 'critical'
};
return [
'score' => max(0, $score),
'status' => $status,
'avg_load' => round($avgLoad, 2),
'max_load' => $maxLoad,
'load_variance' => $loadVariance,
'availability_ratio' => round($availabilityRatio * 100, 2),
'issues' => $issues
];
}
}

View File

@@ -0,0 +1,292 @@
<?php
declare(strict_types=1);
namespace App\Framework\Queue\Services;
use App\Framework\Core\ValueObjects\Byte;
use App\Framework\Core\ValueObjects\Percentage;
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
*/
final readonly class JobMemoryManager
{
private const CRITICAL_MEMORY_THRESHOLD = 90.0; // 90% of limit
private const WARNING_MEMORY_THRESHOLD = 75.0; // 75% of limit
private const OPTIMAL_MEMORY_THRESHOLD = 50.0; // 50% is considered optimal
public function __construct(
private MemoryMonitor $memoryMonitor,
private Logger $logger
) {}
/**
* Get current memory usage for job context
*/
public function getJobMemorySnapshot(string $jobId): array
{
$summary = $this->memoryMonitor->getSummary();
return [
'job_id' => $jobId,
'timestamp' => date('Y-m-d H:i:s'),
'current' => $summary->getCurrentHumanReadable(),
'current_bytes' => $summary->getCurrentBytes(),
'peak' => $summary->getPeakHumanReadable(),
'peak_bytes' => $summary->getPeakBytes(),
'limit' => $summary->getLimitHumanReadable(),
'limit_bytes' => $summary->getLimitBytes(),
'available' => $summary->getAvailableMemory()->toHumanReadable(),
'available_bytes' => $summary->getAvailableMemory()->toBytes(),
'usage_percentage' => $summary->getUsagePercentageFormatted(),
'is_critical' => $summary->isMemoryCritical(self::CRITICAL_MEMORY_THRESHOLD),
'is_warning' => $summary->isMemoryLow(self::WARNING_MEMORY_THRESHOLD),
'status' => $this->getMemoryStatus($summary)
];
}
/**
* Monitor memory during job execution
*/
public function monitorJobExecution(string $jobId, string $phase): MemorySummary
{
$summary = $this->memoryMonitor->getSummary();
// Log based on memory status
if ($summary->isMemoryCritical(self::CRITICAL_MEMORY_THRESHOLD)) {
$this->logger->error('Critical memory usage during job execution', LogContext::withData([
'job_id' => $jobId,
'phase' => $phase,
'memory' => $summary->getCurrentHumanReadable(),
'usage' => $summary->getUsagePercentageFormatted(),
'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()
]));
}
return $summary;
}
/**
* Check if job should be aborted due to memory constraints
*/
public function shouldAbortJob(string $jobId): bool
{
$summary = $this->memoryMonitor->getSummary();
if ($summary->isMemoryCritical(self::CRITICAL_MEMORY_THRESHOLD)) {
$this->logger->error('Job should be aborted due to critical memory usage', LogContext::withData([
'job_id' => $jobId,
'memory' => $summary->getCurrentHumanReadable(),
'usage' => $summary->getUsagePercentageFormatted(),
'limit' => $summary->getLimitHumanReadable()
]));
return true;
}
return false;
}
/**
* Optimize memory before job execution
*/
public function optimizeForJob(string $jobId): array
{
$before = $this->memoryMonitor->getSummary();
// Force garbage collection
gc_collect_cycles();
$after = $this->memoryMonitor->getSummary();
$freedBytes = $before->getCurrentBytes() - $after->getCurrentBytes();
$freedMemory = Byte::fromBytes(max(0, $freedBytes));
$optimization = [
'job_id' => $jobId,
'optimized_at' => date('Y-m-d H:i:s'),
'before' => $before->getCurrentHumanReadable(),
'after' => $after->getCurrentHumanReadable(),
'freed' => $freedMemory->toHumanReadable(),
'freed_bytes' => $freedMemory->toBytes(),
'usage_before' => $before->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()
]));
return $optimization;
}
/**
* Calculate memory efficiency for job metrics
*/
public function calculateJobMemoryEfficiency(array $jobMetrics): array
{
if (empty($jobMetrics)) {
return [
'total_jobs' => 0,
'average_memory' => Byte::fromBytes(0),
'peak_memory' => Byte::fromBytes(0),
'total_memory' => Byte::fromBytes(0),
'efficiency_score' => Percentage::from(100),
'efficiency_rating' => 'excellent'
];
}
$totalBytes = 0;
$peakBytes = 0;
$jobCount = count($jobMetrics);
foreach ($jobMetrics as $metrics) {
if ($metrics instanceof JobMetrics) {
$bytes = $metrics->memoryUsageBytes;
$totalBytes += $bytes;
$peakBytes = max($peakBytes, $bytes);
}
}
$averageBytes = $jobCount > 0 ? (int)($totalBytes / $jobCount) : 0;
$averageMemory = Byte::fromBytes($averageBytes);
$peakMemory = Byte::fromBytes($peakBytes);
$totalMemory = Byte::fromBytes($totalBytes);
// Calculate efficiency score
$memoryLimit = $this->memoryMonitor->getMemoryLimit();
$optimalLimit = Byte::fromBytes((int)($memoryLimit->toBytes() * self::OPTIMAL_MEMORY_THRESHOLD / 100));
// Efficiency is how well we stay under the optimal threshold
$efficiencyRatio = $averageBytes > 0 ? min(1.0, $optimalLimit->toBytes() / $averageBytes) : 1.0;
$efficiencyScore = Percentage::from($efficiencyRatio * 100);
return [
'total_jobs' => $jobCount,
'average_memory' => $averageMemory,
'average_memory_human' => $averageMemory->toHumanReadable(),
'peak_memory' => $peakMemory,
'peak_memory_human' => $peakMemory->toHumanReadable(),
'total_memory' => $totalMemory,
'total_memory_human' => $totalMemory->toHumanReadable(),
'efficiency_score' => $efficiencyScore,
'efficiency_score_formatted' => $efficiencyScore->format(2),
'efficiency_rating' => $this->getEfficiencyRating($efficiencyScore)
];
}
/**
* Get memory recommendations for job processing
*/
public function getMemoryRecommendations(): array
{
$summary = $this->memoryMonitor->getSummary();
$recommendations = [];
$priority = 'low';
if ($summary->isMemoryCritical(self::CRITICAL_MEMORY_THRESHOLD)) {
$priority = 'critical';
$recommendations[] = [
'type' => 'critical',
'message' => 'Memory usage is critical. Consider immediate action.',
'actions' => [
'Increase memory limit or reduce job complexity',
'Force garbage collection before new jobs',
'Split large jobs into smaller chunks',
'Consider job queue throttling'
]
];
} elseif ($summary->isMemoryLow(self::WARNING_MEMORY_THRESHOLD)) {
$priority = 'warning';
$recommendations[] = [
'type' => 'warning',
'message' => 'Memory usage is high. Monitor closely.',
'actions' => [
'Schedule periodic garbage collection',
'Review job memory patterns',
'Consider optimizing job processing',
'Monitor for memory leaks'
]
];
} else {
$recommendations[] = [
'type' => 'info',
'message' => 'Memory usage is within normal range.',
'actions' => [
'Continue monitoring',
'Maintain current optimization strategies'
]
];
}
return [
'priority' => $priority,
'current_usage' => $summary->getUsagePercentageFormatted(),
'available' => $summary->getAvailableMemory()->toHumanReadable(),
'recommendations' => $recommendations,
'timestamp' => date('Y-m-d H:i:s')
];
}
/**
* Check if enough memory is available for job
*/
public function hasEnoughMemoryForJob(Byte $estimatedMemory): bool
{
$summary = $this->memoryMonitor->getSummary();
$available = $summary->getAvailableMemory();
// Add 20% buffer for safety
$requiredWithBuffer = Byte::fromBytes((int)($estimatedMemory->toBytes() * 1.2));
return $available->toBytes() >= $requiredWithBuffer->toBytes();
}
/**
* Get memory status string
*/
private function getMemoryStatus(MemorySummary $summary): string
{
if ($summary->isMemoryCritical(self::CRITICAL_MEMORY_THRESHOLD)) {
return 'critical';
} elseif ($summary->isMemoryLow(self::WARNING_MEMORY_THRESHOLD)) {
return 'warning';
} elseif ($summary->usagePercentage->greaterThan(Percentage::from(self::OPTIMAL_MEMORY_THRESHOLD))) {
return 'elevated';
} else {
return 'normal';
}
}
/**
* Get efficiency rating based on score
*/
private function getEfficiencyRating(Percentage $score): string
{
$value = $score->getValue();
return match (true) {
$value >= 90 => 'excellent',
$value >= 70 => 'good',
$value >= 50 => 'fair',
$value >= 30 => 'poor',
default => 'critical'
};
}
}

View File

@@ -0,0 +1,359 @@
<?php
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;
final readonly class JobMetricsManager
{
public function __construct(
private ConnectionInterface $connection,
private Logger $logger
) {}
public function recordJobMetrics(JobMetrics $metrics): void
{
$existingEntry = $this->findJobMetricsEntry($metrics->jobId);
if ($existingEntry) {
$updatedEntry = $existingEntry->updateWithMetrics($metrics);
$this->entityManager->save($updatedEntry);
} else {
$newEntry = JobMetricsEntry::fromJobMetrics($metrics);
$this->entityManager->save($newEntry);
}
$this->entityManager->flush();
$this->logger->debug('Job metrics recorded', [
'job_id' => $metrics->jobId,
'queue_name' => $metrics->queueName,
'status' => $metrics->status,
'execution_time_ms' => $metrics->executionTimeMs,
'memory_usage_mb' => $metrics->getMemoryUsageMB()
]);
}
public function getJobMetrics(string $jobId): ?JobMetrics
{
$entry = $this->findJobMetricsEntry($jobId);
return $entry ? $entry->getJobMetrics() : null;
}
public function getQueueMetrics(string $queueName, string $timeWindow = '1 hour'): QueueMetrics
{
$windowStart = date('Y-m-d H:i:s', strtotime("-{$timeWindow}"));
$query = "SELECT * FROM job_metrics
WHERE queue_name = ? AND created_at >= ?
ORDER BY created_at DESC";
$results = $this->connection->fetchAll($query, [$queueName, $windowStart]);
$jobMetrics = array_map(function(array $row) {
return $this->mapRowToJobMetrics($row);
}, $results);
return QueueMetrics::calculate($queueName, $jobMetrics, $timeWindow);
}
public function getAllQueueMetrics(string $timeWindow = '1 hour'): array
{
$query = "SELECT DISTINCT queue_name FROM job_metrics";
$queueNames = $this->connection->fetchAll($query);
$allMetrics = [];
foreach ($queueNames as $row) {
$queueName = $row['queue_name'];
$allMetrics[$queueName] = $this->getQueueMetrics($queueName, $timeWindow);
}
return $allMetrics;
}
public function getJobMetricsHistory(string $jobId): array
{
$query = "SELECT * FROM job_metrics
WHERE job_id = ?
ORDER BY updated_at ASC";
$results = $this->connection->fetchAll($query, [$jobId]);
return array_map(function(array $row) {
return $this->mapRowToJobMetrics($row);
}, $results);
}
public function getTopSlowJobs(?string $queueName = null, int $limit = 10): array
{
$whereClause = $queueName ? "WHERE queue_name = ?" : "";
$params = $queueName ? [$queueName] : [];
$query = "SELECT * FROM job_metrics
{$whereClause}
ORDER BY execution_time_ms DESC
LIMIT ?";
$params[] = $limit;
$results = $this->connection->fetchAll($query, $params);
return array_map(function(array $row) {
return $this->mapRowToJobMetrics($row);
}, $results);
}
public function getTopMemoryConsumers(?string $queueName = null, int $limit = 10): array
{
$whereClause = $queueName ? "WHERE queue_name = ?" : "";
$params = $queueName ? [$queueName] : [];
$query = "SELECT * FROM job_metrics
{$whereClause}
ORDER BY memory_usage_bytes DESC
LIMIT ?";
$params[] = $limit;
$results = $this->connection->fetchAll($query, $params);
return array_map(function(array $row) {
return $this->mapRowToJobMetrics($row);
}, $results);
}
public function getFailedJobs(?string $queueName = null, string $timeWindow = '24 hours'): array
{
$windowStart = date('Y-m-d H:i:s', strtotime("-{$timeWindow}"));
$whereClause = "WHERE status = 'failed' AND failed_at >= ?";
$params = [$windowStart];
if ($queueName) {
$whereClause .= " AND queue_name = ?";
$params[] = $queueName;
}
$query = "SELECT * FROM job_metrics
{$whereClause}
ORDER BY failed_at DESC";
$results = $this->connection->fetchAll($query, $params);
return array_map(function(array $row) {
return $this->mapRowToJobMetrics($row);
}, $results);
}
public function getPerformanceStats(?string $queueName = null, string $timeWindow = '24 hours'): array
{
$windowStart = date('Y-m-d H:i:s', strtotime("-{$timeWindow}"));
$whereClause = "WHERE created_at >= ?";
$params = [$windowStart];
if ($queueName) {
$whereClause .= " AND queue_name = ?";
$params[] = $queueName;
}
$query = "SELECT
COUNT(*) as total_jobs,
COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_jobs,
COUNT(CASE WHEN status = 'failed' THEN 1 END) as failed_jobs,
AVG(execution_time_ms) as avg_execution_time_ms,
MIN(execution_time_ms) as min_execution_time_ms,
MAX(execution_time_ms) as max_execution_time_ms,
AVG(memory_usage_bytes) as avg_memory_usage_bytes,
MIN(memory_usage_bytes) as min_memory_usage_bytes,
MAX(memory_usage_bytes) as max_memory_usage_bytes,
AVG(attempts) as avg_attempts
FROM job_metrics
{$whereClause}";
$result = $this->connection->fetchOne($query, $params);
$totalJobs = (int) $result['total_jobs'];
$completedJobs = (int) $result['completed_jobs'];
$failedJobs = (int) $result['failed_jobs'];
$successRate = $totalJobs > 0 ? ($completedJobs / $totalJobs) * 100 : 100;
return [
'time_window' => $timeWindow,
'queue_name' => $queueName,
'total_jobs' => $totalJobs,
'completed_jobs' => $completedJobs,
'failed_jobs' => $failedJobs,
'success_rate' => $successRate,
'failure_rate' => 100 - $successRate,
'average_execution_time_ms' => (float) $result['avg_execution_time_ms'],
'min_execution_time_ms' => (float) $result['min_execution_time_ms'],
'max_execution_time_ms' => (float) $result['max_execution_time_ms'],
'average_memory_usage_bytes' => (int) $result['avg_memory_usage_bytes'],
'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']
];
}
public function getThroughputStats(?string $queueName = null, string $timeWindow = '24 hours'): array
{
$windowStart = date('Y-m-d H:i:s', strtotime("-{$timeWindow}"));
$whereClause = "WHERE completed_at >= ?";
$params = [$windowStart];
if ($queueName) {
$whereClause .= " AND queue_name = ?";
$params[] = $queueName;
}
// Get hourly throughput
$query = "SELECT
DATE_FORMAT(completed_at, '%Y-%m-%d %H:00:00') as hour,
COUNT(*) as jobs_completed
FROM job_metrics
{$whereClause}
GROUP BY hour
ORDER BY hour";
$hourlyStats = $this->connection->fetchAll($query, $params);
$totalHours = count($hourlyStats);
$totalCompleted = array_sum(array_column($hourlyStats, 'jobs_completed'));
$avgThroughput = $totalHours > 0 ? $totalCompleted / $totalHours : 0;
return [
'time_window' => $timeWindow,
'queue_name' => $queueName,
'total_completed' => $totalCompleted,
'average_throughput_per_hour' => $avgThroughput,
'hourly_breakdown' => $hourlyStats
];
}
public function cleanupOldMetrics(int $olderThanDays = 90): int
{
$cutoffDate = date('Y-m-d H:i:s', strtotime("-{$olderThanDays} days"));
$query = "DELETE FROM job_metrics WHERE created_at < ?";
$this->logger->info('Cleaning up old job metrics', [
'cutoff_date' => $cutoffDate,
'older_than_days' => $olderThanDays
]);
$affectedRows = $this->connection->execute($query, [$cutoffDate]);
$this->logger->info('Old job metrics cleaned up', [
'deleted_count' => $affectedRows
]);
return $affectedRows;
}
public function getSystemOverview(): array
{
$allMetrics = $this->getAllQueueMetrics('24 hours');
$totalJobs = 0;
$totalCompleted = 0;
$totalFailed = 0;
$totalPending = 0;
$totalRunning = 0;
$avgExecutionTime = 0;
$avgMemoryUsage = 0;
$healthyQueues = 0;
foreach ($allMetrics as $queueMetrics) {
$totalJobs += $queueMetrics->totalJobs;
$totalCompleted += $queueMetrics->completedJobs;
$totalFailed += $queueMetrics->failedJobs;
$totalPending += $queueMetrics->pendingJobs;
$totalRunning += $queueMetrics->runningJobs;
$avgExecutionTime += $queueMetrics->averageExecutionTimeMs;
$avgMemoryUsage += $queueMetrics->averageMemoryUsageMB;
if ($queueMetrics->isHealthy()) {
$healthyQueues++;
}
}
$queueCount = count($allMetrics);
$systemHealthScore = $queueCount > 0 ? ($healthyQueues / $queueCount) * 100 : 100;
return [
'total_queues' => $queueCount,
'healthy_queues' => $healthyQueues,
'system_health_score' => $systemHealthScore,
'total_jobs' => $totalJobs,
'completed_jobs' => $totalCompleted,
'failed_jobs' => $totalFailed,
'pending_jobs' => $totalPending,
'running_jobs' => $totalRunning,
'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
];
}
private function findJobMetricsEntry(string $jobId): ?JobMetricsEntry
{
$query = "SELECT * FROM job_metrics WHERE job_id = ? ORDER BY updated_at DESC LIMIT 1";
$result = $this->connection->fetchOne($query, [$jobId]);
if (!$result) {
return null;
}
return $this->mapRowToEntry($result);
}
private function mapRowToEntry(array $row): JobMetricsEntry
{
return new JobMetricsEntry(
id: $row['id'],
jobId: $row['job_id'],
queueName: $row['queue_name'],
status: $row['status'],
attempts: (int) $row['attempts'],
maxAttempts: (int) $row['max_attempts'],
executionTimeMs: (float) $row['execution_time_ms'],
memoryUsageBytes: (int) $row['memory_usage_bytes'],
createdAt: $row['created_at'],
updatedAt: $row['updated_at'],
errorMessage: $row['error_message'],
startedAt: $row['started_at'],
completedAt: $row['completed_at'],
failedAt: $row['failed_at'],
metadata: $row['metadata']
);
}
private function mapRowToJobMetrics(array $row): JobMetrics
{
return new JobMetrics(
jobId: $row['job_id'],
queueName: $row['queue_name'],
status: $row['status'],
attempts: (int) $row['attempts'],
maxAttempts: (int) $row['max_attempts'],
executionTimeMs: (float) $row['execution_time_ms'],
memoryUsageBytes: (int) $row['memory_usage_bytes'],
errorMessage: $row['error_message'],
createdAt: $row['created_at'],
startedAt: $row['started_at'],
completedAt: $row['completed_at'],
failedAt: $row['failed_at'],
metadata: $row['metadata'] ? json_decode($row['metadata'], true) : []
);
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Framework\Queue\Services;
use App\Framework\Queue\ValueObjects\JobMetrics;
use App\Framework\Queue\ValueObjects\QueueMetrics;
interface JobMetricsManagerInterface
{
public function recordJobMetrics(JobMetrics $metrics): void;
public function getSystemOverview(): array;
public function getQueueMetrics(string $queueName, string $timeWindow): QueueMetrics;
public function getJobMetrics(string $jobId): ?JobMetrics;
public function getTopSlowJobs(?string $queueName, int $limit): array;
public function getTopMemoryConsumers(?string $queueName, int $limit): array;
public function getFailedJobs(?string $queueName, string $timeWindow): array;
public function getPerformanceStats(?string $queueName, string $timeWindow): array;
public function getThroughputStats(?string $queueName, string $timeWindow): array;
public function cleanupOldMetrics(int $olderThanDays): int;
}

View File

@@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
namespace App\Framework\Queue\Services;
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
*/
final readonly class MockJobMetricsManager implements JobMetricsManagerInterface
{
public function __construct(
private Logger $logger
) {}
public function recordJobMetrics(JobMetrics $metrics): void
{
$this->logger->debug('Job metrics recorded (mock)', [
'job_id' => $metrics->jobId,
'queue_name' => $metrics->queueName,
'status' => $metrics->status,
]);
}
public function getSystemOverview(): array
{
return [
'system_health_score' => 85,
'total_queues' => 3,
'healthy_queues' => 2,
'total_jobs' => 1250,
'completed_jobs' => 1200,
'failed_jobs' => 35,
'pending_jobs' => 10,
'running_jobs' => 5,
'overall_success_rate' => 96.0,
'average_execution_time_ms' => 245,
'average_memory_usage_mb' => 12.5,
'queue_metrics' => [
'emails' => $this->createMockQueueMetrics('emails', 850, 95.2),
'notifications' => $this->createMockQueueMetrics('notifications', 300, 98.0),
'reports' => $this->createMockQueueMetrics('reports', 100, 85.0),
]
];
}
public function getQueueMetrics(string $queueName, string $timeWindow): QueueMetrics
{
return $this->createMockQueueMetrics($queueName, 500, 94.5);
}
public function getJobMetrics(string $jobId): ?JobMetrics
{
if ($jobId === 'nonexistent') {
return null;
}
return new JobMetrics(
jobId: $jobId,
queueName: 'emails',
status: 'completed',
attempts: 1,
maxAttempts: 3,
executionTimeMs: 150,
memoryUsageBytes: 2048000,
createdAt: '2024-01-15 10:30:00',
startedAt: '2024-01-15 10:30:01',
completedAt: '2024-01-15 10:30:02',
failedAt: null,
errorMessage: null,
metadata: ['user_id' => 123, 'template' => 'welcome']
);
}
public function getTopSlowJobs(?string $queueName, int $limit): array
{
$jobs = [];
for ($i = 1; $i <= min($limit, 5); $i++) {
$jobs[] = new JobMetrics(
jobId: "slow-job-{$i}",
queueName: $queueName ?? 'emails',
status: 'completed',
attempts: 1,
maxAttempts: 3,
executionTimeMs: 1000 + ($i * 200),
memoryUsageBytes: 1024000 * $i,
createdAt: '2024-01-15 10:30:00',
startedAt: '2024-01-15 10:30:01',
completedAt: '2024-01-15 10:30:02',
failedAt: null,
errorMessage: null,
metadata: []
);
}
return $jobs;
}
public function getTopMemoryConsumers(?string $queueName, int $limit): array
{
$jobs = [];
for ($i = 1; $i <= min($limit, 5); $i++) {
$jobs[] = new JobMetrics(
jobId: "memory-job-{$i}",
queueName: $queueName ?? 'reports',
status: 'completed',
attempts: 1,
maxAttempts: 3,
executionTimeMs: 300,
memoryUsageBytes: 10485760 * $i, // 10MB, 20MB, etc.
createdAt: '2024-01-15 10:30:00',
startedAt: '2024-01-15 10:30:01',
completedAt: '2024-01-15 10:30:02',
failedAt: null,
errorMessage: null,
metadata: []
);
}
return $jobs;
}
public function getFailedJobs(?string $queueName, string $timeWindow): array
{
$jobs = [];
for ($i = 1; $i <= 3; $i++) {
$jobs[] = new JobMetrics(
jobId: "failed-job-{$i}",
queueName: $queueName ?? 'notifications',
status: 'failed',
attempts: 3,
maxAttempts: 3,
executionTimeMs: 500,
memoryUsageBytes: 1024000,
createdAt: '2024-01-15 10:30:00',
startedAt: '2024-01-15 10:30:01',
completedAt: null,
failedAt: '2024-01-15 10:30:10',
errorMessage: "Connection timeout to external API",
metadata: []
);
}
return $jobs;
}
public function getPerformanceStats(?string $queueName, string $timeWindow): array
{
return [
'total_jobs' => 1000,
'completed_jobs' => 950,
'failed_jobs' => 50,
'success_rate' => 95.0,
'failure_rate' => 5.0,
'average_execution_time_ms' => 250.5,
'min_execution_time_ms' => 50.0,
'max_execution_time_ms' => 2500.0,
'average_memory_usage_mb' => 15.2,
'min_memory_usage_bytes' => 512000,
'max_memory_usage_bytes' => 52428800,
'average_attempts' => 1.1,
];
}
public function getThroughputStats(?string $queueName, string $timeWindow): array
{
$hourlyData = [];
for ($hour = 0; $hour < 24; $hour++) {
$hourlyData[] = [
'hour' => sprintf('2024-01-15 %02d:00:00', $hour),
'jobs_completed' => rand(10, 50)
];
}
return [
'total_completed' => 800,
'average_throughput_per_hour' => 33.3,
'hourly_breakdown' => $hourlyData
];
}
public function cleanupOldMetrics(int $olderThanDays): int
{
// Simulate cleanup
return rand(50, 200);
}
private function createMockQueueMetrics(string $queueName, int $totalJobs, float $successRate): QueueMetrics
{
$completedJobs = (int) ($totalJobs * ($successRate / 100));
$failedJobs = $totalJobs - $completedJobs;
return new QueueMetrics(
queueName: $queueName,
totalJobs: $totalJobs,
completedJobs: $completedJobs,
failedJobs: $failedJobs,
pendingJobs: rand(5, 20),
runningJobs: rand(0, 5),
deadLetterJobs: rand(0, 3),
successRate: new Percentage($successRate),
averageExecutionTimeMs: rand(100, 500),
averageMemoryUsageMB: rand(5, 25),
throughputJobsPerHour: rand(50, 200),
measuredAt: date('Y-m-d H:i:s')
);
}
}

View File

@@ -0,0 +1,510 @@
<?php
declare(strict_types=1);
namespace App\Framework\Queue\Services;
use App\Framework\Queue\ValueObjects\JobMetrics;
use App\Framework\Queue\ValueObjects\QueueMetrics;
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;
/**
* Production implementation of JobMetricsManager with full database integration
*/
final readonly class ProductionJobMetricsManager implements JobMetricsManagerInterface
{
public function __construct(
private ConnectionInterface $connection,
private Logger $logger
) {}
public function recordJobMetrics(JobMetrics $metrics): void
{
try {
$this->connection->beginTransaction();
$existingEntry = $this->findJobMetricsEntry($metrics->jobId);
if ($existingEntry) {
$this->updateJobMetricsEntry($metrics);
} else {
$this->insertJobMetricsEntry($metrics);
}
$this->connection->commit();
$this->logger->debug('Job metrics recorded', [
'job_id' => $metrics->jobId,
'queue_name' => $metrics->queueName,
'status' => $metrics->status,
'execution_time_ms' => $metrics->executionTimeMs,
'memory_usage_bytes' => $metrics->memoryUsageBytes,
]);
} catch (\Exception $e) {
$this->connection->rollback();
$this->logger->error('Failed to record job metrics', [
'job_id' => $metrics->jobId,
'error' => $e->getMessage()
]);
throw $e;
}
}
public function getJobMetrics(string $jobId): ?JobMetrics
{
$query = "SELECT * FROM job_metrics WHERE job_id = ? LIMIT 1";
$result = $this->connection->queryOne(
SqlQuery::create($query, [$jobId])
);
if (!$result) {
return null;
}
return $this->mapRowToJobMetrics($result);
}
public function getQueueMetrics(string $queueName, string $timeWindow = '1 hour'): QueueMetrics
{
$windowStart = date('Y-m-d H:i:s', strtotime("-{$timeWindow}"));
$query = "SELECT * FROM job_metrics
WHERE queue_name = ? AND created_at >= ?
ORDER BY created_at DESC";
$results = $this->connection->query(
SqlQuery::create($query, [$queueName, $windowStart])
)->fetchAll();
$jobMetrics = array_map(function(array $row) {
return $this->mapRowToJobMetrics($row);
}, $results);
return $this->calculateQueueMetrics($queueName, $jobMetrics, $timeWindow);
}
public function getSystemOverview(): array
{
$windowStart = date('Y-m-d H:i:s', strtotime('-24 hours'));
// Get overall statistics
$overallStats = $this->connection->queryOne(
SqlQuery::create("
SELECT
COUNT(*) as total_jobs,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed_jobs,
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed_jobs,
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending_jobs,
SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) as running_jobs,
AVG(execution_time_ms) as avg_execution_time,
AVG(memory_usage_bytes / 1024 / 1024) as avg_memory_mb
FROM job_metrics
WHERE created_at >= ?
", [$windowStart])
);
$totalJobs = (int) $overallStats['total_jobs'];
$completedJobs = (int) $overallStats['completed_jobs'];
$successRate = $totalJobs > 0 ? ($completedJobs / $totalJobs) * 100 : 100.0;
// Get queue names
$queueNames = $this->connection->query(
SqlQuery::create("
SELECT DISTINCT queue_name
FROM job_metrics
WHERE created_at >= ?
", [$windowStart])
)->fetchAll();
$queueMetrics = [];
$healthyQueues = 0;
foreach ($queueNames as $row) {
$queueName = $row['queue_name'];
$metrics = $this->getQueueMetrics($queueName, '24 hours');
$queueMetrics[$queueName] = $metrics;
if ($metrics->isHealthy()) {
$healthyQueues++;
}
}
$systemHealthScore = $this->calculateSystemHealthScore($successRate, count($queueNames), $healthyQueues);
return [
'system_health_score' => $systemHealthScore,
'total_queues' => count($queueNames),
'healthy_queues' => $healthyQueues,
'total_jobs' => $totalJobs,
'completed_jobs' => $completedJobs,
'failed_jobs' => (int) $overallStats['failed_jobs'],
'pending_jobs' => (int) $overallStats['pending_jobs'],
'running_jobs' => (int) $overallStats['running_jobs'],
'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
];
}
public function getTopSlowJobs(?string $queueName, int $limit): array
{
$params = [];
$whereClause = '';
if ($queueName) {
$whereClause = 'WHERE queue_name = ?';
$params[] = $queueName;
}
$query = "SELECT * FROM job_metrics
{$whereClause}
ORDER BY execution_time_ms DESC
LIMIT ?";
$params[] = $limit;
$results = $this->connection->query(
SqlQuery::create($query, $params)
)->fetchAll();
return array_map([$this, 'mapRowToJobMetrics'], $results);
}
public function getTopMemoryConsumers(?string $queueName, int $limit): array
{
$params = [];
$whereClause = '';
if ($queueName) {
$whereClause = 'WHERE queue_name = ?';
$params[] = $queueName;
}
$query = "SELECT * FROM job_metrics
{$whereClause}
ORDER BY memory_usage_bytes DESC
LIMIT ?";
$params[] = $limit;
$results = $this->connection->query(
SqlQuery::create($query, $params)
)->fetchAll();
return array_map([$this, 'mapRowToJobMetrics'], $results);
}
public function getFailedJobs(?string $queueName, string $timeWindow): array
{
$windowStart = date('Y-m-d H:i:s', strtotime("-{$timeWindow}"));
$params = [$windowStart];
$whereClause = 'WHERE status = "failed" AND created_at >= ?';
if ($queueName) {
$whereClause .= ' AND queue_name = ?';
$params[] = $queueName;
}
$query = "SELECT * FROM job_metrics
{$whereClause}
ORDER BY failed_at DESC";
$results = $this->connection->query(
SqlQuery::create($query, $params)
)->fetchAll();
return array_map([$this, 'mapRowToJobMetrics'], $results);
}
public function getPerformanceStats(?string $queueName, string $timeWindow): array
{
$windowStart = date('Y-m-d H:i:s', strtotime("-{$timeWindow}"));
$params = [$windowStart];
$whereClause = 'WHERE created_at >= ?';
if ($queueName) {
$whereClause .= ' AND queue_name = ?';
$params[] = $queueName;
}
$query = "SELECT
COUNT(*) as total_jobs,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed_jobs,
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed_jobs,
AVG(execution_time_ms) as avg_execution_time,
MIN(execution_time_ms) as min_execution_time,
MAX(execution_time_ms) as max_execution_time,
AVG(memory_usage_bytes / 1024 / 1024) as avg_memory_mb,
MIN(memory_usage_bytes) as min_memory_bytes,
MAX(memory_usage_bytes) as max_memory_bytes,
AVG(attempts) as avg_attempts
FROM job_metrics
{$whereClause}";
$stats = $this->connection->queryOne(
SqlQuery::create($query, $params)
);
$totalJobs = (int) $stats['total_jobs'];
$completedJobs = (int) $stats['completed_jobs'];
$failedJobs = (int) $stats['failed_jobs'];
return [
'total_jobs' => $totalJobs,
'completed_jobs' => $completedJobs,
'failed_jobs' => $failedJobs,
'success_rate' => $totalJobs > 0 ? ($completedJobs / $totalJobs) * 100 : 100,
'failure_rate' => $totalJobs > 0 ? ($failedJobs / $totalJobs) * 100 : 0,
'average_execution_time_ms' => (float) $stats['avg_execution_time'],
'min_execution_time_ms' => (float) $stats['min_execution_time'],
'max_execution_time_ms' => (float) $stats['max_execution_time'],
'average_memory_usage_mb' => (float) $stats['avg_memory_mb'],
'min_memory_usage_bytes' => (int) $stats['min_memory_bytes'],
'max_memory_usage_bytes' => (int) $stats['max_memory_bytes'],
'average_attempts' => (float) $stats['avg_attempts'],
];
}
public function getThroughputStats(?string $queueName, string $timeWindow): array
{
$windowStart = date('Y-m-d H:i:s', strtotime("-{$timeWindow}"));
$params = [$windowStart];
$whereClause = 'WHERE status = "completed" AND created_at >= ?';
if ($queueName) {
$whereClause .= ' AND queue_name = ?';
$params[] = $queueName;
}
// Overall throughput
$overallQuery = "SELECT COUNT(*) as total_completed
FROM job_metrics
{$whereClause}";
$totalCompleted = (int) $this->connection->queryOne(
SqlQuery::create($overallQuery, $params)
)['total_completed'];
// Hourly breakdown
$hourlyQuery = "SELECT
DATE_FORMAT(completed_at, '%Y-%m-%d %H:00:00') as hour,
COUNT(*) as jobs_completed
FROM job_metrics
{$whereClause}
GROUP BY DATE_FORMAT(completed_at, '%Y-%m-%d %H:00:00')
ORDER BY hour";
$hourlyStats = $this->connection->query(
SqlQuery::create($hourlyQuery, $params)
)->fetchAll();
// Calculate average per hour
$hours = $this->getHoursInTimeWindow($timeWindow);
$averageThroughput = $hours > 0 ? $totalCompleted / $hours : 0;
return [
'total_completed' => $totalCompleted,
'average_throughput_per_hour' => $averageThroughput,
'hourly_breakdown' => $hourlyStats
];
}
public function cleanupOldMetrics(int $olderThanDays): int
{
$cutoffDate = date('Y-m-d H:i:s', strtotime("-{$olderThanDays} days"));
$query = "DELETE FROM job_metrics WHERE created_at < ?";
$affectedRows = $this->connection->execute(
SqlQuery::create($query, [$cutoffDate])
);
$this->logger->info('Job metrics cleanup completed', [
'cutoff_date' => $cutoffDate,
'deleted_rows' => $affectedRows
]);
return $affectedRows;
}
private function insertJobMetricsEntry(JobMetrics $metrics): void
{
$query = "INSERT INTO job_metrics (
job_id, queue_name, status, attempts, max_attempts,
execution_time_ms, memory_usage_bytes,
created_at, started_at, completed_at, failed_at,
error_message, metadata
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
$this->connection->execute(
SqlQuery::create($query, [
$metrics->jobId,
$metrics->queueName,
$metrics->status,
$metrics->attempts,
$metrics->maxAttempts,
$metrics->executionTimeMs,
$metrics->memoryUsageBytes,
$metrics->createdAt,
$metrics->startedAt,
$metrics->completedAt,
$metrics->failedAt,
$metrics->errorMessage,
$metrics->metadata ? json_encode($metrics->metadata) : null
])
);
}
private function updateJobMetricsEntry(JobMetrics $metrics): void
{
$query = "UPDATE job_metrics SET
status = ?, attempts = ?, execution_time_ms = ?,
memory_usage_bytes = ?, started_at = ?, completed_at = ?,
failed_at = ?, error_message = ?, metadata = ?
WHERE job_id = ?";
$this->connection->execute(
SqlQuery::create($query, [
$metrics->status,
$metrics->attempts,
$metrics->executionTimeMs,
$metrics->memoryUsageBytes,
$metrics->startedAt,
$metrics->completedAt,
$metrics->failedAt,
$metrics->errorMessage,
$metrics->metadata ? json_encode($metrics->metadata) : null,
$metrics->jobId
])
);
}
private function findJobMetricsEntry(string $jobId): ?array
{
$query = "SELECT * FROM job_metrics WHERE job_id = ? LIMIT 1";
$result = $this->connection->queryOne(
SqlQuery::create($query, [$jobId])
);
return $result ?: null;
}
private function mapRowToJobMetrics(array $row): JobMetrics
{
return new JobMetrics(
jobId: $row['job_id'],
queueName: $row['queue_name'],
status: $row['status'],
attempts: (int) $row['attempts'],
maxAttempts: (int) $row['max_attempts'],
executionTimeMs: (int) $row['execution_time_ms'],
memoryUsageBytes: (int) $row['memory_usage_bytes'],
createdAt: $row['created_at'],
startedAt: $row['started_at'],
completedAt: $row['completed_at'],
failedAt: $row['failed_at'],
errorMessage: $row['error_message'],
metadata: $row['metadata'] ? json_decode($row['metadata'], true) : []
);
}
private function calculateQueueMetrics(string $queueName, array $jobMetrics, string $timeWindow): QueueMetrics
{
$totalJobs = count($jobMetrics);
if ($totalJobs === 0) {
return new QueueMetrics(
queueName: $queueName,
totalJobs: 0,
completedJobs: 0,
failedJobs: 0,
pendingJobs: 0,
runningJobs: 0,
deadLetterJobs: 0,
successRate: new Percentage(100.0),
averageExecutionTimeMs: 0,
averageMemoryUsageMB: 0.0,
throughputJobsPerHour: 0.0,
measuredAt: date('Y-m-d H:i:s')
);
}
$completed = $failed = $pending = $running = $deadLetter = 0;
$totalExecutionTime = $totalMemory = 0;
foreach ($jobMetrics as $metrics) {
switch ($metrics->status) {
case 'completed':
$completed++;
break;
case 'failed':
$failed++;
break;
case 'pending':
$pending++;
break;
case 'running':
$running++;
break;
case 'dead_letter':
$deadLetter++;
break;
}
$totalExecutionTime += $metrics->executionTimeMs;
$totalMemory += $metrics->memoryUsageBytes;
}
$successRate = new Percentage($totalJobs > 0 ? ($completed / $totalJobs) * 100 : 100);
$avgExecutionTime = $totalJobs > 0 ? $totalExecutionTime / $totalJobs : 0;
$avgMemoryMB = $totalJobs > 0 ? ($totalMemory / $totalJobs) / (1024 * 1024) : 0;
// Calculate throughput (jobs per hour)
$hours = $this->getHoursInTimeWindow($timeWindow);
$throughput = $hours > 0 ? $completed / $hours : 0;
return new QueueMetrics(
queueName: $queueName,
totalJobs: $totalJobs,
completedJobs: $completed,
failedJobs: $failed,
pendingJobs: $pending,
runningJobs: $running,
deadLetterJobs: $deadLetter,
successRate: $successRate,
averageExecutionTimeMs: $avgExecutionTime,
averageMemoryUsageMB: $avgMemoryMB,
throughputJobsPerHour: $throughput,
measuredAt: date('Y-m-d H:i:s')
);
}
private function calculateSystemHealthScore(float $successRate, int $totalQueues, int $healthyQueues): int
{
$successWeight = 60; // 60% weight for success rate
$healthWeight = 40; // 40% weight for queue health
$successScore = $successRate;
$healthScore = $totalQueues > 0 ? ($healthyQueues / $totalQueues) * 100 : 100;
$weightedScore = ($successScore * $successWeight + $healthScore * $healthWeight) / 100;
return (int) round($weightedScore);
}
private function getHoursInTimeWindow(string $timeWindow): float
{
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

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Framework\Queue\Services;
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
*/
final readonly class ProgressManager
{
public function __construct(
private JobProgressTrackerInterface $progressTracker
) {}
public function startJob(string $jobId, string $message = 'Job started'): void
{
$progress = JobProgress::starting($message);
$this->progressTracker->updateProgress($jobId, $progress, null);
}
public function updateJobProgress(
string $jobId,
float $percentage,
string $message,
?array $metadata = null
): void {
$progress = JobProgress::withPercentage(
percentage: Percentage::from($percentage),
message: $message,
metadata: $metadata
);
$this->progressTracker->updateProgress($jobId, $progress, null);
}
public function completeJobStep(
string $jobId,
string $stepName,
string $description,
?array $metadata = null
): void {
$step = ProgressStep::completed($stepName, $description, $metadata);
$this->progressTracker->completeStep($jobId, $step);
}
public function completeJob(string $jobId, string $message = 'Job completed successfully'): void
{
$this->progressTracker->markJobCompleted($jobId, $message);
}
public function failJob(string $jobId, string $message, ?\Throwable $exception = null): void
{
$this->progressTracker->markJobFailed($jobId, $message, $exception);
}
public function getJobProgress(string $jobId): ?JobProgress
{
return $this->progressTracker->getCurrentProgress($jobId);
}
public function getProgressHistory(string $jobId): array
{
return $this->progressTracker->getProgressHistory($jobId);
}
public function getMultipleJobProgress(array $jobIds): array
{
return $this->progressTracker->getProgressForJobs($jobIds);
}
public function getJobsAboveProgress(float $minPercentage): array
{
return $this->progressTracker->getJobsAboveProgress($minPercentage);
}
public function getRecentlyUpdatedJobs(int $limitMinutes = 60, int $limit = 100): array
{
return $this->progressTracker->getRecentlyUpdatedJobs($limitMinutes, $limit);
}
public function cleanupOldProgress(int $olderThanDays = 30): int
{
return $this->progressTracker->cleanupOldEntries($olderThanDays);
}
/**
* Create a multi-step progress tracker for complex jobs
*/
public function createStepTracker(string $jobId, array $steps): StepProgressTracker
{
return new StepProgressTracker($jobId, $steps, $this);
}
}

View File

@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace App\Framework\Queue\Services;
use App\Framework\Queue\ValueObjects\ProgressStep;
use App\Framework\Core\ValueObjects\Percentage;
/**
* Helper class for tracking progress through multiple steps
*/
final class StepProgressTracker
{
private int $currentStepIndex = 0;
private array $completedSteps = [];
public function __construct(
private readonly string $jobId,
private readonly array $steps,
private readonly ProgressManager $progressManager
) {
if (empty($steps)) {
throw new \InvalidArgumentException('Steps array cannot be empty');
}
// Validate step structure
foreach ($steps as $index => $step) {
if (!is_array($step) || !isset($step['name'], $step['description'])) {
throw new \InvalidArgumentException("Step at index {$index} must have 'name' and 'description' keys");
}
}
}
public function start(string $message = 'Starting multi-step job'): void
{
$this->progressManager->startJob($this->jobId, $message);
}
public function completeCurrentStep(?array $metadata = null): void
{
if ($this->currentStepIndex >= count($this->steps)) {
throw new \RuntimeException('All steps have already been completed');
}
$step = $this->steps[$this->currentStepIndex];
$this->progressManager->completeJobStep(
$this->jobId,
$step['name'],
$step['description'],
$metadata
);
$this->completedSteps[] = $step['name'];
$this->currentStepIndex++;
// Update overall progress percentage
$percentage = ($this->currentStepIndex / count($this->steps)) * 100;
$message = sprintf(
'Completed step %d of %d: %s',
$this->currentStepIndex,
count($this->steps),
$step['description']
);
$this->progressManager->updateJobProgress(
$this->jobId,
$percentage,
$message,
[
'current_step' => $this->currentStepIndex,
'total_steps' => count($this->steps),
'completed_steps' => $this->completedSteps
]
);
}
public function updateCurrentStepProgress(
float $stepPercentage,
string $message,
?array $metadata = null
): void {
if ($this->currentStepIndex >= count($this->steps)) {
throw new \RuntimeException('All steps have already been completed');
}
// Calculate overall percentage: completed steps + current step progress
$completedStepsPercentage = ($this->currentStepIndex / count($this->steps)) * 100;
$currentStepContribution = ($stepPercentage / 100) * (100 / count($this->steps));
$totalPercentage = $completedStepsPercentage + $currentStepContribution;
$step = $this->steps[$this->currentStepIndex];
$fullMessage = sprintf(
'Step %d of %d (%s): %s',
$this->currentStepIndex + 1,
count($this->steps),
$step['name'],
$message
);
$fullMetadata = array_merge($metadata ?? [], [
'current_step' => $this->currentStepIndex + 1,
'total_steps' => count($this->steps),
'step_name' => $step['name'],
'step_percentage' => $stepPercentage,
'completed_steps' => $this->completedSteps
]);
$this->progressManager->updateJobProgress(
$this->jobId,
$totalPercentage,
$fullMessage,
$fullMetadata
);
}
public function complete(string $message = 'All steps completed successfully'): void
{
// Complete any remaining steps if not all were explicitly completed
while ($this->currentStepIndex < count($this->steps)) {
$this->completeCurrentStep();
}
$this->progressManager->completeJob($this->jobId, $message);
}
public function fail(string $message, ?\Throwable $exception = null): void
{
$step = $this->getCurrentStep();
$fullMessage = sprintf(
'Failed on step %d of %d (%s): %s',
$this->currentStepIndex + 1,
count($this->steps),
$step['name'] ?? 'unknown',
$message
);
$this->progressManager->failJob($this->jobId, $fullMessage, $exception);
}
public function getCurrentStep(): ?array
{
if ($this->currentStepIndex >= count($this->steps)) {
return null;
}
return $this->steps[$this->currentStepIndex];
}
public function getRemainingSteps(): array
{
return array_slice($this->steps, $this->currentStepIndex);
}
public function getCompletedSteps(): array
{
return $this->completedSteps;
}
public function isComplete(): bool
{
return $this->currentStepIndex >= count($this->steps);
}
public function getProgress(): float
{
return ($this->currentStepIndex / count($this->steps)) * 100;
}
}

View File

@@ -0,0 +1,408 @@
<?php
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\Database\ConnectionInterface;
use App\Framework\Database\ValueObjects\SqlQuery;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
/**
* Worker Health Check Service für Monitoring und Überwachung
*/
final readonly class WorkerHealthCheckService
{
private const HEALTH_CHECK_INTERVAL_SECONDS = 60;
private const CRITICAL_CPU_THRESHOLD = 90;
private const WARNING_CPU_THRESHOLD = 75;
private const CRITICAL_MEMORY_THRESHOLD_GB = 2.0;
private const WARNING_MEMORY_THRESHOLD_GB = 1.5;
private const HEARTBEAT_TIMEOUT_SECONDS = 120;
public function __construct(
private WorkerRegistry $workerRegistry,
private ConnectionInterface $connection,
private Logger $logger
) {}
/**
* Health Check für alle aktiven Worker durchführen
*/
public function performHealthCheck(): array
{
$this->logger->info('Starting worker health check');
$workers = $this->workerRegistry->findActiveWorkers();
$healthResults = [];
foreach ($workers as $worker) {
$health = $this->checkWorkerHealth($worker);
$healthResults[] = $health;
// Health Status in Datenbank speichern
$this->recordHealthStatus($worker->id, $health);
// Bei kritischen Problemen Worker deaktivieren
if ($health['status'] === 'critical') {
$this->handleCriticalWorker($worker, $health);
}
}
// Cleanup inaktiver Worker
$this->workerRegistry->cleanupInactiveWorkers(2); // 2 Minuten ohne Heartbeat
$overallHealth = $this->calculateOverallHealth($healthResults);
$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']
]));
return [
'workers' => $healthResults,
'overall' => $overallHealth,
'checked_at' => date('Y-m-d H:i:s')
];
}
/**
* Health Check für einzelnen Worker
*/
public function checkWorkerHealth(Worker $worker): array
{
$issues = [];
$warnings = [];
$score = 100;
// Heartbeat prüfen
$heartbeatAge = $worker->lastHeartbeat
? time() - $worker->lastHeartbeat->getTimestamp()
: PHP_INT_MAX;
if ($heartbeatAge > self::HEARTBEAT_TIMEOUT_SECONDS) {
$issues[] = "No heartbeat for {$heartbeatAge} seconds";
$score -= 50;
} elseif ($heartbeatAge > self::HEARTBEAT_TIMEOUT_SECONDS / 2) {
$warnings[] = "Heartbeat delayed ({$heartbeatAge}s)";
$score -= 20;
}
// CPU Usage prüfen
$cpuUsage = $worker->cpuUsage->getValue();
if ($cpuUsage > self::CRITICAL_CPU_THRESHOLD) {
$issues[] = "Critical CPU usage: {$cpuUsage}%";
$score -= 30;
} elseif ($cpuUsage > self::WARNING_CPU_THRESHOLD) {
$warnings[] = "High CPU usage: {$cpuUsage}%";
$score -= 15;
}
// Memory Usage prüfen
$memoryGB = $worker->memoryUsage->toBytes() / (1024 * 1024 * 1024);
if ($memoryGB > self::CRITICAL_MEMORY_THRESHOLD_GB) {
$issues[] = sprintf("Critical memory usage: %.2fGB", $memoryGB);
$score -= 30;
} elseif ($memoryGB > self::WARNING_MEMORY_THRESHOLD_GB) {
$warnings[] = sprintf("High memory usage: %.2fGB", $memoryGB);
$score -= 15;
}
// Job Load prüfen
$loadPercentage = $worker->getLoadPercentage()->getValue();
if ($loadPercentage > 95) {
$issues[] = "Worker overloaded: {$loadPercentage}%";
$score -= 25;
} elseif ($loadPercentage > 80) {
$warnings[] = "High worker load: {$loadPercentage}%";
$score -= 10;
}
// Aktiv-Status prüfen
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',
default => 'healthy'
};
return [
'worker_id' => $worker->id->toString(),
'hostname' => $worker->hostname,
'process_id' => $worker->processId,
'status' => $status,
'score' => max(0, $score),
'metrics' => [
'heartbeat_age_seconds' => $heartbeatAge < PHP_INT_MAX ? $heartbeatAge : null,
'cpu_usage_percent' => $cpuUsage,
'memory_usage_gb' => round($memoryGB, 3),
'job_load_percent' => $loadPercentage,
'current_jobs' => $worker->currentJobs,
'max_jobs' => $worker->maxJobs,
'is_active' => $worker->isActive
],
'issues' => $issues,
'warnings' => $warnings,
'checked_at' => date('Y-m-d H:i:s')
];
}
/**
* Health Status in Datenbank speichern
*/
private function recordHealthStatus(WorkerId $workerId, array $healthData): void
{
try {
$sql = "INSERT INTO worker_health_checks (
worker_id, status, score, metrics, issues, warnings, checked_at
) VALUES (
:worker_id, :status, :score, :metrics, :issues, :warnings, NOW()
)";
$this->connection->execute(SqlQuery::create($sql, [
'worker_id' => $workerId->toString(),
'status' => $healthData['status'],
'score' => $healthData['score'],
'metrics' => json_encode($healthData['metrics']),
'issues' => json_encode($healthData['issues']),
'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()
]));
}
}
/**
* Kritischen Worker behandeln
*/
private function handleCriticalWorker(Worker $worker, array $healthData): void
{
$this->logger->error('Critical worker detected', LogContext::withData([
'worker_id' => $worker->id->toString(),
'hostname' => $worker->hostname,
'issues' => $healthData['issues'],
'score' => $healthData['score']
]));
// Worker als inaktiv markieren wenn zu viele kritische Issues
$criticalIssueCount = count($healthData['issues']);
if ($criticalIssueCount >= 2) {
try {
$this->workerRegistry->deregister($worker->id);
$this->logger->warning('Worker deregistered due to critical issues', LogContext::withData([
'worker_id' => $worker->id->toString(),
'hostname' => $worker->hostname,
'critical_issues' => $criticalIssueCount
]));
} catch (\Exception $e) {
$this->logger->error('Failed to deregister critical worker', LogContext::withData([
'worker_id' => $worker->id->toString(),
'error' => $e->getMessage()
]));
}
}
}
/**
* Overall Health für alle Worker berechnen
*/
private function calculateOverallHealth(array $healthResults): array
{
if (empty($healthResults)) {
return [
'status' => 'critical',
'score' => 0,
'worker_count' => 0,
'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'));
// Durchschnittlicher Score
$avgScore = array_sum(array_column($healthResults, 'score')) / $totalWorkers;
// Percentage der gesunden Worker
$healthyPercentage = ($healthyWorkers / $totalWorkers) * 100;
// Overall Status bestimmen
$status = match (true) {
$healthyPercentage < 50 || $criticalWorkers > $totalWorkers / 2 => 'critical',
$healthyPercentage < 80 || $warningWorkers > 0 => 'warning',
default => 'healthy'
};
$overallIssues = [];
if ($criticalWorkers > 0) {
$overallIssues[] = "{$criticalWorkers} workers in critical state";
}
if ($warningWorkers > 0) {
$overallIssues[] = "{$warningWorkers} workers with warnings";
}
if ($healthyPercentage < 70) {
$overallIssues[] = "Only {$healthyPercentage}% of workers are healthy";
}
return [
'status' => $status,
'score' => round($avgScore, 1),
'worker_count' => $totalWorkers,
'healthy_workers' => $healthyWorkers,
'warning_workers' => $warningWorkers,
'critical_workers' => $criticalWorkers,
'healthy_percentage' => round($healthyPercentage, 1),
'issues' => $overallIssues
];
}
/**
* Worker Health History abrufen
*/
public function getWorkerHealthHistory(WorkerId $workerId, ?Duration $period = null): array
{
$period = $period ?? Duration::fromDays(1);
try {
$sql = "SELECT * FROM worker_health_checks
WHERE worker_id = :worker_id
AND checked_at >= DATE_SUB(NOW(), INTERVAL :hours HOUR)
ORDER BY checked_at DESC
LIMIT 100";
$result = $this->connection->query(SqlQuery::create($sql, [
'worker_id' => $workerId->toString(),
'hours' => $period->toHours()
]));
$history = [];
foreach ($result->fetchAll() as $row) {
$history[] = [
'status' => $row['status'],
'score' => (int) $row['score'],
'metrics' => json_decode($row['metrics'], true),
'issues' => json_decode($row['issues'], true),
'warnings' => json_decode($row['warnings'], true),
'checked_at' => $row['checked_at']
];
}
return $history;
} catch (\Exception $e) {
$this->logger->error('Failed to get worker health history', LogContext::withData([
'worker_id' => $workerId->toString(),
'error' => $e->getMessage()
]));
throw $e;
}
}
/**
* System Health Report generieren
*/
public function generateSystemHealthReport(): array
{
try {
// Aktuelle Health Checks
$currentHealth = $this->performHealthCheck();
// Health Trends (letzte 24 Stunden)
$sql = "SELECT
DATE(checked_at) as check_date,
HOUR(checked_at) as check_hour,
AVG(score) as avg_score,
COUNT(*) as total_checks,
SUM(CASE WHEN status = 'healthy' THEN 1 ELSE 0 END) as healthy_count,
SUM(CASE WHEN status = 'warning' THEN 1 ELSE 0 END) as warning_count,
SUM(CASE WHEN status = 'critical' THEN 1 ELSE 0 END) as critical_count
FROM worker_health_checks
WHERE checked_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR)
GROUP BY DATE(checked_at), HOUR(checked_at)
ORDER BY check_date DESC, check_hour DESC
LIMIT 24";
$result = $this->connection->query(SqlQuery::create($sql));
$trends = $result->fetchAll();
// Top Issues der letzten 24 Stunden
$issuesSql = "SELECT
JSON_UNQUOTE(JSON_EXTRACT(issues, '$[*]')) as issue_list,
COUNT(*) as occurrence_count
FROM worker_health_checks
WHERE checked_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR)
AND JSON_LENGTH(issues) > 0
GROUP BY JSON_EXTRACT(issues, '$[*]')
ORDER BY occurrence_count DESC
LIMIT 10";
$result = $this->connection->query(SqlQuery::create($issuesSql));
$topIssues = $result->fetchAll();
return [
'current_health' => $currentHealth,
'trends_24h' => $trends,
'top_issues_24h' => $topIssues,
'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()
]));
throw $e;
}
}
/**
* Health Check Cleanup (alte Einträge löschen)
*/
public function cleanupHealthChecks(?Duration $retentionPeriod = null): int
{
$retentionPeriod = $retentionPeriod ?? Duration::fromDays(30);
try {
$sql = "DELETE FROM worker_health_checks
WHERE checked_at < DATE_SUB(NOW(), INTERVAL :hours HOUR)";
$deletedCount = $this->connection->execute(SqlQuery::create($sql, [
'hours' => $retentionPeriod->toHours()
]));
$this->logger->info('Health check cleanup completed', LogContext::withData([
'deleted_records' => $deletedCount,
'retention_days' => $retentionPeriod->toHours() / 24
]));
return $deletedCount;
} catch (\Exception $e) {
$this->logger->error('Failed to cleanup health checks', LogContext::withData([
'error' => $e->getMessage()
]));
throw $e;
}
}
}

View File

@@ -0,0 +1,384 @@
<?php
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\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;
/**
* Worker Registry für Distributed Job Processing
*/
final readonly class WorkerRegistry
{
private const TABLE_NAME = 'queue_workers';
public function __construct(
private ConnectionInterface $connection,
private Logger $logger
) {}
/**
* Worker registrieren
*/
public function register(Worker $worker): void
{
$this->logger->info('Registering worker', LogContext::withData([
'worker_id' => $worker->id->toString(),
'hostname' => $worker->hostname,
'process_id' => $worker->processId,
'queues' => $worker->queues,
'max_jobs' => $worker->maxJobs
]));
try {
$sql = "INSERT INTO " . self::TABLE_NAME . " (
id, hostname, process_id, queues, max_jobs, current_jobs,
is_active, cpu_usage, memory_usage_bytes, registered_at,
last_heartbeat, capabilities, version
) VALUES (
: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)";
$data = $worker->toArray();
$data['is_active'] = $data['is_active'] ? 1 : 0;
$this->connection->execute(SqlQuery::create($sql, $data));
$this->logger->debug('Worker registered successfully', LogContext::withData([
'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()
]));
throw $e;
}
}
/**
* Worker deregistrieren
*/
public function deregister(WorkerId $workerId): void
{
$this->logger->info('Deregistering worker', LogContext::withData([
'worker_id' => $workerId->toString()
]));
try {
$sql = "UPDATE " . self::TABLE_NAME . "
SET is_active = 0, 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()
]));
} catch (\Exception $e) {
$this->logger->error('Failed to deregister worker', LogContext::withData([
'worker_id' => $workerId->toString(),
'error' => $e->getMessage()
]));
throw $e;
}
}
/**
* Worker Heartbeat aktualisieren
*/
public function updateHeartbeat(
WorkerId $workerId,
Percentage $cpuUsage,
Byte $memoryUsage,
int $currentJobs
): void {
try {
$sql = "UPDATE " . self::TABLE_NAME . "
SET last_heartbeat = NOW(),
cpu_usage = :cpu_usage,
memory_usage_bytes = :memory_usage_bytes,
current_jobs = :current_jobs,
is_active = 1
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
]));
if ($affectedRows === 0) {
$this->logger->warning('Heartbeat update failed - worker not found', LogContext::withData([
'worker_id' => $workerId->toString()
]));
}
} catch (\Exception $e) {
$this->logger->error('Failed to update worker heartbeat', LogContext::withData([
'worker_id' => $workerId->toString(),
'error' => $e->getMessage()
]));
throw $e;
}
}
/**
* Worker by ID finden
*/
public function findById(WorkerId $workerId): ?Worker
{
try {
$sql = "SELECT * FROM " . self::TABLE_NAME . " WHERE id = :id";
$data = $this->connection->queryOne(SqlQuery::create($sql, ['id' => $workerId->toString()]));
if (!$data) {
return null;
}
return Worker::fromArray($data);
} catch (\Exception $e) {
$this->logger->error('Failed to find worker by ID', LogContext::withData([
'worker_id' => $workerId->toString(),
'error' => $e->getMessage()
]));
throw $e;
}
}
/**
* Alle aktiven Worker finden
*/
public function findActiveWorkers(): array
{
try {
$sql = "SELECT * FROM " . self::TABLE_NAME . "
WHERE is_active = 1
AND last_heartbeat > DATE_SUB(NOW(), INTERVAL 2 MINUTE)
ORDER BY hostname, process_id";
$result = $this->connection->query(SqlQuery::create($sql));
$workers = [];
foreach ($result->fetchAll() as $data) {
$workers[] = Worker::fromArray($data);
}
return $workers;
} catch (\Exception $e) {
$this->logger->error('Failed to find active workers', LogContext::withData([
'error' => $e->getMessage()
]));
throw $e;
}
}
/**
* Worker für bestimmte Queue finden
*/
public function findWorkersForQueue(QueueName $queueName): array
{
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))
ORDER BY current_jobs ASC, cpu_usage ASC";
$result = $this->connection->query(SqlQuery::create($sql, ['queue_name' => $queueName->toString()]));
$workers = [];
foreach ($result->fetchAll() as $data) {
$worker = Worker::fromArray($data);
if ($worker->handlesQueue($queueName) && $worker->isAvailableForJobs()) {
$workers[] = $worker;
}
}
return $workers;
} catch (\Exception $e) {
$this->logger->error('Failed to find workers for queue', LogContext::withData([
'queue_name' => $queueName->toString(),
'error' => $e->getMessage()
]));
throw $e;
}
}
/**
* Beste verfügbare Worker für Queue finden
*/
public function findBestWorkerForQueue(QueueName $queueName): ?Worker
{
$workers = $this->findWorkersForQueue($queueName);
if (empty($workers)) {
return null;
}
// Sortiere nach Load (niedrigste zuerst)
usort($workers, function (Worker $a, Worker $b) {
return $a->getLoadPercentage()->getValue() <=> $b->getLoadPercentage()->getValue();
});
return $workers[0];
}
/**
* Inaktive Worker cleanup
*/
public function cleanupInactiveWorkers(int $inactiveMinutes = 5): int
{
$this->logger->info('Starting cleanup of inactive workers', LogContext::withData([
'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))";
$count = $this->connection->execute(SqlQuery::create($sql, ['minutes' => $inactiveMinutes]));
$this->logger->info('Inactive workers cleanup completed', LogContext::withData([
'deactivated_count' => $count,
'inactive_minutes' => $inactiveMinutes
]));
return $count;
} catch (\Exception $e) {
$this->logger->error('Failed to cleanup inactive workers', LogContext::withData([
'error' => $e->getMessage()
]));
throw $e;
}
}
/**
* Worker Statistiken
*/
public function getWorkerStatistics(): array
{
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(max_jobs) as total_capacity,
SUM(current_jobs) as current_load,
AVG(cpu_usage) as avg_cpu_usage,
AVG(memory_usage_bytes) as avg_memory_usage,
COUNT(DISTINCT hostname) as unique_hosts
FROM " . self::TABLE_NAME;
$stats = $this->connection->queryOne(SqlQuery::create($sql));
// Queue-spezifische Statistiken
$queueStats = $this->getQueueStatistics();
return [
'total_workers' => (int) $stats['total_workers'],
'active_workers' => (int) $stats['active_workers'],
'healthy_workers' => (int) $stats['healthy_workers'],
'unique_hosts' => (int) $stats['unique_hosts'],
'total_capacity' => (int) $stats['total_capacity'],
'current_load' => (int) $stats['current_load'],
'capacity_utilization' => $stats['total_capacity'] > 0
? round(($stats['current_load'] / $stats['total_capacity']) * 100, 2)
: 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
];
} catch (\Exception $e) {
$this->logger->error('Failed to get worker statistics', LogContext::withData([
'error' => $e->getMessage()
]));
throw $e;
}
}
/**
* Queue-spezifische Statistiken
*/
private function getQueueStatistics(): array
{
try {
$sql = "SELECT queues FROM " . self::TABLE_NAME . " WHERE is_active = 1";
$result = $this->connection->query(SqlQuery::create($sql));
$queueCounts = [];
foreach ($result->fetchAll() as $row) {
$queues = json_decode($row['queues'], true);
foreach ($queues as $queue) {
$queueCounts[$queue] = ($queueCounts[$queue] ?? 0) + 1;
}
}
arsort($queueCounts);
return $queueCounts;
} catch (\Exception $e) {
$this->logger->warning('Failed to get queue statistics', LogContext::withData([
'error' => $e->getMessage()
]));
return [];
}
}
/**
* Worker mit bestimmter Capability finden
*/
public function findWorkersWithCapability(string $capability): array
{
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))
ORDER BY current_jobs ASC";
$result = $this->connection->query(SqlQuery::create($sql, ['capability' => $capability]));
$workers = [];
foreach ($result->fetchAll() as $data) {
$worker = Worker::fromArray($data);
if ($worker->hasCapability($capability) && $worker->isAvailableForJobs()) {
$workers[] = $worker;
}
}
return $workers;
} catch (\Exception $e) {
$this->logger->error('Failed to find workers with capability', LogContext::withData([
'capability' => $capability,
'error' => $e->getMessage()
]));
throw $e;
}
}
}