Skip to content

client: Fix the unread count being overwritten by a stale channel snapshot - #6605

Open
andremion wants to merge 11 commits into
developfrom
andrerego/e2e-failure-diagnostics
Open

client: Fix the unread count being overwritten by a stale channel snapshot#6605
andremion wants to merge 11 commits into
developfrom
andrerego/e2e-failure-diagnostics

Conversation

@andremion

@andremion andremion commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Goal

Fix the unread count settling below the real number and never recovering. Reproduced consistently on API 28: 25 incoming messages rendered as "12 unread" on the jump-to-unread button, and the server's correct count could not repair it.

Root cause, from the CI logs rather than inspection: the channel list pushes its own copy of a channel into that channel's state (QueryChannelsStateLogic.addChannelsStateChannelLogicImpl.updateDataForChannel), which writes channel.read unconditionally. That copy's count was captured earlier, so it overwrote the count the channel state had built from message events (25 -> 11, logged). The arbitration that exists to protect the local count could not stop it, because it compared lastReceivedEventDate, a field the server never sends and that mapping synthesises from last_message_at ?: last_read, against a locally advanced clock.

Resolves AND-1355

Implementation

  • The arbitration in mergeCurrentUserRead now decides on the read position: an incoming count is taken only when lastRead moved forward, which is what happens when the channel is read on another device. At the same or an earlier position the incoming count may be stale, so the local count is kept.
  • The local increment no longer goes through that arbitration. It writes into the read map atomically, because it is the local count rather than an incoming read, and because a burst of events must not lose increments.
  • An explicit mark unread is applied by the new ChannelStateImpl.replaceRead. It moves the read position backwards on purpose and its count is authoritative, so it must not be arbitrated. This is the one case that cannot be decided from the data alone, since a deliberate mark unread and a stale payload both carry an older position; only the caller knows which it is.
  • Diagnostics that made this findable, kept because the next wrong count should not need them to be invented again: updateCurrentUserRead logs why a message does not count and logs each new value, the channel state's log tag carries an instance number as the legacy implementation already does, a reads update logs the current user's count transition, and the jump-to-unread assertion compares the rendered count so a failure names the actual value.
  • CI: the e2e failure artifacts now include the allure results, which carry the retry rule's per-attempt logcat, screenshot and hierarchy that TestOps deduplicates away when the same test passes on another API level. The e2e workflow also takes an api_level and a test_class on manual dispatch, so reproducing a single-API-level failure takes 13 minutes and yields three independent samples instead of a 90 minute matrix.

Testing

Summary by CodeRabbit

  • Bug Fixes

    • Improved accuracy of unread message counts and read state handling
    • Enhanced notification-based read state updates to prevent inconsistencies
    • Fixed read timestamp preservation when marking messages as read
  • Tests

    • Expanded E2E test diagnostics to capture detailed failure artifacts for better debugging
    • Added parameterized test execution support for targeted testing scenarios

The count check asserted only that the expected text exists somewhere,
so a CI failure carried no information about what the button actually
showed. Comparing against the rendered count inside the button keeps
the same strictness and makes the failure message carry the actual
value.
The retry rule attaches logcat, screenshot, and hierarchy to every
failed attempt, and the e2e lane already extracts the allure results to
the runner, but the failure artifact only carried the mock server logs.
TestOps deduplicates same-history results, so a failure on a single API
level loses its attachments when the other levels pass; the artifact is
the only reliable place to read them.
@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

PR checklist ✅

All required conditions are satisfied:

  • Title length is OK (or ignored by label).
  • At least one pr: label exists.
  • Sections ### Goal, ### Implementation, and ### Testing are filled, or the PR is bot-authored.
  • An issue is linked (Linear ticket or GitHub issue), or the PR is bot-authored.

🎉 Great job! This PR is ready for review.

@andremion andremion added the pr:test Test-only changes label Jul 30, 2026
@andremion

Copy link
Copy Markdown
Contributor Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The PR updates channel read-state processing and tests, preserves server-confirmed read dates, supports queued unread events, parameterizes E2E runs, uploads Allure results, and improves unread-count UI matching.

