Plugin Directory

Changeset 3396626


Ignore:
Timestamp:
11/16/2025 03:40:01 PM (4 months ago)
Author:
loghin
Message:

Version 1.2.995

Location:
dynamic-front-end-heartbeat-control
Files:
35 added
17 edited

Legend:

Unmodified
Added
Removed
  • dynamic-front-end-heartbeat-control/trunk/defibrillator/cli-helper.php

    r3310561 r3396626  
    22namespace DynamicHeartbeat;
    33
    4 defined('ABSPATH') or die();
    5 
    6 class dfehcUncloggerCli extends dfehcUnclogger {
    7     public function __construct() {
    8         if (!class_exists('WP_CLI')) return;
    9 
    10         $this->db = new dfehcUncloggerDb();
    11         $this->tweaks = new dfehcUncloggerTweaks();
    12 
    13         load_plugin_textdomain('dynamic-front-end-heartbeat-control', false, dirname(plugin_basename(__FILE__)) . '/languages');
    14 
    15         WP_CLI::add_command('dfehc-unclogger', [$this, 'dfehc_unclogger_command']);
    16     }
    17 
    18     public function dfehc_unclogger_command($args, $assoc_args) {
    19         if (empty($args)) {
    20             WP_CLI::line('commands:');
    21             WP_CLI::line('  - wp dfehc-unclogger db <command>');
    22             return;
    23         }
    24 
    25         if (!isset($args[1]) || $args[0] !== 'db') {
    26             WP_CLI::line('Invalid command.');
    27             return;
    28         }
    29 
    30         if ($args[1] === 'optimize_all') {
    31             $before = $this->db->get_database_size();
    32             $this->db->optimize_all();
    33             $after = $this->db->get_database_size();
    34             WP_CLI::success('Before: ' . $before . ', after: ' . $after);
    35         }
    36     }
    37 }
    38 
    39 if (defined('WP_CLI') && WP_CLI) {
    40     class DFEHC_CLI_Command {
    41         public function recalc_interval() {
    42             $load = dfehc_get_server_load();
    43             if ($load === null) {
    44                 WP_CLI::error('Unable to fetch server load.');
    45                 return;
    46             }
    47             $interval = dfehc_calculate_recommended_interval_user_activity($load);
    48             set_transient('dfehc_recommended_interval', $interval, 5 * MINUTE_IN_SECONDS);
    49             WP_CLI::success("Heartbeat interval recalculated and cached: {$interval}s");
    50         }
    51 
    52         public function process_users() {
    53             dfehc_process_user_activity();
    54             WP_CLI::success('User activity queue processed.');
    55         }
    56 
    57         public function clear_cache() {
    58             global $wpdb;
    59             $transients = array_merge(
    60                 $wpdb->get_col("SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_%dfehc_%'"),
    61                 $wpdb->get_col("SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_site_transient_%dfehc_%'")
    62             );
    63             foreach ($transients as $transient) {
    64                 $key = preg_replace('/^_site_transient_|^_transient_/', '', $transient);
    65                 delete_transient($key);
    66                 delete_site_transient($key);
    67             }
    68             delete_option('dfehc_server_load_logs');
    69             delete_option('dfehc_stale_total_visitors');
    70             WP_CLI::success('DFEHC transients and options cleared.');
    71         }
    72 
    73         public function enable_heartbeat() {
    74             update_option('dfehc_disable_heartbeat', 0);
    75             WP_CLI::success('Heartbeat has been enabled.');
    76         }
    77 
    78         public function disable_heartbeat() {
    79             update_option('dfehc_disable_heartbeat', 1);
    80             WP_CLI::success('Heartbeat has been disabled.');
    81         }
    82 
    83         public function status() {
    84             $load = dfehc_get_server_load();
    85             $interval = get_transient('dfehc_recommended_interval');
    86             $heartbeat_enabled = !get_option('dfehc_disable_heartbeat');
    87 
    88             WP_CLI::log("Server Load: " . ($load !== null ? round($load, 2) : 'N/A'));
    89             WP_CLI::log("Heartbeat Interval: " . ($interval !== false ? $interval . 's' : 'Not set'));
    90             WP_CLI::log("Heartbeat Enabled: " . ($heartbeat_enabled ? 'Yes' : 'No'));
    91         }
    92 
    93         public function calibrate_baseline() {
    94             $hostname = \DynamicHeartbeat\Dfehc_ServerLoadEstimator::get_hostname_key();
    95             $blog_id = is_multisite() ? get_current_blog_id() : 1;
    96             $transient = \DynamicHeartbeat\Dfehc_ServerLoadEstimator::BASELINE_TRANSIENT_PREFIX . $hostname . '_' . $blog_id;
    97 
    98             $baseline = \DynamicHeartbeat\Dfehc_ServerLoadEstimator::calibrate_baseline();
    99             set_transient($transient, $baseline, 7 * DAY_IN_SECONDS);
    100 
    101             WP_CLI::success("Baseline calibrated: $baseline");
    102         }
    103         public function load() {
    104            $load = \DynamicHeartbeat\Dfehc_ServerLoadEstimator::get_server_load();
    105            $spike_score = (float) get_transient(\DynamicHeartbeat\Dfehc_ServerLoadEstimator::LOAD_SPIKE_TRANSIENT);
    106 
    107     if ($load === false) {
    108         WP_CLI::error('Unable to estimate server load.');
    109         return;
    110     }
    111 
    112     WP_CLI::log("Current Load: {$load}%");
    113     WP_CLI::log("Spike Score: " . round($spike_score, 2));
    114 }
    115 
    116     }
    117 
    118     WP_CLI::add_command('dfehc', 'DFEHC_CLI_Command');
    119 }
     4if (!\defined('WP_CLI') || !WP_CLI) {
     5    return;
     6}
     7
     8if (!\function_exists(__NAMESPACE__ . '\\dfehc_blog_id')) {
     9    function dfehc_blog_id(): int {
     10        return \function_exists('\get_current_blog_id') ? (int) \get_current_blog_id() : 0;
     11    }
     12}
     13
     14if (!\function_exists(__NAMESPACE__ . '\\dfehc_scope_key')) {
     15    function dfehc_scope_key(string $base): string {
     16        if (\function_exists('\dfehc_scoped_key')) {
     17            return \dfehc_scoped_key($base);
     18        }
     19        $hostToken = \function_exists('\dfehc_host_token')
     20            ? \dfehc_host_token()
     21            : \substr(\md5((string) ((\php_uname('n') ?: 'unknown') . (\defined('DB_NAME') ? DB_NAME : ''))), 0, 10);
     22        return "{$base}_" . dfehc_blog_id() . "_{$hostToken}";
     23    }
     24}
     25
     26if (\class_exists(__NAMESPACE__ . '\\dfehcUnclogger') && \class_exists('WP_CLI')) {
     27    class DfehcUncloggerCli extends dfehcUnclogger {
     28        public function __construct() {
     29            if (!\class_exists('\WP_CLI')) return;
     30
     31            if (\class_exists(__NAMESPACE__ . '\\dfehcUncloggerDb')) {
     32                $this->db = new dfehcUncloggerDb();
     33            }
     34            if (\class_exists(__NAMESPACE__ . '\\dfehcUncloggerTweaks')) {
     35                $this->tweaks = new dfehcUncloggerTweaks();
     36            }
     37
     38            if (\function_exists('\load_plugin_textdomain') && \function_exists('\plugin_basename')) {
     39                \load_plugin_textdomain(
     40                    'dynamic-front-end-heartbeat-control',
     41                    false,
     42                    \dirname(\plugin_basename(__FILE__)) . '/languages'
     43                );
     44            }
     45
     46            if (isset($this->db)) {
     47                \WP_CLI::add_command('dfehc-unclogger', [$this, 'dfehc_unclogger_command']);
     48            }
     49        }
     50
     51        public function dfehc_unclogger_command(array $args, array $assoc_args) {
     52            if (empty($args)) {
     53                \WP_CLI::line('commands:');
     54                \WP_CLI::line('  - wp dfehc-unclogger db optimize_all');
     55                return 0;
     56            }
     57
     58            if (!isset($args[1]) || $args[0] !== 'db') {
     59                \WP_CLI::error('Invalid command. Usage: wp dfehc-unclogger db optimize_all');
     60                return 1;
     61            }
     62
     63            if ($args[1] === 'optimize_all') {
     64                if (!isset($this->db)) {
     65                    \WP_CLI::error('Database unclogger is not available.');
     66                    return 1;
     67                }
     68                $before = \method_exists($this->db, 'get_database_size') ? $this->db->get_database_size() : 'unknown';
     69                if (\method_exists($this->db, 'optimize_all')) {
     70                    $this->db->optimize_all();
     71                }
     72                $after = \method_exists($this->db, 'get_database_size') ? $this->db->get_database_size() : 'unknown';
     73                \WP_CLI::success('Before: ' . $before . ', after: ' . $after);
     74                return 0;
     75            }
     76
     77            \WP_CLI::error('Unknown db subcommand.');
     78            return 1;
     79        }
     80    }
     81    new DfehcUncloggerCli();
     82}
     83
     84class DFEHC_CLI_Command {
     85
     86    public function recalc_interval(array $args = [], array $assoc_args = []) {
     87        if (!\function_exists('\dfehc_get_server_load')) {
     88            \WP_CLI::error('dfehc_get_server_load() is unavailable.');
     89            return;
     90        }
     91        $load = \dfehc_get_server_load();
     92        if ($load === false || $load === null) {
     93            \WP_CLI::error('Unable to fetch server load.');
     94            return;
     95        }
     96
     97        $interval = null;
     98        if (\function_exists('\dfehc_calculate_recommended_interval_user_activity')) {
     99            $interval = (int) \dfehc_calculate_recommended_interval_user_activity((float) $load);
     100        } elseif (\function_exists('\dfehc_calculate_recommended_interval')) {
     101            $interval = (int) \dfehc_calculate_recommended_interval(60.0, (float) $load, 0.0);
     102        } else {
     103            \WP_CLI::error('No interval calculator function available.');
     104            return;
     105        }
     106
     107        $baseKey = \defined('DFEHC_RECOMMENDED_INTERVAL') ? \DFEHC_RECOMMENDED_INTERVAL : 'dfehc_recommended_interval';
     108        $key     = dfehc_scope_key($baseKey);
     109
     110        if (\function_exists('\dfehc_set_transient_noautoload')) {
     111            \dfehc_set_transient_noautoload($key, $interval, 5 * MINUTE_IN_SECONDS);
     112        } else {
     113            \set_transient($key, $interval, 5 * MINUTE_IN_SECONDS);
     114        }
     115
     116        \WP_CLI::success("Heartbeat interval recalculated and cached: {$interval}s");
     117    }
     118
     119    public function process_users(array $args = [], array $assoc_args = []) {
     120        if (!\function_exists('\dfehc_process_user_activity')) {
     121            \WP_CLI::error('dfehc_process_user_activity() is unavailable.');
     122            return;
     123        }
     124        \dfehc_process_user_activity();
     125        \WP_CLI::success('User activity queue processed.');
     126    }
     127
     128    public function clear_cache(array $args = [], array $assoc_args = []) {
     129        $network = !empty($assoc_args['network']) && \is_multisite();
     130        $flush_object_cache = !empty($assoc_args['flush-object-cache']);
     131        $group = !empty($assoc_args['group']) ? (string) $assoc_args['group'] : (\defined('DFEHC_CACHE_GROUP') ? \DFEHC_CACHE_GROUP : 'dfehc');
     132
     133        if (empty($assoc_args['yes'])) {
     134            \WP_CLI::confirm('This will clear DFEHC caches. Continue?');
     135        }
     136
     137        $bases = [
     138            'dfehc_db_metrics',
     139            'dfehc_db_size_mb',
     140            'dfehc_db_size_fail',
     141            'dfehc_server_load',
     142            'dfehc_server_load_payload',
     143            'dfehc:server_load',
     144            'dfehc_recommended_interval',
     145            'dfehc_load_averages',
     146            'dfehc_ema_' . dfehc_blog_id(),
     147            'dfehc_prev_int_' . dfehc_blog_id(),
     148            'dfehc_total_visitors',
     149            'dfehc_regenerating_cache',
     150            'dfehc_system_load_avg',
     151        ];
     152
     153        if ($network && \function_exists('\get_sites')) {
     154            $siteIds = \array_map('intval', (array) \get_sites(['fields' => 'ids']));
     155        } else {
     156            $siteIds = [ dfehc_blog_id() ];
     157        }
     158
     159        foreach ($siteIds as $sid) {
     160            $switched = false;
     161            if ($network) { \switch_to_blog($sid); $switched = true; }
     162            try {
     163                foreach ($bases as $base) {
     164                    $key = dfehc_scope_key($base);
     165                    \delete_transient($key);
     166                    \delete_site_transient($key);
     167                }
     168                \delete_option('dfehc_stale_total_visitors');
     169            } finally {
     170                if ($switched) { \restore_current_blog(); }
     171            }
     172        }
     173
     174        if ($flush_object_cache) {
     175            if (\function_exists('\wp_cache_flush_group')) {
     176                @\wp_cache_flush_group($group);
     177                \WP_CLI::success("Flushed object cache group: {$group}");
     178            } elseif (\function_exists('\wp_cache_flush')) {
     179                @\wp_cache_flush();
     180                \WP_CLI::warning('Global object cache flushed (group-specific flush not supported).');
     181            } else {
     182                \WP_CLI::warning('Object cache flush not supported by this installation.');
     183            }
     184        }
     185
     186        \WP_CLI::success('DFEHC caches cleared.');
     187    }
     188
     189    public function enable_heartbeat(array $args = [], array $assoc_args = []) {
     190        \update_option('dfehc_disable_heartbeat', 0, false);
     191        \WP_CLI::success('Heartbeat has been enabled.');
     192    }
     193
     194    public function disable_heartbeat(array $args = [], array $assoc_args = []) {
     195        \update_option('dfehc_disable_heartbeat', 1, false);
     196        \WP_CLI::success('Heartbeat has been disabled.');
     197    }
     198
     199    public function status(array $args = [], array $assoc_args = []) {
     200        $load = \function_exists('\dfehc_get_server_load') ? \dfehc_get_server_load() : null;
     201
     202        $baseKey  = \defined('DFEHC_RECOMMENDED_INTERVAL') ? \DFEHC_RECOMMENDED_INTERVAL : 'dfehc_recommended_interval';
     203        $key      = dfehc_scope_key($baseKey);
     204        $interval = \get_transient($key);
     205
     206        $heartbeat_enabled = !\get_option('dfehc_disable_heartbeat');
     207
     208        \WP_CLI::log('Server Load: ' . ($load !== null && $load !== false ? \round((float) $load, 2) : 'N/A'));
     209        \WP_CLI::log('Heartbeat Interval: ' . ($interval !== false && $interval !== null ? (int) $interval . 's' : 'Not set'));
     210        \WP_CLI::log('Heartbeat Enabled: ' . ($heartbeat_enabled ? 'Yes' : 'No'));
     211    }
     212
     213    public function calibrate_baseline(array $args = [], array $assoc_args = []) {
     214        if (!\class_exists(__NAMESPACE__ . '\\Dfehc_ServerLoadEstimator')) {
     215            \WP_CLI::error('Server load estimator is unavailable.');
     216            return;
     217        }
     218        $baseline = Dfehc_ServerLoadEstimator::calibrate_baseline();
     219        \WP_CLI::success("Baseline calibrated: {$baseline}");
     220    }
     221
     222    public function load(array $args = [], array $assoc_args = []) {
     223        if (!\class_exists(__NAMESPACE__ . '\\Dfehc_ServerLoadEstimator')) {
     224            \WP_CLI::error('Server load estimator is unavailable.');
     225            return;
     226        }
     227        $load = Dfehc_ServerLoadEstimator::get_server_load();
     228        if ($load === false || $load === null) {
     229            \WP_CLI::error('Unable to estimate server load.');
     230            return;
     231        }
     232
     233        \WP_CLI::log('Current Load: ' . \round((float) $load, 2) . '%');
     234
     235        $spikeShown = false;
     236        $candidates = [
     237            dfehc_scope_key('dfehc_spike_score'),
     238            'dfehc_spike_score',
     239        ];
     240        foreach ($candidates as $k) {
     241            $v = \get_transient($k);
     242            if (\is_numeric($v)) {
     243                \WP_CLI::log('Spike Score: ' . \round((float) $v, 2));
     244                $spikeShown = true;
     245                break;
     246            }
     247        }
     248        if (!$spikeShown) {
     249            \WP_CLI::log('Spike Score: N/A');
     250        }
     251    }
     252}
     253
     254\WP_CLI::add_command('dfehc', __NAMESPACE__ . '\\DFEHC_CLI_Command', [
     255    'shortdesc' => 'Dynamic Front-End Heartbeat Control utilities.',
     256    'synopsis'  => [],
     257]);
  • dynamic-front-end-heartbeat-control/trunk/defibrillator/db-health.php

    r3310561 r3396626  
    44namespace DynamicHeartbeat;
    55
    6 function dfehc_gather_database_metrics(bool $force = false): array
    7 {
     6if (!\function_exists(__NAMESPACE__ . '\\dfehc_blog_id')) {
     7    function dfehc_blog_id(): int {
     8        return \function_exists('\get_current_blog_id') ? (int) \get_current_blog_id() : 0;
     9    }
     10}
     11
     12function dfehc_scoped_key(string $base): string {
     13    return $base . '_' . dfehc_blog_id();
     14}
     15
     16function dfehc_cache_group(): string {
     17    return \defined('DFEHC_CACHE_GROUP') ? (string) \DFEHC_CACHE_GROUP : 'dfehc';
     18}
     19
     20function dfehc_cache_get(string $key) {
     21    if (\function_exists('\wp_using_ext_object_cache') && \wp_using_ext_object_cache()) {
     22        return \wp_cache_get($key, dfehc_cache_group());
     23    }
     24    return \get_site_transient($key);
     25}
     26
     27function dfehc_cache_set(string $key, $value, int $ttl): void {
     28    if (\function_exists('\wp_using_ext_object_cache') && \wp_using_ext_object_cache()) {
     29        \wp_cache_set($key, $value, dfehc_cache_group(), $ttl);
     30        return;
     31    }
     32    \set_site_transient($key, $value, $ttl);
     33}
     34
     35function dfehc_gather_database_metrics(bool $force = false): array {
    836    static $cached = null;
    9     if ($cached !== null && !$force) {
     37    $persist_ttl = (int) \apply_filters('dfehc_db_metrics_ttl', 600);
     38    $metrics_key = dfehc_scoped_key('dfehc_db_metrics');
     39
     40    if (!$force) {
     41        $persisted = dfehc_cache_get($metrics_key);
     42        if (\is_array($persisted)) {
     43            $cached = $persisted;
     44            return $persisted;
     45        }
     46    } elseif ($cached !== null) {
    1047        return $cached;
    1148    }
    1249
    13     $unclogger = new DfehcUncloggerDb();
    14     $revision_count = $unclogger->count_revisions();
    15     $trashed_posts_count = $unclogger->count_trashed_posts();
    16     $expired_transients_count = $unclogger->count_expired_transients();
    17 
    18     $order_count = wp_count_posts('shop_order')->publish ?? 0;
    19     $product_count = wp_count_posts('product')->publish ?? 0;
    20     $page_count = wp_count_posts('page')->publish ?? 0;
    21 
    22     $customer_query = new \WP_User_Query([
    23         'role' => 'customer',
    24         'number' => 1,
    25         'fields' => 'ID',
    26         'count_total' => true,
     50    $toggles = \wp_parse_args(\apply_filters('dfehc_db_metrics_toggles', []), [
     51        'revisions'          => true,
     52        'trashed_posts'      => true,
     53        'expired_transients' => true,
     54        'orders'             => true,
     55        'products'           => true,
     56        'pages'              => true,
     57        'customers'          => true,
     58        'users'              => true,
     59        'db_size'            => true,
     60        'disk'               => true,
    2761    ]);
    28     $customer_count = (int) $customer_query->get_total();
    29 
    30     $user_query = new \WP_User_Query([
    31         'number' => 1,
    32         'fields' => 'ID',
    33         'count_total' => true,
    34     ]);
    35     $user_count = (int) $user_query->get_total();
     62
     63    $budget_ms = (int) \apply_filters('dfehc_db_metrics_budget_ms', 250);
     64    $budget_ms = $budget_ms > 0 ? $budget_ms : 250;
     65    $t0 = \microtime(true);
     66    $within_budget = static function () use ($t0, $budget_ms): bool {
     67        return ((\microtime(true) - $t0) * 1000) < $budget_ms;
     68    };
     69
     70    $sources = [];
     71    $metrics = [
     72        'revision_count'          => 0,
     73        'trashed_posts_count'     => 0,
     74        'expired_transients_count'=> 0,
     75        'order_count'             => 0,
     76        'product_count'           => 0,
     77        'page_count'              => 0,
     78        'customer_count'          => 0,
     79        'user_count'              => 0,
     80        'db_size_mb'              => 0.0,
     81        'disk_free_space_mb'      => 0.0,
     82        'collected_at'            => \current_time('mysql'),
     83        'cache_backend'           => (\function_exists('\wp_using_ext_object_cache') && \wp_using_ext_object_cache()) ? 'object' : 'transient',
     84    ];
     85
     86    $unclogger = null;
     87    if (\class_exists(__NAMESPACE__ . '\\DfehcUncloggerDb')) {
     88        $unclogger = new DfehcUncloggerDb();
     89    } elseif (\class_exists('\\DfehcUncloggerDb')) {
     90        $unclogger = new \DfehcUncloggerDb();
     91    }
     92
     93    if ($toggles['revisions'] && $within_budget()) {
     94        $metrics['revision_count'] = $unclogger ? (int) $unclogger->count_revisions() : 0;
     95        $sources['revision_count'] = $unclogger ? 'sql' : 'none';
     96    } else {
     97        $sources['revision_count'] = 'skipped';
     98    }
     99
     100    if ($toggles['trashed_posts'] && $within_budget()) {
     101        $metrics['trashed_posts_count'] = $unclogger ? (int) $unclogger->count_trashed_posts() : 0;
     102        $sources['trashed_posts_count'] = $unclogger ? 'sql' : 'none';
     103    } else {
     104        $sources['trashed_posts_count'] = 'skipped';
     105    }
     106
     107    if ($toggles['expired_transients'] && $within_budget()) {
     108        $metrics['expired_transients_count'] = $unclogger ? (int) $unclogger->count_expired_transients() : 0;
     109        $sources['expired_transients_count'] = $unclogger ? 'sql' : 'none';
     110    } else {
     111        $sources['expired_transients_count'] = 'skipped';
     112    }
     113
     114    if ($toggles['orders'] && $within_budget() && \function_exists('\post_type_exists') && \post_type_exists('shop_order')) {
     115        $o = \wp_count_posts('shop_order');
     116        $metrics['order_count'] = (int) ((\is_object($o) ? (array) $o : [])['publish'] ?? 0);
     117        $sources['order_count'] = 'wp_count_posts';
     118    } else {
     119        $sources['order_count'] = $toggles['orders'] ? 'skipped' : 'disabled';
     120    }
     121
     122    if ($toggles['products'] && $within_budget() && \function_exists('\post_type_exists') && \post_type_exists('product')) {
     123        $p = \wp_count_posts('product');
     124        $metrics['product_count'] = (int) ((\is_object($p) ? (array) $p : [])['publish'] ?? 0);
     125        $sources['product_count'] = 'wp_count_posts';
     126    } else {
     127        $sources['product_count'] = $toggles['products'] ? 'skipped' : 'disabled';
     128    }
     129
     130    if ($toggles['pages'] && $within_budget() && \function_exists('\post_type_exists') && \post_type_exists('page')) {
     131        $pg = \wp_count_posts('page');
     132        $metrics['page_count'] = (int) ((\is_object($pg) ? (array) $pg : [])['publish'] ?? 0);
     133        $sources['page_count'] = 'wp_count_posts';
     134    } else {
     135        $sources['page_count'] = $toggles['pages'] ? 'skipped' : 'disabled';
     136    }
     137
     138    if ($toggles['users'] && $within_budget()) {
     139        $cu = \function_exists('\count_users') ? \count_users() : null;
     140        $metrics['user_count'] = \is_array($cu) && isset($cu['total_users']) ? (int) $cu['total_users'] : 0;
     141        $sources['user_count'] = $cu ? 'count_users' : 'none';
     142    } else {
     143        $sources['user_count'] = $toggles['users'] ? 'skipped' : 'disabled';
     144    }
     145
     146    if ($toggles['customers'] && $within_budget()) {
     147        $cu = \function_exists('\count_users') ? \count_users() : null;
     148        $avail = \is_array($cu) && isset($cu['avail_roles']) ? (array) $cu['avail_roles'] : [];
     149        $metrics['customer_count'] = (int) ($avail['customer'] ?? 0);
     150        $sources['customer_count'] = $cu ? 'count_users' : 'none';
     151    } else {
     152        $sources['customer_count'] = $toggles['customers'] ? 'skipped' : 'disabled';
     153    }
    36154
    37155    global $wpdb;
    38     $db_size_mb = get_transient('dfehc_db_size_mb');
    39     if ($db_size_mb === false) {
    40         $db_size_mb = $wpdb->get_var(
    41             $wpdb->prepare(
     156
     157    $db_size_key      = dfehc_scoped_key('dfehc_db_size_mb');
     158    $db_size_fail_key = dfehc_scoped_key('dfehc_db_size_fail');
     159    $db_size_mb       = null;
     160
     161    if ($toggles['db_size'] && $within_budget()) {
     162        $db_size_mb = dfehc_cache_get($db_size_key);
     163        $db_size_fail = dfehc_cache_get($db_size_fail_key);
     164        if ($db_size_mb === false && $db_size_fail === false) {
     165            $prev = $wpdb->suppress_errors();
     166            $wpdb->suppress_errors(true);
     167
     168            $qry = $wpdb->prepare(
    42169                "SELECT SUM(data_length + index_length) / 1024 / 1024
    43170                 FROM information_schema.tables
    44171                 WHERE table_schema = %s",
    45172                $wpdb->dbname
    46             )
    47         );
    48         if ($db_size_mb !== null) {
    49             set_transient('dfehc_db_size_mb', (float) $db_size_mb, 6 * HOUR_IN_SECONDS);
    50         }
     173            );
     174            $db_size_mb = $wpdb->get_var($qry);
     175
     176            $wpdb->suppress_errors($prev);
     177
     178            if (\is_numeric($db_size_mb)) {
     179                $db_size_mb = (float) $db_size_mb;
     180                if ($db_size_mb <= 0 || $db_size_mb > (float) \apply_filters('dfehc_db_size_upper_bound_mb', 10 * 1024 * 1024)) {
     181                    $db_size_mb = null;
     182                } else {
     183                    dfehc_cache_set($db_size_key, (float) $db_size_mb, 6 * HOUR_IN_SECONDS);
     184                    $sources['db_size_mb'] = 'information_schema';
     185                }
     186            } else {
     187                $db_size_mb = null;
     188            }
     189
     190            if ($db_size_mb === null && $within_budget()) {
     191                $prev = $wpdb->suppress_errors();
     192                $wpdb->suppress_errors(true);
     193
     194                $prefix_like = $wpdb->esc_like($wpdb->base_prefix) . '%';
     195                $rows = $wpdb->get_results(
     196                    $wpdb->prepare("SHOW TABLE STATUS WHERE Name LIKE %s", $prefix_like),
     197                    ARRAY_A
     198                );
     199
     200                $wpdb->suppress_errors($prev);
     201
     202                if (\is_array($rows) && $rows) {
     203                    $sum = 0.0;
     204                    foreach ($rows as $r) {
     205                        $sum += (isset($r['Data_length']) ? (float) $r['Data_length'] : 0.0)
     206                              + (isset($r['Index_length']) ? (float) $r['Index_length'] : 0.0);
     207                    }
     208                    if ($sum > 0) {
     209                        $db_size_mb = \round($sum / 1024 / 1024, 2);
     210                        dfehc_cache_set($db_size_key, (float) $db_size_mb, 6 * HOUR_IN_SECONDS);
     211                        $sources['db_size_mb'] = 'show_table_status';
     212                    }
     213                }
     214            }
     215
     216            if ($db_size_mb === null) {
     217                dfehc_cache_set($db_size_fail_key, 1, (int) \apply_filters('dfehc_db_size_fail_ttl', 900));
     218            }
     219        } elseif (\is_numeric($db_size_mb)) {
     220            $db_size_mb = (float) $db_size_mb;
     221            $sources['db_size_mb'] = 'cache';
     222        }
     223    } else {
     224        $sources['db_size_mb'] = $toggles['db_size'] ? 'skipped' : 'disabled';
    51225    }
    52226
    53227    if ($db_size_mb === null || $db_size_mb === false) {
    54         $multipliers = apply_filters('dfehc_db_size_multipliers', [
    55             'user' => 0.03,
    56             'order' => 0.05,
    57             'revision' => 0.05,
    58             'trash' => 0.05,
    59             'page' => 0.10,
     228        $multipliers = \apply_filters('dfehc_db_size_multipliers', [
     229            'user'      => 0.03,
     230            'order'     => 0.05,
     231            'revision'  => 0.05,
     232            'trash'     => 0.05,
     233            'page'      => 0.10,
    60234            'transient' => 0.01,
    61             'default' => 500,
     235            'default'   => 500.0,
    62236        ]);
    63         $estimate = 0;
    64         $estimate += $user_count * $multipliers['user'];
    65         $estimate += $order_count * $multipliers['order'];
    66         $estimate += $revision_count * $multipliers['revision'];
    67         $estimate += $trashed_posts_count * $multipliers['trash'];
    68         $estimate += $page_count * $multipliers['page'];
    69         $estimate += $expired_transients_count * $multipliers['transient'];
     237        $estimate = 0.0;
     238        $estimate += $metrics['user_count']              * (float) ($multipliers['user'] ?? 0.03);
     239        $estimate += $metrics['order_count']             * (float) ($multipliers['order'] ?? 0.05);
     240        $estimate += $metrics['revision_count']          * (float) ($multipliers['revision'] ?? 0.05);
     241        $estimate += $metrics['trashed_posts_count']     * (float) ($multipliers['trash'] ?? 0.05);
     242        $estimate += $metrics['page_count']              * (float) ($multipliers['page'] ?? 0.10);
     243        $estimate += $metrics['expired_transients_count']* (float) ($multipliers['transient'] ?? 0.01);
    70244        if ($estimate <= 0) {
    71             $estimate = $multipliers['default'];
    72         }
    73         $db_size_mb = $estimate;
    74     }
    75 
    76     $disk_paths = apply_filters('dfehc_disk_paths', [
    77         WP_CONTENT_DIR,
    78         defined('ABSPATH') ? ABSPATH : null,
    79     ]);
    80     $disk_free_space_mb = null;
    81     foreach ($disk_paths as $p) {
    82         if ($p && is_dir($p) && is_readable($p)) {
    83             $space = disk_free_space($p);
    84             if ($space !== false) {
    85                 $disk_free_space_mb = round($space / 1024 / 1024, 2);
    86                 break;
    87             }
    88         }
    89     }
    90     if ($disk_free_space_mb === null) {
    91         $disk_free_space_mb = round((50 * 1024 * 1024 * 1024) / 1024 / 1024, 2);
    92     }
    93 
    94     $metrics = [
    95         'revision_count' => $revision_count,
    96         'trashed_posts_count' => $trashed_posts_count,
    97         'expired_transients_count' => $expired_transients_count,
    98         'order_count' => $order_count,
    99         'product_count' => $product_count,
    100         'page_count' => $page_count,
    101         'customer_count' => $customer_count,
    102         'user_count' => $user_count,
    103         'db_size' => (float) $db_size_mb,
    104         'disk_free_space_mb' => $disk_free_space_mb,
    105     ];
     245            $estimate = (float) ($multipliers['default'] ?? 500.0);
     246        }
     247        $db_size_mb = \round($estimate, 2);
     248        $sources['db_size_mb'] = 'estimated';
     249    }
     250
     251    $metrics['db_size_mb'] = (float) $db_size_mb;
     252
     253    if ($toggles['disk'] && $within_budget()) {
     254        $disk_paths = \apply_filters('dfehc_disk_paths', (function (): array {
     255            $paths = [];
     256            if (\defined('WP_CONTENT_DIR')) { $paths[] = WP_CONTENT_DIR; }
     257            if (\function_exists('\wp_get_upload_dir')) {
     258                $u = \wp_get_upload_dir();
     259                if (\is_array($u) && !empty($u['basedir'])) {
     260                    $paths[] = (string) $u['basedir'];
     261                }
     262            }
     263            if (\defined('ABSPATH')) { $paths[] = ABSPATH; }
     264            return $paths;
     265        })());
     266
     267        $disk_free_space_mb = null;
     268        foreach ($disk_paths as $p) {
     269            if ($p && \is_dir($p) && \is_readable($p)) {
     270                $space = @\disk_free_space($p);
     271                if ($space !== false) {
     272                    $disk_free_space_mb = \round($space / 1024 / 1024, 2);
     273                    $sources['disk_free_space_mb'] = 'disk_free_space';
     274                    break;
     275                }
     276            }
     277        }
     278        if ($disk_free_space_mb === null) {
     279            $fallback = (float) \apply_filters('dfehc_disk_free_default_mb', \round((50 * 1024 * 1024 * 1024) / 1024 / 1024, 2));
     280            $disk_free_space_mb = $fallback;
     281            $sources['disk_free_space_mb'] = 'default';
     282        }
     283        $metrics['disk_free_space_mb'] = (float) $disk_free_space_mb;
     284    } else {
     285        $metrics['disk_free_space_mb'] = (float) \apply_filters('dfehc_disk_free_default_mb', \round((50 * 1024 * 1024 * 1024) / 1024 / 1024, 2));
     286        $sources['disk_free_space_mb'] = $toggles['disk'] ? 'skipped' : 'disabled';
     287    }
     288
     289    $metrics['sources'] = $sources;
    106290
    107291    $cached = $metrics;
     292    dfehc_cache_set($metrics_key, $metrics, $persist_ttl);
     293
    108294    return $metrics;
    109295}
    110296
    111 function dfehc_evaluate_database_health(array $metrics): array
    112 {
     297function dfehc_evaluate_database_health(array $metrics): array {
    113298    $severity = 'ok';
    114299    $conditions_met = 0;
    115300
    116301    $thresholds = [
    117         'revision' => 1000,
    118         'trash' => 1000,
    119         'transients' => 5000,
    120         'db_size' => 500,
     302        'revision'     => 1000,
     303        'trash'        => 1000,
     304        'transients'   => 5000,
     305        'db_size_mb'  => 500,
    121306        'disk_free_mb' => 10240,
    122307    ];
    123308
    124     if ($metrics['order_count'] > 10000) {
    125         $thresholds = array_merge($thresholds, [
    126             'revision' => 5000,
    127             'trash' => 5000,
     309    if (((int) ($metrics['order_count'] ?? 0)) > 10000) {
     310        $thresholds = \array_merge($thresholds, [
     311            'revision'   => 5000,
     312            'trash'      => 5000,
    128313            'transients' => 20000,
    129             'db_size' => 3000,
     314            'db_size_mb' => 3000,
    130315        ]);
    131316    }
    132317
    133     if ($metrics['product_count'] > 3000) {
    134         $thresholds = array_merge($thresholds, [
    135             'revision' => 6000,
    136             'trash' => 6000,
     318    if (((int) ($metrics['product_count'] ?? 0)) > 3000) {
     319        $thresholds = \array_merge($thresholds, [
     320            'revision'   => 6000,
     321            'trash'      => 6000,
    137322            'transients' => 30000,
    138             'db_size' => 3000,
     323            'db_size_mb' => 3000,
    139324        ]);
    140325    }
    141326
    142     if ($metrics['user_count'] > 10000) {
    143         $thresholds = array_merge($thresholds, [
    144             'revision' => 3000,
    145             'trash' => 3000,
     327    if (((int) ($metrics['user_count'] ?? 0)) > 10000) {
     328        $thresholds = \array_merge($thresholds, [
     329            'revision'   => 3000,
     330            'trash'      => 3000,
    146331            'transients' => 10000,
    147             'db_size' => 3000,
     332            'db_size_mb' => 3000,
    148333        ]);
    149334    }
    150335
    151     $thresholds = apply_filters('dfehc_database_health_thresholds', $thresholds, $metrics);
    152 
    153     if ($metrics['revision_count'] > $thresholds['revision']) $conditions_met++;
    154     if ($metrics['trashed_posts_count'] > $thresholds['trash']) $conditions_met++;
    155     if ($metrics['expired_transients_count'] > $thresholds['transients']) $conditions_met++;
    156     if ($metrics['db_size'] > $thresholds['db_size']) $conditions_met++;
    157     if ($metrics['disk_free_space_mb'] < $thresholds['disk_free_mb']) $conditions_met++;
     336    $thresholds = \apply_filters('dfehc_database_health_thresholds', $thresholds, $metrics);
     337
     338    if (((int) ($metrics['revision_count'] ?? 0)) > (int) ($thresholds['revision'] ?? 0)) $conditions_met++;
     339    if (((int) ($metrics['trashed_posts_count'] ?? 0)) > (int) ($thresholds['trash'] ?? 0)) $conditions_met++;
     340    if (((int) ($metrics['expired_transients_count'] ?? 0)) > (int) ($thresholds['transients'] ?? 0)) $conditions_met++;
     341    if (((float) ($metrics['db_size_mb'] ?? 0)) > (float) ($thresholds['db_size_mb'] ?? 0)) $conditions_met++;
     342    if (((float) ($metrics['disk_free_space_mb'] ?? \INF)) < (float) ($thresholds['disk_free_mb'] ?? 0)) $conditions_met++;
    158343
    159344    if ($conditions_met >= 3) {
     
    163348    }
    164349
    165     $color_map = apply_filters('dfehc_database_health_colors', [
    166         'ok' => '#00ff00',
    167         'warn' => '#ffff00',
    168         'critical' => '#ff0000',
     350    $color_map = \apply_filters('dfehc_database_health_colors', [
     351        'ok'       => '#1a7f37',
     352        'warn'     => '#9a6700',
     353        'critical' => '#d1242f',
    169354    ]);
    170355
    171356    return [
    172         'severity' => $severity,
    173         'status_color' => $color_map[$severity] ?? '#00ff00',
    174         'conditions_met' => $conditions_met,
    175         'thresholds' => $thresholds,
    176         'metrics' => $metrics,
     357        'severity'        => $severity,
     358        'status_color'    => $color_map[$severity] ?? '#1a7f37',
     359        'conditions_met'  => $conditions_met,
     360        'thresholds'      => $thresholds,
     361        'metrics'         => $metrics,
    177362    ];
    178363}
    179364
    180 function dfehc_get_database_health_status(): array
    181 {
     365function dfehc_get_database_health_status(): array {
    182366    $metrics = dfehc_gather_database_metrics();
    183367    return dfehc_evaluate_database_health($metrics);
    184368}
    185369
    186 function check_database_health_on_admin_page(): void
    187 {
    188     if (is_admin() && isset($_GET['page']) && $_GET['page'] === 'dfehc_plugin') {
    189         dfehc_get_database_health_status();
    190     }
    191 }
    192 add_action('admin_init', __NAMESPACE__ . '\\check_database_health_on_admin_page');
     370function check_database_health_on_admin_page(): void {
     371    if (!\is_admin()) return;
     372    if (!\current_user_can('manage_options')) return;
     373    $page = isset($_GET['page']) ? \sanitize_text_field((string) $_GET['page']) : '';
     374    if ($page !== 'dfehc_plugin') return;
     375    dfehc_get_database_health_status();
     376}
     377\add_action('admin_init', __NAMESPACE__ . '\\check_database_health_on_admin_page');
  • dynamic-front-end-heartbeat-control/trunk/defibrillator/load-estimator.php

    r3320647 r3396626  
    33
    44defined('ABSPATH') || exit;
     5
     6if (!defined('DFEHC_CACHE_GROUP')) {
     7    define('DFEHC_CACHE_GROUP', 'dfehc');
     8}
    59
    610class Dfehc_ServerLoadEstimator {
     
    812    const LOAD_CACHE_TRANSIENT      = 'dfehc_last_known_load';
    913    const LOAD_SPIKE_TRANSIENT      = 'dfehc_load_spike_score';
     14    const BASELINE_RESET_CD_PREFIX  = 'dfehc_baseline_reset_cd_';
    1015
    1116    public static function get_server_load(float $duration = 0.025) {
    12         if (!function_exists('microtime') || (defined('DFEHC_DISABLE_LOAD_ESTIMATION') && DFEHC_DISABLE_LOAD_ESTIMATION)) {
     17        if (\apply_filters('dfehc_disable_loop_estimator', false)) {
    1318            return false;
    1419        }
    15 
    16         $hostname   = self::get_hostname_key();
    17         $baseline_t = self::get_baseline_transient_name($hostname);
    18         $cache_ttl  = (int) apply_filters('dfehc_load_cache_ttl', 90);
    19         $cached     = get_transient(self::LOAD_CACHE_TRANSIENT);
    20 
    21         if ($cached !== false) {
     20        if (!\function_exists('microtime') || (\defined('DFEHC_DISABLE_LOAD_ESTIMATION') && DFEHC_DISABLE_LOAD_ESTIMATION)) {
     21            return false;
     22        }
     23        $duration = (float) \apply_filters('dfehc_loop_duration', $duration);
     24        if (!\is_finite($duration) || $duration <= 0.0) {
     25            $duration = 0.025;
     26        }
     27        if ($duration < 0.01) {
     28            $duration = 0.01;
     29        }
     30        if ($duration > 0.5) {
     31            $duration = 0.5;
     32        }
     33        $suffix    = self::scope_suffix();
     34        $baselineT = self::get_baseline_transient_name($suffix);
     35        $cacheTtl  = (int) \apply_filters('dfehc_load_cache_ttl', 90);
     36        $cacheKey  = self::get_cache_key($suffix);
     37        $cached = self::get_scoped_transient($cacheKey);
     38        if ($cached !== false && $cached !== null) {
    2239            return $cached;
    2340        }
    24 
    25         $sys_avg = self::try_sys_getloadavg();
    26         if ($sys_avg !== null) {
    27             set_transient(self::LOAD_CACHE_TRANSIENT, $sys_avg, $cache_ttl);
    28             return $sys_avg;
    29         }
    30 
    31         $baseline = self::get_baseline_value($baseline_t);
    32         if (!$baseline) {
    33             $baseline = self::maybe_calibrate($baseline_t, $duration);
    34         }
    35 
    36         $loops_per_sec = self::run_loop($duration);
    37         if ($loops_per_sec <= 0) {
     41        $sysAvg = self::try_sys_getloadavg();
     42        if ($sysAvg !== null) {
     43            self::set_scoped_transient_noautoload($cacheKey, $sysAvg, $cacheTtl);
     44            return $sysAvg;
     45        }
     46        $baseline = self::get_baseline_value($baselineT);
     47        if ($baseline === false || $baseline === null || !\is_numeric($baseline) || $baseline <= 0) {
     48            $baseline = self::maybe_calibrate($baselineT, $duration);
     49        }
     50        $loopsPerSec = self::run_loop_avg($duration);
     51        if ($loopsPerSec <= 0) {
    3852            return false;
    3953        }
    40 
    41         $load_ratio   = ($baseline * 0.125) / max($loops_per_sec, 1);
    42         $load_percent = round(min(100, max(0, $load_ratio * 100)), 2);
    43 
    44         self::update_spike_score($load_percent);
    45         set_transient(self::LOAD_CACHE_TRANSIENT, $load_percent, $cache_ttl);
    46 
    47         return $load_percent;
     54        $loadRatio   = ($baseline * 0.125) / \max($loopsPerSec, 1);
     55        $loadPercent = \round(\min(100.0, \max(0.0, $loadRatio * 100.0)), 2);
     56        $loadPercent = (float) \apply_filters('dfehc_computed_load_percent', $loadPercent, $baseline, $loopsPerSec);
     57        self::update_spike_score($loadPercent, $suffix);
     58        self::set_scoped_transient_noautoload($cacheKey, $loadPercent, $cacheTtl);
     59        return $loadPercent;
    4860    }
    4961
    5062    public static function calibrate_baseline(float $duration = 0.025): float {
    51         return self::run_loop($duration);
     63        $duration = (float) \apply_filters('dfehc_loop_duration', $duration);
     64        if (!\is_finite($duration) || $duration <= 0.0) {
     65            $duration = 0.025;
     66        }
     67        if ($duration < 0.01) {
     68            $duration = 0.01;
     69        }
     70        if ($duration > 0.5) {
     71            $duration = 0.5;
     72        }
     73        return self::run_loop_avg($duration);
     74    }
     75
     76    public static function maybe_calibrate_if_idle(): void {
     77        if (\is_admin() || \is_user_logged_in()) {
     78            return;
     79        }
     80        if ((\function_exists('wp_doing_cron') && \wp_doing_cron()) ||
     81            (\function_exists('wp_doing_ajax') && \wp_doing_ajax()) ||
     82            (\function_exists('wp_is_json_request') && \wp_is_json_request()) ||
     83            (\defined('WP_CLI') && WP_CLI)) {
     84            return;
     85        }
     86        $suffix  = self::scope_suffix();
     87        $seenKey = 'dfehc_seen_recently_' . $suffix;
     88        $ttl     = (int) \apply_filters('dfehc_seen_recently_ttl', 60);
     89        if (\get_transient($seenKey) !== false) {
     90            return;
     91        }
     92        self::set_transient_noautoload($seenKey, 1, $ttl);
     93        self::ensure_baseline();
    5294    }
    5395
    5496    public static function maybe_calibrate_during_cron(): void {
    55         if (!defined('DOING_CRON') || !DOING_CRON) {
    56             return;
    57         }
     97        if (!\function_exists('wp_doing_cron') || !\wp_doing_cron()) {
     98            return;
     99        }
     100        $suffix = self::scope_suffix();
     101        $cdKey  = 'dfehc_cron_cal_cd_' . $suffix;
     102        $cdTtl  = (int) \apply_filters('dfehc_cron_calibration_cooldown', 300);
     103        if (\get_transient($cdKey) !== false) {
     104            return;
     105        }
     106        self::set_transient_noautoload($cdKey, 1, $cdTtl);
    58107        self::ensure_baseline();
    59108    }
    60109
    61     public static function maybe_calibrate_if_idle(): void {
    62         if (is_admin() || is_user_logged_in()) {
    63             return;
    64         }
    65         self::ensure_baseline();
    66     }
    67 
    68110    private static function try_sys_getloadavg(): ?float {
    69         if (!function_exists('sys_getloadavg')) {
     111        if (!\function_exists('sys_getloadavg')) {
    70112            return null;
    71113        }
    72         $avg = sys_getloadavg();
    73         if (!is_array($avg) || !isset($avg[0])) {
     114        $avg = \sys_getloadavg();
     115        if (!\is_array($avg) || !isset($avg[0])) {
    74116            return null;
    75117        }
    76         $cores = function_exists('dfehc_get_cpu_cores') ? dfehc_get_cpu_cores() : 1;
     118        $raw = (float) $avg[0];
     119        $raw = (float) \apply_filters('dfehc_raw_sys_load', $raw);
     120        $cores = 0;
     121        if (\defined('DFEHC_CPU_CORES')) {
     122            $cores = (int) DFEHC_CPU_CORES;
     123        } elseif (\function_exists('\dfehc_get_cpu_cores')) {
     124            $cores = (int) \dfehc_get_cpu_cores();
     125        } elseif (\function_exists('dfehc_get_cpu_cores')) {
     126            $cores = (int) \dfehc_get_cpu_cores();
     127        }
     128        $cores = (int) \apply_filters('dfehc_cpu_cores', $cores);
    77129        if ($cores <= 0) {
    78130            $cores = 1;
    79131        }
    80         return min(100, round(($avg[0] / $cores) * 100, 2));
     132        return \min(100.0, \round(($raw / $cores) * 100.0, 2));
     133    }
     134
     135    private static function now(): float {
     136        return \function_exists('hrtime') ? (hrtime(true) / 1e9) : \microtime(true);
    81137    }
    82138
    83139    private static function run_loop(float $duration): float {
    84         $start = microtime(true);
     140        if ($duration <= 0.0) {
     141            return 0.0;
     142        }
     143        if ($duration < 0.01) {
     144            $duration = 0.01;
     145        }
     146        if ($duration > 0.5) {
     147            $duration = 0.5;
     148        }
     149        $duration += \mt_rand(0, 2) * 0.001;
     150        $warm = self::now();
     151        for ($i = 0; $i < 1000; $i++) { $warm += 0; }
     152        $start = self::now();
    85153        $end   = $start + $duration;
    86154        $cnt   = 0;
     155        $cap   = (int) \apply_filters('dfehc_loop_iteration_cap', 10000000);
    87156        $now   = $start;
    88         while ($now < $end) {
     157        while ($now < $end && $cnt < $cap) {
    89158            ++$cnt;
    90             $now = microtime(true);
     159            $now = self::now();
    91160        }
    92161        $elapsed = $now - $start;
     
    94163    }
    95164
    96     private static function maybe_calibrate(string $baseline_t, float $duration): float {
    97         $hostname  = self::get_hostname_key();
    98         $lock_key  = 'dfehc_calibrating_' . $hostname;
    99         $lock      = self::acquire_lock($lock_key, 30);
    100 
    101         $baseline = self::run_loop($duration);
     165    private static function run_loop_avg(float $duration): float {
     166        $a = self::run_loop($duration);
     167        $b = self::run_loop(\min(0.5, $duration * 1.5));
     168        if ($a <= 0.0 && $b <= 0.0) {
     169            return 0.0;
     170        }
     171        if ($a <= 0.0) return $b;
     172        if ($b <= 0.0) return $a;
     173        return ($a + $b) / 2.0;
     174    }
     175
     176    private static function maybe_calibrate(string $baselineT, float $duration): float {
     177        $suffix   = self::scope_suffix();
     178        $lockKey  = 'dfehc_calibrating_' . $suffix;
     179        $lock     = self::acquire_lock($lockKey, 30);
     180        $baseline = self::run_loop_avg($duration);
     181        if ($baseline <= 0.0) {
     182            $baseline = 1.0;
     183        }
    102184        if ($lock) {
    103             $exp = (int) apply_filters('dfehc_baseline_expiration', 7 * DAY_IN_SECONDS);
    104             self::set_baseline_value($baseline_t, $baseline, $exp);
    105             self::release_lock($lock, $lock_key);
     185            $exp = (int) \apply_filters('dfehc_baseline_expiration', 7 * DAY_IN_SECONDS);
     186            self::set_baseline_value($baselineT, $baseline, $exp);
     187            self::release_lock($lock, $lockKey);
    106188        }
    107189        return $baseline;
    108190    }
    109191
    110     private static function update_spike_score(float $load_percent): void {
    111         $score         = (float) get_transient(self::LOAD_SPIKE_TRANSIENT);
    112         $decay         = (float) apply_filters('dfehc_spike_decay', 0.5);
    113         $increment     = (float) apply_filters('dfehc_spike_increment', 1.0);
    114         $threshold     = (float) apply_filters('dfehc_spike_threshold', 3.0);
    115         $hostname      = self::get_hostname_key();
    116         $baseline_name = self::get_baseline_transient_name($hostname);
    117 
    118         if ($load_percent > 90) {
    119             $score += $increment;
    120         } else {
    121             $score = max(0.0, $score - $decay);
    122         }
    123 
     192    private static function update_spike_score(float $loadPercent, string $suffix): void {
     193        $spikeKey   = self::get_spike_key($suffix);
     194        $score      = (float) self::get_scoped_transient($spikeKey);
     195        $decay      = (float) \apply_filters('dfehc_spike_decay', 0.5);
     196        $increment  = (float) \apply_filters('dfehc_spike_increment', 1.0);
     197        $threshold  = (float) \apply_filters('dfehc_spike_threshold', 3.0);
     198        $trigger    = (float) \apply_filters('dfehc_spike_trigger', 90.0);
     199        $resetCdTtl = (int) \apply_filters('dfehc_baseline_reset_cooldown', 3600);
     200        $resetCdKey = self::BASELINE_RESET_CD_PREFIX . $suffix;
     201        $scoreMax   = (float) \apply_filters('dfehc_spike_score_max', 20.0);
     202        if ($loadPercent > $trigger) {
     203            $score += $increment * (1 + (($loadPercent - $trigger) / 20.0));
     204        } else {
     205            $score = \max(0.0, $score - $decay);
     206        }
     207        $score = \min($scoreMax, \max(0.0, $score));
    124208        if ($score >= $threshold) {
    125             self::delete_baseline_value($baseline_name);
    126             delete_transient(self::LOAD_SPIKE_TRANSIENT);
    127         } else {
    128             set_transient(self::LOAD_SPIKE_TRANSIENT, $score, HOUR_IN_SECONDS);
    129         }
     209            if (\get_transient($resetCdKey) === false) {
     210                $baselineName = self::get_baseline_transient_name($suffix);
     211                self::delete_baseline_value($baselineName);
     212                self::set_transient_noautoload($resetCdKey, 1, $resetCdTtl);
     213                self::delete_scoped_transient($spikeKey);
     214                return;
     215            }
     216        }
     217        self::set_scoped_transient_noautoload($spikeKey, $score, (int) \apply_filters('dfehc_spike_score_ttl', HOUR_IN_SECONDS));
    130218    }
    131219
    132220    private static function ensure_baseline(): void {
    133         $hostname   = self::get_hostname_key();
    134         $baseline_t = self::get_baseline_transient_name($hostname);
    135         if (self::get_baseline_value($baseline_t)) {
    136             return;
    137         }
    138         $lock_key = 'dfehc_calibrating_' . $hostname;
    139         $lock     = self::acquire_lock($lock_key, 30);
     221        $suffix    = self::scope_suffix();
     222        $baselineT = self::get_baseline_transient_name($suffix);
     223        $existing  = self::get_baseline_value($baselineT);
     224        if ($existing !== false && $existing !== null && \is_numeric($existing) && $existing > 0) {
     225            return;
     226        }
     227        $lockKey = 'dfehc_calibrating_' . $suffix;
     228        $lock    = self::acquire_lock($lockKey, 30);
    140229        if (!$lock) {
    141230            return;
    142231        }
    143         $baseline = self::run_loop(0.025);
    144         $exp      = (int) apply_filters('dfehc_baseline_expiration', 7 * DAY_IN_SECONDS);
    145         self::set_baseline_value($baseline_t, $baseline, $exp);
    146         self::release_lock($lock, $lock_key);
     232        $duration = (float) \apply_filters('dfehc_loop_duration', 0.025);
     233        if (!\is_finite($duration) || $duration <= 0.0) {
     234            $duration = 0.025;
     235        }
     236        if ($duration < 0.01) {
     237            $duration = 0.01;
     238        }
     239        if ($duration > 0.5) {
     240            $duration = 0.5;
     241        }
     242        $baseline = self::run_loop_avg($duration);
     243        if ($baseline <= 0.0) {
     244            $baseline = 1.0;
     245        }
     246        $exp = (int) \apply_filters('dfehc_baseline_expiration', 7 * DAY_IN_SECONDS);
     247        self::set_baseline_value($baselineT, $baseline, $exp);
     248        self::release_lock($lock, $lockKey);
    147249    }
    148250
    149251    private static function acquire_lock(string $key, int $ttl) {
    150         if (class_exists('WP_Lock')) {
     252        if (\class_exists('\WP_Lock')) {
    151253            $lock = new \WP_Lock($key, $ttl);
    152254            return $lock->acquire() ? $lock : null;
    153255        }
    154         return wp_cache_add($key, 1, $ttl) ? (object) ['key' => $key] : null;
     256        if (\function_exists('wp_cache_add') && \wp_cache_add($key, 1, DFEHC_CACHE_GROUP, $ttl)) {
     257            return (object) ['type' => 'cache', 'key' => $key];
     258        }
     259        if (\get_transient($key) !== false) {
     260            return null;
     261        }
     262        if (\set_transient($key, 1, $ttl)) {
     263            return (object) ['type' => 'transient', 'key' => $key];
     264        }
     265        return null;
    155266    }
    156267
     
    158269        if ($lock instanceof \WP_Lock) {
    159270            $lock->release();
    160         } else {
    161             wp_cache_delete($key);
    162         }
    163     }
    164 
    165     private static function get_baseline_transient_name(string $hostname): string {
    166         return self::BASELINE_TRANSIENT_PREFIX . $hostname;
     271            return;
     272        }
     273        if (\is_object($lock) && isset($lock->type, $lock->key)) {
     274            if ($lock->type === 'cache') {
     275                \wp_cache_delete($key, DFEHC_CACHE_GROUP);
     276                return;
     277            }
     278            if ($lock->type === 'transient') {
     279                \delete_transient($key);
     280                return;
     281            }
     282        }
     283    }
     284
     285    private static function get_baseline_transient_name(string $suffix): string {
     286        return self::BASELINE_TRANSIENT_PREFIX . $suffix;
    167287    }
    168288
    169289    private static function get_hostname_key(): string {
    170         return substr(md5(php_uname('n')), 0, 10);
     290        $host = @\php_uname('n');
     291        if (!$host) {
     292            $url = \defined('WP_HOME') && WP_HOME ? WP_HOME : (\function_exists('home_url') ? \home_url() : '');
     293            $parts = \parse_url((string) $url);
     294            $host = $parts['host'] ?? ($url ?: 'unknown');
     295        }
     296        $salt = \defined('DB_NAME') ? (string) DB_NAME : '';
     297        return \substr(\md5((string) $host . $salt), 0, 10);
     298    }
     299
     300    private static function get_blog_id(): int {
     301        return \function_exists('get_current_blog_id') ? (int) \get_current_blog_id() : 0;
     302    }
     303
     304    private static function scope_suffix(): string {
     305        $sapi = \php_sapi_name();
     306        $sapiTag = $sapi ? \substr(\preg_replace('/[^a-z0-9]/i', '', strtolower($sapi)), 0, 6) : 'web';
     307        $suffix = self::get_hostname_key() . '_' . self::get_blog_id() . '_' . $sapiTag;
     308        $override = \apply_filters('dfehc_baseline_scope_suffix', null, $suffix);
     309        if (\is_string($override) && $override !== '') {
     310            return $override;
     311        }
     312        return $suffix;
     313    }
     314
     315    private static function get_cache_key(string $suffix): string {
     316        return self::LOAD_CACHE_TRANSIENT . '_' . $suffix;
     317    }
     318
     319    private static function get_spike_key(string $suffix): string {
     320        return self::LOAD_SPIKE_TRANSIENT . '_' . $suffix;
    171321    }
    172322
    173323    private static function get_baseline_value(string $name) {
    174         return is_multisite() ? get_site_transient($name) : get_transient($name);
     324        return \is_multisite() ? \get_site_transient($name) : \get_transient($name);
    175325    }
    176326
    177327    private static function set_baseline_value(string $name, $value, int $exp): void {
    178         if (is_multisite()) {
    179             set_site_transient($name, $value, $exp);
    180         } else {
    181             set_transient($name, $value, $exp);
     328        if (\is_multisite()) {
     329            self::set_site_transient_noautoload($name, $value, $exp);
     330        } else {
     331            self::set_transient_noautoload($name, $value, $exp);
    182332        }
    183333    }
    184334
    185335    private static function delete_baseline_value(string $name): void {
    186         if (is_multisite()) {
    187             delete_site_transient($name);
    188         } else {
    189             delete_transient($name);
    190         }
     336        if (\is_multisite()) {
     337            \delete_site_transient($name);
     338        } else {
     339            \delete_transient($name);
     340        }
     341    }
     342
     343    private static function get_scoped_transient(string $key) {
     344        return \is_multisite() ? \get_site_transient($key) : \get_transient($key);
     345    }
     346
     347    private static function set_scoped_transient_noautoload(string $key, $value, int $ttl): void {
     348        if (\is_multisite()) {
     349            self::set_site_transient_noautoload($key, $value, $ttl);
     350        } else {
     351            self::set_transient_noautoload($key, $value, $ttl);
     352        }
     353    }
     354
     355    private static function delete_scoped_transient(string $key): void {
     356        if (\is_multisite()) {
     357            \delete_site_transient($key);
     358        } else {
     359            \delete_transient($key);
     360        }
     361    }
     362
     363    private static function set_transient_noautoload(string $key, $value, int $ttl): void {
     364        $jitter = (\function_exists('random_int') ? \random_int(0, 5) : 0);
     365        $ttl = \max(1, $ttl + $jitter);
     366        if (\function_exists('wp_using_ext_object_cache') && \wp_using_ext_object_cache()) {
     367            \wp_cache_set($key, $value, DFEHC_CACHE_GROUP, $ttl);
     368            return;
     369        }
     370        \set_transient($key, $value, $ttl);
     371        global $wpdb;
     372        if (!isset($wpdb->options)) {
     373            return;
     374        }
     375        $opt_key = "_transient_$key";
     376        $opt_key_to = "_transient_timeout_$key";
     377        $wpdb->suppress_errors(true);
     378        $autoload = $wpdb->get_var($wpdb->prepare("SELECT autoload FROM {$wpdb->options} WHERE option_name=%s LIMIT 1", $opt_key));
     379        if ($autoload === 'yes') {
     380            $wpdb->update($wpdb->options, ['autoload' => 'no'], ['option_name' => $opt_key, 'autoload' => 'yes'], ['%s'], ['%s','%s']);
     381        }
     382        $autoload_to = $wpdb->get_var($wpdb->prepare("SELECT autoload FROM {$wpdb->options} WHERE option_name=%s LIMIT 1", $opt_key_to));
     383        if ($autoload_to === 'yes') {
     384            $wpdb->update($wpdb->options, ['autoload' => 'no'], ['option_name' => $opt_key_to, 'autoload' => 'yes'], ['%s'], ['%s','%s']);
     385        }
     386        $wpdb->suppress_errors(false);
     387    }
     388
     389    private static function set_site_transient_noautoload(string $key, $value, int $ttl): void {
     390        $jitter = (\function_exists('random_int') ? \random_int(0, 5) : 0);
     391        $ttl = \max(1, $ttl + $jitter);
     392        if (\function_exists('wp_using_ext_object_cache') && \wp_using_ext_object_cache()) {
     393            \wp_cache_set($key, $value, DFEHC_CACHE_GROUP, $ttl);
     394            return;
     395        }
     396        \set_site_transient($key, $value, $ttl);
    191397    }
    192398}
    193399
    194 add_action('init', [Dfehc_ServerLoadEstimator::class, 'maybe_calibrate_during_cron']);
    195 add_action('template_redirect', [Dfehc_ServerLoadEstimator::class, 'maybe_calibrate_if_idle']);
    196 
    197 add_filter('heartbeat_settings', function ($settings) {
    198     if (!class_exists(Dfehc_ServerLoadEstimator::class)) {
     400\add_action('init', [Dfehc_ServerLoadEstimator::class, 'maybe_calibrate_during_cron']);
     401\add_action('template_redirect', [Dfehc_ServerLoadEstimator::class, 'maybe_calibrate_if_idle']);
     402
     403\add_filter('heartbeat_settings', function ($settings) {
     404    if (!\class_exists(Dfehc_ServerLoadEstimator::class)) {
    199405        return $settings;
    200406    }
    201 
    202407    $load = Dfehc_ServerLoadEstimator::get_server_load();
    203408    if ($load === false) {
    204409        return $settings;
    205410    }
    206 
    207     $ths = apply_filters('dfehc_heartbeat_thresholds', [
     411    $ths = \wp_parse_args(\apply_filters('dfehc_heartbeat_thresholds', []), [
    208412        'low'    => 20,
    209413        'medium' => 50,
    210414        'high'   => 75,
    211415    ]);
    212 
    213     if (!is_admin() && !current_user_can('edit_posts')) {
    214         $settings['interval'] = $load < $ths['low'] ? 50 : ($load < $ths['medium'] ? 60 : ($load < $ths['high'] ? 120 : 180));
    215     } elseif (current_user_can('editor')) {
    216         $settings['interval'] = $load < $ths['high'] ? 30 : 60;
    217     } elseif (current_user_can('administrator')) {
    218         $settings['interval'] = $load < $ths['high'] ? 20 : 40;
    219     }
    220 
     416    $suggested = null;
     417    if (!\is_admin() && !\current_user_can('edit_posts')) {
     418        $suggested = $load < $ths['low'] ? 50 : ($load < $ths['medium'] ? 60 : ($load < $ths['high'] ? 120 : 180));
     419    } elseif (\current_user_can('manage_options')) {
     420        $suggested = $load < $ths['high'] ? 20 : 40;
     421    } elseif (\current_user_can('edit_others_posts')) {
     422        $suggested = $load < $ths['high'] ? 30 : 60;
     423    }
     424    if ($suggested !== null) {
     425        $suggested = (int) \min(\max($suggested, 15), 300);
     426        if (isset($settings['interval']) && \is_numeric($settings['interval'])) {
     427            $current = (int) $settings['interval'];
     428            $settings['interval'] = (int) \min(\max(\max($current, $suggested), 15), 300);
     429        } else {
     430            $settings['interval'] = $suggested;
     431        }
     432    }
    221433    return $settings;
    222 });
     434}, 5);
  • dynamic-front-end-heartbeat-control/trunk/defibrillator/rest-api.php

    r3287827 r3396626  
    22namespace DynamicHeartbeat;
    33
    4 defined('ABSPATH') or die();
    5 
    6 class DfehcUncloggerRestApi {
    7     public function __construct() {
    8         add_action('rest_api_init', [$this, 'register_routes']);
    9     }
    10 
    11     public function register_routes() {
    12         register_rest_route('dfehc-unclogger/v1', '/woocommerce-transients/count/', [
    13             'methods' => 'GET',
    14             'callback' => [$this, 'count_woocommerce_transients'],
    15             'permission_callback' => [$this, 'permission_check'],
     4if (!\defined('ABSPATH')) {
     5    exit;
     6}
     7
     8class DfehcUncloggerRestApi
     9{
     10    public function __construct()
     11    {
     12        \add_action('rest_api_init', [$this, 'register_routes']);
     13    }
     14
     15    public function register_routes(): void
     16    {
     17        \register_rest_route(
     18            'dfehc-unclogger/v1',
     19            '/woocommerce-transients/count',
     20            [
     21                'methods'             => \WP_REST_Server::READABLE,
     22                'callback'            => [$this, 'count_woocommerce_transients'],
     23                'permission_callback' => [$this, 'permission_check'],
     24                'args'                => [
     25                    'network' => [
     26                        'type'        => 'boolean',
     27                        'required'    => false,
     28                        'description' => 'When true on multisite, count across sites (optionally filtered by "sites").',
     29                    ],
     30                    'sites' => [
     31                        'type'        => 'array',
     32                        'items'       => ['type' => 'integer'],
     33                        'required'    => false,
     34                        'description' => 'Optional list of site IDs to include when network=true. Also accepts comma-separated string.',
     35                    ],
     36                ],
     37            ]
     38        );
     39
     40        \register_rest_route(
     41            'dfehc-unclogger/v1',
     42            '/woocommerce-transients/delete',
     43            [
     44                'methods'             => \WP_REST_Server::CREATABLE,
     45                'callback'            => [$this, 'delete_woocommerce_transients'],
     46                'permission_callback' => [$this, 'permission_check'],
     47                'args'                => [
     48                    'network' => [
     49                        'type'        => 'boolean',
     50                        'required'    => false,
     51                        'description' => 'Run across all sites (multisite only, super admins only).',
     52                    ],
     53                    'sites' => [
     54                        'type'        => 'array',
     55                        'items'       => ['type' => 'integer'],
     56                        'required'    => false,
     57                        'description' => 'Optional list of site IDs to include when network=true. Also accepts comma-separated string.',
     58                    ],
     59                    'allow_sql' => [
     60                        'type'        => 'boolean',
     61                        'required'    => false,
     62                        'description' => 'Opt-in to SQL fallback (still requires dfehc_unclogger_allow_sql_delete filter to allow).',
     63                    ],
     64                    'dry_run' => [
     65                        'type'        => 'boolean',
     66                        'required'    => false,
     67                        'description' => 'If true, estimates what would be deleted without performing deletion.',
     68                    ],
     69                    'limit' => [
     70                        'type'        => 'integer',
     71                        'required'    => false,
     72                        'description' => 'Per-chunk DELETE limit for SQL fallback.',
     73                    ],
     74                    'time_budget' => [
     75                        'type'        => 'number',
     76                        'required'    => false,
     77                        'description' => 'Maximum seconds to spend in SQL fallback loop.',
     78                    ],
     79                ],
     80            ]
     81        );
     82
     83        \register_rest_route(
     84            'dfehc-unclogger/v1',
     85            '/woocommerce-cache/clear',
     86            [
     87                'methods'             => \WP_REST_Server::CREATABLE,
     88                'callback'            => [$this, 'clear_woocommerce_cache'],
     89                'permission_callback' => [$this, 'permission_check'],
     90                'args'                => [
     91                    'network' => [
     92                        'type'        => 'boolean',
     93                        'required'    => false,
     94                        'description' => 'Run across all sites (multisite only, super admins only).',
     95                    ],
     96                    'sites' => [
     97                        'type'        => 'array',
     98                        'items'       => ['type' => 'integer'],
     99                        'required'    => false,
     100                        'description' => 'Optional list of site IDs to include when network=true. Also accepts comma-separated string.',
     101                    ],
     102                    'allow_sql' => [
     103                        'type'        => 'boolean',
     104                        'required'    => false,
     105                        'description' => 'Opt-in to SQL fallback (still requires dfehc_unclogger_allow_sql_delete filter to allow).',
     106                    ],
     107                    'dry_run' => [
     108                        'type'        => 'boolean',
     109                        'required'    => false,
     110                        'description' => 'If true, estimates what would be cleared without performing deletion.',
     111                    ],
     112                    'limit' => [
     113                        'type'        => 'integer',
     114                        'required'    => false,
     115                        'description' => 'Per-chunk DELETE limit for SQL fallback.',
     116                    ],
     117                    'time_budget' => [
     118                        'type'        => 'number',
     119                        'required'    => false,
     120                        'description' => 'Maximum seconds to spend in SQL fallback loop.',
     121                    ],
     122                ],
     123            ]
     124        );
     125    }
     126
     127    public function permission_check(\WP_REST_Request $request)
     128{
     129    $allowed = (bool) \apply_filters('dfehc_unclogger_allow_rest', true, $request);
     130    if (!$allowed) {
     131        return new \WP_Error('dfehc_rest_disabled', 'DFEHC REST endpoints are disabled.', ['status' => 403]);
     132    }
     133
     134    $required_caps = (array) \apply_filters('dfehc_unclogger_required_capabilities', ['manage_options', 'manage_woocommerce'], $request);
     135    $can_manage = false;
     136    foreach ($required_caps as $cap) {
     137        if (\current_user_can($cap)) {
     138            $can_manage = true;
     139            break;
     140        }
     141    }
     142    if (!$can_manage) {
     143        return new \WP_Error('dfehc_forbidden', 'You are not allowed to access this endpoint.', ['status' => 403]);
     144    }
     145
     146    $network = (bool) $this->get_bool_param($request, 'network');
     147    if ($network) {
     148        if (!\is_multisite() || !\is_super_admin()) {
     149            return new \WP_Error('dfehc_network_forbidden', 'Network-wide action requires super admin on multisite.', ['status' => 403]);
     150        }
     151    }
     152
     153    if ((bool) \apply_filters('dfehc_unclogger_rest_rate_limit_enable', true, $request)) {
     154        $limit  = (int) \apply_filters('dfehc_unclogger_rest_rate_limit', 60, $request);
     155        $window = (int) \apply_filters('dfehc_unclogger_rest_rate_window', 60, $request);
     156        $user_id = (int) \get_current_user_id();
     157        $ip = (string) ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
     158        $key = $this->scoped_key('dfehc_unclogger_rl_' . ($user_id ? 'u' . $user_id : 'ip' . \md5($ip)));
     159        $cnt = (int) \get_transient($key);
     160        if ($cnt >= $limit) {
     161            return new \WP_Error('dfehc_rate_limited', 'Rate limited.', ['status' => 429]);
     162        }
     163        \set_transient($key, $cnt + 1, $window);
     164    }
     165
     166    return true;
     167}
     168
     169    public function count_woocommerce_transients(\WP_REST_Request $request): \WP_REST_Response
     170    {
     171        if (!$this->has_woocommerce()) {
     172            return \rest_ensure_response([
     173                'available'      => false,
     174                'reason'         => 'woocommerce_not_active',
     175                'can_estimate'   => null,
     176                'count'          => null,
     177            ]);
     178        }
     179
     180        if (\wp_using_ext_object_cache()) {
     181            return \rest_ensure_response([
     182                'available'      => true,
     183
     184                'reason'         => 'persistent_object_cache',
     185                'can_estimate'   => false,
     186                'count'          => null,
     187            ]);
     188        }
     189
     190        $network = (bool) $this->get_bool_param($request, 'network');
     191        $sites   = $this->parse_sites_param($request);
     192
     193        if ($network && \is_multisite()) {
     194            $ids = $sites ?: \get_sites(['fields' => 'ids']);
     195            $total = 0;
     196            foreach ($ids as $blog_id) {
     197                $switched = false;
     198                \switch_to_blog((int) $blog_id);
     199                $switched = true;
     200                try {
     201                    $total += $this->count_wc_transients_for_site();
     202                } finally {
     203                    if ($switched) { \restore_current_blog(); }
     204                }
     205            }
     206            return \rest_ensure_response([
     207                'available'      => true,
     208                'reason'         => 'ok',
     209                'can_estimate'   => true,
     210                'count'          => (int) $total,
     211                'network'        => true,
     212                'sites_counted'  => \count($ids),
     213            ]);
     214        }
     215
     216        $count = $this->count_wc_transients_for_site();
     217        return \rest_ensure_response([
     218            'available'      => true,
     219            'reason'         => 'ok',
     220            'can_estimate'   => true,
     221            'count'          => (int) $count,
     222            'network'        => false,
    16223        ]);
    17 
    18         register_rest_route('dfehc-unclogger/v1', '/woocommerce-transients/delete/', [
    19             'methods' => 'POST',
    20             'callback' => [$this, 'delete_woocommerce_transients'],
    21             'permission_callback' => [$this, 'permission_check'],
     224    }
     225
     226    public function delete_woocommerce_transients(\WP_REST_Request $request): \WP_REST_Response
     227    {
     228        $network = (bool) $this->get_bool_param($request, 'network');
     229        $sites   = $this->parse_sites_param($request);
     230
     231        $allow_sql_req = (bool) $this->get_bool_param($request, 'allow_sql');
     232        $allow_sql_filter = (bool) \apply_filters('dfehc_unclogger_allow_sql_delete', false, $request);
     233        $allow_sql = $allow_sql_req && $allow_sql_filter;
     234
     235        $dry_run = (bool) $this->get_bool_param($request, 'dry_run');
     236        $limit_default = (int) \apply_filters('dfehc_unclogger_sql_delete_limit', 500);
     237        $time_default  = (float) \apply_filters('dfehc_unclogger_sql_delete_time_budget', 2.5);
     238        $limit = (int) ($request->get_param('limit') ?? $limit_default);
     239        if ($limit <= 0) { $limit = $limit_default; }
     240        $time_budget = (float) ($request->get_param('time_budget') ?? $time_default);
     241        if ($time_budget <= 0) { $time_budget = $time_default; }
     242
     243        $results = $this->run_per_site($network, $sites, function (int $site_id) use ($allow_sql, $limit, $time_budget, $dry_run): array {
     244            return $this->delete_wc_transients_for_site($allow_sql, $limit, $time_budget, $dry_run);
     245        });
     246
     247        return \rest_ensure_response($results + [
     248            'network'     => (bool) $network,
     249            'action'      => 'delete_transients',
     250            'allow_sql'   => (bool) $allow_sql,
     251            'dry_run'     => (bool) $dry_run,
     252            'limit'       => (int) $limit,
     253            'time_budget' => (float) $time_budget,
    22254        ]);
    23 
    24         register_rest_route('dfehc-unclogger/v1', '/woocommerce-cache/clear/', [
    25             'methods' => 'POST',
    26             'callback' => [$this, 'clear_woocommerce_cache'],
    27             'permission_callback' => [$this, 'permission_check'],
     255    }
     256
     257    public function clear_woocommerce_cache(\WP_REST_Request $request): \WP_REST_Response
     258    {
     259        $network = (bool) $this->get_bool_param($request, 'network');
     260        $sites   = $this->parse_sites_param($request);
     261
     262        $allow_sql_req = (bool) $this->get_bool_param($request, 'allow_sql');
     263        $allow_sql_filter = (bool) \apply_filters('dfehc_unclogger_allow_sql_delete', false, $request);
     264        $allow_sql = $allow_sql_req && $allow_sql_filter;
     265
     266        $dry_run = (bool) $this->get_bool_param($request, 'dry_run');
     267        $limit_default = (int) \apply_filters('dfehc_unclogger_sql_delete_limit', 500);
     268        $time_default  = (float) \apply_filters('dfehc_unclogger_sql_delete_time_budget', 2.5);
     269        $limit = (int) ($request->get_param('limit') ?? $limit_default);
     270        if ($limit <= 0) { $limit = $limit_default; }
     271        $time_budget = (float) ($request->get_param('time_budget') ?? $time_default);
     272        if ($time_budget <= 0) { $time_budget = $time_default; }
     273
     274        $results = $this->run_per_site($network, $sites, function (int $site_id) use ($allow_sql, $limit, $time_budget, $dry_run): array {
     275            return $this->clear_wc_cache_for_site($allow_sql, $limit, $time_budget, $dry_run);
     276        });
     277
     278        return \rest_ensure_response($results + [
     279            'network'     => (bool) $network,
     280            'action'      => 'clear_cache',
     281            'allow_sql'   => (bool) $allow_sql,
     282            'dry_run'     => (bool) $dry_run,
     283            'limit'       => (int) $limit,
     284            'time_budget' => (float) $time_budget,
    28285        ]);
    29286    }
    30287
    31     public function permission_check() {
    32         return current_user_can('manage_options');
    33     }
    34 
    35     public function count_woocommerce_transients() {
     288    protected function has_woocommerce(): bool
     289    {
     290        return \class_exists('\WC_Cache_Helper') || \function_exists('\wc');
     291    }
     292
     293    protected function run_per_site(bool $network, array $sites, callable $work): array
     294    {
     295        $sites_processed = 0;
     296        $cleared_via_all = [];
     297        $fallback_used = false;
     298        $deleted_transients_total = 0;
     299        $deleted_timeouts_total   = 0;
     300        $errors_all = [];
     301
     302        if ($network && \is_multisite()) {
     303            $ids = $sites ?: \get_sites(['fields' => 'ids']);
     304            foreach ($ids as $blog_id) {
     305                $switched = false;
     306                \switch_to_blog((int) $blog_id);
     307                $switched = true;
     308                try {
     309                    $res = (array) $work((int) $blog_id);
     310                    $sites_processed++;
     311                    $cleared_via_all = \array_merge($cleared_via_all, (array) ($res['cleared_via'] ?? []));
     312                    $fallback_used = $fallback_used || !empty($res['fallback_used']);
     313                    $deleted_transients_total += (int) ($res['deleted_transients'] ?? 0);
     314                    $deleted_timeouts_total   += (int) ($res['deleted_timeouts'] ?? 0);
     315                    if (!empty($res['errors']) && \is_array($res['errors'])) {
     316                        $errors_all = \array_merge($errors_all, $res['errors']);
     317                    }
     318                } finally {
     319                    if ($switched) { \restore_current_blog(); }
     320                }
     321            }
     322        } else {
     323            $res = (array) $work(\get_current_blog_id() ? (int) \get_current_blog_id() : 0);
     324            $sites_processed = 1;
     325            $cleared_via_all = \array_merge($cleared_via_all, (array) ($res['cleared_via'] ?? []));
     326            $fallback_used = !empty($res['fallback_used']);
     327            $deleted_transients_total += (int) ($res['deleted_transients'] ?? 0);
     328            $deleted_timeouts_total   += (int) ($res['deleted_timeouts'] ?? 0);
     329            if (!empty($res['errors']) && \is_array($res['errors'])) {
     330                $errors_all = \array_merge($errors_all, $res['errors']);
     331            }
     332        }
     333
     334        return [
     335            'sites_processed'        => (int) $sites_processed,
     336            'cleared_via'            => \array_values(\array_unique(\array_filter($cleared_via_all, 'strlen'))),
     337            'fallback_used'          => (bool) $fallback_used,
     338            'deleted_transients'     => (int) $deleted_transients_total,
     339            'deleted_timeouts'       => (int) $deleted_timeouts_total,
     340            'errors'                 => \array_values(\array_unique(\array_filter($errors_all, 'strlen'))),
     341        ];
     342    }
     343
     344    protected function delete_wc_transients_for_site(bool $allow_sql, int $limit, float $time_budget, bool $dry_run): array
     345    {
     346        if (!$this->has_woocommerce()) {
     347            return ['cleared_via' => ['no_woocommerce'], 'fallback_used' => false, 'deleted_transients' => 0, 'deleted_timeouts' => 0, 'errors' => []];
     348        }
     349
     350        $cleared_via = [];
     351        $errors = [];
     352        $deleted_transients = 0;
     353        $deleted_timeouts   = 0;
     354        $used_fallback = false;
     355
     356        if (\function_exists('\wc_delete_product_transients')) {
     357            if ($dry_run) {
     358                $cleared_via[] = 'wc_delete_product_transients(dry_run)';
     359            } else {
     360                try { \wc_delete_product_transients(); $cleared_via[] = 'wc_delete_product_transients'; } catch (\Throwable $e) { $errors[] = 'wc_delete_product_transients: ' . $e->getMessage(); }
     361            }
     362        }
     363
     364        if (\function_exists('\wc_delete_expired_transients')) {
     365            if ($dry_run) {
     366                $cleared_via[] = 'wc_delete_expired_transients(dry_run)';
     367            } else {
     368                try { \wc_delete_expired_transients(); $cleared_via[] = 'wc_delete_expired_transients'; } catch (\Throwable $e) { $errors[] = 'wc_delete_expired_transients: ' . $e->getMessage(); }
     369            }
     370        }
     371
     372        if (\class_exists('\WC_Cache_Helper')) {
     373            foreach (['product','shipping','orders'] as $group) {
     374                if ($dry_run) {
     375                    $cleared_via[] = "bump:{$group}(dry_run)";
     376                } else {
     377                    try { \WC_Cache_Helper::get_transient_version($group, true); $cleared_via[] = "bump:{$group}"; } catch (\Throwable $e) { $errors[] = "bump:{$group}: " . $e->getMessage(); }
     378                }
     379            }
     380        }
     381
     382        if (!$cleared_via || $allow_sql) {
     383            if ($allow_sql && !$dry_run) {
     384                $used_fallback = true;
     385                $res = $this->chunked_sql_delete($limit, $time_budget, false);
     386                $deleted_transients += (int) $res['deleted_transients'];
     387                $deleted_timeouts   += (int) $res['deleted_timeouts'];
     388                $cleared_via[] = 'sql_chunk_delete';
     389            } elseif ($allow_sql && $dry_run) {
     390                $used_fallback = true;
     391                $res = $this->chunked_sql_delete($limit, $time_budget, true);
     392                $deleted_transients += (int) $res['deleted_transients'];
     393                $deleted_timeouts   += (int) $res['deleted_timeouts'];
     394                $cleared_via[] = 'sql_chunk_delete(dry_run)';
     395            } elseif (!$cleared_via) {
     396                $cleared_via[] = 'noop';
     397            }
     398        }
     399
     400        return [
     401            'cleared_via'        => \array_values(\array_unique($cleared_via)),
     402            'fallback_used'      => (bool) $used_fallback,
     403            'deleted_transients' => (int) $deleted_transients,
     404            'deleted_timeouts'   => (int) $deleted_timeouts,
     405            'errors'             => \array_values(\array_unique($errors)),
     406        ];
     407    }
     408
     409    protected function clear_wc_cache_for_site(bool $allow_sql, int $limit, float $time_budget, bool $dry_run): array
     410    {
     411        if (!$this->has_woocommerce()) {
     412            return ['cleared_via' => ['no_woocommerce'], 'fallback_used' => false, 'deleted_transients' => 0, 'deleted_timeouts' => 0, 'errors' => []];
     413        }
     414
     415        $cleared_via = [];
     416        $errors = [];
     417        $deleted_transients = 0;
     418        $deleted_timeouts   = 0;
     419        $used_fallback = false;
     420
     421        if (\class_exists('\WC_Cache_Helper')) {
     422            foreach (['product','shipping','orders'] as $group) {
     423                if ($dry_run) {
     424                    $cleared_via[] = "bump:{$group}(dry_run)";
     425                } else {
     426                    try { \WC_Cache_Helper::get_transient_version($group, true); $cleared_via[] = "bump:{$group}"; } catch (\Throwable $e) { $errors[] = "bump:{$group}: " . $e->getMessage(); }
     427                }
     428            }
     429        }
     430
     431        if (\function_exists('\wc_delete_product_transients')) {
     432            if ($dry_run) {
     433                $cleared_via[] = 'wc_delete_product_transients(dry_run)';
     434            } else {
     435                try { \wc_delete_product_transients(); $cleared_via[] = 'wc_delete_product_transients'; } catch (\Throwable $e) { $errors[] = 'wc_delete_product_transients: ' . $e->getMessage(); }
     436            }
     437        }
     438
     439        if (\function_exists('\wc_delete_expired_transients')) {
     440            if ($dry_run) {
     441                $cleared_via[] = 'wc_delete_expired_transients(dry_run)';
     442            } else {
     443                try { \wc_delete_expired_transients(); $cleared_via[] = 'wc_delete_expired_transients'; } catch (\Throwable $e) { $errors[] = 'wc_delete_expired_transients: ' . $e->getMessage(); }
     444            }
     445        }
     446
     447        if ($allow_sql) {
     448            if ($dry_run) {
     449                $used_fallback = true;
     450                $res = $this->chunked_sql_delete($limit, $time_budget, true);
     451                $deleted_transients += (int) $res['deleted_transients'];
     452                $deleted_timeouts   += (int) $res['deleted_timeouts'];
     453                $cleared_via[] = 'sql_chunk_delete(dry_run)';
     454            } else {
     455                $used_fallback = true;
     456                $res = $this->chunked_sql_delete($limit, $time_budget, false);
     457                $deleted_transients += (int) $res['deleted_transients'];
     458                $deleted_timeouts   += (int) $res['deleted_timeouts'];
     459                $cleared_via[] = 'sql_chunk_delete';
     460            }
     461        }
     462
     463        if (!$cleared_via) {
     464            $cleared_via[] = 'noop';
     465        }
     466
     467        return [
     468            'cleared_via'        => \array_values(\array_unique($cleared_via)),
     469            'fallback_used'      => (bool) $used_fallback,
     470            'deleted_transients' => (int) $deleted_transients,
     471            'deleted_timeouts'   => (int) $deleted_timeouts,
     472            'errors'             => \array_values(\array_unique($errors)),
     473        ];
     474    }
     475
     476    protected function chunked_sql_delete(int $limit, float $time_budget, bool $dry_run): array
     477    {
     478        $deleted_transients = 0;
     479        $deleted_timeouts   = 0;
     480
     481        if (\wp_using_ext_object_cache()) {
     482            return ['deleted_transients' => 0, 'deleted_timeouts' => 0];
     483        }
     484
    36485        global $wpdb;
    37         $count = $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name LIKE '_transient_woocommerce_%'");
    38         return rest_ensure_response(['count' => (int) $count]);
    39     }
    40 
    41     public function delete_woocommerce_transients() {
     486
     487        $limit = max(1, (int) $limit);
     488        $time_budget = max(0.1, (float) $time_budget);
     489
     490        $like_to = $wpdb->esc_like('_transient_timeout_woocommerce_') . '%';
     491        $like    = $wpdb->esc_like('_transient_woocommerce_') . '%';
     492
     493        if ($dry_run) {
     494            $timeouts = (int) $wpdb->get_var($wpdb->prepare(
     495                "SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name LIKE %s",
     496                $like_to
     497            ));
     498            $values = (int) $wpdb->get_var($wpdb->prepare(
     499                "SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name LIKE %s",
     500                $like
     501            ));
     502            return ['deleted_transients' => $values, 'deleted_timeouts' => $timeouts];
     503        }
     504
     505        $start = \microtime(true);
     506
     507        do {
     508            $count_to = (int) $wpdb->query(
     509                $wpdb->prepare(
     510                    "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s LIMIT %d",
     511                    $like_to,
     512                    $limit
     513                )
     514            );
     515            $deleted_timeouts += $count_to;
     516
     517            $count_val = (int) $wpdb->query(
     518                $wpdb->prepare(
     519                    "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s LIMIT %d",
     520                    $like,
     521                    $limit
     522                )
     523            );
     524            $deleted_transients += $count_val;
     525
     526            $elapsed = \microtime(true) - $start;
     527            $more = ($count_to + $count_val) >= (2 * $limit);
     528        } while ($more && $elapsed < $time_budget);
     529
     530        return ['deleted_transients' => $deleted_transients, 'deleted_timeouts' => $deleted_timeouts];
     531    }
     532
     533    protected function count_wc_transients_for_site(): int
     534    {
    42535        global $wpdb;
    43         $wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_woocommerce_%'");
    44         return rest_ensure_response(['deleted' => true]);
    45     }
    46 
    47     public function clear_woocommerce_cache() {
    48         if (function_exists('wc_delete_product_transients')) {
    49             wc_delete_product_transients();
    50         }
    51         return rest_ensure_response(['cleared' => true]);
     536        $like = $wpdb->esc_like('_transient_woocommerce_') . '%';
     537        $count = (int) $wpdb->get_var(
     538            $wpdb->prepare(
     539                "SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name LIKE %s",
     540                $like
     541            )
     542        );
     543        return $count;
     544    }
     545
     546    protected function parse_sites_param(\WP_REST_Request $request): array
     547    {
     548        $sites = $request->get_param('sites');
     549        if (is_array($sites)) {
     550            return array_values(array_filter(array_map('intval', $sites), fn($n) => $n > 0));
     551        }
     552        if (is_string($sites) && $sites !== '') {
     553            $parts = array_map('trim', explode(',', $sites));
     554            return array_values(array_filter(array_map('intval', $parts), fn($n) => $n > 0));
     555        }
     556        return [];
     557    }
     558
     559    protected function get_bool_param(\WP_REST_Request $request, string $name): bool
     560    {
     561        $v = $request->get_param($name);
     562        if (is_bool($v)) return $v;
     563        if (is_numeric($v)) return ((int) $v) !== 0;
     564        if (is_string($v)) {
     565            $l = strtolower($v);
     566            return in_array($l, ['1','true','yes','on'], true);
     567        }
     568        return false;
     569    }
     570
     571    protected function scoped_key(string $base): string
     572    {
     573        if (\function_exists('\dfehc_scoped_key')) {
     574            return \dfehc_scoped_key($base);
     575        }
     576        $bid = \function_exists('\get_current_blog_id') ? (string) \get_current_blog_id() : '0';
     577        $host = @\php_uname('n') ?: (\defined('WP_HOME') ? WP_HOME : (\function_exists('\home_url') ? \home_url() : 'unknown'));
     578        $salt = \defined('DB_NAME') ? (string) DB_NAME : '';
     579        $tok  = \substr(\md5((string) $host . $salt), 0, 10);
     580        return "{$base}_{$bid}_{$tok}";
    52581    }
    53582}
  • dynamic-front-end-heartbeat-control/trunk/defibrillator/unclogger-db.php

    r3310561 r3396626  
    55
    66if (!class_exists(__NAMESPACE__ . '\DfehcUncloggerDb')) {
    7     class DfehcUncloggerDb {
    8 
    9     public function get_database_size() {
    10         global $wpdb;
    11         return (float) $wpdb->get_var("SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024, 1) FROM information_schema.tables WHERE TABLE_SCHEMA = '{$wpdb->dbname}' GROUP BY table_schema");
     7    class DfehcUncloggerDb
     8    {
     9        private function sanitize_identifier(string $name): string
     10        {
     11            $name = str_replace('`', '``', $name);
     12            return '`' . $name . '`';
     13        }
     14
     15        private function time_budget_exceeded(float $start, float $budget): bool
     16        {
     17            return (microtime(true) - $start) >= $budget;
     18        }
     19
     20        public function get_database_size()
     21        {
     22            global $wpdb;
     23            $sql = "SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024, 1)
     24                    FROM information_schema.tables
     25                    WHERE table_schema = %s
     26                    GROUP BY table_schema";
     27            return (float) $wpdb->get_var($wpdb->prepare($sql, $wpdb->dbname));
     28        }
     29
     30        public function count_trashed_posts()
     31        {
     32            global $wpdb;
     33            $sql = "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_status = %s";
     34            return (int) $wpdb->get_var($wpdb->prepare($sql, 'trash'));
     35        }
     36
     37        public function delete_trashed_posts()
     38        {
     39            global $wpdb;
     40            $limit  = (int) apply_filters('dfehc_unclogger_delete_limit', 1000);
     41            $budget = (float) apply_filters('dfehc_unclogger_time_budget', 3.0);
     42            $start  = microtime(true);
     43            $total  = 0;
     44            do {
     45                $deleted = (int) $wpdb->query(
     46                    $wpdb->prepare(
     47                        "DELETE FROM {$wpdb->posts} WHERE post_status = %s ORDER BY ID ASC LIMIT %d",
     48                        'trash',
     49                        $limit
     50                    )
     51                );
     52                $total += $deleted;
     53                if ($deleted < $limit) break;
     54            } while (!$this->time_budget_exceeded($start, $budget));
     55            return $total;
     56        }
     57
     58        public function count_revisions()
     59        {
     60            global $wpdb;
     61            $sql = "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = %s";
     62            return (int) $wpdb->get_var($wpdb->prepare($sql, 'revision'));
     63        }
     64
     65        public function delete_revisions()
     66        {
     67            global $wpdb;
     68            $limit  = (int) apply_filters('dfehc_unclogger_delete_limit', 1000);
     69            $budget = (float) apply_filters('dfehc_unclogger_time_budget', 3.0);
     70            $start  = microtime(true);
     71            $total  = 0;
     72            do {
     73                $deleted = (int) $wpdb->query(
     74                    $wpdb->prepare(
     75                        "DELETE FROM {$wpdb->posts} WHERE post_type = %s ORDER BY ID ASC LIMIT %d",
     76                        'revision',
     77                        $limit
     78                    )
     79                );
     80                $total += $deleted;
     81                if ($deleted < $limit) break;
     82            } while (!$this->time_budget_exceeded($start, $budget));
     83            return $total;
     84        }
     85
     86        public function count_auto_drafts()
     87        {
     88            global $wpdb;
     89            $sql = "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_status = %s";
     90            return (int) $wpdb->get_var($wpdb->prepare($sql, 'auto-draft'));
     91        }
     92
     93        public function delete_auto_drafts()
     94        {
     95            global $wpdb;
     96            $limit  = (int) apply_filters('dfehc_unclogger_delete_limit', 1000);
     97            $budget = (float) apply_filters('dfehc_unclogger_time_budget', 3.0);
     98            $start  = microtime(true);
     99            $total  = 0;
     100            do {
     101                $deleted = (int) $wpdb->query(
     102                    $wpdb->prepare(
     103                        "DELETE FROM {$wpdb->posts} WHERE post_status = %s ORDER BY ID ASC LIMIT %d",
     104                        'auto-draft',
     105                        $limit
     106                    )
     107                );
     108                $total += $deleted;
     109                if ($deleted < $limit) break;
     110            } while (!$this->time_budget_exceeded($start, $budget));
     111            return $total;
     112        }
     113
     114        public function count_orphaned_postmeta()
     115        {
     116            global $wpdb;
     117            $sql = "SELECT COUNT(*)
     118                    FROM {$wpdb->postmeta} pm
     119                    LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id
     120                    WHERE p.ID IS NULL";
     121            return (int) $wpdb->get_var($sql);
     122        }
     123
     124        public function delete_orphaned_postmeta()
     125        {
     126            global $wpdb;
     127            $limit  = (int) apply_filters('dfehc_unclogger_delete_limit', 2000);
     128            $budget = (float) apply_filters('dfehc_unclogger_time_budget', 3.0);
     129            $start  = microtime(true);
     130            $total  = 0;
     131            do {
     132                $deleted = (int) $wpdb->query(
     133                    $wpdb->prepare(
     134                        "DELETE pm
     135                         FROM {$wpdb->postmeta} pm
     136                         LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id
     137                         WHERE p.ID IS NULL
     138                         LIMIT %d",
     139                        $limit
     140                    )
     141                );
     142                $total += $deleted;
     143                if ($deleted < $limit) break;
     144            } while (!$this->time_budget_exceeded($start, $budget));
     145            return $total;
     146        }
     147
     148        public function count_woocommerce_transients()
     149        {
     150            global $wpdb;
     151            $like1 = $wpdb->esc_like('_transient_woocommerce_') . '%';
     152            $like2 = $wpdb->esc_like('_transient_timeout_woocommerce_') . '%';
     153            $sql   = "SELECT
     154                        (SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name LIKE %s) +
     155                        (SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name LIKE %s)";
     156            return (int) $wpdb->get_var($wpdb->prepare($sql, $like1, $like2));
     157        }
     158
     159        public function delete_woocommerce_transients()
     160        {
     161            global $wpdb;
     162            $limit  = (int) apply_filters('dfehc_unclogger_delete_limit', 1000);
     163            $budget = (float) apply_filters('dfehc_unclogger_time_budget', 3.0);
     164            $start  = microtime(true);
     165            $like1  = $wpdb->esc_like('_transient_woocommerce_') . '%';
     166            $like2  = $wpdb->esc_like('_transient_timeout_woocommerce_') . '%';
     167            $total  = 0;
     168            do {
     169                $d1 = (int) $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name LIKE %s LIMIT %d", $like1, $limit));
     170                $d2 = (int) $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name LIKE %s LIMIT %d", $like2, $limit));
     171                $total += ($d1 + $d2);
     172                if ($d1 + $d2 < (2 * $limit)) break;
     173            } while (!$this->time_budget_exceeded($start, $budget));
     174            return $total;
     175        }
     176
     177        public function clear_woocommerce_cache()
     178        {
     179            $cleared = false;
     180            if (class_exists('\WC_Cache_Helper')) {
     181                try { \WC_Cache_Helper::get_transient_version('product', true); $cleared = true; } catch (\Throwable $e) {}
     182                try { \WC_Cache_Helper::get_transient_version('shipping', true); $cleared = true; } catch (\Throwable $e) {}
     183                try { \WC_Cache_Helper::get_transient_version('orders', true); $cleared = true; } catch (\Throwable $e) {}
     184            }
     185            if (function_exists('\wc_delete_product_transients')) {
     186                try { \wc_delete_product_transients(); $cleared = true; } catch (\Throwable $e) {}
     187            }
     188            if (function_exists('\wc_delete_expired_transients')) {
     189                try { \wc_delete_expired_transients(); $cleared = true; } catch (\Throwable $e) {}
     190            }
     191            return (bool) $cleared;
     192        }
     193
     194        public function count_expired_transients()
     195        {
     196            global $wpdb;
     197            $like = $wpdb->esc_like('_transient_timeout_') . '%';
     198            $sql  = "SELECT COUNT(*)
     199                     FROM {$wpdb->options}
     200                     WHERE option_name LIKE %s
     201                       AND CAST(option_value AS UNSIGNED) < %d";
     202            return (int) $wpdb->get_var($wpdb->prepare($sql, $like, time()));
     203        }
     204
     205        public function delete_expired_transients()
     206        {
     207            global $wpdb;
     208            $batch  = (int) apply_filters('dfehc_delete_transients_batch', 1000);
     209            $budget = (float) apply_filters('dfehc_unclogger_time_budget', 3.0);
     210            $start  = microtime(true);
     211            $likeTO = $wpdb->esc_like('_transient_timeout_') . '%';
     212            $count  = 0;
     213
     214            do {
     215                $rows = (array) $wpdb->get_col(
     216                    $wpdb->prepare(
     217                        "SELECT REPLACE(option_name, %s, '') AS tname
     218                         FROM {$wpdb->options}
     219                         WHERE option_name LIKE %s
     220                           AND CAST(option_value AS UNSIGNED) < %d
     221                         LIMIT %d",
     222                        '_transient_timeout_',
     223                        $likeTO,
     224                        time(),
     225                        $batch
     226                    )
     227                );
     228                if (!$rows) break;
     229                foreach ($rows as $name) {
     230                    $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name = %s", "_transient_{$name}"));
     231                    $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name = %s", "_transient_timeout_{$name}"));
     232                    $count++;
     233                }
     234                if (count($rows) < $batch) break;
     235            } while (!$this->time_budget_exceeded($start, $budget));
     236
     237            return $count;
     238        }
     239
     240        public function count_tables_with_different_prefix()
     241        {
     242            global $wpdb;
     243            $sql = "SELECT COUNT(TABLE_NAME)
     244                    FROM information_schema.TABLES
     245                    WHERE TABLE_SCHEMA = %s
     246                      AND TABLE_NAME NOT LIKE %s";
     247            return (int) $wpdb->get_var($wpdb->prepare($sql, $wpdb->dbname, $wpdb->base_prefix . '%'));
     248        }
     249
     250        public function list_tables_with_different_prefix()
     251        {
     252            global $wpdb;
     253            $sql = "SELECT TABLE_NAME
     254                    FROM information_schema.TABLES
     255                    WHERE TABLE_SCHEMA = %s
     256                      AND TABLE_NAME NOT LIKE %s";
     257            $results = (array) $wpdb->get_col($wpdb->prepare($sql, $wpdb->dbname, $wpdb->base_prefix . '%'));
     258            return implode(', ', array_map('esc_html', $results));
     259        }
     260
     261        public function drop_tables_with_different_prefix()
     262        {
     263            global $wpdb;
     264            $allowed_prefixes = (array) apply_filters('dfehc_unclogger_allowed_drop_prefixes', []);
     265            if (empty($allowed_prefixes)) {
     266                return 0;
     267            }
     268            $clauses = [];
     269            $params  = [$wpdb->dbname];
     270            foreach ($allowed_prefixes as $p) {
     271                $clauses[] = "TABLE_NAME LIKE %s";
     272                $params[]  = $wpdb->esc_like($p) . '%';
     273            }
     274            $sql = "SELECT TABLE_NAME
     275                    FROM information_schema.TABLES
     276                    WHERE TABLE_SCHEMA = %s
     277                      AND (" . implode(' OR ', $clauses) . ")";
     278            $tables = (array) $wpdb->get_col($wpdb->prepare($sql, $params));
     279
     280            $count = 0;
     281            foreach ($tables as $t) {
     282                $safe = $this->sanitize_identifier($t);
     283                $wpdb->query("DROP TABLE IF EXISTS {$safe}");
     284                $count++;
     285            }
     286            return $count;
     287        }
     288
     289        public function count_myisam_tables()
     290        {
     291            global $wpdb;
     292            $sql = "SELECT COUNT(*) FROM information_schema.TABLES
     293                    WHERE TABLE_SCHEMA = %s AND ENGINE = 'MyISAM'";
     294            return (int) $wpdb->get_var($wpdb->prepare($sql, $wpdb->dbname));
     295        }
     296
     297        public function list_myisam_tables()
     298        {
     299            global $wpdb;
     300            $sql = "SELECT TABLE_NAME FROM information_schema.TABLES
     301                    WHERE TABLE_SCHEMA = %s AND ENGINE = 'MyISAM'";
     302            $results = (array) $wpdb->get_col($wpdb->prepare($sql, $wpdb->dbname));
     303            return implode(', ', array_map('esc_html', $results));
     304        }
     305
     306        public function convert_to_innodb()
     307        {
     308            global $wpdb;
     309            $sql = "SELECT TABLE_NAME FROM information_schema.TABLES
     310                    WHERE TABLE_SCHEMA = %s AND ENGINE = 'MyISAM' AND TABLE_NAME LIKE %s";
     311            $tables = (array) $wpdb->get_col($wpdb->prepare($sql, $wpdb->dbname, $wpdb->base_prefix . '%'));
     312
     313            $budget = (float) apply_filters('dfehc_unclogger_schema_time_budget', 5.0);
     314            $start  = microtime(true);
     315            $count  = 0;
     316
     317            foreach ($tables as $t) {
     318                if ($this->time_budget_exceeded($start, $budget)) break;
     319                $safe = $this->sanitize_identifier($t);
     320                $wpdb->query("ALTER TABLE {$safe} ENGINE=InnoDB");
     321                $count++;
     322            }
     323            return $count;
     324        }
     325
     326        public function optimize_tables()
     327        {
     328            global $wpdb;
     329            $sql = "SELECT TABLE_NAME FROM information_schema.TABLES
     330                    WHERE TABLE_SCHEMA = %s AND TABLE_NAME LIKE %s";
     331            $tables = (array) $wpdb->get_col($wpdb->prepare($sql, $wpdb->dbname, $wpdb->base_prefix . '%'));
     332
     333            $budget = (float) apply_filters('dfehc_unclogger_schema_time_budget', 5.0);
     334            $start  = microtime(true);
     335            $count  = 0;
     336
     337            foreach ($tables as $t) {
     338                if ($this->time_budget_exceeded($start, $budget)) break;
     339                $safe = $this->sanitize_identifier($t);
     340                $wpdb->query("OPTIMIZE TABLE {$safe}");
     341                $count++;
     342            }
     343            return $count;
     344        }
     345
     346        public function count_tables()
     347        {
     348            global $wpdb;
     349            $sql = "SELECT COUNT(TABLE_NAME) FROM information_schema.TABLES WHERE TABLE_SCHEMA = %s";
     350            return (int) $wpdb->get_var($wpdb->prepare($sql, $wpdb->dbname));
     351        }
     352
     353        public function optimize_all()
     354        {
     355            $this->delete_trashed_posts();
     356            $this->delete_revisions();
     357            $this->delete_auto_drafts();
     358            $this->delete_orphaned_postmeta();
     359            $this->delete_expired_transients();
     360            $this->convert_to_innodb();
     361            $this->optimize_tables();
     362        }
     363
     364        public function set_wp_post_revisions($value)
     365        {
     366            if (!isset($this->config)) {
     367                return new \WP_Error('config_missing', 'Config instance not set.');
     368            }
     369            if ($value === 'default') {
     370                return $this->config->remove('constant', 'WP_POST_REVISIONS');
     371            }
     372            return $this->config->update('constant', 'WP_POST_REVISIONS', $value);
     373        }
    12374    }
    13 
    14     public function count_trashed_posts() {
    15         global $wpdb;
    16         return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}posts WHERE post_status = 'trash'");
    17     }
    18 
    19     public function delete_trashed_posts() {
    20         global $wpdb;
    21         return (int) $wpdb->query("DELETE FROM {$wpdb->prefix}posts WHERE post_status = 'trash'");
    22     }
    23 
    24     public function count_revisions() {
    25         global $wpdb;
    26         return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}posts WHERE post_type = 'revision'");
    27     }
    28 
    29     public function delete_revisions() {
    30         global $wpdb;
    31         return (int) $wpdb->query("DELETE FROM {$wpdb->prefix}posts WHERE post_type = 'revision'");
    32     }
    33 
    34     public function count_auto_drafts() {
    35         global $wpdb;
    36         return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}posts WHERE post_status = 'auto-draft'");
    37     }
    38 
    39     public function delete_auto_drafts() {
    40         global $wpdb;
    41         return (int) $wpdb->query("DELETE FROM {$wpdb->prefix}posts WHERE post_status = 'auto-draft'");
    42     }
    43 
    44     public function count_orphaned_postmeta() {
    45         global $wpdb;
    46         return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}postmeta pm LEFT JOIN {$wpdb->prefix}posts wp ON wp.ID = pm.post_id WHERE wp.ID IS NULL");
    47     }
    48 
    49     public function delete_orphaned_postmeta() {
    50         global $wpdb;
    51         return (int) $wpdb->query("DELETE pm FROM {$wpdb->prefix}postmeta pm LEFT JOIN {$wpdb->prefix}posts wp ON wp.ID = pm.post_id WHERE wp.ID IS NULL");
    52     }
    53 
    54     public function count_woocommerce_transients() {
    55         global $wpdb;
    56         return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name LIKE '%woocommerce_%transient%'");
    57     }
    58 
    59     public function delete_woocommerce_transients() {
    60         global $wpdb;
    61         return (int) $wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '%woocommerce_%transient%'");
    62     }
    63 
    64     public function clear_woocommerce_cache() {
    65         if (function_exists('wc_delete_product_transients')) {
    66             wc_delete_product_transients();
    67         }
    68 
    69         if (function_exists('wc_cache_helper')) {
    70             wc_cache_helper()->clear_cache();
    71         }
    72 
    73         if (function_exists('wp_cache_flush')) {
    74             wp_cache_flush();
    75         }
    76 
    77         return true;
    78     }
    79 
    80     public function count_expired_transients() {
    81         global $wpdb;
    82         $query = $wpdb->get_results("SELECT option_value FROM {$wpdb->prefix}options WHERE option_name LIKE '_transient_timeout_%'", ARRAY_A);
    83         return count(array_filter($query, fn($t) => (int) $t['option_value'] < time()));
    84     }
    85 
    86     public function delete_expired_transients() {
    87         global $wpdb;
    88         $query = $wpdb->get_results("SELECT option_name, option_value FROM {$wpdb->prefix}options WHERE option_name LIKE '_transient_timeout_%'", ARRAY_A);
    89 
    90         $count = 0;
    91         foreach ($query as $transient) {
    92             if ((int) $transient['option_value'] < time()) {
    93                 $name = str_replace('_transient_timeout_', '', $transient['option_name']);
    94                 $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->prefix}options WHERE option_name = %s", "_transient_{$name}"));
    95                 $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->prefix}options WHERE option_name = %s", "_transient_timeout_{$name}"));
    96                 $count++;
    97             }
    98         }
    99         return $count;
    100     }
    101 
    102     public function count_tables_with_different_prefix() {
    103         global $wpdb;
    104         return (int) $wpdb->get_var("SELECT COUNT(TABLE_NAME) FROM information_schema.TABLES WHERE TABLE_SCHEMA = '{$wpdb->dbname}' AND TABLE_NAME NOT LIKE '{$wpdb->base_prefix}%'");
    105     }
    106 
    107     public function list_tables_with_different_prefix() {
    108         global $wpdb;
    109         $results = $wpdb->get_results("SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = '{$wpdb->dbname}' AND TABLE_NAME NOT LIKE '{$wpdb->base_prefix}%'", ARRAY_A);
    110         return implode(', ', wp_list_pluck($results, 'TABLE_NAME'));
    111     }
    112 
    113     public function drop_tables_with_different_prefix() {
    114         global $wpdb;
    115         $results = $wpdb->get_results("SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = '{$wpdb->dbname}' AND TABLE_NAME NOT LIKE '{$wpdb->base_prefix}%'", ARRAY_A);
    116 
    117         $count = 0;
    118         foreach ($results as $row) {
    119             $table = esc_sql($row['TABLE_NAME']);
    120             $wpdb->query("DROP TABLE IF EXISTS `{$table}`");
    121             $count++;
    122         }
    123         return $count;
    124     }
    125 
    126     public function count_myisam_tables() {
    127         global $wpdb;
    128         $results = $wpdb->get_results("SHOW TABLE STATUS WHERE Engine = 'MyISAM'", ARRAY_A);
    129         return is_array($results) ? count($results) : 0;
    130     }
    131 
    132     public function list_myisam_tables() {
    133         global $wpdb;
    134         $results = $wpdb->get_results("SHOW TABLE STATUS WHERE Engine = 'MyISAM'", ARRAY_A);
    135         return implode(', ', wp_list_pluck($results, 'Name'));
    136     }
    137 
    138     public function convert_to_innodb() {
    139         global $wpdb;
    140         $results = $wpdb->get_results("SHOW TABLE STATUS WHERE Engine = 'MyISAM'", ARRAY_A);
    141         $count = 0;
    142         foreach ($results as $row) {
    143             $table = esc_sql($row['Name']);
    144             $wpdb->query("ALTER TABLE `{$table}` ENGINE=InnoDB");
    145             $count++;
    146         }
    147         return $count;
    148     }
    149 
    150     public function optimize_tables() {
    151         global $wpdb;
    152         $results = $wpdb->get_results("SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = '{$wpdb->dbname}'", ARRAY_A);
    153         $count = 0;
    154         foreach ($results as $row) {
    155             $table = esc_sql($row['TABLE_NAME']);
    156             $wpdb->query("OPTIMIZE TABLE `{$table}`");
    157             $count++;
    158         }
    159         return $count;
    160     }
    161 
    162     public function count_tables() {
    163         global $wpdb;
    164         return (int) $wpdb->get_var("SELECT COUNT(TABLE_NAME) FROM information_schema.TABLES WHERE TABLE_SCHEMA = '{$wpdb->dbname}'");
    165     }
    166 
    167     public function optimize_all() {
    168         $this->delete_trashed_posts();
    169         $this->delete_revisions();
    170         $this->delete_auto_drafts();
    171         $this->delete_orphaned_postmeta();
    172         $this->drop_tables_with_different_prefix();
    173         $this->delete_expired_transients();
    174         $this->convert_to_innodb();
    175         $this->optimize_tables();
    176     }
    177 
    178     public function set_wp_post_revisions($value) {
    179         if (!isset($this->config)) {
    180             return new \WP_Error('config_missing', 'Config instance not set.');
    181         }
    182         if ($value === 'default') {
    183             return $this->config->remove('constant', 'WP_POST_REVISIONS');
    184         }
    185         return $this->config->update('constant', 'WP_POST_REVISIONS', $value);
    186     }
    187     }
    188375}
  • dynamic-front-end-heartbeat-control/trunk/defibrillator/unclogger.php

    r3347790 r3396626  
    1717    ];
    1818
     19    protected array $allowed_tools = [
     20        'delete_trashed_posts',
     21        'delete_revisions',
     22        'delete_auto_drafts',
     23        'delete_orphaned_postmeta',
     24        'delete_expired_transients',
     25        'count_expired_transients',
     26        'convert_to_innodb',
     27        'optimize_tables',
     28        'optimize_all',
     29        'clear_woocommerce_cache',
     30    ];
     31
    1932    public function __construct()
    2033    {
     
    2235            $base = rtrim(self::get_plugin_path(), '/\\');
    2336            $cli  = $base . '/cli-helper.php';
    24 
    2537            if (!is_file($cli)) {
    2638                $cli = $base . '/defibrillator/cli-helper.php';
    2739            }
    28 
    2940            if (is_file($cli)) {
    3041                require_once $cli;
     
    3344
    3445        $this->db = new DfehcUncloggerDb();
    35 
    3646        $this->set_default_settings();
    3747
     
    5666        }
    5767    }
    58     public function get_settings() {
    59         return get_option('dfehc_unclogger_settings', $this->default_settings);
    60     }
    61 
    62     public function set_setting($req) {
     68
     69    public function get_settings()
     70    {
     71        $settings = get_option('dfehc_unclogger_settings', $this->default_settings);
     72        return [
     73            'success' => true,
     74            'data' => $settings,
     75        ];
     76    }
     77
     78    public function set_setting($req)
     79    {
    6380        $data = $req->get_json_params();
    64 
    65         $setting = sanitize_text_field($data['setting']);
    66         $value = sanitize_text_field($data['value']);
    67 
    68         $method = 'set_' . strtolower($setting);
    69         if (method_exists($this->db, $method)) {
    70             return $this->db->{$method}($value);
    71         }
    72 
    73         if (isset($this->tweaks) && method_exists($this->tweaks, $method)) {
    74             return $this->tweaks->{$method}($value);
    75         }
    76 
    77         return new \WP_Error('invalid_method', 'Setting method not found.');
    78     }
    79 
    80     public function optimize_db($req) {
    81         $tool = sanitize_text_field($req['tool'] ?? null);
     81        if (!is_array($data)) {
     82            return new \WP_Error('invalid_body', 'Invalid JSON payload.');
     83        }
     84
     85        $setting = sanitize_text_field($data['setting'] ?? '');
     86        $value   = $data['value'] ?? null;
     87
     88        $known = ['auto_cleanup', 'post_revision_limit'];
     89        if (!in_array($setting, $known, true)) {
     90            return new \WP_Error('invalid_setting', 'Unknown setting key.');
     91        }
     92
     93        if ($setting === 'auto_cleanup') {
     94            $value = (bool) $value;
     95        } elseif ($setting === 'post_revision_limit') {
     96            $value = max(0, (int) $value);
     97        }
     98
     99        $settings = get_option('dfehc_unclogger_settings', $this->default_settings);
     100        $settings[$setting] = $value;
     101        update_option('dfehc_unclogger_settings', $settings, true);
     102
     103        if (defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) {
     104            error_log('[DFEHC] Setting updated: ' . $setting . ' => ' . wp_json_encode($value));
     105        }
     106
     107        return [
     108            'success' => true,
     109            'data' => $settings,
     110        ];
     111    }
     112
     113    protected function get_max_load(string $context = 'interactive'): float
     114    {
     115        $interactive = (float) apply_filters('dfehc_optimize_max_load', 45.0);
     116        $background  = (float) apply_filters('dfehc_optimize_max_load_background', 5.0);
     117        return $context === 'background' ? $background : $interactive;
     118    }
     119
     120    public function optimize_db($req)
     121    {
     122        $tool = sanitize_key($req->get_param('tool'));
     123
     124        $uid = get_current_user_id();
     125        $ip  = isset($_SERVER['REMOTE_ADDR']) ? (string) $_SERVER['REMOTE_ADDR'] : '0.0.0.0';
     126        $rl_key   = 'dfehc_optimize_rl_' . ($uid ? 'u' . $uid : 'ip' . md5($ip));
     127        $rl_limit = (int) apply_filters('dfehc_optimize_rl_limit', 10);
     128        $rl_win   = (int) apply_filters('dfehc_optimize_rl_window', 60);
     129        $rl_cnt   = (int) get_transient($rl_key);
     130        if ($rl_cnt >= $rl_limit) {
     131            return new \WP_Error('rate_limited', 'Too many requests. Please try again shortly.', ['status' => 429]);
     132        }
     133        set_transient($rl_key, $rl_cnt + 1, $rl_win);
    82134
    83135        if (get_transient('dfehc_optimizing')) {
     
    85137        }
    86138
    87         $load = function_exists('dfehc_get_server_load') ? dfehc_get_server_load() : 0;
    88         if ($load > 45) {
     139        if (!$tool || !in_array($tool, $this->allowed_tools, true) || !method_exists($this->db, $tool)) {
     140            return new \WP_Error('invalid_tool', 'No valid optimization tool specified.');
     141        }
     142
     143        $load = function_exists('dfehc_get_server_load') ? (float) dfehc_get_server_load() : 0.0;
     144        if ($load > $this->get_max_load('interactive')) {
    89145            return new \WP_Error('server_busy', 'Server load is too high to run optimization safely.');
    90         }
    91 
    92         if (!$tool || !method_exists($this->db, $tool)) {
    93             return new \WP_Error('invalid_tool', 'No valid optimization tool specified.');
    94146        }
    95147
     
    98150        if ($tool === 'optimize_all') {
    99151            wp_schedule_single_event(time() + 10, 'dfehc_async_optimize_all');
    100             return ['scheduled' => true];
    101         }
    102 
     152            delete_transient('dfehc_optimizing');
     153            return [
     154                'success' => true,
     155                'data' => ['scheduled' => true, 'tool' => $tool],
     156            ];
     157        }
     158
     159        $result = null;
    103160        try {
    104             $result = call_user_func([$this->db, $tool]);
    105         } finally {
     161            $result = call_user_func([$this, 'guarded_run_tool'], $tool);
     162            if (defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) {
     163                error_log('[DFEHC] Ran tool: ' . $tool . ' -> ' . wp_json_encode($result));
     164            }
     165        } catch (\Throwable $e) {
    106166            delete_transient('dfehc_optimizing');
    107         }
     167            return new \WP_Error('optimize_failed', 'Optimization failed: ' . $e->getMessage());
     168        }
     169
     170        delete_transient('dfehc_optimizing');
    108171
    109172        return [
    110             'result' => $result,
    111             'tool' => $tool,
    112             'settings' => $this->get_settings(),
     173            'success' => true,
     174            'data' => [
     175                'result'   => $result,
     176                'tool'     => $tool,
     177                'settings' => get_option('dfehc_unclogger_settings', $this->default_settings),
     178            ],
    113179        ];
    114180    }
    115181
    116     public function get_option($option_name) {
    117         $settings = get_option('dfehc_unclogger_settings', []);
    118         return $settings[$option_name] ?? false;
    119     }
    120 
    121     public function update_option($option_name, $value) {
    122         $settings = get_option('dfehc_unclogger_settings', []);
    123         $settings[$option_name] = $value;
    124         update_option('dfehc_unclogger_settings', $settings, true);
    125     }
    126 
    127     public function register_rest_routes() {
     182    protected function guarded_run_tool(string $tool)
     183    {
     184        if (!method_exists($this->db, $tool)) {
     185            throw new \RuntimeException('Unknown tool: ' . $tool);
     186        }
     187        if (in_array($tool, ['convert_to_innodb', 'optimize_tables', 'optimize_all'], true)) {
     188            $load = function_exists('dfehc_get_server_load') ? (float) dfehc_get_server_load() : 0.0;
     189            if ($load > $this->get_max_load('interactive')) {
     190                throw new \RuntimeException('Server load spiked during operation.');
     191            }
     192        }
     193        return call_user_func([$this->db, $tool]);
     194    }
     195
     196    public function register_rest_routes()
     197    {
    128198        register_rest_route('dfehc-unclogger/v1', '/get/', [
    129             'methods' => 'GET',
    130             'permission_callback' => 'dfehc_permission_check',
    131             'callback' => [$this, 'get_settings'],
     199            'methods'             => \WP_REST_Server::READABLE,
     200            'permission_callback' => __NAMESPACE__ . '\\dfehc_permission_check',
     201            'callback'            => [$this, 'get_settings'],
    132202        ]);
    133203
    134204        register_rest_route('dfehc-unclogger/v1', '/optimize-db/(?P<tool>[^/]+)', [
    135             'methods' => 'GET',
    136             'permission_callback' => 'dfehc_permission_check',
    137             'callback' => [$this, 'optimize_db'],
     205            'methods'             => \WP_REST_Server::CREATABLE,
     206            'permission_callback' => __NAMESPACE__ . '\\dfehc_permission_check',
     207            'callback'            => [$this, 'optimize_db'],
     208            'args'                => [
     209                'tool' => [
     210                    'required' => true,
     211                    'sanitize_callback' => 'sanitize_key',
     212                ],
     213            ],
    138214        ]);
    139215
    140216        register_rest_route('dfehc-unclogger/v1', '/set/', [
    141             'methods' => 'POST',
    142             'permission_callback' => 'dfehc_permission_check',
    143             'callback' => [$this, 'set_setting'],
     217            'methods'             => \WP_REST_Server::CREATABLE,
     218            'permission_callback' => __NAMESPACE__ . '\\dfehc_permission_check',
     219            'callback'            => [$this, 'set_setting'],
    144220        ]);
    145221    }
    146222
    147     public function __call($method, $args) {
     223    public function __call($method, $args)
     224    {
    148225        if (method_exists($this->db, $method)) {
    149226            return call_user_func_array([$this->db, $method], $args);
    150227        }
    151 
    152228        throw new \BadMethodCallException("Method {$method} does not exist.");
    153229    }
    154230}
    155231
    156 function dfehc_permission_check() {
    157     return apply_filters('dfehc_unclogger_permission_check', current_user_can('manage_options'));
    158 }
    159 
    160 function dfehc_async_optimize_all() {
     232function dfehc_permission_check()
     233{
     234    return (bool) apply_filters('dfehc_unclogger_permission_check', current_user_can('manage_options'));
     235}
     236
     237function dfehc_async_optimize_all()
     238{
    161239    if (get_transient('dfehc_optimizing')) {
    162240        return;
    163241    }
    164242
    165     $load = function_exists('dfehc_get_server_load') ? dfehc_get_server_load() : 0;
    166     if ($load > 5) {
     243    $load = function_exists('dfehc_get_server_load') ? (float) dfehc_get_server_load() : 0.0;
     244    $max  = (float) apply_filters('dfehc_optimize_max_load_background', 5.0);
     245    if ($load > $max) {
    167246        return;
    168247    }
     
    170249    set_transient('dfehc_optimizing', 1, 300);
    171250
     251    $prev = ignore_user_abort(true);
     252    if (function_exists('set_time_limit')) {
     253        @set_time_limit(60);
     254    }
     255
    172256    try {
    173         $db = new \DynamicHeartbeat\DfehcUncloggerDb();
     257        $db = class_exists(__NAMESPACE__ . '\\DfehcUncloggerDb') ? new DfehcUncloggerDb() : new \DynamicHeartbeat\DfehcUncloggerDb();
    174258        $db->optimize_all();
     259        if (defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) {
     260            error_log('[DFEHC] optimize_all completed.');
     261        }
    175262    } finally {
     263        ignore_user_abort($prev);
    176264        delete_transient('dfehc_optimizing');
    177265    }
    178266}
    179267
     268add_action('dfehc_async_optimize_all', __NAMESPACE__ . '\\dfehc_async_optimize_all');
     269
    180270if (defined('WP_CLI') && WP_CLI) {
    181     \WP_CLI::add_command('dfehc optimize', function() {
     271    \WP_CLI::add_command('dfehc optimize', function () {
    182272        if (get_transient('dfehc_optimizing')) {
    183273            \WP_CLI::error('Optimization already in progress.');
    184274        }
    185 
    186         $load = function_exists('dfehc_get_server_load') ? dfehc_get_server_load() : 0;
    187         if ($load > 5) {
     275        $load = function_exists('dfehc_get_server_load') ? (float) dfehc_get_server_load() : 0.0;
     276        $max  = (float) apply_filters('dfehc_optimize_max_load_background', 5.0);
     277        if ($load > $max) {
    188278            \WP_CLI::error('Server load too high to proceed.');
    189279        }
    190 
    191280        set_transient('dfehc_optimizing', 1, 300);
    192 
     281        $prev = ignore_user_abort(true);
     282        if (function_exists('set_time_limit')) {
     283            @set_time_limit(60);
     284        }
    193285        try {
    194             $db = new \DynamicHeartbeat\DfehcUncloggerDb();
     286            $db = class_exists(__NAMESPACE__ . '\\DfehcUncloggerDb') ? new DfehcUncloggerDb() : new \DynamicHeartbeat\DfehcUncloggerDb();
    195287            $db->optimize_all();
    196288            \WP_CLI::success('Database optimized successfully.');
    197289        } finally {
     290            ignore_user_abort($prev);
    198291            delete_transient('dfehc_optimizing');
    199292        }
     
    203296$dfehc_unclogger = new \DynamicHeartbeat\DfehcUnclogger();
    204297
    205 class DfehcUncloggerDb {
    206 
    207     public function get_database_size() {
    208         global $wpdb;
    209         return (float) $wpdb->get_var("SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024, 1) FROM information_schema.tables WHERE TABLE_SCHEMA = '{$wpdb->dbname}' GROUP BY table_schema");
    210     }
    211 
    212     public function count_trashed_posts() {
    213         global $wpdb;
    214         return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}posts WHERE post_status = 'trash'");
    215     }
    216 
    217     public function delete_trashed_posts() {
    218         global $wpdb;
    219         return (int) $wpdb->query("DELETE FROM {$wpdb->prefix}posts WHERE post_status = 'trash'");
    220     }
    221 
    222     public function count_revisions() {
    223         global $wpdb;
    224         return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}posts WHERE post_type = 'revision'");
    225     }
    226 
    227     public function delete_revisions() {
    228         global $wpdb;
    229         return (int) $wpdb->query("DELETE FROM {$wpdb->prefix}posts WHERE post_type = 'revision'");
    230     }
    231 
    232     public function count_auto_drafts() {
    233         global $wpdb;
    234         return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}posts WHERE post_status = 'auto-draft'");
    235     }
    236 
    237     public function delete_auto_drafts() {
    238         global $wpdb;
    239         return (int) $wpdb->query("DELETE FROM {$wpdb->prefix}posts WHERE post_status = 'auto-draft'");
    240     }
    241 
    242     public function count_orphaned_postmeta() {
    243         global $wpdb;
    244         return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}postmeta pm LEFT JOIN {$wpdb->prefix}posts wp ON wp.ID = pm.post_id WHERE wp.ID IS NULL");
    245     }
    246 
    247     public function delete_orphaned_postmeta() {
    248         global $wpdb;
    249         return (int) $wpdb->query("DELETE pm FROM {$wpdb->prefix}postmeta pm LEFT JOIN {$wpdb->prefix}posts wp ON wp.ID = pm.post_id WHERE wp.ID IS NULL");
    250     }
    251 
    252     public function count_woocommerce_transients() {
    253         global $wpdb;
    254         return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name LIKE '%woocommerce_%transient%'");
    255     }
    256 
    257     public function delete_woocommerce_transients() {
    258         global $wpdb;
    259         return (int) $wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '%woocommerce_%transient%'");
    260     }
    261 
    262     public function clear_woocommerce_cache() {
    263         if (function_exists('wc_delete_product_transients')) {
    264             wc_delete_product_transients();
    265         }
    266 
    267         if (function_exists('wc_cache_helper')) {
    268             wc_cache_helper()->clear_cache();
    269         }
    270 
    271         if (function_exists('wp_cache_flush')) {
    272             wp_cache_flush();
    273         }
    274 
    275         return true;
    276     }
    277 
    278     public function count_expired_transients() {
    279         global $wpdb;
    280         $query = $wpdb->get_results("SELECT option_value FROM {$wpdb->prefix}options WHERE option_name LIKE '_transient_timeout_%'", ARRAY_A);
    281         return count(array_filter($query, fn($t) => (int) $t['option_value'] < time()));
    282     }
    283 
    284     public function delete_expired_transients() {
    285         global $wpdb;
    286         $query = $wpdb->get_results("SELECT option_name, option_value FROM {$wpdb->prefix}options WHERE option_name LIKE '_transient_timeout_%'", ARRAY_A);
     298if (!class_exists(__NAMESPACE__ . '\\DfehcUncloggerDb')) {
     299class DfehcUncloggerDb
     300{
     301    private function sanitize_ident(string $name): string
     302    {
     303        return preg_replace('/[^A-Za-z0-9$_]/', '', $name);
     304    }
     305
     306    public function get_database_size()
     307    {
     308        global $wpdb;
     309        $sql = "SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024, 1)
     310                FROM information_schema.tables
     311                WHERE table_schema = %s
     312                GROUP BY table_schema";
     313        return (float) $wpdb->get_var($wpdb->prepare($sql, $wpdb->dbname));
     314    }
     315
     316    public function count_trashed_posts()
     317    {
     318        global $wpdb;
     319        $sql = "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_status = %s";
     320        return (int) $wpdb->get_var($wpdb->prepare($sql, 'trash'));
     321    }
     322
     323    public function delete_trashed_posts()
     324    {
     325        global $wpdb;
     326        $sql = "DELETE FROM {$wpdb->posts} WHERE post_status = %s";
     327        return (int) $wpdb->query($wpdb->prepare($sql, 'trash'));
     328    }
     329
     330    public function count_revisions()
     331    {
     332        global $wpdb;
     333        $sql = "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = %s";
     334        return (int) $wpdb->get_var($wpdb->prepare($sql, 'revision'));
     335    }
     336
     337    public function delete_revisions()
     338    {
     339        global $wpdb;
     340        $sql = "DELETE FROM {$wpdb->posts} WHERE post_type = %s";
     341        return (int) $wpdb->query($wpdb->prepare($sql, 'revision'));
     342    }
     343
     344    public function count_auto_drafts()
     345    {
     346        global $wpdb;
     347        $sql = "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_status = %s";
     348        return (int) $wpdb->get_var($wpdb->prepare($sql, 'auto-draft'));
     349    }
     350
     351    public function delete_auto_drafts()
     352    {
     353        global $wpdb;
     354        $sql = "DELETE FROM {$wpdb->posts} WHERE post_status = %s";
     355        return (int) $wpdb->query($wpdb->prepare($sql, 'auto-draft'));
     356    }
     357
     358    public function count_orphaned_postmeta()
     359    {
     360        global $wpdb;
     361        $sql = "SELECT COUNT(*)
     362                FROM {$wpdb->postmeta} pm
     363                LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id
     364                WHERE p.ID IS NULL";
     365        return (int) $wpdb->get_var($sql);
     366    }
     367
     368    public function delete_orphaned_postmeta()
     369    {
     370        global $wpdb;
     371        $sql = "DELETE pm
     372                FROM {$wpdb->postmeta} pm
     373                LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id
     374                WHERE p.ID IS NULL";
     375        return (int) $wpdb->query($sql);
     376    }
     377
     378    public function count_woocommerce_transients()
     379    {
     380        global $wpdb;
     381        $like_val = $wpdb->esc_like('_transient_woocommerce_') . '%';
     382        $like_to  = $wpdb->esc_like('_transient_timeout_woocommerce_') . '%';
     383        $sql = "SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s";
     384        return (int) $wpdb->get_var($wpdb->prepare($sql, $like_val, $like_to));
     385    }
     386
     387    public function delete_woocommerce_transients()
     388    {
     389        global $wpdb;
     390        $like_val = $wpdb->esc_like('_transient_woocommerce_') . '%';
     391        $like_to  = $wpdb->esc_like('_transient_timeout_woocommerce_') . '%';
     392        $sql1 = $wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $like_val);
     393        $sql2 = $wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $like_to);
     394        $c1 = (int) $wpdb->query($sql1);
     395        $c2 = (int) $wpdb->query($sql2);
     396        return $c1 + $c2;
     397    }
     398
     399    public function clear_woocommerce_cache()
     400{
     401    if (class_exists('\WC_Cache_Helper')) {
     402        try { \WC_Cache_Helper::get_transient_version('product', true); } catch (\Throwable $e) {}
     403        try { \WC_Cache_Helper::get_transient_version('shipping', true); } catch (\Throwable $e) {}
     404        try { \WC_Cache_Helper::get_transient_version('orders', true); } catch (\Throwable $e) {}
     405    }
     406    if (function_exists('\wc_delete_product_transients')) {
     407        \wc_delete_product_transients();
     408    }
     409    return true;
     410}
     411
     412
     413    public function count_expired_transients()
     414    {
     415        global $wpdb;
     416        $like_to = $wpdb->esc_like('_transient_timeout_') . '%';
     417        $sql = "SELECT COUNT(*)
     418                FROM {$wpdb->options}
     419                WHERE option_name LIKE %s
     420                  AND CAST(option_value AS UNSIGNED) < %d";
     421        return (int) $wpdb->get_var($wpdb->prepare($sql, $like_to, time()));
     422    }
     423
     424    public function delete_expired_transients()
     425    {
     426        global $wpdb;
     427        $like_to = $wpdb->esc_like('_transient_timeout_') . '%';
     428        $names_sql = "SELECT REPLACE(option_name, %s, '') AS tname
     429                      FROM {$wpdb->options}
     430                      WHERE option_name LIKE %s
     431                        AND CAST(option_value AS UNSIGNED) < %d
     432                      LIMIT %d";
     433        $batch = (int) apply_filters('dfehc_delete_transients_batch', 1000);
     434        $time_budget = (float) apply_filters('dfehc_delete_transients_time_budget', 2.5);
     435        $start = microtime(true);
    287436
    288437        $count = 0;
    289         foreach ($query as $transient) {
    290             if ((int) $transient['option_value'] < time()) {
    291                 $name = str_replace('_transient_timeout_', '', $transient['option_name']);
    292                 $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->prefix}options WHERE option_name = %s", "_transient_{$name}"));
    293                 $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->prefix}options WHERE option_name = %s", "_transient_timeout_{$name}"));
     438        do {
     439            $rows = $wpdb->get_col($wpdb->prepare($names_sql, '_transient_timeout_', $like_to, time(), $batch));
     440            if (!$rows) {
     441                break;
     442            }
     443            foreach ($rows as $name) {
     444                $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name = %s", "_transient_{$name}"));
     445                $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name = %s", "_transient_timeout_{$name}"));
    294446                $count++;
    295             }
    296         }
     447                if ((microtime(true) - $start) > $time_budget) {
     448                    break 2;
     449                }
     450            }
     451        } while (true);
     452
    297453        return $count;
    298454    }
    299455
    300     public function count_tables_with_different_prefix() {
    301         global $wpdb;
    302         return (int) $wpdb->get_var("SELECT COUNT(TABLE_NAME) FROM information_schema.TABLES WHERE TABLE_SCHEMA = '{$wpdb->dbname}' AND TABLE_NAME NOT LIKE '{$wpdb->base_prefix}%'");
    303     }
    304 
    305     public function list_tables_with_different_prefix() {
    306         global $wpdb;
    307         $results = $wpdb->get_results("SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = '{$wpdb->dbname}' AND TABLE_NAME NOT LIKE '{$wpdb->base_prefix}%'", ARRAY_A);
    308         return implode(', ', wp_list_pluck($results, 'TABLE_NAME'));
    309     }
    310 
    311     public function drop_tables_with_different_prefix() {
    312         global $wpdb;
    313         $results = $wpdb->get_results("SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = '{$wpdb->dbname}' AND TABLE_NAME NOT LIKE '{$wpdb->base_prefix}%'", ARRAY_A);
     456    public function count_tables_with_different_prefix()
     457    {
     458        global $wpdb;
     459        $sql = "SELECT COUNT(TABLE_NAME)
     460                FROM information_schema.TABLES
     461                WHERE TABLE_SCHEMA = %s
     462                  AND TABLE_NAME NOT LIKE %s";
     463        return (int) $wpdb->get_var($wpdb->prepare($sql, $wpdb->dbname, $wpdb->base_prefix . '%'));
     464    }
     465
     466    public function list_tables_with_different_prefix()
     467    {
     468        global $wpdb;
     469        $sql = "SELECT TABLE_NAME
     470                FROM information_schema.TABLES
     471                WHERE TABLE_SCHEMA = %s
     472                  AND TABLE_NAME NOT LIKE %s";
     473        $results = $wpdb->get_col($wpdb->prepare($sql, $wpdb->dbname, $wpdb->base_prefix . '%'));
     474        return implode(', ', array_map('esc_html', (array) $results));
     475    }
     476
     477    public function drop_tables_with_different_prefix()
     478    {
     479        global $wpdb;
     480        $allowed_prefixes = (array) apply_filters('dfehc_unclogger_allowed_drop_prefixes', []);
     481        if (empty($allowed_prefixes)) {
     482            return 0;
     483        }
     484        $sql = "SELECT TABLE_NAME
     485                FROM information_schema.TABLES
     486                WHERE TABLE_SCHEMA = %s
     487                  AND (" . implode(' OR ', array_fill(0, count($allowed_prefixes), "TABLE_NAME LIKE %s")) . ")";
     488        $params = [$wpdb->dbname];
     489        foreach ($allowed_prefixes as $p) {
     490            $params[] = $wpdb->esc_like($p) . '%';
     491        }
     492        $prepared = $wpdb->prepare($sql, $params);
     493        $results  = $wpdb->get_col($prepared);
    314494
    315495        $count = 0;
    316         foreach ($results as $row) {
    317             $table = esc_sql($row['TABLE_NAME']);
    318             $wpdb->query("DROP TABLE IF EXISTS `{$table}`");
     496        foreach ((array) $results as $table) {
     497            $safe = $this->sanitize_ident($table);
     498            if ($safe === '') {
     499                continue;
     500            }
     501            $wpdb->query("DROP TABLE IF EXISTS `{$safe}`");
    319502            $count++;
    320503        }
     
    322505    }
    323506
    324     public function count_myisam_tables() {
    325         global $wpdb;
    326         $results = $wpdb->get_results("SHOW TABLE STATUS WHERE Engine = 'MyISAM'", ARRAY_A);
    327         return is_array($results) ? count($results) : 0;
    328     }
    329 
    330     public function list_myisam_tables() {
    331         global $wpdb;
    332         $results = $wpdb->get_results("SHOW TABLE STATUS WHERE Engine = 'MyISAM'", ARRAY_A);
    333         return implode(', ', wp_list_pluck($results, 'Name'));
    334     }
    335 
    336     public function convert_to_innodb() {
    337         global $wpdb;
    338         $results = $wpdb->get_results("SHOW TABLE STATUS WHERE Engine = 'MyISAM'", ARRAY_A);
     507    public function count_myisam_tables()
     508    {
     509        global $wpdb;
     510        $sql = "SELECT COUNT(*) FROM information_schema.TABLES
     511                WHERE TABLE_SCHEMA = %s AND ENGINE = 'MyISAM'";
     512        return (int) $wpdb->get_var($wpdb->prepare($sql, $wpdb->dbname));
     513    }
     514
     515    public function list_myisam_tables()
     516    {
     517        global $wpdb;
     518        $sql = "SELECT TABLE_NAME FROM information_schema.TABLES
     519                WHERE TABLE_SCHEMA = %s AND ENGINE = 'MyISAM'";
     520        $results = $wpdb->get_col($wpdb->prepare($sql, $wpdb->dbname));
     521        return implode(', ', array_map('esc_html', (array) $results));
     522    }
     523
     524    public function convert_to_innodb()
     525    {
     526        global $wpdb;
     527        $sql = "SELECT TABLE_NAME FROM information_schema.TABLES
     528                WHERE TABLE_SCHEMA = %s AND ENGINE = 'MyISAM' AND TABLE_NAME LIKE %s";
     529        $tables = $wpdb->get_col($wpdb->prepare($sql, $wpdb->dbname, $wpdb->base_prefix . '%'));
     530
    339531        $count = 0;
    340         foreach ($results as $row) {
    341             $table = esc_sql($row['Name']);
    342             $wpdb->query("ALTER TABLE `{$table}` ENGINE=InnoDB");
     532        foreach ((array) $tables as $t) {
     533            $safe = $this->sanitize_ident($t);
     534            if ($safe === '') {
     535                continue;
     536            }
     537            $wpdb->query("ALTER TABLE `{$safe}` ENGINE=InnoDB");
    343538            $count++;
    344539        }
     
    346541    }
    347542
    348     public function optimize_tables() {
    349         global $wpdb;
    350         $results = $wpdb->get_results("SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = '{$wpdb->dbname}'", ARRAY_A);
     543    public function optimize_tables()
     544    {
     545        global $wpdb;
     546        $sql = "SELECT TABLE_NAME FROM information_schema.TABLES
     547                WHERE TABLE_SCHEMA = %s AND TABLE_NAME LIKE %s";
     548        $tables = $wpdb->get_col($wpdb->prepare($sql, $wpdb->dbname, $wpdb->base_prefix . '%'));
     549
    351550        $count = 0;
    352         foreach ($results as $row) {
    353             $table = esc_sql($row['TABLE_NAME']);
    354             $wpdb->query("OPTIMIZE TABLE `{$table}`");
     551        foreach ((array) $tables as $t) {
     552            $safe = $this->sanitize_ident($t);
     553            if ($safe === '') {
     554                continue;
     555            }
     556            $wpdb->query("OPTIMIZE TABLE `{$safe}`");
    355557            $count++;
    356558        }
     
    358560    }
    359561
    360     public function count_tables() {
    361         global $wpdb;
    362         return (int) $wpdb->get_var("SELECT COUNT(TABLE_NAME) FROM information_schema.TABLES WHERE TABLE_SCHEMA = '{$wpdb->dbname}'");
    363     }
    364 
    365     public function optimize_all() {
     562    public function count_tables()
     563    {
     564        global $wpdb;
     565        $sql = "SELECT COUNT(TABLE_NAME) FROM information_schema.TABLES WHERE TABLE_SCHEMA = %s";
     566        return (int) $wpdb->get_var($wpdb->prepare($sql, $wpdb->dbname));
     567    }
     568
     569    public function optimize_all()
     570    {
    366571        $this->delete_trashed_posts();
    367572        $this->delete_revisions();
    368573        $this->delete_auto_drafts();
    369574        $this->delete_orphaned_postmeta();
    370         $this->drop_tables_with_different_prefix();
    371575        $this->delete_expired_transients();
    372576        $this->convert_to_innodb();
     
    374578    }
    375579
    376     public function set_wp_post_revisions($value) {
     580    public function set_wp_post_revisions($value)
     581    {
    377582        if (!isset($this->config)) {
    378583            return new \WP_Error('config_missing', 'Config instance not set.');
     
    384589    }
    385590}
     591}
  • dynamic-front-end-heartbeat-control/trunk/engine/interval-helper.php

    r3320647 r3396626  
    22declare(strict_types=1);
    33
    4 define('DFEHC_OPTIONS_PREFIX', 'dfehc_');
    5 define('DFEHC_OPTION_MIN_INTERVAL', DFEHC_OPTIONS_PREFIX . 'min_interval');
    6 define('DFEHC_OPTION_MAX_INTERVAL', DFEHC_OPTIONS_PREFIX . 'max_interval');
    7 define('DFEHC_OPTION_PRIORITY_SLIDER', DFEHC_OPTIONS_PREFIX . 'priority_slider');
    8 define('DFEHC_OPTION_EMA_ALPHA', DFEHC_OPTIONS_PREFIX . 'ema_alpha');
    9 define('DFEHC_OPTION_MAX_SERVER_LOAD', DFEHC_OPTIONS_PREFIX . 'max_server_load');
    10 define('DFEHC_OPTION_MAX_RESPONSE_TIME', DFEHC_OPTIONS_PREFIX . 'max_response_time');
    11 define('DFEHC_OPTION_SMA_WINDOW', DFEHC_OPTIONS_PREFIX . 'sma_window');
    12 define('DFEHC_OPTION_MAX_DECREASE_RATE', DFEHC_OPTIONS_PREFIX . 'max_decrease_rate');
    13 
    14 define('DFEHC_DEFAULT_MIN_INTERVAL', 15);
    15 define('DFEHC_DEFAULT_MAX_INTERVAL', 300);
    16 define('DFEHC_DEFAULT_MAX_SERVER_LOAD', 85);
    17 define('DFEHC_DEFAULT_MAX_RESPONSE_TIME', 5.0);
    18 define('DFEHC_DEFAULT_EMA_ALPHA', 0.4);
    19 define('DFEHC_DEFAULT_SMA_WINDOW', 5);
    20 define('DFEHC_DEFAULT_MAX_DECREASE_RATE', 0.25);
    21 define('DFEHC_DEFAULT_EMA_TTL', 600);
    22 
    23 function dfehc_store_lockfree(string $key, $value, int $ttl): bool
    24 {
    25     if (function_exists('wp_cache_add') && wp_cache_add($key, $value, '', $ttl)) {
    26         return true;
    27     }
    28     return set_transient($key, $value, $ttl);
    29 }
    30 
    31 function dfehc_set_transient(string $key, float $value, float $interval): void
    32 {
    33     $ttl = (int) apply_filters(
    34         'dfehc_transient_ttl',
    35         max(60, (int) ceil($interval) * 2),
    36         $key,
    37         $value,
    38         $interval
    39     );
    40     dfehc_store_lockfree($key, $value, $ttl);
    41 }
    42 
    43 function dfehc_weighted_sum(array $factors, array $weights): float
    44 {
    45     $sum = 0.0;
    46     foreach ($factors as $k => $v) {
    47         $sum += ($weights[$k] ?? 0.0) * $v;
    48     }
    49     return $sum;
    50 }
    51 
    52 function dfehc_normalize_weights(array $weights): array
    53 {
    54     $total = array_sum($weights);
    55     if ($total <= 0) {
    56         return array_fill_keys(array_keys($weights), 1 / max(1, count($weights)));
    57     }
    58     foreach ($weights as $k => $w) {
    59         $weights[$k] = $w / $total;
    60     }
    61     return $weights;
    62 }
    63 
    64 function dfehc_apply_exponential_moving_average(float $current): float
    65 {
    66     $alpha = max(
    67         0.01,
    68         min(1.0, (float) get_option(DFEHC_OPTION_EMA_ALPHA, DFEHC_DEFAULT_EMA_ALPHA))
    69     );
    70     $key  = 'dfehc_ema_' . get_current_blog_id();
    71     $prev = get_transient($key);
    72     $ema  = ($prev === false) ? $current : $alpha * $current + (1 - $alpha) * (float) $prev;
    73     $ttl  = (int) apply_filters('dfehc_ema_ttl', DFEHC_DEFAULT_EMA_TTL, $current, $ema);
    74     dfehc_store_lockfree($key, $ema, $ttl);
    75     return $ema;
    76 }
    77 
    78 function dfehc_calculate_recommended_interval(
    79     float $time_elapsed,
    80     float $load_average,
    81     float $server_response_time
    82 ): float {
    83     $min_interval      = max(1, (int) get_option(DFEHC_OPTION_MIN_INTERVAL, DFEHC_DEFAULT_MIN_INTERVAL));
    84     $max_interval      = max($min_interval, (int) get_option(DFEHC_OPTION_MAX_INTERVAL, DFEHC_DEFAULT_MAX_INTERVAL));
    85     $max_server_load   = max(0.1, (float) get_option(DFEHC_OPTION_MAX_SERVER_LOAD, DFEHC_DEFAULT_MAX_SERVER_LOAD));
    86     $max_response_time = max(0.1, (float) get_option(DFEHC_OPTION_MAX_RESPONSE_TIME, DFEHC_DEFAULT_MAX_RESPONSE_TIME));
    87 
    88     $factors = [
    89         'user_activity' => max(0.0, min(1.0, $time_elapsed / $max_interval)),
    90         'server_load'   => max(0.0, min(1.0, $load_average / $max_server_load)),
    91         'response_time' => $server_response_time > 0
    92             ? max(0.0, min(1.0, $server_response_time / $max_response_time))
    93             : 0.0,
    94     ];
    95 
    96     $slider  = max(-1.0, min(1.0, (float) get_option(DFEHC_OPTION_PRIORITY_SLIDER, 0.0)));
    97     $weights = [
    98         'user_activity' => 0.4 - 0.2 * $slider,
    99         'server_load'   => (0.6 + 0.2 * $slider) / 2,
    100         'response_time' => (0.6 + 0.2 * $slider) / 2,
    101     ];
    102     $weights = dfehc_normalize_weights($weights);
    103 
    104     $raw      = $min_interval + dfehc_weighted_sum($factors, $weights) * ($max_interval - $min_interval);
    105     $smoothed = dfehc_apply_exponential_moving_average($raw);
    106     $lagged   = dfehc_defensive_stance($smoothed);
    107 
    108     return max($min_interval, min($max_interval, $lagged));
    109 }
    110 
    111 function dfehc_calculate_interval_based_on_duration(
    112     float $avg_duration,
    113     float $load_average
    114 ): float {
    115     $min_interval = max(1, (int) get_option(DFEHC_OPTION_MIN_INTERVAL, DFEHC_DEFAULT_MIN_INTERVAL));
    116     $max_interval = max($min_interval, (int) get_option(DFEHC_OPTION_MAX_INTERVAL, DFEHC_DEFAULT_MAX_INTERVAL));
    117     if ($avg_duration <= $min_interval) {
    118         return (float) $min_interval;
    119     }
    120     if ($avg_duration >= $max_interval) {
    121         return (float) $max_interval;
    122     }
    123     return dfehc_calculate_recommended_interval($avg_duration, $load_average, 0.0);
    124 }
    125 
    126 function dfehc_smooth_moving(array $values): float
    127 {
    128     if (!$values) {
    129         return 0.0;
    130     }
    131     $window = max(1, (int) get_option(DFEHC_OPTION_SMA_WINDOW, DFEHC_DEFAULT_SMA_WINDOW));
    132     $subset = array_slice($values, -$window);
    133     if (!$subset) {
    134         return 0.0;
    135     }
    136     return array_sum($subset) / count($subset);
    137 }
    138 
    139 function dfehc_defensive_stance(float $proposed): float
    140 {
    141     $key      = 'dfehc_prev_int_' . get_current_blog_id();
    142     $previous = get_transient($key);
    143     if ($previous === false) {
    144         dfehc_store_lockfree($key, $proposed, (int) ceil($proposed));
    145         return $proposed;
    146     }
    147 
    148     $previous  = (float) $previous;
    149     $max_drop  = max(
    150         0.0,
    151         min(1.0, (float) get_option(DFEHC_OPTION_MAX_DECREASE_RATE, DFEHC_DEFAULT_MAX_DECREASE_RATE))
    152     );
    153     $max_rise  = (float) apply_filters('dfehc_max_increase_rate', 0.5);
    154 
    155     $lower = $previous * (1 - $max_drop);
    156     $upper = $previous * (1 + $max_rise);
    157     $final = max($lower, min($upper, $proposed));
    158 
    159     dfehc_store_lockfree($key, $final, (int) ceil($final));
    160     return $final;
    161 }
     4if (!defined('DFEHC_OPTIONS_PREFIX')) define('DFEHC_OPTIONS_PREFIX', 'dfehc_');
     5if (!defined('DFEHC_OPTION_MIN_INTERVAL')) define('DFEHC_OPTION_MIN_INTERVAL', DFEHC_OPTIONS_PREFIX . 'min_interval');
     6if (!defined('DFEHC_OPTION_MAX_INTERVAL')) define('DFEHC_OPTION_MAX_INTERVAL', DFEHC_OPTIONS_PREFIX . 'max_interval');
     7if (!defined('DFEHC_OPTION_PRIORITY_SLIDER')) define('DFEHC_OPTION_PRIORITY_SLIDER', DFEHC_OPTIONS_PREFIX . 'priority_slider');
     8if (!defined('DFEHC_OPTION_EMA_ALPHA')) define('DFEHC_OPTION_EMA_ALPHA', DFEHC_OPTIONS_PREFIX . 'ema_alpha');
     9if (!defined('DFEHC_OPTION_MAX_SERVER_LOAD')) define('DFEHC_OPTION_MAX_SERVER_LOAD', DFEHC_OPTIONS_PREFIX . 'max_server_load');
     10if (!defined('DFEHC_OPTION_MAX_RESPONSE_TIME')) define('DFEHC_OPTION_MAX_RESPONSE_TIME', DFEHC_OPTIONS_PREFIX . 'max_response_time');
     11if (!defined('DFEHC_OPTION_SMA_WINDOW')) define('DFEHC_OPTION_SMA_WINDOW', DFEHC_OPTIONS_PREFIX . 'sma_window');
     12if (!defined('DFEHC_OPTION_MAX_DECREASE_RATE')) define('DFEHC_OPTION_MAX_DECREASE_RATE', DFEHC_OPTIONS_PREFIX . 'max_decrease_rate');
     13
     14if (!defined('DFEHC_DEFAULT_MIN_INTERVAL')) define('DFEHC_DEFAULT_MIN_INTERVAL', 15);
     15if (!defined('DFEHC_DEFAULT_MAX_INTERVAL')) define('DFEHC_DEFAULT_MAX_INTERVAL', 300);
     16if (!defined('DFEHC_DEFAULT_MAX_SERVER_LOAD')) define('DFEHC_DEFAULT_MAX_SERVER_LOAD', 85);
     17if (!defined('DFEHC_DEFAULT_MAX_RESPONSE_TIME')) define('DFEHC_DEFAULT_MAX_RESPONSE_TIME', 5.0);
     18if (!defined('DFEHC_DEFAULT_EMA_ALPHA')) define('DFEHC_DEFAULT_EMA_ALPHA', 0.4);
     19if (!defined('DFEHC_DEFAULT_SMA_WINDOW')) define('DFEHC_DEFAULT_SMA_WINDOW', 5);
     20if (!defined('DFEHC_DEFAULT_MAX_DECREASE_RATE')) define('DFEHC_DEFAULT_MAX_DECREASE_RATE', 0.25);
     21if (!defined('DFEHC_DEFAULT_EMA_TTL')) define('DFEHC_DEFAULT_EMA_TTL', 600);
     22
     23if (!function_exists('dfehc_host_token')) {
     24    function dfehc_host_token(): string {
     25        static $t = '';
     26        if ($t !== '') return $t;
     27        $host = @php_uname('n') ?: (defined('WP_HOME') ? WP_HOME : (function_exists('home_url') ? home_url() : 'unknown'));
     28        $salt = defined('DB_NAME') ? (string) DB_NAME : '';
     29        return $t = substr(md5((string) $host . $salt), 0, 10);
     30    }
     31}
     32
     33if (!function_exists('dfehc_blog_id')) {
     34    function dfehc_blog_id(): int {
     35        return function_exists('get_current_blog_id') ? (int) get_current_blog_id() : 0;
     36    }
     37}
     38
     39if (!function_exists('dfehc_scoped_key')) {
     40    function dfehc_scoped_key(string $base): string {
     41        return "{$base}_" . dfehc_blog_id() . '_' . dfehc_host_token();
     42    }
     43}
     44
     45if (!function_exists('dfehc_store_lockfree')) {
     46    function dfehc_store_lockfree(string $key, $value, int $ttl): bool {
     47        $group = defined('DFEHC_CACHE_GROUP') ? DFEHC_CACHE_GROUP : 'dfehc';
     48        if (function_exists('wp_using_ext_object_cache') && wp_using_ext_object_cache()) {
     49            wp_cache_set($key, $value, $group, $ttl);
     50            return true;
     51        }
     52        return set_transient($key, $value, $ttl);
     53    }
     54}
     55
     56if (!function_exists('dfehc_set_transient_noautoload')) {
     57    function dfehc_set_transient_noautoload(string $key, $value, int $expiration): void {
     58        $group = defined('DFEHC_CACHE_GROUP') ? DFEHC_CACHE_GROUP : 'dfehc';
     59        if (function_exists('wp_using_ext_object_cache') && wp_using_ext_object_cache()) {
     60            if (function_exists('wp_cache_add')) {
     61                if (!wp_cache_add($key, $value, $group, $expiration)) {
     62                    wp_cache_set($key, $value, $group, $expiration);
     63                }
     64            } else {
     65                wp_cache_set($key, $value, $group, $expiration);
     66            }
     67            return;
     68        }
     69        set_transient($key, $value, $expiration);
     70        global $wpdb;
     71        if (!isset($wpdb->options)) return;
     72        $opt_key = "_transient_$key";
     73        $opt_key_to = "_transient_timeout_$key";
     74        $wpdb->suppress_errors(true);
     75        $autoload = $wpdb->get_var($wpdb->prepare("SELECT autoload FROM {$wpdb->options} WHERE option_name=%s LIMIT 1", $opt_key));
     76        if ($autoload === 'yes') {
     77            $wpdb->update($wpdb->options, ['autoload' => 'no'], ['option_name' => $opt_key, 'autoload' => 'yes'], ['%s'], ['%s','%s']);
     78        }
     79        $autoload_to = $wpdb->get_var($wpdb->prepare("SELECT autoload FROM {$wpdb->options} WHERE option_name=%s LIMIT 1", $opt_key_to));
     80        if ($autoload_to === 'yes') {
     81            $wpdb->update($wpdb->options, ['autoload' => 'no'], ['option_name' => $opt_key_to, 'autoload' => 'yes'], ['%s'], ['%s','%s']);
     82        }
     83        $wpdb->suppress_errors(false);
     84    }
     85}
     86
     87if (!function_exists('dfehc_set_transient')) {
     88    function dfehc_set_transient(string $key, float $value, float $interval): void {
     89        $ttl = (int) apply_filters('dfehc_transient_ttl', max(60, (int) ceil($interval) * 2), $key, $value, $interval);
     90        $ttl += function_exists('random_int') ? random_int(0, 5) : 0;
     91        dfehc_set_transient_noautoload($key, $value, $ttl);
     92    }
     93}
     94
     95if (!function_exists('dfehc_clamp')) {
     96    function dfehc_clamp(float $v, float $lo, float $hi): float {
     97        return max($lo, min($hi, $v));
     98    }
     99}
     100
     101if (!function_exists('dfehc_weighted_sum')) {
     102    function dfehc_weighted_sum(array $factors, array $weights): float {
     103        $sum = 0.0;
     104        foreach ($factors as $k => $v) {
     105            $sum += ((float) ($weights[$k] ?? 0.0)) * (float) $v;
     106        }
     107        return $sum;
     108    }
     109}
     110
     111if (!function_exists('dfehc_normalize_weights')) {
     112    function dfehc_normalize_weights(array $weights): array {
     113        $total = array_sum($weights);
     114        if ($total <= 0) {
     115            $n = max(1, count($weights));
     116            $equal = 1 / $n;
     117            foreach ($weights as $k => $_) {
     118                $weights[$k] = $equal;
     119            }
     120            return $weights;
     121        }
     122        foreach ($weights as $k => $w) {
     123            $weights[$k] = $w / $total;
     124        }
     125        return $weights;
     126    }
     127}
     128
     129if (!function_exists('dfehc_apply_exponential_moving_average')) {
     130    function dfehc_apply_exponential_moving_average(float $current): float {
     131        $alpha = dfehc_clamp((float) get_option(DFEHC_OPTION_EMA_ALPHA, DFEHC_DEFAULT_EMA_ALPHA), 0.01, 1.0);
     132        $key   = dfehc_scoped_key('dfehc_ema');
     133        $prev  = get_transient($key);
     134        $ema   = ($prev === false) ? $current : $alpha * $current + (1 - $alpha) * (float) $prev;
     135        $ttl_default = max(DFEHC_DEFAULT_EMA_TTL, (int) get_option(DFEHC_OPTION_MAX_INTERVAL, DFEHC_DEFAULT_MAX_INTERVAL) * 2);
     136        $ttl_default = (int) dfehc_clamp($ttl_default, 60, 86400);
     137        $ttl = (int) apply_filters('dfehc_ema_ttl', $ttl_default, $current, $ema);
     138        $ttl += function_exists('random_int') ? random_int(0, 5) : 0;
     139        dfehc_set_transient_noautoload($key, $ema, $ttl);
     140        return $ema;
     141    }
     142}
     143
     144if (!function_exists('dfehc_calculate_recommended_interval')) {
     145    function dfehc_calculate_recommended_interval(float $time_elapsed, float $load_average, float $server_response_time): float {
     146        $min_interval      = max(1, (int) get_option(DFEHC_OPTION_MIN_INTERVAL, DFEHC_DEFAULT_MIN_INTERVAL));
     147        $max_interval      = max($min_interval, (int) get_option(DFEHC_OPTION_MAX_INTERVAL, DFEHC_DEFAULT_MAX_INTERVAL));
     148        $max_server_load   = max(0.1, (float) get_option(DFEHC_OPTION_MAX_SERVER_LOAD, DFEHC_DEFAULT_MAX_SERVER_LOAD));
     149        $max_response_time = max(0.1, (float) get_option(DFEHC_OPTION_MAX_RESPONSE_TIME, DFEHC_DEFAULT_MAX_RESPONSE_TIME));
     150
     151        $custom_norm = apply_filters('dfehc_normalize_load', null, $load_average);
     152        if (is_numeric($custom_norm)) {
     153            $la = dfehc_clamp((float) $custom_norm, 0.0, 1.0);
     154        } else {
     155            if ($load_average <= 1.0) {
     156                $la = dfehc_clamp($load_average, 0.0, 1.0);
     157            } elseif ($load_average <= 100.0) {
     158                $la = dfehc_clamp($load_average / 100.0, 0.0, 1.0);
     159            } else {
     160                $assumed_cores = (float) apply_filters('dfehc_assumed_cores_for_normalization', 8.0);
     161                $la = dfehc_clamp($load_average / max(1.0, $assumed_cores), 0.0, 1.0);
     162            }
     163        }
     164
     165        $msl_ratio = $max_server_load > 1.0 ? ($max_server_load / 100.0) : $max_server_load;
     166        if ($msl_ratio <= 0) $msl_ratio = 1.0;
     167        $server_load_factor = dfehc_clamp($la / $msl_ratio, 0.0, 1.0);
     168
     169        $rt_units = apply_filters('dfehc_response_time_is_ms', null, $server_response_time);
     170        $rt = (float) $server_response_time;
     171        if ($rt_units === true) {
     172            $rt = $rt / 1000.0;
     173        } elseif ($rt_units === null) {
     174            if ($rt > ($max_response_time * 3) && $rt <= 60000.0) {
     175                $rt = $rt / 1000.0;
     176            }
     177        }
     178        $rt = max(0.0, $rt);
     179        $response_time_factor = $rt > 0 ? dfehc_clamp($rt / $max_response_time, 0.0, 1.0) : 0.0;
     180
     181        $factors = [
     182            'user_activity' => dfehc_clamp($time_elapsed / $max_interval, 0.0, 1.0),
     183            'server_load'   => $server_load_factor,
     184            'response_time' => $response_time_factor,
     185        ];
     186        $factors = (array) apply_filters('dfehc_interval_factors', $factors, $time_elapsed, $load_average, $server_response_time);
     187
     188        $slider  = dfehc_clamp((float) get_option(DFEHC_OPTION_PRIORITY_SLIDER, 0.0), -1.0, 1.0);
     189        $weights = [
     190            'user_activity' => 0.4 - 0.2 * $slider,
     191            'server_load'   => (0.6 + 0.2 * $slider) / 2,
     192            'response_time' => (0.6 + 0.2 * $slider) / 2,
     193        ];
     194        $weights = (array) apply_filters('dfehc_interval_weights', $weights, $slider);
     195        $weights = dfehc_normalize_weights($weights);
     196
     197        $raw      = $min_interval + dfehc_weighted_sum($factors, $weights) * ($max_interval - $min_interval);
     198        $smoothed = dfehc_apply_exponential_moving_average($raw);
     199        $lagged   = dfehc_defensive_stance($smoothed);
     200
     201        $final = (float) apply_filters('dfehc_interval_snap', $lagged, $min_interval, $max_interval);
     202        $final = dfehc_clamp($final, (float) $min_interval, (float) $max_interval);
     203
     204        return $final;
     205    }
     206}
     207
     208if (!function_exists('dfehc_calculate_interval_based_on_duration')) {
     209    function dfehc_calculate_interval_based_on_duration(float $avg_duration, float $load_average): float {
     210        $min_interval = max(1, (int) get_option(DFEHC_OPTION_MIN_INTERVAL, DFEHC_DEFAULT_MIN_INTERVAL));
     211        $max_interval = max($min_interval, (int) get_option(DFEHC_OPTION_MAX_INTERVAL, DFEHC_DEFAULT_MAX_INTERVAL));
     212        if ($avg_duration <= $min_interval) return (float) $min_interval;
     213        if ($avg_duration >= $max_interval) return (float) $max_interval;
     214        $proposed = dfehc_calculate_recommended_interval($avg_duration, $load_average, 0.0);
     215        return dfehc_defensive_stance($proposed);
     216    }
     217}
     218
     219if (!function_exists('dfehc_smooth_moving')) {
     220    function dfehc_smooth_moving(array $values): float {
     221        if (!$values) return 0.0;
     222        $window = max(1, (int) get_option(DFEHC_OPTION_SMA_WINDOW, DFEHC_DEFAULT_SMA_WINDOW));
     223        $subset = array_slice($values, -$window);
     224        if (!$subset) return 0.0;
     225        return array_sum($subset) / count($subset);
     226    }
     227}
     228
     229if (!function_exists('dfehc_defensive_stance')) {
     230    function dfehc_defensive_stance(float $proposed): float {
     231        $key      = dfehc_scoped_key('dfehc_prev_int');
     232        $previous = get_transient($key);
     233        if ($previous === false) {
     234            $ttl = (int) apply_filters('dfehc_prev_interval_ttl', 1800);
     235            $ttl += function_exists('random_int') ? random_int(0, 5) : 0;
     236            dfehc_set_transient_noautoload($key, $proposed, $ttl);
     237            return $proposed;
     238        }
     239        $previous  = (float) $previous;
     240        $max_drop  = dfehc_clamp((float) get_option(DFEHC_OPTION_MAX_DECREASE_RATE, DFEHC_DEFAULT_MAX_DECREASE_RATE), 0.0, 0.95);
     241        $max_rise  = dfehc_clamp((float) apply_filters('dfehc_max_increase_rate', 0.5), 0.0, 5.0);
     242        $lower     = $previous * (1 - $max_drop);
     243        $upper     = $previous * (1 + $max_rise);
     244        $final     = dfehc_clamp($proposed, $lower, $upper);
     245        $ttl = (int) apply_filters('dfehc_prev_interval_ttl', 1800);
     246        $ttl += function_exists('random_int') ? random_int(0, 5) : 0;
     247        dfehc_set_transient_noautoload($key, $final, $ttl);
     248        return $final;
     249    }
     250}
  • dynamic-front-end-heartbeat-control/trunk/engine/server-load.php

    r3347790 r3396626  
    99}
    1010
    11 define('DFEHC_SERVER_LOAD_TTL', (int) apply_filters('dfehc_server_load_ttl', 180));
    12 define('DFEHC_UNKNOWN_LOAD', 0.404);
    13 define('DFEHC_SERVER_LOAD_CACHE_KEY', 'dfehc:server_load');
    14 define('DFEHC_SERVER_LOAD_PAYLOAD_KEY', 'dfehc_server_load_payload');
    15 define('DFEHC_LOAD_LOCK', 'dfehc_load_lock');
     11if (!defined('DFEHC_CACHE_GROUP')) {
     12    define('DFEHC_CACHE_GROUP', 'dfehc');
     13}
     14if (!defined('DFEHC_SERVER_LOAD_TTL')) {
     15    define('DFEHC_SERVER_LOAD_TTL', 180);
     16}
     17if (!defined('DFEHC_SERVER_LOAD_CACHE_KEY')) {
     18    define('DFEHC_SERVER_LOAD_CACHE_KEY', 'dfehc:server_load');
     19}
     20if (!defined('DFEHC_SERVER_LOAD_PAYLOAD_KEY')) {
     21    define('DFEHC_SERVER_LOAD_PAYLOAD_KEY', 'dfehc_server_load_payload');
     22}
     23
     24if (!function_exists('dfehc_server_load_ttl')) {
     25    function dfehc_server_load_ttl(): int
     26    {
     27        return (int) apply_filters('dfehc_server_load_ttl', (int) DFEHC_SERVER_LOAD_TTL);
     28    }
     29}
     30
     31if (!function_exists('dfehc_unknown_load')) {
     32    function dfehc_unknown_load(): float
     33    {
     34        static $v;
     35        if ($v === null) {
     36            $v = (float) apply_filters('dfehc_unknown_load', 0.404);
     37        }
     38        return $v;
     39    }
     40}
     41
     42if (!function_exists('dfehc_host_token')) {
     43    function dfehc_host_token(): string
     44    {
     45        static $t = '';
     46        if ($t !== '') return $t;
     47        $host = @php_uname('n') ?: (defined('WP_HOME') ? WP_HOME : (function_exists('home_url') ? home_url() : 'unknown'));
     48        $salt = defined('DB_NAME') ? (string) DB_NAME : '';
     49        return $t = substr(md5((string) $host . $salt), 0, 10);
     50    }
     51}
     52
     53if (!function_exists('dfehc_blog_id')) {
     54    function dfehc_blog_id(): int
     55    {
     56        return function_exists('get_current_blog_id') ? (int) get_current_blog_id() : 0;
     57    }
     58}
     59
     60if (!function_exists('dfehc_key')) {
     61    function dfehc_key(string $base): string
     62    {
     63        return $base . '_' . dfehc_blog_id() . '_' . dfehc_host_token();
     64    }
     65}
     66
     67if (!function_exists('dfehc_client_ip')) {
     68    function dfehc_client_ip(): string
     69    {
     70        $ip = (string)($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
     71        $trusted = (array) apply_filters('dfehc_trusted_proxies', []);
     72        if ($trusted && in_array($ip, $trusted, true)) {
     73            $headers = (array) apply_filters('dfehc_proxy_ip_headers', ['HTTP_X_FORWARDED_FOR', 'HTTP_CF_CONNECTING_IP', 'HTTP_X_REAL_IP']);
     74            foreach ($headers as $h) {
     75                if (!empty($_SERVER[$h])) {
     76                    $raw = (string) $_SERVER[$h];
     77                    $parts = array_map('trim', explode(',', $raw));
     78                    $cand = end($parts);
     79                    if ($cand) {
     80                        return (string) apply_filters('dfehc_client_ip', $cand);
     81                    }
     82                }
     83            }
     84        }
     85        return (string) apply_filters('dfehc_client_ip', $ip);
     86    }
     87}
     88
     89if (!function_exists('dfehc_get_redis_server')) {
     90    function dfehc_get_redis_server(): string
     91    {
     92        $h = getenv('REDIS_HOST');
     93        return $h ? (string) $h : '127.0.0.1';
     94    }
     95}
     96if (!function_exists('dfehc_get_redis_port')) {
     97    function dfehc_get_redis_port(): int
     98    {
     99        $p = getenv('REDIS_PORT');
     100        return $p && ctype_digit((string) $p) ? (int) $p : 6379;
     101    }
     102}
     103if (!function_exists('dfehc_get_memcached_server')) {
     104    function dfehc_get_memcached_server(): string
     105    {
     106        $h = getenv('MEMCACHED_HOST');
     107        return $h ? (string) $h : '127.0.0.1';
     108    }
     109}
     110if (!function_exists('dfehc_get_memcached_port')) {
     111    function dfehc_get_memcached_port(): int
     112    {
     113        $p = getenv('MEMCACHED_PORT');
     114        return $p && ctype_digit((string) $p) ? (int) $p : 11211;
     115    }
     116}
    16117
    17118function _dfehc_get_cache_client(): array
     
    35136            $redis = new Redis();
    36137            if ($redis->connect(dfehc_get_redis_server(), dfehc_get_redis_port(), 1.0)) {
    37                 $pass = apply_filters('dfehc_redis_auth', getenv('REDIS_PASSWORD'));
    38                 if ($pass) {
     138                $pass = apply_filters('dfehc_redis_auth', getenv('REDIS_PASSWORD') ?: null);
     139                $user = apply_filters('dfehc_redis_user', getenv('REDIS_USERNAME') ?: null);
     140                if ($user && $pass && method_exists($redis, 'auth')) {
     141                    $redis->auth([$user, $pass]);
     142                } elseif ($pass && method_exists($redis, 'auth')) {
    39143                    $redis->auth($pass);
    40144                }
     
    42146            }
    43147        } catch (Throwable $e) {
    44             trigger_error('DFEHC Redis connect error: ' . $e->getMessage(), E_USER_WARNING);
     148            if (defined('WP_DEBUG') && WP_DEBUG) {
     149                error_log('DFEHC Redis connect error: ' . $e->getMessage());
     150            }
    45151        }
    46152    }
    47153    if (class_exists('Memcached')) {
    48154        try {
    49             $mc = new Memcached();
    50             $mc->addServer(dfehc_get_memcached_server(), dfehc_get_memcached_port());
     155            $mc = new Memcached('dfehc');
     156            if (!$mc->getServerList()) {
     157                $mc->addServer(dfehc_get_memcached_server(), dfehc_get_memcached_port());
     158            }
    51159            $user = getenv('MEMCACHED_USERNAME');
    52160            $pass = getenv('MEMCACHED_PASSWORD');
     
    56164            }
    57165            $versions = $mc->getVersion();
    58             $ok       = $versions && is_array($versions) && reset($versions) && reset($versions) !== '0.0.0';
     166            $first = is_array($versions) ? reset($versions) : false;
     167            $ok = $first && $first !== '0.0.0';
    59168            if ($ok) {
    60169                return $cached = ['client' => $mc, 'type' => 'memcached'];
    61170            }
    62171        } catch (Throwable $e) {
    63             trigger_error('DFEHC Memcached connect error: ' . $e->getMessage(), E_USER_WARNING);
     172            if (defined('WP_DEBUG') && WP_DEBUG) {
     173                error_log('DFEHC Memcached connect error: ' . $e->getMessage());
     174            }
    64175        }
    65176    }
     
    73184        return;
    74185    }
     186    $key = dfehc_key(DFEHC_SERVER_LOAD_CACHE_KEY);
     187    $ttl = dfehc_server_load_ttl();
    75188    try {
     189        $ttl += function_exists('random_int') ? random_int(0, 5) : 0;
    76190        if ($type === 'redis') {
    77             $client->setex(DFEHC_SERVER_LOAD_CACHE_KEY, DFEHC_SERVER_LOAD_TTL, $value);
     191            $client->setex($key, $ttl, $value);
    78192        } elseif ($type === 'memcached') {
    79             $client->set(DFEHC_SERVER_LOAD_CACHE_KEY, $value, DFEHC_SERVER_LOAD_TTL);
     193            $client->set($key, $value, $ttl);
    80194        }
    81195    } catch (Throwable $e) {
    82         trigger_error('DFEHC cache write error: ' . $e->getMessage(), E_USER_WARNING);
     196        if (defined('WP_DEBUG') && WP_DEBUG) {
     197            error_log('DFEHC cache write error: ' . $e->getMessage());
     198        }
    83199    }
    84200}
     
    86202function dfehc_get_server_load(): float
    87203{
    88     $payload = get_transient(DFEHC_SERVER_LOAD_PAYLOAD_KEY);
     204    $payloadKey = dfehc_key(DFEHC_SERVER_LOAD_PAYLOAD_KEY);
     205    $payload = null;
     206    if (wp_using_ext_object_cache()) {
     207        $payload = wp_cache_get($payloadKey, DFEHC_CACHE_GROUP);
     208        if ($payload === false) {
     209            $payload = null;
     210        }
     211    } else {
     212        $payload = get_transient($payloadKey);
     213    }
    89214    if (!(is_array($payload) && isset($payload['raw'], $payload['cores'], $payload['source']))) {
    90         $lock = dfehc_acquire_lock();
    91         if (!$lock) {
    92             return DFEHC_UNKNOWN_LOAD;
    93         }
    94         $data    = dfehc_detect_load_raw_with_source();
    95         $payload = [
    96             'raw'    => (float) $data['load'],
    97             'cores'  => dfehc_get_cpu_cores(),
    98             'source' => (string) $data['source'],
    99         ];
    100         set_transient(DFEHC_SERVER_LOAD_PAYLOAD_KEY, $payload, DFEHC_SERVER_LOAD_TTL);
    101         dfehc_release_lock($lock);
    102     }
    103     $raw    = (float) $payload['raw'];
    104     $cores  = (int) ($payload['cores'] ?: dfehc_get_cpu_cores());
     215        if (!dfehc_load_acquire_lock()) {
     216            return dfehc_unknown_load();
     217        }
     218        try {
     219            $data = dfehc_detect_load_raw_with_source();
     220            $payload = [
     221                'raw' => (float) $data['load'],
     222                'cores' => dfehc_get_cpu_cores(),
     223                'source' => (string) $data['source'],
     224            ];
     225            $ttl = dfehc_server_load_ttl();
     226            $ttl += function_exists('random_int') ? random_int(0, 5) : 0;
     227            if (wp_using_ext_object_cache()) {
     228                wp_cache_set($payloadKey, $payload, DFEHC_CACHE_GROUP, $ttl);
     229            } else {
     230                set_transient($payloadKey, $payload, $ttl);
     231            }
     232        } finally {
     233            dfehc_load_release_lock();
     234        }
     235    }
     236    $raw = (float) $payload['raw'];
     237    $cores = (int) ($payload['cores'] ?: dfehc_get_cpu_cores());
    105238    $source = (string) $payload['source'];
    106239    $divide = (bool) apply_filters('dfehc_divide_cpu_load', true, $raw, $cores, $source);
    107     $load   = ($source === 'cpu_load' && $divide && $cores > 0) ? $raw / $cores : $raw;
     240    $load = ($source === 'cpu_load' && $divide && $cores > 0) ? $raw / $cores : $raw;
     241    $load = max(0.0, (float) $load);
    108242    return (float) apply_filters('dfehc_contextual_load_value', $load, $source);
    109243}
     
    129263    if (function_exists('shell_exec') && !in_array('shell_exec', $disabled, true) && !ini_get('open_basedir')) {
    130264        $out = shell_exec('LANG=C uptime 2>&1');
    131         if ($out && preg_match('/load average: ([0-9.]+)/', $out, $m)) {
     265        if ($out && preg_match('/load average[s]?:\s*([0-9.]+)/', $out, $m)) {
    132266            return ['load' => (float) $m[1], 'source' => 'cpu_load'];
    133267        }
    134268    }
    135269    if (defined('DFEHC_PLUGIN_PATH')) {
    136         $est = DFEHC_PLUGIN_PATH . 'defibrillator/load-estimator.php';
     270        $est = rtrim((string) DFEHC_PLUGIN_PATH, "/\\") . '/defibrillator/load-estimator.php';
    137271        if (file_exists($est)) {
    138272            require_once $est;
    139273            if (class_exists('DynamicHeartbeat\\Dfehc_ServerLoadEstimator')) {
    140                 return [
    141                     'load'   => (float) DynamicHeartbeat\Dfehc_ServerLoadEstimator::get_server_load(),
    142                     'source' => 'fallback',
    143                 ];
    144             }
    145         }
    146     }
    147     return ['load' => DFEHC_UNKNOWN_LOAD, 'source' => 'fallback'];
     274                $pct = DynamicHeartbeat\Dfehc_ServerLoadEstimator::get_server_load();
     275                if (is_numeric($pct)) {
     276                    $cores = dfehc_get_cpu_cores();
     277                    $cpuLoad = ((float) $pct / 100.0) * max(1, $cores);
     278                    return ['load' => $cpuLoad, 'source' => 'cpu_load'];
     279                }
     280            }
     281        }
     282    }
     283    if (function_exists('do_action')) {
     284        do_action('dfehc_load_detection_fell_back');
     285    }
     286    return ['load' => dfehc_unknown_load(), 'source' => 'fallback'];
    148287}
    149288
    150289function dfehc_get_cpu_cores(): int
    151290{
     291    static $cores = null;
     292    if ($cores !== null) {
     293        return (int) $cores;
     294    }
     295    $override = getenv('DFEHC_CPU_CORES');
     296    if ($override && ctype_digit((string) $override) && (int) $override > 0) {
     297        $cores = (int) $override;
     298        return (int) $cores;
     299    }
    152300    if (is_readable('/sys/fs/cgroup/cpu.max')) {
    153         [$quota, $period] = explode(' ', trim(file_get_contents('/sys/fs/cgroup/cpu.max')));
    154         if ($quota !== 'max') {
    155             $quota  = (int) $quota;
    156             $period = (int) $period;
    157             if ($quota > 0 && $period > 0) {
    158                 return max(1, (int) ceil($quota / $period));
     301        $line = file_get_contents('/sys/fs/cgroup/cpu.max');
     302        if ($line !== false) {
     303            [$quota, $period] = explode(' ', trim($line));
     304            if ($quota !== 'max') {
     305                $q = (int) $quota;
     306                $p = (int) $period;
     307                if ($q > 0 && $p > 0) {
     308                    $cores = max(1, (int) ceil($q / $p));
     309                    $cores = (int) apply_filters('dfehc_cpu_cores', (int) $cores);
     310                    return (int) $cores;
     311                }
    159312            }
    160313        }
     
    163316        $content = file_get_contents('/proc/self/cgroup');
    164317        if ($content !== false && preg_match('/^[0-9]+:[^:]*cpu[^:]*:(.+)$/m', $content, $m)) {
    165             $path       = '/' . ltrim(trim($m[1]), '/');
    166             $base       = '/sys/fs/cgroup' . $path;
    167             $quotaFile  = "$base/cpu.cfs_quota_us";
     318            $path = '/' . ltrim(trim($m[1]), '/');
     319            $base = '/sys/fs/cgroup' . $path;
     320            $quotaFile = "$base/cpu.cfs_quota_us";
    168321            $periodFile = "$base/cpu.cfs_period_us";
    169322            if (is_readable($quotaFile) && is_readable($periodFile)) {
    170                 $quota  = (int) file_get_contents($quotaFile);
     323                $quota = (int) file_get_contents($quotaFile);
    171324                $period = (int) file_get_contents($periodFile);
    172325                if ($quota > 0 && $period > 0) {
    173                     return max(1, (int) ceil($quota / $period));
     326                    $cores = max(1, (int) ceil($quota / $period));
     327                    $cores = (int) apply_filters('dfehc_cpu_cores', (int) $cores);
     328                    return (int) $cores;
    174329                }
    175330            }
     
    177332    }
    178333    if (is_readable('/sys/fs/cgroup/cpu/cpu.cfs_quota_us') && is_readable('/sys/fs/cgroup/cpu/cpu.cfs_period_us')) {
    179         $quota  = (int) file_get_contents('/sys/fs/cgroup/cpu/cpu.cfs_quota_us');
     334        $quota = (int) file_get_contents('/sys/fs/cgroup/cpu/cpu.cfs_quota_us');
    180335        $period = (int) file_get_contents('/sys/fs/cgroup/cpu/cpu.cfs_period_us');
    181336        if ($quota > 0 && $period > 0) {
    182             return max(1, (int) ceil($quota / $period));
     337            $cores = max(1, (int) ceil($quota / $period));
     338            $cores = (int) apply_filters('dfehc_cpu_cores', (int) $cores);
     339            return (int) $cores;
    183340        }
    184341    }
     
    186343    if (function_exists('shell_exec') && !in_array('shell_exec', $disabled, true) && !ini_get('open_basedir')) {
    187344        $n = shell_exec('nproc 2>/dev/null');
    188         if ($n && ctype_digit(trim($n))) {
    189             return max(1, (int) trim($n));
     345        if ($n && ctype_digit(trim((string) $n))) {
     346            $cores = max(1, (int) trim((string) $n));
     347            $cores = (int) apply_filters('dfehc_cpu_cores', (int) $cores);
     348            return (int) $cores;
    190349        }
    191350    }
     
    195354            $cnt = preg_match_all('/^processor/m', $info);
    196355            if ($cnt) {
    197                 return $cnt;
    198             }
    199         }
    200     }
    201     return 1;
     356                $cores = (int) $cnt;
     357                $cores = (int) apply_filters('dfehc_cpu_cores', (int) $cores);
     358                return (int) $cores;
     359            }
     360        }
     361    }
     362    $cores = 1;
     363    $cores = (int) apply_filters('dfehc_cpu_cores', (int) $cores);
     364    return (int) $cores;
    202365}
    203366
    204367function dfehc_log_server_load(): void
    205368{
    206     $load   = dfehc_get_server_load();
    207     $optKey = 'dfehc_server_load_logs';
    208     $logs   = get_site_option($optKey, []);
     369    $load = dfehc_get_server_load();
     370    $optKey = 'dfehc_server_load_logs_' . dfehc_blog_id() . '_' . dfehc_host_token();
     371    $logs = get_option($optKey, []);
    209372    if (!is_array($logs)) {
    210373        $logs = [];
    211374    }
    212     $now    = time();
     375    $now = time();
    213376    $cutoff = $now - DAY_IN_SECONDS;
    214     $logs   = array_filter(
     377    $logs = array_filter(
    215378        $logs,
    216         static fn(array $row): bool => isset($row['timestamp']) && $row['timestamp'] >= $cutoff
     379        static function (array $row) use ($cutoff): bool {
     380            return isset($row['timestamp']) && $row['timestamp'] >= $cutoff;
     381        }
    217382    );
     383    if (count($logs) > 2000) {
     384        $logs = array_slice($logs, -2000);
     385    }
    218386    $logs[] = ['timestamp' => $now, 'load' => $load];
    219     update_site_option($optKey, array_values($logs), false);
     387    update_option($optKey, array_values($logs), false);
    220388}
    221389add_action('dfehc_log_server_load_hook', 'dfehc_log_server_load');
     
    223391function dfehc_get_server_load_ajax_handler(): void
    224392{
    225     $nonce = $_POST['nonce'] ?? $_GET['nonce'] ?? '';
    226     if (!wp_verify_nonce((string) $nonce, 'dfehc-ajax-nonce')) {
    227         wp_send_json_error('Invalid nonce.');
    228     }
    229     if (!is_user_logged_in() && !apply_filters('dfehc_allow_public_server_load', false)) {
    230         wp_send_json_error('Not authorised.');
    231     }
     393    $allow_public = apply_filters('dfehc_allow_public_server_load', false);
     394    if (!$allow_public) {
     395        $action = 'get_server_load';
     396        $nonce_action = 'dfehc-' . $action;
     397        $valid = function_exists('check_ajax_referer')
     398            ? check_ajax_referer($nonce_action, 'nonce', false)
     399            : wp_verify_nonce((string)($_POST['nonce'] ?? $_GET['nonce'] ?? ''), $nonce_action);
     400        if (!$valid) {
     401            wp_send_json_error(['message' => 'Invalid nonce.'], 403);
     402        }
     403        $cap = apply_filters('dfehc_required_capability', 'read');
     404        if (!current_user_can($cap)) {
     405            wp_send_json_error(['message' => 'Not authorised.'], 403);
     406        }
     407    } else {
     408        $ip = dfehc_client_ip();
     409        $rk = dfehc_key('dfehc_rl_' . md5($ip));
     410        $cnt = (int) get_transient($rk);
     411        $limit = (int) apply_filters('dfehc_public_rate_limit', 60);
     412        $win   = (int) apply_filters('dfehc_public_rate_window', 60);
     413        if ($cnt >= $limit) {
     414            wp_send_json_error(['message' => 'rate_limited'], 429);
     415        }
     416        set_transient($rk, $cnt + 1, $win);
     417    }
     418    nocache_headers();
    232419    wp_send_json_success(dfehc_get_server_load_persistent());
    233420}
    234421add_action('wp_ajax_get_server_load', 'dfehc_get_server_load_ajax_handler');
    235 add_action('wp_ajax_nopriv_get_server_load', 'dfehc_get_server_load_ajax_handler');
    236 
    237 function dfehc_get_server_load_persistent(): float {
     422
     423add_action('init', static function (): void {
     424    if (apply_filters('dfehc_allow_public_server_load', false)) {
     425        add_action('wp_ajax_nopriv_get_server_load', 'dfehc_get_server_load_ajax_handler');
     426    }
     427}, 0);
     428
     429function dfehc_get_server_load_persistent(): float
     430{
    238431    static $cached = null;
    239432    if ($cached !== null) {
    240         return $cached;
    241     }
    242 
    243     ['client' => $client, 'type' => $type] = _dfehc_get_cache_client();
     433        return (float) $cached;
     434    }
     435    ['client' => $client] = _dfehc_get_cache_client();
    244436    $val = false;
    245 
     437    $key = dfehc_key(DFEHC_SERVER_LOAD_CACHE_KEY);
    246438    if ($client) {
    247439        try {
    248             $val = $client->get(DFEHC_SERVER_LOAD_CACHE_KEY);
     440            $val = $client->get($key);
    249441        } catch (Throwable $e) {
    250442            if (defined('WP_DEBUG') && WP_DEBUG) {
    251                 trigger_error('DFEHC cache read error: ' . $e->getMessage(), E_USER_WARNING);
    252             }
    253         }
    254     }
    255 
    256     if ($val !== false) {
    257         return $cached = (float) $val;
    258     }
    259 
     443                error_log('DFEHC cache read error: ' . $e->getMessage());
     444            }
     445        }
     446    }
     447    if ($val !== false && $val !== '') {
     448        $cached = max(0.0, (float) $val);
     449        return (float) $cached;
     450    }
    260451    $fresh = dfehc_get_server_load();
     452    $fresh = max(0.0, (float) $fresh);
    261453    dfehc_cache_server_load($fresh);
    262     return $cached = $fresh;
     454    if (wp_using_ext_object_cache()) {
     455        $ttl = dfehc_server_load_ttl();
     456        $ttl += function_exists('random_int') ? random_int(0, 5) : 0;
     457        wp_cache_set($key, $fresh, DFEHC_CACHE_GROUP, $ttl);
     458    }
     459    $cached = $fresh;
     460    return (float) $cached;
    263461}
    264462
     
    267465        $schedules['dfehc_minute'] = [
    268466            'interval' => 60,
    269             'display'  => __('Server load (DFEHC)', 'dfehc'),
     467            'display' => __('Server load (DFEHC)', 'dfehc'),
    270468        ];
    271469    }
     
    279477    if (!wp_next_scheduled('dfehc_log_server_load_hook')) {
    280478        try {
    281             $start = time() - (time() % 60) + 60;
     479            $now = time();
     480            $start = $now - ($now % 60) + 60;
    282481            wp_schedule_event($start, 'dfehc_minute', 'dfehc_log_server_load_hook');
    283482        } catch (Throwable $e) {
    284483            if (defined('WP_DEBUG') && WP_DEBUG) {
    285                 trigger_error('DFEHC scheduling error: ' . $e->getMessage(), E_USER_WARNING);
     484                error_log('DFEHC scheduling error: ' . $e->getMessage());
    286485            }
    287486        }
     
    293492add_action('init', static function () use ($__dfehc_schedule): void {
    294493    $__dfehc_schedule();
    295     if (wp_next_scheduled('dfehc_log_server_load_hook')) {
    296         remove_action('init', __FUNCTION__, 1);
    297     }
    298494}, 1);
     495
     496function dfehc_load_acquire_lock(): bool
     497{
     498    $key = dfehc_key('dfehc_load_lock');
     499    if (class_exists('WP_Lock')) {
     500        $lock = new WP_Lock($key, 30);
     501        if ($lock->acquire()) {
     502            $GLOBALS['dfehc_load_lock'] = $lock;
     503            return true;
     504        }
     505        return false;
     506    }
     507    if (function_exists('wp_cache_add') && wp_cache_add($key, 1, DFEHC_CACHE_GROUP, 30)) {
     508        $GLOBALS['dfehc_load_lock_cache_key'] = $key;
     509        return true;
     510    }
     511    if (false !== get_transient($key)) {
     512        return false;
     513    }
     514    if (set_transient($key, 1, 30)) {
     515        $GLOBALS['dfehc_load_lock_transient_key'] = $key;
     516        return true;
     517    }
     518    return false;
     519}
     520
     521function dfehc_load_release_lock(): void
     522{
     523    if (isset($GLOBALS['dfehc_load_lock']) && $GLOBALS['dfehc_load_lock'] instanceof WP_Lock) {
     524        $GLOBALS['dfehc_load_lock']->release();
     525        unset($GLOBALS['dfehc_load_lock']);
     526        return;
     527    }
     528    if (isset($GLOBALS['dfehc_load_lock_cache_key'])) {
     529        wp_cache_delete($GLOBALS['dfehc_load_lock_cache_key'], DFEHC_CACHE_GROUP);
     530        unset($GLOBALS['dfehc_load_lock_cache_key']);
     531        return;
     532    }
     533    if (isset($GLOBALS['dfehc_load_lock_transient_key'])) {
     534        delete_transient($GLOBALS['dfehc_load_lock_transient_key']);
     535        unset($GLOBALS['dfehc_load_lock_transient_key']);
     536    }
     537}
  • dynamic-front-end-heartbeat-control/trunk/engine/server-response.php

    r3320647 r3396626  
    33
    44defined('DFEHC_DEFAULT_RESPONSE_TIME') || define('DFEHC_DEFAULT_RESPONSE_TIME', 50.0);
    5 defined('DFEHC_HEAD_NEG_TTL')          || define('DFEHC_HEAD_NEG_TTL', 600);
    6 defined('DFEHC_HEAD_POS_TTL')          || define('DFEHC_HEAD_POS_TTL', WEEK_IN_SECONDS);
    7 defined('DFEHC_SPIKE_OPT_EPS')         || define('DFEHC_SPIKE_OPT_EPS', 0.1);
    8 defined('DFEHC_BASELINE_EXP')          || define('DFEHC_BASELINE_EXP', 7 * DAY_IN_SECONDS);
     5defined('DFEHC_HEAD_NEG_TTL') || define('DFEHC_HEAD_NEG_TTL', 600);
     6defined('DFEHC_HEAD_POS_TTL') || define('DFEHC_HEAD_POS_TTL', WEEK_IN_SECONDS);
     7defined('DFEHC_SPIKE_OPT_EPS') || define('DFEHC_SPIKE_OPT_EPS', 0.1);
     8defined('DFEHC_BASELINE_EXP') || define('DFEHC_BASELINE_EXP', 7 * DAY_IN_SECONDS);
     9defined('DFEHC_CACHE_GROUP') || define('DFEHC_CACHE_GROUP', 'dfehc');
     10
     11if (!function_exists('dfehc_host_token')) {
     12    function dfehc_host_token(): string {
     13        static $t = '';
     14        if ($t !== '') return $t;
     15        $host = @php_uname('n') ?: (defined('WP_HOME') ? WP_HOME : (function_exists('home_url') ? home_url() : 'unknown'));
     16        $salt = defined('DB_NAME') ? (string) DB_NAME : '';
     17        return $t = substr(md5((string) $host . $salt), 0, 10);
     18    }
     19}
     20
     21if (!function_exists('dfehc_blog_id')) {
     22    function dfehc_blog_id(): int {
     23        return function_exists('get_current_blog_id') ? (int) get_current_blog_id() : 0;
     24    }
     25}
     26
     27if (!function_exists('dfehc_key')) {
     28    function dfehc_key(string $base): string {
     29        return $base . '_' . dfehc_blog_id() . '_' . dfehc_host_token();
     30    }
     31}
     32
     33if (!function_exists('dfehc_store_lockfree')) {
     34    function dfehc_store_lockfree(string $key, $value, int $ttl): bool {
     35        if (function_exists('wp_cache_add') && wp_cache_add($key, $value, DFEHC_CACHE_GROUP, $ttl)) {
     36            return true;
     37        }
     38        return set_transient($key, $value, $ttl);
     39    }
     40}
     41
     42if (!function_exists('dfehc_set_transient_noautoload')) {
     43    function dfehc_set_transient_noautoload(string $key, $value, int $expiration): void {
     44        if (wp_using_ext_object_cache()) {
     45            if (function_exists('wp_cache_add')) {
     46                if (!wp_cache_add($key, $value, DFEHC_CACHE_GROUP, $expiration)) {
     47                    wp_cache_set($key, $value, DFEHC_CACHE_GROUP, $expiration);
     48                }
     49            } else {
     50                wp_cache_set($key, $value, DFEHC_CACHE_GROUP, $expiration);
     51            }
     52            return;
     53        }
     54        set_transient($key, $value, $expiration);
     55        global $wpdb;
     56        $opt_key = "_transient_$key";
     57        $opt_key_to = "_transient_timeout_$key";
     58        $wpdb->suppress_errors(true);
     59        $autoload = $wpdb->get_var($wpdb->prepare("SELECT autoload FROM {$wpdb->options} WHERE option_name=%s LIMIT 1", $opt_key));
     60        if ($autoload === 'yes') {
     61            $wpdb->update($wpdb->options, ['autoload' => 'no'], ['option_name' => $opt_key, 'autoload' => 'yes'], ['%s'], ['%s','%s']);
     62        }
     63        $autoload_to = $wpdb->get_var($wpdb->prepare("SELECT autoload FROM {$wpdb->options} WHERE option_name=%s LIMIT 1", $opt_key_to));
     64        if ($autoload_to === 'yes') {
     65            $wpdb->update($wpdb->options, ['autoload' => 'no'], ['option_name' => $opt_key_to, 'autoload' => 'yes'], ['%s'], ['%s','%s']);
     66        }
     67        $wpdb->suppress_errors(false);
     68    }
     69}
     70
     71if (!function_exists('dfehc_client_ip')) {
     72    function dfehc_client_ip(): string {
     73        $ip = (string)($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
     74        $trusted = (array) apply_filters('dfehc_trusted_proxies', []);
     75        if ($trusted && in_array($ip, $trusted, true)) {
     76            $headers = (array) apply_filters('dfehc_proxy_ip_headers', ['HTTP_X_FORWARDED_FOR', 'HTTP_CF_CONNECTING_IP', 'HTTP_X_REAL_IP']);
     77            foreach ($headers as $h) {
     78                if (!empty($_SERVER[$h])) {
     79                    $raw = (string) $_SERVER[$h];
     80                    $parts = array_map('trim', explode(',', $raw));
     81                    $cand = end($parts);
     82                    if ($cand) {
     83                        return (string) apply_filters('dfehc_client_ip', $cand);
     84                    }
     85                }
     86            }
     87        }
     88        return (string) apply_filters('dfehc_client_ip', $ip);
     89    }
     90}
    991
    1092function dfehc_get_server_response_time(): array
    1193{
     94    $now = time();
    1295    $default_ms = (float) apply_filters('dfehc_default_response_time', DFEHC_DEFAULT_RESPONSE_TIME);
    1396
    1497    $defaults = [
    1598        'main_response_ms' => null,
    16         'db_response_ms'   => null,
    17         'method'           => '',
    18         'measurements'     => [],
    19         'recalibrated'     => false,
    20         'timestamp'        => current_time('mysql'),
    21         'baseline_used'    => null,
    22         'spike_score'      => 0.0,
     99        'db_response_ms' => null,
     100        'method' => '',
     101        'measurements' => [],
     102        'recalibrated' => false,
     103        'timestamp' => current_time('mysql'),
     104        'baseline_used' => null,
     105        'spike_score' => 0.0,
     106        'ts_unix' => $now,
    23107    ];
    24108
    25     $cached = get_transient('dfehc_cached_response_data');
     109    $cacheKey = dfehc_key('dfehc_cached_response_data');
     110    $cached = get_transient($cacheKey);
    26111    if ($cached !== false && is_array($cached)) {
    27112        return array_merge($defaults, $cached);
    28113    }
    29114
    30     if (dfehc_is_high_traffic()) {
     115    if (function_exists('dfehc_is_high_traffic') && dfehc_is_high_traffic()) {
    31116        $high = [
    32117            'main_response_ms' => $default_ms,
    33             'db_response_ms'   => null,
    34             'method'           => 'throttled',
    35             'measurements'     => [],
    36             'recalibrated'     => false,
    37             'timestamp'        => current_time('mysql'),
    38             'baseline_used'    => null,
    39             'spike_score'      => 0.0,
     118            'db_response_ms' => null,
     119            'method' => 'throttled',
     120            'measurements' => [],
     121            'recalibrated' => false,
     122            'timestamp' => current_time('mysql'),
     123            'baseline_used' => null,
     124            'spike_score' => 0.0,
     125            'ts_unix' => $now,
    40126        ];
    41         set_transient('dfehc_cached_response_data', $high, (int) apply_filters('dfehc_high_traffic_cache_expiration', 300));
     127        $ttl = (int) apply_filters('dfehc_high_traffic_cache_expiration', 300);
     128        $ttl += function_exists('random_int') ? random_int(0, 5) : 0;
     129        dfehc_set_transient_noautoload($cacheKey, $high, $ttl);
    42130        return $high;
    43131    }
     
    47135    }
    48136
    49     $results  = dfehc_perform_response_measurements($default_ms);
    50     $baseline = get_transient('dfehc_baseline_response_data');
    51     $spike    = (float) get_transient('dfehc_spike_score');
    52     $now      = time();
    53     $max_age  = (int) apply_filters('dfehc_max_baseline_age', DFEHC_BASELINE_EXP);
    54 
    55     if (is_array($baseline)) {
    56         $ts = strtotime($baseline['timestamp'] ?? 'now');
    57         if ($now - $ts > $max_age) {
    58             delete_transient('dfehc_baseline_response_data');
    59             $baseline = false;
    60         }
    61     }
    62 
    63     if ($baseline === false && $results['method'] === 'http_loopback' && $results['main_response_ms'] !== null) {
    64         $exp                  = (int) apply_filters('dfehc_baseline_expiration', DFEHC_BASELINE_EXP);
    65         $results['timestamp'] = current_time('mysql');
    66         set_transient('dfehc_baseline_response_data', $results, $exp);
    67         $baseline = $results;
    68         $spike    = 0.0;
    69     }
    70 
    71     $results['baseline_used'] = $baseline;
    72 
    73     if (is_array($baseline) && $results['method'] === 'http_loopback' && isset($results['main_response_ms'], $baseline['main_response_ms'])) {
    74         $base_ms   = max(1.0, (float) $baseline['main_response_ms']);
    75         $curr_ms   = (float) $results['main_response_ms'];
    76         $factor    = (float) apply_filters('dfehc_spike_threshold_factor', 2.0);
    77         $increment = (float) apply_filters('dfehc_spike_increment', max(1.0, $curr_ms / $base_ms - $factor));
    78         $decay     = (float) apply_filters('dfehc_spike_decay', 0.25);
    79         $threshold = (float) apply_filters('dfehc_recalibrate_threshold', 5.0);
    80 
    81         if ($curr_ms > $base_ms * $factor) {
    82             $spike += $increment;
    83         } else {
    84             $spike = max(0.0, $spike - $decay);
    85         }
    86 
    87         if ($spike >= $threshold) {
    88             set_transient('dfehc_baseline_response_data', $results, (int) apply_filters('dfehc_baseline_expiration', DFEHC_BASELINE_EXP));
    89             $spike                   = 0.0;
    90             $results['recalibrated'] = true;
    91         }
    92     }
    93 
    94     $results['spike_score'] = $spike;
    95     $prev_spike             = (float) get_transient('dfehc_spike_score');
    96 
    97     if (abs($spike - $prev_spike) >= DFEHC_SPIKE_OPT_EPS) {
    98         set_transient('dfehc_spike_score', $spike, DFEHC_BASELINE_EXP);
    99     }
    100 
    101     set_transient('dfehc_cached_response_data', $results, (int) apply_filters('dfehc_cache_expiration', 3 * MINUTE_IN_SECONDS));
    102 
    103     dfehc_release_lock();
    104 
    105     return array_merge($defaults, $results);
     137    try {
     138        $results = dfehc_perform_response_measurements($default_ms);
     139        $baselineKey = dfehc_key('dfehc_baseline_response_data');
     140        $spikeKey = dfehc_key('dfehc_spike_score');
     141        $baseline = get_transient($baselineKey);
     142        $spike = (float) get_transient($spikeKey);
     143        $max_age = (int) apply_filters('dfehc_max_baseline_age', DFEHC_BASELINE_EXP);
     144
     145        if (is_array($baseline)) {
     146            $ts = isset($baseline['ts_unix']) && is_numeric($baseline['ts_unix']) ? (int) $baseline['ts_unix'] : strtotime($baseline['timestamp'] ?? 'now');
     147            if ($now - (int) $ts > $max_age) {
     148                delete_transient($baselineKey);
     149                $baseline = false;
     150            }
     151        }
     152
     153        if ($baseline === false && $results['method'] === 'http_loopback' && $results['main_response_ms'] !== null && count((array) $results['measurements']) >= (int) apply_filters('dfehc_baseline_min_samples', 2)) {
     154            $exp = (int) apply_filters('dfehc_baseline_expiration', DFEHC_BASELINE_EXP);
     155            $results['timestamp'] = current_time('mysql');
     156            $results['ts_unix'] = $now;
     157            $exp += function_exists('random_int') ? random_int(0, 5) : 0;
     158            dfehc_set_transient_noautoload($baselineKey, $results, $exp);
     159            $baseline = $results;
     160            $spike = 0.0;
     161        }
     162
     163        $results['baseline_used'] = $baseline;
     164
     165        if (is_array($baseline) && $results['method'] === 'http_loopback' && isset($results['main_response_ms'], $baseline['main_response_ms'])) {
     166            $base_ms = max(1.0, (float) $baseline['main_response_ms']);
     167            $curr_ms = (float) $results['main_response_ms'];
     168            $factor = (float) apply_filters('dfehc_spike_threshold_factor', 2.0);
     169            $raw_inc = max(0.0, $curr_ms / $base_ms - $factor);
     170            $floor = (float) apply_filters('dfehc_spike_increment_floor', 0.25);
     171            $cap = (float) apply_filters('dfehc_spike_increment_cap', 3.0);
     172            $increment = min($cap, max($floor, $raw_inc));
     173            $decay = (float) apply_filters('dfehc_spike_decay', 0.25);
     174            $threshold = (float) apply_filters('dfehc_recalibrate_threshold', 5.0);
     175
     176            if ($curr_ms > $base_ms * $factor) {
     177                $spike += $increment;
     178            } else {
     179                $spike = max(0.0, $spike - $decay);
     180            }
     181
     182            if ($spike >= $threshold) {
     183                $results['timestamp'] = current_time('mysql');
     184                $results['ts_unix'] = $now;
     185                $exp = (int) apply_filters('dfehc_baseline_expiration', DFEHC_BASELINE_EXP);
     186                $exp += function_exists('random_int') ? random_int(0, 5) : 0;
     187                dfehc_set_transient_noautoload($baselineKey, $results, $exp);
     188                $spike = 0.0;
     189                $results['recalibrated'] = true;
     190            }
     191        }
     192
     193        $results['spike_score'] = $spike;
     194        $prev_spike = (float) get_transient($spikeKey);
     195
     196        if (abs($spike - $prev_spike) >= DFEHC_SPIKE_OPT_EPS) {
     197            $ttl = DFEHC_BASELINE_EXP + (function_exists('random_int') ? random_int(0, 5) : 0);
     198            dfehc_set_transient_noautoload($spikeKey, $spike, $ttl);
     199        }
     200
     201        $exp = (int) apply_filters('dfehc_cache_expiration', 3 * MINUTE_IN_SECONDS);
     202        $exp += function_exists('random_int') ? random_int(0, 5) : 0;
     203        dfehc_set_transient_noautoload($cacheKey, $results, $exp);
     204
     205        return array_merge($defaults, $results);
     206    } finally {
     207        dfehc_release_lock();
     208    }
    106209}
    107210
    108211function dfehc_perform_response_measurements(float $default_ms): array
    109212{
     213    $now = time();
    110214    $r = [
    111215        'main_response_ms' => null,
    112         'db_response_ms'   => null,
    113         'method'           => 'http_loopback',
    114         'measurements'     => [],
    115         'recalibrated'     => false,
    116         'timestamp'        => current_time('mysql'),
     216        'db_response_ms' => null,
     217        'method' => 'http_loopback',
     218        'measurements' => [],
     219        'recalibrated' => false,
     220        'timestamp' => current_time('mysql'),
     221        'ts_unix' => $now,
    117222    ];
    118223
     224    if (apply_filters('dfehc_disable_loopback', false) || (function_exists('wp_is_recovery_mode') && wp_is_recovery_mode())) {
     225        $r['method'] = 'throttled';
     226        $r['main_response_ms'] = $default_ms;
     227        $r['db_response_ms'] = $default_ms;
     228        return $r;
     229    }
     230
    119231    global $wpdb;
    120     $db_start = microtime(true);
    121     $wpdb->get_var("SELECT option_value FROM {$wpdb->options} LIMIT 1");
    122     $r['db_response_ms'] = (microtime(true) - $db_start) * 1000;
    123 
    124     $url = get_rest_url() ?: home_url('/wp-json/');
    125     if (!get_option('permalink_structure')) {
     232    try {
     233        $db_start = microtime(true);
     234        $wpdb->query('SELECT 1');
     235        $r['db_response_ms'] = (microtime(true) - $db_start) * 1000;
     236    } catch (\Throwable $e) {
     237        $r['db_response_ms'] = $default_ms;
     238    }
     239
     240    $rest = function_exists('get_rest_url') ? get_rest_url() : '';
     241    $url = $rest ?: (function_exists('home_url') ? home_url('/wp-json/') : '/wp-json/');
     242    if (function_exists('get_option') && !get_option('permalink_structure')) {
    126243        $url = add_query_arg('rest_route', '/', home_url('/index.php'));
    127244    }
    128245
    129     $n            = max(1, min((int) apply_filters('dfehc_num_requests', 2), 5));
    130     $sleep_us     = (int) apply_filters('dfehc_request_pause_us', 50000);
    131     $timeout      = (int) apply_filters('dfehc_request_timeout', 10);
    132     $sslverify    = (bool) apply_filters('dfehc_ssl_verify', true);
     246    $ajax_fallback = function_exists('admin_url') ? admin_url('admin-ajax.php?action=dfehc_ping') : '/wp-admin/admin-ajax.php?action=dfehc_ping';
     247    $use_ajax_fallback = false;
     248
     249    $home_host = function_exists('home_url') ? (string) parse_url(home_url(), PHP_URL_HOST) : '';
     250    $headers = (array) apply_filters('dfehc_probe_headers', [
     251        'Host' => $home_host ?: '',
     252        'Cache-Control' => 'max-age=0, must-revalidate',
     253        'X-DFEHC-Probe' => '1',
     254    ]);
     255    $hard_deadline = microtime(true) + (float) apply_filters('dfehc_total_timeout', (int) apply_filters('dfehc_request_timeout', 10) + 2);
     256
     257    if (defined('WP_HTTP_BLOCK_EXTERNAL') && WP_HTTP_BLOCK_EXTERNAL) {
     258        $probe_host = parse_url($url, PHP_URL_HOST);
     259        $accessible = getenv('WP_ACCESSIBLE_HOSTS') ?: (defined('WP_ACCESSIBLE_HOSTS') ? WP_ACCESSIBLE_HOSTS : '');
     260        $allowed_hosts = array_filter(array_map('trim', explode(',', (string) $accessible)));
     261        $is_same_host = $home_host && $probe_host && strcasecmp((string) $home_host, (string) $probe_host) === 0;
     262        if (!$is_same_host && !in_array((string) $probe_host, $allowed_hosts, true)) {
     263            $use_ajax_fallback = true;
     264        }
     265    }
     266
     267    if ($use_ajax_fallback || empty($url)) {
     268        $url = $ajax_fallback;
     269        $r['method'] = 'ajax_loopback';
     270    }
     271
     272    $n = max(1, min((int) apply_filters('dfehc_num_requests', 3), 5));
     273    $sleep_us = (int) apply_filters('dfehc_request_pause_us', 50000);
     274    $timeout = (int) apply_filters('dfehc_request_timeout', 10);
     275    $sslverify = (bool) apply_filters('dfehc_ssl_verify', true);
    133276    $get_fallback = (bool) apply_filters('dfehc_use_get_fallback', true);
    134     $use_head     = (bool) apply_filters('dfehc_use_head_method', true);
    135 
    136     $head_key       = 'dfehc_head_supported_' . md5($url);
     277    $use_head = (bool) apply_filters('dfehc_use_head_method', true);
     278    $redirection = (int) apply_filters('dfehc_redirection', 1);
     279
     280    $scheme = (string) parse_url($url, PHP_URL_SCHEME);
     281    $head_key = dfehc_key('dfehc_head_supported_' . md5($scheme . '|' . $url));
    137282    $head_supported = get_transient($head_key);
    138283    if ($head_supported === false) {
     
    140285    }
    141286
    142     $times         = [];
    143     $hard_deadline = microtime(true) + (float) apply_filters('dfehc_total_timeout', $timeout + 2);
     287    $negKey = dfehc_key('dfehc_probe_fail');
     288    if (get_transient($negKey)) {
     289        $r['method'] = 'failed';
     290        $r['main_response_ms'] = $default_ms;
     291        if ($r['db_response_ms'] === null) {
     292            $r['db_response_ms'] = $default_ms;
     293        }
     294        return $r;
     295    }
     296
     297    $times = [];
    144298
    145299    for ($i = 0; $i < $n; $i++) {
     
    152306
    153307        $args = [
    154             'timeout'   => (int) ceil($remaining),
     308            'timeout' => max(1, min((int) ceil($remaining), $timeout)),
    155309            'sslverify' => $sslverify,
    156             'headers'   => ['Cache-Control' => 'no-cache'],
     310            'headers' => $headers,
     311            'redirection' => min($redirection, 3),
     312            'reject_unsafe_urls' => true,
     313            'blocking' => true,
     314            'decompress' => false,
     315            'limit_response_size' => (int) apply_filters('dfehc_limit_response_size', 512),
    157316        ];
    158317
    159318        $start = microtime(true);
    160         $resp  = null;
    161 
    162         if ($use_head && $head_supported !== false) {
     319        $resp = null;
     320
     321        if ($use_head && $head_supported !== 0) {
    163322            $resp = wp_remote_head($probe_url, $args);
    164323            if (is_wp_error($resp) || wp_remote_retrieve_response_code($resp) >= 400) {
    165324                if ($head_supported === null) {
    166                     set_transient($head_key, 0, (int) apply_filters('dfehc_head_negative_ttl', DFEHC_HEAD_NEG_TTL));
     325                    $ttl = (int) apply_filters('dfehc_head_negative_ttl', DFEHC_HEAD_NEG_TTL);
     326                    $ttl += function_exists('random_int') ? random_int(0, 5) : 0;
     327                    dfehc_set_transient_noautoload($head_key, 0, $ttl);
    167328                }
    168329                $resp = null;
    169330            } else {
    170331                if ($head_supported === null) {
    171                     set_transient($head_key, 1, DFEHC_HEAD_POS_TTL);
     332                    $ttl = (int) apply_filters('dfehc_head_positive_ttl', DFEHC_HEAD_POS_TTL);
     333                    $ttl += function_exists('random_int') ? random_int(0, 5) : 0;
     334                    dfehc_set_transient_noautoload($head_key, 1, $ttl);
    172335                }
    173336            }
     
    179342
    180343        $code = is_wp_error($resp) ? 0 : wp_remote_retrieve_response_code($resp);
    181         if ($code >= 200 && $code < 300) {
     344        if (($code >= 200 && $code < 300) || $code === 304) {
    182345            $times[] = (microtime(true) - $start) * 1000;
    183346        }
     
    193356
    194357    if ($times) {
    195         sort($times);
    196         $cnt                   = count($times);
    197         $r['measurements']     = $times;
    198         $r['main_response_ms'] = $cnt % 2 ? $times[intdiv($cnt, 2)] : ($times[$cnt / 2 - 1] + $times[$cnt / 2]) / 2;
     358        sort($times, SORT_NUMERIC);
     359        if (count($times) >= 3 && (bool) apply_filters('dfehc_trim_extremes', true)) {
     360            array_shift($times);
     361            array_pop($times);
     362        }
     363        $cnt = count($times);
     364        $r['measurements'] = $times;
     365        $r['main_response_ms'] = $cnt % 2 ? (float) $times[intdiv($cnt, 2)] : (float) (($times[$cnt / 2 - 1] + $times[$cnt / 2]) / 2);
    199366    } else {
     367        $ttl = (int) apply_filters('dfehc_probe_fail_ttl', 60);
     368        $ttl += function_exists('random_int') ? random_int(0, 5) : 0;
     369        dfehc_set_transient_noautoload($negKey, 1, $ttl);
    200370        $r['method'] = 'failed';
    201371    }
     
    211381}
    212382
    213 function dfehc_is_high_traffic(): bool
    214 {
    215     $flag_key = 'dfehc_high_traffic_flag';
    216     $flag     = get_transient($flag_key);
    217     if ($flag !== false) {
    218         return (bool) $flag;
    219     }
    220 
    221     $threshold = (int) apply_filters('dfehc_high_traffic_threshold', 100);
    222     $cnt_key   = 'dfehc_cached_visitor_cnt';
    223     $count     = get_transient($cnt_key);
    224     if ($count === false) {
    225         $count = function_exists('dfehc_get_website_visitors') && is_numeric(dfehc_get_website_visitors()) ? (int) dfehc_get_website_visitors() : 0;
    226         set_transient($cnt_key, $count, 60);
    227     }
    228 
    229     $high = $count >= $threshold;
    230     set_transient($flag_key, $high ? 1 : 0, 60);
    231 
    232     return $high;
    233 }
    234 
    235 function dfehc_acquire_lock(): bool
    236 {
    237     $key = 'dfehc_measure_lock_' . get_current_blog_id();
    238 
    239     if (class_exists('WP_Lock')) {
    240         $lock = new WP_Lock($key, 60);
    241         if ($lock->acquire()) {
    242             $GLOBALS['dfehc_rt_lock'] = $lock;
     383function dfehc_ping_handler(): void {
     384    $ip = dfehc_client_ip();
     385    $k = dfehc_key('dfehc_ping_rl_' . md5($ip));
     386    $window = (int) apply_filters('dfehc_ping_rl_ttl', 2);
     387    $limit  = (int) apply_filters('dfehc_ping_rl_limit', 2);
     388    $cnt = (int) get_transient($k);
     389    if ($cnt >= $limit) {
     390        status_header(429);
     391        nocache_headers();
     392        wp_send_json_error('rate_limited', 429);
     393    }
     394    dfehc_set_transient_noautoload($k, $cnt + 1, $window);
     395    nocache_headers();
     396    wp_send_json_success('ok');
     397}
     398
     399add_action('wp_ajax_dfehc_ping', 'dfehc_ping_handler');
     400if (apply_filters('dfehc_enable_public_ping', true)) {
     401    add_action('wp_ajax_nopriv_dfehc_ping', 'dfehc_ping_handler');
     402}
     403
     404if (!function_exists('dfehc_acquire_lock')) {
     405    function dfehc_acquire_lock(): bool
     406    {
     407        $key = dfehc_key('dfehc_measure_lock');
     408
     409        if (class_exists('WP_Lock')) {
     410            $lock = new WP_Lock($key, 60);
     411            if ($lock->acquire()) {
     412                $GLOBALS['dfehc_rt_lock'] = $lock;
     413                return true;
     414            }
     415            return false;
     416        }
     417
     418        if (function_exists('wp_cache_add') && wp_cache_add($key, 1, DFEHC_CACHE_GROUP, 60)) {
     419            $GLOBALS['dfehc_rt_lock_cache_key'] = $key;
    243420            return true;
    244421        }
     422
     423        if (false !== get_transient($key)) {
     424            return false;
     425        }
     426
     427        if (set_transient($key, 1, 60)) {
     428            $GLOBALS['dfehc_rt_lock_transient_key'] = $key;
     429            return true;
     430        }
     431
    245432        return false;
    246433    }
    247 
    248     if (function_exists('wp_cache_add') && wp_cache_add($key, 1, '', 60)) {
    249         $GLOBALS['dfehc_rt_lock_cache_key'] = $key;
    250         return true;
    251     }
    252 
    253     if (false !== get_transient($key)) {
    254         return false;
    255     }
    256 
    257     if (set_transient($key, 1, 60)) {
    258         $GLOBALS['dfehc_rt_lock_transient_key'] = $key;
    259         return true;
    260     }
    261 
    262     return false;
    263 }
    264 
    265 function dfehc_release_lock(): void
    266 {
    267     if (isset($GLOBALS['dfehc_rt_lock']) && $GLOBALS['dfehc_rt_lock'] instanceof WP_Lock) {
    268         $GLOBALS['dfehc_rt_lock']->release();
    269         unset($GLOBALS['dfehc_rt_lock']);
    270         return;
    271     }
    272 
    273     if (isset($GLOBALS['dfehc_rt_lock_cache_key'])) {
    274         wp_cache_delete($GLOBALS['dfehc_rt_lock_cache_key']);
    275         unset($GLOBALS['dfehc_rt_lock_cache_key']);
    276         return;
    277     }
    278 
    279     if (isset($GLOBALS['dfehc_rt_lock_transient_key'])) {
    280         delete_transient($GLOBALS['dfehc_rt_lock_transient_key']);
    281         unset($GLOBALS['dfehc_rt_lock_transient_key']);
    282     }
    283 }
     434}
     435
     436if (!function_exists('dfehc_release_lock')) {
     437    function dfehc_release_lock(): void
     438    {
     439        if (isset($GLOBALS['dfehc_rt_lock']) && $GLOBALS['dfehc_rt_lock'] instanceof WP_Lock) {
     440            $GLOBALS['dfehc_rt_lock']->release();
     441            unset($GLOBALS['dfehc_rt_lock']);
     442            return;
     443        }
     444
     445        if (isset($GLOBALS['dfehc_rt_lock_cache_key'])) {
     446            wp_cache_delete($GLOBALS['dfehc_rt_lock_cache_key'], DFEHC_CACHE_GROUP);
     447            unset($GLOBALS['dfehc_rt_lock_cache_key']);
     448            return;
     449        }
     450
     451        if (isset($GLOBALS['dfehc_rt_lock_transient_key'])) {
     452            delete_transient($GLOBALS['dfehc_rt_lock_transient_key']);
     453            unset($GLOBALS['dfehc_rt_lock_transient_key']);
     454        }
     455    }
     456}
     457
     458if (!function_exists('dfehc_is_high_traffic')) {
     459    function dfehc_is_high_traffic(): bool
     460    {
     461        $flag_key = dfehc_key('dfehc_high_traffic_flag');
     462        $flag = get_transient($flag_key);
     463        if ($flag !== false) {
     464            return (bool) $flag;
     465        }
     466
     467        $threshold = (int) apply_filters('dfehc_high_traffic_threshold', 100);
     468        $cnt_key = dfehc_key('dfehc_cached_visitor_cnt');
     469        $count = get_transient($cnt_key);
     470        if ($count === false) {
     471            $count = (int) apply_filters('dfehc_website_visitors', 0);
     472            dfehc_set_transient_noautoload($cnt_key, (int) $count, 60);
     473        }
     474
     475        $load = apply_filters('dfehc_current_server_load', null);
     476        if (is_numeric($load)) {
     477            $max_load = (float) apply_filters('dfehc_high_traffic_load_threshold', 85.0);
     478            if ((float) $load >= $max_load) {
     479                dfehc_set_transient_noautoload($flag_key, 1, 60);
     480                return true;
     481            }
     482        }
     483
     484        $high = $count >= $threshold;
     485        dfehc_set_transient_noautoload($flag_key, $high ? 1 : 0, 60);
     486
     487        return $high;
     488    }
     489}
  • dynamic-front-end-heartbeat-control/trunk/engine/system-load-fallback.php

    r3320647 r3396626  
    22declare(strict_types=1);
    33
    4 defined('DFEHC_SERVER_LOAD_TTL')   || define('DFEHC_SERVER_LOAD_TTL', 60);
    5 defined('DFEHC_SENTINEL_NO_LOAD')  || define('DFEHC_SENTINEL_NO_LOAD', 0.404);
    6 defined('DFEHC_SYSTEM_LOAD_KEY')   || define('DFEHC_SYSTEM_LOAD_KEY', 'dfehc_system_load_avg');
     4defined('DFEHC_SERVER_LOAD_TTL') || define('DFEHC_SERVER_LOAD_TTL', 60);
     5defined('DFEHC_SENTINEL_NO_LOAD') || define('DFEHC_SENTINEL_NO_LOAD', 0.404);
     6defined('DFEHC_SYSTEM_LOAD_KEY') || define('DFEHC_SYSTEM_LOAD_KEY', 'dfehc_system_load_avg');
     7defined('DFEHC_CACHE_GROUP') || define('DFEHC_CACHE_GROUP', 'dfehc');
     8
     9if (!function_exists('dfehc_host_token')) {
     10    function dfehc_host_token(): string {
     11        static $t = '';
     12        if ($t !== '') return $t;
     13        $host = @php_uname('n') ?: (defined('WP_HOME') ? WP_HOME : (function_exists('home_url') ? home_url() : 'unknown'));
     14        $salt = defined('DB_NAME') ? (string) DB_NAME : '';
     15        return $t = substr(md5((string) $host . $salt), 0, 10);
     16    }
     17}
     18
     19if (!function_exists('dfehc_blog_id')) {
     20    function dfehc_blog_id(): int {
     21        return function_exists('get_current_blog_id') ? (int) get_current_blog_id() : 0;
     22    }
     23}
     24
     25if (!function_exists('dfehc_scoped_key')) {
     26    function dfehc_scoped_key(string $base): string {
     27        return $base . '_' . dfehc_blog_id() . '_' . dfehc_host_token();
     28    }
     29}
     30
     31if (!function_exists('dfehc_set_transient_noautoload')) {
     32    function dfehc_set_transient_noautoload(string $key, $value, int $expiration): void {
     33        $jitter = function_exists('random_int') ? random_int(0, 5) : 0;
     34        $expiration = max(1, $expiration + $jitter);
     35        if (function_exists('wp_using_ext_object_cache') && wp_using_ext_object_cache()) {
     36            wp_cache_set($key, $value, DFEHC_CACHE_GROUP, $expiration);
     37            return;
     38        }
     39        set_transient($key, $value, $expiration);
     40        if (isset($GLOBALS['wpdb'])) {
     41            global $wpdb;
     42            if (!isset($wpdb->options)) {
     43                return;
     44            }
     45            $wpdb->suppress_errors(true);
     46            $wpdb->query($wpdb->prepare("UPDATE {$wpdb->options} SET autoload='no' WHERE option_name=%s AND autoload='yes' LIMIT 1", "_transient_$key"));
     47            $wpdb->query($wpdb->prepare("UPDATE {$wpdb->options} SET autoload='no' WHERE option_name=%s AND autoload='yes' LIMIT 1", "_transient_timeout_$key"));
     48            $wpdb->suppress_errors(false);
     49        }
     50    }
     51}
     52
     53if (!function_exists('dfehc_exec_with_timeout')) {
     54    function dfehc_exec_with_timeout(string $cmd, float $timeoutSec = 1.0): string {
     55        $timeoutSec = max(0.1, min(5.0, $timeoutSec));
     56        $disabled = array_map('trim', explode(',', (string) ini_get('disable_functions')));
     57        if (ini_get('open_basedir')) {
     58            return '';
     59        }
     60        $can_proc = function_exists('proc_open') && !in_array('proc_open', $disabled, true);
     61        if ($can_proc) {
     62            $descriptorspec = [['pipe','r'],['pipe','w'],['pipe','w']];
     63            $process = @proc_open($cmd, $descriptorspec, $pipes);
     64            if (!is_resource($process)) return '';
     65            foreach ($pipes as $p) { @stream_set_blocking($p, false); @stream_set_timeout($p, (int)ceil($timeoutSec)); }
     66            $out = ''; $err = '';
     67            $start = microtime(true);
     68            $spins = 0;
     69            while (true) {
     70                $out .= @stream_get_contents($pipes[1]) ?: '';
     71                $err .= @stream_get_contents($pipes[2]) ?: '';
     72                $status = @proc_get_status($process);
     73                if (!$status || !$status['running']) break;
     74                if ((microtime(true) - $start) > $timeoutSec) { @proc_terminate($process); break; }
     75                $spins++;
     76                usleep($spins > 10 ? 25000 : 10000);
     77            }
     78            foreach ($pipes as $p) { @fclose($p); }
     79            @proc_close($process);
     80            return trim($out);
     81        }
     82        $can_shell = function_exists('shell_exec') && !in_array('shell_exec', $disabled, true);
     83        if ($can_shell) {
     84            return trim((string) @shell_exec($cmd));
     85        }
     86        return '';
     87    }
     88}
    789
    890if (!function_exists('dfehc_get_cpu_cores')) {
    9     function dfehc_get_cpu_cores(): int
    10     {
     91    function dfehc_get_cpu_cores(): int {
    1192        static $cached = null;
    12         if ($cached !== null) {
    13             return $cached;
    14         }
    15 
     93        if ($cached !== null) return $cached;
     94        $tkey = dfehc_scoped_key('dfehc_cpu_cores');
     95        if (function_exists('wp_using_ext_object_cache') && wp_using_ext_object_cache()) {
     96            $oc = wp_cache_get($tkey, DFEHC_CACHE_GROUP);
     97            if ($oc !== false && (int) $oc > 0) {
     98                return $cached = (int) $oc;
     99            }
     100        }
     101        $tc = get_transient($tkey);
     102        if ($tc !== false && (int) $tc > 0) {
     103            return $cached = (int) $tc;
     104        }
    16105        $disabled = array_map('trim', explode(',', (string) ini_get('disable_functions')));
    17 
    18         if (PHP_OS_FAMILY !== 'Windows'
    19             && function_exists('shell_exec')
    20             && !in_array('shell_exec', $disabled, true)
    21             && !ini_get('open_basedir')
    22         ) {
    23             $val = trim((string) shell_exec('getconf _NPROCESSORS_ONLN 2>NUL'));
     106        if (PHP_OS_FAMILY !== 'Windows' && function_exists('shell_exec') && !in_array('shell_exec', $disabled, true) && !ini_get('open_basedir')) {
     107            $val = trim((string) @shell_exec('getconf _NPROCESSORS_ONLN 2>/dev/null'));
    24108            if (ctype_digit($val) && (int) $val > 0) {
    25                 return $cached = (int) $val;
    26             }
    27         }
    28 
    29         if (PHP_OS_FAMILY === 'Windows'
    30             && function_exists('shell_exec')
    31             && !in_array('shell_exec', $disabled, true)
    32         ) {
    33             $out = shell_exec('wmic cpu get NumberOfLogicalProcessors /value 2>NUL');
    34             if ($out && preg_match('/NumberOfLogicalProcessors=(\d+)/i', $out, $m) && (int) $m[1] > 0) {
    35                 return $cached = (int) $m[1];
    36             }
    37         }
    38 
     109                $cores = (int) $val;
     110                dfehc_set_transient_noautoload($tkey, $cores, (int) apply_filters('dfehc_cpu_cores_ttl', DAY_IN_SECONDS));
     111                return $cached = $cores;
     112            }
     113        }
     114        if (PHP_OS_FAMILY === 'Windows' && !ini_get('open_basedir')) {
     115            $csv = dfehc_exec_with_timeout('typeperf -sc 1 "\Processor(_Total)\% Processor Time" 2>NUL', 1.5);
     116            if ($csv !== '') {
     117                $wval = dfehc_exec_with_timeout('wmic cpu get NumberOfLogicalProcessors /value 2>NUL', 1.0);
     118                if ($wval && preg_match('/NumberOfLogicalProcessors=(\d+)/i', $wval, $m) && (int) $m[1] > 0) {
     119                    $cores = (int) $m[1];
     120                    dfehc_set_transient_noautoload($tkey, $cores, (int) apply_filters('dfehc_cpu_cores_ttl', DAY_IN_SECONDS));
     121                    return $cached = $cores;
     122                }
     123            }
     124            $wout = dfehc_exec_with_timeout('wmic cpu get NumberOfLogicalProcessors /value 2>NUL', 1.0);
     125            if ($wout && preg_match('/NumberOfLogicalProcessors=(\d+)/i', $wout, $m2) && (int) $m2[1] > 0) {
     126                $cores = (int) $m2[1];
     127                dfehc_set_transient_noautoload($tkey, $cores, (int) apply_filters('dfehc_cpu_cores_ttl', DAY_IN_SECONDS));
     128                return $cached = $cores;
     129            }
     130        }
    39131        if (is_readable('/proc/cpuinfo')) {
    40             $cnt = preg_match_all('/^processor/m', (string) file_get_contents('/proc/cpuinfo'));
     132            $cnt = preg_match_all('/^processor/m', (string) @file_get_contents('/proc/cpuinfo'));
    41133            if ($cnt > 0) {
    42                 return $cached = $cnt;
    43             }
    44         }
    45 
     134                $cores = (int) $cnt;
     135                dfehc_set_transient_noautoload($tkey, $cores, (int) apply_filters('dfehc_cpu_cores_ttl', DAY_IN_SECONDS));
     136                return $cached = $cores;
     137            }
     138        }
     139        dfehc_set_transient_noautoload($tkey, 1, (int) apply_filters('dfehc_cpu_cores_ttl', DAY_IN_SECONDS));
    46140        return $cached = 1;
    47141    }
     
    49143
    50144if (!function_exists('dfehc_get_system_load_average')) {
    51     function dfehc_get_system_load_average(): float
    52     {
    53         $ttl   = (int) apply_filters('dfehc_system_load_ttl', DFEHC_SERVER_LOAD_TTL);
    54         $key   = DFEHC_SYSTEM_LOAD_KEY;
     145    function dfehc_get_system_load_average(): float {
     146        $ttl = (int) apply_filters('dfehc_system_load_ttl', DFEHC_SERVER_LOAD_TTL);
     147        $key = dfehc_scoped_key(DFEHC_SYSTEM_LOAD_KEY);
     148        if (function_exists('wp_using_ext_object_cache') && wp_using_ext_object_cache()) {
     149            $vc = wp_cache_get($key, DFEHC_CACHE_GROUP);
     150            if ($vc !== false && $vc !== null) {
     151                $ratio = (float) $vc;
     152                $as_percent = (bool) apply_filters('dfehc_system_load_return_percent', false, $ratio);
     153                return $as_percent ? (float) round($ratio * 100, 2) : $ratio;
     154            }
     155        }
    55156        $cache = get_transient($key);
    56157        if ($cache !== false) {
    57             return (float) $cache;
     158            $ratio = (float) $cache;
     159            $as_percent = (bool) apply_filters('dfehc_system_load_return_percent', false, $ratio);
     160            return $as_percent ? (float) round($ratio * 100, 2) : $ratio;
    58161        }
    59162
    60163        $raw = null;
     164        $source = '';
     165        $normalized_ratio = false;
    61166
    62167        if (function_exists('dfehc_get_server_load')) {
    63168            $val = dfehc_get_server_load();
    64169            if ($val !== DFEHC_SENTINEL_NO_LOAD) {
    65                 dfehc_set_transient_noautoload($key, $val, $ttl);
    66                 return (float) $val;
     170                dfehc_set_transient_noautoload($key, (float) $val, $ttl);
     171                $ratio = (float) $val;
     172                $as_percent = (bool) apply_filters('dfehc_system_load_return_percent', false, $ratio);
     173                return $as_percent ? (float) round($ratio * 100, 2) : $ratio;
    67174            }
    68175        }
    69176
    70177        if (function_exists('sys_getloadavg')) {
    71             $arr = sys_getloadavg();
     178            $arr = @sys_getloadavg();
    72179            if ($arr && isset($arr[0])) {
    73180                $raw = (float) $arr[0];
     181                $source = 'sys_getloadavg';
    74182            }
    75183        }
    76184
    77185        if ($raw === null && PHP_OS_FAMILY !== 'Windows' && is_readable('/proc/loadavg')) {
    78             $parts = explode(' ', (string) file_get_contents('/proc/loadavg'));
     186            $parts = explode(' ', (string) @file_get_contents('/proc/loadavg'));
    79187            if (isset($parts[0])) {
    80188                $raw = (float) $parts[0];
    81             }
    82         }
    83 
    84         if ($raw === null) {
    85             $disabled = array_map('trim', explode(',', (string) ini_get('disable_functions')));
    86             if (function_exists('shell_exec') && !in_array('shell_exec', $disabled, true)) {
    87                 if (PHP_OS_FAMILY === 'Windows') {
    88                     $out = shell_exec('wmic cpu get loadpercentage /value 2>NUL');
    89                     if ($out && preg_match('/loadpercentage=(\d+)/i', $out, $m)) {
    90                         $raw = ((float) $m[1]) / 100.0 * dfehc_get_cpu_cores();
    91                     }
     189                $source = 'proc_loadavg';
     190            }
     191        }
     192
     193        if ($raw === null && PHP_OS_FAMILY !== 'Windows') {
     194            $out = dfehc_exec_with_timeout('LANG=C uptime 2>/dev/null', 1.0);
     195            if ($out && preg_match('/load average[s]?:\s*([0-9.]+)/', $out, $m)) {
     196                $raw = (float) $m[1];
     197                $source = 'uptime';
     198            }
     199        }
     200
     201        if ($raw === null && PHP_OS_FAMILY === 'Windows' && !ini_get('open_basedir')) {
     202            $csv = dfehc_exec_with_timeout('typeperf -sc 1 "\Processor(_Total)\% Processor Time" 2>NUL', 1.5);
     203            if ($csv) {
     204                $lines = array_values(array_filter(array_map('trim', explode("\n", $csv))));
     205                $last = end($lines);
     206                if ($last && preg_match('/,"?([0-9.]+)"?$/', $last, $m)) {
     207                    $pct = (float) $m[1];
     208                    $raw = ($pct / 100.0) * dfehc_get_cpu_cores();
     209                    $source = 'typeperf';
     210                }
     211            }
     212        }
     213
     214        if ($raw === null && PHP_OS_FAMILY === 'Windows' && !ini_get('open_basedir')) {
     215            $psCmd = "powershell -NoProfile -NonInteractive -Command \"\\\$v=(Get-Counter '\\Processor(_Total)\\% Processor Time').CounterSamples[0].CookedValue; [Console]::Out.WriteLine([Math]::Round(\\\$v,2))\" 2>NUL";
     216            $ps = dfehc_exec_with_timeout($psCmd, 1.5);
     217            if ($ps !== '' && is_numeric(trim($ps))) {
     218                $pct = (float) trim($ps);
     219                $raw = ($pct / 100.0) * dfehc_get_cpu_cores();
     220                $source = 'powershell';
     221            }
     222        }
     223
     224        if ($raw === null && PHP_OS_FAMILY === 'Windows' && !ini_get('open_basedir')) {
     225            $out = dfehc_exec_with_timeout('wmic cpu get loadpercentage /value 2>NUL', 1.0);
     226            if ($out && preg_match('/loadpercentage=(\d+)/i', $out, $m)) {
     227                $pct = (float) $m[1];
     228                $raw = ($pct / 100.0) * dfehc_get_cpu_cores();
     229                $source = 'wmic';
     230            }
     231        }
     232
     233        if ($raw === null && class_exists('\\DynamicHeartbeat\\Dfehc_ServerLoadEstimator')) {
     234            $est = \DynamicHeartbeat\Dfehc_ServerLoadEstimator::get_server_load();
     235            if (is_numeric($est)) {
     236                $pct = (float) $est;
     237                if ($pct >= 0.0 && $pct <= 100.0) {
     238                    $raw = $pct / 100.0;
     239                    $normalized_ratio = true;
     240                    $source = 'estimator_percent';
    92241                } else {
    93                     $out = shell_exec('LANG=C uptime 2>/dev/null');
    94                     if ($out && preg_match('/load average[s]?:\s*([0-9.]+)/', $out, $m)) {
    95                         $raw = (float) $m[1];
    96                     }
     242                    $raw = (float) $pct;
     243                    $source = 'estimator_raw';
    97244                }
    98245            }
    99         }
    100 
    101         if ($raw === null && class_exists('\\DynamicHeartbeat\\Dfehc_ServerLoadEstimator')) {
    102             $raw = (float) \DynamicHeartbeat\Dfehc_ServerLoadEstimator::get_server_load();
    103246        }
    104247
     
    106249            $sentinel_ttl = (int) apply_filters('dfehc_sentinel_ttl', 5);
    107250            dfehc_set_transient_noautoload($key, DFEHC_SENTINEL_NO_LOAD, $sentinel_ttl);
    108             return DFEHC_SENTINEL_NO_LOAD;
    109         }
    110 
    111         $divide = (bool) apply_filters('dfehc_divide_load_by_cores', true, $raw);
    112         if ($divide) {
    113             $cores = dfehc_get_cpu_cores();
    114             if ($cores > 0) {
    115                 $raw = $raw / $cores;
    116             }
    117         }
    118 
    119         dfehc_set_transient_noautoload($key, $raw, $ttl);
    120         return (float) $raw;
    121     }
    122 }
     251            $ratio = (float) DFEHC_SENTINEL_NO_LOAD;
     252            $as_percent = (bool) apply_filters('dfehc_system_load_return_percent', false, $ratio);
     253            return $as_percent ? (float) round($ratio * 100, 2) : $ratio;
     254        }
     255
     256        if ($normalized_ratio) {
     257            $ratio = (float) $raw;
     258        } else {
     259            $divide = (bool) apply_filters('dfehc_divide_load_by_cores', true, $raw, $source);
     260            if ($divide) {
     261                $cores = dfehc_get_cpu_cores();
     262                $ratio = $cores > 0 ? ((float) $raw) / $cores : (float) $raw;
     263            } else {
     264                $ratio = (float) $raw;
     265            }
     266        }
     267
     268        dfehc_set_transient_noautoload($key, $ratio, $ttl);
     269        $as_percent = (bool) apply_filters('dfehc_system_load_return_percent', false, $ratio);
     270        return $as_percent ? (float) round($ratio * 100, 2) : (float) $ratio;
     271    }
     272}
  • dynamic-front-end-heartbeat-control/trunk/heartbeat-async.php

    r3320647 r3396626  
    22declare(strict_types=1);
    33
    4 define('DFEHC_MAX_SERVER_LOAD', 85);
    5 define('DFEHC_MIN_INTERVAL', 15);
    6 define('DFEHC_MAX_INTERVAL', 300);
    7 define('DFEHC_FALLBACK_INTERVAL', 60);
    84define('DFEHC_LOAD_AVERAGES', 'dfehc_load_averages');
    95define('DFEHC_SERVER_LOAD', 'dfehc_server_load');
     
    117define('DFEHC_CAPABILITY', 'read');
    128define('DFEHC_LOAD_LOCK_BASE', 'dfehc_compute_load_lock');
     9define('DFEHC_CACHE_GROUP', 'dfehc');
     10
     11function dfehc_max_server_load(): int { static $v; if ($v === null) { $v = (int) apply_filters('dfehc_max_server_load', 85); } return $v; }
     12function dfehc_min_interval(): int { static $v; if ($v === null) { $v = (int) apply_filters('dfehc_min_interval', 15); } return $v; }
     13function dfehc_max_interval(): int { static $v; if ($v === null) { $v = (int) apply_filters('dfehc_max_interval', 300); } return $v; }
     14function dfehc_fallback_interval(): int { static $v; if ($v === null) { $v = (int) apply_filters('dfehc_fallback_interval', 60); } return $v; }
     15function dfehc_server_load_ttl(): int { static $v; if ($v === null) { $v = (int) apply_filters('dfehc_server_load_ttl', 180); } return $v; }
     16
     17function dfehc_host_token(): string
     18{
     19    $host = @php_uname('n') ?: (defined('WP_HOME') ? WP_HOME : (function_exists('home_url') ? home_url() : 'unknown'));
     20    $salt = defined('DB_NAME') ? (string) DB_NAME : '';
     21    return substr(md5((string) $host . $salt), 0, 10);
     22}
     23
     24function dfehc_scoped_key(string $base): string
     25{
     26    $blog = function_exists('get_current_blog_id') ? (string) get_current_blog_id() : '0';
     27    return "{$base}_{$blog}_" . dfehc_host_token();
     28}
    1329
    1430function dfehc_store_lockfree(string $key, $value, int $ttl): bool
    1531{
    16     if (function_exists('wp_cache_add') && wp_cache_add($key, $value, '', $ttl)) {
     32    if (function_exists('wp_cache_add') && wp_cache_add($key, $value, DFEHC_CACHE_GROUP, $ttl)) {
    1733        return true;
    1834    }
     
    2036}
    2137
     38function dfehc_client_ip(): string
     39{
     40    $ip = isset($_SERVER['REMOTE_ADDR']) ? (string) $_SERVER['REMOTE_ADDR'] : '0.0.0.0';
     41    $trusted = (array) apply_filters('dfehc_trusted_proxies', []);
     42    if ($trusted && in_array($ip, $trusted, true)) {
     43        $headers = (array) apply_filters('dfehc_proxy_ip_headers', ['HTTP_X_FORWARDED_FOR', 'HTTP_CF_CONNECTING_IP', 'HTTP_X_REAL_IP']);
     44        foreach ($headers as $h) {
     45            if (!empty($_SERVER[$h])) {
     46                $raw = (string) $_SERVER[$h];
     47                $parts = array_map('trim', explode(',', $raw));
     48                $cand = end($parts);
     49                if ($cand) {
     50                    return $cand;
     51                }
     52            }
     53        }
     54    }
     55    return $ip;
     56}
     57
    2258function dfehc_register_ajax(string $action, callable $callback): void
    2359{
    2460    add_action("wp_ajax_$action", $callback);
    25     if (apply_filters('dfehc_allow_public_server_load', false)) {
     61    $allow_public = false;
     62    if ($action === 'get_server_load') {
     63        $allow_public = (bool) apply_filters('dfehc_allow_public_server_load', false);
     64    } elseif ($action === 'dfehc_async_heartbeat') {
     65        $allow_public = (bool) apply_filters('dfehc_allow_public_async', false);
     66    } else {
     67        $allow_public = (bool) apply_filters("{$action}_allow_public", false);
     68    }
     69    if ($allow_public) {
    2670        add_action("wp_ajax_nopriv_$action", $callback);
    2771    }
     
    3175{
    3276    if (wp_using_ext_object_cache()) {
    33         wp_cache_set($key, $value, '', $expiration);
     77        if (function_exists('wp_cache_add')) {
     78            if (!wp_cache_add($key, $value, DFEHC_CACHE_GROUP, $expiration)) {
     79                wp_cache_set($key, $value, DFEHC_CACHE_GROUP, $expiration);
     80            }
     81        } else {
     82            wp_cache_set($key, $value, DFEHC_CACHE_GROUP, $expiration);
     83        }
    3484        return;
    3585    }
    3686    dfehc_store_lockfree($key, $value, $expiration);
    3787    global $wpdb;
    38     $wpdb->query(
    39         $wpdb->prepare(
    40             "UPDATE {$wpdb->options} SET autoload='no' WHERE option_name=%s AND autoload='yes' LIMIT 1",
    41             "_transient_$key"
    42         )
    43     );
     88    $opt_key = "_transient_$key";
     89    $opt_key_to = "_transient_timeout_$key";
     90    $wpdb->suppress_errors(true);
     91    $autoload = $wpdb->get_var($wpdb->prepare("SELECT autoload FROM {$wpdb->options} WHERE option_name=%s LIMIT 1", $opt_key));
     92    if ($autoload === 'yes') {
     93        $wpdb->update($wpdb->options, ['autoload' => 'no'], ['option_name' => $opt_key, 'autoload' => 'yes'], ['%s'], ['%s','%s']);
     94    }
     95    $autoload_to = $wpdb->get_var($wpdb->prepare("SELECT autoload FROM {$wpdb->options} WHERE option_name=%s LIMIT 1", $opt_key_to));
     96    if ($autoload_to === 'yes') {
     97        $wpdb->update($wpdb->options, ['autoload' => 'no'], ['option_name' => $opt_key_to, 'autoload' => 'yes'], ['%s'], ['%s','%s']);
     98    }
     99    $wpdb->suppress_errors(false);
    44100}
    45101
     
    49105        static $cached = null;
    50106        if ($cached !== null) {
    51             return $cached;
    52         }
     107            return (int) $cached;
     108        }
     109        $detected = 1;
    53110        if (is_readable('/proc/cpuinfo')) {
    54111            $cnt = preg_match_all('/^processor/m', (string) file_get_contents('/proc/cpuinfo'));
    55112            if ($cnt > 0) {
    56                 return $cached = $cnt;
    57             }
    58         }
    59         $disabled = array_map('trim', explode(',', (string) ini_get('disable_functions')));
    60         if (function_exists('shell_exec') && !in_array('shell_exec', $disabled, true)) {
    61             $out = (string) (shell_exec('nproc 2>/dev/null') ?? '');
    62             if (ctype_digit(trim($out)) && (int) $out > 0) {
    63                 return $cached = (int) trim($out);
    64             }
    65         }
    66         return $cached = 1;
     113                $detected = $cnt;
     114            }
     115        } else {
     116            $disabled = array_map('trim', explode(',', (string) ini_get('disable_functions')));
     117            if (function_exists('shell_exec') && !in_array('shell_exec', $disabled, true)) {
     118                $out = (string) (shell_exec('nproc 2>/dev/null') ?? '');
     119                if (ctype_digit(trim($out)) && (int) $out > 0) {
     120                    $detected = (int) trim($out);
     121                }
     122            }
     123        }
     124        $env_override = getenv('DFEHC_CPU_CORES');
     125        if ($env_override !== false && ctype_digit((string) $env_override) && (int) $env_override > 0) {
     126            $detected = (int) $env_override;
     127        }
     128        $detected = (int) apply_filters('dfehc_cpu_cores', $detected);
     129        if ($detected <= 0) {
     130            $detected = 1;
     131        }
     132        $cached = $detected;
     133        return (int) $cached;
    67134    }
    68135}
     
    70137function dfehc_acquire_lock(string $base, int $ttl)
    71138{
    72     $key = $base . '_' . get_current_blog_id();
     139    $key = dfehc_scoped_key($base);
    73140    if (class_exists('WP_Lock')) {
    74141        $lock = new WP_Lock($key, $ttl);
     
    78145        return null;
    79146    }
    80     if (function_exists('wp_cache_add') && wp_cache_add($key, 1, '', $ttl)) {
     147    if (function_exists('wp_cache_add') && wp_cache_add($key, 1, DFEHC_CACHE_GROUP, $ttl)) {
    81148        return (object) ['cache_key' => $key];
    82149    }
     
    97164    }
    98165    if (is_object($lock) && isset($lock->cache_key)) {
    99         wp_cache_delete($lock->cache_key);
     166        wp_cache_delete($lock->cache_key, DFEHC_CACHE_GROUP);
    100167        return;
    101168    }
     
    105172}
    106173
    107 function dfehc_get_or_calculate_server_load(): float|false
    108 {
    109     $load = get_transient(DFEHC_SERVER_LOAD);
     174function dfehc_get_or_calculate_server_load()
     175{
     176    $key = dfehc_scoped_key(DFEHC_SERVER_LOAD);
     177    $load = get_transient($key);
    110178    if ($load !== false) {
    111179        return (float) $load;
    112180    }
    113     $ttl = (int) apply_filters('dfehc_server_load_ttl', 180);
     181    $ttl = dfehc_server_load_ttl();
    114182    $lock = dfehc_acquire_lock(DFEHC_LOAD_LOCK_BASE, $ttl + 5);
    115183    if (!$lock) {
     
    123191    $cores = max(1, dfehc_get_cpu_cores());
    124192    $load_pct = min(100.0, round($raw / $cores * 100, 2));
    125     dfehc_set_transient_noautoload(DFEHC_SERVER_LOAD, $load_pct, $ttl);
     193    dfehc_set_transient_noautoload($key, $load_pct, $ttl);
    126194    return $load_pct;
    127195}
     
    129197function dfehc_get_server_load_ajax_handler(): void
    130198{
    131     $nonce = $_REQUEST['nonce'] ?? '';
    132     if (!wp_verify_nonce((string) $nonce, 'dfehc-ajax-nonce')) {
    133         wp_send_json_error('Heartbeat: Invalid nonce.');
    134     }
    135199    $cap = apply_filters('dfehc_required_capability', DFEHC_CAPABILITY);
    136     $allow_public = apply_filters('dfehc_allow_public_server_load', false);
    137     if (!current_user_can($cap) && !$allow_public) {
    138         wp_send_json_error('Heartbeat: Not authorised.');
     200    $allow_public = (bool) apply_filters('dfehc_allow_public_server_load', false);
     201    if (!$allow_public) {
     202        $nonce_action = 'dfehc-get_server_load';
     203        $valid = function_exists('check_ajax_referer')
     204            ? check_ajax_referer($nonce_action, 'nonce', false)
     205            : wp_verify_nonce(isset($_REQUEST['nonce']) ? (string) $_REQUEST['nonce'] : '', $nonce_action);
     206        if (!$valid) {
     207            wp_send_json_error(['message' => 'dfehc/get_server_load: invalid nonce'], 403);
     208        }
     209        if (!current_user_can($cap)) {
     210            wp_send_json_error(['message' => 'dfehc/get_server_load: not authorized'], 403);
     211        }
     212    } else {
     213        $limit = absint(apply_filters('dfehc_public_rate_limit', 30));
     214        $window = absint(apply_filters('dfehc_public_rate_window', 60));
     215        $ip = dfehc_client_ip();
     216        $rl_key = dfehc_scoped_key('dfehc_rl_' . md5($ip));
     217        $cnt = (int) get_transient($rl_key);
     218        if ($cnt >= $limit) {
     219            wp_send_json_error(['message' => 'dfehc/get_server_load: rate limited'], 429);
     220        }
     221        dfehc_set_transient_noautoload($rl_key, $cnt + 1, $window);
    139222    }
    140223    $load = dfehc_get_or_calculate_server_load();
    141224    if ($load === false) {
    142         wp_send_json_success(DFEHC_FALLBACK_INTERVAL);
    143     }
    144     $interval = dfehc_calculate_recommended_interval_user_activity($load);
     225        wp_send_json_success(dfehc_fallback_interval());
     226    }
     227    $interval = dfehc_calculate_recommended_interval_user_activity((float) $load);
    145228    if ($interval <= 0) {
    146         $interval = DFEHC_FALLBACK_INTERVAL;
     229        $interval = dfehc_fallback_interval();
    147230    }
    148231    wp_send_json_success($interval);
     
    150233dfehc_register_ajax('get_server_load', 'dfehc_get_server_load_ajax_handler');
    151234
    152 function dfehc_calculate_server_load(): float|false
     235function dfehc_calculate_server_load()
    153236{
    154237    if (function_exists('sys_getloadavg')) {
     
    171254        }
    172255    }
    173     if (defined('DFEHC_PLUGIN_PATH') && file_exists(DFEHC_PLUGIN_PATH . 'defibrillator/load-estimator.php')) {
    174         require_once DFEHC_PLUGIN_PATH . 'defibrillator/load-estimator.php';
    175         if (class_exists(\DynamicHeartbeat\Dfehc_ServerLoadEstimator::class)) {
    176             return \DynamicHeartbeat\Dfehc_ServerLoadEstimator::estimate();
     256    if (defined('DFEHC_PLUGIN_PATH')) {
     257        $estimator = rtrim((string) DFEHC_PLUGIN_PATH, "/\\") . '/defibrillator/load-estimator.php';
     258        if (file_exists($estimator)) {
     259            require_once $estimator;
     260            if (class_exists(\DynamicHeartbeat\Dfehc_ServerLoadEstimator::class)) {
     261                if (method_exists(\DynamicHeartbeat\Dfehc_ServerLoadEstimator::class, 'get_server_load')) {
     262                    $pct = \DynamicHeartbeat\Dfehc_ServerLoadEstimator::get_server_load();
     263                    if ($pct !== false && is_numeric($pct)) {
     264                        $cores = max(1, dfehc_get_cpu_cores());
     265                        return (float) ($cores * ((float) $pct) / 100.0);
     266                    }
     267                } elseif (method_exists(\DynamicHeartbeat\Dfehc_ServerLoadEstimator::class, 'estimate')) {
     268                    $raw = \DynamicHeartbeat\Dfehc_ServerLoadEstimator::estimate();
     269                    if (is_numeric($raw)) {
     270                        return (float) $raw;
     271                    }
     272                }
     273            }
    177274        }
    178275    }
     
    182279class Heartbeat_Async
    183280{
    184     protected string $action = 'dfehc_async_heartbeat';
    185     protected bool $scheduled = false;
     281    protected $action = 'dfehc_async_heartbeat';
     282    protected $scheduled = false;
     283
    186284    public function __construct()
    187285    {
    188286        add_action('init', [$this, 'maybe_schedule']);
     287        add_action($this->action, [$this, 'run_action']);
    189288        dfehc_register_ajax($this->action, [$this, 'handle_async_request']);
    190289    }
     290
    191291    public function maybe_schedule(): void
    192292    {
    193         if ($this->scheduled) {
     293        if ($this->scheduled === true) {
    194294            return;
    195295        }
    196296        $this->scheduled = true;
    197         $interval = 300;
    198         $aligned = time() - (time() % $interval) + $interval;
     297        $interval = absint(300);
     298        $now = time();
     299        $aligned = $now - ($now % $interval) + $interval;
     300        $schedules = function_exists('wp_get_schedules') ? wp_get_schedules() : [];
     301        if (!isset($schedules['dfehc_5_minutes'])) {
     302            return;
     303        }
    199304        if (!wp_next_scheduled($this->action)) {
    200305            wp_schedule_event($aligned, 'dfehc_5_minutes', $this->action);
    201306        } else {
    202307            $next = wp_next_scheduled($this->action);
    203             if ($next === false || $next - time() > $interval * 2) {
    204                 wp_schedule_single_event(time() + $interval, $this->action);
    205             }
    206         }
    207     }
     308            if ($next === false || ($next - $now) > ($interval * 2)) {
     309                wp_schedule_single_event($now + $interval, $this->action);
     310            }
     311        }
     312    }
     313
    208314    public function handle_async_request(): void
    209315    {
     316        $cap = apply_filters('dfehc_required_capability', DFEHC_CAPABILITY);
     317        $allow_public = (bool) apply_filters('dfehc_allow_public_async', false);
     318        if (!$allow_public) {
     319            $nonce_action = 'dfehc-' . $this->action;
     320            $valid = function_exists('check_ajax_referer')
     321                ? check_ajax_referer($nonce_action, 'nonce', false)
     322                : wp_verify_nonce(isset($_REQUEST['nonce']) ? (string) $_REQUEST['nonce'] : '', $nonce_action);
     323            if (!$valid) {
     324                wp_send_json_error(['message' => 'dfehc/dfehc_async_heartbeat: invalid nonce'], 403);
     325            }
     326            if (!current_user_can($cap)) {
     327                wp_send_json_error(['message' => 'dfehc/dfehc_async_heartbeat: not authorized'], 403);
     328            }
     329        } else {
     330            $limit = absint(apply_filters('dfehc_public_rate_limit', 30));
     331            $window = absint(apply_filters('dfehc_public_rate_window', 60));
     332            $ip = dfehc_client_ip();
     333            $rl_key = dfehc_scoped_key('dfehc_rl_' . md5($ip));
     334            $cnt = (int) get_transient($rl_key);
     335            if ($cnt >= $limit) {
     336                wp_send_json_error(['message' => 'dfehc/dfehc_async_heartbeat: rate limited'], 429);
     337            }
     338            dfehc_set_transient_noautoload($rl_key, $cnt + 1, $window);
     339        }
    210340        try {
    211341            $this->run_action();
     342            wp_send_json_success(true);
    212343        } catch (\Throwable $e) {
    213344            if (defined('WP_DEBUG') && WP_DEBUG) {
    214                 error_log('[DFEHC Async] ' . $e->getMessage());
    215             }
     345                error_log('[dfehc] async error: ' . $e->getMessage());
     346            }
     347            wp_send_json_error(['message' => 'dfehc/dfehc_async_heartbeat: internal error'], 500);
    216348        }
    217349        wp_die();
    218350    }
     351
    219352    protected function run_action(): void
    220353    {
    221         $last_activity = get_transient('dfehc_last_user_activity');
    222         if ($last_activity === false) {
     354        $lock = dfehc_acquire_lock('dfehc_async_run', 30);
     355        if (!$lock) {
    223356            return;
    224357        }
    225         $elapsed = time() - (int) $last_activity;
    226         $load_raw = dfehc_calculate_server_load();
    227         if ($load_raw === false) {
    228             return;
    229         }
    230         $cores = max(1, dfehc_get_cpu_cores());
    231         $load_pct = min(100.0, round($load_raw / $cores * 100, 2));
    232         dfehc_set_transient_noautoload(DFEHC_SERVER_LOAD, $load_pct, (int) apply_filters('dfehc_server_load_ttl', 300));
    233         $samples = get_transient(DFEHC_LOAD_AVERAGES) ?: [];
    234         $samples[] = $load_pct;
    235         if (count($samples) > 5) {
    236             array_shift($samples);
    237         }
    238         dfehc_set_transient_noautoload(DFEHC_LOAD_AVERAGES, $samples, 1800);
    239         $weights_raw = apply_filters('dfehc_load_weights', [5, 4, 3, 2, 1]);
    240         $weights = array_slice(array_pad(array_values((array) $weights_raw), count($samples), 1), 0, count($samples));
    241         $avg_load = dfehc_weighted_average($samples, $weights);
    242         $interval = $this->calculate_interval($elapsed, $avg_load);
    243         dfehc_set_transient_noautoload(DFEHC_RECOMMENDED_INTERVAL, $interval, 300);
    244     }
     358        try {
     359            $last_activity = get_transient(dfehc_scoped_key('dfehc_last_user_activity'));
     360            if ($last_activity === false) {
     361                dfehc_set_transient_noautoload(dfehc_scoped_key(DFEHC_RECOMMENDED_INTERVAL), dfehc_fallback_interval(), 300);
     362                return;
     363            }
     364            $now = time();
     365            $elapsed = $now - (int) $last_activity;
     366            if ($elapsed < 0) {
     367                $elapsed = 0;
     368            }
     369            $load_raw = dfehc_calculate_server_load();
     370            if ($load_raw === false) {
     371                dfehc_set_transient_noautoload(dfehc_scoped_key(DFEHC_RECOMMENDED_INTERVAL), dfehc_fallback_interval(), 300);
     372                return;
     373            }
     374            $cores = max(1, dfehc_get_cpu_cores());
     375            $load_pct = min(100.0, round($load_raw / $cores * 100, 2));
     376            dfehc_set_transient_noautoload(dfehc_scoped_key(DFEHC_SERVER_LOAD), $load_pct, dfehc_server_load_ttl());
     377            $samples = get_transient(dfehc_scoped_key(DFEHC_LOAD_AVERAGES));
     378            if (!is_array($samples)) {
     379                $samples = [];
     380            }
     381            $samples[] = $load_pct;
     382            if (count($samples) > 5) {
     383                array_shift($samples);
     384            }
     385            dfehc_set_transient_noautoload(dfehc_scoped_key(DFEHC_LOAD_AVERAGES), $samples, 1800);
     386            $weights_raw = apply_filters('dfehc_load_weights', [5, 4, 3, 2, 1]);
     387            $wr = array_values((array) $weights_raw);
     388            if (!$wr) {
     389                $wr = [1];
     390            }
     391            $wr_count = count($wr);
     392            $weights = [];
     393            $n = count($samples);
     394            for ($i = 0; $i < $n; $i++) {
     395                $j = min($n - 1 - $i, $wr_count - 1);
     396                $w = isset($wr[$j]) ? $wr[$j] : 1;
     397                if (!is_numeric($w) || $w <= 0) {
     398                    $w = 1;
     399                }
     400                $weights[] = (float) $w;
     401            }
     402            $avg_load = dfehc_weighted_average($samples, $weights);
     403            $interval = $this->calculate_interval($elapsed, $avg_load);
     404            dfehc_set_transient_noautoload(dfehc_scoped_key(DFEHC_RECOMMENDED_INTERVAL), $interval, 300);
     405        } finally {
     406            dfehc_release_lock($lock);
     407        }
     408    }
     409
    245410    protected function calculate_interval(int $elapsed, float $load_pct): int
    246411    {
    247         if ($elapsed <= DFEHC_MIN_INTERVAL && $load_pct < DFEHC_MAX_SERVER_LOAD) {
    248             return DFEHC_MIN_INTERVAL;
    249         }
    250         if ($elapsed >= DFEHC_MAX_INTERVAL) {
    251             return DFEHC_MAX_INTERVAL;
    252         }
    253         $load_factor = min(1.0, $load_pct / DFEHC_MAX_SERVER_LOAD);
    254         $activity_factor = ($elapsed - DFEHC_MIN_INTERVAL) / (DFEHC_MAX_INTERVAL - DFEHC_MIN_INTERVAL);
     412        if ($elapsed <= dfehc_min_interval() && $load_pct < dfehc_max_server_load()) {
     413            return dfehc_min_interval();
     414        }
     415        if ($elapsed >= dfehc_max_interval()) {
     416            return dfehc_max_interval();
     417        }
     418        $load_factor = min(1.0, $load_pct / dfehc_max_server_load());
     419        $activity_factor = ($elapsed - dfehc_min_interval()) / (dfehc_max_interval() - dfehc_min_interval());
    255420        $activity_factor = max(0.0, min(1.0, $activity_factor));
    256421        $dominant = max($load_factor, $activity_factor);
    257         return (int) round(DFEHC_MIN_INTERVAL + $dominant * (DFEHC_MAX_INTERVAL - DFEHC_MIN_INTERVAL));
     422        return (int) round(dfehc_min_interval() + $dominant * (dfehc_max_interval() - dfehc_min_interval()));
    258423    }
    259424}
     
    261426function dfehc_weighted_average(array $values, array $weights): float
    262427{
     428    if ($values === []) {
     429        return 0.0;
     430    }
    263431    $tv = 0.0;
    264432    $tw = 0.0;
    265433    foreach ($values as $i => $v) {
    266         $w = $weights[$i] ?? 1;
    267         $tv += $v * $w;
    268         $tw += $w;
     434        $w = isset($weights[$i]) ? $weights[$i] : 1;
     435        if (!is_numeric($w) || $w <= 0) {
     436            $w = 1;
     437        }
     438        $tv += (float) $v * (float) $w;
     439        $tw += (float) $w;
    269440    }
    270441    return $tw > 0 ? round($tv / $tw, 2) : 0.0;
     
    273444function dfehc_calculate_recommended_interval_user_activity(float $current_load): int
    274445{
    275     $interval = get_transient(DFEHC_RECOMMENDED_INTERVAL);
     446    $key = dfehc_scoped_key(DFEHC_RECOMMENDED_INTERVAL);
     447    $interval = get_transient($key);
    276448    if ($interval !== false) {
    277449        return (int) $interval;
    278450    }
    279     return $current_load >= DFEHC_MAX_SERVER_LOAD ? DFEHC_MAX_INTERVAL : DFEHC_MIN_INTERVAL;
     451    return $current_load >= dfehc_max_server_load() ? dfehc_max_interval() : dfehc_min_interval();
    280452}
    281453
     
    290462    return $s;
    291463}
    292 add_filter('cron_schedules', 'dfehc_register_schedules');
     464add_filter('cron_schedules', 'dfehc_register_schedules', 1);
    293465
    294466function dfehc_prune_server_load_logs(): void
    295467{
    296     $max_age = (int) apply_filters('dfehc_log_retention_seconds', DAY_IN_SECONDS);
    297     $max_cnt = (int) apply_filters('dfehc_log_retention_max', 1440);
     468    $max_age = absint(apply_filters('dfehc_log_retention_seconds', DAY_IN_SECONDS));
     469    $max_cnt = absint(apply_filters('dfehc_log_retention_max', 1440));
    298470    $now = time();
    299471    $all_ids = function_exists('get_sites')
    300         ? array_map(static fn($s) => (int) $s->blog_id, get_sites(['fields' => 'ids']))
     472        ? array_map('intval', get_sites(['fields' => 'ids']))
    301473        : [get_current_blog_id()];
    302     $chunk_size = (int) apply_filters('dfehc_prune_chunk_size', 50);
     474    $chunk_size = absint(apply_filters('dfehc_prune_chunk_size', 50));
    303475    $offset_key = 'dfehc_prune_offset';
    304476    $offset = (int) get_site_option($offset_key, 0);
     
    309481    }
    310482    foreach ($chunk as $id) {
     483        $did_switch = false;
    311484        if (is_multisite()) {
    312485            switch_to_blog($id);
    313         }
    314         $option = 'dfehc_server_load_logs_' . get_current_blog_id();
    315         $logs = get_option($option, []);
    316         if ($logs) {
    317             $cutoff = $now - $max_age;
    318             $logs = array_filter(
    319                 $logs,
    320                 static fn($row) => isset($row['timestamp']) && $row['timestamp'] >= $cutoff
    321             );
    322             if (count($logs) > $max_cnt) {
    323                 $logs = array_slice($logs, -$max_cnt);
    324             }
    325             update_option($option, array_values($logs), false);
    326         }
    327         if (is_multisite()) {
    328             restore_current_blog();
     486            $did_switch = true;
     487        }
     488        try {
     489            $option = 'dfehc_server_load_logs_' . get_current_blog_id();
     490            $logs = get_option($option, []);
     491            if ($logs) {
     492                $cutoff = $now - $max_age;
     493                $logs = array_filter(
     494                    $logs,
     495                    static function ($row) use ($cutoff) {
     496                        return isset($row['timestamp']) && (int) $row['timestamp'] >= $cutoff;
     497                    }
     498                );
     499                if (count($logs) > $max_cnt) {
     500                    $logs = array_slice($logs, -$max_cnt);
     501                }
     502                update_option($option, array_values($logs), false);
     503            }
     504        } finally {
     505            if ($did_switch) {
     506                restore_current_blog();
     507            }
    329508        }
    330509    }
     
    343522}
    344523
    345 add_filter('dfehc_required_capability', fn() => 'manage_options');
    346 add_filter('dfehc_server_load_ttl', fn() => 120);
    347 add_filter('dfehc_load_weights', fn() => [3, 2, 1]);
    348 add_filter('dfehc_async_retry', fn() => 1);
    349 add_filter('dfehc_log_retention_seconds', fn() => 2 * DAY_IN_SECONDS);
    350 add_filter('dfehc_log_retention_max', fn() => 3000);
     524add_filter('dfehc_required_capability', function () { return 'manage_options'; });
     525add_filter('dfehc_server_load_ttl', function () { return 120; });
     526add_filter('dfehc_load_weights', function () { return [3, 2, 1]; });
     527add_filter('dfehc_async_retry', function () { return 1; });
     528add_filter('dfehc_log_retention_seconds', function () { return 2 * DAY_IN_SECONDS; });
     529add_filter('dfehc_log_retention_max', function () { return 3000; });
     530add_filter('dfehc_allow_public_server_load', '__return_false');
     531add_filter('dfehc_allow_public_async', '__return_false');
    351532
    352533new Heartbeat_Async();
  • dynamic-front-end-heartbeat-control/trunk/heartbeat-controller.php

    r3320647 r3396626  
    44Plugin URI: https://heartbeat.support
    55Description: An enhanced solution to optimize the performance of your WordPress website. Stabilize your website's load averages and enhance the browsing experience for visitors during high-traffic fluctuations.
    6 Version: 1.2.99
     6Version: 1.2.995
    77Author: Codeloghin
    88Author URI: https://codeloghin.com
     
    1515    define('DFEHC_PLUGIN_PATH', plugin_dir_path(__FILE__));
    1616}
    17 
    18 require_once plugin_dir_path(__FILE__) . 'engine/interval-helper.php';
    19 require_once plugin_dir_path(__FILE__) . 'engine/server-load.php';
    20 require_once plugin_dir_path(__FILE__) . 'engine/server-response.php';
    21 require_once plugin_dir_path(__FILE__) . 'engine/system-load-fallback.php';
    22 require_once plugin_dir_path(__FILE__) . 'visitor/manager.php';
    23 require_once plugin_dir_path(__FILE__) . 'visitor/cookie-helper.php';
    24 require_once plugin_dir_path(__FILE__) . 'defibrillator/unclogger.php';
    25 require_once plugin_dir_path(__FILE__) . 'defibrillator/rest-api.php';
    26 require_once plugin_dir_path(__FILE__) . 'defibrillator/db-health.php';
    27 require_once plugin_dir_path(__FILE__) . 'widget.php';
    28 require_once plugin_dir_path(__FILE__) . 'settings.php';
    29 
     17if (!defined('DFEHC_CACHE_GROUP')) {
     18    define('DFEHC_CACHE_GROUP', 'dfehc');
     19}
    3020if (!defined('DFEHC_MIN_INTERVAL')) {
    3121    define('DFEHC_MIN_INTERVAL', 15);
     
    5343}
    5444
     45require_once DFEHC_PLUGIN_PATH . 'engine/interval-helper.php';
     46require_once DFEHC_PLUGIN_PATH . 'engine/server-load.php';
     47require_once DFEHC_PLUGIN_PATH . 'engine/server-response.php';
     48require_once DFEHC_PLUGIN_PATH . 'engine/system-load-fallback.php';
     49require_once DFEHC_PLUGIN_PATH . 'visitor/manager.php';
     50require_once DFEHC_PLUGIN_PATH . 'visitor/cookie-helper.php';
     51require_once DFEHC_PLUGIN_PATH . 'defibrillator/unclogger.php';
     52require_once DFEHC_PLUGIN_PATH . 'defibrillator/rest-api.php';
     53require_once DFEHC_PLUGIN_PATH . 'defibrillator/db-health.php';
     54require_once DFEHC_PLUGIN_PATH . 'widget.php';
     55require_once DFEHC_PLUGIN_PATH . 'settings.php';
     56
     57function dfehc_scoped_tkey(string $base): string
     58{
     59    if (function_exists('dfehc_scoped_key')) {
     60        return dfehc_scoped_key($base);
     61    }
     62    $bid = function_exists('get_current_blog_id') ? (string) get_current_blog_id() : '0';
     63    $host = @php_uname('n') ?: (defined('WP_HOME') ? WP_HOME : (function_exists('home_url') ? home_url() : 'unknown'));
     64    $salt = defined('DB_NAME') ? (string) DB_NAME : '';
     65    $tok = substr(md5((string) $host . $salt), 0, 10);
     66    return "{$base}_{$bid}_{$tok}";
     67}
     68
    5569function dfehc_set_transient_noautoload(string $key, $value, int $expiration): void
    5670{
    57     if (wp_using_ext_object_cache()) {
    58         wp_cache_set($key, $value, '', $expiration);
     71    $jitter = function_exists('random_int') ? random_int(0, 5) : 0;
     72    $expiration = max(1, $expiration + $jitter);
     73    if (function_exists('wp_using_ext_object_cache') && wp_using_ext_object_cache()) {
     74        wp_cache_set($key, $value, DFEHC_CACHE_GROUP, $expiration);
    5975        return;
    6076    }
    6177    set_transient($key, $value, $expiration);
    62     global $wpdb;
    63     $wpdb->query(
    64         $wpdb->prepare(
    65             "UPDATE {$wpdb->options} SET autoload='no' WHERE option_name=%s AND autoload='yes' LIMIT 1",
    66             '_transient_' . $key
    67         )
    68     );
     78    if (isset($GLOBALS['wpdb'])) {
     79        global $wpdb;
     80        if (!isset($wpdb->options)) {
     81            return;
     82        }
     83        $opt_key_val = '_transient_' . $key;
     84        $opt_key_to  = '_transient_timeout_' . $key;
     85        $wpdb->suppress_errors(true);
     86        $autoload = $wpdb->get_var($wpdb->prepare("SELECT autoload FROM {$wpdb->options} WHERE option_name=%s LIMIT 1", $opt_key_val));
     87        if ($autoload === 'yes') {
     88            $wpdb->query($wpdb->prepare("UPDATE {$wpdb->options} SET autoload='no' WHERE option_name=%s AND autoload='yes' LIMIT 1", $opt_key_val));
     89        }
     90        $autoload_to = $wpdb->get_var($wpdb->prepare("SELECT autoload FROM {$wpdb->options} WHERE option_name=%s LIMIT 1", $opt_key_to));
     91        if ($autoload_to === 'yes') {
     92            $wpdb->query($wpdb->prepare("UPDATE {$wpdb->options} SET autoload='no' WHERE option_name=%s AND autoload='yes' LIMIT 1", $opt_key_to));
     93        }
     94        $wpdb->suppress_errors(false);
     95    }
    6996}
    7097
    7198function dfehc_enqueue_scripts(): void
    7299{
    73     wp_enqueue_script('heartbeat', plugin_dir_url(__FILE__) . 'js/heartbeat.min.js', ['jquery'], '1.5.2', true);
    74     if (function_exists('wp_script_add_data')) {
    75         wp_script_add_data('heartbeat', 'async', true);
    76     }
    77     $load_average = dfehc_get_system_load_average();
    78     $recommended  = dfehc_calculate_recommended_interval_user_activity($load_average, DFEHC_BATCH_SIZE);
    79     wp_localize_script('heartbeat', 'dfehc_heartbeat_vars', [
    80         'recommendedInterval'       => $recommended,
     100    wp_register_script('dfehc-heartbeat', plugin_dir_url(__FILE__) . 'js/heartbeat.min.js', ['heartbeat'], '1.6.0', true);
     101    wp_enqueue_script('dfehc-heartbeat');
     102
     103    $load = function_exists('dfehc_get_server_load') ? dfehc_get_server_load() : null;
     104    if ($load === false || $load === null) {
     105        $load = (float) DFEHC_MAX_SERVER_LOAD;
     106    }
     107
     108    $recommended = function_exists('dfehc_calculate_recommended_interval_user_activity')
     109        ? dfehc_calculate_recommended_interval_user_activity((float) $load, DFEHC_BATCH_SIZE)
     110        : 60.0;
     111
     112    $host = wp_parse_url(home_url(), PHP_URL_HOST) ?: 'site';
     113    $ver  = defined('DFEHC_VERSION') ? (string) DFEHC_VERSION : (string) filemtime(__FILE__);
     114
     115    wp_localize_script('dfehc-heartbeat', 'dfehc_heartbeat_vars', [
     116        'recommendedInterval'       => (float) $recommended,
    81117        'heartbeat_control_enabled' => get_option('dfehc_heartbeat_control_enabled', '1'),
    82         'cache_duration'            => 5 * 60 * 1000,
    83118        'nonce'                     => wp_create_nonce(DFEHC_NONCE_ACTION),
     119        'ver'                       => $ver,
     120        'cache_bypass_rate'         => 0.05,
    84121    ]);
    85122}
     
    88125function dfehc_get_user_activity_summary(int $batch_size = DFEHC_BATCH_SIZE): array
    89126{
    90     $cached = get_transient('dfehc_user_activity_summary');
    91     if ($cached !== false) {
     127    $key = dfehc_scoped_tkey('dfehc_user_activity_summary');
     128    $cached = get_transient($key);
     129    if ($cached !== false && is_array($cached)) {
    92130        return $cached;
    93131    }
    94132    $summary = dfehc_gather_user_activity_data($batch_size);
    95     dfehc_set_transient_noautoload('dfehc_user_activity_summary', $summary, DFEHC_USER_ACTIVITY_TTL);
     133    dfehc_set_transient_noautoload($key, $summary, (int) DFEHC_USER_ACTIVITY_TTL);
    96134    return $summary;
    97135}
     
    99137function dfehc_calculate_recommended_interval_user_activity(?float $load_average = null, int $batch_size = DFEHC_BATCH_SIZE): float
    100138{
    101     if (!function_exists('sys_getloadavg')) {
    102         return 60.0;
    103     }
    104139    if ($load_average === null) {
    105         $load_average = dfehc_get_system_load_average();
     140        $load_average = function_exists('dfehc_get_server_load') ? dfehc_get_server_load() : null;
     141    }
     142    if ($load_average === false || $load_average === null) {
     143        $load_average = (float) DFEHC_MAX_SERVER_LOAD;
    106144    }
    107145    $user_data = dfehc_get_user_activity_summary($batch_size);
    108     if ($user_data['total_weight'] === 0) {
     146    if (empty($user_data['total_weight'])) {
    109147        return (float) DFEHC_MIN_INTERVAL;
    110148    }
    111     $avg_duration = $user_data['total_duration'] / max(1, $user_data['total_weight']);
    112     return dfehc_calculate_interval_based_on_duration($avg_duration, $load_average);
     149    $avg_duration = (float) $user_data['total_duration'] / max(1, (int) $user_data['total_weight']);
     150    return (float) dfehc_calculate_interval_based_on_duration($avg_duration, (float) $load_average);
    113151}
    114152
     
    125163        foreach ($userBatch as $user) {
    126164            $activity = get_user_meta($user->ID, 'dfehc_user_activity', true);
    127             if (empty($activity['durations'])) {
     165            if (empty($activity['durations']) || !is_array($activity['durations'])) {
    128166                continue;
    129167            }
    130             $weight   = count($activity['durations']);
    131             $avg      = array_sum($activity['durations']) / $weight;
    132             $total_weighted_duration += $weight * $avg;
     168            $weight = count($activity['durations']);
     169            if ($weight <= 0) {
     170                continue;
     171            }
     172            $avg = array_sum($activity['durations']) / $weight;
     173            $total_weighted_duration += $weight * (float) $avg;
    133174            $total_weight            += $weight;
    134175        }
     
    144185    }
    145186    if (!class_exists('Heartbeat_Async')) {
    146         return get_transient('dfehc_recommended_interval');
     187        $fallback = get_transient(dfehc_scoped_tkey('dfehc_recommended_interval'));
     188        return $fallback !== false ? (float) $fallback : (float) DFEHC_MIN_INTERVAL;
    147189    }
    148190    if (!class_exists('Dfehc_Get_Recommended_Heartbeat_Interval_Async')) {
     
    150192        {
    151193            protected string $action = 'dfehc_get_recommended_interval_async';
     194            public function dispatch(): void
     195            {
     196                if (has_action($this->action)) {
     197                    do_action($this->action);
     198                } else {
     199                    if (!wp_next_scheduled($this->action)) {
     200                        wp_schedule_single_event(time() + 1, $this->action);
     201                    }
     202                }
     203            }
    152204            protected function run_action(): void
    153205            {
    154                 $lock = dfehc_acquire_lock('dfehc_interval_calculation_lock', DFEHC_LOCK_TTL);
    155                 if (!$lock) {
     206                $lock = function_exists('dfehc_acquire_lock') ? dfehc_acquire_lock('dfehc_interval_calculation_lock', (int) DFEHC_LOCK_TTL) : null;
     207                if (!$lock && function_exists('dfehc_acquire_lock')) {
    156208                    return;
    157209                }
    158                 $last_activity = (int) get_transient('dfehc_last_user_activity');
    159                 $elapsed       = time() - $last_activity;
    160                 $load          = dfehc_get_system_load_average();
    161                 $interval      = dfehc_calculate_recommended_interval($elapsed, $load, 0);
    162                 dfehc_set_transient_noautoload('dfehc_recommended_interval', $interval, 5 * MINUTE_IN_SECONDS);
    163                 dfehc_release_lock($lock);
     210                $last_key = dfehc_scoped_tkey('dfehc_last_user_activity');
     211                $ri_key   = dfehc_scoped_tkey('dfehc_recommended_interval');
     212                $last_activity = (int) get_transient($last_key);
     213                $elapsed       = max(0, time() - $last_activity);
     214                $load = function_exists('dfehc_get_server_load') ? dfehc_get_server_load() : null;
     215                if ($load === false || $load === null) {
     216                    $load = (float) DFEHC_MAX_SERVER_LOAD;
     217                }
     218                $interval = (float) dfehc_calculate_recommended_interval((float) $elapsed, (float) $load, 0.0);
     219                if ($interval <= 0) {
     220                    $interval = (float) DFEHC_MIN_INTERVAL;
     221                }
     222                dfehc_set_transient_noautoload($ri_key, $interval, 5 * MINUTE_IN_SECONDS);
     223                if ($lock && function_exists('dfehc_release_lock')) {
     224                    dfehc_release_lock($lock);
     225                }
    164226            }
    165227        }
    166228    }
    167     $current = dfehc_get_website_visitors();
    168     $prev    = get_transient('dfehc_previous_visitor_count');
     229
     230    $vis_key   = dfehc_scoped_tkey('dfehc_previous_visitor_count');
     231    $ri_key    = dfehc_scoped_tkey('dfehc_recommended_interval');
     232
     233    $current = function_exists('dfehc_get_website_visitors') ? (int) dfehc_get_website_visitors() : 0;
     234    $prev    = get_transient($vis_key);
    169235    $ratio   = (float) apply_filters('dfehc_visitors_delta_ratio', 0.2);
    170     if ($prev === false || abs($current - $prev) > $current * $ratio) {
    171         delete_transient('dfehc_recommended_interval');
    172         dfehc_set_transient_noautoload('dfehc_previous_visitor_count', $current, 5 * MINUTE_IN_SECONDS);
    173     }
    174     $cached = get_transient('dfehc_recommended_interval');
    175     if ($cached !== false) {
     236    if ($prev === false || ($current > 0 && abs($current - (int) $prev) > $current * $ratio)) {
     237        delete_transient($ri_key);
     238        dfehc_set_transient_noautoload($vis_key, $current, 5 * MINUTE_IN_SECONDS);
     239    }
     240
     241    $cached = get_transient($ri_key);
     242    if ($cached !== false && is_numeric($cached)) {
    176243        return (float) $cached;
    177244    }
    178     $lock = dfehc_acquire_lock('dfehc_interval_calculation_lock', DFEHC_LOCK_TTL);
    179     if ($lock) {
     245
     246    $lock = function_exists('dfehc_acquire_lock') ? dfehc_acquire_lock('dfehc_interval_calculation_lock', (int) DFEHC_LOCK_TTL) : null;
     247    if ($lock || !function_exists('dfehc_acquire_lock')) {
    180248        (new Dfehc_Get_Recommended_Heartbeat_Interval_Async())->dispatch();
    181         dfehc_release_lock($lock);
    182     }
    183     return get_transient('dfehc_recommended_interval');
     249        if ($lock && function_exists('dfehc_release_lock')) {
     250            dfehc_release_lock($lock);
     251        }
     252    }
     253
     254    $val = get_transient($ri_key);
     255    if ($val === false || !is_numeric($val)) {
     256        return (float) dfehc_calculate_recommended_interval_user_activity();
     257    }
     258    return (float) $val;
    184259}
    185260
     
    187262{
    188263    check_ajax_referer(DFEHC_NONCE_ACTION, 'nonce');
     264    $ip = (string) ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
     265    $rlk = dfehc_scoped_tkey('dfehc_rl_' . md5($ip));
     266    $cnt = (int) get_transient($rlk);
     267    $limit = (int) apply_filters('dfehc_recommend_rl_limit', 30);
     268    $window = (int) apply_filters('dfehc_recommend_rl_window', 60);
     269    if ($cnt >= $limit) {
     270        wp_send_json_error(['message' => 'rate_limited'], 429);
     271    }
     272    dfehc_set_transient_noautoload($rlk, $cnt + 1, $window);
    189273    $interval = dfehc_get_recommended_heartbeat_interval_async();
    190     wp_send_json_success(['interval' => $interval]);
     274    if (!is_numeric($interval) || $interval <= 0) {
     275        $interval = (float) dfehc_calculate_recommended_interval_user_activity();
     276    }
     277    wp_send_json_success(['interval' => (float) $interval]);
    191278}
    192279add_action('wp_ajax_dfehc_update_heartbeat_interval', 'dfehc_get_recommended_intervals');
     
    195282function dfehc_override_heartbeat_interval(array $settings): array
    196283{
    197     $interval            = (int) ($settings['interval'] ?? DFEHC_MIN_INTERVAL);
    198     $interval            = min(max($interval, DFEHC_MIN_INTERVAL), DFEHC_MAX_INTERVAL);
     284    $interval             = (int) ($settings['interval'] ?? DFEHC_MIN_INTERVAL);
     285    $interval             = min(max($interval, (int) DFEHC_MIN_INTERVAL), (int) DFEHC_MAX_INTERVAL);
    199286    $settings['interval'] = $interval;
    200287    return $settings;
     
    202289add_filter('heartbeat_settings', 'dfehc_override_heartbeat_interval');
    203290
    204 add_filter('dfehc_safe_transient_get', static function ($value, $key) {
    205     if ($value === false) {
    206         dfehc_set_transient_noautoload("dfehc_retry_{$key}", true, DFEHC_LOCK_TTL);
    207     }
    208     return $value;
    209 }, 10, 2);
    210 
    211291function dfehc_get_server_health_status(float $load): string
    212292{
     
    228308function dfehc_invalidate_heartbeat_cache(): void
    229309{
    230     $visitors = dfehc_get_website_visitors();
     310    $visitors = function_exists('dfehc_get_website_visitors') ? (int) dfehc_get_website_visitors() : 0;
    231311    if ($visitors > 100) {
    232         delete_transient('dfehc_recommended_interval');
     312        delete_transient(dfehc_scoped_tkey('dfehc_recommended_interval'));
    233313    }
    234314}
  • dynamic-front-end-heartbeat-control/trunk/js/heartbeat.js

    r3320647 r3396626  
    33    low:    [15, 30, 60, 120, 180, 240, 300],
    44    medium: [30, 60, 120, 180, 240, 300],
    5     high:   [60, 120, 180, 240, 300],
    6   };
    7 
    8   const cacheTimeout   = dfehc_heartbeat_vars.cache_duration || 5 * 60 * 1000;
    9   const memoryCache    = Object.create(null);
    10   const localCacheKey  = 'dfehc_heartbeat_server_load';
     5    high:   [60, 120, 180, 240, 300]
     6  };
     7
     8  const vars = typeof window.dfehc_heartbeat_vars === 'object' ? window.dfehc_heartbeat_vars : {};
     9
     10  const clampNumber = (v, lo, hi, d) => {
     11    const n = Number(v);
     12    if (!Number.isFinite(n)) return d;
     13    return Math.min(hi, Math.max(lo, n));
     14  };
     15
     16  const DEFAULT_MIN = 15;
     17  const DEFAULT_MAX = 300;
     18  const ABS_MIN = 1;
     19  const ABS_MAX = 3600;
     20
     21  let MIN = clampNumber(vars.min_interval, ABS_MIN, ABS_MAX, DEFAULT_MIN);
     22  let MAX = clampNumber(vars.max_interval, MIN, ABS_MAX, DEFAULT_MAX);
     23  if (MIN > MAX) { const t = MIN; MIN = MAX; MAX = t; }
     24
     25  const LOAD_CAP = Number.isFinite(vars.max_server_load) ? Number(vars.max_server_load) : 85;
     26
     27  const msOrAuto = (v, d) => {
     28    const n = Number(v);
     29    if (!Number.isFinite(n)) return d;
     30    return n <= 600 ? n * 1000 : n;
     31  };
     32  const cacheTimeout = msOrAuto(vars.cache_duration, 5 * 60 * 1000);
     33  const cacheBypassRate = Math.min(1, Math.max(0, Number(vars.cache_bypass_rate) || 0.05));
     34
     35  const sanitizeKey = (s) => String(s || '').replace(/[^a-z0-9_.-]/gi, '_');
     36  const siteKey = sanitizeKey(vars.site_key || location.host);
     37  const localCacheKey = `dfehc_heartbeat_server_load:${siteKey}:${vars.ver || '1'}`;
     38  const memoryCache = Object.create(null);
     39
     40  const SUPPORTS_BC = typeof window.BroadcastChannel === 'function';
     41  const bc = SUPPORTS_BC ? new BroadcastChannel('dfehc-heartbeat') : null;
     42  const TAB_ID = Math.random().toString(36).slice(2) + Date.now().toString(36);
     43  let isLeader = false;
     44  let lastLeaderBeat = 0;
     45  const LEADER_TTL = 5000;
     46  const leaderKey = `${localCacheKey}:leader`;
     47
     48  function readLeader() {
     49    try {
     50      const raw = localStorage.getItem(leaderKey);
     51      if (!raw) return null;
     52      const obj = JSON.parse(raw);
     53      if (!obj || typeof obj !== 'object') return null;
     54      const ts = Number(obj.ts);
     55      const tab = String(obj.tab || '');
     56      if (!Number.isFinite(ts) || !tab) return null;
     57      return { ts, tab };
     58    } catch {
     59      return null;
     60    }
     61  }
     62
     63  function writeLeader(ts, tab) {
     64    try { localStorage.setItem(leaderKey, JSON.stringify({ ts, tab })); } catch {}
     65  }
     66
     67  function clearLeader(tab) {
     68    try {
     69      const cur = readLeader();
     70      if (!cur || cur.tab === tab) localStorage.removeItem(leaderKey);
     71    } catch {}
     72  }
     73
     74  function tryBecomeLeader() {
     75    if (document.hidden) return false;
     76    const now = Date.now();
     77    const cur = readLeader();
     78    if (!cur || now - cur.ts > LEADER_TTL) {
     79      writeLeader(now, TAB_ID);
     80      isLeader = true;
     81      lastLeaderBeat = now;
     82      if (bc) bc.postMessage({ t: 'leader', ts: now, tab: TAB_ID });
     83      return true;
     84    }
     85    if (cur.tab === TAB_ID) {
     86      isLeader = true;
     87      lastLeaderBeat = cur.ts;
     88      return true;
     89    }
     90    return false;
     91  }
     92
     93  function renewLeadership() {
     94    if (!isLeader) return;
     95    const now = Date.now();
     96    if (now - lastLeaderBeat > 2000) {
     97      writeLeader(now, TAB_ID);
     98      lastLeaderBeat = now;
     99    }
     100  }
     101
     102  function relinquishLeadership() {
     103    if (!isLeader) return;
     104    clearLeader(TAB_ID);
     105    isLeader = false;
     106  }
    11107
    12108  const getLocalCache = (key) => {
    13109    try {
    14       const raw = localStorage.getItem(key);
     110      const raw = window.localStorage.getItem(key);
    15111      if (!raw) return null;
    16       const { timestamp, data } = JSON.parse(raw);
     112      const parsed = JSON.parse(raw);
     113      if (!parsed || typeof parsed !== 'object') return null;
     114      const timestamp = Number(parsed.timestamp);
     115      const data = parsed.data;
     116      if (!Number.isFinite(timestamp)) return null;
    17117      return Date.now() - timestamp < cacheTimeout ? data : null;
    18118    } catch {
    19       localStorage.removeItem(key);
     119      try { window.localStorage.removeItem(key); } catch {}
    20120      return null;
    21121    }
     
    24124  const setLocalCache = (key, data) => {
    25125    try {
    26       localStorage.setItem(key, JSON.stringify({ timestamp: Date.now(), data }));
     126      const jitterMs = Math.floor(Math.random() * 5000);
     127      window.localStorage.setItem(key, JSON.stringify({ timestamp: Date.now() - jitterMs, data }));
     128    } catch {}
     129  };
     130
     131  const nearestFrom = (value, list) =>
     132    list.reduce((best, v) => (Math.abs(v - value) < Math.abs(best - value) ? v : best), list[0]);
     133
     134  const trafficLevel = (load) => (load <= 50 ? 'low' : load <= 75 ? 'medium' : 'high');
     135
     136  const calcRecommendedIntervalFromLoad = (load) => {
     137    const bucket = Math.max(0, Math.min(100, Math.round(load)));
     138    if (Object.prototype.hasOwnProperty.call(memoryCache, bucket)) return memoryCache[bucket];
     139    const level = trafficLevel(load);
     140    const opts = intervals[level];
     141    const min = Math.max(MIN, opts[0]);
     142    const max = Math.min(MAX, opts[opts.length - 1]);
     143    const ratio = Math.max(0, Math.min(1, load / LOAD_CAP));
     144    const raw = min + (max - min) * ratio;
     145    const clamped = Math.min(Math.max(raw, MIN), MAX);
     146    const snapped = nearestFrom(clamped, opts);
     147    memoryCache[bucket] = snapped;
     148    return snapped;
     149  };
     150
     151  const toNum = (v) => {
     152    const n = Number(v);
     153    return Number.isFinite(n) ? n : null;
     154  };
     155
     156  const coerceServerPayload = (val) => {
     157    if (val && typeof val === 'object') {
     158      const interval = toNum(val.interval);
     159      const load = toNum(val.load);
     160      return { interval, load };
     161    }
     162    if (Number.isFinite(Number(val))) {
     163      const n = Number(val);
     164      const mode = (vars.server_payload || 'auto').toLowerCase();
     165      if (mode === 'interval') return { interval: n, load: null };
     166      if (mode === 'load') return { interval: null, load: n };
     167      if (n >= 0 && n <= 100) return { interval: null, load: n };
     168      if (n > 100) return { interval: n, load: null };
     169    }
     170    return { interval: null, load: null };
     171  };
     172
     173  const debounce = (fn, wait) => {
     174    let t;
     175    return (...args) => {
     176      clearTimeout(t);
     177      t = setTimeout(() => fn(...args), wait);
     178    };
     179  };
     180
     181  let lastInterval = null;
     182  const applyPresetThenExact = (secs) => {
     183    const hb = wp && wp.heartbeat;
     184    if (!hb || typeof hb.interval !== 'function') return;
     185    if (typeof secs !== 'number' || !Number.isFinite(secs)) return;
     186    if (lastInterval !== null && Math.round(lastInterval) === Math.round(secs)) return;
     187    const preset = secs <= 20 ? 'fast' : secs <= 60 ? 'standard' : 'slow';
     188    try { hb.interval(preset); hb.interval(secs); } catch {}
     189    lastInterval = secs;
     190  };
     191
     192  const setHeartbeatInterval = (secs) => {
     193    let s = secs;
     194    if (s >= 60) {
     195      const jitter = Math.floor(Math.random() * 3) - 1;
     196      s = Math.max(MIN, Math.min(MAX, s + jitter));
     197    }
     198    applyPresetThenExact(s);
     199  };
     200
     201  const debouncedSetInterval = debounce(setHeartbeatInterval, 150);
     202
     203  let failureCount = 0;
     204  let nextRetryAt = 0;
     205  const backoffMs = () => Math.min(120000, 2000 * Math.pow(2, Math.min(failureCount, 5)));
     206
     207  const fetchWithTimeout = (url, opts, ms) => {
     208    if ('AbortController' in window) {
     209      const ctrl = new AbortController();
     210      const t = setTimeout(() => ctrl.abort(), ms);
     211      return fetch(url, { ...opts, signal: ctrl.signal }).finally(() => clearTimeout(t));
     212    }
     213    return Promise.race([
     214      fetch(url, opts),
     215      new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), ms))
     216    ]);
     217  };
     218
     219  let reqSeq = 0;
     220
     221  const fetchServerData = async (nonce, rid) => {
     222    if (Date.now() < nextRetryAt) {
     223      const base = Number(vars.fallback_interval) || 60;
     224      const jitter = Math.floor(Math.random() * 7) - 3;
     225      return { interval: Math.max(MIN, Math.min(MAX, base + jitter)), load: null, rid };
     226    }
     227
     228    if (Math.random() >= cacheBypassRate) {
     229      const cached = getLocalCache(localCacheKey);
     230      if (cached !== null) return { ...coerceServerPayload(cached), rid };
     231    }
     232
     233    const I_AM_LEADER = tryBecomeLeader();
     234    if (!I_AM_LEADER) {
     235      const wait = Math.min(1500, Math.max(150, Math.floor(Math.random() * 600)));
     236      await new Promise(r => setTimeout(r, wait));
     237      const cached = getLocalCache(localCacheKey);
     238      if (cached !== null) return { ...coerceServerPayload(cached), rid };
     239    }
     240
     241    if (navigator.onLine === false) {
     242      const base = Number(vars.fallback_interval) || 60;
     243      const jitter = Math.floor(Math.random() * 7) - 3;
     244      return { interval: Math.max(MIN, Math.min(MAX, base + jitter)), load: null, rid };
     245    }
     246
     247    const url =
     248      vars.ajax_url ||
     249      (typeof window.ajaxurl !== 'undefined' ? window.ajaxurl : `${location.origin.replace(/\/$/, '')}/wp-admin/admin-ajax.php`);
     250
     251    try {
     252      const body = new URLSearchParams({ action: 'get_server_load' });
     253      if (nonce) body.append('nonce', nonce);
     254      const res = await fetchWithTimeout(url, {
     255        method: 'POST',
     256        body,
     257        credentials: 'same-origin',
     258        headers: {
     259          'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
     260          'Cache-Control': 'no-store'
     261        }
     262      }, 6000);
     263      if (!res.ok) throw new Error(String(res.status));
     264      const json = await res.json();
     265      if (!json || json.success !== true) throw new Error('bad payload');
     266      setLocalCache(localCacheKey, json.data);
     267      if (bc) bc.postMessage({ t: 'payload', data: json.data });
     268      failureCount = 0;
     269      nextRetryAt = 0;
     270      renewLeadership();
     271      return { ...coerceServerPayload(json.data), rid };
    27272    } catch {
    28     }
    29   };
    30 
    31   const fetchServerLoad = async (nonce) => {
    32     const cached = getLocalCache(localCacheKey);
    33     if (cached !== null) return cached;
    34 
    35     try {
    36       const body = new URLSearchParams({ action: 'get_server_load', nonce });
    37       const res  = await fetch(ajaxurl, {
    38         method:       'POST',
    39         body,
    40         credentials:  'same-origin',
    41       });
    42 
    43       if (!res.ok) throw new Error(`HTTP ${res.status}`);
    44       const json = await res.json();
    45       if (!json.success || typeof json.data !== 'number') throw new Error('Bad payload');
    46 
    47       setLocalCache(localCacheKey, json.data);
    48       return json.data;
    49     } catch (err) {
    50       console.error('DFEHC fetch error:', err.message);
    51       return 60;
    52     }
    53   };
    54 
    55   const smoothMoving = (arr, windowSize = 5) => {
    56     const slice = arr.slice(-windowSize);
    57     return slice.reduce((s, v) => s + v, 0) / slice.length;
    58   };
    59 
    60   const weightedAverage = (arr) => {
    61     const len     = arr.length;
    62     const denom   = (len * (len + 1)) / 2;
    63     return arr.reduce((sum, v, i) => sum + v * (i + 1), 0) / denom;
    64   };
    65 
    66   const trafficLevel = (load) =>
    67     load <= 50 ? 'low' : load <= 75 ? 'medium' : 'high';
    68 
    69   const calcRecommendedInterval = (load) => {
    70     const bucket = Math.round(load / 5) * 5;
    71     if (bucket in memoryCache) return memoryCache[bucket];
    72 
    73     const level           = trafficLevel(load);
    74     const recent          = intervals[level];
    75     const avg             = weightedAverage(recent);
    76     const smooth          = smoothMoving(recent);
    77     const loadFactor      = 1 - Math.min(load / 85, 1);
    78     const rawInterval     = Math.round(avg + smooth * loadFactor);
    79     const clampedInterval = Math.min(Math.max(rawInterval, 30), 300);
    80 
    81     memoryCache[bucket] = clampedInterval;
    82     return clampedInterval;
     273      failureCount += 1;
     274      nextRetryAt = Date.now() + backoffMs();
     275      const base = Number(vars.fallback_interval) || 60;
     276      const jitter = Math.floor(Math.random() * 7) - 3;
     277      relinquishLeadership();
     278      return { interval: Math.max(MIN, Math.min(MAX, base + jitter)), load: null, rid };
     279    }
    83280  };
    84281
    85282  const heartbeat = {
    86     update: (interval) => {
    87       if (wp && wp.heartbeat && typeof wp.heartbeat.interval === 'function') {
    88         wp.heartbeat.interval(interval);
     283    update(interval) { debouncedSetInterval(interval); },
     284    updateUI(interval) {
     285      const sel = document.querySelector('#dfehc-heartbeat-interval');
     286      if (sel) sel.value = String(interval);
     287      debouncedSetInterval(interval);
     288    },
     289    async init(nonce) {
     290      const conn = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
     291      const et = conn && conn.effectiveType ? String(conn.effectiveType) : '';
     292      if ('deviceMemory' in navigator && navigator.deviceMemory < 2) return;
     293      if ((conn && conn.saveData) || et.startsWith('2g') || et.startsWith('slow-2g')) return;
     294      const myRid = ++reqSeq;
     295      const payload = await fetchServerData(nonce, myRid);
     296      if (payload.rid !== reqSeq) return;
     297      const interval = payload.interval;
     298      const load = payload.load;
     299      const snapList =
     300        intervals[trafficLevel(typeof load === 'number' ? load : LOAD_CAP)] || intervals.low;
     301      const finalInterval =
     302        typeof interval === 'number'
     303          ? nearestFrom(Math.min(Math.max(interval, MIN), MAX), snapList)
     304          : calcRecommendedIntervalFromLoad(typeof load === 'number' ? load : 60);
     305      this.updateUI(finalInterval);
     306    }
     307  };
     308
     309  if (bc) {
     310    bc.onmessage = (e) => {
     311      const m = e.data || {};
     312      if (m.t === 'leader') {
     313        const tab = String(m.tab || '');
     314        if (tab && tab !== TAB_ID) isLeader = false;
    89315      }
    90     },
    91     updateUI: (interval) => {
    92       const sel = document.querySelector('#dfehc-heartbeat-interval');
    93       if (sel) sel.value = interval;
    94       this.update(interval);
    95     },
    96     init: async function (nonce) {
    97       if ('deviceMemory' in navigator && navigator.deviceMemory < 2) return;
    98       if (navigator.connection && navigator.connection.effectiveType === '2g') return;
    99 
    100       const load          = await fetchServerLoad(nonce);
    101       const recommended   = calcRecommendedInterval(load);
    102       this.updateUI(recommended);
    103     },
    104   };
     316      if (m.t === 'payload') {
     317        try { setLocalCache(localCacheKey, m.data); } catch {}
     318        Object.keys(memoryCache).forEach(k => delete memoryCache[k]);
     319        const coerced = coerceServerPayload(m.data);
     320        const interval = coerced.interval;
     321        const load = coerced.load;
     322        const snapList =
     323          intervals[trafficLevel(typeof load === 'number' ? load : LOAD_CAP)] || intervals.low;
     324        const finalInterval =
     325          typeof interval === 'number'
     326            ? nearestFrom(Math.min(Math.max(interval, MIN), MAX), snapList)
     327            : calcRecommendedIntervalFromLoad(typeof load === 'number' ? load : 60);
     328        debouncedSetInterval(finalInterval);
     329      }
     330    };
     331  }
     332
     333  window.addEventListener('storage', (e) => {
     334    if (e.key !== localCacheKey || !e.newValue) return;
     335    try {
     336      const v = JSON.parse(e.newValue);
     337      if (!v || typeof v !== 'object' || !('data' in v)) return;
     338      Object.keys(memoryCache).forEach(k => delete memoryCache[k]);
     339      const coerced = coerceServerPayload(v.data);
     340      const interval = coerced.interval;
     341      const load = coerced.load;
     342      const snapList =
     343        intervals[trafficLevel(typeof load === 'number' ? load : LOAD_CAP)] || intervals.low;
     344      const finalInterval =
     345        typeof interval === 'number'
     346          ? nearestFrom(Math.min(Math.max(interval, MIN), MAX), snapList)
     347          : calcRecommendedIntervalFromLoad(typeof load === 'number' ? load : 60);
     348      debouncedSetInterval(finalInterval);
     349    } catch {}
     350  });
     351
     352  let memoVersion = String(vars.ver || '1');
     353  function maybeResetMemo() {
     354    const v = String(vars.ver || '1');
     355    if (v !== memoVersion) {
     356      memoVersion = v;
     357      Object.keys(memoryCache).forEach(k => delete memoryCache[k]);
     358    }
     359  }
     360
     361  function cleanup() {
     362    relinquishLeadership();
     363    if (bc && typeof bc.close === 'function') {
     364      try { bc.close(); } catch {}
     365    }
     366  }
     367
     368  document.addEventListener('visibilitychange', () => {
     369    if (!document.hidden) nextRetryAt = 0;
     370  });
     371  window.addEventListener('online', () => { nextRetryAt = 0; });
     372  window.addEventListener('pageshow', (e) => { if (e.persisted) nextRetryAt = 0; });
     373  window.addEventListener('pagehide', cleanup, { once: true });
     374  window.addEventListener('beforeunload', cleanup, { once: true });
    105375
    106376  document.addEventListener('DOMContentLoaded', () => {
    107     if (dfehc_heartbeat_vars.heartbeat_control_enabled !== '1') return;
    108 
    109     const { nonce } = dfehc_heartbeat_vars;
    110 
     377    maybeResetMemo();
     378    if ((vars.heartbeat_control_enabled || '') !== '1') return;
     379    const nonce = vars.nonce;
    111380    if ('requestIdleCallback' in window) {
    112       requestIdleCallback(() => heartbeat.init(nonce));
     381      window.requestIdleCallback(() => heartbeat.init(nonce));
    113382    } else {
    114383      setTimeout(() => heartbeat.init(nonce), 100);
    115384    }
    116 
    117385    const sel = document.querySelector('#dfehc-heartbeat-interval');
    118386    if (sel) {
    119387      sel.addEventListener('change', function () {
    120388        const val = parseInt(this.value, 10);
    121         if (!isNaN(val)) heartbeat.update(val);
     389        if (!Number.isNaN(val)) heartbeat.update(Math.min(Math.max(val, MIN), MAX));
    122390      });
    123391    }
  • dynamic-front-end-heartbeat-control/trunk/js/heartbeat.min.js

    r3320647 r3396626  
    1 ((wp)=>{const i={low:[15,30,60,120,180,240,300],medium:[30,60,120,180,240,300],high:[60,120,180,240,300]},c=dfehc_heartbeat_vars.cache_duration||3e5,l=Object.create(null),d="dfehc_heartbeat_server_load",a=e=>{try{const{timestamp:t,data:n}=JSON.parse(localStorage.getItem(e));return Date.now()-t<c?n:null}catch{return localStorage.removeItem(e),null}},s=(e,t)=>{try{localStorage.setItem(e,JSON.stringify({timestamp:Date.now(),data:t}))}catch{}},u=async e=>{const t=a(d);if(t!==null)return t;try{const t=new URLSearchParams({action:"get_server_load",nonce:e}),n=await fetch(ajaxurl,{method:"POST",body:t,credentials:"same-origin"});if(!n.ok)throw new Error(`HTTP ${n.status}`);const r=await n.json();if(!r.success||typeof r.data!=="number")throw new Error("Bad payload");return s(d,r.data),r.data}catch(e){return console.error("DFEHC fetch error:",e.message),60}},f=(e,t=5)=>{const n=e.slice(-t);return n.reduce((e,t)=>e+t,0)/n.length},p=e=>{const t=e.length;return e.reduce((e,n,r)=>e+n*(r+1),0)/(t*(t+1)/2)},m=e=>e<=50?"low":e<=75?"medium":"high",h=e=>{const t=Math.round(e/5)*5;if(t in l)return l[t];const n=m(e),r=i[n],o=p(r),g=f(r),a=1-Math.min(e/85,1),c_=Math.round(o+g*a),s_=Math.min(Math.max(c_,30),300);return l[t]=s_,s_},g={update:e=>{wp&&wp.heartbeat&&typeof wp.heartbeat.interval=="function"&&wp.heartbeat.interval(e)},updateUI(e){const t=document.querySelector("#dfehc-heartbeat-interval");t&&(t.value=e),this.update(e)},async init(e){if("deviceMemory"in navigator&&navigator.deviceMemory<2)return;if(navigator.connection&&navigator.connection.effectiveType==="2g")return;const t=await u(e),n=h(t);this.updateUI(n)}};document.addEventListener("DOMContentLoaded",()=>{if(dfehc_heartbeat_vars.heartbeat_control_enabled!=="1")return;const{nonce:e}=dfehc_heartbeat_vars;"requestIdleCallback"in window?requestIdleCallback(()=>g.init(e)):setTimeout(()=>g.init(e),100);const t=document.querySelector("#dfehc-heartbeat-interval");t&&t.addEventListener("change",function(){const e=parseInt(this.value,10);isNaN(e)||g.update(e)})})})(window.wp||{});
     1((wp)=>{const intervals={low:[15,30,60,120,180,240,300],medium:[30,60,120,180,240,300],high:[60,120,180,240,300]};const vars=typeof window.dfehc_heartbeat_vars==="object"?window.dfehc_heartbeat_vars:{};const clampNumber=(v,lo,hi,d)=>{const n=Number(v);if(!Number.isFinite(n))return d;return Math.min(hi,Math.max(lo,n))};const DEFAULT_MIN=15;const DEFAULT_MAX=300;const ABS_MIN=1;const ABS_MAX=3600;let MIN=clampNumber(vars.min_interval,ABS_MIN,ABS_MAX,DEFAULT_MIN);let MAX=clampNumber(vars.max_interval,MIN,ABS_MAX,DEFAULT_MAX);if(MIN>MAX){const t=MIN;MIN=MAX;MAX=t}const LOAD_CAP=Number.isFinite(vars.max_server_load)?Number(vars.max_server_load):85;const msOrAuto=(v,d)=>{const n=Number(v);if(!Number.isFinite(n))return d;return n<=600?n*1e3:n};const cacheTimeout=msOrAuto(vars.cache_duration,3e5);const cacheBypassRate=Math.min(1,Math.max(0,Number(vars.cache_bypass_rate)||.05));const sanitizeKey=(s)=>String(s||"").replace(/[^a-z0-9_.-]/gi,"_");const siteKey=sanitizeKey(vars.site_key||location.host);const localCacheKey=`dfehc_heartbeat_server_load:${siteKey}:${vars.ver||"1"}`;const memoryCache=Object.create(null);const SUPPORTS_BC=typeof window.BroadcastChannel==="function";const bc=SUPPORTS_BC?new BroadcastChannel("dfehc-heartbeat"):null;const TAB_ID=Math.random().toString(36).slice(2)+Date.now().toString(36);let isLeader=false;let lastLeaderBeat=0;const LEADER_TTL=5e3;const leaderKey=`${localCacheKey}:leader`;function readLeader(){try{const raw=localStorage.getItem(leaderKey);if(!raw)return null;const obj=JSON.parse(raw);if(!obj||typeof obj!=="object")return null;const ts=Number(obj.ts);const tab=String(obj.tab||"");if(!Number.isFinite(ts)||!tab)return null;return{ts,tab}}catch{return null}}function writeLeader(ts,tab){try{localStorage.setItem(leaderKey,JSON.stringify({ts,tab}))}catch{}}function clearLeader(tab){try{const cur=readLeader();if(!cur||cur.tab===tab)localStorage.removeItem(leaderKey)}catch{}}function tryBecomeLeader(){if(document.hidden)return false;const now=Date.now();const cur=readLeader();if(!cur||now-cur.ts>LEADER_TTL){writeLeader(now,TAB_ID);isLeader=true;lastLeaderBeat=now;if(bc)bc.postMessage({t:"leader",ts:now,tab:TAB_ID});return true}if(cur.tab===TAB_ID){isLeader=true;lastLeaderBeat=cur.ts;return true}return false}function renewLeadership(){if(!isLeader)return;const now=Date.now();if(now-lastLeaderBeat>2e3){writeLeader(now,TAB_ID);lastLeaderBeat=now}}function relinquishLeadership(){if(!isLeader)return;clearLeader(TAB_ID);isLeader=false}const getLocalCache=(key)=>{try{const raw=window.localStorage.getItem(key);if(!raw)return null;const parsed=JSON.parse(raw);if(!parsed||typeof parsed!=="object")return null;const timestamp=Number(parsed.timestamp);const data=parsed.data;if(!Number.isFinite(timestamp))return null;return Date.now()-timestamp<cacheTimeout?data:null}catch{try{window.localStorage.removeItem(key)}catch{}return null}};const setLocalCache=(key,data)=>{try{const jitterMs=Math.floor(Math.random()*5e3);window.localStorage.setItem(key,JSON.stringify({timestamp:Date.now()-jitterMs,data}))}catch{}};const nearestFrom=(value,list)=>list.reduce((best,v)=>Math.abs(v-value)<Math.abs(best-value)?v:best,list[0]);const trafficLevel=(load)=>load<=50?"low":load<=75?"medium":"high";const calcRecommendedIntervalFromLoad=(load)=>{const bucket=Math.max(0,Math.min(100,Math.round(load)));if(Object.prototype.hasOwnProperty.call(memoryCache,bucket))return memoryCache[bucket];const level=trafficLevel(load);const opts=intervals[level];const min=Math.max(MIN,opts[0]);const max=Math.min(MAX,opts[opts.length-1]);const ratio=Math.max(0,Math.min(1,load/LOAD_CAP));const raw=min+(max-min)*ratio;const clamped=Math.min(Math.max(raw,MIN),MAX);const snapped=nearestFrom(clamped,opts);memoryCache[bucket]=snapped;return snapped};const toNum=(v)=>{const n=Number(v);return Number.isFinite(n)?n:null};const coerceServerPayload=(val)=>{if(val&&typeof val==="object"){const interval=toNum(val.interval);const load=toNum(val.load);return{interval,load}}if(Number.isFinite(Number(val))){const n=Number(val);const mode=(vars.server_payload||"auto").toLowerCase();if(mode==="interval")return{interval:n,load:null};if(mode==="load")return{interval:null,load:n};if(n>=0&&n<=100)return{interval:null,load:n};if(n>100)return{interval:n,load:null}}return{interval:null,load:null}};const debounce=(fn,wait)=>{let t;return(...args)=>{clearTimeout(t);t=setTimeout(()=>fn(...args),wait)}};let lastInterval=null;const applyPresetThenExact=(secs)=>{const hb=wp&&wp.heartbeat;if(!hb||typeof hb.interval!=="function")return;if(typeof secs!=="number"||!Number.isFinite(secs))return;if(lastInterval!==null&&Math.round(lastInterval)===Math.round(secs))return;const preset=secs<=20?"fast":secs<=60?"standard":"slow";try{hb.interval(preset);hb.interval(secs)}catch{}lastInterval=secs};const setHeartbeatInterval=(secs)=>{let s=secs;if(s>=60){const jitter=Math.floor(Math.random()*3)-1;s=Math.max(MIN,Math.min(MAX,s+jitter))}applyPresetThenExact(s)};const debouncedSetInterval=debounce(setHeartbeatInterval,150);let failureCount=0;let nextRetryAt=0;const backoffMs=()=>Math.min(12e4,2e3*Math.pow(2,Math.min(failureCount,5)));const fetchWithTimeout=(url,opts,ms)=>{if("AbortController"in window){const ctrl=new AbortController;const t=setTimeout(()=>ctrl.abort(),ms);return fetch(url,{...opts,signal:ctrl.signal}).finally(()=>clearTimeout(t))}return Promise.race([fetch(url,opts),new Promise((_,rej)=>setTimeout(()=>rej(new Error("timeout")),ms))])};let reqSeq=0;const fetchServerData=async(nonce,rid)=>{if(Date.now()<nextRetryAt){const base=Number(vars.fallback_interval)||60;const jitter=Math.floor(Math.random()*7)-3;return{interval:Math.max(MIN,Math.min(MAX,base+jitter)),load:null,rid}}if(Math.random()>=cacheBypassRate){const cached=getLocalCache(localCacheKey);if(cached!==null)return{...coerceServerPayload(cached),rid}}const I_AM_LEADER=tryBecomeLeader();if(!I_AM_LEADER){const wait=Math.min(1500,Math.max(150,Math.floor(Math.random()*600)));await new Promise(r=>setTimeout(r,wait));const cached=getLocalCache(localCacheKey);if(cached!==null)return{...coerceServerPayload(cached),rid}}if(navigator.onLine===false){const base=Number(vars.fallback_interval)||60;const jitter=Math.floor(Math.random()*7)-3;return{interval:Math.max(MIN,Math.min(MAX,base+jitter)),load:null,rid}}const url=vars.ajax_url||typeof window.ajaxurl!=="undefined"?window.ajaxurl:`${location.origin.replace(/\/$/,"")}/wp-admin/admin-ajax.php`;try{const body=new URLSearchParams({action:"get_server_load"});if(nonce)body.append("nonce",nonce);const res=await fetchWithTimeout(url,{method:"POST",body,credentials:"same-origin",headers:{"Content-Type":"application/x-www-form-urlencoded; charset=UTF-8","Cache-Control":"no-store"}},6e3);if(!res.ok)throw new Error(String(res.status));const json=await res.json();if(!json||json.success!==true)throw new Error("bad payload");setLocalCache(localCacheKey,json.data);if(bc)bc.postMessage({t:"payload",data:json.data});failureCount=0;nextRetryAt=0;renewLeadership();return{...coerceServerPayload(json.data),rid}}catch{failureCount+=1;nextRetryAt=Date.now()+backoffMs();const base=Number(vars.fallback_interval)||60;const jitter=Math.floor(Math.random()*7)-3;relinquishLeadership();return{interval:Math.max(MIN,Math.min(MAX,base+jitter)),load:null,rid}}};const heartbeat={update(interval){debouncedSetInterval(interval)},updateUI(interval){const sel=document.querySelector("#dfehc-heartbeat-interval");if(sel)sel.value=String(interval);debouncedSetInterval(interval)},async init(nonce){const conn=navigator.connection||navigator.mozConnection||navigator.webkitConnection;const et=conn&&conn.effectiveType?String(conn.effectiveType):"";if("deviceMemory"in navigator&&navigator.deviceMemory<2)return;if(conn&&conn.saveData||et.startsWith("2g")||et.startsWith("slow-2g"))return;const myRid=++reqSeq;const payload=await fetchServerData(nonce,myRid);if(payload.rid!==reqSeq)return;const interval=payload.interval;const load=payload.load;const snapList=intervals[trafficLevel(typeof load==="number"?load:LOAD_CAP)]||intervals.low;const finalInterval=typeof interval==="number"?nearestFrom(Math.min(Math.max(interval,MIN),MAX),snapList):calcRecommendedIntervalFromLoad(typeof load==="number"?load:60);this.updateUI(finalInterval)}};if(bc){bc.onmessage=(e)=>{const m=e.data||{};if(m.t==="leader"){const tab=String(m.tab||"");if(tab&&tab!==TAB_ID)isLeader=false}if(m.t==="payload"){try{setLocalCache(localCacheKey,m.data)}catch{}Object.keys(memoryCache).forEach(k=>delete memoryCache[k]);const coerced=coerceServerPayload(m.data);const interval=coerced.interval;const load=coerced.load;const snapList=intervals[trafficLevel(typeof load==="number"?load:LOAD_CAP)]||intervals.low;const finalInterval=typeof interval==="number"?nearestFrom(Math.min(Math.max(interval,MIN),MAX),snapList):calcRecommendedIntervalFromLoad(typeof load==="number"?load:60);debouncedSetInterval(finalInterval)}}}window.addEventListener("storage",(e)=>{if(e.key!==localCacheKey||!e.newValue)return;try{const v=JSON.parse(e.newValue);if(!v||typeof v!=="object"||!("data"in v))return;Object.keys(memoryCache).forEach(k=>delete memoryCache[k]);const coerced=coerceServerPayload(v.data);const interval=coerced.interval;const load=coerced.load;const snapList=intervals[trafficLevel(typeof load==="number"?load:LOAD_CAP)]||intervals.low;const finalInterval=typeof interval==="number"?nearestFrom(Math.min(Math.max(interval,MIN),MAX),snapList):calcRecommendedIntervalFromLoad(typeof load==="number"?load:60);debouncedSetInterval(finalInterval)}catch{}});let memoVersion=String(vars.ver||"1");function maybeResetMemo(){const v=String(vars.ver||"1");if(v!==memoVersion){memoVersion=v;Object.keys(memoryCache).forEach(k=>delete memoryCache[k])}}function cleanup(){relinquishLeadership();if(bc&&typeof bc.close==="function"){try{bc.close()}catch{}}}document.addEventListener("visibilitychange",()=>{if(!document.hidden)nextRetryAt=0});window.addEventListener("online",()=>{nextRetryAt=0});window.addEventListener("pageshow",(e)=>{if(e.persisted)nextRetryAt=0});window.addEventListener("pagehide",cleanup,{once:true});window.addEventListener("beforeunload",cleanup,{once:true});document.addEventListener("DOMContentLoaded",()=>{maybeResetMemo();if((vars.heartbeat_control_enabled||"")!=="1")return;const nonce=vars.nonce;if("requestIdleCallback"in window){window.requestIdleCallback(()=>heartbeat.init(nonce))}else{setTimeout(()=>heartbeat.init(nonce),100)}const sel=document.querySelector("#dfehc-heartbeat-interval");if(sel){sel.addEventListener("change",function(){const val=parseInt(this.value,10);if(!Number.isNaN(val))heartbeat.update(Math.min(Math.max(val,MIN),MAX))})}})})(window.wp||{});
  • dynamic-front-end-heartbeat-control/trunk/readme.txt

    r3320647 r3396626  
    33Tested up to:      6.8
    44Requires PHP:      7.2
    5 Stable tag:        1.2.99
     5Stable tag:        1.2.995
    66License:           GPLv2 or later
    77License URI:       https://www.gnu.org/licenses/gpl-2.0.html
     
    4141
    4242== Changelog ==
     43
     44= 1.2.995 =
     45
     46* Improved multisite support.
     47* Increased resilience and reliability under high-traffic loads and limited server resources.
     48* Enhanced overall compatibility and security.
    4349
    4450= 1.2.99 =
  • dynamic-front-end-heartbeat-control/trunk/visitor/cookie-helper.php

    r3320647 r3396626  
    88        return $pattern;
    99    }
    10     $sigs   = (array) apply_filters('dfehc_bot_signatures', [
    11         'bot', 'crawl', 'slurp', 'spider', 'mediapartners', 'bingpreview',
    12         'yandex', 'duckduckbot', 'baiduspider', 'sogou', 'exabot',
    13         'facebot', 'facebookexternalhit', 'ia_archiver',
     10    $sigs = (array) apply_filters('dfehc_bot_signatures', [
     11        'bot','crawl','crawler','slurp','spider','mediapartners','bingpreview',
     12        'yandex','duckduckbot','baiduspider','sogou','exabot',
     13        'facebot','facebookexternalhit','ia_archiver',
    1414    ]);
    1515    $tokens = array_map(
    16         static fn(string $s): string => '(?:^|\\b)' . preg_quote($s, '/'),
     16        static fn(string $s): string =>
     17            '(?<![A-Za-z0-9])' . preg_quote($s, '/') . '(?![A-Za-z0-9])',
    1718        $sigs
    1819    );
     
    2021}
    2122
     23if (!function_exists('dfehc_blog_id')) {
     24    function dfehc_blog_id(): int
     25    {
     26        return function_exists('get_current_blog_id') ? (int) get_current_blog_id() : 0;
     27    }
     28}
     29
     30if (!function_exists('dfehc_host_token')) {
     31    function dfehc_host_token(): string
     32    {
     33        static $t = '';
     34        if ($t !== '') return $t;
     35        $host = @php_uname('n') ?: (defined('WP_HOME') ? WP_HOME : (function_exists('home_url') ? home_url() : 'unknown'));
     36        $salt = defined('DB_NAME') ? (string) DB_NAME : '';
     37        return $t = substr(md5((string) $host . $salt), 0, 10);
     38    }
     39}
     40
     41if (!function_exists('dfehc_scoped_key')) {
     42    function dfehc_scoped_key(string $base): string
     43    {
     44        return $base . '_' . dfehc_blog_id() . '_' . dfehc_host_token();
     45    }
     46}
     47
     48if (!function_exists('dfehc_set_transient_noautoload')) {
     49    function dfehc_set_transient_noautoload(string $key, $value, int $expiration): void
     50    {
     51        if (wp_using_ext_object_cache()) {
     52            if (function_exists('wp_cache_add')) {
     53                if (!wp_cache_add($key, $value, defined('DFEHC_CACHE_GROUP') ? DFEHC_CACHE_GROUP : 'dfehc', $expiration)) {
     54                    wp_cache_set($key, $value, defined('DFEHC_CACHE_GROUP') ? DFEHC_CACHE_GROUP : 'dfehc', $expiration);
     55                }
     56            } else {
     57                wp_cache_set($key, $value, defined('DFEHC_CACHE_GROUP') ? DFEHC_CACHE_GROUP : 'dfehc', $expiration);
     58            }
     59            return;
     60        }
     61        set_transient($key, $value, $expiration);
     62        global $wpdb;
     63        if (!isset($wpdb->options)) {
     64            return;
     65        }
     66        $opt_key = "_transient_$key";
     67        $opt_key_to = "_transient_timeout_$key";
     68        $wpdb->suppress_errors(true);
     69        $autoload = $wpdb->get_var($wpdb->prepare("SELECT autoload FROM {$wpdb->options} WHERE option_name=%s LIMIT 1", $opt_key));
     70        if ($autoload === 'yes') {
     71            $wpdb->update($wpdb->options, ['autoload' => 'no'], ['option_name' => $opt_key, 'autoload' => 'yes'], ['%s'], ['%s','%s']);
     72        }
     73        $autoload_to = $wpdb->get_var($wpdb->prepare("SELECT autoload FROM {$wpdb->options} WHERE option_name=%s LIMIT 1", $opt_key_to));
     74        if ($autoload_to === 'yes') {
     75            $wpdb->update($wpdb->options, ['autoload' => 'no'], ['option_name' => $opt_key_to, 'autoload' => 'yes'], ['%s'], ['%s','%s']);
     76        }
     77        $wpdb->suppress_errors(false);
     78    }
     79}
     80
     81function dfehc_ip_in_cidr(string $ip, string $cidr): bool
     82{
     83    if (strpos($cidr, '/') === false) {
     84        return (bool) filter_var($ip, FILTER_VALIDATE_IP) && $ip === $cidr;
     85    }
     86    [$subnet, $mask] = explode('/', $cidr, 2);
     87    $mask = (int) $mask;
     88
     89    $ip_bin  = @inet_pton($ip);
     90    $sub_bin = @inet_pton($subnet);
     91    if ($ip_bin === false || $sub_bin === false) {
     92        return false;
     93    }
     94    if (strlen($ip_bin) !== strlen($sub_bin)) {
     95        return false;
     96    }
     97
     98    $len = strlen($ip_bin);
     99    $max_bits = $len * 8;
     100    if ($mask < 0 || $mask > $max_bits) {
     101        return false;
     102    }
     103
     104    $bytes = intdiv($mask, 8);
     105    $bits  = $mask % 8;
     106
     107    if ($bytes && substr($ip_bin, 0, $bytes) !== substr($sub_bin, 0, $bytes)) {
     108        return false;
     109    }
     110    if ($bits === 0) {
     111        return true;
     112    }
     113    $ip_byte  = ord($ip_bin[$bytes]);
     114    $sub_byte = ord($sub_bin[$bytes]);
     115    $mask_byte = (0xFF << (8 - $bits)) & 0xFF;
     116    return ($ip_byte & $mask_byte) === ($sub_byte & $mask_byte);
     117}
     118
     119function dfehc_trusted_proxy_request(): bool
     120{
     121    $remote = $_SERVER['REMOTE_ADDR'] ?? '';
     122    if ($remote === '') return false;
     123    $trusted = (array) apply_filters('dfehc_trusted_proxies', []);
     124    foreach ($trusted as $cidr) {
     125        if (is_string($cidr) && dfehc_ip_in_cidr($remote, $cidr)) {
     126            return true;
     127        }
     128    }
     129    return false;
     130}
     131
     132function dfehc_select_client_ip_from_xff(string $xff, array $trustedCidrs): ?string
     133{
     134    $candidates = array_filter(array_map('trim', explode(',', $xff)), 'strlen');
     135    $ipNonTrusted = null;
     136    foreach ($candidates as $ip) {
     137        $ip = preg_replace('/%[0-9A-Za-z.\-]+$/', '', $ip);
     138        if (!filter_var($ip, FILTER_VALIDATE_IP)) {
     139            continue;
     140        }
     141        $isTrustedHop = false;
     142        foreach ($trustedCidrs as $cidr) {
     143            if (is_string($cidr) && dfehc_ip_in_cidr($ip, $cidr)) {
     144                $isTrustedHop = true;
     145                break;
     146            }
     147        }
     148        if ($isTrustedHop) {
     149            continue;
     150        }
     151        if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
     152            return $ip;
     153        }
     154        if ($ipNonTrusted === null) {
     155            $ipNonTrusted = $ip;
     156        }
     157    }
     158    if ($ipNonTrusted !== null) {
     159        return $ipNonTrusted;
     160    }
     161    $last = trim((string) end($candidates));
     162    return filter_var($last, FILTER_VALIDATE_IP) ? $last : null;
     163}
     164
    22165function dfehc_is_request_bot(): bool
    23166{
    24167    static $cached = null;
    25168    if ($cached !== null) {
    26         return $cached;
     169        return (bool) apply_filters('dfehc_is_request_bot', $cached, $_SERVER);
    27170    }
    28171
    29172    $ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
    30173    if ($ua === '' || preg_match(dfehc_get_bot_pattern(), $ua)) {
    31         return $cached = true;
     174        $cached = true;
     175        return (bool) apply_filters('dfehc_is_request_bot', $cached, $_SERVER);
    32176    }
    33177
     
    35179    $sec_ch = $_SERVER['HTTP_SEC_CH_UA'] ?? '';
    36180    if (($accept === '' || stripos($accept, 'text/html') === false) && $sec_ch === '') {
    37         return $cached = true;
    38     }
    39 
    40     $ip     = $_SERVER['REMOTE_ADDR'] ?? '';
    41     $ipKey  = $ip ? 'dfehc_bad_ip_' . md5($ip) : '';
    42     $group  = apply_filters('dfehc_cache_group', 'dfehc');
    43 
    44     if ($ipKey && wp_using_ext_object_cache() && function_exists('wp_cache_get')) {
    45         if (wp_cache_get($ipKey, $group)) {
    46             return $cached = true;
    47         }
    48     }
    49     return $cached = false;
     181        $cached = true;
     182        return (bool) apply_filters('dfehc_is_request_bot', $cached, $_SERVER);
     183    }
     184
     185    $ip     = dfehc_client_ip();
     186    $ipKeyScoped  = dfehc_scoped_key('dfehc_bad_ip_') . md5($ip ?: 'none');
     187    $group  = apply_filters('dfehc_cache_group', defined('DFEHC_CACHE_GROUP') ? DFEHC_CACHE_GROUP : 'dfehc');
     188
     189    if ($ip && function_exists('wp_using_ext_object_cache') && wp_using_ext_object_cache() && function_exists('wp_cache_get')) {
     190        if (wp_cache_get($ipKeyScoped, $group)) {
     191            $cached = true;
     192            return (bool) apply_filters('dfehc_is_request_bot', $cached, $_SERVER);
     193        }
     194    } else {
     195        $tkey = $ipKeyScoped . '_t';
     196        if ($ip && get_transient($tkey)) {
     197            $cached = true;
     198            return (bool) apply_filters('dfehc_is_request_bot', $cached, $_SERVER);
     199        }
     200    }
     201
     202    $cached = false;
     203    return (bool) apply_filters('dfehc_is_request_bot', $cached, $_SERVER);
     204}
     205
     206function dfehc_should_set_cookie(): bool
     207{
     208    if (defined('DOING_CRON') && DOING_CRON) return false;
     209    if (defined('WP_CLI') && WP_CLI) return false;
     210    if (function_exists('is_admin') && is_admin()) return false;
     211    if (function_exists('wp_doing_ajax') && wp_doing_ajax()) return false;
     212    if (function_exists('wp_is_json_request') && wp_is_json_request()) return false;
     213    if (function_exists('rest_get_url_prefix')) {
     214        $p = rest_get_url_prefix();
     215        $uri = $_SERVER['REQUEST_URI'] ?? '';
     216        if ($p && strpos($uri, '/' . trim($p, '/') . '/') === 0) return false;
     217    }
     218    if (function_exists('is_feed') && is_feed()) return false;
     219    if (function_exists('is_robots') && is_robots()) return false;
     220    if (!empty($_SERVER['REQUEST_METHOD']) && strtoupper((string) $_SERVER['REQUEST_METHOD']) !== 'GET') return false;
     221    return true;
    50222}
    51223
    52224function dfehc_set_user_cookie(): void
    53225{
     226    if (!dfehc_should_set_cookie()) {
     227        return;
     228    }
    54229    if (dfehc_is_request_bot()) {
    55230        return;
    56231    }
    57232
    58     $ip     = $_SERVER['REMOTE_ADDR'] ?? '';
    59     $group  = apply_filters('dfehc_cache_group', 'dfehc');
     233    $ip     = dfehc_client_ip();
     234    $group  = apply_filters('dfehc_cache_group', defined('DFEHC_CACHE_GROUP') ? DFEHC_CACHE_GROUP : 'dfehc');
    60235    $maxRPM = (int) apply_filters('dfehc_max_rpm', 120);
    61 
    62     if ($ip && wp_using_ext_object_cache() && function_exists('wp_cache_incr')) {
    63         $rpmKey = 'dfehc_iprpm_' . md5($ip);
    64         $rpm    = wp_cache_incr($rpmKey, 1, $group);
     236    $badIpTtl = (int) apply_filters('dfehc_bad_ip_ttl', HOUR_IN_SECONDS);
     237    $scopedVisitorKey = dfehc_scoped_key('dfehc_total_visitors');
     238
     239    if ($ip && function_exists('wp_using_ext_object_cache') && wp_using_ext_object_cache() && function_exists('wp_cache_incr')) {
     240        $rpmKey = dfehc_scoped_key('dfehc_iprpm_') . md5($ip);
     241        $rpmTtl = 60 + (function_exists('random_int') ? random_int(0, 5) : 0);
     242        if (false === wp_cache_add($rpmKey, 0, $group, $rpmTtl)) {
     243            wp_cache_set($rpmKey, (int) (wp_cache_get($rpmKey, $group) ?: 0), $group, $rpmTtl);
     244        }
     245        $rpm = wp_cache_incr($rpmKey, 1, $group);
    65246        if ($rpm === false) {
    66             wp_cache_set($rpmKey, 1, $group, 60);
     247            wp_cache_set($rpmKey, 1, $group, $rpmTtl);
    67248            $rpm = 1;
     249        } else {
     250            wp_cache_set($rpmKey, (int) $rpm, $group, $rpmTtl);
    68251        }
    69252        if ($rpm > $maxRPM) {
    70             wp_cache_set('dfehc_bad_ip_' . md5($ip), 1, $group, HOUR_IN_SECONDS);
     253            $badKey = dfehc_scoped_key('dfehc_bad_ip_') . md5($ip);
     254            wp_cache_set($badKey, 1, $group, $badIpTtl);
     255            $tkey = $badKey . '_t';
     256            dfehc_set_transient_noautoload($tkey, 1, $badIpTtl);
     257            wp_cache_delete($rpmKey, $group);
    71258            return;
    72259        }
    73260    }
    74261
    75     $name      = 'dfehc_user';
    76     $lifetime  = (int) apply_filters('dfehc_cookie_lifetime', 400);
    77     $path      = (string) apply_filters('dfehc_cookie_path', '/');
    78     $domain    = (string) apply_filters('dfehc_cookie_domain', parse_url(home_url(), PHP_URL_HOST));
    79     $sameSite  = (string) apply_filters('dfehc_cookie_samesite', 'Strict');
    80     $secure    = is_ssl() || $sameSite === 'None';
    81     $httpOnly  = true;
    82 
    83     $val = $_COOKIE[$name] ?? '';
    84     if (!preg_match('/^[A-Za-z0-9]{32,64}$/', $val)) {
    85         $val = function_exists('random_bytes')
    86             ? bin2hex(random_bytes(16))
    87             : uniqid('v_', true) . mt_rand();
    88     }
    89     $expires = time() + $lifetime;
    90 
    91     if (PHP_VERSION_ID >= 70300) {
    92         setcookie($name, $val, [
    93             'expires'  => $expires,
    94             'path'     => $path,
    95             'domain'   => $domain,
    96             'secure'   => $secure,
    97             'httponly' => $httpOnly,
    98             'samesite' => $sameSite,
    99         ]);
    100     } else {
    101         header(sprintf(
    102             'Set-Cookie: %s=%s; Expires=%s; Path=%s; Domain=%s%s; HttpOnly; SameSite=%s',
    103             rawurlencode($name),
    104             rawurlencode($val),
    105             gmdate('D, d-M-Y H:i:s T', $expires),
    106             $path,
    107             $domain,
    108             $secure ? '; Secure' : '',
    109             $sameSite
    110         ));
     262    $name     = (string) apply_filters('dfehc_cookie_name', 'dfehc_user');
     263    $lifetime = (int) apply_filters('dfehc_cookie_lifetime', 400);
     264    $path     = (string) apply_filters('dfehc_cookie_path', defined('COOKIEPATH') ? COOKIEPATH : '/');
     265    $host     = function_exists('home_url') ? parse_url(home_url(), PHP_URL_HOST) : '';
     266    $isIpHost = $host && (filter_var($host, FILTER_VALIDATE_IP) !== false);
     267    $domainDefault = $isIpHost ? '' : ($host ?: (defined('COOKIE_DOMAIN') ? COOKIE_DOMAIN : ''));
     268    $domain   = (string) apply_filters('dfehc_cookie_domain', $domainDefault);
     269
     270    $sameSite = (string) apply_filters('dfehc_cookie_samesite', 'Lax');
     271    $map = ['lax' => 'Lax', 'strict' => 'Strict', 'none' => 'None'];
     272    $sameSiteUpper = $map[strtolower($sameSite)] ?? 'Lax';
     273
     274    $secure   = (function_exists('is_ssl') && is_ssl()) || $sameSiteUpper === 'None';
     275    if ($sameSiteUpper === 'None' && !$secure) {
     276        $sameSiteUpper = 'Lax';
     277    }
     278    $httpOnly = true;
     279
     280    $nowTs = (int) ($_SERVER['REQUEST_TIME'] ?? time());
     281    $existing = $_COOKIE[$name] ?? '';
     282    $val = $existing;
     283    if (!preg_match('/^[A-Fa-f0-9]{32,64}$/', (string) $val)) {
     284        if (function_exists('random_bytes')) {
     285            $val = bin2hex(random_bytes(16));
     286        } else {
     287            $val = bin2hex(pack('H*', substr(sha1(uniqid((string) mt_rand(), true)), 0, 32)));
     288        }
     289    }
     290
     291    $shouldRefresh = !isset($_COOKIE[$name]) || (mt_rand(0, 99) < (int) apply_filters('dfehc_cookie_refresh_percent', 5));
     292    if ($shouldRefresh) {
     293        if (!headers_sent()) {
     294            $expires = $nowTs + $lifetime;
     295            if (PHP_VERSION_ID >= 70300) {
     296                setcookie($name, $val, [
     297                    'expires'  => $expires,
     298                    'path'     => $path,
     299                    'domain'   => $domain ?: null,
     300                    'secure'   => $secure,
     301                    'httponly' => $httpOnly,
     302                    'samesite' => $sameSiteUpper,
     303                ]);
     304            } else {
     305                header(sprintf(
     306                    'Set-Cookie: %s=%s; Expires=%s; Path=%s%s%s; HttpOnly; SameSite=%s',
     307                    rawurlencode($name),
     308                    rawurlencode($val),
     309                    gmdate('D, d-M-Y H:i:s T', $expires),
     310                    $path,
     311                    $domain ? '; Domain=' . $domain : '',
     312                    $secure ? '; Secure' : '',
     313                    $sameSiteUpper
     314                ), false);
     315            }
     316        }
    111317    }
    112318
     
    115321    }
    116322
    117     $visitorKey = 'dfehc_total_visitors';
    118     if (wp_using_ext_object_cache() && function_exists('wp_cache_incr')) {
    119         if (false === wp_cache_incr($visitorKey, 1, $group)) {
    120             wp_cache_set($visitorKey, 1, $group, $lifetime);
    121         }
    122         return;
    123     }
    124 
    125 static $client = null;
    126 
    127 if (!$client && extension_loaded('redis') && class_exists('Redis')) {
    128     try {
    129         $client = new \Redis();
    130 
    131         $sock = get_option('dfehc_redis_socket', '');
    132         $ok   = $sock
    133             ? $client->pconnect($sock)
    134             : $client->pconnect(
    135                 function_exists('dfehc_get_redis_server') ? dfehc_get_redis_server() : '127.0.0.1',
    136                 function_exists('dfehc_get_redis_port')   ? dfehc_get_redis_port()   : 6379,
    137                 1.0
     323    if (function_exists('wp_using_ext_object_cache') && wp_using_ext_object_cache() && function_exists('wp_cache_incr')) {
     324        $vTtl = $lifetime + (function_exists('random_int') ? random_int(0, 5) : 0);
     325        if (false === wp_cache_add($scopedVisitorKey, 0, $group, $vTtl)) {
     326            wp_cache_set($scopedVisitorKey, (int) (wp_cache_get($scopedVisitorKey, $group) ?: 0), $group, $vTtl);
     327        }
     328        $valInc = wp_cache_incr($scopedVisitorKey, 1, $group);
     329        if ($valInc === false) {
     330            wp_cache_set($scopedVisitorKey, 1, $group, $vTtl);
     331        } else {
     332            wp_cache_set($scopedVisitorKey, (int) $valInc, $group, $vTtl);
     333        }
     334        return;
     335    }
     336
     337    $allowDirectClients = (bool) apply_filters('dfehc_enable_direct_cache_clients', false);
     338
     339    static $client = null;
     340    if ($allowDirectClients && !$client && extension_loaded('redis') && class_exists('Redis')) {
     341        try {
     342            $client = new \Redis();
     343            $sock = function_exists('get_option') ? get_option('dfehc_redis_socket', '') : '';
     344            $ok   = $sock
     345                ? $client->pconnect($sock)
     346                : $client->pconnect(
     347                    function_exists('dfehc_get_redis_server') ? dfehc_get_redis_server() : '127.0.0.1',
     348                    function_exists('dfehc_get_redis_port')   ? dfehc_get_redis_port()   : 6379,
     349                    1.0
     350                );
     351            if ($ok) {
     352                $pass = apply_filters('dfehc_redis_auth', getenv('REDIS_PASSWORD') ?: null);
     353                $user = apply_filters('dfehc_redis_user', getenv('REDIS_USERNAME') ?: null);
     354                if ($user && $pass && method_exists($client, 'auth')) {
     355                    $client->auth([$user, $pass]);
     356                } elseif ($pass && method_exists($client, 'auth')) {
     357                    $client->auth($pass);
     358                }
     359                $pong = $client->ping();
     360                if (!in_array($pong, ['+PONG','PONG',true], true)) {
     361                    $client = null;
     362                }
     363            } else {
     364                $client = null;
     365            }
     366        } catch (\Throwable $e) {
     367            $client = null;
     368        }
     369    }
     370    if ($client) {
     371        $client->incr($scopedVisitorKey);
     372        $client->expire($scopedVisitorKey, $lifetime);
     373        return;
     374    }
     375
     376    static $mem = null;
     377    if ($allowDirectClients && !$mem && extension_loaded('memcached') && class_exists('Memcached')) {
     378        $mem = new \Memcached('dfehc-cookie');
     379        if (!$mem->getServerList()) {
     380            $mem->addServer(
     381                function_exists('dfehc_get_memcached_server') ? dfehc_get_memcached_server() : '127.0.0.1',
     382                function_exists('dfehc_get_memcached_port') ? dfehc_get_memcached_port() : 11211
    138383            );
    139 
    140         if (!$ok || $client->ping() !== '+PONG') {
    141             $client = null;
    142         }
    143     } catch (\Throwable $e) {
    144         $client = null;
    145     }
    146 }
    147 
    148 if ($client) {
    149     $client->incr($visitorKey);
    150     $client->expire($visitorKey, $lifetime);
    151     return;
    152 }
    153 
    154     static $mem = null;
    155     if (!$mem && extension_loaded('memcached') && class_exists('Memcached')) {
    156         $mem = new \Memcached();
    157         $mem->addServer(
    158             function_exists('dfehc_get_memcached_server') ? dfehc_get_memcached_server() : '127.0.0.1',
    159             function_exists('dfehc_get_memcached_port') ? dfehc_get_memcached_port() : 11211
    160         );
     384        }
    161385        if (empty($mem->getStats())) {
    162386            $mem = null;
     
    164388    }
    165389    if ($mem) {
    166         $val = $mem->increment($visitorKey, 1);
    167         if ($val === false) {
    168             $mem->set($visitorKey, 1, $lifetime);
     390        $valInc = $mem->increment($scopedVisitorKey, 1);
     391        if ($valInc === false) {
     392            $mem->set($scopedVisitorKey, 1, $lifetime);
    169393        } else {
    170             $mem->touch($visitorKey, $lifetime);
    171         }
    172         return;
    173     }
    174 
    175     $cnt = (int) get_transient($visitorKey);
    176     set_transient($visitorKey, $cnt + 1, $lifetime);
    177 }
    178 
    179 add_action('send_headers', 'dfehc_set_user_cookie');
     394            $mem->touch($scopedVisitorKey, $lifetime);
     395        }
     396        return;
     397    }
     398
     399    $cnt = (int) get_transient($scopedVisitorKey);
     400    $vTtl = $lifetime + (function_exists('random_int') ? random_int(0, 5) : 0);
     401    dfehc_set_transient_noautoload($scopedVisitorKey, $cnt + 1, $vTtl);
     402}
     403
     404add_action('send_headers', 'dfehc_set_user_cookie', 1);
  • dynamic-front-end-heartbeat-control/trunk/visitor/manager.php

    r3320647 r3396626  
    22declare(strict_types=1);
    33
     4if (!function_exists('dfehc_blog_id')) {
     5    function dfehc_blog_id(): int {
     6        return function_exists('get_current_blog_id') ? (int) get_current_blog_id() : 0;
     7    }
     8}
     9if (!function_exists('dfehc_host_token')) {
     10    function dfehc_host_token(): string {
     11        static $t = '';
     12        if ($t !== '') return $t;
     13        $host = @php_uname('n') ?: (defined('WP_HOME') ? WP_HOME : (function_exists('home_url') ? home_url() : 'unknown'));
     14        $salt = defined('DB_NAME') ? (string) DB_NAME : '';
     15        return $t = substr(md5((string) $host . $salt), 0, 10);
     16    }
     17}
     18if (!function_exists('dfehc_scoped_key')) {
     19    function dfehc_scoped_key(string $base): string {
     20        return $base . '_' . dfehc_blog_id() . '_' . dfehc_host_token();
     21    }
     22}
     23if (!function_exists('dfehc_set_transient_noautoload')) {
     24    function dfehc_set_transient_noautoload(string $key, $value, int $expiration): void {
     25        $group = defined('DFEHC_CACHE_GROUP') ? DFEHC_CACHE_GROUP : 'dfehc';
     26        if (function_exists('wp_using_ext_object_cache') && wp_using_ext_object_cache()) {
     27            if (function_exists('wp_cache_add')) {
     28                if (!wp_cache_add($key, $value, $group, $expiration)) {
     29                    wp_cache_set($key, $value, $group, $expiration);
     30                }
     31            } else {
     32                wp_cache_set($key, $value, $group, $expiration);
     33            }
     34            return;
     35        }
     36        set_transient($key, $value, $expiration);
     37        global $wpdb;
     38        if (!isset($wpdb->options)) return;
     39        $opt_key = "_transient_$key";
     40        $opt_key_to = "_transient_timeout_$key";
     41        $wpdb->suppress_errors(true);
     42        $autoload = $wpdb->get_var($wpdb->prepare("SELECT autoload FROM {$wpdb->options} WHERE option_name=%s LIMIT 1", $opt_key));
     43        if ($autoload === 'yes') {
     44            $wpdb->update($wpdb->options, ['autoload' => 'no'], ['option_name' => $opt_key, 'autoload' => 'yes'], ['%s'], ['%s','%s']);
     45        }
     46        $autoload_to = $wpdb->get_var($wpdb->prepare("SELECT autoload FROM {$wpdb->options} WHERE option_name=%s LIMIT 1", $opt_key_to));
     47        if ($autoload_to === 'yes') {
     48            $wpdb->update($wpdb->options, ['autoload' => 'no'], ['option_name' => $opt_key_to, 'autoload' => 'yes'], ['%s'], ['%s','%s']);
     49        }
     50        $wpdb->suppress_errors(false);
     51    }
     52}
     53if (!function_exists('dfehc_get_redis_server')) {
     54    function dfehc_get_redis_server(): string {
     55        $h = getenv('REDIS_HOST');
     56        return $h ? (string) $h : '127.0.0.1';
     57    }
     58}
     59if (!function_exists('dfehc_get_redis_port')) {
     60    function dfehc_get_redis_port(): int {
     61        $p = getenv('REDIS_PORT');
     62        return $p && ctype_digit((string) $p) ? (int) $p : 6379;
     63    }
     64}
     65if (!function_exists('dfehc_get_memcached_server')) {
     66    function dfehc_get_memcached_server(): string {
     67        $h = getenv('MEMCACHED_HOST');
     68        return $h ? (string) $h : '127.0.0.1';
     69    }
     70}
     71if (!function_exists('dfehc_get_memcached_port')) {
     72    function dfehc_get_memcached_port(): int {
     73        $p = getenv('MEMCACHED_PORT');
     74        return $p && ctype_digit((string) $p) ? (int) $p : 11211;
     75    }
     76}
     77if (!defined('DFEHC_SENTINEL_NO_LOAD')) {
     78    define('DFEHC_SENTINEL_NO_LOAD', -1);
     79}
     80
    481if (!function_exists('dfehc_acquire_lock')) {
    582    function dfehc_acquire_lock(string $key, int $ttl = 60) {
     83        $group = apply_filters('dfehc_cache_group', defined('DFEHC_CACHE_GROUP') ? DFEHC_CACHE_GROUP : 'dfehc');
     84        $scoped = dfehc_scoped_key($key);
    685        if (class_exists('WP_Lock')) {
    7             $lock = new WP_Lock($key, $ttl);
     86            $lock = new WP_Lock($scoped, $ttl);
    887            return $lock->acquire() ? $lock : null;
    988        }
    10         return wp_cache_add($key, 1, '', $ttl) ? (object) ['cache_key' => $key] : null;
     89        if (function_exists('wp_cache_add') && wp_cache_add($scoped, 1, $group, $ttl)) {
     90            return (object) ['cache_key' => $scoped, 'cache_group' => $group];
     91        }
     92        if (false !== get_transient($scoped)) {
     93            return null;
     94        }
     95        if (set_transient($scoped, 1, $ttl)) {
     96            return (object) ['transient_key' => $scoped];
     97        }
     98        return null;
    1199    }
    12100    function dfehc_release_lock($lock): void {
    13101        if ($lock instanceof WP_Lock) {
    14102            $lock->release();
    15         } elseif (is_object($lock) && isset($lock->cache_key)) {
    16             wp_cache_delete($lock->cache_key, '');
     103            return;
     104        }
     105        if (is_object($lock) && isset($lock->cache_key)) {
     106            $group = $lock->cache_group ?? apply_filters('dfehc_cache_group', defined('DFEHC_CACHE_GROUP') ? DFEHC_CACHE_GROUP : 'dfehc');
     107            wp_cache_delete($lock->cache_key, $group);
     108            return;
     109        }
     110        if (is_object($lock) && isset($lock->transient_key)) {
     111            delete_transient($lock->transient_key);
    17112        }
    18113    }
     
    20115
    21116function dfehc_set_default_last_activity_time(int $user_id): void {
    22     update_user_meta($user_id, 'last_activity_time', time());
     117    $meta_key = (string) apply_filters('dfehc_last_activity_meta_key', 'last_activity_time');
     118    update_user_meta($user_id, $meta_key, time());
    23119}
    24120add_action('user_register', 'dfehc_set_default_last_activity_time');
     
    26122function dfehc_add_intervals(array $s): array {
    27123    $s['dfehc_5_minutes'] ??= ['interval' => 300, 'display' => __('Every 5 minutes (DFEHC)', 'dfehc')];
     124    $s['dfehc_daily'] ??= ['interval' => DAY_IN_SECONDS, 'display' => __('Daily (DFEHC)', 'dfehc')];
    28125    return $s;
    29126}
     
    31128
    32129function dfehc_schedule_user_activity_processing(): void {
     130    $lock = dfehc_acquire_lock('dfehc_cron_sched_lock', 15);
     131    $aligned = time() - time() % 300 + 300;
    33132    if (!get_option('dfehc_activity_cron_scheduled') && !wp_next_scheduled('dfehc_process_user_activity')) {
    34         wp_schedule_event(time() - time() % 300 + 300, 'dfehc_5_minutes', 'dfehc_process_user_activity');
    35         update_option('dfehc_activity_cron_scheduled', 1, false);
     133        $ok = wp_schedule_event($aligned, 'dfehc_5_minutes', 'dfehc_process_user_activity');
     134        if ($ok) {
     135            update_option('dfehc_activity_cron_scheduled', 1, false);
     136        } else {
     137            wp_schedule_single_event($aligned, 'dfehc_process_user_activity');
     138        }
     139    }
     140    if (!wp_next_scheduled('dfehc_cleanup_user_activity')) {
     141        $args = [0, (int) apply_filters('dfehc_cleanup_batch_size', 75)];
     142        $ok2 = wp_schedule_event($aligned + 300, 'dfehc_daily', 'dfehc_cleanup_user_activity', $args);
     143        if (!$ok2) {
     144            wp_schedule_single_event($aligned + 300, 'dfehc_cleanup_user_activity', $args);
     145        }
     146    }
     147    if ($lock) {
     148        dfehc_release_lock($lock);
    36149    }
    37150}
     
    43156        return;
    44157    }
     158
     159    $prev = (bool) ignore_user_abort(true);
     160
    45161    try {
    46162        dfehc_process_user_activity();
    47163    } finally {
     164        ignore_user_abort((bool) $prev);
    48165        dfehc_release_lock($lock);
    49166    }
     
    52169
    53170function dfehc_process_user_activity(): void {
    54     if (get_transient('dfehc_activity_backfill_complete')) {
     171    $flag_opt = dfehc_scoped_key('dfehc_activity_backfill_done');
     172    if (get_option($flag_opt)) {
    55173        return;
    56174    }
    57175    global $wpdb;
    58     $batch   = (int) apply_filters('dfehc_activity_processing_batch_size', 75);
    59     $last_id = (int) get_option('dfehc_activity_last_id', 0);
    60     $ids     = $wpdb->get_col($wpdb->prepare(
     176    $meta_key = (string) apply_filters('dfehc_last_activity_meta_key', 'last_activity_time');
     177    $batch = (int) apply_filters('dfehc_activity_processing_batch_size', 75);
     178    $batch = max(1, min(500, $batch));
     179    $last_id_opt = dfehc_scoped_key('dfehc_activity_last_id');
     180    $last_id = (int) get_option($last_id_opt, 0);
     181    $ids = $wpdb->get_col($wpdb->prepare(
    61182        "SELECT ID FROM $wpdb->users WHERE ID > %d ORDER BY ID ASC LIMIT %d",
    62183        $last_id, $batch
    63184    ));
    64185    if (!$ids) {
    65         set_transient('dfehc_activity_backfill_complete', true, DAY_IN_SECONDS);
    66         delete_option('dfehc_activity_last_id');
     186        update_option($flag_opt, 1, false);
     187        delete_option($last_id_opt);
     188        update_option(dfehc_scoped_key('dfehc_last_activity_cron'), time(), false);
    67189        return;
    68190    }
    69191    $now = time();
     192    $written = 0;
     193    $max_writes = (int) apply_filters('dfehc_activity_max_writes_per_run', 500);
    70194    foreach ($ids as $id) {
    71         if (!get_user_meta($id, 'last_activity_time', true)) {
    72             update_user_meta($id, 'last_activity_time', $now);
    73         }
    74     }
    75     update_option('dfehc_activity_last_id', end($ids), false);
     195        if (!get_user_meta($id, $meta_key, true)) {
     196            update_user_meta($id, $meta_key, $now);
     197            $written++;
     198            if ($written >= $max_writes) {
     199                break;
     200            }
     201        }
     202    }
     203    update_option($last_id_opt, end($ids), false);
     204    update_option(dfehc_scoped_key('dfehc_last_activity_cron'), time(), false);
    76205}
    77206
    78207function dfehc_record_user_activity(): void {
    79     if (!is_user_logged_in()) {
     208    if (!function_exists('is_user_logged_in') || !is_user_logged_in()) {
    80209        return;
    81210    }
    82211    static $cache = [];
    83     $uid      = get_current_user_id();
    84     $now      = time();
     212    $meta_key = (string) apply_filters('dfehc_last_activity_meta_key', 'last_activity_time');
     213    $uid = get_current_user_id();
     214    $now = time();
    85215    $interval = (int) apply_filters('dfehc_activity_update_interval', 900);
    86     $last     = $cache[$uid] ?? (int) get_user_meta($uid, 'last_activity_time', true);
     216    $interval = max(60, $interval);
     217    $last = $cache[$uid] ?? (int) get_user_meta($uid, $meta_key, true);
    87218    if ($now - $last >= $interval) {
    88         update_user_meta($uid, 'last_activity_time', $now);
     219        update_user_meta($uid, $meta_key, $now);
    89220        $cache[$uid] = $now;
    90221    }
     
    97228        return;
    98229    }
     230    $prev = ignore_user_abort(true);
     231    if (function_exists('set_time_limit')) {
     232        @set_time_limit(30);
     233    }
    99234    try {
    100235        global $wpdb;
     236        $meta_key = (string) apply_filters('dfehc_last_activity_meta_key', 'last_activity_time');
    101237        $batch_size = (int) apply_filters('dfehc_cleanup_batch_size', $batch_size);
     238        $batch_size = max(1, min(500, $batch_size));
    102239        $ids = $wpdb->get_col($wpdb->prepare(
    103240            "SELECT ID FROM $wpdb->users WHERE ID > %d ORDER BY ID ASC LIMIT %d",
     
    105242        ));
    106243        if (!$ids) {
    107             return;
    108         }
     244            update_option(dfehc_scoped_key('dfehc_last_cleanup_cron'), time(), false);
     245            return;
     246        }
     247        $cutoff = time() - (int) apply_filters('dfehc_activity_expiration', WEEK_IN_SECONDS);
    109248        foreach ($ids as $id) {
    110             delete_user_meta($id, 'last_activity_time');
     249            $ts = (int) get_user_meta($id, $meta_key, true);
     250            if ($ts && $ts < $cutoff) {
     251                delete_user_meta($id, $meta_key);
     252            }
    111253        }
    112254        if (count($ids) === $batch_size) {
    113             wp_schedule_single_event(time() + 15, 'dfehc_cleanup_user_activity', [end($ids), $batch_size]);
    114         }
     255            wp_schedule_single_event(time() + 15 + (function_exists('random_int') ? random_int(0, 5) : rand(0, 5)), 'dfehc_cleanup_user_activity', [end($ids), $batch_size]);
     256        }
     257        update_option(dfehc_scoped_key('dfehc_last_cleanup_cron'), time(), false);
    115258    } finally {
     259        ignore_user_abort($prev);
    116260        dfehc_release_lock($lock);
    117261    }
     
    120264
    121265function dfehc_increment_total_visitors(): void {
    122     $key = 'dfehc_total_visitors';
    123     $grp = apply_filters('dfehc_cache_group', 'dfehc');
    124     $ttl = HOUR_IN_SECONDS;
    125 
    126     if (wp_using_ext_object_cache() && function_exists('wp_cache_incr')) {
    127         if (false === wp_cache_incr($key, 1, $grp)) {
     266    $key = dfehc_scoped_key('dfehc_total_visitors');
     267    $grp = apply_filters('dfehc_cache_group', defined('DFEHC_CACHE_GROUP') ? DFEHC_CACHE_GROUP : 'dfehc');
     268    $ttl = (int) apply_filters('dfehc_total_visitors_ttl', HOUR_IN_SECONDS);
     269    $ttl += function_exists('random_int') ? random_int(0, 5) : 0;
     270
     271    if (function_exists('wp_using_ext_object_cache') && wp_using_ext_object_cache() && function_exists('wp_cache_incr')) {
     272        if (false === wp_cache_add($key, 0, $grp, $ttl)) {
     273            $existing = wp_cache_get($key, $grp);
     274            wp_cache_set($key, (int) ($existing ?: 0), $grp, $ttl);
     275        }
     276        $val = wp_cache_incr($key, 1, $grp);
     277        if ($val === false) {
    128278            wp_cache_set($key, 1, $grp, $ttl);
    129279        }
     
    131281    }
    132282
    133   static $conn;
    134 
    135 if (!$conn && extension_loaded('redis') && class_exists('Redis')) {
    136     try {
    137         $conn = new \Redis();
    138         if (
    139             !$conn->pconnect('127.0.0.1', 6379) ||
    140             $conn->ping() !== '+PONG'
    141         ) {
    142             $conn = null;
    143         }
    144     } catch (\Throwable $e) {
    145         $conn = null;
    146     }
    147 }
    148 
    149 if ($conn) {
    150     $conn->incr($key);
    151     $conn->expire($key, $ttl);
    152     return;
    153 }
    154 
     283    $allowDirectClients = (bool) apply_filters('dfehc_enable_direct_cache_clients', false);
     284
     285    static $redis = null;
     286    if ($allowDirectClients && !$redis && extension_loaded('redis') && class_exists('Redis')) {
     287        try {
     288            $redis = new \Redis();
     289            $ok = $redis->pconnect(dfehc_get_redis_server(), dfehc_get_redis_port(), 1.0);
     290            if ($ok) {
     291                $pass = apply_filters('dfehc_redis_auth', getenv('REDIS_PASSWORD') ?: null);
     292                $user = apply_filters('dfehc_redis_user', getenv('REDIS_USERNAME') ?: null);
     293                if ($user && $pass && method_exists($redis, 'auth')) {
     294                    $redis->auth([$user, $pass]);
     295                } elseif ($pass && method_exists($redis, 'auth')) {
     296                    $redis->auth($pass);
     297                }
     298                $pong = $redis->ping();
     299                if (!in_array($pong, ['+PONG','PONG', true], true)) {
     300                    $redis = null;
     301                }
     302            } else {
     303                $redis = null;
     304            }
     305        } catch (\Throwable $e) {
     306            $redis = null;
     307        }
     308    }
     309    if ($redis) {
     310        $redis->incr($key);
     311        $redis->expire($key, $ttl);
     312        return;
     313    }
     314
     315    static $mem = null;
     316    if ($allowDirectClients && !$mem && extension_loaded('memcached') && class_exists('Memcached')) {
     317        $mem = new \Memcached('dfehc-visitors');
     318        if (!$mem->getServerList()) {
     319            $mem->addServer(dfehc_get_memcached_server(), dfehc_get_memcached_port());
     320        }
     321        if (empty($mem->getStats())) {
     322            $mem = null;
     323        }
     324    }
     325    if ($mem) {
     326        $inc = $mem->increment($key, 1);
     327        if ($inc === false) {
     328            $mem->set($key, 1, $ttl);
     329        } else {
     330            $mem->touch($key, $ttl);
     331        }
     332        return;
     333    }
    155334
    156335    $cnt = (int) get_transient($key);
    157     set_transient($key, $cnt + 1, $ttl);
     336    if ($cnt === 0) {
     337        dfehc_set_transient_noautoload($key, 0, $ttl);
     338        $cnt = 0;
     339    }
     340    dfehc_set_transient_noautoload($key, $cnt + 1, $ttl);
    158341}
    159342function dfehc_increment_total_visitors_fallback(): void {
     
    162345
    163346function dfehc_safe_cache_get(string $key): int {
    164     $grp = apply_filters('dfehc_cache_group', 'dfehc');
    165     if (wp_using_ext_object_cache() && function_exists('wp_cache_get')) {
    166         $v = wp_cache_get($key, $grp);
    167         return $v !== false ? (int) $v : 0;
    168     }
    169     $v = get_transient($key);
    170     return $v !== false ? (int) $v : 0;
     347    $scoped = dfehc_scoped_key($key);
     348    $grp = apply_filters('dfehc_cache_group', defined('DFEHC_CACHE_GROUP') ? DFEHC_CACHE_GROUP : 'dfehc');
     349    if (function_exists('wp_using_ext_object_cache') && wp_using_ext_object_cache() && function_exists('wp_cache_get')) {
     350        $v = wp_cache_get($scoped, $grp);
     351        return is_numeric($v) ? (int) $v : 0;
     352    }
     353    $v = get_transient($scoped);
     354    return is_numeric($v) ? (int) $v : 0;
    171355}
    172356
    173357function dfehc_safe_cache_delete(string $key): void {
    174     $grp = apply_filters('dfehc_cache_group', 'dfehc');
    175     if (wp_using_ext_object_cache() && function_exists('wp_cache_delete')) {
    176         wp_cache_delete($key, $grp);
    177     }
    178     delete_transient($key);
     358    $scoped = dfehc_scoped_key($key);
     359    $grp = apply_filters('dfehc_cache_group', defined('DFEHC_CACHE_GROUP') ? DFEHC_CACHE_GROUP : 'dfehc');
     360    if (function_exists('wp_using_ext_object_cache') && wp_using_ext_object_cache() && function_exists('wp_cache_delete')) {
     361        wp_cache_delete($scoped, $grp);
     362    }
     363    delete_transient($scoped);
    179364}
    180365
    181366function dfehc_get_website_visitors(): int {
    182     $cache = get_transient('dfehc_total_visitors');
     367    $cache_key = dfehc_scoped_key('dfehc_total_visitors_cache');
     368    $regen_key = dfehc_scoped_key('dfehc_regenerating_cache');
     369    $stale_opt = dfehc_scoped_key('dfehc_stale_total_visitors');
     370    $cache = get_transient($cache_key);
    183371    if ($cache !== false) {
    184372        return (int) $cache;
    185373    }
    186     if (get_transient('dfehc_regenerating_cache')) {
    187         return (int) get_option('dfehc_stale_total_visitors', 0);
    188     }
    189 
    190     set_transient('dfehc_regenerating_cache', true, MINUTE_IN_SECONDS);
     374    if (get_transient($regen_key)) {
     375        $stale = get_option($stale_opt, 0);
     376        return is_numeric($stale) ? (int) $stale : 0;
     377    }
     378
     379    $regen_ttl = MINUTE_IN_SECONDS + (function_exists('random_int') ? random_int(0, 5) : 0);
     380    dfehc_set_transient_noautoload($regen_key, true, $regen_ttl);
    191381    $total = dfehc_safe_cache_get('dfehc_total_visitors');
    192     set_transient('dfehc_total_visitors', $total, 4 * MINUTE_IN_SECONDS);
    193     update_option('dfehc_stale_total_visitors', $total, false);
    194 
    195     delete_transient('dfehc_regenerating_cache');
    196     return (int) apply_filters('dfehc_get_website_visitors_result', $total);
    197 }
    198 function dfehc_get_users_in_batches(int $batch_size, int $offset): array
    199 {
     382    $ttl = (int) apply_filters('dfehc_visitors_cache_ttl', 10 * MINUTE_IN_SECONDS);
     383    $ttl += function_exists('random_int') ? random_int(0, 5) : 0;
     384    dfehc_set_transient_noautoload($cache_key, (int) $total, $ttl);
     385    update_option($stale_opt, (int) $total, false);
     386    delete_transient($regen_key);
     387
     388    return (int) apply_filters('dfehc_get_website_visitors_result', (int) $total);
     389}
     390
     391function dfehc_get_users_in_batches(int $batch_size, int $offset): array {
     392    $batch_size = max(1, min(1000, $batch_size));
     393    $offset = max(0, $offset);
    200394    $query = new WP_User_Query([
    201395        'number' => $batch_size,
     
    203397        'fields' => ['ID'],
    204398    ]);
    205     return $query->get_results();
    206 }
     399    $res = $query->get_results();
     400    return is_array($res) ? $res : [];
     401}
     402
    207403function dfehc_reset_total_visitors(): void {
     404    $start = microtime(true);
    208405    $lock = dfehc_acquire_lock('dfehc_resetting_visitors', 60);
    209406    if (!$lock) {
     
    212409    try {
    213410        $threshold = (float) apply_filters('dfehc_reset_load_threshold', 15.0);
    214         $load      = function_exists('dfehc_get_server_load') ? dfehc_get_server_load() : 0;
    215         if ($load === \DFEHC_SENTINEL_NO_LOAD || $load >= $threshold) {
     411        $load = function_exists('dfehc_get_server_load') ? dfehc_get_server_load() : DFEHC_SENTINEL_NO_LOAD;
     412        if (!is_numeric($load)) {
     413            return;
     414        }
     415        $load = (float) $load;
     416        if ($load === (float) DFEHC_SENTINEL_NO_LOAD || $load >= $threshold) {
    216417            return;
    217418        }
    218419        dfehc_safe_cache_delete('dfehc_total_visitors');
    219         delete_option('dfehc_stale_total_visitors');
    220         delete_transient('dfehc_total_visitors');
     420        delete_option(dfehc_scoped_key('dfehc_stale_total_visitors'));
     421        delete_transient(dfehc_scoped_key('dfehc_total_visitors'));
     422        delete_transient(dfehc_scoped_key('dfehc_regenerating_cache'));
     423        if (microtime(true) - $start > 5) {
     424            return;
     425        }
    221426    } finally {
    222427        dfehc_release_lock($lock);
     
    226431
    227432function dfehc_on_activate(): void {
     433    $aligned = time() - time() % 300 + 300;
    228434    if (!wp_next_scheduled('dfehc_reset_total_visitors_event')) {
    229         wp_schedule_event(time() + HOUR_IN_SECONDS, 'hourly', 'dfehc_reset_total_visitors_event');
     435        $ok = wp_schedule_event($aligned + HOUR_IN_SECONDS, 'hourly', 'dfehc_reset_total_visitors_event');
     436        if (!$ok) {
     437            wp_schedule_single_event($aligned + HOUR_IN_SECONDS, 'dfehc_reset_total_visitors_event');
     438        }
    230439    }
    231440    dfehc_process_user_activity();
    232441}
    233 register_activation_hook(__FILE__, 'dfehc_on_activate');
     442if (function_exists('register_activation_hook')) {
     443    register_activation_hook(__FILE__, 'dfehc_on_activate');
     444}
    234445
    235446function dfehc_on_deactivate(): void {
    236447    wp_clear_scheduled_hook('dfehc_process_user_activity');
    237448    wp_clear_scheduled_hook('dfehc_reset_total_visitors_event');
     449    wp_clear_scheduled_hook('dfehc_cleanup_user_activity');
    238450    delete_option('dfehc_activity_cron_scheduled');
    239 }
    240 register_deactivation_hook(__FILE__, 'dfehc_on_deactivate');
     451    delete_option(dfehc_scoped_key('dfehc_activity_backfill_done'));
     452}
     453if (function_exists('register_deactivation_hook')) {
     454    register_deactivation_hook(__FILE__, 'dfehc_on_deactivate');
     455}
    241456
    242457if (defined('WP_CLI') && WP_CLI) {
Note: See TracChangeset for help on using the changeset viewer.