-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtable-cron.php
More file actions
1091 lines (912 loc) · 36.2 KB
/
Copy pathtable-cron.php
File metadata and controls
1091 lines (912 loc) · 36.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<?php
/*
* Plugin Name: Table Cron
* Description: Replaces WordPress's option-based cron storage with a dedicated database table for proper task tracking.
* Version: 1.0.0
* Author: liedekef
* License: GPL-2.0
* Text Domain: table-cron
*
* Intercepts all cron operations via the official pre_* filters and stores
* individual scheduled events in a dedicated database table instead of a
* single serialized option row. Fully backwards-compatible with plugins that
* use the standard wp_schedule_event() / wp_schedule_single_event() API.
*
* On activation the existing cron option is migrated into the table.
* On deactivation the table contents are migrated back and the table is dropped.
*/
defined( 'ABSPATH' ) || exit;
define( 'TABLE_CRON_VERSION', '1.0.0' );
define( 'TABLE_CRON_TABLE', 'cron' );
/* =========================================================================
* Table Creation / Migration
* =========================================================================
*/
/*
* Create the cron tasks table and migrate any existing option-based events.
*
* Called on plugin activation and during the fallback schema check.
*/
function table_cron_create_table() {
global $wpdb;
$table = $wpdb->prefix . TABLE_CRON_TABLE;
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
hook VARCHAR(255) NOT NULL DEFAULT '',
timestamp BIGINT UNSIGNED NOT NULL DEFAULT 0,
schedule VARCHAR(255) DEFAULT NULL,
interval_sec INT UNSIGNED DEFAULT NULL,
args LONGTEXT,
args_hash VARCHAR(32) NOT NULL DEFAULT '',
last_run BIGINT UNSIGNED DEFAULT NULL,
PRIMARY KEY (id),
KEY idx_hook_args (hook, args_hash),
KEY idx_scheduled (timestamp),
UNIQUE KEY uniq_hook_args_ts (hook(191), args_hash, timestamp)
) {$charset};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
$created = ( $wpdb->get_var(
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
) === $table );
if ( $created ) {
// set the instanciated table_ready var to true
Table_Cron::instance()->set_table_ready( true );
table_cron_migrate_from_option();
update_option( 'table_cron_version', TABLE_CRON_VERSION );
}
return $created;
}
/*
* Migrate events from the legacy wp_options `cron` row into the table.
*
* Runs silently; if the table already has rows the option is left untouched
* so that a re-activation does not create duplicates.
*/
function table_cron_migrate_from_option() {
global $wpdb;
$table = $wpdb->prefix . TABLE_CRON_TABLE;
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
if ( $count > 0 ) {
return;
}
// Read the raw option value directly from wp_options to bypass
// our own pre_option_cron filter, which would return the (empty)
// table contents instead of the legacy data we need to migrate.
$raw = $wpdb->get_var(
$wpdb->prepare(
"SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
'cron'
)
);
$cron = maybe_unserialize( $raw );
if ( ! is_array( $cron ) ) {
return;
}
unset( $cron['version'] );
$rows = array();
foreach ( $cron as $timestamp => $hooks ) {
if ( ! is_array( $hooks ) ) {
continue;
}
foreach ( $hooks as $hook => $events ) {
if ( ! is_array( $events ) ) {
continue;
}
foreach ( $events as $hash => $data ) {
$args = isset( $data['args'] ) ? $data['args'] : array();
$schedule = isset( $data['schedule'] ) ? $data['schedule'] : null;
$interval = isset( $data['interval'] ) ? (int) $data['interval'] : null;
$args_hash = is_string( $hash ) && 32 === strlen( $hash ) ? $hash : md5( serialize( $args ) );
$rows[] = array(
'hook' => $hook,
'timestamp' => (int) $timestamp,
'schedule' => $schedule,
'interval_sec' => $interval,
'args' => maybe_serialize( $args ),
'args_hash' => $args_hash,
);
}
}
}
if ( empty( $rows ) ) {
return;
}
foreach ( $rows as $row ) {
$wpdb->insert( $table, $row );
}
// Leave a minimal placeholder in the legacy `cron` option so WP core
// does not complain about a missing option. We can't call update_option()
// here since that would go through our own pre_update_option_cron filter
// and try to sync back into the table we're still migrating into.
$wpdb->update(
$wpdb->options,
array( 'option_value' => serialize( array( 'version' => 2 ) ) ),
array( 'option_name' => 'cron' ),
array( '%s' ), array( '%s' )
);
}
/*
* Verify the table exists on every admin_init / init and create it if needed.
* Currently commented out, should not be needed
* This covers edge cases where the table was dropped or the activation hook
* did not fire (e.g. manual file copy).
*/
/*
function table_cron_schema_check() {
global $wpdb;
$table = $wpdb->prefix . TABLE_CRON_TABLE;
if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table ) {
return;
}
table_cron_create_table();
}
add_action( 'init', 'table_cron_schema_check' );
*/
/* =========================================================================
* Cron Interception — the core of the replacement.
*
* Each handler returns a non-null value through the pre_* filter to
* short-circuit the default option-based code path.
* =========================================================================
*/
class Table_Cron {
/* @var Table_Cron|null */
private static $instance = null;
/* @var string Fully-qualified table name. */
private $table;
/* @var int|null Cached GMT timestamp for the current request. */
private $gmt_time;
/* @var bool|null Cached table-existence check. */
private $table_ready;
public static function instance() {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
private function __construct() {
global $wpdb;
$this->table = $wpdb->prefix . TABLE_CRON_TABLE;
// Schedule event (single + recurring).
add_filter( 'pre_schedule_event', array( $this, 'filter_schedule_event' ), 5, 3 );
// Reschedule recurring event.
add_filter( 'pre_reschedule_event', array( $this, 'filter_reschedule_event' ), 5, 3 );
// Unschedule a specific event.
add_filter( 'pre_unschedule_event', array( $this, 'filter_unschedule_event' ), 5, 5 );
// Clear all events for a hook (optionally matching args).
add_filter( 'pre_clear_scheduled_hook', array( $this, 'filter_clear_scheduled_hook' ), 5, 4 );
// Unschedule all events for a hook regardless of args.
add_filter( 'pre_unschedule_hook', array( $this, 'filter_unschedule_hook' ), 5, 3 );
// Retrieve a scheduled event.
add_filter( 'pre_get_scheduled_event', array( $this, 'filter_get_scheduled_event' ), 5, 4 );
// Retrieve all ready-to-run events.
add_filter( 'pre_get_ready_cron_jobs', array( $this, 'filter_get_ready_cron_jobs' ), 5, 1 );
// Intercept direct get_option('cron') calls.
add_filter( 'pre_option_cron', array( $this, 'filter_pre_option_cron' ), 5, 3 );
// Intercept direct update_option('cron') calls.
add_filter( 'pre_update_option_cron', array( $this, 'filter_pre_update_option_cron' ), 5, 3 );
}
/*
* Return the current GMT time (cached per request).
*/
private function now() {
if ( null === $this->gmt_time ) {
$this->gmt_time = microtime( true );
}
return $this->gmt_time;
}
/*
* Check whether the table exists (cached per request).
*
* Returns false if the table is missing so that filter handlers can
* fall through to the default option-based code path.
*/
private function table_exists() {
if ( null === $this->table_ready ) {
global $wpdb;
$this->table_ready = ( $wpdb->get_var(
$wpdb->prepare( 'SHOW TABLES LIKE %s', $this->table )
) === $this->table );
}
return $this->table_ready;
}
/*
* Set the cached table-existence flag directly.
*
* @param bool $ready Whether the table is known to exist right now.
*/
public function set_table_ready( $ready ) {
$this->table_ready = (bool) $ready;
}
/*
* Intercept wp_schedule_single_event() and wp_schedule_event().
*
* @param null|bool|WP_Error $pre Existing filter value (null = proceed).
* @param object $event Event data object.
* @param bool $wp_error Whether to return a WP_Error on failure.
* @return bool|WP_Error True on success, false/WP_Error on failure.
*/
public function filter_schedule_event( $pre, $event, $wp_error ) {
if ( null !== $pre || ! $this->table_exists() ) {
return $pre;
}
global $wpdb;
// Validate timestamp.
if ( ! is_numeric( $event->timestamp ) || $event->timestamp <= 0 ) {
return $wp_error
? new WP_Error( 'invalid_timestamp', __( 'Event timestamp must be a valid Unix timestamp.' ) )
: false;
}
// Validate recurrence for recurring events.
if ( ! empty( $event->schedule ) ) {
$schedules = wp_get_schedules();
if ( ! isset( $schedules[ $event->schedule ] ) ) {
return $wp_error
? new WP_Error( 'invalid_schedule', __( 'Event schedule does not exist.' ) )
: false;
}
}
$args = isset( $event->args ) ? $event->args : array();
$key = md5( serialize( $args ) );
$hook = $event->hook;
$schedule = ! empty( $event->schedule ) ? $event->schedule : null;
$interval = isset( $event->interval ) ? (int) $event->interval : null;
// Duplicate detection (mirrors core logic for single events).
if ( ! $schedule ) {
if ( $event->timestamp < time() + 10 * MINUTE_IN_SECONDS ) {
$min_ts = 0;
} else {
$min_ts = $event->timestamp - 10 * MINUTE_IN_SECONDS;
}
if ( $event->timestamp < time() ) {
$max_ts = time() + 10 * MINUTE_IN_SECONDS;
} else {
$max_ts = $event->timestamp + 10 * MINUTE_IN_SECONDS;
}
$existing = $wpdb->get_var(
$wpdb->prepare(
"SELECT id FROM {$this->table}
WHERE hook = %s AND args_hash = %s
AND timestamp BETWEEN %d AND %d
LIMIT 1",
$hook,
$key,
$min_ts,
$max_ts
)
);
if ( $existing ) {
return $wp_error
? new WP_Error( 'duplicate_event', __( 'A duplicate event already exists.' ) )
: false;
}
}
// Allow other plugins to disallow via the schedule_event filter.
$event = apply_filters( 'schedule_event', $event );
if ( ! $event ) {
return $wp_error
? new WP_Error( 'schedule_event_false', __( 'A plugin disallowed this event.' ) )
: false;
}
$result = $wpdb->insert(
$this->table,
array(
'hook' => $hook,
'timestamp' => (int) $event->timestamp,
'schedule' => $schedule,
'interval_sec' => $interval,
'args' => maybe_serialize( $args ),
'args_hash' => $key,
),
array( '%s', '%d', '%s', '%d', '%s', '%s' )
);
if ( false === $result ) {
// The SELECT-based duplicate check above is not atomic with this
// INSERT, so a concurrent request can slip through it. The
// uniq_hook_args_ts key catches that race at the DB level; treat
// a duplicate-key failure the same as a detected duplicate.
if ( $wpdb->last_error && false !== stripos( $wpdb->last_error, 'Duplicate entry' ) ) {
return $wp_error
? new WP_Error( 'duplicate_event', __( 'A duplicate event already exists.' ) )
: false;
}
return $wp_error
? new WP_Error( 'db_insert_error', __( 'Could not schedule the event.' ) )
: false;
}
return true;
}
/*
* Intercept wp_reschedule_event().
*
* Calculates the next run time, updates the existing row in-place,
* and records the execution timestamp in last_run.
*
* @param null|bool|WP_Error $pre Existing filter value.
* @param object $event Event data object.
* @param bool $wp_error Whether to return a WP_Error on failure.
* @return bool|WP_Error|object True on success (prevents the default
* wp_schedule_event() call from creating a duplicate row).
*/
public function filter_reschedule_event( $pre, $event, $wp_error ) {
if ( null !== $pre || ! $this->table_exists() ) {
return $pre;
}
global $wpdb;
$schedules = wp_get_schedules();
$interval = 0;
if ( isset( $schedules[ $event->schedule ] ) ) {
$interval = $schedules[ $event->schedule ]['interval'];
}
// Fallback: use the interval stored in the table.
if ( 0 === $interval ) {
$existing = $this->get_event_from_table( $event->hook, $event->args, $event->timestamp );
if ( $existing && ! empty( $existing->interval ) ) {
$interval = (int) $existing->interval;
}
}
if ( 0 === $interval ) {
return $wp_error
? new WP_Error( 'invalid_schedule', __( 'Event schedule does not exist.' ) )
: false;
}
$now = time();
$old_ts = (int) $event->timestamp;
$args = $event->args;
$key = md5( serialize( $args ) );
$hook = $event->hook;
$schedule = $event->schedule;
// Calculate next occurrence (same logic as core).
if ( $old_ts >= $now ) {
$new_ts = $now + $interval;
} else {
$new_ts = $now + ( $interval - ( ( $now - $old_ts ) % $interval ) );
}
// Allow other plugins to modify via schedule_event filter.
$new_event = apply_filters(
'schedule_event',
(object) array(
'hook' => $hook,
'timestamp' => $new_ts,
'schedule' => $schedule,
'args' => $args,
'interval' => $interval,
)
);
if ( ! $new_event ) {
return $wp_error
? new WP_Error( 'schedule_event_false', __( 'A plugin disallowed this event.' ) )
: false;
}
$new_ts = (int) $new_event->timestamp;
// Update the existing row: set last_run to now and point to the new time.
$updated = $wpdb->update(
$this->table,
array(
'timestamp' => $new_ts,
'last_run' => $now,
),
array(
'hook' => $hook,
'args_hash' => $key,
'timestamp' => $old_ts,
),
array( '%d', '%d' ),
array( '%s', '%s', '%d' )
);
// If the old row was not found (maybe already deleted), insert a new one.
if ( 0 === $updated ) {
$inserted = $wpdb->insert(
$this->table,
array(
'hook' => $hook,
'timestamp' => $new_ts,
'schedule' => $schedule,
'interval_sec' => $interval,
'args' => maybe_serialize( $args ),
'args_hash' => $key,
'last_run' => $now,
),
array( '%s', '%d', '%s', '%d', '%s', '%s', '%d' )
);
// A concurrent reschedule may have already inserted the same
// (hook, args_hash, new_ts) row and hit uniq_hook_args_ts first;
// that row satisfies what we were trying to do, so it's not an
// error, just a race we lost harmlessly.
if ( false === $inserted
&& ! ( $wpdb->last_error && false !== stripos( $wpdb->last_error, 'Duplicate entry' ) ) ) {
return $wp_error
? new WP_Error( 'db_insert_error', __( 'Could not reschedule the event.' ) )
: false;
}
}
return true;
}
/*
* Intercept wp_unschedule_event().
*
* @param null|bool|WP_Error $pre Existing filter value.
* @param int $timestamp Event timestamp.
* @param string $hook Hook name.
* @param array $args Event arguments.
* @param bool $wp_error Whether to return a WP_Error.
* @return bool|WP_Error True on success.
*/
public function filter_unschedule_event( $pre, $timestamp, $hook, $args, $wp_error ) {
if ( null !== $pre || ! $this->table_exists() ) {
return $pre;
}
global $wpdb;
if ( ! is_numeric( $timestamp ) || $timestamp <= 0 ) {
return $wp_error
? new WP_Error( 'invalid_timestamp', __( 'Event timestamp must be a valid Unix timestamp.' ) )
: false;
}
$key = md5( serialize( $args ) );
$deleted = $wpdb->delete(
$this->table,
array(
'hook' => $hook,
'args_hash' => $key,
'timestamp' => (int) $timestamp,
),
array( '%s', '%s', '%d' )
);
return $deleted !== false ? true : false;
}
/*
* Intercept wp_clear_scheduled_hook().
*
* @param null|int|false|WP_Error $pre Existing filter value.
* @param string $hook Hook name.
* @param array $args Event arguments (optional match).
* @param bool $wp_error Whether to return a WP_Error.
* @return int|false|WP_Error Number of events removed.
*/
public function filter_clear_scheduled_hook( $pre, $hook, $args, $wp_error ) {
if ( null !== $pre || ! $this->table_exists() ) {
return $pre;
}
global $wpdb;
$where = array( 'hook' => $hook );
$fmt = array( '%s' );
if ( ! empty( $args ) ) {
$where['args_hash'] = md5( serialize( $args ) );
$fmt[] = '%s';
}
$count = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$this->table} WHERE hook = %s"
. ( isset( $where['args_hash'] ) ? ' AND args_hash = %s' : '' ),
...array_values( $where )
)
);
$wpdb->delete( $this->table, $where, $fmt );
return $count;
}
/*
* Intercept wp_unschedule_hook().
*
* @param null|int|false|WP_Error $pre Existing filter value.
* @param string $hook Hook name.
* @param bool $wp_error Whether to return a WP_Error.
* @return int|false|WP_Error Number of events removed.
*/
public function filter_unschedule_hook( $pre, $hook, $wp_error ) {
if ( null !== $pre || ! $this->table_exists() ) {
return $pre;
}
global $wpdb;
$count = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$this->table} WHERE hook = %s",
$hook
)
);
$wpdb->delete(
$this->table,
array( 'hook' => $hook ),
array( '%s' )
);
return $count;
}
/*
* Intercept wp_get_scheduled_event().
*
* @param null|false|object $pre Existing filter value.
* @param string $hook Hook name.
* @param array $args Event arguments.
* @param int|null $timestamp Specific timestamp or null for next.
* @return object|false Event object or false.
*/
public function filter_get_scheduled_event( $pre, $hook, $args, $timestamp ) {
if ( null !== $pre || ! $this->table_exists() ) {
return $pre;
}
if ( null !== $timestamp && ! is_numeric( $timestamp ) ) {
return false;
}
$key = md5( serialize( $args ) );
global $wpdb;
if ( null !== $timestamp && false !== $timestamp && '' !== $timestamp ) {
// Specific timestamp requested.
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$this->table}
WHERE hook = %s AND args_hash = %s AND timestamp = %d
LIMIT 1",
$hook,
$key,
(int) $timestamp
)
);
} else {
// Next scheduled event (lowest timestamp >= now or lowest overall).
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$this->table}
WHERE hook = %s AND args_hash = %s
ORDER BY timestamp ASC
LIMIT 1",
$hook,
$key
)
);
}
if ( ! $row ) {
return false;
}
return $this->row_to_event( $row );
}
/*
* Intercept wp_get_ready_cron_jobs().
*
* Returns events in the nested array format expected by wp-cron.php:
* array( timestamp => array( hook => array( hash => event_data ) ) )
*
* Also updates last_run for all events being picked up.
*
* @param null|array $pre Existing filter value.
* @return array Ready cron jobs in core format.
*/
public function filter_get_ready_cron_jobs( $pre ) {
if ( null !== $pre || ! $this->table_exists() ) {
return $pre;
}
global $wpdb;
$now = (int) $this->now();
// Mark all due events as "picked up" for execution.
$wpdb->query(
$wpdb->prepare(
"UPDATE {$this->table}
SET last_run = %d
WHERE timestamp <= %d AND last_run IS NULL",
$now,
$now
)
);
// Fetch all due events.
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$this->table}
WHERE timestamp <= %d
ORDER BY timestamp ASC",
$now
)
);
if ( empty( $rows ) ) {
return array();
}
$results = array();
foreach ( $rows as $row ) {
$ts = (int) $row->timestamp;
$hook = $row->hook;
$hash = $row->args_hash;
$event_data = array(
'schedule' => $row->schedule,
'args' => maybe_unserialize( $row->args ),
);
if ( ! empty( $row->interval_sec ) ) {
$event_data['interval'] = (int) $row->interval_sec;
}
$results[ $ts ][ $hook ][ $hash ] = $event_data;
}
return $results;
}
/*
* Intercept direct get_option('cron') calls.
*
* Reconstructs the legacy nested cron array from the table so that
* any code using get_option('cron') sees current data instead of the
* emptied option row.
*
* @param mixed $pre Existing filter value (false = proceed).
* @param string $option Option name ('cron').
* @param mixed $default_value Default value passed to get_option().
* @return array|false Cron array to short-circuit, or false to proceed normally.
*/
public function filter_pre_option_cron( $pre, $option, $default_value ) {
if ( false !== $pre ) {
return $pre;
}
if ( ! $this->table_exists() ) {
return false;
}
return $this->build_cron_array_from_table();
}
/*
* Intercept direct update_option('cron') calls.
*
* Syncs the incoming cron array into the table and prevents the actual
* DB write by returning a non-false value, which short-circuits
* update_option() in wp-includes/option.php.
*
* @param mixed $value The new value being saved.
* @param mixed $old_value The current value (from get_option, which is
* short-circuited by our pre_option_cron handler).
* @param string $option Option name ('cron').
* @return mixed Returns $value to short-circuit the DB write.
*/
public function filter_pre_update_option_cron( $value, $old_value, $option ) {
if ( ! $this->table_exists() ) {
return $value;
}
if ( ! is_array( $value ) ) {
$value = array();
}
$this->sync_cron_array_to_table( $value );
return $value;
}
/*
* Build the legacy nested cron array from the table.
*
* @return array Cron array in the format expected by wp_get_cron_array().
*/
private function build_cron_array_from_table() {
global $wpdb;
$rows = $wpdb->get_results( "SELECT * FROM {$this->table} ORDER BY timestamp ASC" );
$cron = array();
if ( ! empty( $rows ) ) {
foreach ( $rows as $row ) {
$ts = (string) $row->timestamp;
$hook = $row->hook;
$hash = $row->args_hash;
$entry = array(
'schedule' => $row->schedule,
'args' => maybe_unserialize( $row->args ),
);
if ( ! empty( $row->interval_sec ) ) {
$entry['interval'] = (int) $row->interval_sec;
}
$cron[ $ts ][ $hook ][ $hash ] = $entry;
}
}
$cron['version'] = 2;
return $cron;
}
/*
* Replace all table contents with events from a legacy cron array.
*
* @param array $cron Cron array in the format produced by wp_get_cron_array().
*/
private function sync_cron_array_to_table( array $cron ) {
global $wpdb;
unset( $cron['version'] );
$rows = array();
foreach ( $cron as $timestamp => $hooks ) {
if ( ! is_array( $hooks ) ) {
continue;
}
foreach ( $hooks as $hook => $events ) {
if ( ! is_array( $events ) ) {
continue;
}
foreach ( $events as $hash => $data ) {
$args = isset( $data['args'] ) ? $data['args'] : array();
$schedule = isset( $data['schedule'] ) ? $data['schedule'] : null;
$interval = isset( $data['interval'] ) ? (int) $data['interval'] : null;
$rows[] = array(
'hook' => $hook,
'timestamp' => (int) $timestamp,
'schedule' => $schedule,
'interval_sec' => $interval,
'args' => maybe_serialize( $args ),
'args_hash' => is_string( $hash ) && 32 === strlen( $hash ) ? $hash : md5( serialize( $args ) ),
);
}
}
}
// TRUNCATE auto-commits and isn't transactional, which would leave a
// window where a concurrent get_option('cron')/pre_option_cron read
// sees an empty table. Use DELETE inside an explicit transaction
// instead so other connections keep seeing the old data until COMMIT.
// This relies on the table using a transactional engine (InnoDB,
// which is the MySQL/MariaDB default dbDelta() will pick up here).
$wpdb->query( 'START TRANSACTION' );
$wpdb->query( "DELETE FROM {$this->table}" );
foreach ( $rows as $row ) {
$inserted = $wpdb->insert( $this->table, $row );
if ( false === $inserted
&& ! ( $wpdb->last_error && false !== stripos( $wpdb->last_error, 'Duplicate entry' ) ) ) {
// A genuine DB error (not just a benign duplicate-key hit) —
// roll back rather than leave the table partially rebuilt.
$wpdb->query( 'ROLLBACK' );
return;
}
}
$wpdb->query( 'COMMIT' );
}
/* ------------------------------------------------------------------
* Helpers
* ------------------------------------------------------------------
*/
/*
* Look up a single event from the table.
*
* @param string $hook Hook name.
* @param array $args Event arguments.
* @param int $timestamp Event timestamp.
* @return object|false Event object or false.
*/
private function get_event_from_table( $hook, $args, $timestamp ) {
global $wpdb;
$key = md5( serialize( $args ) );
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$this->table}
WHERE hook = %s AND args_hash = %s AND timestamp = %d
LIMIT 1",
$hook,
$key,
(int) $timestamp
)
);
return $row ? $this->row_to_event( $row ) : false;
}
/*
* Convert a database row into the event object expected by WP core.
*
* @param object $row Database row.
* @return object Event object.
*/
private function row_to_event( $row ) {
$event = (object) array(
'hook' => $row->hook,
'timestamp' => (int) $row->timestamp,
'schedule' => $row->schedule,
'args' => maybe_unserialize( $row->args ),
);
if ( ! empty( $row->interval_sec ) ) {
$event->interval = (int) $row->interval_sec;
}
return $event;
}
}
/* =========================================================================
* Admin Tools Page (optional — lists all cron tasks)
* =========================================================================
*/
function table_cron_admin_menu() {
add_management_page(
__( 'Cron Tasks', 'table-cron' ),
__( 'Cron Tasks', 'table-cron' ),
'manage_options',
'table-cron',
'table_cron_admin_page'
);
}
function table_cron_admin_page() {
global $wpdb;
$table = $wpdb->prefix . TABLE_CRON_TABLE;
$schedules = wp_get_schedules();
$tasks = $wpdb->get_results( "SELECT * FROM {$table} ORDER BY timestamp ASC" );
$now = time();
echo '<div class="wrap">';
echo '<h1>' . esc_html__( 'Scheduled Cron Tasks', 'table-cron' ) . '</h1>';
// this should not be possible ...
if ( empty( $tasks ) ) {
echo '<p>' . esc_html__( 'No cron tasks scheduled.', 'table-cron' ) . '</p>';
echo '</div>';
return;
}
echo '<table class="wp-list-table widefat fixed striped">';
echo '<thead><tr>';
echo '<th>' . esc_html__( 'Hook', 'table-cron' ) . '</th>';
echo '<th>' . esc_html__( 'Scheduled For (UTC)', 'table-cron' ) . '</th>';
echo '<th>' . esc_html__( 'Schedule', 'table-cron' ) . '</th>';
echo '<th>' . esc_html__( 'Interval', 'table-cron' ) . '</th>';
echo '<th>' . esc_html__( 'Last Run', 'table-cron' ) . '</th>';
echo '<th>' . esc_html__( 'Arguments', 'table-cron' ) . '</th>';
echo '</tr></thead><tbody>';
foreach ( $tasks as $task ) {
$ts = (int) $task->timestamp;
$overdue = $ts <= $now;
$class = $overdue ? 'table-cron-overdue' : '';
echo '<tr class="' . esc_attr( $class ) . '">';
echo '<td><code>' . esc_html( $task->hook ) . '</code></td>';
echo '<td>' . esc_html( gmdate( 'Y-m-d H:i:s', $ts ) );
if ( $overdue ) {
echo ' <span style="color:#a00">(' . esc_html__( 'overdue', 'table-cron' ) . ')</span>';
}
echo '</td>';
echo '<td>' . esc_html( $task->schedule ? ( isset( $schedules[ $task->schedule ] ) ? $schedules[ $task->schedule ]['display'] : $task->schedule ) : __( 'once', 'table-cron' ) ) . '</td>';
echo '<td>' . ( ! empty( $task->interval_sec ) ? esc_html( human_time_diff( 0, $task->interval_sec ) ) : '—' ) . '</td>';
echo '<td>' . ( $task->last_run ? esc_html( gmdate( 'Y-m-d H:i:s', (int) $task->last_run ) ) : '—' ) . '</td>';
$args = maybe_unserialize( $task->args );
echo '<td><code>' . esc_html( wp_json_encode( $args ) ) . '</code></td>';
echo '</tr>';
}
echo '</tbody></table>';
echo '<p class="description">' . esc_html( sprintf(
/* translators: %d: number of scheduled cron tasks. */