| 1 |
<?php |
| 2 |
if ( ! defined( 'ABSPATH' ) ) { |
| 3 |
exit; // Exit if accessed directly! |
| 4 |
} |
| 5 |
|
| 6 |
if ( ! class_exists( 'WPSC_Stats_Cache' ) ) : |
| 7 |
|
| 8 |
/** |
| 9 |
* Shared read-through cache for computed ticket statistics - used by both the dashboard |
| 10 |
* cards/widgets and the Reports section. |
| 11 |
* |
| 12 |
* Every expensive aggregate query is wrapped with remember(), keyed by the caller's own slug |
| 13 |
* plus whatever arguments affect its result (e.g. a date range). Instead of tracking down and |
| 14 |
* deleting each cached key by hand whenever a ticket changes, a single "generation" number is |
| 15 |
* bumped on any ticket event that could affect these numbers; that number is folded into every |
| 16 |
* cache key, so a bump instantly makes all previously cached keys unreachable without needing to |
| 17 |
* know what they were. On sites without a persistent object cache (Redis/Memcached), a transient |
| 18 |
* is really just two rows in wp_options, and WordPress only ever cleans those up lazily (on a |
| 19 |
* request for that exact key, or via its own non-guaranteed periodic sweep) - so an orphaned |
| 20 |
* prior-generation row can otherwise sit in wp_options indefinitely. To avoid that, every key this |
| 21 |
* class writes is recorded against the generation it belongs to, and bump_generation() explicitly |
| 22 |
* delete_transient()'s all of them before advancing the counter. |
| 23 |
* |
| 24 |
* The Reports section uses remember_report() instead: longer-lived entries that ignore the |
| 25 |
* generation and are only refreshed on expiry or when the viewer explicitly recalculates. |
| 26 |
*/ |
| 27 |
final class WPSC_Stats_Cache { |
| 28 |
|
| 29 |
/** |
| 30 |
* Option name holding the current cache generation number. |
| 31 |
* |
| 32 |
* @var string |
| 33 |
*/ |
| 34 |
const GENERATION_OPTION = 'wpsc_stats_cache_gen'; |
| 35 |
|
| 36 |
/** |
| 37 |
* Prefix for the option that tracks which transient keys were written under a given |
| 38 |
* generation, e.g. 'wpsc_stats_cache_keys_14'. Used to explicitly clean up that generation's |
| 39 |
* transients once it's superseded, instead of leaving them for WordPress to lazily expire. |
| 40 |
* |
| 41 |
* @var string |
| 42 |
*/ |
| 43 |
const KEYS_OPTION_PREFIX = 'wpsc_stats_cache_keys_'; |
| 44 |
|
| 45 |
/** |
| 46 |
* Safety-net TTL (seconds) applied to every cached entry. |
| 47 |
* |
| 48 |
* @var int |
| 49 |
*/ |
| 50 |
const DEFAULT_TTL = 300; // 5 * MINUTE_IN_SECONDS, kept literal so this file has no load-order dependency. |
| 51 |
|
| 52 |
/** |
| 53 |
* TTL (seconds) for Reports-section entries written by remember_report(). |
| 54 |
* |
| 55 |
* @var int |
| 56 |
*/ |
| 57 |
const REPORT_TTL = 43200; // 12 * HOUR_IN_SECONDS. |
| 58 |
|
| 59 |
/** |
| 60 |
* Option name holding the Reports-section cache version. Unlike the generation above it is |
| 61 |
* NOT bumped on ticket events - only by flush_reports() - so report entries survive until |
| 62 |
* their TTL runs out or the viewer explicitly recalculates. |
| 63 |
* |
| 64 |
* @var string |
| 65 |
*/ |
| 66 |
const REPORT_VERSION_OPTION = 'wpsc_stats_cache_report_ver'; |
| 67 |
|
| 68 |
/** |
| 69 |
* Request flag (POST) asking remember_report() to skip the cached value and recompute. |
| 70 |
* |
| 71 |
* @var string |
| 72 |
*/ |
| 73 |
const RECALCULATE_PARAM = 'wpsc_rp_recalculate'; |
| 74 |
|
| 75 |
/** |
| 76 |
* Response header carrying when the data served by this request was calculated (unix time). |
| 77 |
* The Reports UI reads it to show "last calculated" without every report's response shape |
| 78 |
* having to change. |
| 79 |
* |
| 80 |
* @var string |
| 81 |
*/ |
| 82 |
const COMPUTED_AT_HEADER = 'X-WPSC-Report-Computed-At'; |
| 83 |
|
| 84 |
/** |
| 85 |
* Oldest calculation time among the report entries served in this request. |
| 86 |
* |
| 87 |
* @var int|null |
| 88 |
*/ |
| 89 |
private static $report_computed_at = null; |
| 90 |
|
| 91 |
/** |
| 92 |
* Initialize this class |
| 93 |
*/ |
| 94 |
public static function init() { |
| 95 |
|
| 96 |
// Any of these events can change what a cached card/widget/report would show. Bumping the |
| 97 |
// generation on all of them is deliberately broad (a tag change also invalidates entries |
| 98 |
// that never look at tags) - over-invalidating once in a while is cheap; missing one of |
| 99 |
// these and showing stale numbers is the bug we are specifically trying to avoid. |
| 100 |
$events = array( |
| 101 |
'wpsc_create_new_ticket', |
| 102 |
'wpsc_delete_ticket', |
| 103 |
'wpsc_ticket_delete_permanently', |
| 104 |
'wpsc_ticket_archive', |
| 105 |
'wpsc_ticket_restore', |
| 106 |
'wpsc_change_ticket_status', |
| 107 |
'wpsc_change_assignee', |
| 108 |
'wpsc_change_ticket_category', |
| 109 |
'wpsc_change_ticket_priority', |
| 110 |
'wpsc_change_raised_by', |
| 111 |
'wpsc_change_tag', |
| 112 |
'wpsc_change_ticket_fields', // generic custom-field edits (single-select, checkbox, multi-select, radio-button, ...). |
| 113 |
'wpsc_change_agentonly_fields', // same, for agent-only custom fields. |
| 114 |
'wpsc_post_reply', |
| 115 |
'wpsc_submit_note', |
| 116 |
'wpsc_change_usergroup', // fired by the usergroup addon; harmless no-op if that addon isn't active. |
| 117 |
'wpsc_change_ticket_rating', // fired by the satisfaction-survey addon; harmless no-op if inactive. |
| 118 |
'wpsc_agent_role_update', // agent role capability changes. |
| 119 |
'after_set_add_agent', // agent added (note: fires once per bulk "add agent" submit, not once per agent). |
| 120 |
'wpsc_delete_agent', // agent removed/deactivated - also fires when its WP user account is deleted. |
| 121 |
'wpsc_agentgroup_created', // fired by the agentgroup addon; harmless no-op if inactive. |
| 122 |
'wpsc_agentgroup_updated', // same - agents/supervisors added or removed from an agentgroup. |
| 123 |
'wpsc_agentgroup_deleted', // same - agentgroup removed entirely. |
| 124 |
'wpsc_set_add_new_usergroup', // fired by the usergroup addon; harmless no-op if inactive. |
| 125 |
'wpsc_clone_usergroup', // same - a usergroup created by cloning an existing one. |
| 126 |
'wpsc_set_edit_usergroup', // same - covers members/supervisors added or removed (and any other edit). |
| 127 |
'wpsc_before_destroy_usergroup', // same - usergroup deleted entirely (explicitly, or automatically once its last member is gone). |
| 128 |
'delete_user', // a WP user's agent/usergroup membership can be cleaned up silently when their account is deleted. |
| 129 |
); |
| 130 |
|
| 131 |
foreach ( $events as $event ) { |
| 132 |
add_action( $event, array( __CLASS__, 'bump_generation' ) ); |
| 133 |
} |
| 134 |
} |
| 135 |
|
| 136 |
/** |
| 137 |
* Advance the cache generation, instantly invalidating every previously cached entry, and |
| 138 |
* explicitly delete the now-orphaned transients for the generation being retired so they |
| 139 |
* don't linger in wp_options waiting for WordPress's own lazy expiry. |
| 140 |
* |
| 141 |
* @return void |
| 142 |
*/ |
| 143 |
public static function bump_generation() { |
| 144 |
|
| 145 |
$retiring_generation = self::generation(); |
| 146 |
|
| 147 |
update_option( self::GENERATION_OPTION, $retiring_generation + 1, false ); |
| 148 |
|
| 149 |
self::flush_generation_keys( $retiring_generation ); |
| 150 |
} |
| 151 |
|
| 152 |
/** |
| 153 |
* Current cache generation number. |
| 154 |
* |
| 155 |
* @return int |
| 156 |
*/ |
| 157 |
private static function generation() { |
| 158 |
|
| 159 |
return (int) get_option( self::GENERATION_OPTION, 1 ); |
| 160 |
} |
| 161 |
|
| 162 |
/** |
| 163 |
* Record that $key was written under the current generation, so it can be explicitly |
| 164 |
* deleted once that generation is retired. |
| 165 |
* |
| 166 |
* @param string $key Transient key, as produced in remember(). |
| 167 |
* @return void |
| 168 |
*/ |
| 169 |
private static function track_key( $key ) { |
| 170 |
|
| 171 |
$option_name = self::KEYS_OPTION_PREFIX . self::generation(); |
| 172 |
$keys = get_option( $option_name, array() ); |
| 173 |
|
| 174 |
if ( ! in_array( $key, $keys, true ) ) { |
| 175 |
$keys[] = $key; |
| 176 |
update_option( $option_name, $keys, false ); |
| 177 |
} |
| 178 |
} |
| 179 |
|
| 180 |
/** |
| 181 |
* Delete every transient recorded against $generation, then discard the tracking option |
| 182 |
* itself - both the cached values and the bookkeeping that pointed at them are cleaned up |
| 183 |
* together, leaving nothing behind in wp_options for a retired generation. |
| 184 |
* |
| 185 |
* @param int $generation Generation number being retired. |
| 186 |
* @return void |
| 187 |
*/ |
| 188 |
private static function flush_generation_keys( $generation ) { |
| 189 |
|
| 190 |
$option_name = self::KEYS_OPTION_PREFIX . $generation; |
| 191 |
$keys = get_option( $option_name, array() ); |
| 192 |
|
| 193 |
foreach ( $keys as $key ) { |
| 194 |
delete_transient( $key ); |
| 195 |
} |
| 196 |
|
| 197 |
delete_option( $option_name ); |
| 198 |
} |
| 199 |
|
| 200 |
/** |
| 201 |
* Get a cached value, computing and caching it on a miss. |
| 202 |
* |
| 203 |
* @param string $slug Unique identifier for the card/widget/report, e.g. 'agent-workload'. |
| 204 |
* @param array $args Any inputs that affect the result, e.g. a date range. Two calls |
| 205 |
* with different $args for the same slug are cached separately. |
| 206 |
* @param callable $callback Computes the value on a cache miss. Called with no arguments. |
| 207 |
* @param boolean $per_viewer Pass true when the result differs per viewing agent (i.e. the |
| 208 |
* query uses WPSC_Current_User::get_tl_system_query()/get_atl_system_query() |
| 209 |
* or otherwise depends on who is looking). Left false, one cache |
| 210 |
* entry is shared by every viewer - only set this when the data |
| 211 |
* actually differs per viewer, otherwise this cache loses most of |
| 212 |
* its benefit (each viewer pays the full cost on their own). |
| 213 |
* @param integer $ttl Safety-net TTL in seconds, only relevant for changes that bypass |
| 214 |
* the hooks in init() (e.g. a direct DB import). |
| 215 |
* @return mixed |
| 216 |
*/ |
| 217 |
public static function remember( $slug, $args, $callback, $per_viewer = false, $ttl = self::DEFAULT_TTL ) { |
| 218 |
|
| 219 |
$key_parts = array( $slug, self::generation(), wp_json_encode( $args ) ); |
| 220 |
|
| 221 |
if ( $per_viewer ) { |
| 222 |
$key_parts[] = self::viewer_key_part(); |
| 223 |
} |
| 224 |
|
| 225 |
$key = 'wpsc_sc_' . md5( implode( '|', $key_parts ) ); |
| 226 |
|
| 227 |
$value = get_transient( $key ); |
| 228 |
if ( false === $value ) { |
| 229 |
$value = call_user_func( $callback ); |
| 230 |
set_transient( $key, $value, $ttl ); |
| 231 |
self::track_key( $key ); |
| 232 |
} |
| 233 |
|
| 234 |
return $value; |
| 235 |
} |
| 236 |
|
| 237 |
/** |
| 238 |
* Reports-section variant of remember(): cached for REPORT_TTL and deliberately NOT |
| 239 |
* invalidated by ticket events, so a report shows the same numbers until they expire or the |
| 240 |
* viewer hits "Recalculate" (which sends RECALCULATE_PARAM and forces a fresh computation that |
| 241 |
* overwrites the entry). The calculation time is stored with the value and reported back via |
| 242 |
* COMPUTED_AT_HEADER so the UI can tell the viewer how old the numbers are. |
| 243 |
* |
| 244 |
* Must be called before the handler prints anything, otherwise the header can't be sent. |
| 245 |
* |
| 246 |
* @param string $slug Unique identifier for the report, e.g. 'rp-ticket-statistics'. |
| 247 |
* @param array $args Any inputs that affect the result (date range, filters, ...). |
| 248 |
* @param callable $callback Computes the value on a cache miss. Called with no arguments. |
| 249 |
* @param boolean $per_viewer Same meaning as in remember(). |
| 250 |
* @return mixed |
| 251 |
*/ |
| 252 |
public static function remember_report( $slug, $args, $callback, $per_viewer = false ) { |
| 253 |
|
| 254 |
$key_parts = array( $slug, (int) get_option( self::REPORT_VERSION_OPTION, 1 ), wp_json_encode( $args ) ); |
| 255 |
|
| 256 |
if ( $per_viewer ) { |
| 257 |
$key_parts[] = self::viewer_key_part(); |
| 258 |
} |
| 259 |
|
| 260 |
$key = 'wpsc_rpc_' . md5( implode( '|', $key_parts ) ); |
| 261 |
|
| 262 |
// Only ever reached from report handlers that have already verified their nonce and the |
| 263 |
// viewer's 'view-reports' capability. |
| 264 |
$recalculate = ! empty( $_POST[ self::RECALCULATE_PARAM ] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing |
| 265 |
|
| 266 |
$entry = $recalculate ? false : get_transient( $key ); |
| 267 |
if ( ! is_array( $entry ) || ! array_key_exists( 'value', $entry ) ) { |
| 268 |
$entry = array( |
| 269 |
'value' => call_user_func( $callback ), |
| 270 |
'computed_at' => time(), |
| 271 |
); |
| 272 |
set_transient( $key, $entry, self::REPORT_TTL ); |
| 273 |
} |
| 274 |
|
| 275 |
self::$report_computed_at = null === self::$report_computed_at |
| 276 |
? $entry['computed_at'] |
| 277 |
: min( self::$report_computed_at, $entry['computed_at'] ); |
| 278 |
|
| 279 |
if ( ! headers_sent() ) { |
| 280 |
header( self::COMPUTED_AT_HEADER . ': ' . self::$report_computed_at ); |
| 281 |
} |
| 282 |
|
| 283 |
return $entry['value']; |
| 284 |
} |
| 285 |
|
| 286 |
/** |
| 287 |
* Make every Reports-section entry unreachable, for changes that bypass the ticket hooks but |
| 288 |
* alter report numbers (e.g. the bulk ticket-metrics recalculation). The orphaned transients |
| 289 |
* are left to expire (REPORT_TTL) and be swept by WordPress's daily expired-transient cleanup. |
| 290 |
* |
| 291 |
* @return void |
| 292 |
*/ |
| 293 |
public static function flush_reports() { |
| 294 |
|
| 295 |
update_option( self::REPORT_VERSION_OPTION, (int) get_option( self::REPORT_VERSION_OPTION, 1 ) + 1, false ); |
| 296 |
} |
| 297 |
|
| 298 |
/** |
| 299 |
* Cache key part identifying the current viewer, for per-viewer entries. |
| 300 |
* |
| 301 |
* @return string |
| 302 |
*/ |
| 303 |
private static function viewer_key_part() { |
| 304 |
|
| 305 |
$current_user = WPSC_Current_User::$current_user; |
| 306 |
return $current_user->is_agent ? 'agent_' . $current_user->agent->id : 'customer_' . $current_user->customer->id; |
| 307 |
} |
| 308 |
} |
| 309 |
endif; |
| 310 |
WPSC_Stats_Cache::init(); |
| 311 |
|