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