HEX
Server: LiteSpeed
System: Linux s1049.use1.mysecurecloudhost.com 4.18.0-477.27.2.lve.el8.x86_64 #1 SMP Wed Oct 11 12:32:56 UTC 2023 x86_64
User: xedaptot (3356)
PHP: 8.3.31
Disabled: NONE
Upload Files
File: /home/xedaptot/hi.naniguide.com/app/Jobs/StartCampaignJob.php
<?php

namespace App\Jobs;

use App\Models\Campaign;
use App\Models\CampaignRecipient;
use App\Models\SuppressionList;
use App\Jobs\SendCampaignChunkJob;
use App\Services\CampaignService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;

class StartCampaignJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;
    public int $backoff = 60; // 60 seconds between retries
    public int $timeout = 900; // 15 minutes timeout for very large campaigns

    /**
     * Create a new job instance.
     */
    public function __construct(
        public Campaign $campaign
    ) {}

    /**
     * Execute the job.
     */
    public function handle(): void
    {
        // Refresh campaign to get latest status
        $this->campaign->refresh();

        // Early exit for campaigns that are already finished
        if (in_array($this->campaign->status, ['completed', 'failed'], true)) {
            Log::info("Campaign {$this->campaign->id} is already {$this->campaign->status}. Skipping start job.");
            return;
        }

        try {
            app(CampaignService::class)->ensureCanRun($this->campaign);
        } catch (\RuntimeException $e) {
            $this->campaign->update([
                'status' => 'failed',
                'finished_at' => now(),
                'failure_reason' => $e->getMessage(),
            ]);
            return;
        }

        if ($this->campaign->scheduled_at && $this->campaign->scheduled_at->isFuture()) {
            $delaySeconds = max(1, now()->diffInSeconds($this->campaign->scheduled_at, false));
            $this->release($delaySeconds);
            return;
        }

        // Check if campaign can still be started
        if (!in_array($this->campaign->status, ['draft', 'scheduled', 'queued'], true)) {
            Log::warning("Campaign {$this->campaign->id} cannot be started. Current status: {$this->campaign->status}");
            // If campaign is already completed, don't treat this as an error
            if (in_array($this->campaign->status, ['completed', 'failed'], true)) {
                Log::info("Campaign {$this->campaign->id} is already {$this->campaign->status}. Skipping start job.");
                return;
            }
            return;
        }

        $claimed = DB::transaction(function () {
            return Campaign::whereKey($this->campaign->id)
                ->whereIn('status', ['draft', 'scheduled', 'queued'])
                ->whereNull('started_at')
                ->update([
                    'status' => 'running',
                    'started_at' => now(),
                ]);
        });

        if ($claimed !== 1) {
            Log::warning("Campaign {$this->campaign->id} start was skipped (already started or status changed).", [
                'status' => $this->campaign->status,
                'started_at' => $this->campaign->started_at,
            ]);
            return;
        }

        $this->campaign->refresh();

        try {
            // Prepare recipients if not already prepared
            $this->prepareRecipients();

            // Get pending recipients count
            $pendingCount = $this->campaign->recipients()
                ->where('status', 'pending')
                ->count();

            if ($pendingCount === 0) {
            $totalRecipients = $this->campaign->recipients()->count();
            
            if ($totalRecipients === 0) {
                // No recipients were ever created - this is a failure
                Log::warning("Campaign {$this->campaign->id} has no recipients to send to");
                $this->campaign->update([
                    'status' => 'failed',
                    'finished_at' => now(),
                    'failure_reason' => 'No eligible recipients found. Check if email list has confirmed subscribers.',
                ]);
            } else {
                // Recipients exist but none are pending - campaign is actually complete
                $sentCount = $this->campaign->recipients()->where('status', 'sent')->count();
                $failedCount = $this->campaign->recipients()->where('status', 'failed')->count();
                
                Log::info("Campaign {$this->campaign->id} has no pending recipients. Total: {$totalRecipients}, Sent: {$sentCount}, Failed: {$failedCount}");
                $this->campaign->update([
                    'status' => 'completed',
                    'finished_at' => now(),
                ]);
            }
            return;
        }

            // Dispatch chunks (50-100 recipients per chunk)
            $chunkSize = 50;
            $this->campaign->recipients()
                ->where('status', 'pending')
                ->chunk($chunkSize, function ($recipients) {
                    SendCampaignChunkJob::dispatch($this->campaign, $recipients->pluck('id')->toArray())
                        ->onQueue('campaigns');
                });

            Log::info("Campaign {$this->campaign->id} started. Dispatched chunks for {$pendingCount} recipients");

        } catch (\Exception $e) {
            $errorMessage = $e->getMessage();
            Log::error("Failed to start campaign {$this->campaign->id}: " . $errorMessage, [
                'exception' => $e,
                'trace' => $e->getTraceAsString()
            ]);
            
            $this->campaign->update([
                'status' => 'failed',
                'finished_at' => now(),
                'failure_reason' => $errorMessage,
            ]);

            throw $e;
        }
    }

    /**
     * Prepare recipients from email list.
     */
    protected function prepareRecipients(): void
    {
        // Check if recipients already exist
        if ($this->campaign->recipients()->count() > 0) {
            return;
        }

        if (!$this->campaign->emailList) {
            throw new \RuntimeException("Campaign {$this->campaign->id} has no associated email list");
        }

        // Resolve the audience using the shared campaign service logic so
        // list-only and segment-based campaigns stay aligned.
        $subscribers = app(CampaignService::class)->resolveCampaignSubscribers($this->campaign);

        // Also check global suppression list
        $suppressedEmails = SuppressionList::where('customer_id', $this->campaign->customer_id)
            ->pluck('email')
            ->toArray();

        if (!empty($suppressedEmails)) {
            $subscribers = $subscribers->reject(function ($subscriber) use ($suppressedEmails) {
                return in_array($subscriber->email, $suppressedEmails);
            });
        }

        // Log subscriber status breakdown for debugging
        $totalSubscribers = $this->campaign->emailList->subscribers()->count();
        $confirmedCount = $this->campaign->emailList->subscribers()->where('status', 'confirmed')->count();
        $unconfirmedCount = $this->campaign->emailList->subscribers()->where('status', 'unconfirmed')->count();
        $unsubscribedCount = $this->campaign->emailList->subscribers()->where('status', 'unsubscribed')->count();
        
        Log::info("Campaign {$this->campaign->id} subscriber breakdown", [
            'total' => $totalSubscribers,
            'confirmed' => $confirmedCount,
            'unconfirmed' => $unconfirmedCount,
            'unsubscribed' => $unsubscribedCount,
        ]);

        if ($subscribers->isEmpty()) {
            throw new \RuntimeException(
                "Email list {$this->campaign->emailList->id} has no confirmed subscribers. " .
                "Total: {$totalSubscribers}, Confirmed: {$confirmedCount}, Unconfirmed: {$unconfirmedCount}, Unsubscribed: {$unsubscribedCount}. " .
                "Please confirm subscribers before starting the campaign."
            );
        }

        // Create recipient records in batches to handle large lists
        // Dynamic batch size based on subscriber count
        $subscriberCount = $subscribers->count();
        $batchSize = $subscriberCount > 25000 ? 250 : 500; // Smaller batches for very large lists
        $totalRecipients = 0;
        $processedBatches = 0;
        
        // Convert subscribers collection to array for chunking
        $subscriberArray = $subscribers->all();
        $subscriberChunks = array_chunk($subscriberArray, $batchSize);
        
        Log::info("Campaign {$this->campaign->id} processing " . count($subscriberChunks) . " batches of {$batchSize} recipients each (total: {$subscriberCount})");
        
        foreach ($subscriberChunks as $batchIndex => $batch) {
            $recipients = [];
            
            foreach ($batch as $subscriber) {
                $recipients[] = [
                    'campaign_id' => $this->campaign->id,
                    'email' => $subscriber->email,
                    'uuid' => (string) Str::uuid(),
                    'first_name' => $subscriber->first_name,
                    'last_name' => $subscriber->last_name,
                    'status' => 'pending',
                    'meta' => json_encode([
                        'custom_fields' => is_array($subscriber->custom_fields) ? $subscriber->custom_fields : [],
                    ]),
                    'created_at' => now(),
                    'updated_at' => now(),
                ];
            }
            
            // Batch insert for this chunk
            try {
                CampaignRecipient::insert($recipients);
                $totalRecipients += count($recipients);
                $processedBatches++;
                
                // Log progress for large campaigns (more frequent for very large lists)
                $logFrequency = $subscriberCount > 25000 ? 5 : 10;
                if ($processedBatches % $logFrequency == 0 || count($subscriberChunks) <= $logFrequency) {
                    $progress = round(($processedBatches / count($subscriberChunks)) * 100, 1);
                    Log::info("Campaign {$this->campaign->id} processed batch " . ($batchIndex + 1) . "/" . count($subscriberChunks) . " (" . count($recipients) . " recipients) - {$progress}% complete");
                }
                
                // Progressive delay for very large campaigns to prevent database overload
                if (count($subscriberChunks) > 50) {
                    usleep(200000); // 0.2 second delay for very large campaigns
                } elseif (count($subscriberChunks) > 20) {
                    usleep(100000); // 0.1 second delay for large campaigns
                }
                
                // Memory cleanup for very large campaigns
                if ($processedBatches % 20 == 0 && $subscriberCount > 25000) {
                    gc_collect_cycles();
                    Log::debug("Campaign {$this->campaign->id} performed garbage collection at batch {$processedBatches}");
                }
                
            } catch (\Exception $e) {
                Log::error("Failed to insert batch " . ($batchIndex + 1) . " for campaign {$this->campaign->id}: " . $e->getMessage());
                throw new \RuntimeException("Failed to create recipients for campaign {$this->campaign->id}: " . $e->getMessage());
            }
        }

        // Update campaign total recipients count
        $this->campaign->update([
            'total_recipients' => $totalRecipients,
        ]);

        Log::info("Prepared {$totalRecipients} recipients for campaign {$this->campaign->id} in {$processedBatches} batches");
    }

    /**
     * Handle a job failure.
     */
    public function failed(\Throwable $exception): void
    {
        $errorMessage = $exception->getMessage();
        Log::error("StartCampaignJob failed for campaign {$this->campaign->id}: " . $errorMessage, [
            'exception' => $exception,
            'trace' => $exception->getTraceAsString()
        ]);
        
        $this->campaign->update([
            'status' => 'failed',
            'finished_at' => now(),
            'failure_reason' => $errorMessage,
        ]);
    }
}