| 1 |
<?php |
| 2 |
/** |
| 3 |
* CPU Load Monitoring and Deferral Logic |
| 4 |
* |
| 5 |
* Provides utility methods to detect CPU load and defer batch processing |
| 6 |
* when the server is under high load, improving performance during peak traffic. |
| 7 |
* |
| 8 |
* @package Metasync |
| 9 |
*/ |
| 10 |
|
| 11 |
class Metasync_CPU_Monitor { |
| 12 |
/** |
| 13 |
* Option key for persistent statistics |
| 14 |
*/ |
| 15 |
const STATS_OPTION_KEY = 'metasync_cpu_stats'; |
| 16 |
|
| 17 |
/** |
| 18 |
* Transient key for deferral notices |
| 19 |
*/ |
| 20 |
const DEFER_NOTICE_TRANSIENT = 'metasync_cpu_deferral_notice'; |
| 21 |
|
| 22 |
/** |
| 23 |
* Default CPU load threshold (per core) |
| 24 |
*/ |
| 25 |
const DEFAULT_PER_CORE = 2.0; |
| 26 |
|
| 27 |
/** |
| 28 |
* Check if sys_getloadavg() function is available |
| 29 |
* |
| 30 |
* @return bool True if function exists, false on Windows or if unavailable |
| 31 |
*/ |
| 32 |
public static function is_available() { |
| 33 |
return function_exists( 'sys_getloadavg' ); |
| 34 |
} |
| 35 |
|
| 36 |
/** |
| 37 |
* Get the current 1-minute load average |
| 38 |
* |
| 39 |
* Wraps sys_getloadavg() with a filter hook for testing purposes. |
| 40 |
* |
| 41 |
* @return float|false Load average or false on error |
| 42 |
*/ |
| 43 |
public static function get_load_average() { |
| 44 |
if ( ! self::is_available() ) { |
| 45 |
return false; |
| 46 |
} |
| 47 |
|
| 48 |
$raw = sys_getloadavg(); |
| 49 |
if ( ! is_array( $raw ) || empty( $raw ) ) { |
| 50 |
return false; |
| 51 |
} |
| 52 |
|
| 53 |
// Apply filter for testing purposes (allows mocking high load) |
| 54 |
$load = apply_filters( 'metasync_cpu_load_average', $raw[0] ); |
| 55 |
|
| 56 |
return is_numeric( $load ) ? (float) $load : false; |
| 57 |
} |
| 58 |
|
| 59 |
/** |
| 60 |
* Cached core-detection result for the current request. |
| 61 |
* |
| 62 |
* int = detected core count |
| 63 |
* false = every detection method failed (restricted host) |
| 64 |
* null = not probed yet |
| 65 |
* |
| 66 |
* @var int|false|null |
| 67 |
*/ |
| 68 |
private static $detected_cores = null; |
| 69 |
|
| 70 |
/** |
| 71 |
* Probe the system for its CPU core count |
| 72 |
* |
| 73 |
* Detection methods, in order: |
| 74 |
* 1. Linux: Parse /proc/cpuinfo |
| 75 |
* 2. Linux/some Unix: shell_exec('nproc') |
| 76 |
* 3. macOS: shell_exec('sysctl -n hw.ncpu') |
| 77 |
* |
| 78 |
* Returns false when every method fails — common on managed/shared |
| 79 |
* hosting where open_basedir blocks /proc/cpuinfo and shell_exec is |
| 80 |
* disabled. Callers must treat false as "unknown", NOT as 1 core: |
| 81 |
* sys_getloadavg() still reports the WHOLE machine's load on such |
| 82 |
* hosts, so pairing it with an assumed 1-core threshold wrongly flags |
| 83 |
* large, healthy shared servers as overloaded. |
| 84 |
* |
| 85 |
* @return int|false Core count, or false when detection failed |
| 86 |
*/ |
| 87 |
public static function detect_cpu_core_count() { |
| 88 |
if ( self::$detected_cores !== null ) { |
| 89 |
return self::$detected_cores; |
| 90 |
} |
| 91 |
|
| 92 |
$detected = false; |
| 93 |
|
| 94 |
// Try Linux: count processor lines in /proc/cpuinfo |
| 95 |
// Use @ to suppress open_basedir warnings on restricted shared hosting |
| 96 |
if ( @is_readable( '/proc/cpuinfo' ) ) { |
| 97 |
$cpuinfo = @file_get_contents( '/proc/cpuinfo' ); |
| 98 |
if ( $cpuinfo !== false ) { |
| 99 |
$count = substr_count( $cpuinfo, 'processor' ); |
| 100 |
if ( $count > 0 ) { |
| 101 |
$detected = (int) $count; |
| 102 |
} |
| 103 |
} |
| 104 |
} |
| 105 |
|
| 106 |
// Try shell_exec: nproc (Linux, some Unix), then sysctl (macOS) |
| 107 |
if ( $detected === false && function_exists( 'shell_exec' ) && ! in_array( 'shell_exec', explode( ',', ini_get( 'disable_functions' ) ), true ) ) { |
| 108 |
$nproc = intval( @shell_exec( 'nproc 2>/dev/null' ) ); |
| 109 |
if ( $nproc > 0 ) { |
| 110 |
$detected = $nproc; |
| 111 |
} else { |
| 112 |
$sysctl = intval( @shell_exec( 'sysctl -n hw.ncpu 2>/dev/null' ) ); |
| 113 |
if ( $sysctl > 0 ) { |
| 114 |
$detected = $sysctl; |
| 115 |
} |
| 116 |
} |
| 117 |
} |
| 118 |
|
| 119 |
/** |
| 120 |
* Filter the detected CPU core count. |
| 121 |
* |
| 122 |
* Lets hosts and tests override detection: return a positive int to |
| 123 |
* force a core count, or false to mark detection as failed. |
| 124 |
* |
| 125 |
* @param int|false $detected Detected core count, or false when unknown. |
| 126 |
*/ |
| 127 |
$filtered = apply_filters( 'metasync_cpu_detected_cores', $detected ); |
| 128 |
|
| 129 |
// Defend the int|false contract: anything that is not a positive |
| 130 |
// number counts as failed detection. This keeps the cache from being |
| 131 |
// reset to null (which would defeat it and re-run shell_exec probes |
| 132 |
// on every call) and stops a bogus 0/negative/garbage filter return |
| 133 |
// from silently recreating the 1-core/2.0-threshold gate this class |
| 134 |
// must avoid while reporting detection as "reliable". |
| 135 |
// Round through float so numeric strings like "1e3" parse by value |
| 136 |
// (a direct (int) cast would stop at the "e" and yield 1). |
| 137 |
$normalized = is_numeric( $filtered ) ? (int) round( (float) $filtered ) : 0; |
| 138 |
self::$detected_cores = $normalized > 0 ? $normalized : false; |
| 139 |
|
| 140 |
return self::$detected_cores; |
| 141 |
} |
| 142 |
|
| 143 |
/** |
| 144 |
* Reset the cached core-detection result (used by unit tests) |
| 145 |
*/ |
| 146 |
public static function reset_core_detection_cache() { |
| 147 |
self::$detected_cores = null; |
| 148 |
} |
| 149 |
|
| 150 |
/** |
| 151 |
* Whether the core count came from a real probe rather than the fallback |
| 152 |
* |
| 153 |
* @return bool True when a detection method succeeded |
| 154 |
*/ |
| 155 |
public static function is_core_detection_reliable() { |
| 156 |
return self::detect_cpu_core_count() !== false; |
| 157 |
} |
| 158 |
|
| 159 |
/** |
| 160 |
* Get the number of CPU cores, falling back to 1 when detection fails |
| 161 |
* |
| 162 |
* Note: when is_core_detection_reliable() is false this returns the |
| 163 |
* 1-core fallback, which is NOT a trustworthy basis for a load |
| 164 |
* threshold — is_load_safe() fails open in that case. |
| 165 |
* |
| 166 |
* @return int Number of CPU cores (minimum 1) |
| 167 |
*/ |
| 168 |
public static function get_cpu_core_count() { |
| 169 |
$detected = self::detect_cpu_core_count(); |
| 170 |
return $detected === false ? 1 : max( 1, (int) $detected ); |
| 171 |
} |
| 172 |
|
| 173 |
/** |
| 174 |
* Get the configured per-core load threshold |
| 175 |
* |
| 176 |
* @return float Per-core threshold (default 2.0) |
| 177 |
*/ |
| 178 |
public static function get_per_core_threshold() { |
| 179 |
return (float) ( Metasync::get_option( 'performance' )['cpu_load_per_core_threshold'] ?? self::DEFAULT_PER_CORE ); |
| 180 |
} |
| 181 |
|
| 182 |
/** |
| 183 |
* Get the effective load threshold for this system |
| 184 |
* |
| 185 |
* Calculated as: number_of_cores × per_core_threshold |
| 186 |
* |
| 187 |
* @return float Effective threshold |
| 188 |
*/ |
| 189 |
public static function get_effective_threshold() { |
| 190 |
return (float) self::get_cpu_core_count() * self::get_per_core_threshold(); |
| 191 |
} |
| 192 |
|
| 193 |
/** |
| 194 |
* Check if it's safe to process (CPU load is below threshold) |
| 195 |
* |
| 196 |
* Main guard method called before batch processing operations. |
| 197 |
* Returns true to proceed, false to defer. On failure, calls record_deferral(). |
| 198 |
* |
| 199 |
* @return bool True if safe to process, false if deferred |
| 200 |
*/ |
| 201 |
public static function is_load_safe() { |
| 202 |
// Skip check if sys_getloadavg() is unavailable (Windows) |
| 203 |
if ( ! self::is_available() ) { |
| 204 |
return true; |
| 205 |
} |
| 206 |
|
| 207 |
// without a real core count the effective threshold |
| 208 |
// (assumed 1 core × per-core limit) is meaningless. On managed |
| 209 |
// hosts that block /proc/cpuinfo and shell_exec, sys_getloadavg() |
| 210 |
// reports the whole shared machine's load — which normally sits |
| 211 |
// far above a 1-core threshold — so the gate would block every |
| 212 |
// job forever. A gate that cannot measure must not drop work: |
| 213 |
// fail open. |
| 214 |
if ( ! self::is_core_detection_reliable() ) { |
| 215 |
return true; |
| 216 |
} |
| 217 |
|
| 218 |
$load = self::get_load_average(); |
| 219 |
if ( $load === false ) { |
| 220 |
return true; // Error: assume safe |
| 221 |
} |
| 222 |
|
| 223 |
$threshold = self::get_effective_threshold(); |
| 224 |
if ( $load > $threshold ) { |
| 225 |
self::record_deferral( $load ); |
| 226 |
return false; |
| 227 |
} |
| 228 |
|
| 229 |
return true; |
| 230 |
} |
| 231 |
|
| 232 |
/** |
| 233 |
* Record a deferral event and update statistics |
| 234 |
* |
| 235 |
* Updates persistent stats (deferrals count, max load, running average). |
| 236 |
* Sets a transient notice for display to admins (5-minute TTL). |
| 237 |
* |
| 238 |
* @param float $load The current load average |
| 239 |
*/ |
| 240 |
public static function record_deferral( $load ) { |
| 241 |
$stats = get_option( self::STATS_OPTION_KEY, array( |
| 242 |
'deferrals' => 0, |
| 243 |
'max_load' => 0.0, |
| 244 |
'total_load' => 0.0, |
| 245 |
'sample_count' => 0, |
| 246 |
) ); |
| 247 |
|
| 248 |
// Increment deferral counter |
| 249 |
$stats['deferrals']++; |
| 250 |
|
| 251 |
// Track maximum load observed |
| 252 |
$stats['max_load'] = max( (float) $stats['max_load'], $load ); |
| 253 |
|
| 254 |
// Update running sum for average calculation |
| 255 |
$stats['total_load'] += $load; |
| 256 |
$stats['sample_count']++; |
| 257 |
|
| 258 |
// Timestamp of the most recent deferral, so consumers can tell an |
| 259 |
// active load problem from a historic spike (the counter is lifetime). |
| 260 |
$stats['last_deferral'] = time(); |
| 261 |
|
| 262 |
// Store updated stats (autoload=false to reduce options table bloat) |
| 263 |
update_option( self::STATS_OPTION_KEY, $stats, false ); |
| 264 |
|
| 265 |
// Set admin notice transient (5-minute TTL per requirements) |
| 266 |
set_transient( self::DEFER_NOTICE_TRANSIENT, array( |
| 267 |
'load' => round( $load, 2 ), |
| 268 |
'threshold' => round( self::get_effective_threshold(), 2 ), |
| 269 |
'cores' => self::get_cpu_core_count(), |
| 270 |
'time' => time(), |
| 271 |
), 300 ); |
| 272 |
} |
| 273 |
|
| 274 |
/** |
| 275 |
* Get current CPU statistics |
| 276 |
* |
| 277 |
* @return array Statistics including deferrals, max_load, avg_load, sample_count |
| 278 |
*/ |
| 279 |
public static function get_stats() { |
| 280 |
$stats = get_option( self::STATS_OPTION_KEY, array( |
| 281 |
'deferrals' => 0, |
| 282 |
'max_load' => 0.0, |
| 283 |
'total_load' => 0.0, |
| 284 |
'sample_count' => 0, |
| 285 |
) ); |
| 286 |
|
| 287 |
// Calculate average load |
| 288 |
$avg_load = $stats['sample_count'] > 0 ? $stats['total_load'] / $stats['sample_count'] : 0.0; |
| 289 |
|
| 290 |
return array( |
| 291 |
'deferrals' => (int) $stats['deferrals'], |
| 292 |
'max_load' => (float) $stats['max_load'], |
| 293 |
'avg_load' => round( $avg_load, 2 ), |
| 294 |
'sample_count' => (int) $stats['sample_count'], |
| 295 |
'last_deferral' => (int) ( $stats['last_deferral'] ?? 0 ), |
| 296 |
); |
| 297 |
} |
| 298 |
|
| 299 |
/** |
| 300 |
* Reset CPU statistics to default values |
| 301 |
*/ |
| 302 |
public static function reset_stats() { |
| 303 |
delete_option( self::STATS_OPTION_KEY ); |
| 304 |
} |
| 305 |
} |
| 306 |
|