diff --git a/mailpoet/lib/Segments/WP.php b/mailpoet/lib/Segments/WP.php index 016e27d204..9e515cffd8 100644 --- a/mailpoet/lib/Segments/WP.php +++ b/mailpoet/lib/Segments/WP.php @@ -219,6 +219,7 @@ class WP { $this->updateFirstNameIfMissing(); $this->insertUsersToSegment(); $this->removeOrphanedSubscribers(); + $this->subscribersRepository->invalidateTotalSubscribersCache(); return true; } diff --git a/mailpoet/lib/Segments/WooCommerce.php b/mailpoet/lib/Segments/WooCommerce.php index 4aadc42554..803011f6fb 100644 --- a/mailpoet/lib/Segments/WooCommerce.php +++ b/mailpoet/lib/Segments/WooCommerce.php @@ -188,6 +188,7 @@ class WooCommerce { $this->updateGlobalStatus(); } + $this->subscribersRepository->invalidateTotalSubscribersCache(); return $lastCheckedOrderId; } diff --git a/mailpoet/lib/Subscribers/ImportExport/Import/Import.php b/mailpoet/lib/Subscribers/ImportExport/Import/Import.php index 4f5c73b106..7d82e6bbb8 100644 --- a/mailpoet/lib/Subscribers/ImportExport/Import/Import.php +++ b/mailpoet/lib/Subscribers/ImportExport/Import/Import.php @@ -512,6 +512,8 @@ class Import { } } if (empty($createdOrUpdatedSubscribers)) return null; + + $this->subscriberRepository->invalidateTotalSubscribersCache(); $createdOrUpdatedSubscribersIds = array_column($createdOrUpdatedSubscribers, 'id'); if ($subscribersCustomFields) { $this->createOrUpdateCustomFields( @@ -588,7 +590,9 @@ class Import { * @return array */ public function synchronizeWPUsers(array $wpUsers): array { - return array_map([$this->wpSegment, 'synchronizeUser'], $wpUsers); + $users = array_map([$this->wpSegment, 'synchronizeUser'], $wpUsers); + $this->subscriberRepository->invalidateTotalSubscribersCache(); + return $users; } public function addSubscribersToSegments(array $subscribersIds, array $segmentsIds): void { diff --git a/mailpoet/lib/Subscribers/SubscribersRepository.php b/mailpoet/lib/Subscribers/SubscribersRepository.php index 671d361fa6..3e9b2c401b 100644 --- a/mailpoet/lib/Subscribers/SubscribersRepository.php +++ b/mailpoet/lib/Subscribers/SubscribersRepository.php @@ -7,6 +7,7 @@ use MailPoet\Entities\SegmentEntity; use MailPoet\Entities\SubscriberCustomFieldEntity; use MailPoet\Entities\SubscriberEntity; use MailPoet\Entities\SubscriberSegmentEntity; +use MailPoet\Util\License\Features\Subscribers; use MailPoet\WP\Functions as WPFunctions; use MailPoetVendor\Carbon\Carbon; use MailPoetVendor\Carbon\CarbonImmutable; @@ -41,10 +42,7 @@ class SubscribersRepository extends Repository { return SubscriberEntity::class; } - /** - * @return int - */ - public function getTotalSubscribers() { + public function getTotalSubscribers(): int { $query = $this->entityManager ->createQueryBuilder() ->select('count(n.id)') @@ -59,6 +57,10 @@ class SubscribersRepository extends Repository { return (int)$query->getSingleScalarResult(); } + public function invalidateTotalSubscribersCache(): void { + $this->wp->deleteTransient(Subscribers::SUBSCRIBERS_COUNT_CACHE_KEY); + } + public function findBySegment(int $segmentId): array { return $this->entityManager ->createQueryBuilder() @@ -116,6 +118,7 @@ class SubscribersRepository extends Repository { ->setParameter('ids', $ids) ->getQuery()->execute(); + $this->invalidateTotalSubscribersCache(); return count($ids); } @@ -135,6 +138,7 @@ class SubscribersRepository extends Repository { ->setParameter('ids', $ids) ->getQuery()->execute(); + $this->invalidateTotalSubscribersCache(); return count($ids); } @@ -171,6 +175,7 @@ class SubscribersRepository extends Repository { ->getQuery()->execute(); }); + $this->invalidateTotalSubscribersCache(); return $count; } @@ -282,6 +287,7 @@ class SubscribersRepository extends Repository { ->setParameter('ids', $ids) ->getQuery()->execute(); + $this->invalidateTotalSubscribersCache(); return count($ids); } diff --git a/mailpoet/lib/Util/License/Features/Subscribers.php b/mailpoet/lib/Util/License/Features/Subscribers.php index 2400cc3471..48e230313c 100644 --- a/mailpoet/lib/Util/License/Features/Subscribers.php +++ b/mailpoet/lib/Util/License/Features/Subscribers.php @@ -5,6 +5,7 @@ namespace MailPoet\Util\License\Features; use MailPoet\Services\Bridge; use MailPoet\Settings\SettingsController; use MailPoet\Subscribers\SubscribersRepository; +use MailPoet\WP\Functions as WPFunctions; class Subscribers { const SUBSCRIBERS_OLD_LIMIT = 2000; @@ -18,6 +19,9 @@ class Subscribers { const PREMIUM_EMAIL_VOLUME_LIMIT_SETTING_KEY = 'premium.premium_key_state.data.email_volume_limit'; const PREMIUM_EMAILS_SENT_SETTING_KEY = 'premium.premium_key_state.data.emails_sent'; const PREMIUM_SUPPORT_SETTING_KEY = 'premium.premium_key_state.data.support_tier'; + const SUBSCRIBERS_COUNT_CACHE_KEY = 'mailpoet_subscribers_count'; + const SUBSCRIBERS_COUNT_CACHE_EXPIRATION_MINUTES = 60; + const SUBSCRIBERS_COUNT_CACHE_MIN_VALUE = 1000; /** @var SettingsController */ private $settings; @@ -25,15 +29,20 @@ class Subscribers { /** @var SubscribersRepository */ private $subscribersRepository; + /** @var WPFunctions */ + private $wp; + public function __construct( SettingsController $settings, - SubscribersRepository $subscribersRepository + SubscribersRepository $subscribersRepository, + WPFunctions $wp ) { $this->settings = $settings; $this->subscribersRepository = $subscribersRepository; + $this->wp = $wp; } - public function check() { + public function check(): bool { $limit = $this->getSubscribersLimit(); if ($limit === false) return false; $subscribersCount = $this->getSubscribersCount(); @@ -49,11 +58,21 @@ class Subscribers { return $emailsSent > $emailVolumeLimit; } - public function getSubscribersCount() { - return $this->subscribersRepository->getTotalSubscribers(); + public function getSubscribersCount(): int { + $count = $this->wp->getTransient(self::SUBSCRIBERS_COUNT_CACHE_KEY); + if (is_numeric($count)) { + return (int)$count; + } + $count = $this->subscribersRepository->getTotalSubscribers(); + + // cache only when number of subscribers exceeds minimum value + if ($count > self::SUBSCRIBERS_COUNT_CACHE_MIN_VALUE) { + $this->wp->setTransient(self::SUBSCRIBERS_COUNT_CACHE_KEY, $count, self::SUBSCRIBERS_COUNT_CACHE_EXPIRATION_MINUTES * 60); + } + return $count; } - public function hasValidApiKey() { + public function hasValidApiKey(): bool { return $this->hasValidMssKey() || $this->hasValidPremiumKey(); } diff --git a/mailpoet/tests/integration/Util/License/Features/SubscribersTest.php b/mailpoet/tests/integration/Util/License/Features/SubscribersTest.php new file mode 100644 index 0000000000..9352e05026 --- /dev/null +++ b/mailpoet/tests/integration/Util/License/Features/SubscribersTest.php @@ -0,0 +1,137 @@ +subscribers = $this->diContainer->get(Subscribers::class); + $this->subscribersRepository = $this->diContainer->get(SubscribersRepository::class); + $this->wp = $this->diContainer->get(WPFunctions::class); + $this->wp->deleteTransient(Subscribers::SUBSCRIBERS_COUNT_CACHE_KEY); + } + + public function testItComputesSubscribersCount() { + // no subscribers + $count = $this->subscribers->getSubscribersCount(); + expect($count)->same(0); + + // create some subscribers (unconfirmed, subscribed, and inactive should be counted) + $this->createSubscriber('unconfirmed@fake.loc', SubscriberEntity::STATUS_UNCONFIRMED); + $this->createSubscriber('subscribed@fake.loc', SubscriberEntity::STATUS_SUBSCRIBED); + $this->createSubscriber('inactive@fake.loc', SubscriberEntity::STATUS_INACTIVE); + + // check count + $count = $this->subscribers->getSubscribersCount(); + expect($count)->same(3); + + // add more subscribers (bounced, unsubscribed, and trashed should not be counted) + $this->createSubscriber('bounced@fake.loc', SubscriberEntity::STATUS_BOUNCED); + $this->createSubscriber('unsubscribed@fake.loc', SubscriberEntity::STATUS_UNSUBSCRIBED); + $trashed = $this->createSubscriber('trashed@fake.loc', SubscriberEntity::STATUS_SUBSCRIBED); + $trashed->setDeletedAt(new \DateTimeImmutable()); + $this->subscribersRepository->flush(); + + // check count + $count = $this->subscribers->getSubscribersCount(); + expect($count)->same(3); + } + + public function testItDoesntCacheSubscribersCountForLowValues() { + // no subscribers + $count = $this->subscribers->getSubscribersCount(); + expect($count)->same(0); + + // add subscriber + $this->createSubscriber('one@fake.loc', SubscriberEntity::STATUS_SUBSCRIBED); + $count = $this->subscribers->getSubscribersCount(); + expect($count)->same(1); + + // add another subscriber (count updates without cache purging) + $this->createSubscriber('two@fake.loc', SubscriberEntity::STATUS_SUBSCRIBED); + $count = $this->subscribers->getSubscribersCount(); + expect($count)->same(2); + } + + public function testItCachesSubscribersCountForHighValues() { + $subscribers = $this->getServiceWithOverrides(Subscribers::class, [ + 'subscribersRepository' => Stub::make(SubscribersRepository::class, [ + 'getTotalSubscribers' => 123456, + ]), + ]); + + $count = $subscribers->getSubscribersCount(); + expect($count)->same(123456); + + $subscribers = $this->getServiceWithOverrides(Subscribers::class, [ + 'subscribersRepository' => Stub::make(SubscribersRepository::class, [ + 'getTotalSubscribers' => 999999, + ]), + ]); + + // check count (cached value) + $count = $subscribers->getSubscribersCount(); + expect($count)->same(123456); + + // check count (uncached value) + $this->wp->deleteTransient(Subscribers::SUBSCRIBERS_COUNT_CACHE_KEY); + $count = $subscribers->getSubscribersCount(); + expect($count)->same(999999); + } + + public function testItInvalidatesSubscribersCountCache() { + $subscribers = $this->getServiceWithOverrides(Subscribers::class, [ + 'subscribersRepository' => Stub::make(SubscribersRepository::class, [ + 'getTotalSubscribers' => 123456, + ]), + ]); + $subscribers->getSubscribersCount(); + + $subscribers = $this->getServiceWithOverrides(Subscribers::class, [ + 'subscribersRepository' => Stub::make(SubscribersRepository::class, [ + 'getTotalSubscribers' => 999999, + ]), + ]); + + // check count (cached value) + $count = $subscribers->getSubscribersCount(); + expect($count)->same(123456); + + // modify timestamp, check count (-> uncached value) + $this->wp->updateOption( + '_transient_timeout_' . Subscribers::SUBSCRIBERS_COUNT_CACHE_KEY, + $this->wp->currentTime('timestamp') - 1 + ); + $count = $subscribers->getSubscribersCount(); + expect($count)->same(999999); + } + + public function _after() { + parent::_after(); + $this->truncateEntity(SubscriberEntity::class); + $this->wp->deleteTransient(Subscribers::SUBSCRIBERS_COUNT_CACHE_KEY); + } + + private function createSubscriber(string $email, string $status): SubscriberEntity { + $subscriber = new SubscriberEntity(); + $subscriber->setEmail($email); + $subscriber->setStatus($status); + $this->subscribersRepository->persist($subscriber); + $this->subscribersRepository->flush(); + return $subscriber; + } +} diff --git a/mailpoet/tests/unit/Util/License/Features/SubscribersTest.php b/mailpoet/tests/unit/Util/License/Features/SubscribersTest.php index 34e78b9646..0ad61bd953 100644 --- a/mailpoet/tests/unit/Util/License/Features/SubscribersTest.php +++ b/mailpoet/tests/unit/Util/License/Features/SubscribersTest.php @@ -6,6 +6,7 @@ use Codeception\Util\Stub; use MailPoet\Settings\SettingsController; use MailPoet\Subscribers\SubscribersRepository; use MailPoet\Util\License\Features\Subscribers as SubscribersFeature; +use MailPoet\WP\Functions as WPFunctions; class SubscribersTest extends \MailPoetUnitTest { public function testCheckReturnsTrueIfOldUserReachedLimit() { @@ -178,12 +179,18 @@ class SubscribersTest extends \MailPoetUnitTest { if ($name === SubscribersFeature::PREMIUM_SUPPORT_SETTING_KEY) return isset($specs['support_tier']) ? $specs['support_tier'] : 'free'; }, ]); + $subscribersRepository = Stub::make(SubscribersRepository::class, [ 'getTotalSubscribers' => function() use($specs) { return $specs['subscribers_count']; }, ]); - return new SubscribersFeature($settings, $subscribersRepository); + $wpFunctions = Stub::make(WPFunctions::class, [ + 'getTransient' => false, + 'setTransient' => false, + ]); + + return new SubscribersFeature($settings, $subscribersRepository, $wpFunctions); } }