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:
parent
d0aab3d11b
commit
f408dbe4c8
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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/*
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue