Skip to content

RAS-1867 Re-implement My Messages to make query more performant#472

Open
LJBabbage wants to merge 5 commits into
mainfrom
refactor-internal-threads-queries
Open

RAS-1867 Re-implement My Messages to make query more performant#472
LJBabbage wants to merge 5 commits into
mainfrom
refactor-internal-threads-queries

Conversation

@LJBabbage
Copy link
Copy Markdown
Contributor

@LJBabbage LJBabbage commented May 18, 2026

What and why?

This PR improves performance for internal thread retrieval (My Messages), removes unused functionality, rationalises the query logic, and adds coverage for previously untested paths.

Performance

Before

When the My Messages tab was selected, we queried the secure_message table first and performed operations such as determining the latest message before joining to the status table, without index.

This resulted in unnecessary scanning and processing of large datasets before pagination was applied, causing poor performance for users with a high message volume.

Now

The query flow has been reversed for internal users using my_messages:

  • We query the status table first using actor-based filters
  • We then join to secure_message using the indexed msg_id
  • Latest message logic is then applied
  • LIMIT and OFFSET are used to paginate efficiently at the database level

Additional changes

  • Consolidated the 4 internal thread SQL queries into a single query builder to avoid future misalignment between count and result queries
  • Retained the existing pattern of:
    • querying total count first
    • then retrieving the paginated results
  • Left external thread retrieval unchanged to minimise risk in this PR (though it could be optimised similarly in future)
  • Removed redundant pagination code
  • Removed unused search criteria and associated tests
  • Extended test coverage for previously untested logic paths

How to test

Deploy this branch and test a variety of thread and filtering scenarios. Suggested checks:

  1. Create messages across 2 different surveys and confirm they appear in the correct inbox
  2. Verify that:
  • a new respondent message appears in the inbox
  • the thread is shown as unread/bold
  • once replied to, it moves into My Messages
  1. Verify messages sent initially via reporting units appear in My Messages
  2. Confirm business reference searching still works
  3. Confirm closed message filtering still works
  4. Verify pagination behaviour
  5. Regression test any additional thread filtering combinations

Jira

@LJBabbage LJBabbage requested a review from a team as a code owner May 18, 2026 14:31

for message in paginated_list.items:
msg = message.serialize(user, body_summary=body_summary)
msg["_links"] = {"self": {"href": f"{host_url}{MESSAGE_BY_ID_ENDPOINT}/{msg['msg_id']}"}}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't used so removed

"self": {"href": f"{host_url}{endpoint}?{string_query_args}&page={message_args.page}"},
}

if paginated_list.has_next:
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As above this isn't used

"subject": self.subject,
"body": self.body[:100] if body_summary else self.body,
"thread_id": self.thread_id,
"case_id": self.case_id,
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As above


@staticmethod
def thread_count_by_survey(request_args, user):
"""Count users threads for a specific survey"""
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

merged with results to stop drift in the future and make more maintainable

self.assertEqual(len(response), 1)

self.assertEqual(len(response.items), 1)
def test_thread_list_no_message(self):
Copy link
Copy Markdown
Contributor Author

@LJBabbage LJBabbage May 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not related to this PR, but added line coverage

"from_internal": self.from_internal,
"sent_date": str(self.sent_at),
"read_date": str(self.read_at),
"_links": "",
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again not needed

)

logger.info("Successfully retrieved threads for user", user_uuid=g.user.user_uuid)
messages, links = process_paginated_list(result, request.host_url, g.user, message_args, THREAD_LIST_ENDPOINT)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've split the internal and external respondent process up, so this is done else where

@@ -1,8 +0,0 @@
from behave import given, when
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not needed

@@ -1,8 +0,0 @@
from behave import given, when
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not needed

Then the thread count is '0' threads


Scenario: Respondent sends messages to different ce, internal user filters by ce
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't offer the ability to filter on these criteria https://github.com/ONSdigital/response-operations-ui/blob/main/response_operations_ui/controllers/message_controllers.py#L129 and never have so I removed it in the code and naturally the test here. Frontstage only allows filtering is_closed

| specific user |
| group |

Scenario Outline: There are 3 conversations between an internal user and respondent each with 2 messages ,
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As above we don't have this functionality in rops

if messages:
messages = add_users_and_business_details(messages)
return jsonify({"messages": messages, "_links": links})
return jsonify({"messages": messages})
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_links isn't used at all in rops or frontstage, so it and all code associated with it can be removed. Rops doesn't even mock it in tests, frontstage does, which has now been removed without any effect.

@anwilkie
Copy link
Copy Markdown
Contributor

/deploy wilkia

@ras-rm-pr-bot
Copy link
Copy Markdown
Collaborator

Deploying to dev cluster with following parameters:

  • namespace: wilkia

  • tag: pr-472

  • configBranch: main

  • paramKey: ``

  • paramValue: ``

args = get_args(new_respondent_conversations=True)
response = Retriever.retrieve_thread_list(self.user_internal, args)
self.assertEqual(len(response), 1)
self.assertEqual(response[0]["labels"], ["INBOX", "UNREAD"])
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this also include the "SENT" label?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sent is only associated with a ROPS user i believe, the assert is also only proving what I set above is coming back. The irony is that the system only cares about UNREAD (to highlight) and not the others, but I didn't want to unpick it at this juncture

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When i tested it SENT is getting attached to a respondent and a Rops user but I wasn't sure how the labels were actually being used. I did think that maybe they weren't all used when they returned to Rops/frontstage so that makes sense if it is just UNREAD. It's working the same on the PR as on main so no issues with it.

Respondent creates a new thread:
SENT <respondent_uuid>
INBOX GROUP
UNREAD GROUP 

Rops creates a new thread:
SENT <rops_user_uuid>
INBOX <respondent_uuid>
UNREAD <respondent_uuid>

Comment thread secure_message/repository/retriever.py Outdated
"""

PAGINATION_SQL = """
ORDER BY sm.msg_id, sm.id DESC
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The my messages tab is displaying the messages in sent_at DESC in the current implementation

Copy link
Copy Markdown
Contributor

@matthew-robinson-ons matthew-robinson-ons left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested multiple message scenarios. Labels are consistently displayed as before, attaching to the correct actor. Messages move in and out of my messages as before based on which internal user replied most recently. It's hard to comment on the performance of the queries without having anything to compare it against, we only really have that information in PROD currently.

The pagination object is displaying the messages on the page in a different order, previously it was showing the most recent (last sent) messages first.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants