A Tale of How Realm's Cascading Delete Won the Long Start

All users take fast launch and responsive UI for granted in mobile apps. If the application takes a long time to start, the user becomes sad and angry. You can easily spoil the customer experience or even lose the user even before he starts using the application.



Once we found that the Dodo Pizza application starts in an average of 3 seconds, and for some "lucky ones" it takes 15-20 seconds.



Under the cut is a story with a happy ending: about the growth of the Realm database, memory leaks, how we saved up nested objects, and then pulled ourselves together and fixed everything.










The author of the article: Maxim Kachinkin is an Android developer at Dodo Pizza.






Three seconds from a click on the application icon to the onResume () of the first activity is infinity. And for some users, the launch time reached 15-20 seconds. How is this even possible?



A very short summary for those who have no time to read
Realm. , . . , — 1 . — - -.



Search and analysis of the problem



Today, any mobile application must launch quickly and be responsive. But it's not just the mobile app. The user experience of interacting with a service and a company is a complex thing. For example, in our case, delivery speed is one of the key indicators for a pizza service. If delivery is fast, the pizza will be hot and the customer who wants to eat now will not have to wait long. For the application, in turn, it is important to create the feeling of a fast service, because if the application only starts 20 seconds, then how long will it take for a pizza?



At first, we ourselves were faced with the fact that sometimes the application is launched for a couple of seconds, and then complaints from other colleagues began to reach us that it was “long”. But we did not manage to repeat this situation stably.



How long is it? According toGoogle documentation , if a cold start of an application takes less than 5 seconds, then it is considered "kind of normal". The Dodo Pizza Android application was launched (according to Firebase _app_start metric ) on a cold start in an average of 3 seconds - "Not great, not terrible", as they say.



But then complaints began to appear that the application was launched for a very, very, very long time! To begin with, we decided to measure what is "very, very, very long". And we used Firebase trace App start trace for this .







This standard trace measures the time between the moment the user opens the application and the moment when the onResume () of the first activation is executed. In Firebase Console, this metric is called _app_start. It turned out that:



  • Users above the 95th percentile have a start-up time of almost 20 seconds (some have more), despite a median cold start time of less than 5 seconds.
  • Startup time is not constant, but growing over time. But sometimes falls are observed. We found this pattern when we increased the analysis scale to 90 days.






Two thoughts came to mind:



  1. Something is leaking.
  2. This “something” is discarded after release and then leaks out again.


“Probably something with the database,” we thought, and we were right. Firstly, we use the database as a cache; we clear it during migration. Secondly, the database is loaded when the application starts. It all fits together.



What's wrong with the Realm database



We began to check how the content of the database changes over the lifetime of the application, from the first installation and further in the process of active use. You can view the contents of the Realm database through Stetho or in more detail and visually by opening the file through Realm Studio . To view the contents of the database via ADB, copy the Realm database file:



adb exec-out run-as ${PACKAGE_NAME} cat files/${DB_NAME}


Having looked at the contents of the database at different times, we found out that the number of objects of a certain type is constantly increasing.





The picture shows a fragment of Realm Studio for two files: on the left - the application database after some time after installation, on the right - after active use. It can be seen that the number of objects ImageEntityand MoneyTypehas grown significantly (the screenshot shows the number of objects of each type).



Database Growth Relation to Startup Times



Uncontrolled database growth is very bad. But how does this affect the launch time of the application? It is quite easy to measure it through the ActivityManager. Since Android 4.4, logcat displays a log with Displayed string and time. This time is equal to the interval from the moment the application was launched until the end of the rendering of the activity. During this time, events occur:



  • Starting the process.
  • Object initialization.
  • Creation and initialization of activity.
  • Layout creation.
  • Application rendering.


Suitable for us. If you run ADB with the -S and -W flags, you can get extended output with the start time:



adb shell am start -S -W ru.dodopizza.app/.MainActivity -c android.intent.category.LAUNCHER -a android.intent.action.MAIN


If you scrape grep -i WaitTimetime from there , you can automate the collection of this metric and see the results graphically. The graph below shows the dependence of the application launch time on the number of cold starts of the application.







At the same time, the dependence of the size and growth of the base was the same, which grew from 4 MB to 15 MB. In total, it turns out that over time (with the growth of cold starts), both the application launch time and the size of the database grew. We have a hypothesis in our hands. Now all that remained was to confirm the dependence. Therefore, we decided to remove the "leaks" and see if it will speed up the launch.



Reasons for Database Infinite Growth



Before removing the "leaks", it is worth understanding why they appeared at all. To do this, let's remember what Realm is.



Realm is a non-relational database. It allows you to describe relationships between objects in a similar way that many ORM relational databases on Android describe. At the same time, Realm directly saves objects in memory with the least number of transformations and mappings. This allows you to read data from the disk very quickly, which is a strength of Realm and is loved for.



(For the purposes of this article, this description will be enough for us. You can read more about Realm in the cool documentation or in their academy ).



Many developers are used to working more with relational databases (for example, ORM databases with SQL under the hood). And things like cascading data deletion often seem like a matter of course. But not in Realm.



