From 88d2f6fcf45dda12bafc99064e3f25b37d531d26 Mon Sep 17 00:00:00 2001 From: Honey <8e307ae0076a4dab6b94b036ea3edc7e08f823a625269c1e6919e881a048b4d2@buzz.block.builderlab.xyz> Date: Fri, 9 Oct 2026 11:34:59 -0700 Subject: [PATCH] fix(read-state): list up to 25 threads per channel row Five thread rows hid a mention in the sixth unread thread. Raise the cap to 25, the most that keeps a POST's rows for MAX_INTENTS channels within the 1 MiB response limit (50 would be ~1.4 MB, a 503 that hides committed outcomes); a unit test pins that bound. The cap tests now follow the constant. The doc now says the v1 shape is stable while what the counts include may change pre-release, so clients replace rows with each response. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Honey <8e307ae0076a4dab6b94b036ea3edc7e08f823a625269c1e6919e881a048b4d2@buzz.block.builderlab.xyz> --- .../buzz-db/src/store/personal_read/model.rs | 5 ++- .../participation_postgres_tests.rs | 7 +-- .../personal_read/threads_postgres_tests.rs | 45 ++++++++----------- crates/buzz-relay/src/api/buzz_v1/auth.rs | 5 ++- crates/buzz-relay/src/api/buzz_v1/handlers.rs | 36 +++++++++++++++ docs/buzz-v1-read-state.md | 16 ++++--- 6 files changed, 76 insertions(+), 38 deletions(-) diff --git a/crates/buzz-db/src/store/personal_read/model.rs b/crates/buzz-db/src/store/personal_read/model.rs index 8fb8f71f99..448d2491cb 100644 --- a/crates/buzz-db/src/store/personal_read/model.rs +++ b/crates/buzz-db/src/store/personal_read/model.rs @@ -54,8 +54,9 @@ pub(super) struct ReadAccount { /// Maximum channel summaries in one sidebar page. pub const MAX_CHANNELS: usize = 20; -/// Maximum thread rows per channel row. -pub const MAX_THREAD_SUMMARIES: usize = 5; +/// Maximum thread rows per channel row. A POST can return rows for +/// `MAX_INTENTS` channels, and they must fit the 1 MiB response limit. +pub const MAX_THREAD_SUMMARIES: usize = 25; /// Latest-probe event budget per channel, before eligibility filtering. pub const MAX_CHANNEL_SCAN: usize = 256; /// Unread-window work budget per channel, before eligibility/ancestry joins. diff --git a/crates/buzz-db/src/store/personal_read/participation_postgres_tests.rs b/crates/buzz-db/src/store/personal_read/participation_postgres_tests.rs index fc1eb50b5c..5a3d3a0aca 100644 --- a/crates/buzz-db/src/store/personal_read/participation_postgres_tests.rs +++ b/crates/buzz-db/src/store/personal_read/participation_postgres_tests.rs @@ -433,11 +433,12 @@ async fn threads_are_capped_after_replies_that_do_not_count_are_removed() { let w = World::new().await; let c = w.channel().await; let old = w.now - 40 * DAY; - // The actor's thread has the oldest unread reply. Five unjoined threads - // are newer and would fill the list if the cap came first. + // The actor's thread has the oldest unread reply. A cap's worth of + // unjoined threads are newer and would fill the list if the cap came + // first. let mine = w.post(c, &w.actor, old, vec![]).await; let answer = w.reply(c, &w.peer, &mine, Some(&mine), w.now, vec![]).await; - for i in 1..=5 { + for i in 1..=MAX_THREAD_SUMMARIES as u64 { let root = w.post(c, &w.peer, old, vec![]).await; w.reply(c, &w.peer, &root, Some(&root), w.now + i, vec![]) .await; diff --git a/crates/buzz-db/src/store/personal_read/threads_postgres_tests.rs b/crates/buzz-db/src/store/personal_read/threads_postgres_tests.rs index d9ec61e042..2bb86aab8d 100644 --- a/crates/buzz-db/src/store/personal_read/threads_postgres_tests.rs +++ b/crates/buzz-db/src/store/personal_read/threads_postgres_tests.rs @@ -197,8 +197,10 @@ async fn thread_summaries_order_cap_and_anchor() { let (db, pool, community, channel, actor, fixture_root) = fixture().await; // After the fixture root, so marking the newest root covers every root. let base = fixture_root.created_at.as_secs() + 1; + // One more thread than the cap. + let n = MAX_THREAD_SUMMARIES as u64 + 1; let mut roots = Vec::new(); - for i in 0..6 { + for i in 0..n { let root = post(&db, community, channel, base + i, vec![]).await; join(&db, &pool, community, channel, &actor, &root).await; roots.push(root); @@ -211,11 +213,13 @@ async fn thread_summaries_order_cap_and_anchor() { channel_mark(channel, newest_root.id.to_hex()), ) .await; - // Threads 0 and 1 tie on newest reply time; thread 2 has one directed - // reply and two that arrive last, together. + // Threads 0 and 1 tie on newest reply time and the rest follow, newest + // first. Thread 2 has one directed reply and two that arrive last, + // together. + let offset = |i: u64| if i < 2 { n * 10 } else { (n - i) * 10 }; let mut anchors = Vec::new(); - for (i, root) in roots.iter().enumerate() { - let at = base + 100 + [50, 50, 40, 30, 20, 10][i]; + for (i, root) in (0..).zip(&roots) { + let at = base + 100 + offset(i); anchors.push(reply(&db, &pool, community, channel, root, at, vec![]).await); } reply( @@ -234,7 +238,7 @@ async fn thread_summaries_order_cap_and_anchor() { community, channel, &roots[2], - base + 140, + base + 100 + offset(2), vec![], ) .await; @@ -257,17 +261,10 @@ async fn thread_summaries_order_cap_and_anchor() { let order = |row: &ChannelReadSummary| -> Vec<_> { row.threads.iter().map(|t| t.root_id.clone()).collect() }; - // Six unread threads exceed the cap of five. - assert_eq!( - order(&row), - vec![ - tied[0].clone(), - tied[1].clone(), - roots[2].id.to_hex(), - roots[3].id.to_hex(), - roots[4].id.to_hex(), - ] - ); + let ids = |roots: &[nostr::Event]| roots.iter().map(|r| r.id.to_hex()).collect::>(); + let last = roots.len() - 1; + // The oldest unread thread is past the cap. + assert_eq!(order(&row), [tied.to_vec(), ids(&roots[2..last])].concat()); let third = &row.threads[2]; assert_eq!(third.mentions, 3); assert_eq!( @@ -276,7 +273,7 @@ async fn thread_summaries_order_cap_and_anchor() { "equal arrivals break toward the smaller ID" ); - // Reading one listed thread through its anchor lists the sixth. + // Reading one listed thread through its anchor lists the oldest. let first = row.threads[0].clone_target(channel); assert_eq!( apply(&db, community, &actor, first).await, @@ -285,16 +282,12 @@ async fn thread_summaries_order_cap_and_anchor() { let row = sidebar(&db, community, &actor).await; assert_eq!( order(&row), - vec![ - tied[1].clone(), - roots[2].id.to_hex(), - roots[3].id.to_hex(), - roots[4].id.to_hex(), - roots[5].id.to_hex(), - ] + [vec![tied[1].clone()], ids(&roots[2..])].concat() ); let mentions: Vec<_> = row.threads.iter().map(|t| t.mentions).collect(); - assert_eq!(mentions, [1, 3, 1, 1, 1]); + let mut expected = vec![1; MAX_THREAD_SUMMARIES]; + expected[1] = 3; + assert_eq!(mentions, expected); } impl ThreadReadSummary { diff --git a/crates/buzz-relay/src/api/buzz_v1/auth.rs b/crates/buzz-relay/src/api/buzz_v1/auth.rs index 08434a253d..dd65d3dad6 100644 --- a/crates/buzz-relay/src/api/buzz_v1/auth.rs +++ b/crates/buzz-relay/src/api/buzz_v1/auth.rs @@ -161,9 +161,12 @@ pub(super) async fn recheck( Ok(()) } +/// Largest serialized API response; a larger one is a 503. +pub(super) const MAX_RESPONSE_BYTES: usize = 1024 * 1024; + pub(super) fn response(value: impl serde::Serialize) -> Result { let bytes = serde_json::to_vec(&value).map_err(|_| Error::unavailable())?; - if bytes.len() > 1024 * 1024 { + if bytes.len() > MAX_RESPONSE_BYTES { return Err(Error::unavailable()); } Ok(( diff --git a/crates/buzz-relay/src/api/buzz_v1/handlers.rs b/crates/buzz-relay/src/api/buzz_v1/handlers.rs index fe2fc2d9f0..f3dbf09565 100644 --- a/crates/buzz-relay/src/api/buzz_v1/handlers.rs +++ b/crates/buzz-relay/src/api/buzz_v1/handlers.rs @@ -208,3 +208,39 @@ pub(super) async fn write_intent( Err(_) => json!({"status":"unknown","retryable":true}), } } + +#[cfg(test)] +mod tests { + use super::*; + use buzz_db::personal_read::{ThreadReadSummary, MAX_THREAD_SUMMARIES}; + + /// A POST that applied to `MAX_INTENTS` channels, each with a full thread + /// list, must still fit: an oversized body is a 503 that hides committed + /// outcomes. + #[test] + fn largest_write_response_fits_the_response_limit() { + let id = || Some("f".repeat(64)); + let channels: Vec<_> = (0..MAX_INTENTS) + .map(|_| ChannelReadSummary { + channel_id: Uuid::max(), + unread: true, + mentions: u32::MAX, + read_through_id: id(), + latest_id: id(), + threads: (0..MAX_THREAD_SUMMARIES) + .map(|_| ThreadReadSummary { + root_id: "f".repeat(64), + unread: true, + mentions: u32::MAX, + read_through_id: id(), + latest_id: "f".repeat(64), + }) + .collect(), + }) + .collect(); + let outcomes = vec![json!({"status":"unknown","retryable":true}); MAX_INTENTS]; + let body = json!({ "outcomes": outcomes, "channels": channels }); + let bytes = serde_json::to_vec(&body).unwrap().len(); + assert!(bytes <= auth::MAX_RESPONSE_BYTES, "{bytes} bytes"); + } +} diff --git a/docs/buzz-v1-read-state.md b/docs/buzz-v1-read-state.md index 4b4eb8bf57..f89e2e0774 100644 --- a/docs/buzz-v1-read-state.md +++ b/docs/buzz-v1-read-state.md @@ -89,7 +89,7 @@ bounded scans found no top-level message, not that the channel is empty. `read_through_id` may name a message that has since been deleted. `threads` lists threads with unread replies that count, newest unread reply -first by author time (then `root_id`), at most 5. Threads past the fifth are +first by author time (then `root_id`), at most 25. Threads past the 25th are omitted with their mentions, and nothing signals the omission: after marking the listed threads read, a refresh can list the next ones. A thread row's `unread` is always true in this version. No message bytes are included. @@ -201,7 +201,8 @@ device-local. ## Bounds and deployment -- 20 sidebar rows and 100 intents per request; 5 thread rows per channel. +- 20 sidebar rows and 100 intents per request; 25 thread rows per channel, so + a POST's rows for 100 channels fit the response limit. - 64 KiB write body; 1 MiB serialized API response. - 4096 raw events per channel inside the horizon, before eligibility. - The latest fallback probes 256 events; a long ineligible or reply-only tail @@ -232,10 +233,13 @@ local seed is not a DAU/concurrency or p95/p99 production acceptance result. ## Compatibility and extension rules -Within `/buzz/v1`, clients must ignore unknown response object fields. Existing -required fields, status variants and their meanings remain stable; additive -fields do not authorize silently changing `mentions` or frontier semantics. -Breaking changes require an explicitly negotiated contract or a new API version. +Within `/buzz/v1`, clients must ignore unknown response object fields. The +shape is stable: existing fields, their types, status variants and what a +`mark_through` does remain. What the counts include is not yet: which messages +are unread or directed, which threads are listed, and the bounds may change +while v1 is pre-release. A client therefore replaces its rows with each +response rather than reconciling them with its own counts. Breaking shape +changes require an explicitly negotiated contract or a new API version. Requests remain strict: send new parameters or intent types only after the relay advertises the corresponding capability. Missing optional data means unsupported or not requested, never an empty list, zero count or unchanged revision.