Problem/Motivation
Normal workflow in HEAD without pending revisions:
- Create a page
- Open the edit page
- Open another edit page in another tab to simulate a concurrent edit
- Change the title and save a new revision
- Now in the other tab without refreshing the page change the title again and save a new revision
- Verify a validation error is displayed
Steps to reproduce with Content Moderation in HEAD:
- Enable the Content Moderation module
- Enable the Editorial workflow for articles
- Create an article and save it as draft
- Open the edit page
- Open another edit page in another tab to simulate a concurrent edit
- Change the title and save a draft
- Now in the other tab without refreshing the page change the title again and save as draft
- Verify a validation error is displayed
- Create another article and save it as published
- Open the edit page
- Open another edit page in another tab to simulate a concurrent edit
- Change the title and save a draft
- Now in the other tab without refreshing the page change the title again and save as draft
Expected result: a validation error is displayed.
Actual result: the second draft "overrides" the first one, although both revisions are correctly stored, so there is no actual data loss.
Proposed resolution
Keep track of the entity revision at the moment the form is built and verify the latest revision matches it when saving the entity via a dedicated constraint validator.
The ultimate goal for this issue is to bring consistency in the entity validation logic when it comes to pending revisions. This solution assumes sequential creation is the default intended behavior for pending revisions, at least for Content Moderation.
Remaining tasks
Fix the blocking issue: #2926483: Add API methods for determining whether an entity object is the latest (translation-affecting) revision- Validate the proposed solution
Update the latest patch- Reviews
User interface changes
None
API changes
Only additions
Data model changes
None
Comment | File | Size | Author |
---|---|---|---|
#147 | interdiff_2892132_145-147.txt | 894 bytes | ankithashetty |
#147 | 2892132-147.patch | 27.49 KB | ankithashetty |
Issue fork drupal-2892132
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
Comment #2
plachPosting a very rough patch (with all sorts of hacks and workarounds) allowing me to satisfy all the use cases I posted at:
https://docs.google.com/spreadsheets/d/1nlQLx5BzXyx1aIB_8ISfPGqSnUPH07YU...
Actually without the revision translation merging logic being implemented at #2860097: Ensure that content translations can be moderated independently this allows to do very nasty stuff, so I think it would make more sense to close this issue and merge the two patches. Posting the patch anyway to check whether bot is happy.
Comment #3
plachSetting to NR for the bot, but this is heavily NW :)
Comment #5
plachFixed stupid thing
Comment #7
plachAnother stupid fix (still heavily NW :)
Comment #8
plachMain things to address:
We need an API to retrieve the latest entity revision id, currently we have it in the
ModerationInformation
service, but we need something at entity subsystem level.We need a proper name for this and probably we should add it only for revisionable entity types, nbd.
Currently we are abusing the the
$entity->original
property, which kind of makes sense, except it doesn't because it doesn't contain the original revision but the original *latest* revision.We need to find a way to pass this information along so it's available to the validation code.
Another property, e.g.
$entity->latestRevision
, is the simplest solution that comes to my mind but it's not a very clean API.Comment #9
plachI spoke with @catch about #8:
@internal
and later, when it's properly shaped up, it would be published as@api
.@internal
magic property, e.g.$entity->originalLatestRevisionId
or something like that. We will open a follow-up to introduce a service to track request-scoped entity metadata via a KV interface, details to be discussed. E.g. how to deal with nested requests and information setting/overwriting/merging.Comment #10
plachAs I mentioned above this should be eventually merged into #2860097: Ensure that content translations can be moderated independently as it allows to edit revisions in a way that, without the proper revision translation merging introduced there, may cause (apparent) data-loss.
Comment #11
plachAddressed the outstanding issue from #8, hopefully I didn't break anything. The next step should be to add test coverage. Then we should be able to merge this into #2860097: Ensure that content translations can be moderated independently or we could remove the parts that make translation merging required and add them back there along with test coverage.
Setting to NR for the bot.
Comment #12
timmillwoodThis will only work if the entity is saved via a form. How about checking the changed time of the revision loaded with the revision ID $entity->getLoadedRevisionId(), and the $entity?
Comment #13
dawehnerYou don't really need a singleton. You can achieve the same having that as a service in the container or even have just static methods.
Comment #14
plach@timmillwood:
Well, not exactly, although you'd have to manually populate the field as you do in the form. Alternatively we could populate it during hook_load(), but it would be an additional query for each load, since that would need to bypass entity cache.
I'm pretty sure I tried that in one of my innumerable experiments, but I will now add test coverage for the typical scenarios I used for testing so we can check whether that works.
@dawehner:
Static methods I'd like to avoid to allow for proper DI. I went for the singleton because this will be @internal until we discover more about its typical usage, and it becomes clearer who should be responsible for instantiating it. In the meantime it doesn't affect signatures and it's still unit testable. You're right that we can use a service instead of a singleton, though.
Comment #16
plachIn #11 I was suggesting to merge this back into #2860097: Ensure that content translations can be moderated independently, however the originally reported bug has nothing to do with translations, so I decide to limit the changes to only those required to fix the original issue and iterate over the current solution in #2860097: Ensure that content translations can be moderated independently.
The attached patch should be more or less ready to go, I think. I'm not attaching an interdiff because it's almost a complete rewrite wrt the previous one.
Comment #18
plachThis should fix the two failures: this new approach uncovered a bug in the logic of
::setNewRevision()
.The other failure is caused by a problematic test that was trying to validate a past revision, which is not supposed to be stored as-is. For now I commented that part. I don't think we are losing test coverage, since the the immediately following assertions are covering the same use case with the latest revision, that is the correct case. I added a todo to re-evaluate that code in #2860097: Ensure that content translations can be moderated independently, since it's dealing with multilingual, but unless I'm missing something, I think it could be simply removed. I'll get in touch with Tim tomorrow to talk about that.
Comment #19
plachFixed typo
Comment #23
plachSlightly adjusted the previous fix
Comment #24
plachOk, reviews welcome
Comment #25
dawehner🙃 Let's use triple equal
Isn't that exactly what the entity repository is about?
🙃 These changes seems to be a bit out of scope here
Comment #26
timmillwoodWouldn't it make more sense to compare the changed time with the latest revision and not the default revision? If the entity type is not revisionable then the latest revision would be the default / only revision, so there's no need to check for an instanceof RevisionableInterface.
Instead of adding this get() static method, can't
\Drupal::service('entity.common_queries')
just be called or the service injected, like any other service within Drupal 8?This might be not needed once #2864995: Allow entity query to query the latest revision is in.
Comment #27
plachThanks!
I was not aware about its existence, honestly. From what I can tell, it seems more focused on instantiating entities rather than querying them, however I could see it being a candidate for these query methods. The main problem is there is no base class for
EntityRepositoryInterface
, so we are not allowed to add methods toEntityRepository
.Comment #28
plachDiscussed this in IRC with @timmillwood: we're getting rid of the
Queries
class for now. We will get back to that topic once #2864995: Allow entity query to query the latest revision is sorted out.Regarding #26.1 I pointed out :
Comment #29
plachWrong interdiff, sorry.
Comment #30
amateescu CreditAttribution: amateescu for Pfizer, Inc. commentedI think it's worth waiting a bit for #2864995: Allow entity query to query the latest revision which is RTBC and will add a clean way of querying for the latest revision :)
Also, tests were added by @plach so I'm removing that tag.
Comment #31
amateescu CreditAttribution: amateescu for Pfizer, Inc. commented#2864995: Allow entity query to query the latest revision just got in so we can use it here!
Comment #32
plachDone :)
Comment #33
amateescu CreditAttribution: amateescu for Pfizer, Inc. commented@plach, nice and fast! Now here's an actual review of the patch :)
Is there a reason for assigning the
$revision_id
variable and then just use it once in the check below?Can't this be rewritten to
if ($this->getRevisionId() == $this->getLoadedRevisionId())
?revision ID :)
Same as above, we're not using the $revision_key variable anywhere else so we can simply use call the method below.
I'm not sure where we use the revision ID set as a hidden element in the entity form, since the constraint validator only looks at the loaded revision ID and the latest revision ID..
A bigger point here is that we're probably missing some dedicated test coverage with actual entity forms being manipulated and submitted at independently of each other.
Comment #34
plachAddressed #33.
1: Fixed (it's easier to debug if the result is stored in a variable :)
2: Fixed.
3: Not fixed, I think it reads better the current way.
4: By storing the revision ID as an (hidden) form element, we know which value it had when the form was instantiated. This allows to compare it with the latest revision ID in the validator. We are using the loaded revision ID there (which is updated while building the entity with the revision id in the hidden field) because a call to
::setNewRevision()
will set the revision ID to NULL. Good point about the test coverage, fixed.Comment #35
plachSmall additional clean-up
Comment #36
timmillwoodShouldn't this be
===
not&
?Can we test the case when we create a new revision and set it as default?
Comment #37
plach1: Nope, the idea is that both
SAVE_NEW_REVISION
andSAVE_PENDING_REVISION
satisfy that condition2: That's precisely the
SAVE_NEW_REVISION
case :)Maybe the following code would be more explicit?
Comment #38
timmillwoodSpoke with @plach on IRC, all make sense now. Thanks.
Looks good to me. I guess it'd be good if @amateescu can give a +1 as he provided a recent review, but I think we should be good to go.
Comment #40
plachComment #41
Wim LeersSupernit: s/id/ID/
❓ Hm, this is effectively checking against a race condition. But what if two requests are simultaneously executing this code? Then there's still the chance for a race condition, isn't there?
Nit: The "inheritdoc" seems out of place here?
Supernit: s/french/French/
Supernit: can be just "inheritdoc"?
👍 Bitwise AND operator. This is correct/intentional, but since it's so rare, it tripped me up at first :P
Comment #42
plach1: fixed
2: Of course: probably we should be using locking in the storage handler to secure ourselves against race conditions. However not even that would be 100% safe, because PHP provides nothing like Java's synchronized methods. That said, the issue here is about fixing the existing logic, so I think implementing anything else would be out of scope.
3: Actually PHP Storm stops complaining about arguments not being documented with that. Unfortunately it's not so smart yet to actually use the documentation available in
.api.php
files. One man can dream though ;)4: fixed
5: fixed
6: yeah, I'm starting to have doubts about that code: it was meant to be more readable that way :D
7: I hope so: we moved from an approach that could work in programmatic workflows with some tweaks to one that should be totally transparent. If you instantiate an entity and save a draft and another client tries to save a draft at the same time, the revision id checks should make validation fail. This should be extensively covered by the kernel test.
Comment #43
plachFixed a few more occurrences of "id" and "french".
Comment #44
Wim Leers#42.2: should we have a follow-up for it, or do you think it's just not realistic?
#42.7: 👍
Comment #45
plachI think a followup would be totally fine, I guess that would need to be addressed when implementing some kind of conflict management in core.
Comment #46
amateescu CreditAttribution: amateescu for Pfizer, Inc. commentedRe #34.4:
Ahh, I see, this was the part that I was missing. The explanation makes sense, thanks :)
Since this is a content moderation test class, you can use
\Drupal\content_moderation\ModerationInformation::getLatestRevisionId()
.Also, a small plug for #2918569: Simplify ModerationInformation::getLatestRevisionId() :)
Comment #47
plachRrright!
Comment #48
amateescu CreditAttribution: amateescu for Pfizer, Inc. commentedCool, no other observations from me. RTBC++
Comment #49
hchonov"was being changed" vs "was being edited"?
Couple of lines underneath we have to load the unchanged entity, therefore I wonder if we really need the new method or could just retrieve the revision ID from the unchanged entity? Excuse me if this has been already discussed, I didn't manage to find out why the new method has been introduced instead of using the unchanged entity.
Do we need to assign the result to a variable which is used only once?
I would like to use the current thread to point to an issue, the fix of which would make unnecessary the addition of the changed timestamp and the revision ID as a hidden value to the form - #2824293: Inconsistent form build between initial form generation and the first ajax request.
Comment #50
hchonovRegarding #49.2:
Retrieving the revision ID through an entity query would be a performance optimization instead of retrieving the unchanged entity in the case where the the entity has been saved meanwhile with a new revision.
If the entity has not been saved with a new revision meanwhile or has not been saved at all meanwhile then we will always perform both an entity query for retrieving the latest revision ID and load the unchanged entity.
I have nothing against an entity query, I even do it as an optimazation for this constraint validator in #2837707: EntityChangedConstraintValidator should be retrieving the latest changed time through an entity query instead by loading the unchanged entity. I am however against the case where we will both do an entity query and entity load, therefore I am putting back the status to needs work.
What would be better is to have is a solution where we either retrieve the changed timestamp and the revision ID from the unchanged entity object or we retrieve them both through an entity query, but not a mixed solution which is more expensive in the case the entity has not been saved meanwhile
Comment #51
plachThe unchanged entity won't provide us the latest revision id (yet), however it will after #2784201: Track the latest_revision and latest_revision_translation_affected ID in the entity data table for revisionable entity types, and that will also save us the additional entity query. With this change, the timestamp check is only required when concurrently editing the same revision.
I am happy to get rid of these checks altogether, if #2824293: Inconsistent form build between initial form generation and the first ajax request can find a solution that works also in programmatic workflows, but meanwhile let's get this in to unblock progress in #2860097: Ensure that content translations can be moderated independently.
Comment #53
plachComment #55
plachThe testbot is failing due to an infrastructure issue, moving to NR since the latest changes were minor enough to be harmless.
Comment #56
plachHere's what's happening: #2919773: Fail to download drupal/coder because github.com/klausi/coder has gone missing
Comment #57
hchonov@plach, I am sorry, I think I've misunderstood the issue by thinking we are not preventing the use case where an entity has been saved in a new revision without any field value changes, which case is being solved by this issue as well.
The issue summary also states:
The change however affects not only drafts being saved, but normal entity revisions as well.
With the current change we will not be able to edit entities if they currently have forward revisions? If this is true, then this is BC break because forward revisions were possible before the concept of drafts came into core and additionally I do not really understand the need for drafts anymore. If we disallow saving an entity if it contains drafts, then what is the difference to locking the entity through e.g. using the content_lock module?
Comment #58
plachWe are always editing the latest revision, even when pending revisions are involved, so this should be the intended behavior AFAICT. Once the latest revision is loaded into the edit form (or instantiated via a programmatic workflow), validation will always succeed.
Comment #59
hchonovI don't think that this is true. We might have forward revisions for an entity, but when we visit entity_type/{entity}/edit the entity will be loaded in its default revision, which is not the latest revision if forward revisions are present.
Comment #60
plachConceptually we should alway be editing the latest revision. In core currently, if Content Moderation is not installed, the default revision is always the latest revision. If CM is enabled the latest revision is always loaded in the entity form. We also have an issue to make sure we always load the latest revision: #2865616: Add an option for EntityConverter to load the latest entity revision and fix all entity forms to use this option..
That being said, unless some custom code is attempting to edit and save a past revision, which is not supported, there should be no BC issue.
Comment #61
hchonovBut there might be custom cases, which are using forward revisions. By doing this change in the validatior we will break them by introducing logic which should be only available for entities using content moderation. I think we should add an another constraint with this new logic through a hook only for entities being handled by content moderation instead of introducing a BC break.
Comment #62
amateescu CreditAttribution: amateescu for Pfizer, Inc. commented@hchonov, editing a forward revision which is *not* the latest revision is a data-loss scenario (i.e. a critical bug), because the forward revision that is being edited will become the new latest revision, and the data from the previous latest revision will never see the light of day :)
So I see this change as one that is enforcing the correct *default* behavior for editing revisionable entities (i.e. the behavior that prevents data loss by default), and custom code which may want to allow editing a pending revision which is not also the latest revision can simply opt out of this default constraint provided by core.
Comment #64
hchonovI am aware of this problem, but it could be solved. We could flag the type of the revision e.g. default or draft and not relly on a draft being the latest. We could do then
entity_load(5, 'draft');
.I am sorry, but I am not entirely convinced by this change, because in our software we were thinking already of employing forward revisions.
One of the places where I wanted to experiment with is the autosave_form module where instead of writing to a custom storage I could create a forward revision something just like drafts, but I would need multiple autosave revisions by multiple users, which means the latest revision is only for a certain user and not the one that other users will be editing, because multiple users would have multiple forward autosave revisions. But if you do this change in core you do it only to solve the problem of content moderation and force any another approaches using forward revisions to adjust or to completely stop working. This change will make e.g. autosave revisions impossible.
If at some point we introduce the concept of revision type then I could imagine a lot of usages of forward revisions. Having said this I would like if this constraint only applies to default revisions, otherwise it is BC break from my POV.
Comment #65
amateescu CreditAttribution: amateescu for Pfizer, Inc. commented@hchonov the use case you describe in #64 with the autosave_form module is quite an advanced flow for an entity form and it will need exactly the same validation code that's being added by this patch (i.e. prevent saving if the user is not editing the latest revision), with the only difference that the latest revision check needs to be per user. And saying that it is impossible is kind of exaggerating a bit, modules that want to provide advanced editing workflows like that simply need to alter the EntityChanged constraint with one that is suited for their needs.
Since we don't have different revision types in core, I stand by what I said in #62 that this patch provides the correct behavior for current HEAD, which is to prevent data-loss by default.
Comment #66
hchonov@amateescu, no, it will not be possible for an user to save a default revision if the entity already has an autosave forward revision and we don't want to prevent others from editing just because there are autosave revisions, for this case we have the conflict module. Imagine an user goes to Node/edit and edits default revision 3, because of the changes on the page there are 2 autosave forward revisions created 4 and 5. Now another user goes to that same edit form and wants to save it, but that is impossible because of the new constraint logic.
Comment #67
amateescu CreditAttribution: amateescu for Pfizer, Inc. commentedYou keep saying that it's impossible for the autosave_form module to work after this change, do I really need to mention for the third time that it simply needs to replace the EntityChanged constraint added by core?
Comment #68
hchonovI don't understand why should custom and contrib adjust and replace core constraints, but not content moderation, which is the one currently needing that change? Why could we not consider a solution to first make the change isolated and only available to content moderation?
Comment #69
plach@hchonov:
Would it alleviate your concerns if we got back to comparing the latest revision id at the time the entity was instantiated with the current latest revision id? This would still allow us to detect concurrent edits without making assumptions on which revision was loaded in the edit form.
Comment #70
amateescu CreditAttribution: amateescu for Pfizer, Inc. commentedI've also answered this a few times already. Because core needs to provide a solution which prevents data loss by default. Yes, this problem is surfaced by Content Moderation in core, but so were other critical bugs which had to be fixed in their respective modules and not in Content Moderation. These are the most prominent examples that come to mind right now:
#2858431: Book storage and UI is not revision aware, changes to drafts leak into live site
#2856363: Path alias changes for draft revisions immediately leak into live site
#2858434: Menu changes from node form leak into live site when creating draft revision
Comment #71
amateescu CreditAttribution: amateescu for Pfizer, Inc. commented@plach, would that approach have any drawbacks compared to the latest patch?
Comment #72
plachWell, it would have to wait for #2784201: Track the latest_revision and latest_revision_translation_affected ID in the entity data table for revisionable entity types to be implemented cleanly, that is in a way that doesn't force programmatic workflows to perform additional steps (see #11).
Comment #73
xjmHm, I think this is at least major.
Comment #74
hchonov@amateescu, if it is needed to give an example, I could also refer to a major bug, that had impact on data integrity, but the fix of which was reverted just because we changed a behaviour and a contrib module broke. Then it took another half an year until we agreed on a solution without introducing a regression. This is where I've learned, that even if we wish and insist on a behaviour being correct or wrong we're not allowed to just change it without thinking on the possible consequences of doing this and forcing somebody to just adjust to the new behaviour.
I agree that this could be considered as data loss, but then only from the POV of content moderation. We could not declare the content moderation use case the default one just like this. We should not think of content moderation being the one and only one making use of forward revisions.
The problem we have is that we have different POVs how forward revisions could be used. From the perspective of content moderation it should not be possible to save an entity if it is loaded in the default revision but has forward revisions. From the perspective of a possible autosave solution it should be possible to save an entity loaded in the default revision even if it has forward (autosave) revisions. As there might be different use cases each requiring a different behaviour, why should we at all define a default behaviour that then has to be overwritten by each module using forward revisions?
Comment #75
hchonov@plach, I don't really understand this approach. Could you please describe it in more details?
Comment #76
plach@hchonov:
See #11.
Comment #77
hchonov@plach, #11 will also prevent an user to save a default entity revision if an autosave forward revision was created in the meanwhile by another user.
Unfortunately I see only two available options here:
No matter what the decision would be I think that we need a framework or release manager review for that.
Comment #78
plachI spoke with @hchonov in IRC, we agreed to leave the final word to a committer.
That's correct. @hchonov explained me that the reason why autosave is not concerned about concurrent edits is that it works with the conflict module out of the box.
IMO our ultimate goal should be to bring a (basic) conflict management solution in core and get rid of this validator, however I still think the solution proposed in #11 (comparing the latest revision at the time the entity was instantiated, with the one at validation time) would be a reasonable behavior without conflict management available.
Aside from that, we agreed we see three possible ways forward here:
The main outcome of our discussion was that neither of us is aware of an official definition of the "pending revision" concept and what their default behavior is supposed to be. This lack of clarity may end up affecting the contrib/custom space regardless of the solution we pick above, but we should try to minimize the impact of these choices. A discussion on this topic, unless it has already taken place, would help in avoiding the risk of making some changes in the current behavior, just to revert them or change them again, if we find other problems in the core entity system or in CM.
Comment #79
catchCatching up on this. One thing that hasn't been discussed is that workspaces theoretically at least allows you to have multiple latest-pending-revisions. Take the example of workspace 1 changing an image and workspace 2 adding a French translation on the same entity. This ends up with the same conflict resolution issues that autosave has to deal with.
However a first implementation of workspaces is likely to need the same constraint added here, restricting things one entity can only be in one draft/pending workspace at a time - until we have that conflict management in core at least.
I'm not sure where that leaves things on [i]this[/i] issue yet.
Comment #80
timmillwoodAnother way to think about workspaces is there is only ever one "latest pending revision", but it is possible to create a new revision from any other revision.
I think this issue is a good initial step, and we will need to revisit it once we have workspaces in core because workspaces may open up a number of other similar issues.
I'm also interested in how this issue relates to #2860097: Ensure that content translations can be moderated independently because I'm not sure which one actually needs fixing first.
Comment #81
Gábor Hojtsy@timmillwood: I think this one would be first given that the presence of this bug prevent the merge from happening cleanly in #2860097: Ensure that content translations can be moderated independently, right?
Comment #82
timmillwood@Gábor Hojtsy - I'd be nervous RTBCing this until we have a patch for #2860097: Ensure that content translations can be moderated independently confirming this is the right approach.
Although the issue is without the patch here there is a chance of data loss when working with pending revisions. So maybe we commit this patch, then in #2860097: Ensure that content translations can be moderated independently rewrite it if needed.
Comment #83
plachOne more reason to go back to #11 then :)
Comment #84
hchonovWe still need release manager review and framework manager review because of all the discussions we had about the BC break changes the patch is introducing.
Comment #85
xjm@catch is both a framework and a release manager, so #79 is a start at those things. :)
Comment #86
effulgentsia CreditAttribution: effulgentsia at Acquia commentedI read through the issue comments, and found #62, #68, and #78 particularly interesting, so thank you for those. In trying to think of a way to address all those concerns, what do you all think of following the single responsibility principle, by which I mean:
EntityChangedConstraintValidator
as it is in HEAD: its responsibility is solely to ensure that the entity itself (meaning its default revision) hasn't changed while the user has been editing.EntityNoNewPendingRevisionsConstraint
, to implement something along the lines of #11.EntityChanged
is also set. The latter is a bit of a weird coupling, but a semi-reasonable one, since allowing a save when there are new pending revisions means that the EntityChanged "problem" is punted to a later time when one of the revisions needs to become the default one.EntityNoLaterPendingRevisionsConstraint
, since that seems like yet another responsibility. However, per #83, I'm not sure we want such a constraint, or at least I don't think we need it to solve the scope of this issue, do we?EntityNoNewPendingRevisionsConstraint
the responsibility of conflict module, or something like it. Since it is only through the capability of such a module that the constraint can be relaxed while still satisfying the spirit ofEntityChanged
when later advancing the default revision.Comment #87
plach@timmillwood:
#2860097: Ensure that content translations can be moderated independently has now a patch that includes a fix for this, more or less #11. So I think we should got back to #11 and adjust that solution with whatever committers suggest to do to move forward.
@effulgentsia:
Thanks for your feedback! #86 seems like a reasonable way forward to me.
Yep, after experimenting heavily with #2860097: Ensure that content translations can be moderated independently, I think we would need to turn #51 to something similar to #11 anyway over there. Actually this was more or less my plan from the beginning (see #16), as I thought #51 would be less controversial and cleaner to implement as a first step :D
Comment #88
plach@effulgentsia:
I just realized that splitting the validator into two separate ones may not be possible because timestamp validation needs to be skipped in some cases, depending on the revisions being checked.
Comment #89
effulgentsia CreditAttribution: effulgentsia at Acquia commentedWhat's an example of that?
Comment #90
plachIn case of multilingual entities we may loading a translation in the edit form that's not the latest revision, because other translations may have been edited after that, so newer revisions exist. In that case it can happen that the loaded timestamp is lowed than the default revision timestamp, but this is not signalling a concurrent edit.
Comment #91
xjmCan we update the issue summary to describe what happens when the drafts are concurrently edited and what the result is, both with CM and HEAD? Specific STR and actual vs. expected behavior would help. Plus let's retitle the issue to describe the bug rather than a proposed change in an implementation. That information will help us weigh in on what direction we should go here.
Comment #92
plachComment #93
plachThis patch relies on #2926483: Add API methods for determining whether an entity object is the latest (translation-affecting) revision and restores an approach similar to #11. I'll update the issue summary ASAP. Attached you can find also a patch to review.
Comment #94
plachComment #95
plachSplit off the following fix that was originally included in #51: #2928778: Exception when trying to save a new revision after manually setting the original revision ID.
Comment #96
plachSmall improvement
Comment #97
plachRetitled and updated IS
Comment #98
plachStill working on the IS.
Comment #99
plachCompleted the IS with the description of the current status.
Comment #100
plachComment #101
plachHere's an updated patch based on the latest conversations I had with @amateescu, @catch, @hchonov and @timmillwood.
This is allowing the revision validation logic to be enabled per bundle, via a bundle info key. Content moderation enables that only for moderated bundles.
Comment #103
plachRerolled combined patch.
Comment #104
plachAdded test coverage for multilingual pending revision validation. This should be ready for review now.
Comment #105
hchonovWhile I understand that this method is added for convenience I am not sure that I like the idea of duplication. The method for retrieving the latest revision ID lives in the storage so such a method could live in the storage as well e.g. -
\Drupal\Core\Entity\RevisionableStorageInterface::isLatestRevision(RevisionableInterface $entity)
. One could argue that we have\Drupal\Core\Entity\RevisionableInterface::isDefaultRevision()
, which lives on the entity class but it is not entirely correct because both methods should be actually performing on call entity queries instead on entity load, which is being done correctly by::isLatestRevision()
, but not yet by::isDefaultRevision()
.Same as the previous remark - I think this method belongs in the storage.
This kind of reminds me of the rule of three, according to which a code should be generalised as soon as there are three places where it is used - otherwise it is a premature refactoring which is not necessary correct. As content moderation is currently the only one example needing such a logic I still think that it should be introducing its own constraint instead of inserting this logic in the default core constraint and making it much more complex - there is a critical that is not yet committed but currently is making the logic already harder to understand. In our call we've decided to postpone to revision type field because we don't have enough examples requiring it and I think the same applies for this constraint as well.
1. & 2. are actually not that relevant. 3. is the important point.
Comment #106
Wim LeersHigh-level review from API-First Initiative POV.
Comment #107
tedbowSince we can't test 2 open tabs this
content_moderation_test_form_node_moderated_content_edit_form_alter
to change the revision id stored in the form.Would it closer to actual conditions to:
$this->drupalGet($edit_path);
$node->save()
$revision_id = $node_storage->getLatestRevisionId($node->id());
$this->submitForm()
to submit the form that was created in thedrupalGet()
instead of$this->drupalPostForm()
Then we won't need the
content_moderation_test
module at all right?Comment #108
plachI'm sorry but I have to disagree. In this case we have a strong use case in core, so the situation is completely different. Additionally this solution is following the approach we discussed during the call: we decided to go with a logic available to any contrib/custom module, without needing to depend on Content Moderation, but activated by Content Moderation. At least this was my understanding and this is what the patch is doing. I tried to make activation as little intrusive as possible, without introducing a full-fledged API exactly to allow to iterate on this approach. We can get rid of that and make CM extend the revision validator to apply it only to CM-enabled entities, but doing more than that would mean going too far IMO. The fact that we are willing to support different ways to use pending revisions does not mean that the way pending revision are used in CM is a minor use case, in fact I'd say it's quite the opposite. I think this a sensible default behavior and forcing code willing to use the same logic to depend on CM would be an unneeded requirement IMO.
Comment #109
plachComment #111
effulgentsia CreditAttribution: effulgentsia at Acquia commentedEven the ".review" patch in #104 no longer applies, due to a conflict with #2837022: Concurrently editing two translations of a node may result in data loss for non-translatable fields. An updated patch here would help with reviewing.
Comment #112
plachRerolled
Comment #113
Wim LeersThe IS says
and points to a bunch of comments, but doesn't say which solution got consensus. Further down in the IS: — but we're now ~30 comments further. Do we have consensus now?And which of the three possible solutions does the current patch implement? AFAICT it's solution 3.
Nit: s/an/a/
Note that the latest direction in #2784201: Track the latest_revision and latest_revision_translation_affected ID in the entity data table for revisionable entity types actually is NOT adding a field anymore. Which would make these comments inaccurate. Perhaps better to simply state "@todo revise when lands"?
Nit: s/array&/array &/
Nit: s/id/ID/
These if-conditions do not match. This is super confusing.
AFAIK it's not valid to do
instanceof RevisionableInterface
, one must do$entity_type->isRevisionable()
?Also, shouldn't all three be triggered only when the bundle has this new constraint?
Why can't we store this in form state storage?
Nit: s/identifier/ID/
This sentence is hard to parse.
An
@see \Drupal\Core\Entity\EntityChangedInterface
could help.This is confusing. If
$this->checkChangedTime
is set, then we'll overwrite$valid
. Should they be AND'ed or OR'd? If not, let's do an if/else.The function name says that it's going to validate changed revisions.
The docblock says it's going to do validation (of what?) based on revision changes.
It's similar, but not the same.
Nit: s/$valid/$is_valid/
===
is preferred.This is only enabled by Content Moderation for now. It's fine that this logic lives in core, outside
content_moderation
, because as the IS says: that allows other modules to reuse this behavior.But I don't understand why we need to update the existing constraint validator. Especially if we're adding a new constraint, which is what is happening here.
Such a new constraint could also get a much clearer name:
SequentialPendingRevisionConstraint(Validator)
.Comment #114
plachAfter some additional manual testing in #2860097: Ensure that content translations can be moderated independently, I verified that this no longer triggers a validation error when dealing with pending multilingual revisions, due to the revision merge strategy introduced in #2924724: Add an API to create a new revision correctly handling multilingual pending revisions. This allowed me to implement the solution @effulgentsia suggested in #86: implementing two separate constraint validators. He also suggested in Slack a very neat way to register validators only for specific bundles, which allowed me to get rid of all that bundle info crap. This should now be way cleaner and address @hchonov's concerns, while at the same time allow this logic to be reused without depending on Content Moderation.
@Wim Leers, #113:
6: Because if the form is not cached it will be retrieved again on submit. We need to make sure that we get the latest revision ID when the form was originally displayed. This is the same strategy we are using for the changed time stamp.
12: Not with revision ids ;)
13: See above for an explanation on why we were not introducing a separate constraint/validator. Great name suggestion, btw, I slightly changed it because the validation itself does not differentiate between default and pending revisions: it would prevent concurrent editing even if the changed timestamp constraint were disabled, as long as new revisions are created on every save.
Everything else is fixed or did not apply anymore.
The interdiff is completely useless ;)
Comment #115
plachReviewing my own code:
Outdated variable name.
These are still mentioning the new field.
Comment #116
plachUpdated the IS.
Comment #119
Wim LeersJust nits, really — this patch is much clearer! I don't feel qualified to RTBC this patch though.
Description, label, name don't match.
Mismatch.
This could use a very brief description of what it actually does. Because it touches many abstraction layers.
That's not the validator class it tests.
Why an array, if 100% of them contain 2 values? Then why not do a
$from, $to
or$before, $after
or something like that?Comment #120
plachThis should fix test failures and address #119.
Comment #121
timmillwoodI must admit I don't understand all the logic, but as far as I understand looks fine to me.
Comment #122
amateescu CreditAttribution: amateescu for Pfizer, Inc. commentedCalling this hidden field a widget is a bit misleading, how about
addLatestRevisionIdFormField()
?I think the description needs to go on the next line, after @file :)
No need for
{@inheritdoc}
in hook implementations.You could use the interface here.
No need for
sprintf()
,'node/' . $node->id() . '/edit'
works just fine and it's more clear :)No need to use
t()
in tests.I'm not sure this is valid PHP 5 syntax, but why don't we copy the message text in the assertion? That's what we usually do in other places..
Anything wrong with using 0, 1 and 2 as the constant values? :)
Is this really needed in the base test class?
Comment #123
plachThanks!
1-7: Fixed
8: Those wouldn't allow me to do fancy bitwise things in
::doEntitySave()
;)9: Well,
entity_test.translation
is a generic state key supported inentity_test
, so I thought it would be more useful to make it an "API".Comment #124
amateescu CreditAttribution: amateescu for Pfizer, Inc. commentedMissed a spot :)
Comment #125
hchonovBasically I think that it is fine now, but I would rather move the CM logic to the CM module.
If we don't declare this as the default behavior, then I am not sure if this code should be placed here instead in a hook_form_alter implementation in the content moderation module. Then we could use an entity builder to add the value to the entity when the form is submitted.
As the new constraint is only added by content moderation probably it should be placed in the content moderation module instead in core?
If this test remains in core and is not moved to the content moderation module, which I think would be better until we have defined the way to go, then I think that we should move the test to
\Drupal\KernelTests\Core\Entity\EntityValidationTest
, where we already have a test for concurrent editing -::testEntityChangedConstraintOnConcurrentMultilingualEditing()
.Side note: as actually only CM is the one that currently needs those changes then probably the right component would be "content moderation" instead the "entity system"?
Comment #126
matsbla CreditAttribution: matsbla commentedI tested patch in #123
When I apply that patch I'm not able to concurrently add two drafts in two different languages. I'm also not able to concurrently edit two translations in two different languages even though I only edit translatable fields. Is that how it is suppose to work?
I find it a little bit strange that I'm constrained to work concurrently in two different language version of a node.
Comment #127
plach@hchonov:
1/2: I believe in #2940575: Document the scope and purpose of pending revisions we clarified that sequential revision creation is not CM-specific, so it should be ok for it to live in the core Entity system.
3: Well,
EntityValidationTest
does not deal with revisions, so merging the tests would force to add a lot of logic in the setup phase. On top of thatSequentialEntityRevisionCreationValidationTest
focuses on the sequential revision logic, the multilingual logic is not the main use case under test.@matsbla:
I will check whether it is possible to allow concurrent editing of different translations.
Comment #128
plachThis is a data integrity issue, so it should be considered critical.
Comment #129
MarkedGoose CreditAttribution: MarkedGoose commented@plach:
Any updates on the data integrity issue? We have the same problem as matsbla.
Comment #133
amateescu CreditAttribution: amateescu for Pfizer, Inc. commentedJust closed a duplicate of this: #3091890: Publishing multiple translations of the same node at the same time leads to data loss
Comment #136
daffie CreditAttribution: daffie commentedPatch fails to apply.
Comment #137
anmolgoyal74 CreditAttribution: anmolgoyal74 at OpenSense Labs for DrupalFit commentedRe-rolled for 9.2.x
Comment #138
anmolgoyal74 CreditAttribution: anmolgoyal74 at OpenSense Labs for DrupalFit commentedRemoved the "core" key from
content_moderation_test.info.yml
file.Comment #140
nikitagupta CreditAttribution: nikitagupta at Srijan | A Material+ Company for Drupal India Association commentedComment #141
jlatorre CreditAttribution: jlatorre commentedAre we seriously thinking about blocking the edition as a long term solution for this problem? There's the content_lock module for that. It cannot be accepted as a core solution.
My company is composed of multiple translators working on the same nodes, sometime at the same time, and I cannot imagine to defend Drupal solution with that kind of behavior.
IMHO It should be ok to edit different language on the same node with no problem.
Comment #142
daffie CreditAttribution: daffie commentedIt is great the maker of this show off his/her programming skills by using hexadecimal numbers, only it does not make the test easy to understand. Can we remove the hexadecimal numbers and adhere to the KISS principle.
Could we change this to testing if one of the violations is the one we expect.
Would it be possible to add a second test like this with a multilingual entity?
Comment #143
nikitagupta CreditAttribution: nikitagupta at Srijan | A Material+ Company for Drupal India Association commentedAddressed #142.1
can you please more elaborate on #142.2?
for #142.3, multilingual entity translation is not dependent on languages, so we can concurrently edit the entity. in my opinion, this is the ideal scenario. I believe that we didn't need a test case for the multilingual entity.
#141 mentioned the same I also believe that we can concurrently edit the same node in a different language
Comment #144
daffie CreditAttribution: daffie commentedCould we change it to:
Comment #145
nikitagupta CreditAttribution: nikitagupta at Srijan | A Material+ Company for Drupal India Association commentedAddressed #144.1,2
Comment #146
daffie CreditAttribution: daffie commentedPlease add the letter "s" before "equential".
Comment #147
ankithashettyFixed the custom command errors in #145 patch. Thanks!
Comment #148
daffie CreditAttribution: daffie commentedAll my points have been addressed.
All code changes look good to me.
For me it is RTBC.
Comment #149
larowlanAre we sure we want to add this in the base content entity form, and not form-alter it in from Content Moderation?
This can be modified by a content editor using the browser debugging tools.
Is there a way we can do this in a more tamper-proof fashion?
As the only consumer of this constraint, shouldn't it live in the Content Moderation module?
Using a form-based solution for setting this prevents the same validation being applied to REST/JsonAPI based updates where CM is enabled right?
Comment #150
Sam152 CreditAttribution: Sam152 as a volunteer and at PreviousNext commentedI'm a little rusty on this stuff, but a few comments:
I think @larowlan is right, this can be tampered, where
#type => value
cannot.Is this actually the right related issue? This is storing the latest ID at a point in time, not related to the persisted latest ID in the data tables?
Kind of seems like https://www.drupal.org/project/drupal/issues/2896474 is more on the money here.
To address @larowlan's point, I think @hchonov and @plach already discussed this as a feature of core vs CM. I've added my 2c to this discussion a bunch of times, but I think until something like #3023194: [PP-2] Add parallel revisioning support has been implemented, core should assume sequential revisioning when implementing features, API additions and bugfixes for modules like CM, so my preference would be to apply this validator in core, but given the intersection of this issue with entity API, I don't have final say on this and I no longer feel that strongly about it.
Shame you can't open two sessions here. I wonder if you could take a snapshot of the page and restore it somehow? Not a blocker in any case.
Should updating a workflow invalidate these caches, instead of having them invalidated in a test?
Neat that this can be tested in core, without CM.
Comment #151
Berdir1. It has to be a value that was sent from the client, that's the point. #type value is internal and would just be updated to reflect the current value. It has to be sent from the client and be the original value. That's how the hidden changed field works too:
Comment #152
Sam152 CreditAttribution: Sam152 as a volunteer and at PreviousNext commentedMh, my mistake re: 1, I thought
#type => value
behaved the same as hidden, but were stored in the form state and were tamper proof.Comment #154
pmaguniaWe are experiencing a variation of this issue referenced by one of the Related Issues but the patch did not resolve the issue.
I had to modify the patch by removing all changes in
tests
directories but that shouldn’t have effected the efficacy of the patch (We were on Drupal 9.2).The defect we were facing was saving simultaneous translations with content moderation turned on resulted in data loss.
When trying out the patch no messages were displayed to the user. It just failed silently.
Comment #156
stephencamilo CreditAttribution: stephencamilo as a volunteer commentedComment #157
matsbla CreditAttribution: matsbla commentedComment #163
acbramley CreditAttribution: acbramley at PreviousNext commentedRerolled #147 on to 11.x (thankfully not too painful), setting to NW as no feedback has been addressed. Downgrading from critical also.
EDIT: Should have mentioned that the steps in the IS still reproduce this on 11.x.