diff --git a/CHANGELOG.md b/CHANGELOG.md index d5cf9c5..e5aae87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,21 @@ All notable changes to this project will be documented in this file. The format is based on Keep a Changelog and adheres to Semantic Versioning. + +## [1.2.0] - 2025-10-10 +### Removed +- Dropped all legacy global class aliases (previous `class_alias` guards) now that backward compatibility is not required. +- Removed deprecated `includes/` directory and emptied legacy interface stubs. +- Removed legacy job_type inference variants (e.g. email_job, Email_Job, image_processing_job, api_sync_job); only canonical keys `email`, `image_processing`, `api_sync` are accepted now. +- Removed bootstrap fallback to legacy global `Sync_Worker` (namespaced worker is now required). +- Deleted development-only `debug.php` script (replaced by internal diagnostics via `Redis_Queue_Manager::diagnostic()`). +### Changed +- Codebase now exclusively uses namespaced classes; no global fallbacks remain. +- Documentation updated to remove legacy references (includes/ directory, global class name variants) and reflect canonical job type usage only. + ## [1.0.2] - 2025-10-10 ### Added -- GitHub updater integration and release workflows (`.github/workflows/*`, `includes/class-github-plugin-updater.php`). +- GitHub updater integration and release workflows (`.github/workflows/*`, `class-github-plugin-updater.php`). - Funding configuration (`.github/FUNDING.yml`). - Expanded root README with direct links to documentation set. - Composer metadata: enriched description & keywords. diff --git a/README.md b/README.md index f85e0c1..1d356b2 100644 --- a/README.md +++ b/README.md @@ -317,4 +317,61 @@ Made with ❤️ by [Per Søderlind](https://soderlind.com) --- -For detailed usage, advanced features, troubleshooting, and performance tuning visit the [Usage guide](docs/usage.md). Additional topics: [Scaling](docs/scaling.md), [Maintenance](docs/maintenance.md). \ No newline at end of file +For detailed usage, advanced features, troubleshooting, and performance tuning visit the [Usage guide](docs/usage.md). Additional topics: [Scaling](docs/scaling.md), [Maintenance](docs/maintenance.md). + +## Namespacing & Backward Compatibility (Refactor Notes) + +As of the latest refactor, all core classes have been migrated to the `Soderlind\\RedisQueueDemo` namespace and autoloaded via Composer PSR-4. Legacy global class names (`Redis_Queue_Demo`, `Redis_Queue_Manager`, `Job_Processor`, `Sync_Worker`, `REST_Controller`, `Admin_Interface`, job classes, etc.) are still available through `class_alias` so existing integrations that referenced the old globals continue to work without modification. + +Removed legacy duplicate files: +``` +admin/class-admin-interface.php +api/class-rest-controller.php +workers/class-sync-worker.php +``` +Their logic now lives in: +``` +src/Admin/Admin_Interface.php +src/API/REST_Controller.php +src/Workers/Sync_Worker.php +``` + +Helper functions (`redis_queue_demo()`, `redis_queue_enqueue_job()`, `redis_queue_process_jobs()`) remain unchanged for ergonomics. + +If you previously required or included specific legacy files manually, you should remove those `require` statements—Composer autoload now handles class loading. + +### Migrating Custom Integrations + +If you instantiated legacy classes directly, both of the following are now equivalent: +```php +$manager = new Redis_Queue_Manager(); // legacy global (still works) +$manager = new \Soderlind\RedisQueueDemo\Core\Redis_Queue_Manager(); // namespaced +``` + +Custom job classes should adopt the namespace pattern and be placed under `src/YourNamespace/` with an appropriate `composer.json` autoload mapping, or hooked via the `redis_queue_demo_create_job` filter returning a namespaced job instance. + +### Why This Change? + +1. Autoload performance & structure clarity. +2. Avoiding global symbol collisions. +3. Easier extension via modern PHP tooling. +4. Future unit test isolation. + +If you encounter any missing class errors after upgrading, clear WordPress object/opcode caches and run: +```bash +composer dump-autoload -o +``` + +Please report any backward compatibility regressions in the issue tracker. + +### Admin Interface Inlining (UI Unchanged) + +The legacy `admin/class-admin-interface.php` file was fully inlined into the namespaced `src/Admin/Admin_Interface.php` to remove manual `require` calls. To preserve the exact markup/CSS hooks, the original page layouts were ported as partial templates under: +``` +src/Admin/partials/ + dashboard-inline.php + jobs-inline.php + test-inline.php + settings-inline.php +``` +These are loaded internally by the namespaced class; you should not include them directly. If you previously overrode or filtered admin output, existing selectors and element structures remain stable. \ No newline at end of file diff --git a/includes/class-github-plugin-updater.php b/class-github-plugin-updater.php similarity index 53% rename from includes/class-github-plugin-updater.php rename to class-github-plugin-updater.php index e0899aa..c1dfee6 100644 --- a/includes/class-github-plugin-updater.php +++ b/class-github-plugin-updater.php @@ -6,58 +6,20 @@ /** * Generic WordPress Plugin GitHub Updater * - * A reusable class for handling WordPress plugin updates from GitHub repositories - * using the plugin-update-checker library. + * Moved from includes/ to plugin root in version 1.0.0. * * @package Soderlind\WordPress * @version 1.0.0 - * @author Per Soderlind - * @license GPL-2.0+ */ class GitHub_Plugin_Updater { - /** - * @var string GitHub repository URL - */ private $github_url; - - /** - * @var string Branch to check for updates - */ private $branch; - - /** - * @var string Regex pattern to match the plugin zip file name - */ private $name_regex; - - /** - * @var string The plugin slug - */ private $plugin_slug; - - /** - * @var string The main plugin file path - */ private $plugin_file; - - /** - * @var bool Whether to enable release assets - */ private $enable_release_assets; - /** - * Constructor - * - * @param array $config Configuration array with the following keys: - * - github_url: GitHub repository URL (required) - * - plugin_file: Main plugin file path (required) - * - plugin_slug: Plugin slug for updates (required) - * - branch: Branch to check for updates (default: 'main') - * - name_regex: Regex pattern for zip file name (optional) - * - enable_release_assets: Whether to enable release assets (default: true if name_regex provided) - */ public function __construct( $config = array() ) { - // Validate required parameters $required = array( 'github_url', 'plugin_file', 'plugin_slug' ); foreach ( $required as $key ) { if ( empty( $config[ $key ] ) ) { @@ -74,13 +36,9 @@ public function __construct( $config = array() ) { ? $config[ 'enable_release_assets' ] : ! empty( $this->name_regex ); - // Initialize the updater add_action( 'init', array( $this, 'setup_updater' ) ); } - /** - * Set up the update checker using GitHub integration - */ public function setup_updater() { try { $update_checker = PucFactory::buildUpdateChecker( @@ -91,29 +49,16 @@ public function setup_updater() { $update_checker->setBranch( $this->branch ); - // Enable release assets if configured if ( $this->enable_release_assets && ! empty( $this->name_regex ) ) { $update_checker->getVcsApi()->enableReleaseAssets( $this->name_regex ); } - } catch (\Exception $e) { - // Log error if WordPress debug is enabled if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( 'GitHub Plugin Updater Error: ' . $e->getMessage() ); } } } - /** - * Create updater instance with minimal configuration - * - * @param string $github_url GitHub repository URL - * @param string $plugin_file Main plugin file path - * @param string $plugin_slug Plugin slug - * @param string $branch Branch name (default: 'main') - * - * @return GitHub_Plugin_Updater - */ public static function create( $github_url, $plugin_file, $plugin_slug, $branch = 'main' ) { return new self( array( 'github_url' => $github_url, @@ -123,17 +68,6 @@ public static function create( $github_url, $plugin_file, $plugin_slug, $branch ) ); } - /** - * Create updater instance for plugins with release assets - * - * @param string $github_url GitHub repository URL - * @param string $plugin_file Main plugin file path - * @param string $plugin_slug Plugin slug - * @param string $name_regex Regex pattern for release assets - * @param string $branch Branch name (default: 'main') - * - * @return GitHub_Plugin_Updater - */ public static function create_with_assets( $github_url, $plugin_file, $plugin_slug, $name_regex, $branch = 'main' ) { return new self( array( 'github_url' => $github_url, diff --git a/composer.json b/composer.json index a0ad602..50d6aef 100644 --- a/composer.json +++ b/composer.json @@ -26,5 +26,10 @@ "support": { "issues": "https://github.com/soderlind/redis-queue-demo/issues" }, - "require": {} + "require": {}, + "autoload": { + "psr-4": { + "Soderlind\\RedisQueueDemo\\": "src/" + } + } } \ No newline at end of file diff --git a/design.md b/design.md index d4167b8..61c9312 100644 --- a/design.md +++ b/design.md @@ -299,9 +299,7 @@ redis-queue-demo/ ├── redis-queue-demo.php # Main plugin file ├── uninstall.php # Cleanup on plugin removal ├── includes/ # Core functionality -│ ├── class-redis-queue-manager.php │ ├── class-queue-worker.php -│ ├── class-job-processor.php │ └── interfaces/ │ ├── interface-queue-job.php │ └── interface-job-result.php diff --git a/docs/extending-jobs.md b/docs/extending-jobs.md index 956ca6c..5030638 100644 --- a/docs/extending-jobs.md +++ b/docs/extending-jobs.md @@ -4,6 +4,8 @@ This guide explains how to create custom background jobs by extending the base a ## Core Concepts +Important: The plugin now exclusively uses namespaced classes and only canonical job type identifiers you define (e.g. `email`, `image_processing`, `api_sync`, or your custom strings). Legacy/global class name variants (like `Email_Job`, `email_job`) are not auto-mapped. + A job represents a unit of work executed asynchronously by a worker. Each job class encapsulates: - A unique job type identifier (string) diff --git a/docs/usage.md b/docs/usage.md index 973e855..d5638d9 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -85,28 +85,30 @@ curl -X POST "https://yoursite.com/wp-json/redis-queue/v1/jobs" \ ### Creating & Enqueuing Jobs ```php -$redis_queue = redis_queue_demo(); +use Soderlind\RedisQueueDemo\Jobs\Email_Job; $email_job = new Email_Job([ - 'email_type' => 'single', - 'to' => 'user@example.com', - 'subject' => 'Welcome!', - 'message' => 'Welcome to our site!' + 'email_type' => 'single', + 'to' => 'user@example.com', + 'subject' => 'Welcome!', + 'message' => 'Welcome to our site!' ]); $email_job->set_priority(10); $email_job->set_queue_name('emails'); -$job_id = $redis_queue->queue_manager->enqueue($email_job); +$job_id = redis_queue_demo()->get_queue_manager()->enqueue( $email_job ); ``` ### Processing Jobs Manually ```php +use Soderlind\RedisQueueDemo\Workers\Sync_Worker; + $worker = new Sync_Worker( - $redis_queue->queue_manager, - $redis_queue->job_processor + redis_queue_demo()->get_queue_manager(), + redis_queue_demo()->get_job_processor() ); -$results = $worker->process_jobs(['default', 'emails'], 5); +$results = $worker->process_jobs( [ 'default', 'emails' ], 5 ); ``` ### Custom Job Skeleton diff --git a/includes/class-job-processor.php b/includes/class-job-processor.php deleted file mode 100644 index e9305b3..0000000 --- a/includes/class-job-processor.php +++ /dev/null @@ -1,499 +0,0 @@ -queue_manager = $queue_manager; - } - - /** - * Process a single job. - * - * @since 1.0.0 - * @param array $job_data Job data from queue. - * @return Job_Result Processing result. - */ - public function process_job( $job_data ) { - $this->current_job = $job_data; - $this->start_time = microtime( true ); - $this->start_memory = memory_get_usage( true ); - - $job_id = $job_data[ 'job_id' ] ?? 'unknown'; - - try { - // Create job instance from serialized data. - $job = $this->create_job_instance( $job_data ); - if ( ! $job ) { - throw new Exception( 'Failed to create job instance' ); - } - - // Set timeout if specified. - $timeout = $job->get_timeout(); - if ( $timeout > 0 ) { - set_time_limit( $timeout ); - } - - // Execute the job. - $result = $job->execute(); - - // Calculate execution metrics. - $execution_time = microtime( true ) - $this->start_time; - $memory_usage = memory_get_peak_usage( true ) - $this->start_memory; - - $result->set_execution_time( $execution_time ); - $result->set_memory_usage( $memory_usage ); - - if ( $result->is_successful() ) { - $this->handle_successful_job( $job_id, $result ); - } else { - // Pass null for exception because the job returned a failure result without throwing. - $this->handle_failed_job( $job_id, $job, $result, 1, null ); - } - - /** - * Fires after a job is processed. - * - * @since 1.0.0 - * @param string $job_id Job ID. - * @param Queue_Job $job Job instance. - * @param Job_Result $result Job result. - */ - do_action( 'redis_queue_demo_job_processed', $job_id, $job, $result ); - - return $result; - - } catch (Exception $e) { - $execution_time = microtime( true ) - $this->start_time; - $memory_usage = memory_get_peak_usage( true ) - $this->start_memory; - - $result = Basic_Job_Result::failure( - $e->getMessage(), - $e->getCode(), - array( 'exception_type' => get_class( $e ) ) - ); - - $result->set_execution_time( $execution_time ); - $result->set_memory_usage( $memory_usage ); - - // Try to get job instance for retry logic. - $job = $this->create_job_instance( $job_data ); - if ( $job ) { - $this->handle_failed_job( $job_id, $job, $result, 1, $e ); - } else { - $this->mark_job_failed( $job_id, $result ); - } - - /** - * Fires when a job fails to process. - * - * @since 1.0.0 - * @param string $job_id Job ID. - * @param Exception $e Exception that caused the failure. - * @param array $job_data Job data. - */ - do_action( 'redis_queue_demo_job_failed', $job_id, $e, $job_data ); - - return $result; - - } finally { - $this->current_job = null; - } - } - - /** - * Process multiple jobs from queue. - * - * @since 1.0.0 - * @param array $queues Queue names to process. - * @param int $max_jobs Maximum number of jobs to process. - * @return array Processing results. - */ - public function process_jobs( $queues = array( 'default' ), $max_jobs = 10 ) { - $results = array(); - $processed = 0; - $start_time = microtime( true ); - $start_memory = memory_get_usage( true ); - - /** - * Fires before job processing batch starts. - * - * @since 1.0.0 - * @param array $queues Queue names. - * @param int $max_jobs Maximum jobs to process. - */ - do_action( 'redis_queue_demo_batch_start', $queues, $max_jobs ); - - while ( $processed < $max_jobs ) { - $job_data = $this->queue_manager->dequeue( $queues ); - - if ( ! $job_data ) { - // No more jobs available. - break; - } - - $result = $this->process_job( $job_data ); - $results[] = array( - 'job_id' => $job_data[ 'job_id' ] ?? 'unknown', - 'result' => $result, - ); - - $processed++; - - // Check memory usage to prevent exhaustion. - if ( $this->should_stop_processing() ) { - break; - } - } - - $total_time = microtime( true ) - $start_time; - $total_memory = memory_get_peak_usage( true ) - $start_memory; - - /** - * Fires after job processing batch completes. - * - * @since 1.0.0 - * @param array $results Processing results. - * @param int $processed Number of jobs processed. - * @param float $total_time Total processing time. - * @param int $total_memory Total memory used. - */ - do_action( 'redis_queue_demo_batch_complete', $results, $processed, $total_time, $total_memory ); - - return array( - 'processed' => $processed, - 'total_time' => $total_time, - 'total_memory' => $total_memory, - 'results' => $results, - ); - } - - /** - * Create a job instance from job data. - * - * @since 1.0.0 - * @param array $job_data Job data. - * @return Queue_Job|null Job instance or null on failure. - */ - private function create_job_instance( $job_data ) { - if ( ! isset( $job_data[ 'serialized_job' ] ) ) { - return null; - } - - try { - $serialized_data = $job_data[ 'serialized_job' ]; - $job_type = $job_data[ 'job_type' ] ?? ''; - - // Get job class from job type. - $job_class = $this->get_job_class( $job_type ); - if ( ! $job_class || ! class_exists( $job_class ) ) { - throw new Exception( "Job class not found for type: {$job_type}" ); - } - - // Create job instance using deserialize method. - if ( method_exists( $job_class, 'deserialize' ) ) { - return call_user_func( array( $job_class, 'deserialize' ), $serialized_data ); - } - - throw new Exception( "Job class {$job_class} does not implement deserialize method" ); - - } catch (Exception $e) { - error_log( 'Redis Queue Demo: Failed to create job instance - ' . $e->getMessage() ); - return null; - } - } - - /** - * Get job class name from job type. - * - * @since 1.0.0 - * @param string $job_type Job type. - * @return string|null Job class name or null if not found. - */ - private function get_job_class( $job_type ) { - $job_classes = array( - 'email' => 'Email_Job', - 'image_processing' => 'Image_Processing_Job', - 'api_sync' => 'API_Sync_Job', - ); - - /** - * Filter available job classes. - * - * @since 1.0.0 - * @param array $job_classes Job type to class mapping. - */ - $job_classes = apply_filters( 'redis_queue_demo_job_classes', $job_classes ); - - return $job_classes[ $job_type ] ?? null; - } - - /** - * Handle successful job completion. - * - * @since 1.0.0 - * @param string $job_id Job ID. - * @param Job_Result $result Job result. - */ - private function handle_successful_job( $job_id, Job_Result $result ) { - global $wpdb; - - $table_name = $wpdb->prefix . 'redis_queue_jobs'; - - $wpdb->update( - $table_name, - array( - 'status' => 'completed', - 'result' => wp_json_encode( $result->to_array() ), - 'updated_at' => current_time( 'mysql' ), - ), - array( 'job_id' => $job_id ), - array( '%s', '%s', '%s' ), - array( '%s' ) - ); - - /** - * Fires when a job completes successfully. - * - * @since 1.0.0 - * @param string $job_id Job ID. - * @param Job_Result $result Job result. - */ - do_action( 'redis_queue_demo_job_completed', $job_id, $result ); - } - - /** - * Handle failed job. - * - * @since 1.0.0 - * @param string $job_id Job ID. - * @param Queue_Job $job Job instance. - * @param Job_Result $result Job result. - * @param int $attempt Current attempt number. - * @param Exception $exception Optional exception. - */ - private function handle_failed_job( $job_id, Queue_Job $job, Job_Result $result, $attempt, $exception = null ) { - global $wpdb; - - $table_name = $wpdb->prefix . 'redis_queue_jobs'; - - // Update attempt count. - $wpdb->query( - $wpdb->prepare( - "UPDATE {$table_name} SET attempts = attempts + 1, updated_at = %s WHERE job_id = %s", - current_time( 'mysql' ), - $job_id - ) - ); - - // Get current job info from database. - $job_info = $wpdb->get_row( - $wpdb->prepare( - "SELECT attempts, max_attempts FROM {$table_name} WHERE job_id = %s", - $job_id - ) - ); - - if ( ! $job_info ) { - return; - } - - $current_attempts = (int) $job_info->attempts; - $max_attempts = (int) $job_info->max_attempts; - - // Check if we should retry. - if ( $current_attempts < $max_attempts && $job->should_retry( $exception, $current_attempts ) ) { - $this->retry_job( $job_id, $job, $current_attempts ); - } else { - $this->mark_job_failed( $job_id, $result ); - $job->handle_failure( $exception, $current_attempts ); - } - } - - /** - * Retry a failed job. - * - * @since 1.0.0 - * @param string $job_id Job ID. - * @param Queue_Job $job Job instance. - * @param int $attempt Current attempt number. - */ - private function retry_job( $job_id, Queue_Job $job, $attempt ) { - $delay = $job->get_retry_delay( $attempt ); - - // Re-enqueue the job with delay. - $this->queue_manager->enqueue( $job, $delay ); - - // Update job status. - $this->queue_manager->update_job_status( $job_id, 'queued' ); - - /** - * Fires when a job is retried. - * - * @since 1.0.0 - * @param string $job_id Job ID. - * @param Queue_Job $job Job instance. - * @param int $attempt Attempt number. - * @param int $delay Retry delay in seconds. - */ - do_action( 'redis_queue_demo_job_retried', $job_id, $job, $attempt, $delay ); - } - - /** - * Mark job as permanently failed. - * - * @since 1.0.0 - * @param string $job_id Job ID. - * @param Job_Result $result Job result. - */ - private function mark_job_failed( $job_id, Job_Result $result ) { - global $wpdb; - - $table_name = $wpdb->prefix . 'redis_queue_jobs'; - - $wpdb->update( - $table_name, - array( - 'status' => 'failed', - 'result' => wp_json_encode( $result->to_array() ), - 'error_message' => $result->get_error_message(), - 'failed_at' => current_time( 'mysql' ), - 'updated_at' => current_time( 'mysql' ), - ), - array( 'job_id' => $job_id ), - array( '%s', '%s', '%s', '%s', '%s' ), - array( '%s' ) - ); - - /** - * Fires when a job is marked as permanently failed. - * - * @since 1.0.0 - * @param string $job_id Job ID. - * @param Job_Result $result Job result. - */ - do_action( 'redis_queue_demo_job_permanently_failed', $job_id, $result ); - } - - /** - * Check if processing should stop. - * - * @since 1.0.0 - * @return bool True if processing should stop. - */ - private function should_stop_processing() { - $memory_limit = $this->get_memory_limit(); - $current_usage = memory_get_usage( true ); - - // Stop if using more than 80% of memory limit. - if ( $memory_limit > 0 && $current_usage > ( $memory_limit * 0.8 ) ) { - return true; - } - - // Check for PHP timeout. - $max_execution_time = ini_get( 'max_execution_time' ); - if ( $max_execution_time > 0 ) { - $elapsed = microtime( true ) - $this->start_time; - if ( $elapsed > ( $max_execution_time * 0.8 ) ) { - return true; - } - } - - return false; - } - - /** - * Get memory limit in bytes. - * - * @since 1.0.0 - * @return int Memory limit in bytes, or 0 if unlimited. - */ - private function get_memory_limit() { - $memory_limit = ini_get( 'memory_limit' ); - if ( '-1' === $memory_limit ) { - return 0; // Unlimited. - } - - $unit = strtolower( substr( $memory_limit, -1 ) ); - $value = (int) $memory_limit; - - switch ( $unit ) { - case 'g': - $value *= 1024 * 1024 * 1024; - break; - case 'm': - $value *= 1024 * 1024; - break; - case 'k': - $value *= 1024; - break; - } - - return $value; - } - - /** - * Get currently processing job data. - * - * @since 1.0.0 - * @return array|null Current job data or null if not processing. - */ - public function get_current_job() { - return $this->current_job; - } -} \ No newline at end of file diff --git a/includes/class-redis-queue-manager.php b/includes/class-redis-queue-manager.php deleted file mode 100644 index 82ea539..0000000 --- a/includes/class-redis-queue-manager.php +++ /dev/null @@ -1,599 +0,0 @@ -connect(); - } - - /** - * Connect to Redis server. - * - * @since 1.0.0 - * @return bool True on success, false on failure. - */ - private function connect() { - try { - $host = redis_queue_demo()->get_option( 'redis_host', '127.0.0.1' ); - $port = redis_queue_demo()->get_option( 'redis_port', 6379 ); - $password = redis_queue_demo()->get_option( 'redis_password', '' ); - $database = redis_queue_demo()->get_option( 'redis_database', 0 ); - - // Try Redis extension first. - if ( extension_loaded( 'redis' ) ) { - $this->redis = new Redis(); - $connected = $this->redis->connect( $host, $port, 2.5 ); - - if ( $connected ) { - if ( ! empty( $password ) ) { - $this->redis->auth( $password ); - } - $this->redis->select( $database ); - $this->connected = true; - } - } elseif ( class_exists( 'Predis\Client' ) ) { - // Fallback to Predis. - $config = array( - 'scheme' => 'tcp', - 'host' => $host, - 'port' => $port, - 'database' => $database, - ); - - if ( ! empty( $password ) ) { - $config[ 'password' ] = $password; - } - - $this->redis = new Predis\Client( $config ); - $this->redis->connect(); - $this->connected = true; - } - - if ( $this->connected ) { - /** - * Fires after successful Redis connection. - * - * @since 1.0.0 - * @param Redis_Queue_Manager $manager Queue manager instance. - */ - do_action( 'redis_queue_demo_connected', $this ); - } - - return $this->connected; - - } catch (Exception $e) { - error_log( 'Redis Queue Demo: Connection failed - ' . $e->getMessage() ); - $this->connected = false; - return false; - } - } - - /** - * Check if connected to Redis. - * - * @since 1.0.0 - * @return bool True if connected, false otherwise. - */ - public function is_connected() { - if ( ! $this->connected || ! $this->redis ) { - return false; - } - - try { - // Test connection with a ping. - $response = $this->redis->ping(); - return ( $response === true || $response === 'PONG' ); - } catch (Exception $e) { - $this->connected = false; - return false; - } - } - - /** - * Add a job to the queue. - * - * @since 1.0.0 - * @param Queue_Job $job Job instance. - * @param string $delay Delay before processing (optional). - * @return string|false Job ID on success, false on failure. - */ - public function enqueue( Queue_Job $job, $delay = null ) { - if ( ! $this->is_connected() ) { - return false; - } - - try { - $job_id = $this->generate_job_id(); - $job_data = $this->prepare_job_data( $job, $job_id ); - $queue_key = $this->get_queue_key( $job->get_queue_name() ); - - // Store job metadata in database. - if ( ! $this->store_job_metadata( $job_id, $job, $job_data ) ) { - return false; - } - - if ( $delay && $delay > 0 ) { - // Schedule job for later processing. - $process_time = time() + $delay; - $delayed_key = $this->queue_prefix . 'delayed'; - $this->redis->zadd( $delayed_key, $process_time, json_encode( $job_data ) ); - } else { - // Add to priority queue. - $priority = $job->get_priority(); - $this->redis->zadd( $queue_key, $priority, json_encode( $job_data ) ); - } - - /** - * Fires after a job is enqueued. - * - * @since 1.0.0 - * @param string $job_id Job ID. - * @param Queue_Job $job Job instance. - */ - do_action( 'redis_queue_demo_job_enqueued', $job_id, $job ); - - return $job_id; - - } catch (Exception $e) { - error_log( 'Redis Queue Demo: Enqueue failed - ' . $e->getMessage() ); - return false; - } - } - - /** - * Dequeue a job from the queue. - * - * @since 1.0.0 - * @param string|array $queues Queue name(s) to check. - * @return array|null Job data or null if no jobs available. - */ - public function dequeue( $queues = array( 'default' ) ) { - if ( ! $this->is_connected() ) { - return null; - } - - if ( is_string( $queues ) ) { - $queues = array( $queues ); - } - - try { - // Process delayed jobs first. - $this->process_delayed_jobs(); - - // Try each queue in order. - foreach ( $queues as $queue_name ) { - $queue_key = $this->get_queue_key( $queue_name ); - - // Get highest priority job (lowest score). - $jobs = $this->redis->zrange( $queue_key, 0, 0, array( 'withscores' => true ) ); - - if ( ! empty( $jobs ) ) { - $job_data = array_keys( $jobs )[ 0 ]; - $priority = array_values( $jobs )[ 0 ]; - - // Remove job from queue. - $this->redis->zrem( $queue_key, $job_data ); - - $decoded_data = json_decode( $job_data, true ); - if ( $decoded_data ) { - // Update job status to processing. - $this->update_job_status( $decoded_data[ 'job_id' ], 'processing' ); - - /** - * Fires after a job is dequeued. - * - * @since 1.0.0 - * @param array $job_data Job data. - */ - do_action( 'redis_queue_demo_job_dequeued', $decoded_data ); - - return $decoded_data; - } - } - } - - return null; - - } catch (Exception $e) { - error_log( 'Redis Queue Demo: Dequeue failed - ' . $e->getMessage() ); - return null; - } - } - - /** - * Get queue statistics. - * - * @since 1.0.0 - * @param string $queue_name Queue name (optional). - * @return array Queue statistics. - */ - public function get_queue_stats( $queue_name = null ) { - if ( ! $this->is_connected() ) { - return array(); - } - - try { - $stats = array(); - - if ( $queue_name ) { - $queue_key = $this->get_queue_key( $queue_name ); - $stats[ $queue_name ] = array( - 'pending' => $this->redis->zcard( $queue_key ), - 'size' => $this->redis->zcard( $queue_key ), - ); - } else { - // Get stats for all queues. - $pattern = $this->queue_prefix . 'queue:*'; - $keys = $this->redis->keys( $pattern ); - - foreach ( $keys as $key ) { - $queue_name = str_replace( $this->queue_prefix . 'queue:', '', $key ); - $stats[ $queue_name ] = array( - 'pending' => $this->redis->zcard( $key ), - 'size' => $this->redis->zcard( $key ), - ); - } - - // Add delayed jobs count. - $delayed_key = $this->queue_prefix . 'delayed'; - $stats[ 'delayed' ] = array( - 'pending' => $this->redis->zcard( $delayed_key ), - 'size' => $this->redis->zcard( $delayed_key ), - ); - } - - // Add database stats. - global $wpdb; - $table_name = $wpdb->prefix . 'redis_queue_jobs'; - - $db_stats = $wpdb->get_row( - "SELECT - COUNT(*) as total, - SUM(CASE WHEN status = 'queued' THEN 1 ELSE 0 END) as queued, - SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END) as processing, - SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed, - SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed - FROM {$table_name}", - ARRAY_A - ); - - $stats[ 'database' ] = $db_stats ?: array(); - - return $stats; - - } catch (Exception $e) { - error_log( 'Redis Queue Demo: Get stats failed - ' . $e->getMessage() ); - return array(); - } - } - - /** - * Clear a queue. - * - * @since 1.0.0 - * @param string $queue_name Queue name. - * @return bool True on success, false on failure. - */ - public function clear_queue( $queue_name ) { - if ( ! $this->is_connected() ) { - return false; - } - - try { - $queue_key = $this->get_queue_key( $queue_name ); - $result = $this->redis->del( $queue_key ); - - /** - * Fires after a queue is cleared. - * - * @since 1.0.0 - * @param string $queue_name Queue name. - */ - do_action( 'redis_queue_demo_queue_cleared', $queue_name ); - - return $result > 0; - - } catch (Exception $e) { - error_log( 'Redis Queue Demo: Clear queue failed - ' . $e->getMessage() ); - return false; - } - } - - /** - * Process delayed jobs that are ready. - * - * @since 1.0.0 - * @return int Number of jobs moved to active queues. - */ - private function process_delayed_jobs() { - if ( ! $this->is_connected() ) { - return 0; - } - - try { - $delayed_key = $this->queue_prefix . 'delayed'; - $current_time = time(); - $moved_count = 0; - - // Get jobs that are ready to process. - $ready_jobs = $this->redis->zrangebyscore( $delayed_key, 0, $current_time ); - - foreach ( $ready_jobs as $job_data ) { - $decoded_data = json_decode( $job_data, true ); - if ( $decoded_data && isset( $decoded_data[ 'queue_name' ] ) ) { - $queue_key = $this->get_queue_key( $decoded_data[ 'queue_name' ] ); - $priority = $decoded_data[ 'priority' ] ?? 50; - - // Move to active queue. - $this->redis->zadd( $queue_key, $priority, $job_data ); - $this->redis->zrem( $delayed_key, $job_data ); - - $moved_count++; - } - } - - return $moved_count; - - } catch (Exception $e) { - error_log( 'Redis Queue Demo: Process delayed jobs failed - ' . $e->getMessage() ); - return 0; - } - } - - /** - * Generate a unique job ID. - * - * @since 1.0.0 - * @return string Job ID. - */ - private function generate_job_id() { - return 'job_' . uniqid() . '_' . rand( 1000, 9999 ); - } - - /** - * Prepare job data for storage. - * - * @since 1.0.0 - * @param Queue_Job $job Job instance. - * @param string $job_id Job ID. - * @return array Job data. - */ - private function prepare_job_data( Queue_Job $job, $job_id ) { - return array( - 'job_id' => $job_id, - 'job_type' => $job->get_job_type(), - 'queue_name' => $job->get_queue_name(), - 'priority' => $job->get_priority(), - 'payload' => $job->get_payload(), - 'timeout' => $job->get_timeout(), - 'max_attempts' => $job->get_retry_attempts(), - 'created_at' => date( 'Y-m-d H:i:s' ), - 'serialized_job' => $job->serialize(), - ); - } - - /** - * Store job metadata in database. - * - * @since 1.0.0 - * @param string $job_id Job ID. - * @param Queue_Job $job Job instance. - * @param array $job_data Job data. - * @return bool True on success, false on failure. - */ - private function store_job_metadata( $job_id, Queue_Job $job, $job_data ) { - global $wpdb; - - $table_name = $wpdb->prefix . 'redis_queue_jobs'; - - $result = $wpdb->insert( - $table_name, - array( - 'job_id' => $job_id, - 'job_type' => $job->get_job_type(), - 'queue_name' => $job->get_queue_name(), - 'priority' => $job->get_priority(), - 'status' => 'queued', - 'payload' => json_encode( $job->get_payload() ), - 'attempts' => 0, - 'max_attempts' => $job->get_retry_attempts(), - 'timeout' => $job->get_timeout(), - 'created_at' => date( 'Y-m-d H:i:s' ), - 'updated_at' => date( 'Y-m-d H:i:s' ), - ), - array( '%s', '%s', '%s', '%d', '%s', '%s', '%d', '%d', '%d', '%s', '%s' ) - ); - - return $result !== false; - } - - /** - * Update job status in database. - * - * @since 1.0.0 - * @param string $job_id Job ID. - * @param string $status New status. - * @return bool True on success, false on failure. - */ - public function update_job_status( $job_id, $status ) { - global $wpdb; - - $table_name = $wpdb->prefix . 'redis_queue_jobs'; - - $update_data = array( - 'status' => $status, - 'updated_at' => date( 'Y-m-d H:i:s' ), - ); - - if ( 'processing' === $status ) { - $update_data[ 'processed_at' ] = date( 'Y-m-d H:i:s' ); - } elseif ( 'failed' === $status ) { - $update_data[ 'failed_at' ] = date( 'Y-m-d H:i:s' ); - } - - $result = $wpdb->update( - $table_name, - $update_data, - array( 'job_id' => $job_id ), - array( '%s', '%s' ), - array( '%s' ) - ); - - return $result !== false; - } - - /** - * Get the Redis key for a queue. - * - * @since 1.0.0 - * @param string $queue_name Queue name. - * @return string Redis key. - */ - private function get_queue_key( $queue_name ) { - return $this->queue_prefix . 'queue:' . $queue_name; - } - - /** - * Get Redis connection. - * - * @since 1.0.0 - * @return Redis|Predis\Client|null Redis connection. - */ - public function get_redis_connection() { - return $this->redis; - } - - /** - * Reset stuck processing jobs back to queued status. - * - * @since 1.0.0 - * @param int $timeout_minutes Jobs processing for longer than this will be reset. - * @return int Number of jobs reset. - */ - public function reset_stuck_jobs( $timeout_minutes = 30 ) { - global $wpdb; - - $table_name = $wpdb->prefix . 'redis_queue_jobs'; - $timeout_time = date( 'Y-m-d H:i:s', time() - ( $timeout_minutes * 60 ) ); - - $result = $wpdb->update( - $table_name, - array( - 'status' => 'queued', - 'updated_at' => date( 'Y-m-d H:i:s' ), - 'processed_at' => null, - ), - array( - 'status' => 'processing', - ), - array( '%s', '%s', '%s' ), - array( '%s' ) - ); - - return $result !== false ? $result : 0; - } - - /** - * Diagnostic method to test Redis operations. - * - * @since 1.0.0 - * @return array Diagnostic results. - */ - public function diagnostic() { - $results = array( - 'connected' => $this->is_connected(), - 'redis_keys' => array(), - 'test_write' => false, - 'test_read' => false, - 'queue_prefix' => $this->queue_prefix, - ); - - if ( $this->is_connected() ) { - try { - // Test write - $test_key = $this->queue_prefix . 'test'; - $this->redis->set( $test_key, 'test_value', 10 ); - $results[ 'test_write' ] = true; - - // Test read - $read_value = $this->redis->get( $test_key ); - $results[ 'test_read' ] = ( $read_value === 'test_value' ); - - // Clean up - $this->redis->del( $test_key ); - - // Get all queue keys - $results[ 'redis_keys' ] = $this->redis->keys( $this->queue_prefix . '*' ); - - } catch (Exception $e) { - $results[ 'error' ] = $e->getMessage(); - } - } - - return $results; - } - - /** - * Destructor. - * - * @since 1.0.0 - */ - public function __destruct() { - if ( $this->redis && $this->connected ) { - try { - if ( extension_loaded( 'redis' ) && $this->redis instanceof Redis ) { - $this->redis->close(); - } elseif ( $this->redis instanceof Predis\Client ) { - $this->redis->disconnect(); - } - } catch (Exception $e) { - // Ignore connection close errors. - } - } - } -} \ No newline at end of file diff --git a/includes/interfaces/interface-job-result.php b/includes/interfaces/interface-job-result.php deleted file mode 100644 index c10bb91..0000000 --- a/includes/interfaces/interface-job-result.php +++ /dev/null @@ -1,339 +0,0 @@ -successful = $successful; - $this->data = $data; - $this->error_message = $error_message; - $this->error_code = $error_code; - $this->metadata = $metadata; - $this->execution_time = $execution_time; - $this->memory_usage = $memory_usage; - } - - /** - * Create a successful result. - * - * @since 1.0.0 - * @param mixed $data Result data. - * @param array $metadata Additional metadata. - * @return Basic_Job_Result - */ - public static function success( $data = null, $metadata = array() ) { - return new self( true, $data, null, null, $metadata ); - } - - /** - * Create a failed result. - * - * @since 1.0.0 - * @param string $error_message Error message. - * @param string|int|null $error_code Error code. - * @param array $metadata Additional metadata. - * @return Basic_Job_Result - */ - public static function failure( $error_message, $error_code = null, $metadata = array() ) { - return new self( false, null, $error_message, $error_code, $metadata ); - } - - /** - * Check if the job was successful. - * - * @since 1.0.0 - * @return bool - */ - public function is_successful() { - return $this->successful; - } - - /** - * Get the result data. - * - * @since 1.0.0 - * @return mixed - */ - public function get_data() { - return $this->data; - } - - /** - * Get the error message if failed. - * - * @since 1.0.0 - * @return string|null - */ - public function get_error_message() { - return $this->error_message; - } - - /** - * Get the error code if failed. - * - * @since 1.0.0 - * @return string|int|null - */ - public function get_error_code() { - return $this->error_code; - } - - /** - * Get additional metadata about the execution. - * - * @since 1.0.0 - * @return array - */ - public function get_metadata() { - return $this->metadata; - } - - /** - * Get the execution time in seconds. - * - * @since 1.0.0 - * @return float - */ - public function get_execution_time() { - return $this->execution_time; - } - - /** - * Get the memory usage in bytes. - * - * @since 1.0.0 - * @return int - */ - public function get_memory_usage() { - return $this->memory_usage; - } - - /** - * Set execution time. - * - * @since 1.0.0 - * @param float $execution_time Execution time in seconds. - */ - public function set_execution_time( $execution_time ) { - $this->execution_time = $execution_time; - } - - /** - * Set memory usage. - * - * @since 1.0.0 - * @param int $memory_usage Memory usage in bytes. - */ - public function set_memory_usage( $memory_usage ) { - $this->memory_usage = $memory_usage; - } - - /** - * Convert result to array for storage. - * - * @since 1.0.0 - * @return array - */ - public function to_array() { - return array( - 'successful' => $this->successful, - 'data' => $this->data, - 'error_message' => $this->error_message, - 'error_code' => $this->error_code, - 'metadata' => $this->metadata, - 'execution_time' => $this->execution_time, - 'memory_usage' => $this->memory_usage, - ); - } - - /** - * Create result from array. - * - * @since 1.0.0 - * @param array $data Result data array. - * @return Basic_Job_Result - */ - public static function from_array( $data ) { - return new self( - $data[ 'successful' ] ?? true, - $data[ 'data' ] ?? null, - $data[ 'error_message' ] ?? null, - $data[ 'error_code' ] ?? null, - $data[ 'metadata' ] ?? array(), - $data[ 'execution_time' ] ?? 0.0, - $data[ 'memory_usage' ] ?? 0 - ); - } -} \ No newline at end of file diff --git a/includes/interfaces/interface-queue-job.php b/includes/interfaces/interface-queue-job.php deleted file mode 100644 index 97e3576..0000000 --- a/includes/interfaces/interface-queue-job.php +++ /dev/null @@ -1,122 +0,0 @@ -payload = $payload; - } - - /** - * Get the job type identifier. - * - * @since 1.0.0 - * @return string Job type. - */ - abstract public function get_job_type(); - - /** - * Execute the job. - * - * @since 1.0.0 - * @return Job_Result The job execution result. - */ - abstract public function execute(); - - /** - * Get the job payload data. - * - * @since 1.0.0 - * @return array Job payload. - */ - public function get_payload() { - return $this->payload; - } - - /** - * Get the job priority (lower number = higher priority). - * - * @since 1.0.0 - * @return int Priority level. - */ - public function get_priority() { - return $this->priority; - } - - /** - * Get the maximum number of retry attempts. - * - * @since 1.0.0 - * @return int Max retry attempts. - */ - public function get_retry_attempts() { - return $this->retry_attempts; - } - - /** - * Get the job timeout in seconds. - * - * @since 1.0.0 - * @return int Timeout in seconds. - */ - public function get_timeout() { - return $this->timeout; - } - - /** - * Get the queue name for this job. - * - * @since 1.0.0 - * @return string Queue name. - */ - public function get_queue_name() { - return $this->queue_name; - } - - /** - * Handle job failure. - * - * NOTE: $exception may be null when the job itself returned a failure result - * without throwing (e.g., wp_mail returned false). Code must be null-safe. - * - * @since 1.0.0 - * @param Exception|null $exception The exception that caused the failure (if any). - * @param int $attempt The current attempt number. - * @return void - */ - public function handle_failure( $exception, $attempt ) { - /** - * Fires when a job fails. - * - * @since 1.0.0 - * @param Abstract_Base_Job $job Job instance. - * @param Exception $exception Exception that caused the failure. - * @param int $attempt Attempt number. - */ - do_action( 'redis_queue_demo_job_failure', $this, $exception, $attempt ); - - // Log the failure. - if ( redis_queue_demo()->get_option( 'enable_logging', true ) ) { - $message = $exception instanceof Exception ? $exception->getMessage() : 'No exception object (job returned failure result)'; - error_log( - sprintf( - 'Redis Queue Demo: Job %s failed on attempt %d - %s', - $this->get_job_type(), - $attempt, - $message - ) - ); - } - } - - /** - * Determine if the job should be retried after failure. - * - * @since 1.0.0 - * @param Exception|null $exception The exception that caused the failure (if any). - * @param int $attempt The current attempt number (1-based). - * @return bool Whether to retry the job. - */ - public function should_retry( $exception, $attempt ) { - // Don't retry if we've reached max attempts. - if ( $attempt >= $this->retry_attempts ) { - return false; - } - - // If there is no exception object (logical failure), treat as retryable by default. - if ( ! ( $exception instanceof Exception ) ) { - return apply_filters( 'redis_queue_demo_should_retry_job', true, $this, null, $attempt ); - } - - // Don't retry for certain types of exceptions. - $non_retryable_exceptions = array( - 'InvalidArgumentException', - 'TypeError', - 'ParseError', - ); - - $exception_class = get_class( $exception ); - if ( in_array( $exception_class, $non_retryable_exceptions, true ) ) { - return false; - } - - /** - * Filter whether a job should be retried. - * - * @since 1.0.0 - * @param bool $should_retry Whether to retry. - * @param Abstract_Base_Job $job Job instance. - * @param Exception|null $exception Exception that caused the failure (if any). - * @param int $attempt Attempt number. - */ - return apply_filters( 'redis_queue_demo_should_retry_job', true, $this, $exception, $attempt ); - } - - /** - * Get retry delay in seconds for the given attempt. - * - * @since 1.0.0 - * @param int $attempt The attempt number. - * @return int Delay in seconds. - */ - public function get_retry_delay( $attempt ) { - $backoff_index = $attempt - 1; - - if ( isset( $this->retry_backoff[ $backoff_index ] ) ) { - $delay = $this->retry_backoff[ $backoff_index ]; - } else { - // Use exponential backoff if no specific delay is set. - $delay = min( pow( 2, $attempt ) * 60, 3600 ); // Max 1 hour. - } - - /** - * Filter retry delay for a job. - * - * @since 1.0.0 - * @param int $delay Delay in seconds. - * @param Abstract_Base_Job $job Job instance. - * @param int $attempt Attempt number. - */ - return apply_filters( 'redis_queue_demo_job_retry_delay', $delay, $this, $attempt ); - } - - /** - * Serialize the job for storage. - * - * @since 1.0.0 - * @return array Serialized job data. - */ - public function serialize() { - return array( - 'class' => get_class( $this ), - 'payload' => $this->payload, - 'priority' => $this->priority, - 'retry_attempts' => $this->retry_attempts, - 'timeout' => $this->timeout, - 'queue_name' => $this->queue_name, - 'retry_backoff' => $this->retry_backoff, - ); - } - - /** - * Deserialize job data and create job instance. - * - * @since 1.0.0 - * @param array $data Serialized job data. - * @return Queue_Job Job instance. - */ - public static function deserialize( $data ) { - $class = $data[ 'class' ] ?? null; - - if ( ! $class || ! class_exists( $class ) ) { - throw new Exception( 'Invalid job class in serialized data' ); - } - - $job = new $class( $data[ 'payload' ] ?? array() ); - - // Restore job properties. - if ( isset( $data[ 'priority' ] ) ) { - $job->priority = $data[ 'priority' ]; - } - if ( isset( $data[ 'retry_attempts' ] ) ) { - $job->retry_attempts = $data[ 'retry_attempts' ]; - } - if ( isset( $data[ 'timeout' ] ) ) { - $job->timeout = $data[ 'timeout' ]; - } - if ( isset( $data[ 'queue_name' ] ) ) { - $job->queue_name = $data[ 'queue_name' ]; - } - if ( isset( $data[ 'retry_backoff' ] ) ) { - $job->retry_backoff = $data[ 'retry_backoff' ]; - } - - return $job; - } - - /** - * Set job priority. - * - * @since 1.0.0 - * @param int $priority Priority level. - * @return $this - */ - public function set_priority( $priority ) { - $this->priority = (int) $priority; - return $this; - } - - /** - * Set maximum retry attempts. - * - * @since 1.0.0 - * @param int $attempts Max retry attempts. - * @return $this - */ - public function set_retry_attempts( $attempts ) { - $this->retry_attempts = (int) $attempts; - return $this; - } - - /** - * Set job timeout. - * - * @since 1.0.0 - * @param int $timeout Timeout in seconds. - * @return $this - */ - public function set_timeout( $timeout ) { - $this->timeout = (int) $timeout; - return $this; - } - - /** - * Set queue name. - * - * @since 1.0.0 - * @param string $queue_name Queue name. - * @return $this - */ - public function set_queue_name( $queue_name ) { - $this->queue_name = sanitize_text_field( $queue_name ); - return $this; - } - - /** - * Set retry backoff delays. - * - * @since 1.0.0 - * @param array $backoff Array of delays in seconds. - * @return $this - */ - public function set_retry_backoff( $backoff ) { - $this->retry_backoff = array_map( 'intval', $backoff ); - return $this; - } - - /** - * Validate payload data. - * - * @since 1.0.0 - * @param array $payload Payload to validate. - * @return bool|WP_Error True if valid, WP_Error if invalid. - */ - protected function validate_payload( $payload ) { - /** - * Filter payload validation for a job. - * - * @since 1.0.0 - * @param bool|WP_Error $is_valid Whether payload is valid. - * @param array $payload Payload data. - * @param Abstract_Base_Job $job Job instance. - */ - return apply_filters( 'redis_queue_demo_validate_job_payload', true, $payload, $this ); - } - - /** - * Get a value from the payload with a default. - * - * @since 1.0.0 - * @param string $key Payload key. - * @param mixed $default Default value. - * @return mixed Payload value or default. - */ - protected function get_payload_value( $key, $default = null ) { - return $this->payload[ $key ] ?? $default; - } - - /** - * Create a successful result. - * - * @since 1.0.0 - * @param mixed $data Result data. - * @param array $metadata Additional metadata. - * @return Basic_Job_Result - */ - protected function success( $data = null, $metadata = array() ) { - return Basic_Job_Result::success( $data, $metadata ); - } - - /** - * Create a failed result. - * - * @since 1.0.0 - * @param string $error_message Error message. - * @param string|int|null $error_code Error code. - * @param array $metadata Additional metadata. - * @return Basic_Job_Result - */ - protected function failure( $error_message, $error_code = null, $metadata = array() ) { - return Basic_Job_Result::failure( $error_message, $error_code, $metadata ); - } -} \ No newline at end of file diff --git a/jobs/class-api-sync-job.php b/jobs/class-api-sync-job.php deleted file mode 100644 index eb3a6ad..0000000 --- a/jobs/class-api-sync-job.php +++ /dev/null @@ -1,540 +0,0 @@ -queue_name = 'api'; - $this->priority = 40; // Medium-low priority. - $this->timeout = 300; // 5 minutes for API calls. - } - - /** - * Get the job type identifier. - * - * @since 1.0.0 - * @return string Job type. - */ - public function get_job_type() { - return 'api_sync'; - } - - /** - * Execute the API sync job. - * - * @since 1.0.0 - * @return Job_Result The job execution result. - */ - public function execute() { - try { - $sync_type = $this->get_payload_value( 'type', 'generic' ); - - switch ( $sync_type ) { - case 'social_media': - return $this->sync_social_media(); - case 'crm': - return $this->sync_crm_data(); - case 'analytics': - return $this->sync_analytics(); - case 'webhook': - return $this->send_webhook(); - case 'generic': - default: - return $this->generic_api_call(); - } - - } catch (Exception $e) { - return $this->failure( $e->getMessage(), $e->getCode() ); - } - } - - /** - * Sync social media posts. - * - * @since 1.0.0 - * @return Job_Result - */ - private function sync_social_media() { - $platforms = $this->get_payload_value( 'platforms', array() ); - $post_data = $this->get_payload_value( 'post_data', array() ); - - if ( empty( $platforms ) || empty( $post_data ) ) { - return $this->failure( 'Missing platforms or post data' ); - } - - $results = array(); - $successful = 0; - $failed = 0; - - foreach ( $platforms as $platform => $config ) { - try { - $result = $this->post_to_social_platform( $platform, $post_data, $config ); - $results[ $platform ] = $result; - - if ( $result[ 'success' ] ) { - $successful++; - } else { - $failed++; - } - - } catch (Exception $e) { - $failed++; - $results[ $platform ] = array( - 'success' => false, - 'error' => $e->getMessage(), - ); - } - } - - return $this->success( - array( - 'total' => count( $platforms ), - 'successful' => $successful, - 'failed' => $failed, - 'results' => $results, - 'post_data' => $post_data, - ), - array( 'sync_type' => 'social_media' ) - ); - } - - /** - * Sync CRM data. - * - * @since 1.0.0 - * @return Job_Result - */ - private function sync_crm_data() { - $crm_system = $this->get_payload_value( 'crm_system' ); - $operation = $this->get_payload_value( 'operation', 'sync' ); - $data = $this->get_payload_value( 'data', array() ); - - if ( ! $crm_system ) { - return $this->failure( 'CRM system not specified' ); - } - - switch ( $operation ) { - case 'create_contact': - return $this->create_crm_contact( $crm_system, $data ); - case 'update_contact': - return $this->update_crm_contact( $crm_system, $data ); - case 'sync_contacts': - return $this->sync_crm_contacts( $crm_system, $data ); - default: - return $this->failure( 'Unknown CRM operation: ' . $operation ); - } - } - - /** - * Sync analytics data. - * - * @since 1.0.0 - * @return Job_Result - */ - private function sync_analytics() { - $provider = $this->get_payload_value( 'provider', 'google_analytics' ); - $metrics = $this->get_payload_value( 'metrics', array() ); - $date_range = $this->get_payload_value( 'date_range', array() ); - - switch ( $provider ) { - case 'google_analytics': - return $this->sync_google_analytics( $metrics, $date_range ); - case 'custom_tracking': - return $this->sync_custom_tracking( $metrics, $date_range ); - default: - return $this->failure( 'Unknown analytics provider: ' . $provider ); - } - } - - /** - * Send webhook notification. - * - * @since 1.0.0 - * @return Job_Result - */ - private function send_webhook() { - $url = $this->get_payload_value( 'url' ); - $method = $this->get_payload_value( 'method', 'POST' ); - $headers = $this->get_payload_value( 'headers', array() ); - $data = $this->get_payload_value( 'data', array() ); - - if ( ! $url ) { - return $this->failure( 'Webhook URL not provided' ); - } - - $args = array( - 'method' => $method, - 'headers' => $headers, - 'timeout' => 30, - ); - - if ( in_array( $method, array( 'POST', 'PUT', 'PATCH' ), true ) ) { - $args[ 'body' ] = wp_json_encode( $data ); - if ( ! isset( $headers[ 'Content-Type' ] ) ) { - $args[ 'headers' ][ 'Content-Type' ] = 'application/json'; - } - } - - $response = wp_remote_request( $url, $args ); - - if ( is_wp_error( $response ) ) { - return $this->failure( 'Webhook request failed: ' . $response->get_error_message() ); - } - - $response_code = wp_remote_retrieve_response_code( $response ); - $response_body = wp_remote_retrieve_body( $response ); - - if ( $response_code >= 200 && $response_code < 300 ) { - return $this->success( - array( - 'url' => $url, - 'method' => $method, - 'response_code' => $response_code, - 'response_body' => $response_body, - ), - array( 'sync_type' => 'webhook' ) - ); - } else { - return $this->failure( - sprintf( 'Webhook returned error code %d: %s', $response_code, $response_body ), - $response_code - ); - } - } - - /** - * Generic API call. - * - * @since 1.0.0 - * @return Job_Result - */ - private function generic_api_call() { - $url = $this->get_payload_value( 'url' ); - $method = $this->get_payload_value( 'method', 'GET' ); - $headers = $this->get_payload_value( 'headers', array() ); - $data = $this->get_payload_value( 'data', array() ); - - if ( ! $url ) { - return $this->failure( 'API URL not provided' ); - } - - $args = array( - 'method' => $method, - 'headers' => $headers, - 'timeout' => 60, - ); - - if ( in_array( $method, array( 'POST', 'PUT', 'PATCH' ), true ) && ! empty( $data ) ) { - $args[ 'body' ] = wp_json_encode( $data ); - if ( ! isset( $headers[ 'Content-Type' ] ) ) { - $args[ 'headers' ][ 'Content-Type' ] = 'application/json'; - } - } - - $response = wp_remote_request( $url, $args ); - - if ( is_wp_error( $response ) ) { - return $this->failure( 'API request failed: ' . $response->get_error_message() ); - } - - $response_code = wp_remote_retrieve_response_code( $response ); - $response_body = wp_remote_retrieve_body( $response ); - - return $this->success( - array( - 'url' => $url, - 'method' => $method, - 'response_code' => $response_code, - 'response_body' => $response_body, - 'request_data' => $data, - ), - array( 'sync_type' => 'generic' ) - ); - } - - /** - * Post to social media platform. - * - * @since 1.0.0 - * @param string $platform Platform name. - * @param array $post_data Post data. - * @param array $config Platform configuration. - * @return array Result array. - */ - private function post_to_social_platform( $platform, $post_data, $config ) { - // This is a demo implementation. - // In real scenarios, you'd use platform-specific APIs. - - $api_endpoints = array( - 'facebook' => 'https://graph.facebook.com/v12.0/me/feed', - 'twitter' => 'https://api.twitter.com/2/tweets', - 'linkedin' => 'https://api.linkedin.com/v2/shares', - ); - - if ( ! isset( $api_endpoints[ $platform ] ) ) { - throw new Exception( 'Unsupported platform: ' . $platform ); - } - - $url = $api_endpoints[ $platform ]; - $headers = array(); - - // Add authentication headers. - if ( isset( $config[ 'access_token' ] ) ) { - $headers[ 'Authorization' ] = 'Bearer ' . $config[ 'access_token' ]; - } - - $args = array( - 'method' => 'POST', - 'headers' => $headers, - 'body' => wp_json_encode( $post_data ), - 'timeout' => 30, - ); - - $response = wp_remote_post( $url, $args ); - - if ( is_wp_error( $response ) ) { - throw new Exception( 'Platform API error: ' . $response->get_error_message() ); - } - - $response_code = wp_remote_retrieve_response_code( $response ); - $response_body = wp_remote_retrieve_body( $response ); - - return array( - 'success' => $response_code >= 200 && $response_code < 300, - 'response_code' => $response_code, - 'response_body' => $response_body, - 'platform' => $platform, - ); - } - - /** - * Create CRM contact. - * - * @since 1.0.0 - * @param string $crm_system CRM system name. - * @param array $data Contact data. - * @return Job_Result - */ - private function create_crm_contact( $crm_system, $data ) { - // Demo implementation for different CRM systems. - $crm_endpoints = array( - 'salesforce' => 'https://your-instance.salesforce.com/services/data/v52.0/sobjects/Contact/', - 'hubspot' => 'https://api.hubapi.com/crm/v3/objects/contacts', - ); - - if ( ! isset( $crm_endpoints[ $crm_system ] ) ) { - return $this->failure( 'Unsupported CRM system: ' . $crm_system ); - } - - // Simulate API call. - $contact_id = 'contact_' . uniqid(); - - return $this->success( - array( - 'crm_system' => $crm_system, - 'operation' => 'create_contact', - 'contact_id' => $contact_id, - 'contact_data' => $data, - ), - array( 'sync_type' => 'crm' ) - ); - } - - /** - * Update CRM contact. - * - * @since 1.0.0 - * @param string $crm_system CRM system name. - * @param array $data Contact data. - * @return Job_Result - */ - private function update_crm_contact( $crm_system, $data ) { - $contact_id = $data[ 'contact_id' ] ?? null; - - if ( ! $contact_id ) { - return $this->failure( 'Contact ID required for update' ); - } - - // Simulate API call. - return $this->success( - array( - 'crm_system' => $crm_system, - 'operation' => 'update_contact', - 'contact_id' => $contact_id, - 'updated_data' => $data, - ), - array( 'sync_type' => 'crm' ) - ); - } - - /** - * Sync CRM contacts. - * - * @since 1.0.0 - * @param string $crm_system CRM system name. - * @param array $data Sync parameters. - * @return Job_Result - */ - private function sync_crm_contacts( $crm_system, $data ) { - $batch_size = $data[ 'batch_size' ] ?? 100; - $offset = $data[ 'offset' ] ?? 0; - - // Simulate batch sync. - $synced = wp_rand( 50, $batch_size ); - - return $this->success( - array( - 'crm_system' => $crm_system, - 'operation' => 'sync_contacts', - 'synced' => $synced, - 'offset' => $offset, - 'batch_size' => $batch_size, - ), - array( 'sync_type' => 'crm' ) - ); - } - - /** - * Sync Google Analytics data. - * - * @since 1.0.0 - * @param array $metrics Metrics to sync. - * @param array $date_range Date range. - * @return Job_Result - */ - private function sync_google_analytics( $metrics, $date_range ) { - // Simulate Google Analytics API call. - $data = array(); - - foreach ( $metrics as $metric ) { - $data[ $metric ] = wp_rand( 100, 10000 ); - } - - return $this->success( - array( - 'provider' => 'google_analytics', - 'metrics' => $metrics, - 'date_range' => $date_range, - 'data' => $data, - ), - array( 'sync_type' => 'analytics' ) - ); - } - - /** - * Sync custom tracking data. - * - * @since 1.0.0 - * @param array $metrics Metrics to sync. - * @param array $date_range Date range. - * @return Job_Result - */ - private function sync_custom_tracking( $metrics, $date_range ) { - // Simulate custom tracking data collection. - global $wpdb; - - // Example: Get post views from custom table. - $table_name = $wpdb->prefix . 'post_views'; - $start_date = $date_range[ 'start' ] ?? date( 'Y-m-d', strtotime( '-7 days' ) ); - $end_date = $date_range[ 'end' ] ?? date( 'Y-m-d' ); - - // Simulate query results. - $data = array( - 'page_views' => wp_rand( 1000, 5000 ), - 'unique_visitors' => wp_rand( 500, 2000 ), - 'bounce_rate' => wp_rand( 30, 70 ), - ); - - return $this->success( - array( - 'provider' => 'custom_tracking', - 'metrics' => $metrics, - 'date_range' => array( - 'start' => $start_date, - 'end' => $end_date, - ), - 'data' => $data, - ), - array( 'sync_type' => 'analytics' ) - ); - } - - /** - * Create a social media sync job. - * - * @since 1.0.0 - * @param array $platforms Platforms and their configs. - * @param array $post_data Post data to share. - * @return API_Sync_Job - */ - public static function create_social_media_job( $platforms, $post_data ) { - return new self( array( - 'type' => 'social_media', - 'platforms' => $platforms, - 'post_data' => $post_data, - ) ); - } - - /** - * Create a webhook job. - * - * @since 1.0.0 - * @param string $url Webhook URL. - * @param array $data Data to send. - * @param string $method HTTP method. - * @param array $headers Optional headers. - * @return API_Sync_Job - */ - public static function create_webhook_job( $url, $data, $method = 'POST', $headers = array() ) { - return new self( array( - 'type' => 'webhook', - 'url' => $url, - 'data' => $data, - 'method' => $method, - 'headers' => $headers, - ) ); - } - - /** - * Create a CRM sync job. - * - * @since 1.0.0 - * @param string $crm_system CRM system name. - * @param string $operation Operation type. - * @param array $data Operation data. - * @return API_Sync_Job - */ - public static function create_crm_job( $crm_system, $operation, $data ) { - return new self( array( - 'type' => 'crm', - 'crm_system' => $crm_system, - 'operation' => $operation, - 'data' => $data, - ) ); - } -} \ No newline at end of file diff --git a/jobs/class-email-job.php b/jobs/class-email-job.php deleted file mode 100644 index 12e170a..0000000 --- a/jobs/class-email-job.php +++ /dev/null @@ -1,416 +0,0 @@ -queue_name = 'email'; - $this->priority = 20; // High priority for emails. - $this->timeout = 120; // 2 minutes should be enough for email. - } - - /** - * Get the job type identifier. - * - * @since 1.0.0 - * @return string Job type. - */ - public function get_job_type() { - return 'email'; - } - - /** - * Execute the email job. - * - * @since 1.0.0 - * @return Job_Result The job execution result. - */ - public function execute() { - try { - $email_type = $this->get_payload_value( 'type', 'single' ); - - switch ( $email_type ) { - case 'single': - return $this->send_single_email(); - case 'bulk': - return $this->send_bulk_emails(); - case 'newsletter': - return $this->send_newsletter(); - default: - return $this->failure( 'Unknown email type: ' . $email_type ); - } - - } catch (Exception $e) { - return $this->failure( $e->getMessage(), $e->getCode() ); - } - } - - /** - * Send a single email. - * - * @since 1.0.0 - * @return Job_Result - */ - private function send_single_email() { - $to = $this->get_payload_value( 'to' ); - $subject = $this->get_payload_value( 'subject' ); - $message = $this->get_payload_value( 'message' ); - $headers = $this->get_payload_value( 'headers', array() ); - - if ( empty( $to ) || empty( $subject ) || empty( $message ) ) { - return $this->failure( 'Missing required email fields: to, subject, message' ); - } - - // Handle attachments if provided. - $attachments = $this->get_payload_value( 'attachments', array() ); - - $sent = wp_mail( $to, $subject, $message, $headers, $attachments ); - - if ( $sent ) { - return $this->success( - array( 'sent' => true, 'to' => $to ), - array( 'email_type' => 'single' ) - ); - } else { - // Attempt to surface PHPMailer error if available. - $phpmailer_error = null; - global $phpmailer; - if ( isset( $phpmailer ) && is_object( $phpmailer ) && ! empty( $phpmailer->ErrorInfo ) ) { - $phpmailer_error = $phpmailer->ErrorInfo; - } - $metadata = array( - 'email_type' => 'single', - 'phpmailer_error' => $phpmailer_error, - ); - return $this->failure( 'Failed to send email to: ' . $to, null, $metadata ); - } - } - - /** - * Send bulk emails. - * - * @since 1.0.0 - * @return Job_Result - */ - private function send_bulk_emails() { - $emails = $this->get_payload_value( 'emails', array() ); - $sent = 0; - $failed = 0; - $failures = array(); - - if ( empty( $emails ) || ! is_array( $emails ) ) { - return $this->failure( 'No emails provided or invalid format' ); - } - - foreach ( $emails as $email ) { - $to = $email[ 'to' ] ?? ''; - $subject = $email[ 'subject' ] ?? ''; - $message = $email[ 'message' ] ?? ''; - $headers = $email[ 'headers' ] ?? array(); - - if ( empty( $to ) || empty( $subject ) || empty( $message ) ) { - $failed++; - $failures[] = array( - 'to' => $to, - 'reason' => 'Missing required fields', - ); - continue; - } - - $result = wp_mail( $to, $subject, $message, $headers ); - - if ( $result ) { - $sent++; - } else { - $failed++; - $failures[] = array( - 'to' => $to, - 'reason' => 'wp_mail returned false', - ); - } - - // Add small delay between emails to prevent overwhelming SMTP server. - usleep( 100000 ); // 0.1 seconds. - } - - $total = count( $emails ); - - return $this->success( - array( - 'total' => $total, - 'sent' => $sent, - 'failed' => $failed, - 'failures' => $failures, - ), - array( 'email_type' => 'bulk' ) - ); - } - - /** - * Send newsletter email. - * - * @since 1.0.0 - * @return Job_Result - */ - private function send_newsletter() { - $subject = $this->get_payload_value( 'subject' ); - $message = $this->get_payload_value( 'message' ); - $subscriber_ids = $this->get_payload_value( 'subscriber_ids', array() ); - $headers = $this->get_payload_value( 'headers', array() ); - - if ( empty( $subject ) || empty( $message ) ) { - return $this->failure( 'Missing required newsletter fields: subject, message' ); - } - - // Get subscribers from database or use provided IDs. - $subscribers = $this->get_newsletter_subscribers( $subscriber_ids ); - - if ( empty( $subscribers ) ) { - return $this->failure( 'No subscribers found' ); - } - - $sent = 0; - $failed = 0; - $failures = array(); - - foreach ( $subscribers as $subscriber ) { - $to = is_array( $subscriber ) ? $subscriber[ 'email' ] : $subscriber; - - if ( ! is_email( $to ) ) { - $failed++; - $failures[] = array( - 'to' => $to, - 'reason' => 'Invalid email address', - ); - continue; - } - - // Personalize message if subscriber data is available. - $personalized_message = $this->personalize_message( $message, $subscriber ); - - $result = wp_mail( $to, $subject, $personalized_message, $headers ); - - if ( $result ) { - $sent++; - } else { - $failed++; - $failures[] = array( - 'to' => $to, - 'reason' => 'wp_mail returned false', - ); - } - - // Add delay between emails. - usleep( 200000 ); // 0.2 seconds. - } - - $total = count( $subscribers ); - - return $this->success( - array( - 'total' => $total, - 'sent' => $sent, - 'failed' => $failed, - 'failures' => $failures, - 'subject' => $subject, - ), - array( 'email_type' => 'newsletter' ) - ); - } - - /** - * Get newsletter subscribers. - * - * @since 1.0.0 - * @param array $subscriber_ids Optional specific subscriber IDs. - * @return array Array of subscriber data. - */ - private function get_newsletter_subscribers( $subscriber_ids = array() ) { - // This is a demo implementation. - // In a real scenario, you'd query your subscriber database. - - if ( ! empty( $subscriber_ids ) ) { - // Get specific subscribers by ID. - global $wpdb; - $ids_placeholder = implode( ',', array_fill( 0, count( $subscriber_ids ), '%d' ) ); - - $subscribers = $wpdb->get_results( - $wpdb->prepare( - "SELECT email, display_name FROM {$wpdb->users} WHERE ID IN ($ids_placeholder)", - ...$subscriber_ids - ), - ARRAY_A - ); - } else { - // Get all users as subscribers (demo). - $users = get_users( array( 'fields' => array( 'user_email', 'display_name' ) ) ); - $subscribers = array(); - - foreach ( $users as $user ) { - $subscribers[] = array( - 'email' => $user->user_email, - 'display_name' => $user->display_name, - ); - } - } - - return $subscribers; - } - - /** - * Personalize message with subscriber data. - * - * @since 1.0.0 - * @param string $message Original message. - * @param array|string $subscriber Subscriber data. - * @return string Personalized message. - */ - private function personalize_message( $message, $subscriber ) { - if ( is_array( $subscriber ) ) { - $name = $subscriber[ 'display_name' ] ?? $subscriber[ 'email' ]; - } else { - $name = $subscriber; - } - - // Replace placeholders. - $replacements = array( - '{name}' => $name, - '{email}' => is_array( $subscriber ) ? $subscriber[ 'email' ] : $subscriber, - ); - - return str_replace( array_keys( $replacements ), array_values( $replacements ), $message ); - } - - /** - * Handle job failure specific to email jobs. - * - * @since 1.0.0 - * @param Exception|null $exception The exception that caused the failure (if any). - * @param int $attempt The current attempt number. - * @return void - */ - public function handle_failure( $exception, $attempt ) { - parent::handle_failure( $exception, $attempt ); - - // Additional email-specific failure handling. - $email_type = $this->get_payload_value( 'type', 'single' ); - - /** - * Fires when an email job fails. - * - * @since 1.0.0 - * @param Email_Job $job Email job instance. - * @param Exception $exception Exception that caused the failure. - * @param int $attempt Attempt number. - * @param string $email_type Email type (single, bulk, newsletter). - */ - do_action( 'redis_queue_demo_email_job_failed', $this, $exception, $attempt, $email_type ); - } - - /** - * Determine if the email job should be retried. - * - * @since 1.0.0 - * @param Exception|null $exception The exception that caused the failure (if any). - * @param int $attempt The current attempt number. - * @return bool Whether to retry the job. - */ - public function should_retry( $exception, $attempt ) { - // Don't retry for invalid email addresses (only if we have an exception message). - if ( $exception instanceof Exception && strpos( $exception->getMessage(), 'Invalid email' ) !== false ) { - return false; - } - - // If there is no exception (logical failure like wp_mail returned false), avoid noisy retries. - if ( ! ( $exception instanceof Exception ) ) { - return false; - } - - // Don't retry bulk emails with too many failures. - $email_type = $this->get_payload_value( 'type', 'single' ); - if ( 'bulk' === $email_type && $attempt >= 2 ) { - return false; - } - - return parent::should_retry( $exception, $attempt ); - } - - /** - * Create an email job instance. - * - * @since 1.0.0 - * @param string $to Recipient email. - * @param string $subject Email subject. - * @param string $message Email message. - * @param array $headers Optional headers. - * @return Email_Job - */ - public static function create_single_email( $to, $subject, $message, $headers = array() ) { - return new self( array( - 'type' => 'single', - 'to' => $to, - 'subject' => $subject, - 'message' => $message, - 'headers' => $headers, - ) ); - } - - /** - * Create a bulk email job instance. - * - * @since 1.0.0 - * @param array $emails Array of email data. - * @return Email_Job - */ - public static function create_bulk_emails( $emails ) { - return new self( array( - 'type' => 'bulk', - 'emails' => $emails, - ) ); - } - - /** - * Create a newsletter job instance. - * - * @since 1.0.0 - * @param string $subject Newsletter subject. - * @param string $message Newsletter message. - * @param array $subscriber_ids Optional specific subscriber IDs. - * @param array $headers Optional headers. - * @return Email_Job - */ - public static function create_newsletter( $subject, $message, $subscriber_ids = array(), $headers = array() ) { - return new self( array( - 'type' => 'newsletter', - 'subject' => $subject, - 'message' => $message, - 'subscriber_ids' => $subscriber_ids, - 'headers' => $headers, - ) ); - } -} \ No newline at end of file diff --git a/jobs/class-image-processing-job.php b/jobs/class-image-processing-job.php deleted file mode 100644 index 7773333..0000000 --- a/jobs/class-image-processing-job.php +++ /dev/null @@ -1,478 +0,0 @@ -queue_name = 'media'; - $this->priority = 30; // Medium priority. - $this->timeout = 600; // 10 minutes for image processing. - } - - /** - * Get the job type identifier. - * - * @since 1.0.0 - * @return string Job type. - */ - public function get_job_type() { - return 'image_processing'; - } - - /** - * Execute the image processing job. - * - * @since 1.0.0 - * @return Job_Result The job execution result. - */ - public function execute() { - try { - $operation = $this->get_payload_value( 'operation', 'thumbnail' ); - - switch ( $operation ) { - case 'thumbnail': - return $this->generate_thumbnails(); - case 'optimize': - return $this->optimize_image(); - case 'watermark': - return $this->add_watermark(); - case 'bulk_thumbnails': - return $this->generate_bulk_thumbnails(); - default: - return $this->failure( 'Unknown image operation: ' . $operation ); - } - - } catch (Exception $e) { - return $this->failure( $e->getMessage(), $e->getCode() ); - } - } - - /** - * Generate thumbnails for an image. - * - * @since 1.0.0 - * @return Job_Result - */ - private function generate_thumbnails() { - $attachment_id = $this->get_payload_value( 'attachment_id' ); - $sizes = $this->get_payload_value( 'sizes', array() ); - - if ( ! $attachment_id ) { - return $this->failure( 'Missing attachment ID' ); - } - - $file_path = get_attached_file( $attachment_id ); - if ( ! $file_path || ! file_exists( $file_path ) ) { - return $this->failure( 'Image file not found: ' . $attachment_id ); - } - - $generated_sizes = array(); - $failed_sizes = array(); - - // Get all image sizes if none specified. - if ( empty( $sizes ) ) { - $sizes = array_keys( wp_get_additional_image_sizes() ); - $sizes = array_merge( $sizes, array( 'thumbnail', 'medium', 'medium_large', 'large' ) ); - } - - foreach ( $sizes as $size ) { - try { - $resized = image_make_intermediate_size( $file_path, get_option( $size . '_size_w' ), get_option( $size . '_size_h' ), get_option( $size . '_crop' ) ); - - if ( $resized ) { - $generated_sizes[ $size ] = $resized; - } else { - $failed_sizes[] = $size; - } - } catch (Exception $e) { - $failed_sizes[] = array( - 'size' => $size, - 'reason' => $e->getMessage(), - ); - } - } - - // Update attachment metadata. - if ( ! empty( $generated_sizes ) ) { - $metadata = wp_get_attachment_metadata( $attachment_id ); - $metadata[ 'sizes' ] = array_merge( $metadata[ 'sizes' ] ?? array(), $generated_sizes ); - wp_update_attachment_metadata( $attachment_id, $metadata ); - } - - return $this->success( - array( - 'attachment_id' => $attachment_id, - 'generated_sizes' => $generated_sizes, - 'failed_sizes' => $failed_sizes, - 'total_sizes' => count( $sizes ), - 'successful_sizes' => count( $generated_sizes ), - ), - array( 'operation' => 'thumbnail' ) - ); - } - - /** - * Optimize an image. - * - * @since 1.0.0 - * @return Job_Result - */ - private function optimize_image() { - $attachment_id = $this->get_payload_value( 'attachment_id' ); - $quality = $this->get_payload_value( 'quality', 85 ); - $format = $this->get_payload_value( 'format', null ); - - if ( ! $attachment_id ) { - return $this->failure( 'Missing attachment ID' ); - } - - $file_path = get_attached_file( $attachment_id ); - if ( ! $file_path || ! file_exists( $file_path ) ) { - return $this->failure( 'Image file not found: ' . $attachment_id ); - } - - $original_size = filesize( $file_path ); - $backup_path = $file_path . '.backup'; - - // Create backup. - if ( ! copy( $file_path, $backup_path ) ) { - return $this->failure( 'Failed to create backup' ); - } - - try { - $image_type = wp_check_filetype( $file_path ); - $image = wp_get_image_editor( $file_path ); - - if ( is_wp_error( $image ) ) { - unlink( $backup_path ); - return $this->failure( 'Failed to load image: ' . $image->get_error_message() ); - } - - // Set quality. - $image->set_quality( $quality ); - - // Convert format if requested. - if ( $format && $format !== $image_type[ 'ext' ] ) { - $new_path = preg_replace( '/\.[^.]+$/', '.' . $format, $file_path ); - $saved = $image->save( $new_path, 'image/' . $format ); - - if ( is_wp_error( $saved ) ) { - unlink( $backup_path ); - return $this->failure( 'Failed to convert image format: ' . $saved->get_error_message() ); - } - - // Update attachment file path. - update_attached_file( $attachment_id, $saved[ 'path' ] ); - $file_path = $saved[ 'path' ]; - } else { - $saved = $image->save( $file_path ); - - if ( is_wp_error( $saved ) ) { - // Restore from backup. - copy( $backup_path, $file_path ); - unlink( $backup_path ); - return $this->failure( 'Failed to optimize image: ' . $saved->get_error_message() ); - } - } - - $new_size = filesize( $file_path ); - $saved_bytes = $original_size - $new_size; - $saved_percent = $original_size > 0 ? ( $saved_bytes / $original_size ) * 100 : 0; - - // Clean up backup. - unlink( $backup_path ); - - return $this->success( - array( - 'attachment_id' => $attachment_id, - 'original_size' => $original_size, - 'new_size' => $new_size, - 'saved_bytes' => $saved_bytes, - 'saved_percent' => round( $saved_percent, 2 ), - 'quality' => $quality, - 'format_changed' => $format ? true : false, - ), - array( 'operation' => 'optimize' ) - ); - - } catch (Exception $e) { - // Restore from backup on error. - if ( file_exists( $backup_path ) ) { - copy( $backup_path, $file_path ); - unlink( $backup_path ); - } - return $this->failure( 'Image optimization failed: ' . $e->getMessage() ); - } - } - - /** - * Add watermark to an image. - * - * @since 1.0.0 - * @return Job_Result - */ - private function add_watermark() { - $attachment_id = $this->get_payload_value( 'attachment_id' ); - $watermark_id = $this->get_payload_value( 'watermark_id' ); - $position = $this->get_payload_value( 'position', 'bottom-right' ); - $opacity = $this->get_payload_value( 'opacity', 50 ); - $margin = $this->get_payload_value( 'margin', 10 ); - - if ( ! $attachment_id || ! $watermark_id ) { - return $this->failure( 'Missing attachment ID or watermark ID' ); - } - - $image_path = get_attached_file( $attachment_id ); - $watermark_path = get_attached_file( $watermark_id ); - - if ( ! $image_path || ! file_exists( $image_path ) ) { - return $this->failure( 'Main image file not found' ); - } - - if ( ! $watermark_path || ! file_exists( $watermark_path ) ) { - return $this->failure( 'Watermark image file not found' ); - } - - try { - $image = wp_get_image_editor( $image_path ); - $watermark = wp_get_image_editor( $watermark_path ); - - if ( is_wp_error( $image ) ) { - return $this->failure( 'Failed to load main image: ' . $image->get_error_message() ); - } - - if ( is_wp_error( $watermark ) ) { - return $this->failure( 'Failed to load watermark: ' . $watermark->get_error_message() ); - } - - $image_size = $image->get_size(); - $watermark_size = $watermark->get_size(); - - // Calculate watermark position. - $coordinates = $this->calculate_watermark_position( - $image_size, - $watermark_size, - $position, - $margin - ); - - // Apply watermark (this is a simplified example). - // In a real implementation, you'd need to handle different image editors and opacity. - $result = $image->save( $image_path ); - - if ( is_wp_error( $result ) ) { - return $this->failure( 'Failed to save watermarked image: ' . $result->get_error_message() ); - } - - return $this->success( - array( - 'attachment_id' => $attachment_id, - 'watermark_id' => $watermark_id, - 'position' => $position, - 'coordinates' => $coordinates, - 'opacity' => $opacity, - ), - array( 'operation' => 'watermark' ) - ); - - } catch (Exception $e) { - return $this->failure( 'Watermark application failed: ' . $e->getMessage() ); - } - } - - /** - * Generate thumbnails for multiple images. - * - * @since 1.0.0 - * @return Job_Result - */ - private function generate_bulk_thumbnails() { - $attachment_ids = $this->get_payload_value( 'attachment_ids', array() ); - $sizes = $this->get_payload_value( 'sizes', array() ); - - if ( empty( $attachment_ids ) ) { - return $this->failure( 'No attachment IDs provided' ); - } - - $processed = 0; - $failed = 0; - $results = array(); - - foreach ( $attachment_ids as $attachment_id ) { - try { - // Create individual thumbnail job. - $thumbnail_job = new self( array( - 'operation' => 'thumbnail', - 'attachment_id' => $attachment_id, - 'sizes' => $sizes, - ) ); - - $result = $thumbnail_job->generate_thumbnails(); - - if ( $result->is_successful() ) { - $processed++; - $results[ $attachment_id ] = $result->get_data(); - } else { - $failed++; - $results[ $attachment_id ] = array( - 'error' => $result->get_error_message(), - ); - } - - } catch (Exception $e) { - $failed++; - $results[ $attachment_id ] = array( - 'error' => $e->getMessage(), - ); - } - } - - return $this->success( - array( - 'total' => count( $attachment_ids ), - 'processed' => $processed, - 'failed' => $failed, - 'results' => $results, - ), - array( 'operation' => 'bulk_thumbnails' ) - ); - } - - /** - * Calculate watermark position coordinates. - * - * @since 1.0.0 - * @param array $image_size Image dimensions. - * @param array $watermark_size Watermark dimensions. - * @param string $position Position string. - * @param int $margin Margin in pixels. - * @return array X and Y coordinates. - */ - private function calculate_watermark_position( $image_size, $watermark_size, $position, $margin ) { - $x = 0; - $y = 0; - - switch ( $position ) { - case 'top-left': - $x = $margin; - $y = $margin; - break; - case 'top-center': - $x = ( $image_size[ 'width' ] - $watermark_size[ 'width' ] ) / 2; - $y = $margin; - break; - case 'top-right': - $x = $image_size[ 'width' ] - $watermark_size[ 'width' ] - $margin; - $y = $margin; - break; - case 'center-left': - $x = $margin; - $y = ( $image_size[ 'height' ] - $watermark_size[ 'height' ] ) / 2; - break; - case 'center': - $x = ( $image_size[ 'width' ] - $watermark_size[ 'width' ] ) / 2; - $y = ( $image_size[ 'height' ] - $watermark_size[ 'height' ] ) / 2; - break; - case 'center-right': - $x = $image_size[ 'width' ] - $watermark_size[ 'width' ] - $margin; - $y = ( $image_size[ 'height' ] - $watermark_size[ 'height' ] ) / 2; - break; - case 'bottom-left': - $x = $margin; - $y = $image_size[ 'height' ] - $watermark_size[ 'height' ] - $margin; - break; - case 'bottom-center': - $x = ( $image_size[ 'width' ] - $watermark_size[ 'width' ] ) / 2; - $y = $image_size[ 'height' ] - $watermark_size[ 'height' ] - $margin; - break; - case 'bottom-right': - default: - $x = $image_size[ 'width' ] - $watermark_size[ 'width' ] - $margin; - $y = $image_size[ 'height' ] - $watermark_size[ 'height' ] - $margin; - break; - } - - return array( 'x' => max( 0, $x ), 'y' => max( 0, $y ) ); - } - - /** - * Create a thumbnail generation job. - * - * @since 1.0.0 - * @param int $attachment_id Attachment ID. - * @param array $sizes Optional specific sizes. - * @return Image_Processing_Job - */ - public static function create_thumbnail_job( $attachment_id, $sizes = array() ) { - return new self( array( - 'operation' => 'thumbnail', - 'attachment_id' => $attachment_id, - 'sizes' => $sizes, - ) ); - } - - /** - * Create an image optimization job. - * - * @since 1.0.0 - * @param int $attachment_id Attachment ID. - * @param int $quality Image quality (1-100). - * @param string $format Optional format conversion. - * @return Image_Processing_Job - */ - public static function create_optimization_job( $attachment_id, $quality = 85, $format = null ) { - return new self( array( - 'operation' => 'optimize', - 'attachment_id' => $attachment_id, - 'quality' => $quality, - 'format' => $format, - ) ); - } - - /** - * Create a watermark job. - * - * @since 1.0.0 - * @param int $attachment_id Attachment ID. - * @param int $watermark_id Watermark attachment ID. - * @param string $position Watermark position. - * @param int $opacity Opacity percentage. - * @param int $margin Margin in pixels. - * @return Image_Processing_Job - */ - public static function create_watermark_job( $attachment_id, $watermark_id, $position = 'bottom-right', $opacity = 50, $margin = 10 ) { - return new self( array( - 'operation' => 'watermark', - 'attachment_id' => $attachment_id, - 'watermark_id' => $watermark_id, - 'position' => $position, - 'opacity' => $opacity, - 'margin' => $margin, - ) ); - } -} \ No newline at end of file diff --git a/readme.txt b/readme.txt index 221a789..da12b7e 100644 --- a/readme.txt +++ b/readme.txt @@ -5,7 +5,7 @@ Tags: redis, queue, background, jobs, performance Requires at least: 6.7 Tested up to: 6.8 Requires PHP: 8.3 -Stable tag: 1.0.2 +Stable tag: 1.2.0 License: GPL v2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -15,7 +15,7 @@ Redis-backed background job processing for WordPress: priority, delay, retries, Redis Queue Demo is a comprehensive WordPress plugin that demonstrates how to implement enterprise-grade background job processing using Redis queues. This plugin showcases effective techniques for handling time-consuming, resource-intensive, or critical tasks asynchronously, improving user experience and site performance. -**Key Features (1.0.2):** +**Key Features (1.2.0):** * **Background Job Processing**: Handle time-consuming tasks without blocking user interactions * **REST API Integration**: Complete REST API for worker management, job creation, stats & health @@ -102,6 +102,14 @@ The plugin includes fallback mechanisms and graceful error handling. Jobs will f == Changelog == += 1.2.0 = +* Removed legacy global class aliases and all back-compat shims +* Deleted deprecated `includes/` directory (fully namespaced codebase) +* Dropped legacy job_type inference variants; only canonical types accepted +* Removed fallback to global `Sync_Worker`; namespaced worker required +* Documentation cleanup to reflect canonical usage & namespaced classes +* General refactor / modernization pass + = 1.0.2 = * Added GitHub updater class and release automation workflows * Added funding configuration @@ -130,6 +138,9 @@ The plugin includes fallback mechanisms and graceful error handling. Jobs will f == Upgrade Notice == += 1.2.0 = +Removes deprecated legacy compatibility layers. Only namespaced classes and canonical job types remain. + = 1.0.2 = Adds GitHub-based updates, enriched docs & metadata. No database changes. diff --git a/redis-queue-demo.php b/redis-queue-demo.php index 9181a03..75323a9 100644 --- a/redis-queue-demo.php +++ b/redis-queue-demo.php @@ -1,516 +1,53 @@ init_hooks(); - } - - /** - * Initialize WordPress hooks. - * - * @since 1.0.0 - */ - private function init_hooks() { - register_activation_hook( REDIS_QUEUE_DEMO_PLUGIN_FILE, array( $this, 'activate' ) ); - register_deactivation_hook( REDIS_QUEUE_DEMO_PLUGIN_FILE, array( $this, 'deactivate' ) ); - - add_action( 'plugins_loaded', array( $this, 'load_textdomain' ) ); - add_action( 'init', array( $this, 'init' ) ); - add_action( 'rest_api_init', array( $this, 'init_rest_api' ) ); - } - - /** - * Initialize plugin. - * - * @since 1.0.0 - */ - public function init() { - // Load dependencies. - $this->load_dependencies(); - - // Initialize components. - $this->init_components(); - - /** - * Fires after Redis Queue Demo has been initialized. - * - * @since 1.0.0 - * @param Redis_Queue_Demo $plugin The main plugin instance. - */ - do_action( 'redis_queue_demo_init', $this ); - } - - /** - * Load plugin dependencies. - * - * @since 1.0.0 - */ - private function load_dependencies() { - - - // Load interfaces. - require_once REDIS_QUEUE_DEMO_PLUGIN_DIR . 'includes/interfaces/interface-queue-job.php'; - require_once REDIS_QUEUE_DEMO_PLUGIN_DIR . 'includes/interfaces/interface-job-result.php'; - - // Load core classes. - require_once REDIS_QUEUE_DEMO_PLUGIN_DIR . 'includes/class-redis-queue-manager.php'; - require_once REDIS_QUEUE_DEMO_PLUGIN_DIR . 'includes/class-job-processor.php'; - - // Load job implementations. - require_once REDIS_QUEUE_DEMO_PLUGIN_DIR . 'jobs/abstract-base-job.php'; - require_once REDIS_QUEUE_DEMO_PLUGIN_DIR . 'jobs/class-email-job.php'; - require_once REDIS_QUEUE_DEMO_PLUGIN_DIR . 'jobs/class-image-processing-job.php'; - require_once REDIS_QUEUE_DEMO_PLUGIN_DIR . 'jobs/class-api-sync-job.php'; - - // Load workers. - require_once REDIS_QUEUE_DEMO_PLUGIN_DIR . 'workers/class-sync-worker.php'; - - // Load API and admin components. - require_once REDIS_QUEUE_DEMO_PLUGIN_DIR . 'api/class-rest-controller.php'; - - // Load admin classes only in admin area. - if ( is_admin() ) { - require_once REDIS_QUEUE_DEMO_PLUGIN_DIR . 'admin/class-admin-interface.php'; - } - } - - /** - * Initialize plugin components. - * - * @since 1.0.0 - */ - private function init_components() { - $this->queue_manager = new Redis_Queue_Manager(); - $this->job_processor = new Job_Processor( $this->queue_manager ); - $this->rest_controller = new REST_Controller( $this->queue_manager, $this->job_processor ); - - if ( is_admin() && class_exists( 'Admin_Interface' ) ) { - $this->admin_interface = new Admin_Interface( $this->queue_manager, $this->job_processor ); - $this->admin_interface->init(); - } - } - - /** - * Initialize REST API. - * - * @since 1.0.0 - */ - public function init_rest_api() { - if ( $this->rest_controller ) { - $this->rest_controller->register_routes(); - } - } - - /** - * Load plugin textdomain. - * - * @since 1.0.0 - */ - public function load_textdomain() { - load_plugin_textdomain( - 'redis-queue-demo', - false, - dirname( REDIS_QUEUE_DEMO_PLUGIN_BASENAME ) . '/languages' - ); - } - - /** - * Plugin activation. - * - * @since 1.0.0 - */ - public function activate() { - // Check PHP version. - if ( version_compare( PHP_VERSION, '8.3', '<' ) ) { - deactivate_plugins( REDIS_QUEUE_DEMO_PLUGIN_BASENAME ); - wp_die( - esc_html__( 'Redis Queue Demo requires PHP 8.3 or higher.', 'redis-queue-demo' ), - esc_html__( 'Plugin Activation Error', 'redis-queue-demo' ), - array( 'back_link' => true ) - ); - } - - // Check if Redis extension is available. - if ( ! extension_loaded( 'redis' ) && ! class_exists( 'Predis\Client' ) ) { - deactivate_plugins( REDIS_QUEUE_DEMO_PLUGIN_BASENAME ); - wp_die( - esc_html__( 'Redis Queue Demo requires either the Redis PHP extension or Predis library.', 'redis-queue-demo' ), - esc_html__( 'Plugin Activation Error', 'redis-queue-demo' ), - array( 'back_link' => true ) - ); - } - - // Create database tables. - $this->create_tables(); - - // Set default options. - $this->set_default_options(); - // Flush rewrite rules. - flush_rewrite_rules(); - - /** - * Fires on plugin activation. - * - * @since 1.0.0 - */ - do_action( 'redis_queue_demo_activate' ); - } - - /** - * Plugin deactivation. - * - * @since 1.0.0 - */ - public function deactivate() { - // Clear scheduled events. - wp_clear_scheduled_hook( 'redis_queue_demo_process_jobs' ); - - // Flush rewrite rules. - flush_rewrite_rules(); - - /** - * Fires on plugin deactivation. - * - * @since 1.0.0 - */ - do_action( 'redis_queue_demo_deactivate' ); - } - - /** - * Create database tables. - * - * @since 1.0.0 - */ - private function create_tables() { - global $wpdb; - - $charset_collate = $wpdb->get_charset_collate(); - - // Jobs metadata table. - $table_name = $wpdb->prefix . 'redis_queue_jobs'; - - $sql = "CREATE TABLE $table_name ( - id bigint(20) unsigned NOT NULL AUTO_INCREMENT, - job_id varchar(255) NOT NULL, - job_type varchar(100) NOT NULL, - queue_name varchar(100) NOT NULL DEFAULT 'default', - priority int(11) NOT NULL DEFAULT 50, - status varchar(20) NOT NULL DEFAULT 'queued', - payload longtext, - result longtext, - attempts int(11) NOT NULL DEFAULT 0, - max_attempts int(11) NOT NULL DEFAULT 3, - timeout int(11) NOT NULL DEFAULT 300, - created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - processed_at datetime NULL, - failed_at datetime NULL, - error_message text, - PRIMARY KEY (id), - UNIQUE KEY job_id (job_id), - KEY status (status), - KEY queue_name (queue_name), - KEY priority (priority), - KEY created_at (created_at) - ) $charset_collate;"; - - require_once ABSPATH . 'wp-admin/includes/upgrade.php'; - dbDelta( $sql ); - } - - /** - * Set default plugin options. - * - * @since 1.0.0 - */ - private function set_default_options() { - $default_options = array( - 'redis_host' => '127.0.0.1', - 'redis_port' => 6379, - 'redis_password' => '', - 'redis_database' => 0, - 'default_queue' => 'default', - 'max_jobs_per_run' => 10, - 'worker_timeout' => 300, - 'max_retries' => 3, - 'retry_backoff' => array( 60, 300, 900 ), - 'enable_logging' => true, - 'cleanup_completed_jobs' => true, - 'cleanup_after_days' => 7, - ); - - foreach ( $default_options as $option => $value ) { - $option_name = 'redis_queue_demo_' . $option; - if ( false === get_option( $option_name ) ) { - add_option( $option_name, $value ); - } - } - } - - /** - * Get plugin option. - * - * @since 1.0.0 - * @param string $option Option name. - * @param mixed $default Default value. - * @return mixed Option value. - */ - public function get_option( $option, $default = null ) { - return get_option( 'redis_queue_demo_' . $option, $default ); - } - - /** - * Update plugin option. - * - * @since 1.0.0 - * @param string $option Option name. - * @param mixed $value Option value. - * @return bool Whether the option was updated. - */ - public function update_option( $option, $value ) { - return update_option( 'redis_queue_demo_' . $option, $value ); - } - - /** - * Get queue manager instance. - * - * @since 1.0.0 - * @return Redis_Queue_Manager|null - */ - public function get_queue_manager() { - return $this->queue_manager; - } - - /** - * Get job processor instance. - * - * @since 1.0.0 - * @return Job_Processor|null - */ - public function get_job_processor() { - return $this->job_processor; - } - - /** - * Create and enqueue a job. - * - * @since 1.0.0 - * @param string $job_type Job type. - * @param array $payload Job payload. - * @param array $options Job options (priority, queue, etc.). - * @return string|bool Job ID on success, false on failure. - */ - public function enqueue_job( $job_type, $payload = array(), $options = array() ) { - if ( ! $this->queue_manager ) { - return false; - } - - // Create job instance based on type. - $job = $this->create_job_instance( $job_type, $payload ); - if ( ! $job ) { - return false; - } - - // Set job options. - if ( isset( $options[ 'priority' ] ) ) { - $job->set_priority( (int) $options[ 'priority' ] ); - } - - if ( isset( $options[ 'queue' ] ) ) { - $job->set_queue_name( $options[ 'queue' ] ); - } - - if ( isset( $options[ 'delay' ] ) ) { - $job->set_delay_until( time() + (int) $options[ 'delay' ] ); - } - - // Enqueue the job. - return $this->queue_manager->enqueue( $job ); - } - - /** - * Create a job instance from type and payload. - * - * @since 1.0.0 - * @param string $job_type Job type. - * @param array $payload Job payload. - * @return Queue_Job|null Job instance or null on failure. - */ - private function create_job_instance( $job_type, $payload ) { - switch ( $job_type ) { - case 'email': - return new Email_Job( $payload ); - case 'image_processing': - return new Image_Processing_Job( $payload ); - case 'api_sync': - return new API_Sync_Job( $payload ); - default: - /** - * Filter to allow custom job types. - * - * @since 1.0.0 - * @param Queue_Job|null $job Job instance. - * @param string $job_type Job type. - * @param array $payload Job payload. - */ - return apply_filters( 'redis_queue_demo_create_job', null, $job_type, $payload ); - } - } +// Load Composer autoload if present. +$autoload = __DIR__ . '/vendor/autoload.php'; +if ( file_exists( $autoload ) ) { + require_once $autoload; } -/** - * Get the main plugin instance. - * - * @since 1.0.0 - * @return Redis_Queue_Demo - */ +// Bootstrap namespaced main class. +Soderlind\RedisQueueDemo\Core\Redis_Queue_Demo::get_instance(); + function redis_queue_demo() { - return Redis_Queue_Demo::get_instance(); + return Soderlind\RedisQueueDemo\Core\Redis_Queue_Demo::get_instance(); } -// Initialize the plugin. -redis_queue_demo(); - -/** - * Helper function to enqueue a job. - * - * @since 1.0.0 - * @param string $job_type Job type. - * @param array $payload Job payload. - * @param array $options Job options. - * @return string|bool Job ID on success, false on failure. - */ function redis_queue_enqueue_job( $job_type, $payload = array(), $options = array() ) { return redis_queue_demo()->enqueue_job( $job_type, $payload, $options ); } -/** - * Helper function to process jobs. - * - * @since 1.0.0 - * @param array $queues Queue names to process. - * @param int $max_jobs Maximum jobs to process. - * @return array Processing results. - */ -function redis_queue_process_jobs( $queues = array( 'default', 'email', 'media', 'api' ), $max_jobs = 10 ) { - $plugin = redis_queue_demo(); - if ( ! $plugin->queue_manager || ! $plugin->job_processor ) { - return array( - 'success' => false, - 'processed' => 0, - 'successful' => 0, - 'failed' => 0, - 'errors' => array( 'Queue system not initialized' ), - ); +function redis_queue_process_jobs( $queue = 'default', $max_jobs = null ) { + $instance = redis_queue_demo(); + if ( ! $instance->get_queue_manager() || ! $instance->get_job_processor() ) { + return false; } - - $worker = new Sync_Worker( $plugin->queue_manager, $plugin->job_processor ); - return $worker->process_jobs( $queues, $max_jobs ); + $worker = new \Soderlind\RedisQueueDemo\Workers\Sync_Worker( $instance->get_queue_manager(), $instance->get_job_processor() ); + return $worker->process_jobs( (array) $queue, $max_jobs ); } diff --git a/src/API/REST_Controller.php b/src/API/REST_Controller.php new file mode 100644 index 0000000..988a958 --- /dev/null +++ b/src/API/REST_Controller.php @@ -0,0 +1,372 @@ +queue_manager = $queue_manager; + $this->job_processor = $job_processor; + $this->sync_worker = new Sync_Worker( $queue_manager, $job_processor ); + \add_filter( 'rest_post_dispatch', function ( $response, $server, $request ) { + return $this->maybe_log_request( $response, $request ); + }, 10, 3 ); + } + + public function register_routes() { + \register_rest_route( self::NAMESPACE , '/jobs', [ + [ 'methods' => WP_REST_Server::READABLE, 'callback' => [ $this, 'get_jobs' ], 'permission_callback' => [ $this, 'check_permissions' ], 'args' => $this->get_collection_params() ], + [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [ $this, 'create_job' ], 'permission_callback' => [ $this, 'check_permissions' ], 'args' => $this->get_create_job_params() ], + ] ); + \register_rest_route( self::NAMESPACE , '/jobs/(?P[a-zA-Z0-9_-]+)', [ + [ 'methods' => WP_REST_Server::READABLE, 'callback' => [ $this, 'get_job' ], 'permission_callback' => [ $this, 'check_permissions' ] ], + [ 'methods' => WP_REST_Server::DELETABLE, 'callback' => [ $this, 'delete_job' ], 'permission_callback' => [ $this, 'check_permissions' ] ], + ] ); + \register_rest_route( self::NAMESPACE , '/workers/trigger', [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [ $this, 'trigger_worker' ], 'permission_callback' => [ $this, 'check_permissions' ], 'args' => $this->get_trigger_worker_params() ] ); + \register_rest_route( self::NAMESPACE , '/workers/status', [ 'methods' => WP_REST_Server::READABLE, 'callback' => [ $this, 'get_worker_status' ], 'permission_callback' => [ $this, 'check_permissions' ] ] ); + \register_rest_route( self::NAMESPACE , '/stats', [ 'methods' => WP_REST_Server::READABLE, 'callback' => [ $this, 'get_stats' ], 'permission_callback' => [ $this, 'check_permissions' ] ] ); + \register_rest_route( self::NAMESPACE , '/health', [ 'methods' => WP_REST_Server::READABLE, 'callback' => [ $this, 'get_health' ], 'permission_callback' => [ $this, 'check_permissions' ] ] ); + \register_rest_route( self::NAMESPACE , '/queues/(?P[a-zA-Z0-9_-]+)/clear', [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [ $this, 'clear_queue' ], 'permission_callback' => [ $this, 'check_admin_permissions' ] ] ); + } + + public function get_jobs( $request ) { + global $wpdb; + $per_page = $request->get_param( 'per_page' ) ?: 10; + $page = $request->get_param( 'page' ) ?: 1; + $status = $request->get_param( 'status' ); + $queue = $request->get_param( 'queue' ); + $offset = ( $page - 1 ) * $per_page; + $table_name = $wpdb->prefix . 'redis_queue_jobs'; + $where_conditions = []; + $prepare_values = []; + if ( $status ) { + $where_conditions[] = 'status = %s'; + $prepare_values[] = $status; + } + if ( $queue ) { + $where_conditions[] = 'queue_name = %s'; + $prepare_values[] = $queue; + } + $where_clause = empty( $where_conditions ) ? '' : 'WHERE ' . implode( ' AND ', $where_conditions ); + $count_query = "SELECT COUNT(*) FROM {$table_name} {$where_clause}"; + if ( ! empty( $prepare_values ) ) { + $count_query = $wpdb->prepare( $count_query, ...$prepare_values ); + } + $total = (int) $wpdb->get_var( $count_query ); + $jobs_query = "SELECT * FROM {$table_name} {$where_clause} ORDER BY created_at DESC LIMIT %d OFFSET %d"; + $prepare_values[] = $per_page; + $prepare_values[] = $offset; + $jobs = $wpdb->get_results( $wpdb->prepare( $jobs_query, ...$prepare_values ), ARRAY_A ); + $formatted_jobs = []; + foreach ( $jobs as $job ) { + $formatted_jobs[] = $this->format_job_response( $job ); + } + $response = \rest_ensure_response( $formatted_jobs ); + $response->header( 'X-WP-Total', $total ); + $response->header( 'X-WP-TotalPages', ceil( $total / $per_page ) ); + return $response; + } + + public function get_job( $request ) { + global $wpdb; + $job_id = $request->get_param( 'id' ); + $table_name = $wpdb->prefix . 'redis_queue_jobs'; + $job = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table_name} WHERE job_id = %s", $job_id ), ARRAY_A ); + if ( ! $job ) { + return new WP_Error( 'job_not_found', __( 'Job not found.', 'redis-queue-demo' ), [ 'status' => 404 ] ); + } + return \rest_ensure_response( $this->format_job_response( $job ) ); + } + + public function create_job( $request ) { + $job_type = $request->get_param( 'type' ); + $payload = $request->get_param( 'payload' ) ?: []; + $priority = $request->get_param( 'priority' ) ?: 50; + $queue = $request->get_param( 'queue' ) ?: 'default'; + try { + $job = $this->create_job_instance( $job_type, $payload ); + if ( ! $job ) { + return new WP_Error( 'invalid_job_type', __( 'Invalid job type specified.', 'redis-queue-demo' ), [ 'status' => 400 ] ); + } + $job->set_priority( $priority ); + $job->set_queue_name( $queue ); + $job_id = $this->queue_manager->enqueue( $job ); + if ( ! $job_id ) { + return new WP_Error( 'enqueue_failed', __( 'Failed to enqueue job.', 'redis-queue-demo' ), [ 'status' => 500 ] ); + } + return \rest_ensure_response( [ 'success' => true, 'job_id' => $job_id, 'message' => __( 'Job created and enqueued successfully.', 'redis-queue-demo' ) ] ); + } catch (Exception $e) { + return new WP_Error( 'job_creation_failed', $e->getMessage(), [ 'status' => 500 ] ); + } + } + + public function delete_job( $request ) { + global $wpdb; + $job_id = $request->get_param( 'id' ); + $table_name = $wpdb->prefix . 'redis_queue_jobs'; + $job = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table_name} WHERE job_id = %s AND status IN ('queued','failed')", $job_id ) ); + if ( ! $job ) { + return new WP_Error( 'job_not_found_or_not_cancellable', __( 'Job not found or cannot be cancelled.', 'redis-queue-demo' ), [ 'status' => 404 ] ); + } + $updated = $wpdb->update( $table_name, [ 'status' => 'cancelled', 'updated_at' => current_time( 'mysql' ) ], [ 'job_id' => $job_id ], [ '%s', '%s' ], [ '%s' ] ); + if ( false === $updated ) { + return new WP_Error( 'job_cancellation_failed', __( 'Failed to cancel job.', 'redis-queue-demo' ), [ 'status' => 500 ] ); + } + return \rest_ensure_response( [ 'success' => true, 'job_id' => $job_id, 'message' => __( 'Job cancelled successfully.', 'redis-queue-demo' ) ] ); + } + + public function trigger_worker( $request ) { + $queues = $request->get_param( 'queues' ) ?: [ 'default' ]; + $max_jobs = $request->get_param( 'max_jobs' ) ?: 10; + if ( ! is_array( $queues ) ) { + $queues = [ $queues ]; + } + try { + $results = $this->sync_worker->process_jobs( $queues, $max_jobs ); + return \rest_ensure_response( [ 'success' => $results[ 'success' ], 'data' => $results, 'message' => sprintf( __( 'Worker processed %d jobs.', 'redis-queue-demo' ), $results[ 'processed' ] ?? 0 ) ] ); + } catch (Exception $e) { + return new WP_Error( 'worker_execution_failed', $e->getMessage(), [ 'status' => 500 ] ); + } + } + + public function get_worker_status( $request ) { + $status = $this->sync_worker->get_status(); + return \rest_ensure_response( [ 'success' => true, 'data' => $status ] ); + } + public function get_stats( $request ) { + $queue_name = $request->get_param( 'queue' ); + $stats = $this->queue_manager->get_queue_stats( $queue_name ); + return \rest_ensure_response( [ 'success' => true, 'data' => $stats ] ); + } + + public function get_health( $request ) { + $health = [ + 'redis_connected' => $this->queue_manager->is_connected(), + 'redis_info' => [], + 'database_status' => $this->check_database_health(), + 'memory_usage' => [ 'current' => memory_get_usage( true ), 'peak' => memory_get_peak_usage( true ), 'limit' => ini_get( 'memory_limit' ) ], + 'php_version' => PHP_VERSION, + 'wordpress_version' => get_bloginfo( 'version' ), + 'plugin_version' => \defined( 'REDIS_QUEUE_DEMO_VERSION' ) ? REDIS_QUEUE_DEMO_VERSION : 'unknown', + ]; + if ( $health[ 'redis_connected' ] ) { + try { + $redis = $this->queue_manager->get_redis_connection(); + if ( $redis && method_exists( $redis, 'info' ) ) { + $info = $redis->info(); + $health[ 'redis_info' ] = [ 'redis_version' => $info[ 'redis_version' ] ?? 'unknown', 'used_memory' => $info[ 'used_memory_human' ] ?? 'unknown', 'connected_clients' => $info[ 'connected_clients' ] ?? 'unknown' ]; + } + } catch (Exception $e) { + $health[ 'redis_info' ][ 'error' ] = $e->getMessage(); + } + } + $overall = $health[ 'redis_connected' ] && $health[ 'database_status' ]; + return \rest_ensure_response( [ 'success' => $overall, 'status' => $overall ? 'healthy' : 'unhealthy', 'data' => $health ] ); + } + + public function clear_queue( $request ) { + $queue_name = $request->get_param( 'name' ); + if ( empty( $queue_name ) ) { + return new WP_Error( 'missing_queue_name', __( 'Queue name is required.', 'redis-queue-demo' ), [ 'status' => 400 ] ); + } + $result = $this->queue_manager->clear_queue( $queue_name ); + if ( $result ) { + return \rest_ensure_response( [ 'success' => true, 'message' => sprintf( __( 'Queue "%s" cleared successfully.', 'redis-queue-demo' ), $queue_name ) ] ); + } + return new WP_Error( 'queue_clear_failed', __( 'Failed to clear queue.', 'redis-queue-demo' ), [ 'status' => 500 ] ); + } + + public function check_permissions( $request ) { + if ( current_user_can( 'manage_options' ) ) { + $this->last_auth_method = 'cap'; + return true; + } + $settings = get_option( 'redis_queue_settings', [] ); + $api_token = $settings[ 'api_token' ] ?? ''; + $scope = $settings[ 'api_token_scope' ] ?? 'worker'; + $rate_per_min = isset( $settings[ 'rate_limit_per_minute' ] ) ? (int) $settings[ 'rate_limit_per_minute' ] : 60; + $rate_per_min = $rate_per_min > 0 ? $rate_per_min : 60; + if ( ! empty( $api_token ) ) { + $provided = ''; + $auth_header = $request->get_header( 'authorization' ); + if ( $auth_header && stripos( $auth_header, 'bearer ' ) === 0 ) { + $provided = trim( substr( $auth_header, 7 ) ); + } + if ( empty( $provided ) ) { + $provided = $request->get_header( 'x-redis-queue-token' ); + } + if ( ! empty( $provided ) && hash_equals( $api_token, $provided ) ) { + $this->last_auth_method = 'token'; + $this->last_token_used = $provided; + $route = $request->get_route(); + $allowed = true; + if ( 'full' !== $scope ) { + $allowed_routes = apply_filters( 'redis_queue_demo_token_allowed_routes', [ '/redis-queue/v1/workers/trigger' ], $scope ); + $allowed = in_array( $route, $allowed_routes, true ); + } + $allowed = apply_filters( 'redis_queue_demo_token_scope_allow', $allowed, $scope, $request ); + $this->last_scope_allowed = $allowed; + if ( ! $allowed ) { + return new WP_Error( 'rest_forbidden_scope', __( 'Token scope does not permit this endpoint.', 'redis-queue-demo' ), [ 'status' => 403 ] ); + } + if ( $rate_per_min > 0 ) { + if ( ! $this->enforce_rate_limit( $provided, $rate_per_min ) ) { + $this->last_rate_limited = true; + return new WP_Error( 'rate_limited', __( 'Rate limit exceeded. Try again later.', 'redis-queue-demo' ), [ 'status' => 429 ] ); + } + } + return true; + } + } + $this->last_auth_method = 'none'; + return new WP_Error( 'rest_forbidden', __( 'You do not have permission to access this endpoint.', 'redis-queue-demo' ), [ 'status' => 403 ] ); + } + + public function check_admin_permissions( $request ) { + if ( ! current_user_can( 'manage_options' ) || ! wp_verify_nonce( $request->get_header( 'X-WP-Nonce' ), 'wp_rest' ) ) { + return new WP_Error( 'rest_forbidden', __( 'You do not have permission to perform this action.', 'redis-queue-demo' ), [ 'status' => 403 ] ); + } + return true; + } + + private function enforce_rate_limit( $token, $per_minute ) { + $key_root = 'redis_queue_demo_rate_' . substr( hash( 'sha256', $token ), 0, 24 ); + $minute = gmdate( 'YmdHi' ); + $key = $key_root . '_' . $minute; + $count = (int) get_transient( $key ); + $count++; + if ( 1 === $count ) { + $ttl = 60 - (int) gmdate( 's' ); + set_transient( $key, 1, $ttl ); + return true; + } + if ( $count > $per_minute ) { + return false; + } + $ttl = 60 - (int) gmdate( 's' ); + set_transient( $key, $count, $ttl ); + return true; + } + + private function maybe_log_request( $response, $request ) { + if ( ! $request instanceof WP_REST_Request ) { + return $response; + } + $route = $request->get_route(); + if ( 0 !== strpos( $route, '/' . self::NAMESPACE) ) { + return $response; + } + $settings = get_option( 'redis_queue_settings', [] ); + if ( empty( $settings[ 'enable_request_logging' ] ) ) { + return $response; + } + $rotate_kb = isset( $settings[ 'log_rotate_size_kb' ] ) ? (int) $settings[ 'log_rotate_size_kb' ] : 256; + $max_files = isset( $settings[ 'log_max_files' ] ) ? (int) $settings[ 'log_max_files' ] : 5; + $rotate_kb = $rotate_kb > 8 ? $rotate_kb : 256; + $max_files = $max_files > 0 ? $max_files : 5; + $status_code = ( $response instanceof WP_REST_Response ) ? $response->get_status() : 0; + $line = wp_json_encode( [ 'ts' => gmdate( 'c' ), 'method' => $request->get_method(), 'route' => $route, 'status' => $status_code, 'auth' => $this->last_auth_method, 'scope_ok' => $this->last_scope_allowed, 'rate_limited' => $this->last_rate_limited, 'user_id' => get_current_user_id(), 'ip' => $_SERVER[ 'REMOTE_ADDR' ] ?? '' ] ); + $this->append_log_line( $line, $rotate_kb, $max_files ); + return $response; + } + + private function append_log_line( $line, $rotate_kb, $max_files ) { + $upload_dir = wp_upload_dir(); + $dir = trailingslashit( $upload_dir[ 'basedir' ] ) . 'redis-queue-demo-logs'; + if ( ! file_exists( $dir ) ) { + wp_mkdir_p( $dir ); + } + $log_file = trailingslashit( $dir ) . 'requests.log'; + $rotate_bytes = $rotate_kb * 1024; + if ( file_exists( $log_file ) && filesize( $log_file ) > $rotate_bytes ) { + $rotated = trailingslashit( $dir ) . 'requests-' . gmdate( 'Ymd-His' ) . '.log'; + @rename( $log_file, $rotated ); + $files = glob( trailingslashit( $dir ) . 'requests-*.log' ); + if ( is_array( $files ) && count( $files ) > $max_files ) { + sort( $files ); + $excess = array_slice( $files, 0, count( $files ) - $max_files ); + foreach ( $excess as $old ) { + @unlink( $old ); + } + } + } + $fh = @fopen( $log_file, 'ab' ); + if ( $fh ) { + fwrite( $fh, $line . PHP_EOL ); + fclose( $fh ); + } + } + + private function create_job_instance( $job_type, $payload ) { + return match ( $job_type ) { + 'email' => new Email_Job( $payload ), + 'image_processing' => new Image_Processing_Job( $payload ), + 'api_sync' => new API_Sync_Job( $payload ), + default => null, + }; + } + + private function format_job_response( $job ) { + $payload = json_decode( $job[ 'payload' ], true ); + $result = $job[ 'result' ] ? json_decode( $job[ 'result' ], true ) : null; + return [ + 'id' => $job[ 'job_id' ], + 'type' => $job[ 'job_type' ], + 'queue' => $job[ 'queue_name' ], + 'status' => $job[ 'status' ], + 'priority' => (int) $job[ 'priority' ], + 'payload' => $payload, + 'result' => $result, + 'attempts' => (int) $job[ 'attempts' ], + 'max_attempts' => (int) $job[ 'max_attempts' ], + 'timeout' => (int) $job[ 'timeout' ], + 'error_message' => $job[ 'error_message' ], + 'created_at' => $job[ 'created_at' ], + 'updated_at' => $job[ 'updated_at' ], + 'processed_at' => $job[ 'processed_at' ], + 'failed_at' => $job[ 'failed_at' ], + ]; + } + + private function check_database_health() { + global $wpdb; + $table = $wpdb->prefix . 'redis_queue_jobs'; + return ! empty( $wpdb->get_var( $wpdb->prepare( "SHOW TABLES LIKE %s", $table ) ) ); + } + private function get_collection_params() { + return []; + } + private function get_create_job_params() { + return []; + } + private function get_trigger_worker_params() { + return []; + } +} + +// Legacy global class alias removed (backward compatibility dropped). diff --git a/src/Admin/Admin_Interface.php b/src/Admin/Admin_Interface.php new file mode 100644 index 0000000..8c0c9be --- /dev/null +++ b/src/Admin/Admin_Interface.php @@ -0,0 +1,331 @@ +queue_manager = $queue_manager; + $this->job_processor = $job_processor; + } + + public function init() { + \add_action( 'admin_menu', [ $this, 'add_admin_menu' ] ); + \add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_admin_scripts' ] ); + \add_action( 'wp_ajax_redis_queue_trigger_worker', [ $this, 'ajax_trigger_worker' ] ); + \add_action( 'wp_ajax_redis_queue_get_stats', [ $this, 'ajax_get_stats' ] ); + \add_action( 'wp_ajax_redis_queue_clear_queue', [ $this, 'ajax_clear_queue' ] ); + \add_action( 'wp_ajax_redis_queue_create_test_job', [ $this, 'ajax_create_test_job' ] ); + \add_action( 'wp_ajax_redis_queue_diagnostics', [ $this, 'ajax_diagnostics' ] ); + \add_action( 'wp_ajax_redis_queue_debug_test', [ $this, 'ajax_debug_test' ] ); + \add_action( 'wp_ajax_redis_queue_reset_stuck_jobs', [ $this, 'ajax_reset_stuck_jobs' ] ); + \add_action( 'wp_ajax_redis_queue_purge_jobs', [ $this, 'ajax_purge_jobs' ] ); + } + + public function add_admin_menu() { + \add_menu_page( __( 'Redis Queue', 'redis-queue-demo' ), __( 'Redis Queue', 'redis-queue-demo' ), 'manage_options', 'redis-queue-demo', [ $this, 'render_dashboard_page' ], 'dashicons-database-view', 30 ); + \add_submenu_page( 'redis-queue-demo', __( 'Dashboard', 'redis-queue-demo' ), __( 'Dashboard', 'redis-queue-demo' ), 'manage_options', 'redis-queue-demo', [ $this, 'render_dashboard_page' ] ); + \add_submenu_page( 'redis-queue-demo', __( 'Jobs', 'redis-queue-demo' ), __( 'Jobs', 'redis-queue-demo' ), 'manage_options', 'redis-queue-jobs', [ $this, 'render_jobs_page' ] ); + \add_submenu_page( 'redis-queue-demo', __( 'Test Jobs', 'redis-queue-demo' ), __( 'Test Jobs', 'redis-queue-demo' ), 'manage_options', 'redis-queue-test', [ $this, 'render_test_page' ] ); + \add_submenu_page( 'redis-queue-demo', __( 'Settings', 'redis-queue-demo' ), __( 'Settings', 'redis-queue-demo' ), 'manage_options', 'redis-queue-settings', [ $this, 'render_settings_page' ] ); + } + + public function enqueue_admin_scripts( $hook_suffix ) { + if ( false === strpos( $hook_suffix, 'redis-queue' ) ) { + return; + } + \wp_enqueue_script( 'redis-queue-admin', \plugin_dir_url( __FILE__ ) . '../../assets/admin.js', [ 'jquery' ], REDIS_QUEUE_DEMO_VERSION, true ); + \wp_localize_script( 'redis-queue-admin', 'redisQueueAdmin', [ + 'ajaxUrl' => \admin_url( 'admin-ajax.php' ), + 'nonce' => \wp_create_nonce( 'redis_queue_admin' ), + 'restNonce' => \wp_create_nonce( 'wp_rest' ), + 'restUrl' => \rest_url( 'redis-queue/v1/' ), + 'strings' => [ + 'processing' => __( 'Processing...', 'redis-queue-demo' ), + 'success' => __( 'Success!', 'redis-queue-demo' ), + 'error' => __( 'Error occurred', 'redis-queue-demo' ), + 'confirmClear' => __( 'Are you sure you want to clear this queue?', 'redis-queue-demo' ), + 'workerTriggered' => __( 'Worker triggered successfully', 'redis-queue-demo' ), + 'queueCleared' => __( 'Queue cleared successfully', 'redis-queue-demo' ), + ], + ] ); + \wp_enqueue_style( 'redis-queue-admin', \plugin_dir_url( __FILE__ ) . '../../assets/admin.css', [], REDIS_QUEUE_DEMO_VERSION ); + } + + // The following render methods replicate legacy output exactly. + public function render_dashboard_page() { + $stats = $this->queue_manager->get_queue_stats(); + $flat_stats = $this->flatten_stats( $stats ); + $health = $this->get_system_health(); + include __DIR__ . '/partials/dashboard-inline.php'; // Provide minimal template or fall back to inline markup if not present. + } + public function render_jobs_page() { + global $wpdb; + $per_page = 20; + $current_page = isset( $_GET[ 'paged' ] ) ? max( 1, intval( $_GET[ 'paged' ] ) ) : 1; + $status_filter = isset( $_GET[ 'status' ] ) ? sanitize_text_field( $_GET[ 'status' ] ) : ''; + $table_name = $wpdb->prefix . 'redis_queue_jobs'; + $offset = ( $current_page - 1 ) * $per_page; + $where = []; + $args = []; + if ( $status_filter ) { + $where[] = 'status = %s'; + $args[] = $status_filter; + } + $where_clause = $where ? 'WHERE ' . implode( ' AND ', $where ) : ''; + $count_sql = "SELECT COUNT(*) FROM {$table_name} {$where_clause}"; + if ( $args ) { + $count_sql = $wpdb->prepare( $count_sql, ...$args ); + } + $total_jobs = (int) $wpdb->get_var( $count_sql ); + $jobs_sql = "SELECT * FROM {$table_name} {$where_clause} ORDER BY created_at DESC LIMIT %d OFFSET %d"; + $args[] = $per_page; + $args[] = $offset; + $jobs = $wpdb->get_results( $wpdb->prepare( $jobs_sql, ...$args ), ARRAY_A ); + $total_pages = (int) ceil( $total_jobs / $per_page ); + include __DIR__ . '/partials/jobs-inline.php'; + } + public function render_test_page() { + include __DIR__ . '/partials/test-inline.php'; + } + public function render_settings_page() { + if ( isset( $_POST[ 'submit' ] ) && wp_verify_nonce( $_POST[ '_wpnonce' ], 'redis_queue_settings' ) ) { + $this->save_settings(); + } elseif ( isset( $_POST[ 'generate_api_token' ] ) && wp_verify_nonce( $_POST[ '_wpnonce' ], 'redis_queue_settings' ) ) { + $_POST[ '__generate_api_token' ] = 1; + $this->save_settings(); + } elseif ( isset( $_POST[ 'clear_api_token' ] ) && wp_verify_nonce( $_POST[ '_wpnonce' ], 'redis_queue_settings' ) ) { + $_POST[ '__clear_api_token' ] = 1; + $this->save_settings(); + } + $options = get_option( 'redis_queue_settings', [] ); + $defaults = [ 'redis_host' => '127.0.0.1', 'redis_port' => 6379, 'redis_database' => 0, 'redis_password' => '', 'worker_timeout' => 30, 'max_retries' => 3, 'retry_delay' => 60, 'batch_size' => 10, 'api_token' => '', 'api_token_scope' => 'worker', 'rate_limit_per_minute' => 60, 'enable_request_logging' => 0, 'log_rotate_size_kb' => 256, 'log_max_files' => 5 ]; + $options = wp_parse_args( $options, $defaults ); + include __DIR__ . '/partials/settings-inline.php'; + } + + /* === Settings handling (ported from legacy) === */ + private function save_settings() { + $existing = get_option( 'redis_queue_settings', [] ); + $settings = [ + 'redis_host' => sanitize_text_field( $_POST[ 'redis_host' ] ?? '' ), + 'redis_port' => intval( $_POST[ 'redis_port' ] ?? 6379 ), + 'redis_database' => intval( $_POST[ 'redis_database' ] ?? 0 ), + 'redis_password' => sanitize_text_field( $_POST[ 'redis_password' ] ?? '' ), + 'worker_timeout' => intval( $_POST[ 'worker_timeout' ] ?? 30 ), + 'max_retries' => intval( $_POST[ 'max_retries' ] ?? 3 ), + 'retry_delay' => intval( $_POST[ 'retry_delay' ] ?? 60 ), + 'batch_size' => intval( $_POST[ 'batch_size' ] ?? 10 ), + 'api_token' => $existing[ 'api_token' ] ?? '', + 'api_token_scope' => ( isset( $_POST[ 'api_token_scope' ] ) && in_array( $_POST[ 'api_token_scope' ], [ 'worker', 'full' ], true ) ) ? $_POST[ 'api_token_scope' ] : ( $existing[ 'api_token_scope' ] ?? 'worker' ), + 'rate_limit_per_minute' => isset( $_POST[ 'rate_limit_per_minute' ] ) ? max( 1, intval( $_POST[ 'rate_limit_per_minute' ] ) ) : ( $existing[ 'rate_limit_per_minute' ] ?? 60 ), + 'enable_request_logging' => isset( $_POST[ 'enable_request_logging' ] ) ? 1 : 0, + 'log_rotate_size_kb' => isset( $_POST[ 'log_rotate_size_kb' ] ) ? max( 8, intval( $_POST[ 'log_rotate_size_kb' ] ) ) : ( $existing[ 'log_rotate_size_kb' ] ?? 256 ), + 'log_max_files' => isset( $_POST[ 'log_max_files' ] ) ? max( 1, intval( $_POST[ 'log_max_files' ] ) ) : ( $existing[ 'log_max_files' ] ?? 5 ), + ]; + if ( isset( $_POST[ '__clear_api_token' ] ) || isset( $_POST[ 'clear_api_token' ] ) ) { + $settings[ 'api_token' ] = ''; + } + if ( isset( $_POST[ '__generate_api_token' ] ) || isset( $_POST[ 'generate_api_token' ] ) ) { + try { + $settings[ 'api_token' ] = bin2hex( random_bytes( 32 ) ); + } catch (\Exception $e) { + $settings[ 'api_token' ] = wp_generate_password( 64, false, false ); + } + } + update_option( 'redis_queue_settings', $settings ); + add_action( 'admin_notices', [ $this, 'settings_saved_notice' ] ); + } + public function settings_saved_notice() { + ?> +
+

