Repository: Automattic/jetpack (PHP)

Issue:
Boost: Cache Preloading - Version One, Cornerstone Pages
Implementing a Cache Preloading system for Cornerstone Pages in Jetpack Boost will enhance the performance optimization capabilities of our Page Cache feature. This implementation will automatically rebuild the cache for [Cornerstone Pages](https://jetpack.com/support/jetpack-boost/cornerstone-pages/) whenever they are updated or their cache is invalidated (such as from Post edits). This ensures critical pages are always served at optimal speed to all visitors.

Currently, our Page Cache system builds cache files on-demand when a user visits a page. While effective, this approach means the first visitor after a cache invalidation experiences slower page loads. For cornerstone pages—which represent a site's most important content and conversion paths—this inconsistent performance can negatively impact user experience, SEO, and business outcomes

By adding preload functionality specifically tied to cornerstone pages, we'll eliminate this performance gap and provide several key benefits:

- Consistent performance for all visitors to critical pages
- Immediate cache rebuilding after content updates
- Improved SEO performance for key landing pages
- Better conversion rates through consistently fast page loads
- The implementation should leverage our existing Cornerstone Pages feature, and integrate with the current cache invalidation mechanisms to automatically trigger preloading when needed, ensuring seamless functionality with minimal configuration required from user.

ref: pc9hqz-3nV-p2


Discussion: **OpenAI suggested the following labels for this issue:**
- [Feature Group] Content Management: The issue relates to managing crucial content through the implementation of cache preloading for cornerstone pages.
- [Boost Feature] Page Cache: The issue discusses enhancing the Page Cache feature for cornerstone pages to ensure optimal performance.
- [Boost Feature] Optimizations: The suggested cache preloading falls under optimizing performance for critical pages, which is a focus of Jetpack Boost.



Fix (patch):
diff --git a/projects/plugins/boost/app/contracts/Has_Slug.php b/projects/plugins/boost/app/contracts/Has_Slug.php
index 8fc5d53a1ef16..e9ee4c3ad930d 100644
--- a/projects/plugins/boost/app/contracts/Has_Slug.php
+++ b/projects/plugins/boost/app/contracts/Has_Slug.php
@@ -2,5 +2,10 @@
 namespace Automattic\Jetpack_Boost\Contracts;
 
 interface Has_Slug {
+	/**
+	 * Get the module slug.
+	 *
+	 * Returns a unique identifier for this module within Jetpack Boost.
+	 */
 	public static function get_slug();
 }
diff --git a/projects/plugins/boost/app/lib/cornerstone/Cornerstone_Utils.php b/projects/plugins/boost/app/lib/cornerstone/Cornerstone_Utils.php
index adebb1e2aff32..65d24ea9d275a 100644
--- a/projects/plugins/boost/app/lib/cornerstone/Cornerstone_Utils.php
+++ b/projects/plugins/boost/app/lib/cornerstone/Cornerstone_Utils.php
@@ -23,20 +23,28 @@ public static function get_list() {
 	}
 
 	/**
-	 * Check if a post is a cornerstone page.
+	 * Checks if a URL is a cornerstone page.
 	 *
-	 * @param int $post_id The ID of the post to check.
-	 * @return bool True if the post is a cornerstone page, false otherwise.
+	 * @param string $url The URL to check.
+	 * @return bool True if the URL is a cornerstone page, false otherwise.
 	 */
-	public static function is_cornerstone_page( $post_id ) {
+	public static function is_cornerstone_page_by_url( $url ) {
 		$cornerstone_pages = self::get_list();
 		if ( empty( $cornerstone_pages ) ) {
 			return false;
 		}
 
-		$post_url          = untrailingslashit( get_permalink( $post_id ) );
 		$cornerstone_pages = array_map( 'untrailingslashit', $cornerstone_pages );
+		return in_array( untrailingslashit( $url ), $cornerstone_pages, true );
+	}
 
-		return in_array( $post_url, $cornerstone_pages, true );
+	/**
+	 * Check if a post ID is a cornerstone page.
+	 *
+	 * @param int $post_id The ID of the post to check.
+	 * @return bool True if the post is a cornerstone page, false otherwise.
+	 */
+	public static function is_cornerstone_page( $post_id ) {
+		return self::is_cornerstone_page_by_url( get_permalink( $post_id ) );
 	}
 }
diff --git a/projects/plugins/boost/app/modules/optimizations/page-cache/Page_Cache.php b/projects/plugins/boost/app/modules/optimizations/page-cache/Page_Cache.php
index 044f88b8584de..80983ae452100 100644
--- a/projects/plugins/boost/app/modules/optimizations/page-cache/Page_Cache.php
+++ b/projects/plugins/boost/app/modules/optimizations/page-cache/Page_Cache.php
@@ -8,6 +8,7 @@
 use Automattic\Jetpack_Boost\Contracts\Changes_Page_Output;
 use Automattic\Jetpack_Boost\Contracts\Has_Data_Sync;
 use Automattic\Jetpack_Boost\Contracts\Has_Deactivate;
+use Automattic\Jetpack_Boost\Contracts\Has_Submodules;
 use Automattic\Jetpack_Boost\Contracts\Optimization;
 use Automattic\Jetpack_Boost\Contracts\Pluggable;
 use Automattic\Jetpack_Boost\Modules\Modules_Index;
@@ -21,7 +22,7 @@
 use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Filesystem_Utils;
 use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Logger;
 
-class Page_Cache implements Pluggable, Has_Deactivate, Has_Data_Sync, Optimization {
+class Page_Cache implements Pluggable, Has_Deactivate, Has_Data_Sync, Has_Submodules, Optimization {
 	/**
 	 * @var array - The errors that occurred when removing the cache.
 	 */
@@ -158,4 +159,10 @@ public static function is_available() {
 	public static function get_slug() {
 		return 'page_cache';
 	}
+
+	public function get_submodules() {
+		return array(
+			Cache_Preload::class,
+		);
+	}
 }
diff --git a/projects/plugins/boost/app/modules/optimizations/page-cache/class-cache-preload.php b/projects/plugins/boost/app/modules/optimizations/page-cache/class-cache-preload.php
new file mode 100644
index 0000000000000..76d28706d2293
--- /dev/null
+++ b/projects/plugins/boost/app/modules/optimizations/page-cache/class-cache-preload.php
@@ -0,0 +1,163 @@
+<?php
+
+namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache;
+
+use Automattic\Jetpack_Boost\Contracts\Is_Always_On;
+use Automattic\Jetpack_Boost\Contracts\Pluggable;
+use Automattic\Jetpack_Boost\Lib\Cornerstone\Cornerstone_Utils;
+use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Filesystem_Utils;
+use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Logger;
+
+/**
+ * Class Cache_Preload
+ *
+ * Handles the preloading of cache for pages, currently only for Cornerstone Pages.
+ * This module automagically preloads the cache after cache invalidation events, or when
+ * Cornerstone Pages are updated, to ensure that important pages always have a cache.
+ *
+ * @since $$next-version$$
+ * @package Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache
+ */
+class Cache_Preload implements Pluggable, Is_Always_On {
+
+	/**
+	 * @since $$next-version$$
+	 */
+	public function setup() {
+		add_action( 'update_option_jetpack_boost_ds_cornerstone_pages_list', array( $this, 'schedule_cornerstone_preload' ) );
+		add_action( 'jetpack_boost_preload_pages', array( $this, 'preload_pages' ) );
+
+		add_action( 'post_updated', array( $this, 'handle_post_update' ), 10, 1 );
+		add_action( 'jetpack_boost_invalidate_cache_success', array( $this, 'handle_cache_invalidation' ), 10, 2 );
+	}
+
+	/**
+	 * @since $$next-version$$
+	 */
+	public static function get_slug() {
+		return 'cache_preload';
+	}
+
+	/**
+	 * @since $$next-version$$
+	 */
+	public static function is_available() {
+		if ( defined( 'JETPACK_BOOST_ALPHA_FEATURES' ) ) {
+			return \JETPACK_BOOST_ALPHA_FEATURES === true;
+		}
+
+		return false;
+	}
+
+	/**
+	 * Schedule preload for all Cornerstone Pages.
+	 *
+	 * This method is triggered when the Cornerstone Pages list is updated,
+	 * ensuring all Cornerstone Pages have their cache preloaded.
+	 *
+	 * @since $$next-version$$
+	 * @return void
+	 */
+	public function schedule_cornerstone_preload() {
+		$this->schedule_preload_cronjob( Cornerstone_Utils::get_list() );
+	}
+
+	/**
+	 * Schedules the preload cronjob, if not already scheduled
+	 * to execute within 10 minutes with the same arguments, per wp_schedule_single_event.
+	 *
+	 * @since $$next-version$$
+	 * @param array $posts The posts to preload.
+	 * @return void
+	 */
+	public function schedule_preload_cronjob( array $posts ) {
+		wp_schedule_single_event( time(), 'jetpack_boost_preload_pages', array( $posts ) );
+	}
+
+	/**
+	 * Preloads the pages scheduled for preload.
+	 *
+	 * @since $$next-version$$
+	 * @param array $posts The posts to preload.
+	 * @return void
+	 */
+	public function preload_pages( $posts ) {
+		if ( empty( $posts ) ) {
+			return;
+		}
+
+		foreach ( $posts as $url ) {
+			$this->preload_page( $url );
+		}
+	}
+
+	/**
+	 * Preload a single page. Makes an HTTP request to the specified URL to generate a fresh cache entry.
+	 *
+	 * @since $$next-version$$
+	 * @param string $page The URL of the page to preload.
+	 * @return void
+	 */
+	private function preload_page( string $page ) {
+		// Add a cache-busting header to ensure our response is fresh.
+		$args = array(
+			'headers' => array(
+				'Cache-Control' => 'no-cache, no-store, must-revalidate, max-age=0',
+				'Pragma'        => 'no-cache',
+				'Expires'       => '0',
+			),
+		);
+
+		$response = wp_remote_get( $page, $args );
+
+		if ( is_wp_error( $response ) ) {
+			Logger::debug( 'Error preloading page: ' . $response->get_error_message() );
+			return;
+		}
+
+		$status_code = wp_remote_retrieve_response_code( $response );
+		if ( $status_code !== 200 ) {
+			Logger::debug( sprintf( 'Error preloading page %s: HTTP status code %d', $page, $status_code ) );
+		}
+	}
+
+	/**
+	 * Handle post updates to check if the post is a cornerstone page and schedule preload if needed.
+	 *
+	 * @since $$next-version$$
+	 * @param int $post_id The ID of the post being updated.
+	 * @return void
+	 */
+	public function handle_post_update( int $post_id ) {
+		if ( Cornerstone_Utils::is_cornerstone_page( $post_id ) ) {
+			$this->schedule_preload_cronjob( array( get_permalink( $post_id ) ) );
+		}
+	}
+
+	/**
+	 * Handle cache invalidation events to schedule preloading for affected pages.
+	 *
+	 * If cache for Cornerstone Pages is invalidated, this method schedules those pages
+	 * for preloading to ensure they have fresh cache.
+	 *
+	 * @since $$next-version$$
+	 * @param string $path The path that was invalidated.
+	 * @param string $type The type of invalidation that occurred (e.g., Filesystem_Utils::DELETE_ALL).
+	 * @return void
+	 */
+	public function handle_cache_invalidation( string $path, string $type ) {
+		if ( $type === Filesystem_Utils::DELETE_ALL ) {
+			// If the cache is invalidated for all files, schedule preload for all Cornerstone Pages.
+			$this->schedule_cornerstone_preload();
+			return;
+		}
+
+		// Otherwise identify if a Cornerstone Page cache file is being deleted and schedule preload that page if it is.
+		$cornerstone_pages = Cornerstone_Utils::get_list();
+		$cornerstone_pages = array_map( 'untrailingslashit', $cornerstone_pages );
+		// If the $path is in the Cornerstone Page list, add it to the preload list.
+		if ( in_array( untrailingslashit( $path ), $cornerstone_pages, true ) ) {
+			$this->schedule_preload_cronjob( array( $path ) );
+		}
+	}
+}
diff --git a/projects/plugins/boost/app/modules/optimizations/page-cache/pre-wordpress/storage/File_Storage.php b/projects/plugins/boost/app/modules/optimizations/page-cache/pre-wordpress/storage/File_Storage.php
index ae08d1b795341..89f5b27d9bf9f 100644
--- a/projects/plugins/boost/app/modules/optimizations/page-cache/pre-wordpress/storage/File_Storage.php
+++ b/projects/plugins/boost/app/modules/optimizations/page-cache/pre-wordpress/storage/File_Storage.php
@@ -156,19 +156,29 @@ private function sanitize_path( $path ) {
 	 *
 	 * @param string $path - The path to delete. File or directory.
 	 * @param string $type - defines what files/directories are deleted or rebuilt.
+	 * @return bool|Boost_Cache_Error True on success, error object on failure
 	 */
 	public function invalidate( $path, $type ) {
 		Logger::debug( "invalidate: $path $type" );
 		$normalized_path = $this->root_path . Boost_Cache_Utils::normalize_request_uri( $path );
 
+		$response = null;
 		if ( ! in_array( $type, array( Filesystem_Utils::DELETE_FILE, Filesystem_Utils::REBUILD_FILE ), true ) && is_dir( $normalized_path ) ) {
-			return Filesystem_Utils::walk_directory( $normalized_path, $type );
+			$response = Filesystem_Utils::walk_directory( $normalized_path, $type );
 		} elseif ( $type === Filesystem_Utils::DELETE_FILE && is_file( $normalized_path ) ) {
-			return Filesystem_Utils::delete_file( $normalized_path );
+			$response = Filesystem_Utils::delete_file( $normalized_path );
 		} elseif ( $type === Filesystem_Utils::REBUILD_FILE && is_file( $normalized_path ) ) {
-			return Filesystem_Utils::rebuild_file( $normalized_path );
-		} else {
+			$response = Filesystem_Utils::rebuild_file( $normalized_path );
+		}
+
+		if ( $response === null ) {
 			return new Boost_Cache_Error( 'no-cache-files-to-delete', 'No cache files to delete.' );
 		}
+
+		if ( $response === true ) {
+			do_action( 'jetpack_boost_invalidate_cache_success', $path, $type );
+		}
+
+		return $response;
 	}
 }
diff --git a/projects/plugins/boost/changelog/add-boost-cache-preloading-cornerstone-pages b/projects/plugins/boost/changelog/add-boost-cache-preloading-cornerstone-pages
new file mode 100644
index 0000000000000..77b017a2feaac
--- /dev/null
+++ b/projects/plugins/boost/changelog/add-boost-cache-preloading-cornerstone-pages
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Page Cache: Add Cache Preloading for Cornerstone Pages