$data Non-sensitive metadata only. */ public static function record(int $review_id, string $actor_type, ?int $actor_id, string $event_type, array $data = array()): void { global $wpdb; $events = Database::table('commentmate_audit_events'); $actor_type = substr(sanitize_key($actor_type), 0, 20); $event_type = substr(sanitize_key($event_type), 0, 100); $data_json = empty($data) ? null : wp_json_encode($data); if (!is_string($data_json) || strlen($data_json) > self::MAX_EVENT_DATA_BYTES) { $data_json = wp_json_encode(array('truncated' => true)); } $created_at = current_time('mysql', true); if ($review_id < 1) { $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Bounded audit retention uses plugin tables under a review lock; fresh counts and lock state keep inserts and trimming atomic. $events, array( 'review_id' => 0, 'actor_type' => $actor_type, 'actor_id' => $actor_id, 'event_type' => $event_type, 'data_json' => $data_json, 'created_at' => $created_at, ), array('%d', '%s', '%d', '%s', '%s', '%s') ); return; } // Take an explicit review-row lock rather than relying on INSERT…SELECT // source locking. Under READ COMMITTED, that source SELECT may be a // non-locking consistent read and can otherwise insert an audit row after // cleanup deletes this review. All current callers invoke Audit::record() // after their own mutation transaction commits, so this short transaction // cannot accidentally commit a caller's in-flight business mutation. $reviews = Database::table('commentmate_reviews'); if (false === $wpdb->query('START TRANSACTION')) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary for the surrounding plugin-table operation; caching cannot provide atomicity or row locks. return; } $committed = false; try { $review = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Bounded audit retention uses plugin tables under a review lock; fresh counts and lock state keep inserts and trimming atomic. $wpdb->prepare('SELECT id, cleanup_locked FROM %i WHERE id = %d FOR UPDATE', $reviews, $review_id), ARRAY_A ); if (!is_array($review) || (int) $review['cleanup_locked'] !== 0) { return; } // Guest mutations are rate limited but can occur over a long-lived // review. Keep an append-only recent window rather than allowing audit // storage to grow without bound; cleanup still removes this entire table // when the owner deletes the review. $count = $wpdb->get_var($wpdb->prepare('SELECT COUNT(*) FROM %i WHERE review_id = %d', $events, $review_id)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Bounded audit retention uses plugin tables under a review lock; fresh counts and lock state keep inserts and trimming atomic. if (!is_numeric($count)) { return; } $trim_count = ((int) $count) - self::MAX_EVENTS_PER_REVIEW + 1; if ($trim_count > 0 && false === $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Bounded audit retention uses plugin tables under a review lock; fresh counts and lock state keep inserts and trimming atomic. $wpdb->prepare('DELETE FROM %i WHERE review_id = %d ORDER BY id ASC LIMIT %d', $events, $review_id, $trim_count) )) { return; } $inserted = $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Bounded audit retention uses plugin tables under a review lock; fresh counts and lock state keep inserts and trimming atomic. $events, array( 'review_id' => $review_id, 'actor_type' => $actor_type, 'actor_id' => $actor_id, 'event_type' => $event_type, 'data_json' => $data_json, 'created_at' => $created_at, ), array('%d', '%s', '%d', '%s', '%s', '%s') ); if (false === $inserted || false === $wpdb->query('COMMIT')) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary for the surrounding plugin-table operation; caching cannot provide atomicity or row locks. return; } $committed = true; } catch (\Throwable) { // Audit events are observability only. Never turn an audit-storage issue // into a guest-visible failure after the underlying mutation committed. } finally { if (!$committed) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary for the surrounding plugin-table operation; caching cannot provide atomicity or row locks. } } } }