diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 90cbbac0cf..5c8e263a2a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -447,7 +447,7 @@ The subscriber uses a **dedicated** `redis::aio::PubSub` connection — not from **Reconnection:** exponential backoff 1s → 30s (`backoff_secs * 2`). Backoff resets to 1s only after a clean stream end, not on each reconnect attempt. -**Presence:** `SET buzz:presence:{pubkey_hex} {status} EX 90` — 90-second TTL (3× the 30-second heartbeat interval). Single missed heartbeat does not cause presence flap. +**Presence:** `SET buzz:presence:{pubkey_hex} {status} EX 180` — 180-second TTL (3× the 60-second heartbeat interval). Single missed heartbeat does not cause presence flap. **Typing indicators:** ``` @@ -797,7 +797,7 @@ Docker Compose provides the full local development stack. All services include h | Pattern | Type | TTL | Purpose | |---------|------|-----|---------| | `buzz:channel:{uuid}` | Pub/Sub channel | — | Event fan-out (single-community form; shared multi-community Redis must use `buzz:{community}:channel:{uuid}` or equivalent) | -| `buzz:presence:{pubkey_hex}` | String | 90s | Online/away status (single-community form; shared multi-community Redis must scope by community) | +| `buzz:presence:{pubkey_hex}` | String | 180s | Online/away status (single-community form; shared multi-community Redis must scope by community) | | `buzz:typing:{channel_uuid}` | Sorted Set | 60s | Active typers (5s window; shared multi-community Redis must scope by community) | ### Full-Text Search (Postgres FTS) diff --git a/crates/buzz-pubsub/src/lib.rs b/crates/buzz-pubsub/src/lib.rs index eae8c5ef9e..4f1690beef 100644 --- a/crates/buzz-pubsub/src/lib.rs +++ b/crates/buzz-pubsub/src/lib.rs @@ -328,7 +328,7 @@ impl PubSubManager { publisher::publish_event(&self.pool, ctx, topic, event).await } - /// Set presence with 60s TTL. Call on connect and every 30s heartbeat. + /// Set presence with 180s TTL. Call on connect and every 60s heartbeat. pub async fn set_presence( &self, ctx: &TenantContext, diff --git a/crates/buzz-pubsub/src/presence.rs b/crates/buzz-pubsub/src/presence.rs index 178ba7550a..e0c9dfd6c9 100644 --- a/crates/buzz-pubsub/src/presence.rs +++ b/crates/buzz-pubsub/src/presence.rs @@ -1,7 +1,7 @@ //! Presence tracking — online/away status with TTL. //! -//! Stored as `SET buzz:{community}:presence:{pubkey_hex} "online" EX 90`. -//! TTL is 3x the 30s heartbeat interval so a single missed heartbeat doesn't +//! 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; @@ -12,8 +12,8 @@ use std::collections::HashMap; use crate::error::PubSubError; use crate::topic::BUZZ_PREFIX; -/// 3x the 30s heartbeat — single missed heartbeat won't cause presence flap. -pub const PRESENCE_TTL_SECS: u64 = 90; +/// 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 { @@ -109,6 +109,12 @@ mod tests { TenantContext::resolved(CommunityId::from_uuid(Uuid::from_u128(id)), host) } + #[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(); diff --git a/desktop/src/features/presence/hooks.ts b/desktop/src/features/presence/hooks.ts index 5ba8683693..04936241df 100644 --- a/desktop/src/features/presence/hooks.ts +++ b/desktop/src/features/presence/hooks.ts @@ -11,14 +11,14 @@ import { mergePresenceUpdate, parseLivePresenceEvent, presenceQueryWantsPubkey, + PRESENCE_HEARTBEAT_INTERVAL_MS, + PRESENCE_TTL_SECONDS, resolveAutomaticPresenceStatus, } from "@/features/presence/lib/presence"; import type { PresenceLookup, PresenceStatus } from "@/shared/api/types"; -const PRESENCE_HEARTBEAT_INTERVAL_MS = 30_000; const PRESENCE_STATUS_TICK_INTERVAL_MS = 30_000; const PRESENCE_ACTIVITY_THROTTLE_MS = 1_000; -const PRESENCE_TTL_SECONDS = 90; const PRESENCE_PREFERENCE_STORAGE_KEY = "buzz-presence-preference"; type PresencePreference = "auto" | "away" | "offline" | null; diff --git a/desktop/src/features/presence/lib/presence.test.mjs b/desktop/src/features/presence/lib/presence.test.mjs index c202dcb776..90a6ac524b 100644 --- a/desktop/src/features/presence/lib/presence.test.mjs +++ b/desktop/src/features/presence/lib/presence.test.mjs @@ -5,7 +5,9 @@ import { mergePresenceUpdate, parseLivePresenceEvent, presenceQueryWantsPubkey, + PRESENCE_HEARTBEAT_INTERVAL_MS, PRESENCE_IDLE_TIMEOUT_MS, + PRESENCE_TTL_SECONDS, resolveAutomaticPresenceStatus, } from "./presence.ts"; @@ -13,6 +15,15 @@ const WILL = "8e39cba681211b3782d0e4483e9343719b9b7be66515252da5491f26421896b1"; const OTHER = "44b8e82baaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +test("presence heartbeat is one minute with a three-window TTL", () => { + assert.equal(PRESENCE_HEARTBEAT_INTERVAL_MS, 60_000); + assert.equal(PRESENCE_TTL_SECONDS, 180); + assert.equal( + PRESENCE_TTL_SECONDS, + 3 * (PRESENCE_HEARTBEAT_INTERVAL_MS / 1000), + ); +}); + test("merge adds an absent pubkey going online (the core bug)", () => { const old = {}; const next = mergePresenceUpdate(old, WILL, "online"); diff --git a/desktop/src/features/presence/lib/presence.ts b/desktop/src/features/presence/lib/presence.ts index 34ac4fc353..5b1fdc21c5 100644 --- a/desktop/src/features/presence/lib/presence.ts +++ b/desktop/src/features/presence/lib/presence.ts @@ -36,6 +36,12 @@ export function mergePresenceUpdate( return { ...old, [pubkey]: status }; } +// Keep the local optimistic cache and relay expiry at three heartbeat windows. +// The relay owns the authoritative TTL; deploy its TTL increase before shipping +// a desktop build with a slower heartbeat. +export const PRESENCE_HEARTBEAT_INTERVAL_MS = 60_000; +export const PRESENCE_TTL_SECONDS = 3 * (PRESENCE_HEARTBEAT_INTERVAL_MS / 1000); + // Away means "human not at the machine" (Slack/Discord semantics), never // "Buzz is not the focused window". OS-wide idle is authoritative when the // platform exposes it; otherwise fall back to in-app activity.