23use Psr\Log\LoggerAwareInterface;
24use Psr\Log\LoggerInterface;
25use Psr\Log\NullLogger;
136 private $requestInfo;
138 private string $secret;
139 private bool $cliMode;
169 private $wallClockOverride;
182 private $hasNewClientId =
false;
187 private const POSITION_STORE_TTL = 60;
190 private const LOCK_TIMEOUT = 3;
192 private const LOCK_TTL = 6;
194 private const FLD_POSITIONS =
'positions';
195 private const FLD_TIMESTAMPS =
'timestamps';
196 private const FLD_WRITE_INDEX =
'writeIndex';
206 $this->requestInfo = [
207 'IPAddress' => $_SERVER[
'REMOTE_ADDR'] ??
'',
208 'UserAgent' => $_SERVER[
'HTTP_USER_AGENT'] ??
'',
210 'ChronologyClientId' =>
null,
211 'ChronologyPositionIndex' =>
null
214 $this->secret = $secret ??
'';
215 $this->logger =
$logger ??
new NullLogger();
216 $this->cliMode = $cliMode ?? ( PHP_SAPI ===
'cli' || PHP_SAPI ===
'phpdbg' );
219 private function load() {
221 if ( !$this->enabled || $this->clientId ) {
225 'ip' => $this->requestInfo[
'IPAddress'],
226 'agent' => $this->requestInfo[
'UserAgent'],
227 'clientId' => $this->requestInfo[
'ChronologyClientId'] ?: null
229 if ( $this->cliMode ) {
231 } elseif ( $this->store instanceof EmptyBagOStuff ) {
234 $this->logger->debug(
'Cannot use ChronologyProtector with EmptyBagOStuff' );
237 if ( isset( $client[
'clientId'] ) ) {
238 $this->clientId = $client[
'clientId'];
240 $this->hasNewClientId =
true;
241 $this->clientId = ( $this->secret !=
'' )
242 ? hash_hmac(
'md5', $client[
'ip'] .
"\n" . $client[
'agent'], $this->secret )
243 : md5( $client[
'ip'] .
"\n" . $client[
'agent'] );
245 $this->key = $this->store->makeGlobalKey( __CLASS__, $this->clientId,
'v4' );
246 $this->waitForPosIndex = $this->requestInfo[
'ChronologyPositionIndex'];
248 $this->clientLogInfo = [
249 'clientIP' => $client[
'ip'],
250 'clientAgent' => $client[
'agent'],
251 'clientId' => $client[
'clientId'] ?? null
256 if ( $this->clientId ) {
257 throw new LogicException(
'ChronologyProtector already initialized' );
260 $this->requestInfo = $info + $this->requestInfo;
300 if ( !$this->enabled ) {
309 $this->logger->debug(
"ChronologyProtector will wait for '$pos' on $cluster ($primaryName)'" );
311 $this->logger->debug(
"ChronologyProtector skips wait on $cluster ($primaryName)" );
340 $this->logger->debug( __METHOD__ .
": $cluster ($masterName) position now '$pos'" );
341 $this->shutdownPositionsByPrimary[$masterName] = $pos;
342 $this->shutdownTimestampsByCluster[$cluster] = $pos->asOfTime();
344 $this->logger->debug( __METHOD__ .
": $cluster ($masterName) position unknown" );
345 $this->shutdownTimestampsByCluster[$cluster] = $this->
getCurrentTime();
348 $this->logger->debug( __METHOD__ .
": $cluster ($masterName) has no replication" );
349 $this->shutdownTimestampsByCluster[$cluster] = $this->
getCurrentTime();
363 if ( !$this->enabled ) {
367 if ( !$this->shutdownTimestampsByCluster ) {
368 $this->logger->debug( __METHOD__ .
": no primary positions data to save" );
373 $scopeLock = $this->store->getScopedLock( $this->key, self::LOCK_TIMEOUT, self::LOCK_TTL );
376 $this->unmarshalPositions( $this->store->get( $this->key ) ),
377 $this->shutdownPositionsByPrimary,
378 $this->shutdownTimestampsByCluster,
382 $ok = $this->store->set(
384 $this->marshalPositions( $positions ),
385 self::POSITION_STORE_TTL
392 $clusterList = implode(
', ', array_keys( $this->shutdownTimestampsByCluster ) );
395 $this->logger->debug(
"ChronologyProtector saved position data for $clusterList" );
396 $bouncedPositions = [];
399 $this->logger->warning(
"ChronologyProtector failed to save position data for $clusterList" );
400 $clientPosIndex =
null;
404 return $bouncedPositions;
418 if ( !$this->enabled ) {
425 $timestamp = $timestampsByCluster[$cluster] ??
null;
426 if ( $timestamp ===
null ) {
427 $recentTouchTimestamp =
false;
428 } elseif ( ( $this->startupTimestamp - $timestamp ) > self::POSITION_COOKIE_TTL ) {
433 $recentTouchTimestamp =
false;
434 $this->logger->debug( __METHOD__ .
": old timestamp ($timestamp) for $cluster" );
436 $recentTouchTimestamp = $timestamp;
437 $this->logger->debug( __METHOD__ .
": recent timestamp ($timestamp) for $cluster" );
440 return $recentTouchTimestamp;
467 if ( $this->startupTimestamp !==
null ) {
475 if ( $this->hasNewClientId ) {
476 $this->startupPositionsByPrimary = [];
477 $this->startupTimestampsByCluster = [];
481 $this->logger->debug(
'ChronologyProtector using store ' . get_class( $this->store ) );
482 $this->logger->debug(
"ChronologyProtector fetching positions for {$this->clientId}" );
484 $data = $this->unmarshalPositions( $this->store->get( $this->key ) );
486 $this->startupPositionsByPrimary = $data ? $data[self::FLD_POSITIONS] : [];
487 $this->startupTimestampsByCluster = $data[self::FLD_TIMESTAMPS] ?? [];
497 $indexReached = is_array( $data ) ? $data[self::FLD_WRITE_INDEX] :
null;
498 if ( $this->waitForPosIndex > 0 ) {
499 if ( $indexReached >= $this->waitForPosIndex ) {
500 $this->logger->debug(
'expected and found position index {cpPosIndex}', [
501 'cpPosIndex' => $this->waitForPosIndex,
502 ] + $this->clientLogInfo );
504 $this->logger->warning(
'expected but failed to find position index {cpPosIndex}', [
505 'cpPosIndex' => $this->waitForPosIndex,
506 'indexReached' => $indexReached,
507 'exception' =>
new \RuntimeException(),
508 ] + $this->clientLogInfo );
511 if ( $indexReached ) {
512 $this->logger->debug(
'found position data with index {indexReached}', [
513 'indexReached' => $indexReached
514 ] + $this->clientLogInfo );
530 array $shutdownPositions,
531 array $shutdownTimestamps,
532 ?
int &$clientPosIndex =
null
535 $mergedPositions = $storedValue[self::FLD_POSITIONS] ?? [];
537 foreach ( $shutdownPositions as $masterName => $pos ) {
539 !isset( $mergedPositions[$masterName] ) ||
540 !( $mergedPositions[$masterName] instanceof
DBPrimaryPos ) ||
541 $pos->asOfTime() > $mergedPositions[$masterName]->asOfTime()
543 $mergedPositions[$masterName] = $pos;
548 $mergedTimestamps = $storedValue[self::FLD_TIMESTAMPS] ?? [];
550 foreach ( $shutdownTimestamps as $cluster => $timestamp ) {
552 !isset( $mergedTimestamps[$cluster] ) ||
553 $timestamp > $mergedTimestamps[$cluster]
555 $mergedTimestamps[$cluster] = $timestamp;
559 $clientPosIndex = ( $storedValue[self::FLD_WRITE_INDEX] ?? 0 ) + 1;
562 self::FLD_POSITIONS => $mergedPositions,
563 self::FLD_TIMESTAMPS => $mergedTimestamps,
564 self::FLD_WRITE_INDEX => $clientPosIndex
574 if ( $this->wallClockOverride ) {
575 return $this->wallClockOverride;
578 $clockTime = (float)time();
581 return max( microtime(
true ), $clockTime );
591 $this->wallClockOverride =& $time;
594 private function marshalPositions( array $positions ) {
595 foreach ( $positions[ self::FLD_POSITIONS ] as
$key => $pos ) {
596 $positions[ self::FLD_POSITIONS ][
$key ] = $pos->toArray();
606 private function unmarshalPositions( $positions ) {
611 foreach ( $positions[ self::FLD_POSITIONS ] as
$key => $pos ) {
612 $class = $pos[
'_type_' ];
613 $positions[ self::FLD_POSITIONS ][
$key ] = $class::newFromArray( $pos );
634 return "{$writeIndex}@{$time}#{$clientId}";
646 static $placeholder = [
'index' =>
null,
'clientId' => null ];
648 if ( $value ===
null ) {
653 if ( !preg_match(
'/^(\d+)@(\d+)#([0-9a-f]{32})$/', $value, $m ) ) {
660 } elseif ( isset( $m[2] ) && $m[2] !==
'' && (
int)$m[2] < $minTimestamp ) {
664 $clientId = ( isset( $m[3] ) && $m[3] !==
'' ) ? $m[3] :
null;
666 return [
'index' => $index,
'clientId' =>
$clientId ];