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' ); } } }