prefix . 'nbh_performance_cache_index'; } /** Return the normalized cache-tag mapping table. */ public static function tags_table_name() { global $wpdb; return $wpdb->prefix . 'nbh_performance_cache_tags'; } /** Install or upgrade cache index tables. */ public static function install() { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $table = self::table_name(); $tags_table = self::tags_table_name(); $charset = $wpdb->get_charset_collate(); $sql = "CREATE TABLE {$table} ( cache_key varchar(191) NOT NULL, file_path text NOT NULL, metadata_path text NOT NULL, url_hash char(64) NOT NULL, url text NOT NULL, dependencies longtext NOT NULL, variants longtext NOT NULL, status_code smallint(5) unsigned NOT NULL DEFAULT 200, file_size bigint(20) unsigned NOT NULL DEFAULT 0, generated_at bigint(20) unsigned NOT NULL DEFAULT 0, expires_at bigint(20) unsigned NOT NULL DEFAULT 0, stale_until bigint(20) unsigned NOT NULL DEFAULT 0, last_accessed bigint(20) unsigned NOT NULL DEFAULT 0, hit_count bigint(20) unsigned NOT NULL DEFAULT 0, PRIMARY KEY (cache_key), KEY url_hash (url_hash), KEY generated_at (generated_at), KEY stale_until (stale_until) ) {$charset};"; $tag_sql = "CREATE TABLE {$tags_table} ( cache_key varchar(191) NOT NULL, cache_tag varchar(191) NOT NULL, PRIMARY KEY (cache_key, cache_tag), KEY cache_tag (cache_tag) ) {$charset};"; dbDelta( $sql ); dbDelta( $tag_sql ); // dbDelta() can report SQL actions even when the host denies a CREATE // or ALTER operation. Only commit the schema marker after verification. $tables = array( $table => array( 'cache_key', 'file_path', 'url_hash', 'expires_at' ), $tags_table => array( 'cache_key', 'cache_tag' ), ); foreach ( $tables as $name => $required_columns ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Installation must verify actual table/column existence after dbDelta(), never a stale object-cache value. $found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $name ) ) ); if ( $name !== $found ) { update_option( 'nbh_performance_cache_index_schema_error', 'missing_table', false ); return false; } foreach ( $required_columns as $column ) { // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Validated backtick-quoted table identifier, required for WordPress 6.0/6.1 before the %i placeholder was introduced. $exists = $wpdb->get_row( $wpdb->prepare( 'SHOW COLUMNS FROM `%2$s` LIKE %s', $column, nbh_performance_validated_table_name( $name ) ) ); if ( ! $exists ) { update_option( 'nbh_performance_cache_index_schema_error', 'missing_column', false ); return false; } } } delete_option( 'nbh_performance_cache_index_schema_error' ); update_option( 'nbh_performance_cache_index_schema_version', self::SCHEMA_VERSION, false ); return true; } /** * Add or replace one entry and its normalized dependency tags. * * @param string $key Cache key. * @param string $file HTML file. * @param string $url Canonical URL. * @param array $dependencies Dependency tags. * @param array $variants Cache variants. * @param array $metadata Response metadata. * @return bool */ public function record( $key, $file, $url, array $dependencies, array $variants, array $metadata = array() ) { global $wpdb; $key = $this->normalize_key( $key ); if ( '' === $key ) { return false; } $file = wp_normalize_path( $file ); if ( ! CacheEntryFiles::is_owned_html_path( $file ) ) { return false; } $url = esc_url_raw( $url ); $dependencies = array_values( array_unique( array_filter( array_map( 'sanitize_key', $dependencies ) ) ) ); $paths = CacheEntryFiles::paths( $file ); $generated = isset( $metadata['generated_at'] ) ? absint( $metadata['generated_at'] ) : time(); $expires = isset( $metadata['expires_at'] ) ? absint( $metadata['expires_at'] ) : $generated; $stale_until = isset( $metadata['stale_until'] ) ? absint( $metadata['stale_until'] ) : $expires; $data = array( 'cache_key' => $key, 'file_path' => $file, 'metadata_path' => $paths['metadata'], 'url_hash' => hash( 'sha256', $this->normalize_url( $url ) ), 'url' => $url, 'dependencies' => wp_json_encode( $dependencies ), 'variants' => wp_json_encode( $variants ), 'status_code' => isset( $metadata['status'] ) ? absint( $metadata['status'] ) : 200, 'file_size' => is_file( $file ) ? (int) filesize( $file ) : 0, 'generated_at' => $generated, 'expires_at' => $expires, 'stale_until' => $stale_until, 'last_accessed' => $generated, 'hit_count' => 0, ); $formats = array( '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%d', '%d', '%d', '%d' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Live cache-index/runtime-manifest state is required for invalidation; it cannot depend on the cache being invalidated. $result = $wpdb->replace( self::table_name(), $data, $formats ); if ( false === $result ) { return false; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Live cache-index/runtime-manifest state is required for invalidation; it cannot depend on the cache being invalidated. if ( false === $wpdb->delete( self::tags_table_name(), array( 'cache_key' => $key ), array( '%s' ) ) ) { $this->remove( $key ); return false; } foreach ( $dependencies as $tag ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Live cache-index/runtime-manifest state is required for invalidation; it cannot depend on the cache being invalidated. $tag_written = $wpdb->query( // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder -- Validated backtick-quoted table identifier, required for WordPress 6.0/6.1 before the %i placeholder was introduced. $wpdb->prepare( 'INSERT IGNORE INTO `%3$s` (cache_key, cache_tag) VALUES (%s, %s)', $key, $tag, nbh_performance_validated_table_name( self::tags_table_name() ) ) ); if ( false === $tag_written ) { // Reject a partially indexed page; retaining an untagged row // would prevent correct invalidation after content changes. $this->remove( $key ); return false; } } $this->prune(); return true; } /** Return all entries keyed by cache key. */ public function all() { global $wpdb; $table = self::table_name(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder -- Live cache-index/runtime-manifest state is required for invalidation; it cannot depend on the cache being invalidated. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT cache_key, file_path, metadata_path, url, dependencies, variants, status_code, file_size, generated_at, expires_at, stale_until, last_accessed, hit_count FROM `%1$s` ORDER BY generated_at DESC', nbh_performance_validated_table_name( $table ) ), ARRAY_A ); $out = array(); foreach ( (array) $rows as $row ) { $out[ $row['cache_key'] ] = $this->hydrate( $row ); } return $out; } /** Delete every entry carrying one of the supplied normalized tags. */ public function purge_dependencies( array $dependencies ) { global $wpdb; $dependencies = array_values( array_unique( array_filter( array_map( 'sanitize_key', $dependencies ) ) ) ); if ( empty( $dependencies ) ) { return true; } // Query in bounded groups with fixed placeholders. Never concatenate SQL // fragments derived from the number or content of dependency tags. // This keeps WordPress 6.0 compatibility without relying on %i. $matching_rows = array(); foreach ( array_chunk( $dependencies, 8 ) as $tags ) { $tags = array_pad( $tags, 8, '' ); // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder -- Strictly validated backtick-quoted identifiers for WordPress 6.0/6.1, before %i. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Invalidation requires the live tag index, never a cached result. $batch = $wpdb->get_results( $wpdb->prepare( 'SELECT DISTINCT i.cache_key, i.file_path FROM `%9$s` t INNER JOIN `%10$s` i ON i.cache_key = t.cache_key WHERE t.cache_tag IN (%s,%s,%s,%s,%s,%s,%s,%s)', $tags[0], $tags[1], $tags[2], $tags[3], $tags[4], $tags[5], $tags[6], $tags[7], nbh_performance_validated_table_name( self::tags_table_name() ), nbh_performance_validated_table_name( self::table_name() ) ), ARRAY_A ); // phpcs:enable WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder if ( ! is_array( $batch ) ) { // Never report a successful purge after a failed index read. return false; } foreach ( $batch as $row ) { if ( isset( $row['cache_key'] ) ) { $matching_rows[ $row['cache_key'] ] = $row; } } } $rows = array_values( $matching_rows ); $success = true; foreach ( (array) $rows as $row ) { if ( ! CacheEntryFiles::delete( isset( $row['file_path'] ) ? $row['file_path'] : '' ) ) { $success = false; continue; // Do not lose retry information for a failed deletion. } if ( ! $this->remove( isset( $row['cache_key'] ) ? $row['cache_key'] : '' ) ) { $success = false; } } return $success; } /** Return all indexed variants for a canonical URL. */ public function entries_for_url( $url ) { global $wpdb; $table = self::table_name(); $hash = hash( 'sha256', $this->normalize_url( $url ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder -- Live cache-index/runtime-manifest state is required for invalidation; it cannot depend on the cache being invalidated. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT cache_key, file_path, metadata_path, url, dependencies, variants, status_code, file_size, generated_at, expires_at, stale_until, last_accessed, hit_count FROM `%2$s` WHERE url_hash=%s', $hash, nbh_performance_validated_table_name( $table ) ), ARRAY_A ); return array_map( array( $this, 'hydrate' ), (array) $rows ); } /** Remove one indexed key and all normalized tags. */ public function remove( $key ) { global $wpdb; $key = $this->normalize_key( $key ); if ( '' === $key ) { return false; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Live cache-index/runtime-manifest state is required for invalidation; it cannot depend on the cache being invalidated. $wpdb->delete( self::tags_table_name(), array( 'cache_key' => $key ), array( '%s' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Live cache-index/runtime-manifest state is required for invalidation; it cannot depend on the cache being invalidated. return false !== $wpdb->delete( self::table_name(), array( 'cache_key' => $key ), array( '%s' ) ); } /** Return constant-time cache totals. */ public function summary() { global $wpdb; $table = self::table_name(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder -- Live cache-index/runtime-manifest state is required for invalidation; it cannot depend on the cache being invalidated. $row = $wpdb->get_row( $wpdb->prepare( 'SELECT COUNT(*) AS files, COALESCE(SUM(file_size), 0) AS bytes, COALESCE(SUM(hit_count), 0) AS hits FROM `%1$s`', nbh_performance_validated_table_name( $table ) ), ARRAY_A ); return array( 'files' => is_array( $row ) && isset( $row['files'] ) ? (int) $row['files'] : 0, 'bytes' => is_array( $row ) && isset( $row['bytes'] ) ? (int) $row['bytes'] : 0, 'hits' => is_array( $row ) && isset( $row['hits'] ) ? (int) $row['hits'] : 0, ); } /** Return a bounded batch whose stale safety window has ended. */ public function stale_entries( $cutoff, $limit = 1000 ) { global $wpdb; $table = self::table_name(); $cutoff = max( 0, (int) $cutoff ); $limit = max( 1, min( 5000, (int) $limit ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder -- Live cache-index/runtime-manifest state is required for invalidation; it cannot depend on the cache being invalidated. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT cache_key, file_path, stale_until FROM `%3$s` WHERE stale_until > 0 AND stale_until < %d ORDER BY stale_until ASC LIMIT %d', $cutoff, $limit, nbh_performance_validated_table_name( $table ) ), ARRAY_A ); return is_array( $rows ) ? $rows : array(); } /** Clear both index tables. */ public function clear() { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder -- Live cache-index/runtime-manifest state is required for invalidation; it cannot depend on the cache being invalidated. $tags_deleted = $wpdb->query( $wpdb->prepare( 'DELETE FROM `%1$s`', nbh_performance_validated_table_name( self::tags_table_name() ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder -- Live cache-index/runtime-manifest state is required for invalidation; it cannot depend on the cache being invalidated. $entries_deleted = $wpdb->query( $wpdb->prepare( 'DELETE FROM `%1$s`', nbh_performance_validated_table_name( self::table_name() ) ) ); return false !== $tags_deleted && false !== $entries_deleted; } /** Remove orphaned index rows whose HTML or metadata is missing. */ public function repair_orphans( $limit = 2000 ) { global $wpdb; $table = self::table_name(); $limit = max( 1, min( 5000, absint( $limit ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder -- Live cache-index/runtime-manifest state is required for invalidation; it cannot depend on the cache being invalidated. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT cache_key, file_path, metadata_path FROM `%2$s` ORDER BY generated_at ASC LIMIT %d', $limit, nbh_performance_validated_table_name( $table ) ), ARRAY_A ); $count = 0; foreach ( (array) $rows as $row ) { if ( ! is_file( $row['file_path'] ) || ! is_file( $row['metadata_path'] ) ) { CacheEntryFiles::delete( $row['file_path'] ); $this->remove( $row['cache_key'] ); ++$count; } } return $count; } /** Bound the index and remove the oldest complete entries. */ private function prune() { global $wpdb; if ( get_transient( 'nbh_performance_cache_index_prune_lock' ) ) { return; } set_transient( 'nbh_performance_cache_index_prune_lock', 1, 5 * MINUTE_IN_SECONDS ); $maximum = max( 1000, (int) apply_filters( 'nbh_performance_cache_index_limit', 100000 ) ); $table = self::table_name(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder -- Live cache-index/runtime-manifest state is required for invalidation; it cannot depend on the cache being invalidated. $count = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM `%1$s`', nbh_performance_validated_table_name( $table ) ) ); if ( $count <= $maximum ) { return; } $remove = min( 5000, $count - $maximum ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder -- Live cache-index/runtime-manifest state is required for invalidation; it cannot depend on the cache being invalidated. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT cache_key, file_path FROM `%2$s` ORDER BY generated_at ASC LIMIT %d', $remove, nbh_performance_validated_table_name( $table ) ), ARRAY_A ); foreach ( (array) $rows as $row ) { if ( CacheEntryFiles::delete( isset( $row['file_path'] ) ? $row['file_path'] : '' ) ) { $this->remove( isset( $row['cache_key'] ) ? $row['cache_key'] : '' ); } } } /** Normalize a key into the database limit. */ private function normalize_key( $key ) { $key = trim( (string) $key ); if ( '' === $key || false !== strpos( $key, '..' ) ) { return ''; } return strlen( $key ) <= 191 ? $key : 'sha256:' . hash( 'sha256', $key ); } /** Normalize URLs so all variants map to one purge target. */ private function normalize_url( $url ) { $parts = wp_parse_url( esc_url_raw( (string) $url ) ); if ( ! is_array( $parts ) ) { return ''; } $host = isset( $parts['host'] ) ? strtolower( $parts['host'] ) : ''; $path = isset( $parts['path'] ) ? '/' . ltrim( $parts['path'], '/' ) : '/'; $query = isset( $parts['query'] ) ? ( new CacheKeyGenerator() )->canonical_query( $parts['query'] ) : ''; return $host . $path . ( '' !== $query ? '?' . $query : '' ); } /** Hydrate one database row. */ private function hydrate( $row ) { return array( 'key' => isset( $row['cache_key'] ) ? $row['cache_key'] : '', 'file' => isset( $row['file_path'] ) ? wp_normalize_path( $row['file_path'] ) : '', 'metadata' => isset( $row['metadata_path'] ) ? wp_normalize_path( $row['metadata_path'] ) : '', 'url' => isset( $row['url'] ) ? esc_url_raw( $row['url'] ) : '', 'dependencies' => isset( $row['dependencies'] ) ? $this->decode_array( $row['dependencies'] ) : array(), 'variants' => isset( $row['variants'] ) ? $this->decode_array( $row['variants'] ) : array(), 'status' => isset( $row['status_code'] ) ? (int) $row['status_code'] : 200, 'file_size' => isset( $row['file_size'] ) ? (int) $row['file_size'] : 0, 'generated_at' => isset( $row['generated_at'] ) ? (int) $row['generated_at'] : 0, 'expires_at' => isset( $row['expires_at'] ) ? (int) $row['expires_at'] : 0, 'stale_until' => isset( $row['stale_until'] ) ? (int) $row['stale_until'] : 0, 'last_accessed' => isset( $row['last_accessed'] ) ? (int) $row['last_accessed'] : 0, 'hit_count' => isset( $row['hit_count'] ) ? (int) $row['hit_count'] : 0, ); } private function decode_array( $value ) { $decoded = json_decode( (string) $value, true ); return is_array( $decoded ) ? $decoded : array(); } }