Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.84% covered (success)
91.84%
90 / 98
33.33% covered (danger)
33.33%
2 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
CacheDecorator
91.84% covered (success)
91.84%
90 / 98
33.33% covered (danger)
33.33%
2 / 6
33.59
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
 suggest
94.94% covered (success)
94.94%
75 / 79
0.00% covered (danger)
0.00%
0 / 1
24.07
 filter
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 runTaskSetListener
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 serialize
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 unserialize
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace GrowthExperiments\NewcomerTasks\TaskSuggester;
4
5use GrowthExperiments\NewcomerTasks\Task\TaskSet;
6use GrowthExperiments\NewcomerTasks\Task\TaskSetFilters;
7use GrowthExperiments\NewcomerTasks\TaskSetListener;
8use JobQueueGroup;
9use LogicException;
10use MediaWiki\Json\JsonCodec;
11use MediaWiki\User\UserIdentity;
12use Psr\Log\LoggerAwareInterface;
13use Psr\Log\LoggerAwareTrait;
14use Psr\Log\NullLogger;
15use StatusValue;
16use Wikimedia\ObjectCache\WANObjectCache;
17
18/**
19 * A TaskSuggester decorator which uses WANObjectCache to get/set TaskSets.
20 */
21class 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}