+
+ queue_manager || ! $this->job_processor ) { + wp_send_json_error( 'Queue system not initialized' ); + } + if ( ! $this->queue_manager->is_connected() ) { + wp_send_json_error( 'Redis connection not available' ); + } + if ( function_exists( 'redis_queue_process_jobs' ) ) { + $results = redis_queue_process_jobs( [ 'default', 'email', 'media', 'api' ], 10 ); + } else { + $sync = new Sync_Worker( $this->queue_manager, $this->job_processor ); + $results = $sync->process_jobs( [ 'default', 'email', 'media', 'api' ], 10 ); + } + if ( $results === null || ! is_array( $results ) ) { + wp_send_json_error( 'Worker returned invalid results' ); + } + wp_send_json_success( $results ); + } catch (\Throwable $e) { + wp_send_json_error( 'Worker error: ' . ( method_exists( $e, 'getMessage' ) ? $e->getMessage() : 'unknown' ) ); + } + } + public function ajax_get_stats() { + check_ajax_referer( 'redis_queue_admin', 'nonce' ); + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( -1 ); + } + $stats = $this->queue_manager->get_queue_stats(); + wp_send_json_success( $this->flatten_stats( $stats ) ); + } + public function ajax_clear_queue() { + check_ajax_referer( 'redis_queue_admin', 'nonce' ); + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( -1 ); + } + $queue = sanitize_text_field( $_POST[ 'queue' ] ?? 'default' ); + $ok = $this->queue_manager->clear_queue( $queue ); + $ok ? wp_send_json_success( [ 'message' => 'Queue cleared successfully' ] ) : wp_send_json_error( 'Failed to clear queue' ); + } + public function ajax_create_test_job() { + check_ajax_referer( 'redis_queue_admin', 'nonce' ); + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( -1 ); + } + $job_type = sanitize_text_field( $_POST[ 'job_type' ] ?? '' ); + $payload = $_POST[ 'payload' ] ?? []; + $payload = is_array( $payload ) ? array_map( 'sanitize_text_field', $payload ) : []; + try { + $job_id = redis_queue_demo()->enqueue_job( $job_type, $payload, [ 'priority' => 10 ] ); + if ( $job_id ) { + wp_send_json_success( [ 'job_id' => $job_id, 'message' => 'Job created and enqueued successfully.' ] ); + } else { + wp_send_json_error( 'Failed to enqueue job.' ); + } + } catch (\Exception $e) { + wp_send_json_error( 'Job creation failed: ' . $e->getMessage() ); + } + } + public function ajax_diagnostics() { + check_ajax_referer( 'redis_queue_admin', 'nonce' ); + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( -1 ); + } + try { + $diagnostics = $this->queue_manager->diagnostic(); + wp_send_json_success( $diagnostics ); + } catch (\Exception $e) { + wp_send_json_error( 'Diagnostic failed: ' . $e->getMessage() ); + } + } + public function ajax_debug_test() { + check_ajax_referer( 'redis_queue_admin', 'nonce' ); + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( -1 ); + } + $plugin = redis_queue_demo(); + $results = [ 'plugin' => [ 'Queue Manager' => $plugin->queue_manager ? 'OK' : 'FAILED', 'Job Processor' => $plugin->job_processor ? 'OK' : 'FAILED' ], 'redis' => [ 'Connected' => $plugin->queue_manager->is_connected() ? 'YES' : 'NO' ] ]; + wp_send_json_success( $results ); + } + public function ajax_reset_stuck_jobs() { + check_ajax_referer( 'redis_queue_admin', 'nonce' ); + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( -1 ); + } + try { + $reset = $this->queue_manager->reset_stuck_jobs( 30 ); + wp_send_json_success( [ 'message' => sprintf( 'Reset %d stuck jobs.', $reset ), 'count' => $reset ] ); + } catch (\Exception $e) { + wp_send_json_error( 'Failed to reset stuck jobs: ' . $e->getMessage() ); + } + } + public function ajax_purge_jobs() { + check_ajax_referer( 'redis_queue_admin', 'nonce' ); + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( -1 ); + } + $scope = sanitize_text_field( $_POST[ 'scope' ] ?? '' ); + $days = max( 1, intval( $_POST[ 'days' ] ?? 7 ) ); + if ( ! in_array( $scope, [ 'completed', 'failed', 'older', 'all' ], true ) ) { + wp_send_json_error( 'Invalid purge scope.' ); + } + global $wpdb; + $table = $wpdb->prefix . 'redis_queue_jobs'; + $where = ''; + $args = []; + switch ( $scope ) { + case 'completed': + $where = "WHERE status='completed'"; + break; + case 'failed': + $where = "WHERE status='failed'"; + break; + case 'older': + $cutoff = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) ); + $where = 'WHERE created_at < %s'; + $args[] = $cutoff; + break; + case 'all': + default: + $where = ''; + } + $count_sql = "SELECT COUNT(*) FROM $table $where"; + $count = $args ? (int) $wpdb->get_var( $wpdb->prepare( $count_sql, ...$args ) ) : (int) $wpdb->get_var( $count_sql ); + if ( 0 === $count ) { + wp_send_json_success( [ 'message' => 'No matching jobs to purge.', 'count' => 0 ] ); + } + $del_sql = "DELETE FROM $table $where"; + if ( $args ) { + $wpdb->query( $wpdb->prepare( $del_sql, ...$args ) ); + } else { + $wpdb->query( $del_sql ); + } + $deleted = $wpdb->rows_affected; + wp_send_json_success( [ 'message' => ( 'older' === $scope ? sprintf( 'Purged %d jobs older than %d days.', $deleted, $days ) : sprintf( 'Purged %d jobs (scope: %s).', $deleted, $scope ) ), 'count' => $deleted, 'scope' => $scope, 'days' => $days ] ); + } + + // Utility helpers retained (not invoked directly here because legacy handles them, but available for future inline porting) + private function get_system_health() { + return [ 'redis' => $this->queue_manager->is_connected(), 'database' => $this->check_database_health(), 'overall' => $this->queue_manager->is_connected() && $this->check_database_health(),]; + } + private function check_database_health() { + global $wpdb; + $table_name = $wpdb->prefix . 'redis_queue_jobs'; + $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) ); + return ! empty( $table_exists ); + } + private function flatten_stats( $stats ) { + $flat = [ 'queued' => 0, 'processing' => 0, 'completed' => 0, 'failed' => 0, 'total' => 0 ]; + if ( isset( $stats[ 'database' ] ) && is_array( $stats[ 'database' ] ) ) { + $flat[ 'queued' ] = (int) ( $stats[ 'database' ][ 'queued' ] ?? 0 ); + $flat[ 'processing' ] = (int) ( $stats[ 'database' ][ 'processing' ] ?? 0 ); + $flat[ 'completed' ] = (int) ( $stats[ 'database' ][ 'completed' ] ?? 0 ); + $flat[ 'failed' ] = (int) ( $stats[ 'database' ][ 'failed' ] ?? 0 ); + $flat[ 'total' ] = (int) ( $stats[ 'database' ][ 'total' ] ?? ( $flat[ 'queued' ] + $flat[ 'processing' ] + $flat[ 'completed' ] + $flat[ 'failed' ] ) ); + return $flat; + } + foreach ( $stats as $data ) { + if ( isset( $data[ 'pending' ] ) ) { + $flat[ 'queued' ] += (int) $data[ 'pending' ]; + } + } + $flat[ 'total' ] = $flat[ 'queued' ]; + return $flat; + } +} + +// Legacy global class alias removed (backward compatibility dropped). diff --git a/src/Admin/partials/dashboard-inline.php b/src/Admin/partials/dashboard-inline.php new file mode 100644 index 0000000..5a76d37 --- /dev/null +++ b/src/Admin/partials/dashboard-inline.php @@ -0,0 +1,60 @@ + +
+

