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