Initial commit
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
<?php
|
||||
|
||||
|
||||
namespace FluentCrm\App\Services\Libs\Mailer;
|
||||
|
||||
use FluentCrm\App\Models\CampaignEmail;
|
||||
use FluentCrm\App\Services\Helper;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
abstract class BaseHandler
|
||||
{
|
||||
protected $runnerTitle = '';
|
||||
|
||||
protected $sentCount = 0;
|
||||
|
||||
protected $maximumProcessingTime = 50;
|
||||
|
||||
protected $calledFrom = 'cron';
|
||||
|
||||
protected $startingTimeStamp = null;
|
||||
|
||||
protected $optionKey = 'fluentcrm_is_sending_emails';
|
||||
|
||||
protected $isMultiThread = false;
|
||||
|
||||
protected $sendingChunkNumber = 0;
|
||||
|
||||
protected $lastLockRefreshAt = 0;
|
||||
|
||||
abstract protected function isTimeUp();
|
||||
|
||||
protected function sendEmails($campaignEmails)
|
||||
{
|
||||
global $wpdb;
|
||||
do_action('fluent_crm/sending_emails_starting', $campaignEmails);
|
||||
|
||||
if (defined('FLUENTMAIL')) {
|
||||
add_filter('fluentmail_will_log_email', 'fluentcrm_maybe_disable_fsmtp_log', 10, 2);
|
||||
}
|
||||
|
||||
$failedIds = [];
|
||||
|
||||
$this->sendingChunkNumber++;
|
||||
|
||||
$sendableStatuses = ['subscribed', 'transactional'];
|
||||
$table = $wpdb->prefix . 'fc_campaign_emails';
|
||||
|
||||
foreach ($campaignEmails as $email) {
|
||||
// Stop starting new emails once the runtime budget is spent. The
|
||||
// rate-limit wait below counts against wall-clock, so check every
|
||||
// iteration; any rows we already claimed but don't reach stay
|
||||
// 'processing' and are recovered by the stale-row reset.
|
||||
if ($this->isTimeUp()) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Check again if the contact is in subscribed status or not
|
||||
// If not then we will cancel the email
|
||||
if ($email->subscriber && !in_array($email->subscriber->status, $sendableStatuses, true)) {
|
||||
$email->status = 'cancelled';
|
||||
$email->save();
|
||||
continue;
|
||||
}
|
||||
|
||||
$emailData = $email->data();
|
||||
|
||||
// for the same id
|
||||
if (Helper::wasProcessedByKeyId('mail_' . $email->id . '_' . $email->email_address)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Wait for this email's global rate-limit slot BEFORE marking it
|
||||
// sent, so a crash/timeout during the wait leaves the row in
|
||||
// 'processing' (recoverable by the stale-row reset) instead of
|
||||
// 'sent'-but-never-delivered. Heartbeat the processing lock first
|
||||
// (time-gated, so it costs an in-memory check not a write per email)
|
||||
// so a long backpressure sleep can't let the lock expire mid-batch
|
||||
// and admit a second concurrent sender.
|
||||
$this->maybeRefreshLock();
|
||||
GlobalRateLimiter::throttle($emailData);
|
||||
|
||||
// Mark as 'sent' and clear email_body BEFORE sending.
|
||||
// This prevents duplicates on crash — if the process dies after this
|
||||
// point, the email won't be re-queued. Missing one email is acceptable,
|
||||
// sending duplicates is not.
|
||||
//
|
||||
// The WHERE pins both status='processing' AND the original claim's
|
||||
// updated_at. If the rate-limit wait above ran long enough that the
|
||||
// stale-row reset reclaimed this row and another sender re-claimed it
|
||||
// (even one that is mid-send right now, with status back at
|
||||
// 'processing'), that re-claim rewrote updated_at — so our UPDATE
|
||||
// matches 0 rows and we skip. This closes the duplicate-send window
|
||||
// independently of the lock TTL, so it holds even for slow SMTP
|
||||
// transports where a single wp_mail() can hang past the lock. (Same
|
||||
// UPDATE, one extra WHERE column — no added query.)
|
||||
//
|
||||
// Use the RAW updated_at string, not $email->updated_at: the model
|
||||
// casts that column to a DateTime, and $wpdb binds a DateTime object
|
||||
// as an empty string (it does not call __toString), which would make
|
||||
// the WHERE `updated_at = ''`, match 0 rows, and strand EVERY email in
|
||||
// 'processing' forever. The raw attribute is the exact stored string.
|
||||
$claimToken = Arr::get($email->getAttributes(), 'updated_at');
|
||||
$claimed = $wpdb->update($table, [
|
||||
'status' => 'sent',
|
||||
'scheduled_at' => current_time('mysql'),
|
||||
'email_body' => '',
|
||||
'is_parsed' => 1,
|
||||
], ['id' => $email->id, 'status' => 'processing', 'updated_at' => $claimToken]);
|
||||
|
||||
if ($claimed === false) {
|
||||
Helper::debugLog('DB Error at ' . $this->runnerTitle, $wpdb->last_error, 'error');
|
||||
return new \WP_Error('db_error', $wpdb->last_error ?: 'mark-sent update failed');
|
||||
}
|
||||
|
||||
if ($claimed === 0) {
|
||||
// Row was reclaimed (and likely already sent) by another process
|
||||
// during our wait. Do not send it again.
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->sentCount++;
|
||||
|
||||
// Already throttled above (before mark-sent); skip the in-Mailer
|
||||
// reservation so this email isn't rate-limited twice.
|
||||
$response = Mailer::send($emailData, $email->subscriber, $email, true);
|
||||
|
||||
// wp_mail() returns false on failure (not WP_Error) in most cases.
|
||||
// We must catch both to avoid marking undelivered emails as 'sent'.
|
||||
// Note: emails are marked 'sent' BEFORE wp_mail() by design to prevent
|
||||
// duplicate sends on crash. This is intentional — losing one email is
|
||||
// acceptable, sending duplicates is not.
|
||||
if (is_wp_error($response) || $response === false) {
|
||||
$failedIds[] = $email->id;
|
||||
}
|
||||
}
|
||||
|
||||
$this->updateEmailsStatus($failedIds, 'failed');
|
||||
|
||||
if (defined('FLUENTMAIL')) {
|
||||
remove_filter('fluentmail_will_log_email', 'fluentcrm_maybe_disable_fsmtp_log', 10);
|
||||
}
|
||||
|
||||
do_action('fluentcrm_sending_emails_done', $campaignEmails);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function processBatchEmails()
|
||||
{
|
||||
if ($this->isTimeUp()) {
|
||||
return 'time_up';
|
||||
}
|
||||
|
||||
$emails = $this->getNextBatchEmails();
|
||||
|
||||
if (!$emails || $emails->isEmpty()) {
|
||||
return 'empty';
|
||||
}
|
||||
|
||||
$this->refreshLock();
|
||||
$result = $this->sendEmails($emails);
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
usleep(10000); // 0.01 seconds sleep
|
||||
|
||||
return $this->processBatchEmails();
|
||||
}
|
||||
|
||||
abstract protected function getNextBatchEmails();
|
||||
|
||||
protected function logSentCount()
|
||||
{
|
||||
if ($this->sentCount) {
|
||||
Helper::debugLog(sprintf($this->runnerTitle . ': Sent %d', $this->sentCount), sprintf('%d seconds via %s', time() - $this->startingTimeStamp, $this->calledFrom));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Memory exceeded
|
||||
*
|
||||
* Ensures the batch process never exceeds 90% of the maximum WordPress memory.
|
||||
*
|
||||
* Based on WP_Background_Process::memory_exceeded()
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function memoryExceeded()
|
||||
{
|
||||
$memory_limit = fluentCrmGetMemoryLimit() * 0.70;
|
||||
$current_memory = memory_get_usage(true);
|
||||
|
||||
$memory_exceeded = $current_memory >= $memory_limit;
|
||||
|
||||
return apply_filters('fluentcrm_memory_exceeded', $memory_exceeded, $this);
|
||||
}
|
||||
|
||||
protected function updateEmailsStatus($ids, $status)
|
||||
{
|
||||
if (!$ids) {
|
||||
return false;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$whereIn = implode(',', array_fill(0, count($ids), '%d'));
|
||||
$query = "UPDATE {$wpdb->prefix}fc_campaign_emails SET status = %s WHERE id IN ($whereIn)";
|
||||
$wpdb->query($wpdb->prepare($query, array_merge([$status], $ids)));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function handleFailedLog()
|
||||
{
|
||||
add_action('wp_mail_failed', function ($error) {
|
||||
$data = $error->get_error_data();
|
||||
$to = Arr::get($data, 'to');
|
||||
if ($to) {
|
||||
if (is_array($to)) {
|
||||
$to = $to[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (!$to || !\is_string($to) || !is_email($to)) {
|
||||
return;
|
||||
}
|
||||
|
||||
CampaignEmail::where('email_address', $to)
|
||||
->limit(1)
|
||||
->whereIn('status', ['processing', 'sent', 'failed'])
|
||||
->orderBy('updated_at', 'DESC')
|
||||
->update([
|
||||
'status' => 'failed',
|
||||
'note' => $error->get_error_message()
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Atomically acquire the processing lock.
|
||||
*
|
||||
* Replaces the old isProcessing() + processing() two-step pattern
|
||||
* which had a TOCTOU race condition — two processes could both read
|
||||
* "not processing" and both start sending emails.
|
||||
*
|
||||
* @return bool True if the lock was acquired, false if another process holds it.
|
||||
*/
|
||||
protected function acquireLock()
|
||||
{
|
||||
// Single atomic conditional UPDATE on wp_options (Helper::acquireDbLock)
|
||||
// on every environment. We no longer take a wp_cache_add() fast path when
|
||||
// an external object cache is active: that primitive is only atomic if the
|
||||
// drop-in implements it against the shared backend, and some do not —
|
||||
// notably LiteSpeed Object Cache, whose add() checks only the per-process
|
||||
// in-memory array and then writes unconditionally. Under it, concurrent
|
||||
// senders all acquired the lock and ran at once, overshooting the provider
|
||||
// rate limit. The DB row lock has no such gap. See Helper::acquireDbLock().
|
||||
$lockTimeout = $this->maximumProcessingTime + 30;
|
||||
|
||||
return Helper::acquireDbLock($this->optionKey, $lockTimeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the lock timestamp (heartbeat) to prevent stuck-lock detection.
|
||||
*
|
||||
* Writes to the same wp_options row acquireLock() claims, so the heartbeat
|
||||
* and the stale-detection read share one source of truth.
|
||||
*/
|
||||
protected function refreshLock()
|
||||
{
|
||||
Helper::refreshDbLock($this->optionKey);
|
||||
$this->lastLockRefreshAt = microtime(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Heartbeat the lock only when it's getting close to its TTL, instead of on
|
||||
* every email. The per-email send loop calls this so a long rate-limit wait
|
||||
* can't let the lock expire mid-batch — but in normal sending (sub-second
|
||||
* waits, batch done in seconds) it never actually writes: the guard is just
|
||||
* an in-memory timestamp compare. It fires ~once per (TTL/3) only when a
|
||||
* batch runs long under backpressure.
|
||||
*/
|
||||
protected function maybeRefreshLock()
|
||||
{
|
||||
// Refresh at TTL/4 so the gap between heartbeats, plus one email's
|
||||
// max wait (GlobalRateLimiter caps a single wait at 15s) plus its
|
||||
// wp_mail() send, stays under the lock TTL (maximumProcessingTime + 30):
|
||||
// 20s gap + 15s wait + ~30s send = 65s < 80s. That keeps the lock alive
|
||||
// across a long backpressure sleep without writing on every email.
|
||||
$interval = max(8, (int)(($this->maximumProcessingTime + 30) / 4));
|
||||
|
||||
if ((microtime(true) - $this->lastLockRefreshAt) >= $interval) {
|
||||
$this->refreshLock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the processing lock so another process can acquire it.
|
||||
*/
|
||||
protected function releaseLock()
|
||||
{
|
||||
Helper::releaseDbLock($this->optionKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Libs\Mailer;
|
||||
|
||||
use FluentCrm\App\Models\CampaignEmail;
|
||||
|
||||
class CampaignEmailIterator implements \Iterator
|
||||
{
|
||||
protected $key = 0;
|
||||
protected $limit = 0;
|
||||
protected $offset = 0;
|
||||
protected $emails = null;
|
||||
protected $campaignId = null;
|
||||
|
||||
public function __construct($campaignId = null, $limit = 10)
|
||||
{
|
||||
$this->campaignId = $campaignId;
|
||||
$this->limit = $limit ? $limit : 10;
|
||||
}
|
||||
|
||||
#[\ReturnTypeWillChange]
|
||||
public function current()
|
||||
{
|
||||
return $this->emails;
|
||||
}
|
||||
|
||||
#[\ReturnTypeWillChange]
|
||||
public function key()
|
||||
{
|
||||
return $this->key++;
|
||||
}
|
||||
|
||||
#[\ReturnTypeWillChange]
|
||||
public function next()
|
||||
{
|
||||
$this->offset = $this->offset;
|
||||
}
|
||||
|
||||
#[\ReturnTypeWillChange]
|
||||
public function rewind()
|
||||
{
|
||||
$this->offset = 0;
|
||||
}
|
||||
|
||||
#[\ReturnTypeWillChange]
|
||||
public function valid()
|
||||
{
|
||||
$currentTime = current_time('mysql');
|
||||
|
||||
$emails = CampaignEmail::whereIn('status', ['pending', 'scheduled'])
|
||||
->when($this->campaignId, function ($query) {
|
||||
return $query->where('campaign_id', $this->campaignId);
|
||||
})
|
||||
->where('scheduled_at', '<=', $currentTime)
|
||||
->whereNotNull('scheduled_at')
|
||||
->with('campaign', 'subscriber')
|
||||
->orderBy('scheduled_at', 'ASC')
|
||||
->offset($this->offset)
|
||||
->limit($this->limit)
|
||||
->get();
|
||||
|
||||
$ids = $emails->pluck('id')->toArray();
|
||||
|
||||
if ($ids) {
|
||||
// Update the status to 'processing' for the selected emails
|
||||
global $wpdb;
|
||||
$placeholders = implode(',', array_fill(0, count($ids), '%d'));
|
||||
$query = "UPDATE {$wpdb->prefix}fc_campaign_emails SET status = %s, updated_at = %s, scheduled_at = %s WHERE id IN ($placeholders)";
|
||||
$wpdb->query($wpdb->prepare($query, array_merge(['processing', $currentTime, $currentTime], $ids)));
|
||||
}
|
||||
|
||||
$this->emails = $emails;
|
||||
|
||||
return !$this->emails->isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Libs\Mailer;
|
||||
|
||||
use FluentCrm\App\Models\CampaignEmail;
|
||||
use FluentCrm\App\Services\Helper;
|
||||
|
||||
class CliSendingHandler extends BaseHandler
|
||||
{
|
||||
|
||||
protected $runnerTitle = 'CliSendingHandler::handle';
|
||||
|
||||
protected $sendingPerChunk = 30;
|
||||
|
||||
protected $maximumProcessingTime = 50;
|
||||
|
||||
public $offset = 350;
|
||||
|
||||
public $minPendingRequired = 400;
|
||||
|
||||
protected $optionKey = 'fluentcrm_is_sending_cli_emails';
|
||||
|
||||
public function __construct($optionName = 'fluentcrm_is_sending_cli_emails', $runTime = 50, $offset = 350, $minPendingRequired = 400)
|
||||
{
|
||||
$this->optionKey = $optionName;
|
||||
$this->maximumProcessingTime = $runTime;
|
||||
$this->offset = $offset;
|
||||
$this->minPendingRequired = $minPendingRequired;
|
||||
}
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$systemCheck = $this->isSystemOk();
|
||||
if (is_wp_error($systemCheck)) {
|
||||
return $systemCheck;
|
||||
}
|
||||
|
||||
Helper::maybeDisableEmojiOnEmail();
|
||||
Helper::debugLog('Starting ' . $this->runnerTitle, '', 'extended');
|
||||
|
||||
try {
|
||||
$this->handleFailedLog();
|
||||
$result = $this->processBatchEmails();
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
$this->releaseLock();
|
||||
$this->logSentCount();
|
||||
return new \WP_Error('wp_error', $result->get_error_message());
|
||||
}
|
||||
|
||||
if ($result === 'time_up') {
|
||||
$this->releaseLock();
|
||||
$this->logSentCount();
|
||||
return new \WP_Error('time_up', 'Time Up');
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$this->releaseLock();
|
||||
Helper::debugLog('Exception at ' . $this->runnerTitle, $e->getMessage(), 'error');
|
||||
return new \WP_Error('exception', $e->getMessage());
|
||||
}
|
||||
|
||||
$this->logSentCount();
|
||||
$this->releaseLock();
|
||||
return true;
|
||||
}
|
||||
|
||||
private function isSystemOk()
|
||||
{
|
||||
if (!defined('WP_CLI') || !WP_CLI) {
|
||||
return new \WP_Error('not_cli', 'This is not a CLI request');
|
||||
}
|
||||
|
||||
$this->calledFrom = 'CLI';
|
||||
|
||||
if (
|
||||
did_action('fluent_crm/sending_cli_threading_email') ||
|
||||
apply_filters('fluent_crm/disable_email_processing', false)
|
||||
) {
|
||||
return new \WP_Error('disabled', 'Email Processing is disabled');
|
||||
}
|
||||
|
||||
if ($this->memoryExceeded()) {
|
||||
Helper::debugLog('Mailer Memory Exceeded at ' . $this->runnerTitle, 'Memory Limit: ' . fluentCrmGetMemoryLimit() . '<br />Current Usage: ' . memory_get_usage(true));
|
||||
return new \WP_Error('memory_exceeded', 'Memory Exceeded at ' . $this->runnerTitle);
|
||||
}
|
||||
|
||||
if (Helper::getUpcomingEmailCount() < $this->minPendingRequired) {
|
||||
return new \WP_Error('not_enough', 'Pending emails are not enough to process');
|
||||
}
|
||||
|
||||
$this->isMultiThread = true;
|
||||
$this->startingTimeStamp = time();
|
||||
|
||||
if (!$this->acquireLock()) {
|
||||
Helper::debugLog('already Processing', 'CliSendingHandler::handle', 'extended');
|
||||
return new \WP_Error('already_processing', 'Already Processing');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function getNextBatchEmails()
|
||||
{
|
||||
$remaining = Helper::getUpcomingEmailCount();
|
||||
if ($remaining < $this->minPendingRequired) {
|
||||
\WP_CLI::line(sprintf('only %d emails left. Exiting....', $remaining));
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($this->memoryExceeded()) {
|
||||
Helper::debugLog('Mailer Memory Exceeded at ' . $this->runnerTitle, 'Memory Limit: ' . fluentCrmGetMemoryLimit() . '<br />Current Usage: ' . memory_get_usage(true));
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($this->sentCount) {
|
||||
\WP_CLI::line(sprintf('Sent %1d emails. -> %2d', $this->sentCount, $this->sendingChunkNumber));
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'fc_campaign_emails';
|
||||
$currentTime = current_time('mysql');
|
||||
|
||||
// Use transaction-based atomic claiming like Handler to prevent duplicates
|
||||
$wpdb->query('START TRANSACTION');
|
||||
|
||||
$rows = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT id FROM {$table} WHERE status IN ('pending', 'scheduled') AND scheduled_at <= %s ORDER BY scheduled_at DESC LIMIT %d, %d FOR UPDATE",
|
||||
$currentTime, $this->offset, $this->sendingPerChunk
|
||||
));
|
||||
|
||||
$ids = wp_list_pluck($rows, 'id');
|
||||
|
||||
if ($ids) {
|
||||
$idsPlaceholder = implode(',', array_fill(0, count($ids), '%d'));
|
||||
$result = $wpdb->query($wpdb->prepare(
|
||||
"UPDATE {$table} SET status = 'processing', updated_at = %s WHERE id IN ($idsPlaceholder) AND status IN ('pending', 'scheduled')",
|
||||
array_merge([$currentTime], $ids)
|
||||
));
|
||||
|
||||
if ($result === false || $wpdb->rows_affected === 0) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
$wpdb->query('COMMIT');
|
||||
|
||||
if (!$ids) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$this->refreshLock();
|
||||
|
||||
return CampaignEmail::whereIn('id', $ids)
|
||||
->where('status', 'processing')
|
||||
->with(['campaign', 'subscriber'])
|
||||
->get();
|
||||
}
|
||||
|
||||
public function setRunnerTitle($title)
|
||||
{
|
||||
$this->runnerTitle = $title;
|
||||
return $this;
|
||||
}
|
||||
|
||||
protected function isTimeUp()
|
||||
{
|
||||
return (time() - $this->startingTimeStamp) >= $this->maximumProcessingTime;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Libs\Mailer;
|
||||
|
||||
use FluentCrm\App\Services\Helper;
|
||||
|
||||
/**
|
||||
* Cross-process global send-rate limiter (evenly spaced).
|
||||
*
|
||||
* Bulk and automation email funnels through Mailer::send(), which calls
|
||||
* throttle() here. That makes this the one authoritative cap on the install's
|
||||
* outgoing send rate.
|
||||
*
|
||||
* Two pacing modes, selected by the `multi_threading_emails` experimental flag
|
||||
* — the single oracle for whether parallel sending can happen:
|
||||
*
|
||||
* - Flag OFF (default, ~90% of installs): the cron Handler is the only
|
||||
* SUSTAINED sender. MultiThreadHandler is gated off (Scheduler) and the CLI
|
||||
* sender no-ops (Commands::cli_send), and the funnel/contact-job sender
|
||||
* (Handler::processSubscriberEmail) shares the cron Handler's lock so it is
|
||||
* serialized, never concurrent. The TAT lives in a process static and pacing
|
||||
* is a plain in-memory compare + sleep — NO DB read or write. Common path,
|
||||
* cheapest it can be.
|
||||
*
|
||||
* - Flag ON: the Handler, MultiThreadHandler and WP-CLI workers run at once in
|
||||
* separate processes that share no memory, so the TAT moves to the DB and is
|
||||
* advanced by atomic compare-and-swap. Same even drip, coordinated globally.
|
||||
*
|
||||
* Both modes implement the identical GCRA algorithm below; they differ only in
|
||||
* where the TAT is stored. The flag is checked once per process (memoized).
|
||||
*
|
||||
* Two gaps in flag-off mode are DELIBERATELY accepted, not bugs:
|
||||
* (1) Sparse direct sends that don't hold the sender lock — double opt-in and
|
||||
* the public unsubscribe/manage-link emails (ExternalPages) — pace from
|
||||
* their own process static and are NOT coordinated with the bulk loop.
|
||||
* Their real-world volume (signups, manual link requests) sits well under
|
||||
* the `emails_per_second - 3` buffer, which is precisely the headroom that
|
||||
* absorbs them, so aggregate stays under the provider cap.
|
||||
* (2) If multi-threading is toggled OFF while a multi/CLI worker is mid-batch,
|
||||
* that worker keeps pacing via the DB for up to ~one batch (~50s) while a
|
||||
* new cron loop paces in memory — two uncoordinated bulk streams. This is
|
||||
* rare (requires a mid-send flag toggle) and self-heals when the worker
|
||||
* drains; accepted rather than guarded.
|
||||
*
|
||||
* Callers may opt a send OUT of the cap by passing $preThrottled=true to
|
||||
* Mailer::send (e.g. double opt-in, a single transactional email on signup that
|
||||
* should not be delayed). The bulk handlers also pass it — but only because they
|
||||
* already reserved the slot themselves before marking the row sent.
|
||||
*
|
||||
* Algorithm (GCRA / leaky bucket): a shared "theoretical arrival time" (TAT)
|
||||
* marks the earliest moment the next send may go out. Each send atomically does
|
||||
*
|
||||
* slot = max(TAT, now); TAT = slot + (1 / limit)
|
||||
*
|
||||
* then sleeps until `slot`. Because the read-modify-write is atomic across
|
||||
* processes, consecutive sends — whichever process they come from — are handed
|
||||
* timeslots exactly 1/limit apart: an even drip at the configured rate, no
|
||||
* bursts, which is what burst-sensitive providers like Amazon SES require.
|
||||
*
|
||||
* Store (multi-thread mode only): ONE fixed wp_options row holding the TAT
|
||||
* (microseconds), advanced by an optimistic compare-and-swap (CAS) — a plain
|
||||
* SELECT then a conditional UPDATE
|
||||
* that only advances the TAT if no other sender moved it first. The single-row
|
||||
* conditional UPDATE is atomic on MySQL/MariaDB (InnoDB row lock) and SQLite
|
||||
* (global write serialization) alike, with no session variables, GET_LOCK, or
|
||||
* GREATEST — so it is portable AND immune to read/write-split routing (the slot
|
||||
* is computed in PHP, never read back from a server-side variable that a replica
|
||||
* might not have).
|
||||
*
|
||||
* A deliberately earlier design used a session variable (@fc_slot) and an
|
||||
* object-cache mutex. Both were removed after review: @fc_slot silently fails
|
||||
* open when a follow-up SELECT routes to a replica (HyperDB/ProxySQL/RDS Proxy),
|
||||
* and a TTL-expiring cache mutex cannot do a safe non-idempotent RMW without
|
||||
* fencing. The DB CAS path has neither problem.
|
||||
*
|
||||
* Fail-open by design: if the store is unavailable the limiter returns at once
|
||||
* rather than blocking the queue — but every fail-open is LOGGED (sampled) so a
|
||||
* silently-degraded limiter is detectable instead of giving false confidence.
|
||||
*
|
||||
* Backpressure: the wait is never clamped to "send early". Under N concurrent
|
||||
* senders the TAT runs at most ~N×interval ahead of now (each process holds one
|
||||
* in-flight reservation), so waits are bounded by real concurrency, and the
|
||||
* caller sleeps the full wait. Only a corrupt/runaway TAT (more than
|
||||
* GARBAGE_AHEAD_MICRO in the future) is treated as poison and reset to now.
|
||||
*
|
||||
* Caller responsibilities (see BaseHandler::sendEmails): reserve the slot BEFORE
|
||||
* marking the row 'sent' (so a crash mid-wait leaves it recoverable), and
|
||||
* refresh the processing lock around the wait (so a long backpressure sleep
|
||||
* cannot expire the lock mid-batch and admit a second concurrent sender).
|
||||
*
|
||||
* Caveats (documented, not guarded): (a) if a caller invokes Mailer::send while
|
||||
* holding an open DB transaction, the CAS UPDATE joins it and the row lock is
|
||||
* held until that transaction commits — the bundled senders all commit before
|
||||
* sending, so this only affects third-party callers. (b) Spacing is only as good
|
||||
* as clock sync across app servers sharing one DB; the TAT itself is monotonic,
|
||||
* but each process's local `now` is used for the sleep target.
|
||||
*
|
||||
* Scope: the wp_options table is per-blog, so multisite blogs keep independent
|
||||
* caps automatically.
|
||||
*/
|
||||
class GlobalRateLimiter
|
||||
{
|
||||
const DB_OPTION = '_fc_email_rate_tat';
|
||||
|
||||
// A TAT more than this far in the future is treated as corrupt (clock jump,
|
||||
// poisoned value) and reset to now — never as legitimate backpressure.
|
||||
// Kept small so the longest possible single wait, PLUS the wp_mail() that
|
||||
// follows it, PLUS the sender's heartbeat gap, all stay under the sender's
|
||||
// lock TTL (maximumProcessingTime + 30 = 80s): 20s gap + 15s wait + ~30s
|
||||
// send = 65s < 80s. Legitimate backpressure (real concurrency × interval)
|
||||
// is only ever a few seconds, far below this cap.
|
||||
const GARBAGE_AHEAD_MICRO = 15000000; // 15s
|
||||
|
||||
// Bounded CAS retry budget. Exceeding it means pathological contention; the
|
||||
// limiter then fails open (logged) rather than spinning forever.
|
||||
const MAX_CAS_ATTEMPTS = 50;
|
||||
|
||||
private static $dbRowReady = false;
|
||||
private static $cachedLimit = null;
|
||||
private static $failOpenCount = 0;
|
||||
|
||||
// Pacing mode, memoized per process. True once we know parallel sending can
|
||||
// occur (multi-threading flag on); null until first checked.
|
||||
private static $multiThread = null;
|
||||
|
||||
// In-memory TAT (microseconds) for the single-sender fast path. Process-local
|
||||
// by design — only correct when this is the install's only sender.
|
||||
private static $lastSlotMicro = 0;
|
||||
|
||||
/**
|
||||
* Throttle one outgoing email against the global per-second cap.
|
||||
*
|
||||
* Self-contained: reads the configured limit and the enable switch itself,
|
||||
* so any caller invokes it with zero wiring. The bulk handlers call this
|
||||
* directly (before marking a row sent) and pass $preThrottled=true to
|
||||
* Mailer::send so the email is not throttled twice.
|
||||
*
|
||||
* @param array $data The email payload, exposed to the enable filter for
|
||||
* per-email exemptions (inspect $data['scope'] etc.).
|
||||
*/
|
||||
public static function throttle($data = [])
|
||||
{
|
||||
if (!apply_filters('fluent_crm/enable_global_rate_limit', true, $data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$limit = self::getLimit();
|
||||
if ($limit < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 32-bit PHP cannot hold a microsecond timestamp (~1.7e15 > PHP_INT_MAX
|
||||
// 2.1e9); the GCRA math would overflow to garbage. Fail open (logged),
|
||||
// for either mode, before any interval math runs.
|
||||
if (PHP_INT_SIZE < 8) {
|
||||
self::logFailOpen('php_32bit');
|
||||
return;
|
||||
}
|
||||
|
||||
$intervalMicro = (int)ceil(1000000 / $limit);
|
||||
|
||||
// Two pacing modes (see the class docblock for the accepted flag-off gaps):
|
||||
//
|
||||
// - Multi-threading OFF (default, ~90% of installs): the cron loop is the
|
||||
// only sustained sender (MultiThreadHandler gated off, CLI no-op, funnel
|
||||
// sends share its lock), so we pace from a process-local TAT: no DB row,
|
||||
// no query, just an in-memory compare and a sleep. The 2-fewer-queries path.
|
||||
//
|
||||
// - Multi-threading ON: the Handler, MultiThreadHandler and CLI workers
|
||||
// run concurrently in separate processes that share no memory, so the
|
||||
// TAT must live in the DB and advance by atomic compare-and-swap.
|
||||
if (self::isMultiThreadMode()) {
|
||||
self::reserveViaDb($intervalMicro);
|
||||
} else {
|
||||
self::reserveViaMemory($intervalMicro);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to use the DB (cross-process) path instead of in-memory pacing.
|
||||
* Driven by the multi-threading experimental flag: when it is off, the only
|
||||
* SUSTAINED senders are gated/serialized (MultiThreadHandler off, CLI no-op,
|
||||
* funnel sends share the cron lock), so the in-memory path governs the rate.
|
||||
* The flag does NOT cover the two accepted gaps documented on the class:
|
||||
* sparse unlocked direct sends, and a mid-send flag toggle. Memoized per
|
||||
* process — the experimental settings are read once and don't change
|
||||
* mid-request.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private static function isMultiThreadMode()
|
||||
{
|
||||
if (self::$multiThread === null) {
|
||||
self::$multiThread = Helper::isExperimentalEnabled('multi_threading_emails');
|
||||
}
|
||||
|
||||
return self::$multiThread;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory even pacing for the single-sender case. The TAT is a process
|
||||
* static; correct ONLY because no other process sends concurrently (see
|
||||
* isMultiThreadMode). No DB read/write — this is what saves the two
|
||||
* wp_options queries per send on single-threaded installs.
|
||||
*
|
||||
* @param int $intervalMicro Spacing between sends in microseconds (1e6/limit).
|
||||
*/
|
||||
private static function reserveViaMemory($intervalMicro)
|
||||
{
|
||||
$nowMicro = (int)round(microtime(true) * 1000000);
|
||||
|
||||
$slot = max(self::$lastSlotMicro, $nowMicro);
|
||||
|
||||
// A backward clock step (NTP correction) could strand $lastSlotMicro far
|
||||
// ahead of now and turn the next wait into a multi-minute stall. Treat an
|
||||
// absurd gap as poison and reset to now — same guard the DB path uses.
|
||||
if ($slot - $nowMicro > self::GARBAGE_AHEAD_MICRO) {
|
||||
$slot = $nowMicro;
|
||||
}
|
||||
|
||||
self::$lastSlotMicro = $slot + $intervalMicro;
|
||||
|
||||
$waitMicro = $slot - (int)round(microtime(true) * 1000000);
|
||||
if ($waitMicro > 0) {
|
||||
usleep($waitMicro);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DB-backed pacing for the multi-sender case: reserve the next slot via the
|
||||
* cross-process compare-and-swap, then sleep the full wait until it arrives.
|
||||
*
|
||||
* @param int $intervalMicro Spacing between sends in microseconds (1e6/limit).
|
||||
*/
|
||||
private static function reserveViaDb($intervalMicro)
|
||||
{
|
||||
$slot = self::reserveTat($intervalMicro);
|
||||
if ($slot === null) {
|
||||
return; // fail open (already logged)
|
||||
}
|
||||
|
||||
// Sleep the FULL wait — never send early. The wait is bounded by real
|
||||
// concurrency (TAT runs at most ~N×interval ahead), so this is genuine
|
||||
// backpressure, not an unbounded stall.
|
||||
$waitMicro = $slot - (int)round(microtime(true) * 1000000);
|
||||
if ($waitMicro > 0) {
|
||||
usleep($waitMicro);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The global per-second send cap shared by every process, derived from the
|
||||
* email settings with the buffer + floor the senders have always used.
|
||||
* Memoized per process; a settings change is picked up by the next process.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public static function getLimit()
|
||||
{
|
||||
if (self::$cachedLimit !== null) {
|
||||
return self::$cachedLimit;
|
||||
}
|
||||
|
||||
$emailSettings = fluentcrmGetGlobalSettings('email_settings', []);
|
||||
|
||||
if (!empty($emailSettings['emails_per_second'])) {
|
||||
$limit = (int)$emailSettings['emails_per_second'] - 3; // 3 is buffer
|
||||
} else {
|
||||
$limit = 14;
|
||||
}
|
||||
|
||||
if (!$limit || $limit < 4) {
|
||||
$limit = 4;
|
||||
}
|
||||
|
||||
self::$cachedLimit = (int)apply_filters('fluent_crm/global_email_limit_per_second', $limit, $emailSettings);
|
||||
|
||||
return self::$cachedLimit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically advance the shared TAT and return this caller's slot
|
||||
* (microseconds), using an optimistic compare-and-swap loop.
|
||||
*
|
||||
* @return int|null Slot timestamp in microseconds, or null to fail open.
|
||||
*/
|
||||
private static function reserveTat($intervalMicro)
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
self::ensureDbRow();
|
||||
|
||||
for ($attempt = 0; $attempt < self::MAX_CAS_ATTEMPTS; $attempt++) {
|
||||
$nowMicro = (int)round(microtime(true) * 1000000);
|
||||
|
||||
$currentRaw = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
|
||||
self::DB_OPTION
|
||||
));
|
||||
|
||||
if ($currentRaw === null) {
|
||||
self::logFailOpen('row_missing');
|
||||
return null;
|
||||
}
|
||||
|
||||
$tat = (int)$currentRaw;
|
||||
|
||||
// Corrupt/runaway TAT guard: a value absurdly far in the future
|
||||
// (clock jump, poisoned write) is reset to now, never honored as a
|
||||
// multi-minute sleep.
|
||||
if ($tat > $nowMicro + self::GARBAGE_AHEAD_MICRO) {
|
||||
$tat = $nowMicro;
|
||||
}
|
||||
|
||||
$slot = max($tat, $nowMicro);
|
||||
$newTat = (string)($slot + $intervalMicro);
|
||||
|
||||
// Advance only if nobody moved the TAT since our read. The new value
|
||||
// is always strictly greater than the old (interval >= 1), so a
|
||||
// successful advance always changes the row — rows-changed semantics
|
||||
// cannot mask a real win as a false loss.
|
||||
$affected = $wpdb->query($wpdb->prepare(
|
||||
"UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s",
|
||||
$newTat, self::DB_OPTION, $currentRaw
|
||||
));
|
||||
|
||||
if ($affected === false) {
|
||||
self::logFailOpen('db_error');
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($affected > 0) {
|
||||
return $slot; // won the slot
|
||||
}
|
||||
|
||||
// Lost the race (another sender advanced the TAT). Brief jittered
|
||||
// backoff to avoid a thundering retry, then re-read and try again.
|
||||
usleep(500 + (($attempt * 211) % 1500));
|
||||
}
|
||||
|
||||
self::logFailOpen('cas_contention');
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the single TAT row exists (idempotent, once per process).
|
||||
* INSERT IGNORE is translated to INSERT OR IGNORE by the WP SQLite plugin.
|
||||
*/
|
||||
private static function ensureDbRow()
|
||||
{
|
||||
if (self::$dbRowReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$wpdb->query($wpdb->prepare(
|
||||
"INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, '0', 'no')",
|
||||
self::DB_OPTION
|
||||
));
|
||||
|
||||
self::$dbRowReady = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a fail-open event so a silently-degraded limiter is detectable. A
|
||||
* limiter that is secretly off is worse than none — it gives false
|
||||
* confidence — so this logs (sampled, to avoid flooding) whenever the cap is
|
||||
* NOT enforced for a send.
|
||||
*
|
||||
* @param string $reason
|
||||
*/
|
||||
private static function logFailOpen($reason)
|
||||
{
|
||||
self::$failOpenCount++;
|
||||
|
||||
// First few, then every 100th, to surface the problem without flooding.
|
||||
if (self::$failOpenCount <= 3 || self::$failOpenCount % 100 === 0) {
|
||||
Helper::debugLog(
|
||||
'GlobalRateLimiter fail-open',
|
||||
'reason: ' . $reason . ' (occurrence ' . self::$failOpenCount . ') — per-second rate limit NOT enforced for this send',
|
||||
'extended'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Libs\Mailer;
|
||||
|
||||
use FluentCrm\App\Hooks\Handlers\Scheduler;
|
||||
use FluentCrm\App\Models\CampaignEmail;
|
||||
use FluentCrm\App\Services\Helper;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
use FluentCrm\Framework\Support\Collection;
|
||||
use FluentCrm\Framework\Support\Str;
|
||||
|
||||
class Handler extends BaseHandler
|
||||
{
|
||||
protected $runnerTitle = 'Handler::handle';
|
||||
|
||||
protected $sendingPerChunk = 20;
|
||||
|
||||
protected $maximumProcessingTime = 50;
|
||||
|
||||
protected $optionKey = 'fluentcrm_is_sending_emails';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
/**
|
||||
* The default mailer chunk size for the main email handler.
|
||||
*
|
||||
* @param int $sendingPerChunk Number of campaign emails pulled per batch. Default is 20.
|
||||
* @return int
|
||||
*/
|
||||
$sendingPerChunk = (int)apply_filters('fluent_crm/mailer_handler_chunk_size', $this->sendingPerChunk);
|
||||
if ($sendingPerChunk > 0) {
|
||||
$this->sendingPerChunk = $sendingPerChunk;
|
||||
}
|
||||
|
||||
/**
|
||||
* The maximum processing window (seconds) for the main email handler.
|
||||
*
|
||||
* @param int $maximumProcessingTime Max loop runtime in seconds. Default is 50.
|
||||
* @return int
|
||||
*/
|
||||
$maximumProcessingTime = (int)apply_filters('fluent_crm/mailer_handler_max_processing_seconds', $this->maximumProcessingTime);
|
||||
if ($maximumProcessingTime > 0) {
|
||||
$this->maximumProcessingTime = $maximumProcessingTime;
|
||||
}
|
||||
}
|
||||
|
||||
public function handle()
|
||||
{
|
||||
if (!$this->isSystemOk()) {
|
||||
return true; // Early return
|
||||
}
|
||||
|
||||
Helper::debugLog('Running Scheduler -> ' . $this->calledFrom, 'Handler::handle');
|
||||
|
||||
Helper::maybeDisableEmojiOnEmail();
|
||||
|
||||
try {
|
||||
$this->handleFailedLog();
|
||||
$result = $this->processBatchEmails();
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
Helper::debugLog('Error at Mailer::handle', $result->get_error_message(), 'error');
|
||||
$this->releaseLock();
|
||||
$this->logSentCount();
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($result === 'time_up') {
|
||||
$this->releaseLock();
|
||||
$this->callBackGround();
|
||||
$this->logSentCount();
|
||||
return true;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Helper::debugLog('Exception at Mailer::handle', $e->getMessage(), 'error');
|
||||
}
|
||||
|
||||
$this->releaseLock();
|
||||
$this->logSentCount();
|
||||
|
||||
if ($this->sentCount || random_int(0, 50) > 20) { // sometimes we want to check this
|
||||
$lastChecked = fluentCrmGetOptionCache('_fcrm_last_email_process_cleanup', 600);
|
||||
if (!$lastChecked || time() - $lastChecked > 70) {
|
||||
// Keep stale-row recovery in the scheduler helper so all callers
|
||||
// use the same chunking, sender-lock guard, and deferred logging.
|
||||
// A direct UPDATE here can overlap with a chained ajax/cron sender
|
||||
// that is claiming rows and can reproduce the same deadlock class.
|
||||
Scheduler::resetStaleProcessingEmails($this->maximumProcessingTime + 30, $this->runnerTitle);
|
||||
fluentCrmSetOptionCache('_fcrm_last_email_process_cleanup', time(), 600);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$this->sentCount) {
|
||||
do_action('fluentcrm_scheduled_maybe_regular_tasks');
|
||||
do_action('fluent_crm_process_automation');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function isSystemOk()
|
||||
{
|
||||
$this->calledFrom = Arr::get($_REQUEST, 'action') == 'fluentcrm-post-campaigns-send-now' ? 'ajax' : 'cron';
|
||||
|
||||
// Cheap guards first — in-process re-entrancy and the hard kill-switch.
|
||||
// No point taking the lock if processing is disabled for this request.
|
||||
if (did_action('fluent_crm/sending_emails_starting') || apply_filters('fluent_crm/disable_email_processing', false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->memoryExceeded()) {
|
||||
Helper::debugLog('Mailer Memory Exceeded at ' . $this->runnerTitle, 'Memory Limit: ' . fluentCrmGetMemoryLimit() . '<br />Current Usage: ' . memory_get_usage(true));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extend PHP execution time to give the handler enough headroom.
|
||||
// The handler has its own isTimeUp() check and will stop gracefully
|
||||
// within maximumProcessingTime seconds, but PHP's max_execution_time
|
||||
// (often 30s in web context) can kill the process before that.
|
||||
if (function_exists('set_time_limit')) {
|
||||
@set_time_limit($this->maximumProcessingTime + 30);
|
||||
}
|
||||
|
||||
$systemMaxProcessingTime = fluentCrmMaxRunTime();
|
||||
|
||||
if ($this->maximumProcessingTime > $systemMaxProcessingTime) {
|
||||
$this->maximumProcessingTime = $systemMaxProcessingTime;
|
||||
}
|
||||
|
||||
// Acquire the lock before any expensive work. The atomic lock — not any
|
||||
// cron-timing pre-check — is the authoritative guard against concurrent
|
||||
// and duplicate sends, so cron, the AJAX continuation, and send-now can
|
||||
// all call handle() directly and let the loser bail right here after a
|
||||
// single atomic op. Acquiring early avoids paying for the
|
||||
// willMultiThreadEmail() COUNT(*) on a potentially multi-million-row
|
||||
// table only to discover another runner already holds the lock.
|
||||
// (memoryExceeded above is checked before this, so there is no lock to
|
||||
// release on that early return.)
|
||||
if (!$this->acquireLock()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The lock is now held. handle() calls isSystemOk() OUTSIDE its
|
||||
// try/catch, so anything that throws below (a DB error on the _last_called
|
||||
// write or the willMultiThreadEmail() count) would escape uncaught and
|
||||
// leave the lock orphaned until its ~80s TTL. Guard it here so a failure
|
||||
// releases the lock immediately instead.
|
||||
try {
|
||||
// Record the start of an actual (lock-winning) send cycle.
|
||||
// callBackGround() reads this to keep the loopback continuation alive.
|
||||
fluentcrm_update_option($this->optionKey . '_last_called', time());
|
||||
|
||||
$this->startingTimeStamp = time();
|
||||
$this->isMultiThread = Helper::willMultiThreadEmail();
|
||||
|
||||
if ($this->isMultiThread) {
|
||||
if (!as_has_scheduled_action('fluent_crm_send_multi_thread_emails', [], 'fluent-crm')) {
|
||||
Helper::debugLog('Scheduling multi thread emails', 'extended log');
|
||||
as_schedule_recurring_action(time(), 60, 'fluent_crm_send_multi_thread_emails', [], 'fluent-crm', false);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->releaseLock();
|
||||
Helper::debugLog('isSystemOk post-lock failure at ' . $this->runnerTitle, $e->getMessage(), 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function getNextBatchEmails()
|
||||
{
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'fc_campaign_emails';
|
||||
$currentTime = current_time('mysql');
|
||||
|
||||
// Atomic claim: SELECT ids then UPDATE status in a transaction.
|
||||
// The status check in the UPDATE WHERE clause prevents double-claiming
|
||||
// if another handler somehow selects the same rows.
|
||||
$wpdb->query('START TRANSACTION');
|
||||
|
||||
$rows = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT id FROM {$table} WHERE status IN ('pending', 'scheduled') AND scheduled_at <= %s ORDER BY scheduled_at ASC LIMIT %d FOR UPDATE",
|
||||
$currentTime, $this->sendingPerChunk
|
||||
));
|
||||
|
||||
$ids = wp_list_pluck($rows, 'id');
|
||||
|
||||
if ($ids) {
|
||||
$idsPlaceholder = implode(',', array_fill(0, count($ids), '%d'));
|
||||
$result = $wpdb->query($wpdb->prepare(
|
||||
"UPDATE {$table} SET status = 'processing', updated_at = %s WHERE id IN ($idsPlaceholder) AND status IN ('pending', 'scheduled')",
|
||||
array_merge([$currentTime], $ids)
|
||||
));
|
||||
|
||||
if ($result === false) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
return new Collection([]);
|
||||
}
|
||||
}
|
||||
|
||||
$wpdb->query('COMMIT');
|
||||
|
||||
if (!$ids) {
|
||||
return new Collection([]);
|
||||
}
|
||||
|
||||
// Only return rows we actually claimed (status = processing)
|
||||
return CampaignEmail::whereIn('id', $ids)
|
||||
->where('status', 'processing')
|
||||
->with(['campaign', 'subscriber'])
|
||||
->get();
|
||||
}
|
||||
|
||||
public function processSubscriberEmail($subscriberId)
|
||||
{
|
||||
if (!$this->isSystemOk()) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'fc_campaign_emails';
|
||||
$currentTime = current_time('mysql');
|
||||
|
||||
$wpdb->query('START TRANSACTION');
|
||||
|
||||
$rows = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT id FROM {$table} WHERE status IN ('pending', 'scheduled') AND scheduled_at <= %s AND scheduled_at IS NOT NULL AND subscriber_id = %d FOR UPDATE",
|
||||
$currentTime, $subscriberId
|
||||
));
|
||||
|
||||
$ids = wp_list_pluck($rows, 'id');
|
||||
|
||||
if ($ids) {
|
||||
$idsPlaceholder = implode(',', array_fill(0, count($ids), '%d'));
|
||||
$result = $wpdb->query($wpdb->prepare(
|
||||
"UPDATE {$table} SET status = 'processing', updated_at = %s WHERE id IN ($idsPlaceholder) AND status IN ('pending', 'scheduled')",
|
||||
array_merge([$currentTime], $ids)
|
||||
));
|
||||
|
||||
if ($result === false) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
$this->releaseLock();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$wpdb->query('COMMIT');
|
||||
|
||||
if ($ids) {
|
||||
$emailCollection = CampaignEmail::whereIn('id', $ids)
|
||||
->where('status', 'processing')
|
||||
->with('campaign', 'subscriber')
|
||||
->get();
|
||||
|
||||
$this->sendEmails($emailCollection);
|
||||
}
|
||||
|
||||
$this->releaseLock();
|
||||
}
|
||||
|
||||
public function sendDoubleOptInEmail($subscriber)
|
||||
{
|
||||
if ($subscriber->status == 'subscribed' || !$subscriber->email) {
|
||||
return false; // already subscribed
|
||||
}
|
||||
|
||||
$listIdOfSubscriber = Helper::latestListIdOfSubscriber($subscriber->id);
|
||||
$config = null;
|
||||
if ($listIdOfSubscriber) {
|
||||
$globalDoubleOptin = fluentcrm_get_list_meta($listIdOfSubscriber, 'global_double_optin');
|
||||
if ($globalDoubleOptin && $globalDoubleOptin->value == 'no') {
|
||||
$meta = fluentcrm_get_meta($listIdOfSubscriber, 'FluentCrm\App\Models\Lists', 'double_optin_settings');
|
||||
$config = $meta ? $meta->value : null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$config) {
|
||||
$config = Helper::getDoubleOptinSettings();
|
||||
}
|
||||
|
||||
if (!Arr::get($config, 'email_subject') || !Arr::get($config, 'email_body')) {
|
||||
return false; // is not valid
|
||||
}
|
||||
|
||||
$emailBody = apply_filters('fluent_crm/parse_campaign_email_text', $config['email_body'], $subscriber);
|
||||
$emailSubject = apply_filters('fluent_crm/parse_campaign_email_text', $config['email_subject'], $subscriber);
|
||||
|
||||
$emailPreHeader = '';
|
||||
if (Arr::get($config, 'email_pre_header')) {
|
||||
$emailPreHeader = apply_filters('fluent_crm/parse_campaign_email_text', $config['email_pre_header'], $subscriber);
|
||||
}
|
||||
|
||||
$url = site_url('?fluentcrm=1&route=confirmation&hash=' . $subscriber->hash . '&secure_hash=' . $subscriber->getSecureHash());
|
||||
|
||||
$emailBody = apply_filters('fluent_crm/double_optin_email_body', $emailBody, $subscriber);
|
||||
$emailSubject = apply_filters('fluent_crm/double_optin_email_subject', $emailSubject, $subscriber);
|
||||
$emailPreHeader = apply_filters('fluent_crm/double_optin_email_pre_header', $emailPreHeader, $subscriber);
|
||||
|
||||
$emailBody = str_replace('#activate_link#', $url, $emailBody);
|
||||
|
||||
$templateData = [
|
||||
'preHeader' => $emailPreHeader,
|
||||
'email_body' => $emailBody,
|
||||
'footer_text' => '',
|
||||
'config' => Helper::getTemplateConfig($config['design_template'], false)
|
||||
];
|
||||
|
||||
$emailBody = apply_filters(
|
||||
'fluent_crm/email-design-template-' . $config['design_template'],
|
||||
$emailBody,
|
||||
$templateData,
|
||||
false,
|
||||
$subscriber
|
||||
);
|
||||
|
||||
if (Str::contains($emailBody, ['##crm.', '{{crm.'])) {
|
||||
// we have CRM specific smartcodes
|
||||
$emailBody = apply_filters('fluent_crm/parse_extended_crm_text', $emailBody, $subscriber);
|
||||
}
|
||||
|
||||
$data = [
|
||||
'to' => [
|
||||
'email' => $subscriber->email,
|
||||
'name' => $subscriber->full_name
|
||||
],
|
||||
'subject' => $emailSubject,
|
||||
'body' => $emailBody,
|
||||
'headers' => Helper::getMailHeader(),
|
||||
'scope' => 'double_optin'
|
||||
];
|
||||
|
||||
Helper::maybeDisableEmojiOnEmail();
|
||||
Mailer::send($data, $subscriber, null, true); // want to send without any rate-limiting checking
|
||||
return true;
|
||||
}
|
||||
|
||||
private function callBackGround()
|
||||
{
|
||||
if ($this->memoryExceeded()) {
|
||||
Helper::debugLog('Handler::callBackGround Memory Exceeded', 'Memory Limit: ' . fluentCrmGetMemoryLimit() . '<br />Current Usage: ' . memory_get_usage(true), 'info');
|
||||
return false;
|
||||
}
|
||||
|
||||
$nextCron = as_next_scheduled_action('fluentcrm_scheduled_every_minute_tasks');
|
||||
$willRun = !$nextCron || $nextCron == 1 || ($nextCron - time()) >= 5 || ($nextCron - time()) < -70;
|
||||
|
||||
if (!$willRun) {
|
||||
$lastCalled = (int)fluentcrm_get_option($this->optionKey . '_last_called');
|
||||
if ($lastCalled && (time() - $lastCalled) < 50) {
|
||||
$willRun = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($willRun) {
|
||||
|
||||
$url = add_query_arg([
|
||||
'action' => 'fluentcrm-post-campaigns-send-now',
|
||||
'time' => time()
|
||||
], admin_url('admin-ajax.php'));
|
||||
|
||||
Helper::debugLog('Sent to Background Handler::callBackGround', $url, 'extended');
|
||||
|
||||
self::fireNonBlockingRequest($url, [
|
||||
'campaign_id' => null,
|
||||
'retry' => 1
|
||||
]);
|
||||
} else {
|
||||
Helper::debugLog('Not Running', 'Handler::callBackGround -> ' . ($nextCron - time()), 'extended');
|
||||
}
|
||||
}
|
||||
|
||||
protected function isTimeUp()
|
||||
{
|
||||
return (time() - $this->startingTimeStamp) >= $this->maximumProcessingTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a non-blocking POST request to continue the sender chain.
|
||||
*
|
||||
* cURL stays the first transport because it bypasses WP_Http SSL filters
|
||||
* that can break local/self-signed loopbacks. If cURL times out or fails,
|
||||
* fall back silently to WordPress HTTP and log only in FluentCRM debug logs.
|
||||
*
|
||||
* @param string $url
|
||||
* @param array $body POST body data
|
||||
*/
|
||||
public static function fireNonBlockingRequest($url, $body = [])
|
||||
{
|
||||
$timeout = max(1, (int)apply_filters('fluent_crm/non_blocking_request_timeout', 3, $url, $body));
|
||||
$connectTimeout = max(1, (int)apply_filters('fluent_crm/non_blocking_request_connect_timeout', 2, $url, $body));
|
||||
|
||||
if (apply_filters('fluent_crm/non_blocking_request_use_wp_http', false, $url, $body)) {
|
||||
self::fireNonBlockingWpRequest($url, $body, $timeout);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!function_exists('curl_init')) {
|
||||
self::fireNonBlockingWpRequest($url, $body, $timeout);
|
||||
return;
|
||||
}
|
||||
|
||||
$ch = curl_init($url);
|
||||
if (!$ch) {
|
||||
Helper::debugLog('FluentCRM non-blocking cURL request failed', 'Unable to initialize cURL. URL: ' . esc_url_raw($url), 'extended');
|
||||
self::fireNonBlockingWpRequest($url, $body, $timeout);
|
||||
return;
|
||||
}
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query($body),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
CURLOPT_SSL_VERIFYHOST => false,
|
||||
CURLOPT_TIMEOUT => $timeout,
|
||||
CURLOPT_CONNECTTIMEOUT => $connectTimeout,
|
||||
CURLOPT_NOSIGNAL => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/x-www-form-urlencoded',
|
||||
],
|
||||
]);
|
||||
|
||||
// Fire and forget — we don't need the response
|
||||
$response = curl_exec($ch);
|
||||
$errorNo = curl_errno($ch);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if (!$errorNo && $response !== false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$errorMessage = $errorNo ? ('Error #' . $errorNo . ': ' . $error) : 'Unknown cURL failure';
|
||||
Helper::debugLog('FluentCRM non-blocking cURL request failed', $errorMessage . ' URL: ' . esc_url_raw($url), 'extended');
|
||||
|
||||
self::fireNonBlockingWpRequest($url, $body, $timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire the sender-chain request via WordPress HTTP as a fallback transport.
|
||||
*
|
||||
* @param string $url
|
||||
* @param array $body
|
||||
* @param int $timeout
|
||||
*/
|
||||
private static function fireNonBlockingWpRequest($url, $body, $timeout)
|
||||
{
|
||||
add_filter('https_local_ssl_verify', '__return_false');
|
||||
$response = wp_remote_post($url, [
|
||||
'sslverify' => false,
|
||||
'blocking' => false,
|
||||
'timeout' => $timeout,
|
||||
'body' => $body
|
||||
]);
|
||||
remove_filter('https_local_ssl_verify', '__return_false');
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
Helper::debugLog('FluentCRM non-blocking WP HTTP request failed', $response->get_error_message() . ' URL: ' . esc_url_raw($url), 'extended');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Libs\Mailer;
|
||||
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
class Mailer
|
||||
{
|
||||
public static function send($data, $subscriber = null, $emailModel = null, $preThrottled = false)
|
||||
{
|
||||
|
||||
$headers = static::buildHeaders($data, $subscriber, $emailModel);
|
||||
|
||||
if (apply_filters('fluent_crm/is_simulated_mail', false, $data, $headers)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$to = $data['to']['email'];
|
||||
|
||||
if (!$to) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (self::willIncludeName()) {
|
||||
if ($name = Arr::get($data, 'to.name')) {
|
||||
$name = sanitize_text_field($name);
|
||||
// If the name contains a comma, we need to wrap it in double quotes to prevent issues with email clients
|
||||
if (strpos($name, ',') !== false) {
|
||||
$name = '"' . str_replace('"', '\"', $name) . '"';
|
||||
}
|
||||
|
||||
$to = $name . ' <' . $to . '>';
|
||||
}
|
||||
}
|
||||
|
||||
// Global cross-process rate cap. Every email — campaigns, automation,
|
||||
// double opt-in, transactional — funnels through here, so this is the
|
||||
// single point that holds the install's aggregate send rate within the
|
||||
// provider's per-second limit. Fail-open: never blocks if its store is
|
||||
// unavailable. Placed after the simulated-mail / empty-recipient guards
|
||||
// so only real dispatches consume a slot.
|
||||
//
|
||||
// The bulk handlers reserve their slot BEFORE marking the row sent (so a
|
||||
// crash mid-wait leaves it recoverable) and pass $preThrottled=true to
|
||||
// skip a second reservation here. Direct callers (double opt-in, etc.)
|
||||
// leave it false and get throttled here.
|
||||
if (!$preThrottled) {
|
||||
GlobalRateLimiter::throttle($data);
|
||||
}
|
||||
|
||||
return wp_mail(
|
||||
$to,
|
||||
$data['subject'],
|
||||
$data['body'],
|
||||
$headers
|
||||
);
|
||||
}
|
||||
|
||||
protected static function buildHeaders($data, $subscriber = null, $emailModel = null)
|
||||
{
|
||||
$data = apply_filters('fluent_crm/email_data_before_headers', $data, $subscriber, $emailModel);
|
||||
|
||||
$headers = [];
|
||||
|
||||
$contentType = Arr::get($data, 'headers.Content-Type');
|
||||
if ($contentType) {
|
||||
$headers[] = "Content-Type: {$contentType}";
|
||||
} else {
|
||||
$headers[] = "Content-Type: text/html; charset=UTF-8";
|
||||
}
|
||||
|
||||
$from = Arr::get($data, 'headers.From');
|
||||
$replyTo = Arr::get($data, 'headers.Reply-To');
|
||||
|
||||
if ($from) {
|
||||
$headers[] = "From: {$from}";
|
||||
}
|
||||
|
||||
// Set Reply-To Header
|
||||
if ($replyTo) {
|
||||
$headers[] = "Reply-To: {$replyTo}";
|
||||
}
|
||||
|
||||
if ($subscriber && apply_filters('fluent_crm/enable_unsub_header', true, $data, $subscriber, $emailModel)) {
|
||||
$campaign = ($emailModel && $emailModel->campaign) ? $emailModel->campaign : null;
|
||||
$isTransactional = $campaign && Arr::get($campaign->settings, 'is_transactional') == 'yes';
|
||||
if (!$isTransactional) {
|
||||
$args = [
|
||||
'fluentcrm' => 1,
|
||||
'route' => 'unsubscribe',
|
||||
'secure_hash' => fluentCrmGetContactManagedHash($subscriber->id)
|
||||
];
|
||||
if ($emailModel) {
|
||||
$args['ce_id'] = $emailModel->id;
|
||||
}
|
||||
|
||||
$unsubscribeUrl = add_query_arg($args, site_url('index.php'));
|
||||
|
||||
$headers[] = "List-Unsubscribe: <{$unsubscribeUrl}>";
|
||||
$headers[] = "List-Unsubscribe-Post: List-Unsubscribe=One-Click";
|
||||
}
|
||||
}
|
||||
|
||||
return apply_filters('fluent_crm/email_headers', $headers, $data, $subscriber, $emailModel);
|
||||
}
|
||||
|
||||
private static function willIncludeName()
|
||||
{
|
||||
static $status = null;
|
||||
if ($status !== null) {
|
||||
return $status;
|
||||
}
|
||||
$status = apply_filters('fluent_crm/enable_mailer_to_name', true);
|
||||
return $status;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Services\Libs\Mailer;
|
||||
|
||||
use FluentCrm\App\Models\CampaignEmail;
|
||||
use FluentCrm\App\Services\Helper;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
use FluentCrm\Framework\Support\Collection;
|
||||
|
||||
class MultiThreadHandler extends BaseHandler
|
||||
{
|
||||
|
||||
protected $runnerTitle = 'MultiThreadHandler::handle';
|
||||
|
||||
protected $sendingPerChunk = 20;
|
||||
|
||||
protected $maximumProcessingTime = 50;
|
||||
|
||||
protected $optionKey = 'fluentcrm_is_sending_multi_emails';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
/**
|
||||
* The mailer chunk size for the multi-thread email handler.
|
||||
*
|
||||
* @param int $sendingPerChunk Number of campaign emails pulled per batch. Default is 20.
|
||||
* @return int
|
||||
*/
|
||||
$sendingPerChunk = (int)apply_filters('fluent_crm/mailer_multi_thread_chunk_size', $this->sendingPerChunk);
|
||||
if ($sendingPerChunk > 0) {
|
||||
$this->sendingPerChunk = $sendingPerChunk;
|
||||
}
|
||||
|
||||
/**
|
||||
* The maximum processing window (seconds) for the multi-thread email handler.
|
||||
*
|
||||
* @param int $maximumProcessingTime Max loop runtime in seconds. Default is 50.
|
||||
* @return int
|
||||
*/
|
||||
$maximumProcessingTime = (int)apply_filters('fluent_crm/mailer_multi_thread_max_processing_seconds', $this->maximumProcessingTime);
|
||||
if ($maximumProcessingTime > 0) {
|
||||
$this->maximumProcessingTime = $maximumProcessingTime;
|
||||
}
|
||||
}
|
||||
|
||||
public function handle()
|
||||
{
|
||||
if (!$this->isSystemOk()) {
|
||||
return true; // Early return
|
||||
}
|
||||
|
||||
Helper::maybeDisableEmojiOnEmail();
|
||||
|
||||
try {
|
||||
$this->handleFailedLog();
|
||||
$result = $this->processBatchEmails();
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
$this->releaseLock();
|
||||
$this->logSentCount();
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($result === 'time_up') {
|
||||
$this->releaseLock();
|
||||
$this->callBackGround();
|
||||
$this->logSentCount();
|
||||
return true;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Helper::debugLog('Exception at ' . $this->runnerTitle, $e->getMessage(), 'error');
|
||||
}
|
||||
|
||||
$this->logSentCount();
|
||||
$this->releaseLock();
|
||||
return true;
|
||||
}
|
||||
|
||||
private function isSystemOk()
|
||||
{
|
||||
$this->calledFrom = Arr::get($_REQUEST, 'action') == 'fluentcrm-post-multi-thread-send-now' ? 'ajax' : 'cron';
|
||||
|
||||
// Cheap guards first — in-process re-entrancy and the hard kill-switch.
|
||||
if (
|
||||
did_action('fluent_crm/sending_multi_threading_email') ||
|
||||
apply_filters('fluent_crm/disable_email_processing', false)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->memoryExceeded()) {
|
||||
Helper::debugLog('Mailer Memory Exceeded at ' . $this->runnerTitle, 'Memory Limit: ' . fluentCrmGetMemoryLimit() . '<br />Current Usage: ' . memory_get_usage(true));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (function_exists('set_time_limit')) {
|
||||
@set_time_limit($this->maximumProcessingTime + 30);
|
||||
}
|
||||
|
||||
$systemMaxProcessingTime = fluentCrmMaxRunTime();
|
||||
|
||||
if ($this->maximumProcessingTime > $systemMaxProcessingTime) {
|
||||
$this->maximumProcessingTime = $systemMaxProcessingTime;
|
||||
}
|
||||
|
||||
// Acquire the lock before the willMultiThreadEmail() COUNT(*). The
|
||||
// atomic lock is the authoritative guard against concurrent runners, so
|
||||
// a losing racer bails here after a single atomic op instead of paying
|
||||
// for the count query. (memoryExceeded above runs before this, so it
|
||||
// has no lock to release.)
|
||||
if (!$this->acquireLock()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The lock is now held, and handle() calls isSystemOk() OUTSIDE its
|
||||
// try/catch. Guard every path below so a thrown error (the
|
||||
// willMultiThreadEmail() count, the cancel scheduling, or the
|
||||
// _last_called write) releases the lock instead of orphaning it until
|
||||
// the ~80s TTL.
|
||||
try {
|
||||
if (!Helper::willMultiThreadEmail(300)) {
|
||||
as_schedule_single_action(time() + 1, 'fluent_crm_cancel_multi_thread_mailing', [], 'fluent-crm', true);
|
||||
$this->releaseLock();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Record the start of an actual (lock-winning) send cycle.
|
||||
// callBackGround() reads this to keep the loopback continuation alive.
|
||||
fluentcrm_update_option($this->optionKey . '_last_called', time());
|
||||
|
||||
$this->isMultiThread = true;
|
||||
$this->startingTimeStamp = time();
|
||||
} catch (\Throwable $e) {
|
||||
$this->releaseLock();
|
||||
Helper::debugLog('isSystemOk post-lock failure at ' . $this->runnerTitle, $e->getMessage(), 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function getNextBatchEmails()
|
||||
{
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'fc_campaign_emails';
|
||||
$currentTime = current_time('mysql');
|
||||
|
||||
/**
|
||||
* Filter the queue offset used by the multi-thread email handler.
|
||||
*
|
||||
* @param int $offset Queue offset. Default is 250.
|
||||
* @return int
|
||||
*/
|
||||
$offset = (int)apply_filters('fluent_crm/mailer_multi_thread_offset', 250);
|
||||
if ($offset < 0) {
|
||||
$offset = 0;
|
||||
}
|
||||
|
||||
$wpdb->query('START TRANSACTION');
|
||||
|
||||
$rows = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT id FROM {$table} WHERE status IN ('pending', 'scheduled') AND scheduled_at <= %s ORDER BY scheduled_at DESC LIMIT %d, %d FOR UPDATE",
|
||||
$currentTime, $offset, $this->sendingPerChunk
|
||||
));
|
||||
|
||||
$ids = wp_list_pluck($rows, 'id');
|
||||
|
||||
if ($ids) {
|
||||
$idsPlaceholder = implode(',', array_fill(0, count($ids), '%d'));
|
||||
$result = $wpdb->query($wpdb->prepare(
|
||||
"UPDATE {$table} SET status = 'processing', updated_at = %s WHERE id IN ($idsPlaceholder) AND status IN ('pending', 'scheduled')",
|
||||
array_merge([$currentTime], $ids)
|
||||
));
|
||||
|
||||
if ($result === false) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
return new Collection([]);
|
||||
}
|
||||
}
|
||||
|
||||
$wpdb->query('COMMIT');
|
||||
|
||||
if (!$ids) {
|
||||
return new Collection([]);
|
||||
}
|
||||
|
||||
// Only return rows we actually claimed (status = processing)
|
||||
return CampaignEmail::whereIn('id', $ids)
|
||||
->where('status', 'processing')
|
||||
->with('campaign', 'subscriber')
|
||||
->get();
|
||||
}
|
||||
|
||||
protected function isTimeUp()
|
||||
{
|
||||
return (time() - $this->startingTimeStamp) >= $this->maximumProcessingTime;
|
||||
}
|
||||
|
||||
private function callBackGround()
|
||||
{
|
||||
if ($this->memoryExceeded()) {
|
||||
Helper::debugLog('Memory Exceeded at MultiThreadHandler::callBackGround', 'Memory Limit: ' . fluentCrmGetMemoryLimit() . '<br />Current Usage: ' . memory_get_usage(true));
|
||||
return;
|
||||
}
|
||||
|
||||
$nextCron = as_next_scheduled_action('fluent_crm_send_multi_thread_emails');
|
||||
$willRun = !$nextCron || $nextCron == 1 || ($nextCron - time()) >= 5 || ($nextCron - time()) < -70;
|
||||
|
||||
|
||||
if (!$willRun) {
|
||||
$lastCalled = (int)fluentcrm_get_option($this->optionKey . '_last_called');
|
||||
if ($lastCalled && (time() - $lastCalled) < 50) {
|
||||
$willRun = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($willRun) {
|
||||
$url = add_query_arg([
|
||||
'action' => 'fluentcrm-post-multi-thread-send-now',
|
||||
'time' => time()
|
||||
], admin_url('admin-ajax.php'));
|
||||
|
||||
Handler::fireNonBlockingRequest($url, [
|
||||
'campaign_id' => null,
|
||||
'retry' => 1
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user