1
<?php
2
/**
3
 * Mahara: Electronic portfolio, weblog, resume builder and social networking
4
 * Copyright (C) 2006-2009 Catalyst IT Ltd and others; see:
5
 *                         http://wiki.mahara.org/Contributors
6
 *
7
 * This program is free software: you can redistribute it and/or modify
8
 * it under the terms of the GNU General Public License as published by
9
 * the Free Software Foundation, either version 3 of the License, or
10
 * (at your option) any later version.
11
 *
12
 * This program is distributed in the hope that it will be useful,
13
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15
 * GNU General Public License for more details.
16
 *
17
 * You should have received a copy of the GNU General Public License
18
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
 *
20
 * @package    mahara
21
 * @subpackage core
22
 * @author     Catalyst IT Ltd
23
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL
24
 * @copyright  (C) 2006-2009 Catalyst IT Ltd http://catalyst.net.nz
25
 *
26
 */
27
28
define('INTERNAL', 1);
29
define('PUBLIC', 1);
30
define('CRON', 1);
31
define('TITLE', '');
32
33
require(dirname(dirname(__FILE__)).'/init.php');
34
require_once(get_config('docroot') . 'artefact/lib.php');
35
require_once(get_config('docroot') . 'import/lib.php');
36
require_once(get_config('docroot') . 'export/lib.php');
37
require_once(get_config('docroot') . 'lib/activity.php');
38
require_once(get_config('docroot') . 'lib/file.php');
39
40
// This is here for debugging purposes, it allows us to fake the time to test
41
// cron behaviour
42
$realstart = time();
43
$fake = isset($argv[1]);
44
$start = $fake ? strtotime($argv[1]) : $realstart;
45
46
log_info('---------- cron running ' . date('r', $start) . ' ----------');
47
raise_memory_limit('128M');
48
49
if (!is_writable(get_config('dataroot'))) {
50
    log_warn("Unable to write to dataroot directory.");
51
}
52
53
// for each plugin type
54
foreach (plugin_types() as $plugintype) {
55
56
    $table = $plugintype . '_cron';
57
58
    // get list of cron jobs to run for this plugin type
59
    $now = $fake ? (time() - ($realstart - $start)) : time();
60
    $jobs = get_records_select_array(
61
        $table,
62
        'nextrun < ? OR nextrun IS NULL',
63
        array(db_format_timestamp($now)),
64
        '',
65
        'plugin,callfunction,minute,hour,day,month,dayofweek,' . db_format_tsfield('nextrun')
66
    );
67
68
    if ($jobs) {
69
        // for each cron entry
70
        foreach ($jobs as $job) {
71
            if (!cron_lock($job, $start, $plugintype)) {
72
                continue;
73
            }
74
75
            // If some other cron instance ran the job while we were messing around,
76
            // skip it.
77
            $nextrun = get_field_sql('
78
                SELECT ' . db_format_tsfield('nextrun') . '
79
                FROM {' . $table . '}
80
                WHERE plugin = ? AND callfunction = ?',
81
                array($job->plugin, $job->callfunction)
82
            );
83
            if ($nextrun != $job->nextrun) {
84
                log_info("Too late to run $plugintype $job->plugin $job->callfunction; skipping.");
85
                cron_free($job, $start, $plugintype);
86
                continue;
87
            }
88
89
            $classname = generate_class_name($plugintype, $job->plugin);
90
91
            log_info("Running $classname::" . $job->callfunction);
92
93
            safe_require($plugintype, $job->plugin, 'lib.php', 'require_once');
94
95
            try {
96
                call_static_method($classname, $job->callfunction);
97
            }
98
            catch (Exception $e) {
99
                log_message($e->getMessage(), LOG_LEVEL_WARN, true, true, $e->getFile(), $e->getLine(), $e->getTrace());
100
                $output = $e instanceof MaharaException ? $e->render_exception() : $e->getMessage();
101
                echo "$output\n";
102
                // Don't call handle_exception; try to update next run time and free the lock
103
            }
104
105
            $nextrun = cron_next_run_time($start, (array)$job);
106
107
            // update next run time
108
            set_field(
109
                $plugintype . '_cron',
110
                'nextrun',
111
                db_format_timestamp($nextrun), 
112
                'plugin',
113
                $job->plugin,
114
                'callfunction',
115
                $job->callfunction
116
            );
117
118
            cron_free($job, $start, $plugintype);
119
            $now = $fake ? (time() - ($realstart - $start)) : time();
120
        }
121
    }
122
}
123
124
// and now the core ones (much simpler)
125
$now = $fake ? (time() - ($realstart - $start)) : time();
126
$jobs = get_records_select_array(
127
    'cron',
128
    'nextrun < ? OR nextrun IS NULL',
129
    array(db_format_timestamp($now)),
130
    '',
131
    'id,callfunction,minute,hour,day,month,dayofweek,' . db_format_tsfield('nextrun')
132
);
133
if ($jobs) {
134
    foreach ($jobs as $job) {
135
        if (!cron_lock($job, $start)) {
136
            continue;
137
        }
138
139
        // If some other cron instance ran the job while we were messing around,
140
        // skip it.
141
        $nextrun = get_field_sql('
142
            SELECT ' . db_format_tsfield('nextrun') . '
143
            FROM {cron}
144
            WHERE id = ?',
145
            array($job->id)
146
        );
147
        if ($nextrun != $job->nextrun) {
148
            log_info("Too late to run core $job->callfunction; skipping.");
149
            cron_free($job, $start);
150
            continue;
151
        }
152
153
        log_info("Running core cron " . $job->callfunction);
154
155
        $function = $job->callfunction;
156
157
        try {
158
            $function();
159
        }
160
        catch (Exception $e) {
161
            log_message($e->getMessage(), LOG_LEVEL_WARN, true, true, $e->getFile(), $e->getLine(), $e->getTrace());
162
            $output = $e instanceof MaharaException ? $e->render_exception() : $e->getMessage();
163
            echo "$output\n";
164
            // Don't call handle_exception; try to update next run time and free the lock
165
        }
166
        
167
        $nextrun = cron_next_run_time($start, (array)$job);
168
        
169
        // update next run time
170
        set_field('cron', 'nextrun', db_format_timestamp($nextrun), 'id', $job->id);
171
172
        cron_free($job, $start);
173
        $now = $fake ? (time() - ($realstart - $start)) : time();
174
    }
175
}
176
177
$finish = time();
178
179
//Time relative to fake cron time
180
if (isset($argv[1])) {
181
    $diff = $realstart - $start;
182
    $finish = $finish - $diff;
183
}
184
log_info('---------- cron finished ' . date('r', $finish) . ' ----------');
185
186
function cron_next_run_time($lastrun, $job) {
187
    $run_date = getdate($lastrun);
188
189
    // we don't care about seconds for cron
190
    $run_date['seconds'] = 0;
191
192
    // assert valid month
193
    if (!cron_valid_month($job, $run_date)) {
194
        cron_next_month($job, $run_date);
195
196
        cron_first_day($job, $run_date);
197
        cron_first_hour($job, $run_date);
198
        cron_first_minute($job, $run_date);
199
200
        return datearray_to_timestamp($run_date);
201
    }
202
203
    // assert valid day
204
    if (!cron_valid_day($job, $run_date)) {
205
        cron_next_day($job, $run_date);
206
207
        cron_first_hour($job, $run_date);
208
        cron_first_minute($job, $run_date);
209
210
        return datearray_to_timestamp($run_date);
211
    }
212
213
    // assert valid hour
214
    if (!cron_valid_hour($job, $run_date)) {
215
        cron_next_hour($job, $run_date);
216
217
        cron_first_minute($job, $run_date);
218
219
        return datearray_to_timestamp($run_date);
220
    }
221
222
    cron_next_minute($job, $run_date);
223
224
    return datearray_to_timestamp($run_date);
225
226
}
227
228
function datearray_to_timestamp($date_array) {
229
    return mktime(
230
        $date_array['hours'],
231
        $date_array['minutes'],
232
        $date_array['seconds'],
233
        $date_array['mon'],
234
        $date_array['mday'],
235
        $date_array['year']
236
    );
237
}
238
239
/**
240
  * Determine next value for a single cron field
241
  *
242
  * This function is designed to parse a cron field specification and then
243
  * given a current value of the field, determine the next value of that field.
244
  * 
245
  * @param $fieldspec Cron field specification (e.g. "3,7,20-30,40-50/2")
246
  * @param $currentvalue Current value of this field
247
  * @param $ceiling Maximum value this field can take (e.g. for minutes this would be set to 60)
248
  * @param &$propagate Determines (a) if this value can remain at current value
249
  * or not, (b) returns true if this field wrapped to zero to find the next
250
  * value.
251
  * @param &$steps Returns the number of steps that were taken to get from currentvalue to the next value.
252
  * @param $allowzero Is this field allowed to be 0?
253
  * @param $ceil_zero_same If the fieldspec has a number equivalent of ceiling in it, is that the same as 0?
254
  *
255
  * @return The next value for this field
256
  */
