Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 296
0.00% covered (danger)
0.00%
0 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
PopulateVoterListJob
0.00% covered (danger)
0.00%
0 / 296
0.00% covered (danger)
0.00%
0 / 4
992
0.00% covered (danger)
0.00%
0 / 1
 pushJobsForElection
0.00% covered (danger)
0.00%
0 / 128
0.00% covered (danger)
0.00%
0 / 1
182
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 run
0.00% covered (danger)
0.00%
0 / 158
0.00% covered (danger)
0.00%
0 / 1
272
 fetchJobKey
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Extension\SecurePoll\Jobs;
4
5use Exception;
6use Job;
7use JobSpecification;
8use MediaWiki\Extension\SecurePoll\Entities\Election;
9use MediaWiki\MediaWikiServices;
10use MediaWiki\Page\PageReference;
11use MediaWiki\SpecialPage\SpecialPage;
12use MediaWiki\User\ActorMigration;
13use MediaWiki\WikiMap\WikiMap;
14use MWExceptionHandler;
15use RuntimeException;
16use Wikimedia\Rdbms\IDatabase;
17
18/**
19 * Job for populating the voter list for an election.
20 */
21class 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}