Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
91.84% |
90 / 98 |
|
33.33% |
2 / 6 |
CRAP | |
0.00% |
0 / 1 |
CacheDecorator | |
91.84% |
90 / 98 |
|
33.33% |
2 / 6 |
33.59 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
suggest | |
94.94% |
75 / 79 |
|
0.00% |
0 / 1 |
24.07 | |||
filter | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
runTaskSetListener | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
serialize | |
66.67% |
4 / 6 |
|
0.00% |
0 / 1 |
3.33 | |||
unserialize | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace GrowthExperiments\NewcomerTasks\TaskSuggester; |
4 | |
5 | use GrowthExperiments\NewcomerTasks\Task\TaskSet; |
6 | use GrowthExperiments\NewcomerTasks\Task\TaskSetFilters; |
7 | use GrowthExperiments\NewcomerTasks\TaskSetListener; |
8 | use JobQueueGroup; |
9 | use LogicException; |
10 | use MediaWiki\Json\JsonCodec; |
11 | use MediaWiki\User\UserIdentity; |
12 | use Psr\Log\LoggerAwareInterface; |
13 | use Psr\Log\LoggerAwareTrait; |
14 | use Psr\Log\NullLogger; |
15 | use StatusValue; |
16 | use Wikimedia\ObjectCache\WANObjectCache; |
17 | |
18 | /** |
19 | * A TaskSuggester decorator which uses WANObjectCache to get/set TaskSets. |
20 | */ |
21 | class CacheDecorator implements TaskSuggester, LoggerAwareInterface { |
22 | |
23 | use LoggerAwareTrait; |
24 | |
25 | private const CACHE_VERSION = 4; |
26 | |
27 | /** @var TaskSuggester */ |
28 | private $taskSuggester; |
29 | |
30 | /** @var WANObjectCache */ |
31 | private $cache; |
32 | |
33 | /** @var JobQueueGroup */ |
34 | private $jobQueueGroup; |
35 | |
36 | /** @var TaskSetListener */ |
37 | private $taskSetListener; |
38 | |
39 | /** @var JsonCodec */ |
40 | private $jsonCodec; |
41 | |
42 | /** |
43 | * @param TaskSuggester $taskSuggester |
44 | * @param JobQueueGroup $jobQueueGroup |
45 | * @param WANObjectCache $cache |
46 | * @param TaskSetListener $taskSetListener |
47 | * @param JsonCodec $jsonCodec |
48 | */ |
49 | public function __construct( |
50 | TaskSuggester $taskSuggester, |
51 | JobQueueGroup $jobQueueGroup, |
52 | WANObjectCache $cache, |
53 | TaskSetListener $taskSetListener, |
54 | JsonCodec $jsonCodec |
55 | ) { |
56 | $this->taskSuggester = $taskSuggester; |
57 | $this->cache = $cache; |
58 | $this->jobQueueGroup = $jobQueueGroup; |
59 | $this->logger = new NullLogger(); |
60 | $this->taskSetListener = $taskSetListener; |
61 | $this->jsonCodec = $jsonCodec; |
62 | } |
63 | |
64 | /** @inheritDoc */ |
65 | public function suggest( |
66 | UserIdentity $user, |
67 | TaskSetFilters $taskSetFilters, |
68 | ?int $limit = null, |
69 | ?int $offset = null, |
70 | array $options = [] |
71 | ) { |
72 | $useCache = $options['useCache'] ?? true; |
73 | $resetCache = $options['resetCache'] ?? false; |
74 | $revalidateCache = $options['revalidateCache'] ?? true; |
75 | $excludePageIds = $options['excludePageIds'] ?? []; |
76 | $debug = $options['debug'] ?? false; |
77 | $limit ??= SearchTaskSuggester::DEFAULT_LIMIT; |
78 | |
79 | if ( $debug || $limit > SearchTaskSuggester::DEFAULT_LIMIT ) { |
80 | return $this->taskSuggester->suggest( $user, $taskSetFilters, $limit, $offset, $options ); |
81 | } |
82 | |
83 | $json = $this->cache->getWithSetCallback( |
84 | $this->cache->makeKey( |
85 | 'GrowthExperiments-NewcomerTasks-TaskSet', |
86 | $user->getId() |
87 | ), |
88 | $this->cache::TTL_WEEK, |
89 | function ( $oldValue, &$ttl ) use ( |
90 | $user, $taskSetFilters, $limit, $useCache, $resetCache, $revalidateCache, $excludePageIds |
91 | ) { |
92 | // This callback is always invoked each time getWithSetCallback is called, |
93 | // because we need to examine the contents of the cache (if any) before |
94 | // deciding whether to return those contents or if they need to be regenerated. |
95 | |
96 | if ( $oldValue !== false ) { |
97 | $oldValue = $this->unserialize( $oldValue ); |
98 | } |
99 | |
100 | if ( $useCache |
101 | && !$resetCache |
102 | && $oldValue instanceof TaskSet |
103 | && $oldValue->filtersEqual( $taskSetFilters ) |
104 | && $oldValue->count() |
105 | ) { |
106 | // There's a cached value we can use; we need to randomize and potentially |
107 | // revalidate it. |
108 | // &$ttl needs to be set to UNCACHEABLE so that WANObjectCache |
109 | // doesn't attempt a set() after returning the existing value. |
110 | $ttl = $this->cache::TTL_UNCACHEABLE; |
111 | |
112 | if ( $revalidateCache ) { |
113 | // Filter out cached tasks which have already been done. |
114 | // Filter before limiting, so they can be replaced by other tasks. |
115 | $newValue = $this->taskSuggester->filter( $user, $oldValue ); |
116 | } else { |
117 | $newValue = $oldValue; |
118 | } |
119 | $this->logger->debug( 'CacheDecorator hit', [ |
120 | 'user' => $user->getName(), |
121 | 'taskTypes' => implode( '|', $taskSetFilters->getTaskTypeFilters() ), |
122 | 'topics' => implode( '|', $taskSetFilters->getTopicFilters() ) ?: null, |
123 | 'limit' => $limit, |
124 | 'revalidateCache' => $revalidateCache, |
125 | 'ttl' => $ttl, |
126 | 'cachedTaskCount' => $oldValue->count(), |
127 | 'validTaskCount' => ( $newValue instanceof TaskSet ) ? $newValue->count() : null, |
128 | ] ); |
129 | if ( $newValue instanceof TaskSet ) { |
130 | // Shuffle the contents again (they were shuffled when first placed into the |
131 | // cache) and return only the subset of tasks that the requester asked for. |
132 | $newValue->randomSort(); |
133 | } |
134 | return $this->serialize( $newValue ); |
135 | } |
136 | |
137 | // We don't have a task set, or the taskset filters in the request don't match |
138 | // what is stored in the cache, or using the cached value was explicitly diallowed |
139 | // by the caller. Call the search backend and return the results. |
140 | // N.B. we cache whatever the taskSuggester returns, which could be a StatusValue, |
141 | // so when retrieving items from the cache we need to check the type before assuming |
142 | // we are working with a TaskSet. |
143 | $result = $this->taskSuggester->suggest( |
144 | $user, |
145 | $taskSetFilters, |
146 | SearchTaskSuggester::DEFAULT_LIMIT, |
147 | null, |
148 | [ 'excludePageIds' => $excludePageIds ] |
149 | ); |
150 | if ( $result instanceof TaskSet && $result->count() ) { |
151 | $result->randomSort(); |
152 | if ( $useCache || $resetCache ) { |
153 | // Schedule a job to refresh the taskset before the cache |
154 | // expires. |
155 | try { |
156 | $this->jobQueueGroup->lazyPush( |
157 | new NewcomerTasksCacheRefreshJob( [ |
158 | 'userId' => $user->getId(), |
159 | 'jobReleaseTimestamp' => (int)wfTimestamp() + |
160 | // Process the job the day before the cache expires. |
161 | ( $this->cache::TTL_WEEK - $this->cache::TTL_DAY ), |
162 | ] ) |
163 | ); |
164 | } catch ( \JobQueueError $jobQueueError ) { |
165 | // Ignore jobqueue errors. |
166 | } |
167 | } |
168 | } |
169 | if ( !$useCache && !$resetCache ) { |
170 | $ttl = $this->cache::TTL_UNCACHEABLE; |
171 | } |
172 | $this->logger->debug( 'CacheDecorator miss', [ |
173 | 'user' => $user->getName(), |
174 | 'taskTypes' => implode( '|', $taskSetFilters->getTaskTypeFilters() ), |
175 | 'topics' => implode( '|', $taskSetFilters->getTopicFilters() ) ?: null, |
176 | 'limit' => $limit, |
177 | 'useCache' => $useCache, |
178 | 'taskCount' => ( $result instanceof TaskSet ) ? $result->count() : null, |
179 | ] ); |
180 | return $this->serialize( $result ); |
181 | }, |
182 | // minAsOf is used to reject values below the defined timestamp. By |
183 | // settings minAsOf = INF (PHP's constant for the infinite), we are |
184 | // telling WANObjectCache to always invoke the callback. See |
185 | // callback comment for more on why. |
186 | [ 'minAsOf' => INF, 'version' => self::CACHE_VERSION ] |
187 | ); |
188 | $result = $this->unserialize( $json ); |
189 | |
190 | // Discard extra items when the method was called with $limit < DEFAULT_LIMIT, |
191 | // and run listeners. |
192 | if ( $result instanceof TaskSet && $result->count() ) { |
193 | $result->truncate( $limit ); |
194 | $this->runTaskSetListener( $result ); |
195 | } |
196 | return $result; |
197 | } |
198 | |
199 | /** @inheritDoc */ |
200 | public function filter( UserIdentity $user, TaskSet $taskSet ) { |
201 | return $this->taskSuggester->filter( $user, $taskSet ); |
202 | } |
203 | |
204 | /** |
205 | * |
206 | * @param TaskSet|StatusValue $taskSet |
207 | */ |
208 | private function runTaskSetListener( $taskSet ) { |
209 | if ( $taskSet instanceof StatusValue ) { |
210 | return; |
211 | } |
212 | $this->taskSetListener->run( $taskSet ); |
213 | } |
214 | |
215 | /** |
216 | * Serialize a value for caching. Serializing StatusValue is left to the default caching logic. |
217 | * @param TaskSet|StatusValue $value |
218 | * @return string|StatusValue |
219 | */ |
220 | private function serialize( $value ) { |
221 | if ( $value instanceof TaskSet ) { |
222 | return $this->jsonCodec->serialize( $value ); |
223 | } elseif ( $value instanceof StatusValue ) { |
224 | return $value; |
225 | } else { |
226 | $type = get_debug_type( $value ); |
227 | throw new LogicException( 'Unexpected type ' . $type ); |
228 | } |
229 | } |
230 | |
231 | /** |
232 | * Unserialize a cached value. StatusValue is handled by PHP serialization so we just pass |
233 | * it through here. |
234 | * @param string|StatusValue $value |
235 | * @return TaskSet|StatusValue |
236 | */ |
237 | private function unserialize( $value ) { |
238 | if ( $value instanceof StatusValue ) { |
239 | return $value; |
240 | } else { |
241 | return $this->jsonCodec->unserialize( $value, TaskSet::class ); |
242 | } |
243 | } |
244 | |
245 | } |