<?php
declare( strict_types = 1 );
namespace Automattic\WooCommerce\Internal\PushNotifications\DataStores;
defined( 'ABSPATH' ) || exit;
use WC_Log_Levels;
/**
* Reads push notification step log lines back from the `wc_log` table, for
* stores that chose the database log handler.
*
* The table indexes only `log_id` and `level`, so a query by source would scan
* every row. Instead the reader walks back from the newest row in windows of
* primary key IDs, which bounds the rows examined whatever the table size, and
* stops once it has passed the start of the requested range or examined its
* limit. Coverage can therefore fall short of the range on a store whose other
* plugins log heavily; `covered_from` says where the read stopped.
*
* @since 11.3.0
*/
class StepLogTableReader {
/**
* Rows examined per window.
*/
const WINDOW_SIZE = 5000;
/**
* Most rows examined in one read.
*/
const MAX_ROWS_EXAMINED = 50000;
/**
* Returns every row written under a source between two times, as far back
* as the bounded scan reached.
*
* Rows are turned into what the caller wants as each one is read, and the
* scan stops once it holds enough, so a short page does not walk the whole
* row budget. `has_more` says whether it stopped with older windows unread.
*
* @param string $source The log source, e.g. `push-notifications-store-order`.
* @param int $from Earliest timestamp to include, inclusive.
* @param int $to Latest timestamp to include, inclusive.
* @param int $limit The fewest rows to collect before stopping.
* @param callable|null $expand Called with each row, returning a row per result it should produce.
* @return array{rows: array<int, array{timestamp: int, level: string, message: string, context: array|null, raw: string|null}>, covered_from: int, has_more: bool}
*
* @since 11.3.0
*/
public function read( string $source, int $from, int $to, int $limit = PHP_INT_MAX, ?callable $expand = null ): array {
global $wpdb;
$table = $wpdb->prefix . 'woocommerce_log';
$max_id = (int) $wpdb->get_var( "SELECT MAX(log_id) FROM {$table}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$rows = array();
$upper = $max_id;
$examined = 0;
$covered_from = $from;
$has_more = false;
while ( $upper > 0 && $examined < self::MAX_ROWS_EXAMINED ) {
$lower = max( 0, $upper - self::WINDOW_SIZE );
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT log_id, timestamp, level, message, context FROM {$table} WHERE log_id > %d AND log_id <= %d AND source = %s ORDER BY log_id ASC", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$lower,
$upper,
$source
),
ARRAY_A
);
foreach ( (array) $results as $result ) {
$row = self::build_row( $result );
if ( $row['timestamp'] < $from || $row['timestamp'] > $to ) {
continue;
}
if ( null === $expand ) {
$rows[] = $row;
continue;
}
foreach ( $expand( $row ) as $expanded ) {
$rows[] = $expanded;
}
}
$examined += $upper - $lower;
$oldest = $this->get_window_start_timestamp( $table, $lower );
$upper = $lower;
/*
* Windows are walked newest first, so everything left is older than
* this window's oldest row. That row's second can carry on into the
* next window, so stop only once a full page is newer than it.
*/
if ( count( array_filter( $rows, fn( array $row ) => null === $oldest || $row['timestamp'] > $oldest ) ) >= $limit ) {
$covered_from = null === $oldest ? $from : $oldest;
$has_more = $upper > 0 && $covered_from > $from;
break;
}
if ( null === $oldest ) {
continue;
}
if ( $oldest <= $from ) {
$covered_from = $from;
break;
}
$covered_from = $oldest;
}
if ( 0 === $upper ) {
$covered_from = $from;
}
usort( $rows, fn( array $a, array $b ) => $a['timestamp'] <=> $b['timestamp'] );
return array(
'rows' => $rows,
'covered_from' => $covered_from,
'has_more' => $has_more,
);
}
/**
* Returns the timestamp of the oldest row in the window just read, to
* decide whether the scan has passed the start of the range.
*
* @param string $table The log table name.
* @param int $lower The exclusive lower ID bound of the window just read.
* @return int|null Null when the window held no rows at all.
*/
private function get_window_start_timestamp( string $table, int $lower ): ?int {
global $wpdb;
$timestamp = $wpdb->get_var(
$wpdb->prepare(
"SELECT timestamp FROM {$table} WHERE log_id > %d ORDER BY log_id ASC LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$lower
)
);
return null === $timestamp ? null : (int) strtotime( $timestamp . ' UTC' );
}
/**
* Converts one table row to the shape the file reader produces.
*
* @param array $result The row as `wpdb` returned it.
* @return array{timestamp: int, level: string, message: string, context: array|null, raw: string|null}
*/
private static function build_row( array $result ): array {
$context = null;
$raw = null;
if ( ! empty( $result['context'] ) ) {
$decoded = json_decode( (string) $result['context'], true );
if ( is_array( $decoded ) ) {
$context = $decoded;
} else {
$raw = (string) $result['context'];
}
}
return array(
'timestamp' => (int) strtotime( $result['timestamp'] . ' UTC' ),
'level' => (string) WC_Log_Levels::get_severity_level( (int) $result['level'] ),
'message' => (string) $result['message'],
'context' => $context,
'raw' => $raw,
);
}
}