├── .gitignore ├── .travis.yml ├── LICENSE.txt ├── README.md ├── composer.json ├── docs ├── README.md ├── examples.md ├── install.md ├── motivation.md └── plugins.md ├── inc ├── class-command.php ├── class-job.php ├── connector │ └── namespace.php ├── namespace.php └── upgrade │ └── namespace.php ├── phpcs.ruleset.xml ├── phpunit.xml.dist ├── plugin.php └── tests ├── bootstrap.php ├── install-tests.sh └── tests └── class-tests-rescheduling.php /.gitignore: -------------------------------------------------------------------------------- 1 | tests/tests/class-wp-core-cron.php 2 | .phpunit.result.cache 3 | composer.lock 4 | vendor/ 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | php: 3 | - 7.4 4 | - 8.0 5 | - 8.1 6 | env: 7 | - WP_VERSION=latest WP_MULTISITE=0 8 | - WP_VERSION=latest WP_MULTISITE=1 9 | matrix: 10 | include: 11 | - php: 8.0 12 | env: WP_VERSION=trunk WP_MULTISITE=0 13 | - php: 8.0 14 | env: WP_VERSION=trunk WP_MULTISITE=1 15 | allow_failures: 16 | - php: 8.1 17 | services: 18 | - mysql 19 | install: 20 | - composer install 21 | - bash tests/install-tests.sh wordpress_test root '' 127.0.0.1 $WP_VERSION 22 | script: 23 | - vendor/bin/phpunit 24 | notifications: 25 | email: false 26 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Cavalcade - A better wp-cron 2 | 3 | Copyright 2014-2017 Human Made and contributors 4 | 5 | This program is free software; you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation; either version 2 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program; if not, write to the Free Software 17 | Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA 18 | 19 | This program incorporates work covered by the following copyright and 20 | permission notices: 21 | 22 | Cavalcade - A better wp-cron 23 | 24 | Copyright 2014-2017 Human Made and contributors 25 | 26 | Cavalcade is released under the GPL 27 | 28 | =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-= 29 | 30 | GNU GENERAL PUBLIC LICENSE 31 | Version 2, June 1991 32 | 33 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 34 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 35 | Everyone is permitted to copy and distribute verbatim copies 36 | of this license document, but changing it is not allowed. 37 | 38 | Preamble 39 | 40 | The licenses for most software are designed to take away your 41 | freedom to share and change it. By contrast, the GNU General Public 42 | License is intended to guarantee your freedom to share and change free 43 | software--to make sure the software is free for all its users. This 44 | General Public License applies to most of the Free Software 45 | Foundation's software and to any other program whose authors commit to 46 | using it. (Some other Free Software Foundation software is covered by 47 | the GNU Lesser General Public License instead.) You can apply it to 48 | your programs, too. 49 | 50 | When we speak of free software, we are referring to freedom, not 51 | price. Our General Public Licenses are designed to make sure that you 52 | have the freedom to distribute copies of free software (and charge for 53 | this service if you wish), that you receive source code or can get it 54 | if you want it, that you can change the software or use pieces of it 55 | in new free programs; and that you know you can do these things. 56 | 57 | To protect your rights, we need to make restrictions that forbid 58 | anyone to deny you these rights or to ask you to surrender the rights. 59 | These restrictions translate to certain responsibilities for you if you 60 | distribute copies of the software, or if you modify it. 61 | 62 | For example, if you distribute copies of such a program, whether 63 | gratis or for a fee, you must give the recipients all the rights that 64 | you have. You must make sure that they, too, receive or can get the 65 | source code. And you must show them these terms so they know their 66 | rights. 67 | 68 | We protect your rights with two steps: (1) copyright the software, and 69 | (2) offer you this license which gives you legal permission to copy, 70 | distribute and/or modify the software. 71 | 72 | Also, for each author's protection and ours, we want to make certain 73 | that everyone understands that there is no warranty for this free 74 | software. If the software is modified by someone else and passed on, we 75 | want its recipients to know that what they have is not the original, so 76 | that any problems introduced by others will not reflect on the original 77 | authors' reputations. 78 | 79 | Finally, any free program is threatened constantly by software 80 | patents. We wish to avoid the danger that redistributors of a free 81 | program will individually obtain patent licenses, in effect making the 82 | program proprietary. To prevent this, we have made it clear that any 83 | patent must be licensed for everyone's free use or not licensed at all. 84 | 85 | The precise terms and conditions for copying, distribution and 86 | modification follow. 87 | 88 | GNU GENERAL PUBLIC LICENSE 89 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 90 | 91 | 0. This License applies to any program or other work which contains 92 | a notice placed by the copyright holder saying it may be distributed 93 | under the terms of this General Public License. The "Program", below, 94 | refers to any such program or work, and a "work based on the Program" 95 | means either the Program or any derivative work under copyright law: 96 | that is to say, a work containing the Program or a portion of it, 97 | either verbatim or with modifications and/or translated into another 98 | language. (Hereinafter, translation is included without limitation in 99 | the term "modification".) Each licensee is addressed as "you". 100 | 101 | Activities other than copying, distribution and modification are not 102 | covered by this License; they are outside its scope. The act of 103 | running the Program is not restricted, and the output from the Program 104 | is covered only if its contents constitute a work based on the 105 | Program (independent of having been made by running the Program). 106 | Whether that is true depends on what the Program does. 107 | 108 | 1. You may copy and distribute verbatim copies of the Program's 109 | source code as you receive it, in any medium, provided that you 110 | conspicuously and appropriately publish on each copy an appropriate 111 | copyright notice and disclaimer of warranty; keep intact all the 112 | notices that refer to this License and to the absence of any warranty; 113 | and give any other recipients of the Program a copy of this License 114 | along with the Program. 115 | 116 | You may charge a fee for the physical act of transferring a copy, and 117 | you may at your option offer warranty protection in exchange for a fee. 118 | 119 | 2. You may modify your copy or copies of the Program or any portion 120 | of it, thus forming a work based on the Program, and copy and 121 | distribute such modifications or work under the terms of Section 1 122 | above, provided that you also meet all of these conditions: 123 | 124 | a) You must cause the modified files to carry prominent notices 125 | stating that you changed the files and the date of any change. 126 | 127 | b) You must cause any work that you distribute or publish, that in 128 | whole or in part contains or is derived from the Program or any 129 | part thereof, to be licensed as a whole at no charge to all third 130 | parties under the terms of this License. 131 | 132 | c) If the modified program normally reads commands interactively 133 | when run, you must cause it, when started running for such 134 | interactive use in the most ordinary way, to print or display an 135 | announcement including an appropriate copyright notice and a 136 | notice that there is no warranty (or else, saying that you provide 137 | a warranty) and that users may redistribute the program under 138 | these conditions, and telling the user how to view a copy of this 139 | License. (Exception: if the Program itself is interactive but 140 | does not normally print such an announcement, your work based on 141 | the Program is not required to print an announcement.) 142 | 143 | These requirements apply to the modified work as a whole. If 144 | identifiable sections of that work are not derived from the Program, 145 | and can be reasonably considered independent and separate works in 146 | themselves, then this License, and its terms, do not apply to those 147 | sections when you distribute them as separate works. But when you 148 | distribute the same sections as part of a whole which is a work based 149 | on the Program, the distribution of the whole must be on the terms of 150 | this License, whose permissions for other licensees extend to the 151 | entire whole, and thus to each and every part regardless of who wrote it. 152 | 153 | Thus, it is not the intent of this section to claim rights or contest 154 | your rights to work written entirely by you; rather, the intent is to 155 | exercise the right to control the distribution of derivative or 156 | collective works based on the Program. 157 | 158 | In addition, mere aggregation of another work not based on the Program 159 | with the Program (or with a work based on the Program) on a volume of 160 | a storage or distribution medium does not bring the other work under 161 | the scope of this License. 162 | 163 | 3. You may copy and distribute the Program (or a work based on it, 164 | under Section 2) in object code or executable form under the terms of 165 | Sections 1 and 2 above provided that you also do one of the following: 166 | 167 | a) Accompany it with the complete corresponding machine-readable 168 | source code, which must be distributed under the terms of Sections 169 | 1 and 2 above on a medium customarily used for software interchange; or, 170 | 171 | b) Accompany it with a written offer, valid for at least three 172 | years, to give any third party, for a charge no more than your 173 | cost of physically performing source distribution, a complete 174 | machine-readable copy of the corresponding source code, to be 175 | distributed under the terms of Sections 1 and 2 above on a medium 176 | customarily used for software interchange; or, 177 | 178 | c) Accompany it with the information you received as to the offer 179 | to distribute corresponding source code. (This alternative is 180 | allowed only for noncommercial distribution and only if you 181 | received the program in object code or executable form with such 182 | an offer, in accord with Subsection b above.) 183 | 184 | The source code for a work means the preferred form of the work for 185 | making modifications to it. For an executable work, complete source 186 | code means all the source code for all modules it contains, plus any 187 | associated interface definition files, plus the scripts used to 188 | control compilation and installation of the executable. However, as a 189 | special exception, the source code distributed need not include 190 | anything that is normally distributed (in either source or binary 191 | form) with the major components (compiler, kernel, and so on) of the 192 | operating system on which the executable runs, unless that component 193 | itself accompanies the executable. 194 | 195 | If distribution of executable or object code is made by offering 196 | access to copy from a designated place, then offering equivalent 197 | access to copy the source code from the same place counts as 198 | distribution of the source code, even though third parties are not 199 | compelled to copy the source along with the object code. 200 | 201 | 4. You may not copy, modify, sublicense, or distribute the Program 202 | except as expressly provided under this License. Any attempt 203 | otherwise to copy, modify, sublicense or distribute the Program is 204 | void, and will automatically terminate your rights under this License. 205 | However, parties who have received copies, or rights, from you under 206 | this License will not have their licenses terminated so long as such 207 | parties remain in full compliance. 208 | 209 | 5. You are not required to accept this License, since you have not 210 | signed it. However, nothing else grants you permission to modify or 211 | distribute the Program or its derivative works. These actions are 212 | prohibited by law if you do not accept this License. Therefore, by 213 | modifying or distributing the Program (or any work based on the 214 | Program), you indicate your acceptance of this License to do so, and 215 | all its terms and conditions for copying, distributing or modifying 216 | the Program or works based on it. 217 | 218 | 6. Each time you redistribute the Program (or any work based on the 219 | Program), the recipient automatically receives a license from the 220 | original licensor to copy, distribute or modify the Program subject to 221 | these terms and conditions. You may not impose any further 222 | restrictions on the recipients' exercise of the rights granted herein. 223 | You are not responsible for enforcing compliance by third parties to 224 | this License. 225 | 226 | 7. If, as a consequence of a court judgment or allegation of patent 227 | infringement or for any other reason (not limited to patent issues), 228 | conditions are imposed on you (whether by court order, agreement or 229 | otherwise) that contradict the conditions of this License, they do not 230 | excuse you from the conditions of this License. If you cannot 231 | distribute so as to satisfy simultaneously your obligations under this 232 | License and any other pertinent obligations, then as a consequence you 233 | may not distribute the Program at all. For example, if a patent 234 | license would not permit royalty-free redistribution of the Program by 235 | all those who receive copies directly or indirectly through you, then 236 | the only way you could satisfy both it and this License would be to 237 | refrain entirely from distribution of the Program. 238 | 239 | If any portion of this section is held invalid or unenforceable under 240 | any particular circumstance, the balance of the section is intended to 241 | apply and the section as a whole is intended to apply in other 242 | circumstances. 243 | 244 | It is not the purpose of this section to induce you to infringe any 245 | patents or other property right claims or to contest validity of any 246 | such claims; this section has the sole purpose of protecting the 247 | integrity of the free software distribution system, which is 248 | implemented by public license practices. Many people have made 249 | generous contributions to the wide range of software distributed 250 | through that system in reliance on consistent application of that 251 | system; it is up to the author/donor to decide if he or she is willing 252 | to distribute software through any other system and a licensee cannot 253 | impose that choice. 254 | 255 | This section is intended to make thoroughly clear what is believed to 256 | be a consequence of the rest of this License. 257 | 258 | 8. If the distribution and/or use of the Program is restricted in 259 | certain countries either by patents or by copyrighted interfaces, the 260 | original copyright holder who places the Program under this License 261 | may add an explicit geographical distribution limitation excluding 262 | those countries, so that distribution is permitted only in or among 263 | countries not thus excluded. In such case, this License incorporates 264 | the limitation as if written in the body of this License. 265 | 266 | 9. The Free Software Foundation may publish revised and/or new versions 267 | of the General Public License from time to time. Such new versions will 268 | be similar in spirit to the present version, but may differ in detail to 269 | address new problems or concerns. 270 | 271 | Each version is given a distinguishing version number. If the Program 272 | specifies a version number of this License which applies to it and "any 273 | later version", you have the option of following the terms and conditions 274 | either of that version or of any later version published by the Free 275 | Software Foundation. If the Program does not specify a version number of 276 | this License, you may choose any version ever published by the Free Software 277 | Foundation. 278 | 279 | 10. If you wish to incorporate parts of the Program into other free 280 | programs whose distribution conditions are different, write to the author 281 | to ask for permission. For software which is copyrighted by the Free 282 | Software Foundation, write to the Free Software Foundation; we sometimes 283 | make exceptions for this. Our decision will be guided by the two goals 284 | of preserving the free status of all derivatives of our free software and 285 | of promoting the sharing and reuse of software generally. 286 | 287 | NO WARRANTY 288 | 289 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 290 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 291 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 292 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 293 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 294 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 295 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 296 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 297 | REPAIR OR CORRECTION. 298 | 299 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 300 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 301 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 302 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 303 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 304 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 305 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 306 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 307 | POSSIBILITY OF SUCH DAMAGES. 308 | 309 | END OF TERMS AND CONDITIONS 310 | 311 | How to Apply These Terms to Your New Programs 312 | 313 | If you develop a new program, and you want it to be of the greatest 314 | possible use to the public, the best way to achieve this is to make it 315 | free software which everyone can redistribute and change under these terms. 316 | 317 | To do so, attach the following notices to the program. It is safest 318 | to attach them to the start of each source file to most effectively 319 | convey the exclusion of warranty; and each file should have at least 320 | the "copyright" line and a pointer to where the full notice is found. 321 | 322 | 323 | Copyright (C) 324 | 325 | This program is free software; you can redistribute it and/or modify 326 | it under the terms of the GNU General Public License as published by 327 | the Free Software Foundation; either version 2 of the License, or 328 | (at your option) any later version. 329 | 330 | This program is distributed in the hope that it will be useful, 331 | but WITHOUT ANY WARRANTY; without even the implied warranty of 332 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 333 | GNU General Public License for more details. 334 | 335 | You should have received a copy of the GNU General Public License along 336 | with this program; if not, write to the Free Software Foundation, Inc., 337 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 338 | 339 | Also add information on how to contact you by electronic and paper mail. 340 | 341 | If the program is interactive, make it output a short notice like this 342 | when it starts in an interactive mode: 343 | 344 | Gnomovision version 69, Copyright (C) year name of author 345 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 346 | This is free software, and you are welcome to redistribute it 347 | under certain conditions; type `show c' for details. 348 | 349 | The hypothetical commands `show w' and `show c' should show the appropriate 350 | parts of the General Public License. Of course, the commands you use may 351 | be called something other than `show w' and `show c'; they could even be 352 | mouse-clicks or menu items--whatever suits your program. 353 | 354 | You should also get your employer (if you work as a programmer) or your 355 | school, if any, to sign a "copyright disclaimer" for the program, if 356 | necessary. Here is a sample; alter the names: 357 | 358 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 359 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 360 | 361 | , 1 April 1989 362 | Ty Coon, President of Vice 363 | 364 | This General Public License does not permit incorporating your program into 365 | proprietary programs. If your program is a subroutine library, you may 366 | consider it more useful to permit linking proprietary applications with the 367 | library. If this is what you want to do, use the GNU Lesser General 368 | Public License instead of this License. 369 | 370 | WRITTEN OFFER 371 | 372 | The source code for any program binaries or compressed scripts that are 373 | included with Cavalcade can be freely obtained at the following URL: 374 | 375 | https://github.com/humanmade/Cavalcade 376 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 16 | 17 | 18 | 21 | 24 | 25 |
4 | Cavalcade
5 | A better wp-cron. Horizontally scalable, 6 | works perfectly with multisite. 7 |
9 | 10 | Build status 11 | 12 | 13 | Coverage via codecov.io 14 | 15 |
19 | A Human Made project. Maintained by @rmccue. 20 | 22 | 23 |
26 | 27 | Cavalcade is a scalable job system, designed as a drop-in replacement for 28 | WordPress's built-in pseudo-cron system. 29 | 30 | ![Flowchart of how Cavalcade works](http://i.imgur.com/nyTFDfR.png) 31 | 32 | From the WordPress side, none of your code needs to change. Cavalcade 33 | transparently integrates with the existing wp-cron functions to act as a full 34 | replacement. Cavalcade pushes these jobs off into their own database table for 35 | efficient storage. 36 | 37 | At the core of Cavalcade is the job runner. The runner is a daemon that 38 | supervises the entire system. The runner constantly checks the database for new 39 | jobs, and is responsible for spawning and managing workers to handle the jobs 40 | when they're ready. 41 | 42 | The runner spawns workers, which perform the actual tasks themselves. This is 43 | done by running a special WP-CLI command. 44 | 45 | 46 | ## Documentation 47 | 48 | **[View documentation →](https://github.com/humanmade/Cavalcade/tree/master/docs)** 49 | 50 | * [Motivation](docs/motivation.md) - Why Cavalcade? 51 | * [Installation](docs/install.md) 52 | * [Example Use Cases](docs/examples.md) 53 | * [Plugins](docs/plugins.md) - Extending the functionality of Cavalcade 54 | 55 | ## License 56 | 57 | Cavalcade is [licensed under the GPLv2 or later](LICENSE.txt). 58 | 59 | ## Who? 60 | 61 | Created by Human Made for high volume and large-scale sites, such as 62 | [Happytables](http://happytables.com/). We run Cavalcade on sites with millions 63 | of monthly page views, and thousands of sites, including 64 | [The Tab](http://thetab.com/), and the 65 | [United Influencers](http://unitedinfluencers.se/) network. 66 | 67 | Maintained by [Ryan McCue](https://github.com/rmccue). 68 | 69 | Interested in joining in on the fun? 70 | [Join us, and become human!](https://hmn.md/is/hiring/) 71 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "humanmade/cavalcade", 3 | "description": "A better wp-cron. Horizontally scalable, works perfectly with multisite.", 4 | "homepage": "https://github.com/humanmade/Cavalcade", 5 | "type": "wordpress-muplugin", 6 | "license": "GPL-2.0+", 7 | "authors": [ 8 | { 9 | "name": "Human Made", 10 | "homepage": "https://hmn.md/" 11 | } 12 | ], 13 | "require": { 14 | "composer/installers": "^1.0 || ^2.0" 15 | }, 16 | "require-dev": { 17 | "phpunit/phpunit": "^7.1 || ^9.0", 18 | "yoast/phpunit-polyfills": "^1.0" 19 | }, 20 | "config": { 21 | "allow-plugins": { 22 | "composer/installers": true 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Cavalcade Documentation 2 | 3 | Cavalcade is a scalable job system, designed as a drop-in replacement for WordPress's built-in pseudo-cron system. 4 | 5 | From the WordPress side, none of your code needs to change. Cavalcade transparently integrates with the existing wp-cron functions to act as a full replacement. Cavalcade pushes these jobs off into their own database table for efficient storage. 6 | 7 | At the core of Cavalcade is the job runner. The runner is a daemon that supervises the entire system. The runner constantly checks the database for new jobs, and is responsible for spawning and managing workers to handle the jobs when they're ready. 8 | 9 | The runner spawns workers, which perform the actual tasks themselves. This is done by running a special WP-CLI command. 10 | 11 | * [Motivation](motivation.md) - Why Cavalcade? 12 | * [Installation](install.md) 13 | * [Example Use Cases](examples.md) 14 | * [Plugins](plugins.md) - Extending the functionality of Cavalcade 15 | -------------------------------------------------------------------------------- /docs/examples.md: -------------------------------------------------------------------------------- 1 | # Example Use Cases 2 | 3 | With Cavalcade, WP's cron system becomes a first-class citizen. Here's some of the things that Cavalcade works great for. 4 | 5 | ## Newsletters 6 | 7 | Sites may want to send out a weekly newsletter to users. If these emails are highly customised (such as weekly metrics), this can lead to huge scaling issues. Particularly, if you have a multisite install, this means a lot of `switch_to_blog()` calls, which can be super expensive. 8 | 9 | You may be using something like this currently: 10 | 11 | ```php 12 | // Only run on primary site 13 | if ( is_main_site() ) { 14 | wp_schedule_event( time(), 'weekly', 'send_newsletter' ); 15 | } 16 | 17 | add_action( 'send_newsletter', function () { 18 | foreach ( wp_get_sites() as $site ) { 19 | // Prepare and send email 20 | switch_to_blog( $site->blog_id ); 21 | $email = prepare_email( $site ); 22 | $user = get_primary_user( $site ); 23 | send_email( $user, $content ); 24 | } 25 | }); 26 | ``` 27 | 28 | This will quickly lead to timeouts without any configuration in WordPress. However, even once you've moved cron tasks off to running via the command line, this will again hit an upper limit with memory, as WP and PHP both have memory leakage. 29 | 30 | With regular cron, this is mostly unavoidable, as multisite cron is almost impossible to offload to the command line safely. 31 | 32 | Cavalcade simplifies this by using a single daemon runner for all sites. When the event occurs and the job is run, WP-CLI is invoked to perform the job using the `--url` parameter directly. This avoids needing to switch sites and saves excessive database calls. 33 | 34 | ```php 35 | // Run on every site 36 | wp_schedule_event( time(), 'weekly', 'send_newsletter' ); 37 | 38 | add_action( 'send_newsletter', function () { 39 | // Prepare and send email 40 | $site = get_blog_details( get_current_blog_id() ); 41 | $email = prepare_email( $site ); 42 | $user = get_primary_user( $site ); 43 | send_email( $user, $content ); 44 | }); 45 | ``` 46 | 47 | Cavalcade's design ensures that the cron system can scale up to thousands of simultaneous tasks without breaking a sweat. 48 | 49 | 50 | ## Asynchronous Calls 51 | 52 | Often, you'll want to call some long-running code asynchronously. For example, sending email notifications on post publish is slow, so pushing this off to an asynchronous call avoids blocking the request. However, WP cron reliability issues have meant that typically it wasn't a valid choice. 53 | 54 | With Cavalcade, you can simply use `wp_schedule_single_event()` and forget worrying. These will scale up as you scale your servers horizontally, so you don't need to worry about another generic job queue or asynchronous processing utility. 55 | 56 | Let's send an email to all users when you publish a post: 57 | 58 | ```php 59 | add_action( 'wp_publish_post', function ( $post ) { 60 | wp_schedule_single_event( time(), 'send_notifications', $post ); 61 | }); 62 | 63 | add_action( 'send_notifications', function( $post ) { 64 | foreach ( get_users() as $user ) { 65 | send_notification( $user, $post ); 66 | } 67 | }); 68 | ``` 69 | 70 | Thanks to WP cron's arguments simply being serialized data, this can be adapted generically to any action: 71 | 72 | ```php 73 | function add_deferred_action( $hook, $callback, $priority, $num_args ) { 74 | add_action( $hook, function () { 75 | wp_schedule_single_event( time(), 'defer-' . $hook, func_get_args() ); 76 | }, $priority, $num_args ); 77 | add_action( 'defer-' . $hook, $callback ); 78 | } 79 | 80 | // Then to use it, just replace your existing call... 81 | # add_action( 'wp_publish_post', 'expensive_task_on_publish', 20, 2 ); 82 | // with the deferred one: 83 | add_deferred_action( 'wp_publish_post', 'expensive_task_on_publish', 20, 2 ); 84 | ``` 85 | 86 | As long as your callback doesn't rely on global state (apart from the current site), this is a quick-and-easy way to run expensive tasks. 87 | 88 | 89 | ## Get in touch! 90 | 91 | Got a cool use case you solved using Cavalcade? [Let us know](https://github.com/humanmade/Cavalcade/issues/new)! 92 | -------------------------------------------------------------------------------- /docs/install.md: -------------------------------------------------------------------------------- 1 | # Installing Cavalcade 2 | 3 | Cavalcade requires a little bit of setup, and is not recommended for the faint of heart. Keep in mind that it is an incredibly powerful system designed for high traffic, large installs. Don't install it on every site just for fun. 4 | 5 | Installing Cavalcade is a two step process. The Cavalcade plugin needs to be added to your WordPress install, and the Cavalcade Runner daemon needs to be installed as a system-level service. 6 | 7 | ### WordPress Plugin 8 | 9 | Clone or submodule this repository into your `mu-plugins` directory, and load it as an MU plugin. For example, create `mu-plugins/cavalcade.php` with the following code: 10 | 11 | ```php 12 | require_once __DIR__ . '/cavalcade/plugin.php'; 13 | ``` 14 | 15 | 16 | To start using it in your code, don't change anything. Simply use the normal wp-cron functions, such as `wp_schedule_event`, `wp_schedule_single_event` and `wp_next_scheduled`. Cavalcade integrates seamlessly into these, and the first events you see appear in your jobs table will likely be WP's normal core events such as update checks. 17 | 18 | You'll also want to disable the built in WordPress cron in `wp-config.php`: 19 | 20 | ```php 21 | define( 'DISABLE_WP_CRON', true ); 22 | ``` 23 | 24 | ### Runner 25 | 26 | This is the more complex part. [Grab the Cavalcade runner from GitHub][runner] and run it. The first parameter passed to Cavalcade should be the relative path to your WordPress install (i.e. to the directory where your `wp-config.php` is). By default, this will use the current working directory; useful if you make `cavalcade` available in your path. 27 | 28 | [runner]: https://github.com/humanmade/Cavalcade-Runner 29 | 30 | The runner will remain in the foreground by itself; use your normal system daemonisation tools, or `nohup` with `&` to run it in the background. We recommend: 31 | 32 | ```sh 33 | nohup bin/cavalcade > /var/log/cavalcade.log & 34 | ``` 35 | 36 | (Cavalcade outputs all relevant logging information to stdout, and only sends meta-information such as shutdown notices to stderr.) 37 | 38 | Note: The runner has three additional requirements: 39 | 40 | * **pcntl** - The [Process Control PHP extension](http://php.net/pcntl) must be installed. Cavalcade Runner uses this to spawn worker processes and keep monitor them. 41 | * **pdo**/**pdo-mysql** - Unlike WordPress, Cavalcade-Runner uses PDO to connect to the database. 42 | * **wp-cli** - wp-cli must be installed on your server and available in the PATH. Cavalcade Runner internally calls `wp cavalcade run ` to run the jobs. 43 | 44 | The runner is an independent piece of Cavalcade, so writing your own runner is possible if you have alternative requirements. 45 | 46 | If you're using Upstart (Ubuntu 12.04, 14.04) or systemd (Ubuntu 16.04+), we recommend using one of the existing service scripts included with Cavalcade-Runner. 47 | 48 | Note that while the Runner does not require system-level user access (such as a root account), we don’t recommend using it on systems you don’t control (such as shared hosting). 49 | 50 | ### Usage 51 | 52 | You can manually manage jobs via the `wp cavalcade` command, run `wp help cavalcade` for full documentation. 53 | -------------------------------------------------------------------------------- /docs/motivation.md: -------------------------------------------------------------------------------- 1 | # Why Cavalcade? 2 | 3 | We created Cavalcade to serve our needs, as none of the existing options for scheduled tasks in WordPress was a good fit. Here's why. 4 | 5 | ### Guaranteed Running 6 | 7 | wp-cron is not actually a real task scheduler, and doesn't actually operate like cron. Instead, it's a pseudo-cron system, which is run as a loopback HTTP call when you access a page on the site (essentially, the page "forks" itself to run scheduled tasks). 8 | 9 | This is fine for high traffic single-sites, but lower traffic sites might not have their cron activated if the site isn't viewed. There are workarounds for this, but they typically don't allow second-level granulaity or don't work with multisite. 10 | 11 | ### Designed for Multisite 12 | 13 | wp-cron was originally designed for single sites, and has had multisite grafted on to it. For large multisite installations, this simply doesn't scale. One of the tricks to ensure wp-cron runs is to ping a page on the site in a real cron task, but this needs to be done once-per-site. 14 | 15 | Cavalcade however contains full support from the ground up for multisite. Firstly, rather than storing tasks per-site, they're stored all together with the site ID as part of the data. This ensures that all sites are treated the same, regardless of traffic. 16 | 17 | Secondly, workers are localised to sites when they're started (via WP-CLI's `--url` argument), allowing per-site plugins and themes to be loaded properly. Since it starts with this data, it also runs through the normal sunrise process, removing the need for complicated switches and conditionals in your code. 18 | 19 | ### Horizontally Scalable 20 | 21 | One of the best ways of handling high traffic sites is to horizontally scale your WordPress install. This involves having multiple application servers running WordPress, and a load-balancer to spread out the requests. However, traditional wp-cron cannot handle this due to the above limitations. 22 | 23 | Cavalcade is designed to be inherently parallel. When you horizontally scale your servers, simply have one runner per server. This means that as you scale your site and server stack, Cavalcade will scale with you. 24 | 25 | ### Parallel Processing 26 | 27 | Typically, wp-cron runs every scheduled event in a loop giving you **sequential** processing of your tasks. If you have a long-running task, this will block processing of other events, as wp-cron uses a global lock. 28 | 29 | Cavalcade instead uses one-lock-per-task, allowing **parallel** processing of tasks instead. By default, Cavalcade uses four worker processes to run your tasks, however, this is configurable. This means that if you have a long-running task, it will continue to execute in the background while the runner continues to process the rest of the remaining tasks. 30 | 31 | ### Status Monitoring 32 | 33 | Unlike wp-cron, which simply runs an action and forgets about it, Cavalcade monitors the status of your tasks. If you have a fatal error, Cavalcade will log the failure in the database, and automatically pause that event from running in the future. If you want to restart it, the schedule will be resumed, and will continue running on schedule. (For example, if you have an event run on Mondays and it fails, restarting it will continue to run it on Mondays.) 34 | -------------------------------------------------------------------------------- /docs/plugins.md: -------------------------------------------------------------------------------- 1 | # Plugins 2 | 3 | Cavalcade is fully extensible. For additional functionality for the plugin side of Cavalcade, you can use the existing hooks and system in WordPress. 4 | 5 | Plugins in Cavalcade Runner work a little differently. While most functionality can be handled in WordPress, meta-level reporting and logging of jobs is best done in the Runner. 6 | 7 | Since the Runner is a separate, non-WordPress daemon, it includes its own plugin system. This system will be familiar to anyone who has written a WordPress plugin before. 8 | 9 | ## Writing a Plugin 10 | 11 | The only file Cavalcade loads from your project is `wp-config.php`, so all plugin code for Cavalcade needs to be registered before your `require 'wp-settings.php'` line. 12 | 13 | To add a hook, call `HM\Cavalcade\Runner::instance()->hooks->register()`. This function is almost identical to the `add_filter()` function in WordPress: 14 | 15 | ```php 16 | /** 17 | * Register a callback for a hook. 18 | * 19 | * @param string $hook Hook to register callback for. 20 | * @param callable $callback Function to call when hook is triggered. 21 | * @param int $priority Priority to register at. 22 | */ 23 | public function register( $hook, $callback, $priority = 10 ); 24 | ``` 25 | 26 | ## Hook Naming 27 | 28 | The best place to find hooks to use is to read the source code directly. 29 | 30 | Hooks are named `Class.method.action`, where Class is the class name excluding the `HM\Cavalcade\Runner`, and with `\` replaced with `.`. This ensures you know exactly where a hook is defined. 31 | 32 | ## Adding Your Own Hooks 33 | 34 | You can add your own hooks to your plugins, if you want to allow others to extend them: 35 | 36 | ```php 37 | /** 38 | * Run a hook's callbacks. 39 | * 40 | * @param string $hook Hook to run. 41 | * @param mixed $value Main value to pass. 42 | * @param mixed ...$args Other arguments to pass. 43 | * @return mixed Filtered value after running through callbacks. 44 | */ 45 | public function run( $hook, $value = null, ...$args ); 46 | ``` 47 | -------------------------------------------------------------------------------- /inc/class-command.php: -------------------------------------------------------------------------------- 1 | 18 | * : ID of the job to run. 19 | * 20 | * @synopsis 21 | */ 22 | public function run( $args, $assoc_args ) { 23 | $job = Job::get( $args[0] ); 24 | if ( empty( $job ) ) { 25 | WP_CLI::error( 'Invalid job ID' ); 26 | } 27 | // Make the current job id available for hooks run by this job 28 | define( 'CAVALCADE_JOB_ID', $job->id ); 29 | 30 | // Handle SIGTERM calls as we don't want to kill a running job 31 | pcntl_signal( SIGTERM, SIG_IGN ); 32 | 33 | // Set the wp-cron constant for plugin and theme interactions 34 | defined( 'DOING_CRON' ) or define( 'DOING_CRON', true ); 35 | 36 | /** 37 | * Fires scheduled events. 38 | * 39 | * @ignore 40 | * 41 | * @param string $hook Name of the hook that was scheduled to be fired. 42 | * @param array $args The arguments to be passed to the hook. 43 | */ 44 | do_action_ref_array( $job->hook, $job->args ); 45 | } 46 | 47 | /** 48 | * Show logs on completed jobs 49 | * 50 | * @synopsis [--format=] [--fields=] [--job=] [--hook=] 51 | */ 52 | public function log( $args, $assoc_args ) { 53 | 54 | global $wpdb; 55 | 56 | $log_table = $wpdb->base_prefix . 'cavalcade_logs'; 57 | $job_table = $wpdb->base_prefix . 'cavalcade_jobs'; 58 | 59 | $assoc_args = wp_parse_args( $assoc_args, [ 60 | 'format' => 'table', 61 | 'fields' => 'job,hook,timestamp,status', 62 | 'hook' => null, 63 | 'job' => null, 64 | ]); 65 | 66 | $where = []; 67 | $data = []; 68 | 69 | if ( $assoc_args['job'] ) { 70 | $where[] = 'job = %d'; 71 | $data[] = $assoc_args['job']; 72 | } 73 | 74 | if ( $assoc_args['hook'] ) { 75 | $where[] = 'hook = %s'; 76 | $data[] = $assoc_args['hook']; 77 | } 78 | 79 | $where = $where ? 'WHERE ' . implode( ' AND ', $where ) : ''; 80 | 81 | $query = "SELECT $log_table.*, $job_table.hook,$job_table.args FROM {$wpdb->base_prefix}cavalcade_logs INNER JOIN $job_table ON $log_table.job = $job_table.id $where"; 82 | 83 | if ( $data ) { 84 | $query = $wpdb->prepare( $query, $data ); 85 | } 86 | 87 | $logs = $wpdb->get_results( $query ); 88 | 89 | \WP_CLI\Utils\format_items( $assoc_args['format'], $logs, explode( ',', $assoc_args['fields'] ) ); 90 | } 91 | 92 | /** 93 | * Show jobs. 94 | * 95 | * @synopsis [--format=] [--id=] [--site=] [--hook=] [--status=] [--limit=] [--page=] [--order=] [--orderby=] 96 | */ 97 | public function jobs( $args, $assoc_args ) { 98 | 99 | global $wpdb; 100 | 101 | $assoc_args = wp_parse_args( 102 | $assoc_args, 103 | [ 104 | 'format' => 'table', 105 | 'fields' => 'id,site,hook,start,nextrun,status', 106 | 'id' => null, 107 | 'site' => null, 108 | 'hook' => null, 109 | 'status' => null, 110 | 'limit' => 20, 111 | 'page' => 1, 112 | 'order' => null, 113 | 'orderby' => null, 114 | ] 115 | ); 116 | 117 | $where = []; 118 | $data = []; 119 | $_order = [ 120 | 'ASC', 121 | 'DESC', 122 | ]; 123 | $_orderby = [ 124 | 'id', 125 | 'site', 126 | 'hook', 127 | 'args', 128 | 'start', 129 | 'nextrun', 130 | 'interval', 131 | 'status', 132 | ]; 133 | $order = 'DESC'; 134 | $orderby = 'id'; 135 | 136 | if ( $assoc_args['id'] ) { 137 | $where[] = 'id = %d'; 138 | $data[] = $assoc_args['id']; 139 | } 140 | 141 | if ( $assoc_args['site'] ) { 142 | $where[] = 'site = %d'; 143 | $data[] = $assoc_args['site']; 144 | } 145 | 146 | if ( $assoc_args['hook'] ) { 147 | $where[] = 'hook = %s'; 148 | $data[] = $assoc_args['hook']; 149 | } 150 | 151 | if ( $assoc_args['status'] ) { 152 | $where[] = 'status = %s'; 153 | $data[] = $assoc_args['status']; 154 | } 155 | 156 | if ( $assoc_args['order'] && in_array( strtoupper( $assoc_args['order'] ), $_order, true ) ) { 157 | $order = strtoupper( $assoc_args['order'] ); 158 | } 159 | 160 | if ( $assoc_args['orderby'] && in_array( $assoc_args['orderby'], $_orderby, true ) ) { 161 | $orderby = $assoc_args['orderby']; 162 | } 163 | 164 | $where = $where ? 'WHERE ' . implode( ' AND ', $where ) : ''; 165 | 166 | $limit = 'LIMIT %d'; 167 | $data[] = absint( $assoc_args['limit'] ); 168 | $offset = 'OFFSET %d'; 169 | $data[] = absint( ( $assoc_args['page'] - 1 ) * $assoc_args['limit'] ); 170 | 171 | $query = "SELECT * FROM {$wpdb->base_prefix}cavalcade_jobs $where ORDER BY $orderby $order $limit $offset"; 172 | 173 | if ( $data ) { 174 | $query = $wpdb->prepare( $query, $data ); 175 | } 176 | 177 | $logs = $wpdb->get_results( $query ); 178 | 179 | if ( empty( $logs ) ) { 180 | \WP_CLI::error( 'No Cavalcade jobs found.' ); 181 | } else { 182 | \WP_CLI\Utils\format_items( $assoc_args['format'], $logs, explode( ',', $assoc_args['fields'] ) ); 183 | } 184 | 185 | } 186 | 187 | /** 188 | * Upgrade to the latest database schema. 189 | */ 190 | public function upgrade() { 191 | if ( Upgrade\upgrade_database() ) { 192 | WP_CLI::success( 'Database version upgraded.' ); 193 | return; 194 | } 195 | 196 | WP_CLI::success( 'Database upgrade not required.' ); 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /inc/class-job.php: -------------------------------------------------------------------------------- 1 | id = $id; 23 | } 24 | 25 | /** 26 | * Has this job been created yet? 27 | * 28 | * @return boolean 29 | */ 30 | public function is_created() { 31 | return (bool) $this->id; 32 | } 33 | 34 | /** 35 | * Is this a recurring job? 36 | * 37 | * @return boolean 38 | */ 39 | public function is_recurring() { 40 | return ! empty( $this->interval ); 41 | } 42 | 43 | public function save() { 44 | global $wpdb; 45 | 46 | $data = [ 47 | 'hook' => $this->hook, 48 | 'site' => $this->site, 49 | 'start' => gmdate( DATE_FORMAT, $this->start ), 50 | 'nextrun' => gmdate( DATE_FORMAT, $this->nextrun ), 51 | 'args' => serialize( $this->args ), 52 | ]; 53 | 54 | if ( $this->is_recurring() ) { 55 | $data['interval'] = $this->interval; 56 | if ( get_database_version() >= 2 ) { 57 | $data['schedule'] = $this->schedule; 58 | } 59 | } 60 | 61 | if ( $this->is_created() ) { 62 | $where = [ 63 | 'id' => $this->id, 64 | ]; 65 | $result = $wpdb->update( $this->get_table(), $data, $where, $this->row_format( $data ), $this->row_format( $where ) ); 66 | } else { 67 | $result = $wpdb->insert( $this->get_table(), $data, $this->row_format( $data ) ); 68 | $this->id = $wpdb->insert_id; 69 | } 70 | 71 | self::flush_query_cache(); 72 | wp_cache_set( "job::{$this->id}", $this, 'cavalcade-jobs' ); 73 | return (bool) $result; 74 | } 75 | 76 | public function delete( $options = [] ) { 77 | global $wpdb; 78 | $wpdb->show_errors(); 79 | 80 | $defaults = [ 81 | 'delete_running' => false, 82 | ]; 83 | $options = wp_parse_args( $options, $defaults ); 84 | 85 | if ( $this->status === 'running' && ! $options['delete_running'] ) { 86 | return new WP_Error( 'cavalcade.job.delete.still_running', __( 'Cannot delete running jobs', 'cavalcade' ) ); 87 | } 88 | 89 | $where = [ 90 | 'id' => $this->id, 91 | ]; 92 | $result = $wpdb->delete( $this->get_table(), $where, $this->row_format( $where ) ); 93 | 94 | self::flush_query_cache(); 95 | wp_cache_delete( "job::{$this->id}", 'cavalcade-jobs' ); 96 | 97 | return (bool) $result; 98 | } 99 | 100 | public static function get_table() { 101 | global $wpdb; 102 | return $wpdb->base_prefix . 'cavalcade_jobs'; 103 | } 104 | 105 | /** 106 | * Convert row data to Job instance 107 | * 108 | * @param stdClass $row Raw job data from the database. 109 | * @return Job 110 | */ 111 | protected static function to_instance( $row ) { 112 | $job = new Job( $row->id ); 113 | 114 | // Populate the object with row values 115 | $job->site = $row->site; 116 | $job->hook = $row->hook; 117 | $job->args = unserialize( $row->args ); 118 | $job->start = mysql2date( 'G', $row->start ); 119 | $job->nextrun = mysql2date( 'G', $row->nextrun ); 120 | $job->interval = $row->interval; 121 | $job->status = $row->status; 122 | 123 | if ( ! $row->interval ) { 124 | // One off event. 125 | $job->schedule = false; 126 | } elseif ( ! empty( $row->schedule ) ) { 127 | $job->schedule = $row->schedule; 128 | } else { 129 | $job->schedule = get_schedule_by_interval( $row->interval ); 130 | } 131 | 132 | wp_cache_set( "job::{$job->id}", $job, 'cavalcade-jobs' ); 133 | return $job; 134 | } 135 | 136 | /** 137 | * Convert list of data to Job instances 138 | * 139 | * @param stdClass[] $rows Raw mapping rows 140 | * @return Job[] 141 | */ 142 | protected static function to_instances( $rows ) { 143 | return array_map( [ get_called_class(), 'to_instance' ], $rows ); 144 | } 145 | 146 | /** 147 | * Get job by job ID 148 | * 149 | * @param int|Job $job Job ID or instance 150 | * @return Job|WP_Error|null Job on success, WP_Error if error occurred, or null if no job found 151 | */ 152 | public static function get( $job ) { 153 | global $wpdb; 154 | 155 | if ( $job instanceof Job ) { 156 | return $job; 157 | } 158 | 159 | $job = absint( $job ); 160 | 161 | $cached_job = wp_cache_get( "job::{$job}", 'cavalcade-jobs' ); 162 | if ( $cached_job ) { 163 | return $cached_job; 164 | } 165 | 166 | $suppress = $wpdb->suppress_errors(); 167 | $job = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM ' . static::get_table() . ' WHERE id = %d', $job ) ); 168 | $wpdb->suppress_errors( $suppress ); 169 | 170 | if ( ! $job ) { 171 | return null; 172 | } 173 | 174 | return static::to_instance( $job ); 175 | } 176 | 177 | /** 178 | * Get jobs by site ID 179 | * 180 | * @param int|stdClass $site Site ID, or site object from {@see get_blog_details} 181 | * @param bool $include_completed Should we include completed jobs? 182 | * @param bool $include_failed Should we include failed jobs? 183 | * @param bool $exclude_future Should we exclude future (not ready) jobs? 184 | * @return Job[]|WP_Error Jobs on success, error otherwise. 185 | */ 186 | public static function get_by_site( $site, $include_completed = false, $include_failed = false, $exclude_future = false ) { 187 | 188 | // Allow passing a site object in 189 | if ( is_object( $site ) && isset( $site->blog_id ) ) { 190 | $site = $site->blog_id; 191 | } 192 | 193 | if ( ! is_numeric( $site ) ) { 194 | return new WP_Error( 'cavalcade.job.invalid_site_id' ); 195 | } 196 | 197 | $args = [ 198 | 'site' => $site, 199 | 'args' => null, 200 | 'statuses' => [ 'waiting', 'running' ], 201 | 'limit' => 0, 202 | '__raw' => true, 203 | ]; 204 | 205 | if ( $include_completed ) { 206 | $args['statuses'][] = 'completed'; 207 | } 208 | if ( $include_failed ) { 209 | $args['statuses'][] = 'failed'; 210 | } 211 | if ( $exclude_future ) { 212 | $args['timestamp'] = 'past'; 213 | } 214 | 215 | $results = static::get_jobs_by_query( $args ); 216 | 217 | if ( empty( $results ) ) { 218 | return []; 219 | } 220 | 221 | return static::to_instances( $results ); 222 | } 223 | 224 | /** 225 | * Query jobs database. 226 | * 227 | * Returns an array of Job instances for the current site based 228 | * on the paramaters. 229 | * 230 | * @todo: allow searching within time window for duplicate events. 231 | * 232 | * @param array|\stdClass $args { 233 | * @param string $hook Jobs hook to return. Optional. 234 | * @param int|string|null $timestamp Timestamp to search for. Optional. 235 | * String shortcuts `future`: > NOW(); `past`: <= NOW() 236 | * @param array $args Cron job arguments. 237 | * @param int|object $site Site to query. Default current site. 238 | * @param array $statuses Job statuses to query. Default to waiting and running. 239 | * @param int $limit Max number of jobs to return. Default 1. 240 | * @param string $order ASC or DESC. Default ASC. 241 | * } 242 | * @return Job[]|WP_Error Jobs on success, error otherwise. 243 | */ 244 | public static function get_jobs_by_query( $args = [] ) { 245 | global $wpdb; 246 | $args = (array) $args; 247 | $results = []; 248 | 249 | $defaults = [ 250 | 'timestamp' => null, 251 | 'hook' => null, 252 | 'args' => [], 253 | 'site' => get_current_blog_id(), 254 | 'statuses' => [ 'waiting', 'running' ], 255 | 'limit' => 1, 256 | 'order' => 'ASC', 257 | '__raw' => false, 258 | ]; 259 | 260 | $args = wp_parse_args( $args, $defaults ); 261 | 262 | /** 263 | * Filters the get_jobs_by_query() arguments. 264 | * 265 | * An example use case would be to enforce limits on the number of results 266 | * returned if you run into performance problems. 267 | * 268 | * @param array $args { 269 | * @param string $hook Jobs hook to return. Optional. 270 | * @param int|string|array $timestamp Timestamp to search for. Optional. 271 | * String shortcuts `future`: > NOW(); `past`: <= NOW() 272 | * Array of 2 time stamps will search between those dates. 273 | * @param array $args Cron job arguments. 274 | * @param int|object $site Site to query. Default current site. 275 | * @param array $statuses Job statuses to query. Default to waiting and running. 276 | * Possible values are 'waiting', 'running', 'completed' and 'failed'. 277 | * @param int $limit Max number of jobs to return. Default 1. 278 | * @param string $order ASC or DESC. Default ASC. 279 | * @param bool $__raw If true return the raw array of data rather than Job objects. 280 | * } 281 | */ 282 | $args = apply_filters( 'cavalcade.get_jobs_by_query.args', $args ); 283 | 284 | // Allow passing a site object in 285 | if ( is_object( $args['site'] ) && isset( $args['site']->blog_id ) ) { 286 | $args['site'] = $args['site']->blog_id; 287 | } 288 | 289 | if ( ! is_numeric( $args['site'] ) ) { 290 | return new WP_Error( 'cavalcade.job.invalid_site_id' ); 291 | } 292 | 293 | if ( ! empty( $args['hook'] ) && ! is_string( $args['hook'] ) ) { 294 | return new WP_Error( 'cavalcade.job.invalid_hook_name' ); 295 | } 296 | 297 | if ( ! is_array( $args['args'] ) && ! is_null( $args['args'] ) ) { 298 | return new WP_Error( 'cavalcade.job.invalid_event_arguments' ); 299 | } 300 | 301 | if ( ! is_numeric( $args['limit'] ) ) { 302 | return new WP_Error( 'cavalcade.job.invalid_limit' ); 303 | } 304 | 305 | $args['limit'] = absint( $args['limit'] ); 306 | 307 | // Find all scheduled events for this site 308 | $table = static::get_table(); 309 | 310 | $sql = "SELECT * FROM `{$table}` WHERE site = %d"; 311 | $sql_params[] = $args['site']; 312 | 313 | if ( is_string( $args['hook'] ) ) { 314 | $sql .= ' AND hook = %s'; 315 | $sql_params[] = $args['hook']; 316 | } 317 | 318 | if ( ! is_null( $args['args'] ) ) { 319 | $sql .= ' AND args = %s'; 320 | $sql_params[] = serialize( $args['args'] ); 321 | } 322 | 323 | // Timestamp 'future' shortcut. 324 | if ( $args['timestamp'] === 'future' ) { 325 | $sql .= " AND nextrun > %s"; 326 | $sql_params[] = date( DATE_FORMAT ); 327 | } 328 | 329 | // Timestamp past shortcut. 330 | if ( $args['timestamp'] === 'past' ) { 331 | $sql .= " AND nextrun <= %s"; 332 | $sql_params[] = date( DATE_FORMAT ); 333 | } 334 | 335 | // Timestamp array range. 336 | if ( is_array( $args['timestamp'] ) && count( $args['timestamp'] ) === 2 ) { 337 | $sql .= ' AND nextrun BETWEEN %s AND %s'; 338 | $sql_params[] = date( DATE_FORMAT, (int) $args['timestamp'][0] ); 339 | $sql_params[] = date( DATE_FORMAT, (int) $args['timestamp'][1] ); 340 | } 341 | 342 | // Default integer timestamp. 343 | if ( is_int( $args['timestamp'] ) ) { 344 | $sql .= ' AND nextrun = %s'; 345 | $sql_params[] = date( DATE_FORMAT, (int) $args['timestamp'] ); 346 | } 347 | 348 | $sql .= ' AND status IN(' . implode( ',', array_fill( 0, count( $args['statuses'] ), '%s' ) ) . ')'; 349 | $sql_params = array_merge( $sql_params, $args['statuses'] ); 350 | 351 | $sql .= ' ORDER BY nextrun'; 352 | if ( $args['order'] === 'DESC' ) { 353 | $sql .= ' DESC'; 354 | } else { 355 | $sql .= ' ASC'; 356 | } 357 | 358 | if ( $args['limit'] > 0 ) { 359 | $sql .= ' LIMIT %d'; 360 | $sql_params[] = $args['limit']; 361 | } 362 | 363 | // Cache results. 364 | $last_changed = wp_cache_get_last_changed( 'cavalcade-jobs' ); 365 | $query_hash = sha1( serialize( [ $sql, $sql_params ] ) ) . "::{$last_changed}"; 366 | $results = wp_cache_get( "jobs::{$query_hash}", 'cavalcade-jobs' ); 367 | 368 | if ( false === $results ) { 369 | $query = $wpdb->prepare( $sql, $sql_params ); 370 | $results = $wpdb->get_results( $query ); 371 | wp_cache_set( "jobs::{$query_hash}", $results, 'cavalcade-jobs' ); 372 | } 373 | 374 | if ( $args['__raw'] === true ) { 375 | return $results; 376 | } 377 | 378 | return static::to_instances( $results ); 379 | } 380 | 381 | /** 382 | * Invalidates existing query cache keys by updating last changed time. 383 | */ 384 | public static function flush_query_cache() { 385 | wp_cache_set( 'last_changed', microtime(), 'cavalcade-jobs' ); 386 | } 387 | 388 | /** 389 | * Get the (printf-style) format for a given column. 390 | * 391 | * @param string $column Column to retrieve format for. 392 | * @return string Format specifier. Defaults to '%s' 393 | */ 394 | protected static function column_format( $column ) { 395 | $columns = [ 396 | 'id' => '%d', 397 | 'site' => '%d', 398 | 'hook' => '%s', 399 | 'args' => '%s', 400 | 'start' => '%s', 401 | 'nextrun' => '%s', 402 | 'interval' => '%d', 403 | 'schedule' => '%s', 404 | 'status' => '%s', 405 | ]; 406 | 407 | if ( isset( $columns[ $column ] ) ) { 408 | return $columns[ $column ]; 409 | } 410 | 411 | return '%s'; 412 | } 413 | 414 | /** 415 | * Get the (printf-style) formats for an entire row. 416 | * 417 | * @param array $row Map of field to value. 418 | * @return array List of formats for fields in the row. Order matches the input order. 419 | */ 420 | protected static function row_format( $row ) { 421 | $format = []; 422 | foreach ( $row as $field => $value ) { 423 | $format[] = static::column_format( $field ); 424 | } 425 | return $format; 426 | } 427 | } 428 | -------------------------------------------------------------------------------- /inc/connector/namespace.php: -------------------------------------------------------------------------------- 1 | $event->hook, 54 | 'timestamp' => $event->timestamp, 55 | 'args' => $event->args, 56 | ]; 57 | 58 | if ( $event->schedule === false ) { 59 | // Search ten minute range to test for duplicate events. 60 | if ( $event->timestamp < time() + 10 * MINUTE_IN_SECONDS ) { 61 | $min_timestamp = 0; 62 | } else { 63 | $min_timestamp = $event->timestamp - 10 * MINUTE_IN_SECONDS; 64 | } 65 | 66 | if ( $event->timestamp < time() ) { 67 | $max_timestamp = time() + 10 * MINUTE_IN_SECONDS; 68 | } else { 69 | $max_timestamp = $event->timestamp + 10 * MINUTE_IN_SECONDS; 70 | } 71 | 72 | $query['timestamp'] = [ 73 | $min_timestamp, 74 | $max_timestamp, 75 | ]; 76 | } 77 | 78 | $jobs = Job::get_jobs_by_query( $query ); 79 | if ( is_wp_error( $jobs ) ) { 80 | if ( $wp_error ) { 81 | return $jobs; 82 | } 83 | 84 | return false; 85 | } 86 | 87 | // The job does not exist. 88 | if ( empty( $jobs ) ) { 89 | /** This filter is documented in wordpress/wp-includes/cron.php */ 90 | $event = apply_filters( 'schedule_event', $event ); 91 | 92 | // A plugin disallowed this event. 93 | if ( ! $event ) { 94 | if ( $wp_error ) { 95 | return new WP_Error( 96 | 'schedule_event_false', 97 | __( 'A plugin disallowed this event.' ) 98 | ); 99 | } 100 | return false; 101 | } 102 | 103 | return schedule_event( $event, $wp_error ); 104 | } 105 | 106 | // The job exists. 107 | $existing = $jobs[0]; 108 | 109 | $schedule_match = Cavalcade\get_database_version() >= 2 && $existing->schedule === $event->schedule; 110 | 111 | if ( $schedule_match && $existing->interval === null && ! isset( $event->interval ) ) { 112 | // Unchanged or duplicate single event. 113 | if ( $wp_error ) { 114 | return new WP_Error( 115 | 'duplicate_event', 116 | __( 'A duplicate event already exists.' ) 117 | ); 118 | } 119 | return false; 120 | } elseif ( $schedule_match && $existing->interval === $event->interval ) { 121 | // Unchanged recurring event. 122 | if ( $wp_error ) { 123 | return new WP_Error( 124 | 'duplicate_event', 125 | __( 'A duplicate event already exists.' ) 126 | ); 127 | } 128 | return false; 129 | } else { 130 | // Event has changed. Update it. 131 | if ( Cavalcade\get_database_version() >= 2 ) { 132 | $existing->schedule = $event->schedule; 133 | } 134 | if ( isset( $event->interval ) ) { 135 | $existing->interval = $event->interval; 136 | } else { 137 | $existing->interval = null; 138 | } 139 | $existing->save(); 140 | return true; 141 | } 142 | } 143 | 144 | /** 145 | * Reschedules a recurring event. 146 | * 147 | * Note: The Cavalcade reschedule behaviour is intentionally different to WordPress's. 148 | * To avoid drift of cron schedules, Cavalcade adds the interval to the next scheduled 149 | * run time without checking if this time is in the past. 150 | * 151 | * To ensure the next run time is in the future, it is recommended you delete and reschedule 152 | * a job. 153 | * 154 | * @param null|bool|WP_Error $pre Value to return instead. Default null to continue adding the event. 155 | * @param stdClass $event { 156 | * An object containing an event's data. 157 | * 158 | * @type string $hook Action hook to execute when the event is run. 159 | * @type int $timestamp Unix timestamp (UTC) for when to next run the event. 160 | * @type string|false $schedule How often the event should subsequently recur. 161 | * @type array $args Array containing each separate argument to pass to the hook's callback function. 162 | * @type int $interval The interval time in seconds for the schedule. Only present for recurring events. 163 | * } 164 | * @param bool $wp_error Optional. Whether to return a WP_Error on failure. Default false. 165 | * @return bool|WP_Error True if event successfully rescheduled. False or WP_Error for failure. 166 | */ 167 | function pre_reschedule_event( $pre, $event, $wp_error = false ) { 168 | // Allow other filters to do their thing. 169 | if ( $pre !== null ) { 170 | return $pre; 171 | } 172 | 173 | $schedules = wp_get_schedules(); 174 | 175 | if ( ! isset( $schedules[ $event->schedule ] ) ) { 176 | if ( $wp_error ) { 177 | return new WP_Error( 178 | 'invalid_schedule', 179 | __( 'Event schedule does not exist.' ) 180 | ); 181 | } 182 | 183 | return false; 184 | } 185 | 186 | // First check if the job exists already. 187 | $jobs = Job::get_jobs_by_query( [ 188 | 'hook' => $event->hook, 189 | 'timestamp' => $event->timestamp, 190 | 'args' => $event->args, 191 | ] ); 192 | 193 | if ( is_wp_error( $jobs ) || empty( $jobs ) ) { 194 | // The job does not exist. 195 | if ( $wp_error ) { 196 | if ( ! is_wp_error( $jobs ) ) { 197 | $jobs = new WP_Error( 198 | 'invalid_event', 199 | __( 'Event does not exist.', 'cavalcade' ) 200 | ); 201 | } 202 | return $jobs; 203 | } 204 | return false; 205 | } 206 | 207 | $job = $jobs[0]; 208 | 209 | // Now we assume something is wrong (single job?) and fail to reschedule 210 | if ( 0 === $event->interval && 0 === $job->interval ) { 211 | if ( $wp_error ) { 212 | return new WP_Error( 213 | 'invalid_schedule', 214 | __( 'Event schedule does not exist.' ) 215 | ); 216 | } 217 | return false; 218 | } 219 | 220 | $job->nextrun = $job->nextrun + $event->interval; 221 | $job->interval = $event->interval; 222 | $job->schedule = $event->schedule; 223 | $job->save(); 224 | 225 | // Rescheduled. 226 | return true; 227 | } 228 | 229 | /** 230 | * Unschedule a previously scheduled event. 231 | * 232 | * The $timestamp and $hook parameters are required so that the event can be 233 | * identified. 234 | * 235 | * @param null|bool|WP_Error $pre Value to return instead. Default null to continue unscheduling the event. 236 | * @param int $timestamp Timestamp for when to run the event. 237 | * @param string $hook Action hook, the execution of which will be unscheduled. 238 | * @param array $args Arguments to pass to the hook's callback function. 239 | * @param bool $wp_error Optional. Whether to return a WP_Error on failure. Default false. 240 | * @return null|bool|WP_Error True if event successfully unscheduled. False or WP_Error for failure. 241 | */ 242 | function pre_unschedule_event( $pre, $timestamp, $hook, $args, $wp_error = false ) { 243 | // Allow other filters to do their thing. 244 | if ( $pre !== null ) { 245 | return $pre; 246 | } 247 | 248 | // First check if the job exists already. 249 | $jobs = Job::get_jobs_by_query( [ 250 | 'hook' => $hook, 251 | 'timestamp' => $timestamp, 252 | 'args' => $args, 253 | ] ); 254 | 255 | if ( is_wp_error( $jobs ) || empty( $jobs ) ) { 256 | // The job does not exist. 257 | if ( $wp_error ) { 258 | if ( ! is_wp_error( $jobs ) ) { 259 | $jobs = new WP_Error( 260 | 'invalid_event', 261 | __( 'Event does not exist.', 'cavalcade' ) 262 | ); 263 | } 264 | return $jobs; 265 | } 266 | return false; 267 | } 268 | 269 | $job = $jobs[0]; 270 | 271 | // Delete it. 272 | $job->delete(); 273 | 274 | return true; 275 | } 276 | 277 | /** 278 | * Unschedules all events attached to the hook with the specified arguments. 279 | * 280 | * Warning: This function may return Boolean FALSE, but may also return a non-Boolean 281 | * value which evaluates to FALSE. For information about casting to booleans see the 282 | * {@link https://php.net/manual/en/language.types.boolean.php PHP documentation}. Use 283 | * the `===` operator for testing the return value of this function. 284 | * 285 | * @param null|array|WP_Error $pre Value to return instead. Default null to continue unscheduling the event. 286 | * @param string $hook Action hook, the execution of which will be unscheduled. 287 | * @param array|null $args Arguments to pass to the hook's callback function, null to clear all 288 | * events regardless of arugments. 289 | * @param bool $wp_error Optional. Whether to return a WP_Error on failure. Default false. 290 | * @return bool|int|WP_Error On success an integer indicating number of events unscheduled (0 indicates no 291 | * events were registered with the hook and arguments combination), false or WP_Error if 292 | * unscheduling one or more events fail. 293 | */ 294 | function pre_clear_scheduled_hook( $pre, $hook, $args, $wp_error = false ) { 295 | // Allow other filters to do their thing. 296 | if ( $pre !== null ) { 297 | return $pre; 298 | } 299 | 300 | // First check if the job exists already. 301 | $jobs = Job::get_jobs_by_query( [ 302 | 'hook' => $hook, 303 | 'args' => $args, 304 | 'limit' => 100, 305 | '__raw' => true, 306 | ] ); 307 | 308 | if ( is_wp_error( $jobs ) ) { 309 | if ( $wp_error ) { 310 | return $jobs; 311 | } 312 | return false; 313 | } 314 | 315 | if ( empty( $jobs ) ) { 316 | return 0; 317 | } 318 | 319 | $ids = wp_list_pluck( $jobs, 'id' ); 320 | 321 | global $wpdb; 322 | 323 | // Clear all scheduled events for this site 324 | $table = Job::get_table(); 325 | 326 | $sql = "DELETE FROM `{$table}` WHERE site = %d"; 327 | $sql_params[] = get_current_blog_id(); 328 | 329 | $sql .= ' AND id IN(' . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ')'; 330 | $sql_params = array_merge( $sql_params, $ids ); 331 | 332 | $query = $wpdb->prepare( $sql, $sql_params ); 333 | $results = $wpdb->query( $query ); 334 | 335 | // Flush the caches. 336 | Job::flush_query_cache(); 337 | foreach ( $ids as $id ) { 338 | wp_cache_delete( "job::{$id}", 'cavalcade-jobs' ); 339 | } 340 | 341 | return $results; 342 | } 343 | 344 | /** 345 | * Unschedules all events attached to the hook. 346 | * 347 | * Can be useful for plugins when deactivating to clean up the cron queue. 348 | * 349 | * Warning: This function may return Boolean FALSE, but may also return a non-Boolean 350 | * value which evaluates to FALSE. For information about casting to booleans see the 351 | * {@link https://php.net/manual/en/language.types.boolean.php PHP documentation}. Use 352 | * the `===` operator for testing the return value of this function. 353 | * 354 | * @param null|array|WP_Error $pre Value to return instead. Default null to continue unscheduling the hook. 355 | * @param string $hook Action hook, the execution of which will be unscheduled. 356 | * @param bool $wp_error Optional. Whether to return a WP_Error on failure. Default false. 357 | * @return bool|int|WP_Error On success an integer indicating number of events unscheduled (0 indicates no 358 | * events were registered on the hook), false or WP_Error if unscheduling fails. 359 | */ 360 | function pre_unschedule_hook( $pre, $hook, $wp_error = false ) { 361 | return pre_clear_scheduled_hook( $pre, $hook, null, $wp_error ); 362 | } 363 | 364 | /** 365 | * Retrieve a scheduled event. 366 | * 367 | * Retrieve the full event object for a given event, if no timestamp is specified the next 368 | * scheduled event is returned. 369 | * 370 | * @param null|bool $pre Value to return instead. Default null to continue retrieving the event. 371 | * @param string $hook Action hook of the event. 372 | * @param array $args Array containing each separate argument to pass to the hook's callback function. 373 | * Although not passed to a callback, these arguments are used to uniquely identify the 374 | * event. 375 | * @param int|null $timestamp Unix timestamp (UTC) of the event. Null to retrieve next scheduled event. 376 | * @return bool|object The event object. False if the event does not exist. 377 | */ 378 | function pre_get_scheduled_event( $pre, $hook, $args, $timestamp ) { 379 | // Allow other filters to do their thing. 380 | if ( $pre !== null ) { 381 | return $pre; 382 | } 383 | 384 | $jobs = Job::get_jobs_by_query( [ 385 | 'hook' => $hook, 386 | 'timestamp' => $timestamp, 387 | 'args' => $args, 388 | ] ); 389 | 390 | if ( is_wp_error( $jobs ) || empty( $jobs ) ) { 391 | return false; 392 | } 393 | 394 | $job = $jobs[0]; 395 | 396 | $value = (object) [ 397 | 'hook' => $job->hook, 398 | 'timestamp' => $job->nextrun, 399 | 'schedule' => $job->schedule, 400 | 'args' => $job->args, 401 | ]; 402 | 403 | if ( isset( $job->interval ) ) { 404 | $value->interval = (int) $job->interval; 405 | } 406 | 407 | return $value; 408 | } 409 | 410 | /** 411 | * Retrieve cron jobs ready to be run. 412 | * 413 | * Returns the results of _get_cron_array() limited to events ready to be run, 414 | * ie, with a timestamp in the past. 415 | * 416 | * @param null|array $pre Array of ready cron tasks to return instead. Default null 417 | * to continue using results from _get_cron_array(). 418 | * @return array Cron jobs ready to be run. 419 | */ 420 | function pre_get_ready_cron_jobs( $pre ) { 421 | // Allow other filters to do their thing. 422 | if ( $pre !== null ) { 423 | return $pre; 424 | } 425 | 426 | $results = Job::get_jobs_by_query( [ 427 | 'timestamp' => 'past', 428 | 'limit' => 100, 429 | ] ); 430 | $crons = []; 431 | 432 | foreach ( $results as $result ) { 433 | $timestamp = $result->nextrun; 434 | $hook = $result->hook; 435 | $key = md5( serialize( $result->args ) ); 436 | $value = [ 437 | 'schedule' => $result->schedule, 438 | 'args' => $result->args, 439 | '_job' => $result, 440 | ]; 441 | 442 | if ( isset( $result->interval ) ) { 443 | $value['interval'] = (int) $result->interval; 444 | } 445 | 446 | // Build the array up. 447 | if ( ! isset( $crons[ $timestamp ] ) ) { 448 | $crons[ $timestamp ] = []; 449 | } 450 | if ( ! isset( $crons[ $timestamp ][ $hook ] ) ) { 451 | $crons[ $timestamp ][ $hook ] = []; 452 | } 453 | $crons[ $timestamp ][ $hook ][ $key ] = $value; 454 | } 455 | 456 | ksort( $crons, SORT_NUMERIC ); 457 | 458 | return $crons; 459 | } 460 | 461 | /** 462 | * Schedule an event with Cavalcade 463 | * 464 | * Note on return value: Although `false` can be returned to shortcircuit the 465 | * filter, this causes the calling function to return false. Plugins checking 466 | * this return value will hence think that the function has failed. Instead, we 467 | * hijack the save event in {@see update_cron} to simply skip saving to the DB. 468 | * 469 | * @param stdClass $event { 470 | * @param string $hook Hook to fire 471 | * @param int $timestamp 472 | * @param array $args 473 | * @param string|bool $schedule How often the event should occur (key from {@see wp_get_schedules}) 474 | * @param int|null $interval Time in seconds between events (derived from `$schedule` value) 475 | * } 476 | * @param bool $wp_error Optional. Whether to return a WP_Error on failure. Default false. 477 | * @return bool|stdClass|WP_Error Event object passed in (as we aren't hijacking it). False or WP_Error if job not saved. 478 | */ 479 | function schedule_event( $event, $wp_error = false ) { 480 | // Make sure timestamp is a positive integer. 481 | if ( ! is_numeric( $event->timestamp ) || $event->timestamp <= 0 ) { 482 | if ( $wp_error ) { 483 | return new WP_Error( 484 | 'invalid_timestamp', 485 | __( 'Event timestamp must be a valid Unix timestamp.' ) 486 | ); 487 | } 488 | 489 | return false; 490 | } 491 | 492 | if ( ! empty( $event->schedule ) ) { 493 | return schedule_recurring_event( $event, $wp_error ); 494 | } 495 | 496 | $job = new Job(); 497 | $job->hook = $event->hook; 498 | $job->site = get_current_blog_id(); 499 | $job->nextrun = $event->timestamp; 500 | $job->start = $job->nextrun; 501 | $job->args = $event->args; 502 | 503 | $result = $job->save(); 504 | if ( ! $result && $wp_error ) { 505 | return new WP_Error( 506 | 'could_not_set', 507 | __( 'The cron event list could not be saved.' ) 508 | ); 509 | } 510 | return $result; 511 | } 512 | 513 | function schedule_recurring_event( $event, $wp_error = false ) { 514 | // Make sure timestamp is a positive integer. 515 | if ( ! is_numeric( $event->timestamp ) || $event->timestamp <= 0 ) { 516 | if ( $wp_error ) { 517 | return new WP_Error( 518 | 'invalid_timestamp', 519 | __( 'Event timestamp must be a valid Unix timestamp.' ) 520 | ); 521 | } 522 | 523 | return false; 524 | } 525 | 526 | $schedules = wp_get_schedules(); 527 | 528 | if ( ! isset( $schedules[ $event->schedule ] ) ) { 529 | if ( $wp_error ) { 530 | return new WP_Error( 531 | 'invalid_schedule', 532 | __( 'Event schedule does not exist.' ) 533 | ); 534 | } 535 | 536 | return false; 537 | } 538 | 539 | $job = new Job(); 540 | $job->hook = $event->hook; 541 | $job->site = get_current_blog_id(); 542 | $job->nextrun = $event->timestamp; 543 | $job->start = $job->nextrun; 544 | $job->interval = $event->interval; 545 | $job->args = $event->args; 546 | 547 | if ( Cavalcade\get_database_version() >= 2 ) { 548 | $job->schedule = $event->schedule; 549 | } 550 | 551 | $result = $job->save(); 552 | if ( ! $result && $wp_error ) { 553 | return new WP_Error( 554 | 'could_not_set', 555 | __( 'The cron event list could not be saved.' ) 556 | ); 557 | } 558 | return $result; 559 | } 560 | 561 | /** 562 | * Hijack option update call for cron 563 | * 564 | * We force this to not save to the database by always returning the old value. 565 | * 566 | * @param array $value Cron array to save 567 | * @param array $old_value Existing value (actually hijacked via {@see get_cron}) 568 | * @return array Existing value, to shortcircuit saving 569 | */ 570 | function update_cron_array( $value, $old_value ) { 571 | // Ignore the version 572 | $stored = $old_value; 573 | unset( $stored['version'] ); 574 | unset( $value['version'] ); 575 | 576 | // Massage so we can compare 577 | $massager = function ( $crons ) { 578 | $new = []; 579 | 580 | foreach ( $crons as $timestamp => $hooks ) { 581 | foreach ( $hooks as $hook => $groups ) { 582 | foreach ( $groups as $key => $item ) { 583 | // Workaround for https://core.trac.wordpress.org/ticket/33423 584 | if ( $timestamp === 'wp_batch_split_terms' ) { 585 | $timestamp = $hook; 586 | $hook = 'wp_batch_split_terms'; 587 | } 588 | 589 | $real_key = $timestamp . $hook . $key; 590 | 591 | if ( isset( $item['interval'] ) ) { 592 | $real_key .= (string) $item['interval']; 593 | } 594 | 595 | $real_key = sha1( $real_key ); 596 | $new[ $real_key ] = [ 597 | 'timestamp' => $timestamp, 598 | 'hook' => $hook, 599 | 'key' => $key, 600 | 'value' => $item, 601 | ]; 602 | } 603 | } 604 | } 605 | 606 | return $new; 607 | }; 608 | 609 | $original = $massager( $stored ); 610 | $new = $massager( $value ); 611 | 612 | // Any new or changed? 613 | $added = array_diff_key( $new, $original ); 614 | foreach ( $added as $key => $item ) { 615 | // Skip new ones, as these are handled in schedule_event/schedule_recurring_event 616 | if ( isset( $original[ $key ] ) ) { 617 | // Skip existing events, we handle them below 618 | continue; 619 | } 620 | 621 | // Added new event 622 | $event = (object) [ 623 | 'hook' => $item['hook'], 624 | 'timestamp' => $item['timestamp'], 625 | 'args' => $item['value']['args'], 626 | ]; 627 | if ( ! empty( $item['value']['schedule'] ) ) { 628 | $event->schedule = $item['value']['schedule']; 629 | $event->interval = $item['value']['interval']; 630 | } 631 | 632 | schedule_event( $event ); 633 | } 634 | 635 | // Any removed? 636 | $removed = array_diff_key( $original, $new ); 637 | foreach ( $removed as $key => $item ) { 638 | $job = $item['value']['_job']; 639 | 640 | if ( isset( $new[ $key ] ) ) { 641 | // Changed events: the only way to change an event without changing 642 | // its key is to change the schedule or interval 643 | $job->interval = $new['value']['interval']; 644 | $job->save(); 645 | 646 | continue; 647 | } 648 | 649 | // Remaining keys are removed values only 650 | $job->delete(); 651 | } 652 | 653 | // Cancel the DB update 654 | return $old_value; 655 | } 656 | 657 | /** 658 | * Get cron array. 659 | * 660 | * This is constructed based on our database values, rather than being actually 661 | * stored like this. 662 | * 663 | * @param array|boolean $value Value to override with. False by default, truthy if another plugin has already overridden. 664 | * @return array Overridden cron array. 665 | */ 666 | function get_cron_array( $value ) { 667 | if ( $value !== false ) { 668 | // Something else is trying to filter the value, let it 669 | return $value; 670 | } 671 | 672 | // Massage into the correct format 673 | $crons = []; 674 | $results = Cavalcade\get_jobs(); 675 | foreach ( $results as $result ) { 676 | $timestamp = $result->nextrun; 677 | $hook = $result->hook; 678 | $key = md5( serialize( $result->args ) ); 679 | $value = [ 680 | 'schedule' => $result->schedule, 681 | 'args' => $result->args, 682 | '_job' => $result, 683 | ]; 684 | 685 | if ( isset( $result->interval ) ) { 686 | $value['interval'] = $result->interval; 687 | } 688 | 689 | // Build the array up, urgh 690 | if ( ! isset( $crons[ $timestamp ] ) ) { 691 | $crons[ $timestamp ] = []; 692 | } 693 | if ( ! isset( $crons[ $timestamp ][ $hook ] ) ) { 694 | $crons[ $timestamp ][ $hook ] = []; 695 | } 696 | $crons[ $timestamp ][ $hook ][ $key ] = $value; 697 | } 698 | 699 | ksort( $crons, SORT_NUMERIC ); 700 | 701 | // Set the version too 702 | $crons['version'] = 2; 703 | 704 | return $crons; 705 | } 706 | -------------------------------------------------------------------------------- /inc/namespace.php: -------------------------------------------------------------------------------- 1 | get_col( "SHOW TABLES LIKE '{$wpdb->base_prefix}cavalcade_%'" ) ) === 2 ); 62 | 63 | if ( $installed ) { 64 | // Don't check again :) 65 | wp_cache_set( 'installed', $installed, 'cavalcade' ); 66 | } 67 | 68 | return $installed; 69 | } 70 | 71 | function create_tables() { 72 | if ( ! is_blog_installed() ) { 73 | // Do not create tables before blog is installed. 74 | return false; 75 | } 76 | 77 | global $wpdb; 78 | 79 | $charset_collate = $wpdb->get_charset_collate(); 80 | 81 | $query = "CREATE TABLE IF NOT EXISTS `{$wpdb->base_prefix}cavalcade_jobs` ( 82 | `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, 83 | `site` bigint(20) unsigned NOT NULL, 84 | 85 | `hook` varchar(255) NOT NULL, 86 | `args` longtext NOT NULL, 87 | 88 | `start` datetime NOT NULL, 89 | `nextrun` datetime NOT NULL, 90 | `interval` int unsigned DEFAULT NULL, 91 | `status` varchar(255) NOT NULL DEFAULT 'waiting', 92 | `schedule` varchar(255) DEFAULT NULL, 93 | 94 | PRIMARY KEY (`id`), 95 | KEY `status` (`status`), 96 | KEY `site` (`site`), 97 | KEY `hook` (`hook`) 98 | ) ENGINE=InnoDB {$charset_collate};\n"; 99 | 100 | // TODO: check return value 101 | $wpdb->query( $query ); 102 | 103 | $query = "CREATE TABLE IF NOT EXISTS `{$wpdb->base_prefix}cavalcade_logs` ( 104 | `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, 105 | `job` bigint(20) NOT NULL, 106 | `status` varchar(255) NOT NULL DEFAULT '', 107 | `timestamp` datetime NOT NULL, 108 | `content` longtext NOT NULL, 109 | PRIMARY KEY (`id`), 110 | KEY `job` (`job`), 111 | KEY `status` (`status`) 112 | ) ENGINE=InnoDB {$charset_collate};\n"; 113 | 114 | $wpdb->query( $query ); 115 | 116 | wp_cache_set( 'installed', true, 'cavalcade' ); 117 | update_site_option( 'cavalcade_db_version', DATABASE_VERSION ); 118 | 119 | /** 120 | * Ensure site meta is populated when running the WP CLI script to 121 | * install a network. Using the CLI, WP installs a single site with 122 | * wp_install() and then upgrades it to a multiste install immediately. 123 | * 124 | * Note: This does not work for multisite manual installs. 125 | */ 126 | add_filter( 'populate_network_meta', function ( $site_meta ) { 127 | $site_meta['cavalcade_db_version'] = DATABASE_VERSION; 128 | return $site_meta; 129 | } ); 130 | return true; 131 | } 132 | 133 | /** 134 | * Populate the Cavalcade db version when upgrading to multisite. 135 | * 136 | * This ensures the database option is copied from the options table 137 | * accross to the sitemeta table when WordPress is upgraded from 138 | * a single site install to a multisite install. 139 | */ 140 | function maybe_populate_site_option() { 141 | if ( is_multisite() ) { 142 | return; 143 | } 144 | 145 | $set_site_meta = function ( $site_meta ) { 146 | $site_meta['cavalcade_db_version'] = get_option( 'cavalcade_db_version' ); 147 | return $site_meta; 148 | }; 149 | 150 | add_filter( 'populate_network_meta', $set_site_meta ); 151 | } 152 | 153 | /** 154 | * Get jobs for the specified site. 155 | * 156 | * @param int|stdClass $site Site ID or object (from {@see get_blog_details}) to get jobs for. Null for current site. 157 | * @return Job[] List of jobs on the site. 158 | */ 159 | function get_jobs( $site = null ) { 160 | global $wpdb; 161 | 162 | if ( empty( $site ) ) { 163 | $site = get_current_blog_id(); 164 | } 165 | 166 | return Job::get_by_site( $site ); 167 | } 168 | 169 | /** 170 | * Get the WP Cron schedule names by interval. 171 | * 172 | * This is used as a fallback when Cavalcade does not have the 173 | * schedule name stored in the database to make a best guest as 174 | * the schedules name. 175 | * 176 | * Interval collisions caused by two plugins registering the same 177 | * interval with different names are unified into a single name. 178 | * 179 | * @return array Cron Schedules indexed by interval. 180 | */ 181 | function get_schedules_by_interval() { 182 | $schedules = []; 183 | 184 | foreach ( wp_get_schedules() as $name => $schedule ) { 185 | $schedules[ (int) $schedule['interval'] ] = $name; 186 | } 187 | 188 | return $schedules; 189 | } 190 | 191 | /** 192 | * Helper function to get a schedule name from a specific interval. 193 | * 194 | * @param int $interval Cron schedule interval. 195 | * @return string Cron schedule name. 196 | */ 197 | function get_schedule_by_interval( $interval = null ) { 198 | if ( empty( $interval ) ) { 199 | return '__fake_schedule'; 200 | } 201 | 202 | $schedules = get_schedules_by_interval(); 203 | 204 | if ( ! empty ( $schedules[ (int) $interval ] ) ) { 205 | return $schedules[ (int) $interval ]; 206 | } 207 | 208 | return '__fake_schedule'; 209 | } 210 | 211 | /** 212 | * Get the current Cavalcade database schema version. 213 | * 214 | * @return int Database schema version. 215 | */ 216 | function get_database_version() { 217 | $version = (int) get_site_option( 'cavalcade_db_version' ); 218 | 219 | // Normalise schema version for unset option. 220 | if ( $version < 2 ) { 221 | $version = 1; 222 | } 223 | 224 | return $version; 225 | } 226 | -------------------------------------------------------------------------------- /inc/upgrade/namespace.php: -------------------------------------------------------------------------------- 1 | base_prefix}cavalcade_jobs` 57 | ADD `schedule` varchar(255) DEFAULT NULL"; 58 | 59 | $wpdb->query( $query ); 60 | 61 | $schedules = Cavalcade\get_schedules_by_interval(); 62 | 63 | foreach ( $schedules as $interval => $name ) { 64 | $query = "UPDATE `{$wpdb->base_prefix}cavalcade_jobs` 65 | SET `schedule` = %s 66 | WHERE `interval` = %d 67 | AND `status` NOT IN ( 'failed', 'completed' )"; 68 | 69 | $wpdb->query( 70 | $wpdb->prepare( $query, $name, $interval ) 71 | ); 72 | } 73 | } 74 | 75 | /** 76 | * Upgrade Cavalcade database tables to version 3. 77 | * 78 | * Add indexes required for pre-flight filters. 79 | */ 80 | function upgrade_database_3() { 81 | global $wpdb; 82 | 83 | $query = "ALTER TABLE `{$wpdb->base_prefix}cavalcade_jobs` 84 | ADD INDEX `site` (`site`), 85 | ADD INDEX `hook` (`hook`)"; 86 | 87 | $wpdb->query( $query ); 88 | } 89 | 90 | /** 91 | * Upgrade Cavalcade database tables to version 4. 92 | * 93 | * Remove nextrun index as it negatively affects performance. 94 | */ 95 | function upgrade_database_4() { 96 | global $wpdb; 97 | 98 | $query = "ALTER TABLE `{$wpdb->base_prefix}cavalcade_jobs` 99 | DROP INDEX `nextrun`"; 100 | 101 | $wpdb->query( $query ); 102 | } 103 | -------------------------------------------------------------------------------- /phpcs.ruleset.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | inc/ 5 | plugin.php 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | tests/tests 12 | 13 | 14 | 15 | 16 | 32656 17 | 49961 18 | 53635 19 | 53950 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /plugin.php: -------------------------------------------------------------------------------- 1 | [db-host] [wp-version] [skip-database-creation]" 5 | exit 1 6 | fi 7 | 8 | DB_NAME=$1 9 | DB_USER=$2 10 | DB_PASS=$3 11 | DB_HOST=${4-localhost} 12 | WP_VERSION=${5-latest} 13 | SKIP_DB_CREATE=${6-false} 14 | 15 | WP_TESTS_DIR=${WP_TESTS_DIR-/tmp/wordpress-tests-lib} 16 | WP_CORE_DIR=${WP_CORE_DIR-/tmp/wordpress/} 17 | 18 | download() { 19 | if [ `which curl` ]; then 20 | curl -s "$1" > "$2"; 21 | elif [ `which wget` ]; then 22 | wget -nv -O "$2" "$1" 23 | fi 24 | } 25 | 26 | if [[ $WP_VERSION =~ [0-9]+\.[0-9]+(\.[0-9]+)? ]]; then 27 | WP_TESTS_TAG="tags/$WP_VERSION" 28 | elif [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then 29 | WP_TESTS_TAG="trunk" 30 | else 31 | # http serves a single offer, whereas https serves multiple. we only want one 32 | download http://api.wordpress.org/core/version-check/1.7/ /tmp/wp-latest.json 33 | grep '[0-9]+\.[0-9]+(\.[0-9]+)?' /tmp/wp-latest.json 34 | LATEST_VERSION=$(grep -o '"version":"[^"]*' /tmp/wp-latest.json | sed 's/"version":"//') 35 | if [[ -z "$LATEST_VERSION" ]]; then 36 | echo "Latest WordPress version could not be found" 37 | exit 1 38 | fi 39 | WP_TESTS_TAG="tags/$LATEST_VERSION" 40 | fi 41 | 42 | set -ex 43 | 44 | install_wp() { 45 | 46 | if [ -d $WP_CORE_DIR ]; then 47 | return; 48 | fi 49 | 50 | mkdir -p $WP_CORE_DIR 51 | 52 | if [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then 53 | mkdir -p /tmp/wordpress-nightly 54 | download https://wordpress.org/nightly-builds/wordpress-latest.zip /tmp/wordpress-nightly/wordpress-nightly.zip 55 | unzip -q /tmp/wordpress-nightly/wordpress-nightly.zip -d /tmp/wordpress-nightly/ 56 | mv /tmp/wordpress-nightly/wordpress/* $WP_CORE_DIR 57 | else 58 | if [ $WP_VERSION == 'latest' ]; then 59 | local ARCHIVE_NAME='latest' 60 | else 61 | local ARCHIVE_NAME="wordpress-$WP_VERSION" 62 | fi 63 | download https://wordpress.org/${ARCHIVE_NAME}.tar.gz /tmp/wordpress.tar.gz 64 | tar --strip-components=1 -zxmf /tmp/wordpress.tar.gz -C $WP_CORE_DIR 65 | fi 66 | 67 | download https://raw.github.com/markoheijnen/wp-mysqli/master/db.php $WP_CORE_DIR/wp-content/db.php 68 | } 69 | 70 | install_test_suite() { 71 | # portable in-place argument for both GNU sed and Mac OSX sed 72 | if [[ $(uname -s) == 'Darwin' ]]; then 73 | local ioption='-i .bak' 74 | else 75 | local ioption='-i' 76 | fi 77 | 78 | # set up testing suite if it doesn't yet exist 79 | if [ ! -d $WP_TESTS_DIR ]; then 80 | # set up testing suite 81 | mkdir -p $WP_TESTS_DIR 82 | svn co --quiet https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/includes/ $WP_TESTS_DIR/includes 83 | svn co --quiet https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/data/ $WP_TESTS_DIR/data 84 | fi 85 | 86 | if [ ! -f wp-tests-config.php ]; then 87 | download https://develop.svn.wordpress.org/${WP_TESTS_TAG}/wp-tests-config-sample.php "$WP_TESTS_DIR"/wp-tests-config.php 88 | # remove all forward slashes in the end 89 | WP_CORE_DIR=$(echo $WP_CORE_DIR | sed "s:/\+$::") 90 | sed $ioption "s:dirname( __FILE__ ) . '/src/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php 91 | sed $ioption "s/youremptytestdbnamehere/$DB_NAME/" "$WP_TESTS_DIR"/wp-tests-config.php 92 | sed $ioption "s/yourusernamehere/$DB_USER/" "$WP_TESTS_DIR"/wp-tests-config.php 93 | sed $ioption "s/yourpasswordhere/$DB_PASS/" "$WP_TESTS_DIR"/wp-tests-config.php 94 | sed $ioption "s|localhost|${DB_HOST}|" "$WP_TESTS_DIR"/wp-tests-config.php 95 | fi 96 | 97 | } 98 | 99 | install_db() { 100 | 101 | if [ ${SKIP_DB_CREATE} = "true" ]; then 102 | return 0 103 | fi 104 | 105 | # parse DB_HOST for port or socket references 106 | local PARTS=(${DB_HOST//\:/ }) 107 | local DB_HOSTNAME=${PARTS[0]}; 108 | local DB_SOCK_OR_PORT=${PARTS[1]}; 109 | local EXTRA="" 110 | 111 | if ! [ -z $DB_HOSTNAME ] ; then 112 | if [ $(echo $DB_SOCK_OR_PORT | grep -e '^[0-9]\{1,\}$') ]; then 113 | EXTRA=" --host=$DB_HOSTNAME --port=$DB_SOCK_OR_PORT --protocol=tcp" 114 | elif ! [ -z $DB_SOCK_OR_PORT ] ; then 115 | EXTRA=" --socket=$DB_SOCK_OR_PORT" 116 | elif ! [ -z $DB_HOSTNAME ] ; then 117 | EXTRA=" --host=$DB_HOSTNAME --protocol=tcp" 118 | fi 119 | fi 120 | 121 | # create database 122 | mysqladmin create $DB_NAME --user="$DB_USER" --password="$DB_PASS"$EXTRA 123 | } 124 | 125 | # Download and copy the WordPress Core Cron 126 | # tests for Cavalcade to ensure the plugin does 127 | # not change the behaviour. 128 | install_core_cron_tests() { 129 | download https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/tests/cron.php "$TRAVIS_BUILD_DIR"/tests/tests/class-wp-core-cron.php 130 | } 131 | 132 | 133 | install_wp 134 | install_test_suite 135 | install_core_cron_tests 136 | install_db 137 | -------------------------------------------------------------------------------- /tests/tests/class-tests-rescheduling.php: -------------------------------------------------------------------------------- 1 | assertEquals( $expected, $actual ); 36 | } 37 | } 38 | --------------------------------------------------------------------------------