From ba7d2d0f2125e3b2b6b8b316c6e299d123cb6d4b Mon Sep 17 00:00:00 2001 From: IanM Date: Sun, 26 Jul 2026 22:57:12 +0100 Subject: [PATCH] Implement queue pausing across all backends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Illuminate's Worker consults the queue manager's pause surface when popping jobs, but Flarum's QueueFactory stubbed the whole family as no-ops. The stubs are now real, backed by the shared cache store using Illuminate's own key format (illuminate:queue:paused:{connection}:{queue}), so the signal reaches every worker process — across containers, and including Horizon workers, which resolve this same factory. - Per-queue pause plus a wildcard (*) that covers every queue on the connection, so pause-all works without core having to enumerate queue names. The layers are orthogonal: resuming all does not clear individually paused queues. - pausedQueues() tracks what is paused per connection for the dashboard, self-healing when a pauseFor() TTL expires or a pause is cleared through the raw cache key. - queue:pause / queue:resume commands (Flarum-native: the stock Illuminate ones type-hint the concrete QueueManager). The queue argument accepts queue or connection:queue and defaults to the active connection; --all pauses or resumes everything. Registered only for non-sync connections, like the other queue commands. - POST /api/queue/pause admin endpoint; refuses (409) on the sync driver, where jobs execute inline and there is nothing to pause. - flarum.queue.queues container binding: the queue names known to the installation (default: ['default']). Extensions routing jobs onto named queues can append theirs to get per-queue admin controls. - Admin UI: a warning on the dashboard whenever any queue is paused, and pause switches on the Advanced page — a single switch when only one queue is known, per-queue switches plus pause-all otherwise. Sync driver shows no pause controls. Note for operators: long-running workers evaluate the pause state with the code they booted with, so a one-time queue:restart is needed after upgrading to pick up the new factory. --- .../core/js/src/admin/AdminApplication.tsx | 2 + .../js/src/admin/components/AdvancedPage.tsx | 50 ++++ .../js/src/admin/components/DashboardPage.tsx | 22 ++ framework/core/locale/core.yml | 10 + .../core/src/Admin/Content/AdminPayload.php | 20 ++ .../Api/Controller/PauseQueueController.php | 54 ++++ framework/core/src/Api/routes.php | 7 + .../core/src/Queue/Console/PauseCommand.php | 61 ++++ .../core/src/Queue/Console/ResumeCommand.php | 52 ++++ framework/core/src/Queue/QueueFactory.php | 101 ++++++- .../core/src/Queue/QueueServiceProvider.php | 20 +- .../integration/api/queue/PauseQueueTest.php | 110 ++++++++ .../integration/queue/QueuePauseTest.php | 266 ++++++++++++++++++ 13 files changed, 757 insertions(+), 18 deletions(-) create mode 100644 framework/core/src/Api/Controller/PauseQueueController.php create mode 100644 framework/core/src/Queue/Console/PauseCommand.php create mode 100644 framework/core/src/Queue/Console/ResumeCommand.php create mode 100644 framework/core/tests/integration/api/queue/PauseQueueTest.php create mode 100644 framework/core/tests/integration/queue/QueuePauseTest.php diff --git a/framework/core/js/src/admin/AdminApplication.tsx b/framework/core/js/src/admin/AdminApplication.tsx index 14a65a00fc..a3159f13f4 100644 --- a/framework/core/js/src/admin/AdminApplication.tsx +++ b/framework/core/js/src/admin/AdminApplication.tsx @@ -94,6 +94,8 @@ export interface AdminApplicationData extends ApplicationData { dbOptions: Record; phpVersion: string; queueDriver: string; + pausedQueues: string[]; + knownQueues: string[]; schedulerStatus: string; sessionDriver: string; } diff --git a/framework/core/js/src/admin/components/AdvancedPage.tsx b/framework/core/js/src/admin/components/AdvancedPage.tsx index 330b2d0b91..c07c0bc10e 100644 --- a/framework/core/js/src/admin/components/AdvancedPage.tsx +++ b/framework/core/js/src/admin/components/AdvancedPage.tsx @@ -10,6 +10,7 @@ import ItemList from '../../common/utils/ItemList'; import InfoTile from '../../common/components/InfoTile'; import { MaintenanceMode } from '../../common/Application'; import Button from '../../common/components/Button'; +import Switch from '../../common/components/Switch'; import classList from '../../common/utils/classList'; import ExtensionBisect from './ExtensionBisect'; import { DatabaseDriver } from '../AdminApplication'; @@ -215,9 +216,58 @@ export default class AdvancedPage e items.add('content', this.queueCustomContent(), 100); } + if (driver !== 'sync') { + items.add('pause', this.queuePauseControl(), 110); + } + return items; } + queuePauseControl() { + const paused = app.data.pausedQueues || []; + const knownQueues = app.data.knownQueues || ['default']; + const allPaused = paused.includes('*'); + + const toggle = (queue: string, value: boolean) => + app + .request({ + method: 'POST', + url: app.forum.attribute('apiUrl') + '/queue/pause', + body: { paused: value, queue }, + }) + .then(() => { + app.data.pausedQueues = value ? [...paused, queue] : paused.filter((name) => name !== queue); + m.redraw(); + }); + + // With a single known queue, separate "all" and per-queue switches are + // redundant — offer one switch that pauses everything. + if (knownQueues.length <= 1) { + return ( +
+ toggle('*', value)}> + {app.translator.trans('core.admin.advanced.queue.pause_label')} + +

