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<?php
/**
* WHAT IS THIS FILE?
*
* The clock. It arranges for the background worker to run every few minutes.
*
* WHY DOES IT EXIST?
*
* Because the worker has to run without anyone asking it to, and WP-Cron is how
* WordPress does that. This file is deliberately thin: it schedules, it locks, it
* calls the worker. All the actual work is in class-worker.php.
*
* HOW WP-CRON REALLY WORKS, AND WHY IT MATTERS HERE
*
* WP-Cron is not cron. Nothing on the server wakes WordPress up. Instead, on each
* page load WordPress checks whether anything is overdue and, if so, fires off a
* request to itself to run it.
*
* Two consequences shape this file:
*
* 1. A SITE WITH NO VISITORS DOES NO WORK. On a quiet network the queue can sit
* still for hours. That is not a bug in the plugin, and the dashboard says so
* plainly โ along with the one line of real cron that fixes it.
*
* 2. TICKS CAN OVERLAP. Two visitors arriving together can trigger two runs at
* once. That is normal, not exotic. Two defences: the lock below stops the
* common case cheaply, and claim_next() in class-documents.php makes the
* uncommon case harmless anyway. Belt and braces, because the cost of getting
* this wrong is uploading โ and being billed for โ the same document twice.
*
* ON MULTISITE, THIS RUNS ON THE MAIN SITE ONLY
*
* The queue is one network-wide table, so one clock is enough. WP-Cron events are
* per-site, so scheduling on every site in a 200-site network would give us 200
* clocks all working the same queue โ 200 times the overhead for exactly the same
* throughput.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class Equalify_Iris_Scheduler {
/** The cron hook name. */
const HOOK = 'equalify_iris_tick';
/** Our custom schedule's name. */
const SCHEDULE = 'equalify_iris_five_minutes';
/** How often a tick should run, in seconds. */
const INTERVAL = 300;
/**
* How long the "a tick is already running" lock lasts, in seconds.
*
* Comfortably longer than a tick's own budget, so a tick cannot be running
* while its lock has quietly expired โ but short enough that a tick killed
* mid-run (a PHP timeout, a restart) does not block the queue for long.
*/
const LOCK_DURATION = 120;
private Equalify_Iris_Worker $worker;
public function __construct( Equalify_Iris_Worker $worker ) {
$this->worker = $worker;
}
public function init(): void {
add_filter( 'cron_schedules', array( $this, 'add_schedule' ) ); // phpcs:ignore WordPress.WP.CronInterval
add_action( self::HOOK, array( $this, 'run_tick' ) );
}
/**
* Teach WordPress about a five-minute interval.
*
* WordPress ships with hourly, twice daily and daily, and nothing shorter than
* an hour. Hourly would mean a queue of 5,000 documents took months.
*
* WHY FIVE MINUTES AND NOT ONE?
*
* Because Iris converts two documents at a time and each takes minutes. Ticking
* every minute would mean four out of five ticks finding nothing to do and
* spending a PHP process to discover that. Five minutes keeps us close to the
* pace Iris can actually work at.
*/
public function add_schedule( array $schedules ): array {
$schedules[ self::SCHEDULE ] = array(
'interval' => self::INTERVAL,
'display' => __( 'Every five minutes (Equalify Iris)', 'equalify-iris' ),
);
return $schedules;
}
/**
* Make sure the tick is scheduled. Safe to call on every page load.
*/
public static function schedule(): void {
if ( ! is_main_site() ) {
return;
}
if ( wp_next_scheduled( self::HOOK ) ) {
return;
}
wp_schedule_event( time() + 60, self::SCHEDULE, self::HOOK );
}
/**
* Stop the tick from running.
*
* Called on deactivation. Not called when an admin presses Stop โ the tick
* keeps being scheduled and simply returns early, so that pressing Start again
* resumes within five minutes instead of waiting for a fresh schedule.
*/
public static function unschedule(): void {
wp_clear_scheduled_hook( self::HOOK );
}
/** When the next tick is due, or 0 if none is scheduled. */
public static function next_run(): int {
return (int) wp_next_scheduled( self::HOOK );
}
/**
* Is WP-Cron turned off on this install?
*
* Some hosts set DISABLE_WP_CRON and run real cron instead, which is the better
* setup. Others set it and forget the second half, which means nothing ever
* runs. The dashboard needs to be able to tell an admin which situation they are
* in, because from inside WordPress the two look identical.
*/
public static function wp_cron_is_disabled(): bool {
return defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON;
}
/**
* Run one tick, unless another one is already running.
*/
public function run_tick(): array {
if ( ! $this->lock() ) {
return array(
'ran' => false,
'reason' => 'locked',
);
}
try {
return $this->worker->run();
} finally {
// `finally` so the lock is always released, even if the worker throws.
// Without it, one unexpected error would leave the lock in place and
// stall the queue for its full duration.
$this->unlock();
}
}
/**
* Run a tick right now, ignoring the schedule.
*
* Used by WP-CLI and by the "Run now" button. Still takes the lock, because
* running by hand while a scheduled tick is in progress is exactly the overlap
* we are guarding against.
*/
public function run_now(): array {
return $this->run_tick();
}
// -----------------------------------------------------------------------
// The lock
// -----------------------------------------------------------------------
/**
* Try to take the lock.
*
* A network transient with an expiry, not a database row, because the expiry is
* the important part: whatever happens to the process holding it โ timeout,
* fatal error, someone pulling a power cable โ the lock lets go by itself.
*
* This is a best-effort lock, not a guarantee. Two processes reading and setting
* a transient at the same instant can both believe they won. That is fine, and
* saying so here is more useful than pretending otherwise: the real protection
* against double work is claim_next(), which the database enforces. This lock
* exists to make the common case cheap, not to be the only defence.
*/
private function lock(): bool {
if ( get_site_transient( self::HOOK . '_lock' ) ) {
return false;
}
set_site_transient( self::HOOK . '_lock', time(), self::LOCK_DURATION );
return true;
}
/** Release the lock. */
private function unlock(): void {
delete_site_transient( self::HOOK . '_lock' );
}
/** Is a tick running right now? Shown on the dashboard. */
public static function is_locked(): bool {
return (bool) get_site_transient( self::HOOK . '_lock' );
}
}