Skip to content

Commit

Permalink
Caching questionnaires and their SM (#3461)
Browse files Browse the repository at this point in the history
* Caching questionnaires and their SM

* spotless ran

* spotless ran

* updated tests

* WIP tests updated

* resolved feedback

* spotess ran

* fixed imports issue

* fixed imports issue

* updated tests

* Feedback resolved

* spotless ran

* Refactor Cache Content + Fix build

* Refactor ContentCache to be used through DefaultRepository

* Fix failing `FhirCarePlanGeneratorTest` tests

* Use mutex to invalidate cache

* Fix broken test in RegisterScreenTest

* [wip] Log quest tests ran in ci

---------

Co-authored-by: Martin Ndegwa <[email protected]>
Co-authored-by: Benjamin Mwalimu <[email protected]>
Co-authored-by: L≡ZRS <[email protected]>
Co-authored-by: Peter Lubell-Doughtie <[email protected]>
  • Loading branch information
5 people authored Nov 15, 2024
1 parent e8020c5 commit 5a9981e
Show file tree
Hide file tree
Showing 14 changed files with 306 additions and 16 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ jobs:
force-avd-creation: true
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: true
script: ./gradlew clean -PlocalPropertiesFile=local.properties :quest:fhircoreJacocoReport --stacktrace -Pandroid.testInstrumentationRunnerArguments.notPackage=org.smartregister.fhircore.quest.performance
script: ./gradlew clean -PlocalPropertiesFile=local.properties :quest:fhircoreJacocoReport --info -Pandroid.testInstrumentationRunnerArguments.notPackage=org.smartregister.fhircore.quest.performance

- name: Run Quest module unit and instrumentation tests and generate aggregated coverage report (Disabled)
if: false
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* Copyright 2021-2024 Ona Systems, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.smartregister.fhircore.engine.data.local

import androidx.collection.LruCache
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.hl7.fhir.r4.model.Resource
import org.hl7.fhir.r4.model.ResourceType
import org.smartregister.fhircore.engine.util.DispatcherProvider

@Singleton
class ContentCache @Inject constructor(private val dispatcherProvider: DispatcherProvider) {
private val maxMemory: Int = (Runtime.getRuntime().maxMemory() / 1024).toInt()
private val cacheSize: Int = maxMemory / 8
private val cache = LruCache<String, Resource>(cacheSize)
private val mutex = Mutex()

suspend fun <T : Resource> saveResource(resource: T): T {
val key = "${resource.resourceType.name}/${resource.idPart}"
return withContext(dispatcherProvider.io()) {
mutex.withLock { cache.put(key, resource.copy()) }
@Suppress("UNCHECKED_CAST")
getResource(resource.resourceType, resource.idPart)!! as T
}
}

fun getResource(type: ResourceType, id: String) = cache["$type/$id"]?.copy()

suspend fun invalidate() =
withContext(dispatcherProvider.io()) { mutex.withLock { cache.evictAll() } }
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import com.google.android.fhir.SearchResult
import com.google.android.fhir.datacapture.extensions.logicalId
import com.google.android.fhir.db.ResourceNotFoundException
import com.google.android.fhir.get
import com.google.android.fhir.getResourceType
import com.google.android.fhir.search.Order
import com.google.android.fhir.search.Search
import com.google.android.fhir.search.filter.ReferenceParamFilterCriterion
Expand Down Expand Up @@ -115,17 +116,33 @@ constructor(
@ApplicationContext open val context: Context,
) {

@Inject lateinit var contentCache: ContentCache

init {
DaggerDefaultRepositoryComponent.create().inject(this)
}

suspend inline fun <reified T : Resource> loadResource(resourceId: String): T? =
fhirEngine.loadResource(resourceId)

@Throws(ResourceNotFoundException::class)
suspend fun loadResource(resourceId: String, resourceType: ResourceType): Resource =
fhirEngine.get(resourceType, resourceId)

@Throws(ResourceNotFoundException::class)
suspend fun loadResource(reference: Reference) =
IdType(reference.reference).let {
fhirEngine.get(ResourceType.fromCode(it.resourceType), it.idPart)
}

suspend inline fun <reified T : Resource> loadResourceFromCache(resourceId: String): T? {
val resourceType = getResourceType(T::class.java)
val resource =
contentCache.getResource(resourceType, resourceId)
?: fhirEngine.loadResource<T>(resourceId)?.let { contentCache.saveResource(it) }
return resource as? T
}

suspend inline fun <reified T : Resource> searchResourceFor(
token: TokenClientParam,
subjectType: ResourceType,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* Copyright 2021-2024 Ona Systems, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.smartregister.fhircore.engine.data.local

import dagger.Component
import javax.inject.Singleton
import org.smartregister.fhircore.engine.di.DispatcherModule

@Singleton
@Component(modules = [DispatcherModule::class])
interface DefaultRepositoryComponent {
fun inject(defaultRepository: DefaultRepository)
}
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,9 @@ constructor(
}
source.setParameter(Task.SP_PERIOD, period)
source.setParameter(ActivityDefinition.SP_VERSION, IntegerType(index))
val structureMapId = IdType(action.transform).idPart
val structureMap = defaultRepository.loadResourceFromCache<StructureMap>(structureMapId)

val structureMap = fhirEngine.get<StructureMap>(IdType(action.transform).idPart)
structureMapUtilities.transform(
transformSupportServices.simpleWorkerContext,
source,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/*
* Copyright 2021-2024 Ona Systems, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.smartregister.fhircore.engine.data.local

import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.hl7.fhir.r4.model.Questionnaire
import org.hl7.fhir.r4.model.Resource
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.smartregister.fhircore.engine.robolectric.RobolectricTest
import org.smartregister.fhircore.engine.rule.CoroutineTestRule

@OptIn(ExperimentalCoroutinesApi::class)
@HiltAndroidTest
class ContentCacheTest : RobolectricTest() {

@get:Rule(order = 0) val hiltRule = HiltAndroidRule(this)

@get:Rule(order = 1) val coroutineTestRule = CoroutineTestRule()

@Inject lateinit var contentCache: ContentCache

private val resourceId = "123"
private val mockResource: Resource = Questionnaire().apply { id = resourceId }

@Before
fun setUp() {
hiltRule.inject()
}

@Test
fun `saveResource should store resource in cache`() = runTest {
contentCache.saveResource(mockResource)

val cachedResource = contentCache.getResource(mockResource.resourceType, mockResource.idPart)
assertNotNull(cachedResource)
assertEquals(mockResource.idPart, cachedResource?.idPart)
}

@Test
fun `getResource should return the correct resource from cache`() = runTest {
contentCache.saveResource(mockResource)

val result = contentCache.getResource(mockResource.resourceType, mockResource.idPart)
assertEquals(mockResource.idPart, result?.idPart)
}

@Test
fun `getResource should return null if resource does not exist`() = runTest {
val result = contentCache.getResource(mockResource.resourceType, "non_existing_id")
assertNull(result)
}

@Test
fun `invalidate should clear all resources from cache`() = runTest {
contentCache.saveResource(mockResource)
contentCache.invalidate()

val result = contentCache.getResource(mockResource.resourceType, mockResource.idPart)
assertNull(result)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
Expand Down Expand Up @@ -106,12 +105,15 @@ import org.mockito.ArgumentMatchers.anyBoolean
import org.smartregister.fhircore.engine.app.fakes.Faker
import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry
import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig
import org.smartregister.fhircore.engine.configuration.app.ConfigService
import org.smartregister.fhircore.engine.configuration.event.EventTriggerCondition
import org.smartregister.fhircore.engine.configuration.event.EventWorkflow
import org.smartregister.fhircore.engine.data.local.DefaultRepository
import org.smartregister.fhircore.engine.domain.model.ResourceConfig
import org.smartregister.fhircore.engine.robolectric.RobolectricTest
import org.smartregister.fhircore.engine.rule.CoroutineTestRule
import org.smartregister.fhircore.engine.util.DispatcherProvider
import org.smartregister.fhircore.engine.util.SharedPreferencesHelper
import org.smartregister.fhircore.engine.util.extension.REFERENCE
import org.smartregister.fhircore.engine.util.extension.SDF_YYYY_MM_DD
import org.smartregister.fhircore.engine.util.extension.asReference
Expand All @@ -130,6 +132,7 @@ import org.smartregister.fhircore.engine.util.extension.plusYears
import org.smartregister.fhircore.engine.util.extension.referenceValue
import org.smartregister.fhircore.engine.util.extension.updateDependentTaskDueDate
import org.smartregister.fhircore.engine.util.extension.valueToString
import org.smartregister.fhircore.engine.util.fhirpath.FhirPathDataExtractor
import org.smartregister.fhircore.engine.util.helper.TransformSupportServices

@OptIn(ExperimentalCoroutinesApi::class)
Expand All @@ -146,20 +149,29 @@ class FhirCarePlanGeneratorTest : RobolectricTest() {

@Inject lateinit var fhirEngine: FhirEngine

@Inject lateinit var dispatcherProvider: DispatcherProvider

@Inject lateinit var configService: ConfigService

@Inject lateinit var sharedPreferencesHelper: SharedPreferencesHelper

@Inject lateinit var fhirPathDataExtractor: FhirPathDataExtractor

@Inject lateinit var configurationRegistry: ConfigurationRegistry

private val context: Context = ApplicationProvider.getApplicationContext()
private val knowledgeManager = KnowledgeManager.create(context)
private val fhirContext: FhirContext = FhirContext.forCached(FhirVersionEnum.R4)

private lateinit var defaultRepository: DefaultRepository
private lateinit var fhirResourceUtil: FhirResourceUtil
private lateinit var fhirCarePlanGenerator: FhirCarePlanGenerator
private lateinit var structureMapUtilities: StructureMapUtilities
private lateinit var immunizationResource: Immunization
private lateinit var encounter: Encounter
private lateinit var opv0: Task
private lateinit var opv1: Task
private val defaultRepository: DefaultRepository = mockk(relaxed = true)

private val iParser: IParser = fhirContext.newJsonParser()
private val jsonParser = fhirContext.getCustomJsonParser()
private val xmlParser = fhirContext.newXmlParser()
Expand All @@ -168,7 +180,21 @@ class FhirCarePlanGeneratorTest : RobolectricTest() {
fun setup() {
hiltRule.inject()
structureMapUtilities = StructureMapUtilities(transformSupportServices.simpleWorkerContext)
every { defaultRepository.fhirEngine } returns fhirEngine
defaultRepository =
spyk(
DefaultRepository(
fhirEngine = fhirEngine,
dispatcherProvider = dispatcherProvider,
sharedPreferencesHelper = sharedPreferencesHelper,
configurationRegistry = mockk(),
configService = configService,
configRulesExecutor = mockk(),
fhirPathDataExtractor = fhirPathDataExtractor,
parser = iParser,
context = context,
),
)

coEvery { defaultRepository.create(anyBoolean(), any()) } returns listOf()

fhirResourceUtil =
Expand Down Expand Up @@ -898,6 +924,8 @@ class FhirCarePlanGeneratorTest : RobolectricTest() {
coEvery { fhirEngine.update(any()) } just runs
coEvery { fhirEngine.get<StructureMap>("528a8603-2e43-4a2e-a33d-1ec2563ffd3e") } returns
structureMapReferral
.encodeResourceToString()
.decodeResourceFromString() // Ensure 'months' Duration code is correctly escaped
coEvery { fhirEngine.search<CarePlan>(Search(ResourceType.CarePlan)) } returns
listOf(
SearchResult(
Expand Down Expand Up @@ -981,6 +1009,8 @@ class FhirCarePlanGeneratorTest : RobolectricTest() {
runs
coEvery { fhirEngine.get<StructureMap>("528a8603-2e43-4a2e-a33d-1ec2563ffd3e") } returns
structureMap
.encodeResourceToString()
.decodeResourceFromString() // Ensure 'months' Duration code is correctly escaped

coEvery { fhirEngine.search<CarePlan>(Search(ResourceType.CarePlan)) } returns listOf()

Expand Down Expand Up @@ -2529,8 +2559,12 @@ class FhirCarePlanGeneratorTest : RobolectricTest() {
coEvery { fhirEngine.search<CarePlan>(Search(ResourceType.CarePlan)) } returns listOf()
coEvery { fhirEngine.get<StructureMap>(structureMapRegister.logicalId) } returns
structureMapRegister
.encodeResourceToString()
.decodeResourceFromString() // Ensure 'months' Duration code is correctly escaped
coEvery { fhirEngine.get<StructureMap>("528a8603-2e43-4a2e-a33d-1ec2563ffd3e") } returns
structureMapReferral
.encodeResourceToString()
.decodeResourceFromString() // Ensure 'months' Duration code is correctly escaped

return PlanDefinitionResources(
planDefinition,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ group extractTaskRestriction(source subject: Patient, target task: Task, source
startDateTime.value = evaluate(start, $this.value.substring(0,10) + 'T00:00:00.00Z') "rule_period_start";

subject -> taskRestrictionPeriod.end = create('dateTime') as endDateTime,
endDateTime.value = evaluate(start, (($this + (((restrictionEndDate).toString() + ' \'days\'')).toQuantity()).value.substring(0,10)) + 'T00:00:00.00Z') "rule_period_end";
endDateTime.value = evaluate(start, (($this + (((restrictionEndDate).toString() + ' days')).toQuantity()).value.substring(0,10)) + 'T00:00:00.00Z') "rule_period_end";

subject -> task.restriction = taskRestriction "rule_restriction_period";
} "rule_task_restriction_period";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ group ExtractActivityDetail(source subject : Patient, source definition: Activit
subject then ExtractDiseaseCode(src, det) "r_act_det_data";
subject -> det.scheduled = evaluate(definition, $this.timing) as timing,
evaluate(timing, $this.repeat) as repeat then {
subject -> evaluate(subject, today()) as dueDate, evaluate(subject, today() + ((repeat.count.toString().toInteger() - 1).toString() + ' \'months\'').toQuantity()) as maxDate
subject -> evaluate(subject, ((repeat.count.toString().toInteger() - 1).toString() + ' months').toQuantity()) as duration,
duration.code = 'months',
evaluate(subject, today()) as dueDate, evaluate(subject, today() + duration) as maxDate
then ExtractTasks(dueDate, maxDate, repeat, subject, careplan, activity, timing) "r_tasks";
subject -> repeat.count = create('positiveInt') as c, c.value = evaluate(activity, $this.outcomeReference.count().value) "r_task_rep_count";
} "r_tim_repeat";
Expand All @@ -48,7 +50,8 @@ group ExtractTasks(
// start of task is today OR first date of every month if future month | end is last day of given month
create('date') as startOfMonth, startOfMonth.value = evaluate(dueDate, $this.value.substring(0,7) + '-01'),
create('date') as start, start.value = evaluate(dueDate, iif($this = today(), $this, startOfMonth).value ),
evaluate(startOfMonth, ($this + '1 \'months\''.toQuantity()) - '1 \'days\''.toQuantity()) as end,
evaluate(startOfMonth, '1 month'.toQuantity()) as duration1month, duration1month.code = 'month',
evaluate(startOfMonth, ($this + duration1month) - '1 day'.toQuantity()) as end,
create('Period') as period,
careplan.contained = create('Task') as task then {
subject then ExtractPeriod(start, end, period) "r_task_period_extr";
Expand All @@ -67,7 +70,9 @@ group ExtractTasks(
subject -> task.reasonReference = create('Reference') as ref, ref.reference = 'Questionnaire/e14b5743-0a06-4ab5-aaee-ac158d4cb64f' "r_task_reason_ref";
subject -> activity.outcomeReference = reference(task) "r_cp_task_ref";
subject -> timing.event = evaluate(period, $this.start) "r_activity_timing";
repeat -> evaluate(period, $this.start + (repeat.period.toString() + ' \'months\'').toQuantity()) as nextDueDate
repeat -> evaluate(period, (repeat.period.toString() + ' months').toQuantity()) as duration,
duration.code = 'months',
evaluate(period, $this.start + duration) as nextDueDate
then ExtractTasks(nextDueDate, maxDate, repeat, subject, careplan, activity, timing) "r_task_repeat";
} "r_cp_acti_outcome";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@ group ExtractTimingCode(source subject : Group, target concept: CodeableConcept)

group ExtractPeriod_1m(source offset : DateType, target period: Period){
offset -> offset as start,
evaluate(offset, $this + 1 'month') as end then
evaluate(offset, "1 month".toQuantity()) as duration1month,
duration1month.code = 'month',
evaluate(offset, $this + duration1month) as end then
ExtractPeriod(start, end, period) "r_period";
}

Expand Down
Loading

0 comments on commit 5a9981e

Please sign in to comment.