Merge pull request #5768 from element-hq/feature/bma/fixCrashInWorkManager
Fix crash in work manager
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.push.impl.workmanager
|
||||
|
||||
class DataForWorkManagerIsTooBig : Exception()
|
||||
@@ -29,30 +29,29 @@ class SyncNotificationWorkManagerRequest(
|
||||
private val workerDataConverter: WorkerDataConverter,
|
||||
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
|
||||
) : WorkManagerRequest {
|
||||
override fun build(): Result<WorkRequest> {
|
||||
override fun build(): Result<List<WorkRequest>> {
|
||||
if (notificationEventRequests.isEmpty()) {
|
||||
return Result.failure(InvalidParameterException("notificationEventRequests cannot be empty"))
|
||||
}
|
||||
val data = workerDataConverter.serialize(notificationEventRequests).getOrElse {
|
||||
return Result.failure(it)
|
||||
}
|
||||
Timber.d("Scheduling ${notificationEventRequests.size} notification requests with WorkManager for $sessionId")
|
||||
return Result.success(
|
||||
OneTimeWorkRequestBuilder<FetchNotificationsWorker>()
|
||||
.setInputData(data)
|
||||
.apply {
|
||||
// Expedited workers aren't needed on Android 12 or lower:
|
||||
// They force displaying a foreground sync notification for no good reason, since they sync almost immediately anyway
|
||||
// See https://developer.android.com/develop/background-work/background-tasks/persistent/getting-started/define-work#backwards-compat
|
||||
if (buildVersionSdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
|
||||
setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
|
||||
return workerDataConverter.serialize(notificationEventRequests).map { dataList ->
|
||||
dataList.map { data ->
|
||||
OneTimeWorkRequestBuilder<FetchNotificationsWorker>()
|
||||
.setInputData(data)
|
||||
.apply {
|
||||
// Expedited workers aren't needed on Android 12 or lower:
|
||||
// They force displaying a foreground sync notification for no good reason, since they sync almost immediately anyway
|
||||
// See https://developer.android.com/develop/background-work/background-tasks/persistent/getting-started/define-work#backwards-compat
|
||||
if (buildVersionSdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
|
||||
setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
|
||||
}
|
||||
}
|
||||
}
|
||||
.setTraceTag(workManagerTag(sessionId, WorkManagerRequestType.NOTIFICATION_SYNC))
|
||||
// TODO investigate using this instead of the resolver queue
|
||||
// .setInputMerger()
|
||||
.build()
|
||||
)
|
||||
.setTraceTag(workManagerTag(sessionId, WorkManagerRequestType.NOTIFICATION_SYNC))
|
||||
// TODO investigate using this instead of the resolver queue
|
||||
// .setInputMerger()
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.work.Data
|
||||
import androidx.work.workDataOf
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.androidutils.json.JsonProvider
|
||||
import io.element.android.libraries.core.extensions.mapCatchingExceptions
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
@@ -23,13 +24,67 @@ import timber.log.Timber
|
||||
class WorkerDataConverter(
|
||||
private val json: JsonProvider,
|
||||
) {
|
||||
fun serialize(notificationEventRequests: List<NotificationEventRequest>): Result<Data> {
|
||||
fun serialize(notificationEventRequests: List<NotificationEventRequest>): Result<List<Data>> {
|
||||
// First try to serialize all requests at once. In the vast majority of cases this will work.
|
||||
return serializeRequests(notificationEventRequests)
|
||||
.map { listOf(it) }
|
||||
.recoverCatching { t ->
|
||||
if (t is DataForWorkManagerIsTooBig) {
|
||||
// Perform serialization on sublists, workDataOf have failed because of size limit
|
||||
Timber.w(t, "Failed to serialize ${notificationEventRequests.size} notification requests, split the requests per room.")
|
||||
// Group the requests per rooms
|
||||
val requestsSortedPerRoom = notificationEventRequests.groupBy { it.roomId }.values
|
||||
// Build a list of sublist with size at most CHUNK_SIZE, and with all rooms kept together
|
||||
buildList {
|
||||
val currentChunk = mutableListOf<NotificationEventRequest>()
|
||||
for (requests in requestsSortedPerRoom) {
|
||||
if (currentChunk.size + requests.size <= CHUNK_SIZE) {
|
||||
// Can add the whole room requests to the current chunk
|
||||
currentChunk.addAll(requests)
|
||||
} else {
|
||||
// Add the current chunk
|
||||
add(currentChunk.toList())
|
||||
// Start a new chunk with the current room requests
|
||||
currentChunk.clear()
|
||||
// If a room has more requests than CHUNK_SIZE, we need to split them
|
||||
requests.chunked(CHUNK_SIZE) { chunk ->
|
||||
if (chunk.size == CHUNK_SIZE) {
|
||||
add(chunk.toList())
|
||||
} else {
|
||||
currentChunk.addAll(chunk)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Add any remaining requests
|
||||
add(currentChunk.toList())
|
||||
}
|
||||
.filter { it.isNotEmpty() }
|
||||
.also {
|
||||
Timber.d("Split notification requests into ${it.size} chunks for WorkManager serialization")
|
||||
it.forEach { requests ->
|
||||
Timber.d(" - Chunk with ${requests.size} requests")
|
||||
}
|
||||
}
|
||||
.mapNotNull { serializeRequests(it).getOrNull() }
|
||||
} else {
|
||||
throw t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun serializeRequests(notificationEventRequests: List<NotificationEventRequest>): Result<Data> {
|
||||
return runCatchingExceptions { json().encodeToString(notificationEventRequests.map { it.toData() }) }
|
||||
.onFailure {
|
||||
Timber.e(it, "Failed to serialize notification requests")
|
||||
}
|
||||
.map { str ->
|
||||
workDataOf(REQUESTS_KEY to str)
|
||||
.mapCatchingExceptions { str ->
|
||||
// Note: workDataOf can fail if the data is too large
|
||||
try {
|
||||
workDataOf(REQUESTS_KEY to str)
|
||||
} catch (_: IllegalStateException) {
|
||||
throw DataForWorkManagerIsTooBig()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +106,7 @@ class WorkerDataConverter(
|
||||
|
||||
companion object {
|
||||
private const val REQUESTS_KEY = "requests"
|
||||
internal const val CHUNK_SIZE = 20
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import io.element.android.libraries.workmanager.api.workManagerTag
|
||||
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import kotlin.collections.first
|
||||
|
||||
class SyncNotificationWorkManagerRequestTest {
|
||||
@Test
|
||||
@@ -33,7 +34,7 @@ class SyncNotificationWorkManagerRequestTest {
|
||||
|
||||
val result = request.build()
|
||||
assertThat(result.isSuccess).isTrue()
|
||||
result.getOrNull()!!.run {
|
||||
result.getOrNull()!!.first().run {
|
||||
assertThat(this).isInstanceOf(OneTimeWorkRequest::class.java)
|
||||
assertThat(workSpec.input.hasKeyWithValueOfType<String>("requests")).isTrue()
|
||||
// True in API 33+
|
||||
@@ -52,7 +53,7 @@ class SyncNotificationWorkManagerRequestTest {
|
||||
|
||||
val result = request.build()
|
||||
assertThat(result.isSuccess).isTrue()
|
||||
result.getOrNull()!!.run {
|
||||
result.getOrNull()!!.first().run {
|
||||
assertThat(this).isInstanceOf(OneTimeWorkRequest::class.java)
|
||||
assertThat(workSpec.input.hasKeyWithValueOfType<String>("requests")).isTrue()
|
||||
// False before API 33
|
||||
|
||||
@@ -10,10 +10,12 @@ package io.element.android.libraries.push.impl.workmanager
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID_3
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
|
||||
import io.element.android.libraries.push.api.push.NotificationEventRequest
|
||||
@@ -45,10 +47,95 @@ class WorkerDataConverterTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `serializing lots of data leads to several work data generated - one room - 100 events should be split in 5 chunks`() {
|
||||
val data = List(100) {
|
||||
NotificationEventRequest(
|
||||
sessionId = A_SESSION_ID,
|
||||
roomId = A_ROOM_ID,
|
||||
eventId = EventId(AN_EVENT_ID.value + it),
|
||||
providerInfo = "info$it",
|
||||
)
|
||||
}
|
||||
val sut = WorkerDataConverter(DefaultJsonProvider())
|
||||
val serialized = sut.serialize(data)
|
||||
assertThat(serialized.getOrNull()?.size).isGreaterThan(1)
|
||||
assertThat(serialized.getOrNull()?.size).isEqualTo(100 / WorkerDataConverter.CHUNK_SIZE)
|
||||
// All the items are present
|
||||
val deserialized = serialized.getOrNull()?.flatMap { sut.deserialize(it)!! }
|
||||
assertThat(deserialized).containsExactlyElementsIn(data)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `serializing lots of data leads to several work data generated - one room - 101 events should be split in 6 chunks`() {
|
||||
val data = List(101) {
|
||||
NotificationEventRequest(
|
||||
sessionId = A_SESSION_ID,
|
||||
roomId = A_ROOM_ID,
|
||||
eventId = EventId(AN_EVENT_ID.value + it),
|
||||
providerInfo = "info$it",
|
||||
)
|
||||
}
|
||||
val sut = WorkerDataConverter(DefaultJsonProvider())
|
||||
val serialized = sut.serialize(data)
|
||||
assertThat(serialized.getOrNull()?.size).isGreaterThan(1)
|
||||
assertThat(serialized.getOrNull()?.size).isEqualTo(100 / WorkerDataConverter.CHUNK_SIZE + 1)
|
||||
// All the items are present
|
||||
val deserialized = serialized.getOrNull()?.flatMap { sut.deserialize(it)!! }
|
||||
assertThat(deserialized).containsExactlyElementsIn(data)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `serializing lots of data leads to several work data generated - 3 rooms - 25 events should be split in 2 chunks and room not mixed`() {
|
||||
val data1 = List(15) {
|
||||
NotificationEventRequest(
|
||||
sessionId = A_SESSION_ID,
|
||||
roomId = A_ROOM_ID,
|
||||
eventId = EventId(AN_EVENT_ID.value + it),
|
||||
providerInfo = "info".repeat(100) + it,
|
||||
)
|
||||
}
|
||||
val data2 = List(3) {
|
||||
NotificationEventRequest(
|
||||
sessionId = A_SESSION_ID,
|
||||
roomId = A_ROOM_ID_2,
|
||||
eventId = EventId(AN_EVENT_ID.value + it),
|
||||
providerInfo = "info".repeat(100) + it,
|
||||
)
|
||||
}
|
||||
val data3 = List(7) {
|
||||
NotificationEventRequest(
|
||||
sessionId = A_SESSION_ID,
|
||||
roomId = A_ROOM_ID_3,
|
||||
eventId = EventId(AN_EVENT_ID.value + it),
|
||||
providerInfo = "info".repeat(100) + it,
|
||||
)
|
||||
}
|
||||
val data = (data1 + data2 + data3).shuffled()
|
||||
val sut = WorkerDataConverter(DefaultJsonProvider())
|
||||
val serialized = sut.serialize(data)
|
||||
assertThat(serialized.getOrNull()?.size).isEqualTo(2)
|
||||
// All the items are present
|
||||
val deserialized = serialized.getOrNull()?.flatMap { sut.deserialize(it)!! }
|
||||
assertThat(deserialized).containsExactlyElementsIn(data)
|
||||
// Rooms are not mixed between the chunks
|
||||
val setsOfRooms = serialized.getOrNull()!!
|
||||
.map { workData -> sut.deserialize(workData)!! }
|
||||
.map {
|
||||
it.map { request -> request.roomId }.toSet()
|
||||
}
|
||||
// Ensure that all sets are distinct
|
||||
assertThat(setsOfRooms.size).isEqualTo(2)
|
||||
// 3 roomId are present
|
||||
assertThat(setsOfRooms.flatten().toSet()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
|
||||
// No intersection between sets
|
||||
assertThat(setsOfRooms[0].intersect(setsOfRooms[1])).isEmpty()
|
||||
}
|
||||
|
||||
private fun testIdentity(data: List<NotificationEventRequest>) {
|
||||
val sut = WorkerDataConverter(DefaultJsonProvider())
|
||||
val serialized = sut.serialize(data).getOrThrow()
|
||||
val result = sut.deserialize(serialized)
|
||||
val result = sut.deserialize(serialized.first())
|
||||
assertThat(result).isEqualTo(data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,5 +11,5 @@ package io.element.android.libraries.workmanager.api
|
||||
import androidx.work.WorkRequest
|
||||
|
||||
interface WorkManagerRequest {
|
||||
fun build(): Result<WorkRequest>
|
||||
fun build(): Result<List<WorkRequest>>
|
||||
}
|
||||
|
||||
@@ -28,8 +28,8 @@ class DefaultWorkManagerScheduler(
|
||||
|
||||
override fun submit(workManagerRequest: WorkManagerRequest) {
|
||||
workManagerRequest.build().fold(
|
||||
onSuccess = {
|
||||
workManager.enqueue(it)
|
||||
onSuccess = { workRequests ->
|
||||
workManager.enqueue(workRequests)
|
||||
},
|
||||
onFailure = {
|
||||
Timber.e(it, "Failed to build WorkManager request $workManagerRequest")
|
||||
|
||||
Reference in New Issue
Block a user