Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
90.74% |
147 / 162 |
|
68.75% |
11 / 16 |
CRAP | |
0.00% |
0 / 1 |
| ChronologyProtector | |
90.74% |
147 / 162 |
|
68.75% |
11 / 16 |
63.95 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
2 | |||
| load | |
100.00% |
25 / 25 |
|
100.00% |
1 / 1 |
8 | |||
| setRequestInfo | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
| getClientId | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| setEnabled | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getSessionPrimaryPos | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
| stageSessionPrimaryPos | |
85.71% |
12 / 14 |
|
0.00% |
0 / 1 |
5.07 | |||
| persistSessionReplicationPositions | |
85.71% |
24 / 28 |
|
0.00% |
0 / 1 |
5.07 | |||
| getTouched | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
3 | |||
| getStartupSessionPositions | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| lazyStartup | |
70.83% |
17 / 24 |
|
0.00% |
0 / 1 |
9.59 | |||
| mergePositions | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
5 | |||
| marshalPositions | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| unmarshalPositions | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
4 | |||
| makeCookieValueFromCPIndex | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getCPInfoFromCookieValue | |
91.67% |
11 / 12 |
|
0.00% |
0 / 1 |
9.05 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * @license GPL-2.0-or-later |
| 4 | * @file |
| 5 | */ |
| 6 | namespace Wikimedia\Rdbms; |
| 7 | |
| 8 | use LogicException; |
| 9 | use Psr\Log\LoggerInterface; |
| 10 | use Psr\Log\NullLogger; |
| 11 | use Wikimedia\ObjectCache\BagOStuff; |
| 12 | use 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 | */ |
| 119 | class 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 | } |