Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 87
0.00% covered (danger)
0.00%
0 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
ElasticaWrite
0.00% covered (danger)
0.00%
0 / 87
0.00% covered (danger)
0.00%
0 / 6
272
0.00% covered (danger)
0.00%
0 / 1
 build
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 serde
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
42
 __construct
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 allowRetries
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 doJob
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
30
 requeueError
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace CirrusSearch\Job;
4
5use CirrusSearch\Connection;
6use CirrusSearch\DataSender;
7use CirrusSearch\UpdateGroup;
8use MediaWiki\Logger\LoggerFactory;
9use MediaWiki\MediaWikiServices;
10use MediaWiki\Status\Status;
11use Wikimedia\Assert\Assert;
12
13/**
14 * Performs writes to elasticsearch indexes with requeuing and an
15 * exponential backoff (if supported by jobqueue) when the index
16 * writes fail.
17 *
18 * @license GPL-2.0-or-later
19 */
20class ElasticaWrite extends CirrusGenericJob {
21
22    private const MAX_ERROR_RETRY = 4;
23
24    /**
25     * @var array Map from method name to list of classes to
26     *  handle serialization for each argument.
27     */
28    private static $SERDE = [
29        'sendData' => [ null, ElasticaDocumentsJsonSerde::class ],
30    ];
31
32    /**
33     * @param string $cluster
34     * @param string $updateGroup UpdateGroup::* constant
35     * @param string $method
36     * @param array $arguments
37     * @param array $params
38     * @param string|null $updateKind the kind of update to perform (used for monitoring)
39     * @param int|null $rootEventTime the time of MW event that caused this update (used for monitoring)
40     * @return self
41     */
42    public static function build(
43        string $cluster,
44        string $updateGroup,
45        string $method,
46        array $arguments,
47        array $params = [],
48        ?string $updateKind = null,
49        ?int $rootEventTime = null
50    ): self {
51        return new self( [
52            'method' => $method,
53            'arguments' => self::serde( $method, $arguments ),
54            'cluster' => $cluster,
55            'update_group' => $updateGroup,
56            CirrusTitleJob::UPDATE_KIND => $updateKind,
57            CirrusTitleJob::ROOT_EVENT_TIME => $rootEventTime
58        ] + $params );
59    }
60
61    private static function serde( string $method, array $arguments, bool $serialize = true ): array {
62        if ( isset( self::$SERDE[$method] ) ) {
63            foreach ( self::$SERDE[$method] as $i => $serde ) {
64                if ( $serde !== null && array_key_exists( $i, $arguments ) ) {
65                    $impl = new $serde();
66                    if ( $serialize ) {
67                        $arguments[$i] = $impl->serialize( $arguments[$i] );
68                    } else {
69                        $arguments[$i] = $impl->deserialize( $arguments[$i] );
70                    }
71                }
72            }
73        }
74        return $arguments;
75    }
76
77    /**
78     * Entry point for jobs received from the job queue. Creating new
79     * jobs should be done via self::build.
80     */
81    public function __construct( array $params ) {
82        parent::__construct( $params + [
83            'createdAt' => time(),
84            'errorCount' => 0,
85            'retryCount' => 0,
86            'cluster' => null,
87            CirrusTitleJob::UPDATE_KIND => null,
88            CirrusTitleJob::ROOT_EVENT_TIME => null,
89            // BC for jobs created pre-1.42
90            'update_group' => UpdateGroup::PAGE,
91        ] );
92    }
93
94    /**
95     * This job handles all its own retries internally. These jobs are so
96     * numerous that if they were to start failing they would possibly
97     * overflow the job queue and bring down redis in production.
98     *
99     * Basically we just can't let these jobs hang out in the abandoned
100     * queue for a week like retries typically do. If these jobs get
101     * failed they will log to CirrusSearchChangeFailed which is a signal
102     * that some point in time around the failure needs to be reindexed
103     * manually. See https://wikitech.wikimedia.org/wiki/Search for more
104     * details.
105     * @return bool
106     */
107    public function allowRetries() {
108        return false;
109    }
110
111    /**
112     * @return bool
113     */
114    protected function doJob() {
115        // While we can only have a single connection per job, we still
116        // use decideClusters() which includes a variety of safeguards.
117        $connections = $this->decideClusters( $this->params['update_group'] );
118        if ( !$connections ) {
119            // Chosen cluster no longer exists in configuration.
120            return true;
121        }
122        Assert::precondition( count( $connections ) == 1,
123            'per self::build() we must have a single connection' );
124
125        $conn = reset( $connections );
126        $arguments = self::serde( $this->params['method'], $this->params['arguments'], false );
127
128        LoggerFactory::getInstance( 'CirrusSearch' )->debug(
129            "Running {method} on cluster {cluster} {diff}s after insertion",
130            [
131                'method' => $this->params['method'],
132                'arguments' => $arguments,
133                'diff' => time() - $this->params['createdAt'],
134                'cluster' => $conn->getClusterName(),
135            ]
136        );
137
138        $sender = new DataSender( $conn, $this->searchConfig );
139        try {
140            $status = $sender->{$this->params['method']}( ...$arguments );
141        } catch ( \Exception $e ) {
142            LoggerFactory::getInstance( 'CirrusSearch' )->warning(
143                "Exception thrown while running DataSender::{method} in cluster {cluster}: {errorMessage}",
144                [
145                    'method' => $this->params['method'],
146                    'cluster' => $conn->getClusterName(),
147                    'errorMessage' => $e->getMessage(),
148                    'exception' => $e,
149                ]
150            );
151            $status = Status::newFatal( 'cirrussearch-send-failure' );
152        }
153
154        $ok = true;
155        if ( !$status->isOK() ) {
156            $action = $this->requeueError( $conn ) ? "Requeued" : "Dropped";
157            $this->setLastError( "ElasticaWrite job failed: {$action}" );
158            $ok = false;
159        }
160
161        return $ok;
162    }
163
164    /**
165     * Re-queue job that failed, or drop the job if it has failed
166     * too many times
167     *
168     * @param Connection $conn
169     * @return bool True when the job has been queued
170     */
171    private function requeueError( Connection $conn ) {
172        if ( $this->params['errorCount'] >= self::MAX_ERROR_RETRY ) {
173            LoggerFactory::getInstance( 'CirrusSearchChangeFailed' )->warning(
174                "Dropping failing ElasticaWrite job for DataSender::{method} in cluster {cluster} after repeated failure",
175                [
176                    'method' => $this->params['method'],
177                    'cluster' => $conn->getClusterName(),
178                ]
179            );
180            return false;
181        } else {
182            $delay = $this->backoffDelay( $this->params['retryCount'] );
183            $params = $this->params;
184            $params['errorCount']++;
185            unset( $params['jobReleaseTimestamp'] );
186            $jobQueue = MediaWikiServices::getInstance()->getJobQueueGroup();
187            $params += self::buildJobDelayOptions( self::class, $delay, $jobQueue );
188            $job = new self( $params );
189            // Individual failures should have already logged specific errors,
190            LoggerFactory::getInstance( 'CirrusSearch' )->info(
191                "ElasticaWrite job reported failure on cluster {cluster}. Requeueing job with delay of {delay}.",
192                [
193                    'cluster' => $conn->getClusterName(),
194                    'delay' => $delay
195                ]
196            );
197            $jobQueue->push( $job );
198            return true;
199        }
200    }
201}