can_sync() ) { WP_CLI::error( 'Market Sync is not connected. Restore the connection before scanning for pending orders.' ); } if ( ! function_exists( 'as_enqueue_async_action' ) || ! function_exists( 'as_get_scheduled_actions' ) ) { WP_CLI::error( 'Action Scheduler is unavailable; no pending order scan was started.' ); } $job = new Pending_Orders_Job(); // Taken before anything is queued, so it is never later than the // `collected_at` a successful scan writes, and so every import action // this run creates gets a higher ID than $action_id. $started_at = time(); $action_id = 0; $listed = Pending_Order_Imports::get(); try { // A one-off action, deliberately not recurring: Scheduler only ever // inspects recurring actions on this hook, so the hourly series is // left exactly as it is. The runner below may also pick up that // hourly action if it happens to be due, which costs one repeated // scan and nothing else — the scan replaces its list wholesale and // Order_Import_Job tolerates being queued twice for one order. $action_id = (int) as_enqueue_async_action( $job->hook(), [], $job->action_group() ); if ( $action_id <= 0 ) { throw new \RuntimeException( 'Action Scheduler rejected the pending order scan.' ); } $this->run_queue( $job->hook(), 'pending order scan' ); $failed = $this->failed_actions_since( $job->hook(), $job->action_group(), $action_id ); if ( [] !== $failed ) { throw new \RuntimeException( sprintf( 'Pending order scan action #%d failed; an automatic retry may be pending.', $failed[0] ) ); } $listed = Pending_Order_Imports::get(); // Pending_Orders_Job::handle() returns early — without throwing — when // the store is not connected or a SaaS page errors, so the action // completes either way. Only a scan that actually published stamps a // fresh `collected_at`, including for an empty backlog, so this is // what separates a real result from a silently skipped one. if ( $listed['collected_at'] < $started_at ) { throw new \RuntimeException( 'The scan published no result; see the orders log for the reason.' ); } } catch ( Throwable $exception ) { Log_Helper::by_area( Log_Helper::LOG_ORDERS )->error( 'Manual pending order scan could not be completed.', [ 'action_id' => $action_id, 'reason' => $exception->getMessage(), ] ); WP_CLI::error( 'Pending order scan failed: ' . $exception->getMessage() ); } Log_Helper::by_area( Log_Helper::LOG_ORDERS )->info( 'Manual pending order scan completed.', [ 'action_id' => $action_id, 'listed' => count( $listed['ids'] ), 'total' => $listed['total'], 'truncated' => $listed['truncated'], 'run_imports' => $run_imports, ] ); WP_CLI::log( sprintf( 'Listed %1$d of %2$d pending order(s)%3$s.', count( $listed['ids'] ), $listed['total'], $listed['truncated'] ? ' (backlog truncated; the next scan continues it)' : '' ) ); if ( [] === $listed['ids'] ) { WP_CLI::success( 'No marketplace orders are waiting to be imported.' ); return; } if ( ! $run_imports ) { WP_CLI::success( sprintf( '%d order import(s) queued; they run on the next queue pass. Pass --run-imports to run them now.', count( $listed['ids'] ) ) ); return; } $this->run_imports( $action_id ); WP_CLI::success( sprintf( '%d order import(s) queued and run.', count( $listed['ids'] ) ) ); } /** * Runs the imports the scan just queued, reporting failures without failing * the command: the scan itself already succeeded, and an order that cannot * import (unlinked products being the usual cause) is a data problem to read * in the log, not a reason to discard the scan result. */ private function run_imports( int $scan_action_id ): void { try { $this->run_queue( Action_Registry::HOOK_IMPORT_ORDER, 'order imports' ); } catch ( Throwable $exception ) { Log_Helper::by_area( Log_Helper::LOG_ORDERS )->error( 'Manual order import run could not be completed.', [ 'reason' => $exception->getMessage() ] ); WP_CLI::warning( 'Order imports could not be run: ' . $exception->getMessage() ); return; } $failed = $this->failed_actions_since( Action_Registry::HOOK_IMPORT_ORDER, Action_Registry::GROUP, $scan_action_id ); if ( [] === $failed ) { return; } WP_CLI::warning( sprintf( '%d order import(s) failed; see the orders log. Failed action(s): %s.', count( $failed ), implode( ', ', array_map( static fn( int $id ): string => '#' . $id, $failed ) ) ) ); } /** * Drives Action Scheduler synchronously for one hook. * * @throws \RuntimeException When the runner itself could not complete. */ private function run_queue( string $hook, string $subject ): void { $runner = WP_CLI::runcommand( 'action-scheduler run', [ 'launch' => false, 'exit_error' => false, 'return' => 'all', 'command_args' => [ '--hooks=' . $hook, '--batch-size=100', ], ] ); if ( is_object( $runner ) && 0 === (int) ( $runner->return_code ?? 1 ) ) { return; } $reason = is_object( $runner ) ? trim( (string) ( $runner->stderr ?? '' ) ) : ''; throw new \RuntimeException( // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Caught within this CLI command and sent only to logs and WP_CLI plain-text output. '' !== $reason ? $reason : sprintf( 'The Action Scheduler runner failed for the %s.', $subject ) ); } /** * Failed actions on a hook created no earlier than a known action. * * Action IDs increase, so comparing against the scan's own ID scopes this to * work this command started and ignores older failures already reported. * * @return int[] */ private function failed_actions_since( string $hook, string $group, int $since_action_id ): array { $actions = as_get_scheduled_actions( [ 'hook' => $hook, 'group' => $group, 'status' => ActionScheduler_Store::STATUS_FAILED, 'per_page' => 100, 'orderby' => 'date', 'order' => 'DESC', ] ); $failed = []; foreach ( array_keys( $actions ) as $action_id ) { if ( (int) $action_id < $since_action_id ) { continue; } $failed[] = (int) $action_id; } return $failed; } }