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 @@ ?> +
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.