Plugin Directory


Ignore:
Timestamp:
11/12/2026 01:25:30 PM (less than one hour ago)
Author:
wpdevteam
Message:

Update to version 1.4.8 from GitHub

File:
1 edited

Legend:

Unmodified
Added
Removed
  • xspeed/trunk/includes/object-cache.php

    r3740781 r3798397  
    501501
    502502        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 );
    508504        }
    509505
    … …  
    517513
    518514        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 );
    524516        }
    525517
    … …  
    537529
    538530        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 );
    544532        }
    545533
    … …  
    623611                private $salt = '';
    624612
    625                 /** Option holding the Memcached generation floor (see generation_floor()). */
     613                /** Option holding the generation floor (see generation_floor()). */
    626614                const GENERATION_OPTION = 'xspeed_oc_generation';
    627615
    628616                /**
    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.
    633630                 */
    634631                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;
    635641
    636642                /**
    … …  
    923929                }
    924930
     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                 */
    925937                private function full_key( $key, $group ) {
    926938                        $group  = $this->group( $group );
    927939                        $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;
    934964                }
    935965
    … …  
    963993                                        }
    964994                                        $payload = maybe_serialize( $data );
    965                                         $stored  = $this->conn->add( $id, $payload, (int) $expire );
     995                                        $stored  = $this->backend_add( $id, $payload, (int) $expire );
    966996                                        if ( ! $stored ) {
    967997                                                return false; // key already exists in the backend.
    … …  
    9991029                                        $payload = maybe_serialize( $data );
    10001030                                        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];
    10041040                                        } else {
    1005                                                 $stored = (bool) $this->conn->set( $id, $payload, (int) $expire );
     1041                                                $stored = (bool) $this->conn->set( $this->bid( $id ), $payload, (int) $expire );
    10061042                                        }
    10071043                                } catch ( \Throwable $e ) {
    … …  
    10541090                        if ( $this->is_persistent_group( $group ) ) {
    10551091                                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                                        }
    10571098                                        if ( false !== $raw && null !== $raw ) {
    10581099                                                $val                = maybe_unserialize( $raw );
    … …  
    10721113                }
    10731114
     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                 */
    10741122                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 ) {
    10751226                        $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;
    10781316                        }
    10791317                        return $out;
    … …  
    11341372                }
    11351373
     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
    11361385                public function flush() {
     1386                        $this->flush_failure = '';
    11371387                        $this->cache = array();
    1138                         if ( $this->persistent ) {
     1388                        if ( $this->persistent && $this->conn ) {
    11391389                                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;
    11411398                                } catch ( \Throwable $e ) {
     1399                                        $this->record_flush_failure( $e->getMessage() );
    11421400                                        return false;
    11431401                                }
    … …  
    11531411                private function backend_delete( $id ) {
    11541412                        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 );
    11581582                }
    11591583
    11601584                private function backend_incr( $id, $offset ) {
    11611585                        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;
    11631588                        }
    11641589                        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 );
    11671592                }
    11681593
    11691594                private function backend_decr( $id, $offset ) {
    11701595                        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;
    11721598                        }
    11731599                        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                 */
    11781910                private function backend_flush() {
    11791911                        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)
    11931913                                if ( '' === $this->salt ) {
    11941914                                        return false;
    11951915                                }
    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.
    12051921                        return $this->bump_generation();
    12061922                }
    12071923
    12081924                /**
     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                /**
    12092092                 * 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.
    12112094                 *
    12122095                 * @return bool
    12132096                 */
    12142097                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 ) {
    12352107                                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 );
    12442110                                } 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;
    12472124                        }
    12482125
    … …  
    12532130
    12542131                /**
     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                /**
    12552152                 * The counter key holding this site's namespace generation. Salted, so
    12562153                 * each site owns its own counter on a shared daemon.
    … …  
    12592156                 */
    12602157                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.
    12652163                        return $this->salt . '.xspeed-oc-gen';
    12662164                }
    12672165
    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, so
    1272                  * this is what stops an eviction from silently rewinding the namespace
    1273                  * and resurrecting keys a Purge already cleared.
    1274                  *
    1275                  * Read straight through $wpdb rather than get_option(), because the
    1276                  * options API routes through this very cache and would recurse. Returns
    1277                  * 1 whenever the database is not available yet — the drop-in loads
    1278                  * before $wpdb exists, and on those early requests nothing has been
    1279                  * flushed anyway.
    1280                  *
    1281                  * @return int
    1282                  */
    12832166                /**
    12842167                 * The options table holding the generation floor, or '' when the
    … …  
    13172200                }
    13182201
     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                 */
    13192218                private function generation_floor() {
    13202219                        if ( null !== $this->generation_floor ) {
    … …  
    13242223                        $this->generation_floor = 1;
    13252224
    1326                         if ( 'memcached' !== $this->backend || ! isset( $GLOBALS['wpdb'] ) ) {
     2225                        if ( ! isset( $GLOBALS['wpdb'] ) ) {
    13272226                                return $this->generation_floor;
    13282227                        }
    … …  
    13552254                 * counter cannot rewind past it.
    13562255                 *
     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                 *
    13572259                 * @param int $generation The generation just written.
    13582260                 * @return void
    13592261                 */
    13602262                private function persist_generation_floor( $generation ) {
    1361                         if ( 'memcached' !== $this->backend || ! isset( $GLOBALS['wpdb'] ) ) {
     2263                        if ( ! isset( $GLOBALS['wpdb'] ) ) {
    13622264                                return;
    13632265                        }
    … …  
    13762278                                                "INSERT INTO {$table} (option_name, option_value, autoload)
    13772279                                                 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.
    13792281                                                self::GENERATION_OPTION,
    13802282                                                (string) (int) $generation
    13812283                                        )
    13822284                                );
    1383                                 $this->generation_floor = (int) $generation;
     2285                                $this->generation_floor = max( (int) $this->generation_floor, (int) $generation );
    13842286                        } catch ( \Throwable $e ) {
    13852287                                // 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.
    13872290                                return;
    13882291                        }
    … …  
    13912294                /**
    13922295                 * Read this site's namespace generation, once per request.
    1393                  *
    1394                  * Only meaningful for Memcached, where flushing works by advancing the
    1395                  * generation rather than deleting keys. Redis deletes by pattern, so it
    1396                  * stays on generation 1 and its key shape is unchanged.
    13972296                 *
    13982297                 * @return int
    … …  
    14002299                private function generation() {
    14012300                        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;
    14022306                                return $this->generation;
    14032307                        }
    … …  
    14572361
    14582362                /**
    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 ) {
    14682367                        if ( 'redis' !== $this->backend || ! $this->conn ) {
    14692368                                return -1;
    14702369                        }
    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;
    14902411                        }
    14912412                        return -1;
    … …  
    15202441                        // wp_cache_flush_group) stops serving stale data — previously this
    15212442                        // 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.
    15242446                        // (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 ) {
    15262453                                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, '/' ) . ':/'
    15292458                                        );
    15302459                                } catch ( \Throwable $e ) {
Note: See TracChangeset for help on using the changeset viewer.