Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 301
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 / 301
0.00% covered (danger)
0.00%
0 / 4
1260
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 / 163
0.00% covered (danger)
0.00%
0 / 1
420
 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\SpecialPage\SpecialPage;
11use MediaWiki\User\ActorMigration;
12use MediaWiki\WikiMap\WikiMap;
13use MWExceptionHandler;
14use RuntimeException;
15use Wikimedia\Rdbms\IDatabase;
16
17/**
18 * Job for populating the voter list for an election.
19 */
20class 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            global $wgDisableUserGroupExpiry;
284            if ( $this->params['list_exclude-groups'] ) {
285                $list = $dbr->newSelectQueryBuilder()
286                    ->select( 'user_id' )
287                    ->from( 'user' )
288                    ->leftJoin( 'user_groups', null, [
289                        'ug_user = user_id',
290                        'ug_group' => $this->params['list_exclude-groups'],
291                        ( !isset( $wgDisableUserGroupExpiry ) || $wgDisableUserGroupExpiry )
292                            ? '1'
293                            : $dbr->expr( 'ug_expiry', '=', null )->or( 'ug_expiry', '>=', $dbr->timestamp() ),
294                    ] )
295                    ->where( [
296                        $dbr->expr( 'user_id', '>=', $min ),
297                        $dbr->expr( 'user_id', '<', $max ),
298                        'ug_user' => null,
299                    ] )
300                    ->caller( __METHOD__ )
301                    ->fetchFieldValues();
302
303                if ( $users === null ) {
304                    $users = $list;
305                } else {
306                    $users = array_intersect( $users, $list );
307                }
308            }
309
310            // Criterion 4: In a listed group (overrides 1-3)
311            if ( $this->params['list_include-groups'] ) {
312                $list = $dbr->newSelectQueryBuilder()
313                    ->select( 'ug_user' )
314                    ->from( 'user_groups' )
315                    ->where( [
316                        $dbr->expr( 'ug_user', '>=', $min ),
317                        $dbr->expr( 'ug_user', '<', $max ),
318                        'ug_group' => $this->params['list_include-groups'],
319                        ( !isset( $wgDisableUserGroupExpiry ) || $wgDisableUserGroupExpiry )
320                            ? '1'
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    private static function fetchJobKey( IDatabase $db, $electionId ) {
427        return $db->newSelectQueryBuilder()
428            ->select( 'pr_value' )
429            ->from( 'securepoll_properties' )
430            ->where( [
431                'pr_entity' => $electionId,
432                'pr_key' => 'list_job-key',
433            ] )
434            ->caller( __METHOD__ )
435            ->fetchField();
436    }
437}