Skip to content

fix(unread): keep the last good unread, Activity and followed threads while the read warehouse is behind (task #164) - #3685

Merged
stamp-build[bot] merged 2 commits into
mainfrom
agent/lagoon/rw-stale-keep-last
Oct 24, 2026
Merged

stamp-build[bot] merged 2 commits into
mainfrom
agent/lagoon/rw-stale-keep-last

Conversation

@stamp-build

@stamp-build stamp-build Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Lagoon from raft opened this pull request through Stamp
View in Stamp →


Task #164: keep the last good numbers while the read warehouse (RW) is behind

On 10-24, between 09:52 and 09:54 UTC, the production RW restart produced 734 × 503 errors on the sidebar unread, channel unread, Activity and followed-threads reads. Clients that already had data kept it. A fresh open, a server switch or a sign-in could show an error, blank or zero. This PR keeps the last good data, says it may be old (stdrc's O2), and recovers by itself.

Server contract (verified at botiverse/slock f6ace2b9128be9bb6e85e0e46cd066ee17e87530, staging)

  • routes/rwStaleRead.ts: on rw_overloaded, the server answers with a body remembered for up to 5 minutes, plus the header X-Raft-Stale: rw_overloaded. For the unread summary and the inbox, a soft timeout can also answer X-Raft-Stale: rw_slow with a body up to 60 s old. The routes are servers/unread-summary, channels/unread (the summary body also carries "stale": true), channels/inbox (first page with no query, body "stale": true) and channels/threads/followed (first page, body "stale": true).
  • routes/errorResponse.ts respondToRisingWaveOverload: 503, Retry-After: 2, body {"error":"Temporarily overloaded, retry shortly","code":"rw_overloaded","retryable":true}. There is no Postgres fallback.

What changed

  • Shared layer (data/RaftStaleRead.kt): parses X-Raft-Stale and the 503 code. They are surfaced as typed facts: staleReason (InboxSnapshot, ThreadListSnapshot, ActivityUnreadSummarySnapshot), ChannelUnreadSummary.stale, HomeNetworkPayload/HomeSnapshot.unreadStale, and ScreenError.serverCode == "rw_overloaded". A stale page is always Content, never Empty. The platform layer only passes headers (the existing Ktor executor already does).
  • SharedHomeStore (servers/unread-summary, channels/unread):
    • A 503 or stale answer keeps the shown row counts, server badges and app badge, using the existing task ci(android): use stable signing identity for Alpha APKs #831 freeze.
    • A stale 200 only fills a Home that has no live counts yet.
    • Cold start, server switch and sign-in keep the restored last-known row hints on screen (rowHintsRetained) instead of rows with no count.
    • Per-server Activity counts are kept during an RW outage. Any other unavailable summary still makes them unknown, as before.
    • Sets SharedHomeState.unreadStale.
    • The followed-thread reconcile read (channels/unread) leaves the thread marks when the answer is stale or overloaded.
  • SharedActivityStore (channels/inbox): a stale page never replaces shown rows. An overload keeps them, attaches the typed error and sets stale. With no cache, an overload stays the existing load-failed state with Retry, never "No activity". A stale page with no cache fills the list and marks it stale.
  • ProcessActivityUnreadStore (Activity badge): a stale summary never replaces a known count; freshness becomes Stale.
  • Followed threads (channels/threads/followed): the repository tags stale and overload answers. The channel page's unread-thread pill releases ids it could not resolve during an outage, so the next trigger asks again (one request per trigger).
  • Recovery (RaftStaleReadRecovery, same schedule as web #11156 rwStaleCounts.ts):
    • The first read waits for the server's Retry-After (seconds or HTTP-date; 5 s without one). After that the waits are 5, 10, 20, 40 and 60 s, each with ±20 % jitter, never past 60 s.
    • Reads run only while the app is in the foreground; a timer that ends in the background waits. The process visibility comes from updateRaftProcessNetworkVisibility, which both hosts already call.
    • The app returning to the foreground, or the socket rejoining its rooms (rooms:joined), reads at once.
    • Each read goes through the store's single-flight load at Background priority. It is skipped while the surface is hidden (Home re-reads when shown; entering Activity always refreshes) and while a load is already in flight (that load's answer settles recovery).
    • A fresh answer clears the flag and the backoff.
    • The inbox keeps its single 2 s overload retry. Followed threads (getFollowedThreads / getUnreadFollowedThreads) now get the same single retry, and the unread-thread pill's lookup recovers on the backoff above.
  • Retention (RaftLastCountsPersistStorage, web lastCounts.ts limits): applies to the Home last-known row counts and previews and to the Activity first pages.
    • A copy older than 7 days is not shown.
    • At most 5 servers per user are kept; the least recently saved server goes first, with all its keys. Other users' copies are never touched.
    • Sign-out clears the whole family. The Activity first pages were not cleared on sign-out before; they are now.
    • The Activity badge cache gets the same 7-day and 5-server limits; it was already cleared on sign-out.
    • The Home identity cache (channel names, no counts) is unchanged.
  • Cold start: the cache is shown first, with no notice. Only a server answer (a stale header or a 503) raises the notice, so a normal open never flashes it. The flag is never persisted.
  • UI (stdrc O2): an info-tone RaftPanelNotice with the pending spinner, directly under the head. On Activity it sits above the filters; on phone Home, above the list. Counts are unchanged, with no fading or marker. It is a polite live region, so it is announced once, and it leaves when the flag clears.
  • Design system: RaftPanelNotice(pending = true) puts the spinner in the icon's place, as RaftBanner(pending) does (raft-ui 0.22 T-wrapper: canonical fact codec + SQLite binding (RFC 043 §9.9, V1 shadow persistence) #709). It has a golden case in banner_{light,dark} and a README row.

Copy

Taken exactly from web PR botiverse/slock #11156 (head da23419e), keys thread.staleNotice and layout.sidebar.staleCountsNotice, as used by StaleCountsNotice:

  • en: "Activity may be out of date. It'll update on its own when Raft catches up." / "Unread counts may be out of date. They'll update on their own when Raft catches up."
  • zh-CN: 「动态可能不是最新的,Raft 恢复后会自动更新。」 / 「未读数可能不是最新的,Raft 恢复后会自动更新。」
  • ja: 「アクティビティが最新でない可能性があります。Raft が追いつくと自動で更新されます。」 / 「未読数が最新でない可能性があります。Raft が追いつくと自動で更新されます。」

Design-system rules checked

  • Uses the design-system component (RaftPanelNotice); the missing pending variant was added to compose/designsystem with goldens, not styled in page code.
  • Info tone (info-soft), never yellow or warning: waiting is not a problem. The spinner is decorative (cleared semantics) and the text says what waits.
  • PanelNotice placement: a full-width strip directly under the panel head, no radius, line-seam below, 16dp phone edges.
  • Dark mode: the colours are theme tokens only, with no literals. Both light and dark goldens were captured and reviewed.
  • Strings go through MR.strings in en, zh-CN and ja. check_string_edge_spaces is clean.
  • Numbers are not dimmed or marked (stdrc rejected O1 and O3).

Goldens

  • compose/shared/src/androidUnitTest/screenshots/activity.rw-stale-notice{,.dark}.png
  • compose/shared/src/androidUnitTest/screenshots/home.rw-stale-notice{,.dark}.png
  • compose/designsystem/screenshots/banner_{light,dark}.png (adds the pending PanelNotice case)

Tests (all run locally on Linux, green)

  • Whole :shared:testDebugUnitTest: 8,700 tests, 0 failures. :designsystem:desktopTest: goldens verified.
  • RaftStaleReadRecoveryTest (5):
    • the delay schedule (Retry-After first, then 5/10/20/40/60 s, the jitter bounds, the 60 s cap);
    • Retry-After as seconds or an HTTP-date;
    • a timer that ends in the background waits, and the return to the foreground reads at once;
    • a socket reconnect reads at once without doubling a running read;
    • a fresh answer stops everything;
    • each stale answer arms the next step.
  • RaftLastCountsRetentionTest (4):
    • 7-day expiry;
    • 5 servers per user, least recently saved first, other users untouched;
    • sign-out clears the family, including copies listed in the old index format;
    • the Activity badge cache's limits.
  • SharedHomeStoreRwStaleTest (8):
    • channel unread 503 and server summary 503 each keep the counts and set stale;
    • a stale header keeps the counts;
    • a fresh load clears stale through one Background recovery load;
    • stale and overload become typed facts;
    • cold start with a cache plus 503 shows the last-known counts as stale;
    • cold start shows the cache with no notice until the server answers;
    • no cache plus 503 leaves the counts unknown, not zero.
  • SharedActivityStoreRwStaleTest (8): the same matrix on the real RealInboxRepository, plus no reads while hidden and no notice before the server answers.
  • FollowedThreadsRwStaleTest (3).
  • SharedThreadUnreadThreadJumpTest (+1): the pill recovers on the backoff until a fresh answer.
  • ProcessActivityUnreadStoreTest (+1).
  • Not run locally: the iOS compile (Linux host). The full test.yml scope covers it.

Review notes

  • Realtime / catch-up: there are no event-mapping or catch-up changes. Two realtime-adjacent hunks changed, and Tenny's review is requested for both:
    1. SharedHomeStore.refreshFollowedThreadUnread, the channels/unread read that a thread:updated reply triggers, now skips applying a stale or overloaded answer.
    2. RaftSharedStoreProvider's onRoomsJoined, under the current-grant check, now also calls RaftStaleReadRecoverySignals.onRealtimeRoomsJoined() to nudge recovery.
  • rw_slow (soft timeout, at most 60 s old) also raises the notice and keeps the shown data, as the task specifies and as the web does.
  • Differences from the web: the mobile notice is raised only by the read warehouse (a stale header or a 503 rw_overloaded). The web also raises it for any other 5xx or no network; on mobile those keep their existing error handling. Mobile has no desktop sidebar notice.
  • RealInboxRepository still issues every inbox read at Visible priority, including background refreshes; that is pre-existing and not changed here.
  • No changeset: no target version is assigned.

🤖 Generated with Claude Code

Lagoon and others added 2 commits October 24, 2026 12:05
… while the read warehouse is behind (task #164)

During the 10-24 RW restart, servers/unread-summary, channels/unread,
channels/inbox and channels/threads/followed answered 503 rw_overloaded
or the server's remembered body (X-Raft-Stale). A fresh open, server
switch or sign-in could show blank or zero.

- Shared layer: RaftStaleRead.kt reads X-Raft-Stale and the 503
  {code:"rw_overloaded"} body (botiverse/slock f6ace2b9) and surfaces
  them as typed facts: staleReason on InboxSnapshot / ThreadListSnapshot /
  ActivityUnreadSummarySnapshot, ChannelUnreadSummary.stale,
  HomeNetworkPayload.unreadStale, ScreenError.serverCode = rw_overloaded.
  A stale page is always Content, never Empty.
- SharedHomeStore: a stale or overloaded unread read keeps the shown
  counts (task #831 freeze), keeps the cold-start last-known row hints
  on screen, and sets SharedHomeState.unreadStale. A stale followed-thread
  reconcile leaves the thread marks.
- SharedActivityStore: a stale page never replaces shown rows; an overload
  keeps them and attaches the typed error; SharedActivityState.stale.
  With no cache, an overload stays the load-failed state with Retry.
- ProcessActivityUnreadStore: a stale summary never replaces a known count.
- Unread-thread pill: ids an overloaded/stale list could not resolve may
  be asked again by the next trigger.
- Recovery: one background-priority read per surface with backoff
  (15 s doubling to 5 min, +/-20 %), skipped while hidden or in flight;
  a fresh answer clears the flag.
- UI (stdrc O2): info PanelNotice with spinner under the Activity head
  and the phone Home head; counts unchanged. RaftPanelNotice gains
  `pending` (as RaftBanner's) with goldens.

Signed-off-by: Lagoon <lagoon@agents.stamp.build>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ule, retention (task #164)

Aligns with botiverse/slock #11156 (head da23419e, StaleCountsNotice):
- Copy: the notice strings are the web's own en / zh-CN / ja
  (thread.staleNotice, layout.sidebar.staleCountsNotice); the drafted
  en/ja copy is gone.
- Recovery: the first read waits for the server's Retry-After (seconds or
  HTTP-date, 5 s without one), then 5, 10, 20, 40 and 60 s with +/-20 %
  jitter, never past 60 s. It reads only in the foreground; a timer that
  ends in the background waits. The app returning to the foreground
  (updateRaftProcessNetworkVisibility) or the socket rejoining its rooms
  (rooms:joined) reads at once.
- Followed threads: getFollowedThreads / getUnreadFollowedThreads get the
  inbox's single Retry-After overload retry, and the channel page's
  unread-thread lookup retries on the same backoff until a fresh answer.
- Retention: the last-known copies (Home row counts and previews,
  Activity first pages) share RaftLastCountsPersistStorage: nothing older
  than 7 days is shown, at most 5 servers per user are kept (least
  recently saved first, other users untouched), and sign-out clears the
  whole family. The Activity first pages were not cleared on sign-out
  before. The Activity badge cache gets the same 7-day and 5-server limits
  (it was already cleared on sign-out).
- Cold start: the cache shows without the notice; only a server answer
  raises it (already so; now pinned by tests).

Signed-off-by: Lagoon <lagoon@agents.stamp.build>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@stamp-build
stamp-build Bot merged commit 5b860fc into main Oct 24, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants