Skip to content

Commit f71c91f

Browse files
fix(conversations): don't let stale syncs revert a pending read marker
Reading a chat and returning to the conversation list often flipped the entry to read and back to unread seconds later, sticking there until the next sync. Log analysis showed a request-ordering race: the resume-triggered room list sync request leaves before the concurrently sent read marker reaches the server, so the sync response is computed against the pre-marker state and the authoritative merge restores the old unread state - even though the server accepted the marker moments later. Read markers written locally are now tracked as pending in the ChatMessageSyncer singleton until a room list sync confirms their delivery. While a marker is pending and a sync reports a read state still behind it, that state is provably stale and the local read state is kept in the merge. Once the server has caught up (or moved past the marker, e.g. read further on another device) the marker is released and the server state applies unchanged - including a lower one, so marking as unread from another device keeps working. When sending the marker ultimately fails, ReadMarkerSyncWorker releases it as well, so the server re-asserts its state at the next sync and the client can never stay diverged; a process death empties the in-memory pending state with the same effect. Assisted-by: Claude Code:claude-fable-5 Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
1 parent 232736d commit f71c91f

4 files changed

Lines changed: 136 additions & 9 deletions

File tree

app/src/main/java/com/nextcloud/talk/chat/data/network/ChatMessageSyncer.kt

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -404,11 +404,38 @@ class ChatMessageSyncer @Inject constructor(
404404
)
405405
}
406406

407+
/**
408+
* Read markers that were written locally but whose delivery the server has not confirmed yet.
409+
*
410+
* A room list sync request can be answered before a marker sent at the same time reaches the
411+
* server, so the sync response carries a provably stale read state — applying it would revert
412+
* the conversation entry to unread until the next sync. While a marker is pending, such stale
413+
* responses are kept out of the merge; the entry is only released once a sync confirms the
414+
* marker (server read state caught up) or sending it ultimately failed, so the server's
415+
* authority is restored either way and marking as unread from another device stays possible.
416+
*/
417+
private val pendingReadMarkers = ConcurrentHashMap<String, Int>()
418+
419+
/**
420+
* The locally written but not yet server-confirmed read marker of the conversation, or null.
421+
*/
422+
fun pendingReadMarker(internalConversationId: String): Int? = pendingReadMarkers[internalConversationId]
423+
424+
/**
425+
* Releases a pending read marker: called when a room list sync confirmed it or when sending it
426+
* ultimately failed. Only removes [lastReadMessage] itself, so a newer marker written in the
427+
* meantime stays pending.
428+
*/
429+
fun clearPendingReadMarker(internalConversationId: String, lastReadMessage: Int) {
430+
pendingReadMarkers.remove(internalConversationId, lastReadMessage)
431+
}
432+
407433
/**
408434
* Optimistically writes the user's read state into the conversation entry, so the
409435
* conversation list reflects it immediately — before (and independent of) the read marker
410436
* reaching the server. The unread count is recounted from the cached messages above the new
411-
* marker. The server stays the authority: the next room list sync re-asserts its state.
437+
* marker. The server stays the authority: the next room list sync re-asserts its state, with
438+
* [pendingReadMarkers] bridging the window until the marker's delivery is confirmed.
412439
*/
413440
suspend fun updateLocalReadState(target: SyncTarget, lastReadMessage: Int) {
414441
val conversation =
@@ -418,6 +445,7 @@ class ChatMessageSyncer @Inject constructor(
418445
messageId = lastReadMessage.toLong(),
419446
excludedActorId = target.user.userId!!
420447
)
448+
pendingReadMarkers[target.internalConversationId] = lastReadMessage
421449
conversationsDao.updateReadState(conversation.internalId, lastReadMessage, unreadMessages)
422450
Log.d(TAG, "Local read state for room ${target.roomToken}: lastRead=$lastReadMessage, unread=$unreadMessages")
423451
}

