wp-background-process.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507
  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. // set_time_limit( 0 );
  196. $this->start_time = time(); // Set start time of current process.
  197. $lock_duration = ( property_exists( $this, 'queue_lock_time' ) ) ? $this->queue_lock_time : 60; // 1 minute
  198. $lock_duration = apply_filters( $this->identifier . '_queue_lock_time', $lock_duration );
  199. set_site_transient( $this->identifier . '_process_lock', microtime(), $lock_duration );
  200. }
  201. /**
  202. * Unlock process
  203. *
  204. * Unlock the process so that other instances can spawn.
  205. *
  206. * @return $this
  207. */
  208. protected function unlock_process() {
  209. delete_site_transient( $this->identifier . '_process_lock' );
  210. return $this;
  211. }
  212. /**
  213. * Get batch
  214. *
  215. * @return stdClass Return the first batch from the queue
  216. */
  217. protected function get_batch() {
  218. global $wpdb;
  219. $table = $wpdb->options;
  220. $column = 'option_name';
  221. $key_column = 'option_id';
  222. $value_column = 'option_value';
  223. if ( is_multisite() ) {
  224. $table = $wpdb->sitemeta;
  225. $column = 'meta_key';
  226. $key_column = 'meta_id';
  227. $value_column = 'meta_value';
  228. }
  229. $key = $wpdb->esc_like( $this->identifier . '_batch_' ) . '%';
  230. $query = $wpdb->get_row( $wpdb->prepare( "
  231. SELECT *
  232. FROM {$table}
  233. WHERE {$column} LIKE %s
  234. ORDER BY {$key_column} ASC
  235. LIMIT 1
  236. ", $key ) );
  237. $batch = new stdClass();
  238. $batch->key = $query->$column;
  239. $batch->data = maybe_unserialize( $query->$value_column );
  240. return $batch;
  241. }
  242. /**
  243. * Handle
  244. *
  245. * Pass each queue item to the task handler, while remaining
  246. * within server memory and time limit constraints.
  247. */
  248. protected function handle() {
  249. $this->lock_process();
  250. do {
  251. $batch = $this->get_batch();
  252. foreach ( $batch->data as $key => $value ) {
  253. $task = $this->task( $value );
  254. if ( false !== $task ) {
  255. $batch->data[ $key ] = $task;
  256. } else {
  257. unset( $batch->data[ $key ] );
  258. }
  259. if ( $this->time_exceeded() || $this->memory_exceeded() ) {
  260. // Batch limits reached.
  261. break;
  262. }
  263. }
  264. // Update or delete current batch.
  265. if ( ! empty( $batch->data ) ) {
  266. $this->update( $batch->key, $batch->data );
  267. } else {
  268. $this->delete( $batch->key );
  269. }
  270. } while ( ! $this->time_exceeded() && ! $this->memory_exceeded() && ! $this->is_queue_empty() );
  271. $this->unlock_process();
  272. // Start next batch or complete process.
  273. if ( ! $this->is_queue_empty() ) {
  274. $this->dispatch();
  275. } else {
  276. $this->complete();
  277. }
  278. wp_die();
  279. }
  280. /**
  281. * Memory exceeded
  282. *
  283. * Ensures the batch process never exceeds 90%
  284. * of the maximum WordPress memory.
  285. *
  286. * @return bool
  287. */
  288. protected function memory_exceeded() {
  289. $memory_limit = $this->get_memory_limit() * 0.9; // 90% of max memory
  290. $current_memory = memory_get_usage( true );
  291. $return = false;
  292. if ( $current_memory >= $memory_limit ) {
  293. $return = true;
  294. }
  295. return apply_filters( $this->identifier . '_memory_exceeded', $return );
  296. }
  297. /**
  298. * Get memory limit
  299. *
  300. * @return int
  301. */
  302. protected function get_memory_limit() {
  303. if ( function_exists( 'ini_get' ) ) {
  304. $memory_limit = ini_get( 'memory_limit' );
  305. } else {
  306. // Sensible default.
  307. $memory_limit = '128M';
  308. }
  309. if ( ! $memory_limit || -1 === intval( $memory_limit ) ) {
  310. // Unlimited, set to 32GB.
  311. $memory_limit = '32000M';
  312. }
  313. return intval( $memory_limit ) * 1024 * 1024;
  314. }
  315. /**
  316. * Time exceeded.
  317. *
  318. * Ensures the batch never exceeds a sensible time limit.
  319. * A timeout limit of 30s is common on shared hosting.
  320. *
  321. * @return bool
  322. */
  323. protected function time_exceeded() {
  324. $finish = $this->start_time + apply_filters( $this->identifier . '_default_time_limit', 20 ); // 20 seconds
  325. $return = false;
  326. if ( time() >= $finish ) {
  327. $return = true;
  328. }
  329. return apply_filters( $this->identifier . '_time_exceeded', $return );
  330. }
  331. /**
  332. * Complete.
  333. *
  334. * Override if applicable, but ensure that the below actions are
  335. * performed, or, call parent::complete().
  336. */
  337. protected function complete() {
  338. // Unschedule the cron healthcheck.
  339. $this->clear_scheduled_event();
  340. }
  341. /**
  342. * Schedule cron healthcheck
  343. *
  344. * @access public
  345. * @param mixed $schedules Schedules.
  346. * @return mixed
  347. */
  348. public function schedule_cron_healthcheck( $schedules ) {
  349. $interval = apply_filters( $this->identifier . '_cron_interval', 5 );
  350. if ( property_exists( $this, 'cron_interval' ) ) {
  351. $interval = apply_filters( $this->identifier . '_cron_interval', $this->cron_interval );
  352. }
  353. // Adds every 5 minutes to the existing schedules.
  354. $schedules[ $this->identifier . '_cron_interval' ] = array(
  355. 'interval' => MINUTE_IN_SECONDS * $interval,
  356. 'display' => sprintf( __( 'Every %d Minutes' ), $interval ),
  357. );
  358. return $schedules;
  359. }
  360. /**
  361. * Handle cron healthcheck
  362. *
  363. * Restart the background process if not already running
  364. * and data exists in the queue.
  365. */
  366. public function handle_cron_healthcheck() {
  367. if ( $this->is_process_running() ) {
  368. // Background process already running.
  369. exit;
  370. }
  371. if ( $this->is_queue_empty() ) {
  372. // No data to process.
  373. $this->clear_scheduled_event();
  374. exit;
  375. }
  376. $this->handle();
  377. exit;
  378. }
  379. /**
  380. * Schedule event
  381. */
  382. protected function schedule_event() {
  383. if ( ! wp_next_scheduled( $this->cron_hook_identifier ) ) {
  384. wp_schedule_event( time(), $this->cron_interval_identifier, $this->cron_hook_identifier );
  385. }
  386. }
  387. /**
  388. * Clear scheduled event
  389. */
  390. protected function clear_scheduled_event() {
  391. $timestamp = wp_next_scheduled( $this->cron_hook_identifier );
  392. if ( $timestamp ) {
  393. wp_unschedule_event( $timestamp, $this->cron_hook_identifier );
  394. }
  395. }
  396. /**
  397. * Cancel Process
  398. *
  399. * Stop processing queue items, clear cronjob and delete batch.
  400. *
  401. */
  402. public function cancel_process() {
  403. if ( ! $this->is_queue_empty() ) {
  404. $batch = $this->get_batch();
  405. $this->delete( $batch->key );
  406. wp_clear_scheduled_hook( $this->cron_hook_identifier );
  407. }
  408. }
  409. /**
  410. * Task
  411. *
  412. * Override this method to perform any actions required on each
  413. * queue item. Return the modified item for further processing
  414. * in the next pass through. Or, return false to remove the
  415. * item from the queue.
  416. *
  417. * @param mixed $item Queue item to iterate over.
  418. *
  419. * @return mixed
  420. */
  421. abstract protected function task( $item );
  422. }
  423. }