Problem/Motivation

Since the changes for #3164669: Private message threads collapsed when they shouldn't be it seems that the mapper may return a thread does not contain *all* users requested. I was testing the latest dev version and noticed how my test user would end up adding a message sent to another user to a thread that only contained the test user itself (this obviously is a glitch, although I can imagine it happening when a user is deleted).

Steps to reproduce

  1. Start with a clean install (just to be sure there are no threads in the system yet)
  2. Create three users; alice, bob and charlie. Make sure they have the appropriate permissions, i.e. use private message system and view user information
  3. Log in as alice
  4. Go and create a private message to bob
  5. See how everything works as expected
  6. Go and create another private message, but select both bob and charlie as recipients
  7. Notice how your new message ends up as a new message in the thread you shared with bob, and charlie is nowhere to be found

Expected: a new thread was created for the combination alice, bob and charlie.

Proposed resolution

TBD. I would hope it is possible to formulate a SQL query to select a thread with all the passed users but *only* the passed users. Although I think that started the whole problem in the first place, so maybe we should just add more conditions on the checks we use when selecting a thread (we're only checking if the there users are in the set of relevant users, but not whether they are *all* in the thread, AFAICT).

Remaining tasks

  • Agree on approach/create merge request
  • Extend user test
  • Review

User interface changes

None.

API changes

None.

Data model changes

None.

Command icon Show commands

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

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

Support from Acquia helps fund testing for Drupal Acquia logo

Comments

eelkeblok created an issue. See original summary.

artem_sylchuk’s picture

(we're only checking if the there users are in the set of relevant users, but not whether they are *all* in the thread, AFACIT).

No, the PrivateMessageMapper::getThreadIdForMembers first selects all the threads where all given users are presents and then iterates over them to find the first thread where there is no more users except the given ones. I quickly checked the code and didn't find obvious reasons for the described issue.

But in fact the forcing thread to be unique for the given set of users brings so many problems so I'm not sure what the purpose of it in general.
https://www.drupal.org/project/private_message/issues/2942602
https://www.drupal.org/project/private_message/issues/3158310#comment-14...

Plus for example, as you mentioned the user deleted from the site may cause multiple thread duplications.
So I'd like to remove that restriction and not force thread uniqueness.

Any suggestions, thoughts, concerns will be appreciated.

eelkeblok’s picture

TBH, I don't fully understand that either. I suppose it is to get to a model similar to Whatsapp and such apps where you have a unique conversation per other user or group of users. I am more familiar with PM models where it is more like email, where you create a message with a subject and might have multiple such threads, with the same or different users. Do note that I think a subject will be mandatory, in that case.

[I did understand the logic, but what I think is happening is that the logic fails to check if *all* users are part of the found conversation.]

eelkeblok’s picture

WRT to the actual issue, I'll probably be doing a deeper dive this week to try and understand it better. Will try to get some better steps to reproduce too.

eelkeblok’s picture

I did a deeper dive. Although the code indeed claims to find all threads where all given users are present, in fact it returns all threads any of the users are involved in*. It then proceeds to filter out threads that have other users too, but does so under the assumption that the threads the earlier query turned up at least has all of the users we are looking for. This is a wrong assumption. To make it easier to follow, this is the relevant code:

/**
   * {@inheritdoc}
   */
  public function getThreadIdForMembers(array $members) {
    $uids = [];
    foreach ($members as $member) {
      $uids[$member->id()] = $member->id();
    }

    // Select threads common for the given members.
    $threads_ids = $this->database->select('private_message_thread__members', 'pmt')
      ->fields('pmt', ['entity_id'])
      ->condition('members_target_id', $uids, 'IN')
      ->groupBy('entity_id')
      ->execute()
      ->fetchCol();
    // Exclude threads with other participants.
    foreach ($threads_ids as $thread_id) {
      $query = $this->database->select('private_message_thread__members', 'pmt')
        ->condition('members_target_id', $uids, 'NOT IN')
        ->condition('entity_id', $thread_id);
      if ($query->countQuery()->execute()->fetchField() == 0) {
        return $thread_id;
      }
    }
    return FALSE;
  }

I think the mistake is basically in the line

->condition('members_target_id', $uids, 'IN')

This effectively serves as an OR, not an AND, for all the user IDs in the $uids array. In many cases, this problem is then fixed by actually filtering out the threads with other users, but it may still turn up threads that do not have all users we are looking for.

I will push a fix shortly, that solves my specific test case. I'll also try and come up with a test that actually demonstrates the problem.

*: In my case, this is nicely demonstrated in the debugger, where the query comes up with dozens of threads; one of the users is a community manager who has threads with a lot of other users; just one with the second user involved in the lookup, though. Because there happens to be also a "corrupt" thread where she is the only member, and that thread happens to be hit on first in the filtering loop, that is what the method produces instead of the actual common thread between the two users involved.

eelkeblok’s picture

Issue summary: View changes

Adding steps to reproduce. Note that this bug only exists in the current dev version, not in beta16.

eelkeblok’s picture

Branch tests-only contains a test that basically replicates the steps to reproduce. Unfortunately, the existing thread members test surfaced that my first stab at the solution doesn't cut it either.

eelkeblok’s picture

Basically, I now reversed the problem (the query is now basically doing what was inteded before, so it still needs the filtering to not find threads that have extra members). I'll restore the "filter" code.

artem_sylchuk’s picture

Hi @eelkeblok, thanks for the instructions, now I see why it is happening.
Actually #3154558: 1116 Too many tables; MariaDB can only use 61 tables in a join was the issue where the change was introduced and it had reasons to replace the working solution with joins to something else.
However it was relying on the count of the members in the thread to identify the correct one and it was wrong too (see #3164669: Private message threads collapsed when they shouldn't be).

Looks like there is no good way to do that in SQL so probably we can do that in PHP.
Attaching my quick attempt as a patch. Not sure about the performance of this solution, it may cause problems if there a lot of threads with many common users.

eelkeblok’s picture

See my branch, I think with the filtering code restored it will be a somewhat efficient solution. I'll create an MR to make it more visible.

eelkeblok’s picture

Your patch looks OK too. I guess the test I wrote in https://git.drupalcode.org/project/private_message/-/merge_requests/9 should also work for it (you could create a branch for your patch and merge in the tests-only branch, that should let the test verify whether your solution is valid).

I think the groupBy might still be relevant, as depending on the number of users the query would now return duplicates for common threads. Question is what has more overhead. My solution (in MR 8) is more SQL-centric, but the basis question remains the same; more basic SQL vs. more processing in PHP.

eelkeblok’s picture

Status: Active » Needs review

Setting to Needs review. Quick summary:

  • MR 8 holds my solution, which is based on subqueries. It includes the test from...
  • MR 9, contains just an extra test, demonstrating the issue
  • Patch #9, would need combining with the test to make sure it is valid. Makes the query less complicated, at the cost of more PHP processing. Could possibly be improved by re-introducing the groupBy on the thread ID, although it is hard to say whether that would slow the query down further than it improves the PHP processing speed because it would need to check less results
eelkeblok’s picture

Priority: Normal » Major

Status: Needs review » Needs work
dinazaur’s picture

Status: Needs work » Reviewed & tested by the community

Hello,
I've tried to reproduce error from the 3154558 issue, since approach provided by @eelkeblok uses SQL. I've written the test that generates "treads' with 63+ users few times in a row with different combinations of users, and the test passed. Don't want to commit it because it will only overload Drupal CI without any reasonable testability.

artem_sylchuk’s picture

Status: Reviewed & tested by the community » Fixed
Issue tags: +LutskCodeSprint2021

Thanks to everyone, I think it is the main blocker for releasing a new beta, so I'm happy to merge it.

artem_sylchuk’s picture

During the code sprint @dinazaur had a brilliant idea to create the members hash field in the thread entity. It will completely eliminate the problem.
However, it will require to update all existing threads.
I think it worth to keep it noted.

Status: Fixed » Closed (fixed)

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