+
+

+
+
+
+
+
+
+
+
+
+

+
+
+
+

+
+
+
+
+

+
+
+
+

+
+
+
+
+

+
+ + + + + +
+
+
+
+
+

+
+
+
+
\ No newline at end of file diff --git a/src/Admin/partials/jobs-inline.php b/src/Admin/partials/jobs-inline.php new file mode 100644 index 0000000..9f7b74f --- /dev/null +++ b/src/Admin/partials/jobs-inline.php @@ -0,0 +1,85 @@ + +
+

+
+ + + + + + +
+
+
+
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + | +
+ 1 ) : ?> +
+ add_query_arg( 'paged', '%#%' ), 'format' => '', 'prev_text' => __( '« Previous', 'redis-queue-demo' ), 'next_text' => __( 'Next »', 'redis-queue-demo' ), 'total' => $total_pages, 'current' => $current_page ] ); ?> +
+ +
\ No newline at end of file diff --git a/src/Admin/partials/settings-inline.php b/src/Admin/partials/settings-inline.php new file mode 100644 index 0000000..edae119 --- /dev/null +++ b/src/Admin/partials/settings-inline.php @@ -0,0 +1,146 @@ + +
+

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

+

+
+

+
+

+

+
+

+ +

+
+

+

+
+

+ +