By the way, the cascade deletion feature has been asked to do for a long time. This revision and another related to it were actively discussed. There was a feeling that it would soon be done. But then everything turned into the introduction of strong and weak links, which would also automatically solve this problem. For this task, there was a rather lively and active pull request , which was paused for now due to internal difficulties.



Data leak without cascading delete



How exactly does data leak if you hope for a non-existent cascading delete? If you have nested Realm objects, then they must be deleted.

Let's look at an (almost) real-world example. We have an object CartItemEntity:



@RealmClass
class CartItemEntity(
 @PrimaryKey
 override var id: String? = null,
 ...
 var name: String = "",
 var description: String = "",
 var image: ImageEntity? = null,
 var category: String = MENU_CATEGORY_UNKNOWN_ID,
 var customizationEntity: CustomizationEntity? = null,
 var cartComboProducts: RealmList<CartProductEntity> = RealmList(),
 ...
) : RealmObject()


The product in the cart has different fields, including a picture ImageEntity, customized ingredients CustomizationEntity. Also, the product in the basket can be a combo with its own set of products RealmList (CartProductEntity). All of the listed fields are Realm objects. If we insert a new object (copyToRealm () / copyToRealmOrUpdate ()) with the same id, then this object will be completely overwritten. But all internal objects (image, customizationEntity and cartComboProducts) will lose connection with the parent and remain in the database.



Since the connection with them is lost, we no longer read them or delete them (unless we explicitly refer to them or clear the entire “table”). We called this "memory leaks".



When we work with Realm, we must explicitly go through all the elements and explicitly delete everything before such operations. This can be done, for example, like this:



val entity = realm.where(CartItemEntity::class.java).equalTo("id", id).findFirst()
if (first != null) {
 deleteFromRealm(first.image)
 deleteFromRealm(first.customizationEntity)
 for(cartProductEntity in first.cartComboProducts) {
   deleteFromRealm(cartProductEntity)
 }
 first.deleteFromRealm()
}
//    


If you do this, then everything will work as it should. In this example, we assume that there are no other nested Realm objects inside the image, customizationEntity, and cartComboProducts, so there are no other nested loops and deletes.



Quick solution



First of all, we decided to clean up the fastest growing objects and check the results - whether this will solve our original problem. First, the most simple and intuitive solution was made, namely: each object should be responsible for removing its children after itself. To do this, we introduced the following interface, which returned a list of its nested Realm objects:



interface NestedEntityAware {
 fun getNestedEntities(): Collection<RealmObject?>
}


And we implemented it in our Realm objects:



@RealmClass
class DataPizzeriaEntity(
 @PrimaryKey
 var id: String? = null,
 var name: String? = null,
 var coordinates: CoordinatesEntity? = null,
 var deliverySchedule: ScheduleEntity? = null,
 var restaurantSchedule: ScheduleEntity? = null,
 ...
) : RealmObject(), NestedEntityAware {

 override fun getNestedEntities(): Collection<RealmObject?> {
   return listOf(
       coordinates,
       deliverySchedule,
       restaurantSchedule
   )
 }
}


As getNestedEntitieswe return all children a flat list. And each child object can also implement the NestedEntityAware interface, informing that it has internal Realm objects to be deleted, for example ScheduleEntity:



@RealmClass
class ScheduleEntity(
 var monday: DayOfWeekEntity? = null,
 var tuesday: DayOfWeekEntity? = null,
 var wednesday: DayOfWeekEntity? = null,
 var thursday: DayOfWeekEntity? = null,
 var friday: DayOfWeekEntity? = null,
 var saturday: DayOfWeekEntity? = null,
 var sunday: DayOfWeekEntity? = null
) : RealmObject(), NestedEntityAware {

 override fun getNestedEntities(): Collection<RealmObject?> {
   return listOf(
       monday, tuesday, wednesday, thursday, friday, saturday, sunday
   )
 }
}


And so on, the nesting of objects can be repeated.



Then we write a method that recursively removes all nested objects. The method (made in the form of an extension) deleteAllNestedEntitiesgets all the top-level objects and deleteNestedRecursivelyrecursively removes all nested objects using the NestedEntityAware interface:



fun <T> Realm.deleteAllNestedEntities(entities: Collection<T>,
 entityClass: Class<out RealmObject>,
 idMapper: (T) -> String,
 idFieldName : String = "id"
 ) {

 val existedObjects = where(entityClass)
     .`in`(idFieldName, entities.map(idMapper).toTypedArray())
     .findAll()

 deleteNestedRecursively(existedObjects)
}

private fun Realm.deleteNestedRecursively(entities: Collection<RealmObject?>) {
 for(entity in entities) {
   entity?.let { realmObject ->
     if (realmObject is NestedEntityAware) {
       deleteNestedRecursively((realmObject as NestedEntityAware).getNestedEntities())
     }
     realmObject.deleteFromRealm()
   }
 }
}


We did this with the fastest growing objects and checked what happened.







As a result, the objects that we covered with this solution stopped growing. And the overall growth of the base slowed down, but did not stop.



