| 1 |
<?php |
| 2 |
/** |
| 3 |
* The plugin's ONE cleanup schedule. |
| 4 |
* |
| 5 |
* @package Templately |
| 6 |
*/ |
| 7 |
|
| 8 |
namespace Templately\Modules\Utilities\Cleanup; |
| 9 |
|
| 10 |
use Templately\Utils\Helper; |
| 11 |
|
| 12 |
/** |
| 13 |
* Owns the recurring sweep, the run claim that stops two sweeps overlapping, and |
| 14 |
* the on-demand nudge used after an import finishes. |
| 15 |
* |
| 16 |
* It also RETIRES the schedules it supersedes. Two existed before this module: |
| 17 |
* the import feature's `templately_fsi_daily_cleanup` and the MCP server's |
| 18 |
* `templately_mcp_oauth_sweep`. Leaving either registered would mean two |
| 19 |
* cleaners running on their own cadences with no shared claim — which is the |
| 20 |
* situation this module exists to end. |
| 21 |
*/ |
| 22 |
final class Scheduler { |
| 23 |
|
| 24 |
const EVENT = 'templately_utilities_daily_cleanup'; |
| 25 |
|
| 26 |
/** One-off sweep, scheduled after an import finishes. */ |
| 27 |
const EVENT_ONCE = 'templately_utilities_cleanup_once'; |
| 28 |
|
| 29 |
const CLAIM_OPTION = 'templately_cleanup_claim'; |
| 30 |
|
| 31 |
/** |
| 32 |
* How long a run may hold the claim before it is considered abandoned. |
| 33 |
* |
| 34 |
* Comfortably longer than any legitimate run (the scan budget is seconds and |
| 35 |
* a sweep stops at a task boundary), short enough that a run killed by a |
| 36 |
* fatal or a host restart self-heals well before the next daily fire. A |
| 37 |
* stuck claim would otherwise disable cleanup permanently and silently. |
| 38 |
*/ |
| 39 |
const CLAIM_TTL = 900; // 15 minutes. |
| 40 |
|
| 41 |
/** Schedules this module supersedes; cleared once at boot. */ |
| 42 |
const SUPERSEDED_EVENTS = [ |
| 43 |
'templately_fsi_daily_cleanup', |
| 44 |
'templately_mcp_oauth_sweep', |
| 45 |
]; |
| 46 |
|
| 47 |
/** Wall-clock budget for one sweep, in seconds. */ |
| 48 |
const RUN_BUDGET = 20.0; |
| 49 |
|
| 50 |
public static function boot(): void { |
| 51 |
add_action( self::EVENT, [ self::class, 'run_scheduled' ] ); |
| 52 |
// The one-off carries its trigger so the journal can tell a post-import |
| 53 |
// sweep from the daily one — "what triggered this" is half of what the |
| 54 |
// run history is for. |
| 55 |
add_action( self::EVENT_ONCE, [ self::class, 'run_once' ], 10, 1 ); |
| 56 |
add_action( 'init', [ self::class, 'ensure_scheduled' ] ); |
| 57 |
} |
| 58 |
|
| 59 |
/** |
| 60 |
* Register the recurring event, and retire the ones this replaces. |
| 61 |
* |
| 62 |
* Idempotent — safe on every request. |
| 63 |
*/ |
| 64 |
public static function ensure_scheduled(): void { |
| 65 |
self::retire_superseded(); |
| 66 |
|
| 67 |
if ( Settings::is_disabled() ) { |
| 68 |
// Switched off: no schedule at all, not a schedule that no-ops. |
| 69 |
self::unschedule(); |
| 70 |
|
| 71 |
return; |
| 72 |
} |
| 73 |
|
| 74 |
if ( ! wp_next_scheduled( self::EVENT ) ) { |
| 75 |
wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::EVENT ); |
| 76 |
} |
| 77 |
} |
| 78 |
|
| 79 |
public static function unschedule(): void { |
| 80 |
$timestamp = wp_next_scheduled( self::EVENT ); |
| 81 |
if ( $timestamp ) { |
| 82 |
wp_unschedule_event( $timestamp, self::EVENT ); |
| 83 |
} |
| 84 |
} |
| 85 |
|
| 86 |
/** |
| 87 |
* Clear the schedules superseded by this module. |
| 88 |
* |
| 89 |
* An orphaned recurring event keeps firing at its own cadence forever, even |
| 90 |
* after the code that registered it is gone — so removing the registration |
| 91 |
* is not enough on a site that already has the event stored. |
| 92 |
*/ |
| 93 |
private static function retire_superseded(): void { |
| 94 |
foreach ( self::SUPERSEDED_EVENTS as $event ) { |
| 95 |
$timestamp = wp_next_scheduled( $event ); |
| 96 |
while ( $timestamp ) { |
| 97 |
wp_unschedule_event( $timestamp, $event ); |
| 98 |
$timestamp = wp_next_scheduled( $event ); |
| 99 |
} |
| 100 |
} |
| 101 |
} |
| 102 |
|
| 103 |
/** |
| 104 |
* Schedule a sweep to run shortly. |
| 105 |
* |
| 106 |
* Replaces the import feature's `mt_rand(1,100) <= 5` orphan sweep: the |
| 107 |
* sweep now always happens, asynchronously, instead of on one import in |
| 108 |
* twenty — and it no longer depends on an in-flight pack id. |
| 109 |
*/ |
| 110 |
public static function kick( string $trigger = Context::TRIGGER_POST_IMPORT ): void { |
| 111 |
if ( Settings::is_disabled() ) { |
| 112 |
return; |
| 113 |
} |
| 114 |
|
| 115 |
$args = [ $trigger ]; |
| 116 |
|
| 117 |
if ( ! wp_next_scheduled( self::EVENT_ONCE, $args ) ) { |
| 118 |
wp_schedule_single_event( time() + MINUTE_IN_SECONDS, self::EVENT_ONCE, $args ); |
| 119 |
} |
| 120 |
} |
| 121 |
|
| 122 |
/** |
| 123 |
* The one-off callback. Takes the trigger the kick recorded. |
| 124 |
*/ |
| 125 |
public static function run_once( string $trigger = Context::TRIGGER_POST_IMPORT ): void { |
| 126 |
self::run( TaskRegistry::get_instance()->schedulable_ids(), $trigger ); |
| 127 |
} |
| 128 |
|
| 129 |
/** |
| 130 |
* The scheduled callback: run every schedulable task. |
| 131 |
*/ |
| 132 |
public static function run_scheduled(): void { |
| 133 |
self::run( TaskRegistry::get_instance()->schedulable_ids(), Context::TRIGGER_SCHEDULE ); |
| 134 |
} |
| 135 |
|
| 136 |
/** |
| 137 |
* Run the given tasks under a claim, journalling the outcome. |
| 138 |
* |
| 139 |
* @param string[] $task_ids |
| 140 |
* @param string $trigger |
| 141 |
* @param bool $dry_run Caller's request; an unactivated site forces true. |
| 142 |
* @return array{results: array<string,TaskResult>, dry_run: bool, entry: ?array, skipped: bool} |
| 143 |
*/ |
| 144 |
public static function run( array $task_ids, string $trigger, bool $dry_run = false ): array { |
| 145 |
if ( Settings::is_disabled() ) { |
| 146 |
return [ 'results' => [], 'dry_run' => true, 'entry' => null, 'skipped' => true ]; |
| 147 |
} |
| 148 |
|
| 149 |
if ( ! self::claim( $trigger ) ) { |
| 150 |
// Another sweep holds an unexpired claim. Skip rather than queue — |
| 151 |
// the next scheduled fire will pick the work up. |
| 152 |
return [ 'results' => [], 'dry_run' => $dry_run, 'entry' => null, 'skipped' => true ]; |
| 153 |
} |
| 154 |
|
| 155 |
// The gate: nothing is removed on a site nobody has activated, whatever |
| 156 |
// the caller asked for. |
| 157 |
$dry_run = $dry_run || Activation::forces_dry_run(); |
| 158 |
|
| 159 |
$context = Context::create( $trigger, RetentionPolicy::from_settings(), $dry_run, self::RUN_BUDGET ); |
| 160 |
|
| 161 |
try { |
| 162 |
$results = TaskRegistry::get_instance()->run( $task_ids, $context ); |
| 163 |
$entry = RunJournal::record( $trigger, $results, $dry_run, $context->elapsed_ms() ); |
| 164 |
} finally { |
| 165 |
self::release(); |
| 166 |
} |
| 167 |
|
| 168 |
return [ 'results' => $results, 'dry_run' => $dry_run, 'entry' => $entry, 'skipped' => false ]; |
| 169 |
} |
| 170 |
|
| 171 |
/** |
| 172 |
* Take the run claim, unless a live one is held. |
| 173 |
*/ |
| 174 |
public static function claim( string $trigger ): bool { |
| 175 |
$claim = get_option( self::CLAIM_OPTION, null ); |
| 176 |
|
| 177 |
if ( is_array( $claim ) && isset( $claim['expires_at'] ) && (int) $claim['expires_at'] > time() ) { |
| 178 |
return false; |
| 179 |
} |
| 180 |
|
| 181 |
if ( is_array( $claim ) ) { |
| 182 |
// A claim that outlived its TTL means the run holding it died — |
| 183 |
// a fatal, a host restart, an execution timeout. Log it, because a |
| 184 |
// crashed sweep is worth knowing about even though it self-heals. |
| 185 |
Helper::log( 'Reclaiming an expired cleanup claim from a run that did not finish.', 'cleanup' ); |
| 186 |
} |
| 187 |
|
| 188 |
update_option( |
| 189 |
self::CLAIM_OPTION, |
| 190 |
[ |
| 191 |
'started_at' => time(), |
| 192 |
'expires_at' => time() + self::CLAIM_TTL, |
| 193 |
'trigger' => $trigger, |
| 194 |
], |
| 195 |
false |
| 196 |
); |
| 197 |
|
| 198 |
return true; |
| 199 |
} |
| 200 |
|
| 201 |
public static function release(): void { |
| 202 |
delete_option( self::CLAIM_OPTION ); |
| 203 |
} |
| 204 |
|
| 205 |
public static function is_running(): bool { |
| 206 |
$claim = get_option( self::CLAIM_OPTION, null ); |
| 207 |
|
| 208 |
return is_array( $claim ) && isset( $claim['expires_at'] ) && (int) $claim['expires_at'] > time(); |
| 209 |
} |
| 210 |
} |
| 211 |
|