diff --git a/CHANGELOG.md b/CHANGELOG.md index 66d9f92..3b18fd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,3 +26,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Service edit form with default/custom mode toggle and credential fields - Dashboard migration controller action for Perfect Publisher Pro import - Template placeholders: {title}, {url}, {introtext}, {fulltext}, {image}, {category}, {author}, {date} +- Queue processing: Joomla Scheduled Task plugin (`plg_task_mokojoomcross`) — preferred method +- Queue processing: Page-load fallback via system plugin `onAfterRender` with configurable throttle +- Configurable processing method: scheduler-only (recommended), page-load only, or both +- Dashboard warning banner when page-load processing is active instead of scheduler +- Shared `QueueProcessor` helper with DB lock to prevent concurrent execution +- Failed post retry with configurable max retries and delay +- Automatic log cleanup based on configurable retention period diff --git a/src/packages/com_mokojoomcross/config.xml b/src/packages/com_mokojoomcross/config.xml index fa5dfa3..5c8d967 100644 --- a/src/packages/com_mokojoomcross/config.xml +++ b/src/packages/com_mokojoomcross/config.xml @@ -51,4 +51,40 @@ rows="4" /> + +
diff --git a/src/packages/com_mokojoomcross/language/en-GB/com_mokojoomcross.ini b/src/packages/com_mokojoomcross/language/en-GB/com_mokojoomcross.ini index d8ab086..5bdeaa6 100644 --- a/src/packages/com_mokojoomcross/language/en-GB/com_mokojoomcross.ini +++ b/src/packages/com_mokojoomcross/language/en-GB/com_mokojoomcross.ini @@ -73,3 +73,20 @@ COM_MOKOJOOMCROSS_HEADING_MODE="Mode" COM_MOKOJOOMCROSS_DASHBOARD_RECENT_ACTIVITY="Recent Activity" COM_MOKOJOOMCROSS_DASHBOARD_NO_RECENT="No recent activity." COM_MOKOJOOMCROSS_DASHBOARD_TOTAL_POSTS="Total Posts" +COM_MOKOJOOMCROSS_DASHBOARD_PAGELOAD_WARNING_TITLE="Page-load queue processing is active" +COM_MOKOJOOMCROSS_DASHBOARD_PAGELOAD_WARNING="You are using page-load processing for the cross-post queue. This is a fallback method and may be unreliable on low-traffic sites. For production use, switch to Joomla Scheduled Tasks: create a task of type MokoJoomCross - Process Queue in System → Scheduled Tasks, then set queue processing to Scheduler only in component options." + +; Queue Processing Configuration +COM_MOKOJOOMCROSS_CONFIG_QUEUE="Queue Processing" +COM_MOKOJOOMCROSS_CONFIG_QUEUE_PROCESSING="Processing Method" +COM_MOKOJOOMCROSS_CONFIG_QUEUE_PROCESSING_DESC="How queued posts, retries, and scheduled posts are processed. Scheduler (recommended) uses Joomla's built-in Task Scheduler. Page-load piggybacks on page requests." +COM_MOKOJOOMCROSS_CONFIG_QUEUE_SCHEDULER="Scheduler only (recommended)" +COM_MOKOJOOMCROSS_CONFIG_QUEUE_PAGELOAD="Page-load only (fallback)" +COM_MOKOJOOMCROSS_CONFIG_QUEUE_BOTH="Both (scheduler + page-load)" +COM_MOKOJOOMCROSS_CONFIG_PAGELOAD_CLIENT="Page-load Client" +COM_MOKOJOOMCROSS_CONFIG_PAGELOAD_CLIENT_DESC="Which Joomla application triggers page-load processing." +COM_MOKOJOOMCROSS_CONFIG_PAGELOAD_BOTH="Backend and Frontend" +COM_MOKOJOOMCROSS_CONFIG_PAGELOAD_ADMIN="Backend only" +COM_MOKOJOOMCROSS_CONFIG_PAGELOAD_SITE="Frontend only" +COM_MOKOJOOMCROSS_CONFIG_PAGELOAD_INTERVAL="Page-load Interval (seconds)" +COM_MOKOJOOMCROSS_CONFIG_PAGELOAD_INTERVAL_DESC="Minimum seconds between page-load queue runs. Lower = more responsive but more DB queries per page load." diff --git a/src/packages/com_mokojoomcross/src/Helper/QueueProcessor.php b/src/packages/com_mokojoomcross/src/Helper/QueueProcessor.php new file mode 100644 index 0000000..57ac9c8 --- /dev/null +++ b/src/packages/com_mokojoomcross/src/Helper/QueueProcessor.php @@ -0,0 +1,371 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +namespace Joomla\Component\MokoJoomCross\Administrator\Helper; + +defined('_JEXEC') or die; + +use Joomla\CMS\Component\ComponentHelper; +use Joomla\CMS\Factory; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface; + +/** + * Shared queue processor used by: + * - System plugin onAfterRender (page-load processing) + * - Task scheduler plugin (Joomla scheduled task) + * + * Handles: queued posts, failed retries, scheduled posts, and log cleanup. + * Uses a simple DB-based lock to prevent concurrent execution. + */ +class QueueProcessor +{ + /** + * Process the post queue: dispatch queued posts, retry failed, fire scheduled. + * + * @param int $batchSize Max posts to process per run + * + * @return array ['processed' => int, 'succeeded' => int, 'failed' => int, 'skipped' => int] + */ + public static function processQueue(int $batchSize = 10): array + { + $result = ['processed' => 0, 'succeeded' => 0, 'failed' => 0, 'skipped' => 0]; + + if (!self::acquireLock()) { + $result['skipped'] = -1; + + return $result; + } + + try { + $db = Factory::getDbo(); + $componentParams = ComponentHelper::getParams('com_mokojoomcross'); + $maxRetry = (int) $componentParams->get('retry_max', 3); + $retryDelay = (int) $componentParams->get('retry_delay', 300); + $now = Factory::getDate()->toSql(); + + // Build service plugin map + $pluginMap = self::getServicePluginMap(); + + // 1. Process queued posts + $query = $db->getQuery(true) + ->select('p.*, s.service_type, s.credentials, s.params AS service_params') + ->from($db->quoteName('#__mokojoomcross_posts', 'p')) + ->join('INNER', $db->quoteName('#__mokojoomcross_services', 's') + . ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('p.service_id')) + ->where($db->quoteName('p.status') . ' = ' . $db->quote('queued')) + ->where('(' . $db->quoteName('p.scheduled_at') . ' IS NULL OR ' + . $db->quoteName('p.scheduled_at') . ' <= ' . $db->quote($now) . ')') + ->where($db->quoteName('s.published') . ' = 1') + ->order($db->quoteName('p.created') . ' ASC') + ->setLimit($batchSize); + + $db->setQuery($query); + $queuedPosts = $db->loadObjectList() ?: []; + + // 2. Process failed posts eligible for retry + $retryAfter = Factory::getDate('now - ' . $retryDelay . ' seconds')->toSql(); + + $query = $db->getQuery(true) + ->select('p.*, s.service_type, s.credentials, s.params AS service_params') + ->from($db->quoteName('#__mokojoomcross_posts', 'p')) + ->join('INNER', $db->quoteName('#__mokojoomcross_services', 's') + . ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('p.service_id')) + ->where($db->quoteName('p.status') . ' = ' . $db->quote('failed')) + ->where($db->quoteName('p.retry_count') . ' < ' . $maxRetry) + ->where($db->quoteName('p.modified') . ' <= ' . $db->quote($retryAfter)) + ->where($db->quoteName('s.published') . ' = 1') + ->order($db->quoteName('p.modified') . ' ASC') + ->setLimit($batchSize); + + $db->setQuery($query); + $retryPosts = $db->loadObjectList() ?: []; + + $allPosts = array_merge($queuedPosts, $retryPosts); + + foreach ($allPosts as $post) { + $result['processed']++; + + $plugin = $pluginMap[$post->service_type] ?? null; + + if (!$plugin) { + $result['skipped']++; + continue; + } + + $isRetry = ($post->status === 'failed'); + + if ($isRetry) { + // Increment retry count + $newRetryCount = (int) $post->retry_count + 1; + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokojoomcross_posts')) + ->set($db->quoteName('retry_count') . ' = ' . $newRetryCount) + ->where($db->quoteName('id') . ' = ' . (int) $post->id) + ); + $db->execute(); + } + + // Mark as posting + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokojoomcross_posts')) + ->set($db->quoteName('status') . ' = ' . $db->quote('posting')) + ->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('id') . ' = ' . (int) $post->id) + ); + $db->execute(); + + $credentials = json_decode($post->credentials ?: '{}', true) ?: []; + $params = json_decode($post->service_params ?: '{}', true) ?: []; + + try { + $apiResult = $plugin->publish($post->message, [], $credentials, $params); + + if (!empty($apiResult['success'])) { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokojoomcross_posts')) + ->set($db->quoteName('status') . ' = ' . $db->quote('posted')) + ->set($db->quoteName('platform_post_id') . ' = ' . $db->quote($apiResult['platform_post_id'] ?? '')) + ->set($db->quoteName('platform_response') . ' = ' . $db->quote(json_encode($apiResult['response'] ?? []))) + ->set($db->quoteName('posted_at') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('id') . ' = ' . (int) $post->id) + ); + $db->execute(); + + self::log($db, (int) $post->id, (int) $post->service_id, 'info', + sprintf('%s to %s (ID: %s)', $isRetry ? 'Retry succeeded' : 'Posted', $post->service_type, $apiResult['platform_post_id'] ?? 'n/a')); + + $result['succeeded']++; + } else { + $errorMsg = $apiResult['response']['error'] ?? json_encode($apiResult['response'] ?? []); + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokojoomcross_posts')) + ->set($db->quoteName('status') . ' = ' . $db->quote('failed')) + ->set($db->quoteName('error_message') . ' = ' . $db->quote(mb_substr($errorMsg, 0, 1000))) + ->set($db->quoteName('platform_response') . ' = ' . $db->quote(json_encode($apiResult['response'] ?? []))) + ->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('id') . ' = ' . (int) $post->id) + ); + $db->execute(); + + self::log($db, (int) $post->id, (int) $post->service_id, 'error', + sprintf('Failed %s: %s', $post->service_type, mb_substr($errorMsg, 0, 500))); + + $result['failed']++; + } + } catch (\Throwable $e) { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokojoomcross_posts')) + ->set($db->quoteName('status') . ' = ' . $db->quote('failed')) + ->set($db->quoteName('error_message') . ' = ' . $db->quote(mb_substr($e->getMessage(), 0, 1000))) + ->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('id') . ' = ' . (int) $post->id) + ); + $db->execute(); + + self::log($db, (int) $post->id, (int) $post->service_id, 'error', + sprintf('Exception %s: %s', $post->service_type, mb_substr($e->getMessage(), 0, 500))); + + $result['failed']++; + } + } + + // 3. Clean up old logs + self::cleanupLogs($db, $componentParams); + + } finally { + self::releaseLock(); + } + + return $result; + } + + /** + * Check if there are pending items in the queue. + * + * @return bool + */ + public static function hasPendingWork(): bool + { + $db = Factory::getDbo(); + + $componentParams = ComponentHelper::getParams('com_mokojoomcross'); + $maxRetry = (int) $componentParams->get('retry_max', 3); + $retryDelay = (int) $componentParams->get('retry_delay', 300); + $retryAfter = Factory::getDate('now - ' . $retryDelay . ' seconds')->toSql(); + $now = Factory::getDate()->toSql(); + + // Queued posts ready to go + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokojoomcross_posts')) + ->where($db->quoteName('status') . ' = ' . $db->quote('queued')) + ->where('(' . $db->quoteName('scheduled_at') . ' IS NULL OR ' + . $db->quoteName('scheduled_at') . ' <= ' . $db->quote($now) . ')'); + $db->setQuery($query); + $queued = (int) $db->loadResult(); + + // Failed posts eligible for retry + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokojoomcross_posts')) + ->where($db->quoteName('status') . ' = ' . $db->quote('failed')) + ->where($db->quoteName('retry_count') . ' < ' . $maxRetry) + ->where($db->quoteName('modified') . ' <= ' . $db->quote($retryAfter)); + $db->setQuery($query); + $retryable = (int) $db->loadResult(); + + return ($queued + $retryable) > 0; + } + + /** + * Import mokojoomcross plugins and build a type → plugin instance map. + * + * @return array