Skip to content
Open
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
89 changes: 86 additions & 3 deletions crates/buzz-db/src/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -683,7 +683,12 @@ pub async fn membership_pairs(
.collect()
}

/// Returns all active members of the given channel.
/// Returns all active members of the given channel, ordered by `joined_at`.
///
/// The roster is returned in full and is never truncated: callers use it to
/// build the kind 39002 (NIP-29 group members) snapshot and to resolve actor
/// roles for admin-event authorization, so a partial list silently hides late
/// joiners from channel discovery and makes them read as non-members.
///
/// Returns an empty list if the channel has been soft-deleted.
pub async fn get_members(
Expand All @@ -698,7 +703,6 @@ pub async fn get_members(
JOIN channels c ON cm.community_id = c.community_id AND cm.channel_id = c.id AND c.deleted_at IS NULL
WHERE cm.community_id = $1 AND cm.channel_id = $2 AND cm.removed_at IS NULL
ORDER BY cm.joined_at ASC
LIMIT 1000
"#,
)
.bind(community_id.as_uuid())
Expand Down Expand Up @@ -1527,7 +1531,7 @@ mod tests {
use crate::user::{ensure_user, set_agent_owner};
use nostr::Keys;

const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz";
const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials

async fn setup_pool() -> PgPool {
PgPool::connect(TEST_DB_URL)
Expand Down Expand Up @@ -1928,6 +1932,85 @@ mod tests {
assert_eq!(channel_ids.len(), channel_count as usize);
}

/// `get_members` must return the complete roster, not a truncated prefix.
///
/// The relay builds the kind 39002 (NIP-29 group members) snapshot and every
/// admin role lookup from this list, so a cap silently hides late joiners:
/// their clients never discover the channel, and an owner past the cutoff
/// reads as a non-member.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn get_members_returns_full_roster_beyond_1000() {
let database_url =
std::env::var("BUZZ_TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.to_string());
let pool = PgPool::connect(&database_url)
.await
.expect("connect to test DB");
let community_id = make_test_community(&pool).await;
let community = CommunityId::from_uuid(community_id);
let creator = random_pubkey();

// create_test_channel also inserts the creator as the first (owner) member.
let channel = create_test_channel(
&pool,
community_id,
"high-volume-roster",
ChannelType::Stream,
ChannelVisibility::Open,
None,
&creator,
None,
)
.await
.expect("create test channel");

// Bulk-insert additional members with strictly increasing `joined_at`, so
// member N lands at roster position N (the creator holds position 0).
// The final member is an owner joining well past the old 1000-row cutoff.
let extra_members = 1_500;
sqlx::query(
r#"
INSERT INTO channel_members (community_id, channel_id, pubkey, role, joined_at)
SELECT
$1,
$2,
decode(lpad(to_hex(n), 64, '0'), 'hex'),
(CASE WHEN n = $3 THEN 'owner' ELSE 'member' END)::member_role,
NOW() + (n || ' seconds')::interval
FROM generate_series(1, $3) n
"#,
)
.bind(community_id)
.bind(channel.id)
.bind(extra_members)
.execute(&pool)
.await
.expect("insert high-volume channel members");

let members = get_members(&pool, community, channel.id)
.await
.expect("load channel members");

assert_eq!(
members.len(),
extra_members as usize + 1,
"get_members truncated the roster"
);

// The last joiner sits at the final roster position — past any
// 1000-row cap — which also pins the documented `joined_at` ordering.
let late_owner = hex::decode(format!("{:064x}", extra_members)).expect("hex pubkey");
let late = members.last().expect("roster is non-empty");
assert_eq!(
late.pubkey, late_owner,
"member who joined after the 1000th must be present and ordered last"
);
assert_eq!(
late.role, "owner",
"role of a late-joining owner must resolve correctly"
);
}

/// A random non-admin, non-owner user cannot remove someone else's bot.
#[tokio::test]
#[ignore = "requires Postgres"]
Expand Down
36 changes: 36 additions & 0 deletions crates/buzz-db/src/feed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -830,4 +830,40 @@ mod tests {
let unique: std::collections::HashSet<Vec<u8>> = byte_seqs.into_iter().collect();
assert_eq!(unique.len(), 5, "all channel IDs must be distinct");
}

/// `insert_mentions` must index every p-tag even past Postgres's
/// bind-parameter statement cap.
///
/// Relay-signed kind 39002 member snapshots carry one p-tag per channel
/// member, and a multi-row INSERT binds 6 parameters per row — a single
/// statement tops out at ~10.9k rows against the 65,535-parameter limit.
/// Clients discover their channels via `{kinds:[39002], "#p":[me]}`, so a
/// failed insert silently breaks discovery for the whole channel.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn insert_mentions_indexes_rosters_past_bind_parameter_cap() {
let pool = setup_pool().await;
let community = CommunityId::from_uuid(make_test_community(&pool).await);
let channel = insert_test_channel(&pool, community).await;

// 11,000 rows x 6 binds = 66,000 > 65,535: overflows a single statement.
let mention_count = 11_000usize;
let tags: Vec<Tag> = (1..=mention_count)
.map(|n| Tag::parse(["p", &format!("{n:064x}")]).expect("p tag"))
.collect();
let event = store_feed_event(&pool, community, 39002, "", Some(channel), tags).await;

let indexed: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM event_mentions WHERE community_id = $1 AND event_id = $2",
)
.bind(community.as_uuid())
.bind(event.id.as_bytes().as_slice())
.fetch_one(&pool)
.await
.expect("count indexed mentions");
assert_eq!(
indexed as usize, mention_count,
"every roster p-tag must land in event_mentions"
);
}
}
47 changes: 29 additions & 18 deletions crates/buzz-db/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,24 +145,35 @@ pub async fn insert_mentions(
return Ok(());
}

// Single multi-row INSERT ... ON CONFLICT DO NOTHING — one round-trip regardless of mention count.
let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new(
"INSERT INTO event_mentions \
(community_id, pubkey_hex, event_id, event_created_at, channel_id, event_kind) ",
);

qb.push_values(&valid_pubkeys, |mut b, pubkey| {
b.push_bind(community_id.as_uuid())
.push_bind(pubkey.as_str())
.push_bind(event_id_bytes.as_slice())
.push_bind(created_at)
.push_bind(channel_id)
.push_bind(kind as i32);
});

qb.push(" ON CONFLICT DO NOTHING");

qb.build().execute(pool).await?;
// Multi-row INSERT ... ON CONFLICT DO NOTHING, chunked to stay under
// Postgres's 65,535 bind-parameter statement cap (6 binds per row caps a
// single statement at ~10.9k rows). Relay-signed kind 39002 rosters carry
// one p-tag per channel member and can exceed that. All chunks run in one
// transaction: callers treat mention indexing as fire-and-forget (failures
// are logged, the event is already stored, and nothing retries), so a
// partially committed index would be permanent.
const MENTION_INSERT_CHUNK_ROWS: usize = 5_000;
let mut tx = pool.begin().await?;
for chunk in valid_pubkeys.chunks(MENTION_INSERT_CHUNK_ROWS) {
let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new(
"INSERT INTO event_mentions \
(community_id, pubkey_hex, event_id, event_created_at, channel_id, event_kind) ",
);

qb.push_values(chunk, |mut b, pubkey| {
b.push_bind(community_id.as_uuid())
.push_bind(pubkey.as_str())
.push_bind(event_id_bytes.as_slice())
.push_bind(created_at)
.push_bind(channel_id)
.push_bind(kind as i32);
});

qb.push(" ON CONFLICT DO NOTHING");

qb.build().execute(&mut *tx).await?;
}
tx.commit().await?;
Ok(())
}

Expand Down
Loading