Steps to reproduce:

1. Enable Content Translation module
2. Add 2 language
3. Go to a Article content type settings page Language settings tab and tickEnable translation on it
4. Make "changed" field translatable, set "Body" field as not translatable
5. Add an article node.
6. Open translation form for the two other languages (so you edit them concurrently)
7. One the first translation change body field value and save it
8. Then you save the second translation. Notice that there is no message like "The content has either been modified by another user, or you have already submitted modifications. As a result, your changes cannot be saved."
9. Go back to the first translation (in step 7). Notice that the updated fields have been changed back to the original value because the unchanged body field in step 8 is saved over the new updated body in step 7.

The same also goes for any untranslatable fields. When someone edit concurrently different language versions of the node they will revert any changes made in untranslatable.

Problem

  • It is not possible to update any untranslatable field as long as someone else edit translations concurrently, they will then revet any changes made (without knowing it themselves)
  • For sites with many languages or where content is frequently updated this can be a significant drawback in the user experience

Proposed resolutions

  • Change code logic in the EntityChangedConstraintValidator and/or in the EntityChangedTrait::getChangedTimeAcrossTranslations
CommentFileSizeAuthor
#104 entity-concurrent_translation_validation-2837022-104.patch6.78 KBplach
#104 entity-concurrent_translation_validation-2837022-104.interdiff.txt868 bytesplach
#101 entity-concurrent_translation_validation-2837022-101.interdiff.txt1.99 KBplach
#101 entity-concurrent_translation_validation-2837022-101.patch6.75 KBplach
2837022-do-not-test.patch10.61 KBxjm
translation-2837022-95.patch17.5 KBxjm
#97 translation-interdiff-96.txt10.62 KBxjm
#97 translation-2837022-96.patch7.38 KBxjm
#95 translation-interdiff-95.txt3.63 KBxjm
#95 translation-2837022-95.patch17.5 KBxjm
#82 interdiff-69-82.txt13.29 KBhchonov
#82 2837022-82.patch17.44 KBhchonov
#69 interdiff-61-69.txt1.74 KBhchonov
#69 2837022-69.patch7.13 KBhchonov
#61 interdiff-translation-changed.txt3.65 KBxjm
#61 entity-2837022-61.patch6.33 KBxjm
#57 interdiff-49-57.txt2.66 KBhchonov
#57 2837022-57.patch6.44 KBhchonov
#49 interdiff-47-49.txt3.03 KBhchonov
#49 2837022-49.patch5.55 KBhchonov
#49 2837022-49-test-only.patch3.57 KBhchonov
#47 interdiff-42-47.txt4.53 KBhchonov
#47 2837022-47.patch5.95 KBhchonov
#42 interdiff-37-42.txt1.53 KBhchonov
#42 2837022-42.patch5.32 KBhchonov
#42 2837022-42-test-only.patch3.23 KBhchonov
#37 2837022-37.patch5.02 KBhchonov
#37 2837022-37-test-only.patch3.23 KBhchonov
#36 2837022-add-initial-test.patch4.25 KBvlad.dancer
#19 check-changed-value-correctly-using-sum-instead-max-2837022-19.patch1.88 KBvlad.dancer
#17 check-changed-value-correctly-using-sum-instead-max-2837022-17.patch1.86 KBvlad.dancer
#12 Edit_Article_TEST_EN.png123.33 KBvlad.dancer
#12 compare-lowest-changed-value-2465909-26.patch695 bytesvlad.dancer
#10 changed-field-non-translatable.png43.39 KBmatsbla
#10 changed-field-translatable.png30.49 KBmatsbla

Comments

matsbla created an issue. See original summary.

cilefen’s picture

Priority: Critical » Major

Lost input is not critical.

matsbla’s picture

Title: EntityChangedConstraint does not stop authors to edit untranslatable image files with translatable attributes concurrently » Changes made on untranslatable image files and untranslatable field gets reverted when translations edited concurrently
Issue summary: View changes

I disagree that the priority of this issue should be decreased. For multilingual site with many languages or where content is frequently updated this will lead to a significant regressions in user experience.

It would also be good to clear up a little what the issue is about: I wonder if EntityChangedConstraint is only suppose to conatrain cuncurrent editing of same translation? Or also cuncurrent editing of untranslatable fields? I think it should constrain editing of nodes where any untranslatable fields has changes as translators might need to take into account these changes. At least cuncurrent editing of untranslatable fields should not lead to reverting changes made by others without knowing it, which is how the current state is now.

cilefen’s picture

It's fine that you disagree but you would have to justify why this is critical according to the guidelines.

matsbla’s picture

Becauase issues that gives "Significant regressions in user experience" is listed as example under issues that can be marked as critical.

cilefen’s picture

The passage you quoted is about non-bugs and the critical priority is "at the core maintainers' discretion" in that case. You submitted a detailed bug report and I don't like arguing in comments. Do what you feel is right and we can see what others think. I could be wrong.

edited

matsbla’s picture

matsbla’s picture

berdir’s picture

Editing different translations of the same node is not supported, the only bug here is that it should not be possible and you should get a validation error if someone else saved. See discussion in #2465909: Implement per-language locking at entity form level.

matsbla’s picture

Okay I found one lead to why the constrain is not working. If the changed field is set to translatable the constrain is not working, while if you make it non-translatable the constrain will work;
Changed field translatable
However if you set it to non-translatable then changed date will be updated for all translations everytime you update one of them so you loose data about when specific translations where last updated;
Changed field non translatable

Version: 8.2.x-dev » 8.3.x-dev

Drupal 8.2.6 was released on February 1, 2017 and is the final full bugfix release for the Drupal 8.2.x series. Drupal 8.2.x will not receive any further development aside from critical and security fixes. Sites should prepare to update to 8.3.0 on April 5, 2017. (Drupal 8.3.0-alpha1 is available for testing.)

Bug reports should be targeted against the 8.3.x-dev branch from now on, and new development or disruptive changes should be targeted against the 8.4.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

