Files
buzz/crates/buzz-pubsub/src/presence.rs
Will Pfleger 0808ab485c fix(cli): enrich template cardinality error with per-candidate presence and profile hints (#4825)
## Problem

`buzz channels create --template` fails with a bare-pubkey
duplicate-instance error when a persona has more than one live instance.
When duplicate instances share the same display name and avatar, the
error gives the operator no signal to identify which instance is stale.

## What changed

**`crates/buzz-cli/src/commands/channels.rs`** (boundary) + one
visibility line in **`crates/buzz-cli/src/commands/users.rs`** (`fn
presence_subject` → `pub(crate)`), plus a relay behavior change in
**`crates/buzz-relay/src/api/bridge.rs`** and a supporting test in
**`crates/buzz-pubsub/src/presence.rs`**.

### Relay: presence-lookup failure is now surfaced as a failure

`synthesize_presence` previously collapsed a Redis `get_presence_bulk`
error into `unwrap_or_default()` → HTTP 200 `[]`, making a backend
outage indistinguishable from an authoritative all-offline snapshot. It
now returns `Option<Result<Vec<Value>, (StatusCode, Json<Value>)>>`:
`None` means the request is not a presence query (fall through),
`Some(Ok)` is a real snapshot (an empty vec is an authoritative
all-offline result), and `Some(Err)` is a backend failure surfaced as a
non-2xx response. The event tag-parse and sign paths were converted from
`.ok()?` fall-throughs to explicit `Some(Err(...))` for the same reason
— a silent fall-through would have reintroduced the fake-empty-success
anti-pattern. The function takes `pubsub` and `relay_keypair` directly
rather than the whole `AppState`, which keeps the Redis-error seam
unit-testable without live infrastructure; its single production caller
was updated accordingly, and no other caller consumes
`synthesize_presence`.

### Hint fetch architecture

**`assemble_roster_resolution<F, Fut>`** — post-fetch stage extracted
from `build_roster_resolution` as an injectable-fetcher async generic.
It contains: archive-filter-based duplicate detection, the conditional
`fetch_hints` call, and `finalize_roster_resolution` delegation.
Accepting the fetcher as a closure makes the wiring directly testable
without a relay.

`build_roster_resolution` reduces to: gather slugs → `tokio::join!(scan,
archive)` → delegate with the real `fetch_candidate_hints` closure.

**Zero hint queries on the happy path**: `assemble_roster_resolution`
only invokes the fetcher when duplicate live instances exist after
archive filtering. Untrusted archive snapshot (`Err`) conservatively
treats all found instances as live.

When duplicates exist, `fetch_candidate_hints` runs the presence
(kind:40902) and profile (kind:0) queries concurrently, bounding **each
independently** through `join_bounded_queries`: a per-query 3-second
timeout maps to `Err`, so a lookup that completes is never discarded
because its sibling hung. On total failure or timeout, bare pubkeys are
printed promptly.

### Trusted-snapshot boundary — `hints_from_results`

The relay drops the Redis presence key when an identity goes offline, so
the bulk presence snapshot never returns an event for an offline
instance — precisely the stale duplicate an operator needs flagged.
`hints_from_results` therefore treats a *trusted* presence snapshot as a
**complete snapshot** for the requested pubkeys: it seeds all of them
`offline`, then overlays returned statuses.

A response is trusted only when `trusted_presence_snapshot` validates
**every** array element as the actual snapshot contract: each must parse
as a complete signed `nostr::Event` of the presence-update kind carrying
exactly one `p` tag whose subject is one of the requested pubkeys.
Requiring the *sole* `p`-tag subject — the exact value `build_hint_map`
later reads via `presence_subject` — is what stops a mixed-tag event
(`[["p","<unrequested>"],["p","<requested>"]]`) from passing the gate
yet overlaying a different subject downstream. Any element that is not
such an event — a vacuous object like
`{"pubkey":"…","content":"online"}`, `[{}]`, `[null]`, an event of the
wrong kind, one for an unrequested subject, or one carrying more than
one `p` tag — makes presence enrichment untrusted: nothing is seeded
`offline`, so absence is never falsely inferred as offline. A failed,
timed-out, or non-array presence response is likewise untrusted. In
every untrusted case the successful profile sibling still contributes
its hints.

### Response-to-map conversion — `build_hint_map`

Sync production function taking the offline seed plus raw
presence/profile event slices. Relay-signed presence events carry the
agent pubkey in the `p` tag (not the event author); `build_hint_map`
calls `presence_subject` from `users.rs` (now `pub(crate)`) to resolve
the correct key.

### Hint display — `format_candidate`

Appends `[online/offline, profile updated YYYY-MM-DD]` (or subset) to
each candidate pubkey in the error. `profile_updated_at` names the field
correctly: kind:0 is replaceable state that desktop republishes on
rename and profile reconciliation, so `created_at` reflects the last
profile update, not provisioning time. Date formatted via
`chrono::DateTime::from_timestamp`; out-of-range timestamps omit the
date. Missing entries fall back to bare pubkeys.

### Cardinality rule

`apply_cardinality_rule` remains pure and relay-free; zero/one/many
semantics unchanged.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Carl <9d00794d3df50972eb8b615511783cab12a77a8fd5dd5edd58073ec73b54bd8b@buzz.block.builderlab.xyz>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: npub1g8493u0xfsjrvflg4n08ezd7vec99mnwzlv0qgwpr9d7gvjwhuzqx59rhw <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-08-27 14:40:53 -04:00

236 lines
7.4 KiB
Rust

//! Presence tracking — online/away status with TTL.
//!
//! Stored as `SET buzz:{community}:presence:{pubkey_hex} "online" EX 180`.
//! TTL is 3x the 60s heartbeat interval so a single missed heartbeat doesn't
//! cause presence flap. Clean disconnect deletes immediately.
use buzz_core::TenantContext;
use deadpool_redis::Pool;
use nostr::PublicKey;
use std::collections::HashMap;
use crate::error::PubSubError;
use crate::topic::BUZZ_PREFIX;
/// 3x the 60s heartbeat — single missed heartbeat won't cause presence flap.
pub const PRESENCE_TTL_SECS: u64 = 180;
/// Returns the Redis key for the presence entry of `pubkey` under `ctx`.
pub fn presence_key(ctx: &TenantContext, pubkey: &PublicKey) -> String {
format!(
"{BUZZ_PREFIX}:{}:presence:{}",
ctx.community(),
pubkey.to_hex()
)
}
/// Sets presence status for `pubkey` with a [`PRESENCE_TTL_SECS`]-second TTL.
pub async fn set_presence(
pool: &Pool,
ctx: &TenantContext,
pubkey: &PublicKey,
status: &str,
) -> Result<(), PubSubError> {
let mut conn = pool.get().await?;
let key = presence_key(ctx, pubkey);
redis::cmd("SET")
.arg(&key)
.arg(status)
.arg("EX")
.arg(PRESENCE_TTL_SECS)
.query_async::<()>(&mut conn)
.await?;
Ok(())
}
/// Removes the presence entry for `pubkey`. Call on clean disconnect.
pub async fn clear_presence(
pool: &Pool,
ctx: &TenantContext,
pubkey: &PublicKey,
) -> Result<(), PubSubError> {
let mut conn = pool.get().await?;
let key = presence_key(ctx, pubkey);
redis::cmd("DEL")
.arg(&key)
.query_async::<()>(&mut conn)
.await?;
Ok(())
}
/// Returns the current presence status for `pubkey`, or `None` if not set or expired.
pub async fn get_presence(
pool: &Pool,
ctx: &TenantContext,
pubkey: &PublicKey,
) -> Result<Option<String>, PubSubError> {
let mut conn = pool.get().await?;
let key = presence_key(ctx, pubkey);
let value: Option<String> = redis::cmd("GET").arg(&key).query_async(&mut conn).await?;
Ok(value)
}
/// Returns `pubkey_hex → status` for all currently-set keys.
pub async fn get_presence_bulk(
pool: &Pool,
ctx: &TenantContext,
pubkeys: &[PublicKey],
) -> Result<HashMap<String, String>, PubSubError> {
if pubkeys.is_empty() {
return Ok(HashMap::new());
}
let mut conn = pool.get().await?;
let keys: Vec<String> = pubkeys
.iter()
.map(|pubkey| presence_key(ctx, pubkey))
.collect();
let values: Vec<Option<String>> = redis::cmd("MGET").arg(&keys).query_async(&mut conn).await?;
let result = pubkeys
.iter()
.zip(values.iter())
.filter_map(|(pk, v)| v.as_ref().map(|s| (pk.to_hex(), s.clone())))
.collect();
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_util::make_test_pool;
use buzz_core::{CommunityId, TenantContext};
use nostr::Keys;
use uuid::Uuid;
fn make_pubkey() -> PublicKey {
Keys::generate().public_key()
}
fn ctx(id: u128, host: &str) -> TenantContext {
TenantContext::resolved(CommunityId::from_uuid(Uuid::from_u128(id)), host)
}
#[tokio::test]
async fn get_presence_bulk_surfaces_connection_failure_as_error() {
// A backend outage must surface as `Err`, not a silently-empty `Ok`.
// `synthesize_presence` relies on this to return an error response
// rather than a fake-empty "all offline" snapshot on a Redis failure.
// Pool points at a closed port so the connection attempt fails.
let pool = deadpool_redis::Config::from_url("redis://127.0.0.1:1")
.create_pool(Some(deadpool_redis::Runtime::Tokio1))
.expect("pool builds lazily");
let ctx = ctx(0xaaaa, "a.example");
let pubkey = make_pubkey();
let result = get_presence_bulk(&pool, &ctx, &[pubkey]).await;
assert!(
result.is_err(),
"a connection failure must surface as Err, got {result:?}"
);
}
#[test]
fn presence_ttl_is_three_one_minute_heartbeat_windows() {
assert_eq!(PRESENCE_TTL_SECS, 180);
assert_eq!(PRESENCE_TTL_SECS, 3 * 60);
}
#[test]
fn test_presence_key_format() {
let pubkey = make_pubkey();
let ctx = ctx(0xaaaa, "a.example");
let key = presence_key(&ctx, &pubkey);
let prefix = format!("buzz:{}:presence:", ctx.community());
assert!(key.starts_with(&prefix));
let hex_part = key.strip_prefix(&prefix).unwrap();
assert_eq!(hex_part.len(), 64);
assert!(hex_part.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn same_pubkey_in_two_communities_has_different_presence_keys() {
let pubkey = make_pubkey();
let community_a = ctx(0xaaaa, "a.example");
let community_b = ctx(0xbbbb, "b.example");
assert_ne!(
presence_key(&community_a, &pubkey),
presence_key(&community_b, &pubkey)
);
}
#[tokio::test]
#[ignore = "requires Redis"]
async fn test_presence_set_and_get() {
let pool = make_test_pool();
let pubkey = make_pubkey();
let ctx = ctx(0xaaaa, "a.example");
let status = get_presence(&pool, &ctx, &pubkey).await.unwrap();
assert!(status.is_none());
set_presence(&pool, &ctx, &pubkey, "online").await.unwrap();
let status = get_presence(&pool, &ctx, &pubkey).await.unwrap();
assert_eq!(status.as_deref(), Some("online"));
set_presence(&pool, &ctx, &pubkey, "away").await.unwrap();
let status = get_presence(&pool, &ctx, &pubkey).await.unwrap();
assert_eq!(status.as_deref(), Some("away"));
clear_presence(&pool, &ctx, &pubkey).await.unwrap();
let status = get_presence(&pool, &ctx, &pubkey).await.unwrap();
assert!(status.is_none());
}
#[tokio::test]
#[ignore = "requires Redis"]
async fn test_presence_bulk() {
let pool = make_test_pool();
let pk1 = make_pubkey();
let pk2 = make_pubkey();
let pk3 = make_pubkey();
let ctx = ctx(0xaaaa, "a.example");
set_presence(&pool, &ctx, &pk1, "online").await.unwrap();
set_presence(&pool, &ctx, &pk2, "away").await.unwrap();
let result = get_presence_bulk(&pool, &ctx, &[pk1, pk2, pk3])
.await
.unwrap();
assert_eq!(
result.get(&pk1.to_hex()).map(|s| s.as_str()),
Some("online")
);
assert_eq!(result.get(&pk2.to_hex()).map(|s| s.as_str()), Some("away"));
assert!(!result.contains_key(&pk3.to_hex()));
clear_presence(&pool, &ctx, &pk1).await.unwrap();
clear_presence(&pool, &ctx, &pk2).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Redis"]
async fn test_presence_ttl() {
let pool = make_test_pool();
let pubkey = make_pubkey();
let ctx = ctx(0xaaaa, "a.example");
set_presence(&pool, &ctx, &pubkey, "online").await.unwrap();
let mut conn = pool.get().await.unwrap();
let ttl: i64 = redis::cmd("TTL")
.arg(presence_key(&ctx, &pubkey))
.query_async(&mut conn)
.await
.unwrap();
assert!(
ttl > 0 && ttl <= PRESENCE_TTL_SECS as i64,
"TTL should be 1-{PRESENCE_TTL_SECS}s, got {ttl}"
);
clear_presence(&pool, &ctx, &pubkey).await.unwrap();
}
}