257
function cron_next_field_value($fieldspec, $currentvalue, $ceiling, &$propagate, &$steps, $allowzero = true, $ceil_zero_same = false) {
258
    $timeslices = array_pad(Array(), $ceiling, false);
259
260
    foreach ( explode(',',$fieldspec) as $spec ) {
261
		if (preg_match("~^(\\*|([0-9]{1,2})(-([0-9]{1,2}))?)(/([0-9]{1,2}))?$~",$spec,$matches)) {
262
            if ($matches[1] == '*') {
263
                $from = 0;
264
                $to   = $ceiling - 1;
265
            }
266
            else {
267
                $from = $matches[2];
268
                if (isset($matches[4])) {
269
                    $to   = $matches[4];
270
                }
271
                else {
272
                    $to   = $from;
273
                }
274
            }
275
            if (isset($matches[6])) {
276
                $step = $matches[6];
277
            }
278
            else {
279
                $step = 1;
280
            }
281
282
            for ($i = $from; $i <= $to; $i += $step) {
283
                if ($ceil_zero_same && $i == $ceiling) {
284
                    $timeslices[0] = true;
285
                }
286
                else {
287
                    $timeslices[$i] = true;
288
                }
289
            }
290
291
        }
292
    }
293
294
    // the previous field wrapped, this one HAS to change
295
    if ($propagate) {
296
        $currentvalue++;
297
        $steps = 1;
298
    }
299
    else {
300
        $steps = 0;
301
    }
302
303
    for ($currentvalue; $currentvalue < $ceiling; $currentvalue++, $steps++) {
304
        if ($timeslices[$currentvalue]) {
305
            break;
306
        }
307
    }
308
309
    // if we found a value
310
    if ($currentvalue != $ceiling) {
311
        $propagate = 0;
312
        return $currentvalue;
313
    }
314
315
    for ($currentvalue= ($allowzero ? 0 : 1); $currentvalue < $ceiling; $currentvalue++, $steps++) {
316
        if ($timeslices[$currentvalue]) {
317
            break;
318
        }
319
    }
320
321
    $propagate = 1;
322
    return $currentvalue;
323
}
324
325
function cron_day_of_week($date_array) {
326
    return date('w', mktime(0, 0, 0, $date_array['mon'], $date_array['mday'], $date_array['year']));
327
}
328
329
// --------------------------------------------------------
330
331
function cron_valid_month($job, $run_date) {
332
    $propagate = 0;
333
    cron_next_field_value($job['month'], $run_date['mon'], 13, $propagate, $steps, false);
334
335
    if ($steps) {
336
        return false;
337
    }
338
    else {
339
        return true;
340
    }
341
}
342
343
function cron_valid_day($job, $run_date) {
344
    $propagate = 0;
345
    cron_next_field_value($job['day'], $run_date['mday'], 32, $propagate, $dayofmonth_steps, false);
346
347
    $propagate = 0;
348
    cron_next_field_value($job['dayofweek'], cron_day_of_week($run_date), 7, $propagate, $dayofweek_steps, true);
349
350
    if ($job['dayofweek'] == '*') {
351
        return ($dayofmonth_steps ? false : true);
352
    }
353
    else if ($job['day'] == '*') {
354
        return ($dayofweek_steps ? false : true);
355
    }
356
    else {
357
        if ($dayofmonth_steps && $dayofweek_steps) {
358
            return false;
359
        }
360
        else {
361
            return true;
362
        }
363
    }
364
}
365
366
function cron_valid_hour($job, $run_date) {
367
    $propagate = 0;
368
    cron_next_field_value($job['hour'], $run_date['hours'], 24, $propagate, $steps);
369
370
    if ($steps) {
371
        return false;
372
    }
373
    else {
374
        return true;
375
    }
376
}
377
378
function cron_valid_minute($job, $run_date) {
379
    $propagate = 0;
380
    cron_next_field_value($job['minute'], $run_date['minutes'], 60, $propagate, $steps);
381
382
    if ($steps) {
383
        return false;
384
    }
385
    else {
386
        return true;
387
    }
388
}
389
390
function cron_next_month($job, &$run_date) {
391
    $propagate = 1;
392
    $run_date['mon'] = cron_next_field_value($job['month'], $run_date['mon'], 13, $propagate, $steps, false);
393
394
    if ($propagate) {
395
        $run_date['year']++;
396
    }
397
}
398
399
function cron_next_day($job, &$run_date) {
400
    // work out which has less steps
401
    $propagate = 1;
402
    cron_next_field_value($job['day'], $run_date['mday'], 32, $propagate, $month_steps, false);
403
    $propagate = 1;
404
    cron_next_field_value($job['dayofweek'], cron_day_of_week($run_date), 7, $propagate, $week_steps, true, true);
405
406
    if ($job['dayofweek'] == '*') {
407
        $run_date['mday'] += $month_steps;
408
    }
409
    else if ($job['day'] == '*') {
410
        $run_date['mday'] += $week_steps;
411
    }
412
    else if ($month_steps < $week_steps) {
413
        $run_date['mday'] += $month_steps;
414
    }
415
    else {
416
        $run_date['mday'] += $week_steps;
417
    }
418
419
    // if the day is outside the range of this month, try again from 0
420
    if ($run_date['mday'] > date('t', mktime(0, 0, 0, $run_date['mon'], 1, $run_date['year']))) {
421
        cron_next_month($job, $run_date);
422
423
        cron_first_day($job, $run_date);
424
    }
425
}
426
427
function cron_next_hour($job, &$run_date) {
428
    $propagate = 1;
429
    $run_date['hours'] = cron_next_field_value($job['hour'], $run_date['hours'], 24, $propagate, $steps);
430
431
    if ($propagate) {
432
        cron_next_day($job, $run_date);
433
    }
434
}
435
436
function cron_next_minute($job, &$run_date) {
437
    $propagate = 1;
438
    $run_date['minutes'] = cron_next_field_value($job['minute'], $run_date['minutes'], 60, $propagate, $steps);
439
440
    if ($propagate) {
441
        cron_next_hour($job, $run_date);
442
    }
443
}
444
445
function cron_first_day($job, &$run_date) {
446
    $propagate = 0;
447
    cron_next_field_value($job['day'], 1, 32, $propagate, $month_steps, false);
448
449
    $propagate = 0;
450
    $run_date['mday'] = 1;
451
    cron_next_field_value($job['dayofweek'], cron_day_of_week($run_date), 7, $propagate, $week_steps, true, true);
452
453
    if ($job['dayofweek'] == '*') {
454
        $run_date['mday'] += $month_steps;
455
    }
456
    else if ($job['day'] == '*') {
457
        $run_date['mday'] += $week_steps;
458
    }
459
    else if ($month_steps < $week_steps) {
460
        $run_date['mday'] += $month_steps;
461
    }
462
    else {
463
        log_debug('using week_steps: ' . $week_steps);
464
        $run_date['mday'] += $week_steps;
465
    }
466
467
    log_debug('    setting mday to ' . $run_date['mday']);
468
}
469
470
function cron_first_hour($job, &$run_date) {
471
    $propagate = 0;
472
    $run_date['hours'] = cron_next_field_value($job['hour'], 0, 24, $propagate, $steps);
473
}
474
475
function cron_first_minute($job, &$run_date) {
476
    $propagate = 0;
477
    $run_date['minutes'] = cron_next_field_value($job['minute'], 0, 60, $propagate, $steps);
478
}
479
480
function cron_job_id($job, $plugintype) {
481
    return $plugintype . (!empty($job->plugin) ? "_$job->plugin" : '') . '_' . $job->callfunction;
482
}
483
484
function cron_lock($job, $start, $plugintype='core') {
485
    global $DB_IGNORE_SQL_EXCEPTIONS;
486
487
    $jobname = cron_job_id($job, $plugintype);
488
    $lockname = '_cron_lock_' . $jobname;
489
490
    // The rationale for catching the SQLException on this insert is to
491
    // ensure that if two crons run simultaneously, they may both fail the
492
    // get_field and thus both try the insert. We try the get_field first
493
    // to try and limit the number of exceptions that we catch and throw.
494
    if (!$started = get_field('config', 'value', 'field', $lockname)) {
495
        try {
496
            $DB_IGNORE_SQL_EXCEPTIONS = true;
497
            insert_record('config', (object) array('field' => $lockname, 'value' => $start));
498
            $DB_IGNORE_SQL_EXCEPTIONS = false;
499
            return true;
500
        }
501
        catch (SQLException $e) {
502
            $DB_IGNORE_SQL_EXCEPTIONS = false;
503
            $started = get_field('config', 'value', 'field', $lockname);
504
        }
505
    }
506
507
    $strstart = $started ? date('r', $started) : '';
508
    $msg = "long-running cron job $jobname ($strstart).";
509
510
    // If it's been going for more than 24 hours, remove the lock
511
    if ($started && $started < $start - 60*60*24) {
512
        log_info('Removing lock record for ' . $msg);
513
        cron_free($job, $started, $plugintype);
514
        return false;
515
    }
516
517
    log_info('Skipping ' . $msg);
518
    return false;
519
}
520
521
function cron_free($job, $start, $plugintype='core') {
522
    delete_records('config', 'field', '_cron_lock_' . cron_job_id($job, $plugintype), 'value', $start);
523
}