В корпоративной разработке на WordPress часто возникают задачи массовой обработки данных: регулярный импорт 500 000 товаров из ERP/1C, пересчет складских остатков, миграция метаполей миллионов заказов WooCommerce или очистка устаревших транзиентов и логов аудита.
Попытка выполнять подобные ресурсоемкие операции через веб-интерфейс администратора (AJAX-запросы или REST API) неизбежно приводит к аварийным ситуациям:
max_execution_time): Браузерный скрипт падает с ошибкой Maximum execution time of 30 seconds exceeded, оставляя базу данных в полу-обновленном, поврежденном состоянии.$wpdb->queries) и сохраняет каждый загруженный объект в глобальный массив кэша ($wp_object_cache). При обработке массива из 50 000 постов PHP исчерпывает лимит оперативной памяти (memory_limit) и процесс аварийно завершается (Fatal error: Allowed memory size exhausted).UPDATE запросов по всей таблице замораживает транзакции чекаута реальных покупателей интернет-магазина.systemd / crontab без риска несанкционированного вызова злоумышленниками из интернета.Промышленный стандарт решения — создание собственных консольных команд на базе WP-CLI. Команды выполняются в CLI-контексте без сетевых таймаутов, поддерживают курсорный батчинг, интерактивное подтверждение и форматированный вывод.
При разработке консольных команд для обработки больших массивов данных действуют два обязательных правила:
OFFSET 50000 в SQL-запросах — она заставляет СУБД сканировать все предыдущие строки, приводя к квадратичной деградации скорости. Выборка порций данных (Batching) должна производиться строго по первичному ключу: WHERE id > $last_processed_id ORDER BY id ASC LIMIT 500.$wpdb->queries, очищать внутренний кэш постов через clean_post_cache() и вызывать сборщик мусора PHP gc_collect_cycles().Кастомная команда WP-CLI регистрируется через фасад WP_CLI::add_command(). Парсер WP-CLI анализирует PHPDoc-аннотации над методами класса (Synopsis) и автоматически генерирует справку, проверяет типы аргументов, обязательность параметров и допустимые значения флагов еще до передачи управления бизнес-логике.
Код команды изолируется в плагине или mu-плагине. Файл подключается строго при наличии константы WP_CLI, защищая сайт от сбоев при веб-запросах.
Регистрация происходит на хуке cli_init ядра WordPress.
Над методом класса описываются позиционные аргументы (<action>), именованные параметры ([--batch-size=<int>]) и булевы флаги ([--dry-run]).
WP-CLI автоматически отсекает невалидные вызовы с кодом ошибки 1 до запуска логики.
Деструктивные команды обязаны поддерживать флаг безопасного тестирования --dry-run и запрашивать подтверждение у оператора при запуске без флага --force.
Оператор видит сводку предстоящих изменений перед фиксацией в базе.
Данные извлекаются порциями по 500–1000 записей. Прогресс визуализируется консольным баром \WP_CLI\Utils\make_progress_bar.
После каждого батча освобождается оперативная память через stop_the_insanity().
Результаты выводятся в виде интерактивной таблицы, JSON или CSV через \WP_CLI\Utils\format_items. Скрипт завершается со стандартным exit code 0 или 1.
Это позволяет встраивать команду в сложные Bash-скрипты и CI/CD пайплайны.
Оператор вызывает wp agenticlab users-sync --batch-size=500 --dry-run в терминале
Парсер WP-CLI проверяет PHPDoc-правила, типы аргументов и обязательные параметры
WordPress загружает ядро, активные плагины и устанавливает соединение с MySQL
Выборка по ID курсора, обработка записей, тик прогресс-бара и сброс $wpdb->queries
Отрисовка итоговой таблицы результатов через \WP_CLI\Utils\format_items
Завершение процесса со статусом exit(0) или WP_CLI::error(..., exit=1)
Команда WP-CLI обрабатывает 100 000 постов в цикле foreach ($posts as $post). Несмотря на то, что скрипт делит выборку на пачки по 500 штук, потребление оперативной памяти процессом монотонно растет и на 25 000-й записи скрипт падает с ошибкой 'Allowed memory size exhausted'. В чем фундаментальная причина утечки?
Почему при обработке таблицы из 2 000 000 строк использование конструкции 'SELECT * FROM wp_posts LIMIT 500 OFFSET 1500000' приводит к колоссальным задержкам СУБД (запрос выполняется десятки секунд), и как это исправить?
Ниже представлены четыре готовых компонента, образующих надежный и расширяемый каркас для кастомных команд WP-CLI.
Класс реализует синопсис PHPDoc, интерактивный режим подтверждения, флаг --dry-run, курсорную пагинацию, прогресс-бар и очистку оперативной памяти.
<?php
declare(strict_types=1);
namespace AgenticLab\CLI;
use WP_CLI;
use WP_CLI\Utils;
use WP_User;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Промышленное управление пользователями и аудит активности в WP-CLI.
* Реализует стандарты AGENTICLAB 2.0: курсорный батчинг, защита памяти и --dry-run.
*
* ## EXAMPLES
*
* # Тестовый прогон аудита пользователей
* wp agenticlab users audit --dry-run
*
* # Реальный перевод неактивных подписчиков с выводом в JSON
* wp agenticlab users deactivate --batch-size=250 --format=json
*
* @package agenticlab-cli
*/
class AgenticLab_Users_CLI {
/**
* Аудит активности пользователей и распределения ролей без изменения данных.
*
* ## OPTIONS
*
* [--days-inactive=<int>]
* : Количество дней без активности для выявления неактивных пользователей.
* ---
* default: 180
* ---
*
* [--format=<format>]
* : Формат вывода сводки аудита.
* ---
* default: table
* options:
* - table
* - json
* - csv
* - count
* ---
*
* ## EXAMPLES
*
* wp agenticlab users audit
* wp agenticlab users audit --days-inactive=90 --format=json
*
* @when after_wp_load
*/
public function audit( array $args, array $assoc_args ): void {
global $wpdb;
$days_inactive = (int) ( $assoc_args['days-inactive'] ?? 180 );
$format = (string) ( $assoc_args['format'] ?? 'table' );
$cutoff_date = gmdate( 'Y-m-d H:i:s', time() - ( $days_inactive * DAY_IN_SECONDS ) );
$total_users = (int) $wpdb->get_var( "SELECT COUNT(ID) FROM {$wpdb->users}" );
$inactive_count = (int) $wpdb->get_var( $wpdb->prepare(
"SELECT COUNT(ID) FROM {$wpdb->users} WHERE user_registered < %s",
$cutoff_date
) );
if ( 'count' === $format ) {
WP_CLI::line( (string) $inactive_count );
return;
}
WP_CLI::line( WP_CLI::colorize( "%B=== Fullstack Agentic Lab User Base Audit ===%n" ) );
WP_CLI::line( sprintf( "Всего зарегистрировано пользователей: %d", $total_users ) );
WP_CLI::line( sprintf( "Неактивных (старше %d дней, до %s): %d (%.1f%%)",
$days_inactive,
$cutoff_date,
$inactive_count,
$total_users > 0 ? ( $inactive_count / $total_users ) * 100 : 0
) );
$sample_users = $wpdb->get_results( $wpdb->prepare(
"SELECT ID, user_login, user_email, user_registered
FROM {$wpdb->users}
WHERE user_registered < %s
ORDER BY ID ASC
LIMIT 10",
$cutoff_date
) );
$report = [];
foreach ( $sample_users as $u ) {
$user_obj = get_userdata( (int) $u->ID );
$roles = $user_obj instanceof WP_User ? implode( ', ', $user_obj->roles ) : 'none';
$report[] = [
'id' => (int) $u->ID,
'login' => $u->user_login,
'email' => $u->user_email,
'registered' => $u->user_registered,
'roles' => $roles,
];
}
if ( ! empty( $report ) ) {
WP_CLI::line( "
Выборка неактивных пользователей (первые 10):" );
Utils\format_items( $format, $report, [ 'id', 'login', 'email', 'registered', 'roles' ] );
}
WP_CLI::success( "Аудит пользователей успешно завершен." );
}
/**
* Массовая деактивация или смена роли неактивных пользователей.
*
* ## OPTIONS
*
* [--batch-size=<int>]
* : Количество пользователей, обрабатываемых за одну порцию.
* ---
* default: 500
* ---
*
* [--days-inactive=<int>]
* : Количество дней без активности для признания пользователя неактивным.
* ---
* default: 180
* ---
*
* [--target-role=<role>]
* : Целевая роль для назначения неактивным пользователям.
* ---
* default: inactive_subscriber
* ---
*
* [--dry-run]
* : Симуляция операции без внесения реальных изменений в базу данных.
*
* [--force]
* : Пропустить интерактивный запрос подтверждения оператора.
*
* [--format=<format>]
* : Формат вывода итоговых данных.
* ---
* default: table
* options:
* - table
* - json
* - csv
* - count
* ---
*
* ## EXAMPLES
*
* wp agenticlab users deactivate --days-inactive=90 --dry-run
* wp agenticlab users deactivate --batch-size=1000 --force
*
* @when after_wp_load
*/
public function deactivate( array $args, array $assoc_args ): void {
global $wpdb;
$batch_size = (int) ( $assoc_args['batch-size'] ?? 500 );
$days_inactive = (int) ( $assoc_args['days-inactive'] ?? 180 );
$target_role = (string) ( $assoc_args['target-role'] ?? 'inactive_subscriber' );
$dry_run = isset( $assoc_args['dry-run'] );
$force = isset( $assoc_args['force'] );
$format = (string) ( $assoc_args['format'] ?? 'table' );
$cutoff_date = gmdate( 'Y-m-d H:i:s', time() - ( $days_inactive * DAY_IN_SECONDS ) );
WP_CLI::line( WP_CLI::colorize( "%B=== Fullstack Agentic Lab Users Inactivity Audit ===%n" ) );
WP_CLI::line( sprintf( "Порог неактивности: %d дней (ранее %s)", $days_inactive, $cutoff_date ) );
WP_CLI::line( sprintf( "Размер пачки (Batch): %d записей", $batch_size ) );
if ( $dry_run ) {
WP_CLI::warning( "Включен режим симуляции (--dry-run). База данных изменена не будет." );
} elseif ( ! $force ) {
WP_CLI::confirm( "Вы уверены, что хотите изменить роли пользователей на боевом сервере?", $assoc_args );
}
// Подсчет общего количества кандидатов на обработку
$total_count = (int) $wpdb->get_var( $wpdb->prepare(
"SELECT COUNT(ID) FROM {$wpdb->users} WHERE user_registered < %s",
$cutoff_date
) );
if ( 0 === $total_count ) {
if ( $dry_run ) {
WP_CLI::success( "Симуляция завершена: неактивных пользователей не обнаружено." );
} else {
WP_CLI::success( "Неактивных пользователей, удовлетворяющих критериям, не обнаружено." );
}
return;
}
WP_CLI::line( sprintf( "Всего кандидатов для обработки: %d", $total_count ) );
$progress = Utils\make_progress_bar( 'Обработка пользователей...', $total_count );
$cursor_id = 0;
$processed_count = 0;
$modified_count = 0;
$report_data = [];
// Главный цикл курсорной пагинации (WHERE ID > $cursor_id)
while ( true ) {
$users = $wpdb->get_results( $wpdb->prepare(
"SELECT ID, user_login, user_email, user_registered
FROM {$wpdb->users}
WHERE ID > %d AND user_registered < %s
ORDER BY ID ASC
LIMIT %d",
$cursor_id,
$cutoff_date,
$batch_size
) );
if ( empty( $users ) ) {
break; // Все записи обработаны
}
foreach ( $users as $user_row ) {
$user_id = (int) $user_row->ID;
$cursor_id = $user_id; // Сдвигаем курсор на ID текущей записи
$user = get_userdata( $user_id );
if ( ! $user instanceof WP_User || in_array( 'administrator', $user->roles, true ) ) {
$progress->tick();
continue; // Пропускаем администраторов в целях безопасности
}
if ( ! $dry_run ) {
$user->set_role( $target_role );
}
$modified_count++;
$processed_count++;
if ( 'count' !== $format && count( $report_data ) < 100 ) {
$report_data[] = [
'id' => $user_id,
'login' => $user_row->user_login,
'email' => $user_row->user_email,
'registered' => $user_row->user_registered,
'new_role' => $target_role,
'status' => $dry_run ? 'SIMULATED' : 'MODIFIED',
];
}
$progress->tick();
}
// ОЧИСТКА ПАМЯТИ: Сброс накопителей ядра WordPress после каждого батча
AgenticLab_Memory_Manager::stop_the_insanity();
}
$progress->finish();
// Вывод структурированного отчета
if ( 'count' === $format ) {
WP_CLI::line( (string) $modified_count );
} elseif ( ! empty( $report_data ) ) {
WP_CLI::line( "
Пример обработанных записей (первые 100):" );
Utils\format_items( $format, $report_data, [ 'id', 'login', 'email', 'registered', 'new_role', 'status' ] );
}
if ( $dry_run ) {
WP_CLI::success( sprintf( "Симуляция завершена. Было бы изменено пользователей: %d из %d.", $modified_count, $total_count ) );
} else {
WP_CLI::success( sprintf( "Успешно обновлено пользователей: %d из %d.", $modified_count, $total_count ) );
}
}
}
Служебный класс предотвращает разрастание оперативной памяти процессом PHP, очищая внутренние очереди $wpdb, кэш объектов и вызывая детерминированный сборщик мусора.
<?php
declare(strict_types=1);
namespace AgenticLab\CLI;
use WP_CLI;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Менеджер контроля и очистки оперативной памяти для фоновых CLI-процессов.
* Реализует оптимизированный паттерн "Stop The Insanity".
*/
class AgenticLab_Memory_Manager {
/**
* Принудительная очистка глобальных массивов ядра WordPress и запуск сборщика мусора.
*/
public static function stop_the_insanity(): void {
global $wpdb, $wp_object_cache;
// 1. Очистка истории SQL-запросов (если включен SAVEQUERIES)
$wpdb->queries = [];
// 2. Очистка внутреннего объектного кэша ядра WP-CLI
if ( function_exists( '\WP_CLI\Utils\wp_clean_in_memory_cache' ) ) {
Utils\wp_clean_in_memory_cache();
} elseif ( is_object( $wp_object_cache ) ) {
$wp_object_cache->group_ops = [];
$wp_object_cache->stats = [];
$wp_object_cache->memcache_debug = [];
$wp_object_cache->cache = [];
if ( method_exists( $wp_object_cache, '__remoteset' ) ) {
$wp_object_cache->__remoteset(); // Сброс кэш-интерфейсов сторонних драйверов
}
}
// 3. Вызов циклического сборщика мусора PHP
if ( function_exists( 'gc_collect_cycles' ) ) {
gc_collect_cycles();
}
// Мониторинг потребления памяти: предупреждение при приближении к лимиту
self::check_memory_threshold();
}
/**
* Проверка порога потребления оперативной памяти.
*/
private static function check_memory_threshold(): void {
$memory_limit = self::get_memory_limit_bytes();
if ( $memory_limit <= 0 ) {
return; // Лимит не задан (-1)
}
$current_usage = memory_get_usage( true );
$usage_percent = ( $current_usage / $memory_limit ) * 100;
if ( $usage_percent > 85 ) {
$current_human = function_exists( 'size_format' ) ? size_format( $current_usage ) : (string) $current_usage;
$limit_human = function_exists( 'size_format' ) ? size_format( $memory_limit ) : (string) $memory_limit;
WP_CLI::warning( sprintf(
'Потребление оперативной памяти превысило 85%% (%s из %s)! Рекомендуется уменьшить --batch-size.',
$current_human,
$limit_human
) );
}
}
private static function get_memory_limit_bytes(): int {
$limit = ini_get( 'memory_limit' );
if ( '-1' === $limit || empty( $limit ) ) {
return -1;
}
if ( function_exists( 'wp_convert_hr_to_bytes' ) ) {
return (int) wp_convert_hr_to_bytes( $limit );
}
$unit = strtolower( substr( $limit, -1 ) );
$bytes = (int) substr( $limit, 0, -1 );
return match ( $unit ) {
'g' => $bytes * 1024 * 1024 * 1024,
'm' => $bytes * 1024 * 1024,
'k' => $bytes * 1024,
default => (int) $limit,
};
}
}
Файл инициализации регистрирует корневое пространство имен agenticlab и подключает команды только внутри среды WP-CLI.
<?php
/**
* Точка входа для регистрации кастомных команд WP-CLI.
* Подключается из основного плагина через проверку defined('WP_CLI').
*/
declare(strict_types=1);
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// Защита: код исполняется строго внутри контекста консоли WP-CLI
if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {
return;
}
require_once __DIR__ . '/includes/cli/class-agenticlab-memory-manager.php';
require_once __DIR__ . '/includes/cli/class-agenticlab-users-cli.php';
/**
* Регистрация корневой команды "wp agenticlab users" в диспетчере WP-CLI.
*/
WP_CLI::add_command( 'agenticlab users', \AgenticLab\CLI\AgenticLab_Users_CLI::class, [
'shortdesc' => 'Управление пользователями, аудит и пакетная обработка профилей.',
] );
Скрипт проверяет корректность работы команды: синтаксис, симуляцию --dry-run, коды возврата (0 vs 1) и JSON-вывод.
#!/usr/bin/env bash
# ==============================================================================
# test-agenticlab-cli.sh — Интеграционное тестирование кастомной команды WP-CLI
# ==============================================================================
set -euo pipefail
echo "🧪 [1/4] Проверка регистрации подкоманд 'wp agenticlab users'..."
if ! wp agenticlab users --help > /dev/null 2>&1; then
echo "❌ ОШИБКА: Команда 'wp agenticlab users' не зарегистрирована в WP-CLI!"
exit 1
fi
echo "✅ Команды 'audit' и 'deactivate' обнаружены в диспетчере."
echo "🧪 [2/4] Тестирование подкоманды аудита и режима симуляции (--dry-run)..."
AUDIT_COUNT=$(wp agenticlab users audit --days-inactive=1000 --format=count 2>&1 || true)
echo "✅ Подкоманда 'audit' выполнена штатно (найдено кандидатов: ${AUDIT_COUNT})."
DRY_RUN_OUTPUT=$(wp agenticlab users deactivate --days-inactive=1000 --dry-run 2>&1)
if [[ "${DRY_RUN_OUTPUT}" != *"Симуляция"* && "${DRY_RUN_OUTPUT}" != *"не обнаружено"* ]]; then
echo "❌ ОШИБКА: Флаг --dry-run не сработал должным образом!"
exit 1
fi
echo "✅ Режим симуляции функционирует штатно."
echo "🧪 [3/4] Проверка вывода в формате JSON..."
JSON_OUTPUT=$(wp agenticlab users deactivate --days-inactive=1000 --dry-run --format=json)
if ! echo "${JSON_OUTPUT}" | jq . > /dev/null 2>&1; then
echo "⚠️ Замечание: Вывод не содержит JSON или кандидатов не найдено (валидно)."
fi
echo "✅ Форматирование вывода проверено."
echo "🧪 [4/4] Проверка валидации недопустимых аргументов..."
set +e
wp agenticlab users deactivate --invalid-flag=true > /dev/null 2>&1
EXIT_CODE=$?
set -e
if [[ "${EXIT_CODE}" -eq 0 ]]; then
echo "❌ ОШИБКА: WP-CLI должен завершаться с ошибкой при передаче неизвестных флагов!"
exit 1
fi
echo "✅ Синопсис корректно отклоняет неизвестные флаги (Exit Code: ${EXIT_CODE})."
echo "🎉 Все интеграционные тесты кастомной команды успешно пройдены!"
OFFSET при батчинге#OFFSET 100000 заставляет базу данных последовательно перебирать и отбрасывать 100 000 строк перед возвратом следующих.WHERE ID > $last_id ORDER BY ID ASC LIMIT $batch_size.echo / print#stdout.WP_CLI: WP_CLI::line(), WP_CLI::log(), WP_CLI::success(), WP_CLI::warning() и WP_CLI::error().class_exists('WP_CLI')#Fatal error: Class 'WP_CLI' not found.WP_CLI::add_command() был подключен без проверки константы defined('WP_CLI'). В обычном веб-запросе через Nginx класс WP_CLI не загружается ядром.if ( defined( 'WP_CLI' ) && WP_CLI ).wp search-replace.