{app.translator.trans('core.admin.advanced.queue.pause_help')}

+
+ ); + } + + return ( +
+ toggle('*', value)}> + {app.translator.trans('core.admin.advanced.queue.pause_all_label')} + + {knownQueues.map((queue) => ( + toggle(queue, value)}> + {app.translator.trans('core.admin.advanced.queue.pause_queue_label', { queue })} + + ))} +

{app.translator.trans('core.admin.advanced.queue.pause_help')}

+
+ ); + } + queueSyncContent() { return ( diff --git a/framework/core/js/src/admin/components/DashboardPage.tsx b/framework/core/js/src/admin/components/DashboardPage.tsx index 8889803bbf..cca0cc1e33 100644 --- a/framework/core/js/src/admin/components/DashboardPage.tsx +++ b/framework/core/js/src/admin/components/DashboardPage.tsx @@ -46,6 +46,28 @@ export default class DashboardPage extends AdminPage { ); } + if (app.data.pausedQueues?.length) { + items.add( + 'queuePaused', + + {app.translator.trans('core.admin.dashboard.queue_paused_manage')} + , + ], + }} + > + {app.data.pausedQueues.includes('*') + ? app.translator.trans('core.admin.dashboard.queue_paused_all_warning') + : app.translator.trans('core.admin.dashboard.queue_paused_warning', { queues: app.data.pausedQueues.join(', ') })} + , + 105 + ); + } + if (app.data.maintenanceMode) { items.add( 'maintenanceMode', diff --git a/framework/core/locale/core.yml b/framework/core/locale/core.yml index e366cb4a10..5376a92754 100644 --- a/framework/core/locale/core.yml +++ b/framework/core/locale/core.yml @@ -69,6 +69,13 @@ core: pgsql: search_configuration: Search configuration to use queue: + pause_label: Pause queue processing + pause_all_label: Pause all queues + pause_queue_label: "Pause the {queue} queue" + pause_help: >- + While paused, workers stop picking up new jobs from the affected + queues — jobs accumulate until processing is resumed. Jobs that are + already running are not affected. section_label: Queue sync_info: Your forum is using the synchronous queue driver. Jobs are processed immediately in the main process thread. sync_help: For better performance and user experience, consider using the database queue driver. Learn more in the documentation. @@ -194,6 +201,9 @@ core: # These translations are used in the Dashboard page. dashboard: + queue_paused_warning: "Queue processing is paused (queue: {queues}). Jobs will accumulate until it is resumed." + queue_paused_all_warning: All queue processing is paused. Jobs will accumulate until it is resumed. + queue_paused_manage: Manage clear_cache_button: Clear Cache description: Your forum at a glance. info_button: System Info diff --git a/framework/core/src/Admin/Content/AdminPayload.php b/framework/core/src/Admin/Content/AdminPayload.php index 0e8a7fb512..658d9b87b4 100644 --- a/framework/core/src/Admin/Content/AdminPayload.php +++ b/framework/core/src/Admin/Content/AdminPayload.php @@ -30,6 +30,24 @@ class AdminPayload { + /** + * The names of the queues currently paused on the active connection — + * powers the dashboard warning and the Advanced page toggle, regardless + * of the queue backend in use. + * + * @return string[] + */ + protected function pausedQueues(): array + { + /** @var \Flarum\Queue\QueueFactory $factory */ + $factory = $this->container->make(\Illuminate\Contracts\Queue\Factory::class); + + /** @var \Illuminate\Contracts\Queue\Queue $queue */ + $queue = $this->container->make(\Illuminate\Contracts\Queue\Queue::class); + + return $factory->pausedQueues((string) $queue->getConnectionName()); + } + public function __construct( protected Container $container, protected SettingsRepositoryInterface $settings, @@ -84,6 +102,8 @@ public function __invoke(Document $document, Request $request): void $document->payload['schedulerStatus'] = $this->appInfo->getSchedulerStatus(); $document->payload['queueDriver'] = $this->appInfo->identifyQueueDriver(); + $document->payload['pausedQueues'] = $this->pausedQueues(); + $document->payload['knownQueues'] = $this->container->make('flarum.queue.queues'); $document->payload['sessionDriver'] = $this->appInfo->identifySessionDriver(true); /** diff --git a/framework/core/src/Api/Controller/PauseQueueController.php b/framework/core/src/Api/Controller/PauseQueueController.php new file mode 100644 index 0000000000..2c8030ad60 --- /dev/null +++ b/framework/core/src/Api/Controller/PauseQueueController.php @@ -0,0 +1,54 @@ +assertAdmin(); + + // Sync jobs execute inline and never pass through the worker, so + // there is nothing to pause. + if ($this->queue instanceof SyncQueue) { + return new EmptyResponse(409); + } + + $body = $request->getParsedBody(); + $paused = (bool) Arr::get($body, 'paused', true); + $queue = (string) Arr::get($body, 'queue', 'default'); + + $connection = $this->queue->getConnectionName(); + + if ($paused) { + $this->factory->pause($connection, $queue); + } else { + $this->factory->resume($connection, $queue); + } + + return new EmptyResponse(204); + } +} diff --git a/framework/core/src/Api/routes.php b/framework/core/src/Api/routes.php index 8b6406763b..9ccc79ad53 100644 --- a/framework/core/src/Api/routes.php +++ b/framework/core/src/Api/routes.php @@ -190,6 +190,13 @@ $route->toController(Controller\ClearCacheController::class) ); + // Pause or resume queue processing + $map->post( + '/queue/pause', + 'queue.pause', + $route->toController(Controller\PauseQueueController::class) + ); + // List available mail drivers, available fields and validation status $map->get( '/mail/settings', diff --git a/framework/core/src/Queue/Console/PauseCommand.php b/framework/core/src/Queue/Console/PauseCommand.php new file mode 100644 index 0000000000..eec4bfc471 --- /dev/null +++ b/framework/core/src/Queue/Console/PauseCommand.php @@ -0,0 +1,61 @@ +components->error('Queue pausing is currently disabled.'); + + return 1; + } + + [$connectionName, $queue] = $this->option('all') + ? [$connection->getConnectionName(), '*'] + : $this->parseQueue($this->argument('queue'), $connection); + + $factory->pause($connectionName, $queue); + + $this->components->info("Job processing on queue [{$connectionName}:{$queue}] has been paused."); + + return 0; + } + + /** + * @return array{string, string} + */ + protected function parseQueue(string $argument, Queue $connection): array + { + if (str_contains($argument, ':')) { + return explode(':', $argument, 2); + } + + return [$connection->getConnectionName(), $argument]; + } +} diff --git a/framework/core/src/Queue/Console/ResumeCommand.php b/framework/core/src/Queue/Console/ResumeCommand.php new file mode 100644 index 0000000000..562a4e4a69 --- /dev/null +++ b/framework/core/src/Queue/Console/ResumeCommand.php @@ -0,0 +1,52 @@ +option('all') + ? [$connection->getConnectionName(), '*'] + : $this->parseQueue($this->argument('queue'), $connection); + + $factory->resume($connectionName, $queue); + + $this->components->info("Job processing on queue [{$connectionName}:{$queue}] has been resumed."); + + return 0; + } + + /** + * @return array{string, string} + */ + protected function parseQueue(string $argument, Queue $connection): array + { + if (str_contains($argument, ':')) { + return explode(':', $argument, 2); + } + + return [$connection->getConnectionName(), $argument]; + } +} diff --git a/framework/core/src/Queue/QueueFactory.php b/framework/core/src/Queue/QueueFactory.php index e9ee35553a..f0c5945198 100644 --- a/framework/core/src/Queue/QueueFactory.php +++ b/framework/core/src/Queue/QueueFactory.php @@ -10,8 +10,13 @@ namespace Flarum\Queue; use Closure; +use Illuminate\Contracts\Cache\Repository as Cache; +use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Contracts\Queue\Factory; use Illuminate\Contracts\Queue\Queue; +use Illuminate\Queue\Events\QueuePaused; +use Illuminate\Queue\Events\QueueResumed; +use Illuminate\Queue\Worker; class QueueFactory implements Factory { @@ -25,7 +30,9 @@ class QueueFactory implements Factory * once requested by the application. */ public function __construct( - private readonly Closure $factory + private readonly Closure $factory, + private readonly ?Cache $cache = null, + private readonly ?Dispatcher $events = null, ) { } @@ -47,15 +54,12 @@ public function connection($name = null) /* * Flarum's simplified queue factory stands in for Illuminate's full * QueueManager. The methods below are the manager-level surface of the - * Queue Pause/Resume feature, which the queue Worker and console commands - * may call. Flarum does not support queue pausing *yet* — it is planned for - * a future Flarum version — so for now they are no-ops. Stubbing the whole - * family also means a new Illuminate release wiring up another pause method - * can't crash the worker with a "Call to undefined method" (as happened - * with isPaused() and, later, getPausedQueues()). Signatures mirror + * Queue Pause/Resume feature, which the queue Worker consults when + * popping jobs. The pause state lives in the shared cache store using + * Illuminate's own key format, so the signal reaches every worker + * process — including ones in other containers, and Horizon workers, + * which resolve this same factory. Signatures mirror * Illuminate\Queue\QueueManager. - * - * TODO: implement real queue pausing in a future Flarum version. */ /** @@ -67,7 +71,14 @@ public function connection($name = null) */ public function isPaused($connection, $queue): bool { - return false; + if ($this->cache?->get("illuminate:queue:paused:{$connection}:{$queue}", false)) { + return true; + } + + // A wildcard pause covers every queue on the connection — including + // names core cannot enumerate — so "pause all" works regardless of + // the queue backend in use. + return $queue !== '*' && (bool) $this->cache?->get("illuminate:queue:paused:{$connection}:*", false); } /** @@ -79,7 +90,10 @@ public function isPaused($connection, $queue): bool */ public function getPausedQueues($connection, $queues): array { - return []; + return array_values(array_filter( + $queues, + fn ($queue) => $this->isPaused($connection, $queue) + )); } /** @@ -91,7 +105,11 @@ public function getPausedQueues($connection, $queues): array */ public function pause($connection, $queue): void { - // No-op for now: queue pausing is planned for a future Flarum version. + $this->cache?->forever("illuminate:queue:paused:{$connection}:{$queue}", true); + + $this->trackPausedQueue($connection, $queue); + + $this->events?->dispatch(new QueuePaused($connection, $queue)); } /** @@ -104,7 +122,11 @@ public function pause($connection, $queue): void */ public function pauseFor($connection, $queue, $ttl): void { - // No-op for now: queue pausing is planned for a future Flarum version. + $this->cache?->put("illuminate:queue:paused:{$connection}:{$queue}", true, $ttl); + + $this->trackPausedQueue($connection, $queue); + + $this->events?->dispatch(new QueuePaused($connection, $queue, $ttl)); } /** @@ -116,7 +138,55 @@ public function pauseFor($connection, $queue, $ttl): void */ public function resume($connection, $queue): void { - // No-op for now: queue pausing is planned for a future Flarum version. + $this->cache?->forget("illuminate:queue:paused:{$connection}:{$queue}"); + + $this->untrackPausedQueue($connection, $queue); + + $this->events?->dispatch(new QueueResumed($connection, $queue)); + } + + /** + * The names of the queues currently paused on the given connection. + * + * This is a Flarum addition over Illuminate's surface, powering the admin + * dashboard warning. Pauses are tracked when made through this factory; + * entries whose underlying pause has expired (pauseFor) or was cleared + * externally are filtered out. + * + * @return string[] + */ + public function pausedQueues(string $connection): array + { + $tracked = (array) ($this->cache?->get("flarum:queue:paused-list:{$connection}") ?? []); + + $paused = $this->getPausedQueues($connection, $tracked); + + if ($paused !== $tracked) { + $this->cache?->forever("flarum:queue:paused-list:{$connection}", $paused); + } + + return $paused; + } + + protected function trackPausedQueue(string $connection, string $queue): void + { + $tracked = (array) ($this->cache?->get("flarum:queue:paused-list:{$connection}") ?? []); + + if (! in_array($queue, $tracked, true)) { + $tracked[] = $queue; + $this->cache?->forever("flarum:queue:paused-list:{$connection}", $tracked); + } + } + + protected function untrackPausedQueue(string $connection, string $queue): void + { + $tracked = (array) ($this->cache?->get("flarum:queue:paused-list:{$connection}") ?? []); + + $remaining = array_values(array_diff($tracked, [$queue])); + + if ($remaining !== $tracked) { + $this->cache?->forever("flarum:queue:paused-list:{$connection}", $remaining); + } } /** @@ -126,6 +196,7 @@ public function resume($connection, $queue): void */ public function withoutInterruptionPolling(): void { - // No-op for now: queue pausing is planned for a future Flarum version. + Worker::$restartable = false; + Worker::$pausable = false; } } diff --git a/framework/core/src/Queue/QueueServiceProvider.php b/framework/core/src/Queue/QueueServiceProvider.php index 87a0d78125..8ff83e084e 100644 --- a/framework/core/src/Queue/QueueServiceProvider.php +++ b/framework/core/src/Queue/QueueServiceProvider.php @@ -38,6 +38,8 @@ class QueueServiceProvider extends AbstractServiceProvider Commands\ForgetFailedCommand::class, Console\ListenCommand::class, Commands\ListFailedCommand::class, + Console\PauseCommand::class, + Console\ResumeCommand::class, Commands\RestartCommand::class, Commands\RetryCommand::class, Console\WorkCommand::class, @@ -47,10 +49,21 @@ public function register(): void { // Register a simple connection factory that always returns the same // connection, as that is enough for our purposes. + // The queue names known to this installation. Extensions that route + // jobs onto named queues (e.g. via AbstractJob::$onQueue) should + // append theirs so admin tooling can offer per-queue controls. + $this->container->singleton('flarum.queue.queues', function () { + return ['default']; + }); + $this->container->singleton(Factory::class, function (Container $container) { - return new QueueFactory(function () use ($container) { - return $container->make('flarum.queue.connection'); - }); + return new QueueFactory( + function () use ($container) { + return $container->make('flarum.queue.connection'); + }, + $container->make('cache.store'), + $container->make('events') + ); }); // Extensions can override this binding if they want to make Flarum use @@ -159,6 +172,7 @@ public function __call(string $name, ?array $arguments): mixed $this->container->alias(ConnectorInterface::class, 'queue.connection'); $this->container->alias(Factory::class, 'queue'); + $this->container->alias(Factory::class, QueueFactory::class); $this->container->alias(Worker::class, 'queue.worker'); $this->container->alias(Listener::class, 'queue.listener'); diff --git a/framework/core/tests/integration/api/queue/PauseQueueTest.php b/framework/core/tests/integration/api/queue/PauseQueueTest.php new file mode 100644 index 0000000000..46a441946c --- /dev/null +++ b/framework/core/tests/integration/api/queue/PauseQueueTest.php @@ -0,0 +1,110 @@ +config('queue', ['driver' => 'database']); + + $this->prepareDatabase([ + User::class => [$this->normalUser()], + ]); + } + + private function factory(): Factory + { + return $this->app()->getContainer()->make(Factory::class); + } + + #[Test] + public function admins_can_pause_and_resume_the_queue() + { + $response = $this->send( + $this->request('POST', '/api/queue/pause', [ + 'authenticatedAs' => 1, + 'json' => ['paused' => true], + ]) + ); + + $this->assertEquals(204, $response->getStatusCode()); + $this->assertTrue($this->factory()->isPaused('flarum', 'default')); + + $response = $this->send( + $this->request('POST', '/api/queue/pause', [ + 'authenticatedAs' => 1, + 'json' => ['paused' => false], + ]) + ); + + $this->assertEquals(204, $response->getStatusCode()); + $this->assertFalse($this->factory()->isPaused('flarum', 'default')); + } + + #[Test] + public function a_specific_queue_can_be_paused() + { + $response = $this->send( + $this->request('POST', '/api/queue/pause', [ + 'authenticatedAs' => 1, + 'json' => ['paused' => true, 'queue' => 'media'], + ]) + ); + + $this->assertEquals(204, $response->getStatusCode()); + $this->assertTrue($this->factory()->isPaused('flarum', 'media')); + $this->assertFalse($this->factory()->isPaused('flarum', 'default')); + + $this->factory()->resume('flarum', 'media'); + } + + #[Test] + public function the_sync_driver_cannot_be_paused() + { + // Fresh app without the database driver config: sync jobs execute + // inline and never pass through the worker, so pausing is meaningless + // and the endpoint must refuse rather than pretend. + $this->config('queue', []); + + $response = $this->send( + $this->request('POST', '/api/queue/pause', [ + 'authenticatedAs' => 1, + 'json' => ['paused' => true], + ]) + ); + + $this->assertEquals(409, $response->getStatusCode()); + } + + #[Test] + public function regular_users_cannot_pause_the_queue() + { + $response = $this->send( + $this->request('POST', '/api/queue/pause', [ + 'authenticatedAs' => 2, + 'json' => ['paused' => true], + ]) + ); + + $this->assertEquals(403, $response->getStatusCode()); + $this->assertFalse($this->factory()->isPaused('flarum', 'default')); + } +} diff --git a/framework/core/tests/integration/queue/QueuePauseTest.php b/framework/core/tests/integration/queue/QueuePauseTest.php new file mode 100644 index 0000000000..a96245260d --- /dev/null +++ b/framework/core/tests/integration/queue/QueuePauseTest.php @@ -0,0 +1,266 @@ +config('queue', ['driver' => 'database']); + } + + private function factory(): QueueFactory + { + $factory = $this->app()->getContainer()->make(Factory::class); + + $this->assertInstanceOf(QueueFactory::class, $factory); + + return $factory; + } + + #[Test] + public function pausing_marks_the_queue_paused_in_the_shared_cache() + { + $factory = $this->factory(); + + $this->assertFalse($factory->isPaused('flarum', 'default')); + + $factory->pause('flarum', 'default'); + + $this->assertTrue($factory->isPaused('flarum', 'default')); + + // The exact upstream key format, for interoperability with anything + // built against Illuminate's own convention (e.g. Horizon tooling). + $this->assertTrue( + (bool) $this->app()->getContainer()->make('cache.store')->get('illuminate:queue:paused:flarum:default') + ); + + $factory->resume('flarum', 'default'); + } + + #[Test] + public function resuming_clears_the_pause() + { + $factory = $this->factory(); + + $factory->pause('flarum', 'default'); + $factory->resume('flarum', 'default'); + + $this->assertFalse($factory->isPaused('flarum', 'default')); + } + + #[Test] + public function paused_queues_are_filtered_from_a_queue_list() + { + $factory = $this->factory(); + + $factory->pause('flarum', 'media'); + + $this->assertSame(['media'], $factory->getPausedQueues('flarum', ['default', 'media'])); + + $factory->resume('flarum', 'media'); + } + + #[Test] + public function a_worker_does_not_pop_jobs_from_a_paused_queue() + { + $container = $this->app()->getContainer(); + $db = $container->make('db.connection'); + + PauseProbeJob::$ran = false; + $container->make(Queue::class)->push(new PauseProbeJob()); + + $this->assertSame(1, $db->table('queue_jobs')->count()); + + $factory = $this->factory(); + $factory->pause('flarum', 'default'); + + $worker = $container->make(Worker::class); + $worker->runNextJob('flarum', 'default', new WorkerOptions(sleep: 0)); + + $this->assertFalse(PauseProbeJob::$ran, 'A paused queue must not have jobs popped from it.'); + $this->assertSame(1, $db->table('queue_jobs')->count(), 'The job must remain queued while paused.'); + + $factory->resume('flarum', 'default'); + $worker->runNextJob('flarum', 'default', new WorkerOptions(sleep: 0)); + + $this->assertTrue(PauseProbeJob::$ran, 'After resuming, the job must be processed.'); + $this->assertSame(0, $db->table('queue_jobs')->count()); + } + + #[Test] + public function pause_and_resume_dispatch_events() + { + $container = $this->app()->getContainer(); + $events = $container->make('events'); + + $seen = []; + $events->listen(QueuePaused::class, function () use (&$seen) { + $seen[] = 'paused'; + }); + $events->listen(QueueResumed::class, function () use (&$seen) { + $seen[] = 'resumed'; + }); + + $factory = $this->factory(); + $factory->pause('flarum', 'default'); + $factory->resume('flarum', 'default'); + + $this->assertSame(['paused', 'resumed'], $seen); + } + + #[Test] + public function the_pause_and_resume_commands_toggle_the_state() + { + $this->runCommand(['command' => 'queue:pause', 'queue' => 'default']); + + $this->assertTrue($this->factory()->isPaused('flarum', 'default')); + + $this->runCommand(['command' => 'queue:resume', 'queue' => 'default']); + + $this->assertFalse($this->factory()->isPaused('flarum', 'default')); + } + + #[Test] + public function paused_queues_are_tracked_per_connection_for_the_dashboard() + { + $factory = $this->factory(); + + $this->assertSame([], $factory->pausedQueues('flarum')); + + $factory->pause('flarum', 'default'); + $factory->pause('flarum', 'media'); + + $this->assertSame(['default', 'media'], $factory->pausedQueues('flarum')); + + $factory->resume('flarum', 'media'); + + $this->assertSame(['default'], $factory->pausedQueues('flarum')); + + $factory->resume('flarum', 'default'); + + $this->assertSame([], $factory->pausedQueues('flarum')); + } + + #[Test] + public function the_paused_queue_list_self_heals_when_a_pause_expires() + { + $factory = $this->factory(); + + $factory->pause('flarum', 'default'); + + // Simulate a pauseFor() TTL expiring (or an external resume through + // the raw Illuminate cache key): the list must not report queues + // that are no longer actually paused. + $this->app()->getContainer()->make('cache.store')->forget('illuminate:queue:paused:flarum:default'); + + $this->assertSame([], $factory->pausedQueues('flarum')); + } + + #[Test] + public function the_commands_default_to_the_default_queue() + { + $this->runCommand(['command' => 'queue:pause']); + + $this->assertTrue($this->factory()->isPaused('flarum', 'default')); + + $this->runCommand(['command' => 'queue:resume']); + + $this->assertFalse($this->factory()->isPaused('flarum', 'default')); + } + + #[Test] + public function pausing_all_queues_pauses_any_queue_name() + { + $factory = $this->factory(); + + $factory->pause('flarum', '*'); + + // Every queue name — including ones core cannot enumerate — reads as + // paused, so workers stop popping regardless of what they consume. + $this->assertTrue($factory->isPaused('flarum', 'default')); + $this->assertTrue($factory->isPaused('flarum', 'media')); + $this->assertSame(['default', 'media'], $factory->getPausedQueues('flarum', ['default', 'media'])); + $this->assertSame(['*'], $factory->pausedQueues('flarum')); + + $factory->resume('flarum', '*'); + + $this->assertFalse($factory->isPaused('flarum', 'default')); + } + + #[Test] + public function resuming_all_does_not_clear_individual_pauses() + { + $factory = $this->factory(); + + $factory->pause('flarum', 'media'); + $factory->pause('flarum', '*'); + $factory->resume('flarum', '*'); + + // The layers are orthogonal: an individually paused queue stays + // paused after a resume-all. + $this->assertTrue($factory->isPaused('flarum', 'media')); + $this->assertFalse($factory->isPaused('flarum', 'default')); + + $factory->resume('flarum', 'media'); + } + + #[Test] + public function the_commands_support_pausing_and_resuming_all_queues() + { + $this->runCommand(['command' => 'queue:pause', '--all' => true]); + + $this->assertTrue($this->factory()->isPaused('flarum', 'anything')); + + $this->runCommand(['command' => 'queue:resume', '--all' => true]); + + $this->assertFalse($this->factory()->isPaused('flarum', 'anything')); + } + + #[Test] + public function the_known_queue_registry_defaults_to_the_default_queue_and_is_extendable() + { + $container = $this->app()->getContainer(); + + $this->assertSame(['default'], $container->make('flarum.queue.queues')); + } +} + +class PauseProbeJob extends AbstractJob +{ + public static bool $ran = false; + + public function handle(): void + { + static::$ran = true; + } +}