` for the TTL aoo sends (600 s) or the admin override * - stale copy: transient `aoo_stale_` kept 7 days — served (header `X-Aoo-Cache: stale`) when aoo * is unreachable, and its ETag is used for a cheap conditional re-fetch (304 → renew) * - article cards (`embed/cards`, W4) share all of the above — index entry type 'cards' * - index option `aooshop_cache_index` lists every key ({type, slug, page, price?, sort?}) so purge from aoo / "ล้างแคช" can find them * - listing filters `price` / `sort` are part of the key only when set (unfiltered pages keep their 0.1 keys) — the * router whitelists them first, so junk values never create entries * - listing pages (AooShop_Router::LISTING_TYPES) are always asked with `parts=1` (0.4 per-type templates — * payload.parts {header, filters, grid, footer}); not part of the cache key since every 0.4 request is the same. * A copy cached before the upgrade simply has no parts → renderer falls back (grid = whole html) * - after a network/5xx failure aoo is skipped for 60 s (circuit) so visitors don't each wait 8 s * - public data API (W2, `embed/data/*` — class-data-api.php) shares all of the above — index entry type 'data' * (purged by every event via `aooshop_purge_list_types`) · `facets_unavailable` (503 before aoo's DB migration) * is a feature answer, not an outage → never trips the circuit * * @package AooShop */ defined( 'ABSPATH' ) || exit; class AooShop_Cache { // 0.7.0: was `aoo_page_` / `aoo_stale_` (too short a prefix for WordPress.org) — old rows expire on their own, // uninstall.php removes both spellings. const PREFIX = 'aooshop_page_'; const STALE_PREFIX = 'aooshop_stale_'; const INDEX_OPTION = 'aooshop_cache_index'; const DOWN_KEY = 'aooshop_api_down'; const REFRESH_HOOK = 'aooshop_refresh_page'; const STALE_TTL = 604800; // 7 days. const DEFAULT_TTL = 600; const DOWN_TTL = 60; const INDEX_MAX = 5000; /** Listing filters carried in page queries (whitelisted by AooShop_Router::filters()). */ const FILTER_KEYS = array( 'price', 'sort', 'on_sale' ); /** @var AooShop_Settings */ private $settings; /** @var AooShop_Api_Client */ private $api; public function __construct( AooShop_Settings $settings, AooShop_Api_Client $api ) { $this->settings = $settings; $this->api = $api; } public function hooks() { add_action( self::REFRESH_HOOK, array( $this, 'refresh' ), 10, 1 ); } /** Normalized page query → cache key (md5). */ public static function key( array $query ) { $norm = array( 'type' => isset( $query['type'] ) ? (string) $query['type'] : '', 'slug' => isset( $query['slug'] ) ? (string) $query['slug'] : '', 'page' => isset( $query['page'] ) ? max( 1, (int) $query['page'] ) : 1, 'q' => isset( $query['q'] ) ? (string) $query['q'] : '', ); foreach ( self::FILTER_KEYS as $k ) { if ( isset( $query[ $k ] ) && '' !== (string) $query[ $k ] ) { $norm[ $k ] = (string) $query[ $k ]; } } return md5( wp_json_encode( $norm ) ); } /** Normalized card args (AooShop_Cards::normalize()) → cache key — own namespace, never equal to a page key. */ public static function cards_key( array $args ) { return md5( wp_json_encode( array( 'cards' => $args ) ) ); } /** Data API endpoint + normalized args → cache key — own namespace. */ public static function data_key( $endpoint, array $args ) { return md5( wp_json_encode( array( 'data' => (string) $endpoint, 'args' => $args ) ) ); } /** * Public data API payload (`embed/data/{products,facets,collections,suggest}`) — args already whitelisted by * AooShop_Data_Api::normalize_*() (only scalar values; '' / null are not sent). * * @param string $endpoint 'data/products' | 'data/facets' | 'data/collections' | 'data/suggest'. * @param array $args Normalized args. * @param bool $force Skip the fresh copy (refresh event). * @return array{payload:array, source:string}|WP_Error */ public function get_data( $endpoint, array $args, $force = false ) { return $this->remember( self::data_key( $endpoint, $args ), $endpoint, $args, array( 'type' => 'data', 'slug' => '', 'endpoint' => (string) $endpoint, 'args' => $args ), 'is_array', __( 'Incomplete data from AOO.', 'aoo-shop' ), $force ); } /** * Page payload for a route query. * * @param array $query {type, slug?, page?, q?}. * @param bool $force Skip the fresh copy (refresh event). * @return array{payload:array, source:string}|WP_Error source: hit | miss | revalidated | stale */ public function get_page( array $query, $force = false ) { $api_query = array( 'type' => $query['type'], 'slug' => isset( $query['slug'] ) ? $query['slug'] : null, 'page' => ( isset( $query['page'] ) && (int) $query['page'] > 1 ) ? (int) $query['page'] : null, 'q' => isset( $query['q'] ) ? $query['q'] : null, ); foreach ( self::FILTER_KEYS as $k ) { $api_query[ $k ] = isset( $query[ $k ] ) && '' !== (string) $query[ $k ] ? (string) $query[ $k ] : null; } if ( in_array( (string) $query['type'], AooShop_Router::LISTING_TYPES, true ) ) { $api_query['parts'] = 1; } return $this->remember( self::key( $query ), 'page', $api_query, $query, function ( $payload ) { return isset( $payload['status'], $payload['html'] ); }, __( 'Incomplete page data from AOO.', 'aoo-shop' ), $force ); } /** * Article product cards (`embed/cards`) — same fresh/stale/circuit rules as pages, same index (so "ล้างแคช" * and purge from aoo clear them: index entry type = 'cards'; W6 should purge every 'cards' entry on any * product change since a card list can hold any product). * * @param array $args Normalized args (AooShop_Cards::normalize() — api keys only). * @param bool $force Skip the fresh copy (refresh event). * @return array{payload:array, source:string}|WP_Error */ public function get_cards( array $args, $force = false ) { $api_query = $args; foreach ( array( 'ids', 'slugs' ) as $list ) { $api_query[ $list ] = ! empty( $args[ $list ] ) ? implode( ',', (array) $args[ $list ] ) : null; } return $this->remember( self::cards_key( $args ), 'cards', $api_query, array( 'type' => 'cards', 'slug' => '', 'args' => $args ), function ( $payload ) { return isset( $payload['html'] ) && is_string( $payload['html'] ) && isset( $payload['products'] ) && is_array( $payload['products'] ); }, __( 'Incomplete product card data from AOO.', 'aoo-shop' ), $force ); } /** * Fresh copy → conditional fetch (ETag of the stale copy) → stale fallback. * * @param string $key Cache key. * @param string $endpoint Embed endpoint. * @param array $api_query Query sent to aoo. * @param array $query What the index / refresh event remember ({type, slug, …}). * @param callable $valid fn(array $payload): bool. * @param string $invalid Error message for an invalid payload. * @param bool $force Skip the fresh copy. */ private function remember( $key, $endpoint, array $api_query, array $query, $valid, $invalid, $force ) { if ( ! $force ) { $fresh = get_transient( self::PREFIX . $key ); if ( is_array( $fresh ) && isset( $fresh['payload'] ) ) { return array( 'payload' => $fresh['payload'], 'source' => 'hit' ); } } $stale = get_transient( self::STALE_PREFIX . $key ); $stale = ( is_array( $stale ) && isset( $stale['payload'] ) ) ? $stale : null; if ( get_transient( self::DOWN_KEY ) ) { return $this->fallback( $key, $query, $stale, new WP_Error( 'aoo_down', __( 'AOO is not responding (requests paused for now).', 'aoo-shop' ) ) ); } $res = $this->api->get( $endpoint, $api_query, $stale ? $stale['etag'] : null ); if ( is_wp_error( $res ) ) { $data = $res->get_error_data(); // `facets_unavailable` = aoo is up but its DB lacks the facets RPC yet — not an outage. if ( is_array( $data ) && ! empty( $data['retryable'] ) && 'aoo_facets_unavailable' !== $res->get_error_code() ) { set_transient( self::DOWN_KEY, 1, self::DOWN_TTL ); } return $this->fallback( $key, $query, $stale, $res ); } if ( 304 === $res['code'] && $stale ) { $this->store( $key, $query, $stale['payload'], $res['etag'] ? $res['etag'] : $stale['etag'] ); return array( 'payload' => $stale['payload'], 'source' => 'revalidated' ); } $payload = is_array( $res['data'] ) ? $res['data'] : null; if ( ! $payload || ! call_user_func( $valid, $payload ) ) { return $this->fallback( $key, $query, $stale, new WP_Error( 'aoo_bad_payload', $invalid ) ); } $etag = $res['etag'] ? $res['etag'] : ( isset( $payload['cache']['etag'] ) ? (string) $payload['cache']['etag'] : '' ); $this->store( $key, $query, $payload, $etag ); return array( 'payload' => $payload, 'source' => 'miss' ); } /** Refresh event (scheduled when a stale copy was served). */ public function refresh( $query ) { if ( ! is_array( $query ) || empty( $query['type'] ) ) { return; } delete_transient( self::DOWN_KEY ); if ( 'cards' === $query['type'] ) { if ( isset( $query['args'] ) && is_array( $query['args'] ) ) { $this->get_cards( $query['args'], true ); } return; } if ( 'data' === $query['type'] ) { if ( ! empty( $query['endpoint'] ) && isset( $query['args'] ) && is_array( $query['args'] ) ) { $this->get_data( (string) $query['endpoint'], $query['args'], true ); } return; } $this->get_page( $query, true ); } private function fallback( $key, array $query, $stale, WP_Error $error ) { if ( $stale ) { if ( ! wp_next_scheduled( self::REFRESH_HOOK, array( $query ) ) ) { wp_schedule_single_event( time() + self::DOWN_TTL + 5, self::REFRESH_HOOK, array( $query ) ); } return array( 'payload' => $stale['payload'], 'source' => 'stale' ); } return $error; } private function ttl_for( array $payload ) { $ttl = isset( $payload['cache']['ttl'] ) ? (int) $payload['cache']['ttl'] : self::DEFAULT_TTL; return max( 60, $ttl > 0 ? $ttl : self::DEFAULT_TTL ); } private function store( $key, array $query, array $payload, $etag ) { $entry = array( 'payload' => $payload, 'etag' => (string) $etag, 'at' => time(), ); set_transient( self::PREFIX . $key, $entry, $this->ttl_for( $payload ) ); set_transient( self::STALE_PREFIX . $key, $entry, self::STALE_TTL ); $this->index_add( $key, $query ); } private function index_add( $key, array $query ) { $index = get_option( self::INDEX_OPTION, array() ); if ( ! is_array( $index ) ) { $index = array(); } if ( isset( $index[ $key ] ) ) { return; // Avoid an option write on every miss of a known page. } $entry = array( 'type' => (string) $query['type'], 'slug' => isset( $query['slug'] ) ? (string) $query['slug'] : '', 'page' => isset( $query['page'] ) ? max( 1, (int) $query['page'] ) : 1, // Purge → exact URL for page caches. ); foreach ( self::FILTER_KEYS as $k ) { if ( isset( $query[ $k ] ) && '' !== (string) $query[ $k ] ) { $entry[ $k ] = (string) $query[ $k ]; // Purge → URL with the same query string. } } $index[ $key ] = $entry; if ( count( $index ) > self::INDEX_MAX ) { $drop = array_slice( array_keys( $index ), 0, count( $index ) - self::INDEX_MAX ); foreach ( $drop as $old ) { $this->delete_key( $old ); unset( $index[ $old ] ); } } update_option( self::INDEX_OPTION, $index, false ); } private function delete_key( $key ) { delete_transient( self::PREFIX . $key ); delete_transient( self::STALE_PREFIX . $key ); } /** * Action `aooshop_after_purge( $count, $match, $targets )` — plus the pre-rename `aoo_after_purge` on sites with the * legacy-names flag (theme / mu-plugin code may listen to it). * * @param int $count Pages dropped. * @param callable|null $match Matcher used. * @param array|null $targets null = everything, else ['urls' => [...]]. */ public static function fire_after_purge( $count, $match, $targets ) { do_action( 'aooshop_after_purge', $count, $match, $targets ); } /** * Drop cached pages. `$keep_stale` = only expire the fresh copy (next view re-fetches but a stale copy * still covers an aoo outage) — what purge from aoo uses (class-purge.php). The admin button drops everything. * Fires `aooshop_after_purge( $count, $match, null )` (null targets = "everything" for page caches — * class-host-cache.php) unless `$notify` is false (class-purge.php fires it itself with exact URLs). * * @param callable|null $match fn(array $entry{type, slug, page?}): bool — null = all. * @param bool $keep_stale Keep the 7-day stale copies (and their index entries). * @param bool $notify Fire `aooshop_after_purge`. * @return int Number of pages dropped. */ public function purge( $match = null, $keep_stale = false, $notify = true ) { $index = get_option( self::INDEX_OPTION, array() ); if ( ! is_array( $index ) ) { return 0; } $count = 0; foreach ( $index as $key => $entry ) { if ( $match && ! call_user_func( $match, $entry ) ) { continue; } delete_transient( self::PREFIX . $key ); if ( ! $keep_stale ) { delete_transient( self::STALE_PREFIX . $key ); unset( $index[ $key ] ); } $count++; } update_option( self::INDEX_OPTION, $index, false ); delete_transient( self::DOWN_KEY ); if ( $notify ) { AooShop_Cache::fire_after_purge( $count, $match, null ); } return $count; } public function stats() { $index = get_option( self::INDEX_OPTION, array() ); return array( 'entries' => is_array( $index ) ? count( $index ) : 0, 'api_down' => (bool) get_transient( self::DOWN_KEY ), ); } }