Skip to content

Commit 83631b4

Browse files
authored
Prune duplicate recurring events (#251)
1 parent 83b9f50 commit 83631b4

File tree

7 files changed

+115
-7
lines changed

7 files changed

+115
-7
lines changed

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ By default the plugin disables default WP cron processing. It is recommended to
2525

2626
## Frequently Asked Questions ##
2727

28+
### Deviations from WordPress Core ###
29+
30+
* Cron jobs are stored in a custom table and not in the `cron` option in wp_options. As long relevent code uses WP core functions for retrieving events and not direct SQL, all will stay compatible.
31+
* Duplicate recurring events with the same action/args/schedule are prevented. If multiple of the same action is needed on the same schedule, can add an arbitrary number to the args array.
32+
* When the cron control runner is running events, it does so via WP CLI. So the environment can be slightly different than that of a normal web request.
33+
* The cron control runner can process multiple events in parallel, whereas core cron only did 1 at a time. By default, events with the same action will not run in parallel unless specifically granted permission to do so.
34+
2835
### Adding Internal Events ###
2936

3037
**This should be done sparingly as "Internal Events" bypass certain locks and limits built into the plugin.** Overuse will lead to unexpected resource usage, and likely resource exhaustion.

__tests__/unit-tests/test-events-store.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,4 +203,33 @@ public function test_query_raw_events() {
203203
$this->assertEquals( 1, count( $result ), 'returned event from second page' );
204204
$this->assertEquals( $event_two->get_timestamp(), $result[0]->timestamp, 'found the right event' );
205205
}
206+
207+
public function test_query_raw_events_orderby() {
208+
$store = Events_Store::instance();
209+
210+
$event_one = Utils::create_test_event( [ 'timestamp' => 5, 'action' => 'test_query_raw_events_orderby' ] );
211+
$event_two = Utils::create_test_event( [ 'timestamp' => 2, 'action' => 'test_query_raw_events_orderby' ] );
212+
$event_three = Utils::create_test_event( [ 'timestamp' => 3, 'action' => 'test_query_raw_events_orderby' ] );
213+
$event_four = Utils::create_test_event( [ 'timestamp' => 1, 'action' => 'test_query_raw_events_orderby' ] );
214+
215+
// Default orderby should be timestamp ASC
216+
$result = $store->_query_events_raw();
217+
$this->assertEquals( 4, count( $result ), 'returned the correct amount of events' );
218+
$this->assertEquals( $event_four->get_timestamp(), $result[0]->timestamp, 'the oldest "due now" event is returned first' );
219+
220+
// Fetch by timestamp in descending order.
221+
$result = $store->_query_events_raw( [ 'orderby' => 'timestamp', 'order' => 'desc' ] );
222+
$this->assertEquals( 4, count( $result ), 'returned the correct amount of events' );
223+
$this->assertEquals( $event_one->get_timestamp(), $result[0]->timestamp, 'the farthest "due now" event is returned first' );
224+
225+
// Fetch by ID in ascending order.
226+
$result = $store->_query_events_raw( [ 'orderby' => 'ID', 'order' => 'asc' ] );
227+
$this->assertEquals( 4, count( $result ), 'returned the correct amount of events' );
228+
$this->assertEquals( $event_one->get_id(), $result[0]->ID, 'the lowest ID is returned first' );
229+
230+
// Fetch by ID in descending order.
231+
$result = $store->_query_events_raw( [ 'orderby' => 'ID', 'order' => 'desc' ] );
232+
$this->assertEquals( 4, count( $result ), 'returned the correct amount of events' );
233+
$this->assertEquals( $event_four->get_id(), $result[0]->ID, 'the highest ID is returned first' );
234+
}
206235
}

