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\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            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}