mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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((
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user