Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.74% covered (success)
90.74%
147 / 162
68.75% covered (warning)
68.75%
11 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChronologyProtector
90.74% covered (success)
90.74%
147 / 162
68.75% covered (warning)
68.75%
11 / 16
63.95
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 load
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
8
 setRequestInfo
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getClientId
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setEnabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSessionPrimaryPos
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 stageSessionPrimaryPos
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
5.07
 persistSessionReplicationPositions
85.71% covered (warning)
85.71%
24 / 28
0.00% covered (danger)
0.00%
0 / 1
5.07
 getTouched
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 getStartupSessionPositions
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 lazyStartup
70.83% covered (warning)
70.83%
17 / 24
0.00% covered (danger)
0.00%
0 / 1
9.59
 mergePositions
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 marshalPositions
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 unmarshalPositions
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 makeCookieValueFromCPIndex
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCPInfoFromCookieValue
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
9.05
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6namespace Wikimedia\Rdbms;
7
8use LogicException;
9use Psr\Log\LoggerInterface;
10use Psr\Log\NullLogger;
11use Wikimedia\ObjectCache\BagOStuff;
12use Wikimedia\ObjectCache\EmptyBagOStuff;
13
14/**
15 * Provide a given client with protection against visible database lag.
16 *
17 * ### In a nut shell
18 *
19 * This class tries to hide visible effects of database lag. It does this by temporarily remembering
20 * the database positions after a client makes a write, and on their next web request we will prefer
21 * non-lagged database replicas. When replica connections are established, we wait up to a few seconds
22 * for sufficient replication to have occurred, if they were not yet caught up to that same point.
23 *
24 * This ensures a consistent ordering of events as seen by a client. Kind of like Hawking's
25 * [Chronology Protection Agency](https://en.wikipedia.org/wiki/Chronology_protection_conjecture).
26 *
27 * ### Purpose
28 *
29 * For performance and scalability reasons, almost all data is queried from replica databases.
30 * Only queries relating to writing data, are sent to a primary database. When rendering a web page
31 * with content or activity feeds on it, the very latest information may thus not yet be there.
32 * That's okay in general, but if, for example, a client recently changed their preferences or
33 * submitted new data, we do our best to make sure their next web response does reflect at least
34 * their own recent changes.
35 *
36 * ### How
37 *
38 * To explain how it works, we will look at an example lifecycle for a client.
39 *
40 * A client is browsing the site. Their web requests are generally read-only and display data from
41 * database replicas, which may be a few seconds out of date if a client elsewhere in the world
42 * recently modified that same data. If the application is run from multiple data centers, then
43 * these web requests may be served from the nearest secondary DC.
44 *
45 * A client performs a POST request, perhaps to publish an edit or change their preferences. This
46 * request is routed to the primary DC (this is the responsibility of infrastructure outside
47 * the web app). There, the data is saved to the primary database, after which the database
48 * host will asynchronously replicate this to its replicas in the same and any other DCs.
49 *
50 * Toward the end of the response to this POST request, the application takes note of the primary
51 * database's current "position", and save this under a "clientId" key in the ChronologyProtector
52 * store. The web response will also set two cookies that are similarly short-lived (about ten
53 * seconds): `UseDC=master` and `cpPosIndex=<posIndex>@<write time>#<clientId>`.
54 *
55 * The ten seconds window is meant to account for the time needed for the database writes to have
56 * replicated across all active database replicas, including the cross-dc latency for those
57 * further away in any secondary DCs. The "clientId" is placed in the cookie to handle the case
58 * where the client IP addresses frequently changes between web requests.
59 *
60 * Future web requests from the client should fall in one of two categories:
61 *
62 * 1. Within the ten second window. Their UseDC cookie will make them return
63 *    to the primary DC where we access the ChronologyProtector store and use
64 *    the database "position" to decide which local database replica to use
65 *    and on-demand wait a split second for replication to catch up if needed.
66 * 2. After the ten second window. They will be routed to the nearest and
67 *    possibly different DC. Any local ChronologyProtector store existing there
68 *    will not be interacted with. A random database replica may be used as
69 *    the client's own writes are expected to have been applied here by now.
70 *
71 * @anchor ChronologyProtector-storage-requirements
72 *
73 * ### Storage requirements
74 *
75 * The store used by ChronologyProtector, as configured via {@link $wgMicroStashType},
76 * should meet the following requirements:
77 *
78 * - Low latencies. Nearly all web requests that involve a database connection will
79 *   unconditionally query this store first. It is expected to respond within the order
80 *   of one millisecond.
81 * - Best effort persistence, without active eviction pressure. Data stored here cannot be
82 *   obtained elsewhere or recomputed. As such, under normal operating conditions, this store
83 *   should not be full, and should not evict values before their intended expiry time elapsed.
84 * - No replication, local consistency. Each DC may have a fully independent dc-local store
85 *   associated with ChronologyProtector (no replication across DCs is needed). Local writes
86 *   must be immediately reflected in subsequent local reads. No intra-dc read lag is allowed.
87 * - No redundancy, fast failure. Loss of data will likely be noticeable and disruptive to
88 *   clients, but the data is not considered essential. Under maintenance or unprecedented load,
89 *   it is recommended to lose some data, instead of compromising other requirements such as
90 *   latency or availability for new writes. The fallback is that users may be temporary
91 *   confused as they observe their own actions as not being immediately reflected.
92 *   For example, they might change their skin or language preference but still get a one or two
93 *   page views afterward with the old settings. Or they might have published an edit and briefly
94 *   not yet see it appear in their contribution history.
95 *
96 * ### Operational requirements
97 *
98 * These are the expectations a site administrator must meet for chronology protection:
99 *
100 * - If the application is run from multiple data centers, then you must designate one of them
101 *   as the "primary DC". The primary DC is where the primary database is located, from which
102 *   replication propagates to replica databases in that same DC and any other DCs.
103 *
104 * - Web requests that use the POST verb, or carry a `UseDC=master` cookie, must be routed to
105 *   the primary DC only.
106 *
107 *   An exception is requests carrying the `Promise-Non-Write-API-Action: true` header,
108 *   which use the POST verb for large read queries, but don't actually require the primary DC.
109 *
110 *   If you have legacy extensions deployed that perform queries on the primary database during
111 *   GET requests, then you will have to identify a way to route any of its relevant URLs to the
112 *   primary DC as well, or to accept that their reads do not enjoy chronology protection, and
113 *   that writes may be slower (due to cross-dc latency).
114 *   See [T91820](https://phabricator.wikimedia.org/T91820) for %Wikimedia Foundation's routing.
115 *
116 * @ingroup Database
117 * @internal
118 */
119class ChronologyProtector {
120    /** @var array Web request information about the client */
121    private $requestInfo;
122    /** @var string Secret string for HMAC hashing */
123    private string $secret;
124    private bool $cliMode;
125    /** @var BagOStuff */
126    private $store;
127    /** @var LoggerInterface */
128    protected $logger;
129
130    /** @var string Storage key name */
131    protected $key;
132    /** @var string Hash of client parameters */
133    protected $clientId;
134    /** @var string[] Map of client information fields for logging */
135    protected $clientLogInfo;
136    /** @var int|null Expected minimum index of the last write to the position store */
137    protected $waitForPosIndex;
138
139    /** @var bool Whether reading/writing session consistency replication positions is enabled */
140    protected $enabled = true;
141
142    /** @var array<string,DBPrimaryPos|null> Map of (primary server name => position) */
143    protected $startupPositionsByPrimary = null;
144    /** @var array<string,DBPrimaryPos|null> Map of (primary server name => position) */
145    protected $shutdownPositionsByPrimary = [];
146
147    /**
148     * Whether a clientId is new during this request.
149     *
150     * If the clientId wasn't passed by the incoming request, lazyStartup()
151     * can skip fetching position data, and thus LoadBalancer can skip
152     * its IDatabaseForOwner::primaryPosWait() call.
153     *
154     * See also: <https://phabricator.wikimedia.org/T314434>
155     *
156     * @var bool
157     */
158    private $hasNewClientId = false;
159
160    /** Seconds to store position write index cookies (safely less than POSITION_STORE_TTL) */
161    public const POSITION_COOKIE_TTL = 10;
162    /** Seconds to store replication positions */
163    private const POSITION_STORE_TTL = 60;
164
165    /** Lock timeout to use for key updates */
166    private const LOCK_TIMEOUT = 3;
167    /** Lock expiry to use for key updates */
168    private const LOCK_TTL = 6;
169
170    private const FLD_POSITIONS = 'positions';
171    private const FLD_WRITE_INDEX = 'writeIndex';
172
173    /**
174     * @param BagOStuff|null $cpStash
175     * @param string|null $secret Secret string for HMAC hashing [optional]
176     * @param bool|null $cliMode Whether the context is CLI or not, setting it to true would disable CP
177     * @param LoggerInterface|null $logger
178     * @since 1.27
179     */
180    public function __construct( $cpStash = null, $secret = null, $cliMode = null, $logger = null ) {
181        $this->requestInfo = [
182            'IPAddress' => $_SERVER['REMOTE_ADDR'] ?? '',
183            'UserAgent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
184            // Headers application can inject via LBFactory::setRequestInfo()
185            'ChronologyClientId' => null, // prior $cpClientId value from LBFactory::shutdown()
186            'ChronologyPositionIndex' => null // prior $cpIndex value from LBFactory::shutdown()
187        ];
188        $this->store = $cpStash ?? new EmptyBagOStuff();
189        $this->secret = $secret ?? '';
190        $this->logger = $logger ?? new NullLogger();
191        $this->cliMode = $cliMode ?? ( PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg' );
192    }
193
194    private function load() {
195        // Not enabled or already loaded, short-circuit.
196        if ( !$this->enabled || $this->clientId ) {
197            return;
198        }
199        $client = [
200            'ip' => $this->requestInfo['IPAddress'],
201            'agent' => $this->requestInfo['UserAgent'],
202            'clientId' => $this->requestInfo['ChronologyClientId'] ?: null
203        ];
204        if ( $this->cliMode ) {
205            $this->setEnabled( false );
206        } elseif ( $this->store instanceof EmptyBagOStuff ) {
207            // No where to store any DB positions and wait for them to appear
208            $this->setEnabled( false );
209            $this->logger->debug( 'Cannot use ChronologyProtector with EmptyBagOStuff' );
210        }
211
212        if ( isset( $client['clientId'] ) ) {
213            $this->clientId = $client['clientId'];
214        } else {
215            $this->hasNewClientId = true;
216            $this->clientId = ( $this->secret != '' )
217                ? hash_hmac( 'md5', $client['ip'] . "\n" . $client['agent'], $this->secret )
218                : md5( $client['ip'] . "\n" . $client['agent'] );
219        }
220        $this->key = $this->store->makeGlobalKey( __CLASS__, $this->clientId, 'v4' );
221        $this->waitForPosIndex = $this->requestInfo['ChronologyPositionIndex'];
222
223        $this->clientLogInfo = [
224            'clientIP' => $client['ip'],
225            'clientAgent' => $client['agent'],
226            'clientId' => $client['clientId'] ?? null
227        ];
228    }
229
230    public function setRequestInfo( array $info ) {
231        if ( $this->clientId ) {
232            throw new LogicException( 'ChronologyProtector already initialized' );
233        }
234
235        $this->requestInfo = $info + $this->requestInfo;
236    }
237
238    /**
239     * @return string Client ID hash
240     * @since 1.32
241     */
242    public function getClientId() {
243        $this->load();
244        return $this->clientId;
245    }
246
247    /**
248     * @param bool $enabled Whether reading/writing session replication positions is enabled
249     * @since 1.27
250     */
251    public function setEnabled( $enabled ) {
252        $this->enabled = $enabled;
253    }
254
255    /**
256     * Yield client "session consistency" replication position for a new ILoadBalancer
257     *
258     * If the stash has a previous primary position recorded, this will try to make
259     * sure that the next query to a replica server of that primary will see changes up
260     * to that position by delaying execution. The delay may timeout and allow stale
261     * data if no non-lagged replica servers are available.
262     *
263     * @internal This method should only be called from LBFactory.
264     *
265     * @param ILoadBalancer $lb
266     * @return DBPrimaryPos|null
267     */
268    public function getSessionPrimaryPos( ILoadBalancer $lb ) {
269        $this->load();
270        if ( !$this->enabled ) {
271            return null;
272        }
273
274        $cluster = $lb->getClusterName();
275        $primaryName = $lb->getServerName( ServerInfo::WRITER_INDEX );
276
277        $pos = $this->getStartupSessionPositions()[$primaryName] ?? null;
278        if ( $pos instanceof DBPrimaryPos ) {
279            $this->logger->debug( "ChronologyProtector will wait for '$pos' on $cluster ($primaryName)'" );
280        } else {
281            $this->logger->debug( "ChronologyProtector skips wait on $cluster ($primaryName)" );
282        }
283
284        return $pos;
285    }
286
287    /**
288     * Update client "session consistency" replication position for an end-of-life ILoadBalancer
289     *
290     * This remarks the replication position of the primary DB if this request made writes to
291     * it using the provided ILoadBalancer instance.
292     *
293     * @internal This method should only be called from LBFactory.
294     *
295     * @param ILoadBalancer $lb
296     * @return void
297     */
298    public function stageSessionPrimaryPos( ILoadBalancer $lb ) {
299        $this->load();
300        if ( !$this->enabled || !$lb->hasOrMadeRecentPrimaryChanges( INF ) ) {
301            return;
302        }
303
304        $cluster = $lb->getClusterName();
305        $masterName = $lb->getServerName( ServerInfo::WRITER_INDEX );
306
307        // If LoadBalancer::hasStreamingReplicaServers is false (single DB host),
308        // or if the database type has no replication (i.e. SQLite), then we do not need to
309        // save any position data, as it would never be loaded or waited for. When we save this
310        // data, it is for DB_REPLICA queries in future requests, which load it via
311        // ChronologyProtector::getSessionPrimaryPos (from LoadBalancer::getReaderIndex)
312        // and wait for that position. In a single-server setup, all queries go the primary DB.
313        //
314        // In that case we still store a null value, so that ChronologyProtector::getTouched
315        // reliably detects recent writes for non-database purposes,
316        // such as ParserOutputAccess/PoolWorkArticleViewCurrent. This also makes getTouched()
317        // easier to setup and test, as we it work work always once CP is enabled
318        // (e.g. wgMicroStashType or wgMainCacheType set to a non-DB cache).
319        if ( $lb->hasStreamingReplicaServers() ) {
320            $pos = $lb->getPrimaryPos();
321            if ( $pos ) {
322                $this->logger->debug( __METHOD__ . "$cluster ($masterName) position now '$pos'" );
323                $this->shutdownPositionsByPrimary[$masterName] = $pos;
324            } else {
325                $this->logger->debug( __METHOD__ . "$cluster ($masterName) position unknown" );
326                $this->shutdownPositionsByPrimary[$masterName] = null;
327            }
328        } else {
329            $this->logger->debug( __METHOD__ . "$cluster ($masterName) has no replication" );
330            $this->shutdownPositionsByPrimary[$masterName] = null;
331        }
332    }
333
334    /**
335     * Persist any staged client "session consistency" replication positions
336     *
337     * @internal This method should only be called from LBFactory.
338     *
339     * @param int|null &$clientPosIndex DB position key write counter; incremented on update
340     * @return DBPrimaryPos[] Empty on success; map of (db name => unsaved position) on failure
341     */
342    public function persistSessionReplicationPositions( &$clientPosIndex = null ) {
343        $this->load();
344        if ( !$this->enabled ) {
345            return [];
346        }
347
348        if ( !$this->shutdownPositionsByPrimary ) {
349            $this->logger->debug( __METHOD__ . ": no primary positions data to save" );
350
351            return [];
352        }
353
354        $scopeLock = $this->store->getScopedLock( $this->key, self::LOCK_TIMEOUT, self::LOCK_TTL );
355        if ( $scopeLock ) {
356            $positions = $this->mergePositions(
357                $this->unmarshalPositions( $this->store->get( $this->key ) ),
358                $this->shutdownPositionsByPrimary,
359                $clientPosIndex
360            );
361
362            $ok = $this->store->set(
363                $this->key,
364                $this->marshalPositions( $positions ),
365                self::POSITION_STORE_TTL
366            );
367            unset( $scopeLock );
368        } else {
369            $ok = false;
370        }
371
372        $primaryList = implode( ', ', array_keys( $this->shutdownPositionsByPrimary ) );
373
374        if ( $ok ) {
375            $this->logger->debug( "ChronologyProtector saved position data for $primaryList" );
376            $bouncedPositions = [];
377        } else {
378            // Maybe position store is down
379            $this->logger->warning( "ChronologyProtector failed to save position data for $primaryList" );
380            $clientPosIndex = null;
381            $bouncedPositions = $this->shutdownPositionsByPrimary;
382        }
383
384        return $bouncedPositions;
385    }
386
387    /**
388     * Whether the request came from a client that recently made database changes (last 10 seconds).
389     *
390     * When a user saves an edit or makes other changes to the database, the response to that
391     * request contains a short-lived ChronologyProtector cookie (or cpPosIndex query parameter).
392     *
393     * If we find such cookie on the current request,
394     * and we find any corresponding database positions in the MicroStash,
395     * and they are not expired,
396     * then we return true.
397     *
398     * @since 1.28 Changed parameter in 1.35. Removed parameter in 1.44.
399     * @return bool
400     */
401    public function getTouched() {
402        $this->load();
403
404        if ( !$this->enabled ) {
405            return false;
406        }
407
408        if ( $this->getStartupSessionPositions() ) {
409            $this->logger->debug( __METHOD__ . ": found recent writes" );
410            return true;
411        }
412
413        $this->logger->debug( __METHOD__ . ": found no recent writes" );
414        return false;
415    }
416
417    /**
418     * @return array<string,DBPrimaryPos|null>
419     */
420    protected function getStartupSessionPositions() {
421        $this->lazyStartup();
422
423        return $this->startupPositionsByPrimary;
424    }
425
426    /**
427     * Load the stored replication positions and touch timestamps for the client
428     *
429     * @return void
430     */
431    protected function lazyStartup() {
432        if ( $this->startupPositionsByPrimary !== null ) {
433            return;
434        }
435
436        // There wasn't a client id in the cookie so we built one
437        // There is no point in looking it up.
438        if ( $this->hasNewClientId ) {
439            $this->startupPositionsByPrimary = [];
440            return;
441        }
442
443        $this->logger->debug( 'ChronologyProtector using store ' . get_class( $this->store ) );
444        $this->logger->debug( "ChronologyProtector fetching positions for {$this->clientId}" );
445
446        $data = $this->unmarshalPositions( $this->store->get( $this->key ) );
447
448        $this->startupPositionsByPrimary = $data ? $data[self::FLD_POSITIONS] : [];
449
450        // When a stored array expires and is re-created under the same (deterministic) key,
451        // the array value naturally starts again from index zero. As such, it is possible
452        // that if certain store writes were lost (e.g. store down), that we unintentionally
453        // point to an offset in an older incarnation of the array.
454        // We don't try to detect or do something about this because:
455        // 1. Waiting for an older offset is harmless and generally no-ops.
456        // 2. The older value will have expired by now and thus treated as non-existing,
457        //    which means we wouldn't even "see" it here.
458        $indexReached = is_array( $data ) ? $data[self::FLD_WRITE_INDEX] : null;
459        if ( $this->waitForPosIndex > 0 ) {
460            if ( $indexReached >= $this->waitForPosIndex ) {
461                $this->logger->debug( 'expected and found position index {cpPosIndex}', [
462                    'cpPosIndex' => $this->waitForPosIndex,
463                ] + $this->clientLogInfo );
464            } else {
465                $this->logger->warning( 'expected but failed to find position index {cpPosIndex}', [
466                    'cpPosIndex' => $this->waitForPosIndex,
467                    'indexReached' => $indexReached,
468                    'exception' => new \RuntimeException(),
469                ] + $this->clientLogInfo );
470            }
471        } else {
472            if ( $indexReached ) {
473                $this->logger->debug( 'found position data with index {indexReached}', [
474                    'indexReached' => $indexReached
475                ] + $this->clientLogInfo );
476            }
477        }
478    }
479
480    /**
481     * Merge the new replication positions with the currently stored ones (highest wins)
482     *
483     * @param array<string,mixed>|false $storedValue Current replication position data
484     * @param array<string,DBPrimaryPos> $shutdownPositions New replication positions
485     * @param int|null &$clientPosIndex New position write index
486     * @return array<string,mixed> Combined replication position data
487     */
488    protected function mergePositions(
489        $storedValue,
490        array $shutdownPositions,
491        ?int &$clientPosIndex = null
492    ) {
493        /** @var array<string,DBPrimaryPos> $mergedPositions */
494        $mergedPositions = $storedValue[self::FLD_POSITIONS] ?? [];
495        // Use the newest positions for each DB primary
496        foreach ( $shutdownPositions as $masterName => $pos ) {
497            if (
498                !isset( $mergedPositions[$masterName] ) ||
499                !( $mergedPositions[$masterName] instanceof DBPrimaryPos ) ||
500                $pos->asOfTime() > $mergedPositions[$masterName]->asOfTime()
501            ) {
502                $mergedPositions[$masterName] = $pos;
503            }
504        }
505
506        $clientPosIndex = ( $storedValue[self::FLD_WRITE_INDEX] ?? 0 ) + 1;
507
508        return [
509            self::FLD_POSITIONS => $mergedPositions,
510            self::FLD_WRITE_INDEX => $clientPosIndex
511        ];
512    }
513
514    private function marshalPositions( array $positions ): array {
515        foreach ( $positions[ self::FLD_POSITIONS ] as $key => $pos ) {
516            if ( $pos ) {
517                $positions[ self::FLD_POSITIONS ][ $key ] = $pos->toArray();
518            }
519        }
520
521        return $positions;
522    }
523
524    /**
525     * @param array|false $positions
526     * @return array|false
527     */
528    private function unmarshalPositions( $positions ) {
529        if ( !$positions ) {
530            return $positions;
531        }
532
533        foreach ( $positions[ self::FLD_POSITIONS ] as $key => $pos ) {
534            if ( $pos ) {
535                $class = $pos[ '_type_' ];
536                $positions[ self::FLD_POSITIONS ][ $key ] = $class::newFromArray( $pos );
537            }
538        }
539
540        return $positions;
541    }
542
543    /**
544     * Build a string conveying the client and write index of the chronology protector data
545     *
546     * @param int $writeIndex
547     * @param int $time UNIX timestamp; can be used to detect stale cookies (T190082)
548     * @param string $clientId Client ID hash from ILBFactory::shutdown()
549     * @return string Value to use for "cpPosIndex" cookie
550     * @since 1.32 in LBFactory, moved to CP in 1.41
551     */
552    public static function makeCookieValueFromCPIndex(
553        int $writeIndex,
554        int $time,
555        string $clientId
556    ) {
557        // Format is "<write index>@<write timestamp>#<client ID hash>"
558        return "{$writeIndex}@{$time}#{$clientId}";
559    }
560
561    /**
562     * Parse a string conveying the client and write index of the chronology protector data
563     *
564     * @param string|null $value Value of "cpPosIndex" cookie
565     * @param int $minTimestamp Lowest UNIX timestamp that a non-expired value can have
566     * @return array (index: int or null, clientId: string or null)
567     * @since 1.32 in LBFactory, moved to CP in 1.41
568     */
569    public static function getCPInfoFromCookieValue( ?string $value, int $minTimestamp ) {
570        static $placeholder = [ 'index' => null, 'clientId' => null ];
571
572        if ( $value === null ) {
573            return $placeholder; // not set
574        }
575
576        // Format is "<write index>@<write timestamp>#<client ID hash>"
577        if ( !preg_match( '/^(\d+)@(\d+)#([0-9a-f]{32})$/', $value, $m ) ) {
578            return $placeholder; // invalid
579        }
580
581        $index = (int)$m[1];
582        if ( $index <= 0 ) {
583            return $placeholder; // invalid
584        } elseif ( isset( $m[2] ) && $m[2] !== '' && (int)$m[2] < $minTimestamp ) {
585            return $placeholder; // expired
586        }
587
588        $clientId = ( isset( $m[3] ) && $m[3] !== '' ) ? $m[3] : null;
589
590        return [ 'index' => $index, 'clientId' => $clientId ];
591    }
592}