vlad.dancer’s picture

Version: 8.3.x-dev » 8.4.x-dev
Status: Active » Needs review
StatusFileSize
new695 bytes
new123.33 KB

Hi, everyone!

While working on various concurrent content editing issues (in most cases with translations) like:
- #2808335: Changed time not updated when only non-translatable fields are changed on a translated entity
- this one

I've used patch from here https://www.drupal.org/node/2808335#comment-11929474 and got this data

while debugging EntityChangedConstraintValidator::validate + EntityChangedTrait::getChangedTimeAcrossTranslations,
we will see on the screenshot that en translation of the updated entity($saved_entity) has higher date than en translation of being updating entity($entity),

so the question is why we use max() instead of min() to find a right "changed" candidate?

I've did a quick patch on top of 2808335 an also did manual tests with both patches on simplytestme,
and it seems that it fixes this problem:

I am confused as in this case the EntityChangedConstraint should have failed and you shouldn't be allowed to save the other translations, but it looks like the constraint is not failing during your manual test

Status: Needs review » Needs work

The last submitted patch, 12: compare-lowest-changed-value-2465909-26.patch, failed testing.

vlad.dancer’s picture

Version: 8.4.x-dev » 8.3.x-dev
Status: Needs work » Needs review

Issue #2808335: Changed time not updated when only non-translatable fields are changed on a translated entity was resolved and patches were commited to the 8.3/4.x versions.
So lets try run tests for patch on 8.3.x

Status: Needs review » Needs work

The last submitted patch, 12: compare-lowest-changed-value-2465909-26.patch, failed testing.

vlad.dancer’s picture

Title: Changes made on untranslatable image files and untranslatable field gets reverted when translations edited concurrently » EntityChangedConstraintValidator doesn't add violation when translations with shared fields edited concurrently and changed field set to translatable
Issue summary: View changes

Change issue description to be more generic.

vlad.dancer’s picture

Status: Needs work » Needs review
StatusFileSize
new1.86 KB

Ok, the thing is that when you don't make "changed" field translatable then entity that is saving has the same changed value for all translations,
but when you'll set "changed" field to be translatable then saving entity has also same changed values for all translation except one - current, it has lower value!

Despite of the fix above works for validation case, but it might broke entity saving cases, at least breaks tests now)

Then I've came up with:
EntityChangedConstraintValidator uses EntityChangedTrait::getChangedTimeAcrossTranslations method to distinguish changes between original entity and current.
But EntityChangedTrait::getChangedTimeAcrossTranslations uses max function inside to find out latest value for changed field across all translations (according to the function doc block)
Despite of above it sounds good in terms when entity already validated and being saving, but on other side it's unacceptable to use it at validation step.

I propose to use "sum" strategy to detect changes across all translations in two entities inside EntityChangedConstraintValidator and don't use getChangedTimeAcrossTranslations at all.

Status: Needs review » Needs work
vlad.dancer’s picture

vlad.dancer’s picture

Status: Needs work » Needs review
xjm’s picture

Title: EntityChangedConstraintValidator doesn't add violation when translations with shared fields edited concurrently and changed field set to translatable » Concurrently editing two translations of a node may result in data loss for non-translatable fields
Priority: Major » Critical

Discussed with @plach, @Berdir, @tstoeckler, and @amateescu discussed this issue and agreed it's a critical issue. While lost user input itself would only be major, in this case, the data that was saved is deleted/overwritten, especially when revisioning is not enabled. So in this case it's actually lost data that can lead to integrity problems. The validation API is not preventing this, when it should.

Per discussion, we are not sure if we should support changing the translatability of the changed field.

vlad.dancer’s picture

@xjm, thanks for youк attention to this problem.

But I really worry about:

...we are not sure if we should support changing the translatability of the changed field.

As I see all patches has long life in core issues before they will be commited, so I worry about end users and their data.
Do you know when you'll deprecate support for "changing the translatability of the changed field"?

My patch works correctly both for (un)/translated changed field, but the way of fixing constraint seems for me redundant in case when you want to drop off support of the translated changed field.
As I see I leave this patch just here for people who needs it till you'll release a new Drupal version w/o translation support of the changed field.

BTW: I've almost finished a functional test for that case.

matsbla’s picture

we are not sure if we should support changing the translatability of the changed field

To me it is not clear weather this mean it will not any longer be possible to make the change field translatable. Or will it only not be possible to change the translatability once the field has been initiated? I think it is crucial to keep make it possible to make the change field translatable to be able to check when different translations were last updated.

hchonov’s picture

The entity changed constraint validator works together with the node form where we set as an input the current changed timestamp, so that even if the entity gets a new timestamp meanwhile on building the entity we'll map on it the changed timestamp from the user input and then no matter if we're dealing with a modified translatable or non-translable field the entity changed constraint validator should fail the validation. Having said this I am not sure how does it happen that the constraint validator isn't failing? Is it possible that it fails but for some reason the error message is filtered away? Could you please debug the use case and post the timestamps being compared in the entity changed constraint validator when you're saving the second translation?

vlad.dancer’s picture

@hchonov. Thanks for participating.

Having said this I am not sure how does it happen that the constraint validator isn't failing?

This is a reproducible bug, see "Steps to reproduce" section.

For the asked timestamps look at my screenshot:
timestamps

hchonov’s picture

But at your screenshot I see the validation failing message? If we see this message then we have no bug. We have a bug only if the second translation gets saved without validation errors being shown to the user preventing the saving.

vlad.dancer’s picture

This message appeared because I've manually fixed some code to check my ideas, so if you want I can add another screenshot without my fixes and with outputted timestamps.

hchonov’s picture

I've looked at the constraint once more and I get it now where the error happens :
if ($saved_entity && $saved_entity->getChangedTimeAcrossTranslations() > $entity->getChangedTimeAcrossTranslations()) {..}

