From f49b89191b4c8126b435257da0dee7fa16c445c4 Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Tue, 18 Feb 2025 13:36:36 +0000 Subject: [PATCH 01/12] Add smart lifetime system --- README.md | 40 ++++++++++++++++--- inc/namespace.php | 100 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 133 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 4d21057..57062c6 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,39 @@ -# Longcache +# Smartcache -Longcache will set Cache-Control headers for CDNs / reverse proxies to cache WordPress pages for a long period of times (days+). As such this plugin also handles invalidations on content updates, to FLUSH the CDN / reverse proxy on page changes. +Smartcache integrates with Batcache to smartly tune the cache lifetime for your content. -This is a work-in-progress. -## Todo +## Behaviour -- Delete post / transition should trigger invalidate. +By default, caching plugins like Batcache will cache for a static 5 minutes for all content. + +Smartcache dynamically tunes this cache lifetime to match the type of content. It breaks content into three buckets: frequently updated, regular content, and infrequently updated content. + +Frequently updated content is cached for 5 minutes. This content is: + +* The "home" page - this is the page showing the list of posts, not necessarily the "front page" for sites using a static home page. +* Content (posts, pages, etc) published in the past 24 hours + +Regular content is cached for 6 hours. This content is: + +* Most archive pages (including categories, tags, author pages) +* Date archive pages, except the current one (today/current month/current year) +* Search pages + +Infrequently updated content is cached for 14 days. This content is: + +* Pages (except those published recently) +* Date archive pages which aren't the current one +* 404 pages + + +## Overriding behaviour + +Smartcache has a variety of filters available. These include: + +* `smartcache.old_threshold` - Filter how long a post must be published before it's considered old (infrequently updated). Default is 7 days. +* `smartcache.is_old_post` - Filter whether a specific post is considered old (infrequently updated). (Default true for posts older than the old threshold.) +* `smartcache.is_new_post` - Filter whether a specific post is considered new (frequently updated). (Default true if published in previous 24 hours.) +* `smartcache.max-age` - Filter the maximum lifetime for the current page directly. +* `smartcache.should_cache` - Filter whether a page should be cached at all. (Only affects whether Smartcache generates a header, but may be overridden by other behaviour or by the cache itself.) +* `smartcache.urls_to_invalidate_for_post` - Filter which URLs to invalidate for a given post. diff --git a/inc/namespace.php b/inc/namespace.php index cd98f73..bfa0d76 100644 --- a/inc/namespace.php +++ b/inc/namespace.php @@ -7,6 +7,27 @@ use WP_CLI; use WP_Post; +/** + * Short lifetime, used by frequently updated content. + * + * (5 minutes.) + */ +const LIFETIME_SHORT = 300; + +/** + * Medium lifetime, used by most regular content. + * + * (6 hours.) + */ +const LIFETIME_MEDIUM = 21600; + +/** + * Long lifetime, used by rarely updated or old content. + * + * (14 days.) + */ +const LIFETIME_LONG = 1209600; + /** * Bootstrap function to set up the plugin. * @@ -46,6 +67,82 @@ function should_cache_response() : bool { return apply_filters( 'smartcache.should_cache', $should_cache ); } +/** + * Check if the given post is "old". + * + * Old content is unlikely to change frequently. + */ +function is_old_content( WP_Post $post ) { + $old_threshold = apply_filters( 'smartcache.old_threshold', 7 * DAY_IN_SECONDS ); + return apply_filters( 'smartcache.is_old_post', $post->post_date_gmt < ( time() - $old_threshold ) ); +} + +function is_new_content( WP_Post $post ) { + return apply_filters( 'smartcache.is_new_post', $post->post_date_gmt > ( time() - DAY_IN_SECONDS ) ); +} + +/** + * Get the default lifetime for the current page. + * + * Determines an appropriate lifetime based on the age and type of the content. + * + * This can be overridden by setting a different max age manually. + * + * @return int One of LIFETIME_SHORT, LIFETIME_MEDIUM, or LIFETIME_LONG. + */ +function get_default_lifetime() : int { + // The home (i.e. post list page) is likely to change more frequently, + // and feed readers should always receive fresh content. + if ( is_home() || is_feed() ) { + return LIFETIME_SHORT; + } + + // Single content depends on how old the content is. + if ( is_singular() ) { + $post = get_queried_object(); + + // If the post was published today, cache it for a shorter time. + // This accounts for fixes to the content, new comments, etc. + if ( is_new_content( $post ) ) { + return LIFETIME_SHORT; + } + + // If the post is older than 7 days, cache it for longer. + // Also, pages are likely to change less frequently. + if ( is_old_content( $post ) || is_page() ) { + return LIFETIME_LONG; + } + + return LIFETIME_MEDIUM; + } + + // Date-based archives won't change after the period is over. + if ( is_date() ) { + $is_current = $is_current = get_query_var( 'year' ) === date( 'Y' ); + if ( is_month() || is_day() ) { + $is_current = $is_current && get_query_var( 'monthnum' ) === date( 'm' ); + } + if ( is_day() ) { + $is_current = $is_current && get_query_var( 'day' ) === date( 'd' ); + } + + return $is_current ? LIFETIME_MEDIUM : LIFETIME_LONG; + } + + // 404 pages never change, except on publication. + if ( is_404() ) { + return LIFETIME_LONG; + } + + // Other archive pages are likely to change less frequently. + if ( is_archive() || is_search() ) { + return LIFETIME_MEDIUM; + } + + // Default to medium lifetime for other pages. + return LIFETIME_MEDIUM; +} + /** * Set the cache TTL depending on the curernt global scope. * @@ -57,7 +154,7 @@ function set_cache_ttl() : void { } global $batcache; - $max_age = absint( apply_filters( 'smartcache.max-age', DAY_IN_SECONDS * 14 ) ); // 14 days by default. + $max_age = absint( apply_filters( 'smartcache.max-age', get_default_lifetime() ) ); if ( ! $batcache || ! is_object( $batcache ) ) { header( 'Cache-Control: s-maxage=' . $max_age . ', must-revalidate' ); } else { @@ -177,7 +274,6 @@ function on_transition_post_status( string $new_status, string $old_status, WP_P return; } - queue_invalidate_urls( get_urls_to_invalidate_for_post( $post->ID ) ); } From 0b67e4ffdb222237fe31fd090c9960cc7574a359 Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Tue, 18 Feb 2025 13:46:07 +0000 Subject: [PATCH 02/12] Skip invalidation for new content This content naturally expires within a few minutes anyway, so should not be manually invalidated. This prevents a flood of invalidations when content is first created, which also tends to happen at the highest traffic time. --- README.md | 1 + inc/namespace.php | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/README.md b/README.md index 57062c6..1677e49 100644 --- a/README.md +++ b/README.md @@ -37,3 +37,4 @@ Smartcache has a variety of filters available. These include: * `smartcache.max-age` - Filter the maximum lifetime for the current page directly. * `smartcache.should_cache` - Filter whether a page should be cached at all. (Only affects whether Smartcache generates a header, but may be overridden by other behaviour or by the cache itself.) * `smartcache.urls_to_invalidate_for_post` - Filter which URLs to invalidate for a given post. +* `smartcache.should_invalidate` - Should we invalidate URLs for this post? (Default true, false for new content as it will expire naturally quickly.) diff --git a/inc/namespace.php b/inc/namespace.php index bfa0d76..7aed448 100644 --- a/inc/namespace.php +++ b/inc/namespace.php @@ -274,6 +274,12 @@ function on_transition_post_status( string $new_status, string $old_status, WP_P return; } + // Is this a new post? If so, don't send any invalidations. + $skip_invalidation = apply_filters( 'smartcache.should_invalidate', ! is_new_content( $post ), $post ); + if ( $skip_invalidation ) { + return; + } + queue_invalidate_urls( get_urls_to_invalidate_for_post( $post->ID ) ); } From bd3aee27b58ee2a1d730dd56e979c8acdb8d451f Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Tue, 18 Feb 2025 16:10:00 +0000 Subject: [PATCH 03/12] Add extra docs --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 1677e49..4e741a9 100644 --- a/README.md +++ b/README.md @@ -16,16 +16,20 @@ Frequently updated content is cached for 5 minutes. This content is: Regular content is cached for 6 hours. This content is: +* Content published in the past 30 days (except last 24 hours as above) * Most archive pages (including categories, tags, author pages) * Date archive pages, except the current one (today/current month/current year) * Search pages Infrequently updated content is cached for 14 days. This content is: +* Content published more than 30 days ago * Pages (except those published recently) * Date archive pages which aren't the current one * 404 pages +For infrequently updated content, the cache is manually invalidated when the content is updated. + ## Overriding behaviour From 6f86220640ef91459188ce9cc020172eeae9f9c5 Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Tue, 18 Feb 2025 16:15:46 +0000 Subject: [PATCH 04/12] Ensure brand new publishes invalidate 404 cache --- README.md | 5 ++++- inc/namespace.php | 16 +++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4e741a9..ddafc16 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,10 @@ Infrequently updated content is cached for 14 days. This content is: * Date archive pages which aren't the current one * 404 pages -For infrequently updated content, the cache is manually invalidated when the content is updated. +Smartcache forces cache invalidation on the CDN in the following cases: + +* Publishing a new piece of content (to invalidate the 404) +* Updating an infrequently updated piece of content ## Overriding behaviour diff --git a/inc/namespace.php b/inc/namespace.php index 7aed448..001cfcd 100644 --- a/inc/namespace.php +++ b/inc/namespace.php @@ -274,9 +274,19 @@ function on_transition_post_status( string $new_status, string $old_status, WP_P return; } - // Is this a new post? If so, don't send any invalidations. - $skip_invalidation = apply_filters( 'smartcache.should_invalidate', ! is_new_content( $post ), $post ); - if ( $skip_invalidation ) { + // If we're *just* publishing the post, ensure it invalidates. + // + // For other new content changes, skip invalidation to avoid rush of traffic during + // high-traffic events. + // + // For old content, invalidate it. + if ( is_new_content( $post ) ) { + $should_invalidate = ( $new_status !== $old_status ); + } else { + $should_invalidate = true; + } + $should_invalidate = apply_filters( 'smartcache.should_invalidate', $should_invalidate, $post ); + if ( ! $should_invalidate ) { return; } From 4567b5e3a5ddbfef18ebd9290918950c814b8284 Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Tue, 18 Feb 2025 16:31:53 +0000 Subject: [PATCH 05/12] Add one-click button to invalidate whole cache --- inc/admin/namespace.php | 60 +++++++++++++++++++++++++++++++---------- 1 file changed, 46 insertions(+), 14 deletions(-) diff --git a/inc/admin/namespace.php b/inc/admin/namespace.php index bb22251..b09545a 100644 --- a/inc/admin/namespace.php +++ b/inc/admin/namespace.php @@ -44,20 +44,52 @@ function render_settings_page() : void { ?> -