__tests__/unit-tests/test-internal-events.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,35 @@ function test_migrate_legacy_cron_events() {
6161
$cron_row = $wpdb->get_row( "SELECT * FROM $wpdb->options WHERE option_name = 'cron'" );
6262
$this->assertNull( $cron_row, 'cron option was deleted' );
6363
}
64+
65+
function test_prune_duplicate_events() {
66+
// We don't prune single events, even if duplicates.
67+
$original_single_event = Utils::create_test_event( [ 'timestamp' => time(), 'action' => 'single_event', 'args' => [ 'same' ] ] );
68+
$duplicate_single_event = Utils::create_test_event( [ 'timestamp' => time() + 100, 'action' => 'single_event', 'args' => [ 'same' ] ] );
69+
$unique_single_event = Utils::create_test_event( [ 'timestamp' => time() + 200, 'action' => 'single_event', 'args' => [ 'unique' ] ] );
70+
71+
// We do prune duplicate recurring events.
72+
$original_recurring_event = Utils::create_test_event( [ 'timestamp' => time() + 500, 'action' => 'recurring_event', 'schedule' => 'hourly', 'interval' => \HOUR_IN_SECONDS ] );
73+
$duplicate_recurring_event = Utils::create_test_event( [ 'timestamp' => time() + 100, 'action' => 'recurring_event', 'schedule' => 'hourly', 'interval' => \HOUR_IN_SECONDS ] );
74+
$duplicate_recurring_event2 = Utils::create_test_event( [ 'timestamp' => time() + 200, 'action' => 'recurring_event', 'schedule' => 'hourly', 'interval' => \HOUR_IN_SECONDS ] );
75+
$unique_recurring_event = Utils::create_test_event( [ 'timestamp' => time() + 100, 'action' => 'recurring_event', 'schedule' => 'hourly', 'interval' => \HOUR_IN_SECONDS, 'args' => [ 'unique' ] ] );
76+
77+
// Run the pruning.
78+
Cron_Control\Internal_Events::instance()->clean_legacy_data();
79+
80+
// Should have 5 events left, and the oldest IDs should have been kept..
81+
$remaining_events = Cron_Control\Events::query( [ 'limit' => 100, 'orderby' => 'ID', 'order' => 'ASC' ] );
82+
$this->assertEquals( 5, count( $remaining_events ), 'correct number of registered events left after pruning' );
83+
$this->assertEquals( $remaining_events[0]->get_id(), $original_single_event->get_id(), 'original single event was kept' );
84+
$this->assertEquals( $remaining_events[1]->get_id(), $duplicate_single_event->get_id(), 'duplicate single event was also kept' );
85+
$this->assertEquals( $remaining_events[2]->get_id(), $unique_single_event->get_id(), 'unique single event was kept' );
86+
$this->assertEquals( $remaining_events[3]->get_id(), $original_recurring_event->get_id(), 'original recurring event was kept' );
87+
$this->assertEquals( $remaining_events[4]->get_id(), $unique_recurring_event->get_id(), 'unique recurring event was kept' );
88+
89+
// The two duplicates should be marked as completed now.
90+
$duplicate_recurring_1 = Cron_Control\Event::get( $duplicate_recurring_event->get_id() );
91+
$duplicate_recurring_2 = Cron_Control\Event::get( $duplicate_recurring_event2->get_id() );
92+
$this->assertEquals( $duplicate_recurring_1->get_status(), Cron_Control\Events_Store::STATUS_COMPLETED, 'duplicate recurring event 1 was marked as completed' );
93+
$this->assertEquals( $duplicate_recurring_2->get_status(), Cron_Control\Events_Store::STATUS_COMPLETED, 'duplicate recurring event 2 was marked as completed' );
94+
}
6495
}

includes/class-events-store.php

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,10 @@ public function _query_events_raw( array $args = [] ): array {
633633
'default' => 1,
634634
'validation' => fn( $page ) => is_int( $page ) && $page >= 1,
635635
],
636+
'orderby' => [
637+
'default' => 'timestamp',
638+
'validation' => fn( $orderby ) => is_null( $orderby ) || ( is_string( $orderby ) && in_array( $orderby, [ 'timestamp', 'ID' ], true ) ),
639+
],
636640
'order' => [
637641
'default' => 'ASC',
638642
'validation' => fn( $order ) => is_string( $order ) && in_array( strtoupper( $order ), [ 'ASC', 'DESC'], true ),
@@ -716,9 +720,10 @@ public function _query_events_raw( array $args = [] ): array {
716720
}
717721
}
718722

719-
// TODO: adjust for situations where we don't need to sort.
720-
$sql .= ' ORDER BY timestamp';
721-
$sql .= strtoupper( $parsed_args['order'] ) === 'ASC' ? ' ASC' : ' DESC';
723+
if ( ! is_null( $parsed_args['orderby'] ) ) {
724+
$sql .= ' ORDER BY ' . $parsed_args['orderby'];
725+
$sql .= strtoupper( $parsed_args['order'] ) === 'ASC' ? ' ASC' : ' DESC';
726+
}
722727

