is_sendable() ) { return false; } $queued = $this->locked( function (): array { $pending = $this->pending(); /* * Bounded. With no cap, an SMTP outage grew this option by one * payload per submission until the row — which is read and written * whole on every push — became large enough to be its own outage. * Dropping the oldest keeps the queue moving and the failure visible. */ if ( count( $pending ) >= self::MAX_PENDING ) { $dropped = array_shift( $pending ); /** * Fires when a queued notification is dropped to keep the queue bounded. * * @since 1.0.0 * * @param array $notification Payload that was dropped. * @param int $size Size of the queue when it happened. */ do_action( 'formpilot_notification_dropped', is_array( $dropped ) ? $dropped : array(), self::MAX_PENDING ); } return $pending; }, $notification->to_array() ); /* * Not queued - the lock stayed busy, or the option could not be written - * so it is sent now. A slow response is better than a lost notification, * which is what used to happen, with `true` returned regardless. */ if ( ! $queued ) { return $this->mailer->send( $notification ); } $this->schedule(); return true; } /** * Makes sure a drain is due, without stacking events. */ private function schedule(): void { if ( ! wp_next_scheduled( self::HOOK ) ) { wp_schedule_single_event( time() + MINUTE_IN_SECONDS, self::HOOK ); } } /** * Delivers a batch of pending notifications. * * Hooked to {@see CronQueue::HOOK}. */ public function process(): int { $batch = array(); /* * Taking the batch and writing the remainder back is one critical section. * WP-Cron has no mutex, so two overlapping runs used to read the same first * twenty payloads and both send them — every recipient got the message * twice — and a push landing between another push's read and write was lost * outright, with nothing reporting either. */ $this->locked( function () use ( &$batch ): array { $pending = $this->pending(); $batch = array_slice( $pending, 0, self::BATCH ); return array_slice( $pending, self::BATCH ); } ); if ( array() === $batch ) { return 0; } $sent = 0; foreach ( $batch as $payload ) { if ( ! is_array( $payload ) ) { continue; } if ( $this->mailer->send( Notification::from_array( $payload ) ) ) { ++$sent; } } // Only when something is actually left, so an empty queue stops scheduling. if ( array() !== $this->pending() ) { $this->schedule(); } return $sent; } /** * Runs a read-modify-write against the queue option under a lock. * * The lock is {@see Lock}, an `INSERT IGNORE` on a row of the options table, * so the database decides who holds it. It used to be `add_option()`, which * is not atomic - it asks a cacheable `get_option()` and then upserts - and a * caller that still had no lock after a few tries wrote anyway. Two requests * could both "hold" it, and the read-modify-write below then lost mail or sent * a batch twice. Now nothing is written without the lock: the caller is told, * and decides what to do instead. * * @param callable $mutate Receives nothing, returns the new queue. * @param array $append Optional. Payload appended after the callback runs. * * @return bool True when the write happened. */ private function locked( callable $mutate, array $append = array() ): bool { $token = ''; $written = false; for ( $attempt = 0; $attempt < self::LOCK_TRIES; $attempt++ ) { $token = Lock::acquire( self::LOCK, self::LOCK_LIFETIME ); if ( '' !== $token ) { break; } usleep( self::LOCK_WAIT ); } if ( '' === $token ) { return false; } try { $pending = (array) $mutate(); if ( array() !== $append ) { $pending[] = $append; } /* * The write, and only the write. Scheduling belongs to the callers: * doing it here would have `process()` — which runs *from* the hook, so * `wp_next_scheduled()` is false while it runs — schedule another run * every time, including when it drained the queue to nothing. That is a * cron event every minute for ever on a site sending no mail at all. */ $written = update_option( self::OPTION, array_values( $pending ), false ); } finally { Lock::release( self::LOCK, $token ); } return $written; } /** * Returns the pending notifications. * * @return array> Stored payloads. */ public function pending(): array { /* * Read past the cache. The option is not autoloaded and is written by other * requests, so a value cached earlier in this one can be stale — which under * a lock is exactly the read that must not be. */ wp_cache_delete( self::OPTION, 'options' ); $pending = get_option( self::OPTION, array() ); return is_array( $pending ) ? array_values( $pending ) : array(); } }