Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

LUI-198: Optimize patient dashboard loading by implementing pagination for observations #209

Open
wants to merge 16 commits into
base: master
Choose a base branch
from

Conversation

ODORA0
Copy link
Member

@ODORA0 ODORA0 commented Nov 13, 2024

Add pagination for patient observations in dashboard

Description:

  • Added pagination to optimize patient observations loading in the dashboard
  • Implemented configurable page size through global properties (default: 50)
  • Added pagination metadata to support UI implementation

Previously, the dashboard would load all patient observations at once, causing
performance issues for patients with many observations. This change:

  • Loads observations in configurable page sizes
  • Allows navigation through URL parameters (?page=0&pageSize=50)
  • Improves dashboard loading performance

Testing:

  • Test with patients having large numbers of observations

Ticket: https://openmrs.atlassian.net/browse/LUI-198

@ODORA0 ODORA0 marked this pull request as ready for review November 21, 2024 10:11
@ODORA0 ODORA0 requested review from dkayiwa and ebambo November 25, 2024 04:57
@dkayiwa
Copy link
Member

dkayiwa commented Nov 25, 2024

Did you get a chance to take a look at this? https://openmrs.atlassian.net/wiki/spaces/docs/pages/25477199/Pull+Request+Tips

@ODORA0 ODORA0 changed the title (Enhancement) Optimize patient dashboard loading by implementing pagination for observations LUI-198: Optimize patient dashboard loading by implementing pagination for observations Nov 26, 2024
@ODORA0
Copy link
Member Author

ODORA0 commented Nov 26, 2024

@dkayiwa please review again

@dkayiwa
Copy link
Member

dkayiwa commented Nov 26, 2024

Can we also include the ticket id in the commit message?

Integer page = getPageParameter(request);
Integer pageSize = getPageSizeParameter(request, as);

// Get all observations
Copy link
Member

Choose a reason for hiding this comment

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

For each time that one moves through the pages, you are still fetching and loading all the patient's observations in memory. My impression was that you would load in memory only those observations for the current page.

@ODORA0 ODORA0 requested a review from dkayiwa December 2, 2024 05:34
boolean includeVoided = false;

