Repository: symfony/symfony
Pull request #59978: [Messenger] Add `--class-filter` option to the `messenger:failed:remove` command
Author: login: arnaud-deabreu
Labels: name: Feature
name: Status: Reviewed
name: Messenger
Created: 2025-03-14T16:42:49Z | Merged: 2025-03-24T08:02:30Z

| Q             | A
| ------------- | ---
| Branch?       | 7.3 
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no
| Issues        | -
| License       | MIT

This PR adds the `--class-filter` to the `messenger:failed:remove` command which is already available on `messenger:failed:show` command.  
It will yield the message ids that match the given class and use it as if it were passed as the `id` argument in the command so the `--force` and `--show-messages` can work with this filter.
When using the filter, the command will prompt for confirmation before removing the messages.

Example output:

```
$ bin/console messenger:failed:remove --class-filter="App\Message\MyMessage"

 There is 16 messages to remove. Do you want to continue? (yes/no) [yes]:
```

<!--
Replace this notice by a description of your feature/bugfix.
This will help reviewers and should be a good start for the documentation.

Additionally (see https://symfony.com/releases):
 - Always add tests and ensure they pass.
 - Bug fixes must be submitted against the lowest maintained branch where they apply
   (lowest branches are regularly merged to upper ones so they get the fixes too).
 - Features and deprecations must be submitted against the latest branch.
 - For new features, provide some code snippets to help understand usage.
 - Changelog entry should follow https://symfony.com/doc/current/contributing/code/conventions.html#writing-a-changelog-entry
 - Never break backward compatibility (see https://symfony.com/bc).
-->



Diff:
diff --git a/src/Symfony/Component/Messenger/CHANGELOG.md b/src/Symfony/Component/Messenger/CHANGELOG.md
index b7973518fe41d..21fef52a1edd6 100644
--- a/src/Symfony/Component/Messenger/CHANGELOG.md
+++ b/src/Symfony/Component/Messenger/CHANGELOG.md
@@ -7,6 +7,7 @@ CHANGELOG
  * Add `CloseableTransportInterface` to allow closing the transport
  * Add `SentForRetryStamp` that identifies whether a failed message was sent for retry
  * Add `Symfony\Component\Messenger\Middleware\DeduplicateMiddleware` and `Symfony\Component\Messenger\Stamp\DeduplicateStamp`
+ * Add `--class-filter` option to the `messenger:failed:remove` command
 
 7.2
 ---
diff --git a/src/Symfony/Component/Messenger/Command/FailedMessagesRemoveCommand.php b/src/Symfony/Component/Messenger/Command/FailedMessagesRemoveCommand.php
index 4c8d44e9b72fb..de2b6f3f14d12 100644
--- a/src/Symfony/Component/Messenger/Command/FailedMessagesRemoveCommand.php
+++ b/src/Symfony/Component/Messenger/Command/FailedMessagesRemoveCommand.php
@@ -37,6 +37,7 @@ protected function configure(): void
                 new InputOption('force', null, InputOption::VALUE_NONE, 'Force the operation without confirmation'),
                 new InputOption('transport', null, InputOption::VALUE_OPTIONAL, 'Use a specific failure transport', self::DEFAULT_TRANSPORT_OPTION),
                 new InputOption('show-messages', null, InputOption::VALUE_NONE, 'Display messages before removing it (if multiple ids are given)'),
+                new InputOption('class-filter', null, InputOption::VALUE_REQUIRED, 'Filter by a specific class name'),
             ])
             ->setHelp(<<<'EOF'
 The <info>%command.name%</info> removes given messages that are pending in the failure transport.
@@ -69,6 +70,24 @@ protected function execute(InputInterface $input, OutputInterface $output): int
         $shouldDeleteAllMessages = $input->getOption('all');
 
         $idsCount = \count($ids);
+
+        if (!$receiver instanceof ListableReceiverInterface) {
+            throw new RuntimeException(\sprintf('The "%s" receiver does not support removing specific messages.', $failureTransportName));
+        }
+
+        if (!$idsCount && null !== $input->getOption('class-filter')) {
+            $ids = $this->getMessageIdsByClassFilter($input->getOption('class-filter'), $receiver);
+            $idsCount = \count($ids);
+
+            if (!$idsCount) {
+                throw new RuntimeException('No failed messages were found with this filter.');
+            }
+
+            if (!$io->confirm(\sprintf('Can you confirm you want to remove %d message%s?', $idsCount, 1 === $idsCount ? '' : 's'))) {
+                return 0;
+            }
+        }
+
         if (!$shouldDeleteAllMessages && !$idsCount) {
             throw new RuntimeException('Please specify at least one message id. If you want to remove all failed messages, use the "--all" option.');
         } elseif ($shouldDeleteAllMessages && $idsCount) {
@@ -77,10 +96,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int
 
         $shouldDisplayMessages = $input->getOption('show-messages') || 1 === $idsCount;
 
-        if (!$receiver instanceof ListableReceiverInterface) {
-            throw new RuntimeException(\sprintf('The "%s" receiver does not support removing specific messages.', $failureTransportName));
-        }
-
         if ($shouldDeleteAllMessages) {
             $this->removeAllMessages($receiver, $io, $shouldForce, $shouldDisplayMessages);
         } else {
@@ -119,6 +134,26 @@ private function removeMessagesById(array $ids, ListableReceiverInterface $recei
         }
     }
 
+    private function getMessageIdsByClassFilter(string $classFilter, ListableReceiverInterface $receiver): array
+    {
+        $ids = [];
+
+        $this->phpSerializer?->acceptPhpIncompleteClass();
+        try {
+            foreach ($receiver->all() as $envelope) {
+                if ($classFilter !== $envelope->getMessage()::class) {
+                    continue;
+                }
+
+                $ids[] = $this->getMessageId($envelope);
+            };
+        } finally {
+            $this->phpSerializer?->rejectPhpIncompleteClass();
+        }
+
+        return $ids;
+    }
+
     private function removeAllMessages(ListableReceiverInterface $receiver, SymfonyStyle $io, bool $shouldForce, bool $shouldDisplayMessages): void
     {
         if (!$shouldForce) {
diff --git a/src/Symfony/Component/Messenger/Tests/Command/FailedMessagesRemoveCommandTest.php b/src/Symfony/Component/Messenger/Tests/Command/FailedMessagesRemoveCommandTest.php
index bb8365d351637..64812a74aa91f 100644
--- a/src/Symfony/Component/Messenger/Tests/Command/FailedMessagesRemoveCommandTest.php
+++ b/src/Symfony/Component/Messenger/Tests/Command/FailedMessagesRemoveCommandTest.php
@@ -182,6 +182,53 @@ public function testRemoveMultipleMessagesAndDisplayMessagesWithServiceLocator()
         $this->assertStringContainsString('Message with id 30 removed.', $tester->getDisplay());
     }
 
+    public function testRemoveMessagesFilteredByClassMessage()
+    {
+        $globalFailureReceiverName = 'failure_receiver';
+        $receiver = $this->createMock(ListableReceiverInterface::class);
+
+        $anotherClass = new class extends \stdClass {};
+
+        $series = [
+            new Envelope(new \stdClass(), [new TransportMessageIdStamp(10)]),
+            new Envelope(new $anotherClass(), [new TransportMessageIdStamp(20)]),
+            new Envelope(new \stdClass(), [new TransportMessageIdStamp(30)]),
+        ];
+        $receiver->expects($this->once())->method('all')->willReturn($series);
+
+        $expectedRemovedIds = [10, 30];
+        $receiver->expects($this->exactly(2))->method('find')
+            ->willReturnCallback(function (...$args) use ($series, &$expectedRemovedIds) {
+                $expectedArgs = array_shift($expectedRemovedIds);
+                $this->assertSame([$expectedArgs], $args);
+
+                $return = array_filter(
+                    $series,
+                    static fn (Envelope $envelope) => [$envelope->last(TransportMessageIdStamp::class)->getId()] === $args,
+                );
+
+                return current($return);
+            })
+        ;
+
+        $serviceLocator = $this->createMock(ServiceLocator::class);
+        $serviceLocator->expects($this->once())->method('has')->with($globalFailureReceiverName)->willReturn(true);
+        $serviceLocator->expects($this->any())->method('get')->with($globalFailureReceiverName)->willReturn($receiver);
+
+        $command = new FailedMessagesRemoveCommand(
+            $globalFailureReceiverName,
+            $serviceLocator
+        );
+
+        $tester = new CommandTester($command);
+        $tester->execute(['--class-filter' => "stdClass", '--force' => true, '--show-messages' => true]);
+
+        $this->assertStringContainsString('There is 2 messages to remove. Do you want to continue? (yes/no)', $tester->getDisplay());
+        $this->assertStringContainsString('Failed Message Details', $tester->getDisplay());
+        $this->assertStringContainsString('Message with id 10 removed.', $tester->getDisplay());
+        $this->assertStringContainsString('Message with id 30 removed.', $tester->getDisplay());
+    }
+
     public function testCompletingTransport()
     {
         $globalFailureReceiverName = 'failure_receiver';


Reviews:
user: login: chalasr
body: Looks good 👍 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-23T18:00:04Z
user: login: chalasr
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-24T00:06:01Z
user: login: arnaud-deabreu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T07:33:12Z
user: login: fabpot
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-24T08:01:03Z

Review comments:
diff_hunk: @@ -69,6 +70,24 @@ protected function execute(InputInterface $input, OutputInterface $output): int
         $shouldDeleteAllMessages = $input->getOption('all');
 
         $idsCount = \count($ids);
+
+        if (!$receiver instanceof ListableReceiverInterface) {
+            throw new RuntimeException(\sprintf('The "%s" receiver does not support removing specific messages.', $failureTransportName));
+        }
+
+        if (!$idsCount && null !== $input->getOption('class-filter')) {
+            $ids = $this->getMessageIdsByClassFilter($input->getOption('class-filter'), $receiver);
+            $idsCount = \count($ids);
+
+            if (!$idsCount) {
+                throw new RuntimeException('No failed messages were found with this filter.');
+            }
+
+            if (!$io->confirm(\sprintf('There is %d messages to remove. Do you want to continue?', $idsCount))) {
path: src/Symfony/Component/Messenger/Command/FailedMessagesRemoveCommand.php
user: login: chalasr
body: The message should be `There is 1 message` when there is only one and `There are %d messages` when there are more
created_at: 2025-03-24T00:06:01Z
author_association: MEMBER
diff_hunk: @@ -69,6 +70,24 @@ protected function execute(InputInterface $input, OutputInterface $output): int
         $shouldDeleteAllMessages = $input->getOption('all');
 
         $idsCount = \count($ids);
+
+        if (!$receiver instanceof ListableReceiverInterface) {
+            throw new RuntimeException(\sprintf('The "%s" receiver does not support removing specific messages.', $failureTransportName));
+        }
+
+        if (!$idsCount && null !== $input->getOption('class-filter')) {
+            $ids = $this->getMessageIdsByClassFilter($input->getOption('class-filter'), $receiver);
+            $idsCount = \count($ids);
+
+            if (!$idsCount) {
+                throw new RuntimeException('No failed messages were found with this filter.');
+            }
+
+            if (!$io->confirm(\sprintf('There is %d messages to remove. Do you want to continue?', $idsCount))) {
path: src/Symfony/Component/Messenger/Command/FailedMessagesRemoveCommand.php
user: login: arnaud-deabreu
body: Thanks for reviewing this!  
I made the changes but there is now a lot of tests failing even after rebasing. Looks not related, anyway but don't hesitate if something is wrong
created_at: 2025-03-24T07:33:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -69,6 +70,24 @@ protected function execute(InputInterface $input, OutputInterface $output): int
         $shouldDeleteAllMessages = $input->getOption('all');
 
         $idsCount = \count($ids);
+
+        if (!$receiver instanceof ListableReceiverInterface) {
+            throw new RuntimeException(\sprintf('The "%s" receiver does not support removing specific messages.', $failureTransportName));
+        }
+
+        if (!$idsCount && null !== $input->getOption('class-filter')) {
+            $ids = $this->getMessageIdsByClassFilter($input->getOption('class-filter'), $receiver);
+            $idsCount = \count($ids);
+
+            if (!$idsCount) {
+                throw new RuntimeException('No failed messages were found with this filter.');
+            }
+
+            if (!$io->confirm(\sprintf('There is %d message%s to remove. Do you want to continue?', $idsCount, $idsCount > 1 ? 's' : ''))) {
path: src/Symfony/Component/Messenger/Command/FailedMessagesRemoveCommand.php
user: login: fabpot
body: To avoid using `is` or `are` depending on the number of messages, let's rewrite it to something like:

```suggestion
            if (!$io->confirm(\sprintf('Can you confirm you want to remove %d message%s?', $idsCount, 1 === $idsCount ? '' : 's'))) {
```
created_at: 2025-03-24T08:00:54Z
author_association: MEMBER

PR comments:
user: login: OskarStark
created_at: 2025-03-23T23:04:44Z
body: class-filter sounds to much technical to me. What about message-filter ?
author_association: CONTRIBUTOR
user: login: chalasr
created_at: 2025-03-24T00:10:52Z
body: @OskarStark `message-filter` seems too broad to me. Also `class-filter` is inline with what exists in `FailedMessagesShowCommand`
author_association: MEMBER
user: login: fabpot
created_at: 2025-03-24T08:02:26Z
body: Thank you @arnaud-deabreu.
author_association: MEMBER