Hello everyone,

We have a project in which we wanted to re-autotranslate to several languages when the base language changed, but only for published entities. A continous job was created, with google as the provider.

So we set in a presave() hook a to load and save translations while setting the 'content_translation_outdated' field via ContentTranslationMetadataWrapper::setOutdated()
(Instead of doing this manually, we also tried https://www.drupal.org/project/drupal/issues/2950627 this patch which provides a checkbox to set this property via the UI)

This results in an endless loop since on in the function ContentEntitySource::shouldCreateContinuousItem

web/modules/contrib/tmgmt/sources/content/src/Plugin/tmgmt/Source/ContentEntitySource.php:473

    $translation = $entity->hasTranslation($job->getTargetLangcode()) ? $entity->getTranslation($job->getTargetLangcode()) : NULL;
    $metadata = isset($translation) ? $translation_manager->getTranslationMetadata($translation) : NULL;

    // If a translation exists and is not marked as outdated, no new job items
    // needs to be created.
    if (isset($translation) && !$metadata->isOutdated()) {
      return FALSE;
    }
    else {

it will continue to fail this check !$metadata->isOutdated() since the continous job won't reset 'content_translation_outdated'.

What happens is, that the newly created job item is created, the auto translation will succeed, and the node translation will be saved.
The custom presave hook will run but it won't do anything if the activeLanguage of the node is not the source language so this will pass.. But the tmgmt update hook will trigger, after the save of the node, and start the cycle anew, wanting to create a job item for the saved translation etc,..

Steps to reproduce

  1. Setup a fresh ddev instance of Drupal https://ddev.readthedocs.io/en/latest/users/quickstart/#drupal
  2. Require tmgmt with composer and install via drush.
  3. Enable the tmgmt_test module via drush so the test translator is available.
  4. Enable German as a language.
  5. Enable Spanish as a language
  6. Make the default article bundle translatable along with all fields
  7. In the provider settings enable auto accept translations
  8. In tmgmt settings ensure submit items on cron is checked.
  9. Setup a continuous job for the article in German using test translator
  10. Setup a continuous job for the article in Spanish using test translator
  11. Create an article and save it
  12. Run the following SQL and you should see two inactive continuous jobs
    select tji.item_id, tji.tjiid, tj.target_language from tmgmt_job_item as tji join tmgmt_job as tj on tji.tjid = tj.tjid where tj.job_type = 'continuous' and tji.state = 0;
    
  13. Run ddev drush php-eval "tmgmt_cron();" and then run the SQL above again and there should be no inactive job items.
  14. Go to the article in the original language and click mark translation as outdated then save it.
  15. Now if you run the SQL again there should be two inactive items
  16. Now run ddev drush php-eval "tmgmt_cron();" again and when you run your SQL you will see one job item remaining.

Now matter how many times you run tmgmt_cron() via drush from there there is always one remaining inactive item and it alternates between de and es. It's like one is always marking the other as outdated.

Issue fork tmgmt-3063378

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

vierlex created an issue. See original summary.

criz’s picture

Still not sure what is happening here, but this is a patch that disables creating new job_items for a continuous job when a translation is outdated.

achap’s picture

So I think this is actually a bug and not just with moderated entities. Initially I thought it was due to the referenced core bug (which is why marking revisions as outdated was hidden in the first place in core for moderated entities) but I was actually able to reproduce it for unmoderated entities too on a freshly installed site without any of my custom code.

Steps to reproduce:

  1. Setup a fresh ddev instance of Drupal https://ddev.readthedocs.io/en/latest/users/quickstart/#drupal
  2. Require tmgmt with composer and install via drush.
  3. Enable the tmgmt_test module via drush so the test translator is available.
  4. Enable German as a language.
  5. Enable Spanish as a language
  6. Make the default article bundle translatable along with all fields
  7. Setup a continuous job for the article in German using test translator
  8. Setup a continuous job for the article in Spanish using test translator
  9. Create an article and save it
  10. Run the following SQL and you should see two inactive continuous jobs select tji.item_id, tji.tjiid, tj.target_language from tmgmt_job_item as tji join tmgmt_job as tj on tji.tjid = tj.tjid where tj.job_type = 'continuous' and tji.state = 'inactive';
  11. Run ddev drush php-eval "tmgmt_cron();" and then run the SQL above again and there should be no inactive job items.
  12. Go to the article in the original language and click mark translation as outdated then save it.
  13. Now if you run the SQL again there should be two inactive items
  14. Now run ddev drush php-eval "tmgmt_cron();" again and when you run your SQL you will see one job item remaining.

Now matter how many times you run tmgmt_cron() via drush from there there is always one remaining inactive item and it alternates between de and es. It's like one is always marking the other as outdated.

Would appreciate if someone else can confirm my steps to reproduce. Would potentially think about marking this as major if it's confirmed as this can cause a huge amount of revisions/costs from re translation.

Versions: Drupal core 11.1.8, tmgmt 1.17.0

achap’s picture

Assigned: Unassigned » achap

Creating a test to try and highlight the bug

achap’s picture

When adding an additional continuous job in Spanish and duplicating the steps that were created in German I can replicate it in a test:

    1)
    Drupal\Tests\tmgmt_content\Kernel\ContentEntitySourceUnitTest::testContinuousJobItems
    No continuous job item is automatically created when translation is
    accepted.
    Failed asserting that 4 matches expected 3.
    
    /builds/issue/tmgmt-3063378/vendor/phpunit/phpunit/src/Framework/Constraint/Equality/IsEqual.php:95
    /builds/issue/tmgmt-3063378/sources/content/tests/src/Kernel/ContentEntitySourceUnitTest.php:834
    /builds/issue/tmgmt-3063378/vendor/phpunit/phpunit/src/Framework/TestResult.php:729

As best I can tell, when the translation is accepted and saved, tmgmt_content_entity_update() is called, at which point the untranslated entity is again sent to the continuous manager but at this point in shouldCreateContinuousItem the metadata returns that both of the translations are still outdated. For whatever reason, this doesn't occur with a single translation job. The German node was previously being marked as unoutdated correctly.

achap’s picture

Assigned: achap » Unassigned
Status: Active » Needs review

Explicitly marking the translation as outdated when it is saved in ContentEntitySource means the test passes but I would have thought that would have been done automatically by Drupal? I'm not sure if this is the correct fix but it does stop the loop. I would appreciate if someone could review it and share their thoughts especially if they can figure out why this only occurs with a second job.

(The fail in the MR is unrelated to me. It's something to do with composer which I haven't touched).

achap’s picture

I did a bit more digging on this and I'm fairly confident that default Drupal behavior doesn't automatically mark a translation as unoutdated when you save a translation so I think my fix is correct. I also found a corresponding issue on the 7.x branch that had the same problem and was committed: https://www.drupal.org/project/tmgmt/issues/1619786

As for my earlier statement that "The German node was previously being marked as unoutdated correctly." after checking again it definitely wasn't reset to 0 automatically but the issue definitely only starts to occur once you have 2 languages in 2 jobs affecting the same node type. Hence why I added a second Spanish job to the test.

Honestly I think this should be marked as major, as with this bug a new revision will be created on each cron run and it will also be sent for translation continuously costing money.

I guess not a lot of people are using this functionality and instead are just re-requesting the translation via the UI when needed but this is an important feature for me.

charginghawk’s picture

Status: Needs review » Reviewed & tested by the community

Working for us!

achap’s picture

Status: Reviewed & tested by the community » Needs review
Related issues: +#2950626: Allow flagging translations as outdated when content is moderated

Recently switched from workbench moderation to content moderation and am using the patch from https://www.drupal.org/project/drupal/issues/2950626 to allow marking moderatable entities as outdated again. Simply setting setOutdated(FALSE) is fine for non moderatable entities but won't work for moderatable entities with the core patch in its current form. So I have created: https://www.drupal.org/project/tmgmt/issues/3560685 to handle both cases. Will set this one as needs review and keep it open as technically it could get merged on its own. However, https://www.drupal.org/project/tmgmt/issues/3560685 handles both cases.

achap’s picture

Status: Needs review » Reviewed & tested by the community

I've updated my MR for the related core issue, https://www.drupal.org/node/2950626, so hopefully the complexity of keeping track of the moderated entities' outdated status can be handled there and we can just get call setOutdated like we are doing. Closed the other issue and rebased this with upstream fixes. Tests are now passing so this shouldn't be blocked by anything. Setting back to RTBC.

balazswmann’s picture

Thank you for opening this issue. I run into the very same problem on a project where I'm only using the tmgmt (8.x-1.8) module along with tmgmt_deepl so there is no content moderation.

I can confirm the bug which seemingly only appears if a content is translated to two or more languages. So if you have continuous jobs, then two or more translation jobs are created when you update the content. I also did a quick test with only one continuous job (one language) but I couldn't reproduce the issue that way. The steps mentioned in comment #3 to reproduce the issue are accurate and helped me to clarify that I run into a same bug.

I also tried out the patch from the opened MR and after doing some quick tests I can confirm that it seemingly resolves the problem, but I haven't had time to fully understand why it works.

idebr’s picture

Version: 8.x-1.8 » 8.x-1.x-dev
claudiu.cristea’s picture

The test shows the issue and the fix is common sense but I could not reproduce using #3. When I run 1st SQL it returns an empty set. Looking into DB, I see the 2 items with state 2 (active) but I expect to see them with state 0 (inactive). Any chance to revisit the steps to reproduce?

achap’s picture

Issue summary: View changes

Just went through the steps again and the missing steps were to enable auto accept on the provider and submit items on cron. Have added to the issue summary. Thanks for taking a look!

claudiu.cristea’s picture

Issue summary: View changes

Thank you, I've managed to reproduce the bug manually. However, this comment gives me headaches

For whatever reason, this doesn't occur with a single translation job

I want to understand why. Also, only one inactive item is added every time, maybe here is the issue

claudiu.cristea’s picture

I couldn't wrap my head around why it happens with ≥2 target languages but not with one language. Finally I've asked some help from Claude. Following the report and the code it makes perfectly sense. Here's Claude's assessment:


The three pieces of code involved

  1. Every entity save re-checks all continuous jobs. tmgmt_content_entity_update() (sources/content/tmgmt_content.module:313) fires on any save of the entity — including TMGMT's own save of a translation — and calls tmgmt_content_create_continuous_job_items(), which loops over all continuous jobs (one per target language) and calls ContinuousManager::addItem() for each.
  2. The decision is based on the outdated flag. ContentEntitySource::shouldCreateContinuousItem() (sources/content/src/Plugin/tmgmt/Source/ContentEntitySource.php:706) returns TRUE whenever the target translation is missing or $metadata->isOutdated(). Since doSaveTranslations() never calls setOutdated(FALSE) (that's what MR !122 adds), once a translation is flagged outdated it stays "outdated" forever, even after a fresh translation is saved over it.
  3. The dedup guard. ContinuousManager::addItem() (src/ContinuousManager.php:143-148) refuses to create a new job item if the most recent item for that entity in that job is still active (!isAborted() && !isAccepted()).

Why one language doesn't loop

Note the ordering in JobItem::acceptTranslation() (src/Entity/JobItem.php:938-945): saveTranslation() runs before $this->accepted(). So when the German translation is saved and hook_entity_update() fires, the German job item is still in "needs review" state — still active.

Single language (DE only)
  1. Source edited, DE flagged outdated → job item J1 created for the DE job.
  2. Translation comes back and is accepted: saveTranslation() saves the DE translation → tmgmt_content_entity_update() fires → addItem() for the DE job → shouldCreateContinuousItem() says TRUE (flag is still stale), but J1 is still active (not yet marked accepted) → guard at ContinuousManager.php:145 blocks it, logging "changes were ignored as job item is still active."
  3. accepted() runs, J1 becomes accepted. But nothing saves the entity again, so the hook never fires again. Chain terminates.

The DE flag is still wrongly TRUE, but there's no trigger left to act on it — the bug is latent, not visible.

Why two languages loop forever

With DE + ES you have two continuous jobs, and each save of one language's translation acts as a fresh trigger for the other language, at a moment when the other's guard no longer protects it:

  1. Source edited, DE and ES both flagged outdated → J1 (DE) and J2 (ES) created.
  2. Cron accepts ES first: ES translation saved → hook → DE job checked: J1 still active → blocked. ES job checked: J2 still mid-accept → blocked. J2 becomes accepted.
  3. Cron accepts DE: DE translation saved → hook → ES job checked: J2 is now accepted (guard doesn't block) and ES is still flagged outdated (flag never cleared) → new item J3 (ES) created. J1 becomes accepted.
  4. J3 gets translated and accepted: ES saved → hook → DE job: J1 is accepted, DE flag still stale-TRUE → new item J4 (DE).
  5. …and so on, alternating forever, one item per language per cycle — matching the reporter's observation that DE and ES keep "marking each other" outdated.

So the ≥2-languages condition isn't about the outdated flag propagating between translations — it never changes at all, which is precisely the bug. It's that the loop needs an external save event hitting a job whose last item is already accepted. A language's own save always happens while its own item is still active (blocked by the guard); only another language's save arrives after acceptance. That's why the fix in MR !122 — calling setOutdated(FALSE) when TMGMT saves the translation — breaks the cycle: after acceptance, shouldCreateContinuousItem() correctly returns FALSE regardless of who triggers the re-check.

claudiu.cristea’s picture

Status: Reviewed & tested by the community » Needs work

Let's fix some minor issues here

achap’s picture

Status: Needs work » Needs review

Yes it was quite hard to debug what was going on because the bug only surfaces with more than one continuous job. I have fixed up those items on the MR.

claudiu.cristea’s picture

Status: Needs review » Reviewed & tested by the community

Thank you

claudiu.cristea’s picture

Status: Reviewed & tested by the community » Fixed

Everything looks good, thank you. Merged

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.