Changes

Read-state and E2E updates

Layer / File(s) Summary
Parameterize E2E execution
.github/workflows/e2e-test*.yml, fastlane/Fastfile
Workflows accept API-level and test-class inputs. Fastlane runs a selected class without batching when applicable. Both workflows upload mock-server logs and Allure results.
Process read-state events
stream-chat-android-client/src/main/java/.../ChannelStateImpl.kt, .../ChannelStateLogic.kt, .../ChannelEventHandlerImpl.kt, .../ChannelStateLegacyImpl.kt
Read-state updates use authoritative replacement where required. Unread processing filters already-read events, advances timestamps monotonically, and preserves server-confirmed dates during optimistic mark-read operations.
Validate read-state behavior
stream-chat-android-client/src/test/java/.../*ReadReceiptsTest.kt, .../ChannelStateLogicTest.kt, .../ChannelEventHandlerImplTest.kt
Tests cover queued events, duplicate filtering, unread filters, authoritative replacement, monotonic timestamps, and preserved read dates.
Match unread-count UI text
stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/.../UserRobotMessageListAsserts.kt
The assertion scopes regex text matching to the unread button subtree and compares the resolved text with the expected localized value.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Workflow
  participant Fastlane
  participant E2ETests
  participant Artifacts
  Workflow->>Fastlane: pass api_level and optional test_class
  Fastlane->>E2ETests: run selected class or batched tests
  E2ETests->>Artifacts: write mock-server logs and Allure results
Loading

Possibly related PRs

Suggested labels: pr:test, pr:ci, released

Suggested reviewers: velikovpetar, gpunto, testableapple

Poem

A rabbit checks each unread sign,
Keeps read-state clocks in line.
Allure logs join the nightly run,
Queued messages count one by one.
E2E carrots shine!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the primary fix: preventing stale channel snapshots from overwriting unread counts.
Description check ✅ Passed The description clearly covers the goal, implementation, diagnostics, CI changes, and testing results for the unread-count fix.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch andrerego/e2e-failure-diagnostics

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotMessageListAsserts.kt`:
- Around line 341-344: Update the selector in the message-list unread-count
assertion near MessageListPage.MessageList.scrollToFirstUnreadButton to avoid
the hardcoded English “unread” text. Use a locale-neutral matcher that
identifies the numeric count within the scoped button, while preserving the
existing expectedText comparison and wait behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: de39816b-b9d1-4e46-a28b-c655b996794b

📥 Commits

Reviewing files that changed from the base of the PR and between f7f89b6 and db16799.

📒 Files selected for processing (3)
  • .github/workflows/e2e-test-cron.yml
  • .github/workflows/e2e-test.yml
  • stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotMessageListAsserts.kt

@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

SDK Size Comparison 📏

SDK Before After Difference Status
stream-chat-android-client 5.96 MB 5.96 MB 0.00 MB 🟢
stream-chat-android-ui-components 11.22 MB 11.23 MB 0.00 MB 🟢
stream-chat-android-compose 12.71 MB 12.72 MB 0.00 MB 🟢

@andremion andremion changed the title e2e: Carry failure diagnostics in the CI artifacts and the unread count assertion client: Fix the unread count dropping queued messages after marking read Jul 31, 2026
Marking a channel read optimistically stamped the read dates with the
newest loaded message's created_at, and updateCurrentUserRead dropped
any event not strictly newer than that clock. On a slow device the
optimistic mark-read can execute in the middle of an incoming burst, so
every event still queued behind it was discarded as outdated and the
unread count settled below the real value. The server's correct count
could not repair it, because the read-state merge prefers the local
side with the newer event clock.

The optimistic update now only zeroes the count: the read dates stay
anchored to server-confirmed values, advanced by query responses and
read events. The counter skips messages already covered by last_read,
matching the iOS SDK's seen check, counts everything else exactly once
via the processed-id cache, and the event clock only moves forward.
Applies to both the current and the legacy state implementations.
@andremion
andremion force-pushed the andrerego/e2e-failure-diagnostics branch from 10e2458 to 7e17c9a Compare July 31, 2026 08:44
@andremion andremion added pr:bug Bug fix and removed pr:test Test-only changes labels Jul 31, 2026
The count is the only text inside the button, so matching any text
scoped to the button avoids assuming the English wording while keeping
the comparison against the localized expected string.
@andremion andremion changed the title client: Fix the unread count dropping queued messages after marking read client: Stop fabricating read dates and gating the unread count on the event clock Jul 31, 2026
The count could be wrong with no way to tell which condition suppressed
it. Every skip now names its reason and every increment logs the new
value, so a wrong count is traceable from a logcat. The conditions are
collected into one function in their original order, mirroring how the
iOS SDK reports the same reasons.
Reproducing a failure that only appears on one API level meant
dispatching the nightly matrix and waiting for all levels to finish. The
PR workflow now takes an api_level and a test_class on manual dispatch:
with a class set, batching is skipped so each batch job runs that class
and gives an independent sample, which is what a flaky test needs.
Two instances of the same channel logged under one tag, so a read state
that looked stale could not be told from a second instance holding its
own. The tag now carries a sequence number, as the legacy implementation
already does, and a reads update logs the current user's unread
transition with the incoming value.
The channel list pushes its own copy of a channel into each channel's
state, and that copy carries a read whose count was captured earlier.
It was written over the count the channel state had built from message
events, so an unread count of 25 became 11 and the server could not
correct it afterwards.

Two changes make the count hold. The merge that arbitrates an incoming
read now decides on the read position: a count is only taken when it
arrives with a newer lastRead, which is what happens when the channel
is read elsewhere. lastReceivedEventDate cannot decide it, since the
server never sends that field and mapping synthesises it. And the local
increment no longer goes through that merge at all: it writes into the
read map atomically, because it is the local count rather than an
incoming read, and a burst of events must not lose increments.
A mark unread moves the read position backwards on purpose and the count
it reports is the truth, but it was applied through the same path that
protects the local count against stale payloads, so the count was kept
and the unread state did not appear. The event now replaces the read
state directly, and the arbitration stays conservative for payloads:
an incoming count is taken only when the read position moved forward.
@andremion andremion changed the title client: Stop fabricating read dates and gating the unread count on the event clock client: Fix the unread count being overwritten by a stale channel snapshot Jul 31, 2026
@andremion

Copy link
Copy Markdown
Contributor Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplReadReceiptsTest.kt (1)

237-482: 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Add a concurrency test for the atomic increment claim.

The fixture updates and new tests (Lines 430-448, 564-587) correctly validate the new staleness and mark-read semantics. updateCurrentUserRead's production comment states the read-modify-write "has to be atomic to survive a burst of events," but this suite only calls it sequentially. Add a test that launches several concurrent updateCurrentUserRead calls (different message IDs) under runTest and confirms the final unreadMessages count equals the number of distinct messages, with no lost increments.

As per path instructions: "For concurrency-sensitive logic such as uploads, synchronization, and message state, add deterministic tests using runTest and virtual time."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplReadReceiptsTest.kt`
around lines 237 - 482, Add a deterministic concurrency test for
ChannelStateImpl.updateCurrentUserRead that launches multiple concurrent calls
under runTest, using distinct message IDs and a shared initial read state. Await
all launched jobs, then assert unreadMessages equals the number of unique
messages to verify no increments are lost.

Source: Path instructions

stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogic.kt (1)

191-216: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Port the lastRead-based read arbitration and atomic local increments into the legacy channel state path.

ChannelStateLogic.kt still treats lastReceivedEventDate as the “more recent” arbiter in mergeCurrentUserRead (line 230), so stale query-channel reads can preserve a stale unread count. Use the lastRead-based comparison shared with ChannelStateImpl.kt. Also add ChannelStateLegacyImpl.replaceRead(...) and use it for NotificationMarkUnreadEvent in ChannelEventHandlerLegacyImpl.kt; the legacy path still calls updateRead, which sends the replace through updateReads/mergeCurrentUserRead and can preserve an older unread count. ChannelStateLogicTest.kt needs matching lastRead assertions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogic.kt`
around lines 191 - 216, Update legacy read arbitration in mergeCurrentUserRead
and updateReads to compare lastRead using the same behavior as ChannelStateImpl,
rather than lastReceivedEventDate, and preserve the corresponding unread-count
outcome. Add ChannelStateLegacyImpl.replaceRead and route
NotificationMarkUnreadEvent handling in ChannelEventHandlerLegacyImpl through it
instead of updateRead, ensuring the replacement is applied atomically. Extend
ChannelStateLogicTest with matching lastRead-based assertions.
🧹 Nitpick comments (1)
fastlane/Fastfile (1)

110-116: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for branch precedence and empty input.

Test use_backend: true, a non-empty test_class, nil, and "". Assert that backend selection wins, targeted selection bypasses batch_tests, and empty input preserves batching.

As per coding guidelines, **/*: “add or refresh tests for changed behavior.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@fastlane/Fastfile` around lines 110 - 116, Add or update Fastfile tests
covering the test-filter branching around targeted_class: verify use_backend:
true takes precedence over a non-empty test_class, non-empty test_class bypasses
batch_tests, and both nil and "" preserve batching. Use the existing test
symbols and assert the resulting filter and batching behavior.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@fastlane/Fastfile`:
- Around line 110-116: Validate options[:test_class] against the documented
fully qualified test-class-name pattern before assigning targeted_class or
constructing test_filter, rejecting invalid workflow input. Update the targeted
test execution path so the validated class value is passed to sh as a separate
argument rather than interpolated into a shell command string; keep the
backend_test_class path unchanged.