-
- -

- Use * as a wildcard, wildcards can only be at the end of a URL. A maximum of absolute URLs or wildcard URLs can be issued per request. -

- -
+ + + + + + + + + +
+ Invalidate all URLs + +
+ + +
+ +

+ +

+
+ Invalidate URLs + +
+ +

+ Use * as a wildcard, wildcards can only be at the end of a URL. A maximum of absolute URLs or wildcard URLs can be issued per request. +

+ +
+

Log

Date: Tue, 18 Feb 2025 17:05:39 +0000 Subject: [PATCH 06/12] Implement quota recording system --- inc/namespace.php | 48 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/inc/namespace.php b/inc/namespace.php index 001cfcd..9d7b3b4 100644 --- a/inc/namespace.php +++ b/inc/namespace.php @@ -28,12 +28,19 @@ */ const LIFETIME_LONG = 1209600; +/** + * Global cache group for Smartcache. + */ +const CACHE_GROUP_GLOBAL = 'smartcache-global'; + /** * Bootstrap function to set up the plugin. * * @return void */ function bootstrap() : void { + wp_cache_add_global_groups( CACHE_GROUP_GLOBAL ); + add_action( 'template_redirect', __NAMESPACE__ . '\\set_cache_ttl' ); add_action( 'transition_post_status', __NAMESPACE__ . '\\on_transition_post_status', 10, 3 ); add_action( 'smartcache.invalidate_urls', __NAMESPACE__ . '\\on_cron_invalidate_urls' ); @@ -44,6 +51,46 @@ function bootstrap() : void { } } +/** + * Get the monthly invalidation quota. + * + * On Altis, this is always 1000. + * + * @return int + */ +function get_invalidation_quota() : int { + return apply_filters( 'smartcache.invalidation_quota', 1000 ); +} + +/** + * Get the quota usage for this month. + * + * @return int + */ +function get_invalidation_quota_usage() : int { + $month = gmdate( 'Y-m' ); + return (int) wp_cache_get( 'usage-' . $month, CACHE_GROUP_GLOBAL ) ?? 0; +} + +/** + * Increase the quota usage. + * + * This never gets reset, as we just use monthly keys. + * + * @param int $num Number to increment by. + * @return void + */ +function increment_invalidation_quota_usage( $num = 1 ) { + // If it doesn't exist, create it. + $month = gmdate( 'Y-m' ); + if ( ! wp_cache_get( 'usage-' . $month, CACHE_GROUP_GLOBAL ) ) { + wp_cache_set( 'usage-' . $month, 0, CACHE_GROUP_GLOBAL ); + } + + // Then, increment. (Using incr ensures resiliency against concurrency.) + wp_cache_incr( 'usage-' . $month, $num, CACHE_GROUP_GLOBAL ); +} + /** * Check if the current request should be cached. * @@ -188,6 +235,7 @@ function invalidate_urls( array $urls ) : bool { }, $urls ); try { + increment_invalidation_quota_usage( count( $urls ) ); $result = Cloud\purge_cdn_paths( $urls ); } catch ( Exception $e ) { foreach ( $urls as $url ) { From 9f4b72246498b81a6782b8b5a284f90d4fdca576 Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Tue, 18 Feb 2025 17:06:02 +0000 Subject: [PATCH 07/12] Display quota and warning --- inc/admin/namespace.php | 50 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/inc/admin/namespace.php b/inc/admin/namespace.php index b09545a..68fa21d 100644 --- a/inc/admin/namespace.php +++ b/inc/admin/namespace.php @@ -14,13 +14,13 @@ */ function bootstrap() : void { add_action( 'admin_menu', __NAMESPACE__ . '\\register_admin_page' ); + add_action( 'admin_init', __NAMESPACE__ . '\\register_settings' ); add_action( 'admin_init', __NAMESPACE__ . '\\check_on_invalidate_urls_submit' ); require_once ABSPATH . '/wp-admin/includes/class-wp-list-table.php'; require_once __DIR__ . '/class-log-list-table.php'; } - function register_admin_page() : void { add_submenu_page( 'options-general.php', @@ -32,6 +32,54 @@ function register_admin_page() : void { ); } +function register_settings() { + add_settings_section( + 'smartcache-quota', + __( 'Quota', 'smartcache' ), + __NAMESPACE__ . '\\render_quota_section', + 'smartcache' + ); +} + +function render_quota_section() { + $usage = Smartcache\get_invalidation_quota_usage(); + $quota = Smartcache\get_invalidation_quota(); + + $is_warning = $usage >= ( 0.8 * $quota ); + $is_full = $usage >= $quota; + $class = $is_warning ? 'warning' : ( $is_full ? 'error' : '' ); + ?> + + + + + +
+ Monthly quota usage + + + + + +

+ +

+
+ From 81c498b8e9b4b0e4860d0eb51b50f7d140e1f7d2 Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Tue, 18 Feb 2025 17:08:29 +0000 Subject: [PATCH 08/12] Add details on how to invalidate --- inc/admin/namespace.php | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/inc/admin/namespace.php b/inc/admin/namespace.php index 68fa21d..63f8a50 100644 --- a/inc/admin/namespace.php +++ b/inc/admin/namespace.php @@ -119,17 +119,27 @@ function render_settings_page() : void { - Invalidate URLs +
- + +

+ Specify URLs to invalidate, one per line. +

Use * as a wildcard, wildcards can only be at the end of a URL. A maximum of absolute URLs or wildcard URLs can be issued per request.

+

+ If you need to invalidate a lot of URLs, use a single broad wildcard instead of listing each URL. +

Date: Tue, 18 Feb 2025 17:08:40 +0000 Subject: [PATCH 09/12] Invert control in POST handler --- inc/admin/namespace.php | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/inc/admin/namespace.php b/inc/admin/namespace.php index 63f8a50..9347538 100644 --- a/inc/admin/namespace.php +++ b/inc/admin/namespace.php @@ -165,15 +165,21 @@ class="large-text code" * @return void */ function check_on_invalidate_urls_submit() { - if ( isset( $_POST['smartcache_urls'] ) && check_admin_referer( 'smartcache.invalidate-urls' ) ) { - $urls = array_filter( array_map( 'sanitize_text_field', array_map( 'trim', explode( "\n", $_POST['smartcache_urls'] ) ) ) ); + if ( ! isset( $_POST['smartcache_urls'] ) ) { + return; + } + + if ( ! check_admin_referer( 'smartcache.invalidate-urls' ) ) { + add_settings_error( 'logcache', 'invalidated', __( 'Could not validate your request (invalid nonce). Try again.'), 'success' ); + return; + } - $result = Smartcache\invalidate_urls( $urls ); + $urls = array_filter( array_map( 'sanitize_text_field', array_map( 'trim', explode( "\n", $_POST['smartcache_urls'] ) ) ) ); + $result = Smartcache\invalidate_urls( $urls ); - if ( $result === true ) { - add_settings_error( 'logcache', 'invalidated', __( 'Invalidate request successful.'), 'success' ); - } else { - add_settings_error( 'logcache', 'invalidated', __( 'There was a problem issueing the invalidation request.'), 'error' ); - } + if ( $result === true ) { + add_settings_error( 'logcache', 'invalidated', __( 'Invalidate request successful.'), 'success' ); + } else { + add_settings_error( 'logcache', 'invalidated', __( 'There was a problem issuing the invalidation request.'), 'error' ); } } From 89ef5ef4b5179775fc306164ce1ebc45155fe711 Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Tue, 18 Feb 2025 17:14:03 +0000 Subject: [PATCH 10/12] Disable invalidation UI if quota has been exceeded --- inc/admin/namespace.php | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/inc/admin/namespace.php b/inc/admin/namespace.php index 9347538..cc34a79 100644 --- a/inc/admin/namespace.php +++ b/inc/admin/namespace.php @@ -81,10 +81,18 @@ class="usage-meter " } function render_settings_page() : void { - settings_errors( 'smartcache' ); + $usage = Smartcache\get_invalidation_quota_usage(); + $quota = Smartcache\get_invalidation_quota(); + $exceeded_quota = $usage >= $quota; + if ( $exceeded_quota ) { + add_settings_error( 'smartcache', 'quota_exceeded', __( 'You have exceeded your invalidation quota for this month. Contact support.'), 'warning' ); + } + $submit_attr = $exceeded_quota ? 'disabled' : ''; + ?>

+ @@ -142,7 +156,13 @@ class="large-text code"

@@ -170,7 +190,7 @@ function check_on_invalidate_urls_submit() { } if ( ! check_admin_referer( 'smartcache.invalidate-urls' ) ) { - add_settings_error( 'logcache', 'invalidated', __( 'Could not validate your request (invalid nonce). Try again.'), 'success' ); + add_settings_error( 'smartcache', 'invalidated', __( 'Could not validate your request (invalid nonce). Try again.'), 'success' ); return; } @@ -178,8 +198,8 @@ function check_on_invalidate_urls_submit() { $result = Smartcache\invalidate_urls( $urls ); if ( $result === true ) { - add_settings_error( 'logcache', 'invalidated', __( 'Invalidate request successful.'), 'success' ); + add_settings_error( 'smartcache', 'invalidated', __( 'Invalidate request successful.'), 'success' ); } else { - add_settings_error( 'logcache', 'invalidated', __( 'There was a problem issuing the invalidation request.'), 'error' ); + add_settings_error( 'smartcache', 'invalidated', __( 'There was a problem issuing the invalidation request.'), 'error' ); } } From 6ae04662ccc42a72dc8bd44c5d164faf006ab0b8 Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Tue, 18 Feb 2025 17:35:45 +0000 Subject: [PATCH 11/12] Move Smartcache under tools menu --- inc/admin/namespace.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/inc/admin/namespace.php b/inc/admin/namespace.php index cc34a79..b70f6a3 100644 --- a/inc/admin/namespace.php +++ b/inc/admin/namespace.php @@ -23,7 +23,7 @@ function bootstrap() : void { function register_admin_page() : void { add_submenu_page( - 'options-general.php', + 'tools.php', _x( 'Smartcache', 'settings page title', 'smartcache' ), _x( 'Smartcache', 'settings menu title', 'smartcache' ), 'manage_options', From 01b1b5806dec7c67dbcff25498bfaef02702397e Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Tue, 18 Feb 2025 19:07:37 +0000 Subject: [PATCH 12/12] Update quota to match fair use policy --- inc/admin/namespace.php | 2 +- inc/namespace.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/inc/admin/namespace.php b/inc/admin/namespace.php index b70f6a3..545e63e 100644 --- a/inc/admin/namespace.php +++ b/inc/admin/namespace.php @@ -149,7 +149,7 @@ class="large-text code" Specify URLs to invalidate, one per line.

- Use * as a wildcard, wildcards can only be at the end of a URL. A maximum of absolute URLs or wildcard URLs can be issued per request. + Use * as a wildcard at the end of a URL. A maximum of absolute URLs or wildcard URLs can be issued per request.

If you need to invalidate a lot of URLs, use a single broad wildcard instead of listing each URL. diff --git a/inc/namespace.php b/inc/namespace.php index 9d7b3b4..37001ae 100644 --- a/inc/namespace.php +++ b/inc/namespace.php @@ -59,7 +59,7 @@ function bootstrap() : void { * @return int */ function get_invalidation_quota() : int { - return apply_filters( 'smartcache.invalidation_quota', 1000 ); + return apply_filters( 'smartcache.invalidation_quota', 10_000 ); } /**