wp-background-process.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506
  1. <?php
  2. /**
  3. * WP Background Process
  4. *
  5. * @package WP-Background-Processing
  6. */
  7. if ( ! class_exists( 'WP_Background_Process' ) ) {
  8. /**
  9. * Abstract WP_Background_Process class.
  10. *
  11. * @abstract
  12. * @extends WP_Async_Request
  13. */
  14. abstract class WP_Background_Process extends WP_Async_Request {
  15. /**
  16. * Action
  17. *
  18. * (default value: 'background_process')
  19. *
  20. * @var string
  21. * @access protected
  22. */
  23. protected $action = 'background_process';
  24. /**
  25. * Start time of current process.
  26. *
  27. * (default value: 0)
  28. *
  29. * @var int
  30. * @access protected
  31. */
  32. protected $start_time = 0;
  33. /**
  34. * Cron_hook_identifier
  35. *
  36. * @var mixed
  37. * @access protected
  38. */
  39. protected $cron_hook_identifier;
  40. /**
  41. * Cron_interval_identifier
  42. *
  43. * @var mixed
  44. * @access protected
  45. */
  46. protected $cron_interval_identifier;
  47. /**
  48. * Initiate new background process
  49. */
  50. public function __construct() {
  51. parent::__construct();
  52. $this->cron_hook_identifier = $this->identifier . '_cron';
  53. $this->cron_interval_identifier = $this->identifier . '_cron_interval';
  54. add_action( $this->cron_hook_identifier, array( $this, 'handle_cron_healthcheck' ) );
  55. add_filter( 'cron_schedules', array( $this, 'schedule_cron_healthcheck' ) );
  56. }
  57. /**
  58. * Dispatch
  59. *
  60. * @access public
  61. * @return void
  62. */
  63. public function dispatch() {
  64. // Schedule the cron healthcheck.
  65. $this->schedule_event();
  66. // Perform remote post.
  67. return parent::dispatch();
  68. }
  69. /**
  70. * Push to queue
  71. *
  72. * @param mixed $data Data.
  73. *
  74. * @return $this
  75. */
  76. public function push_to_queue( $data ) {
  77. $this->data[] = $data;
  78. return $this;
  79. }
  80. /**
  81. * Save queue
  82. *
  83. * @return $this
  84. */
  85. public function save() {
  86. $key = $this->generate_key();
  87. if ( ! empty( $this->data ) ) {
  88. update_site_option( $key, $this->data );
  89. }
  90. return $this;
  91. }
  92. /**
  93. * Update queue
  94. *
  95. * @param string $key Key.
  96. * @param array $data Data.
  97. *
  98. * @return $this
  99. */
  100. public function update( $key, $data ) {
  101. if ( ! empty( $data ) ) {
  102. update_site_option( $key, $data );
  103. }
  104. return $this;
  105. }
  106. /**
  107. * Delete queue
  108. *
  109. * @param string $key Key.
  110. *
  111. * @return $this
  112. */
  113. public function delete( $key ) {
  114. delete_site_option( $key );
  115. return $this;
  116. }
  117. /**
  118. * Generate key
  119. *
  120. * Generates a unique key based on microtime. Queue items are
  121. * given a unique key so that they can be merged upon save.
  122. *
  123. * @param int $length Length.
  124. *
  125. * @return string
  126. */
  127. protected function generate_key( $length = 64 ) {
  128. $unique = md5( microtime() . rand() );
  129. $prepend = $this->identifier . '_batch_';
  130. return substr( $prepend . $unique, 0, $length );
  131. }
  132. /**
  133. * Maybe process queue
  134. *
  135. * Checks whether data exists within the queue and that
  136. * the process is not already running.
  137. */
  138. public function maybe_handle() {
  139. // Don't lock up other requests while processing
  140. session_write_close();
  141. if ( $this->is_process_running() ) {
  142. // Background process already running.
  143. wp_die();
  144. }
  145. if ( $this->is_queue_empty() ) {
  146. // No data to process.
  147. wp_die();
  148. }
  149. check_ajax_referer( $this->identifier, 'nonce' );
  150. $this->handle();
  151. wp_die();
  152. }
  153. /**
  154. * Is queue empty
  155. *
  156. * @return bool
  157. */
  158. protected function is_queue_empty() {
  159. global $wpdb;
  160. $table = $wpdb->options;
  161. $column = 'option_name';
  162. if ( is_multisite() ) {
  163. $table = $wpdb->sitemeta;
  164. $column = 'meta_key';
  165. }
  166. $key = $wpdb->esc_like( $this->identifier . '_batch_' ) . '%';
  167. $count = $wpdb->get_var( $wpdb->prepare( "
  168. SELECT COUNT(*)
  169. FROM {$table}
  170. WHERE {$column} LIKE %s
  171. ", $key ) );
  172. return ( $count > 0 ) ? false : true;
  173. }
  174. /**
  175. * Is process running
  176. *
  177. * Check whether the current process is already running
  178. * in a background process.
  179. */
  180. protected function is_process_running() {
  181. if ( get_site_transient( $this->identifier . '_process_lock' ) ) {
  182. // Process already running.
  183. return true;
  184. }
  185. return false;
  186. }
  187. /**
  188. * Lock process
  189. *
  190. * Lock the process so that multiple instances can't run simultaneously.
  191. * Override if applicable, but the duration should be greater than that
  192. * defined in the time_exceeded() method.
  193. */
  194. protected function lock_process() {
  195. $this->start_time = time(); // Set start time of current process.
  196. $lock_duration = ( property_exists( $this, 'queue_lock_time' ) ) ? $this->queue_lock_time : 60; // 1 minute
  197. $lock_duration = apply_filters( $this->identifier . '_queue_lock_time', $lock_duration );
  198. set_site_transient( $this->identifier . '_process_lock', microtime(), $lock_duration );
  199. }
  200. /**
  201. * Unlock process
  202. *
  203. * Unlock the process so that other instances can spawn.
  204. *
  205. * @return $this
  206. */
  207. protected function unlock_process() {
  208. delete_site_transient( $this->identifier . '_process_lock' );
  209. return $this;
  210. }
  211. /**
  212. * Get batch
  213. *
  214. * @return stdClass Return the first batch from the queue
  215. */
  216. protected function get_batch() {
  217. global $wpdb;
  218. $table = $wpdb->options;
  219. $column = 'option_name';
  220. $key_column = 'option_id';
  221. $value_column = 'option_value';
  222. if ( is_multisite() ) {
  223. $table = $wpdb->sitemeta;
  224. $column = 'meta_key';
  225. $key_column = 'meta_id';
  226. $value_column = 'meta_value';
  227. }
  228. $key = $wpdb->esc_like( $this->identifier . '_batch_' ) . '%';
  229. $query = $wpdb->get_row( $wpdb->prepare( "
  230. SELECT *
  231. FROM {$table}
  232. WHERE {$column} LIKE %s
  233. ORDER BY {$key_column} ASC
  234. LIMIT 1
  235. ", $key ) );
  236. $batch = new stdClass();
  237. $batch->key = $query->$column;
  238. $batch->data = maybe_unserialize( $query->$value_column );
  239. return $batch;
  240. }
  241. /**
  242. * Handle
  243. *
  244. * Pass each queue item to the task handler, while remaining
  245. * within server memory and time limit constraints.
  246. */
  247. protected function handle() {
  248. $this->lock_process();
  249. do {
  250. $batch = $this->get_batch();
  251. foreach ( $batch->data as $key => $value ) {
  252. $task = $this->task( $value );
  253. if ( false !== $task ) {
  254. $batch->data[ $key ] = $task;
  255. } else {
  256. unset( $batch->data[ $key ] );
  257. }
  258. if ( $this->time_exceeded() || $this->memory_exceeded() ) {
  259. // Batch limits reached.
  260. break;
  261. }
  262. }
  263. // Update or delete current batch.
  264. if ( ! empty( $batch->data ) ) {
  265. $this->update( $batch->key, $batch->data );
  266. } else {
  267. $this->delete( $batch->key );
  268. }
  269. } while ( ! $this->time_exceeded() && ! $this->memory_exceeded() && ! $this->is_queue_empty() );
  270. $this->unlock_process();
  271. // Start next batch or complete process.
  272. if ( ! $this->is_queue_empty() ) {
  273. $this->dispatch();
  274. } else {
  275. $this->complete();
  276. }
  277. wp_die();
  278. }
  279. /**
  280. * Memory exceeded
  281. *
  282. * Ensures the batch process never exceeds 90%
  283. * of the maximum WordPress memory.
  284. *
  285. * @return bool
  286. */
  287. protected function memory_exceeded() {
  288. $memory_limit = $this->get_memory_limit() * 0.9; // 90% of max memory
  289. $current_memory = memory_get_usage( true );
  290. $return = false;
  291. if ( $current_memory >= $memory_limit ) {
  292. $return = true;
  293. }
  294. return apply_filters( $this->identifier . '_memory_exceeded', $return );
  295. }
  296. /**
  297. * Get memory limit
  298. *
  299. * @return int
  300. */
  301. protected function get_memory_limit() {
  302. if ( function_exists( 'ini_get' ) ) {
  303. $memory_limit = ini_get( 'memory_limit' );
  304. } else {
  305. // Sensible default.
  306. $memory_limit = '128M';
  307. }
  308. if ( ! $memory_limit || -1 === intval( $memory_limit ) ) {
  309. // Unlimited, set to 32GB.
  310. $memory_limit = '32000M';
  311. }
  312. return intval( $memory_limit ) * 1024 * 1024;
  313. }
  314. /**
  315. * Time exceeded.
  316. *
  317. * Ensures the batch never exceeds a sensible time limit.
  318. * A timeout limit of 30s is common on shared hosting.
  319. *
  320. * @return bool
  321. */
  322. protected function time_exceeded() {
  323. $finish = $this->start_time + apply_filters( $this->identifier . '_default_time_limit', 20 ); // 20 seconds
  324. $return = false;
  325. if ( time() >= $finish ) {
  326. $return = true;
  327. }
  328. return apply_filters( $this->identifier . '_time_exceeded', $return );
  329. }
  330. /**
  331. * Complete.
  332. *
  333. * Override if applicable, but ensure that the below actions are
  334. * performed, or, call parent::complete().
  335. */
  336. protected function complete() {
  337. // Unschedule the cron healthcheck.
  338. $this->clear_scheduled_event();
  339. }
  340. /**
  341. * Schedule cron healthcheck
  342. *
  343. * @access public
  344. * @param mixed $schedules Schedules.
  345. * @return mixed
  346. */
  347. public function schedule_cron_healthcheck( $schedules ) {
  348. $interval = apply_filters( $this->identifier . '_cron_interval', 5 );
  349. if ( property_exists( $this, 'cron_interval' ) ) {
  350. $interval = apply_filters( $this->identifier . '_cron_interval', $this->cron_interval );
  351. }
  352. // Adds every 5 minutes to the existing schedules.
  353. $schedules[ $this->identifier . '_cron_interval' ] = array(
  354. 'interval' => MINUTE_IN_SECONDS * $interval,
  355. 'display' => sprintf( __( 'Every %d Minutes' ), $interval ),
  356. );
  357. return $schedules;
  358. }
  359. /**
  360. * Handle cron healthcheck
  361. *
  362. * Restart the background process if not already running
  363. * and data exists in the queue.
  364. */
  365. public function handle_cron_healthcheck() {
  366. if ( $this->is_process_running() ) {
  367. // Background process already running.
  368. exit;
  369. }
  370. if ( $this->is_queue_empty() ) {
  371. // No data to process.
  372. $this->clear_scheduled_event();
  373. exit;
  374. }
  375. $this->handle();
  376. exit;
  377. }
  378. /**
  379. * Schedule event
  380. */
  381. protected function schedule_event() {
  382. if ( ! wp_next_scheduled( $this->cron_hook_identifier ) ) {
  383. wp_schedule_event( time(), $this->cron_interval_identifier, $this->cron_hook_identifier );
  384. }
  385. }
  386. /**
  387. * Clear scheduled event
  388. */
  389. protected function clear_scheduled_event() {
  390. $timestamp = wp_next_scheduled( $this->cron_hook_identifier );
  391. if ( $timestamp ) {
  392. wp_unschedule_event( $timestamp, $this->cron_hook_identifier );
  393. }
  394. }
  395. /**
  396. * Cancel Process
  397. *
  398. * Stop processing queue items, clear cronjob and delete batch.
  399. *
  400. */
  401. public function cancel_process() {
  402. if ( ! $this->is_queue_empty() ) {
  403. $batch = $this->get_batch();
  404. $this->delete( $batch->key );
  405. wp_clear_scheduled_hook( $this->cron_hook_identifier );
  406. }
  407. }
  408. /**
  409. * Task
  410. *
  411. * Override this method to perform any actions required on each
  412. * queue item. Return the modified item for further processing
  413. * in the next pass through. Or, return false to remove the
  414. * item from the queue.
  415. *
  416. * @param mixed $item Queue item to iterate over.
  417. *
  418. * @return mixed
  419. */
  420. abstract protected function task( $item );
  421. }
  422. }