fetch_action( (int) $action_id ); $args = array_values( $action->get_args() ); if ( self::PRODUCT_HOOK !== $action->get_hook() || 4 !== count( $args ) ) { return; } list( $run_id, $gen, $lane, $position ) = array( (string) $args[0], (int) $args[1], (int) $args[2], (int) $args[3] ); if ( ! Options::is_connected() || ! SyncLock::is_held_by( $run_id, $gen ) ) { return; } $queue = self::queue(); if ( ! isset( $queue['ids'][ $position ] ) || ! SyncState::is_lane_working_on( $lane, $run_id, $gen, (int) $queue['ids'][ $position ] ) ) { return; } Log::error( sprintf( 'Approved-product sync: the process died while writing product %s (action %d); its lane goes on.', $queue['ids'][ $position ], (int) $action_id ) ); SyncState::lane_handled( $lane, $run_id, $gen, (int) $queue['ids'][ $position ], null ); self::next( $run_id, $gen, $lane, $position + self::LANES, $queue['to'] ); } /** * Action Scheduler callback, queued by every lane that runs out: the last one to finish goes on. * * @param mixed $run_id Run id. * @param mixed $gen Generation. */ public static function finish( $run_id, $gen ): void { $run_id = (string) $run_id; $gen = (int) $gen; if ( ! SyncLock::is_held_by( $run_id, $gen ) || ! SyncState::lanes_done( $gen, $run_id ) ) { return; // A lane still working queues its own finish. } if ( ! SyncLock::advance( $run_id, $gen ) ) { return; // Another finish of this generation won. } $next = $gen + 1; $queue = self::queue(); $fresh = Options::is_connected() ? SyncApi::pending() : null; $taken = array_flip( $queue['ids'] ); $new = array(); foreach ( null === $fresh ? array() : $fresh as $id ) { if ( ! isset( $taken[ $id ] ) ) { $new[] = $id; } } if ( array() === $new ) { self::complete( $run_id, $next ); return; } $ids = array_merge( $queue['ids'], $new ); self::save_queue( $ids ); SyncState::add_total( count( $new ) ); self::launch( $run_id, $next, count( $queue['ids'] ), count( $ids ) ); } /** * Disconnect, deactivation: nothing more is written, nothing is scheduled, nothing shows as running. */ public static function stop(): void { SyncSchedule::unschedule(); self::unschedule_actions(); SyncLock::clear(); SyncState::clear(); delete_option( Options::SYNC_QUEUE ); } /** * Starts generation $gen on every lane: lane k takes positions $from + k, $from + k + 3, … below $to. Called only * once this run holds the lock for $gen (after acquire() or advance()), so the lanes it begins are this run's. */ private static function launch( string $run_id, int $gen, int $from, int $to ): void { for ( $lane = 0; $lane < self::LANES; $lane++ ) { $position = $from + $lane; SyncState::lane_begin( $lane, $run_id, $gen, $position >= $to ); if ( $position < $to ) { as_enqueue_async_action( self::PRODUCT_HOOK, array( $run_id, $gen, $lane, $position ), self::GROUP ); } } } private static function next( string $run_id, int $gen, int $lane, int $position, int $to ): void { if ( $position < $to ) { as_enqueue_async_action( self::PRODUCT_HOOK, array( $run_id, $gen, $lane, $position ), self::GROUP ); return; } SyncState::lane_done( $lane, $run_id, $gen ); as_enqueue_async_action( self::FINISH_HOOK, array( $run_id, $gen ), self::GROUP ); } private static function complete( string $run_id, int $gen ): void { SyncState::complete(); EnrichedApi::flush(); delete_option( Options::SYNC_QUEUE ); SyncLock::release( $run_id, $gen ); } /** * Writes one product with no user and product-only permissions (SyncPermissions), never as the key's owner or any * administrator. The owner is only checked: a store whose connecting user was removed or can no longer manage * WooCommerce writes nothing, which is how the merchant revokes the sync without disconnecting. */ private static function as_sync( string $product_id, string $run_id ): string { if ( 0 === self::owner() ) { Log::error( 'Approved-product sync: the user who connected the store can no longer manage WooCommerce; product ' . $product_id . ' skipped.' ); return 'skipped'; } return SyncPermissions::run( (int) $product_id, static function () use ( $product_id, $run_id ): string { return SyncProduct::sync( $product_id, $run_id ); } ); } /** * The user who connected the store, while they may still manage WooCommerce; 0 otherwise. Checked, never used to * write. */ private static function owner(): int { $owner = ( new KeyMinter() )->owner( Options::key_id() ); return $owner > 0 && user_can( $owner, 'manage_woocommerce' ) ? $owner : 0; } private static function name( string $product_id ): string { $product = wc_get_product( (int) $product_id ); return $product instanceof \WC_Product ? $product->get_name() : '#' . $product_id; } /** * @param array $ids Every id this run has taken, in order. */ private static function save_queue( array $ids ): void { update_option( Options::SYNC_QUEUE, array( 'ids' => $ids, 'to' => count( $ids ), ), false ); } /** * @return array{ids: array, to: int} */ private static function queue(): array { $queue = Options::fresh( Options::SYNC_QUEUE ); $ids = is_array( $queue ) && isset( $queue['ids'] ) && is_array( $queue['ids'] ) ? array_values( array_map( 'strval', $queue['ids'] ) ) : array(); return array( 'ids' => $ids, 'to' => is_array( $queue ) && isset( $queue['to'] ) ? min( (int) $queue['to'], count( $ids ) ) : 0, ); } private static function unschedule_actions(): void { if ( ! function_exists( 'as_unschedule_all_actions' ) ) { return; } foreach ( array( self::PRODUCT_HOOK, self::FINISH_HOOK ) as $hook ) { // @phpstan-ignore-next-line argument.type -- null means "any args" to Action Scheduler (see OrderEvents::unschedule_all()). as_unschedule_all_actions( $hook, null, self::GROUP ); } } }