Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.65% covered (warning)
77.65%
337 / 434
40.00% covered (danger)
40.00%
4 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiPerformTest
77.65% covered (warning)
77.65%
337 / 434
40.00% covered (danger)
40.00%
4 / 10
183.45
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 run
76.92% covered (warning)
76.92%
170 / 221
0.00% covered (danger)
0.00%
0 / 1
46.38
 validateRequestedObject
66.67% covered (warning)
66.67%
42 / 63
0.00% covered (danger)
0.00%
0 / 1
34.81
 getImplementationObject
26.67% covered (danger)
26.67%
4 / 15
0.00% covered (danger)
0.00%
0 / 1
10.31
 getTesterObject
25.00% covered (danger)
25.00%
4 / 16
0.00% covered (danger)
0.00%
0 / 1
15.55
 isFalse
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
7
 getAllowedParams
n/a
0 / 0
n/a
0 / 0
1
 getExamplesMessages
n/a
0 / 0
n/a
0 / 0
2
 isInternal
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getNumericMetadataValue
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 compareImplementationStats
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 maybeUpdateImplementationRanking
100.00% covered (success)
100.00%
88 / 88
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 MediaWiki\Api\ApiMain;
14use MediaWiki\Api\ApiUsageException;
15use MediaWiki\Extension\WikiLambda\HttpStatus;
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\ZBoolean;
24use MediaWiki\Extension\WikiLambda\ZObjects\ZObject;
25use MediaWiki\Extension\WikiLambda\ZObjects\ZPersistentObject;
26use MediaWiki\Extension\WikiLambda\ZObjects\ZReference;
27use MediaWiki\Extension\WikiLambda\ZObjects\ZResponseEnvelope;
28use MediaWiki\Extension\WikiLambda\ZObjects\ZString;
29use MediaWiki\Extension\WikiLambda\ZObjects\ZTypedList;
30use MediaWiki\Extension\WikiLambda\ZObjects\ZTypedMap;
31use MediaWiki\Extension\WikiLambda\ZObjectStore;
32use MediaWiki\Extension\WikiLambda\ZObjectUtils;
33use MediaWiki\JobQueue\JobQueueGroup;
34use MediaWiki\Json\FormatJson;
35use MediaWiki\Logger\LoggerFactory;
36use MediaWiki\MediaWikiServices;
37use MediaWiki\Title\Title;
38use Wikimedia\ParamValidator\ParamValidator;
39
40class ApiPerformTest extends WikiLambdaApiBase {
41
42    private JobQueueGroup $jobQueueGroup;
43
44    public function __construct(
45        ApiMain $mainModule,
46        string $moduleName,
47        private readonly ZObjectStore $zObjectStore
48    ) {
49        parent::__construct( $mainModule, $moduleName, 'wikilambda_perform_test_' );
50
51        $this->setUp();
52
53        // TODO (T330033): Consider injecting this service rather than just fetching from main
54        $services = MediaWikiServices::getInstance();
55        $this->jobQueueGroup = $services->getJobQueueGroup();
56    }
57
58    /**
59     * @inheritDoc
60     */
61    protected function run() {
62        $params = $this->extractRequestParams();
63        $pageResult = $this->getResult();
64        $functionZid = $params[ 'zfunction' ];
65        $requestedImplementations = $params[ 'zimplementations' ] ?: [];
66        $requestedTesters = $params[ 'ztesters' ] ?: [];
67
68        // 1. Work out the matrix of implementations/testers that we want to run
69        // TODO (T362190): Consider handling an inline ZFunction (for when it's not been created yet)?
70
71        // 1.a. Check that Function exists
72        $targetTitle = Title::newFromText( $functionZid, NS_MAIN );
73        if ( !$targetTitle || !( $targetTitle->exists() ) ) {
74            $this->dieWithError(
75                [ "wikilambda-performtest-error-unknown-zid", $functionZid ],
76                null,
77                null,
78                HttpStatus::NOT_FOUND
79            );
80        }
81
82        // 1.b. Check that Function Zid belongs to an object of the right type (Z8/Function)
83        $targetObject = $this->zObjectStore->fetchZObjectByTitle( $targetTitle );
84        if ( $targetObject->getZType() !== ZTypeRegistry::Z_FUNCTION ) {
85            $this->dieWithError(
86                [ "wikilambda-performtest-error-nonfunction", $functionZid ],
87                null,
88                null,
89                HttpStatus::BAD_REQUEST
90            );
91        }
92
93        // Get function latest revision Id for caching
94        $functionRevision = $targetTitle->getLatestRevID();
95        $targetFunction = $targetObject->getInnerZObject();
96        '@phan-var \MediaWiki\Extension\WikiLambda\ZObjects\ZFunction $targetFunction';
97
98        // 1.c. If no specific implementation Zids are passed in the request, test all of them (connected ones)
99        if ( !count( $requestedImplementations ) ) {
100            $targetFunctionImplementions = $targetFunction->getValueByKey( ZTypeRegistry::Z_FUNCTION_IMPLEMENTATIONS );
101            '@phan-var \MediaWiki\Extension\WikiLambda\ZObjects\ZTypedList $targetFunctionImplementions';
102            $requestedImplementations = $targetFunctionImplementions->getAsArray();
103        }
104
105        // 1.d. If no specific tester Zids are passed in the request, run all of them (connected ones)
106        if ( !count( $requestedTesters ) ) {
107            $targetFunctionTesters = $targetFunction->getValueByKey( ZTypeRegistry::Z_FUNCTION_TESTERS );
108            '@phan-var \MediaWiki\Extension\WikiLambda\ZObjects\ZTypedList $targetFunctionTesters';
109            $requestedTesters = $targetFunctionTesters->getAsArray();
110        }
111
112        // We only update the implementation ranking for connected implementations and testers,
113        // and only if all connected implementations and testers are included in the results,
114        // and at least one result is live (not from cache).
115        // These vars are used to track those conditions.
116        $attachedImplementationZids = $targetFunction->getImplementationZids();
117        $attachedTesterZids = $targetFunction->getTesterZids();
118        $canUpdateImplementationRanking = false;
119
120        // 2. For each selected implementation, run each selected tester
121
122        // Exit early flags
123        $exitEarly = false;
124        $exitEarlyResponse = null;
125        // Array of test results for each implementation:tester combination
126        $responseArray = [];
127        // Map of $implementationZid:$testerMap; used for implementation ranking
128        $implementationMap = [];
129
130        foreach ( $requestedImplementations as $implementation ) {
131            // 2.a. Validate the implementation passed in the request, and if all goes well,
132            // get all the details needed (zid, revision, inner object and whether its passed inline)
133            [ $inlineImplementation,
134                $implementationZid,
135                $implementationObject,
136                $implementationRevision,
137                $implementationZError
138            ] = $this->validateRequestedObject( $implementation, ZTypeRegistry::Z_IMPLEMENTATION );
139
140            // Initial validation of implementation went well! We prepare to iterate through the testers
141
142            // 2.b. Re-use our copy of the target function, setting the implementations
143            // to a list with just the one we're testing now
144            $targetFunction->setValueByKey(
145                ZTypeRegistry::Z_FUNCTION_IMPLEMENTATIONS,
146                new ZTypedList(
147                    ZTypedList::buildType( new ZReference( ZTypeRegistry::Z_IMPLEMENTATION ) ),
148                    $implementationObject
149                )
150            );
151
152            // 3. For each tester passed in the request, perform function call
153            // against the current implementation.
154
155            // Map of $testerZid:$testResult for a particular implementation
156            $testerMap = [];
157
158            foreach ( $requestedTesters as $requestedTester ) {
159                // 3.a. Validate the tester passed in the request, and if all goes well,
160                // get all the details needed (zid, revision, inner object and whether its passed inline)
161                [ $inlineTester,
162                    $testerZid,
163                    $testerObject,
164                    $testerRevision,
165                    $testerZError
166                ] = $this->validateRequestedObject( $requestedTester, ZTypeRegistry::Z_TESTER );
167
168                // Initial validation of tester went well! We prepare to run the calls
169
170                // 3.b. Initialize test result object
171                $passed = true;
172                $testResult = [
173                    'zFunctionId' => $functionZid,
174                    'zImplementationId' => $implementationZid,
175                    'zTesterId' => $testerZid
176                ];
177
178                // 3.c. If there was any validation error, set test result as false and create error metadata
179                if ( $implementationZError || $testerZError ) {
180                    $testResult[ 'validateStatus' ] = new ZBoolean( false );
181                    $testResult[ 'testMetadata' ] = ZResponseEnvelope::wrapInResponseMap(
182                        'validateErrors',
183                        $implementationZError ?: $testerZError
184                    );
185
186                    // Next implementation:test iteration
187                    $responseArray[] = $testResult;
188                    continue;
189                }
190
191                // 3.d. (T297707): Work out if this has been cached before (checking revisions of objects),
192                // and if so reply with that instead of executing.
193                if ( !$inlineImplementation && !$inlineTester ) {
194                    $possiblyCachedResult = $this->zObjectStore->findZTesterResult(
195                        $functionZid,
196                        $functionRevision,
197                        $implementationZid,
198                        $implementationRevision,
199                        $testerZid,
200                        $testerRevision,
201                    );
202
203                    if ( $possiblyCachedResult ) {
204                        $possiblyCachedResult->setMetaDataValue(
205                            "loadedFromMediaWikiCache",
206                            new ZString( date( 'Y-m-d\TH:i:s\Z' ) )
207                        );
208
209                        $this->getLogger()->debug( 'Cache result hit: ' . $possiblyCachedResult->getZValue() );
210                        $testResult[ 'validateStatus' ] = $possiblyCachedResult->getZValue();
211                        $testResult[ 'testMetadata'] = $possiblyCachedResult->getZMetadata();
212
213                        // Update bookkeeping for the call to maybeUpdateImplementationRanking, if needed.
214                        // Implementation ranking only involves attached implementations and testers.
215                        if ( in_array( $testerZid, $attachedTesterZids ) &&
216                            in_array( $implementationZid, $attachedImplementationZids ) ) {
217                            $testerMap[$testerZid] = $testResult;
218                        }
219
220                        // Next implementation:test iteration
221                        $responseArray[] = $testResult;
222                        continue;
223                    }
224                }
225
226                // 3.e. If there was an exitEarly error, avoid execution and set result to exitEarlyRespone
227                if ( $exitEarly ) {
228                    $testResult[ 'validateStatus' ] = new ZBoolean( false );
229                    $testResult[ 'testMetadata' ] = $exitEarlyResponse;
230
231                    // Next implementation:test iteration
232                    $responseArray[] = $testResult;
233                    continue;
234                }
235
236                // 3.f. Use tester to create a function call of the test case inputs
237                $testFunctionCall = $testerObject->getValueByKey( ZTypeRegistry::Z_TESTER_CALL );
238                '@phan-var \MediaWiki\Extension\WikiLambda\ZObjects\ZFunctionCall $testFunctionCall';
239
240                // 3.g. Set the target function of the call to our modified copy of the
241                // target function with only the current implementation.
242                // It might not be the top level Z7K1, so descend down the nested function
243                // call to find the position of the target before setting the new value.
244                $testFunctionCall = ZObjectUtils::dereferenceZFunction(
245                    $testFunctionCall,
246                    $functionZid,
247                    $targetFunction
248                );
249
250                // 3.h. Execute the test case function call
251                try {
252                    $flags = [ 'isUnsavedCode' => $inlineImplementation ];
253                    $response = $this->executeFunctionCall( $testFunctionCall, $flags );
254                } catch ( ApiUsageException $e ) {
255                    // If reached concurrency limit, add failed response and stop further executions
256                    if ( $e->getCode() === HttpStatus::TOO_MANY_REQUESTS ) {
257                        $zError = ZErrorFactory::createZErrorInstance( ZErrorTypeRegistry::Z_ERROR_UNKNOWN, [
258                            'message' => $e->getMessage()
259                        ] );
260
261                        // We set $exitEarly flag and failed response for all following executions from the matrix
262                        $exitEarly = true;
263                        $exitEarlyResponse = ZResponseEnvelope::wrapInResponseMap( 'errors', $zError );
264
265                        $testResult[ 'validateStatus' ] = new ZBoolean( false );
266                        $testResult[ 'testMetadata' ] = $exitEarlyResponse;
267
268                        // Next implementation:test iteration
269                        $responseArray[] = $testResult;
270                        continue;
271                    }
272
273                    throw $e;
274                }
275
276                // 3.i. Get a valid Response Envelope/Z22 object by passing it through ZObjectFactory::create
277                // If the orchestrator response is not valid, it will build and return a Response Envelope/Z22
278                // with the error information in its 'errors' key.
279                $testResultObject = $this->getResponseEnvelope(
280                    $response[ 'result' ],
281                    json_encode( $testFunctionCall )
282                );
283                $testMetadata = $testResultObject->getValueByKey( ZTypeRegistry::Z_RESPONSEENVELOPE_METADATA );
284                '@phan-var \MediaWiki\Extension\WikiLambda\ZObjects\ZTypedMap $testMetadata';
285
286                // 3.i. Validate test result.
287                // (T394107) Don't let a type error from this being invalid result in a server-side error
288                $validateTestValue = $testResultObject->getZValue();
289                if ( !( $validateTestValue instanceof ZObject ) ) {
290                    $zError = ZErrorFactory::createZErrorInstance( ZErrorTypeRegistry::Z_ERROR_UNKNOWN, [
291                        'message' => wfMessage( 'wikilambda-performtest-error-invalidtester', $testerZid )->text()
292                    ] );
293                    $testResult[ 'validateStatus' ] = new ZBoolean( false );
294                    $testResult[ 'testMetadata' ] = ZResponseEnvelope::wrapInResponseMap( 'validateErrors', $zError );
295
296                    // Next implementation:test iteration
297                    $responseArray[] = $testResult;
298                    continue;
299                }
300
301                // 3.j. Use tester to create a function call validating the output
302                $validateFunctionCall = $testerObject->getValueByKey( ZTypeRegistry::Z_TESTER_VALIDATION );
303                '@phan-var \MediaWiki\Extension\WikiLambda\ZObjects\ZFunctionCall $validateFunctionCall';
304
305                $targetValidationFunctionZID = $validateFunctionCall->getZValue();
306                $validateFunctionCall->setValueByKey( $targetValidationFunctionZID . 'K1', $validateTestValue );
307
308                // 3.k. Execute the validation function call
309                try {
310                    $flags = [ 'validate' => false ];
311                    $response = $this->executeFunctionCall( $validateFunctionCall, $flags );
312                } catch ( ApiUsageException $e ) {
313                    // If reached concurrency limit, add failed response and stop further executions
314                    if ( $e->getCode() === HttpStatus::TOO_MANY_REQUESTS ) {
315                        $zError = ZErrorFactory::createZErrorInstance( ZErrorTypeRegistry::Z_ERROR_UNKNOWN, [
316                            'message' => $e->getMessage()
317                        ] );
318
319                        // We set $exitEarly flag and failed response for all following executions from the matrix
320                        $exitEarly = true;
321                        $exitEarlyResponse = ZResponseEnvelope::wrapInResponseMap( 'errors', $zError );
322
323                        $testResult[ 'validateStatus' ] = new ZBoolean( false );
324                        $testResult[ 'testMetadata' ] = $exitEarlyResponse;
325
326                        // Next implementation:test iteration
327                        $responseArray[] = $testResult;
328                        continue;
329                    }
330
331                    throw $e;
332                }
333
334                // 3.i. Get a valid Response Envelope/Z22 object by passing it through ZObjectFactory::create
335                // If the orchestrator response is not valid, it will build and return a Response Envelope/Z22
336                // with the error information in its 'errors' key.
337                $validateResult = $this->getResponseEnvelope(
338                    $response[ 'result' ],
339                    json_encode( $validateFunctionCall )
340                );
341
342                // 3.l. If the test result doesn't match the expected result, set test failure and metadata
343                if ( $validateResult->hasErrors() ) {
344                    $validateResult->setValueByKey(
345                        ZTypeRegistry::Z_RESPONSEENVELOPE_VALUE,
346                        new ZReference( ZTypeRegistry::Z_BOOLEAN_FALSE )
347                    );
348                    // Add the validator errors to the metadata map
349                    $testMetadata->setValueForKey(
350                        new ZString( "validateErrors" ),
351                        $validateResult->getErrors() );
352                }
353
354                $validateResultItem = $validateResult->getZValue();
355                if ( self::isFalse( $validateResultItem ) ) {
356                    $passed = false;
357                    // Add the expected and actual values to the metadata map
358                    $testMetadata->setValueForKey(
359                        new ZString( "actualTestResult" ),
360                        $validateTestValue
361                    );
362                    $testMetadata->setValueForKey(
363                        new ZString( "expectedTestResult" ),
364                        $validateFunctionCall->getValueByKey( $targetValidationFunctionZID . 'K2' )
365                    );
366                }
367
368                // 3.m. (T297707): Store this response in a DB table for faster future responses.
369                // We can only do this for persisted revisions, not inline items, as we can't
370                // version them otherwise, so use truthiness (neither null nor 0, non-extant).
371                // We also only do this if the validation step didn't have an error itself.
372                if (
373                    !$inlineImplementation && !$inlineTester &&
374                    !$validateResult->hasErrors()
375                ) {
376                    // Store a fake ZResponseEnvelope of the validation result and the real meta-data run
377                    // via an asynchronous job so that we don't trigger a "DB write on API GET" performance
378                    // error.
379                    $this->getLogger()->debug(
380                        'Tester result cache job triggered',
381                        [
382                            'functionZid' => $functionZid,
383                            'functionRevision' => $functionRevision
384                        ]
385                    );
386
387                    $stashedResult = new ZResponseEnvelope( $validateResultItem, $testMetadata );
388
389                    $cacheTesterResultsJob = new CacheTesterResultsJob(
390                        [
391                            'functionZid' => $functionZid,
392                            'functionRevision' => $functionRevision,
393                            'implementationZid' => $implementationZid,
394                            'implementationRevision' => $implementationRevision,
395                            'testerZid' => $testerZid,
396                            'testerRevision' => $testerRevision,
397                            'passed' => $passed,
398                            'stashedResult' => $stashedResult->__toString()
399                        ]
400                    );
401
402                    $this->jobQueueGroup->push( $cacheTesterResultsJob );
403                }
404
405                // Stash the response
406                $testResult[ 'validateStatus' ] = $validateResultItem;
407                $testResult[ 'testMetadata' ] = $testMetadata;
408                $responseArray[] = $testResult;
409
410                // Update bookkeeping for the call to maybeUpdateImplementationRanking, if needed.
411                // Implementation ranking only involves attached implementations and testers.
412                if ( in_array( $testerZid, $attachedTesterZids ) &&
413                    in_array( $implementationZid, $attachedImplementationZids ) ) {
414                    $testerMap[$testerZid] = $testResult;
415                    // Since this $testResult is "live" (not from cache), indicating that the
416                    // function, implementation, or tester has changed, we should check
417                    // if there is an improved implementation ranking
418                    // TODO (T330370): Revisit this strategy when we have more experience with it
419                    $canUpdateImplementationRanking = true;
420                }
421            }
422
423            // Update bookkeeping for the call to maybeUpdateImplementationRanking, if needed.
424            if ( in_array( $implementationZid, $attachedImplementationZids ) ) {
425                $implementationMap[ $implementationZid ] = $testerMap;
426            }
427        }
428
429        // 4. Maybe update implementation ranking (in persistent storage)
430        if ( $canUpdateImplementationRanking ) {
431            $this->maybeUpdateImplementationRanking(
432                $functionZid,
433                $functionRevision,
434                $implementationMap,
435                $attachedImplementationZids,
436                $attachedTesterZids
437            );
438        } else {
439            $this->getLogger()->info(
440                __METHOD__ . ' Not updating {functionZid} implementation ranking; no live results',
441                [
442                    'functionZid' => $functionZid,
443                    'canUpdateImplementationRanking' => $canUpdateImplementationRanking
444                ]
445            );
446        }
447
448        // 5. Return the response.
449        $pageResult->addValue( [ 'query' ], $this->getModuleName(), $responseArray );
450    }
451
452    /**
453     * Given an item passed as an input in the zimplementations or the ztesters
454     * arrays, it validates the input value and returns the broken down data
455     * needed to perform the execution of each test vs each implementation.
456     *
457     * In the case of any validation errors, if the object is inline, we will
458     * directly die with error. In the case of validation errors for references
459     * we will return the error, which will be attached to the result matrix in
460     * the request. This is because inline errors occur on edit/create pages of
461     * test/implementation, and the inline object will be the only one for that type.
462     *
463     * The data returned is an array containing:
464     * * isInline: whether the object passed is an inline object instead of a reference
465     * * objectZid: the zid of the implementation or test
466     * * innerObject: the value of the object, which can be the zid or literal Z14
467     *   in the case of implementations, and will be the literal Z20 in case of testers.
468     * * revision: the latest revision ID, which will be used for caching
469     * * zError: error to attach to the result matrix (if anything went wrong and the
470     *   input object is not an inline literal)
471     *
472     * @param ZObject|\stdClass|string $object - requested object (implementation or tester)
473     * @param string $type - either 'Z14' or 'Z20'
474     * @return array
475     */
476    private function validateRequestedObject( $object, $type ) {
477        $isInline = false;
478
479        $isImplementation = $type === ZTypeRegistry::Z_IMPLEMENTATION;
480        $createPermission = $isImplementation ?
481            'wikilambda-create-implementation' :
482            'wikilambda-create-tester';
483        $nonObjectMessge = $isImplementation ?
484            'wikilambda-performtest-error-nonimplementation' :
485            'wikilambda-performtest-error-nontester';
486        $invalidObjectMessge = $isImplementation ?
487            'wikilambda-performtest-error-invalidimplementation' :
488            'wikilambda-performtest-error-invalidtester';
489
490        // If object is a string, it was passed as a parameter in the request;
491        // decode it to see if it's a zid (persisted) or an inline object (not persisted)
492        if ( is_string( $object ) ) {
493            // (T358089) Decode any '|' characters of ZObjects that were escaped for the API transit
494            $object = str_replace( '🪈', '|', $object );
495            $decodedJson = FormatJson::decode( $object );
496            if ( $decodedJson ) {
497                // If the input is a JSON, we have received an inline implementation.
498
499                // NOTE on errors:
500                // In the case of inline object, we know there will only be one, so any failure
501                // while validating it will be a non-recoverable error, and we will die without
502                // iterating to other items in the list. This will happen only in the FunctionReport
503                // widget in create/edit implementation/tester pages.
504                $isInline = true;
505
506                // If we are received an inline implementation, check that user has the necessary special rights
507                if ( !$this->getContext()->getAuthority()->isAllowed( $createPermission ) ) {
508                    // Non-recoverable Failure: if implementation creation is not authorized, we end the request.
509                    $zError = ZErrorFactory::createZErrorInstance( ZErrorTypeRegistry::Z_ERROR_USER_CANNOT_RUN, [] );
510                    WikiLambdaApiBase::dieWithZError( $zError, HttpStatus::FORBIDDEN );
511                }
512
513                try {
514                    // For an inline implementation, check that the ZObject is valid
515                    $object = ZObjectFactory::create( $decodedJson );
516                } catch ( ZErrorException $e ) {
517                    // Non-recoverable Failure: if this implementation is not valid, we end the request.
518                    $this->dieWithError(
519                        [ $invalidObjectMessge, $e->getZErrorMessage() ],
520                        null, null, HttpStatus::BAD_REQUEST
521                    );
522                }
523
524                // For an inline implementation, check that the ZObject is an implementation/Z14
525                // or a persisted object/Z2 containing an implementation/Z14
526                if ( (
527                    ( $object instanceof ZPersistentObject ) &&
528                    ( $object->getInternalZType() !== $type )
529                ) || (
530                    !( $object instanceof ZPersistentObject ) &&
531                    ( $object->getZType() !== $type )
532                ) ) {
533                    // Non-recoverable Failure: if this is not an implementation, we end the request.
534                    $this->dieWithError(
535                        [ $nonObjectMessge, $object ],
536                        null, null, HttpStatus::BAD_REQUEST
537                    );
538                }
539
540            } else {
541                // If the input is not a JSON, we assume that we have received a Zid
542                $object = new ZReference( $object );
543            }
544        }
545
546        // Get the object zid (value if it's a reference, or identity if persistent object)
547        $objectZid = ZObjectUtils::getZid( $object );
548
549        // If the object is a reference (not inline):
550        // * get its revision ID, and
551        // * check that it exists and belongs to the right type
552        $revision = null;
553        $innerObject = null;
554        $zError = null;
555        $fetchedObject = null;
556        if ( !$isInline ) {
557            $title = Title::newFromText( $objectZid, NS_MAIN );
558
559            if ( !$title || !( $title instanceof Title ) || !$title->exists() ) {
560                $zError = ZErrorFactory::createZErrorInstance( ZErrorTypeRegistry::Z_ERROR_UNKNOWN, [
561                    'message' => wfMessage( $nonObjectMessge, (string)$object )->text()
562                ] );
563            } else {
564                $revision = $title->getLatestRevID();
565            }
566
567            if ( !$zError ) {
568                $fetchedObject = $this->zObjectStore->fetchZObjectByTitle( $title )->getInnerZObject();
569                if ( $fetchedObject->getZType() !== $type ) {
570                    $zError = ZErrorFactory::createZErrorInstance( ZErrorTypeRegistry::Z_ERROR_UNKNOWN, [
571                        'message' => wfMessage( $nonObjectMessge, (string)$object )->text()
572                    ] );
573                }
574            }
575        }
576
577        if ( !$zError ) {
578            $innerObject = $isImplementation ?
579                $this->getImplementationObject( $object ) :
580                $this->getTesterObject( $object, $fetchedObject );
581        }
582
583        return [
584            $isInline,
585            $objectZid,
586            $innerObject,
587            $revision,
588            $zError
589        ];
590    }
591
592    /**
593     * Return a ZObject with a reference or a literal implementation.
594     * If the input ZObject has a persisted object containing a non-implementation,
595     * die with ApiUsageException.
596     *
597     * @param ZObject $zobject - either a reference, or a literal implementation or persistent object
598     * @return ZObject
599     * @throws ApiUsageException
600     */
601    private function getImplementationObject( $zobject ) {
602        // Input implementation was passed inline (as a literal persistent object):
603        if ( $zobject->getZType() === ZTypeRegistry::Z_PERSISTENTOBJECT ) {
604            return $zobject->getValueByKey( ZTypeRegistry::Z_PERSISTENTOBJECT_VALUE );
605        }
606
607        // Input implementation was passed inline (as a literal implementation), or
608        // input implementation was passed as a reference:
609        if (
610            $zobject->getZType() === ZTypeRegistry::Z_IMPLEMENTATION ||
611            $zobject->getZType() === ZTypeRegistry::Z_REFERENCE
612        ) {
613            return $zobject;
614        }
615
616        // This should never happen, as validation should have taken care of this.
617        // log an error and die:
618        $this->getLogger()->error(
619            __METHOD__ . ' expected implementation but found something else',
620            [ 'zobject' => $zobject ]
621        );
622
623        $this->dieWithError(
624            [ "wikilambda-performtest-error-nonimplementation", $zobject ],
625            null,
626            null,
627            HttpStatus::BAD_REQUEST
628        );
629    }
630
631    /**
632     * Return a ZObject with a literal tester.
633     * * If the tester object was passed inline, return that
634     * * If the tester object was passed as a reference, return the fetched
635     *
636     * @param ZObject $zobject
637     * @param ZObject|null $fetched
638     * @return ZObject
639     * @throws ApiUsageException
640     */
641    private function getTesterObject( $zobject, $fetched ) {
642        // Input tester was passed inline (as a literal persistent object):
643        if ( $zobject->getZType() === ZTypeRegistry::Z_PERSISTENTOBJECT ) {
644            return $zobject->getValueByKey( ZTypeRegistry::Z_PERSISTENTOBJECT_VALUE );
645        }
646
647        // Input tester was passed inline (as a literal tester):
648        if ( $zobject->getZType() === ZTypeRegistry::Z_TESTER ) {
649            return $zobject;
650        }
651
652        // Input tester was passed as a reference: we want the literal tester, which should be fetched:
653        if ( $zobject->getZType() === ZTypeRegistry::Z_REFERENCE && ( $fetched !== null ) ) {
654            return $fetched;
655        }
656
657        // This should never happen, as validation should have taken care of this.
658        // log an error and die:
659        $this->getLogger()->error(
660            __METHOD__ . ' expected tester but found something else',
661            [ 'zobject' => $zobject ]
662        );
663
664        $this->dieWithError(
665            [ "wikilambda-performtest-error-nontester", $zobject ],
666            null,
667            null,
668            HttpStatus::BAD_REQUEST
669        );
670    }
671
672    private static function isFalse( $object ) {
673        if ( $object instanceof ZObject ) {
674            if ( $object instanceof ZReference ) {
675                return self::isFalse( $object->getZValue() );
676            } elseif ( $object->getZType() === ZTypeRegistry::Z_BOOLEAN ) {
677                return self::isFalse( $object->getValueByKey( ZTypeRegistry::Z_BOOLEAN_VALUE ) );
678            }
679        } elseif ( $object instanceof \stdClass ) {
680            if ( $object->{ ZTypeRegistry::Z_OBJECT_TYPE } === ZTypeRegistry::Z_REFERENCE ) {
681                return self::isFalse( $object->{ ZTypeRegistry::Z_REFERENCE_VALUE } );
682            } elseif ( $object->{ ZTypeRegistry::Z_OBJECT_TYPE } === ZTypeRegistry::Z_BOOLEAN ) {
683                return self::isFalse( $object->{ ZTypeRegistry::Z_BOOLEAN_VALUE } );
684            }
685        }
686        return $object === ZTypeRegistry::Z_BOOLEAN_FALSE;
687    }
688
689    /**
690     * @inheritDoc
691     * @codeCoverageIgnore
692     */
693    protected function getAllowedParams(): array {
694        return [
695            'zfunction' => [
696                ParamValidator::PARAM_TYPE => 'text',
697                ParamValidator::PARAM_REQUIRED => true,
698            ],
699            'zimplementations' => [
700                ParamValidator::PARAM_ISMULTI => true,
701                ParamValidator::PARAM_REQUIRED => false,
702            ],
703            'ztesters' => [
704                ParamValidator::PARAM_ISMULTI => true,
705                ParamValidator::PARAM_REQUIRED => false,
706            ],
707        ];
708    }
709
710    /**
711     * @see ApiBase::getExamplesMessages()
712     * @return array
713     * @codeCoverageIgnore
714     */
715    protected function getExamplesMessages() {
716        // Don't try to read the latest ZID from the DB on client wikis, we can't.
717        $exampleZid =
718            ( MediaWikiServices::getInstance()->getMainConfig()->get( 'WikiLambdaEnableRepoMode' ) ) ?
719            $this->zObjectStore->findFirstZImplementationFunction() :
720            'Z10000';
721
722        $queryPrefix = 'action=wikilambda_perform_test&format=json&wikilambda_perform_test_zfunction=';
723
724        return [
725            $queryPrefix . $exampleZid
726                => 'apihelp-wikilambda_perform_test-example',
727            $queryPrefix . 'Z801'
728                => 'apihelp-wikilambda_perform_test-z801',
729            $queryPrefix . 'Z801&wikilambda_perform_test_zimplementations=Z901'
730                => 'apihelp-wikilambda_perform_test-z801-implementation',
731            $queryPrefix . 'Z801&wikilambda_perform_test_ztesters=Z8010|Z8011'
732                => 'apihelp-wikilambda_perform_test-z801-tester',
733            $queryPrefix . 'Z801&wikilambda_perform_test_zimplementations=Z901&wikilambda_perform_test_ztesters=Z8010'
734                => 'apihelp-wikilambda_perform_test-z801-implementation-and-testers',
735        ];
736    }
737
738    /**
739     * Mark as internal. This isn't meant to be user-facing, and can change at any time.
740     * @return bool
741     */
742    public function isInternal() {
743        return true;
744    }
745
746    /**
747     * Retrieves the $metadataMap value for ZString($keyString) and converts it to a float.  It must
748     * be a value of type ZString, whose underlying string begins with a float, e.g. '320.815 ms'.
749     * If ZString($keyString) isn't used in $metadataMap, returns zero.
750     *
751     * N.B. We do not check the units here; we assume that they are always the same (i.e.,
752     * milliseconds) for the values retrieved by this function.  This consistency is primarily
753     * the responsibility of the backend services that generate the metadata elements.
754     *
755     * @param ZTypedMap $metadataMap
756     * @param string $keyString
757     * @return float
758     */
759    private static function getNumericMetadataValue( $metadataMap, $keyString ) {
760        $key = new ZString( $keyString );
761        $value = $metadataMap->getValueGivenKey( $key );
762        if ( !$value ) {
763            return 0;
764        }
765        $value = $value->getZValue();
766        return floatval( $value );
767    }
768
769    /**
770     * Callback for uasort() to order the implementations for a function that's been tested
771     * @param array $a Implementation stats
772     * @param array $b Implementation stats
773     * @return int Result of comparison
774     */
775    private static function compareImplementationStats( $a, $b ) {
776        if ( $a[ 'numFailed' ] < $b[ 'numFailed' ] ) {
777            return -1;
778        }
779        if ( $b[ 'numFailed' ] < $a[ 'numFailed' ] ) {
780            return 1;
781        }
782        if ( $a[ 'averageTime' ] < $b[ 'averageTime' ] ) {
783            return -1;
784        }
785        if ( $b[ 'averageTime' ] < $a[ 'averageTime' ] ) {
786            return 1;
787        }
788        return 0;
789    }
790
791    /**
792     * Based on tester results contained in $implementationMap, order the implementations of the
793     * given function from best-performing to worst-performing (in terms of speed).  If the
794     * ordering is significantly different than the previous ordering for this function, instantiate
795     * an asynchronous job to update Z8K4/implementations in the function's persistent storage.
796     *
797     * TODO (T329138): Consider possible refinements to the ranking strategy.
798     *
799     * @param string $functionZid
800     * @param int $functionRevision
801     * @param array $implementationMap contains $implementationZid => $testerMap, for each tested
802     * implementation.  $testerMap contains $testerZid => $testResult for each tester. See
803     * ApiPerformTest::run for the structure of $testResult.
804     * @param array $attachedImplementationZids
805     * @param array $attachedTesterZids
806     */
807    public static function maybeUpdateImplementationRanking(
808        $functionZid, $functionRevision, $implementationMap, $attachedImplementationZids, $attachedTesterZids
809    ) {
810        // NOTE: As this code is static for testing purposes, we can't use $this->getLogger() here
811        $logger = LoggerFactory::getInstance( 'WikiLambda' );
812
813        // We don't currently support updates involving a Z0, and we don't expect to get any here.
814        // (However, it maybe could happen if the value of Z8K4 has been manually edited.)
815        unset( $implementationMap[ ZTypeRegistry::Z_NULL_REFERENCE ] );
816
817        if ( count( $attachedImplementationZids ) <= 1 ) {
818            // No point in updating.
819            $logger->debug(
820                __METHOD__ . ' Not updating {functionZid}: Implementation count <= 1',
821                [
822                    'functionZid' => $functionZid,
823                    'functionRevision' => $functionRevision,
824                    'implementationMap' => $implementationMap
825                ]
826            );
827            return;
828        }
829
830        // We only update if we have results for all currently attached implementations,
831        // and all currently attached testers.  We already know that the implementations and
832        // testers in the maps are attached; now we check whether all attached ones are present.
833        $implementationZids = array_keys( $implementationMap );
834        $testerZids = array_keys( reset( $implementationMap ) );
835        if ( array_diff( $attachedImplementationZids, $implementationZids ) ||
836            array_diff( $attachedTesterZids, $testerZids ) ) {
837            $logger->debug(
838                __METHOD__ . ' Not updating {functionZid}: Missing results for attached implementations or testers',
839                [
840                    'functionZid' => $functionZid,
841                    'functionRevision' => $functionRevision,
842                    'attachedImplementationZids' => $attachedImplementationZids,
843                    'implementationZids' => $implementationZids,
844                    'attachedTesterZids' => $attachedTesterZids,
845                    'testerZids' => $testerZids,
846                    'implementationMap' => $implementationMap
847                ]
848            );
849            return;
850        }
851
852        // Record which implementation is first in Z8K4 before this update happens
853        $previousFirst = $attachedImplementationZids[ 0 ];
854
855        // For each implementation, get (count of tests-failed) and (average runtime of tests)
856        // and add them into $implementationMap.
857        // TODO (T314539): Revisit Use of (count of tests-failed) after failing implementations are
858        //   routinely deactivated
859        foreach ( $implementationMap as $implementationZid => $testerMap ) {
860            $numFailed = 0;
861            $averageTime = 0.0;
862            foreach ( $testerMap as $testerId => $testResult ) {
863                if ( self::isFalse( $testResult[ 'validateStatus' ] ) ) {
864                    $numFailed++;
865                }
866                $metadataMap = $testResult[ 'testMetadata' ];
867                '@phan-var \MediaWiki\Extension\WikiLambda\ZObjects\ZTypedMap $metadataMap';
868                $averageTime += self::getNumericMetadataValue( $metadataMap, 'orchestrationDuration' );
869            }
870            $averageTime /= count( $testerMap );
871            $implementationMap[ $implementationZid ][ 'numFailed' ] = $numFailed;
872            $implementationMap[ $implementationZid ][ 'averageTime' ] = $averageTime;
873        }
874
875        uasort( $implementationMap, [ self::class, 'compareImplementationStats' ] );
876        // Get the ranked Zids
877
878        // Bail out if the new first element is the same as the previous
879        $newFirst = array_key_first( $implementationMap );
880        if ( $newFirst === $previousFirst ) {
881            $logger->debug(
882                __METHOD__ . ' Not updating {functionZid}: Same first element',
883                [
884                    'functionZid' => $functionZid,
885                    'functionRevision' => $functionRevision,
886                    'previousFirst' => $previousFirst,
887                    'implementationMap' => $implementationMap
888                ]
889            );
890            return;
891        }
892
893        // Bail out if the performance of $newFirst is only marginally better than the
894        // performance of $previousFirst.  Note: if numFailed of $newFirst is less than
895        // numFailed of $previousFirst, then we should *not* bail out.
896        // TODO (T329138): Also consider:
897        //   Check if all of the average times are roughly indistinguishable.
898        $previousFirstStats = $implementationMap[ $previousFirst ];
899        $newFirstStats = $implementationMap[ $newFirst ];
900        $relativeThreshold = 0.8;
901        if ( $newFirstStats[ 'averageTime' ] >= $relativeThreshold * $previousFirstStats[ 'averageTime' ] &&
902            $newFirstStats[ 'numFailed' ] >= $previousFirstStats[ 'numFailed' ] ) {
903            $logger->debug(
904                __METHOD__ . ' Not updating {functionZid}: New first element only marginally better than previous',
905                [
906                    'functionZid' => $functionZid,
907                    'functionRevision' => $functionRevision,
908                    'previousFirst' => $previousFirst,
909                    'newFirst' => $newFirst,
910                    'implementationMap' => $implementationMap
911                ]
912            );
913            return;
914        }
915
916        $implementationRankingZids = array_keys( $implementationMap );
917        $logger->info(
918            __METHOD__ . ' Creating UpdateImplementationsJob for {functionZid}',
919            [
920                'functionZid' => $functionZid,
921                'functionRevision' => $functionRevision,
922                'implementationRankingZids' => $implementationRankingZids,
923                'implementationMap' => $implementationMap
924            ]
925        );
926
927        $updateImplementationsJob = new UpdateImplementationsJob(
928            [ 'functionZid' => $functionZid,
929                'functionRevision' => $functionRevision,
930                'implementationRankingZids' => $implementationRankingZids
931            ] );
932        // NOTE: As this code is static for testing purposes, we can't use $this->jobQueueGroup here
933        // TODO (T330033): Consider using an injected service for the following
934        $services = MediaWikiServices::getInstance();
935        $jobQueueGroup = $services->getJobQueueGroup();
936        $jobQueueGroup->push( $updateImplementationsJob );
937    }
938}