Changeset 3798397 for xspeed/trunk/includes/object-cache.php
- Timestamp:
- 11/12/2026 01:25:30 PM (less than one hour ago)
- File:
-
- 1 edited
-
xspeed/trunk/includes/object-cache.php (modified) (21 diffs)
Legend:
- Unmodified
- Added
- Removed
-
xspeed/trunk/includes/object-cache.php
r3740781 r3798397 501 501 502 502 function wp_cache_add_multiple( array $data, $group = '', $expire = 0 ) { 503 $out = array(); 504 foreach ( $data as $key => $value ) { 505 $out[ $key ] = wp_cache_add( $key, $value, $group, $expire ); 506 } 507 return $out; 503 return $GLOBALS['wp_object_cache']->add_multiple( $data, $group, (int) $expire ); 508 504 } 509 505 … … 517 513 518 514 function wp_cache_set_multiple( array $data, $group = '', $expire = 0 ) { 519 $out = array(); 520 foreach ( $data as $key => $value ) { 521 $out[ $key ] = wp_cache_set( $key, $value, $group, $expire ); 522 } 523 return $out; 515 return $GLOBALS['wp_object_cache']->set_multiple( $data, $group, (int) $expire ); 524 516 } 525 517 … … 537 529 538 530 function wp_cache_delete_multiple( array $keys, $group = '' ) { 539 $out = array(); 540 foreach ( $keys as $key ) { 541 $out[ $key ] = wp_cache_delete( $key, $group ); 542 } 543 return $out; 531 return $GLOBALS['wp_object_cache']->delete_multiple( $keys, $group ); 544 532 } 545 533 … … 623 611 private $salt = ''; 624 612 625 /** Option holding the Memcachedgeneration floor (see generation_floor()). */613 /** Option holding the generation floor (see generation_floor()). */ 626 614 const GENERATION_OPTION = 'xspeed_oc_generation'; 627 615 628 616 /** 629 * @var int|null This site's namespace generation, resolved lazily once 630 * per request. Advancing it is how Memcached flushes only this site's 631 * keys — the daemon offers no way to enumerate or scope a real flush. 632 * Null until first read; 1 means the original, unsuffixed key shape. 617 * Suffix of the Redis generation counter, `{salt}:generation`. No key 618 * shape a flush, sweep or cleanup deletes can match it: those all 619 * need a digit or `g` plus digits after `{salt}:`. 620 */ 621 const REDIS_COUNTER_SUFFIX = ':generation'; 622 623 /** Cron hook that deletes Redis keys from earlier generations. */ 624 const RECLAIM_HOOK = 'xspeed_oc_reclaim'; 625 626 /** 627 * @var int|null Namespace generation, read once per request and 628 * re-read whenever Redis reports that a flush moved it. Null until 629 * first read. On Memcached, 1 uses the original key shape. 633 630 */ 634 631 private $generation = null; 632 633 /** 634 * @var bool True when Redis would not store the counter this request 635 * seeded, so the generation is one no other request uses. 636 */ 637 private $generation_private = false; 638 639 /** @var bool True once this request has queued the reclaim event. */ 640 private $reclaim_queued = false; 635 641 636 642 /** … … 923 929 } 924 930 931 /** 932 * This request's id for a key, `{salt}:{blog}:{group}:{key}`. The 933 * runtime cache is keyed by it; the backend key adds the generation 934 * (see bid()), so a generation change seen mid-request leaves values 935 * of non-persistent groups in place. 936 */ 925 937 private function full_key( $key, $group ) { 926 938 $group = $this->group( $group ); 927 939 $prefix = isset( $this->global_groups[ $group ] ) ? 0 : $this->blog_prefix; 928 $gen = $this->generation(); 929 // Generation 1 keeps the historical key shape, so Redis (which 930 // flushes by pattern and never advances the generation) is 931 // byte-identical to before and existing entries stay readable. 932 $ns = 1 === $gen ? $this->salt : $this->salt . '.g' . $gen; 933 return $ns . ':' . $prefix . ':' . $group . ':' . $key; 940 return $this->salt . ':' . $prefix . ':' . $group . ':' . $key; 941 } 942 943 /** 944 * The backend key for a runtime id. 945 * 946 * Redis: `{salt}:g{n}:{blog}:{group}:{key}` for every generation, so 947 * a flush only has to move the counter (#801). The `g` keeps these 948 * apart from the flat `{salt}:{blog}:` keys earlier versions wrote, 949 * and from a neighbour whose salt ends in `:<digits>`. 950 * 951 * Memcached: generation 1 keeps the historical shape, later ones are 952 * `{salt}.g{n}:...`. 953 * 954 * @param string $id Runtime id from full_key(). 955 * @return string 956 */ 957 private function bid( $id ) { 958 $gen = $this->generation(); 959 $rest = (string) substr( $id, strlen( $this->salt ) ); 960 if ( 'redis' === $this->backend ) { 961 return $this->salt . ':g' . $gen . $rest; 962 } 963 return 1 === $gen ? $id : $this->salt . '.g' . $gen . $rest; 934 964 } 935 965 … … 963 993 } 964 994 $payload = maybe_serialize( $data ); 965 $stored = $this-> conn->add( $id, $payload, (int) $expire );995 $stored = $this->backend_add( $id, $payload, (int) $expire ); 966 996 if ( ! $stored ) { 967 997 return false; // key already exists in the backend. … … 999 1029 $payload = maybe_serialize( $data ); 1000 1030 if ( 'redis' === $this->backend ) { 1001 $stored = $expire > 0 1002 ? (bool) $this->conn->setex( $id, (int) $expire, $payload ) 1003 : (bool) $this->conn->set( $id, $payload ); 1031 // Preserve the bundled client's rejection of null payloads. 1032 if ( null === $payload && 'builtin' === $this->client ) { 1033 throw new \TypeError( 'Redis payload must be a string' ); 1034 } 1035 $cmd = $expire > 0 1036 ? array( 'SETEX', $this->bid( $id ), (string) (int) $expire, $payload ) 1037 : array( 'SET', $this->bid( $id ), $payload ); 1038 $replies = $this->redis_write( array( $cmd ), array( $id ) ); 1039 $stored = is_array( $replies ) && true === $replies[0]; 1004 1040 } else { 1005 $stored = (bool) $this->conn->set( $ id, $payload, (int) $expire );1041 $stored = (bool) $this->conn->set( $this->bid( $id ), $payload, (int) $expire ); 1006 1042 } 1007 1043 } catch ( \Throwable $e ) { … … 1054 1090 if ( $this->is_persistent_group( $group ) ) { 1055 1091 try { 1056 $raw = $this->conn->get( $id ); 1092 if ( 'redis' === $this->backend ) { 1093 $raw = $this->redis_read( array( $id ) ); 1094 $raw = $raw[ $id ] ?? false; 1095 } else { 1096 $raw = $this->conn->get( $this->bid( $id ) ); 1097 } 1057 1098 if ( false !== $raw && null !== $raw ) { 1058 1099 $val = maybe_unserialize( $raw ); … … 1072 1113 } 1073 1114 1115 /** 1116 * Read several keys: the ones already in this request's cache are 1117 * served locally, the rest come back in one MGET / getMulti instead 1118 * of a round trip each. WordPress primes posts, meta and terms this 1119 * way, so a page asks for hundreds of keys in a handful of calls. 1120 * (#800) 1121 */ 1074 1122 public function get_multiple( $keys, $group = 'default', $force = false ) { 1123 $out = array(); 1124 $missing = array(); // full id => caller's key. 1125 1126 foreach ( (array) $keys as $key ) { 1127 $id = $this->full_key( $key, $group ); 1128 if ( ! $force && isset( $this->cache[ $id ] ) ) { 1129 ++$this->cache_hits; 1130 $val = $this->cache[ $id ]; 1131 $out[ $key ] = is_object( $val ) ? clone $val : $val; 1132 continue; 1133 } 1134 $out[ $key ] = false; 1135 $missing[ $id ] = $key; 1136 } 1137 1138 if ( ! empty( $missing ) && $this->is_persistent_group( $group ) ) { 1139 try { 1140 $raw = $this->backend_get_multi( array_keys( $missing ) ); 1141 } catch ( \Throwable $e ) { 1142 $raw = array(); // Backend trouble reads as misses. 1143 } 1144 foreach ( $raw as $id => $value ) { 1145 if ( ! isset( $missing[ $id ] ) || false === $value || null === $value ) { 1146 continue; 1147 } 1148 $val = maybe_unserialize( $value ); 1149 $this->cache[ $id ] = $val; 1150 ++$this->cache_hits; 1151 $out[ $missing[ $id ] ] = is_object( $val ) ? clone $val : $val; 1152 unset( $missing[ $id ] ); 1153 } 1154 } 1155 1156 $this->cache_misses += count( $missing ); 1157 return $out; 1158 } 1159 1160 /** 1161 * Store several values with one pipelined round trip on Redis (one 1162 * setMulti on ext-memcached). The runtime cache takes every value. 1163 * A key the backend refused is reported false and evicted so no 1164 * reader gets the previous value. 1165 */ 1166 public function set_multiple( array $data, $group = 'default', $expire = 0 ) { 1167 if ( ! $this->is_persistent_group( $group ) ) { 1168 $out = array(); 1169 foreach ( $data as $key => $value ) { 1170 $out[ $key ] = $this->set( $key, $value, $group, $expire ); 1171 } 1172 return $out; 1173 } 1174 1175 $out = array(); 1176 $payloads = array(); 1177 $caller_keys = array(); 1178 foreach ( $data as $key => $value ) { 1179 // Preserve the client's single-key handling of null payloads. 1180 if ( null === $value && 'redis' === $this->backend ) { 1181 $out[ $key ] = $this->set( $key, $value, $group, $expire ); 1182 continue; 1183 } 1184 $id = $this->full_key( $key, $group ); 1185 if ( is_object( $value ) ) { 1186 $value = clone $value; 1187 } 1188 $this->cache[ $id ] = $value; 1189 $payloads[ $id ] = maybe_serialize( $value ); 1190 $caller_keys[ $id ] = $key; 1191 $out[ $key ] = true; 1192 } 1193 1194 if ( empty( $payloads ) ) { 1195 return $out; 1196 } 1197 1198 try { 1199 $failed = $this->backend_set_multi( $payloads, (int) $expire ); 1200 } catch ( \Throwable $e ) { 1201 $failed = $this->backend_set_individual( $payloads, (int) $expire ); 1202 } 1203 if ( ! empty( $failed ) ) { 1204 foreach ( $failed as $id ) { 1205 $out[ $caller_keys[ $id ] ] = false; 1206 } 1207 // See set(): the backend may still hold the previous value. 1208 foreach ( $failed as $id ) { 1209 try { 1210 $this->backend_delete( $id ); 1211 } catch ( \Throwable $e ) { 1212 break; // Backend fully down, so reads fail too. 1213 } 1214 } 1215 $this->degraded = true; 1216 } 1217 return $out; 1218 } 1219 1220 /** 1221 * Add several values: SET NX for each key, pipelined into one round 1222 * trip on Redis. A key already present, in this request or in the 1223 * backend, is left alone and reported false, exactly as add() does. 1224 */ 1225 public function add_multiple( array $data, $group = 'default', $expire = 0 ) { 1075 1226 $out = array(); 1076 foreach ( (array) $keys as $key ) { 1077 $out[ $key ] = $this->get( $key, $group, $force ); 1227 if ( wp_suspend_cache_addition() ) { 1228 foreach ( $data as $key => $unused ) { 1229 $out[ $key ] = false; 1230 } 1231 return $out; 1232 } 1233 if ( 'redis' !== $this->backend || ! $this->conn || ! $this->is_persistent_group( $group ) ) { 1234 foreach ( $data as $key => $value ) { 1235 $out[ $key ] = $this->add( $key, $value, $group, $expire ); 1236 } 1237 return $out; 1238 } 1239 1240 $pending = array(); // full id => array( key, value ). 1241 foreach ( $data as $key => $value ) { 1242 if ( null === $value ) { 1243 $out[ $key ] = $this->add( $key, $value, $group, $expire ); 1244 continue; 1245 } 1246 $id = $this->full_key( $key, $group ); 1247 if ( isset( $this->cache[ $id ] ) || isset( $pending[ $id ] ) ) { 1248 $out[ $key ] = false; 1249 continue; 1250 } 1251 if ( is_object( $value ) ) { 1252 $value = clone $value; 1253 } 1254 $pending[ $id ] = array( $key, $value ); 1255 $out[ $key ] = false; 1256 } 1257 if ( empty( $pending ) ) { 1258 return $out; 1259 } 1260 1261 $commands = array(); 1262 foreach ( $pending as $id => $entry ) { 1263 $commands[ $id ] = maybe_serialize( $entry[1] ); 1264 } 1265 try { 1266 $stored = $this->redis_add_multi( $commands, (int) $expire ); 1267 } catch ( \Throwable $e ) { 1268 $stored = null; 1269 } 1270 if ( null === $stored ) { 1271 // The pipeline failed as a whole: fall back to add() per key, 1272 // which has its own runtime-only path for a dead backend. 1273 foreach ( $pending as $entry ) { 1274 $out[ $entry[0] ] = $this->add( $entry[0], $entry[1], $group, $expire ); 1275 } 1276 return $out; 1277 } 1278 foreach ( $pending as $id => $entry ) { 1279 if ( ! empty( $stored[ $id ] ) ) { 1280 $this->cache[ $id ] = $entry[1]; 1281 $out[ $entry[0] ] = true; 1282 } 1283 } 1284 return $out; 1285 } 1286 1287 /** 1288 * Delete several keys, pipelined into one round trip on Redis. 1289 */ 1290 public function delete_multiple( array $keys, $group = 'default' ) { 1291 if ( count( $keys ) < 2 || 'redis' !== $this->backend || ! $this->conn || ! $this->is_persistent_group( $group ) ) { 1292 $out = array(); 1293 foreach ( $keys as $key ) { 1294 $out[ $key ] = $this->delete( $key, $group ); 1295 } 1296 return $out; 1297 } 1298 1299 $ids = array(); 1300 foreach ( $keys as $key ) { 1301 $id = $this->full_key( $key, $group ); 1302 unset( $this->cache[ $id ] ); 1303 $ids[ $id ] = $key; 1304 } 1305 $out = array(); 1306 try { 1307 $replies = $this->redis_delete_multi( array_keys( $ids ) ); 1308 } catch ( \Throwable $e ) { 1309 $replies = false; 1310 } 1311 $i = 0; 1312 foreach ( $ids as $key ) { 1313 // A dead backend reports true, like delete() does. 1314 $out[ $key ] = is_array( $replies ) ? (bool) ( $replies[ $i ] ?? false ) : true; 1315 ++$i; 1078 1316 } 1079 1317 return $out; … … 1134 1372 } 1135 1373 1374 /** @var string Normalized failure code for the last flush in this request. */ 1375 private $flush_failure = ''; 1376 1377 public function flush_failure_reason(): string { 1378 return $this->flush_failure ?: 'failed'; 1379 } 1380 1381 private function record_flush_failure( string $message ): void { 1382 $this->flush_failure = preg_match( '/NOPERM|permission(?:s)?[ -]denied/i', $message ) ? 'permission_denied' : 'failed'; 1383 } 1384 1136 1385 public function flush() { 1386 $this->flush_failure = ''; 1137 1387 $this->cache = array(); 1138 if ( $this->persistent ) {1388 if ( $this->persistent && $this->conn ) { 1139 1389 try { 1140 return (bool) $this->backend_flush( ); 1390 if ( method_exists( $this->conn, 'clearLastError' ) ) { 1391 $this->conn->clearLastError(); 1392 } 1393 $ok = (bool) $this->backend_flush(); 1394 if ( ! $ok && '' === $this->flush_failure && method_exists( $this->conn, 'getLastError' ) ) { 1395 $this->record_flush_failure( (string) $this->conn->getLastError() ); 1396 } 1397 return $ok; 1141 1398 } catch ( \Throwable $e ) { 1399 $this->record_flush_failure( $e->getMessage() ); 1142 1400 return false; 1143 1401 } … … 1153 1411 private function backend_delete( $id ) { 1154 1412 if ( 'redis' === $this->backend ) { 1155 return $this->conn->del( $id ); 1156 } 1157 return $this->conn->delete( $id ); 1413 $replies = $this->redis_write( array( array( 'DEL', $this->bid( $id ) ) ), array( $id ) ); 1414 return is_array( $replies ) ? $replies[0] : false; 1415 } 1416 return $this->conn->delete( $this->bid( $id ) ); 1417 } 1418 1419 /** 1420 * Atomic add: store only when the key is absent. phpredis has no 1421 * add() method, so Redis takes SET with the NX option; calling add() 1422 * on phpredis threw, and the catch in add() fell through to a plain 1423 * set() that overwrote whatever another process had stored. 1424 */ 1425 private function backend_add( $id, $payload, $expire ) { 1426 if ( 'redis' === $this->backend ) { 1427 if ( null === $payload && 'builtin' === $this->client ) { 1428 throw new \TypeError( 'Redis payload must be a string' ); 1429 } 1430 $replies = $this->redis_write( array( $this->redis_set_nx_command( $this->bid( $id ), $payload, $expire ) ), array( $id ), true ); 1431 return is_array( $replies ) && true === $replies[0]; 1432 } 1433 return (bool) $this->conn->add( $this->bid( $id ), $payload, (int) $expire ); 1434 } 1435 1436 /** 1437 * Read several ids in one round trip where the client can. 1438 * 1439 * @param string[] $ids Runtime ids. 1440 * @return array<string,mixed> Raw values keyed by runtime id; misses 1441 * may be absent or false. 1442 */ 1443 private function backend_get_multi( array $ids ) { 1444 if ( 'redis' === $this->backend ) { 1445 return $this->redis_read( $ids ); 1446 } 1447 $map = array(); 1448 foreach ( $ids as $id ) { 1449 $map[ $this->bid( $id ) ] = $id; 1450 } 1451 if ( 'ext-memcached' === $this->client ) { 1452 $vals = $this->conn->getMulti( array_keys( $map ) ); 1453 } elseif ( 'builtin-memcached' === $this->client && method_exists( $this->conn, 'get_multi' ) ) { 1454 $vals = $this->conn->get_multi( array_keys( $map ) ); 1455 } else { 1456 $vals = array(); 1457 foreach ( $map as $bid => $unused ) { 1458 $vals[ $bid ] = $this->conn->get( $bid ); 1459 } 1460 } 1461 $out = array(); 1462 foreach ( is_array( $vals ) ? $vals : array() as $bid => $value ) { 1463 if ( isset( $map[ $bid ] ) ) { 1464 $out[ $map[ $bid ] ] = $value; 1465 } 1466 } 1467 return $out; 1468 } 1469 1470 /** 1471 * Store several payloads, batched where the client can. 1472 * 1473 * @param array<string,string> $payloads Serialized values keyed by runtime id. 1474 * @param int $expire TTL in seconds, 0 for none. 1475 * @return string[] Runtime ids the backend did not store. 1476 */ 1477 private function backend_set_multi( array $payloads, $expire ) { 1478 $failed = array(); 1479 1480 if ( 'redis' === $this->backend ) { 1481 $commands = array(); 1482 foreach ( $payloads as $id => $payload ) { 1483 $commands[] = $expire > 0 1484 ? array( 'SETEX', $this->bid( $id ), (string) $expire, $payload ) 1485 : array( 'SET', $this->bid( $id ), $payload ); 1486 } 1487 $replies = $this->redis_write( $commands, array_keys( $payloads ) ); 1488 $i = 0; 1489 foreach ( $payloads as $id => $unused ) { 1490 if ( ! is_array( $replies ) || true !== $replies[ $i ] ) { 1491 $failed[] = $id; 1492 } 1493 ++$i; 1494 } 1495 return $failed; 1496 } 1497 1498 $map = array(); 1499 foreach ( $payloads as $id => $payload ) { 1500 $map[ $this->bid( $id ) ] = $id; 1501 } 1502 if ( 'ext-memcached' === $this->client && $this->conn->setMulti( array_combine( array_keys( $map ), array_values( $payloads ) ), $expire ) ) { 1503 return array(); 1504 } 1505 1506 // No batch primitive, or setMulti refused something (an item over 1507 // the size limit fails the whole call): store one by one to learn 1508 // which keys did not make it. 1509 return $this->backend_set_individual( $payloads, $expire ); 1510 } 1511 1512 /** Isolate payload errors so one refused value cannot erase its neighbors. */ 1513 private function backend_set_individual( array $payloads, $expire ) { 1514 $failed = array(); 1515 $disconnected = false; 1516 foreach ( $payloads as $id => $payload ) { 1517 if ( $disconnected ) { 1518 $failed[] = $id; 1519 continue; 1520 } 1521 try { 1522 if ( 'redis' === $this->backend ) { 1523 $cmd = $expire > 0 1524 ? array( 'SETEX', $this->bid( $id ), (string) $expire, $payload ) 1525 : array( 'SET', $this->bid( $id ), $payload ); 1526 $replies = $this->redis_write( array( $cmd ), array( $id ) ); 1527 $ok = is_array( $replies ) && true === $replies[0]; 1528 $disconnected = false === $replies; 1529 } else { 1530 $ok = $this->conn->set( $this->bid( $id ), $payload, $expire ); 1531 } 1532 } catch ( \Throwable $e ) { 1533 $ok = false; 1534 // Only payload errors leave the connection safe to retry. 1535 $disconnected = ! $e instanceof \TypeError && ! $e instanceof \ValueError; 1536 } 1537 if ( ! $ok ) { 1538 $failed[] = $id; 1539 } 1540 } 1541 return $failed; 1542 } 1543 1544 /** 1545 * SET NX for several ids in one Redis round trip. 1546 * 1547 * @param array<string,string> $payloads Serialized values keyed by runtime id. 1548 * @param int $expire TTL in seconds, 0 for none. 1549 * @return array<string,bool>|null Stored flag per runtime id, or null 1550 * when the pipeline failed. 1551 */ 1552 private function redis_add_multi( array $payloads, $expire ) { 1553 $commands = array(); 1554 foreach ( $payloads as $id => $payload ) { 1555 $commands[] = $this->redis_set_nx_command( $this->bid( $id ), $payload, $expire ); 1556 } 1557 $replies = $this->redis_write( $commands, array_keys( $payloads ), true ); 1558 if ( ! is_array( $replies ) ) { 1559 return null; 1560 } 1561 $stored = array(); 1562 $i = 0; 1563 foreach ( $payloads as $id => $unused ) { 1564 $stored[ $id ] = true === $replies[ $i ]; 1565 ++$i; 1566 } 1567 return $stored; 1568 } 1569 1570 /** 1571 * DEL several ids in one Redis round trip, one reply per id. 1572 * 1573 * @param string[] $ids Runtime ids. 1574 * @return array|false Per-id delete counts, or false on failure. 1575 */ 1576 private function redis_delete_multi( array $ids ) { 1577 $commands = array(); 1578 foreach ( $ids as $id ) { 1579 $commands[] = array( 'DEL', $this->bid( $id ) ); 1580 } 1581 return $this->redis_write( $commands, $ids ); 1158 1582 } 1159 1583 1160 1584 private function backend_incr( $id, $offset ) { 1161 1585 if ( 'redis' === $this->backend ) { 1162 return $this->conn->incrBy( $id, $offset ); 1586 $replies = $this->redis_write( array( array( 'INCRBY', $this->bid( $id ), (string) $offset ) ), array( $id ), true ); 1587 return is_array( $replies ) ? $replies[0] : false; 1163 1588 } 1164 1589 return 'builtin-memcached' === $this->client 1165 ? $this->conn->incr( $ id, $offset )1166 : $this->conn->increment( $ id, $offset );1590 ? $this->conn->incr( $this->bid( $id ), $offset ) 1591 : $this->conn->increment( $this->bid( $id ), $offset ); 1167 1592 } 1168 1593 1169 1594 private function backend_decr( $id, $offset ) { 1170 1595 if ( 'redis' === $this->backend ) { 1171 return $this->conn->decrBy( $id, $offset ); 1596 $replies = $this->redis_write( array( array( 'DECRBY', $this->bid( $id ), (string) $offset ) ), array( $id ), true ); 1597 return is_array( $replies ) ? $replies[0] : false; 1172 1598 } 1173 1599 return 'builtin-memcached' === $this->client 1174 ? $this->conn->decr( $id, $offset ) 1175 : $this->conn->decrement( $id, $offset ); 1176 } 1177 1600 ? $this->conn->decr( $this->bid( $id ), $offset ) 1601 : $this->conn->decrement( $this->bid( $id ), $offset ); 1602 } 1603 1604 // --- Redis generation checks ----------------------------------- 1605 // A Redis flush only moves the generation counter (#801), so a 1606 // request that resolved its generation before another request 1607 // flushed would keep reading and writing the dead one. Every read 1608 // and write therefore carries a read of the counter in the same 1609 // round trip, and the request follows the counter when it moved. 1610 1611 /** 1612 * Read ids with one MGET that also fetches the generation counter. 1613 * When the counter moved, the request adopts it and reads again 1614 * from the new generation. 1615 * 1616 * @param string[] $ids Runtime ids. 1617 * @return array<string,mixed> Raw values keyed by runtime id. 1618 */ 1619 private function redis_read( array $ids ) { 1620 $ids = array_values( $ids ); 1621 $vals = $this->redis_mget( $ids, true ); 1622 if ( ! is_array( $vals ) ) { 1623 return array(); 1624 } 1625 $counter = array_pop( $vals ); 1626 if ( $this->generation_moved( $counter ) ) { 1627 $vals = $this->redis_mget( $ids, false ); 1628 if ( ! is_array( $vals ) ) { 1629 return array(); 1630 } 1631 } 1632 return array_combine( $ids, $vals ); 1633 } 1634 1635 /** 1636 * MGET the backend keys for $ids, optionally followed by the counter. 1637 * 1638 * @return array|false Values in order, false on failure. 1639 */ 1640 private function redis_mget( array $ids, $with_counter ) { 1641 $keys = array(); 1642 foreach ( $ids as $id ) { 1643 $keys[] = $this->bid( $id ); 1644 } 1645 if ( $with_counter ) { 1646 $keys[] = $this->generation_key(); 1647 } 1648 if ( method_exists( $this->conn, 'mget' ) ) { 1649 $vals = $this->conn->mget( $keys ); 1650 } else { 1651 $vals = array(); 1652 foreach ( $keys as $key ) { 1653 $vals[] = $this->conn->get( $key ); 1654 } 1655 } 1656 return is_array( $vals ) && count( $vals ) === count( $keys ) ? array_values( $vals ) : false; 1657 } 1658 1659 /** 1660 * Run Redis write commands in one round trip, followed by a read of 1661 * the generation counter. 1662 * 1663 * When the counter shows that another request flushed after this one 1664 * resolved its generation, the commands wrote to the dead generation. 1665 * 1666 * A plain write (SET, DEL) is not repeated in the new generation: this 1667 * request's value may come from a read made before the flush, and a 1668 * third request may already have cached an older database value 1669 * there. Those keys are deleted in the new generation instead, so the 1670 * next read goes to the database. 1671 * 1672 * An atomic write (SET NX, INCRBY, DECRBY) is run again in the new 1673 * generation and its replies replace the dead generation's. Deleting 1674 * there would drop a lock or counter another request holds, and the 1675 * dead generation's SET NX reply would report a lock nobody can see. 1676 * Atomic commands must be one per id, in the order of $ids. 1677 * 1678 * @param array $commands Commands as array( NAME, key, ...args ). 1679 * @param string[] $ids Runtime ids the commands touch. 1680 * @param bool $atomic True for SET NX, INCRBY and DECRBY. 1681 * @return array|false Normalised replies, one per command, or false 1682 * when the round trip failed. 1683 */ 1684 private function redis_write( array $commands, array $ids, $atomic = false ) { 1685 $ids = array_values( $ids ); 1686 for ( $attempt = 1; ; ++$attempt ) { 1687 $commands[] = array( 'GET', $this->generation_key() ); 1688 $replies = $this->redis_pipeline( $commands ); 1689 array_pop( $commands ); 1690 if ( ! is_array( $replies ) ) { 1691 return false; 1692 } 1693 $counter = array_pop( $replies ); 1694 if ( empty( $ids ) || ! $this->generation_moved( $counter ) ) { 1695 return $replies; 1696 } 1697 if ( ! $atomic ) { 1698 $deletes = array(); 1699 foreach ( $ids as $id ) { 1700 $deletes[] = array( 'DEL', $this->bid( $id ) ); 1701 } 1702 try { 1703 $deleted = $this->redis_pipeline( $deletes ); 1704 } catch ( \Throwable $e ) { 1705 // A fallback write would revive values read before the flush. 1706 return false; 1707 } 1708 if ( ! is_array( $deleted ) || count( $deleted ) !== count( $ids ) ) { 1709 return false; 1710 } 1711 foreach ( $deleted as $result ) { 1712 if ( ! is_int( $result ) || $result < 0 ) { 1713 return false; 1714 } 1715 } 1716 return $replies; 1717 } 1718 if ( $attempt >= 3 ) { 1719 // Flushes keep landing between attempts: report nothing 1720 // stored rather than a result from a dead generation. 1721 return array_fill( 0, count( $replies ), false ); 1722 } 1723 foreach ( $commands as $i => $cmd ) { 1724 $commands[ $i ][1] = $this->bid( $ids[ $i ] ); 1725 } 1726 } 1727 } 1728 1729 /** 1730 * Follow a counter value read from Redis. 1731 * 1732 * @param mixed $value Counter reply. 1733 * @return bool True when the generation changed. 1734 */ 1735 private function adopt_generation( $value ) { 1736 if ( ! $this->is_generation_value( $value ) || (int) $value === $this->generation ) { 1737 return false; 1738 } 1739 $this->generation = (int) $value; 1740 $this->generation_private = false; 1741 return true; 1742 } 1743 1744 /** 1745 * Check a counter reply fetched beside a read or write, and switch to 1746 * the counter's generation when it differs from this request's. 1747 * 1748 * A missing counter (evicted, or deleted by a disable) proves 1749 * nothing: another request may have flushed before it went, and the 1750 * retired generation's values stay in Redis until the reclaim. So the 1751 * request resolves the counter again, which seeds a new one above 1752 * every earlier generation. When Redis would not store this request's 1753 * seed, the generation is already one nobody else writes to and 1754 * there is nothing to check. 1755 * 1756 * @param mixed $value Counter reply. 1757 * @return bool True when the generation changed. 1758 */ 1759 private function generation_moved( $value ) { 1760 if ( $this->is_generation_value( $value ) ) { 1761 return $this->adopt_generation( $value ); 1762 } 1763 if ( $this->generation_private ) { 1764 return false; 1765 } 1766 $fresh = $this->resolve_redis_generation(); 1767 if ( $fresh === $this->generation ) { 1768 return false; 1769 } 1770 $this->generation = $fresh; 1771 return true; 1772 } 1773 1774 /** 1775 * Read the counter now and follow it, for an operation that must act 1776 * on the current generation rather than the one this request saw 1777 * first. 1778 * 1779 * @return int The current generation. 1780 */ 1781 private function refresh_generation() { 1782 $gen = $this->generation(); 1783 try { 1784 $this->generation_moved( $this->conn->get( $this->generation_key() ) ); 1785 } catch ( \Throwable $e ) { 1786 unset( $e ); 1787 } 1788 return null === $this->generation ? $gen : $this->generation; 1789 } 1790 1791 /** @param mixed $value */ 1792 private function is_generation_value( $value ) { 1793 return ( is_int( $value ) && $value > 0 ) || ( is_string( $value ) && '' !== $value && ctype_digit( $value ) && (int) $value > 0 ); 1794 } 1795 1796 /** SET NX command, with a TTL when $expire is positive. */ 1797 private function redis_set_nx_command( $key, $payload, $expire ) { 1798 $cmd = array( 'SET', $key, $payload, 'NX' ); 1799 if ( $expire > 0 ) { 1800 $cmd[] = 'EX'; 1801 $cmd[] = (string) (int) $expire; 1802 } 1803 return $cmd; 1804 } 1805 1806 /** 1807 * Send commands to Redis in one round trip on either client and 1808 * normalise the replies: true/false for SET and SETEX, an int (or 1809 * false on an error reply) for DEL, INCRBY and DECRBY, and the value 1810 * or false for GET. 1811 * 1812 * @param array $commands Commands as array( NAME, key, ...args ). 1813 * @return array|false Replies in order, or false when the round trip failed. 1814 */ 1815 private function redis_pipeline( array $commands ) { 1816 $commands = array_values( $commands ); 1817 if ( 'phpredis' === $this->client ) { 1818 $pipe = $this->conn->multi( \Redis::PIPELINE ); 1819 foreach ( $commands as $cmd ) { 1820 $this->phpredis_queue( $pipe, $cmd ); 1821 } 1822 $raw = $pipe->exec(); 1823 } elseif ( method_exists( $this->conn, 'pipeline' ) ) { 1824 $raw = $this->conn->pipeline( $commands ); 1825 } else { 1826 $raw = array(); 1827 foreach ( $commands as $cmd ) { 1828 $raw[] = $this->redis_single( $cmd ); 1829 } 1830 } 1831 if ( ! is_array( $raw ) || count( $raw ) !== count( $commands ) ) { 1832 return false; 1833 } 1834 $out = array(); 1835 foreach ( $commands as $i => $cmd ) { 1836 $r = $raw[ $i ]; 1837 switch ( $cmd[0] ) { 1838 case 'SET': 1839 case 'SETEX': 1840 $out[] = true === $r || 'OK' === $r || '+OK' === $r; 1841 break; 1842 case 'GET': 1843 $out[] = null === $r ? false : $r; 1844 break; 1845 default: 1846 $out[] = is_int( $r ) || ( is_string( $r ) && is_numeric( $r ) ) ? (int) $r : false; 1847 } 1848 } 1849 return $out; 1850 } 1851 1852 /** Queue one command on a phpredis pipeline. */ 1853 private function phpredis_queue( $pipe, array $cmd ) { 1854 switch ( $cmd[0] ) { 1855 case 'SET': 1856 if ( isset( $cmd[3] ) ) { 1857 $opts = isset( $cmd[5] ) ? array( 'nx', 'ex' => (int) $cmd[5] ) : array( 'nx' ); 1858 $pipe->set( $cmd[1], $cmd[2], $opts ); 1859 } else { 1860 $pipe->set( $cmd[1], $cmd[2] ); 1861 } 1862 break; 1863 case 'SETEX': 1864 $pipe->setex( $cmd[1], (int) $cmd[2], $cmd[3] ); 1865 break; 1866 case 'DEL': 1867 $pipe->del( $cmd[1] ); 1868 break; 1869 case 'INCRBY': 1870 $pipe->incrBy( $cmd[1], (int) $cmd[2] ); 1871 break; 1872 case 'DECRBY': 1873 $pipe->decrBy( $cmd[1], (int) $cmd[2] ); 1874 break; 1875 default: 1876 $pipe->get( $cmd[1] ); 1877 } 1878 } 1879 1880 /** Run one command on a client that cannot pipeline. */ 1881 private function redis_single( array $cmd ) { 1882 switch ( $cmd[0] ) { 1883 case 'SET': 1884 return isset( $cmd[3] ) 1885 ? $this->conn->add( $cmd[1], $cmd[2], isset( $cmd[5] ) ? (int) $cmd[5] : 0 ) 1886 : $this->conn->set( $cmd[1], $cmd[2] ); 1887 case 'SETEX': 1888 return $this->conn->setex( $cmd[1], (int) $cmd[2], $cmd[3] ); 1889 case 'DEL': 1890 return $this->conn->del( $cmd[1] ); 1891 case 'INCRBY': 1892 return $this->conn->incrBy( $cmd[1], (int) $cmd[2] ); 1893 case 'DECRBY': 1894 return $this->conn->decrBy( $cmd[1], (int) $cmd[2] ); 1895 default: 1896 return $this->conn->get( $cmd[1] ); 1897 } 1898 } 1899 1900 /** 1901 * Flush persistent values. 1902 * 1903 * Redis advances the generation counter, so every key of the old 1904 * generation becomes unreachable in one command and a cron event 1905 * deletes them later (#801). Memcached advances its own counter the 1906 * same way and lets LRU drop the old keys. 1907 * 1908 * @return bool False when the flush could not be carried out. 1909 */ 1178 1910 private function backend_flush() { 1179 1911 if ( 'redis' === $this->backend ) { 1180 // Scope the flush to THIS site's namespace (salt:*) instead of 1181 // FLUSHDB, which would wipe the entire Redis database — 1182 // including other sites / apps sharing the same DB index. 1183 // (FBS-83119) 1184 // 1185 // There is deliberately NO empty-salt fallback to FLUSHDB. 1186 // xspeed_oc_salt() always returns a non-empty value, but a 1187 // drop-in left over from an older version can still be the 1188 // object loaded for the request that runs the upgrade 1189 // migration — and that is exactly when a global flush would 1190 // destroy a neighbouring site's cache. An unsalted pattern is 1191 // scoped to nothing, so we bail rather than widen the blast 1192 // radius. 1912 // An empty salt cannot limit the flush to this site. (FBS-83119) 1193 1913 if ( '' === $this->salt ) { 1194 1914 return false; 1195 1915 } 1196 return $this->delete_redis_pattern( $this->escape_glob( $this->salt ) . ':*' ) >= 0; 1197 } 1198 1199 // Memcached has no key enumeration, so flush_all() / flush() are 1200 // unavoidably SERVER-WIDE — they wipe every other site and app on 1201 // the same daemon. Bump this site's namespace generation instead: 1202 // every key is built through it (see full_key()), so incrementing 1203 // it orphans this site's entries and leaves everyone else's alone. 1204 // The orphans expire on their own under Memcached's LRU. 1916 return $this->redis_flush(); 1917 } 1918 1919 // Memcached cannot enumerate keys. Advance this site's namespace 1920 // so its values become unreachable without a server-wide flush. 1205 1921 return $this->bump_generation(); 1206 1922 } 1207 1923 1208 1924 /** 1925 * Advance the Redis generation counter. 1926 * 1927 * INCRBY keeps concurrent flushes from overwriting each other. When 1928 * the counter came back at or below this request's generation, it was 1929 * evicted and recreated, so it is raised past both this request's 1930 * generation and the time-based seed. When Redis refuses the 1931 * increment (out of memory under noeviction, NOPERM, an error), the 1932 * flush deletes this site's keys instead and reports whether that 1933 * worked. 1934 * 1935 * @return bool 1936 */ 1937 private function redis_flush() { 1938 $key = $this->generation_key(); 1939 $current = $this->generation(); 1940 $new = $this->redis_incr( $key, 1 ); 1941 1942 if ( false !== $new && $new <= $current ) { 1943 $target = max( $current + 1, $this->generation_seed() ); 1944 $new = $this->redis_incr( $key, $target - $new ); 1945 if ( false === $new || $new <= $current ) { 1946 // A counter that cannot be moved past this generation 1947 // would hand other requests a generation that is still 1948 // live. Remove it; the next reader seeds a fresh one. 1949 try { 1950 $this->conn->del( $key ); 1951 } catch ( \Throwable $e ) { 1952 unset( $e ); 1953 } 1954 $new = false; 1955 } 1956 } 1957 1958 if ( false === $new ) { 1959 return $this->redis_flush_by_delete(); 1960 } 1961 1962 $this->generation = $new; 1963 $this->generation_private = false; 1964 $this->persist_generation_floor( $new ); 1965 $this->queue_reclaim(); 1966 return true; 1967 } 1968 1969 /** 1970 * Delete every key this site owns in every generation, for when the 1971 * counter cannot be moved. Covers the current `{salt}:g{n}:{blog}:` 1972 * shape, the flat `{salt}:{blog}:` shape of 1.4.4 and earlier, and the 1973 * `{salt}|g{n}:` keys and `{salt}.xspeed-oc-gen` counter of the build 1974 * QA tested before #813. The lookaheads keep a neighbouring salt such 1975 * as `{salt}:3` or `{salt}:g5:3` out of the match. The counter itself 1976 * is kept. 1977 * 1978 * @return bool True only when every matching key was deleted. 1979 */ 1980 private function redis_flush_by_delete() { 1981 $salt = preg_quote( $this->salt, '/' ); 1982 return -1 !== $this->delete_redis_pattern( 1983 $this->escape_glob( $this->salt ) . '*', 1984 '/\A' . $salt . '(?::g[0-9]+:[0-9]+:(?!g[0-9]+:[0-9]+:|generation\z)|:[0-9]+:(?!g[0-9]+:[0-9]+:|generation\z)|\|g[0-9]+:[0-9]+:|\.xspeed-oc-gen\z)/' 1985 ); 1986 } 1987 1988 /** 1989 * INCRBY on the connected Redis client. 1990 * 1991 * @return int|false The new value, or false when Redis refused. 1992 */ 1993 private function redis_incr( $key, $by ) { 1994 try { 1995 $new = $this->redis_single( array( 'INCRBY', $key, (string) (int) $by ) ); 1996 } catch ( \Throwable $e ) { 1997 return false; 1998 } 1999 return is_int( $new ) || ( is_string( $new ) && is_numeric( $new ) ) ? (int) $new : false; 2000 } 2001 2002 /** 2003 * Value for a new Redis counter: the current time in milliseconds, 2004 * and never below the database floor. Either guard alone stops an 2005 * evicted counter from coming back at a generation an earlier flush 2006 * already retired: the floor holds even if the clock went back, and 2007 * the clock holds even if a floor write was lost. 2008 * 2009 * @return int 2010 */ 2011 private function generation_seed() { 2012 return max( $this->generation_floor() + 1, (int) floor( microtime( true ) * 1000 ) ); 2013 } 2014 2015 /** 2016 * Read or create the Redis generation counter for this request. 2017 * 2018 * A missing counter (never created, evicted, deleted by a disable) is 2019 * seeded with SET NX, so concurrent requests agree on one value. The 2020 * database floor is read only here and on a flush, so a normal request 2021 * adds no query. 2022 * 2023 * @return int 2024 */ 2025 private function resolve_redis_generation() { 2026 $key = $this->generation_key(); 2027 try { 2028 $this->generation_private = false; 2029 $val = $this->conn->get( $key ); 2030 if ( $this->is_generation_value( $val ) ) { 2031 return (int) $val; 2032 } 2033 $seed = $this->generation_seed(); 2034 $replies = $this->redis_pipeline( 2035 array( 2036 array( 'SET', $key, (string) $seed, 'NX' ), 2037 array( 'GET', $key ), 2038 ) 2039 ); 2040 if ( is_array( $replies ) && $this->is_generation_value( $replies[1] ) ) { 2041 return (int) $replies[1]; 2042 } 2043 $this->generation_private = true; 2044 return $seed; 2045 } catch ( \Throwable $e ) { 2046 // Unreadable counter: a fresh generation nobody else uses 2047 // misses every read, which is safe, where guessing an old one 2048 // could serve values a flush retired. 2049 $this->generation_private = true; 2050 return $this->generation_seed(); 2051 } 2052 } 2053 2054 /** 2055 * Schedule the reclaim event at the end of the request. Once per 2056 * request, however many times it flushed. 2057 */ 2058 private function queue_reclaim() { 2059 if ( $this->reclaim_queued || ! function_exists( 'add_action' ) ) { 2060 return; 2061 } 2062 $this->reclaim_queued = true; 2063 add_action( 'shutdown', array( $this, 'schedule_reclaim' ) ); 2064 } 2065 2066 /** 2067 * Schedule the plugin's reclaim of old Redis generations, unless one 2068 * is already pending. The event carries no arguments, so 2069 * wp_next_scheduled() sees an event queued by any earlier flush. 2070 * 2071 * @return void 2072 */ 2073 public function schedule_reclaim() { 2074 if ( ! function_exists( 'wp_next_scheduled' ) || ! function_exists( 'wp_schedule_single_event' ) ) { 2075 return; 2076 } 2077 // Disabling Object Cache flushes, then removes this file in the same request. 2078 if ( defined( 'WP_CONTENT_DIR' ) && ! file_exists( WP_CONTENT_DIR . '/object-cache.php' ) ) { 2079 return; 2080 } 2081 try { 2082 if ( ! wp_next_scheduled( self::RECLAIM_HOOK ) ) { 2083 wp_schedule_single_event( time() + 30, self::RECLAIM_HOOK ); 2084 } 2085 } catch ( \Throwable $e ) { 2086 // The keys stay unreachable either way; the next flush retries. 2087 unset( $e ); 2088 } 2089 } 2090 2091 /** 1209 2092 * Advance this site's namespace generation, invalidating every key 1210 * built from it . Used as the Memcached flush primitive.2093 * built from it in Memcached. 1211 2094 * 1212 2095 * @return bool 1213 2096 */ 1214 2097 private function bump_generation() { 1215 $key = $this->generation_key(); 1216 $new = null; 1217 1218 try { 1219 $new = 'builtin-memcached' === $this->client 1220 ? $this->conn->incr( $key, 1 ) 1221 : $this->conn->increment( $key, 1 ); 1222 } catch ( \Throwable $e ) { 1223 $new = false; 1224 } 1225 1226 // increment() fails when the counter does not exist — either it was 1227 // never seeded, or the daemon evicted it under LRU. Either way, 1228 // resuming from the CURRENT generation is what matters: seeding 1229 // back to a low number could land on a generation this site used 1230 // before and resurrect the keys this flush is meant to clear. 1231 // Jumping forward from the generation we resolved for this request 1232 // keeps the namespace monotonic across an eviction. 1233 if ( false === $new || null === $new ) { 1234 $next = $this->generation() + 1; 2098 $key = $this->generation_key(); 2099 $current = $this->generation(); 2100 $new = $this->increment_counter( $key, 1 ); 2101 2102 // Memcached refuses to increment a counter that does not exist, 2103 // either never seeded or evicted under LRU. Seed it with add(), 2104 // which only succeeds when the key is still absent; losing that 2105 // race means another request created it, so increment again. 2106 if ( false === $new ) { 1235 2107 try { 1236 // Store as a string: the bundled Memcached_Client types 1237 // this parameter `string`, and Memcached stores scalars as 1238 // strings regardless. (This file has no strict_types — it 1239 // must load standalone — so an int would be coerced rather 1240 // than rejected, but passing the right type keeps the two 1241 // clients behaving identically.) 1242 $this->conn->set( $key, (string) $next, 0 ); 1243 $new = $next; 2108 // A string: the bundled client types this parameter. 2109 $seeded = $this->conn->add( $key, (string) ( $current + 1 ), 0 ); 1244 2110 } catch ( \Throwable $e ) { 1245 return false; 1246 } 2111 $seeded = false; 2112 } 2113 $new = $seeded ? $current + 1 : $this->increment_counter( $key, 1 ); 2114 } 2115 2116 // A re-seeded counter may be below the database floor. Increment 2117 // by the difference so a concurrent flush is never overwritten. 2118 if ( false !== $new && $new <= $current ) { 2119 $new = $this->increment_counter( $key, $current + 1 - $new ); 2120 } 2121 2122 if ( false === $new || $new <= $current ) { 2123 return false; 1247 2124 } 1248 2125 … … 1253 2130 1254 2131 /** 2132 * Atomically add to a counter on whichever client is connected. 2133 * 2134 * @param string $key Counter key. 2135 * @param int $by Amount to add. 2136 * @return int|false The new value, or false when the counter write fails. 2137 */ 2138 private function increment_counter( $key, $by ) { 2139 try { 2140 if ( 'builtin-memcached' === $this->client ) { 2141 $new = $this->conn->incr( $key, $by ); 2142 } else { 2143 $new = $this->conn->increment( $key, $by ); 2144 } 2145 } catch ( \Throwable $e ) { 2146 return false; 2147 } 2148 return is_numeric( $new ) ? (int) $new : false; 2149 } 2150 2151 /** 1255 2152 * The counter key holding this site's namespace generation. Salted, so 1256 2153 * each site owns its own counter on a shared daemon. … … 1259 2156 */ 1260 2157 private function generation_key() { 1261 // Deliberately OUTSIDE the `{salt}:` namespace: Redis flushes by 1262 // deleting everything matching `{salt}:*`, which would otherwise 1263 // sweep away this invalidation marker if a site were reconfigured 1264 // from memcached to redis and back. 2158 if ( 'redis' === $this->backend ) { 2159 return $this->salt . self::REDIS_COUNTER_SUFFIX; 2160 } 2161 // Memcached keeps the key it has always used, so an upgrade does 2162 // not lose the generation a site is on. 1265 2163 return $this->salt . '.xspeed-oc-gen'; 1266 2164 } 1267 2165 1268 /**1269 * The lowest generation this site may use, read from the database.1270 *1271 * Memcached can evict the counter at any time; the database cannot, so1272 * this is what stops an eviction from silently rewinding the namespace1273 * and resurrecting keys a Purge already cleared.1274 *1275 * Read straight through $wpdb rather than get_option(), because the1276 * options API routes through this very cache and would recurse. Returns1277 * 1 whenever the database is not available yet — the drop-in loads1278 * before $wpdb exists, and on those early requests nothing has been1279 * flushed anyway.1280 *1281 * @return int1282 */1283 2166 /** 1284 2167 * The options table holding the generation floor, or '' when the … … 1317 2200 } 1318 2201 2202 /** 2203 * The lowest generation this site may use, read from the database. 2204 * 2205 * The counter lives in the cache it namespaces, and either backend 2206 * can evict it; the database cannot, so this is what stops an 2207 * eviction from rewinding the namespace and resurrecting keys a Purge 2208 * already cleared. 2209 * 2210 * Read straight through $wpdb rather than get_option(), because the 2211 * options API routes through this very cache and would recurse. Returns 2212 * 1 whenever the database is not available yet; the drop-in loads 2213 * before $wpdb exists, and on those early requests nothing has been 2214 * flushed anyway. 2215 * 2216 * @return int 2217 */ 1319 2218 private function generation_floor() { 1320 2219 if ( null !== $this->generation_floor ) { … … 1324 2223 $this->generation_floor = 1; 1325 2224 1326 if ( 'memcached' !== $this->backend ||! isset( $GLOBALS['wpdb'] ) ) {2225 if ( ! isset( $GLOBALS['wpdb'] ) ) { 1327 2226 return $this->generation_floor; 1328 2227 } … … 1355 2254 * counter cannot rewind past it. 1356 2255 * 2256 * The upsert only ever raises the stored value: two flushes finishing 2257 * out of order must not leave the lower generation as the floor. 2258 * 1357 2259 * @param int $generation The generation just written. 1358 2260 * @return void 1359 2261 */ 1360 2262 private function persist_generation_floor( $generation ) { 1361 if ( 'memcached' !== $this->backend ||! isset( $GLOBALS['wpdb'] ) ) {2263 if ( ! isset( $GLOBALS['wpdb'] ) ) { 1362 2264 return; 1363 2265 } … … 1376 2278 "INSERT INTO {$table} (option_name, option_value, autoload) 1377 2279 VALUES (%s, %s, 'no') 1378 ON DUPLICATE KEY UPDATE option_value = VALUES(option_value)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is derived from $wpdb, never user input.2280 ON DUPLICATE KEY UPDATE option_value = IF( CAST( option_value AS UNSIGNED ) < CAST( VALUES(option_value) AS UNSIGNED ), VALUES(option_value), option_value )", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is derived from $wpdb, never user input. 1379 2281 self::GENERATION_OPTION, 1380 2282 (string) (int) $generation 1381 2283 ) 1382 2284 ); 1383 $this->generation_floor = (int) $generation;2285 $this->generation_floor = max( (int) $this->generation_floor, (int) $generation ); 1384 2286 } catch ( \Throwable $e ) { 1385 2287 // Best effort: the cached counter still carries the flush for 1386 // as long as it survives. 2288 // as long as it survives, and a re-seeded Redis counter starts 2289 // from the clock. 1387 2290 return; 1388 2291 } … … 1391 2294 /** 1392 2295 * Read this site's namespace generation, once per request. 1393 *1394 * Only meaningful for Memcached, where flushing works by advancing the1395 * generation rather than deleting keys. Redis deletes by pattern, so it1396 * stays on generation 1 and its key shape is unchanged.1397 2296 * 1398 2297 * @return int … … 1400 2299 private function generation() { 1401 2300 if ( null !== $this->generation ) { 2301 return $this->generation; 2302 } 2303 2304 if ( 'redis' === $this->backend ) { 2305 $this->generation = $this->persistent && $this->conn ? $this->resolve_redis_generation() : 1; 1402 2306 return $this->generation; 1403 2307 } … … 1457 2361 1458 2362 /** 1459 * Delete every Redis key matching $pattern across both client kinds 1460 * (phpredis native scan + our pure-PHP Redis_Client). Returns the 1461 * count deleted, or -1 if the backend isn't redis. SCAN-based so it 1462 * never blocks the server the way KEYS would. (FBS-83119) 1463 * 1464 * Callers MUST pass any literal segment through escape_glob() — this 1465 * receives a finished pattern and cannot tell wildcard from data. 1466 */ 1467 private function delete_redis_pattern( $pattern ) { 2363 * Delete matching Redis keys, or return -1 if any scan or deletion fails. 2364 * Literal glob segments must pass through escape_glob(). (FBS-83119) 2365 */ 2366 private function delete_redis_pattern( $pattern, $filter ) { 1468 2367 if ( 'redis' !== $this->backend || ! $this->conn ) { 1469 2368 return -1; 1470 2369 } 1471 // Our pure-PHP client. 1472 if ( method_exists( $this->conn, 'delete_by_pattern' ) ) { 1473 return $this->conn->delete_by_pattern( $pattern ); 1474 } 1475 // phpredis: iterate the SCAN cursor (setOption SCAN_RETRY keeps it 1476 // simple — scan() returns false when the cursor is exhausted). 1477 if ( $this->conn instanceof \Redis ) { 1478 $deleted = 0; 1479 $it = null; 1480 if ( defined( '\Redis::SCAN_RETRY' ) ) { 1481 $this->conn->setOption( \Redis::OPT_SCAN, \Redis::SCAN_RETRY ); 1482 } 1483 do { 1484 $keys = $this->conn->scan( $it, $pattern, 500 ); 1485 if ( is_array( $keys ) && ! empty( $keys ) ) { 1486 $deleted += (int) $this->conn->del( $keys ); 1487 } 1488 } while ( $it > 0 ); 1489 return $deleted; 2370 try { 2371 if ( method_exists( $this->conn, 'delete_by_pattern' ) ) { 2372 $deleted = $this->conn->delete_by_pattern( $pattern, 500, $filter ); 2373 return false === $deleted ? -1 : $deleted; 2374 } 2375 if ( $this->conn instanceof \Redis ) { 2376 $deleted = 0; 2377 $cursor = '0'; 2378 do { 2379 // scan() conflates an empty page with failure. The raw 2380 // reply keeps the cursor and keys separate in both cases. 2381 $reply = $this->conn->rawCommand( 'SCAN', $cursor, 'MATCH', $pattern, 'COUNT', '500' ); 2382 if ( ! is_array( $reply ) || count( $reply ) !== 2 || ! is_string( $reply[0] ) || ! ctype_digit( $reply[0] ) || ! is_array( $reply[1] ) ) { 2383 return -1; 2384 } 2385 foreach ( $reply[1] as $key ) { 2386 if ( ! is_string( $key ) ) { 2387 return -1; 2388 } 2389 } 2390 $cursor = $reply[0]; 2391 $keys = preg_grep( $filter, $reply[1] ); 2392 if ( ! empty( $keys ) ) { 2393 $keys = array_values( $keys ); 2394 $n = $this->conn->unlink( $keys ); 2395 // Redis before 4.0 has no UNLINK; any other error is a failure. 2396 if ( false === $n && 0 === strpos( (string) $this->conn->getLastError(), 'ERR unknown command' ) ) { 2397 $this->conn->clearLastError(); 2398 $n = $this->conn->del( $keys ); 2399 } 2400 if ( false === $n ) { 2401 return -1; 2402 } 2403 $deleted += $n; 2404 } 2405 } while ( '0' !== $cursor ); 2406 return $deleted; 2407 } 2408 } catch ( \Throwable $e ) { 2409 $this->record_flush_failure( $e->getMessage() ); 2410 return -1; 1490 2411 } 1491 2412 return -1; … … 1520 2441 // wp_cache_flush_group) stops serving stale data — previously this 1521 2442 // was a runtime-only no-op against the backend. The key layout is 1522 // salt:{prefix}:{group}:{key}, so match salt:*:{group}:* to cover 1523 // both blog-prefixed and global groups for this site's namespace. 2443 // salt:g{gen}:{prefix}:{group}:{key}, so match 2444 // salt:g{gen}:*:{group}:* to cover both blog-prefixed and global 2445 // groups for this site's namespace. 1524 2446 // (FBS-83119) 1525 if ( $this->is_persistent_group( $group ) && 'redis' === $this->backend && '' !== $this->salt ) { 2447 // Only the current generation holds live values; older ones are 2448 // already unreachable and the reclaim event deletes them. The 2449 // counter is read again first, since another request may have 2450 // flushed since this one resolved it. A flush landing during the 2451 // scan retires the whole generation, this group included. 2452 if ( $this->is_persistent_group( $group ) && 'redis' === $this->backend && '' !== $this->salt && $this->conn ) { 1526 2453 try { 1527 $this->delete_redis_pattern( 1528 $this->escape_glob( $this->salt ) . ':*:' . $this->escape_glob( $group ) . ':*' 2454 $gen = $this->refresh_generation(); 2455 return -1 !== $this->delete_redis_pattern( 2456 $this->escape_glob( $this->salt ) . ':g' . $gen . ':*:' . $this->escape_glob( $group ) . ':*', 2457 '/\A' . preg_quote( $this->salt, '/' ) . ':g' . $gen . ':[0-9]+:' . preg_quote( $group, '/' ) . ':/' 1529 2458 ); 1530 2459 } catch ( \Throwable $e ) {
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)