Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
90.62% |
116 / 128 |
|
72.73% |
8 / 11 |
CRAP | |
0.00% |
0 / 1 |
CachedPageViewService | |
90.62% |
116 / 128 |
|
72.73% |
8 / 11 |
40.25 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
setLogger | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
setCachedDays | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
supports | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getPageData | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
4 | |||
getSiteData | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
4 | |||
getTopPages | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getCacheExpiry | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getWithCache | |
96.30% |
26 / 27 |
|
0.00% |
0 / 1 |
8 | |||
getTitlesWithCache | |
85.29% |
58 / 68 |
|
0.00% |
0 / 1 |
15.72 | |||
extendDateRange | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\PageViewInfo; |
4 | |
5 | use InvalidArgumentException; |
6 | use MediaWiki\Message\Message; |
7 | use MediaWiki\Page\PageReference; |
8 | use MediaWiki\Status\Status; |
9 | use MediaWiki\Title\TitleFormatter; |
10 | use Psr\Log\LoggerAwareInterface; |
11 | use Psr\Log\LoggerInterface; |
12 | use Psr\Log\NullLogger; |
13 | use StatusValue; |
14 | use Wikimedia\ObjectCache\BagOStuff; |
15 | |
16 | /** |
17 | * Wraps a PageViewService and caches the results. |
18 | */ |
19 | class 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 | } |