Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.87% covered (warning)
81.87%
140 / 171
55.00% covered (warning)
55.00%
11 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
TransactionProfiler
81.87% covered (warning)
81.87%
140 / 171
55.00% covered (warning)
55.00%
11 / 20
102.89
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 setLogger
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setStatsFactory
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setRequestMethod
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 silenceForScope
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
 setExpectation
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 setExpectations
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 resetExpectations
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 redefineExpectations
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getExpectation
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 recordConnection
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 transactionWritingIn
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 recordQueryCompletion
71.43% covered (warning)
71.43%
20 / 28
0.00% covered (danger)
0.00%
0 / 1
29.33
 transactionWritingOut
71.43% covered (warning)
71.43%
30 / 42
0.00% covered (danger)
0.00%
0 / 1
12.33
 initPlaceholderExpectations
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 isAboveThreshold
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 pingAndCheckThreshold
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 reportExpectationViolated
85.19% covered (warning)
85.19%
23 / 27
0.00% covered (danger)
0.00%
0 / 1
4.05
 getGeneralizedSql
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getRawSql
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getCurrentTime
n/a
0 / 0
n/a
0 / 0
2
 setMockTime
n/a
0 / 0
n/a
0 / 0
1
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6namespace Wikimedia\Rdbms;
7
8use InvalidArgumentException;
9use Psr\Log\LoggerAwareInterface;
10use Psr\Log\LoggerInterface;
11use Psr\Log\NullLogger;
12use RuntimeException;
13use Wikimedia\ScopedCallback;
14use Wikimedia\Stats\StatsFactory;
15
16/**
17 * Detect high-contention DB queries via profiling calls.
18 *
19 * This class is meant to work with an IDatabase object, which manages queries.
20 *
21 * @internal For use by Database only
22 * @since 1.24
23 * @ingroup Profiler
24 * @ingroup Database
25 */
26class TransactionProfiler implements LoggerAwareInterface {
27    /** @var LoggerInterface */
28    private $logger;
29    /** @var StatsFactory */
30    private $statsFactory;
31    /** @var array<string,array> Map of (event name => map of FLD_* class constants) */
32    private $expect;
33    /** @var array<string,int> Map of (event name => current hits) */
34    private $hits;
35    /** @var array<string,int> Map of (event name => violation counter) */
36    private $violations;
37    /** @var array<string,int> Map of (event name => silence counter) */
38    private $silenced;
39
40    /**
41     * @var array<string,array> Map of (trx ID => (write start time, list of DBs involved))
42     * @phan-var array<string,array{start:float,conns:array<string,int>}>
43     */
44    private $dbTrxHoldingLocks;
45
46    /**
47     * @var array[][] Map of (trx ID => list of (query name, start time, end time))
48     * @phan-var array<string,array<int,array{0:string|GeneralizedSQL,1:float,2:float}>>
49     */
50    private $dbTrxMethodTimes;
51
52    /** @var string|null HTTP request method; null for CLI mode */
53    private $method;
54
55    /** @var float|null */
56    private $wallClockOverride;
57
58    /** Treat locks as long-running if they last longer than this many seconds */
59    private const DB_LOCK_THRESHOLD_SEC = 3.0;
60    /** Include events in any violation logs if they last longer than this many seconds */
61    private const EVENT_THRESHOLD_SEC = 0.25;
62
63    /** List of event names */
64    private const EVENT_NAMES = [
65        'writes',
66        'queries',
67        'conns',
68        'masterConns',
69        'maxAffected',
70        'readQueryRows',
71        'readQueryTime',
72        'writeQueryTime'
73    ];
74
75    /** List of event names with hit counters */
76    private const COUNTER_EVENT_NAMES = [
77        'writes',
78        'queries',
79        'conns',
80        'masterConns'
81    ];
82
83    /** Key to max expected value */
84    private const FLD_LIMIT = 0;
85    /** Key to the function that set the max expected value */
86    private const FLD_FNAME = 1;
87
88    /** Any type of expectation */
89    public const EXPECTATION_ANY = 'any';
90    /** Any expectations about replica usage never occurring */
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
109    /**
110     * Set statsFactory
111     *
112     * @param StatsFactory $statsFactory
113     * @return void
114     */
115    public function setStatsFactory( StatsFactory $statsFactory ) {
116        $this->statsFactory = $statsFactory;
117    }
118
119    /**
120     * @param ?string $method HTTP method; null for CLI mode
121     * @return void
122     */
123    public function setRequestMethod( ?string $method ) {
124        $this->method = $method;
125    }
126
127    /**
128     * Temporarily ignore expectations until the returned object goes out of scope
129     *
130     * During this time, violation of expectations will not be logged and counters
131     * for expectations (e.g. "conns") will not be incremented.
132     *
133     * This will suppress warnings about event counters which have a limit of zero.
134     * The main use case is too avoid warnings about primary connections/writes and
135     * warnings about getting any primary/replica connections at all.
136     *
137     * @param string $type Class EXPECTATION_* constant [default: TransactionProfiler::EXPECTATION_ANY]
138     * @return ScopedCallback
139     */
140    public function silenceForScope( string $type = self::EXPECTATION_ANY ) {
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
163    /**
164     * Set performance expectations
165     *
166     * With conflicting expectations, the most narrow ones will be used
167     *
168     * @param string $event Event name, {@see self::EVENT_NAMES}
169     * @param float|int $limit Maximum event count, event value, or total event value
170     * @param string $fname Caller
171     * @since 1.25
172     */
173    public function setExpectation( string $event, $limit, string $fname ) {
174        if ( !isset( $this->expect[$event] ) ) {
175            return; // obsolete/bogus expectation
176        }
177
178        if ( $limit <= $this->expect[$event][self::FLD_LIMIT] ) {
179            // New limit is more restrictive
180            $this->expect[$event] = [
181                self::FLD_LIMIT => $limit,
182                self::FLD_FNAME => $fname
183            ];
184        }
185    }
186
187    /**
188     * Set one or multiple performance expectations
189     *
190     * With conflicting expectations, the most narrow ones will be used
191     *
192     * Use this to initialize expectations or make them stricter mid-request
193     *
194     * @param array $expects Map of (event name => limit), {@see self::EVENT_NAMES}
195     * @param string $fname
196     * @since 1.26
197     */
198    public function setExpectations( array $expects, string $fname ) {
199        foreach ( $expects as $event => $value ) {
200            $this->setExpectation( $event, $value, $fname );
201        }
202    }
203
204    /**
205     * Reset all performance expectations and hit counters
206     *
207     * Use this for unit testing or before applying a totally different set of expectations
208     * for a different part of the request, such as during "post-send" (execution after HTTP
209     * response completion)
210     *
211     * @since 1.25
212     */
213    public function resetExpectations() {
214        $this->initPlaceholderExpectations();
215    }
216
217    /**
218     * Clear all expectations and hit counters and set new performance expectations
219     *
220     * Use this to apply a totally different set of expectations for a different part
221     * of the request, such as during "post-send" (execution after HTTP response completion)
222     *
223     * @param array $expects Map of (event name => limit), {@see self::EVENT_NAMES}
224     * @param string $fname
225     * @since 1.33
226     */
227    public function redefineExpectations( array $expects, string $fname ) {
228        $this->initPlaceholderExpectations();
229        $this->setExpectations( $expects, $fname );
230    }
231
232    /**
233     * Get the expectation associated with a specific event name.
234     *
235     * This will return the value of the expectation even if the event is silenced.
236     *
237     * Use this to check if a specific event is allowed before performing it, such as checking
238     * if the request will allow writes before performing them and instead deferring the writes
239     * to outside the request.
240     *
241     * @since 1.44
242     * @param string $event Event name. Valid event names are defined in {@see self::EVENT_NAMES}
243     * @return float|int Maximum event count, event value, or total event value
244     *    depending on the type of event.
245     * @throws InvalidArgumentException If the provided event name is not one in {@see self::EVENT_NAMES}
246     */
247    public function getExpectation( string $event ) {
248        if ( !isset( $this->expect[$event] ) ) {
249            throw new InvalidArgumentException( "Unrecognised event name '$event' provided." );
250        }
251
252        return $this->expect[$event][self::FLD_LIMIT];
253    }
254
255    /**
256     * Mark a DB as having been connected to with a new handle
257     *
258     * Note that there can be multiple connections to a single DB.
259     *
260     * @param string $server DB server
261     * @param string|null $db DB name
262     * @param bool $isPrimaryWithReplicas If the server is the primary and there are replicas
263     */
264    public function recordConnection( $server, $db, bool $isPrimaryWithReplicas ) {
265        // Report when too many connections happen...
266        if ( $this->pingAndCheckThreshold( 'conns' ) ) {
267            $this->reportExpectationViolated(
268                'conns',
269                "[connect to $server ($db)]",
270                $this->hits['conns']
271            );
272        }
273
274        // Report when too many primary connections happen...
275        if ( $isPrimaryWithReplicas && $this->pingAndCheckThreshold( 'masterConns' ) ) {
276            $this->reportExpectationViolated(
277                'masterConns',
278                "[connect to $server ($db)]",
279                $this->hits['masterConns']
280            );
281        }
282    }
283
284    /**
285     * Mark a DB as in a transaction with one or more writes pending
286     *
287     * Note that there can be multiple connections to a single DB.
288     *
289     * @param string $server DB server
290     * @param string|null $db DB name
291     * @param string $id ID string of transaction
292     * @param float $startTime UNIX timestamp
293     */
294    public function transactionWritingIn( $server, $db, string $id, float $startTime ) {
295        $name = "{$db} {$server} TRX#$id";
296        if ( isset( $this->dbTrxHoldingLocks[$name] ) ) {
297            $this->logger->warning( "Nested transaction for '$name' - out of sync." );
298        }
299        $this->dbTrxHoldingLocks[$name] = [
300            'start' => $startTime,
301            'conns' => [], // all connections involved
302        ];
303        $this->dbTrxMethodTimes[$name] = [];
304
305        foreach ( $this->dbTrxHoldingLocks as $name => &$info ) {
306            // Track all DBs in transactions for this transaction
307            $info['conns'][$name] = 1;
308        }
309    }
310
311    /**
312     * Register the name and time of a method for slow DB trx detection
313     *
314     * This assumes that all queries are synchronous (non-overlapping)
315     *
316     * @param string|GeneralizedSql $query Function name or generalized SQL
317     * @param float $sTime Starting UNIX wall time
318     * @param bool $isWrite Whether this is a write query
319     * @param int|null $rowCount Number of affected/read rows
320     * @param string $trxId Transaction id
321     * @param string|null $serverName db host name like db1234
322     */
323    public function recordQueryCompletion(
324        $query,
325        float $sTime,
326        bool $isWrite,
327        ?int $rowCount,
328        string $trxId,
329        ?string $serverName = null
330    ) {
331        $eTime = $this->getCurrentTime();
332        $elapsed = ( $eTime - $sTime );
333
334        if ( $isWrite && $this->isAboveThreshold( $rowCount, 'maxAffected' ) ) {
335            $this->reportExpectationViolated( 'maxAffected', $query, $rowCount, $trxId, $serverName );
336        } elseif ( !$isWrite && $this->isAboveThreshold( $rowCount, 'readQueryRows' ) ) {
337            $this->reportExpectationViolated( 'readQueryRows', $query, $rowCount, $trxId, $serverName );
338        }
339
340        // Report when too many writes/queries happen...
341        if ( $this->pingAndCheckThreshold( 'queries' ) ) {
342            $this->reportExpectationViolated( 'queries', $query, $this->hits['queries'], $trxId, $serverName );
343        }
344        if ( $isWrite && $this->pingAndCheckThreshold( 'writes' ) ) {
345            $this->reportExpectationViolated( 'writes', $query, $this->hits['writes'], $trxId, $serverName );
346        }
347        // Report slow queries...
348        if ( !$isWrite && $this->isAboveThreshold( $elapsed, 'readQueryTime' ) ) {
349            $this->reportExpectationViolated( 'readQueryTime', $query, $elapsed, $trxId, $serverName );
350        }
351        if ( $isWrite && $this->isAboveThreshold( $elapsed, 'writeQueryTime' ) ) {
352            $this->reportExpectationViolated( 'writeQueryTime', $query, $elapsed, $trxId, $serverName );
353        }
354
355        if ( !$this->dbTrxHoldingLocks ) {
356            // Short-circuit
357            return;
358        } elseif ( !$isWrite && $elapsed < self::EVENT_THRESHOLD_SEC ) {
359            // Not an important query nor slow enough
360            return;
361        }
362
363        foreach ( $this->dbTrxHoldingLocks as $name => $info ) {
364            $lastQuery = end( $this->dbTrxMethodTimes[$name] );
365            if ( $lastQuery ) {
366                // Additional query in the trx...
367                $lastEnd = $lastQuery[2];
368                if ( $sTime >= $lastEnd ) {
369                    if ( ( $sTime - $lastEnd ) > self::EVENT_THRESHOLD_SEC ) {
370                        // Add an entry representing the time spent doing non-queries
371                        $this->dbTrxMethodTimes[$name][] = [ '...delay...', $lastEnd, $sTime ];
372                    }
373                    $this->dbTrxMethodTimes[$name][] = [ $query, $sTime, $eTime ];
374                }
375            } else {
376                // First query in the trx...
377                if ( $sTime >= $info['start'] ) {
378                    $this->dbTrxMethodTimes[$name][] = [ $query, $sTime, $eTime ];
379                }
380            }
381        }
382    }
383
384    /**
385     * Mark a DB as no longer in a transaction
386     *
387     * This will check if locks are possibly held for longer than
388     * needed and log any affected transactions to a special DB log.
389     * Note that there can be multiple connections to a single DB.
390     *
391     * @param string $server DB server
392     * @param string|null $db DB name
393     * @param string $id ID string of transaction
394     * @param float $writeTime Time spent in write queries
395     * @param int $affected Number of rows affected by writes
396     */
397    public function transactionWritingOut(
398        $server,
399        $db,
400        string $id,
401        float $writeTime,
402        int $affected
403    ) {
404        // Must match $name in transactionWritingIn()
405        $name = "{$db} {$server} TRX#$id";
406        if ( !isset( $this->dbTrxMethodTimes[$name] ) ) {
407            $this->logger->warning( "Detected no transaction for '$name' - out of sync." );
408            return;
409        }
410
411        $slow = false;
412
413        // Warn if too much time was spend writing...
414        if ( $this->isAboveThreshold( $writeTime, 'writeQueryTime' ) ) {
415            $this->reportExpectationViolated(
416                'writeQueryTime',
417                "[transaction writes to {$db} at {$server}]",
418                $writeTime,
419                $id
420            );
421            $slow = true;
422        }
423        // Warn if too many rows were changed...
424        if ( $this->isAboveThreshold( $affected, 'maxAffected' ) ) {
425            $this->reportExpectationViolated(
426                'maxAffected',
427                "[transaction writes to {$db} at {$server}]",
428                $affected,
429                $id
430            );
431        }
432        // Fill in the last non-query period...
433        $lastQuery = end( $this->dbTrxMethodTimes[$name] );
434        if ( $lastQuery ) {
435            $now = $this->getCurrentTime();
436            $lastEnd = $lastQuery[2];
437            if ( ( $now - $lastEnd ) > self::EVENT_THRESHOLD_SEC ) {
438                $this->dbTrxMethodTimes[$name][] = [ '...delay...', $lastEnd, $now ];
439            }
440        }
441        // Check for any slow queries or non-query periods...
442        foreach ( $this->dbTrxMethodTimes[$name] as $info ) {
443            $elapsed = ( $info[2] - $info[1] );
444            if ( $elapsed >= self::DB_LOCK_THRESHOLD_SEC ) {
445                $slow = true;
446                break;
447            }
448        }
449        if ( $slow ) {
450            $trace = '';
451            foreach ( $this->dbTrxMethodTimes[$name] as $i => [ $query, $sTime, $end ] ) {
452                $trace .= sprintf(
453                    "%-2d %.3fs %s\n", $i, ( $end - $sTime ), $this->getGeneralizedSql( $query ) );
454            }
455            $this->logger->warning( "Suboptimal transaction [{dbs}]:\n{trace}", [
456                'dbs' => implode( ', ', array_keys( $this->dbTrxHoldingLocks[$name]['conns'] ) ),
457                'trace' => mb_substr( $trace, 0, 2000 )
458            ] );
459        }
460        unset( $this->dbTrxHoldingLocks[$name] );
461        unset( $this->dbTrxMethodTimes[$name] );
462    }
463
464    private function initPlaceholderExpectations() {
465        $this->expect = array_fill_keys(
466            self::EVENT_NAMES,
467            [ self::FLD_LIMIT => INF, self::FLD_FNAME => null ]
468        );
469
470        $this->hits = array_fill_keys( self::COUNTER_EVENT_NAMES, 0 );
471        $this->violations = array_fill_keys( self::EVENT_NAMES, 0 );
472    }
473
474    /**
475     * @param float|int $value
476     * @param string $event
477     * @return bool
478     */
479    private function isAboveThreshold( $value, string $event ) {
480        if ( $this->silenced[$event] > 0 ) {
481            return false;
482        }
483
484        return ( $value > $this->expect[$event][self::FLD_LIMIT] );
485    }
486
487    /**
488     * @param string $event
489     * @return bool
490     */
491    private function pingAndCheckThreshold( string $event ) {
492        if ( $this->silenced[$event] > 0 ) {
493            return false;
494        }
495
496        $newValue = ++$this->hits[$event];
497        $limit = $this->expect[$event][self::FLD_LIMIT];
498
499        return ( $newValue > $limit );
500    }
501
502    /**
503     * @param string $event
504     * @param string|GeneralizedSql $query
505     * @param float|int $actual
506     * @param string|null $trxId Transaction id
507     * @param string|null $serverName db host name like db1234
508     */
509    private function reportExpectationViolated(
510        $event,
511        $query,
512        $actual,
513        ?string $trxId = null,
514        ?string $serverName = null
515    ) {
516        $violations = ++$this->violations[$event];
517        // First violation; check if this is a web request
518        if ( $violations === 1 && $this->method !== null ) {
519            $this->statsFactory->getCounter( 'rdbms_trxprofiler_warnings_total' )
520                ->setLabel( 'event', $event )
521                ->setLabel( 'method', $this->method )
522                ->increment();
523        }
524
525        $max = $this->expect[$event][self::FLD_LIMIT];
526        $by = $this->expect[$event][self::FLD_FNAME];
527
528        $message = "Expectation ($event <= $max) by $by not met (actual: {actualSeconds})";
529        if ( $trxId ) {
530            $message .= ' in trx #{trxId}';
531        }
532        $message .= ":\n{query}\n";
533
534        $this->logger->warning(
535            $message,
536            [
537                'db_log_category' => 'performance',
538                'measure' => $event,
539                'maxSeconds' => $max,
540                'by' => $by,
541                'actualSeconds' => $actual,
542                'query' => $this->getGeneralizedSql( $query ),
543                'exception' => new RuntimeException(),
544                'trxId' => $trxId,
545                // Avoid truncated JSON in Logstash (T349140)
546                'fullQuery' => mb_substr( $this->getRawSql( $query ), 0, 2000 ),
547                'dbHost' => $serverName
548            ]
549        );
550    }
551
552    /**
553     * @param GeneralizedSql|string $query
554     * @return string
555     */
556    private function getGeneralizedSql( $query ) {
557        return $query instanceof GeneralizedSql ? $query->stringify() : $query;
558    }
559
560    /**
561     * @param GeneralizedSql|string $query
562     * @return string
563     */
564    private function getRawSql( $query ) {
565        return $query instanceof GeneralizedSql ? $query->getRawSql() : $query;
566    }
567
568    /**
569     * @return float UNIX timestamp
570     * @codeCoverageIgnore
571     */
572    private function getCurrentTime() {
573        return $this->wallClockOverride ?: microtime( true );
574    }
575
576    /**
577     * @param float|null &$time Mock UNIX timestamp for testing
578     * @codeCoverageIgnore
579     */
580    public function setMockTime( &$time ) {
581        $this->wallClockOverride =& $time;
582    }
583}