Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
90.86% |
179 / 197 |
|
54.55% |
12 / 22 |
CRAP | |
0.00% |
0 / 1 |
| Stats | |
91.79% |
179 / 195 |
|
54.55% |
12 / 22 |
85.71 | |
0.00% |
0 / 1 |
| table_name | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| schema | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
| bucket | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| add | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| add_count | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
3 | |||
| move | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
3 | |||
| backfill_counts | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
3 | |||
| series | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
6 | |||
| totals_by_source | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
4 | |||
| origins | |
94.12% |
16 / 17 |
|
0.00% |
0 / 1 |
8.01 | |||
| sent_counts | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
2 | |||
| has_history | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
4 | |||
| since | |
77.78% |
7 / 9 |
|
0.00% |
0 / 1 |
6.40 | |||
| newest | |
77.78% |
7 / 9 |
|
0.00% |
0 / 1 |
6.40 | |||
| log_still_counts | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
3.14 | |||
| uncounted | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
4.07 | |||
| count_log_rows | |
95.83% |
23 / 24 |
|
0.00% |
0 / 1 |
6 | |||
| backfill_state | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
3 | |||
| start_backfill | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
2 | |||
| backfill_batch | |
92.00% |
23 / 25 |
|
0.00% |
0 / 1 |
10.05 | |||
| prune | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
| drop | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * Email counts for the dashboard. |
| 4 | * |
| 5 | * @package automattic/jetpack-send |
| 6 | */ |
| 7 | |
| 8 | namespace Automattic\Jetpack\Send; |
| 9 | |
| 10 | if ( ! defined( 'ABSPATH' ) ) { |
| 11 | exit( 0 ); |
| 12 | } |
| 13 | |
| 14 | // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- A custom counts table; GET /stats caches the results. |
| 15 | |
| 16 | /** |
| 17 | * One row per site-local hour, status, type and plugin, holding a number. The counts outlive the |
| 18 | * log, so a short retention never empties the dashboard. No addresses, subjects or row ids. |
| 19 | * |
| 20 | * Product code only ever adds, except move() when one email changes status. |
| 21 | */ |
| 22 | class Stats { |
| 23 | |
| 24 | /** |
| 25 | * Days of counts kept: a year's range plus the year before it. |
| 26 | * |
| 27 | * @var int |
| 28 | */ |
| 29 | const KEEP_DAYS = 800; |
| 30 | |
| 31 | /** |
| 32 | * `{ until_id, cursor, done }` for counting the emails logged before the counts existed. |
| 33 | * Kept once done: it's the flag that stops a second upgrade counting them again. |
| 34 | * |
| 35 | * @var string |
| 36 | */ |
| 37 | const BACKFILL_OPTION = 'jetpack_send_stats_backfill'; |
| 38 | |
| 39 | /** |
| 40 | * Log rows read per backfill batch. |
| 41 | * |
| 42 | * @var int |
| 43 | */ |
| 44 | const BACKFILL_ROWS = 5000; |
| 45 | |
| 46 | /** |
| 47 | * The statuses counted, in the order the dashboard lists them. |
| 48 | * |
| 49 | * @var string[] |
| 50 | */ |
| 51 | const STATUSES = array( Email_Log::STATUS_SENT, Email_Log::STATUS_FALLBACK, Email_Log::STATUS_FAILED ); |
| 52 | |
| 53 | /** |
| 54 | * Table name. |
| 55 | * |
| 56 | * @return string |
| 57 | */ |
| 58 | public static function table_name() { |
| 59 | global $wpdb; |
| 60 | return $wpdb->prefix . 'jetpack_send_stats'; |
| 61 | } |
| 62 | |
| 63 | /** |
| 64 | * The table's definition, for dbDelta(). |
| 65 | * |
| 66 | * @param string $charset Charset and collation clause. |
| 67 | * @return string |
| 68 | */ |
| 69 | public static function schema( $charset ) { |
| 70 | $table = self::table_name(); |
| 71 | return "CREATE TABLE {$table} ( |
| 72 | bucket int(10) unsigned NOT NULL, |
| 73 | status varchar(20) NOT NULL, |
| 74 | source varchar(20) NOT NULL, |
| 75 | origin varchar(100) NOT NULL DEFAULT '', |
| 76 | emails int(10) unsigned NOT NULL DEFAULT 0, |
| 77 | PRIMARY KEY (bucket,status,source,origin) |
| 78 | ) {$charset};"; |
| 79 | } |
| 80 | |
| 81 | /** |
| 82 | * The start of the site-local hour a time falls in, as a Unix time. Half-hour zones land |
| 83 | * local midnights on bucket edges, and daylight-saving days have 23 or 25 buckets. |
| 84 | * |
| 85 | * @since $$next-version$$ |
| 86 | * |
| 87 | * @param int $time Unix time. |
| 88 | * @param \DateTimeZone|null $zone Site timezone, when the caller already has it. |
| 89 | * @return int |
| 90 | */ |
| 91 | public static function bucket( $time, $zone = null ) { |
| 92 | $time = (int) $time; |
| 93 | $zone = $zone instanceof \DateTimeZone ? $zone : wp_timezone(); |
| 94 | $offset = $zone->getOffset( new \DateTime( '@' . $time ) ); |
| 95 | return (int) ( floor( ( $time + $offset ) / HOUR_IN_SECONDS ) * HOUR_IN_SECONDS - $offset ); |
| 96 | } |
| 97 | |
| 98 | /** |
| 99 | * Count one email once its outcome is known. |
| 100 | * |
| 101 | * @since $$next-version$$ |
| 102 | * |
| 103 | * @param int $time When the email's log row was created, Unix time. |
| 104 | * @param string $status `sent`, `fallback` or `failed`. |
| 105 | * @param string $source `wp_mail`, `test` or `resend`. |
| 106 | * @param string $origin What sent it; '' for tests. |
| 107 | * @param int $log_id The log row; 0 when the insert failed, which still counts. |
| 108 | * @return void |
| 109 | */ |
| 110 | public static function add( $time, $status, $source, $origin, $log_id = 0 ) { |
| 111 | if ( self::backfill_counts( $log_id ) ) { |
| 112 | return; |
| 113 | } |
| 114 | self::add_count( $time, $status, $source, $origin, 1 ); |
| 115 | } |
| 116 | |
| 117 | /** |
| 118 | * Add to a count, for one or more emails at once. |
| 119 | * |
| 120 | * @since $$next-version$$ |
| 121 | * |
| 122 | * @param int $time Unix time. |
| 123 | * @param string $status Status. |
| 124 | * @param string $source Type. |
| 125 | * @param string $origin Origin key. |
| 126 | * @param int $n Emails to add. |
| 127 | * @return void |
| 128 | */ |
| 129 | public static function add_count( $time, $status, $source, $origin, $n ) { |
| 130 | global $wpdb; |
| 131 | if ( (int) $n < 1 || ! Email_Log::has_v4_schema() ) { |
| 132 | return; |
| 133 | } |
| 134 | $wpdb->query( |
| 135 | $wpdb->prepare( |
| 136 | 'INSERT INTO %i ( bucket, status, source, origin, emails ) VALUES ( %d, %s, %s, %s, %d ) ON DUPLICATE KEY UPDATE emails = emails + %d', |
| 137 | self::table_name(), |
| 138 | self::bucket( $time ), |
| 139 | substr( (string) $status, 0, 20 ), |
| 140 | substr( (string) $source, 0, 20 ), |
| 141 | substr( (string) $origin, 0, 100 ), |
| 142 | (int) $n, |
| 143 | (int) $n |
| 144 | ) |
| 145 | ); |
| 146 | } |
| 147 | |
| 148 | /** |
| 149 | * Move one counted email to another status, e.g. when the host's mail fails after a hand-off. |
| 150 | * |
| 151 | * @since $$next-version$$ |
| 152 | * |
| 153 | * @param int $time When its log row was created, Unix time. |
| 154 | * @param string $from Status it was counted as. |
| 155 | * @param string $to Status it has now. |
| 156 | * @param string $source Type. |
| 157 | * @param string $origin Origin key. |
| 158 | * @param int $log_id The log row. |
| 159 | * @return void |
| 160 | */ |
| 161 | public static function move( $time, $from, $to, $source, $origin, $log_id = 0 ) { |
| 162 | global $wpdb; |
| 163 | if ( ! Email_Log::has_v4_schema() || self::backfill_counts( $log_id ) ) { |
| 164 | return; |
| 165 | } |
| 166 | $wpdb->query( |
| 167 | $wpdb->prepare( |
| 168 | 'UPDATE %i SET emails = emails - 1 WHERE bucket = %d AND status = %s AND source = %s AND origin = %s AND emails > 0', |
| 169 | self::table_name(), |
| 170 | self::bucket( $time ), |
| 171 | (string) $from, |
| 172 | (string) $source, |
| 173 | substr( (string) $origin, 0, 100 ) |
| 174 | ) |
| 175 | ); |
| 176 | self::add_count( $time, $to, $source, $origin, 1 ); |
| 177 | Email_Log::clear_stats_cache(); |
| 178 | } |
| 179 | |
| 180 | /** |
| 181 | * Whether the backfill counts a row, so a live count would count it twice. |
| 182 | * |
| 183 | * @param int $log_id Log row id. |
| 184 | * @return bool |
| 185 | */ |
| 186 | private static function backfill_counts( $log_id ) { |
| 187 | $state = self::backfill_state(); |
| 188 | return null !== $state && (int) $log_id > 0 && (int) $log_id <= $state['until_id']; |
| 189 | } |
| 190 | |
| 191 | /** |
| 192 | * Site emails and resends per hour and status; tests aren't counted. |
| 193 | * |
| 194 | * @since $$next-version$$ |
| 195 | * |
| 196 | * @param int $start_utc First second, Unix time. |
| 197 | * @param int $end_utc Second after the last one. |
| 198 | * @return array<int, array{sent: int, fallback: int, failed: int}> By bucket, only those with emails. |
| 199 | */ |
| 200 | public static function series( $start_utc, $end_utc ) { |
| 201 | global $wpdb; |
| 202 | $series = array(); |
| 203 | $fold = static function ( $bucket, $status, $n ) use ( &$series ) { |
| 204 | if ( in_array( $status, self::STATUSES, true ) ) { |
| 205 | $series[ $bucket ] ??= array_fill_keys( self::STATUSES, 0 ); |
| 206 | $series[ $bucket ][ $status ] += $n; |
| 207 | } |
| 208 | }; |
| 209 | if ( Email_Log::has_v4_schema() ) { |
| 210 | $rows = $wpdb->get_results( $wpdb->prepare( "SELECT bucket, status, SUM(emails) AS n FROM %i WHERE bucket >= %d AND bucket < %d AND source <> 'test' GROUP BY bucket, status", self::table_name(), $start_utc, $end_utc ), ARRAY_A ); |
| 211 | foreach ( (array) $rows as $row ) { |
| 212 | $fold( (int) $row['bucket'], (string) $row['status'], (int) $row['n'] ); |
| 213 | } |
| 214 | } |
| 215 | foreach ( self::uncounted( $start_utc, $end_utc ) as $row ) { |
| 216 | if ( 'test' !== $row['source'] ) { |
| 217 | $fold( $row['bucket'], $row['status'], $row['n'] ); |
| 218 | } |
| 219 | } |
| 220 | ksort( $series ); |
| 221 | return $series; |
| 222 | } |
| 223 | |
| 224 | /** |
| 225 | * Every email by type and status, tests included. |
| 226 | * |
| 227 | * @since $$next-version$$ |
| 228 | * |
| 229 | * @param int $start_utc First second, Unix time. |
| 230 | * @param int $end_utc Second after the last one. |
| 231 | * @return array<string, array<string, int>> By type, then status. |
| 232 | */ |
| 233 | public static function totals_by_source( $start_utc, $end_utc ) { |
| 234 | global $wpdb; |
| 235 | $totals = array(); |
| 236 | if ( Email_Log::has_v4_schema() ) { |
| 237 | $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT source, status, SUM(emails) AS n FROM %i WHERE bucket >= %d AND bucket < %d GROUP BY source, status', self::table_name(), $start_utc, $end_utc ), ARRAY_A ); |
| 238 | foreach ( (array) $rows as $row ) { |
| 239 | $totals[ (string) $row['source'] ][ (string) $row['status'] ] = (int) $row['n']; |
| 240 | } |
| 241 | } |
| 242 | foreach ( self::uncounted( $start_utc, $end_utc ) as $row ) { |
| 243 | $totals[ $row['source'] ][ $row['status'] ] = ( $totals[ $row['source'] ][ $row['status'] ] ?? 0 ) + $row['n']; |
| 244 | } |
| 245 | return $totals; |
| 246 | } |
| 247 | |
| 248 | /** |
| 249 | * Site emails and resends per origin, most first. Tests and emails with no origin are left out. |
| 250 | * |
| 251 | * @since $$next-version$$ |
| 252 | * |
| 253 | * @param int $start_utc First second, Unix time. |
| 254 | * @param int $end_utc Second after the last one. |
| 255 | * @return array<int, array{origin: string, n: int}> |
| 256 | */ |
| 257 | public static function origins( $start_utc, $end_utc ) { |
| 258 | global $wpdb; |
| 259 | $counts = array(); |
| 260 | if ( Email_Log::has_v4_schema() ) { |
| 261 | $rows = $wpdb->get_results( $wpdb->prepare( "SELECT origin, SUM(emails) AS n FROM %i WHERE bucket >= %d AND bucket < %d AND source <> 'test' AND origin <> '' GROUP BY origin", self::table_name(), $start_utc, $end_utc ), ARRAY_A ); |
| 262 | foreach ( (array) $rows as $row ) { |
| 263 | $counts[ (string) $row['origin'] ] = (int) $row['n']; |
| 264 | } |
| 265 | } |
| 266 | foreach ( self::uncounted( $start_utc, $end_utc ) as $row ) { |
| 267 | if ( 'test' !== $row['source'] && '' !== $row['origin'] ) { |
| 268 | $counts[ $row['origin'] ] = ( $counts[ $row['origin'] ] ?? 0 ) + $row['n']; |
| 269 | } |
| 270 | } |
| 271 | arsort( $counts ); |
| 272 | $origins = array(); |
| 273 | foreach ( $counts as $origin => $n ) { |
| 274 | if ( $n > 0 ) { |
| 275 | $origins[] = array( |
| 276 | 'origin' => (string) $origin, |
| 277 | 'n' => $n, |
| 278 | ); |
| 279 | } |
| 280 | } |
| 281 | return $origins; |
| 282 | } |
| 283 | |
| 284 | /** |
| 285 | * Emails the mail service accepted since a time, every type: tests and resends use the plan's sends too. |
| 286 | * |
| 287 | * @since $$next-version$$ |
| 288 | * |
| 289 | * @param int $start_utc First second, Unix time. |
| 290 | * @return array{emails: int, tests: int} |
| 291 | */ |
| 292 | public static function sent_counts( $start_utc ) { |
| 293 | $end = time() + DAY_IN_SECONDS; |
| 294 | $totals = self::totals_by_source( (int) $start_utc, $end ); |
| 295 | $emails = 0; |
| 296 | foreach ( $totals as $statuses ) { |
| 297 | $emails += (int) ( $statuses[ Email_Log::STATUS_SENT ] ?? 0 ); |
| 298 | } |
| 299 | return array( |
| 300 | 'emails' => $emails, |
| 301 | 'tests' => (int) ( $totals['test'][ Email_Log::STATUS_SENT ] ?? 0 ), |
| 302 | ); |
| 303 | } |
| 304 | |
| 305 | /** |
| 306 | * Whether anything but a test was ever counted. Stops at the first one. |
| 307 | * |
| 308 | * @since $$next-version$$ |
| 309 | * |
| 310 | * @return bool |
| 311 | */ |
| 312 | public static function has_history() { |
| 313 | global $wpdb; |
| 314 | if ( Email_Log::has_v4_schema() && null !== $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM %i WHERE source <> 'test' AND emails > 0 LIMIT 1", self::table_name() ) ) ) { |
| 315 | return true; |
| 316 | } |
| 317 | return self::log_still_counts() && Email_Log::has_history(); |
| 318 | } |
| 319 | |
| 320 | /** |
| 321 | * The oldest counted hour, tests included. |
| 322 | * |
| 323 | * @since $$next-version$$ |
| 324 | * |
| 325 | * @return int|null Bucket, or null with no counts. |
| 326 | */ |
| 327 | public static function since() { |
| 328 | global $wpdb; |
| 329 | $since = null; |
| 330 | if ( Email_Log::has_v4_schema() ) { |
| 331 | $value = $wpdb->get_var( $wpdb->prepare( 'SELECT MIN(bucket) FROM %i WHERE emails > 0', self::table_name() ) ); |
| 332 | $since = null === $value ? null : (int) $value; |
| 333 | } |
| 334 | $oldest = self::log_still_counts() ? Email_Log::oldest_created_at() : null; |
| 335 | if ( null !== $oldest ) { |
| 336 | $bucket = self::bucket( (int) strtotime( $oldest . ' UTC' ) ); |
| 337 | $since = null === $since ? $bucket : min( $since, $bucket ); |
| 338 | } |
| 339 | return $since; |
| 340 | } |
| 341 | |
| 342 | /** |
| 343 | * The newest counted hour with a site email or resend. |
| 344 | * |
| 345 | * @since $$next-version$$ |
| 346 | * |
| 347 | * @return int|null Bucket, or null with none. |
| 348 | */ |
| 349 | public static function newest() { |
| 350 | global $wpdb; |
| 351 | $newest = null; |
| 352 | if ( Email_Log::has_v4_schema() ) { |
| 353 | $value = $wpdb->get_var( $wpdb->prepare( "SELECT MAX(bucket) FROM %i WHERE source <> 'test' AND emails > 0", self::table_name() ) ); |
| 354 | $newest = null === $value ? null : (int) $value; |
| 355 | } |
| 356 | $last = self::log_still_counts() ? Email_Log::last_email_at() : null; |
| 357 | if ( null !== $last ) { |
| 358 | $bucket = self::bucket( (int) strtotime( $last . ' UTC' ) ); |
| 359 | $newest = null === $newest ? $bucket : max( $newest, $bucket ); |
| 360 | } |
| 361 | return $newest; |
| 362 | } |
| 363 | |
| 364 | /** |
| 365 | * Whether some emails are still only in the log: before the upgrade, or while the backfill runs. |
| 366 | * |
| 367 | * @return bool |
| 368 | */ |
| 369 | private static function log_still_counts() { |
| 370 | if ( ! Email_Log::has_v4_schema() ) { |
| 371 | return true; |
| 372 | } |
| 373 | $state = self::backfill_state(); |
| 374 | return null !== $state && ! $state['done']; |
| 375 | } |
| 376 | |
| 377 | /** |
| 378 | * Log rows not in the counts yet, grouped by hour, status, type and origin: every row before the |
| 379 | * upgrade, and the rows the backfill hasn't reached while it runs. So the numbers are right either way. |
| 380 | * |
| 381 | * @param int $start_utc First second, Unix time. |
| 382 | * @param int $end_utc Second after the last one. |
| 383 | * @return array<int, array{bucket: int, status: string, source: string, origin: string, n: int}> |
| 384 | */ |
| 385 | private static function uncounted( $start_utc, $end_utc ) { |
| 386 | if ( ! Email_Log::has_v4_schema() ) { |
| 387 | return self::count_log_rows( 0, PHP_INT_MAX, (int) $start_utc, (int) $end_utc ); |
| 388 | } |
| 389 | $state = self::backfill_state(); |
| 390 | if ( null === $state || $state['done'] ) { |
| 391 | return array(); |
| 392 | } |
| 393 | return self::count_log_rows( $state['cursor'], $state['until_id'], (int) $start_utc, (int) $end_utc ); |
| 394 | } |
| 395 | |
| 396 | /** |
| 397 | * Count log rows with `$after_id < id <= $until_id` inside a window, in batches, grouping in PHP. |
| 398 | * |
| 399 | * @param int $after_id Lowest id, excluded. |
| 400 | * @param int $until_id Highest id, included. |
| 401 | * @param int|null $start_utc First second, or null for any. |
| 402 | * @param int|null $end_utc Second after the last one, or null for any. |
| 403 | * @return array<int, array{bucket: int, status: string, source: string, origin: string, n: int}> |
| 404 | */ |
| 405 | private static function count_log_rows( $after_id, $until_id, $start_utc = null, $end_utc = null ) { |
| 406 | global $wpdb; |
| 407 | if ( ! Email_Log::is_installed() ) { |
| 408 | return array(); |
| 409 | } |
| 410 | $zone = wp_timezone(); |
| 411 | $from = null === $start_utc ? '0000-00-00 00:00:00' : gmdate( 'Y-m-d H:i:s', $start_utc ); |
| 412 | $to = null === $end_utc ? '9999-12-31 23:59:59' : gmdate( 'Y-m-d H:i:s', $end_utc ); |
| 413 | $counts = array(); |
| 414 | $last = (int) $after_id; |
| 415 | do { |
| 416 | $rows = (array) $wpdb->get_results( $wpdb->prepare( 'SELECT id, created_at, status, source, origin FROM %i WHERE id > %d AND id <= %d AND created_at >= %s AND created_at < %s ORDER BY id LIMIT %d', Email_Log::table_name(), $last, $until_id, $from, $to, self::BACKFILL_ROWS ), ARRAY_A ); |
| 417 | foreach ( $rows as $row ) { |
| 418 | $bucket = self::bucket( (int) strtotime( $row['created_at'] . ' UTC' ), $zone ); |
| 419 | $key = $bucket . '|' . $row['status'] . '|' . $row['source'] . '|' . $row['origin']; |
| 420 | if ( ! isset( $counts[ $key ] ) ) { |
| 421 | $counts[ $key ] = array( |
| 422 | 'bucket' => $bucket, |
| 423 | 'status' => (string) $row['status'], |
| 424 | 'source' => (string) $row['source'], |
| 425 | 'origin' => (string) $row['origin'], |
| 426 | 'n' => 0, |
| 427 | ); |
| 428 | } |
| 429 | ++$counts[ $key ]['n']; |
| 430 | $last = max( $last, (int) $row['id'] ); |
| 431 | } |
| 432 | $full = count( $rows ) === self::BACKFILL_ROWS; |
| 433 | } while ( $full ); |
| 434 | return array_values( $counts ); |
| 435 | } |
| 436 | |
| 437 | /** |
| 438 | * The backfill's state. |
| 439 | * |
| 440 | * @since $$next-version$$ |
| 441 | * |
| 442 | * @return array{until_id: int, cursor: int, done: bool}|null Null before the upgrade wrote it. |
| 443 | */ |
| 444 | public static function backfill_state() { |
| 445 | $state = get_option( self::BACKFILL_OPTION, null ); |
| 446 | if ( ! is_array( $state ) || ! isset( $state['until_id'] ) ) { |
| 447 | return null; |
| 448 | } |
| 449 | return array( |
| 450 | 'until_id' => (int) $state['until_id'], |
| 451 | 'cursor' => (int) ( $state['cursor'] ?? 0 ), |
| 452 | 'done' => ! empty( $state['done'] ), |
| 453 | ); |
| 454 | } |
| 455 | |
| 456 | /** |
| 457 | * Write the backfill's boundary once: emails logged before the upgrade are counted by the backfill, |
| 458 | * later ones live. A boundary that's already there (a second upgrade after a rollback) stays. |
| 459 | * |
| 460 | * @since $$next-version$$ |
| 461 | * |
| 462 | * @param int $until_id The newest log row at the upgrade; 0 for an empty log. |
| 463 | * @return void |
| 464 | */ |
| 465 | public static function start_backfill( $until_id ) { |
| 466 | if ( false !== get_option( self::BACKFILL_OPTION, false ) ) { |
| 467 | return; |
| 468 | } |
| 469 | $until_id = max( 0, (int) $until_id ); |
| 470 | update_option( |
| 471 | self::BACKFILL_OPTION, |
| 472 | array( |
| 473 | 'until_id' => $until_id, |
| 474 | 'cursor' => 0, |
| 475 | 'done' => 0 === $until_id, |
| 476 | ), |
| 477 | false |
| 478 | ); |
| 479 | } |
| 480 | |
| 481 | /** |
| 482 | * Count the next batches of emails logged before the upgrade. Only adds; never deletes or lowers. |
| 483 | * |
| 484 | * @since $$next-version$$ |
| 485 | * |
| 486 | * @param float $budget_seconds Keep going while there's time; 0 for one batch. |
| 487 | * @return bool Whether the backfill is done. |
| 488 | */ |
| 489 | public static function backfill_batch( $budget_seconds = 2.0 ) { |
| 490 | global $wpdb; |
| 491 | $state = self::backfill_state(); |
| 492 | if ( ! Email_Log::has_v4_schema() || null === $state ) { |
| 493 | return false; |
| 494 | } |
| 495 | if ( $state['done'] ) { |
| 496 | return true; |
| 497 | } |
| 498 | if ( ! Lock::acquire( 'backfill', 5 * MINUTE_IN_SECONDS ) ) { |
| 499 | return false; |
| 500 | } |
| 501 | |
| 502 | try { |
| 503 | $started = microtime( true ); |
| 504 | $zone = wp_timezone(); |
| 505 | do { |
| 506 | $rows = (array) $wpdb->get_results( $wpdb->prepare( 'SELECT id, created_at, status, source, origin FROM %i WHERE id > %d AND id <= %d ORDER BY id LIMIT %d', Email_Log::table_name(), $state['cursor'], $state['until_id'], self::BACKFILL_ROWS ), ARRAY_A ); |
| 507 | $batch = array(); |
| 508 | foreach ( $rows as $row ) { |
| 509 | $time = (int) strtotime( $row['created_at'] . ' UTC' ); |
| 510 | $key = self::bucket( $time, $zone ) . '|' . $row['status'] . '|' . $row['source'] . '|' . $row['origin']; |
| 511 | if ( ! isset( $batch[ $key ] ) ) { |
| 512 | $batch[ $key ] = array( $time, (string) $row['status'], (string) $row['source'], (string) $row['origin'], 0 ); |
| 513 | } |
| 514 | ++$batch[ $key ][4]; |
| 515 | } |
| 516 | |
| 517 | $state['cursor'] = count( $rows ) < self::BACKFILL_ROWS ? $state['until_id'] : (int) end( $rows )['id']; |
| 518 | $state['done'] = $state['cursor'] >= $state['until_id']; |
| 519 | // The cursor moves before the counts are added: a request that dies here drops a count, never doubles one. |
| 520 | update_option( self::BACKFILL_OPTION, $state, false ); |
| 521 | foreach ( $batch as $count ) { |
| 522 | self::add_count( $count[0], $count[1], $count[2], $count[3], $count[4] ); |
| 523 | } |
| 524 | } while ( ! $state['done'] && microtime( true ) - $started < $budget_seconds ); |
| 525 | } finally { |
| 526 | Lock::release( 'backfill' ); |
| 527 | } |
| 528 | return $state['done']; |
| 529 | } |
| 530 | |
| 531 | /** |
| 532 | * Delete counts older than KEEP_DAYS. |
| 533 | * |
| 534 | * @since $$next-version$$ |
| 535 | * |
| 536 | * @return void |
| 537 | */ |
| 538 | public static function prune() { |
| 539 | global $wpdb; |
| 540 | if ( ! Email_Log::has_v4_schema() ) { |
| 541 | return; |
| 542 | } |
| 543 | $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE bucket < %d', self::table_name(), time() - self::KEEP_DAYS * DAY_IN_SECONDS ) ); |
| 544 | } |
| 545 | |
| 546 | /** |
| 547 | * Drop the table and the backfill's state (uninstall). |
| 548 | * |
| 549 | * @since $$next-version$$ |
| 550 | * |
| 551 | * @return void |
| 552 | */ |
| 553 | public static function drop() { |
| 554 | global $wpdb; |
| 555 | $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', self::table_name() ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange -- Uninstall. |
| 556 | delete_option( self::BACKFILL_OPTION ); |
| 557 | } |
| 558 | } |