PluginProbe
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! / 3.8.0
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! v3.8.0
3.8.0 3.7.5 3.7.4 3.7.3 3.7.2 1-final 3.7.1 3.7.0 3.6.8 3.6.7 3.6.6 3.6.5 3.6.4 3.6.3 3.6.2 3.6.1 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.1 All 112 releases
templately / modules / utilities / Cleanup / Scheduler.php

Scheduler.php in Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! 3.8.0, at modules/utilities/Cleanup/Scheduler.php

211 lines 6.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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