23use Psr\Log\LoggerInterface;
24use Psr\Log\NullLogger;
135 private $requestInfo;
137 private string $secret;
138 private bool $cliMode;
172 private $hasNewClientId =
false;
177 private const POSITION_STORE_TTL = 60;
180 private const LOCK_TIMEOUT = 3;
182 private const LOCK_TTL = 6;
184 private const FLD_POSITIONS =
'positions';
185 private const FLD_WRITE_INDEX =
'writeIndex';
195 $this->requestInfo = [
196 'IPAddress' => $_SERVER[
'REMOTE_ADDR'] ??
'',
197 'UserAgent' => $_SERVER[
'HTTP_USER_AGENT'] ??
'',
199 'ChronologyClientId' =>
null,
200 'ChronologyPositionIndex' =>
null
203 $this->secret = $secret ??
'';
204 $this->logger =
$logger ??
new NullLogger();
205 $this->cliMode = $cliMode ?? ( PHP_SAPI ===
'cli' || PHP_SAPI ===
'phpdbg' );
208 private function load() {
210 if ( !$this->enabled || $this->clientId ) {
214 'ip' => $this->requestInfo[
'IPAddress'],
215 'agent' => $this->requestInfo[
'UserAgent'],
216 'clientId' => $this->requestInfo[
'ChronologyClientId'] ?: null
218 if ( $this->cliMode ) {
220 } elseif ( $this->store instanceof EmptyBagOStuff ) {
223 $this->logger->debug(
'Cannot use ChronologyProtector with EmptyBagOStuff' );
226 if ( isset( $client[
'clientId'] ) ) {
227 $this->clientId = $client[
'clientId'];
229 $this->hasNewClientId =
true;
230 $this->clientId = ( $this->secret !=
'' )
231 ? hash_hmac(
'md5', $client[
'ip'] .
"\n" . $client[
'agent'], $this->secret )
232 : md5( $client[
'ip'] .
"\n" . $client[
'agent'] );
234 $this->key = $this->store->makeGlobalKey( __CLASS__, $this->clientId,
'v4' );
235 $this->waitForPosIndex = $this->requestInfo[
'ChronologyPositionIndex'];
237 $this->clientLogInfo = [
238 'clientIP' => $client[
'ip'],
239 'clientAgent' => $client[
'agent'],
240 'clientId' => $client[
'clientId'] ?? null
245 if ( $this->clientId ) {
246 throw new LogicException(
'ChronologyProtector already initialized' );
249 $this->requestInfo = $info + $this->requestInfo;
284 if ( !$this->enabled ) {
293 $this->logger->debug(
"ChronologyProtector will wait for '$pos' on $cluster ($primaryName)'" );
295 $this->logger->debug(
"ChronologyProtector skips wait on $cluster ($primaryName)" );
336 $this->logger->debug( __METHOD__ .
": $cluster ($masterName) position now '$pos'" );
337 $this->shutdownPositionsByPrimary[$masterName] = $pos;
339 $this->logger->debug( __METHOD__ .
": $cluster ($masterName) position unknown" );
340 $this->shutdownPositionsByPrimary[$masterName] =
null;
343 $this->logger->debug( __METHOD__ .
": $cluster ($masterName) has no replication" );
344 $this->shutdownPositionsByPrimary[$masterName] =
null;
358 if ( !$this->enabled ) {
362 if ( !$this->shutdownPositionsByPrimary ) {
363 $this->logger->debug( __METHOD__ .
": no primary positions data to save" );
368 $scopeLock = $this->store->getScopedLock( $this->key, self::LOCK_TIMEOUT, self::LOCK_TTL );
371 $this->unmarshalPositions( $this->store->get( $this->key ) ),
372 $this->shutdownPositionsByPrimary,
376 $ok = $this->store->set(
378 $this->marshalPositions( $positions ),
379 self::POSITION_STORE_TTL
386 $primaryList = implode(
', ', array_keys( $this->shutdownPositionsByPrimary ) );
389 $this->logger->debug(
"ChronologyProtector saved position data for $primaryList" );
390 $bouncedPositions = [];
393 $this->logger->warning(
"ChronologyProtector failed to save position data for $primaryList" );
394 $clientPosIndex =
null;
398 return $bouncedPositions;
418 if ( !$this->enabled ) {
423 $this->logger->debug( __METHOD__ .
": found recent writes" );
427 $this->logger->debug( __METHOD__ .
": found no recent writes" );
446 if ( $this->startupPositionsByPrimary !==
null ) {
452 if ( $this->hasNewClientId ) {
453 $this->startupPositionsByPrimary = [];
457 $this->logger->debug(
'ChronologyProtector using store ' . get_class( $this->store ) );
458 $this->logger->debug(
"ChronologyProtector fetching positions for {$this->clientId}" );
460 $data = $this->unmarshalPositions( $this->store->get( $this->key ) );
462 $this->startupPositionsByPrimary = $data ? $data[self::FLD_POSITIONS] : [];
472 $indexReached = is_array( $data ) ? $data[self::FLD_WRITE_INDEX] :
null;
473 if ( $this->waitForPosIndex > 0 ) {
474 if ( $indexReached >= $this->waitForPosIndex ) {
475 $this->logger->debug(
'expected and found position index {cpPosIndex}', [
476 'cpPosIndex' => $this->waitForPosIndex,
477 ] + $this->clientLogInfo );
479 $this->logger->warning(
'expected but failed to find position index {cpPosIndex}', [
480 'cpPosIndex' => $this->waitForPosIndex,
481 'indexReached' => $indexReached,
482 'exception' =>
new \RuntimeException(),
483 ] + $this->clientLogInfo );
486 if ( $indexReached ) {
487 $this->logger->debug(
'found position data with index {indexReached}', [
488 'indexReached' => $indexReached
489 ] + $this->clientLogInfo );
504 array $shutdownPositions,
505 ?
int &$clientPosIndex =
null
508 $mergedPositions = $storedValue[self::FLD_POSITIONS] ?? [];
510 foreach ( $shutdownPositions as $masterName => $pos ) {
512 !isset( $mergedPositions[$masterName] ) ||
513 !( $mergedPositions[$masterName] instanceof
DBPrimaryPos ) ||
514 $pos->asOfTime() > $mergedPositions[$masterName]->asOfTime()
516 $mergedPositions[$masterName] = $pos;
520 $clientPosIndex = ( $storedValue[self::FLD_WRITE_INDEX] ?? 0 ) + 1;
523 self::FLD_POSITIONS => $mergedPositions,
524 self::FLD_WRITE_INDEX => $clientPosIndex
528 private function marshalPositions( array $positions ): array {
529 foreach ( $positions[ self::FLD_POSITIONS ] as
$key => $pos ) {
531 $positions[ self::FLD_POSITIONS ][
$key ] = $pos->toArray();
542 private function unmarshalPositions( $positions ) {
547 foreach ( $positions[ self::FLD_POSITIONS ] as $key => $pos ) {
549 $class = $pos[
'_type_' ];
550 $positions[ self::FLD_POSITIONS ][ $key ] = $class::newFromArray( $pos );
572 return "{$writeIndex}@{$time}#{$clientId}";
584 static $placeholder = [
'index' =>
null,
'clientId' => null ];
586 if ( $value ===
null ) {
591 if ( !preg_match(
'/^(\d+)@(\d+)#([0-9a-f]{32})$/', $value, $m ) ) {
598 } elseif ( isset( $m[2] ) && $m[2] !==
'' && (
int)$m[2] < $minTimestamp ) {
602 $clientId = ( isset( $m[3] ) && $m[3] !==
'' ) ? $m[3] :
null;
604 return [
'index' => $index,
'clientId' => $clientId ];