mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
fix(read-state): share the write deadline with the row refresh
Leave messages without resolvable ancestry out of the unread scan in SQL instead of a Rust fact, refresh POST rows within the remaining intent deadline, and document the thread-row cap and frontier anchor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Honey <8e307ae0076a4dab6b94b036ea3edc7e08f823a625269c1e6919e881a048b4d2@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -75,7 +75,8 @@ pub struct ChannelReadSummary {
|
||||
/// Unread top-level messages directed at the actor: every one in a DM,
|
||||
/// otherwise those that tag the actor with `p` or carry `broadcast=1`.
|
||||
pub mentions: u32,
|
||||
/// The message the channel timeline was last marked through, if any.
|
||||
/// The anchor of the timeline's frontier: the marked message that arrived
|
||||
/// last. None until the timeline is marked.
|
||||
pub read_through_id: Option<String>,
|
||||
/// Last eligible top-level message to arrive, whatever its author or read
|
||||
/// progress: marking the timeline through it reads the timeline.
|
||||
@@ -91,9 +92,11 @@ pub struct ThreadReadSummary {
|
||||
pub root_id: String,
|
||||
/// Whether an unread reply that counts was found.
|
||||
pub unread: bool,
|
||||
/// Unread replies that count. Every one is directed at the actor.
|
||||
/// Unread replies that count: directed at the actor, or in one of the
|
||||
/// actor's conversations.
|
||||
pub mentions: u32,
|
||||
/// The message the thread was last marked through, if any.
|
||||
/// The anchor of the thread's frontier: the marked message that arrived
|
||||
/// last. None until the thread is marked.
|
||||
pub read_through_id: Option<String>,
|
||||
/// Last counted reply to arrive: marking through it reads the thread.
|
||||
pub latest_id: String,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Bounded evidence projection. The cap limits evidence, not the definition of
|
||||
//! unread: an unexamined tail yields a positive lower bound or an unknown count.
|
||||
//! unread: an unexamined tail goes uncounted, so counts may undercount.
|
||||
|
||||
use super::{model::*, participation, writes};
|
||||
use buzz_core::CommunityId;
|
||||
@@ -133,8 +133,9 @@ impl Db {
|
||||
), threaded AS MATERIALIZED (
|
||||
SELECT e.*, tm.root_event_id AS root, tm.parent_event_id AS parent,
|
||||
COALESCE(tm.root_event_id<>e.id,false) AS is_reply,
|
||||
-- A NIP-10 reply marker without canonical ancestry: never a
|
||||
-- timeline anchor, since a mark cannot validate it.
|
||||
-- A NIP-10 reply marker without canonical ancestry: neither
|
||||
-- counted nor a timeline anchor, since a mark cannot validate
|
||||
-- it. Parity-tested against the shared NIP-10 parser.
|
||||
tm.root_event_id IS NULL AND jsonb_typeof(e.tags)='array'
|
||||
AND EXISTS (SELECT 1 FROM jsonb_array_elements(e.tags) t(tag)
|
||||
WHERE jsonb_typeof(tag)='array' AND tag->>0='e'
|
||||
@@ -155,16 +156,14 @@ impl Db {
|
||||
FROM threaded e
|
||||
LEFT JOIN personal_read_frontiers tf ON tf.community_id=$1 AND tf.actor=$2
|
||||
AND tf.channel_id=r.id AND tf.root_id=e.root AND e.is_reply
|
||||
WHERE e.pubkey<>$2
|
||||
WHERE e.pubkey<>$2 AND NOT e.unresolved
|
||||
), grouped AS (
|
||||
SELECT CASE WHEN root IS NULL THEN NULL
|
||||
WHEN is_reply THEN encode(root,'hex') ELSE '' END AS root,
|
||||
CASE WHEN is_reply THEN encode(parent,'hex') END AS parent,
|
||||
is_reply, covered, read_through_id,
|
||||
-- ->>0 also selects scalar "p"/"e": reject nonarrays first.
|
||||
-- C collation matches Rust's ASCII case/hex rules. Reply
|
||||
-- markers matter only without canonical ancestry; otherwise
|
||||
-- metadata, not tag spelling, owns the context.
|
||||
-- C collation matches Rust's ASCII case rules.
|
||||
CASE WHEN octet_length(tags::text)<=8192
|
||||
AND jsonb_typeof(tags)='array' THEN
|
||||
(SELECT CASE WHEN bool_or(jsonb_typeof(tag)<>'array'
|
||||
@@ -172,10 +171,7 @@ impl Db {
|
||||
THEN NULL ELSE jsonb_build_object(
|
||||
'directed',COALESCE(bool_or(
|
||||
(tag->>0='p' AND lower((tag->>1) COLLATE "C")=encode($2,'hex'))
|
||||
OR (tag->>0='broadcast' AND tag->>1='1')),false),
|
||||
'reply_marked',root IS NULL AND COALESCE(bool_or(tag->>0='e'
|
||||
AND tag->>3='reply'
|
||||
AND (tag->>1) COLLATE "C" ~ '^[0123456789abcdefABCDEF]{64}$'),false)) END
|
||||
OR (tag->>0='broadcast' AND tag->>1='1')),false)) END
|
||||
FROM jsonb_array_elements(tags) t(tag)
|
||||
WHERE tag->>0 IN ('p','broadcast','e')) ELSE NULL END AS facts,
|
||||
count(*) AS n,
|
||||
@@ -227,11 +223,6 @@ impl Db {
|
||||
let Some(facts) = e["facts"].as_object() else {
|
||||
continue;
|
||||
};
|
||||
// SQL's bounded ancestry fact is parity-tested against the shared
|
||||
// NIP-10 parser; no raw tag payload crosses the DB boundary.
|
||||
if facts.get("reply_marked") == Some(&Value::Bool(true)) && e["root"].is_null() {
|
||||
continue;
|
||||
}
|
||||
if e["covered"] == true {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -158,6 +158,12 @@ async fn sidebar_ancestry_fact_matches_shared_nip10_parser() {
|
||||
.unwrap();
|
||||
// A reply marker without recorded ancestry leaves the message out.
|
||||
assert_eq!(page.channels[0].unread, !reply, "tags={tags:?}");
|
||||
// ...and it is never the timeline's anchor.
|
||||
assert_eq!(
|
||||
page.channels[0].latest_id.is_some(),
|
||||
!reply,
|
||||
"tags={tags:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -174,12 +174,10 @@ pub(super) async fn write(
|
||||
outcomes.push(outcome);
|
||||
}
|
||||
let mut body = json!({ "outcomes": outcomes });
|
||||
// Committed outcomes stand even when their rows cannot be read: omit them.
|
||||
if let Ok(Ok(channels)) = tokio::time::timeout(
|
||||
Duration::from_secs(8),
|
||||
refreshed(&state, &headers, &principal, &applied),
|
||||
)
|
||||
.await
|
||||
// Committed outcomes stand even when their rows cannot be read in the
|
||||
// remaining budget: omit them.
|
||||
if let Ok(Ok(channels)) =
|
||||
tokio::time::timeout_at(deadline, refreshed(&state, &headers, &principal, &applied)).await
|
||||
{
|
||||
body["channels"] = json!(channels);
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ marking one never moves another.
|
||||
|---|---|---|
|
||||
| `unread` | an unread top-level message counts | an unread reply counts |
|
||||
| `mentions` | unread top-level messages directed at you | unread replies that count |
|
||||
| `read_through_id` | the message the timeline was last marked through, or null | the message the thread was last marked through, or null |
|
||||
| `read_through_id` | the anchor of the timeline's frontier, or null | the anchor of the thread's frontier, or null |
|
||||
| `latest_id` | the last eligible top-level message to arrive, or null | the last counted unread reply to arrive |
|
||||
|
||||
A thread reply never makes the channel row unread and never becomes its
|
||||
@@ -89,8 +89,10 @@ 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. A thread row's `unread` is
|
||||
always true in this version. No message bytes are included.
|
||||
first by author time (then `root_id`), at most 5. Threads past the fifth 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.
|
||||
|
||||
A message is eligible when it is non-own, nondeleted, of the advertised
|
||||
`eligible_kinds` and inside the horizon. The same kinds alone define latest
|
||||
@@ -146,8 +148,8 @@ request order. An ambiguous timeout/storage failure returns
|
||||
later failures. `channels` holds the updated sidebar row of each distinct
|
||||
channel with an applied intent, in the sidebar's row shape and ordered by ID;
|
||||
a channel you have not joined has no row. Replace those rows rather than
|
||||
re-deriving them. `channels` is omitted when the rows could not be read after
|
||||
the writes committed: refresh them with `?channel_ids=`. Retry the same
|
||||
re-deriving them. `channels` is omitted when the rows could not be read within
|
||||
the request's deadline after the writes committed: refresh them with `?channel_ids=`. Retry the same
|
||||
operands, never substitute latest. Keep pending intent durably on the client
|
||||
until its outcome is resolved.
|
||||
|
||||
@@ -186,9 +188,9 @@ microsecond resolution. Three limits follow, none of which strands a badge:
|
||||
|
||||
A channel's `latest_id` is the last top-level message to arrive among those the
|
||||
unread count examined: the 4,096 most recent events by author time inside the
|
||||
horizon. So marking through it reads every top-level message counted. When the
|
||||
horizon holds no top-level message, it is the last to arrive among the
|
||||
channel's 256 most recent events.
|
||||
horizon. So marking through it reads every top-level message counted. When
|
||||
that scan holds no top-level message, it is the last top-level message to
|
||||
arrive among the channel's 256 most recent events.
|
||||
|
||||
There is no import of earlier client read state. The relay records the
|
||||
account's `started_at` at its first applied read intent and never moves it.
|
||||
@@ -211,8 +213,8 @@ device-local.
|
||||
500 ms savepoint budget. The lookup is exact, so its work grows with the
|
||||
replies under each parent. Past either bound a reply is undecided and left
|
||||
out.
|
||||
- DB statement/lock deadlines and HTTP read deadlines bound work; writes use a
|
||||
shared eight-second intent-processing deadline after admission. Limits are
|
||||
- DB statement/lock deadlines and HTTP read deadlines bound work; a write's intents
|
||||
and its row refresh share one eight-second deadline after admission. Limits are
|
||||
containment, not a production capacity claim.
|
||||
|
||||
Apply migrations 0056 through 0058 (or the equivalent desired schema). 0056
|
||||
|
||||
Reference in New Issue
Block a user