Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
```
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion crates/buzz-pubsub/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 10 additions & 4 deletions crates/buzz-pubsub/src/presence.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions desktop/src/features/presence/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions desktop/src/features/presence/lib/presence.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,25 @@ import {
mergePresenceUpdate,
parseLivePresenceEvent,
presenceQueryWantsPubkey,
PRESENCE_HEARTBEAT_INTERVAL_MS,
PRESENCE_IDLE_TIMEOUT_MS,
PRESENCE_TTL_SECONDS,
resolveAutomaticPresenceStatus,
} from "./presence.ts";

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");
Expand Down
6 changes: 6 additions & 0 deletions desktop/src/features/presence/lib/presence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading