From 96eb442c6b8dcd58fdcd2286552657a00b5bf2bb Mon Sep 17 00:00:00 2001 From: Szymon Porwolik Date: Thu, 27 Nov 2025 19:33:53 +0100 Subject: [PATCH] Cache library and cache invalidation --- application/libraries/DxclusterCache.php | 215 +++++++++++++++++++++++ application/models/Dxcluster_model.php | 76 +------- application/models/Logbook_model.php | 40 +++-- 3 files changed, 247 insertions(+), 84 deletions(-) create mode 100644 application/libraries/DxclusterCache.php diff --git a/application/libraries/DxclusterCache.php b/application/libraries/DxclusterCache.php new file mode 100644 index 000000000..cf5b59101 --- /dev/null +++ b/application/libraries/DxclusterCache.php @@ -0,0 +1,215 @@ +CI =& get_instance(); + } + + // ========================================================================= + // CACHE KEY GENERATION + // ========================================================================= + + /** + * Generate RAW spot cache key (instance-wide, shared by all users) + */ + public function getRawCacheKey($maxage, $band) { + return "dxcluster_raw_{$maxage}_{$band}_Any_All"; + } + + /** + * Generate logbook IDs key component (user-specific) + */ + public function getLogbookKey($user_id, $logbook_ids, $confirmation_prefs) { + $logbook_ids_str = implode('_', $logbook_ids); + $confirmation_hash = md5($confirmation_prefs); + return "{$user_id}_{$logbook_ids_str}_{$confirmation_hash}"; + } + + /** + * Generate WORKED callsign cache key + */ + public function getWorkedCallKey($logbook_key, $callsign) { + return "dxcluster_worked_call_{$logbook_key}_{$callsign}"; + } + + /** + * Generate WORKED DXCC cache key + */ + public function getWorkedDxccKey($logbook_key, $dxcc) { + return "dxcluster_worked_dxcc_{$logbook_key}_{$dxcc}"; + } + + /** + * Generate WORKED continent cache key + */ + public function getWorkedContKey($logbook_key, $cont) { + return "dxcluster_worked_cont_{$logbook_key}_{$cont}"; + } + + // ========================================================================= + // CACHE INVALIDATION + // ========================================================================= + + /** + * Invalidate cache after QSO add/edit/delete for current user + * @param string $callsign - The worked callsign + */ + public function invalidateForCallsign($callsign) { + if (empty($callsign)) return; + + // Get current user's logbook key + $logbook_key = $this->getCurrentUserLogbookKey(); + if (empty($logbook_key)) return; + + // Delete callsign cache + $this->deleteFile($this->getWorkedCallKey($logbook_key, $callsign)); + + // Look up DXCC and continent from callsign + $this->CI->load->model('logbook_model'); + $dxcc_info = $this->CI->logbook_model->dxcc_lookup($callsign, date('Y-m-d')); + + if (!empty($dxcc_info['adif'])) { + $this->deleteFile($this->getWorkedDxccKey($logbook_key, $dxcc_info['adif'])); + } + if (!empty($dxcc_info['cont'])) { + $this->deleteFile($this->getWorkedContKey($logbook_key, $dxcc_info['cont'])); + } + } + + /** + * Invalidate all worked cache for current user (bulk operations) + */ + public function invalidateAllWorkedForCurrentUser() { + $logbook_key = $this->getCurrentUserLogbookKey(); + if (empty($logbook_key)) return; + + $this->invalidateByPrefix("dxcluster_worked_call_{$logbook_key}_"); + $this->invalidateByPrefix("dxcluster_worked_dxcc_{$logbook_key}_"); + $this->invalidateByPrefix("dxcluster_worked_cont_{$logbook_key}_"); + } + + /** + * Get current user's logbook key from session + */ + protected function getCurrentUserLogbookKey() { + $user_id = $this->CI->session->userdata('user_id'); + $active_logbook = $this->CI->session->userdata('active_station_logbook'); + + if (empty($user_id) || empty($active_logbook)) return null; + + $this->CI->load->model('logbooks_model'); + + $logbook_ids = $this->CI->logbooks_model->list_logbook_relationships($active_logbook); + $confirmation_prefs = $this->CI->session->userdata('user_default_confirmation') ?? ''; + + if (empty($logbook_ids)) return null; + + return $this->getLogbookKey($user_id, $logbook_ids, $confirmation_prefs); + } + + // ========================================================================= + // INTERNAL HELPERS + // ========================================================================= + + protected function deleteFile($cache_key) { + $cache_path = $this->getCachePath(); + if (!$cache_path) return; + @unlink($cache_path . $cache_key); + } + + protected function invalidateByPrefix($prefix) { + $cache_path = $this->getCachePath(); + if (!$cache_path) return; + + $handle = @opendir($cache_path); + if (!$handle) return; + + while (($filename = readdir($handle)) !== false) { + if (strpos($filename, $prefix) === 0) { + @unlink($cache_path . $filename); + } + } + closedir($handle); + } + + protected function getCachePath() { + $cache_path = $this->CI->config->item('cache_path'); + $cache_path = ($cache_path === '' || $cache_path === false) ? APPPATH . 'cache/' : $cache_path; + $cache_path = rtrim($cache_path, '/\\') . DIRECTORY_SEPARATOR; + return (is_dir($cache_path) && is_writable($cache_path)) ? $cache_path : false; + } + + // ========================================================================= + // GARBAGE COLLECTION + // ========================================================================= + + /** + * Run garbage collection with probability check (1% chance) + * Call this on each request when worked cache is enabled + */ + public function maybeRunGc() { + if (mt_rand(1, 100) === 1) { + $this->cleanExpiredCache(); + } + } + + /** + * Clean expired dxcluster cache files + * Uses file mtime for fast pre-filtering before reading file contents + */ + public function cleanExpiredCache() { + $cache_path = $this->getCachePath(); + if (!$cache_path || !is_readable($cache_path)) return; + + $handle = @opendir($cache_path); + if (!$handle) return; + + $now = time(); + $deleted = 0; + + // Max TTL for dxcluster files: raw=59s, worked=900s - use 900s + buffer + $max_ttl = 1000; + + while (($filename = readdir($handle)) !== false) { + // Only process dxcluster cache files + if (strpos($filename, 'dxcluster_') !== 0) continue; + + $file = $cache_path . $filename; + if (!is_file($file)) continue; + + // Fast pre-filter: skip files modified recently (can't be expired yet) + $mtime = @filemtime($file); + if ($mtime !== false && ($now - $mtime) < $max_ttl) { + continue; + } + + // File is old enough to potentially be expired - read and verify + $data = @unserialize(@file_get_contents($file)); + if (!is_array($data) || !isset($data['time'], $data['ttl'])) { + @unlink($file); + $deleted++; + continue; + } + + // Check if expired + if ($data['ttl'] > 0 && $now > $data['time'] + $data['ttl']) { + @unlink($file); + $deleted++; + } + } + + closedir($handle); + + if ($deleted > 0) { + log_message('debug', "DXCluster cache GC: deleted {$deleted} expired files"); + } + } +} diff --git a/application/models/Dxcluster_model.php b/application/models/Dxcluster_model.php index a2246687f..11edac190 100644 --- a/application/models/Dxcluster_model.php +++ b/application/models/Dxcluster_model.php @@ -57,8 +57,9 @@ class Dxcluster_model extends CI_Model { // Garbage collection: 1% chance to clean expired cache files // Only needed when worked cache is enabled (creates many per-callsign files) - if ($cache_worked_enabled && mt_rand(1, 100) === 1) { - $this->cleanExpiredDxclusterCache(); + if ($cache_worked_enabled) { + $this->load->library('DxclusterCache'); + $this->dxclustercache->maybeRunGc(); } } @@ -80,9 +81,9 @@ class Dxcluster_model extends CI_Model { $logbooks_locations_array = $this->logbooks_model->list_logbook_relationships($this->session->userdata('active_station_logbook')); // Cache key for RAW cluster response (instance-wide, no worked status) - // Use fixed 'Any'/'All' for de/mode to maximize cache hits - filters applied after retrieval - // Future: when API supports server-side filtering, use actual $de/$mode values here - $raw_cache_key = "dxcluster_raw_{$maxage}_{$band}_Any_All"; + // Use DxclusterCache library for centralized key generation + $this->load->library('DxclusterCache'); + $raw_cache_key = $this->dxclustercache->getRawCacheKey($maxage, $band); // Check cache for raw processed spots (without worked status) $spotsout = null; @@ -733,69 +734,4 @@ class Dxcluster_model extends CI_Model { return $spot; } - - /** - * Clean expired DX cluster cache files - * Called with low probability on each cache access to prevent buildup - * Uses file mtime for fast pre-filtering before reading file contents - */ - protected function cleanExpiredDxclusterCache() { - // Use configured cache path (same as CI cache driver) - $cache_path = $this->config->item('cache_path'); - $cache_path = ($cache_path === '' || $cache_path === false) ? APPPATH . 'cache/' : $cache_path; - - // Ensure trailing slash - $cache_path = rtrim($cache_path, '/\\') . DIRECTORY_SEPARATOR; - - // Check directory exists and is readable - if (!is_dir($cache_path) || !is_readable($cache_path)) { - return; - } - - // Use opendir/readdir instead of glob (more compatible with UNC paths) - $handle = @opendir($cache_path); - if (!$handle) return; - - $now = time(); - $deleted = 0; - - // Max TTL for dxcluster files: raw=59s, worked=900s - use 900s + buffer - $max_ttl = 1000; - - while (($filename = readdir($handle)) !== false) { - // Only process dxcluster cache files - if (strpos($filename, 'dxcluster_') !== 0) continue; - - $file = $cache_path . $filename; - if (!is_file($file)) continue; - - // Fast pre-filter: skip files modified recently (can't be expired yet) - // filemtime() is much faster than reading+deserializing the file - $mtime = @filemtime($file); - if ($mtime !== false && ($now - $mtime) < $max_ttl) { - continue; // File too new to be expired - } - - // File is old enough to potentially be expired - read and verify - $data = @unserialize(@file_get_contents($file)); - if (!is_array($data) || !isset($data['time'], $data['ttl'])) { - // Invalid cache file - delete it - @unlink($file); - $deleted++; - continue; - } - - // Check if expired - if ($data['ttl'] > 0 && $now > $data['time'] + $data['ttl']) { - @unlink($file); - $deleted++; - } - } - - closedir($handle); - - if ($deleted > 0) { - log_message('debug', "DXCluster cache GC: deleted {$deleted} expired files"); - } - } } diff --git a/application/models/Logbook_model.php b/application/models/Logbook_model.php index 622a0c192..6dc407bd5 100644 --- a/application/models/Logbook_model.php +++ b/application/models/Logbook_model.php @@ -8,6 +8,7 @@ class Logbook_model extends CI_Model { public function __construct() { $this->oop_populate_modes(); $this->load->Model('Modes'); + $this->load->library('DxclusterCache'); } private $oop_modes = []; @@ -940,6 +941,9 @@ class Logbook_model extends CI_Model { } } } + + // Invalidate DXCluster cache for this callsign + $this->dxclustercache->invalidateForCallsign($data['COL_CALL']); } } @@ -1680,6 +1684,9 @@ class Logbook_model extends CI_Model { try { $this->db->update($this->config->item('table_name'), $data); $retvals['success']=true; + + // Invalidate DXCluster cache for this callsign + $this->dxclustercache->invalidateForCallsign($data['COL_CALL']); } catch (Exception $e) { $retvals['success']=false; $retvals['detail']=$e; @@ -2742,14 +2749,12 @@ class Logbook_model extends CI_Model { // Build cache key with user_id, logbook_ids, and confirmation preference $user_id = $this->session->userdata('user_id'); - $logbook_ids_str = implode('_', $logbooks_locations_array); - $confirmation_hash = md5($user_default_confirmation); // Hash to keep key shorter - $logbook_ids_key = "{$user_id}_{$logbook_ids_str}_{$confirmation_hash}"; + $logbook_ids_key = $this->dxclustercache->getLogbookKey($user_id, $logbooks_locations_array, $user_default_confirmation); $spots_by_callsign = []; // Group spots by callsign for processing foreach ($spots as $spot) { - // Validate spot has required properties - if (!isset($spot->spotted) || !isset($spot->dxcc_spotted->dxcc_id) || !isset($spot->dxcc_spotted->cont) || !isset($spot->band) || !isset($spot->mode)) { + // Validate spot has required properties (must be non-empty) + if (empty($spot->spotted) || empty($spot->dxcc_spotted->dxcc_id) || empty($spot->dxcc_spotted->cont) || empty($spot->band) || empty($spot->mode)) { continue; } @@ -2782,7 +2787,7 @@ class Logbook_model extends CI_Model { if (!isset($this->spot_status_cache[$cache_key])) { // Check file cache if ($cache_enabled) { - $file_cache_key = "dxcluster_worked_call_{$logbook_ids_key}_{$callsign}"; + $file_cache_key = $this->dxclustercache->getWorkedCallKey($logbook_ids_key, $callsign); $cached_data = $this->cache->get($file_cache_key); if ($cached_data !== false) { // Load from file cache into in-memory cache @@ -2800,7 +2805,7 @@ class Logbook_model extends CI_Model { if (!isset($this->spot_status_cache[$cache_key])) { if ($cache_enabled) { - $file_cache_key = "dxcluster_worked_dxcc_{$logbook_ids_key}_{$dxcc}"; + $file_cache_key = $this->dxclustercache->getWorkedDxccKey($logbook_ids_key, $dxcc); $cached_data = $this->cache->get($file_cache_key); if ($cached_data !== false) { $this->spot_status_cache[$cache_key] = $cached_data; @@ -2816,7 +2821,7 @@ class Logbook_model extends CI_Model { if (!isset($this->spot_status_cache[$cache_key])) { if ($cache_enabled) { - $file_cache_key = "dxcluster_worked_cont_{$logbook_ids_key}_{$cont}"; + $file_cache_key = $this->dxclustercache->getWorkedContKey($logbook_ids_key, $cont); $cached_data = $this->cache->get($file_cache_key); if ($cached_data !== false) { $this->spot_status_cache[$cache_key] = $cached_data; @@ -3059,7 +3064,7 @@ class Logbook_model extends CI_Model { // Save to file cache for 15 minutes if ($cache_enabled) { - $file_cache_key = "dxcluster_worked_call_{$logbook_ids_key}_{$callsign}"; + $file_cache_key = $this->dxclustercache->getWorkedCallKey($logbook_ids_key, $callsign); $this->cache->save($file_cache_key, $data, $cache_ttl); } } @@ -3068,7 +3073,7 @@ class Logbook_model extends CI_Model { $this->spot_status_cache[$cache_key] = $data; if ($cache_enabled) { - $file_cache_key = "dxcluster_worked_dxcc_{$logbook_ids_key}_{$dxcc}"; + $file_cache_key = $this->dxclustercache->getWorkedDxccKey($logbook_ids_key, $dxcc); $this->cache->save($file_cache_key, $data, $cache_ttl); } } @@ -3077,7 +3082,7 @@ class Logbook_model extends CI_Model { $this->spot_status_cache[$cache_key] = $data; if ($cache_enabled) { - $file_cache_key = "dxcluster_worked_cont_{$logbook_ids_key}_{$cont}"; + $file_cache_key = $this->dxclustercache->getWorkedContKey($logbook_ids_key, $cont); $this->cache->save($file_cache_key, $data, $cache_ttl); } } // Cache NOT WORKED items (negative results) - store empty arrays @@ -3088,7 +3093,7 @@ class Logbook_model extends CI_Model { $this->spot_status_cache[$cache_key] = []; // Empty = not worked if ($cache_enabled) { - $file_cache_key = "dxcluster_worked_call_{$logbook_ids_key}_{$callsign}"; + $file_cache_key = $this->dxclustercache->getWorkedCallKey($logbook_ids_key, $callsign); $this->cache->save($file_cache_key, [], $cache_ttl); } } @@ -3099,7 +3104,7 @@ class Logbook_model extends CI_Model { $this->spot_status_cache[$cache_key] = []; if ($cache_enabled) { - $file_cache_key = "dxcluster_worked_dxcc_{$logbook_ids_key}_{$dxcc}"; + $file_cache_key = $this->dxclustercache->getWorkedDxccKey($logbook_ids_key, $dxcc); $this->cache->save($file_cache_key, [], $cache_ttl); } } @@ -3110,7 +3115,7 @@ class Logbook_model extends CI_Model { $this->spot_status_cache[$cache_key] = []; if ($cache_enabled) { - $file_cache_key = "dxcluster_worked_cont_{$logbook_ids_key}_{$cont}"; + $file_cache_key = $this->dxclustercache->getWorkedContKey($logbook_ids_key, $cont); $this->cache->save($file_cache_key, [], $cache_ttl); } } @@ -4294,6 +4299,10 @@ class Logbook_model extends CI_Model { /* Delete QSO based on the QSO ID */ function delete($id) { if ($this->check_qso_is_accessible($id)) { + // Get callsign before deleting for cache invalidation + $qso = $this->get_qso($id); + $callsign = ($qso->num_rows() > 0) ? $qso->row()->COL_CALL : null; + $this->load->model('qsl_model'); $this->load->model('eqsl_images'); @@ -4305,6 +4314,9 @@ class Logbook_model extends CI_Model { $this->db->where('qsoid', $id); $this->db->delete("oqrs"); + + // Invalidate DXCluster cache for this callsign + $this->dxclustercache->invalidateForCallsign($callsign); } else { return; }