From 2b4477eefb93a85391baec98356c1cb9b9c0244e Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Sat, 8 Aug 2026 02:41:43 +0600 Subject: [PATCH] feat: add self-healing sync queue for failed writes Adds a persistent journal of failed Elasticsearch/OpenSearch write operations (index, delete, bulk index) and a WP-CLI command to replay them when the search server is reachable again. New files: - FailedWrites.php - journal class with dbDelta table, record(), get_pending(), delete_entries(), count_pending() methods - TestFailedWrites.php - 13 tests covering all journal operations Modified files: - elasticpress.php - bump version to 5.4.0, instantiate FailedWrites - Command.php - add replay-queue subcommand with --limit, --dry-run, --skip-health-check flags - Indexable.php - fire ep_after_delete_{slug} hook for delete capture - Upgrades.php - add 5.4.0 upgrade routine creating the journal table - uninstall.php - drop the journal table on uninstall Fixes #4226 --- elasticpress.php | 7 +- includes/classes/Command.php | 136 +++++++++++ includes/classes/FailedWrites.php | 360 ++++++++++++++++++++++++++++++ includes/classes/Indexable.php | 15 +- includes/classes/Upgrades.php | 14 ++ tests/php/TestFailedWrites.php | 240 ++++++++++++++++++++ uninstall.php | 25 +++ 7 files changed, 795 insertions(+), 2 deletions(-) create mode 100644 includes/classes/FailedWrites.php create mode 100644 tests/php/TestFailedWrites.php diff --git a/elasticpress.php b/elasticpress.php index a6bcdd86cc..870c1b00ca 100644 --- a/elasticpress.php +++ b/elasticpress.php @@ -33,7 +33,7 @@ define( 'EP_URL', plugin_dir_url( __FILE__ ) ); define( 'EP_PATH', plugin_dir_path( __FILE__ ) ); define( 'EP_FILE', plugin_basename( __FILE__ ) ); -define( 'EP_VERSION', '5.3.3' ); +define( 'EP_VERSION', '5.4.0' ); define( 'EP_PHP_VERSION_MIN', '7.4' ); @@ -267,6 +267,11 @@ function register_indexable_posts() { */ Upgrades::factory(); +/** + * Setup failed writes journal. + */ +FailedWrites::factory(); + /** * Handle upgrades. Certain version require a re-sync on upgrade. * Deprecated in favor of `\ElasticPress\Upgrades::factory()`. diff --git a/includes/classes/Command.php b/includes/classes/Command.php index ed8b2ef74c..95697baace 100644 --- a/includes/classes/Command.php +++ b/includes/classes/Command.php @@ -1115,6 +1115,142 @@ public function get_last_cli_sync( $args, $assoc_args ) { $this->pretty_json_encode( $last_sync, $pretty ); } + /** + * Replay entries from the failed writes journal. + * + * When Elasticsearch is briefly unreachable, EP records failed + * index/delete operations in a journal. This command retries them + * one batch at a time, deleting each entry on success. + * + * ## OPTIONS + * + * [--limit=] + * : Max number of entries to replay this run. Default: 50. + * + * [--dry-run] + * : List entries that would be replayed without sending requests. + * + * [--skip-health-check] + * : Skip the Elasticsearch reachability probe. + * + * ## EXAMPLES + * + * $ wp elasticpress replay-queue + * $ wp elasticpress replay-queue --limit=200 + * $ wp elasticpress replay-queue --dry-run + * + * @subcommand replay-queue + * @since 5.4.0 + * @param array $args Positional CLI args. + * @param array $assoc_args Associative CLI args. + */ + public function replay_queue( $args, $assoc_args ) { + $limit = (int) \WP_CLI\Utils\get_flag_value( $assoc_args, 'limit', 50 ); + $dry_run = (bool) \WP_CLI\Utils\get_flag_value( $assoc_args, 'dry-run', false ); + $skip_health = (bool) \WP_CLI\Utils\get_flag_value( $assoc_args, 'skip-health-check', false ); + + $journal = FailedWrites::factory(); + + $total = $journal->count_pending(); + + if ( ! $total ) { + WP_CLI::success( esc_html__( 'No failed writes to replay.', 'elasticpress' ) ); + return; + } + + $rows = $journal->get_pending( $limit ); + + /* translators: %d: count */ + WP_CLI::log( sprintf( esc_html__( 'Found %1$d pending entries; replaying up to %2$d.', 'elasticpress' ), (int) $total, count( $rows ) ) ); + + if ( $dry_run ) { + foreach ( $rows as $row ) { + /* translators: 1: slug, 2: action, 3: object id */ + WP_CLI::line( sprintf( esc_html__( 'Would replay: %1$s %2$s %3$d', 'elasticpress' ), $row->indexable_slug, $row->action, (int) $row->object_id ) ); + } + return; + } + + if ( ! $skip_health && ! Elasticsearch::factory()->get_elasticsearch_version( true ) ) { + WP_CLI::error( esc_html__( 'Elasticsearch is unreachable. Aborting to avoid stacking failures. Re-run with --skip-health-check to override.', 'elasticpress' ) ); + } + + $progress = \WP_CLI\Utils\make_progress_bar( esc_html__( 'Replaying failed writes', 'elasticpress' ), count( $rows ) ); + + $replayed = 0; + $failed = 0; + + foreach ( $rows as $row ) { + $progress->tick(); + + $result = $this->replay_single_entry( $row ); + + if ( true === $result ) { + $journal->delete_entries( [ $row->id ] ); + ++$replayed; + } else { + ++$failed; + if ( is_string( $result ) && $result ) { + $journal->update_entry( $row->id, [ 'error_message' => $result ] ); + } + } + } + + $progress->finish(); + + /* translators: 1: replayed count, 2: still-failing count */ + WP_CLI::log( sprintf( esc_html__( 'Replayed: %1$d. Still failing: %2$d.', 'elasticpress' ), $replayed, $failed ) ); + + if ( $failed ) { + WP_CLI::warning( sprintf( /* translators: %d: failed count */ esc_html__( '%d entries could not be replayed and remain in the journal.', 'elasticpress' ), $failed ) ); + } else { + WP_CLI::success( esc_html__( 'All replayed entries succeeded.', 'elasticpress' ) ); + } + } + + /** + * Replay a single journal entry. + * + * @param object $row Journal row. + * @since 5.4.0 + * @return bool|string True on success; false or error message on failure. + */ + protected function replay_single_entry( $row ) { + $indexable = Indexables::factory()->get( $row->indexable_slug ); + + if ( ! $indexable ) { + return sprintf( 'unknown indexable slug: %s', $row->indexable_slug ); + } + + $switched = false; + if ( is_multisite() && ! empty( $row->blog_id ) && (int) get_current_blog_id() !== (int) $row->blog_id ) { + switch_to_blog( (int) $row->blog_id ); + $switched = true; + } + + try { + if ( 'delete' === $row->action ) { + $return = $indexable->delete( (int) $row->object_id, true ); + } else { + $return = $indexable->index( (int) $row->object_id, true ); + } + } finally { + if ( $switched ) { + restore_current_blog(); + } + } + + if ( ! empty( $return ) && ! is_wp_error( $return ) ) { + return true; + } + + if ( is_wp_error( $return ) ) { + return $return->get_error_message(); + } + + return false; + } + /** * maybe change Elastic host on the fly diff --git a/includes/classes/FailedWrites.php b/includes/classes/FailedWrites.php new file mode 100644 index 0000000000..5a9c834f8a --- /dev/null +++ b/includes/classes/FailedWrites.php @@ -0,0 +1,360 @@ +setup(); + } + + return self::$instance; + } + + /** + * Initialize hooks. + * + * @since 5.4.0 + */ + public function setup() { + add_action( 'ep_after_index_post', [ $this, 'maybe_capture_index' ], 10, 2 ); + add_action( 'ep_after_index_comment', [ $this, 'maybe_capture_index' ], 10, 2 ); + add_action( 'ep_after_index_term', [ $this, 'maybe_capture_index' ], 10, 2 ); + add_action( 'ep_after_delete_post', [ $this, 'capture_delete' ], 10, 2 ); + add_action( 'ep_after_delete_comment', [ $this, 'capture_delete' ], 10, 2 ); + add_action( 'ep_after_delete_term', [ $this, 'capture_delete' ], 10, 2 ); + add_action( 'ep_after_bulk_index', [ $this, 'maybe_capture_bulk' ], 10, 3 ); + } + + /** + * Return the fully prefixed table name. + * + * @since 5.4.0 + * @return string + */ + public static function get_table_name() { + global $wpdb; + return $wpdb->prefix . self::TABLE; + } + + /** + * Return the SQL used to (re)create the table via dbDelta. + * + * @since 5.4.0 + * @return string + */ + public static function get_table_schema() { + global $wpdb; + + $table = self::get_table_name(); + $charset = $wpdb->get_charset_collate(); + + return "CREATE TABLE {$table} ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + indexable_slug varchar(32) NOT NULL, + object_id bigint(20) unsigned NOT NULL, + action varchar(16) NOT NULL, + blog_id bigint(20) unsigned NOT NULL, + error_message text NULL, + created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY indexable_object (indexable_slug, object_id), + KEY created_at (created_at) + ) {$charset};"; + } + + /** + * Maybe capture a single index failure. + * + * @param array|object $document Document that was sent. + * @param mixed $response ES response. `false` indicates failure. + * @since 5.4.0 + */ + public function maybe_capture_index( $document, $response ) { + if ( ! empty( $response ) ) { + return; + } + + $slug = $this->detect_indexable_slug(); + + if ( ! $slug ) { + return; + } + + $object_id = is_array( $document ) ? (int) ( $document['ID'] ?? 0 ) : (int) ( $document->ID ?? 0 ); + + if ( ! $object_id ) { + return; + } + + $this->record( $slug, $object_id, 'index' ); + } + + /** + * Capture a delete failure. + * + * @param int $object_id Object id. + * @param mixed $response ES response. `false` indicates failure. + * @since 5.4.0 + */ + public function capture_delete( $object_id, $response ) { + if ( ! empty( $response ) ) { + return; + } + + $slug = $this->detect_slug_from_current_filter(); + + if ( ! $slug ) { + return; + } + + $this->record( $slug, (int) $object_id, 'delete' ); + } + + /** + * Maybe capture a bulk index failure. + * + * @param array $object_ids Object ids. + * @param string $slug Indexable slug. + * @param mixed $result Bulk result. WP_Error or array. + * @since 5.4.0 + */ + public function maybe_capture_bulk( $object_ids, $slug, $result ) { + if ( is_wp_error( $result ) ) { + foreach ( (array) $object_ids as $object_id ) { + $this->record( $slug, (int) $object_id, 'index', $result->get_error_message() ); + } + } + } + + /** + * Insert or refresh a journal entry. + * + * Dedupes by (indexable_slug, object_id) so a post failing repeatedly + * doesn't pile up rows. + * + * @param string $slug Indexable slug. + * @param int $id Object id. + * @param string $action 'index' or 'delete'. + * @param string|null $error Optional error message. + * @since 5.4.0 + * @return int|false Inserted id or false. + */ + public function record( $slug, $id, $action, $error = null ) { + global $wpdb; + + $slug = sanitize_key( $slug ); + $action = ( 'delete' === $action ) ? 'delete' : 'index'; + $id = (int) $id; + $blog = (int) get_current_blog_id(); + + if ( ! $slug || ! $id ) { + return false; + } + + $existing = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $wpdb->prepare( + 'SELECT id FROM %i WHERE indexable_slug = %s AND object_id = %d', + $this->table(), + $slug, + $id + ) + ); + + $data = [ + 'indexable_slug' => $slug, + 'object_id' => $id, + 'action' => $action, + 'blog_id' => $blog, + 'error_message' => $error ? sanitize_text_field( $error ) : null, + ]; + + $format = [ '%s', '%d', '%s', '%d', '%s' ]; + + if ( $existing ) { + $wpdb->update( $this->table(), $data, [ 'id' => (int) $existing ], $format, [ '%d' ] ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery + return (int) $existing; + } + + $wpdb->insert( $this->table(), $data, $format ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery + return (int) $wpdb->insert_id; + } + + /** + * Pull pending entries in FIFO order. + * + * @param int $limit Max rows. + * @since 5.4.0 + * @return array + */ + public function get_pending( $limit = 50 ) { + global $wpdb; + + $limit = max( 1, (int) $limit ); + + $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $wpdb->prepare( + 'SELECT * FROM %i ORDER BY id ASC LIMIT %d', + $this->table(), + $limit + ) + ); + + return is_array( $rows ) ? $rows : []; + } + + /** + * Delete journal entries by id. + * + * @param int[] $ids Entry ids. + * @since 5.4.0 + * @return int Rows deleted. + */ + public function delete_entries( array $ids ) { + global $wpdb; + + $ids = array_filter( array_map( 'intval', $ids ) ); + + if ( ! $ids ) { + return 0; + } + + $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); + $params = array_merge( [ $this->table() ], $ids ); + + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $sql = $wpdb->prepare( "DELETE FROM %i WHERE id IN ({$placeholders})", $params ); + + return (int) $wpdb->query( $sql ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared + } + + /** + * Count pending entries. + * + * @since 5.4.0 + * @return int + */ + public function count_pending() { + global $wpdb; + + return (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $this->table() ) + ); + } + + /** + * Drop a row by id. Used by replay when an error persists. + * + * @param int $id Entry id. + * @param array $data Fields to update. + * @since 5.4.0 + * @return int Rows affected. + */ + public function update_entry( $id, array $data ) { + global $wpdb; + + if ( empty( $data ) ) { + return 0; + } + + $formats = []; + foreach ( $data as $key => $value ) { + if ( 'error_message' === $key ) { + $data[ $key ] = $value ? sanitize_text_field( $value ) : null; + $formats[] = '%s'; + } elseif ( in_array( $key, [ 'object_id', 'blog_id' ], true ) ) { + $formats[] = '%d'; + } else { + $formats[] = '%s'; + } + } + + return (int) $wpdb->update( $this->table(), $data, [ 'id' => (int) $id ], $formats, [ '%d' ] ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery + } + + /** + * Resolve the indexable slug from the current action hook. + * + * @since 5.4.0 + * @return string|null + */ + protected function detect_slug_from_current_filter() { + $current = current_action(); + + if ( ! $current || 0 !== strpos( $current, 'ep_after_delete_' ) ) { + return null; + } + + return substr( $current, strlen( 'ep_after_delete_' ) ); + } + + /** + * Resolve the indexable slug from the current action hook. + * + * @since 5.4.0 + * @return string|null + */ + protected function detect_indexable_slug() { + $current = current_action(); + + if ( $current && 0 === strpos( $current, 'ep_after_index_' ) ) { + return substr( $current, strlen( 'ep_after_index_' ) ); + } + + return null; + } + + /** + * Resolve the table name with safe prefix interpolation. + * + * @since 5.4.0 + * @return string + */ + protected function table() { + return self::get_table_name(); + } +} diff --git a/includes/classes/Indexable.php b/includes/classes/Indexable.php index fb12a9081f..4a89b2b606 100644 --- a/includes/classes/Indexable.php +++ b/includes/classes/Indexable.php @@ -221,7 +221,20 @@ public function delete( $object_id, $blocking = true ) { */ do_action( 'ep_delete_' . $this->slug, $object_id, $this->slug ); - return Elasticsearch::factory()->delete_document( $this->get_index_name(), $this->slug, $object_id, $blocking ); + $return = Elasticsearch::factory()->delete_document( $this->get_index_name(), $this->slug, $object_id, $blocking ); + + /** + * Fires after an object deletion request + * + * @hook ep_after_delete_{indexable_slug} + * @param {int} $object_id Object id. + * @param {bool} $return True on success, false on failure. + * @param {string} $indexable_slug The slug of the indexable type that was deleted. + * @since 5.4.0 + */ + do_action( 'ep_after_delete_' . $this->slug, $object_id, $return, $this->slug ); + + return $return; } /** diff --git a/includes/classes/Upgrades.php b/includes/classes/Upgrades.php index 85514a8712..2845d1ca6d 100644 --- a/includes/classes/Upgrades.php +++ b/includes/classes/Upgrades.php @@ -51,6 +51,7 @@ public function setup() { '4.5.0' => [ 'upgrade_4_5_0', 'init' ], '4.7.0' => [ 'upgrade_4_7_0', 'init' ], '5.0.0' => [ 'upgrade_5_0_0', 'init' ], + '5.4.0' => [ 'upgrade_5_4_0', 'init' ], ]; array_walk( $routines, [ $this, 'run_upgrade_routine' ] ); @@ -344,6 +345,19 @@ public function resync_notice_4_0_0_instant_results( $notices ) { return $notices; } + /** + * Upgrade routine of v5.4.0. + * + * Create the failed writes journal table on upgrade and on a fresh install. + */ + public function upgrade_5_4_0() { + if ( ! function_exists( 'dbDelta' ) ) { + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + } + + dbDelta( FailedWrites::get_table_schema() ); + } + /** * Check if a reindex is needed based on the version number. */ diff --git a/tests/php/TestFailedWrites.php b/tests/php/TestFailedWrites.php new file mode 100644 index 0000000000..bac737a9bc --- /dev/null +++ b/tests/php/TestFailedWrites.php @@ -0,0 +1,240 @@ +create_table(); + } + + /** + * Tear down the test. + */ + public function tear_down() { + $this->drop_table(); + parent::tear_down(); + } + + /** + * Create the table via dbDelta. + */ + protected function create_table() { + if ( ! function_exists( 'dbDelta' ) ) { + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + } + dbDelta( FailedWrites::get_table_schema() ); + } + + /** + * Drop the journal table. + */ + protected function drop_table() { + global $wpdb; + $table = FailedWrites::get_table_name(); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->query( "DROP TABLE IF EXISTS {$table}" ); + } + + /** + * Test record() inserts a new row. + */ + public function testRecordInserts() { + $journal = FailedWrites::factory(); + + $id = $journal->record( 'post', 123, 'index', 'connection refused' ); + + $this->assertIsInt( $id ); + $this->assertGreaterThan( 0, $id ); + + $row = $journal->get_pending( 10 ); + + $this->assertCount( 1, $row ); + $this->assertSame( 'post', $row[0]->indexable_slug ); + $this->assertSame( 123, (int) $row[0]->object_id ); + $this->assertSame( 'index', $row[0]->action ); + $this->assertSame( 'connection refused', $row[0]->error_message ); + } + + /** + * Test record() dedupes by slug + object_id. + */ + public function testRecordDedupes() { + $journal = FailedWrites::factory(); + + $first = $journal->record( 'post', 123, 'index', 'first' ); + $second = $journal->record( 'post', 123, 'index', 'second' ); + + $this->assertSame( $first, $second ); + + $rows = $journal->get_pending( 10 ); + $this->assertCount( 1, $rows ); + $this->assertSame( 'second', $rows[0]->error_message ); + } + + /** + * Test record() keeps distinct slugs/ids. + */ + public function testRecordKeepsDistinctRows() { + $journal = FailedWrites::factory(); + + $journal->record( 'post', 1, 'index' ); + $journal->record( 'post', 2, 'delete' ); + $journal->record( 'comment', 1, 'index' ); + + $this->assertSame( 3, $journal->count_pending() ); + } + + /** + * Test action is normalized to index. + */ + public function testRecordActionNormalization() { + $journal = FailedWrites::factory(); + + $journal->record( 'post', 1, 'INDEX' ); + $journal->record( 'post', 2, 'unknown' ); + + $rows = $journal->get_pending( 10 ); + $by_id = []; + foreach ( $rows as $row ) { + $by_id[ (int) $row->object_id ] = $row->action; + } + + $this->assertSame( 'index', $by_id[1] ); + $this->assertSame( 'index', $by_id[2] ); + } + + /** + * Test get_pending() orders by id ASC and respects limit. + */ + public function testGetPendingOrderAndLimit() { + $journal = FailedWrites::factory(); + + $journal->record( 'post', 1, 'index' ); + $journal->record( 'post', 2, 'index' ); + $journal->record( 'post', 3, 'index' ); + + $rows = $journal->get_pending( 2 ); + $this->assertCount( 2, $rows ); + $this->assertSame( 1, (int) $rows[0]->object_id ); + $this->assertSame( 2, (int) $rows[1]->object_id ); + } + + /** + * Test delete_entries() removes by id. + */ + public function testDeleteEntries() { + $journal = FailedWrites::factory(); + + $a = $journal->record( 'post', 1, 'index' ); + $b = $journal->record( 'post', 2, 'index' ); + + $deleted = $journal->delete_entries( [ $a ] ); + $this->assertSame( 1, $deleted ); + + $rows = $journal->get_pending( 10 ); + $this->assertCount( 1, $rows ); + $this->assertSame( $b, (int) $rows[0]->id ); + } + + /** + * Test update_entry() rewrites the error message. + */ + public function testUpdateEntry() { + $journal = FailedWrites::factory(); + + $id = $journal->record( 'post', 1, 'index', 'first' ); + + $journal->update_entry( $id, [ 'error_message' => 'still failing' ] ); + + $rows = $journal->get_pending( 10 ); + $this->assertSame( 'still failing', $rows[0]->error_message ); + } + + /** + * Test that maybe_capture_index ignores success returns. + */ + public function testMaybeCaptureIndexSkipsSuccess() { + $journal = FailedWrites::factory(); + + $journal->maybe_capture_index( [ 'ID' => 1 ], (object) [ 'result' => 'created' ] ); + + $this->assertSame( 0, $journal->count_pending() ); + } + + /** + * Test that maybe_capture_index records on a false return. + */ + public function testMaybeCaptureIndexRecordsOnFailure() { + $journal = FailedWrites::factory(); + + do_action( 'ep_after_index_post', [ 'ID' => 42 ], false ); + + $rows = $journal->get_pending( 10 ); + $this->assertCount( 1, $rows ); + $this->assertSame( 'post', $rows[0]->indexable_slug ); + $this->assertSame( 42, (int) $rows[0]->object_id ); + } + + /** + * Test that capture_delete records on a false return. + */ + public function testCaptureDeleteRecordsOnFailure() { + $journal = FailedWrites::factory(); + + do_action( 'ep_after_delete_post', 99, false, 'post' ); + + $rows = $journal->get_pending( 10 ); + $this->assertCount( 1, $rows ); + $this->assertSame( 'delete', $rows[0]->action ); + $this->assertSame( 99, (int) $rows[0]->object_id ); + } + + /** + * Test that capture_delete ignores a true return. + */ + public function testCaptureDeleteSkipsSuccess() { + $journal = FailedWrites::factory(); + + do_action( 'ep_after_delete_post', 99, true, 'post' ); + + $this->assertSame( 0, $journal->count_pending() ); + } + + /** + * Test that maybe_capture_bulk records every id on a WP_Error result. + */ + public function testMaybeCaptureBulkRecordsOnError() { + $journal = FailedWrites::factory(); + + $error = new \WP_Error( 'ep_bulk_failed', 'connection refused' ); + $journal->maybe_capture_bulk( [ 1, 2, 3 ], 'post', $error ); + + $this->assertSame( 3, $journal->count_pending() ); + } + + /** + * Test that maybe_capture_bulk ignores array results. + */ + public function testMaybeCaptureBulkIgnoresArray() { + $journal = FailedWrites::factory(); + + $journal->maybe_capture_bulk( [ 1, 2 ], 'post', [ 'errors' => false ] ); + + $this->assertSame( 0, $journal->count_pending() ); + } +} diff --git a/uninstall.php b/uninstall.php index 782eea27cf..d96aed65b1 100644 --- a/uninstall.php +++ b/uninstall.php @@ -58,6 +58,15 @@ class EP_Uninstaller { 'ep_hide_yellow_health_notice', ]; + /** + * List of custom tables (without prefix) to drop when uninstalling the plugin. + * + * @var array + */ + protected $tables = [ + 'ep_failed_writes', + ]; + /** * List of transient keys that need to be deleted when uninstalling the plugin. * @@ -109,6 +118,7 @@ public function __construct() { // Uninstall ElasticPress. $this->clean_options_and_transients(); $this->clean_site_meta(); + $this->clean_tables(); $this->remove_elasticpress_capability(); } @@ -218,6 +228,21 @@ protected function clean_site_meta() { } } + /** + * Drop the plugin's custom tables. + * + * @since 5.4.0 + */ + protected function clean_tables() { + global $wpdb; + + foreach ( $this->tables as $table ) { + $full = $wpdb->prefix . $table; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->query( "DROP TABLE IF EXISTS {$full}" ); + } + } + /** * Remove the ElasticPress' capability *