feat(Production): Complete production deployment infrastructure

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

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

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Framework\Redis;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
/**
* Exception thrown when Redis TTL (Time-To-Live) is invalid
*/
final class InvalidRedisTtlException extends FrameworkException
{
/**
* Create exception for negative TTL
*/
public static function negative(int $seconds): self
{
return self::create(
ErrorCode::VAL_INVALID_INPUT,
"Redis TTL cannot be negative: {$seconds} seconds"
)->withData([
'ttl_seconds' => $seconds,
'reason' => 'negative_value',
]);
}
/**
* Create exception for zero TTL
*/
public static function zero(): self
{
return self::create(
ErrorCode::VAL_INVALID_INPUT,
'Redis TTL cannot be zero. Use delete() to remove keys immediately.'
)->withData([
'ttl_seconds' => 0,
'reason' => 'zero_value',
'suggested_action' => 'Use RedisConnection::delete() instead',
]);
}
/**
* Create exception for expired DateTimeInterface
*/
public static function alreadyExpired(int $seconds): self
{
return self::create(
ErrorCode::VAL_INVALID_INPUT,
"Redis TTL refers to a time in the past: {$seconds} seconds ago"
)->withData([
'ttl_seconds' => $seconds,
'reason' => 'past_timestamp',
'suggested_action' => 'Provide a future timestamp',
]);
}
}

View File

@@ -20,6 +20,7 @@ final readonly class RedisConfig
public float $timeout = 1.0,
public float $readWriteTimeout = 1.0,
public string $scheme = 'tcp',
public ?string $keyPrefix = null,
public array $options = []
) {
}
@@ -35,7 +36,8 @@ final readonly class RedisConfig
password: $env->get(EnvKey::REDIS_PASSWORD, null),
database: 0,
timeout: 1.0,
readWriteTimeout:1.0
readWriteTimeout: 1.0,
keyPrefix: $env->get(EnvKey::REDIS_PREFIX, null)
);
}
@@ -52,6 +54,7 @@ final readonly class RedisConfig
timeout: $this->timeout,
readWriteTimeout: $this->readWriteTimeout,
scheme: $this->scheme,
keyPrefix: $this->keyPrefix,
options: $this->options
);
}

View File

@@ -118,6 +118,138 @@ final class RedisConnection implements RedisConnectionInterface
}
}
public function flush(): void
{
if (! $this->isConnected()) {
$this->reconnect();
}
$this->client->flushDB();
}
public function get(string $key): string|false
{
if (! $this->isConnected()) {
$this->reconnect();
}
return $this->client->get($key);
}
public function set(string $key, string $value, ?int $ttl = null): bool
{
if (! $this->isConnected()) {
$this->reconnect();
}
if ($ttl === null) {
return $this->client->set($key, $value);
}
return $this->client->setex($key, $ttl, $value);
}
public function delete(string ...$keys): int
{
if (empty($keys)) {
return 0;
}
if (! $this->isConnected()) {
$this->reconnect();
}
return $this->client->del($keys);
}
public function exists(string ...$keys): int
{
if (empty($keys)) {
return 0;
}
if (! $this->isConnected()) {
$this->reconnect();
}
return $this->client->exists(...$keys);
}
public function ttl(string $key): int
{
if (! $this->isConnected()) {
$this->reconnect();
}
return $this->client->ttl($key);
}
public function expire(string $key, int $seconds): bool
{
if (! $this->isConnected()) {
$this->reconnect();
}
return $this->client->expire($key, $seconds);
}
public function incr(string $key, int $by = 1): int
{
if (! $this->isConnected()) {
$this->reconnect();
}
return $by === 1
? $this->client->incr($key)
: $this->client->incrBy($key, $by);
}
public function decr(string $key, int $by = 1): int
{
if (! $this->isConnected()) {
$this->reconnect();
}
return $by === 1
? $this->client->decr($key)
: $this->client->decrBy($key, $by);
}
public function mGet(array $keys): array
{
if (empty($keys)) {
return [];
}
if (! $this->isConnected()) {
$this->reconnect();
}
return $this->client->mGet($keys);
}
public function mSet(array $keyValuePairs): bool
{
if (empty($keyValuePairs)) {
return true;
}
if (! $this->isConnected()) {
$this->reconnect();
}
return $this->client->mSet($keyValuePairs);
}
public function command(string $command, string|int ...$arguments): mixed
{
if (! $this->isConnected()) {
$this->reconnect();
}
return $this->client->rawCommand($command, ...$arguments);
}
/**
* Close the connection when the object is destroyed
*/

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Framework\Redis;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\Core\CacheErrorCode;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
@@ -16,7 +16,7 @@ final class RedisConnectionException extends FrameworkException
public function __construct(string $message, int $code = 0, ?\Throwable $previous = null, ?int $retryAfter = null)
{
$context = ExceptionContext::forOperation('Redis Connection');
$errorCode = ErrorCode::CACHE_CONNECTION_FAILED;
$errorCode = CacheErrorCode::CONNECTION_FAILED;
parent::__construct($message, $context, $code, $previous, $errorCode, $retryAfter);
}

