drained ) { return; } $this->drained = true; if ( ! $this->shouldDrainInline() ) { return; } $this->closeResponse(); $this->run( self::BATCH_SIZE, self::INLINE_BUDGET ); } /** * Drain the queue. Used by the worker endpoint and WP-CLI. * * @param int $limit Maximum jobs to process. * @param float $budget Wall-clock ceiling in seconds. * * @return array{processed:int, failed:int} */ public function run( int $limit = self::BATCH_SIZE, float $budget = self::WORKER_BUDGET ): array { $started = microtime( true ); $processed = 0; $failed = 0; Queue::recoverStalled(); update_option( self::LAST_RUN_OPTION, time(), false ); foreach ( Queue::claim( $limit ) as $row ) { if ( ( microtime( true ) - $started ) > $budget ) { // Hand the rest back rather than risking a timeout mid-job. Queue::release( (int) $row['id'], (int) $row['attempts'], 'Worker budget exhausted before start.' ); continue; } // Nothing is listening in this request. Hand it back for a request that // does register the handler, instead of firing a hook into the void // and deleting the job as though it had run. if ( ! has_action( (string) $row['hook'] ) ) { Queue::defer( $row, self::UNHANDLED_DELAY ); continue; } if ( $this->runJob( $row ) ) { ++$processed; } else { ++$failed; } } /** * Fires after every queue run, worker or inline. * * For self-arming periodic jobs. A job that re-queues itself at the end of * each run has one way to get started — something has to queue the first * one — and until 0.28.0 that something was an admin opening one of the * plugin's screens. A store that switched a module on and never opened * those screens never got the job at all. Re-arming here, with the * idempotent `Queue::pushUnique()`, costs one indexed lookup per run and * only in requests that are already doing queue work. * * @since 0.28.0 */ do_action( 'rahkar_wp_queue_ran' ); // Correct the watermark now that the table has changed, so an emptied // queue stops waking the dispatcher on subsequent requests. Queue::refreshWatermark(); return [ 'processed' => $processed, 'failed' => $failed, ]; } /** * Execute one job. * * @param array $row Job row. * * @return bool True when the job completed. */ private function runJob( array $row ): bool { $id = (int) $row['id']; $hook = (string) $row['hook']; $attempts = (int) $row['attempts'] + 1; $payload = json_decode( (string) $row['payload'], true ); $payload = is_array( $payload ) ? $payload : []; try { /** * Fires when a queued job runs. * * The hook name is whatever was passed to {@see Queue::push()}; handlers * are registered by the module that queued the work. * * @param array $payload Job arguments. */ // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- The hook is whatever the caller queued; jobs of add-ons carry their own prefixes. do_action( $hook, $payload ); Queue::complete( $id ); return true; } catch ( \Throwable $e ) { Queue::release( $id, $attempts, $e->getMessage() ); Logger::error( 'queue', 'Job threw an exception.', [ 'id' => $id, 'hook' => $hook, 'error' => $e->getMessage(), ] ); return false; } } /** * Is an inline drain appropriate for this request? */ private function shouldDrainInline(): bool { // A cron or CLI context has its own drain path. if ( Context::is( Context::ANY_BACKGROUND ) ) { return false; } // Answered from the autoloaded watermark — no query. On a site with an // empty queue this is where every request stops, which is what keeps the // drain hook free to register unconditionally. if ( ! Queue::maybeHasDue() ) { return false; } /** * Filters whether the queue may drain after this response. * * Lets a site opt out on specific requests — for instance to keep a * latency-sensitive REST endpoint's worker free. * * @param bool $allowed Whether to drain. */ return (bool) apply_filters( 'rahkar_wp_allow_inline_drain', true ); } /** * Send the response and detach from the visitor. * * `fastcgi_finish_request()` is the clean path and exists on php-fpm, which is * effectively universal on the hosts this targets. The fallback flushes the * buffers and relies on `Connection: close`, which is less reliable but still * better than making the visitor wait on an SMS API. */ private function closeResponse(): void { ignore_user_abort( true ); if ( function_exists( 'fastcgi_finish_request' ) ) { fastcgi_finish_request(); return; } if ( function_exists( 'litespeed_finish_request' ) ) { litespeed_finish_request(); return; } if ( ! headers_sent() ) { header( 'Connection: close' ); header( 'Content-Encoding: none' ); } while ( ob_get_level() > 0 ) { ob_end_flush(); } flush(); } /** * Poke the worker endpoint without waiting for it. * * Third trigger. Used when work is queued that should not wait for the next * visitor — and rate-limited, because a burst of orders must not turn into a * burst of loopback requests. */ public function pokeLoopback(): void { $last = (int) get_option( self::LAST_RUN_OPTION, 0 ); if ( ( time() - $last ) < self::LOOPBACK_COOLDOWN ) { return; } // A request to this same site. Certificate verification follows the filter // WordPress core uses for its own loopbacks (`spawn_cron()`, the site // health checks), rather than being switched off here: local and staging // sites often run self-signed certificates, and a host that verifies its // own certificate can say so in one place for every loopback. wp_remote_post( WorkerEndpoint::url(), [ 'timeout' => 0.01, 'blocking' => false, // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WordPress core's own filter, used as core uses it. 'sslverify' => (bool) apply_filters( 'https_local_ssl_verify', false ), ] ); } /** * Seconds since the queue last ran, or null when it never has. */ public static function secondsSinceLastRun(): ?int { $last = (int) get_option( self::LAST_RUN_OPTION, 0 ); return $last > 0 ? time() - $last : null; } }