diff --git a/src/Console/CleanOrphanedKeysCommand.php b/src/Console/CleanOrphanedKeysCommand.php index a034e45..e566e5a 100644 --- a/src/Console/CleanOrphanedKeysCommand.php +++ b/src/Console/CleanOrphanedKeysCommand.php @@ -5,22 +5,92 @@ use Illuminate\Console\Command; use Illuminate\Support\Facades\Log; use Padosoft\SuperCache\RedisConnector; +use Padosoft\SuperCache\Service\GetClusterNodesService; +use Padosoft\SuperCache\SuperCacheManager; class CleanOrphanedKeysCommand extends Command { - protected $signature = 'supercache:clean-orphans {--connection_name= : (opzionale) nome della connessione redis}' ; + protected $signature = 'supercache:clean-orphans {--connection_name= : (opzionale) nome della connessione redis}'; protected $description = 'Clean orphaned cache keys'; protected RedisConnector $redis; protected int $numShards; + protected SuperCacheManager $superCache; + protected GetClusterNodesService $getClusterNodesService; - public function __construct(RedisConnector $redis) + public function __construct(RedisConnector $redis, SuperCacheManager $superCache, GetClusterNodesService $getClusterNodesService) { parent::__construct(); $this->redis = $redis; $this->numShards = (int) config('supercache.num_shards'); // Numero di shard configurato + $this->superCache = $superCache; + $this->getClusterNodesService = $getClusterNodesService; } public function handle(): void + { + if (config('database.redis.clusters.' . ($this->option('connection_name') ?? 'default')) !== null) { + $this->handleOnCluster(); + } else { + $this->handleOnStandalone(); + } + } + + public function handleOnCluster(): void + { + // Tenta di acquisire un lock globale + $lockAcquired = $this->superCache->lock('clean_orphans:lock', $this->option('connection_name'), 300); + if (!$lockAcquired) { + return; + } + // Purtroppo lo scan non funziona sul cluster per cui va eseguito su ogni nodo (master) + $array_nodi = $this->getClusterNodesService->getClusterNodes($this->option('connection_name')); + + foreach ($array_nodi as $node) { + [$host, $port] = explode(':', $node); + + // Per ogni shard vado a cercare i bussolotti (SET) dei tags che contengono le chiavi + for ($i = 0; $i < $this->numShards; $i++) { + // Inserisco nel pattern supercache: così sonop sicura di trovare solo i SET che riguardano il contesto della supercache + $shard_key = '*' . config('supercache.prefix') . 'tag:*:shard:' . $i; + // Creo una connessione persistente perchè considerando la durata dell'elaborazione si evita che dopo 30 secondi muoia tutto! + $connection = $this->redis->getNativeRedisConnection($this->option('connection_name'), $host, $port); + + $cursor = null; + do { + $response = $connection['connection']->scan($cursor, $shard_key); + + if ($response === false) { + //Nessuna chiave trovata ... + break; + } + + foreach ($response as $key) { + // Ho trovato un bussolotto che matcha, vado a recuperare i membri del SET + $members = $connection['connection']->sMembers($key); + foreach ($members as $member) { + // Controllo che la chiave sia morta, ma ATTENZIONE non posso usare la connessione che ho già perchè sono su un singolo nodo, mentre nel bussolotto potrebbero esserci chiavi in sharding diversi + if ($this->redis->getRedisConnection($this->option('connection_name'))->exists($member)) { + // La chiave è viva! vado avanti + continue; + } + // Altrimenti rimuovo i cadaveri dal bussolotto e dai tags + // Qui posso usare la connessione che già ho in quanto sto modificando il bussolotto che sicuramente è nello shard corretto del nodo + $connection['connection']->srem($key, $member); + // Rimuovo anche i tag, che però potrebbero essere in un altro nodo quindi uso una nuova connessione + $this->redis->getRedisConnection($this->option('connection_name'))->del($member . ':tags'); + } + } + } while ($cursor !== 0); // Continua fino a completamento + + // Chiudo la connessione + $connection['connection']->close(); + } + } + // Rilascio il lock globale + $this->superCache->unLock('clean_orphans:lock', $this->option('connection_name')); + } + + public function handleOnStandalone(): void { // Carica il prefisso di default per le chiavi $prefix = config('supercache.prefix'); @@ -33,7 +103,6 @@ public function handle(): void local success, result = pcall(function() local database_prefix = string.gsub(KEYS[5], "temp", "") local shard_prefix = KEYS[1] - local tags_prefix = KEYS[6] local num_shards = string.gsub(KEYS[2], database_prefix, "") local lock_key = KEYS[3] local lock_ttl = string.gsub(KEYS[4], database_prefix, "") @@ -41,8 +110,10 @@ public function handle(): void -- Tenta di acquisire un lock globale if redis.call("SET", lock_key, "1", "NX", "EX", lock_ttl) then -- Scansiona tutti gli shard + -- redis.log(redis.LOG_NOTICE, "Scansiona tutti gli shard: " .. num_shards) for i = 0, num_shards - 1 do local shard_key = shard_prefix .. ":" .. i + -- redis.log(redis.LOG_NOTICE, "shard_key: " .. shard_key) -- Ottieni tutte le chiavi dallo shard local cursor = "0" local keys = {} @@ -55,15 +126,17 @@ public function handle(): void until cursor == "0" -- Cerco tutte le chiavi associate a questa chiave for _, key in ipairs(keys) do + -- redis.log(redis.LOG_NOTICE, "CHIAVE: " .. key) local members = redis.call("SMEMBERS", key) for _, member in ipairs(members) do + redis.log(redis.LOG_NOTICE, "member: " .. database_prefix .. member) if redis.call("EXISTS", database_prefix .. member) == 0 then -- La chiave è orfana, rimuovila dallo shard redis.call("SREM", key, member) + -- redis.log(redis.LOG_NOTICE, "Rimossa chiave orfana key: " .. key .. " member: " .. member) -- Devo rimuovere anche la chiave con i tags - -- gescat_laravel_database_supercache:tags:supercache:trilly - local tagsKey = tags_prefix .. member - redis.log(redis.LOG_WARNING, "tagsKey: " .. tagsKey) + local tagsKey = database_prefix .. member .. ":tags" + -- redis.log(redis.LOG_NOTICE, "Rimuovo la chiave con tagsKey: " .. tagsKey) redis.call("DEL", tagsKey) end end @@ -83,26 +156,24 @@ public function handle(): void try { // Parametri dello script $shardPrefix = $prefix . 'tag:*:shard'; - $tagPrefix = $prefix . 'tags:'; + $tagPrefix = $prefix; $lockKey = $prefix . 'clean_orphans:lock'; $lockTTL = 300; // Timeout lock di 300 secondi // Esegui lo script Lua $return = $this->redis->getRedisConnection($this->option('connection_name'))->eval( $script, - 6, // Numero di parametri passati a Lua come KEYS + 5, // Numero di parametri passati a Lua come KEYS $shardPrefix, $this->numShards, $lockKey, $lockTTL, 'temp', - $tagPrefix, ); if ($return !== 'OK') { Log::error('Errore durante l\'esecuzione dello script Lua: ' . $return); } - } catch (\Exception $e) { Log::error('Errore durante l\'esecuzione dello script Lua: ' . $e->getMessage()); } diff --git a/src/Console/GetClusterNodesCommand.php b/src/Console/GetClusterNodesCommand.php new file mode 100644 index 0000000..f5328bb --- /dev/null +++ b/src/Console/GetClusterNodesCommand.php @@ -0,0 +1,39 @@ +redis = $redis; + $this->service = $service; + } + + /** + * @throws \JsonException + */ + public function handle(): void + { + try { + $array_nodi = $this->service->getClusterNodes($this->option('connection_name')); + $this->output->writeln(json_encode($array_nodi, JSON_THROW_ON_ERROR)); + } catch (\Throwable $e) { + Log::error('Errore durante il recupero dei nodi del cluster ' . $e->getMessage()); + $this->output->writeln(json_encode([], JSON_THROW_ON_ERROR)); + } + } +} diff --git a/src/Console/ListenerCommand.php b/src/Console/ListenerCommand.php index 5650d4f..b1fb1d5 100644 --- a/src/Console/ListenerCommand.php +++ b/src/Console/ListenerCommand.php @@ -3,14 +3,19 @@ namespace Padosoft\SuperCache\Console; use Illuminate\Console\Command; +use Illuminate\Support\Str; use Padosoft\SuperCache\RedisConnector; use Illuminate\Support\Facades\Log; +use Padosoft\SuperCache\SuperCacheManager; class ListenerCommand extends Command { protected $signature = 'supercache:listener {--connection_name= : (opzionale) nome della connessione redis } + {--namespace_id= : (opzionale) id del namespace da usare per suddividere i processi e da impostare se supercache.use_namespace = true } {--checkEvent= : (opzionale) se 1 si esegue controllo su attivazione evento expired di Redis } + {--host= : (opzionale) host del nodo del cluster (da impostare solo in caso di Redis in cluster) } + {--port= : (opzionale) porta del nodo del cluster (da impostare solo in caso di Redis in cluster) } '; protected $description = 'Listener per eventi di scadenza chiavi Redis'; protected RedisConnector $redis; @@ -18,8 +23,9 @@ class ListenerCommand extends Command protected int $batchSizeThreshold; // Numero di chiavi per batch protected int $timeThreshold; // Tempo massimo prima di processare il batch protected bool $useNamespace; + protected SuperCacheManager $superCache; - public function __construct(RedisConnector $redis) + public function __construct(RedisConnector $redis, SuperCacheManager $superCache) { parent::__construct(); $this->redis = $redis; @@ -27,16 +33,48 @@ public function __construct(RedisConnector $redis) $this->batchSizeThreshold = config('supercache.batch_size'); $this->timeThreshold = config('supercache.time_threshold'); // secondi $this->useNamespace = (bool) config('supercache.use_namespace', false); + $this->superCache = $superCache; } /** - * Aggiunge la chiave scaduta al batch corrente. + * Verifica se Redis è configurato per generare notifiche di scadenza. */ + protected function checkRedisNotifications(): bool + { + $checkEvent = $this->option('checkEvent'); + if ($checkEvent === null) { + return true; + } + if ((int) $checkEvent === 0) { + return true; + } + $config = $this->redis->getRedisConnection($this->option('connection_name'))->config('GET', 'notify-keyspace-events'); + + return str_contains($config['notify-keyspace-events'], 'Ex') || str_contains($config['notify-keyspace-events'], 'xE'); + } + protected function onExpireEvent(string $key): void { + $debug = 'EXPIRED $key: ' . $key . PHP_EOL . + 'Host: ' . $this->option('host') . PHP_EOL . + 'Port: ' . $this->option('port') . PHP_EOL . + 'Connection Name: ' . $this->option('connection_name') . PHP_EOL . + 'Namespace ID: ' . $this->option('namespace_id') . PHP_EOL; + // Filtro le chiavi di competenza di questo listener, ovvero quelle che iniziano con gescat_laravel_database_supercache: e che eventualemnte terminano con ns se c'è il namespace attivo // Attenzione la chiave arriva completa con il prefisso da conf redis.oprion.prefix + il prefisso della supercache // del tipo 'gescat_laravel_database_supercache:' + $prefix = config('database.redis.options')['prefix'] . config('supercache.prefix'); + $cleanedKey = str_replace(['{', '}'], '', $key); + if (!Str::startsWith($cleanedKey, $prefix)) { + return; + } + + if ($this->useNamespace && $this->option('namespace_id') !== null && !Str::endsWith($cleanedKey, 'ns' . $this->option('namespace_id'))) { + return; + } + $original_key = str_replace(config('database.redis.options')['prefix'], '', $key); + //$original_key = $this->superCache->getOriginalKey($key); $hash_key = crc32($original_key); // questo hash mi serve poi nello script LUA in quanto Redis non ha nativa la funzione crc32, ma solo il crc16 che però non è nativo in php $this->batch[] = $original_key . '|' . $hash_key; // faccio la concatenzazione con il '|' come separatore in quanto Lua non supporta array multidimensionali } @@ -62,42 +100,66 @@ protected function shouldProcessBatchByTime(): bool return false; } + protected function processBatchOnCluster(): void + { + foreach ($this->batch as $key) { + $explodeKey = explode('|', $key); + $cleanedKey = str_replace(['{', '}'], '', $explodeKey[0]); + $this->superCache->forget($cleanedKey, $this->option('connection_name'), true, true, true); + } + + $this->batch = []; + } + /** * Processa le chiavi accumulate in batch tramite uno script Lua. */ - protected function processBatch(): void + protected function processBatchOnStandalone(): void { + $debug = 'Processo batch: ' . implode(', ', $this->batch) . PHP_EOL . + 'Host: ' . $this->option('host') . PHP_EOL . + 'Port: ' . $this->option('port') . PHP_EOL; + $luaScript = <<<'LUA' local success, result = pcall(function() local keys = ARGV - local prefix = KEYS[1] - local database_prefix = string.gsub(KEYS[3], "temp", "") - local shard_count = string.gsub(KEYS[2], database_prefix, "") + local prefix = ARGV[1] + local database_prefix = ARGV[3] + local shard_count = ARGV[2] + -- redis.log(redis.LOG_NOTICE, 'prefix: ' .. prefix); + -- redis.log(redis.LOG_NOTICE, 'database_prefix: ' .. database_prefix); + -- redis.log(redis.LOG_NOTICE, 'shard_count: ' .. shard_count); for i, key in ipairs(keys) do - local row = {} - for value in string.gmatch(key, "[^|]+") do - table.insert(row, value) - end - local fullKey = database_prefix .. row[1] - -- redis.log(redis.LOG_NOTICE, 'Chiave Redis Expired: ' .. fullKey) - -- Controlla se la chiave è effettivamente scaduta - if redis.call('EXISTS', fullKey) == 0 then - local tagsKey = prefix .. 'tags:' .. row[1] - local tags = redis.call("SMEMBERS", tagsKey) - -- redis.log(redis.LOG_NOTICE, 'Tags associati: ' .. table.concat(tags, ", ")); - -- Rimuove la chiave dai set di tag associati - for j, tag in ipairs(tags) do - local shardIndex = tonumber(row[2]) % tonumber(shard_count) - local shardKey = prefix .. "tag:" .. tag .. ":shard:" .. shardIndex - redis.call("SREM", shardKey, row[1]) - -- redis.log(redis.LOG_NOTICE, 'Rimossa chiave tag: ' .. shardKey); + -- salto le prime 3 chiavi che ho usato come settings + if i > 3 then + local row = {} + for value in string.gmatch(key, "[^|]+") do + table.insert(row, value) + end + local fullKey = database_prefix .. row[1] + -- redis.log(redis.LOG_NOTICE, 'Chiave Redis Expired: ' .. fullKey) + -- Controlla se la chiave è effettivamente scaduta + if redis.call('EXISTS', fullKey) == 0 then + -- local tagsKey = prefix .. 'tags:' .. row[1] + local tagsKey = fullKey .. ':tags' + -- redis.log(redis.LOG_NOTICE, 'Tag: ' .. tagsKey); + local tags = redis.call("SMEMBERS", tagsKey) + -- redis.log(redis.LOG_NOTICE, 'Tags associati: ' .. table.concat(tags, ", ")); + -- Rimuove la chiave dai set di tag associati + for j, tag in ipairs(tags) do + local shardIndex = tonumber(row[2]) % tonumber(shard_count) + local shardKey = database_prefix .. prefix .. "tag:" .. tag .. ":shard:" .. shardIndex + -- redis.log(redis.LOG_NOTICE, 'Rimuovo la chiave dallo shard: ' .. row[1]); + redis.call("SREM", shardKey, row[1]) + -- redis.log(redis.LOG_NOTICE, 'Rimossa chiave tag: ' .. shardKey); + end + -- Rimuove l'associazione della chiave con i tag + redis.call("DEL", tagsKey) + -- redis.log(redis.LOG_NOTICE, 'Rimossa chiave tags: ' .. tagsKey); + else + redis.log(redis.LOG_WARNING, 'la chiave ' .. fullKey .. ' è ancora attiva'); end - -- Rimuove l'associazione della chiave con i tag - redis.call("DEL", tagsKey) - -- redis.log(redis.LOG_NOTICE, 'Rimossa chiave tags: ' .. tagsKey); - else - redis.log(redis.LOG_NOTICE, 'la chiave ' .. fullKey .. ' è ancora attiva'); end end end) @@ -108,66 +170,56 @@ protected function processBatch(): void return "OK" LUA; - $connection = $this->redis->getRedisConnection($this->option('connection_name')); + try { // Esegue lo script Lua passando le chiavi in batch - $return = $connection->eval( - $luaScript, - // KEYS: prefix e numero di shard - 3, - config('supercache.prefix'), - config('supercache.num_shards'), - 'temp', - // ARGV: le chiavi del batch - ...$this->batch - ); + $connection = $this->redis->getNativeRedisConnection($this->option('connection_name'), $this->option('host'), $this->option('port')); + + $return = $connection['connection']->eval($luaScript, [config('supercache.prefix'), config('supercache.num_shards'), config('database.redis.options')['prefix'], ...$this->batch], 0); if ($return !== 'OK') { Log::error('Errore durante l\'esecuzione dello script Lua: ' . $return); } // Pulisce il batch dopo il successo $this->batch = []; - } catch (\Exception $e) { + // Essendo una connessione nativa va chiusa + $connection['connection']->close(); + } catch (\Throwable $e) { Log::error('Errore durante l\'esecuzione dello script Lua: ' . $e->getMessage()); } } - /** - * Verifica se Redis è configurato per generare notifiche di scadenza. - */ - protected function checkRedisNotifications(): bool - { - $checkEvent = $this->option('checkEvent'); - if ($checkEvent === null) { - return true; - } - if ((int) $checkEvent === 0) { - return true; - } - $config = $this->redis->getRedisConnection($this->option('connection_name'))->config('GET', 'notify-keyspace-events'); - - return str_contains($config['notify-keyspace-events'], 'Ex') || str_contains($config['notify-keyspace-events'], 'xE'); - } - - public function handle() + public function handle(): void { if (!$this->checkRedisNotifications()) { $this->error('Le notifiche di scadenza di Redis non sono abilitate. Abilitale per usare il listener.'); - - return; } - $async_connection = $this->redis->getNativeRedisConnection($this->option('connection_name')); - - // Pattern per ascoltare solo gli eventi expired - $pattern = '__keyevent@' . $async_connection['database'] . '__:expired'; - // Sottoscrizione agli eventi di scadenza - $async_connection['connection']->psubscribe([$pattern], function ($redis, $channel, $message, $key) { - $this->onExpireEvent($key); - - // Verifica se è necessario processare il batch - if (count($this->batch) >= $this->batchSizeThreshold || $this->shouldProcessBatchByTime()) { - $this->processBatch(); - } - }); + try { + $async_connection = $this->redis->getNativeRedisConnection($this->option('connection_name'), $this->option('host'), $this->option('port')); + $pattern = '__keyevent@' . $async_connection['database'] . '__:expired'; + // La psubscribe è BLOCCANTE, il command resta attivo finchè non cade la connessione + $async_connection['connection']->psubscribe([$pattern], function ($redis, $channel, $message, $key) { + $this->onExpireEvent($key); + + // Verifica se è necessario processare il batch + // In caso di un cluster Redis il primo che arriva al count impostato fa scattare la pulizia. + // Possono andare in conflitto? No, perchè ogni nodo ha i suoi eventi, per cui non può esserci lo stesso evento expire su più nodi + if (count($this->batch) >= $this->batchSizeThreshold || $this->shouldProcessBatchByTime()) { + if (config('database.redis.clusters.' . ($this->option('connection_name') ?? 'default')) !== null) { + $this->processBatchOnCluster(); + } else { + $this->processBatchOnStandalone(); + } + } + }); + } catch (\Throwable $e) { + $error = 'Errore durante la sottoscrizione agli eventi EXPIRED:' . PHP_EOL . + 'Host: ' . $this->option('host') . PHP_EOL . + 'Port: ' . $this->option('port') . PHP_EOL . + 'Connection Name: ' . $this->option('connection_name') . PHP_EOL . + 'Namespace ID: ' . $this->option('namespace_id') . PHP_EOL . + 'Error: ' . $e->getMessage(); + Log::error($error); + } } } diff --git a/src/RedisConnector.php b/src/RedisConnector.php index 99a6449..1f36212 100644 --- a/src/RedisConnector.php +++ b/src/RedisConnector.php @@ -6,61 +6,55 @@ class RedisConnector { - protected $connection; - - public function __construct() + public function getRedisConnection(?string $connection_name = null): \Illuminate\Redis\Connections\Connection { - $this->connection = config('supercache.connection'); - } - - public function getRedisConnection(?string $connection_name = null) - { - return Redis::connection($connection_name ?? $this->connection); + return Redis::connection($connection_name ?? config('supercache.connection')); } /** - * Establishes and returns a native Redis connection. - * Questo metodo ritorna una cionnessione redis senza utilizzare il wrapper di Laravel. - * La connessione nativa è necessaria per la sottoscrizione agli eventi (Es. psubscribe([...)) in quanto Laravel gestisce solo connessioni sync, - * mentre per le sottoscrizioni è necessaria una connessione async + * Establishes a native Redis connection based on the provided connection name and optional host and port. * - * @param string|null $connection_name Optional. The name of the Redis connection to establish. If not provided, the default connection is used. - * @return array The Redis connection instance and database. + * @param string|null $connection_name The name of the Redis connection configuration to use. Defaults to 'default'. + * @param string|null $host The hostname to use for the connection. If not provided, it will be retrieved from the configuration. + * @param string|null $port The port number to use for the connection. If not provided, it will be retrieved from the configuration. + * @return array|null Returns an associative array with the Redis connection instance and the selected database, or null on failure. + * The array contains: + * - 'connection': The instance of the native Redis connection. + * - 'database': The selected database index. */ - public function getNativeRedisConnection(?string $connection_name = null): array + public function getNativeRedisConnection(?string $connection_name = null, ?string $host = null, ?string $port = null): ?array { - $isCluster = config('database.redis.clusters.' . ($connection_name ?? 'default')) !== null ? true : false; - if ($isCluster) { - $config = config('database.redis.clusters.' . ($connection_name ?? 'default')); - $url = $config[0]['host'] . ':' . $config[0]['port']; - $nativeRedisCluster = new \RedisCluster( - null, // Nome del cluster (può essere null) - [$url], // Nodo master - -1, // Timeout connessione - -1, // Timeout lettura - true, // Persistente - ($config[0]['password'] !== null && $config[0]['password'] !== '' ? $config[0]['password'] : null) // Password se necessaria - ); - - // Nel cluster c'è sempre un unico databse - return ['connection' => $nativeRedisCluster, 'database' => 0]; - } // Crea una nuova connessione nativa Redis + $config = config('database.redis.clusters.' . ($connection_name ?? config('supercache.connection'))); + if ($config !== null && ($host === null || $port === null)) { + // Sono nel caso del cluster, host e port sono obbligatori in quanto le connessioni vanno stabilite per ogni nodo del cluster + throw new \RuntimeException('Host e port non possono essere null per le connessioni in cluster'); + } + if ($config === null) { + // Sono nel caso di una connessione standalone + $config = config('database.redis.' . ($connection_name ?? config('supercache.connection'))); + } $nativeRedis = new \Redis(); - // Connessione al server Redis (no cluster) - $config = config('database.redis.' . ($connection_name ?? 'default')); - $nativeRedis->connect($config['host'], $config['port']); + if ($host !== null && $port !== null) { + // Se ho host e port (caso del cluster) uso questi + //$nativeRedis->connect($host, $port); + $nativeRedis->connect($host, $port, 0, null, 0, -1); + } else { + // Altrimenti utilizzo host e port dalla configurazione della connessione standalone + $nativeRedis->connect($config['host'], $config['port'], 0, null, 0, -1); + } // Autenticazione con username e password (se configurati) - if ($config['username'] !== null && $config['password'] !== null && $config['password'] !== '' && $config['username'] !== '') { + if (array_key_exists('username', $config) && $config['username'] !== '' && array_key_exists('password', $config) && $config['password'] !== '') { $nativeRedis->auth([$config['username'], $config['password']]); - } elseif ($config['password'] !== null && $config['password'] !== '') { + } elseif (array_key_exists('password', $config) && $config['password'] !== '') { $nativeRedis->auth($config['password']); // Per versioni Redis senza ACL } - // Seleziono il database corretto - $database = ($config['database'] !== null && $config['database'] !== '') ? (int) $config['database'] : 0; + // Seleziono il database corretto (Per il cluster è sempre 0) + $database = (array_key_exists('database', $config) && $config['database'] !== '') ? (int) $config['database'] : 0; $nativeRedis->select($database); + //$nativeRedis->setOption(\Redis::OPT_READ_TIMEOUT, -1); return ['connection' => $nativeRedis, 'database' => $database]; } @@ -68,6 +62,6 @@ public function getNativeRedisConnection(?string $connection_name = null): array // Metodo per ottimizzare le operazioni Redis con pipeline public function pipeline($callback, ?string $connection_name = null) { - return $this->getRedisConnection($connection_name)->pipeline($callback); + return $this->getRedisConnection(($connection_name ?? config('supercache.connection')))->pipeline($callback); } } diff --git a/src/Service/GetClusterNodesService.php b/src/Service/GetClusterNodesService.php new file mode 100644 index 0000000..ac7f42a --- /dev/null +++ b/src/Service/GetClusterNodesService.php @@ -0,0 +1,71 @@ +redis = $redis; + } + + /** + * Recupera l'elenco dei nodi in un cluster Redis. + * + * Questo metodo si connette al cluster Redis ed estrae l'elenco dei nodi, concentrandosi principalmente + * sui nodi master. Include anche una logica opzionale per raccogliere tutti i nodi, inclusi gli slave, se necessario. + * L'elenco risultante contiene informazioni uniche host:port per ciascun nodo. + * + * @param string|null $connection Stringa opzionale per identificare la connessione. Se null, verrà utilizzata la connessione predefinita. + * @return array Un array di stringhe che rappresenta i host:port di ciascun nodo all'interno del cluster. + */ + public function getClusterNodes(?string $connection = null): array + { + try { + $array_nodi = []; // Alla fine in questo array dovrei avere le informazioni host:port di tutti i nodi che compongono il cluster + $redisConnection = $this->redis->getRedisConnection($connection); + // 1) Recupero i nodi master dalla connessione, in una configurazione standard in genere ci sono 3 master e "n" slave + $masters = $redisConnection->_masters(); + // 2) Per ogni nodo master mi faccio dare i nodi a lui collegati + foreach ($masters as $master) { + $array_nodi[] = $master[0] . ':' . $master[1]; + + // Dovrebbe essere sufficente avere i nodi master in quanto i nodi slave sono solo delle repliche e non inviano eventi, + // inoltre le loro chiavi sono repliche di chiavi presenti in shard nei master + // Se poi ci fossero dei problemi o si vede che perdiamo rroba, il copdoce sotto serve per avere tutti i nodi del cluster. + // Attenzione che il comando 'CLUSTER NODES' su AWS non funziona, ma va abilitato + + /* + $nodeInfo = $redisConnection->rawCommand($master[0] . ':' . $master[1], 'CLUSTER', 'NODES'); + // Questo comando restituisce una string che rappresenta in formato CSV le info sui nodi + // Ogni riga è un nodo, ogni riga ha poi "n" colonne, a noi interessa la seconda colonna con host:port@port + $splittedRow = explode(PHP_EOL, $nodeInfo); + + foreach ($splittedRow as $row) { + $splittedColumn = explode(' ', $row); + if (array_key_exists(1, $splittedColumn)) { + $infoNodo = $splittedColumn[1]; + $splittedInfoNodo = explode('@', $infoNodo); + $hostAndPort = $splittedInfoNodo[0]; + $array_nodi[] = $hostAndPort; + } + } + */ + } + + // 3) Tolgo i doppioni, infatti per la ridondanza ogni master ha in comune con glia ltri alcuni nodi + //$array_nodi = array_unique($array_nodi); + // Stampa l'array come JSON per il parsing lato script bash + return $array_nodi; + } catch (\Throwable $e) { + Log::error('Errore durante il recupero dei nodi del cluster ' . $e->getMessage()); + + return []; + } + } +} diff --git a/src/SuperCacheManager.php b/src/SuperCacheManager.php index bb930d0..d259c21 100644 --- a/src/SuperCacheManager.php +++ b/src/SuperCacheManager.php @@ -12,6 +12,12 @@ class SuperCacheManager protected int $numShards; public string $prefix; public bool $useNamespace; + + /** + * Questa proprietà viene settata dinamicamente dove serve in base al nome della connessione + * + * @deprecated + */ public bool $isCluster = false; public function __construct(RedisConnector $redis) @@ -53,17 +59,18 @@ public function put(string $key, mixed $value, ?int $ttl = null, ?string $connec { // Calcola la chiave con o senza namespace in base alla configurazione $finalKey = $this->getFinalKey($key); - $this->redis->getRedisConnection($connection_name)->set($finalKey, $this->serializeForRedis($value)); - if ($ttl !== null) { - $this->redis->getRedisConnection($connection_name)->expire($finalKey, $ttl); + $this->redis->getRedisConnection($connection_name)->setEx($finalKey, $ttl, $this->serializeForRedis($value)); + + return; } + $this->redis->getRedisConnection($connection_name)->set($finalKey, $this->serializeForRedis($value)); } - public function getTTLKey(string $key, ?string $connection_name = null): int + public function getTTLKey(string $key, ?string $connection_name = null, bool $isWithTags = false): int { // Calcola la chiave con o senza namespace in base alla configurazione - $finalKey = $this->getFinalKey($key); + $finalKey = $this->getFinalKey($key, $isWithTags); return $this->redis->getRedisConnection($connection_name)->ttl($finalKey); } @@ -74,14 +81,16 @@ public function getTTLKey(string $key, ?string $connection_name = null): int */ public function putWithTags(string $key, mixed $value, array $tags, ?int $ttl = null, ?string $connection_name = null): void { - $finalKey = $this->getFinalKey($key); + $finalKey = $this->getFinalKey($key, true); // Usa pipeline solo se non è un cluster - if (!$this->isCluster) { + $isCluster = config('database.redis.clusters.' . ($connection_name ?? 'default')) !== null; + if (!$isCluster) { $this->redis->pipeline(function ($pipe) use ($finalKey, $value, $tags, $ttl) { - $pipe->set($finalKey, $this->serializeForRedis($value)); - + // Qui devo mettere le {} perchè così mi assicuro che la chiave e i suoi tags stiano nello stesso has if ($ttl !== null) { - $pipe->expire($finalKey, $ttl); + $pipe->setEx($finalKey, $ttl, $this->serializeForRedis($value)); + } else { + $pipe->set($finalKey, $this->serializeForRedis($value)); } foreach ($tags as $tag) { @@ -92,9 +101,10 @@ public function putWithTags(string $key, mixed $value, array $tags, ?int $ttl = $pipe->sadd($this->prefix . 'tags:' . $finalKey, ...$tags); }, $connection_name); } else { - $this->redis->getRedisConnection($connection_name)->set($finalKey, $this->serializeForRedis($value)); if ($ttl !== null) { - $this->redis->getRedisConnection($connection_name)->expire($finalKey, $ttl); + $this->redis->getRedisConnection($connection_name)->setEx($finalKey, $ttl, $this->serializeForRedis($value)); + } else { + $result = $this->redis->getRedisConnection($connection_name)->set($finalKey, $this->serializeForRedis($value)); } foreach ($tags as $tag) { @@ -122,7 +132,7 @@ public function putWithTags(string $key, mixed $value, array $tags, ?int $ttl = */ public function rememberWithTags($key, array $tags, \Closure $callback, ?int $ttl = null, ?string $connection_name = null) { - $finalKey = $this->getFinalKey($key); + $finalKey = $this->getFinalKey($key, true); $value = $this->get($finalKey, $connection_name); // Se esiste già, ok la ritorno @@ -132,7 +142,7 @@ public function rememberWithTags($key, array $tags, \Closure $callback, ?int $tt $value = $callback(); - $this->putWithTags($finalKey, $value, $tags, $ttl, $connection_name); + $this->putWithTags($key, $value, $tags, $ttl, $connection_name); return $value; } @@ -141,9 +151,10 @@ public function rememberWithTags($key, array $tags, \Closure $callback, ?int $tt * Recupera un valore dalla cache. * Il valore della chiave sarà deserializzato tranne nel caso di valori numerici */ - public function get(string $key, ?string $connection_name = null): mixed + public function get(string $key, ?string $connection_name = null, bool $isWithTags = false): mixed { - $finalKey = $this->getFinalKey($key); + $finalKey = $this->getFinalKey($key, $isWithTags); + $value = $this->redis->getRedisConnection($connection_name)->get($finalKey); return $value ? $this->unserializeForRedis($value) : null; @@ -152,18 +163,17 @@ public function get(string $key, ?string $connection_name = null): mixed /** * Rimuove una chiave dalla cache e dai suoi set di tag. */ - public function forget(string $key, ?string $connection_name = null, ?bool $isFinalKey = false): void + public function forget(string $key, ?string $connection_name = null, bool $isFinalKey = false, bool $isWithTags = false, bool $onlyTags = false): void { if ($isFinalKey) { $finalKey = $key; } else { - $finalKey = $this->getFinalKey($key); + $finalKey = $this->getFinalKey($key, $isWithTags); } - // Recupera i tag associati alla chiave $tags = $this->redis->getRedisConnection($connection_name)->smembers($this->prefix . 'tags:' . $finalKey); - - if (!$this->isCluster) { + $isCluster = config('database.redis.clusters.' . ($connection_name ?? 'default')) !== null; + if (!$isCluster) { $this->redis->pipeline(function ($pipe) use ($tags, $finalKey) { foreach ($tags as $tag) { $shard = $this->getShardNameForTag($tag, $finalKey); @@ -192,7 +202,7 @@ public function flushByTags(array $tags, ?string $connection_name = null): void $keys = $this->getKeysOfTag($tag, $connection_name); foreach ($keys as $key) { // Con questo cancello sia i tag che le chiavi - $this->forget($key, $connection_name, true); + $this->forget($key, $connection_name, true, true); } } } @@ -202,7 +212,7 @@ public function flushByTags(array $tags, ?string $connection_name = null): void */ public function getTagsOfKey(string $key, ?string $connection_name = null): array { - $finalKey = $this->getFinalKey($key); + $finalKey = $this->getFinalKey($key, true); return $this->redis->getRedisConnection($connection_name)->smembers($this->prefix . 'tags:' . $finalKey); } @@ -210,8 +220,11 @@ public function getTagsOfKey(string $key, ?string $connection_name = null): arra /** * Recupera tutte le chiavi associate a un tag. */ - public function getKeysOfTag(string $tag, ?string $connection_name = null): array + public function getKeysOfTag(string $tag, ?string $connection_name = null, bool $isfinalTag = false): array { + if ($isfinalTag) { + return $this->redis->getRedisConnection($connection_name)->smembers($tag); + } $keys = []; // Itera attraverso tutti gli shard del tag @@ -240,7 +253,7 @@ public function getShardNameForTag(string $tag, string $key): string * * Se l'opzione 'use_namespace' è disattivata, la chiave sarà formata senza namespace. */ - public function getFinalKey(string $key): string + public function getFinalKey(string $key, bool $isWithTags = false): string { // Se il namespace è abilitato, calcola la chiave con namespace come suffisso if ($this->useNamespace) { @@ -264,9 +277,13 @@ public function flush(?string $connection_name = null): void /** * Check if a cache key exists without retrieving the value. */ - public function has(string $key, ?string $connection_name = null): bool + public function has(string $key, ?string $connection_name = null, bool $isWithTags = false, bool $isfinalKey = false): bool { - $finalKey = $this->getFinalKey($key); + if ($isfinalKey) { + $finalKey = $key; + } else { + $finalKey = $this->getFinalKey($key, $isWithTags); + } return $this->redis->getRedisConnection($connection_name)->exists($finalKey) > 0; } @@ -319,11 +336,13 @@ public function getKeys(array $patterns, ?string $connection_name = null): array ] )) { $iterator = $keys[0]; + foreach ($keys[1] as $key) { - $tempArrKeys[] = $key; if ($key === null) { continue; } + $tempArrKeys[] = $key; + $original_key = $this->getOriginalKey($key); $value = $this->get($original_key); $results[$original_key] = $value; @@ -353,7 +372,7 @@ public function getOriginalKey(string $finalKey): string */ public function lock(string $key, ?string $connection_name = null, int $ttl = 10, string $value = '1'): bool { - $finalKey = $this->getFinalKey($key); + $finalKey = $this->getFinalKey($key) . ':semaphore'; $luaScript = <<<'LUA' if redis.call("SET", KEYS[1], ARGV[2], "NX", "EX", tonumber(ARGV[1])) then return 1 @@ -369,6 +388,7 @@ public function lock(string $key, ?string $connection_name = null, int $ttl = 10 $ttl, $value ); + return $result === 1; } @@ -380,7 +400,7 @@ public function lock(string $key, ?string $connection_name = null, int $ttl = 10 */ public function unLock(string $key, ?string $connection_name = null): void { - $finalKey = $this->getFinalKey($key); + $finalKey = $this->getFinalKey($key) . ':semaphore'; $luaScript = <<<'LUA' redis.call('DEL', KEYS[1]); LUA; diff --git a/src/SuperCacheServiceProvider.php b/src/SuperCacheServiceProvider.php index c4a57e0..a57c590 100644 --- a/src/SuperCacheServiceProvider.php +++ b/src/SuperCacheServiceProvider.php @@ -4,6 +4,7 @@ use Illuminate\Support\ServiceProvider; use Padosoft\SuperCache\Console\GetAllTagsOfKeyCommand; +use Padosoft\SuperCache\Console\GetClusterNodesCommand; use Padosoft\SuperCache\Console\ListenerCommand; use Padosoft\SuperCache\Console\CleanOrphanedKeysCommand; class SuperCacheServiceProvider extends ServiceProvider @@ -36,6 +37,7 @@ public function boot() GetAllTagsOfKeyCommand::class, ListenerCommand::class, CleanOrphanedKeysCommand::class, + GetClusterNodesCommand::class, ]); } } diff --git a/tests/Unit/SuperCacheManagerTest.php b/tests/Unit/SuperCacheManagerTest.php index 3a4c220..3728fda 100644 --- a/tests/Unit/SuperCacheManagerTest.php +++ b/tests/Unit/SuperCacheManagerTest.php @@ -94,10 +94,10 @@ public function test_put_with_tags(string $key, mixed $value, array $tags, ?int $this->superCache->useNamespace = $namespaceEnabled; $this->superCache->putWithTags($key, $value, $tags, $ttl); // La chiave deve essere stata creata - $this->assertEquals($value, $this->superCache->get($key)); + $this->assertEquals($value, $this->superCache->get($key, null, true)); // La chiave deve avere i tag corretti $this->assertEquals($tags, $this->superCache->getTagsOfKey($key)); - $ttlSet = $this->superCache->getTTLKey($key); + $ttlSet = $this->superCache->getTTLKey($key, null, true); if ($ttl !== null) { // Verifica che il TTL sia un valore positivo $this->assertGreaterThan(0, $ttlSet); @@ -119,13 +119,13 @@ public function test_forget(string $key, array $tags, bool $namespaceEnabled): v $this->superCache->putWithTags($key, '1', $tags); } - // MI assicuro che la chiave sia stata inserita prima di rimuoverla e controllo anche i tag - $this->assertEquals('1', $this->superCache->get($key)); + // MI assicuro che la chiave sia stata inserita prima di rimuoverla e controllo anche i tag + $this->assertEquals('1', $this->superCache->get($key, null,count($tags) > 0)); $tagsCached = $this->superCache->getTagsOfKey($key); $this->assertEquals($tags, $tagsCached); - $this->superCache->forget($key); + $this->superCache->forget($key, null, false, count($tags) > 0); // Dopo la rimozione devono essere spariti chiave e tag - $this->assertNull($this->superCache->get($key)); + $this->assertNull($this->superCache->get($key,null,count($tags) > 0)); foreach ($tagsCached as $tag) { $shard = $this->superCache->getShardNameForTag($tag, $key); $this->assertNull($this->superCache->get($shard)); @@ -143,8 +143,12 @@ public function test_flush(): void public function test_has(): void { $this->superCache->put('key1', 'value1'); + $this->superCache->putWithTags('key2', 'value1', ['tag1']); $this->assertTrue($this->superCache->has('key1')); + $this->assertTrue($this->superCache->has('key2', null, true)); + $finalKey = $this->superCache->getFinalKey('key2', true); + $this->assertTrue($this->superCache->has($finalKey, null, true, true)); $this->assertFalse($this->superCache->has('non_existing_key')); }