Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.55% covered (success)
90.55%
115 / 127
72.73% covered (warning)
72.73%
8 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
CachedPageViewService
90.55% covered (success)
90.55%
115 / 127
72.73% covered (warning)
72.73%
8 / 11
40.28
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 setLogger
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setCachedDays
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 supports
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPageData
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 getSiteData
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 getTopPages
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCacheExpiry
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getWithCache
96.30% covered (success)
96.30%
26 / 27
0.00% covered (danger)
0.00%
0 / 1
8
 getTitlesWithCache
85.29% covered (warning)
85.29%
58 / 68
0.00% covered (danger)
0.00%
0 / 1
15.72
 extendDateRange
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace MediaWiki\Extension\PageViewInfo;
4
5use BagOStuff;
6use InvalidArgumentException;
7use MediaWiki\Status\Status;
8use MediaWiki\Title\Title;
9use Message;
10use Psr\Log\LoggerAwareInterface;
11use Psr\Log\LoggerInterface;
12use Psr\Log\NullLogger;
13use StatusValue;
14
15/**
16 * Wraps a PageViewService and caches the results.
17 */
18class CachedPageViewService implements PageViewService, LoggerAwareInterface {
19    private const ERROR_EXPIRY = 1800;
20
21    /** @var PageViewService */
22    protected $service;
23
24    /** @var BagOStuff */
25    protected $cache;
26
27    /** @var LoggerInterface */
28    protected $logger;
29
30    /** @var string Cache prefix, in case multiple instances of this service coexist */
31    protected $prefix;
32
33    /** @var int */
34    protected $cachedDays = 30;
35
36    public function __construct( PageViewService $service, BagOStuff $cache, string $prefix = '' ) {
37        $this->service = $service;
38        $this->logger = new NullLogger();
39        $this->cache = $cache;
40        $this->prefix = $prefix;
41    }
42
43    public function setLogger( LoggerInterface $logger ) {
44        $this->logger = $logger;
45    }
46
47    /**
48     * Set the number of days that will be cached. To avoid cache fragmentation, the inner service
49     * is always called with this number of days; if necessary, the response will be expanded with
50     * nulls.
51     * @param int $cachedDays
52     */
53    public function setCachedDays( $cachedDays ) {
54        $this->cachedDays = $cachedDays;
55    }
56
57    public function supports( $metric, $scope ) {
58        return $this->service->supports( $metric, $scope );
59    }
60
61    public function getPageData( array $titles, $days, $metric = self::METRIC_VIEW ) {
62        $status = $this->getTitlesWithCache( $metric, $titles );
63        $data = $status->getValue();
64        foreach ( $data as $title => $titleData ) {
65            if ( $days < $this->cachedDays ) {
66                $data[$title] = array_slice( $titleData, -$days, null, true );
67            } elseif ( $days > $this->cachedDays ) {
68                $data[$title] = $this->extendDateRange( $titleData, $days );
69            }
70        }
71        $status->setResult( $status->isOK(), $data );
72        return $status;
73    }
74
75    public function getSiteData( $days, $metric = self::METRIC_VIEW ) {
76        $status = $this->getWithCache( $metric, self::SCOPE_SITE );
77        if ( $status->isOK() ) {
78            $data = $status->getValue();
79            if ( $days < $this->cachedDays ) {
80                $data = array_slice( $data, -$days, null, true );
81            } elseif ( $days > $this->cachedDays ) {
82                $data = $this->extendDateRange( $data, $days );
83            }
84            $status->setResult( true, $data );
85        }
86        return $status;
87    }
88
89    public function getTopPages( $metric = self::METRIC_VIEW ) {
90        return $this->getWithCache( $metric, self::SCOPE_TOP );
91    }
92
93    public function getCacheExpiry( $metric, $scope ) {
94        // add some random delay to avoid cache stampedes
95        return $this->service->getCacheExpiry( $metric, $scope ) + mt_rand( 0, 600 );
96    }
97
98    /**
99     * Like BagOStuff::getWithSetCallback, but returns a StatusValue like PageViewService calls do.
100     * Returns (and caches) null wrapped in a StatusValue on error.
101     * @param string $metric A METRIC_* constant
102     * @param string $scope A SCOPE_* constant (except SCOPE_ARTICLE which has its own method)
103     * @return StatusValue
104     */
105    protected function getWithCache( $metric, $scope ) {
106        $key = $this->cache->makeKey(
107            'pvi',
108            $this->prefix,
109            ( $scope === self::SCOPE_SITE ) ? $this->cachedDays : "",
110            $metric,
111            $scope
112        );
113        $data = $this->cache->get( $key );
114
115        if ( $data === false ) {
116            // no cached data
117            /** @var StatusValue $status */
118            switch ( $scope ) {
119                case self::SCOPE_SITE:
120                    $status = $this->service->getSiteData( $this->cachedDays, $metric );
121                    break;
122                case self::SCOPE_TOP:
123                    $status = $this->service->getTopPages( $metric );
124                    break;
125                default:
126                    throw new InvalidArgumentException( "invalid scope: $scope" );
127            }
128            if ( $status->isOK() ) {
129                $data = $status->getValue();
130                $expiry = $this->getCacheExpiry( $metric, $scope );
131            } else {
132                $data = null;
133                $expiry = self::ERROR_EXPIRY;
134            }
135            $this->cache->set( $key, $data, $expiry );
136        } elseif ( $data === null ) {
137            // cached error
138            $status = StatusValue::newGood( [] );
139            $status->fatal( 'pvi-cached-error', \Message::durationParam( self::ERROR_EXPIRY ) );
140        } else {
141            // valid cached data
142            $status = StatusValue::newGood( $data );
143        }
144        return $status;
145    }
146
147    /**
148     * The equivalent of getWithCache for multiple titles (ie. for SCOPE_ARTICLE).
149     * Errors are also handled per-article.
150     * @param string $metric A METRIC_* constant
151     * @param Title[] $titles
152     * @return StatusValue
153     * @suppress SecurityCheck-DoubleEscaped
154     */
155    protected function getTitlesWithCache( $metric, array $titles ) {
156        if ( !$titles ) {
157            return StatusValue::newGood( [] );
158        }
159
160        // Set up the response array, without any values. This will help preserve the order of titles.
161        $data = array_fill_keys( array_map( static function ( Title $t ) {
162            return $t->getPrefixedDBkey();
163        }, $titles ), false );
164
165        // Fetch data for all titles from cache. Hopefully we are using a cache which has
166        // a cheap getMulti implementation.
167        $titleToCacheKey = $statuses = [];
168        foreach ( $titles as $title ) {
169            $dbKey = $title->getPrefixedDBkey();
170            $titleToCacheKey[$dbKey] = $this->cache->makeKey(
171                'pvi', $this->prefix,
172                $this->cachedDays,
173                $metric,
174                self::SCOPE_ARTICLE,
175                md5( $dbKey )
176            );
177        }
178        $cacheKeyToTitle = array_flip( $titleToCacheKey );
179        $rawData = $this->cache->getMulti( array_keys( $cacheKeyToTitle ) );
180        foreach ( $rawData as $key => $value ) {
181            // BagOStuff::getMulti is unclear on how missing items should be handled; let's
182            // assume some implementations might return that key with a value of false
183            if ( $value !== false ) {
184                $statuses[$cacheKeyToTitle[$key]] = empty( $value['#error'] ) ? StatusValue::newGood()
185                    : StatusValue::newFatal(
186                        'pvi-cached-error-title',
187                        wfEscapeWikiText( $cacheKeyToTitle[$key] ),
188                        Message::durationParam( self::ERROR_EXPIRY )
189                    );
190                unset( $value['#error'] );
191                $data[$cacheKeyToTitle[$key]] = $value;
192            }
193        }
194
195        // Now get and cache the data for the remaining titles from the real service. It might not
196        // return data for all of them.
197        foreach ( $titles as $i => $titleObj ) {
198            if ( $data[$titleObj->getPrefixedDBkey()] !== false ) {
199                unset( $titles[$i] );
200            }
201        }
202        $uncachedStatus = $this->service->getPageData( $titles, $this->cachedDays, $metric );
203        foreach ( $uncachedStatus->success as $title => $success ) {
204            $titleData = $uncachedStatus->getValue()[$title] ?? null;
205            if ( !is_array( $titleData ) || count( $titleData ) < $this->cachedDays ) {
206                // PageViewService is expected to return [ date => null ] for all requested dates
207                $this->logger->warning( 'Upstream service returned invalid data for {title}', [
208                    'title' => $title,
209                    'statusMessage' => Status::wrap( $uncachedStatus )
210                        ->getWikiText( false, false, 'en' ),
211                ] );
212                $titleData = $this->extendDateRange(
213                    is_array( $titleData ) ? $titleData : [],
214                    $this->cachedDays
215                );
216            }
217            $data[$title] = $titleData;
218            if ( $success ) {
219                $statuses[$title] = StatusValue::newGood();
220                $expiry = $this->getCacheExpiry( $metric, self::SCOPE_ARTICLE );
221            } else {
222                $data[$title]['#error'] = true;
223                $statuses[$title] = StatusValue::newFatal(
224                    'pvi-cached-error-title',
225                    wfEscapeWikiText( $title ),
226                    Message::durationParam( self::ERROR_EXPIRY )
227                );
228                $expiry = self::ERROR_EXPIRY;
229            }
230            $this->cache->set( $titleToCacheKey[$title], $data[$title], $expiry );
231            unset( $data[$title]['#error'] );
232        }
233
234        // Almost done; we need to truncate the data at the first "hole" (title not returned
235        // either by getMulti or getPageData) so we return a consecutive prefix of the
236        // requested titles and do not mess up continuation.
237        $holeIndex = array_search( false, array_values( $data ), true );
238        $data = array_slice( $data, 0, $holeIndex ?: null, true );
239        $statuses = array_slice( $statuses, 0, $holeIndex ?: null, true );
240
241        $status = StatusValue::newGood( $data );
242        array_walk( $statuses, [ $status, 'merge' ] );
243        $status->success = array_map( static function ( StatusValue $s ) {
244             return $s->isOK();
245        }, $statuses );
246        $status->successCount = count( array_filter( $status->success ) );
247        $status->failCount = count( $status->success ) - $status->successCount;
248        $status->setResult( (bool)$status->successCount, $data );
249        return $status;
250    }
251
252    /**
253     * Add extra days (with a null value) to the beginning of a date range to make it have at least
254     * ::$cachedDays days.
255     * @param array $data YYYY-MM-DD => count, ordered, has less than $cachedDays items
256     * @param int $days
257     * @return array
258     */
259    protected function extendDateRange( $data, $days ) {
260        // set to noon to avoid skip second and similar problems
261        $day = strtotime( array_key_first( $data ) . 'T00:00Z' ) + 12 * 3600;
262        for ( $i = $days - count( $data ); $i > 0; $i-- ) {
263            $day -= 24 * 3600;
264            $data = [ gmdate( 'Y-m-d', $day ) => null ] + $data;
265        }
266        return $data;
267    }
268}