Files
Vladimir Bryzgalov f510ac484a fix(cron): self-heal scheduled events on every boot + on plugin upgrade
Pilot on washanyanya.ru lost the cf7stg_dispatch cron event somewhere
between 21.04 and 01.05 — wp_next_scheduled('cf7stg_dispatch') returned
NULL, queue had 6 pending rows, sent-24h=0, failed=0, no error trail.
Settings/token were intact. The most likely cause is the PUC self-update
to 1.2.0: WordPress plugin upgrades replace files but do NOT call
register_activation_hook, so Activator::activate() never re-ran and any
cron event Activator::deactivate() had cleared (or that was lost for any
other reason) stayed missing.

Two-part fix:

1. Activator::ensure_scheduled() — extracted from the old private
   schedule_events(), now public and idempotent. wp_next_scheduled()
   short-circuits when events are already queued, so it's safe to call
   on every request. Plugin::boot() invokes it on plugins_loaded — every
   admin or front-end hit auto-heals lost cron events.

2. Activator::on_upgrade_complete() bound to upgrader_process_complete.
   When WordPress finishes upgrading THIS plugin (matched via
   plugin_basename + $hook_extra['plugins']), we re-run install_table()
   (idempotent dbDelta), seed_silent_drop_options() (uses add_option,
   preserves user values), and ensure_scheduled(). guard_requirements
   is intentionally skipped — wp_die mid-upgrade would leave admin in
   a broken state, and the host obviously meets requirements since the
   old version is currently running.

Tests untouched (this is integration code wrapping WP-only APIs); full
suite still 126/126 green.

Changelog entry for 1.2.1 added to readme.txt.
Stable tag and CF7STG_VERSION will be bumped by release.sh.

Refs: cp-1lg

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:19:34 +05:00

147 lines
5.7 KiB
PHP

<?php
declare(strict_types=1);
namespace Cf7stg;
final class Activator
{
public const DB_VERSION = '1';
public const DB_VERSION_OPTION = 'cf7stg_db_version';
public static function activate(): void
{
self::guard_requirements();
self::install_table();
self::seed_silent_drop_options();
self::ensure_scheduled();
}
public static function deactivate(): void
{
wp_clear_scheduled_hook('cf7stg_dispatch');
wp_clear_scheduled_hook('cf7stg_cleanup');
}
/**
* Self-heal cron events. Idempotent — safe to call from Plugin::boot()
* on every request. If wp_next_scheduled() shows the hook isn't queued,
* re-schedule it. This recovers from any cause of cron loss (manual
* wp_clear_scheduled_hook, plugin upgrade that bypassed activation,
* truncated wp_options.cron value) without requiring deactivate/activate
* by the user.
*
* The cron_schedules filter must be in place inside this same request so
* 'cf7stg_every_minute' is a known recurrence at wp_schedule_event time.
* Dispatcher::register_hooks() also registers it on plugins_loaded, but
* we add it here too in case ensure_scheduled() runs from a context where
* Dispatcher's registration hasn't fired yet (e.g. during an upgrade hook).
*/
public static function ensure_scheduled(): void
{
add_filter('cron_schedules', ['\\Cf7stg\\Dispatcher', 'register_schedule']);
if (!wp_next_scheduled('cf7stg_dispatch')) {
wp_schedule_event(time() + 60, 'cf7stg_every_minute', 'cf7stg_dispatch');
}
if (!wp_next_scheduled('cf7stg_cleanup')) {
wp_schedule_event(time() + 3600, 'daily', 'cf7stg_cleanup');
}
}
/**
* upgrader_process_complete callback. WordPress fires this after a
* plugin / theme / core update completes. Plugin updates do NOT call
* register_activation_hook, so any cron events the user's old install
* relied on must be re-installed explicitly here. We also re-run the
* table installer (idempotent dbDelta) and re-seed silent-drop options
* (uses add_option, so user values are preserved).
*
* @param mixed $upgrader WP_Upgrader instance (unused).
* @param array $hook_extra ['action'=>'update','type'=>'plugin','plugins'=>[...]]
*/
public static function on_upgrade_complete($upgrader, $hook_extra): void
{
if (!is_array($hook_extra)) return;
if (($hook_extra['action'] ?? '') !== 'update') return;
if (($hook_extra['type'] ?? '') !== 'plugin') return;
$plugins = (array)($hook_extra['plugins'] ?? []);
$self = plugin_basename(CF7STG_PLUGIN_FILE);
if (!in_array($self, $plugins, true)) {
return;
}
// Skip guard_requirements() — already running on a live install whose
// PHP/WP versions are clearly compatible. wp_die here would interrupt
// the upgrader and leave admin in a broken state.
self::install_table();
self::seed_silent_drop_options();
self::ensure_scheduled();
}
private static function guard_requirements(): void
{
if (version_compare(PHP_VERSION, '7.4', '<')) {
deactivate_plugins(plugin_basename(CF7STG_PLUGIN_FILE));
wp_die('Плагин CF7 Spam → Telegram требует PHP 7.4 или выше. Текущая: ' . PHP_VERSION);
}
global $wp_version;
if (version_compare($wp_version, '6.0', '<')) {
deactivate_plugins(plugin_basename(CF7STG_PLUGIN_FILE));
wp_die('Плагин CF7 Spam → Telegram требует WordPress 6.0 или выше.');
}
}
private static function install_table(): void
{
global $wpdb;
$table = $wpdb->prefix . 'cf7stg_queue';
$charset_collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
flamingo_post_id BIGINT UNSIGNED NULL,
form_id BIGINT UNSIGNED NULL,
source_key VARCHAR(40) NOT NULL DEFAULT 'cf7',
payload_json LONGTEXT NOT NULL,
label VARCHAR(10) NOT NULL DEFAULT 'unclear',
is_retro TINYINT(1) NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
attempts SMALLINT UNSIGNED NOT NULL DEFAULT 0,
last_error TEXT NULL,
next_try_at DATETIME NULL,
created_at DATETIME NOT NULL,
sent_at DATETIME NULL,
PRIMARY KEY (id),
KEY idx_status_next (status, next_try_at),
UNIQUE KEY uq_flamingo (flamingo_post_id, is_retro)
) {$charset_collate};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta($sql);
update_option(self::DB_VERSION_OPTION, self::DB_VERSION);
}
/**
* Seed safe defaults for silent-drop options. Uses add_option (not update_option),
* so existing values from prior versions or user customisations are preserved.
* Runs on every activate() call — fresh installs and upgrades alike.
*/
private static function seed_silent_drop_options(): void
{
add_option(Settings::OPT_DRY_RUN_DROP, true);
add_option(Settings::OPT_DROP_THRESHOLD, -5);
add_option(Settings::OPT_HARD_DROP_ENABLED, true);
add_option(Settings::OPT_DROP_COUNTERS, [
'total' => 0,
'real_today' => 0,
'real_yesterday' => 0,
'dry_today' => 0,
'dry_yesterday' => 0,
'today_date' => wp_date('Y-m-d'),
'reset_at' => wp_date('Y-m-d'),
]);
}
}