diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 29f32f14..708c9875 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -298,7 +298,7 @@ Fictioneer customizes WordPress by using as many standard action and filter hook | `restrict_manage_posts` | `fictioneer_add_chapter_story_filter_dropdown` | `save_post` | `fictioneer_refresh_chapters_schema`, `fictioneer_refresh_chapter_schema`, `fictioneer_refresh_collections_schema`, `fictioneer_refresh_post_caches`, `fictioneer_refresh_post_schema`, `fictioneer_refresh_recommendations_schema`, `fictioneer_refresh_recommendation_schema`, `fictioneer_refresh_stories_schema`, `fictioneer_refresh_story_schema`, `fictioneer_save_seo_metabox`, `fictioneer_save_word_count`, `fictioneer_track_chapter_and_story_updates`, `fictioneer_update_modified_date_on_story_for_chapter`, `fictioneer_update_shortcode_relationships`, `fictioneer_purge_transients`, `fictioneer_save_story_metaboxes`, `fictioneer_save_chapter_metaboxes`, `fictioneer_save_advanced_metabox`, `fictioneer_save_support_links_metabox`, `fictioneer_save_collection_metaboxes`, `fictioneer_save_recommendation_metaboxes`, `fictioneer_save_post_metaboxes`, `fictioneer_delete_cached_story_card_after_update` | `show_user_profile` | `fictioneer_custom_profile_fields` -| `shutdown` | `fictioneer_save_story_card_cache` +| `shutdown` | `fictioneer_save_story_card_cache`, `fictioneer_save_query_result_cache_registry` | `switch_theme` | `fictioneer_theme_deactivation` | `template_redirect` | `fictioneer_disable_date_archives`, `fictioneer_generate_epub`, `fictioneer_handle_oauth`, `fictioneer_logout`, `fictioneer_disable_attachment_pages`, `fictioneer_gate_unpublished_content`, `fictioneer_serve_sitemap`, `fictioneer_redirect_story` | `transition_post_status` | `fictioneer_log_story_chapter_status_changes`, `fictioneer_chapter_future_to_publish`, `fictioneer_post_story_to_discord`, `fictioneer_post_chapter_to_discord` diff --git a/INSTALLATION.md b/INSTALLATION.md index 74435091..975e7706 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -1597,9 +1597,11 @@ define( 'CONSTANT_NAME', value ); | FICTIONEER_QUERY_ID_ARRAY_LIMIT | integer | Maximum allowed IDs in 'post__{not}_in' query arguments. Default `1000`. | FICTIONEER_PATREON_EXPIRATION_TIME | integer | Time until a user’s Patreon data expires in seconds. Default `WEEK_IN_SECONDS`. | FICTIONEER_PARTIAL_CACHE_EXPIRATION_TIME | integer | Time until a cached partial expires in seconds. Default `4 * HOUR_IN_SECONDS`. -| FICTIONEER_CARD_CACHE_LIMIT | integer | Number of cards cached by the story card cache feature if enabled. Default `50`. +| FICTIONEER_CARD_CACHE_LIMIT | integer | Number of story cards cached if the feature is enabled. Default `50`. | FICTIONEER_CARD_CACHE_EXPIRATION_TIME | integer | Time until the whole story card cache expires in seconds. Default `HOUR_IN_SECONDS`. | FICTIONEER_STORY_CARD_CHAPTER_LIMIT | integer | Maximum number of chapters shown on story cards. Default 3. +| FICTIONEER_QUERY_RESULT_CACHE_THRESHOLD | integer | Count of a query result required to be eligible for caching. Default `75`. +| FICTIONEER_QUERY_RESULT_CACHE_LIMIT | integer | Number of query results cached if the feature is enabled. Default `50`. | FICTIONEER_CACHE_PURGE_ASSIST | boolean | Whether to call the cache purge assist function on post updates. Default `true`. | FICTIONEER_RELATIONSHIP_PURGE_ASSIST | boolean | Whether to purge related post caches. Default `true`. | FICTIONEER_CHAPTER_LIST_TRANSIENTS | boolean | Whether to cache chapter lists on story pages as Transients. Default `true`. diff --git a/functions.php b/functions.php index 5ea23a4e..a454230f 100644 --- a/functions.php +++ b/functions.php @@ -303,6 +303,16 @@ if ( ! defined( 'FICTIONEER_STORY_CARD_CHAPTER_LIMIT' ) ) { define( 'FICTIONEER_STORY_CARD_CHAPTER_LIMIT', 3 ); } +// Integer: Count of the query results required to be eligible for caching +if ( ! defined( 'FICTIONEER_QUERY_RESULT_CACHE_THRESHOLD' ) ) { + define( 'FICTIONEER_QUERY_RESULT_CACHE_THRESHOLD', 75 ); +} + +// Integer: Maximum query results cached as Transients +if ( ! defined( 'FICTIONEER_QUERY_RESULT_CACHE_LIMIT' ) ) { + define( 'FICTIONEER_QUERY_RESULT_CACHE_LIMIT', 50 ); +} + /* * Booleans */ diff --git a/includes/functions/_service-caching.php b/includes/functions/_service-caching.php index bc1ddb4f..352ca313 100644 --- a/includes/functions/_service-caching.php +++ b/includes/functions/_service-caching.php @@ -83,7 +83,7 @@ if ( ! defined( 'FICTIONEER_ENABLE_PARTIAL_CACHING' ) ) { ); } -// Boolean: Partial caching enabled? +// Boolean: Story card caching enabled? if ( ! defined( 'FICTIONEER_ENABLE_STORY_CARD_CACHING' ) ) { define( 'FICTIONEER_ENABLE_STORY_CARD_CACHING', @@ -91,6 +91,14 @@ if ( ! defined( 'FICTIONEER_ENABLE_STORY_CARD_CACHING' ) ) { ); } +// Boolean: Query result caching enabled= +if ( ! defined( 'FICTIONEER_ENABLE_QUERY_RESULT_CACHING' ) ) { + define( + 'FICTIONEER_ENABLE_QUERY_RESULT_CACHING', + get_option( 'fictioneer_enable_query_result_caching' ) && ! is_customize_preview() + ); +} + // ============================================================================= // ENABLE SHORTCODE TRANSIENTS? // ============================================================================= @@ -683,6 +691,9 @@ if ( ! function_exists( 'fictioneer_track_chapter_and_story_updates' ) ) { delete_post_meta( $story_id, 'fictioneer_story_data_collection' ); delete_post_meta( $story_id, 'fictioneer_story_chapter_index_html' ); + // Delete cached query results + fictioneer_delete_transients_like( "fictioneer_query_{$story_id}" ); + // Refresh cached HTML output delete_transient( 'fictioneer_story_chapter_list_html' . $story_id ); @@ -1326,3 +1337,185 @@ function fictioneer_get_cached_story_card( $key ) { // Return cached card return $card; } + +// ============================================================================= +// TRANSIENT QUERY RESULT CACHE +// ============================================================================= + +/** + * Returns the global registry array for cached query results + * + * Note: The registry is stored as auto-loaded options in the + * database and set up as global variable on request. It is + * only updated in the 'shutdown' action. + * + * @since 5.22.3 + * @global $fictioneer_query_result_registry + * + * @return array Array with Transient keys for cached query results. + */ + +function fictioneer_get_query_result_cache_registry() { + // Abort if... + if ( ! FICTIONEER_ENABLE_QUERY_RESULT_CACHING ) { + return []; + } + + // Initialize global cache variable + global $fictioneer_query_result_registry; + + if ( ! $fictioneer_query_result_registry ) { + $fictioneer_query_result_registry = get_option( 'fictioneer_query_cache_registry' ); + + if ( ! is_array( $fictioneer_query_result_registry ) ) { + $fictioneer_query_result_registry = []; + update_option( 'fictioneer_query_cache_registry', [] ); + } + } + + // Return array for good measure, but the global will do + return $fictioneer_query_result_registry; +} + +/** + * Adds query result to the global query result cache + * + * Note: Only query results that exceed a certain threshold (75) + * are stored in the cache and only the latest n items (50). + * + * @since 5.22.3 + * @global $fictioneer_query_result_registry + * @see FICTIONEER_QUERY_RESULT_CACHE_THRESHOLD + * @see FICTIONEER_QUERY_RESULT_CACHE_LIMIT + * + * @param string $key The cache key of the query. + * @param array $result The result of the query. + */ + +function fictioneer_cache_query_result( $key, $result ) { + // Abort if... + if ( ! FICTIONEER_ENABLE_QUERY_RESULT_CACHING ) { + return; + } + + if ( count( $result ) < FICTIONEER_QUERY_RESULT_CACHE_THRESHOLD ) { + return; + } + + // Initialize global cache variable + global $fictioneer_query_result_registry; + + if ( ! $fictioneer_query_result_registry ) { + $fictioneer_query_result_registry = fictioneer_get_query_result_cache_registry(); + } + + // Build key; must begin with fictioneer_query_{$post_id} for clearing purposes + $transient_key = "fictioneer_query_{$key}"; + + // Prepend result to stack + $fictioneer_query_result_registry = array_merge( array( $key => $transient_key ), $fictioneer_query_result_registry ); + + // Randomized expiration to avoid large sets of results to expire simultaneously + set_transient( $transient_key, $result, 8 * HOUR_IN_SECONDS + rand( 0, 4 * HOUR_IN_SECONDS ) ); + + // Only allow n items in the cache to avoid bloating the database + if ( count( $fictioneer_query_result_registry ) > FICTIONEER_QUERY_RESULT_CACHE_LIMIT ) { + array_pop( $fictioneer_query_result_registry ); // Drop oldest entry + } +} + +/** + * Adds query result to the global query result cache + * + * Note: Only query results that exceed a certain threshold (75) + * are stored in the cache and only the latest n items (50). + * + * @since 5.22.3 + * @global $fictioneer_query_result_registry + * + * @param string $key The cache key of the query. + * + * @return array|null Array of WP_Post objects or null if not cached or expired. + */ + +function fictioneer_get_cached_query_result( $key ) { + // Abort if... + if ( ! FICTIONEER_ENABLE_QUERY_RESULT_CACHING ) { + return null; + } + + // Initialize global cache variable + global $fictioneer_query_result_registry; + + if ( ! $fictioneer_query_result_registry ) { + $fictioneer_query_result_registry = fictioneer_get_query_result_cache_registry(); + } + + // Look for cached result... + if ( isset( $fictioneer_query_result_registry[ $key ] ) ) { + $transient = get_transient( $fictioneer_query_result_registry[ $key ] ); + + if ( is_array( $transient ) ) { + // Hit + return $transient; + } else { + // Miss + fictioneer_delete_cached_query_result( $key ); // Cleanup + } + } + + // Nothing cached or expired + return null; +} + +/** + * Deletes query result from the global query result cache + * + * @since 5.22.3 + * @global $fictioneer_query_result_registry + * + * @param string $key The cache key of the query. + */ + +function fictioneer_delete_cached_query_result( $key ) { + // Abort if... + if ( ! FICTIONEER_ENABLE_QUERY_RESULT_CACHING ) { + return; + } + + // Initialize global cache variable + global $fictioneer_query_result_registry; + + if ( ! $fictioneer_query_result_registry ) { + $fictioneer_query_result_registry = fictioneer_get_query_result_cache_registry(); + } + + // Remove entry (if present) + unset( $fictioneer_query_result_registry[ $key ] ); +} + +/** + * Saves the final global query result cache to the database + * + * @since 5.22.3 + * @global $fictioneer_query_result_registry + */ + +function fictioneer_save_query_result_cache_registry() { + // Abort if... + if ( ! FICTIONEER_ENABLE_QUERY_RESULT_CACHING ) { + return; + } + + // Initialize global cache variable + global $fictioneer_query_result_registry; + + // Anything to save? + if ( + ! is_null( $fictioneer_query_result_registry ) && + $fictioneer_query_result_registry !== get_option( 'fictioneer_query_cache_registry' ) + ) { + update_option( 'fictioneer_query_cache_registry', $fictioneer_query_result_registry ?: [] ); + } +} +add_action( 'shutdown', 'fictioneer_save_query_result_cache_registry' ); diff --git a/includes/functions/_utility.php b/includes/functions/_utility.php index fb178ab5..d1616b17 100644 --- a/includes/functions/_utility.php +++ b/includes/functions/_utility.php @@ -196,16 +196,20 @@ if ( ! function_exists( 'fictioneer_get_last_fiction_update' ) ) { /** * Returns array of chapter posts for a story * + * Note: Returns reduced WP_Post objects with several properties stripped, + * such as the content and excerpt. + * * @since 5.9.2 * @since 5.22.3 - Refactored. * * @param int $story_id ID of the story. * @param array $args Optional. Additional query arguments. + * @param bool $full Optional. Whether to not reduce the posts. Default false. * * @return array Array of chapter posts or empty. */ -function fictioneer_get_story_chapter_posts( $story_id, $args = [] ) { +function fictioneer_get_story_chapter_posts( $story_id, $args = [], $full = false ) { // Static variable cache static $cached_results = []; @@ -240,6 +244,13 @@ function fictioneer_get_story_chapter_posts( $story_id, $args = [] ) { return $cached_results[ $cache_key ]; } + // Query result cache registry hit? + $cached_query_result = fictioneer_get_cached_query_result( $cache_key ); + + if ( $cached_query_result ) { + return $cached_query_result; + } + // Batched or one go? if ( count( $chapter_ids ) <= FICTIONEER_QUERY_ID_ARRAY_LIMIT ) { $query_args['post__in'] = $chapter_ids ?: [0]; @@ -263,9 +274,34 @@ function fictioneer_get_story_chapter_posts( $story_id, $args = [] ) { return $chapter_positions[ $a->ID ] - $chapter_positions[ $b->ID ]; }); + // F-REF-1 + if ( ! $full ) { + foreach ( $chapter_posts as $post ) { + // Chapter contents are extremely large and this kind of + // query does not need them, but we leave a reference + // in case this causes issues in the future. + $post->post_content = 'F-REF-1'; + + unset( + $post->post_type, // We know this if fcn_chapter + $post->ping_status, // Unused + $post->to_ping, // Unused + $post->pinged, // Unused + $post->menu_order, // Unused + $post->post_mime_type, // Unused + $post->post_parent, // Unused + $post->post_content_filtered, // Unused here + $post->guid, // Unused here + $post->post_excerpt // Unused here + ); + } + } + // Cache for subsequent calls $cached_results[ $cache_key ] = $chapter_posts; + fictioneer_cache_query_result( $cache_key, $chapter_posts ); + // Return chapters selected in story return $chapter_posts; } @@ -2564,7 +2600,7 @@ if ( ! function_exists( 'fictioneer_multi_save_guard' ) ) { function fictioneer_multi_save_guard( $post_id ) { if ( - ( defined('REST_REQUEST') && REST_REQUEST ) || + ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) || get_post_status( $post_id ) === 'auto-draft' diff --git a/includes/functions/settings/_register_settings.php b/includes/functions/settings/_register_settings.php index 1a255934..05408aa7 100644 --- a/includes/functions/settings/_register_settings.php +++ b/includes/functions/settings/_register_settings.php @@ -683,6 +683,12 @@ define( 'FICTIONEER_OPTIONS', array( 'group' => 'fictioneer-settings-general-group', 'sanitize_callback' => 'fictioneer_sanitize_checkbox', 'default' => 0 + ), + 'fictioneer_enable_query_result_caching' => array( + 'name' => 'fictioneer_enable_query_result_caching', + 'group' => 'fictioneer-settings-general-group', + 'sanitize_callback' => 'fictioneer_sanitize_checkbox', + 'default' => 0 ) ), 'integers' => array( @@ -1148,6 +1154,7 @@ function fictioneer_get_option_label( $option ) { 'fictioneer_disable_anti_flicker' => __( 'Disable anti-flicker script', 'fictioneer' ), 'fictioneer_hide_categories' => __( 'Hide categories on posts', 'fictioneer' ), 'fictioneer_enable_story_card_caching' => __( 'Enable caching of story cards', 'fictioneer' ), + 'fictioneer_enable_query_result_caching' => __( 'Enable caching of large query results', 'fictioneer' ), ); } diff --git a/includes/functions/settings/_settings_actions.php b/includes/functions/settings/_settings_actions.php index fc50d7cb..51552179 100644 --- a/includes/functions/settings/_settings_actions.php +++ b/includes/functions/settings/_settings_actions.php @@ -487,6 +487,9 @@ function fictioneer_purge_theme_caches() { // Cache busting string fictioneer_regenerate_cache_bust(); + // Query result cache registry + delete_option( 'fictioneer_query_cache_registry' ); + // Log fictioneer_log( __( 'Purged theme caches.', 'fictioneer' ) ); } diff --git a/includes/functions/settings/_settings_page_general.php b/includes/functions/settings/_settings_page_general.php index 8b5c6388..35769309 100644 --- a/includes/functions/settings/_settings_page_general.php +++ b/includes/functions/settings/_settings_page_general.php @@ -748,6 +748,21 @@ ?> +
+ Queries that return many results are resource-intensive and can slow down your site, especially for stories with hundreds of chapters. This feature mitigates the slowdown by caching the results of the largest queries in the database, typically resulting in faster loading times.

You can use the FICTIONEER_QUERY_RESULT_CACHE_LIMIT constant to change the number of cached results (default is 50) and the FICTIONEER_QUERY_RESULT_CACHE_THRESHOLD constant to change what constitutes as large result (default is 75). Be aware that increasing these numbers will result in higher RAM consumption.

', 'fictioneer' ) + ); + ?> +
+