File: /home/xedaptot/ai.naniguide.com/app/Jobs/BulkVerify.php
<?php
namespace App\Jobs;
use Illuminate\Bus\Batchable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use App\Library\Traits\Trackable;
use App\Library\Exception\RateLimitExceeded;
use App\Library\BulkCreditTracker;
use App\Library\Contracts\CreditTrackerInterface;
use App\Library\Contracts\BulkVerificationTargetInterface;
use App\Model\EmailVerificationServer;
use Exception;
class BulkVerify implements ShouldQueue
{
use Trackable;
use Batchable;
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public $timeout = 900;
public $maxExceptions = 1; // This is required if retryUntil is used, otherwise, the default value is 255
public $failOnTimeout = true;
public const MIN_PER_BATCH = 200;
public const MAX_PER_BATCH = 1000;
public const NUMBER_OF_BATCHES = 10;
// Allow it to try MANY times (in case of rate limit exceed)
// public $tries = 1;
protected $server;
protected $list;
protected $customer;
/**
* The credit tracker to deduct verification credits from, or null if unlimited / no subscription.
*
* SERIALIZATION REQUIREMENT: This tracker must be stateless from the object's perspective —
* its mutable state must live outside the object (e.g. on disk for CreditTracker, in cache for
* InMemoryCreditTracker). When Laravel serializes this job to the queue and later deserializes it,
* the tracker is reconstructed from its serialized fields (filepath / resourceKey). Any operation
* (test, count, rollback) then reads/writes the external store directly, so the values are always
* current regardless of when deserialization happened.
*
* DO NOT pass an object that holds live credit state purely in a PHP property — it would be
* snapshotted at dispatch time and silently stale when the job eventually runs.
*/
protected ?CreditTrackerInterface $creditTracker;
public function __construct(BulkVerificationTargetInterface $list, EmailVerificationServer $server, ?CreditTrackerInterface $creditTracker = null)
{
$this->list = $list;
$this->server = $server;
$this->creditTracker = $creditTracker;
$this->customer = $this->list->customer;
}
/**
* Determine the time at which the job should timeout.
*
* @return \DateTime
*/
public function retryUntil()
{
// @important: remember that messages might be released over and over
// if there is any limit setting in place
// As a result, it is just save to have it retry virtually forever
return now()->addDays(7);
}
public function handle()
{
$this->customer->setUserDbConnection();
if ($this->batch()->cancelled()) {
$this->list->logger()->warning('[BulkVerify] Job cancelled');
return;
}
$this->monitor->updateJsonData([
'percentage' => round($this->list->getVerifiedSubscribersPercentage() * 100, 2),
'message' => trans('messages.list.verification.progress.started'),
]);
$logger = $this->list->logger();
$logger->info('[BulkVerify] Start bulk verifying');
$creditTracker = $this->creditTracker;
if (!is_null($creditTracker)) {
// Redundant check — execute_with_limits will enforce this too, but fail fast here before doing any work
$creditTracker->test();
}
$serviceCredits = $this->server->driver()->getCredits();
if (!is_null($serviceCredits) && $serviceCredits <= 0) { // check null first, otherwise, null <= 0 always returns true
$logger->warning('[BulkVerify] User has run out of service credit');
throw new Exception('User run out of service credit');
}
$subscribersQuery = $this->list->getUnverifiedQuery();
$unverifiedCount = $subscribersQuery->count();
if ($unverifiedCount == 0) {
$this->monitor->updateJsonData([
'message' => trans('messages.list.verification.progress.done'),
'batch_info' => null, // clean up batch information
'percentage' => 100,
]);
$logger->info('[BulkVerify] No contact left, verification done');
return;
}
$perSubmit = (int) floor($unverifiedCount / static::NUMBER_OF_BATCHES);
if ($perSubmit < static::MIN_PER_BATCH) {
$perSubmit = static::MIN_PER_BATCH;
} elseif ($perSubmit > static::MAX_PER_BATCH) {
$perSubmit = static::MAX_PER_BATCH;
}
if ($unverifiedCount < $perSubmit) {
$perSubmit = $unverifiedCount;
}
if (!is_null($creditTracker) && !$creditTracker->isUnlimited()) {
$remainingCredits = $creditTracker->getRemainingCredits();
if ($remainingCredits >= $perSubmit) {
$logger->info("[BulkVerify] Remaining credits is {$remainingCredits}, ok to take {$perSubmit}");
} else {
$logger->info("[BulkVerify] Remaining credits is only {$remainingCredits}!");
$perSubmit = $remainingCredits;
}
} else {
$this->list->logger()->info("[BulkVerify] Good news, we have unlimited verification credits. Taking {$perSubmit} per batch");
}
// Sometimes it is null as the service does not support fetching remaining credits
if (!is_null($serviceCredits) && $serviceCredits < $perSubmit) {
$perSubmit = $serviceCredits;
$logger->info("[BulkVerify] Available service credit is only {$serviceCredits}! So, only take {$serviceCredits}!");
} else {
$logger->info("[BulkVerify] Available service credit is {$serviceCredits}! ok to take {$perSubmit}");
}
$subscribersQuery = $subscribersQuery->limit($perSubmit);
try {
// Submit a partition of list to the remote verification service
// Get the session token or id, use it to check status
$trackers = !is_null($creditTracker) ? [new BulkCreditTracker($creditTracker, $perSubmit)] : [];
$token = \App\Helpers\execute_with_limits([], null, $trackers, function () use ($subscribersQuery) {
return $this->server->driver()->bulkSubmit($subscribersQuery);
});
$this->list->logger()->info("[BulkVerify] ==> Submitted! Waiting for BulkCheck job...");
if (!is_null($creditTracker)) {
$this->list->logger()->info("[BulkVerify] {$perSubmit} credits counted, {$creditTracker->getRemainingCredits()} remains ('-1' means UNLIMITED)");
}
sleep(1);
// Dispatch checker job
$bulkCheck = new BulkCheck($this->list, $this->server, $this->creditTracker, $token);
$bulkCheck->setMonitor($this->monitor);
$this->batch()->add($bulkCheck);
} catch (RateLimitExceeded $ex) {
$this->list->logger()->warning('[BulkVerify] Rate limit exceeded, try again in 10 seconds. '.$ex->getMessage());
$this->release(10);
return;
} catch (\Throwable $ex) {
// Laravel batch cannot catch Throwable errors (it is handled by higher level of Laravel)
// Just catch Throwable here and throw Exception instead so that errors can be logged to job monitor
throw new Exception('Verification failed. '.$ex->getMessage());
}
}
}