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 $violations;
38 private $silenced;
39
44 private $dbTrxHoldingLocks;
45
50 private $dbTrxMethodTimes;
51
53 private $method;
54
56 private $wallClockOverride;
57
59 private const DB_LOCK_THRESHOLD_SEC = 3.0;
61 private const EVENT_THRESHOLD_SEC = 0.25;
62
64 private const EVENT_NAMES = [
65 'writes',
66 'queries',
67 'conns',
68 'masterConns',
69 'maxAffected',
70 'readQueryRows',
71 'readQueryTime',
72 'writeQueryTime'
73 ];
74
76 private const COUNTER_EVENT_NAMES = [
77 'writes',
78 'queries',
79 'conns',
80 'masterConns'
81 ];
82
84 private const FLD_LIMIT = 0;
86 private const FLD_FNAME = 1;
87
89 public const EXPECTATION_ANY = 'any';
91 public const EXPECTATION_REPLICAS_ONLY = 'replicas-only';
92
93 public function __construct() {
94 $this->initPlaceholderExpectations();
95
96 $this->dbTrxHoldingLocks = [];
97 $this->dbTrxMethodTimes = [];
98
99 $this->silenced = array_fill_keys( self::EVENT_NAMES, 0 );
100
101 $this->setLogger( new NullLogger() );
102 $this->statsFactory = StatsFactory::newNull();
103 }
104
105 public function setLogger( LoggerInterface $logger ): void {
106 $this->logger = $logger;
107 }
108
115 public function setStatsFactory( StatsFactory $statsFactory ) {
116 $this->statsFactory = $statsFactory;
117 }
118
123 public function setRequestMethod( ?string $method ) {
124 $this->method = $method;
125 }
126
139 #[\NoDiscard]
140 public function silenceForScope( string $type = self::EXPECTATION_ANY ): ScopedCallback {
141 if ( $type === self::EXPECTATION_REPLICAS_ONLY ) {
142 $events = [];
143 foreach ( [ 'writes', 'masterConns' ] as $event ) {
144 if ( $this->expect[$event][self::FLD_LIMIT] === 0 ) {
145 $events[] = $event;
146 }
147 }
148 } else {
149 $events = self::EVENT_NAMES;
150 }
151
152 foreach ( $events as $event ) {
153 ++$this->silenced[$event];
154 }
155
156 return new ScopedCallback( function () use ( $events ) {
157 foreach ( $events as $event ) {
158 --$this->silenced[$event];
159 }
160 } );
161 }
162
178 public function isSilenced( string $event ): bool {
179 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
180 throw new RuntimeException( 'May only be called in tests' );
181 }
182 if ( !isset( $this->silenced[$event] ) ) {
183 throw new InvalidArgumentException( "Unrecognised event name '$event' provided." );
184 }
185
186 return $this->silenced[$event] > 0;
187 }
188
199 public function setExpectation( string $event, $limit, string $fname ) {
200 if ( !isset( $this->expect[$event] ) ) {
201 return; // obsolete/bogus expectation
202 }
203
204 if ( $limit <= $this->expect[$event][self::FLD_LIMIT] ) {
205 // New limit is more restrictive
206 $this->expect[$event] = [
207 self::FLD_LIMIT => $limit,
208 self::FLD_FNAME => $fname
209 ];
210 }
211 }
212
224 public function setExpectations( array $expects, string $fname ) {
225 foreach ( $expects as $event => $value ) {
226 $this->setExpectation( $event, $value, $fname );
227 }
228 }
229
239 public function resetExpectations() {
240 $this->initPlaceholderExpectations();
241 }
242
253 public function redefineExpectations( array $expects, string $fname ) {
254 $this->initPlaceholderExpectations();
255 $this->setExpectations( $expects, $fname );
256 }
257
273 public function getExpectation( string $event ) {
274 if ( !isset( $this->expect[$event] ) ) {
275 throw new InvalidArgumentException( "Unrecognised event name '$event' provided." );
276 }
277
278 return $this->expect[$event][self::FLD_LIMIT];
279 }
280
290 public function recordConnection( $server, $db, bool $isPrimaryWithReplicas ) {
291 // Report when too many connections happen...
292 if ( $this->pingAndCheckThreshold( 'conns' ) ) {
293 $this->reportExpectationViolated(
294 'conns',
295 "[connect to $server ($db)]",
296 $this->hits['conns']
297 );
298 }
299
300 // Report when too many primary connections happen...
301 if ( $isPrimaryWithReplicas && $this->pingAndCheckThreshold( 'masterConns' ) ) {
302 $this->reportExpectationViolated(
303 'masterConns',
304 "[connect to $server ($db)]",
305 $this->hits['masterConns']
306 );
307 }
308 }
309
320 public function transactionWritingIn( $server, $db, string $id, float $startTime ) {
321 $name = "{$db} {$server} TRX#$id";
322 if ( isset( $this->dbTrxHoldingLocks[$name] ) ) {
323 $this->logger->warning( "Nested transaction for '$name' - out of sync." );
324 }
325 $this->dbTrxHoldingLocks[$name] = [
326 'start' => $startTime,
327 'conns' => [], // all connections involved
328 ];
329 $this->dbTrxMethodTimes[$name] = [];
330
331 foreach ( $this->dbTrxHoldingLocks as $name => &$info ) {
332 // Track all DBs in transactions for this transaction
333 $info['conns'][$name] = 1;
334 }
335 }
336
349 public function recordQueryCompletion(
350 $query,
351 float $sTime,
352 bool $isWrite,
353 ?int $rowCount,
354 string $trxId,
355 ?string $serverName = null
356 ) {
357 $eTime = $this->getCurrentTime();
358 $elapsed = ( $eTime - $sTime );
359
360 if ( $isWrite && $this->isAboveThreshold( $rowCount, 'maxAffected' ) ) {
361 $this->reportExpectationViolated( 'maxAffected', $query, $rowCount, $trxId, $serverName );
362 } elseif ( !$isWrite && $this->isAboveThreshold( $rowCount, 'readQueryRows' ) ) {
363 $this->reportExpectationViolated( 'readQueryRows', $query, $rowCount, $trxId, $serverName );
364 }
365
366 // Report when too many writes/queries happen...
367 if ( $this->pingAndCheckThreshold( 'queries' ) ) {
368 $this->reportExpectationViolated( 'queries', $query, $this->hits['queries'], $trxId, $serverName );
369 }
370 if ( $isWrite && $this->pingAndCheckThreshold( 'writes' ) ) {
371 $this->reportExpectationViolated( 'writes', $query, $this->hits['writes'], $trxId, $serverName );
372 }
373 // Report slow queries...
374 if ( !$isWrite && $this->isAboveThreshold( $elapsed, 'readQueryTime' ) ) {
375 $this->reportExpectationViolated( 'readQueryTime', $query, $elapsed, $trxId, $serverName );
376 }
377 if ( $isWrite && $this->isAboveThreshold( $elapsed, 'writeQueryTime' ) ) {
378 $this->reportExpectationViolated( 'writeQueryTime', $query, $elapsed, $trxId, $serverName );
379 }
380
381 if ( !$this->dbTrxHoldingLocks ) {
382 // Short-circuit
383 return;
384 } elseif ( !$isWrite && $elapsed < self::EVENT_THRESHOLD_SEC ) {
385 // Not an important query nor slow enough
386 return;
387 }
388
389 foreach ( $this->dbTrxHoldingLocks as $name => $info ) {
390 $lastQuery = end( $this->dbTrxMethodTimes[$name] );
391 if ( $lastQuery ) {
392 // Additional query in the trx...
393 $lastEnd = $lastQuery[2];
394 if ( $sTime >= $lastEnd ) {
395 if ( ( $sTime - $lastEnd ) > self::EVENT_THRESHOLD_SEC ) {
396 // Add an entry representing the time spent doing non-queries
397 $this->dbTrxMethodTimes[$name][] = [ '...delay...', $lastEnd, $sTime ];
398 }
399 $this->dbTrxMethodTimes[$name][] = [ $query, $sTime, $eTime ];
400 }
401 } else {
402 // First query in the trx...
403 if ( $sTime >= $info['start'] ) {
404 $this->dbTrxMethodTimes[$name][] = [ $query, $sTime, $eTime ];
405 }
406 }
407 }
408 }
409
423 public function transactionWritingOut(
424 $server,
425 $db,
426 string $id,
427 float $writeTime,
428 int $affected
429 ) {
430 // Must match $name in transactionWritingIn()
431 $name = "{$db} {$server} TRX#$id";
432 if ( !isset( $this->dbTrxMethodTimes[$name] ) ) {
433 $this->logger->warning( "Detected no transaction for '$name' - out of sync." );
434 return;
435 }
436
437 $slow = false;
438
439 // Warn if too much time was spend writing...
440 if ( $this->isAboveThreshold( $writeTime, 'writeQueryTime' ) ) {
441 $this->reportExpectationViolated(
442 'writeQueryTime',
443 "[transaction writes to {$db} at {$server}]",
444 $writeTime,
445 $id
446 );
447 $slow = true;
448 }
449 // Warn if too many rows were changed...
450 if ( $this->isAboveThreshold( $affected, 'maxAffected' ) ) {
451 $this->reportExpectationViolated(
452 'maxAffected',
453 "[transaction writes to {$db} at {$server}]",
454 $affected,
455 $id
456 );
457 }
458 // Fill in the last non-query period...
459 $lastQuery = end( $this->dbTrxMethodTimes[$name] );
460 if ( $lastQuery ) {
461 $now = $this->getCurrentTime();
462 $lastEnd = $lastQuery[2];
463 if ( ( $now - $lastEnd ) > self::EVENT_THRESHOLD_SEC ) {
464 $this->dbTrxMethodTimes[$name][] = [ '...delay...', $lastEnd, $now ];
465 }
466 }
467 // Check for any slow queries or non-query periods...
468 foreach ( $this->dbTrxMethodTimes[$name] as $info ) {
469 $elapsed = ( $info[2] - $info[1] );
470 if ( $elapsed >= self::DB_LOCK_THRESHOLD_SEC ) {
471 $slow = true;
472 break;
473 }
474 }
475 if ( $slow ) {
476 $trace = '';
477 foreach ( $this->dbTrxMethodTimes[$name] as $i => [ $query, $sTime, $end ] ) {
478 $trace .= sprintf(
479 "%-2d %.3fs %s\n", $i, ( $end - $sTime ), $this->getGeneralizedSql( $query ) );
480 }
481 $this->logger->warning( "Suboptimal transaction [{dbs}]:\n{trace}", [
482 'dbs' => implode( ', ', array_keys( $this->dbTrxHoldingLocks[$name]['conns'] ) ),
483 'trace' => mb_substr( $trace, 0, 2000 )
484 ] );
485 }
486 unset( $this->dbTrxHoldingLocks[$name] );
487 unset( $this->dbTrxMethodTimes[$name] );
488 }
489
490 private function initPlaceholderExpectations() {
491 $this->expect = array_fill_keys(
492 self::EVENT_NAMES,
493 [ self::FLD_LIMIT => INF, self::FLD_FNAME => null ]
494 );
495
496 $this->hits = array_fill_keys( self::COUNTER_EVENT_NAMES, 0 );
497 $this->violations = array_fill_keys( self::EVENT_NAMES, 0 );
498 }
499
505 private function isAboveThreshold( $value, string $event ) {
506 if ( $this->silenced[$event] > 0 ) {
507 return false;
508 }
509
510 return ( $value > $this->expect[$event][self::FLD_LIMIT] );
511 }
512
517 private function pingAndCheckThreshold( string $event ) {
518 if ( $this->silenced[$event] > 0 ) {
519 return false;
520 }
521
522 $newValue = ++$this->hits[$event];
523 $limit = $this->expect[$event][self::FLD_LIMIT];
524
525 return ( $newValue > $limit );
526 }
527
535 private function reportExpectationViolated(
536 $event,
537 $query,
538 $actual,
539 ?string $trxId = null,
540 ?string $serverName = null
541 ) {
542 $violations = ++$this->violations[$event];
543 // First violation; check if this is a web request
544 if ( $violations === 1 && $this->method !== null ) {
545 $this->statsFactory->getCounter( 'rdbms_trxprofiler_warnings_total' )
546 ->setLabel( 'event', $event )
547 ->setLabel( 'method', $this->method )
548 ->increment();
549 }
550
551 $max = $this->expect[$event][self::FLD_LIMIT];
552 $by = $this->expect[$event][self::FLD_FNAME];
553
554 $message = "Expectation ($event <= $max) by $by not met (actual: {actualSeconds})";
555 if ( $trxId ) {
556 $message .= ' in trx #{trxId}';
557 }
558 $message .= ":\n{query}\n";
559
560 $this->logger->warning(
561 $message,
562 [
563 'db_log_category' => 'performance',
564 'measure' => $event,
565 'maxSeconds' => $max,
566 'by' => $by,
567 'actualSeconds' => $actual,
568 'query' => $this->getGeneralizedSql( $query ),
569 'exception' => new RuntimeException(),
570 'trxId' => $trxId,
571 // Avoid truncated JSON in Logstash (T349140)
572 'fullQuery' => mb_substr( $this->getRawSql( $query ), 0, 2000 ),
573 'dbHost' => $serverName
574 ]
575 );
576 }
577
582 private function getGeneralizedSql( $query ) {
583 return $query instanceof GeneralizedSql ? $query->stringify() : $query;
584 }
585
590 private function getRawSql( $query ) {
591 return $query instanceof GeneralizedSql ? $query->getRawSql() : $query;
592 }
593
598 private function getCurrentTime() {
599 return $this->wallClockOverride ?: microtime( true );
600 }
601
606 public function setMockTime( &$time ) {
607 $this->wallClockOverride =& $time;
608 }
609}
if(!defined('MW_SETUP_CALLBACK'))
Definition WebStart.php:68
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.
recordQueryCompletion( $query, float $sTime, bool $isWrite, ?int $rowCount, string $trxId, ?string $serverName=null)
Register the name and time of a method for slow DB trx detection.
setStatsFactory(StatsFactory $statsFactory)
Set statsFactory.
isSilenced(string $event)
Check whether an event currently has its expectations silenced.
This is the primary interface for validating metrics definitions, caching defined metrics,...