Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
69.05% covered (warning)
69.05%
29 / 42
62.50% covered (warning)
62.50%
5 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
SearchAfter
69.05% covered (warning)
69.05%
29 / 42
62.50% covered (warning)
62.50%
5 / 8
25.57
0.00% covered (danger)
0.00%
0 / 1
 __construct
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 current
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 key
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 next
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
4.13
 runSearch
16.67% covered (danger)
16.67%
2 / 12
0.00% covered (danger)
0.00%
0 / 1
8.21
 rewind
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 valid
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 calcBackoff
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare( strict_types = 1 );
4namespace CirrusSearch\Elastica;
5
6use Elastica\Exception\ExceptionInterface as ElasticaExceptionInterface;
7use Elastica\Query;
8use Elastica\ResultSet;
9use Elastica\Search;
10use InvalidArgumentException;
11use MediaWiki\Logger\LoggerFactory;
12use RuntimeException;
13
14class SearchAfter implements \Iterator {
15    private const MAX_BACKOFF_SEC = 120;
16    private const MICROSEC_PER_SEC = 1_000_000;
17    /** @var Search */
18    private $search;
19    /** @var Query */
20    private $baseQuery;
21    /** @var ?ResultSet */
22    private $currentResultSet;
23    /** @var ?int */
24    private $currentPage;
25    /** @var float[] Sequence of second length backoffs to use for retries */
26    private $backoff;
27
28    /**
29     * @param Search $search
30     * @param int $numRetries The number of retries to perform on each iteration
31     * @param float $backoffFactor Scales the backoff duration, backoff calculated as
32     *   {backoffFactor} * 2^({retry} - 1) which gives, with no scaling, [0.5, 1, 2, 4, 8, ...]
33     */
34    public function __construct( Search $search, int $numRetries = 12, float $backoffFactor = 1. ) {
35        $this->search = $search;
36        $this->baseQuery = clone $search->getQuery();
37        if ( !$this->baseQuery->hasParam( 'sort' ) ) {
38            throw new InvalidArgumentException( 'ScrollAfter query must have a sort' );
39        }
40        if ( $numRetries < 0 ) {
41            throw new InvalidArgumentException( '$numRetries must be >= 0' );
42        }
43        $this->backoff = $this->calcBackoff( $numRetries, $backoffFactor );
44    }
45
46    public function current(): ResultSet {
47        if ( $this->currentResultSet === null ) {
48            throw new RuntimeException( 'Iterator is in an invalid state and must be rewound' );
49        }
50        return $this->currentResultSet;
51    }
52
53    public function key(): int {
54        return $this->currentPage ?? 0;
55    }
56
57    public function next(): void {
58        if ( $this->currentResultSet !== null ) {
59            if ( count( $this->currentResultSet ) === 0 ) {
60                return;
61            }
62            $lastHit = $this->currentResultSet[count( $this->currentResultSet ) - 1];
63            $this->search->getQuery()->setParam( 'search_after', $lastHit->getSort() );
64        } elseif ( $this->currentPage !== -1 ) {
65            // iterator is in failed state
66            return;
67        }
68        // ensure if runSearch throws the iterator becomes invalid
69        $this->currentResultSet = null;
70        $this->currentResultSet = $this->runSearch();
71        $this->currentPage++;
72    }
73
74    private function runSearch() {
75        foreach ( $this->backoff as $backoffSec ) {
76            try {
77                return $this->search->search();
78            } catch ( ElasticaExceptionInterface $e ) {
79                LoggerFactory::getInstance( 'CirrusSearch' )->warning(
80                    "Exception thrown during SearchAfter iteration. Retrying in {backoffSec}s.",
81                    [
82                        'exception' => $e,
83                        'backoffSec' => $backoffSec,
84                    ]
85                );
86                usleep( (int)( $backoffSec * self::MICROSEC_PER_SEC ) );
87            }
88        }
89        // Final attempt after exhausting retries.
90        return $this->search->search();
91    }
92
93    public function rewind(): void {
94        // Use -1 so that on increment the first page is 0
95        $this->currentPage = -1;
96        $this->currentResultSet = null;
97        $this->search->setQuery( clone $this->baseQuery );
98        // rewind performs the first query
99        $this->next();
100    }
101
102    public function valid(): bool {
103        return count( $this->currentResultSet ?? [] ) > 0;
104    }
105
106    private function calcBackoff( int $maxRetries, float $backoffFactor ): array {
107        $backoff = [];
108        for ( $retry = 0; $retry < $maxRetries; $retry++ ) {
109            $backoff[$retry] = min( $backoffFactor * pow( 2, $retry - 1 ), self::MAX_BACKOFF_SEC );
110        }
111        return $backoff;
112    }
113}