payload, true ); return array( 'id' => (int) $row->id, 'uuid' => (string) $row->uuid, 'event_id' => (string) $row->event_id, 'endpoint_id' => (int) $row->endpoint_id, 'event' => (string) $row->event, 'payload' => is_array( $payload ) ? $payload : array(), 'status' => (string) $row->status, 'attempts' => (int) $row->attempts, 'next_attempt_at' => $row->next_attempt_at ? (string) $row->next_attempt_at : null, 'response_code' => null === $row->response_code ? null : (int) $row->response_code, 'response_body' => (string) $row->response_body, 'error' => (string) $row->error, 'duration_ms' => null === $row->duration_ms ? null : (int) $row->duration_ms, 'is_test' => (bool) $row->is_test, 'created_at' => (string) $row->created_at, 'updated_at' => (string) $row->updated_at, ); } /** * Queue a delivery. * * @param int $endpoint_id Endpoint ID. * @param array $envelope Event envelope. * @param bool $is_test Test delivery. * @return int Delivery ID, 0 on failure. */ public static function create( $endpoint_id, array $envelope, $is_test = false ) { global $wpdb; $now = self::now(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table. $ok = $wpdb->insert( Installer::deliveries_table(), array( 'uuid' => 'msg_' . str_replace( '-', '', wp_generate_uuid4() ), 'event_id' => isset( $envelope['id'] ) ? (string) $envelope['id'] : '', 'endpoint_id' => (int) $endpoint_id, 'event' => isset( $envelope['event'] ) ? (string) $envelope['event'] : '', 'payload' => (string) wp_json_encode( $envelope, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION ), 'status' => 'pending', 'attempts' => 0, 'next_attempt_at' => $now, 'is_test' => $is_test ? 1 : 0, 'created_at' => $now, 'updated_at' => $now, ), array( '%s', '%s', '%d', '%s', '%s', '%s', '%d', '%s', '%d', '%s', '%s' ) ); return $ok ? (int) $wpdb->insert_id : 0; } /** * Get one delivery. * * @param int $id Delivery ID. * @return array|null */ public static function get( $id ) { global $wpdb; $table = Installer::deliveries_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Custom table. return self::hydrate( $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', $table, $id ) ) ); } /** * Update columns of a delivery. * * @param int $id Delivery ID. * @param array $fields Columns. */ public static function update( $id, array $fields ) { global $wpdb; $fields['updated_at'] = self::now(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. $wpdb->update( Installer::deliveries_table(), $fields, array( 'id' => (int) $id ) ); } /** * Atomically claim a pending delivery so two workers never send it twice. * * @param int $id Delivery ID. * @return bool */ public static function claim( $id ) { global $wpdb; $table = Installer::deliveries_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Custom table. $updated = $wpdb->query( $wpdb->prepare( "UPDATE %i SET status = 'processing', updated_at = %s WHERE id = %d AND status = 'pending'", $table, self::now(), $id ) ); return 1 === (int) $updated; } /** * Due deliveries for live endpoints. * * @param int $limit Max rows. * @return array[] */ public static function due( $limit ) { global $wpdb; $deliveries = Installer::deliveries_table(); $endpoints = Installer::endpoints_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Custom tables. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT d.* FROM %i d INNER JOIN %i e ON e.id = d.endpoint_id WHERE d.status = 'pending' AND d.next_attempt_at <= %s AND e.active = 1 AND e.paused_at IS NULL ORDER BY d.next_attempt_at ASC, d.id ASC LIMIT %d", $deliveries, $endpoints, self::now(), $limit ) ); return array_values( array_filter( array_map( array( __CLASS__, 'hydrate' ), (array) $rows ) ) ); } /** * Return deliveries stuck in "processing" (for example after a PHP timeout) to the queue. * * @return int Rows recovered. */ public static function recover_stuck() { global $wpdb; $table = Installer::deliveries_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Custom table. return (int) $wpdb->query( $wpdb->prepare( "UPDATE %i SET status = 'pending', updated_at = %s WHERE status = 'processing' AND updated_at < %s", $table, self::now(), self::now( -10 * MINUTE_IN_SECONDS ) ) ); } /** * Earliest next attempt among pending deliveries for live endpoints. * * @return int|null Unix timestamp. */ public static function next_due_time() { global $wpdb; $deliveries = Installer::deliveries_table(); $endpoints = Installer::endpoints_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Custom tables. $next = $wpdb->get_var( $wpdb->prepare( "SELECT MIN(d.next_attempt_at) FROM %i d INNER JOIN %i e ON e.id = d.endpoint_id WHERE d.status = 'pending' AND e.active = 1 AND e.paused_at IS NULL", $deliveries, $endpoints ) ); return $next ? (int) strtotime( $next . ' UTC' ) : null; } /** * Cancel pending deliveries for an endpoint. * * @param int $endpoint_id Endpoint ID. */ public static function cancel_pending_for_endpoint( $endpoint_id ) { global $wpdb; $table = Installer::deliveries_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Custom table. $wpdb->query( $wpdb->prepare( "UPDATE %i SET status = 'cancelled', next_attempt_at = NULL, updated_at = %s WHERE endpoint_id = %d AND status IN ('pending','processing')", $table, self::now(), $endpoint_id ) ); } /** * Paginated list. * * Every filter is always part of the (fixed) SQL; an empty filter value turns its * condition into a no-op, so the query text never has to be assembled at runtime. * * @param array $args Filters (endpoint_id, event, status, search) plus per_page and page. * @return array { items: array[], total: int } */ public static function query( array $args = array() ) { global $wpdb; $table = Installer::deliveries_table(); $per_page = isset( $args['per_page'] ) ? min( 100, max( 1, (int) $args['per_page'] ) ) : 25; $page = isset( $args['page'] ) ? max( 1, (int) $args['page'] ) : 1; $offset = ( $page - 1 ) * $per_page; $endpoint = ! empty( $args['endpoint_id'] ) ? (int) $args['endpoint_id'] : 0; $event = ! empty( $args['event'] ) ? (string) $args['event'] : ''; $status = ! empty( $args['status'] ) && in_array( $args['status'], self::STATUSES, true ) ? (string) $args['status'] : ''; $search = ! empty( $args['search'] ) ? (string) $args['search'] : ''; $like = '%' . $wpdb->esc_like( $search ) . '%'; // phpcs:disable WordPress.DB.DirectDatabaseQuery -- Custom table; results are paginated log rows that change constantly. $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE ( %d = 0 OR endpoint_id = %d ) AND ( %s = '' OR event = %s ) AND ( %s = '' OR status = %s ) AND ( %s = '' OR event_id = %s OR uuid = %s OR payload LIKE %s )", $table, $endpoint, $endpoint, $event, $event, $status, $status, $search, $search, $search, $like ) ); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE ( %d = 0 OR endpoint_id = %d ) AND ( %s = '' OR event = %s ) AND ( %s = '' OR status = %s ) AND ( %s = '' OR event_id = %s OR uuid = %s OR payload LIKE %s ) ORDER BY id DESC LIMIT %d OFFSET %d", $table, $endpoint, $endpoint, $event, $event, $status, $status, $search, $search, $search, $like, $per_page, $offset ) ); // phpcs:enable return array( 'items' => array_values( array_filter( array_map( array( __CLASS__, 'hydrate' ), (array) $rows ) ) ), 'total' => $total, ); } /** * Delivery counts by status for the last N hours, per endpoint. * * @param int $hours Window. * @return array endpoint_id => [ status => count, avg_ms => int ] */ public static function stats( $hours = 24 ) { global $wpdb; $table = Installer::deliveries_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Custom table. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT endpoint_id, status, COUNT(*) AS total, AVG(duration_ms) AS avg_ms FROM %i WHERE created_at >= %s AND is_test = 0 GROUP BY endpoint_id, status', $table, self::now( -$hours * HOUR_IN_SECONDS ) ) ); $stats = array(); foreach ( (array) $rows as $row ) { $id = (int) $row->endpoint_id; if ( ! isset( $stats[ $id ] ) ) { $stats[ $id ] = array( 'pending' => 0, 'success' => 0, 'failed' => 0, 'avg_ms' => 0, ); } $key = 'processing' === $row->status ? 'pending' : $row->status; $stats[ $id ][ $key ] = ( isset( $stats[ $id ][ $key ] ) ? $stats[ $id ][ $key ] : 0 ) + (int) $row->total; if ( 'success' === $row->status ) { $stats[ $id ]['avg_ms'] = (int) round( (float) $row->avg_ms ); } } return $stats; } /** * Count deliveries by status. * * @param string $status Status. * @return int */ public static function count( $status ) { global $wpdb; $table = Installer::deliveries_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Custom table. return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE status = %s', $table, $status ) ); } /** * Daily cleanup (cron callback). WordPress passes an empty string to callbacks of events * without arguments, so this must not forward its argument to purge(). */ public static function cleanup() { self::purge(); } /** * Delete finished deliveries older than the retention period. * * @param int|null $days Days to keep. Defaults to the setting. * @return int Rows deleted. */ public static function purge( $days = null ) { global $wpdb; $table = Installer::deliveries_table(); $days = null === $days || '' === $days ? (int) Settings::get( 'retention_days' ) : max( 0, (int) $days ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Custom table. return (int) $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE status IN ('success','failed','cancelled') AND created_at <= %s", $table, self::now( -$days * DAY_IN_SECONDS ) ) ); } }