The "normal" solution



The base, although it began to grow more slowly, was still growing. So we started looking further. In our project, data caching in Realm is very actively used. Therefore, writing all nested objects for each object is laborious, plus the risk of an error increases, because you can forget to specify the objects when changing the code.



I wanted to make sure not to use interfaces, but to make everything work by itself.



When we want something to work on its own, we have to use reflection. To do this, we can go through each field of the class and check if it is a Realm object or a list of objects:



RealmModel::class.java.isAssignableFrom(field.type)

RealmList::class.java.isAssignableFrom(field.type)


If the field is a RealmModel or RealmList, then add the object of this field to the list of nested objects. Everything is exactly the same as we did above, only here it will be done by itself. The cascading delete method itself is very simple and looks like this:



fun <T : Any> Realm.cascadeDelete(entities: Collection<T?>) {
 if(entities.isEmpty()) {
   return
 }

 entities.filterNotNull().let { notNullEntities ->
   notNullEntities
       .filterRealmObject()
       .flatMap { realmObject -> getNestedRealmObjects(realmObject) }
       .also { realmObjects -> cascadeDelete(realmObjects) }

   notNullEntities
       .forEach { entity ->
         if((entity is RealmObject) && entity.isValid) {
           entity.deleteFromRealm()
         }
       }
 }
}


The extension filterRealmObjectfilters and passes only Realm objects. The method getNestedRealmObjectsfinds all nested Realm objects through reflection and adds them into a linear list. Then we do the same recursively. When deleting, you need to check the object for validity isValid, because it may be that different parent objects may have the same nested objects. It is better to avoid this and just use id autogeneration when creating new objects.





Full implementation of the getNestedRealmObjects method
private fun getNestedRealmObjects(realmObject: RealmObject) : List<RealmObject> {
 val nestedObjects = mutableListOf<RealmObject>()
 val fields = realmObject.javaClass.superclass.declaredFields

//   ,     RealmModel   RealmList
 fields.forEach { field ->
   when {
     RealmModel::class.java.isAssignableFrom(field.type) -> {
       try {
         val child = getChildObjectByField(realmObject, field)
         child?.let {
           if (isInstanceOfRealmObject(it)) {
             nestedObjects.add(child as RealmObject)
           }
         }
       } catch (e: Exception) { ... }
     }

     RealmList::class.java.isAssignableFrom(field.type) -> {
       try {
         val childList = getChildObjectByField(realmObject, field)
         childList?.let { list ->
           (list as RealmList<*>).forEach {
             if (isInstanceOfRealmObject(it)) {
               nestedObjects.add(it as RealmObject)
             }
           }
         }
       } catch (e: Exception) { ... }
     }
   }
 }

 return nestedObjects
}

private fun getChildObjectByField(realmObject: RealmObject, field: Field): Any? {
 val methodName = "get${field.name.capitalize()}"
 val method = realmObject.javaClass.getMethod(methodName)
 return method.invoke(realmObject)
}




As a result, in our client code, we use a "cascading delete" for every data change operation. For example, for an insert operation, it looks like this:



override fun <T : Entity> insert(
 entityInformation: EntityInformation,
 entities: Collection<T>): Collection<T> = entities.apply {
 realmInstance.cascadeDelete(getManagedEntities(entityInformation, this))
 realmInstance.copyFromRealm(
     realmInstance
         .copyToRealmOrUpdate(this.map { entity -> entity as RealmModel }
 ))
}


First, the method getManagedEntitiesgets all the added objects, and then the method cascadeDeleterecursively removes all collected objects before writing new ones. We end up using this approach throughout the application. Memory leaks in Realm are completely gone. Having carried out the same measurement of the dependence of the launch time on the number of cold starts of the application, we see the result.







The green line shows the dependence of the application launch time on the number of cold starts during automatic cascade deletion of nested objects.



Results and conclusions



The ever-growing Realm database greatly slowed down the application launch. We have released an update with our own "cascading deletion" of nested objects. And now we track and evaluate how our decision affected the application launch time through the _app_start metric.







For the analysis, we take a time interval of 90 days and see: the application launch time, both the median and the one that falls on the 95th percentile of users, began to decrease and does not rise any more.







If you look at the seven-day graph, the _app_start metric looks completely adequate and is less than 1 second.



We should also add that by default Firebase sends notifications if the median _app_start value exceeds 5 seconds. However, as we can see, you should not rely on this, but rather go in and check it explicitly.



The peculiarity of the Realm database is that it is a non-relational database. Despite its simple use, the similarity of working with ORM solutions and linking objects, it does not have a cascading deletion.



If this is not taken into account, then nested objects will accumulate, "leak". The database will grow constantly, which in turn will affect the slowdown or launch of the application.



I shared our experience, how quickly do a cascading delete objects in the Realm, which is no out of the box, but which had long talk and talk . In our case, this greatly accelerated the launch time of the application.



Despite the discussion about the imminent appearance of this feature, the lack of cascading deletion in Realm is done by design. Consider this if you are designing a new application. And if you are already using Realm - check if you have any such problems.



All Articles