Automatic WordPress security: local firewall, malware and file-integrity scanning, vulnerability protection, quarantine, scheduled backups, an optional page cache, and automatic global threat intelligence. See README.md for installation, update, and uninstall instructions.
214 lines
7.5 KiB
PHP
214 lines
7.5 KiB
PHP
<?php
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
class Argus_Cache_Warmer {
|
|
|
|
const BACKOFF_OPTION = 'argus_wpd_cache_warmer_backoff';
|
|
const SLOW_ORIGIN_MS = 3000;
|
|
const MAX_ATTEMPTS = 3;
|
|
const REFILL_LIMIT = 200;
|
|
|
|
public static function process_queue() {
|
|
$backoff = get_option( self::BACKOFF_OPTION, array( 'consecutive_slow' => 0, 'next_allowed_ts' => 0 ) );
|
|
if ( time() < (int) ( $backoff['next_allowed_ts'] ?? 0 ) ) {
|
|
return array( 'skipped' => true, 'reason' => 'Backing off -- origin was slow on a recent warming pass.' );
|
|
}
|
|
|
|
if ( self::origin_looks_unhealthy() ) {
|
|
self::apply_backoff( $backoff );
|
|
return array( 'skipped' => true, 'reason' => 'Origin average response time is elevated; pausing warming this cycle.' );
|
|
}
|
|
|
|
self::refill_queue();
|
|
|
|
$batch_size = max( 1, (int) Argus_Settings::get( 'cache_warm_batch_size', 20 ) );
|
|
$batch = self::claim_batch( $batch_size );
|
|
if ( empty( $batch ) ) {
|
|
return array( 'processed' => 0 );
|
|
}
|
|
|
|
$concurrency = max( 1, min( 10, (int) Argus_Settings::get( 'cache_warm_concurrency', 4 ) ) );
|
|
$min_interval = max( 0, (int) Argus_Settings::get( 'cache_warm_min_interval_secs', 1 ) );
|
|
|
|
$results = array( 'ok' => 0, 'failed' => 0 );
|
|
foreach ( array_chunk( $batch, $concurrency ) as $chunk ) {
|
|
$chunk_results = self::fetch_concurrently( wp_list_pluck( $chunk, 'url' ) );
|
|
foreach ( $chunk as $item ) {
|
|
$ok = ! empty( $chunk_results[ $item->url ] );
|
|
self::record_result( $item, $ok );
|
|
$ok ? ++$results['ok'] : ++$results['failed'];
|
|
}
|
|
if ( $min_interval > 0 ) {
|
|
sleep( $min_interval );
|
|
}
|
|
}
|
|
|
|
if ( $results['failed'] > $results['ok'] ) {
|
|
$backoff['consecutive_slow'] = (int) ( $backoff['consecutive_slow'] ?? 0 ) + 1;
|
|
self::apply_backoff( $backoff );
|
|
} else {
|
|
update_option( self::BACKOFF_OPTION, array( 'consecutive_slow' => 0, 'next_allowed_ts' => 0 ), false );
|
|
}
|
|
|
|
return array( 'processed' => count( $batch ) ) + $results;
|
|
}
|
|
|
|
protected static function origin_looks_unhealthy() {
|
|
$summary = Argus_Cache_Log::summary( 1 );
|
|
$miss_ms = $summary[ Argus_Static_Cache::STATUS_MISS ]['avg_ms'] ?? null;
|
|
return null !== $miss_ms && $miss_ms > self::SLOW_ORIGIN_MS;
|
|
}
|
|
|
|
protected static function apply_backoff( array $backoff ) {
|
|
$consecutive = max( 1, (int) ( $backoff['consecutive_slow'] ?? 1 ) );
|
|
$delay = min( HOUR_IN_SECONDS, 60 * ( 2 ** $consecutive ) );
|
|
update_option(
|
|
self::BACKOFF_OPTION,
|
|
array( 'consecutive_slow' => $consecutive, 'next_allowed_ts' => time() + $delay ),
|
|
false
|
|
);
|
|
}
|
|
|
|
protected static function refill_queue() {
|
|
global $wpdb;
|
|
$discovered = Argus_DB::table( 'cache_discovered_urls' );
|
|
$queue = Argus_DB::table( 'cache_warm_queue' );
|
|
$home = home_url( '/' );
|
|
$now = current_time( 'mysql', true );
|
|
|
|
$candidates = $wpdb->get_results(
|
|
$wpdb->prepare(
|
|
"SELECT url, lastmod FROM {$discovered}
|
|
WHERE eligibility = %s
|
|
AND ( last_warmed_at IS NULL OR last_warmed_at < %s )
|
|
AND NOT EXISTS ( SELECT 1 FROM {$queue} q WHERE q.url = {$discovered}.url AND q.status = 'queued' )
|
|
ORDER BY (url = %s) DESC, lastmod DESC
|
|
LIMIT %d", // phpcs:ignore
|
|
Argus_Cache_Eligibility::SAFE_TO_CACHE,
|
|
gmdate( 'Y-m-d H:i:s', strtotime( '-1 hour' ) ),
|
|
$home,
|
|
self::REFILL_LIMIT
|
|
)
|
|
);
|
|
|
|
foreach ( $candidates as $i => $row ) {
|
|
$wpdb->query(
|
|
$wpdb->prepare(
|
|
"INSERT INTO {$queue} (url, priority, status, queued_at) VALUES (%s, %d, 'queued', %s)", // phpcs:ignore
|
|
$row->url,
|
|
$row->url === $home ? 0 : ( 100 + $i ),
|
|
$now
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
protected static function claim_batch( $limit ) {
|
|
global $wpdb;
|
|
$table = Argus_DB::table( 'cache_warm_queue' );
|
|
|
|
$rows = $wpdb->get_results(
|
|
$wpdb->prepare( "SELECT * FROM {$table} WHERE status = 'queued' ORDER BY priority ASC, id ASC LIMIT %d", $limit ) // phpcs:ignore
|
|
);
|
|
if ( empty( $rows ) ) {
|
|
return array();
|
|
}
|
|
|
|
$ids = wp_list_pluck( $rows, 'id' );
|
|
$wpdb->query( "UPDATE {$table} SET status = 'processing' WHERE id IN (" . implode( ',', array_map( 'absint', $ids ) ) . ')' ); // phpcs:ignore
|
|
|
|
return $rows;
|
|
}
|
|
|
|
protected static function record_result( $item, $ok ) {
|
|
global $wpdb;
|
|
$queue = Argus_DB::table( 'cache_warm_queue' );
|
|
$discovered = Argus_DB::table( 'cache_discovered_urls' );
|
|
$now = current_time( 'mysql', true );
|
|
|
|
if ( $ok ) {
|
|
$wpdb->update( $queue, array( 'status' => 'done', 'last_attempt_at' => $now, 'last_result' => 'warmed' ), array( 'id' => $item->id ) );
|
|
$wpdb->update( $discovered, array( 'last_warmed_at' => $now ), array( 'url' => $item->url ) );
|
|
Argus_Cache_Log::record( Argus_Static_Cache::STATUS_WARMED, $item->url, 'Preloaded by cache warmer.' );
|
|
return;
|
|
}
|
|
|
|
$attempts = (int) $item->attempts + 1;
|
|
if ( $attempts >= self::MAX_ATTEMPTS ) {
|
|
$wpdb->update( $queue, array( 'status' => 'failed', 'attempts' => $attempts, 'last_attempt_at' => $now, 'last_result' => 'gave up after ' . $attempts . ' attempts' ), array( 'id' => $item->id ) );
|
|
} else {
|
|
$wpdb->update( $queue, array( 'status' => 'queued', 'attempts' => $attempts, 'last_attempt_at' => $now, 'last_result' => 'retrying' ), array( 'id' => $item->id ) );
|
|
}
|
|
}
|
|
|
|
protected static function fetch_concurrently( array $urls ) {
|
|
if ( ! function_exists( 'curl_multi_init' ) ) {
|
|
$out = array();
|
|
foreach ( $urls as $url ) {
|
|
$response = wp_remote_get( $url, array( 'timeout' => 20, 'user-agent' => self::user_agent() ) );
|
|
$out[ $url ] = ! is_wp_error( $response ) && wp_remote_retrieve_response_code( $response ) < 500;
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
$mh = curl_multi_init(); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
$handles = array();
|
|
|
|
foreach ( $urls as $url ) {
|
|
$ch = curl_init( $url ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
curl_setopt_array( // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
$ch,
|
|
array(
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_TIMEOUT => 20,
|
|
CURLOPT_USERAGENT => self::user_agent(),
|
|
CURLOPT_NOBODY => false,
|
|
CURLOPT_SSL_VERIFYPEER => true,
|
|
)
|
|
);
|
|
curl_multi_add_handle( $mh, $ch ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
$handles[ $url ] = $ch;
|
|
}
|
|
|
|
$running = null;
|
|
do {
|
|
curl_multi_exec( $mh, $running ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
curl_multi_select( $mh ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
} while ( $running > 0 );
|
|
|
|
$out = array();
|
|
foreach ( $handles as $url => $ch ) {
|
|
$code = curl_getinfo( $ch, CURLINFO_HTTP_CODE ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
$out[ $url ] = $code > 0 && $code < 500;
|
|
curl_multi_remove_handle( $mh, $ch ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
curl_close( $ch ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
}
|
|
curl_multi_close( $mh ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
|
|
return $out;
|
|
}
|
|
|
|
protected static function user_agent() {
|
|
return 'ARGUS-Defence-CacheWarmer/1.0 (+' . home_url( '/' ) . ')';
|
|
}
|
|
|
|
public static function status() {
|
|
global $wpdb;
|
|
$table = Argus_DB::table( 'cache_warm_queue' );
|
|
$rows = $wpdb->get_results( "SELECT status, COUNT(*) AS cnt FROM {$table} GROUP BY status" ); // phpcs:ignore
|
|
|
|
$out = array( 'queued' => 0, 'processing' => 0, 'done' => 0, 'failed' => 0 );
|
|
foreach ( $rows as $row ) {
|
|
if ( isset( $out[ $row->status ] ) ) {
|
|
$out[ $row->status ] = (int) $row->cnt;
|
|
}
|
|
}
|
|
$backoff = get_option( self::BACKOFF_OPTION, array() );
|
|
$out['paused_until'] = ( ! empty( $backoff['next_allowed_ts'] ) && $backoff['next_allowed_ts'] > time() ) ? $backoff['next_allowed_ts'] : null;
|
|
return $out;
|
|
}
|
|
}
|