Problem/Motivation

When using Drupal core's Content Translation content, Node Revision Delete does not retain X "Maximum number allowed" of revisions per node translation. Instead, Node Revision Delete will only retain X "Maximum number allowed" of revisions per node, which risks deleting all revisions of a single translation, including an old pending draft revision if using Drupal core's Content Moderation and Workflows (see related #3094354: Delete draft revisions safely).

Steps to Reproduce

  • With Drupal core's Content Translation and Languages enabled and configured, create a node item with a translations A and B.
  • Create multiple (4+) revisions of a single translation A
  • Create multiple (4+) revisions of the other translation B so that these are more recent than the previously created revisions
  • Using Node Revision Delete default configuration and changed both "Maximum number allowed" to 4, Delete revisions now
  • Observe that all of the old revisions matching Node Revision Delete configuration are deleted, expected
  • Observe all translation A revisions are deleted

I think the expected behavior for a multilingual site would be that configuration would apply consistently to node translations, so that 4 translation A revisions are kept and 4 translation B revisions are also kept...particularly if using Content Moderation and Workflows and there is a pending draft translation revision (see related #3094354: Delete draft revisions safely).

Proposed resolution

TBD

Remaining tasks

  • Get consensus about problem
  • Get consensus about how to solve it
  • Patch
  • TBD

User interface changes

TBD

API changes

TBD

Data model changes

TBD

Release notes snippet

TBD

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

jasonawant created an issue. See original summary.

jasonawant’s picture

Issue summary: View changes
jasonawant’s picture

In addition to any UI changes to communicate the intent behind retaining "Maximum number allowed", I think the current query logic found in NodeRevisionDelete::getCandidatesRevisions() needs to account for multiple language translations.

The first thing that comes to mind is using Drupal core language manager to get available languages, loop over them and to language condition to the query for each language. However, this would multiply the number of queries by the number of active languages unless there is a way to stuff this into a single query.

Instead of looping over all available languages, the NodeRevisionDelete::getCandidatesNodes() would be refactored or replaced with logic, i.e. getCandidatesNodeTranslations(), to return Node IDs keyed by language or something like this, so NodeRevisionDelete::getCandidatesRevisions() can target its query logic on only candidate nodes and translations instead of looping on all available languages.

adriancid’s picture

Issue summary: View changes

Thanks for the issue and for use the Issue Summary Template.

As I say in the release

" This version has not been tested in multilingual sites."

I know this is important. But at this time I concentrating all the efforts finishing the test for the actual code. Seeing we will need to refactor at some point the getCandidates method I will exclude these methods from the Unit tests for the moment.

If you have time to help me with this will be great.

When you create a new revision in a multilingual environment Drupal create a new revision for all the other configured language (here is where the module fails as it doesn't consider this behavior for the moment). If you have a content type configured to be multilingual and you have English (EN) and Spanish (ES). When you edit a node in EN a new revision is created for ES even if you don't change anything in the ES language. I don't know if is here where the database field revision_translation_affected can help us.

jasonawant’s picture

Issue summary: View changes

Thanks for the response!

Regarding,

When you create a new revision in a multilingual environment Drupal create a new revision for all the other configured language

How can I observe this? I'm not seeing this. If I create a node in language A, create a translation in language B, then create a second revision in language A, I only see 2 revisions in the node_revision table and see three revisions between /node/1/revisions and /es/node/1/revisions.

oh, uhm, I see 4 records in the node_field_revision table. Is this what you are referencing?

jasonawant’s picture

I'll tell ya'; it's hard to wrap my head around the "revision_translation_affected" usage. From Entity API,

If a translation has changes in a certain revision, the translation is considered "affected" by that revision, and will be flagged as such via the "revision_translation_affected" field. With the built-in UI, every time a new revision is saved, the changes for the edited translations will be stored, while all field values for the other translations will be copied as-is. However, if multiple translations of the default revision are being subsequently modified without creating a new revision when saving, they will all be affected by the default revision. Additionally, all revision translations will be affected when saving a revision containing changes for untranslatable fields. On the other hand, pending revisions are not supposed to contain multiple affected translations, even when they are being manipulated via the API.

While I did observe the following

When you edit a node in EN a new revision is created for ES even if you don't change anything in the ES language.

In that a node_field_revision is created with a revision_translation_affected value of NULL, I do not see any current Node Revision Delete query logic that uses (via a join) the node_field_revision table. B/c of this, I'm not sure if Node Revision Delete is impacted by this behavior.

jasonawant’s picture

This has become a priority for our stakeholders. We will begin to outline a solution and plan to include it in future sprint work.

jasonawant’s picture

Ok, I took a look at what needs to updated and it looks like the following.

The solution it to apply the configured "Maximum number allowed" per translation. To do that, we can make the following changes.

1. NodeRevisionDelete::getCandidatesNodes(); need to get candidate nodes by counting their translations separately; may need to track nodes by language id for #2 below; depends on the #2.

$candidate_nodes = [];
foreach ($language_manager->getLanguages() as $language) {
  $new_query = $database->select('node_field_revision', 'nfr');
  $new_query->fields('nfr', ['nid']);
  $new_query->condition('nfr.langcode', $language->getId());
  $new_query->condition('nfr.created', $content_type_config['minimum_age_to_delete'], '<');
  $new_query->condition('nfr.changed', $content_type_config['when_to_delete'], '<');
  $new_query->isNotNull('nfr.revision_translation_affected');
  $new_query->addExpression('COUNT(*)', 'total');
  $new_query->having('COUNT(*) > ' . $content_type_config['minimum_revisions_to_keep']);
  $new_query->groupBy('nfr.nid');

  $result = $new_query->execute()->fetchCol();

  $candidate_nodes = array_unique(array_merge($candidate_nodes, $result), SORT_REGULAR);
}

2. NodeRevisionDelete::getCandidatesRevisionsByNids(); I haven't looked at how to refactor this piece; but this where the bulk of the changes will be. This change could/should consider #3094354: Delete draft revisions safely

3. AdminSettingsForm::buildForm(); depending on #2 and #1 above

jasonawant’s picture

Assigning to prepare a patch for this and #3094354: Delete draft revisions safely

jasonawant’s picture

Status: Active » Needs review

I've worked on a patch as part of #3094354-13: Delete draft revisions safely. If we want these split up, let me know.

adriancid’s picture

@jasonawant Is better if you can split the patch, I prefer to review small patches. Thanks.

jasonawant’s picture

The patches are going to overlap b/c of changes to getCandidatesRevisions and getCandidatesRevisionsByNids query logic for both future revisions protection (#3094354) and translation revisions in this issue.

I've uploaded a specific patch for #3094354-14: Delete draft revisions safely there. If you could review that one first, I can then iterate on it in this issue queue to solve for per translation revisions. If you prefer this issue first, let me know and I'll work on a patch just for this issue.

jasonawant’s picture

jasonawant’s picture

StatusFileSize
new19.07 KB
new1.39 KB

I encountered the following error in the remote environment. I'm unable to reproduce this error locally easily, so I've added additional fields to the revision query in hopes this fixes the error in the remote.

Again, this patch includes patches from the following issues

and also includes query logic to support #3094354: Delete draft revisions safely.

jasonawant’s picture

StatusFileSize
new19.05 KB
new1.76 KB
new1.48 KB

I was wrong about the previous fix; it didn't work. To solve my issue, I had to groupBy the nfr.changed column.

This changed solved my issue.

Again, this patch includes patches from the following issues

and also includes query logic to support #3094354: Delete draft revisions safely.

cachesclay’s picture

Patch from #15 looked pretty good when testing it out, but minimum age to delete doesn't seem to get respected, tried adding this but not quite right.

diff --git a/node_revision_delete/src/NodeRevisionDelete.php b/node_revision_delete/src/NodeRevisionDelete.php
index d8a428677c..02b0a01857 100644
--- a/node_revision_delete/src/NodeRevisionDelete.php
+++ b/node_revision_delete/src/NodeRevisionDelete.php
@@ -345,6 +345,8 @@ class NodeRevisionDelete implements NodeRevisionDeleteInterface {
           $query->condition('nfr.nid', $candidate_node, '=');
           // Exclude revisions that are newer than the current revision.
           $query->condition('nfr.changed', $subquery, '<');
+          $query->join('node_revision', 'nr', 'nfr.vid = nr.vid');
+          $query->condition('nr.revision_timestamp', $content_type_config['minimum_age_to_delete'], '<');
           $query->isNotNull('nfr.revision_translation_affected');
           $query->groupBy('nfr.nid');
           $query->groupBy('nfr.vid');
@@ -400,6 +402,8 @@ class NodeRevisionDelete implements NodeRevisionDeleteInterface {
         $query->condition('nfr.changed', $content_type_config['when_to_delete'], '<');
         // Exclude revisions that are newer than the current revision.
         $query->condition('nfr.changed', $subquery, '<');
+        $query->join('node_revision', 'nr', 'nfr.vid = nr.vid');
+        $query->condition('nr.revision_timestamp', $content_type_config['minimum_age_to_delete'], '<');
         $query->isNotNull('nfr.revision_translation_affected');
         $query->groupBy('nfr.nid');
         $query->groupBy('nfr.vid');
bobbysaul’s picture

Version: 8.x-1.0-rc1 » 8.x-1.x-dev
StatusFileSize
new14.16 KB

I updated the patch from @jasonawant since it stopped applying after rc1. It seems like some of the changes in the patch was committed. Also, I updated it a bit to not change getCandidatesRevisionsByNids to getCandidatesRevisionsByNid.

google01’s picture

It is confirmed that patch # 17 does not respect the "minimum age" setting.

Sharique made their first commit to this issue’s fork.

sharique’s picture

Here is updated patch to fix minimum age consideration.

sharique’s picture

My bad, I name diff file extention to .patch, I also raised MR, for easier review.

pere orga’s picture

Priority: Normal » Major

Increasing priority.

sokru’s picture

I can confirm patch from #20 solves the issue from issue summary and code looks good, but I'd like to see tests included to avoid any regression on future releases.

adriancid’s picture

Status: Needs review » Needs work
  1. +++ b/src/NodeRevisionDelete.php
    @@ -326,36 +326,41 @@ class NodeRevisionDelete implements NodeRevisionDeleteInterface {
    -        if ($this->configFactory->get($this->configurationFileName)->get('delete_newer')) {
    

    I don't see where is handled here this configuration 'delete_newer'

  2. +++ b/src/NodeRevisionDelete.php
    @@ -372,41 +377,45 @@ class NodeRevisionDelete implements NodeRevisionDeleteInterface {
    -      if ($this->configFactory->get($this->configurationFileName)->get('delete_newer')) {
    

    And the 'delete_newer' configuration is not considered here either.

smulvih2’s picture

Confirmed patch #20 fixes the issue for me.

google01’s picture

The patch maintains the latest revisions for all languages regardless of which language each revision belongs to.

If "Minimum number of revisions to keep" is set to 5, it effectively keeps 5 revisions, but not the last 5 for each translation, only the last 5 in all languages.

heikkiy’s picture

The patch seems to need a reroll for the latest rc4.

BAHbKA made their first commit to this issue’s fork.

bahbka’s picture

I've rerolled the patch, to check that it works for the scenario provided in https://www.drupal.org/project/node_revision_delete/issues/3262899, but as was mentioned in
- #25 "delete_newer" option is missing,
- #27 states that patch doesn't work at all

On my opinion it should include scenario where editor re-save a node without any change: in this case translation_affected is not set at all, but revision itself is pretty much redundant(doesn't contain any changes).

This discussions could be closed or resolved only with a help of tests. Currently module ships with tons of different tests, but they don't cover module core feature: the deletion of the revisions and merging this issue into dev without even confirming that it doesn't break anything is a really bad idea.
I will try to find an issue for this kind of a test or open a new one.

falco010’s picture

StatusFileSize
new15.8 KB

Hereby a rerolled patch that should apply to the latest 1.0-rc4 release.

I also tested the claim in #27 that the patch does not work and for me, it works fine.
For example, if you configure it to keep a minimum of 3, it will keep a minimum of 3 per language!

adriancid’s picture

Status: Needs work » Needs review
seanb’s picture

Status: Needs review » Needs work

Here is a first review of the patch.

  1. +++ b/src/Form/AdminSettingsForm.php
    @@ -162,19 +162,26 @@ class AdminSettingsForm extends ConfigFormBase {
    +          $candidate_nodes = array_unique(array_merge($nodes, $candidate_nodes));
    

    Doing an array_merge() inside a foreach loop is not very efficient. Can we do the merge outside the loop using the spread operator?

    https://wiki.php.net/rfc/argument_unpacking

    I see this is also being done in other placed in the module, so I guess this could also be a separate followup.

  2. +++ b/src/NodeRevisionDelete.php
    @@ -376,7 +382,8 @@ class NodeRevisionDelete implements NodeRevisionDeleteInterface {
    +    $nid = current($nids);
    +    $node = $this->entityTypeManager->getStorage('node')->load($nid);
    
    @@ -384,39 +391,41 @@ class NodeRevisionDelete implements NodeRevisionDeleteInterface {
    +      foreach ($this->languageManager->getLanguages() as $lang) {
    ...
    +        $query->condition('nfr.nid', $nid, '=');
    

    It seems this method now only retrieves the candidate revisions for a single nid (the first one), not for all the nids passed? Within the module I see all calls only pass a single node ID, but I guess we should either:
    - Change this method to only support a single NID (might be less complex and better for performance?)
    - Fix the query to fetch all candidate revisions for all passed node IDs.

  3. +++ b/src/NodeRevisionDelete.php
    @@ -384,39 +391,41 @@ class NodeRevisionDelete implements NodeRevisionDeleteInterface {
    +        $subquery = $this->connection->select('node_field_data', 'nfd');
    +        $subquery->fields('nfd', ['changed']);
    ...
    +        $query = $this->connection->select('node_field_revision', 'nfr');
    +        $query->fields('nfr', ['vid']);
    

    The query is quite complex, it would help a lot to add a bit more documentation. For example, explain a bit about the background of why we need a subquery to fetch the changed date from the node_field_data table etc.

  4. +++ b/src/NodeRevisionDelete.php
    @@ -384,39 +391,41 @@ class NodeRevisionDelete implements NodeRevisionDeleteInterface {
    +        // Exclude revisions that are newer than the current revision.
    +        $query->condition('nfr.changed', $subquery, '<');
    +        $query->isNotNull('nfr.revision_translation_affected');
    

    This seems to be my biggest concern for now. New draft translations don't add a record to the node_field_data table (#3088790: Creating a new translation draft is not populating node_field_data leading to inconsistent data display.). It seems the subqueries in getCandidatesRevisions() / getCandidatesRevisionsByNids() and getCandidatesNodes() might not work without that being solved. Without a record in node_field_data for example we are also not able to fetch a node type for a revision by vid and langcode.

----------

I would have to think about this a bit more, but reading the patch I'm starting to wonder if a highly complex query like this is really the most robust and maintainable way to move forward. I have a feeling it would help a lot to maybe start using the queue system.

We could have several ways to add node IDs / revision IDs to the queue, and let the queue try to figure out whether that specific revision is ready to be deleted or not. This might be less performant, but I think trying to do all checks in a single query has a bunch of limits that might be tricky to overcome as well. Anyway, please excuse this sidestep, that is definitely not in scope for this issue, and I'll think a bit more on whether to open a follow-up for this.

*edit: Opened #3300328: Simplify architecture to solve issue around content moderation, translations and cron as a followup.

sokru’s picture

Status: Needs work » Needs review
StatusFileSize
new19.48 KB

The attached patch is a re-roll for 8.x-1.0-rc5 based on #31. Unfortunately not being able to produce interdiff.

The patch should resolve:
- #25.1 and #25.2 so delete_newer setting is respected.
- #33.2 by renaming getCandidatesRevisionsByNids to getCandidatesRevisionsByNid and adjusting the code accordingly.

And I fully agree with @seanB's other points raised on #33, but I'd hope they could be resolved on follow-up issues.

Status: Needs review » Needs work
sokru’s picture

Status: Needs work » Needs review
StatusFileSize
new18.76 KB
new7.12 KB

This patch should pass the CI tests, but this is still missing test coverage for multilingual.

sokru’s picture

Adjusted the patch to work with delete_newer option. This patch also includes test coverage. Interdiff doesn't contain the new test file.

Since this is quite complex issue to review, I would appreciate any manual tests with real content. I'll write the test coverage I used in manual steps:

1. Install the site with Standard profile drush site:install standard
2. Enable modules drush en language content_moderation content_translation node_revision_delete workflows -y
3. Add a new non-english language /admin/config/regional/language/add.
4. Enable workflow to Article content type /admin/config/workflow/workflows/manage/editorial, set "Default moderation state" as "Published".
5. Enable translations and select "Limit the number of revisions" and "Minimum number of revisions to keep" as 1 for article. /admin/structure/types/manage/article.
6. Add node #1, Edit it two times.
7. Add translation to #1, edit translation two times.
8. Add node #2. Edit it two times, both times set moderation state as "Draft"
9. Add node #3.
10. Add translation to node #3 and edit translation two times.
11. Visit /admin/config/content/node_revision_delete and expect "Candidate nodes" to be 2 and "Candidate revisions" to be 3.
12. Select "Delete revisions newer than the current revision" and submit the form.
13. Expect "Candidate nodes" to be 2 and "Candidate revisions" to be 4.

Is this working as expected or would it need some changes?

heikkiy’s picture

The merge request diff applies against latest rc6 but there is a message in the merge request that it needs a rebase against the main branch.

seanb’s picture

We just added a 2.x version of the module. This is a complete rewrite based on plugins to determine which revisions can be deleted. It also uses a queue worker to actually delete revisions, and takes multiple translations of content into account. Could you please test if this solves the issues you currently experience?

vincent.hoehn’s picture

For me, the new version 2.x does not work. Previously, too many revisions were deleted. But now too few revisions are deleted. At least that's how it looks.
In the sandbox, the module works great at first sight. But in our system with two languages and 40k revisions, it doesn't seem to work. Too bad!

b-prod’s picture

I confirm what @vincent.hoehn say: the 2.x version does not work at all, if I specify to keep only 1 revision, nothing happens.

For example, I have a node with 2860 revisions, none is deleted.

alejomc’s picture

StatusFileSize
new17.5 KB

For those whose need the patch for -rc6.

seanb’s picture

@vincent.hoehn @B-Prod could you maybe try and debug what is happening in the 2.x version to see why the revisions are not being deleted? Are you getting errors in the logs? Are items being added to the queue? Is the queue being processed? Is this process running out of memory? Any additional info to help resolve the issues would be appreciated!

tvalimaa’s picture

#42 looks like working with 1.0 rc-7.

I have now check and calculate revisions count per node and node translations after clicking delete revisions on node.

smitghelani’s picture

#42 has one code change is missing from #37,
File CandidateNodesForm.php has below mentioned change inside the #37 patch, but which is missing inside the #42 patch i have added that change and created new patch and other changes are same as #42 and tested with latest version of module 8.x-1.-rc7.

adriancid’s picture

Status: Needs review » Closed (outdated)

Closing because the current development is being made in branch 2.x