Skip to content
644 changes: 644 additions & 0 deletions crates/buzz-db/src/channel.rs

Large diffs are not rendered by default.

410 changes: 400 additions & 10 deletions crates/buzz-db/src/lib.rs

Large diffs are not rendered by default.

75 changes: 66 additions & 9 deletions crates/buzz-db/src/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,20 @@ pub async fn run_migrations(pool: &PgPool) -> Result<()> {
.await
}

#[cfg(test)]
pub(crate) async fn run_migrations_through(pool: &PgPool, target: i64) -> Result<()> {
with_exclusive_schema_destruction_lock(pool, |mut conn| async move {
let outcome = async {
reject_legacy_nip_rs_cardinality_ambiguity(&mut conn).await?;
MIGRATOR.run_to(target, &mut conn).await?;
Ok(())
}
.await;
(conn, outcome)
})
.await
}

async fn run_migrations_locked(conn: &mut PgConnection) -> Result<()> {
reject_legacy_nip_rs_cardinality_ambiguity(conn).await?;
MIGRATOR.run(&mut *conn).await?;
Expand All @@ -43,6 +57,7 @@ async fn run_migrations_locked(conn: &mut PgConnection) -> Result<()> {
// guard, so migration fails closed if any is missing. (The fence probe
// re-runs this same check at startup on non-migrating relays.)
crate::replica_fence::verify_floor_guard_catalog(&mut *conn).await?;
crate::channel::verify_channel_roster_fence_catalog(&mut *conn).await?;
Ok(())
}

Expand Down Expand Up @@ -625,7 +640,7 @@ mod tests {
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
migrations.sort_by_key(|migration| migration.version);

assert_eq!(migrations.len(), 31);
assert_eq!(migrations.len(), 32);
assert_eq!(migrations[0].version, 1);
assert_eq!(&*migrations[0].description, "initial schema");
assert!(migrations[0]
Expand Down Expand Up @@ -1036,6 +1051,36 @@ mod tests {
assert_eq!(migrations[29].version, 30);
let deletion_recovery = migrations[29].sql.as_str();
assert!(deletion_recovery.contains("SET LOCAL lock_timeout = '5s'"));

// Mixed-version channel-roster fence: old canonical replacement writers
// acquire their replacement key before INSERT; this trigger then takes
// the membership key and validates the exact active pubkey/role p-tag set.
assert_eq!(migrations[31].version, 32);
let roster_fence = migrations[31].sql.as_str();
assert!(roster_fence.contains("CREATE TRIGGER trg_events_guard_channel_roster_snapshot"));
assert!(roster_fence.contains("NEW.kind <> 39002"));
assert!(roster_fence.contains("'buzz_channel_membership:'"));
assert!(roster_fence.contains("cm.removed_at IS NULL"));
assert!(roster_fence.contains("cm.role::text"));
assert!(roster_fence.contains("jsonb_array_length(roster_tag.tag_json) <> 4"));
assert!(roster_fence.contains("roster_tag.tag_json->>3"));
assert!(roster_fence.contains("snapshot_members IS DISTINCT FROM canonical_members"));
assert!(roster_fence.contains("ERRCODE = '23514'"));

// Fresh desired-state bootstrap must install the identical executable
// fence as migration 0032. CI and isolated relay startup use schema.sql
// without running migrations, so drift reopens rolling-deploy races.
fn extract_roster_fence(sql: &str) -> &str {
let fence_start = "CREATE OR REPLACE FUNCTION guard_channel_roster_snapshot()";
let fence_end = " FOR EACH ROW EXECUTE FUNCTION guard_channel_roster_snapshot();";
let start = sql.find(fence_start).expect("roster fence function");
let relative_end = sql[start..].find(fence_end).expect("roster fence trigger");
&sql[start..start + relative_end + fence_end.len()]
}
assert_eq!(
extract_roster_fence(roster_fence),
extract_roster_fence(desired_schema)
);
}

#[test]
Expand Down Expand Up @@ -1224,6 +1269,7 @@ mod tests {
// Build the needles so this test's own source never matches them.
let migrate_macro = ["sqlx", "::migrate!"].concat();
let migrator_run = ["MIGRATOR", ".run("].concat();
let migrator_run_to = ["MIGRATOR", ".run_to("].concat();

let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let this_file = manifest_dir.join("src/migration.rs");
Expand All @@ -1250,23 +1296,24 @@ mod tests {
rust_sources(crates_dir, &mut files);
for path in &files {
let source = std::fs::read_to_string(path).expect("read rust source");
let (macro_hits, run_hits) = (
let (macro_hits, run_hits, run_to_hits) = (
count(&source, &migrate_macro),
count(&source, &migrator_run),
count(&source, &migrator_run_to),
);
if *path == this_file {
assert_eq!(
(macro_hits, run_hits),
(1, 1),
"migration.rs must embed the migrator once and run it exactly once, \
inside the locked wrapper"
(macro_hits, run_hits, run_to_hits),
(1, 1, 1),
"migration.rs must embed the migrator once, run it once in production, \
and expose exactly one test-only bounded run"
);
} else if *path == push_gateway_exception {
continue;
} else {
assert_eq!(
(macro_hits, run_hits),
(0, 0),
(macro_hits, run_hits, run_to_hits),
(0, 0, 0),
"{} embeds or runs a SQLx migrator outside the schema/destruction \
lock contract; route migration execution through \
buzz_db migration::run_migrations",
Expand All @@ -1289,13 +1336,23 @@ mod tests {
.find("async fn with_exclusive_schema_destruction_lock")
.expect("exclusive lock wrapper");
let run_site = source.find(&migrator_run).expect("migrator run site");
let run_to_site = source
.find(&migrator_run_to)
.expect("bounded test migrator run site");
assert!(
source[entry..locked].contains("with_exclusive_schema_destruction_lock("),
"run_migrations must delegate through the exclusive schema/destruction lock"
);
assert!(
run_site > locked && run_site < wrapper,
"the migrator run site must live inside run_migrations_locked"
"the production migrator run site must live inside run_migrations_locked"
);
assert!(
run_to_site > entry
&& run_to_site < locked
&& source[entry..run_to_site].contains("#[cfg(test)]")
&& source[entry..run_to_site].contains("with_exclusive_schema_destruction_lock("),
"the bounded migrator run must remain test-only and use the exclusive lock wrapper"
);
assert!(
source[wrapper..].contains("pg_advisory_lock($1)")
Expand Down
133 changes: 122 additions & 11 deletions crates/buzz-relay/src/handlers/side_effects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1049,6 +1049,55 @@ fn group_members_tags(group_id: &str, members: &[MemberRecord]) -> anyhow::Resul
Ok(tags)
}

async fn store_group_members_event(
tenant: &TenantContext,
state: &Arc<AppState>,
channel_id: Uuid,
member_snapshot: &mut buzz_db::channel::LockedMemberSnapshot,
) -> anyhow::Result<Option<buzz_core::StoredEvent>> {
let group_id = channel_id.to_string();
let tags = group_members_tags(&group_id, &member_snapshot.members)?;
let relay_pubkey = state.relay_keypair.public_key().to_bytes();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let ts = member_snapshot
.latest_member_event_timestamp(tenant.community(), channel_id, &relay_pubkey)
.await?
.map(|timestamp| timestamp + 1)
.unwrap_or(now)
.max(now);
let event = EventBuilder::new(Kind::Custom(KIND_NIP29_GROUP_MEMBERS as u16), "")
.tags(tags)
.custom_created_at(nostr::Timestamp::from(ts))
.sign_with_keys(&state.relay_keypair)
.map_err(|error| anyhow::anyhow!("failed to sign member snapshot: {error}"))?;
let (stored, inserted) = member_snapshot
.replace_member_event(tenant.community(), channel_id, &event)
.await?;
Ok(inserted.then_some(stored))
}

async fn dispatch_group_members_event(
tenant: &TenantContext,
state: &Arc<AppState>,
stored: Option<buzz_core::StoredEvent>,
relay_pubkey_hex: &str,
) {
if let Some(stored) = stored {
dispatch_persistent_event(
tenant,
state,
&stored,
KIND_NIP29_GROUP_MEMBERS,
relay_pubkey_hex,
None,
)
.await;
}
}

/// Emit NIP-29 group discovery events (39000, 39001, 39002) signed by the relay keypair.
/// Called after group creation, metadata changes, or membership changes.
/// Events are stored channel-scoped (`channel_id = Some(...)`) so that existing
Expand Down Expand Up @@ -1151,18 +1200,18 @@ pub async fn emit_group_discovery_events(
.await?;
}

{
let tags = group_members_tags(&group_id, &members)?;
emit_addressable_discovery_event(
tenant,
state,
channel_id,
KIND_NIP29_GROUP_MEMBERS,
tags,
&relay_pubkey_hex,
)
// Re-capture membership behind the writer lock immediately before the
// authoritative 39002 replacement. Metadata/admin snapshots retain their
// existing behavior; only membership publication needs this freshness fence.
let relay_pubkey = state.relay_keypair.public_key().to_bytes();
let mut member_snapshot = state
.db
.lock_member_snapshot(tenant.community(), channel_id, &relay_pubkey)
.await?;
}
let stored_members =
store_group_members_event(tenant, state, channel_id, &mut member_snapshot).await?;
member_snapshot.release().await?;
dispatch_group_members_event(tenant, state, stored_members, &relay_pubkey_hex).await;

Ok(())
}
Expand Down Expand Up @@ -3052,6 +3101,68 @@ pub async fn publish_nip43_member_removed(
publish_nip43_delta(tenant, state, 8001, target_pubkey_hex, "member-removed").await
}

/// Repair legacy kind:39002 snapshots truncated by the former 1,000-member
/// database cap.
///
/// The scan is deliberately limited to canonical rosters above that boundary,
/// so normal-sized channels and already-correct large snapshots incur no
/// rewrites. Community identity travels with every candidate; a shared relay
/// never resolves a channel against a neighboring tenant.
pub async fn reconcile_large_channel_member_snapshots(
state: &Arc<AppState>,
) -> anyhow::Result<usize> {
const LEGACY_ROSTER_LIMIT: i64 = 1_000;

let relay_pubkey = state.relay_keypair.public_key();
let candidates = state
.db
.list_large_channel_rosters_needing_reconciliation(
LEGACY_ROSTER_LIMIT,
&relay_pubkey.to_bytes(),
)
.await?;
let relay_pubkey_hex = relay_pubkey.to_hex();
let mut reconciled = 0usize;

for candidate in candidates {
let result = async {
let channel_id = candidate.channel_id;
// Hold the membership-writer lock from roster capture through
// replacement. Otherwise a rolling deployment can publish stale
// roster A after another relay commits and publishes roster B.
let mut member_snapshot = state
.db
.lock_member_snapshot(candidate.community_id, channel_id, &relay_pubkey.to_bytes())
.await?;
let tenant = TenantContext::resolved(candidate.community_id, candidate.host.clone());
let stored_members =
store_group_members_event(&tenant, state, channel_id, &mut member_snapshot).await?;
member_snapshot.release().await?;
dispatch_group_members_event(&tenant, state, stored_members, &relay_pubkey_hex).await;
Ok::<bool, anyhow::Error>(true)
}
.await;

match result {
Ok(true) => reconciled += 1,
Ok(false) => {}
Err(error) => {
metrics::counter!("buzz_channel_roster_reconciliation_failures_total").increment(1);
warn!(
community_id = %candidate.community_id,
host = %candidate.host,
channel_id = %candidate.channel_id,
%error,
"large channel roster reconciliation failed"
);
}
}
}

metrics::counter!("buzz_channel_roster_reconciliations_total").increment(reconciled as u64);
Ok(reconciled)
}

/// Reconcile channels that exist in the DB but don't have kind:39000 events.
///
/// This handles the case where channels were created via direct SQL inserts
Expand Down
25 changes: 25 additions & 0 deletions crates/buzz-relay/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,31 @@ async fn main() -> anyhow::Result<()> {
);
}

match state.db.verify_channel_roster_fence().await {
Ok(()) => {
info!("Channel roster fence verified");
}
Err(error) => {
error!(%error, "Channel roster fence validation failed");
return Err(anyhow::anyhow!(
"Channel roster fence is unsafe; apply or repair migration 0032 before starting this relay: {error}"
));
}
}

// Repair legacy NIP-29 channel rosters that were persisted while the
// canonical member query still truncated at 1,000 rows. Validation above
// makes migration 0032 a code/schema compatibility gate before the new
// replacement protocol or listener can serve traffic.
match buzz_relay::handlers::side_effects::reconcile_large_channel_member_snapshots(&state).await
{
Ok(count) if count > 0 => info!(count, "large channel member snapshots repaired"),
Ok(_) => {}
Err(error) => {
tracing::warn!(%error, "large channel member snapshot startup reconciliation failed")
}
}

// NIP-43: reconcile the event-backed roster for every provisioned
// community before opening the listener. `relay_members` is canonical;
// this repairs pre-snapshot communities and any publication that failed
Expand Down
2 changes: 2 additions & 0 deletions deploy/charts/buzz/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,8 @@ default so long-lived WebSocket connections have time to drain.

Schema migrations are embedded in the relay binary via `sqlx::migrate!` and run at startup, gated by `BUZZ_AUTO_MIGRATE` (default `true`). Multiple replicas race-safely behind a Postgres advisory lock. `helm upgrade` is the entire upgrade procedure.

Migration 0032 is a hard compatibility boundary for relay versions that publish repaired channel rosters. The relay verifies the roster-fence trigger catalog and behavior before opening listeners and refuses to start if 0032 is missing or inert. Apply migrations before rolling the relay; for large installations, prefer a controlled `buzz-admin migrate` job with PostgreSQL lock monitoring before the code rollout.

If you prefer decoupling migrations from serving, set `migrate.autoMigrate=false`. **In that mode the chart does not run migrations for you** — you own running `buzz-admin migrate` (separate Pod / one-shot Job) against the database before every `helm install` / `helm upgrade`. Readiness probes only verify DB connectivity, not schema freshness, so a pod will appear healthy against an unmigrated schema and fail under load. A pre-upgrade Helm Job for this is on the chart roadmap; the values knob `migrate.preUpgradeJob.enabled` is reserved.

## Backups
Expand Down
Loading
Loading