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:
Honey
2026-10-08 12:51:01 -07:00
parent daa45ec517
commit 509bf6c6a3
5 changed files with 35 additions and 35 deletions

View File

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

View File

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

View File

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

View File

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

View File

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