Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
5.58% covered (danger)
5.58%
14 / 251
0.00% covered (danger)
0.00%
0 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
Reindexer
5.58% covered (danger)
5.58%
14 / 251
0.00% covered (danger)
0.00%
0 / 25
3960.62
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 reindex
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
56
 waitForCounts
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
30
 waitForGreen
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 getHealth
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 decideMaxShardsPerNodeForReindex
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 setConnectionTimeout
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 destroyClients
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 output
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 outputIndented
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 error
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 fatalError
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 makeUpdateFieldsScript
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
42
 makeWeightedTagsPrefixReplaceScript
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 pruneWeightedTagsDeleteMarkersScript
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 migrateArticleTopicUnderscoresScript
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 makeRemoteReindexInfo
58.33% covered (warning)
58.33%
14 / 24
0.00% covered (danger)
0.00%
0 / 1
12.63
 monitorReindexTask
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
56
 monitorSleepSeconds
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 estimateTimeRemaining
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
 estimateSlices
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getNumberOfNodes
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getNumberOfShards
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 safeCount
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 safeRefresh
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace CirrusSearch\Maintenance;
4
5use CirrusSearch\CirrusConfigNames;
6use CirrusSearch\Connection;
7use CirrusSearch\Elastica\ReindexRequest;
8use CirrusSearch\Elastica\ReindexResponse;
9use CirrusSearch\Elastica\ReindexTask;
10use CirrusSearch\Query\ArticlePredictionKeyword;
11use CirrusSearch\SearchConfig;
12use Elastica\Client;
13use Elastica\Exception\Connection\HttpException;
14use Elastica\Index;
15use Elastica\Request;
16use Elastica\Transport\Http;
17use Elastica\Transport\Https;
18use MediaWiki\Utils\MWTimestamp;
19use StatusValue;
20
21/**
22 * @license GPL-2.0-or-later
23 */
24class Reindexer {
25    private const MAX_CONSECUTIVE_ERRORS = 5;
26    private const MONITOR_SLEEP_SECONDS = 30;
27    private const MAX_WAIT_FOR_COUNT_SEC = 600;
28    private const AUTO_SLICE_CEILING = 20;
29
30    /**
31     * @var SearchConfig
32     */
33    private $searchConfig;
34
35    /* "From" portion */
36    /**
37     * @var Index
38     */
39    private $oldIndex;
40
41    /**
42     * @var Connection
43     */
44    private $oldConnection;
45
46    /* "To" portion */
47
48    /**
49     * @var Index
50     */
51    private $index;
52
53    /**
54     * @var Connection
55     */
56    private $connection;
57
58    /**
59     * @var ?Printer
60     */
61    private $out;
62
63    /**
64     * @var string[] list of fields to delete
65     */
66    private $fieldsToDelete;
67
68    /**
69     * @var string[] list of weighted tag prefixes to rename (old prefix => new prefix)
70     */
71    private $weightedTagsPrefixMap;
72
73    /**
74     * @param SearchConfig $searchConfig
75     * @param Connection $source
76     * @param Connection $target
77     * @param Index $index
78     * @param Index $oldIndex
79     * @param Printer|null $out
80     * @param string[] $fieldsToDelete
81     * @param string[] $weightedTagsPrefixMap
82     */
83    public function __construct(
84        SearchConfig $searchConfig,
85        Connection $source,
86        Connection $target,
87        Index $index,
88        Index $oldIndex,
89        ?Printer $out = null,
90        array $fieldsToDelete = [],
91        array $weightedTagsPrefixMap = []
92    ) {
93        // @todo: this constructor has too many arguments - refactor!
94        $this->searchConfig = $searchConfig;
95        $this->oldConnection = $source;
96        $this->connection = $target;
97        $this->oldIndex = $oldIndex;
98        $this->index = $index;
99        $this->out = $out;
100        $this->fieldsToDelete = $fieldsToDelete;
101        $this->weightedTagsPrefixMap = $weightedTagsPrefixMap;
102    }
103
104    /**
105     * Dump everything from the live index into the one being worked on.
106     *
107     * @param int|null $slices The number of slices to use, or null to use
108     *  the number of shards
109     * @param int $chunkSize
110     * @param float $acceptableCountDeviation
111     */
112    public function reindex(
113        $slices = null,
114        $chunkSize = 100,
115        $acceptableCountDeviation = 0.05
116    ) {
117        // Set some settings that should help io load during bulk indexing.  We'll have to
118        // optimize after this to consolidate down to a proper number of segments but that is
119        // is worth the price.  total_shards_per_node will help to make sure that each shard
120        // has as few neighbors as possible.
121        $this->outputIndented( "Preparing index settings for reindex\n" );
122        $this->setConnectionTimeout();
123        $settings = $this->index->getSettings();
124        $oldSettings = $settings->get();
125        if ( !is_array( $oldSettings ) ) {
126            throw new \RuntimeException( 'Invalid response from index settings' );
127        }
128        $settings->set( [
129            'refresh_interval' => -1,
130            'routing.allocation.total_shards_per_node' =>
131                $this->decideMaxShardsPerNodeForReindex( $oldSettings ),
132            // It's probably inefficient to let the index be created with replicas,
133            // then drop the empty replicas a few moments later. Doing it like this
134            // allows reindexing and index creation to operate independantly without
135            // needing to know about each other.
136            'auto_expand_replicas' => 'false',
137            'number_of_replicas' => 0,
138        ] );
139        $this->waitForGreen();
140
141        $request = new ReindexRequest( $this->oldIndex, $this->index, $chunkSize );
142        if ( $slices === null ) {
143            $request->setSlices( $this->estimateSlices( $this->oldIndex ) );
144        } else {
145            $request->setSlices( $slices );
146        }
147        $remote = self::makeRemoteReindexInfo( $this->oldConnection, $this->connection );
148        if ( $remote !== null ) {
149            $request->setRemoteInfo( $remote );
150        }
151
152        $script = $this->makeUpdateFieldsScript();
153        if ( $script !== null ) {
154            $request->setScript( $script );
155        }
156
157        try {
158            $task = $request->reindexTask();
159        } catch ( \Exception $e ) {
160            $this->fatalError( $e->getMessage() );
161        }
162
163        $this->outputIndented( "Started reindex task: " . $task->getId() . "\n" );
164        $response = $this->monitorReindexTask( $task, $this->index );
165        $task->delete();
166        if ( !$response->isSuccessful() ) {
167            $this->fatalError(
168                "Reindex task was not successful: " . $response->getUnsuccessfulReason()
169            );
170        }
171
172        $this->outputIndented( "Verifying counts..." );
173        // We can't verify counts are exactly equal because they won't be - we still push updates
174        // into the old index while reindexing the new one.
175        $this->waitForCounts( $acceptableCountDeviation );
176        $this->output( "done\n" );
177
178        // Revert settings changed just for reindexing. Although we set number_of_replicas above
179        // we do not reset it's value here, rather allowing auto_expand_replicas to pick an
180        // appropriate value.
181        $newSettings = [
182            'refresh_interval' => $oldSettings['refresh_interval'],
183            'auto_expand_replicas' => $oldSettings['auto_expand_replicas'],
184            'routing.allocation.total_shards_per_node' =>
185                $oldSettings['routing']['allocation']['total_shards_per_node'] ?? -1,
186        ];
187        $settings->set( $newSettings );
188    }
189
190    private function waitForCounts( float $acceptableCountDeviation ) {
191        $oldCount = (float)$this->safeCount( $this->oldIndex );
192        $this->safeRefresh( $this->index );
193        // While elasticsearch should be ready immediately after a refresh, we have seen this return
194        // exceptionally low values in 2% of reindex attempts. Wait around a bit and hope the refresh
195        // becomes available
196        $start = microtime( true );
197        $timeoutAfter = $start + self::MAX_WAIT_FOR_COUNT_SEC;
198        while ( true ) {
199            $newCount = (float)$this->safeCount( $this->index );
200            $difference = $oldCount > 0 ? abs( $oldCount - $newCount ) / $oldCount : 0;
201            if ( $difference <= $acceptableCountDeviation ) {
202                break;
203            }
204            $this->output(
205                "Not close enough!  old=$oldCount new=$newCount difference=$difference\n"
206            );
207            if ( microtime( true ) > $timeoutAfter ) {
208                $this->fatalError( 'Failed to load index - counts not close enough.  ' .
209                    "old=$oldCount new=$newCount difference=$difference.  " .
210                    'Check for warnings above.' );
211            } else {
212                $this->output( "Waiting to re-check counts..." );
213                sleep( 30 );
214            }
215        }
216    }
217
218    public function waitForGreen() {
219        $this->outputIndented( "Waiting for index green status..." );
220        $each = 0;
221        $status = $this->getHealth();
222        while ( $status['status'] !== 'green' ) {
223            if ( $each === 0 ) {
224                $this->output( '.' );
225            }
226            $each = ( $each + 1 ) % 20;
227            sleep( 1 );
228            $status = $this->getHealth();
229        }
230        $this->output( "done\n" );
231    }
232
233    /**
234     * Get health information about the index
235     *
236     * @return array Response data array
237     */
238    private function getHealth() {
239        $indexName = $this->index->getName();
240        $path = "_cluster/health/$indexName";
241        while ( true ) {
242            $response = $this->index->getClient()->request( $path );
243            if ( $response->hasError() ) {
244                $this->error( 'Error fetching index health but going to retry.  Message: ' .
245                    $response->getError() );
246                sleep( 1 );
247                continue;
248            }
249            return $response->getData();
250        }
251    }
252
253    /**
254     * Decide shards per node during reindex operation
255     *
256     * While reindexing we run with no replicas, meaning the default
257     * configuration for max shards per node might allow things to
258     * become very unbalanced. Choose a value that spreads the
259     * indexing load across as many instances as possible.
260     *
261     * @param array $settings Configured live index settings
262     * @return int
263     */
264    private function decideMaxShardsPerNodeForReindex( array $settings ): int {
265        $numberOfNodes = $this->getHealth()[ 'number_of_nodes' ];
266        $numberOfShards = $settings['number_of_shards'];
267        return (int)ceil( $numberOfShards / $numberOfNodes );
268    }
269
270    /**
271     * Set the maintenance timeout to the connection we will issue the reindex request
272     * to, so that it does not timeout while the reindex is running.
273     */
274    private function setConnectionTimeout() {
275        $timeout = $this->searchConfig->get( CirrusConfigNames::MaintenanceTimeout );
276        $this->connection->setTimeout( $timeout );
277    }
278
279    /**
280     * Destroy client connections
281     */
282    private function destroyClients() {
283        $this->connection->destroyClient();
284        $this->oldConnection->destroyClient();
285        // Destroying connections resets timeouts, so we have to reinstate them
286        $this->setConnectionTimeout();
287    }
288
289    /**
290     * @param string $message
291     * @param string|null $channel
292     */
293    protected function output( $message, $channel = null ) {
294        if ( $this->out ) {
295            $this->out->output( $message, $channel );
296        }
297    }
298
299    /**
300     * @param string $message
301     * @param string $prefix By default prefixes tab to fake an
302     *  additional indentation level.
303     */
304    private function outputIndented( $message, $prefix = "\t" ) {
305        if ( $this->out ) {
306            $this->out->outputIndented( $prefix . $message );
307        }
308    }
309
310    /**
311     * @param string|StatusValue $message
312     */
313    private function error( $message ) {
314        if ( $this->out ) {
315            $this->out->error( $message );
316        }
317    }
318
319    /**
320     * @param string|StatusValue $message
321     * @param int $exitCode
322     * @return never
323     */
324    private function fatalError( $message, $exitCode = 1 ) {
325        $this->error( $message );
326        exit( $exitCode );
327    }
328
329    /**
330     * @return array|null Returns an array suitable for use as
331     *  the _reindex api script parameter to delete fields from
332     *  the copied documents, or null if no script is needed.
333     */
334    private function makeUpdateFieldsScript() {
335        $script = [
336            'source' => '',
337            'lang' => 'painless',
338        ];
339        foreach ( $this->fieldsToDelete as $field ) {
340            $field = trim( $field );
341            if ( strlen( $field ) ) {
342                $script['source'] .= "ctx._source.remove('$field');";
343            }
344        }
345        $script['source'] .= $this->makeWeightedTagsPrefixReplaceScript();
346        $script['source'] .= $this->pruneWeightedTagsDeleteMarkersScript();
347        $script['source'] .= $this->migrateArticleTopicUnderscoresScript();
348        // Populate the page_id if it's the first time we add the page_id field to the mapping
349        if ( !isset( $this->oldIndex->getMapping()['properties']['page_id'] )
350                 && isset( $this->index->getMapping()['properties']['page_id'] ) ) {
351            $this->outputIndented( "Populating the page_id field if not set\n" );
352            $prefLen = strlen( $this->searchConfig->makeId( 1 ) ) - 1;
353            $script['source'] .= "if (ctx._source.page_id == null) {ctx._source.page_id = Long.parseLong(ctx._id.substring($prefLen));}";
354        }
355        if ( $script['source'] === '' ) {
356            return null;
357        }
358
359        return $script;
360    }
361
362    private function makeWeightedTagsPrefixReplaceScript(): string {
363        if ( count( $this->weightedTagsPrefixMap ) === 0 ) {
364            return '';
365        }
366        $scriptSource = "if (ctx._source.containsKey('weighted_tags')) {";
367        foreach ( $this->weightedTagsPrefixMap as $oldPrefix => $newPrefix ) {
368            $scriptSource .= "
369            for (int i = 0; i < ctx._source.weighted_tags.length; i++) {
370                if (ctx._source.weighted_tags[i].startsWith('$oldPrefix/')) {
371                    ctx._source.weighted_tags[i] = ctx._source.weighted_tags[i].replace('$oldPrefix/', '$newPrefix/');
372                }
373            }";
374        }
375        $scriptSource .= "}";
376        return $scriptSource;
377    }
378
379    private function pruneWeightedTagsDeleteMarkersScript(): string {
380        // There was at one point a bug that inserted these tags directly, instead of interpreting them
381        // as markers. Prune them back out.
382        return "
383            if (ctx._source.containsKey('weighted_tags') && ctx._source.weighted_tags instanceof List) {
384                ctx._source.weighted_tags.removeIf(item -> item != null && item.endsWith('/__DELETE_GROUPING__'));
385            }";
386    }
387
388    private function migrateArticleTopicUnderscoresScript(): string {
389        // At some point the data source for article topics changed from
390        // providing the values with spaces to providing them with underscores.
391        // Normalize the previously indexed data to match.
392        $prefix = ArticlePredictionKeyword::ARTICLE_TOPIC_TAG_PREFIX;
393        return "
394            if (ctx._source.containsKey('weighted_tags') && ctx._source.weighted_tags instanceof List) {
395                for (int i = 0; i < ctx._source.weighted_tags.length; i++) {
396                    if (ctx._source.weighted_tags[i].startsWith('$prefix/')) {
397                        ctx._source.weighted_tags[i] = ctx._source.weighted_tags[i].replace(' ', '_');
398                    }
399                }
400            }";
401    }
402
403    /**
404     * Creates an array suitable for use as the _reindex api source.remote
405     * parameter to read from $oldConnection.
406     *
407     * This is very fragile, but the transports don't expose enough to do more really
408     *
409     * @param Connection $source Connection to read data from
410     * @param Connection $dest Connection to reindex data into
411     * @return array|null
412     */
413    public static function makeRemoteReindexInfo( Connection $source, Connection $dest ) {
414        if ( $source->getClusterName() === $dest->getClusterName() ) {
415            return null;
416        }
417
418        $innerConnection = $source->getClient()->getConnection();
419        $transport = $innerConnection->getTransportObject();
420        if ( !$transport instanceof Http ) {
421            throw new \RuntimeException(
422                'Remote reindex not implemented for transport: ' . get_class( $transport )
423            );
424        }
425
426        // We make some pretty bold assumptions that classes extending from \Elastica\Transport\Http
427        // don't change how any of this works.
428        $url = $innerConnection->hasConfig( 'url' )
429            ? $innerConnection->getConfig( 'url' )
430            : '';
431        if ( $url === '' ) {
432            $scheme = ( $transport instanceof Https )
433                ? 'https'
434                : 'http';
435            $url = $scheme . '://' . $innerConnection->getHost() . ':' .
436                $innerConnection->getPort() . '/' . $innerConnection->getPath();
437        }
438
439        if ( $innerConnection->getUsername() && $innerConnection->getPassword() ) {
440            return [
441                'host' => $url,
442                'username' => $innerConnection->getUsername(),
443                'password' => $innerConnection->getPassword(),
444            ];
445        } else {
446            return [ 'host' => $url ];
447        }
448    }
449
450    /**
451     * @param ReindexTask $task
452     * @param Index $target
453     * @return ReindexResponse
454     */
455    private function monitorReindexTask( ReindexTask $task, Index $target ) {
456        $consecutiveErrors = 0;
457        $sleepSeconds = self::monitorSleepSeconds( 1, 2, self::MONITOR_SLEEP_SECONDS );
458        $completionEstimateGen = self::estimateTimeRemaining();
459        while ( !$task->isComplete() ) {
460            try {
461                $status = $task->getStatus();
462            } catch ( \Exception $e ) {
463                if ( ++$consecutiveErrors > self::MAX_CONSECUTIVE_ERRORS ) {
464                    $this->output( "\n" );
465                    $this->fatalError(
466                        "$e\n\n" .
467                        "Lost connection to elasticsearch cluster. The reindex task "
468                        . "{$task->getId()} is still running.\nThe task should be manually "
469                        . "canceled, and the index {$target->getName()}\n"
470                        . "should be removed.\n" .
471                        $e->getMessage()
472                    );
473                }
474                if ( $e instanceof HttpException ) {
475                    // Allow through potentially intermittent network problems:
476                    // * couldn't connect,
477                    // * 28: timeout out
478                    // * 52: connected, closed with no response
479                    if ( !in_array( $e->getError(), [ CURLE_COULDNT_CONNECT, 28, 52 ] ) ) {
480                        // Wrap exception to include info about task id?
481                        throw $e;
482                    }
483                }
484                $this->outputIndented( "Error: {$e->getMessage()}\n" );
485                usleep( 500000 );
486                continue;
487            }
488
489            $consecutiveErrors = 0;
490
491            $estCompletion = $completionEstimateGen->send(
492                $status->getTotal() - $status->getCreated() );
493            // What is worth reporting here?
494            $this->outputIndented(
495                "Task: {$task->getId()} "
496                . "Search Retries: {$status->getSearchRetries()} "
497                . "Bulk Retries: {$status->getBulkRetries()} "
498                . "Indexed: {$status->getCreated()} / {$status->getTotal()} "
499                . "Complete: $estCompletion\n"
500            );
501            if ( !$status->isComplete() ) {
502                sleep( $sleepSeconds->current() );
503                $sleepSeconds->next();
504            }
505        }
506
507        return $task->getResponse();
508    }
509
510    private static function monitorSleepSeconds( int $base, int $ratio, int $max ): \Generator {
511        $val = $base;
512        // @phan-suppress-next-line PhanInfiniteLoop https://github.com/phan/phan/issues/3545
513        while ( true ) {
514            yield $val;
515            $val = min( $max, $val * $ratio );
516        }
517    }
518
519    /**
520     * Generator returning the estimated timestamp of completion.
521     * @return \Generator Must be provided the remaining count via Generator::send, replies
522     *  with a unix timestamp estimating the completion time.
523     */
524    private static function estimateTimeRemaining(): \Generator {
525        $estimatedStr = null;
526        $remain = null;
527        $prevRemain = null;
528        $now = microtime( true );
529        while ( true ) {
530            $start = $now;
531            $prevRemain = $remain;
532            $remain = yield $estimatedStr;
533            $now = microtime( true );
534            if ( $remain === null || $prevRemain === null ) {
535                continue;
536            }
537            # Very simple calc, no smoothing and will vary wildly. Could be
538            # improved if deemed useful.
539            $elapsed  = $now - $start;
540            $rate = ( $prevRemain - $remain ) / $elapsed;
541            if ( $rate > 0 ) {
542                $estimatedCompletion = $now + ( $remain / $rate );
543                $estimatedStr = MWTimestamp::convert( TS_RFC2822, $estimatedCompletion );
544            }
545        }
546    }
547
548    /**
549     * Auto detect the number of slices to use when reindexing.
550     *
551     * Note that elasticseach 7.x added an 'auto' setting, but we are on
552     * 6.x. That setting uses one slice per shard, up to a certain limit (20 in
553     * 7.9). This implementation provides the same limits, and adds an additional
554     * constraint that the auto-detected value must be <= the number of nodes.
555     *
556     * @param Index $index The index the estimate a slice count for
557     * @return int The number of slices to reindex with
558     */
559    private function estimateSlices( Index $index ): int {
560        return min(
561            $this->getNumberOfNodes( $index->getClient() ),
562            $this->getNumberOfShards( $index ),
563            self::AUTO_SLICE_CEILING
564        );
565    }
566
567    private function getNumberOfNodes( Client $client ): int {
568        $endpoint = ( new \Elasticsearch\Endpoints\Cat\Nodes() )
569            ->setParams( [ 'format' => 'json' ] );
570        return count( $client->requestEndpoint( $endpoint )->getData() );
571    }
572
573    private function getNumberOfShards( Index $index ): int {
574        $response = $index->request( '_settings/index.number_of_shards', Request::GET );
575        $data = $response->getData();
576        // Can't use $index->getName() because that is probably an alias
577        $realIndexName = array_keys( $data )[0];
578        // In theory this should never happen, we will get a ResponseException if the index doesn't
579        // exist and every index must have a number_of_shards settings. But better safe than sorry.
580        if ( !isset( $data[$realIndexName]['settings']['index']['number_of_shards'] ) ) {
581            throw new \RuntimeException(
582                "Couldn't detect number of shards in {$index->getName()}"
583            );
584        }
585        return (int)$data[$realIndexName]['settings']['index']['number_of_shards'];
586    }
587
588    private function safeCount( Index $index, int $attempts = 3 ): int {
589        return ConfigUtils::safeCountOrFail(
590            $index,
591            function ( StatusValue $error ): never {
592                $this->fatalError( $error );
593            },
594            $attempts
595        );
596    }
597
598    private function safeRefresh( Index $index, int $attempts = 3 ): void {
599        ConfigUtils::safeRefreshOrFail(
600            $index,
601            function ( StatusValue $error ): never {
602                $this->fatalError( $error );
603            },
604            $attempts
605        );
606    }
607}