prefix . 'mybot_queue'; } /** * Push a job onto the queue. * * @param string $type Job type. * @param array $payload Attached data. * @param int $delay Number of seconds to wait before running. * @return int|false Job ID, or false on failure. */ public function push( $type, $payload, $delay = 0 ) { global $wpdb; $available_at = gmdate( 'Y-m-d H:i:s', current_time( 'timestamp' ) + max( 0, (int) $delay ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $inserted = $wpdb->insert( self::table(), array( 'job_type' => substr( sanitize_key( $type ), 0, 48 ), 'payload' => wp_json_encode( $payload ), 'status' => 'pending', 'available_at' => $available_at, 'created_at' => current_time( 'mysql' ), ), array( '%s', '%s', '%s', '%s', '%s' ) ); if ( ! $inserted ) { return false; } // Make sure cron runs soon, in case the schedule has drifted. if ( ! wp_next_scheduled( self::CRON_HOOK ) ) { wp_schedule_single_event( time() + 30, self::CRON_HOOK ); } return (int) $wpdb->insert_id; } /** * Claim a batch of jobs for processing. * * A conditional status UPDATE is used so that two cron processes never handle * the same job. * * @param int $limit Maximum number of jobs. * @return array List of claimed jobs. */ protected function claim( $limit = self::BATCH_SIZE ) { global $wpdb; $table = self::table(); $now = current_time( 'mysql' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $ids = $wpdb->get_col( $wpdb->prepare( 'SELECT id FROM %i WHERE status = %s AND available_at <= %s ORDER BY id ASC LIMIT %d', $table, 'pending', $now, max( 1, (int) $limit ) ) ); if ( empty( $ids ) ) { return array(); } $claimed = array(); foreach ( $ids as $id ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $updated = $wpdb->query( $wpdb->prepare( 'UPDATE %i SET status = %s WHERE id = %d AND status = %s', $table, 'processing', (int) $id, 'pending' ) ); if ( 1 === (int) $updated ) { $claimed[] = (int) $id; } } if ( empty( $claimed ) ) { return array(); } $placeholders = implode( ',', array_fill( 0, count( $claimed ), '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, job_type, payload, attempts FROM %i WHERE id IN ({$placeholders}) ORDER BY id ASC", array_merge( array( $table ), $claimed ) ), ARRAY_A ); return is_array( $rows ) ? $rows : array(); } /** * Process a batch of jobs. Called by WP-Cron. * * @return int Number of processed jobs. */ public function process_batch() { $started = time(); /** * Map of job types to handler functions. * * @param array $handlers Array of type => callable. */ $handlers = apply_filters( 'mybot_queue_handlers', array() ); $jobs = $this->claim( self::BATCH_SIZE ); $handled = 0; $total = count( $jobs ); for ( $index = 0; $index < $total; $index++ ) { $job = $jobs[ $index ]; // Stop before the server terminates the request. if ( ( time() - $started ) > self::TIME_BUDGET ) { /* * Release this job and every job after it. claim() marked the whole batch * "processing" up front, and nothing else ever resets that state, so releasing * only the current job left the rest stuck in "processing" forever: their posts * were never embedded, the progress bar stopped, and the queue never moved again. */ for ( $rest = $index; $rest < $total; $rest++ ) { $this->release( (int) $jobs[ $rest ]['id'] ); } break; } $type = (string) $job['job_type']; if ( ! isset( $handlers[ $type ] ) || ! is_callable( $handlers[ $type ] ) ) { $this->fail( (int) $job['id'], 'No handler registered for job type: ' . $type ); continue; } $payload = json_decode( (string) $job['payload'], true ); if ( ! is_array( $payload ) ) { $payload = array(); } try { call_user_func( $handlers[ $type ], $payload, $job ); $this->complete( (int) $job['id'] ); ++$handled; } catch ( Exception $e ) { $this->retry_or_fail( $job, $e->getMessage() ); } catch ( Error $e ) { $this->retry_or_fail( $job, $e->getMessage() ); } } return $handled; } /** * Mark the job as completed. * * @param int $id Job ID. * @return void */ public function complete( $id ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->update( self::table(), array( 'status' => 'done' ), array( 'id' => (int) $id ), array( '%s' ), array( '%d' ) ); } /** * Return a job to the pending state. * * @param int $id Job ID. * @return void */ public function release( $id ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->update( self::table(), array( 'status' => 'pending' ), array( 'id' => (int) $id ), array( '%s' ), array( '%d' ) ); } /** * Retry the job or mark it as failed. * * @param array $job Job record. * @param string $error Error message. * @return void */ public function retry_or_fail( $job, $error ) { global $wpdb; $id = (int) $job['id']; $attempts = (int) $job['attempts'] + 1; if ( $attempts >= self::MAX_ATTEMPTS ) { $this->fail( $id, $error, $attempts ); return; } // Progressive backoff: 1 minute, 4 minutes, 9 minutes. $delay = $attempts * $attempts * 60; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->update( self::table(), array( 'status' => 'pending', 'attempts' => $attempts, 'last_error' => substr( (string) $error, 0, 1000 ), 'available_at' => gmdate( 'Y-m-d H:i:s', current_time( 'timestamp' ) + $delay ), ), array( 'id' => $id ), array( '%s', '%d', '%s', '%s' ), array( '%d' ) ); } /** * Mark the job as permanently failed. * * @param int $id Job ID. * @param string $error Error message. * @param int $attempts Number of attempts made. * @return void */ public function fail( $id, $error, $attempts = self::MAX_ATTEMPTS ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->update( self::table(), array( 'status' => 'failed', 'attempts' => (int) $attempts, 'last_error' => substr( (string) $error, 0, 1000 ), ), array( 'id' => (int) $id ), array( '%s', '%d', '%s' ), array( '%d' ) ); } /** * Number of pending jobs. * * @param string $type Filter by type; leave empty to count all. * @return int */ public function pending_count( $type = '' ) { global $wpdb; $table = self::table(); if ( '' !== $type ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE status = %s AND job_type = %s', $table, 'pending', $type ) ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE status = %s', $table, 'pending' ) ); } /** * Statistics grouped by status. * * @return array */ public function stats() { global $wpdb; $table = self::table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT status, COUNT(*) AS total FROM %i GROUP BY status', $table ), ARRAY_A ); $stats = array( 'pending' => 0, 'processing' => 0, 'done' => 0, 'failed' => 0, ); if ( is_array( $rows ) ) { foreach ( $rows as $row ) { $status = (string) $row['status']; if ( isset( $stats[ $status ] ) ) { $stats[ $status ] = (int) $row['total']; } } } return $stats; } /** * Delete finished jobs while keeping failed jobs for inspection. * * @param int $keep_days Number of days to keep finished records. * @return int Number of deleted rows. */ public function purge_finished( $keep_days = 3 ) { global $wpdb; $table = self::table(); $cutoff = gmdate( 'Y-m-d H:i:s', current_time( 'timestamp' ) - ( max( 1, (int) $keep_days ) * DAY_IN_SECONDS ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return (int) $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE status = %s AND created_at < %s', $table, 'done', $cutoff ) ); } /** * List of recent failed jobs, used by the diagnostics page. * * @param int $limit Number of records. * @return array */ public function recent_failures( $limit = 10 ) { global $wpdb; $table = self::table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT id, job_type, attempts, last_error, created_at FROM %i WHERE status = %s ORDER BY id DESC LIMIT %d', $table, 'failed', max( 1, min( 50, absint( $limit ) ) ) ), ARRAY_A ); return is_array( $rows ) ? $rows : array(); } /** * Delete the entire queue, used when an administrator wants to clean it up. * * @return int Number of deleted rows. */ public function flush_all() { global $wpdb; $table = self::table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return (int) $wpdb->query( $wpdb->prepare( 'DELETE FROM %i', $table ) ); } }