MediaWiki master
TransactionProfiler.php
Go to the documentation of this file.
1<?php
6namespace Wikimedia\Rdbms;
7
8use InvalidArgumentException;
9use Psr\Log\LoggerAwareInterface;
10use Psr\Log\LoggerInterface;
11use Psr\Log\NullLogger;
12use RuntimeException;
13use Wikimedia\ScopedCallback;
15
26class TransactionProfiler implements LoggerAwareInterface {
28 private $logger;
30 private $statsFactory;
32 private $expect;
34 private $hits;
36 private $hitsFName;
38 private $violations;
40 private $silenced;
41
46 private $dbTrxHoldingLocks;
47
52 private $dbTrxMethodTimes;
53
55 private $method;
56
58 private $wallClockOverride;
59
61 private const DB_LOCK_THRESHOLD_SEC = 3.0;
63 private const EVENT_THRESHOLD_SEC = 0.25;
64
66 private const EVENT_NAMES = [
67 'writes',
68 'queries',
69 'queriesPerCaller',
70 'conns',
71 'masterConns',
72 'maxAffected',
73 'readQueryRows',
74 'readQueryTime',
75 'writeQueryTime'
76 ];
77
79 private const COUNTER_EVENT_NAMES = [
80 'writes',
81 'queries',
82 'conns',
83 'masterConns'
84 ];
85
87 private const FLD_LIMIT = 0;
89 private const FLD_FNAME = 1;
90
92 public const EXPECTATION_ANY = 'any';
94 public const EXPECTATION_REPLICAS_ONLY = 'replicas-only';
95
96 public function __construct() {
97 $this->initPlaceholderExpectations();
98
99 $this->dbTrxHoldingLocks = [];
100 $this->dbTrxMethodTimes = [];
101
102 $this->silenced = array_fill_keys( self::EVENT_NAMES, 0 );
103
104 $this->setLogger( new NullLogger() );
105 $this->statsFactory = StatsFactory::newNull();
106 }
107
108 public function setLogger( LoggerInterface $logger ): void {
109 $this->logger = $logger;
110 }
111
118 public function setStatsFactory( StatsFactory $statsFactory ) {
119 $this->statsFactory = $statsFactory;
120 }
121
126 public function setRequestMethod( ?string $method ) {
127 $this->method = $method;
128 }
129
142 #[\NoDiscard]
143 public function silenceForScope( string $type = self::EXPECTATION_ANY ): ScopedCallback {
144 if ( $type === self::EXPECTATION_REPLICAS_ONLY ) {
145 $events = [];
146 foreach ( [ 'writes', 'masterConns' ] as $event ) {
147 if ( $this->expect[$event][self::FLD_LIMIT] === 0 ) {
148 $events[] = $event;
149 }
150 }
151 } else {
152 $events = self::EVENT_NAMES;
153 }
154
155 foreach ( $events as $event ) {
156 ++$this->silenced[$event];
157 }
158
159 return new ScopedCallback( function () use ( $events ) {
160 foreach ( $events as $event ) {
161 --$this->silenced[$event];
162 }
163 } );
164 }
165
181 public function isSilenced( string $event ): bool {
182 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
183 throw new RuntimeException( 'May only be called in tests' );
184 }
185 if ( !isset( $this->silenced[$event] ) ) {
186 throw new InvalidArgumentException( "Unrecognised event name '$event' provided." );
187 }
188
189 return $this->silenced[$event] > 0;
190 }
191
202 public function setExpectation( string $event, $limit, string $fname ) {
203 if ( !isset( $this->expect[$event] ) ) {
204 return; // obsolete/bogus expectation
205 }
206
207 if ( $limit <= $this->expect[$event][self::FLD_LIMIT] ) {
208 // New limit is more restrictive
209 $this->expect[$event] = [
210 self::FLD_LIMIT => $limit,
211 self::FLD_FNAME => $fname
212 ];
213 }
214 }
215
227 public function setExpectations( array $expects, string $fname ) {
228 foreach ( $expects as $event => $value ) {
229 $this->setExpectation( $event, $value, $fname );
230 }
231 }
232
242 public function resetExpectations() {
243 $this->initPlaceholderExpectations();
244 }
245
256 public function redefineExpectations( array $expects, string $fname ) {
257 $this->initPlaceholderExpectations();
258 $this->setExpectations( $expects, $fname );
259 }
260
276 public function getExpectation( string $event ) {
277 if ( !isset( $this->expect[$event] ) ) {
278 throw new InvalidArgumentException( "Unrecognised event name '$event' provided." );
279 }
280
281 return $this->expect[$event][self::FLD_LIMIT];
282 }
283
293 public function recordConnection( $server, $db, bool $isPrimaryWithReplicas ) {
294 // Report when too many connections happen...
295 if ( $this->pingAndCheckThreshold( 'conns' ) ) {
296 $this->reportExpectationViolated(
297 'conns',
298 "[connect to $server ($db)]",
299 $this->hits['conns']
300 );
301 }
302
303 // Report when too many primary connections happen...
304 if ( $isPrimaryWithReplicas && $this->pingAndCheckThreshold( 'masterConns' ) ) {
305 $this->reportExpectationViolated(
306 'masterConns',
307 "[connect to $server ($db)]",
308 $this->hits['masterConns']
309 );
310 }
311 }
312
323 public function transactionWritingIn( $server, $db, string $id, float $startTime ) {
324 $name = "{$db} {$server} TRX#$id";
325 if ( isset( $this->dbTrxHoldingLocks[$name] ) ) {
326 $this->logger->warning( "Nested transaction for '$name' - out of sync." );
327 }
328 $this->dbTrxHoldingLocks[$name] = [
329 'start' => $startTime,
330 'conns' => [], // all connections involved
331 ];
332 $this->dbTrxMethodTimes[$name] = [];
333
334 foreach ( $this->dbTrxHoldingLocks as $name => &$info ) {
335 // Track all DBs in transactions for this transaction
336 $info['conns'][$name] = 1;
337 }
338 }
339
353 public function recordQueryCompletion(
354 $query,
355 float $sTime,
356 bool $isWrite,
357 ?int $rowCount,
358 string $trxId,
359 ?string $serverName = null,
360 ?string $fname = null,
361 ) {
362 $eTime = $this->getCurrentTime();
363 $elapsed = ( $eTime - $sTime );
364
365 if ( $isWrite && $this->isAboveThreshold( $rowCount, 'maxAffected' ) ) {
366 $this->reportExpectationViolated( 'maxAffected', $query, $rowCount, $trxId, $serverName );
367 } elseif ( !$isWrite && $this->isAboveThreshold( $rowCount, 'readQueryRows' ) ) {
368 $this->reportExpectationViolated( 'readQueryRows', $query, $rowCount, $trxId, $serverName );
369 }
370
371 // Report when too many writes/queries happen...
372 if ( $this->pingAndCheckThreshold( 'queries' ) ) {
373 $this->reportExpectationViolated( 'queries', $query, $this->hits['queries'], $trxId, $serverName );
374 }
375 if ( $isWrite && $this->pingAndCheckThreshold( 'writes' ) ) {
376 $this->reportExpectationViolated( 'writes', $query, $this->hits['writes'], $trxId, $serverName );
377 }
378 if ( $fname !== null && $this->pingAndCheckThresholdFname( 'queriesPerCaller', $fname ) ) {
379 $this->reportExpectationViolated(
380 'queriesPerCaller',
381 $query,
382 $this->hitsFName['queriesPerCaller'][$fname] . ' by ' . $fname,
383 $trxId,
384 $serverName,
385 );
386 }
387 // Report slow queries...
388 if ( !$isWrite && $this->isAboveThreshold( $elapsed, 'readQueryTime' ) ) {
389 $this->reportExpectationViolated( 'readQueryTime', $query, $elapsed, $trxId, $serverName );
390 }
391 if ( $isWrite && $this->isAboveThreshold( $elapsed, 'writeQueryTime' ) ) {
392 $this->reportExpectationViolated( 'writeQueryTime', $query, $elapsed, $trxId, $serverName );
393 }
394
395 if ( !$this->dbTrxHoldingLocks ) {
396 // Short-circuit
397 return;
398 } elseif ( !$isWrite && $elapsed < self::EVENT_THRESHOLD_SEC ) {
399 // Not an important query nor slow enough
400 return;
401 }
402
403 foreach ( $this->dbTrxHoldingLocks as $name => $info ) {
404 $lastQuery = end( $this->dbTrxMethodTimes[$name] );
405 if ( $lastQuery ) {
406 // Additional query in the trx...
407 $lastEnd = $lastQuery[2];
408 if ( $sTime >= $lastEnd ) {
409 if ( ( $sTime - $lastEnd ) > self::EVENT_THRESHOLD_SEC ) {
410 // Add an entry representing the time spent doing non-queries
411 $this->dbTrxMethodTimes[$name][] = [ '...delay...', $lastEnd, $sTime ];
412 }
413 $this->dbTrxMethodTimes[$name][] = [ $query, $sTime, $eTime ];
414 }
415 } else {
416 // First query in the trx...
417 if ( $sTime >= $info['start'] ) {
418 $this->dbTrxMethodTimes[$name][] = [ $query, $sTime, $eTime ];
419 }
420 }
421 }
422 }
423
437 public function transactionWritingOut(
438 $server,
439 $db,
440 string $id,
441 float $writeTime,
442 int $affected
443 ) {
444 // Must match $name in transactionWritingIn()
445 $name = "{$db} {$server} TRX#$id";
446 if ( !isset( $this->dbTrxMethodTimes[$name] ) ) {
447 $this->logger->warning( "Detected no transaction for '$name' - out of sync." );
448 return;
449 }
450
451 $slow = false;
452
453 // Warn if too much time was spend writing...
454 if ( $this->isAboveThreshold( $writeTime, 'writeQueryTime' ) ) {
455 $this->reportExpectationViolated(
456 'writeQueryTime',
457 "[transaction writes to {$db} at {$server}]",
458 $writeTime,
459 $id
460 );
461 $slow = true;
462 }
463 // Warn if too many rows were changed...
464 if ( $this->isAboveThreshold( $affected, 'maxAffected' ) ) {
465 $this->reportExpectationViolated(
466 'maxAffected',
467 "[transaction writes to {$db} at {$server}]",
468 $affected,
469 $id
470 );
471 }
472 // Fill in the last non-query period...
473 $lastQuery = end( $this->dbTrxMethodTimes[$name] );
474 if ( $lastQuery ) {
475 $now = $this->getCurrentTime();
476 $lastEnd = $lastQuery[2];
477 if ( ( $now - $lastEnd ) > self::EVENT_THRESHOLD_SEC ) {
478 $this->dbTrxMethodTimes[$name][] = [ '...delay...', $lastEnd, $now ];
479 }
480 }
481 // Check for any slow queries or non-query periods...
482 foreach ( $this->dbTrxMethodTimes[$name] as $info ) {
483 $elapsed = ( $info[2] - $info[1] );
484 if ( $elapsed >= self::DB_LOCK_THRESHOLD_SEC ) {
485 $slow = true;
486 break;
487 }
488 }
489 if ( $slow ) {
490 $trace = '';
491 foreach ( $this->dbTrxMethodTimes[$name] as $i => [ $query, $sTime, $end ] ) {
492 $trace .= sprintf(
493 "%-2d %.3fs %s\n", $i, ( $end - $sTime ), $this->getGeneralizedSql( $query ) );
494 }
495 $this->logger->warning( "Suboptimal transaction [{dbs}]:\n{trace}", [
496 'dbs' => implode( ', ', array_keys( $this->dbTrxHoldingLocks[$name]['conns'] ) ),
497 'trace' => mb_substr( $trace, 0, 2000 )
498 ] );
499 }
500 unset( $this->dbTrxHoldingLocks[$name] );
501 unset( $this->dbTrxMethodTimes[$name] );
502 }
503
504 private function initPlaceholderExpectations() {
505 $this->expect = array_fill_keys(
506 self::EVENT_NAMES,
507 [ self::FLD_LIMIT => INF, self::FLD_FNAME => null ]
508 );
509
510 $this->hits = array_fill_keys( self::COUNTER_EVENT_NAMES, 0 );
511 $this->hitsFName = [];
512 $this->violations = array_fill_keys( self::EVENT_NAMES, 0 );
513 }
514
520 private function isAboveThreshold( $value, string $event ) {
521 if ( $this->silenced[$event] > 0 ) {
522 return false;
523 }
524
525 return ( $value > $this->expect[$event][self::FLD_LIMIT] );
526 }
527
532 private function pingAndCheckThreshold( string $event ) {
533 if ( $this->silenced[$event] > 0 ) {
534 return false;
535 }
536
537 $newValue = ++$this->hits[$event];
538 $limit = $this->expect[$event][self::FLD_LIMIT];
539
540 return ( $newValue > $limit );
541 }
542
543 private function pingAndCheckThresholdFname( string $event, string $fname ): bool {
544 if ( $this->silenced[$event] > 0 || str_starts_with( $fname, 'Wikimedia\\Rdbms\\' ) ) {
545 return false;
546 }
547 $limit = $this->expect[$event][self::FLD_LIMIT];
548 if ( $limit === INF ) {
549 // expectation disabled, skip collecting function names
550 return false;
551 }
552 if ( !isset( $this->hitsFName[$event][$fname] ) ) {
553 $this->hitsFName[$event][$fname] = 0;
554 }
555
556 $newValue = ++$this->hitsFName[$event][$fname];
557
558 return $newValue > $limit;
559 }
560
568 private function reportExpectationViolated(
569 $event,
570 $query,
571 $actual,
572 ?string $trxId = null,
573 ?string $serverName = null
574 ) {
575 $violations = ++$this->violations[$event];
576 // First violation; check if this is a web request
577 if ( $violations === 1 && $this->method !== null ) {
578 $this->statsFactory->getCounter( 'rdbms_trxprofiler_warnings_total' )
579 ->setLabel( 'event', $event )
580 ->setLabel( 'method', $this->method )
581 ->increment();
582 }
583
584 $max = $this->expect[$event][self::FLD_LIMIT];
585 $by = $this->expect[$event][self::FLD_FNAME];
586
587 $message = "Expectation ($event <= $max) by $by not met (actual: {actualSeconds})";
588 if ( $trxId ) {
589 $message .= ' in trx #{trxId}';
590 }
591 $message .= ":\n{query}\n";
592
593 $this->logger->warning(
594 $message,
595 [
596 'db_log_category' => 'performance',
597 'measure' => $event,
598 'maxSeconds' => $max,
599 'by' => $by,
600 'actualSeconds' => $actual,
601 'query' => $this->getGeneralizedSql( $query ),
602 'exception' => new RuntimeException(),
603 'trxId' => $trxId,
604 // Avoid truncated JSON in Logstash (T349140)
605 'fullQuery' => mb_substr( $this->getRawSql( $query ), 0, 2000 ),
606 'dbHost' => $serverName
607 ]
608 );
609 }
610
615 private function getGeneralizedSql( $query ) {
616 return $query instanceof GeneralizedSql ? $query->stringify() : $query;
617 }
618
623 private function getRawSql( $query ) {
624 return $query instanceof GeneralizedSql ? $query->getRawSql() : $query;
625 }
626
631 private function getCurrentTime() {
632 return $this->wallClockOverride ?: microtime( true );
633 }
634
639 public function setMockTime( &$time ) {
640 $this->wallClockOverride =& $time;
641 }
642}
if(!defined('MW_SETUP_CALLBACK'))
Definition WebStart.php:71
Detect high-contention DB queries via profiling calls.
transactionWritingOut( $server, $db, string $id, float $writeTime, int $affected)
Mark a DB as no longer in a transaction.
resetExpectations()
Reset all performance expectations and hit counters.
getExpectation(string $event)
Get the expectation associated with a specific event name.
const EXPECTATION_ANY
Any type of expectation.
redefineExpectations(array $expects, string $fname)
Clear all expectations and hit counters and set new performance expectations.
setExpectations(array $expects, string $fname)
Set one or multiple performance expectations.
transactionWritingIn( $server, $db, string $id, float $startTime)
Mark a DB as in a transaction with one or more writes pending.
recordConnection( $server, $db, bool $isPrimaryWithReplicas)
Mark a DB as having been connected to with a new handle.
const EXPECTATION_REPLICAS_ONLY
Any expectations about replica usage never occurring.
setExpectation(string $event, $limit, string $fname)
Set performance expectations.
silenceForScope(string $type=self::EXPECTATION_ANY)
Temporarily ignore expectations until the returned object goes out of scope.
setStatsFactory(StatsFactory $statsFactory)
Set statsFactory.
isSilenced(string $event)
Check whether an event currently has its expectations silenced.
recordQueryCompletion( $query, float $sTime, bool $isWrite, ?int $rowCount, string $trxId, ?string $serverName=null, ?string $fname=null,)
Register the name and time of a method for slow DB trx detection.
This is the primary interface for validating metrics definitions, caching defined metrics,...