Files
argus-wp-defence/includes/class-argus-cache-warmer.php
root df0f2fccb8 ARGUS WordPress Defence 1.0.0 — first production release
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.
2026-08-09 13:40:16 +00:00

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;
}
}