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