From 8b2fef50994ba6ae92bef81418d4837898c8dcd2 Mon Sep 17 00:00:00 2001 From: arthurbaghdas Date: Wed, 8 Oct 2025 09:32:58 +0400 Subject: [PATCH 1/5] DP-36234: Verify that Trash clear drush command is working correctly --- .../views.view.mass_dashboard_trash.yml | 63 +++++++++++++++++ .../src/Drush/Commands/TrashbinCommands.php | 67 ++++++++++++++++--- .../modules/custom/trashbin/trashbin.module | 10 --- 3 files changed, 122 insertions(+), 18 deletions(-) diff --git a/conf/drupal/config/views.view.mass_dashboard_trash.yml b/conf/drupal/config/views.view.mass_dashboard_trash.yml index 3b9cc0349a..0a29c79809 100644 --- a/conf/drupal/config/views.view.mass_dashboard_trash.yml +++ b/conf/drupal/config/views.view.mass_dashboard_trash.yml @@ -611,6 +611,69 @@ display: multi_type: separator separator: ', ' field_api_classes: false + moderation_state: + id: moderation_state + table: node_field_data + field: moderation_state + relationship: vid + group_type: group + admin_label: '' + entity_type: node + plugin_id: moderation_state_field + label: 'Moderation state' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + click_sort_column: value + type: content_moderation_state + settings: { } + group_column: value + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false nos_per_1000_cleaned: id: nos_per_1000_cleaned table: mass_bigquery_data diff --git a/docroot/modules/custom/trashbin/src/Drush/Commands/TrashbinCommands.php b/docroot/modules/custom/trashbin/src/Drush/Commands/TrashbinCommands.php index b98e8e89d2..dda138bcc8 100644 --- a/docroot/modules/custom/trashbin/src/Drush/Commands/TrashbinCommands.php +++ b/docroot/modules/custom/trashbin/src/Drush/Commands/TrashbinCommands.php @@ -32,20 +32,71 @@ protected function __construct( #[CLI\Usage(name: 'drush --simulate trashbin:purge node', description: 'Get a report of what would be purged.')] public function purge($entity_type, $options = ['max' => 1000, 'days-ago' => 180]) { $maximum = strtotime($options['days-ago'] . ' days ago', $this->time->getCurrentTime()); + + // Validate entity type and get storage/definition. $storage = $this->etm->getStorage($entity_type); - $query = $storage->getQuery(); - $query->addTag(self::TRASHBIN_ONLY_TRASH); - $query->condition('changed', $maximum, '<'); - $query->range(0, $options['max']); - $ids = $query->accessCheck(FALSE)->execute(); + $definition = $storage->getEntityType(); + + // Resolve table/keys. + $base_table = method_exists($storage, 'getBaseTable') ? $storage->getBaseTable() : NULL; + if (!$base_table) { + $this->logger()->error('Entity type {type} does not have a base table and cannot be purged.', ['type' => $entity_type]); + return; + } + + $id_key = $definition->getKey('id'); + $rev_key = $definition->getKey('revision'); + $changed_key = $definition->getKey('changed'); + + if (!$id_key || !$rev_key) { + $this->logger()->error('Entity type {type} must be revisionable to use trashbin purge (missing id/revision keys).', ['type' => $entity_type]); + return; + } + + // Build a DB query that joins to content_moderation_state_field_data on the + // current (latest) revision id and filters to moderation_state = trash. + $connection = \Drupal::database(); + $query = $connection->select($base_table, 'b') + ->fields('b', [$id_key]) + ->range(0, (int) $options['max']); + + // Note: innerJoin() returns the alias string, not the Select object, so it + // must not be chained. + $query->innerJoin( + 'content_moderation_state_field_data', + 'md', + 'md.content_entity_type_id = :etype AND md.content_entity_id = b.' . $id_key . ' AND md.content_entity_revision_id = b.' . $rev_key, + [':etype' => $entity_type] + ); + + $query->condition('md.moderation_state', 'trash', '='); + + if ($changed_key) { + $query->condition('b.' . $changed_key, $maximum, '<'); + } + + $ids = $query->execute()->fetchCol(); + $this->logger()->notice('Found {count} entities to delete.', ['count' => count($ids)]); + + if (!$ids) { + return; + } + $entities = $storage->loadMultiple($ids); foreach ($entities as $entity) { - if (Drush::simulate()) { - $this->logger()->notice('Simulated delete of "{title}". ID={id}, {url}', ['title' => $entity->label(), 'id' => $entity->id(), 'url' => $entity->toUrl('canonical', ['absolute' => TRUE])->toString()]); + if (\Drush\Drush::simulate()) { + $this->logger()->notice('Simulated delete of "{title}". ID={id}, {url}', [ + 'title' => $entity->label(), + 'id' => $entity->id(), + 'url' => $entity->toUrl('canonical', ['absolute' => TRUE])->toString(), + ]); } else { - $this->logger()->notice('Start delete of "{title}". ID={id}', ['title' => $entity->label(), 'id' => $entity->id()]); + $this->logger()->notice('Start delete of "{title}". ID={id}', [ + 'title' => $entity->label(), + 'id' => $entity->id(), + ]); $entity->delete(); } } diff --git a/docroot/modules/custom/trashbin/trashbin.module b/docroot/modules/custom/trashbin/trashbin.module index 7633b4ab8e..45c13f43d2 100644 --- a/docroot/modules/custom/trashbin/trashbin.module +++ b/docroot/modules/custom/trashbin/trashbin.module @@ -57,13 +57,3 @@ function trashbin_query_entity_reference_alter(AlterableInterface $query) { $query->condition('cm.moderation_state', 'trash', '!='); } } - -/** - * Implements hook_query_TAG_alter(). - * - * Returns only trashed entities when tagged accordingly. - */ -function trashbin_query_trashbin_only_trash_alter(AlterableInterface $query): void { - $query->addJoin('LEFT', 'content_moderation_state_field_data', 'md', 'md.content_entity_revision_id = base_table.vid'); - $query->condition('md.moderation_state', 'trash', '='); -} From ebd3c01839948217ba17efd16df38aa44fe6e86e Mon Sep 17 00:00:00 2001 From: arthurbaghdas Date: Wed, 8 Oct 2025 09:34:08 +0400 Subject: [PATCH 2/5] DP-36234: Verify that Trash clear drush command is working correctly --- changelogs/DP-36234.yml | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 changelogs/DP-36234.yml diff --git a/changelogs/DP-36234.yml b/changelogs/DP-36234.yml new file mode 100644 index 0000000000..751e0cf56c --- /dev/null +++ b/changelogs/DP-36234.yml @@ -0,0 +1,41 @@ +# +# Write your changelog entry here. Every pull request must have a changelog yml file. +# +# Change types: +# ############################################################################# +# You can use one of the following types: +# - Added: For new features. +# - Changed: For changes to existing functionality. +# - Deprecated: For soon-to-be removed features. +# - Removed: For removed features. +# - Fixed: For any bug fixes. +# - Security: In case of vulnerabilities. +# +# Format +# ############################################################################# +# The format is crucial. Please follow the examples below. For reference, the requirements are: +# - All 3 parts are required and you must include "Type", "description" and "issue". +# - "Type" must be left aligned and followed by a colon. +# - "description" must be indented with 2 spaces followed by a colon +# - "issue" must be indented with 4 spaces followed by a colon. +# - "issue" is for the Jira ticket number only e.g. DP-1234 +# - No extra spaces, indents, or blank lines are allowed. +# +# Example: +# ############################################################################# +# Fixed: +# - description: Fixes scrolling on edit pages in Safari. +# issue: DP-13314 +# +# You may add more than 1 description & issue for each type using the following format: +# Changed: +# - description: Automating the release branch. +# issue: DP-10166 +# - description: Second change item that needs a description. +# issue: DP-19875 +# - description: Third change item that needs a description along with an issue. +# issue: DP-19843 +# +Fixed: + - description: Verify that Trash clear drush command is working correctly. + issue: DP-36234 From 32efecd17a10c30b5d597423707d9948caeb49ca Mon Sep 17 00:00:00 2001 From: arthurbaghdas Date: Tue, 14 Oct 2025 14:30:59 +0400 Subject: [PATCH 3/5] Fix --- .../custom/trashbin/src/Drush/Commands/TrashbinCommands.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docroot/modules/custom/trashbin/src/Drush/Commands/TrashbinCommands.php b/docroot/modules/custom/trashbin/src/Drush/Commands/TrashbinCommands.php index dda138bcc8..a12e438ba6 100644 --- a/docroot/modules/custom/trashbin/src/Drush/Commands/TrashbinCommands.php +++ b/docroot/modules/custom/trashbin/src/Drush/Commands/TrashbinCommands.php @@ -13,7 +13,6 @@ final class TrashbinCommands extends DrushCommands { use AutowireTrait; - const TRASHBIN_ONLY_TRASH = 'trashbin_only_trash'; const TRASHBIN_PURGE = 'trashbin:purge'; protected function __construct( @@ -38,7 +37,7 @@ public function purge($entity_type, $options = ['max' => 1000, 'days-ago' => 180 $definition = $storage->getEntityType(); // Resolve table/keys. - $base_table = method_exists($storage, 'getBaseTable') ? $storage->getBaseTable() : NULL; + $base_table = method_exists($storage, 'getBaseTable') ? $storage->getDataTable() : NULL; if (!$base_table) { $this->logger()->error('Entity type {type} does not have a base table and cannot be purged.', ['type' => $entity_type]); return; @@ -59,6 +58,7 @@ public function purge($entity_type, $options = ['max' => 1000, 'days-ago' => 180 $query = $connection->select($base_table, 'b') ->fields('b', [$id_key]) ->range(0, (int) $options['max']); + $query->condition('changed', $maximum, '<'); // Note: innerJoin() returns the alias string, not the Select object, so it // must not be chained. @@ -85,7 +85,7 @@ public function purge($entity_type, $options = ['max' => 1000, 'days-ago' => 180 $entities = $storage->loadMultiple($ids); foreach ($entities as $entity) { - if (\Drush\Drush::simulate()) { + if (Drush::simulate()) { $this->logger()->notice('Simulated delete of "{title}". ID={id}, {url}', [ 'title' => $entity->label(), 'id' => $entity->id(), From 5efc86f9a4ae201ff8ca6431a43fa7c959ca4a1e Mon Sep 17 00:00:00 2001 From: arthurbaghdas Date: Thu, 16 Oct 2025 10:38:08 +0400 Subject: [PATCH 4/5] Modify timestamp check --- .../src/Drush/Commands/TrashbinCommands.php | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/docroot/modules/custom/trashbin/src/Drush/Commands/TrashbinCommands.php b/docroot/modules/custom/trashbin/src/Drush/Commands/TrashbinCommands.php index a12e438ba6..fff6a04ffa 100644 --- a/docroot/modules/custom/trashbin/src/Drush/Commands/TrashbinCommands.php +++ b/docroot/modules/custom/trashbin/src/Drush/Commands/TrashbinCommands.php @@ -36,16 +36,16 @@ public function purge($entity_type, $options = ['max' => 1000, 'days-ago' => 180 $storage = $this->etm->getStorage($entity_type); $definition = $storage->getEntityType(); - // Resolve table/keys. - $base_table = method_exists($storage, 'getBaseTable') ? $storage->getDataTable() : NULL; + // Resolve table/keys from the entity type definition (nodes: node_field_data/node_revision). + $base_table = $definition->getDataTable() ?: $definition->getBaseTable(); if (!$base_table) { - $this->logger()->error('Entity type {type} does not have a base table and cannot be purged.', ['type' => $entity_type]); + $this->logger()->error('Entity type {type} does not have a base/data table and cannot be purged.', ['type' => $entity_type]); return; } $id_key = $definition->getKey('id'); $rev_key = $definition->getKey('revision'); - $changed_key = $definition->getKey('changed'); + $changed_key = 'changed'; if (!$id_key || !$rev_key) { $this->logger()->error('Entity type {type} must be revisionable to use trashbin purge (missing id/revision keys).', ['type' => $entity_type]); @@ -58,7 +58,6 @@ public function purge($entity_type, $options = ['max' => 1000, 'days-ago' => 180 $query = $connection->select($base_table, 'b') ->fields('b', [$id_key]) ->range(0, (int) $options['max']); - $query->condition('changed', $maximum, '<'); // Note: innerJoin() returns the alias string, not the Select object, so it // must not be chained. @@ -69,11 +68,28 @@ public function purge($entity_type, $options = ['max' => 1000, 'days-ago' => 180 [':etype' => $entity_type] ); - $query->condition('md.moderation_state', 'trash', '='); + $revision_table = $definition->getRevisionTable(); + $rt_timestamp = 'rt.revision_timestamp'; + if ($entity_type == 'media') { + $rt_timestamp = 'rt.revision_created'; + } + + if ($revision_table) { + $query->innerJoin( + $revision_table, + 'rt', + 'rt.' . $rev_key . ' = b.' . $rev_key . ' AND rt.' . $id_key . ' = b.' . $id_key + ); + } - if ($changed_key) { - $query->condition('b.' . $changed_key, $maximum, '<'); + $query->condition('md.moderation_state', 'trash'); + $query->where('b.' . $changed_key . ' <> ' . $rt_timestamp); + // Optional age cutoff: only when days-ago > 0. Use the later of changed vs + // revision timestamp so we never prematurely purge recently-touched items. + if (!empty($options['days-ago']) && (int) $options['days-ago'] > 0) { + $query->where('GREATEST(b.' . $changed_key . ', ' . $rt_timestamp . ') < :max', [':max' => $maximum]); } + $query->orderBy($rt_timestamp, 'DESC'); $ids = $query->execute()->fetchCol(); From c8c8c43ae19c35845d728381c0c40e3ddedc2be3 Mon Sep 17 00:00:00 2001 From: arthurbaghdas Date: Thu, 16 Oct 2025 13:09:30 +0400 Subject: [PATCH 5/5] Modify timestamp check --- .../src/Drush/Commands/TrashbinCommands.php | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/docroot/modules/custom/trashbin/src/Drush/Commands/TrashbinCommands.php b/docroot/modules/custom/trashbin/src/Drush/Commands/TrashbinCommands.php index fff6a04ffa..57765d1fbd 100644 --- a/docroot/modules/custom/trashbin/src/Drush/Commands/TrashbinCommands.php +++ b/docroot/modules/custom/trashbin/src/Drush/Commands/TrashbinCommands.php @@ -22,7 +22,7 @@ protected function __construct( } /** - * Delete content entities that have been in the bin for more than n days. + * Delete content entities in Trash; when --days-ago=0, delete all trashed items; when >0, delete only those older than N days. */ #[CLI\Command(name: self::TRASHBIN_PURGE, aliases: [])] #[CLI\Argument(name: 'entity_type', description: 'Entity type to purge')] @@ -30,7 +30,11 @@ protected function __construct( #[CLI\Option(name: 'days-ago', description: 'Number of days that the item must be unchanged in the trashbin.')] #[CLI\Usage(name: 'drush --simulate trashbin:purge node', description: 'Get a report of what would be purged.')] public function purge($entity_type, $options = ['max' => 1000, 'days-ago' => 180]) { - $maximum = strtotime($options['days-ago'] . ' days ago', $this->time->getCurrentTime()); + // Capture command start time to avoid racing with edits during execution. + $startedAt = $this->time->getCurrentTime(); + $maximum = strtotime($options['days-ago'] . ' days ago', $startedAt); + + $cutoff = ((int) $options['days-ago'] > 0) ? $maximum : $startedAt; // Validate entity type and get storage/definition. $storage = $this->etm->getStorage($entity_type); @@ -82,13 +86,14 @@ public function purge($entity_type, $options = ['max' => 1000, 'days-ago' => 180 ); } + // Always: moderation_state = 'trash' $query->condition('md.moderation_state', 'trash'); - $query->where('b.' . $changed_key . ' <> ' . $rt_timestamp); - // Optional age cutoff: only when days-ago > 0. Use the later of changed vs - // revision timestamp so we never prematurely purge recently-touched items. - if (!empty($options['days-ago']) && (int) $options['days-ago'] > 0) { - $query->where('GREATEST(b.' . $changed_key . ', ' . $rt_timestamp . ') < :max', [':max' => $maximum]); - } + + // Execution-stable cutoff: + // - days-ago > 0 → cutoff = now - N days + // - days-ago = 0 → cutoff = command start time (prevents deleting items created/edited during the run) + $query->where('GREATEST(b.' . $changed_key . ', ' . $rt_timestamp . ') < :cutoff', [':cutoff' => $cutoff]); + $query->orderBy($rt_timestamp, 'DESC'); $ids = $query->execute()->fetchCol();