Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.47% covered (warning)
86.47%
115 / 133
37.50% covered (danger)
37.50%
3 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
QueryBuildDocument
86.47% covered (warning)
86.47%
115 / 133
37.50% covered (danger)
37.50%
3 / 8
34.54
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 execute
81.25% covered (warning)
81.25%
13 / 16
0.00% covered (danger)
0.00%
0 / 1
3.06
 doExecute
88.89% covered (warning)
88.89%
72 / 81
0.00% covered (danger)
0.00%
0 / 1
19.50
 getRevisionIDs
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 getAllowedParams
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 isInternal
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getExamplesMessages
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 markUnrenderable
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace CirrusSearch\Api;
4
5use CirrusSearch\BuildDocument\BuildDocument;
6use CirrusSearch\BuildDocument\DocumentSizeLimiter;
7use CirrusSearch\CirrusConfigNames;
8use CirrusSearch\CirrusSearch;
9use CirrusSearch\PoolCounterKey;
10use CirrusSearch\Profile\SearchProfileService;
11use CirrusSearch\Search\CirrusIndexField;
12use CirrusSearch\SearchConfig;
13use MediaWiki\Api\ApiBase;
14use MediaWiki\Api\ApiQuery;
15use MediaWiki\Api\ApiQueryBase;
16use MediaWiki\Api\ApiResult;
17use MediaWiki\MediaWikiServices;
18use MediaWiki\PoolCounter\PoolCounterWorkViaCallback;
19use MediaWiki\Revision\SlotRecord;
20use Wikimedia\ParamValidator\ParamValidator;
21
22/**
23 * Generate CirrusSearch document for page.
24 *
25 * @license GPL-2.0-or-later
26 */
27class QueryBuildDocument extends ApiQueryBase {
28    use ApiTrait;
29
30    public function __construct( ApiQuery $query, string $moduleName ) {
31        parent::__construct( $query, $moduleName, 'cb' );
32    }
33
34    public function execute() {
35        $engine = MediaWikiServices::getInstance()->getSearchEngineFactory()->create();
36        if ( !( $engine instanceof CirrusSearch ) ) {
37            throw new \RuntimeException( 'Could not create cirrus engine' );
38        }
39
40        if ( $this->getUser()->getName() === $engine->getConfig()->get( CirrusConfigNames::StreamingUpdaterUsername ) ) {
41            // Bypass poolcounter protection for the internal cirrus user
42            $this->doExecute( $engine );
43        } else {
44            // Protect against too many concurrent requests
45            // Use a global key this API is internal and could only be useful for manual debugging purposes
46            // so no real need to have it on per user basis.
47            $worker = new PoolCounterWorkViaCallback( PoolCounterKey::QUERY_BUILD_DOCUMENT, 'QueryBuildDocument',
48                [
49                    'doWork' => function () use ( $engine ) {
50                        return $this->doExecute( $engine );
51                    },
52                    'error' => function (): never {
53                        $this->dieWithError( 'apierror-concurrency-limit' );
54                    },
55                ]
56            );
57            $worker->execute();
58        }
59    }
60
61    public function doExecute( CirrusSearch $engine ) {
62        $result = $this->getResult();
63        $services = MediaWikiServices::getInstance();
64
65        $builders = $this->getParameter( 'builders' );
66        $profile = $this->getParameter( 'limiterprofile' );
67        $flags = 0;
68        if ( !in_array( 'content', $builders ) ) {
69            $flags |= BuildDocument::SKIP_PARSE;
70        }
71        if ( !in_array( 'links', $builders ) ) {
72            $flags |= BuildDocument::SKIP_LINKS;
73        }
74
75        $searchConfig = $engine->getConfig();
76        // When building redirect documents is enabled redirects flow through the normal
77        // builder pipeline and are returned as ordinary documents rather than being marked
78        // unrenderable.
79        $buildRedirects = $searchConfig->buildRedirectDocuments();
80
81        $pages = [];
82        $wikiPageFactory = $services->getWikiPageFactory();
83        $revisionStore = $services->getRevisionStore();
84        $revisionBased = false;
85        if ( $this->getPageSet()->getRevisionIDs() ) {
86            $revisionBased = true;
87            foreach ( $this->getRevisionIDs() as $pageId => $revId ) {
88                $rev = $revisionStore->getRevisionById( $revId );
89                if ( $rev === null ) {
90                    // We cannot trust ApiPageSet to properly identify missing revisions, RevisionStore
91                    // might not agree with it likely because they could be using different db replicas (T370770)
92                    $result->addValue( 'query', 'badrevids', [
93                        $revId => [ 'revid' => $revId, 'missing' => true ]
94                    ] );
95                } elseif ( $rev->audienceCan( $rev::DELETED_TEXT, $rev::FOR_PUBLIC ) ) {
96                    // redirects should only exist if enabled; treat an
97                    // inaccessible main slot as not-a-redirect.
98                    $content = $rev->getContent( SlotRecord::MAIN );
99                    if ( $content && $content->isRedirect() && !$buildRedirects ) {
100                        $this->markUnrenderable( $result, $pageId );
101                    } else {
102                        $pages[$pageId] = $rev;
103                    }
104                } else {
105                    // While the user might have permissions, we want to limit
106                    // what could possibly be indexed to that which is public.
107                    // For an anon this would fail deeper in the system
108                    // anyways, this early check mostly avoids blowing up deep
109                    // in the bowels.
110                    $result->addValue(
111                        [ 'query', 'pages', $pageId ],
112                        'texthidden', true
113                    );
114                }
115            }
116        } else {
117            foreach ( $this->getPageSet()->getGoodPages() as $pageId => $title ) {
118                $page = $wikiPageFactory->newFromTitle( $title );
119                if ( $page->isRedirect() && !$buildRedirects ) {
120                    $this->markUnrenderable( $result, $pageId );
121                } else {
122                    $pages[$pageId] = $page;
123                }
124            }
125        }
126
127        $builder = new BuildDocument(
128            $this->getCirrusConnection(),
129            $this->getDB(),
130            $services->getRevisionStore(),
131            $services->getBacklinkCacheFactory(),
132            new DocumentSizeLimiter( $searchConfig->getProfileService()
133                ->loadProfile( SearchProfileService::DOCUMENT_SIZE_LIMITER, SearchProfileService::CONTEXT_DEFAULT, $profile ) ),
134            $services->getTitleFormatter(),
135            $services->getWikiPageFactory(),
136            $services->getTitleFactory()
137        );
138        $baseMetadata = [];
139        $clusterGroup = $searchConfig->getClusterAssignment()->getCrossClusterName();
140        if ( $clusterGroup !== null ) {
141            $baseMetadata['cluster_group'] = $clusterGroup;
142        }
143        $docs = $builder->initialize( $pages, $flags );
144        foreach ( $docs as $pageId => $doc ) {
145            $pageId = $doc->get( 'page_id' );
146            $revision = $revisionBased ? $pages[$pageId] : null;
147            if ( $builder->finalize( $doc, false, $revision ) ) {
148                $result->addValue(
149                    [ 'query', 'pages', $pageId ],
150                    'cirrusbuilddoc', $doc->getData()
151                );
152                $hints = CirrusIndexField::getHint( $doc, CirrusIndexField::NOOP_HINT );
153                $metadata = [];
154                if ( $hints !== null ) {
155                    $metadata = $baseMetadata + [ 'noop_hints' => $hints ];
156                }
157                $limiterStats = CirrusIndexField::getHint( $doc, DocumentSizeLimiter::HINT_DOC_SIZE_LIMITER_STATS );
158                if ( $limiterStats !== null ) {
159                    $metadata += [ 'size_limiter_stats' => $limiterStats ];
160                }
161                $indexName = $this->getCirrusConnection()->getIndexName( $searchConfig->get( SearchConfig::INDEX_BASE_NAME ),
162                    $this->getCirrusConnection()->getIndexSuffixForNamespace( $doc->get( 'namespace' ) ) );
163                $metadata += [
164                    'index_name' => $indexName
165                ];
166
167                $result->addValue( [ 'query', 'pages', $pageId ],
168                    'cirrusbuilddoc_metadata', $metadata );
169                $result->addValue(
170                    [ 'query', 'pages', $pageId ],
171                    'cirrusbuilddoc_comment',
172                    'The CirrusDoc format is meant for internal use by CirrusSearch for debugging or queries, '
173                    . 'it might change at any time without notice'
174                );
175            }
176        }
177    }
178
179    private function getRevisionIDs(): array {
180        $result = [];
181        $warning = false;
182        foreach ( $this->getPageSet()->getRevisionIDs() as $revId => $pageId ) {
183            if ( isset( $result[$pageId] ) ) {
184                $warning = true;
185                if ( $result[$pageId] >= $revId ) {
186                    continue;
187                }
188            }
189            $result[$pageId] = $revId;
190        }
191        if ( $warning ) {
192            $this->addWarning( [ 'apiwarn-cirrus-ignore-revisions' ] );
193        }
194        return $result;
195    }
196
197    /** @inheritDoc */
198    public function getAllowedParams() {
199        return [
200            'builders' => [
201                ParamValidator::PARAM_DEFAULT => [ 'content', 'links' ],
202                ParamValidator::PARAM_ISMULTI => true,
203                ParamValidator::PARAM_ALLOW_DUPLICATES => false,
204                ParamValidator::PARAM_TYPE => [
205                    'content',
206                    'links',
207                ],
208                ApiBase::PARAM_HELP_MSG => 'apihelp-query+cirrusbuilddoc-param-builders',
209            ],
210            'limiterprofile' => [
211                ParamValidator::PARAM_TYPE => 'string'
212            ],
213        ];
214    }
215
216    /**
217     * Mark as internal. This isn't meant to be used by normal api users
218     * @return bool
219     */
220    public function isInternal() {
221        return true;
222    }
223
224    /**
225     * @see ApiBase::getExamplesMessages
226     * @return array
227     */
228    protected function getExamplesMessages() {
229        return [
230            'action=query&prop=cirrusbuilddoc&titles=Main_Page' =>
231                'apihelp-query+cirrusbuilddoc-example'
232        ];
233    }
234
235    /**
236     * @param ApiResult $result Result obect to write to
237     * @param int $pageId The page to mark unrenderable
238     */
239    private function markUnrenderable( ApiResult $result, int $pageId ) {
240        $result->addValue(
241            [ 'query', 'pages', $pageId ],
242            'unrenderable', true
243        );
244    }
245
246}