app/src/main/java/com/nextcloud/talk/conversationlist/data/network/OfflineFirstConversationsRepository.kt

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ class OfflineFirstConversationsRepository @Inject constructor(
185185

186186
dao.syncConversationsForUser(
187187
accountId = user.id!!,
188-
serverItems = conversationsFromSync,
188+
serverItems = preserveReadStateOfPendingMarkers(previousConversations, conversationsFromSync),
189189
conversationIdsToDelete = determineLeftConversationIds(previousConversations, conversationsFromSync)
190190
)
191191

@@ -295,6 +295,41 @@ class OfflineFirstConversationsRepository @Inject constructor(
295295
connectivityManager.restrictBackgroundStatus == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED
296296
}
297297

298+
/**
299+
* Keeps provably stale read states out of the sync merge: a sync response computed before a
300+
* concurrently sent read marker reached the server still reports the room as unread, and
301+
* applying it would revert the conversation entry until the next sync. While a marker for the
302+
* room is pending and the server's read state is still behind it, the local read state is
303+
* kept; once the server has caught up (or moved past it, e.g. read further on another device)
304+
* the marker is released and the server state applies unchanged — including a lower one, so
305+
* marking as unread from another device keeps working.
306+
*/
307+
private fun preserveReadStateOfPendingMarkers(
308+
previousConversations: Map<String, ConversationEntity>,
309+
conversationsFromSync: List<ConversationEntity>
310+
): List<ConversationEntity> =
311+
conversationsFromSync.map { serverItem ->
312+
val pendingMarker = chatMessageSyncer.pendingReadMarker(serverItem.internalId)
313+
?: return@map serverItem
314+
val previous = previousConversations[serverItem.internalId]
315+
?: return@map serverItem
316+
317+
if (serverItem.lastReadMessage < pendingMarker) {
318+
Log.d(
319+
TAG,
320+
"Keeping local read state for room ${serverItem.token}: server lastRead=" +
321+
"${serverItem.lastReadMessage} is behind the pending read marker $pendingMarker"
322+
)
323+
serverItem.copy(
324+
lastReadMessage = previous.lastReadMessage,
325+
unreadMessages = previous.unreadMessages
326+
)
327+
} else {
328+
chatMessageSyncer.clearPendingReadMarker(serverItem.internalId, pendingMarker)
329+
serverItem
330+
}
331+
}
332+
298333
private fun determineLeftConversationIds(
299334
previousConversations: Map<String, ConversationEntity>,
300335
conversationsFromSync: List<ConversationEntity>

app/src/main/java/com/nextcloud/talk/jobs/ReadMarkerSyncWorker.kt

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import androidx.work.WorkerParameters
2121
import autodagger.AutoInjector
2222
import com.nextcloud.talk.application.NextcloudTalkApplication
2323
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
24+
import com.nextcloud.talk.chat.data.network.ChatMessageSyncer
2425
import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource
2526
import com.nextcloud.talk.users.UserManager
2627
import com.nextcloud.talk.utils.ApiUtils
@@ -34,10 +35,12 @@ import javax.inject.Inject
3435
*
3536
* The caller updates the local conversation entry optimistically before enqueuing this worker, so
3637
* the conversation list reflects the read state immediately. The server stays the authority:
37-
* every room list sync re-asserts the server's read state over the local entry, so when all
38-
* attempts fail the client falls back to the server state at the next sync instead of staying
39-
* diverged. Work is unique per room with [ExistingWorkPolicy.REPLACE], so the newest marker for a
40-
* room always wins and retries can never ship a stale marker backwards.
38+
* every room list sync re-asserts the server's read state over the local entry — guarded by the
39+
* pending marker in [ChatMessageSyncer] only while the marker is provably not delivered yet.
40+
* When all attempts fail, the pending marker is released so the client falls back to the server
41+
* state at the next sync instead of staying diverged. Work is unique per room with
42+
* [ExistingWorkPolicy.REPLACE], so the newest marker for a room always wins and retries can never
43+
* ship a stale marker backwards.
4144
*/
4245
@AutoInjector(NextcloudTalkApplication::class)
4346
class ReadMarkerSyncWorker(context: Context, workerParams: WorkerParameters) :
@@ -49,6 +52,9 @@ class ReadMarkerSyncWorker(context: Context, workerParams: WorkerParameters) :
4952
@Inject
5053
lateinit var chatNetworkDataSource: ChatNetworkDataSource
5154

55+
@Inject
56+
lateinit var chatMessageSyncer: ChatMessageSyncer
57+
5258
override suspend fun doWork(): Result {
5359
sharedApplication!!.componentApplication.inject(this)
5460

@@ -71,7 +77,7 @@ class ReadMarkerSyncWorker(context: Context, workerParams: WorkerParameters) :
7177
val credentials = user?.let { ApiUtils.getCredentials(it.username, it.token) }
7278
if (user == null || credentials == null) {
7379
Log.e(TAG, "No user or credentials found for user id $userId, dropping read marker sync")
74-
return Result.failure()
80+
return fail(userId, roomToken, lastReadMessage)
7581
}
7682

7783
val sent = runCatching {
@@ -88,11 +94,21 @@ class ReadMarkerSyncWorker(context: Context, workerParams: WorkerParameters) :
8894
Result.success()
8995
} else {
9096
Log.w(TAG, "Sending read marker for room $roomToken failed (attempt ${runAttemptCount + 1})")
91-
retryOrFail()
97+
retryOrFail(userId, roomToken, lastReadMessage)
9298
}
9399
}
94100

95-
private fun retryOrFail(): Result = if (runAttemptCount < MAX_RUN_ATTEMPTS - 1) Result.retry() else Result.failure()
101+
private fun retryOrFail(userId: Long, roomToken: String, lastReadMessage: Int): Result =
102+
if (runAttemptCount < MAX_RUN_ATTEMPTS - 1) {
103+
Result.retry()
104+
} else {
105+
fail(userId, roomToken, lastReadMessage)
106+
}
107+
108+
private fun fail(userId: Long, roomToken: String, lastReadMessage: Int): Result {
109+
chatMessageSyncer.clearPendingReadMarker("$userId@$roomToken", lastReadMessage)
110+
return Result.failure()
111+
}
96112

97113
companion object {
98114
private val TAG: String = ReadMarkerSyncWorker::class.java.simpleName

app/src/test/java/com/nextcloud/talk/conversationlist/data/network/ConversationListFreshnessIntegrationTest.kt

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,46 @@ class ConversationListFreshnessIntegrationTest {
153153
}
154154
}
155155

156+
@Test
157+
fun `a sync cannot revert the read state while its marker is pending`() {
158+
val user = user(withKeepNotificationsCapability = false)
159+
val repository = OfflineFirstConversationsRepository(
160+
db.conversationsDao(),
161+
conversationsNetwork,
162+
chatNetwork,
163+
networkMonitor,
164+
syncer,
165+
ApplicationProvider.getApplicationContext()
166+
)
167+
whenever(conversationsNetwork.getRooms(any(), any(), any())).thenReturn(
168+
// computed before the pending marker 12 reached the server: provably stale
169+
Observable.just(listOf(staleServerRoom(lastReadMessage = 10, unreadMessages = 2))),
170+
// the server caught up with the marker: confirms it and releases the guard
171+
Observable.just(listOf(staleServerRoom(lastReadMessage = 12, unreadMessages = 0))),
172+
// marked as unread from another device: must apply now that nothing is pending
173+
Observable.just(listOf(staleServerRoom(lastReadMessage = 8, unreadMessages = 4)))
174+
)
175+
176+
runBlocking {
177+
seedConversation(lastActivity = 12, lastReadMessage = 10, unreadMessages = 2)
178+
syncer.updateLocalReadState(target(), lastReadMessage = 12)
179+
assertEquals(12, conversationEntity().lastReadMessage)
180+
assertEquals(0, conversationEntity().unreadMessages)
181+
182+
repository.getRooms(user).join()
183+
assertEquals("stale sync must not revert the read marker", 12, conversationEntity().lastReadMessage)
184+
assertEquals("stale sync must not revert the unread count", 0, conversationEntity().unreadMessages)
185+
186+
repository.getRooms(user).join()
187+
assertEquals(12, conversationEntity().lastReadMessage)
188+
assertEquals(0, conversationEntity().unreadMessages)
189+
190+
repository.getRooms(user).join()
191+
assertEquals("server authority must be restored", 8, conversationEntity().lastReadMessage)
192+
assertEquals("marking unread on another device must apply", 4, conversationEntity().unreadMessages)
193+
}
194+
}
195+
156196
@Test
157197
fun `room list flow reflects database writes reactively`() {
158198
val user = user(withKeepNotificationsCapability = false)
@@ -184,6 +224,14 @@ class ConversationListFreshnessIntegrationTest {
184224
}
185225
}
186226

227+
private fun staleServerRoom(lastReadMessage: Int, unreadMessages: Int): Conversation =
228+
Conversation(
229+
token = ROOM_TOKEN,
230+
lastActivity = 12,
231+
lastReadMessage = lastReadMessage,
232+
unreadMessages = unreadMessages
233+
)
234+
187235
private suspend fun seedConversation(lastActivity: Long, lastReadMessage: Int, unreadMessages: Int) {
188236
val entity = Conversation(
189237
token = ROOM_TOKEN,

0 commit comments

Comments
 (0)