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
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