The problem is that we build the entity from the user input and apply the changed timestamp only for the current language, but as the entity is not yet cached as part of the form cache it is reloaded with new timestamps on the other translations ..

Possible solutions:
1. Provide input fields for all changed timestamps of all translations
2. Instead of directly using getChangedTimeAcrossTranslations first compare the changed timestamp of the entity in its current translation with the timestamp of the original in the current translation of the entity - here we have to get the translation entity object. If this comparison doesn't fail then continue to the usual check as of now.
3. Solve #2824293: Inconsistent form build between initial form generation and the first ajax request.

I would go with 2 as the fastest solution here.

hchonov’s picture

I suggest the following :

if ($saved_entity && (($saved_entity->getChangedTime() > $entity->getChangedTime()) || ($saved_entity->getChangedTimeAcrossTranslations() > $entity->getChangedTimeAcrossTranslations()))) {..}
hchonov’s picture

Issue tags: +Needs tests

This will solve the current issue but we still need a test. A kernel test showing that the entity changed constraint is failing will be sufficient.

vlad.dancer’s picture

@hchonov, just checked:
> I suggest the following
And this solution doesn't work for me.

Also I want to say that I've tried to create kernel test (using as base EntityKernelTestBase) but I have no luck with it, I mean test can't test this case, entity was built with other changed timestamps than via admin interface, did I missed something?

$reloaded = EntityTestMulChanged::load($entity->id());
$rel_translation = clone EntityTestMulChanged::load($entity->id())->getTranslation('uk');
$rel_translation->set('translatable', 'Привiт');

$reloaded->set('translatable', 'Hello1');
$reloaded->save();

$errors = $this->getErrorsForEntity($rel_translation);
....
function getErrorsForEntity(...) {
...
  $form_object = $form_state->getFormObject();
  $form_object
      ->setEntity($entity)
      ->setFormDisplay($display, $form_state)
      ->validateForm($form, $form_state);

  return $form_state->getErrors();
}
xjm’s picture

Status: Needs review » Needs work
Issue tags: +Triaged D8 critical

The framework and release managers discussed this issue a couple weeks ago and agreed it was critical because of the data integrity/data loss issue.

Marking NW for the test coverage. In terms of how to test it, if the bug only happens with a browser, one possibility is to add a sleep(1) to ensure the timestamp rolls over. We do this in a few places in tests already for changed functionality, e.g. NodeFormSaveChangedTimeTest.

umed91’s picture

I have been trying to reproduce this bug but not able to. Followed the steps to reproduce it and I am on the same branch mentioned in the issue.
Does the issue still exists? Did anyone face this recently?

umed91’s picture

Ok, I am able to reproduce the bug now.

sathish.redcrackle’s picture

After applying the patch same problem while saving Taxonomy by editing two different translation same time.

vlad.dancer’s picture

StatusFileSize
new4.25 KB

Here is my try with this test. I've haven't had a success. Maybe someone knows what I missed or could make some changes into it.

hchonov’s picture

Version: 8.3.x-dev » 8.4.x-dev
Status: Needs work » Needs review
StatusFileSize
new3.23 KB
new5.02 KB

I've updated the EntityChangedConstraintValidator the way I've proposed in #29 and provided a test which shows that it works.

The last submitted patch, 37: 2837022-37-test-only.patch, failed testing. View results

matsbla’s picture

I did a manual test of 2837022-37.patch in #37 and it works good!

However, I tried to set up an image field with untranlatable file, but with a translatable title. When I changed the image file in one language and just changed the translation of title in another language, the lock didn't work.

hchonov’s picture

@matsbla, could you please describe step by step how I can reproduce the problem?

matsbla’s picture

  1. Create an image field, set it to translatable and save
  2. Then edit the field settings again, and make sure that File elements has translations disabled under "Translatable elements" section
  3. Add 2 translations using this image field
  4. Then try edit the translations concurrently where you replace the image file with new image in one translation and keep the old image file in the other translation
  5. Result: There is no lock even though one of the elements changed are untranslatable. The untranslatable file now in fact gets 2 different images for the 2 different translations, even though the file is untranslatable
hchonov’s picture

Version: 8.4.x-dev » 8.5.x-dev
Issue tags: -Needs tests
StatusFileSize
new3.23 KB
new5.32 KB
new1.53 KB

After looking deeper at the issue my conclusion is that the main problem described by the issue is fixed. Yay! :).

The problem described by @matsbla in #41 might sound similar but is of completely different character. The problem is that content_translation allows for making only specific field properties translatable or not translatable and syncs the non-translatable properties in hook_entity_presave(), which means content_translation will be updating a field after the check for changes by ChangedItem::preSave() has run, because the entity field preSave methods are executed before the presave hooks have run. I've created a separate issue for this problem - #2912318: Changing field value in an entity presave hook will not update the entity changed timestamp where I've provided an initial patch, which is not perfect, but should fix the problem. @matsbla would you please test the patch from the other issue? Thank you.

I am re-uploading the patch that is solving the problem of the current issue. I've updated the comment in the patch as well. As there is already a test coverage I am removing the needs tests tag.

The last submitted patch, 42: 2837022-42-test-only.patch, failed testing. View results

hchonov’s picture

A related issue is #2824293: Inconsistent form build between initial form generation and the first ajax request, which probably will solve the problem as well when ready, but it is a much harder fix to get in than the current simple solution of the latest patch in the current Issue.

matsbla’s picture

I've tested the new patch in #2912318: Changing field value in an entity presave hook will not update the entity changed timestamp and looks like that solves the problem with translatable image fields.

Do we need to wait for D8.5 to get these constrains in?

plach’s picture

The fix looks good, to me, however I found the comments a bit hard to parse. I'll try to suggest something clearer and add commas and points here and there to to pretend I can use punctuation :)

hchonov’s picture

@plach, I've refactored the comments and I hope that they are more easy to understand now :)

plach’s picture

Status: Needs review » Needs work

I got back to this the other day and briefly discussed it with @hchonov as, after thinking a bit more about it, I had some doubts.

