PluginProbe ʕ •ᴥ•ʔ
MailPoet – Newsletters, Email Marketing, and Automation / 5.34.2
MailPoet – Newsletters, Email Marketing, and Automation v5.34.2
5.34.2 5.34.1 5.34.0 5.33.1 5.33.0 5.32.0 5.31.0 5.30.0 5.29.0 5.28.1 5.28.0 5.27.0 5.26.0 5.26.1 5.25.0 5.24.0 4.43.0 4.43.1 4.44.0 4.44.1 4.45.0 4.46.0 4.47.0 4.48.0 4.48.1 4.48.2 4.49.0 4.49.1 4.5.0 4.5.1 4.5.2 4.50.0 4.50.1 4.51.0 4.51.1 4.51.2 4.52.0 4.53.0 4.54.0 4.55.0 4.56.0 4.57.0 4.58.0 4.58.1 4.58.2 4.6.0 4.6.1 4.6.2 4.7.0 4.7.1 4.8.0 4.8.1 4.9.0 5.0.0 5.0.1 5.0.2 5.1.0 5.1.1 5.10.0 5.10.1 5.11.0 5.12.0 5.12.1 5.12.10 5.12.11 5.12.12 5.12.13 5.12.2 5.12.3 5.12.4 5.12.5 5.12.6 5.12.7 5.12.8 5.12.9 5.13.0 5.13.1 5.13.2 5.14.0 5.14.1 5.14.2 5.14.3 5.15.0 5.15.1 5.16.0 5.16.1 5.16.2 5.16.3 5.16.4 5.17.0 5.17.1 5.17.2 5.17.3 5.17.4 5.17.5 5.17.6 5.18.0 5.19.0 5.2.0 5.2.1 5.2.2 5.2.3 5.20.0 5.21.0 5.21.1 5.21.2 5.21.3 5.22.0 5.22.1 5.22.2 5.22.3 5.22.4 5.23.0 5.23.1 5.23.2 5.3.0 5.3.1 5.3.2 5.3.3 5.3.4 5.3.5 5.3.6 5.3.7 5.4.0 5.4.1 5.4.2 5.5.0 5.5.1 5.5.2 5.6.0 5.6.1 5.6.2 5.6.3 5.6.4 5.7.0 5.7.1 5.8.0 5.8.1 5.9.0 3.0.0-beta.15 3.7.1 3.0.0-beta.16 3.7.2 3.0.0-beta.17 3.7.3 3.0.0-beta.18 3.7.4 3.0.0-beta.19 3.7.5 3.0.0-beta.2 3.7.6 3.0.0-beta.20 3.7.8 3.0.0-beta.21 3.70.0 3.0.0-beta.22 3.71.0 3.0.0-beta.23 3.71.1 3.0.0-beta.23.1 3.71.2 3.0.0-beta.23.2 3.71.3 3.0.0-beta.24 3.72.0 3.0.0-beta.25 3.73.0 3.0.0-beta.26 3.73.1 3.0.0-beta.27 3.73.2 3.0.0-beta.28 3.74.0 3.0.0-beta.29 3.74.1 3.0.0-beta.3 3.74.2 3.0.0-beta.30 3.74.3 3.0.0-beta.31 3.75.0 3.0.0-beta.32 3.75.1 3.0.0-beta.33 3.76.0 3.0.0-beta.33.1 3.77.0 3.0.0-beta.34.0.0 3.77.1 3.0.0-beta.36.0.0 3.78.0 3.0.0-beta.36.0.1 3.79.0 3.0.0-beta.36.2.0 3.8 3.0.0-beta.36.3.0 3.8.1 3.0.0-beta.36.3.1 3.8.2 3.0.0-beta.37.0.0 3.8.3 3.0.0-beta.4 3.8.4 3.0.0-beta.5 3.8.5 3.0.0-beta.6 3.8.6 3.0.0-beta.7 3.80.0 3.0.0-beta.7.1 3.81.0 3.0.0-beta.8 3.82.0 3.0.0-beta.9 3.83.0 3.0.0-rc.1.0.0 3.84.0 3.0.0-rc.1.0.1 3.84.1 3.0.0-rc.1.0.2 3.85.0 3.0.0-rc.1.0.3 3.85.1 3.0.0-rc.1.0.4 3.86.0 3.0.0-rc.2.0.0 3.87.0 3.0.0-rc.2.0.1 3.87.1 3.0.0-rc.2.0.2 3.87.2 3.0.0-rc.2.0.3 3.88.0 3.0.1 3.88.1 3.0.2 3.88.2 3.0.3 3.89.0 3.0.4 3.89.1 3.0.5 3.89.2 3.0.6 3.89.3 3.0.7 3.89.4 3.0.8 3.9.0 3.0.9 3.9.1 3.1.0 3.90.0 3.10 3.90.1 3.10.1 3.90.2 3.100.0 3.91.0 3.100.1 3.91.1 3.100.2 3.92.0 3.101.0 3.92.1 3.101.1 3.93.0 3.102.0 3.93.1 3.102.1 3.94.0 3.103.0 3.95.0 3.103.1 3.95.1 3.11.0 3.96.0 3.11.1 3.96.1 3.11.2 3.97.0 3.11.3 3.98.0 3.11.4 3.98.1 3.11.5 3.99.0 3.12.0 3.99.1 3.12.1 4.0.0 3.13.0 4.0.1 3.14.0 4.1.0 3.14.1 4.1.1 3.15.0 4.10.0 3.16.0 4.11.0 3.16.1 4.11.1 3.16.2 4.12.0 3.16.3 4.12.1 3.17.0 4.12.2 3.17.1 4.13.0 3.17.2 4.14.0 3.18.0 4.15.0 3.18.1 4.16.0 3.18.2 4.17.0 3.19.0 4.17.1 3.19.1 4.18.0 3.19.2 4.18.1 3.19.3 4.19.0 3.2.0 4.2.0 3.2.1 4.20.0 3.2.2 4.20.1 3.2.3 4.20.2 3.2.4 4.21.0 3.2.5 4.22.0 3.20.0 4.22.1 3.21.0 4.22.2 3.21.1 4.23.0 3.22.0 4.24.0 3.23.0 4.25.0 3.23.1 4.26.0 3.23.2 4.26.1 3.24.0 4.27.0 3.25.0 4.28.0 3.25.1 4.29.0 3.26.0 4.3.0 3.26.1 4.3.1 3.27.0 4.30.0 3.28.0 4.31.0 3.29.0 4.31.1 3.3.0 4.32.0 3.3.1 4.33.0 3.3.2 4.34.0 3.3.3 4.35.0 3.3.4 4.35.1 3.3.5 4.36.0 3.3.6 4.37.0 3.30.0 4.38.0 3.31.0 4.39.0 3.31.1 4.4.0 3.32.0 4.40.0 3.32.1 4.41.0 3.32.2 4.41.1 3.33.0 4.41.2 3.34.0 4.41.3 3.34.1 4.42.0 3.34.2 4.42.1 3.34.3 3.34.4 3.35.0 3.35.1 3.35.3 3.35.4 3.36.0 3.37.0 3.37.1 3.37.2 3.37.3 3.38.0 3.38.1 3.39.0 3.39.1 3.39.2 3.4.0 3.4.1 3.4.2 3.4.3 3.4.4 3.40.0 3.40.1 3.41.0 3.41.1 3.41.2 3.42.0 3.42.1 3.42.2 3.42.3 3.43.0 3.43.1 3.44.0 3.45.0 3.45.1 3.46.0 3.46.1 3.46.10 3.46.11 3.46.12 3.46.13 3.46.14 3.46.2 3.46.3 3.46.4 3.46.5 3.46.6 3.46.7 3.46.8 3.46.9 3.47.0 3.47.1 3.47.10 3.47.11 3.47.2 3.47.3 3.47.5 3.47.6 3.47.7 3.47.9 3.48.0 3.48.1 3.49.0 3.49.1 3.5.0 3.5.1 3.50.0 3.51.0 3.51.1 3.51.2 3.52.0 3.53.0 3.54.0 3.54.1 3.54.2 3.54.3 3.55.0 3.55.1 3.56.0 3.56.1 3.56.2 3.57.0 3.57.1 3.58.0 3.59.0 3.59.1 3.59.2 3.6.0 3.6.1 3.6.2 3.6.3 3.6.4 3.6.5 3.6.6 3.6.7 3.60.0 3.60.1 3.60.10 3.60.11 3.60.12 3.60.2 3.60.3 3.60.4 3.60.6 3.60.7 3.60.8 3.60.9 3.61.0 3.62.0 3.62.1 3.63.0 3.64.0 3.64.1 3.64.2 3.64.3 3.65.0 trunk 3.65.1 3.0.0 3.66.0 3.0.0-beta.1 3.67.0 3.0.0-beta.10 3.67.1 3.0.0-beta.11 3.68.0 3.0.0-beta.12 3.69.0 3.0.0-beta.13 3.69.1 3.0.0-beta.14 3.7.0
mailpoet / lib / Subscribers / SegmentsCountRecalculator.php
mailpoet / lib / Subscribers Last commit date
ConfirmationEmailTemplate 1 month ago ImportExport 2 weeks ago RestApi 3 days ago Statistics 1 month ago BulkActionController.php 3 days ago BulkActionException.php 1 month ago BulkConfirmationEmailResender.php 2 months ago ConfirmationEmailCustomizer.php 2 months ago ConfirmationEmailMailer.php 2 months ago ConfirmationEmailResolver.php 2 months ago EngagementDataBackfiller.php 2 months ago InactiveSubscribersController.php 4 days ago LinkTokens.php 2 months ago NewSubscriberNotificationMailer.php 2 months ago RequiredCustomFieldValidator.php 2 months ago SegmentsCountRecalculator.php 2 weeks ago Source.php 2 months ago SubscriberActions.php 2 months ago SubscriberCustomFieldRepository.php 3 years ago SubscriberIPsRepository.php 2 years ago SubscriberLimitNotificationEvaluator.php 2 months ago SubscriberLimitNotificationMailer.php 2 months ago SubscriberLimitNotificationScheduler.php 2 months ago SubscriberListingRepository.php 2 weeks ago SubscriberPersonalDataEraser.php 2 months ago SubscriberSaveController.php 3 days ago SubscriberSegmentRepository.php 2 weeks ago SubscriberSubscribeController.php 2 weeks ago SubscriberTagRepository.php 4 years ago SubscribersCountsController.php 2 weeks ago SubscribersEmailCountsController.php 2 weeks ago SubscribersRepository.php 3 days ago TrackingConsentController.php 4 days ago index.php 3 years ago
SegmentsCountRecalculator.php
276 lines
1 <?php declare(strict_types = 1);
2
3 namespace MailPoet\Subscribers;
4
5 if (!defined('ABSPATH')) exit;
6
7
8 use MailPoet\Cron\CronWorkerScheduler;
9 use MailPoet\Cron\Workers\SubscribersSegmentsCountSync;
10 use MailPoet\DI\ContainerWrapper;
11 use MailPoet\Doctrine\WPDB\Connection;
12 use MailPoet\Entities\SegmentEntity;
13 use MailPoet\Entities\SubscriberEntity;
14 use MailPoet\Entities\SubscriberSegmentEntity;
15 use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
16 use MailPoetVendor\Doctrine\DBAL\ParameterType;
17 use MailPoetVendor\Doctrine\ORM\EntityManager;
18
19 /**
20 * Keeps SubscriberEntity::$segmentsCount in sync.
21 *
22 * segments_count is the number of the subscriber's subscribed memberships in
23 * non-deleted segments. It mirrors the anti-join that used to power the
24 * "Subscribers without a list" count, so the read can become
25 * `WHERE segments_count = 0` instead of scanning every subscriber.
26 *
27 * Every recalculation re-derives the value from subscriber_segment + segments,
28 * so it is idempotent: it is safe to call from several write paths, to call
29 * twice, or to run concurrently with the backfill — the value always converges.
30 * The semantics intentionally match the previous query exactly: only
31 * status = 'subscribed' memberships in segments with deleted_at IS NULL are
32 * counted, with no filtering by segment type (WP/WooCommerce segments count too).
33 */
34 class SegmentsCountRecalculator {
35 /** Subscribers touched per UPDATE when recalculating large/segment-wide sets. */
36 public const BATCH_SIZE = 10000;
37
38 /**
39 * When a single segment change would recompute at least this many memberships
40 * inline, defer to the background sweep instead. Read via static:: so tests
41 * can lower it without inserting hundreds of thousands of rows.
42 */
43 protected const DEFER_THRESHOLD = 200000;
44
45 /** @var EntityManager */
46 private $entityManager;
47
48 public function __construct(
49 EntityManager $entityManager
50 ) {
51 $this->entityManager = $entityManager;
52 }
53
54 public function getDeferThreshold(): int {
55 return static::DEFER_THRESHOLD;
56 }
57
58 /**
59 * Hand a recalculation off to the background sweep (scheduled to run as soon
60 * as possible) instead of doing it inline. Used when a single change touches
61 * too many subscribers to recompute within one request. The sweep re-derives
62 * every subscriber's count from source, so it converges regardless of what
63 * the deferred change was.
64 *
65 * CronWorkerScheduler is resolved lazily rather than injected: it transitively
66 * depends on SegmentsRepository, which depends on this class, so a constructor
67 * dependency would form a circular reference (the same reason SimpleWorker
68 * pulls it from the container).
69 */
70 public function scheduleBackgroundRecalculation(): void {
71 // The sweep worker no-ops on SQLite and reads never trust the column there,
72 // so there is nothing to schedule.
73 if (Connection::isSQLite()) {
74 return;
75 }
76 ContainerWrapper::getInstance()
77 ->get(CronWorkerScheduler::class)
78 ->scheduleImmediatelyIfNotRunning(SubscribersSegmentsCountSync::TASK_TYPE);
79 }
80
81 /**
82 * Recalculate the count for an explicit set of subscribers.
83 *
84 * @param int[] $subscriberIds
85 */
86 public function recalculateForSubscribers(array $subscriberIds): void {
87 // The UPDATE ... LEFT JOIN syntax below is not supported by the SQLite
88 // integration used in WordPress Playground. Reads stay on the anti-join
89 // there because the sync worker never flips the backfill flag (see
90 // SubscribersSegmentsCountSync::processTaskStrategy()).
91 if (Connection::isSQLite()) {
92 return;
93 }
94
95 $subscriberIds = array_values(array_unique($subscriberIds));
96 if ($subscriberIds === []) {
97 return;
98 }
99
100 $subscribersTable = $this->getTableName(SubscriberEntity::class);
101 $membershipSelect = $this->membershipCountSubquery('ssg.subscriber_id IN (:ids)');
102 $connection = $this->entityManager->getConnection();
103
104 foreach (array_chunk($subscriberIds, self::BATCH_SIZE) as $chunk) {
105 $connection->executeStatement(
106 "UPDATE {$subscribersTable} s
107 LEFT JOIN ({$membershipSelect}) m ON m.subscriber_id = s.id
108 SET s.segments_count = IFNULL(m.c, 0)
109 WHERE s.id IN (:ids)",
110 ['ids' => $chunk],
111 ['ids' => ArrayParameterType::INTEGER]
112 );
113 }
114 }
115
116 /**
117 * Recalculate the count for an inclusive range of subscriber ids.
118 * Used by the backfill and reconcile workers.
119 */
120 public function recalculateForIdRange(int $minId, int $maxId): void {
121 // See recalculateForSubscribers(): UPDATE ... LEFT JOIN is unsupported on SQLite.
122 if (Connection::isSQLite()) {
123 return;
124 }
125
126 if ($minId > $maxId) {
127 return;
128 }
129
130 $subscribersTable = $this->getTableName(SubscriberEntity::class);
131 $membershipSelect = $this->membershipCountSubquery('ssg.subscriber_id BETWEEN :minId AND :maxId');
132
133 $this->entityManager->getConnection()->executeStatement(
134 "UPDATE {$subscribersTable} s
135 LEFT JOIN ({$membershipSelect}) m ON m.subscriber_id = s.id
136 SET s.segments_count = IFNULL(m.c, 0)
137 WHERE s.id BETWEEN :minId AND :maxId",
138 ['minId' => $minId, 'maxId' => $maxId]
139 );
140 }
141
142 /**
143 * Recalculate the count for every subscriber that has a membership in the
144 * given segment. Used when a segment is trashed, restored or deleted, which
145 * changes the count of all of its members at once.
146 */
147 public function recalculateForSegment(int $segmentId, bool $subscribedOnly = true): void {
148 $this->recalculateForSegments([$segmentId], $subscribedOnly);
149 }
150
151 /**
152 * Recalculate the count for every subscriber that has a membership in any of
153 * the given segments. Used when segments are trashed, restored or deleted,
154 * which changes the count of all of their members at once.
155 *
156 * Members are walked in keyset-paginated batches rather than materialized into
157 * one array, so this stays memory-safe even on multi-million-member segments.
158 *
159 * $subscribedOnly = true (default): only walk members whose current
160 * subscriber_segment.status = 'subscribed'. Safe when the segment's
161 * deleted_at changed but no membership statuses changed — non-subscribed
162 * members were never counted and their recomputation is a no-op.
163 *
164 * $subscribedOnly = false: walk all members regardless of status. Required
165 * when the caller performed raw-SQL writes that may have changed membership
166 * statuses (e.g. the WooCommerce sync), so subscribers transitioning away
167 * from subscribed must also be recomputed.
168 *
169 * @param int[] $segmentIds
170 */
171 public function recalculateForSegments(array $segmentIds, bool $subscribedOnly = true): void {
172 // recalculateForSubscribers() is a no-op on SQLite, so skip the walk too.
173 if (Connection::isSQLite()) {
174 return;
175 }
176
177 $segmentIds = array_values(array_unique(array_map('intval', $segmentIds)));
178 if ($segmentIds === []) {
179 return;
180 }
181
182 // Recomputing a multi-million-member segment inline would blow the request
183 // budget, so hand the largest changes to the background sweep instead.
184 if ($this->countSegmentMembers($segmentIds, $subscribedOnly) >= $this->getDeferThreshold()) {
185 $this->scheduleBackgroundRecalculation();
186 return;
187 }
188
189 $subscriberSegmentTable = $this->getTableName(SubscriberSegmentEntity::class);
190 $connection = $this->entityManager->getConnection();
191
192 $lastId = 0;
193 do {
194 $batchSize = self::BATCH_SIZE;
195 $sql = "SELECT DISTINCT subscriber_id FROM {$subscriberSegmentTable}
196 WHERE segment_id IN (:segmentIds) AND subscriber_id > :lastId";
197 $params = ['segmentIds' => $segmentIds, 'lastId' => $lastId];
198 $types = ['segmentIds' => ArrayParameterType::INTEGER, 'lastId' => ParameterType::INTEGER];
199 if ($subscribedOnly) {
200 $sql .= ' AND status = :status';
201 $params['status'] = SubscriberEntity::STATUS_SUBSCRIBED;
202 $types['status'] = ParameterType::STRING;
203 }
204 $sql .= ' ORDER BY subscriber_id ASC LIMIT ' . $batchSize;
205 $ids = $connection->executeQuery($sql, $params, $types)->fetchFirstColumn();
206
207 if ($ids === []) {
208 break;
209 }
210
211 $subscriberIds = array_map(function ($id): int {
212 return is_numeric($id) ? (int)$id : 0;
213 }, $ids);
214 $this->recalculateForSubscribers($subscriberIds);
215 $lastId = (int)end($subscriberIds);
216 } while (count($ids) === self::BATCH_SIZE);
217 }
218
219 /**
220 * Count the memberships a segment change would touch. Uses COUNT(*) rather
221 * than COUNT(DISTINCT subscriber_id): a subscriber shared across several of
222 * the given segments is counted more than once, but an over-count only makes
223 * the deferral threshold trip slightly earlier, which is safe.
224 *
225 * When $type is null the query stays on the segment_id index with no join,
226 * which is what the recalculation path wants. Pass a $type to scope the count
227 * to segments of that type (joining the segments table), matching a
228 * type-scoped delete.
229 *
230 * @param int[] $segmentIds
231 */
232 public function countSegmentMembers(array $segmentIds, bool $subscribedOnly, ?string $type = null): int {
233 if ($segmentIds === []) {
234 return 0;
235 }
236
237 $subscriberSegmentTable = $this->getTableName(SubscriberSegmentEntity::class);
238 $sql = "SELECT COUNT(*) FROM {$subscriberSegmentTable} ss";
239 $params = ['segmentIds' => $segmentIds];
240 $types = ['segmentIds' => ArrayParameterType::INTEGER];
241 if ($type !== null) {
242 $segmentsTable = $this->getTableName(SegmentEntity::class);
243 $sql .= " JOIN {$segmentsTable} s ON ss.segment_id = s.id AND s.type = :type";
244 $params['type'] = $type;
245 $types['type'] = ParameterType::STRING;
246 }
247 $sql .= " WHERE ss.segment_id IN (:segmentIds)";
248 if ($subscribedOnly) {
249 $sql .= ' AND ss.status = :status';
250 $params['status'] = SubscriberEntity::STATUS_SUBSCRIBED;
251 $types['status'] = ParameterType::STRING;
252 }
253 $count = $this->entityManager->getConnection()->executeQuery($sql, $params, $types)->fetchOne();
254 return is_numeric($count) ? (int)$count : 0;
255 }
256
257 private function membershipCountSubquery(string $subscriberCondition): string {
258 $subscriberSegmentTable = $this->getTableName(SubscriberSegmentEntity::class);
259 $segmentsTable = $this->getTableName(SegmentEntity::class);
260 $subscribedStatus = SubscriberEntity::STATUS_SUBSCRIBED;
261
262 return "SELECT ssg.subscriber_id, COUNT(*) AS c
263 FROM {$subscriberSegmentTable} ssg
264 JOIN {$segmentsTable} g ON g.id = ssg.segment_id AND g.deleted_at IS NULL
265 WHERE ssg.status = '{$subscribedStatus}' AND {$subscriberCondition}
266 GROUP BY ssg.subscriber_id";
267 }
268
269 /**
270 * @param class-string $entityClass
271 */
272 private function getTableName(string $entityClass): string {
273 return $this->entityManager->getClassMetadata($entityClass)->getTableName();
274 }
275 }
276