---

Outside diff comments:
In
`@stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogic.kt`:
- Around line 191-216: Update legacy read arbitration in mergeCurrentUserRead
and updateReads to compare lastRead using the same behavior as ChannelStateImpl,
rather than lastReceivedEventDate, and preserve the corresponding unread-count
outcome. Add ChannelStateLegacyImpl.replaceRead and route
NotificationMarkUnreadEvent handling in ChannelEventHandlerLegacyImpl through it
instead of updateRead, ensuring the replacement is applied atomically. Extend
ChannelStateLogicTest with matching lastRead-based assertions.

In
`@stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplReadReceiptsTest.kt`:
- Around line 237-482: Add a deterministic concurrency test for
ChannelStateImpl.updateCurrentUserRead that launches multiple concurrent calls
under runTest, using distinct message IDs and a shared initial read state. Await
all launched jobs, then assert unreadMessages equals the number of unique
messages to verify no increments are lost.

---

Nitpick comments:
In `@fastlane/Fastfile`:
- Around line 110-116: Add or update Fastfile tests covering the test-filter
branching around targeted_class: verify use_backend: true takes precedence over
a non-empty test_class, non-empty test_class bypasses batch_tests, and both nil
and "" preserve batching. Use the existing test symbols and assert the resulting
filter and batching behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6e10894e-595a-44ab-bf63-baa9412e0398

📥 Commits

Reviewing files that changed from the base of the PR and between db16799 and 1cb337f.

📒 Files selected for processing (11)
  • .github/workflows/e2e-test.yml
  • fastlane/Fastfile
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelEventHandlerImpl.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogic.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt
  • stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImpl.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelEventHandlerImplTest.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogicTest.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplReadReceiptsTest.kt
  • stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImplTest.kt
  • stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotMessageListAsserts.kt
🚧 Files skipped from review as they are similar to previous changes (2)
  • stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotMessageListAsserts.kt
  • .github/workflows/e2e-test.yml

Comment thread fastlane/Fastfile
The value is interpolated into the instrumentation shell command, so it
is now checked to be a class name with an optional method. Dispatching
already requires write access, but a typo used to fail somewhere in the
shell instead of with a clear message.
@andremion
andremion marked this pull request as ready for review July 31, 2026 15:20
@andremion
andremion requested a review from a team as a code owner July 31, 2026 15:20
@andremion
andremion enabled auto-merge July 31, 2026 15:21
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr:bug Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant