process_batch( (array) $post_ids ); } ); add_action( self::ROLES_HOOK, static function () use ( $connection ): void { ( new Roles( $connection ) )->mirror(); } ); add_action( self::ADOPT_HOOK, static function () use ( $connection ): void { ( new Sync( $connection ) )->adopt(); } ); add_action( self::POST_HOOK, static function ( $post_id ) use ( $connection ): void { $sync = new Sync( $connection ); $result = $sync->sync_post( (int) $post_id ); unset( $result ); } ); } /** * Whether Action Scheduler is available. */ public static function has_action_scheduler(): bool { return function_exists( 'as_schedule_single_action' ) && function_exists( 'as_get_scheduled_actions' ); } /** * Queues one batch of posts. * * @param int[] $post_ids The batch. */ public function add_batch( array $post_ids ): void { if ( array() === $post_ids ) { return; } $this->schedule( self::BATCH_HOOK, array( array_map( 'intval', $post_ids ) ) ); } /** * Queues one post, replacing any pending action for the same post. * * An editor saving three times in a minute should produce one sync, not * three: the third would send the same document the first two did. * * @param int $post_id The post. */ public function add_post( int $post_id ): void { $args = array( $post_id ); if ( self::has_action_scheduler() ) { if ( ! as_has_scheduled_action( self::POST_HOOK, $args, self::GROUP ) ) { as_schedule_single_action( time() + 10, self::POST_HOOK, $args, self::GROUP ); } return; } if ( ! wp_next_scheduled( self::POST_HOOK, $args ) ) { wp_schedule_single_event( time() + 10, self::POST_HOOK, $args ); } } /** * Queues the role mirror, to run before the batches. * * No arguments and one at a time: mirroring twice would be the same calls * with the same answers. */ public function add_roles(): void { if ( self::has_action_scheduler() ) { if ( ! as_has_scheduled_action( self::ROLES_HOOK, array(), self::GROUP ) ) { as_schedule_single_action( time(), self::ROLES_HOOK, array(), self::GROUP ); } return; } if ( ! wp_next_scheduled( self::ROLES_HOOK ) ) { wp_schedule_single_event( time(), self::ROLES_HOOK ); } } /** * Queues the adoption pass, to run after the roles and before the batches. */ public function add_adopt(): void { if ( self::has_action_scheduler() ) { if ( ! as_has_scheduled_action( self::ADOPT_HOOK, array(), self::GROUP ) ) { as_schedule_single_action( time(), self::ADOPT_HOOK, array(), self::GROUP ); } return; } if ( ! wp_next_scheduled( self::ADOPT_HOOK ) ) { wp_schedule_single_event( time(), self::ADOPT_HOOK ); } } /** * Whether anything is still waiting. */ public function has_pending(): bool { return $this->pending_count() > 0; } /** * How many actions are waiting. * * Action Scheduler can count them; WP-Cron cannot, so its events are counted * out of the cron array by hand. */ public function pending_count(): int { if ( self::has_action_scheduler() ) { $pending = as_get_scheduled_actions( array( 'hook' => self::BATCH_HOOK, 'status' => \ActionScheduler_Store::STATUS_PENDING, 'group' => self::GROUP, 'per_page' => 0, ), 'ids' ); $single = as_get_scheduled_actions( array( 'hook' => self::POST_HOOK, 'status' => \ActionScheduler_Store::STATUS_PENDING, 'group' => self::GROUP, 'per_page' => 0, ), 'ids' ); $roles = as_get_scheduled_actions( array( 'hook' => self::ROLES_HOOK, 'status' => \ActionScheduler_Store::STATUS_PENDING, 'group' => self::GROUP, 'per_page' => 0, ), 'ids' ); $adopt = as_get_scheduled_actions( array( 'hook' => self::ADOPT_HOOK, 'status' => \ActionScheduler_Store::STATUS_PENDING, 'group' => self::GROUP, 'per_page' => 0, ), 'ids' ); return count( (array) $pending ) + count( (array) $single ) + count( (array) $roles ) + count( (array) $adopt ); } $count = 0; foreach ( (array) _get_cron_array() as $events ) { foreach ( array( self::ROLES_HOOK, self::ADOPT_HOOK, self::BATCH_HOOK, self::POST_HOOK ) as $hook ) { $count += count( (array) ( $events[ $hook ] ?? array() ) ); } } return $count; } /** * Drops everything queued. Used when a full sync starts, and on disconnect. */ public function cancel_all(): void { if ( self::has_action_scheduler() ) { as_unschedule_all_actions( self::ROLES_HOOK, null, self::GROUP ); as_unschedule_all_actions( self::ADOPT_HOOK, null, self::GROUP ); as_unschedule_all_actions( self::BATCH_HOOK, null, self::GROUP ); as_unschedule_all_actions( self::POST_HOOK, null, self::GROUP ); return; } // wp_unschedule_hook(), not wp_clear_scheduled_hook(): the latter only // matches events whose arguments equal the ones it is given, so called // without any it clears nothing here — every action this class schedules // carries a post id or a batch. wp_unschedule_hook( self::ROLES_HOOK ); wp_unschedule_hook( self::ADOPT_HOOK ); wp_unschedule_hook( self::BATCH_HOOK ); wp_unschedule_hook( self::POST_HOOK ); } /** * Runs queued work now, in the foreground. * * Action Scheduler has its own runner, so for it this just asks that runner to * get on with it. The fallback path is hand-rolled, and the order there is not * incidental: **each action is unscheduled before it runs**, not after. * * Running first would leave the action still pending while its own handler * asks "is anything left?" — which is how a finished sync failed to notice it * had finished, and the progress on screen stuck at the last batch forever. * Claiming the work first is also what Action Scheduler does, so both paths * now behave the same way. * * Takes the Sync rather than the Connection so a test can run the real queue * against a workspace that is not the network. * * @param Sync $sync The service to run the work with. * * @return int How many actions ran. */ public function run_now( Sync $sync ): int { $ran = 0; if ( self::has_action_scheduler() ) { foreach ( array( self::ROLES_HOOK, self::ADOPT_HOOK, self::BATCH_HOOK, self::POST_HOOK ) as $hook ) { $ids = as_get_scheduled_actions( array( 'hook' => $hook, 'status' => \ActionScheduler_Store::STATUS_PENDING, 'group' => self::GROUP, 'per_page' => 0, ), 'ids' ); foreach ( (array) $ids as $id ) { \ActionScheduler::runner()->process_action( (int) $id ); ++$ran; } } return $ran; } // Claim everything first, then run it: see the note above. $claimed = array(); foreach ( (array) _get_cron_array() as $timestamp => $events ) { // Roles first: the batches tag entries, and the roles they are // tagged with should exist by the time they do. foreach ( array( self::ROLES_HOOK, self::ADOPT_HOOK, self::BATCH_HOOK, self::POST_HOOK ) as $hook ) { foreach ( (array) ( $events[ $hook ] ?? array() ) as $event ) { $args = (array) ( $event['args'] ?? array() ); $claimed[] = array( $hook, $args ); wp_unschedule_event( (int) $timestamp, $hook, $args ); } } } foreach ( $claimed as list( $hook, $args ) ) { if ( self::ROLES_HOOK === $hook ) { $sync->mirror_roles(); } elseif ( self::ADOPT_HOOK === $hook ) { $sync->adopt(); } elseif ( self::BATCH_HOOK === $hook ) { $sync->process_batch( (array) ( $args[0] ?? array() ) ); } else { $sync->sync_post( (int) ( $args[0] ?? 0 ) ); } ++$ran; } return $ran; } /** * Schedules one action, through whichever scheduler exists. * * @param string $hook The hook. * @param array $args Its arguments. */ private function schedule( string $hook, array $args ): void { if ( self::has_action_scheduler() ) { as_schedule_single_action( time() + 5, $hook, $args, self::GROUP ); return; } wp_schedule_single_event( time() + 5, $hook, $args ); } }