Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 296 |
|
0.00% |
0 / 4 |
CRAP | |
0.00% |
0 / 1 |
PopulateVoterListJob | |
0.00% |
0 / 296 |
|
0.00% |
0 / 4 |
992 | |
0.00% |
0 / 1 |
pushJobsForElection | |
0.00% |
0 / 128 |
|
0.00% |
0 / 1 |
182 | |||
__construct | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
run | |
0.00% |
0 / 158 |
|
0.00% |
0 / 1 |
272 | |||
fetchJobKey | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\SecurePoll\Jobs; |
4 | |
5 | use Exception; |
6 | use Job; |
7 | use JobSpecification; |
8 | use MediaWiki\Extension\SecurePoll\Entities\Election; |
9 | use MediaWiki\MediaWikiServices; |
10 | use MediaWiki\SpecialPage\SpecialPage; |
11 | use MediaWiki\User\ActorMigration; |
12 | use MediaWiki\WikiMap\WikiMap; |
13 | use MWExceptionHandler; |
14 | use RuntimeException; |
15 | use Wikimedia\Rdbms\IDatabase; |
16 | |
17 | /** |
18 | * Job for populating the voter list for an election. |
19 | */ |
20 | class PopulateVoterListJob extends Job { |
21 | public static function pushJobsForElection( Election $election ) { |
22 | static $props = [ |
23 | 'need-list', |
24 | 'list_populate', |
25 | 'list_edits-before', |
26 | 'list_edits-before-count', |
27 | 'list_edits-before-date', |
28 | 'list_edits-between', |
29 | 'list_edits-between-count', |
30 | 'list_edits-startdate', |
31 | 'list_edits-enddate', |
32 | 'list_exclude-groups', |
33 | 'list_include-groups', |
34 | ]; |
35 | static $listProps = [ |
36 | 'list_exclude-groups', |
37 | 'list_include-groups', |
38 | ]; |
39 | |
40 | $dbw = $election->context->getDB(); |
41 | $services = MediaWikiServices::getInstance(); |
42 | $lbFactory = $services->getDBLoadBalancerFactory(); |
43 | |
44 | // First, fetch the current config and calculate a hash of it for |
45 | // detecting changes |
46 | $params = [ |
47 | 'electionWiki' => WikiMap::getCurrentWikiId(), |
48 | 'electionId' => $election->getId(), |
49 | 'list_populate' => '0', |
50 | 'need-list' => '', |
51 | 'list_edits-before' => '', |
52 | 'list_edits-between' => '', |
53 | 'list_exclude-groups' => '', |
54 | 'list_include-groups' => '', |
55 | ]; |
56 | |
57 | $res = $dbw->newSelectQueryBuilder() |
58 | ->select( [ |
59 | 'pr_key', |
60 | 'pr_value' |
61 | ] ) |
62 | ->from( 'securepoll_properties' ) |
63 | ->where( [ |
64 | 'pr_entity' => $election->getId(), |
65 | 'pr_key' => $props, |
66 | ] ) |
67 | ->caller( __METHOD__ ) |
68 | ->fetchResultSet(); |
69 | foreach ( $res as $row ) { |
70 | $params[$row->pr_key] = $row->pr_value; |
71 | } |
72 | |
73 | if ( !$params['list_populate'] || $params['need-list'] === '' ) { |
74 | // No need for a job, bail out |
75 | return; |
76 | } |
77 | |
78 | foreach ( $listProps as $prop ) { |
79 | if ( $params[$prop] === '' ) { |
80 | $params[$prop] = []; |
81 | } else { |
82 | $params[$prop] = explode( '|', $params[$prop] ); |
83 | } |
84 | } |
85 | |
86 | ksort( $params ); |
87 | $key = sha1( serialize( $params ) ); |
88 | |
89 | // Now fill in the remaining params |
90 | $params += [ |
91 | 'jobKey' => $key, |
92 | 'nextUserId' => 1, |
93 | ]; |
94 | |
95 | // Get the list of wikis we need jobs on |
96 | $wikis = $election->getProperty( 'wikis' ); |
97 | if ( $wikis ) { |
98 | $wikis = explode( "\n", $wikis ); |
99 | if ( !in_array( WikiMap::getCurrentWikiId(), $wikis ) ) { |
100 | $wikis[] = WikiMap::getCurrentWikiId(); |
101 | } |
102 | } else { |
103 | $wikis = [ WikiMap::getCurrentWikiId() ]; |
104 | } |
105 | |
106 | // Find the max user_id for each wiki, both to know when we're done |
107 | // with that wiki's job and for the special page to calculate progress. |
108 | $maxIds = []; |
109 | $total = 0; |
110 | foreach ( $wikis as $wiki ) { |
111 | $dbr = $lbFactory->getMainLB( $wiki )->getConnection( DB_REPLICA, [], $wiki ); |
112 | $max = $dbr->newSelectQueryBuilder() |
113 | ->select( 'MAX(user_id)' ) |
114 | ->from( 'user' ) |
115 | ->caller( __METHOD__ ) |
116 | ->fetchField(); |
117 | if ( !$max ) { |
118 | $max = 0; |
119 | } |
120 | $maxIds[$wiki] = $max; |
121 | $total += $max; |
122 | |
123 | // reuse connection |
124 | unset( $dbr ); |
125 | } |
126 | |
127 | // Start the jobs! |
128 | $title = SpecialPage::getTitleFor( 'SecurePoll' ); |
129 | $lockKey = "SecurePoll_PopulateVoterListJob-{$election->getId()}"; |
130 | $lockMethod = __METHOD__; |
131 | |
132 | // Clear any transaction snapshots, acquire a mutex, and start a new transaction |
133 | $lbFactory->commitPrimaryChanges( __METHOD__ ); |
134 | $dbw->lock( $lockKey, $lockMethod ); |
135 | $dbw->startAtomic( __METHOD__ ); |
136 | $dbw->onTransactionResolution( |
137 | static function () use ( $dbw, $lockKey, $lockMethod ) { |
138 | $dbw->unlock( $lockKey, $lockMethod ); |
139 | }, |
140 | __METHOD__ |
141 | ); |
142 | |
143 | // If the same job is (supposed to be) already running, don't restart it |
144 | $jobKey = self::fetchJobKey( $dbw, $election->getId() ); |
145 | if ( $params['jobKey'] === $jobKey ) { |
146 | $dbw->endAtomic( __METHOD__ ); |
147 | |
148 | return; |
149 | } |
150 | |
151 | // Record the new job key (which will cause any outdated jobs to |
152 | // abort) and the progress figures. |
153 | $dbw->newReplaceQueryBuilder() |
154 | ->replaceInto( 'securepoll_properties' ) |
155 | ->uniqueIndexFields( [ 'pr_entity', 'pr_key' ] ) |
156 | ->row( [ |
157 | 'pr_entity' => $election->getId(), |
158 | 'pr_key' => 'list_job-key', |
159 | 'pr_value' => $params['jobKey'], |
160 | ] ) |
161 | ->row( [ |
162 | 'pr_entity' => $election->getId(), |
163 | 'pr_key' => 'list_total-count', |
164 | 'pr_value' => $total, |
165 | ] ) |
166 | ->row( [ |
167 | 'pr_entity' => $election->getId(), |
168 | 'pr_key' => 'list_complete-count', |
169 | 'pr_value' => 0, |
170 | ] ) |
171 | ->caller( __METHOD__ ) |
172 | ->execute(); |
173 | |
174 | $jobQueueGroupFactory = $services->getJobQueueGroupFactory(); |
175 | foreach ( $wikis as $wiki ) { |
176 | $params['maxUserId'] = $maxIds[$wiki]; |
177 | $params['thisWiki'] = $wiki; |
178 | |
179 | $jobQueueGroup = $jobQueueGroupFactory->makeJobQueueGroup( $wiki ); |
180 | |
181 | // If possible, delay the job execution in case the user |
182 | // immediately re-edits. |
183 | $jobQueue = $jobQueueGroup->get( 'securePollPopulateVoterList' ); |
184 | if ( $jobQueue->delayedJobsEnabled() ) { |
185 | $params['jobReleaseTimestamp'] = time() + 3600; |
186 | } else { |
187 | unset( $params['jobReleaseTimestamp'] ); |
188 | } |
189 | |
190 | $jobQueueGroup->push( |
191 | new JobSpecification( |
192 | 'securePollPopulateVoterList', $params, [], $title |
193 | ) |
194 | ); |
195 | } |
196 | |
197 | $dbw->endAtomic( __METHOD__ ); |
198 | $lbFactory->commitPrimaryChanges( __METHOD__ ); |
199 | } |
200 | |
201 | public function __construct( $title, $params ) { |
202 | parent::__construct( 'securePollPopulateVoterList', $title, $params ); |
203 | } |
204 | |
205 | public function run() { |
206 | $min = (int)$this->params['nextUserId']; |
207 | $max = min( $min + 500, $this->params['maxUserId'] + 1 ); |
208 | $next = $min; |
209 | |
210 | $services = MediaWikiServices::getInstance(); |
211 | $lbFactory = $services->getDBLoadBalancerFactory(); |
212 | try { |
213 | // Check if the job key changed, and abort if so. |
214 | $dbwElection = $lbFactory->getPrimaryDatabase( $this->params['electionWiki'] ); |
215 | $dbwLocal = $lbFactory->getPrimaryDatabase(); |
216 | $jobKey = self::fetchJobKey( $dbwElection, $this->params['electionId'] ); |
217 | if ( $jobKey !== $this->params['jobKey'] ) { |
218 | return true; |
219 | } |
220 | |
221 | $dbr = $lbFactory->getReplicaDatabase(); |
222 | |
223 | $actorQuery = ActorMigration::newMigration()->getJoin( 'rev_user' ); |
224 | $field = $actorQuery['fields']['rev_user']; |
225 | |
226 | // Construct the list of user_ids in our range that pass the criteria |
227 | $users = null; |
228 | |
229 | // Criterion 1: $NUM edits before $DATE |
230 | if ( $this->params['list_edits-before'] ) { |
231 | $timestamp = $dbr->timestamp( $this->params['list_edits-before-date'] ); |
232 | |
233 | $list = $dbr->newSelectQueryBuilder() |
234 | ->select( $field ) |
235 | ->from( 'revision' ) |
236 | ->tables( $actorQuery['tables'] ) |
237 | ->where( [ |
238 | $dbr->expr( $field, '>=', $min ), |
239 | $dbr->expr( $field, '<', $max ), |
240 | $dbr->expr( 'rev_timestamp', '<', $timestamp ), |
241 | ] ) |
242 | ->groupBy( $field ) |
243 | ->having( 'COUNT(*) >= ' . $dbr->addQuotes( $this->params['list_edits-before-count'] ) ) |
244 | ->caller( __METHOD__ ) |
245 | ->fetchFieldValues(); |
246 | |
247 | // @phan-suppress-next-line PhanSuspiciousValueComparison Same as in next if |
248 | if ( $users === null ) { |
249 | $users = $list; |
250 | } else { |
251 | $users = array_intersect( $users, $list ); |
252 | } |
253 | } |
254 | |
255 | // Criterion 2: $NUM edits bewteen $DATE1 and $DATE2 |
256 | if ( $this->params['list_edits-between'] ) { |
257 | $timestamp1 = $dbr->timestamp( $this->params['list_edits-startdate'] ); |
258 | $timestamp2 = $dbr->timestamp( $this->params['list_edits-enddate'] ); |
259 | |
260 | $list = $dbr->newSelectQueryBuilder() |
261 | ->select( $field ) |
262 | ->from( 'revision' ) |
263 | ->tables( $actorQuery['tables'] ) |
264 | ->where( [ |
265 | $dbr->expr( $field, '>=', $min ), |
266 | $dbr->expr( $field, '<', $max ), |
267 | $dbr->expr( 'rev_timestamp', '>=', $timestamp1 ), |
268 | $dbr->expr( 'rev_timestamp', '<', $timestamp2 ), |
269 | ] ) |
270 | ->groupBy( $field ) |
271 | ->having( 'COUNT(*) >= ' . $dbr->addQuotes( $this->params['list_edits-between-count'] ) ) |
272 | ->caller( __METHOD__ ) |
273 | ->fetchFieldValues(); |
274 | |
275 | if ( $users === null ) { |
276 | $users = $list; |
277 | } else { |
278 | $users = array_intersect( $users, $list ); |
279 | } |
280 | } |
281 | |
282 | // Criterion 3: Not in a listed group |
283 | if ( $this->params['list_exclude-groups'] ) { |
284 | $list = $dbr->newSelectQueryBuilder() |
285 | ->select( 'user_id' ) |
286 | ->from( 'user' ) |
287 | ->leftJoin( 'user_groups', null, [ |
288 | 'ug_user = user_id', |
289 | 'ug_group' => $this->params['list_exclude-groups'], |
290 | $dbr->expr( 'ug_expiry', '=', null )->or( 'ug_expiry', '>=', $dbr->timestamp() ), |
291 | ] ) |
292 | ->where( [ |
293 | $dbr->expr( 'user_id', '>=', $min ), |
294 | $dbr->expr( 'user_id', '<', $max ), |
295 | 'ug_user' => null, |
296 | ] ) |
297 | ->caller( __METHOD__ ) |
298 | ->fetchFieldValues(); |
299 | |
300 | if ( $users === null ) { |
301 | $users = $list; |
302 | } else { |
303 | $users = array_intersect( $users, $list ); |
304 | } |
305 | } |
306 | |
307 | // Criterion 4: In a listed group (overrides 1-3) |
308 | if ( $this->params['list_include-groups'] ) { |
309 | $list = $dbr->newSelectQueryBuilder() |
310 | ->select( 'ug_user' ) |
311 | ->from( 'user_groups' ) |
312 | ->where( [ |
313 | $dbr->expr( 'ug_user', '>=', $min ), |
314 | $dbr->expr( 'ug_user', '<', $max ), |
315 | 'ug_group' => $this->params['list_include-groups'], |
316 | $dbr->expr( 'ug_expiry', '=', null )->or( 'ug_expiry', '>=', $dbr->timestamp() ), |
317 | ] ) |
318 | ->caller( __METHOD__ ) |
319 | ->fetchFieldValues(); |
320 | |
321 | if ( $users === null ) { |
322 | $users = $list; |
323 | } else { |
324 | $users = array_values( array_unique( array_merge( $users, $list ) ) ); |
325 | } |
326 | } |
327 | |
328 | $ins = []; |
329 | foreach ( $users as $user_id ) { |
330 | $ins[] = [ |
331 | 'li_name' => $this->params['need-list'], |
332 | 'li_member' => $user_id, |
333 | ]; |
334 | } |
335 | |
336 | // Flush any prior REPEATABLE-READ snapshots so the locking below works |
337 | $lbFactory->commitPrimaryChanges( __METHOD__ ); |
338 | |
339 | // Check again that the jobKey didn't change, holding a lock this time... |
340 | $lockKey = "SecurePoll_PopulateVoterListJob-{$this->params['electionId']}"; |
341 | $lockMethod = __METHOD__; |
342 | if ( !$dbwElection->lock( $lockKey, $lockMethod, 30 ) ) { |
343 | throw new RuntimeException( "Could not acquire '$lockKey'." ); |
344 | } |
345 | $dbwElection->startAtomic( __METHOD__ ); |
346 | $dbwElection->onTransactionResolution( |
347 | static function () use ( $dbwElection, $lockKey, $lockMethod ) { |
348 | $dbwElection->unlock( $lockKey, $lockMethod ); |
349 | }, |
350 | __METHOD__ |
351 | ); |
352 | $dbwLocal->startAtomic( __METHOD__ ); |
353 | |
354 | $jobKey = self::fetchJobKey( $dbwElection, $this->params['electionId'] ); |
355 | if ( $jobKey === $this->params['jobKey'] ) { |
356 | $dbwLocal->newDeleteQueryBuilder() |
357 | ->deleteFrom( 'securepoll_lists' ) |
358 | ->where( [ |
359 | 'li_name' => $this->params['need-list'], |
360 | $dbwLocal->expr( 'li_member', '>=', $min ), |
361 | $dbwLocal->expr( 'li_member', '<', $max ), |
362 | ] ) |
363 | ->caller( __METHOD__ ) |
364 | ->execute(); |
365 | if ( $ins ) { |
366 | $dbwLocal->newInsertQueryBuilder() |
367 | ->insertInto( 'securepoll_lists' ) |
368 | ->rows( $ins ) |
369 | ->caller( __METHOD__ ) |
370 | ->execute(); |
371 | } |
372 | |
373 | $count = $dbwElection->newSelectQueryBuilder() |
374 | ->select( 'pr_value' ) |
375 | ->from( 'securepoll_properties' ) |
376 | ->where( [ |
377 | 'pr_entity' => $this->params['electionId'], |
378 | 'pr_key' => 'list_complete-count', |
379 | ] ) |
380 | ->caller( __METHOD__ ) |
381 | ->fetchField(); |
382 | $dbwElection->newUpdateQueryBuilder() |
383 | ->update( 'securepoll_properties' ) |
384 | ->set( [ |
385 | 'pr_value' => $count + $max - $min, |
386 | ] ) |
387 | ->where( [ |
388 | 'pr_entity' => $this->params['electionId'], |
389 | 'pr_key' => 'list_complete-count', |
390 | ] ) |
391 | ->caller( __METHOD__ ) |
392 | ->execute(); |
393 | } |
394 | |
395 | $dbwLocal->endAtomic( __METHOD__ ); |
396 | $dbwElection->endAtomic( __METHOD__ ); |
397 | // Commit now so the jobs pushed below see any changes from above |
398 | $lbFactory->commitPrimaryChanges( __METHOD__ ); |
399 | |
400 | $next = $max; |
401 | } catch ( Exception $exception ) { |
402 | MWExceptionHandler::rollbackPrimaryChangesAndLog( $exception ); |
403 | } |
404 | |
405 | // Schedule the next run of this job, if necessary |
406 | if ( $next <= $this->params['maxUserId'] ) { |
407 | $params = $this->params; |
408 | $params['nextUserId'] = $next; |
409 | unset( $params['jobReleaseTimestamp'] ); |
410 | |
411 | $services->getJobQueueGroup()->push( |
412 | new JobSpecification( |
413 | 'securePollPopulateVoterList', $params, [], $this->title |
414 | ) |
415 | ); |
416 | } |
417 | |
418 | return true; |
419 | } |
420 | |
421 | private static function fetchJobKey( IDatabase $db, $electionId ) { |
422 | return $db->newSelectQueryBuilder() |
423 | ->select( 'pr_value' ) |
424 | ->from( 'securepoll_properties' ) |
425 | ->where( [ |
426 | 'pr_entity' => $electionId, |
427 | 'pr_key' => 'list_job-key', |
428 | ] ) |
429 | ->caller( __METHOD__ ) |
430 | ->fetchField(); |
431 | } |
432 | } |