56 private $jobQueueGroup;
59 private $readOnlyMode;
67 private $statsFactory;
76 private const MAX_ALLOWED_LAG = 3;
78 private const SYNC_TIMEOUT = self::MAX_ALLOWED_LAG;
80 private const LAG_CHECK_PERIOD = 1.0;
82 private const ERROR_BACKOFF_TTL = 1;
84 private const READONLY_BACKOFF_TTL = 30;
90 $this->debug = $debug;
112 LoggerInterface $logger
115 $this->options = $serviceOptions;
116 $this->lbFactory = $lbFactory;
117 $this->jobQueueGroup = $jobQueueGroup;
118 $this->readOnlyMode = $readOnlyMode;
119 $this->linkCache = $linkCache;
120 $this->pageProps = $pageProps;
121 $this->statsFactory = $statsFactory;
122 $this->logger = $logger;
151 public function run( array $options ) {
152 $type = $options[
'type'] ??
false;
153 $maxJobs = $options[
'maxJobs'] ??
false;
154 $maxTime = $options[
'maxTime'] ??
false;
155 $throttle = $options[
'throttle'] ??
true;
160 $response = [
'jobs' => [],
'reached' =>
'none-ready' ];
162 if ( $type !==
false && !isset( $jobClasses[$type] ) ) {
164 $response[
'reached'] =
'none-possible';
168 if ( $this->readOnlyMode->isReadOnly() ) {
170 $response[
'reached'] =
'read-only';
174 [ , $maxLag ] = $this->lbFactory->getMainLB()->getMaxLag();
175 if ( $maxLag >= self::MAX_ALLOWED_LAG ) {
177 $response[
'reached'] =
'replica-lag-limit';
182 $this->lbFactory->getTransactionProfiler()
183 ->setExpectations( $profilerLimits[
'JobRunner'], __METHOD__ );
186 if ( $this->lbFactory->hasTransactionRound() ) {
187 throw new LogicException( __METHOD__ .
' called with an active transaction round.' );
195 $loopStartTime = microtime(
true );
202 $backoffs = $this->syncBackoffDeltas( $backoffs, $backoffDeltas, $wait );
203 $backoffKeys = $throttle ? array_keys( $backoffs ) : [];
206 if ( $type ===
false ) {
208 $job = $this->jobQueueGroup
213 $job = in_array( $type, $backoffKeys ) ? false : $this->jobQueueGroup->pop( $type );
218 $jType =
$job->getType();
221 $ttw = $this->getBackoffTimeToWait(
$job );
225 $backoffDeltas[$jType] = ( $backoffDeltas[$jType] ?? 0 ) + $ttw;
226 $backoffs = $this->syncBackoffDeltas( $backoffs, $backoffDeltas, $wait );
232 if ( $info[
'status'] !==
false || !
$job->allowRetries() ) {
233 $this->jobQueueGroup->ack(
$job );
237 if ( $info[
'status'] ===
false && mt_rand( 0, 49 ) == 0 ) {
238 $ttw = max( $ttw, $this->getErrorBackoffTTL( $info[
'caught'] ) );
239 $backoffDeltas[$jType] = ( $backoffDeltas[$jType] ?? 0 ) + $ttw;
242 $response[
'jobs'][] = [
244 'status' => ( $info[
'status'] === false ) ?
'failed' :
'ok',
245 'error' => $info[
'error'],
246 'time' => $info[
'timeMs']
248 $timeMsTotal += $info[
'timeMs'];
251 if ( $maxJobs && $jobsPopped >= $maxJobs ) {
252 $response[
'reached'] =
'job-limit';
254 } elseif ( $maxTime && ( microtime(
true ) - $loopStartTime ) > $maxTime ) {
255 $response[
'reached'] =
'time-limit';
263 if ( in_array( DBConnectionError::class, $info[
'caught'],
true ) ) {
264 $response[
'reached'] =
'exception';
271 $timePassed = microtime(
true ) - $lastSyncTime;
272 if ( $timePassed >= self::LAG_CHECK_PERIOD || $timePassed < 0 ) {
273 $opts = [
'ifWritesSince' => $lastSyncTime,
'timeout' => self::SYNC_TIMEOUT ];
274 if ( !$this->lbFactory->waitForReplication( $opts ) ) {
275 $response[
'reached'] =
'replica-lag-limit';
278 $lastSyncTime = microtime(
true );
282 if ( !$this->checkMemoryOK() ) {
283 $response[
'reached'] =
'memory-limit';
290 if ( $backoffDeltas ) {
291 $this->syncBackoffDeltas( $backoffs, $backoffDeltas,
'wait' );
294 $response[
'backoffs'] = $backoffs;
295 $response[
'elapsed'] = $timeMsTotal;
319 $telemetry = Telemetry::getInstance();
320 $oldRequestId = $telemetry->getRequestId();
322 if (
$job->getRequestId() !==
null ) {
324 $telemetry->overrideRequestId(
$job->getRequestId() );
329 $telemetry->regenerateRequestId();
332 $oldTimeout = $this->lbFactory->setDefaultReplicationWaitTimeout( self::SYNC_TIMEOUT );
334 return $this->doExecuteJob(
$job );
336 $this->lbFactory->setDefaultReplicationWaitTimeout( $oldTimeout );
337 $telemetry->overrideRequestId( $oldRequestId );
350 $jType =
$job->getType();
351 $msg =
$job->toString() .
" STARTING";
352 $this->logger->debug( $msg, [
'job_type' =>
$job->getType() ] );
353 $this->debugCallback( $msg );
357 $this->linkCache->clear();
358 $this->pageProps->clear();
362 $rssStart = $this->getMaxRssKb();
363 $jobStartTime = microtime(
true );
365 $fnameTrxOwner = get_class(
$job ) .
'::run';
367 if (
$job->hasExecutionFlag( $job::JOB_NO_EXPLICIT_TRX_ROUND ) ) {
368 $this->lbFactory->commitPrimaryChanges( $fnameTrxOwner );
370 $this->lbFactory->beginPrimaryChanges( $fnameTrxOwner );
374 $scope = LoggerFactory::getContext()->addScoped( [
375 'context.job_type' => $jType,
377 $status =
$job->run();
378 $error =
$job->getLastError();
379 ScopedCallback::consume( $scope );
382 $this->lbFactory->commitPrimaryChanges(
388 DeferredUpdates::doUpdates();
389 }
catch ( Throwable $e ) {
390 MWExceptionHandler::rollbackPrimaryChangesAndLog( $e );
392 $error = get_class( $e ) .
': ' . $e->getMessage() .
' in '
393 . $e->getFile() .
' on line ' . $e->getLine();
394 $caught[] = get_class( $e );
398 $job->tearDown( $status );
399 }
catch ( Throwable $e ) {
400 MWExceptionHandler::logException( $e );
403 $timeMs = intval( ( microtime(
true ) - $jobStartTime ) * 1000 );
404 $rssEnd = $this->getMaxRssKb();
407 $readyTs =
$job->getReadyTimestamp();
409 $pickupDelay = max( 0, $jobStartTime - $readyTs );
410 $this->statsFactory->getTiming(
'jobqueue_pickup_delay_seconds' )
411 ->setLabel(
'jobtype', $jType )
412 ->observe( 1000 * $pickupDelay );
415 $rootTimestamp =
$job->getRootJobParams()[
'rootJobTimestamp'];
416 if ( $rootTimestamp ) {
417 $age = max( 0, $jobStartTime - (
int)
wfTimestamp( TS::UNIX, $rootTimestamp ) );
419 $this->statsFactory->getTiming(
"jobqueue_pickup_root_age_seconds" )
420 ->setLabel(
'jobtype', $jType )
421 ->observe( 1000 * $age );
424 $this->statsFactory->getTiming(
'jobqueue_runtime_seconds' )
425 ->setLabel(
'jobtype', $jType )
426 ->observe( $timeMs );
428 if ( $rssStart && $rssEnd ) {
429 $this->statsFactory->getCounter(
'jobqueue_rss_delta_total' )
430 ->setLabel(
'rss_delta', $jType )
431 ->incrementBy( max( $rssEnd - $rssStart, 0 ) );
434 if ( $status ===
false ) {
435 $msg =
$job->toString() .
" t={job_duration} error={job_error}";
436 $this->logger->error( $msg, [
437 'job_type' =>
$job->getType(),
438 'job_duration' => $timeMs,
439 'job_error' => $error,
442 $msg =
$job->toString() .
" t=$timeMs error={$error}";
443 $this->debugCallback( $msg );
445 $msg =
$job->toString() .
" t={job_duration} good";
446 $this->logger->info( $msg, [
447 'job_type' =>
$job->getType(),
448 'job_duration' => $timeMs,
451 $msg =
$job->toString() .
" t=$timeMs good";
452 $this->debugCallback( $msg );
467 private function getErrorBackoffTTL( array $caught ) {
468 return in_array( DBReadOnlyError::class, $caught )
469 ? self::READONLY_BACKOFF_TTL
470 : self::ERROR_BACKOFF_TTL;
476 private function getMaxRssKb() {
477 $info = getrusage( 0 );
479 return isset( $info[
'ru_maxrss'] ) ? (int)$info[
'ru_maxrss'] : null;
487 private function getBackoffTimeToWait( RunnableJob
$job ) {
490 if ( !isset( $throttling[
$job->getType()] ) ||
$job instanceof DuplicateJob ) {
494 $itemsPerSecond = $throttling[
$job->getType()];
495 if ( $itemsPerSecond <= 0 ) {
500 if (
$job->workItemCount() > 0 ) {
501 $exactSeconds =
$job->workItemCount() / $itemsPerSecond;
503 $seconds = floor( $exactSeconds );
504 $remainder = $exactSeconds - $seconds;
505 $seconds += ( mt_rand() / mt_getrandmax() < $remainder ) ? 1 : 0;
508 return (
int)$seconds;
519 private function loadBackoffs( array $backoffs, $mode =
'wait' ) {
520 $file =
wfTempDir() .
'/mw-runJobs-backoffs.json';
521 if ( is_file( $file ) ) {
522 $noblock = ( $mode ===
'nowait' ) ? LOCK_NB : 0;
523 $handle = fopen( $file,
'rb' );
524 if ( !flock( $handle, LOCK_SH | $noblock ) ) {
528 $content = stream_get_contents( $handle );
529 flock( $handle, LOCK_UN );
531 $ctime = microtime(
true );
532 $cBackoffs = json_decode( $content,
true ) ?: [];
533 foreach ( $cBackoffs as $type => $timestamp ) {
534 if ( $timestamp < $ctime ) {
535 unset( $cBackoffs[$type] );
556 private function syncBackoffDeltas( array $backoffs, array &$deltas, $mode =
'wait' ) {
558 return $this->loadBackoffs( $backoffs, $mode );
561 $noblock = ( $mode ===
'nowait' ) ? LOCK_NB : 0;
562 $file =
wfTempDir() .
'/mw-runJobs-backoffs.json';
563 $handle = fopen( $file,
'wb+' );
564 if ( !flock( $handle, LOCK_EX | $noblock ) ) {
568 $ctime = microtime(
true );
569 $content = stream_get_contents( $handle );
570 $cBackoffs = json_decode( $content,
true ) ?: [];
571 foreach ( $deltas as $type => $seconds ) {
572 $cBackoffs[$type] = isset( $cBackoffs[$type] ) && $cBackoffs[$type] >= $ctime
573 ? $cBackoffs[$type] + $seconds
576 foreach ( $cBackoffs as $type => $timestamp ) {
577 if ( $timestamp < $ctime ) {
578 unset( $cBackoffs[$type] );
581 ftruncate( $handle, 0 );
582 fwrite( $handle, json_encode( $cBackoffs ) );
583 flock( $handle, LOCK_UN );
596 private function checkMemoryOK() {
597 static $maxBytes =
null;
598 if ( $maxBytes ===
null ) {
600 if ( preg_match(
'!^(\d+)(k|m|g|)$!i', ini_get(
'memory_limit' ), $m ) ) {
601 [ , $num, $unit ] = $m;
602 $conv = [
'g' => 1073741824,
'm' => 1048576,
'k' => 1024,
'' => 1 ];
603 $maxBytes = (int)$num * $conv[strtolower( $unit )];
608 $usedBytes = memory_get_usage();
609 if ( $maxBytes && $usedBytes >= 0.95 * $maxBytes ) {
610 $msg =
"Detected excessive memory usage ({used_bytes}/{max_bytes}).";
611 $this->logger->error( $msg, [
612 'used_bytes' => $usedBytes,
613 'max_bytes' => $maxBytes,
616 $msg =
"Detected excessive memory usage ($usedBytes/$maxBytes).";
617 $this->debugCallback( $msg );
629 private function debugCallback( $msg ) {
630 if ( $this->debug ) {
631 ( $this->debug )( ConvertibleTimestamp::now( TS::DB ) .
" $msg\n" );