├── .nvmrc
├── .gitignore
├── .github
├── CODEOWNERS
├── dependabot.yml
├── workflows
│ ├── dependency-review.yml
│ ├── ci-grunt.yaml
│ ├── phpcs.yaml
│ ├── codeql.yml
│ └── ci-php.yaml
└── checkstyle-problem-matcher.json
├── package.json
├── phpunit.xml
├── includes
├── wp-cli
│ ├── class-main.php
│ ├── class-orchestrate.php
│ ├── class-rest-api.php
│ ├── class-lock.php
│ ├── class-orchestrate-sites.php
│ └── class-orchestrate-runner.php
├── class-singleton.php
├── wp-cli.php
├── constants.php
├── functions.php
├── class-lock.php
├── utils.php
├── class-rest-api.php
├── class-main.php
├── wp-adapter.php
├── class-internal-events.php
├── class-event.php
└── class-events.php
├── phpunit-multisite.xml
├── cron-control.php
├── composer.json
├── Gruntfile.js
├── __tests__
├── bootstrap.php
├── unit-tests
│ ├── test-cli-orchestrate-sites.php
│ ├── test-rest-api.php
│ ├── test-events.php
│ ├── test-events-store.php
│ ├── test-internal-events.php
│ ├── test-event.php
│ └── test-wp-adapter.php
├── bin
│ ├── test.sh
│ └── install-wp-tests.sh
└── utils.php
├── phpcs.xml
├── readme.txt
├── README.md
└── languages
└── cron-control.pot
/.nvmrc:
--------------------------------------------------------------------------------
1 | 18.13
2 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners
2 | * @Automattic/vip-platform-cantina
3 |
--------------------------------------------------------------------------------
/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.6.1",
13 | "grunt-cli": "^1.5.0",
14 | "grunt-wp-i18n": "^1.0.4",
15 | "grunt-wp-readme-to-markdown": "^2.1.0"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/phpunit.xml:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 | ./__tests__/unit-tests/
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/includes/wp-cli/class-main.php:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
12 |
13 |
14 | ./__tests__/unit-tests/
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/.github/workflows/dependency-review.yml:
--------------------------------------------------------------------------------
1 | name: Dependency Review
2 |
3 | on:
4 | pull_request:
5 |
6 | permissions:
7 | contents: read
8 |
9 | jobs:
10 | dependency-review:
11 | runs-on: ubuntu-latest
12 | name: Review Dependencies
13 | permissions:
14 | contents: read
15 | pull-requests: write
16 | steps:
17 | - name: Check out the source code
18 | uses: actions/checkout@v4.2.2
19 |
20 | - name: Review dependencies
21 | uses: actions/dependency-review-action@v4.7.1
22 | with:
23 | comment-summary-in-pr: true
24 | show-openssf-scorecard: true
25 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/cron-control.php:
--------------------------------------------------------------------------------
1 | =7.4.0"
7 | },
8 | "require-dev": {
9 | "dealerdirect/phpcodesniffer-composer-installer": "*",
10 | "johnpbloch/wordpress-core": "^6.1.1",
11 | "phpcompatibility/phpcompatibility-wp": "^2.1.4",
12 | "wp-cli/wp-cli": "*",
13 | "wp-coding-standards/wpcs": "^3.1",
14 | "wp-phpunit/wp-phpunit": "^6.1.1",
15 | "yoast/phpunit-polyfills": "^4.0.0"
16 | },
17 | "config": {
18 | "sort-packages": true,
19 | "platform": {
20 | "php": "7.4"
21 | },
22 | "allow-plugins": {
23 | "dealerdirect/phpcodesniffer-composer-installer": true
24 | }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/.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 | permissions:
12 | contents: read
13 |
14 | jobs:
15 | build:
16 | name: Run Grunt tasks
17 | runs-on: ubuntu-latest
18 | permissions:
19 | contents: read
20 | steps:
21 | - name: Check out source code
22 | uses: actions/checkout@v4.2.2
23 |
24 | - name: Set up Node.js environment
25 | uses: actions/setup-node@v4.4.0
26 | with:
27 | node-version: lts/*
28 | cache: npm
29 |
30 | - name: Install dependencies
31 | run: npm ci --ignore-scripts
32 |
33 | - name: Run postinstall scripts
34 | run: npm rebuild && npm run prepare --if-present
35 |
36 | - name: Run build tasks
37 | run: npm run build
38 |
--------------------------------------------------------------------------------
/.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 | permissions:
12 | contents: read
13 |
14 | jobs:
15 | codestyle:
16 | name: Run code style check
17 | runs-on: ubuntu-latest
18 | permissions:
19 | contents: read
20 | steps:
21 | - name: Check out source code
22 | uses: actions/checkout@v4.2.2
23 |
24 | - name: Set up PHP
25 | uses: shivammathur/setup-php@v2
26 | with:
27 | php-version: 8.3
28 |
29 | - name: Install PHP Dependencies
30 | uses: ramsey/composer-install@3.1.1
31 |
32 | - name: Add error matcher
33 | run: echo "::add-matcher::$(pwd)/.github/checkstyle-problem-matcher.json"
34 |
35 | - name: Run style check
36 | run: vendor/bin/phpcs --report=checkstyle
37 |
--------------------------------------------------------------------------------
/.github/workflows/codeql.yml:
--------------------------------------------------------------------------------
1 | name: CodeQL Analysis
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 | branches:
9 | - main
10 | schedule:
11 | - cron: '1 1 * * 3'
12 |
13 | permissions:
14 | contents: read
15 |
16 | jobs:
17 | analyze:
18 | name: Static Code Analysis with CodeQL
19 | runs-on: ubuntu-latest
20 | permissions:
21 | actions: read
22 | contents: read
23 | security-events: write
24 | strategy:
25 | fail-fast: false
26 | matrix:
27 | language:
28 | - actions
29 | steps:
30 | - name: Checkout repository
31 | uses: actions/checkout@v4.2.2
32 |
33 | - name: Initialize CodeQL
34 | uses: github/codeql-action/init@v3.29.4
35 | with:
36 | languages: ${{ matrix.language }}
37 |
38 | - name: Perform CodeQL Analysis
39 | uses: github/codeql-action/analyze@v3.29.4
40 | with:
41 | category: "/language:${{matrix.language}}"
42 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 | permissions:
12 | contents: read
13 |
14 | jobs:
15 | test:
16 | name: Run tests
17 | runs-on: ubuntu-latest
18 | permissions:
19 | contents: read
20 | strategy:
21 | fail-fast: false
22 | matrix:
23 | php:
24 | - "8.0"
25 | - "8.1"
26 | - "8.2"
27 | - "8.3"
28 | - "8.4"
29 | wpmu:
30 | - "0"
31 | - "1"
32 | wordpress:
33 | - latest
34 | - trunk
35 | services:
36 | mysql:
37 | image: mariadb:latest
38 | env:
39 | MYSQL_ROOT_PASSWORD: root
40 | ports:
41 | - 3306
42 | options: --health-cmd="healthcheck.sh --innodb_initialized" --health-interval=5s --health-timeout=2s --health-retries=3
43 | steps:
44 | - name: Install svn
45 | run: sudo apt-get update && sudo apt-get install -y subversion
46 |
47 | - name: Check out source code
48 | uses: actions/checkout@v4.2.2
49 |
50 | - name: Set up PHP
51 | uses: shivammathur/setup-php@v2
52 | with:
53 | php-version: ${{ matrix.php }}
54 |
55 | - name: Install PHP Dependencies
56 | uses: ramsey/composer-install@3.1.1
57 |
58 | - name: Verify MariaDB connection
59 | run: |
60 | while ! mysqladmin ping -h 127.0.0.1 -P ${{ job.services.mysql.ports[3306] }} --silent; do
61 | sleep 1
62 | done
63 | timeout-minutes: 3
64 |
65 | - name: Install WP Test Suite
66 | run: ./__tests__/bin/install-wp-tests.sh wordpress_test root root 127.0.0.1:${{ job.services.mysql.ports[3306] }} ${{ matrix.wordpress }}
67 |
68 | - name: Run tests
69 | run: vendor/bin/phpunit
70 | env:
71 | WP_MULTISITE: ${{ matrix.wpmu }}
72 |
--------------------------------------------------------------------------------
/__tests__/bootstrap.php:
--------------------------------------------------------------------------------
1 | 'hourly',
29 | 'action' => 'cron_control_additional_internal_event',
30 | 'callback' => '__return_true',
31 | ),
32 | )
33 | );
34 |
35 | require_once dirname( __DIR__ ) . '/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_once $_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 | require_once WP_CLI_ROOT . '/php/utils.php';
57 | require_once WP_CLI_ROOT . '/php/dispatcher.php';
58 | require_once WP_CLI_ROOT . '/php/class-wp-cli.php';
59 | require_once 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_once dirname( __DIR__ ) . '/includes/wp-cli.php';
65 | Cron_Control\CLI\prepare_environment();
66 |
--------------------------------------------------------------------------------
/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 | */node_modules/*
45 | */vendor/*
46 |
47 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/__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 | public function test_list_sites_2_hosts() {
18 | add_filter( 'sites_pre_query', [ $this, 'mock_get_sites' ], 10, 2 );
19 |
20 | // With two hosts, all active sites should still be returned.
21 | $this->mock_hosts_list( 2 );
22 | $expected = wp_json_encode( [ [ 'url' => 'site1.com' ], [ 'url' => 'site2.com/two' ], [ 'url' => 'site3.com/three' ], [ 'url' => 'site7.com/seven' ] ] );
23 | $this->expectOutputString( $expected );
24 | ( new CLI\Orchestrate_Sites() )->list();
25 |
26 | remove_filter( 'sites_pre_query', [ $this, 'mock_get_sites' ], 10, 2 );
27 | }
28 |
29 | public function test_list_sites_7_hosts() {
30 | add_filter( 'sites_pre_query', [ $this, 'mock_get_sites' ], 10, 2 );
31 |
32 | // With seven hosts, our current request should only be given two of the active sites.
33 | $this->mock_hosts_list( 7 );
34 | $expected = wp_json_encode( [ [ 'url' => 'site1.com' ], [ 'url' => 'site7.com/seven' ] ] );
35 | $this->expectOutputString( $expected );
36 | ( new CLI\Orchestrate_Sites() )->list();
37 |
38 | remove_filter( 'sites_pre_query', [ $this, 'mock_get_sites' ], 10, 2 );
39 | }
40 |
41 | public function mock_hosts_list( $number_of_hosts ) {
42 | // Always have the "current" host.
43 | $heartbeats = [ gethostname() => time() ];
44 |
45 | if ( $number_of_hosts > 1 ) {
46 | for ( $i = 1; $i < $number_of_hosts; $i++ ) {
47 | $heartbeats[ "test_$i" ] = time();
48 | }
49 | }
50 |
51 | wp_cache_set( CLI\Orchestrate_Sites::RUNNER_HOST_HEARTBEAT_KEY, $heartbeats );
52 | }
53 |
54 | public function mock_get_sites( $site_data, $query_class ) {
55 | if ( $query_class->query_vars['count'] ) {
56 | return 4;
57 | }
58 |
59 | return [
60 | new WP_Site( (object) [ 'domain' => 'site1.com', 'path' => '/' ] ),
61 | new WP_Site( (object) [ 'domain' => 'site2.com', 'path' => '/two' ] ),
62 | new WP_Site( (object) [ 'domain' => 'site3.com', 'path' => '/three' ] ),
63 | new WP_Site( (object) [ 'domain' => 'site7.com', 'path' => '/seven' ] ),
64 | ];
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/__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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/__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 |
--------------------------------------------------------------------------------
/__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 |
--------------------------------------------------------------------------------
/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/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 | return compact( 'requested_path', 'requested_file', 'self' );
85 | }
86 |
87 | /**
88 | * Consistently set flag Core uses to indicate cron execution is ongoing
89 | */
90 | function set_doing_cron() {
91 | if ( ! defined( 'DOING_CRON' ) ) {
92 | define( 'DOING_CRON', true );
93 | }
94 |
95 | // WP 4.8 introduced the `wp_doing_cron()` function and filter.
96 | // These can be used to override the `DOING_CRON` constant, which may cause problems for plugin's requests.
97 | add_filter( 'wp_doing_cron', '__return_true', 99999 );
98 | }
99 |
100 | // Helper method for deprecating publicly accessibly functions/methods.
101 | function _deprecated_function( string $func, string $replacement = '', $error_level = 2 ) {
102 | $error_levels = [
103 | 'debug' => 1,
104 | 'notice' => 2,
105 | 'warn' => 3,
106 | ];
107 |
108 | $message = sprintf( 'Cron-Control: Deprecation. %s is deprecated and will soon be removed.', $func );
109 | if ( ! empty( $replacement ) ) {
110 | $message .= sprintf( ' Use %s instead.', $replacement );
111 | }
112 |
113 | // Use E_WARNING error level.
114 | $warning_constant = defined( 'CRON_CONTROL_WARN_FOR_DEPRECATIONS' ) && CRON_CONTROL_WARN_FOR_DEPRECATIONS;
115 | if ( $warning_constant || $error_level >= $error_levels['warn'] ) {
116 | trigger_error( $message, E_USER_WARNING );
117 | return;
118 | }
119 |
120 | // Use E_USER_NOTICE regardless of Debug mode.
121 | if ( $error_level >= $error_levels['notice'] ) {
122 | trigger_error( $message, E_USER_NOTICE );
123 | return;
124 | }
125 |
126 | // Use E_USER_NOTICE only in Debug mode.
127 | if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
128 | trigger_error( $message, E_USER_NOTICE );
129 | }
130 | }
131 |
--------------------------------------------------------------------------------
/includes/wp-cli/class-orchestrate-sites.php:
--------------------------------------------------------------------------------
1 | ]
23 | * : The polling interval used by the runner to retrieve events and sites
24 | *
25 | * @param array $args Array of positional arguments.
26 | * @param array $assoc_args Array of flags.
27 | */
28 | public function heartbeat( $args, $assoc_args ) {
29 | $assoc_args = wp_parse_args(
30 | $assoc_args,
31 | [
32 | 'heartbeat-interval' => 60,
33 | ]
34 | );
35 |
36 | $this->do_heartbeat( intval( $assoc_args['heartbeat-interval'] ) );
37 | }
38 |
39 | /**
40 | * List sites
41 | */
42 | public function list() {
43 | $hosts = $this->get_hosts();
44 |
45 | // Use 2 hosts per site.
46 | $num_groups = (int) ( count( $hosts ) / 2 );
47 | if ( $num_groups < 2 ) {
48 | // Every host runs every site.
49 | $this->display_sites();
50 | return;
51 | }
52 |
53 | $id = array_search( gethostname(), $hosts, true );
54 | $this->display_sites( $num_groups, $id % $num_groups );
55 | }
56 |
57 | /**
58 | * Display sites.
59 | *
60 | * @param int $num_groups Number of groups.
61 | * @param int $group Group number.
62 | */
63 | private function display_sites( $num_groups = 1, $group = 0 ) {
64 | $site_query = array(
65 | 'archived' => 0,
66 | 'spam' => 0,
67 | 'deleted' => 0,
68 | );
69 |
70 | $site_count = get_sites( array_merge( array( 'count' => 1 ), $site_query ) );
71 |
72 | if ( $site_count > self::MAX_SITES ) {
73 | trigger_error( sprintf( 'Cron-Control: This multisite has more than %u active subsites, currently unsupported.', self::MAX_SITES ), E_USER_WARNING );
74 | }
75 |
76 | // Keep the query simple, then process the results.
77 | $all_sites = get_sites( array_merge( array( 'number' => self::MAX_SITES ), $site_query ) );
78 | $sites_to_display = [];
79 | foreach ( $all_sites as $index => $site ) {
80 | if ( $index % $num_groups !== $group ) {
81 | // The site does not belong to this group.
82 | continue;
83 | }
84 |
85 | // We just need the url to display.
86 | $sites_to_display[] = [ 'url' => $this->get_raw_site_url( $site->path, $site->domain ) ];
87 | }
88 |
89 | \WP_CLI\Utils\format_items( 'json', $sites_to_display, 'url' );
90 | }
91 |
92 | /**
93 | * We can't use the home or siteurl since those don't always match with the `wp_blogs` entry.
94 | * And that can lead to "site not found" errors when passed via the `--url` WP-CLI param.
95 | * Instead, we construct the URL from data in the `wp_blogs` table.
96 | */
97 | private function get_raw_site_url( string $site_path, string $site_domain ): string {
98 | $path = ( $site_path && '/' !== $site_path ) ? $site_path : '';
99 | return $site_domain . $path;
100 | }
101 |
102 | /**
103 | * Updates the watchdog timer and removes stale hosts.
104 | *
105 | * @param int $heartbeat_interval Heartbeat interval.
106 | */
107 | private function do_heartbeat( $heartbeat_interval = 60 ) {
108 | if ( defined( 'WPCOM_SANDBOXED' ) && true === WPCOM_SANDBOXED ) {
109 | return;
110 | }
111 |
112 | $heartbeats = wp_cache_get( self::RUNNER_HOST_HEARTBEAT_KEY );
113 | if ( ! $heartbeats ) {
114 | $heartbeats = [];
115 | }
116 |
117 | // Remove stale hosts
118 | // If a host has missed 2 heartbeats, remove it from jobs processing.
119 | $heartbeats = array_filter(
120 | $heartbeats,
121 | function ( $timestamp ) use ( $heartbeat_interval ) {
122 | if ( time() - ( $heartbeat_interval * 2 ) > $timestamp ) {
123 | return false;
124 | }
125 |
126 | return true;
127 | }
128 | );
129 |
130 | $heartbeats[ gethostname() ] = time();
131 | wp_cache_set( self::RUNNER_HOST_HEARTBEAT_KEY, $heartbeats );
132 | }
133 |
134 | /**
135 | * Retrieves hosts and their last alive time from the cache.
136 | *
137 | * @return array Hosts.
138 | */
139 | private function get_hosts() {
140 | $heartbeats = wp_cache_get( self::RUNNER_HOST_HEARTBEAT_KEY );
141 | if ( ! $heartbeats ) {
142 | return [];
143 | }
144 |
145 | return array_keys( $heartbeats );
146 | }
147 | }
148 |
149 | \WP_CLI::add_command( 'cron-control orchestrate sites', 'Automattic\WP\Cron_Control\CLI\Orchestrate_Sites' );
150 |
--------------------------------------------------------------------------------
/__tests__/unit-tests/test-rest-api.php:
--------------------------------------------------------------------------------
1 | server = $wp_rest_server;
20 | do_action( 'rest_api_init' );
21 |
22 | Utils::clear_cron_table();
23 | }
24 |
25 | public function tearDown(): void {
26 | global $wp_rest_server;
27 | $wp_rest_server = null;
28 | $this->server = null;
29 |
30 | Utils::clear_cron_table();
31 | parent::tearDown();
32 | }
33 |
34 | /**
35 | * Verify that GET requests to the endpoint fail
36 | */
37 | public function test_invalid_request() {
38 | $request = new WP_REST_Request( 'GET', '/' . REST_API::API_NAMESPACE . '/' . REST_API::ENDPOINT_LIST );
39 | $response = $this->server->dispatch( $request );
40 | $this->assertResponseStatus( 404, $response );
41 | }
42 |
43 | /**
44 | * Test that list endpoint returns expected format
45 | */
46 | public function test_get_items() {
47 | $event = Utils::create_test_event();
48 |
49 | // Don't test internal events with this test.
50 | $internal_events = array(
51 | 'a8c_cron_control_force_publish_missed_schedules',
52 | 'a8c_cron_control_confirm_scheduled_posts',
53 | 'a8c_cron_control_clean_legacy_data',
54 | 'a8c_cron_control_purge_completed_events',
55 | );
56 | foreach ( $internal_events as $internal_event ) {
57 | wp_clear_scheduled_hook( $internal_event );
58 | }
59 |
60 | $request = new WP_REST_Request( 'POST', '/' . REST_API::API_NAMESPACE . '/' . REST_API::ENDPOINT_LIST );
61 | $request->set_body(
62 | wp_json_encode(
63 | array(
64 | 'secret' => WP_CRON_CONTROL_SECRET,
65 | )
66 | )
67 | );
68 | $request->set_header( 'content-type', 'application/json' );
69 |
70 | $response = $this->server->dispatch( $request );
71 | $data = $response->get_data();
72 |
73 | $this->assertResponseStatus( 200, $response );
74 | $this->assertArrayHasKey( 'events', $data );
75 | $this->assertArrayHasKey( 'endpoint', $data );
76 | $this->assertArrayHasKey( 'total_events_pending', $data );
77 |
78 | $this->assertResponseData(
79 | array(
80 | 'events' => array(
81 | array(
82 | 'timestamp' => $event->get_timestamp(),
83 | 'action' => md5( $event->get_action() ),
84 | 'instance' => $event->get_instance(),
85 | ),
86 | ),
87 | 'endpoint' => get_rest_url( null, REST_API::API_NAMESPACE . '/' . REST_API::ENDPOINT_RUN ),
88 | 'total_events_pending' => 1,
89 | ),
90 | $response
91 | );
92 | }
93 |
94 | /**
95 | * Test that list endpoint returns expected format
96 | */
97 | public function test_run_event() {
98 | $event = Utils::create_test_event();
99 |
100 | $expected_data = [
101 | 'action' => md5( $event->get_action() ),
102 | 'instance' => $event->get_instance(),
103 | 'timestamp' => $event->get_timestamp(),
104 | 'secret' => WP_CRON_CONTROL_SECRET,
105 | ];
106 |
107 | $request = new WP_REST_Request( 'PUT', '/' . REST_API::API_NAMESPACE . '/' . REST_API::ENDPOINT_RUN );
108 | $request->set_body( wp_json_encode( $expected_data ) );
109 | $request->set_header( 'content-type', 'application/json' );
110 |
111 | $response = $this->server->dispatch( $request );
112 | $data = $response->get_data();
113 |
114 | $this->assertResponseStatus( 200, $response );
115 | $this->assertArrayHasKey( 'success', $data );
116 | $this->assertArrayHasKey( 'message', $data );
117 | }
118 |
119 | /**
120 | * Check response code
121 | *
122 | * @param string $status Status code.
123 | * @param object $response REST API response object.
124 | */
125 | protected function assertResponseStatus( $status, $response ) {
126 | $this->assertEquals( $status, $response->get_status() );
127 | }
128 |
129 | /**
130 | * Ensure response includes the expected data
131 | *
132 | * @param array $data Expected data.
133 | * @param object $response REST API response object.
134 | */
135 | protected function assertResponseData( $data, $response ) {
136 | $this->assert_array_equals( $data, $response->get_data() );
137 | }
138 |
139 | private function assert_array_equals( $expected, $test ) {
140 | $tested_data = array();
141 |
142 | foreach ( $expected as $key => $value ) {
143 | if ( isset( $test[ $key ] ) ) {
144 | $tested_data[ $key ] = $test[ $key ];
145 | } else {
146 | $tested_data[ $key ] = null;
147 | }
148 | }
149 |
150 | $this->assertEquals( $expected, $tested_data );
151 | }
152 | }
153 |
--------------------------------------------------------------------------------
/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/wp-cli/class-orchestrate-runner.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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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__/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 | public 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 | public 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 | public 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-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 | public 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 | public 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 | public 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 | 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 | Utils::create_test_event( [ 'timestamp' => 2, 'action' => 'test_query_raw_events_orderby' ] );
212 | 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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/__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 | public 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 | $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 | public 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 | public 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-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 | public 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 | public 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->assertEqualsWithDelta( time() + HOUR_IN_SECONDS, $event->get_timestamp(), 1 );
74 | }
75 |
76 | public 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 | public 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 | public 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 | public 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 | public 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 | public 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 | public 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 |
--------------------------------------------------------------------------------
/languages/cron-control.pot:
--------------------------------------------------------------------------------
1 | # Copyright (C) 2025 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: 2025-07-19 22:39:04+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: 2025-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.4\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:397
190 | msgid "Invalid event ID"
191 | msgstr ""
192 |
193 | #: includes/wp-cli/class-events.php:405
194 | #. translators: 1: Event ID
195 | msgid ""
196 | "Failed to delete event %d. Please confirm that the entry exists and that "
197 | "the ID is that of an event."
198 | msgstr ""
199 |
200 | #: includes/wp-cli/class-events.php:410 includes/wp-cli/class-events.php:445
201 | msgid ""
202 | "This is an event created by the Cron Control plugin. It will recreated "
203 | "automatically."
204 | msgstr ""
205 |
206 | #: includes/wp-cli/class-events.php:414
207 | #. translators: 1: Event execution time in UTC
208 | msgid "Execution time: %s UTC"
209 | msgstr ""
210 |
211 | #: includes/wp-cli/class-events.php:416
212 | #. translators: 1: Event action
213 | msgid "Action: %s"
214 | msgstr ""
215 |
216 | #: includes/wp-cli/class-events.php:418
217 | #. translators: 1: Event instance
218 | msgid "Instance identifier: %s"
219 | msgstr ""
220 |
221 | #: includes/wp-cli/class-events.php:420
222 | msgid "Are you sure you want to delete this event?"
223 | msgstr ""
224 |
225 | #: includes/wp-cli/class-events.php:427
226 | #. translators: 1: Event ID
227 | msgid "Failed to delete event %d"
228 | msgstr ""
229 |
230 | #: includes/wp-cli/class-events.php:431
231 | #. translators: 1: Event ID
232 | msgid "Removed event %d"
233 | msgstr ""
234 |
235 | #: includes/wp-cli/class-events.php:440
236 | #: includes/wp-cli/class-orchestrate-runner.php:85
237 | msgid "Invalid action"
238 | msgstr ""
239 |
240 | #: includes/wp-cli/class-events.php:453
241 | #. translators: 1: Event action
242 | msgid "No events with action `%s` found"
243 | msgstr ""
244 |
245 | #: includes/wp-cli/class-events.php:457
246 | #. translators: 1: Total event count
247 | msgid "Found %s event(s) to delete"
248 | msgstr ""
249 |
250 | #: includes/wp-cli/class-events.php:458
251 | msgid "Are you sure you want to delete the event(s)?"
252 | msgstr ""
253 |
254 | #: includes/wp-cli/class-events.php:460
255 | msgid "Deleting event(s)"
256 | msgstr ""
257 |
258 | #: includes/wp-cli/class-events.php:477
259 | #. translators: 1: Expected deleted-event count, 2: Actual deleted-event count
260 | msgid "Expected to delete %1$s events, but could only delete %2$s events."
261 | msgstr ""
262 |
263 | #: includes/wp-cli/class-events.php:482
264 | #. translators: 1: Total event count
265 | msgid "Deleted %s event(s)"
266 | msgstr ""
267 |
268 | #: includes/wp-cli/class-events.php:495
269 | #. translators: 1: Event count
270 | msgid "Found %s completed event to remove. Continue?"
271 | msgid_plural "Found %s completed events to remove. Continue?"
272 | msgstr[0] ""
273 | msgstr[1] ""
274 |
275 | #: includes/wp-cli/class-events.php:499
276 | msgid "Entries removed"
277 | msgstr ""
278 |
279 | #: includes/wp-cli/class-lock.php:25
280 | msgid "This lock limits the number of events run concurrently."
281 | msgstr ""
282 |
283 | #: includes/wp-cli/class-lock.php:40
284 | msgid "Specify an action"
285 | msgstr ""
286 |
287 | #: includes/wp-cli/class-lock.php:50
288 | msgid ""
289 | "This lock prevents concurrent executions of events with the same action, "
290 | "regardless of the action's arguments."
291 | msgstr ""
292 |
293 | #: includes/wp-cli/class-lock.php:69
294 | #. translators: 1: Lock limit
295 | msgid "Maximum: %s"
296 | msgstr ""
297 |
298 | #: includes/wp-cli/class-lock.php:73
299 | msgid "Resetting lock..."
300 | msgstr ""
301 |
302 | #: includes/wp-cli/class-lock.php:79
303 | #. translators: 1: Previous lock value
304 | msgid "Previous value: %s"
305 | msgstr ""
306 |
307 | #: includes/wp-cli/class-lock.php:81
308 | #. translators: 1: Previous lock timestamp
309 | msgid "Previously modified: %s UTC"
310 | msgstr ""
311 |
312 | #: includes/wp-cli/class-lock.php:83
313 | msgid "Are you sure you want to reset this lock?"
314 | msgstr ""
315 |
316 | #: includes/wp-cli/class-lock.php:87
317 | msgid "Lock reset"
318 | msgstr ""
319 |
320 | #: includes/wp-cli/class-lock.php:88
321 | msgid "New lock values:"
322 | msgstr ""
323 |
324 | #: includes/wp-cli/class-lock.php:96
325 | #. translators: 1: Current lock value
326 | msgid "Current value: %s"
327 | msgstr ""
328 |
329 | #: includes/wp-cli/class-lock.php:98
330 | #. translators: 1: Current lock timestamp
331 | msgid "Last modified: %s UTC"
332 | msgstr ""
333 |
334 | #: includes/wp-cli/class-orchestrate-runner.php:30
335 | #: includes/wp-cli/class-orchestrate-runner.php:73
336 | msgid "Automatic event execution is disabled"
337 | msgstr ""
338 |
339 | #: includes/wp-cli/class-orchestrate-runner.php:81
340 | msgid "Invalid timestamp"
341 | msgstr ""
342 |
343 | #: includes/wp-cli/class-orchestrate-runner.php:89
344 | msgid "Invalid instance"
345 | msgstr ""
346 |
347 | #: includes/wp-cli/class-orchestrate-runner.php:95
348 | #. translators: 1: Event execution time in UTC, 2: Human time diff
349 | msgid ""
350 | "Given timestamp is for %1$s UTC, %2$s from now. The event's existence was "
351 | "not confirmed, and no attempt was made to execute it."
352 | msgstr ""
353 |
354 | #: includes/wp-cli/class-orchestrate.php:26
355 | msgid "Automatic execution is enabled"
356 | msgstr ""
357 |
358 | #: includes/wp-cli/class-orchestrate.php:30
359 | msgid "Automatic execution is disabled indefinitely"
360 | msgstr ""
361 |
362 | #: includes/wp-cli/class-orchestrate.php:35
363 | #. translators: 1: Human time diff, 2: Time execution is disabled until
364 | msgid "Automatic execution is disabled for %1$s (until %2$s UTC)"
365 | msgstr ""
366 |
367 | #: includes/wp-cli/class-orchestrate.php:61
368 | msgid "Enabled"
369 | msgstr ""
370 |
371 | #: includes/wp-cli/class-orchestrate.php:65
372 | msgid "Could not enable automatic execution. Please check the current status."
373 | msgstr ""
374 |
375 | #: includes/wp-cli/class-orchestrate.php:70
376 | msgid "Disabled"
377 | msgstr ""
378 |
379 | #: includes/wp-cli/class-orchestrate.php:74
380 | #: includes/wp-cli/class-orchestrate.php:85
381 | msgid "Could not disable automatic execution. Please check the current status."
382 | msgstr ""
383 |
384 | #: includes/wp-cli/class-orchestrate.php:81
385 | #. translators: 1: Human time diff, 2: Time execution is disabled until
386 | msgid "Disabled for %1$s (until %2$s UTC)"
387 | msgstr ""
388 |
389 | #: includes/wp-cli/class-orchestrate.php:87
390 | msgid "Timestamp is in the past."
391 | msgstr ""
392 |
393 | #: includes/wp-cli/class-orchestrate.php:91
394 | msgid "Please provide a valid action."
395 | msgstr ""
396 |
397 | #: includes/wp-cli/class-rest-api.php:47
398 | msgid "No events in the current queue"
399 | msgstr ""
400 |
401 | #: includes/wp-cli/class-rest-api.php:55
402 | #. translators: 1: Event count
403 | msgid "Displaying %s event"
404 | msgid_plural "Displaying %s events"
405 | msgstr[0] ""
406 | msgstr[1] ""
407 |
408 | #: includes/wp-cli/class-rest-api.php:61
409 | msgid "Invalid output format requested"
410 | msgstr ""
411 |
412 | #: includes/wp-cli.php:29
413 | msgid "Cron Control installation completed. Please try again."
414 | msgstr ""
415 |
416 | #. Plugin Name of the plugin/theme
417 | msgid "Cron Control"
418 | msgstr ""
419 |
420 | #. Plugin URI of the plugin/theme
421 | msgid "https://vip.wordpress.com/"
422 | msgstr ""
423 |
424 | #. Description of the plugin/theme
425 | msgid ""
426 | "Execute WordPress cron events in parallel, using a custom post type for "
427 | "event storage."
428 | msgstr ""
429 |
430 | #. Author of the plugin/theme
431 | msgid "Erick Hitter, Automattic"
432 | msgstr ""
--------------------------------------------------------------------------------
/includes/class-event.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 its 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 |
--------------------------------------------------------------------------------
/__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 | public 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 | public 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 | public 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 | public 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 | public 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 | public 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 |
--------------------------------------------------------------------------------
/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 int $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 | }
181 |
182 | ++$i;
183 | reset( $events );
184 | } while ( $i <= 15 && count( $reduced_queue ) < $max_queue_size && ! empty( $events ) );
185 |
186 | /**
187 | * IMPORTANT: DO NOT re-sort the $reduced_queue array from this point forward.
188 | * Doing so defeats the preceding effort.
189 | *
190 | * While the events are now out of order with respect to timestamp, they're ordered
191 | * such that one of each action is run before another of an already-run action.
192 | * The timestamp misordering is trivial given that we're only dealing with events
193 | * for the current $job_queue_window.
194 | */
195 |
196 | // Finally, ensure that we don't have more than we need.
197 | if ( count( $reduced_queue ) > $max_queue_size ) {
198 | $reduced_queue = array_slice( $reduced_queue, 0, $max_queue_size );
199 | }
200 |
201 | return $reduced_queue;
202 | }
203 |
204 | /**
205 | * Execute a specific event
206 | *
207 | * @param int $timestamp Unix timestamp.
208 | * @param string $action md5 hash of the action used when the event is registered.
209 | * @param string $instance md5 hash of the event's arguments array, which Core uses to index the `cron` option.
210 | * @param bool $force Run event regardless of timestamp or lock status? eg, when executing jobs via wp-cli.
211 | * @return array|WP_Error
212 | */
213 | public function run_event( $timestamp, $action, $instance, $force = false ) {
214 | // Validate input data.
215 | if ( empty( $timestamp ) || empty( $action ) || empty( $instance ) ) {
216 | return new WP_Error( 'missing-data', __( 'Invalid or incomplete request data.', 'automattic-cron-control' ), [ 'status' => 400 ] );
217 | }
218 |
219 | // Ensure we don't run jobs ahead of time.
220 | if ( ! $force && $timestamp > time() ) {
221 | /* translators: 1: Job identifier */
222 | $error_message = sprintf( __( 'Job with identifier `%1$s` is not scheduled to run yet.', 'automattic-cron-control' ), "$timestamp-$action-$instance" );
223 | return new WP_Error( 'premature', $error_message, [ 'status' => 404 ] );
224 | }
225 |
226 | $event = Event::find( [
227 | 'timestamp' => $timestamp,
228 | 'action_hashed' => $action,
229 | 'instance' => $instance,
230 | 'status' => Events_Store::STATUS_PENDING,
231 | ] );
232 |
233 | // Nothing to do...
234 | if ( is_null( $event ) ) {
235 | /* translators: 1: Job identifier */
236 | $error_message = sprintf( __( 'Job with identifier `%1$s` could not be found.', 'automattic-cron-control' ), "$timestamp-$action-$instance" );
237 | return new WP_Error( 'no-event', $error_message, [ 'status' => 404 ] );
238 | }
239 |
240 | unset( $timestamp, $action, $instance );
241 |
242 | // Limit how many events are processed concurrently, unless explicitly bypassed.
243 | if ( ! $force ) {
244 | // Prepare event-level lock.
245 | $this->prime_event_action_lock( $event );
246 |
247 | if ( ! $this->can_run_event( $event ) ) {
248 | /* translators: 1: Event action, 2: Event arguments */
249 | $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() ) );
250 | return new WP_Error( 'no-free-threads', $error_message, [ 'status' => 429 ] );
251 | }
252 |
253 | // Free locks later in case event throws an uncatchable error.
254 | $this->running_event = $event;
255 | add_action( 'shutdown', array( $this, 'do_lock_cleanup_on_shutdown' ) );
256 | }
257 |
258 | // Core reschedules/conpletes an event before running it, so we respect that.
259 | if ( $event->is_recurring() ) {
260 | $event->reschedule();
261 | } else {
262 | $event->complete();
263 | }
264 |
265 | try {
266 | $event->run();
267 | } catch ( \Throwable $t ) {
268 | /**
269 | * Note that timeouts and memory exhaustion do not invoke this block.
270 | * Instead, those locks are freed in `do_lock_cleanup_on_shutdown()`.
271 | */
272 |
273 | do_action( 'a8c_cron_control_event_threw_catchable_error', $event->get_legacy_event_format(), $t );
274 |
275 | $return = array(
276 | 'success' => false,
277 | /* translators: 1: Event action, 2: Event arguments, 3: Throwable error, 4: Line number that raised Throwable error */
278 | '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() ),
279 | );
280 | }
281 |
282 | // Free locks for the next event, unless they weren't set to begin with.
283 | if ( ! $force ) {
284 | // If we got this far, there's no uncaught error to handle.
285 | $this->running_event = null;
286 | remove_action( 'shutdown', array( $this, 'do_lock_cleanup_on_shutdown' ) );
287 |
288 | $this->do_lock_cleanup( $event );
289 | }
290 |
291 | // Callback didn't trigger a Throwable, indicating it succeeded.
292 | if ( ! isset( $return ) ) {
293 | $return = array(
294 | 'success' => true,
295 | /* translators: 1: Event action, 2: Event arguments */
296 | 'message' => sprintf( __( 'Job with action `%1$s` and arguments `%2$s` executed.', 'automattic-cron-control' ), $event->get_action(), maybe_serialize( $event->get_args() ) ),
297 | );
298 | }
299 |
300 | return $return;
301 | }
302 |
303 | private function prime_event_action_lock( Event $event ): void {
304 | Lock::prime_lock( $this->get_lock_key_for_event_action( $event ), JOB_LOCK_EXPIRY_IN_MINUTES * \MINUTE_IN_SECONDS );
305 | }
306 |
307 | // Checks concurrency locks, deciding if the event can be run at this moment.
308 | private function can_run_event( Event $event ): bool {
309 | // Limit to one concurrent execution of a specific action by default.
310 | $limit = 1;
311 |
312 | if ( isset( $this->concurrent_action_whitelist[ $event->get_action() ] ) ) {
313 | $limit = absint( $this->concurrent_action_whitelist[ $event->get_action() ] );
314 | $limit = min( $limit, JOB_CONCURRENCY_LIMIT );
315 | }
316 |
317 | if ( ! Lock::check_lock( $this->get_lock_key_for_event_action( $event ), $limit, JOB_LOCK_EXPIRY_IN_MINUTES * \MINUTE_IN_SECONDS ) ) {
318 | return false;
319 | }
320 |
321 | // Internal Events aren't subject to the global lock.
322 | if ( $event->is_internal() ) {
323 | return true;
324 | }
325 |
326 | // Check if any resources are available to execute this job.
327 | // If not, the individual-event lock must be freed, otherwise it's deadlocked until it times out.
328 | if ( ! Lock::check_lock( self::LOCK, JOB_CONCURRENCY_LIMIT ) ) {
329 | $this->reset_event_lock( $event );
330 | return false;
331 | }
332 |
333 | // Let's go!
334 | return true;
335 | }
336 |
337 | private function do_lock_cleanup( Event $event ): void {
338 | // Site-level lock isn't set when event is Internal, so we don't want to alter it.
339 | if ( ! $event->is_internal() ) {
340 | Lock::free_lock( self::LOCK );
341 | }
342 |
343 | // Reset individual event lock.
344 | $this->reset_event_lock( $event );
345 | }
346 |
347 | private function reset_event_lock( Event $event ): bool {
348 | $lock_key = $this->get_lock_key_for_event_action( $event );
349 | $expires = JOB_LOCK_EXPIRY_IN_MINUTES * \MINUTE_IN_SECONDS;
350 |
351 | if ( isset( $this->concurrent_action_whitelist[ $event->get_action() ] ) ) {
352 | return Lock::free_lock( $lock_key, $expires );
353 | } else {
354 | return Lock::reset_lock( $lock_key, $expires );
355 | }
356 | }
357 |
358 | /**
359 | * Turn the event action into a string that can be used with a lock
360 | *
361 | * @param Event|stdClass $event
362 | * @return string
363 | */
364 | public function get_lock_key_for_event_action( $event ): string {
365 | // Hashed solely to constrain overall length.
366 | $action = method_exists( $event, 'get_action' ) ? $event->get_action() : $event->action;
367 | return md5( 'ev-' . $action );
368 | }
369 |
370 | /**
371 | * If event execution throws uncatchable error, free locks
372 | * Covers situations such as timeouts and memory exhaustion, which aren't \Throwable errors
373 | * Under normal conditions, this callback isn't hooked to `shutdown`
374 | */
375 | public function do_lock_cleanup_on_shutdown() {
376 | $event = $this->running_event;
377 |
378 | if ( is_null( $event ) ) {
379 | return;
380 | }
381 |
382 | do_action( 'a8c_cron_control_freeing_event_locks_after_uncaught_error', $event->get_legacy_event_format() );
383 |
384 | $this->do_lock_cleanup( $event );
385 | }
386 |
387 | /**
388 | * Return status of automatic event execution
389 | *
390 | * @return int 0 if run is enabled, 1 if run is disabled indefinitely, otherwise timestamp when execution will resume
391 | */
392 | public function run_disabled() {
393 | $disabled = (int) get_option( self::DISABLE_RUN_OPTION, 0 );
394 |
395 | if ( $disabled <= 1 || $disabled > time() ) {
396 | return $disabled;
397 | }
398 |
399 | $this->update_run_status( 0 );
400 | return 0;
401 | }
402 |
403 | /**
404 | * Set automatic execution status
405 | *
406 | * @param int $new_status 0 if run is enabled, 1 if run is disabled indefinitely, otherwise timestamp when execution will resume.
407 | * @return bool
408 | */
409 | public function update_run_status( $new_status ) {
410 | $new_status = absint( $new_status );
411 |
412 | // Don't store a past timestamp.
413 | if ( $new_status > 1 && $new_status < time() ) {
414 | return false;
415 | }
416 |
417 | return update_option( self::DISABLE_RUN_OPTION, $new_status );
418 | }
419 |
420 | /**
421 | * Query for multiple events.
422 | *
423 | * @param array $query_args Event query args.
424 | * @return array An array of Event objects.
425 | */
426 | public static function query( array $query_args = [] ): array {
427 | $event_db_rows = Events_Store::instance()->_query_events_raw( $query_args );
428 | $events = array_map( fn( $db_row ) => Event::get_from_db_row( $db_row ), $event_db_rows );
429 | return array_filter( $events, fn( $event ) => ! is_null( $event ) );
430 | }
431 |
432 | /**
433 | * Format multiple events the way WP expects them.
434 | *
435 | * @param array $events Array of Event objects that need formatting.
436 | * @return array Array of event data in the deeply nested format WP expects.
437 | */
438 | public static function format_events_for_wp( array $events ): array {
439 | $crons = [];
440 |
441 | foreach ( $events as $event ) {
442 | // Level 1: Ensure the root timestamp node exists.
443 | $timestamp = $event->get_timestamp();
444 | if ( ! isset( $crons[ $timestamp ] ) ) {
445 | $crons[ $timestamp ] = [];
446 | }
447 |
448 | // Level 2: Ensure the action node exists.
449 | $action = $event->get_action();
450 | if ( ! isset( $crons[ $timestamp ][ $action ] ) ) {
451 | $crons[ $timestamp ][ $action ] = [];
452 | }
453 |
454 | // Finally, add the rest of the event details.
455 | $formatted_event = [
456 | 'schedule' => empty( $event->get_schedule() ) ? false : $event->get_schedule(),
457 | 'args' => $event->get_args(),
458 | ];
459 |
460 | $interval = $event->get_interval();
461 | if ( ! empty( $interval ) ) {
462 | $formatted_event['interval'] = $interval;
463 | }
464 |
465 | $instance = $event->get_instance();
466 | $crons[ $timestamp ][ $action ][ $instance ] = $formatted_event;
467 | }
468 |
469 | // Re-sort the array just as core does when events are scheduled.
470 | uksort( $crons, 'strnatcasecmp' );
471 | return $crons;
472 | }
473 |
474 | /**
475 | * Flatten the WP events array.
476 | * Each event will have a unique key for quick comparisons.
477 | *
478 | * @param array $events Deeply nested array of event data in the format WP core uses.
479 | * @return array Flat array that is easier to compare and work with :)
480 | */
481 | public static function flatten_wp_events_array( array $events ): array {
482 | // Core legacy thing, we don't need this.
483 | unset( $events['version'] );
484 |
485 | $flattened = [];
486 | foreach ( $events as $timestamp => $ts_events ) {
487 | foreach ( $ts_events as $action => $action_instances ) {
488 | foreach ( $action_instances as $instance => $event_details ) {
489 | $unique_key = "$timestamp:$action:$instance";
490 |
491 | $flat_event = [
492 | 'timestamp' => $timestamp,
493 | 'action' => $action,
494 | 'instance' => $instance,
495 | 'args' => $event_details['args'],
496 | ];
497 |
498 | if ( ! empty( $event_details['schedule'] ) ) {
499 | $unique_key = "$unique_key:{$event_details['schedule']}:{$event_details['interval']}";
500 |
501 | $flat_event['schedule'] = $event_details['schedule'];
502 | $flat_event['interval'] = $event_details['interval'];
503 | }
504 |
505 | $flattened[ sha1( $unique_key ) ] = $flat_event;
506 | }
507 | }
508 | }
509 |
510 | return $flattened;
511 | }
512 | }
513 |
--------------------------------------------------------------------------------