9use Psr\Log\LoggerInterface;
10use Psr\Log\NullLogger;
121 private $requestInfo;
123 private string $secret;
124 private bool $cliMode;
158 private $hasNewClientId =
false;
163 private const POSITION_STORE_TTL = 60;
166 private const LOCK_TIMEOUT = 3;
168 private const LOCK_TTL = 6;
170 private const FLD_POSITIONS =
'positions';
171 private const FLD_WRITE_INDEX =
'writeIndex';
181 $this->requestInfo = [
182 'IPAddress' => $_SERVER[
'REMOTE_ADDR'] ??
'',
183 'UserAgent' => $_SERVER[
'HTTP_USER_AGENT'] ??
'',
185 'ChronologyClientId' =>
null,
186 'ChronologyPositionIndex' =>
null
189 $this->secret = $secret ??
'';
190 $this->logger =
$logger ??
new NullLogger();
191 $this->cliMode = $cliMode ?? ( PHP_SAPI ===
'cli' || PHP_SAPI ===
'phpdbg' );
194 private function load() {
196 if ( !$this->enabled || $this->clientId ) {
200 'ip' => $this->requestInfo[
'IPAddress'],
201 'agent' => $this->requestInfo[
'UserAgent'],
202 'clientId' => $this->requestInfo[
'ChronologyClientId'] ?: null
204 if ( $this->cliMode ) {
206 } elseif ( $this->store instanceof EmptyBagOStuff ) {
209 $this->logger->debug(
'Cannot use ChronologyProtector with EmptyBagOStuff' );
212 if ( isset( $client[
'clientId'] ) ) {
213 $this->clientId = $client[
'clientId'];
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'] );
220 $this->key = $this->store->makeGlobalKey( __CLASS__, $this->clientId,
'v4' );
221 $this->waitForPosIndex = $this->requestInfo[
'ChronologyPositionIndex'];
223 $this->clientLogInfo = [
224 'clientIP' => $client[
'ip'],
225 'clientAgent' => $client[
'agent'],
226 'clientId' => $client[
'clientId'] ?? null
231 if ( $this->clientId ) {
232 throw new LogicException(
'ChronologyProtector already initialized' );
235 $this->requestInfo = $info + $this->requestInfo;
270 if ( !$this->enabled ) {
279 $this->logger->debug(
"ChronologyProtector will wait for '$pos' on $cluster ($primaryName)'" );
281 $this->logger->debug(
"ChronologyProtector skips wait on $cluster ($primaryName)" );
322 $this->logger->debug( __METHOD__ .
": $cluster ($masterName) position now '$pos'" );
323 $this->shutdownPositionsByPrimary[$masterName] = $pos;
325 $this->logger->debug( __METHOD__ .
": $cluster ($masterName) position unknown" );
326 $this->shutdownPositionsByPrimary[$masterName] =
null;
329 $this->logger->debug( __METHOD__ .
": $cluster ($masterName) has no replication" );
330 $this->shutdownPositionsByPrimary[$masterName] =
null;
344 if ( !$this->enabled ) {
348 if ( !$this->shutdownPositionsByPrimary ) {
349 $this->logger->debug( __METHOD__ .
": no primary positions data to save" );
354 $scopeLock = $this->store->getScopedLock( $this->key, self::LOCK_TIMEOUT, self::LOCK_TTL );
357 $this->unmarshalPositions( $this->store->get( $this->key ) ),
358 $this->shutdownPositionsByPrimary,
362 $ok = $this->store->set(
364 $this->marshalPositions( $positions ),
365 self::POSITION_STORE_TTL
372 $primaryList = implode(
', ', array_keys( $this->shutdownPositionsByPrimary ) );
375 $this->logger->debug(
"ChronologyProtector saved position data for $primaryList" );
376 $bouncedPositions = [];
379 $this->logger->warning(
"ChronologyProtector failed to save position data for $primaryList" );
380 $clientPosIndex =
null;
384 return $bouncedPositions;
404 if ( !$this->enabled ) {
409 $this->logger->debug( __METHOD__ .
": found recent writes" );
413 $this->logger->debug( __METHOD__ .
": found no recent writes" );
432 if ( $this->startupPositionsByPrimary !==
null ) {
438 if ( $this->hasNewClientId ) {
439 $this->startupPositionsByPrimary = [];
443 $this->logger->debug(
'ChronologyProtector using store ' . get_class( $this->store ) );
444 $this->logger->debug(
"ChronologyProtector fetching positions for {$this->clientId}" );
446 $data = $this->unmarshalPositions( $this->store->get( $this->key ) );
448 $this->startupPositionsByPrimary = $data ? $data[self::FLD_POSITIONS] : [];
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 );
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 );
472 if ( $indexReached ) {
473 $this->logger->debug(
'found position data with index {indexReached}', [
474 'indexReached' => $indexReached
475 ] + $this->clientLogInfo );
490 array $shutdownPositions,
491 ?
int &$clientPosIndex =
null
494 $mergedPositions = $storedValue[self::FLD_POSITIONS] ?? [];
496 foreach ( $shutdownPositions as $masterName => $pos ) {
498 !isset( $mergedPositions[$masterName] ) ||
499 !( $mergedPositions[$masterName] instanceof
DBPrimaryPos ) ||
500 $pos->asOfTime() > $mergedPositions[$masterName]->asOfTime()
502 $mergedPositions[$masterName] = $pos;
506 $clientPosIndex = ( $storedValue[self::FLD_WRITE_INDEX] ?? 0 ) + 1;
509 self::FLD_POSITIONS => $mergedPositions,
510 self::FLD_WRITE_INDEX => $clientPosIndex
514 private function marshalPositions( array $positions ): array {
515 foreach ( $positions[ self::FLD_POSITIONS ] as
$key => $pos ) {
517 $positions[ self::FLD_POSITIONS ][
$key ] = $pos->toArray();
528 private function unmarshalPositions( $positions ) {
533 foreach ( $positions[ self::FLD_POSITIONS ] as $key => $pos ) {
535 $class = $pos[
'_type_' ];
536 $positions[ self::FLD_POSITIONS ][ $key ] = $class::newFromArray( $pos );
558 return "{$writeIndex}@{$time}#{$clientId}";
570 static $placeholder = [
'index' =>
null,
'clientId' => null ];
572 if ( $value ===
null ) {
577 if ( !preg_match(
'/^(\d+)@(\d+)#([0-9a-f]{32})$/', $value, $m ) ) {
584 } elseif ( isset( $m[2] ) && $m[2] !==
'' && (
int)$m[2] < $minTimestamp ) {
588 $clientId = ( isset( $m[3] ) && $m[3] !==
'' ) ? $m[3] :
null;
590 return [
'index' => $index,
'clientId' => $clientId ];