// Get observations for the current page
List<Obs> paginatedObs = Context.getObsService().getObservations(persons, encounters, questions,
Copy link
Member

Choose a reason for hiding this comment

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

Though the comment says get observations for the current page, i do not seen anything in the getObservations method call that does so. Was it just a typo?

Copy link
Member Author

Choose a reason for hiding this comment

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

Ooh yeah, my bad the getObservations() method doesn't have parameters for offset/limit pagination, let me remove that comment

Copy link
Member

Choose a reason for hiding this comment

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

So then, shall we then have any dashboard loading optimisation if the method loads all of them in memory?

Copy link
Member Author

Choose a reason for hiding this comment

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

Okay, I critically looked at the ObsService interface, didn't notice that it lacks proper pagination parameters (offset/limit). Maybe this should be done as a DAO level implementation so we can have a DB level pagination support, but I would think of doing it this way as well i.e. we can use an existing API features like mostRecentN to limit initial load like so

Suggested change
List<Obs> paginatedObs = Context.getObsService().getObservations(persons, encounters, questions,
List<Obs> paginatedObs = Context.getObsService().getObservations(
persons,
null,
null,
null,
null,
null,
Collections.singletonList("obsDatetime desc"),
pageSize, // mostRecentN - only fetch what we need
null,
null,
null,
false
);

or using date filtering

Suggested change
List<Obs> paginatedObs = Context.getObsService().getObservations(persons, encounters, questions,
Calendar cal = Calendar.getInstance();
cal.add(Calendar.DAY_OF_MONTH, -30);
Date thirtyDaysAgo = cal.getTime();
List<Obs> recentObs = Context.getObsService().getObservations(
persons,
null,
null,
null,
null,
null,
sort,
null,
null,
thirtyDaysAgo,
new Date(),
false
);

Your thoughts @dkayiwa

Copy link
Member

Choose a reason for hiding this comment

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

Limiting the number of loaded obs with mostRecentN makes lots of sense to me. For as long as your use case allows it. That is, as long as you are aware that it behaves differently from page size in the sense that the rest of the obs will never be fetched.

Copy link
Member Author

Choose a reason for hiding this comment

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

Okay!
@eudson Any thoughts on this, is it acceptable to only show the most recent observations?

model.put("patientEncounters", Context.getEncounterService().getEncountersByPatient(p));
model.put(
"patientEncounters",
Context.getEncounterService().getEncounters(p.getPatientIdentifier().getIdentifier(), 0, 100,
Copy link

Choose a reason for hiding this comment

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

@ODORA0 why are you hardcoding the start(0) and length(100) ? My understanding of these two variables is that they can help paginate the results, the start being the page and the length the number of records per page.

Copy link
Member Author

Choose a reason for hiding this comment

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

My bad here, I might have forgotten to make the change but changed so that we using the proper pagination parameters using the configured page size in the maximumNumberToShow GP or default to only 50 when empty and allow users to paginate through encounters.

Integer totalCount = Context.getObsService().getObservationCount(persons, encounters, questions,
answers, personTypes, locations, obsGroupId, fromDate, toDate, includeVoided);
List<Obs> paginatedObs = Context.getObsService().getObservations(persons, null, null, null, null,
null, Collections.singletonList("obsDatetime desc"), pageSize,
Copy link

Choose a reason for hiding this comment

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

Can we apply the same concept here?

Copy link
Member Author

Choose a reason for hiding this comment

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

So I am using the using proper ObsService method signature for observations and using mostRecentN for pagination with the pageSize parameter, using same pageSize from global property or default which is set to 50 thus showing most recent observations up to pageSize limit

@ODORA0 ODORA0 requested a review from dkayiwa December 5, 2024 06:36

private Integer getPageParameter(HttpServletRequest request) {
try {
return Integer.parseInt(request.getParameter("page"));
Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Member

Choose a reason for hiding this comment

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

Basing on the user interface screen that this controller serves, could you remind me on how this parameter is changed to a different value?

Copy link
Member Author

Choose a reason for hiding this comment

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

So the method retrieves the page parameter from the HTTP request, and if it'ss missing or is invalid like non numeric it will default to 0. The page value calculates which subset of data (encounters or observations) to fetch patientDashboard?page=2

Copy link
Member

@dkayiwa dkayiwa Dec 5, 2024

Choose a reason for hiding this comment

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

What i mean is, how is this parameter changed in the HTTP request, by the user interface?

Copy link
Member Author

Choose a reason for hiding this comment

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

With the dropdown selector

Copy link
Member

Choose a reason for hiding this comment

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

Can you share a screenshot of that entire page?

Copy link
Member Author

Choose a reason for hiding this comment

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

image

Copy link
Member

Choose a reason for hiding this comment

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

Awesome!!! 👍

Copy link
Member

Choose a reason for hiding this comment

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

Did you check the link about naming conventions?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, I have changed that, please review

return Integer.parseInt(pageSizeParam);
}

String globalPageSize = as.getGlobalProperty("dashboard.encounters.maximumNumberToShow");
Copy link
Member

Choose a reason for hiding this comment

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

Is this pull request about encounters or observations?

Copy link
Member

Choose a reason for hiding this comment

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

And just in case you find this helpful: https://openmrs.atlassian.net/browse/LUI-188

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, we thought we could align encounters as well to limit it to based max numbers to show or default to the fallback property 50. cc @eudson
Encounters fetched are now limited to the value of the GP

Copy link
Member

Choose a reason for hiding this comment

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

The global property that you are using here is specifically for encounters. You can take a look at the ticket link that i shared above to get some brief historical background about what i am driving at. 😊

Copy link
Member Author

Choose a reason for hiding this comment

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

Okay so that solution effectively decouples the configuration for the Form Entry tab and the Encounters tab which tackles both usability and configurability needs without disrupting existing any setups. Are you trying to say we employ the same thing but for the patientEncounters jsp, LOL, I am having a brain fart right now with looking at old tech am not as familiar with

Copy link
Member

Choose a reason for hiding this comment

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

One should be able to set different values for encounters and observations.

Copy link
Member Author

Choose a reason for hiding this comment

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

This involves creating a different GP for Obs as in maximumNumberObservationsToShow? Please advise

Copy link
Member

Choose a reason for hiding this comment

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

Yes you need to create a separate global property for it.

@ODORA0 ODORA0 requested a review from dkayiwa December 6, 2024 11:28
@dkayiwa
Copy link
Member

dkayiwa commented Dec 6, 2024

There is lots of formatting changes which makes it hard to review the actual changes. Do you mind doing something about it?

@ODORA0
Copy link
Member Author

ODORA0 commented Dec 6, 2024

@dkayiwa Most of the changes are formatting is in the config xml that happens when running mvn clean. The change there only involves adding the extra GP for obs

@dkayiwa
Copy link
Member

dkayiwa commented Dec 6, 2024

Can you just avoid running it?

@ODORA0
Copy link
Member Author

ODORA0 commented Dec 6, 2024

@dkayiwa Done

@@ -382,6 +382,14 @@
Allows one to limit the number of encounters shown on the form entry tab of the patient dashboard specifically
</description>
</globalProperty>
<globalProperty>
<property>dashboard.observations.maximumNumberToShow</property>
<defaultValue>false</defaultValue>
Copy link
Member

Choose a reason for hiding this comment

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

The above default value does not look valid.

Copy link
Member Author

Choose a reason for hiding this comment

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

Changed

List<Person> persons = Collections.singletonList(person);

// Get most recent observations using limit parameter
List<Obs> paginatedObs = Context.getObsService().getObservations(persons, null, null, null, null,
Copy link
Member

Choose a reason for hiding this comment

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

According to the user interface you shared, what do you do with the startIndex?

Copy link
Member Author

Choose a reason for hiding this comment

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

Select the page size

Copy link
Member

Choose a reason for hiding this comment

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

Is the page size the same as startIndex?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, based on the link you shared I renamed pageSize to that

Copy link
Member

Choose a reason for hiding this comment

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

Where are you passing the two parameters in the getObservations api call?

Copy link
Member Author

Choose a reason for hiding this comment

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

We are only passing the limit and not startIndex directly in that method call

Copy link
Member

Choose a reason for hiding this comment

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

Which means that if you are on the last page, you still load all obs from the database to memory.

Copy link
Member Author

Choose a reason for hiding this comment

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

@dkayiwa Yes that's right, added that

List<Obs> paginatedObs = Context.getObsService().getObservations(persons, null, null, null, null, null,
Collections.singletonList("obsDatetime desc"), limit, startIndex, null, null, false);

model.put("limit", limit);
Copy link
Member

Choose a reason for hiding this comment

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

Can you remind me of why we are storing limit in the model?

Copy link
Member Author

Choose a reason for hiding this comment

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

We storing limit in the model to make it accessible to/for the JSP and also keeping the pagination logic synchronised between the backend and JSP frontend

Copy link
Member

Choose a reason for hiding this comment

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

Can you point me to the JSP that is currently using this limit that you have put in the model?

Copy link
Member Author

Choose a reason for hiding this comment

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

Copy link
Member

Choose a reason for hiding this comment

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

Do you mind pointing me to the actual line that uses limit?

Copy link
Member Author

@ODORA0 ODORA0 Dec 9, 2024

Choose a reason for hiding this comment

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

Looking at the dashboard JSP file, no references to a limit or pagination for observations, safe deleting it.


model.put(
"patientEncounters",
Context.getEncounterService().getEncounters(p.getPatientIdentifier().getIdentifier(),
Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Member Author

Choose a reason for hiding this comment

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

Changed

@@ -425,4 +465,30 @@ public ModelAndView handleRequest(HttpServletRequest request, HttpServletRespons
protected void populateModel(HttpServletRequest request, Map<String, Object> model) {
}

private Integer getStartIndexParameter(HttpServletRequest request) {
try {
return Integer.parseInt(request.getParameter("startIndex"));
Copy link
Member

Choose a reason for hiding this comment

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

Can we also do the StringUtils.isNotBlank check for cases where startIndex may not be supplied?

Copy link
Member Author

Choose a reason for hiding this comment

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

Spring's StringUtils.hasText instead of isNotBlank that's from the javadoc as using isNotBlank threw errors

private Integer getLimitParameter(HttpServletRequest request, AdministrationService as, String globalPropertyKey) {
try {
String limitParam = request.getParameter("limit");
if (limitParam != null) {
Copy link
Member

@dkayiwa dkayiwa Dec 8, 2024

Choose a reason for hiding this comment

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

Can we instead use StringUtils.isNotBlank such that we also filter out non null values but with empty spaces?

@ODORA0 ODORA0 requested a review from dkayiwa December 8, 2024 15:04
String heightString = as.getGlobalProperty("concept.height");
ConceptNumeric heightConcept = null;
if (StringUtils.hasLength(heightString)) {
heightConcept = cs.getConceptNumeric(GeneralUtils.getConcept(heightString).getConceptId());
}
for (Obs obs : patientObs) {
Copy link
Member

Choose a reason for hiding this comment

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

Did you intentionally remove this for loop? Is it related to the pagination?

Copy link
Member Author

Choose a reason for hiding this comment

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

Rather than loading all observations and finding the latest in memory, we would leverage the database to do this work more efficiently. Not directly related to pagination but aligns with the optimization goals since we no longer need to iterate over all patientObs to extract weight and height.

Copy link
Member

@dkayiwa dkayiwa Dec 8, 2024

Choose a reason for hiding this comment

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

With performance optimisations, unless you have tested and confirmed, you can be very shocked to find a different outcome. The database itself being an expensive resource, and you have anyway already fetched these observations in the above call, you can find that actually iterating through the collection in memory may be faster than making another expensive database call. So i would remove that loop only after i have done some confirmations.

Copy link
Member Author

Choose a reason for hiding this comment

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

The queries for weight and height are correctly implemented, unless there are other dependencies on the for loop logic that exist that are not unaccounted for I think I will put this back to be on a safer side.

Copy link
Member Author

Choose a reason for hiding this comment

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

@dkayiwa The paginated list only contains a limited number of observations (default 50), therefore BMI calculation couldn't find the necessary values, resulting in "?" if at all we add back the for loop while paginating. Now for this to work I would have to do something like this

// Paginated observations for display
List<Obs> paginatedObs = Context.getObsService().getObservations(..., limit, startIndex, ...);

// Additional query to get ALL observations for BMI calculation
List<Obs> allObs = Context.getObsService().getObservations(persons, null, null, null, null,
    null, null, null, null, null, null, false);

// Finding weight/height in complete set of observations
for (Obs obs : allObs) {
    // ... looking for weight/height
}

In essence, we'd now be using:

  • Paginated query for display purposes (performance optimization)
  • Complete query for BMI calculation (functionality requirement)

Does this sound an approach to use?

Copy link
Member

Choose a reason for hiding this comment

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

What do you see as the best approach?

Copy link
Member Author

Choose a reason for hiding this comment

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

I think I will go with this option I have suggested, in the long run we have separation of concerns. Change made

if (latestWeight != null && latestHeight != null) {
model.put("patientWeight", latestWeight);
Copy link
Member

Choose a reason for hiding this comment

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

What happens to the model when latestHeight is null but latestWeight is not null?

Copy link
Member Author

@ODORA0 ODORA0 Dec 9, 2024

Choose a reason for hiding this comment

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

No BMI in the model, since both latestWeight and latestHeight are required for BMI calculation, the model will not have patientBmi or patientBmiAsString if either is null. he model will only include the observation that is not null (patientWeight in this case). But since I will revert this, I think any changes like setting a default value or log warning wouldn't be necessary

@ODORA0 ODORA0 requested a review from dkayiwa December 9, 2024 11:36
null, Collections.singletonList("obsDatetime desc"), limit, startIndex, null, null, false);

// Get all observations for BMI calculation
List<Obs> allObs = Context.getObsService().getObservations(persons, null, null, null, null, null,
Copy link
Member

Choose a reason for hiding this comment

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

If you are loading all the patient's observations from the database into memory, then what optimisation is pagination doing?

Copy link
Member Author

Choose a reason for hiding this comment

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

Enhanced so that:

  • Retrieves the latest observation for weight and height for the patient using the same observation retrieval method (getObservations).
  • Sorting by obsDatetime desc ensures that only the most recent data is considered.
    This has been tested and actually is optimised, loads faster for a patient with > 61k observation_count

@ODORA0 ODORA0 requested a review from dkayiwa December 13, 2024 05:17
}
}
if (latestWeight != null) {
Copy link
Member

Choose a reason for hiding this comment

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

According to the original code, latestHeight or latestWeight was put in the model as long as it was not null. Did you intentionally change it?

Copy link
Member

Choose a reason for hiding this comment

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

Did you see the above comment?

@ODORA0 ODORA0 requested a review from dkayiwa December 13, 2024 11:03
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.

3 participants