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 <noreply@anthropic.com>
Signed-off-by: Honey <8e307ae0076a4dab6b94b036ea3edc7e08f823a625269c1e6919e881a048b4d2@buzz.block.builderlab.xyz>
This commit is contained in:
Honey
2026-10-09 11:34:59 -07:00
parent 509bf6c6a3
commit 88d2f6fcf4
6 changed files with 76 additions and 38 deletions

View File

@@ -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.

View File

@@ -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;

View File

@@ -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::<Vec<_>>();
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 {

View File

@@ -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<Response, Error> {
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((

View File

@@ -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");
}
}

View File

@@ -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.