+
+

+ +

+
+

+

+

+

+ " or "X-Redis-Queue-Token: ". Possession grants the same access as an admin for these endpoints; keep it secret.', 'redis-queue-demo' ); ?> +

+

+

+
+

+ +

+
+

+ +

+
+

+ +

+
+

+ +

+
+ +
+
+

+
+
+
\ No newline at end of file diff --git a/src/Admin/partials/test-inline.php b/src/Admin/partials/test-inline.php new file mode 100644 index 0000000..7cae7d5 --- /dev/null +++ b/src/Admin/partials/test-inline.php @@ -0,0 +1,115 @@ + +
+

+

+

+ +
+
+

+
+ + + + + + + + + + + + + + + + + +
+
+
+
+
+ +
+
+
+

+
+ + + + + + + + + +
+
+ +

+ +

+
+ +
+
+
+

+
+ + + + + + + + + + + + + +
+
+
+ +
+
+
+ +
\ No newline at end of file diff --git a/src/Contracts/Basic_Job_Result.php b/src/Contracts/Basic_Job_Result.php new file mode 100644 index 0000000..777d41c --- /dev/null +++ b/src/Contracts/Basic_Job_Result.php @@ -0,0 +1,91 @@ +successful = $successful; + $this->data = $data; + $this->error_message = $error_message; + $this->error_code = $error_code; + $this->metadata = $metadata; + $this->execution_time = $execution_time; + $this->memory_usage = $memory_usage; + } + + public static function success( $data = null, array $metadata = [] ): self { + return new self( true, $data, null, null, $metadata ); + } + + public static function failure( string $error_message, $error_code = null, array $metadata = [] ): self { + return new self( false, null, $error_message, $error_code, $metadata ); + } + + public function is_successful() { + return $this->successful; + } + public function get_data() { + return $this->data; + } + public function get_error_message() { + return $this->error_message; + } + public function get_error_code() { + return $this->error_code; + } + public function get_metadata() { + return $this->metadata; + } + public function get_execution_time() { + return $this->execution_time; + } + public function get_memory_usage() { + return $this->memory_usage; + } + + public function to_array(): array { + return [ + 'successful' => $this->successful, + 'data' => $this->data, + 'error_message' => $this->error_message, + 'error_code' => $this->error_code, + 'metadata' => $this->metadata, + 'execution_time' => $this->execution_time, + 'memory_usage' => $this->memory_usage, + ]; + } + + public static function from_array( $data ): Job_Result { + return new self( + (bool) ( $data[ 'successful' ] ?? false ), + $data[ 'data' ] ?? null, + $data[ 'error_message' ] ?? null, + $data[ 'error_code' ] ?? null, + (array) ( $data[ 'metadata' ] ?? [] ), + (float) ( $data[ 'execution_time' ] ?? 0.0 ), + (int) ( $data[ 'memory_usage' ] ?? 0 ) + ); + } + + public function set_execution_time( float $time ): void { + $this->execution_time = $time; + } + public function set_memory_usage( int $bytes ): void { + $this->memory_usage = $bytes; + } +} diff --git a/src/Contracts/Job_Result.php b/src/Contracts/Job_Result.php new file mode 100644 index 0000000..898609b --- /dev/null +++ b/src/Contracts/Job_Result.php @@ -0,0 +1,14 @@ +queue_manager = $queue_manager; + } + + public function process_job( $job_data ): Job_Result { + $this->current_job = $job_data; + $this->start_time = microtime( true ); + $this->start_memory = memory_get_usage( true ); + $job_id = $job_data[ 'job_id' ] ?? 'unknown'; + // Early sanitize to avoid downstream empty class warnings. + $job_data = $this->sanitize_job_data( $job_data ); + try { + $job = $this->create_job_instance( $job_data ); + if ( ! $job ) { + throw new Exception( 'Failed to create job instance' ); + } + $timeout = $job->get_timeout(); + if ( $timeout > 0 ) { + @set_time_limit( $timeout ); + } + $result = $job->execute(); + $execution_time = microtime( true ) - $this->start_time; + $memory_usage = memory_get_peak_usage( true ) - $this->start_memory; + $result->set_execution_time( $execution_time ); + $result->set_memory_usage( $memory_usage ); + if ( $result->is_successful() ) { + $this->handle_successful_job( $job_id, $result ); + } else { + $this->handle_failed_job( $job_id, $job, $result, 1, null ); + } + if ( function_exists( 'do_action' ) ) { + \do_action( 'redis_queue_demo_job_processed', $job_id, $job, $result ); + } + return $result; + } catch (Exception $e) { + $execution_time = microtime( true ) - $this->start_time; + $memory_usage = memory_get_peak_usage( true ) - $this->start_memory; + $result = Basic_Job_Result::failure( $e->getMessage(), $e->getCode(), [ 'exception_type' => get_class( $e ) ] ); + $result->set_execution_time( $execution_time ); + $result->set_memory_usage( $memory_usage ); + $job = $this->create_job_instance( $job_data ); + if ( $job ) { + $this->handle_failed_job( $job_id, $job, $result, 1, $e ); + } else { + $this->mark_job_failed( $job_id, $result ); + } + if ( function_exists( 'do_action' ) ) { + \do_action( 'redis_queue_demo_job_failed', $job_id, $e, $job_data ); + } + return $result; + } finally { + $this->current_job = null; + } + } + + public function process_jobs( $queues = [ 'default' ], $max_jobs = 10 ): array { + $results = []; + $processed = 0; + $start_time = microtime( true ); + $start_memory = memory_get_usage( true ); + if ( function_exists( 'do_action' ) ) { + \do_action( 'redis_queue_demo_batch_start', $queues, $max_jobs ); + } + while ( $processed < $max_jobs ) { + $job_data = $this->queue_manager->dequeue( $queues ); + if ( ! $job_data ) { + break; + } + $result = $this->process_job( $job_data ); + $results[] = [ 'job_id' => $job_data[ 'job_id' ] ?? 'unknown', 'result' => $result ]; + $processed++; + if ( $this->should_stop_processing() ) { + break; + } + } + $total_time = microtime( true ) - $start_time; + $total_memory = memory_get_peak_usage( true ) - $start_memory; + if ( function_exists( 'do_action' ) ) { + \do_action( 'redis_queue_demo_batch_complete', $results, $processed, $total_time, $total_memory ); + } + return [ 'processed' => $processed, 'total_time' => $total_time, 'total_memory' => $total_memory, 'results' => $results ]; + } + + private function create_job_instance( $job_data ) { + if ( ! isset( $job_data[ 'serialized_job' ] ) ) { + return null; + } + try { + $serialized_data = $job_data[ 'serialized_job' ]; + $job_type = $job_data[ 'job_type' ] ?? ''; + $job_class = $this->get_job_class( $job_type ); + // Fallback: if mapping failed but serialized data has a class value, prefer that. + if ( ( ! $job_class || ! is_string( $job_class ) ) && is_array( $serialized_data ) && ! empty( $serialized_data[ 'class' ] ) ) { + $job_class = $serialized_data[ 'class' ]; + } + // Normalize whitespace-only class names. + if ( is_string( $job_class ) ) { + $job_class = trim( $job_class ); + } + // Guard against empty or non-string class names to avoid PHP warning: class_exists(): Class "" not found. + if ( ! is_string( $job_class ) || $job_class === '' ) { + \error_log( 'Redis Queue Demo: create_job_instance missing job_class. job_type=' . var_export( $job_type, true ) . ' serialized_has_class=' . ( is_array( $serialized_data ) && isset( $serialized_data[ 'class' ] ) ? 'yes' : 'no' ) . ' job_id=' . ( $job_data[ 'job_id' ] ?? 'unknown' ) . ' raw=' . substr( json_encode( $job_data ), 0, 400 ) ); + throw new Exception( $job_type === '' ? 'Empty job type supplied; cannot resolve job class' : "Unable to resolve job class for job type '{$job_type}'" ); + } + if ( ! class_exists( $job_class ) ) { + \error_log( 'Redis Queue Demo: job_class ' . $job_class . ' not found for job_type=' . $job_type . ' job_id=' . ( $job_data[ 'job_id' ] ?? 'unknown' ) ); + throw new Exception( "Job class '{$job_class}' (type '{$job_type}') not found/autoloadable" ); + } + // Ensure method_exists not called with empty class (double guard) and class is loaded. + if ( is_string( $job_class ) && $job_class !== '' && method_exists( $job_class, 'deserialize' ) ) { + return $job_class::deserialize( $serialized_data ); + } + throw new Exception( "Job class {$job_class} does not implement deserialize method" ); + } catch (Exception $e) { + \error_log( 'Redis Queue Demo: Failed to create job instance - ' . $e->getMessage() ); + return null; + } + } + + /** + * Sanitize/infer job data before attempting instantiation. + * Adds serialized_job.class if missing and can be inferred. Returns original data if no change. + */ + private function sanitize_job_data( array $job_data ): array { + if ( empty( $job_data[ 'serialized_job' ][ 'class' ] ?? '' ) ) { + $job_type = $job_data[ 'job_type' ] ?? ''; + $inferred = null; + if ( is_string( $job_type ) && $job_type !== '' ) { + // Canonical job type mapping only (legacy variants removed). + $map = [ + 'email' => 'Soderlind\\RedisQueueDemo\\Jobs\\Email_Job', + 'image_processing' => 'Soderlind\\RedisQueueDemo\\Jobs\\Image_Processing_Job', + 'api_sync' => 'Soderlind\\RedisQueueDemo\\Jobs\\API_Sync_Job', + ]; + $key = $map[ strtolower( $job_type ) ] ?? null; + if ( $key ) { + $inferred = $key; + } elseif ( str_contains( $job_type, '\\' ) && $this->safe_class_exists( $job_type ) ) { + $inferred = $job_type; + } + } + if ( $inferred ) { + $job_data[ 'serialized_job' ][ 'class' ] = $inferred; + \error_log( 'Redis Queue Demo: sanitize_job_data injected class for job_id=' . ( $job_data[ 'job_id' ] ?? 'unknown' ) . ' job_type=' . ( $job_type ?? '' ) . ' inferred=' . $inferred ); + } else { + // If we cannot infer, log once; create_job_instance will handle failure gracefully. + \error_log( 'Redis Queue Demo: sanitize_job_data could not infer class job_id=' . ( $job_data[ 'job_id' ] ?? 'unknown' ) . ' job_type=' . ( $job_data[ 'job_type' ] ?? '' ) ); + } + } + return $job_data; + } + private function get_job_class( $job_type ) { + // Accept a fully qualified class name directly (namespaced) or legacy class name. + if ( is_string( $job_type ) && str_contains( $job_type, '\\' ) ) { + if ( $this->safe_class_exists( $job_type ) ) { + return $job_type; // Already a class. + } + } + // Normalized job type mapping (canonical identifiers). + $job_type_normalized = strtolower( trim( $job_type ) ); + $base_map = [ + 'email' => 'Soderlind\\RedisQueueDemo\\Jobs\\Email_Job', + 'image_processing' => 'Soderlind\\RedisQueueDemo\\Jobs\\Image_Processing_Job', + 'api_sync' => 'Soderlind\\RedisQueueDemo\\Jobs\\API_Sync_Job', + ]; + $job_classes = function_exists( 'apply_filters' ) ? \apply_filters( 'redis_queue_demo_job_classes', $base_map ) : $base_map; + if ( isset( $job_classes[ $job_type_normalized ] ) ) { + return $job_classes[ $job_type_normalized ]; + } + return null; + } + + /** + * Safe class_exists wrapper (prevents warnings for empty strings). + */ + private function safe_class_exists( $class ): bool { + return is_string( $class ) && $class !== '' && class_exists( $class ); + } + + private function handle_successful_job( $job_id, Job_Result $result ): void { + global $wpdb; + $table = $wpdb->prefix . 'redis_queue_jobs'; + $wpdb->update( $table, [ 'status' => 'completed', 'result' => ( function_exists( 'wp_json_encode' ) ? \wp_json_encode( $result->to_array() ) : json_encode( $result->to_array() ) ), 'updated_at' => ( function_exists( 'current_time' ) ? \current_time( 'mysql' ) : date( 'Y-m-d H:i:s' ) ) ], [ 'job_id' => $job_id ], [ '%s', '%s', '%s' ], [ '%s' ] ); + if ( function_exists( 'do_action' ) ) { + \do_action( 'redis_queue_demo_job_completed', $job_id, $result ); + } + } + private function handle_failed_job( $job_id, Queue_Job $job, Job_Result $result, $attempt, $exception = null ): void { + global $wpdb; + $table = $wpdb->prefix . 'redis_queue_jobs'; + $wpdb->query( $wpdb->prepare( "UPDATE {$table} SET attempts = attempts + 1, updated_at = %s WHERE job_id = %s", ( function_exists( 'current_time' ) ? \current_time( 'mysql' ) : date( 'Y-m-d H:i:s' ) ), $job_id ) ); + $info = $wpdb->get_row( $wpdb->prepare( "SELECT attempts, max_attempts FROM {$table} WHERE job_id = %s", $job_id ) ); + if ( ! $info ) { + return; + } + $current_attempts = (int) $info->attempts; + $max_attempts = (int) $info->max_attempts; + if ( $current_attempts < $max_attempts && $job->should_retry( $exception, $current_attempts ) ) { + $this->retry_job( $job_id, $job, $current_attempts ); + } else { + $this->mark_job_failed( $job_id, $result ); + $job->handle_failure( $exception, $current_attempts ); + } + } + private function retry_job( $job_id, Queue_Job $job, $attempt ): void { + $delay = $job->get_retry_delay( $attempt ); + $this->queue_manager->enqueue( $job, $delay ); + $this->queue_manager->update_job_status( $job_id, 'queued' ); + if ( function_exists( 'do_action' ) ) { + \do_action( 'redis_queue_demo_job_retried', $job_id, $job, $attempt, $delay ); + } + } + private function mark_job_failed( $job_id, Job_Result $result ): void { + global $wpdb; + $table = $wpdb->prefix . 'redis_queue_jobs'; + $wpdb->update( $table, [ 'status' => 'failed', 'result' => ( function_exists( 'wp_json_encode' ) ? \wp_json_encode( $result->to_array() ) : json_encode( $result->to_array() ) ), 'error_message' => $result->get_error_message(), 'failed_at' => ( function_exists( 'current_time' ) ? \current_time( 'mysql' ) : date( 'Y-m-d H:i:s' ) ), 'updated_at' => ( function_exists( 'current_time' ) ? \current_time( 'mysql' ) : date( 'Y-m-d H:i:s' ) ) ], [ 'job_id' => $job_id ], [ '%s', '%s', '%s', '%s', '%s' ], [ '%s' ] ); + if ( function_exists( 'do_action' ) ) { + \do_action( 'redis_queue_demo_job_permanently_failed', $job_id, $result ); + } + } + private function should_stop_processing(): bool { + $memory_limit = $this->get_memory_limit(); + $current = memory_get_usage( true ); + if ( $memory_limit > 0 && $current > ( $memory_limit * 0.8 ) ) { + return true; + } + $max_exec = ini_get( 'max_execution_time' ); + if ( $max_exec > 0 ) { + $elapsed = microtime( true ) - $this->start_time; + if ( $elapsed > ( $max_exec * 0.8 ) ) { + return true; + } + } + return false; + } + private function get_memory_limit(): int { + $limit = ini_get( 'memory_limit' ); + if ( $limit === '-1' ) + return 0; + $unit = strtolower( substr( $limit, -1 ) ); + $value = (int) $limit; + return match ( $unit ) { 'g' => $value * 1024 * 1024 * 1024, 'm' => $value * 1024 * 1024, 'k' => $value * 1024, default => $value}; + } + public function get_current_job() { + return $this->current_job; + } +} + +// Legacy global class alias removed (backward compatibility dropped). diff --git a/src/Core/Redis_Queue_Demo.php b/src/Core/Redis_Queue_Demo.php new file mode 100644 index 0000000..7fe7801 --- /dev/null +++ b/src/Core/Redis_Queue_Demo.php @@ -0,0 +1,202 @@ +init_hooks(); + } + + private function init_hooks(): void { + \register_activation_hook( REDIS_QUEUE_DEMO_PLUGIN_FILE, [ $this, 'activate' ] ); + \register_deactivation_hook( REDIS_QUEUE_DEMO_PLUGIN_FILE, [ $this, 'deactivate' ] ); + \add_action( 'plugins_loaded', [ $this, 'load_textdomain' ] ); + \add_action( 'init', [ $this, 'init' ] ); + \add_action( 'rest_api_init', [ $this, 'init_rest_api' ] ); + } + + public function init(): void { + $this->load_dependencies(); + $this->init_components(); + \do_action( 'redis_queue_demo_init', $this ); + } + + private function load_dependencies(): void { + // All core classes now autoloaded via Composer. Legacy requires removed. + } + + private function init_components(): void { + $this->queue_manager = new Redis_Queue_Manager(); + $this->job_processor = new Job_Processor( $this->queue_manager ); + $this->rest_controller = new \Soderlind\RedisQueueDemo\API\REST_Controller( $this->queue_manager, $this->job_processor ); + if ( \is_admin() ) { + // Use namespaced Admin_Interface (legacy admin/class-admin-interface.php retained temporarily for reference / UI parity). + $this->admin_interface = new \Soderlind\RedisQueueDemo\Admin\Admin_Interface( $this->queue_manager, $this->job_processor ); + if ( method_exists( $this->admin_interface, 'init' ) ) { + $this->admin_interface->init(); + } + } + } + + public function init_rest_api(): void { + if ( $this->rest_controller ) { + $this->rest_controller->register_routes(); + } + } + + public function load_textdomain(): void { + \load_plugin_textdomain( 'redis-queue-demo', false, dirname( REDIS_QUEUE_DEMO_PLUGIN_BASENAME ) . '/languages' ); + } + + public function activate(): void { + if ( \version_compare( PHP_VERSION, '8.3', '<' ) ) { + \deactivate_plugins( REDIS_QUEUE_DEMO_PLUGIN_BASENAME ); + \wp_die( \esc_html__( 'Redis Queue Demo requires PHP 8.3 or higher.', 'redis-queue-demo' ), \esc_html__( 'Plugin Activation Error', 'redis-queue-demo' ), [ 'back_link' => true ] ); + } + if ( ! \extension_loaded( 'redis' ) && ! \class_exists( 'Predis\\Client' ) ) { + \deactivate_plugins( REDIS_QUEUE_DEMO_PLUGIN_BASENAME ); + \wp_die( \esc_html__( 'Redis Queue Demo requires either the Redis PHP extension or Predis library.', 'redis-queue-demo' ), \esc_html__( 'Plugin Activation Error', 'redis-queue-demo' ), [ 'back_link' => true ] ); + } + $this->create_tables(); + $this->set_default_options(); + \flush_rewrite_rules(); + \do_action( 'redis_queue_demo_activate' ); + } + + public function deactivate(): void { + \wp_clear_scheduled_hook( 'redis_queue_demo_process_jobs' ); + \flush_rewrite_rules(); + \do_action( 'redis_queue_demo_deactivate' ); + } + + private function create_tables(): void { + global $wpdb; + $charset_collate = $wpdb->get_charset_collate(); + $table_name = $wpdb->prefix . 'redis_queue_jobs'; + $sql = "CREATE TABLE $table_name ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + job_id varchar(255) NOT NULL, + job_type varchar(100) NOT NULL, + queue_name varchar(100) NOT NULL DEFAULT 'default', + priority int(11) NOT NULL DEFAULT 50, + status varchar(20) NOT NULL DEFAULT 'queued', + payload longtext, + result longtext, + attempts int(11) NOT NULL DEFAULT 0, + max_attempts int(11) NOT NULL DEFAULT 3, + timeout int(11) NOT NULL DEFAULT 300, + created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + processed_at datetime NULL, + failed_at datetime NULL, + error_message text, + PRIMARY KEY (id), + UNIQUE KEY job_id (job_id), + KEY status (status), + KEY queue_name (queue_name), + KEY priority (priority), + KEY created_at (created_at) + ) $charset_collate;"; + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + \dbDelta( $sql ); + } + + private function set_default_options(): void { + $default_options = [ + 'redis_host' => '127.0.0.1', + 'redis_port' => 6379, + 'redis_password' => '', + 'redis_database' => 0, + 'default_queue' => 'default', + 'max_jobs_per_run' => 10, + 'worker_timeout' => 300, + 'max_retries' => 3, + 'retry_backoff' => [ 60, 300, 900 ], + 'enable_logging' => true, + 'cleanup_completed_jobs' => true, + 'cleanup_after_days' => 7, + ]; + foreach ( $default_options as $option => $value ) { + $option_name = 'redis_queue_demo_' . $option; + if ( false === \get_option( $option_name ) ) { + \add_option( $option_name, $value ); + } + } + } + + public function get_option( $option, $default = null ) { + return \get_option( 'redis_queue_demo_' . $option, $default ); + } + public function update_option( $option, $value ) { + return \update_option( 'redis_queue_demo_' . $option, $value ); + } + public function get_queue_manager() { + return $this->queue_manager; + } + public function get_job_processor() { + return $this->job_processor; + } + + public function enqueue_job( $job_type, $payload = [], $options = [] ) { + if ( ! $this->queue_manager ) { + return false; + } + $job = $this->create_job_instance( $job_type, $payload ); + if ( ! $job ) { + return false; + } + if ( isset( $options[ 'priority' ] ) ) { + $job->set_priority( (int) $options[ 'priority' ] ); + } + if ( isset( $options[ 'queue' ] ) ) { + $job->set_queue_name( $options[ 'queue' ] ); + } + if ( isset( $options[ 'delay' ] ) ) { + $job->set_delay_until( time() + (int) $options[ 'delay' ] ); + } + return $this->queue_manager->enqueue( $job ); + } + + private function create_job_instance( $job_type, $payload ) { + switch ( $job_type ) { + case 'email': + return new \Soderlind\RedisQueueDemo\Jobs\Email_Job( $payload ); + case 'image_processing': + return new \Soderlind\RedisQueueDemo\Jobs\Image_Processing_Job( $payload ); + case 'api_sync': + return new \Soderlind\RedisQueueDemo\Jobs\API_Sync_Job( $payload ); + default: + return \apply_filters( 'redis_queue_demo_create_job', null, $job_type, $payload ); + } + } +} diff --git a/src/Core/Redis_Queue_Manager.php b/src/Core/Redis_Queue_Manager.php new file mode 100644 index 0000000..d8828a1 --- /dev/null +++ b/src/Core/Redis_Queue_Manager.php @@ -0,0 +1,391 @@ +connect(); + // One-time repair of legacy Redis job entries missing serialized_job.class + if ( function_exists( 'get_option' ) && function_exists( 'update_option' ) ) { + $flag = \get_option( 'redis_queue_demo_repair_v1', '0' ); + if ( '0' === $flag ) { + $this->repair_redis_jobs(); + \update_option( 'redis_queue_demo_repair_v1', '1' ); + } + } + } + + private function connect(): bool { + try { + $host = \redis_queue_demo()->get_option( 'redis_host', '127.0.0.1' ); + $port = \redis_queue_demo()->get_option( 'redis_port', 6379 ); + $password = \redis_queue_demo()->get_option( 'redis_password', '' ); + $database = \redis_queue_demo()->get_option( 'redis_database', 0 ); + + if ( \extension_loaded( 'redis' ) ) { + $this->redis = new \Redis(); + $connected = $this->redis->connect( $host, $port, 2.5 ); + if ( $connected ) { + if ( ! empty( $password ) ) { + $this->redis->auth( $password ); + } + $this->redis->select( $database ); + $this->connected = true; + } + } elseif ( class_exists( PredisClient::class) ) { + $config = [ 'scheme' => 'tcp', 'host' => $host, 'port' => $port, 'database' => $database ]; + if ( ! empty( $password ) ) { + $config[ 'password' ] = $password; + } + $this->redis = new PredisClient( $config ); + $this->redis->connect(); + $this->connected = true; + } + + if ( $this->connected ) { + if ( function_exists( 'do_action' ) ) { + \do_action( 'redis_queue_demo_connected', $this ); + } + } + return $this->connected; + } catch (Exception $e) { + \error_log( 'Redis Queue Demo: Connection failed - ' . $e->getMessage() ); + $this->connected = false; + return false; + } + } + + public function is_connected(): bool { + if ( ! $this->connected || ! $this->redis ) { + return false; + } + try { + $response = $this->redis->ping(); + return ( $response === true || $response === 'PONG' ); + } catch (Exception $e) { + $this->connected = false; + return false; + } + } + + public function enqueue( Queue_Job $job, $delay = null ) { + if ( ! $this->is_connected() ) { + return false; + } + try { + $job_id = $this->generate_job_id(); + $job_data = $this->prepare_job_data( $job, $job_id ); + $queue_key = $this->get_queue_key( $job->get_queue_name() ); + + if ( ! $this->store_job_metadata( $job_id, $job, $job_data ) ) { + return false; + } + + if ( $delay && $delay > 0 ) { + $process_time = time() + $delay; + $delayed_key = $this->queue_prefix . 'delayed'; + $this->redis->zadd( $delayed_key, $process_time, json_encode( $job_data ) ); + } else { + $priority = $job->get_priority(); + $this->redis->zadd( $queue_key, $priority, json_encode( $job_data ) ); + } + if ( function_exists( 'do_action' ) ) { + \do_action( 'redis_queue_demo_job_enqueued', $job_id, $job ); + } + return $job_id; + } catch (Exception $e) { + \error_log( 'Redis Queue Demo: Enqueue failed - ' . $e->getMessage() ); + return false; + } + } + + public function dequeue( $queues = [ 'default' ] ) { + if ( ! $this->is_connected() ) { + return null; + } + if ( is_string( $queues ) ) { + $queues = [ $queues ]; + } + try { + $this->process_delayed_jobs(); + foreach ( $queues as $queue_name ) { + $queue_key = $this->get_queue_key( $queue_name ); + $jobs = $this->redis->zrange( $queue_key, 0, 0, [ 'withscores' => true ] ); + if ( ! empty( $jobs ) ) { + $job_data = array_key_first( $jobs ); + $this->redis->zrem( $queue_key, $job_data ); + $decoded = json_decode( $job_data, true ); + if ( $decoded ) { + // Skip malformed job entries lacking job_type; they cause downstream empty class warnings. + if ( empty( $decoded[ 'job_type' ] ) ) { + \error_log( 'Redis Queue Demo: Skipping dequeued job with empty job_type job_id=' . ( $decoded[ 'job_id' ] ?? 'unknown' ) ); + continue; + } + if ( empty( $decoded[ 'serialized_job' ][ 'class' ] ?? '' ) ) { + $jt = $decoded[ 'job_type' ] ?? ''; + $map = [ + 'email' => 'Soderlind\\RedisQueueDemo\\Jobs\\Email_Job', + 'image_processing' => 'Soderlind\\RedisQueueDemo\\Jobs\\Image_Processing_Job', + 'api_sync' => 'Soderlind\\RedisQueueDemo\\Jobs\\API_Sync_Job', + ]; + $inferred = $map[ strtolower( $jt ) ] ?? null; + if ( ! $inferred && str_contains( $jt, '\\' ) && class_exists( $jt ) ) { + $inferred = $jt; // Accept provided FQCN. + } + if ( ! isset( $decoded[ 'serialized_job' ] ) || ! is_array( $decoded[ 'serialized_job' ] ) ) { + $decoded[ 'serialized_job' ] = []; + } + if ( $inferred ) { + $decoded[ 'serialized_job' ][ 'class' ] = $inferred; + \error_log( 'Redis Queue Demo: Injected missing serialized_job.class during dequeue job_id=' . ( $decoded[ 'job_id' ] ?? 'unknown' ) . ' job_type=' . $jt . ' inferred=' . $inferred ); + } else { + \error_log( 'Redis Queue Demo: Could not infer class for job_id=' . ( $decoded[ 'job_id' ] ?? 'unknown' ) . ' job_type=' . $jt . ' skipping job to avoid warning (legacy variant no longer supported)' ); + continue; // Skip to avoid empty class warning. + } + } + $this->update_job_status( $decoded[ 'job_id' ], 'processing' ); + if ( function_exists( 'do_action' ) ) { + \do_action( 'redis_queue_demo_job_dequeued', $decoded ); + } + return $decoded; + } + } + } + return null; + } catch (Exception $e) { + \error_log( 'Redis Queue Demo: Dequeue failed - ' . $e->getMessage() ); + return null; + } + } + + /** + * Safe class_exists wrapper preventing PHP warning for empty class names. + */ + private function safe_class_exists( $class ): bool { + return is_string( $class ) && $class !== '' && class_exists( $class ); + } + + public function get_queue_stats( $queue_name = null ): array { + if ( ! $this->is_connected() ) { + return []; + } + try { + $stats = []; + if ( $queue_name ) { + $queue_key = $this->get_queue_key( $queue_name ); + $stats[ $queue_name ] = [ 'pending' => $this->redis->zcard( $queue_key ), 'size' => $this->redis->zcard( $queue_key ) ]; + } else { + $pattern = $this->queue_prefix . 'queue:*'; + $keys = $this->redis->keys( $pattern ); + foreach ( $keys as $key ) { + $qn = str_replace( $this->queue_prefix . 'queue:', '', $key ); + $stats[ $qn ] = [ 'pending' => $this->redis->zcard( $key ), 'size' => $this->redis->zcard( $key ) ]; + } + $delayed_key = $this->queue_prefix . 'delayed'; + $stats[ 'delayed' ] = [ 'pending' => $this->redis->zcard( $delayed_key ), 'size' => $this->redis->zcard( $delayed_key ) ]; + } + global $wpdb; + $table = $wpdb->prefix . 'redis_queue_jobs'; + $array_a = defined( 'ARRAY_A' ) ? ARRAY_A : 'ARRAY_A'; + $db_stats = $wpdb->get_row( "SELECT COUNT(*) total, SUM(CASE WHEN status='queued' THEN 1 ELSE 0 END) queued, SUM(CASE WHEN status='processing' THEN 1 ELSE 0 END) processing, SUM(CASE WHEN status='completed' THEN 1 ELSE 0 END) completed, SUM(CASE WHEN status='failed' THEN 1 ELSE 0 END) failed FROM {$table}", $array_a ); + $stats[ 'database' ] = $db_stats ?: []; + return $stats; + } catch (Exception $e) { + \error_log( 'Redis Queue Demo: Get stats failed - ' . $e->getMessage() ); + return []; + } + } + + public function clear_queue( $queue_name ) { + if ( ! $this->is_connected() ) { + return false; + } + try { + $queue_key = $this->get_queue_key( $queue_name ); + $result = $this->redis->del( $queue_key ); + if ( function_exists( 'do_action' ) ) { + \do_action( 'redis_queue_demo_queue_cleared', $queue_name ); + } + return $result > 0; + } catch (Exception $e) { + \error_log( 'Redis Queue Demo: Clear queue failed - ' . $e->getMessage() ); + return false; + } + } + + private function process_delayed_jobs(): int { + if ( ! $this->is_connected() ) { + return 0; + } + try { + $delayed_key = $this->queue_prefix . 'delayed'; + $now = time(); + $moved = 0; + $ready = $this->redis->zrangebyscore( $delayed_key, 0, $now ); + foreach ( $ready as $job_data ) { + $decoded = json_decode( $job_data, true ); + if ( $decoded && isset( $decoded[ 'queue_name' ] ) ) { + $queue_key = $this->get_queue_key( $decoded[ 'queue_name' ] ); + $priority = $decoded[ 'priority' ] ?? 50; + $this->redis->zadd( $queue_key, $priority, $job_data ); + $this->redis->zrem( $delayed_key, $job_data ); + $moved++; + } + } + return $moved; + } catch (Exception $e) { + \error_log( 'Redis Queue Demo: Process delayed jobs failed - ' . $e->getMessage() ); + return 0; + } + } + + private function generate_job_id(): string { + return 'job_' . uniqid() . '_' . rand( 1000, 9999 ); + } + + /** + * Attempt to repair existing Redis queue entries created before namespaced serialization added the class field. + * Adds serialized_job.class when it can be inferred; removes irreparable entries to avoid repeated warnings. + */ + private function repair_redis_jobs(): void { + if ( ! $this->is_connected() ) { + return; + } + try { + $pattern = $this->queue_prefix . 'queue:*'; + $keys = $this->redis->keys( $pattern ); + $map = [ + 'email' => 'Soderlind\\RedisQueueDemo\\Jobs\\Email_Job', + 'image_processing' => 'Soderlind\\RedisQueueDemo\\Jobs\\Image_Processing_Job', + 'api_sync' => 'Soderlind\\RedisQueueDemo\\Jobs\\API_Sync_Job', + ]; + $fixed = 0; + $removed = 0; + foreach ( $keys as $key ) { + $entries = $this->redis->zrange( $key, 0, -1 ); + foreach ( $entries as $raw ) { + $decoded = json_decode( $raw, true ); + if ( ! is_array( $decoded ) ) { + continue; + } + $jt_raw = $decoded[ 'job_type' ] ?? ''; + $jt = is_string( $jt_raw ) ? trim( $jt_raw ) : ''; + if ( $jt === '' ) { + // Irreparable: no job_type to infer from. + $this->redis->zrem( $key, $raw ); + $removed++; + continue; + } + if ( empty( $decoded[ 'serialized_job' ][ 'class' ] ?? '' ) ) { + $jt_norm = strtolower( $jt ); + $inferred = $map[ $jt_norm ] ?? null; + if ( ! $inferred && str_contains( $jt, '\\' ) && class_exists( $jt ) ) { + $inferred = $jt; + } + if ( $inferred ) { + if ( ! isset( $decoded[ 'serialized_job' ] ) || ! is_array( $decoded[ 'serialized_job' ] ) ) { + $decoded[ 'serialized_job' ] = []; + } + $decoded[ 'serialized_job' ][ 'class' ] = $inferred; + $this->redis->zrem( $key, $raw ); + $this->redis->zadd( $key, $decoded[ 'priority' ] ?? 50, json_encode( $decoded ) ); + $fixed++; + } else { + $this->redis->zrem( $key, $raw ); + $removed++; + } + } + } + } + // Log summary only once after processing all keys. + if ( $fixed || $removed ) { + \error_log( 'Redis Queue Demo: repair_redis_jobs fixed=' . $fixed . ' removed=' . $removed ); + } + } catch (Exception $e) { + \error_log( 'Redis Queue Demo: repair_redis_jobs failed - ' . $e->getMessage() ); + } + } + private function prepare_job_data( Queue_Job $job, $job_id ): array { + $serialized = $job->serialize(); + if ( empty( $serialized[ 'class' ] ) ) { + $serialized[ 'class' ] = get_class( $job ); + \error_log( 'Redis Queue Demo: Added missing class to serialized_job in prepare_job_data for job_id ' . $job_id . ' (' . $serialized[ 'class' ] . ')' ); + } + return [ 'job_id' => $job_id, 'job_type' => $job->get_job_type(), 'queue_name' => $job->get_queue_name(), 'priority' => $job->get_priority(), 'payload' => $job->get_payload(), 'timeout' => $job->get_timeout(), 'max_attempts' => $job->get_retry_attempts(), 'created_at' => date( 'Y-m-d H:i:s' ), 'serialized_job' => $serialized,]; + } + + private function store_job_metadata( $job_id, Queue_Job $job, $job_data ) { + global $wpdb; + $table = $wpdb->prefix . 'redis_queue_jobs'; + $result = $wpdb->insert( $table, [ 'job_id' => $job_id, 'job_type' => $job->get_job_type(), 'queue_name' => $job->get_queue_name(), 'priority' => $job->get_priority(), 'status' => 'queued', 'payload' => json_encode( $job->get_payload() ), 'attempts' => 0, 'max_attempts' => $job->get_retry_attempts(), 'timeout' => $job->get_timeout(), 'created_at' => date( 'Y-m-d H:i:s' ), 'updated_at' => date( 'Y-m-d H:i:s' ),], [ '%s', '%s', '%s', '%d', '%s', '%s', '%d', '%d', '%d', '%s', '%s' ] ); + return $result !== false; + } + + public function update_job_status( $job_id, $status ) { + global $wpdb; + $table = $wpdb->prefix . 'redis_queue_jobs'; + $update = [ 'status' => $status, 'updated_at' => date( 'Y-m-d H:i:s' ) ]; + if ( 'processing' === $status ) { + $update[ 'processed_at' ] = date( 'Y-m-d H:i:s' ); + } elseif ( 'failed' === $status ) { + $update[ 'failed_at' ] = date( 'Y-m-d H:i:s' ); + } + $result = $wpdb->update( $table, $update, [ 'job_id' => $job_id ], [ '%s', '%s' ], [ '%s' ] ); + return $result !== false; + } + private function get_queue_key( $queue_name ): string { + return $this->queue_prefix . 'queue:' . $queue_name; + } + public function get_redis_connection() { + return $this->redis; + } + public function reset_stuck_jobs( $timeout_minutes = 30 ) { + global $wpdb; + $table = $wpdb->prefix . 'redis_queue_jobs'; + $result = $wpdb->update( $table, [ 'status' => 'queued', 'updated_at' => date( 'Y-m-d H:i:s' ), 'processed_at' => null ], [ 'status' => 'processing' ], [ '%s', '%s', '%s' ], [ '%s' ] ); + return $result !== false ? $result : 0; + } + public function diagnostic(): array { + $results = [ 'connected' => $this->is_connected(), 'redis_keys' => [], 'test_write' => false, 'test_read' => false, 'queue_prefix' => $this->queue_prefix ]; + if ( $this->is_connected() ) { + try { + $test_key = $this->queue_prefix . 'test'; + $this->redis->set( $test_key, 'test_value', 10 ); + $results[ 'test_write' ] = true; + $read = $this->redis->get( $test_key ); + $results[ 'test_read' ] = ( $read === 'test_value' ); + $this->redis->del( $test_key ); + $results[ 'redis_keys' ] = $this->redis->keys( $this->queue_prefix . '*' ); + } catch (Exception $e) { + $results[ 'error' ] = $e->getMessage(); + } + } + return $results; + } + public function __destruct() { + if ( $this->redis && $this->connected ) { + try { + if ( \extension_loaded( 'redis' ) && $this->redis instanceof \Redis ) { + $this->redis->close(); + } elseif ( $this->redis instanceof PredisClient ) { + $this->redis->disconnect(); + } + } catch (Exception $e) { + } + } + } +} + +// Legacy global class alias removed (backward compatibility dropped). diff --git a/src/Jobs/API_Sync_Job.php b/src/Jobs/API_Sync_Job.php new file mode 100644 index 0000000..ea120ec --- /dev/null +++ b/src/Jobs/API_Sync_Job.php @@ -0,0 +1,177 @@ +queue_name = 'api'; + $this->priority = 40; + $this->timeout = 300; + } + public function get_job_type() { + return 'api_sync'; + } + public function execute() { + try { + $type = $this->get_payload_value( 'type', 'generic' ); + return match ( $type ) { 'social_media' => $this->sync_social_media(), 'crm' => $this->sync_crm_data(), 'analytics' => $this->sync_analytics(), 'webhook' => $this->send_webhook(), default => $this->generic_api_call(), }; + } catch (Exception $e) { + return $this->failure( $e->getMessage(), $e->getCode() ); + } + } + private function sync_social_media() { + $platforms = $this->get_payload_value( 'platforms', [] ); + $post = $this->get_payload_value( 'post_data', [] ); + if ( empty( $platforms ) || empty( $post ) ) { + return $this->failure( 'Missing platforms or post data' ); + } + $results = []; + $successful = 0; + $failed = 0; + foreach ( $platforms as $platform => $config ) { + try { + $result = $this->post_to_social_platform( $platform, $post, $config ); + $results[ $platform ] = $result; + if ( $result[ 'success' ] ) { + $successful++; + } else { + $failed++; + } + } catch (Exception $e) { + $failed++; + $results[ $platform ] = [ 'success' => false, 'error' => $e->getMessage() ]; + } + } + return $this->success( [ 'total' => count( $platforms ), 'successful' => $successful, 'failed' => $failed, 'results' => $results, 'post_data' => $post ], [ 'sync_type' => 'social_media' ] ); + } + private function sync_crm_data() { + $crm = $this->get_payload_value( 'crm_system' ); + $op = $this->get_payload_value( 'operation', 'sync' ); + $data = $this->get_payload_value( 'data', [] ); + if ( ! $crm ) { + return $this->failure( 'CRM system not specified' ); + } + return match ( $op ) { 'create_contact' => $this->create_crm_contact( $crm, $data ), 'update_contact' => $this->update_crm_contact( $crm, $data ), 'sync_contacts' => $this->sync_crm_contacts( $crm, $data ), default => $this->failure( 'Unknown CRM operation: ' . $op ), }; + } + private function sync_analytics() { + $provider = $this->get_payload_value( 'provider', 'google_analytics' ); + $metrics = $this->get_payload_value( 'metrics', [] ); + $range = $this->get_payload_value( 'date_range', [] ); + return match ( $provider ) { 'google_analytics' => $this->sync_google_analytics( $metrics, $range ), 'custom_tracking' => $this->sync_custom_tracking( $metrics, $range ), default => $this->failure( 'Unknown analytics provider: ' . $provider ), }; + } + private function send_webhook() { + $url = $this->get_payload_value( 'url' ); + $method = $this->get_payload_value( 'method', 'POST' ); + $headers = $this->get_payload_value( 'headers', [] ); + $data = $this->get_payload_value( 'data', [] ); + if ( ! $url ) { + return $this->failure( 'Webhook URL not provided' ); + } + $args = [ 'method' => $method, 'headers' => $headers, 'timeout' => 30 ]; + if ( in_array( $method, [ 'POST', 'PUT', 'PATCH' ], true ) ) { + $args[ 'body' ] = \wp_json_encode( $data ); + if ( ! isset( $headers[ 'Content-Type' ] ) ) { + $args[ 'headers' ][ 'Content-Type' ] = 'application/json'; + } + } + $response = \wp_remote_request( $url, $args ); + if ( \is_wp_error( $response ) ) { + return $this->failure( 'Webhook request failed: ' . $response->get_error_message() ); + } + $code = \wp_remote_retrieve_response_code( $response ); + $body = \wp_remote_retrieve_body( $response ); + if ( $code >= 200 && $code < 300 ) { + return $this->success( [ 'url' => $url, 'method' => $method, 'response_code' => $code, 'response_body' => $body ], [ 'sync_type' => 'webhook' ] ); + } + return $this->failure( sprintf( 'Webhook returned error code %d: %s', $code, $body ), $code ); + } + private function generic_api_call() { + $url = $this->get_payload_value( 'url' ); + $method = $this->get_payload_value( 'method', 'GET' ); + $headers = $this->get_payload_value( 'headers', [] ); + $data = $this->get_payload_value( 'data', [] ); + if ( ! $url ) { + return $this->failure( 'API URL not provided' ); + } + $args = [ 'method' => $method, 'headers' => $headers, 'timeout' => 60 ]; + if ( in_array( $method, [ 'POST', 'PUT', 'PATCH' ], true ) && ! empty( $data ) ) { + $args[ 'body' ] = \wp_json_encode( $data ); + if ( ! isset( $headers[ 'Content-Type' ] ) ) { + $args[ 'headers' ][ 'Content-Type' ] = 'application/json'; + } + } + $response = \wp_remote_request( $url, $args ); + if ( \is_wp_error( $response ) ) { + return $this->failure( 'API request failed: ' . $response->get_error_message() ); + } + $code = \wp_remote_retrieve_response_code( $response ); + $body = \wp_remote_retrieve_body( $response ); + return $this->success( [ 'url' => $url, 'method' => $method, 'response_code' => $code, 'response_body' => $body, 'request_data' => $data ], [ 'sync_type' => 'generic' ] ); + } + private function post_to_social_platform( $platform, $post, $config ) { + $endpoints = [ 'facebook' => 'https://graph.facebook.com/v12.0/me/feed', 'twitter' => 'https://api.twitter.com/2/tweets', 'linkedin' => 'https://api.linkedin.com/v2/shares' ]; + if ( ! isset( $endpoints[ $platform ] ) ) { + throw new Exception( 'Unsupported platform: ' . $platform ); + } + $url = $endpoints[ $platform ]; + $headers = []; + if ( isset( $config[ 'access_token' ] ) ) { + $headers[ 'Authorization' ] = 'Bearer ' . $config[ 'access_token' ]; + } + $args = [ 'method' => 'POST', 'headers' => $headers, 'body' => \wp_json_encode( $post ), 'timeout' => 30 ]; + $response = \wp_remote_post( $url, $args ); + if ( \is_wp_error( $response ) ) { + throw new Exception( 'Platform API error: ' . $response->get_error_message() ); + } + $code = \wp_remote_retrieve_response_code( $response ); + $body = \wp_remote_retrieve_body( $response ); + return [ 'success' => $code >= 200 && $code < 300, 'response_code' => $code, 'response_body' => $body, 'platform' => $platform ]; + } + private function create_crm_contact( $crm, $data ) { + $endpoints = [ 'salesforce' => 'https://example.salesforce.com/services/data/v52.0/sobjects/Contact/', 'hubspot' => 'https://api.hubapi.com/crm/v3/objects/contacts' ]; + if ( ! isset( $endpoints[ $crm ] ) ) { + return $this->failure( 'Unsupported CRM system: ' . $crm ); + } + $contact_id = 'contact_' . uniqid(); + return $this->success( [ 'crm_system' => $crm, 'operation' => 'create_contact', 'contact_id' => $contact_id, 'contact_data' => $data ], [ 'sync_type' => 'crm' ] ); + } + private function update_crm_contact( $crm, $data ) { + $contact_id = $data[ 'contact_id' ] ?? null; + if ( ! $contact_id ) { + return $this->failure( 'Contact ID required for update' ); + } + return $this->success( [ 'crm_system' => $crm, 'operation' => 'update_contact', 'contact_id' => $contact_id, 'updated_data' => $data ], [ 'sync_type' => 'crm' ] ); + } + private function sync_crm_contacts( $crm, $data ) { + $batch_size = $data[ 'batch_size' ] ?? 100; + $offset = $data[ 'offset' ] ?? 0; + $synced = \wp_rand( 50, $batch_size ); + return $this->success( [ 'crm_system' => $crm, 'operation' => 'sync_contacts', 'synced' => $synced, 'offset' => $offset, 'batch_size' => $batch_size ], [ 'sync_type' => 'crm' ] ); + } + private function sync_google_analytics( $metrics, $range ) { + $data = []; + foreach ( $metrics as $metric ) { + $data[ $metric ] = \wp_rand( 100, 10000 ); + } + return $this->success( [ 'provider' => 'google_analytics', 'metrics' => $metrics, 'date_range' => $range, 'data' => $data ], [ 'sync_type' => 'analytics' ] ); + } + private function sync_custom_tracking( $metrics, $range ) { + $start = $range[ 'start' ] ?? date( 'Y-m-d', strtotime( '-7 days' ) ); + $end = $range[ 'end' ] ?? date( 'Y-m-d' ); + $data = [ 'page_views' => \wp_rand( 1000, 5000 ), 'unique_visitors' => \wp_rand( 500, 2000 ), 'bounce_rate' => \wp_rand( 30, 70 ) ]; + return $this->success( [ 'provider' => 'custom_tracking', 'metrics' => $metrics, 'date_range' => [ 'start' => $start, 'end' => $end ], 'data' => $data ], [ 'sync_type' => 'analytics' ] ); + } + public static function create_social_media_job( $platforms, $post ) { + return new self( [ 'type' => 'social_media', 'platforms' => $platforms, 'post_data' => $post ] ); + } + public static function create_webhook_job( $url, $data, $method = 'POST', $headers = [] ) { + return new self( [ 'type' => 'webhook', 'url' => $url, 'data' => $data, 'method' => $method, 'headers' => $headers ] ); + } + public static function create_crm_job( $crm, $operation, $data ) { + return new self( [ 'type' => 'crm', 'crm_system' => $crm, 'operation' => $operation, 'data' => $data ] ); + } +} + +// Legacy global class alias removed (backward compatibility dropped). diff --git a/src/Jobs/Abstract_Base_Job.php b/src/Jobs/Abstract_Base_Job.php new file mode 100644 index 0000000..b9a8751 --- /dev/null +++ b/src/Jobs/Abstract_Base_Job.php @@ -0,0 +1,113 @@ +payload = $payload; + } + + abstract public function get_job_type(); + abstract public function execute(); + public function get_payload() { + return $this->payload; + } + public function get_priority() { + return $this->priority; + } + public function get_retry_attempts() { + return $this->retry_attempts; + } + public function get_timeout() { + return $this->timeout; + } + public function get_queue_name() { + return $this->queue_name; + } + public function handle_failure( $exception, $attempt ) { + \do_action( 'redis_queue_demo_job_failure', $this, $exception, $attempt ); + if ( \redis_queue_demo()->get_option( 'enable_logging', true ) ) { + $message = $exception instanceof Exception ? $exception->getMessage() : 'Failure result without exception'; + \error_log( sprintf( 'Redis Queue Demo: Job %s failed on attempt %d - %s', $this->get_job_type(), $attempt, $message ) ); + } + } + public function should_retry( $exception, $attempt ) { + if ( $attempt >= $this->retry_attempts ) { + return false; + } + if ( ! ( $exception instanceof Exception ) ) { + return \apply_filters( 'redis_queue_demo_should_retry_job', true, $this, null, $attempt ); + } + $non_retry = [ 'InvalidArgumentException', 'TypeError', 'ParseError' ]; + if ( in_array( get_class( $exception ), $non_retry, true ) ) { + return false; + } + return \apply_filters( 'redis_queue_demo_should_retry_job', true, $this, $exception, $attempt ); + } + public function get_retry_delay( $attempt ) { + $idx = $attempt - 1; + $delay = $this->retry_backoff[ $idx ] ?? min( pow( 2, $attempt ) * 60, 3600 ); + return \apply_filters( 'redis_queue_demo_job_retry_delay', $delay, $this, $attempt ); + } + public function serialize() { + return [ 'class' => get_class( $this ), 'payload' => $this->payload, 'priority' => $this->priority, 'retry_attempts' => $this->retry_attempts, 'timeout' => $this->timeout, 'queue_name' => $this->queue_name, 'retry_backoff' => $this->retry_backoff ]; + } + public static function deserialize( $data ) { + $class = $data[ 'class' ] ?? null; + if ( ! $class || ! class_exists( $class ) ) { + throw new Exception( 'Invalid job class in serialized data' ); + } + $job = new $class( $data[ 'payload' ] ?? [] ); + foreach ( [ 'priority', 'retry_attempts', 'timeout', 'queue_name', 'retry_backoff' ] as $prop ) { + if ( isset( $data[ $prop ] ) ) { + $job->$prop = $data[ $prop ]; + } + } + return $job; + } + public function set_priority( $p ) { + $this->priority = (int) $p; + return $this; + } + public function set_retry_attempts( $a ) { + $this->retry_attempts = (int) $a; + return $this; + } + public function set_timeout( $t ) { + $this->timeout = (int) $t; + return $this; + } + public function set_queue_name( $q ) { + $this->queue_name = \sanitize_text_field( $q ); + return $this; + } + public function set_retry_backoff( $b ) { + $this->retry_backoff = array_map( 'intval', $b ); + return $this; + } + protected function validate_payload( $payload ) { + return \apply_filters( 'redis_queue_demo_validate_job_payload', true, $payload, $this ); + } + protected function get_payload_value( $key, $default = null ) { + return $this->payload[ $key ] ?? $default; + } + protected function success( $data = null, $metadata = [] ) { + return Basic_Job_Result::success( $data, $metadata ); + } + protected function failure( $msg, $code = null, $metadata = [] ) { + return Basic_Job_Result::failure( $msg, $code, $metadata ); + } +} + +// Legacy global class alias removed (backward compatibility dropped). diff --git a/src/Jobs/Email_Job.php b/src/Jobs/Email_Job.php new file mode 100644 index 0000000..64a07c3 --- /dev/null +++ b/src/Jobs/Email_Job.php @@ -0,0 +1,152 @@ +queue_name = 'email'; + $this->priority = 20; + $this->timeout = 120; + } + public function get_job_type() { + return 'email'; + } + public function execute() { + try { + $type = $this->get_payload_value( 'type', 'single' ); + return match ( $type ) { 'single' => $this->send_single_email(), 'bulk' => $this->send_bulk_emails(), 'newsletter' => $this->send_newsletter(), default => $this->failure( 'Unknown email type: ' . $type ), }; + } catch (Exception $e) { + return $this->failure( $e->getMessage(), $e->getCode() ); + } + } + private function send_single_email() { + $to = $this->get_payload_value( 'to' ); + $subject = $this->get_payload_value( 'subject' ); + $message = $this->get_payload_value( 'message' ); + $headers = $this->get_payload_value( 'headers', [] ); + if ( empty( $to ) || empty( $subject ) || empty( $message ) ) { + return $this->failure( 'Missing required email fields: to, subject, message' ); + } + $attachments = $this->get_payload_value( 'attachments', [] ); + $sent = \wp_mail( $to, $subject, $message, $headers, $attachments ); + if ( $sent ) { + return $this->success( [ 'sent' => true, 'to' => $to ], [ 'email_type' => 'single' ] ); + } + global $phpmailer; + $phpmailer_error = ( isset( $phpmailer ) && is_object( $phpmailer ) && ! empty( $phpmailer->ErrorInfo ) ) ? $phpmailer->ErrorInfo : null; + return $this->failure( 'Failed to send email to: ' . $to, null, [ 'email_type' => 'single', 'phpmailer_error' => $phpmailer_error ] ); + } + private function send_bulk_emails() { + $emails = $this->get_payload_value( 'emails', [] ); + if ( empty( $emails ) || ! is_array( $emails ) ) { + return $this->failure( 'No emails provided or invalid format' ); + } + $sent = 0; + $failed = 0; + $failures = []; + foreach ( $emails as $email ) { + $to = $email[ 'to' ] ?? ''; + $subject = $email[ 'subject' ] ?? ''; + $message = $email[ 'message' ] ?? ''; + $headers = $email[ 'headers' ] ?? []; + if ( empty( $to ) || empty( $subject ) || empty( $message ) ) { + $failed++; + $failures[] = [ 'to' => $to, 'reason' => 'Missing required fields' ]; + continue; + } + $result = \wp_mail( $to, $subject, $message, $headers ); + if ( $result ) { + $sent++; + } else { + $failed++; + $failures[] = [ 'to' => $to, 'reason' => 'wp_mail returned false' ]; + } + usleep( 100000 ); + } + return $this->success( [ 'total' => count( $emails ), 'sent' => $sent, 'failed' => $failed, 'failures' => $failures ], [ 'email_type' => 'bulk' ] ); + } + private function send_newsletter() { + $subject = $this->get_payload_value( 'subject' ); + $message = $this->get_payload_value( 'message' ); + $subscriber_ids = $this->get_payload_value( 'subscriber_ids', [] ); + $headers = $this->get_payload_value( 'headers', [] ); + if ( empty( $subject ) || empty( $message ) ) { + return $this->failure( 'Missing required newsletter fields: subject, message' ); + } + $subscribers = $this->get_newsletter_subscribers( $subscriber_ids ); + if ( empty( $subscribers ) ) { + return $this->failure( 'No subscribers found' ); + } + $sent = 0; + $failed = 0; + $failures = []; + foreach ( $subscribers as $subscriber ) { + $to = is_array( $subscriber ) ? $subscriber[ 'email' ] : $subscriber; + if ( ! \is_email( $to ) ) { + $failed++; + $failures[] = [ 'to' => $to, 'reason' => 'Invalid email address' ]; + continue; + } + $personalized = $this->personalize_message( $message, $subscriber ); + $result = \wp_mail( $to, $subject, $personalized, $headers ); + if ( $result ) { + $sent++; + } else { + $failed++; + $failures[] = [ 'to' => $to, 'reason' => 'wp_mail returned false' ]; + } + usleep( 200000 ); + } + return $this->success( [ 'total' => count( $subscribers ), 'sent' => $sent, 'failed' => $failed, 'failures' => $failures, 'subject' => $subject ], [ 'email_type' => 'newsletter' ] ); + } + private function get_newsletter_subscribers( $subscriber_ids = [] ) { + if ( ! empty( $subscriber_ids ) ) { + global $wpdb; + $placeholders = implode( ',', array_fill( 0, count( $subscriber_ids ), '%d' ) ); + return $wpdb->get_results( $wpdb->prepare( "SELECT email, display_name FROM {$wpdb->users} WHERE ID IN ($placeholders)", ...$subscriber_ids ), ARRAY_A ); + } + $users = \get_users( [ 'fields' => [ 'user_email', 'display_name' ] ] ); + $subs = []; + foreach ( $users as $u ) { + $subs[] = [ 'email' => $u->user_email, 'display_name' => $u->display_name ]; + } + return $subs; + } + private function personalize_message( $message, $subscriber ) { + $name = is_array( $subscriber ) ? ( $subscriber[ 'display_name' ] ?? $subscriber[ 'email' ] ) : $subscriber; + $repl = [ '{name}' => $name, '{email}' => is_array( $subscriber ) ? $subscriber[ 'email' ] : $subscriber ]; + return str_replace( array_keys( $repl ), array_values( $repl ), $message ); + } + public function handle_failure( $exception, $attempt ) { + parent::handle_failure( $exception, $attempt ); + $email_type = $this->get_payload_value( 'type', 'single' ); + \do_action( 'redis_queue_demo_email_job_failed', $this, $exception, $attempt, $email_type ); + } + public function should_retry( $exception, $attempt ) { + if ( $exception instanceof Exception && str_contains( $exception->getMessage(), 'Invalid email' ) ) { + return false; + } + if ( ! ( $exception instanceof Exception ) ) { + return false; + } + $email_type = $this->get_payload_value( 'type', 'single' ); + if ( $email_type === 'bulk' && $attempt >= 2 ) { + return false; + } + return parent::should_retry( $exception, $attempt ); + } + public static function create_single_email( $to, $subject, $message, $headers = [] ) { + return new self( [ 'type' => 'single', 'to' => $to, 'subject' => $subject, 'message' => $message, 'headers' => $headers ] ); + } + public static function create_bulk_emails( $emails ) { + return new self( [ 'type' => 'bulk', 'emails' => $emails ] ); + } + public static function create_newsletter( $subject, $message, $subscriber_ids = [], $headers = [] ) { + return new self( [ 'type' => 'newsletter', 'subject' => $subject, 'message' => $message, 'subscriber_ids' => $subscriber_ids, 'headers' => $headers ] ); + } +} + +// Legacy global class alias removed (backward compatibility dropped). diff --git a/src/Jobs/Image_Processing_Job.php b/src/Jobs/Image_Processing_Job.php new file mode 100644 index 0000000..0a56d2e --- /dev/null +++ b/src/Jobs/Image_Processing_Job.php @@ -0,0 +1,232 @@ +queue_name = 'media'; + $this->priority = 30; + $this->timeout = 600; + } + public function get_job_type() { + return 'image_processing'; + } + public function execute() { + try { + $operation = $this->get_payload_value( 'operation', 'thumbnail' ); + return match ( $operation ) { 'thumbnail' => $this->generate_thumbnails(), 'optimize' => $this->optimize_image(), 'watermark' => $this->add_watermark(), 'bulk_thumbnails' => $this->generate_bulk_thumbnails(), default => $this->failure( 'Unknown image operation: ' . $operation ), }; + } catch (Exception $e) { + return $this->failure( $e->getMessage(), $e->getCode() ); + } + } + private function generate_thumbnails() { + $attachment_id = $this->get_payload_value( 'attachment_id' ); + $sizes = $this->get_payload_value( 'sizes', [] ); + if ( ! $attachment_id ) { + return $this->failure( 'Missing attachment ID' ); + } + $file_path = \get_attached_file( $attachment_id ); + if ( ! $file_path || ! file_exists( $file_path ) ) { + return $this->failure( 'Image file not found: ' . $attachment_id ); + } + if ( empty( $sizes ) ) { + $sizes = array_keys( \wp_get_additional_image_sizes() ); + $sizes = array_merge( $sizes, [ 'thumbnail', 'medium', 'medium_large', 'large' ] ); + } + $generated = []; + $failed = []; + foreach ( $sizes as $size ) { + try { + $resized = \image_make_intermediate_size( $file_path, \get_option( $size . '_size_w' ), \get_option( $size . '_size_h' ), \get_option( $size . '_crop' ) ); + if ( $resized ) { + $generated[ $size ] = $resized; + } else { + $failed[] = $size; + } + } catch (Exception $e) { + $failed[] = [ 'size' => $size, 'reason' => $e->getMessage() ]; + } + } + if ( ! empty( $generated ) ) { + $metadata = \wp_get_attachment_metadata( $attachment_id ); + $metadata[ 'sizes' ] = array_merge( $metadata[ 'sizes' ] ?? [], $generated ); + \wp_update_attachment_metadata( $attachment_id, $metadata ); + } + return $this->success( [ 'attachment_id' => $attachment_id, 'generated_sizes' => $generated, 'failed_sizes' => $failed, 'total_sizes' => count( $sizes ), 'successful_sizes' => count( $generated ) ], [ 'operation' => 'thumbnail' ] ); + } + private function optimize_image() { + $attachment_id = $this->get_payload_value( 'attachment_id' ); + $quality = $this->get_payload_value( 'quality', 85 ); + $format = $this->get_payload_value( 'format', null ); + if ( ! $attachment_id ) { + return $this->failure( 'Missing attachment ID' ); + } + $file_path = \get_attached_file( $attachment_id ); + if ( ! $file_path || ! file_exists( $file_path ) ) { + return $this->failure( 'Image file not found: ' . $attachment_id ); + } + $original = filesize( $file_path ); + $backup = $file_path . '.backup'; + if ( ! copy( $file_path, $backup ) ) { + return $this->failure( 'Failed to create backup' ); + } + try { + $image_type = \wp_check_filetype( $file_path ); + $image = \wp_get_image_editor( $file_path ); + if ( \is_wp_error( $image ) ) { + unlink( $backup ); + return $this->failure( 'Failed to load image: ' . $image->get_error_message() ); + } + $image->set_quality( $quality ); + if ( $format && $format !== $image_type[ 'ext' ] ) { + $new_path = preg_replace( '/\.[^.]+$/', '.' . $format, $file_path ); + $saved = $image->save( $new_path, 'image/' . $format ); + if ( \is_wp_error( $saved ) ) { + unlink( $backup ); + return $this->failure( 'Failed to convert image format: ' . $saved->get_error_message() ); + } + \update_attached_file( $attachment_id, $saved[ 'path' ] ); + $file_path = $saved[ 'path' ]; + } else { + $saved = $image->save( $file_path ); + if ( \is_wp_error( $saved ) ) { + copy( $backup, $file_path ); + unlink( $backup ); + return $this->failure( 'Failed to optimize image: ' . $saved->get_error_message() ); + } + } + $new_size = filesize( $file_path ); + $saved_bytes = $original - $new_size; + $saved_percent = $original > 0 ? ( $saved_bytes / $original ) * 100 : 0; + unlink( $backup ); + return $this->success( [ 'attachment_id' => $attachment_id, 'original_size' => $original, 'new_size' => $new_size, 'saved_bytes' => $saved_bytes, 'saved_percent' => round( $saved_percent, 2 ), 'quality' => $quality, 'format_changed' => (bool) $format ], [ 'operation' => 'optimize' ] ); + } catch (Exception $e) { + if ( file_exists( $backup ) ) { + copy( $backup, $file_path ); + unlink( $backup ); + } + return $this->failure( 'Image optimization failed: ' . $e->getMessage() ); + } + } + private function add_watermark() { + $attachment_id = $this->get_payload_value( 'attachment_id' ); + $watermark_id = $this->get_payload_value( 'watermark_id' ); + $position = $this->get_payload_value( 'position', 'bottom-right' ); + $opacity = $this->get_payload_value( 'opacity', 50 ); + $margin = $this->get_payload_value( 'margin', 10 ); + if ( ! $attachment_id || ! $watermark_id ) { + return $this->failure( 'Missing attachment ID or watermark ID' ); + } + $image_path = \get_attached_file( $attachment_id ); + $watermark_path = \get_attached_file( $watermark_id ); + if ( ! $image_path || ! file_exists( $image_path ) ) { + return $this->failure( 'Main image file not found' ); + } + if ( ! $watermark_path || ! file_exists( $watermark_path ) ) { + return $this->failure( 'Watermark image file not found' ); + } + try { + $image = \wp_get_image_editor( $image_path ); + $watermark = \wp_get_image_editor( $watermark_path ); + if ( \is_wp_error( $image ) ) { + return $this->failure( 'Failed to load main image: ' . $image->get_error_message() ); + } + if ( \is_wp_error( $watermark ) ) { + return $this->failure( 'Failed to load watermark: ' . $watermark->get_error_message() ); + } + $image_size = $image->get_size(); + $watermark_size = $watermark->get_size(); + $coords = $this->calculate_watermark_position( $image_size, $watermark_size, $position, $margin ); + $result = $image->save( $image_path ); + if ( \is_wp_error( $result ) ) { + return $this->failure( 'Failed to save watermarked image: ' . $result->get_error_message() ); + } + return $this->success( [ 'attachment_id' => $attachment_id, 'watermark_id' => $watermark_id, 'position' => $position, 'coordinates' => $coords, 'opacity' => $opacity ], [ 'operation' => 'watermark' ] ); + } catch (Exception $e) { + return $this->failure( 'Watermark application failed: ' . $e->getMessage() ); + } + } + private function generate_bulk_thumbnails() { + $ids = $this->get_payload_value( 'attachment_ids', [] ); + $sizes = $this->get_payload_value( 'sizes', [] ); + if ( empty( $ids ) ) { + return $this->failure( 'No attachment IDs provided' ); + } + $processed = 0; + $failed = 0; + $results = []; + foreach ( $ids as $id ) { + try { + $job = new self( [ 'operation' => 'thumbnail', 'attachment_id' => $id, 'sizes' => $sizes ] ); + $res = $job->generate_thumbnails(); + if ( $res->is_successful() ) { + $processed++; + $results[ $id ] = $res->get_data(); + } else { + $failed++; + $results[ $id ] = [ 'error' => $res->get_error_message() ]; + } + } catch (Exception $e) { + $failed++; + $results[ $id ] = [ 'error' => $e->getMessage() ]; + } + } + return $this->success( [ 'total' => count( $ids ), 'processed' => $processed, 'failed' => $failed, 'results' => $results ], [ 'operation' => 'bulk_thumbnails' ] ); + } + private function calculate_watermark_position( $image_size, $watermark_size, $position, $margin ) { + $x = 0; + $y = 0; + switch ( $position ) { + case 'top-left': + $x = $margin; + $y = $margin; + break; + case 'top-center': + $x = ( $image_size[ 'width' ] - $watermark_size[ 'width' ] ) / 2; + $y = $margin; + break; + case 'top-right': + $x = $image_size[ 'width' ] - $watermark_size[ 'width' ] - $margin; + $y = $margin; + break; + case 'center-left': + $x = $margin; + $y = ( $image_size[ 'height' ] - $watermark_size[ 'height' ] ) / 2; + break; + case 'center': + $x = ( $image_size[ 'width' ] - $watermark_size[ 'width' ] ) / 2; + $y = ( $image_size[ 'height' ] - $watermark_size[ 'height' ] ) / 2; + break; + case 'center-right': + $x = $image_size[ 'width' ] - $watermark_size[ 'width' ] - $margin; + $y = ( $image_size[ 'height' ] - $watermark_size[ 'height' ] ) / 2; + break; + case 'bottom-left': + $x = $margin; + $y = $image_size[ 'height' ] - $watermark_size[ 'height' ] - $margin; + break; + case 'bottom-center': + $x = ( $image_size[ 'width' ] - $watermark_size[ 'width' ] ) / 2; + $y = $image_size[ 'height' ] - $watermark_size[ 'height' ] - $margin; + break; + case 'bottom-right': + default: + $x = $image_size[ 'width' ] - $watermark_size[ 'width' ] - $margin; + $y = $image_size[ 'height' ] - $watermark_size[ 'height' ] - $margin; + } + return [ 'x' => max( 0, $x ), 'y' => max( 0, $y ) ]; + } + public static function create_thumbnail_job( $attachment_id, $sizes = [] ) { + return new self( [ 'operation' => 'thumbnail', 'attachment_id' => $attachment_id, 'sizes' => $sizes ] ); + } + public static function create_optimization_job( $attachment_id, $quality = 85, $format = null ) { + return new self( [ 'operation' => 'optimize', 'attachment_id' => $attachment_id, 'quality' => $quality, 'format' => $format ] ); + } + public static function create_watermark_job( $attachment_id, $watermark_id, $position = 'bottom-right', $opacity = 50, $margin = 10 ) { + return new self( [ 'operation' => 'watermark', 'attachment_id' => $attachment_id, 'watermark_id' => $watermark_id, 'position' => $position, 'opacity' => $opacity, 'margin' => $margin ] ); + } +} + +// Legacy global class alias removed (backward compatibility dropped). diff --git a/src/Update/GitHub_Plugin_Updater.php b/src/Update/GitHub_Plugin_Updater.php new file mode 100644 index 0000000..e15a0a9 --- /dev/null +++ b/src/Update/GitHub_Plugin_Updater.php @@ -0,0 +1,68 @@ +github_url = $config[ 'github_url' ]; + $this->plugin_file = $config[ 'plugin_file' ]; + $this->plugin_slug = $config[ 'plugin_slug' ]; + $this->branch = $config[ 'branch' ] ?? 'main'; + $this->name_regex = $config[ 'name_regex' ] ?? ''; + $this->enable_release_assets = $config[ 'enable_release_assets' ] ?? ! empty( $this->name_regex ); + add_action( 'init', [ $this, 'setup_updater' ] ); + } + + public function setup_updater(): void { + try { + $update_checker = PucFactory::buildUpdateChecker( + $this->github_url, + $this->plugin_file, + $this->plugin_slug + ); + $update_checker->setBranch( $this->branch ); + if ( $this->enable_release_assets && ! empty( $this->name_regex ) ) { + $update_checker->getVcsApi()->enableReleaseAssets( $this->name_regex ); + } + } catch (\Exception $e) { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + error_log( 'GitHub Plugin Updater Error: ' . $e->getMessage() ); + } + } + } + + public static function create( string $github_url, string $plugin_file, string $plugin_slug, string $branch = 'main' ): self { + return new self( [ + 'github_url' => $github_url, + 'plugin_file' => $plugin_file, + 'plugin_slug' => $plugin_slug, + 'branch' => $branch, + ] ); + } + + public static function create_with_assets( string $github_url, string $plugin_file, string $plugin_slug, string $name_regex, string $branch = 'main' ): self { + return new self( [ + 'github_url' => $github_url, + 'plugin_file' => $plugin_file, + 'plugin_slug' => $plugin_slug, + 'branch' => $branch, + 'name_regex' => $name_regex, + ] ); + } +} diff --git a/src/Workers/Sync_Worker.php b/src/Workers/Sync_Worker.php new file mode 100644 index 0000000..36a122a --- /dev/null +++ b/src/Workers/Sync_Worker.php @@ -0,0 +1,289 @@ + 0, + 'jobs_failed' => 0, + 'total_time' => 0, + 'start_time' => null, + 'last_activity' => null, + ]; + + public function __construct( Redis_Queue_Manager $queue_manager, Job_Processor $job_processor, $config = [] ) { + $this->queue_manager = $queue_manager; + $this->job_processor = $job_processor; + $this->config = $this->parse_config( $config ); + $this->stats[ 'start_time' ] = microtime( true ); + } + + public function process_jobs( $queues = [ 'default' ], $max_jobs = null ) { + if ( ! $this->queue_manager->is_connected() ) { + return [ 'success' => false, 'error' => 'Redis connection not available' ]; + } + $this->state = 'working'; + $this->stats[ 'last_activity' ] = microtime( true ); + if ( null === $max_jobs ) { + $max_jobs = $this->config[ 'max_jobs_per_run' ]; + } + function_exists( '\do_action' ) && \do_action( 'redis_queue_demo_worker_start', $this, $queues, $max_jobs ); + try { + $results = $this->job_processor->process_jobs( $queues, $max_jobs ); + $this->stats[ 'jobs_processed' ] += $results[ 'processed' ]; + $this->stats[ 'total_time' ] += $results[ 'total_time' ]; + foreach ( $results[ 'results' ] as $job_result ) { + if ( ! $job_result[ 'result' ]->is_successful() ) { + $this->stats[ 'jobs_failed' ]++; + } + } + $this->state = 'idle'; + function_exists( '\do_action' ) && \do_action( 'redis_queue_demo_worker_complete', $this, $results ); + return [ + 'success' => true, + 'processed' => $results[ 'processed' ], + 'total_time' => $results[ 'total_time' ], + 'total_memory' => $results[ 'total_memory' ], + 'results' => $results[ 'results' ], + 'worker_stats' => $this->get_stats(), + ]; + } catch (Exception $e) { + $this->state = 'error'; + function_exists( '\do_action' ) && \do_action( 'redis_queue_demo_worker_error', $this, $e ); + return [ 'success' => false, 'error' => $e ? $e->getMessage() : 'Unknown error occurred', 'code' => $e ? $e->getCode() : 0 ]; + } catch (Throwable $e) { + $this->state = 'error'; + return [ 'success' => false, 'error' => $e ? $e->getMessage() : 'Unknown throwable error occurred', 'code' => $e ? $e->getCode() : 0 ]; + } finally { + $this->stats[ 'last_activity' ] = microtime( true ); + } + } + + /** + * Backward compatibility wrapper. + * Some legacy code (REST endpoints, cron callbacks, third-party integrations) may still call $worker->process(). + * Provide a thin adapter that delegates to process_jobs() with configured defaults. + * + * @param array|null $queues Optional queues list (defaults to ['default']). + * @param int|null $max_jobs Optional maximum jobs to process; falls back to config if null. + * @return array Result array identical to process_jobs(). + */ + public function process( $queues = null, $max_jobs = null ) { + if ( null === $queues ) { + $queues = [ 'default' ]; + } + return $this->process_jobs( $queues, $max_jobs ); + } + + public function process_job_by_id( $job_id ) { + global $wpdb; + $table_name = $wpdb->prefix . 'redis_queue_jobs'; + $array_a = defined( 'ARRAY_A' ) ? ARRAY_A : 'ARRAY_A'; + $job_data = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table_name} WHERE job_id = %s AND status IN ('queued', 'failed')", $job_id ), $array_a ); + if ( ! $job_data ) { + return [ 'success' => false, 'error' => 'Job not found or not processable' ]; + } + $this->state = 'working'; + $this->stats[ 'last_activity' ] = microtime( true ); + try { + $payload_array = json_decode( $job_data[ 'payload' ], true ); + if ( ! is_array( $payload_array ) ) { + $payload_array = []; + } + // Attempt to resolve class from job_type mapping (namespaced). + $job_type = $job_data[ 'job_type' ]; + $job_class = null; + // Use Job_Processor internal mapping via reflection (avoid exposing new public API just for this). + if ( method_exists( $this->job_processor, 'get_current_job' ) ) { // cheap existence check to ensure object ok. + try { + $ref = new \ReflectionClass( $this->job_processor ); + if ( $ref->hasMethod( 'get_job_class' ) ) { + $m = $ref->getMethod( 'get_job_class' ); + $m->setAccessible( true ); + $job_class = $m->invoke( $this->job_processor, $job_type ); + } + } catch (\Throwable $e) { + // Silently ignore; we'll fallback below. + } + } + if ( ! is_string( $job_class ) || $job_class === '' ) { + // Fallback guess: treat job_type as class if it looks namespaced, else compose PSR-4 path. + if ( str_contains( $job_type, '\\' ) && class_exists( $job_type ) ) { + $job_class = $job_type; + } else { + $map = [ + 'email' => 'Soderlind\\RedisQueueDemo\\Jobs\\Email_Job', + 'image_processing' => 'Soderlind\\RedisQueueDemo\\Jobs\\Image_Processing_Job', + 'api_sync' => 'Soderlind\\RedisQueueDemo\\Jobs\\API_Sync_Job', + ]; + $job_class = $map[ $job_type ] ?? null; + } + } + $serialized_job = [ + 'class' => $job_class, + 'payload' => $payload_array, + 'options' => $payload_array[ 'options' ] ?? [], + 'timestamp' => $job_data[ 'created_at' ] ?? null, + ]; + $processed_job_data = [ + 'job_id' => $job_data[ 'job_id' ], + 'job_type' => $job_type, + 'queue_name' => $job_data[ 'queue_name' ], + 'priority' => $job_data[ 'priority' ], + 'payload' => $payload_array, + 'serialized_job' => $serialized_job, + ]; + $result = $this->job_processor->process_job( $processed_job_data ); + $this->stats[ 'jobs_processed' ]++; + $this->stats[ 'last_activity' ] = microtime( true ); + if ( ! $result->is_successful() ) { + $this->stats[ 'jobs_failed' ]++; + } + $this->state = 'idle'; + return [ 'success' => true, 'job_id' => $job_id, 'job_result' => $result, 'worker_stats' => $this->get_stats() ]; + } catch (Exception $e) { + $this->state = 'error'; + $this->stats[ 'jobs_failed' ]++; + return [ 'success' => false, 'job_id' => $job_id, 'error' => $e->getMessage(), 'code' => $e->getCode() ]; + } + } + + public function get_status() { + $uptime = microtime( true ) - $this->stats[ 'start_time' ]; + return [ + 'state' => $this->state, + 'uptime' => $uptime, + 'redis_connected' => $this->queue_manager->is_connected(), + 'config' => $this->config, + 'stats' => $this->get_stats(), + 'current_job' => $this->job_processor->get_current_job(), + 'memory_usage' => [ + 'current' => memory_get_usage( true ), + 'peak' => memory_get_peak_usage( true ), + 'limit' => $this->get_memory_limit(), + ], + ]; + } + + public function get_stats() { + $uptime = microtime( true ) - $this->stats[ 'start_time' ]; + $jobs_per_second = $uptime > 0 ? $this->stats[ 'jobs_processed' ] / $uptime : 0; + return array_merge( $this->stats, [ + 'uptime' => $uptime, + 'jobs_per_second' => $jobs_per_second, + 'success_rate' => $this->calculate_success_rate(), + ] ); + } + + public function reset_stats() { + $this->stats = [ + 'jobs_processed' => 0, + 'jobs_failed' => 0, + 'total_time' => 0, + 'start_time' => microtime( true ), + 'last_activity' => null, + ]; + } + + public function update_config( $config ) { + $this->config = $this->parse_config( $config ); + } + + public function get_config() { + return $this->config; + } + + public function should_stop() { + $memory_limit = $this->get_memory_limit(); + $current_usage = memory_get_usage( true ); + if ( $memory_limit > 0 && $current_usage > ( $memory_limit * 0.8 ) ) { + return true; + } + $uptime = microtime( true ) - $this->stats[ 'start_time' ]; + return $uptime > $this->config[ 'max_execution_time' ]; + } + + private function parse_config( $config ) { + $defaults = [ + 'max_jobs_per_run' => \redis_queue_demo()->get_option( 'max_jobs_per_run', 10 ), + 'memory_limit' => ini_get( 'memory_limit' ), + 'max_execution_time' => \redis_queue_demo()->get_option( 'worker_timeout', 300 ), + 'sleep_interval' => 1, + 'retry_failed_jobs' => true, + 'cleanup_on_shutdown' => true, + ]; + $parsed = array_merge( $defaults, $config ); + $parsed[ 'max_jobs_per_run' ] = max( 1, (int) $parsed[ 'max_jobs_per_run' ] ); + $parsed[ 'max_execution_time' ] = max( 30, (int) $parsed[ 'max_execution_time' ] ); + $parsed[ 'sleep_interval' ] = max( 1, (int) $parsed[ 'sleep_interval' ] ); + return $parsed; + } + + private function calculate_success_rate() { + if ( 0 === $this->stats[ 'jobs_processed' ] ) { + return 100.0; + } + $successful = $this->stats[ 'jobs_processed' ] - $this->stats[ 'jobs_failed' ]; + return ( $successful / $this->stats[ 'jobs_processed' ] ) * 100; + } + + private function get_memory_limit() { + $memory_limit = ini_get( 'memory_limit' ); + if ( '-1' === $memory_limit ) { + return 0; + } + $unit = strtolower( substr( $memory_limit, -1 ) ); + $value = (int) $memory_limit; + switch ( $unit ) { + case 'g': + $value *= 1024 * 1024 * 1024; + break; + case 'm': + $value *= 1024 * 1024; + break; + case 'k': + $value *= 1024; + break; + } + return $value; + } + + public static function create_default( Redis_Queue_Manager $queue_manager, Job_Processor $job_processor ) { + return new self( $queue_manager, $job_processor ); + } + + public function __destruct() { + if ( $this->config[ 'cleanup_on_shutdown' ] ) { + function_exists( '\do_action' ) && \do_action( 'redis_queue_demo_worker_shutdown', $this ); + } + } +} + +// Legacy global class alias removed (backward compatibility dropped). diff --git a/workers/class-sync-worker.php b/workers/class-sync-worker.php index b371480..05c56cc 100644 --- a/workers/class-sync-worker.php +++ b/workers/class-sync-worker.php @@ -213,17 +213,44 @@ public function process_job_by_id( $job_id ) { $this->stats[ 'last_activity' ] = microtime( true ); try { - // Prepare job data for processor. + $payload_array = json_decode( $job_data[ 'payload' ], true ); + if ( ! is_array( $payload_array ) ) { + $payload_array = array(); + } + $job_type = $job_data[ 'job_type' ]; + // Infer class similar to namespaced worker. + $map = array( + 'email' => 'Soderlind\\RedisQueueDemo\\Jobs\\Email_Job', + 'image_processing' => 'Soderlind\\RedisQueueDemo\\Jobs\\Image_Processing_Job', + 'api_sync' => 'Soderlind\\RedisQueueDemo\\Jobs\\API_Sync_Job', + ); + $job_class = null; + if ( isset( $map[ $job_type ] ) ) { + $job_class = $map[ $job_type ]; + } elseif ( str_contains( $job_type, '\\' ) && class_exists( $job_type ) ) { + $job_class = $job_type; + } + if ( empty( $job_class ) ) { + // Log and abort gracefully; update status to failed to avoid infinite loop. + error_log( 'Redis Queue Demo (legacy worker): Unable to infer class for job_id=' . $job_id . ' job_type=' . $job_type ); + $wpdb->update( $wpdb->prefix . 'redis_queue_jobs', array( 'status' => 'failed', 'error_message' => 'Unresolvable job class', 'updated_at' => current_time( 'mysql' ) ), array( 'job_id' => $job_id ), array( '%s', '%s', '%s' ), array( '%s' ) ); + return array( 'success' => false, 'job_id' => $job_id, 'error' => 'Unresolvable job class', 'code' => 0 ); + } + $serialized_job = array( + 'class' => $job_class, + 'payload' => $payload_array, + 'options' => isset( $payload_array[ 'options' ] ) ? $payload_array[ 'options' ] : array(), + 'timestamp' => $job_data[ 'created_at' ], + ); $processed_job_data = array( - 'job_id' => $job_data[ 'job_id' ], - 'job_type' => $job_data[ 'job_type' ], + 'job_id' => $job_id, + 'job_type' => $job_type, 'queue_name' => $job_data[ 'queue_name' ], 'priority' => $job_data[ 'priority' ], - 'payload' => json_decode( $job_data[ 'payload' ], true ), - 'serialized_job' => json_decode( $job_data[ 'payload' ], true ), // Simplified for demo. + 'payload' => $payload_array, + 'serialized_job' => $serialized_job, ); - - $result = $this->job_processor->process_job( $processed_job_data ); + $result = $this->job_processor->process_job( $processed_job_data ); $this->stats[ 'jobs_processed' ]++; $this->stats[ 'last_activity' ] = microtime( true );