Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.59% covered (warning)
86.59%
310 / 358
54.55% covered (warning)
54.55%
6 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiPerformTest
86.59% covered (warning)
86.59%
310 / 358
54.55% covered (warning)
54.55%
6 / 11
91.29
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 execute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 executeGenerator
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 run
80.93% covered (warning)
80.93%
174 / 215
0.00% covered (danger)
0.00%
0 / 1
46.49
 getImplementationListEntry
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 getTesterObject
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 isFalse
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
9.36
 getAllowedParams
n/a
0 / 0
n/a
0 / 0
1
 getExamplesMessages
n/a
0 / 0
n/a
0 / 0
1
 isInternal
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getNumericMetadataValue
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 compareImplementationStats
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 maybeUpdateImplementationRanking
100.00% covered (success)
100.00%
91 / 91
100.00% covered (success)
100.00%
1 / 1
10
1<?php
2/**
3 * WikiLambda function call API
4 *
5 * @file
6 * @ingroup Extensions
7 * @copyright 2020– Abstract Wikipedia team; see AUTHORS.txt
8 * @license MIT
9 */
10
11namespace MediaWiki\Extension\WikiLambda\ActionAPI;
12
13use ApiPageSet;
14use FormatJson;
15use JobQueueGroup;
16use MediaWiki\Extension\WikiLambda\Jobs\CacheTesterResultsJob;
17use MediaWiki\Extension\WikiLambda\Jobs\UpdateImplementationsJob;
18use MediaWiki\Extension\WikiLambda\Registry\ZErrorTypeRegistry;
19use MediaWiki\Extension\WikiLambda\Registry\ZTypeRegistry;
20use MediaWiki\Extension\WikiLambda\ZErrorException;
21use MediaWiki\Extension\WikiLambda\ZErrorFactory;
22use MediaWiki\Extension\WikiLambda\ZObjectFactory;
23use MediaWiki\Extension\WikiLambda\ZObjects\ZObject;
24use MediaWiki\Extension\WikiLambda\ZObjects\ZReference;
25use MediaWiki\Extension\WikiLambda\ZObjects\ZResponseEnvelope;
26use MediaWiki\Extension\WikiLambda\ZObjects\ZString;
27use MediaWiki\Extension\WikiLambda\ZObjects\ZTypedList;
28use MediaWiki\Extension\WikiLambda\ZObjects\ZTypedMap;
29use MediaWiki\Extension\WikiLambda\ZObjectStore;
30use MediaWiki\Extension\WikiLambda\ZObjectUtils;
31use MediaWiki\Logger\LoggerFactory;
32use MediaWiki\MediaWikiServices;
33use MediaWiki\Title\Title;
34use Wikimedia\ParamValidator\ParamValidator;
35
36class ApiPerformTest extends WikiLambdaApiBase {
37
38    private ZObjectStore $zObjectStore;
39    private JobQueueGroup $jobQueueGroup;
40
41    public function __construct( $query, $moduleName, ZObjectStore $zObjectStore ) {
42        parent::__construct( $query, $moduleName, 'wikilambda_perform_test_' );
43
44        $this->zObjectStore = $zObjectStore;
45
46        $this->setUp();
47
48        // TODO (T330033): Consider injecting this service rather than just fetching from main
49        $services = MediaWikiServices::getInstance();
50        $this->jobQueueGroup = $services->getJobQueueGroup();
51    }
52
53    public function execute() {
54        $this->run();
55    }
56
57    public function executeGenerator( $resultPageSet ) {
58        $this->run( $resultPageSet );
59    }
60
61    /**
62     * @param ApiPageSet|null $resultPageSet
63     */
64    private function run( $resultPageSet = null ) {
65        $params = $this->extractRequestParams();
66        $pageResult = $this->getResult();
67        $functionZid = $params[ 'zfunction' ];
68        $requestedImplementations = $params[ 'zimplementations' ] ?: [];
69        $requestedTesters = $params[ 'ztesters' ] ?: [];
70
71        // 1. Work out matrix of what we want for what
72        // TODO (T362190): Consider handling an inline ZFunction (for when it's not been created yet)?
73        $targetTitle = Title::newFromText( $functionZid, NS_MAIN );
74        if ( !$targetTitle || !( $targetTitle->exists() ) ) {
75            $this->dieWithError( [ "wikilambda-performtest-error-unknown-zid", $functionZid ], null, null, 404 );
76        }
77
78        // Needed for caching.
79        $functionRevision = $targetTitle->getLatestRevID();
80
81        // @phan-suppress-next-line PhanTypeMismatchArgumentNullable
82        $targetObject = $this->zObjectStore->fetchZObjectByTitle( $targetTitle );
83        if ( $targetObject->getZType() !== ZTypeRegistry::Z_FUNCTION ) {
84            $this->dieWithError( [ "wikilambda-performtest-error-nonfunction", $functionZid ], null, null, 400 );
85        }
86        $targetFunction = $targetObject->getInnerZObject();
87        '@phan-var \MediaWiki\Extension\WikiLambda\ZObjects\ZFunction $targetFunction';
88
89        if ( !count( $requestedImplementations ) ) {
90            $targetFunctionImplementions = $targetFunction->getValueByKey( ZTypeRegistry::Z_FUNCTION_IMPLEMENTATIONS );
91            '@phan-var \MediaWiki\Extension\WikiLambda\ZObjects\ZTypedList $targetFunctionImplementions';
92            $requestedImplementations = $targetFunctionImplementions->getAsArray();
93        }
94
95        if ( !count( $requestedTesters ) ) {
96            $targetFunctionTesters = $targetFunction->getValueByKey( ZTypeRegistry::Z_FUNCTION_TESTERS );
97            '@phan-var \MediaWiki\Extension\WikiLambda\ZObjects\ZTypedList $targetFunctionTesters';
98            $requestedTesters = $targetFunctionTesters->getAsArray();
99        }
100
101        // We only update the implementation ranking for attached implementations and testers,
102        // and only if all attached implementations and testers are included in the results,
103        // and at least one result is live (not from cache).  These vars are used to track
104        // those conditions.
105        $attachedImplementationZids = $targetFunction->getImplementationZids();
106        $attachedTesterZids = $targetFunction->getTesterZids();
107        $canUpdateImplementationRanking = false;
108
109        // 2. For each implementation, run each tester
110        $responseArray = [];
111        // Map of $implementationZid:$testerMap; used for implementation ranking
112        $implementationMap = [];
113        foreach ( $requestedImplementations as $implementation ) {
114            $inlineImplementation = false;
115            if ( is_string( $implementation ) ) {
116                // (T358089) Decode any '|' characters of ZObjects that were escaped for the API transit
117                $implementation = str_replace( '🪈', '|', $implementation );
118                $decodedJson = FormatJson::decode( $implementation );
119                // If not JSON, assume we have received a ZID.
120                if ( $decodedJson ) {
121                    $inlineImplementation = true;
122
123                    if ( !$this->getContext()->getAuthority()->isAllowed( 'wikilambda-create-implementation' ) ) {
124                        $zError = ZErrorFactory::createZErrorInstance(
125                            ZErrorTypeRegistry::Z_ERROR_USER_CANNOT_RUN,
126                            []
127                        );
128                        $this->dieWithZError( $zError, 403 );
129                    }
130
131                    try {
132                        $implementation = ZObjectFactory::create( $decodedJson );
133                    } catch ( ZErrorException $e ) {
134                        $this->dieWithError(
135                            [
136                                'wikilambda-performtest-error-invalidimplementation',
137                                $e->getZErrorMessage()
138                            ],
139                            null, null, 400
140                        );
141                    }
142                } else {
143                    $implementation = new ZReference( $implementation );
144                }
145            }
146            $implementationZid = ZObjectUtils::getZid( $implementation );
147            $implementationListEntry = $this->getImplementationListEntry( $implementation );
148
149            // Note that the Implementation ZID can be non-Z0 if it's being run on an unsaved edit.
150            $implementationRevision = null;
151            if ( !$inlineImplementation ) {
152                $title = Title::newFromText( $implementationZid, NS_MAIN );
153                if ( $title ) {
154                    $implementationRevision = $title->getLatestRevID();
155                } else {
156                    $this->dieWithError(
157                        [
158                            'wikilambda-performtest-error-invalidimplementation',
159                            $implementationZid
160                        ],
161                        null, null, 400
162                    );
163                }
164            }
165
166            // Re-use our copy of the target function, setting the implementations to just the one
167            // we're testing now
168            $targetFunction->setValueByKey(
169                ZTypeRegistry::Z_FUNCTION_IMPLEMENTATIONS,
170                new ZTypedList(
171                    ZTypedList::buildType( new ZReference( ZTypeRegistry::Z_IMPLEMENTATION ) ),
172                    $implementationListEntry
173                )
174            );
175            // Map of $testerZid:$testResult for a particular implementation
176            $testerMap = [];
177            foreach ( $requestedTesters as $requestedTester ) {
178                $passed = true;
179                $testResult = [
180                    'zFunctionId' => $functionZid,
181                    'zImplementationId' => $implementationZid,
182                ];
183
184                $inlineTester = false;
185                if ( is_string( $requestedTester ) ) {
186                    // (T358089) Decode any '|' characters of ZObjects that were escaped for the API transit
187                    $requestedTester = str_replace( '🪈', '|', $requestedTester );
188                    $decodedJson = FormatJson::decode( $requestedTester );
189                    // If not JSON, assume we have received a ZID.
190                    if ( $decodedJson ) {
191                        $inlineTester = true;
192
193                        if ( !$this->getContext()->getAuthority()->isAllowed( 'wikilambda-create-tester' ) ) {
194                            $zError = ZErrorFactory::createZErrorInstance(
195                                ZErrorTypeRegistry::Z_ERROR_USER_CANNOT_RUN,
196                                []
197                            );
198                            $this->dieWithZError( $zError, 403 );
199                        }
200
201                        try {
202                            $requestedTester = ZObjectFactory::create( $decodedJson );
203                        } catch ( ZErrorException $e ) {
204                            $this->dieWithError(
205                                [
206                                    'wikilambda-performtest-error-invalidtester',
207                                    $e->getZErrorMessage()
208                                ],
209                                null, null, 400
210                            );
211                        }
212                    } else {
213                        $requestedTester = new ZReference( $requestedTester );
214                    }
215                }
216
217                $testerZid = ZObjectUtils::getZid( $requestedTester );
218                $testResult[ 'zTesterId' ] = $testerZid;
219                $testerObject = $this->getTesterObject( $requestedTester );
220
221                // Note that the Tester ZID can be non-Z0 if it's being run on an unsaved edit.
222                $testerRevision = null;
223                if ( !$inlineTester ) {
224                    $title = Title::newFromText( $testerZid, NS_MAIN );
225                    if ( $title ) {
226                        $testerRevision = $title->getLatestRevID();
227                    } else {
228                        $this->dieWithError(
229                            [
230                                'wikilambda-performtest-error-invalidtester',
231                                $testerZid
232                            ],
233                            null, null, 400
234                        );
235                    }
236                }
237
238                // (T297707): Work out if this has been cached before (checking revisions of objects),
239                // and if so reply with that instead of executing.
240                if ( !$inlineImplementation && !$inlineTester ) {
241                    $possiblyCachedResult = $this->zObjectStore->findZTesterResult(
242                        $functionZid,
243                        $functionRevision,
244                        $implementationZid,
245                        $implementationRevision,
246                        $testerZid,
247                        $testerRevision,
248                    );
249
250                    if ( $possiblyCachedResult ) {
251                        $possiblyCachedResult->setMetaDataValue(
252                            "loadedFromMediaWikiCache",
253                            new ZString( date( 'Y-m-d\TH:i:s\Z' ) )
254                        );
255
256                        $this->getLogger()->debug(
257                            'Cache result hit: ' . $possiblyCachedResult->getZValue(),
258                            []
259                        );
260                        $testResult[ 'validateStatus' ] = $possiblyCachedResult->getZValue();
261                        $testResult[ 'testMetadata'] = $possiblyCachedResult->getZMetadata();
262
263                        $responseArray[] = $testResult;
264                        // Update bookkeeping for the call to maybeUpdateImplementationRanking, if needed.
265                        // Implementation ranking only involves attached implementations and testers.
266                        if ( in_array( $testerZid, $attachedTesterZids ) &&
267                            in_array( $implementationZid, $attachedImplementationZids ) ) {
268                            $testerMap[$testerZid] = $testResult;
269                        }
270                        continue;
271                    }
272                }
273
274                // Use tester to create a function call of the test case inputs
275                $testFunctionCall = $testerObject->getValueByKey( ZTypeRegistry::Z_TESTER_CALL );
276                '@phan-var \MediaWiki\Extension\WikiLambda\ZObjects\ZFunctionCall $testFunctionCall';
277
278                // Set the target function of the call to our modified copy of the target function with only the
279                // current implementation
280                $testFunctionCall->setValueByKey( ZTypeRegistry::Z_FUNCTIONCALL_FUNCTION, $targetFunction );
281
282                // Execute the test case function call
283                $testResultObject = $this->executeFunctionCall( $testFunctionCall, true );
284                $testMetadata = $testResultObject->getValueByKey( ZTypeRegistry::Z_RESPONSEENVELOPE_METADATA );
285                '@phan-var \MediaWiki\Extension\WikiLambda\ZObjects\ZTypedMap $testMetadata';
286
287                // Use tester to create a function call validating the output
288                $validateTestValue = $testResultObject->hasErrors() ?
289                    $testResultObject->getErrors() :
290                    $testResultObject->getZValue();
291
292                $validateFunctionCall = $testerObject->getValueByKey( ZTypeRegistry::Z_TESTER_VALIDATION );
293                '@phan-var \MediaWiki\Extension\WikiLambda\ZObjects\ZFunctionCall $validateFunctionCall';
294
295                $targetValidationFunctionZID = $validateFunctionCall->getZValue();
296                $validateFunctionCall->setValueByKey( $targetValidationFunctionZID . 'K1', $validateTestValue );
297
298                // Execute the validation function call and stash it
299                $validateResult = $this->executeFunctionCall( $validateFunctionCall, false );
300
301                // If the running failed, we explicitly set the tester passing as false
302                if ( $validateResult->hasErrors() ) {
303                    $validateResult->setValueByKey(
304                        ZTypeRegistry::Z_RESPONSEENVELOPE_VALUE,
305                        new ZReference( ZTypeRegistry::Z_BOOLEAN_FALSE )
306                    );
307                    // Add the validator errors to the metadata map
308                    $testMetadata->setValueForKey(
309                        new ZString( "validateErrors" ),
310                        $validateResult->getErrors() );
311                }
312
313                $validateResultItem = $validateResult->getZValue();
314
315                if ( self::isFalse( $validateResultItem ) ) {
316                    $passed = false;
317                    // Add the expected and actual values to the metadata map
318                    $testMetadata->setValueForKey(
319                        new ZString( "actualTestResult" ),
320                        $validateTestValue
321                    );
322                    $testMetadata->setValueForKey(
323                        new ZString( "expectedTestResult" ),
324                        $validateFunctionCall->getValueByKey( $targetValidationFunctionZID . 'K2' )
325                    );
326                }
327
328                // (T297707): Store this response in a DB table for faster future responses.
329                // We can only do this for persisted revisions, not inline items, as we can't
330                // version them otherwise, so use truthiness (neither null nor 0, non-extant).
331                // We also only do this if the validation step didn't have an error itself.
332                if (
333                    !$inlineImplementation && !$inlineTester &&
334                    !$validateResult->hasErrors()
335                ) {
336                    // Store a fake ZResponseEnvelope of the validation result and the real meta-data run
337                    // via an asynchronous job so that we don't trigger a "DB write on API GET" performance
338                    // error.
339                    $this->getLogger()->debug(
340                        'Tester result cache job triggered',
341                        [
342                            'functionZid' => $functionZid,
343                            'functionRevision' => $functionRevision
344                        ]
345                    );
346
347                    $stashedResult = new ZResponseEnvelope( $validateResultItem, $testMetadata );
348
349                    $cacheTesterResultsJob = new CacheTesterResultsJob(
350                        [
351                            'functionZid' => $functionZid,
352                            'functionRevision' => $functionRevision,
353                            'implementationZid' => $implementationZid,
354                            'implementationRevision' => $implementationRevision,
355                            'testerZid' => $testerZid,
356                            'testerRevision' => $testerRevision,
357                            'passed' => $passed,
358                            'stashedResult' => $stashedResult->__toString()
359                            ]
360                    );
361
362                    $this->jobQueueGroup->push( $cacheTesterResultsJob );
363                }
364
365                // Stash the response
366                $testResult[ 'validateStatus' ] = $validateResultItem;
367                $testResult[ 'testMetadata' ] = $testMetadata;
368                $responseArray[] = $testResult;
369
370                // Update bookkeeping for the call to maybeUpdateImplementationRanking, if needed.
371                // Implementation ranking only involves attached implementations and testers.
372                if ( in_array( $testerZid, $attachedTesterZids ) &&
373                    in_array( $implementationZid, $attachedImplementationZids ) ) {
374                    $testerMap[$testerZid] = $testResult;
375                    // Since this $testResult is "live" (not from cache), indicating that the
376                    // function, implementation, or tester has changed, we should check
377                    // if there is an improved implementation ranking
378                    // TODO (T330370): Revisit this strategy when we have more experience with it
379                    $canUpdateImplementationRanking = true;
380                }
381            }
382            // Update bookkeeping for the call to maybeUpdateImplementationRanking, if needed.
383            if ( in_array( $implementationZid, $attachedImplementationZids ) ) {
384                $implementationMap[ $implementationZid ] = $testerMap;
385            }
386        }
387
388        // 3. Maybe update implementation ranking (in persistent storage)
389        if ( $canUpdateImplementationRanking ) {
390            $this->maybeUpdateImplementationRanking(
391                $functionZid,
392                $functionRevision,
393                $implementationMap,
394                $attachedImplementationZids,
395                $attachedTesterZids
396            );
397        } else {
398            $this->getLogger()->info(
399                __METHOD__ . ' Not updating {functionZid} implementation ranking; no live results',
400                [
401                    'functionZid' => $functionZid,
402                    'canUpdateImplementationRanking' => $canUpdateImplementationRanking
403                ]
404            );
405        }
406
407        // 4. Return the response.
408        $pageResult->addValue( [ 'query' ], $this->getModuleName(), $responseArray );
409    }
410
411    private function getImplementationListEntry( $zobject ) {
412        if ( $zobject->getZType() === ZTypeRegistry::Z_REFERENCE ||
413                $zobject->getZType() === ZTypeRegistry::Z_IMPLEMENTATION ) {
414            return $zobject;
415        } elseif ( $zobject->getZType() === ZTypeRegistry::Z_PERSISTENTOBJECT ) {
416            return $this->getImplementationListEntry(
417                $zobject->getValueByKey( ZTypeRegistry::Z_PERSISTENTOBJECT_VALUE ) );
418        }
419        $this->dieWithError( [ "wikilambda-performtest-error-nonimplementation", $zobject ], null, null, 400 );
420    }
421
422    private function getTesterObject( $zobject ) {
423        if ( $zobject->getZType() === ZTypeRegistry::Z_REFERENCE ) {
424            $zid = ZObjectUtils::getZid( $zobject );
425            $title = Title::newFromText( $zid, NS_MAIN );
426            if ( !( $title instanceof Title ) || !$title->exists() ) {
427                $this->dieWithError( [ "wikilambda-performtest-error-unknown-zid", $zid ], null, null, 404 );
428            }
429            // @phan-suppress-next-line PhanTypeMismatchArgumentNullable
430            return $this->getTesterObject( $this->zObjectStore->fetchZObjectByTitle( $title )->getInnerZObject() );
431        } elseif ( $zobject->getZType() === ZTypeRegistry::Z_PERSISTENTOBJECT ) {
432            return $this->getTesterObject( $zobject->getValueByKey( ZTypeRegistry::Z_PERSISTENTOBJECT_VALUE ) );
433        } elseif ( $zobject->getZType() === ZTypeRegistry::Z_TESTER ) {
434            return $zobject;
435        }
436        $this->dieWithError( [ "wikilambda-performtest-error-nontester", $zobject ], null, null, 400 );
437    }
438
439    private static function isFalse( $object ) {
440        if ( $object instanceof ZObject ) {
441            if ( $object instanceof ZReference ) {
442                return self::isFalse( $object->getZValue() );
443            } elseif ( $object->getZType() === ZTypeRegistry::Z_BOOLEAN ) {
444                return self::isFalse( $object->getValueByKey( ZTypeRegistry::Z_BOOLEAN_VALUE ) );
445            }
446        } elseif ( $object instanceof \stdClass ) {
447            if ( $object->{ ZTypeRegistry::Z_OBJECT_TYPE } === ZTypeRegistry::Z_REFERENCE ) {
448                return self::isFalse( $object->{ ZTypeRegistry::Z_REFERENCE_VALUE } );
449            } elseif ( $object->{ ZTypeRegistry::Z_OBJECT_TYPE } === ZTypeRegistry::Z_BOOLEAN ) {
450                return self::isFalse( $object->{ ZTypeRegistry::Z_BOOLEAN_VALUE } );
451            }
452        }
453        return $object === ZTypeRegistry::Z_BOOLEAN_FALSE;
454    }
455
456    /**
457     * @inheritDoc
458     * @codeCoverageIgnore
459     */
460    protected function getAllowedParams(): array {
461        return [
462            'zfunction' => [
463                ParamValidator::PARAM_TYPE => 'text',
464                ParamValidator::PARAM_REQUIRED => true,
465            ],
466            'zimplementations' => [
467                ParamValidator::PARAM_ISMULTI => true,
468                ParamValidator::PARAM_REQUIRED => false,
469            ],
470            'ztesters' => [
471                ParamValidator::PARAM_ISMULTI => true,
472                ParamValidator::PARAM_REQUIRED => false,
473            ],
474        ];
475    }
476
477    /**
478     * @see ApiBase::getExamplesMessages()
479     * @return array
480     * @codeCoverageIgnore
481     */
482    protected function getExamplesMessages() {
483        $exampleZid = $this->zObjectStore->findFirstZImplementationFunction();
484
485        $queryPrefix = 'action=wikilambda_perform_test&format=json&wikilambda_perform_test_zfunction=';
486
487        return [
488            $queryPrefix . $exampleZid
489                => 'apihelp-wikilambda_perform_test-example',
490            $queryPrefix . 'Z801'
491                => 'apihelp-wikilambda_perform_test-z801',
492            $queryPrefix . 'Z801&wikilambda_perform_test_zimplementations=Z901'
493                => 'apihelp-wikilambda_perform_test-z801-implementation',
494            $queryPrefix . 'Z801&wikilambda_perform_test_ztesters=Z8010|Z8011'
495                => 'apihelp-wikilambda_perform_test-z801-tester',
496            $queryPrefix . 'Z801&wikilambda_perform_test_zimplementations=Z901&wikilambda_perform_test_ztesters=Z8010'
497                => 'apihelp-wikilambda_perform_test-z801-implementation-and-testers',
498        ];
499    }
500
501    /**
502     * Mark as internal. This isn't meant to be user-facing, and can change at any time.
503     * @return bool
504     */
505    public function isInternal() {
506        return true;
507    }
508
509    /**
510     * Retrieves the $metadataMap value for ZString($keyString) and converts it to a float.  It must
511     * be a value of type ZString, whose underlying string begins with a float, e.g. '320.815 ms'.
512     * If ZString($keyString) isn't used in $metadataMap, returns zero.
513     *
514     * N.B. We do not check the units here; we assume that they are always the same (i.e.,
515     * milliseconds) for the values retrieved by this function.  This consistency is primarily
516     * the responsibility of the backend services that generate the metadata elements.
517     *
518     * @param ZTypedMap $metadataMap
519     * @param string $keyString
520     * @return float
521     */
522    private static function getNumericMetadataValue( $metadataMap, $keyString ) {
523        $key = new ZString( $keyString );
524        $value = $metadataMap->getValueGivenKey( $key );
525        if ( !$value ) {
526            return 0;
527        }
528        $value = $value->getZValue();
529        return floatval( $value );
530    }
531
532    /**
533     * Callback for uasort() to order the implementations for a function that's been tested
534     * @param array $a Implementation stats
535     * @param array $b Implementation stats
536     * @return int Result of comparison
537     */
538    private static function compareImplementationStats( $a, $b ) {
539        if ( $a[ 'numFailed' ] < $b[ 'numFailed' ] ) {
540            return -1;
541        }
542        if ( $b[ 'numFailed' ] < $a[ 'numFailed' ] ) {
543            return 1;
544        }
545        if ( $a[ 'averageTime' ] < $b[ 'averageTime' ] ) {
546            return -1;
547        }
548        if ( $b[ 'averageTime' ] < $a[ 'averageTime' ] ) {
549            return 1;
550        }
551        return 0;
552    }
553
554    /**
555     * Based on tester results contained in $implementationMap, order the implementations of the
556     * given function from best-performing to worst-performing (in terms of speed).  If the
557     * ordering is significantly different than the previous ordering for this function, instantiate
558     * an asynchronous job to update Z8K4/implementations in the function's persistent storage.
559     *
560     * TODO (T329138): Consider whether average Cpu usage is good enough to determine the ranking.
561     *   Should we eliminate implementations that are outliers relative to others on the same test?
562     *   Should we consider non-CPU time needed to, e.g., retrieve info from Wikidata?
563     *
564     * @param string $functionZid
565     * @param int $functionRevision
566     * @param array $implementationMap contains $implementationZid => $testerMap, for each tested
567     * implementation.  $testerMap contains $testerZid => $testResult for each tester. See
568     * ApiPerformTest::run for the structure of $testResult.
569     * @param array $attachedImplementationZids
570     * @param array $attachedTesterZids
571     */
572    public static function maybeUpdateImplementationRanking(
573        $functionZid, $functionRevision, $implementationMap, $attachedImplementationZids, $attachedTesterZids
574    ) {
575        // NOTE: As this code is static for testing purposes, we can't use $this->getLogger() here
576        $logger = LoggerFactory::getInstance( 'WikiLambda' );
577
578        // We don't currently support updates involving a Z0, and we don't expect to get any here.
579        // (However, it maybe could happen if the value of Z8K4 has been manually edited.)
580        unset( $implementationMap[ ZTypeRegistry::Z_NULL_REFERENCE ] );
581
582        if ( count( $attachedImplementationZids ) <= 1 ) {
583            // No point in updating.
584            $logger->debug(
585                __METHOD__ . ' Not updating {functionZid}: Implementation count <= 1',
586                [
587                    'functionZid' => $functionZid,
588                    'functionRevision' => $functionRevision,
589                    'implementationMap' => $implementationMap
590                ]
591            );
592            return;
593        }
594
595        // We only update if we have results for all currently attached implementations,
596        // and all currently attached testers.  We already know that the implementations and
597        // testers in the maps are attached; now we check whether all attached ones are present.
598        $implementationZids = array_keys( $implementationMap );
599        $testerZids = array_keys( reset( $implementationMap ) );
600        if ( array_diff( $attachedImplementationZids, $implementationZids ) ||
601            array_diff( $attachedTesterZids, $testerZids ) ) {
602            $logger->debug(
603                __METHOD__ . ' Not updating {functionZid}: Missing results for attached implementations or testers',
604                [
605                    'functionZid' => $functionZid,
606                    'functionRevision' => $functionRevision,
607                    'attachedImplementationZids' => $attachedImplementationZids,
608                    'implementationZids' => $implementationZids,
609                    'attachedTesterZids' => $attachedTesterZids,
610                    'testerZids' => $testerZids,
611                    'implementationMap' => $implementationMap
612                ]
613            );
614            return;
615        }
616
617        // Record which implementation is first in Z8K4 before this update happens
618        $previousFirst = $attachedImplementationZids[ 0 ];
619
620        // For each implementation, get (count of tests-failed) and (average runtime of tests)
621        // and add them into $implementationMap.
622        // TODO (T314539): Revisit Use of (count of tests-failed) after failing implementations are
623        //   routinely deactivated
624        foreach ( $implementationMap as $implementationZid => $testerMap ) {
625            $numFailed = 0;
626            $averageTime = 0.0;
627            foreach ( $testerMap as $testerId => $testResult ) {
628                if ( self::isFalse( $testResult[ 'validateStatus' ] ) ) {
629                    $numFailed++;
630                }
631                $metadataMap = $testResult[ 'testMetadata' ];
632                '@phan-var \MediaWiki\Extension\WikiLambda\ZObjects\ZTypedMap $metadataMap';
633                $averageTime +=
634                    ( self::getNumericMetadataValue( $metadataMap, 'orchestrationCpuUsage' )
635                    + self::getNumericMetadataValue( $metadataMap, 'evaluationCpuUsage' )
636                    + self::getNumericMetadataValue( $metadataMap, 'executionCpuUsage' ) );
637            }
638            $averageTime /= count( $testerMap );
639            $implementationMap[ $implementationZid ][ 'numFailed' ] = $numFailed;
640            $implementationMap[ $implementationZid ][ 'averageTime' ] = $averageTime;
641        }
642
643        uasort( $implementationMap, [ self::class, 'compareImplementationStats' ] );
644        // Get the ranked Zids
645
646        // Bail out if the new first element is the same as the previous
647        $newFirst = array_key_first( $implementationMap );
648        if ( $newFirst === $previousFirst ) {
649            $logger->debug(
650                __METHOD__ . ' Not updating {functionZid}: Same first element',
651                [
652                    'functionZid' => $functionZid,
653                    'functionRevision' => $functionRevision,
654                    'previousFirst' => $previousFirst,
655                    'implementationMap' => $implementationMap
656                ]
657            );
658            return;
659        }
660
661        // Bail out if the performance of $newFirst is only marginally better than the
662        // performance of $previousFirst.  Note: if numFailed of $newFirst is less than
663        // numFailed of $previousFirst, then we should *not* bail out.
664        // TODO (T329138): Also consider:
665        //   Check if all of the average times are roughly indistinguishable.
666        $previousFirstStats = $implementationMap[ $previousFirst ];
667        $newFirstStats = $implementationMap[ $newFirst ];
668        $relativeThreshold = 0.8;
669        if ( $newFirstStats[ 'averageTime' ] >= $relativeThreshold * $previousFirstStats[ 'averageTime' ] &&
670            $newFirstStats[ 'numFailed' ] >= $previousFirstStats[ 'numFailed' ] ) {
671            $logger->debug(
672                __METHOD__ . ' Not updating {functionZid}: New first element only marginally better than previous',
673                [
674                    'functionZid' => $functionZid,
675                    'functionRevision' => $functionRevision,
676                    'previousFirst' => $previousFirst,
677                    'newFirst' => $newFirst,
678                    'implementationMap' => $implementationMap
679                ]
680            );
681            return;
682        }
683
684        $implementationRankingZids = array_keys( $implementationMap );
685        $logger->info(
686            __METHOD__ . ' Creating UpdateImplementationsJob for {functionZid}',
687            [
688                'functionZid' => $functionZid,
689                'functionRevision' => $functionRevision,
690                'implementationRankingZids' => $implementationRankingZids,
691                'implementationMap' => $implementationMap
692            ]
693        );
694
695        $updateImplementationsJob = new UpdateImplementationsJob(
696            [ 'functionZid' => $functionZid,
697                'functionRevision' => $functionRevision,
698                'implementationRankingZids' => $implementationRankingZids
699            ] );
700        // NOTE: As this code is static for testing purposes, we can't use $this->jobQueueGroup here
701        // TODO (T330033): Consider using an injected service for the following
702        $services = MediaWikiServices::getInstance();
703        $jobQueueGroup = $services->getJobQueueGroup();
704        $jobQueueGroup->push( $updateImplementationsJob );
705    }
706}