723728
// Skip paging/limits if "-1" was passed to get all events.
724729
if ( $parsed_args['limit'] >= 1 ) {
@@ -740,7 +745,7 @@ public function _query_events_raw( array $args = [] ): array {
740745

741746
// Conditionally use a more specific cache for common FE queries, helping avoid most bulk invalidations.
742747
$allowed_arg_count = array_key_exists( 'timestamp', $args ) ? 4 : 3;
743-
if ( 1 === $args['limit'] && count( $args ) === $allowed_arg_count ) {
748+
if ( isset( $args['limit'] ) && 1 === $args['limit'] && count( $args ) === $allowed_arg_count ) {
744749
$has_timestamp = array_key_exists( 'timestamp', $args ) && ! is_null( $args['timestamp'] );
745750

746751
// Request was for the next event based on action/args, i.e. wp_next_scheduled()

includes/class-internal-events.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ public function clean_legacy_data() {
216216
// While this plugin doesn't use this locking mechanism, other code may check the value.
217217
delete_transient( 'doing_cron' );
218218

219+
$this->prune_duplicate_events();
219220
$this->ensure_internal_events_have_correct_schedule();
220221
}
221222

@@ -255,6 +256,34 @@ private function migrate_legacy_cron_events() {
255256
}
256257
}
257258

259+
// Recurring events that have the same action/args/schedule are unnecessary. We can safely remove them.
260+
private function prune_duplicate_events() {
261+
$events = Events::query( [ 'limit' => -1, 'orderby' => 'ID', 'order' => 'ASC' ] );
262+
263+
$original_events = [];
264+
$duplicate_events = [];
265+
foreach ( $events as $event ) {
266+
if ( ! $event->is_recurring() ) {
267+
// Only interested in recurring events.
268+
continue;
269+
}
270+
271+
$unique_key = sha1( "{$event->get_action()}:{$event->get_instance()}:{$event->get_schedule()}" );
272+
if ( ! isset( $original_events[ $unique_key ] ) ) {
273+
// The first occurrence, will also be the oldest (lowest ID).
274+
$original_events[ $unique_key ] = true;
275+
} else {
276+
// Found a duplicate!
277+
$duplicate_events[] = $event;
278+
}
279+
}
280+
281+
foreach ( $duplicate_events as $duplicate_event ) {
282+
// For now we'll just complete them.
283+
$duplicate_event->complete();
284+
}
285+
}
286+
258287
private function ensure_internal_events_have_correct_schedule() {
259288
$schedules = wp_get_schedules();
260289

languages/cron-control.pot

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ msgid ""
44
msgstr ""
55
"Project-Id-Version: Cron Control 3.0\n"
66
"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/cron-control\n"
7-
"POT-Creation-Date: 2022-01-19 22:39:12+00:00\n"
7+
"POT-Creation-Date: 2022-01-20 03:55:29+00:00\n"
88
"MIME-Version: 1.0\n"
99
"Content-Type: text/plain; charset=utf-8\n"
1010
"Content-Transfer-Encoding: 8bit\n"
@@ -58,11 +58,11 @@ msgstr ""
5858
msgid "Job with action `%1$s` and arguments `%2$s` executed."
5959
msgstr ""
6060

61-
#: includes/class-internal-events.php:110
61+
#: includes/class-internal-events.php:90
6262
msgid "Cron Control internal job - every 2 minutes (used to be 1 minute)"
6363
msgstr ""
6464

65-
#: includes/class-internal-events.php:114
65+
#: includes/class-internal-events.php:94
6666
msgid "Cron Control internal job - every 10 minutes"
6767
msgstr ""
6868

readme.txt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ By default the plugin disables default WP cron processing. It is recommended to
2525

2626
== Frequently Asked Questions ==
2727

28+
= Deviations from WordPress Core =
29+
30+
* Cron jobs are stored in a custom table and not in the `cron` option in wp_options. As long relevent code uses WP core functions for retrieving events and not direct SQL, all will stay compatible.
31+
* Duplicate recurring events with the same action/args/schedule are prevented. If multiple of the same action is needed on the same schedule, can add an arbitrary number to the args array.
32+
* When the cron control runner is running events, it does so via WP CLI. So the environment can be slightly different than that of a normal web request.
33+
* The cron control runner can process multiple events in parallel, whereas core cron only did 1 at a time. By default, events with the same action will not run in parallel unless specifically granted permission to do so.
34+
2835
= Adding Internal Events =
2936

3037
**This should be done sparingly as "Internal Events" bypass certain locks and limits built into the plugin.** Overuse will lead to unexpected resource usage, and likely resource exhaustion.

0 commit comments

Comments
 (0)