feat: selective retry of failed lead email jobs (#286)

## Summary

- Adds `leads:retry-failed-jobs` artisan command to selectively retry
failed `emails`-queue jobs after a Resend rate-limit incident
- Retries jobs for verified leads and `VerifyUserLeadEmailNotification`
for unverified leads; forgets jobs for deleted leads (DDoS cleanup) or
unverified leads receiving waitlist emails
- Adds `deleteWhenMissingModels = true` to all lead mail/notification
classes so future jobs for deleted leads are silently discarded instead
of failing

## Usage

```bash
# Preview (no changes)
php artisan leads:retry-failed-jobs --dry-run

# Execute
php artisan leads:retry-failed-jobs
```

## Test plan

- [x] `leads:retry-failed-jobs` forgets jobs for deleted leads
- [x] `leads:retry-failed-jobs` retries jobs for verified leads
- [x] `leads:retry-failed-jobs` forgets waitlist jobs for unverified
leads
- [x] `leads:retry-failed-jobs` retries
`VerifyUserLeadEmailNotification` for unverified leads
- [x] `leads:retry-failed-jobs` handles mixed job types correctly
- [x] `--dry-run` does not modify `failed_jobs`
This commit is contained in:
Víctor Falcón 2026-04-15 08:00:29 +01:00 committed by GitHub
parent d0aab3d11b
commit f408dbe4c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 323 additions and 0 deletions

View File

@ -0,0 +1,134 @@
<?php
namespace App\Console\Commands;
use App\Models\UserLead;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
#[Signature('leads:retry-failed-jobs {--dry-run : Show what would happen without making changes}')]
#[Description('Selectively retry failed lead email jobs, forgetting stale ones whose lead was deleted or is not yet verified')]
class RetryLeadFailedJobsCommand extends Command
{
public function handle(): int
{
$isDryRun = $this->option('dry-run');
if ($isDryRun) {
$this->warn('DRY RUN — no changes will be made.');
$this->newLine();
}
$failedJobs = DB::table('failed_jobs')
->where('queue', 'emails')
->get();
if ($failedJobs->isEmpty()) {
$this->info('No failed email jobs found.');
return self::SUCCESS;
}
$retried = 0;
$forgotten = 0;
$skipped = 0;
$progressBar = $this->output->createProgressBar($failedJobs->count());
$progressBar->start();
foreach ($failedJobs as $job) {
$payload = json_decode($job->payload, true);
$displayName = $payload['displayName'] ?? '';
$command = $payload['data']['command'] ?? '';
$leadId = $this->extractLeadId($command);
if ($leadId === null) {
$skipped++;
$progressBar->advance();
continue;
}
$lead = UserLead::find($leadId);
$action = $this->determineAction($lead, $displayName);
if ($action === 'retry') {
$retried++;
if (! $isDryRun) {
$this->callSilently('queue:retry', ['id' => [$job->uuid]]);
}
} else {
$forgotten++;
if (! $isDryRun) {
$this->callSilently('queue:forget', ['id' => [$job->uuid]]);
}
}
$progressBar->advance();
}
$progressBar->finish();
$this->newLine(2);
$this->table(
['Action', 'Count'],
[
['retried'.($isDryRun ? ' (dry run)' : ''), $retried],
['forgotten'.($isDryRun ? ' (dry run)' : ''), $forgotten],
['skipped (no lead ID found)', $skipped],
]
);
return self::SUCCESS;
}
/**
* Extract the UserLead UUID from a serialized job payload command string.
*
* Handles both queued mailables (id stored as a single string) and
* queued notifications (id stored as a single-element array).
*/
private function extractLeadId(string $command): ?string
{
// Mail jobs store the lead as a single string ID
if (preg_match('/App\\\\Models\\\\UserLead";s:2:"id";s:\d+:"([0-9a-f-]{36})"/', $command, $matches)) {
return $matches[1];
}
// Notification jobs store notifiables as an array of IDs
if (preg_match('/App\\\\Models\\\\UserLead";s:2:"id";a:\d+:\{i:0;s:\d+:"([0-9a-f-]{36})"/', $command, $matches)) {
return $matches[1];
}
return null;
}
/**
* Decide whether to retry or forget a failed job based on lead state.
*
* Rules:
* - Lead deleted forget (DDoS cleanup or similar)
* - Lead verified retry (Resend rate limit was the issue)
* - Lead unverified + VerifyUserLeadEmailNotification retry (still needs the verification email)
* - Lead unverified + anything else forget (waitlist emails to unverified leads are meaningless)
*/
private function determineAction(?UserLead $lead, string $displayName): string
{
if ($lead === null) {
return 'forget';
}
if ($lead->hasVerifiedEmail()) {
return 'retry';
}
if (str_contains($displayName, 'VerifyUserLeadEmailNotification')) {
return 'retry';
}
return 'forget';
}
}

View File

@ -17,6 +17,13 @@ class WaitlistOvertaken extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* Delete the job if its models no longer exist (e.g. lead was cleaned up after DDoS).
*
* @var bool
*/
public $deleteWhenMissingModels = true;
/**
* The number of times the job may be attempted.
*

View File

@ -17,6 +17,13 @@ class WaitlistReferralNotification extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* Delete the job if its models no longer exist (e.g. lead was cleaned up after DDoS).
*
* @var bool
*/
public $deleteWhenMissingModels = true;
/**
* The number of times the job may be attempted.
*

View File

@ -18,6 +18,13 @@ class WaitlistWelcome extends Mailable implements ShouldQueue
use Queueable;
use SerializesModels;
/**
* Delete the job if its models no longer exist (e.g. lead was cleaned up after DDoS).
*
* @var bool
*/
public $deleteWhenMissingModels = true;
/**
* The number of times the job may be attempted.
*

View File

@ -11,6 +11,13 @@ class VerifyUserLeadEmailNotification extends Notification implements ShouldQueu
{
use Queueable;
/**
* Delete the job if the notifiable model no longer exists.
*
* @var bool
*/
public $deleteWhenMissingModels = true;
public function __construct(private readonly string $verificationUrl)
{
$this->onQueue('emails');

View File

@ -49,6 +49,10 @@ parameters:
path: app/Listeners/*
- identifier: public.property.unused
path: app/Listeners/*
- identifier: public.method.unused
path: app/Notifications/*
- identifier: public.property.unused
path: app/Notifications/*
# Model relationship methods are called dynamically by Eloquent
- identifier: public.method.unused
path: app/Models/*

View File

@ -0,0 +1,157 @@
<?php
use App\Models\UserLead;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use function Pest\Laravel\artisan;
/**
* Build a serialized ModelIdentifier command string for a UserLead.
*
* Uses Illuminate\Contracts\Database\ModelIdentifier (a real Laravel class) so that
* queue:retry can safely unserialize it when refreshing retryUntil without hitting
* any undefined-class errors. The string matches our extractLeadId() regex patterns.
*/
function serializedMailLeadCommand(string $leadId): string
{
return sprintf(
'O:45:"Illuminate\Contracts\Database\ModelIdentifier":5:{s:5:"class";s:19:"App\Models\UserLead";s:2:"id";s:36:"%s";s:9:"relations";a:0:{}s:10:"connection";s:5:"mysql";s:15:"collectionClass";N;}',
$leadId
);
}
function serializedNotificationLeadCommand(string $leadId): string
{
return sprintf(
'O:45:"Illuminate\Contracts\Database\ModelIdentifier":5:{s:5:"class";s:19:"App\Models\UserLead";s:2:"id";a:1:{i:0;s:36:"%s";}s:9:"relations";a:0:{}s:10:"connection";s:5:"mysql";s:15:"collectionClass";N;}',
$leadId
);
}
/**
* Insert a fake failed mailable job into failed_jobs, referencing the given lead UUID.
*/
function insertFailedMailJob(string $displayName, string $leadId): string
{
$uuid = (string) Str::uuid();
DB::table('failed_jobs')->insert([
'uuid' => $uuid,
'connection' => 'database',
'queue' => 'emails',
'payload' => json_encode([
'uuid' => $uuid,
'displayName' => $displayName,
'job' => 'Illuminate\Queue\CallQueuedHandler@call',
'data' => [
'commandName' => 'Illuminate\Mail\SendQueuedMailable',
'command' => serializedMailLeadCommand($leadId),
],
]),
'exception' => 'Too Many Requests',
'failed_at' => now(),
]);
return $uuid;
}
/**
* Insert a fake failed VerifyUserLeadEmailNotification job, referencing the given lead UUID.
*/
function insertFailedNotificationJob(string $leadId): string
{
$uuid = (string) Str::uuid();
DB::table('failed_jobs')->insert([
'uuid' => $uuid,
'connection' => 'database',
'queue' => 'emails',
'payload' => json_encode([
'uuid' => $uuid,
'displayName' => 'App\Notifications\VerifyUserLeadEmailNotification',
'job' => 'Illuminate\Queue\CallQueuedHandler@call',
'data' => [
'commandName' => 'Illuminate\Notifications\SendQueuedNotifications',
'command' => serializedNotificationLeadCommand($leadId),
],
]),
'exception' => 'Too Many Requests',
'failed_at' => now(),
]);
return $uuid;
}
test('leads:retry-failed-jobs does nothing when no failed jobs exist', function () {
artisan('leads:retry-failed-jobs')
->expectsOutputToContain('No failed email jobs found.')
->assertSuccessful();
});
test('leads:retry-failed-jobs forgets jobs for deleted leads', function () {
$deletedLeadId = (string) Str::uuid();
insertFailedMailJob('App\Mail\WaitlistWelcome', $deletedLeadId);
artisan('leads:retry-failed-jobs')->assertSuccessful();
expect(DB::table('failed_jobs')->count())->toBe(0);
});
test('leads:retry-failed-jobs retries jobs for verified leads', function () {
$lead = UserLead::factory()->create();
insertFailedMailJob('App\Mail\WaitlistOvertaken', $lead->id);
artisan('leads:retry-failed-jobs')->assertSuccessful();
expect(DB::table('failed_jobs')->count())->toBe(0);
});
test('leads:retry-failed-jobs forgets waitlist jobs for unverified leads', function () {
$lead = UserLead::factory()->unverified()->create();
insertFailedMailJob('App\Mail\WaitlistOvertaken', $lead->id);
artisan('leads:retry-failed-jobs')->assertSuccessful();
expect(DB::table('failed_jobs')->count())->toBe(0);
});
test('leads:retry-failed-jobs retries VerifyUserLeadEmailNotification for unverified leads', function () {
$lead = UserLead::factory()->unverified()->create();
insertFailedNotificationJob($lead->id);
artisan('leads:retry-failed-jobs')->assertSuccessful();
expect(DB::table('failed_jobs')->count())->toBe(0);
});
test('leads:retry-failed-jobs handles a mix of job types correctly', function () {
$verifiedLead = UserLead::factory()->create();
$unverifiedLead = UserLead::factory()->unverified()->create();
$deletedLeadId = (string) Str::uuid();
insertFailedMailJob('App\Mail\WaitlistOvertaken', $verifiedLead->id); // retry
insertFailedMailJob('App\Mail\WaitlistReferralNotification', $verifiedLead->id); // retry
insertFailedMailJob('App\Mail\WaitlistWelcome', $deletedLeadId); // forget
insertFailedMailJob('App\Mail\WaitlistOvertaken', $unverifiedLead->id); // forget
insertFailedNotificationJob($unverifiedLead->id); // retry
artisan('leads:retry-failed-jobs')->assertSuccessful();
expect(DB::table('failed_jobs')->count())->toBe(0);
});
test('leads:retry-failed-jobs --dry-run does not modify failed_jobs', function () {
$lead = UserLead::factory()->create();
$deletedLeadId = (string) Str::uuid();
insertFailedMailJob('App\Mail\WaitlistOvertaken', $lead->id);
insertFailedMailJob('App\Mail\WaitlistWelcome', $deletedLeadId);
artisan('leads:retry-failed-jobs', ['--dry-run' => true])
->expectsOutputToContain('DRY RUN')
->assertSuccessful();
// Nothing should be touched in dry-run mode
expect(DB::table('failed_jobs')->count())->toBe(2);
});