22/**
33 * Calendar Cache Manager
44 *
5- * Centralizes all transient caching for calendar queries.
5+ * Centralizes all caching for calendar queries.
66 * Handles cache key generation, TTLs, and get/set operations.
77 *
8+ * Two cache layers:
9+ * 1. Bucket caches (dates, counts) — keyed without geo params, used by
10+ * EventQueryBuilder/Pagination internals. Stored as transients (which
11+ * Redis object cache backs anyway on persistent-cache hosts).
12+ * 2. Full-response cache (this is the calendar REST envelope itself,
13+ * pre-rendered HTML included). Keyed on the COMPLETE CalendarRequest
14+ * envelope INCLUDING geo params. Stored in wp_cache (dedicated group
15+ * `data-machine-calendar`) with transient fallback for non-persistent
16+ * cache environments.
17+ *
18+ * The full-response cache is the DOS mitigation: bot crawlers hammering
19+ * `?past=1&lat=...&lng=...&archive_taxonomy=venue&archive_term_id=...`
20+ * variants now hit one expensive query per cache window instead of one
21+ * per request. See Extra-Chill/data-machine-events#246.
22+ *
823 * @package DataMachineEvents\Blocks\Calendar\Cache
924 * @since 0.14.0
1025 */
1732
1833class CalendarCache {
1934
20- const PREFIX = 'data-machine_cal_ ' ;
21- const TTL_DATES = 30 * MINUTE_IN_SECONDS ;
22- const TTL_COUNTS = 30 * MINUTE_IN_SECONDS ;
35+ const PREFIX = 'data-machine_cal_ ' ;
36+ const FULL_PREFIX = 'data-machine_cal_full_ ' ;
37+ const GROUP = 'data-machine-calendar ' ;
38+ const TTL_DATES = 30 * MINUTE_IN_SECONDS ;
39+ const TTL_COUNTS = 30 * MINUTE_IN_SECONDS ;
40+ const TTL_FULL_UPCOMING = HOUR_IN_SECONDS ;
41+ const TTL_FULL_PAST = 24 * HOUR_IN_SECONDS ;
2342
2443 /**
25- * Get a cached value.
44+ * Get a cached value (transient-backed bucket cache) .
2645 *
2746 * @param string $key Full cache key.
2847 * @return mixed Cached value or false if not found.
@@ -32,7 +51,7 @@ public static function get( string $key ) {
3251 }
3352
3453 /**
35- * Set a cached value.
54+ * Set a cached value (transient-backed bucket cache) .
3655 *
3756 * @param string $key Full cache key.
3857 * @param mixed $value Value to cache.
@@ -44,7 +63,10 @@ public static function set( string $key, $value, int $ttl ): bool {
4463 }
4564
4665 /**
47- * Generate a cache key from query parameters.
66+ * Generate a cache key from query parameters (bucket caches).
67+ *
68+ * Does NOT include geo params — bucket caches operate on the broader
69+ * date/count slice and geo filtering happens downstream.
4870 *
4971 * @param array $params Query parameters.
5072 * @param string $prefix Key prefix (e.g. 'dates', 'counts').
@@ -66,4 +88,115 @@ public static function generate_key( array $params, string $prefix ): string {
6688
6789 return self ::PREFIX . $ prefix . '_ ' . md5 ( wp_json_encode ( $ key_data ) );
6890 }
91+
92+ /**
93+ * Generate a cache key for the full calendar REST response.
94+ *
95+ * Includes the COMPLETE CalendarRequest envelope so distinct geo
96+ * searches, scopes, paged windows, and archive contexts all get
97+ * isolated cache buckets. This is the key surface that issue #246
98+ * was missing — bot variants over `lat`/`lng`/`radius`/`archive_term_id`
99+ * collapsed onto one bucket and re-ran the query every time.
100+ *
101+ * @param array $envelope CalendarRequest::toAbilitiesArgs() output.
102+ * @return string Full cache key.
103+ */
104+ public static function generate_full_response_key ( array $ envelope ): string {
105+ $ key_data = array (
106+ 'paged ' => (int ) ( $ envelope ['paged ' ] ?? 1 ),
107+ 'past ' => (bool ) ( $ envelope ['past ' ] ?? false ),
108+ 'event_search ' => (string ) ( $ envelope ['event_search ' ] ?? '' ),
109+ 'date_start ' => (string ) ( $ envelope ['date_start ' ] ?? '' ),
110+ 'date_end ' => (string ) ( $ envelope ['date_end ' ] ?? '' ),
111+ 'scope ' => (string ) ( $ envelope ['scope ' ] ?? '' ),
112+ 'tax_filter ' => $ envelope ['tax_filter ' ] ?? array (),
113+ 'archive_taxonomy ' => (string ) ( $ envelope ['archive_taxonomy ' ] ?? '' ),
114+ 'archive_term_id ' => (int ) ( $ envelope ['archive_term_id ' ] ?? 0 ),
115+ 'geo_lat ' => (string ) ( $ envelope ['geo_lat ' ] ?? '' ),
116+ 'geo_lng ' => (string ) ( $ envelope ['geo_lng ' ] ?? '' ),
117+ 'geo_radius ' => (int ) ( $ envelope ['geo_radius ' ] ?? 0 ),
118+ 'geo_radius_unit ' => (string ) ( $ envelope ['geo_radius_unit ' ] ?? '' ),
119+ 'cutoff_hour ' => \DataMachineEvents \Blocks \Calendar \Grouping \LateNightCutoff::cutoff_hour (),
120+ );
121+
122+ return self ::FULL_PREFIX . md5 ( wp_json_encode ( $ key_data ) );
123+ }
124+
125+ /**
126+ * Get a cached full calendar REST response.
127+ *
128+ * Tries the object cache first (Redis/Memcached on persistent-cache
129+ * hosts), falls back to a transient. Returns false on miss so callers
130+ * can use the standard `false === $cached` check.
131+ *
132+ * @param string $key Full cache key from generate_full_response_key().
133+ * @return mixed Cached envelope array or false on miss.
134+ */
135+ public static function get_full_response ( string $ key ) {
136+ $ found = false ;
137+ $ cached = wp_cache_get ( $ key , self ::GROUP , false , $ found );
138+ if ( $ found && false !== $ cached ) {
139+ return $ cached ;
140+ }
141+
142+ // Transient fallback for non-persistent cache environments. On
143+ // Redis-backed hosts get_transient also routes through the object
144+ // cache, so this is functionally a no-op there but harmless.
145+ $ transient = get_transient ( $ key );
146+ if ( false !== $ transient ) {
147+ // Promote into the object cache so subsequent hits in this
148+ // process / cache window skip the transient SQL lookup.
149+ wp_cache_set ( $ key , $ transient , self ::GROUP , self ::ttl_for_envelope_default () );
150+ return $ transient ;
151+ }
152+
153+ return false ;
154+ }
155+
156+ /**
157+ * Set a cached full calendar REST response.
158+ *
159+ * Writes to BOTH the object cache and the transient store. The
160+ * transient store survives a `wp_cache_flush()` and acts as the
161+ * source of truth for non-persistent cache hosts; the object cache
162+ * is the fast path for persistent-cache hosts.
163+ *
164+ * @param string $key Full cache key from generate_full_response_key().
165+ * @param mixed $value Response envelope to cache.
166+ * @param int $ttl Time-to-live in seconds.
167+ * @return bool True on success.
168+ */
169+ public static function set_full_response ( string $ key , $ value , int $ ttl ): bool {
170+ wp_cache_set ( $ key , $ value , self ::GROUP , $ ttl );
171+ return set_transient ( $ key , $ value , $ ttl );
172+ }
173+
174+ /**
175+ * Resolve the appropriate full-response TTL for a request envelope.
176+ *
177+ * Past events are immutable — once a show happened, it happened.
178+ * Cache them aggressively (24h). Upcoming events change as new ones
179+ * are published, but `CacheInvalidator` busts the entire group on
180+ * any event save / taxonomy edit, so a 1h ceiling is just a safety
181+ * net for missed invalidation paths.
182+ *
183+ * @param array $envelope CalendarRequest::toAbilitiesArgs() output.
184+ * @return int TTL seconds.
185+ */
186+ public static function ttl_for_envelope ( array $ envelope ): int {
187+ $ past = ! empty ( $ envelope ['past ' ] );
188+ return $ past ? self ::TTL_FULL_PAST : self ::TTL_FULL_UPCOMING ;
189+ }
190+
191+ /**
192+ * Default TTL used when promoting a transient hit back into the
193+ * object cache. We don't know if the entry was past or upcoming at
194+ * promotion time, so we use the shorter (upcoming) TTL — better to
195+ * recompute one extra time than to extend an already-stale window.
196+ *
197+ * @return int TTL seconds.
198+ */
199+ private static function ttl_for_envelope_default (): int {
200+ return self ::TTL_FULL_UPCOMING ;
201+ }
69202}
0 commit comments