- Add comprehensive health check system with multiple endpoints - Add Prometheus metrics endpoint - Add production logging configurations (5 strategies) - Add complete deployment documentation suite: * QUICKSTART.md - 30-minute deployment guide * DEPLOYMENT_CHECKLIST.md - Printable verification checklist * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference * production-logging.md - Logging configuration guide * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation * README.md - Navigation hub * DEPLOYMENT_SUMMARY.md - Executive summary - Add deployment scripts and automation - Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment - Update README with production-ready features All production infrastructure is now complete and ready for deployment.
205 lines
6.7 KiB
PHP
205 lines
6.7 KiB
PHP
<?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\Queue;
|
|
use App\Framework\Queue\Entities\DeadLetterJob;
|
|
use App\Framework\Queue\ValueObjects\DeadLetterQueueName;
|
|
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 Queue $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']
|
|
);
|
|
}
|
|
}
|