View File

@@ -35,4 +35,81 @@ interface RedisConnectionInterface
* Reconnect if connection is lost
*/
public function reconnect(): void;
/**
* Flush all keys in current database
* WARNING: Destructive operation!
*/
public function flush(): void;
/**
* Get value for given key
* Returns false if key doesn't exist
*/
public function get(string $key): string|false;
/**
* Set key-value pair with optional expiration in seconds
*/
public function set(string $key, string $value, ?int $ttl = null): bool;
/**
* Delete one or more keys
* Returns number of keys deleted
*/
public function delete(string ...$keys): int;
/**
* Check if one or more keys exist
* Returns number of existing keys
*/
public function exists(string ...$keys): int;
/**
* Get TTL for key in seconds
* Returns -1 if key has no expiration
* Returns -2 if key doesn't exist
*/
public function ttl(string $key): int;
/**
* Set expiration on existing key in seconds
*/
public function expire(string $key, int $seconds): bool;
/**
* Increment numeric value by amount (default: 1)
*/
public function incr(string $key, int $by = 1): int;
/**
* Decrement numeric value by amount (default: 1)
*/
public function decr(string $key, int $by = 1): int;
/**
* Get multiple values at once
* Returns array of values (false for missing keys)
*
* @param array<string> $keys
* @return array<string|false>
*/
public function mGet(array $keys): array;
/**
* Set multiple key-value pairs at once
*
* @param array<string, string> $keyValuePairs
*/
public function mSet(array $keyValuePairs): bool;
/**
* Execute arbitrary Redis command
* Provides direct access to any Redis command
*
* @param string $command Redis command name (e.g., 'KEYS', 'SCAN', 'HGETALL')
* @param string|int ...$arguments Command arguments
* @return mixed Command result
*/
public function command(string $command, string|int ...$arguments): mixed;
}

View File

