| @@ -36,8 +36,40 @@ | ||
| 36 | 36 | return $wpdb->prefix . 'easyfonts_decisions'; |
| 37 | 37 | } |
| 38 | 38 | |
| 39 | 39 | /** |
| 40 | + * Canonical route key for a URL: the path only, with the query string | |
| 41 | + * dropped. This is the single normalization used everywhere a page is | |
| 42 | + * identified (usage rows, beacon decisions, warm locks), so the server | |
| 43 | + * buffer and the browser beacon always agree on one key per page — and | |
| 44 | + * volatile params (cache-busters like efbust, the easyfonts_probe key, | |
| 45 | + * builder flags, tracking params) can never mint duplicate rows. | |
| 46 | + * | |
| 47 | + * @param string $url Absolute or relative URL. | |
| 48 | + * @return string Path beginning with '/', never empty. | |
| 49 | + */ | |
| 50 | + public static function route_for( string $url ): string { | |
| 51 | + $path = (string) wp_parse_url( $url, PHP_URL_PATH ); | |
| 52 | + | |
| 53 | + if ( '' === $path ) { | |
| 54 | + return '/'; | |
| 55 | + } | |
| 56 | + | |
| 57 | + // Guarantee a canonical leading slash — malformed input (e.g. a bare | |
| 58 | + // scheme string) must never mint a route outside the path namespace. | |
| 59 | + return '/' === $path[0] ? $path : '/' . $path; | |
| 60 | + } | |
| 61 | + | |
| 62 | + /** | |
| 63 | + * Canonical route key for the current request. | |
| 64 | + * | |
| 65 | + * @return string | |
| 66 | + */ | |
| 67 | + public static function current_route(): string { | |
| 68 | + return self::route_for( home_url( add_query_arg( array() ) ) ); | |
| 69 | + } | |
| 70 | + | |
| 71 | + /** | |
| 40 | 72 | * Record a set of font observations for one page in a single query each. |
| 41 | 73 | * |
| 42 | 74 | * @param string $page_url Page URL. |
| 43 | 75 | * @param int $page_id Post ID (0 if unknown). |
| @@ -45,11 +77,21 @@ | ||
| 45 | 77 | */ |
| 46 | 78 | public function record( string $page_url, int $page_id, array $records ): void { |
| 47 | 79 | global $wpdb; |
| 48 | 80 | |
| 49 | - $now = current_time( 'mysql' ); | |
| 81 | + if ( empty( $records ) ) { | |
| 82 | + return; | |
| 83 | + } | |
| 84 | + | |
| 85 | + $now = gmdate( 'Y-m-d H:i:s' ); // UTC — compared against UTC_TIMESTAMP() in gc(). | |
| 50 | 86 | $table = $this->usage_table(); |
| 51 | 87 | |
| 88 | + // Collapse to one row per (page, family, weight, style); merge any | |
| 89 | + // duplicates within this batch (strongest rendered/above_fold wins, | |
| 90 | + // beacon origin beats buffer) so a single multi-row upsert replaces what | |
| 91 | + // used to be one query per variant on every render. | |
| 92 | + $by_key = array(); | |
| 93 | + | |
| 52 | 94 | foreach ( $records as $rec ) { |
| 53 | 95 | $family = sanitize_text_field( $rec['family'] ?? '' ); |
| 54 | 96 | |
| 55 | 97 | if ( '' === $family ) { |
| @@ -55,40 +97,86 @@ | ||
| 55 | 97 | if ( '' === $family ) { |
| 56 | 98 | continue; |
| 57 | 99 | } |
| 58 | 100 | |
| 59 | - $weight = sanitize_text_field( $rec['weight'] ?? '400' ); | |
| 60 | - $style = sanitize_text_field( $rec['style'] ?? 'normal' ); | |
| 61 | - $origin = sanitize_text_field( $rec['origin'] ?? 'buffer' ); | |
| 101 | + $weight = sanitize_text_field( $rec['weight'] ?? '400' ); | |
| 102 | + $style = sanitize_text_field( $rec['style'] ?? 'normal' ); | |
| 103 | + $origin = sanitize_text_field( $rec['origin'] ?? 'buffer' ); | |
| 104 | + $key = sha1( $page_url . '|' . strtolower( $family ) . '|' . $weight . '|' . $style ); | |
| 105 | + | |
| 62 | 106 | $rendered = ! empty( $rec['rendered'] ) ? 1 : 0; |
| 63 | 107 | $above_fold = ! empty( $rec['above_fold'] ) ? 1 : 0; |
| 64 | - $key = sha1( $page_url . '|' . strtolower( $family ) . '|' . $weight . '|' . $style ); | |
| 65 | 108 | |
| 66 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery | |
| 67 | - $wpdb->query( | |
| 68 | - $wpdb->prepare( | |
| 69 | - "INSERT INTO {$table} | |
| 70 | - (usage_key, page_url, page_id, family, weight, style, origin, rendered, above_fold, hits, last_seen) | |
| 71 | - VALUES (%s,%s,%d,%s,%s,%s,%s,%d,%d,1,%s) | |
| 72 | - ON DUPLICATE KEY UPDATE | |
| 73 | - hits = hits + 1, | |
| 74 | - rendered = GREATEST(rendered, VALUES(rendered)), | |
| 75 | - above_fold = GREATEST(above_fold, VALUES(above_fold)), | |
| 76 | - origin = VALUES(origin), | |
| 77 | - last_seen = VALUES(last_seen)", | |
| 78 | - $key, | |
| 79 | - $page_url, | |
| 80 | - $page_id, | |
| 81 | - $family, | |
| 82 | - $weight, | |
| 83 | - $style, | |
| 84 | - $origin, | |
| 85 | - $rendered, | |
| 86 | - $above_fold, | |
| 87 | - $now | |
| 88 | - ) | |
| 109 | + if ( isset( $by_key[ $key ] ) ) { | |
| 110 | + $by_key[ $key ]['rendered'] = max( $by_key[ $key ]['rendered'], $rendered ); | |
| 111 | + $by_key[ $key ]['above_fold'] = max( $by_key[ $key ]['above_fold'], $above_fold ); | |
| 112 | + | |
| 113 | + if ( 'beacon' === $origin ) { | |
| 114 | + $by_key[ $key ]['origin'] = 'beacon'; | |
| 115 | + } | |
| 116 | + | |
| 117 | + continue; | |
| 118 | + } | |
| 119 | + | |
| 120 | + $by_key[ $key ] = array( | |
| 121 | + 'key' => $key, | |
| 122 | + 'family' => $family, | |
| 123 | + 'weight' => $weight, | |
| 124 | + 'style' => $style, | |
| 125 | + 'origin' => $origin, | |
| 126 | + 'rendered' => $rendered, | |
| 127 | + 'above_fold' => $above_fold, | |
| 89 | 128 | ); |
| 90 | 129 | } |
| 130 | + | |
| 131 | + if ( empty( $by_key ) ) { | |
| 132 | + return; | |
| 133 | + } | |
| 134 | + | |
| 135 | + $placeholders = array(); | |
| 136 | + $args = array(); | |
| 137 | + | |
| 138 | + foreach ( $by_key as $row ) { | |
| 139 | + // A beacon measurement that saw the variant loaded but not rendered | |
| 140 | + // counts one "miss" toward the scope-out corroboration threshold. | |
| 141 | + $miss = ( 'beacon' === $row['origin'] && 0 === $row['rendered'] ) ? 1 : 0; | |
| 142 | + | |
| 143 | + $placeholders[] = '(%s,%s,%d,%s,%s,%s,%s,%d,%d,1,%d,%s)'; | |
| 144 | + array_push( | |
| 145 | + $args, | |
| 146 | + $row['key'], | |
| 147 | + $page_url, | |
| 148 | + $page_id, | |
| 149 | + $row['family'], | |
| 150 | + $row['weight'], | |
| 151 | + $row['style'], | |
| 152 | + $row['origin'], | |
| 153 | + $row['rendered'], | |
| 154 | + $row['above_fold'], | |
| 155 | + $miss, | |
| 156 | + $now | |
| 157 | + ); | |
| 158 | + } | |
| 159 | + | |
| 160 | + $alias = $this->dup_alias(); | |
| 161 | + | |
| 162 | + $sql = "INSERT INTO {$table} | |
| 163 | + (usage_key, page_url, page_id, family, weight, style, origin, rendered, above_fold, hits, beacon_misses, last_seen) | |
| 164 | + VALUES " . implode( ',', $placeholders ) . "{$alias} | |
| 165 | + ON DUPLICATE KEY UPDATE | |
| 166 | + hits = hits + 1, | |
| 167 | + rendered = GREATEST(rendered, {$this->dup( 'rendered' )}), | |
| 168 | + above_fold = GREATEST(above_fold, {$this->dup( 'above_fold' )}), | |
| 169 | + origin = {$this->dup( 'origin' )}, | |
| 170 | + beacon_misses = CASE | |
| 171 | + WHEN {$this->dup( 'origin' )} = 'beacon' AND {$this->dup( 'rendered' )} = 1 THEN 0 | |
| 172 | + WHEN {$this->dup( 'origin' )} = 'beacon' AND {$this->dup( 'rendered' )} = 0 THEN beacon_misses + 1 | |
| 173 | + ELSE beacon_misses | |
| 174 | + END, | |
| 175 | + last_seen = {$this->dup( 'last_seen' )}"; | |
| 176 | + | |
| 177 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared | |
| 178 | + $wpdb->query( $wpdb->prepare( $sql, $args ) ); | |
| 91 | 179 | } |
| 92 | 180 | |
| 93 | 181 | /** |
| 94 | 182 | * Sets of what the beacon observed actually rendering, for used/unused |
| @@ -122,8 +210,211 @@ | ||
| 122 | 210 | ); |
| 123 | 211 | } |
| 124 | 212 | |
| 125 | 213 | /** |
| 214 | + * Per-route render profile in a SINGLE query: which families the beacon has | |
| 215 | + * seen rendering on the route, and which of those are above the fold. Both | |
| 216 | + * are family-level and based on the page's authorial font-family, so they're | |
| 217 | + * robust to weight/style differences and self-correcting. | |
| 218 | + * | |
| 219 | + * - `rendered` drives page scoping (which fonts load on this page). | |
| 220 | + * - `above_fold` the families above the fold. | |
| 221 | + * - `fold_faces` drives preload: for every family rendered here, the faces | |
| 222 | + * ('weight|style') the beacon saw above the fold — empty when the family | |
| 223 | + * renders only further down. (2.0.4) Preload used to take the whole | |
| 224 | + * family, so a family with many weights filled every slot with weights | |
| 225 | + * the page never shows. | |
| 226 | + * | |
| 227 | + * Empty `rendered` means the route hasn't been measured yet — callers must | |
| 228 | + * neither scope nor preload from no data. | |
| 229 | + * | |
| 230 | + * @param string $route Canonical route (path). | |
| 231 | + * @return array{rendered:array<string,bool>,above_fold:array<string,bool>,fold_faces:array<string,array<string,bool>>,scope_out:array<string,bool>,measured:bool} | |
| 232 | + */ | |
| 233 | + public function route_render_profile( string $route ): array { | |
| 234 | + global $wpdb; | |
| 235 | + | |
| 236 | + $out = array( | |
| 237 | + 'rendered' => array(), | |
| 238 | + 'above_fold' => array(), | |
| 239 | + 'fold_faces' => array(), | |
| 240 | + 'scope_out' => array(), | |
| 241 | + 'measured' => false, | |
| 242 | + ); | |
| 243 | + | |
| 244 | + if ( '' === $route ) { | |
| 245 | + return $out; | |
| 246 | + } | |
| 247 | + | |
| 248 | + // Per family on this route: was it ever rendered (used), was it above | |
| 249 | + // the fold, and has a real-browser beacon ever reported on it. The | |
| 250 | + // beacon reports both what rendered AND what loaded-but-never-rendered | |
| 251 | + // (recorded as rendered = 0, origin = 'beacon'), so a family is a | |
| 252 | + // confirmed scope-out ONLY when a beacon has actually measured it and | |
| 253 | + // never saw it render. A family that hasn't been beacon-measured yet | |
| 254 | + // (e.g. a font just added/replaced in the Customizer) is NEVER scoped | |
| 255 | + // out — it loads normally until a beacon has had a chance to judge it. | |
| 256 | + // Grouped per face (still one query); folded per family below. | |
| 257 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery | |
| 258 | + $rows = $wpdb->get_results( | |
| 259 | + $wpdb->prepare( | |
| 260 | + "SELECT family, weight, style, | |
| 261 | + MAX(rendered) AS rnd, | |
| 262 | + MAX(CASE WHEN rendered = 1 THEN above_fold ELSE 0 END) AS af, | |
| 263 | + MAX(CASE WHEN origin = 'beacon' THEN 1 ELSE 0 END) AS beaconed, | |
| 264 | + MAX(beacon_misses) AS misses | |
| 265 | + FROM {$this->usage_table()} | |
| 266 | + WHERE page_url = %s | |
| 267 | + GROUP BY family, weight, style", | |
| 268 | + $route | |
| 269 | + ), | |
| 270 | + ARRAY_A | |
| 271 | + ); | |
| 272 | + | |
| 273 | + $families = array(); | |
| 274 | + | |
| 275 | + foreach ( (array) $rows as $r ) { | |
| 276 | + $fam = strtolower( (string) $r['family'] ); | |
| 277 | + $agg = $families[ $fam ] ?? array( 'rnd' => 0, 'af' => 0, 'beaconed' => 0, 'misses' => 0, 'faces' => array() ); | |
| 278 | + | |
| 279 | + $agg['rnd'] = max( $agg['rnd'], (int) $r['rnd'] ); | |
| 280 | + $agg['af'] = max( $agg['af'], (int) $r['af'] ); | |
| 281 | + $agg['beaconed'] = max( $agg['beaconed'], (int) $r['beaconed'] ); | |
| 282 | + $agg['misses'] = max( $agg['misses'], (int) $r['misses'] ); | |
| 283 | + | |
| 284 | + if ( ! empty( $r['af'] ) ) { | |
| 285 | + $agg['faces'][ $r['weight'] . '|' . $r['style'] ] = true; | |
| 286 | + } | |
| 287 | + | |
| 288 | + $families[ $fam ] = $agg; | |
| 289 | + } | |
| 290 | + | |
| 291 | + /** | |
| 292 | + * Consistent beacon measurements required before a never-rendered | |
| 293 | + * family is scoped off a page. Corroboration means one bad or forged | |
| 294 | + * report can't remove a font (beacons are throttled hourly, so this is | |
| 295 | + * also a minimum observation window). | |
| 296 | + * | |
| 297 | + * @param int $min | |
| 298 | + */ | |
| 299 | + $min_misses = max( 1, (int) apply_filters( 'easyfonts_scope_out_min_measurements', 2 ) ); | |
| 300 | + | |
| 301 | + foreach ( $families as $fam => $r ) { | |
| 302 | + $rendered = ! empty( $r['rnd'] ); | |
| 303 | + $beaconed = ! empty( $r['beaconed'] ); | |
| 304 | + | |
| 305 | + if ( $beaconed ) { | |
| 306 | + $out['measured'] = true; | |
| 307 | + } | |
| 308 | + | |
| 309 | + if ( $rendered ) { | |
| 310 | + $out['rendered'][ $fam ] = true; | |
| 311 | + $out['fold_faces'][ $fam ] = $r['faces']; | |
| 312 | + | |
| 313 | + if ( ! empty( $r['af'] ) ) { | |
| 314 | + $out['above_fold'][ $fam ] = true; | |
| 315 | + } | |
| 316 | + } elseif ( $beaconed && (int) $r['misses'] >= $min_misses ) { | |
| 317 | + // Multiple beacons measured it and never saw it render → | |
| 318 | + // confirmed scope-out. | |
| 319 | + $out['scope_out'][ $fam ] = true; | |
| 320 | + } | |
| 321 | + } | |
| 322 | + | |
| 323 | + return $out; | |
| 324 | + } | |
| 325 | + | |
| 326 | + /** | |
| 327 | + * Cached set of families that are above the fold on EVERY route where they | |
| 328 | + * render — i.e. uniformly above-the-fold site-wide. These can be preloaded | |
| 329 | + * wherever they appear without consulting per-route data (and even before a | |
| 330 | + * given route has been measured), which is the cheap path: no per-route | |
| 331 | + * check needed for the fonts whose answer is the same everywhere. | |
| 332 | + * | |
| 333 | + * (2.0.4) With the faces ('weight|style') those families render in. A value | |
| 334 | + * of `true` is a family stored by 2.0.3 without its faces — it stands for | |
| 335 | + * the whole family until the next recompute (next beacon, or weekly GC). | |
| 336 | + * | |
| 337 | + * @return array<string,array<string,bool>|true> Lowercased family => faces. | |
| 338 | + */ | |
| 339 | + public function global_preload_faces(): array { | |
| 340 | + $list = get_option( 'easyfonts_global_preload', array() ); | |
| 341 | + $out = array(); | |
| 342 | + | |
| 343 | + foreach ( is_array( $list ) ? $list : array() as $key => $value ) { | |
| 344 | + if ( is_array( $value ) ) { | |
| 345 | + $out[ (string) $key ] = array_fill_keys( array_map( 'strval', $value ), true ); | |
| 346 | + } elseif ( is_int( $key ) ) { | |
| 347 | + $out[ strtolower( (string) $value ) ] = true; | |
| 348 | + } | |
| 349 | + } | |
| 350 | + | |
| 351 | + return $out; | |
| 352 | + } | |
| 353 | + | |
| 354 | + /** | |
| 355 | + * Recompute and cache the uniformly-above-the-fold family set. A family | |
| 356 | + * qualifies when every measured row for it (across all routes/variants where | |
| 357 | + * it renders) is above the fold — so its preload answer doesn't vary by page | |
| 358 | + * and we can skip per-route checks for it. Stored with the faces it renders | |
| 359 | + * in, so preload stays weight-level. Cheap: one grouped scan of the (capped) | |
| 360 | + * usage table; called when a beacon is accepted and from GC. | |
| 361 | + * | |
| 362 | + * @return void | |
| 363 | + */ | |
| 364 | + public function recompute_global_preload(): void { | |
| 365 | + global $wpdb; | |
| 366 | + | |
| 367 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared | |
| 368 | + $rows = $wpdb->get_results( | |
| 369 | + "SELECT family, weight, style, MIN(above_fold) AS af FROM {$this->usage_table()} | |
| 370 | + WHERE rendered = 1 | |
| 371 | + GROUP BY family, weight, style", | |
| 372 | + ARRAY_A | |
| 373 | + ); | |
| 374 | + | |
| 375 | + $faces = array(); | |
| 376 | + $below = array(); | |
| 377 | + | |
| 378 | + foreach ( (array) $rows as $r ) { | |
| 379 | + $fam = strtolower( (string) $r['family'] ); | |
| 380 | + | |
| 381 | + if ( empty( $r['af'] ) ) { | |
| 382 | + $below[ $fam ] = true; | |
| 383 | + } | |
| 384 | + | |
| 385 | + $faces[ $fam ][ $r['weight'] . '|' . $r['style'] ] = true; | |
| 386 | + } | |
| 387 | + | |
| 388 | + $global = array(); | |
| 389 | + | |
| 390 | + foreach ( $faces as $fam => $set ) { | |
| 391 | + if ( ! isset( $below[ $fam ] ) ) { | |
| 392 | + $global[ $fam ] = array_keys( $set ); | |
| 393 | + } | |
| 394 | + } | |
| 395 | + | |
| 396 | + update_option( 'easyfonts_global_preload', $global, true ); | |
| 397 | + } | |
| 398 | + | |
| 399 | + /** | |
| 400 | + * Forget the stored decision for a route (all devices), so the next beacon | |
| 401 | + * for it is accepted immediately instead of being throttled. Used by the | |
| 402 | + * "Optimize" action to force a fresh measurement on demand. | |
| 403 | + * | |
| 404 | + * @param string $route Canonical route (path). | |
| 405 | + * @return void | |
| 406 | + */ | |
| 407 | + public function forget_decision( string $route ): void { | |
| 408 | + global $wpdb; | |
| 409 | + | |
| 410 | + foreach ( array( 'mobile', 'desktop', 'any' ) as $dev ) { | |
| 411 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery | |
| 412 | + $wpdb->delete( $this->decisions_table(), array( 'route_key' => sha1( $route . '|' . $dev ) ) ); | |
| 413 | + } | |
| 414 | + } | |
| 415 | + | |
| 416 | + /** | |
| 126 | 417 | * Store beacon decisions (preload / unload) for a route + device. |
| 127 | 418 | * |
| 128 | 419 | * @param string $route Path (e.g. /pricing). |
| 129 | 420 | * @param string $device 'mobile'|'desktop'|'any'. |
| @@ -134,24 +425,26 @@ | ||
| 134 | 425 | global $wpdb; |
| 135 | 426 | |
| 136 | 427 | $key = sha1( $route . '|' . $device ); |
| 137 | 428 | |
| 429 | + $alias = $this->dup_alias(); | |
| 430 | + | |
| 138 | 431 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery |
| 139 | 432 | $wpdb->query( |
| 140 | 433 | $wpdb->prepare( |
| 141 | 434 | "INSERT INTO {$this->decisions_table()} |
| 142 | 435 | (route_key, route, device, preload, unload, updated_at) |
| 143 | - VALUES (%s,%s,%s,%s,%s,%s) | |
| 436 | + VALUES (%s,%s,%s,%s,%s,%s){$alias} | |
| 144 | 437 | ON DUPLICATE KEY UPDATE |
| 145 | - preload = VALUES(preload), | |
| 146 | - unload = VALUES(unload), | |
| 147 | - updated_at = VALUES(updated_at)", | |
| 438 | + preload = {$this->dup( 'preload' )}, | |
| 439 | + unload = {$this->dup( 'unload' )}, | |
| 440 | + updated_at = {$this->dup( 'updated_at' )}", | |
| 148 | 441 | $key, |
| 149 | 442 | $route, |
| 150 | 443 | $device, |
| 151 | 444 | wp_json_encode( array_values( $preload ) ), |
| 152 | 445 | wp_json_encode( array_values( $unload ) ), |
| 153 | - current_time( 'mysql' ) | |
| 446 | + gmdate( 'Y-m-d H:i:s' ) // UTC — parsed as UTC in has_fresh_decision(). | |
| 154 | 447 | ) |
| 155 | 448 | ); |
| 156 | 449 | } |
| 157 | 450 | |
| @@ -252,19 +545,20 @@ | ||
| 252 | 545 | $days = max( 1, $days ); |
| 253 | 546 | $usage = $this->usage_table(); |
| 254 | 547 | $dec = $this->decisions_table(); |
| 255 | 548 | |
| 256 | - // 1. Time-based prune (usage + decisions). | |
| 549 | + // 1. Time-based prune (usage + decisions). Timestamps are stored in | |
| 550 | + // UTC, so compare against UTC_TIMESTAMP() (not session-TZ NOW()). | |
| 257 | 551 | // phpcs:disable WordPress.DB.DirectDatabaseQuery |
| 258 | 552 | $wpdb->query( |
| 259 | 553 | $wpdb->prepare( |
| 260 | - "DELETE FROM {$usage} WHERE last_seen IS NOT NULL AND last_seen < ( NOW() - INTERVAL %d DAY )", | |
| 554 | + "DELETE FROM {$usage} WHERE last_seen IS NOT NULL AND last_seen < ( UTC_TIMESTAMP() - INTERVAL %d DAY )", | |
| 261 | 555 | $days |
| 262 | 556 | ) |
| 263 | 557 | ); |
| 264 | 558 | $wpdb->query( |
| 265 | 559 | $wpdb->prepare( |
| 266 | - "DELETE FROM {$dec} WHERE updated_at IS NOT NULL AND updated_at < ( NOW() - INTERVAL %d DAY )", | |
| 560 | + "DELETE FROM {$dec} WHERE updated_at IS NOT NULL AND updated_at < ( UTC_TIMESTAMP() - INTERVAL %d DAY )", | |
| 267 | 561 | $days |
| 268 | 562 | ) |
| 269 | 563 | ); |
| 270 | 564 | // phpcs:enable |
| @@ -269,8 +563,10 @@ | ||
| 269 | 563 | ); |
| 270 | 564 | // phpcs:enable |
| 271 | 565 | |
| 272 | 566 | // 2. Hard cap: if usage still exceeds the cap, drop the oldest surplus. |
| 567 | + // The id-bounded form is deterministic (replication-safe), unlike a bare | |
| 568 | + // DELETE ... ORDER BY ... LIMIT. | |
| 273 | 569 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared |
| 274 | 570 | $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$usage}" ); |
| 275 | 571 | |
| 276 | 572 | if ( $total > $max_rows ) { |
| @@ -278,13 +574,42 @@ | ||
| 278 | 574 | |
| 279 | 575 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery |
| 280 | 576 | $wpdb->query( |
| 281 | 577 | $wpdb->prepare( |
| 282 | - "DELETE FROM {$usage} ORDER BY last_seen ASC LIMIT %d", | |
| 578 | + "DELETE FROM {$usage} WHERE id IN ( | |
| 579 | + SELECT id FROM ( SELECT id FROM {$usage} ORDER BY last_seen ASC, id ASC LIMIT %d ) AS ef_tmp | |
| 580 | + )", | |
| 283 | 581 | $surplus |
| 284 | 582 | ) |
| 285 | 583 | ); |
| 286 | 584 | } |
| 585 | + | |
| 586 | + // 3. Hard cap on decisions rows too — beacon routes are public input, so | |
| 587 | + // without a cap the table could be grown without bound between prunes. | |
| 588 | + /** | |
| 589 | + * Hard cap on decision rows after time-based pruning. | |
| 590 | + * | |
| 591 | + * @param int $max | |
| 592 | + */ | |
| 593 | + $max_dec = max( 100, (int) apply_filters( 'easyfonts_gc_max_decision_rows', 2000 ) ); | |
| 594 | + | |
| 595 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared | |
| 596 | + $total_dec = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$dec}" ); | |
| 597 | + | |
| 598 | + if ( $total_dec > $max_dec ) { | |
| 599 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery | |
| 600 | + $wpdb->query( | |
| 601 | + $wpdb->prepare( | |
| 602 | + "DELETE FROM {$dec} WHERE id IN ( | |
| 603 | + SELECT id FROM ( SELECT id FROM {$dec} ORDER BY updated_at ASC, id ASC LIMIT %d ) AS ef_tmp | |
| 604 | + )", | |
| 605 | + $total_dec - $max_dec | |
| 606 | + ) | |
| 607 | + ); | |
| 608 | + } | |
| 609 | + | |
| 610 | + // Pruning may have changed which families are uniformly above the fold. | |
| 611 | + $this->recompute_global_preload(); | |
| 287 | 612 | } |
| 288 | 613 | |
| 289 | 614 | /** |
| 290 | 615 | * Has a fresh decision already been recorded for this route + device? |
| @@ -314,12 +639,57 @@ | ||
| 314 | 639 | if ( ! $updated ) { |
| 315 | 640 | return false; |
| 316 | 641 | } |
| 317 | 642 | |
| 318 | - return ( time() - strtotime( (string) $updated ) ) < ( max( 1, $fresh_hours ) * HOUR_IN_SECONDS ); | |
| 643 | + // Stored in UTC — parse it as UTC regardless of PHP's default timezone. | |
| 644 | + return ( time() - strtotime( (string) $updated . ' UTC' ) ) < ( max( 1, $fresh_hours ) * HOUR_IN_SECONDS ); | |
| 319 | 645 | } |
| 320 | 646 | |
| 321 | 647 | /** |
| 648 | + * ON DUPLICATE KEY UPDATE row reference, portable across servers. | |
| 649 | + * | |
| 650 | + * MySQL 8.0.20 deprecated VALUES() in ON DUPLICATE KEY UPDATE in favour of | |
| 651 | + * a row alias — which MariaDB does not support. Detect the server once and | |
| 652 | + * emit whichever syntax is native, so neither engine logs deprecation | |
| 653 | + * warnings nor breaks when VALUES() is eventually removed. | |
| 654 | + * | |
| 655 | + * @param string $column Column name. | |
| 656 | + * @return string SQL fragment referencing the incoming row's value. | |
| 657 | + */ | |
| 658 | + private function dup( string $column ): string { | |
| 659 | + return $this->use_row_alias() ? "ef_new.{$column}" : "VALUES({$column})"; | |
| 660 | + } | |
| 661 | + | |
| 662 | + /** | |
| 663 | + * Row-alias suffix for the INSERT (empty when using VALUES()). | |
| 664 | + * | |
| 665 | + * @return string | |
| 666 | + */ | |
| 667 | + private function dup_alias(): string { | |
| 668 | + return $this->use_row_alias() ? ' AS ef_new' : ''; | |
| 669 | + } | |
| 670 | + | |
| 671 | + /** | |
| 672 | + * Should upserts use the MySQL 8.0.19+ row-alias syntax? | |
| 673 | + * | |
| 674 | + * @return bool | |
| 675 | + */ | |
| 676 | + private function use_row_alias(): bool { | |
| 677 | + static $use = null; | |
| 678 | + | |
| 679 | + if ( null === $use ) { | |
| 680 | + global $wpdb; | |
| 681 | + | |
| 682 | + $info = method_exists( $wpdb, 'db_server_info' ) ? (string) $wpdb->db_server_info() : ''; | |
| 683 | + $is_mariadb = false !== stripos( $info, 'mariadb' ); | |
| 684 | + | |
| 685 | + $use = ! $is_mariadb && '' !== $info && version_compare( (string) $wpdb->db_version(), '8.0.19', '>=' ); | |
| 686 | + } | |
| 687 | + | |
| 688 | + return $use; | |
| 689 | + } | |
| 690 | + | |
| 691 | + /** | |
| 322 | 692 | * Empty both tables. |
| 323 | 693 | */ |
| 324 | 694 | public function truncate(): void { |
| 325 | 695 | global $wpdb; |
| @@ -327,6 +697,9 @@ | ||
| 327 | 697 | // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared |
| 328 | 698 | $wpdb->query( "TRUNCATE TABLE {$this->usage_table()}" ); |
| 329 | 699 | $wpdb->query( "TRUNCATE TABLE {$this->decisions_table()}" ); |
| 330 | 700 | // phpcs:enable |
| 701 | + | |
| 702 | + // No usage data left → no uniformly-above-the-fold fonts. | |
| 703 | + delete_option( 'easyfonts_global_preload' ); | |
| 331 | 704 | } |
| 332 | 705 | } |