├── .github ├── CODEOWNERS ├── checkstyle-problem-matcher.json ├── dependabot.yml └── workflows │ ├── ci-grunt.yaml │ ├── ci-php.yaml │ └── phpcs.yaml ├── .gitignore ├── .nvmrc ├── Gruntfile.js ├── LICENSE ├── README.md ├── __tests__ ├── bin │ ├── install-wp-tests.sh │ └── test.sh ├── bootstrap.php ├── unit-tests │ ├── test-cli-orchestrate-sites.php │ ├── test-event.php │ ├── test-events-store.php │ ├── test-events.php │ ├── test-internal-events.php │ ├── test-rest-api.php │ └── test-wp-adapter.php └── utils.php ├── composer.json ├── composer.lock ├── cron-control.php ├── includes ├── class-event.php ├── class-events-store.php ├── class-events.php ├── class-internal-events.php ├── class-lock.php ├── class-main.php ├── class-rest-api.php ├── class-singleton.php ├── constants.php ├── functions.php ├── utils.php ├── wp-adapter.php ├── wp-cli.php └── wp-cli │ ├── class-events.php │ ├── class-lock.php │ ├── class-main.php │ ├── class-orchestrate-runner.php │ ├── class-orchestrate-sites.php │ ├── class-orchestrate.php │ └── class-rest-api.php ├── languages └── cron-control.pot ├── package-lock.json ├── package.json ├── phpcs.xml ├── phpunit-multisite.xml ├── phpunit.xml └── readme.txt /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners 2 | * @Automattic/vip-platform-cantina 3 | -------------------------------------------------------------------------------- /.github/checkstyle-problem-matcher.json: -------------------------------------------------------------------------------- 1 | { 2 | "problemMatcher": [ 3 | { 4 | "owner": "phpcs", 5 | "severity": "error", 6 | "pattern": [ 7 | { 8 | "regexp": "^$", 9 | "file": 1 10 | }, 11 | { 12 | "regexp": "+)$", 13 | "line": 1, 14 | "column": 2, 15 | "severity": 3, 16 | "message": 4, 17 | "code": 5, 18 | "loop": true 19 | } 20 | ] 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - directory: / 4 | open-pull-requests-limit: 15 5 | package-ecosystem: composer 6 | schedule: 7 | interval: daily 8 | 9 | - directory: / 10 | open-pull-requests-limit: 15 11 | package-ecosystem: github-actions 12 | schedule: 13 | interval: daily 14 | 15 | - directory: / 16 | open-pull-requests-limit: 15 17 | package-ecosystem: npm 18 | schedule: 19 | interval: daily 20 | -------------------------------------------------------------------------------- /.github/workflows/ci-grunt.yaml: -------------------------------------------------------------------------------- 1 | name: CI (Grunt) 2 | 3 | on: 4 | push: 5 | workflow_dispatch: 6 | 7 | concurrency: 8 | group: ci-grunt-${{ github.ref }} 9 | cancel-in-progress: true 10 | 11 | env: 12 | NODE_VERSION: "18" 13 | 14 | jobs: 15 | build: 16 | name: Run Grunt tasks 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Check out source code 20 | uses: actions/checkout@v3.3.0 21 | 22 | - name: Set up Node.js environment 23 | uses: actions/setup-node@v3.6.0 24 | with: 25 | node-version: ${{ env.NODE_VERSION }} 26 | cache: npm 27 | 28 | - name: Install dependencies 29 | run: npm ci --ignore-scripts 30 | 31 | - name: Run postinstall scripts 32 | run: npm rebuild && npm run prepare --if-present 33 | 34 | - name: Run build tasks 35 | run: npm run build 36 | -------------------------------------------------------------------------------- /.github/workflows/ci-php.yaml: -------------------------------------------------------------------------------- 1 | name: CI (PHP) 2 | 3 | on: 4 | push: 5 | workflow_dispatch: 6 | 7 | concurrency: 8 | group: ci-php-${{ github.ref }} 9 | cancel-in-progress: true 10 | 11 | jobs: 12 | test: 13 | name: Run tests 14 | runs-on: ubuntu-latest 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | php: 19 | - "8.0" 20 | - "8.1" 21 | - "8.2" 22 | - "8.3" 23 | - "8.4" 24 | wpmu: 25 | - "0" 26 | - "1" 27 | wordpress: 28 | - latest 29 | - trunk 30 | services: 31 | mysql: 32 | image: mariadb:latest 33 | env: 34 | MYSQL_ROOT_PASSWORD: root 35 | ports: 36 | - 3306 37 | options: --health-cmd="healthcheck.sh --innodb_initialized" --health-interval=5s --health-timeout=2s --health-retries=3 38 | steps: 39 | - name: Install svn 40 | run: sudo apt-get update && sudo apt-get install -y subversion 41 | 42 | - name: Check out source code 43 | uses: actions/checkout@v3.3.0 44 | 45 | - name: Set up PHP 46 | uses: shivammathur/setup-php@v2 47 | with: 48 | php-version: ${{ matrix.php }} 49 | 50 | - name: Install PHP Dependencies 51 | uses: ramsey/composer-install@3.0.0 52 | 53 | - name: Verify MariaDB connection 54 | run: | 55 | while ! mysqladmin ping -h 127.0.0.1 -P ${{ job.services.mysql.ports[3306] }} --silent; do 56 | sleep 1 57 | done 58 | timeout-minutes: 3 59 | 60 | - name: Install WP Test Suite 61 | run: ./__tests__/bin/install-wp-tests.sh wordpress_test root root 127.0.0.1:${{ job.services.mysql.ports[3306] }} ${{ matrix.wordpress }} 62 | 63 | - name: Run tests 64 | run: vendor/bin/phpunit 65 | env: 66 | WP_MULTISITE: ${{ matrix.wpmu }} 67 | -------------------------------------------------------------------------------- /.github/workflows/phpcs.yaml: -------------------------------------------------------------------------------- 1 | name: Code Style Check 2 | 3 | on: 4 | push: 5 | workflow_dispatch: 6 | 7 | concurrency: 8 | group: csc-${{ github.ref }} 9 | cancel-in-progress: true 10 | 11 | jobs: 12 | codestyle: 13 | name: Run code style check 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Check out source code 17 | uses: actions/checkout@v3.3.0 18 | 19 | - name: Set up PHP 20 | uses: shivammathur/setup-php@v2 21 | with: 22 | php-version: 8.1 23 | 24 | - name: Install PHP Dependencies 25 | uses: ramsey/composer-install@3.0.0 26 | 27 | - name: Add error matcher 28 | run: echo "::add-matcher::$(pwd)/.github/checkstyle-problem-matcher.json" 29 | 30 | - name: Run style check 31 | run: vendor/bin/phpcs --report=checkstyle 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | .DS_Store 3 | Thumbs.db 4 | wp-cli.local.yml 5 | node_modules/ 6 | *.sql 7 | *.tar.gz 8 | *.zip 9 | .phpunit.result.cache 10 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18.13 2 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function( grunt ) { 2 | 3 | 'use strict'; 4 | var banner = '/**\n * <%= pkg.homepage %>\n * Copyright (c) <%= grunt.template.today("yyyy") %>\n * This file is generated automatically. Do not edit.\n */\n'; 5 | // Project configuration 6 | grunt.initConfig( { 7 | 8 | pkg: grunt.file.readJSON( 'package.json' ), 9 | 10 | addtextdomain: { 11 | options: { 12 | textdomain: 'automattic-cron-control', 13 | }, 14 | update_all_domains: { 15 | options: { 16 | updateDomains: true 17 | }, 18 | src: [ '*.php', '**/*.php', '!node_modules/**', '!__tests__/**', '!vendor/**' ] 19 | } 20 | }, 21 | 22 | wp_readme_to_markdown: { 23 | your_target: { 24 | files: { 25 | 'README.md': 'readme.txt' 26 | } 27 | }, 28 | }, 29 | 30 | makepot: { 31 | target: { 32 | options: { 33 | domainPath: '/languages', 34 | mainFile: 'cron-control.php', 35 | potFilename: 'cron-control.pot', 36 | potHeaders: { 37 | poedit: true, 38 | 'x-poedit-keywordslist': true 39 | }, 40 | type: 'wp-plugin', 41 | updateTimestamp: true, 42 | exclude: [ 'node_modules/.*', '__tests__/.*', 'vendor/.*' ], 43 | } 44 | } 45 | }, 46 | } ); 47 | 48 | grunt.loadNpmTasks( 'grunt-wp-i18n' ); 49 | grunt.loadNpmTasks( 'grunt-wp-readme-to-markdown' ); 50 | grunt.registerTask( 'i18n', ['addtextdomain', 'makepot'] ); 51 | grunt.registerTask( 'readme', ['wp_readme_to_markdown'] ); 52 | grunt.registerTask( 'default', ['i18n', 'readme'] ); 53 | 54 | grunt.util.linefeed = '\n'; 55 | 56 | }; 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cron Control # 2 | **Contributors:** [automattic](https://profiles.wordpress.org/automattic/), [ethitter](https://profiles.wordpress.org/ethitter/) 3 | **Tags:** cron, cron control, concurrency, parallel, async 4 | **Requires at least:** 5.1 5 | **Tested up to:** 5.8 6 | **Requires PHP:** 7.4 7 | **Stable tag:** 3.1 8 | **License:** GPLv2 or later 9 | **License URI:** http://www.gnu.org/licenses/gpl-2.0.html 10 | 11 | Execute WordPress cron events in parallel, with custom event storage for high-volume cron. 12 | 13 | ## Description ## 14 | 15 | This plugin sets up a custom cron table for better events storage. Using WP hooks, it then intercepts cron registration/retrieval/deletions. There are two additional interaction layers exposed by the plugin - WP-CLI and the REST API. 16 | 17 | By default the plugin disables default WP cron processing. It is recommended to use the cron control runner to process cron: https://github.com/Automattic/cron-control-runner. This is how we are able to process cron events in parallel, allowing for high-volume and reliable cron. 18 | 19 | ## Installation ## 20 | 21 | 1. Define `WP_CRON_CONTROL_SECRET` in `wp-config.php`, set to `false` to disable the REST API interface. 22 | 1. Upload the `cron-control` directory to the `/wp-content/mu-plugins/` directory 23 | 1. Create a file at `/wp-content/mu-plugins/cron-control.php` to load `/wp-content/mu-plugins/cron-control/cron-control.php` 24 | 1. (optional) Set up the the cron control runner for event processing. 25 | 26 | ## Frequently Asked Questions ## 27 | 28 | ### Deviations from WordPress Core ### 29 | 30 | * Cron jobs are stored in a custom table and not in the `cron` option in wp_options. As long relevant code uses WP core functions for retrieving events and not direct SQL, all will stay compatible. 31 | * Duplicate recurring events with the same action/args/schedule are prevented. If multiple of the same action is needed on the same schedule, can add an arbitrary number to the args array. 32 | * When the cron control runner is running events, it does so via WP-CLI. So the environment can be slightly different than that of a normal web request. 33 | * The cron control runner can process multiple events in parallel, whereas core cron only did 1 at a time. By default, events with the same action will not run in parallel unless specifically granted permission to do so. 34 | 35 | ### Adding Internal Events ### 36 | 37 | **This should be done sparingly as "Internal Events" bypass certain locks and limits built into the plugin.** Overuse will lead to unexpected resource usage, and likely resource exhaustion. 38 | 39 | In `wp-config.php` or a similarly-early and appropriate place, define `CRON_CONTROL_ADDITIONAL_INTERNAL_EVENTS` as an array of arrays like: 40 | 41 | ``` 42 | define( 'CRON_CONTROL_ADDITIONAL_INTERNAL_EVENTS', array( 43 | array( 44 | 'schedule' => 'hourly', 45 | 'action' => 'do_a_thing', 46 | 'callback' => '__return_true', 47 | ), 48 | ) ); 49 | ``` 50 | 51 | Due to the early loading (to limit additions), the `action` and `callback` generally can't directly reference any Core, plugin, or theme code. Since WordPress uses actions to trigger cron, class methods can be referenced, so long as the class name is not dynamically referenced. For example: 52 | 53 | ``` 54 | define( 'CRON_CONTROL_ADDITIONAL_INTERNAL_EVENTS', array( 55 | array( 56 | 'schedule' => 'hourly', 57 | 'action' => 'do_a_thing', 58 | 'callback' => array( 'Some_Class', 'some_method' ), 59 | ), 60 | ) ); 61 | ``` 62 | 63 | Take care to reference the full namespace when appropriate. 64 | 65 | ### Increasing Event Concurrency ### 66 | 67 | In some circumstances, multiple events with the same action can safely run in parallel. This is usually not the case, largely due to Core's alloptions, but sometimes an event is written in a way that we can support concurrent executions. 68 | 69 | To allow concurrency for your event, and to specify the level of concurrency, please hook the `a8c_cron_control_concurrent_event_whitelist` filter as in the following example: 70 | 71 | ``` 72 | add_filter( 'a8c_cron_control_concurrent_event_whitelist', function( $wh ) { 73 | $wh['my_custom_event'] = 2; 74 | 75 | return $wh; 76 | } ); 77 | ``` 78 | 79 | ## Development & Testing ## 80 | 81 | ### Quick and easy testing ### 82 | 83 | If you have docker installed, can just run `./__tests__/bin/test.sh`. 84 | 85 | ### Manual testing setup ### 86 | 87 | First, you'll need svn and composer. Example of installing them on a docker container if needed: 88 | 89 | ``` 90 | apk add subversion 91 | wget -q https://getcomposer.org/installer -O - | php -- --install-dir=/usr/bin/ --filename=composer 92 | ``` 93 | 94 | Next change directories to the plugin and set up the test environment: 95 | 96 | ``` 97 | cd wp-content/mu-plugins/cron-control 98 | 99 | composer install 100 | 101 | # Note that the values below can be different, it is: [db-host] [wp-version] 102 | ./__tests__/bin/install-wp-tests.sh test wordpress wordpress database latest 103 | ``` 104 | 105 | Lastly, kick things off with one command: `phpunit` 106 | 107 | ### Readme & language file updates ### 108 | 109 | Will need `npm`. Example of installing on a docker container: `apk add --update npm` 110 | 111 | Run `npm install` then `npm run build` to create/update language files and to convert `readme.txt` to `readme.md` if needed. 112 | 113 | ## Changelog ## 114 | 115 | ### 3.1 ### 116 | * Update installation process, always ensuring the custom table is installed. 117 | * Swap out deprecated `wpmu_new_blog` hook. 118 | * Ignore archived/deleted/spam subsites during the runner's `list sites` cli command. 119 | * Migrate legacy events from the `cron` option to the new table before deleting the option. 120 | * Delete duplicate recurring events. Runs daily. 121 | 122 | ### 3.0 ### 123 | * Implement WP cron filters that were added in WP 5.1. 124 | * Cleanup the event's store & introduce new Event() object. 125 | * Switch to a more efficient caching strategy. 126 | 127 | ### 2.0 ### 128 | * Support additional Internal Events 129 | * Break large cron queues into several caches 130 | * Introduce Golang runner to execute cron 131 | * Support concurrency for whitelisted events 132 | 133 | ### 1.5 ### 134 | * Convert from custom post type to custom table with proper indices 135 | 136 | ### 1.0 ### 137 | * Initial release 138 | -------------------------------------------------------------------------------- /__tests__/bin/install-wp-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ $# -lt 3 ]; then 4 | echo "usage: $0 [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 | fi 84 | 85 | if [ ! -f wp-tests-config.php ]; then 86 | download https://develop.svn.wordpress.org/${WP_TESTS_TAG}/wp-tests-config-sample.php "$WP_TESTS_DIR"/wp-tests-config.php 87 | # remove all forward slashes in the end 88 | WP_CORE_DIR=$(echo $WP_CORE_DIR | sed "s:/\+$::") 89 | sed $ioption "s:dirname( __FILE__ ) . '/src/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php 90 | sed $ioption "s/youremptytestdbnamehere/$DB_NAME/" "$WP_TESTS_DIR"/wp-tests-config.php 91 | sed $ioption "s/yourusernamehere/$DB_USER/" "$WP_TESTS_DIR"/wp-tests-config.php 92 | sed $ioption "s/yourpasswordhere/$DB_PASS/" "$WP_TESTS_DIR"/wp-tests-config.php 93 | sed $ioption "s|localhost|${DB_HOST}|" "$WP_TESTS_DIR"/wp-tests-config.php 94 | fi 95 | 96 | } 97 | 98 | install_db() { 99 | 100 | if [ ${SKIP_DB_CREATE} = "true" ]; then 101 | return 0 102 | fi 103 | 104 | # parse DB_HOST for port or socket references 105 | local PARTS=(${DB_HOST//\:/ }) 106 | local DB_HOSTNAME=${PARTS[0]}; 107 | local DB_SOCK_OR_PORT=${PARTS[1]}; 108 | local EXTRA="" 109 | 110 | if ! [ -z $DB_HOSTNAME ] ; then 111 | if [ $(echo $DB_SOCK_OR_PORT | grep -e '^[0-9]\{1,\}$') ]; then 112 | EXTRA=" --host=$DB_HOSTNAME --port=$DB_SOCK_OR_PORT --protocol=tcp" 113 | elif ! [ -z $DB_SOCK_OR_PORT ] ; then 114 | EXTRA=" --socket=$DB_SOCK_OR_PORT" 115 | elif ! [ -z $DB_HOSTNAME ] ; then 116 | EXTRA=" --host=$DB_HOSTNAME --protocol=tcp" 117 | fi 118 | fi 119 | 120 | # create database 121 | mysqladmin create $DB_NAME --user="$DB_USER" --password="$DB_PASS"$EXTRA 122 | } 123 | 124 | install_wp 125 | install_test_suite 126 | install_db 127 | -------------------------------------------------------------------------------- /__tests__/bin/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | while [ $# -gt 0 ]; do 4 | case "$1" in 5 | --wp) 6 | shift 7 | WP_VERSION="$1" 8 | ;; 9 | 10 | --multisite) 11 | shift 12 | WP_MULTISITE="$1" 13 | ;; 14 | 15 | --php) 16 | shift 17 | PHP_VERSION="$1" 18 | ;; 19 | 20 | --php-options) 21 | shift 22 | PHP_OPTIONS="$1" 23 | ;; 24 | 25 | --phpunit) 26 | shift 27 | PHPUNIT_VERSION="$1" 28 | ;; 29 | 30 | --network) 31 | shift 32 | NETWORK_NAME_OVERRIDE="$1" 33 | ;; 34 | 35 | --dbhost) 36 | shift 37 | MYSQL_HOST_OVERRIDE="$1" 38 | ;; 39 | 40 | *) 41 | ARGS="${ARGS} $1" 42 | ;; 43 | esac 44 | 45 | shift 46 | done 47 | 48 | : "${WP_VERSION:=latest}" 49 | : "${WP_MULTISITE:=0}" 50 | : "${PHP_VERSION:=""}" 51 | : "${PHP_OPTIONS:=""}" 52 | : "${PHPUNIT_VERSION:=""}" 53 | 54 | export WP_VERSION 55 | export WP_MULTISITE 56 | export PHP_VERSION 57 | export PHP_OPTIONS 58 | export PHPUNIT_VERSION 59 | 60 | echo "--------------" 61 | echo "Will test with WP_VERSION=${WP_VERSION} and WP_MULTISITE=${WP_MULTISITE}" 62 | echo "--------------" 63 | echo 64 | 65 | MARIADB_VERSION="10.3" 66 | 67 | UUID=$(date +%s000) 68 | if [ -z "${NETWORK_NAME_OVERRIDE}" ]; then 69 | NETWORK_NAME="tests-${UUID}" 70 | docker network create "${NETWORK_NAME}" 71 | else 72 | NETWORK_NAME="${NETWORK_NAME_OVERRIDE}" 73 | fi 74 | 75 | export MYSQL_USER=wordpress 76 | export MYSQL_PASSWORD=wordpress 77 | export MYSQL_DATABASE=wordpress_test 78 | 79 | db="" 80 | if [ -z "${MYSQL_HOST_OVERRIDE}" ]; then 81 | MYSQL_HOST="db-${UUID}" 82 | db=$(docker run --rm --network "${NETWORK_NAME}" --name "${MYSQL_HOST}" -e MYSQL_ROOT_PASSWORD="wordpress" -e MARIADB_INITDB_SKIP_TZINFO=1 -e MYSQL_USER -e MYSQL_PASSWORD -e MYSQL_DATABASE -d "mariadb:${MARIADB_VERSION}") 83 | else 84 | MYSQL_HOST="${MYSQL_HOST_OVERRIDE}" 85 | fi 86 | 87 | export MYSQL_HOST 88 | 89 | cleanup() { 90 | if [ -n "${db}" ]; then 91 | docker rm -f "${db}" 92 | fi 93 | 94 | if [ -z "${NETWORK_NAME_OVERRIDE}" ]; then 95 | docker network rm "${NETWORK_NAME}" 96 | fi 97 | } 98 | 99 | trap cleanup EXIT 100 | 101 | # shellcheck disable=SC2086 # ARGS must not be quoted 102 | docker run \ 103 | -it \ 104 | --rm \ 105 | --network "${NETWORK_NAME}" \ 106 | -e WP_VERSION \ 107 | -e WP_MULTISITE \ 108 | -e PHP_VERSION \ 109 | -e PHP_OPTIONS \ 110 | -e PHPUNIT_VERSION \ 111 | -e MYSQL_USER \ 112 | -e MYSQL_PASSWORD \ 113 | -e MYSQL_DATABASE \ 114 | -e MYSQL_HOST \ 115 | -v "$(pwd):/home/circleci/project" \ 116 | ghcr.io/automattic/vip-container-images/wp-test-runner:latest \ 117 | ${ARGS} 118 | -------------------------------------------------------------------------------- /__tests__/bootstrap.php: -------------------------------------------------------------------------------- 1 | 'hourly', 29 | 'action' => 'cron_control_additional_internal_event', 30 | 'callback' => '__return_true', 31 | ), 32 | ) 33 | ); 34 | 35 | require dirname( dirname( __FILE__ ) ) . '/cron-control.php'; 36 | 37 | // Plugin loads after `wp_install()` is called, so we compensate. 38 | if ( ! Cron_Control\Events_Store::is_installed() ) { 39 | Cron_Control\Events_Store::instance()->install(); 40 | Cron_Control\register_adapter_hooks(); 41 | } 42 | } 43 | tests_add_filter( 'muplugins_loaded', '_manually_load_plugin' ); 44 | 45 | // Utilities. 46 | require_once __DIR__ . '/utils.php'; 47 | 48 | // Start up the WP testing environment. 49 | require $_tests_dir . '/includes/bootstrap.php'; 50 | 51 | // Setup WP-CLI dependencies. 52 | if ( ! defined( 'WP_CLI_ROOT' ) ) { 53 | define( 'WP_CLI_ROOT', __DIR__ . '/../vendor/wp-cli/wp-cli' ); 54 | } 55 | 56 | include WP_CLI_ROOT . '/php/utils.php'; 57 | include WP_CLI_ROOT . '/php/dispatcher.php'; 58 | include WP_CLI_ROOT . '/php/class-wp-cli.php'; 59 | include WP_CLI_ROOT . '/php/class-wp-cli-command.php'; 60 | 61 | \WP_CLI\Utils\load_dependencies(); 62 | 63 | // WP_CLI wasn't defined during plugin bootup, so bootstrap our cli classes manually 64 | require dirname( dirname( __FILE__ ) ) . '/includes/wp-cli.php'; 65 | Cron_Control\CLI\prepare_environment(); 66 | -------------------------------------------------------------------------------- /__tests__/unit-tests/test-cli-orchestrate-sites.php: -------------------------------------------------------------------------------- 1 | markTestSkipped( 'Skipping tests that only run on multisites.' ); 12 | } 13 | 14 | parent::setUp(); 15 | } 16 | 17 | function tearDown(): void { 18 | parent::tearDown(); 19 | } 20 | 21 | function test_list_sites_removes_inactive_subsites() { 22 | add_filter( 'sites_pre_query', [ $this, 'mock_get_sites' ], 10, 2 ); 23 | 24 | // The archived/spam/deleted subsites should not be returned. 25 | $expected = wp_json_encode( [ [ 'url' => 'site1.com' ], [ 'url' => 'site2.com/two' ], [ 'url' => 'site3.com/three' ], [ 'url' => 'site7.com/seven' ] ] ); 26 | $this->expectOutputString( $expected ); 27 | ( new CLI\Orchestrate_Sites() )->list(); 28 | 29 | remove_filter( 'sites_pre_query', [ $this, 'mock_get_sites' ], 10, 2 ); 30 | } 31 | 32 | function test_list_sites_2_hosts() { 33 | add_filter( 'sites_pre_query', [ $this, 'mock_get_sites' ], 10, 2 ); 34 | 35 | // With two hosts, all active sites should still be returned. 36 | $this->mock_hosts_list( 2 ); 37 | $expected = wp_json_encode( [ [ 'url' => 'site1.com' ], [ 'url' => 'site2.com/two' ], [ 'url' => 'site3.com/three' ], [ 'url' => 'site7.com/seven' ] ] ); 38 | $this->expectOutputString( $expected ); 39 | ( new CLI\Orchestrate_Sites() )->list(); 40 | 41 | remove_filter( 'sites_pre_query', [ $this, 'mock_get_sites' ], 10, 2 ); 42 | } 43 | 44 | function test_list_sites_7_hosts() { 45 | add_filter( 'sites_pre_query', [ $this, 'mock_get_sites' ], 10, 2 ); 46 | 47 | // With seven hosts, our current request should only be given two of the active sites. 48 | $this->mock_hosts_list( 7 ); 49 | $expected = wp_json_encode( [ [ 'url' => 'site1.com' ], [ 'url' => 'site7.com/seven' ] ] ); 50 | $this->expectOutputString( $expected ); 51 | ( new CLI\Orchestrate_Sites() )->list(); 52 | 53 | remove_filter( 'sites_pre_query', [ $this, 'mock_get_sites' ], 10, 2 ); 54 | } 55 | 56 | function mock_hosts_list( $number_of_hosts ) { 57 | // Always have the "current" host. 58 | $heartbeats = [ gethostname() => time() ]; 59 | 60 | if ( $number_of_hosts > 1 ) { 61 | for ( $i = 1; $i < $number_of_hosts; $i++ ) { 62 | $heartbeats[ "test_$i" ] = time(); 63 | } 64 | } 65 | 66 | wp_cache_set( CLI\Orchestrate_Sites::RUNNER_HOST_HEARTBEAT_KEY, $heartbeats ); 67 | } 68 | 69 | function mock_get_sites( $site_data, $query_class ) { 70 | if ( $query_class->query_vars['count'] ) { 71 | return 7; 72 | } 73 | 74 | return [ 75 | new WP_Site( (object) [ 'domain' => 'site1.com', 'path' => '/' ] ), 76 | new WP_Site( (object) [ 'domain' => 'site2.com', 'path' => '/two' ] ), 77 | new WP_Site( (object) [ 'domain' => 'site3.com', 'path' => '/three' ] ), 78 | new WP_Site( (object) [ 'domain' => 'site4.com', 'path' => '/four', 'archived' => '1' ] ), 79 | new WP_Site( (object) [ 'domain' => 'site5.com', 'path' => '/five', 'spam' => '1' ] ), 80 | new WP_Site( (object) [ 'domain' => 'site6.com', 'path' => '/six', 'deleted' => '1' ] ), 81 | new WP_Site( (object) [ 'domain' => 'site7.com', 'path' => '/seven' ] ), 82 | ]; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /__tests__/unit-tests/test-event.php: -------------------------------------------------------------------------------- 1 | set_action( 'test_run_event_action' ); 28 | $event->run(); 29 | 30 | $this->assertEquals( 1, $called, 'event callback was triggered once' ); 31 | } 32 | 33 | function test_complete() { 34 | // Mock up an event, but try to complete it before saving. 35 | $event = new Event(); 36 | $event->set_action( 'test_complete' ); 37 | $event->set_timestamp( time() ); 38 | $event->set_args( [ 'test', 'args' ] ); 39 | $result = $event->complete(); 40 | $this->assertEquals( 'cron-control:event:cannot-complete', $result->get_error_code() ); 41 | 42 | // Now save the event and make sure props were updated correctly. 43 | $event->save(); 44 | $result = $event->complete(); 45 | $this->assertTrue( $result, 'event was successfully completed' ); 46 | $this->assertEquals( Events_Store::STATUS_COMPLETED, $event->get_status(), 'the status was updated' ); 47 | $this->assertNotEquals( Event::create_instance_hash( [ 'test', 'args' ] ), $event->get_instance(), 'the instance was updated/randomized' ); 48 | } 49 | 50 | function test_reschedule() { 51 | // Try to reschedule a non-recurring event. 52 | $event = new Event(); 53 | $event->set_action( 'test_reschedule' ); 54 | $event->set_timestamp( time() + 10 ); 55 | $event->save(); 56 | $result = $event->reschedule(); 57 | $this->assertEquals( 'cron-control:event:cannot-reschedule', $result->get_error_code() ); 58 | $this->assertEquals( Events_Store::STATUS_COMPLETED, $event->get_status() ); 59 | 60 | // Mock up recurring event, but try to reschedule before saving. 61 | $event = new Event(); 62 | $event->set_action( 'test_reschedule' ); 63 | $event->set_timestamp( time() + 10 ); 64 | $event->set_schedule( 'hourly', HOUR_IN_SECONDS ); 65 | $result = $event->reschedule(); 66 | $this->assertEquals( 'cron-control:event:cannot-reschedule', $result->get_error_code() ); 67 | 68 | // Now save the event and make sure props were updated correctly. 69 | $event->save(); 70 | $result = $event->reschedule(); 71 | $this->assertTrue( $result, 'event was successfully rescheduled' ); 72 | $this->assertEquals( Events_Store::STATUS_PENDING, $event->get_status() ); 73 | $this->assertEquals( time() + HOUR_IN_SECONDS, $event->get_timestamp() ); 74 | } 75 | 76 | function test_exists() { 77 | $event = new Event(); 78 | $event->set_action( 'test_exists' ); 79 | $event->set_timestamp( time() ); 80 | $this->assertFalse( $event->exists() ); 81 | 82 | $event->save(); 83 | $this->assertTrue( $event->exists() ); 84 | } 85 | 86 | function test_create_instance_hash() { 87 | $empty_args = Event::create_instance_hash( [] ); 88 | $this->assertEquals( md5( serialize( [] ) ), $empty_args ); 89 | 90 | $has_args = Event::create_instance_hash( [ 'some', 'data' ] ); 91 | $this->assertEquals( md5( serialize( [ 'some', 'data' ] ) ), $has_args ); 92 | } 93 | 94 | function test_get_wp_event_format() { 95 | $event = new Event(); 96 | $event->set_action( 'test_get_wp_event_format' ); 97 | $event->set_timestamp( 123 ); 98 | $event->save(); 99 | 100 | $this->assertEquals( (object) [ 101 | 'hook' => 'test_get_wp_event_format', 102 | 'timestamp' => 123, 103 | 'schedule' => false, 104 | 'args' => [], 105 | ], $event->get_wp_event_format() ); 106 | 107 | $event->set_schedule( 'hourly', HOUR_IN_SECONDS ); 108 | $event->set_args( [ 'args' ] ); 109 | $event->save(); 110 | 111 | $this->assertEquals( (object) [ 112 | 'hook' => 'test_get_wp_event_format', 113 | 'timestamp' => 123, 114 | 'schedule' => 'hourly', 115 | 'interval' => HOUR_IN_SECONDS, 116 | 'args' => [ 'args' ], 117 | ], $event->get_wp_event_format() ); 118 | } 119 | 120 | function test_get() { 121 | $test_event = new Event(); 122 | $test_event->set_action( 'test_get_action' ); 123 | $test_event->set_timestamp( 1637447875 ); 124 | $test_event->save(); 125 | 126 | // Successful get by ID. 127 | $event = Event::get( $test_event->get_id() ); 128 | $this->assertEquals( 'test_get_action', $event->get_action(), 'found event by id' ); 129 | 130 | // Failed get by ID. 131 | $event = Event::get( PHP_INT_MAX ); 132 | $this->assertNull( $event, 'could not find event by ID' ); 133 | } 134 | 135 | function test_find() { 136 | $test_event = new Event(); 137 | $test_event->set_action( 'test_find_action' ); 138 | $test_event->set_timestamp( 1637447876 ); 139 | $test_event->save(); 140 | 141 | // Successful find by args. 142 | $event = Event::find( [ 'action' => 'test_find_action', 'timestamp' => 1637447876 ] ); 143 | $this->assertEquals( 'test_find_action', $event->get_action(), 'found event by args' ); 144 | 145 | // Failed find by args. 146 | $event = Event::find( [ 'action' => 'non_existent_action', 'timestamp' => 1637447876 ] ); 147 | $this->assertNull( $event, 'could not find event by args' ); 148 | } 149 | 150 | function test_validate_props() { 151 | // Invalid status. 152 | $this->run_event_save_test( [ 153 | 'creation' => [ 154 | 'args' => [ 155 | 'timestamp' => 1637447873, 156 | 'action' => 'test_event', 157 | 'status' => 'invalid_status', 158 | ], 159 | 'result' => new WP_Error( 'cron-control:event:prop-validation:invalid-status' ), 160 | ], 161 | ] ); 162 | 163 | // Invalid/missing action. 164 | $this->run_event_save_test( [ 165 | 'creation' => [ 166 | 'args' => [ 167 | 'timestamp' => 1637447873, 168 | 'action' => '', 169 | ], 170 | 'result' => new WP_Error( 'cron-control:event:prop-validation:invalid-action' ), 171 | ], 172 | ] ); 173 | 174 | // Missing timestamp. 175 | $this->run_event_save_test( [ 176 | 'creation' => [ 177 | 'args' => [ 'action' => 'test_event' ], 178 | 'result' => new WP_Error( 'cron-control:event:prop-validation:invalid-timestamp' ), 179 | ], 180 | ] ); 181 | 182 | // Invalid timestamp. 183 | $this->run_event_save_test( [ 184 | 'creation' => [ 185 | 'args' => [ 186 | 'timestamp' => -100, 187 | 'action' => 'test_event', 188 | ], 189 | 'result' => new WP_Error( 'cron-control:event:prop-validation:invalid-timestamp' ), 190 | ], 191 | ] ); 192 | 193 | // Invalid schedule. 194 | $this->run_event_save_test( [ 195 | 'creation' => [ 196 | 'args' => [ 197 | 'timestamp' => 1637447873, 198 | 'action' => 'test_event', 199 | 'schedule' => '', 200 | 'interval' => HOUR_IN_SECONDS, 201 | ], 202 | 'result' => new WP_Error( 'cron-control:event:prop-validation:invalid-schedule' ), 203 | ], 204 | ] ); 205 | 206 | // Invalid interval. 207 | $this->run_event_save_test( [ 208 | 'creation' => [ 209 | 'args' => [ 210 | 'timestamp' => 1637447873, 211 | 'action' => 'test_event', 212 | 'schedule' => 'hourly', 213 | 'interval' => 0, 214 | ], 215 | 'result' => new WP_Error( 'cron-control:event:prop-validation:invalid-schedule' ), 216 | ], 217 | ] ); 218 | } 219 | 220 | // Run through various flows of event saving. 221 | function test_event_save() { 222 | // Create event w/ bare information to test the defaults. 223 | // Then update the timestamp. 224 | $this->run_event_save_test( [ 225 | 'creation' => [ 226 | 'args' => [ 227 | 'action' => 'test_event_creations_1', 228 | 'timestamp' => 1637447872, 229 | ], 230 | 'result' => [ 231 | 'status' => 'pending', 232 | 'action' => 'test_event_creations_1', 233 | 'args' => [], 234 | 'schedule' => null, 235 | 'interval' => 0, 236 | 'timestamp' => 1637447872, 237 | ], 238 | ], 239 | 'update' => [ 240 | 'args' => [ 'timestamp' => 1637447872 + 500 ], 241 | 'result' => [ 242 | 'status' => 'pending', 243 | 'action' => 'test_event_creations_1', 244 | 'args' => [], 245 | 'schedule' => null, 246 | 'interval' => 0, 247 | 'timestamp' => 1637447872 + 500, 248 | ], 249 | ], 250 | ] ); 251 | 252 | // Create event w/ all non-default data. 253 | // Then try to update with invalid timestamp 254 | $this->run_event_save_test( [ 255 | 'creation' => [ 256 | 'args' => [ 257 | 'status' => 'complete', 258 | 'action' => 'test_event_creations_2', 259 | 'args' => [ 'some' => 'data' ], 260 | 'schedule' => 'hourly', 261 | 'interval' => HOUR_IN_SECONDS, 262 | 'timestamp' => 1637447873, 263 | ], 264 | 'result' => [ 265 | 'status' => 'complete', 266 | 'action' => 'test_event_creations_2', 267 | 'args' => [ 'some' => 'data' ], 268 | 'schedule' => 'hourly', 269 | 'interval' => HOUR_IN_SECONDS, 270 | 'timestamp' => 1637447873, 271 | ], 272 | ], 273 | 'update' => [ 274 | 'args' => [ 'timestamp' => -1 ], 275 | 'result' => new WP_Error( 'cron-control:event:prop-validation:invalid-timestamp' ), 276 | ], 277 | ] ); 278 | } 279 | 280 | private function run_event_save_test( array $event_data ) { 281 | // 1) Create event. 282 | $test_event = new Event(); 283 | Utils::apply_event_props( $test_event, $event_data['creation']['args'] ); 284 | $save_result = $test_event->save(); 285 | 286 | // 2) Verify event creation save results. 287 | $expected_result = $event_data['creation']['result']; 288 | $this->verify_save_result( $expected_result, $save_result, $test_event ); 289 | 290 | if ( ! isset( $event_data['update'] ) ) { 291 | // No update tests to perform. 292 | return; 293 | } 294 | 295 | // 3) Apply event updates. 296 | Utils::apply_event_props( $test_event, $event_data['update']['args'] ); 297 | $update_result = $test_event->save(); 298 | 299 | // 4) Verify the update result. 300 | $expected_update_result = $event_data['update']['result']; 301 | $this->verify_save_result( $expected_update_result, $update_result, $test_event ); 302 | } 303 | 304 | private function verify_save_result( $expected_result, $actual_result, $test_event ) { 305 | if ( is_wp_error( $expected_result ) ) { 306 | $this->assertEquals( $expected_result->get_error_code(), $actual_result->get_error_code(), 'save should fail w/ WP Error' ); 307 | // Nothing more to test. 308 | return; 309 | } 310 | 311 | $this->assertTrue( $actual_result, 'event was saved' ); 312 | $expected_result['id'] = $test_event->get_id(); 313 | 314 | Utils::assert_event_object_matches_database( $test_event, $expected_result, $this ); 315 | 316 | // Initiate the event again, testing the getters and ensuring data is hydrated correctly. 317 | $check_event = Event::get( $test_event->get_id() ); 318 | Utils::assert_event_object_has_correct_props( $check_event, $expected_result, $this ); 319 | } 320 | 321 | public function test_get_from_db_row() { 322 | // Create bare test event. 323 | $test_event = new Event(); 324 | $test_event->set_action( 'test_get_from_db_row' ); 325 | $test_event->set_timestamp( 1637447875 ); 326 | $test_event->save(); 327 | 328 | $expected_result = [ 329 | 'id' => $test_event->get_id(), 330 | 'status' => Events_Store::STATUS_PENDING, 331 | 'action' => 'test_get_from_db_row', 332 | 'args' => [], 333 | 'schedule' => null, 334 | 'interval' => 0, 335 | 'timestamp' => 1637447875, 336 | ]; 337 | 338 | $raw_event = Events_Store::instance()->_get_event_raw( $test_event->get_id() ); 339 | 340 | // Will populate the event w/ full data from database. 341 | $event = Event::get_from_db_row( $raw_event ); 342 | Utils::assert_event_object_has_correct_props( $event, $expected_result, $this ); 343 | 344 | // Will not populate event w/ partial data from database. 345 | unset( $raw_event->ID ); 346 | $by_missing_data = Event::get_from_db_row( $raw_event ); 347 | $this->assertNull( $by_missing_data, 'event could not be populated' ); 348 | } 349 | } 350 | -------------------------------------------------------------------------------- /__tests__/unit-tests/test-events-store.php: -------------------------------------------------------------------------------- 1 | assertEquals( count( $wpdb->get_col( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) ) ), 1 ); 24 | 25 | $this->assertTrue( Events_Store::is_installed() ); 26 | } 27 | 28 | function test_event_creation() { 29 | $store = Events_Store::instance(); 30 | 31 | // We don't validate fields here, so not much to test other than return values. 32 | $result = $store->_create_event( [ 33 | 'status' => Events_Store::STATUS_PENDING, 34 | 'action' => 'test_raw_event', 35 | 'action_hashed' => md5( 'test_raw_event' ), 36 | 'timestamp' => 1637447873, 37 | 'args' => serialize( [] ), 38 | 'instance' => Event::create_instance_hash( [] ), 39 | ] ); 40 | $this->assertTrue( is_int( $result ) && $result > 0, 'event was inserted' ); 41 | 42 | $empty_result = $store->_create_event( [] ); 43 | $this->assertTrue( 0 === $empty_result, 'empty event was not inserted' ); 44 | } 45 | 46 | function test_event_updates() { 47 | $store = Events_Store::instance(); 48 | 49 | // Make a valid event. 50 | $event = new Event(); 51 | $event->set_action( 'test_get_action' ); 52 | $event->set_timestamp( 1637447875 ); 53 | $event->save(); 54 | 55 | $result = $store->_update_event( $event->get_id(), [ 'timestamp' => 1637447875 + 100 ] ); 56 | $this->assertTrue( $result, 'event was updated' ); 57 | 58 | // Spot check the updated property. 59 | $raw_event = $store->_get_event_raw( $event->get_id() ); 60 | $this->assertEquals( 1637447875 + 100, $raw_event->timestamp ); 61 | 62 | $failed_result = $store->_update_event( $event->get_id(), [] ); 63 | $this->assertFalse( $failed_result, 'event was not updated due to invalid args' ); 64 | } 65 | 66 | function test_get_raw_event() { 67 | $store = Events_Store::instance(); 68 | 69 | $result = $store->_get_event_raw( -1 ); 70 | $this->assertNull( $result, 'returns null when given invalid ID' ); 71 | 72 | $result = $store->_get_event_raw( PHP_INT_MAX ); 73 | $this->assertNull( $result, 'returns null when given an non-existent ID' ); 74 | 75 | // Event w/ all defaults. 76 | $this->run_get_raw_event_test( [ 77 | 'creation_args' => [ 78 | 'action' => 'test_event', 79 | 'timestamp' => 1637447873, 80 | ], 81 | 'expected_data' => [ 82 | 'status' => Events_Store::STATUS_PENDING, 83 | 'action' => 'test_event', 84 | 'args' => [], 85 | 'schedule' => null, 86 | 'interval' => 0, 87 | 'timestamp' => 1637447873, 88 | ], 89 | ] ); 90 | 91 | // Event w/ all non-defaults. 92 | $this->run_get_raw_event_test( [ 93 | 'creation_args' => [ 94 | 'status' => Events_Store::STATUS_COMPLETED, 95 | 'action' => 'test_event', 96 | 'args' => [ 'some' => 'data' ], 97 | 'schedule' => 'hourly', 98 | 'interval' => HOUR_IN_SECONDS, 99 | 'timestamp' => 1637447873, 100 | ], 101 | 'expected_data' => [ 102 | 'status' => Events_Store::STATUS_COMPLETED, 103 | 'action' => 'test_event', 104 | 'args' => [ 'some' => 'data' ], 105 | 'schedule' => 'hourly', 106 | 'interval' => HOUR_IN_SECONDS, 107 | 'timestamp' => 1637447873, 108 | ], 109 | ] ); 110 | } 111 | 112 | private function run_get_raw_event_test( array $event_data ) { 113 | $test_event = Utils::create_test_event( $event_data['creation_args'] ); 114 | 115 | $expected_data = $event_data['expected_data']; 116 | $expected_data['id'] = $test_event->get_id(); 117 | 118 | Utils::assert_event_object_matches_database( $test_event, $expected_data, $this ); 119 | } 120 | 121 | public function test_query_raw_events() { 122 | $store = Events_Store::instance(); 123 | 124 | $args = [ 125 | 'status' => Events_Store::STATUS_PENDING, 126 | 'action' => 'test_query_raw_events', 127 | 'args' => [ 'some' => 'data' ], 128 | 'schedule' => 'hourly', 129 | 'interval' => HOUR_IN_SECONDS, 130 | ]; 131 | 132 | $event_one = Utils::create_test_event( array_merge( $args, [ 'timestamp' => 1 ] ) ); 133 | $event_two = Utils::create_test_event( array_merge( $args, [ 'timestamp' => 2 ] ) ); 134 | $event_three = Utils::create_test_event( array_merge( $args, [ 'timestamp' => 3 ] ) ); 135 | $event_four = Utils::create_test_event( array_merge( $args, [ 'timestamp' => 4 ] ) ); 136 | 137 | // Should give us just the first event that has the oldest timestamp. 138 | $result = $store->_query_events_raw( [ 139 | 'status' => [ Events_Store::STATUS_PENDING ], 140 | 'action' => 'test_query_raw_events', 141 | 'args' => [ 'some' => 'data' ], 142 | 'schedule' => 'hourly', 143 | 'limit' => 1, 144 | ] ); 145 | 146 | $this->assertEquals( 1, count( $result ), 'returns one event w/ oldest timestamp' ); 147 | $this->assertEquals( $event_one->get_timestamp(), $result[0]->timestamp, 'found the right event' ); 148 | 149 | // Should give two events now, in desc order 150 | $result = $store->_query_events_raw( [ 151 | 'status' => [ Events_Store::STATUS_PENDING ], 152 | 'action' => 'test_query_raw_events', 153 | 'args' => [ 'some' => 'data' ], 154 | 'schedule' => 'hourly', 155 | 'limit' => 2, 156 | 'order' => 'desc', 157 | ] ); 158 | 159 | $this->assertEquals( 2, count( $result ), 'returned 2 events' ); 160 | $this->assertEquals( $event_four->get_timestamp(), $result[0]->timestamp, 'found the right event' ); 161 | $this->assertEquals( $event_three->get_timestamp(), $result[1]->timestamp, 'found the right event' ); 162 | 163 | // Should find just the middle two events that match the timeframe. 164 | $result = $store->_query_events_raw( [ 165 | 'status' => [ Events_Store::STATUS_PENDING ], 166 | 'action' => 'test_query_raw_events', 167 | 'args' => [ 'some' => 'data' ], 168 | 'schedule' => 'hourly', 169 | 'limit' => 100, 170 | 'timestamp' => [ 'from' => 2, 'to' => 3 ], 171 | ] ); 172 | 173 | $this->assertEquals( 2, count( $result ), 'returned middle events that match the timeframe' ); 174 | $this->assertEquals( $event_two->get_timestamp(), $result[0]->timestamp, 'found the right event' ); 175 | $this->assertEquals( $event_three->get_timestamp(), $result[1]->timestamp, 'found the right event' ); 176 | 177 | $event_five = Utils::create_test_event( array_merge( $args, [ 'timestamp' => time() + 5 ] ) ); 178 | 179 | // Should find all but the last event that is not due yet. 180 | $result = $store->_query_events_raw( [ 181 | 'status' => [ Events_Store::STATUS_PENDING ], 182 | 'action' => 'test_query_raw_events', 183 | 'args' => [ 'some' => 'data' ], 184 | 'schedule' => 'hourly', 185 | 'limit' => 100, 186 | 'timestamp' => 'due_now', 187 | ] ); 188 | 189 | $this->assertEquals( 4, count( $result ), 'returned all due now events' ); 190 | $this->assertEquals( $event_one->get_timestamp(), $result[0]->timestamp, 'found the right event' ); 191 | $this->assertEquals( $event_four->get_timestamp(), $result[3]->timestamp, 'found the right event' ); 192 | 193 | // Grab the second page. 194 | $result = $store->_query_events_raw( [ 195 | 'status' => [ Events_Store::STATUS_PENDING ], 196 | 'action' => 'test_query_raw_events', 197 | 'args' => [ 'some' => 'data' ], 198 | 'schedule' => 'hourly', 199 | 'limit' => 1, 200 | 'page' => 2, 201 | ] ); 202 | 203 | $this->assertEquals( 1, count( $result ), 'returned event from second page' ); 204 | $this->assertEquals( $event_two->get_timestamp(), $result[0]->timestamp, 'found the right event' ); 205 | } 206 | 207 | public function test_query_raw_events_orderby() { 208 | $store = Events_Store::instance(); 209 | 210 | $event_one = Utils::create_test_event( [ 'timestamp' => 5, 'action' => 'test_query_raw_events_orderby' ] ); 211 | $event_two = Utils::create_test_event( [ 'timestamp' => 2, 'action' => 'test_query_raw_events_orderby' ] ); 212 | $event_three = Utils::create_test_event( [ 'timestamp' => 3, 'action' => 'test_query_raw_events_orderby' ] ); 213 | $event_four = Utils::create_test_event( [ 'timestamp' => 1, 'action' => 'test_query_raw_events_orderby' ] ); 214 | 215 | // Default orderby should be timestamp ASC 216 | $result = $store->_query_events_raw(); 217 | $this->assertEquals( 4, count( $result ), 'returned the correct amount of events' ); 218 | $this->assertEquals( $event_four->get_timestamp(), $result[0]->timestamp, 'the oldest "due now" event is returned first' ); 219 | 220 | // Fetch by timestamp in descending order. 221 | $result = $store->_query_events_raw( [ 'orderby' => 'timestamp', 'order' => 'desc' ] ); 222 | $this->assertEquals( 4, count( $result ), 'returned the correct amount of events' ); 223 | $this->assertEquals( $event_one->get_timestamp(), $result[0]->timestamp, 'the farthest "due now" event is returned first' ); 224 | 225 | // Fetch by ID in ascending order. 226 | $result = $store->_query_events_raw( [ 'orderby' => 'ID', 'order' => 'asc' ] ); 227 | $this->assertEquals( 4, count( $result ), 'returned the correct amount of events' ); 228 | $this->assertEquals( $event_one->get_id(), $result[0]->ID, 'the lowest ID is returned first' ); 229 | 230 | // Fetch by ID in descending order. 231 | $result = $store->_query_events_raw( [ 'orderby' => 'ID', 'order' => 'desc' ] ); 232 | $this->assertEquals( 4, count( $result ), 'returned the correct amount of events' ); 233 | $this->assertEquals( $event_four->get_id(), $result[0]->ID, 'the highest ID is returned first' ); 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /__tests__/unit-tests/test-events.php: -------------------------------------------------------------------------------- 1 | 'test_query_action', 'args' => [ 'first' ], 'timestamp' => 1 ] ); 23 | Utils::create_test_event( [ 'action' => 'test_query_action', 'args' => [ 'second' ], 'timestamp' => 2 ] ); 24 | 25 | // Ensure both are returned 26 | $events = Events::query( [ 'action' => 'test_query_action', 'limit' => 100 ] ); 27 | $this->assertEquals( count( $events ), 2, 'Correct number of events returned' ); 28 | $this->assertEquals( $events[0]->get_args(), [ 'first' ], 'Oldest event returned first by default' ); 29 | $this->assertEquals( $events[1]->get_args(), [ 'second' ], 'Correct second event also found.' ); 30 | 31 | // Empty array when none are found. 32 | $events = Events::query( [ 'action' => 'non_existent_action', 'limit' => 100 ] ); 33 | $this->assertEquals( $events, [], 'Returns empty array when no results found' ); 34 | } 35 | 36 | function test_format_events_for_wp() { 37 | $events = $this->create_test_events(); 38 | 39 | $expected_format = [ 40 | $events['A']->get_timestamp() => [ // A & B & C share timestamps 41 | $events['A']->get_action() => [ // A & B share action 42 | $events['A']->get_instance() => [ 43 | 'schedule' => false, 44 | 'args' => [], 45 | ], 46 | $events['B']->get_instance() => [ 47 | 'schedule' => 'hourly', 48 | 'interval' => HOUR_IN_SECONDS, 49 | 'args' => [ 'B' ], 50 | ], 51 | ], 52 | $events['C']->get_action() => [ // C has it's own action 53 | $events['C']->get_instance() => [ 54 | 'schedule' => false, 55 | 'args' => [ 'C' ], 56 | ], 57 | ], 58 | ], 59 | $events['D']->get_timestamp() => [ // D is on it's own since the timestamp is unique. 60 | $events['D']->get_action() => [ 61 | $events['D']->get_instance() => [ 62 | 'schedule' => false, 63 | 'args' => [ 'D' ], 64 | ], 65 | ], 66 | ], 67 | ]; 68 | 69 | $formatted = Events::format_events_for_wp( array_values( $events ) ); 70 | $this->assertEquals( $formatted, $expected_format, 'Returns the correct array format' ); 71 | 72 | $empty_formatted = Events::format_events_for_wp( [] ); 73 | $this->assertEquals( $empty_formatted, [], 'Returns empty array when no events to format' ); 74 | } 75 | 76 | function test_flatten_wp_events_array() { 77 | // Setup an events array the way WP gives it to us. 78 | $events = $this->create_test_events(); 79 | $formatted = Events::format_events_for_wp( array_values( $events ) ); 80 | $formatted['version'] = 2; 81 | 82 | // Ensure we flatten it w/ all events accounted for. 83 | $flattened = Events::flatten_wp_events_array( $formatted ); 84 | $this->assertEquals( count( $flattened ), 4, 'Returns all expected events' ); 85 | // Could maybe test more here, but honestly feels like it would couple too closely to the implementation itself. 86 | } 87 | 88 | private function create_test_events() { 89 | $time_one = 1400000000; 90 | $time_two = 1500000000; 91 | 92 | $events_to_create = [ 93 | 'A' => [ 94 | 'action' => 'test_format_events_for_wp', 95 | 'args' => [], 96 | 'timestamp' => $time_one, 97 | ], 98 | 'B' => [ 99 | 'action' => 'test_format_events_for_wp', 100 | 'args' => [ 'B' ], 101 | 'timestamp' => $time_one, 102 | 'schedule' => 'hourly', 103 | 'interval' => HOUR_IN_SECONDS, 104 | ], 105 | 'C' => [ 106 | 'action' => 'test_format_events_for_wp_two', 107 | 'args' => [ 'C' ], 108 | 'timestamp' => $time_one, 109 | ], 110 | 'D' => [ 111 | 'action' => 'test_format_events_for_wp', 112 | 'args' => [ 'D' ], 113 | 'timestamp' => $time_two, 114 | ], 115 | ]; 116 | 117 | $events = []; 118 | foreach ( $events_to_create as $event_key => $event_args ) { 119 | $events[ $event_key ] = Utils::create_test_event( $event_args ); 120 | } 121 | 122 | return $events; 123 | } 124 | 125 | function test_get_events() { 126 | $events = Events::instance(); 127 | 128 | $test_events = $this->register_active_events_for_listing(); 129 | 130 | // Fetch w/ default args = (10 + internal) max events, +30 seconds window. 131 | $results = $events->get_events(); 132 | $due_now_events = [ $test_events['test_event_1'], $test_events['test_event_2'], $test_events['test_event_3'] ]; 133 | $this->check_get_events( $results, $due_now_events ); 134 | 135 | // Fetch w/ 1 max queue size. 136 | $results = $events->get_events( 1 ); 137 | $first_event = [ $test_events['test_event_1'] ]; 138 | $this->check_get_events( $results, $first_event ); 139 | 140 | // Fetch w/ +11mins queue window (should exclude just our last event +30min event). 141 | $results = $events->get_events( null, 60 * 11 ); 142 | $window_events = [ 143 | $test_events['test_event_1'], 144 | $test_events['test_event_2'], 145 | $test_events['test_event_3'], 146 | $test_events['test_event_4'], 147 | $test_events['test_event_5'], 148 | ]; 149 | $this->check_get_events( $results, $window_events ); 150 | } 151 | 152 | private function check_get_events( $results, $desired_results ) { 153 | $this->assertEquals( count( $results['events'] ), count( $desired_results ), 'Incorrect number of events returned' ); 154 | 155 | foreach ( $results['events'] as $event ) { 156 | $this->assertContains( $event['action'], wp_list_pluck( $desired_results, 'hashed' ), 'Missing registered event' ); 157 | } 158 | } 159 | 160 | private function register_active_events_for_listing() { 161 | $test_events = [ 162 | [ 'timestamp' => strtotime( '-1 minute' ), 'action' => 'test_event_1' ], 163 | [ 'timestamp' => time(), 'action' => 'test_event_2' ], 164 | [ 'timestamp' => time(), 'action' => 'test_event_3' ], 165 | [ 'timestamp' => strtotime( '+5 minutes' ), 'action' => 'test_event_4' ], 166 | [ 'timestamp' => strtotime( '+10 minutes' ), 'action' => 'test_event_5' ], 167 | [ 'timestamp' => strtotime( '+30 minutes' ), 'action' => 'test_event_6' ], 168 | ]; 169 | 170 | $scheduled = []; 171 | foreach ( $test_events as $test_event_args ) { 172 | $event = Utils::create_test_event( $test_event_args ); 173 | $scheduled[ $event->get_action() ] = [ 174 | 'action' => $event->get_action(), 175 | 'hashed' => md5( $event->get_action() ), 176 | ]; 177 | } 178 | 179 | return $scheduled; 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /__tests__/unit-tests/test-internal-events.php: -------------------------------------------------------------------------------- 1 | schedule_internal_events(); 20 | $scheduled_events = Cron_Control\Events::query( [ 'limit' => 100 ] ); 21 | 22 | $expected_count = 4; // Number of events created by the Internal_Events::prepare_internal_events() method, which is private. 23 | $expected_count += count( \CRON_CONTROL_ADDITIONAL_INTERNAL_EVENTS ); 24 | $this->assertEquals( count( $scheduled_events ), $expected_count, 'Correct number of Internal Events registered' ); 25 | 26 | foreach ( $scheduled_events as $scheduled_event ) { 27 | $this->assertTrue( $scheduled_event->is_internal(), sprintf( 'Action `%s` is not an Internal Event', $scheduled_event->get_action() ) ); 28 | } 29 | } 30 | 31 | function test_migrate_legacy_cron_events() { 32 | global $wpdb; 33 | 34 | // Ensure we start with an empty cron option. 35 | delete_option( 'cron' ); 36 | 37 | // Create one saved event and two unsaved events. 38 | $existing_event = Utils::create_test_event( [ 'timestamp' => time(), 'action' => 'existing_event' ] ); 39 | $legacy_event = Utils::create_unsaved_event( [ 'timestamp' => time() + 500, 'action' => 'legacy_event' ] ); 40 | $legacy_recurring_event = Utils::create_unsaved_event( [ 'timestamp' => time() + 600, 'action' => 'legacy_recurring_event', 'schedule' => 'hourly', 'interval' => \HOUR_IN_SECONDS ] ); 41 | 42 | $cron_array = Cron_Control\Events::format_events_for_wp( [ $existing_event, $legacy_event, $legacy_recurring_event ] ); 43 | $cron_array['version'] = 2; 44 | 45 | // Put the legacy event directly into the cron option, avoiding our special filtering. @codingStandardsIgnoreLine 46 | $result = $wpdb->query( $wpdb->prepare( "INSERT INTO `$wpdb->options` (`option_name`, `option_value`, `autoload`) VALUES (%s, %s, %s)", 'cron', serialize( $cron_array ), 'yes' ) ); 47 | wp_cache_delete( 'alloptions', 'options' ); 48 | wp_cache_delete( 'notoptions', 'options' ); 49 | wp_cache_delete( 'cron', 'options' ); 50 | 51 | // Run the migration. 52 | Cron_Control\Internal_Events::instance()->clean_legacy_data(); 53 | 54 | // Should now have all three events registered. 55 | $registered_events = Cron_Control\Events::query( [ 'limit' => 100 ] ); 56 | $this->assertEquals( 3, count( $registered_events ), 'correct number of registered events' ); 57 | $this->assertEquals( $registered_events[0]->get_action(), $existing_event->get_action(), 'existing event stayed registered' ); 58 | $this->assertEquals( $registered_events[1]->get_action(), $legacy_event->get_action(), 'legacy event was registered' ); 59 | $this->assertEquals( $registered_events[2]->get_schedule(), $legacy_recurring_event->get_schedule(), 'legacy recurring event was registered' ); 60 | 61 | $cron_row = $wpdb->get_row( "SELECT * FROM $wpdb->options WHERE option_name = 'cron'" ); 62 | $this->assertNull( $cron_row, 'cron option was deleted' ); 63 | } 64 | 65 | function test_prune_duplicate_events() { 66 | // We don't prune single events, even if duplicates. 67 | $original_single_event = Utils::create_test_event( [ 'timestamp' => time(), 'action' => 'single_event', 'args' => [ 'same' ] ] ); 68 | $duplicate_single_event = Utils::create_test_event( [ 'timestamp' => time() + 100, 'action' => 'single_event', 'args' => [ 'same' ] ] ); 69 | $unique_single_event = Utils::create_test_event( [ 'timestamp' => time() + 200, 'action' => 'single_event', 'args' => [ 'unique' ] ] ); 70 | 71 | // We do prune duplicate recurring events. 72 | $original_recurring_event = Utils::create_test_event( [ 'timestamp' => time() + 500, 'action' => 'recurring_event', 'schedule' => 'hourly', 'interval' => \HOUR_IN_SECONDS ] ); 73 | $duplicate_recurring_event = Utils::create_test_event( [ 'timestamp' => time() + 100, 'action' => 'recurring_event', 'schedule' => 'hourly', 'interval' => \HOUR_IN_SECONDS ] ); 74 | $duplicate_recurring_event2 = Utils::create_test_event( [ 'timestamp' => time() + 200, 'action' => 'recurring_event', 'schedule' => 'hourly', 'interval' => \HOUR_IN_SECONDS ] ); 75 | $unique_recurring_event = Utils::create_test_event( [ 'timestamp' => time() + 100, 'action' => 'recurring_event', 'schedule' => 'hourly', 'interval' => \HOUR_IN_SECONDS, 'args' => [ 'unique' ] ] ); 76 | 77 | // This prevent events starting with `wp_` from being scheduled, like wp_version_check, 78 | // wp_update_plugins or wp_update_themes to avoid affecting the count assertions. 79 | $prevent_wp_cron_events = function ( $event ) { 80 | if ( str_starts_with( $event->hook, 'wp_' ) ) { 81 | return false; 82 | } 83 | return $event; 84 | }; 85 | 86 | // Filter to block any WordPress core cron events so the test events are isolated. 87 | add_filter( 'schedule_event', $prevent_wp_cron_events ); 88 | 89 | // Run the pruning. 90 | Cron_Control\Internal_Events::instance()->clean_legacy_data(); 91 | 92 | // Remove the filter after the pruning calls. 93 | remove_filter( 'schedule_event', $prevent_wp_cron_events ); 94 | 95 | // Should have 5 events left, and the oldest IDs should have been kept.. 96 | $remaining_events = Cron_Control\Events::query( [ 'limit' => 100, 'orderby' => 'ID', 'order' => 'ASC' ] ); 97 | $this->assertCount( 5, $remaining_events, 'correct number of registered events left after pruning' ); 98 | $this->assertEquals( $remaining_events[0]->get_id(), $original_single_event->get_id(), 'original single event was kept' ); 99 | $this->assertEquals( $remaining_events[1]->get_id(), $duplicate_single_event->get_id(), 'duplicate single event was also kept' ); 100 | $this->assertEquals( $remaining_events[2]->get_id(), $unique_single_event->get_id(), 'unique single event was kept' ); 101 | $this->assertEquals( $remaining_events[3]->get_id(), $original_recurring_event->get_id(), 'original recurring event was kept' ); 102 | $this->assertEquals( $remaining_events[4]->get_id(), $unique_recurring_event->get_id(), 'unique recurring event was kept' ); 103 | 104 | // The two duplicates should be marked as completed now. 105 | $duplicate_recurring_1 = Cron_Control\Event::get( $duplicate_recurring_event->get_id() ); 106 | $duplicate_recurring_2 = Cron_Control\Event::get( $duplicate_recurring_event2->get_id() ); 107 | $this->assertEquals( $duplicate_recurring_1->get_status(), Cron_Control\Events_Store::STATUS_COMPLETED, 'duplicate recurring event 1 was marked as completed' ); 108 | $this->assertEquals( $duplicate_recurring_2->get_status(), Cron_Control\Events_Store::STATUS_COMPLETED, 'duplicate recurring event 2 was marked as completed' ); 109 | } 110 | 111 | function test_force_publish_missed_schedules() { 112 | // Define the filter callback to override post status. 113 | $future_insert_filter = function ( $data ) { 114 | if ( 'publish' === $data['post_status'] ) { 115 | $data['post_status'] = 'future'; // Ensure it remains future even if the date is in the past. 116 | } 117 | return $data; 118 | }; 119 | 120 | // Add the filter to ensure 'future' posts with past dates are not auto-published. 121 | add_filter( 'wp_insert_post_data', $future_insert_filter ); 122 | 123 | // Create two posts with a 'future' status. 124 | $this->factory()->post->create( 125 | array( 126 | 'post_title' => 'Future post that should be published', 127 | 'post_status' => 'future', 128 | 'post_type' => 'post', 129 | 'post_date' => gmdate( 'Y-m-d H:i:s', time() - 1000 ), 130 | ) 131 | ); 132 | 133 | $this->factory()->post->create( 134 | array( 135 | 'post_title' => 'Future post that should not be published', 136 | 'post_status' => 'future', 137 | 'post_type' => 'post', 138 | 'post_date' => gmdate( 'Y-m-d H:i:s', time() + 1000 ), 139 | ) 140 | ); 141 | 142 | // Remove the filter after creating the test posts. 143 | remove_filter( 'wp_insert_post_data', $future_insert_filter ); 144 | 145 | // Count posts with 'future' status before running the method. 146 | $future_posts_before = get_posts( 147 | array( 148 | 'post_status' => 'future', 149 | 'numberposts' => -1, 150 | ) 151 | ); 152 | 153 | $this->assertCount( 2, $future_posts_before, 'Two posts should be scheduled initially.' ); 154 | 155 | // Run the function to publish missed schedules. 156 | Cron_Control\Internal_Events::instance()->force_publish_missed_schedules(); 157 | 158 | // Query posts again after running the function. 159 | $future_posts_after = get_posts( 160 | array( 161 | 'post_status' => 'future', 162 | 'post_type' => 'post', 163 | 'numberposts' => -1, 164 | ) 165 | ); 166 | 167 | $published_posts = get_posts( 168 | array( 169 | 'post_status' => 'publish', 170 | 'post_type' => 'post', 171 | 'numberposts' => -1, 172 | ) 173 | ); 174 | 175 | // Assert counts after the function runs. 176 | $this->assertCount( 1, $future_posts_after, 'One post should still be scheduled.' ); 177 | $this->assertCount( 1, $published_posts, 'One post should be published.' ); 178 | } 179 | 180 | public function test_confirm_scheduled_posts() { 181 | // Create posts with 'future' status. 182 | $future_posts = array( 183 | $this->factory()->post->create( 184 | array( 185 | 'post_title' => '1 hour in the future', 186 | 'post_status' => 'future', 187 | 'post_type' => 'post', 188 | 'post_date' => gmdate( 'Y-m-d H:i:s', strtotime( '+1 hour' ) ), 189 | ) 190 | ), 191 | $this->factory()->post->create( 192 | array( 193 | 'post_title' => '2 hours in the future', 194 | 'post_status' => 'future', 195 | 'post_type' => 'post', 196 | 'post_date' => gmdate( 'Y-m-d H:i:s', strtotime( '+2 hours' ) ), 197 | ) 198 | ), 199 | $this->factory()->post->create( 200 | array( 201 | 'post_title' => '3 hours in the future', 202 | 'post_status' => 'future', 203 | 'post_type' => 'post', 204 | 'post_date' => gmdate( 'Y-m-d H:i:s', strtotime( '+3 hours' ) ), 205 | ) 206 | ), 207 | ); 208 | 209 | // Clear existing cron events to isolate the test. 210 | Utils::clear_cron_table(); 211 | 212 | // Query all cron events to confirm none exist. 213 | $events = Cron_Control\Events::query(); 214 | $this->assertEmpty( $events, 'No scheduled events should exist initially.' ); 215 | 216 | // Call the method to confirm scheduled posts. 217 | Cron_Control\Internal_Events::instance()->confirm_scheduled_posts(); 218 | 219 | // Verify that cron jobs are scheduled for each future post. 220 | foreach ( $future_posts as $future_post_id ) { 221 | $timestamp = wp_next_scheduled( 'publish_future_post', array( $future_post_id ) ); 222 | $this->assertNotFalse( $timestamp, "Cron job should be scheduled for post ID: $future_post_id." ); 223 | } 224 | 225 | // Reschedule one post with a different timestamp and call the method again. 226 | $future_post_gmt_time = strtotime( get_gmt_from_date( get_post( $future_posts[0] )->post_date ) . ' GMT' ); 227 | wp_clear_scheduled_hook( 'publish_future_post', array( $future_posts[0] ) ); 228 | wp_schedule_single_event( $future_post_gmt_time - 3600, 'publish_future_post', array( $future_posts[0] ) ); // Schedule 1 hour earlier. 229 | 230 | Cron_Control\Internal_Events::instance()->confirm_scheduled_posts(); 231 | 232 | // Verify the post's cron job has been rescheduled to the correct timestamp. 233 | $rescheduled_timestamp = wp_next_scheduled( 'publish_future_post', array( $future_posts[0] ) ); 234 | $this->assertEquals( $future_post_gmt_time, $rescheduled_timestamp, 'Cron job for post 1 should be rescheduled to the correct timestamp.' ); 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /__tests__/unit-tests/test-rest-api.php: -------------------------------------------------------------------------------- 1 | server = $wp_rest_server; 17 | do_action( 'rest_api_init' ); 18 | 19 | Utils::clear_cron_table(); 20 | } 21 | 22 | function tearDown(): void { 23 | global $wp_rest_server; 24 | $wp_rest_server = null; 25 | 26 | Utils::clear_cron_table(); 27 | parent::tearDown(); 28 | } 29 | 30 | /** 31 | * Verify that GET requests to the endpoint fail 32 | */ 33 | public function test_invalid_request() { 34 | $request = new WP_REST_Request( 'GET', '/' . REST_API::API_NAMESPACE . '/' . REST_API::ENDPOINT_LIST ); 35 | $response = $this->server->dispatch( $request ); 36 | $this->assertResponseStatus( 404, $response ); 37 | } 38 | 39 | /** 40 | * Test that list endpoint returns expected format 41 | */ 42 | public function test_get_items() { 43 | $event = Utils::create_test_event(); 44 | 45 | // Don't test internal events with this test. 46 | $internal_events = array( 47 | 'a8c_cron_control_force_publish_missed_schedules', 48 | 'a8c_cron_control_confirm_scheduled_posts', 49 | 'a8c_cron_control_clean_legacy_data', 50 | 'a8c_cron_control_purge_completed_events', 51 | ); 52 | foreach ( $internal_events as $internal_event ) { 53 | wp_clear_scheduled_hook( $internal_event ); 54 | } 55 | 56 | $request = new WP_REST_Request( 'POST', '/' . REST_API::API_NAMESPACE . '/' . REST_API::ENDPOINT_LIST ); 57 | $request->set_body( 58 | wp_json_encode( 59 | array( 60 | 'secret' => WP_CRON_CONTROL_SECRET, 61 | ) 62 | ) 63 | ); 64 | $request->set_header( 'content-type', 'application/json' ); 65 | 66 | $response = $this->server->dispatch( $request ); 67 | $data = $response->get_data(); 68 | 69 | $this->assertResponseStatus( 200, $response ); 70 | $this->assertArrayHasKey( 'events', $data ); 71 | $this->assertArrayHasKey( 'endpoint', $data ); 72 | $this->assertArrayHasKey( 'total_events_pending', $data ); 73 | 74 | $this->assertResponseData( 75 | array( 76 | 'events' => array( 77 | array( 78 | 'timestamp' => $event->get_timestamp(), 79 | 'action' => md5( $event->get_action() ), 80 | 'instance' => $event->get_instance(), 81 | ), 82 | ), 83 | 'endpoint' => get_rest_url( null, REST_API::API_NAMESPACE . '/' . REST_API::ENDPOINT_RUN ), 84 | 'total_events_pending' => 1, 85 | ), 86 | $response 87 | ); 88 | } 89 | 90 | /** 91 | * Test that list endpoint returns expected format 92 | */ 93 | public function test_run_event() { 94 | $event = Utils::create_test_event(); 95 | 96 | $expected_data = [ 97 | 'action' => md5( $event->get_action() ), 98 | 'instance' => $event->get_instance(), 99 | 'timestamp' => $event->get_timestamp(), 100 | 'secret' => WP_CRON_CONTROL_SECRET, 101 | ]; 102 | 103 | $request = new WP_REST_Request( 'PUT', '/' . REST_API::API_NAMESPACE . '/' . REST_API::ENDPOINT_RUN ); 104 | $request->set_body( wp_json_encode( $expected_data ) ); 105 | $request->set_header( 'content-type', 'application/json' ); 106 | 107 | $response = $this->server->dispatch( $request ); 108 | $data = $response->get_data(); 109 | 110 | $this->assertResponseStatus( 200, $response ); 111 | $this->assertArrayHasKey( 'success', $data ); 112 | $this->assertArrayHasKey( 'message', $data ); 113 | } 114 | 115 | /** 116 | * Check response code 117 | * 118 | * @param string $status Status code. 119 | * @param object $response REST API response object. 120 | */ 121 | protected function assertResponseStatus( $status, $response ) { 122 | $this->assertEquals( $status, $response->get_status() ); 123 | } 124 | 125 | /** 126 | * Ensure response includes the expected data 127 | * 128 | * @param array $data Expected data. 129 | * @param object $response REST API response object. 130 | */ 131 | protected function assertResponseData( $data, $response ) { 132 | $this->assert_array_equals( $data, $response->get_data() ); 133 | } 134 | 135 | private function assert_array_equals( $expected, $test ) { 136 | $tested_data = array(); 137 | 138 | foreach ( $expected as $key => $value ) { 139 | if ( isset( $test[ $key ] ) ) { 140 | $tested_data[ $key ] = $test[ $key ]; 141 | } else { 142 | $tested_data[ $key ] = null; 143 | } 144 | } 145 | 146 | $this->assertEquals( $expected, $tested_data ); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /__tests__/unit-tests/test-wp-adapter.php: -------------------------------------------------------------------------------- 1 | run_schedule_test( (object) [ 27 | 'timestamp' => time() + 500, 28 | 'hook' => 'test_pre_schedule_event_single', 29 | 'schedule' => false, 30 | 'args' => [], 31 | ] ); 32 | 33 | // Test recurring events. 34 | $this->run_schedule_test( (object) [ 35 | 'timestamp' => time() + 500, 36 | 'hook' => 'test_pre_schedule_event_recurring', 37 | 'args' => [], 38 | 'schedule' => 'hourly', 39 | 'interval' => HOUR_IN_SECONDS, 40 | ] ); 41 | } 42 | 43 | private function run_schedule_test( $event_args ) { 44 | // Make sure the return value is what core expects 45 | $result = Cron_Control\pre_schedule_event( null, $event_args ); 46 | $this->assertTrue( $result, 'scheduling was successful' ); 47 | 48 | // Ensure the event made it's way to the DB. 49 | $event = Event::find( [ 'action' => $event_args->hook ] ); 50 | $this->assertEquals( $event_args->timestamp, $event->get_timestamp() ); 51 | 52 | // Try to register again, and we get a duplicate event error. 53 | $fail_result = Cron_Control\pre_schedule_event( null, $event_args ); 54 | $this->assertEquals( 'cron-control:wp:duplicate-event', $fail_result->get_error_code() ); 55 | } 56 | 57 | function test_pre_reschedule_event() { 58 | $event_args = (object) [ 59 | 'timestamp' => time() - 500, // Past "due" keeps the calculation simple 60 | 'hook' => 'test_pre_reschedule_event', 61 | 'args' => [], 62 | 'schedule' => 'hourly', 63 | 'interval' => HOUR_IN_SECONDS, 64 | ]; 65 | 66 | // Schedule the event for the first time. 67 | Cron_Control\pre_schedule_event( null, $event_args ); 68 | 69 | // Make sure the successful return value is what core expects 70 | $result = Cron_Control\pre_reschedule_event( null, $event_args ); 71 | $this->assertTrue( $result, 'rescheduling was successful' ); 72 | 73 | // Ensure the event update made it's way to the DB. 74 | $event = Event::find( [ 'action' => $event_args->hook ] ); 75 | $this->assertEquals( $event_args->timestamp + HOUR_IN_SECONDS, $event->get_timestamp() ); 76 | 77 | // Should fail if it can't find the event. 78 | $event_args->action = 'test_pre_reschedule_event_missing'; 79 | $fail_result = Cron_Control\pre_reschedule_event( null, $event_args ); 80 | $this->assertEquals( 'cron-control:wp:event-not-found', $fail_result->get_error_code() ); 81 | } 82 | 83 | function test_pre_unschedule_event() { 84 | $event = (object) [ 85 | 'timestamp' => time(), 86 | 'hook' => 'test_pre_unschedule_event', 87 | 'args' => [], 88 | 'schedule' => false, 89 | ]; 90 | 91 | // Schedule the event for the first time. 92 | Cron_Control\pre_schedule_event( null, $event ); 93 | 94 | // Make sure the successful return value is what core expects 95 | $result = Cron_Control\pre_unschedule_event( null, $event->timestamp, $event->hook, $event->args ); 96 | $this->assertTrue( $result, 'unscheduling was successful' ); 97 | 98 | // Ensure the event update made it's way to the DB. 99 | $object = Event::find( [ 'action' => $event->hook, 'status' => Events_Store::STATUS_COMPLETED ] ); 100 | $this->assertEquals( Events_Store::STATUS_COMPLETED, $object->get_status() ); 101 | 102 | // Now that the event is "gone", it should fail to unschedule again. 103 | $fail_result = Cron_Control\pre_unschedule_event( null, $event->timestamp, $event->hook, $event->args ); 104 | $this->assertEquals( 'cron-control:wp:event-not-found', $fail_result->get_error_code() ); 105 | } 106 | 107 | function test_pre_clear_scheduled_hook() { 108 | $event_details = [ 109 | 'hook' => 'test_pre_clear_scheduled_hook', 110 | 'schedule' => false, 111 | 'timestamp' => time(), 112 | ]; 113 | 114 | $event_one = array_merge( $event_details, [ 'args' => [ 'default args' ] ] ); 115 | 116 | // Same args (instance), but very different timestamps so they both register. 117 | $event_two = array_merge( $event_details, [ 'args' => [ 'other args' ], 'timestamp' => time() + 500 ] ); 118 | $event_three = array_merge( $event_details, [ 'args' => [ 'other args' ], 'timestamp' => time() + 1500 ] ); 119 | 120 | // Schedule the events for the first time. 121 | Cron_Control\pre_schedule_event( null, (object) $event_one ); 122 | Cron_Control\pre_schedule_event( null, (object) $event_two ); 123 | Cron_Control\pre_schedule_event( null, (object) $event_three ); 124 | 125 | // Unschedule the two similar events. 126 | $result = Cron_Control\pre_clear_scheduled_hook( null, 'test_pre_clear_scheduled_hook', [ 'other args' ] ); 127 | $this->assertEquals( 2, $result, 'clearing was successful' ); 128 | 129 | // Ensure the event update made it's way to the DB. 130 | $object = Event::find( [ 131 | 'action' => 'test_pre_clear_scheduled_hook', 132 | 'timestamp' => $event_two['timestamp'], 133 | 'status' => Events_Store::STATUS_COMPLETED, 134 | ] ); 135 | $this->assertEquals( Events_Store::STATUS_COMPLETED, $object->get_status() ); 136 | 137 | // Empty args array should clear no events since we have not registered any w/ empty args. 138 | $result = Cron_Control\pre_clear_scheduled_hook( null, 'test_pre_clear_scheduled_hook', [] ); 139 | $this->assertEquals( 0, $result, 'no events were cleared' ); 140 | } 141 | 142 | function test_pre_unschedule_hook() { 143 | $event_details = [ 144 | 'hook' => 'test_pre_unschedule_hook', 145 | 'schedule' => false, 146 | 'timestamp' => time(), 147 | ]; 148 | 149 | $event_one = array_merge( $event_details, [ 'args' => [] ] ); 150 | $event_two = array_merge( $event_details, [ 'args' => [ 'default args' ] ] ); 151 | $event_three = array_merge( $event_details, [ 'args' => [ 'unique args' ] ] ); 152 | 153 | // Schedule the events. 154 | Cron_Control\pre_schedule_event( null, (object) $event_one ); 155 | Cron_Control\pre_schedule_event( null, (object) $event_two ); 156 | Cron_Control\pre_schedule_event( null, (object) $event_three ); 157 | 158 | // Should clear them all, even though args are different. 159 | $result = Cron_Control\pre_unschedule_hook( null, 'test_pre_unschedule_hook' ); 160 | $this->assertEquals( 3, $result, 'clearing was successful' ); 161 | 162 | // Ensure the event update made it's way to the DB. 163 | $object = Event::find( [ 164 | 'action' => 'test_pre_unschedule_hook', 165 | 'status' => Events_Store::STATUS_COMPLETED, 166 | ] ); 167 | $this->assertEquals( Events_Store::STATUS_COMPLETED, $object->get_status() ); 168 | 169 | // Nothing left to clear, returns 0 as WP expects. 170 | $result = Cron_Control\pre_unschedule_hook( null, 'test_pre_unschedule_hook' ); 171 | $this->assertEquals( 0, $result, 'nothing was cleared' ); 172 | } 173 | 174 | function test_pre_get_scheduled_event() { 175 | $event_details = (object) [ 176 | 'hook' => 'test_pre_get_scheduled_event', 177 | 'args' => [], 178 | 'timestamp' => time() + 2500, 179 | ]; 180 | 181 | // Event does not exist yet. 182 | $result = Cron_Control\pre_get_scheduled_event( null, $event_details->hook, $event_details->args, $event_details->timestamp ); 183 | $this->assertFalse( $result, 'event does not exist, returns false as WP expected' ); 184 | 185 | // Now it exists. 186 | Cron_Control\pre_schedule_event( null, $event_details ); 187 | $result = Cron_Control\pre_get_scheduled_event( null, $event_details->hook, $event_details->args, $event_details->timestamp ); 188 | $this->assertEquals( $result->hook, $event_details->hook ); 189 | 190 | // Make a similar event but w/ an earlier timestamp. 191 | $event_details->timestamp = time() + 10; 192 | Cron_Control\pre_schedule_event( null, $event_details ); 193 | 194 | // If we fetch w/o timestamp, it gets the (new) next occurring event. 195 | $result = Cron_Control\pre_get_scheduled_event( null, $event_details->hook, $event_details->args, null ); 196 | $this->assertEquals( $result->timestamp, $event_details->timestamp, 'fetched the next occurring event' ); 197 | } 198 | 199 | function test_pre_get_ready_cron_jobs() { 200 | $ready_jobs = Cron_Control\pre_get_ready_cron_jobs( null ); 201 | $this->assertTrue( [] === $ready_jobs, 'returns no ready jobs' ); 202 | 203 | // Schedule some events, two are due, two are in the future (one is recurring). 204 | $test_events = $this->create_test_events(); 205 | 206 | // Should give us the two due jobs. 207 | $ready_jobs = Cron_Control\pre_get_ready_cron_jobs( null ); 208 | $this->assertEquals( 2, count( $ready_jobs ), 'returns two ready jobs' ); 209 | 210 | // Make it flat for easier testing (this flattening is tested individually elsewhere) 211 | $flat_jobs = array_values( Events::flatten_wp_events_array( $ready_jobs ) ); 212 | $this->assert_event_matches_expected_args( $flat_jobs[0], $test_events['due_one'] ); 213 | $this->assert_event_matches_expected_args( $flat_jobs[1], $test_events['due_two'] ); 214 | } 215 | 216 | public function test_pre_get_cron_option() { 217 | $cron_option = Cron_Control\pre_get_cron_option( false ); 218 | $this->assertEquals( [ 'version' => 2 ], $cron_option, 'cron option is effectively empty' ); 219 | 220 | // Schedule some events. 221 | $test_events = $this->create_test_events(); 222 | 223 | // Make sure we get the right response. 224 | $cron_option = Cron_Control\pre_get_cron_option( false ); 225 | $this->assertEquals( 4 + 1, count( $cron_option ), 'cron option returned 4 events + version arg' ); 226 | 227 | // Make it flat for easier testing (this flattening is tested individually elsewhere) 228 | $flat_option = array_values( Events::flatten_wp_events_array( $cron_option ) ); 229 | $this->assert_event_matches_expected_args( $flat_option[0], $test_events['due_one'] ); 230 | $this->assert_event_matches_expected_args( $flat_option[1], $test_events['due_two'] ); 231 | $this->assert_event_matches_expected_args( $flat_option[2], $test_events['future'] ); 232 | $this->assert_event_matches_expected_args( $flat_option[3], $test_events['recurring'] ); 233 | } 234 | 235 | public function test_pre_update_cron_option() { 236 | // Ironically, if the function being tested here is broken, 237 | // the below will make it clear because test setup/teardown won't be able to clear things out :) 238 | // Though it probably breaks things in much earlier tests. 239 | $cron_option = Cron_Control\pre_get_cron_option( false ); 240 | $this->assertEquals( [ 'version' => 2 ], $cron_option ); 241 | 242 | // If given invalid data, just returns the old value it was given. 243 | $update_result = Cron_Control\pre_update_cron_option( 'not array', [ 'old array' ] ); 244 | $this->assertEquals( [ 'old array' ], $update_result ); 245 | 246 | // Schedule one event, and leave two unsaved. 247 | $default_args = [ 'timestamp' => time() + 100, 'args' => [ 'some', 'args' ] ]; 248 | $existing_event = Utils::create_unsaved_event( array_merge( $default_args, [ 'action' => 'test_pre_update_cron_option_existing' ] ) ); 249 | $existing_event->save(); 250 | 251 | $event_to_add = Utils::create_unsaved_event( array_merge( $default_args, [ 'action' => 'test_pre_update_cron_option_new' ] ) ); 252 | $recurring_event_to_add = Utils::create_unsaved_event( array_merge( $default_args, [ 253 | 'action' => 'test_pre_update_cron_option_new_recurring', 254 | 'schedule' => 'hourly', 255 | 'interval' => HOUR_IN_SECONDS, 256 | ] ) ); 257 | 258 | // Mock the scenario of sending a fresh events into the mix. 259 | $existing_option = Events::format_events_for_wp( [ $existing_event ] ); 260 | $new_option = Events::format_events_for_wp( [ $existing_event, $event_to_add, $recurring_event_to_add ] ); 261 | $update_result = Cron_Control\pre_update_cron_option( $new_option, $existing_option ); 262 | 263 | $this->assertEquals( $existing_option, $update_result, 'return value is always the prev value' ); 264 | $added_event = Event::find( [ 'action' => 'test_pre_update_cron_option_new' ] ); 265 | $this->assertEquals( $event_to_add->get_action(), $added_event->get_action(), 'single event was registered' ); 266 | $added_recurring_event = Event::find( [ 'action' => 'test_pre_update_cron_option_new_recurring' ] ); 267 | $this->assertEquals( $recurring_event_to_add->get_schedule(), $added_recurring_event->get_schedule(), 'recurring event was registered' ); 268 | 269 | // Mock the scenario of deleting an event from the mix. 270 | $existing_option = Events::format_events_for_wp( [ $existing_event, $added_event ] ); 271 | $new_option = Events::format_events_for_wp( [ $event_to_add ] ); 272 | $update_result = Cron_Control\pre_update_cron_option( $new_option, $existing_option ); 273 | 274 | $this->assertEquals( $existing_option, $update_result, 'return value is always the prev value' ); 275 | $removed_event = Event::find( [ 'action' => 'test_pre_update_cron_option_existing' ] ); 276 | $this->assertEquals( null, $removed_event, 'event was removed' ); 277 | } 278 | 279 | private function assert_event_matches_expected_args( $event, $args ) { 280 | $this->assertEquals( $event['timestamp'], $args['timestamp'], 'timestamp matches' ); 281 | $this->assertEquals( $event['action'], $args['hook'], 'action matches' ); 282 | $this->assertEquals( $event['args'], $args['args'], 'args match' ); 283 | 284 | if ( ! empty( $args['schedule'] ) ) { 285 | $this->assertEquals( $event['schedule'], $args['schedule'], 'schedule matches' ); 286 | $this->assertEquals( $event['interval'], $args['interval'], 'interval matches' ); 287 | } 288 | } 289 | 290 | private function create_test_events() { 291 | $event_details = [ 'hook' => 'test_create_test_events', 'schedule' => false ]; 292 | $recurring_args = [ 'schedule' => 'hourly', 'interval' => HOUR_IN_SECONDS ]; 293 | 294 | // Schedule some events. 295 | $due_event_one = array_merge( $event_details, [ 'timestamp' => time() - 1000, 'args' => [] ] ); 296 | $due_event_two = array_merge( $event_details, [ 'timestamp' => time() - 10, 'args' => [ 'default args' ] ] ); 297 | $future_event = array_merge( $event_details, [ 'timestamp' => time() + 500, 'args' => [ 'unique args' ] ] ); 298 | $recurring = array_merge( $event_details, $recurring_args, [ 'timestamp' => time() + 1500, 'args' => [ 'recurring args' ] ] ); 299 | Cron_Control\pre_schedule_event( null, (object) $due_event_one ); 300 | Cron_Control\pre_schedule_event( null, (object) $due_event_two ); 301 | Cron_Control\pre_schedule_event( null, (object) $future_event ); 302 | Cron_Control\pre_schedule_event( null, (object) $recurring ); 303 | 304 | return [ 305 | 'due_one' => $due_event_one, 306 | 'due_two' => $due_event_two, 307 | 'future' => $future_event, 308 | 'recurring' => $recurring, 309 | ]; 310 | } 311 | } 312 | -------------------------------------------------------------------------------- /__tests__/utils.php: -------------------------------------------------------------------------------- 1 | get_table_name(); 15 | } 16 | 17 | public static function clear_cron_table(): void { 18 | _set_cron_array( [] ); 19 | } 20 | 21 | public static function create_test_event( array $args = [] ): Event { 22 | $event = self::create_unsaved_event( $args ); 23 | $event->save(); 24 | return $event; 25 | } 26 | 27 | public static function create_unsaved_event( array $args = [] ) { 28 | if ( empty( $args ) ) { 29 | $args = array( 30 | 'timestamp' => time(), 31 | 'action' => 'test_unsaved_event_action', 32 | 'args' => [], 33 | ); 34 | } 35 | 36 | $event = new Event(); 37 | self::apply_event_props( $event, $args ); 38 | return $event; 39 | } 40 | 41 | public static function apply_event_props( Event $event, array $props ): void { 42 | $props_to_set = array_keys( $props ); 43 | 44 | if ( in_array( 'status', $props_to_set, true ) ) { 45 | $event->set_status( $props['status'] ); 46 | } 47 | 48 | if ( in_array( 'action', $props_to_set, true ) ) { 49 | $event->set_action( $props['action'] ); 50 | } 51 | 52 | if ( in_array( 'args', $props_to_set, true ) ) { 53 | $event->set_args( $props['args'] ); 54 | } 55 | 56 | if ( in_array( 'schedule', $props_to_set, true ) ) { 57 | $event->set_schedule( $props['schedule'], $props['interval'] ); 58 | } 59 | 60 | if ( in_array( 'timestamp', $props_to_set, true ) ) { 61 | $event->set_timestamp( $props['timestamp'] ); 62 | } 63 | } 64 | 65 | public static function assert_event_object_matches_database( Event $event, array $expected_data, $context ): void { 66 | $raw_event = Events_Store::instance()->_get_event_raw( $event->get_id() ); 67 | 68 | $context->assertEquals( $raw_event->ID, $expected_data['id'], 'id matches' ); 69 | $context->assertEquals( $raw_event->status, $expected_data['status'], 'status matches' ); 70 | $context->assertEquals( $raw_event->action, $expected_data['action'], 'action matches' ); 71 | $context->assertEquals( $raw_event->action_hashed, md5( $expected_data['action'] ), 'action_hash matches' ); 72 | $context->assertEquals( $raw_event->args, serialize( $expected_data['args'] ), 'args match' ); 73 | $context->assertEquals( $raw_event->instance, md5( serialize( $expected_data['args'] ) ), 'instance matches' ); 74 | $context->assertEquals( $raw_event->schedule, $expected_data['schedule'], 'schedule matches' ); 75 | $context->assertEquals( $raw_event->interval, $expected_data['interval'], 'interval matches' ); 76 | $context->assertEquals( $raw_event->timestamp, $expected_data['timestamp'], 'timestamp matches' ); 77 | 78 | // Just make sure these were set. 79 | $context->assertNotNull( $raw_event->created, 'created date was set' ); 80 | $context->assertNotNull( $raw_event->last_modified, 'last modified date was set' ); 81 | } 82 | 83 | public static function assert_event_object_has_correct_props( Event $event, array $expected_data, $context ): void { 84 | $context->assertEquals( $event->get_id(), $expected_data['id'], 'id matches' ); 85 | $context->assertEquals( $event->get_status(), $expected_data['status'], 'status matches' ); 86 | $context->assertEquals( $event->get_action(), $expected_data['action'], 'action matches' ); 87 | $context->assertEquals( $event->get_args(), $expected_data['args'], 'args match' ); 88 | $context->assertEquals( $event->get_instance(), Event::create_instance_hash( $expected_data['args'] ), 'instance matches' ); 89 | $context->assertEquals( $event->get_schedule(), $expected_data['schedule'], 'schedule matches' ); 90 | $context->assertEquals( $event->get_timestamp(), $expected_data['timestamp'], 'timestamp matches' ); 91 | 92 | // Special case: In the db it's "0", but in our class we keep as null. 93 | $expected_interval = 0 === $expected_data['interval'] ? null : $expected_data['interval']; 94 | $context->assertEquals( $event->get_interval(), $expected_interval, 'interval matches' ); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "automattic/cron-control", 3 | "description": "Execute WordPress cron events in parallel, with custom event storage for high-volume cron.", 4 | "license": "GPL-2.0", 5 | "require": { 6 | "php": ">=7.4.0" 7 | }, 8 | "require-dev": { 9 | "dealerdirect/phpcodesniffer-composer-installer": "1.0.0", 10 | "phpcompatibility/phpcompatibility-wp": "2.1.4", 11 | "phpunit/phpunit": "9.5.28", 12 | "wp-coding-standards/wpcs": "^2.3", 13 | "johnpbloch/wordpress-core": "6.1.1", 14 | "wp-phpunit/wp-phpunit": "6.1.1", 15 | "yoast/phpunit-polyfills": "1.1.0", 16 | "wp-cli/wp-cli": "*" 17 | }, 18 | "config": { 19 | "sort-packages": true, 20 | "allow-plugins": { 21 | "dealerdirect/phpcodesniffer-composer-installer": true 22 | }, 23 | "platform": { 24 | "php": "7.4" 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /cron-control.php: -------------------------------------------------------------------------------- 1 | id ) ? $this->id : null; 36 | } 37 | 38 | public function get_status(): ?string { 39 | return isset( $this->status ) ? $this->status : null; 40 | } 41 | 42 | public function get_action(): ?string { 43 | return isset( $this->action ) ? $this->action : null; 44 | } 45 | 46 | public function get_args(): array { 47 | return $this->args; 48 | } 49 | 50 | public function get_instance(): string { 51 | // Defaults to a hash of the empty args array. 52 | return isset( $this->instance ) ? $this->instance : self::create_instance_hash( $this->args ); 53 | } 54 | 55 | public function get_schedule(): ?string { 56 | return isset( $this->schedule ) ? $this->schedule : null; 57 | } 58 | 59 | public function get_interval(): ?int { 60 | return isset( $this->interval ) ? $this->interval : null; 61 | } 62 | 63 | public function get_timestamp(): ?int { 64 | return isset( $this->timestamp ) ? $this->timestamp : null; 65 | } 66 | 67 | /* 68 | |-------------------------------------------------------------------------- 69 | | Setters 70 | |-------------------------------------------------------------------------- 71 | */ 72 | 73 | public function set_status( string $status ): void { 74 | $this->status = strtolower( $status ); 75 | } 76 | 77 | public function set_action( string $action ): void { 78 | $this->action = $action; 79 | $this->action_hashed = md5( $action ); 80 | } 81 | 82 | public function set_args( array $args ): void { 83 | $this->args = $args; 84 | $this->instance = self::create_instance_hash( $this->args ); 85 | } 86 | 87 | public function set_schedule( string $schedule, int $interval ): void { 88 | $this->schedule = $schedule; 89 | $this->interval = $interval; 90 | } 91 | 92 | public function set_timestamp( int $timestamp ): void { 93 | $this->timestamp = $timestamp; 94 | } 95 | 96 | /* 97 | |-------------------------------------------------------------------------- 98 | | Methods for interacting with the object. 99 | |-------------------------------------------------------------------------- 100 | */ 101 | 102 | /** 103 | * Save the event (create if needed, else update). 104 | * 105 | * @return true|WP_Error true on success, WP_Error on failure. 106 | */ 107 | public function save() { 108 | // Set default status for new events. 109 | if ( ! $this->exists() && null === $this->get_status() ) { 110 | $this->set_status( Events_Store::STATUS_PENDING ); 111 | } 112 | 113 | $validation_result = $this->validate_props(); 114 | if ( is_wp_error( $validation_result ) ) { 115 | return $validation_result; 116 | } 117 | 118 | $row_data = [ 119 | 'status' => $this->get_status(), 120 | 'action' => $this->get_action(), 121 | 'action_hashed' => $this->action_hashed, 122 | 'args' => serialize( $this->get_args() ), 123 | 'instance' => $this->get_instance(), 124 | 'timestamp' => $this->get_timestamp(), 125 | ]; 126 | 127 | if ( $this->is_recurring() ) { 128 | $row_data['schedule'] = $this->get_schedule(); 129 | $row_data['interval'] = $this->get_interval(); 130 | } else { 131 | // Data store expects these as the defaults for "empty". 132 | $row_data['schedule'] = null; 133 | $row_data['interval'] = 0; 134 | } 135 | 136 | // About to be updated, so increment the "last modified" timestamp. 137 | $current_time = current_time( 'mysql', true ); 138 | $row_data['last_modified'] = $current_time; 139 | 140 | if ( $this->exists() ) { 141 | $success = Events_Store::instance()->_update_event( $this->id, $row_data ); 142 | if ( ! $success ) { 143 | return new WP_Error( 'cron-control:event:failed-update' ); 144 | } 145 | 146 | $this->last_modified = $current_time; 147 | return true; 148 | } 149 | 150 | $row_data['created'] = $current_time; 151 | 152 | $event_id = Events_Store::instance()->_create_event( $row_data ); 153 | if ( $event_id < 1 ) { 154 | return new WP_Error( 'cron-control:event:failed-create' ); 155 | } 156 | 157 | $this->id = $event_id; 158 | $this->created = $current_time; 159 | return true; 160 | } 161 | 162 | public function run(): void { 163 | do_action_ref_array( $this->action, $this->args ); 164 | } 165 | 166 | /** 167 | * Mark the event as completed. 168 | * TODO: Probably introduce cancel() method and status as well for more specific situations. 169 | * 170 | * @return true|WP_Error true on success, WP_Error on failure. 171 | */ 172 | public function complete() { 173 | if ( ! $this->exists() ) { 174 | return new WP_Error( 'cron-control:event:cannot-complete' ); 175 | } 176 | 177 | // Prevent conflicts with the unique constraints in the table. 178 | // Is a bit unfortunate since it won't be as easy to query for the event anymore. 179 | // Perhaps in the future could remove the unique constraint in favor of stricter duplicate checking. 180 | $this->instance = 'randomized:' . (string) mt_rand( 1000000, 9999999999999 ); 181 | 182 | $this->set_status( Events_Store::STATUS_COMPLETED ); 183 | return $this->save(); 184 | } 185 | 186 | /** 187 | * Reschedule the event w/ an updated timestamp. 188 | * 189 | * @return true|WP_Error true on success, WP_Error on failure. 190 | */ 191 | public function reschedule() { 192 | if ( ! $this->exists() ) { 193 | return new WP_Error( 'cron-control:event:cannot-reschedule' ); 194 | } 195 | 196 | if ( ! $this->is_recurring() ) { 197 | // The event doesn't recur (or data was corrupted somehow), mark it as cancelled instead. 198 | $this->complete(); 199 | return new WP_Error( 'cron-control:event:cannot-reschedule' ); 200 | } 201 | 202 | $fresh_interval = $this->get_refreshed_schedule_interval(); 203 | $next_timestamp = $this->calculate_next_timestamp( $fresh_interval ); 204 | 205 | if ( $this->interval !== $fresh_interval ) { 206 | $this->set_schedule( $this->schedule, $this->interval ); 207 | } 208 | 209 | $this->set_timestamp( $next_timestamp ); 210 | return $this->save(); 211 | } 212 | 213 | /* 214 | |-------------------------------------------------------------------------- 215 | | Utilities 216 | |-------------------------------------------------------------------------- 217 | */ 218 | 219 | public static function get( int $event_id ): ?Event { 220 | $db_row = Events_Store::instance()->_get_event_raw( $event_id ); 221 | return is_null( $db_row ) ? null : self::get_from_db_row( $db_row ); 222 | } 223 | 224 | public static function find( array $query_args ): ?Event { 225 | $results = Events_Store::instance()->_query_events_raw( array_merge( $query_args, [ 'limit' => 1 ] ) ); 226 | return empty( $results ) ? null : self::get_from_db_row( $results[0] ); 227 | } 228 | 229 | public static function get_from_db_row( object $data ): ?Event { 230 | if ( ! isset( $data->ID, $data->status, $data->action, $data->timestamp ) ) { 231 | // Missing expected/required data, cannot setup the object. 232 | return null; 233 | } 234 | 235 | $event = new Event(); 236 | $event->id = (int) $data->ID; 237 | $event->set_status( (string) $data->status ); 238 | $event->set_action( (string) $data->action ); 239 | $event->set_timestamp( (int) $data->timestamp ); 240 | $event->set_args( (array) maybe_unserialize( $data->args ) ); 241 | $event->created = $data->created; 242 | $event->last_modified = $data->last_modified; 243 | 244 | if ( ! empty( $data->schedule ) && ! empty( $data->interval ) ) { 245 | // Note: the db is sending back "null" and "0" for the above two on single events, 246 | // so we do the above empty() checks to filter that out. 247 | $event->set_schedule( (string) $data->schedule, (int) $data->interval ); 248 | } 249 | 250 | return $event; 251 | } 252 | 253 | public function exists(): bool { 254 | return isset( $this->id ); 255 | } 256 | 257 | public function is_recurring() : bool { 258 | // To allow validation to do it's job, here we just see if the props have ever been set. 259 | return isset( $this->schedule, $this->interval ); 260 | } 261 | 262 | public function is_internal(): bool { 263 | return Internal_Events::instance()->is_internal_event( $this->action ); 264 | } 265 | 266 | // The format WP expects an event to come in. 267 | public function get_wp_event_format(): object { 268 | $wp_event = [ 269 | 'hook' => $this->get_action(), 270 | 'timestamp' => $this->get_timestamp(), 271 | 'schedule' => empty( $this->get_schedule() ) ? false : $this->get_schedule(), 272 | 'args' => $this->get_args(), 273 | ]; 274 | 275 | if ( $this->is_recurring() ) { 276 | $wp_event['interval'] = $this->get_interval(); 277 | } 278 | 279 | return (object) $wp_event; 280 | } 281 | 282 | // The old way this plugin used to pass around event objects. 283 | // Needed for BC for some hooks, hopefully deprecated/removed fully later on. 284 | public function get_legacy_event_format(): object { 285 | $legacy_format = [ 286 | 'ID' => $this->get_id(), 287 | 'timestamp' => $this->get_timestamp(), 288 | 'action' => $this->get_action(), 289 | 'action_hashed' => $this->action_hashed, 290 | 'instance' => $this->get_instance(), 291 | 'args' => $this->get_args(), 292 | 'schedule' => isset( $this->schedule ) ? $this->get_schedule() : false, 293 | 'interval' => isset( $this->interval ) ? $this->get_interval() : 0, 294 | 'status' => $this->get_status(), 295 | 'created' => $this->created, 296 | 'last_modified' => $this->last_modified, 297 | ]; 298 | 299 | return (object) $legacy_format; 300 | } 301 | 302 | public static function create_instance_hash( array $args ): string { 303 | return md5( serialize( $args ) ); 304 | } 305 | 306 | private function validate_props() { 307 | $status = $this->get_status(); 308 | if ( ! in_array( $status, Events_Store::ALLOWED_STATUSES, true ) ) { 309 | return new WP_Error( 'cron-control:event:prop-validation:invalid-status' ); 310 | } 311 | 312 | $action = $this->get_action(); 313 | if ( empty( $action ) ) { 314 | return new WP_Error( 'cron-control:event:prop-validation:invalid-action' ); 315 | } 316 | 317 | $timestamp = $this->get_timestamp(); 318 | if ( empty( $timestamp ) || $timestamp < 1 ) { 319 | return new WP_Error( 'cron-control:event:prop-validation:invalid-timestamp' ); 320 | } 321 | 322 | if ( $this->is_recurring() ) { 323 | if ( empty( $this->get_schedule() ) || $this->get_interval() <= 0 ) { 324 | return new WP_Error( 'cron-control:event:prop-validation:invalid-schedule' ); 325 | } 326 | } 327 | 328 | // Don't prevent event creation, but do warn about overly large arguments. 329 | if ( ! $this->args_array_is_reasonably_sized() ) { 330 | trigger_error( sprintf( 'Cron-Control: Event (action: %s) was added w/ abnormally large arguments. This can badly effect performance.', $this->get_action() ), E_USER_WARNING ); 331 | } 332 | 333 | return true; 334 | } 335 | 336 | // Similar functionality to wp_reschedule_event(). 337 | private function calculate_next_timestamp( int $interval ): ?int { 338 | $now = time(); 339 | 340 | if ( $this->timestamp >= $now ) { 341 | // Event was ran ahead (or right on) it's due time, schedule it to run again after it's full interval. 342 | return $now + $interval; 343 | } 344 | 345 | // Event ran a bit delayed, adjust accordingly (example: a 12h interval event running 6h late will be scheduled for +6h from now). 346 | // TODO: Maybe we can simplify here later and just always return `$now + $interval`? 347 | $elapsed_time_since_due = $now - $this->timestamp; 348 | $remaining_seconds_into_the_future = ( $interval - ( $elapsed_time_since_due % $interval ) ); 349 | return $now + $remaining_seconds_into_the_future; 350 | } 351 | 352 | private function get_refreshed_schedule_interval() { 353 | // Try to get the interval from the schedule in case it's been updated. 354 | $schedules = wp_get_schedules(); 355 | if ( isset( $schedules[ $this->schedule ] ) ) { 356 | return (int) $schedules[ $this->schedule ]['interval']; 357 | } 358 | 359 | // If we couldn't get from schedule (was removed), use whatever was saved already. 360 | return $this->interval; 361 | } 362 | 363 | private function args_array_is_reasonably_sized(): bool { 364 | // We aim to cache queries w/ up to 500 events. 365 | $max_events_per_page = 500; 366 | 367 | // A compressed db row of an event is around 300 bytes. 368 | $db_row_size_of_normal_event = 300; 369 | 370 | // Note: Memcache can only cache up to 1mb values, after compression. 371 | $reasonable_size = ( MB_IN_BYTES / $max_events_per_page ) - $db_row_size_of_normal_event; 372 | 373 | // First a quick uncompressed test. 374 | $uncompressed_size = mb_strlen( serialize( $this->get_args() ), '8bit' ); 375 | if ( $uncompressed_size < $reasonable_size * 4 ) { 376 | // After compression, this is for sure generously under the limit. 377 | return true; 378 | } 379 | 380 | // Now the more expensive test, accounting for compression. 381 | $compressed_args = gzdeflate( serialize( $this->get_args() ) ); 382 | if ( false === $compressed_args ) { 383 | // Wasn't able to compress, let's assume it is above the ideal limit. 384 | return false; 385 | } 386 | 387 | $compressed_size = mb_strlen( $compressed_args, '8bit' ); 388 | return $compressed_size < $reasonable_size; 389 | } 390 | } 391 | -------------------------------------------------------------------------------- /includes/class-events.php: -------------------------------------------------------------------------------- 1 | concurrent_action_whitelist = $concurrency_whitelist; 90 | } 91 | } 92 | 93 | /** 94 | * List events pending for the current period. 95 | * 96 | * @param null|int $job_queue_size Maximum number of events to return (excludes internal events). 97 | * @param null|int $job_queue_window How many seconds into the future events should be fetched. 98 | * @return array Events to be run in the next batch. 99 | */ 100 | public function get_events( $job_queue_size = null, $job_queue_window = null ): array { 101 | $job_queue_size = is_null( $job_queue_size ) ? JOB_QUEUE_SIZE : $job_queue_size; 102 | $job_queue_window = is_null( $job_queue_window ) ? JOB_QUEUE_WINDOW_IN_SECONDS : $job_queue_window; 103 | 104 | // Grab relevant events that are due, or soon will be. 105 | $current_time = time(); 106 | $events = self::query( [ 107 | 'timestamp' => [ 'from' => 0, 'to' => $current_time + $job_queue_window ], 108 | 'status' => Events_Store::STATUS_PENDING, 109 | 'limit' => -1, // Need to get all, to ensure we grab internals even when queue is backed up. 110 | ] ); 111 | 112 | // That was easy. 113 | if ( empty( $events ) ) { 114 | return [ 'events' => null ]; 115 | } 116 | 117 | $current_events = []; 118 | $internal_events = []; 119 | foreach ( $events as $event ) { 120 | // action is hashed to avoid information disclosure. 121 | $event_data_public = [ 122 | 'timestamp' => $event->get_timestamp(), 123 | 'action' => md5( $event->get_action() ), 124 | 'instance' => $event->get_instance(), 125 | ]; 126 | 127 | // Queue internal events separately to avoid them being blocked. 128 | if ( $event->is_internal() ) { 129 | $internal_events[] = $event_data_public; 130 | } else { 131 | $current_events[] = $event_data_public; 132 | } 133 | } 134 | 135 | // Limit batch size to avoid resource exhaustion. 136 | if ( count( $current_events ) > $job_queue_size ) { 137 | $current_events = $this->reduce_queue( $current_events, $job_queue_size ); 138 | } 139 | 140 | // Combine with Internal Events. 141 | // TODO: un-nest array, which is nested for legacy reasons. 142 | return [ 'events' => array_merge( $current_events, $internal_events ) ]; 143 | } 144 | 145 | /** 146 | * Trim events queue down to a specific limit. 147 | * 148 | * @param array $events List of events to be run in the current period. 149 | * @param array $max_queue_size Maximum number of events to return. 150 | * @return array 151 | */ 152 | private function reduce_queue( $events, $max_queue_size ): array { 153 | // Loop through events, adding one of each action during each iteration. 154 | $reduced_queue = array(); 155 | $action_counts = array(); 156 | 157 | $i = 1; // Intentionally not zero-indexed to facilitate comparisons against $action_counts members. 158 | 159 | do { 160 | // Each time the events array is iterated over, move one instance of an action to the current queue. 161 | foreach ( $events as $key => $event ) { 162 | $action = $event['action']; 163 | 164 | // Prime the count. 165 | if ( ! isset( $action_counts[ $action ] ) ) { 166 | $action_counts[ $action ] = 0; 167 | } 168 | 169 | // Check and do the move. 170 | if ( $action_counts[ $action ] < $i ) { 171 | $reduced_queue[] = $event; 172 | $action_counts[ $action ]++; 173 | unset( $events[ $key ] ); 174 | } 175 | } 176 | 177 | // When done with an iteration and events remain, start again from the beginning of the $events array. 178 | if ( empty( $events ) ) { 179 | break; 180 | } else { 181 | $i++; 182 | reset( $events ); 183 | 184 | continue; 185 | } 186 | } while ( $i <= 15 && count( $reduced_queue ) < $max_queue_size && ! empty( $events ) ); 187 | 188 | /** 189 | * IMPORTANT: DO NOT re-sort the $reduced_queue array from this point forward. 190 | * Doing so defeats the preceding effort. 191 | * 192 | * While the events are now out of order with respect to timestamp, they're ordered 193 | * such that one of each action is run before another of an already-run action. 194 | * The timestamp misordering is trivial given that we're only dealing with events 195 | * for the current $job_queue_window. 196 | */ 197 | 198 | // Finally, ensure that we don't have more than we need. 199 | if ( count( $reduced_queue ) > $max_queue_size ) { 200 | $reduced_queue = array_slice( $reduced_queue, 0, $max_queue_size ); 201 | } 202 | 203 | return $reduced_queue; 204 | } 205 | 206 | /** 207 | * Execute a specific event 208 | * 209 | * @param int $timestamp Unix timestamp. 210 | * @param string $action md5 hash of the action used when the event is registered. 211 | * @param string $instance md5 hash of the event's arguments array, which Core uses to index the `cron` option. 212 | * @param bool $force Run event regardless of timestamp or lock status? eg, when executing jobs via wp-cli. 213 | * @return array|WP_Error 214 | */ 215 | public function run_event( $timestamp, $action, $instance, $force = false ) { 216 | // Validate input data. 217 | if ( empty( $timestamp ) || empty( $action ) || empty( $instance ) ) { 218 | return new WP_Error( 'missing-data', __( 'Invalid or incomplete request data.', 'automattic-cron-control' ), [ 'status' => 400 ] ); 219 | } 220 | 221 | // Ensure we don't run jobs ahead of time. 222 | if ( ! $force && $timestamp > time() ) { 223 | /* translators: 1: Job identifier */ 224 | $error_message = sprintf( __( 'Job with identifier `%1$s` is not scheduled to run yet.', 'automattic-cron-control' ), "$timestamp-$action-$instance" ); 225 | return new WP_Error( 'premature', $error_message, [ 'status' => 404 ] ); 226 | } 227 | 228 | $event = Event::find( [ 229 | 'timestamp' => $timestamp, 230 | 'action_hashed' => $action, 231 | 'instance' => $instance, 232 | 'status' => Events_Store::STATUS_PENDING, 233 | ] ); 234 | 235 | // Nothing to do... 236 | if ( is_null( $event ) ) { 237 | /* translators: 1: Job identifier */ 238 | $error_message = sprintf( __( 'Job with identifier `%1$s` could not be found.', 'automattic-cron-control' ), "$timestamp-$action-$instance" ); 239 | return new WP_Error( 'no-event', $error_message, [ 'status' => 404 ] ); 240 | } 241 | 242 | unset( $timestamp, $action, $instance ); 243 | 244 | // Limit how many events are processed concurrently, unless explicitly bypassed. 245 | if ( ! $force ) { 246 | // Prepare event-level lock. 247 | $this->prime_event_action_lock( $event ); 248 | 249 | if ( ! $this->can_run_event( $event ) ) { 250 | /* translators: 1: Event action, 2: Event arguments */ 251 | $error_message = sprintf( __( 'No resources available to run the job with action `%1$s` and arguments `%2$s`.', 'automattic-cron-control' ), $event->get_action(), maybe_serialize( $event->get_args() ) ); 252 | return new WP_Error( 'no-free-threads', $error_message, [ 'status' => 429 ] ); 253 | } 254 | 255 | // Free locks later in case event throws an uncatchable error. 256 | $this->running_event = $event; 257 | add_action( 'shutdown', array( $this, 'do_lock_cleanup_on_shutdown' ) ); 258 | } 259 | 260 | // Core reschedules/conpletes an event before running it, so we respect that. 261 | if ( $event->is_recurring() ) { 262 | $event->reschedule(); 263 | } else { 264 | $event->complete(); 265 | } 266 | 267 | try { 268 | $event->run(); 269 | } catch ( \Throwable $t ) { 270 | /** 271 | * Note that timeouts and memory exhaustion do not invoke this block. 272 | * Instead, those locks are freed in `do_lock_cleanup_on_shutdown()`. 273 | */ 274 | 275 | do_action( 'a8c_cron_control_event_threw_catchable_error', $event->get_legacy_event_format(), $t ); 276 | 277 | $return = array( 278 | 'success' => false, 279 | /* translators: 1: Event action, 2: Event arguments, 3: Throwable error, 4: Line number that raised Throwable error */ 280 | 'message' => sprintf( __( 'Callback for job with action `%1$s` and arguments `%2$s` raised a Throwable - %3$s in %4$s on line %5$d.', 'automattic-cron-control' ), $event->get_action(), maybe_serialize( $event->get_args() ), $t->getMessage(), $t->getFile(), $t->getLine() ), 281 | ); 282 | } 283 | 284 | // Free locks for the next event, unless they weren't set to begin with. 285 | if ( ! $force ) { 286 | // If we got this far, there's no uncaught error to handle. 287 | $this->running_event = null; 288 | remove_action( 'shutdown', array( $this, 'do_lock_cleanup_on_shutdown' ) ); 289 | 290 | $this->do_lock_cleanup( $event ); 291 | } 292 | 293 | // Callback didn't trigger a Throwable, indicating it succeeded. 294 | if ( ! isset( $return ) ) { 295 | $return = array( 296 | 'success' => true, 297 | /* translators: 1: Event action, 2: Event arguments */ 298 | 'message' => sprintf( __( 'Job with action `%1$s` and arguments `%2$s` executed.', 'automattic-cron-control' ), $event->get_action(), maybe_serialize( $event->get_args() ) ), 299 | ); 300 | } 301 | 302 | return $return; 303 | } 304 | 305 | private function prime_event_action_lock( Event $event ): void { 306 | Lock::prime_lock( $this->get_lock_key_for_event_action( $event ), JOB_LOCK_EXPIRY_IN_MINUTES * \MINUTE_IN_SECONDS ); 307 | } 308 | 309 | // Checks concurrency locks, deciding if the event can be run at this moment. 310 | private function can_run_event( Event $event ): bool { 311 | // Limit to one concurrent execution of a specific action by default. 312 | $limit = 1; 313 | 314 | if ( isset( $this->concurrent_action_whitelist[ $event->get_action() ] ) ) { 315 | $limit = absint( $this->concurrent_action_whitelist[ $event->get_action() ] ); 316 | $limit = min( $limit, JOB_CONCURRENCY_LIMIT ); 317 | } 318 | 319 | if ( ! Lock::check_lock( $this->get_lock_key_for_event_action( $event ), $limit, JOB_LOCK_EXPIRY_IN_MINUTES * \MINUTE_IN_SECONDS ) ) { 320 | return false; 321 | } 322 | 323 | // Internal Events aren't subject to the global lock. 324 | if ( $event->is_internal() ) { 325 | return true; 326 | } 327 | 328 | // Check if any resources are available to execute this job. 329 | // If not, the individual-event lock must be freed, otherwise it's deadlocked until it times out. 330 | if ( ! Lock::check_lock( self::LOCK, JOB_CONCURRENCY_LIMIT ) ) { 331 | $this->reset_event_lock( $event ); 332 | return false; 333 | } 334 | 335 | // Let's go! 336 | return true; 337 | } 338 | 339 | private function do_lock_cleanup( Event $event ): void { 340 | // Site-level lock isn't set when event is Internal, so we don't want to alter it. 341 | if ( ! $event->is_internal() ) { 342 | Lock::free_lock( self::LOCK ); 343 | } 344 | 345 | // Reset individual event lock. 346 | $this->reset_event_lock( $event ); 347 | } 348 | 349 | private function reset_event_lock( Event $event ): bool { 350 | $lock_key = $this->get_lock_key_for_event_action( $event ); 351 | $expires = JOB_LOCK_EXPIRY_IN_MINUTES * \MINUTE_IN_SECONDS; 352 | 353 | if ( isset( $this->concurrent_action_whitelist[ $event->get_action() ] ) ) { 354 | return Lock::free_lock( $lock_key, $expires ); 355 | } else { 356 | return Lock::reset_lock( $lock_key, $expires ); 357 | } 358 | } 359 | 360 | /** 361 | * Turn the event action into a string that can be used with a lock 362 | * 363 | * @param Event|stdClass $event 364 | * @return string 365 | */ 366 | public function get_lock_key_for_event_action( $event ): string { 367 | // Hashed solely to constrain overall length. 368 | $action = method_exists( $event, 'get_action' ) ? $event->get_action() : $event->action; 369 | return md5( 'ev-' . $action ); 370 | } 371 | 372 | /** 373 | * If event execution throws uncatchable error, free locks 374 | * Covers situations such as timeouts and memory exhaustion, which aren't \Throwable errors 375 | * Under normal conditions, this callback isn't hooked to `shutdown` 376 | */ 377 | public function do_lock_cleanup_on_shutdown() { 378 | $event = $this->running_event; 379 | 380 | if ( is_null( $event ) ) { 381 | return; 382 | } 383 | 384 | do_action( 'a8c_cron_control_freeing_event_locks_after_uncaught_error', $event->get_legacy_event_format() ); 385 | 386 | $this->do_lock_cleanup( $event ); 387 | } 388 | 389 | /** 390 | * Return status of automatic event execution 391 | * 392 | * @return int 0 if run is enabled, 1 if run is disabled indefinitely, otherwise timestamp when execution will resume 393 | */ 394 | public function run_disabled() { 395 | $disabled = (int) get_option( self::DISABLE_RUN_OPTION, 0 ); 396 | 397 | if ( $disabled <= 1 || $disabled > time() ) { 398 | return $disabled; 399 | } 400 | 401 | $this->update_run_status( 0 ); 402 | return 0; 403 | } 404 | 405 | /** 406 | * Set automatic execution status 407 | * 408 | * @param int $new_status 0 if run is enabled, 1 if run is disabled indefinitely, otherwise timestamp when execution will resume. 409 | * @return bool 410 | */ 411 | public function update_run_status( $new_status ) { 412 | $new_status = absint( $new_status ); 413 | 414 | // Don't store a past timestamp. 415 | if ( $new_status > 1 && $new_status < time() ) { 416 | return false; 417 | } 418 | 419 | return update_option( self::DISABLE_RUN_OPTION, $new_status ); 420 | } 421 | 422 | /** 423 | * Query for multiple events. 424 | * 425 | * @param array $query_args Event query args. 426 | * @return array An array of Event objects. 427 | */ 428 | public static function query( array $query_args = [] ): array { 429 | $event_db_rows = Events_Store::instance()->_query_events_raw( $query_args ); 430 | $events = array_map( fn( $db_row ) => Event::get_from_db_row( $db_row ), $event_db_rows ); 431 | return array_filter( $events, fn( $event ) => ! is_null( $event ) ); 432 | } 433 | 434 | /** 435 | * Format multiple events the way WP expects them. 436 | * 437 | * @param array $events Array of Event objects that need formatting. 438 | * @return array Array of event data in the deeply nested format WP expects. 439 | */ 440 | public static function format_events_for_wp( array $events ): array { 441 | $crons = []; 442 | 443 | foreach ( $events as $event ) { 444 | // Level 1: Ensure the root timestamp node exists. 445 | $timestamp = $event->get_timestamp(); 446 | if ( ! isset( $crons[ $timestamp ] ) ) { 447 | $crons[ $timestamp ] = []; 448 | } 449 | 450 | // Level 2: Ensure the action node exists. 451 | $action = $event->get_action(); 452 | if ( ! isset( $crons[ $timestamp ][ $action ] ) ) { 453 | $crons[ $timestamp ][ $action ] = []; 454 | } 455 | 456 | // Finally, add the rest of the event details. 457 | $formatted_event = [ 458 | 'schedule' => empty( $event->get_schedule() ) ? false : $event->get_schedule(), 459 | 'args' => $event->get_args(), 460 | ]; 461 | 462 | $interval = $event->get_interval(); 463 | if ( ! empty( $interval ) ) { 464 | $formatted_event['interval'] = $interval; 465 | } 466 | 467 | $instance = $event->get_instance(); 468 | $crons[ $timestamp ][ $action ][ $instance ] = $formatted_event; 469 | } 470 | 471 | // Re-sort the array just as core does when events are scheduled. 472 | uksort( $crons, 'strnatcasecmp' ); 473 | return $crons; 474 | } 475 | 476 | /** 477 | * Flatten the WP events array. 478 | * Each event will have a unique key for quick comparisons. 479 | * 480 | * @param array $events Deeply nested array of event data in the format WP core uses. 481 | * @return array Flat array that is easier to compare and work with :) 482 | */ 483 | public static function flatten_wp_events_array( array $events ): array { 484 | // Core legacy thing, we don't need this. 485 | unset( $events['version'] ); 486 | 487 | $flattened = []; 488 | foreach ( $events as $timestamp => $ts_events ) { 489 | foreach ( $ts_events as $action => $action_instances ) { 490 | foreach ( $action_instances as $instance => $event_details ) { 491 | $unique_key = "$timestamp:$action:$instance"; 492 | 493 | $flat_event = [ 494 | 'timestamp' => $timestamp, 495 | 'action' => $action, 496 | 'instance' => $instance, 497 | 'args' => $event_details['args'], 498 | ]; 499 | 500 | if ( ! empty( $event_details['schedule'] ) ) { 501 | $unique_key = "$unique_key:{$event_details['schedule']}:{$event_details['interval']}"; 502 | 503 | $flat_event['schedule'] = $event_details['schedule']; 504 | $flat_event['interval'] = $event_details['interval']; 505 | } 506 | 507 | $flattened[ sha1( $unique_key ) ] = $flat_event; 508 | } 509 | } 510 | } 511 | 512 | return $flattened; 513 | } 514 | } 515 | -------------------------------------------------------------------------------- /includes/class-internal-events.php: -------------------------------------------------------------------------------- 1 | prepare_internal_events(); 17 | $this->prepare_internal_events_schedules(); 18 | 19 | // Schedule the internal events once our custom store is in place. 20 | if ( Events_Store::is_installed() ) { 21 | $is_cron_or_cli = wp_doing_cron() || ( defined( 'WP_CLI' ) && WP_CLI ); 22 | $is_admin = is_admin() && ! wp_doing_ajax(); 23 | 24 | if ( $is_cron_or_cli || $is_admin ) { 25 | add_action( 'wp_loaded', [ $this, 'schedule_internal_events' ] ); 26 | } 27 | } 28 | 29 | // Register schedules and callbacks. 30 | add_filter( 'cron_schedules', [ $this, 'register_internal_events_schedules' ] ); 31 | foreach ( $this->internal_events as $internal_event ) { 32 | add_action( $internal_event['action'], $internal_event['callback'] ); 33 | } 34 | } 35 | 36 | /** 37 | * Populate internal events, allowing for additions. 38 | */ 39 | private function prepare_internal_events() { 40 | $internal_events = [ 41 | [ 42 | 'schedule' => 'a8c_cron_control_minute', 43 | 'action' => 'a8c_cron_control_force_publish_missed_schedules', 44 | 'callback' => [ $this, 'force_publish_missed_schedules' ], 45 | ], 46 | [ 47 | 'schedule' => 'a8c_cron_control_ten_minutes', 48 | 'action' => 'a8c_cron_control_confirm_scheduled_posts', 49 | 'callback' => [ $this, 'confirm_scheduled_posts' ], 50 | ], 51 | [ 52 | 'schedule' => 'hourly', 53 | 'action' => 'a8c_cron_control_purge_completed_events', 54 | 'callback' => [ $this, 'purge_completed_events' ], 55 | ], 56 | [ 57 | 'schedule' => 'daily', 58 | 'action' => 'a8c_cron_control_clean_legacy_data', 59 | 'callback' => [ $this, 'clean_legacy_data' ], 60 | ], 61 | ]; 62 | 63 | // Allow additional internal events to be specified, ensuring the above cannot be overwritten. 64 | if ( defined( 'CRON_CONTROL_ADDITIONAL_INTERNAL_EVENTS' ) && is_array( \CRON_CONTROL_ADDITIONAL_INTERNAL_EVENTS ) ) { 65 | $internal_actions = wp_list_pluck( $internal_events, 'action' ); 66 | 67 | foreach ( \CRON_CONTROL_ADDITIONAL_INTERNAL_EVENTS as $additional ) { 68 | if ( in_array( $additional['action'], $internal_actions, true ) ) { 69 | continue; 70 | } 71 | 72 | if ( ! array_key_exists( 'schedule', $additional ) || ! array_key_exists( 'action', $additional ) || ! array_key_exists( 'callback', $additional ) ) { 73 | continue; 74 | } 75 | 76 | $internal_events[] = $additional; 77 | } 78 | } 79 | 80 | $this->internal_events = $internal_events; 81 | } 82 | 83 | /** 84 | * Allow custom internal events to provide their own schedules. 85 | */ 86 | private function prepare_internal_events_schedules() { 87 | $internal_events_schedules = [ 88 | 'a8c_cron_control_minute' => [ 89 | 'interval' => 2 * MINUTE_IN_SECONDS, 90 | 'display' => __( 'Cron Control internal job - every 2 minutes (used to be 1 minute)', 'automattic-cron-control' ), 91 | ], 92 | 'a8c_cron_control_ten_minutes' => [ 93 | 'interval' => 10 * MINUTE_IN_SECONDS, 94 | 'display' => __( 'Cron Control internal job - every 10 minutes', 'automattic-cron-control' ), 95 | ], 96 | ]; 97 | 98 | // Allow additional schedules for custom events, ensuring the above cannot be overwritten. 99 | if ( defined( 'CRON_CONTROL_ADDITIONAL_INTERNAL_EVENTS_SCHEDULES' ) && is_array( \CRON_CONTROL_ADDITIONAL_INTERNAL_EVENTS_SCHEDULES ) ) { 100 | foreach ( \CRON_CONTROL_ADDITIONAL_INTERNAL_EVENTS_SCHEDULES as $name => $attrs ) { 101 | if ( array_key_exists( $name, $internal_events_schedules ) ) { 102 | continue; 103 | } 104 | 105 | if ( ! array_key_exists( 'interval', $attrs ) || ! array_key_exists( 'display', $attrs ) ) { 106 | continue; 107 | } 108 | 109 | $internal_events_schedules[ $name ] = $attrs; 110 | } 111 | } 112 | 113 | $this->internal_events_schedules = $internal_events_schedules; 114 | } 115 | 116 | public function register_internal_events_schedules( array $schedules ): array { 117 | return array_merge( $schedules, $this->internal_events_schedules ); 118 | } 119 | 120 | public function schedule_internal_events() { 121 | foreach ( $this->internal_events as $event_args ) { 122 | if ( ! wp_next_scheduled( $event_args['action'] ) ) { 123 | wp_schedule_event( time(), $event_args['schedule'], $event_args['action'] ); 124 | } 125 | } 126 | } 127 | 128 | /** 129 | * Check if an action belongs to an internal event. 130 | * 131 | * @param string $action Event action. 132 | */ 133 | public function is_internal_event( $action ): bool { 134 | return in_array( $action, wp_list_pluck( $this->internal_events, 'action' ), true ); 135 | } 136 | 137 | /* 138 | |-------------------------------------------------------------------------- 139 | | Internal Event Callbacks 140 | |-------------------------------------------------------------------------- 141 | */ 142 | 143 | /** 144 | * Publish scheduled posts that miss their schedule. 145 | */ 146 | public function force_publish_missed_schedules() { 147 | global $wpdb; 148 | 149 | $missed_posts = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} p JOIN (SELECT DISTINCT post_type FROM {$wpdb->posts}) AS t ON p.post_type = t.post_type WHERE post_status = 'future' AND post_date <= %s LIMIT 0,100;", current_time( 'mysql', false ) ) ); 150 | 151 | foreach ( $missed_posts as $missed_post ) { 152 | $missed_post = absint( $missed_post ); 153 | wp_publish_post( $missed_post ); 154 | wp_clear_scheduled_hook( 'publish_future_post', array( $missed_post ) ); 155 | 156 | do_action( 'a8c_cron_control_published_post_that_missed_schedule', $missed_post ); 157 | } 158 | } 159 | 160 | /** 161 | * Ensure scheduled posts have a corresponding cron job to publish them. 162 | */ 163 | public function confirm_scheduled_posts() { 164 | global $wpdb; 165 | 166 | $page = 1; 167 | $quantity = 100; 168 | 169 | do { 170 | $offset = max( 0, $page - 1 ) * $quantity; 171 | $future_posts = $wpdb->get_results( $wpdb->prepare( "SELECT ID, post_date FROM {$wpdb->posts} p JOIN (SELECT DISTINCT post_type FROM {$wpdb->posts}) AS t ON p.post_type = t.post_type WHERE post_status = 'future' AND post_date > %s LIMIT %d,%d", current_time( 'mysql', false ), $offset, $quantity ) ); 172 | 173 | if ( ! empty( $future_posts ) ) { 174 | foreach ( $future_posts as $future_post ) { 175 | $future_post->ID = absint( $future_post->ID ); 176 | $gmt_time = strtotime( get_gmt_from_date( $future_post->post_date ) . ' GMT' ); 177 | $timestamp = wp_next_scheduled( 'publish_future_post', array( $future_post->ID ) ); 178 | 179 | if ( false === $timestamp ) { 180 | wp_schedule_single_event( $gmt_time, 'publish_future_post', array( $future_post->ID ) ); 181 | 182 | do_action( 'a8c_cron_control_publish_scheduled', $future_post->ID ); 183 | } elseif ( (int) $timestamp !== $gmt_time ) { 184 | wp_clear_scheduled_hook( 'publish_future_post', array( $future_post->ID ) ); 185 | wp_schedule_single_event( $gmt_time, 'publish_future_post', array( $future_post->ID ) ); 186 | 187 | do_action( 'a8c_cron_control_publish_rescheduled', $future_post->ID ); 188 | } 189 | } 190 | } 191 | 192 | $page++; 193 | 194 | if ( count( $future_posts ) < $quantity || $page > 5 ) { 195 | break; 196 | } 197 | } while ( ! empty( $future_posts ) ); 198 | } 199 | 200 | /** 201 | * Delete event objects for events that have run. 202 | */ 203 | public function purge_completed_events() { 204 | Events_Store::instance()->purge_completed_events(); 205 | } 206 | 207 | /** 208 | * Handles legacy data and general cleanup. 209 | */ 210 | public function clean_legacy_data() { 211 | $this->migrate_legacy_cron_events(); 212 | 213 | // Now that we've migrated events, can delete the cron option to save space in alloptions. 214 | delete_option( 'cron' ); 215 | 216 | // While this plugin doesn't use this locking mechanism, other code may check the value. 217 | delete_transient( 'doing_cron' ); 218 | 219 | $this->prune_duplicate_events(); 220 | $this->ensure_internal_events_have_correct_schedule(); 221 | } 222 | 223 | private function migrate_legacy_cron_events() { 224 | global $wpdb; 225 | 226 | // Grab directly from the database to avoid our special filtering. 227 | $cron_row = $wpdb->get_row( "SELECT * FROM $wpdb->options WHERE option_name = 'cron'" ); 228 | if ( ! isset( $cron_row->option_value ) ) { 229 | return; 230 | } 231 | 232 | $cron_array = maybe_unserialize( $cron_row->option_value ); 233 | if ( ! is_array( $cron_array ) ) { 234 | return; 235 | } 236 | 237 | $legacy_events = Events::flatten_wp_events_array( $cron_array ); 238 | $registered_events = Events::flatten_wp_events_array( pre_get_cron_option( false ) ); 239 | 240 | // Add any legacy events that are not registered in our custom table yet. 241 | $events_to_add = array_diff_key( $legacy_events, $registered_events ); 242 | foreach ( $events_to_add as $event_to_add ) { 243 | $wp_event = [ 244 | 'timestamp' => $event_to_add['timestamp'], 245 | 'hook' => $event_to_add['action'], 246 | 'args' => $event_to_add['args'], 247 | ]; 248 | 249 | if ( ! empty( $event_to_add['schedule'] ) ) { 250 | $wp_event['schedule'] = $event_to_add['schedule']; 251 | $wp_event['interval'] = $event_to_add['interval']; 252 | } 253 | 254 | // Pass it up through this function so we can take advantage of duplicate prevention. 255 | pre_schedule_event( null, (object) $wp_event ); 256 | } 257 | } 258 | 259 | // Recurring events that have the same action/args/schedule are unnecessary. We can safely remove them. 260 | private function prune_duplicate_events() { 261 | $events = Events::query( [ 'limit' => -1, 'orderby' => 'ID', 'order' => 'ASC' ] ); 262 | 263 | $original_events = []; 264 | $duplicate_events = []; 265 | foreach ( $events as $event ) { 266 | if ( ! $event->is_recurring() ) { 267 | // Only interested in recurring events. 268 | continue; 269 | } 270 | 271 | $unique_key = sha1( "{$event->get_action()}:{$event->get_instance()}:{$event->get_schedule()}" ); 272 | if ( ! isset( $original_events[ $unique_key ] ) ) { 273 | // The first occurrence, will also be the oldest (lowest ID). 274 | $original_events[ $unique_key ] = true; 275 | } else { 276 | // Found a duplicate! 277 | $duplicate_events[] = $event; 278 | } 279 | } 280 | 281 | foreach ( $duplicate_events as $duplicate_event ) { 282 | // For now we'll just complete them. 283 | $duplicate_event->complete(); 284 | } 285 | } 286 | 287 | private function ensure_internal_events_have_correct_schedule() { 288 | $schedules = wp_get_schedules(); 289 | 290 | foreach ( $this->internal_events as $internal_event ) { 291 | $timestamp = wp_next_scheduled( $internal_event['action'] ); 292 | 293 | // Will reschedule on its own. 294 | if ( false === $timestamp ) { 295 | continue; 296 | } 297 | 298 | $event = Event::find( [ 299 | 'timestamp' => $timestamp, 300 | 'action' => $internal_event['action'], 301 | 'instance' => md5( maybe_serialize( [] ) ), 302 | ] ); 303 | 304 | if ( ! is_null( $event ) && $event->get_schedule() !== $internal_event['schedule'] ) { 305 | // Update to the new schedule. 306 | $event->set_schedule( $internal_event['schedule'], $schedules[ $internal_event['schedule'] ]['interval'] ); 307 | $event->save(); 308 | } 309 | } 310 | } 311 | } 312 | -------------------------------------------------------------------------------- /includes/class-lock.php: -------------------------------------------------------------------------------- 1 | = $limit ) { 41 | return false; 42 | } else { 43 | wp_cache_incr( self::get_key( $lock ) ); 44 | return true; 45 | } 46 | } 47 | 48 | /** 49 | * When event completes, allow another 50 | * 51 | * @param string $lock Lock name. 52 | * @param int $expires Lock expiration timestamp. 53 | * @return bool 54 | */ 55 | public static function free_lock( $lock, $expires = 0 ) { 56 | if ( self::get_lock_value( $lock ) > 1 ) { 57 | wp_cache_decr( self::get_key( $lock ) ); 58 | } else { 59 | wp_cache_set( self::get_key( $lock ), 0, null, $expires ); 60 | } 61 | 62 | wp_cache_set( self::get_key( $lock, 'timestamp' ), time(), null, $expires ); 63 | 64 | return true; 65 | } 66 | 67 | /** 68 | * Build cache key 69 | * 70 | * @param string $lock Lock name. 71 | * @param string $type Key type, either lock or timestamp. 72 | * @return string|bool 73 | */ 74 | private static function get_key( $lock, $type = 'lock' ) { 75 | switch ( $type ) { 76 | case 'lock': 77 | return "a8ccc_lock_{$lock}"; 78 | break; 79 | 80 | case 'timestamp': 81 | return "a8ccc_lock_ts_{$lock}"; 82 | break; 83 | } 84 | 85 | return false; 86 | } 87 | 88 | /** 89 | * Ensure lock entries are initially set 90 | * 91 | * @param string $lock Lock name. 92 | * @param int $expires Lock expiration timestamp. 93 | * @return null 94 | */ 95 | public static function prime_lock( $lock, $expires = 0 ) { 96 | wp_cache_add( self::get_key( $lock ), 0, null, $expires ); 97 | wp_cache_add( self::get_key( $lock, 'timestamp' ), time(), null, $expires ); 98 | 99 | return null; 100 | } 101 | 102 | /** 103 | * Retrieve a lock from cache 104 | * 105 | * @param string $lock Lock name. 106 | * @return int 107 | */ 108 | public static function get_lock_value( $lock ) { 109 | return (int) wp_cache_get( self::get_key( $lock ), null, true ); 110 | } 111 | 112 | /** 113 | * Retrieve a lock's timestamp 114 | * 115 | * @param string $lock Lock name. 116 | * @return int 117 | */ 118 | public static function get_lock_timestamp( $lock ) { 119 | return (int) wp_cache_get( self::get_key( $lock, 'timestamp' ), null, true ); 120 | } 121 | 122 | /** 123 | * Clear a lock's current values, in order to free it 124 | * 125 | * @param string $lock Lock name. 126 | * @param int $expires Lock expiration timestamp. 127 | * @return bool 128 | */ 129 | public static function reset_lock( $lock, $expires = 0 ) { 130 | wp_cache_set( self::get_key( $lock ), 0, null, $expires ); 131 | wp_cache_set( self::get_key( $lock, 'timestamp' ), time(), null, $expires ); 132 | 133 | return true; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /includes/class-main.php: -------------------------------------------------------------------------------- 1 | check_requirements(); 17 | if ( ! empty( $missing_requirements ) ) { 18 | $this->alert_for_missing_requirements( $missing_requirements ); 19 | 20 | // Avoid loading any of the rest of the plugin. 21 | return; 22 | } 23 | 24 | $this->load_plugin_classes(); 25 | $this->block_normal_cron_execution(); 26 | } 27 | 28 | private function check_requirements() { 29 | global $wp_version; 30 | 31 | $missing_reqs = []; 32 | 33 | if ( ! defined( '\WP_CRON_CONTROL_SECRET' ) ) { 34 | /* translators: 1: Constant name */ 35 | $missing_reqs[] = sprintf( __( 'Must define the constant %1$s.', 'automattic-cron-control' ), 'WP_CRON_CONTROL_SECRET' ); 36 | } 37 | 38 | $required_php_version = '7.4'; 39 | if ( version_compare( phpversion(), $required_php_version, '<' ) ) { 40 | /* translators: 1: PHP version */ 41 | $missing_reqs[] = sprintf( __( 'The PHP version must be %1$s or above.', 'automattic-cron-control' ), $required_php_version ); 42 | } 43 | 44 | $required_wp_version = '5.1'; 45 | if ( version_compare( $wp_version, $required_wp_version, '<' ) ) { 46 | /* translators: 1: WP version */ 47 | $missing_reqs[] = sprintf( __( 'The WP version must be %1$s or above.', 'automattic-cron-control' ), $required_wp_version ); 48 | } 49 | 50 | return $missing_reqs; 51 | } 52 | 53 | private function alert_for_missing_requirements( $missing_requirements ) { 54 | foreach ( $missing_requirements as $requirement_message ) { 55 | trigger_error( 'Cron-Control: ' . $requirement_message, E_USER_WARNING ); 56 | } 57 | 58 | $admin_message = 'Cron Control: ' . implode( ' ', $missing_requirements ); 59 | add_action( 'admin_notices', function () use ( $admin_message ) { 60 | ?> 61 |
62 |

[], 'code' => [] ] ); ?>

63 |
64 | set_disable_cron_constants(); 110 | } 111 | 112 | /** 113 | * Block direct cron execution as early as possible 114 | * 115 | * NOTE: We cannot influence the response if php-fpm is in use, as WP core calls fastcgi_finish_request() very early on 116 | */ 117 | public function block_direct_cron() { 118 | if ( false !== stripos( $_SERVER['REQUEST_URI'], '/wp-cron.php' ) || false !== stripos( $_SERVER['SCRIPT_NAME'], '/wp-cron.php' ) ) { 119 | $wp_error = new \WP_Error( 'forbidden', __( 'Normal cron execution is blocked when the Cron Control plugin is active.', 'automattic-cron-control' ) ); 120 | wp_send_json_error( $wp_error, 403 ); 121 | } 122 | } 123 | 124 | /** 125 | * Block the `spawn_cron()` function 126 | * 127 | * @param array $spawn_cron_args Arguments used to trigger a wp-cron.php request. 128 | * @return array 129 | */ 130 | public function block_spawn_cron( $spawn_cron_args ) { 131 | delete_transient( 'doing_cron' ); 132 | 133 | $spawn_cron_args['url'] = ''; 134 | $spawn_cron_args['key'] = ''; 135 | $spawn_cron_args['args'] = array(); 136 | 137 | return $spawn_cron_args; 138 | } 139 | 140 | /** 141 | * Define constants that block Core's cron 142 | * 143 | * If a constant is already defined and isn't what we expect, log it 144 | */ 145 | private function set_disable_cron_constants() { 146 | $constants = array( 147 | 'DISABLE_WP_CRON' => true, 148 | 'ALTERNATE_WP_CRON' => false, 149 | ); 150 | 151 | foreach ( $constants as $constant => $expected_value ) { 152 | if ( defined( $constant ) ) { 153 | if ( constant( $constant ) !== $expected_value ) { 154 | /* translators: 1: Constant name */ 155 | trigger_error( 'Cron-Control: ' . sprintf( __( '%1$s set to unexpected value; must be corrected for proper behaviour.', 'automattic-cron-control' ), $constant ), E_USER_WARNING ); 156 | } 157 | } else { 158 | define( $constant, $expected_value ); 159 | } 160 | } 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /includes/class-rest-api.php: -------------------------------------------------------------------------------- 1 | 'POST', 45 | 'callback' => array( $this, 'get_events' ), 46 | 'permission_callback' => array( $this, 'check_secret' ), 47 | 'show_in_index' => false, 48 | ) 49 | ); 50 | 51 | register_rest_route( 52 | self::API_NAMESPACE, 53 | '/' . self::ENDPOINT_RUN, 54 | array( 55 | 'methods' => 'PUT', 56 | 'callback' => array( $this, 'run_event' ), 57 | 'permission_callback' => array( $this, 'check_secret' ), 58 | 'show_in_index' => false, 59 | ) 60 | ); 61 | } 62 | 63 | /** 64 | * List events pending for the current period 65 | * 66 | * For monitoring and alerting, also provides the total number of pending events 67 | */ 68 | public function get_events() { 69 | $response_array = array( 70 | 'events' => array(), 71 | 'orchestrate_disabled' => Events::instance()->run_disabled(), 72 | ); 73 | 74 | // Include events only when automatic execution is enabled. 75 | if ( 0 === $response_array['orchestrate_disabled'] ) { 76 | $response_array = array_merge( $response_array, Events::instance()->get_events() ); 77 | } 78 | 79 | $response_array['endpoint'] = get_rest_url( null, self::API_NAMESPACE . '/' . self::ENDPOINT_RUN ); 80 | 81 | // Provide pending event count for monitoring etc. 82 | $response_array['total_events_pending'] = count_events_by_status( Events_Store::STATUS_PENDING ); 83 | 84 | return rest_ensure_response( $response_array ); 85 | } 86 | 87 | /** 88 | * Execute a specific event 89 | * 90 | * @param object $request REST API request object. 91 | * @return object 92 | */ 93 | public function run_event( $request ) { 94 | // Stop if event execution is . 95 | $run_disabled = Events::instance()->run_disabled(); 96 | if ( 0 !== $run_disabled ) { 97 | if ( 1 === $run_disabled ) { 98 | $message = __( 'Automatic event execution is disabled indefinitely.', 'automattic-cron-control' ); 99 | } else { 100 | /* translators: 1: Time automatic execution is disabled until, 2: Unix timestamp */ 101 | $message = sprintf( __( 'Automatic event execution is disabled until %1$s UTC (%2$d).', 'automattic-cron-control' ), date_i18n( TIME_FORMAT, $run_disabled ), $run_disabled ); 102 | } 103 | 104 | return rest_ensure_response( 105 | new \WP_Error( 106 | 'automatic-execution-disabled', 107 | $message, 108 | array( 109 | 'status' => 403, 110 | ) 111 | ) 112 | ); 113 | } 114 | 115 | // Parse request for details needed to identify the event to execute. 116 | // `$timestamp` is, unsurprisingly, the Unix timestamp the event is scheduled for. 117 | // `$action` is the md5 hash of the action used when the event is registered. 118 | // `$instance` is the md5 hash of the event's arguments array, which Core uses to index the `cron` option. 119 | $event = $request->get_json_params(); 120 | $timestamp = isset( $event['timestamp'] ) ? absint( $event['timestamp'] ) : null; 121 | $action = isset( $event['action'] ) ? trim( sanitize_text_field( $event['action'] ) ) : null; 122 | $instance = isset( $event['instance'] ) ? trim( sanitize_text_field( $event['instance'] ) ) : null; 123 | 124 | return rest_ensure_response( run_event( $timestamp, $action, $instance ) ); 125 | } 126 | 127 | /** 128 | * Check if request is authorized 129 | * 130 | * @param object $request REST API request object. 131 | * @return bool|\WP_Error 132 | */ 133 | public function check_secret( $request ) { 134 | if ( false === \WP_CRON_CONTROL_SECRET ) { 135 | return new \WP_Error( 136 | 'api-disabled', 137 | __( 'Cron Control REST API endpoints are disabled', 'automattic-cron-control' ), 138 | array( 139 | 'status' => 403, 140 | ) 141 | ); 142 | } 143 | 144 | $body = $request->get_json_params(); 145 | 146 | // For now, mimic original plugin's "authentication" method. This needs to be better. 147 | if ( ! isset( $body['secret'] ) || ! hash_equals( \WP_CRON_CONTROL_SECRET, $body['secret'] ) ) { 148 | return new \WP_Error( 149 | 'no-secret', 150 | __( 'Secret must be specified with all requests', 'automattic-cron-control' ), 151 | array( 152 | 'status' => 400, 153 | ) 154 | ); 155 | } 156 | 157 | return true; 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /includes/class-singleton.php: -------------------------------------------------------------------------------- 1 | class_init(); 33 | } 34 | 35 | return self::$__instances[ $caller ]; 36 | } 37 | 38 | /** 39 | * Singleton constructor 40 | */ 41 | protected function __construct() {} 42 | 43 | /** 44 | * PLUGIN SETUP 45 | */ 46 | 47 | /** 48 | * Register hooks 49 | */ 50 | protected function class_init() {} 51 | } 52 | -------------------------------------------------------------------------------- /includes/constants.php: -------------------------------------------------------------------------------- 1 | is_internal_event( $action ); 18 | } 19 | 20 | /** 21 | * Check which of the plugin's REST endpoints the current request is for, if any 22 | * 23 | * @return string|bool 24 | */ 25 | function get_endpoint_type() { 26 | // Request won't change, so hold for the duration. 27 | static $endpoint_slug = null; 28 | if ( ! is_null( $endpoint_slug ) ) { 29 | return $endpoint_slug; 30 | } 31 | 32 | // Determine request URL according to how Core does. 33 | $request = parse_request(); 34 | 35 | // Search by our URL "prefix". 36 | $namespace = sprintf( '%s/%s', rest_get_url_prefix(), REST_API::API_NAMESPACE ); 37 | 38 | // Check if any parts of the parse request are in our namespace. 39 | $endpoint_slug = false; 40 | 41 | foreach ( $request as $req ) { 42 | if ( 0 === stripos( $req, $namespace ) ) { 43 | $req_parts = explode( '/', $req ); 44 | $endpoint_slug = array_pop( $req_parts ); 45 | break; 46 | } 47 | } 48 | 49 | return $endpoint_slug; 50 | } 51 | 52 | /** 53 | * Check if the current request is to one of the plugin's REST endpoints 54 | * 55 | * @param string $type Endpoint Constant from REST_API class to compare against. 56 | * @return bool 57 | */ 58 | function is_rest_endpoint_request( $type ) { 59 | return get_endpoint_type() === $type; 60 | } 61 | 62 | /** 63 | * Execute a specific event 64 | * 65 | * @param int $timestamp Unix timestamp. 66 | * @param string $action_hashed md5 hash of the action used when the event is registered. 67 | * @param string $instance md5 hash of the event's arguments array, which Core uses to index the `cron` option. 68 | * @param bool $force Run event regardless of timestamp or lock status? eg, when executing jobs via wp-cli. 69 | * @return array|\WP_Error 70 | */ 71 | function run_event( $timestamp, $action_hashed, $instance, $force = false ) { 72 | return Events::instance()->run_event( $timestamp, $action_hashed, $instance, $force ); 73 | } 74 | 75 | /** 76 | * Count events with a given status 77 | * 78 | * @param string $status Status to count. 79 | * @return int|false 80 | */ 81 | function count_events_by_status( $status ) { 82 | return Events_Store::instance()->count_events_by_status( $status ); 83 | } 84 | -------------------------------------------------------------------------------- /includes/utils.php: -------------------------------------------------------------------------------- 1 | index were replaced with $rewrite_index, and whitespace updated, but otherwise, this is directly from WP::parse_request() 29 | */ 30 | // Borrowed from Core. @codingStandardsIgnoreStart 31 | $pathinfo = isset( $_SERVER['PATH_INFO'] ) ? $_SERVER['PATH_INFO'] : ''; 32 | list( $pathinfo ) = explode( '?', $pathinfo ); 33 | $pathinfo = str_replace( "%", "%25", $pathinfo ); 34 | 35 | list( $req_uri ) = explode( '?', $_SERVER['REQUEST_URI'] ); 36 | $self = $_SERVER['PHP_SELF']; 37 | 38 | $home_path = parse_url( home_url(), PHP_URL_PATH ); 39 | $home_path_regex = ''; 40 | if ( is_string( $home_path ) && '' !== $home_path ) { 41 | $home_path = trim( $home_path, '/' ); 42 | $home_path_regex = sprintf( '|^%s|i', preg_quote( $home_path, '|' ) ); 43 | } 44 | 45 | /* 46 | * Trim path info from the end and the leading home path from the front. 47 | * For path info requests, this leaves us with the requesting filename, if any. 48 | * For 404 requests, this leaves us with the requested permalink. 49 | */ 50 | $req_uri = str_replace( $pathinfo, '', $req_uri ); 51 | $req_uri = trim( $req_uri, '/' ); 52 | $pathinfo = trim( $pathinfo, '/' ); 53 | $self = trim( $self, '/' ); 54 | 55 | if ( ! empty( $home_path_regex ) ) { 56 | $req_uri = preg_replace( $home_path_regex, '', $req_uri ); 57 | $req_uri = trim( $req_uri, '/' ); 58 | $pathinfo = preg_replace( $home_path_regex, '', $pathinfo ); 59 | $pathinfo = trim( $pathinfo, '/' ); 60 | $self = preg_replace( $home_path_regex, '', $self ); 61 | $self = trim( $self, '/' ); 62 | } 63 | 64 | // The requested permalink is in $pathinfo for path info requests and 65 | // $req_uri for other requests. 66 | if ( ! empty( $pathinfo ) && ! preg_match( '|^.*' . $rewrite_index . '$|', $pathinfo ) ) { 67 | $requested_path = $pathinfo; 68 | } else { 69 | // If the request uri is the index, blank it out so that we don't try to match it against a rule. 70 | if ( $req_uri == $rewrite_index ) { 71 | $req_uri = ''; 72 | } 73 | 74 | $requested_path = $req_uri; 75 | } 76 | 77 | $requested_file = $req_uri; 78 | // Borrowed from Core. @codingStandardsIgnoreEnd 79 | /** 80 | * End what's borrowed from Core 81 | */ 82 | 83 | // Return array of data about the request. 84 | $parsed_request = compact( 'requested_path', 'requested_file', 'self' ); 85 | 86 | return $parsed_request; 87 | } 88 | 89 | /** 90 | * Consistently set flag Core uses to indicate cron execution is ongoing 91 | */ 92 | function set_doing_cron() { 93 | if ( ! defined( 'DOING_CRON' ) ) { 94 | define( 'DOING_CRON', true ); 95 | } 96 | 97 | // WP 4.8 introduced the `wp_doing_cron()` function and filter. 98 | // These can be used to override the `DOING_CRON` constant, which may cause problems for plugin's requests. 99 | add_filter( 'wp_doing_cron', '__return_true', 99999 ); 100 | } 101 | 102 | // Helper method for deprecating publicly accessibly functions/methods. 103 | function _deprecated_function( string $function, string $replacement = '', $error_level = 2 ) { 104 | $error_levels = [ 105 | 'debug' => 1, 106 | 'notice' => 2, 107 | 'warn' => 3, 108 | ]; 109 | 110 | $message = sprintf( 'Cron-Control: Deprecation. %s is deprecated and will soon be removed.', $function ); 111 | if ( ! empty( $replacement ) ) { 112 | $message .= sprintf( ' Use %s instead.', $replacement ); 113 | } 114 | 115 | // Use E_WARNING error level. 116 | $warning_constant = defined( 'CRON_CONTROL_WARN_FOR_DEPRECATIONS' ) && CRON_CONTROL_WARN_FOR_DEPRECATIONS; 117 | if ( $warning_constant || $error_level >= $error_levels['warn'] ) { 118 | trigger_error( $message, E_USER_WARNING ); 119 | return; 120 | } 121 | 122 | // Use E_USER_NOTICE regardless of Debug mode. 123 | if ( $error_level >= $error_levels['notice'] ) { 124 | trigger_error( $message, E_USER_NOTICE ); 125 | return; 126 | } 127 | 128 | // Use E_USER_NOTICE only in Debug mode. 129 | if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { 130 | trigger_error( $message, E_USER_NOTICE ); 131 | return; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /includes/wp-adapter.php: -------------------------------------------------------------------------------- 1 | $event->hook, 37 | 'args' => $event->args, 38 | ]; 39 | 40 | $is_recurring = ! empty( $event->schedule ); 41 | if ( $is_recurring ) { 42 | // Prevent exact duplicate recurring events altogether (ignoring timeframe). 43 | // This diverges a bit from core behavior, but preventing such duplicates comes with good benefits. 44 | $query_args['schedule'] = $event->schedule; 45 | } else { 46 | // Prevent one-time event duplicates if there is already another one like it within a 10m time span. 47 | // Same logic as that in wp_schedule_single_event(). 48 | $ten_minutes = 10 * MINUTE_IN_SECONDS; 49 | $current_time = time(); 50 | 51 | $query_args['timestamp'] = [ 52 | 'from' => ( $current_time + $ten_minutes ) > $event->timestamp ? 0 : $event->timestamp - $ten_minutes, 53 | 'to' => $current_time > $event->timestamp ? $current_time + $ten_minutes : $event->timestamp + $ten_minutes, 54 | ]; 55 | } 56 | 57 | $existing = Event::find( $query_args ); 58 | if ( ! is_null( $existing ) ) { 59 | return new WP_Error( 'cron-control:wp:duplicate-event' ); 60 | } 61 | 62 | // TODO: Maybe re-query the duplicate check with SRTM first before we do the INSERT? (also would need a cache bypass flag) 63 | 64 | /** This filter is documented in wordpress/wp-includes/cron.php */ 65 | $event = apply_filters( 'schedule_event', $event ); 66 | if ( ! isset( $event->hook, $event->timestamp, $event->args ) ) { 67 | return new WP_Error( 'cron-control:wp:schedule-event-prevented' ); 68 | } 69 | 70 | // Passed duplicate checks, all clear to create an event. 71 | $new_event = new Event(); 72 | $new_event->set_action( $event->hook ); 73 | $new_event->set_timestamp( $event->timestamp ); 74 | $new_event->set_args( $event->args ); 75 | 76 | if ( ! empty( $event->schedule ) && ! empty( $event->interval ) ) { 77 | $new_event->set_schedule( $event->schedule, $event->interval ); 78 | } 79 | 80 | return $new_event->save(); 81 | } 82 | 83 | /** 84 | * Intercept event rescheduling. 85 | * Should be unused if core cron is disabled (using cron control runner for example). 86 | * 87 | * @param null $pre Null if the process has not been intercepted yet. 88 | * @param stdClass $event Event object. 89 | * @return bool|WP_Error true on success, WP_Error on failure. 90 | */ 91 | function pre_reschedule_event( $pre, $event ) { 92 | if ( null !== $pre ) { 93 | return $pre; 94 | } 95 | 96 | $event = Event::find( [ 97 | 'timestamp' => $event->timestamp, 98 | 'action' => $event->hook, 99 | 'args' => $event->args, 100 | ] ); 101 | 102 | if ( is_null( $event ) ) { 103 | return new WP_Error( 'cron-control:wp:event-not-found' ); 104 | } 105 | 106 | return $event->reschedule(); 107 | } 108 | 109 | /** 110 | * Intercept event unscheduling. 111 | * 112 | * @param null $pre Null if the process has not been intercepted yet. 113 | * @param int $timestamp Event timestamp. 114 | * @param string $hook Event action. 115 | * @param array $args Event arguments. 116 | * @return bool|WP_Error true on success, WP_Error on failure. 117 | */ 118 | function pre_unschedule_event( $pre, $timestamp, $hook, $args ) { 119 | if ( null !== $pre ) { 120 | return $pre; 121 | } 122 | 123 | $event = Event::find( [ 124 | 'timestamp' => $timestamp, 125 | 'action' => $hook, 126 | 'args' => $args, 127 | ] ); 128 | 129 | if ( is_null( $event ) ) { 130 | return new WP_Error( 'cron-control:wp:event-not-found' ); 131 | } 132 | 133 | return $event->complete(); 134 | } 135 | 136 | /** 137 | * Clear all actions for a given hook with specific event args. 138 | * 139 | * @param null $pre Null if the process has not been intercepted yet. 140 | * @param string $hook Event action. 141 | * @param null|array $args Event arguments. Passing null will delete all events w/ the hook. 142 | * @return int|WP_Error Number of unscheduled events on success (could be 0), WP_Error on failure. 143 | */ 144 | function pre_clear_scheduled_hook( $pre, $hook, $args ) { 145 | if ( null !== $pre ) { 146 | return $pre; 147 | } 148 | 149 | $query_args = [ 150 | 'action' => $hook, 151 | 'args' => $args, 152 | 'limit' => 500, 153 | 'page' => 1, 154 | ]; 155 | 156 | // First grab all the events before making any changes (avoiding pagination complexities). 157 | $all_events = []; 158 | do { 159 | $events = Events::query( $query_args ); 160 | $all_events = array_merge( $all_events, $events ); 161 | 162 | $query_args['page']++; 163 | } while ( ! empty( $events ) ); 164 | 165 | $all_successful = true; 166 | foreach ( $all_events as $event ) { 167 | $result = $event->complete(); 168 | 169 | if ( true !== $result ) { 170 | $all_successful = false; 171 | } 172 | } 173 | 174 | return $all_successful ? count( $all_events ) : new WP_Error( 'cron-control:wp:failed-event-deleting' ); 175 | } 176 | 177 | /** 178 | * Clear all actions for a given hook, regardless of event args. 179 | * 180 | * @param null $pre Null if the process has not been intercepted yet. 181 | * @param string $hook Event action. 182 | * @return int|WP_Error Number of unscheduled events on success (could be 0), WP_Error on failure. 183 | */ 184 | function pre_unschedule_hook( $pre, $hook ) { 185 | if ( null !== $pre ) { 186 | return $pre; 187 | } 188 | 189 | return pre_clear_scheduled_hook( $pre, $hook, null ); 190 | } 191 | 192 | /** 193 | * Intercept event retrieval. 194 | * 195 | * @param null $pre Null if the process has not been intercepted yet. 196 | * @param string $hook Event action. 197 | * @param array $args Event arguments. 198 | * @param int|null $timestamp Event timestamp, null to just retrieve the next event. 199 | * @return object|false The event object. False if the event does not exist. 200 | */ 201 | function pre_get_scheduled_event( $pre, $hook, $args, $timestamp ) { 202 | if ( null !== $pre ) { 203 | return $pre; 204 | } 205 | 206 | $event = Event::find( [ 207 | 'timestamp' => $timestamp, 208 | 'action' => $hook, 209 | 'args' => $args, 210 | ] ); 211 | 212 | return is_null( $event ) ? false : $event->get_wp_event_format(); 213 | } 214 | 215 | /** 216 | * Intercept "ready events" retrieval. 217 | * Should be unused if core cron is disabled (using cron control runner for example). 218 | * 219 | * @param null $pre Null if the process has not been intercepted yet. 220 | * @return array Cron events ready to be run. 221 | */ 222 | function pre_get_ready_cron_jobs( $pre ) { 223 | if ( null !== $pre ) { 224 | return $pre; 225 | } 226 | 227 | // 100 is more than enough here. 228 | // Also, we are unlikely to see this function ever used w/ core cron running disabled. 229 | $events = Events::query( [ 230 | 'timestamp' => 'due_now', 231 | 'limit' => 100, 232 | ] ); 233 | 234 | return Events::format_events_for_wp( $events ); 235 | } 236 | 237 | /** 238 | * Intercepts requests for the entire 'cron' option. 239 | * Ideally this is never called any more, but we must support this for backwards compatibility. 240 | * 241 | * @param false $pre False if the process has not been intercepted yet. 242 | * @return array Cron array, in the format WP expects. 243 | */ 244 | function pre_get_cron_option( $pre ) { 245 | if ( false !== $pre ) { 246 | return $pre; 247 | } 248 | 249 | // For maximum BC, we need to truly give all events here. 250 | // Stepping in increments of 500 to allow query caching to do it's job. 251 | $query_args = [ 'limit' => 500, 'page' => 1 ]; 252 | $all_events = []; 253 | do { 254 | $events = Events::query( $query_args ); 255 | $all_events = array_merge( $all_events, $events ); 256 | 257 | $query_args['page']++; 258 | } while ( ! empty( $events ) ); 259 | 260 | $cron_array = Events::format_events_for_wp( $all_events ); 261 | $cron_array['version'] = 2; // a legacy core thing 262 | return $cron_array; 263 | } 264 | 265 | /** 266 | * Intercepts 'cron' option update. 267 | * Ideally this is never called any more either, but we must support this for backwards compatibility. 268 | * 269 | * @param array $new_value New cron array trying to be saved 270 | * @param array $old_value Existing cron array (already intercepted via pre_get_cron_option() above) 271 | * @return array Always returns $old_value to prevent update from occurring. 272 | */ 273 | function pre_update_cron_option( $new_value, $old_value ) { 274 | if ( ! is_array( $new_value ) ) { 275 | return $old_value; 276 | } 277 | 278 | $current_events = Events::flatten_wp_events_array( $old_value ); 279 | $new_events = Events::flatten_wp_events_array( $new_value ); 280 | 281 | // Remove first, to prevent scheduling conflicts for the "added events" next. 282 | $removed_events = array_diff_key( $current_events, $new_events ); 283 | foreach ( $removed_events as $event_to_remove ) { 284 | $existing_event = Event::find( [ 285 | 'timestamp' => $event_to_remove['timestamp'], 286 | 'action' => $event_to_remove['action'], 287 | 'args' => $event_to_remove['args'], 288 | ] ); 289 | 290 | if ( ! is_null( $existing_event ) ) { 291 | // Mark as completed (perhaps canceled in the future). 292 | $existing_event->complete(); 293 | } 294 | } 295 | 296 | // Now add any new events. 297 | $added_events = array_diff_key( $new_events, $current_events ); 298 | foreach ( $added_events as $event_to_add ) { 299 | $wp_event = [ 300 | 'timestamp' => $event_to_add['timestamp'], 301 | 'hook' => $event_to_add['action'], 302 | 'args' => $event_to_add['args'], 303 | ]; 304 | 305 | if ( ! empty( $event_to_add['schedule'] ) ) { 306 | $wp_event['schedule'] = $event_to_add['schedule']; 307 | $wp_event['interval'] = $event_to_add['interval']; 308 | } 309 | 310 | // Pass it up through this function so we can take advantage of duplicate prevention. 311 | pre_schedule_event( null, (object) $wp_event ); 312 | } 313 | 314 | // Always just return the old value so we don't trigger a db update. 315 | return $old_value; 316 | } 317 | -------------------------------------------------------------------------------- /includes/wp-cli.php: -------------------------------------------------------------------------------- 1 | arguments; 18 | if ( ! is_array( $cmd ) || ! isset( $cmd['0'] ) ) { 19 | return; 20 | } 21 | 22 | if ( false === strpos( $cmd[0], 'cron-control' ) ) { 23 | return; 24 | } 25 | 26 | // Create table and die, to ensure command runs with proper state. 27 | if ( ! Events_Store::is_installed() ) { 28 | Events_Store::instance()->install(); 29 | \WP_CLI::error( __( 'Cron Control installation completed. Please try again.', 'automattic-cron-control' ) ); 30 | } 31 | 32 | // Set DOING_CRON when appropriate. 33 | if ( isset( $cmd[1] ) && 'orchestrate' === $cmd[1] ) { 34 | @ini_set( 'display_errors', '0' ); // Error output breaks JSON used by runner. @codingStandardsIgnoreLine 35 | \Automattic\WP\Cron_Control\set_doing_cron(); 36 | } 37 | } 38 | 39 | /** 40 | * Consistent time format across commands 41 | * 42 | * Defined here for backwards compatibility, as it was here before it was in the primary namespace 43 | */ 44 | const TIME_FORMAT = \Automattic\WP\Cron_Control\TIME_FORMAT; 45 | 46 | /** 47 | * Clear all of the caches for memory management 48 | */ 49 | function stop_the_insanity() { 50 | global $wpdb, $wp_object_cache; 51 | 52 | $wpdb->queries = array(); 53 | 54 | if ( ! is_object( $wp_object_cache ) ) { 55 | return; 56 | } 57 | 58 | $wp_object_cache->group_ops = array(); 59 | $wp_object_cache->stats = array(); 60 | $wp_object_cache->memcache_debug = array(); 61 | $wp_object_cache->cache = array(); 62 | 63 | if ( method_exists( $wp_object_cache, '__remoteset' ) ) { 64 | $wp_object_cache->__remoteset(); 65 | } 66 | } 67 | 68 | /** 69 | * Load commands 70 | */ 71 | require __DIR__ . '/wp-cli/class-main.php'; 72 | require __DIR__ . '/wp-cli/class-events.php'; 73 | require __DIR__ . '/wp-cli/class-lock.php'; 74 | require __DIR__ . '/wp-cli/class-orchestrate.php'; 75 | require __DIR__ . '/wp-cli/class-orchestrate-runner.php'; 76 | require __DIR__ . '/wp-cli/class-orchestrate-sites.php'; 77 | require __DIR__ . '/wp-cli/class-rest-api.php'; 78 | -------------------------------------------------------------------------------- /includes/wp-cli/class-lock.php: -------------------------------------------------------------------------------- 1 | get_reset_lock( $args, $assoc_args, $lock_name, $lock_limit, $lock_description ); 28 | } 29 | 30 | /** 31 | * Manage the lock that limits concurrent execution of jobs with the same action 32 | * 33 | * @subcommand manage-event-lock 34 | * @synopsis [--reset] 35 | * @param array $args Array of positional arguments. 36 | * @param array $assoc_args Array of flags. 37 | */ 38 | public function manage_event_lock( $args, $assoc_args ) { 39 | if ( empty( $args[0] ) ) { 40 | \WP_CLI::error( sprintf( __( 'Specify an action', 'automattic-cron-control' ) ) ); 41 | } 42 | 43 | $lock_name = \Automattic\WP\Cron_Control\Events::instance()->get_lock_key_for_event_action( 44 | (object) array( 45 | 'action' => $args[0], 46 | ) 47 | ); 48 | 49 | $lock_limit = 1; 50 | $lock_description = __( "This lock prevents concurrent executions of events with the same action, regardless of the action's arguments.", 'automattic-cron-control' ); 51 | 52 | $this->get_reset_lock( $args, $assoc_args, $lock_name, $lock_limit, $lock_description ); 53 | } 54 | 55 | /** 56 | * Retrieve a lock's current value, or reset it 57 | * 58 | * @param array $args Array of positional arguments. 59 | * @param array $assoc_args Array of flags. 60 | * @param string $lock_name Name of lock to reset. 61 | * @param int $lock_limit Lock's maximum concurrency. 62 | * @param string $lock_description Human-friendly explanation of lock's purpose. 63 | */ 64 | private function get_reset_lock( $args, $assoc_args, $lock_name, $lock_limit, $lock_description ) { 65 | // Output information about the lock. 66 | \WP_CLI::log( $lock_description . "\n" ); 67 | 68 | /* translators: 1: Lock limit */ 69 | \WP_CLI::log( sprintf( __( 'Maximum: %s', 'automattic-cron-control' ), number_format_i18n( $lock_limit ) ) . "\n" ); 70 | 71 | // Reset requested. 72 | if ( isset( $assoc_args['reset'] ) ) { 73 | \WP_CLI::warning( __( 'Resetting lock...', 'automattic-cron-control' ) . "\n" ); 74 | 75 | $lock = \Automattic\WP\Cron_Control\Lock::get_lock_value( $lock_name ); 76 | $timestamp = \Automattic\WP\Cron_Control\Lock::get_lock_timestamp( $lock_name ); 77 | 78 | /* translators: 1: Previous lock value */ 79 | \WP_CLI::log( sprintf( __( 'Previous value: %s', 'automattic-cron-control' ), number_format_i18n( $lock ) ) ); 80 | /* translators: 1: Previous lock timestamp */ 81 | \WP_CLI::log( sprintf( __( 'Previously modified: %s UTC', 'automattic-cron-control' ), date_i18n( TIME_FORMAT, $timestamp ) ) . "\n" ); 82 | 83 | \WP_CLI::confirm( sprintf( __( 'Are you sure you want to reset this lock?', 'automattic-cron-control' ) ) ); 84 | \WP_CLI::log( '' ); 85 | 86 | \Automattic\WP\Cron_Control\Lock::reset_lock( $lock_name ); 87 | \WP_CLI::success( __( 'Lock reset', 'automattic-cron-control' ) . "\n" ); 88 | \WP_CLI::log( __( 'New lock values:', 'automattic-cron-control' ) ); 89 | } 90 | 91 | // Output lock state. 92 | $lock = \Automattic\WP\Cron_Control\Lock::get_lock_value( $lock_name ); 93 | $timestamp = \Automattic\WP\Cron_Control\Lock::get_lock_timestamp( $lock_name ); 94 | 95 | /* translators: 1: Current lock value */ 96 | \WP_CLI::log( sprintf( __( 'Current value: %s', 'automattic-cron-control' ), number_format_i18n( $lock ) ) ); 97 | /* translators: 1: Current lock timestamp */ 98 | \WP_CLI::log( sprintf( __( 'Last modified: %s UTC', 'automattic-cron-control' ), date_i18n( TIME_FORMAT, $timestamp ) ) ); 99 | } 100 | } 101 | 102 | \WP_CLI::add_command( 'cron-control locks', 'Automattic\WP\Cron_Control\CLI\Lock' ); 103 | -------------------------------------------------------------------------------- /includes/wp-cli/class-main.php: -------------------------------------------------------------------------------- 1 | ] [--queue-window=] [--format=] 25 | * @param array $args Array of positional arguments. 26 | * @param array $assoc_args Array of flags. 27 | */ 28 | public function list_due_now( $args, $assoc_args ) { 29 | if ( 0 !== \Automattic\WP\Cron_Control\Events::instance()->run_disabled() ) { 30 | \WP_CLI::error( __( 'Automatic event execution is disabled', 'automattic-cron-control' ) ); 31 | } 32 | 33 | // Control how many events are fetched. Note that internal events can exceed this cap. 34 | $queue_size = \WP_CLI\Utils\get_flag_value( $assoc_args, 'queue-size', null ); 35 | if ( ! is_numeric( $queue_size ) ) { 36 | $queue_size = null; 37 | } 38 | 39 | // Control how far into the future events are fetched. 40 | $queue_window = \WP_CLI\Utils\get_flag_value( $assoc_args, 'queue-window', null ); 41 | if ( ! is_numeric( $queue_window ) ) { 42 | $queue_window = null; 43 | } 44 | 45 | $events = \Automattic\WP\Cron_Control\Events::instance()->get_events( $queue_size, $queue_window ); 46 | $events = is_array( $events['events'] ) ? $events['events'] : []; 47 | 48 | $format = \WP_CLI\Utils\get_flag_value( $assoc_args, 'format', 'table' ); 49 | 50 | \WP_CLI\Utils\format_items( 51 | $format, 52 | $events, 53 | array( 54 | 'timestamp', 55 | 'action', 56 | 'instance', 57 | ) 58 | ); 59 | } 60 | 61 | /** 62 | * Run a given event; meant for Runner 63 | * 64 | * Not intended for human use, rather it powers the Go-based Runner. Use the `events run` command instead. 65 | * 66 | * @subcommand run 67 | * @synopsis --timestamp= --action= --instance= 68 | * @param array $args Array of positional arguments. 69 | * @param array $assoc_args Array of flags. 70 | */ 71 | public function run_event( $args, $assoc_args ) { 72 | if ( 0 !== \Automattic\WP\Cron_Control\Events::instance()->run_disabled() ) { 73 | \WP_CLI::error( __( 'Automatic event execution is disabled', 'automattic-cron-control' ) ); 74 | } 75 | 76 | $timestamp = \WP_CLI\Utils\get_flag_value( $assoc_args, 'timestamp', null ); 77 | $action = \WP_CLI\Utils\get_flag_value( $assoc_args, 'action', null ); 78 | $instance = \WP_CLI\Utils\get_flag_value( $assoc_args, 'instance', null ); 79 | 80 | if ( ! is_numeric( $timestamp ) ) { 81 | \WP_CLI::error( __( 'Invalid timestamp', 'automattic-cron-control' ) ); 82 | } 83 | 84 | if ( ! is_string( $action ) ) { 85 | \WP_CLI::error( __( 'Invalid action', 'automattic-cron-control' ) ); 86 | } 87 | 88 | if ( ! is_string( $instance ) ) { 89 | \WP_CLI::error( __( 'Invalid instance', 'automattic-cron-control' ) ); 90 | } 91 | 92 | $now = time(); 93 | if ( $timestamp > $now ) { 94 | /* translators: 1: Event execution time in UTC, 2: Human time diff */ 95 | \WP_CLI::error( sprintf( __( 'Given timestamp is for %1$s UTC, %2$s from now. The event\'s existence was not confirmed, and no attempt was made to execute it.', 'automattic-cron-control' ), date_i18n( TIME_FORMAT, $timestamp ), human_time_diff( $now, $timestamp ) ) ); 96 | } 97 | 98 | // Prepare environment. 99 | \Automattic\WP\Cron_Control\set_doing_cron(); 100 | 101 | // Run the event. 102 | $run = \Automattic\WP\Cron_Control\run_event( $timestamp, $action, $instance ); 103 | 104 | if ( is_wp_error( $run ) ) { 105 | $error_data = $run->get_error_data(); 106 | 107 | if ( isset( $error_data['status'] ) && 404 === $error_data['status'] ) { 108 | \WP_CLI::warning( $run->get_error_message() ); 109 | 110 | exit; 111 | } else { 112 | \WP_CLI::error( $run->get_error_message() ); 113 | } 114 | } elseif ( isset( $run['success'] ) && true === $run['success'] ) { 115 | \WP_CLI::success( $run['message'] ); 116 | } else { 117 | \WP_CLI::error( $run['message'] ); 118 | } 119 | } 120 | 121 | /** 122 | * Get some details needed to execute events; meant for Runner 123 | * 124 | * Not intended for human use, rather it powers the Go-based Runner. Use the `orchestrate manage-automatic-execution` command instead. 125 | * 126 | * @subcommand get-info 127 | * @param array $args Array of positional arguments. 128 | * @param array $assoc_args Array of flags. 129 | */ 130 | public function get_info( $args, $assoc_args ) { 131 | $info = array( 132 | array( 133 | 'multisite' => is_multisite() ? 1 : 0, 134 | 'siteurl' => site_url(), 135 | 'disabled' => \Automattic\WP\Cron_Control\Events::instance()->run_disabled(), 136 | ), 137 | ); 138 | 139 | $format = \WP_CLI\Utils\get_flag_value( $assoc_args, 'format', 'table' ); 140 | 141 | \WP_CLI\Utils\format_items( $format, $info, array_keys( $info[0] ) ); 142 | } 143 | } 144 | 145 | \WP_CLI::add_command( 'cron-control orchestrate runner-only', 'Automattic\WP\Cron_Control\CLI\Orchestrate_Runner' ); 146 | -------------------------------------------------------------------------------- /includes/wp-cli/class-orchestrate-sites.php: -------------------------------------------------------------------------------- 1 | ] 22 | * : The polling interval used by the runner to retrieve events and sites 23 | * 24 | * @param array $args Array of positional arguments. 25 | * @param array $assoc_args Array of flags. 26 | */ 27 | public function heartbeat( $args, $assoc_args ) { 28 | $assoc_args = wp_parse_args( 29 | $assoc_args, 30 | [ 31 | 'heartbeat-interval' => 60, 32 | ] 33 | ); 34 | 35 | $this->do_heartbeat( intval( $assoc_args['heartbeat-interval'] ) ); 36 | } 37 | 38 | /** 39 | * List sites 40 | */ 41 | public function list() { 42 | $hosts = $this->get_hosts(); 43 | 44 | // Use 2 hosts per site. 45 | $num_groups = count( $hosts ) / 2; 46 | if ( $num_groups < 2 ) { 47 | // Every host runs every site. 48 | $this->display_sites(); 49 | return; 50 | } 51 | 52 | $id = array_search( gethostname(), $hosts, true ); 53 | $this->display_sites( $num_groups, $id % $num_groups ); 54 | } 55 | 56 | /** 57 | * Display sites. 58 | * 59 | * @param int $num_groups Number of groups. 60 | * @param int $group Group number. 61 | */ 62 | private function display_sites( $num_groups = 1, $group = 0 ) { 63 | $site_count = get_sites( [ 'count' => 1 ] ); 64 | if ( $site_count > 10000 ) { 65 | trigger_error( 'Cron-Control: This multisite has more than 10000 subsites, currently unsupported.', E_USER_WARNING ); 66 | } 67 | 68 | // Keep the query simple, then process the results. 69 | $all_sites = get_sites( [ 'number' => 10000 ] ); 70 | $sites_to_display = []; 71 | foreach ( $all_sites as $index => $site ) { 72 | if ( ! ( $index % $num_groups === $group ) ) { 73 | // The site does not belong to this group. 74 | continue; 75 | } 76 | 77 | if ( in_array( '1', array( $site->archived, $site->spam, $site->deleted ), true ) ) { 78 | // Deactivated subsites don't need cron run on them. 79 | continue; 80 | } 81 | 82 | // We just need the url to display. 83 | $sites_to_display[] = [ 'url' => $this->get_raw_site_url( $site->path, $site->domain ) ]; 84 | } 85 | 86 | \WP_CLI\Utils\format_items( 'json', $sites_to_display, 'url' ); 87 | } 88 | 89 | /** 90 | * We can't use the home or siteurl since those don't always match with the `wp_blogs` entry. 91 | * And that can lead to "site not found" errors when passed via the `--url` WP-CLI param. 92 | * Instead, we construct the URL from data in the `wp_blogs` table. 93 | */ 94 | private function get_raw_site_url( string $site_path, string $site_domain ): string { 95 | $path = ( $site_path && '/' !== $site_path ) ? $site_path : ''; 96 | return $site_domain . $path; 97 | } 98 | 99 | /** 100 | * Updates the watchdog timer and removes stale hosts. 101 | * 102 | * @param int $heartbeat_interval Heartbeat interval. 103 | */ 104 | private function do_heartbeat( $heartbeat_interval = 60 ) { 105 | if ( defined( 'WPCOM_SANDBOXED' ) && true === WPCOM_SANDBOXED ) { 106 | return; 107 | } 108 | 109 | $heartbeats = wp_cache_get( self::RUNNER_HOST_HEARTBEAT_KEY ); 110 | if ( ! $heartbeats ) { 111 | $heartbeats = []; 112 | } 113 | 114 | // Remove stale hosts 115 | // If a host has missed 2 heartbeats, remove it from jobs processing. 116 | $heartbeats = array_filter( 117 | $heartbeats, 118 | function( $timestamp ) use ( $heartbeat_interval ) { 119 | if ( time() - ( $heartbeat_interval * 2 ) > $timestamp ) { 120 | return false; 121 | } 122 | 123 | return true; 124 | } 125 | ); 126 | 127 | $heartbeats[ gethostname() ] = time(); 128 | wp_cache_set( self::RUNNER_HOST_HEARTBEAT_KEY, $heartbeats ); 129 | } 130 | 131 | /** 132 | * Retrieves hosts and their last alive time from the cache. 133 | * 134 | * @return array Hosts. 135 | */ 136 | private function get_hosts() { 137 | $heartbeats = wp_cache_get( self::RUNNER_HOST_HEARTBEAT_KEY ); 138 | if ( ! $heartbeats ) { 139 | return []; 140 | } 141 | 142 | return array_keys( $heartbeats ); 143 | } 144 | } 145 | 146 | \WP_CLI::add_command( 'cron-control orchestrate sites', 'Automattic\WP\Cron_Control\CLI\Orchestrate_Sites' ); 147 | -------------------------------------------------------------------------------- /includes/wp-cli/class-orchestrate.php: -------------------------------------------------------------------------------- 1 | run_disabled(); 23 | 24 | switch ( $status ) { 25 | case 0: 26 | $status = __( 'Automatic execution is enabled', 'automattic-cron-control' ); 27 | break; 28 | 29 | case 1: 30 | $status = __( 'Automatic execution is disabled indefinitely', 'automattic-cron-control' ); 31 | break; 32 | 33 | default: 34 | /* translators: 1: Human time diff, 2: Time execution is disabled until */ 35 | $status = sprintf( __( 'Automatic execution is disabled for %1$s (until %2$s UTC)', 'automattic-cron-control' ), human_time_diff( $status ), date_i18n( TIME_FORMAT, $status ) ); 36 | break; 37 | } 38 | 39 | \WP_CLI::log( $status ); 40 | } 41 | 42 | /** 43 | * Change status of automatic event execution 44 | * 45 | * When using the Go-based runner, it may be necessary to stop execution for a period, or indefinitely 46 | * 47 | * @subcommand manage-automatic-execution 48 | * @synopsis [--enable] [--disable] [--disable_until=] 49 | * @param array $args Array of positional arguments. 50 | * @param array $assoc_args Array of flags. 51 | */ 52 | public function manage_automatic_execution( $args, $assoc_args ) { 53 | // Update execution status. 54 | $disable_ts = \WP_CLI\Utils\get_flag_value( $assoc_args, 'disable_until', 0 ); 55 | $disable_ts = absint( $disable_ts ); 56 | 57 | if ( \WP_CLI\Utils\get_flag_value( $assoc_args, 'enable', false ) ) { 58 | $updated = \Automattic\WP\Cron_Control\Events::instance()->update_run_status( 0 ); 59 | 60 | if ( $updated ) { 61 | \WP_CLI::success( __( 'Enabled', 'automattic-cron-control' ) ); 62 | return; 63 | } 64 | 65 | \WP_CLI::error( __( 'Could not enable automatic execution. Please check the current status.', 'automattic-cron-control' ) ); 66 | } elseif ( \WP_CLI\Utils\get_flag_value( $assoc_args, 'disable', false ) ) { 67 | $updated = \Automattic\WP\Cron_Control\Events::instance()->update_run_status( 1 ); 68 | 69 | if ( $updated ) { 70 | \WP_CLI::success( __( 'Disabled', 'automattic-cron-control' ) ); 71 | return; 72 | } 73 | 74 | \WP_CLI::error( __( 'Could not disable automatic execution. Please check the current status.', 'automattic-cron-control' ) ); 75 | } elseif ( $disable_ts > 0 ) { 76 | if ( $disable_ts > time() ) { 77 | $updated = \Automattic\WP\Cron_Control\Events::instance()->update_run_status( $disable_ts ); 78 | 79 | if ( $updated ) { 80 | /* translators: 1: Human time diff, 2: Time execution is disabled until */ 81 | \WP_CLI::success( sprintf( __( 'Disabled for %1$s (until %2$s UTC)', 'automattic-cron-control' ), human_time_diff( $disable_ts ), date_i18n( TIME_FORMAT, $disable_ts ) ) ); 82 | return; 83 | } 84 | 85 | \WP_CLI::error( __( 'Could not disable automatic execution. Please check the current status.', 'automattic-cron-control' ) ); 86 | } else { 87 | \WP_CLI::error( __( 'Timestamp is in the past.', 'automattic-cron-control' ) ); 88 | } 89 | } 90 | 91 | \WP_CLI::error( __( 'Please provide a valid action.', 'automattic-cron-control' ) ); 92 | } 93 | } 94 | 95 | \WP_CLI::add_command( 'cron-control orchestrate', 'Automattic\WP\Cron_Control\CLI\Orchestrate' ); 96 | -------------------------------------------------------------------------------- /includes/wp-cli/class-rest-api.php: -------------------------------------------------------------------------------- 1 | add_header( 'Content-Type', 'application/json' ); 27 | $queue_request->set_body( 28 | wp_json_encode( 29 | array( 30 | 'secret' => \WP_CRON_CONTROL_SECRET, 31 | ) 32 | ) 33 | ); 34 | 35 | $queue_request = rest_do_request( $queue_request ); 36 | 37 | // Oh well. 38 | if ( $queue_request->is_error() ) { 39 | \WP_CLI::error( $queue_request->as_error()->get_error_message() ); 40 | } 41 | 42 | // Get the decoded JSON object returned by the API. 43 | $queue_response = $queue_request->get_data(); 44 | 45 | // No events, nothing more to do. 46 | if ( empty( $queue_response['events'] ) ) { 47 | \WP_CLI::warning( __( 'No events in the current queue', 'automattic-cron-control' ) ); 48 | return; 49 | } 50 | 51 | // Prepare items for display. 52 | $events_for_display = $this->format_events( $queue_response['events'] ); 53 | $total_events_to_display = count( $events_for_display ); 54 | /* translators: 1: Event count */ 55 | \WP_CLI::log( sprintf( _n( 'Displaying %s event', 'Displaying %s events', $total_events_to_display, 'automattic-cron-control' ), number_format_i18n( $total_events_to_display ) ) ); 56 | 57 | // And reformat. 58 | $format = 'table'; 59 | if ( isset( $assoc_args['format'] ) ) { 60 | if ( 'ids' === $assoc_args['format'] ) { 61 | \WP_CLI::error( __( 'Invalid output format requested', 'automattic-cron-control' ) ); 62 | } else { 63 | $format = $assoc_args['format']; 64 | } 65 | } 66 | 67 | \WP_CLI\Utils\format_items( 68 | $format, 69 | $events_for_display, 70 | array( 71 | 'timestamp', 72 | 'action', 73 | 'instance', 74 | 'scheduled_for', 75 | 'internal_event', 76 | 'schedule_name', 77 | 'event_args', 78 | ) 79 | ); 80 | } 81 | 82 | /** 83 | * Format event data into something human-readable 84 | * 85 | * @param array $events Events to display. 86 | * @return array 87 | */ 88 | private function format_events( $events ) { 89 | $formatted_events = array(); 90 | 91 | foreach ( $events as $event_data ) { 92 | $event = Event::find( [ 93 | 'timestamp' => $event_data['timestamp'], 94 | 'action_hashed' => $event_data['action'], 95 | 'instance' => $event_data['instance'], 96 | ] ); 97 | 98 | $formatted_events[] = [ 99 | 'timestamp' => $event->get_timestamp(), 100 | 'action' => $event->get_action(), 101 | 'instance' => $event->get_instance(), 102 | 'scheduled_for' => date_i18n( TIME_FORMAT, $event->get_timestamp() ), 103 | 'internal_event' => $event->is_internal() ? __( 'true', 'automattic-cron-control' ) : '', 104 | 'schedule_name' => is_null( $event->get_schedule() ) ? __( 'n/a', 'automattic-cron-control' ) : $event->get_schedule(), 105 | 'event_args' => maybe_serialize( $event->get_args ), 106 | ]; 107 | } 108 | 109 | return $formatted_events; 110 | } 111 | } 112 | 113 | \WP_CLI::add_command( 'cron-control rest-api', 'Automattic\WP\Cron_Control\CLI\REST_API' ); 114 | -------------------------------------------------------------------------------- /languages/cron-control.pot: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2022 Erick Hitter, Automattic 2 | # This file is distributed under the same license as the Cron Control package. 3 | msgid "" 4 | msgstr "" 5 | "Project-Id-Version: Cron Control 3.1\n" 6 | "Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/cron-control\n" 7 | "POT-Creation-Date: 2022-01-25 18:26:12+00:00\n" 8 | "MIME-Version: 1.0\n" 9 | "Content-Type: text/plain; charset=utf-8\n" 10 | "Content-Transfer-Encoding: 8bit\n" 11 | "PO-Revision-Date: 2022-MO-DA HO:MI+ZONE\n" 12 | "Last-Translator: FULL NAME \n" 13 | "Language-Team: LANGUAGE \n" 14 | "Language: en\n" 15 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 16 | "X-Poedit-Country: United States\n" 17 | "X-Poedit-SourceCharset: UTF-8\n" 18 | "X-Poedit-KeywordsList: " 19 | "__;_e;_x:1,2c;_ex:1,2c;_n:1,2;_nx:1,2,4c;_n_noop:1,2;_nx_noop:1,2,3c;esc_" 20 | "attr__;esc_html__;esc_attr_e;esc_html_e;esc_attr_x:1,2c;esc_html_x:1,2c;\n" 21 | "X-Poedit-Basepath: ../\n" 22 | "X-Poedit-SearchPath-0: .\n" 23 | "X-Poedit-Bookmarks: \n" 24 | "X-Textdomain-Support: yes\n" 25 | "X-Generator: grunt-wp-i18n 1.0.3\n" 26 | 27 | #: includes/class-events.php:218 28 | msgid "Invalid or incomplete request data." 29 | msgstr "" 30 | 31 | #: includes/class-events.php:224 32 | #. translators: 1: Job identifier 33 | msgid "Job with identifier `%1$s` is not scheduled to run yet." 34 | msgstr "" 35 | 36 | #: includes/class-events.php:238 37 | #. translators: 1: Job identifier 38 | msgid "Job with identifier `%1$s` could not be found." 39 | msgstr "" 40 | 41 | #: includes/class-events.php:251 42 | #. translators: 1: Event action, 2: Event arguments 43 | msgid "" 44 | "No resources available to run the job with action `%1$s` and arguments " 45 | "`%2$s`." 46 | msgstr "" 47 | 48 | #: includes/class-events.php:280 49 | #. translators: 1: Event action, 2: Event arguments, 3: Throwable error, 4: 50 | #. Line number that raised Throwable error 51 | msgid "" 52 | "Callback for job with action `%1$s` and arguments `%2$s` raised a Throwable " 53 | "- %3$s in %4$s on line %5$d." 54 | msgstr "" 55 | 56 | #: includes/class-events.php:298 57 | #. translators: 1: Event action, 2: Event arguments 58 | msgid "Job with action `%1$s` and arguments `%2$s` executed." 59 | msgstr "" 60 | 61 | #: includes/class-internal-events.php:90 62 | msgid "Cron Control internal job - every 2 minutes (used to be 1 minute)" 63 | msgstr "" 64 | 65 | #: includes/class-internal-events.php:94 66 | msgid "Cron Control internal job - every 10 minutes" 67 | msgstr "" 68 | 69 | #: includes/class-main.php:35 70 | #. translators: 1: Constant name 71 | msgid "Must define the constant %1$s." 72 | msgstr "" 73 | 74 | #: includes/class-main.php:41 75 | #. translators: 1: PHP version 76 | msgid "The PHP version must be %1$s or above." 77 | msgstr "" 78 | 79 | #: includes/class-main.php:47 80 | #. translators: 1: WP version 81 | msgid "The WP version must be %1$s or above." 82 | msgstr "" 83 | 84 | #: includes/class-main.php:119 85 | msgid "Normal cron execution is blocked when the Cron Control plugin is active." 86 | msgstr "" 87 | 88 | #: includes/class-main.php:155 89 | #. translators: 1: Constant name 90 | msgid "%1$s set to unexpected value; must be corrected for proper behaviour." 91 | msgstr "" 92 | 93 | #: includes/class-rest-api.php:98 94 | msgid "Automatic event execution is disabled indefinitely." 95 | msgstr "" 96 | 97 | #: includes/class-rest-api.php:101 98 | #. translators: 1: Time automatic execution is disabled until, 2: Unix 99 | #. timestamp 100 | msgid "Automatic event execution is disabled until %1$s UTC (%2$d)." 101 | msgstr "" 102 | 103 | #: includes/class-rest-api.php:137 104 | msgid "Cron Control REST API endpoints are disabled" 105 | msgstr "" 106 | 107 | #: includes/class-rest-api.php:150 108 | msgid "Secret must be specified with all requests" 109 | msgstr "" 110 | 111 | #: includes/wp-cli/class-events.php:40 112 | msgid "Invalid page requested" 113 | msgstr "" 114 | 115 | #: includes/wp-cli/class-events.php:49 116 | msgid "" 117 | "Entries are purged automatically, so this cannot be relied upon as a record " 118 | "of past event execution." 119 | msgstr "" 120 | 121 | #: includes/wp-cli/class-events.php:54 122 | msgid "No events to display" 123 | msgstr "" 124 | 125 | #: includes/wp-cli/class-events.php:65 126 | #. translators: 1: Number of events to display 127 | msgid "Displaying %s entry" 128 | msgid_plural "Displaying all %s entries" 129 | msgstr[0] "" 130 | msgstr[1] "" 131 | 132 | #: includes/wp-cli/class-events.php:68 133 | #. translators: 1: Entries on this page, 2: Total entries, 3: Current page, 4: 134 | #. Total pages 135 | msgid "Displaying %1$s of %2$s entries, page %3$s of %4$s" 136 | msgstr "" 137 | 138 | #: includes/wp-cli/class-events.php:122 139 | msgid "" 140 | "Specify something to delete, or see the `cron-control-fixers` command to " 141 | "remove all data." 142 | msgstr "" 143 | 144 | #: includes/wp-cli/class-events.php:136 145 | msgid "Specify the ID of an event to run" 146 | msgstr "" 147 | 148 | #: includes/wp-cli/class-events.php:144 149 | #. translators: 1: Event ID 150 | msgid "" 151 | "Failed to locate event %d. Please confirm that the entry exists and that " 152 | "the ID is that of an event." 153 | msgstr "" 154 | 155 | #: includes/wp-cli/class-events.php:148 156 | #. translators: 1: Event ID, 2: Event action, 3. Event instance 157 | msgid "Found event %1$d with action `%2$s` and instance identifier `%3$s`" 158 | msgstr "" 159 | 160 | #: includes/wp-cli/class-events.php:154 161 | #. translators: 1: Time in UTC, 2: Human time diff 162 | msgid "This event is not scheduled to run until %1$s UTC (%2$s)" 163 | msgstr "" 164 | 165 | #: includes/wp-cli/class-events.php:157 166 | msgid "Run this event?" 167 | msgstr "" 168 | 169 | #: includes/wp-cli/class-events.php:171 170 | msgid "Failed to run event" 171 | msgstr "" 172 | 173 | #: includes/wp-cli/class-events.php:206 174 | msgid "Invalid status specified" 175 | msgstr "" 176 | 177 | #: includes/wp-cli/class-events.php:258 178 | msgid "Non-repeating" 179 | msgstr "" 180 | 181 | #: includes/wp-cli/class-events.php:260 includes/wp-cli/class-rest-api.php:104 182 | msgid "n/a" 183 | msgstr "" 184 | 185 | #: includes/wp-cli/class-events.php:268 includes/wp-cli/class-rest-api.php:103 186 | msgid "true" 187 | msgstr "" 188 | 189 | #: includes/wp-cli/class-events.php:343 190 | msgid "%s year" 191 | msgid_plural "%s years" 192 | msgstr[0] "" 193 | msgstr[1] "" 194 | 195 | #: includes/wp-cli/class-events.php:344 196 | msgid "%s month" 197 | msgid_plural "%s months" 198 | msgstr[0] "" 199 | msgstr[1] "" 200 | 201 | #: includes/wp-cli/class-events.php:345 202 | msgid "%s week" 203 | msgid_plural "%s weeks" 204 | msgstr[0] "" 205 | msgstr[1] "" 206 | 207 | #: includes/wp-cli/class-events.php:346 208 | msgid "%s day" 209 | msgid_plural "%s days" 210 | msgstr[0] "" 211 | msgstr[1] "" 212 | 213 | #: includes/wp-cli/class-events.php:347 214 | msgid "%s hour" 215 | msgid_plural "%s hours" 216 | msgstr[0] "" 217 | msgstr[1] "" 218 | 219 | #: includes/wp-cli/class-events.php:348 220 | msgid "%s minute" 221 | msgid_plural "%s minutes" 222 | msgstr[0] "" 223 | msgstr[1] "" 224 | 225 | #: includes/wp-cli/class-events.php:349 226 | msgid "%s second" 227 | msgid_plural "%s seconds" 228 | msgstr[0] "" 229 | msgstr[1] "" 230 | 231 | #: includes/wp-cli/class-events.php:397 232 | msgid "Invalid event ID" 233 | msgstr "" 234 | 235 | #: includes/wp-cli/class-events.php:405 236 | #. translators: 1: Event ID 237 | msgid "" 238 | "Failed to delete event %d. Please confirm that the entry exists and that " 239 | "the ID is that of an event." 240 | msgstr "" 241 | 242 | #: includes/wp-cli/class-events.php:410 includes/wp-cli/class-events.php:445 243 | msgid "" 244 | "This is an event created by the Cron Control plugin. It will recreated " 245 | "automatically." 246 | msgstr "" 247 | 248 | #: includes/wp-cli/class-events.php:414 249 | #. translators: 1: Event execution time in UTC 250 | msgid "Execution time: %s UTC" 251 | msgstr "" 252 | 253 | #: includes/wp-cli/class-events.php:416 254 | #. translators: 1: Event action 255 | msgid "Action: %s" 256 | msgstr "" 257 | 258 | #: includes/wp-cli/class-events.php:418 259 | #. translators: 1: Event instance 260 | msgid "Instance identifier: %s" 261 | msgstr "" 262 | 263 | #: includes/wp-cli/class-events.php:420 264 | msgid "Are you sure you want to delete this event?" 265 | msgstr "" 266 | 267 | #: includes/wp-cli/class-events.php:427 268 | #. translators: 1: Event ID 269 | msgid "Failed to delete event %d" 270 | msgstr "" 271 | 272 | #: includes/wp-cli/class-events.php:431 273 | #. translators: 1: Event ID 274 | msgid "Removed event %d" 275 | msgstr "" 276 | 277 | #: includes/wp-cli/class-events.php:440 278 | #: includes/wp-cli/class-orchestrate-runner.php:85 279 | msgid "Invalid action" 280 | msgstr "" 281 | 282 | #: includes/wp-cli/class-events.php:453 283 | #. translators: 1: Event action 284 | msgid "No events with action `%s` found" 285 | msgstr "" 286 | 287 | #: includes/wp-cli/class-events.php:457 288 | #. translators: 1: Total event count 289 | msgid "Found %s event(s) to delete" 290 | msgstr "" 291 | 292 | #: includes/wp-cli/class-events.php:458 293 | msgid "Are you sure you want to delete the event(s)?" 294 | msgstr "" 295 | 296 | #: includes/wp-cli/class-events.php:460 297 | msgid "Deleting event(s)" 298 | msgstr "" 299 | 300 | #: includes/wp-cli/class-events.php:477 301 | #. translators: 1: Expected deleted-event count, 2: Actual deleted-event count 302 | msgid "Expected to delete %1$s events, but could only delete %2$s events." 303 | msgstr "" 304 | 305 | #: includes/wp-cli/class-events.php:482 306 | #. translators: 1: Total event count 307 | msgid "Deleted %s event(s)" 308 | msgstr "" 309 | 310 | #: includes/wp-cli/class-events.php:495 311 | #. translators: 1: Event count 312 | msgid "Found %s completed event to remove. Continue?" 313 | msgid_plural "Found %s completed events to remove. Continue?" 314 | msgstr[0] "" 315 | msgstr[1] "" 316 | 317 | #: includes/wp-cli/class-events.php:499 318 | msgid "Entries removed" 319 | msgstr "" 320 | 321 | #: includes/wp-cli/class-lock.php:25 322 | msgid "This lock limits the number of events run concurrently." 323 | msgstr "" 324 | 325 | #: includes/wp-cli/class-lock.php:40 326 | msgid "Specify an action" 327 | msgstr "" 328 | 329 | #: includes/wp-cli/class-lock.php:50 330 | msgid "" 331 | "This lock prevents concurrent executions of events with the same action, " 332 | "regardless of the action's arguments." 333 | msgstr "" 334 | 335 | #: includes/wp-cli/class-lock.php:69 336 | #. translators: 1: Lock limit 337 | msgid "Maximum: %s" 338 | msgstr "" 339 | 340 | #: includes/wp-cli/class-lock.php:73 341 | msgid "Resetting lock..." 342 | msgstr "" 343 | 344 | #: includes/wp-cli/class-lock.php:79 345 | #. translators: 1: Previous lock value 346 | msgid "Previous value: %s" 347 | msgstr "" 348 | 349 | #: includes/wp-cli/class-lock.php:81 350 | #. translators: 1: Previous lock timestamp 351 | msgid "Previously modified: %s UTC" 352 | msgstr "" 353 | 354 | #: includes/wp-cli/class-lock.php:83 355 | msgid "Are you sure you want to reset this lock?" 356 | msgstr "" 357 | 358 | #: includes/wp-cli/class-lock.php:87 359 | msgid "Lock reset" 360 | msgstr "" 361 | 362 | #: includes/wp-cli/class-lock.php:88 363 | msgid "New lock values:" 364 | msgstr "" 365 | 366 | #: includes/wp-cli/class-lock.php:96 367 | #. translators: 1: Current lock value 368 | msgid "Current value: %s" 369 | msgstr "" 370 | 371 | #: includes/wp-cli/class-lock.php:98 372 | #. translators: 1: Current lock timestamp 373 | msgid "Last modified: %s UTC" 374 | msgstr "" 375 | 376 | #: includes/wp-cli/class-orchestrate-runner.php:30 377 | #: includes/wp-cli/class-orchestrate-runner.php:73 378 | msgid "Automatic event execution is disabled" 379 | msgstr "" 380 | 381 | #: includes/wp-cli/class-orchestrate-runner.php:81 382 | msgid "Invalid timestamp" 383 | msgstr "" 384 | 385 | #: includes/wp-cli/class-orchestrate-runner.php:89 386 | msgid "Invalid instance" 387 | msgstr "" 388 | 389 | #: includes/wp-cli/class-orchestrate-runner.php:95 390 | #. translators: 1: Event execution time in UTC, 2: Human time diff 391 | msgid "" 392 | "Given timestamp is for %1$s UTC, %2$s from now. The event's existence was " 393 | "not confirmed, and no attempt was made to execute it." 394 | msgstr "" 395 | 396 | #: includes/wp-cli/class-orchestrate.php:26 397 | msgid "Automatic execution is enabled" 398 | msgstr "" 399 | 400 | #: includes/wp-cli/class-orchestrate.php:30 401 | msgid "Automatic execution is disabled indefinitely" 402 | msgstr "" 403 | 404 | #: includes/wp-cli/class-orchestrate.php:35 405 | #. translators: 1: Human time diff, 2: Time execution is disabled until 406 | msgid "Automatic execution is disabled for %1$s (until %2$s UTC)" 407 | msgstr "" 408 | 409 | #: includes/wp-cli/class-orchestrate.php:61 410 | msgid "Enabled" 411 | msgstr "" 412 | 413 | #: includes/wp-cli/class-orchestrate.php:65 414 | msgid "Could not enable automatic execution. Please check the current status." 415 | msgstr "" 416 | 417 | #: includes/wp-cli/class-orchestrate.php:70 418 | msgid "Disabled" 419 | msgstr "" 420 | 421 | #: includes/wp-cli/class-orchestrate.php:74 422 | #: includes/wp-cli/class-orchestrate.php:85 423 | msgid "Could not disable automatic execution. Please check the current status." 424 | msgstr "" 425 | 426 | #: includes/wp-cli/class-orchestrate.php:81 427 | #. translators: 1: Human time diff, 2: Time execution is disabled until 428 | msgid "Disabled for %1$s (until %2$s UTC)" 429 | msgstr "" 430 | 431 | #: includes/wp-cli/class-orchestrate.php:87 432 | msgid "Timestamp is in the past." 433 | msgstr "" 434 | 435 | #: includes/wp-cli/class-orchestrate.php:91 436 | msgid "Please provide a valid action." 437 | msgstr "" 438 | 439 | #: includes/wp-cli/class-rest-api.php:47 440 | msgid "No events in the current queue" 441 | msgstr "" 442 | 443 | #: includes/wp-cli/class-rest-api.php:55 444 | #. translators: 1: Event count 445 | msgid "Displaying %s event" 446 | msgid_plural "Displaying %s events" 447 | msgstr[0] "" 448 | msgstr[1] "" 449 | 450 | #: includes/wp-cli/class-rest-api.php:61 451 | msgid "Invalid output format requested" 452 | msgstr "" 453 | 454 | #: includes/wp-cli.php:29 455 | msgid "Cron Control installation completed. Please try again." 456 | msgstr "" 457 | 458 | #. Plugin Name of the plugin/theme 459 | msgid "Cron Control" 460 | msgstr "" 461 | 462 | #. Plugin URI of the plugin/theme 463 | msgid "https://vip.wordpress.com/" 464 | msgstr "" 465 | 466 | #. Description of the plugin/theme 467 | msgid "" 468 | "Execute WordPress cron events in parallel, using a custom post type for " 469 | "event storage." 470 | msgstr "" 471 | 472 | #. Author of the plugin/theme 473 | msgid "Erick Hitter, Automattic" 474 | msgstr "" -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cron-control", 3 | "version": "2.0.0", 4 | "main": "Gruntfile.js", 5 | "author": "Automatic", 6 | "private": true, 7 | "scripts": { 8 | "build": "grunt" 9 | }, 10 | "dependencies": {}, 11 | "devDependencies": { 12 | "grunt": "^1.5.3", 13 | "grunt-cli": "^1.4.3", 14 | "grunt-wp-i18n": "^1.0.3", 15 | "grunt-wp-readme-to-markdown": "^2.1.0" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Generally-applicable sniffs for WordPress plugins 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 | */node_modules/* 48 | */vendor/* 49 | 50 | -------------------------------------------------------------------------------- /phpunit-multisite.xml: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | 14 | ./__tests__/unit-tests/ 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | ./__tests__/unit-tests/ 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /readme.txt: -------------------------------------------------------------------------------- 1 | === Cron Control === 2 | Contributors: automattic, ethitter 3 | Tags: cron, cron control, concurrency, parallel, async 4 | Requires at least: 5.1 5 | Tested up to: 5.8 6 | Requires PHP: 7.4 7 | Stable tag: 3.1 8 | License: GPLv2 or later 9 | License URI: http://www.gnu.org/licenses/gpl-2.0.html 10 | 11 | Execute WordPress cron events in parallel, with custom event storage for high-volume cron. 12 | 13 | == Description == 14 | 15 | This plugin sets up a custom cron table for better events storage. Using WP hooks, it then intercepts cron registration/retrieval/deletions. There are two additional interaction layers exposed by the plugin - WP-CLI and the REST API. 16 | 17 | By default the plugin disables default WP cron processing. It is recommended to use the cron control runner to process cron: https://github.com/Automattic/cron-control-runner. This is how we are able to process cron events in parallel, allowing for high-volume and reliable cron. 18 | 19 | == Installation == 20 | 21 | 1. Define `WP_CRON_CONTROL_SECRET` in `wp-config.php`, set to `false` to disable the REST API interface. 22 | 1. Upload the `cron-control` directory to the `/wp-content/mu-plugins/` directory 23 | 1. Create a file at `/wp-content/mu-plugins/cron-control.php` to load `/wp-content/mu-plugins/cron-control/cron-control.php` 24 | 1. (optional) Set up the the cron control runner for event processing. 25 | 26 | == Frequently Asked Questions == 27 | 28 | = Deviations from WordPress Core = 29 | 30 | * Cron jobs are stored in a custom table and not in the `cron` option in wp_options. As long relevant code uses WP core functions for retrieving events and not direct SQL, all will stay compatible. 31 | * Duplicate recurring events with the same action/args/schedule are prevented. If multiple of the same action is needed on the same schedule, can add an arbitrary number to the args array. 32 | * When the cron control runner is running events, it does so via WP-CLI. So the environment can be slightly different than that of a normal web request. 33 | * The cron control runner can process multiple events in parallel, whereas core cron only did 1 at a time. By default, events with the same action will not run in parallel unless specifically granted permission to do so. 34 | 35 | = Adding Internal Events = 36 | 37 | **This should be done sparingly as "Internal Events" bypass certain locks and limits built into the plugin.** Overuse will lead to unexpected resource usage, and likely resource exhaustion. 38 | 39 | In `wp-config.php` or a similarly-early and appropriate place, define `CRON_CONTROL_ADDITIONAL_INTERNAL_EVENTS` as an array of arrays like: 40 | 41 | ``` 42 | define( 'CRON_CONTROL_ADDITIONAL_INTERNAL_EVENTS', array( 43 | array( 44 | 'schedule' => 'hourly', 45 | 'action' => 'do_a_thing', 46 | 'callback' => '__return_true', 47 | ), 48 | ) ); 49 | ``` 50 | 51 | Due to the early loading (to limit additions), the `action` and `callback` generally can't directly reference any Core, plugin, or theme code. Since WordPress uses actions to trigger cron, class methods can be referenced, so long as the class name is not dynamically referenced. For example: 52 | 53 | ``` 54 | define( 'CRON_CONTROL_ADDITIONAL_INTERNAL_EVENTS', array( 55 | array( 56 | 'schedule' => 'hourly', 57 | 'action' => 'do_a_thing', 58 | 'callback' => array( 'Some_Class', 'some_method' ), 59 | ), 60 | ) ); 61 | ``` 62 | 63 | Take care to reference the full namespace when appropriate. 64 | 65 | = Increasing Event Concurrency = 66 | 67 | In some circumstances, multiple events with the same action can safely run in parallel. This is usually not the case, largely due to Core's alloptions, but sometimes an event is written in a way that we can support concurrent executions. 68 | 69 | To allow concurrency for your event, and to specify the level of concurrency, please hook the `a8c_cron_control_concurrent_event_whitelist` filter as in the following example: 70 | 71 | ``` 72 | add_filter( 'a8c_cron_control_concurrent_event_whitelist', function( $wh ) { 73 | $wh['my_custom_event'] = 2; 74 | 75 | return $wh; 76 | } ); 77 | ``` 78 | 79 | == Development & Testing == 80 | 81 | = Quick and easy testing = 82 | 83 | If you have docker installed, can just run `./__tests__/bin/test.sh`. 84 | 85 | = Manual testing setup = 86 | 87 | First, you'll need svn and composer. Example of installing them on a docker container if needed: 88 | 89 | ``` 90 | apk add subversion 91 | wget -q https://getcomposer.org/installer -O - | php -- --install-dir=/usr/bin/ --filename=composer 92 | ``` 93 | 94 | Next change directories to the plugin and set up the test environment: 95 | 96 | ``` 97 | cd wp-content/mu-plugins/cron-control 98 | 99 | composer install 100 | 101 | # Note that the values below can be different, it is: [db-host] [wp-version] 102 | ./__tests__/bin/install-wp-tests.sh test wordpress wordpress database latest 103 | ``` 104 | 105 | Lastly, kick things off with one command: `phpunit` 106 | 107 | = Readme & language file updates = 108 | 109 | Will need `npm`. Example of installing on a docker container: `apk add --update npm` 110 | 111 | Run `npm install` then `npm run build` to create/update language files and to convert `readme.txt` to `readme.md` if needed. 112 | 113 | == Changelog == 114 | 115 | = 3.1 = 116 | * Update installation process, always ensuring the custom table is installed. 117 | * Swap out deprecated `wpmu_new_blog` hook. 118 | * Ignore archived/deleted/spam subsites during the runner's `list sites` cli command. 119 | * Migrate legacy events from the `cron` option to the new table before deleting the option. 120 | * Delete duplicate recurring events. Runs daily. 121 | 122 | = 3.0 = 123 | * Implement WP cron filters that were added in WP 5.1. 124 | * Cleanup the event's store & introduce new Event() object. 125 | * Switch to a more efficient caching strategy. 126 | 127 | = 2.0 = 128 | * Support additional Internal Events 129 | * Break large cron queues into several caches 130 | * Introduce Golang runner to execute cron 131 | * Support concurrency for whitelisted events 132 | 133 | = 1.5 = 134 | * Convert from custom post type to custom table with proper indices 135 | 136 | = 1.0 = 137 | * Initial release 138 | --------------------------------------------------------------------------------