prefix . 'launchlist_queue'; } /** * Create or update the table. * * @return void */ public static function install() { $db = self::db(); require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $table = self::table(); $collate = $db->get_charset_collate(); dbDelta( "CREATE TABLE {$table} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, delivery_id varchar(36) NOT NULL, form_id varchar(40) NOT NULL DEFAULT '', payload longtext NOT NULL, attempts smallint(5) unsigned NOT NULL DEFAULT 0, next_attempt_at datetime NOT NULL, last_error varchar(100) NOT NULL DEFAULT '', status varchar(10) NOT NULL DEFAULT 'pending', created_at datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY delivery_id (delivery_id), KEY status_next_attempt (status,next_attempt_at), KEY form_status (form_id,status) ) {$collate};" ); update_option( self::DB_VERSION_OPTION, self::DB_VERSION ); } /** * Install the table when this version has not yet (an update, or a site of a network). * * @return void */ public static function maybe_install() { if ( self::DB_VERSION !== get_option( self::DB_VERSION_OPTION ) ) { self::install(); } } /** * Drop the table (uninstall only). * * @return void */ public static function drop() { $table = self::table(); self::db()->query( "DROP TABLE IF EXISTS {$table}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange -- Our own table name. wp_clear_scheduled_hook( self::HOOK ); delete_option( self::DB_VERSION_OPTION ); delete_option( self::WAITING_OPTION ); } /** * On `init`: put the event back if the table has rows but the event went missing. * * @return void */ public static function ensure_scheduled() { if ( get_option( self::WAITING_OPTION ) && ! wp_next_scheduled( self::HOOK ) ) { wp_schedule_single_event( time() + MINUTE_IN_SECONDS, self::HOOK ); } } /** * Keep a submission to send again, unless the table is full. * * @param array $payload The submission (its `id` is kept across tries). * @param string $reason Why it was not taken. * @param int $delay Seconds until the next try. * @param int $attempts Tries made so far. * @param int|null $now The current time. * @return string {@see Queue::STORED}, {@see Queue::FULL} or {@see Queue::ERROR}. */ public static function push( array $payload, $reason, $delay, $attempts = 1, $now = null ) { $now = null === $now ? time() : (int) $now; $json = wp_json_encode( $payload, Client::JSON_FLAGS ); $form = isset( $payload['form'] ) ? substr( (string) $payload['form'], 0, 40 ) : ''; if ( ! is_string( $json ) || empty( $payload['id'] ) ) { return self::ERROR; } $db = self::db(); $table = self::table(); $suppress = $db->suppress_errors( true ); // A database error here must never reach the visitor's form. try { self::maybe_install(); // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Our own table; values prepared. $waiting = (int) $db->get_var( $db->prepare( "SELECT COUNT(*) FROM {$table} WHERE status = %s", self::PENDING ) ); $for_form = (int) $db->get_var( $db->prepare( "SELECT COUNT(*) FROM {$table} WHERE status = %s AND form_id = %s", self::PENDING, $form ) ); // phpcs:enable if ( $waiting >= self::MAX_PENDING || $for_form >= self::MAX_PER_FORM ) { return self::FULL; } $next = $now + max( 0, (int) $delay ); $stored = $db->insert( $table, array( 'delivery_id' => (string) $payload['id'], 'form_id' => $form, 'payload' => $json, 'attempts' => (int) $attempts, 'next_attempt_at' => self::datetime( $next ), 'last_error' => substr( (string) $reason, 0, 100 ), 'status' => self::PENDING, 'created_at' => self::datetime( $now ), ), array( '%s', '%s', '%s', '%d', '%s', '%s', '%s', '%s' ) ); } finally { $db->suppress_errors( $suppress ); } if ( false === $stored ) { return self::ERROR; } self::mark_waiting(); self::schedule( $next ); return self::STORED; } /** * The cron event: send what is due. * * @return void */ public static function run() { try { self::process(); } catch ( \Throwable $e ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( 'LaunchList: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } } } /** * Send the rows that are due, batch after batch, until none is due or the run's 20 seconds are * spent; then schedule the next run (at once while due rows remain). One run at a time. * * Rows of a LaunchList form that asked us to wait (429, 503 `paused`) are moved past that wait * and the run carries on with the other forms; a failure of LaunchList as a whole (network, 5xx, * a reply that is not LaunchList's) ends the run until the hold it sets has passed. * * @param int|null $now The current time. * @return array{sent: int, waiting: int, refused: int, failed: int} */ public static function process( $now = null ) { $now = null === $now ? time() : (int) $now; $deadline = microtime( true ) + self::BUDGET; $tally = array( 'sent' => 0, 'waiting' => 0, 'refused' => 0, 'failed' => 0, ); $lock = self::lock(); if ( false === $lock ) { return $tally; } try { self::purge( $now ); if ( ! Connection::is_active() ) { // Nothing can be sent until the site connects again; stop holding rows past 24 hours. $tally['failed'] += self::expire( $now ); } elseif ( null === Relay::site_hold() ) { self::drain( $now, $deadline, $tally ); } } finally { self::unlock( $lock ); } $hold = Relay::site_hold(); self::reschedule( null === $hold ? 0 : $hold['until'] ); return $tally; } /** * What to do with a row after one try. Pure, so the rules are tested on their own. * * - accepted: delete it. * - the connection is revoked: keep it untouched (sent once the site connects again, while * within the 24 hours) and stop the run. * - retryable: count the try; at 24 hours since it arrived, mark it failed; otherwise wait * (backoff with jitter, at least `Retry-After`, never past the 24 hours). Its `scope` says what * else waits: only this row (409), its LaunchList form (429, 503 `paused`), or everything * (anything else), which stops the run. * - refused for good by LaunchList: delete it. * * @param array $row The row (`attempts`, `created_at`). * @param Result $result The answer. * @param int $now The current time. * @param float|null $jitter Multiplier for the delay, random 0.8 to 1.2 when null. * @return array{action: string, next: int, attempts: int, stop: bool, scope: string, tally: string} */ public static function decide( array $row, Result $result, $now, $jitter = null ) { $attempts = (int) $row['attempts']; if ( $result->ok() ) { return self::decision( 'delete', 0, $attempts + 1, 'row', false, 'sent' ); } if ( $result->revoked() ) { return self::decision( 'hold', 0, $attempts, 'site', true, 'waiting' ); } if ( $result->retryable ) { $attempts = $attempts + 1; $give_up = self::timestamp( $row['created_at'] ) + self::GIVE_UP_AFTER; $scope = 409 === $result->status ? 'row' : ( $result->form_wide() ? 'form' : 'site' ); $stop = 'site' === $scope; if ( $now >= $give_up ) { return self::decision( 'fail', $now, $attempts, $scope, $stop, 'failed' ); } $next = min( $now + self::backoff( $attempts, $result->retry_after, $jitter ), $give_up ); return self::decision( 'retry', $next, $attempts, $scope, $stop, 'waiting' ); } return self::decision( 'delete', 0, $attempts + 1, 'row', false, 'refused' ); } /** * Seconds to wait before the next try: 1, 2, 4, 8 … minutes up to an hour, with 20% jitter, and * never less than `Retry-After`. * * @param int $attempts Tries made so far (1 or more). * @param int|null $retry_after The server's `Retry-After`. * @param float|null $jitter Multiplier, random 0.8 to 1.2 when null. * @return int */ public static function backoff( $attempts, $retry_after = null, $jitter = null ) { $attempts = max( 1, (int) $attempts ); $delay = min( self::MAX_DELAY, self::BASE_DELAY * pow( 2, min( $attempts - 1, 12 ) ) ); if ( null === $jitter ) { $jitter = wp_rand( 80, 120 ) / 100; } $delay = (int) round( $delay * (float) $jitter ); if ( null !== $retry_after ) { $delay = max( $delay, (int) $retry_after ); } return $delay; } /** * Make every waiting row, and every row the plugin stopped trying, due now. A row past its 24 * hours gets exactly one more try. The caller then runs {@see Queue::process()}. * * The founder asked for a try, so the hold an outage left on every form is lifted: without that, * Retry now in the minute after LaunchList came back sends nothing. A failure sets it again. A * hold LaunchList asked for on one form (429, 503 `paused`) stays. * * @param int|null $now The current time. * @return void */ public static function retry_now( $now = null ) { $now = null === $now ? time() : (int) $now; $db = self::db(); $table = self::table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Our own table; values prepared. $db->query( $db->prepare( "UPDATE {$table} SET status = %s, next_attempt_at = %s WHERE status IN (%s, %s)", self::PENDING, self::datetime( $now ), self::PENDING, self::FAILED ) ); delete_transient( Relay::HOLD ); } /** * How many rows are waiting and how many the plugin stopped trying. * * @return array{pending: int, failed: int} */ public static function counts() { $counts = array( 'pending' => 0, 'failed' => 0, ); $db = self::db(); $table = self::table(); $suppress = $db->suppress_errors( true ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Our own table, no input. $rows = $db->get_results( "SELECT status, COUNT(*) AS total FROM {$table} GROUP BY status", ARRAY_A ); $db->suppress_errors( $suppress ); foreach ( is_array( $rows ) ? $rows : array() as $row ) { if ( isset( $counts[ $row['status'] ] ) ) { $counts[ $row['status'] ] = (int) $row['total']; } } return $counts; } /** * Delete every row and the event (on disconnect: the rows can no longer be sent). * * @param bool $record Add a "deleted" entry to the delivery list for the latest rows. * @return void */ public static function clear( $record = false ) { $db = self::db(); $table = self::table(); $suppress = $db->suppress_errors( true ); if ( $record ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Our own table; values prepared. $rows = $db->get_results( $db->prepare( "SELECT * FROM {$table} ORDER BY id DESC LIMIT %d", Activity::LIMIT ), ARRAY_A ); foreach ( array_reverse( is_array( $rows ) ? $rows : array() ) as $row ) { $payload = json_decode( (string) $row['payload'], true ); Activity::record( is_array( $payload ) ? $payload : array(), Activity::DELETED, 'disconnected' ); } } $db->query( "DELETE FROM {$table}" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Our own table, no input. $db->suppress_errors( $suppress ); wp_clear_scheduled_hook( self::HOOK ); delete_option( self::WAITING_OPTION ); } /** * Rows whose stored submission contains a string, ignoring case (the privacy exporter and eraser * look up an email address this way, then check each row's values exactly). * * @param string $needle The string. * @return array> */ public static function rows_containing( $needle ) { $db = self::db(); $table = self::table(); $suppress = $db->suppress_errors( true ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Our own table; values prepared. // LOWER() so the match never depends on the table's collation; the table is capped, so a scan is cheap. $rows = $db->get_results( $db->prepare( "SELECT * FROM {$table} WHERE LOWER(payload) LIKE %s ORDER BY id ASC LIMIT %d", '%' . $db->esc_like( strtolower( (string) $needle ) ) . '%', self::MAX_PENDING * 2 ), ARRAY_A ); $db->suppress_errors( $suppress ); return is_array( $rows ) ? $rows : array(); } /** * Delete rows by ID. * * @param array $ids Row IDs. * @return void */ public static function delete_rows( array $ids ) { foreach ( $ids as $id ) { self::delete( $id ); } self::reschedule(); } /** * Schedule the next run at the earliest time a row needs one (a try while connected; while not, * the moment a row turns 24 hours old; and the moment a failed row is due for deletion), or * remove the event when the table is empty. * * @param int $not_before Not before this time (a hold on every form). * @return void */ public static function reschedule( $not_before = 0 ) { $next = self::earliest(); if ( null === $next ) { wp_clear_scheduled_hook( self::HOOK ); delete_option( self::WAITING_OPTION ); return; } self::mark_waiting(); self::schedule( max( $next, (int) $not_before ), true ); } /** * Send due rows, batch after batch, until none is due, the deadline passes, or LaunchList as a * whole fails. * * @param int $now The current time. * @param float $deadline `microtime( true )` to stop at. * @param array $tally Counts, updated. * @return void */ private static function drain( $now, $deadline, array &$tally ) { while ( microtime( true ) < $deadline ) { $rows = self::due( $now, self::BATCH ); if ( empty( $rows ) ) { return; } foreach ( $rows as $row ) { if ( microtime( true ) >= $deadline ) { return; } $payload = json_decode( (string) $row['payload'], true ); if ( ! is_array( $payload ) ) { self::delete( $row['id'] ); continue; } $form = (string) $row['form_id']; $hold = Relay::form_hold( $form ); if ( null !== $hold ) { self::defer_form( $form, max( $hold['until'], $now + MINUTE_IN_SECONDS ), $now ); continue; } $result = Client::submit( $payload ); $decision = self::decide( $row, $result, $now ); self::apply( $row, $payload, $result, $decision, $now ); ++$tally[ $decision['tally'] ]; if ( 'retry' === $decision['action'] && 'form' === $decision['scope'] ) { $hold = Relay::hold( $result, $form ); self::defer_form( $form, max( null === $hold ? 0 : $hold['until'], $now + MINUTE_IN_SECONDS ), $now ); } if ( $decision['stop'] ) { if ( 'site' === $decision['scope'] && 'hold' !== $decision['action'] ) { Relay::hold( $result, $form ); } return; } } } } /** * Apply a decision to a row. * * @param array $row The row. * @param array $payload Its decoded payload. * @param Result $result The answer. * @param array $decision From {@see Queue::decide()}. * @param int $now The current time. * @return void */ private static function apply( array $row, array $payload, Result $result, array $decision, $now ) { switch ( $decision['action'] ) { case 'delete': self::delete( $row['id'] ); if ( $result->ok() ) { Activity::record( $payload, Activity::SENT, '', $result->status, true ); Relay::received(); } else { Activity::record( $payload, Activity::REFUSED, $result->reason, $result->status, true ); } return; case 'fail': self::update( $row['id'], array( 'status' => self::FAILED, 'attempts' => $decision['attempts'], 'next_attempt_at' => self::datetime( $now ), 'last_error' => self::error_code( $result ), ) ); Activity::record( $payload, Activity::FAILED, 'gave_up', $result->status, true ); return; case 'retry': self::update( $row['id'], array( 'attempts' => $decision['attempts'], 'next_attempt_at' => self::datetime( $decision['next'] ), 'last_error' => self::error_code( $result ), ) ); return; } } /** * Mark rows still waiting 24 hours after they arrived as failed, while the connection does not * work (nothing can try them). * * @param int $now The current time. * @return int How many. */ private static function expire( $now ) { $db = self::db(); $table = self::table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Our own table; values prepared. $rows = $db->get_results( $db->prepare( "SELECT * FROM {$table} WHERE status = %s AND created_at <= %s ORDER BY id ASC LIMIT %d", self::PENDING, self::datetime( $now - self::GIVE_UP_AFTER ), self::MAX_PENDING ), ARRAY_A ); $count = 0; foreach ( is_array( $rows ) ? $rows : array() as $row ) { self::update( $row['id'], array( 'status' => self::FAILED, 'next_attempt_at' => self::datetime( $now ), 'last_error' => 'expired', ) ); $payload = json_decode( (string) $row['payload'], true ); Activity::record( is_array( $payload ) ? $payload : array(), Activity::FAILED, 'expired', 0, true ); ++$count; } return $count; } /** * Delete rows the plugin stopped trying more than seven days ago. * * @param int $now The current time. * @return void */ private static function purge( $now ) { $db = self::db(); $table = self::table(); // `next_attempt_at` of a failed row is when the plugin stopped trying. // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Our own table; values prepared. $db->query( $db->prepare( "DELETE FROM {$table} WHERE status = %s AND next_attempt_at <= %s", self::FAILED, self::datetime( $now - self::KEEP_FAILED ) ) ); } /** * The rows due now, oldest first. * * @param int $now The current time. * @param int $limit Rows. * @return array> */ private static function due( $now, $limit ) { $db = self::db(); $table = self::table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Our own table; values prepared. $rows = $db->get_results( $db->prepare( "SELECT * FROM {$table} WHERE status = %s AND next_attempt_at <= %s ORDER BY next_attempt_at ASC, id ASC LIMIT %d", self::PENDING, self::datetime( $now ), (int) $limit ), ARRAY_A ); return is_array( $rows ) ? $rows : array(); } /** * Move one LaunchList form's due rows past a hold, so the run skips them and carries on. * * @param string $form The LaunchList form ID. * @param int $until The hold's end. * @param int $now The current time. * @return void */ private static function defer_form( $form, $until, $now ) { $db = self::db(); $table = self::table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Our own table; values prepared. $db->query( $db->prepare( "UPDATE {$table} SET next_attempt_at = %s WHERE status = %s AND form_id = %s AND next_attempt_at <= %s", self::datetime( $until ), self::PENDING, (string) $form, self::datetime( $now ) ) ); } /** * When the next run is needed, or null when the table is empty. * * @return int|null */ private static function earliest() { $db = self::db(); $table = self::table(); $times = array(); $suppress = $db->suppress_errors( true ); // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Our own table; values prepared. if ( Connection::is_active() ) { $next = $db->get_var( $db->prepare( "SELECT MIN(next_attempt_at) FROM {$table} WHERE status = %s", self::PENDING ) ); if ( $next ) { $times[] = self::timestamp( $next ); } } else { $oldest = $db->get_var( $db->prepare( "SELECT MIN(created_at) FROM {$table} WHERE status = %s", self::PENDING ) ); if ( $oldest ) { $times[] = self::timestamp( $oldest ) + self::GIVE_UP_AFTER; } } $failed = $db->get_var( $db->prepare( "SELECT MIN(next_attempt_at) FROM {$table} WHERE status = %s", self::FAILED ) ); // phpcs:enable $db->suppress_errors( $suppress ); if ( $failed ) { $times[] = self::timestamp( $failed ) + self::KEEP_FAILED; } return empty( $times ) ? null : min( $times ); } /** * Keep one single event: at exactly this time (after a run, which computed it from the table), * or no later than this time (after a push, which only knows about its own row). * * @param int $when Unix time. * @param bool $exact Set it at this time even when an earlier event exists. * @return void */ private static function schedule( $when, $exact = false ) { $when = max( (int) $when, time() ); $next = wp_next_scheduled( self::HOOK ); if ( false !== $next && ( $exact ? (int) $next === $when : (int) $next <= $when ) ) { return; } wp_clear_scheduled_hook( self::HOOK ); wp_schedule_single_event( $when, self::HOOK ); } /** * Set the flag that says the table has rows. * * @return void */ private static function mark_waiting() { if ( ! get_option( self::WAITING_OPTION ) ) { update_option( self::WAITING_OPTION, '1' ); } } /** * Update one row. * * @param int|string $id Row ID. * @param array $fields Columns. * @return void */ private static function update( $id, array $fields ) { self::db()->update( self::table(), $fields, array( 'id' => (int) $id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery } /** * Delete one row. * * @param int|string $id Row ID. * @return void */ private static function delete( $id ) { self::db()->delete( self::table(), array( 'id' => (int) $id ), array( '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery } /** * Take the run lock: a MySQL named lock, or a transient where named locks are not available. * * @return string|false The kind of lock taken, or false when another run holds it. */ private static function lock() { $db = self::db(); $suppress = $db->suppress_errors( true ); $got = $db->get_var( $db->prepare( 'SELECT GET_LOCK(%s, 0)', self::lock_name() ) ); $db->suppress_errors( $suppress ); if ( '1' === (string) $got ) { return 'mysql'; } if ( null !== $got ) { return false; } if ( get_transient( 'launchlist_queue_lock' ) ) { return false; } set_transient( 'launchlist_queue_lock', 1, 2 * MINUTE_IN_SECONDS ); return 'transient'; } /** * Release the run lock. * * @param string $kind What {@see Queue::lock()} returned. * @return void */ private static function unlock( $kind ) { if ( 'mysql' === $kind ) { $db = self::db(); $db->query( $db->prepare( 'SELECT RELEASE_LOCK(%s)', self::lock_name() ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery return; } delete_transient( 'launchlist_queue_lock' ); } /** * The named lock. MySQL lock names are server-wide, so it names this database and table prefix * (two sites on one MySQL server never share it). At most 64 characters. * * @return string */ public static function lock_name() { $database = defined( 'DB_NAME' ) ? (string) DB_NAME : ''; return 'launchlist_queue_' . md5( $database . '|' . self::db()->prefix ); } /** * A short code for why a try failed. * * @param Result $result The answer. * @return string */ private static function error_code( Result $result ) { $code = 0 === $result->status ? 'network' : 'http_' . $result->status; return '' !== $result->reason && 'network' !== $result->reason ? $code . ':' . $result->reason : $code; } /** * A decision. * * @param string $action delete, hold, retry or fail. * @param int $next Next try (retry) or the time it stopped (fail). * @param int $attempts Tries made. * @param string $scope What else waits: row, form or site. * @param bool $stop Stop this run. * @param string $tally sent, waiting, refused or failed. * @return array{action: string, next: int, attempts: int, stop: bool, scope: string, tally: string} */ private static function decision( $action, $next, $attempts, $scope, $stop, $tally ) { return array( 'action' => $action, 'next' => (int) $next, 'attempts' => (int) $attempts, 'scope' => $scope, 'stop' => (bool) $stop, 'tally' => $tally, ); } /** * A UTC `DATETIME`. * * @param int $time Unix time. * @return string */ public static function datetime( $time ) { return gmdate( 'Y-m-d H:i:s', (int) $time ); } /** * A UTC `DATETIME` as Unix time. * * @param string $datetime The value. * @return int */ private static function timestamp( $datetime ) { $time = strtotime( (string) $datetime . ' UTC' ); return false === $time ? 0 : $time; } /** * The database. * * @return wpdb */ private static function db() { global $wpdb; return $wpdb; } }