Basically I think that the current fix is correct but not enough. For a form workflow it's correct, but I think that we should check that no translated value of the changed timestamp is less then any translation of the original one. That simply cannot happen unless the entity we are saving was instantiated before the "original" entity we are using for comparison was stored.

Hristo is onboard with this and is planning to get back to this ASAP.

hchonov’s picture

Status: Needs work » Needs review
StatusFileSize
new3.57 KB
new5.55 KB
new3.03 KB

Actually as we've talked I've told you that I am going to respond the very same day, however I didn't managed that. I apologize for this, Francesco.

I had previously the same idea as @plach, but however I was afraid that the patch is going to go beyond the bug fix and therefore I've implemented it only for the scenario where it is failing, because it is critical in the FAPI. But as I have now @plach on my side about a more general solution then this is what I am providing in the current patch :).

The last submitted patch, 49: 2837022-49-test-only.patch, failed testing. View results

plach’s picture

Looks good, thanks!

+++ b/core/lib/Drupal/Core/Entity/Plugin/Validation/Constraint/EntityChangedConstraintValidator.php
@@ -18,10 +18,20 @@ public function validate($entity, Constraint $constraint) {
+        // It is not allowed that there is an entity translation with an older
+        // changed timestamp than the one of the corresponding saved entity
+        // translation. Comparing only the highest changed timestamps across all
+        // translations is not a sufficient check as by doing this we will not
+        // be able to detect the case where only one entity translation is
+        // obsolete.

This comment is not explaining the root cause, what about something like the following?

We need to make sure that no entity translation appears as modified "before" its stored timestamp. This situation can only arise when the entity being saved was instantiated before a subsequent save. This typically happens when two edit forms for the same entity are instantiated concurrently and subsequently submitted without a reload in between. To avoid reverting changes, we mark the entity as invalid.

plach’s picture

Status: Needs review » Needs work
benjy’s picture

Is EntityChangedConstraintValidator meant to work for revisions as well? The same issue exists for me if two users are editing an entity, and they both hit save, you get two new revisions so all the data was stored but there is no indication to the user that saved the form last and they can unknowingly promote a revision that doesn't include the other users changes.

timmillwood’s picture

@benjy - #2892132: Entity validation does not always prevent concurrent editing of pending revisions is trying to address EntityChangedConstraintValidator issues.

benjy’s picture

Thanks, that's exactly the issue I was looking for.

plach’s picture

hchonov’s picture

Status: Needs work » Needs review
StatusFileSize
new6.44 KB
new2.66 KB

I've rewritten the comment and hope that it looks better now.

hchonov’s picture

plach’s picture

Status: Needs review » Reviewed & tested by the community

Looks a bit verbose, but not something for a non-native English speaker to judge. Let's see whether committers are happy.

xjm’s picture

Assigned: Unassigned » xjm

Agreed that the comment could be a little clearer and less verbose. +1 for documenting it so thoroughly though. I'll see if I can edit it down to make it a little clearer while still keeping all the information.

xjm’s picture

Assigned: xjm » Unassigned
Status: Reviewed & tested by the community » Needs review
StatusFileSize
new6.33 KB
new3.65 KB

Alright, how's this? I tried to remove unnecessary words and clarify a few phrases, plus moved the bit about comparing translations individually down into the foreach.

xjm’s picture

+++ b/core/lib/Drupal/Core/Entity/Plugin/Validation/Constraint/EntityChangedConstraintValidator.php
@@ -18,10 +18,30 @@ public function validate($entity, Constraint $constraint) {
-        if ($saved_entity && $saved_entity->getChangedTimeAcrossTranslations() > $entity->getChangedTimeAcrossTranslations()) {

If getChangedTimeAcrossTranslations() is this dangerously insufficient, should we be deprecating the method? Or at least updating its docs to warn people when it should not be used?

xjm’s picture

+++ b/core/lib/Drupal/Core/Entity/Plugin/Validation/Constraint/EntityChangedConstraintValidator.php
@@ -18,10 +18,30 @@ public function validate($entity, Constraint $constraint) {
-        // A change to any other translation must add a violation to the current
-        // translation because there might be untranslatable shared fields.

We're also losing the bit about untranslatable shared fields; should we add that back?

xjm’s picture

Sorry for all the serial comments. The comment said that the changed timestamp "might" be added to the form; it wasn't clear to me when or whether that would happen, or if we had to worry about it not being the case. The storage validation shouldn't know about the form, but we could maybe provide an @see to where this happens for entity forms?

plach’s picture

Thanks Jess!

  1. The storage validation shouldn't know about the form, but we could maybe provide an @see to where this happens for entity forms?

    Yep, that was my main doubt around that comment block: on one hand I understand the will to save the time spent to troubleshoot the symptom to people reading this code (including our future selves ;), OTOH mentioning the form workflow with this level of detail feels a bit out of place and can be misleading for people coming from different use cases, e.g REST workflows. That's why I had suggested something more neutral in #51.

  2. The comment said that the changed timestamp "might" be added to the form; it wasn't clear to me when or whether that would happen, or if we had to worry about it not being the case.

      +++ b/core/lib/Drupal/Core/Entity/Plugin/Validation/Constraint/EntityChangedConstraintValidator.php
      @@ -18,26 +18,25 @@ public function validate($entity, Constraint $constraint) {
      +        // first, the changed timestamp can be added to the form so that it
      

    Since we are documenting what actually happens, I'd use "is added" here, if we keep this part.

  3. If ::getChangedTimeAcrossTranslations() is this dangerously insufficient, should we be deprecating the method? Or at least updating its docs to warn people when it should not be used?

    IIRC this method was explicitly added to deal with this use case, however its logic is correct: it is useful to determine when the last time the entity was modified is, we just didn't realize that logic is not suitable for this check. In fact it is being correctly used in the comment module. That said, I'm not sure we should change its documentation as the implementation is following it closely. Maybe we could add a paragraph saying something along these lines:

    This will return the highest timestamp across all translations, if you need to check that no translation was changed after a certain time, you need to check each translation's timestamp individually.

  4. We're also losing the bit about untranslatable shared fields; should we add that back?

    IMO that comment was misleading: even if there are no changed untranslatable fields, concurrently editing two different translations will lead to overwriting one of the two. That is exactly the main use case we are dealing with: preventing data loss due to concurrent edits. I wouldn't stress on untranslatable fields, given that we are already mentioning translations. I'd rather add a couple of lines explicitly mentioning what use case this constraint is meant to address in the class docblock, given it's not explicitly stated anywhere.

hchonov’s picture

Jess++, Francesco++ thank you both :)

Regarding the addition of the changed timestamp to the form:

#64

The comment said that the changed timestamp "might" be added to the form; it wasn't clear to me when or whether that would happen, or if we had to worry about it not being the case.

#65.2

Since we are documenting what actually happens, I'd use "is added" here, if we keep this part.

The correct answer atm is - the changed timestamp might be added :). The reason I choose "might" is that this happens in core only in the NodeForm. I don't know why this logic is not placed in ContentEntityForm, where actually it belongs so that it is available per default for all content entity forms and not only for node forms. This means core offers this protection by default only for node entities. If we write "is added" then we should add the changed timestamp to the form in ContentEntityForm, which unfortunately is out of scope of the current issue.

The storage validation shouldn't know about the form, but we could maybe provide an @see to where this happens for entity forms

Currently - only in \Drupal\node\NodeForm::form().

Regarding ::getChangedTimeAcrossTranslations():
I think that the current documentation on the method is sufficient:

Gets the timestamp of the last entity change across all translations.

and doesn't need any narrow explanation, but I am fine if you both think it should be explained better :).

plach’s picture

The correct answer atm is - the changed timestamp might be added :). The reason I choose "might" is that this happens in core only in the NodeForm.

Ah, sorry, I didn't realize that was the case, then "might" is correct. That's a major bug in itself, anyway.

hchonov’s picture

I've just created #2920251: Offer concurrent editing protection for all entities by including the changed timestamp to each entity form. Feel free to raise the status to "major" if you consider it major :).

hchonov’s picture

StatusFileSize
new7.13 KB
new1.74 KB

As suggested by Jess in #64 I've added @see referring to the NodeForm.

As suggested by Francesco in #65 I've extended the ::getChangedTimeAcrossTranslations() documentation.

I hope that this issue gets in before #2920251: Offer concurrent editing protection for all entities by including the changed timestamp to each entity form, where we could exchange

the changed timestamp can be added to the form so that

with

the changed timestamp is added to the form so that

plach’s picture

Assigned: Unassigned » xjm
Status: Needs review » Reviewed & tested by the community

Back to @xjm, let's see what she thinks.

gábor hojtsy’s picture

+++ b/core/lib/Drupal/Core/Entity/Plugin/Validation/Constraint/EntityChangedConstraintValidator.php
@@ -18,10 +18,31 @@ public function validate($entity, Constraint $constraint) {
+        if ($saved_entity) {
+          $common_translation_languages = array_intersect_key($entity->getTranslationLanguages(), $saved_entity->getTranslationLanguages());
+          foreach (array_keys($common_translation_languages) as $langcode) {
+            // Merely comparing the latest changed timestamps across all
+            // translations is not sufficient since other translations may have
+            // been edited and saved in the meanwhile. Therefore, compare the
+            // changed timestamps of each entity translation individually.
+            if ($saved_entity->getTranslation($langcode)->getChangedTime() > $entity->getTranslation($langcode)->getChangedTime()) {
+              $this->context->addViolation($constraint->message);
+              break;
+            }
+          }
         }

One case that could happen and is not covered is the removal of a translation language, which this validation will not catch and add back, no? I am not sure how we could handle that other than purging translations instead of removing them so not un-RTBC-ing for this :)

If this case is handled already, it would be nice to document it.

hchonov’s picture

One case that could happen and is not covered is the removal of a translation language, which this validation will not catch and add back, no? I am not sure how we could handle that other than purging translations instead of removing them so not un-RTBC-ing for this :)

I think we can detect this. Good catch, Gábor!

What if we have as a condition that the unchanged entity has all the translations of the current entity or if it doesn't have a specific translation that then this translation should be flagged as new on the current entity, otherwise fail as we will add a removed translation back? I mean something like this

foreach (array_keys($entity->getTranslationLanguages()) as $langcode) {
  if (!$unchanged->hasTranslation($langcode) && !$entity->getTranslation($langcode)->isNewTranslation()) {
  // Validation fail as the entity object is obsolete.
  }
}
hchonov’s picture

Should we adress this problem in this issue or in a new one?

hchonov’s picture

@Gábor Hojtsy, I think that the current issue is large enough and the problem described here already fixed in the patch. In order to keep it only to that and as it is already RTBC, I've created another issue and posted the initial patch on top of this one there - #2920889: EntityChangedConstraintValidator doesn't take adding, removing and reverting translations into account.

gábor hojtsy’s picture

Status: Reviewed & tested by the community » Needs review
+++ b/core/lib/Drupal/Core/Entity/Plugin/Validation/Constraint/EntityChangedConstraintValidator.php
@@ -18,10 +18,31 @@ public function validate($entity, Constraint $constraint) {
+        // Ensure that all the entity translations match their current version
+        // in the storage in order to avoid reverting other changes. The entity
+        // object might contain an older entity translation when a translation
+        // is being concurrently edited. If the form that is submitted last
+        // is not yet cached, the entity will be retrieved from the entity
+        // storage on submit, and that entity object will be the updated one
+        // from the previous form submission. To prevent the entity from the
+        // second submission from being saved and overwriting changes from the
+        // first, the changed timestamp can be added to the form so that it
+        // updates the entity object on submit. We use the timestamp to
+        // determine when a previous entity translation version has been edited
+        // and is currently being saved.
+        // @see \Drupal\node\NodeForm::form().
+        if ($saved_entity) {
+          $common_translation_languages = array_intersect_key($entity->getTranslationLanguages(), $saved_entity->getTranslationLanguages());
+          foreach (array_keys($common_translation_languages) as $langcode) {
+            // Merely comparing the latest changed timestamps across all
+            // translations is not sufficient since other translations may have
+            // been edited and saved in the meanwhile. Therefore, compare the
+            // changed timestamps of each entity translation individually.
+            if ($saved_entity->getTranslation($langcode)->getChangedTime() > $entity->getTranslation($langcode)->getChangedTime()) {
+              $this->context->addViolation($constraint->message);
+              break;
+            }
+          }

Another key question here. Do we need this to happen for entities where all fields are translatable? Sounds like an artificial limitation for them without any benefit to me? Why should they not be able to save translation changes concurrently?

hchonov’s picture

@Gábor Hojtsy, this is currently not possible in core, because in that case we have to merge the entities. This is something I've already implemented in the conflict module and by enabling and activating it for an entity type it will automatically perform all the needed merges e.g. revision ID and changed timestamps of concurrently edited translations, even if they have non translatable fields, but no changes made on the non translatable fields :).

The conflict module will ensure that the constraint doesn't fail by properly merging the entities.

Even if the conflict module is enabled we still need the limitations in the constraint, as after it has merged the concurrent edits and before the validation a new concurrent edit might occur.

gábor hojtsy’s picture

@hchonov and that is ... because we always save over all translations of the entity even when only editing one of them?

hchonov’s picture

@Gábor Hojtsy, yes exactly because of this.

gábor hojtsy’s picture

Status: Needs review » Reviewed & tested by the community

Ok, I was pondering if we should document that or not, but we don't need to document the whole entity system in this one comment really. All right, since we now have #2920889: EntityChangedConstraintValidator doesn't take adding, removing and reverting translations into account as a separate issue, this sounds fine with me.

xjm’s picture

Status: Reviewed & tested by the community » Needs work

A few more points of feedback:

  1. +++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityValidationTest.php
    @@ -200,4 +205,47 @@ public function testCompositeConstraintValidation() {
    +    $this->assertNotEquals($entity->getChangedTime(), $translation->getChangedTime());
    

    For this, it actually matters which is greater, right? So we should be asserting that the later is greater than the former to validate that the test provides the correct coverage?

  2. +++ b/core/lib/Drupal/Core/Entity/Plugin/Validation/Constraint/EntityChangedConstraintValidator.php
    @@ -18,10 +18,30 @@ public function validate($entity, Constraint $constraint) {
    +            if ($saved_entity->getTranslation($langcode)->getChangedTime() > $entity->getTranslation($langcode)->getChangedTime()) {
    
    +++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityValidationTest.php
    @@ -200,4 +205,47 @@ public function testCompositeConstraintValidation() {
    +    $this->assertNotEquals($entity->getChangedTime(), $translation->getChangedTime());
    

    For this, it actually matters which is greater, right? So we should be asserting that the later is greater than the former to validate that the test provides the correct coverage?

  3. +++ b/core/lib/Drupal/Core/Entity/EntityChangedInterface.php
    @@ -37,6 +37,10 @@ public function setChangedTime($timestamp);
    +   * This method will return the highest timestamp across all translations. If
    +   * you need to check that no translation was changed after a certain time, you
    +   * have to check each translation's timestamp individually.
    

    Makes sense to me, thanks!

  4. +++ b/core/lib/Drupal/Core/Entity/Plugin/Validation/Constraint/EntityChangedConstraintValidator.php
    @@ -18,10 +18,31 @@ public function validate($entity, Constraint $constraint) {
    +        // object might contain an older entity translation when a translation
    ...
    +          $common_translation_languages = array_intersect_key($entity->getTranslationLanguages(), $saved_entity->getTranslationLanguages());
    +          foreach (array_keys($common_translation_languages) as $langcode) {
    +            // Merely comparing the latest changed timestamps across all
    +            // translations is not sufficient since other translations may have
    +            // been edited and saved in the meanwhile. Therefore, compare the
    +            // changed timestamps of each entity translation individually.
    +            if ($saved_entity->getTranslation($langcode)->getChangedTime() > $entity->getTranslation($langcode)->getChangedTime()) {
    +              $this->context->addViolation($constraint->message);
    +              break;
    +            }
    

    I had another thought: If there's a getChangedTimeAcrossTranslations() method that is reused elsewhere, how about we rename it to getLatestChangedTimeAcrossTranslations() (deprecating the old one) and then move this logic into a getOldestChangedTimeAcrossTranslations()? That'd make the difference really clear. We'd still have to inline it for the backport, but that might be more descriptive and also be useful in other places.

  5. +++ b/core/lib/Drupal/Core/Entity/Plugin/Validation/Constraint/EntityChangedConstraintValidator.php
    @@ -18,10 +18,31 @@ public function validate($entity, Constraint $constraint) {
    +        // is being concurrently edited. If the form that is submitted last
    +        // is not yet cached, the entity will be retrieved from the entity
    
    +++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityValidationTest.php
    @@ -200,4 +205,47 @@ public function testCompositeConstraintValidation() {
    +    // an uncached form by setting the previous timestamp of an entity
    

    So what does the form being cached or not have to do with it? It seems irrelevant, right? Either the form is cached with the same or an even older timestamp, or it's uncached with the same timestamp?

  6. +++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityValidationTest.php
    @@ -200,4 +205,47 @@ public function testCompositeConstraintValidation() {
    +    // Simulate concurrent form editing by saving the entity with an altered
    +    // non-translatable field in order for the changed timestamp to be updated
    +    // across all entity translations. Afterwards we simulate form submitting of
    ...
    +    // translation on the saved entity object. This is what happens in the
    +    // entity form API where we put the changed timestamp of the entity in a
    +    // form hidden value and then set it on the entity which on form submit is
    +    // loaded from the storage. Setting the initial changed timestamp on the
    +    // entity loaded from the storage is used as a prevention from saving a form
    +    // built with a previous version of the entity and thus reverting changes by
    +    // other users.
    

    This is also a bit long; I start to get confused about halfway through it. To address this, instead of having one long comment, I'd put part of it directly above the specific lines being described.

Thanks!

xjm’s picture

Assigned: xjm » Unassigned
hchonov’s picture

Status: Needs work » Needs review
StatusFileSize
new17.44 KB
new13.29 KB

@xjm thank you for the extended review!

1. Yes. I've changed that.
2. Hmm, 2 is actually 1 or I didn't understand something?
3. :).
4. Done. I've exchanged also all the calls to ::getChangedTimeAcrossTranslations() with ::getLatestChangedTimeAcrossTranslations().
5.

So what does the form being cached or not have to do with it? It seems irrelevant, right? Either the form is cached with the same or an even older timestamp, or it's uncached with the same timestamp?

The problems are caused since #2502785: Remove support for $form_state->setCached() for GET requests, because until that issue got in the forms were cached on their retrieval i.e. form get request and afterwards they are being cached only after the first ajax request e.g. after clicking on "Add another item" on a multiple field. When the form is cached, then the entity is being cached as part of the form and in such case we don't even have to put the changed timestamp to the form, as we'll not load the entity from the storage if the form is cached, but retrieve it from the form object. But if the form is not yet cached and we load the entity from the storage, then we might load a newer version and this is why all the problems arise, as in this case we have to map the changed timestamp from the user input on the entity loaded from the storage, so that the entity changed constraint validator fails. I've already created an issue and am working on it to tackle the problems caused since #2502785: Remove support for $form_state->setCached() for GET requests got in - #2824293: Inconsistent form build between initial form generation and the first ajax request, but it will need more time until it is ready and stable. I hope, that this answers your question, @xjm?

6.

This is also a bit long; I start to get confused about halfway through it. To address this, instead of having one long comment, I'd put part of it directly above the specific lines being described.

I was able to split it in three parts, but not to reduce the content of the comment. If this is still no-go, then help making it clearer and shorter will be much appreciated :).

plach’s picture

Looks good to me, but we are still missing the ::getOldestChangedTimeAcrossTranslations() method mentioned in #80.4.

hchonov’s picture

Oh I am sorry, I was going to ask in my previous comment about more details on that method, but I've forgotten. I didn't really understand what for do we need the oldest timestamp across all translations?

plach’s picture

Assigned: Unassigned » xjm
Status: Needs review » Reviewed & tested by the community

Mmh, read #80 again and I think @xjm was referring to encapsulating the logic we have now in the validator, but that's actually comparing two different entity objects so I'm not sure ::getOldestChangedTimeAcrossTranslations() would make sense in that context. Sending back to her to get a clarification ;)

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 82: 2837022-82.patch, failed testing. View results

hchonov’s picture

Status: Needs work » Reviewed & tested by the community

Just a random failure => back to RTBC.

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 82: 2837022-82.patch, failed testing. View results

plach’s picture

Status: Needs work » Reviewed & tested by the community

Same

xjm’s picture

Assigned: xjm » Unassigned
Status: Reviewed & tested by the community » Needs review

Ah yes, you're right, that doesn't make sense when we're comparing two entity objects. Sorry for the misdirect.

How about a getChangedTimesForAllTranslations() that returns a map of langcode to timestamp? Or oh, how about a method for isAnyTranslationNewerThan($other_entity)? That way we can still encapsulate the logic in a reusable way that will help developers write safer code in similar situations, and attach the documentation to the method rather than an inline wall of text. Then the getLatest...() method docs can say "use isAnyTranslationNewerThan() instead" instead of "you have to check each translation's timestamp individually."

What do you think?

hchonov’s picture

@xjm, on the one hand we have the changes here and on the other we have a follow-up issue that checks for a removed translation and relies on the changes here i.e. iterating over the translation objects - #2920889: EntityChangedConstraintValidator doesn't take adding, removing and reverting translations into account. If we move the logic into a separate method on the entity then we'll have to iterate two times over the translation objects when the follow-up is ready and I think it will be better if we avoid this. Would you please take a look at the latest patch in the referenced issue? I rather see this as a framework internal check and would not make it publicly available - at least not yet as we have also one more issue that might eventually make changes to the constraint validator - #2892132: Entity validation does not always prevent concurrent editing of pending revisions.

plach’s picture

Status: Needs review » Reviewed & tested by the community

Agreed, I'd open a follow-up to explore this idea after all the validation issues are in and dust has settled :)

plach’s picture

xjm’s picture

Assigned: Unassigned » xjm

Alright, I guess it doesn't make sense to block a critical on API improvements especially when there's a dependency chain that will further evolve all this. Was just trying to think of ways to make this all more explicit so we don't introduce similar criticals in the future.

I'll try to look at this again later today.

Thanks!

xjm’s picture

Assigned: xjm » Unassigned
Status: Reviewed & tested by the community » Needs review
StatusFileSize
new17.5 KB
new3.63 KB

Alright, so I read over #82 again and I think we should just remove the bit about the form cache from the comment as well as most of the stuff about the form. Our validation logic should not make any assumptions about caching (or even that it's invoked from a form at all). When we implement improvements to the form, we can add docs like the removed ones there.

I also figured out from #84 what we actually need to communicate on the docs for the other method.

Attached implements both those docs changes. NR again to check the accuracy of these changes... (sorry).

xjm’s picture

Status: Needs review » Needs work
+++ b/core/lib/Drupal/Core/Entity/EntityChangedInterface.php
@@ -37,10 +37,32 @@ public function setChangedTime($timestamp);
+  /**
+   * Gets the timestamp of the last entity change across all translations.
+   *
+   * This method will return the highest timestamp across all translations. If
+   * you need to check that no translation was changed after a certain time, you
+   * have to check each translation's timestamp individually.
+   *
+   * @return int
+   *   The timestamp of the last entity save operation across all
+   *   translations.
+   *
+   * @deprecated in Drupal 8.5.x and will be removed before Drupal 9.0.0. Use
+   *   \Drupal\Core\Entity\EntityChangedInterface::getLatestChangedTimeAcrossTranslations()
+   *   instead.

Mmmm, we have some docs duplication here. Usually we should make the deprecated docs reference the replacement (as well as the change record which this issue does not yet have). For the same reason as #94 I think we should not do the rename/deprecation yet in this issue and instead add it in (another) followup. That will also allow this fix to be backported to 8.4.x safely.

xjm’s picture

Status: Needs work » Needs review
StatusFileSize
new7.38 KB
new10.62 KB

For #96.

xjm’s picture

matsbla’s picture

For myself I think the comments are written excellent and are very clear now.
I'm also happy for a solution that can be back-ported to D8.4 so this can be solved as early as possible.
I wonder if there can be any risks to start use the patch already now until it has been committed?
Anyway thanks a lot for all those helping to find a solution on this issue!

plach’s picture

Status: Needs review » Reviewed & tested by the community
StatusFileSize
new6.75 KB
new1.99 KB

I discussed this with @hchonov and @xjm (separately) and we agreed that probably we should remove from comments any reference to the Form API and describe only the generic root cause. This will avoid confusion, since this validator applies also for instance to REST saves. Moving back to RTBC since I just removed a few lines.

The patch is safe to apply 8.5.x, the fix has been stable for many versions now, we've just been bikeshedding over comments :)

It should be safe to apply to 8.4.x too, but obviously some preliminary QA is recommended.

wim leers’s picture

This will avoid confusion, since this validator applies also for instance to REST saves.

And https://www.drupal.org/project/jsonapi and https://www.drupal.org/project/graphql :)

Thanks for helping to make Drupal really API-First!

  1. +++ b/core/lib/Drupal/Core/Entity/Plugin/Validation/Constraint/EntityChangedConstraintValidator.php
    @@ -18,10 +18,23 @@ public function validate($entity, Constraint $constraint) {
    +            // Merely comparing the latest changed timestamps across all
    +            // translations is not sufficient since other translations may have
    +            // been edited and saved in the meanwhile. Therefore, compare the
    +            // changed timestamps of each entity translation individually.
    +            if ($saved_entity->getTranslation($langcode)->getChangedTime() > $entity->getTranslation($langcode)->getChangedTime()) {
    

    <3 improved robustness

  2. +++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityValidationTest.php
    @@ -200,4 +205,49 @@ public function testCompositeConstraintValidation() {
    +   * Tests the EntityChangedConstraintValidator.
    

    Nit: … with multiple translations.

Status: Reviewed & tested by the community » Needs work
plach’s picture

Status: Needs work » Reviewed & tested by the community
StatusFileSize
new868 bytes
new6.78 KB

Addressed #102. Restoring the RTBC status as these are clearly testbot issue.

catch’s picture

  1. +++ b/core/lib/Drupal/Core/Entity/Plugin/Validation/Constraint/EntityChangedConstraintValidator.php
    @@ -18,10 +18,23 @@ public function validate($entity, Constraint $constraint) {
    -          $this->context->addViolation($constraint->message);
    +        // Ensure that all the entity translations are the same as or newer
    +        // than their current version in the storage in order to avoid
    +        // reverting other changes. In fact the entity object that is being
    +        // saved might contain an older entity translation when different
    +        // translations are being concurrently edited.
    +        if ($saved_entity) {
    +          $common_translation_languages = array_intersect_key($entity->getTranslationLanguages(), $saved_entity->getTranslationLanguages());
    +          foreach (array_keys($common_translation_languages) as $langcode) {
    +            // Merely comparing the latest changed timestamps across all
    +            // translations is not sufficient since other translations may have
    +            // been edited and saved in the meanwhile. Therefore, compare the
    +            // changed timestamps of each entity translation individually.
    +            if ($saved_entity->getTranslation($langcode)->getChangedTime() > $entity->getTranslation($langcode)->getChangedTime()) {
    +              $this->context->addViolation($constraint->message);
    +              break;
    +            }
    +          }
    

    At the moment translations are simply removed or added to entities so I don't think we can tell if there's a conflict at all - but should we add a @todo to check for translation deletion/creation if/when we explicitly track those things?

  2. +++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityValidationTest.php
    @@ -200,4 +205,49 @@ public function testCompositeConstraintValidation() {
    +
    +    // Simulate concurrent form editing by saving the entity with an altered
    +    // non-translatable field in order for the changed timestamp to be updated
    +    // across all entity translations.
    

    If we're removing references to the form/form cache should we also remove them here? On the other hand a concrete example doesn't hurt to explain what's going on in the test, so leaving RTBC.

hchonov’s picture

Re #106.1:
The deletion of translations is the one that could cause problems and this has been brought up by Gábor in #71. Therefore we've created a follow-up issue to cover this problem - #2920889: EntityChangedConstraintValidator doesn't take adding, removing and reverting translations into account.
New translations could not cause any problems as they will not be present on the saved entity object.

Re #106.2:
I would like to leave this comment describing a concurrent editing through the entity form API :). A concrete example makes sense, yes.

plach’s picture

I agree with #106.2

  • catch committed 0c20200 on 8.5.x
    Issue #2837022 by hchonov, xjm, vlad.dancer, plach, matsbla, Gábor...

  • catch committed 00f7588 on 8.4.x
    Issue #2837022 by hchonov, xjm, vlad.dancer, plach, matsbla, Gábor...
catch’s picture

Version: 8.5.x-dev » 8.4.x-dev
Status: Reviewed & tested by the community » Fixed

Committed/pushed to 8.5.x and cherry-picked to 8.4.x. Thanks!

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.