-
Notifications
You must be signed in to change notification settings - Fork 179
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
base: master
Are you sure you want to change the base?
Conversation
Did you get a chance to take a look at this? https://openmrs.atlassian.net/wiki/spaces/docs/pages/25477199/Pull+Request+Tips |
@dkayiwa please review again |
Can we also include the ticket id in the commit message? |
Integer page = getPageParameter(request); | ||
Integer pageSize = getPageSizeParameter(request, as); | ||
|
||
// Get all observations |
There was a problem hiding this comment.
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.
boolean includeVoided = false; | ||
|
||
// Get observations for the current page | ||
List<Obs> paginatedObs = Context.getObsService().getObservations(persons, encounters, questions, |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
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
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
|
||
private Integer getPageParameter(HttpServletRequest request) { | ||
try { | ||
return Integer.parseInt(request.getParameter("page")); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you take a look at our naming convention for the pagination parameters? https://openmrs.atlassian.net/wiki/spaces/docs/pages/25469830/REST+Web+Services+API+For+Clients#Limiting-the-number-of-results-and-paging
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With the dropdown selector
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Awesome!!! 👍
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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"); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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. 😊
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There is lots of formatting changes which makes it hard to review the actual changes. Do you mind doing something about it? |
@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 |
Can you just avoid running it? |
@dkayiwa Done |
omod/src/main/resources/config.xml
Outdated
@@ -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> |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Select the page size
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The patientEncounters jsp
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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(), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why not use the more efficient? https://github.com/openmrs/openmrs-core/blob/2.0.0/api/src/main/java/org/openmrs/api/EncounterService.java#L432
There was a problem hiding this comment.
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")); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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?
String heightString = as.getGlobalProperty("concept.height"); | ||
ConceptNumeric heightConcept = null; | ||
if (StringUtils.hasLength(heightString)) { | ||
heightConcept = cs.getConceptNumeric(GeneralUtils.getConcept(heightString).getConceptId()); | ||
} | ||
for (Obs obs : patientObs) { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
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, |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
} | ||
} | ||
if (latestWeight != null) { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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?
Add pagination for patient observations in dashboard
Description:
Previously, the dashboard would load all patient observations at once, causing
performance issues for patients with many observations. This change:
Testing:
Ticket: https://openmrs.atlassian.net/browse/LUI-198