@@ -0,0 +1,270 @@
<?php
declare(strict_types=1);
namespace App\Framework\Redis;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Serializer\Json\JsonSerializer;
use DateTimeInterface;
/**
* High-level Key-Value Store API for Redis
* Provides simplified, type-safe operations with automatic JSON serialization
*/
final readonly class RedisKvStore
{
public function __construct(
private RedisConnectionInterface $connection,
private JsonSerializer $serializer,
private ?string $keyPrefix = null
) {
}
/**
* Get value for given key
* Returns null if key doesn't exist
* Automatically deserializes JSON values
*/
public function get(string $key): mixed
{
$fullKey = $this->applyPrefix($key);
$value = $this->connection->get($fullKey);
if ($value === false) {
return null;
}
return $this->deserialize($value);
}
/**
* Set key-value pair with optional expiration
*
* @param string $key The key to set
* @param mixed $value The value (will be JSON encoded if array/object)
* @param Duration|DateTimeInterface|null $expiration Optional expiration time
* @return bool True on success
* @throws InvalidRedisTtlException If TTL is invalid
*/
public function set(
string $key,
mixed $value,
Duration|DateTimeInterface|null $expiration = null
): bool {
$fullKey = $this->applyPrefix($key);
$serialized = $this->serialize($value);
if ($expiration === null) {
return $this->connection->set($fullKey, $serialized);
}
$ttl = $this->resolveTtl($expiration);
$this->validateTtl($ttl);
return $this->connection->set($fullKey, $serialized, $ttl);
}
/**
* Delete one or more keys
*
* @param string ...$keys Keys to delete
* @return int Number of keys deleted
*/
public function delete(string ...$keys): int
{
if (empty($keys)) {
return 0;
}
$fullKeys = array_map(fn ($k) => $this->applyPrefix($k), $keys);
return $this->connection->delete(...$fullKeys);
}
/**
* Check if key exists
*/
public function exists(string ...$keys): int
{
if (empty($keys)) {
return 0;
}
$fullKeys = array_map(fn ($k) => $this->applyPrefix($k), $keys);
return $this->connection->exists(...$fullKeys);
}
/**
* Get TTL for key in seconds
* Returns -1 if key has no expiration
* Returns -2 if key doesn't exist
*/
public function ttl(string $key): int
{
$fullKey = $this->applyPrefix($key);
return $this->connection->ttl($fullKey);
}
/**
* Set expiration on existing key
*
* @throws InvalidRedisTtlException If TTL is invalid
*/
public function expire(string $key, Duration|DateTimeInterface $expiration): bool
{
$fullKey = $this->applyPrefix($key);
$ttl = $this->resolveTtl($expiration);
$this->validateTtl($ttl);
return $this->connection->expire($fullKey, $ttl);
}
/**
* Increment numeric value by amount
*/
public function increment(string $key, int $by = 1): int
{
$fullKey = $this->applyPrefix($key);
return $this->connection->incr($fullKey, $by);
}
/**
* Decrement numeric value by amount
*/
public function decrement(string $key, int $by = 1): int
{
$fullKey = $this->applyPrefix($key);
return $this->connection->decr($fullKey, $by);
}
/**
* Get multiple values at once
*
* @param string ...$keys
* @return array<string, mixed> Array of key => value pairs (null for missing keys)
*/
public function getMultiple(string ...$keys): array
{
if (empty($keys)) {
return [];
}
$fullKeys = array_map(fn ($k) => $this->applyPrefix($k), $keys);
$values = $this->connection->mGet($fullKeys);
$result = [];
foreach ($keys as $index => $originalKey) {
$value = $values[$index] ?? false;
$result[$originalKey] = $value === false ? null : $this->deserialize($value);
}
return $result;
}
/**
* Set multiple key-value pairs at once
*
* @param array<string, mixed> $keyValuePairs
*/
public function setMultiple(array $keyValuePairs): bool
{
if (empty($keyValuePairs)) {
return true;
}
$prefixedPairs = [];
foreach ($keyValuePairs as $key => $value) {
$prefixedPairs[$this->applyPrefix($key)] = $this->serialize($value);
}
return $this->connection->mSet($prefixedPairs);
}
/**
* Flush all keys in current database
* WARNING: Destructive operation!
*/
public function flush(): void
{
$this->connection->flush();
}
/**
* Apply key prefix if configured
*/
private function applyPrefix(string $key): string
{
return $this->keyPrefix !== null
? $this->keyPrefix . $key
: $key;
}
/**
* Serialize value for storage using JsonSerializer
* Handles primitives and complex types
*/
private function serialize(mixed $value): string
{
// Primitives can be stored directly without JSON encoding
if (is_string($value)) {
return $value;
}
if (is_numeric($value) || is_bool($value)) {
return (string) $value;
}
// Use framework's JsonSerializer for arrays and objects
// It includes memory protection and proper error handling
return $this->serializer->serialize($value);
}
/**
* Deserialize value from storage using JsonSerializer
* Attempts JSON decode, falls back to string
*/
private function deserialize(string $value): mixed
{
// Try JSON decode using framework's serializer
// It handles large data and provides better error messages
try {
return $this->serializer->deserialize($value);
} catch (\App\Framework\Serializer\Exception\DeserializeException) {
// Not JSON or deserialization failed, return as-is
return $value;
}
}
/**
* Resolve TTL from Duration or DateTimeInterface
*/
private function resolveTtl(Duration|DateTimeInterface $expiration): int
{
return match (true) {
$expiration instanceof Duration => $expiration->toSeconds(),
$expiration instanceof DateTimeInterface => max(0, $expiration->getTimestamp() - time()),
default => throw new \InvalidArgumentException('Invalid expiration type')
};
}
/**
* Validate TTL is positive
*
* @throws InvalidRedisTtlException
*/
private function validateTtl(int $ttl): void
{
if ($ttl < 0) {
throw InvalidRedisTtlException::negative($ttl);
}
if ($ttl === 0) {
throw InvalidRedisTtlException::zero();
}
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Framework\Redis\ValueObjects;
enum SetOptions
{
case IF_NOT_EXISTS;
case IF_EXISTS;
case RETURN_OLD_VALUE;
}